-
Notifications
You must be signed in to change notification settings - Fork 0
Exposing it safely
This is the security review and go-live runbook for taking Breeze Core off the LAN. The mechanics of setup live in docs/INSTALL.md and docs/REVERSE-PROXY.md; this document is the why, the threat model, and the checklist.
Contents: 1. Findings & mitigations · 2. App settings for public exposure · 3. fail2ban · 4. systemd sandbox & egress · 5. Auth model & residual risk · 6. Go-live checklist · 7. Hardening without systemd
Status legend: ✅ enforced by the app · ⚙️ you configure at deploy time · 💡 optional/further
A VPN (WireGuard/Tailscale) sidesteps public exposure entirely and is worth a thought — but if you self-host you know the trade-offs. The rest of this document assumes you've chosen a deliberate public HTTPS endpoint.
| # | Concern | Severity | Mitigation | Status |
|---|---|---|---|---|
| 1 | Cleartext credentials/control in transit | High | TLS at the reverse proxy (REVERSE-PROXY.md) + HSTS | ✅ header / ⚙️ TLS |
| 2 | A single shared secret on every request | High | Device-pairing: API key → enrollment only; per-device, revocable, hashed, expiring tokens | ✅ |
| 3 | Brute-forcing the pairing code / credentials | High | In-app rate limits on /api/auth/* + proxy limit_req + fail2ban (§3) |
✅ app / ⚙️ proxy |
| 4 | Framework docs/schema exposed | High |
/docs, /redoc, /openapi.json disabled unless AC_DOCS=1
|
✅ |
| 5 | Host-header spoofing | Med |
TrustedHostMiddleware via AC_TRUSTED_HOSTS
|
✅ code / ⚙️ set it |
| 6 | Missing response-security headers | Med | CSP / HSTS / nosniff / frame-deny middleware | ✅ |
| 7 | Unbounded control inputs reaching the device | Med |
ControlRequest bounds (temp 16–30/0.5°, known fan speeds) → 422
|
✅ |
| 8 | X-Forwarded-For spoofing → fake "LAN" client | High | Proxy overwrites XFF with the real peer ($remote_addr / %{REMOTE_ADDR}s); admin check reads it |
⚙️ (REVERSE-PROXY.md) |
| 9 | Token/DoS via device round-trips | Med | Proxy rate-limit + timeouts + small client_max_body_size
|
⚙️ |
| 10 | Compromised process reaching the internet | Low | systemd IPAddressAllow LAN+loopback / IPAddressDeny=any
|
⚙️ (INSTALL §5) |
| 11 | Secrets at rest | Low |
config.json mode 640 (owner rw, group r — for admin CLIs), devices.json mode 600; tokens stored hashed only |
✅ |
Items marked ✅ are on by default in the app. ⚙️ items are the deploy-time work in the two guides.
New in 2.4.0 — kept safe by design:
GET /api/healthis the one unauthenticated route; it returns only{"status":"ok"}(no version, no counts) so it leaks nothing to anonymous callers.GET /api/version(which does report the build + feature list) sits behind the API key, same reasoning as keeping/docsoff. Response compression (brotli/gzip) is on by default — safe because responses carry no secrets (the config view is sanitized; tokens are never returned), so BREACH doesn't apply; setAC_COMPRESSION=0if your proxy compresses instead.
Set in the systemd unit (details + proxy configs in REVERSE-PROXY.md):
Environment=AC_BEHIND_PROXY=1 # trust X-Forwarded-For from the local proxy
Environment=AC_TRUSTED_HOSTS=breeze.example.com,127.0.0.1,localhost
Environment=AC_ENROLL_LAN_ONLY=1 # approvals only from a private/LAN address (default)
Environment=AC_TOKEN_TTL_DAYS=90 # device tokens expire; lower = tighter
# AC_MIN_AUTH_VERSION=2 # once clients are on v2, refuse legacy v1 (426)
# AC_AUTH_SKEW_SECONDS=60 # v2 timestamp window; wider = larger replay window
# leave AC_DOCS unset so docs stay disabled
ExecStart=… uvicorn meow_ac.app:app --host 127.0.0.1 --port 8420 --proxy-headers --forwarded-allow-ips 127.0.0.1The load-bearing interaction: with
AC_ENROLL_LAN_ONLY=1, approving a pairing requires the real client IP to be private. Behind a proxy that IP comes fromX-Forwarded-For, so (a) uvicorn must trust it only from the proxy (--forwarded-allow-ips), and (b) the proxy must overwrite XFF with the real peer, never append — otherwise an outsider setsX-Forwarded-For: 192.168.x.xand walks past the check. Both proxy configs in REVERSE-PROXY.md do this correctly.
The app logs rejections (with reason codes — see §3.1) and the proxy logs 4xx; fail2ban bans IPs that pile them up. Point the jails at your vhost's dedicated access log (e.g. give the Breeze vhost its own access_log/CustomLog). Two jails: a general one, and a tripwire on admin endpoints.
These jails reduce noise; they are not the security boundary — the API key plus the per-device credential are, and neither is brute-forceable in practice. Tune them accordingly. Two mistakes are easy to make and were both made here:
- Matching too many status codes. A filter that counts
400|404|405|422|429treats normal client behaviour as an attack: the app feature-detects endpoints (404on an older server), the server validates input (422for an out-of-range setpoint), the in-app diagnostics deliberately probes unknown-unit and no-key paths, and429is your own rate limiter. Count401/403only.- Forgetting that clients share an address. Everything arriving through NAT — including your own LAN devices reaching the public hostname via router hairpin — presents as one IP. Ban it and everyone on that connection is locked out at once. Put your WAN address in
ignoreipalongside the LAN ranges.A too-tight jail is not "more secure": it's an outage generator that hits legitimate users first, since attackers don't care about being banned.
/etc/fail2ban/filter.d/breeze-core.conf:
[Definition]
# NOTE: fail2ban strips the [timestamp] but LEAVES the empty '[]' in the
# line it matches. A failregex containing the date matches NOTHING — a
# silent failure. Verify any change with: fail2ban-regex <log> <filter>
failregex = ^<HOST> \S+ \S+ \[\] "[^"]*" (?:401|403)
ignoreregex =/etc/fail2ban/filter.d/breeze-core-tripwire.conf:
[Definition]
failregex = ^<HOST> \S+ \S+ \[\] "(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) /api/auth/(?:enroll/approve|devices)[^"]*" 403
ignoreregex =/etc/fail2ban/jail.d/breeze-core.local (set logpath, and put your LAN ranges and WAN address in ignoreip):
[breeze-core]
enabled = true
port = http,https
filter = breeze-core
logpath = /var/log/nginx/breeze.access.log
# 20 auth failures in 10 min: a phone with a stale credential makes a handful
# and backs off; a script guessing keys makes hundreds.
maxretry = 20
findtime = 600
bantime = 1h
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1d # not 5w — the address may be shared
ignoreip = 127.0.0.1/8 ::1 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 <your.wan.ip>
[breeze-core-tripwire]
enabled = true
port = http,https
filter = breeze-core-tripwire
logpath = /var/log/nginx/breeze.access.log
maxretry = 3 # not 1 — one accidental off-LAN 403 isn't an attack
findtime = 3600
bantime = 604800
bantime.increment = true
bantime.maxtime = 8w
ignoreip = 127.0.0.1/8 ::1 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 <your.wan.ip>sudo systemctl restart fail2ban
sudo fail2ban-client status breeze-coreExample filter/jail files ship in deploy/fail2ban/. ignoreip exempts your LAN so you can't lock yourself out; outsiders get no grace.
Second gotcha: the web UI/app must not spray 401s or a legitimate remote user with an expired token would ban themselves — and everyone sharing their NAT address. Breeze's app backs off to the offline state on an auth failure instead of continuing its 5 s poll, and pauses polling while re-pairing and while backgrounded — keep that behaviour if you fork the client.
Bans and lockouts are unguessable without knowing why a request was refused. The server logs every auth failure to the meow-ac.auth logger with a reason code, the client IP, and an 8-character key-id hint (never a secret):
sudo journalctl -u breeze-core | grep "auth failed"
# auth failed: reason=clock_skew status=401 ip=203.0.113.7 key=1a2b3c4d GET /api/units server_time=… client_time=…| reason | meaning | client should |
|---|---|---|
clock_skew |
the device's clock is outside AC_AUTH_SKEW_SECONDS (default 60) |
re-sign using the returned server_time — not re-pair |
replay |
that nonce was already spent (a retried request) | retry with a fresh nonce |
incomplete_signature |
missing X-Breeze-* headers |
fix the request |
unknown_key / expired
|
credential revoked or lapsed | re-enroll |
bad_signature |
signature didn't verify against the stored public key | re-enroll |
bad_api_key / no_credential
|
wrong or absent X-API-Key / device credential |
fix config or enroll |
The first three are returned with "retryable": true. A client must never delete its credential over a retryable failure — re-pairing needs an admin on the LAN, so doing that to a user who is away from home strands them. Enrolments and revocations are logged too (device enrolled: / device revoked:), which is what you want when someone reports "it logged me out yesterday". Set the logger to DEBUG for a successful-auth trace during an investigation.
The unit in INSTALL.md §5 already runs unprivileged with ProtectSystem=strict, a syscall filter, empty CapabilityBoundingSet, and — the high-value one — IPAddressAllow limited to loopback + your LAN with IPAddressDeny=any. That means even a fully compromised process cannot exfiltrate or attack anything beyond your AC units. Confirm with systemd-analyze security breeze-core (aim for a low score).
MemoryDenyWriteExecute=true is tempting but breaks some CPython builds — test before enabling.
Not on systemd? None of this section applies as-is — OpenRC/runit/s6/supervisord, the BSDs, and macOS have no equivalent of these directives. The egress lockdown especially is the highest-value protection, so don't skip it: §7 Hardening without systemd shows how to recover the important parts (privilege drop, filesystem confinement, and an egress firewall) on those platforms.
-
Enrollment key (
api_key): authorizes only starting enrollment — leaking it doesn't hand over the units. - Device pairing: single-use ~60 s code, hashed and rate-limited, approved by an admin on the LAN.
-
Per-device credential (required together with the API key for all control), named, revocable, expiring, in two profiles:
-
v2 — Ed25519 request signing (recommended). The server stores only the public key; each request is signed over method+path+timestamp+nonce+
SHA3-512(body). The secret never rides the wire, requests can't be replayed (nonce + ±60 s skew) or tampered, and adevices.jsonleak exposes nothing forgeable — the strongest posture. - v1 — bearer token. 256-bit, stored hashed (SHA-256). Still supported; the secret is sent on every request (TLS-protected).
-
v2 — Ed25519 request signing (recommended). The server stores only the public key; each request is signed over method+path+timestamp+nonce+
-
Rollout clamp:
AC_MIN_AUTH_VERSION(default1) accepts both and nudges v1 clients (X-Breeze-Upgradeheader). A new device can still enrol as v1 at the default floor — required, because the web UI and the CLIs are v1-only — so v1 back-compat is not an escalation (enrolling v1 needs the same API key + LAN approval as v2, and a v2 device can never be downgraded to a bearer). Set the floor to2once your clients are updated: v1 is then refused both at control (426) and at enrollment (no new v1 credential is minted), so the server is effectively v2-only. Enrolled v1 devices upgrade in place viaPOST /api/auth/upgrade(no re-pairing). Migrate the web UI + CLIs before clamping. -
Revocation:
ac-approve.zsh revoke <token_id>(orDELETE /api/auth/devices/{id}) kills one device instantly.
Residual, by design: a stolen v1 bearer token is valid until it expires or is revoked — prefer v2 (nothing reusable is transmitted or stored), lower AC_TOKEN_TTL_DAYS, and revoke lost devices. The Breeze app keeps its v2 private key in Keystore-backed encrypted storage with allowBackup=false. Note the bundled web UI and CLIs are still v1 — migrate them before raising the clamp to 2.
- VPN considered and consciously rejected (see the note at the top)
- App bound to
127.0.0.1,--proxy-headers --forwarded-allow-ips 127.0.0.1 - Reverse proxy with valid TLS (Let's Encrypt), HTTP→HTTPS redirect, server tokens off
- Proxy overwrites
X-Forwarded-Forwith the real peer (not append) - Rate limiting active on
/and/api/auth/ - Admin endpoints (
enroll/approve,devices) gated to the LAN at the proxy and by the app -
AC_BEHIND_PROXY=1,AC_TRUSTED_HOSTSset,AC_ENROLL_LAN_ONLY=1,AC_DOCSunset (verifycurl …/openapi.json→ 404) - Port 8420 not reachable from the LAN/internet (proxy talks to it on loopback)
- Egress locked to LAN + loopback — systemd
IPAddressDeny(systemd-analyze securityreviewed) or, off systemd, the user-matched firewall rule from §7 - Service runs as the unprivileged
breezeuser (not root) — verifyps -o user= -p <pid> - fail2ban jails active; filters verified with
fail2ban-regex - Enrolled a device end-to-end over the public URL, then revoked one and confirmed it stops working
-
config.jsonis mode 640 (group-readable for admins in the service group) anddevices.jsonis mode 600; both backed up off-box; the state dir is 750 so neither is world-readable - Consider
AC_TOKEN_TTL_DAYSlower than the 90-day default
Bots will find the hostname (it's published in Certificate Transparency logs) — that's what the jails are for. Check fail2ban-client status breeze-core-tripwire after a week for your first catches.
The §4 sandbox is systemd-only. On OpenRC / runit / s6 / supervisord / SysV, the BSDs, or macOS you install Breeze Core the same way (see INSTALL.md) but must recover the protections yourself. In descending order of importance:
| systemd directive (§4/§5) | What it buys | Non-systemd equivalent |
|---|---|---|
IPAddressAllow/IPAddressDeny
|
Compromised process can't phone home | Firewall egress rule matching the service user (below) — the single most important one |
User= / CapabilityBoundingSet= / NoNewPrivileges
|
No root, no privilege escalation | Run as the unprivileged breeze account (all the init templates do); never root; no setuid |
ProtectSystem=strict / ReadWritePaths / ProtectHome
|
Read-only FS except state dir | Tight ownership + a container read-only rootfs or a FreeBSD jail / chroot |
SystemCallFilter / RestrictAddressFamilies
|
Kernel-attack-surface reduction | No portable equivalent — a container (default seccomp profile) is the practical way to get it |
Restart=on-failure |
Survives crashes | Your supervisor's respawn (supervise-daemon, runit, autorestart=true, daemon -r) |
Restrict the service user's outbound traffic to loopback + your LAN, so even a fully compromised process can only reach your AC units. Match on the breeze UID so the rest of the box is unaffected.
Linux — nftables (/etc/nftables.conf, or your init's firewall step):
table inet breeze {
chain output {
type filter hook output priority 0; policy accept;
meta skuid "breeze" oif "lo" accept
meta skuid "breeze" ip daddr 192.168.0.0/16 accept # your LAN CIDR
meta skuid "breeze" ip6 daddr ::1 accept
meta skuid "breeze" drop # nothing else leaves
}
}
Linux — iptables (older boxes):
iptables -A OUTPUT -m owner --uid-owner breeze -o lo -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner breeze -d 192.168.0.0/16 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner breeze -j REJECTFreeBSD/macOS — pf (/etc/pf.conf; discovery needs UDP too):
lan = "192.168.0.0/16"
pass out on lo0 user breeze
pass out proto { tcp udp } from any to $lan user breeze
block out user breeze # default-deny the rest
Verify: as the breeze user, an outbound connection to a public address should fail while a unit on the LAN still responds.
-
Unprivileged user, always. Every template in
deploy/init/drops tobreeze(command_user/chpst -u/s6-setuidgid/user=/--chuid/daemon -u). Confirm withps -o user= -p <pid>. -
Least-writable state. Only
/etc/breeze-core(or your chosen state dir) needs write access, owned bybreeze, mode750;config.jsonmode640(group-readable so admins you add to thebreezegroup can run the CLIs),devices.jsonmode600. The 750 dir means "group" is only the service account + admins you add, never other local users. Everything else the process touches should be read-only to it. -
Strongest containment: run it in a container with a read-only rootfs (
docker run --read-only --tmpfs /tmp --cap-drop ALL, only/etc/breeze-corewritable — see DOCKER.md) or a FreeBSD jail. Either recovers most of what §4 gives you on systemd, on any host.
TLS + reverse proxy (§2, REVERSE-PROXY.md), the XFF-overwrite rule, fail2ban (§3), the two-credential auth model (§5), and the app-enforced items in §1 are all init-independent. Only §4 needed translating.
If you're doing public exposure without systemd, seriously consider the container path: it hands you privilege drop, a read-only rootfs, a seccomp profile, and network scoping in one place, and the same image runs everywhere.
Windows is its own non-systemd case with a complete runbook in docs/WINDOWS.md; the mapping to this document:
-
Privilege drop — the
BreezeCoreservice runs asLOCAL SERVICE; state in%ProgramData%\breeze-corehas inheritance stripped and is granted only to SYSTEM/Administrators/LocalService (thechmod 600/750analogue). -
TLS + headers + XFF overwrite (§2) — the bundled Caddy wizard does auto-HTTPS + HSTS/headers. Caddy needs no explicit XFF rule: with no
trusted_proxiesconfigured it drops a client-sentX-Forwarded-Forand substitutes the real peer (verified), which is the overwrite. Never add public ranges totrusted_proxies. -
fail2ban (§3) — the
BreezeTripwirewatcher tails Caddy's access log and bans IPs via Windows Firewall (general 4xx/5xx flood + instant admin-403tripwire; LAN never banned; bans expire). It must manage the firewall, so it — and only it — runs as LocalSystem. -
Egress lockdown (§4 item 10) — the weak spot. Windows Firewall can't cleanly default-deny per-app egress;
install-service.ps1 -LockEgressadds a best-effort "block this binary toInternet" rule, but classification is profile-dependent. For strict egress, run the container image or a systemd Linux host instead. - Syscall filter / seccomp — no equivalent; use the container image if you need it.
Breeze Core · Breeze for Android · Packages · AGPL-3.0
Start here
Install it
Use it
Reference
Run it safely
Develop and port