🔄 Topic

Connecting Home Assistant to my M4 Mac’s power state started with fixing a broken wake toggle — a fix so narrow it added no new capability at all. Hours later, adding the ability to put the machine to sleep from the same app meant deliberately refusing to reuse that same easy path, and building an entirely separate, minimal, loopback-only bridge instead.


🎯 Goal

Give Home Assistant working wake and sleep control over the M4, and make sure the amount of trust Home Assistant holds grows exactly as much as each capability actually requires — no more.


🛠 What I Did

I treated wake and sleep as two separate trust decisions, even though they sound like the same feature.

Main areas covered:

  • found the existing Switch M4 Mac toggle automation was broken and ambiguous: switch.macbook_pro_m4 is a wake-on-LAN, assumed-state entity with no reliable state probe (the Mac uses DHCP), and the old automation just called a bare switch.toggle with no real power semantics
  • replaced it with an explicit script.wake_m4_mac whose only action is switch.turn_on on the existing switch entity — no sleep, no shutdown, no shell command, no SSH key, and no credential added to Home Assistant anywhere in the process
  • removed the legacy automation entirely rather than leaving it disabled-but-present, and kept dated backups of both YAML files before editing
  • explicitly recorded the boundary in the handoff at the time: don’t add a Home Assistant sleep button by copying the constrained M4 SSH key into the container — if sleep is wanted, build a separate, narrowly allowlisted bridge to the existing receiver and verify it independently
  • hours later, built exactly that: a small Ubuntu-hosted bridge service reachable only via 127.0.0.1, since Home Assistant’s container uses host networking and the Tailnet/LAN cannot reach it at all
  • the bridge invokes only the existing constrained /usr/local/bin/mac sleep command — no shell, no SSH key, no general command capability — the same discipline as the wake path, just extended to a second action
  • ran the service as a non-root user (juri), systemd-sandboxed, with its auth token and the Home Assistant secret root-owned at mode 0600, never tracked in version control or logged anywhere
  • verified deployed Python and unit files by hash against their committed source, and confirmed the live ExecStart pointed at the installed release path rather than the working checkout
  • tested the actual security boundary, not just the happy path: five targeted tests including mocked timeout/nonzero handling and fixed subprocess argv verification, a no-token request correctly returning 401, a malformed-body request correctly returning 400, a request from the Tailnet address correctly refused, loopback health succeeding, and a scan of the bridge’s own journal confirming its token never appeared in logs
  • deliberately did not send a real sleep command during verification, since the M4 was in active use at the time — leaving the first real invocation to come from an actual, safe moment rather than a test that could interrupt real work

🔗 Key Cybersecurity Connections

This is capability creep resisted in real time: the easy path, after wiring up wake, would have been to reuse the same automation framework and just add a sleep action alongside it — probably by giving Home Assistant the same SSH key already used elsewhere. Refusing that, and building a second, narrower, loopback-only bridge instead, kept each capability’s blast radius matched to what it actually needs, rather than letting the most powerful credential already available become the path of least resistance for every new feature.

Loopback-only binding is a strong, simple network boundary: no firewall rule to misconfigure, no port to accidentally expose beyond the host — the service is architecturally unreachable from the Tailnet or LAN because it never listens anywhere else. Combining that with a fixed, non-shell subprocess call (rather than a general command interface), a non-root systemd-sandboxed service user, and root-owned secrets is layered defense: even a full compromise of the Home Assistant container gets, at most, the exact one action this bridge exposes, from a network position that can already reach it and nothing more.


🔍 Investigation Questions

  • Does a new capability reuse an existing broad credential because it’s convenient, or does it get its own scoped path matched to what it actually needs?
  • Is a bridge or service that should only be reachable locally actually bound to loopback, or does it listen more broadly than intended?
  • Does the service invoke a fixed, constrained command, or does it expose anything resembling general shell/SSH access?
  • Were negative-path tests run — no token, bad body, wrong network origin — or only the happy path where everything is provided correctly?
  • Was a service’s own log output checked for accidental secret leakage?

🚨 Detection Opportunities

Checks for a new home-automation or IoT-adjacent capability:

  • a new automation action reusing a broad existing credential instead of being scoped to its own minimal path
  • a “local-only” service that isn’t actually bound to loopback and is reachable from the wider network
  • a bridge or receiver exposing general command execution instead of a fixed, reviewed action
  • missing negative-path tests (no-auth, malformed input, wrong origin) for a network-facing control
  • a service’s logs never checked for whether they leak the very secret meant to protect it

Example:

project=homeassistant-m4-bridge
signal=new_capability_reused_existing_broad_credential
risk_area=capability_creep_via_convenience
triage=confirm_new_action_has_its_own_scoped_minimal_path

🧭 MITRE ATT&CK Techniques

No direct mapping claimed. This is capability scoping and network-boundary design for a home-automation integration, not an adversary technique.


🗺 Visual Investigation Diagram

Broken, ambiguous wake toggle found
    ↓
Replaced with explicit script.wake_m4_mac — turn_on only
    ↓
Boundary recorded: sleep needs its OWN narrow bridge, not the same key
    ↓
Hours later: loopback-only Ubuntu bridge built for sleep
    ↓
Fixed constrained command, non-root, systemd-sandboxed, root-owned secrets
    ↓
Verified: 401 no-token, 400 bad body, Tailnet request refused
    ↓
Journal scanned — token never leaked
    ↓
Real sleep command deliberately deferred to a safe, real moment

⚠ Challenges

The harder discipline wasn’t building the sleep bridge — it was resisting the shortcut of reusing the wake path’s existing credential when it would have worked and saved real time. Writing down the boundary explicitly, hours before building the sleep feature, is what made it easy to follow through on later instead of rationalizing the shortcut in the moment.


📚 What I Learned

I learned that capability creep often isn’t a dramatic decision — it’s the small, reasonable-sounding choice to reuse an existing credential because building a new, narrower path feels like unnecessary overhead. Writing the boundary down before it’s tested by a real feature request is what keeps that reasonable-sounding shortcut from happening by default.


➡ Next Steps

  • Apply the same loopback-only-plus-fixed-command pattern to any future Home Assistant integration
  • Trigger the real sleep path once, from Home Assistant, during a genuinely safe window, to close the “deliberate residual” left open in this round
  • Periodically re-scan the bridge’s logs for token leakage as the service evolves
  • Review other integrations for credentials that could be scoped down the same way

🧠 Reflection

The two features look almost identical from the outside — “let Home Assistant control my Mac’s power state” — but the trust decision behind each was completely different, and treating them as different is exactly what kept the blast radius of each one small.


🧩 Lessons Learned

What worked

Explicitly recording the sleep-needs-its-own-bridge boundary before building it, then following through with a loopback-only, fixed-command, non-root service.

What broke

Nothing broke — this is proactive capability scoping, catching the tempting shortcut before it happened rather than after.

Why it mattered

Reusing an existing broad credential for a new capability is the path of least resistance, and it’s exactly how a home-automation integration ends up holding far more authority than any single feature actually needs.

Fix / takeaway

Give each new capability its own scoped path matched to what it needs, write the boundary down before it’s tested by real feature pressure, and verify with negative-path tests, not just the happy path.


📈 Skill Progression Context

This supports my cybersecurity progression because resisting capability creep, designing network boundaries around loopback-only reachability, and testing negative paths deliberately are core least-privilege and defense-in-depth disciplines applied to a real home-automation integration.


😄 TL;DR

Home Assistant can wake my Mac now with one boolean and zero new authority — and when I wanted sleep too, I built it its own narrow, loopback-only bridge instead of reaching for the easy shortcut.