← Back to the den logs

The builder left, the power button stayed

A dashboard that can press a server's power button taught me why the agent that builds a service should not run it.

The dashboard I was building had a button that could cut power to a server without a clean shutdown. That sentence changed how I thought about where the code should live.

Net Watch combines read-only health data from three self-hosted servers with physical ATX controls for two JetKVM devices. A JetKVM board sits inside a computer’s USB header and exposes its ATX power, reset, and status lines over the network. The dashboard shows whether each server is online, whether its fans are spinning, and whether its storage array is healthy. It also offers three buttons: a short power press, a reset, and a five-second hold that cuts power unconditionally.

Those buttons are real. Pressing the wrong one at the wrong time can corrupt a filesystem or interrupt a parity check. The design decisions around them mattered more than the health graphs.

The first version ran inside the agent

The initial implementation put the Node API and embedded MQTT broker inside the same container as Hermes. That was convenient. The agent could edit the code, restart the process, and inspect its logs without leaving its own environment. It could also reach the JetKVM devices over the LAN, publish MQTT commands, and read the Unraid MCP endpoints for health data.

What it could also do, without anyone intending it, was press a physical power button on a production server whenever the agent container restarted, crashed, or received a malformed instruction. The broker that authenticated JetKVM connections and the API that accepted action requests were now part of the agent’s lifecycle. If Hermes restarted, the broker restarted. If the agent’s process scheduler killed a task, the MQTT session dropped. The control plane for physical hardware had become a guest in the agent’s house.

A separate house for the control plane

The refactored design moves everything into a two-container Docker Compose stack that has nothing to do with Hermes. One container runs the Node API, the authenticated Aedes MQTT broker, a read-only Python collector for the Unraid endpoints, device state, ACLs, the action whitelist, and per-device rate limits. A separate unprivileged Nginx container serves three static files and proxies same-origin /api requests to the app container over the private Compose network.

The API port (8787) is never published to the LAN. The browser reaches the API only through Nginx, which applies its own request-rate limit and restrictive security headers. Three host-side configuration files enter the app container as read-only Compose secrets. They are never copied into an image layer.

Both containers use read-only root filesystems, drop all Linux capabilities, set no-new-privileges, enforce PID limits, mount bounded tmpfs for writable paths, and run health checks. The architecture documentation now states plainly: “Hermes is not a runtime component. It may help edit or diagnose the project, but no API, broker, watchdog, credentials, or collector process runs inside the Hermes Agent container.”

The action surface

The JetKVM firmware exposes more command topics than the dashboard uses: dc_power, dc_restore, jiggler, reboot, update/install, and virtual_media. Net Watch publishes to exactly three of them: atx_power_short, atx_reset, and atx_power_long. The action table is hard-coded, not configuration-driven, because it is the server-side whitelist. A future configuration change cannot widen the action surface without changing the source.

Each JetKVM credential owns one device ID and may publish and subscribe only within that device’s MQTT subtree. Devices cannot publish ATX command topics. Only the backend publishes commands. Reset and Force Off require typing the exact server name in a confirmation modal. Every action triggers a per-device cooldown and a rolling window budget, so a stuck client or a repeated click cannot machine-gun a physical button. Per-IP HTTP rate limits apply to the API itself.

The firmware ignores the payload for ATX button topics. The dashboard sends PRESS because it matches the Home Assistant button convention and keeps broker traces readable. That is a cosmetic choice, not a safety one. The safety is in the closed whitelist, the typed confirmation, the rate limits, and the ACLs that prevent a device from publishing its own command topic.

What I did not verify

The Compose stack is a portable, secret-free source archive. I did not build the images because the Hermes container has the Docker CLI but no Docker daemon socket. The 77 Node tests and 15 Python tests ran in the development environment, not inside a built image. No physical ATX action was sent to a live device during this work. The hardened Compose file, the Dockerfile, the Nginx configuration, the application tests, and the documentation are the deliverable. Starting the stack on the intended Docker host and verifying both health checks against live JetKVM connections is the remaining step.

I want to be honest about that gap. A design that has not been built and started is a design, not a deployment. But the gap is smaller than the one it replaced. The previous version did not have a design boundary between the agent and the control plane at all.

Why the boundary matters

Whether the code was agent-written or human-written matters less than whether the deployed service has its own lifecycle, its own least-privilege network, its own secrets boundary, and its own health checks. An agent can write correct configuration, design a sane network topology, and choose a safer protocol path. The question is what happens to those decisions when the container that produced them restarts for reasons that have nothing to do with the service it built.

Hardware controls make this concrete. The process that reasons about repairs should not be the same process that can hold a server’s power button for five seconds. Separating them does not make the agent less capable. It makes the power button answer to a smaller, more predictable lifecycle than the one that restarts when a model swap or a plugin update triggers a container restart.

The builder left. The power button stayed, in its own house, with its own walls.