Self-hosted remote task execution for Windows fleets, in two small Rust binaries.
A central server publishes a per-host task list and hosts the scripts / installers; every
machine runs the client from a scheduled task, which asks "what should I run?", downloads and
verifies the files, runs them, and reports back. A minimal, dependency-free alternative to a
configuration-management agent for pushing PowerShell scripts and .exe / .msi installers.
Mandatum executes whatever the server hands out. Since 1.0 every manifest is signed, every file is hash-verified and every request is authenticated with a shared token, and the server can speak HTTPS — but whoever controls the server (or its
scripts/folder) still controls every client. Treat the server like a domain controller. See Security model.
┌──────────────────────────────────────────────────────┐
│ SERVER │
│ master_task_config.json (hot-reloaded on change) │
│ GET /api/tasks/{host} → signed manifest + SHA-256s │
│ GET /api/files/... → scripts / installers │
│ POST /api/status → signed result reports │
│ GET /api/hosts /api/reports /api/health │
└──────────────────────────────────────────────────────┘
▲ Bearer token │ files ▲ HMAC-signed
│ ▼ │ report
│ ┌─────────────┐ │
└────────────────│ CLIENT │───────────┘
│ (per host, │
│ sched task)│
└─────────────┘
│ verify SHA-256 → run with timeout
▼ powershell.exe -NoProfile -ExecutionPolicy Bypass -File …
- Download
mandatum-<version>-windows-x64.zip(or the Linux / macOS tarball for the server) from the latest release and unpack it on the server. - Generate a token:
server --gen-token. Put it intoserver_config.jsonandclient_config.json. - Put your scripts into
scripts/and describe who runs what inmaster_task_config.json(or opengui.htmlin a browser — it edits and downloads that file). server --check, thenserver. It logs tologs/server.logand appends every report toreports/status.jsonl.- On each machine: copy
client.exe,client_config.json,log4rs-client.yamlinto one folder, setserver_base_url, runclient --check, then importMandatum.xmlinto Task Scheduler (runs daily as SYSTEM).
server_config.json
| key | |
|---|---|
listen_address, listen_port |
bind address (0.0.0.0 to serve the network) |
master_config_path |
the fleet manifest (watched, hot-reloaded when it changes and still parses) |
auth_token |
shared secret; leave empty only on a fully trusted network (the server warns loudly) |
tls_cert, tls_key |
PEM chain + key → HTTPS (rustls). Self-signed is fine, see below |
reports_path |
JSON-lines file receiving every status report (default reports/status.jsonl) |
max_output_bytes |
script output kept per report (default 64 KiB) |
master_task_config.json — host groups, each with a list of scripts. hostnames are
case-insensitive; "*" matches everybody. Host-specific tasks come first, then wildcard tasks, each
sorted by executionOrder.
{
"configVersionID": "2026-08-21",
"scriptBaseDirectory": "./scripts",
"globalForceExecution": false,
"hostConfigurations": [
{ "description": "Agent rollout", "hostnames": ["HOST01", "HOST02"],
"scriptsToExecute": [
{ "name": "Install agent", "scriptPath": "installers/agent-setup.exe", "arguments": "/S",
"executionOrder": 10, "timeoutSeconds": 900, "forceExecution": false } ] },
{ "description": "Everyone", "hostnames": ["*"],
"scriptsToExecute": [
{ "name": "Remove language packs", "scriptPath": "remove_languages.ps1", "executionOrder": 10 } ] }
]
}Per script: scriptPath (relative to scriptBaseDirectory, no ..), arguments (double quotes group
words), executionOrder, timeoutSeconds (default 3600 — the process is killed after that),
forceExecution (run even if this host already completed it). Supported types: .ps1, .exe, .msi
(msiexec /i … /qn), .bat / .cmd on Windows; .ps1 (via pwsh) and .sh on Linux / macOS.
client_config.json
| key | |
|---|---|
server_base_url |
http:// or https:// URL of the server |
auth_token |
must equal the server's token |
server_ca_cert |
PEM of your CA or the server's self-signed certificate (needed for HTTPS with a private CA) |
log_and_state_base_dir |
where <host>/ExecutedScriptsState.json (completed tasks) and temp files live, relative to the exe |
hostname_override |
report as a different hostname (testing) |
request_timeout_seconds |
HTTP timeout (default 60) |
All paths resolve relative to the executable's folder, so the client works from Task Scheduler
(whose working directory is System32). Client logs go to client_logs/client.log (rolling).
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -keyout key.pem -out cert.pem -subj "/CN=mandatum" \
-addext "subjectAltName=DNS:mandatum.example.internal,IP:10.0.0.5"Server: "tls_cert": "cert.pem", "tls_key": "key.pem". Clients: "server_ca_cert": "cert.pem" and an
https:// URL. The name / IP in the URL must match the SAN.
server server --check server --gen-token
client client --check client --dry-run
client exits 0 when everything succeeded, 1 when a task failed, 2 when the server could not be reached
or rejected the token / signature.
All routes require Authorization: Bearer <auth_token> when a token is configured.
| route | |
|---|---|
GET /api/tasks/{hostname} |
{hostname, configVersionId, issuedAt, tasks[]} — header X-Mandatum-Signature = hex HMAC-SHA256(token, body); every task carries sha256 |
GET /api/files/<scriptPath> |
the file |
POST /api/status |
a TaskStatusReport; must carry X-Mandatum-Signature over the body |
GET /api/hosts |
last report per host |
GET /api/reports?host=NAME |
last 50 reports (per host) kept in memory; the full history is in reports/status.jsonl |
GET /api/health |
version, config version, uptime, whether auth is on |
What 1.0 gives you:
- Authentication — nobody without the token can list tasks, fetch scripts or post reports.
- Manifest integrity — the client verifies the HMAC over the exact bytes it received and refuses anything unsigned, mis-signed or older than 10 minutes (replay of an old manifest).
- File integrity — every download is compared to the SHA-256 in the signed manifest; a tampered or
swapped file is reported as
HashMismatchand never executed. - Report integrity — forged status reports are rejected.
- Transport confidentiality — optional; enable TLS if the token or script contents must not be readable on the wire.
- No path traversal —
scriptPathcannot escapescriptBaseDirectory;/api/filesserves only that folder. - Bounded execution — per-task timeouts, output size limits, one client instance at a time (Task Scheduler
IgnoreNew).
What it does not give you: the server is the root of trust (protect its files and the token like credentials — rotating the token means updating every client), there is no per-host key, no audit of who changed the manifest, and a host that runs the client as SYSTEM will execute whatever an attacker with server access publishes. Keep the server on a management network and review scripts before publishing.
cargo build --release # target/release/server, target/release/client
cargo test && cargo clippy --all-targets -- -D warningsThe GitHub workflow builds Windows (x64), Linux (x64) and macOS (arm64) on every tag, runs the tests and a server ↔ client round trip, and attaches the archives to the release.
- shared-token authentication on every route, HMAC-SHA256 signed manifests and reports, SHA-256 verified downloads, manifest freshness check, optional HTTPS (rustls) with private-CA support on the client
- per-task
timeoutSeconds(processes are killed), quoted arguments,.msi/.bat/.cmd/.shsupport,-NoProfilefor PowerShell, scripts run from their temp folder - status reports carry task name, path, duration and timestamp; persisted to
reports/status.jsonl; new/api/hosts,/api/reports,/api/health - hot reload watches the directory (survives editors that replace the file), debounced; bad JSON keeps the old config
server --gen-token / --check,client --check / --dry-run, meaningful exit codes, no panics on I/O or network errors, atomic state-file writes, stable task ids (derived from the path when not set)gui.htmleditstimeoutSeconds; example configs include the new keys; release builds for all three platforms
Upgrading from 0.1: the manifest changed from a bare array to an object — 0.1 clients cannot talk to a 1.0 server
and vice versa; upgrade both. Config files are backwards compatible (new keys are optional), but set auth_token.