A simple secure server for letting trusted devices run commands on other trusted devices.
  • Shell 52.6%
  • Rust 47.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-20 23:00:14 -07:00
config examples 2026-04-23 09:09:28 -07:00
legacy examples 2026-04-23 09:09:28 -07:00
src service improvements 2026-09-20 23:00:14 -07:00
.gitignore cmd server 2026-01-12 17:55:08 -08:00
Cargo.toml auth modes 2026-03-26 09:47:11 -07:00
device-overview.md examples 2026-04-23 09:09:28 -07:00
install install fix 2026-07-16 12:12:01 -07:00
README.md service improvements 2026-09-20 23:00:14 -07:00
uninstall installation 2026-01-14 10:36:56 -08:00

Command server

An HTTP server that executes pre-approved binaries on demand. Requests post a command and arguments; the server checks that the binary is allowed in ~/.config/cmdd/config.toml and runs it. Commands run in parallel unless a queue is specified in the request or config, in which case commands sharing that queue execute one-at-a-time. Long-running processes can be declared as services and started, stopped, or restarted over the same authenticated HTTP API.

Configuration

./install creates a starter ~/.config/cmdd/config.toml with a generated shared secret when the file does not already exist, and preserves any existing config file.

Create ~/.config/cmdd/config.toml (or pass --config with another path):

api_keys = ["super-secret-key"]

[history]
enabled = true
max_entries = 1000
max_age_days = 30

[[command]]
name = "echo-hello"
binary = "/usr/bin/echo"
args = ["hello"]
queue = "echo-queue"
allow_args = true

[[command]]
name = "date"
binary = "/usr/bin/date"
allow_args = false

[[command]]
name = "example-service"
binary = "/usr/bin/python3"
args = ["-m", "http.server", "9000"]
mode = "service"
restart = "on-failure"
stop_signal = "SIGTERM"
working_dir = "/srv/example"
env = ["EXAMPLE_MODE=service"]

Fields:

  • api_keys (required for hmac and api_key modes): shared secrets used for request auth. In hmac mode they sign requests; in api_key mode clients send one directly in X-API-Key; in none mode they are ignored.
  • name (optional): request identifier clients send in the JSON body. When omitted, clients send the binary path directly.
  • binary (required): absolute executable path that cmdd actually runs.
  • args (optional): fixed arguments always supplied by the config for that command.
  • queue (optional): commands with the same queue run serially; omit to allow parallel execution. Forked commands release their queue as soon as the process is spawned.
  • fork (optional, default false): if true, the server starts the command and returns immediately with status: "started"; the outcome is recorded in history/stats but not in the response.
  • allow_args (optional, default false): set to true to allow request arguments; when enabled they are appended after any config args.
  • mode (optional, default command): set to service for a long-running process managed with start/stop/restart instead of /execute. Services cannot also set fork.
  • restart (optional, default no, service only): supervisor restart policy — no, on-failure, or always.
  • stop_signal (optional, default SIGINT, service only): one of SIGINT, SIGTERM, SIGHUP, or SIGQUIT; used for graceful stops.
  • env (optional, service only): KEY=VALUE entries added to the service environment.
  • working_dir (optional, service only): working directory for the service process.
  • security.auth_mode (optional, default hmac): hmac, api_key, or none.
  • security.ip_allowlist (optional): comma-separated IP globs (default 127.0.0.1, ::1) that gate all requests. Use * to whitelist every address.
  • security.rate_limit (optional): configure max_requests and window_secs to throttle traffic per API key/IP pair.
  • security.throttle_delay_ms (optional): simple delay applied to every request to slow abusive clients.
  • security.max_failed_attempts and security.failed_attempt_window_secs: block API keys when invalid auth is repeated.
  • security.auth_window_secs (optional, default 60): allowed clock skew and replay window for signed requests in hmac mode.
  • security.default_timeout_ms and security.max_output_bytes: control how long commands can run and how much stdout/stderr is returned (per-command overrides exist).
  • security.allow_ssh_hostnames (optional): when true, any HostName <ip> lines in ~/.ssh/config are merged into the allowlist, so SSH-managed devices can connect.
  • security.ip_allowlist (per-command, optional): overrides the global allowlist for that command.
  • timeout_ms (per command): override the default timeout.
  • max_output_bytes (per command): override the global output cap.
  • [history] enabled (optional, default true): append executions and service actions to ~/.local/share/cmdd/history.jsonl and aggregate counters in ~/.local/share/cmdd/stats.json (override the directory with CMDD_DATA_DIR).
  • [history] max_entries (optional, default 1000): cap retained history entries; the oldest are pruned once the limit is exceeded.
  • [history] max_age_days (optional, default 30): drop history entries older than this many days (0 disables age-based pruning).

Running

cargo run -- --addr 0.0.0.0:8080
# optionally: cargo run -- --config /path/to/config.toml

If the config file is missing, startup fails with an error like:

Error: Failed to read config at /home/user/.config/cmdd/config.toml

Caused by:
    No such file or directory (os error 2)

If the config exists but api_keys is empty while security.auth_mode = "hmac" or "api_key", startup fails with:

Error: config must include at least one api_key when security.auth_mode is hmac

After startup, config changes are picked up automatically on the next request. If a config edit is invalid, cmdd logs a reload warning and keeps serving with the last valid config.

Commands with mode = "service" are supervised with systemd-run --user when a user systemd session is available, and fall back to setsid otherwise. Set CMDD_SERVICE_BACKEND=systemd or CMDD_SERVICE_BACKEND=setsid to force a backend (useful for tests). restart policies on the systemd backend wait five seconds between restarts and give up after three restarts within two minutes, so a crash-looping service retries a few times instead of hammering the host.

Talking to the server

POST http://<host>:<port>/execute with JSON body:

{
  "command": "echo-hello",
  "args": ["world"],
  "queue": "echo-queue"
}
  • Same request body works in every mode; only the auth headers change.

  • security.auth_mode = "hmac": send X-Timestamp, X-Nonce, and X-Signature, where X-Signature is hex HMAC-SHA256(secret, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + raw_body_bytes).

  • security.auth_mode = "api_key": send X-API-Key.

  • security.auth_mode = "none": send no auth headers.

  • command (string): required, must match the configured name, or the binary path when no name is set.

  • args (string array): optional, defaults to empty; rejected when allow_args = false for that command.

  • queue (string): optional; overrides the config queue when provided.

Services

Long-running commands declared with mode = "service" are managed through:

  • POST /services/<name>/start — start the service (idempotent when already active).
  • POST /services/<name>/stop — stop the service gracefully.
  • POST /services/<name>/restart — stop then start.
  • GET /services — list every configured service with its status.
  • GET /services/<name> — status for one service.

Status responses include name, active, backend (systemd or setsid), pid, restarts, and started_at_ms. Service endpoints use the same auth headers, IP allowlist, rate limits, and timeouts as /execute.

History and stats

  • GET /history?limit=<n>&command=<name>&queue=<name> — recent executions and service actions, newest first. limit defaults to 100 and is clamped to 1000.
  • GET /stats — cumulative counters per command and service action plus an overall total.

Both endpoints accept the same auth headers as /execute.

Response

  • Standard execution returns status: "ok" or "failed" with exit_code, stdout, and stderr populated.
  • When fork = true for the command, the server returns immediately with status: "started", exit_code: 0, and empty stdout/stderr while the process continues in the background; its queue is released right after spawn and the outcome is recorded in history.
  • Service endpoints return a JSON status object (name, active, backend, pid, restarts, started_at_ms) instead of an execution result.
  • Errors return 4xx with a plain text message such as Missing signature header, Missing API key header, Malformed nonce header, Expired request timestamp, Replay detected, Invalid request signature, Invalid API key, or Command not allowed.

Auth mode quick reference

  • | Mode | Required headers | Best use case | Tradeoff |

  • | --- | --- | --- | --- |

  • | hmac | X-Timestamp, X-Nonce, X-Signature | Plain HTTP on a trusted LAN | No transport encryption; most setup |

  • | api_key | X-API-Key | Behind HTTPS, WireGuard, or SSH tunnel | Sends shared secret directly |

  • | none | none | Intentionally open / isolated devices | No authentication at all |

  • hmac: best default for plain HTTP on a trusted LAN; protects the shared secret and blocks replay/tampering.

  • api_key: simplest when an HTTPS reverse proxy or encrypted tunnel already protects transport.

  • none: intentionally unauthenticated; only use when that is truly acceptable.

Examples

Auth modes

# Default: good fit for plain HTTP on a trusted LAN
api_keys = ["super-secret-key"]

[security]
auth_mode = "hmac"
# Simpler when an HTTPS reverse proxy already protects transport
api_keys = ["super-secret-key"]

[security]
auth_mode = "api_key"
# Intentionally insecure / unauthenticated
[security]
auth_mode = "none"

hmac: signed request over HTTP

Config:

api_keys = ["super-secret-key"]

[security]
auth_mode = "hmac"

Request:

BODY='{"command":"/usr/bin/echo","args":["hello"],"queue":"echo-queue"}'
TIMESTAMP=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIGNATURE=$(python - <<'PY' "$TIMESTAMP" "$NONCE" "$BODY"
import hashlib
import hmac
import sys

secret = b"super-secret-key"
timestamp, nonce, body = sys.argv[1], sys.argv[2], sys.argv[3]
message = b"POST\n/execute\n" + timestamp.encode() + b"\n" + nonce.encode() + b"\n" + body.encode()
print(hmac.new(secret, message, hashlib.sha256).hexdigest())
PY
)

curl -X POST http://localhost:8080/execute \
  -H "Content-Type: application/json" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIGNATURE" \
  -d "$BODY"

api_key: direct shared secret header

Config:

api_keys = ["super-secret-key"]

[security]
auth_mode = "api_key"

Request:

curl -X POST http://localhost:8080/execute \
  -H "Content-Type: application/json" \
  -H "X-API-Key: super-secret-key" \
  -d '{"command":"/usr/bin/date"}'

none: no auth headers

Config:

[security]
auth_mode = "none"

Request:

curl -X POST http://localhost:8080/execute \
  -H "Content-Type: application/json" \
  -d '{"command":"/usr/bin/date"}'

hmac: no-args command

BODY='{"command":"/usr/bin/date"}'
TIMESTAMP=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIGNATURE=$(python - <<'PY' "$TIMESTAMP" "$NONCE" "$BODY"
import hashlib
import hmac
import sys

secret = b"super-secret-key"
timestamp, nonce, body = sys.argv[1], sys.argv[2], sys.argv[3]
message = b"POST\n/execute\n" + timestamp.encode() + b"\n" + nonce.encode() + b"\n" + body.encode()
print(hmac.new(secret, message, hashlib.sha256).hexdigest())
PY
)

curl -X POST http://localhost:8080/execute \
  -H "Content-Type: application/json" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIGNATURE" \
  -d "$BODY"
# this will fail with 400 if args are supplied because allow_args = false

hmac: missing signature example

curl -X POST http://localhost:8080/execute \
  -H "Content-Type: application/json" \
  -d '{"command":"/usr/bin/date"}'
# response: 401 Missing timestamp header

hmac: Rust (reqwest)

use hmac::{Hmac, Mac};
use reqwest::blocking::Client;
use serde::Deserialize;
use serde_json::json;
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha256 = Hmac<Sha256>;

#[derive(Deserialize)]
struct CommandResponse {
    status: String,
    exit_code: i32,
    stdout: String,
    stderr: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    let body = json!({
        "command": "/usr/bin/echo",
        "args": ["from", "rust"],
        "queue": "echo-queue"
    });
    let body_bytes = serde_json::to_vec(&body)?;
    let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs().to_string();
    let nonce = format!("req-{}", timestamp);
    let mut mac = HmacSha256::new_from_slice(b"super-secret-key")?;
    mac.update(b"POST\n/execute\n");
    mac.update(timestamp.as_bytes());
    mac.update(b"\n");
    mac.update(nonce.as_bytes());
    mac.update(b"\n");
    mac.update(&body_bytes);
    let signature = hex::encode(mac.finalize().into_bytes());

    let resp: CommandResponse = client
        .post("http://localhost:8080/execute")
        .header("X-Timestamp", timestamp)
        .header("X-Nonce", nonce)
        .header("X-Signature", signature)
        .body(body_bytes)
        .send()?
        .json()?;

    println!("status: {}", resp.status);
    println!("stdout: {}", resp.stdout);
    println!("exit: {}", resp.exit_code);
    Ok(())
}

Notes

  • Commands are executed exactly as provided; ensure absolute paths in the config for safety.
  • Config edits are reloaded on the next request; restart is not required for normal config changes.
  • hmac is the default and best fit for plain HTTP on a mildly trusted LAN; it protects the shared secret and blocks basic tampering/replay, but it does not encrypt command bodies or responses.
  • api_key is simplest behind HTTPS or an encrypted tunnel; on plaintext HTTP it sends the shared secret directly.
  • none disables authentication entirely and should only be used for intentionally open or otherwise isolated deployments.
  • Use WireGuard, SSH tunnels, or HTTPS when confidentiality matters.
  • When no queue is set in the request or config, commands are dispatched in parallel.
  • When a queue is set, commands sharing that queue run serially while other queues continue in parallel.
  • Use fork = true when you only need to start a command and return immediately.
  • Use allow_args = false to lock down commands to their exact binary invocation.