76 Commits
v1 ... v1.3.2

Author SHA1 Message Date
9d2999476d refac: added some type checking and fixed a duplicate bug (apparently) 2025-09-25 09:49:19 +02:00
DaanSelen
82cc31e0f6 Update executor.py 2025-09-16 10:37:13 +02:00
f34d1dc7ae refac(meshbook): rework the targeting module to now be case insensitive 2025-09-10 16:26:52 +02:00
DaanSelen
f0e9e40cca chore(docs): README correction 2025-09-10 13:28:42 +02:00
208e9c1223 chore(os-handling): added Debian 13 to the default Debian list 2025-08-26 13:51:18 +02:00
DaanSelen
a736a74af6 Added more robust code checking (#15)
Co-authored-by: Daan Selen <dselen@systemec.nl>
2025-08-07 13:56:38 +02:00
DaanSelen
733136c1ab Version 1.3.1 (#14)
* Looks to be manual overriding, testing now.

* Working

* Added a nice way of handling it intern

---------

Co-authored-by: Daan Selen <dselen@systemec.nl>
2025-08-07 11:14:36 +02:00
Daan Selen
7e10b98c3b Edited template for totp_secret 2025-07-21 23:10:00 +02:00
Daan Selen
615a438003 Changed compatibility 2025-07-21 22:42:16 +02:00
Daan Selen
07d0b99c47 added pyotp in requirements 2025-07-21 22:29:09 +02:00
Daan Selen
2447f65599 Changed Readme. (With the help of some AI) 2025-07-16 20:43:45 +02:00
Daan Selen
e729c72c6a added support for totp 2025-07-11 16:24:21 +02:00
Daan Selen
b20d56170e bumped libmeshctrl 2025-06-30 20:51:11 +02:00
Daan Selen
f52464909a bump libmeshctrl 2025-06-15 21:27:16 +02:00
Daan Selen
4b741c8089 badge addition 2025-06-05 15:49:21 +02:00
Daan Selen
89a57e0a1b Made it more json friendly by removing spaces. 2025-05-02 15:16:49 +02:00
Daan Selen
764ed1ef10 Minor indentation change 2025-04-30 16:58:48 +02:00
Daan Selen
f857b79d82 Added dedicated var 2025-04-25 16:09:36 +02:00
dselen
58598d8d17 Rewrote Meshbook with submodules with classes (#12)
* Massive update, bringing a lot of QoL. RC

* 0.5 seconds delay between tasks.
    insignificant for humans.
    Like a thousand years for computers to get ready.

* Added new features such as ignore_categorisation.

* Version 1.3 RC

* Added defaults.
2025-04-25 16:03:07 +02:00
Daan Selen
ac4dd8994c Removed more prints 2025-04-07 16:55:58 +02:00
Daan Selen
7a60cd7280 hotfix 2025-04-07 16:52:19 +02:00
Daan
e2eca57a0a bump libmeshctrl 2025-04-01 22:17:20 +02:00
dselen
a4b6062c0e Merge pull request #10 from DaanSelen/tag_test
Added targeting tags.
2025-03-27 11:23:00 +01:00
Daan Selen
de4fe0258c added doc note about target_tag 2025-03-27 11:20:51 +01:00
Daan Selen
1d4b89a2ed Targeting tags seems to work well. Needing doc update. 2025-03-27 11:19:04 +01:00
Daan Selen
b2bf899d42 Renamed config due to autocorrect. 2025-03-27 10:15:21 +01:00
Daan Selen
0a211da4d6 Slight modification. 2025-03-27 10:11:33 +01:00
Daan Selen
1450416d62 Test new filtering logic. 2025-03-27 10:01:49 +01:00
Daan Selen
b0f34e9ea0 add target_tag in function parameters. 2025-03-26 16:58:12 +01:00
Daan Selen
47eef4cfb0 Added 'all' option to the groups option. 2025-03-04 11:57:41 +01:00
Daan Selen
ba74e038f7 Renamed meshcentral.conf -> meshbook.conf 2025-03-03 14:01:41 +01:00
Daan Selen
f1df522f61 Modified dir name in gitignore and changed disk info example. 2025-02-28 15:18:57 +01:00
Daan Selen
f5453353fe +el 2025-02-28 14:52:16 +01:00
Daan Selen
898098105c Small logic change. Added extra check and error handling. 2025-02-28 14:19:40 +01:00
Daan
6f945d30d7 Hotfix in powershell checking code. 2025-02-27 21:58:09 +01:00
Daan
a722c024f5 Expansion of previous commit. 2025-02-27 21:47:55 +01:00
Daan
db7ff19bfb changed .gitignore
Added examples directory back if you want to customize them: cp examples meshbooks (which is ignored).
Added powershell support into the yaml (for Windows).
Added extra check in console output segment.
Updated readme.md
updated os_categories with Windows examples
Added more examples.
2025-02-27 21:45:47 +01:00
Daan Selen
9caa52f59e pb -> mb reference. (M)esh(B)ook 2025-02-27 14:52:56 +01:00
Daan Selen
d62d80fb16 Changed hostname value. 2025-02-27 14:52:25 +01:00
Daan Selen
ff6e1f6cb7 Added submodule to meshbook (public) 2025-02-27 09:46:30 +01:00
Daan Selen
a4335ce8ac Expanded logic in replace_placeholder() function so this is easier to work with. 2025-02-25 10:39:07 +01:00
Daan Selen
30d49059c5 Bumped libmeshctrl after: https://github.com/HuFlungDu/pylibmeshctrl/issues/35 2025-02-18 08:43:08 +01:00
dselen
50e413581a Freeze websockets at 14.2 due to breakage. 2025-02-17 16:53:43 +01:00
Daan Selen
64bf28c565 Freeze websockets at 14.2 due to breakage. 2025-02-17 16:53:01 +01:00
dselen
4359a40eb3 Added slight change to fix some Windows JSON formatting.
Merge pull request #6 from DaanSelen/dev
2025-02-14 13:54:51 +01:00
Daan Selen
193cb546f4 Added slight change to fix some Windows JSON formatting. 2025-02-14 13:30:44 +01:00
dselen
aa1d6a1a97 Fix Windows terminal outputs.
Merge pull request #5 from DaanSelen/dev
2025-02-14 13:13:45 +01:00
Daan Selen
e69ad445e2 Fix Windows terminal outputs. 2025-02-14 13:03:35 +01:00
dselen
bd833456d0 Merge pull request #4 from DaanSelen/dev
Tidying up everything and adding slight QoL changes.
2025-02-13 15:37:29 +01:00
Daan Selen
7f0159a8fa Tidying up everything and adding slight QoL changes. 2025-02-13 15:21:18 +01:00
dselen
03683976a8 Added targeting of multiple groups and devices through a new 'keyword'
Through a new sorting method, all cool and logically formatted.
2025-02-13 14:27:02 +01:00
Daan Selen
4f75969ed8 Added multiple group and device targeting with 'groups' and 'devices'. Also more error handling. 2025-02-13 14:22:03 +01:00
Daan Selen
77b1cfc73c Changed documentation according to feedback. 2025-02-13 13:44:23 +01:00
dselen
ad220e8c1a Update operating_system_filtering.md
reformat sentence. (a whole commit for that yes.)
2025-02-13 12:30:58 +01:00
Daan Selen
b7b9fdaea7 Added minor changes (documentation) and indentation for readability.
Readme correct referencing to new docs directory its file.
    couple comments and indentations to improve readability.
    Added a dummy Ubuntu 22 and MacOS categories as an example.
2025-02-13 12:29:06 +01:00
Daan Selen
ab1105b058 Added OS filtering doc and supported (1 level deep) nested definition. 2025-02-13 12:20:03 +01:00
Daan Selen
9494aa14c9 Changed logic in device filtering and made code a little more readable with newlines. 2025-02-13 11:44:53 +01:00
Daan Selen
6a7ec78fe9 Updated OS categorisation and bumped libmeshctrl to 1.1.1 2025-02-12 16:58:20 +01:00
dselen
1779025a97 Update README.md 2025-02-11 16:27:30 +01:00
dselen
e0de06c57e Update requirements.txt 2025-02-11 16:26:33 +01:00
Daan Selen
74d1e8f3bb Removed print line for aesthetic 2025-02-03 15:10:22 +01:00
Daan
7dd32902c4 Added basic operating system filtering. 2025-01-31 23:29:02 +01:00
Daan Selen
0cd653dfe3 Added extra field inside the response JSON. task_name and data.
Made grace-period countdown more verbose.
2025-01-17 09:52:07 +01:00
Daan Selen
ba970f585a Increased timeout for certain actions like updating etc. 2025-01-16 16:12:04 +01:00
Daan Selen
15c8500042 Revert "Learning git... stopped tracking examples directory."
This reverts commit 234683e49c.

Adding examples back.
2025-01-10 12:31:42 +01:00
Daan Selen
234683e49c Learning git... stopped tracking examples directory. 2025-01-10 12:26:58 +01:00
dselen
f04e49eb7d Update .gitignore 2025-01-10 12:24:10 +01:00
Daan Selen
046c2200db Slight asterix addition. 2025-01-10 12:22:49 +01:00
Daan Selen
876ea0738e minimal change. 2025-01-10 12:21:34 +01:00
Daan Selen
b5aa645850 Added an optional grace-period to the execution of the script.
This is done to prevent accidentaly wrong launches. If its not needed then disable it through `--nograce` (no grace (period))
2025-01-10 10:56:03 +01:00
Daan Selen
172ae126ea Added specific versions to dependencies to prevent sudden breakage. 2025-01-09 11:39:30 +01:00
dselen
6fb5ec2bc8 Update SECURITY.md 2025-01-09 10:30:33 +01:00
Daan Selen
f67a36f8b7 Documentation and readme update. 2025-01-09 10:26:15 +01:00
Daan Selen
577a8266ee Updated documentation. 2025-01-09 10:26:06 +01:00
Daan Selen
5492bd7e2f Slight modifications and gave round response a better name: Task {Number}. 2025-01-09 10:08:47 +01:00
Daan Selen
27473583e4 Moved python package and requirements file up a directory. 2025-01-09 10:01:28 +01:00
23 changed files with 1103 additions and 396 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
*.conf
venv
books
.vscode
# Byte-compiled / optimized / DLL files

431
README.md
View File

@@ -1,225 +1,282 @@
> [!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>
What problem does it solve? Well, what I wanted to be able to do is to automate system updates through [MeshCentral](https://github.com/ylianst/meshcentral).<br>
And many people will be comfortable with YAML configurations! It's almost like JSON, but different!<br>
[![CodeQL Advanced](https://github.com/DaanSelen/meshbook/actions/workflows/codeql.yaml/badge.svg)](https://github.com/DaanSelen/meshbook/actions/workflows/codeql.yaml)
# Quick-start:
> \[!NOTE]
> 💬 If you experience issues or have suggestions, [submit an issue](https://github.com/DaanSelen/meshbook/issues) — I'll respond ASAP!
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 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!
Meshbook is a tool to **programmatically manage MeshCentral-managed machines**, inspired by tools like [Ansible](https://github.com/ansible/ansible).
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>
## What problem does it solve?
### Linux setup:
Meshbook is designed to:
```shell
* Automate system updates or commands across multiple systems via [MeshCentral](https://github.com/Ylianst/MeshCentral), even behind third-party-managed firewalls.
* Allow configuration using simple and readable **YAML files** (like Ansible playbooks).
* Simplify the use of **group-based** or **tag-based** device targeting.
---
## 🏁 Quick Start
### ✅ Prerequisites
* Python 3.7+
* Git
* Access to a MeshCentral instance and credentials with:
* `Remote Commands`
* `Details`
* `Agent Console` permissions
A service account with access to the relevant device groups is recommended.
---
### 🔧 Installation
#### Linux
```bash
git clone https://github.com/daanselen/meshbook
cd ./meshbook
python3 -m venv ./venv
source ./venv/bin/activate
pip3 install -r ./meshbook/requirements.txt
pip install -r requirements.txt
cp ./templates/meshcentral.conf.template ./meshcentral.conf
```
### Windows setup:
#### Windows (PowerShell)
```shell
```powershell
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
cd .\meshbook
python -m venv .\venv
.\venv\Scripts\activate
pip install -r .\requirements.txt
cp .\templates\meshcentral.conf.template .\meshcentral.conf
```
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:
> 📌 Rename `meshcentral.conf.template` to `meshcentral.conf` and fill in your actual connection details.
> The URL must start with `wss://<MeshCentral-Host>`.
### Linux run:
---
```shell
python3 .\meshbook\meshbook.py -pb .\examples\echo.yaml
## 🚀 Running Meshbook
Once installed and configured, run a playbook like this:
### Linux:
```bash
python3 meshbook.py -pb ./examples/echo_example.yaml
```
### Windows run:
### Windows:
```shell
.\venv\Scripts\python.exe .\meshbook\meshbook.py -pb .\examples\echo.yaml
```powershell
.\venv\Scripts\python.exe .\meshbook.py -pb .\examples\echo_example.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:
Use `--help` to explore available command-line options:
```bash
python3 meshbook.py --help
```
---
## 🛠️ Creating Configurations
Meshbook configurations are written in YAML. Below is an overview of supported fields.
### ▶️ Group Targeting (Primary*)
```yaml
---
name: My Configuration
group: "Dev Machines"
powershell: true
variables:
- name: message
value: "Hello from Meshbook"
tasks:
- name: Echo a message
command: 'echo "{{ message }}"'
```
* `group`: MeshCentral group (aka "mesh"). Quotation marks required for multi-word names.
* `powershell`: Set `true` for PowerShell commands on Windows clients.
### ▶️ Device Targeting (Secondary*)
You can also target a **specific device** rather than a group. See [`apt_update_example.yaml`](./examples/linux/apt_update_example.yaml) for reference.
### ▶️ Variables
Variables are replaced by Meshbook before execution. Syntax:
```yaml
variables:
- name: example_var
value: "Example value"
tasks:
- name: Use the variable
command: 'echo "{{ example_var }}"'
```
* Primary and Secondary mark the order in which will take prescendence
### ▶️ Tasks
Define multiple tasks:
```yaml
tasks:
- name: Show OS info
command: "cat /etc/os-release"
```
Each task must include:
* `name`: Description for human readability.
* `command`: The actual shell or PowerShell command.
---
## 🪟 Windows Client Notes
* Keep your `os_categories.json` up to date for proper OS filtering.
* Ensure Windows commands are compatible (use `powershell: true` if needed).
* Examples are available in [`examples/windows`](./examples/windows).
---
## 🔎 OS & Tag Filtering
### Filter by OS
You can limit commands to specific OS types:
```yaml
target_os: "Linux" # As defined in os_categories.json
```
See [docs/operating\_system\_filtering.md](./docs/operating_system_filtering.md) for details.
### Filter by Tag
You can also filter using MeshCentral tags:
```yaml
target_tag: "Production"
```
> ⚠️ Tag values are **case-sensitive**.
---
## 📋 Example Playbook
```yaml
---
name: Echo OS Info
group: "Dev"
target_os: "Linux"
variables:
- name: file
value: "/etc/os-release"
tasks:
- name: Show contents of os-release
command: "echo $(cat {{ file }})"
```
Sample output:
```json
{
"Task 1": {
"task_name": "Show contents of os-release",
"data": [
{
"command": "echo $(cat /etc/os-release)",
"result": [
"NAME=\"Ubuntu\"",
"VERSION=\"22.04.4 LTS (Jammy Jellyfish)\""
],
"complete": true,
"device_name": "dev-host1"
}
]
}
}
```
---
## ⚠️ Blocking Commands Warning
Avoid using commands that **block indefinitely** — MeshCentral requires **non-blocking** execution.
🚫 Examples of bad (blocking) commands:
```bash
apt upgrade # Without -y
sleep infinity
ping 1.1.1.1 # Without -c
```
✅ Use instead:
```bash
apt upgrade -y
ping 1.1.1.1 -c 1
```
---
## 🧪 Check Python Environment
Sometimes the wrong Python interpreter or environment is used. To verify:
```bash
python3 -m pip list
pip3 list
```
If not, perhaps you are using the wrong executable, the wrong environment and so on...
The lists should match. If not, make sure the correct environment is activated.
# 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: example configuration
company: "Nerthus"
variables:
- name: var1
value: "This is the first variable"
tasks:
- name: echo the first variable!
command: 'echo "{{ var1 }}"'
## 📂 Project Structure (excerpt)
```bash
meshbook/
├── books/
│ ├── apt-update.yaml
└── rdp.yaml
├── examples/
│ ├── linux/
│ │ ├── apt_update_example.yaml
│ │ └── ...
│ └── windows/
│ ├── get_sys_info.yaml
│ └── ...
├── modules/
│ ├── executor.py
│ └── utilities.py
├── meshbook.py
├── os_categories.json
├── requirements.txt
├── templates/
│ └── config.conf.template
```
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 host1
command: "{{ command1 }} {{ host1 }} {{ cmd_arguments }}"
- name: Ping host2
command: "{{ command1 }} {{ host2 }} {{ cmd_arguments }}"
```
## 📄 License
The following response it received when executing the first yaml of the above files (with the `-s` and the `-i` parameters).
```shell
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.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.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": "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=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": "server"
}
],
"Batch 2": [
{
"action": "msg",
"type": "runcommands",
"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 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 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:
If you want to use this, make sure to use `NON-BLOCKING` commands. MeshCentral does not work if you send it commands that wait.<br>
A couple examples of `BLOCKING COMMANDS` which will never get back to the main MeshCentral server:
```shell
apt upgrade # without -y.
sleep infinity
ping 1.1.1.1 # without a -c flag (because it pings forever).
```
This project is licensed under the terms of the GPL3 License. See [LICENSE](./LICENSE).

View File

@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| < 1.0 | :white_check_mark: |
| >= 1.0 | :white_check_mark: |
## Reporting a Vulnerability

View File

@@ -0,0 +1,65 @@
# **Understanding the OS Filtering Mechanism**
## **Overview**
This function filters devices based on their **reachability** and an optional **OS category filter**. It supports:
- **Broad OS categories** (e.g., `"Linux"` includes all OS versions under `"Linux"`)
- **Specific OS categories** (e.g., `"Debian"` only includes OS versions under `"Linux" -> "Debian"`)
- **Single category selection** (Only `target_os="Linux"` OR `target_os="Debian"` is used, never both at once)
---
## **How It Works (Simplified)**
### **1. OS Category Expansion**
The function first expands the `target_os` category by retrieving all valid OS names under it.
#### **Example OS Category Structure:**
```json
{
"Linux": {
"Debian": [
"Debian GNU/Linux 12 (bookworm)",
"Debian GNU/Linux 11 (bullseye)"
],
"Ubuntu": [
"Ubuntu 24.04.1 LTS"
]
}
}
```
#### **Expanding Different `target_os` Values:**
| `target_os` | Expanded OS Versions |
|--------------|---------------------------------------------------|
| `"Linux"` | `{ "Debian GNU/Linux 12 (bookworm)", "Debian GNU/Linux 11 (bullseye)", "Ubuntu 24.04.1 LTS" }` |
| `"Debian"` | `{ "Debian GNU/Linux 12 (bookworm)", "Debian GNU/Linux 11 (bullseye)" }` |
---
### **2. Device Filtering**
Once the function has the allowed OS versions, it checks each device:
#### **Example Device List:**
```json
[
{"device_id": "A1", "device_os": "Debian GNU/Linux 12 (bookworm)", "reachable": true},
{"device_id": "A2", "device_os": "Ubuntu 24.04.1 LTS", "reachable": true},
{"device_id": "A3", "device_os": "Windows 11", "reachable": true},
{"device_id": "A4", "device_os": "Debian GNU/Linux 11 (bullseye)", "reachable": false}
]
```
#### **Filtering Behavior:**
| Device ID | Device OS | Reachable | Matches `target_os="Linux"` | Matches `target_os="Debian"` |
|-----------|----------------------------------|-----------|-------------------------------|-------------------------------|
| A1 | Debian GNU/Linux 12 (bookworm) | ✅ | ✅ | ✅ |
| A2 | Ubuntu 24.04.1 LTS | ✅ | ✅ | ❌ |
| A3 | Windows 11 | ✅ | ❌ | ❌ |
| A4 | Debian GNU/Linux 11 (bullseye) | ❌ | ❌ (Unreachable) | ❌ (Unreachable) |
#### **Final Output:**
- If `target_os="Linux"`: `["A1", "A2"]`
- If `target_os="Debian"`: `["A1"]`
- If `target_os=None` or `target_os` is undefined: `["A1", "A2", "A3"]`

View File

@@ -1,6 +1,7 @@
---
name: Ping Multiple Points
group: "Kubernetes"
#target_os: "Debian"
variables:
- name: host1
value: "1.1.1.1"

View File

@@ -1,13 +0,0 @@
---
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

@@ -1,6 +1,7 @@
---
name: Echo some text in the terminal of the device
group: "Kubernetes"
group: "Development"
target_os: "Linux" # <----
variables:
- name: package_manager
value: "apt"
@@ -26,6 +27,6 @@ tasks:
- name: Ping Google DNS
command: "ping {{ google_dns }} -c 4"
- name: Ping Quad9 DNS
command: "ping {{ quad9_dns }} -c 4"

View File

@@ -1,12 +1,13 @@
---
name: Refresh the apt cache
group: "Temp-Agents"
device: "<Device-Name>"
#target_os: "Linux"
variables:
- name: package_manager
value: "apt"
tasks:
- name: refresh the cache
- name: refresh the {{ package_manager }} cache
command: "{{ package_manager }} update"
- name: display available upgrades
- name: display available upgrades with {{ package_manager }}
command: "{{ package_manager }} list --upgradable"

View File

@@ -1,6 +1,7 @@
---
name: Refresh the apt cache
group: "Dev"
#target_os: "Linux"
variables:
- name: package_manager
value: "apt"

View File

@@ -0,0 +1,8 @@
---
name: Use DF to get drive information in JSON.
group: Systemec Development
target_os: "Linux"
tasks:
- name: Get disk-info with df returning JSON.
command: >
df -Th -x overlay -x tmpfs -x devtmpfs | awk 'NR>1 {printf "%s{\"size\":\"%s\",\"used\":\"%s\",\"available\":\"%s\",\"mount_point\":\"%s\",\"type\":\"%s\"}", (NR==2?"[":","), $3, $4, $5, $7, $2} END {print "]"}'

View File

@@ -0,0 +1,10 @@
---
name: Echo a string to the terminal through the meshbook example.
group: "Development"
target_os: "Linux"
variables:
- name: file
value: "/etc/os-release"
tasks:
- name: Echo!
command: "echo $(cat {{ file }})"

View File

@@ -0,0 +1,11 @@
---
name: Echo a string to the terminal through the meshbook example.
group: "Endpoint"
target_os: "Windows"
powershell: True
#variables:
# - name: file
# value: "/etc/os-release"
tasks:
- name: Echo!
command: "Get-ComputerInfo | Select-Object CsName, OsName, OsArchitecture, OsLastBootUpTime | Write-Output"

View File

@@ -0,0 +1,8 @@
---
name: Echo a string to the terminal through the meshbook example.
group: "Endpoint"
target_os: "Windows"
powershell: True
tasks:
- name: Get some update information
command: "Get-HotFix | Select-Object PSComputerName, HotFixID, InstalledOn"

View File

@@ -0,0 +1,16 @@
---
name: Echo a string to the terminal through the meshbook example.
group: "Endpoint"
target_os: "Windows"
powershell: True
#variables:
# - name: file
# value: "/etc/os-release"
tasks:
- name: Echo!
command: >
$systemInfo = Get-ComputerInfo | Select-Object CsName, OsName, OsArchitecture, OsLastBootUpTime;
$systemInfo | Format-Table -AutoSize;
Write-Output "I like monkeys.";
Get-SystemLanguage | Write-Output;
Get-ComputerInfo | Format-List;

363
meshbook.py Normal file
View File

@@ -0,0 +1,363 @@
#!/bin/python3
# Public Python libraries
import argparse
import asyncio
from colorama import just_fix_windows_console
import pyotp
import json
import meshctrl
# Local Python libraries/modules
from modules.console import *
from modules.executor import *
from modules.utilities import *
meshbook_version = "1.3.1"
grace_period = 3 # Grace period will last for x (by default 3) second(s).
async def init_connection(credentials: dict) -> meshctrl.Session:
'''
Use the libmeshctrl library to initiate a Secure Websocket (wss) connection to the MeshCentral instance.
'''
if "totp_secret" in credentials:
totp = pyotp.TOTP(credentials["totp_secret"])
otp = totp.now()
session = meshctrl.Session(
credentials['hostname'],
user=credentials['username'],
password=credentials['password'],
token=otp
)
else:
session = meshctrl.Session(
credentials['hostname'],
user=credentials['username'],
password=credentials['password']
)
await session.initialized.wait()
return session
async def gather_targets(args: argparse.Namespace,
meshbook: dict,
group_list: dict[str, list[dict]],
os_categories: dict) -> dict:
"""
Finds target devices based on meshbook criteria (device, devices, group or groups).
"""
group_list = {k.lower(): v for k, v in group_list.items()} # Normalize keys
target_list = []
offline_list = []
target_os = meshbook.get("target_os")
target_tag = meshbook.get("target_tag")
ignore_categorisation = meshbook.get("ignore_categorisation", False)
assert target_os is not None, "target_os must be provided"
assert target_tag is not None, "target_tag must be provided"
async def add_processed_devices(processed):
"""Helper to update target and offline lists."""
if processed:
target_list.extend(processed.get("valid_devices", []))
offline_list.extend(processed.get("offline_devices", []))
async def process_device_helper(device):
processed = await utilities.process_device(
device,
group_list,
os_categories,
target_os,
ignore_categorisation,
target_tag,
add_processed_devices
)
await add_processed_devices(processed)
async def process_group_helper(group):
processed = await utilities.filter_targets(
group, os_categories, target_os, ignore_categorisation, target_tag
)
await add_processed_devices(processed)
'''
Groups receive the first priority, then device targets.
'''
match meshbook:
case {"group": pseudo_target}:
if isinstance(pseudo_target, str):
pseudo_target = pseudo_target.lower()
if pseudo_target in group_list:
await process_group_helper(group_list[pseudo_target])
elif pseudo_target not in group_list:
console.nice_print(
args,
console.text_color.yellow + "Targeted group not found on the MeshCentral server.",
True
)
elif isinstance(pseudo_target, list):
console.nice_print(
args,
console.text_color.yellow + "Please use groups (Notice the plural with 'S') for multiple groups.",
True
)
else:
console.nice_print(
args,
console.text_color.yellow + "The 'group' key is being used, but an unknown data type was found, please check your values.",
True
)
case {"groups": pseudo_target}:
if isinstance(pseudo_target, list):
for sub_group in pseudo_target:
sub_group = sub_group.lower()
if sub_group in group_list:
await process_group_helper(group_list[sub_group])
elif isinstance(pseudo_target, str) and pseudo_target.lower() == "all":
for group in group_list.values():
await process_group_helper(group)
elif isinstance(pseudo_target, str):
console.nice_print(
args,
console.text_color.yellow + "The 'groups' key is being used, but only one string is given. Did you mean 'group'?",
True
)
else:
console.nice_print(
args,
console.text_color.yellow + "The 'groups' key is being used, but an unknown data type was found, please check your values.",
True
)
case {"device": pseudo_target}:
if isinstance(pseudo_target, str):
await process_device_helper(pseudo_target)
elif isinstance(pseudo_target, list):
console.nice_print(
args,
console.text_color.yellow + "Please use devices (Notice the plural with 'S') for multiple devices.",
True
)
else:
console.nice_print(
args,
console.text_color.yellow + "The 'device' key is being used, but an unknown data type was found, please check your values.",
True
)
case {"devices": pseudo_target}:
if isinstance(pseudo_target, list):
for sub_device in pseudo_target:
await process_device_helper(sub_device)
elif isinstance(pseudo_target, str):
console.nice_print(
args,
console.text_color.yellow + "The 'devices' key is being used, but only one string is given. Did you mean 'device'?",
True
)
else:
console.nice_print(
args,
console.text_color.yellow + "The 'devices' key is being used, but an unknown data type was found, please check your values.",
True
)
return {"target_list": target_list, "offline_list": offline_list}
async def main():
just_fix_windows_console()
'''
Main function where the program starts. Place from which all comands originate (eventually).
'''
parser = argparse.ArgumentParser(description="Process command-line arguments")
parser.add_argument("-mb", "--meshbook", type=str, help="Path to the meshbook yaml file.")
parser.add_argument("-oc", "--oscategories", type=str, help="Path to the Operating System categories JSON file.", default="./os_categories.json")
parser.add_argument("--conf", type=str, help="Path for the API configuration file (default: ./config.conf).", default="./api.conf")
parser.add_argument("--nograce", action="store_true", help="Disable the grace 3 seconds before running the meshbook.")
parser.add_argument("-g", "--group", type=str, help="Specify a manual override for the group.", default="")
parser.add_argument("-d", "--device", type=str, help="Specify a manual override for a device", default="")
parser.add_argument("-i", "--indent", action="store_true", help="Use an JSON indentation of 4 when this flag is passed.", default=False)
parser.add_argument("-r", "--raw-result", action="store_true", help="Print the raw result.", default=False)
parser.add_argument("-s", "--silent", action="store_true", help="Suppress terminal output.", default=False)
parser.add_argument("--shlex", action="store_true", help="Shlex the lines.", default=False)
parser.add_argument("--version", action="store_true", help="Show the Meshbook version.")
args = parser.parse_args()
local_categories_file = "./os_categories.json"
if args.version:
console.nice_print(args,
console.text_color.reset + "MeshBook Version: " + console.text_color.yellow + str(meshbook_version))
return
if not args.meshbook:
parser.print_help()
return
try:
with open(local_categories_file, "r") as file:
os_categories = json.load(file)
credentials, meshbook = await asyncio.gather(
(utilities.load_config(args)),
(utilities.compile_book(args.meshbook))
)
if args.group != "":
meshbook["group"] = args.group
if "device" in meshbook:
del meshbook["device"]
elif args.device != "":
meshbook["device"] = args.device
if "group" in meshbook:
del meshbook["group"]
'''
The following section mainly displays used variables and first steps of the program to the console.
'''
# INIT ARGUMENTS PRINTING
console.nice_print(args,
console.text_color.reset + ("-" * 40))
console.nice_print(args,
"meshbook: " + console.text_color.yellow + args.meshbook + console.text_color.reset + ".")
console.nice_print(args,
"Operating System Categorisation file: " + console.text_color.yellow + args.oscategories + console.text_color.reset + ".")
console.nice_print(args,
"Configuration file: " + console.text_color.yellow + args.conf + console.text_color.reset + ".")
# TARGET OS PRINTING
if "target_os" in meshbook:
console.nice_print(args,
"Target Operating System category given: " + console.text_color.yellow + meshbook["target_os"] + console.text_color.reset + ".")
else:
console.nice_print(args,
"Target Operating System category given: " + console.text_color.yellow + "All" + console.text_color.reset + ".")
# Should Meshbook ignore categorisation?
if "ignore_categorisation" in meshbook:
console.nice_print(args,
"Ignore the OS Categorisation file: " + console.text_color.yellow + str(meshbook["ignore_categorisation"]) + console.text_color.reset + ".")
if meshbook["ignore_categorisation"]:
console.nice_print(args,
console.text_color.red + "!!!!\n" +
console.text_color.yellow +
"Ignore categorisation is True.\nThis means that the program checks if the target Operating System is somewhere in the reported device Operating System." +
console.text_color.red + "\n!!!!")
else:
console.nice_print(args,
"Ignore the OS Categorisation file: " + console.text_color.yellow + "False" + console.text_color.reset + ".")
# TARGET TAG PRINTING
if "target_tag" in meshbook:
console.nice_print(args,
"Target Device tag given: " + console.text_color.yellow + meshbook["target_tag"] + console.text_color.reset + ".")
else:
console.nice_print(args,
"Target Device tag given: " + console.text_color.yellow + "All" + console.text_color.reset + ".")
# TARGET PRINTING
if "device" in meshbook:
console.nice_print(args,
"Target device: " + console.text_color.yellow + str(meshbook["device"]) + console.text_color.reset + ".")
elif "devices" in meshbook:
console.nice_print(args,
"Target devices: " + console.text_color.yellow + str(meshbook["devices"]) + console.text_color.reset + ".")
elif "group" in meshbook:
console.nice_print(args,
"Target group: " + console.text_color.yellow + str(meshbook["group"]) + console.text_color.reset + ".")
elif "groups" in meshbook:
console.nice_print(args,
"Target groups: " + console.text_color.yellow + str(meshbook["groups"]) + console.text_color.reset + ".")
# RUNNING PARAMETERS PRINTING
console.nice_print(args, "Grace: " + console.text_color.yellow + str((not args.nograce))) # Negation of bool for correct explanation
console.nice_print(args, "Silent: " + console.text_color.yellow + "False") # Can be pre-defined because if silent flag was passed then none of this would be printed.
session = await init_connection(credentials)
# PROCESS PRINTING aka what its doing in the moment...
console.nice_print(args,
console.text_color.reset + ("-" * 40))
console.nice_print(args,
console.text_color.italic + "Trying to load the MeshCentral account credential file...")
console.nice_print(args,
console.text_color.italic + "Trying to load the meshbook yaml file and compile it into something workable...")
console.nice_print(args,
console.text_color.italic + "Trying to load the Operating System categorisation JSON file...")
console.nice_print(args,
console.text_color.italic + "Connecting to MeshCentral and establish a session using variables from previous credential file.")
console.nice_print(args,
console.text_color.italic + "Generating group list with nodes and reference the targets from that.")
'''
End of the main information displaying section.
'''
group_list = await transform.compile_group_list(session)
compiled_device_list = await gather_targets(args, meshbook, group_list, os_categories)
if "target_list" not in compiled_device_list or len(compiled_device_list["target_list"]) == 0:
console.nice_print(args,
console.text_color.red + "No targets found or targets unreachable, quitting.", True)
console.nice_print(args,
console.text_color.reset + ("-" * 40), True)
else:
console.nice_print(args,
console.text_color.reset + ("-" * 40))
match meshbook:
case {"group": candidate_target_name}:
target_name = candidate_target_name
case {"groups": candidate_target_name}:
target_name = str(candidate_target_name)
case {"device": candidate_target_name}:
target_name = candidate_target_name
case {"devices": candidate_target_name}:
target_name = str(candidate_target_name)
case _:
target_name = ""
console.nice_print(args,
console.text_color.yellow + "Executing meshbook on the target(s): " + console.text_color.green + target_name + console.text_color.yellow + ".")
if not args.nograce:
console.nice_print(args,
console.text_color.yellow + "Initiating grace-period...")
for x in range(grace_period):
console.nice_print(args,
console.text_color.yellow + "{}...".format(x+1)) # Countdown!
await asyncio.sleep(1)
console.nice_print(args, console.text_color.reset + ("-" * 40))
await executor.execute_meshbook(args,
session,
compiled_device_list,
meshbook,
group_list)
await session.close()
except OSError as message:
console.nice_print(args,
console.text_color.red + f'{message}', True)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,183 +0,0 @@
#!/bin/python3
import argparse
import asyncio
from base64 import b64encode
from configparser import ConfigParser
import json
import math
import meshctrl
import os
import yaml
'''
Script utilities are handled in the following section.
'''
class ScriptEndTrigger(Exception):
pass
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}")
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)
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
})
return local_device_list
async def gather_targets(playbook: dict) -> dict:
target_list = []
if "device" in playbook and "group" not in playbook:
pseudo_target = playbook["device"]
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)
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])
responses_list[task["name"]] = task_batch
round += 1
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("-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).", 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:
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)
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())

View File

@@ -1,5 +0,0 @@
argparse
asyncio
configparser
pyyaml
libmeshctrl

26
modules/console.py Normal file
View File

@@ -0,0 +1,26 @@
# Public Python libraries
import argparse
class console:
class text_color:
black = "\033[30m"
red = "\033[31m"
green = "\033[32m"
yellow = "\033[33m"
blue = "\033[34m"
magenta = "\033[35m"
cyan = "\033[36m"
white = "\033[37m"
italic = "\x1B[3m"
reset = "\x1B[0m"
@staticmethod
def nice_print(args: argparse.Namespace, message: str, final: bool=False):
'''
Helper function for terminal output, with a couple variables for the silent flag. Also clears terminal color each time.
'''
if final:
print(message) # Assuming final message, there is no need for clearing.
elif not args.silent:
print(message + console.text_color.reset)

66
modules/executor.py Normal file
View File

@@ -0,0 +1,66 @@
# Public Python libraries
import argparse
import json
import meshctrl
from time import sleep
# Local Python libraries/modules
from modules.console import console
from modules.utilities import transform
intertask_delay = 1
class executor:
@staticmethod
async def execute_meshbook(args: argparse.Namespace, session: meshctrl.Session, compiled_device_list: dict, meshbook: dict, group_list: dict) -> None:
'''
Actual function that handles meshbook execution, also responsible for formatting the resulting JSON.
'''
responses_list = {}
targets = compiled_device_list["target_list"]
offline = compiled_device_list["offline_list"]
round = 1
for task in meshbook["tasks"]:
console.nice_print(args,
console.text_color.green + str(round) + ". Running: " + task["name"])
if "powershell" in meshbook and meshbook["powershell"]:
response = await session.run_command(nodeids=targets, command=task["command"],powershell=True,ignore_output=False,timeout=900)
else:
response = await session.run_command(nodeids=targets, command=task["command"],powershell=False,ignore_output=False,timeout=900)
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 transform.translate_nodeid_to_name(device, group_list)
task_batch.append(response[device])
responses_list["task_" + str(round)] = {
"task_name": task["name"],
"data": task_batch
}
round += 1
sleep(intertask_delay) # Sleep for x amount of time.
for index, device in enumerate(offline): # Replace Device_id with actual human readable name
device_name = await transform.translate_nodeid_to_name(device, group_list)
offline[index] = device_name
responses_list["Offline"] = offline
console.nice_print(args,
console.text_color.reset + ("-" * 40))
if args.indent:
if not args.raw_result:
responses_list = transform.process_shell_response(args.shlex, responses_list)
console.nice_print(args,
json.dumps(responses_list,indent=4), True)
else:
console.nice_print(args,
json.dumps(responses_list), True)

242
modules/utilities.py Normal file
View File

@@ -0,0 +1,242 @@
# Public Python libraries
import argparse
from configparser import ConfigParser
import meshctrl
import os
import yaml
'''
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.
'''
class utilities:
@staticmethod
async def load_config(args: argparse.Namespace,
segment: str = 'meshcentral-account') -> dict:
'''
Function that loads the segment from the config.conf (by default) file and returns the it in a dict.
'''
conf_file = args.conf
if not os.path.exists(conf_file):
print(f'Missing config file {conf_file}. Provide an alternative path.')
os._exit(1)
config = ConfigParser()
try:
config.read(conf_file)
except Exception as err:
print(f"Error reading configuration file '{conf_file}': {err}")
os._exit(1)
if segment not in config:
print(f'Segment "{segment}" not found in config file {conf_file}.')
os._exit(1)
return dict(config[segment])
@staticmethod
async def compile_book(meshbook_file: str) -> dict:
'''
Simple function that opens the file and replaces placeholders through the next function. After that just return it.
'''
with open(meshbook_file, 'r') as f:
meshbook = f.read()
meshbook = await transform.replace_placeholders(yaml.safe_load(meshbook))
return meshbook
@staticmethod
def get_os_variants(target_category: str,
os_map: dict) -> set:
'''
Extracts all OS names under a given category if it exists.
'''
for key, value in os_map.items():
if key == target_category:
if isinstance(value, dict): # Expand nested categories
os_set = set()
for sub_target_cat in value:
os_set.update(utilities.get_os_variants(sub_target_cat, value))
return os_set
elif isinstance(value, list): # Direct OS list
return set(value)
return set()
@staticmethod
async def filter_targets(devices: list[dict],
os_categories: dict,
target_os: str = "",
ignore_categorisation: bool = False,
target_tag: str = "") -> dict:
'''
Filters devices based on reachability and optional OS criteria, supporting nested OS categories.
'''
valid_devices = []
offline_devices = []
allowed_os = set()
# Identify correct OS filtering scope
for key in os_categories:
if key == target_os:
allowed_os = utilities.get_os_variants(target_os, os_categories)
break # Stop searching once a match is found
if isinstance(os_categories[key], dict) and target_os in os_categories[key]:
allowed_os = utilities.get_os_variants(target_os, os_categories[key])
break # Stop searching once a match is found
for device in devices: # Filter out unwanted or unreachable devices.
if target_tag and target_tag not in device["device_tags"]:
continue
if not ignore_categorisation:
if device["device_os"] not in allowed_os:
continue
else:
if target_os not in device["device_os"]:
continue
if not device["reachable"]:
offline_devices.append(device["device_id"])
continue
valid_devices.append(device["device_id"])
return {
"valid_devices": valid_devices,
"offline_devices": offline_devices
}
@staticmethod
async def process_device(device: str,
group_list: dict,
os_categories: dict,
target_os: str,
ignore_categorisation: bool,
target_tag: str,
add_processed_devices=None) -> dict:
"""
Processes a single device or pseudo-target against group_list,
filters matches by OS and tags, and adds processed devices.
"""
matched_devices = []
pseudo_target = device.lower()
# Find devices that match the pseudo_target
for group in group_list:
for dev in group_list[group]:
if dev["device_name"].lower() == pseudo_target:
matched_devices.append(dev)
# If matches found, filter them and add processed devices
if matched_devices:
processed = await utilities.filter_targets(
matched_devices, os_categories, target_os, ignore_categorisation, target_tag
)
if add_processed_devices:
await add_processed_devices(processed)
return processed
# No matches found
return {"valid_devices": [], "offline_devices": []}
import shlex
class transform:
@staticmethod
def process_shell_response(shlex_enable: bool, meshbook_result: dict) -> dict:
for task_name, task_data in meshbook_result.items():
if task_name == "Offline": # Failsafe
continue
for node_responses in task_data["data"]:
task_result = node_responses["result"].splitlines()
if shlex_enable:
for index, line in enumerate(task_result):
line = shlex.split(line)
task_result[index] = line
clean_output = []
for line in task_result:
if len(line) > 0:
clean_output.append(line)
node_responses["result"] = clean_output
return meshbook_result
@staticmethod
async def translate_nodeid_to_name(target_id: str, group_list: dict) -> str:
'''
Simple function that looks up nodeid to the human-readable name if existent - otherwise return None.
'''
for group in group_list:
for device in group_list[group]:
if device["device_id"] == target_id:
return device["device_name"]
return ""
@staticmethod
async def replace_placeholders(meshbook: dict) -> dict:
'''
Replace the placeholders in both name and command fields of the tasks. According to the variables defined in the variables list.
'''
variables = {}
if "variables" in meshbook and isinstance(meshbook["variables"], list):
for var in meshbook["variables"]:
var_name = var["name"]
var_value = var["value"]
variables[var_name] = var_value
else:
return meshbook
for task in meshbook.get("tasks", []):
task_name = task.get("name")
for var_name, var_value in variables.items():
placeholder = f"{{{{ {var_name} }}}}"
task_name = task_name.replace(placeholder, var_value)
task["name"] = task_name
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 meshbook
@staticmethod
async def compile_group_list(session: meshctrl.Session) -> dict:
'''
Function that retrieves the devices from MeshCentral and compiles it into a efficient list.
'''
devices_response = await session.list_devices(details=False, timeout=10)
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
})
return local_device_list

26
os_categories.json Normal file
View File

@@ -0,0 +1,26 @@
{
"Linux": {
"Debian": [
"Debian GNU/Linux 13 (trixie)",
"Debian GNU/Linux 12 (bookworm)",
"Debian GNU/Linux 11 (bullseye)"
],
"Ubuntu": [
"Ubuntu 24.04.1 LTS",
"Ubuntu 22.04.5 LTS",
"Ubuntu 20.04.6 LTS"
]
},
"MacOS": {
"Sequoia": [
"macOS 15.0.1"
]
},
"Windows": {
"11": [
"Microsoft Windows 11 Home - 24H2/26100",
"Microsoft Windows 11 Pro - 24H2/26100"
]
}
}

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
colorama==0.4.6
pyyaml==6.0.2
libmeshctrl==1.2.2
pyotp==2.9.0

View File

@@ -1,4 +1,5 @@
[meshcentral-account]
websocket_url =
hostname =
username =
password =
password =
totp_secret =