Skip to content

Commit 014a6b6

Browse files
author
sadnub
committed
add password authentication option and some validation. Also added operations for device groups
1 parent 088e8d7 commit 014a6b6

File tree

3 files changed

+335
-30
lines changed

3 files changed

+335
-30
lines changed

src/meshctrl/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .meshctrl import MeshCtrl
1+
from .meshctrl import MeshCtrl

src/meshctrl/meshctrl.py

Lines changed: 320 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,362 @@
11
import json
22
import websockets
33
import asyncio
4-
4+
from typing import Optional
55
from . import utils
66

7-
class MeshCtrl():
87

9-
def __init__(self, uri: str, token: str, user: str):
10-
self.uri = uri
11-
self.user = user
12-
self.token = token
8+
class MeshCtrl:
9+
"""MeshCentral websocket client class.
10+
11+
Attributes:
12+
url: A url string used to connect to the MeshCentral instance.
13+
headers: An optional set of headers to pass to websocket client.
14+
"""
15+
16+
def __init__(
17+
self,
18+
loginkey: Optional[str] = None,
19+
token: Optional[str] = None,
20+
loginpass: Optional[str] = None,
21+
loginuser: str = "admin",
22+
logindomain: str = "",
23+
url: str = "wss://localhost:443",
24+
):
25+
"""Inits MeshCtrl with configuration
26+
27+
Args:
28+
url (str):
29+
url used to connect to meshcentral instance. (default is wss://localhost:443).
30+
loginuser (str):
31+
login username for password authentication. (default is admin).
32+
loginkey (str, optional):
33+
Use hex login key to authenticate with meshcentral.
34+
token (str, optional):
35+
supply a 2fa token for login.
36+
loginpass (str, optional):
37+
login password for password authentication.
38+
logindomain (str):
39+
login domain for password authentication. (default is "").
40+
41+
Raises:
42+
ValueError: If the required parameters are missing or misused.
43+
"""
44+
45+
self.headers = {}
46+
self.url = url
47+
48+
# check for valid url
49+
if (
50+
len(self.url) < 5
51+
and not self.url.startswith("wss://")
52+
and not self.url.startswith("ws://")
53+
):
54+
raise ValueError(f"Url parameter supplied is invalid: {url}")
55+
56+
if not self.url.endswith("/"):
57+
self.url += "/control.ashx"
58+
else:
59+
self.url += "control.ashx"
60+
61+
# make sure auth method is configured
62+
if not loginkey and not loginpass:
63+
raise ValueError(
64+
"You must configure either password or cookie authentication"
65+
)
66+
67+
# check for password authentication
68+
if loginpass:
69+
self.headers = {
70+
"x-meshauth": utils.get_pwd_auth(loginuser, loginpass, token)
71+
}
72+
73+
# check for cookie auth
74+
if loginkey:
75+
if len(loginkey) != 160:
76+
raise ValueError("The loginkey is invalid")
77+
self.url += (
78+
f"?auth={utils.get_auth_token(loginuser, loginkey, logindomain)}"
79+
)
1380

1481
async def _websocket_call(self, data: dict) -> dict:
15-
token = utils.get_auth_token(self.user, self.token)
16-
uri = f"{self.uri}/control.ashx?auth={token}"
82+
"""Initiates the websocket connection to mesh and returns the data.
83+
84+
Args:
85+
data (dict):
86+
The data passed to MeshCentral.
87+
88+
Returns:
89+
dict: MeshCentral Response.
90+
"""
1791

18-
async with websockets.connect(uri) as websocket:
92+
async with websockets.connect(
93+
self.url, extra_headers=self.headers
94+
) as websocket:
1995
await websocket.send(json.dumps(data))
2096

2197
async for message in websocket:
2298
response = json.loads(message)
23-
#print(response)
24-
25-
if "responseid" in data:
99+
if "responseid" in response:
26100
if data["responseid"] == response["responseid"]:
27101
return response
28102
else:
29103
if data["action"] == response["action"]:
30104
return response
31105

32-
def _send(self, data):
106+
def _send(self, data: dict) -> dict:
107+
"""Initiates asynchronous call"""
108+
33109
return asyncio.run(self._websocket_call(data))
34110

35-
# pulls a list of groups in MeshCentral
36-
def get_mesh_groups(self) -> dict:
111+
def get_device_group_id_by_name(self, group: str) -> Optional[str]:
112+
"""Get the device group id by group name.
113+
114+
Args:
115+
group (str):
116+
Used to search through device groups.
117+
118+
Returns:
119+
str, None:
120+
Returns device group id if the device group exists otherwise returns None.
121+
"""
122+
123+
device_groups = self.list_device_groups()
124+
125+
for device_group in device_groups:
126+
if device_group["name"] == group:
127+
return device_group["_id"]
128+
129+
return None
130+
131+
def device_group_exists(
132+
self, group: Optional[str] = None, id: Optional[str] = None
133+
) -> bool:
134+
"""Check if a device group exists by group name or id.
135+
136+
This method needs either group or id arguments set. If both are set then group
137+
takes precedence.
138+
139+
Args:
140+
group (str):
141+
Used to check if a device group with the same name exists.
142+
id (str):
143+
Used to check if a device group with the same id exists.
144+
145+
Returns:
146+
bool: True or False depending on if the device group exists.
147+
"""
148+
149+
if not group and not id:
150+
raise ValueError("Arguments group or id must be specified")
151+
152+
device_groups = self.list_device_groups()
153+
154+
for device_group in device_groups:
155+
if device_group:
156+
if device_group["name"] == group:
157+
return True
158+
elif id:
159+
if device_group["_id"] == id:
160+
return True
161+
162+
return False
163+
164+
def list_device_groups(self, hex: bool = False) -> list:
165+
"""List device groups
166+
167+
All device group ids returned from MeshCentral have a `mesh//`
168+
prepended. This function strips it so that other operations that use
169+
this don't have to.
170+
171+
Args:
172+
hex (bool, optional): Converts the mesh ids to hex.
173+
174+
Returns:
175+
list: Mesh device groups.
176+
"""
177+
37178
data = {
38-
"action": "meshes"
179+
"action": "meshes",
39180
}
40181

41-
return self._send(data)
182+
device_groups = self._send(data)
183+
184+
if hex:
185+
for group in device_groups["meshes"]:
186+
group["_id"] = utils.b64_to_hex(group["_id"].split("//")[1])
187+
else:
188+
for group in device_groups["meshes"]:
189+
group["_id"] = group["_id"].split("//")[1]
190+
191+
return device_groups["meshes"]
192+
193+
# TODO: Don't create device group if name already exists
194+
def add_device_group(
195+
self,
196+
name: str,
197+
desc: str = "",
198+
amt_only: bool = False,
199+
features: int = 0,
200+
consent: int = 0,
201+
) -> dict:
202+
"""Add device group
203+
204+
Args:
205+
name (str): Name of device group.
206+
desc (str, optional): Description of device group.
207+
amt_only (bool): Sets the group to AMT only. (default is false).
208+
features (int, optional):
209+
Optional features to enable for the device group. Sum of numbers below.
210+
1. Auto-Remove
211+
2. Hostname Sync
212+
4. Record Sessions
213+
consent (int, optional):
214+
Optionally set the users consent for features. Sum of numbers below:
215+
1. Desktop notify user
216+
2. Terminal notify user
217+
4. Files notify user
218+
8. Desktop prompt user
219+
16. Terminal prompt user
220+
32. Files prompt user
221+
64. Desktop toolbar
42222
223+
Returns:
224+
dict: Returns a confirmation that the device group was created
43225
44-
# created a group with the specified name
45-
def create_mesh_group(self, name: str) -> dict:
46-
data = {
226+
Example:
227+
{
228+
'action': 'createmesh',
229+
'responseid': '259c3c66-8b74-4d0d-8d8b-a8a935220c1b',
230+
'result': 'ok',
231+
'meshid': 'mesh//a8tVU0ytXINDMaokjDPuGPimWQL0otT7YL0pOqgvV5wolzKsK$YnjB02GeuYDo1k',
232+
'links': {'user//tactical': {'name': 'tactical', 'rights': 4294967295}}
233+
}
234+
"""
235+
236+
data = {
47237
"action": "createmesh",
48238
"meshname": name,
49-
"meshtype": 2,
50-
"responseid": utils.gen_response_id()
239+
"meshtype": 2 if not amt_only else 1,
240+
"desc": desc,
241+
"features": features,
242+
"consent": consent,
243+
"responseid": utils.gen_response_id(),
51244
}
52245

53246
return self._send(data)
54247

248+
def remove_device_group(
249+
self, id: Optional[str] = None, group: Optional[str] = None
250+
) -> dict:
251+
"""Remove device group by group name or id
252+
253+
This method needs either group or id arguments set. If both are set then group
254+
takes precedence.
255+
256+
Args:
257+
group (str):
258+
Name of the device group to be deleted.
259+
id (str):
260+
Id of the device group to be deleted. Works with and without 'mesh//' in the id.
261+
262+
Returns:
263+
dict: Returns a confirmation that the device group was deleted.
264+
265+
Example:
266+
{
267+
'action': 'deletemesh',
268+
'responseid': '53bc566e-2fe6-41ed-ae2e-8da25a4bff6c',
269+
'result': 'ok'
270+
}
271+
"""
272+
273+
if not group and not id:
274+
raise ValueError("Arguments name or id must be specified")
275+
276+
data = {"action": "deletemesh", "responseid": utils.gen_response_id()}
277+
278+
if group:
279+
data["meshname"] = group
280+
elif id:
281+
data["meshid"] = id
282+
283+
return self._send(data)
284+
285+
# TODO: look into inviteCodes options
286+
# TODO: Don't create device group if name already exists
287+
def edit_device_group(
288+
self,
289+
id: Optional[str] = None,
290+
group: Optional[str] = None,
291+
name: Optional[str] = None,
292+
desc: Optional[str] = None,
293+
features: Optional[int] = None,
294+
consent: Optional[int] = None,
295+
) -> dict:
296+
"""Edit device group by group name or id
297+
298+
This method needs either group or id arguments set. If both are set then group
299+
takes precedence.
300+
301+
Args:
302+
group (str):
303+
Name of the device group to be updated.
304+
id (str):
305+
Id of the device group to be updated. Works with and without 'mesh//' in the id.
306+
name (str, optional):
307+
New name for device group.
308+
desc (str, optional):
309+
New description for device group.
310+
features (int, optional):
311+
Change device group features. See add_device_group for options.
312+
consent (int, optional):
313+
Change consent options on device group. See add_device_group for options.
314+
315+
Returns:
316+
dict: Returns a confirmation that the device group was updated.
317+
318+
Example:
319+
{
320+
'action': 'editmesh',
321+
'responseid': '3f560b80-7e97-43ba-8037-0ea1d3730ae2',
322+
'result': 'ok'
323+
}
324+
"""
325+
326+
if not group and not id:
327+
raise ValueError("Arguments group or id must be specified")
328+
329+
data = {"action": "editmesh", "responseid": utils.gen_response_id()}
330+
331+
if group:
332+
data["meshidname"] = group
333+
elif id:
334+
data["meshid"] = id
335+
336+
if name:
337+
data["meshname"] = name
338+
339+
if desc:
340+
data["desc"] = desc
341+
342+
if features:
343+
data["flags"] = features
344+
345+
if consent:
346+
data["consent"] = consent
347+
348+
return self._send(data)
349+
55350
# run command on an agent
56351
def run_command(self, node_id: str, command: str, runAsUser: int = 0) -> dict:
57-
data = {
352+
353+
data = {
58354
"action": "runcommands",
59355
"cmds": command,
60356
"nodeids": [f"node//{utils.b64_to_hex(node_id)}"],
61357
"runAsUser": runAsUser,
62358
"type": 1,
63-
"responseid": utils.gen_response_id()
359+
"responseid": utils.gen_response_id(),
64360
}
65361

66362
return self._send(data)

0 commit comments

Comments
 (0)