First real commit, everything implemented

This commit is contained in:
Josiah Baldwin
2024-11-20 15:23:03 -08:00
parent 69afbfeba7
commit 5c20a2b8fb
36 changed files with 3625 additions and 282 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@
*.orig
*.log
*.pot
__pycache__
__pycache__/*
.cache/*
.*.swp

View File

@@ -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"')

View File

@@ -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

View File

@@ -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
@@ -130,3 +163,129 @@ class Icon(enum.IntEnum):
router = enum.auto()
embedded = enum.auto()
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

349
src/meshctrl/device.py Normal file
View File

@@ -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"<Device: nodeid={self.nodeid} name={self.name} description={self.description} computer_name={self.computer_name} icon={self.icon} "\
f"mesh={self.mesh} meshtype={self.meshtype} meshname={self.meshname} domain={self.domain} host={self.host} ip={self.ip} "\
f"tags={self.tags} details={self.details} created_at={self.created_at} lastaddr={self.lastaddr} lastconnect={self.lastconnect} "\
f"connected={self.connected} powered_on={self.powered_on} os_description={self.os_description} links={self.links} _extra_props={self._extra_props}>"
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)})"

View File

@@ -15,3 +15,22 @@ 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

View File

@@ -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
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()

99
src/meshctrl/mesh.py Normal file
View File

@@ -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"<Mesh: meshid={self.meshid} name={self.name} description={self.description} created_at={self.created_at} "\
f"meshtype={self.meshtype} domain={self.domain} "\
f"created_at={self.created_at} creatorid={self.creatorid} creatorname={self.creatorname} links={self.links}>"
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)})"

File diff suppressed because it is too large Load Diff

View File

@@ -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
@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)

View File

@@ -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
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")

191
src/meshctrl/types.py Normal file
View File

@@ -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`'''

View File

@@ -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"<UserGroup: ugrpid={self.ugrpid} name={self.name} description={self.description} "\
f"domain={self.domain} links={self.links}>"
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)})"

View File

@@ -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)
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

1
tests/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/data

0
tests/__init__.py Normal file
View File

View File

@@ -8,3 +8,7 @@
"""
# import pytest
pytest_plugins = [
"tests.environment"
]

View File

@@ -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")

View File

@@ -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"]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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
}
}

View File

@@ -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/<agentid>', 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()

View File

@@ -0,0 +1,2 @@
flask
requests

View File

@@ -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"])

View File

@@ -0,0 +1 @@
{"admin": "3U6zP4iIes5ISH15XxjYLjJcCdw9jU0m", "privileged": "aiIO0zLMGsU7++FYVDNxhlpYlZ1andRB", "unprivileged": "Cz9OMV1wkVd9pXdWi4lkBAAu6TMt43MA"}

6
tests/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
requests
pytest-asyncio
cffi==1.17.1
cryptography==43.0.3
pycparser==2.22
websockets==13.1

112
tests/test_files.py Normal file
View File

@@ -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"

0
tests/test_mesh.py Normal file
View File

View File

@@ -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())
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")

420
tests/test_session.py Normal file
View File

@@ -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"

64
tests/test_shell.py Normal file
View File

@@ -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"

0
tests/test_user_group.py Normal file
View File

138
tests/test_util.py Normal file
View File

@@ -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"
}
}
})

13
tox.ini
View File

@@ -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 =