A simple secure server for letting trusted devices run commands on other trusted devices.
  • Rust 87.1%
  • Shell 12.9%
Find a file
microchipster b75945029b auth modes
2026-03-26 09:47:11 -07:00
src auth modes 2026-03-26 09:47:11 -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
install auth modes 2026-03-26 09:47:11 -07:00
README.md auth modes 2026-03-26 09:47:11 -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.

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 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.
  • 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 with status: "started".
  • allow_args (optional, default false): set to true to allow arguments; omit or false to reject request arguments for this command.
  • 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.

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": 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 appear in the config.

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

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

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.
  • 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.