feat: add bounded admin WebSocket backend (Phase 8D.5)

- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
This commit is contained in:
2026-09-06 14:41:41 +02:00
parent e5dce12ed4
commit aeb2043396
37 changed files with 3651 additions and 91 deletions
+193
View File
@@ -0,0 +1,193 @@
# Admin WebSocket transport host harness
Run from the repository root:
```sh
python3 tests/web_admin_transport/run.py
python3 tests/web_admin_transport/run.py --strict
python3 tests/web_admin_transport/run.py --tickets
python3 tests/web_admin_transport/run.py --sanitize
```
`CC` selects the compiler. The runner compiles the current production
`src/web_admin_transport.c` and production public headers into a temporary C11
translation unit with `-Wall -Wextra -Werror`. Only include directives are removed;
transport functions are not copied or reimplemented. Temporary output is removed.
`platform.h` supplies host types; `fakes.h` doubles dependencies; `test.c` exercises
production entry points and inspects private state for lifecycle/wipe assertions.
No firmware build, network access or device operation is performed.
## Results recorded 2026-09-06
Final continuation: `run.py --tickets` passes **19 transport / 12 ticket groups**,
including the HTTPD-owned shutdown retry/reuse regression. `server_lifecycle.py`
passes **11 groups** against extracted production server lifecycle/URI tables.
`python3 tests/web_cookie_auth/run.py --admin` now links the real cookie policy,
session store, tickets, private adapter and admin transport for endpoint admission,
pre-101 rejection and logout/expiry/currentness cleanup checks; console and runtime
IO remain doubled. These supersede the older counts/integration-pending notes
below. Final admin closure uses direct HTTPD-owned `shutdown`, not queued IDF
session-close work. Parent reports the sequential final firmware build after this
fix passed in **23.55 s**, at **95,580 B RAM / 1,637,273 B flash**, and the final
independent security integration review found no actionable findings. See
`docs/phase8d5_implementation.md` for build history and the pending target procedure.
After the production empty-frame, input-deadline and timer-generation fixes:
- `python3 tests/web_admin_transport/run.py`: **18 groups passed**.
- `python3 tests/web_admin_transport/run.py --strict`: **18 groups passed**.
- All assertions are mandatory by default. `--strict` is retained as a
compatibility flag with identical behavior; there are no expected-defect probes
or failure exemptions.
- Earlier, before these regression additions, `--tickets` also ran the separate
production ticket suite: **12 groups passed**. It was not rerun in this update.
This is a separate suite, not transport plus real-ticket integration.
- The earlier `--sanitize --tickets` attempt was blocked at linking by missing
`/usr/lib64/libasan.so.8.0.0` and `/usr/lib64/libubsan.so.1.0.0`.
Sanitizers were not rerun in this update; no sanitizer pass is claimed.
## Meaningful coverage
- PSRAM-only allocation flags, allocation/timer-create failure cleanup, retry,
idempotent initialization and duplicate attachment rejection.
- Authentication-helper delegation, role rejection, ticket response/capacity,
exact upgrade URI shape, ticket failure and ticket consumption before capacity
rejection. Shared console admission precedes 101; fake console assigns index 1
to verify that the returned shared-slot token is retained.
- One admin socket without replacement; failed upgrade and revocation during
console admission release reservations and console state.
- At most one outstanding transport poll; byte-preserving input, partial input
consumption/retry, consumed-input wiping, output delivery and TX wiping.
- Nonfinal/text/oversized frames and another frame while RX is occupied fail
closed; stalled input closes after the five-second deadline. Pending bytes are
not fed at or after the deadline even if the console can now consume them.
- Session/account notification isolation, idle currentness failure, invalidation
during currentness checking and between output consumption and send. Notifier
paths close the console and flag the slot without socket operations.
- Send/queue failure paths, close-trigger suppression after success, deferred
action support checks and the output-send drain guard. A queue-submission hook
frees and re-admits the HTTPD slot before returning failure: the replacement
generation remains unflagged/live and its next poll delivers output.
- Detach disables acceptance and new timer submissions. A deterministic hook
enters detach during submission, exercises its timeout, then verifies retry.
Queued work after detach does no IO. Successful-stop simulation discards pending
work and frees HTTPD context before `stopped` retires the queue marker/re-attach.
- Disconnect wipes payload and retires console state; replacement generations
reject a previous owner token. Dependency fakes assert external calls occur
outside the transport critical section and socket/input/output operations occur
in the simulated HTTPD owner context.
## Empty-frame regression
IDF 5.5's `httpd_ws_recv_frame` uses `frame->len == 0` as its header-parsing
sentinel. Calling it twice on an empty frame would parse a second header. The
production transport now skips the payload receive for zero-length frames.
The mandatory regression asserts one header parse, no input/close side effect,
and successful feeding of a following nonempty frame.
The fake models the sentinel checked in the installed IDF 5.5
`components/esp_http_server/src/httpd_ws.c`; it counts parses rather than
emulating socket timeout or wire desynchronization. No production source was
edited for this regression update.
## Limits / remaining integration and target work
This is deterministic single-threaded execution, not a concurrency proof. Locks
are assertions and races are selected reentrant hooks; FreeRTOS scheduling,
esp_timer scheduling, stack bounds, allocation placement on hardware, and memory
floors are not measured. Payload byte counts use host ABI metadata sizes; 512-byte
RX plus 1024-byte TX are not the entire allocated struct size.
Cookie/Origin/CSRF parsing, real session expiry/principal storage, ticket crypto,
shared-console allocator/dispatcher/prompts/policy and SSH are doubled here.
Their implementation correctness is not established by this harness. In
particular it does not prove simultaneous use of both real shared console slots.
The independent ticket suite is optional via `--tickets`.
HTTPD request/context/upgrade/send/close and queue operations are fakes. Close
triggering is recorded, not queued as IDF's real session-close work. Queue delivery
loss, socket-slot reuse, TLS partial reads/writes, ping/pong/control-frame handling,
actual HTTPD stop completion and on-wire pre-101 responses require real-IDF or
target validation. The empty-frame sentinel is modeled from source, not linked
from IDF. Failed `httpd_ssl_stop` orchestration is the integrating server's duty;
this harness only calls `stopped` after simulated successful shutdown.
Server route registration, six-socket non-eviction policy, revocation hook order,
status aggregation, full-client coexistence, serial writer/USB isolation and
whole-8D.5 target acceptance remain main integration/target work. No production
source is changed by this harness; passing normal mode does not close Phase 8D.5.
## Temporary authenticated device smoke client
`client.py` is a local Python-standard-library-only tool, not shipped firmware,
UI, or a new endpoint. **Running it contacts the specified device and consumes a
login attempt/session and, for an administrator, an admin console slot.** Only run
against a device you are authorized to test. It never uploads, builds or erases.
```sh
# System TLS trust; certificate hostname must match the explicit HTTPS origin.
python3 tests/web_admin_transport/client.py --url https://device.local
# Trust a locally obtained PEM CA/device certificate; hostname is still verified.
python3 tests/web_admin_transport/client.py --url https://device.local --cafile device-cert.pem --smoke
# Explicit isolated/local-test opt-in ONLY: warns and disables TLS verification.
python3 tests/web_admin_transport/client.py --url https://device.local --insecure --max-runtime 60 --timeout 10
```
Replace the example hostname with your device's certificate-matching hostname.
Only HTTPS origins on port 443 are accepted: no URL credentials, application
paths, queries or fragments. Host and Origin are derived from that validated
origin; redirects and environment proxies are not followed. `--insecure` does
**not** enforce private-address routing: it is an explicit operator opt-in, not
proof that the destination is local. Prefer `--cafile`, with its certificate
obtained through a trusted channel; insecure mode exposes credentials to active
network interception.
Username is requested with `input`, password with non-echoing `getpass`. Password
entry fails rather than falling back to echoed input. No credential arguments,
cookie files or HTTP debug logs are used. Cookies (including HttpOnly) are kept
in an in-memory CookieJar and copied into the WebSocket request header. Routine
results never print cookies, CSRF, passwords, tickets, ticket URLs or exception
representations. Python immutable strings cannot be reliably erased from memory;
this is not protection against process inspection, swap or core dumps.
The default and only mode is bounded smoke (`--smoke` is optional):
1. GET `/api/login-challenge` with `X-Login-Bootstrap: 1`, then JSON username/password
POST `/api/login` with challenge CSRF, then GET `/api/session` for session CSRF.
All requests include the matching Origin.
2. For role `user`, require HTTP 403 from the CSRF-protected admin ticket POST.
3. For role `admin`, mint a ticket, authenticate `/ws/admin` with the cookie and
ticket, validate the 101 handshake, then require 403 when replaying that ticket
with the same live cookie. Run binary `help`, an empty binary frame, empty Enter,
and `exit`, waiting for prompts/closure rather than sending commands in a burst.
4. Close the client socket and attempt CSRF-protected logout in `finally`; require
a subsequent session request to return 401. Cleanup failure is reported and
makes the command fail. If connectivity or authentication-response delivery
fails, server-side cleanup cannot be guaranteed; a session may remain until
its absolute expiry. There are no automatic login retries.
Console bytes are **deliberately printed directly to stdout**, including terminal
control sequences. Use a trusted device and do not capture output into routine
logs if console commands may disclose sensitive information. Authentication
metadata and rejected-response bodies are not printed. Smoke uses no mutating
administration command other than closing its own console/login session.
This version intentionally has no interactive/raw-terminal mode, so it does not
change terminal settings or exercise completion/hidden prompts. It requires
POSIX interval timers for a hard runtime guard: default 60 seconds, configurable
up to 300, starting after credential entry, plus up to 10 seconds for cleanup.
Individual transport timeout defaults to 10 seconds (maximum 30). HTTP/upgrade
headers or response bodies, frames, and per-command output have bounded sizes.
The WS parser accepts only final, unmasked, bounded binary/control frames and
masks all client frames; it is not a general-purpose WebSocket implementation.
**Local validation (2026-09-06):** syntax compiled in memory, and offline in-memory
checks passed for valid/rejected URLs, masked client frame encoding, bounded
server frame rejection, both-role smoke/replay/command sequencing, HttpOnly
CookieJar header forwarding and logout cleanup sequencing. These checks were run
without adding test files or opening sockets. No device/network command, TLS
handshake, browser test, interactive test or hardware validation was performed.
The client is temporary test tooling; its implementation and these local checks
do not establish whole-8D.5 acceptance.
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Temporary stdlib-only Phase 8D.5 smoke client; no device validation implied."""
import argparse
import base64
import getpass
import hashlib
import http.client
import http.cookiejar
import ipaddress
import json
import os
import re
import signal
import socket
import ssl
import struct
import sys
import time
import urllib.request
import warnings
from urllib.parse import urlsplit
class Failure(Exception):
"""Only fixed, secret-free diagnostics may be supplied here."""
def require(condition, message):
if not condition:
raise Failure(message)
def origin_url(value):
try:
u = urlsplit(value)
require(u.scheme == 'https' and u.hostname and not u.username and
not u.password and u.port in (None, 443) and
u.path in ('', '/') and not u.query and not u.fragment,
'URL must be an HTTPS origin on port 443 without credentials/query.')
host = u.hostname.encode('idna').decode('ascii').lower()
try:
address = ipaddress.ip_address(host)
authority = '[' + host + ']' if address.version == 6 else host
except ValueError:
require(len(host) <= 253 and all(re.fullmatch(
r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label)
for label in host.split('.')), 'Invalid hostname.')
authority = host
require(len(authority) + 8 <= 128, 'Origin exceeds firmware limit.')
return host, authority, 'https://' + authority
except (ValueError, UnicodeError):
raise Failure('Invalid HTTPS origin.') from None
def secret_field(body, field):
value = body.get(field)
require(isinstance(value, str) and re.fullmatch(r'[0-9a-fA-F]{64}', value),
'Missing or malformed authentication field.')
return value
class Client:
def __init__(self, args):
self.host, self.authority, self.origin = origin_url(args.url)
self.context = ssl.create_default_context(cafile=args.cafile)
if args.insecure:
print('WARNING: LOCAL TEST ONLY: TLS certificate/hostname verification DISABLED.',
file=sys.stderr)
self.context.check_hostname = False
self.context.verify_mode = ssl.CERT_NONE
self.jar = http.cookiejar.CookieJar()
self.timeout = args.timeout
self.deadline = time.monotonic() + args.max_runtime
self.csrf = None
self.ws = None
def budget(self):
left = self.deadline - time.monotonic()
require(left > 0, 'Maximum runtime exceeded.')
return min(self.timeout, left)
def cookie_request(self, path):
request = urllib.request.Request(self.origin + path)
self.jar.add_cookie_header(request) # Secure/HttpOnly cookies stay in memory.
return request
def api(self, path, method='GET', body=None, headers=None, expected=200):
request = self.cookie_request(path)
fields = {'Origin': self.origin, 'Connection': 'close'}
fields.update(headers or {})
cookie = request.get_header('Cookie')
if cookie:
fields['Cookie'] = cookie
data = json.dumps(body).encode() if body is not None else None
if data is not None:
fields['Content-Type'] = 'application/json'
conn = http.client.HTTPSConnection(self.host, 443, timeout=self.budget(),
context=self.context)
try:
conn.request(method, path, body=data, headers=fields)
response = conn.getresponse() # No redirects or proxy/environment routing.
self.jar.extract_cookies(response, request)
require(response.status == expected, 'Unexpected HTTP status: %d.' % response.status)
payload = response.read(4097)
require(len(payload) <= 4096, 'HTTP response exceeds bound.')
result = json.loads(payload) if payload else {}
require(isinstance(result, dict), 'Expected JSON object.')
return result
finally:
conn.close()
def upgrade(self, ticket, expected=101):
path = '/ws/admin?ticket=' + ticket
cookie = self.cookie_request(path).get_header('Cookie')
require(cookie is not None, 'Session cookie unavailable for upgrade.')
key = base64.b64encode(os.urandom(16)).decode('ascii')
raw = socket.create_connection((self.host, 443), self.budget())
sock = None
try:
sock = self.context.wrap_socket(raw, server_hostname=self.host)
sock.settimeout(self.budget())
message = ('GET %s HTTP/1.1\r\nHost: %s\r\nOrigin: %s\r\n'
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
'Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: %s\r\n'
'Cookie: %s\r\n\r\n') % (path, self.authority, self.origin, key, cookie)
sock.sendall(message.encode('ascii'))
header = bytearray()
while not header.endswith(b'\r\n\r\n'):
require(len(header) < 4096, 'Upgrade headers exceed bound.')
sock.settimeout(self.budget())
byte = sock.recv(1) # Do not consume an immediately following WS frame.
require(byte, 'Connection ended during upgrade.')
header.extend(byte)
lines = bytes(header).decode('ascii').split('\r\n')
parts = lines[0].split(' ', 2)
require(len(parts) >= 2 and parts[0] == 'HTTP/1.1' and parts[1].isdigit(),
'Malformed upgrade response.')
require(int(parts[1]) == expected, 'Unexpected upgrade status: %d.' % int(parts[1]))
if expected != 101:
return None
fields = {}
for line in lines[1:-2]:
name, separator, value = line.partition(':')
name = name.lower()
require(separator and name not in fields, 'Ambiguous upgrade headers.')
fields[name] = value.strip()
accept = base64.b64encode(hashlib.sha1((key +
'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
require(fields.get('sec-websocket-accept') == accept and
fields.get('upgrade', '').lower() == 'websocket' and
'upgrade' in [v.strip() for v in fields.get('connection', '').lower().split(',')] and
'sec-websocket-extensions' not in fields and
'sec-websocket-protocol' not in fields, 'Invalid WebSocket handshake.')
result, sock = sock, None
return result
finally:
if sock is not None:
sock.close()
elif expected != 101:
raw.close()
if sock is None and raw.fileno() != -1:
raw.close()
def send(self, payload, opcode=2):
require(len(payload) <= (125 if opcode >= 8 else 512), 'Client frame exceeds bound.')
mask = os.urandom(4)
length = len(payload)
header = bytes([0x80 | opcode, 0x80 | (length if length < 126 else 126)])
if length >= 126:
header += struct.pack('!H', length)
self.ws.settimeout(self.budget())
self.ws.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def exact(self, count):
data = bytearray()
while len(data) < count:
self.ws.settimeout(self.budget())
chunk = self.ws.recv(count - len(data))
if not chunk:
raise EOFError
data.extend(chunk)
return bytes(data)
def frame(self):
first, second = self.exact(2)
opcode, length = first & 15, second & 127
require(first & 0x80 and not first & 0x70 and not second & 0x80 and
opcode in (2, 8, 9, 10), 'Unsupported server frame.')
require(length != 127 and (opcode < 8 or length <= 125), 'Server frame exceeds bound.')
if length == 126:
length = struct.unpack('!H', self.exact(2))[0]
require(length >= 126, 'Noncanonical frame length.')
require(length <= 1024 and not (opcode == 8 and length == 1), 'Invalid server frame length.')
return opcode, self.exact(length)
def drain(self, closing=False):
recent = bytearray()
total = 0
while True:
try:
opcode, payload = self.frame()
except EOFError:
require(closing, 'WebSocket closed before command prompt.')
return
if opcode == 8:
require(closing, 'WebSocket closed before command prompt.')
return
if opcode == 9:
self.send(payload, 10)
if opcode != 2:
continue
total += len(payload)
require(total <= 65536, 'Console output exceeds smoke bound.')
sys.stdout.buffer.write(payload) # Deliberate console output, never auth metadata.
sys.stdout.buffer.flush()
recent.extend(payload)
del recent[:-128]
if not closing and recent.endswith(b'admin@serial-tool> '):
return
def smoke(self, username, password):
challenge = self.api('/api/login-challenge', headers={'X-Login-Bootstrap': '1'})
self.api('/api/login', 'POST', {'username': username, 'password': password},
{'X-CSRF-Token': secret_field(challenge, 'csrf')})
session = self.api('/api/session')
self.csrf = secret_field(session, 'csrf')
require(session.get('role') in ('user', 'admin'), 'Unexpected session role.')
if session['role'] == 'user':
self.api('/api/admin/ws-ticket', 'POST', headers={'X-CSRF-Token': self.csrf}, expected=403)
print('PASS: user ticket request rejected (403).')
return
ticket = secret_field(self.api('/api/admin/ws-ticket', 'POST',
headers={'X-CSRF-Token': self.csrf}), 'ticket')
self.ws = self.upgrade(ticket)
self.upgrade(ticket, expected=403) # Live cookie + consumed ticket: reject before capacity.
self.drain()
for command in (b'help\r', b'', b'\r'):
self.send(command)
if command:
self.drain()
self.send(b'exit\r')
self.drain(closing=True)
print('\nPASS: admin upgrade/replay, help, empty frame/Enter and exit smoke.')
def cleanup(self):
if self.ws is not None:
self.ws.close()
self.deadline = time.monotonic() + 10
try:
if any(cookie.name == '__Host-sak-session' for cookie in self.jar):
if self.csrf is None:
self.csrf = secret_field(self.api('/api/session'), 'csrf')
self.api('/api/logout', 'POST', headers={'X-CSRF-Token': self.csrf}, expected=204)
self.api('/api/session', expected=401)
print('PASS: logout and unauthenticated session check.')
finally:
self.jar.clear()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--url', required=True, help='HTTPS origin, port 443 only')
trust = parser.add_mutually_exclusive_group()
trust.add_argument('--cafile', help='trusted PEM CA/device certificate; hostname must match')
trust.add_argument('--insecure', action='store_true', help='LOCAL TEST ONLY: disable TLS verification')
parser.add_argument('--smoke', action='store_true', help='bounded smoke (default; only mode)')
parser.add_argument('--timeout', type=float, default=10)
parser.add_argument('--max-runtime', type=float, default=60)
args = parser.parse_args()
client = None
result = 0
try:
require(hasattr(signal, 'setitimer'), 'This bounded client requires POSIX interval timers.')
require(0 < args.timeout <= 30 and 0 < args.max_runtime <= 300, 'Invalid timeout/runtime bounds.')
def expired(signum, frame):
raise Failure('Maximum runtime exceeded.')
signal.signal(signal.SIGALRM, expired)
client = Client(args)
username = input('Username: ')
with warnings.catch_warnings():
warnings.simplefilter('error', getpass.GetPassWarning)
password = getpass.getpass('Password: ')
client.deadline = time.monotonic() + args.max_runtime
signal.setitimer(signal.ITIMER_REAL, args.max_runtime)
try:
client.smoke(username, password)
finally:
password = None # Python cannot guarantee erasure of immutable strings.
except (Exception, KeyboardInterrupt) as error:
print('FAIL: ' + (str(error) if isinstance(error, Failure) else
'Operation failed; details suppressed to protect credentials/tickets.'), file=sys.stderr)
result = 1
finally:
if client is not None and hasattr(signal, 'setitimer'):
signal.setitimer(signal.ITIMER_REAL, 0)
try:
signal.setitimer(signal.ITIMER_REAL, 10)
client.cleanup()
except (Exception, KeyboardInterrupt):
print('WARNING: logout cleanup unconfirmed; session may remain until expiry.', file=sys.stderr)
result = 1
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
return result
if __name__ == '__main__':
sys.exit(main())
+136
View File
@@ -0,0 +1,136 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Deterministic dependencies; production transport is included after this file. */
static int server_storage;
#define SERVER ((void *)&server_storage)
static bool httpd_owner, alloc_fail, timer_fail, auth_allowed, session_current;
static bool console_live, console_full, queue_fail, send_fail, upgrade_fail;
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
static size_t feed_limit, fed_length, output_length;
static uint8_t fed[2048], output[1024], sent[1024];
static size_t sent_length;
static char status[64], response_body[256];
static web_session_view_t auth_view;
static admin_ssh_console_token_t console_token;
static const admin_console_owner_t *console_owner;
static httpd_req_t *live_request;
static httpd_ws_frame_t incoming;
static void (*queued_work)(void *);
static void *queued_argument;
static void (*check_hook)(void);
static void (*queue_hook)(void);
static void (*timer_callback)(void *);
static void io(void) { OUTSIDE(); assert(httpd_owner); }
static void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
OUTSIDE(); assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
return alloc_fail ? NULL : calloc(n, size);
}
static void heap_caps_free(void *p) { OUTSIDE(); free(p); }
static esp_err_t esp_timer_create(const esp_timer_create_args_t *a, esp_timer_handle_t *t) {
OUTSIDE(); assert(a->skip_unhandled_events); timer_callback = a->callback;
if (timer_fail) return ESP_FAIL;
*t = &server_storage; return ESP_OK;
}
static esp_err_t esp_timer_start_periodic(esp_timer_handle_t t, uint64_t period) {
OUTSIDE(); assert(t && period == 20000); return ESP_OK;
}
static esp_err_t esp_timer_delete(esp_timer_handle_t t) { OUTSIDE(); assert(t); return ESP_OK; }
esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *p, bool *valid) {
OUTSIDE(); ++checks;
if (check_hook) { void (*hook)(void) = check_hook; check_hook = NULL; hook(); }
*valid = session_current && id == auth_view.id && p &&
p->user_id == auth_view.principal.user_id && p->auth_generation == auth_view.principal.auth_generation &&
p->role == auth_view.principal.role && p->method == auth_view.principal.method &&
p->username_length == auth_view.principal.username_length &&
!memcmp(p->username, auth_view.principal.username, p->username_length);
return ESP_OK;
}
static esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *v, bool *allowed) {
io(); assert(r); assert(mutation != upgrade);
*v = auth_view; *allowed = auth_allowed; return ESP_OK;
}
void web_admin_tickets_start(void) { OUTSIDE(); }
void web_admin_tickets_stop(void) { OUTSIDE(); ticket_live = false; }
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *u, size_t n) {
OUTSIDE(); (void)id; (void)u; (void)n;
}
esp_err_t web_admin_tickets_issue(web_session_id_t id, const user_principal_t *p, char token[WEB_ADMIN_TICKET_LENGTH + 1U]) {
OUTSIDE(); assert(id == auth_view.id && p->role == USER_ROLE_ADMIN);
if (ticket_live) return ESP_ERR_NO_MEM;
memset(token, 'a', 64); token[64] = 0; ticket_live = true; return ESP_OK;
}
esp_err_t web_admin_tickets_consume(const char *t, web_session_id_t id, const user_principal_t *p) {
OUTSIDE(); assert(id && p); bool valid = ticket_live && strlen(t) == 64;
ticket_live = false; return valid ? ESP_OK : ESP_ERR_NOT_FOUND;
}
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *t, const user_principal_t *p,
const admin_console_owner_t *owner) {
OUTSIDE(); assert(p->role == USER_ROLE_ADMIN);
if (console_full) return ESP_ERR_INVALID_STATE;
assert(!console_live); t->slot_index = 1; console_token = *t; console_owner = owner; console_live = true;
if (revoke_on_open) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
return ESP_OK;
}
void admin_ssh_console_close(const admin_ssh_console_token_t *t) {
OUTSIDE();
if (console_live && !memcmp(t, &console_token, sizeof(*t))) console_live = false;
}
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *t, const uint8_t *data,
size_t length, size_t *consumed) {
io(); assert(console_live && t->session_id == console_token.session_id);
*consumed = length < feed_limit ? length : feed_limit;
assert(fed_length + *consumed <= sizeof(fed));
memcpy(fed + fed_length, data, *consumed); fed_length += *consumed; return *consumed != 0;
}
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *t, uint8_t *data,
size_t capacity, size_t *received) {
io(); assert(t->session_id == console_token.session_id);
*received = output_length < capacity ? output_length : capacity;
memcpy(data, output, *received); output_length -= *received;
if (revoke_on_send) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
return ESP_OK;
}
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *t,
admin_ssh_console_session_snapshot_t *s) {
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live}; return ESP_OK;
}
static esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) {
OUTSIDE(); assert(h == SERVER); ++queues;
if (queue_hook) { void (*hook)(void) = queue_hook; queue_hook = NULL; hook(); }
if (queue_fail) return ESP_FAIL;
assert(!queued_work); queued_work = fn; queued_argument = arg; return ESP_OK;
}
static void *httpd_sess_get_ctx(httpd_handle_t h, int fd) {
io(); assert(h == SERVER); return live_request && live_request->fd == fd ? live_request->sess_ctx : NULL;
}
static int httpd_ws_get_fd_info(httpd_handle_t h, int fd) { io(); assert(h == SERVER && fd >= 0); return HTTPD_WS_CLIENT_WEBSOCKET; }
#define SHUT_RDWR 2
static bool shutdown_fail;
static int shutdown(int fd, int how) {
io(); assert(how == SHUT_RDWR && live_request && live_request->fd == fd);
++closes; return shutdown_fail ? -1 : 0;
}
static int httpd_req_to_sockfd(httpd_req_t *r) { io(); return r->fd; }
static esp_err_t httpd_ws_send_frame_async(httpd_handle_t h, int fd, httpd_ws_frame_t *f) {
io(); assert(h == SERVER && fd >= 0 && f->final && f->type == HTTPD_WS_TYPE_BINARY);
++sends; sent_length = f->len; memcpy(sent, f->payload, f->len); return send_fail ? ESP_FAIL : ESP_OK;
}
/* Match IDF 5.5's frame->len == 0 sentinel, including its empty-frame reparse. */
static esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t capacity) {
io(); (void)r;
if (f->len == 0) { ++receive_headers; f->len = incoming.len; f->final = incoming.final; f->type = incoming.type; }
if (!capacity || !f->len) return ESP_OK;
if (f->len > capacity) return ESP_ERR_INVALID_ARG;
memcpy(f->payload, incoming.payload, f->len); return ESP_OK;
}
static esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(status, sizeof(status), "%s", s); return ESP_OK; }
static esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { io(); (void)r; assert(!strcmp(s, "application/json")); return ESP_OK; }
static esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) { io(); (void)r; assert(k && v); return ESP_OK; }
static esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(response_body, sizeof(response_body), "%s", s); return ESP_OK; }
static bool web_httpd_upgrade_requested(httpd_req_t *r) { io(); (void)r; return upgrade_requested; }
static esp_err_t web_httpd_upgrade(httpd_req_t *r, esp_err_t (*handler)(httpd_req_t *)) {
io(); assert(r && handler && console_live); ++upgrades; return upgrade_fail ? ESP_FAIL : ESP_OK;
}
static bool web_httpd_unread_body(httpd_req_t *r) { io(); (void)r; return false; }
static void web_httpd_wipe_request(httpd_req_t *r, bool closing) { io(); (void)r; (void)closing; ++wipes; }
+52
View File
@@ -0,0 +1,52 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_NO_MEM, ESP_ERR_INVALID_ARG,
ESP_ERR_INVALID_STATE, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND,
ESP_ERR_TIMEOUT, ESP_ERR_NOT_ALLOWED };
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
static int lock_depth;
#define taskENTER_CRITICAL(lock) do { (void)(lock); assert(lock_depth++ == 0); } while (0)
#define taskEXIT_CRITICAL(lock) do { (void)(lock); assert(--lock_depth == 0); } while (0)
#define OUTSIDE() assert(lock_depth == 0)
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
typedef void *httpd_handle_t;
typedef struct httpd_req {
httpd_handle_t handle;
const char *uri;
void *sess_ctx;
void (*free_ctx)(void *);
int fd;
} httpd_req_t;
typedef struct {
bool final;
int type;
uint8_t *payload;
size_t len;
} httpd_ws_frame_t;
enum { HTTPD_WS_TYPE_BINARY = 2, HTTPD_WS_TYPE_TEXT = 1,
HTTPD_WS_TYPE_CLOSE = 8, HTTPD_WS_CLIENT_WEBSOCKET = 3 };
typedef void *esp_timer_handle_t;
typedef struct {
void (*callback)(void *);
const char *name;
bool skip_unhandled_events;
} esp_timer_create_args_t;
static int64_t now;
static int64_t esp_timer_get_time(void) { OUTSIDE(); return now; }
static void vTaskDelay(unsigned ticks) { OUTSIDE(); now += ticks * 1000; }
static void secure_wipe(void *p, size_t n) {
volatile unsigned char *b = p;
while (n--) *b++ = 0;
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Deterministic production-C transport harness; no target build or device IO."""
from pathlib import Path
import argparse
import os
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def stripped(path):
return '\n'.join(line for line in path.read_text().splitlines()
if not line.startswith(('#include', '#pragma once'))) + '\n'
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sanitize', action='store_true')
parser.add_argument('--tickets', action='store_true', help='also run separate real ticket suite')
parser.add_argument('--strict', action='store_true',
help='compatibility flag: all regression assertions are mandatory by default')
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix='web-admin-transport-') as directory:
path = Path(directory)
unit = (HERE / 'platform.h').read_text() + '\n'
for header in ('user_database.h', 'web_session_store.h', 'admin_ssh_console.h',
'web_admin_tickets.h', 'web_admin_transport.h'):
unit += stripped(ROOT / 'src' / header)
unit += (HERE / 'fakes.h').read_text() + '\n'
unit += stripped(ROOT / 'src/web_admin_transport.c')
unit += (HERE / 'test.c').read_text()
(path / 'test.c').write_text(unit)
flags = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if args.sanitize else []
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
'-g', *flags, str(path / 'test.c'), '-o', str(path / 'test')],
check=True, timeout=30)
subprocess.run([str(path / 'test')], check=True, timeout=15)
if args.tickets:
subprocess.run(['python3', str(ROOT / 'tests/web_admin_tickets/run.py'),
*(['--sanitize'] if args.sanitize else [])], check=True, timeout=60)
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Compile production server lifecycle and URI tables against fixed host fakes.
No HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler is
executed. Assertions cover server orchestration and values passed to registration
and SSL-start fakes, not actual requests/101, socket eviction or concurrent stop.
No firmware build, network access or device operation. CC selects the compiler.
"""
import os
from pathlib import Path
import re
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
SOURCE = ROOT / 'src/web_server.c'
source = SOURCE.read_text()
def function(name):
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\(void\)\n\{.*?^\}',
source, re.M | re.S)
if not match:
raise RuntimeError('Production function shape changed: ' + name)
return match.group() + '\n'
def define(path, name):
match = re.search(r'^#define ' + name + r' .+$', path.read_text(), re.M)
if not match:
raise RuntimeError('Missing production constant: ' + name)
return match.group() + '\n'
# Extract complete initializers, retaining real handler pointers and flags.
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 13:
raise RuntimeError('Review URI extraction: expected 11 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
header = '\n'.join(line for line in header.splitlines()
if not line.startswith(('#include', '#pragma once')))
constants = define(SOURCE, 'WEB_SERVER_PORT')
for filename, names in {
'web_admin_transport.h': ('WEB_ADMIN_TICKET_URI', 'WEB_ADMIN_WS_URI'),
'web_serial_transport.h': ('WEB_SERIAL_TRANSPORT_TICKET_URI', 'WEB_SERIAL_TRANSPORT_WS_URI'),
'web_security.h': ('WEB_SECURITY_CERTIFICATE_DER_CAPACITY', 'WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY'),
}.items():
for name in names:
constants += define(ROOT / 'src' / filename, name)
FAKES = r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT };
typedef void *SemaphoreHandle_t;
typedef void *httpd_handle_t;
typedef struct { int unused; } httpd_req_t;
typedef int httpd_err_code_t;
enum { HTTP_GET, HTTP_POST, HTTPD_404_NOT_FOUND = 404, HTTPD_405_METHOD_NOT_ALLOWED = 405 };
enum { WEB_UI_RESOURCE_XTERM_JS, WEB_UI_RESOURCE_XTERM_CSS, WEB_UI_RESOURCE_ADDON_FIT_JS,
WEB_UI_RESOURCE_APP_JS, WEB_UI_RESOURCE_LOGO_PNG };
typedef struct {
const char *uri; int method; esp_err_t (*handler)(httpd_req_t *);
void *user_ctx; bool is_websocket, handle_ws_control_frames;
} httpd_uri_t;
typedef struct {
struct { unsigned max_open_sockets, max_uri_handlers; bool lru_purge_enable;
unsigned recv_wait_timeout, send_wait_timeout; } httpd;
const uint8_t *servercert, *prvtkey_pem;
size_t servercert_len, prvtkey_len;
unsigned port_secure, tls_handshake_timeout_ms;
} httpd_ssl_config_t;
/* Nonproduction defaults deliberately make explicit overrides observable. */
#define HTTPD_SSL_CONFIG_DEFAULT() ((httpd_ssl_config_t){.httpd = {.max_open_sockets = 1, .lru_purge_enable = true}})
#define portMAX_DELAY 0
static int mutex_storage, server_storage, locked;
#define SERVER ((void *)&server_storage)
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live;
static esp_err_t serial_init_error, admin_init_error, admin_attach_error;
static esp_err_t auth_error, ssl_start_error, ssl_stop_error, admin_detach_error;
static unsigned serial_inits, admin_inits, auth_starts, auth_stops;
static unsigned ssl_starts, ssl_stops, serial_attaches, serial_detaches;
static unsigned admin_attaches, admin_detaches, admin_stoppeds;
static unsigned registration_calls, registration_fail_at, registered_count, unregister_calls;
static bool unregister_fail;
static const httpd_uri_t *registered[32];
static char events[128]; static size_t event_length;
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
static void xSemaphoreTake(SemaphoreHandle_t m, int wait) { (void)wait; assert(m && !locked); locked = 1; }
static void xSemaphoreGive(SemaphoreHandle_t m) { assert(m && locked); locked = 0; }
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
static void web_cookie_auth_stop(void) { event('A'); ++auth_stops; auth_live = false; }
static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t *lc,
uint8_t *key, size_t nk, size_t *lk) {
assert(!locked && auth_live && nc && nk); cert[0] = 1; key[0] = 2; *lc = *lk = 1; return ESP_OK;
}
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 16 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->servercert_len == 1 && config->servercert[0] == 1);
assert(config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2);
if (ssl_start_error != ESP_OK) return ssl_start_error;
*server = SERVER; ssl_live = true; return ESP_OK;
}
static esp_err_t register_one(httpd_handle_t server) {
assert(!locked && server == SERVER && ssl_live); ++registration_calls;
return registration_calls == registration_fail_at ? ESP_FAIL : ESP_OK;
}
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
assert(registration_calls >= 16);
assert(serial_init_error != ESP_OK || serial_live);
} else assert(registration_calls < 14);
esp_err_t error = register_one(s);
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
return error;
}
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert(registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST);
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
if (unregister_fail) return ESP_FAIL;
memmove(&registered[i], &registered[i + 1],
(registered_count - i - 1) * sizeof(registered[0]));
--registered_count;
return ESP_OK;
}
}
assert(!"unregister must target the previously registered ticket");
return ESP_FAIL;
}
static esp_err_t httpd_register_err_handler(httpd_handle_t s, httpd_err_code_t code,
esp_err_t (*handler)(httpd_req_t *, httpd_err_code_t)) {
assert(registration_calls == 14 || registration_calls == 15);
assert((code == 404 || code == 405) && handler == route_error_handler);
return register_one(s);
}
static esp_err_t web_serial_transport_attach_server(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 16);
++serial_attaches; serial_live = true; return ESP_OK;
}
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 18); ++admin_inits; return admin_init_error; }
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
}
static esp_err_t web_admin_transport_detach(httpd_handle_t s) {
assert(s == SERVER && ssl_live && admin_owned && !auth_live);
event('D'); ++admin_detaches; return admin_detach_error;
}
static esp_err_t web_serial_transport_detach_server(httpd_handle_t s) {
assert(s == SERVER && ssl_live && serial_live && !auth_live);
event('S'); ++serial_detaches; serial_live = false; return ESP_OK;
}
static esp_err_t httpd_ssl_stop(httpd_handle_t s) {
assert(s == SERVER && ssl_live && !auth_live); event('H'); ++ssl_stops;
if (ssl_stop_error == ESP_OK) ssl_live = false;
return ssl_stop_error;
}
static void web_admin_transport_stopped(httpd_handle_t s) {
assert(s == SERVER && !ssl_live && admin_owned && admin_detaches);
event('R'); ++admin_stoppeds; admin_owned = false;
}
'''
TESTS = r'''
static void clear_events(void) { event_length = 0; events[0] = 0; }
static void reset(void) {
assert(!locked);
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
s_serial_transport_attached = s_admin_transport_owned = false;
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
memset(&s_counters, 0, sizeof(s_counters));
mutex_fail = auth_live = ssl_live = admin_owned = serial_live = false;
serial_init_error = admin_init_error = admin_attach_error = ESP_OK;
auth_error = ssl_start_error = ssl_stop_error = admin_detach_error = ESP_OK;
serial_inits = admin_inits = auth_starts = auth_stops = ssl_starts = ssl_stops = 0;
serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0;
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = false; clear_events();
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) {
assert(web_server_start() == ESP_OK);
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning);
}
static const httpd_uri_t *route(const char *uri) {
const httpd_uri_t *found = NULL;
for (unsigned i = 0; i < registered_count; ++i) if (!strcmp(registered[i]->uri, uri)) {
assert(!found); found = registered[i];
}
assert(found); return found;
}
int main(void) {
reset(); mutex_fail = true;
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
mutex_fail = false; serial_init_error = ESP_FAIL;
assert(web_server_init() == ESP_OK && s_initialized && !s_serial_transport_initialized);
assert(web_server_start() == ESP_OK && auth_live && ssl_live && admin_owned);
assert(serial_inits == 1 && !serial_attaches && !auth_stops);
assert(web_server_stop() == ESP_OK);
puts("PASS mutex failure and serial-init failure isolation from authenticated HTTPS");
for (unsigned mode = 0; mode < 2; ++mode) {
reset(); if (mode == 0) admin_init_error = ESP_ERR_NO_MEM; else admin_attach_error = ESP_FAIL;
assert(web_server_start() == ESP_OK && auth_live && ssl_live && serial_live);
assert(s_serial_transport_attached && !s_admin_transport_owned && !auth_stops);
assert(admin_inits == 1 && admin_attaches == mode);
assert(web_server_stop() == ESP_OK && !admin_detaches && !admin_stoppeds);
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 16 && registration_calls == 18);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket);
assert(route("/ws/serial")->method == HTTP_GET && !route("/ws/serial")->is_websocket);
assert(route("/api/login")->method == HTTP_POST && route("/api/session")->method == HTTP_GET);
assert(web_server_start() == ESP_ERR_INVALID_STATE && auth_starts == 1 && ssl_starts == 1);
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ADSHR"));
assert(!s_server && !s_admin_transport_owned && !s_transitioning && s_counters.stops == 1);
puts("PASS production URI tables/registration, six sockets/no LRU, admission and ordered normal stop");
fresh_registration(); start(); assert(ssl_starts == 2 && serial_inits == 1 && admin_inits == 2);
assert(s_counters.starts == 2 && web_server_stop() == ESP_OK && admin_stoppeds == 2);
puts("PASS restart after successful stop reattaches without repeated serial initialization");
reset(); start(); admin_detach_error = ESP_ERR_TIMEOUT; clear_events();
assert(web_server_stop() == ESP_ERR_TIMEOUT && !strcmp(events, "AD"));
assert(!ssl_stops && !serial_detaches && !admin_stoppeds);
assert(s_server == SERVER && s_admin_transport_owned && admin_owned && ssl_live);
assert(s_serial_transport_attached && !s_transitioning && s_last_error == ESP_ERR_TIMEOUT);
assert(web_server_start() == ESP_ERR_INVALID_STATE && auth_starts == 1);
admin_detach_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "ADSHR") && admin_detaches == 2);
puts("PASS admin detach timeout fences SSL stop, retains ownership and permits stop retry");
reset(); start(); ssl_stop_error = ESP_FAIL; clear_events();
assert(web_server_stop() == ESP_FAIL && !strcmp(events, "ADSH"));
assert(s_server == SERVER && s_admin_transport_owned && admin_owned && ssl_live);
assert(!s_serial_transport_attached && !s_transitioning && !admin_stoppeds);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "ADHR"));
assert(admin_detaches == 2 && serial_detaches == 1 && admin_stoppeds == 1 && !s_admin_transport_owned);
puts("PASS failed SSL stop retains admin ownership; stopped runs only after successful retry");
for (unsigned failure = 1; failure <= 16; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_FAIL);
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
}
puts("PASS required registration positions 1..16 fail fatally before transport attachment");
for (unsigned failure = 17; failure <= 18; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_OK && registration_calls == failure);
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 14 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler);
assert(route("/api/session")->handler == web_cookie_auth_handler);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 16 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 15);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
assert(ticket->method == HTTP_POST && !ticket->is_websocket &&
ticket->handler == web_admin_transport_ticket_handler);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/ws/admin"));
/* Handler identity is checked, not its authentication implementation (doubled). */
assert(!auth_stops && !ssl_stops && !s_transitioning && s_last_error == ESP_OK);
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 16 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && s_server == SERVER && ssl_live);
assert(!s_admin_transport_owned && !admin_attaches && !auth_live);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "AH") && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start(); assert(web_server_stop() == ESP_OK);
puts("PASS registration cleanup SSL failure retains partial server for stop/restart without admin ownership");
reset(); auth_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && !ssl_starts && !admin_inits && !s_server);
reset(); ssl_start_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && !auth_live && !registration_calls && !ssl_stops);
reset(); assert(web_server_stop() == ESP_ERR_INVALID_STATE);
assert(web_server_init() == ESP_OK); s_transitioning = true;
assert(web_server_start() == ESP_ERR_INVALID_STATE && !auth_starts);
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
puts("11 lifecycle groups passed (16 required fatal positions, 2 optional positions, plus failed unregister)");
return 0;
}
'''
unit = FAKES + header + '\n' + constants + state + '\n'.join(uri_tables)
unit += function('ensure_mutex')
unit += ''.join(function(name) for name in ('web_server_init', 'web_server_start', 'web_server_stop'))
unit += TESTS
with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as directory:
temporary = Path(directory)
c_file = temporary / 'test.c'
c_file.write_text(unit)
executable = temporary / 'test'
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
'-g', str(c_file), '-o', str(executable)], check=True, timeout=30)
subprocess.run([str(executable)], check=True, timeout=15)
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
+234
View File
@@ -0,0 +1,234 @@
/* SPDX-License-Identifier: GPL-3.0-only */
static char uri[128];
static httpd_req_t request;
static unsigned cases;
static bool zeroed(const void *p, size_t n) {
const uint8_t *b = p; for (size_t i = 0; i < n; ++i) if (b[i]) return false; return true;
}
static void reset(void) {
assert(lock_depth == 0);
free(s_payload); s_payload = NULL;
memset(&s_slot, 0, sizeof(s_slot)); memset(&s_counts, 0, sizeof(s_counts));
s_timer = NULL; s_server = NULL; s_initialized = s_accepting = s_queued = false;
s_submitting = s_generation = 0;
httpd_owner = true; alloc_fail = timer_fail = false;
auth_allowed = session_current = upgrade_requested = true;
console_live = console_full = queue_fail = send_fail = upgrade_fail = shutdown_fail = false;
ticket_live = revoke_on_open = revoke_on_send = false;
upgrades = closes = sends = queues = wipes = checks = receive_headers = 0;
feed_limit = SIZE_MAX; fed_length = output_length = sent_length = 0;
memset(fed, 0, sizeof(fed)); memset(output, 0, sizeof(output)); memset(sent, 0, sizeof(sent));
memset(status, 0, sizeof(status)); memset(response_body, 0, sizeof(response_body));
queued_work = NULL; queued_argument = NULL; check_hook = queue_hook = NULL;
console_owner = NULL; live_request = NULL; now = 1000000;
auth_view = (web_session_view_t){.id = 7, .principal = {
.user_id = 3, .auth_generation = 9, .role = USER_ROLE_ADMIN,
.method = USER_AUTH_METHOD_PASSWORD, .username_length = 5, .username = "admin"}};
snprintf(uri, sizeof(uri), "%s?ticket=%064d", WEB_ADMIN_WS_URI, 0);
request = (httpd_req_t){.handle = SERVER, .uri = uri, .fd = 12};
incoming = (httpd_ws_frame_t){.final = true, .type = HTTPD_WS_TYPE_BINARY};
}
static void start(void) {
assert(web_admin_transport_init() == ESP_OK);
assert(web_admin_transport_attach(SERVER) == ESP_OK);
}
static void admit(void) {
ticket_live = true;
assert(web_admin_transport_upgrade_handler(&request) == ESP_OK);
assert(upgrades == 1 && s_slot.active && console_live && request.free_ctx);
assert(s_slot.token.slot_index == 1); live_request = &request;
}
static void tick(void) { httpd_owner = false; timer_callback(NULL); httpd_owner = true; }
static void work(void) {
assert(queued_work); void (*fn)(void *) = queued_work; void *arg = queued_argument;
queued_work = NULL; queued_argument = NULL; fn(arg);
}
static void disconnected(void) {
assert(request.free_ctx); request.free_ctx(request.sess_ctx);
request.free_ctx = NULL; request.sess_ctx = NULL; live_request = NULL;
}
static void ok(const char *name) { ++cases; printf("PASS %s\n", name); }
static void revoke_check(void) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
static void detach_in_submit(void) {
assert(web_admin_transport_detach(SERVER) == ESP_ERR_TIMEOUT);
assert(!s_accepting && s_server == SERVER && s_submitting == 1);
}
static void replace_in_submit(void) {
assert(!httpd_owner && s_submitting == 1 && s_queued);
uint32_t generation = s_slot.token.slot_generation;
httpd_owner = true;
disconnected();
assert(!s_slot.occupied && !console_live);
upgrades = 0;
admit();
assert(s_slot.token.slot_generation != generation && !s_slot.close_requested);
httpd_owner = false;
}
int main(void) {
reset(); alloc_fail = true; assert(web_admin_transport_init() == ESP_ERR_NO_MEM);
assert(!s_initialized && !s_payload); alloc_fail = false; timer_fail = true;
assert(web_admin_transport_init() == ESP_FAIL && !s_payload);
timer_fail = false; start(); assert(web_admin_transport_init() == ESP_OK);
assert(web_admin_transport_attach(SERVER) == ESP_ERR_INVALID_STATE);
ok("PSRAM-only allocation failure, timer failure, retry and duplicate attach");
reset(); start(); auth_allowed = false;
assert(web_admin_transport_ticket_handler(&request) == ESP_OK && !ticket_live);
assert(web_admin_transport_upgrade_handler(&request) == ESP_OK && !upgrades);
auth_allowed = true; auth_view.principal.role = USER_ROLE_USER;
assert(web_admin_transport_ticket_handler(&request) != ESP_OK && !strcmp(status, "403 Forbidden"));
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !upgrades);
auth_view.principal.role = USER_ROLE_ADMIN;
assert(web_admin_transport_ticket_handler(&request) == ESP_OK && ticket_live);
assert(strstr(response_body, "\"expires_in\":30"));
assert(web_admin_transport_ticket_handler(&request) != ESP_OK && !strcmp(status, "503 Service Unavailable"));
ok("authorization delegation, admin role and ticket capacity responses");
reset(); start(); ticket_live = true; upgrade_requested = false;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && ticket_live && !upgrades);
upgrade_requested = true; request.uri = "/ws/admin?ticket=short";
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !upgrades);
request.uri = uri; ticket_live = false;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !s_slot.occupied);
ticket_live = true; console_full = true;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !ticket_live && !s_slot.occupied && !upgrades);
console_full = false; revoke_on_open = true; ticket_live = true;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !console_live && !s_slot.occupied && !upgrades);
ok("pre-101 malformed/ticket/shared-console rejection and revocation during admission");
reset(); start(); upgrade_fail = true; ticket_live = true;
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !console_live && !s_slot.occupied);
assert(zeroed(s_payload, sizeof(*s_payload)));
ok("failed upgrade unwinds console, slot and payload");
reset(); start(); admit(); admin_ssh_console_token_t old = s_slot.token;
ticket_live = true; httpd_req_t second = request; second.fd = 13; second.sess_ctx = NULL;
assert(web_admin_transport_upgrade_handler(&second) != ESP_OK && upgrades == 1 && console_live);
tick(); tick(); tick(); assert(queues == 1 && s_queued);
memcpy(output, "hello", 5); output_length = 5; work();
assert(sends == 1 && sent_length == 5 && !memcmp(sent, "hello", 5));
assert(zeroed(s_payload->tx, sizeof(s_payload->tx)) && !s_slot.sending);
disconnected(); assert(!console_live && !s_slot.occupied && zeroed(s_payload, sizeof(*s_payload)));
upgrades = 0; admit(); assert(s_slot.token.session_id != old.session_id);
assert(!owner_current(&old, &auth_view.principal));
ok("single admin slot, shared slot token, one outstanding poll, TX wiping, disconnect/reuse");
reset(); start(); admit(); uint8_t bytes[] = {0, 1, 2, 255};
incoming.payload = bytes; incoming.len = sizeof(bytes); feed_limit = 2;
assert(frame_handler(&request) == ESP_OK && fed_length == 2 && s_payload->rx_offset == 2);
assert(zeroed(s_payload->rx, 2)); tick(); work();
assert(fed_length == 4 && !memcmp(fed, bytes, 4) && s_payload->rx_length == 0 && zeroed(s_payload->rx, 4));
ok("binary-transparent bounded input with partial consume/retry and wiping");
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 0;
assert(frame_handler(&request) == ESP_OK); now += ADMIN_INPUT_TIMEOUT_US;
tick(); work(); assert(closes == 1 && !console_live && s_counts.input_backpressure == 1);
tick(); work(); assert(closes == 1);
ok("input timeout closes once without new task or notifier IO");
for (unsigned late = 0; late < 2; ++late) {
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 2;
assert(frame_handler(&request) == ESP_OK && fed_length == 2);
assert(s_payload->rx_offset == 2 && s_payload->rx_length == 4);
feed_limit = SIZE_MAX; /* Dispatcher is now ready, but the bytes are expired. */
now = s_payload->input_deadline + late;
tick(); work();
assert(fed_length == 2 && s_payload->rx_offset == 2);
assert(closes == 1 && !console_live && s_counts.input_backpressure == 1);
disconnected(); assert(zeroed(s_payload, sizeof(*s_payload)));
}
ok("ready console must not consume pending bytes at or after input deadline");
for (unsigned mode = 0; mode < 3; ++mode) {
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4;
if (mode == 0) incoming.final = false;
if (mode == 1) incoming.type = HTTPD_WS_TYPE_TEXT;
if (mode == 2) incoming.len = WEB_ADMIN_RX_CAPACITY + 1;
assert(frame_handler(&request) != ESP_OK && !fed_length && !console_live);
}
ok("fragmented, text and oversized input rejected before payload feed");
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 0;
assert(frame_handler(&request) == ESP_OK);
assert(frame_handler(&request) != ESP_OK && !fed_length && !console_live);
ok("second frame rejected while input buffer occupied");
reset(); start(); admit(); httpd_owner = false;
web_admin_transport_revoke(99, NULL, 0); assert(console_live);
web_admin_transport_revoke(0, (const uint8_t *)"other", 5); assert(console_live);
web_admin_transport_revoke(0, (const uint8_t *)"admin", 5);
assert(!console_live && s_slot.close_requested && !closes); httpd_owner = true;
tick(); work(); assert(closes == 1);
ok("session/account revocation isolation and HTTPD-only close request");
reset(); start(); admit(); session_current = false; tick(); work();
assert(!console_live && closes == 1 && !sends);
reset(); start(); admit(); memcpy(output, "secret", 6); output_length = 6;
revoke_on_send = true; tick(); work();
assert(!sends && !console_live && zeroed(s_payload->tx, sizeof(s_payload->tx)));
reset(); start(); admit(); check_hook = revoke_check;
assert(!owner_current(&s_slot.token, &auth_view.principal));
ok("idle expiry, revocation between ring read and send, currentness recheck");
reset(); start(); admit(); send_fail = true; output[0] = 1; output_length = 1;
tick(); work(); assert(s_counts.send_failures == 1 && closes == 1 && !console_live);
reset(); start(); admit(); queue_fail = true; tick();
assert(!s_queued && !s_submitting && s_slot.close_requested && s_counts.queue_failures == 1);
queue_fail = false; tick(); work(); assert(!console_live && closes == 1);
ok("send failure and queue failure close/retry paths");
reset(); start(); admit(); request_close(); shutdown_fail = true;
tick(); work(); assert(closes == 1 && !s_slot.close_triggered && !queued_work);
shutdown_fail = false; tick(); work(); assert(closes == 2 && s_slot.close_triggered && !queued_work);
disconnected(); upgrades = 0; admit();
tick(); work(); assert(closes == 2 && console_live && !s_slot.close_requested);
ok("HTTPD-owned shutdown retries without queuing a reusable HTTPD slot pointer or closing replacement");
reset(); start(); admit(); queue_fail = true; queue_hook = replace_in_submit;
tick();
assert(s_counts.queue_failures == 1 && !s_queued && !s_submitting && !queued_work);
assert(s_slot.active && console_live && !s_slot.close_requested && !closes);
queue_fail = false; memcpy(output, "replacement", 11); output_length = 11;
tick(); work();
assert(sends == 1 && sent_length == 11 && !memcmp(sent, "replacement", 11));
assert(console_live && !s_slot.close_requested && !closes);
ok("failed timer submission cannot close HTTPD-replaced generation; replacement poll recovers");
reset(); start(); admit(); tick(); httpd_owner = false;
assert(web_admin_transport_detach(SERVER) == ESP_OK && !console_live);
tick(); assert(queues == 1); httpd_owner = true; work(); assert(!sends && !s_queued);
disconnected(); httpd_owner = false; web_admin_transport_stopped(SERVER);
assert(!s_server && !s_queued); assert(web_admin_transport_attach(SERVER) == ESP_OK);
httpd_owner = true;
ok("detach stops acceptance/submission; stale queued work no-ops before successful stop");
reset(); start(); admit(); queue_hook = detach_in_submit; tick();
assert(s_queued && !s_submitting && !s_accepting);
assert(web_admin_transport_detach(SERVER) == ESP_OK);
/* Simulate successful HTTPD stop: queued callbacks are discarded, context freed. */
queued_work = NULL; queued_argument = NULL; disconnected();
httpd_owner = false; web_admin_transport_stopped(SERVER);
assert(!s_server && !s_queued && web_admin_transport_attach(SERVER) == ESP_OK);
httpd_owner = true;
ok("submission fence timeout/retry and stopped retirement of unexecuted callback");
reset(); start(); admit();
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_SUPPORTED);
assert(console_live && !s_slot.close_requested);
s_slot.sending = true; assert(!owner_drained(&s_slot.token)); s_slot.sending = false;
assert(owner_drained(&s_slot.token)); httpd_owner = false;
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_SELF_CLOSE, 0) == ESP_OK);
assert(!console_live && !closes); httpd_owner = true;
ok("unsupported deferred action has no side effects; self-close notifier and drain guard");
reset(); start(); admit(); incoming.len = 0;
assert(frame_handler(&request) == ESP_OK);
assert(receive_headers == 1 && !fed_length && console_live && !s_slot.close_requested);
incoming.payload = bytes; incoming.len = sizeof(bytes);
assert(frame_handler(&request) == ESP_OK && receive_headers == 2);
assert(fed_length == sizeof(bytes) && !memcmp(fed, bytes, sizeof(bytes)));
ok("empty binary frame parsed once; following nonempty frame feeds normally");
free(s_payload); s_payload = NULL;
printf("%u groups passed\n", cases);
return EXIT_SUCCESS;
}