feat: gate remote command execution behind allow_remote_command
CMD packets arrive as unauthenticated UDP datagrams, yet every hbc client executed the shell command they carry without any opt-in. Add an allow_remote_command config key, default false: when off, the command is logged and refused with "Refused: allow_remote_command is false" (visible in the server event log under the command service), and subprocess is never reached. When on, the client warns at startup that it will execute CMD packets. Applied to all four clients that handle CMD — hbc, hbc_windows.py, hbc_mini.py, and the C hbc_mini — since gating only one leaves the others wide open. The C client reads the same key from ~/.hbc.json and needs a rebuild to pick it up. UPD (self-update) is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4414967bdc
commit
ae3f2fc70f
@@ -212,6 +212,9 @@ hb_port: 50003 # Server UDP port
|
|||||||
interval: 10 # Heartbeat interval (seconds)
|
interval: 10 # Heartbeat interval (seconds)
|
||||||
owner: alice # Optional: claim ownership of this host
|
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:
|
plugins:
|
||||||
cpu_monitor:
|
cpu_monitor:
|
||||||
interval: 300 # Override collection interval
|
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.
|
- 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).
|
- 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
|
## UDP Protocol
|
||||||
@@ -259,7 +283,7 @@ Payload format: `key=value;key=value;...`
|
|||||||
| `HTB` | client → server | Heartbeat (name, timestamp, RTT, acks, interval) |
|
| `HTB` | client → server | Heartbeat (name, timestamp, RTT, acks, interval) |
|
||||||
| `PLG` | client → server | Plugin data (plugin name + metrics) |
|
| `PLG` | client → server | Plugin data (plugin name + metrics) |
|
||||||
| `ACK` | server → client | Acknowledgment |
|
| `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` |
|
| `UPD` | server → client | Trigger self-update via `hb_install.sh` |
|
||||||
|
|
||||||
Value encoding:
|
Value encoding:
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ CLIENT_DEFAULTS = {
|
|||||||
# Host identity
|
# Host identity
|
||||||
"owner": None, # Optional username to set as this host's owner on the server
|
"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
|
# Runtime flags
|
||||||
"foreground": False,
|
"foreground": False,
|
||||||
"verbose": False,
|
"verbose": False,
|
||||||
|
|||||||
+18
-2
@@ -37,6 +37,10 @@ dorestart = False
|
|||||||
shutdown_event: Optional[asyncio.Event] = None
|
shutdown_event: Optional[asyncio.Event] = None
|
||||||
active_tasks: List[asyncio.Task] = []
|
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:
|
class AsyncConnection:
|
||||||
"""Async UDP connection to a heartbeat server."""
|
"""Async UDP connection to a heartbeat server."""
|
||||||
@@ -183,7 +187,7 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
|
|||||||
|
|
||||||
|
|
||||||
async def handle_command(conn: AsyncConnection, msg: dict):
|
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
|
import subprocess
|
||||||
|
|
||||||
cmd = msg.get("cmd", "")
|
cmd = msg.get("cmd", "")
|
||||||
@@ -191,6 +195,15 @@ async def handle_command(conn: AsyncConnection, msg: dict):
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger = logging.getLogger("hbc.command")
|
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}")
|
logger.info(f"Executing command: {cmd}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -501,11 +514,12 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
|
|
||||||
async def async_main(args, config):
|
async def async_main(args, config):
|
||||||
"""Async main function."""
|
"""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
|
# Create shutdown event
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
active_tasks = []
|
active_tasks = []
|
||||||
|
allow_remote_command = bool(config.get("allow_remote_command", False))
|
||||||
|
|
||||||
logger = logging.getLogger("hbc.main")
|
logger = logging.getLogger("hbc.main")
|
||||||
|
|
||||||
@@ -519,6 +533,8 @@ async def async_main(args, config):
|
|||||||
interval = config.get("interval", INTERVAL)
|
interval = config.get("interval", INTERVAL)
|
||||||
|
|
||||||
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
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)
|
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
|
||||||
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
|
||||||
|
|||||||
+14
-1
@@ -375,6 +375,10 @@ static const char *jstr(const jval_t *v, const char *def) {
|
|||||||
* Config
|
* 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 {
|
typedef struct {
|
||||||
int hb_port, interval;
|
int hb_port, interval;
|
||||||
char owner[256];
|
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, "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, "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, "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");
|
jval_t *plugins = jget(root, "plugins");
|
||||||
|
|
||||||
@@ -591,7 +598,13 @@ static void conn_recv(conn_t *c) {
|
|||||||
LOGD("ACK rtt=%.1fms", c->rtt);
|
LOGD("ACK rtt=%.1fms", c->rtt);
|
||||||
} else if (strcmp(id, "CMD") == 0) {
|
} else if (strcmp(id, "CMD") == 0) {
|
||||||
const char *cmd = kv_get(&msg, "cmd");
|
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);
|
LOGI("CMD: %s", cmd);
|
||||||
char out[4096] = "";
|
char out[4096] = "";
|
||||||
FILE *p = popen(cmd, "r");
|
FILE *p = popen(cmd, "r");
|
||||||
|
|||||||
@@ -115,9 +115,14 @@ _DEFAULTS: Dict[str, Any] = {
|
|||||||
"hb_port": 50003,
|
"hb_port": 50003,
|
||||||
"interval": 10,
|
"interval": 10,
|
||||||
"owner": None,
|
"owner": None,
|
||||||
|
"allow_remote_command": False, # Execute shell commands received in CMD packets
|
||||||
"plugins": {},
|
"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]:
|
def _load_config(path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
cfg = dict(_DEFAULTS)
|
cfg = dict(_DEFAULTS)
|
||||||
@@ -870,6 +875,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]):
|
|||||||
if not cmd:
|
if not cmd:
|
||||||
return
|
return
|
||||||
log = logging.getLogger("hbc.cmd")
|
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)
|
log.info("exec: %s", cmd)
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
@@ -1180,6 +1189,11 @@ def main(argv=None):
|
|||||||
|
|
||||||
cfg = _load_config(args.configfile)
|
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:
|
if args.daemon:
|
||||||
_daemonize()
|
_daemonize()
|
||||||
_reconfigure_syslog(level)
|
_reconfigure_syslog(level)
|
||||||
|
|||||||
@@ -112,9 +112,14 @@ _DEFAULTS: Dict[str, Any] = {
|
|||||||
"hb_port": 50003,
|
"hb_port": 50003,
|
||||||
"interval": 10,
|
"interval": 10,
|
||||||
"owner": None,
|
"owner": None,
|
||||||
|
"allow_remote_command": False, # Execute shell commands received in CMD packets
|
||||||
"plugins": {},
|
"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]:
|
def _load_config(path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
cfg = dict(_DEFAULTS)
|
cfg = dict(_DEFAULTS)
|
||||||
@@ -886,6 +891,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]):
|
|||||||
if not cmd:
|
if not cmd:
|
||||||
return
|
return
|
||||||
log = logging.getLogger("hbc.cmd")
|
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)
|
log.info("exec: %s", cmd)
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
@@ -1180,6 +1189,11 @@ def main(argv=None):
|
|||||||
|
|
||||||
cfg = _load_config(args.configfile)
|
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:
|
try:
|
||||||
rc = asyncio.run(_async_main(args, cfg))
|
rc = asyncio.run(_async_main(args, cfg))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user