15 Commits
v0.1 ... v1

Author SHA1 Message Date
Daan
32069cd266 Working version. Rewrite complete. Version 2 soon. 2025-01-09 00:22:07 +01:00
Daan Selen
6a0127be78 Rewrite working now only needs output formatting. 2025-01-08 16:56:25 +01:00
Daan
e5e4aba47e Progress on rewrite. 2024-12-26 00:14:08 +01:00
dselen
e8e08d0d72 Update README.md
typos and note
2024-11-29 09:29:35 +01:00
Daan
a1fb69652b Clarified examples 2024-11-29 09:27:47 +01:00
Daan
df2185612a Expanded documentation! 2024-11-29 09:27:38 +01:00
dselen
8aea76fd1e Update README.md 2024-11-28 16:12:28 +01:00
dselen
1fd09beaf9 > < 2024-11-28 12:32:39 +01:00
dselen
c702107b0a Create SECURITY.md 2024-11-28 12:31:56 +01:00
Daan
917e4b3c9e Changed readme 2024-11-28 10:27:33 +01:00
dselen
48b77f5e66 Update README.md 2024-11-28 09:53:54 +01:00
dselen
bccebb4707 Merge pull request #1 from DaanSelen/dev
Version v0.1.1 added variable support!
2024-11-28 09:50:08 +01:00
Daan
c66c7bbb98 Clarified readme and used examples. 2024-11-28 09:49:29 +01:00
Daan
88e2115be9 Added new example with a slash! 2024-11-28 09:44:35 +01:00
Daan
f05ab15d4b SUPER! Support for variables, might be a little bit buggy for now! 2024-11-28 09:41:49 +01:00
14 changed files with 729 additions and 367 deletions

156
README.md
View File

@@ -1,3 +1,6 @@
> [!NOTE]
> *If you experience issues or have suggestions, submit an issue! https://github.com/DaanSelen/meshbook/issues I'll respond ASAP!*
# Meshbook
A way to programmatically manage MeshCentral-managed machines, a bit like Ansible does.<br>
@@ -8,102 +11,205 @@ And many people will be comfortable with YAML configurations! It's almost like J
The quickest way to start is to grab a template from the templates folder in this repository.<br>
Make sure to correctly pass the MeshCentral websocket API as `wss://<MeshCentral-Host>/control.ashx`.<br>
And make sure to fill in the credentails of an account which has remote commands permissions.<br>
And make sure to fill in the credentails of an account which has `Remote Commands` permissions and `Device Details` permissions on the targeted devices or groups.<br>
> I did this through a "Global Service" group which I added the meshbook account to!
Then make a yaml with a target and some commands! See below examples as a guideline. And do not forget to look at the bottom's notice.<br>
To install, follow the following commands:<br>
### Linux setup:
```shell
git clone https://github.com/daanselen/meshbook
cd ./meshbook
python3 -m venv ./venv
source ./venv/bin/activate
pip3 install -r requirements.txt
pip3 install -r ./meshbook/requirements.txt
```
Then you can use meshbook, for example:
### Windows setup:
```shell
python3 meshbook.py -pb examples/ping.yaml
git clone https://github.com/daanselen/meshbook
cd ./meshbook
python3 -m venv ./venv
.\venv\Scripts\activate # Make sure to check the terminal prefix.
pip3 install -r ./meshbook/requirements.txt
```
# Example:
Now copy the configuration template from ./templates and fill it in with the correct details. The url should start with `wss://` and end in `control.ashx`.<br>
After this you can use meshbook, for example:
For the example, I used the following yaml file:
### Linux run:
```shell
python3 .\meshbook\meshbook.py -pb .\examples\echo.yaml
```
### Windows run:
```shell
.\venv\Scripts\python.exe .\meshbook\meshbook.py -pb .\examples\echo.yaml
```
### How to check if everything is okay?
The python virtual environment can get messed up, therefore...<br>
To check if everything is in working order, make sure that the lists from the following commands are aligned:
```
python3 -m pip list
pip3 list
```
If not, perhaps you are using the wrong executable, the wrong environment and so on...
# How to create a configuration?
This paragraph explains how the program interprets certain information.
### Targeting:
MeshCentral has `meshes` or `groups`, in this program they are called `companies`. Because of the way I designed this.<br>
So to target for example a mesh/group in MeshCentral called: "Nerthus" do:
> If your group has multiple words, then you need to use `"` to group the words.
```yaml
---
name: Ping a single Point
company: Temp-Agents
name: example configuration
company: "Nerthus"
variables:
- name: var1
value: "This is the first variable"
tasks:
- name: Ping Cloudflare
command: "ping 1.1.1.1 -c 4"
- name: echo the first variable!
command: 'echo "{{ var1 }}"'
```
The above group: `Temp-Agents` has four devices, of which one is offline.<br>
It is also possible to target a single device, as seen in: [here](./examples/echo.yaml).<br>
### Variables:
Variables are done by replacing the placeholders just before the runtime.<br>
So if you have var1 declared, then the value of that declaration is placed wherever it finds {{ var1 }}.<br>
This is done to imitate popular methods. See below [from the example](./examples/variable_example.yaml).<br>
### Tasks:
The tasks you want to run should be contained under the `tasks:` with two fields, `name` and `command`.<br>
The name field is for the user of meshbook, to clarify what the following command does in a summary.<br>
The command field actually gets executed on the end-point.<br>
# Example:
For the example, I used the following yaml file (you can find more in [this directory](./examples/)):
The below group: `Temp-Agents` has four devices, of which one is offline.<br>
You can expand the command chain as follows:<br>
```yaml
---
name: Ping Multiple Points
company: Temp-Agents
variables:
- name: host1
value: "1.1.1.1"
- name: host2
value: "9.9.9.9"
- name: command1
value: "ping"
- name: cmd_arguments
value: "-c 4"
tasks:
- name: Ping Cloudflare
command: "ping 1.1.1.1 -c 4"
- name: Ping host1
command: "{{ command1 }} {{ host1 }} {{ cmd_arguments }}"
- name: Ping Google
command: "ping 8.8.8.8 -c 4"
- name: Ping host2
command: "{{ command1 }} {{ host2 }} {{ cmd_arguments }}"
```
The following response it received when executing the first yaml of the above files.
The following response it received when executing the first yaml of the above files (with the `-s` and the `-i` parameters).
```shell
python3 meshbook.py -pb examples/ping.yaml -s
python3 meshbook/meshbook.py -pb examples/variable_example.yaml -si
-=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=-
Running task: {'name': 'Ping host1', 'command': 'ping 1.1.1.1 -c 4'}
-=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=-
Current Batch: 1
Current response number: 1
Current Calculation: 1 % 3 = 1
Current Batch: 1
Current response number: 2
Current Calculation: 2 % 3 = 2
Current Batch: 1
Current response number: 3
Current Calculation: 3 % 3 = 0
-=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=-
Running task: {'name': 'Ping host2', 'command': 'ping 9.9.9.9 -c 4'}
-=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=-
Current Batch: 2
Current response number: 4
Current Calculation: 4 % 3 = 1
Current Batch: 2
Current response number: 5
Current Calculation: 5 % 3 = 2
Current Batch: 2
Current response number: 6
Current Calculation: 6 % 3 = 0
-=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=-
{
"Batch 1": [
{
"action": "msg",
"type": "runcommands",
"result": "PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.\n64 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=6.88 ms\n64 bytes from 1.1.1.1: icmp_seq=2 ttl=59 time=6.50 ms\n64 bytes from 1.1.1.1: icmp_seq=3 ttl=59 time=6.46 ms\n64 bytes from 1.1.1.1: icmp_seq=4 ttl=59 time=6.51 ms\n\n--- 1.1.1.1 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3005ms\nrtt min/avg/max/mdev = 6.460/6.588/6.879/0.169 ms\n",
"result": "PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.\n64 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=6.73 ms\n64 bytes from 1.1.1.1: icmp_seq=2 ttl=59 time=6.37 ms\n64 bytes from 1.1.1.1: icmp_seq=3 ttl=59 time=6.31 ms\n64 bytes from 1.1.1.1: icmp_seq=4 ttl=59 time=6.44 ms\n\n--- 1.1.1.1 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3004ms\nrtt min/avg/max/mdev = 6.312/6.461/6.727/0.159 ms\n",
"responseid": "meshctrl",
"nodeid": "MSI"
},
{
"action": "msg",
"type": "runcommands",
"result": "PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.\n64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=6.22 ms\n64 bytes from 1.1.1.1: icmp_seq=2 ttl=57 time=6.07 ms\n64 bytes from 1.1.1.1: icmp_seq=3 ttl=57 time=5.97 ms\n64 bytes from 1.1.1.1: icmp_seq=4 ttl=57 time=5.90 ms\n\n--- 1.1.1.1 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3004ms\nrtt min/avg/max/mdev = 5.904/6.038/6.216/0.117 ms\n",
"result": "PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.\n64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=6.18 ms\n64 bytes from 1.1.1.1: icmp_seq=2 ttl=57 time=6.17 ms\n64 bytes from 1.1.1.1: icmp_seq=3 ttl=57 time=6.17 ms\n64 bytes from 1.1.1.1: icmp_seq=4 ttl=57 time=6.27 ms\n\n--- 1.1.1.1 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3004ms\nrtt min/avg/max/mdev = 6.170/6.200/6.274/0.042 ms\n",
"responseid": "meshctrl",
"nodeid": "server"
"nodeid": "raspberrypi5"
},
{
"action": "msg",
"type": "runcommands",
"result": "PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.\n64 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=6.83 ms\n64 bytes from 1.1.1.1: icmp_seq=2 ttl=59 time=6.64 ms\n64 bytes from 1.1.1.1: icmp_seq=3 ttl=59 time=6.65 ms\n64 bytes from 1.1.1.1: icmp_seq=4 ttl=59 time=6.53 ms\n\n--- 1.1.1.1 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3005ms\nrtt min/avg/max/mdev = 6.534/6.664/6.834/0.108 ms\n",
"result": "PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.\n64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=6.33 ms\n64 bytes from 1.1.1.1: icmp_seq=2 ttl=57 time=6.13 ms\n64 bytes from 1.1.1.1: icmp_seq=3 ttl=57 time=5.92 ms\n64 bytes from 1.1.1.1: icmp_seq=4 ttl=57 time=5.91 ms\n\n--- 1.1.1.1 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3005ms\nrtt min/avg/max/mdev = 5.908/6.072/6.334/0.173 ms\n",
"responseid": "meshctrl",
"nodeid": "raspberrypi5"
"nodeid": "server"
}
],
"Batch 2": [
{
"action": "msg",
"type": "runcommands",
"result": "PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.\n64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=5.69 ms\n64 bytes from 8.8.8.8: icmp_seq=2 ttl=118 time=5.22 ms\n64 bytes from 8.8.8.8: icmp_seq=3 ttl=118 time=5.19 ms\n64 bytes from 8.8.8.8: icmp_seq=4 ttl=118 time=5.16 ms\n\n--- 8.8.8.8 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3004ms\nrtt min/avg/max/mdev = 5.161/5.315/5.694/0.219 ms\n",
"result": "PING 9.9.9.9 (9.9.9.9) 56(84) bytes of data.\n64 bytes from 9.9.9.9: icmp_seq=1 ttl=61 time=10.4 ms\n64 bytes from 9.9.9.9: icmp_seq=2 ttl=61 time=9.96 ms\n64 bytes from 9.9.9.9: icmp_seq=3 ttl=61 time=9.83 ms\n64 bytes from 9.9.9.9: icmp_seq=4 ttl=61 time=9.96 ms\n\n--- 9.9.9.9 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3005ms\nrtt min/avg/max/mdev = 9.830/10.036/10.396/0.214 ms\n",
"responseid": "meshctrl",
"nodeid": "MSI"
},
{
"action": "msg",
"type": "runcommands",
"result": "PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.\n64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=5.65 ms\n64 bytes from 8.8.8.8: icmp_seq=2 ttl=118 time=5.28 ms\n64 bytes from 8.8.8.8: icmp_seq=3 ttl=118 time=5.25 ms\n64 bytes from 8.8.8.8: icmp_seq=4 ttl=118 time=5.25 ms\n\n--- 8.8.8.8 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3004ms\nrtt min/avg/max/mdev = 5.246/5.357/5.648/0.168 ms\n",
"result": "PING 9.9.9.9 (9.9.9.9) 56(84) bytes of data.\n64 bytes from 9.9.9.9: icmp_seq=1 ttl=60 time=10.8 ms\n64 bytes from 9.9.9.9: icmp_seq=2 ttl=60 time=10.6 ms\n64 bytes from 9.9.9.9: icmp_seq=3 ttl=60 time=10.5 ms\n64 bytes from 9.9.9.9: icmp_seq=4 ttl=60 time=10.5 ms\n\n--- 9.9.9.9 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3005ms\nrtt min/avg/max/mdev = 10.450/10.593/10.773/0.118 ms\n",
"responseid": "meshctrl",
"nodeid": "raspberrypi5"
},
{
"action": "msg",
"type": "runcommands",
"result": "PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.\n64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=4.94 ms\n64 bytes from 8.8.8.8: icmp_seq=2 ttl=118 time=4.68 ms\n64 bytes from 8.8.8.8: icmp_seq=3 ttl=118 time=4.79 ms\n64 bytes from 8.8.8.8: icmp_seq=4 ttl=118 time=4.77 ms\n\n--- 8.8.8.8 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3005ms\nrtt min/avg/max/mdev = 4.678/4.792/4.940/0.094 ms\n",
"result": "PING 9.9.9.9 (9.9.9.9) 56(84) bytes of data.\n64 bytes from 9.9.9.9: icmp_seq=1 ttl=59 time=10.8 ms\n64 bytes from 9.9.9.9: icmp_seq=2 ttl=59 time=10.6 ms\n64 bytes from 9.9.9.9: icmp_seq=3 ttl=59 time=10.9 ms\n64 bytes from 9.9.9.9: icmp_seq=4 ttl=59 time=10.7 ms\n\n--- 9.9.9.9 ping statistics ---\n4 packets transmitted, 4 received, 0% packet loss, time 3006ms\nrtt min/avg/max/mdev = 10.600/10.750/10.898/0.117 ms\n",
"responseid": "meshctrl",
"nodeid": "server"
}
]
}
All tasks completed successfully: Expected 6 Received 6
```
The above with `-si` is quite verbose. use `--help` to read about parameters.
# Important Notice:

11
SECURITY.md Normal file
View File

@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| < 1.0 | :white_check_mark: |
## Reporting a Vulnerability
If you encounter a vulnerability, report this by sending a email to dselen@nerthus.nl or via the GitHub issues.

18
examples/apt_upgrade.yaml Normal file
View File

@@ -0,0 +1,18 @@
---
name: Refresh the apt cache
group: "Dev"
variables:
- name: package_manager
value: "apt"
tasks:
- name: refresh the cache
command: "{{ package_manager }} update"
- name: display available upgrades
command: "{{ package_manager }} list --upgradable"
- name: apply upgrades
command: "{{ package_manager }} upgrade -y"
- name: cleanup remaining packages
command: "{{ package_manager }} autoremove -y"

31
examples/big_test.yaml Normal file
View File

@@ -0,0 +1,31 @@
---
name: Echo some text in the terminal of the device
group: "Kubernetes"
variables:
- name: package_manager
value: "apt"
- name: google_dns
value: "8.8.8.8"
- name: "quad9_dns"
value: "9.9.9.9"
tasks:
- name: refresh the cache
command: "{{ package_manager }} update"
- name: display available upgrades
command: "{{ package_manager }} list --upgradable"
- name: apply upgrades
command: "{{ package_manager }} upgrade -y"
- name: cleanup remaining packages
command: "{{ package_manager }} autoremove -y"
- name: run autoclean
command: "{{ package_manager }} autoclean -y"
- name: Ping Google DNS
command: "ping {{ google_dns }} -c 4"
- name: Ping Quad9 DNS
command: "ping {{ quad9_dns }} -c 4"

13
examples/echo.yaml Normal file
View File

@@ -0,0 +1,13 @@
---
name: Echo some text in the terminal of the device
group: "Kubernetes"
variables:
- name: var1
value: "Testing"
- name: var2
value: "with a slash!"
tasks:
- name: Echo!
command: "echo {{ var1 }}/{{ var2 }}"
- name: Echo 2!
command: "echo womp womp"

View File

@@ -0,0 +1,12 @@
---
name: Refresh the apt cache
group: "Temp-Agents"
variables:
- name: package_manager
value: "apt"
tasks:
- name: refresh the cache
command: "{{ package_manager }} update"
- name: display available upgrades
command: "{{ package_manager }} list --upgradable"

View File

@@ -0,0 +1,18 @@
---
name: Ping Multiple Points
group: "Kubernetes"
variables:
- name: host1
value: "1.1.1.1"
- name: host2
value: "9.9.9.9"
- name: command1
value: "ping"
- name: cmd_arguments
value: "-c 4"
tasks:
- name: Ping host1
command: "{{ command1 }} {{ host1 }} {{ cmd_arguments }}"
- name: Ping host2
command: "{{ command1 }} {{ host2 }} {{ cmd_arguments }}"

343
legacy/meshbook-legacy.py Normal file
View File

@@ -0,0 +1,343 @@
#!/bin/python3
import argparse
import asyncio
from base64 import b64encode
from configparser import ConfigParser
import json
import math
import os
import yaml
import websockets
sequence = 0
response_counter = 0
expected_responses = 0
basic_ready_state = asyncio.Event()
ready_for_next = asyncio.Event()
global_list = []
responses_dict = {}
class ScriptEndTrigger(Exception):
"""Custom Exception to handle script termination events."""
pass
class MeshbookUtilities:
"""Helper utility functions for the Meshcaller application."""
@staticmethod
def base64_encode(string: str) -> str:
"""Encode a string in Base64 format."""
return b64encode(string.encode('utf-8')).decode()
@staticmethod
def get_target_ids(company: str = None, device: str = None) -> list:
"""Retrieve target IDs based on company or device."""
ids = []
for entry in global_list:
nodes = entry.get('nodes', [])
if company and not device:
if entry.get('mesh_name') == company:
ids.extend(node['node_id'] for node in nodes if node.get('powered_on'))
elif device and not company:
for node in nodes:
if node['node_name'] == device and node.get('powered_on'):
return [node['node_id']] # Immediate return for single device
elif not company and not device:
ids.extend(node['node_id'] for node in nodes if node.get('powered_on'))
return ids
@staticmethod
def read_yaml(file_path: str) -> dict:
"""Read a YAML file and return its content as a dictionary."""
with open(file_path, 'r') as file:
return yaml.safe_load(file)
@staticmethod
def replace_placeholders(playbook) -> dict:
# Convert 'variables' to a dictionary for quick lookup
variables = {var["name"]: var["value"] for var in playbook.get("variables", [])}
# Traverse 'tasks' to replace placeholders
for task in playbook.get("tasks", []):
command = task.get("command", "")
for var_name, var_value in variables.items():
placeholder = f"{{{{ {var_name} }}}}" # Create the placeholder string like "{{ host1 }}"
command = command.replace(placeholder, var_value) # Update the command string
task["command"] = command # Save the updated command string
return playbook
@staticmethod
def translate_nodeids(batches_dict, global_list) -> dict:
for batch_name, items in batches_dict.items():
for item in items: # Process each item in the batch
node_id = item["nodeid"] # Get the nodeid field
real_name = None
for company in global_list:
for machine in company["nodes"]:
if machine["node_id"] == node_id:
real_name = machine["node_name"]
break
if real_name:
break
# If found, replace the nodeid with the real node name, otherwise mark it as "Unknown Node"
if real_name:
item["nodeid"] = real_name
return batches_dict
class MeshbookWebsocket:
"""Handles WebSocket connections and interactions."""
def __init__(self):
self.meshsocket = None
self.received_response_queue = asyncio.Queue()
async def ws_on_open(self):
"""Called when WebSocket connection is established."""
if not args.silent:
print('Connection established.')
async def ws_on_close(self):
"""Called when WebSocket connection is closed."""
print('Connection closed by remote host.')
raise ScriptEndTrigger("WebSocket connection closed.")
async def ws_on_message(self, message: str):
"""Processes incoming WebSocket messages."""
try:
received_response = json.loads(message)
await self.received_response_queue.put(received_response)
except json.JSONDecodeError:
print("Error processing:", message)
raise ScriptEndTrigger("Failed to decode JSON message.")
async def ws_send_data(self, message: str):
"""Send data to the WebSocket server."""
if not self.meshsocket:
raise ScriptEndTrigger("WebSocket connection not established. Unable to send data.")
if not args.silent:
print('Sending data to the server.')
await self.meshsocket.send(message)
async def gen_simple_list(self):
"""Send requests to retrieve mesh and node lists."""
await self.ws_send_data(json.dumps({'action': 'meshes', 'responseid': 'meshctrl'}))
await self.ws_send_data(json.dumps({'action': 'nodes', 'responseid': 'meshctrl'}))
async def ws_handler(self, uri: str, username: str, password: str):
"""Main WebSocket connection handler."""
login_string = f'{MeshbookUtilities.base64_encode(username)},{MeshbookUtilities.base64_encode(password)}'
ws_headers = {
'User-Agent': 'MeshCentral API client',
'x-meshauth': login_string
}
if not args.silent:
print("Attempting WebSocket connection...")
try:
async with websockets.connect(uri, additional_headers=ws_headers) as meshsocket:
self.meshsocket = meshsocket
await self.ws_on_open()
await self.gen_simple_list()
while True:
try:
message = await meshsocket.recv()
await self.ws_on_message(message)
except websockets.ConnectionClosed:
await self.ws_on_close()
break
except ScriptEndTrigger as e:
print(f"WebSocket handler terminated: {e}")
except Exception as e:
print(f"An error occurred: {e}")
class MeshbookProcessor:
"""Processes data received from the WebSocket."""
def __init__(self):
self.basic_temp_list = []
def handle_basic_data(self, data):
"""Handles basic data from the server."""
if not args.silent:
print("Processing received basic data...")
self.basic_temp_list.append(data)
if len(self.basic_temp_list) < 2:
return
temp_dict = {}
for entry in self.basic_temp_list:
if isinstance(entry, list):
for mesh in entry:
if mesh.get("type") == "mesh":
mesh_id = mesh["_id"]
temp_dict[mesh_id] = {
"mesh_name": mesh.get("name", "Unknown Mesh"),
"mesh_desc": mesh.get("desc", "No description"),
"nodes": []
}
elif isinstance(entry, dict):
for mesh_id, nodes in entry.items():
if mesh_id in temp_dict:
temp_dict[mesh_id]["nodes"].extend(nodes)
else:
temp_dict[mesh_id] = {
"mesh_name": "Unknown Mesh",
"mesh_desc": "No description",
"nodes": nodes
}
for mesh_id, details in temp_dict.items():
global_list.append({
"mesh_name": details["mesh_name"],
"mesh_id": mesh_id,
"nodes": [
{
"node_id": node["_id"],
"node_name": node.get("name", "Unknown Node"),
"powered_on": node.get("pwr") == 1
}
for node in details["nodes"]
]
})
basic_ready_state.set()
ready_for_next.set()
async def receive_processor(self, python_client: MeshbookWebsocket):
"""Processes messages received from the WebSocket."""
global response_counter
temp_responses_list = []
while True:
message = await python_client.received_response_queue.get()
action_type = message.get('action')
if action_type in ('meshes', 'nodes'):
self.handle_basic_data(message[action_type])
elif action_type == 'msg':
temp_responses_list.append(message)
response_counter += 1 # Increment response counter
if not args.silent or args.information:
print("Current Batch: {}".format(math.ceil(response_counter/len(target_ids))))
print("Current response number: {}".format(response_counter))
print("Current Calculation: {} % {} = {}".format(response_counter, len(target_ids), response_counter % len(target_ids)))
if response_counter % len(target_ids) == 0:
batch_name = "Batch {}".format(math.ceil(response_counter / len(target_ids)))
responses_dict[batch_name] = temp_responses_list
temp_responses_list = []
ready_for_next.set()
elif action_type == 'close':
print(message)
elif not args.silent:
print("Ignored action:", action_type)
class MeshcallerActions:
"""Processes playbook actions."""
@staticmethod
async def process_arguments(python_client: MeshbookWebsocket, playbook_yaml: dict):
"""Executes tasks defined in the playbook."""
global response_counter, expected_responses, target_ids
await basic_ready_state.wait() # Wait for the basic data to be ready
target_ids = MeshbookUtilities.get_target_ids(
company=playbook_yaml.get('company'),
device=playbook_yaml.get('device')
)
if not target_ids:
raise ScriptEndTrigger("No targets found.")
run_command_template = {
'action': 'runcommands',
'nodeids': target_ids,
'type': 0,
'cmds': None,
'runAsUser': 0,
'responseid': 'meshctrl',
'reply': True
}
expected_responses = len(playbook_yaml['tasks']) * len(target_ids) # Calculate the total expected responses: tasks x target nodes
# Send commands for all nodes at once
for task in playbook_yaml['tasks']:
await ready_for_next.wait()
run_command_template["cmds"] = task['command']
run_command_template["nodeids"] = target_ids # Send to all target IDs at once
if not args.silent or args.information:
print("-=-" * 40)
print("Running task:", task)
print("-=-" * 40)
# Send the command to all nodes in one go
await python_client.ws_send_data(json.dumps(run_command_template))
ready_for_next.clear()
# Wait until all expected responses are received
while response_counter < expected_responses:
await asyncio.sleep(1)
# Exit gracefully
if not args.silent or args.information:
print("-=-" * 40)
updated_response_dict = MeshbookUtilities.translate_nodeids(responses_dict, global_list)
if not args.nojson:
print(json.dumps(updated_response_dict,indent=4))
raise ScriptEndTrigger("All tasks completed successfully: Expected {} Received {}".format(expected_responses, response_counter))
async def main():
parser = argparse.ArgumentParser(description="Process command-line arguments")
parser.add_argument("-pb", "--playbook", type=str, help="Path to the playbook file.", required=True)
parser.add_argument("--conf", type=str, help="Path for the API configuration file (default: ./api.conf).")
parser.add_argument("--nojson", action="store_true", help="Makes the program not output the JSON response data.")
parser.add_argument("-s", "--silent", action="store_true", help="Suppress terminal output.")
parser.add_argument("-i", "--information", action="store_true", help="Add the calculations and other informational data to the output.")
global args
args = parser.parse_args()
try:
credentials = MeshbookUtilities.load_config(args.conf)
python_client = MeshbookWebsocket()
processor = MeshbookProcessor()
websocket_task = asyncio.create_task(python_client.ws_handler(
credentials['websocket_url'],
credentials['username'],
credentials['password']
))
processor_task = asyncio.create_task(processor.receive_processor(python_client))
playbook_yaml = MeshbookUtilities.read_yaml(args.playbook)
translated_playbook = MeshbookUtilities.replace_placeholders(playbook_yaml)
await MeshcallerActions.process_arguments(python_client, translated_playbook)
await asyncio.gather(websocket_task, processor_task)
except ScriptEndTrigger as e:
if not args.silent or args.information:
print(e)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,16 +0,0 @@
---
name: Refresh the apt cache
company: Temp-Agents
#device: MSI
tasks:
- name: refresh the cache
command: "apt update"
- name: display available upgrades
command: "apt list --upgradable"
- name: apply upgrades
command: "apt upgrade -y"
- name: cleanup remaining packages
command: "apt autoremove -y"

View File

@@ -1,9 +0,0 @@
---
name: Ping Multiple Points
company: Temp-Agents
tasks:
- name: Ping Cloudflare
command: "ping 1.1.1.1 -c 4"
- name: Ping Google
command: "ping 8.8.8.8 -c 4"

View File

@@ -1,9 +0,0 @@
---
name: Refresh the apt cache
company: Temp-Agents
tasks:
- name: refresh the cache
command: "apt update"
- name: display available upgrades
command: "apt list --upgradable"

View File

@@ -6,335 +6,178 @@ from base64 import b64encode
from configparser import ConfigParser
import json
import math
import meshctrl
import os
import yaml
import websockets
sequence = 0
response_counter = 0
expected_responses = 0
basic_ready_state = asyncio.Event()
ready_for_next = asyncio.Event()
global_list = []
responses_dict = {}
'''
Script utilities are handled in the following section.
'''
class ScriptEndTrigger(Exception):
"""Custom Exception to handle script termination events."""
pass
class MeshbookUtilities:
"""Helper utility functions for the Meshcaller application."""
def output_text(message: str, required=False):
if required:
print(message)
elif not args.silent:
print(message)
async def load_config(conf_file: str = './api.conf', segment: str = 'meshcentral-account') -> ConfigParser:
if not os.path.exists(conf_file):
raise ScriptEndTrigger(f'Missing config file {conf_file}. Provide an alternative path.')
config = ConfigParser()
try:
config.read(conf_file)
except Exception as err:
raise ScriptEndTrigger(f"Error reading configuration file '{conf_file}': {err}")
@staticmethod
def base64_encode(string: str) -> str:
"""Encode a string in Base64 format."""
return b64encode(string.encode('utf-8')).decode()
if segment not in config:
raise ScriptEndTrigger(f'Segment "{segment}" not found in config file {conf_file}.')
return config[segment]
async def init_connection(credentials: dict) -> meshctrl.Session:
session = meshctrl.Session(
credentials['websocket_url'],
user=credentials['username'],
password=credentials['password']
)
await session.initialized.wait()
return session
async def translate_id_to_name(target_id: str) -> str:
for group in group_list:
for device in group_list[group]:
if device["device_id"] == target_id:
return device["device_name"]
'''
Creation and compilation happends in the following section, where the yaml gets read in, and edited accordingly.
'''
async def compile_book(playbook_file: dict) -> dict:
playbook = open(playbook_file, 'r')
playbook = await replace_placeholders(yaml.safe_load(playbook))
return playbook
async def replace_placeholders(playbook: dict) -> dict:
variables = {var["name"]: var["value"] for var in playbook.get("variables", [])}
for task in playbook.get("tasks", []):
command = task.get("command", "")
for var_name, var_value in variables.items():
placeholder = f"{{{{ {var_name} }}}}" # Create the placeholder string like "{{ host1 }}"
command = command.replace(placeholder, var_value)
task["command"] = command
return playbook
'''
Creation and compilation of the MeshCentral nodes list (list of all nodes available to the user in the configuration) is handled in the following section.
'''
async def compile_group_list(session: meshctrl.Session) -> dict:
devices_response = await session.list_devices(details=False, timeout=10)
@staticmethod
def get_target_ids(company: str = None, device: str = None) -> list:
"""Retrieve target IDs based on company or device."""
ids = []
local_device_list = {}
for device in devices_response:
if device.meshname not in local_device_list:
local_device_list[device.meshname] = []
local_device_list[device.meshname].append({
"device_id": device.nodeid,
"device_name": device.name,
"device_os": device.os_description,
"device_tags": device.tags,
"reachable": device.connected
})
for entry in global_list:
nodes = entry.get('nodes', [])
if company and not device:
if entry.get('mesh_name') == company:
ids.extend(node['node_id'] for node in nodes if node.get('powered_on'))
elif device and not company:
for node in nodes:
if node['node_name'] == device and node.get('powered_on'):
return [node['node_id']] # Immediate return for single device
elif not company and not device:
ids.extend(node['node_id'] for node in nodes if node.get('powered_on'))
return local_device_list
return ids
async def gather_targets(playbook: dict) -> dict:
target_list = []
@staticmethod
def load_config(conffile: str = None, segment: str = 'meshcentral-service') -> ConfigParser:
"""Load and return the configuration from a file."""
conffile = conffile or './api.conf'
if not os.path.exists(conffile):
raise ScriptEndTrigger(f'Missing config file {conffile}. Provide an alternative path.')
if "device" in playbook and "group" not in playbook:
pseudo_target = playbook["device"]
try:
my_config = ConfigParser()
my_config.read(conffile)
except Exception as err:
raise ScriptEndTrigger(f'Error reading config file {conffile}: {err}')
for group in group_list:
for device in group_list[group]:
if device["reachable"] and pseudo_target == device["device_name"]:
target_list.append(device["device_id"])
elif "group" in playbook and "device" not in playbook:
pseudo_target = playbook["group"]
for group in group_list:
if pseudo_target == group:
for device in group_list[group]:
if device["reachable"]:
target_list.append(device["device_id"])
return target_list
async def execute_playbook(session: meshctrl.Session, targets: dict, playbook: dict):
responses_list = {}
round = 1
for task in playbook["tasks"]:
output_text(("\033[1m\033[92m" + str(round) + ". Running: " + task["name"] + "\033[0m"), False)
response = await session.run_command(nodeids=targets, command=task["command"], timeout=300)
if segment not in my_config:
raise ScriptEndTrigger(f'Segment "{segment}" not found in config file {conffile}.')
task_batch = []
for device in response:
device_result = response[device]["result"]
response[device]["result"] = device_result.replace("Run commands completed.", "")
response[device]["device_id"] = device
response[device]["device_name"] = await translate_id_to_name(device)
task_batch.append(response[device])
return my_config[segment]
@staticmethod
def read_yaml(file_path: str) -> dict:
"""Read a YAML file and return its content as a dictionary."""
with open(file_path, 'r') as file:
return yaml.safe_load(file)
@staticmethod
def translate_nodeids(batches_dict, global_list) -> dict:
for batch_name, items in batches_dict.items():
for item in items: # Process each item in the batch
node_id = item["nodeid"] # Get the nodeid field
real_name = None
for company in global_list:
for machine in company["nodes"]:
if machine["node_id"] == node_id:
real_name = machine["node_name"]
break
if real_name:
break
# If found, replace the nodeid with the real node name, otherwise mark it as "Unknown Node"
if real_name:
item["nodeid"] = real_name
return batches_dict
class MeshbookWebsocket:
"""Handles WebSocket connections and interactions."""
responses_list[task["name"]] = task_batch
round += 1
def __init__(self):
self.meshsocket = None
self.received_response_queue = asyncio.Queue()
async def ws_on_open(self):
"""Called when WebSocket connection is established."""
if not args.silent:
print('Connection established.')
async def ws_on_close(self):
"""Called when WebSocket connection is closed."""
print('Connection closed by remote host.')
raise ScriptEndTrigger("WebSocket connection closed.")
async def ws_on_message(self, message: str):
"""Processes incoming WebSocket messages."""
try:
received_response = json.loads(message)
await self.received_response_queue.put(received_response)
except json.JSONDecodeError:
print("Error processing:", message)
raise ScriptEndTrigger("Failed to decode JSON message.")
async def ws_send_data(self, message: str):
"""Send data to the WebSocket server."""
if not self.meshsocket:
raise ScriptEndTrigger("WebSocket connection not established. Unable to send data.")
if not args.silent:
print('Sending data to the server.')
await self.meshsocket.send(message)
async def gen_simple_list(self):
"""Send requests to retrieve mesh and node lists."""
await self.ws_send_data(json.dumps({'action': 'meshes', 'responseid': 'meshctrl'}))
await self.ws_send_data(json.dumps({'action': 'nodes', 'responseid': 'meshctrl'}))
async def ws_handler(self, uri: str, username: str, password: str):
"""Main WebSocket connection handler."""
login_string = f'{MeshbookUtilities.base64_encode(username)},{MeshbookUtilities.base64_encode(password)}'
ws_headers = {
'User-Agent': 'MeshCentral API client',
'x-meshauth': login_string
}
if not args.silent:
print("Attempting WebSocket connection...")
try:
async with websockets.connect(uri, additional_headers=ws_headers) as meshsocket:
self.meshsocket = meshsocket
await self.ws_on_open()
await self.gen_simple_list()
while True:
try:
message = await meshsocket.recv()
await self.ws_on_message(message)
except websockets.ConnectionClosed:
await self.ws_on_close()
break
except ScriptEndTrigger as e:
print(f"WebSocket handler terminated: {e}")
except Exception as e:
print(f"An error occurred: {e}")
class MeshbookProcessor:
"""Processes data received from the WebSocket."""
def __init__(self):
self.basic_temp_list = []
def handle_basic_data(self, data):
"""Handles basic data from the server."""
if not args.silent:
print("Processing received basic data...")
self.basic_temp_list.append(data)
if len(self.basic_temp_list) < 2:
return
temp_dict = {}
for entry in self.basic_temp_list:
if isinstance(entry, list):
for mesh in entry:
if mesh.get("type") == "mesh":
mesh_id = mesh["_id"]
temp_dict[mesh_id] = {
"mesh_name": mesh.get("name", "Unknown Mesh"),
"mesh_desc": mesh.get("desc", "No description"),
"nodes": []
}
elif isinstance(entry, dict):
for mesh_id, nodes in entry.items():
if mesh_id in temp_dict:
temp_dict[mesh_id]["nodes"].extend(nodes)
else:
temp_dict[mesh_id] = {
"mesh_name": "Unknown Mesh",
"mesh_desc": "No description",
"nodes": nodes
}
for mesh_id, details in temp_dict.items():
global_list.append({
"mesh_name": details["mesh_name"],
"mesh_id": mesh_id,
"nodes": [
{
"node_id": node["_id"],
"node_name": node.get("name", "Unknown Node"),
"powered_on": node.get("pwr") == 1
}
for node in details["nodes"]
]
})
basic_ready_state.set()
ready_for_next.set()
async def receive_processor(self, python_client: MeshbookWebsocket):
"""Processes messages received from the WebSocket."""
global response_counter
temp_responses_list = []
while True:
message = await python_client.received_response_queue.get()
action_type = message.get('action')
if action_type in ('meshes', 'nodes'):
self.handle_basic_data(message[action_type])
elif action_type == 'msg':
temp_responses_list.append(message)
response_counter += 1 # Increment response counter
if not args.silent or args.information:
print("Current Batch: {}".format(math.ceil(response_counter/len(target_ids))))
print("Current response number: {}".format(response_counter))
print("Current Calculation: {} % {} = {}".format(response_counter, len(target_ids), response_counter % len(target_ids)))
if response_counter % len(target_ids) == 0:
batch_name = "Batch {}".format(math.ceil(response_counter / len(target_ids)))
responses_dict[batch_name] = temp_responses_list
temp_responses_list = []
ready_for_next.set()
elif action_type == 'close':
print(message)
elif not args.silent:
print("Ignored action:", action_type)
class MeshcallerActions:
"""Processes playbook actions."""
@staticmethod
async def process_arguments(python_client: MeshbookWebsocket, playbook_path: str):
"""Executes tasks defined in the playbook."""
global response_counter, expected_responses, target_ids
await basic_ready_state.wait() # Wait for the basic data to be ready
playbook_yaml = MeshbookUtilities.read_yaml(playbook_path)
target_ids = MeshbookUtilities.get_target_ids(
company=playbook_yaml.get('company'),
device=playbook_yaml.get('device')
)
if not target_ids:
raise ScriptEndTrigger("No targets found.")
run_command_template = {
'action': 'runcommands',
'nodeids': target_ids,
'type': 0,
'cmds': None,
'runAsUser': 0,
'responseid': 'meshctrl',
'reply': True
}
expected_responses = len(playbook_yaml['tasks']) * len(target_ids) # Calculate the total expected responses: tasks x target nodes
# Send commands for all nodes at once
for task in playbook_yaml['tasks']:
await ready_for_next.wait()
run_command_template["cmds"] = task['command']
run_command_template["nodeids"] = target_ids # Send to all target IDs at once
if not args.silent or args.information:
print("-=-" * 40)
print("Running task:", task)
print("-=-" * 40)
# Send the command to all nodes in one go
await python_client.ws_send_data(json.dumps(run_command_template))
ready_for_next.clear()
# Wait until all expected responses are received
while response_counter < expected_responses:
await asyncio.sleep(1)
# Exit gracefully
if not args.silent or args.information:
print("-=-" * 40)
updated_response_dict = MeshbookUtilities.translate_nodeids(responses_dict, global_list)
if not args.nojson:
print(json.dumps(updated_response_dict,indent=4))
raise ScriptEndTrigger("All tasks completed successfully: Expected {} Received {}".format(expected_responses, response_counter))
output_text(("-" * 40), False)
output_text((json.dumps(responses_list,indent=4)), True)
async def main():
parser = argparse.ArgumentParser(description="Process command-line arguments")
parser.add_argument("--conf", type=str, help="Path for the API configuration file (default: ./api.conf).")
parser.add_argument("--nojson", action="store_true", help="Makes the program not output the JSON response data.")
parser.add_argument("-pb", "--playbook", type=str, help="Path to the playbook file.", required=True)
parser.add_argument("-s", "--silent", action="store_true", help="Suppress terminal output.")
parser.add_argument("-i", "--information", action="store_true", help="Add the calculations and other informational data to the output.")
parser.add_argument("--conf", type=str, help="Path for the API configuration file (default: ./api.conf).", required=False)
parser.add_argument("--noout", action="store_true", help="Makes the program not output response data.", required=False)
parser.add_argument("-s", "--silent", action="store_true", help="Suppress terminal output", required=False)
global args
args = parser.parse_args()
try:
credentials = MeshbookUtilities.load_config(args.conf)
python_client = MeshbookWebsocket()
processor = MeshbookProcessor()
output_text(("-" * 40), False)
output_text(("\x1B[3mTrying to load the MeshCentral account credential file...\x1B[0m"), False)
output_text(("\x1B[3mTrying to load the Playbook yaml file and compile it into something workable...\x1B[0m"), False)
websocket_task = asyncio.create_task(python_client.ws_handler(
credentials['websocket_url'],
credentials['username'],
credentials['password']
))
processor_task = asyncio.create_task(processor.receive_processor(python_client))
await MeshcallerActions.process_arguments(python_client, args.playbook)
await asyncio.gather(websocket_task, processor_task)
except ScriptEndTrigger as e:
if not args.silent or args.information:
print(e)
credentials, playbook = await asyncio.gather(
(load_config() if args.conf is None else load_config(args.conf)),
(compile_book(args.playbook))
)
output_text(("\x1B[3mConnecting to MeshCentral and establish a session using variables from previous credential file.\x1B[0m"), False)
session = await init_connection(credentials)
output_text(("\x1B[3mGenerating group list with nodes and reference the targets from that.\x1B[0m"), False)
global group_list
group_list = await compile_group_list(session)
targets_list = await gather_targets(playbook)
output_text(("-" * 40), False)
if len(targets_list) == 0:
output_text(("\033[91mNo targets found or targets unreachable, quitting.\x1B[0m"), True)
else:
output_text(("\033[91mExecuting playbook on the targets.\x1B[0m"), False)
await execute_playbook(session, targets_list, playbook)
await session.close()
except OSError as message:
output_text(message, True)
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())

View File

@@ -1,4 +1,5 @@
asyncio==3.4.3
configparser==7.1.0
pyyaml==6.0.2
websockets==14.1
argparse
asyncio
configparser
pyyaml
libmeshctrl

View File

@@ -1,4 +1,4 @@
[meshcentral-service]
[meshcentral-account]
websocket_url =
username =
password =