Analysis of AqualinkD's current network security posture and evaluation of approaches to add authentication.
AqualinkD has zero application-level authentication. There is no login, no API tokens, no session management — anyone who can reach the HTTP port can control the pool equipment.
Every HTTP request hits ev_handler (net_services.c:1967), which either upgrades to a WebSocket or dispatches to action_web_request. Neither path checks credentials:
// Real code from ev_handler (net_services.c:1969-1980)
case MG_EV_HTTP_MSG:
http_msg = (struct mg_http_message *)ev_data;
if ( mg_http_get_header(http_msg, "Sec-WebSocket-Key") != NULL) {
LOG(NET_LOG,LOG_DEBUG, "Enable websockets\n");
mg_ws_upgrade(nc, http_msg, NULL); // No auth check
break;
}
action_web_request(nc, http_msg); // No auth check
break;WebSocket messages are likewise processed without any credential validation:
// Real code from ev_handler (net_services.c:1990-1995)
case MG_EV_WS_MSG:
ws_msg = (struct mg_ws_message *)ev_data;
action_websocket_request(nc, ws_msg); // No auth check
break;HTTP requests route into action_web_request (net_services.c:1623), which either serves a static file or calls action_URI. No auth check before either path:
// Real code from action_web_request (net_services.c:1642-1670)
if (strncmp(http_msg->uri.buf, "/api", 4) != 0) {
serve_file(nc, http_msg); // Static files: no auth
return;
}
// ...
switch (action_URI(NET_API, &buf[5], len - 5, value, false, &msg)) // API: no authThe default configuration binds to all interfaces on port 80 over plain HTTP:
# release/aqualinkd.conf:8
listen_address=http://0.0.0.0:80
The server starts with mg_http_listen(mgr, _aqconfig_.listen_address, ev_handler, mgr) at net_services.c:2253. HTTPS is available only when compiled with MG_TLS > 0 and the user manually provisions certificates (net_services.c:2020-2040).
The struct aqconfig (config.h:59-134) has no authentication fields. The only credential-related config is for MQTT broker connectivity:
// Real fields from struct aqconfig (config.h:88-89)
char *mqtt_user;
char *mqtt_passwd;These authenticate the daemon to an external MQTT broker — they don't protect AqualinkD's own endpoints.
None of the web files (web/index.html, web/aqmanager.html, web/simulator.html) contain a login page, password prompt, or session management JavaScript. Visiting the page gives immediate full control.
| Problem | Impact | Evidence |
|---|---|---|
| No auth on HTTP/WS | Anyone on the network can control pool equipment | ev_handler (net_services.c:1969-1995) — zero credential checks |
| Default bind 0.0.0.0:80 | Exposed to entire LAN (and WAN if port-forwarded) | release/aqualinkd.conf:8 |
| No HTTPS by default | Credentials would be sent in cleartext even if auth existed | MG_TLS conditional at net_services.c:2020 |
| No CSRF/CORS protection | Malicious website could silently send commands to the pool controller | No Origin / Referer / CSRF token checks anywhere |
| MQTT commands unauthenticated | Any MQTT client on the broker can send /set commands |
action_mqtt_message (net_services.c:1493-1549) |
All endpoints pass through action_URI (net_services.c:997). The function accepts requests from three sources (request_source enum in aqualink.h): NET_API (HTTP), NET_WS (WebSocket), and NET_MQTT.
| URI | Return Type | Source | Line | Notes |
|---|---|---|---|---|
/api/devices |
uDevices |
All | 1040 | Full device list with state |
/api/status |
uStatus |
All | 1042 | System status, temperatures |
/api/homebridge |
uHomebridge |
All | 1044 | HomeKit integration format |
/api/dynamicconfig |
uDynamicconf |
All | 1046 | Dynamic config data |
/api/schedules |
uSchedules |
All | 1050 | Schedule data |
/api/config |
uConfig |
All | 1058 | Full daemon configuration |
/api/config/download |
uConfigDownload |
All | 1052 | Config file download |
/api/logfile/download |
uLogDownload |
All | 1112 | SystemD journal logs |
| URI | Risk | Source | Line | What it does |
|---|---|---|---|---|
/api/{device}/set |
HIGH | All | 1419-1459 | Turns pool equipment on/off |
/api/{device}/setpoint/set |
HIGH | All | 1226-1257 | Sets heater/chiller/freeze temperatures |
/api/{device}/setpoint/increment |
MEDIUM | All | 1204-1225 | Increments setpoints |
/api/SWG/Percent/set |
HIGH | All | 1258-1268 | Sets Salt Water Generator percentage |
/api/SWG/Boost/set |
HIGH | All | 1269-1276 | Activates SWG boost mode |
/api/{Light}/color/set |
MEDIUM | All | 1277-1298 | Controls light mode/color |
/api/{Light}/brightness/set |
MEDIUM | All | 1299-1316 | Controls light brightness |
/api/Pump_{N}/RPM/set |
HIGH | All | 1317-1402 | Sets pump speed |
/api/CHEM/ORP/set |
MEDIUM | All | 1403-1417 | Sets chemical feeder levels |
/api/{device}/timer/set |
MEDIUM | All | 1419-1459 | Activates equipment timer |
/api/set_date_time |
MEDIUM | All | 1186-1189 | Sets panel date/time |
/api/debug/{action} |
MEDIUM | All | 1169-1183 | Debug mode control |
These are restricted to from == NET_WS, but that only means the request must come over a WebSocket connection — no credentials are required.
| URI | Risk | Line | What it does |
|---|---|---|---|
/api/config/set |
HIGH | 1054 | Modifies daemon configuration |
/api/webconfig/set |
HIGH | 1056 | Modifies web UI configuration |
/api/schedules/set |
MEDIUM | 1048 | Saves schedules |
/api/restart |
CRITICAL | 1117-1120 | Restarts daemon via raise(SIGRESTART) |
/api/installrelease/{ver} |
CRITICAL | 1121-1131 | Installs software update, triggers raise(SIGRUPGRADE) |
/api/simulator/{type} |
MEDIUM | 1060-1072 | Starts equipment simulator |
/api/setloglevel |
MEDIUM | 1079-1081 | Changes log level |
/api/addlogmask |
MEDIUM | 1082-1100 | Adds debug logging filter |
/api/removelogmask |
MEDIUM | 1101-1110 | Removes debug logging filter |
/api/seriallogger |
MEDIUM | 1144-1164 | Starts RS485 serial logging |
MQTT messages ending in /set or /increment are processed by action_mqtt_message (net_services.c:1493) and forwarded directly to action_URI(NET_MQTT, ...). The mqtt_user/mqtt_passwd fields only authenticate the daemon to the broker — they do not restrict which MQTT clients can publish commands.
Mongoose (already vendored at source/mongoose.c / source/mongoose.h) includes building blocks for authentication, but no complete auth framework.
// mongoose.h:1763
void mg_http_creds(struct mg_http_message *, char *, size_t, char *, size_t);mg_http_creds parses Authorization: Basic ... and Authorization: Bearer ... headers, extracting username+password or token into caller-supplied buffers. This handles the HTTP-layer parsing — we don't need to write header parsing ourselves.
// mongoose.h:1510-1512 — MD5
void mg_md5_init(mg_md5_ctx *c);
void mg_md5_update(mg_md5_ctx *c, const unsigned char *data, size_t len);
void mg_md5_final(mg_md5_ctx *c, unsigned char[16]);
// mongoose.h:1523-1525 — SHA-1
void mg_sha1_init(mg_sha1_ctx *);
void mg_sha1_update(mg_sha1_ctx *, const unsigned char *data, size_t len);
void mg_sha1_final(unsigned char digest[20], mg_sha1_ctx *);
// mongoose.h:1544-1547 — SHA-256
void mg_sha256_init(mg_sha256_ctx *);
void mg_sha256_update(mg_sha256_ctx *, const unsigned char *data, size_t len);
void mg_sha256_final(unsigned char digest[32], mg_sha256_ctx *);
void mg_sha256(uint8_t dst[32], uint8_t *data, size_t datasz);SHA-256 is sufficient for token hashing (SHA256(salt || secret)). No need to vendor an external crypto library.
mg_random() provides random bytes, used internally by Mongoose for WebSocket keys and TLS. Suitable for token generation.
Already present behind #if MG_TLS > 0 (net_services.c:2020-2040). Supports server certificates and optional mutual TLS (client certificates via ca.pem). This is the transport-layer piece — application-level auth is what's missing.
- No session management (cookies, session store, TTL)
- No token lifecycle (generation, revocation, rotation)
- No role/scope enforcement
- No audit logging framework
- No login UI or auth middleware pattern
These must be built in the source/auth.c module proposed below.
- Backward compatible by default —
auth_mode=offpreserves current behavior. Existing installs upgrade without breakage. - Opt-in with progressive modes —
off→audit→write→all. Each mode is a superset of the previous. - Separate machine auth from browser auth — API tokens for automation; session cookies for browsers.
- No plaintext secrets on disk — store
SHA256(salt || secret), never the secret itself. - Route payloads unchanged — auth is enforcement at the connection/request level, not changes to JSON formats.
| Mode | Read endpoints | Write endpoints | Admin endpoints | Use case |
|---|---|---|---|---|
off |
Open | Open | Open | Current behavior, default for upgrades |
audit |
Open (logged) | Open (logged) | Open (logged) | Dry-run: identify all clients before enforcing |
write |
Open | Auth required | Auth required | Protect mutations, allow dashboards |
all |
Auth required | Auth required | Auth required | Full lockdown |
| Method | Mechanism | Best for |
|---|---|---|
basic |
HTTP Basic Auth (Authorization: Basic ...) |
Browser users (native prompt) |
token |
Bearer tokens (Authorization: Bearer aqd_...) |
Automation, Home Assistant, scripts |
either |
Accept both | Migration period (recommended default when auth enabled) |
Mongoose's mg_http_creds (mongoose.h:1763) handles parsing both formats — we just need to verify credentials after extraction.
Format: aqd_<id>.<secret> — prefix makes tokens greppable in configs/logs, dot separates lookup key from secret.
Server storage (dedicated file, e.g., /etc/aqualinkd.tokens, mode 0600):
| Field | Purpose |
|---|---|
token_id |
Lookup key (non-secret) |
name |
Human-readable label |
scope |
read, write, or admin |
enabled |
Quick revocation without deletion |
created_at |
Audit trail |
salt |
Per-token random salt |
hash |
SHA256(salt || secret) via mg_sha256 |
Verification flow: Parse token_id + secret → lookup record by token_id → recompute SHA256(salt || secret) → constant-time compare → enforce scope.
Map the endpoint inventory from Section 2 to minimum required scope:
| Scope | Endpoints |
|---|---|
read |
devices, status, homebridge, dynamicconfig, schedules, config, downloads |
write |
All device /set and /setpoint/set endpoints, schedules/set, light/pump controls |
admin |
config/set, webconfig/set, restart, installrelease, simulator, log controls, seriallogger |
- User authenticates once (Basic prompt or token entry).
- Server creates short-lived session, returns
HttpOnly; SameSite=Laxcookie. - Subsequent requests (HTTP and WebSocket upgrade) use the cookie.
- Session expires after configurable TTL (default: 1 hour).
WebSocket auth: validate session cookie during the Sec-WebSocket-Key upgrade check (net_services.c:1973). If no valid cookie, reject the upgrade with 401.
The enforcement point is the ev_handler function (net_services.c:1956). A single auth check function inserted before dispatching would cover all three paths:
ev_handler
│
┌───────┼───────┐
│ │ │
MG_EV_HTTP MG_EV_WS MG_EV_WS_MSG
│ │ │
▼ ▼ ▼
◆ AUTH CHECK ◆ AUTH CHECK ◆ AUTH CHECK
│ │ │
▼ ▼ ▼
action_web mg_ws action_ws
_request _upgrade _request
For HTTP requests, the check happens in action_web_request before calling action_URI. For WebSocket upgrade, the check happens before mg_ws_upgrade. For WebSocket messages, we use a per-connection auth flag set during the upgrade.
| File | Responsibility |
|---|---|
source/auth.h |
Auth mode/method/scope enums, function declarations |
source/auth.c |
Token file I/O, hash/verify, scope check, session management, mg_http_creds wrapper |
| File | Changes |
|---|---|
source/config.h |
Add auth fields to struct aqconfig and config name defines |
source/config.c |
Register/parse/write new config options, CFG_PASSWD_MASK for secrets |
source/net_services.c |
Auth checks in ev_handler / action_web_request / WebSocket upgrade; audit logging |
source/mongoose-addition.h |
Auth flag in per-connection data (if needed for WS session tracking) |
Makefile |
Add auth.o to build |
# Proposed additions to aqualinkd.conf
network_auth_mode=off|audit|write|all
network_auth_method=basic|token|either
network_auth_user=<username> # For basic auth
network_auth_passwd=<hashed> # Stored hashed, displayed masked
network_auth_token_file=/etc/aqualinkd.tokens
network_auth_session_ttl=3600
The CFG_PASSWD_MASK flag (config.h:217) already exists for masking sensitive values in the web UI — reuse it for network_auth_passwd.
Phase 1: Foundation
- Add config schema and auth mode plumbing (config.h, config.c)
- Implement HTTP Basic auth enforcement in
action_web_request - Implement
auditandwritemodes - No UI changes required — browsers show native Basic Auth prompt
Phase 2: Token engine
- Add
source/auth.cwith token file storage, SHA-256 hashing, verification - Token lifecycle: create, list (masked), revoke, rotate, disable/enable
- Enable
tokenandeithermethods
Phase 3: Browser sessions + WebSocket auth
- Session cookie creation after successful Basic/token auth
- WebSocket upgrade auth check using session cookie
- Per-connection auth flag for WS message enforcement
- Optional login page in web UI
Phase 4: Hardening
- Startup warnings when auth enabled over plain HTTP
- Startup warnings when bound to non-loopback without auth
- Rate limiting on auth failures
- Stricter defaults for new installs (potentially
writemode)
| Risk | Mitigation |
|---|---|
| Existing installs break on upgrade | Default auth_mode=off — auth is strictly opt-in |
| Locked out with no recovery | Local root-only recovery: CLI command to create admin token or reset to off |
| Performance regression from auth checks | Token lookup is O(n) over a small set (<100 tokens); SHA-256 hash is <1μs on a Pi. Negligible |
| MQTT commands bypass auth | Document clearly: MQTT auth is the broker's responsibility. AqualinkD auth covers HTTP/WS only. Consider a future mqtt_require_auth flag |
| Basic auth over plain HTTP | Emit startup warning. Document HTTPS recommendation. Don't block — user's choice |
| Breaking Home Assistant / automation integrations | write mode leaves reads open. either method accepts both Basic and Bearer. Migration cookbook in docs |
- MQTT message-level authentication — this would require a protocol change in how MQTT messages are structured
- CSRF protection — auth alone doesn't prevent CSRF for cookie-based sessions. A future phase should add
Originheader validation - Input validation on URI parameters — the format-string issue at net_services.c:1125 (
snprintfwith user-controlledri2) is a separate bug, not an auth concern - Rate limiting — deferred to Phase 4
Start with Phase 1 (HTTP Basic + audit/write modes). This provides the highest security improvement with the least code change:
| Factor | Reasoning |
|---|---|
| Immediate coverage | Protects all HTTP endpoints and WebSocket upgrades |
| Zero new dependencies | Uses Mongoose's built-in mg_http_creds and mg_sha256 |
| Zero UI changes | Browsers show native Basic Auth prompt automatically |
| Backward compatible | Default off means existing installs don't break |
| Audit mode enables safe rollout | Users see what would be blocked before enforcing |
| Smallest diff | ~300-400 lines of new code (auth.c + config additions + enforcement in net_services.c) |
The audit → write progression is the critical path. Token support and browser sessions are valuable follow-ups but not blockers for the initial security improvement.
If AqualinkD's user base primarily accesses the web UI from browsers on the local network, Phase 1 (Basic auth + write mode) may be sufficient long-term. Basic auth with HTTPS is well-understood and battle-tested.
If users need machine-to-machine auth (Home Assistant, custom scripts, multiple automation systems), token support becomes important. If users want a polished login experience instead of the browser's Basic Auth popup, Phase 3 (sessions + login page) is needed.
New config fields follow the established pattern in config.c. Example of an existing field with password masking:
// config.h:287 — existing pattern
#define CFG_N_mqtt_passwd "mqtt_passwd"
// config.h:217 — mask flag already exists
#define CFG_PASSWD_MASK (1 << 4)The cfgParam struct (config.h:228-237) supports type, default value, valid values, and mask flags. Auth config fields plug directly into this system.
Mongoose connections have a void *fn_data field. The existing code already uses this for MQTT connection tracking (the is_mqtt() / is_websocket() helper functions). A per-connection auth flag for WebSocket sessions fits the same pattern.
action_URI already receives a request_source from parameter (net_services.c:997) distinguishing NET_API, NET_WS, and NET_MQTT. The auth layer can use this to apply different policies per source — e.g., MQTT messages could be exempt from HTTP auth while still being logged in audit mode.
Two independent code reviews (research/claude-research.md, research/codex-research.md) both identified the lack of authentication as the #1 finding:
"This is the single biggest problem. There is zero authentication — no login, no API tokens, nothing." — claude-research.md
"Critical: Unauthenticated control surface exposed by default." — codex-research.md
Both reviews also flagged: default 0.0.0.0:80 binding, no CSRF/CORS protection, XSS via innerHTML in the web UI, and MQTT credentials stored in plaintext on disk.