- Rust 87.1%
- Shell 12.9%
| src | ||
| .gitignore | ||
| Cargo.toml | ||
| install | ||
| README.md | ||
| uninstall | ||
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.
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"]
[[command]]
binary = "/usr/bin/echo"
queue = "echo-queue"
fork = true
[[command]]
binary = "/usr/bin/date"
allow_args = false
Fields:
api_keys(required forhmacandapi_keymodes): shared secrets used for request auth. Inhmacmode they sign requests; inapi_keymode clients send one directly inX-API-Key; innonemode they are ignored.binary(required): must match exactly what clients send.queue(optional): commands with the same queue run serially; omit to allow parallel execution.fork(optional, default false): if true, the server starts the command and returns immediately withstatus: "started".allow_args(optional, default false): set totrueto allow arguments; omit orfalseto reject request arguments for this command.security.auth_mode(optional, defaulthmac):hmac,api_key, ornone.security.ip_allowlist(optional): comma-separated IP globs (default127.0.0.1,::1) that gate all requests. Use*to whitelist every address.security.rate_limit(optional): configuremax_requestsandwindow_secsto 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_attemptsandsecurity.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 inhmacmode.security.default_timeout_msandsecurity.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): whentrue, anyHostName <ip>lines in~/.ssh/configare 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.
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.
Talking to the server
POST http://<host>:<port>/execute with JSON body:
{
"command": "/usr/bin/echo",
"args": ["hello", "world"],
"queue": "echo-queue"
}
-
Same request body works in every mode; only the auth headers change.
-
security.auth_mode = "hmac": sendX-Timestamp,X-Nonce, andX-Signature, whereX-Signatureis hexHMAC-SHA256(secret, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + raw_body_bytes). -
security.auth_mode = "api_key": sendX-API-Key. -
security.auth_mode = "none": send no auth headers. -
command(string): required, must appear in the config. -
args(string array): optional, defaults to empty; rejected whenallow_args = falsefor that command. -
queue(string): optional; overrides the config queue when provided.
Response
- Standard execution returns
status: "ok"or"failed"withexit_code,stdout, andstderrpopulated. - When
fork = truefor the command, the server returns immediately withstatus: "started",exit_code: 0, and emptystdout/stderrwhile the process continues in the background. - Errors return
4xxwith a plain text message such asMissing signature header,Missing API key header,Malformed nonce header,Expired request timestamp,Replay detected,Invalid request signature,Invalid API key, orCommand 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.
hmacis 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_keyis simplest behind HTTPS or an encrypted tunnel; on plaintext HTTP it sends the shared secret directly.nonedisables 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 = truewhen you only need to start a command and return immediately. - Use
allow_args = falseto lock down commands to their exact binary invocation.