Compare commits

...

18 Commits

Author SHA1 Message Date
Josiah Baldwin
564d466ff9 Fixed listening to raw not removing its listener correctly 2025-01-08 13:57:28 -08:00
Josiah Baldwin
6919da4a42 Merge pull request #19 from DaanSelen/add-users
Fix #15
2025-01-08 11:59:04 -08:00
Josiah Baldwin
ff120490fa Merge branch 'main' into add-users 2025-01-08 11:56:47 -08:00
Josiah Baldwin
d9991156f6 Merge pull request #18 from DaanSelen/conversion
Fix #9
2025-01-08 11:46:15 -08:00
Daan
4fea858fbc Fix: https://github.com/HuFlungDu/pylibmeshctrl/issues/15 2025-01-04 17:16:30 +01:00
Daan
3b4a18b379 Also added the ValueError raise condition for lastconnect datetime. 2025-01-04 17:09:33 +01:00
Daan
c072d6012a fix https://github.com/HuFlungDu/pylibmeshctrl/issues/9 2025-01-04 17:05:57 +01:00
Josiah Baldwin
0ee2e2dc94 Merge pull request #12 from DaanSelen/main
Backport for python 3.11 and alike
2024-12-19 12:49:12 -08:00
dselen
f2d9fcd295 Update tunnel.py
Fix syntaxerror
2024-12-19 10:14:09 +01:00
Josiah Baldwin
7456743709 Updated installing docs 2024-12-13 17:10:34 -08:00
Josiah Baldwin
07b828a150 Fixed some docs 2024-12-13 17:04:11 -08:00
Josiah Baldwin
cd7a356eb5 Merge pull request #7 from HuFlungDu/release/1.0.0
1.0.0 release
2024-12-13 16:43:12 -08:00
Josiah Baldwin
5ee2c8edf3 1.0.0 release 2024-12-13 16:39:59 -08:00
Josiah Baldwin
d3d5b87287 Fixed various issues with (down/up)load file functions not passing through arguments 2024-12-13 08:51:49 -08:00
Josiah Baldwin
18eb2de5b6 Added no_proxy os variable bypass so urllib.requests acts as expected 2024-12-13 08:28:54 -08:00
Josiah Baldwin
ec23ba458d Changed how tunnuls handle their ssl contexts; fixed long standing file tunnels not being cleaned up on session close; changed file sizes in tests 2024-12-12 18:06:50 -08:00
Josiah Baldwin
a3c721318d Added ability to download files over http(s)
Also fixed some tests and a couple other bugs
2024-12-12 16:06:18 -08:00
Josiah Baldwin
4eda4e6c08 increased the odds of getting useful node node information from device_info 2024-12-10 18:17:25 -08:00
18 changed files with 253 additions and 114 deletions

View File

@@ -19,7 +19,7 @@ formats:
build: build:
os: ubuntu-22.04 os: ubuntu-22.04
tools: tools:
python: "3.11" python: "3.13"
python: python:
install: install:

View File

@@ -2,7 +2,7 @@
Changelog Changelog
========= =========
Version 0.1 Version 1.0.0
=========== ===========
Create First release

View File

@@ -38,7 +38,7 @@ Library for remotely interacting with a
Installation Installation
------------ ------------
pip install meshctrl pip install libmeshctrl
Usage Usage
----- -----

View File

@@ -24,7 +24,7 @@ platforms = any
# Add here all kinds of additional classifiers as defined under # Add here all kinds of additional classifiers as defined under
# https://pypi.org/classifiers/ # https://pypi.org/classifiers/
classifiers = classifiers =
Development Status :: 4 - Beta Development Status :: 5 - Production/Stable
Programming Language :: Python Programming Language :: Python

View File

@@ -12,6 +12,7 @@ class Device(object):
name (str|None): Device name as it is shown on the meshcentral server 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. 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. tags (list[str]|None): tags associated with device.
users (list[str]|None): latest known usernames which have logged in.
created_at (datetime.Datetime|int|None): Time at which device mas created. Also accepted as agct. 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. 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 icon (~meshctrl.constants.Icon): Icon displayed on the website
@@ -38,6 +39,7 @@ class Device(object):
name (str|None): Device name as it is shown on the meshcentral server 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. description (str|None): Device description as it is shown on the meshcentral server.
tags (list[str]): tags associated with device. tags (list[str]): tags associated with device.
users (list[str]): latest known usernames which have logged in.
computer_name (str|None): Device name as reported from the agent. This may be different from name. Also accepted as rname. 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 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. mesh (~meshctrl.mesh.Mesh|None): Mesh object under which this device exists. Is None for individual device access.
@@ -56,7 +58,7 @@ class Device(object):
''' '''
def __init__(self, nodeid, session, agent=None, def __init__(self, nodeid, session, agent=None,
name=None, desc=None, description=None, name=None, desc=None, description=None,
tags=None, tags=None, users=None,
agct=None, created_at=None, agct=None, created_at=None,
rname=None, computer_name=None, icon=constants.Icon.desktop, rname=None, computer_name=None, icon=constants.Icon.desktop,
mesh=None, mtype=None, meshtype=None, groupname=None, meshname=None, mesh=None, mtype=None, meshtype=None, groupname=None, meshname=None,
@@ -90,13 +92,14 @@ class Device(object):
self.description = description if description is not None else desc self.description = description if description is not None else desc
self.os_description = os_description if os_description is not None else osdesc self.os_description = os_description if os_description is not None else osdesc
self.tags = tags if tags is not None else [] self.tags = tags if tags is not None else []
self.users = users if users is not None else []
self.details = details if details 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 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: if not isinstance(created_at, datetime.datetime) and created_at is not None:
try: try:
created_at = datetime.datetime.fromtimestamp(created_at) created_at = datetime.datetime.fromtimestamp(created_at)
except OSError: except (OSError, ValueError):
# 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. # 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. # 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) created_at = datetime.datetime.fromtimestamp(created_at/1000.0)
@@ -106,7 +109,7 @@ class Device(object):
if not isinstance(lastconnect, datetime.datetime) and lastconnect is not None: if not isinstance(lastconnect, datetime.datetime) and lastconnect is not None:
try: try:
lastconnect = datetime.datetime.fromtimestamp(lastconnect) lastconnect = datetime.datetime.fromtimestamp(lastconnect)
except OSError: except (OSError, ValueError):
# 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. # 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. # 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) lastconnect = datetime.datetime.fromtimestamp(lastconnect/1000.0)
@@ -340,10 +343,10 @@ class Device(object):
def __str__(self): def __str__(self):
return f"<Device: nodeid={self.nodeid} name={self.name} description={self.description} computer_name={self.computer_name} icon={self.icon} "\ 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"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"tags={self.tags} users={self.users} 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}>" 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): 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)}, "\ 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"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"tags={repr(self.tags)}, users={repr(self.users)}, 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)})" f"connected={repr(self.connected)}, powered_on={repr(self.powered_on)}, os_description={repr(self.os_description)}, links={repr(self.links)}, **{repr(self._extra_props)})"

View File

@@ -4,11 +4,32 @@ from . import exceptions
from . import util from . import util
import asyncio import asyncio
import json import json
import importlib
import importlib.util
import shutil
# import urllib
# import urllib.request
import urllib.parse
old_parse = urllib.parse
# Default proxy handler uses OS defined no_proxy in order to be helpful. This is unhelpful for our usecase. Monkey patch out proxy getting functions, but don't effect the user's urllib instance.
spec = importlib.util.find_spec('urllib')
urllib = importlib.util.module_from_spec(spec)
spec.loader.exec_module(urllib)
spec = importlib.util.find_spec('urllib.request')
urllib.request = importlib.util.module_from_spec(spec)
spec.loader.exec_module(urllib.request)
urllib.parse = old_parse
urllib.request.getproxies_environment = lambda: {}
urllib.request.getproxies_registry = lambda: {}
urllib.request.getproxies_macosx_sysconf = lambda: {}
urllib.request.getproxies = lambda: {}
class Files(tunnel.Tunnel): class Files(tunnel.Tunnel):
def __init__(self, session, nodeid): def __init__(self, session, node):
super().__init__(session, nodeid, constants.Protocol.FILES) super().__init__(session, node.nodeid, constants.Protocol.FILES)
self.recorded = None self.recorded = None
self._node = node
self._request_id = 0 self._request_id = 0
self._request_queue = asyncio.Queue() self._request_queue = asyncio.Queue()
self._download_finished = asyncio.Event() self._download_finished = asyncio.Event()
@@ -16,6 +37,17 @@ class Files(tunnel.Tunnel):
self._current_request = None self._current_request = None
self._handle_requests_task = asyncio.create_task(self._handle_requests()) self._handle_requests_task = asyncio.create_task(self._handle_requests())
self._chunk_size = 65564 self._chunk_size = 65564
proxies = {}
if self._session._proxy is not None:
# We don't know which protocol the user is going to use, but we only need support one at a time, so just assume both
proxies = {
"http": self._session._proxy,
"https": self._session._proxy,
"no": ""
}
self._proxy_handler = urllib.request.ProxyHandler(proxies=proxies)
self._http_opener = urllib.request.build_opener(self._proxy_handler, urllib.request.HTTPSHandler(context=self._session._ssl_context))
def _get_request_id(self): def _get_request_id(self):
self._request_id = (self._request_id+1)%(2**32-1) self._request_id = (self._request_id+1)%(2**32-1)
@@ -68,6 +100,7 @@ class Files(tunnel.Tunnel):
Args: Args:
directory (str): Path to the directory you wish to list directory (str): Path to the directory you wish to list
timeout (int): duration in seconds to wait for a response before throwing an error
Returns: Returns:
list[~meshctrl.types.FilesLSItem]: The directory listing list[~meshctrl.types.FilesLSItem]: The directory listing
@@ -75,6 +108,7 @@ class Files(tunnel.Tunnel):
Raises: Raises:
:py:class:`~meshctrl.exceptions.ServerError`: Error from server :py:class:`~meshctrl.exceptions.ServerError`: Error from server
:py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure
asyncio.TimeoutError: Command timed out
""" """
data = await self._send_command({"action": "ls", "path": directory}, "ls", timeout=timeout) data = await self._send_command({"action": "ls", "path": directory}, "ls", timeout=timeout)
return data["dir"] return data["dir"]
@@ -101,10 +135,12 @@ class Files(tunnel.Tunnel):
Args: Args:
directory (str): Path of directory to create directory (str): Path of directory to create
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.ServerError`: Error from server :py:class:`~meshctrl.exceptions.ServerError`: Error from server
:py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure
asyncio.TimeoutError: Command timed out
Returns: Returns:
bool: True if directory was created bool: True if directory was created
@@ -127,10 +163,12 @@ class Files(tunnel.Tunnel):
path (str): Directory from which to delete files path (str): Directory from which to delete files
files (str|list[str]): File or files to remove from the directory files (str|list[str]): File or files to remove from the directory
recursive (bool): Whether to delete the files recursively recursive (bool): Whether to delete the files recursively
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.ServerError`: Error from server :py:class:`~meshctrl.exceptions.ServerError`: Error from server
:py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure
asyncio.TimeoutError: Command timed out
Returns: 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'. 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'.
@@ -155,10 +193,12 @@ class Files(tunnel.Tunnel):
path (str): Directory from which to rename the file path (str): Directory from which to rename the file
name (str): File to rename name (str): File to rename
new_name (str): New name to give the file new_name (str): New name to give the file
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.ServerError`: Error from server :py:class:`~meshctrl.exceptions.ServerError`: Error from server
:py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure
asyncio.TimeoutError: Command timed out
Returns: Returns:
str: Info about file renamed. Something along the lines of 'Rename: "/path/to/file" to "newfile"'. str: Info about file renamed. Something along the lines of 'Rename: "/path/to/file" to "newfile"'.
@@ -173,6 +213,7 @@ class Files(tunnel.Tunnel):
return tasks[2].result() return tasks[2].result()
@util._check_socket
async def upload(self, source, target, name=None, timeout=None): async def upload(self, source, target, name=None, timeout=None):
''' '''
Upload a stream to a device. Upload a stream to a device.
@@ -181,10 +222,12 @@ class Files(tunnel.Tunnel):
source (io.IOBase): An IO instance from which to read the data. Must be open for reading. 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 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. 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.
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property :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 :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property
asyncio.TimeoutError: Command timed out
Returns: Returns:
dict: {result: bool whether upload succeeded, size: number of bytes uploaded} dict: {result: bool whether upload succeeded, size: number of bytes uploaded}
@@ -198,17 +241,26 @@ class Files(tunnel.Tunnel):
raise request["error"] raise request["error"]
return request["return"] return request["return"]
async def download(self, source, target, timeout=None): def _http_download(self, url, target, timeout):
response = self._http_opener.open(url, timeout=timeout)
shutil.copyfileobj(response, target)
@util._check_socket
async def download(self, source, target, skip_http_attempt=False, skip_ws_attempt=False, timeout=None):
''' '''
Download a file from a device into a writable stream. Download a file from a device into a writable stream.
Args: Args:
source (str): Path from which to download from device 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. target (io.IOBase): Stream to which to write data. If None, create new BytesIO which is both readable and writable.
skip_http_attempt (bool): Meshcentral has a way to download files through http(s) instead of through the websocket. This method tends to be much faster than using the websocket, so we try it first. Setting this to True will skip that attempt and just use the established websocket connection.
skip_ws_attempt (bool): Like skip_http_attempt, except just throw an error if the http attempt fails instead of trying with the websocket
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property :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 :py:class:`~meshctrl.exceptions.FileTransferCancelled`: File transfer cancelled. Info available on the `stats` property
asyncio.TimeoutError: Command timed out
Returns: Returns:
dict: {result: bool whether download succeeded, size: number of bytes downloaded} dict: {result: bool whether download succeeded, size: number of bytes downloaded}
@@ -216,6 +268,29 @@ class Files(tunnel.Tunnel):
request_id = f"download_{self._get_request_id()}" request_id = f"download_{self._get_request_id()}"
data = { "action": 'download', "sub": 'start', "id": request_id, "path": source } 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} request = {"id": request_id, "data": data, "type": "download", "source": source, "target": target, "size": 0, "finished": asyncio.Event(), "errored": asyncio.Event(), "error": None}
if not skip_http_attempt:
start_pos = target.tell()
try:
params = urllib.parse.urlencode({
"c": self._authcookie["cookie"],
"m": self._node.mesh.meshid.split("/")[-1],
"n": self._node.nodeid.split("/")[-1],
"f": source
})
url = self._session.url.replace('/control.ashx', f"/devicefile.ashx?{params}")
url = url.replace("wss://", "https://").replace("ws://", "http://")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._http_download, url, target, timeout)
size = target.tell() - start_pos
return {"result": True, "size": size}
except* Exception as eg:
if skip_ws_attempt:
size = target.tell() - start_pos
excs = eg.exceptions + (exceptions.FileTransferError("Errored", {"result": False, "size": size}),)
raise ExceptionGroup("File download failed", excs)
target.seek(start_pos)
await self._request_queue.put(request) await self._request_queue.put(request)
await asyncio.wait_for(request["finished"].wait(), timeout) await asyncio.wait_for(request["finished"].wait(), timeout)
if request["error"] is not None: if request["error"] is not None:
@@ -230,7 +305,7 @@ class Files(tunnel.Tunnel):
return return
if cmd["reqid"] == self._current_request["id"]: if cmd["reqid"] == self._current_request["id"]:
if cmd["action"] == "uploaddone": if cmd["action"] == "uploaddone":
self._current_request["return"] = {"result": "success", "size": self._current_request["size"]} self._current_request["return"] = {"result": True, "size": self._current_request["size"]}
self._current_request["finished"].set() self._current_request["finished"].set()
elif cmd["action"] == "uploadstart": elif cmd["action"] == "uploadstart":
while True: while True:
@@ -252,7 +327,7 @@ class Files(tunnel.Tunnel):
if self._current_request["inflight"] == 0 and self._current_request["complete"]: 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"]})) await self._message_queue.put(json.dumps({ "action": 'uploaddone', "reqid": self._current_request["id"]}))
elif cmd["action"] == "uploaderror": elif cmd["action"] == "uploaderror":
self._current_request["return"] = {"result": "canceled", "size": self._current_request["size"]} self._current_request["return"] = {"result": False, "size": self._current_request["size"]}
self._current_request["error"] = exceptions.FileTransferError("Errored", self._current_request["return"]) self._current_request["error"] = exceptions.FileTransferError("Errored", self._current_request["return"])
self._current_request["errored"].set() self._current_request["errored"].set()
self._current_request["finished"].set() self._current_request["finished"].set()
@@ -268,7 +343,7 @@ class Files(tunnel.Tunnel):
self._current_request["target"].write(data[4:]) self._current_request["target"].write(data[4:])
self._current_request["size"] += len(data)-4 self._current_request["size"] += len(data)-4
if (data[3] & 1) != 0: if (data[3] & 1) != 0:
self._current_request["return"] = {"result": "success", "size": self._current_request["size"]} self._current_request["return"] = {"result": True, "size": self._current_request["size"]}
self._current_request["finished"].set() self._current_request["finished"].set()
else: else:
await self._message_queue.put(json.dumps({ "action": 'download', "sub": 'ack', "id": self._current_request["id"] })) await self._message_queue.put(json.dumps({ "action": 'download', "sub": 'ack', "id": self._current_request["id"] }))
@@ -279,7 +354,7 @@ class Files(tunnel.Tunnel):
if cmd["sub"] == "start": if cmd["sub"] == "start":
await self._message_queue.put(json.dumps({ "action": 'download', "sub": 'startack', "id": self._current_request["id"] })) await self._message_queue.put(json.dumps({ "action": 'download', "sub": 'startack', "id": self._current_request["id"] }))
elif cmd["sub"] == "cancel": elif cmd["sub"] == "cancel":
self._current_request["return"] = {"result": "canceled", "size": self._current_request["size"]} self._current_request["return"] = {"result": False, "size": self._current_request["size"]}
self._current_request["error"] = exceptions.FileTransferCancelled("Cancelled", self._current_request["return"]) self._current_request["error"] = exceptions.FileTransferCancelled("Cancelled", self._current_request["return"])
self._current_request["errored"].set() self._current_request["errored"].set()
self._current_request["finished"].set() self._current_request["finished"].set()

View File

@@ -46,7 +46,7 @@ class Mesh(object):
if not isinstance(created_at, datetime.datetime) and created_at is not None: if not isinstance(created_at, datetime.datetime) and created_at is not None:
try: try:
created_at = datetime.datetime.fromtimestamp(created_at) created_at = datetime.datetime.fromtimestamp(created_at)
except OSError: except (OSError, ValueError):
# 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. # 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. # 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) created_at = datetime.datetime.fromtimestamp(created_at/1000.0)

View File

@@ -124,15 +124,17 @@ class Session(object):
self._message_queue = asyncio.Queue() self._message_queue = asyncio.Queue()
self._send_task = None self._send_task = None
self._listen_task = None self._listen_task = None
self._ssl_context = None
if self._ignore_ssl:
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
self._ssl_context.check_hostname = False
self._ssl_context.verify_mode = ssl.CERT_NONE
async def _main_loop(self): async def _main_loop(self):
try: try:
options = {} options = {}
if self._ignore_ssl: if self._ssl_context is not None:
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) options["ssl"] = self._ssl_context
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
options = { "ssl": ssl_context }
headers = websockets.datastructures.Headers() headers = websockets.datastructures.Headers()
@@ -215,12 +217,14 @@ class Session(object):
return self._command_id return self._command_id
async def close(self): async def close(self):
# Dunno yet
self._main_loop_task.cancel()
try: try:
await self._main_loop_task await asyncio.gather(*[tunnel.close() for name, tunnel in self._file_tunnels.items()])
except asyncio.CancelledError: finally:
pass self._main_loop_task.cancel()
try:
await self._main_loop_task
except asyncio.CancelledError:
pass
@util._check_socket @util._check_socket
async def __aenter__(self): async def __aenter__(self):
@@ -529,7 +533,7 @@ class Session(object):
data = await event_queue.get() data = await event_queue.get()
yield data yield data
finally: finally:
self._eventer.off("server_event", _) self._eventer.off("raw", _)
async def events(self, filter=None): async def events(self, filter=None):
''' '''
@@ -1337,6 +1341,14 @@ class Session(object):
if sysinfo is not None and sysinfo.get("node", None): if sysinfo is not None and sysinfo.get("node", None):
# Node information came with system information # Node information came with system information
node = sysinfo.get("node", None) node = sysinfo.get("node", None)
for meshid, _nodes in nodes["nodes"].items():
for _mesh in meshes:
if _mesh.meshid == meshid:
break
else:
break
if meshid == node["meshid"]:
node["mesh"] = _mesh
else: else:
# This device does not have system information, get node information from the nodes list. # This device does not have system information, get node information from the nodes list.
for meshid, _nodes in nodes["nodes"].items(): for meshid, _nodes in nodes["nodes"].items():
@@ -1810,15 +1822,16 @@ class Session(object):
raise ValueError("No user or session given") raise ValueError("No user or session given")
await self._message_queue.put(json.dumps({"action": "interuser", "data": data, "sessionid": session, "userid": user})) await self._message_queue.put(json.dumps({"action": "interuser", "data": data, "sessionid": session, "userid": user}))
async def upload(self, nodeid, source, target, unique_file_tunnel=False, timeout=None): async def upload(self, node, source, target, unique_file_tunnel=False, timeout=None):
''' '''
Upload a stream to a device. This creates an _File and destroys it every call. If you need to upload multiple files, use {@link Session#file_explorer} instead. Upload a stream to a device.
Args: Args:
nodeid (str): Unique id to upload stream to node (~meshctrl.device.Device|str): Device or id of device to which to upload the file. If it is a device, it must have a ~meshctrl.mesh.Mesh device associated with it (the default). If it is a string, the device will be fetched prior to tunnel creation.
source (io.IOBase): An IO instance from which to read the data. Must be open for reading. 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 target (str): Path which to upload stream to on remote device
unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files`
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property
@@ -1827,23 +1840,26 @@ class Session(object):
Returns: Returns:
dict: {result: bool whether upload succeeded, size: number of bytes uploaded} dict: {result: bool whether upload succeeded, size: number of bytes uploaded}
''' '''
if not isinstance(node, device.Device):
node = await self.device_info(node)
if unique_file_tunnel: if unique_file_tunnel:
async with self.file_explorer(nodeid) as files: async with self.file_explorer(node) as files:
return await files.upload(source, target) return await files.upload(source, target, timeout=timeout)
else: else:
files = await self._cached_file_explorer(nodeid, nodeid) files = await self._cached_file_explorer(node, node.nodeid)
return await files.upload(source, target, timeout=timeout) return await files.upload(source, target, timeout=timeout)
async def upload_file(self, nodeid, filepath, target, unique_file_tunnel=False, timeout=None): async def upload_file(self, node, filepath, target, unique_file_tunnel=False, timeout=None):
''' '''
Friendly wrapper around :py:class:`~meshctrl.session.Session.upload` to upload from a filepath. Creates a ReadableStream and calls upload. Friendly wrapper around :py:class:`~meshctrl.session.Session.upload` to upload from a filepath. Creates a ReadableStream and calls upload.
Args: Args:
nodeid (str): Unique id to upload file to node (~meshctrl.device.Device|str): Device or id of device to which to upload the file. If it is a device, it must have a ~meshctrl.mesh.Mesh device associated with it (the default). If it is a string, the device will be fetched prior to tunnel creation.
filepath (str): Path from which to read the data filepath (str): Path from which to read the data
target (str): Path which to upload file to on remote device target (str): Path which to upload file to on remote device
unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files`
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property
@@ -1853,17 +1869,20 @@ class Session(object):
dict: {result: bool whether upload succeeded, size: number of bytes uploaded} dict: {result: bool whether upload succeeded, size: number of bytes uploaded}
''' '''
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
return await self.upload(nodeid, f, target, unique_file_tunnel, timeout=timeout) return await self.upload(node, f, target, unique_file_tunnel, timeout=timeout)
async def download(self, nodeid, source, target=None, unique_file_tunnel=False, timeout=None): async def download(self, node, source, target=None, skip_http_attempt=False, skip_ws_attempt=False, unique_file_tunnel=False, timeout=None):
''' '''
Download a file from a device into a writable stream. This creates an :py:class:`~meshctrl.files.Files` and destroys it every call. If you need to upload multiple files, use :py:class:`~meshctrl.session.Session.file_explorer` instead. Download a file from a device into a writable stream. This creates an :py:class:`~meshctrl.files.Files` and destroys it every call. If you need to upload multiple files, use :py:class:`~meshctrl.session.Session.file_explorer` instead.
Args: Args:
nodeid (str): Unique id to download file from node (~meshctrl.device.Device|str): Device or id of device from which to download the file. If it is a device, it must have a ~meshctrl.mesh.Mesh device associated with it (the default). If it is a string, the device will be fetched prior to tunnel creation.
source (str): Path from which to download from device 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. target (io.IOBase): Stream to which to write data. If None, create new BytesIO which is both readable and writable.
skip_http_attempt (bool): Meshcentral has a way to download files through http(s) instead of through the websocket. This method tends to be much faster than using the websocket, so we try it first. Setting this to True will skip that attempt and just use the established websocket connection.
skip_ws_attempt (bool): Like skip_http_attempt, except just throw an error if the http attempt fails instead of trying with the websocket
unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files`
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property
@@ -1872,29 +1891,34 @@ class Session(object):
Returns: Returns:
io.IOBase: The stream which has been downloaded into. Cursor will be at the beginning of where the file is downloaded. io.IOBase: The stream which has been downloaded into. Cursor will be at the beginning of where the file is downloaded.
''' '''
if not isinstance(node, device.Device):
node = await self.device_info(node)
if target is None: if target is None:
target = io.BytesIO() target = io.BytesIO()
start = target.tell() start = target.tell()
if unique_file_tunnel: if unique_file_tunnel:
async with self.file_explorer(nodeid) as files: async with self.file_explorer(node) as files:
await files.download(source, target) await files.download(source, target, skip_http_attempt=skip_http_attempt, skip_ws_attempt=skip_ws_attempt, timeout=timeout)
target.seek(start) target.seek(start)
return target return target
else: else:
files = await self._cached_file_explorer(nodeid, nodeid) files = await self._cached_file_explorer(node, node.nodeid)
await files.download(source, target, timeout=timeout) await files.download(source, target, skip_http_attempt=skip_http_attempt, skip_ws_attempt=skip_ws_attempt, timeout=timeout)
target.seek(start) target.seek(start)
return target return target
async def download_file(self, nodeid, source, filepath, unique_file_tunnel=False, timeout=None): async def download_file(self, node, source, filepath, skip_http_attempt=False, skip_ws_attempt=False, unique_file_tunnel=False, timeout=None):
''' '''
Friendly wrapper around :py:class:`~meshctrl.session.Session.download` to download to a filepath. Creates a WritableStream and calls download. Friendly wrapper around :py:class:`~meshctrl.session.Session.download` to download to a filepath. Creates a WritableStream and calls download.
Args: Args:
nodeid (str): Unique id to download file from node (~meshctrl.device.Device|str): Device or id of device from which to download the file. If it is a device, it must have a ~meshctrl.mesh.Mesh device associated with it (the default). If it is a string, the device will be fetched prior to tunnel creation.
source (str): Path from which to download from device source (str): Path from which to download from device
filepath (str): Path to which to download data filepath (str): Path to which to download data
skip_http_attempt (bool): Meshcentral has a way to download files through http(s) instead of through the websocket. This method tends to be much faster than using the websocket, so we try it first. Setting this to True will skip that attempt and just use the established websocket connection.
skip_ws_attempt (bool): Like skip_http_attempt, except just throw an error if the http attempt fails instead of trying with the websocket
unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files` unique_file_tunnel (bool): True: Create a unique :py:class:`~meshctrl.files.Files` for this call, which will be cleaned up on return, else use cached or cache :py:class:`~meshctrl.files.Files`
timeout (int): duration in seconds to wait for a response before throwing an error
Raises: Raises:
:py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property :py:class:`~meshctrl.exceptions.FileTransferError`: File transfer failed. Info available on the `stats` property
@@ -1904,24 +1928,39 @@ class Session(object):
None None
''' '''
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
await self.download(nodeid, source, f, unique_file_tunnel, timeout=timeout) await self.download(node, source, f, skip_http_attempt=skip_http_attempt, skip_ws_attempt=skip_ws_attempt, unique_file_tunnel=unique_file_tunnel, timeout=timeout)
async def _cached_file_explorer(self, nodeid, _id): async def _cached_file_explorer(self, node, _id):
if (_id not in self._file_tunnels or not self._file_tunnels[_id].alive): if (_id not in self._file_tunnels or not self._file_tunnels[_id].alive):
self._file_tunnels[_id] = self.file_explorer(nodeid) self._file_tunnels[_id] = await self.file_explorer(node).__aenter__()
await self._file_tunnels[_id].initialized.wait() await self._file_tunnels[_id].initialized.wait()
return self._file_tunnels[_id] return self._file_tunnels[_id]
def file_explorer(self, nodeid): def file_explorer(self, node):
''' '''
Create, initialize, and return an :py:class:`~meshctrl.files.Files` object for the given node Create, initialize, and return an :py:class:`~meshctrl.files.Files` object for the given node
Args: Args:
nodeid (str): Unique id on which to open file explorer node (~meshctrl.device.Device|str): Device or id of device on which to open file explorer. If it is a device, it must have a ~meshctrl.mesh.Mesh device associated with it (the default). If it is a string, the device will be fetched prior to tunnel creation.
Returns: Returns:
:py:class:`~meshctrl.files.Files`: A newly initialized file explorer. :py:class:`~meshctrl.files.Files`: A newly initialized file explorer.
''' '''
return files.Files(self, nodeid) return _FileExplorerWrapper(self, node)
# This is a little yucky, but I can't get a good API otherwise. Since Tunnel objects are only useable as context managers anyway, this should be fine.
class _FileExplorerWrapper:
def __init__(self, session, node):
self.session = session
self.node = node
self._files = None
async def __aenter__(self):
if not isinstance(self.node, device.Device):
self.node = await self.session.device_info(self.node)
self._files = files.Files(self.session, self.node)
return await self._files.__aenter__()
async def __aexit__(self, exc_t, exc_v, exc_tb):
return await self._files.__aexit__(exc_t, exc_v, exc_tb)

View File

@@ -78,7 +78,6 @@ class Shell(tunnel.Tunnel):
read_bytes = 0 read_bytes = 0
while True: while True:
d = self._buffer.read1(length-read_bytes if length is not None else -1) d = self._buffer.read1(length-read_bytes if length is not None else -1)
# print(f"read: {d}")
read_bytes += len(d) read_bytes += len(d)
ret.append(d) ret.append(d)
if length is not None and read_bytes >= length: if length is not None and read_bytes >= length:
@@ -163,7 +162,6 @@ class SmartShell(object):
command += "\n" command += "\n"
await self._shell.write(command) await self._shell.write(command)
data = await self._shell.expect(self._regex, timeout=timeout) data = await self._shell.expect(self._regex, timeout=timeout)
print(repr(data))
return data[:self._compiled_regex.search(data).span()[0]] return data[:self._compiled_regex.search(data).span()[0]]
@property @property
@@ -178,14 +176,18 @@ class SmartShell(object):
def initialized(self): def initialized(self):
return self._shell.initialized return self._shell.initialized
@property
def _socket_open(self):
return self._shell._socket_open
async def close(self): async def close(self):
await self._init_task await asyncio.wait_for(self._init_task, 10)
return await self._shell.close() return await self._shell.close()
async def __aenter__(self): async def __aenter__(self):
await self._init_task
await self._shell.__aenter__() await self._shell.__aenter__()
await asyncio.wait_for(self._init_task, 10)
return self return self
async def __aexit__(self, *args): async def __aexit__(self, *args):
return await self._shell.__aexit__(*args) await self.close()

View File

@@ -45,22 +45,18 @@ class Tunnel(object):
async def _main_loop(self): async def _main_loop(self):
try: try:
authcookie = await self._session._send_command_no_response_id({ "action":"authcookie" }) self._authcookie = await self._session._send_command_no_response_id({ "action":"authcookie" })
options = {} options = {}
if self._session._ignore_ssl: if self._session._ssl_context is not None:
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) options["ssl"] = self._session._ssl_context
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
options = { "ssl": ssl_context }
if (self.node_id.split('/') != 3) and (self._session._currentDomain is not None): if (len(self.node_id.split('/')) != 3):
self.node_id = f"node/{self._session._currentDomain}/{self.node_id}" self.node_id = f"node/{self._session._currentDomain or ''}/{self.node_id}"
self._tunnel_id = util._get_random_hex(6) 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") 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=' + self._authcookie["rcookie"] }, "initialize_tunnel")
if initialize_tunnel_response.get("result", None) != "OK": 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._main_loop_error = exceptions.ServerError(initialize_tunnel_response.get("result", "Failed to initialize remote tunnel"))
self._socket_open.clear() self._socket_open.clear()
@@ -68,7 +64,8 @@ class Tunnel(object):
self.initialized.set() self.initialized.set()
return 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"]) 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=' + self._authcookie["cookie"])
async for websocket in util.proxy_connect(self.url, proxy_url=self._session._proxy, process_exception=util._process_websocket_exception, **options): async for websocket in util.proxy_connect(self.url, proxy_url=self._session._proxy, process_exception=util._process_websocket_exception, **options):
self.alive = True self.alive = True
@@ -80,7 +77,6 @@ class Tunnel(object):
except* websockets.ConnectionClosed as e: except* websockets.ConnectionClosed as e:
self._socket_open.clear() self._socket_open.clear()
if not self.auto_reconnect: if not self.auto_reconnect:
self.alive = False
raise raise
except* Exception as eg: except* Exception as eg:
self.alive = False self.alive = False

View File

@@ -149,7 +149,7 @@ def _check_socket(f):
finally: finally:
if not self.alive and self._main_loop_error is not None: if not self.alive and self._main_loop_error is not None:
raise self._main_loop_error raise self._main_loop_error
elif not self.alive: elif not self.alive and self.initialized.is_set():
raise exceptions.SocketError("Socket Closed") raise exceptions.SocketError("Socket Closed")
return await f(self, *args, **kwargs) return await f(self, *args, **kwargs)
return wrapper return wrapper

4
tests/.gitignore vendored
View File

@@ -1,2 +1,2 @@
data /data
environment/scripts/meshcentral/users.json /environment/scripts/meshcentral/users.json

View File

@@ -43,10 +43,8 @@ class Agent(object):
return self return self
def __exit__(self, exc_t, exc_v, exc_tb): def __exit__(self, exc_t, exc_v, exc_tb):
try: requests.post(f"{self._clienturl}/remove-agent/{self.nodeid}")
requests.post("{self._clienturl}/remove-agent/{self.nodeid}")
except:
pass
class TestEnvironment(object): class TestEnvironment(object):
def __init__(self): def __init__(self):

View File

@@ -3,5 +3,5 @@ RUN apk add curl
RUN apk add python3 RUN apk add python3
WORKDIR /opt/meshcentral/ WORKDIR /opt/meshcentral/
COPY ./scripts/meshcentral ./scripts COPY ./scripts/meshcentral ./scripts
COPY ./meshcentral/data /opt/meshcentral/meshcentral-data COPY ./config/meshcentral/data /opt/meshcentral/meshcentral-data
CMD ["python3", "/opt/meshcentral/scripts/create_users.py"] CMD ["python3", "/opt/meshcentral/scripts/create_users.py"]

View File

@@ -5,6 +5,7 @@ import meshctrl
import requests import requests
import io import io
import random import random
import time
async def test_commands(env): async def test_commands(env):
async with meshctrl.Session("wss://" + env.dockerurl, user="admin", password=env.users["admin"], ignore_ssl=True, proxy=env.proxyurl) as admin_session: async with meshctrl.Session("wss://" + env.dockerurl, user="admin", password=env.users["admin"], ignore_ssl=True, proxy=env.proxyurl) as admin_session:
@@ -52,6 +53,18 @@ async def test_commands(env):
finally: finally:
assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group" assert (await admin_session.remove_device_group(mesh.meshid, timeout=10)), "Failed to remove device group"
async def test_os_proxy_bypass():
os.environ["no_proxy"] = "*"
import urllib
import urllib.request
os_proxies = urllib.request.getproxies()
meshctrl_proxies = meshctrl.files.urllib.request.getproxies()
print(f"os_proxies: {os_proxies}")
print(f"meshctrl_proxies: {meshctrl_proxies}")
assert meshctrl_proxies.get("no", None) == None, "Meshctrl is using system proxies"
assert os_proxies.get("no", None) == "*", "System is using meshctrl proxies"
assert os_proxies != meshctrl_proxies, "Override didn't work"
async def test_upload_download(env): async def test_upload_download(env):
async with meshctrl.Session("wss://" + env.dockerurl, user="admin", password=env.users["admin"], ignore_ssl=True, proxy=env.proxyurl) as admin_session: async with meshctrl.Session("wss://" + env.dockerurl, user="admin", password=env.users["admin"], ignore_ssl=True, proxy=env.proxyurl) 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) mesh = await admin_session.add_device_group("test", description="This is a test group", amtonly=False, features=0, consent=0, timeout=10)
@@ -69,7 +82,7 @@ async def test_upload_download(env):
else: else:
break break
randdata = random.randbytes(2000000) randdata = random.randbytes(20000000)
upfilestream = io.BytesIO(randdata) upfilestream = io.BytesIO(randdata)
downfilestream = io.BytesIO() downfilestream = io.BytesIO()
@@ -78,7 +91,7 @@ async def test_upload_download(env):
async with admin_session.file_explorer(agent.nodeid) as files: async with admin_session.file_explorer(agent.nodeid) as files:
r = await files.upload(upfilestream, f"{pwd}/test", timeout=5) r = await files.upload(upfilestream, f"{pwd}/test", timeout=5)
print("\ninfo files_upload: {}\n".format(r)) print("\ninfo files_upload: {}\n".format(r))
assert r["result"] == "success", "Upload failed" assert r["result"] == True, "Upload failed"
assert r["size"] == len(randdata), "Uploaded wrong number of bytes" assert r["size"] == len(randdata), "Uploaded wrong number of bytes"
for f in await files.ls(pwd, timeout=5): for f in await files.ls(pwd, timeout=5):
if f["n"] == "test" and f["t"] == meshctrl.constants.FileType.FILE: if f["n"] == "test" and f["t"] == meshctrl.constants.FileType.FILE:
@@ -95,10 +108,23 @@ async def test_upload_download(env):
else: else:
raise Exception("Uploaded file not found") raise Exception("Uploaded file not found")
r = await files.download(f"{pwd}/test", downfilestream, timeout=5) start = time.perf_counter()
r = await files.download(f"{pwd}/test", downfilestream, skip_ws_attempt=True, timeout=5)
print("\ninfo files_download: {}\n".format(r)) print("\ninfo files_download: {}\n".format(r))
assert r["result"] == "success", "Domnload failed" assert r["result"] == True, "Download failed"
assert r["size"] == len(randdata), "Downloaded wrong number of bytes" assert r["size"] == len(randdata), "Downloaded wrong number of bytes"
print(f"http download time: {time.perf_counter()-start}")
downfilestream.seek(0)
assert downfilestream.read() == randdata, "Got wrong data back"
downfilestream.seek(0)
start = time.perf_counter()
r = await files.download(f"{pwd}/test", downfilestream, skip_http_attempt=True, timeout=5)
print("\ninfo files_download: {}\n".format(r))
assert r["result"] == True, "Download failed"
assert r["size"] == len(randdata), "Downloaded wrong number of bytes"
print(f"ws download time: {time.perf_counter()-start}")
downfilestream.seek(0) downfilestream.seek(0)
assert downfilestream.read() == randdata, "Got wrong data back" assert downfilestream.read() == randdata, "Got wrong data back"

View File

@@ -43,7 +43,7 @@ async def test_urlparse():
try: try:
async with meshctrl.Session("wss://localhost", user="unprivileged", password="Not a real password", ignore_ssl=True) as s: async with meshctrl.Session("wss://localhost", user="unprivileged", password="Not a real password", ignore_ssl=True) as s:
pass pass
except* TimeoutError: except* asyncio.TimeoutError:
#We're not running a server, so timeout is our expected outcome #We're not running a server, so timeout is our expected outcome
pass pass
@@ -52,5 +52,4 @@ async def test_urlparse():
async with meshctrl.Session("https://localhost", user="unprivileged", password="Not a real password", ignore_ssl=True) as s: async with meshctrl.Session("https://localhost", user="unprivileged", password="Not a real password", ignore_ssl=True) as s:
pass pass
except* ValueError: except* ValueError:
#We're not running a server, so timeout is our expected outcome
pass pass

View File

@@ -29,7 +29,7 @@ async def test_admin(env):
assert len(no_sessions.keys()) == 0, "non-admin has admin acess" 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_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" assert len(admin_sessions) == 2, "Admin cannot see correct number of user sessions"
async def test_auto_reconnect(env): async def test_auto_reconnect(env):
async with meshctrl.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True, auto_reconnect=True) as admin_session: async with meshctrl.Session(env.mcurl, user="admin", password=env.users["admin"], ignore_ssl=True, auto_reconnect=True) as admin_session:
@@ -39,6 +39,7 @@ async def test_auto_reconnect(env):
# As above, but with proxy # As above, but with proxy
async with meshctrl.Session("wss://" + env.dockerurl, user="admin", password=env.users["admin"], ignore_ssl=True, auto_reconnect=True, proxy=env.proxyurl) as admin_session: async with meshctrl.Session("wss://" + env.dockerurl, user="admin", password=env.users["admin"], ignore_ssl=True, auto_reconnect=True, proxy=env.proxyurl) as admin_session:
env.restart_mesh() env.restart_mesh()
for i in range(3): for i in range(3):
try: try:
@@ -277,21 +278,21 @@ async def test_mesh_device(env):
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" 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. # For now, this expe namects 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" # assert "meshagent" in (await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10))[agent.nodeid]["result"], "ls gave incorrect data"
try: try:
await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10) await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10)
except: except:
raise Exception("Failed to run command on device after it was moved to a new mesh while having individual device permissions") 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) 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)) 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 device group"
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(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 (await admin_session.remove_device_group(mesh2.name, isname=True, timeout=10)), "Failed to remove device group by name"
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?" 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 def test_user_groups(env):
@@ -407,7 +408,7 @@ async def test_session_files(env):
break break
pwd = (await admin_session.run_command(agent.nodeid, "pwd", timeout=10))[agent.nodeid]["result"].strip() pwd = (await admin_session.run_command(agent.nodeid, "pwd", timeout=10))[agent.nodeid]["result"].strip()
randdata = random.randbytes(2000000) randdata = random.randbytes(20000000)
upfilestream = io.BytesIO(randdata) upfilestream = io.BytesIO(randdata)
downfilestream = io.BytesIO() downfilestream = io.BytesIO()
os.makedirs(os.path.join(thisdir, "data"), exist_ok=True) os.makedirs(os.path.join(thisdir, "data"), exist_ok=True)
@@ -416,12 +417,12 @@ async def test_session_files(env):
r = await admin_session.upload(agent.nodeid, upfilestream, f"{pwd}/test", timeout=5) r = await admin_session.upload(agent.nodeid, upfilestream, f"{pwd}/test", timeout=5)
print("\ninfo files_upload: {}\n".format(r)) print("\ninfo files_upload: {}\n".format(r))
assert r["result"] == "success", "Upload failed" assert r["result"] == True, "Upload failed"
assert r["size"] == len(randdata), "Uploaded wrong number of bytes" 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) 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)) print("\ninfo files_upload: {}\n".format(r))
assert r["result"] == "success", "Upload failed" assert r["result"] == True, "Upload failed"
assert r["size"] == len(randdata), "Uploaded wrong number of bytes" assert r["size"] == len(randdata), "Uploaded wrong number of bytes"
s = await admin_session.download(agent.nodeid, f"{pwd}/test", timeout=5) s = await admin_session.download(agent.nodeid, f"{pwd}/test", timeout=5)
@@ -437,7 +438,7 @@ async def test_session_files(env):
r = await admin_session.upload_file(agent.nodeid, os.path.join(thisdir, "data", "test"), f"{pwd}/test2", unique_file_tunnel=True, timeout=5) 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["result"] == True, "Upload failed"
assert r["size"] == len(randdata), "Uploaded wrong number of bytes" 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) await admin_session.download_file(agent.nodeid, f"{pwd}/test2", os.path.join(thisdir, "data", "test"), unique_file_tunnel=True, timeout=5)