forked from Narcissus/pylibmeshctrl
First real commit, everything implemented
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
*.orig
|
||||
*.log
|
||||
*.pot
|
||||
__pycache__
|
||||
__pycache__/*
|
||||
.cache/*
|
||||
.*.swp
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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
|
||||
@@ -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
349
src/meshctrl/device.py
Normal 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)})"
|
||||
@@ -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
|
||||
@@ -1,4 +1,308 @@
|
||||
from . import tunnel
|
||||
from . import constants
|
||||
from . import exceptions
|
||||
from . import util
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
class Files(tunnel.Tunnel):
|
||||
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
99
src/meshctrl/mesh.py
Normal 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
@@ -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):
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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):
|
||||
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
191
src/meshctrl/types.py
Normal 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`'''
|
||||
77
src/meshctrl/user_group.py
Normal file
77
src/meshctrl/user_group.py
Normal 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)})"
|
||||
@@ -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
1
tests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/data
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
@@ -8,3 +8,7 @@
|
||||
"""
|
||||
|
||||
# import pytest
|
||||
|
||||
pytest_plugins = [
|
||||
"tests.environment"
|
||||
]
|
||||
108
tests/environment/__init__.py
Normal file
108
tests/environment/__init__.py
Normal 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")
|
||||
18
tests/environment/client.dockerfile
Normal file
18
tests/environment/client.dockerfile
Normal 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"]
|
||||
52
tests/environment/compose.yaml
Normal file
52
tests/environment/compose.yaml
Normal 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
|
||||
7
tests/environment/meshcentral.dockerfile
Normal file
7
tests/environment/meshcentral.dockerfile
Normal 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"]
|
||||
38
tests/environment/meshcentral/data/config.json
Normal file
38
tests/environment/meshcentral/data/config.json
Normal 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
|
||||
}
|
||||
}
|
||||
68
tests/environment/scripts/client/agent_server.py
Normal file
68
tests/environment/scripts/client/agent_server.py
Normal 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()
|
||||
2
tests/environment/scripts/client/requirements.txt
Normal file
2
tests/environment/scripts/client/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask
|
||||
requests
|
||||
15
tests/environment/scripts/meshcentral/create_users.py
Normal file
15
tests/environment/scripts/meshcentral/create_users.py
Normal 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"])
|
||||
1
tests/environment/scripts/meshcentral/users.json
Normal file
1
tests/environment/scripts/meshcentral/users.json
Normal file
@@ -0,0 +1 @@
|
||||
{"admin": "3U6zP4iIes5ISH15XxjYLjJcCdw9jU0m", "privileged": "aiIO0zLMGsU7++FYVDNxhlpYlZ1andRB", "unprivileged": "Cz9OMV1wkVd9pXdWi4lkBAAu6TMt43MA"}
|
||||
6
tests/requirements.txt
Normal file
6
tests/requirements.txt
Normal 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
112
tests/test_files.py
Normal 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
0
tests/test_mesh.py
Normal 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
420
tests/test_session.py
Normal 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
64
tests/test_shell.py
Normal 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
0
tests/test_user_group.py
Normal file
138
tests/test_util.py
Normal file
138
tests/test_util.py
Normal 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
13
tox.ini
@@ -3,7 +3,7 @@
|
||||
# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS!
|
||||
|
||||
[tox]
|
||||
minversion = 3.24
|
||||
minversion = 3.2
|
||||
envlist = default
|
||||
isolated_build = True
|
||||
|
||||
@@ -53,6 +53,17 @@ commands =
|
||||
# By default, both `sdist` and `wheel` are built. If your sdist is too big or you don't want
|
||||
# to make it available, consider running: `tox -e build -- --wheel`
|
||||
|
||||
[testenv:{types}]
|
||||
deps =
|
||||
-r{toxinidir}/tests/requirements.txt
|
||||
commands =
|
||||
pytest {posargs} -rP
|
||||
|
||||
[testenv:{test}]
|
||||
deps =
|
||||
-r{toxinidir}/tests/requirements.txt
|
||||
commands =
|
||||
pytest {posargs}
|
||||
|
||||
[testenv:{docs,doctests,linkcheck}]
|
||||
description =
|
||||
|
||||
Reference in New Issue
Block a user