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:
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(®istered[i], ®istered[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.')
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user