Skip to content

Instantly share code, notes, and snippets.

@tehranian
Created February 17, 2026 03:46
Show Gist options
  • Select an option

  • Save tehranian/e09856ad71fb443282d8d0f4640944f3 to your computer and use it in GitHub Desktop.

Select an option

Save tehranian/e09856ad71fb443282d8d0f4640944f3 to your computer and use it in GitHub Desktop.
AqualinkD: Authentication Proposal

Authentication Research: AqualinkD

Analysis of AqualinkD's current network security posture and evaluation of approaches to add authentication.


1. Current State: The Problem

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.

How requests are processed today

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 auth

Default network exposure

The 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).

No auth config fields exist

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.

No login UI

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.

Concrete impact

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)

2. What's Exposed Today: Endpoint Inventory

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.

Read-only endpoints

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

Mutating endpoints

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

Admin/dangerous endpoints (WebSocket-only)

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 command path

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.


3. What Mongoose 7.19 Already Provides

Mongoose (already vendored at source/mongoose.c / source/mongoose.h) includes building blocks for authentication, but no complete auth framework.

Credential extraction

// 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.

Hashing primitives

// 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.

RNG

mg_random() provides random bytes, used internally by Mongoose for WebSocket keys and TLS. Suitable for token generation.

TLS support

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.

What Mongoose does NOT provide

  • 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.


4. Proposed Approach: Phased Auth Modes

Design principles

  1. Backward compatible by defaultauth_mode=off preserves current behavior. Existing installs upgrade without breakage.
  2. Opt-in with progressive modesoffauditwriteall. Each mode is a superset of the previous.
  3. Separate machine auth from browser auth — API tokens for automation; session cookies for browsers.
  4. No plaintext secrets on disk — store SHA256(salt || secret), never the secret itself.
  5. Route payloads unchanged — auth is enforcement at the connection/request level, not changes to JSON formats.

Mode definitions

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

Auth methods

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.

Token architecture

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.

Scope enforcement

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

Browser session flow

  1. User authenticates once (Basic prompt or token entry).
  2. Server creates short-lived session, returns HttpOnly; SameSite=Lax cookie.
  3. Subsequent requests (HTTP and WebSocket upgrade) use the cookie.
  4. 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.


5. Implementation Strategy

Where auth checks go

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.

New files

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

Modified files

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

Config additions

# 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.

Phased delivery

Phase 1: Foundation

  • Add config schema and auth mode plumbing (config.h, config.c)
  • Implement HTTP Basic auth enforcement in action_web_request
  • Implement audit and write modes
  • No UI changes required — browsers show native Basic Auth prompt

Phase 2: Token engine

  • Add source/auth.c with token file storage, SHA-256 hashing, verification
  • Token lifecycle: create, list (masked), revoke, rotate, disable/enable
  • Enable token and either methods

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 write mode)

6. Risk Assessment

What could go wrong

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

What we explicitly don't solve (yet)

  • 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 Origin header validation
  • Input validation on URI parameters — the format-string issue at net_services.c:1125 (snprintf with user-controlled ri2) is a separate bug, not an auth concern
  • Rate limiting — deferred to Phase 4

7. Recommendation

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 auditwrite progression is the critical path. Token support and browser sessions are valuable follow-ups but not blockers for the initial security improvement.

When to stop at Phase 1

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.

When to continue to Phase 2+

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.


Appendix A: Existing Code That Supports This Change

Config parameter registration pattern

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.

Per-connection data pattern

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.

The from parameter already classifies requests

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.

Appendix B: Security Findings from Independent Reviews

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment