|
1 | 1 | import json |
2 | 2 | import websockets |
3 | 3 | import asyncio |
4 | | - |
| 4 | +from typing import Optional |
5 | 5 | from . import utils |
6 | 6 |
|
7 | | -class MeshCtrl(): |
8 | 7 |
|
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 | + ) |
13 | 80 |
|
14 | 81 | 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 | + """ |
17 | 91 |
|
18 | | - async with websockets.connect(uri) as websocket: |
| 92 | + async with websockets.connect( |
| 93 | + self.url, extra_headers=self.headers |
| 94 | + ) as websocket: |
19 | 95 | await websocket.send(json.dumps(data)) |
20 | 96 |
|
21 | 97 | async for message in websocket: |
22 | 98 | response = json.loads(message) |
23 | | - #print(response) |
24 | | - |
25 | | - if "responseid" in data: |
| 99 | + if "responseid" in response: |
26 | 100 | if data["responseid"] == response["responseid"]: |
27 | 101 | return response |
28 | 102 | else: |
29 | 103 | if data["action"] == response["action"]: |
30 | 104 | return response |
31 | 105 |
|
32 | | - def _send(self, data): |
| 106 | + def _send(self, data: dict) -> dict: |
| 107 | + """Initiates asynchronous call""" |
| 108 | + |
33 | 109 | return asyncio.run(self._websocket_call(data)) |
34 | 110 |
|
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 | + |
37 | 178 | data = { |
38 | | - "action": "meshes" |
| 179 | + "action": "meshes", |
39 | 180 | } |
40 | 181 |
|
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 |
42 | 222 |
|
| 223 | + Returns: |
| 224 | + dict: Returns a confirmation that the device group was created |
43 | 225 |
|
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 = { |
47 | 237 | "action": "createmesh", |
48 | 238 | "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(), |
51 | 244 | } |
52 | 245 |
|
53 | 246 | return self._send(data) |
54 | 247 |
|
| 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 | + |
55 | 350 | # run command on an agent |
56 | 351 | def run_command(self, node_id: str, command: str, runAsUser: int = 0) -> dict: |
57 | | - data = { |
| 352 | + |
| 353 | + data = { |
58 | 354 | "action": "runcommands", |
59 | 355 | "cmds": command, |
60 | 356 | "nodeids": [f"node//{utils.b64_to_hex(node_id)}"], |
61 | 357 | "runAsUser": runAsUser, |
62 | 358 | "type": 1, |
63 | | - "responseid": utils.gen_response_id() |
| 359 | + "responseid": utils.gen_response_id(), |
64 | 360 | } |
65 | 361 |
|
66 | 362 | return self._send(data) |
0 commit comments