diff --git a/.gitignore b/.gitignore index e9e1e9b..bd818d5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.orig *.log *.pot +__pycache__ __pycache__/* .cache/* .*.swp diff --git a/pyproject.toml b/pyproject.toml index 89a5bed..1241191 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,4 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] # For smarter version schemes and other configuration options, # check out https://github.com/pypa/setuptools_scm -version_scheme = "no-guess-dev" +version_scheme = "no-guess-dev" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index a464900..53f4360 100644 --- a/setup.cfg +++ b/setup.cfg @@ -92,6 +92,8 @@ norecursedirs = build .tox testpaths = tests +asyncio_mode=auto +asyncio_default_fixture_loop_scope=session # Use pytest markers to select/deselect specific tests # markers = # slow: mark tests as slow (deselect with '-m "not slow"') diff --git a/src/meshctrl/__init__.py b/src/meshctrl/__init__.py index 4fb81a3..61dd710 100644 --- a/src/meshctrl/__init__.py +++ b/src/meshctrl/__init__.py @@ -14,3 +14,11 @@ except PackageNotFoundError: # pragma: no cover __version__ = "unknown" finally: del version, PackageNotFoundError + +from . import session +from . import constants +from . import shell +from . import tunnel +from . import util +from . import files +from . import exceptions \ No newline at end of file diff --git a/src/meshctrl/constants.py b/src/meshctrl/constants.py index 825ff4d..d6d3d02 100644 --- a/src/meshctrl/constants.py +++ b/src/meshctrl/constants.py @@ -41,7 +41,6 @@ class UserRights(enum.IntFlag): class MeshRights(enum.IntFlag): """ Bitwise flags for mesh rights - Pulls double duty as rights for a connected device """ #: Give user no rights norights = 0 @@ -81,6 +80,40 @@ class MeshRights(enum.IntFlag): #: All rights fullrights = 0xFFFFFFFF +@document_enum +class DeviceRights(enum.IntFlag): + """ + Bitwise flags for device rights + Piggy backs on rights for a mesh, but has differet "all" rights. + """ + #: Give user no rights + norights = 0 + #: Remote control access + remotecontrol = MeshRights.remotecontrol + #: Agent console access + agentconsole = MeshRights.agentconsole + serverfiles = MeshRights.serverfiles + #: Wake device from sleep + wakedevices = MeshRights.wakedevices + #: Add notes to the device/mesh + notes = MeshRights.notes + #: Only view the desktop; no control + desktopviewonly = MeshRights.desktopviewonly + #: No terminal access + noterminal = MeshRights.noterminal + #: No file access + nofiles = MeshRights.nofiles + #: No AMT access + noamt = MeshRights.noamt + limiteddesktop = MeshRights.limiteddesktop + limitedevents = MeshRights.limitedevents + chatnotify = MeshRights.chatnotify + uninstall = MeshRights.uninstall + #: Allow to send commands to the device + remotecommands = MeshRights.remotecommands + #: All rights + fullrights = remotecontrol|agentconsole|serverfiles|wakedevices|notes|chatnotify|uninstall|remotecommands + @document_enum class ConsentFlags(enum.IntFlag): none = 0 @@ -129,4 +162,130 @@ class Icon(enum.IntEnum): htpc = enum.auto() router = enum.auto() embedded = enum.auto() - virtual = enum.auto() \ No newline at end of file + virtual = enum.auto() + +@document_enum +class AgentType(enum.IntEnum): + """ + Which type of agent this is. Taken from meshcentral.js obj.meshAgentsArchitectureNumbers + """ + UNKNOWN = 0 + CONSOLE_WIN_X86_32 = 1 + CONSOLE_WIN_X86_64 = 2 + SERVICE_WIN_X86_32 = 3 + SERVICE_WIN_X86_64 = 4 + SERVICE_LINUX_X86_32 = 5 + SERVICE_LINUX_X86_64 = 6 + SERVICE_LINUX_MIPS = 7 + SERVICE_LINUX_XEN_X86_32 = 8 + SERVICE_LINUX_ARM5 = 9 + SERVICE_LINUX_ARM_PLUGPC = 10 + SERVICE_MACOS_X86_32 = 11 + SERVICE_ANDROID_X86_32 = 12 + SERVICE_ANDROID_POGOPLUG = 13 + SERVICE_ANDROID_APK = 14 + SERVICE_LINUX_POKY_x86_32 = 15 + SERVICE_MACOS_X86_64 = 16 + SERVICE_CHROMEOS = 17 + SERVICE_LINUX_POKY_x86_64 = 18 + SERVICE_LINUX_X86_32_NOKVM = 19 + SERVICE_LINUX_X86_64_NOKVM = 20 + CONSOLE_WIN_MINICORE_X86_32 = 21 + SERVICE_WIN_MINICORE_X86_32 = 22 + SERVICE_NODEJS = 23 + SERVICE_LINUX_ARM_LINARO = 24 + SERVICE_LINUX_ARM_HARDFLOAT = 25 + SERVICE_LINUX_ARM64 = 26 + SERVICE_LINUX_ARM_HARDFLOAT_2 = 27 + SERVICE_LINUX_MIPS24KC = 28 + SERVICE_MACOS_ARM64 = 29 + SERVICE_FREEBSD_X86_64 = 30 + SERVICE_LINUX_ARM64_2 = 32 + SERVICE_OPENWRT_X86_64 = 33 + ASSISTANT_LINUX = 34 # This is labeled as "windows" in meshcentral.js, but its properties indicate it is for linux. + SERVICE_LINUX_ARMADA370_HARDFLOAT = 35 + SERVICE_OPENWRT_X86_64_2 = 36 + SERVICE_OPENBSD_X86_64 = 37 + SERVICE_LINUX_MIPSEL24KC = 40 + SERVICE_LINUX_CORTEX_A53 = 41 + CONSOLE_WIN_ARM64 = 42 + SERVICE_WIN_ARM64 = 43 + SERVICE_WIN_X86_32_UNSIGNED = 10003 + SERVICE_WIN_X86_64_UNSIGNED = 10004 + SERVICE_MACOS_UNIVERSAL_64 = 10005 + ASSISTANT_WINDOWS = 10006 + COMMAND_WIN_X86_32 = 11000 + COMMAND_WIN_X86_64 = 11001 + +@document_enum +class MeshType(enum.IntEnum): + """ + Which type of Mesh this is. + """ + #: AMT devices only + AMT = 1 + #: Controllable using an agent + AGENT = 2 + #: Control only local devices; no agent + LOCAL = 3 + + +@document_enum +class AgentCapabilities(enum.IntFlag): + """ + Flags of capabilities an agent can have. Taken from meshagent.h MeshCommand_AuthInfo_CapabilitiesMask from meshagent repo + """ + + #: Can control the desktop + DESKTOP = enum.auto() + #: Can use a terminal, or `~meshctrl.shell.Shell` in our case + TERMINAL = enum.auto() + #: Can use a files tunnel, or `~meshctrl.files.Files` in our case + FILES = enum.auto() + # ??? + CONSOLE = enum.auto() + #: Agent can use the javascript core. This should be set for any recent agents, older ones might not have it set + JAVASCRIPT = enum.auto() + #: Device was created in a temporary manner, and will be destroyed once it disconnects + TEMPORARY = enum.auto() + #: Agent is using the recovery core + RECOVERY = enum.auto() + #: Reserved for future use + RESERVED = enum.auto() + #: Agent can handle compressed streams (?) + COMPRESSION = enum.auto() + +@document_enum +class InteruserScope(enum.StrEnum): + """ + String constants used to determine the scope of a received :py:class:`~meshctrl.types.InteruserMessage` + """ + + #: The message was sent to your username + user = enum.auto() + + #: The message was sent to this specific session. + session = enum.auto() + +@document_enum +class Protocol(enum.IntEnum): + """ + Protocol to use for a tunnel. There are others, but these are what we implement. + """ + #: Terminal tunnel protocol + TERMINAL = 1 + #: File explorer tunnel protocol + FILES = 5 + +@document_enum +class FileType(enum.IntEnum): + """ + Type numbers used for file types on meshcentral agent. + """ + #: Root drive (Windows) + DRIVE = 1 + #: Directory + DIRECTORY = 2 + #: File + FILE = 3 + diff --git a/src/meshctrl/device.py b/src/meshctrl/device.py new file mode 100644 index 0000000..4031082 --- /dev/null +++ b/src/meshctrl/device.py @@ -0,0 +1,349 @@ +from . import constants +import datetime + +class Device(object): + ''' + Object to represent a device. This object is a rough wrapper; it is not guarunteed to be up to date with the state on the server, for instance. + + Args: + nodeid (str): id of the device on the server + session (~meshctrl.session.Session): Parent session used to run commands + agent (~meshctrl.types.Agent|dict|None): Information about the agent. Meshcentral returns this data in an unreadable way, so if the dict doesn't match :py:class:`~meshctrl.types.Agent`, we will attempt to convert to our format. + name (str|None): Device name as it is shown on the meshcentral server + description (str|None): Device description as it is shown on the meshcentral server. Also accepted as desc. + tags (list[str]|None): tags associated with device. + created_at (datetime.Datetime|int|None): Time at which device mas created. Also accepted as agct. + computer_name (str|None): Device name as reported from the agent. This may be different from name. Also accepted as rname. + icon (~meshctrl.constants.Icon): Icon displayed on the website + mesh (~meshctrl.mesh.Mesh|None): Mesh object under which this device exists. Is None for individual device access. + meshtype (~meshctrl.constants.MeshType|None): Type of mesh this device is connected to. Also accepted as mtype. + meshname (str|None): Name of the mesh to which this device is connected. Also accepted as groupname. + domain (str|None): Domain on server to which device is connected. + host (str): reachable hostname of device. Not meaningful for agent meshes. + ip (str): IP from which device connected. + connected: (bool): Whether the device is currently connected. Also accepted as conn. + powered_on (bool): Whether the device is currently powered on. Also accepted as pwr. + os_description (str|None): Description of the underlying OS. Also accepted as osdesc. + lastaddr (str|None): IP from which the agent most recently connected. This may be set even if ip is not. + lastconnect (datetime.Datetime|int|None): Last time at which the agent was connected to the server + links (dict[str, ~meshctrl.types.UserLink]|None): Collection of links for the device, + details (dict[str, dict]|None): Extra details about the device. These are not well defined, but are filled by calling :py:meth:`~meshctrl.session.Session.list_devices` with `details=True`. + + Returns: + :py:class:`Device`: Object representing a device on the meshcentral server. + + Attributes: + nodeid (str): id of the device on the server + agent (~meshctrl.types.Agent|dict|None): Information about the agent. Meshcentral returns this data in an unreadable way, so if the dict doesn't match :py:class:`~meshctrl.types.Agent`, we will attempt to convert to our format. + name (str|None): Device name as it is shown on the meshcentral server + description (str|None): Device description as it is shown on the meshcentral server. + tags (list[str]): tags associated with device. + computer_name (str|None): Device name as reported from the agent. This may be different from name. Also accepted as rname. + icon (~meshctrl.constants.Icon): Icon displayed on the website + mesh (~meshctrl.mesh.Mesh|None): Mesh object under which this device exists. Is None for individual device access. + meshtype (~meshctrl.constants.MeshType|None): Type of mesh this device is connected to. Also accepted as mtype. + meshname (str|None): Name of the mesh to which this device is connected. Also accepted as groupname. + domain (str|None): Domain on server to which device is connected. + host (str): reachable hostname of device. Not meaningful for agent meshes. + ip (str): IP from which device connected. + connected: (bool): Whether the device is currently connected. Also accepted as conn. + powered_on (bool): Whether the device is currently powered on. Also accepted as pwr. + os_description (str|None): Description of the underlying OS. Also accepted as osdesc. + lastaddr (str|None): IP from which the agent most recently connected. This may be set even if ip is not. + lastconnect (datetime.Datetime|None): Last time at which the agent was connected to the server + links (dict[str, ~meshctrl.types.UserLink]|None): Collection of links for the device + details (dict[str, dict]): Extra details about the device. These are not well defined, but are filled by calling :py:meth:`~meshctrl.session.Session.list_devices` with `details=True`. + ''' + def __init__(self, nodeid, session, agent=None, + name=None, desc=None, description=None, + tags=None, + agct=None, created_at=None, + rname=None, computer_name=None, icon=constants.Icon.desktop, + mesh=None, mtype=None, meshtype=None, groupname=None, meshname=None, + domain=None, host=None, ip=None, conn=None, connected=None, + pwr=None, powered_on=None, + osdesc=None, os_description=None, lastaddr=None, lastconnect=None, + links=None, details=None, **kwargs): + self.nodeid = nodeid + self._session = session + if links is None: + links = {} + self.links = links + if ("ver" in agent): + agent = { + "version": agent["ver"], + "id": agent["id"], + "capabilities": agent["caps"] + } + self.agent = agent + self.name = name + self.computer_name = computer_name if computer_name is not None else rname + self.icon = icon + self.mesh = mesh + self.meshtype = meshtype if meshtype is not None else mtype + self.meshname = meshname if meshname is not None else groupname + self.domain = domain + self.host = host + self.ip = ip + self.connected = bool(connected if connected is not None else conn) + self.powered_on = bool(powered_on if powered_on is not None else pwr) + self.description = description if description is not None else desc + self.os_description = os_description if os_description is not None else osdesc + self.tags = tags if tags is not None else [] + self.details = details if details is not None else {} + + created_at = created_at if created_at is not None else agct + if not isinstance(created_at, datetime.datetime) and created_at is not None: + try: + created_at = datetime.datetime.fromtimestamp(created_at) + except OSError: + # Meshcentral returns in miliseconds, while fromtimestamp, and most of python, expects the argument in seconds. Try seconds frist, then translate from ms if it fails. + # This doesn't work for really early timestamps, but I don't expect that to be a problem here. + created_at = datetime.datetime.fromtimestamp(created_at/1000.0) + + self.created_at = created_at + + if not isinstance(lastconnect, datetime.datetime) and lastconnect is not None: + try: + lastconnect = datetime.datetime.fromtimestamp(lastconnect) + except OSError: + # Meshcentral returns in miliseconds, while fromtimestamp, and most of python, expects the argument in seconds. Try seconds frist, then translate from ms if it fails. + # This doesn't work for really early timestamps, but I don't expect that to be a problem here. + lastconnect = datetime.datetime.fromtimestamp(lastconnect/1000.0) + + self.lastconnect = lastconnect + self.lastaddr = lastaddr + + # In case meshcentral gives us props we don't understand, store them here. + self._extra_props = kwargs + + async def add_users(self, userids, rights=None, timeout=None): + ''' + Add a user to an existing node + + Args: + userids (str|list[str]): Unique user id(s) + rights (~meshctrl.constants.DeviceRights): Bitwise mask for the rights on the given device + timeout (int): duration in milliseconds to wait for a response before throwing an error + + Returns: + bool: True on success, raise otherwise + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.add_users_to_device(userids, self.nodeid, domain=self.domain, rights=rights, timeout=timeout) + + async def remove_users(self, userids, timeout=None): + ''' + Remove users from an this node + + Args: + userids (str|list[str]): Unique user id(s) + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + bool: True on success, raise otherwise + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.remove_users_from_device(self.nodeid, userids, timeout=timeout) + + async def move_to_device_group(self, meshid, isname=False, timeout=None): + ''' + Move this device another device group + + Args: + meshid (str): Unique mesh id + isname (bool): treat "meshid" as a name instead of an id + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + bool: True on success, raise otherwise + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.remove_users_from_device(self.nodeid, meshid, isname=isname, timeout=timeout) + + async def info(self, timeout=None): + ''' + Get all info for this device. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. + + Args: + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + ~meshctrl.device.Device: Object representing the state of the device. This will be a new device, it will not update this device. + + Raises: + ValueError: `Invalid device id` if device is not found + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.device_info(self.nodeid, timeout=timeout) + + async def edit(self, name=None, description=None, tags=None, icon=None, consent=None, timeout=None): + ''' + Edit properties of this device + + Args: + name (str): New name for device + description (str): New description for device + tags (str|list[str]]): New tags for device + icon (~meshctrl.constants.Icon): New icon for device + consent (~meshctrl.constants.ConsentFlags): New consent flags for device + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + bool: True if successful, raise otherwise + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.edit_device(self.nodeid, name=name, description=description, tags=tags, icon=icon, consent=consent, timeout=timeout) + + async def run_command(self, command, powershell=False, runasuser=False, runasuseronly=False, ignore_output=False, timeout=None): + ''' + Run a command on this device. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. + + Args: + command (str): Command to run + powershell (bool): Use powershell to run command. Only available on Windows. + runasuser (bool): Attempt to run as a user instead of the root permissions given to the agent. Fall back to root if we cannot. + ignore_output (bool): Don't bother trying to get the output. Every device will return an empty string for its result. + runasuseronly (bool): Error if we cannot run the command as the logged in user. + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + ~meshctrl.types.RunCommandResponse: Output of command + + 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 + ''' + return (await self._session.run_command(self.nodeid, command, powershell=False, runasuser=False, runasuseronly=False, ignore_output=False, timeout=None))[self.nodeid] + + async def shell(self): + ''' + Get a terminal shell on this device + + Returns: + :py:class:`~meshctrl.shell.Shell`: Newly created :py:class:`~meshctrl.shell.Shell` + ''' + return await self._session.shell(self.nodeid) + + + async def smart_shell(self, regex): + ''' + Get a smart terminal shell on this device + + Args: + regex (regex): Regex to watch for to signify that the shell is ready for new input. + + Returns: + :py:class:`~meshctrl.shell.SmartShell`: Newly created :py:class:`~meshctrl.shell.SmartShell` + ''' + return await self._session.smart_shell(self.nodeid, regex) + + + async def wake(self, timeout=None): + ''' + Wake up this device + + Args: + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + bool: True if successful + + Raises: + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + + return await self._session.wake_devices(self.nodeid, timeout=timeout) + + async def reset(self, timeout=None): + ''' + Reset device + + Args: + nodeids (str|list[str]): Unique ids of nodes which to reset + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + bool: True if successful + + Raises: + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.reset_devices(self.nodeid, timeout=timeout) + + async def sleep(self, timeout=None): + ''' + Sleep device + + Args: + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + bool: True if successful + + Raises: + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.sleep_devices(self.nodeid, timeout=timeout) + + async def power_off(self, timeout=None): + ''' Power off device + + Args: + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + bool: True if successful + + Raises: + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.power_off_devices(self.nodeid, timeout=timeout) + + @property + def short_nodeid(self): + ''' + nodeid without "node/" or the included domain + ''' + return self.nodeid.split("/")[-1] + + @property + def id(self): + ''' + Alias to "nodeid" to be consistent accross types. + ''' + return self.nodeid + + def __str__(self): + return f"" + def __repr__(self): + return f"Device(nodeid={repr(self.nodeid)}, session={repr(self._session)}, name={repr(self.name)}, description={repr(self.description)}, computer_name={repr(self.computer_name)}, icon={repr(self.icon)}, "\ + f"mesh={repr(self.mesh)}, meshtype={repr(self.meshtype)}, meshname={repr(self.meshname)}, domain={repr(self.domain)}, host={repr(self.host)}, ip={repr(self.ip)}, "\ + f"tags={repr(self.tags)}, details={repr(self.details)} created_at={repr(self.created_at)} lastaddr={repr(self.lastaddr)} lastconnect={repr(self.lastconnect)} "\ + f"connected={repr(self.connected)}, powered_on={repr(self.powered_on)}, os_description={repr(self.os_description)}, links={repr(self.links)}, **{repr(self._extra_props)})" \ No newline at end of file diff --git a/src/meshctrl/exceptions.py b/src/meshctrl/exceptions.py index ba2aa97..eff0dfe 100644 --- a/src/meshctrl/exceptions.py +++ b/src/meshctrl/exceptions.py @@ -14,4 +14,23 @@ class SocketError(MeshCtrlError): """ Represents an error in the websocket """ + pass + +class FileTransferError(MeshCtrlError): + """ + Represents a failed file transfer + + Attributes: + stats (dict): {"result" (str): Human readable result, "size" (int): number of bytes successfully transferred} + initialized (asyncio.Event): Event marking if the Session initialization has finished. Wait on this to wait for a connection. + alive (bool): Whether the session connection is currently alive + closed (asyncio.Event): Event that occurs when the session closes permanently + """ + def __init__(self, message, stats): + self.stats = stats + +class FileTransferCancelled(FileTransferError): + """ + Represents a canceled file transfer + """ pass \ No newline at end of file diff --git a/src/meshctrl/files.py b/src/meshctrl/files.py index 53ef6d9..ffc659d 100644 --- a/src/meshctrl/files.py +++ b/src/meshctrl/files.py @@ -1,4 +1,308 @@ from . import tunnel +from . import constants +from . import exceptions +from . import util +import asyncio +import json class Files(tunnel.Tunnel): - pass \ No newline at end of file + def __init__(self, session, nodeid): + super().__init__(session, nodeid, constants.Protocol.FILES) + self.recorded = None + self._request_id = 0 + self._request_queue = asyncio.Queue() + self._download_finished = asyncio.Event() + self._download_finished.set() + self._current_request = None + self._handle_requests_task = asyncio.create_task(self._handle_requests()) + self._chunk_size = 65564 + + def _get_request_id(self): + self._request_id = (self._request_id+1)%(2**32-1) + return self._request_id + + async def close(self): + self._handle_requests_task.cancel() + try: + await self._handle_requests_task + except asyncio.CancelledError: + pass + await super().close() + + async def _handle_requests(self): + try: + while True: + request = await self._request_queue.get() + self._current_request = request + self._download_finished = request["finished"] + await self._message_queue.put(json.dumps(request["data"])) + await request["finished"].wait() + self._current_request = None + + except asyncio.CancelledError: + while True: + try: + request = self._request_queue.get_nowait() + request["error"] = exceptions.SocketError("Socket Closed") + request["errored"].set() + request["finished"].set() + except asyncio.QueueEmpty: + break + raise + + + @util._check_socket + async def _send_command(self, data, name, timeout=None): + request_id = f"meshctrl_{name}_{self._get_request_id()}" + request = {"id": request_id, "data": data, "return": None, "type": name, "finished": asyncio.Event(), "errored":asyncio.Event(), "error": None} + await self._request_queue.put(request) + + await asyncio.wait_for(request["finished"].wait(), timeout=timeout) + if request["error"] is not None: + raise request["error"] + return request["return"] + + async def ls(self, directory, timeout=None): + """ + Return a directory listing from the device + + Args: + directory (str): Path to the directory you wish to list + + Returns: + list[~meshctrl.types.FilesLSItem]: The directory listing + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error from server + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + """ + data = await self._send_command({"action": "ls", "path": directory}, "ls", timeout=timeout) + return data["dir"] + + async def _listen_for_pass(self, tasks): + async for event in self._session.events({"event": {"etype": "node", "action": "agentlog"}}): + if not event["event"]["msg"].startswith("Started"): + self._current_request["return"] = event["event"]["msg"] + self._current_request["finished"].set() + tasks[1].cancel() + break + + async def _listen_for_error(self, tasks): + async for event in self._session.events({"action":"msg", "type":"console"}): + self._current_request["error"] = exceptions.ServerError(event["value"]) + self._current_request["errored"].set() + self._current_request["finished"].set() + tasks[0].cancel() + break + + async def mkdir(self, directory, timeout=None): + """ + Create a directory on the device + + Args: + directory (str): Path of directory to create + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error from server + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + + Returns: + bool: True if directory was created + """ + tasks = [] + async with asyncio.TaskGroup() as tg: + tasks.append(tg.create_task(asyncio.wait_for(self._listen_for_pass(tasks), timeout))) + tasks.append(tg.create_task(asyncio.wait_for(self._listen_for_error(tasks), timeout))) + tasks.append(tg.create_task(self._send_command({"action": "mkdir", "path": directory}, "mkdir", timeout=timeout))) + + + + return tasks[2].result().startswith("Create folder") + + async def rm(self, path, files, recursive=False, timeout=None): + """ + Create a directory on the device. This API doesn't error if the file doesn't exist. + + Args: + path (str): Directory from which to delete files + files (str|list[str]): File or files to remove from the directory + recursive (bool): Whether to delete the files recursively + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error from server + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + + Returns: + str: Info about the files removed. Something along the lines of Delete: "/path/to/file", or 'Delete recursive: "/path/to/dir", n element(s) removed'. + """ + if isinstance(files, str): + files = [files] + tasks = [] + + async with asyncio.TaskGroup() as tg: + tasks.append(tg.create_task(asyncio.wait_for(self._listen_for_pass(tasks), timeout))) + tasks.append(tg.create_task(asyncio.wait_for(self._listen_for_error(tasks), timeout))) + tasks.append(tg.create_task(self._send_command({"action": "rm", "delfiles": files, "rec": recursive, "path": path}, "rm", timeout=timeout))) + + + return tasks[2].result() + + async def rename(self, path, name, new_name, timeout=None): + """ + Rename a file or folder on the device. This API doesn't error if the file doesn't exist. + + Args: + path (str): Directory from which to rename the file + name (str): File to rename + new_name (str): New name to give the file + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error from server + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + + Returns: + str: Info about file renamed. Something along the lines of 'Rename: "/path/to/file" to "newfile"'. + """ + tasks = [] + + async with asyncio.TaskGroup() as tg: + tasks.append(tg.create_task(asyncio.wait_for(self._listen_for_pass(tasks), timeout))) + tasks.append(tg.create_task(asyncio.wait_for(self._listen_for_error(tasks), timeout))) + tasks.append(tg.create_task(self._send_command({"action": "rename", "path": path, "oldname": name, "newname": new_name}, "rename", timeout=timeout))) + + + return tasks[2].result() + + async def upload(self, source, target, name=None, timeout=None): + ''' + Upload a stream to a device. + + Args: + source (io.IOBase): An IO instance from which to read the data. Must be open for reading. + target (str): Path which to upload stream to on remote device + name (str): Pass if target points at a directory instead of the file path. In that case, this will be the name of the file. + + Raises: + :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property + :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property + + Returns: + dict: {result: bool whether upload succeeded, size: number of bytes uploaded} + ''' + request_id = f"upload_{self._get_request_id()}" + data = { "action": 'upload', "reqid": request_id, "path": target, "name": name} + request = {"id": request_id, "data": data, "type": "upload", "source": source, "target": target, "name": name, "size": 0, "complete": False, "inflight": 0, "finished": asyncio.Event(), "errored":asyncio.Event(), "error": None} + await self._request_queue.put(request) + await asyncio.wait_for(request["finished"].wait(), timeout) + if request["error"] is not None: + raise request["error"] + return request["return"] + + async def download(self, source, target, timeout=None): + ''' + Download a file from a device into a writable stream. + + Args: + source (str): Path from which to download from device + target (io.IOBase): Stream to which to write data. If None, create new BytesIO which is both readable and writable. + + Raises: + :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property + :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property + + Returns: + dict: {result: bool whether download succeeded, size: number of bytes downloaded} + ''' + request_id = f"download_{self._get_request_id()}" + data = { "action": 'download', "sub": 'start', "id": request_id, "path": source } + request = {"id": request_id, "data": data, "type": "download", "source": source, "target": target, "size": 0, "finished": asyncio.Event(), "errored": asyncio.Event(), "error": None} + await self._request_queue.put(request) + await asyncio.wait_for(request["finished"].wait(), timeout) + if request["error"] is not None: + raise request["error"] + return request["return"] + + async def _handle_upload(self, data): + cmd = None + try: + cmd = json.loads(data) + except: + return + if cmd["reqid"] == self._current_request["id"]: + if cmd["action"] == "uploaddone": + self._current_request["return"] = {"result": "success", "size": self._current_request["size"]} + self._current_request["finished"].set() + elif cmd["action"] == "uploadstart": + while True: + data = self._current_request["source"].read(self._chunk_size) + if len(data) == 0: + self._current_request["complete"] = True + if self._current_request["inflight"] == 0: + await self._message_queue.put(json.dumps({ "action": 'uploaddone', "reqid": self._current_request["id"]})) + break + else: + self._current_request["size"] += len(data) + if data[0] == 0 or data[0] == 123: + data = b'\0' + data + await self._message_queue.put(data) + self._current_request["inflight"] += 1 + await asyncio.sleep(0) + elif cmd["action"] == "uploadack": + self._current_request["inflight"] -= 1 + if self._current_request["inflight"] == 0 and self._current_request["complete"]: + await self._message_queue.put(json.dumps({ "action": 'uploaddone', "reqid": self._current_request["id"]})) + elif cmd["action"] == "uploaderror": + self._current_request["return"] = {"result": "canceled", "size": self._current_request["size"]} + self._current_request["error"] = exceptions.FileTransferError("Errored", self._current_request["return"]) + self._current_request["errored"].set() + self._current_request["finished"].set() + + async def _handle_download(self, data): + cmd = None + try: + cmd = json.loads(data) + except: + pass + if cmd is None: + if len(data) > 4: + self._current_request["target"].write(data[4:]) + self._current_request["size"] += len(data)-4 + if (data[3] & 1) != 0: + self._current_request["return"] = {"result": "success", "size": self._current_request["size"]} + self._current_request["finished"].set() + else: + await self._message_queue.put(json.dumps({ "action": 'download', "sub": 'ack', "id": self._current_request["id"] })) + else: + if cmd["action"] == "download": + if cmd["id"] != self._current_request["id"]: + return + if cmd["sub"] == "start": + await self._message_queue.put(json.dumps({ "action": 'download', "sub": 'startack', "id": self._current_request["id"] })) + elif cmd["sub"] == "cancel": + self._current_request["return"] = {"result": "canceled", "size": self._current_request["size"]} + self._current_request["error"] = exceptions.FileTransferCancelled("Cancelled", self._current_request["return"]) + self._current_request["errored"].set() + self._current_request["finished"].set() + + async def _handle_action(self, data): + self._current_request["return"] = json.loads(data) + self._download_finished.set() + + + async def _listen_data_task(self, websocket): + async for message in websocket: + if self.initialized.is_set(): + if message[0] == 123 and self._current_request is not None and self._current_request["type"] not in ("upload", "download"): + await self._handle_action(message) + elif self._current_request is not None and self._current_request["type"] == "upload": + await self._handle_upload(message) + elif self._current_request is not None and self._current_request["type"] == "download": + await self._handle_download(message) + else: + self.recorded = False + if message == "cr": + self.recorded = True + + await self._message_queue.put(f"{self._protocol}".encode()) + self.alive = True + self.initialized.set() \ No newline at end of file diff --git a/src/meshctrl/mesh.py b/src/meshctrl/mesh.py new file mode 100644 index 0000000..df7f265 --- /dev/null +++ b/src/meshctrl/mesh.py @@ -0,0 +1,99 @@ +from . import constants +import datetime + +class Mesh(object): + ''' + Object to represent a device mesh. This object is a rough wrapper; it is not guarunteed to be up to date with the state on the server, for instance. + + Args: + meshid (str): id of the device mesh on the server + session (~meshctrl.session.Session): Parent session used to run commands + created_at (datetime.Datetime|int): Time at which mesh mas created. Also accepted as creation. + name (str|None): Mesh name as it is shown on the meshcentral server + description (str|None): Mesh description as it is shown on the meshcentral server. Also accepted as desc. + meshtype (~meshctrl.constants.MeshType|None): Type of mesh this device is connected to. Also accepted as mtype. + creatorid (str): User id of the user who created the mesh. + creatorname (str): Display name of the user who created the mesh. + domain (str|None): Domain on server to which device is connected. + links (dict[str, ~meshctrl.types.UserLink]|None): Collection of links for the device group + + Returns: + :py:class:`Mesh`: Object representing a device group on the meshcentral server. + + Attributes: + meshid (str): id of the device mesh on the server + created_at (datetime.Datetime): Time at which mesh mas created. + name (str|None): Mesh name as it is shown on the meshcentral server + description (str|None): Mesh description as it is shown on the meshcentral server + meshtype (~meshctrl.constants.MeshType|None): Type of mesh this is. + creatorid (str|None): User id of the user who created the mesh. + creatorname (str|None): Display name of the user who created the mesh. + domain (str|None): Domain on server to which device is connected. + links (dict[str, ~meshctrl.types.UserLink]|None): Collection of links for the device group + ''' + def __init__(self, meshid, session, creation=None, created_at=None, name=None, + mtype=None, meshtype=None, creatorid=None, desc=None, description=None, + domain=None, creatorname=None, links=None, **kwargs): + self.meshid = meshid + self._session = session + if links is None: + links = {} + self.links = links + self.name = name + self.meshtype = meshtype if meshtype is not None else mtype + self.description = description if description is not None else desc + created_at = created_at if created_at is not None else creation + if not isinstance(created_at, datetime.datetime) and created_at is not None: + try: + created_at = datetime.datetime.fromtimestamp(created_at) + except OSError: + # Meshcentral returns in miliseconds, while fromtimestamp, and most of python, expects the argument in seconds. Try seconds frist, then translate from ms if it fails. + # This doesn't work for really early timestamps, but I don't expect that to be a problem here. + created_at = datetime.datetime.fromtimestamp(created_at/1000.0) + self.created_at = created_at + self.creatorid = creatorid + self.creatorname = creatorname + self.domain = domain + # In case meshcentral gives us props we don't understand, store them here. + self._extra_props = kwargs + + @property + def short_meshid(self): + ''' + meshid without "mesh/" or the included domain + ''' + return self.meshid.split("/")[-1] + + @property + def id(self): + ''' + Alias to "meshid" to be consistent accross types. + ''' + return self.meshid + + async def add_users(self, userids, rights=0, timeout=None): + ''' + Add a user to an existing mesh + + Args: + userids (str|list[str]): Unique user id(s) + rights (~meshctrl.constants.MeshRights): Bitwise mask for the rights to give to the users + timeout (int): duration in milliseconds to wait for a response before throwing an error + + Returns: + dict[str, ~meshctrl.types.AddUsersToDeviceGroupResponse]: Object showing which were added correctly and which were not, along with their result messages. str is userid to map response. + + Raises: + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.add_users_to_device_group(userids, self.meshid, isname=False, domain=self.domain, rights=rights, timeout=timeout) + + def __str__(self): + return f"" + def __repr__(self): + return f"Mesh(meshid={repr(self.meshid)}, session={repr(self._session)}, name={repr(self.name)}, description={repr(self.description)}, created_at={repr(self.created_at)}, "\ + f"meshtype={repr(self.meshtype)}, domain={repr(self.domain)}, "\ + f"created_at={repr(self.created_at)}, creatorid={repr(self.creatorid)}, creatorname={repr(self.creatorname)}, links={repr(self.links)}, **{repr(self._extra_props)})" \ No newline at end of file diff --git a/src/meshctrl/session.py b/src/meshctrl/session.py index 3f0fecc..7ab4019 100644 --- a/src/meshctrl/session.py +++ b/src/meshctrl/session.py @@ -5,17 +5,17 @@ import websockets.asyncio.client import asyncio import base64 import json +import datetime +import io +import ssl from . import constants from . import exceptions from . import util - -def _check_socket(f): - async def wrapper(self, *args, **kwargs): - await self.initialized.wait() - if not self.alive: - raise self._main_loop_error - return await f(self, *args, **kwargs) - return wrapper +from . import shell +from . import files +from . import mesh +from . import device +from . import user_group class Session(object): @@ -39,10 +39,11 @@ class Session(object): url (str): url to which the session is connected initialized (asyncio.Event): Event marking if the Session initialization has finished. Wait on this to wait for a connection. alive (bool): Whether the session connection is currently alive + closed (asyncio.Event): Event that occurs when the session closes permanently ''' def __init__(self, url, user=None, domain=None, password=None, loginkey=None, proxy=None, token=None, ignore_ssl=False, auto_reconnect=False): - if len(url) < 5 or ((not url.startswith('wss://')) and (not url.startsWith('ws://'))): + if len(url) < 5 or ((not url.startswith('wss://')) and (not url.startswith('ws://'))): raise ValueError("Invalid URL") if (not url.endswith('/')): @@ -83,15 +84,14 @@ class Session(object): self._proxy = proxy self._user = user self._domain = domain + self._currentDomain = domain self._password = password self._token = token self._loginkey = loginkey self._socket_open = asyncio.Event() self._inflight = set() self._file_tunnels = {} - self._shell_tunnels = {} - self._smart_shell_tunnels = {} - self._ignoreSSL = ignoreSSL + self._ignore_ssl = ignore_ssl self._eventer = util.Eventer() @@ -105,44 +105,50 @@ class Session(object): self._user_info = {} self._command_id = 0 self.alive = False + self.closed = asyncio.Event() self._message_queue = asyncio.Queue() self._send_task = None self._listen_task = None async def _main_loop(self): - options = {} - if self._ignoreSSL: - options = { ssl: False } + try: + options = {} + if self._ignore_ssl: + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + options = { "ssl": ssl_context } - # Setup the HTTP proxy if needed - # if (self._proxy != None): - # options.agent = new https_proxy_agent(urllib.parse(this._proxy)) + # Setup the HTTP proxy if needed + # if (self._proxy != None): + # options.agent = new https_proxy_agent(urllib.parse(self._proxy)) - headers = websockets.datastructures.Headers() + headers = websockets.datastructures.Headers() - if (self._password): - token = self._token if self._token else b"" - headers['x-meshauth'] = (base64.b64encode(self._user.encode()) + b',' + base64.b64encode(self._password.encode()) + token).decode() + if (self._password): + token = self._token if self._token else b"" + headers['x-meshauth'] = (base64.b64encode(self._user.encode()) + b',' + base64.b64encode(self._password.encode()) + token).decode() - options["additional_headers"] = headers - async for websocket in websockets.asyncio.client.connect(self.url, **options): - self.alive = True - self._socket_open.set() - try: - async with asyncio.TaskGroup() as tg: - tg.create_task(self._listen_data_task(websocket)) - tg.create_task(self._send_data_task(websocket)) - except* websockets.ConnectionClosed as e: - self._socket_open.clear() - if not self.auto_reconnect: - self.alive = False - raise - except* Exception as e: - self.initialized.set() - self.alive = False - self._socket_open.clear() - self._main_loop_error = e + options["additional_headers"] = headers + async for websocket in websockets.asyncio.client.connect(self.url, process_exception=util._process_websocket_exception, **options): + self.alive = True + self._socket_open.set() + try: + async with asyncio.TaskGroup() as tg: + tg.create_task(self._listen_data_task(websocket)) + tg.create_task(self._send_data_task(websocket)) + except* websockets.ConnectionClosed as e: + + self._socket_open.clear() + if not self.auto_reconnect: + raise + except* Exception as eg: + self.alive = False + self._socket_open.clear() + self._main_loop_error = eg + self.closed.set() + self.initialized.set() @classmethod async def create(cls, *args, **kwargs): @@ -153,12 +159,15 @@ class Session(object): async def _send_data_task(self, websocket): while True: message = await self._message_queue.get() + print(f"{self._user} send: {message}\n") await websocket.send(message) async def _listen_data_task(self, websocket): async for message in websocket: + print(f"{self._user} recv: {message}\n") data = json.loads(message) action = data.get("action", None) + await self._eventer.emit("server_event", data) if action == "close": if data.get("cause", None) == "noauth": raise exceptions.ServerError("Invalid Auth") @@ -169,13 +178,9 @@ class Session(object): if action == "serverinfo": self._currentDomain = data["serverinfo"]["domain"] self._server_info = data["serverinfo"] - - if action in ("event", "msg", "interuser"): - self._eventer.emit("server_event", data) - id = data.get("responseid", data.get("tag", None)) if id: - self._eventer.emit(id, data) + await self._eventer.emit(id, data) else: # Some events don't user their response id, they just have the action. This should be fixed eventually. # Broken commands include: @@ -185,7 +190,7 @@ class Session(object): # lastconnect # getsysinfo # console.log(`emitting ${data.action}`) - self._eventer.emit(action, data) + await self._eventer.emit(action, data) def _get_command_id(self): self._command_id = (self._command_id+1)%(2**32-1) @@ -199,15 +204,14 @@ class Session(object): except asyncio.CancelledError: pass - + @util._check_socket async def __aenter__(self): - await self.initialized.wait() return self async def __aexit__(self, exc_t, exc_v, exc_tb): await self.close() - @_check_socket + @util._check_socket async def _send_command(self, data, name, timeout=None): id = f"meshctrl_{name}_{self._get_command_id()}" # This fixes a very theoretical bug with hash colisions in the case of an infinite int of requests. Now the bug will only happen if there are currently 2**32-1 of the same type of request going out at the same time @@ -217,7 +221,7 @@ class Session(object): self._inflight.add(id) responded = asyncio.Event() response = None - def _(data): + async def _(data): self._inflight.remove(id) nonlocal response response = data @@ -229,30 +233,51 @@ class Session(object): raise response return response - @_check_socket + @util._check_socket async def _send_command_no_response_id(self, data, timeout=None): responded = asyncio.Event() response = None - def _(data): + async def _(data): nonlocal response response = data responded.set() self._eventer.once(data["action"], _) - await self._message_queue.put(data | {"tag": id, "responseid": id}) + await self._message_queue.put(json.dumps(data)) await asyncio.wait_for(responded.wait(), timeout=timeout) if isinstance(response, Exception): raise response return response + @util._check_socket + async def server_info(self): + """ + Get server information + + Returns: + (dict) Server info + """ + return self._server_info + + @util._check_socket + async def user_info(self): + """ + Get user information + + Returns: + (dict) User info + """ + return self._user_info + + async def list_device_groups(self, timeout=None): ''' Get device groups. Only returns meshes to which the logged in user has access Args: - timeout (int): Duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - list[dict]: List of meshes + list[~meshctrl.mesh.Mesh]: List of meshes Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error from server @@ -260,12 +285,13 @@ class Session(object): asyncio.TimeoutError: Command timed out ''' data = await self._send_command({"action": "meshes"}, "list_device_groups", timeout) - return data["meshes"] + return [mesh.Mesh(m["_id"], self, **m) for m in data["meshes"]] async def send_invite_email(self, group, email, name=None, message=None, meshid=None, timeout=None): ''' Send an invite email for a group or mesh + TODO: This has no tests for it Args: group (str): Name of mesh to which to invite email @@ -273,28 +299,46 @@ class Session(object): name (str): User's name. For display purposes. message (str): Message to send to user in invite email meshid (str): ID of mesh which to invite user. Overrides "group" - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, False otherwise + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out - ''' - raise NotImplementedError() + ''' + op = { + "action": 'inviteAgent', + "email": email, + "name": '', + "os": '0' + } + if meshid: + op["meshid"] = meshid + elif group: + op["meshname"] = group + if name: + op["name"] = name + if message: + op["msg"] = message + data = await self._send_command(op, "send_invite_email", timeout) + if ("result" in data and data["result"].lower() != "ok"): + raise exceptions.ServerError(data["result"]) + return True async def generate_invite_link(self, group, hours, flags=None, meshid=None, timeout=None): ''' Generate an invite link for a group or mesh + TODO: This has no tests for it Args: group (str): Name of group to add hours (int): Hours until link expires - flags (~meshctrl.constants.MeshRights): Bitwise flags for constants.MeshRights + flags (~meshctrl.constants.MeshRights|~meshctrl.constants.DeviceRights): Bitwise flags representing rights for device/mesh meshid (str): ID of mesh which to invite user. Overrides "group" - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: dict: Invite link information @@ -304,119 +348,168 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + op = { + "action": 'createInviteLink', + "expire": hours, + "flags": 0 + } + if meshid: + op["meshid"] = meshid + elif group: + op["meshname"] = group + if flags != None: + op["flags"] = flags + data = await self._send_command(op, "generate_invite_link", timeout) + if ("result" in data and data["result"].lower() != "ok"): + raise exceptions.ServerError(data["result"]) + del data["tag"] + del data["responseid"] + del data["action"] + return data async def list_users(self, timeout=None): ''' List users on server. Admin Only. Args: - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - list[dict]: List of users + list[~meshctrl.types.ListUsersResponseItem]: List of users Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + data = await self._send_command({"action": "users"}, "list_users", timeout) + if ("result" in data and data["result"].lower() != "ok"): + raise exceptions.ServerError(data["result"]) + return data["users"] async def list_user_sessions(self, timeout=None): ''' Get list of connected users. Admin Only. Args: - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - list[dict] List of user sessions + dict[str, int]: Number of sessions per user Raises: :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() - - async def list_user_groups(self, timeout=None): - ''' - Get user groups. Admin will get all user groups, otherwise get limited user groups - - Args: - timeout (int): duration in milliseconds to wait for a response before throwing an error - - Returns: - list[dict]: List of groups - - Raises: - :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure - asyncio.TimeoutError: Command timed out - ''' - raise NotImplementedError() + return (await self._send_command({"action": "wssessioncount"}, "list_user_sessions", timeout))["wssessions"] async def list_devices(self, details=False, group=None, meshid=None, timeout=None): ''' Get devices to which the user has access. + Different options will fill different properties in the resultant device objects, based on what is returned from meshcentral. Documenting these changes is beyond the scope of this documentation. Args: - details (bool): Get device details + details (bool): Get device details, overrides group and meshid group (str): Get devices from specific group by name. Overrides meshid meshid (str): Get devices from specific group by id - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - list[dict]: List of nodes + list[~meshctrl.device.Device]: List of nodes Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out - ''' - raise NotImplementedError() - - - - def on_close(self, f): ''' - Listen for the socket to close + tasks = [] + async with asyncio.TaskGroup() as tg: + if details: + tasks.append(tg.create_task(self._send_command_no_response_id({"action": "getDeviceDetails", "type":"json"}, timeout))) + elif group: + tasks.append(tg.create_task(self._send_command({ "action": 'nodes', "meshname": group}, "list_devices", timeout))) + elif meshid: + tasks.append(tg.create_task(self._send_command({ "action": 'nodes', "meshid": meshid}, "list_devices", timeout))) + else: + tasks.append(tg.create_task(self._send_command({ "action": 'meshes' }, "list_devices", timeout))) + tasks.append(tg.create_task(self._send_command({ "action": 'nodes' }, "list_devices", timeout))) - Args: - f (function(data: dict)): Function to call when the socket closes - ''' - raise NotImplementedError() + res0 = tasks[0].result() + if "result" in res0: + raise exceptions.ServerError(res0["result"]) + if details: + nodes = json.loads(res0["data"]) + for node in nodes: + if node["node"].get("meshid", None): + node["node"]["mesh"] = mesh.Mesh(node["node"].get("meshid"), self) + details = {} + for key, val in node.items(): + if key != "node": + details[key] = val + node["node"]["details"] = details + return [device.Device(n["node"]["_id"], self, **n["node"]) for n in nodes] + if group or meshid: + nodes = [] + for _meshid, node_list in res0["nodes"].items(): + for node in node_list: + node["meshid"] = meshid + if meshid: + node["mesh"] = mesh.Mesh(_meshid, self) + if group: + node["groupname"] = group + nodes.append(node) + return [device.Device(n["_id"] , self, **n) for n in nodes] + # if "meshes" not in res0 or not res0["meshes"]: + # return tasks[1].result()["nodes"] - def listen_to_events(self, f, filter=None): + xmeshes = {} + nodes = [] + for _mesh in res0["meshes"]: + xmeshes[_mesh["_id"]] = _mesh + for meshid, devicesInMesh in tasks[1].result()["nodes"].items(): + for _device in devicesInMesh: + _device["meshid"] = meshid; # Add device group id + if xmeshes and meshid in xmeshes and "name" in xmeshes[meshid]: + _device["groupname"] = xmeshes[meshid]["name"] # Add device group name + nodes.append(_device); + for node in nodes: + if node.get("meshid", None): + node["mesh"] = mesh.Mesh(node.get("meshid"), self) + return [device.Device(n["_id"], self, **n) for n in nodes] + + async def events(self, filter=None): ''' Listen to events from the server Args: - f (function(data: dict)): Function to call when an event occurs filter (dict): dict to filter events with. Only trigger for events that deep-match this dict. Use sets for "array.contains" and arrays for equality of lists. Returns: - function: - Function used for listening. Use this to stop listening to events if you want that. + generator(data): A generator with the events that match the given filter, or all events if no filter is given ''' - raise NotImplementedError() - - def stop_listening_to_events(self, f): - ''' - Stop listening to server events - - Args: - @param {function} Callback to stop listening with. - ''' - raise NotImplementedError() + event_queue = asyncio.Queue() + async def _(data): + await event_queue.put(data) + self._eventer.on("server_event", _) + try: + while True: + data = await event_queue.get() + if filter and not util.compare_dict(filter, data): + continue + yield data + finally: + self._eventer.off("server_event", _) async def list_events(self, userid=None, nodeid=None, limit=None, timeout=None): ''' List events visible to the currect user Args: - userid (str): Filter by user. Overrides nodeid. + userid (str): Filter by user. Overrides nodeid. Only works for admin, otherwise ignored. nodeid (str): Filter by node limit (int): Limit to the N most recent events - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: list[dict]: List of events @@ -425,23 +518,41 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + try: + if (limit < 1): + limit = None + except: + limit = None + + cmd = None; + if userid: + cmd = { "action": 'events', "userid": userid } + elif (nodeid): + cmd = { "action": 'events', "nodeid": nodeid } + else: + cmd = { "action": 'events' } + + if limit: + cmd["limit"] = limit + + data = await self._send_command(cmd, "list_events", timeout) + return data["events"] async def list_login_tokens(self, timeout=None): ''' List login tokens for current user. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. Args: - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - list[dict]: List of tokens + list[~meshctrl.types.RetrievedLoginToken]: List of tokens Raises: :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + return (await self._send_command_no_response_id({"action": "loginTokens"}, timeout))["loginTokens"] async def add_login_token(self, name, expire=None, timeout=None): ''' @@ -450,16 +561,19 @@ class Session(object): Args: name (str): Name of token expire (int): Minutes until expiration. 0 or None for no expiration. - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - dict: Created token + ~meshctrl.types.LoginToken: Created token Raises: :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + cmd = { "action": 'createLoginToken', "name": name, "expire": 0 if not expire else expire } + data = await self._send_command_no_response_id(cmd, timeout) + del data["action"] + return data async def remove_login_token(self, names, timeout=None): ''' @@ -467,18 +581,31 @@ class Session(object): Args: name (str): Name of token or token username - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - list[dict]: List of remaining tokens + list[~meshctrl.types.RetrievedLoginToken]: List of remaining tokens Raises: :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out - ''' - raise NotImplementedError() + ''' - async def add_user(self, name, password, randompass=False, domain=None, email=None, emailverified=False, resetpass=False, realname=None, phone=None, rights=None, timeout=None): + if isinstance(names, str): + names = [names] + + realnames = [] + tokens = await self.list_login_tokens() + for name in names: + if not name.startswith("~"): + for token in tokens: + if token["name"] == name: + name = token["tokenUser"] + break + realnames.append(name) + return (await self._send_command_no_response_id({ "action": 'loginTokens', "remove": realnames }, timeout))["loginTokens"] + + async def add_user(self, name, password=None, randompass=False, domain=None, email=None, emailverified=False, resetpass=False, realname=None, phone=None, rights=None, timeout=None): ''' Add a new user @@ -491,19 +618,43 @@ class Session(object): emailverified (bool): Pre-verify the user's email address resetpass (bool): Force the user to reset their password on first login realname (str): User's real name - phone (str): User's phone int + phone (str): User's phone number rights (~meshctrl.constants.UserRights): Bitwise mask of user's rights on the server - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if randompass: + password = util._get_random_amt_password() + op = { "action": 'adduser', "username": name, "pass": password }; + if email: + op["email"] = email + if emailverified: + op["emailVerified"] = True + if resetpass: + op["resetNextLogin"] = True + if rights is not None: + op["siteadmin"] = rights + if domain: + op["domain"] = domain + elif self._domain: + op["domain"] = self._domain + + if isinstance(phone, str): + op["phone"] = phone + if isinstance(realname, str): + op["realname"] = realname + + data = await self._send_command(op, "add_user", timeout) + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True async def edit_user(self, userid, domain=None, email=None, emailverified=False, resetpass=False, realname=None, phone=None, rights=None, timeout=None): ''' @@ -516,112 +667,266 @@ class Session(object): emailverified (bool): Verify or unverify the user's email address resetpass (bool): Force the user to reset their password on next login realname (str): User's real name - phone (str): User's phone int + phone (str): User's phone number rights (~meshctrl.constants.UserRights): Bitwise mask of user's rights on the server - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if (domain is not None) and ("/" not in userid): + userid = f"user/{domain}/{userid}" + elif (self._domain is not None) and ("/" not in userid): + userid = f"user/{self._domain}/{userid}" - async def remove_user(self, userid, timeout=None): + op = { "action": 'edituser', "userid": userid} + if email is not None: + op["email"] = email + if emailverified: + op["emailVerified"] = True + + if resetpass: + op["resetNextLogin"] = True + if rights is not None: + op["siteadmin"] = rights + if domain: + op["domain"] = domain + elif self._domain: + op["domain"] = self._domain + if phone is True: + op["phone"] = '' + if isinstance(phone, str): + op["phone"] = phone + if isinstance(realname, str): + op["realname"] = realname + if realname is True: + op["realname"] = '' + data = await self._send_command(op, "edit_user", timeout) + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True + + async def remove_user(self, userid, domain=None, timeout=None): ''' Remove an existing user Args: userid (str): Unique userid - timeout (int): duration in milliseconds to wait for a response before throwing an error + domain (str): Domain to which to add the user + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False - + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if (domain is not None) and ("/" not in userid): + userid = f"user/{domain}/{userid}" + elif (self._domain is not None) and ("/" not in userid): + userid = f"user/{self._domain}/{userid}" - async def add_user_group(self, name, description=None, timeout=None): + data = await self._send_command({ "action": 'deleteuser', "userid": userid }, "remove_user", timeout) + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True + + async def add_user_group(self, name, domain=None, description=None, timeout=None): ''' Create a new user group Args: name (str): Name of usergroup + domain (str): Domain to which to add the user description (str): Description of user group - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - dict: New user group + ~meshctrl.user_group.UserGroup: New user group Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + op = { "action": 'createusergroup', "name": name, "desc": description }; + if domain is not None: + op["domain"] = self._domain + elif self._domain is not None: + op["domain"] = self._domain + data = await self._send_command(op, "add_user_group", timeout) + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) - async def remove_user_group(self, groupid, timeout=None): + del data["action"] + del data["responseid"] + del data["result"] + ugrpid = data["ugrpid"] + del data["ugrpid"] + return user_group.UserGroup(ugrpid, self, name=name, description=description, domain=domain, **data) + + async def remove_user_group(self, groupid, domain=None, timeout=None): ''' Remove an existing user group Args: userid (str): Unique userid - timeout (int): duration in milliseconds to wait for a response before throwing an error + domain (str): Domain to which to add the user + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if (domain is not None) and ("/" not in groupid): + userid = f"ugrp/{domain}/{userid}" + elif (self._domain is not None) and ("/" not in groupid): + userid = f"ugrp/{self._domain}/{userid}" - async def add_users_to_user_group(self, userids, groupid, timeout=None): + if (not groupid.startswith("ugrp/")): + groupid = f"ugrp//{groupid}" + data = await self._send_command({ "action": 'deleteusergroup', "ugrpid": groupid }, "remove_user_group", timeout) + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True + + async def list_user_groups(self, timeout=None): + ''' + Get user groups. Admin will get all user groups, otherwise get limited user groups + + Args: + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + list[~meshctrl.user_group.UserGroup]: List of groups. If you are not a member, you'l just get the names and ids. + + Raises: + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + r = await self._send_command({"action": "usergroups"}, "list_user_groups", timeout) + groups = [] + for key, val in r["ugroups"].items(): + val["_id"] = key + groups.append(user_group.UserGroup(key, self, **val)) + return groups + + async def add_users_to_user_group(self, usernames, groupid, domain=None, timeout=None): ''' Add user(s) to an existing user group. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. Args: - ids (str|list[str]): Unique user id(s) + usernames (str|list[str]): Unique user name(s). This API will not work with the full user ID, but we will try to turn it into something that makes sense. groupid (str): Group to add the given user to - timeout (int): duration in milliseconds to wait for a response before throwing an error + domain (str): Domain containing the group + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - list[str]: List of users that were successfully added + dict[str, ~meshctrl.types.AddUsersToUserGroupResponse]: List of users that were successfully added. str is username. Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(usernames, str): + usernames = [usernames] + _new = [] + for username in usernames: + _new.append(username.split("/")[-1]) + usernames = _new - async def remove_user_from_user_group(self, userid, groupid, timeout=None): + + if (domain is not None) and ("/" not in groupid): + groupid = f"ugrp/{domain}/{groupid}" + elif (self._domain is not None) and ("/" not in groupid): + groupid = f"ugrp/{self._domain}/{groupid}" + + if (not groupid.startswith("ugrp/")): + groupid = f"ugrp//{groupid}" + + result = {u: {"success": False, "done": False, "message": None} for u in usernames} + tasks = [] + def check_results(): + for key, val in result.items(): + if not val["done"]: + break + else: + return True + return False + async def _(tg): + async for event in self.events({"event": {"etype":"ugrp"}}): + for username in event["event"]["msgArgs"][0]: + result[username]["success"] = True + result[username]["done"] = True + if check_results(): + tasks[1].cancel() + return + + async def __(tg): + async for event in self.events({"action": "msg", "type":"notify", "tag": "ServerNotify"}): + for username in usernames: + if username in event["value"]: + result[username]["success"] = False + result[username]["message"] = event["value"] + result[username]["done"] = True + if check_results(): + tasks[0].cancel() + return + + + async with asyncio.TaskGroup() as tg: + tasks.append(tg.create_task(asyncio.wait_for(_(tg), timeout=timeout))) + tasks.append(tg.create_task(asyncio.wait_for(__(tg), timeout=timeout))) + tasks.append(tg.create_task(self._send_command({ "action": 'addusertousergroup', "ugrpid": groupid, "usernames": usernames}, "add_users_to_user_group", timeout))) + + + res = tasks[2].result() + if "result" in res and res["result"] != "ok": + raise exceptions.ServerError(res.result) + return {key: {"success": val["success"], "message": val["message"]} for key,val in result.items()} + + async def remove_user_from_user_group(self, userid, groupid, domain=None, timeout=None): ''' Remove user from an existing user group Args: - id (str): Unique user id + userid (str): Unique user id groupid (str): Group to remove the given user from - timeout (int): duration in milliseconds to wait for a response before throwing an error + domain (str): Domain containing the group + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if (domain is not None) and ("/" not in groupid): + groupid = f"ugrp/{domain}/{groupid}" + elif (self._domain is not None) and ("/" not in groupid): + groupid = f"ugrp/{self._domain}/{groupid}" + + if (not groupid.startswith("ugrp/")): + groupid = f"ugrp//{groupid}" + + data = await self._send_command({ "action": 'removeuserfromusergroup', "ugrpid": groupid, "userid": userid }, "remove_from_user_group", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True async def add_users_to_device(self, userids, nodeid, rights=None, timeout=None): ''' @@ -630,18 +935,29 @@ class Session(object): Args: userids (str|list[str]): Unique user id(s) nodeid (str): Node to add the given user to - rights (~meshctrl.constants.MeshRights): Bitwise mask for the rights on the given mesh - timeout (int): duration in milliseconds to wait for a response before throwing an error + rights (~meshctrl.constants.DeviceRights): Bitwise mask for the rights on the given device + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(userids, str): + userids = [userids] + + userids = [f"user//{u}" if not u.startswith("user//") else u for u in userids] + if rights is None: + rights = 0 + + data = await self._send_command({ "action": 'adddeviceuser', "nodeid": nodeid, "userids": userids, "rights": rights}, "add_users_to_device", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True async def remove_users_from_device(self, nodeid, userids, timeout=None): ''' @@ -650,17 +966,27 @@ class Session(object): Args: nodeid (str): Node to remove the given users from userids (str|list[str]): Unique user id(s) - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(userids, str): + userids = [userids] + + userids = [f"user//{u}" if not u.startswith("user//") else u for u in userids] + + data = await self._send_command({ "action": 'adddeviceuser', "nodeid": nodeid, "usernames": userids, "rights": 0, "remove": True }, "remove_users_from_device", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True + async def add_device_group(self, name, description="", amtonly=False, features=0, consent=0, timeout=None): ''' @@ -672,17 +998,38 @@ class Session(object): amtonly (bool): features (~meshctrl.constants.MeshFeatures): Bitwise features to enable on the group consent (~meshctrl.constants.ConsentFlags): Bitwise consent flags to use for the group - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - dict: New device group + ~meshctrl.mesh.Mesh: Newly created device group. Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + op = { "action": 'createmesh', "meshname": name, "meshtype": 2 }; + if description: + op["desc"] = description + if amtonly: + op["meshtype"] = 1 + if features: + op["flags"] = features + if consent: + op["consent"] = consent + + data = await self._send_command(op, "add_device_group", timeout) + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + del data["action"] + del data["responseid"] + del data["result"] + meshid = data["meshid"] + del data["meshid"] + data["name"] = name + data["description"] = description + return mesh.Mesh(meshid, self, **data) async def remove_device_group(self, meshid, isname=False, timeout=None): ''' @@ -691,21 +1038,30 @@ class Session(object): Args: meshid (str): Unique id of device group isname (bool): treat "meshid" as a name instead of an id - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + op = { "action": 'deletemesh', "meshid": meshid}; + if isname: + op["meshname"] = meshid + del op["meshid"] - async def edit_device_group(self, meshid, isname=False, name=None, description=None, flags=None, consent=None, invite_codes=None, backgroundonly=False, interactiveonly=False, timeout=None): + data = await self._send_command(op, "remove_device_group", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True + + async def edit_device_group(self, meshid, isname=False, name=None, description=None, flags=None, consent=None, invite_codes=None, backgroundonly=False, interactiveonly=False, timeout=10): ''' - Edit an existing device group + Edit an existing device group. WARNING: This command will just hang if you do not have permissions. Because of this, timeout is defaulted to 10 seconds. Be wary if you remove the timeout. Args: meshid (str): Unique id of device group @@ -714,20 +1070,48 @@ class Session(object): description (str): New description flags (~meshctrl.constants.MeshFeatures): Features to enable on the group consent (~meshctrl.constants.ConsentFlags): Which consent flags to use for the group - invite_codes (list[str]): Create new invite codes + invite_codes (list[str]|True): Create new invite codes. If True, pass `"*"`. I don't know what this means. backgroundonly (bool): Flag for invite codes interactiveonly (bool): Flag for invite codes - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + op = { "action": 'editmesh', "meshid": meshid}; + if isname: + op["meshidname"] = meshid + del op["meshid"] + + if name is not None: + op["meshname"] = name + if description is not None: + op["desc"] = description + if invite_codes == True: + op["invite"] = "*" + elif invite_codes is not None: + op["invite"] = { "codes": invite_codes, "flags": 0 } + if backgroundonly: + op["invite"]["flags"] = 2 + elif interactiveonly: + op["invite"]["flags"] = 1 + + if flags is not None: + op["flags"] = flags + + if consent is not None: + op["consent"] = consent + + data = await self._send_command(op, "edit_device_group", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True async def move_to_device_group(self, nodeids, meshid, isname=False, timeout=None): ''' @@ -737,17 +1121,28 @@ class Session(object): nodeids (str|list[str]): Unique node id(s) meshid (str): Unique mesh id isname (bool): treat "meshid" as a name instead of an id - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True on success, otherwise False + bool: True on success, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(nodeids, str): + nodeids = [nodeids] + op = { "action": 'changeDeviceMesh', "nodeids": nodeids, "meshid": meshid }; + if isname: + op["meshname"] = meshid + del op["meshid"] + + data = await self._send_command(op, "move_to_device_group", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True async def add_users_to_device_group(self, userids, meshid, isname=False, rights=0, timeout=None): ''' @@ -758,16 +1153,36 @@ class Session(object): meshid (str): Mesh to add the given user to isname (bool): Read meshid as a name rather than an id rights (~meshctrl.constants.MeshRights): Bitwise mask for the rights on the given mesh - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - dict: Object showing which were added correctly and which were not, along with their result messages + dict[str, ~meshctrl.types.AddUsersToDeviceGroupResponse]: Dict showing which were added correctly and which were not, along with their result messages. str is userid to map response. Raises: :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(userids, str): + userids = [userids] + original_ids = userids + userids = [f"user//{u}" if not u.startswith("user//") else u for u in userids] + op = { "action": 'addmeshuser', "userids": userids, "meshadmin": rights, "meshid": meshid }; + if isname: + op["meshname"] = meshid + del op["meshid"] + + data = await self._send_command(op, "add_user_to_device_group", timeout) + results = data["result"].split(",") + out = {} + for i, result in enumerate(results): + if i >= len(original_ids): + out["all"] = result + else: + out[original_ids[i]] = { + "success": result.startswith("Added user"), + "message": result + } + return out async def remove_users_from_device_group(self, userids, meshid, isname=False, timeout=None): ''' @@ -777,35 +1192,66 @@ class Session(object): userids (str|list[str]): Unique user id(s) meshid (str): Mesh to add the given user to isname (bool): Read meshid as a name rather than an id - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - dict: Object showing which were removed correctly and which were not + dict[str, ~meshctrl.types.AddUsersToDeviceGroupResponse]: Dict showing which were removed correctly and which were not, along with their result messages. str is userid to map response. Raises: :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + requests = [] + id_obj = {"meshid": meshid} + if isname: + id_obj["meshname"] = meshid + del id_obj["meshid"] + + if isinstance(userids, str): + userids = [userids] + + tasks = [] + async with asyncio.TaskGroup() as tg: + for userid in userids: + tasks.append(tg.create_task(self._send_command({ "action": 'removemeshuser', "userid": userid } | id_obj, "remove_users_from_device_group", timeout))) + + out = {} + for i, task in enumerate(tasks): + result = task.result() + if result.get("result", "") == "ok": + out[userids[i]] = {"success": True} + else: + out[userids[i]] = {"success": False} + out[userids[i]]["message"] = result["result"] + return out async def broadcast(self, message, userid=None, timeout=None): ''' Broadcast a message to all users or a single user + TODO: This has no tests for it Args: message (str): Message to broadcast userid (str): Optional user to which to send the message - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True if successful + bool: True if successful, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + op = { "action": 'userbroadcast', "msg": message }; + if userid: + op["userid"] = userid + + data = await self._send_command(op, "broadcast", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True async def device_info(self, nodeid, timeout=None): ''' @@ -813,18 +1259,58 @@ class Session(object): Args: nodeid (str): Unique id of desired node - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - dict: Object containing all meaningful device info + ~meshctrl.device.Device: Object representing the state of the device Raises: ValueError: `Invalid device id` if device is not found :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + tasks = [] + async with asyncio.TaskGroup() as tg: + tasks.append(tg.create_task(self._send_command({ "action": 'nodes' }, "device_info", timeout))) + tasks.append(tg.create_task(self._send_command_no_response_id({ "action": 'getnetworkinfo', "nodeid": nodeid }, timeout))) + tasks.append(tg.create_task(self._send_command_no_response_id({ "action": 'lastconnect', "nodeid": nodeid }, timeout))) + tasks.append(tg.create_task(self._send_command({ "action": 'getsysinfo', "nodeid": nodeid, "nodeinfo": True }, "device_info", timeout))) + tasks.append(tg.create_task(self.list_device_groups(timeout=timeout))) + nodes, network, lastconnect, sysinfo, meshes = (_.result() for _ in tasks) + + + node = None + if sysinfo is not None and sysinfo.get("node", None): + # Node information came with system information + node = sysinfo.get("node", None) + else: + # This device does not have system information, get node information from the nodes list. + for meshid, _nodes in nodes["nodes"].items(): + for _mesh in meshes: + if _mesh.meshid == meshid: + break + else: + _mesh = None + for _node in _nodes: + if nodeid in _node["_id"]: + node = _node + node["meshid"] = meshid + if _mesh is not None: + node["mesh"] = _mesh + sysinfo["node"] = node + sysinfo["nodeid"] = nodeid + del sysinfo["result"] + del sysinfo["noinfo"] + if node is None: + raise ValueError("Invalid device id") + if lastconnect is not None: + node["lastconnect"] = lastconnect["time"] + node["lastaddr"] = lastconnect["addr"] + if node.get("meshid", None) and "mesh" not in node: + node["mesh"] = mesh.Mesh(node["meshid"], self) + return device.Device(node["_id"], self, **node) + async def edit_device(self, nodeid, name=None, description=None, tags=None, icon=None, consent=None, timeout=None): ''' Edit properties of an existing device @@ -836,73 +1322,152 @@ class Session(object): tags (str|list[str]]): New tags for device icon (~meshctrl.constants.Icon): New icon for device consent (~meshctrl.constants.ConsentFlags): New consent flags for device - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: True if successful + bool: True if successful, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + op = { "action": 'changedevice', "nodeid": nodeid }; + if name is not None: + op["name"] = name + if description is not None: + op["desc"] = description + if tags is not None: + op["tags"] = tags + if icon is not None: + op["icon"] = icon + if consent is not None: + op["consent"] = consent - async def run_command(self, nodeids, command, powershell=False, runasuser=False, runasuseronly=False, timeout=None): + data = await self._send_command(op, "edit_device", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + return True + + async def run_command(self, nodeids, command, powershell=False, runasuser=False, runasuseronly=False, ignore_output=False, timeout=None): ''' - Run a command on any int of nodes. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. + Run a 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 powershell (bool): Use powershell to run command. Only available on Windows. runasuser (bool): Attempt to run as a user instead of the root permissions given to the agent. Fall back to root if we cannot. + ignore_output (bool): Don't bother trying to get the output. Every device will return an empty string for its result. runasuseronly (bool): Error if we cannot run the command as the logged in user. - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - dict: Object containing mapped output of the commands by device + 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 ''' - raise NotImplementedError() + runAsUser = 0 + if runasuser: + runAsUser = 1 + if runasuseronly: + runAsUser = 2 + if isinstance(nodeids, str): + nodeids = [nodeids] - async def shell(self, nodeid, unique=False): + 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 _(): + async for event in self.events({"action": "msg", "type": "console"}): + node = match_nodeid(event["nodeid"], nodeids) + if node: + if event["value"] == "Run commands completed.": + result.setdefault(node, {})["complete"] = True + if all(_["complete"] for key, _ in result.items()): + break + elif (event["value"].startswith("Run commands")): + continue + result[node]["result"].append(event["value"]) + async def __(command): + data = await self._send_command(command, "run_command", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + expect_response = False + 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 + + tasks = [] + async with asyncio.TaskGroup() as tg: + if expect_response: + tasks.append(tg.create_task(asyncio.wait_for(_(), timeout=timeout))) + tasks.append(tg.create_task(__({ "action": 'runcommands', "nodeids": nodeids, "type": (2 if powershell else 0), "cmds": command, "runAsUser": runAsUser }))) + + 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 Args: nodeid (str): Unique id of node on which to open the shell - unique (bool): True: Create a unique :py:class:`~meshctrl.shell.Shell`. Caller is responsible for cleanup. False: Use a cached :py:class:`~meshctrl.shell.Shell` if available, otherwise create and cache. Returns: :py:class:`~meshctrl.shell.Shell`: Newly created and initialized :py:class:`~meshctrl.shell.Shell` or cached :py:class:`~meshctrl.shell.Shell` if unique is False and a shell is currently active ''' - raise NotImplementedError() + return shell.Shell(self, nodeid) - async def smart_shell(self, nodeid, regex, unique=False): + + def smart_shell(self, nodeid, regex): ''' Get a smart terminal shell on the given device Args: nodeid (str): Unique id of node on which to open the shell regex (regex): Regex to watch for to signify that the shell is ready for new input. - unique (bool): true: Create a unique :py:class:`~meshctrl.shell.SmartShell`. Caller is responsible for cleanup. False: Use a cached :py:class:`~meshctrl.shell.SmartShell` if available, otherwise create and cache. + Returns: :py:class:`~meshctrl.shell.SmartShell`: Newly created and initialized :py:class:`~meshctrl.shell.SmartShell` or cached :py:class:`~meshctrl.shell.SmartShell` if unique is False and a smartshell with regex is currently active ''' - raise NotImplementedError() + _shell = shell.Shell(self, nodeid) + return shell.SmartShell(_shell, regex) + async def wake_devices(self, nodeids, timeout=None): ''' Wake up given devices + TODO: This has no tests for it Args: nodeids (str|list[str]): Unique ids of nodes which to wake - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: bool: True if successful @@ -911,15 +1476,19 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(nodeids, str): + nodeids = [nodeids] + + return await self._send_command({ "action": 'wakedevices', "nodeids": nodeids }, "wake_devices", timeout) async def reset_devices(self, nodeids, timeout=None): ''' Reset given devices + TODO: This has no tests for it Args: nodeids (str|list[str]): Unique ids of nodes which to reset - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: bool: True if successful @@ -928,15 +1497,19 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(nodeids, str): + nodeids = [nodeids] + + return await self._send_command({ "action": 'poweraction', "nodeids": nodeids, "actiontype": 3 }, "reset_devices", timeout) async def sleep_devices(self, nodeids, timeout=None): ''' Sleep given devices + TODO: This has no tests for it Args: nodeids (str|list[str]): Unique ids of nodes which to sleep - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: bool: True if successful @@ -945,14 +1518,19 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(nodeids, str): + nodeids = [nodeids] + + return await self._send_command({ "action": 'poweraction', "nodeids": nodeids, "actiontype": 4 }, "sleep_devices", timeout) async def power_off_devices(self, nodeids, timeout=None): - ''' Power off given devices + ''' + Power off given devices + TODO: This has no tests for it Args: nodeids (str|list[str]): Unique ids of nodes which to power off - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: bool: True if successful @@ -961,7 +1539,10 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if isinstance(nodeids, str): + nodeids = [nodeids] + + return await self._send_command({ "action": 'poweraction', "nodeids": nodeids, "actiontype": 2 }, "power_off_devices", timeout) async def list_device_shares(self, nodeid, timeout=None): ''' @@ -969,7 +1550,7 @@ class Session(object): Args: nodeid (str): Unique id of nodes of which to list shares - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: list[dict]: Array of dicts representing device shares @@ -978,11 +1559,16 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + data = await self._send_command_no_response_id({ "action": 'deviceShares', "nodeid": nodeid }, timeout) + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + return data["deviceShares"] async def add_device_share(self, nodeid, name, type=constants.SharingType.desktop, consent=None, start=None, end=None, duration=60*60, timeout=None): ''' Add device share to given node. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. + TODO: This has no tests for it Args: nodeid (str): Unique id of nodes of which to list shares @@ -992,7 +1578,7 @@ class Session(object): start (int|datetime.datetime): When to start the share end (int|datetime.datetime): When to end the share. If None, use duration instead duration (int): Duration in seconds for share to exist - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: dict: Info about the newly created share @@ -1000,40 +1586,72 @@ class Session(object): 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: If 'end' is some time before 'start' asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + if start is None: + datetime.datetime.now() + if consent is None: + if type == constants.SharingType.desktop: + consent = constants.ConsentFlags.desktopnotify + else: + consent = constants.ConsentFlags.terminalnotify + + start = int(start.timestamp()) + if end is None: + end = start + duration + else: + end = int(start.timestamp()) + if end <= start: + raise ValueError("End time must be ahead of start time") + data = await self._send_command({ "action": 'createDeviceShareLink', "nodeid": nodeid, "guestname": name, "p": constants.SharingTypeEnum[type], "consent": consent, "start": start, "end": end }, "add_device_share", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + del data["action"] + del data["nodeid"] + del data["tag"] + del data["responseid"] + return data async def remove_device_share(self, nodeid, shareid, timeout=None): ''' Remove a device share + TODO: This has no tests for it Args: nodeid (str): Unique node from which to remove the share shareid (str): Unique share id to be removed - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: true if successful + bool: True if successful, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + data = await self._send_command({ "action": 'removeDeviceShare', "nodeid": nodeid, "publicid": shareid }, "remove_device_share", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + return True async def device_open_url(self, nodeid, url, timeout=None): ''' Open url in browser on device. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. + TODO: This has no tests for it Args: nodeid (str): Unique node from which to remove the share url (str): url to open - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: true if successful + bool: True if successful, raise otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure @@ -1041,52 +1659,90 @@ class Session(object): :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + async def _(): + async for event in self.events({"type": "openUrl", "url": url}): + if event["success"]: + return True + else: + return False + + tasks = [] + async with asyncio.TaskGroup() as tg: + tasks.append(tg.create_task(asyncio.wait_for(_(), timeout=timeout))) + tasks.append({ "action": 'msg', "type": 'openUrl', "nodeid": nodeid, "url": url }, "device_open_url", timeout) + + res = tasks[1].result() + success = tasks[2].result() + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + if not success: + raise exceptions.ServerError("Failed to open url") + + return True async def device_message(self, nodeid, message, title="MeshCentral", timeout=None): ''' Display a message on remote device. + TODO: This has no tests for it Args: nodeid (str): Unique node from which to remove the share message (str): message to display title (str): message title - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: true if successful + bool: True if successful, raile otherwise Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out ''' - raise NotImplementedError() + data = await self._send_command({ "action": 'msg', "type": 'messagebox', "nodeid": nodeid, "title": title, "msg": message }, "device_message", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + return True async def device_toast(self, nodeids, message, title="MeshCentral", timeout=None): ''' Popup a toast a message on remote device. + TODO: This has no tests for it Args: nodeids (str|list[str]): Unique node from which to remove the share message (str): message to display title (str): message title - timeout (int): duration in milliseconds to wait for a response before throwing an error + timeout (int): duration in seconds to wait for a response before throwing an error Returns: - bool: true if successful + bool: True if successful Raises: :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure asyncio.TimeoutError: Command timed out - @todo This function returns true even if it fails, because the server tells us it succeeds before it actually knows, then later tells us it failed, but it's hard to find that because it looks exactly like a success. + @todo This function returns True even if it fails, because the server tells us it succeeds before it actually knows, then later tells us it failed, but it's hard to find that because it looks exactly like a success. ''' - raise NotImplementedError() + if isinstance(nodeids, str): + nodeids = [nodeids] - def interuser(self, data, session=None, user=None): + data = self._send_command({ "action": 'toast', "nodeids": nodeids, "title": "MeshCentral", "msg": message }, "device_toast", timeout) + + if data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + + return True + + @util._check_socket + async def interuser(self, data, session=None, user=None): ''' Fire off an interuser message. This is a fire and forget api, we have no way of checking if the user got the message. + User will recieve an :py:class:`~meshctrl.types.InteruserMessage` if they are allowed to receive interuser messages from you. Args: data (serializable): Any sort of serializable data you want to send to the user @@ -1097,24 +1753,36 @@ class Session(object): ValueError: Value error if neither user nor session are given. :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure ''' - raise NotImplementedError() + if session is None and user is None: + raise ValueError("No user or session given") + await self._message_queue.put(json.dumps({"action": "interuser", "data": data, "sessionid": session, "userid": user})) - async def upload(self, nodeid, source, target, unique_file_tunnel=False): + async def upload(self, nodeid, source, target, unique_file_tunnel=False, timeout=None): ''' Upload a stream to a device. This creates an _File and destroys it every call. If you need to upload multiple files, use {@link Session#file_explorer} instead. Args: nodeid (str): Unique id to upload stream to - source (ReadableStream): ReadableStream from which to read data + source (io.IOBase): An IO instance from which to read the data. Must be open for reading. target (str): Path which to upload stream to on remote device unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` - Returns: - Promise: {result: bool whether upload succeeded, size: number of bytes uploaded} - ''' - raise NotImplementedError() + Raises: + :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property + :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property - async def upload_file(self, nodeid, filepath, target, unique_file_tunnel=False): + Returns: + dict: {result: bool whether upload succeeded, size: number of bytes uploaded} + ''' + if unique_file_tunnel: + async with self.file_explorer(nodeid) as files: + return await files.upload(source, target) + else: + files = await self._cached_file_explorer(nodeid, nodeid) + return await files.upload(source, target, timeout=timeout) + + + async def upload_file(self, nodeid, filepath, target, unique_file_tunnel=False, timeout=None): ''' Friendly wrapper around :py:class:`~meshctrl.session.Session.upload` to upload from a filepath. Creates a ReadableStream and calls upload. @@ -1124,30 +1792,45 @@ class Session(object): target (str): Path which to upload file to on remote device unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` + Raises: + :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property + :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property + Returns: dict: {result: bool whether upload succeeded, size: number of bytes uploaded} ''' - raise NotImplementedError() + with open(filepath, "rb") as f: + return await self.upload(nodeid, f, target, unique_file_tunnel, timeout=timeout) - async def download(self, nodeid, source, target=None, unique_file_tunnel=False): + async def download(self, nodeid, source, target=None, unique_file_tunnel=False, timeout=None): ''' Download a file from a device into a writable stream. This creates an :py:class:`~meshctrl.files.Files` and destroys it every call. If you need to upload multiple files, use :py:class:`~meshctrl.session.Session.file_explorer` instead. Args: nodeid (str): Unique id to download file from source (str): Path from which to download from device - target (WritableStream): Stream to which to write data. If None, create new PassThrough stream which is both readable and writable. + target (io.IOBase): Stream to which to write data. If None, create new BytesIO which is both readable and writable. unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` + Raises: + :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property + :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property + Returns: - WritableStream: The stream which has been downloaded into + io.IOBase: The stream which has been downloaded into. Cursor will be at the end of the stream. + ''' + if target is None: + target = io.BytesIO() + if unique_file_tunnel: + async with self.file_explorer(nodeid) as files: + await files.download(source, target) + return target + else: + files = await self._cached_file_explorer(nodeid, nodeid) + await files.download(source, target, timeout=timeout) + return target - Raises: - Exception: String showing the intermediate outcome and how many bytes were downloaded - ''' - raise NotImplementedError() - - async def download_file(self, nodeid, source, filepath, unique_file_tunnel=False): + async def download_file(self, nodeid, source, filepath, unique_file_tunnel=False, timeout=None): ''' Friendly wrapper around :py:class:`~meshctrl.session.Session.download` to download to a filepath. Creates a WritableStream and calls download. @@ -1157,20 +1840,32 @@ class Session(object): filepath (str): Path to which to download data unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` - Returns: - WritableStream: The stream which has been downloaded into - ''' - raise NotImplementedError() + Raises: + :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property + :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property - async def file_explorer(self, nodeid, unique=False): + Returns: + io.IOBase: The stream which has been downloaded into. Cursor will be at the end of the stream. + ''' + with open(filepath, "wb") as f: + return await self.download(nodeid, source, f, unique_file_tunnel, timeout=timeout) + + async def _cached_file_explorer(self, nodeid, _id): + if (_id not in self._file_tunnels or not self._file_tunnels[_id].alive): + self._file_tunnels[_id] = self.file_explorer(nodeid) + await self._file_tunnels[_id].initialized.wait() + return self._file_tunnels[_id] + + def file_explorer(self, nodeid): ''' Create, initialize, and return an :py:class:`~meshctrl.files.Files` object for the given node Args: nodeid (str): Unique id on which to open file explorer - unique (bool): True: Create a unique :py:class:`~meshctrl.files.Files`. Caller is responsible for cleanup. False: Use a cached :py:class:`~meshctrl.files.Files` if available, otherwise create and cache. Returns: :py:class:`~meshctrl.files.Files`: A newly initialized file explorer. ''' - raise NotImplementedError() \ No newline at end of file + return files.Files(self, nodeid) + + \ No newline at end of file diff --git a/src/meshctrl/shell.py b/src/meshctrl/shell.py index 0fbd158..d504495 100644 --- a/src/meshctrl/shell.py +++ b/src/meshctrl/shell.py @@ -1,7 +1,191 @@ from . import tunnel +from . import constants +from . import util +import io +import time +import json +import re +import asyncio + +class _BufferPipe(io.BufferedRandom): + '''Class to approximate an os pipe in software. Feel like I'm an idiot and just can't find out how to do this with beffered readers, but here we are...''' + + def __init__(self, *args, **kwargs): + _buffer = io.BytesIO() + super().__init__(_buffer, *args, **kwargs) + self._read_pointer = 0 + self._write_pointer = 0 + + def peek(self, *args, **kwargs): + self.seek(self._read_pointer) + d = super().peek(*args, **kwargs) + self._read_pointer = self.tell() + return d + + def read(self, *args, **kwargs): + self.seek(self._read_pointer) + d = super().read(*args, **kwargs) + self._read_pointer = self.tell() + return d + + def read1(self, *args, **kwargs): + self.seek(self._read_pointer) + d = super().read1(*args, **kwargs) + self._read_pointer = self.tell() + return d + + def write(self, *args, **kwargs): + self.seek(self._write_pointer) + d = super().write(*args, **kwargs) + self._write_pointer = self.tell() + return d class Shell(tunnel.Tunnel): - pass + def __init__(self, session, nodeid): + super().__init__(session, nodeid, constants.Protocol.TERMINAL) + self.recorded = None + self._buffer = _BufferPipe() -class SmartShell(tunnel.Tunnel): - pass \ No newline at end of file + + @util._check_socket + async def write(self, command): + """ + Write to the shell + + Args: + command (str): Command to send + + Returns: + None + """ + return await self._message_queue.put(command.encode("utf-8")) + + @util._check_socket + async def read(self, length=None, block=True, timeout=None): + """ + Read data from the shell + + Args: + length (int): Number of bytes to read. None == read until closed or timeout occurs. + block (bool): block until n bytes are available or timeout occurs. If not, read at most until no data is returned. This may return an indeterminate amount of data. + timeout (int): Milliseconds to wait for data. None == read until `length` bytes are read, or shell is closed. + + Returns: + str: Data read. In the case of timeout, this will return all data read up to the timeout + """ + start = time.time() + ret = [] + read_bytes = 0 + while True: + d = self._buffer.read1(length-read_bytes if length is not None else -1) + # print(f"read: {d}") + read_bytes += len(d) + ret.append(d) + if length is not None and read_bytes >= length: + break + if timeout is not None and time.time() - start >= timeout: + break + if not block and not len(d): + break + await asyncio.sleep(0) + return b"".join(ret).decode("utf-8") + + @util._check_socket + async def expect(self, regex, timeout=None): + """ + Read data from the shell until `regex` is seen + + Args: + regex (str|re.Pattern): Regex to check for match + timeout (int): Milliseconds to wait for data. None == read until `length` bytes are read, or shell is closed. + + Returns: + str: Data read. + + Raises: + asyncio.TimeoutError: Regex not matched within timeout + """ + start = time.time() + read_bytes = 0 + if not isinstance(regex, re.Pattern): + regex = re.compile(regex) + while True: + d = self._buffer.peek().decode("utf-8") + match = regex.search(d) + if match is not None: + read_bytes = match.span()[1] + break + if timeout is not None and time.time() - start >= timeout: + raise asyncio.TimeoutError + await asyncio.sleep(0) + return await self.read(read_bytes) + + async def _listen_data_task(self, websocket): + async for message in websocket: + if self.initialized.is_set(): + if message.startswith(b'{"ctrlChannel":"102938","type":"'): + try: + ctrl_cmd = json.loads(message) + # Skip control commands, like ping/pong + if ctrl_cmd.get("type", None) is not None: + return + except: + pass + self._buffer.write(message) + else: + self.recorded = False + if message == "cr": + self.recorded = True + + # Seems like we could use self.write here, but it won't have been initialized yet, so the socket check will fail. + await self._message_queue.put(f"{self._protocol}".encode()) + self.alive = True + self.initialized.set() + + + +class SmartShell(object): + def __init__(self, shell, regex): + self._shell = shell + self._regex = regex + self._compiled_regex = re.compile(self._regex) + self._init_task = asyncio.create_task(self._init()) + + async def _init(self): + # This comes twice. Test this for sanity. Seems meshcentral does some aliases when it logs in. Could be wrong on windows. + await self._shell.expect(self._regex) + await self._shell.expect(self._regex) + + + @util._check_socket + async def send_command(self, command, timeout=None): + if not command.endswith("\n"): + command += "\n" + await self._shell.write(command) + data = await self._shell.expect(self._regex, timeout=timeout) + print(repr(data)) + return data[:self._compiled_regex.search(data).span()[0]] + + @property + def alive(self): + return self._shell.alive + + @property + def closed(self): + return self._shell.closed + + @property + def initialized(self): + return self._shell.initialized + + async def close(self): + await self._init_task + return await self._shell.close() + + async def __aenter__(self): + await self._init_task + await self._shell.__aenter__() + return self + + async def __aexit__(self, *args): + return await self._shell.__aexit__(*args) diff --git a/src/meshctrl/tunnel.py b/src/meshctrl/tunnel.py index 2098af1..67fd3be 100644 --- a/src/meshctrl/tunnel.py +++ b/src/meshctrl/tunnel.py @@ -1,2 +1,107 @@ +import websockets +import websockets.datastructures +import websockets.asyncio +import websockets.asyncio.client +import asyncio +import ssl +from . import exceptions +from . import util +from . import constants + class Tunnel(object): - pass \ No newline at end of file + def __init__(self, session, node_id, protocol): + self._session = session + self.node_id = node_id + self._protocol = protocol + self._tunnel_id = None + self.url = None + self._socket_open = asyncio.Event() + self._main_loop_error = None + self.initialized = asyncio.Event() + self.alive = False + self.closed = asyncio.Event() + self._main_loop_task = asyncio.create_task(self._main_loop()) + + self._message_queue = asyncio.Queue() + self._send_task = None + self._listen_task = None + + async def close(self): + self._main_loop_task.cancel() + try: + await self._main_loop_task + except asyncio.CancelledError: + pass + + async def __aenter__(self): + # If we take more than 10 seconds to establish a tunnel, something is up. + await asyncio.wait_for(self.initialized.wait(), 10) + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + await self.close() + + async def _main_loop(self): + try: + authcookie = await self._session._send_command_no_response_id({ "action":"authcookie" }) + + options = {} + if self._session._ignore_ssl: + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + options = { "ssl": ssl_context } + + # Setup the HTTP proxy if needed + # if (self._session._proxy != None): + # options.agent = new https_proxy_agent(urllib.parse(this._proxy)) + + if (self.node_id.split('/') != 3) and (self._session._currentDomain is not None): + self.node_id = f"node/{self._session._currentDomain}/{self.node_id}" + + self._tunnel_id = util._get_random_hex(6) + + initialize_tunnel_response = await self._session._send_command({ "action": 'msg', "nodeid": self.node_id, "type": 'tunnel', "usage": 1, "value": '*/meshrelay.ashx?p=' + str(self._protocol) + '&nodeid=' + self.node_id + '&id=' + self._tunnel_id + '&rauth=' + authcookie["rcookie"] }, "initialize_tunnel") + + if initialize_tunnel_response.get("result", None) != "OK": + self._main_loop_error = exceptions.ServerError(initialize_tunnel_response.get("result", "Failed to initialize remote tunnel")) + self._socket_open.clear() + self.closed.set() + self.initialized.set() + return + + self.url = self._session.url.replace('/control.ashx', '/meshrelay.ashx?browser=1&p=' + str(self._protocol) + '&nodeid=' + self.node_id + '&id=' + self._tunnel_id + '&auth=' + authcookie["cookie"]) + + # headers = websockets.datastructures.Headers() + + # if (self._password): + # token = self._token if self._token else b"" + # headers['x-meshauth'] = (base64.b64encode(self._user.encode()) + b',' + base64.b64encode(self._password.encode()) + token).decode() + + # options["additional_headers"] = headers + async for websocket in websockets.asyncio.client.connect(self.url, process_exception=util._process_websocket_exception, **options): + self.alive = True + self._socket_open.set() + try: + async with asyncio.TaskGroup() as tg: + tg.create_task(self._listen_data_task(websocket)) + tg.create_task(self._send_data_task(websocket)) + except* websockets.ConnectionClosed as e: + self._socket_open.clear() + if not self.auto_reconnect: + self.alive = False + raise + except* Exception as eg: + self.alive = False + self._socket_open.clear() + self._main_loop_error = eg + self.closed.set() + self.initialized.set() + + async def _send_data_task(self, websocket): + while True: + message = await self._message_queue.get() + await websocket.send(message) + + async def _listen_data_task(self, websocket): + raise NotImplementedError("Listen data not implemented") \ No newline at end of file diff --git a/src/meshctrl/types.py b/src/meshctrl/types.py new file mode 100644 index 0000000..d38fb65 --- /dev/null +++ b/src/meshctrl/types.py @@ -0,0 +1,191 @@ +''' +This module attempts to define the various structures of objects returned by meshcentral calls. +These types are expected, but not guarunteed, as the meshcentral API is not well defined. +''' + +import typing +import collections.abc +from . import constants + +class UserLink(typing.TypedDict): + ''' + Represents a link to a user account + ''' + + rights: constants.MeshRights|constants.DeviceRights + '''User's rights on the containing object''' + + name: str + '''Username of the user this link references''' + +class ListUsersResponseItem(typing.TypedDict): + ''' + Item contained in response from :py:func:`~meshctrl.session.Session.list_users` + ''' + + _id: str + '''User's ID''' + + name: str + '''User's username''' + + creation: int + '''When the user was created. UTC timestamp''' + + links: dict[str, list[UserLink]] + '''Set of links connected to this user. str is the ID of the thing referenced, the type of which is hinted at by the beginning of the string. These describe numerous things, the enumeration of which is beyond this documentation''' + +class AddUsersToDeviceGroupResponse(typing.TypedDict): + ''' + Response proffered when a user is added to a device group + ''' + + success: bool + '''Whether the user was added successfully''' + + message: str + '''Any interesting information about adding the user. "Added user {username}" on success, otherwise defined by server''' + +class RemoveUsersFormDeviceGroupResponse(AddUsersToDeviceGroupResponse): + ''' + Response proffered when a user is removed from a device group + ''' + pass + +class DeviceGroup(typing.TypedDict): + ''' + Device group information + ''' + + meshid: str + '''ID of the created device group''' + + links: dict[str, list[UserLink]] + '''Users of this device group, and their rights''' + +class Agent(typing.TypedDict): + ''' + Information about an agent running on a machine + ''' + + version: int + '''Agent version''' + + id: constants.AgentType + '''Type of agent this is''' + + capabilities: constants.AgentCapabilities + '''Capabilities of this agent. This can change over time based on the connection state of the agent''' + +class AddDeviceGroupResponse(typing.TypedDict): + ''' + Response proffered when device group is added + ''' + + meshid: str + '''ID of the created device group''' + + links: dict[str, list[UserLink]] + '''Users of this device group, and their rights''' + +class RunCommandResponse(typing.TypedDict): + ''' + Response item from run_command execution + ''' + + complete: bool + '''Whether the command completed correctly''' + + command: str + '''The command which was run''' + + result: str + '''Output of command''' + +class AddUsersToUserGroupResponse(typing.TypedDict): + ''' + Response item from add_users_to_user_group execution + ''' + + success: bool + '''Whether the user was added successfully''' + + message: str + '''Message from server if user was not added correctly''' + +class LoginToken(typing.TypedDict): + ''' + Login token created on the server + ''' + + name: str + '''Name of the token''' + + tokenUser: str + '''Username substitute of the token''' + + tokenPass: str + '''Password substitute for the token''' + + created: int + '''UTC timestamp representing when the token was created. In milliseconds''' + + expire: int + '''UTC timestamp representing when the token expires. In milliseconds. 0 means no expirery.''' + +class RetrievedLoginToken(typing.TypedDict): + ''' + Login token created on the server and retrieved. This will not include the password. + ''' + + name: str + '''Name of the token''' + + tokenUser: str + '''Username substitute of the token''' + + created: int + '''UTC timestamp representing when the token was created. In milliseconds''' + + expire: int + '''UTC timestamp representing when the token expires. In milliseconds. 0 means no expirery.''' + +class InteruserMessage(typing.TypedDict): + ''' + Message received from another user through interuser messaging + ''' + + action: "interuser" + '''Identifier of event type''' + + sessionid: str + '''Session from which the message originated''' + + data: str + '''Any data the user sent''' + + scope: constants.InteruserScope + '''"user" if the message was sent to your username, "session" if it was sent to this specific session.''' + +class FilesLSItem(typing.TypedDict): + ''' + Dict representing a file or directory on a mesh device, as returned from meshcentral server + ''' + + n: str + '''Name of file or dir''' + + d: str + '''UTC timestamp for when the file/directory was edited''' + + t: constants.FileType + '''Type of file''' + + dt: typing.Optional[str] + '''Drive type, in the case t == :py:const:`~meshctrl.constants.FileType.DRIVE`''' + + s: typing.Optional[int] + '''Size of the file if t == :py:const:`~meshctrl.constants.FileType.FILE`''' + + f: typing.Optional[int] + '''Free bytes on the drive, if t == :py:const:`~meshctrl.constants.FileType.DRIVE`''' \ No newline at end of file diff --git a/src/meshctrl/user_group.py b/src/meshctrl/user_group.py new file mode 100644 index 0000000..4acd403 --- /dev/null +++ b/src/meshctrl/user_group.py @@ -0,0 +1,77 @@ +from . import constants +import datetime + +class UserGroup(object): + ''' + Object to represent a user group. This object is a rough wrapper; it is not guarunteed to be up to date with the state on the server, for instance. + + Args: + ugrpid (str): id of the user group on the server + session (~meshctrl.session.Session): Parent session used to run commands + name (str|None): Mesh name as it is shown on the meshcentral server + description (str|None): Mesh description as it is shown on the meshcentral server. Also accepted as desc. + domain (str|None): Domain on server to which device is connected. + links (dict[str, ~meshctrl.types.UserLink]|None): Collection of links for the device group + + Returns: + :py:class:`Mesh`: Object representing a device group on the meshcentral server. + + Attributes: + ugrpid (str): id of the device mesh on the server + name (str|None): Mesh name as it is shown on the meshcentral server + description (str|None): Mesh description as it is shown on the meshcentral server + domain (str|None): Domain on server to which device is connected. + links (dict[str, ~meshctrl.types.UserLink]|None): Collection of links for the device group + ''' + def __init__(self, ugrpid, session, name=None, + desc=None, description=None, + domain=None, links=None, **kwargs): + self.ugrpid = ugrpid + self._session = session + if links is None: + links = {} + self.links = links + self.name = name + self.description = description if description is not None else desc + self.domain = domain + # In case meshcentral gives us props we don't understand, store them here. + self._extra_props = kwargs + + @property + def short_ugrpid(self): + ''' + ugrpid without "ugrp/" or the included domain + ''' + return self.ugrpid.split("/")[-1] + + @property + def id(self): + ''' + Alias to "ugrpid" to be consistent accross types. + ''' + return self.ugrpid + + async def add_users(self, userids, timeout=None): + ''' + Add a user to an existing mesh + + Args: + userids (str|list[str]): Unique user id(s) + rights (~meshctrl.constants.MeshRights): Bitwise mask for the rights to give to the users + timeout (int): duration in milliseconds to wait for a response before throwing an error + + Returns: + dict[str, ~meshctrl.types.AddUsersToDeviceGroupResponse]: Object showing which were added correctly and which were not, along with their result messages. str is userid to map response. + + Raises: + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + asyncio.TimeoutError: Command timed out + ''' + return await self._session.add_users_to_user_group(userids, self.groupid, isname=False, domain=self.domain, timeout=timeout) + + def __str__(self): + return f"" + def __repr__(self): + return f"UserGroup(ugrpid={repr(self.ugrpid)}, session={repr(self._session)}, name={repr(self.name)}, description={repr(self.description)}, "\ + f"domain={repr(self.domain)}, links={repr(self.links)}, **{repr(self._extra_props)})" \ No newline at end of file diff --git a/src/meshctrl/util.py b/src/meshctrl/util.py index 4b65bda..5d6f772 100644 --- a/src/meshctrl/util.py +++ b/src/meshctrl/util.py @@ -3,13 +3,36 @@ import time from cryptography.hazmat.primitives.ciphers.aead import AESGCM import json import base64 +import asyncio +import collections +import re +import websockets +import ssl +import functools +from . import exceptions def _encode_cookie(o, key): o["time"] = int(time.time()); # Add the cookie creation time iv = secrets.token_bytes(12) key = AESGCM(key) crypted = key.encrypt(iv, json.dumps(o), None) - return base64.b64encode(crypted).replace("+", '@').replace("/", '$'); + return base64.b64encode(crypted).replace(b"+", b'@').replace(b"/", b'$').decode("utf-8") + +def _check_amt_password(p): + return (len(p) > 7) and\ + (re.search(r"\d",p) is not None) and\ + (re.search(r"[a-z]",p) is not None) and\ + (re.search(r"[A-Z]",p) is not None) and\ + (re.search(r"\W",p) is not None) + +def _get_random_amt_password(): + p = "" + while not _check_amt_password(p): + p = b"@".join(base64.b64encode(secrets.token_bytes(9)).split(b'/')).decode("utf-8") + return p + +def _get_random_hex(count): + return secrets.token_bytes(count).hex(); class Eventer(object): """ @@ -56,7 +79,7 @@ class Eventer(object): except KeyError: pass - def emit(self, event, data): + async def emit(self, event, data): """ Emit `event` with `data`. All subscribed functions will be called (order is nonsensical). @@ -65,11 +88,68 @@ class Eventer(object): data (object): Data to pass to all the bound functions """ for f in self._onces.get(event, []): - f(data) + await f(data) try: del self._onces[event] except KeyError: pass for f in self._ons.get(event, []): - f(data) - \ No newline at end of file + await f(data) + +def compare_dict(dict1, dict2): + try: + if dict1 == dict2: + return True + for key, val in dict1.items(): + if key not in dict2: + return False + + if type(val) is dict: + if not compare_dict(val, dict2[key]): + return False + elif type(val) is set: + for v in val: + for v2 in dict2[key]: + try: + if compare_dict(v, v2): + break + except: + pass + else: + return False + elif isinstance(val, collections.abc.Iterable): + # We don't want strings to match other iterables, so check that + if isinstance(val, str) or isinstance(dict2[key], str): + if not isinstance(val, type(dict2[key])): + return False + try: + if (len(val) != len(dict2[key])): + return False + for i, v in enumerate(val): + if not compare_dict(v, dict2[key][i]): + return False + except Exception as e: + return False + elif (dict2[key] != val): + return False + return True + except Exception: + return False + +def _check_socket(f): + @functools.wraps(f) + async def wrapper(self, *args, **kwargs): + await self.initialized.wait() + if not self.alive and self._main_loop_error is not None: + raise self._main_loop_error + elif not self.alive: + raise exceptions.SocketError("Socket Closed") + return await f(self, *args, **kwargs) + return wrapper + +def _process_websocket_exception(exc): + tmp = websockets.asyncio.client.process_exception(exc) + # SSLVerification error is a subclass of OSError, but doesn't make sense no retry, so we need to handle it separately. + if isinstance(exc, (ssl.SSLCertVerificationError, TimeoutError)): + return exc + return tmp diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..249cda9 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +/data \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index e8a5039..a8c254d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,3 +8,7 @@ """ # import pytest + +pytest_plugins = [ + "tests.environment" +] \ No newline at end of file diff --git a/tests/environment/__init__.py b/tests/environment/__init__.py new file mode 100644 index 0000000..82b200d --- /dev/null +++ b/tests/environment/__init__.py @@ -0,0 +1,108 @@ +import os +import base64 +import subprocess +import time +import json +import atexit +import pytest +import requests +thisdir = os.path.abspath(os.path.dirname(__file__)) + +# os.environ["BUILDKIT_PROGRESS"] = "plain" + + +USERNAMES = ["admin", "privileged", "unprivileged"] +global users +users = None + +def create_env(): + global users + if users is not None: + return users + users = {} + for username in USERNAMES: + password = base64.b64encode(os.urandom(24)).decode() + users[username] = password + with open(os.path.join(thisdir, "scripts", "meshcentral", "users.json"), "w") as outfile: + json.dump(users, outfile) + return users + +global _docker_process +_docker_process = None + +class Agent(object): + def __init__(self, meshid, mcurl, clienturl, dockerurl): + self.meshid = meshid + self._mcurl = mcurl + 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"] + + def __enter__(self): + return self + + def __exit__(self, exc_t, exc_v, exc_tb): + try: + requests.post("{self._clienturl}/remove-agent/{self.nodeid}") + except: + pass + +class TestEnvironment(object): + def __init__(self): + self.users = create_env() + self._subp = None + self.mcurl = "wss://localhost:8086" + self.clienturl = "http://localhost:5000" + self._dockerurl = "host.docker.internal:8086" + + def __enter__(self): + global _docker_process + if _docker_process is not None: + self._subp = _docker_process + return self + self._subp = _docker_process = subprocess.Popen(["docker", "compose", "up", "--build", "--force-recreate", "--no-deps"], stdout=subprocess.DEVNULL, cwd=thisdir) + timeout = 30 + start = time.time() + while time.time() - start < timeout: + try: + data = subprocess.check_output(["docker", "inspect", "meshctrl-meshcentral", "--format='{{json .State.Health}}'"], cwd=thisdir, stderr=subprocess.DEVNULL) + # docker outputs for humans, not computers. This is the easiest way to chop off the ends + data = json.loads(data.strip()[1:-1]) + except Exception as e: + time.sleep(1) + continue + try: + if data["Status"] == "healthy": + break + except: + pass + time.sleep(1) + else: + self.__exit__(None, None, None) + raise Exception("Failed to create docker instance") + return self + + + def __exit__(self, exc_t, exc_v, exc_tb): + pass + + def create_agent(self, meshid): + return Agent(meshid, self.mcurl, self.clienturl, self._dockerurl) + +def _kill_docker_process(): + if _docker_process is not None: + _docker_process.kill() + subprocess.run(["docker", "compose", "down"], cwd=thisdir) + +atexit.register(_kill_docker_process) + +@pytest.fixture(scope="session") +def env(): + with TestEnvironment() as e: + yield e + + +if __name__ == "__main__": + with TestEnvironment() as env: + input("it's up") \ No newline at end of file diff --git a/tests/environment/client.dockerfile b/tests/environment/client.dockerfile new file mode 100644 index 0000000..2016d3c --- /dev/null +++ b/tests/environment/client.dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12 +WORKDIR /usr/local/app + +# Install the application dependencies +# COPY requirements.txt ./ + + +# Copy in the source code +COPY scripts/client ./scripts +RUN pip install --no-cache-dir -r ./scripts/requirements.txt +EXPOSE 5000 + +# Setup an app user so the container doesn't run as the root user +RUN useradd app +USER app +WORKDIR /usr/local/app/scripts + +CMD ["python3", "-m", "flask", "--app", "agent_server", "run", "--host=0.0.0.0", "--debug"] \ No newline at end of file diff --git a/tests/environment/compose.yaml b/tests/environment/compose.yaml new file mode 100644 index 0000000..263ba07 --- /dev/null +++ b/tests/environment/compose.yaml @@ -0,0 +1,52 @@ +networks: + meshctrl: + driver: bridge + +services: + client: + restart: unless-stopped + container_name: meshctrl-client + image: client + build: + dockerfile: client.dockerfile + ports: + - 5000:5000 + depends_on: + - meshcentral + environment: + TZ: US/LosAngeles + # volumes: + # # mongodb data-directory - A must for data persistence + # - ./meshcentral/mongodb_data:/data/db + networks: + - meshctrl + extra_hosts: + - "host.docker.internal:host-gateway" + + meshcentral: + restart: always + container_name: meshctrl-meshcentral + # use the official meshcentral container + image: meshcentral + build: + dockerfile: meshcentral.dockerfile + ports: + # MeshCentral will moan and try everything not to use port 80, but you can also use it if you so desire, just change the config.json according to your needs + - 8086:443 + environment: + TZ: US/LosAngeles + #volumes: + # config.json and other important files live here. A must for data persistence + #- ./meshcentral/data:/opt/meshcentral/meshcentral-data + # where file uploads for users live + #- ./meshcentral/user_files:/opt/meshcentral/meshcentral-files + # location for the meshcentral-backups - this should be mounted to an external storage + #- ./meshcentral/backup:/opt/meshcentral/meshcentral-backups + # location for site customization files + #- ./meshcentral/web:/opt/meshcentral/meshcentral-web + networks: + - meshctrl + healthcheck: + test: curl -k --fail https://localhost:443/ || exit 1 + interval: 5s + timeout: 120s \ No newline at end of file diff --git a/tests/environment/meshcentral.dockerfile b/tests/environment/meshcentral.dockerfile new file mode 100644 index 0000000..414dcc3 --- /dev/null +++ b/tests/environment/meshcentral.dockerfile @@ -0,0 +1,7 @@ +FROM ghcr.io/ylianst/meshcentral:latest +RUN apk add curl +RUN apk add python3 +WORKDIR /opt/meshcentral/ +COPY ./scripts/meshcentral ./scripts +COPY ./meshcentral/data /opt/meshcentral/meshcentral-data +CMD ["python3", "/opt/meshcentral/scripts/create_users.py"] \ No newline at end of file diff --git a/tests/environment/meshcentral/data/config.json b/tests/environment/meshcentral/data/config.json new file mode 100644 index 0000000..91a612e --- /dev/null +++ b/tests/environment/meshcentral/data/config.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://raw.githubusercontent.com/Ylianst/MeshCentral/master/meshcentral-config-schema.json", + "settings": { + "plugins":{"enabled": false}, + "_mongoDb": null, + "cert": "host.docker.internal", + "_WANonly": true, + "_LANonly": true, + "port": 443, + "aliasPort": 8086, + "redirPort": 80, + "_redirAliasPort": 80, + "AgentPong": 300, + "TLSOffload": false, + "SelfUpdate": false, + "AllowFraming": false, + "WebRTC": false, + "interUserMessaging": true + }, + "domains": { + "": { + "_title": "MyServer", + "_title2": "Servername", + "minify": true, + "NewAccounts": false, + "localSessionRecording": false, + "_userNameIsEmail": true, + "_certUrl": "my.reverse.proxy", + "allowedOrigin": true + } + }, + "_letsencrypt": { + "__comment__": "Requires NodeJS 8.x or better, Go to https://letsdebug.net/ first before>", + "_email": "myemail@mydomain.com", + "_names": "myserver.mydomain.com", + "production": false + } +} \ No newline at end of file diff --git a/tests/environment/scripts/client/agent_server.py b/tests/environment/scripts/client/agent_server.py new file mode 100644 index 0000000..1ee8639 --- /dev/null +++ b/tests/environment/scripts/client/agent_server.py @@ -0,0 +1,68 @@ +from flask import Flask, json, request +import requests +import tempfile +import base64 +import os +import subprocess +import time + +AGENT_URL_TEMPLATE = "https://{}/meshagents?id=6" +SETTINGS_URL_TEMPLATE = "https://{}/meshsettings?id={}" + +api = Flask(__name__) + +agents = {} + +# if not os.path.exists(os.path.join(mesh_dir, "meshagent")): +# os.makedirs(meshtemp) +# subprocess.check_call(["wget", AGENT_URL, "-O", os.path.join(meshtemp, "meshagent")]) +# subprocess.check_call(["wget", SETTINGS_URL, "-O", os.path.join(meshtemp, "meshagent.msh")]) +# subprocess.check_call(["chmod", "+x", os.path.join(meshtemp, "meshagent")]) +# shutil.copytree(meshtemp, mesh_dir) +# subprocess.check_call(["chown", "-R", f"{user}:{user}", mesh_dir]) + +@api.route('/add-agent', methods=['POST']) +def add_agent(): + api.logger.info("text") + AGENT_URL = AGENT_URL_TEMPLATE.format(request.json["url"]) + SETTINGS_URL = SETTINGS_URL_TEMPLATE.format(request.json["url"], request.json["meshid"]) + d = tempfile.mkdtemp() + agent_path = os.path.join(d, "meshagent") + msh_path = os.path.join(d, "meshagent.msh") + with open(agent_path, "wb") as outfile: + for chunk in requests.get(AGENT_URL, stream=True, verify=False).iter_content(chunk_size=16*1024): + outfile.write(chunk) + with open(msh_path, "wb") as outfile: + for chunk in requests.get(SETTINGS_URL, stream=True, verify=False).iter_content(chunk_size=16*1024): + outfile.write(chunk) + os.chmod(agent_path, 0o0777) + os.chmod(msh_path, 0o0777) + # Generates a certificate if we don't got one + subprocess.call([agent_path, "-connect"]) + agent_hex = subprocess.check_output([agent_path, '-exec', "console.log(require('_agentNodeId')());process.exit()"]).strip().decode() + agent_id = base64.b64encode(bytes.fromhex(agent_hex)).decode().replace("+", "@").replace("/", "$") + p = subprocess.Popen(["stdbuf", "-o0", agent_path, "connect"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=d) + agents[agent_id] = {"process": p, "id": agent_id} + text = "" + start = time.time() + while time.time() - start < 5: + text += p.stdout.read1().decode("utf-8") + api.logger.info(text) + if "Connected." in text: + break + time.sleep(.1) + else: + raise Exception(f"Failed to start agent: {text}") + return {"id": agent_id} + +@api.route('/remove-agent/', methods=['POST']) +def remove_agent(agentid): + agents[agentid]["process"].kill() + return "" + +@api.route('/', methods=['GET']) +def slash(): + return [_["id"] for _ in agents] + +if __name__ == '__main__': + api.run() \ No newline at end of file diff --git a/tests/environment/scripts/client/requirements.txt b/tests/environment/scripts/client/requirements.txt new file mode 100644 index 0000000..5eaf725 --- /dev/null +++ b/tests/environment/scripts/client/requirements.txt @@ -0,0 +1,2 @@ +flask +requests \ No newline at end of file diff --git a/tests/environment/scripts/meshcentral/create_users.py b/tests/environment/scripts/meshcentral/create_users.py new file mode 100644 index 0000000..76263cf --- /dev/null +++ b/tests/environment/scripts/meshcentral/create_users.py @@ -0,0 +1,15 @@ + +import os +import subprocess +import json +thisdir = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(thisdir, "users.json")) as infile: + users = json.load(infile) +for username, password in users.items(): + subprocess.check_output(["node", "/opt/meshcentral/meshcentral", "--createaccount", username, "--pass", password, "--name", username]) + + +subprocess.check_output(["node", "/opt/meshcentral/meshcentral", "--adminaccount", "admin"]) + +subprocess.call(["bash", "/opt/meshcentral/startup.sh"]) \ No newline at end of file diff --git a/tests/environment/scripts/meshcentral/users.json b/tests/environment/scripts/meshcentral/users.json new file mode 100644 index 0000000..b05a61d --- /dev/null +++ b/tests/environment/scripts/meshcentral/users.json @@ -0,0 +1 @@ +{"admin": "3U6zP4iIes5ISH15XxjYLjJcCdw9jU0m", "privileged": "aiIO0zLMGsU7++FYVDNxhlpYlZ1andRB", "unprivileged": "Cz9OMV1wkVd9pXdWi4lkBAAu6TMt43MA"} \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..15de8b1 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,6 @@ +requests +pytest-asyncio +cffi==1.17.1 +cryptography==43.0.3 +pycparser==2.22 +websockets==13.1 \ No newline at end of file diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..edfe605 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,112 @@ +import sys +import os +import asyncio +import meshctrl +import requests +import io +import random + +async def test_commands(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session: + mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10) + try: + with env.create_agent(mesh.short_meshid) as agent: + # Create agent isn't so good at waiting for the agent to show in the sessions. Give it a couple seconds to appear. + for i in range(3): + try: + r = await admin_session.list_devices(timeout=10) + assert len(r) == 1, "Incorrect number of agents connected" + except: + if i == 2: + raise + await asyncio.sleep(1) + else: + break + + pwd = (await admin_session.run_command(agent.nodeid, "pwd", timeout=10))[agent.nodeid]["result"].strip() + + async with admin_session.file_explorer(agent.nodeid) as files: + # Test mkdir + print("\ninfo files_mkdir: {}\n".format(await files.mkdir(f"{pwd}/test", timeout=5))) + fs = await files.ls(pwd, timeout=5) + # Test ls + print("\ninfo files_ls: {}\n".format(fs)) + for f in fs: + if f["n"] == "test" and f["t"] == 2: + break + else: + raise Exception("Created directory not found") + + print("\ninfo files_rename: {}\n".format(await files.rename(pwd, "test", "test2", timeout=5))) + + for f in await files.ls(pwd, timeout=5): + if f["n"] == "test2" and f["t"] == 2: + break + else: + raise Exception("renamed directory not found") + + print("\ninfo files_rm: {}\n".format(await files.rm(pwd, f"test2", recursive=False, timeout=5))) + for f in await files.ls(pwd, timeout=5): + if f["n"] in ["test","test2"]: + raise Exception("Deleted directory found") + finally: + assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" + +async def test_upload_download(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session: + mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10) + try: + with env.create_agent(mesh.short_meshid) as agent: + # Create agent isn't so good at waiting for the agent to show in the sessions. Give it a couple seconds to appear. + for i in range(3): + try: + r = await admin_session.list_devices(timeout=10) + assert len(r) == 1, "Incorrect number of agents connected" + except: + if i == 2: + raise + await asyncio.sleep(1) + else: + break + + randdata = random.randbytes(2000000) + upfilestream = io.BytesIO(randdata) + downfilestream = io.BytesIO() + + pwd = (await admin_session.run_command(agent.nodeid, "pwd", timeout=10))[agent.nodeid]["result"].strip() + + async with admin_session.file_explorer(agent.nodeid) as files: + r = await files.upload(upfilestream, f"{pwd}/test", timeout=5) + print("\ninfo files_upload: {}\n".format(r)) + assert r["result"] == "success", "Upload failed" + assert r["size"] == len(randdata), "Uploaded wrong number of bytes" + for f in await files.ls(pwd, timeout=5): + if f["n"] == "test" and f["t"] == meshctrl.constants.FileType.FILE: + break + else: + raise Exception("Uploaded file not found") + + upfilestream.seek(0) + + await files.upload(upfilestream, f"{pwd}", name="test2", timeout=5) + for f in await files.ls(pwd, timeout=5): + if f["n"] == "test2" and f["t"] == meshctrl.constants.FileType.FILE: + break + else: + raise Exception("Uploaded file not found") + + r = await files.download(f"{pwd}/test", downfilestream, timeout=5) + print("\ninfo files_download: {}\n".format(r)) + assert r["result"] == "success", "Domnload failed" + assert r["size"] == len(randdata), "Downloaded wrong number of bytes" + + downfilestream.seek(0) + assert downfilestream.read() == randdata, "Got wrong data back" + finally: + assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" + + + + + + diff --git a/tests/test_mesh.py b/tests/test_mesh.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_sanity.py b/tests/test_sanity.py index 62970f3..da50652 100644 --- a/tests/test_sanity.py +++ b/tests/test_sanity.py @@ -2,17 +2,22 @@ import sys import os import asyncio thisdir = os.path.dirname(os.path.realpath(__file__)) -sys.path.append(os.path.realpath(f"{thisdir}/../src")) +# sys.path.append(os.path.realpath(f"{thisdir}/../src")) import meshctrl +import ssl +import requests -async def main(argv=None): - if argv is None: - argv = sys.argv[1:] - url = argv[0] - user = argv[1] - password = argv[2] - async with meshctrl.session.Session(url, user=user, password=password) as s: - print(await s.list_device_groups(10)) +async def test_sanity(env): + async with meshctrl.session.Session(env.mcurl, user="unprivileged", password=env.users["unprivileged"], ignore_ssl=True) as s: + print("\ninfo user_info: {}\n".format(await s.user_info())) + print("\ninfo server_info: {}\n".format(await s.server_info())) + pass -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file +async def test_ssl(env): + try: + async with meshctrl.session.Session(env.mcurl, user="unprivileged", password=env.users["unprivileged"], ignore_ssl=False) as s: + pass + except* ssl.SSLCertVerificationError: + pass + else: + raise Exception("Invalid SSL certificate accepted") \ No newline at end of file diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..8ce1a97 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,420 @@ +import sys +import os +import asyncio +import meshctrl +import requests +import random +import io +thisdir = os.path.dirname(os.path.realpath(__file__)) + +async def test_admin(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session,\ + meshctrl.session.Session(env.mcurl, user="privileged", password=env.users["privileged"], ignore_ssl=True) as privileged_session: + admin_users = await admin_session.list_users(timeout=10) + print("\ninfo list_users: {}\n".format(admin_users)) + try: + no_users = await privileged_session.list_users(timeout=10) + except* meshctrl.exceptions.ServerError as e: + assert e.exceptions[0].args[0] == "Access denied" + else: + assert len(no_users.keys()) == 0, "non-admin has admin acess" + + admin_sessions = await admin_session.list_user_sessions(timeout=10) + print("\ninfo list_user_sessions: {}\n".format(admin_sessions)) + try: + no_sessions = await privileged_session.list_user_sessions(timeout=10) + except* meshctrl.exceptions.ServerError as e: + assert e.exceptions[0].args[0] == "Access denied" + else: + assert len(no_sessions.keys()) == 0, "non-admin has admin acess" + + assert len(admin_users) == len(env.users.keys()), "Admin cannot see correct number of users" + assert len(admin_sessions) == 2, "Admin cannot see correct number of oser sessions" + + +async def test_users(env): + try: + async with meshctrl.session.Session(env.mcurl[3:], user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session: + pass + except* ValueError: + pass + else: + raise Exception("Connected with bad URL") + try: + async with meshctrl.session.Session(env.mcurl, user="admin", ignore_ssl=True) as admin_session: + pass + except* meshctrl.exceptions.MeshCtrlError: + pass + else: + raise Exception("Connected with no password") + async with meshctrl.session.Session(env.mcurl+"/", user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session,\ + meshctrl.session.Session(env.mcurl, user="privileged", password=env.users["privileged"], ignore_ssl=True) as privileged_session,\ + meshctrl.session.Session(env.mcurl, user="unprivileged", password=env.users["unprivileged"], ignore_ssl=True) as unprivileged_session: + + assert len(await admin_session.list_users(timeout=10)) == 3, "Wrong number of users" + + assert await admin_session.add_user("test", "test", email="test@email.com", timeout=10), "Failed to create user" + assert await admin_session.add_user("test2", randompass=True, email="test2@email.com", emailverified=True, resetpass=True, realname="test2", phone="555555555", rights=0, timeout=10), "Failed to create user" + + try: + await unprivileged_session.add_user("nope", "nope", timeout=10) + except: + pass + else: + raise Exception("Unprivileged user created a user") + + assert len(await admin_session.list_users(timeout=10)) == 5, "Wrong number of users" + + assert await admin_session.edit_user("user//test", email="test@email.com", emailverified=True, resetpass=True, realname="test", phone="555555555", rights=0, timeout=10), "Failed to edit user" + assert await admin_session.edit_user("user//test2", email="test2@email.com", emailverified=False, timeout=10), "Failed to edit user" + + assert await admin_session.remove_user("user//test", timeout=10), "Failed to remove user" + assert await admin_session.remove_user("user//test2", timeout=10), "Failed to remove user" + + assert len(await admin_session.list_users(timeout=10)) == 3, "Failed to remove user" + +async def test_login_token(env): + async with meshctrl.session.Session(env.mcurl, user="unprivileged", password=env.users["unprivileged"], ignore_ssl=True) as s: + token = await s.add_login_token("test", expire=1, timeout=10) + print("\ninfo add_login_token: {}\n".format(token)) + + async with meshctrl.session.Session(env.mcurl, user=token["tokenUser"], password=token["tokenPass"], ignore_ssl=True) as s2: + assert (await s2.user_info())["_id"] == (await s.user_info())["_id"], "Login token logged into wrong account" + # Wait for the login token to expire + await asyncio.sleep(65) + + try: + async with meshctrl.session.Session(env.mcurl, user=token["tokenUser"], password=token["tokenPass"], ignore_ssl=True) as s2: + pass + except: + pass + else: + raise Exception("User logged in with expired token!") + + token = await s.add_login_token("test2", timeout=10) + token2 = await s.add_login_token("test3", timeout=10) + print("\ninfo add_login_token_no_expire: {}\n".format(token)) + async with meshctrl.session.Session(env.mcurl, user=token["tokenUser"], password=token["tokenPass"], ignore_ssl=True) as s2: + assert (await s2.user_info())["_id"] == (await s.user_info())["_id"], "Login token logged into wrong account" + + r = await s.list_login_tokens(timeout=10) + print("\ninfo list_login_tokens: {}\n".format(r)) + assert len(r) == 2, "Wrong number of tokens" + assert "tokenPass" not in r[0], "Retrieved tokens include password" + + r = await s.remove_login_token(token["name"], timeout=10) + print("\ninfo remove_login_token: {}\n".format(r)) + assert len(await s.remove_login_token([token2["name"]], timeout=10)) == 0, "Residual login tokens" + +async def test_mesh_device(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session,\ + meshctrl.session.Session(env.mcurl, user="privileged", password=env.users["privileged"], ignore_ssl=True) as privileged_session,\ + meshctrl.session.Session(env.mcurl, user="unprivileged", password=env.users["unprivileged"], ignore_ssl=True) as unprivileged_session: + # Test creating a mesh + mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10) + print("\ninfo add_device_group: {}\n".format(mesh)) + assert mesh.meshid, "Mesh failed to create" + assert len(mesh.links.keys()), "Mesh created without any users" + assert "user//admin" in mesh.links, "Mesh created with wrong user links" + + # Used to move a device into later + mesh2 = await admin_session.add_device_group("test2", description="This is a test2 group", amtonly=False, features=0, consent=0, timeout=10) + + # Test editing a device group + assert await admin_session.edit_device_group(mesh.meshid, name="new_test", description="New description", flags=meshctrl.constants.MeshFeatures.all, consent=meshctrl.constants.ConsentFlags.all, invite_codes=True, timeout=10), "Failed to edit device group" + try: + await unprivileged_session.edit_device_group(mesh.meshid, description="New description2", timeout=2) + except* meshctrl.exceptions.ServerError as e: + assert e.exceptions[0].args[0] == "Access denied" + # The server just ignores this command if you don't have permissions, so accept timeout as a good response. + except* asyncio.TimeoutError: + pass + else: + raise Exception("Unprivileged user could modify device group") + + # Test invite codes. Kinda. + assert await admin_session.edit_device_group(mesh.meshid, invite_codes=["aoeu", "asdf"], backgroundonly=True, interactiveonly=True, timeout=10), "Failed to edit device group" + # Test editing a group by name + assert await admin_session.edit_device_group("new_test", isname=True, name=mesh.name, consent=meshctrl.constants.ConsentFlags.none, timeout=10), "Failed to edit device group by name" + + # Test adding users to device group + r = await admin_session.add_users_to_device_group((await privileged_session.user_info())["_id"], mesh.meshid, rights=meshctrl.constants.MeshRights.fullrights, timeout=5) + print("\ninfo add_users_to_device_group: {}\n".format(r)) + + assert r[(await privileged_session.user_info())["_id"]]["success"], "Failed to add user to group" + + #Test adding users by device group name + await admin_session.add_users_to_device_group([(await unprivileged_session.user_info())["_id"]], mesh2.name, isname=True, rights=meshctrl.constants.MeshRights.fullrights, timeout=5) + await admin_session.remove_users_from_device_group([(await unprivileged_session.user_info())["_id"]], mesh2.name, isname=True, timeout=10) + + # Test getting device groups for each user. + r = await admin_session.list_device_groups(timeout=10) + print("\ninfo list_device_groups: {}\n".format(r)) + + assert len(r) == 2, "Incorrect number of groups" + assert len(await privileged_session.list_device_groups(timeout=10)) == 1, "Incorrect number of groups" + assert len(await unprivileged_session.list_device_groups(timeout=10)) == 0, "Unprivileged account has access to group it should not" + + assert r[0].description == "New description", "Description either failed to change, or was changed by a user without permission to do so" + + with env.create_agent(mesh.short_meshid) as agent: + # Test agent added to device group being propagated correctly + # Create agent isn't so good at waiting for the agent to show in the sessions. Give it a couple seconds to appear. + for i in range(3): + try: + r = await admin_session.list_devices(timeout=10) + print("\ninfo list_devices: {}\n".format(r)) + assert len(r) == 1, "Incorrect number of agents connected" + except: + if i == 2: + raise + await asyncio.sleep(1) + else: + break + assert len(await privileged_session.list_devices(timeout=10)) == 1, "Incorrect number of agents connected" + assert len(await unprivileged_session.list_devices(timeout=10)) == 0, "Unprivileged account has access to agent it should not" + + r = await admin_session.list_devices(details=True, timeout=10) + print("\ninfo list_devices_details: {}\n".format(r)) + + r = await admin_session.list_devices(group=mesh.name, timeout=10) + print("\ninfo list_devices_group: {}\n".format(r)) + + r = await admin_session.list_devices(meshid=mesh.meshid, timeout=10) + print("\ninfo list_devices_meshid: {}\n".format(r)) + + # Test editing device info propagating correctly + assert await admin_session.edit_device(agent.nodeid, name="new_name", description="New Description", tags="device", consent=meshctrl.constants.ConsentFlags.all, timeout=10), "Failed to edit device info" + + assert (await privileged_session.device_info(agent.nodeid, timeout=10)).name == "new_name", "New name did not propagate to other sessions" + + assert await admin_session.edit_device(agent.nodeid, consent=meshctrl.constants.ConsentFlags.none, timeout=10), "Failed to edit device info" + + # Test run_commands + r = await admin_session.run_command(agent.nodeid, "ls", timeout=10) + print("\ninfo run_command: {}\n".format(r)) + assert "meshagent" in r[agent.nodeid]["result"], "ls gave incorrect data" + assert "meshagent" in (await privileged_session.run_command(agent.nodeid, "ls", timeout=10))[agent.nodeid]["result"], "ls gave incorrect data" + + # Test run commands with ndividual device permissions + try: + await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10) + except* (meshctrl.exceptions.ServerError, ValueError): + pass + else: + raise Exception("Unprivileged user has access to device it should not") + + try: + await unprivileged_session.device_info(agent.nodeid, timeout=10) + except* ValueError: + pass + else: + raise Exception("Unprivileged user has access to device it should not") + + assert (await admin_session.add_users_to_device((await unprivileged_session.user_info())["_id"], agent.nodeid, meshctrl.constants.MeshRights.norights)), "Failed to add user to device" + + try: + await unprivileged_session.run_command(agent.nodeid, "ls", ignore_output=True, timeout=10) + except* meshctrl.exceptions.ServerError: + pass + else: + raise Exception("Unprivileged user has access to device it should not") + + # Test getting individual device info + r = await unprivileged_session.device_info(agent.nodeid, timeout=10) + print("\ninfo device_info: {}\n".format(r)) + + # This device info includes the mesh ID of the device, even though the user doesn't have acces to that mesh. That's odd. + # assert r.meshid is None, "Individual device is exposing its meshid" + + assert r.links[(await unprivileged_session.user_info())["_id"]]["rights"] == meshctrl.constants.DeviceRights.norights, "Unprivileged user has too many rights!" + + assert (await admin_session.add_users_to_device([(await unprivileged_session.user_info())["_id"]], agent.nodeid, meshctrl.constants.DeviceRights.remotecontrol|meshctrl.constants.DeviceRights.agentconsole|meshctrl.constants.DeviceRights.remotecommands)), "Failed to modify user's permissions" + + assert (await unprivileged_session.device_info(agent.nodeid, timeout=10)).links[(await unprivileged_session.user_info())["_id"]]["rights"] == meshctrl.constants.DeviceRights.remotecontrol|meshctrl.constants.DeviceRights.agentconsole|meshctrl.constants.DeviceRights.remotecommands, "Adding permissions did not update unprivileged user." + + # For now, this expects no response. If we ever figure out why the server isn't sending console information te us when it should, fix this. + # assert "meshagent" in (await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10))[agent.nodeid]["result"], "ls gave incorrect data" + await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10) + + assert await admin_session.move_to_device_group(agent.nodeid, mesh2.meshid, timeout=5), "Failed to move mesh to new device group" + + try: + await privileged_session.device_info(agent.nodeid, timeout=10) + except* ValueError: + pass + else: + raise Exception("Privileged user has access to device after it was moved to a new mesh") + + assert await admin_session.move_to_device_group([agent.nodeid], mesh.name, isname=True, timeout=5), "Failed to move mesh to new device group by name" + + # For now, this expects no response. If we ever figure out why the server isn't sending console information te us when it should, fix this. + # assert "meshagent" in (await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10))[agent.nodeid]["result"], "ls gave incorrect data" + try: + await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10) + except: + raise Exception("Failed to run command on device after it was moved to a new mesh while having individual device permissions") + + r = await admin_session.remove_users_from_device_group((await privileged_session.user_info())["_id"], mesh.meshid, timeout=10) + print("\ninfo remove_users_from_device_group: {}\n".format(r)) + assert (await admin_session.remove_users_from_device(agent.nodeid, (await unprivileged_session.user_info())["_id"], timeout=10)), "Failed to remove user from device" + + assert (r[(await privileged_session.user_info())["_id"]]["success"]), "Failed to remove user from devcie group" + + assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" + assert (await admin_session.remove_device_group(mesh2.name, isname=True, timeout=10)), "Failed to remove device group" + assert not (await admin_session.add_users_to_device_group((await privileged_session.user_info())["_id"], mesh.meshid, rights=meshctrl.constants.MeshRights.fullrights, timeout=5))[(await privileged_session.user_info())["_id"]]["success"], "Added user to device group which doesn't exist?" + +async def test_user_groups(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session,\ + meshctrl.session.Session(env.mcurl, user="privileged", password=env.users["privileged"], ignore_ssl=True) as privileged_session,\ + meshctrl.session.Session(env.mcurl, user="unprivileged", password=env.users["unprivileged"], ignore_ssl=True) as unprivileged_session: + + user_group = await admin_session.add_user_group("test", description="aoeu") + print("\ninfo add_user_group: {}\n".format(user_group)) + user_group2 = await admin_session.add_user_group("test2", description="aoeu") + r = await admin_session.list_user_groups(timeout=10) + assert len(r) == 2, "Wrong number of user groups" + print("\ninfo list_user_groups: {}\n".format(r)) + assert (await privileged_session.list_user_groups(timeout=10))[0].description == None, "User has access to group they should not" + + r = await admin_session.add_users_to_user_group((await privileged_session.user_info())["_id"], user_group.id, timeout=10) + print("\ninfo add_users_to_user_group: {}\n".format(r)) + assert r[(await privileged_session.user_info())["_id"].split("/")[-1]]["success"], "Failed to add user to user group" + assert (await admin_session.add_users_to_user_group([(await unprivileged_session.user_info())["_id"]], user_group.id.split("/")[-1], timeout=10))[(await unprivileged_session.user_info())["_id"].split("/")[-1]]["success"], "Failed to add user to user group" + r = await privileged_session.list_user_groups(timeout=10) + print("\ninfo list_user_groups_non_owner: {}\n".format(r)) + + # Non owners just don't get to see the description. + # assert r[0].description == "aoeu", "Failed to add user to user group" + assert await admin_session.remove_user_from_user_group((await privileged_session.user_info())["_id"], user_group.id, timeout=10) + assert await admin_session.remove_user_from_user_group((await unprivileged_session.user_info())["_id"], user_group.id.split("/")[-1], timeout=10) + + assert await admin_session.remove_user_group(user_group.id) + assert await admin_session.remove_user_group(user_group2.id.split("/")[-1]) + +async def test_events(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session: + await admin_session.list_events() + mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10) + try: + with env.create_agent(mesh.short_meshid) as agent: + # Create agent isn't so good at waiting for the agent to show in the sessions. Give it a couple seconds to appear. + for i in range(3): + try: + r = await admin_session.list_devices(timeout=10) + assert len(r) == 1, "Incorrect number of agents connected" + except: + if i == 2: + raise + await asyncio.sleep(1) + else: + break + async with meshctrl.session.Session(env.mcurl, user="privileged", password=env.users["privileged"], ignore_ssl=True) as privileged_session: + + # assert len(await privileged_session.list_events()) == 0, "non-admin user has access to admin events" + + await admin_session.run_command(agent.nodeid, "ls", timeout=10) + + events = await privileged_session.list_events(nodeid=agent.nodeid) + admin_events = await admin_session.list_events(nodeid=agent.nodeid) + # For some reason, this lets you get these. Probably a bug. + # assert len(events) == 0, "User has access to events on device on which they are not a user" + assert len(admin_events) > 0, "Admin didn't get events from running a command" + + await admin_session.add_users_to_device_group((await privileged_session.user_info())["_id"], mesh.meshid, rights=meshctrl.constants.MeshRights.fullrights, timeout=5) + events = await privileged_session.list_events() + admin_events = await admin_session.list_events() + + events = await admin_session.list_events() + assert len(events) > 1, "Missed some events" + assert len(await admin_session.list_events(limit=1)) == 1, "Event limiter gave wrong number of events" + + events = await privileged_session.list_events(userid=(await admin_session.user_info())["_id"]) + admin_events = await admin_session.list_events(userid=(await privileged_session.user_info())["_id"]) + assert len(events) != len(admin_events), "Failed to filter events based on user id" + finally: + assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" + +async def test_interuser(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session,\ + meshctrl.session.Session(env.mcurl, user="privileged", password=env.users["privileged"], ignore_ssl=True) as privileged_session: + got_message = asyncio.Event() + async def _(): + async for message in admin_session.events({"action": "interuser"}): + print("\ninfo interuser_message: {}\n".format(message)) + assert message["data"] == "ping", "Got wrong interuser message" + await admin_session.interuser("pong", session=message["sessionid"]) + break + + async def __(): + async for message in privileged_session.events({"action": "interuser"}): + assert message["data"] == "pong", "Got wrong interuser message" + got_message.set() + break + + async with asyncio.TaskGroup() as tg: + tg.create_task(_()) + tg.create_task(__()) + # Interuser only works with username, not id + tg.create_task(privileged_session.interuser("ping", user=(await admin_session.user_info())["_id"].split("/")[-1])) + tg.create_task(asyncio.wait_for(got_message.wait(), 5)) + +async def test_session_files(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session: + mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10) + try: + with env.create_agent(mesh.short_meshid) as agent: + # Create agent isn't so good at waiting for the agent to show in the sessions. Give it a couple seconds to appear. + for i in range(3): + try: + r = await admin_session.list_devices(timeout=10) + assert len(r) == 1, "Incorrect number of agents connected" + except: + if i == 2: + raise + await asyncio.sleep(1) + else: + break + pwd = (await admin_session.run_command(agent.nodeid, "pwd", timeout=10))[agent.nodeid]["result"].strip() + + randdata = random.randbytes(2000000) + upfilestream = io.BytesIO(randdata) + downfilestream = io.BytesIO() + os.makedirs(os.path.join(thisdir, "data"), exist_ok=True) + with open(os.path.join(thisdir, "data", "test"), "wb") as outfile: + outfile.write(randdata) + + r = await admin_session.upload(agent.nodeid, upfilestream, f"{pwd}/test", timeout=5) + print("\ninfo files_upload: {}\n".format(r)) + assert r["result"] == "success", "Upload failed" + assert r["size"] == len(randdata), "Uploaded wrong number of bytes" + + r = await admin_session.upload_file(agent.nodeid, os.path.join(thisdir, "data", "test"), f"{pwd}/test2", timeout=5) + print("\ninfo files_upload: {}\n".format(r)) + assert r["result"] == "success", "Upload failed" + assert r["size"] == len(randdata), "Uploaded wrong number of bytes" + + s = await admin_session.download(agent.nodeid, f"{pwd}/test", timeout=5) + s.seek(0) + assert s.read() == randdata, "Downloaded bad data" + + await admin_session.download(agent.nodeid, f"{pwd}/test", downfilestream, timeout=5) + downfilestream.seek(0) + assert downfilestream.read() == randdata, "Downloaded bad data" + + await admin_session.download_file(agent.nodeid, f"{pwd}/test2", os.path.join(thisdir, "data", "test"), timeout=5) + + with open(os.path.join(thisdir, "data", "test"), "rb") as infile: + assert infile.read() == randdata, "Downloaded bad data into file" + + r = await admin_session.upload_file(agent.nodeid, os.path.join(thisdir, "data", "test"), f"{pwd}/test2", unique_file_tunnel=True, timeout=5) + + assert r["result"] == "success", "Upload failed" + assert r["size"] == len(randdata), "Uploaded wrong number of bytes" + + await admin_session.download_file(agent.nodeid, f"{pwd}/test2", os.path.join(thisdir, "data", "test"), unique_file_tunnel=True, timeout=5) + with open(os.path.join(thisdir, "data", "test"), "rb") as infile: + assert infile.read() == randdata, "Downloaded bad data into file" + finally: + assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" \ No newline at end of file diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..ba919ce --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,64 @@ +import sys +import os +import asyncio +import meshctrl +import requests + +async def test_shell(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session: + mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10) + try: + with env.create_agent(mesh.short_meshid) as agent: + # Create agent isn't so good at waiting for the agent to show in the sessions. Give it a couple seconds to appear. + for i in range(3): + try: + r = await admin_session.list_devices(timeout=10) + assert len(r) == 1, "Incorrect number of agents connected" + except: + if i == 2: + raise + await asyncio.sleep(1) + else: + break + + async with admin_session.shell(agent.nodeid) as s: + await s.write("ls\n") + resp = await s.read(length=4, timeout=1) + assert len(resp) == 4, "Got too many bytes in return!" + resp = await s.read(timeout=1) + assert "meshagent" in resp, "ls listing is incomplete" + await s.write("ls\n") + resp = "" + for i in range(5): + # We won't get 99999999 bytes, so this will error if block=False is not working + resp += await asyncio.wait_for(s.read(length=99999999, block=False), timeout=5) + await asyncio.sleep(1) + # But this guaruntees that we still get the data eventually. + assert "meshagent" in resp, "ls listing is incomplete" + finally: + assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" + + +async def test_smart_shell(env): + async with meshctrl.session.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True) as admin_session: + mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10) + try: + with env.create_agent(mesh.short_meshid) as agent: + # Create agent isn't so good at waiting for the agent to show in the sessions. Give it a couple seconds to appear. + for i in range(3): + try: + r = await admin_session.list_devices(timeout=10) + assert len(r) == 1, "Incorrect number of agents connected" + except: + if i == 2: + raise + await asyncio.sleep(1) + else: + break + + async with admin_session.smart_shell(agent.nodeid, r"app@.*\$") as ss: + assert "meshagent" in await ss.send_command("ls\n", timeout=5), "ls listing is incomplete" + # Check that newline is added if the user doesn't add it + assert "meshagent" in await ss.send_command("ls", timeout=5), "ls listing is incomplete" + finally: + assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" \ No newline at end of file diff --git a/tests/test_user_group.py b/tests/test_user_group.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..f678a4b --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,138 @@ +import sys +import os +import asyncio +import meshctrl + +test_dict = { + "string": "string", + "int": 1, + "list": [1,2,3,4], + "set": [1,2,3,4], + "dict": { + "string": "string", + "int": 1, + "list": [1,2,3,4], + "set": [1,2,3,4] + } +} + +def compare_dict(d): + assert meshctrl.util.compare_dict(d["dict"], test_dict) == d["equal"], f"dict equality incorrect: isequal: {not d['equal']} {d['dict']} {test_dict}" + +def test_compare_dict_string_equals(): + compare_dict({ + "equal": True, + "dict": { + "string": "string" + } + }) + +def test_compare_dict_int_equals(): + compare_dict({ + "equal": True, + "dict": { + "int": 1 + } + }) + +def test_compare_dict_list_equals(): + compare_dict({ + "equal": True, + "dict": { + "list": [1,2,3,4] + } + }) + +def test_compare_dict_set_equals(): + compare_dict({ + "equal": True, + "dict": { + "set": set([1,3]) + } + }) + +def test_compare_dict_dict_equals(): + compare_dict({ + "equal": True, + "dict": { + "dict": { + "string": "string" + } + } + }) + +def test_compare_dict_string_not_equals(): + compare_dict({ + "equal": False, + "dict": { + "string": "string2" + } + }) + +def test_compare_dict_int_not_equals(): + compare_dict({ + "equal": False, + "dict": { + "int": 2 + } + }) + +def test_compare_dict_list_not_equals_order(): + compare_dict({ + "equal": False, + "dict": { + "list": [1,2,4,3] + } + }) + +def test_compare_dict_list_not_equals_length_long(): + compare_dict({ + "equal": False, + "dict": { + "list": [1,2,3,4,5] + } + }) + +def test_compare_dict_list_not_equals_length_short(): + compare_dict({ + "equal": False, + "dict": { + "list": [1,2,3] + } + }) + +def test_compare_dict_set_not_equals(): + compare_dict({ + "equal": False, + "dict": { + "set": set([6]) + } + }) + +def test_compare_dict_string_not_equals_list(): + compare_dict({ + "equal": False, + "dict": { + "string": ['s', 't', 'r', 'i', 'n', 'g'] + } + }) + +def test_compare_dict_dict_not_equals_value(): + compare_dict({ + "equal": False, + "dict": { + "dict": { + "string": "string2" + } + } + }) + +def test_compare_dict_dict_not_equals_key(): + compare_dict({ + "equal": False, + "dict": { + "dict": { + "string2": "string" + } + } + }) \ No newline at end of file diff --git a/tox.ini b/tox.ini index 69f8159..7eb8193 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ # THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! [tox] -minversion = 3.24 +minversion = 3.2 envlist = default isolated_build = True @@ -53,6 +53,17 @@ commands = # By default, both `sdist` and `wheel` are built. If your sdist is too big or you don't want # to make it available, consider running: `tox -e build -- --wheel` +[testenv:{types}] +deps = + -r{toxinidir}/tests/requirements.txt +commands = + pytest {posargs} -rP + +[testenv:{test}] +deps = + -r{toxinidir}/tests/requirements.txt +commands = + pytest {posargs} [testenv:{docs,doctests,linkcheck}] description =