diff --git a/README.md b/README.md index cf93797..b19f81b 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,9 @@ hb_port: 50003 # Server UDP port interval: 10 # Heartbeat interval (seconds) owner: alice # Optional: claim ownership of this host +allow_remote_command: false # Execute shell commands sent by the server in CMD packets. + # Default false — see Remote command execution below. + plugins: cpu_monitor: interval: 300 # Override collection interval @@ -242,6 +245,27 @@ plugins: - If a connection fails to open at startup, IPv6 connections are dropped after 3 consecutive failures. IPv4 connections retry indefinitely. - In daemon mode (`-d`), all log output goes to syslog (`LOG_DAEMON` facility). +### Remote command execution + +The server can queue a shell command for a host, delivered in a `CMD` packet. Because +heartbeat packets are unauthenticated UDP, the client only runs those commands when the +host opts in: + +```yaml +allow_remote_command: true +``` + +With the default `false`, the command is logged and refused, and the client replies +`Refused: allow_remote_command is false` — visible in the server's event log under the +`command` service, so a queued command never fails silently. When enabled, `hbc` logs a +warning at startup naming the risk. `SIGHUP` re-execs the client, so a config change takes +effect on reload. + +All four clients enforce this: `hbc`, `hbc_windows.py`, `hbc_mini.py`, and the C +`hbc_mini` (which reads the same key from `~/.hbc.json` and needs a rebuild to pick the +change up). It does not gate `UPD` (self-update via `hb_install.sh`), which remains +ungated. + --- ## UDP Protocol @@ -259,7 +283,7 @@ Payload format: `key=value;key=value;...` | `HTB` | client → server | Heartbeat (name, timestamp, RTT, acks, interval) | | `PLG` | client → server | Plugin data (plugin name + metrics) | | `ACK` | server → client | Acknowledgment | -| `CMD` | server → client | Execute a shell command on the client | +| `CMD` | server → client | Execute a shell command on the client (requires `allow_remote_command`) | | `UPD` | server → client | Trigger self-update via `hb_install.sh` | Value encoding: diff --git a/hbd/client/config.py b/hbd/client/config.py index 2b1d242..f8c7765 100644 --- a/hbd/client/config.py +++ b/hbd/client/config.py @@ -19,6 +19,9 @@ CLIENT_DEFAULTS = { # Host identity "owner": None, # Optional username to set as this host's owner on the server + # Security + "allow_remote_command": False, # Execute shell commands received in CMD packets from the server + # Runtime flags "foreground": False, "verbose": False, diff --git a/hbd/client/main.py b/hbd/client/main.py index 641bcf3..e7b5392 100644 --- a/hbd/client/main.py +++ b/hbd/client/main.py @@ -37,6 +37,10 @@ dorestart = False shutdown_event: Optional[asyncio.Event] = None active_tasks: List[asyncio.Task] = [] +# Set from config in async_main. Off by default: a CMD packet is an unauthenticated +# UDP datagram, so executing one must be opted into per host. +allow_remote_command = False + class AsyncConnection: """Async UDP connection to a heartbeat server.""" @@ -183,16 +187,25 @@ class HeartbeatProtocol(asyncio.DatagramProtocol): async def handle_command(conn: AsyncConnection, msg: dict): - """Execute a command received from server.""" + """Execute a command received from server, if allow_remote_command is set.""" import subprocess - + cmd = msg.get("cmd", "") if not cmd: return - + logger = logging.getLogger("hbc.command") + + if not allow_remote_command: + logger.warning(f"Refused command (allow_remote_command is false): {cmd}") + await conn.sendto({ + "service": "command", + "msg": "Refused: allow_remote_command is false", + }) + return + logger.info(f"Executing command: {cmd}") - + try: result = subprocess.check_output( cmd, shell=True, stderr=subprocess.STDOUT, timeout=30 @@ -501,11 +514,12 @@ async def cleanup(connections: List[AsyncConnection]): async def async_main(args, config): """Async main function.""" - global running, shutdown_event, active_tasks, send_shutdown - + global running, shutdown_event, active_tasks, send_shutdown, allow_remote_command + # Create shutdown event shutdown_event = asyncio.Event() active_tasks = [] + allow_remote_command = bool(config.get("allow_remote_command", False)) logger = logging.getLogger("hbc.main") @@ -519,6 +533,8 @@ async def async_main(args, config): interval = config.get("interval", INTERVAL) logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s") + if allow_remote_command: + logger.warning("allow_remote_command is true — CMD packets from the server will be executed") af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False) else socket.AF_INET6 if getattr(args, "ipv6_only", False) diff --git a/scripts/c/hbc_mini.c b/scripts/c/hbc_mini.c index f5ded92..4ca7c6a 100644 --- a/scripts/c/hbc_mini.c +++ b/scripts/c/hbc_mini.c @@ -375,6 +375,10 @@ static const char *jstr(const jval_t *v, const char *def) { * Config * ============================================================ */ +/* Set from config in cfg_load. Off by default: a CMD packet is an unauthenticated + * UDP datagram, so executing one must be opted into per host. */ +static bool g_allow_remote_command = false; + typedef struct { int hb_port, interval; char owner[256]; @@ -429,6 +433,9 @@ static void config_load(config_t *cfg, const char *path) { if ((v = jget(root, "hb_port"))) cfg->hb_port = jint(v, cfg->hb_port); if ((v = jget(root, "interval"))) cfg->interval = jint(v, cfg->interval); if ((v = jget(root, "owner"))) snprintf(cfg->owner, sizeof(cfg->owner), "%s", jstr(v, "")); + if ((v = jget(root, "allow_remote_command"))) g_allow_remote_command = jint(v, 0) != 0; + if (g_allow_remote_command) + LOGI("allow_remote_command is true - CMD packets from the server will be executed"); jval_t *plugins = jget(root, "plugins"); @@ -591,7 +598,13 @@ static void conn_recv(conn_t *c) { LOGD("ACK rtt=%.1fms", c->rtt); } else if (strcmp(id, "CMD") == 0) { const char *cmd = kv_get(&msg, "cmd"); - if (cmd) { + if (cmd && !g_allow_remote_command) { + LOGI("refused command (allow_remote_command is false): %s", cmd); + kvdict_t rep; kv_clear(&rep); + kv_set(&rep, "service", "command"); + kv_set(&rep, "msg", "Refused: allow_remote_command is false"); + conn_send(c, "HTB", &rep); + } else if (cmd) { LOGI("CMD: %s", cmd); char out[4096] = ""; FILE *p = popen(cmd, "r"); diff --git a/scripts/hbc_mini.py b/scripts/hbc_mini.py index af2d493..b8a7b79 100755 --- a/scripts/hbc_mini.py +++ b/scripts/hbc_mini.py @@ -115,9 +115,14 @@ _DEFAULTS: Dict[str, Any] = { "hb_port": 50003, "interval": 10, "owner": None, + "allow_remote_command": False, # Execute shell commands received in CMD packets "plugins": {}, } +# Set from config in main(). Off by default: a CMD packet is an unauthenticated +# UDP datagram, so executing one must be opted into per host. +_allow_remote_command = False + def _load_config(path: Optional[str] = None) -> Dict[str, Any]: cfg = dict(_DEFAULTS) @@ -870,6 +875,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]): if not cmd: return log = logging.getLogger("hbc.cmd") + if not _allow_remote_command: + log.warning("refused command (allow_remote_command is false): %s", cmd) + await conn.sendto({"service": "command", "msg": "Refused: allow_remote_command is false"}) + return log.info("exec: %s", cmd) try: out = subprocess.check_output( @@ -1180,6 +1189,11 @@ def main(argv=None): cfg = _load_config(args.configfile) + global _allow_remote_command + _allow_remote_command = bool(cfg.get("allow_remote_command", False)) + if _allow_remote_command: + logging.warning("allow_remote_command is true — CMD packets from the server will be executed") + if args.daemon: _daemonize() _reconfigure_syslog(level) diff --git a/scripts/hbc_windows.py b/scripts/hbc_windows.py index d61fb37..278ce57 100644 --- a/scripts/hbc_windows.py +++ b/scripts/hbc_windows.py @@ -112,9 +112,14 @@ _DEFAULTS: Dict[str, Any] = { "hb_port": 50003, "interval": 10, "owner": None, + "allow_remote_command": False, # Execute shell commands received in CMD packets "plugins": {}, } +# Set from config in main(). Off by default: a CMD packet is an unauthenticated +# UDP datagram, so executing one must be opted into per host. +_allow_remote_command = False + def _load_config(path: Optional[str] = None) -> Dict[str, Any]: cfg = dict(_DEFAULTS) @@ -886,6 +891,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]): if not cmd: return log = logging.getLogger("hbc.cmd") + if not _allow_remote_command: + log.warning("refused command (allow_remote_command is false): %s", cmd) + await conn.sendto({"service": "command", "msg": "Refused: allow_remote_command is false"}) + return log.info("exec: %s", cmd) try: out = subprocess.check_output( @@ -1180,6 +1189,11 @@ def main(argv=None): cfg = _load_config(args.configfile) + global _allow_remote_command + _allow_remote_command = bool(cfg.get("allow_remote_command", False)) + if _allow_remote_command: + logging.warning("allow_remote_command is true — CMD packets from the server will be executed") + try: rc = asyncio.run(_async_main(args, cfg)) except KeyboardInterrupt: diff --git a/tests/test_client_command.py b/tests/test_client_command.py new file mode 100644 index 0000000..a2deec1 --- /dev/null +++ b/tests/test_client_command.py @@ -0,0 +1,103 @@ +"""Tests for the allow_remote_command gate on CMD packets in hbc.""" +import asyncio +import subprocess + +import pytest + +from hbd.client import main as client_main +from hbd.client.config import CLIENT_DEFAULTS, load_config + + +class FakeConn: + """Stand-in for AsyncConnection that records what the client sends back.""" + + def __init__(self): + self.sent = [] + + async def sendto(self, msg, msg_id="HTB"): + self.sent.append(msg) + + +@pytest.fixture +def conn(monkeypatch): + """A fake connection with remote commands disabled and subprocess booby-trapped.""" + monkeypatch.setattr(client_main, "allow_remote_command", False) + + def explode(*a, **kw): + raise AssertionError("subprocess must not run when the command is refused") + + monkeypatch.setattr(subprocess, "check_output", explode) + return FakeConn() + + +def test_default_is_false(): + assert CLIENT_DEFAULTS["allow_remote_command"] is False + assert load_config("/nonexistent/hbc.yaml")["allow_remote_command"] is False + assert client_main.allow_remote_command is False + + +def test_refused_when_disabled(conn): + asyncio.run(client_main.handle_command(conn, {"cmd": "id"})) + assert conn.sent == [{ + "service": "command", + "msg": "Refused: allow_remote_command is false", + }] + + +def test_refusal_reported_for_every_attempt(conn): + for _ in range(3): + asyncio.run(client_main.handle_command(conn, {"cmd": "rm -rf /"})) + assert len(conn.sent) == 3 + + +def test_empty_command_is_ignored_either_way(conn, monkeypatch): + asyncio.run(client_main.handle_command(conn, {})) + monkeypatch.setattr(client_main, "allow_remote_command", True) + asyncio.run(client_main.handle_command(conn, {"cmd": ""})) + assert conn.sent == [] + + +def test_executed_when_enabled(conn, monkeypatch): + monkeypatch.setattr(client_main, "allow_remote_command", True) + monkeypatch.setattr(subprocess, "check_output", lambda *a, **kw: b"uid=0(root)") + asyncio.run(client_main.handle_command(conn, {"cmd": "id"})) + assert conn.sent == [{"service": "command", "msg": "OK uid=0(root)"}] + + +def test_failure_reported_when_enabled(conn, monkeypatch): + monkeypatch.setattr(client_main, "allow_remote_command", True) + + def fail(*a, **kw): + raise subprocess.CalledProcessError(1, "false") + + monkeypatch.setattr(subprocess, "check_output", fail) + asyncio.run(client_main.handle_command(conn, {"cmd": "false"})) + assert len(conn.sent) == 1 + assert conn.sent[0]["msg"].startswith("CalledProcessError ") + + +def test_config_file_can_opt_in(tmp_path): + cfg_file = tmp_path / "hbc.yaml" + cfg_file.write_text("allow_remote_command: true\n", encoding="utf-8") + assert load_config(str(cfg_file))["allow_remote_command"] is True + + +@pytest.mark.parametrize("configured, expected", [(True, True), (False, False), (None, False)]) +def test_async_main_sets_the_flag_from_config(monkeypatch, configured, expected): + """async_main must apply the config value before any CMD packet can arrive.""" + monkeypatch.setattr(client_main, "allow_remote_command", not expected) + + class Bail(Exception): + pass + + # gethostname() is the first call after the flag is assigned — bail there so the + # test never opens a socket. + def bail(): + raise Bail + + monkeypatch.setattr(client_main.socket, "gethostname", bail) + + cfg = {} if configured is None else {"allow_remote_command": configured} + with pytest.raises(Bail): + asyncio.run(client_main.async_main(object(), cfg)) + assert client_main.allow_remote_command is expected