Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8902b25d78 | ||
|
|
ece4ba77e3 | ||
|
|
31a22eba06 | ||
|
|
4f628a4098 | ||
|
|
b9653426e4 |
@@ -13,3 +13,6 @@ add_compile_definitions(
|
|||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
set(PROJECT_VER "0.1.0")
|
set(PROJECT_VER "0.1.0")
|
||||||
project(esp32_serial_swiss_army_knife)
|
project(esp32_serial_swiss_army_knife)
|
||||||
|
|
||||||
|
# Narrow mDNS 1.12.0 correctness fix; see tests/mdns_membership/README.md.
|
||||||
|
include(${CMAKE_CURRENT_LIST_DIR}/cmake/mdns_membership.cmake)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
|
|||||||
|
|
||||||
## Development status
|
## Development status
|
||||||
|
|
||||||
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A–8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance evidence](docs/web_administration_acceptance.md). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. Phase 9 hardening begins only on a separate request.
|
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A–8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and its [consolidated acceptance evidence](docs/roadmap.md#phase8-acceptance-evidence). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. The reduced Phase 9 focuses on intermittent trusted-network use, standard no-dump/silent-panic defaults and a short [operational checklist](docs/security_operations.md), without dependency patches or upgrades. **The reduced Phase 9 is complete by user sign-off on 2026-09-18.** The user waived a new device check based on prior Phase 8 validation; application code is unchanged, but crash-diagnostic defaults changed. No new hardware or panic-test pass is claimed.
|
||||||
|
|
||||||
|
**Phase 10 application firmware upload is complete by explicit user acceptance on 2026-09-18:** upload works and normal operation is verified. This does not claim specific fault-injection, NVS before/after comparison, power-loss or wired-recovery checks. Administrators can upload a locally built application from **Settings → HTTPS / Reboot → Firmware update**. Install this OTA-enabled firmware by wire first; future uploads use `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`. See [firmware update and recovery](docs/roadmap.md#phase10-update-and-recovery) for compatibility, interruption and validation limits.
|
||||||
|
|
||||||
### Browser administration
|
### Browser administration
|
||||||
|
|
||||||
@@ -31,8 +33,11 @@ Keep UART0 ready for administrative recovery and native USB for network-independ
|
|||||||
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
|
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
|
||||||
- [Electrical tests](docs/electrical_tests.md): OLED/buttons, MAX3243, UART loopback, and session-broker verification procedures.
|
- [Electrical tests](docs/electrical_tests.md): OLED/buttons, MAX3243, UART loopback, and session-broker verification procedures.
|
||||||
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user provisioning and administration, HTTPS/SSH authentication, session revocation, and reusable integrated web-administration regression procedures (not execution evidence).
|
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user provisioning and administration, HTTPS/SSH authentication, session revocation, and reusable integrated web-administration regression procedures (not execution evidence).
|
||||||
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [acceptance evidence](docs/web_administration_acceptance.md) records sign-off and telemetry limits.
|
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [roadmap acceptance evidence](docs/roadmap.md#phase8-acceptance-evidence) records sign-off and telemetry limits.
|
||||||
|
- [Firmware update](docs/roadmap.md#phase-10--simple-admin-web-firmware-upload): admin-only application upload, NVS preservation, uncertain outcomes, wired recovery, acceptance evidence and reusable regression checks.
|
||||||
|
- [Security operations](docs/security_operations.md): trusted-network use, credentials, identity verification, shutdown and recovery; physical-extraction limits and lightweight upstream maintenance.
|
||||||
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
|
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
|
||||||
|
- [Dual-stack networking and discovery](docs/roadmap.md#phase-12--advanced-network-integration): DHCPv4 hostname publication, STA SLAAC/link-local IPv6, HTTPS/SSH DNS-SD and dual-stack access; complete by explicit user validation, including fresh boot and the full client mix at 230400 baud. Includes recorded heap/stack measurements, evidence limits, saved-config requirements and the narrowly guarded mDNS membership fix.
|
||||||
|
|
||||||
## Flash partition layout
|
## Flash partition layout
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
|
|||||||
|
|
||||||
Application offsets are aligned to the ESP32-S3's required 64 KiB boundary. The final storage partition ends at `0x1000000`, exactly the end of the 16 MiB flash chip.
|
Application offsets are aligned to the ESP32-S3's required 64 KiB boundary. The final storage partition ends at `0x1000000`, exactly the end of the 16 MiB flash chip.
|
||||||
|
|
||||||
The table reserves OTA and storage space; it does not implement OTA downloads, rollback confirmation, core-dump handling, NVS encryption, or filesystem mounting.
|
Admin HTTPS application upload uses the inactive OTA slot and `otadata`; NVS and other data partitions are untouched by the updater. It does not provide remote downloads, automatic rollback/health confirmation, core-dump handling, NVS encryption or filesystem mounting. Image validation does not guarantee a working application; compatible storage schemas and wired recovery remain the owner's responsibility.
|
||||||
|
|
||||||
### One-time migration from the default partition table
|
### One-time migration from the default partition table
|
||||||
|
|
||||||
@@ -63,7 +68,7 @@ pio run --target upload
|
|||||||
pio device monitor -b 115200
|
pio device monitor -b 115200
|
||||||
```
|
```
|
||||||
|
|
||||||
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. Subsequent ordinary uploads do not need a full erase.
|
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. **Do not erase for subsequent ordinary wired or web application updates.** This destructive migration is only for the former default/factory layout, not for installing the updater on an existing custom-layout device.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -71,6 +76,8 @@ This removes saved serial configuration and all other flash contents. The firmwa
|
|||||||
pio run
|
pio run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The small Phase 9 sets `CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y` and `CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y` in `sdkconfig.defaults`. An existing generated `sdkconfig.esp32-s3-devkitc-1-n16r8` can retain earlier selections: check its resolved values after configuration, or change them using `pio run --target menuconfig`. Defaults alone do not enforce existing configurations. Silent panic reboot removes panic register/backtrace output; ordinary logs remain. No existing flash contents are erased, and stored secrets remain unencrypted.
|
||||||
|
|
||||||
## Upload and monitor
|
## Upload and monitor
|
||||||
|
|
||||||
Connect the board's **USB-to-UART** port for firmware upload and the UART0 development console, then run:
|
Connect the board's **USB-to-UART** port for firmware upload and the UART0 development console, then run:
|
||||||
@@ -88,7 +95,7 @@ Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with
|
|||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
|
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. This firmware is not resistant to physical flash or RAM extraction; the completed reduced Phase 9 deliberately does not add that protection.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -98,4 +105,4 @@ This project is licensed under the [GNU General Public License version 3 only](L
|
|||||||
|
|
||||||
Missing user storage is persisted as an empty database; no shared credential is imported or synchronized. Existing valid v1 user records retain their accounts, roles, IDs, verifiers and keys without a schema change. HTTPS `web_sec/material` upgrades valid 1,392-byte v1 storage to 1,340-byte TLS-only v2, retaining exact certificate/key DER, fingerprint and generation, and committing before publication. Invalid records or migration failures fail closed rather than triggering fallback replacement. `web certificate rotate --force` remains available; `web reset --force` replaces TLS identity only, not users.
|
Missing user storage is persisted as an empty database; no shared credential is imported or synchronized. Existing valid v1 user records retain their accounts, roles, IDs, verifiers and keys without a schema change. HTTPS `web_sec/material` upgrades valid 1,392-byte v1 storage to 1,340-byte TLS-only v2, retaining exact certificate/key DER, fingerprint and generation, and committing before publication. Invalid records or migration failures fail closed rather than triggering fallback replacement. `web certificate rotate --force` remains available; `web reset --force` replaces TLS identity only, not users.
|
||||||
|
|
||||||
**Downgrade warning:** older v1-only firmware cannot read v2 HTTPS material. Logical NVS replacement is not a secure flash wipe; historical plaintext credentials can remain in flash. This cleanup requires no factory/partition erase. See [implementation and evidence limits](docs/legacy_credential_removal.md); final integration build evidence is pending.
|
**Downgrade warning:** older v1-only firmware cannot read v2 HTTPS material. Logical NVS replacement is not a secure flash wipe; historical plaintext credentials can remain in flash. This cleanup requires no factory/partition erase. See [consolidated compatibility and evidence limits](docs/roadmap.md#phase8-legacy-credential-compatibility); Phase 8 acceptance is recorded there.
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Only the lwIP backend needs this fix. Keep the managed component immutable.
|
||||||
|
function(project_mdns_membership_overlay)
|
||||||
|
if(CONFIG_MDNS_NETWORKING_SOCKET)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
idf_component_get_property(mdns_dir espressif__mdns COMPONENT_DIR)
|
||||||
|
idf_component_get_property(mdns_lib espressif__mdns COMPONENT_LIB)
|
||||||
|
idf_component_get_property(mdns_version espressif__mdns COMPONENT_VERSION)
|
||||||
|
if(NOT mdns_version STREQUAL "1.12.0")
|
||||||
|
message(FATAL_ERROR "mDNS membership overlay requires component version 1.12.0; review upstream")
|
||||||
|
endif()
|
||||||
|
idf_build_get_property(python PYTHON)
|
||||||
|
set(helper "${CMAKE_CURRENT_LIST_DIR}/mdns_membership.py")
|
||||||
|
set(original "${mdns_dir}/mdns_networking_lwip.c")
|
||||||
|
set(overlay "${CMAKE_BINARY_DIR}/mdns_membership/mdns_networking_lwip.c")
|
||||||
|
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||||
|
"${helper}" "${original}" "${mdns_dir}/idf_component.yml" "${overlay}")
|
||||||
|
execute_process(COMMAND "${python}" "${helper}" "${mdns_dir}" "${overlay}"
|
||||||
|
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error)
|
||||||
|
if(NOT result EQUAL 0)
|
||||||
|
message(FATAL_ERROR "mDNS membership overlay failed: ${output}${error}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
get_target_property(sources ${mdns_lib} SOURCES)
|
||||||
|
get_target_property(source_dir ${mdns_lib} SOURCE_DIR)
|
||||||
|
set(replaced 0)
|
||||||
|
set(updated_sources)
|
||||||
|
foreach(source IN LISTS sources)
|
||||||
|
get_filename_component(absolute "${source}" ABSOLUTE BASE_DIR "${source_dir}")
|
||||||
|
if(absolute STREQUAL original)
|
||||||
|
list(APPEND updated_sources "${overlay}")
|
||||||
|
math(EXPR replaced "${replaced} + 1")
|
||||||
|
else()
|
||||||
|
list(APPEND updated_sources "${source}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
if(NOT replaced EQUAL 1)
|
||||||
|
message(FATAL_ERROR "Expected exactly one mDNS lwIP target source, found ${replaced}; review upstream CMake")
|
||||||
|
endif()
|
||||||
|
set_property(TARGET ${mdns_lib} PROPERTY SOURCES "${updated_sources}")
|
||||||
|
message(STATUS "mDNS 1.12.0: using guarded build-local multicast membership fix")
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
project_mdns_membership_overlay()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the narrowly guarded mDNS 1.12.0 membership overlay; never edit upstream."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
SOURCE_SHA256 = "adc139fa504a925ab644f21f8dce3659927f534e390a176b72b0ae3206c6a3ea"
|
||||||
|
OLD_DEINIT = """ s_interfaces[tcpip_if].proto &= ~(ip_protocol == MDNS_IP_PROTOCOL_V4 ? PROTO_IPV4 : PROTO_IPV6);
|
||||||
|
if (s_interfaces[tcpip_if].proto == 0) {
|
||||||
|
s_interfaces[tcpip_if].ready = false;
|
||||||
|
join_group(tcpip_if, ip_protocol, false);
|
||||||
|
"""
|
||||||
|
NEW_DEINIT = """ int proto = (ip_protocol == MDNS_IP_PROTOCOL_V4 ? PROTO_IPV4 : PROTO_IPV6);
|
||||||
|
if (!(s_interfaces[tcpip_if].proto & proto)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
join_group(tcpip_if, ip_protocol, false);
|
||||||
|
s_interfaces[tcpip_if].proto &= ~proto;
|
||||||
|
if (s_interfaces[tcpip_if].proto == 0) {
|
||||||
|
s_interfaces[tcpip_if].ready = false;
|
||||||
|
"""
|
||||||
|
OLD_INIT = """ err = pcb_init();
|
||||||
|
if (err) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
NEW_INIT = """ err = pcb_init();
|
||||||
|
if (err) {
|
||||||
|
join_group(tcpip_if, ip_protocol, false);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def generate(component: Path, output: Path) -> None:
|
||||||
|
manifest = (component / "idf_component.yml").read_text()
|
||||||
|
if re.findall(r"^version:\s*(\S+)\s*$", manifest, re.MULTILINE) != ["1.12.0"]:
|
||||||
|
raise ValueError("mDNS membership overlay requires exactly version 1.12.0; review upstream")
|
||||||
|
original = (component / "mdns_networking_lwip.c").read_bytes()
|
||||||
|
if hashlib.sha256(original).hexdigest() != SOURCE_SHA256:
|
||||||
|
raise ValueError("mDNS networking source SHA-256 mismatch; review upstream, do not bypass guard")
|
||||||
|
patched = original.decode("utf-8")
|
||||||
|
for old, new in ((OLD_DEINIT, NEW_DEINIT), (OLD_INIT, NEW_INIT)):
|
||||||
|
if patched.count(old) != 1:
|
||||||
|
raise ValueError("mDNS membership replacement must match exactly once")
|
||||||
|
patched = patched.replace(old, new, 1)
|
||||||
|
result = patched.encode("utf-8")
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not output.exists() or output.read_bytes() != result:
|
||||||
|
output.write_bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("component", type=Path)
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
generate(args.component, args.output)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
parser.exit(1, f"mDNS membership overlay: {error}\n")
|
||||||
@@ -104,7 +104,7 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
|
|||||||
|
|
||||||
### HTTPS, WebSocket, and web serial
|
### HTTPS, WebSocket, and web serial
|
||||||
|
|
||||||
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
|
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity and IDF's dual-stack listener. Shared Host/Origin parsing accepts canonical bracketed IPv6 literals (no interface zones) while preserving same-origin/session binding; DNS discovery does not confer certificate trust. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 40. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
|
||||||
|
|
||||||
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
|
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
|
||||||
|
|
||||||
@@ -122,11 +122,17 @@ Cookie login/logout replaces Basic/cache. Digest-only records carry copied princ
|
|||||||
|
|
||||||
`web_server_replace_identity` reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. [HTTPS ownership, generation and recovery contract](../web_administration.md#https-and-reboot).
|
`web_server_replace_identity` reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. [HTTPS ownership, generation and recovery contract](../web_administration.md#https-and-reboot).
|
||||||
|
|
||||||
|
### Admin firmware upload
|
||||||
|
|
||||||
|
`web_firmware_update` streams a raw admin-only application image through HTTPD into the inactive OTA slot using standard ESP-IDF APIs. Existing Origin/CSRF/session checks precede body IO; raw length is bounded before HTTPD's narrowed length is trusted, SDK image validation and final principal currentness precede boot selection. One 4KiB buffer and a transient reboot task bound application storage; NVS and partition layout are untouched. HTTPD is occupied during upload, so normal web traffic can stall; deadlines bound receive progress, not flash-operation latency.
|
||||||
|
|
||||||
|
The server transition and identity reservations exclude competing lifecycle work. A separate atomic gate excludes ordinary reboot paths without depending on HTTPS initialization, preserving UART0 recovery. Successful response schedules delayed reset while retaining reservations; failed response after selection retains the selected-image latch but releases resources for manual reboot. A subsequent upload is refused until reset. No automatic retry, rollback, signing infrastructure or dependency patching. [Contract and validation limits](../roadmap.md#phase-10--simple-admin-web-firmware-upload).
|
||||||
|
|
||||||
### SSH
|
### SSH
|
||||||
|
|
||||||
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
|
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
|
||||||
|
|
||||||
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
|
`ssh_transport` uses wolfSSH on port 22 with one explicitly dual-stack IPv6 wildcard listener and two fixed session/handshake slots. Peer formatting preserves IPv6 interface scope; IPv4 shares the listener, not a second socket. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
|
||||||
|
|
||||||
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
|
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
|
||||||
|
|
||||||
@@ -179,13 +185,15 @@ For SSH, standard output/error is redirected to the invoking session's bounded o
|
|||||||
|
|
||||||
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||||
|
|
||||||
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed, but browser self/generated/key/recovery and restricted network/SSH shell actions are not; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. [Browser policy and recovery](../web_administration.md#browser-shell-policy).
|
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed. Canonical Wi-Fi commands are also allowed, including hidden secret prompts and disruptive changes; these use the ordinary owner queue and may cut off the browser before output arrives, without a deferred acknowledgement guarantee. Browser self/generated/key/recovery, mDNS mutations and restricted SSH shell actions remain denied; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. [Browser policy and recovery](../web_administration.md#browser-shell-policy).
|
||||||
|
|
||||||
## Wi-Fi and persistence
|
## Wi-Fi and persistence
|
||||||
|
|
||||||
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
|
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
|
||||||
|
|
||||||
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
|
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP/address deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. One-second reconciliation reads authoritative driver/netif state in TCP/IP context, enables STA SLAAC and creates link-local addresses without restarting DAD. IPv4 or preferred IPv6 (including link-local-only) establishes ONLINE and may retire the fallback AP; ONLINE does not imply a default route or Internet access. DHCPv4 option 12 carries the full `sak-<suffix>` from permanent bounded hostname storage installed before connection; rename updates subsequent exchanges without restarting DHCP. The bounded snapshot retains up to three preferred numeric IPv6 addresses from the same observation as its availability flags; compile-time capacity checks reject larger lwIP address-slot settings. `wifi status` formats these addresses and their scope. Web Network settings encode up to three fixed-width strings from the same snapshot and display link-local/ULA/GUA groups within a 2304-byte JSON bound; the overview/OLED retains availability-only reporting.
|
||||||
|
|
||||||
|
`mdns_service` initializes the responder at most once after either-family STA readiness. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. HTTPS/SSH owners publish availability under a short portMUX, and only the Wi-Fi owner reconciles the two STA-only DNS-SD records and address families, including while offline. Component calls run outside project service mutexes. Missing-family disable is reasserted every pass and present-family enable/reprobe every 30 seconds because upstream actions can silently drop and expose no readiness acknowledgement. This is eventual convergence, not instantaneous withdrawal: a transient stale/zero-A window can occur after DHCP loss, and AAAA follows upstream valid-address semantics including deprecated addresses. A version/hash-guarded build-local mDNS 1.12.0 source overlay balances per-family multicast membership and join cleanup; managed source stays immutable. Tests and upgrade/removal contract: `tests/mdns_membership/README.md`. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
|
||||||
|
|
||||||
Persistent namespaces/blobs include:
|
Persistent namespaces/blobs include:
|
||||||
|
|
||||||
@@ -231,4 +239,4 @@ Hardware diagnostics are synchronous console commands. RS-232 tests own the phys
|
|||||||
|
|
||||||
`user_database_init(load_result)` has no credential input. Missing storage is persisted empty; `user_database_recover_empty()` is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private `v1_admin_marker` retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains.
|
`user_database_init(load_result)` has no credential input. Missing storage is persisted empty; `user_database_recover_empty()` is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private `v1_admin_marker` retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains.
|
||||||
|
|
||||||
`web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: [legacy compatibility](../legacy_credential_removal.md).
|
`web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: [legacy compatibility](../roadmap.md#phase8-legacy-credential-compatibility).
|
||||||
|
|||||||
+17
-3
@@ -12,6 +12,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
- Dependencies: every subsystem initializer
|
- Dependencies: every subsystem initializer
|
||||||
- Lifecycle constraint: optional display/network failures should not remove UART0 administrative recovery or USB UART1 access; the custom administration frontend starts only after command registration.
|
- Lifecycle constraint: optional display/network failures should not remove UART0 administrative recovery or USB UART1 access; the custom administration frontend starts only after command registration.
|
||||||
|
|
||||||
|
## Small Phase 9 operating baseline
|
||||||
|
|
||||||
|
- `sdkconfig.defaults` explicitly selects no core dumps and silent panic reboot; existing generated configs can override defaults. README documents verification. No dependency overlays, custom crypto policy or version changes are part of this phase.
|
||||||
|
- User accepted the reduced Phase 9 on 2026-09-18 and waived a new hardware check; this is not a claim of a new hardware-test pass.
|
||||||
|
- [Operational checklist](../security_operations.md) and [scope/acceptance](../roadmap.md#phase-9--security-and-production-hardening): intermittent trusted-network use, unchanged authentication/recovery, no physical-extraction protection. Keep upgrades separate and do not restore the abandoned extensive patchset.
|
||||||
|
|
||||||
## Secure randomness
|
## Secure randomness
|
||||||
|
|
||||||
**Responsibility:** provide the sole project-owned, mutex-serialized application DRBG, seeded before Wi-Fi/radio use.
|
**Responsibility:** provide the sole project-owned, mutex-serialized application DRBG, seeded before Wi-Fi/radio use.
|
||||||
@@ -64,7 +70,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
|
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
|
||||||
- Ordinary HTTPS idle cleanup: `src/web_httpd_idle.{c,h}`, owner sweep in `web_httpd_adapter.{c,h}`, lifecycle/TLS composition in `web_server.c`; `tests/web_httpd_idle/run.py`. Independent of diagnostics/optional transports: 15-second observed idle, one-second timer/one queued probe, six rows, actual WS/async/pending exemptions, safe current-owner shutdown and stop/restart fencing. No LRU/socket/timeout/stack increase. SDK queue/owner-delay and regression contract: `docs/https_idle_cleanup.md`.
|
- Ordinary HTTPS idle cleanup: `src/web_httpd_idle.{c,h}`, owner sweep in `web_httpd_adapter.{c,h}`, lifecycle/TLS composition in `web_server.c`; `tests/web_httpd_idle/run.py`. Independent of diagnostics/optional transports: 15-second observed idle, one-second timer/one queued probe, six rows, actual WS/async/pending exemptions, safe current-owner shutdown and stop/restart fencing. No LRU/socket/timeout/stack increase. SDK queue/owner-delay and regression contract: `docs/https_idle_cleanup.md`.
|
||||||
- Independent throughput diagnostics: `web_serial_transport.{c,h}` owns two fixed per-slot binary-TX aggregates and epoch fences; `web_console.c` exposes default-disabled `web performance enable|disable|show|clear`. Queue-entry/callback-entry, synchronous-send and completion/drain-return estimates, not peer receipt or scheduler-only latency. `tests/web_serial_performance/run.py`; resource/evidence limits and UART0 paired capture: `docs/web_throughput_diagnostics.md`.
|
- Independent throughput diagnostics: `web_serial_transport.{c,h}` owns two fixed per-slot binary-TX aggregates and epoch fences; `web_console.c` exposes default-disabled `web performance enable|disable|show|clear`. Queue-entry/callback-entry, synchronous-send and completion/drain-return estimates, not peer receipt or scheduler-only latency. `tests/web_serial_performance/run.py`; resource/evidence limits and UART0 paired capture: `docs/web_throughput_diagnostics.md`.
|
||||||
- Storage compatibility: `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: `docs/legacy_credential_removal.md`.
|
- Storage compatibility: `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: [legacy compatibility](../roadmap.md#phase8-legacy-credential-compatibility).
|
||||||
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
|
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
|
||||||
- HTTP policy/UI: `web_cookie_auth` + `web_auth_parse` enforce bounded cookie/Origin/CSRF/admin admission; `web_login_ui.{c,h}` serves login, `web_ui.c` owns session-fenced Serial/Admin/Settings and shared quick controllers. Tests: `tests/web_cookie_auth/run.py` (domain variants), `tests/web_auth_parse/run.py`, `tests/web_login_ui/run.py`, `tests/web_ui_session/run.py`.
|
- HTTP policy/UI: `web_cookie_auth` + `web_auth_parse` enforce bounded cookie/Origin/CSRF/admin admission; `web_login_ui.{c,h}` serves login, `web_ui.c` owns session-fenced Serial/Admin/Settings and shared quick controllers. Tests: `tests/web_cookie_auth/run.py` (domain variants), `tests/web_auth_parse/run.py`, `tests/web_login_ui/run.py`, `tests/web_ui_session/run.py`.
|
||||||
- Admission diagnostics: `web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`; six post-TLS records/32-event opt-in ring, no HTTPD off-owner inspection. [Contract](../web_admission_diagnostics.md).
|
- Admission diagnostics: `web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`; six post-TLS records/32-event opt-in ring, no HTTPD off-owner inspection. [Contract](../web_admission_diagnostics.md).
|
||||||
@@ -82,10 +88,16 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
### Browser admin backend
|
### Browser admin backend
|
||||||
|
|
||||||
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
|
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
|
||||||
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 39 URI handlers.
|
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 40 URI handlers.
|
||||||
- Currentness/policy: `admin_ssh_console_open_available()` shares two slots with runtime SSH; transport-qualified tokens and owner adapters revalidate outside console locks before commands/prompts. Parsed browser policy remains narrower than typed Settings; [shell contract](../web_administration.md#browser-shell-policy). Tests: `tests/admin_console_boundary/{run,accounts,lifecycle}.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/run.py --tickets`, `tests/web_cookie_auth/run.py --admin`.
|
- Currentness/policy: `admin_ssh_console_open_available()` shares two slots with runtime SSH; transport-qualified tokens and owner adapters revalidate outside console locks before commands/prompts. Parsed browser policy remains narrower than typed Settings; [shell contract](../web_administration.md#browser-shell-policy). Tests: `tests/admin_console_boundary/{run,accounts,lifecycle}.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/run.py --tickets`, `tests/web_cookie_auth/run.py --admin`.
|
||||||
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
|
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
|
||||||
|
|
||||||
|
## Firmware upload
|
||||||
|
|
||||||
|
- `src/web_firmware_update.{c,h}`: admin raw `POST /api/firmware`, cookie/Origin/CSRF admission via `web_cookie_auth`, registration in `web_server`, UI in `web_ui`. Standard SDK OTA into inactive app only; bounded4KiB buffer, validated raw length/header/final image and principal before boot selection. No NVS/layout writes or vendor patches.
|
||||||
|
- HTTPD handles streaming synchronously; upload is deliberately disruptive to web traffic. Server/identity reservations fence lifecycle changes; atomic ordinary-reboot gate also covers console/SSH/browser/button reset paths. Success-response schedules delayed reset; response failure after selection latches uploads until deliberate manual reboot. No blind retries.
|
||||||
|
- Tests: `tests/web_firmware_update/run.py` (active-build SDK5.5.0 headers and actual begin/abort contract), `tests/web_ui_session/run.py`, existing auth/lifecycle tests. [Usage, acceptance limits and reusable regression checks](../roadmap.md#phase-10--simple-admin-web-firmware-upload). Phase 10 complete by explicit user acceptance on 2026-09-18: upload works and normal operation verified; no specific fault/NVS-comparison/power-loss/recovery pass implied. Initial install by wire; subsequent upload is application `firmware.bin`, not full-flash image.
|
||||||
|
|
||||||
## Typed settings source and regression map
|
## Typed settings source and regression map
|
||||||
|
|
||||||
HTTPD reads zero-wait projections and queues only IDs to the existing dispatcher. One original-login slot per domain; canonical owners compare/reserve at execution. [API/lifetime and failure contracts](../web_administration.md#typed-settings-api-and-operation-lifetime).
|
HTTPD reads zero-wait projections and queues only IDs to the existing dispatcher. One original-login slot per domain; canonical owners compare/reserve at execution. [API/lifetime and failure contracts](../web_administration.md#typed-settings-api-and-operation-lifetime).
|
||||||
@@ -146,7 +158,9 @@ Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtur
|
|||||||
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
|
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
|
||||||
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
|
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
|
||||||
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics
|
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics
|
||||||
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only.
|
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only. One-second owner reconciliation handles DHCP hostname updates, preferred IPv4/IPv6 readiness, missed events and mDNS records/families. `ONLINE` includes preferred link-local-only IPv6 and may retire fallback AP without IPv4; snapshots retain IPv6 availability flags plus up to three preferred numeric addresses from the same observation. `wifi status` and web Network settings render those addresses; overview/OLED remain availability-only. The typed runtime adds `ipv6_addresses` (up to three fixed-width strings); backend and UI share a 2304-byte response bound. Regression coverage includes byte order, address clearing and the compile-time slot-capacity bound.
|
||||||
|
- Phase 12: `mdns_service_set_https_available()` / `mdns_service_set_ssh_available()` publish under a short portMUX; only the Wi-Fi owner calls component APIs. STA DNS-SD has two records; family repair re-probes available families every 30 seconds because upstream action admission has no reliable acknowledgement. AAAA uses valid (including deprecated) upstream addresses. DHCPv4 uses full `sak-<suffix>` before connection and in later exchanges after rename; permanent raw-netif hostname storage is updated only in TCP/IP context.
|
||||||
|
- Focused tests: `tests/wifi_phase12`, `tests/mdns_phase12`, `tests/ssh_phase12`, `tests/mdns_membership`; HTTPS IPv6 authority cases live in `tests/web_auth_parse` and cookie suites. Build-local `cmake/mdns_membership.*` replaces only reviewed mDNS 1.12.0 networking source to balance multicast membership; version/hash drift fails configuration. No managed source is edited.
|
||||||
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots.
|
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots.
|
||||||
|
|
||||||
## Local display and controls
|
## Local display and controls
|
||||||
|
|||||||
+56
-29
@@ -1,44 +1,71 @@
|
|||||||
# Current project state
|
# Current project state
|
||||||
|
|
||||||
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
|
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), [architecture](architecture.md) and [decisions](design-decisions.md).
|
||||||
|
|
||||||
## Web popup cosmetics — 2026-09-14
|
## Phase 12 COMPLETE — explicit user validation
|
||||||
|
|
||||||
- `src/web_ui.c`: contextual buttons now occupy entire status cards; bounded grid columns and clipped/ellipsized values prevent narrow-window overflow. Quick panels preserve the visible terminal title, admin toolbar and selected-view indication; full-page promotion retains drafts and restores Settings presentation.
|
- User states: “I just validated Phase 12 successfully.” Record acceptance, not a pending phase. Canonical device evidence and reusable regression guidance: [Phase 12](../roadmap.md#phase-12--advanced-network-integration). No unreported DHCP capture, DNS-zone update, exhaustive address/service transition, fault/soak or byte-integrity pass is implied.
|
||||||
- Quick panels hide full-page explanations and duplicate detail readouts while preserving live status, errors and operation results. Wi-Fi exposes configured profile selection/enabled state and AP policy, plus Apply/Save/Refresh/Result; empty profile slots and advanced editors remain full-page-only. Profile selection is an editing target, not a direct-connect command; existing backend operations are unchanged.
|
- User validated IPv6 ping/HTTPS and Avahi AAAA; Fedora authselect dual-family mDNS resolved the normal hostname lookup issue. Fresh-boot and full-mix captures show healthy reported service lifecycle. At 230400 baud 8N1 RTS/CTS: two SSH sessions with IPv6 ULA peers (user observer/admin console), two serial WebSockets (writer/observer), active browser admin, USB observer; four broker clients, one writer, zero pending/event counts at capture.
|
||||||
- Validation: all 161 UI behavior groups and C/HTML checks PASS; 36 Chromium geometry fixtures PASS at 320/600/900/1200px. `pio run` PASS (94,212 B linked RAM / 1,830,329 B flash). No generated-asset regeneration, upload, erase or hardware validation. Updated regression coverage in `tests/web_ui_session/`.
|
- Memory table preserved in roadmap. Fresh internal8/DMA/PSRAM free: 65,880 / 58,124 / 8,196,732 B. Full-mix free: 35,408 / 27,652 / 8,111,952 B; lifetime minima: 8,528 / 772 / 8,072,612 B; largest blocks: 22,528 / 22,528 / 7,995,392 B. Capability pools overlap; minima are conservative per-region lifetime sums, not simultaneous reserves. DMA772 remains a watch item, not proof of OOM or an acceptance blocker. SSH stack20,480B minimum-free18,476→16,284B.
|
||||||
|
- SSH two successful handshakes, no auth/handshake/timeouts/I/O failures; RX68 accepted65 rejected3, TX161,209B, broker revocations2. Web serial RX27B accepted, TX318,230B across782 frames; send/queue/protocol failures0. Browser admin RX106/TX3,721B with no reported send/queue failures. Do not reinterpret nonzero rejected bytes/writer denials as proven transport loss or claim zero UART/observer drops without their counters.
|
||||||
|
- Boot TLS -0x004C receive errors and auth-failure counts have no demonstrated cause. Snapshots are non-atomic and counts are not an aligned interval. This handoff changes documentation only; no new build/test/upload/erase/device operation/commit; hardware directory untouched. Prior latest build94,444B RAM/1,854,485B flash and UI174+CSP remain historical validation.
|
||||||
|
|
||||||
## Focused cleanup / PSRAM review — 2026-09-13
|
## Follow-up — IPv6 addresses in web Network settings
|
||||||
|
|
||||||
- User-authorized post-acceptance code review: removed superseded `admin_ssh_console_open()` SSH-only wrapper; production already uses available-slot admission. Updated adapter regression to actual production entry. Wrapper was already linker-discarded: no binary saving attributed to removal.
|
- User confirms direct IPv6 ping/HTTPS and Avahi AAAA lookup for `sak-1024.local` succeed. Client NSS uses `mdns4_minimal [NOTFOUND=return]`; systemd-resolved explicit mDNS reports no eligible networks. This supports a client resolver integration issue, not failed firmware AAAA publication on the tested Avahi path. The user subsequently enabled dual-family mDNS through Fedora authselect and confirmed success; see acceptance above.
|
||||||
- Moved ping queue payload4,200 B and public user snapshot2,156 B to lazy PSRAM-only lifetime allocations, no fallback. Queue control/internal locks unchanged; allocation failure affects only ping or status/list/show, not UART0 registration/mutations/recovery. Snapshot fully wiped on success/error. No serial hot-path, stacks, CPU, queue bounds or external-BSS config changes.
|
- Added bounded runtime `ipv6_addresses` (<=3 fixed-width lowercase eight-hextet strings) to existing admin Network snapshot, grouped as link-local/ULA/GUA in Settings. Overview/OLED remain flags-only. No new netif call, allocation, task or snapshot storage. Backend/client JSON bound 2048→2304 (+256B response stack); maximum-escaped fixture with all three addresses is 2067B. Strict UI validation/text-only output and existing session fences preserved; generated assets untouched.
|
||||||
- Baseline pio PASS23.79s100,556 RAM/1,828,573 flash. Final parent pio PASS22.11s **94,212 RAM/1,828,809 flash: −6,344 B linked internal RAM/+236 B flash**. Requested lazy PSRAM6,356 B plus allocator overhead; target pointer sizes4 B, controls84 B verified. This is not measured runtime-minimum improvement. CPU160 retained.
|
- `pio run` PASS **94,444 B linked RAM / 1,854,485 B flash**, +0/+800 versus prior CLI-address build. Network cookie/owner regressions and UI174+CSP PASS. Initial cookie fixture had an obsolete 2048B output buffer; fixed to use the production bound and rerun PASS. Empty/full address lists, byte order, bad counts, malformed/injected UI entries and clearing covered. No upload or hardware operations; hardware directory untouched.
|
||||||
- Focused ping allocation/callback/end-capacity and accounts allocation/full-wipe/failure/retry tests PASS; console boundary/lifecycle/policy and SSH management/runtime suites PASS. Independent review found no actionable bugs and reran ping/accounts/boundary/diff PASS. Sanitizer linking unavailable (missing host runtimes), no sanitizer or hardware pass. No upload/erase/commit. Next target check: ping/user list/show repeatedly alongside NVS writes and full mix230400, collect serial/broker counters and memory before/after first allocations.
|
|
||||||
- Further audited opportunities, not implemented: OLED framebuffer1,024 B (internal I2C staging retained), local/remote completion scratch1,024 B each, optional web diagnostic ring2,816 B, remote console output payload8,192 B. Console rings need explicit secret-output wiping/lifecycle and admission-failure isolation; do not relocate mixed state wholesale. Leave authoritative user database, driver/DMA buffers, locks and task stacks internal. Global external-BSS enable is not surgical: it also changes SDK library placement.
|
|
||||||
|
|
||||||
## Accepted state — 2026-09-13
|
## Follow-up — browser Wi-Fi controls and IPv6 diagnosis
|
||||||
|
|
||||||
- **8D.22 explicitly signed off by the user:** “Yep, I tested the firmware thats a 8d.22 signoff.” The retained Phase 8D scope is complete; earlier per-slice pending target/review/integration gates are superseded. Roadmap already records 8A/B/C as complete and target-hardware validated, so **Phase 8 is complete**. Acceptance does not manufacture individual unreported test passes.
|
- User requests browser Admin shell Wi-Fi parity with SSH/typed settings. Removed only the Wi-Fi status-only policy gate; canonical settings/lifecycle/persistence/diagnostics and hidden secret prompts now work. mDNS remains status-only and unrelated restrictions stay intact. Disruptive commands may cut off the response; owner admission is not peer acknowledgement or cancellation on disconnect.
|
||||||
- [Roadmap](../roadmap.md#phase-8--role-based-users-and-administrative-access--complete) holds the completed-phase gist; [web administration](../web_administration.md) holds current API/owner/recovery contracts; [acceptance evidence](../web_administration_acceptance.md) holds the latest report and limits. The old plan/baseline/per-slice histories are consolidated, not archived as another timeline. Test READMEs now link directly to current contracts and evidence; obsolete forwarding notes were removed too.
|
- User now confirms the CLI displays an IPv6 address, direct IPv6 ping works, and the web interface opens using IPv6. This validates those reported unicast operations, not all Phase 12 checks. `ping sak-1024.local -6` fails on the client with “Die Adressfamilie für Hostnamen wird nicht unterstützt.” That earlier client failure was subsequently resolved through Fedora authselect dual-family mDNS; Avahi AAAA lookup and normal IPv6 hostname access succeeded. Do not reopen this as an established firmware discovery defect.
|
||||||
- Final prior production build **PASS: 100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. No build/test/device execution is implied by this documentation update. The prior combined binary WebSocket-send fix was separately user-accepted at **160 MHz / 230400 baud with full mix including browser admin**; preserve combined send and bounded failed-send isolation.
|
- Added three bounded preferred numeric addresses to manager snapshot, copied/cleared with the same TCP/IP observation, and labelled output in `wifi status`. Web/OLED schema unchanged. +48 bytes per snapshot/settings copy and shared static storage; compile rejects >3 lwIP slots. No new allocation/task/socket. Latest `pio run` PASS **94,444 B RAM / 1,853,685 B flash** (+48/+660 vs initial Phase12). No upload/device operations.
|
||||||
- Latest loaded capture: two serial WS, USB, two SSH roles with SSH serial writer; browser admin used then closed, not active in the capture. Internal/DMA/PSRAM free **31,508 / 23,752 / 8,136,624 B**, minima **2,052 / 460 / 8,065,972 B**, largest **18,432 / 18,432 / 7,995,392 B**; SSH minimum-free stack **15,028 B**. Full boot/loaded table is in acceptance evidence. Web send/queue/protocol and SSH IO errors zero; one SSH handshake failure/session revocation. Missing latest broker/serial counters prohibit an exact zero-drop inference.
|
- PASS: browser policy, new actual Wi-Fi secret-handler/browser-prompt fixture (cancellation/revocation/wiping/history/role guards), account/lifecycle boundary, preferred-address lifecycle/byte-order/rendering/capacity, Network settings, cookie Network and UI171+CSP, diff check. Broader `admin_console_boundary/run.py` passes console/certificate then fails existing SSH-adapter compilation due missing `web_firmware_update_reserve_reboot` fake; left unrelated fixture unchanged. ASan/UBSan unavailable at host link. Hardware directory untouched.
|
||||||
|
|
||||||
## Follow-ups, not acceptance blockers
|
## Phase 12 implementation history — superseded by acceptance above
|
||||||
|
|
||||||
- Extremely low internal/DMA lifetime minima warrant correlated transient-headroom investigation; overlapping capability pools and conservative/non-simultaneous region minima do not prove allocation failure. Numeric reserves, HTTPD/dispatcher stack margins, peak correlation and detailed soak/fault evidence remain unapproved/unreported. Do not reopen functional sign-off or invent a reserve threshold.
|
- User authorized the agreed DHCPv4 hostname / dual-stack SLAAC / STA DNS-SD baseline. Another agent owns `hardware/`; this work did not read or edit it. Do not commit or revert that agent's work. No upload, erase or device operation performed.
|
||||||
- SDK TLS `-0x004C` is generic NET_RECV_FAILED, not OOM. Two boot auth failures plausibly involve stale cookies, but causation is unconfirmed. Prior intermittent web admission issues and accepted idle cleanup do not justify claiming every admission failure fixed.
|
- Wi-Fi owner applies full `sak-<suffix>` before DHCP and on rename for future exchanges, using permanent TCP/IP-owned storage to preserve the existing 59-byte hostname maximum beyond IDF's setter limit. Preferred IPv6 link-local/ULA/GUA can establish ONLINE without IPv4; link-local-only can retire fallback AP. One-second reconciliation handles stale/missed events. Web/OLED expose availability; the follow-up above adds actual preferred addresses to CLI output.
|
||||||
- Real DNS/reannouncement, NVS power-loss, browser geometry/accessibility, individual fault cases and exact duration claims require explicit evidence if investigated; retained [regression procedures](../user_administration_tests.md) are not execution records.
|
- HTTPS default listener verified dual-stack; shared authority parser now strictly canonicalizes bracketed IPv6 without zone IDs. SSH uses one explicitly dual-stack listener with scope-safe peers, preserving two slots. Service owners publish availability through short portMUX sections; Wi-Fi owner reconciles two DNS-SD records and address families. No new task/socket/broker slot/serial payload buffer, dependency version, partition or generated asset change.
|
||||||
- **Next: Phase 9 security/production hardening only if requested.** No source, configuration, test implementation, build, upload, erase, branch or commit is authorized by documentation consolidation.
|
- mDNS action API can silently drop queued work; missing families disable each pass, available families repair/re-probe every 30 seconds. Transient stale/zero A window remains until processed; AAAA follows valid-address semantics including deprecated addresses. Upstream calls can block, so polling is not a hard deadline. The later user acceptance establishes the reported device/AAAA behavior, not exhaustive multicast fault/transition testing.
|
||||||
|
- New **narrow** `cmake/mdns_membership.*` overlay fixes verified mDNS 1.12.0 per-family multicast reference imbalance and failed-PCB join cleanup. Only a build-local source copy is changed; version/source hash guarded, managed source immutable. This is not the abandoned Phase 9 patch set. Membership/CMake tests include negative controls and repeated transitions.
|
||||||
|
- Final `pio run` PASS: **94,396 B RAM / 1,853,025 B flash** (+176 / +5,380 versus recorded Phase 10); not runtime headroom. Local saved sdkconfig mDNS capacity changed to 2; durable defaults pin IPv4/IPv6 and two services. Initial bool-atomic target failure fixed with portMUX; final target build includes overlay. Existing SDK Kconfig notes remain.
|
||||||
|
- Host suites PASS: Wi-Fi, mDNS, membership, SSH dual-stack and existing management/runtime/security, auth parser689 (host + actual lwIP), cookie variants, Network settings (after updated netif fakes), UI171+CSP, HTTPS lifecycle45+status8+identity, firmware88+SDKcontract, broker diagnostics and session-store/serial. No network packet, hardware, fault-injection or new high-speed serial pass is implied. Canonical contracts, limits and device checklist: [Phase 12](../roadmap.md#phase-12--advanced-network-integration).
|
||||||
|
|
||||||
## Scope and safety to retain
|
## Phase 10 COMPLETE — explicit user acceptance, 2026-09-18
|
||||||
|
|
||||||
- 8D.15 dedicated typed network diagnostics was removed; shell diagnostics remain subject to frontend policy. Unimplemented 8D.19 ordinary browser-session/native-USB controls were removed; SSH settings remain. No implicit full browser-shell parity or browser identity reset/recovery/export.
|
- User confirmed after firmware upload implementation and the concise-UI fix: “That works perfectly. And the usual operation is also verified.” Acceptance establishes that upload works and normal operation is verified. Do not infer specific fault-injection, NVS before/after comparisons, power-loss or recovery passes. The roadmap's compact regression guidance is reusable, not an acceptance blocker.
|
||||||
- One UART1 broker writer, isolated observers and binary transparency. UART0 is administrative recovery; native USB is network-independent UART1, not administration or uninterrupted reboot.
|
- Latest concise-UI change was copy-only; reported UI regression **169 groups PASS**. No rebuild after that text change. The integration build below is historical, not validation of a newly rebuilt UI or this documentation update.
|
||||||
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; later revocation/timeout does not cancel admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity never rolls back on lifecycle failure. Lost ACK/result means uncertainty, never automatic replay.
|
|
||||||
- Preserve private IDF HTTPD version guards, at-most-one owner-work reservations through failed destruction, retained SSH context until all slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
|
|
||||||
|
|
||||||
## Previous documentation consolidation handoff
|
- User authorized simple OTA implementation; initial Git clean. New web_firmware_update module with standard SDK APIs, raw POST /api/firmware, admin cookie/Origin/CSRF and final principal currentness. Settings → HTTPS / Reboot has File/XHR upload progress, confirmation, session fencing and no automatic retries. Only inactive app/otadata writes; NVS/layout untouched. No signatures/antirollback/automaticrollback, dependencies or generated assets changed. Consolidated procedure, contract and regression guidance: [Phase 10](../roadmap.md#phase-10--simple-admin-web-firmware-upload); standalone guide removed.
|
||||||
|
- 4KiB internal buffer + transient2048B-stack reboot owner allocated before erase; 10s stall/120s receive-loop budget, not totalflashdeadline. HTTPD synchronously blocks other web work during upload; networkserial maystall/drop, reboot disruptsall. No task/request/socket lifetime capture after handler. Service/identity reservation and atomic ordinary-reboot gate cover UI/UART0/SSH/browser/localbutton paths. Failed response after bootselect schedules noautomaticreset; selected latch rejects further uploads409, manual reboot available. Successful response schedules500ms reboot retaining reservations.
|
||||||
|
- Review fixed two actualSDK5.5.0 edge cases: failed esp_ota_begin maypublishlivehandle beforeeraseerror (abortthat handle); rawContentLength64 canwrapHTTPDsize_t32 (overflow-safe actualslotbound/strictdecimal/equality check beforebody/erase). End consumes handle evenerror. SDKvalidation followed by exactparsedimage length includingSHA; basic header requiresS3appdescriptor/hash. Unrelated old/privateSDK code unpatched.
|
||||||
|
- Parent final pio PASS **94,220 B RAM / 1,847,645 B flash**, +24RAM/+19,080flash vsPhase9, not runtimeheadroom. Parent newbackend88cases+actualSDKbeginfailurecontract, UI169groups+CSP, serverlifecycle44, admin25, consolelifecycle, SSHruntime, cookielifecyclePASS. Additionalbase/admin/display/lifecyclecookie, idle18, SSHmanagement/runtime/security agentPASS after adding missing rebootfake to adminfixture (no productionchange). Independent review final noactionablefindings; realbuilt firmware parsed with SDKmetadata bothOTAoffsets (notdeviceflashproof).
|
||||||
|
- **Scope decision (2026-09-18):** User removed the BLE transport/provisioning proposal entirely because it no longer fits the project concept. BLE is not planned; retain the existing USB, HTTPS/WebSocket and SSH transport scope. This earlier scope decision did not authorize implementation; Phase 12 was subsequently authorized and implemented as recorded above. Do not resurrect Phase 9 patches. This update changed documentation only; no build, test, upload, erase, device operation or commit was performed.
|
||||||
|
|
||||||
Initial Git status was clean. This task changes root `README.md`, `docs/` and five test-directory READMEs; executable source/tests/config/generated assets remain untouched. Independent documentation review checked acceptance scope, owner contracts and local links. It restored explicit pointer-backed HTTPD response-header lifetime and same-version SDK-patch audit warnings, updated test README links, and removed obsolete forwarding notes without reopening sign-off.
|
## Session logging scope decision
|
||||||
|
|
||||||
Validation completed: independent Python local-link/anchor audit PASS across 29 authored Markdown files (122 local links, 53 Markdown fragments); 213 authored source/test/document files checked with zero obsolete Phase 8D filename references. Earlier path audit resolved127 expanded navigation references. GPT logs, vendored/generated trees and remote URL fetching excluded. Twenty obsolete phase documents removed, with no forwarding stubs/archive dump. Git diff/scope checks confirm documentation-only changes. No firmware tests/build/device commands run.
|
- User removed the filesystem-backed session recording proposal entirely. On-device session logging/transcripts are out of scope: users capture relevant output in their SSH or web terminal instead. Avoid adding RAM pressure, flash-write interference or potentially incomplete recordings to the serial path. The reserved `storage` partition remains unchanged and unmounted; this decision does not authorize partition migration.
|
||||||
|
|
||||||
|
## Accepted state — 2026-09-18
|
||||||
|
|
||||||
|
- **Reduced Phase 9 complete by explicit user sign-off.** User waived a new whole-phase device check based on prior Phase 8 validation. Application code was unchanged, but no-core-dump/silent-panic defaults changed. Do not record the waived check as executed or claim a new panic/hardware pass.
|
||||||
|
- Small scope: standard `sdkconfig.defaults` options, source-reviewed [operational checklist](../security_operations.md), README/roadmap guidance. No dependency patches, crypto policy replacement, allocator hooks, SDK migration, encryption, eFuse or partition changes. User abandoned the extensive earlier Phase 9 and restored baseline `f40c09c`; do not resurrect it.
|
||||||
|
- Historical Phase 9 build: normal `pio run` PASS on PlatformIO6.12.0 / IDF5.5.0 / original20241119 toolchains, **94,196 B linked RAM / 1,828,565 B flash**. Generated configuration confirmed no dumps/silent panic; compilation inputs had no abandoned overlays/crypto guard. Existing generated config already selected these options. Defaults do not override saved sdkconfig; README explains verification. No new build is implied by this documentation consolidation.
|
||||||
|
- **Phase 8 complete:** 8A–C target validated; explicit 8D.22 user acceptance on 2026-09-13. Completion/telemetry and legacy compatibility now live in [roadmap acceptance](../roadmap.md#phase8-acceptance-evidence) and [storage compatibility](../roadmap.md#phase8-legacy-credential-compatibility). Separate acceptance/legacy history documents removed; active [web contracts](../web_administration.md) and [regression procedures](../user_administration_tests.md) retained.
|
||||||
|
- Post-acceptance baseline includes PSRAM-only ping payload/user snapshot allocations and refined web quick panels. Prior focused host/geometry/build checks passed; those are not additional hardware claims. Preserve lazy-allocation failure isolation and unchanged serial hot path.
|
||||||
|
|
||||||
|
## Evidence limits and follow-ups
|
||||||
|
|
||||||
|
- Previously accepted combined binary WS send: CPU160MHz / 230400 baud full mix including browser admin. The earlier Phase 8 telemetry had very low internal/DMA lifetime minima (2,052/460 B); these are nonblocking headroom follow-ups, not approved reserves or proof of simultaneous allocation failure. Full table, capture workload and counter limits are preserved in the roadmap. The latest Phase 12 full-mix minima are 8,528/772 B, with the same evidence limits.
|
||||||
|
- TLS `-0x004C` means generic NET_RECV_FAILED, not OOM. Historical authentication/admission symptoms do not establish a cause. Do not invent fault, soak, timing or power-loss passes.
|
||||||
|
- Credentials remain unencrypted; old flash contents are not erased. Intermittent trusted-network operation reduces exposure, not physical-extraction risk. Upstream upgrades are separate deliberate tasks, not an endless local backport programme.
|
||||||
|
- Phase 10 is complete by the explicit acceptance above; detailed unreported regression scenarios remain unevidenced, not completion blockers. Device operations, branch/reset, commits and dependency upgrades remain outside this documentation task.
|
||||||
|
|
||||||
|
## Contracts to preserve
|
||||||
|
|
||||||
|
- One UART1 writer, isolated observers and binary transparency. UART0 is administrative recovery; native USB is network-independent UART1, not administration or uninterrupted reboot.
|
||||||
|
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; revocation/timeout does not cancel already admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity is not rolled back on lifecycle failure. Lost acknowledgement means uncertainty, not automatic replay.
|
||||||
|
- Preserve private IDF HTTPD version guards, one outstanding owner-work reservation through failed destruction, SSH context lifetime until slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
|
||||||
|
- Removed Phase 8 scope stays removed: dedicated typed network diagnostics and ordinary browser-session/USB controls. No implicit browser-shell parity or browser identity reset/recovery/export.
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ Only constraints supported by implementation or current project documentation be
|
|||||||
|
|
||||||
**Rationale/evidence:** Serial, Wi-Fi, local UI, web security, users, and SSH security each validate schema/size and own their namespace. User/security mutations build and validate candidate state before committing it; security modules avoid silently replacing an established identity. The live user database remains internal while its 5,360-byte candidate is a persistent PSRAM-preferred allocation with internal fallback and is wiped after every transaction.
|
**Rationale/evidence:** Serial, Wi-Fi, local UI, web security, users, and SSH security each validate schema/size and own their namespace. User/security mutations build and validate candidate state before committing it; security modules avoid silently replacing an established identity. The live user database remains internal while its 5,360-byte candidate is a persistent PSRAM-preferred allocation with internal fallback and is wiped after every transaction.
|
||||||
|
|
||||||
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Legacy credential synchronization and reconciliation are removed. Missing user storage commits empty; valid user v1 bytes remain compatible, with private `v1_admin_marker` derived from admin count, not a public bootstrap contract. HTTPS v1 (1,392 bytes) migrates through a private validated reader to TLS-only v2 (1,340 bytes), preserving exact DER/fingerprint/generation and committing before publication. Failures fail closed without fallback regeneration or overwriting rejected records. See [legacy compatibility](../legacy_credential_removal.md).
|
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Legacy credential synchronization and reconciliation are removed. Missing user storage commits empty; valid user v1 bytes remain compatible, with private `v1_admin_marker` derived from admin count, not a public bootstrap contract. HTTPS v1 (1,392 bytes) migrates through a private validated reader to TLS-only v2 (1,340 bytes), preserving exact DER/fingerprint/generation and committing before publication. Failures fail closed without fallback regeneration or overwriting rejected records. See [legacy compatibility](../roadmap.md#phase8-legacy-credential-compatibility).
|
||||||
|
|
||||||
**Relevant files:** `src/serial_config.c`, `src/wifi_config.c`, `src/mdns_config.c`, `src/mdns_service.c`, `src/local_ui_config.c`, `src/web_security.c`, `src/user_database.c`, `src/ssh_security.c`
|
**Relevant files:** `src/serial_config.c`, `src/wifi_config.c`, `src/mdns_config.c`, `src/mdns_service.c`, `src/local_ui_config.c`, `src/web_security.c`, `src/user_database.c`, `src/ssh_security.c`
|
||||||
|
|
||||||
@@ -144,13 +144,13 @@ Only constraints supported by implementation or current project documentation be
|
|||||||
|
|
||||||
## Wi-Fi callbacks enqueue; the manager owns policy
|
## Wi-Fi callbacks enqueue; the manager owns policy
|
||||||
|
|
||||||
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, reconciliation, and station mDNS announcement transitions. mDNS initializes at most once, remains allocated across transient disconnects while its component handlers withdraw/re-enable the STA interface, and treats failure as nonfatal.
|
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, reconciliation, and station mDNS announcement transitions. mDNS initializes at most once, remains allocated across transient disconnects, and treats failure as nonfatal. IPv4 or preferred IPv6 including link-local-only establishes ONLINE; route/Internet reachability is not implied. Full DHCP hostname storage and IPv6 state access belong to TCP/IP context. HTTPS/SSH owners only publish availability with short critical sections; Wi-Fi owns DNS-SD record and family reconciliation. Public mDNS actions are not acknowledged, so absent-family disables repeat every pass and healthy enables re-probe on a slower 30-second repair cadence. Record/address withdrawal is eventual, and upstream AAAA includes deprecated-but-valid addresses.
|
||||||
|
|
||||||
**Rationale/evidence:** Callback paths avoid blocking, NVS, and policy work. Manager deadlines consult authoritative driver/netif state so dropped events are recoverable.
|
**Rationale/evidence:** Callback paths avoid blocking, NVS, and policy work. Manager deadlines consult authoritative driver/netif state so dropped events are recoverable.
|
||||||
|
|
||||||
**Consequence for future changes:** Keep callbacks short and nonblocking. Add state transitions to the manager rather than directly invoking Wi-Fi policy from consoles, UI, or callbacks. Preserve queue-drop observability.
|
**Consequence for future changes:** Keep callbacks short and nonblocking. Add state transitions to the manager rather than directly invoking Wi-Fi policy from consoles, UI, or callbacks. Preserve queue-drop observability.
|
||||||
|
|
||||||
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_config.{h,c}`
|
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_config.{h,c}`. `cmake/mdns_membership.*` is a narrowly reviewed exception for mDNS 1.12.0 multicast join/leave imbalance: patch only a build-local networking source with strict version/hash guards, never the managed source. Dependency upgrades must review/remove the overlay; do not bypass its guards or restore the abandoned Phase 9 patches.
|
||||||
|
|
||||||
## Optional local UI cannot become a core dependency
|
## Optional local UI cannot become a core dependency
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ UART0 and authenticated `admin` SSH sessions use the same registered command imp
|
|||||||
|
|
||||||
Browser admin uses the same dispatcher with a [narrower parsed frontend policy](web_administration.md#browser-shell-policy), independent of typed Settings permissions. It supports bounded deferred `reboot`, `web stop`, exact `web certificate rotate --force` and owner-relative `exit`. Drain (up to ten seconds plus 200 ms) is best-effort application-buffer acknowledgement, not peer receipt or an execution deadline; pending input is discarded. Certificate work runs on the existing dispatcher through the shared service-before-identity reservation, commits before stop/restart and never rolls back a committed identity after lifecycle failure. Failed stop retains ownership and skips start. Verify changed trust through UART0 `web certificate info`, recover with UART0/admin SSH `web stop` / `web start`, then sign in freshly. HTTPS-only actions leave SSH/native USB/UART0 independent; reboot affects every transport and loses unsaved RAM.
|
Browser admin uses the same dispatcher with a [narrower parsed frontend policy](web_administration.md#browser-shell-policy), independent of typed Settings permissions. It supports bounded deferred `reboot`, `web stop`, exact `web certificate rotate --force` and owner-relative `exit`. Drain (up to ten seconds plus 200 ms) is best-effort application-buffer acknowledgement, not peer receipt or an execution deadline; pending input is discarded. Certificate work runs on the existing dispatcher through the shared service-before-identity reservation, commits before stop/restart and never rolls back a committed identity after lifecycle failure. Failed stop retains ownership and skips start. Verify changed trust through UART0 `web certificate info`, recover with UART0/admin SSH `web stop` / `web start`, then sign in freshly. HTTPS-only actions leave SSH/native USB/UART0 independent; reboot affects every transport and loses unsaved RAM.
|
||||||
|
|
||||||
Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/`mdns` allow status only. Browser `user` allows status/list/show and interactive add/password plus forced role/delete for **other accounts**, not self/generated/key/recovery commands. Restricted SSH stop/disconnect/reset/host-key mutation remains unavailable in the browser shell. Typed Accounts/Network/SSH settings separately provide their documented bounded workflows; this is not shell parity. See [web administration](web_administration.md) for lifecycle/API ownership and uncertainty.
|
Browser `web` allows only status/stop/exact forced certificate rotation; `mdns` allows status only. Browser `wifi` supports the canonical commands, including bare `wifi` status, settings, hidden secret prompts and explicit `wifi ap show-secret`. Secret input is not echoed, retained in history or completed. Wi-Fi changes can disconnect the browser before a result arrives, without deferred drain or cancellation; reconnect and inspect before retrying, or recover through UART0. Browser `user` allows status/list/show and interactive add/password plus forced role/delete for **other accounts**, not self/generated/key/recovery commands. Restricted SSH stop/disconnect/reset/host-key mutation remains unavailable in the browser shell. Typed Accounts/Network/SSH settings separately provide their documented bounded workflows; this is not shell parity. See [web administration](web_administration.md) for lifecycle/API ownership and uncertainty.
|
||||||
|
|
||||||
## System
|
## System
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
|
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
|
||||||
| `reboot` | Drain console output briefly and restart the ESP32. |
|
| `reboot` | Drain console output briefly and restart the ESP32; refused while a firmware upload or another reboot owns exclusion. |
|
||||||
| `exit` | Close the current administrative SSH or browser session after its acknowledgement drains; unavailable on UART0. Browser `exit` leaves serial connected. Ctrl+D on an empty administrative command line does the same. |
|
| `exit` | Close the current administrative SSH or browser session after its acknowledgement drains; unavailable on UART0. Browser `exit` leaves serial connected. Ctrl+D on an empty administrative command line does the same. |
|
||||||
|
|
||||||
|
Firmware upload is an admin **Settings → HTTPS / Reboot** action, not a shell command. Ordinary UART0/admin-SSH/browser-shell/local-display and typed reboot paths are gated against an active upload. If upload boot selection succeeded but its response failed, no automatic restart is scheduled: the selected image remains, another admissible upload returns 409 until reset, and a deliberate manual `reboot` is permitted after reservations release. Inspect first; a lost response is not cancellation. See [firmware update and wired recovery](roadmap.md#phase10-update-and-recovery). Do not erase for an ordinary update; native USB is UART1 access, not recovery administration.
|
||||||
|
|
||||||
## Role-based users
|
## Role-based users
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -121,7 +123,9 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
|
|||||||
| `mdns save` / `mdns load` | Save the working suffix to its independent NVS record or load it. |
|
| `mdns save` / `mdns load` | Save the working suffix to its independent NVS record or load it. |
|
||||||
| `mdns defaults` / `mdns reset` | Restore the MAC-derived suffix in RAM, or restore and persist it. |
|
| `mdns defaults` / `mdns reset` | Restore the MAC-derived suffix in RAM, or restore and persist it. |
|
||||||
|
|
||||||
When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sak-<suffix>.local`. The default suffix is the lower-case hexadecimal STA MAC address. Suffixes may contain lowercase ASCII letters, digits, and internal hyphens only. Changing a suffix while online causes a best-effort reannouncement; mDNS failures do not stop Wi-Fi, UART0, UART1, or native USB access.
|
When the Wi-Fi station has IPv4 or a preferred IPv6 address, the Wi-Fi manager announces `sak-<suffix>.local` and advertises available HTTPS/SSH services through DNS-SD. This is STA-only, local-link discovery, not certificate or host-key trust. The default suffix is the lower-case hexadecimal STA MAC address. Suffixes may contain lowercase ASCII letters, digits, and internal hyphens only. Changing a suffix queues a best-effort reannouncement and updates the DHCPv4 hostname (`sak-<suffix>`, without `.local`) for subsequent DHCP exchanges; it does not force a lease restart. A configured DHCP/DNS server may publish that name in its own zone.
|
||||||
|
|
||||||
|
`wifi status` and browser Network status distinguish IPv4 absence and IPv6 link-local/ULA/GUA availability. `wifi` / `wifi status` and the web Network settings dialog additionally list up to three actual preferred IPv6 addresses, labelled link-local, ULA or GUA; these are copied with the flags, not inferred from enabled IPv6 support. A link-local destination needs the client's interface as its zone. To separate client address preference from IPv6 reachability, run `ping -6 -c 3 sak-1024.local` on an IPv6-capable client; choosing IPv4 with plain `ping` does not mean the device lacks IPv6. If lookup fails, inspect AAAA resolution (for example `avahi-resolve-host-name -6 sak-1024.local` where Avahi is installed), then test the numeric address from `wifi status` directly. Link-local-only connectivity counts as `ONLINE` and can retire the fallback AP after the existing stability interval; it does not establish Internet access. HTTPS/WebSocket and SSH support both families; browser IPv6 literals require brackets and cannot contain interface zones. Use the `.local` hostname for link-local browser access where supported by the client. Existing ping selects the first usable resolver result without family racing, and traceroute remains IPv4-only. mDNS failures do not stop Wi-Fi, UART0, UART1, or native USB access. See [Phase 12](roadmap.md#phase-12--advanced-network-integration) for discovery convergence limits and pending device validation.
|
||||||
|
|
||||||
## HTTPS web terminal
|
## HTTPS web terminal
|
||||||
|
|
||||||
@@ -189,4 +193,4 @@ UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix
|
|||||||
|
|
||||||
Follow the exact wiring in [Electrical tests](electrical_tests.md) before invoking diagnostics. The OLED must be powered from 3.3 V because module I²C pull-ups may connect to `VCC`; verify that all external pull-ups also terminate at 3.3 V. Display diagnostics probe the standard SSD1315-compatible 7-bit `0x3c`/`0x3d` addresses. The currently tested module acknowledges at `0x3c`, whose 8-bit write/read forms are `0x78`/`0x79`; an explicit `scan --force` is available only for the dedicated local-UI bus. Diagnostics initially run at 100 kHz and treat an absent display as nonfatal. RS-232 diagnostics that require UART1 refuse to use it until `serial stop` releases it. The RGB LED shows test state: blue idle, yellow/orange running, green passed, red failed.
|
Follow the exact wiring in [Electrical tests](electrical_tests.md) before invoking diagnostics. The OLED must be powered from 3.3 V because module I²C pull-ups may connect to `VCC`; verify that all external pull-ups also terminate at 3.3 V. Display diagnostics probe the standard SSD1315-compatible 7-bit `0x3c`/`0x3d` addresses. The currently tested module acknowledges at `0x3c`, whose 8-bit write/read forms are `0x78`/`0x79`; an explicit `scan --force` is available only for the dedicated local-UI bus. Diagnostics initially run at 100 kHz and treat an absent display as nonfatal. RS-232 diagnostics that require UART1 refuse to use it until `serial stop` releases it. The RGB LED shows test state: blue idle, yellow/orange running, green passed, red failed.
|
||||||
|
|
||||||
HTTPS storage migration preserves the exact TLS identity and commits TLS-only v2 before publication. Older v1-only firmware cannot read v2. Logical NVS replacement is not secure flash erasure; no factory erase is required. See [legacy removal](legacy_credential_removal.md).
|
HTTPS storage migration preserves the exact TLS identity and commits TLS-only v2 before publication. Older v1-only firmware cannot read v2. Logical NVS replacement is not secure flash erasure; no factory erase is required. See [legacy removal](roadmap.md#phase8-legacy-credential-compatibility).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Bounded ordinary HTTPS idle retention
|
# Bounded ordinary HTTPS idle retention
|
||||||
|
|
||||||
Current owner/SDK contract. The user accepted idle cleanup as working; [overall acceptance](web_administration_acceptance.md) records current status and evidence limits. No individual unreported soak, fault or reserve result is inferred.
|
Current owner/SDK contract. The user accepted idle cleanup as working; [overall acceptance](roadmap.md#phase8-acceptance-evidence) records current status and evidence limits. No individual unreported soak, fault or reserve result is inferred.
|
||||||
|
|
||||||
## Evidence and scope
|
## Evidence and scope
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Detach first prevents submissions, then waits at most one second for any submitt
|
|||||||
|
|
||||||
`tests/web_httpd_idle/run.py` exercises production lifecycle/sweep paths, installed SDK request cleanup, host socketpair/readiness/shutdown/fd reuse and deterministic TLS/timer/queue doubles. `tests/web_admin_transport/server_lifecycle.py` checks server composition. Historical host validation passed; these references do not claim a new run or real target scheduler/TLS timing.
|
`tests/web_httpd_idle/run.py` exercises production lifecycle/sweep paths, installed SDK request cleanup, host socketpair/readiness/shutdown/fd reuse and deterministic TLS/timer/queue doubles. `tests/web_admin_transport/server_lifecycle.py` checks server composition. Historical host validation passed; these references do not claim a new run or real target scheduler/TLS timing.
|
||||||
|
|
||||||
Bounded storage: six observation rows (144 bytes), one persistent timer (installed non-profiled layout 32 bytes before allocator overhead), lifecycle/generation flags and one reserved probe. SDK control UDP/mailbox allocation is transient and separate. No per-connection payload/task/stack/socket growth. Actual HTTPD/timer margins and runtime overhead/reserve floors require target measurement, not host sizes. See [latest firmware resources](web_administration_acceptance.md#latest-firmware-evidence).
|
Bounded storage: six observation rows (144 bytes), one persistent timer (installed non-profiled layout 32 bytes before allocator overhead), lifecycle/generation flags and one reserved probe. SDK control UDP/mailbox allocation is transient and separate. No per-connection payload/task/stack/socket growth. Actual HTTPD/timer margins and runtime overhead/reserve floors require target measurement, not host sizes. See [latest firmware resources](roadmap.md#phase8-acceptance-evidence).
|
||||||
|
|
||||||
## Regression procedure — not execution evidence
|
## Regression procedure — not execution evidence
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Legacy credential removal and storage compatibility
|
|
||||||
|
|
||||||
Current storage/recovery contract. The user accepted cleanup on 2026-09-08, confirming the certificate fingerprint was unchanged and preexisting test users remained usable. [Overall acceptance and evidence limits](web_administration_acceptance.md) supersede the old per-slice handoff; no unreported provisioning, recovery, power-loss or all-key tests are implied.
|
|
||||||
|
|
||||||
## User database
|
|
||||||
|
|
||||||
- `user_database_init(load_result)` takes no legacy credential. Missing `user_db/database` storage is committed as an empty v1 database; no account/password is imported.
|
|
||||||
- Create the first administrator on physical UART0 with `user add <username> admin`, optionally `--generate`. Entered passwords use hidden confirmation; generated passwords are shown once. There is no reserved bootstrap account or public bootstrap state.
|
|
||||||
- `user recover --force` is UART0-only, calls `user_database_recover_empty()`, and destructively rebuilds only an unavailable database empty. It refuses a healthy initialized database, including an empty one. Follow with normal `user add`; unrelated configuration and TLS/SSH identities are untouched.
|
|
||||||
- Valid existing v1 database bytes load without rewriting or changing accounts, roles, IDs, authentication generations, verifiers or authorized keys. Previously migrated role-`user` accounts are not automatically promoted. The private `v1_admin_marker` preserves the old wire byte and is derived from administrator count during mutations; it is not a new role, public bootstrap field or schema change.
|
|
||||||
- No public bootstrap, legacy user migration or synchronization API remains. Final-admin protection, conditional mutations, copied principal currentness and target-only revocation remain the canonical account contracts.
|
|
||||||
|
|
||||||
## HTTPS identity storage
|
|
||||||
|
|
||||||
`web_security` owns only TLS material. At the unchanged `web_sec/material` key, a private byte-oriented reader validates the shipped **1,392-byte v1** layout and builds **1,340-byte TLS-only v2**. It retains the **exact private-key DER, certificate DER, SHA-256 fingerprint and material generation**: this migration is not certificate rotation.
|
|
||||||
|
|
||||||
The v2 candidate is validated and committed before live publication. Temporary credential-bearing v1 input is wiped. No public legacy credential reader/type, display, rotation or authentication path remains. Unknown/malformed material, read failures, invalid cryptographic identity and migration write/commit failures fail closed; they do not trigger fallback regeneration or overwrite of rejected material. Truly missing material may be generated and saved normally.
|
|
||||||
|
|
||||||
`web credentials show`, `web credentials rotate --force` and `user bootstrap` (including its generated form) are removed. `web certificate info` and `web certificate rotate --force` remain subject to existing frontend policy. `web reset --force` explicitly replaces TLS certificate/private key only; it does not reset passwords, import accounts or synchronize verifiers. TLS replacement/restart can close browser sessions and requires renewed certificate trust/login, without changing user credentials or revoking unrelated SSH sessions.
|
|
||||||
|
|
||||||
## Compatibility and physical-security limits
|
|
||||||
|
|
||||||
**Older v1-only firmware cannot read v2 HTTPS storage.** Do not assume a downgrade preserves usable HTTPS or restores removed credentials. Existing user database v1 compatibility is separate from this HTTPS downgrade incompatibility.
|
|
||||||
|
|
||||||
Replacing an NVS blob logically removes credential fields from the current record; it is **not secure flash wiping**. Append-oriented historical copies may retain plaintext legacy credentials, and current Wi-Fi secrets/TLS/SSH private keys remain unencrypted. PBKDF2 verifiers remain subject to offline guessing. No factory/partition erase is required or authorized by this cleanup.
|
|
||||||
|
|
||||||
## Regression references
|
|
||||||
|
|
||||||
Use `tests/web_security/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py` and [current legacy-removal procedures](user_administration_tests.md#current-legacy-removal-regression-procedure). Prior host tests used NVS fault doubles and a generated identity wrapped in the historical TLS layout, not an independently captured old-device fixture; they do not prove flash durability or power-loss behavior. Procedures are not claims of execution.
|
|
||||||
+144
-65
@@ -1,10 +1,10 @@
|
|||||||
# Implementation roadmap
|
# Implementation roadmap
|
||||||
|
|
||||||
This document tracks the implementation and hardware-validation plan for the ESP32 Serial Swiss Army Knife. It describes intended sequencing rather than a release schedule. A phase is complete only when its implementation, documentation, build validation, and relevant hardware tests have passed.
|
This document tracks the implementation and hardware-validation plan for the ESP32 Serial Swiss Army Knife. It describes intended sequencing rather than a release schedule. Completion requires implementation, documentation and build validation, plus relevant hardware tests or an explicitly recorded user waiver. A waiver is not a hardware-test pass.
|
||||||
|
|
||||||
## Status legend
|
## Status legend
|
||||||
|
|
||||||
- **Complete** — implemented and validated on the target hardware.
|
- **Complete** — implemented and accepted, with target-hardware validation or an explicit phase-specific user waiver recorded below.
|
||||||
- **In progress** — implementation or validation is actively underway, but the overall phase is not complete.
|
- **In progress** — implementation or validation is actively underway, but the overall phase is not complete.
|
||||||
- **Implemented; validation pending** — code is present and builds, but the current implementation still needs the listed hardware checks.
|
- **Implemented; validation pending** — code is present and builds, but the current implementation still needs the listed hardware checks.
|
||||||
- **Planned** — accepted project direction, not yet implemented.
|
- **Planned** — accepted project direction, not yet implemented.
|
||||||
@@ -38,12 +38,11 @@ These constraints apply across all phases:
|
|||||||
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
|
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
|
||||||
| 6 | Authenticated SSH serial transport | **Complete** |
|
| 6 | Authenticated SSH serial transport | **Complete** |
|
||||||
| 7 | Local display and button interface | **Complete** |
|
| 7 | Local display and button interface | **Complete** |
|
||||||
| 8 | Role-based users and administrative access | **In progress (8A–8C complete; integrated web administration 8D planned)** |
|
| 8 | Role-based users and administrative access | **Complete** |
|
||||||
| 9 | Security and production hardening | **Planned** |
|
| 9 | Small intermittent-use security baseline | **Complete (user signoff 2026-09-18; new hardware check waived)** |
|
||||||
| 10 | Authenticated, rollback-capable OTA | **Planned** |
|
| 10 | Simple admin web firmware upload | **Complete (explicit user acceptance 2026-09-18; upload and normal operation verified)** |
|
||||||
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
|
| 12 | Dual-stack networking and local service discovery | **Complete (explicit user validation; fresh boot and full client mix at 230400 baud)** |
|
||||||
| 12 | Advanced network integration | **Under evaluation** |
|
|
||||||
| 13 | Optional filesystem-backed features | **Under evaluation** |
|
|
||||||
|
|
||||||
## Completed phases
|
## Completed phases
|
||||||
|
|
||||||
@@ -200,93 +199,173 @@ Completed implementation (8A–8C target-hardware validated; 8D explicitly accep
|
|||||||
- Typed Serial, Accounts/password/authorized-key, Network/Wi-Fi/mDNS, Display, Broker and SSH settings; confirmed HTTPS stop/restart/reboot and HTTPS/SSH identity rotation. Canonical owners compare reserved service/identity/target generations, preserve commit/failure semantics and expose no stored secrets. Admitted work is not cancelled by later logout/timeout; uncertain mutations are never automatically replayed.
|
- Typed Serial, Accounts/password/authorized-key, Network/Wi-Fi/mDNS, Display, Broker and SSH settings; confirmed HTTPS stop/restart/reboot and HTTPS/SSH identity rotation. Canonical owners compare reserved service/identity/target generations, preserve commit/failure semantics and expose no stored secrets. Admitted work is not cancelled by later logout/timeout; uncertain mutations are never automatically replayed.
|
||||||
- Accessible Serial/Wi-Fi/client/writer contextual controls reuse existing controllers. Writer transfer is explicit and generation-safe; one writer, isolated observers, binary transparency, UART0 administrative recovery and network-independent native USB UART1 remain invariants.
|
- Accessible Serial/Wi-Fi/client/writer contextual controls reuse existing controllers. Writer transfer is explicit and generation-safe; one writer, isolated observers, binary transparency, UART0 administrative recovery and network-independent native USB UART1 remain invariants.
|
||||||
- Scope removed by user: **8D.15** dedicated typed network diagnostics (shell diagnostics retained) and the unimplemented **8D.19** ordinary browser-session/native-USB controls (SSH controls retained). Browser identity reset/recovery/export is excluded; canonical recovery remains available.
|
- Scope removed by user: **8D.15** dedicated typed network diagnostics (shell diagnostics retained) and the unimplemented **8D.19** ordinary browser-session/native-USB controls (SSH controls retained). Browser identity reset/recovery/export is excluded; canonical recovery remains available.
|
||||||
- Prior final build PASS: **100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. Earlier combined binary WebSocket-send fix was explicitly accepted at **160 MHz / 230400 baud with full client mix including browser admin**. Latest overall sign-off does not invent individual fault/soak/duration results or a latest exact zero-drop comparison.
|
- Acceptance evidence and legacy storage compatibility are condensed below. [Web administration](web_administration.md) retains current API/owner/recovery contracts; [regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) retain practical checks, not claims of execution.
|
||||||
- Latest loaded internal/DMA minima **2,052 / 460 B** remain a conservative transient-headroom follow-up, not a blocker reopening acceptance or an approved reserve. See [acceptance and telemetry](web_administration_acceptance.md), [current contracts](web_administration.md), [regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and [legacy compatibility](legacy_credential_removal.md).
|
|
||||||
|
|
||||||
## Current and planned phases
|
#### Phase8 acceptance evidence
|
||||||
|
|
||||||
Phase 8 is complete for its accepted scope. Phase 9 is next **only on a separate user request**; later work remains planned or under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below remain guidance for future work, not claims that every listed fault, soak or reserve measurement was individually performed for 8D.
|
**8D.22 signoff, 2026-09-13:** the user explicitly accepted the tested firmware, superseding earlier per-slice target, review and integration gates for the retained scope above. Earlier scoped acceptance covered browser login/logout, shared administration, Serial/account/Network presentation and legacy cleanup. Prior host regression suites and independent reviews were reported passing; this consolidation asserts no new build or test execution.
|
||||||
|
|
||||||
|
Prior final Phase 8 build: **PASS, 100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. The combined binary WebSocket-send fix was separately accepted at **160 MHz / 230400 baud with the full client mix including browser admin**; retain combined send and bounded failed-send isolation, not the earlier frequency-only experiment. Ordinary HTTPS idle cleanup was accepted, not every possible admission failure or owner delay.
|
||||||
|
|
||||||
|
Latest reported Phase 8 telemetry (bytes):
|
||||||
|
|
||||||
|
| Sample / capability | Free | Minimum free | Largest block |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Boot internal 8-bit | 59,808 | 58,840 | 31,744 |
|
||||||
|
| Boot internal DMA | 52,052 | 51,084 | 31,744 |
|
||||||
|
| Boot PSRAM | 8,196,968 | 8,183,972 | 8,126,464 |
|
||||||
|
| Loaded after burst, internal 8-bit | 31,508 | 2,052 | 18,432 |
|
||||||
|
| Loaded after burst, internal DMA | 23,752 | 460 | 18,432 |
|
||||||
|
| Loaded after burst, PSRAM | 8,136,624 | 8,065,972 | 7,995,392 |
|
||||||
|
|
||||||
|
Loaded SSH minimum-free stack: **15,028 B**. This sample had two SSH sessions (serial/admin), two serial WebSockets and USB, with SSH as writer. Browser admin had been used then closed; it was **not active in this capture**. Web send/queue/protocol and SSH IO errors were zero; one SSH handshake failure and one session revocation were reported without an established cause.
|
||||||
|
|
||||||
|
**Limits, not reopened acceptance gates:** missing latest broker/serial counters prevent an exact zero-drop or byte-integrity inference. No latest browser-admin-active full-mix, individual fault-injection, exact duration, soak, cleanup-cycle or reserve-floor result is inferred. Internal/DMA lifetime minima remain a nonblocking transient-headroom follow-up, not approved reserves or proof of allocation failure: capability pools overlap and per-region minima may be non-simultaneous, so do not sum them. HTTPD/dispatcher stack margins, correlated peaks and allocation-reserve policy remain unmeasured/unapproved here. SDK TLS `-0x004C` means NET_RECV_FAILED, not an OOM diagnosis; stale cookies as the cause of two boot authentication failures remain unconfirmed.
|
||||||
|
|
||||||
|
#### Phase8 legacy credential compatibility
|
||||||
|
|
||||||
|
Cleanup was accepted **2026-09-08**, with unchanged certificate fingerprint and usable preexisting test users. This is not an unreported provisioning, recovery, power-loss or all-key pass.
|
||||||
|
|
||||||
|
- **Users:** missing `user_db/database` is committed as an empty v1 database, with no legacy account/password import. First-admin provisioning is physical UART0 `user add <username> admin` (optional `--generate`); entered passwords use hidden confirmation and generated passwords appear once. UART0-only `user recover --force` rebuilds only an unavailable database empty, refuses healthy databases (including empty ones), and leaves other configuration and TLS/SSH identities untouched; follow with normal `user add`.
|
||||||
|
- Valid existing v1 user bytes load without rewrite or changes to accounts, roles, IDs, authentication generations, verifiers or authorized keys; migrated `user` accounts are not promoted. Private `v1_admin_marker` preserves the old wire byte and is derived from admin count on mutation, not a new role/schema/public bootstrap state. Public bootstrap and legacy migration/synchronization APIs are removed; final-admin protection, conditional mutations, principal currentness and targeted revocation remain.
|
||||||
|
- **TLS:** at unchanged `web_sec/material`, a private reader validates the **1,392-byte v1** layout and migrates to **1,340-byte TLS-only v2**, preserving exact private-key DER, certificate DER, SHA-256 fingerprint and material generation—not rotating identity. The candidate is validated and committed before publication; temporary credential-bearing input is wiped. Malformed/unknown material, read/crypto failures and migration write/commit failures fail closed without regeneration or overwrite; truly missing material may be generated normally.
|
||||||
|
- `web credentials show`, `web credentials rotate --force` and `user bootstrap` are removed. `web certificate info` / `web certificate rotate --force` retain frontend policy. `web reset --force` replaces TLS identity only, not accounts/passwords/verifiers; replacement/restart may close browser sessions and require renewed certificate trust/login, without revoking unrelated SSH sessions. No public legacy credential reader, display, rotation or authentication path remains.
|
||||||
|
- **Downgrade/security:** older v1-only firmware cannot read v2 HTTPS storage; user-database v1 compatibility does not make HTTPS downgrade safe or restore removed credentials. Logical NVS replacement is **not secure flash wiping**: historical plaintext legacy credentials may remain, Wi-Fi secrets and TLS/SSH private keys remain unencrypted, and PBKDF2 verifiers permit offline guessing. No erase is required or authorized by cleanup.
|
||||||
|
- Prior host tests used NVS fault doubles and a generated identity wrapped in the historical layout, **not an independently captured old-device fixture**; they do not prove flash durability or power-loss behavior. Focused runners: `tests/web_security/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py`. See [legacy-removal regressions](user_administration_tests.md#current-legacy-removal-regression-procedure).
|
||||||
|
|
||||||
### Phase 9 — Security and production hardening
|
### Phase 9 — Security and production hardening
|
||||||
|
|
||||||
Harden stored credentials, boot integrity, update trust, failure diagnostics, and dependency maintenance. This phase must be staged carefully because some ESP32 eFuse operations are irreversible.
|
**Complete — user signoff 2026-09-18; new hardware check explicitly waived.** Reduced scope: an intermittently powered wireless serial cable on a trusted network, not a permanently exposed Internet service. The earlier extensive Phase 9 implementation was abandoned in favor of the accepted pre-Phase-9 baseline. Short exposure reduces risk; it does not make upstream defects harmless or certify this firmware secure.
|
||||||
|
|
||||||
Planned work:
|
Small implementation:
|
||||||
|
|
||||||
- Define development, test, and production security profiles.
|
- Keep the baseline PlatformIO/ESP-IDF and managed-library versions and upstream sources. No generated dependency patches, custom SSH state machine, crypto-policy replacement, allocator hooks, or SDK migration in this phase.
|
||||||
- Enable and validate NVS encryption for Wi-Fi, HTTPS, and SSH material.
|
- Explicitly disable core dumps and select silent panic reboot through standard `sdkconfig.defaults` options. Keep ordinary logging; reduced crash diagnostics are a deliberate trade-off. Existing generated configurations must be checked because defaults do not override saved selections.
|
||||||
- Evaluate flash encryption and PSRAM encryption for the target hardware and threat model.
|
- Follow the [operational checklist](security_operations.md): unique credentials, trusted identity verification, no Internet forwarding, networking off/power off after use, and existing rotation/recovery procedures.
|
||||||
- Enable secure boot with a documented signing-key and recovery process.
|
- Review upstream maintenance releases occasionally and before planned upgrades. Evaluate reachable issues against actual use; any stock dependency upgrade is a separate build/device-tested task, not a reason to accumulate local backports. Preserve existing license notices.
|
||||||
- Define secret-safe core-dump handling; do not expose credentials or session material in diagnostics.
|
|
||||||
- Audit key and session-material zeroization across application, mbedTLS, wolfSSL, and wolfSSH allocations.
|
|
||||||
- Add authentication throttling, lockout/backoff, and security-event counters where appropriate.
|
|
||||||
- Review TLS/SSH algorithms, certificate policy, password policy, and browser security headers.
|
|
||||||
- Review current wolfSSL/wolfSSH and ESP-IDF security releases instead of treating integration versions as permanent pins.
|
|
||||||
- Recheck all third-party licenses and preserve source/license notices.
|
|
||||||
- Document provisioning, key rotation, factory reset, backup, recovery, and device decommissioning.
|
|
||||||
|
|
||||||
Security features must be validated first on expendable hardware before any irreversible production configuration is recommended.
|
**Excluded:** secure boot, NVS/flash/PSRAM encryption, eFuse changes, partition changes, exhaustive CVE/zeroization audits, new authentication throttling, and production certification. Existing authentication, session isolation and recovery behavior remain unchanged. There is no protection against physical extraction or replacement of firmware. Phase 10 uses administrator-selected firmware, without a separate image-signing system.
|
||||||
|
|
||||||
### Phase 10 — Authenticated OTA and rollback
|
**Host/build evidence (2026-09-18):** normal `pio run` passed on PlatformIO 6.12.0 / ESP-IDF 5.5.0 with the original 20241119 toolchains: **94,196 B linked RAM / 1,828,565 B flash**. Generated configuration confirms no dumps and silent panic reboot; compilation inputs contain none of the abandoned Phase 9 source overlays or wolf crypto guards. No device operation or target validation was performed.
|
||||||
|
|
||||||
Use the existing dual 4 MiB application slots and `otadata` partition to add safe firmware updates.
|
**Acceptance (2026-09-18):** the user signed off this small Phase 9 and explicitly waived a new whole-phase hardware check of boot, UART0 recovery, native USB serial, HTTPS/SSH login and normal serial sharing. Application code is unchanged, **but crash-diagnostic defaults changed**; this is a waiver, not evidence of target or panic-path validation. The prior build/configuration evidence above remains the validation record; no new hardware test was performed. A separately controlled panic check with synthetic secrets remains optional, not a completion gate; do not add a production panic endpoint or erase flash. Disabling dumps does not erase old contents.
|
||||||
|
|
||||||
Planned work:
|
### Phase 10 — Simple admin web firmware upload
|
||||||
|
|
||||||
- Accept only authenticated, integrity-checked firmware images compatible with the selected secure-boot policy.
|
**Complete — explicit user acceptance on 2026-09-18: firmware upload works and normal operation is verified.** The admin **Settings → HTTPS / Reboot** firmware card uploads the locally built `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`. Install this OTA-enabled firmware by wire first, then use web application uploads. There is no release server, automatic download or update service. [Firmware update and recovery](#phase10-update-and-recovery) below covers operation; [regression guidance](#phase10-regression-guidance) covers future checks.
|
||||||
- Support an update path that does not expose a plaintext management endpoint.
|
|
||||||
- Stream downloads with bounded RAM use and explicit progress/error reporting.
|
|
||||||
- Preserve serial, Wi-Fi, HTTPS, and SSH configuration across successful updates.
|
|
||||||
- Mark new firmware pending until a post-boot health check succeeds.
|
|
||||||
- Roll back automatically after failed boot or health confirmation.
|
|
||||||
- Define power-loss behavior for every update stage.
|
|
||||||
- Keep UART0 recovery and wired re-flashing documented and functional.
|
|
||||||
- Add version/compatibility checks and reject accidental downgrade unless an explicit policy allows it.
|
|
||||||
|
|
||||||
OTA is complete only after successful update, interrupted-update, invalid-image, rollback, and configuration-persistence tests on hardware.
|
Implemented scope:
|
||||||
|
|
||||||
### Phase 11 — BLE
|
- Raw `POST /api/firmware` requires exact `Content-Type: application/octet-stream`, known exact `Content-Length`, cookie, same-origin Origin and `X-CSRF-Token`; no multipart/JSON, Basic authentication or ordinary `user` access. Reject queries, ambiguous headers, chunked transfer and `Expect`; UI code leaves browser-managed headers alone. Admission precedes body/flash work; session/principal currentness is rechecked before boot selection. The card confirms upload/reboot and shows progress/results without automatic retries. HTTPD capacity is 40 method/path handlers.
|
||||||
|
- Standard ESP-IDF OTA APIs (`esp_ota_begin`, `esp_ota_write`, `esp_ota_end`, `esp_ota_set_boot_partition`) stream one upload through a 4 KiB buffer into the inactive slot (two 4 MiB app slots). A reboot task with a 2 KiB stack is preallocated before erase. Lifecycle/identity reservations and ordinary software-reboot gating exclude competing work; no custom flash protocol or whole-image RAM buffer.
|
||||||
|
- Accepts an ESP32-S3 application binary for this board/layout, not an ELF, ZIP, merged full-flash image, bootloader or partition-table image. Browser hints require a nonempty `.bin` of at most 4 MiB; the server bounds raw length against actual destination capacity before trusting HTTPD's narrowed length. Checks completeness, target/header and exact SDK image length/integrity, including mandatory appended SHA-256, before selection. The digest is not publisher authentication; neither it nor filename checks prove board compatibility or a working application.
|
||||||
|
- Before selection, rejected/incomplete uploads and receive/write/validation failures abort any live OTA handle without selecting the candidate; the inactive slot may be erased/partially written. Boot-metadata failure (`firmware_commit_failed`) needs inspection and carries SDK transactional uncertainty. `200 {"ok":true,"rebooting":true}` means validation/selection succeeded; successful synchronous send schedules restart after 500 ms, retaining reservations, but proves neither browser receipt nor boot success. Response failure after selection schedules no restart, releases reservations for manual reboot and latches further admissible uploads to 409 `firmware_selected_reboot_required`; the latch survives HTTPS stop/start until device reset.
|
||||||
|
- Synchronous HTTPD receive/flash blocks other HTTPD work: browser sessions can stall/drop. The 120-second total receive-loop and ten-second stall checks use the existing one-second socket timeout, not an absolute deadline: synchronous SDK erase/write/validation and scheduling are not preempted. The browser's 180-second timeout cannot cancel committed work. UART0/native USB remain independent paths, not guarantees of uninterrupted serial timing during flash; reboot interrupts all transports. Software exclusion cannot prevent physical reset, power loss or panic.
|
||||||
|
- **Preserve NVS:** write only the inactive application slot and the OTA selection metadata. Do not erase the chip, rewrite the partition table/bootloader, or touch `nvs`, `nvs_key`, PHY, storage or other data partitions. Existing users, passwords, Wi-Fi/serial settings and HTTPS/SSH identities remain stored, as with an application-only wired update without erase. This preserves stored bytes; the uploaded firmware must still understand the existing schemas and must not itself erase/migrate them incompatibly.
|
||||||
|
- Keep wired USB-to-UART flashing documented as recovery if the uploaded application does not boot or no longer serves the web UI. Basic image validation cannot prevent a valid but broken application from requiring wired recovery.
|
||||||
|
|
||||||
Evaluate and, if resource limits permit, add BLE as another bounded transport or provisioning path.
|
**Not in scope:** secure boot, image signatures/signing-key management, anti-rollback/version-downgrade enforcement, automatic rollback/post-boot health-confirmation machinery, remote release discovery, partition migration or NVS backup/restore. Use upstream OTA support without dependency patches. The administrator is responsible for selecting trusted, compatible firmware, including when deliberately installing an older build.
|
||||||
|
|
||||||
Planned work:
|
**Historical integration build and host evidence (parent-reported):** `pio run` **PASS**, **94,220 B linked RAM / 1,847,645 B flash**. Against Phase 9's 94,196 B / 1,828,565 B, this is **+24 B RAM / +19,080 B flash**. Final backend 88 cases plus the pinned SDK begin/abort failure-contract test, UI 169 groups, server lifecycle 44, admin transport 25, console lifecycle, SSH runtime and cookie lifecycle checks passed. Additional cookie base/admin/display/lifecycle, HTTPD idle cleanup 18, and SSH management/runtime/security checks passed; the extra cookie `--admin` run initially lacked a reboot symbol in its fixture, corrected in test-only code before passing. Host doubles are not target evidence. The later concise-UI fix was copy-only; its latest reported UI regression passed all **169 groups**, with no rebuild after the text change. The build figures above remain historical; this documentation update ran no build or tests.
|
||||||
|
|
||||||
- Decide whether BLE is primarily a serial transport, a provisioning interface, or both.
|
**Acceptance (2026-09-18):** after the firmware upload implementation and concise-UI fix, the user confirmed: “That works perfectly. And the usual operation is also verified.” Phase 10 is complete by this explicit acceptance of working upload and verified normal operation. Do not infer specific fault-injection, NVS before/after comparisons, power-loss or wired-recovery passes. The [regression guidance](#phase10-regression-guidance) below is reusable future guidance, not an acceptance blocker.
|
||||||
- Require authenticated pairing/bonding appropriate to the device threat model.
|
|
||||||
- Integrate serial access through the existing broker rather than bypassing writer ownership.
|
|
||||||
- Preserve binary transparency through explicit framing and MTU-aware partial transfers.
|
|
||||||
- Bound connections, queues, retries, and advertising behavior.
|
|
||||||
- Measure coexistence with 2.4 GHz Wi-Fi, HTTPS, WebSocket, SSH, USB CDC, and UART service.
|
|
||||||
- Store bond/provisioning data under the hardened secret-storage policy.
|
|
||||||
- Provide UART0 controls and a physical recovery path for clearing BLE state.
|
|
||||||
|
|
||||||
BLE remains subordinate to stable Wi-Fi and serial operation; it should be omitted if coexistence or memory costs cannot be bounded acceptably.
|
#### Phase10 update and recovery
|
||||||
|
|
||||||
|
1. Keep **USB-to-UART** available for UART0 administration/flashing; native USB CDC is network-independent UART1 access, not administration. Install the updater by wire first: older firmware without the route cannot install its own first web updater. The custom OTA layout is required; [one-time old-layout migration](../README.md#one-time-migration-from-the-default-partition-table) is separate and destructive.
|
||||||
|
2. Build trusted, schema-compatible `esp32-s3-devkitc-1-n16r8` firmware with `pio run`; choose **`.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`**, never a merged image or a renamed non-application file. Observe the [downgrade warning](../README.md#legacy-credential-removal).
|
||||||
|
3. Save desired RAM-only settings and record nonsecret configuration/public HTTPS/SSH fingerprints. Arrange a maintenance window and stable power/network; sign in as `admin` over trusted HTTPS, open **Settings → HTTPS / Reboot → Firmware update**, choose the file and confirm **Upload and reboot…**.
|
||||||
|
4. Wait for validation/reboot, not just 100% transmitted bytes. Restore network reachability, reload and explicitly sign in; verify the running application, saved configuration and identities before another upload.
|
||||||
|
5. A lost response, timeout, page close, sign-out or browser abort proves neither cancellation nor failure. The UI locks uncertain outcomes without replay; reload clears only browser locks, not the device latch. Inspect through UART0 and deliberately reboot if appropriate: any later reset can boot an already selected image.
|
||||||
|
6. For initial wired installation or recovery when boot/HTTPS fails, connect USB-to-UART, select a known-good compatible checkout, run `pio run --target upload`, then `pio device monitor -b 115200`. **Do not erase for ordinary updates or recovery**; there is no automatic recovery flashing/rollback.
|
||||||
|
|
||||||
|
Replies: **400** malformed/incompatible/incomplete input; **401/403** authentication/Origin/CSRF or `admin_required`; **408** `firmware_timeout`; **409** selected-image latch; **413/415** destination capacity/content type; **500** `firmware_write_failed`/`firmware_commit_failed`; **503** busy/unavailable/resources. Unread rejected bodies close rather than drain; bounded secret-free JSON may never reach a disconnected browser.
|
||||||
|
|
||||||
|
#### Phase10 regression guidance
|
||||||
|
|
||||||
|
These are reusable checks, **not recorded passes or outstanding acceptance gates**. Record only performed outcomes; host flash/network/scheduling doubles are not hardware evidence. Focused host suites: `python3 tests/web_firmware_update/run.py` and `python3 tests/web_ui_session/run.py` (the SDK begin/abort case covers a live handle published on erase failure).
|
||||||
|
|
||||||
|
- Exercise wired-first install, known-good upload/reboot/new build and explicit reconnect; compare saved users/roles, password/key login, Wi-Fi/serial/display/hostname settings and public HTTPS/SSH fingerprints without recording secrets.
|
||||||
|
- Interrupt/cancel receive; reject corrupt, truncated, wrong-target, non-application and oversized inputs without unintended selection/restart, then deliberately perform a valid update. Reject unauthenticated/ordinary-user, wrong-Origin and missing/wrong-CSRF requests before flash. Never attempt power interruption at commit without wired recovery ready.
|
||||||
|
- Exercise competing upload, HTTPS lifecycle/identity and ordinary reboot exclusion; HTTPD requests may wait rather than promptly return busy. With controlled post-selection response-failure injection, verify no automatic restart, subsequent 409 (also after HTTPS restart), then manual reboot; an arbitrary disconnect does not establish this fault case.
|
||||||
|
- Observe web stalls/drops and serial/network recovery, single-writer isolation, UART0 recovery and native USB UART1 access without networking; demonstrate known-good wired recovery without erase and recheck configuration/identities. Do not claim uninterrupted traffic.
|
||||||
|
|
||||||
|
## Current and planned phases
|
||||||
|
|
||||||
|
**Phases 8, 9, 10 and 12 are complete** for their accepted scopes. Phase 9 includes the explicit new-hardware-check waiver above; Phase 10 includes explicit user acceptance of upload and normal operation. Phase 12 includes explicit user validation and the fresh-boot/full-client evidence below. Optional features must not weaken completed serial and recovery paths. General release gates below guide future work, not claims that every fault, soak, recovery or reserve measurement was performed for completed phases.
|
||||||
|
|
||||||
### Phase 12 — Advanced network integration
|
### Phase 12 — Advanced network integration
|
||||||
|
|
||||||
These features are candidates, not current commitments:
|
**Complete by explicit user validation:** “I just validated Phase 12 successfully.” Accepted baseline:
|
||||||
|
|
||||||
- Additional DNS-SD service advertisement and any certificate-name integration beyond the implemented configurable STA hostname.
|
- Dual-stack local access and DNS-SD discovery: advertise available HTTPS and SSH services using the shared `sak-<suffix>.local` hostname, with appropriate IPv4 `A` and IPv6 `AAAA` records. Preserve IPv4 access and verify IPv6 support throughout HTTPS, WebSocket and SSH. Clients choose address-family preference and fallback; DNS-SD cannot mandate IPv6 preference. Certificate-name integration and trust remain separate concerns.
|
||||||
- Enterprise Wi-Fi support, subject to credential-storage and certificate-validation design.
|
- Send the configured device hostname (`sak-<suffix>`, without `.local`) through DHCPv4 Host Name option 12 so a suitably configured DHCP/DNS server can publish the lease address in its own DNS zone. Set the STA netif hostname before DHCP starts, define how hostname changes reach subsequent DHCP exchanges, and verify the transmitted option and server-side DNS registration. DNS publication and the DNS domain remain server policy; mDNS does not provide this integration.
|
||||||
- IPv6 behavior and diagnostics beyond the current basic support.
|
- IPv6 baseline: link-local addressing and SLAAC-provided ULA/global addresses, with mDNS/DNS-SD for local discovery; no DHCPv6 requirement. Preserve link-local interface scope and keep advertisements synchronized with usable addresses and service availability. Registration of SLAAC addresses in infrastructure DNS is a separate optional feature, outside this baseline; SLAAC does not provide automatic hostname registration.
|
||||||
- WireGuard feasibility evaluation, including RAM, CPU, licensing, key storage, routing, and recovery impact.
|
- Validate IPv4-only, IPv6-only and dual-stack operation, including reconnects, address changes and service lifecycle changes. Discovery remains local-link by default; preserve UART0 and native USB recovery regardless of network state.
|
||||||
|
|
||||||
The device is not intended to become a general-purpose router. Captive-portal interception, unauthenticated DNS redirection, NAPT, and a plaintext serial listener remain out of scope unless the project requirements are explicitly revised.
|
The device is not intended to become a general-purpose router. Captive-portal interception, unauthenticated DNS redirection, NAPT, and a plaintext serial listener remain out of scope unless the project requirements are explicitly revised.
|
||||||
|
|
||||||
### Phase 13 — Optional filesystem-backed features
|
#### Implementation and operational boundaries
|
||||||
|
|
||||||
The `storage` partition is reserved but not currently mounted. Possible uses must be justified individually:
|
- The Wi-Fi owner installs the full configured hostname before STA connection and updates it after hostname edits. DHCPv4 option 12 uses `sak-<suffix>`, not `.local`. Rename does not force DHCP restart: the next DHCP exchange carries the new name, and DNS registration/cache cleanup remain server policy. Existing hostname limits and NVS formats are unchanged.
|
||||||
|
- STA enables SLAAC and IPv6 link-local creation; either IPv4 or a preferred IPv6 address establishes `ONLINE`. **Link-local-only counts as online**, prevents IPv4-only timeout/failover, and can retire the fallback AP after the existing stability interval. This indicates local address availability, not Internet reachability or a default route. Web, CLI and OLED distinguish absent IPv4 from IPv6 availability. A diagnostic follow-up adds up to three preferred numeric IPv6 addresses to the snapshot, copied and cleared with the same observation; `wifi status` and browser Network settings print them in link-local/ULA/GUA groups; overview/OLED status retains availability flags. The settings JSON is bounded to 2304 bytes, with no extra netif calls on HTTPD.
|
||||||
|
- HTTPS/WebSocket use IDF's existing dual-stack listener. SSH explicitly uses one dual-stack listener, retaining the existing two-session limit. Bracketed IPv6 HTTPS authorities are canonicalized and bound to the existing Host/Origin/session checks; scoped IPv6 literal URLs are rejected. Prefer the `.local` hostname for link-local browser access, subject to client resolver support. No certificate regeneration, new trust mechanism, or automatic IPv6 preference is introduced.
|
||||||
|
- STA-only mDNS advertises `_https._tcp:443` and `_ssh._tcp:22` according to listener availability, without TXT metadata. Record and address-family changes converge through the existing Wi-Fi owner, including offline reconciliation; service setters do no component work. The responder is not restarted for ordinary changes. Initialization failure remains latched/nonfatal.
|
||||||
|
- mDNS 1.12.0 exposes no readiness acknowledgement and can silently drop queued family actions. Absent families are disabled on each one-second owner pass; available families are re-enabled/reprobed on a 30-second repair cadence. Upstream synchronous calls can delay this cadence. There can be a transient stale/zero-A response window after IPv4 loss before disable is processed. AAAA records follow upstream **valid-address** semantics, including deprecated-but-still-valid addresses, not preferred-only filtering. Client caches expire independently.
|
||||||
|
- `cmake/mdns_membership.*` applies one version/hash-guarded, build-local source overlay to mDNS 1.12.0: balance per-family multicast leaves and unwind joins after PCB creation failure. Managed sources and dependency versions remain unchanged. An upstream mismatch fails configuration for deliberate review; see `tests/mdns_membership/README.md` for maintenance/removal and regression evidence. This is not the abandoned Phase 9 patch set.
|
||||||
|
- `sdkconfig.defaults` explicitly enables IPv4/IPv6 and increases mDNS service capacity from one to two. Existing saved configurations override defaults: verify `CONFIG_LWIP_IPV4=y`, `CONFIG_LWIP_IPV6=y`, `CONFIG_MDNS_MAX_SERVICES=2` and STA-only predefined mDNS interfaces before building. The local N16R8 saved configuration was updated accordingly. No new task, transport socket, broker slot, serial buffer, partition, filesystem or generated web asset was added.
|
||||||
|
- Existing diagnostic limits remain: hostname ping selects the resolver's first usable result (not Happy Eyeballs), scoped link-local ping is not newly supported, and traceroute remains IPv4-only. DHCPv6, infrastructure DNS registration of SLAAC addresses, and new IPv6 resolver provisioning are outside this baseline.
|
||||||
|
|
||||||
- LittleFS-backed web assets to decouple large static files from the application image.
|
#### Validation evidence and acceptance
|
||||||
- Bounded diagnostic logs with explicit retention and secret-redaction rules.
|
|
||||||
- Import/export of non-secret configuration.
|
|
||||||
- Optional user files needed by later administration features.
|
|
||||||
|
|
||||||
Before enabling storage, define corruption recovery, wear limits, quotas, atomic update behavior, compatibility across OTA slots, and whether encryption is required. Serial operation and recovery must not depend on a mountable filesystem.
|
`pio run` passed on PlatformIO 6.12.0 / ESP-IDF 5.5.0 with the guarded mDNS source compiled: **94,396 B linked RAM / 1,853,025 B flash**, +176 B RAM / +5,380 B flash versus the recorded Phase 10 build. These are static link sizes, not runtime heap or stack headroom. Initial target compilation rejected lock-free bool atomics; the implementation now uses short portMUX sections. Existing SDK Kconfig notes remain.
|
||||||
|
|
||||||
|
Host checks passed: Wi-Fi/address/hostname reconciliation, mDNS lifecycle/family repair, actual patched multicast-membership functions and CMake guards, SSH dual-stack/management/runtime/security, HTTPS authority parser (689 cases on host and actual installed lwIP parser), cookie authorization variants, Network settings, browser UI/CSP (171 groups), HTTPS lifecycle (45 groups and eight status projections), firmware upload (88 cases plus SDK contract), broker diagnostics, and session-store/serial integration. Network-settings host fakes were updated after the new netif dependency exposed a compilation failure; the rerun passed. These are not packet-level or hardware evidence.
|
||||||
|
|
||||||
|
Follow-up after the user's live status report: both preferred-address flags were `yes`, while plain client `ping` selected IPv4. Source verification confirms the flags require actual preferred addresses; this is not evidence of failed SLAAC or verified IPv6 reachability. `wifi status` now prints the addresses for diagnosis. Latest follow-up `pio run` passed: **94,444 B linked RAM / 1,853,685 B flash** (+48 / +660 versus the initial Phase 12 build). Browser Admin console now permits canonical Wi-Fi commands, including disruptive edits and hidden prompts, as explicitly requested. Focused policy/browser Wi-Fi prompt, address snapshot/rendering, Network/cookie and UI regressions passed. The broader console-boundary suite passes its console/certificate stages but has a pre-existing SSH-adapter fixture compilation failure for missing `web_firmware_update_reserve_reboot`; no production change was made to hide it.
|
||||||
|
|
||||||
|
Latest address-display build: `pio run` **94,444 B linked RAM / 1,854,485 B flash**; Network API/owner checks and **174 browser groups plus CSP** passed. These remain build/host results, separate from the user's device evidence below.
|
||||||
|
|
||||||
|
##### User-reported device acceptance
|
||||||
|
|
||||||
|
User explicitly validated Phase 12 after confirming numeric IPv6 ping/HTTPS, Avahi AAAA resolution and normal IPv6 hostname access following the Fedora `authselect` mDNS correction. The earlier hostname failure was a client resolver configuration issue, not missing device IPv6 addresses. The acceptance capture adds:
|
||||||
|
|
||||||
|
- **Fresh boot:** HTTPS, SSH and mDNS running without reported startup failures; zero SSH sessions, serial stopped, no broker clients, USB attached but not host-open. The configured UART profile is **230400 baud, 8N1, RTS/CTS, DTR active, RTS threshold 96**.
|
||||||
|
- **Full client mix:** two authenticated public-key SSH sessions with IPv6 ULA peers (one serial observer, one admin console); two serial WebSockets (one writer, one observer); one active browser admin WebSocket; native USB host-open as an observer. Four broker clients, exactly one writer, all reported pending/event counts zero. Serial running with RX-available/TX-pending zero, CTS asserted and valid RS-232 voltage at observation time.
|
||||||
|
- SSH: two successful handshakes, zero handshake/authentication failures or timeouts, zero stream I/O failures and zero session revocations. Stream counters **RX 68 / accepted 65 / rejected 3 / TX 161,209 bytes**; two broker writer revocations were reported. The rejected bytes are retained as evidence, not silently described as zero loss or assigned an unverified cause.
|
||||||
|
- Web serial: two connections, **27 accepted RX frames/bytes**, zero rejected RX frames/bytes; **782 binary TX frames / 318,230 bytes**, 11 control frames / 948 bytes. Send/queue/protocol/close failure counters zero. Two writer requests were denied; the final broker snapshot still shows one writer. Browser admin: one connection, **106 RX / 3,721 TX bytes**, zero send/queue/protocol/authorization/backpressure failures. No response errors reported.
|
||||||
|
- HTTPS and SSH both report running, not transitioning, `ESP_OK`; mDNS reports expected announcement and `ESP_OK`. Expected announcement alone is not packet-level service-record proof; the earlier successful Avahi lookup separately establishes the reported AAAA lookup.
|
||||||
|
|
||||||
|
All heap figures are bytes, copied from the user's observations:
|
||||||
|
|
||||||
|
| Observation | Heap capability | Free | Lifetime minimum-free | Largest block |
|
||||||
|
|---|---|---:|---:|---:|
|
||||||
|
| Fresh boot | Internal 8-bit | 65,880 | 64,912 | 31,744 |
|
||||||
|
| Fresh boot | Internal DMA | 58,124 | 57,156 | 31,744 |
|
||||||
|
| Fresh boot | External PSRAM | 8,196,732 | 8,187,980 | 8,126,464 |
|
||||||
|
| Full client mix | Internal 8-bit | 35,408 | 8,528 | 22,528 |
|
||||||
|
| Full client mix | Internal DMA | 27,652 | 772 | 22,528 |
|
||||||
|
| Full client mix | External PSRAM | 8,111,952 | 8,072,612 | 7,995,392 |
|
||||||
|
|
||||||
|
SSH owner stack: **20,480 B** configured, **18,476 B minimum-free** at fresh-boot observation and **16,284 B minimum-free** under the full mix.
|
||||||
|
|
||||||
|
**Evidence limits:** minimum-free is the conservative sum of matching heap regions' lifetime minima, not a simultaneous free-space measurement or guaranteed allocation reserve. Internal 8-bit and DMA capabilities overlap and must not be added as independent pools. The **772 B DMA lifetime minimum remains a headroom watch item**, not an observed allocation failure or acceptance blocker. Boot output contains two TLS `-0x004C` receive errors and unauthenticated-request failures; the capture does not establish their cause or connect them to memory exhaustion. Command snapshots are non-atomic; differing cumulative request/authentication counts must not be treated as one aligned measurement interval. No UART overflow/per-observer drop counters, soak duration, byte-for-byte capture integrity, exhaustive fault injection, DHCP packet capture/server-zone update, or every address-family/service transition is established by this excerpt. User acceptance is recorded without inventing those passes.
|
||||||
|
|
||||||
|
##### Reusable regression guidance
|
||||||
|
|
||||||
|
The following procedures are retained for future regression testing, **not outstanding acceptance blockers or a claim that every item was executed**. Keep UART0 and USB recovery available:
|
||||||
|
|
||||||
|
1. On IPv4, IPv6-only and dual-stack STA networks, exercise HTTPS login, serial/admin WebSockets, SSH user/admin access and one-writer/observer isolation. Test hostname access plus IPv4 and unscoped ULA/GUA IPv6 literals; verify SSH host keys and HTTPS identity rather than trusting discovery.
|
||||||
|
2. Capture DHCP DISCOVER/REQUEST option 12 at boot, rename/renew and reconnect, including a maximum-length suffix. With a configured DHCP/DNS server, verify the resulting zone entry and its server-controlled update/removal behavior.
|
||||||
|
3. Browse `_https._tcp` and `_ssh._tcp` and inspect A/AAAA/SRV answers over both families. Stop/restart each service; rename; drop/reacquire DHCP while IPv6 survives; change/deprecate/expire RA prefixes; disconnect/reconnect. Confirm eventual withdrawal/restoration, valid-address AAAA semantics and no persistent zero-A response. Exercise repeated family transitions and, where practical, missed-event/action repair.
|
||||||
|
4. Check link-local-only readiness and the fallback-AP transition explicitly. Confirm the client preserves interface scope; do not infer browser link-local success from `ONLINE` alone. Test multicast filtering/AP isolation separately from firmware address readiness.
|
||||||
|
5. Repeat the previously accepted 230400-baud mixed USB/WebSocket/SSH workload with discovery and address changes. Capture current/minimum internal, DMA and PSRAM availability, task stack margins, UART overflow and per-client drop counters. No new runtime memory reserve or maximum-baud performance guarantee is claimed.
|
||||||
|
|
||||||
|
Phase 12 is complete by the explicit user validation above. This acceptance update changes documentation only; the agent did not run a new build, test, upload, erase or device operation.
|
||||||
|
|
||||||
## Cross-phase release gates
|
## Cross-phase release gates
|
||||||
|
|
||||||
Every phase should satisfy the following before being marked complete:
|
Every phase should satisfy the following before being marked complete, with any user-waived check explicitly recorded in that phase rather than reported as passed:
|
||||||
|
|
||||||
1. A clean release build for the ESP32-S3 N16R8 target.
|
1. A clean release build for the ESP32-S3 N16R8 target.
|
||||||
2. No new compiler or project diagnostics attributable to the change.
|
2. No new compiler or project diagnostics attributable to the change.
|
||||||
@@ -305,7 +384,7 @@ The following are not implemented merely because flash partitions or library sup
|
|||||||
|
|
||||||
- NVS, flash, or PSRAM encryption.
|
- NVS, flash, or PSRAM encryption.
|
||||||
- Secure boot or production eFuse provisioning.
|
- Secure boot or production eFuse provisioning.
|
||||||
- OTA download, image confirmation, or rollback policy.
|
- Automatic OTA downloads, image signing, post-boot health confirmation and automatic rollback. The simple admin upload is complete in Phase 10; these advanced update features remain deferred.
|
||||||
- Core-dump collection or secret-safe core-dump processing.
|
- Core-dump collection or secret-safe core-dump processing.
|
||||||
- Filesystem mounting.
|
- Filesystem mounting.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Security operations — intermittent hobby use
|
||||||
|
|
||||||
|
This checklist is for a wireless serial cable used occasionally on a trusted network,
|
||||||
|
not an Internet-facing appliance or a production-security certification.
|
||||||
|
It covers existing firmware operations only: no dependency patches, crypto upgrades,
|
||||||
|
secure-boot, encrypted-NVS, or eFuse changes are part of this scope.
|
||||||
|
|
||||||
|
## Provision once, locally
|
||||||
|
|
||||||
|
- Use the physical **UART0 administration console** on a trusted computer.
|
||||||
|
Native USB CDC is a separate UART1 serial-data path, not the admin console.
|
||||||
|
- Create the first administrator with `user add <username> admin`.
|
||||||
|
Use a unique password; the prompt accepts 12–64 printable ASCII characters.
|
||||||
|
Add a separate `user` account for ordinary serial use, especially over SSH:
|
||||||
|
SSH `admin` opens administration, whereas SSH `user` opens UART1.
|
||||||
|
- Use unique Wi-Fi credentials, not passwords reused from other services.
|
||||||
|
Configure station profiles using the [Wi-Fi command reference](command_reference.md#wi-fi);
|
||||||
|
enter their passwords with `wifi profile secret <slot>`.
|
||||||
|
If using the device AP, set its password with `wifi ap secret`.
|
||||||
|
If the AP is unnecessary, select `wifi ap policy off`.
|
||||||
|
Run `wifi save` to persist working Wi-Fi settings.
|
||||||
|
- Before entering network credentials into a client, check device identity locally:
|
||||||
|
run `web certificate info` and `ssh host-key info` on trusted UART0.
|
||||||
|
Compare the SHA-256 certificate fingerprint in the browser certificate viewer
|
||||||
|
and the SSH host-key fingerprint in the SSH client with those local values.
|
||||||
|
Record the verified fingerprints; do not blindly dismiss certificate or changed-key warnings.
|
||||||
|
- Keep passwords in a password manager. Generated account passwords (`--generate`)
|
||||||
|
are displayed once; avoid terminal recording or sharing that output.
|
||||||
|
|
||||||
|
## Each use
|
||||||
|
|
||||||
|
- Use only a trusted LAN or the device's password-protected AP.
|
||||||
|
Do not configure router port forwarding or otherwise expose HTTPS/SSH to the Internet.
|
||||||
|
A trusted network reduces exposure; it does not make every attached client trustworthy.
|
||||||
|
- Check unexpected identity warnings against UART0 before logging in.
|
||||||
|
Keep UART0, native USB, and the connected RS-232 equipment physically controlled:
|
||||||
|
local serial access is not protected by network account authentication.
|
||||||
|
- When finished, sign out of the browser and close SSH clients.
|
||||||
|
Browser serial **Disconnect** alone is not sign-out.
|
||||||
|
- Prefer powering the device off when unused. If it must remain powered for USB
|
||||||
|
serial use, run `wifi stop` from UART0 and check `wifi status` after the queued
|
||||||
|
operation completes. This stops network access, not native USB UART1 access.
|
||||||
|
To keep Wi-Fi disabled at the next boot, follow with `wifi save`.
|
||||||
|
`wifi start` enables it again in RAM; save again only if that boot policy is wanted.
|
||||||
|
Do not rely on a temporary stop surviving reboot without saving.
|
||||||
|
|
||||||
|
## Change credentials or identity when needed
|
||||||
|
|
||||||
|
Use trusted UART0 for these operations to avoid losing a remote session mid-change.
|
||||||
|
Rotate after suspected disclosure, a lost client key, or a change of owner/access;
|
||||||
|
there is no need for a calendar-driven rotation ritual for this use case.
|
||||||
|
|
||||||
|
| What changed | Built-in operation |
|
||||||
|
|---|---|
|
||||||
|
| Account password | `user password <username>` (no-echo prompt). |
|
||||||
|
| Authorized SSH client key | `user show <username>` to identify its index; `user key delete <username> <0..2> --force`, then `user key add <username>` to enter the replacement public key. |
|
||||||
|
| Account no longer needed | `user delete <username> --force`; the final administrator is protected. |
|
||||||
|
| Wi-Fi password | Change the router credential as appropriate, then `wifi profile secret <slot>`; for the device AP use `wifi ap secret`. Finish with `wifi save`. |
|
||||||
|
| HTTPS device identity | `web certificate rotate --force`. |
|
||||||
|
| SSH device identity | `ssh host-key rotate --force`. |
|
||||||
|
|
||||||
|
Account changes invalidate that account's network sessions; identity replacement
|
||||||
|
interrupts the affected service. Check command results, then verify new fingerprints
|
||||||
|
through UART0 before updating browser trust or SSH known-host entries and reconnecting.
|
||||||
|
Changing a password does not remove authorized SSH keys; review both after compromise.
|
||||||
|
Identity rotation is not an account-password change. If an acknowledgement is lost,
|
||||||
|
inspect current state on UART0 rather than blindly repeating the operation.
|
||||||
|
|
||||||
|
## Recovery and backup limits
|
||||||
|
|
||||||
|
- Keep UART0 available for forgotten passwords, Wi-Fi mistakes, and network-service
|
||||||
|
recovery. Native USB can still carry UART1 data when networking fails, but cannot
|
||||||
|
repair accounts or identities; neither path promises uninterrupted service through reboot.
|
||||||
|
- For a healthy database, use normal UART0 account commands to repair access.
|
||||||
|
`user recover --force` is **only** for an unavailable user database: it destroys
|
||||||
|
that database's contents and rebuilds it empty, refuses a healthy database, and
|
||||||
|
requires creating a new administrator afterward. It is not a forgotten-password reset.
|
||||||
|
- `web reset --force` replaces HTTPS certificate/private-key material only;
|
||||||
|
`ssh reset --force` replaces SSH host-key material. Neither resets accounts or Wi-Fi.
|
||||||
|
These are scoped recovery operations, not a whole-device factory reset.
|
||||||
|
- No supported configuration/credential/identity backup-export command was found
|
||||||
|
in the application console. Keep a private reprovisioning record of settings,
|
||||||
|
account names/roles, client public keys, and verified fingerprints; store passwords
|
||||||
|
separately in a password manager. Do not expect status output to recover secrets
|
||||||
|
or private keys. Reprovisioning may require accepting a newly verified device identity.
|
||||||
|
- Physical extraction or firmware replacement is **not protected by this checklist**.
|
||||||
|
Treat a lost device or a raw flash copy as potentially exposing stored Wi-Fi secrets,
|
||||||
|
private identities, and password verifiers. Rotation is not a secure flash-erasure claim.
|
||||||
|
|
||||||
|
## Lightweight maintenance
|
||||||
|
|
||||||
|
Occasionally, and before a planned firmware update, review relevant ESP-IDF,
|
||||||
|
wolfSSL/wolfSSH, and bundled web-library upstream release/security notes.
|
||||||
|
If a relevant issue appears, stop network use until a separate upgrade decision is made;
|
||||||
|
USB serial remains an option. Dependency upgrades and their validation are separate work,
|
||||||
|
not an endless CVE audit or an implied promise that this firmware is vulnerability-free.
|
||||||
|
|
||||||
|
Commands and limits were checked against application sources and the [command reference](command_reference.md) at `f40c09c`.
|
||||||
|
This is source-reviewed operating guidance, not a build, hardware test, security audit,
|
||||||
|
or verification of any individual device's flash/eFuse configuration.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# User administration and authentication tests
|
# User administration and authentication tests
|
||||||
|
|
||||||
Reusable procedures, **not execution evidence**. [Phase 8 is accepted](web_administration_acceptance.md); overall 8D.22 user sign-off does not assert that every checklist item below ran. Historical 8A/B sections describe their original baselines only; current cookie authentication, removed bootstrap/credentials and UART0-only recovery override them. Use current contracts and the integrated regression section for present behavior. Never log generated/entered passwords, cookies/tickets or private/verifier material.
|
Reusable procedures, **not execution evidence**. [Phase 8 is accepted](roadmap.md#phase8-acceptance-evidence); overall 8D.22 user sign-off does not assert that every checklist item below ran. Historical 8A/B sections describe their original baselines only; current cookie authentication, removed bootstrap/credentials and UART0-only recovery override them. Use current contracts and the integrated regression section for present behavior. Never log generated/entered passwords, cookies/tickets or private/verifier material.
|
||||||
|
|
||||||
## Current Broker management regression procedure — 8D.16
|
## Current Broker management regression procedure — 8D.16
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ The [Network contract](web_administration.md#network) defines the current byte c
|
|||||||
|
|
||||||
## Current legacy-removal regression procedure
|
## Current legacy-removal regression procedure
|
||||||
|
|
||||||
The legacy bootstrap/credential/reconciliation instructions in the phase baselines below are **historical only**, superseded by [legacy credential removal](legacy_credential_removal.md) and the current [command reference](command_reference.md). Basic authentication is also historical; current HTTPS uses cookie login. The current overrides apply to later Phase 8C/browser procedures too: no `user bootstrap` or `web credentials` command remains, first-admin creation uses normal UART0 `user add`, and recovery rebuilds empty. Never treat the checklist below as evidence of execution.
|
The legacy bootstrap/credential/reconciliation instructions in the phase baselines below are **historical only**, superseded by [legacy credential removal](roadmap.md#phase8-legacy-credential-compatibility) and the current [command reference](command_reference.md). Basic authentication is also historical; current HTTPS uses cookie login. The current overrides apply to later Phase 8C/browser procedures too: no `user bootstrap` or `web credentials` command remains, first-admin creation uses normal UART0 `user add`, and recovery rebuilds empty. Never treat the checklist below as evidence of execution.
|
||||||
|
|
||||||
1. On a disposable controlled NVS image with only `user_db/database` missing, boot and confirm an empty database is persisted with zero accounts/admins and no imported credential. Reboot and confirm it remains empty. Keep physical UART0 attached; do not factory-erase the device for this test.
|
1. On a disposable controlled NVS image with only `user_db/database` missing, boot and confirm an empty database is persisted with zero accounts/admins and no imported credential. Reboot and confirm it remains empty. Keep physical UART0 attached; do not factory-erase the device for this test.
|
||||||
2. Run `user add maint admin` on UART0, check hidden password confirmation and cancellation, then confirm account/password persistence after reboot. Separately exercise `user add operator user --generate` and secure one-time display. Final-administrator delete/demotion must still fail. Existing SSH own-password generation restrictions and typed browser generated-password support remain unchanged.
|
2. Run `user add maint admin` on UART0, check hidden password confirmation and cancellation, then confirm account/password persistence after reboot. Separately exercise `user add operator user --generate` and secure one-time display. Final-administrator delete/demotion must still fail. Existing SSH own-password generation restrictions and typed browser generated-password support remain unchanged.
|
||||||
@@ -36,7 +36,7 @@ The legacy bootstrap/credential/reconciliation instructions in the phase baselin
|
|||||||
6. Confirm help/completion/status expose no legacy credential/bootstrap operation or secret. Removed commands must reject without mutation. Exercise retained user generation and `web certificate rotate --force` through supported frontends. `web reset --force` must change TLS only, require new certificate trust/login after HTTPS restart, leave user credentials/generations unchanged, and not revoke unrelated SSH sessions.
|
6. Confirm help/completion/status expose no legacy credential/bootstrap operation or secret. Removed commands must reject without mutation. Exercise retained user generation and `web certificate rotate --force` through supported frontends. `web reset --force` must change TLS only, require new certificate trust/login after HTTPS restart, leave user credentials/generations unchanged, and not revoke unrelated SSH sessions.
|
||||||
7. Recheck UART0/native USB availability and broker one-writer/isolated-observer behavior with network authentication unavailable. Record only nonsecret counters and telemetry. Older v1-only firmware cannot read HTTPS v2; logical NVS replacement is not secure flash erasure and no factory erase is required.
|
7. Recheck UART0/native USB availability and broker one-writer/isolated-observer behavior with network authentication unavailable. Record only nonsecret counters and telemetry. Older v1-only firmware cannot read HTTPS v2; logical NVS replacement is not secure flash erasure and no factory erase is required.
|
||||||
|
|
||||||
Acceptance and prior evidence limits are recorded in [legacy compatibility](legacy_credential_removal.md) and [overall acceptance](web_administration_acceptance.md); the preceding procedure is not an execution log.
|
Acceptance and prior evidence limits are recorded in [legacy compatibility](roadmap.md#phase8-legacy-credential-compatibility) and [overall acceptance](roadmap.md#phase8-acceptance-evidence); the preceding procedure is not an execution log.
|
||||||
|
|
||||||
## Historical Phase 8A baseline — role-based database and UART0 administration
|
## Historical Phase 8A baseline — role-based database and UART0 administration
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ Finally, issue commands concurrently from UART0 and admin SSH, including `user l
|
|||||||
|
|
||||||
## Integrated web administration regression procedure
|
## Integrated web administration regression procedure
|
||||||
|
|
||||||
Current retained-scope checks for future changes. [8D.22 sign-off](web_administration_acceptance.md) closes the phase; these are **not additional acceptance conditions or claims of individual execution**. Record exact revision, workload/client mix, duration, counters and heap/stack evidence for any new run. Do not treat host doubles as target timing/power-loss proof or reserve approval. Prepare UART0 recovery/native USB before disruptive tests and do not erase persisted data without explicit approval.
|
Current retained-scope checks for future changes. [8D.22 sign-off](roadmap.md#phase8-acceptance-evidence) closes the phase; these are **not additional acceptance conditions or claims of individual execution**. Record exact revision, workload/client mix, duration, counters and heap/stack evidence for any new run. Do not treat host doubles as target timing/power-loss proof or reserve approval. Prepare UART0 recovery/native USB before disruptive tests and do not erase persisted data without explicit approval.
|
||||||
|
|
||||||
### 1. Integrated login and authorization
|
### 1. Integrated login and authorization
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Web administration contracts
|
# Web administration contracts
|
||||||
|
|
||||||
Current, accepted firmware behavior. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](web_administration_acceptance.md), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
|
Current implementation contracts; [Phase 10 firmware upload is complete by explicit user acceptance on 2026-09-18](roadmap.md#phase-10--simple-admin-web-firmware-upload), confirming upload and normal operation, not specific fault or recovery checks. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](roadmap.md#phase8-acceptance-evidence), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
|
||||||
|
|
||||||
## Authentication and admission
|
## Authentication and admission
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ Current, accepted firmware behavior. Phase status and executed-evidence limits b
|
|||||||
- Mutation admission requires current cookie/principal, strict Origin and CSRF validation; administration additionally requires current `admin`. Normal users retain serial/status but cannot invoke administration directly. Authentication POST fetches use CORS mode with fixed same-origin URLs and same-origin credentials: do not accept Origin `null` to compensate for browser no-referrer behavior.
|
- Mutation admission requires current cookie/principal, strict Origin and CSRF validation; administration additionally requires current `admin`. Normal users retain serial/status but cannot invoke administration directly. Authentication POST fetches use CORS mode with fixed same-origin URLs and same-origin credentials: do not accept Origin `null` to compensate for browser no-referrer behavior.
|
||||||
- Session-store initialization is part of admitted HTTPS start; authentication failure gates HTTPS. Failed start/accepted stop disables and wipes session state. Logout invalidates only the originating session before socket cleanup; account mutation invalidates that account's sessions/tickets, including deletion/recreation, without revoking unrelated accounts. Currentness checks remain authoritative if best-effort notifications fail.
|
- Session-store initialization is part of admitted HTTPS start; authentication failure gates HTTPS. Failed start/accepted stop disables and wipes session state. Logout invalidates only the originating session before socket cleanup; account mutation invalidates that account's sessions/tickets, including deletion/recreation, without revoking unrelated accounts. Currentness checks remain authoritative if best-effort notifications fail.
|
||||||
- Four serial tickets and two admin tickets are digest-only, single-use, 30-second, session/principal-bound records. Cookie/Origin/ticket/currentness and transport admission precede explicit WebSocket 101. Store RNG/SHA/database calls run outside short spinlocks; IDs/expiry/epochs fence stale publication without nested store/transport locks.
|
- Four serial tickets and two admin tickets are digest-only, single-use, 30-second, session/principal-bound records. Cookie/Origin/ticket/currentness and transport admission precede explicit WebSocket 101. Store RNG/SHA/database calls run outside short spinlocks; IDs/expiry/epochs fence stale publication without nested store/transport locks.
|
||||||
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 39 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
|
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 40 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
|
||||||
|
|
||||||
`web_httpd_adapter` alone accesses private IDF 5.5.0 HTTPD state. It rejects duplicate/ambiguous headers, postpones 101 until admission, and wipes consumed header scratch while preserving right-aligned unread bytes. Optional Settings registration stages descriptor/name allocations before publishing either, avoiding the pinned public registration failure path. Re-audit these private boundaries on SDK upgrades and same-version SDK patches: the version guard does not detect patches that retain the same version number. HTTPD response headers are pointer-backed, not copied; both `Set-Cookie` value buffers must remain valid and distinct through response send. Do not reuse or wipe those buffers before sending completes. Do not enable header/ticket debug logging. Auth documents, scripts and sensitive responses are no-store with CSP/no-referrer/frame-denial protections; authored loader changes require matching CSP hashes. Generated assets are not a normal documentation/build output.
|
`web_httpd_adapter` alone accesses private IDF 5.5.0 HTTPD state. It rejects duplicate/ambiguous headers, postpones 101 until admission, and wipes consumed header scratch while preserving right-aligned unread bytes. Optional Settings registration stages descriptor/name allocations before publishing either, avoiding the pinned public registration failure path. Re-audit these private boundaries on SDK upgrades and same-version SDK patches: the version guard does not detect patches that retain the same version number. HTTPD response headers are pointer-backed, not copied; both `Set-Cookie` value buffers must remain valid and distinct through response send. Do not reuse or wipe those buffers before sending completes. Do not enable header/ticket debug logging. Auth documents, scripts and sensitive responses are no-store with CSP/no-referrer/frame-denial protections; authored loader changes require matching CSP hashes. Generated assets are not a normal documentation/build output.
|
||||||
|
|
||||||
@@ -28,12 +28,14 @@ HTTPD alone owns browser-admin socket IO and its 1,552-byte PSRAM-only payload.
|
|||||||
Typed Settings permissions do not expand shell permissions. Parsed canonical arguments, not raw prefixes or completion suggestions, control admission:
|
Typed Settings permissions do not expand shell permissions. Parsed canonical arguments, not raw prefixes or completion suggestions, control admission:
|
||||||
|
|
||||||
- Browser `web` permits only `web status`, `web stop`, and exact `web certificate rotate --force`; certificate info/reset, diagnostics/performance and other web forms are denied.
|
- Browser `web` permits only `web status`, `web stop`, and exact `web certificate rotate --force`; certificate info/reset, diagnostics/performance and other web forms are denied.
|
||||||
- Browser `wifi`/`mdns` permit only status. Network mutation belongs to typed Settings or UART0/admin SSH.
|
- Browser `wifi` uses the canonical command handler, including bare `wifi` (status), profile/AP settings, persistence, lifecycle and network diagnostics. Profile/AP secret entry uses the shared hidden prompt: no input echo, history or completion of secret bytes; cancellation, disconnect and failed currentness discard the input. Explicit `wifi ap show-secret` reveals the AP password only in the invoking admin terminal; routine status and completion do not reveal credentials. Browser `mdns` still permits only exact `mdns status`.
|
||||||
- Browser `user` permits status/list/show and interactive add/password plus forced role/delete for **other accounts only**. Self changes, generated passwords, key commands and recovery are denied there; typed Accounts supports the separately bounded self/generated/key workflows.
|
- Browser `user` permits status/list/show and interactive add/password plus forced role/delete for **other accounts only**. Self changes, generated passwords, key commands and recovery are denied there; typed Accounts supports the separately bounded self/generated/key workflows.
|
||||||
- Browser SSH stop/disconnect/reset and host-key mutation are denied; typed SSH Settings has its own safe owner path. Do not claim full browser-shell parity.
|
- Browser SSH stop/disconnect/reset and host-key mutation are denied; typed SSH Settings has its own safe owner path. Do not claim full browser-shell parity.
|
||||||
- Browser `reboot` and owner-relative `exit` are supported. First-admin provisioning uses normal `user add` on UART0; unavailable-database recovery is UART0-only. The legacy `user bootstrap` and web credential commands no longer exist.
|
- Browser `reboot` and owner-relative `exit` are supported. First-admin provisioning uses normal `user add` on UART0; unavailable-database recovery is UART0-only. The legacy `user bootstrap` and web credential commands no longer exist.
|
||||||
|
|
||||||
Self-affecting shell actions use the existing bounded drain/control path (up to ten seconds plus a short delay), not guaranteed peer delivery. Browser certificate rotation hands a typed action after drain/200 ms to the existing 12 KiB dispatcher, never crypto/NVS on the 4 KiB control stack. Pending input is discarded through execution, and an executing slot remains reserved across self-detach. UART0/admin SSH retain canonical recovery actions.
|
Wi-Fi changes use the existing canonical manager path, not deferred browser drain/acknowledgement. Stop, reconnect, profile/AP edits, load/defaults/reset can disconnect network clients before output arrives. A lost result does not cancel admitted work: reconnect and inspect before retrying; use UART0 if networking is unavailable. Changes are RAM-only until `wifi save`, except `wifi reset`, which also persists defaults. Native USB remains independent UART1 access, not an administration console.
|
||||||
|
|
||||||
|
Deferred self-affecting shell actions use the existing bounded drain/control path (up to ten seconds plus a short delay), not guaranteed peer delivery. Browser certificate rotation hands a typed action after drain/200 ms to the existing 12 KiB dispatcher, never crypto/NVS on the 4 KiB control stack. Pending input is discarded through execution, and an executing slot remains reserved across self-detach. UART0/admin SSH retain canonical recovery actions.
|
||||||
|
|
||||||
## Typed settings API and operation lifetime
|
## Typed settings API and operation lifetime
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ All routes below are under `/api/settings/`. Each domain has bodyless GET snapsh
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `web_serial_settings`, serial service | `serial` / `serial-operation` | 256 / 256 / 96 |
|
| `web_serial_settings`, serial service | `serial` / `serial-operation` | 256 / 256 / 96 |
|
||||||
| `web_account_settings`, user database | `accounts` / `account-operation` | 768 / 1024 (accounts), 512 (keys) / 96 |
|
| `web_account_settings`, user database | `accounts` / `account-operation` | 768 / 1024 (accounts), 512 (keys) / 96 |
|
||||||
| `web_network_settings`, Wi-Fi + mDNS | `network` / `network-operation` | 768 / 2048 / 128 |
|
| `web_network_settings`, Wi-Fi + mDNS | `network` / `network-operation` | 768 / 2304 / 128 |
|
||||||
| `web_display_settings`, local status UI | `display` / `display-operation` | 256 / 128 / 96 |
|
| `web_display_settings`, local status UI | `display` / `display-operation` | 256 / 128 / 96 |
|
||||||
| `web_broker_settings`, session broker | `broker` / `broker-operation` | 256 / 2048 / 96 |
|
| `web_broker_settings`, session broker | `broker` / `broker-operation` | 256 / 2048 / 96 |
|
||||||
| `web_ssh_settings`, SSH owner/security | `ssh` / `ssh-operation` | 256 / 768 / 96 |
|
| `web_ssh_settings`, SSH owner/security | `ssh` / `ssh-operation` | 256 / 768 / 96 |
|
||||||
@@ -67,7 +69,7 @@ Separate bodyless POST `accounts/generate-password` returns one 24-character val
|
|||||||
|
|
||||||
### Network
|
### Network
|
||||||
|
|
||||||
Wi-Fi config/runtime is one zero-wait consistent projection; mDNS is a separate projection, not cross-domain atomic authorization. Four stable profiles carry enabled/priority/security/SSID/password-configured metadata. `mixed` means WPA2-or-stronger, not open. `announced` is expected STA announcement, not verified DNS.
|
Wi-Fi config/runtime is one zero-wait consistent projection; mDNS is a separate projection, not cross-domain atomic authorization. Four stable profiles carry enabled/priority/security/SSID/password-configured metadata. `mixed` means WPA2-or-stronger, not open. `announced` is expected STA announcement, not verified DNS. Runtime `ipv6_addresses` contains up to three preferred addresses from the same snapshot as the availability flags, encoded as eight lowercase four-digit hextets without a zone. Network settings display separate link-local, ULA and GUA lists; absent groups show `none`. Link-local access needs the client's interface scope, and address presence does not assert a route or Internet reachability. Encoding uses the existing snapshot only, not netif/driver calls on HTTPD; the 2304-byte response buffer adds 256 bytes of bounded stack storage.
|
||||||
|
|
||||||
SSID wire values are reversible **bytes**, maximum 32: printable ASCII, standard single-character JSON escapes and `\u00HH`, with no raw non-ASCII, non-byte Unicode or surrogates. NUL/non-UTF-8 round-trip. UI text is UTF-8-encoded before byte serialization; exact reversible text or literal hex preserves existing bytes and BOM, with no silent replacement/truncation.
|
SSID wire values are reversible **bytes**, maximum 32: printable ASCII, standard single-character JSON escapes and `\u00HH`, with no raw non-ASCII, non-byte Unicode or surrogates. NUL/non-UTF-8 round-trip. UI text is UTF-8-encoded before byte serialization; exact reversible text or literal hex preserves existing bytes and BOM, with no silent replacement/truncation.
|
||||||
|
|
||||||
@@ -111,6 +113,14 @@ HTTPS ordering is **commit → stop → restart**. Precommit generation/RNG/stor
|
|||||||
|
|
||||||
Save drafts; rotation/restart invalidates all web logins and closes both browser routes. Inspect `web certificate info` through trusted UART0, verify fingerprint before renewing trust, then reload/sign in freshly. Accepting a warning alone is not trusted verification. Use canonical UART0/admin SSH `web stop` / `web start` for retained-server recovery. Network/SSH/USB are not stopped by HTTPS-only operations; whole-device reboot interrupts all transports and loses unsaved RAM.
|
Save drafts; rotation/restart invalidates all web logins and closes both browser routes. Inspect `web certificate info` through trusted UART0, verify fingerprint before renewing trust, then reload/sign in freshly. Accepting a warning alone is not trusted verification. Use canonical UART0/admin SSH `web stop` / `web start` for retained-server recovery. Network/SSH/USB are not stopped by HTTPS-only operations; whole-device reboot interrupts all transports and loses unsaved RAM.
|
||||||
|
|
||||||
|
### Application firmware upload
|
||||||
|
|
||||||
|
The admin-only firmware card is in **Settings → HTTPS / Reboot**. It sends a raw `POST /api/firmware` with `application/octet-stream`, known length, session cookie, same-origin Origin and `X-CSRF-Token`; it is not a JSON Settings operation or dispatcher/result-slot workflow. Standard SDK OTA APIs stream through a 4 KiB buffer to the inactive application slot and select it only after validation/currentness checks. Only that slot and `otadata` are written; NVS/data partitions are untouched.
|
||||||
|
|
||||||
|
One upload reserves HTTPS lifecycle/identity and excludes ordinary software reboot. The synchronous HTTPD handler can stall/drop browser serial/admin sessions. Its 120-second total receive-loop and ten-second stall checks are not preemptive flash deadlines or uninterrupted-traffic guarantees. Successful response send schedules a delayed reboot, not proof of peer receipt. Failed response after commit leaves the image selected, schedules no automatic reboot, releases reservations for manual reboot, and latches subsequent admissible uploads to 409 `firmware_selected_reboot_required` until reset. Never automatically retry an uncertain outcome.
|
||||||
|
|
||||||
|
See [firmware update](roadmap.md#phase-10--simple-admin-web-firmware-upload) for wired-first installation, exact image selection, manual recovery, acceptance limits and reusable future hardware regression checks. Phase 10 is accepted; there are no signature/version policies or automatic rollback.
|
||||||
|
|
||||||
### SSH
|
### SSH
|
||||||
|
|
||||||
GET `ssh` supplies service/session state plus identity generation, fixed P-256 algorithm, unpadded OpenSSH `SHA256:` base64 fingerprint and rotatable flag. Service actions use exactly `action`, `generation`, `target`; rotate adds `identity_generation` and requires target zero. Start/stop and exact-session disconnect use published state, saturated service generation and the command mutex; exhausted SSH session slots retire rather than wrap. Disconnect success is an owner close request, not completed teardown. HTTPD never calls wolfSSH or waits for the SSH task.
|
GET `ssh` supplies service/session state plus identity generation, fixed P-256 algorithm, unpadded OpenSSH `SHA256:` base64 fingerprint and rotatable flag. Service actions use exactly `action`, `generation`, `target`; rotate adds `identity_generation` and requires target zero. Start/stop and exact-session disconnect use published state, saturated service generation and the command mutex; exhausted SSH session slots retire rather than wrap. Disconnect success is an owner close request, not completed teardown. HTTPD never calls wolfSSH or waits for the SSH task.
|
||||||
@@ -123,6 +133,6 @@ SSH changes leave invoking HTTPS available, so they use the ordinary ID-dispatch
|
|||||||
|
|
||||||
## Diagnostics, recovery and scope boundaries
|
## Diagnostics, recovery and scope boundaries
|
||||||
|
|
||||||
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](legacy_credential_removal.md). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
|
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](roadmap.md#phase8-legacy-credential-compatibility). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
|
||||||
|
|
||||||
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption, secure boot or OTA is implied by acceptance. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
|
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption or secure boot is implied by Phase 8 acceptance. Phase 10 application upload is complete by explicit user acceptance of upload and normal operation; unreported fault, NVS-comparison, power-loss and recovery checks are not implied. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Web administration acceptance
|
|
||||||
|
|
||||||
## Phase 8D.22 sign-off — 2026-09-13
|
|
||||||
|
|
||||||
The user explicitly accepted the tested firmware: **“Yep, I tested the firmware thats a 8d.22 signoff.”** Phase 8D is complete for its retained scope. This supersedes earlier per-slice target-pending, parent-review and integration-acceptance gates; it does not require additional testing to establish the user's sign-off. Phase 8A/B/C were already recorded as complete and target-hardware validated in the roadmap, so [Phase 8 is complete](roadmap.md#phase-8--role-based-users-and-administrative-access--complete).
|
|
||||||
|
|
||||||
Accepted functionality comprises cookie authentication, isolated browser serial/admin terminals, typed Serial/Accounts/authorized-key/Network/Display/Broker/SSH/HTTPS settings, contextual controls, confirmed generation-safe writer/service/identity actions and retained UART0/native-USB recovery. [Current contracts](web_administration.md) define actual permissions, bounded failures and partial effects; acceptance is not a claim of unrestricted browser-shell parity.
|
|
||||||
|
|
||||||
Scope decisions remain effective: 8D.15 dedicated typed network diagnostics was removed (shell diagnostics retained); the unimplemented 8D.19 ordinary browser-session/native-USB control remainder was removed (SSH controls retained). Browser identity Reset/recovery/export was not added. Phase 9 security/production hardening is next only on a separate user request.
|
|
||||||
|
|
||||||
## Latest firmware evidence
|
|
||||||
|
|
||||||
The **prior final firmware build passed**, reporting **100,556 bytes linked RAM / 1,828,573 bytes flash**, at **160 MHz**. This is recorded prior build evidence, not a build run during documentation consolidation. Host regression suites and independent implementation reviews were previously reported passing; no new host/runtime test pass is asserted here.
|
|
||||||
|
|
||||||
Latest user telemetry, bytes:
|
|
||||||
|
|
||||||
| Sample / memory capability | Free | Minimum free | Largest block |
|
|
||||||
|---|---:|---:|---:|
|
|
||||||
| Boot internal 8-bit | 59,808 | 58,840 | 31,744 |
|
|
||||||
| Boot internal DMA | 52,052 | 51,084 | 31,744 |
|
|
||||||
| Boot PSRAM | 8,196,968 | 8,183,972 | 8,126,464 |
|
|
||||||
| Loaded after burst, internal 8-bit | 31,508 | 2,052 | 18,432 |
|
|
||||||
| Loaded after burst, internal DMA | 23,752 | 460 | 18,432 |
|
|
||||||
| Loaded after burst, PSRAM | 8,136,624 | 8,065,972 | 7,995,392 |
|
|
||||||
|
|
||||||
Loaded SSH minimum-free stack was **15,028 bytes**. The capture had two active SSH sessions across the serial/admin roles, two serial WebSockets and USB, with SSH holding the serial writer. Browser admin had been used and then closed; it was **not active in the captured loaded sample**. Web send/queue/protocol error counters were zero; SSH IO errors were zero, with one handshake failure and one session revocation retained without attributing a cause.
|
|
||||||
|
|
||||||
Latest broker/serial counters were not supplied, so these transport counters do **not** establish an exact latest zero-drop or byte-integrity result. No latest full-mix-with-browser-admin-active, individual fault-injection, exact duration, soak, cleanup-cycle or reserve-floor result is inferred. Generic SDK TLS `-0x004C` / NET_RECV_FAILED is not an OOM diagnosis. Two boot authentication failures could plausibly involve stale browser cookies, but that explanation is unconfirmed.
|
|
||||||
|
|
||||||
## Earlier acceptance retained without replaying the timeline
|
|
||||||
|
|
||||||
- M1 browser login/logout and M2 shared browser administration were explicitly accepted by the user; later Serial/account/Network presentation and legacy-credential cleanup also received scoped acceptance.
|
|
||||||
- The user explicitly accepted the combined binary WebSocket-send fix at **160 MHz, 230400 baud with the full client mix, including browser admin**. That prior acceptance stands independently of the latest capture's closed browser-admin socket. It does not imply an unreported latest exact counter comparison or soak duration. Keep the combined send and bounded failed-send isolation, not the earlier frequency-only experiment.
|
|
||||||
- Ordinary HTTPS idle cleanup was accepted as working; that is not a guarantee against all future admission failures or owner delays.
|
|
||||||
|
|
||||||
## Nonblocking follow-ups and evidence limits
|
|
||||||
|
|
||||||
The extremely low internal/DMA lifetime minima remain an unresolved transient-headroom follow-up, **not a blocker reopening 8D sign-off and not an approved reserve**. Capability pools overlap; summed per-region lifetime minima can be conservative/non-simultaneous and do not prove an allocation failure. Do not add internal and DMA numbers together or attribute an error to memory pressure without correlated evidence. HTTPD/dispatcher stack margins, peak correlation, allocation reserve policy and long-run cleanup/soak evidence remain distinct future measurements.
|
|
||||||
|
|
||||||
[Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and focused test runners remain available for future changes; listing them is not evidence they all ran on hardware. Documentation-only consolidation changes no firmware, configuration, generated assets or test implementation and performs no build/upload/erase/commit. No new Phase 9 work is authorized by this acceptance.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Web admission diagnostics
|
# Web admission diagnostics
|
||||||
|
|
||||||
Current default-disabled instrumentation, not an admission fix or an allocation-failure detector. `src/web_diagnostics.{c,h}` observes public synchronous HTTPS create/close callbacks and the four serial/admin ticket/upgrade handlers. It adds no owner task, queued probe or socket-capacity change. See [ordinary idle cleanup](https_idle_cleanup.md) for the independent cleanup policy and [acceptance evidence](web_administration_acceptance.md) for user reports.
|
Current default-disabled instrumentation, not an admission fix or an allocation-failure detector. `src/web_diagnostics.{c,h}` observes public synchronous HTTPS create/close callbacks and the four serial/admin ticket/upgrade handlers. It adds no owner task, queued probe or socket-capacity change. See [ordinary idle cleanup](https_idle_cleanup.md) for the independent cleanup policy and [acceptance evidence](roadmap.md#phase8-acceptance-evidence) for user reports.
|
||||||
|
|
||||||
## Usage and interpretation
|
## Usage and interpretation
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current contract and accepted fix
|
## Current contract and accepted fix
|
||||||
|
|
||||||
Default-disabled instrumentation measures broker fan-out and binary WebSocket TX, not CPU usage, peer receipt or pure scheduler latency. The user separately accepted the combined binary header/payload-send fix at **160 MHz / 230400 baud with full mix including browser admin**. [Acceptance evidence](web_administration_acceptance.md) distinguishes that report from the latest capture and unmeasured reserves. The old frequency-only experiment is not the retained solution.
|
Default-disabled instrumentation measures broker fan-out and binary WebSocket TX, not CPU usage, peer receipt or pure scheduler latency. The user separately accepted the combined binary header/payload-send fix at **160 MHz / 230400 baud with full mix including browser admin**. [Acceptance evidence](roadmap.md#phase8-acceptance-evidence) distinguishes that report from the latest capture and unmeasured reserves. The old frequency-only experiment is not the retained solution.
|
||||||
|
|
||||||
`web_httpd_ws_send_binary` is an IDF-5.5.0-pinned owner-only session-override send with a bounded 516-byte header/payload scratch copy and 512-byte payload. Generation validation and one outstanding item per slot remain. A non-full send installs a reject-only override before close/shutdown, preventing SDK automatic PONG/CLOSE from reentering TLS with different arguments after incomplete output, even if shutdown fails. No retry/replay; HTTPD owns TLS destruction. Text/control/admin retain the SDK sender. One API call does not promise one TLS record/packet or peer receipt. Keep CPU160, priorities, scheduling and 4096-byte broker/512-byte web payload bounds unless separately changing them with evidence.
|
`web_httpd_ws_send_binary` is an IDF-5.5.0-pinned owner-only session-override send with a bounded 516-byte header/payload scratch copy and 512-byte payload. Generation validation and one outstanding item per slot remain. A non-full send installs a reject-only override before close/shutdown, preventing SDK automatic PONG/CLOSE from reentering TLS with different arguments after incomplete output, even if shutdown fails. No retry/replay; HTTPD owns TLS destruction. Text/control/admin retain the SDK sender. One API call does not promise one TLS record/packet or peer receipt. Keep CPU160, priorities, scheduling and 4096-byte broker/512-byte web payload bounds unless separately changing them with evidence.
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -16,7 +16,7 @@ CONFIG_ESP_WIFI_RX_BA_WIN=6
|
|||||||
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4
|
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4
|
||||||
|
|
||||||
# Keep concurrent HTTPS handshakes from exhausting scarce internal DRAM.
|
# Keep concurrent HTTPS handshakes from exhausting scarce internal DRAM.
|
||||||
# Active TLS material remains unencrypted in PSRAM until the hardening phase.
|
# Active TLS material remains unencrypted in PSRAM; physical extraction is outside scope.
|
||||||
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
|
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
|
||||||
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
|
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
|
||||||
# Hardware AES can hang in the PSRAM DMA path and cannot be shared safely with
|
# Hardware AES can hang in the PSRAM DMA path and cannot be shared safely with
|
||||||
@@ -39,6 +39,9 @@ CONFIG_ESP_HTTPS_SERVER_ENABLE=y
|
|||||||
CONFIG_HTTPD_WS_SUPPORT=y
|
CONFIG_HTTPD_WS_SUPPORT=y
|
||||||
# Reserve capacity for HTTPS/WebSocket clients plus two bounded SSH sessions.
|
# Reserve capacity for HTTPS/WebSocket clients plus two bounded SSH sessions.
|
||||||
CONFIG_LWIP_MAX_SOCKETS=16
|
CONFIG_LWIP_MAX_SOCKETS=16
|
||||||
|
# One dual-stack listener per service; SLAAC is enabled on STA by its owner.
|
||||||
|
CONFIG_LWIP_IPV4=y
|
||||||
|
CONFIG_LWIP_IPV6=y
|
||||||
# Keep work submission bounded; one-second socket timeouts limit shared-task stalls.
|
# Keep work submission bounded; one-second socket timeouts limit shared-task stalls.
|
||||||
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ CONFIG_ESP_WOLFSSL_NO_STACK_SIZE_BUILD_WARNING=y
|
|||||||
# Keep configurable hostname discovery STA-only and bounded. The responder task
|
# Keep configurable hostname discovery STA-only and bounded. The responder task
|
||||||
# remains internal; general mDNS metadata prefers PSRAM to protect internal heap.
|
# remains internal; general mDNS metadata prefers PSRAM to protect internal heap.
|
||||||
CONFIG_MDNS_MAX_INTERFACES=1
|
CONFIG_MDNS_MAX_INTERFACES=1
|
||||||
CONFIG_MDNS_MAX_SERVICES=1
|
CONFIG_MDNS_MAX_SERVICES=2
|
||||||
CONFIG_MDNS_PREDEF_NETIF_STA=y
|
CONFIG_MDNS_PREDEF_NETIF_STA=y
|
||||||
# CONFIG_MDNS_PREDEF_NETIF_AP is not set
|
# CONFIG_MDNS_PREDEF_NETIF_AP is not set
|
||||||
# CONFIG_MDNS_PREDEF_NETIF_ETH is not set
|
# CONFIG_MDNS_PREDEF_NETIF_ETH is not set
|
||||||
@@ -72,3 +75,8 @@ CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT=y
|
|||||||
|
|
||||||
# Keep diagnostic and interactive-console logging concise but useful.
|
# Keep diagnostic and interactive-console logging concise but useful.
|
||||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||||
|
|
||||||
|
# Intermittent-use baseline: avoid secret-bearing crash dumps/register output.
|
||||||
|
# Ordinary logging remains enabled; this is not physical-memory protection.
|
||||||
|
CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y
|
||||||
|
CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ idf_component_register(
|
|||||||
"web_broker_settings.c"
|
"web_broker_settings.c"
|
||||||
"web_ssh_settings.c"
|
"web_ssh_settings.c"
|
||||||
"web_lifecycle_settings.c"
|
"web_lifecycle_settings.c"
|
||||||
|
"web_firmware_update.c"
|
||||||
"web_admin_tickets.c"
|
"web_admin_tickets.c"
|
||||||
"web_admin_transport.c"
|
"web_admin_transport.c"
|
||||||
"web_assets_data.c"
|
"web_assets_data.c"
|
||||||
@@ -59,6 +60,7 @@ idf_component_register(
|
|||||||
"mdns_console.c"
|
"mdns_console.c"
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
REQUIRES
|
REQUIRES
|
||||||
|
app_update
|
||||||
bootloader_support
|
bootloader_support
|
||||||
console
|
console
|
||||||
esp_driver_gpio
|
esp_driver_gpio
|
||||||
|
|||||||
@@ -556,9 +556,9 @@ static bool remote_command_allowed(const admin_request_t *request)
|
|||||||
strcmp(argv[1], "recover") == 0) {
|
strcmp(argv[1], "recover") == 0) {
|
||||||
allowed = false;
|
allowed = false;
|
||||||
}
|
}
|
||||||
/* Temporary browser policy until lifecycle acknowledgements/revocation are
|
/* Classify parsed canonical arguments, not raw prefixes. Wi-Fi uses the
|
||||||
* coordinated (8D.7). Classify parsed canonical arguments, not raw prefixes.
|
* canonical handler, including hidden prompts and disruptive changes;
|
||||||
* User mutations remain available through UART0/SSH, subject to their policy.
|
* unrelated browser restrictions remain narrower than UART0/SSH.
|
||||||
*/
|
*/
|
||||||
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
|
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
|
||||||
if (strcmp(argv[0], "web") == 0) {
|
if (strcmp(argv[0], "web") == 0) {
|
||||||
@@ -566,7 +566,7 @@ static bool remote_command_allowed(const admin_request_t *request)
|
|||||||
strcmp(argv[1], "stop") == 0)) ||
|
strcmp(argv[1], "stop") == 0)) ||
|
||||||
(argc == 4U && strcmp(argv[1], "certificate") == 0 &&
|
(argc == 4U && strcmp(argv[1], "certificate") == 0 &&
|
||||||
strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0);
|
strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0);
|
||||||
} else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) {
|
} else if (strcmp(argv[0], "mdns") == 0) {
|
||||||
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
||||||
} else if (strcmp(argv[0], "user") == 0) {
|
} else if (strcmp(argv[0], "user") == 0) {
|
||||||
allowed = admin_ssh_console_web_user_command_allowed(
|
allowed = admin_ssh_console_web_user_command_allowed(
|
||||||
|
|||||||
+14
-8
@@ -13,6 +13,7 @@
|
|||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_netif_ip_addr.h"
|
#include "esp_netif_ip_addr.h"
|
||||||
#include "esp_system.h"
|
#include "esp_system.h"
|
||||||
|
#include "web_firmware_update.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/portmacro.h"
|
#include "freertos/portmacro.h"
|
||||||
#include "freertos/semphr.h"
|
#include "freertos/semphr.h"
|
||||||
@@ -270,14 +271,14 @@ static char parity_letter(serial_config_parity_t parity)
|
|||||||
static void format_ipv4(uint32_t address, char output[16])
|
static void format_ipv4(uint32_t address, char output[16])
|
||||||
{
|
{
|
||||||
if (address == 0U) {
|
if (address == 0U) {
|
||||||
memcpy(output, "0.0.0.0", sizeof("0.0.0.0"));
|
memcpy(output, "none", sizeof("none"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_ip4_addr_t ip = {.addr = address};
|
esp_ip4_addr_t ip = {.addr = address};
|
||||||
int written = snprintf(output, 16U, IPSTR, IP2STR(&ip));
|
int written = snprintf(output, 16U, IPSTR, IP2STR(&ip));
|
||||||
if (written < 0 || written >= 16) {
|
if (written < 0 || written >= 16) {
|
||||||
memcpy(output, "0.0.0.0", sizeof("0.0.0.0"));
|
memcpy(output, "unknown", sizeof("unknown"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,8 +565,8 @@ static void render_overview_page(const local_status_snapshot_t *snapshot)
|
|||||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line);
|
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line);
|
||||||
|
|
||||||
format_ipv4(snapshot->wifi.ip, address);
|
format_ipv4(snapshot->wifi.ip, address);
|
||||||
format_text(line, "IP:%s", address);
|
format_text(line, "IPv4:%s", snapshot->wifi_available ? address : "unknown");
|
||||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 32U, line);
|
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 32U, line);
|
||||||
|
|
||||||
format_text(line, "Web:%u SSH:%u USB:%u",
|
format_text(line, "Web:%u SSH:%u USB:%u",
|
||||||
snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U,
|
snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U,
|
||||||
@@ -663,7 +664,11 @@ static void render_network_page(const local_status_snapshot_t *snapshot)
|
|||||||
const char *ssid;
|
const char *ssid;
|
||||||
uint8_t channel;
|
uint8_t channel;
|
||||||
|
|
||||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "Network / services");
|
/* Address availability, not route/reachability; fit both flags in 21 columns. */
|
||||||
|
format_text(line, "IPv6 LL:%c ULA/GUA:%c",
|
||||||
|
!snapshot->wifi_available ? '?' : snapshot->wifi.ipv6_linklocal ? 'Y' : 'N',
|
||||||
|
!snapshot->wifi_available ? '?' : snapshot->wifi.ipv6_routable ? 'Y' : 'N');
|
||||||
|
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, line);
|
||||||
wifi_identity(snapshot, &ssid, &channel);
|
wifi_identity(snapshot, &ssid, &channel);
|
||||||
format_ssid(ssid, ssid_display);
|
format_ssid(ssid, ssid_display);
|
||||||
format_text(line, "WiFi:%s Ch:%u", ssid_display, channel);
|
format_text(line, "WiFi:%s Ch:%u", ssid_display, channel);
|
||||||
@@ -671,8 +676,8 @@ static void render_network_page(const local_status_snapshot_t *snapshot)
|
|||||||
wifi_snapshot_bitmap(snapshot));
|
wifi_snapshot_bitmap(snapshot));
|
||||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 8U, line);
|
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 8U, line);
|
||||||
format_ipv4(snapshot->wifi.ip, address);
|
format_ipv4(snapshot->wifi.ip, address);
|
||||||
format_text(line, "IP:%s", address);
|
format_text(line, "IPv4:%s", snapshot->wifi_available ? address : "unknown");
|
||||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 16U, line);
|
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, line);
|
||||||
format_text(line, "AP:%s Clients:%u", on_off(snapshot->wifi.ap_running),
|
format_text(line, "AP:%s Clients:%u", on_off(snapshot->wifi.ap_running),
|
||||||
snapshot->wifi.ap_client_count);
|
snapshot->wifi.ap_client_count);
|
||||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line);
|
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line);
|
||||||
@@ -1101,7 +1106,8 @@ static void execute_action(local_status_ui_state_t *state,
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case LOCAL_STATUS_ACTION_REBOOT:
|
case LOCAL_STATUS_ACTION_REBOOT:
|
||||||
state->restart_pending = true;
|
error = web_firmware_update_reserve_reboot();
|
||||||
|
state->restart_pending = error == ESP_OK;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
error = ESP_ERR_INVALID_ARG;
|
error = ESP_ERR_INVALID_ARG;
|
||||||
|
|||||||
+142
-4
@@ -4,6 +4,9 @@
|
|||||||
#include "mdns_service.h"
|
#include "mdns_service.h"
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include "esp_netif.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "sdkconfig.h"
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@@ -17,6 +20,125 @@ static bool s_component_initialized;
|
|||||||
static bool s_initialization_failed;
|
static bool s_initialization_failed;
|
||||||
static bool s_announced;
|
static bool s_announced;
|
||||||
static esp_err_t s_last_error;
|
static esp_err_t s_last_error;
|
||||||
|
static void lock_service(void);
|
||||||
|
static void unlock_service(void);
|
||||||
|
static portMUX_TYPE s_availability_mux = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
static bool s_https_available;
|
||||||
|
static bool s_ssh_available;
|
||||||
|
static unsigned s_requested_families;
|
||||||
|
static bool s_family_request_valid;
|
||||||
|
static int64_t s_family_refresh_at;
|
||||||
|
#define MDNS_FAMILY_REPAIR_US INT64_C(30000000)
|
||||||
|
/* Component-facing state belongs exclusively to the Wi-Fi manager task. */
|
||||||
|
static bool s_https_registered;
|
||||||
|
static bool s_ssh_registered;
|
||||||
|
static uint32_t s_applied_generation;
|
||||||
|
|
||||||
|
void mdns_service_set_https_available(bool available)
|
||||||
|
{
|
||||||
|
portENTER_CRITICAL(&s_availability_mux);
|
||||||
|
s_https_available = available;
|
||||||
|
portEXIT_CRITICAL(&s_availability_mux);
|
||||||
|
}
|
||||||
|
|
||||||
|
void mdns_service_set_ssh_available(bool available)
|
||||||
|
{
|
||||||
|
portENTER_CRITICAL(&s_availability_mux);
|
||||||
|
s_ssh_available = available;
|
||||||
|
portEXIT_CRITICAL(&s_availability_mux);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t reconcile_record(const char *type, uint16_t port,
|
||||||
|
bool available, bool *registered)
|
||||||
|
{
|
||||||
|
if (available == *registered) return ESP_OK;
|
||||||
|
esp_err_t error = available
|
||||||
|
? mdns_service_add(NULL, type, "_tcp", port, NULL, 0)
|
||||||
|
: mdns_service_remove(type, "_tcp");
|
||||||
|
if (error == ESP_OK) *registered = available;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
esp_netif_t *sta;
|
||||||
|
bool online;
|
||||||
|
unsigned families;
|
||||||
|
} family_snapshot_t;
|
||||||
|
|
||||||
|
/* get_all_ip6 reads lwIP state directly in IDF 5.5; collect in TCP/IP context. */
|
||||||
|
static esp_err_t read_families(void *context)
|
||||||
|
{
|
||||||
|
family_snapshot_t *snapshot = context;
|
||||||
|
esp_netif_t *sta = snapshot->sta;
|
||||||
|
if (snapshot->online && esp_netif_is_netif_up(sta)) {
|
||||||
|
esp_netif_ip_info_t ip4 = {0};
|
||||||
|
if (esp_netif_get_ip_info(sta, &ip4) == ESP_OK && ip4.ip.addr != 0) snapshot->families |= 1U;
|
||||||
|
esp_ip6_addr_t ip6[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
|
||||||
|
int count = esp_netif_get_all_ip6(sta, ip6);
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
if (ip6[i].addr[0] || ip6[i].addr[1] || ip6[i].addr[2] || ip6[i].addr[3]) {
|
||||||
|
snapshot->families |= 2U;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Public mDNS has no readiness getter and its action API can silently drop a
|
||||||
|
* full-queue submission. Never treat our requested mask as acknowledged state.
|
||||||
|
* Disables are idempotent; enables restart probes, so repair those at a slower
|
||||||
|
* cadence rather than churning every manager poll. */
|
||||||
|
static esp_err_t reconcile_families(bool online)
|
||||||
|
{
|
||||||
|
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||||
|
if (!sta) return ESP_ERR_INVALID_STATE;
|
||||||
|
family_snapshot_t snapshot = { .sta = sta, .online = online };
|
||||||
|
esp_err_t error = esp_netif_tcpip_exec(read_families, &snapshot);
|
||||||
|
if (error != ESP_OK) return error;
|
||||||
|
unsigned families = snapshot.families;
|
||||||
|
int64_t now = esp_timer_get_time();
|
||||||
|
bool refresh = !s_family_request_valid || families != s_requested_families ||
|
||||||
|
now >= s_family_refresh_at;
|
||||||
|
unsigned actions = 0;
|
||||||
|
if (!(families & 1U)) actions |= MDNS_EVENT_DISABLE_IP4;
|
||||||
|
else if (refresh) actions |= MDNS_EVENT_ENABLE_IP4;
|
||||||
|
if (!(families & 2U)) actions |= MDNS_EVENT_DISABLE_IP6;
|
||||||
|
else if (refresh) actions |= MDNS_EVENT_ENABLE_IP6;
|
||||||
|
if (!actions) return ESP_OK;
|
||||||
|
error = mdns_netif_action(sta, (mdns_event_actions_t)actions);
|
||||||
|
if (error == ESP_OK && refresh) {
|
||||||
|
s_requested_families = families;
|
||||||
|
s_family_request_valid = true;
|
||||||
|
s_family_refresh_at = now + MDNS_FAMILY_REPAIR_US;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t mdns_service_reconcile(void)
|
||||||
|
{
|
||||||
|
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||||
|
lock_service();
|
||||||
|
bool initialized = s_component_initialized;
|
||||||
|
bool online = s_announced;
|
||||||
|
esp_err_t error = s_initialization_failed ? s_last_error : ESP_OK;
|
||||||
|
unlock_service();
|
||||||
|
if (!initialized) return error;
|
||||||
|
|
||||||
|
error = reconcile_families(online);
|
||||||
|
portENTER_CRITICAL(&s_availability_mux);
|
||||||
|
bool https_available = s_https_available;
|
||||||
|
bool ssh_available = s_ssh_available;
|
||||||
|
portEXIT_CRITICAL(&s_availability_mux);
|
||||||
|
esp_err_t https_error = reconcile_record("_https", 443, https_available, &s_https_registered);
|
||||||
|
if (error == ESP_OK) error = https_error;
|
||||||
|
esp_err_t ssh_error = reconcile_record("_ssh", 22, ssh_available, &s_ssh_registered);
|
||||||
|
if (error == ESP_OK) error = ssh_error;
|
||||||
|
lock_service();
|
||||||
|
s_last_error = error;
|
||||||
|
unlock_service();
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
static void lock_service(void)
|
static void lock_service(void)
|
||||||
{
|
{
|
||||||
@@ -149,9 +271,16 @@ esp_err_t mdns_service_start(void)
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
mdns_config_t config = s_config;
|
mdns_config_t config = s_config;
|
||||||
|
uint32_t generation = s_config_generation;
|
||||||
unlock_service();
|
unlock_service();
|
||||||
|
|
||||||
|
/* Predefined AP/ETH handlers would independently re-enable those interfaces.
|
||||||
|
* Fail nonfatally rather than accidentally advertise outside STA. */
|
||||||
|
#if !CONFIG_MDNS_PREDEF_NETIF_STA || CONFIG_MDNS_PREDEF_NETIF_AP || CONFIG_MDNS_PREDEF_NETIF_ETH
|
||||||
|
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||||
|
#else
|
||||||
esp_err_t error = mdns_init();
|
esp_err_t error = mdns_init();
|
||||||
|
#endif
|
||||||
if (error == ESP_OK) {
|
if (error == ESP_OK) {
|
||||||
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
|
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
|
||||||
make_hostname(&config, hostname, sizeof(hostname));
|
make_hostname(&config, hostname, sizeof(hostname));
|
||||||
@@ -165,12 +294,13 @@ esp_err_t mdns_service_start(void)
|
|||||||
}
|
}
|
||||||
|
|
||||||
lock_service();
|
lock_service();
|
||||||
|
if (error == ESP_OK) s_applied_generation = generation;
|
||||||
s_component_initialized = error == ESP_OK;
|
s_component_initialized = error == ESP_OK;
|
||||||
s_initialization_failed = error != ESP_OK;
|
s_initialization_failed = error != ESP_OK;
|
||||||
s_announced = error == ESP_OK;
|
s_announced = error == ESP_OK;
|
||||||
s_last_error = error;
|
s_last_error = error;
|
||||||
unlock_service();
|
unlock_service();
|
||||||
return error;
|
return error == ESP_OK ? mdns_service_reconcile() : error;
|
||||||
}
|
}
|
||||||
|
|
||||||
void mdns_service_stop(void)
|
void mdns_service_stop(void)
|
||||||
@@ -181,6 +311,7 @@ void mdns_service_stop(void)
|
|||||||
lock_service();
|
lock_service();
|
||||||
s_announced = false;
|
s_announced = false;
|
||||||
unlock_service();
|
unlock_service();
|
||||||
|
(void)mdns_service_reconcile();
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t mdns_service_reannounce(void)
|
esp_err_t mdns_service_reannounce(void)
|
||||||
@@ -195,11 +326,18 @@ esp_err_t mdns_service_reannounce(void)
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
mdns_config_t config = s_config;
|
mdns_config_t config = s_config;
|
||||||
|
uint32_t generation = s_config_generation;
|
||||||
unlock_service();
|
unlock_service();
|
||||||
|
|
||||||
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
|
esp_err_t error = ESP_OK;
|
||||||
make_hostname(&config, hostname, sizeof(hostname));
|
if (generation != s_applied_generation) {
|
||||||
esp_err_t error = mdns_hostname_set(hostname);
|
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
|
||||||
|
make_hostname(&config, hostname, sizeof(hostname));
|
||||||
|
error = mdns_hostname_set(hostname);
|
||||||
|
if (error == ESP_OK) s_applied_generation = generation;
|
||||||
|
}
|
||||||
|
esp_err_t service_error = mdns_service_reconcile();
|
||||||
|
if (error == ESP_OK) error = service_error;
|
||||||
|
|
||||||
lock_service();
|
lock_service();
|
||||||
s_last_error = error;
|
s_last_error = error;
|
||||||
|
|||||||
+17
-1
@@ -32,7 +32,23 @@ typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD,
|
|||||||
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
|
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
|
||||||
const mdns_config_t *config, bool *stored);
|
const mdns_config_t *config, bool *stored);
|
||||||
|
|
||||||
/* Only wifi_manager may call these lifecycle operations. */
|
/* Service-owner notifications: short portMUX publication, allocation-free,
|
||||||
|
* safe before init. No blocking semaphore or component call.
|
||||||
|
* Publish true only after the listener starts, false when it becomes unavailable.
|
||||||
|
* No mDNS API or other service lock is taken here. */
|
||||||
|
void mdns_service_set_https_available(bool available);
|
||||||
|
void mdns_service_set_ssh_available(bool available);
|
||||||
|
|
||||||
|
/* Only wifi_manager may call these lifecycle operations (serialized).
|
||||||
|
* Reconcile periodically, including while offline, to retire unavailable services
|
||||||
|
* and reconcile STA address-family readiness from authoritative netif state.
|
||||||
|
* No additional Wi-Fi arguments are needed; WIFI_STA_DEF must remain alive.
|
||||||
|
* Stop requests family disable as well as clearing announcement expectation.
|
||||||
|
* Availability notifications converge on the next reconciliation, not immediately.
|
||||||
|
* Public upstream actions have no acknowledgement and may silently drop; absent
|
||||||
|
* families are disabled each pass, present families repaired every 30 seconds.
|
||||||
|
* Consequently healthy enables re-probe at that repair cadence, not every poll. */
|
||||||
|
esp_err_t mdns_service_reconcile(void);
|
||||||
esp_err_t mdns_service_start(void);
|
esp_err_t mdns_service_start(void);
|
||||||
void mdns_service_stop(void);
|
void mdns_service_stop(void);
|
||||||
esp_err_t mdns_service_reannounce(void);
|
esp_err_t mdns_service_reannounce(void);
|
||||||
|
|||||||
+59
-14
@@ -13,6 +13,7 @@
|
|||||||
#include "esp_heap_caps.h"
|
#include "esp_heap_caps.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_system.h"
|
#include "esp_system.h"
|
||||||
|
#include "web_firmware_update.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/semphr.h"
|
#include "freertos/semphr.h"
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
#include "lwip/inet.h"
|
#include "lwip/inet.h"
|
||||||
#include "lwip/sockets.h"
|
#include "lwip/sockets.h"
|
||||||
#include "lwip/tcp.h"
|
#include "lwip/tcp.h"
|
||||||
|
#include "mdns_service.h"
|
||||||
#include "sdkconfig.h"
|
#include "sdkconfig.h"
|
||||||
#include "secure_random.h"
|
#include "secure_random.h"
|
||||||
#include "serial_service.h"
|
#include "serial_service.h"
|
||||||
@@ -79,7 +81,7 @@ typedef struct {
|
|||||||
size_t tx_length;
|
size_t tx_length;
|
||||||
uint8_t rx_buffer[SSH_TRANSPORT_IO_BUFFER_SIZE];
|
uint8_t rx_buffer[SSH_TRANSPORT_IO_BUFFER_SIZE];
|
||||||
uint8_t tx_buffer[SSH_TRANSPORT_IO_BUFFER_SIZE];
|
uint8_t tx_buffer[SSH_TRANSPORT_IO_BUFFER_SIZE];
|
||||||
char peer[48];
|
char peer[SSH_TRANSPORT_PEER_CAPACITY];
|
||||||
} ssh_slot_t;
|
} ssh_slot_t;
|
||||||
|
|
||||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
@@ -265,6 +267,8 @@ static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
|
|||||||
}
|
}
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case ADMIN_SSH_DEFER_REBOOT:
|
case ADMIN_SSH_DEFER_REBOOT:
|
||||||
|
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
esp_restart();
|
esp_restart();
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
case ADMIN_SSH_DEFER_STOP:
|
case ADMIN_SSH_DEFER_STOP:
|
||||||
@@ -672,7 +676,7 @@ static esp_err_t create_context(void)
|
|||||||
|
|
||||||
static esp_err_t create_listener(void)
|
static esp_err_t create_listener(void)
|
||||||
{
|
{
|
||||||
int socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
|
int socket_fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
|
||||||
if (socket_fd < 0) {
|
if (socket_fd < 0) {
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
@@ -680,12 +684,17 @@ static esp_err_t create_listener(void)
|
|||||||
int enabled = 1;
|
int enabled = 1;
|
||||||
(void)setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR,
|
(void)setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR,
|
||||||
&enabled, sizeof(enabled));
|
&enabled, sizeof(enabled));
|
||||||
struct sockaddr_in address = {
|
/* IDF lwIP binds :: to IPADDR_TYPE_ANY when V6ONLY is disabled.
|
||||||
.sin_family = AF_INET,
|
* One listener preserves the shared accept budget and socket footprint. */
|
||||||
.sin_port = htons(SSH_TRANSPORT_PORT),
|
int ipv6_only = 0;
|
||||||
.sin_addr.s_addr = htonl(INADDR_ANY),
|
struct sockaddr_in6 address = {
|
||||||
|
.sin6_family = AF_INET6,
|
||||||
|
.sin6_port = htons(SSH_TRANSPORT_PORT),
|
||||||
|
.sin6_addr = IN6ADDR_ANY_INIT,
|
||||||
};
|
};
|
||||||
if (bind(socket_fd, (struct sockaddr *)&address, sizeof(address)) < 0 ||
|
if (setsockopt(socket_fd, IPPROTO_IPV6, IPV6_V6ONLY,
|
||||||
|
&ipv6_only, sizeof(ipv6_only)) < 0 ||
|
||||||
|
bind(socket_fd, (struct sockaddr *)&address, sizeof(address)) < 0 ||
|
||||||
listen(socket_fd, SSH_TRANSPORT_LISTEN_BACKLOG) < 0 ||
|
listen(socket_fd, SSH_TRANSPORT_LISTEN_BACKLOG) < 0 ||
|
||||||
set_nonblocking(socket_fd) != ESP_OK) {
|
set_nonblocking(socket_fd) != ESP_OK) {
|
||||||
close(socket_fd);
|
close(socket_fd);
|
||||||
@@ -714,12 +723,14 @@ static esp_err_t start_runtime(void)
|
|||||||
s_context = NULL;
|
s_context = NULL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mdns_service_set_ssh_available(error == ESP_OK);
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t stop_runtime(void)
|
static esp_err_t stop_runtime(void)
|
||||||
{
|
{
|
||||||
close_socket(&s_listen_fd);
|
close_socket(&s_listen_fd);
|
||||||
|
mdns_service_set_ssh_available(false);
|
||||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||||
request_slot_close(&s_slots[index], false);
|
request_slot_close(&s_slots[index], false);
|
||||||
}
|
}
|
||||||
@@ -847,28 +858,56 @@ static ssh_slot_t *find_free_slot(size_t *slot_index)
|
|||||||
static void format_peer(const struct sockaddr_storage *address,
|
static void format_peer(const struct sockaddr_storage *address,
|
||||||
char *output, size_t output_size)
|
char *output, size_t output_size)
|
||||||
{
|
{
|
||||||
|
if (output_size == 0U) return;
|
||||||
output[0] = '\0';
|
output[0] = '\0';
|
||||||
|
int written = -1;
|
||||||
if (address->ss_family == AF_INET) {
|
if (address->ss_family == AF_INET) {
|
||||||
const struct sockaddr_in *ipv4 = (const struct sockaddr_in *)address;
|
const struct sockaddr_in *ipv4 = (const struct sockaddr_in *)address;
|
||||||
char host[INET_ADDRSTRLEN] = {0};
|
char host[INET_ADDRSTRLEN] = {0};
|
||||||
if (inet_ntop(AF_INET, &ipv4->sin_addr, host, sizeof(host)) != NULL) {
|
if (inet_ntop(AF_INET, &ipv4->sin_addr, host, sizeof(host)) != NULL) {
|
||||||
(void)snprintf(output, output_size, "%s:%u", host,
|
written = snprintf(output, output_size, "%s:%u", host,
|
||||||
(unsigned int)ntohs(ipv4->sin_port));
|
(unsigned int)ntohs(ipv4->sin_port));
|
||||||
}
|
}
|
||||||
} else if (address->ss_family == AF_INET6) {
|
} else if (address->ss_family == AF_INET6) {
|
||||||
const struct sockaddr_in6 *ipv6 = (const struct sockaddr_in6 *)address;
|
const struct sockaddr_in6 *ipv6 = (const struct sockaddr_in6 *)address;
|
||||||
char host[INET6_ADDRSTRLEN] = {0};
|
char host[INET6_ADDRSTRLEN] = {0};
|
||||||
if (inet_ntop(AF_INET6, &ipv6->sin6_addr, host, sizeof(host)) != NULL) {
|
if (inet_ntop(AF_INET6, &ipv6->sin6_addr, host, sizeof(host)) != NULL) {
|
||||||
(void)snprintf(output, output_size, "[%s]:%u", host,
|
/* lwIP accept supplies the interface zone in sin6_scope_id.
|
||||||
(unsigned int)ntohs(ipv6->sin6_port));
|
* Retain it for link-local peers; never infer an interface by name. */
|
||||||
|
if (ipv6->sin6_scope_id != 0U) {
|
||||||
|
written = snprintf(output, output_size, "[%s%%%" PRIu32 "]:%u",
|
||||||
|
host, (uint32_t)ipv6->sin6_scope_id,
|
||||||
|
(unsigned int)ntohs(ipv6->sin6_port));
|
||||||
|
} else {
|
||||||
|
written = snprintf(output, output_size, "[%s]:%u", host,
|
||||||
|
(unsigned int)ntohs(ipv6->sin6_port));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (output[0] == '\0') {
|
/* A truncated scoped endpoint must not look like a usable address. */
|
||||||
|
if (written < 0 || (size_t)written >= output_size) {
|
||||||
strncpy(output, "unknown", output_size - 1U);
|
strncpy(output, "unknown", output_size - 1U);
|
||||||
output[output_size - 1U] = '\0';
|
output[output_size - 1U] = '\0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void listener_failed(void)
|
||||||
|
{
|
||||||
|
close_socket(&s_listen_fd);
|
||||||
|
mdns_service_set_ssh_available(false);
|
||||||
|
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||||
|
request_slot_close(&s_slots[index], false);
|
||||||
|
}
|
||||||
|
taskENTER_CRITICAL(&s_lock);
|
||||||
|
s_running = false;
|
||||||
|
s_cleanup_pending = true;
|
||||||
|
s_last_error = ESP_FAIL;
|
||||||
|
if (s_management_generation < UINT32_MAX) ++s_management_generation;
|
||||||
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
/* process_slots retires the context only after every session is free.
|
||||||
|
* Do not alter an already admitted lifecycle command or auto-restart. */
|
||||||
|
}
|
||||||
|
|
||||||
static void accept_connections(void)
|
static void accept_connections(void)
|
||||||
{
|
{
|
||||||
if (s_listen_fd < 0 || s_context == NULL) {
|
if (s_listen_fd < 0 || s_context == NULL) {
|
||||||
@@ -878,13 +917,19 @@ static void accept_connections(void)
|
|||||||
for (unsigned int accepted_count = 0U;
|
for (unsigned int accepted_count = 0U;
|
||||||
accepted_count < SSH_TRANSPORT_ACCEPT_BUDGET;
|
accepted_count < SSH_TRANSPORT_ACCEPT_BUDGET;
|
||||||
++accepted_count) {
|
++accepted_count) {
|
||||||
struct sockaddr_storage peer_address;
|
struct sockaddr_storage peer_address = {0};
|
||||||
socklen_t peer_length = sizeof(peer_address);
|
socklen_t peer_length = sizeof(peer_address);
|
||||||
int socket_fd = accept(s_listen_fd, (struct sockaddr *)&peer_address,
|
int socket_fd = accept(s_listen_fd, (struct sockaddr *)&peer_address,
|
||||||
&peer_length);
|
&peer_length);
|
||||||
if (socket_fd < 0) {
|
if (socket_fd < 0) {
|
||||||
if (errno != EAGAIN && errno != EWOULDBLOCK) {
|
int error = errno;
|
||||||
|
if (error != EAGAIN && error != EWOULDBLOCK && error != EINTR) {
|
||||||
add_counter(&s_counters.io_failures, 1U);
|
add_counter(&s_counters.io_failures, 1U);
|
||||||
|
/* Resource pressure/aborted peers do not invalidate a listener. */
|
||||||
|
if (error == EBADF || error == EINVAL || error == ENOTSOCK ||
|
||||||
|
error == EOPNOTSUPP) {
|
||||||
|
listener_failed();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -16,6 +16,8 @@ extern "C" {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define SSH_TRANSPORT_PORT 22U
|
#define SSH_TRANSPORT_PORT 22U
|
||||||
|
/* IPv6 text (45), %uint32 scope (11), brackets/colon/port (8), NUL. */
|
||||||
|
#define SSH_TRANSPORT_PEER_CAPACITY 65U
|
||||||
#define SSH_TRANSPORT_MAX_SESSIONS 2U
|
#define SSH_TRANSPORT_MAX_SESSIONS 2U
|
||||||
#define SSH_TRANSPORT_IO_BUFFER_SIZE 512U
|
#define SSH_TRANSPORT_IO_BUFFER_SIZE 512U
|
||||||
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
|
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
|
||||||
@@ -82,7 +84,7 @@ typedef struct {
|
|||||||
user_role_t user_role;
|
user_role_t user_role;
|
||||||
user_auth_method_t auth_method;
|
user_auth_method_t auth_method;
|
||||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||||
char peer[48];
|
char peer[SSH_TRANSPORT_PEER_CAPACITY];
|
||||||
} ssh_transport_session_snapshot_t;
|
} ssh_transport_session_snapshot_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -121,6 +123,7 @@ esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
|
|||||||
|
|
||||||
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
|
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
|
||||||
esp_err_t ssh_transport_init(void);
|
esp_err_t ssh_transport_init(void);
|
||||||
|
/* One dual-stack wildcard listener; failure never falls back to one family. */
|
||||||
esp_err_t ssh_transport_start(void);
|
esp_err_t ssh_transport_start(void);
|
||||||
esp_err_t ssh_transport_stop(void);
|
esp_err_t ssh_transport_stop(void);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "esp_console.h"
|
#include "esp_console.h"
|
||||||
#include "esp_heap_caps.h"
|
#include "esp_heap_caps.h"
|
||||||
#include "esp_system.h"
|
#include "esp_system.h"
|
||||||
|
#include "web_firmware_update.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
|
||||||
@@ -57,6 +58,10 @@ static int command_reboot(int argc, char **argv)
|
|||||||
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
|
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (web_firmware_update_reserve_reboot() != ESP_OK) {
|
||||||
|
printf("Reboot refused: firmware update or reboot in progress.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
||||||
fflush(stdout);
|
fflush(stdout);
|
||||||
/* Give the UART driver time to transmit the acknowledgement before reset. */
|
/* Give the UART driver time to transmit the acknowledgement before reset. */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "esp_heap_caps.h"
|
#include "esp_heap_caps.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
#include "esp_system.h"
|
#include "esp_system.h"
|
||||||
|
#include "web_firmware_update.h"
|
||||||
#include "web_server.h"
|
#include "web_server.h"
|
||||||
#include "web_security.h"
|
#include "web_security.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@@ -114,6 +115,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
|||||||
}
|
}
|
||||||
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
||||||
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
||||||
|
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
esp_restart();
|
esp_restart();
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,125 @@
|
|||||||
#include "web_auth_parse.h"
|
#include "web_auth_parse.h"
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#ifdef ESP_PLATFORM
|
||||||
|
#include "lwip/sockets.h"
|
||||||
|
#else
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#endif
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* lwIP's inet_pton accepts some non-IPv6 suffixes and oversized hextets.
|
||||||
|
* Validate the entire grammar first, rather than trusting libc/lwIP parity. */
|
||||||
|
static bool ipv6_syntax(const char *s, size_t n)
|
||||||
|
{
|
||||||
|
size_t i = 0;
|
||||||
|
unsigned groups = 0;
|
||||||
|
bool compressed = false;
|
||||||
|
if (n && s[0] == ':') {
|
||||||
|
if (n < 2 || s[1] != ':') return false;
|
||||||
|
compressed = true;
|
||||||
|
i = 2;
|
||||||
|
}
|
||||||
|
while (i < n) {
|
||||||
|
size_t start = i;
|
||||||
|
while (i < n && s[i] != ':') ++i;
|
||||||
|
size_t end = i;
|
||||||
|
if (memchr(s + start, '.', end - start)) {
|
||||||
|
if (end != n) return false;
|
||||||
|
for (unsigned part = 0; part < 4; ++part) {
|
||||||
|
size_t first = start;
|
||||||
|
unsigned value = 0;
|
||||||
|
while (start < end && s[start] >= '0' && s[start] <= '9') {
|
||||||
|
value = value * 10U + (unsigned)(s[start++] - '0');
|
||||||
|
if (start - first > 3 || value > 255) return false;
|
||||||
|
}
|
||||||
|
if (start == first || (start - first > 1 && s[first] == '0')) return false;
|
||||||
|
if (part < 3 && (start == end || s[start++] != '.')) return false;
|
||||||
|
}
|
||||||
|
if (start != end) return false;
|
||||||
|
groups += 2;
|
||||||
|
} else {
|
||||||
|
if (end == start || end - start > 4) return false;
|
||||||
|
for (size_t j = start; j < end; ++j)
|
||||||
|
if (!((s[j] >= '0' && s[j] <= '9') ||
|
||||||
|
(s[j] >= 'a' && s[j] <= 'f') ||
|
||||||
|
(s[j] >= 'A' && s[j] <= 'F'))) return false;
|
||||||
|
++groups;
|
||||||
|
}
|
||||||
|
if (groups > 8) return false;
|
||||||
|
if (i < n) {
|
||||||
|
++i;
|
||||||
|
if (i < n && s[i] == ':') {
|
||||||
|
if (compressed) return false;
|
||||||
|
compressed = true;
|
||||||
|
++i;
|
||||||
|
} else if (i == n) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compressed ? groups < 8 : groups == 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ipv6_authority(const char *text, size_t length, char *out)
|
||||||
|
{
|
||||||
|
const char *close = memchr(text, ']', length);
|
||||||
|
if (!close) return false;
|
||||||
|
size_t end = (size_t)(close - text), suffix = length - end - 1U;
|
||||||
|
if (suffix && (suffix != 4 || memcmp(close + 1, ":443", 4))) return false;
|
||||||
|
char literal[46];
|
||||||
|
if (end < 2 || end - 1 >= sizeof(literal) || !ipv6_syntax(text + 1, end - 1)) return false;
|
||||||
|
memcpy(literal, text + 1, end - 1);
|
||||||
|
literal[end - 1] = 0;
|
||||||
|
/* lwIP only recognizes some dotted-tail layouts. Convert the already
|
||||||
|
* validated decimal tail to two hextets before invoking its parser. */
|
||||||
|
if (strchr(literal, '.')) {
|
||||||
|
char *tail = strrchr(literal, ':');
|
||||||
|
if (!tail) return false;
|
||||||
|
++tail;
|
||||||
|
unsigned octets[4] = {0};
|
||||||
|
unsigned part = 0;
|
||||||
|
for (const char *p = tail; *p; ++p) {
|
||||||
|
if (*p == '.') ++part;
|
||||||
|
else octets[part] = octets[part] * 10U + (unsigned)(*p - '0');
|
||||||
|
}
|
||||||
|
snprintf(tail, sizeof(literal) - (size_t)(tail - literal), "%x:%x",
|
||||||
|
(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
|
||||||
|
}
|
||||||
|
struct in6_addr address;
|
||||||
|
if (inet_pton(AF_INET6, literal, &address) != 1) return false;
|
||||||
|
|
||||||
|
/* Format bytes ourselves: lwIP ntop differs from RFC 5952 and host libc.
|
||||||
|
* Mapped addresses stay bracketed IPv6, rendered as hex, never IPv4/DNS. */
|
||||||
|
const unsigned char *bytes = (const unsigned char *)&address;
|
||||||
|
unsigned words[8], best = 8, longest = 1;
|
||||||
|
for (unsigned i = 0; i < 8; ++i) words[i] = ((unsigned)bytes[2*i] << 8) | bytes[2*i+1];
|
||||||
|
for (unsigned i = 0; i < 8;) {
|
||||||
|
unsigned start = i;
|
||||||
|
while (i < 8 && !words[i]) ++i;
|
||||||
|
if (i - start > longest) { best = start; longest = i - start; }
|
||||||
|
if (i < 8) ++i;
|
||||||
|
}
|
||||||
|
static const char hex[] = "0123456789abcdef";
|
||||||
|
size_t pos = 0;
|
||||||
|
out[pos++] = '[';
|
||||||
|
for (unsigned i = 0; i < 8;) {
|
||||||
|
if (i == best) {
|
||||||
|
out[pos++] = ':'; out[pos++] = ':';
|
||||||
|
i += longest;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pos > 1 && out[pos - 1] != ':') out[pos++] = ':';
|
||||||
|
unsigned shift = 12;
|
||||||
|
while (shift && !(words[i] >> shift)) shift -= 4;
|
||||||
|
for (;;) {
|
||||||
|
out[pos++] = hex[(words[i] >> shift) & 15U];
|
||||||
|
if (!shift) break;
|
||||||
|
shift -= 4;
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
out[pos++] = ']'; out[pos] = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static void wipe(void *buffer, size_t length)
|
static void wipe(void *buffer, size_t length)
|
||||||
{
|
{
|
||||||
@@ -18,6 +137,7 @@ static bool alnum_ascii(unsigned char c)
|
|||||||
static bool authority(const char *text, size_t length, char *out)
|
static bool authority(const char *text, size_t length, char *out)
|
||||||
{
|
{
|
||||||
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
|
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
|
||||||
|
if (text[0] == '[') return ipv6_authority(text, length, out);
|
||||||
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
|
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
|
||||||
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
|
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
|
||||||
size_t label = 0;
|
size_t label = 0;
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ typedef struct {
|
|||||||
} web_auth_credentials_t;
|
} web_auth_credentials_t;
|
||||||
|
|
||||||
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
|
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
|
||||||
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
|
* alias. Failures clear output. Host supports ASCII DNS/IPv4 and bracketed IPv6.
|
||||||
* IPv6 literals are deliberately rejected until the device supports that route.
|
* IPv6 uses lowercase hex, longest zero-run compression (first on ties), and
|
||||||
* Only optional :443 is accepted. Origin is mandatory and must match Host.
|
* hex tails even for mapped IPv4. No zones, DNS lookup or IPv4 equivalence.
|
||||||
|
* Only optional exact :443 is accepted. Origin is mandatory and must match
|
||||||
|
* canonical Host; accepting a literal does not establish network reachability.
|
||||||
* HTTP callers must separately reject duplicate header lines, enforce methods,
|
* HTTP callers must separately reject duplicate header lines, enforce methods,
|
||||||
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
|
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
|
||||||
bool web_auth_parse_origin(const char *host, size_t host_length,
|
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||||
|
|||||||
@@ -218,12 +218,18 @@ esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
|
|||||||
return require(r, mutation, upgrade, 0, view, allowed);
|
return require(r, mutation, upgrade, 0, view, allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
|
esp_err_t web_cookie_auth_require_body(httpd_req_t *r, size_t body_limit,
|
||||||
web_session_view_t *view, bool *allowed)
|
web_session_view_t *view, bool *allowed)
|
||||||
{
|
{
|
||||||
return require(r, true, false, body_limit, view, allowed);
|
return require(r, true, false, body_limit, view, allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
|
||||||
|
web_session_view_t *view, bool *allowed)
|
||||||
|
{
|
||||||
|
return web_cookie_auth_require_body(r, body_limit, view, allowed);
|
||||||
|
}
|
||||||
|
|
||||||
static bool secret(char out[65])
|
static bool secret(char out[65])
|
||||||
{
|
{
|
||||||
uint8_t bytes[32];
|
uint8_t bytes[32];
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
|
|||||||
bool upgrade, web_session_view_t *view,
|
bool upgrade, web_session_view_t *view,
|
||||||
bool *allowed);
|
bool *allowed);
|
||||||
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
|
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
|
||||||
|
/* Same mutation policy for a bounded raw body; caller validates content type. */
|
||||||
|
esp_err_t web_cookie_auth_require_body(httpd_req_t *request, size_t body_limit,
|
||||||
|
web_session_view_t *view, bool *allowed);
|
||||||
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
|
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
|
||||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
|
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
|
||||||
web_session_view_t *view, bool *allowed);
|
web_session_view_t *view, bool *allowed);
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
#include "web_firmware_update.h"
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdatomic.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "esp_app_desc.h"
|
||||||
|
#include "esp_app_format.h"
|
||||||
|
#include "esp_image_format.h"
|
||||||
|
#include "esp_ota_ops.h"
|
||||||
|
#include "esp_system.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "secure_random.h"
|
||||||
|
#include "web_cookie_auth.h"
|
||||||
|
#include "web_httpd_adapter.h"
|
||||||
|
#include "web_security.h"
|
||||||
|
|
||||||
|
#define BUFFER_SIZE 4096U
|
||||||
|
#define STALL_US 10000000LL
|
||||||
|
#define TOTAL_US 120000000LL
|
||||||
|
#define PREFIX_SIZE (sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t))
|
||||||
|
|
||||||
|
/* A successful reservation is retained through reset, not a check-then-reset.
|
||||||
|
* Only the upload owner accesses the selected latch while holding this gate. */
|
||||||
|
static atomic_bool s_reboot_gate;
|
||||||
|
static bool s_firmware_selected;
|
||||||
|
|
||||||
|
esp_err_t web_firmware_update_reserve_reboot(void)
|
||||||
|
{
|
||||||
|
bool expected = false;
|
||||||
|
return atomic_compare_exchange_strong(&s_reboot_gate, &expected, true) ?
|
||||||
|
ESP_OK : ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allocated before boot selection. No HTTPD stop, queue allocation, captured
|
||||||
|
* request or socket in this owner. A successful send is not proof of receipt. */
|
||||||
|
static void reboot_owner(void *argument)
|
||||||
|
{
|
||||||
|
(void)argument;
|
||||||
|
uint32_t decision = 0;
|
||||||
|
xTaskNotifyWait(0, UINT32_MAX, &decision, portMAX_DELAY);
|
||||||
|
if (decision == 1) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(500));
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||||
|
{
|
||||||
|
esp_err_t error = httpd_resp_set_status(request, status);
|
||||||
|
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||||
|
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||||
|
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||||
|
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||||
|
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||||
|
/* Reject without HTTPD's unbounded discard of the remaining request body. */
|
||||||
|
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t content_length(httpd_req_t *request, size_t limit)
|
||||||
|
{
|
||||||
|
/* Auth's adapter already rejects duplicate headers. IDF 5.5.0 parses a
|
||||||
|
* uint64_t length then narrows it to size_t; never trust that value alone.
|
||||||
|
* Its getters strip leading spaces only. Require 1..20 decimal digits. */
|
||||||
|
char raw[21];
|
||||||
|
size_t length = httpd_req_get_hdr_value_len(request, "Content-Length");
|
||||||
|
if (!length || length >= sizeof(raw) ||
|
||||||
|
httpd_req_get_hdr_value_str(request, "Content-Length", raw, sizeof(raw)) != ESP_OK ||
|
||||||
|
strspn(raw, "0123456789") != length)
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
size_t value = 0;
|
||||||
|
for (size_t i = 0; i < length; ++i) {
|
||||||
|
unsigned digit = (unsigned)(raw[i] - '0');
|
||||||
|
if (value > limit / 10 || (value == limit / 10 && digit > limit % 10))
|
||||||
|
return ESP_ERR_INVALID_SIZE;
|
||||||
|
value = value * 10 + digit;
|
||||||
|
}
|
||||||
|
return value == request->content_len ? ESP_OK : ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool application_prefix(const uint8_t *buffer)
|
||||||
|
{
|
||||||
|
esp_image_header_t header;
|
||||||
|
esp_image_segment_header_t segment;
|
||||||
|
uint32_t magic;
|
||||||
|
memcpy(&header, buffer, sizeof(header));
|
||||||
|
memcpy(&segment, buffer + sizeof(header), sizeof(segment));
|
||||||
|
memcpy(&magic, buffer + sizeof(header) + sizeof(segment), sizeof(magic));
|
||||||
|
/* App descriptor distinguishes app images from bootloader/full-flash data.
|
||||||
|
* SDK end validation remains authoritative for revision, segments and hash. */
|
||||||
|
return header.magic == ESP_IMAGE_HEADER_MAGIC &&
|
||||||
|
header.chip_id == ESP_CHIP_ID_ESP32S3 &&
|
||||||
|
header.segment_count > 0 && header.segment_count <= ESP_IMAGE_MAX_SEGMENTS &&
|
||||||
|
header.hash_appended == 1 && segment.data_len >= sizeof(esp_app_desc_t) &&
|
||||||
|
magic == ESP_APP_DESC_MAGIC_WORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t web_firmware_update_handler(httpd_req_t *request)
|
||||||
|
{
|
||||||
|
web_session_view_t view = {0};
|
||||||
|
bool allowed = false, reserved = false, active = false, gate_owned = false;
|
||||||
|
uint32_t identity_token = 0;
|
||||||
|
uint8_t *buffer = NULL;
|
||||||
|
TaskHandle_t reboot_task = NULL;
|
||||||
|
esp_ota_handle_t ota = 0;
|
||||||
|
const char *status = "500 Internal Server Error";
|
||||||
|
const char *body = "{\"error\":\"firmware_write_failed\"}";
|
||||||
|
/* Auth rejects ambiguous headers/transfer encoding before any body IO.
|
||||||
|
* The actual inactive partition, not a compiled slot size, bounds the body. */
|
||||||
|
esp_err_t error = web_cookie_auth_require_body(request, SIZE_MAX, &view, &allowed);
|
||||||
|
if (error != ESP_OK || !allowed) goto done;
|
||||||
|
#define REJECT(s, b) do { status = (s); body = "{\"error\":\"" b "\"}"; goto failed; } while (0)
|
||||||
|
if (view.principal.role != USER_ROLE_ADMIN)
|
||||||
|
REJECT("403 Forbidden", "admin_required");
|
||||||
|
char type[40] = {0};
|
||||||
|
if (httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) != ESP_OK ||
|
||||||
|
strcmp(type, "application/octet-stream"))
|
||||||
|
REJECT("415 Unsupported Media Type", "firmware_content_type");
|
||||||
|
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||||
|
const esp_partition_t *target = esp_ota_get_next_update_partition(NULL);
|
||||||
|
if (!running || !target || target->type != ESP_PARTITION_TYPE_APP ||
|
||||||
|
target->subtype < ESP_PARTITION_SUBTYPE_APP_OTA_0 ||
|
||||||
|
target->subtype > ESP_PARTITION_SUBTYPE_APP_OTA_15 ||
|
||||||
|
target->address == running->address)
|
||||||
|
REJECT("503 Service Unavailable", "firmware_unavailable");
|
||||||
|
esp_err_t length_error = content_length(request, target->size);
|
||||||
|
if (length_error == ESP_ERR_INVALID_SIZE)
|
||||||
|
REJECT("413 Payload Too Large", "firmware_too_large");
|
||||||
|
if (length_error != ESP_OK)
|
||||||
|
REJECT("400 Bad Request", "invalid_request");
|
||||||
|
if (request->content_len < PREFIX_SIZE)
|
||||||
|
REJECT("400 Bad Request", "invalid_firmware");
|
||||||
|
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||||
|
REJECT("503 Service Unavailable", "busy");
|
||||||
|
gate_owned = true;
|
||||||
|
if (s_firmware_selected)
|
||||||
|
REJECT("409 Conflict", "firmware_selected_reboot_required");
|
||||||
|
if (web_firmware_update_reserve(request->handle) != ESP_OK)
|
||||||
|
REJECT("503 Service Unavailable", "busy");
|
||||||
|
reserved = true;
|
||||||
|
/* Also exclude direct canonical identity mutations which bypass server. */
|
||||||
|
if (web_security_reserve_identity(0, false, &identity_token) != ESP_OK)
|
||||||
|
REJECT("503 Service Unavailable", "busy");
|
||||||
|
buffer = malloc(BUFFER_SIZE);
|
||||||
|
if (!buffer || xTaskCreate(reboot_owner, "fw_reboot", 2048, NULL, 5, &reboot_task) != pdPASS)
|
||||||
|
REJECT("503 Service Unavailable", "firmware_resources");
|
||||||
|
|
||||||
|
size_t received = 0, prefix = 0;
|
||||||
|
int64_t started = esp_timer_get_time(), last_progress = started;
|
||||||
|
while (received < request->content_len) {
|
||||||
|
int64_t now = esp_timer_get_time();
|
||||||
|
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
|
||||||
|
REJECT("408 Request Timeout", "firmware_timeout");
|
||||||
|
/* Accumulate the entire prefix, even when TLS gives one byte at a time. */
|
||||||
|
size_t want = active ? request->content_len - received : PREFIX_SIZE - prefix;
|
||||||
|
if (want > BUFFER_SIZE) want = BUFFER_SIZE;
|
||||||
|
int count = httpd_req_recv(request, (char *)buffer + (active ? 0 : prefix), want);
|
||||||
|
now = esp_timer_get_time();
|
||||||
|
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
|
||||||
|
REJECT("408 Request Timeout", "firmware_timeout");
|
||||||
|
if (count == HTTPD_SOCK_ERR_TIMEOUT) continue;
|
||||||
|
if (count <= 0 || (size_t)count > want)
|
||||||
|
REJECT("400 Bad Request", "firmware_incomplete");
|
||||||
|
received += (size_t)count;
|
||||||
|
last_progress = now;
|
||||||
|
size_t write_size = (size_t)count;
|
||||||
|
if (!active) {
|
||||||
|
prefix += (size_t)count;
|
||||||
|
if (prefix < PREFIX_SIZE) continue;
|
||||||
|
if (!application_prefix(buffer))
|
||||||
|
REJECT("400 Bad Request", "invalid_firmware");
|
||||||
|
esp_err_t begin_error = esp_ota_begin(target, request->content_len, &ota);
|
||||||
|
/* IDF 5.5.0 can publish a live handle before an erase failure. */
|
||||||
|
active = ota != 0;
|
||||||
|
if (begin_error != ESP_OK) goto failed;
|
||||||
|
write_size = prefix;
|
||||||
|
}
|
||||||
|
if (esp_ota_write(ota, buffer, write_size) != ESP_OK) goto failed;
|
||||||
|
}
|
||||||
|
if (esp_timer_get_time() - started >= TOTAL_US)
|
||||||
|
REJECT("408 Request Timeout", "firmware_timeout");
|
||||||
|
/* esp_ota_end consumes the handle even on validation failure. */
|
||||||
|
active = false;
|
||||||
|
if (esp_ota_end(ota) != ESP_OK)
|
||||||
|
REJECT("400 Bad Request", "invalid_firmware");
|
||||||
|
/* SDK end validates flash, but does not compare its parsed image length to
|
||||||
|
* our HTTP length. Reject truncation into old flash and appended garbage. */
|
||||||
|
_Static_assert(sizeof(esp_image_metadata_t) <= BUFFER_SIZE, "metadata fits upload buffer");
|
||||||
|
esp_image_metadata_t *metadata = (esp_image_metadata_t *)buffer;
|
||||||
|
esp_partition_pos_t position = {.offset = target->address, .size = target->size};
|
||||||
|
if (esp_image_get_metadata(&position, metadata) != ESP_OK || metadata->image_len != received)
|
||||||
|
REJECT("400 Bad Request", "invalid_firmware");
|
||||||
|
bool current = false;
|
||||||
|
if (web_session_store_check_principal(view.id, &view.principal, ¤t) != ESP_OK || !current)
|
||||||
|
REJECT("401 Unauthorized", "authentication_required");
|
||||||
|
if (esp_ota_set_boot_partition(target) != ESP_OK)
|
||||||
|
REJECT("500 Internal Server Error", "firmware_commit_failed");
|
||||||
|
|
||||||
|
s_firmware_selected = true;
|
||||||
|
/* Commit is irreversible here. Failed send leaves the selected image for a
|
||||||
|
* later reboot, but deliberately does not schedule this upload's reboot.
|
||||||
|
* Never retry an upload automatically after a lost acknowledgement. */
|
||||||
|
error = respond(request, "200 OK", "{\"ok\":true,\"rebooting\":true}");
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
xTaskNotify(reboot_task, 1, eSetValueWithOverwrite);
|
||||||
|
reboot_task = NULL;
|
||||||
|
reserved = false; /* Retain service/identity reservations until reset. */
|
||||||
|
identity_token = 0;
|
||||||
|
gate_owned = false; /* Reboot owner retains exclusion until reset. */
|
||||||
|
}
|
||||||
|
goto done;
|
||||||
|
failed:
|
||||||
|
if (active) { esp_ota_abort(ota); active = false; }
|
||||||
|
error = respond(request, status, body);
|
||||||
|
done:
|
||||||
|
if (reboot_task) xTaskNotify(reboot_task, 2, eSetValueWithOverwrite);
|
||||||
|
if (identity_token) web_security_release_identity(identity_token);
|
||||||
|
if (reserved) web_firmware_update_release();
|
||||||
|
free(buffer);
|
||||||
|
secure_wipe(&view, sizeof(view));
|
||||||
|
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||||
|
if (gate_owned) atomic_store(&s_reboot_gate, false);
|
||||||
|
return error;
|
||||||
|
#undef REJECT
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
#pragma once
|
||||||
|
#include "esp_http_server.h"
|
||||||
|
|
||||||
|
#define WEB_FIRMWARE_UPDATE_URI "/api/firmware"
|
||||||
|
|
||||||
|
esp_err_t web_firmware_update_handler(httpd_req_t *request);
|
||||||
|
|
||||||
|
/* Atomically exclude uploads until reset. Call immediately before an ordinary
|
||||||
|
* reboot (and before any acknowledgement delay); failure means do not reset.
|
||||||
|
* Independent of HTTPS initialization, so UART0 recovery remains available. */
|
||||||
|
esp_err_t web_firmware_update_reserve_reboot(void);
|
||||||
|
|
||||||
|
/* Internal HTTPS owner reservation. HTTPD takes before flash and releases on
|
||||||
|
* failure; successful response transfers lifetime to the reboot owner. No
|
||||||
|
* service mutex is held during receive/flash/response. Implemented by server. */
|
||||||
|
esp_err_t web_firmware_update_reserve(httpd_handle_t server);
|
||||||
|
void web_firmware_update_release(void);
|
||||||
@@ -334,7 +334,8 @@ static esp_err_t snapshot_response(httpd_req_t *request)
|
|||||||
wifi_manager_settings_t wifi;
|
wifi_manager_settings_t wifi;
|
||||||
mdns_service_snapshot_t mdns;
|
mdns_service_snapshot_t mdns;
|
||||||
/* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */
|
/* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */
|
||||||
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK)
|
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK ||
|
||||||
|
wifi.runtime.ipv6_count > WIFI_MANAGER_IPV6_MAX_ADDRESSES)
|
||||||
return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}");
|
return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}");
|
||||||
char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0;
|
char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0;
|
||||||
#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0)
|
#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0)
|
||||||
@@ -355,9 +356,21 @@ static esp_err_t snapshot_response(httpd_req_t *request)
|
|||||||
/* IPv4 bytes are already in network order, independent of host endianness. */
|
/* IPv4 bytes are already in network order, independent of host endianness. */
|
||||||
const uint8_t *ip = (const uint8_t *)&r->ip;
|
const uint8_t *ip = (const uint8_t *)&r->ip;
|
||||||
ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\","
|
ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\","
|
||||||
"\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d},",
|
"\"ipv6_linklocal\":%s,\"ipv6_routable\":%s,\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d,\"ipv6_addresses\":[",
|
||||||
json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile,
|
json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile,
|
||||||
ip[0], ip[1], ip[2], ip[3], json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
|
ip[0], ip[1], ip[2], ip[3], json_bool(r->ipv6_linklocal), json_bool(r->ipv6_routable),
|
||||||
|
json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
|
||||||
|
for (unsigned i = 0; i < r->ipv6_count; ++i) {
|
||||||
|
const uint8_t *bytes = (const uint8_t *)r->ipv6_addresses[i].addr;
|
||||||
|
ADD("%s\"", i ? "," : "");
|
||||||
|
/* Fixed-width network-order hextets keep the wire schema unambiguous. */
|
||||||
|
for (unsigned block = 0; block < 8; ++block) {
|
||||||
|
unsigned value = ((unsigned)bytes[2 * block] << 8) | bytes[2 * block + 1];
|
||||||
|
ADD("%s%04x", block ? ":" : "", value);
|
||||||
|
}
|
||||||
|
ADD("\"");
|
||||||
|
}
|
||||||
|
ADD("]},");
|
||||||
ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}",
|
ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}",
|
||||||
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
|
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
|
||||||
#undef SSID
|
#undef SSID
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
#include "esp_http_server.h"
|
#include "esp_http_server.h"
|
||||||
|
|
||||||
#define WEB_NETWORK_REQUEST_MAX 768U
|
#define WEB_NETWORK_REQUEST_MAX 768U
|
||||||
#define WEB_NETWORK_SNAPSHOT_MAX 2048U
|
#define WEB_NETWORK_SNAPSHOT_MAX 2304U
|
||||||
|
|
||||||
/* Integration: optional exact GET /api/settings/network -> snapshot_handler;
|
/* Integration: optional exact GET /api/settings/network -> snapshot_handler;
|
||||||
* exact GET and POST /api/settings/network-operation -> operation_handler.
|
* exact GET and POST /api/settings/network-operation -> operation_handler.
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
* non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI
|
* non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI
|
||||||
* must encode UTF-8 text into bytes before encoding this field, and retain a
|
* must encode UTF-8 text into bytes before encoding this field, and retain a
|
||||||
* reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes.
|
* reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes.
|
||||||
|
* Runtime ipv6_addresses contains at most three preferred addresses as fixed
|
||||||
|
* eight-hextet lowercase strings, without a zone or reachability assertion.
|
||||||
* No saved PSK/length is returned, only password_configured. Omitted password
|
* No saved PSK/length is returned, only password_configured. Omitted password
|
||||||
* preserves current bytes; clear_password:true is distinct from replacement.
|
* preserves current bytes; clear_password:true is distinct from replacement.
|
||||||
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
|
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
|
||||||
|
|||||||
+40
-2
@@ -31,6 +31,7 @@
|
|||||||
#include "web_broker_settings.h"
|
#include "web_broker_settings.h"
|
||||||
#include "web_ssh_settings.h"
|
#include "web_ssh_settings.h"
|
||||||
#include "web_lifecycle_settings.h"
|
#include "web_lifecycle_settings.h"
|
||||||
|
#include "web_firmware_update.h"
|
||||||
#include "web_admin_transport.h"
|
#include "web_admin_transport.h"
|
||||||
#include "web_session_store.h"
|
#include "web_session_store.h"
|
||||||
#include "web_cookie_auth.h"
|
#include "web_cookie_auth.h"
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
#include "web_diagnostics.h"
|
#include "web_diagnostics.h"
|
||||||
#include "web_ui.h"
|
#include "web_ui.h"
|
||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
|
#include "mdns_service.h"
|
||||||
|
|
||||||
#define WEB_SERVER_PORT 443U
|
#define WEB_SERVER_PORT 443U
|
||||||
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
|
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
|
||||||
@@ -270,7 +272,7 @@ static esp_err_t status_handler(httpd_req_t *request)
|
|||||||
"{\n"
|
"{\n"
|
||||||
" \"uptime_ms\":%" PRIu64 ",\n"
|
" \"uptime_ms\":%" PRIu64 ",\n"
|
||||||
" \"wifi\":{\"available\":%s,\"state\":\"%s\",\"sta_ipv4\":\"%s\","
|
" \"wifi\":{\"available\":%s,\"state\":\"%s\",\"sta_ipv4\":\"%s\","
|
||||||
"\"rssi\":%d,\"channel\":%u,\"ap_running\":%s,\"ap_clients\":%u},\n"
|
"\"ipv6_linklocal\":%s,\"ipv6_routable\":%s,\"rssi\":%d,\"channel\":%u,\"ap_running\":%s,\"ap_clients\":%u},\n"
|
||||||
" \"serial\":{\"running\":%s,\"config_available\":%s,\"baud\":%" PRIu32 ","
|
" \"serial\":{\"running\":%s,\"config_available\":%s,\"baud\":%" PRIu32 ","
|
||||||
"\"data_bits\":\"%s\",\"parity\":\"%s\",\"stop_bits\":\"%s\","
|
"\"data_bits\":\"%s\",\"parity\":\"%s\",\"stop_bits\":\"%s\","
|
||||||
"\"flow\":\"%s\",\"rx_bytes\":%" PRIu64 ",\"rx_dropped\":%" PRIu64 ","
|
"\"flow\":\"%s\",\"rx_bytes\":%" PRIu64 ",\"rx_dropped\":%" PRIu64 ","
|
||||||
@@ -288,7 +290,10 @@ static esp_err_t status_handler(httpd_req_t *request)
|
|||||||
(uint64_t)(esp_timer_get_time() / 1000),
|
(uint64_t)(esp_timer_get_time() / 1000),
|
||||||
wifi_available ? "true" : "false",
|
wifi_available ? "true" : "false",
|
||||||
wifi_available ? wifi_manager_state_to_string(wifi.state) : "unavailable",
|
wifi_available ? wifi_manager_state_to_string(wifi.state) : "unavailable",
|
||||||
ipv4, wifi_available ? (int)wifi.sta_rssi : 0,
|
ipv4,
|
||||||
|
wifi_available && wifi.ipv6_linklocal ? "true" : "false",
|
||||||
|
wifi_available && wifi.ipv6_routable ? "true" : "false",
|
||||||
|
wifi_available ? (int)wifi.sta_rssi : 0,
|
||||||
wifi_available ? (unsigned int)wifi.sta_channel : 0U,
|
wifi_available ? (unsigned int)wifi.sta_channel : 0U,
|
||||||
wifi_available && wifi.ap_running ? "true" : "false",
|
wifi_available && wifi.ap_running ? "true" : "false",
|
||||||
wifi_available ? (unsigned int)wifi.ap_client_count : 0U,
|
wifi_available ? (unsigned int)wifi.ap_client_count : 0U,
|
||||||
@@ -573,6 +578,12 @@ static const httpd_uri_t s_logo_uri = {
|
|||||||
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_LOGO_PNG,
|
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_LOGO_PNG,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static const httpd_uri_t s_firmware_uri = {
|
||||||
|
.uri = WEB_FIRMWARE_UPDATE_URI,
|
||||||
|
.method = HTTP_POST,
|
||||||
|
.handler = web_firmware_update_handler,
|
||||||
|
};
|
||||||
|
|
||||||
static const httpd_uri_t *const s_uri_handlers[] = {
|
static const httpd_uri_t *const s_uri_handlers[] = {
|
||||||
&s_root_uri,
|
&s_root_uri,
|
||||||
&s_status_uri,
|
&s_status_uri,
|
||||||
@@ -583,6 +594,7 @@ static const httpd_uri_t *const s_uri_handlers[] = {
|
|||||||
&s_addon_fit_js_uri,
|
&s_addon_fit_js_uri,
|
||||||
&s_app_js_uri,
|
&s_app_js_uri,
|
||||||
&s_logo_uri,
|
&s_logo_uri,
|
||||||
|
&s_firmware_uri,
|
||||||
};
|
};
|
||||||
|
|
||||||
static const httpd_uri_t s_auth_uris[] = {
|
static const httpd_uri_t s_auth_uris[] = {
|
||||||
@@ -778,6 +790,7 @@ static esp_err_t start_server(bool reserved)
|
|||||||
s_serial_transport_error = attach_error;
|
s_serial_transport_error = attach_error;
|
||||||
s_serial_transport_attached = serial_transport_attached;
|
s_serial_transport_attached = serial_transport_attached;
|
||||||
s_admin_transport_owned = admin_transport_owned;
|
s_admin_transport_owned = admin_transport_owned;
|
||||||
|
mdns_service_set_https_available(error == ESP_OK);
|
||||||
if (error == ESP_OK) {
|
if (error == ESP_OK) {
|
||||||
s_server = server;
|
s_server = server;
|
||||||
++s_counters.starts;
|
++s_counters.starts;
|
||||||
@@ -816,6 +829,8 @@ static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool re
|
|||||||
esp_err_t serial_transport_error = s_serial_transport_error;
|
esp_err_t serial_transport_error = s_serial_transport_error;
|
||||||
s_transitioning = true;
|
s_transitioning = true;
|
||||||
if (s_generation != UINT32_MAX) ++s_generation;
|
if (s_generation != UINT32_MAX) ++s_generation;
|
||||||
|
/* Admission is about to close, even if later teardown must be retried. */
|
||||||
|
mdns_service_set_https_available(false);
|
||||||
xSemaphoreGive(s_server_mutex);
|
xSemaphoreGive(s_server_mutex);
|
||||||
|
|
||||||
web_cookie_auth_stop();
|
web_cookie_auth_stop();
|
||||||
@@ -944,6 +959,29 @@ esp_err_t web_server_restart_current(uint32_t expected_generation)
|
|||||||
return stop_server(expected_generation, true, false);
|
return stop_server(expected_generation, true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The upload retains the existing lifecycle fence, not the mutex. This also
|
||||||
|
* excludes canonical stop/start and service-coordinated identity replacement. */
|
||||||
|
esp_err_t web_firmware_update_reserve(httpd_handle_t server)
|
||||||
|
{
|
||||||
|
if (!s_server_mutex || xSemaphoreTake(s_server_mutex, 0U) != pdTRUE)
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
if (!server || s_server != server || s_transitioning || s_last_error != ESP_OK) {
|
||||||
|
xSemaphoreGive(s_server_mutex);
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
s_transitioning = true;
|
||||||
|
if (s_generation != UINT32_MAX) ++s_generation;
|
||||||
|
xSemaphoreGive(s_server_mutex);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void web_firmware_update_release(void)
|
||||||
|
{
|
||||||
|
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||||
|
s_transitioning = false;
|
||||||
|
xSemaphoreGive(s_server_mutex);
|
||||||
|
}
|
||||||
|
|
||||||
esp_err_t web_server_reboot_current(uint32_t expected_generation)
|
esp_err_t web_server_reboot_current(uint32_t expected_generation)
|
||||||
{
|
{
|
||||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||||
|
|||||||
+63
-7
@@ -192,6 +192,7 @@ static const char s_index_html[] =
|
|||||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
||||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button><button id=\"settings-lifecycle\" class=\"button\" type=\"button\" aria-pressed=\"false\">HTTPS / Reboot</button></div>"
|
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button><button id=\"settings-lifecycle\" class=\"button\" type=\"button\" aria-pressed=\"false\">HTTPS / Reboot</button></div>"
|
||||||
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, service and device reboot</h2>"
|
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, service and device reboot</h2>"
|
||||||
|
"<section aria-label=\"Firmware upload\"><h3>Firmware update</h3><p class=\"connection-detail\">Select the firmware.bin built for this device (max. 4 MiB). Saved settings are kept. Keep power connected during the update.</p><div class=\"settings-edit\"><label>Application .bin <input id=\"firmware-file\" type=\"file\" accept=\".bin\"></label></div><button id=\"firmware-upload\" class=\"button\" type=\"button\">Upload and reboot…</button><progress id=\"firmware-progress\" max=\"100\" value=\"0\" aria-label=\"Firmware upload progress\"></progress><p id=\"firmware-detail\" class=\"connection-detail\" role=\"status\">Choose a firmware file to begin.</p><a href=\"/\">Reconnect after reboot</a></section>"
|
||||||
"<p class=\"connection-detail\">Stop/Restart HTTPS closes ALL web logins and both browser terminal routes, including clients admitted before execution. Settings and certificate identity are unchanged; HTTPS restart preserves device working configuration. Save unsaved browser drafts first. Recover a stopped web service with <code>web start</code> through UART0 or still-running, reachable admin SSH. USB remains UART1 serial access, not a web administration console.</p>"
|
"<p class=\"connection-detail\">Stop/Restart HTTPS closes ALL web logins and both browser terminal routes, including clients admitted before execution. Settings and certificate identity are unchanged; HTTPS restart preserves device working configuration. Save unsaved browser drafts first. Recover a stopped web service with <code>web start</code> through UART0 or still-running, reachable admin SSH. USB remains UART1 serial access, not a web administration console.</p>"
|
||||||
"<p class=\"connection-detail\">Reboot interrupts ALL clients and the entire device, including SSH, USB and UART operation during restart. Unsaved RAM-only working configuration and browser drafts can be lost. Saved configuration and identities are not reset. After boot, restore network reachability, reload and sign in explicitly; inspect the outcome before another action.</p>"
|
"<p class=\"connection-detail\">Reboot interrupts ALL clients and the entire device, including SSH, USB and UART operation during restart. Unsaved RAM-only working configuration and browser drafts can be lost. Saved configuration and identities are not reset. After boot, restore network reachability, reload and sign in explicitly; inspect the outcome before another action.</p>"
|
||||||
"<p class=\"connection-detail\">Rotate replaces and persists the HTTPS certificate AND private key, changes browser trust, and disconnects all web logins/terminals. No SSH identity or user/configuration change. Verify the NEW SHA-256 certificate fingerprint using trusted UART0 (<code>web certificate info</code>) before accepting browser trust; a certificate warning is not verification. Reload and sign in freshly. Native USB remains independent UART1 serial access, not administration. No browser TLS reset/recovery or key/certificate export.</p>"
|
"<p class=\"connection-detail\">Rotate replaces and persists the HTTPS certificate AND private key, changes browser trust, and disconnects all web logins/terminals. No SSH identity or user/configuration change. Verify the NEW SHA-256 certificate fingerprint using trusted UART0 (<code>web certificate info</code>) before accepting browser trust; a certificate warning is not verification. Reload and sign in freshly. Native USB remains independent UART1 serial access, not administration. No browser TLS reset/recovery or key/certificate export.</p>"
|
||||||
@@ -895,11 +896,59 @@ static const char s_app_js[] =
|
|||||||
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
|
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
|
||||||
" brokerButtons();\n"
|
" brokerButtons();\n"
|
||||||
"});\n"
|
"});\n"
|
||||||
|
"let firmwareWork = null, firmwareLocked = false;\n"
|
||||||
|
"const firmwareUncertain = 'Update status unknown. The firmware may already be installed. Reconnect and check before retrying.';\n"
|
||||||
|
"const firmwareWarning = 'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.';\n"
|
||||||
|
"function firmwareButtons() {\n"
|
||||||
|
" element('firmware-file').disabled = element('firmware-upload').disabled = !!firmwareWork || firmwareLocked || !!lifecycleAbort || lifecyclePending;\n"
|
||||||
|
"}\n"
|
||||||
|
"function cancelFirmware() {\n"
|
||||||
|
" const work = firmwareWork; firmwareWork = null; element('firmware-file').value = '';\n"
|
||||||
|
" if (work) { firmwareLocked = true; work.controller.abort(); work.xhr?.abort(); element('firmware-detail').textContent = firmwareUncertain; }\n"
|
||||||
|
" firmwareButtons();\n"
|
||||||
|
"}\n"
|
||||||
|
"async function uploadFirmware() {\n"
|
||||||
|
" if (firmwareWork || firmwareLocked || lifecycleAbort || lifecyclePending || selected !== 'settings' || settingsDomain !== 'lifecycle' || accountRole !== 'admin' || !sessionVerified || !csrf || suspended || unloading || navigating || loggingOut) return;\n"
|
||||||
|
" const file = element('firmware-file').files?.[0], detail = element('firmware-detail'), progress = element('firmware-progress');\n"
|
||||||
|
" if (!file || !/\\.bin$/i.test(file.name) || !Number.isSafeInteger(file.size) || file.size < 1 || file.size > 4 * 1024 * 1024) { detail.textContent = 'Choose a nonempty .bin file up to 4 MiB.'; return; }\n"
|
||||||
|
" if (!window.confirm(firmwareWarning)) return;\n"
|
||||||
|
" const generation = workGeneration, work = {controller:new AbortController(), xhr:null}; firmwareWork = work; lifecycleButtons();\n"
|
||||||
|
" const current = () => firmwareWork === work && live(generation) && sessionVerified && accountRole === 'admin' && !suspended && !loggingOut;\n"
|
||||||
|
" const finish = (message, locked) => { if (!current()) return; firmwareWork = null; firmwareLocked = locked; detail.textContent = message; element('firmware-file').value = ''; lifecycleButtons(); };\n"
|
||||||
|
" progress.value = 0; detail.textContent = 'Checking session…';\n"
|
||||||
|
" try {\n"
|
||||||
|
" if (!await loadSession(generation, work.controller.signal, false)) { finish('Session changed; no upload sent. Sign in again.', false); return; }\n"
|
||||||
|
" if (!current()) return;\n"
|
||||||
|
" const xhr = new XMLHttpRequest(); work.xhr = xhr;\n"
|
||||||
|
" // XHR uses CORS mode and same-origin cookies. File supplies the known body length; never set Origin or Content-Length manually.\n"
|
||||||
|
" xhr.open('POST', '/api/firmware'); xhr.timeout = 180000;\n"
|
||||||
|
" xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('X-CSRF-Token', csrf);\n"
|
||||||
|
" xhr.upload.onprogress = event => {\n"
|
||||||
|
" if (!current()) return;\n"
|
||||||
|
" if (event.lengthComputable && Number.isFinite(event.total) && event.total > 0 && Number.isFinite(event.loaded) && event.loaded >= 0) { progress.value = Math.min(100, Math.floor(event.loaded * 100 / event.total)); detail.textContent = progress.value === 100 ? 'Upload complete. Validating firmware…' : 'Uploading: ' + progress.value + '%'; }\n"
|
||||||
|
" else detail.textContent = 'Uploading… Progress unavailable.';\n"
|
||||||
|
" };\n"
|
||||||
|
" xhr.onerror = xhr.ontimeout = xhr.onabort = () => finish(firmwareUncertain, true);\n"
|
||||||
|
" xhr.onload = () => {\n"
|
||||||
|
" if (!current()) return;\n"
|
||||||
|
" if (xhr.status === 401) { cancelFirmware(); login(); return; }\n"
|
||||||
|
" let value; try { if (xhr.responseText.length > 128) throw new Error(); value = JSON.parse(xhr.responseText); } catch (_) { finish(firmwareUncertain, true); return; }\n"
|
||||||
|
" if (xhr.status === 200 && value && Object.keys(value).length === 2 && value.ok === true && value.rebooting === true) { progress.value = 100; finish('Firmware accepted; rebooting. Reconnect and sign in shortly.', true); return; }\n"
|
||||||
|
" const errors = {invalid_request:'Invalid upload request.', invalid_firmware:'Invalid or incompatible application image.', firmware_incomplete:'Incomplete firmware image.', authentication_required:'Session ended.', origin:'Origin security check failed.', csrf:'Session security check failed.', admin_required:'Administrator access required.', firmware_timeout:'Device upload deadline exceeded.', firmware_too_large:'Image exceeds the device partition capacity.', firmware_content_type:'Raw binary content type required.', firmware_write_failed:'Firmware write failed.', firmware_commit_failed:'Firmware commit failed; inspect the device before any reset or retry.', unavailable:'Service unavailable.', busy:'Device is busy.', firmware_unavailable:'Firmware update partition unavailable.', firmware_resources:'Insufficient device resources.'};\n"
|
||||||
|
" const code = value && Object.keys(value).length === 1 && value.error;\n"
|
||||||
|
" if (xhr.status >= 400 && Object.hasOwn(errors, code)) { finish(errors[code], ['authentication_required','origin','csrf','admin_required','firmware_commit_failed'].includes(code)); }\n"
|
||||||
|
" else finish(firmwareUncertain, true);\n"
|
||||||
|
" };\n"
|
||||||
|
" detail.textContent = 'Uploading… Keep power connected.'; xhr.send(file);\n"
|
||||||
|
" } catch (_) { finish(work.xhr ? firmwareUncertain : 'Session check failed; no upload sent. Reconnect and try again.', !!work.xhr); }\n"
|
||||||
|
"}\n"
|
||||||
|
"element('firmware-upload').addEventListener('click', uploadFirmware);\n"
|
||||||
"const lifecycleActions = ['stop','restart','reboot','rotate'];\n"
|
"const lifecycleActions = ['stop','restart','reboot','rotate'];\n"
|
||||||
"let lifecycleSnapshot = null, lifecycleAbort = null, lifecyclePending = false, lifecycleAwaitingAck = false, lifecycleId = 0, lifecycleAction = '';\n"
|
"let lifecycleSnapshot = null, lifecycleAbort = null, lifecyclePending = false, lifecycleAwaitingAck = false, lifecycleId = 0, lifecycleAction = '';\n"
|
||||||
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. Rotation may have persisted a NEW identity even when stop/start fails; no rollback. The stored fingerprint may differ from a retained old server certificate. Inspect with trusted UART0 web certificate info, verify the new fingerprint before renewing trust, then reload and sign in freshly. SSH and native USB UART1 access are not stopped by rotation. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
|
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. Rotation may have persisted a NEW identity even when stop/start fails; no rollback. The stored fingerprint may differ from a retained old server certificate. Inspect with trusted UART0 web certificate info, verify the new fingerprint before renewing trust, then reload and sign in freshly. SSH and native USB UART1 access are not stopped by rotation. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
|
||||||
"function lifecycleButtons() {\n"
|
"function lifecycleButtons() {\n"
|
||||||
" const busy = !!lifecycleAbort;\n"
|
" firmwareButtons();\n"
|
||||||
|
" const busy = !!lifecycleAbort || !!firmwareWork || firmwareLocked;\n"
|
||||||
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
|
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
|
||||||
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\n"
|
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
@@ -911,7 +960,7 @@ static const char s_app_js[] =
|
|||||||
" lifecycleButtons();\n"
|
" lifecycleButtons();\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"async function lifecycleRequest(action, snapshotRead = false) {\n"
|
"async function lifecycleRequest(action, snapshotRead = false) {\n"
|
||||||
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || lifecycleAbort || (action && lifecyclePending)) return;\n"
|
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || firmwareWork || firmwareLocked || lifecycleAbort || (action && lifecyclePending)) return;\n"
|
||||||
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
|
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
|
||||||
" if (action) {\n"
|
" if (action) {\n"
|
||||||
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) return;\n"
|
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) return;\n"
|
||||||
@@ -1259,9 +1308,12 @@ static const char s_app_js[] =
|
|||||||
" return netShape(w, ['generation','enabled_at_boot','ap','profiles']) && netInteger(w.generation, 1, 4294967295) && typeof w.enabled_at_boot === 'boolean' &&\n"
|
" return netShape(w, ['generation','enabled_at_boot','ap','profiles']) && netInteger(w.generation, 1, 4294967295) && typeof w.enabled_at_boot === 'boolean' &&\n"
|
||||||
" netShape(w.ap, ['policy','channel','ssid','password_configured']) && ['off','fallback','always'].includes(w.ap.policy) && netInteger(w.ap.channel, 1, 11) && netBytes(w.ap.ssid) && w.ap.ssid.length > 0 && w.ap.password_configured === true &&\n"
|
" netShape(w.ap, ['policy','channel','ssid','password_configured']) && ['off','fallback','always'].includes(w.ap.policy) && netInteger(w.ap.channel, 1, 11) && netBytes(w.ap.ssid) && w.ap.ssid.length > 0 && w.ap.password_configured === true &&\n"
|
||||||
" Array.isArray(w.profiles) && w.profiles.length === 4 && w.profiles.every((p, i) => netShape(p, ['index','enabled','priority','security','ssid','password_configured']) && p.index === i && typeof p.enabled === 'boolean' && netInteger(p.priority, 0, 255) && ['mixed','wpa3'].includes(p.security) && netBytes(p.ssid) && typeof p.password_configured === 'boolean' && (!p.enabled || p.ssid.length > 0 && p.password_configured) && (p.ssid.length > 0 || !p.password_configured)) &&\n"
|
" Array.isArray(w.profiles) && w.profiles.length === 4 && w.profiles.every((p, i) => netShape(p, ['index','enabled','priority','security','ssid','password_configured']) && p.index === i && typeof p.enabled === 'boolean' && netInteger(p.priority, 0, 255) && ['mixed','wpa3'].includes(p.security) && netBytes(p.ssid) && typeof p.password_configured === 'boolean' && (!p.enabled || p.ssid.length > 0 && p.password_configured) && (p.ssid.length > 0 || !p.password_configured)) &&\n"
|
||||||
" netShape(r, ['started','state','active_profile','ip','ap_running','ap_clients','last_error']) && typeof r.started === 'boolean' && ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown'].includes(r.state) && netInteger(r.active_profile, -1, 3) && typeof r.ip === 'string' && r.ip.length <= 15 && (r.ip === '' || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(r.ip) && r.ip.split('.').every(n => Number(n) <= 255)) && typeof r.ap_running === 'boolean' && netInteger(r.ap_clients, 0, 255) && netInteger(r.last_error, -2147483648, 2147483647) &&\n"
|
" netShape(r, ['started','state','active_profile','ip','ipv6_linklocal','ipv6_routable','ipv6_addresses','ap_running','ap_clients','last_error']) && typeof r.started === 'boolean' && ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown'].includes(r.state) && netInteger(r.active_profile, -1, 3) && typeof r.ip === 'string' && r.ip.length <= 15 && (r.ip === '' || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(r.ip) && r.ip.split('.').every(n => Number(n) <= 255)) && typeof r.ipv6_linklocal === 'boolean' && typeof r.ipv6_routable === 'boolean' && Array.isArray(r.ipv6_addresses) && r.ipv6_addresses.length <= 3 && r.ipv6_addresses.every(a => typeof a === 'string' && a.length === 39 && /^(?:[0-9a-f]{4}:){7}[0-9a-f]{4}$/.test(a)) && typeof r.ap_running === 'boolean' && netInteger(r.ap_clients, 0, 255) && netInteger(r.last_error, -2147483648, 2147483647) &&\n"
|
||||||
" netShape(m, ['generation','suffix','hostname','announced','last_error']) && netInteger(m.generation, 1, 4294967295) && netSuffix(m.suffix) && m.hostname === 'sak-' + m.suffix && typeof m.announced === 'boolean' && netInteger(m.last_error, -2147483648, 2147483647);\n"
|
" netShape(m, ['generation','suffix','hostname','announced','last_error']) && netInteger(m.generation, 1, 4294967295) && netSuffix(m.suffix) && m.hostname === 'sak-' + m.suffix && typeof m.announced === 'boolean' && netInteger(m.last_error, -2147483648, 2147483647);\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
|
"const networkIPv4 = ip => ip && ip !== '0.0.0.0' ? ip : 'none';\n"
|
||||||
|
"const networkIPv6 = r => 'IPv6 link-local: ' + (r.ipv6_linklocal ? 'available' : 'none') + ' / ULA/GUA: ' + (r.ipv6_routable ? 'available' : 'none');\n"
|
||||||
|
"const networkIPv6Addresses = (r, prefix) => r.ipv6_addresses.filter(a => prefix.test(a)).join(', ') || 'none';\n"
|
||||||
"function networkContext() { return JSON.stringify([networkSnapshot?.wifi.generation, networkSnapshot?.mdns.generation, ...networkFields.map(id => [net(id).value, net(id).checked])]); }\n"
|
"function networkContext() { return JSON.stringify([networkSnapshot?.wifi.generation, networkSnapshot?.mdns.generation, ...networkFields.map(id => [net(id).value, net(id).checked])]); }\n"
|
||||||
"function clearNetworkSecret() {\n"
|
"function clearNetworkSecret() {\n"
|
||||||
" window.clearTimeout(networkSecretTimer); networkSecretTimer = null; networkSecretUntil = 0; networkSecretContext = '';\n"
|
" window.clearTimeout(networkSecretTimer); networkSecretTimer = null; networkSecretUntil = 0; networkSecretContext = '';\n"
|
||||||
@@ -1350,7 +1402,7 @@ static const char s_app_js[] =
|
|||||||
" net('detail').textContent = 'Reading network. Previous snapshot is stale; refresh discards drafts.';\n"
|
" net('detail').textContent = 'Reading network. Previous snapshot is stale; refresh discards drafts.';\n"
|
||||||
" try {\n"
|
" try {\n"
|
||||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||||
" const {payload, status} = await api('/api/settings/network', generation, {signal: controller.signal, limit: 2048, current});\n"
|
" const {payload, status} = await api('/api/settings/network', generation, {signal: controller.signal, limit: 2304, current});\n"
|
||||||
" if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n"
|
" if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n"
|
||||||
" networkSnapshot = payload; networkFresh = true;\n"
|
" networkSnapshot = payload; networkFresh = true;\n"
|
||||||
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
|
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
|
||||||
@@ -1358,13 +1410,15 @@ static const char s_app_js[] =
|
|||||||
" ['Wi-Fi generation', w.generation], ['Enabled at boot', w.enabled_at_boot],\n"
|
" ['Wi-Fi generation', w.generation], ['Enabled at boot', w.enabled_at_boot],\n"
|
||||||
" ['AP policy / channel', w.ap.policy + ' / ' + w.ap.channel], ['AP SSID', networkSSIDSummary(w.ap.ssid)], ['AP password configured', w.ap.password_configured],\n"
|
" ['AP policy / channel', w.ap.policy + ' / ' + w.ap.channel], ['AP SSID', networkSSIDSummary(w.ap.ssid)], ['AP password configured', w.ap.password_configured],\n"
|
||||||
" ...w.profiles.flatMap(p => [['STA ' + p.index, 'enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security], ['STA ' + p.index + ' SSID', networkSSIDSummary(p.ssid)], ['STA ' + p.index + ' password configured', p.password_configured]]),\n"
|
" ...w.profiles.flatMap(p => [['STA ' + p.index, 'enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security], ['STA ' + p.index + ' SSID', networkSSIDSummary(p.ssid)], ['STA ' + p.index + ' password configured', p.password_configured]]),\n"
|
||||||
" ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IP', r.ip || 'none'],\n"
|
" ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IPv4', networkIPv4(r.ip)], ['IPv6 link-local', r.ipv6_linklocal ? 'available' : 'none'], ['IPv6 ULA/GUA', r.ipv6_routable ? 'available' : 'none'],\n"
|
||||||
|
" ['IPv6 link-local addresses', networkIPv6Addresses(r, /^fe[89ab]/)], ['IPv6 ULA addresses', networkIPv6Addresses(r, /^f[cd]/)], ['IPv6 GUA addresses', networkIPv6Addresses(r, /^[23]/)],\n"
|
||||||
|
" ['IPv6 reporting', 'Preferred addresses; no route or Internet reachability guarantee. Link-local access requires the client interface scope.'],\n"
|
||||||
" ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n"
|
" ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n"
|
||||||
" ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n"
|
" ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n"
|
||||||
" ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n"
|
" ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n"
|
||||||
" if (!['ap','0','1','2','3'].includes(net('target').value) || quick && net('target').value !== 'ap' && !w.profiles[Number(net('target').value)]?.ssid) net('target').value = 'ap';\n"
|
" if (!['ap','0','1','2','3'].includes(net('target').value) || quick && net('target').value !== 'ap' && !w.profiles[Number(net('target').value)]?.ssid) net('target').value = 'ap';\n"
|
||||||
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
|
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
|
||||||
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : '') + (quick ? r.state + ' · IP: ' + (r.ip || 'none') + ' · AP: ' + (r.ap_running ? 'running' : 'off') + ' · Profile: ' + (r.active_profile < 0 ? 'none' : r.active_profile) : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). Browser drafts are not saved; Save persists device working state.');\n"
|
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : '') + (quick ? r.state + ' · IPv4: ' + networkIPv4(r.ip) + ' · ' + networkIPv6(r) + ' · AP: ' + (r.ap_running ? 'running' : 'off') + ' · Profile: ' + (r.active_profile < 0 ? 'none' : r.active_profile) : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). Browser drafts are not saved; Save persists device working state.');\n"
|
||||||
" } catch (error) { if (live(generation) && current()) net('detail').textContent = 'Network snapshot stale or unavailable/invalid. Refresh explicitly to retry. No values inferred.'; }\n"
|
" } catch (error) { if (live(generation) && current()) net('detail').textContent = 'Network snapshot stale or unavailable/invalid. Refresh explicitly to retry. No values inferred.'; }\n"
|
||||||
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
|
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
@@ -1639,6 +1693,7 @@ static const char s_app_js[] =
|
|||||||
"const requests = new Set();\n"
|
"const requests = new Set();\n"
|
||||||
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
|
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
|
||||||
"const cancelWork = () => {\n"
|
"const cancelWork = () => {\n"
|
||||||
|
" cancelFirmware();\n"
|
||||||
" clearSettings();\n"
|
" clearSettings();\n"
|
||||||
" ++workGeneration;\n"
|
" ++workGeneration;\n"
|
||||||
" ++connectionGeneration;\n"
|
" ++connectionGeneration;\n"
|
||||||
@@ -2011,7 +2066,8 @@ static const char s_app_js[] =
|
|||||||
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
|
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
|
||||||
" if (wifi && wifi.available) {\n"
|
" if (wifi && wifi.available) {\n"
|
||||||
" const parts = [textValue(wifi.state, 'unknown')];\n"
|
" const parts = [textValue(wifi.state, 'unknown')];\n"
|
||||||
" if (typeof wifi.sta_ipv4 === 'string' && wifi.sta_ipv4 !== '0.0.0.0') parts.push(wifi.sta_ipv4);\n"
|
" parts.push('IPv4: ' + networkIPv4(typeof wifi.sta_ipv4 === 'string' ? wifi.sta_ipv4 : ''));\n"
|
||||||
|
" parts.push(typeof wifi.ipv6_linklocal === 'boolean' && typeof wifi.ipv6_routable === 'boolean' ? networkIPv6(wifi) : 'IPv6 availability: unknown');\n"
|
||||||
" if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n"
|
" if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n"
|
||||||
" if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n"
|
" if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n"
|
||||||
" if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n"
|
" if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n"
|
||||||
|
|||||||
@@ -118,6 +118,28 @@ static void print_ipv4(uint32_t address)
|
|||||||
printf(IPSTR, IP2STR(&ip));
|
printf(IPSTR, IP2STR(&ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void print_ipv6_addresses(const wifi_manager_snapshot_t *snapshot)
|
||||||
|
{
|
||||||
|
printf("IPv6 preferred address availability: link-local=%s ULA/GUA=%s\n",
|
||||||
|
snapshot->ipv6_linklocal ? "yes" : "no",
|
||||||
|
snapshot->ipv6_routable ? "yes" : "no");
|
||||||
|
for (uint8_t i = 0; i < snapshot->ipv6_count && i < WIFI_MANAGER_IPV6_MAX_ADDRESSES; ++i) {
|
||||||
|
const wifi_manager_ipv6_address_t *address = &snapshot->ipv6_addresses[i];
|
||||||
|
uint16_t prefix = ESP_IP6_ADDR_BLOCK1(address);
|
||||||
|
const char *kind = (prefix & 0xffc0U) == 0xfe80U ? "link-local" :
|
||||||
|
(prefix & 0xfe00U) == 0xfc00U ? "ULA" :
|
||||||
|
(prefix & 0xe000U) == 0x2000U ? "GUA" : "other";
|
||||||
|
printf("IPv6 preferred %s: " IPV6STR "\n", kind, IPV62STR(*address));
|
||||||
|
}
|
||||||
|
if (snapshot->ipv6_count == 0U) {
|
||||||
|
printf("IPv6 preferred addresses: none\n");
|
||||||
|
}
|
||||||
|
if (snapshot->ipv6_linklocal) {
|
||||||
|
printf("Link-local destinations require a zone (%%interface) naming the client's interface, not the ESP32's.\n");
|
||||||
|
}
|
||||||
|
printf("IPv6 addresses do not establish a default route or Internet reachability.\n");
|
||||||
|
}
|
||||||
|
|
||||||
static int show_status(void)
|
static int show_status(void)
|
||||||
{
|
{
|
||||||
wifi_manager_snapshot_t snapshot;
|
wifi_manager_snapshot_t snapshot;
|
||||||
@@ -157,7 +179,10 @@ static int show_status(void)
|
|||||||
printf(" gateway=");
|
printf(" gateway=");
|
||||||
print_ipv4(snapshot.gateway);
|
print_ipv4(snapshot.gateway);
|
||||||
putchar('\n');
|
putchar('\n');
|
||||||
|
} else {
|
||||||
|
printf("IPv4: none\n");
|
||||||
}
|
}
|
||||||
|
print_ipv6_addresses(&snapshot);
|
||||||
|
|
||||||
printf("AP: policy=%s running=%s clients=%u channel=%u SSID=",
|
printf("AP: policy=%s running=%s clients=%u channel=%u SSID=",
|
||||||
wifi_config_ap_policy_to_string(snapshot.ap_policy),
|
wifi_config_ap_policy_to_string(snapshot.ap_policy),
|
||||||
|
|||||||
+260
-63
@@ -19,11 +19,14 @@
|
|||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "mdns_service.h"
|
#include "mdns_service.h"
|
||||||
#include "nvs.h"
|
#include "nvs.h"
|
||||||
|
#include "esp_netif_net_stack.h"
|
||||||
|
#include "lwip/netif.h"
|
||||||
|
|
||||||
#define WIFI_MANAGER_QUEUE_LENGTH 16U
|
#define WIFI_MANAGER_QUEUE_LENGTH 16U
|
||||||
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
|
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
|
||||||
#define WIFI_MANAGER_TASK_PRIORITY 5U
|
#define WIFI_MANAGER_TASK_PRIORITY 5U
|
||||||
#define WIFI_MANAGER_ATTEMPT_US (12LL * 1000LL * 1000LL)
|
#define WIFI_MANAGER_ATTEMPT_US (12LL * 1000LL * 1000LL)
|
||||||
|
#define WIFI_MANAGER_RECONCILE_US (1LL * 1000LL * 1000LL)
|
||||||
#define WIFI_MANAGER_STABLE_US (30LL * 1000LL * 1000LL)
|
#define WIFI_MANAGER_STABLE_US (30LL * 1000LL * 1000LL)
|
||||||
#define WIFI_MANAGER_DISCONNECT_SETTLE_US (1LL * 1000LL * 1000LL)
|
#define WIFI_MANAGER_DISCONNECT_SETTLE_US (1LL * 1000LL * 1000LL)
|
||||||
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2U
|
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2U
|
||||||
@@ -42,6 +45,7 @@ typedef enum {
|
|||||||
MESSAGE_STA_DISCONNECTED,
|
MESSAGE_STA_DISCONNECTED,
|
||||||
MESSAGE_STA_GOT_IP,
|
MESSAGE_STA_GOT_IP,
|
||||||
MESSAGE_STA_LOST_IP,
|
MESSAGE_STA_LOST_IP,
|
||||||
|
MESSAGE_STA_GOT_IP6,
|
||||||
MESSAGE_STA_STOPPED,
|
MESSAGE_STA_STOPPED,
|
||||||
MESSAGE_AP_STOPPED,
|
MESSAGE_AP_STOPPED,
|
||||||
MESSAGE_AP_CLIENT_JOINED,
|
MESSAGE_AP_CLIENT_JOINED,
|
||||||
@@ -78,6 +82,9 @@ typedef struct {
|
|||||||
bool ap_enabled;
|
bool ap_enabled;
|
||||||
bool associated;
|
bool associated;
|
||||||
bool online;
|
bool online;
|
||||||
|
int64_t reconcile_deadline;
|
||||||
|
bool mdns_reannounce_pending;
|
||||||
|
bool mdns_failure_reported;
|
||||||
uint8_t profile_order[WIFI_CONFIG_STA_PROFILE_COUNT];
|
uint8_t profile_order[WIFI_CONFIG_STA_PROFILE_COUNT];
|
||||||
uint8_t profile_count;
|
uint8_t profile_count;
|
||||||
uint8_t next_profile;
|
uint8_t next_profile;
|
||||||
@@ -112,13 +119,72 @@ static void manager_task(void *context);
|
|||||||
static void start_profile_cycle(manager_runtime_t *runtime);
|
static void start_profile_cycle(manager_runtime_t *runtime);
|
||||||
static void start_next_profile(manager_runtime_t *runtime);
|
static void start_next_profile(manager_runtime_t *runtime);
|
||||||
|
|
||||||
static void start_mdns_announcement(void)
|
/* IDF 5.5 esp_netif_set_hostname caps names at 32 bytes, below our existing
|
||||||
|
* 59-byte name contract. lwIP supports the full DHCP option 12 name. This
|
||||||
|
* permanent buffer and netif pointer are changed only in TCP/IP context;
|
||||||
|
* esp-netif never owns/frees it. wlanif_init preserves netif->hostname.
|
||||||
|
* Raw hostname readers must also run in TCP/IP context; application status
|
||||||
|
* uses the copied mdns_service snapshot, never this mutable pointer. */
|
||||||
|
static char s_station_hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *hostname;
|
||||||
|
bool applied;
|
||||||
|
} station_hostname_request_t;
|
||||||
|
|
||||||
|
static esp_err_t set_station_hostname(void *context)
|
||||||
|
{
|
||||||
|
#if LWIP_NETIF_HOSTNAME
|
||||||
|
station_hostname_request_t *request = context;
|
||||||
|
const char *hostname = request->hostname;
|
||||||
|
struct netif *netif = esp_netif_get_netif_impl(s_sta_netif);
|
||||||
|
if (netif == NULL) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
size_t length = strnlen(hostname, sizeof(s_station_hostname));
|
||||||
|
if (length == 0 || length >= sizeof(s_station_hostname)) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
if (strcmp(s_station_hostname, hostname) != 0) {
|
||||||
|
memcpy(s_station_hostname, hostname, length + 1U);
|
||||||
|
}
|
||||||
|
netif_set_hostname(netif, s_station_hostname);
|
||||||
|
request->applied = true;
|
||||||
|
return ESP_OK;
|
||||||
|
#else
|
||||||
|
(void)context;
|
||||||
|
return ESP_ERR_NOT_SUPPORTED;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Called before connect (and hence the default handler's DHCP start), and on
|
||||||
|
* rename. lwIP reads the name for subsequent DHCP option 12 exchanges,
|
||||||
|
* including renew/rebind. A rename never forces lease/radio churn. */
|
||||||
|
static esp_err_t refresh_station_hostname(void)
|
||||||
|
{
|
||||||
|
mdns_service_snapshot_t snapshot;
|
||||||
|
esp_err_t error = mdns_service_get_snapshot(&snapshot);
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
station_hostname_request_t request = { .hostname = snapshot.hostname };
|
||||||
|
error = esp_netif_tcpip_exec(set_station_hostname, &request);
|
||||||
|
/* IDF 5.5 ignores tcpip_send_msg_wait_sem's enqueue error. Its wrapper
|
||||||
|
* can return ESP_OK without running the callback under memory pressure. */
|
||||||
|
if (error == ESP_OK && !request.applied) {
|
||||||
|
error = ESP_FAIL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void start_mdns_announcement(manager_runtime_t *runtime)
|
||||||
{
|
{
|
||||||
esp_err_t error = mdns_service_start();
|
esp_err_t error = mdns_service_start();
|
||||||
if (error != ESP_OK) {
|
runtime->mdns_reannounce_pending = error != ESP_OK;
|
||||||
|
if (error != ESP_OK && !runtime->mdns_failure_reported) {
|
||||||
/* Name discovery is optional; never make network or serial recovery depend on it. */
|
/* Name discovery is optional; never make network or serial recovery depend on it. */
|
||||||
ESP_LOGW(TAG, "mDNS announcement unavailable: %s", esp_err_to_name(error));
|
ESP_LOGW(TAG, "mDNS announcement unavailable: %s", esp_err_to_name(error));
|
||||||
}
|
}
|
||||||
|
runtime->mdns_failure_reported = error != ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void lock_shared(void)
|
static void lock_shared(void)
|
||||||
@@ -177,6 +243,10 @@ static void clear_station_network_snapshot(void)
|
|||||||
s_shared.snapshot.ip = 0U;
|
s_shared.snapshot.ip = 0U;
|
||||||
s_shared.snapshot.netmask = 0U;
|
s_shared.snapshot.netmask = 0U;
|
||||||
s_shared.snapshot.gateway = 0U;
|
s_shared.snapshot.gateway = 0U;
|
||||||
|
s_shared.snapshot.ipv6_linklocal = false;
|
||||||
|
s_shared.snapshot.ipv6_routable = false;
|
||||||
|
s_shared.snapshot.ipv6_count = 0U;
|
||||||
|
memset(s_shared.snapshot.ipv6_addresses, 0, sizeof(s_shared.snapshot.ipv6_addresses));
|
||||||
s_shared.snapshot.sta_channel = 0U;
|
s_shared.snapshot.sta_channel = 0U;
|
||||||
s_shared.snapshot.sta_rssi = 0;
|
s_shared.snapshot.sta_rssi = 0;
|
||||||
s_shared.snapshot.sta_auth = WIFI_AUTH_OPEN;
|
s_shared.snapshot.sta_auth = WIFI_AUTH_OPEN;
|
||||||
@@ -417,6 +487,7 @@ static void mark_intentional_disconnect(manager_runtime_t *runtime)
|
|||||||
}
|
}
|
||||||
runtime->associated = false;
|
runtime->associated = false;
|
||||||
runtime->online = false;
|
runtime->online = false;
|
||||||
|
mdns_service_stop();
|
||||||
clear_station_network_snapshot();
|
clear_station_network_snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +589,10 @@ static void start_next_profile(manager_runtime_t *runtime)
|
|||||||
set_active_profile((int8_t)slot, profile);
|
set_active_profile((int8_t)slot, profile);
|
||||||
set_state(WIFI_MANAGER_STATE_CONNECTING);
|
set_state(WIFI_MANAGER_STATE_CONNECTING);
|
||||||
|
|
||||||
esp_err_t error = configure_station(profile);
|
esp_err_t error = refresh_station_hostname();
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = configure_station(profile);
|
||||||
|
}
|
||||||
if (error == ESP_OK) {
|
if (error == ESP_OK) {
|
||||||
error = esp_wifi_connect();
|
error = esp_wifi_connect();
|
||||||
}
|
}
|
||||||
@@ -662,6 +736,7 @@ static void start_radio_and_policy(manager_runtime_t *runtime)
|
|||||||
}
|
}
|
||||||
|
|
||||||
runtime->radio_started = true;
|
runtime->radio_started = true;
|
||||||
|
|
||||||
runtime->ap_enabled = want_ap;
|
runtime->ap_enabled = want_ap;
|
||||||
set_last_error(ESP_OK);
|
set_last_error(ESP_OK);
|
||||||
note_ap_running(want_ap, &config);
|
note_ap_running(want_ap, &config);
|
||||||
@@ -709,24 +784,127 @@ static void update_connected_snapshot(const manager_message_t *message,
|
|||||||
unlock_shared();
|
unlock_shared();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
esp_netif_ip_info_t ip4;
|
||||||
|
bool read_completed;
|
||||||
|
bool linklocal;
|
||||||
|
bool routable;
|
||||||
|
uint8_t ipv6_count;
|
||||||
|
wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];
|
||||||
|
} station_addresses_t;
|
||||||
|
|
||||||
|
/* IDF's IPv6 getters access lwIP directly. Run the bounded address scan in
|
||||||
|
* TCP/IP context, not concurrently with DAD, RA lifetime changes or teardown. */
|
||||||
|
static esp_err_t read_station_addresses(void *context)
|
||||||
|
{
|
||||||
|
station_addresses_t *addresses = context;
|
||||||
|
if (!esp_netif_is_netif_up(s_sta_netif)) {
|
||||||
|
/* A successful empty read withdraws readiness during netif teardown. */
|
||||||
|
addresses->read_completed = true;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t error = esp_netif_get_ip_info(s_sta_netif, &addresses->ip4);
|
||||||
|
#if CONFIG_LWIP_IPV6
|
||||||
|
/* IDF 5.5's CONFIG_LWIP_IPV6_AUTOCONFIG only controls its default
|
||||||
|
* per-netif enablement. lwIP SLAAC is compiled with LWIP_IPV6_AUTOCONFIG.
|
||||||
|
* Keep this policy STA-only, including when that SDK default is disabled. */
|
||||||
|
struct netif *netif = esp_netif_get_netif_impl(s_sta_netif);
|
||||||
|
#if LWIP_IPV6_AUTOCONFIG
|
||||||
|
netif_set_ip6_autoconfig_enabled(netif, 1);
|
||||||
|
#endif
|
||||||
|
/* An IDF disconnect invalidates all slots. Inspect slot state rather than
|
||||||
|
* trusting queued association events: both disconnect/connect may drop.
|
||||||
|
* Do not restart DAD for tentative or duplicate addresses. */
|
||||||
|
if (netif_ip6_addr_state(netif, 0) == IP6_ADDR_INVALID) {
|
||||||
|
netif_create_ip6_linklocal_address(netif, 1);
|
||||||
|
}
|
||||||
|
_Static_assert(LWIP_IPV6_NUM_ADDRESSES <= WIFI_MANAGER_IPV6_MAX_ADDRESSES,
|
||||||
|
"IPv6 snapshot capacity must cover every configured lwIP slot");
|
||||||
|
esp_ip6_addr_t ip6[LWIP_IPV6_NUM_ADDRESSES];
|
||||||
|
int count = esp_netif_get_all_preferred_ip6(s_sta_netif, ip6);
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
memcpy(addresses->ipv6_addresses[i].addr, ip6[i].addr, sizeof(ip6[i].addr));
|
||||||
|
++addresses->ipv6_count;
|
||||||
|
uint16_t prefix = ESP_IP6_ADDR_BLOCK1(&ip6[i]);
|
||||||
|
if ((prefix & 0xffc0U) == 0xfe80U) {
|
||||||
|
addresses->linklocal = true;
|
||||||
|
} else if ((prefix & 0xe000U) == 0x2000U || (prefix & 0xfe00U) == 0xfc00U) {
|
||||||
|
addresses->routable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
addresses->read_completed = true;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
static void handle_got_ip(manager_runtime_t *runtime,
|
static void handle_got_ip(manager_runtime_t *runtime,
|
||||||
const manager_message_t *message)
|
const manager_message_t *message)
|
||||||
{
|
{
|
||||||
esp_netif_ip_info_t current_ip;
|
(void)message; /* Events are hints, never authoritative address storage. */
|
||||||
wifi_ap_record_t ap_record;
|
wifi_ap_record_t ap_record;
|
||||||
memset(¤t_ip, 0, sizeof(current_ip));
|
if (!runtime->radio_started || runtime->stop_pending ||
|
||||||
memset(&ap_record, 0, sizeof(ap_record));
|
runtime->advance_after_disconnect || runtime->backoff_deadline != 0 ||
|
||||||
|
|
||||||
/* Driver/netif state is authoritative when old queued events arrive late. */
|
|
||||||
if (esp_netif_get_ip_info(s_sta_netif, ¤t_ip) != ESP_OK ||
|
|
||||||
current_ip.ip.addr == 0U ||
|
|
||||||
current_ip.ip.addr != message->data.got_ip.ip ||
|
|
||||||
esp_wifi_sta_get_ap_info(&ap_record) != ESP_OK) {
|
esp_wifi_sta_get_ap_info(&ap_record) != ESP_OK) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
manager_message_t connected = { .type = MESSAGE_STA_CONNECTED };
|
||||||
|
/* SSIDs are bytes, not strings. The driver record has no length field;
|
||||||
|
* retain the active profile's length, including any embedded NUL bytes. */
|
||||||
|
lock_shared();
|
||||||
|
connected.data.connected.ssid_len = s_shared.snapshot.sta_ssid_len;
|
||||||
|
unlock_shared();
|
||||||
|
if (connected.data.connected.ssid_len > WIFI_CONFIG_SSID_MAX_LEN ||
|
||||||
|
(connected.data.connected.ssid_len < WIFI_CONFIG_SSID_MAX_LEN &&
|
||||||
|
ap_record.ssid[connected.data.connected.ssid_len] != 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memcpy(connected.data.connected.ssid, ap_record.ssid,
|
||||||
|
connected.data.connected.ssid_len);
|
||||||
|
if (!connected_event_matches_active_profile(&connected)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
runtime->associated = true;
|
runtime->associated = true;
|
||||||
runtime->online = true;
|
|
||||||
|
station_addresses_t addresses = {0};
|
||||||
|
esp_err_t address_error = esp_netif_tcpip_exec(read_station_addresses, &addresses);
|
||||||
|
if (address_error == ESP_OK && !addresses.read_completed) {
|
||||||
|
address_error = ESP_FAIL;
|
||||||
|
}
|
||||||
|
if (address_error != ESP_OK) {
|
||||||
|
/* Do not retire recovery AP or retain ONLINE on an unverified read.
|
||||||
|
* Retry on the owner cadence, without logging on every failure. */
|
||||||
|
memset(&addresses, 0, sizeof(addresses));
|
||||||
|
set_last_error(address_error);
|
||||||
|
}
|
||||||
|
bool online = addresses.ip4.ip.addr != 0U || addresses.linklocal || addresses.routable;
|
||||||
|
bool was_online = runtime->online;
|
||||||
|
lock_shared();
|
||||||
|
bool new_ip4 = addresses.ip4.ip.addr != 0U &&
|
||||||
|
addresses.ip4.ip.addr != s_shared.snapshot.ip;
|
||||||
|
s_shared.snapshot.ip = addresses.ip4.ip.addr;
|
||||||
|
s_shared.snapshot.netmask = addresses.ip4.netmask.addr;
|
||||||
|
s_shared.snapshot.gateway = addresses.ip4.gw.addr;
|
||||||
|
s_shared.snapshot.ipv6_linklocal = addresses.linklocal;
|
||||||
|
s_shared.snapshot.ipv6_routable = addresses.routable;
|
||||||
|
s_shared.snapshot.ipv6_count = addresses.ipv6_count;
|
||||||
|
memcpy(s_shared.snapshot.ipv6_addresses, addresses.ipv6_addresses,
|
||||||
|
sizeof(s_shared.snapshot.ipv6_addresses));
|
||||||
|
if (new_ip4) {
|
||||||
|
++s_shared.snapshot.counters.got_ip;
|
||||||
|
}
|
||||||
|
unlock_shared();
|
||||||
|
runtime->online = online;
|
||||||
|
if (!online) {
|
||||||
|
if (was_online) {
|
||||||
|
mdns_service_stop();
|
||||||
|
runtime->stable_deadline = 0;
|
||||||
|
}
|
||||||
|
if (runtime->attempt_deadline == 0) {
|
||||||
|
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
|
||||||
|
}
|
||||||
|
set_state(WIFI_MANAGER_STATE_WAITING_IP);
|
||||||
|
return;
|
||||||
|
}
|
||||||
runtime->intentional_disconnects = 0U;
|
runtime->intentional_disconnects = 0U;
|
||||||
runtime->advance_after_disconnect = false;
|
runtime->advance_after_disconnect = false;
|
||||||
runtime->attempt_deadline = 0;
|
runtime->attempt_deadline = 0;
|
||||||
@@ -736,14 +914,13 @@ static void handle_got_ip(manager_runtime_t *runtime,
|
|||||||
|
|
||||||
wifi_app_config_t config;
|
wifi_app_config_t config;
|
||||||
copy_working_config(&config);
|
copy_working_config(&config);
|
||||||
runtime->stable_deadline = config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK
|
if (!was_online) {
|
||||||
? esp_timer_get_time() + WIFI_MANAGER_STABLE_US
|
runtime->stable_deadline = config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK
|
||||||
: 0;
|
? esp_timer_get_time() + WIFI_MANAGER_STABLE_US
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
lock_shared();
|
lock_shared();
|
||||||
s_shared.snapshot.ip = message->data.got_ip.ip;
|
|
||||||
s_shared.snapshot.netmask = message->data.got_ip.netmask;
|
|
||||||
s_shared.snapshot.gateway = message->data.got_ip.gateway;
|
|
||||||
s_shared.snapshot.sta_channel = ap_record.primary;
|
s_shared.snapshot.sta_channel = ap_record.primary;
|
||||||
s_shared.snapshot.sta_rssi = ap_record.rssi;
|
s_shared.snapshot.sta_rssi = ap_record.rssi;
|
||||||
s_shared.snapshot.sta_auth = ap_record.authmode;
|
s_shared.snapshot.sta_auth = ap_record.authmode;
|
||||||
@@ -753,11 +930,12 @@ static void handle_got_ip(manager_runtime_t *runtime,
|
|||||||
s_shared.snapshot.retry_seconds = 0U;
|
s_shared.snapshot.retry_seconds = 0U;
|
||||||
s_shared.snapshot.last_error = ESP_OK;
|
s_shared.snapshot.last_error = ESP_OK;
|
||||||
s_shared.snapshot.state = WIFI_MANAGER_STATE_ONLINE;
|
s_shared.snapshot.state = WIFI_MANAGER_STATE_ONLINE;
|
||||||
++s_shared.snapshot.counters.got_ip;
|
|
||||||
unlock_shared();
|
unlock_shared();
|
||||||
|
|
||||||
wifi_config_secure_wipe(&config, sizeof(config));
|
wifi_config_secure_wipe(&config, sizeof(config));
|
||||||
start_mdns_announcement();
|
if (!was_online) {
|
||||||
|
start_mdns_announcement(runtime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void handle_sta_disconnected(manager_runtime_t *runtime,
|
static void handle_sta_disconnected(manager_runtime_t *runtime,
|
||||||
@@ -884,13 +1062,16 @@ static void handle_message(manager_runtime_t *runtime,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MESSAGE_COMMAND_MDNS_REANNOUNCE:
|
case MESSAGE_COMMAND_MDNS_REANNOUNCE:
|
||||||
if (runtime->online) {
|
{
|
||||||
esp_err_t error = mdns_service_reannounce();
|
esp_err_t error = refresh_station_hostname();
|
||||||
if (error != ESP_OK) {
|
if (error != ESP_OK) {
|
||||||
ESP_LOGW(TAG, "mDNS reannouncement unavailable: %s",
|
set_last_error(error);
|
||||||
esp_err_to_name(error));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
runtime->mdns_reannounce_pending = true;
|
||||||
|
if (runtime->online) {
|
||||||
|
runtime->mdns_reannounce_pending = mdns_service_reannounce() != ESP_OK;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MESSAGE_STA_CONNECTED:
|
case MESSAGE_STA_CONNECTED:
|
||||||
@@ -898,18 +1079,17 @@ static void handle_message(manager_runtime_t *runtime,
|
|||||||
!connected_event_matches_active_profile(message)) {
|
!connected_event_matches_active_profile(message)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
runtime->associated = true;
|
if (!runtime->associated) {
|
||||||
runtime->online = false;
|
update_connected_snapshot(message, runtime->ap_enabled);
|
||||||
if (runtime->attempt_deadline == 0) {
|
|
||||||
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
|
|
||||||
}
|
}
|
||||||
update_connected_snapshot(message, runtime->ap_enabled);
|
handle_got_ip(runtime, message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MESSAGE_STA_DISCONNECTED:
|
case MESSAGE_STA_DISCONNECTED:
|
||||||
handle_sta_disconnected(runtime, message);
|
handle_sta_disconnected(runtime, message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MESSAGE_STA_GOT_IP6:
|
||||||
case MESSAGE_STA_GOT_IP:
|
case MESSAGE_STA_GOT_IP:
|
||||||
if (manager_is_started()) {
|
if (manager_is_started()) {
|
||||||
handle_got_ip(runtime, message);
|
handle_got_ip(runtime, message);
|
||||||
@@ -917,20 +1097,8 @@ static void handle_message(manager_runtime_t *runtime,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MESSAGE_STA_LOST_IP:
|
case MESSAGE_STA_LOST_IP:
|
||||||
if (manager_is_started() && runtime->online) {
|
if (manager_is_started()) {
|
||||||
esp_netif_ip_info_t current_ip;
|
handle_got_ip(runtime, message);
|
||||||
memset(¤t_ip, 0, sizeof(current_ip));
|
|
||||||
if (esp_netif_get_ip_info(s_sta_netif, ¤t_ip) == ESP_OK &&
|
|
||||||
current_ip.ip.addr != 0U) {
|
|
||||||
/* Ignore a delayed loss event after a newer DHCP lease. */
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
mdns_service_stop();
|
|
||||||
runtime->online = false;
|
|
||||||
runtime->stable_deadline = 0;
|
|
||||||
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
|
|
||||||
clear_station_network_snapshot();
|
|
||||||
set_state(WIFI_MANAGER_STATE_WAITING_IP);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -984,6 +1152,7 @@ static int64_t next_runtime_deadline(const manager_runtime_t *runtime)
|
|||||||
runtime->restart_deadline,
|
runtime->restart_deadline,
|
||||||
runtime->backoff_deadline,
|
runtime->backoff_deadline,
|
||||||
runtime->stable_deadline,
|
runtime->stable_deadline,
|
||||||
|
runtime->reconcile_deadline,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (size_t i = 0U; i < sizeof(candidates) / sizeof(candidates[0]); ++i) {
|
for (size_t i = 0U; i < sizeof(candidates) / sizeof(candidates[0]); ++i) {
|
||||||
@@ -998,7 +1167,8 @@ static TickType_t runtime_wait_ticks(const manager_runtime_t *runtime)
|
|||||||
{
|
{
|
||||||
int64_t deadline = next_runtime_deadline(runtime);
|
int64_t deadline = next_runtime_deadline(runtime);
|
||||||
if (deadline == 0) {
|
if (deadline == 0) {
|
||||||
return portMAX_DELAY;
|
/* Bootstrap the permanent cadence even if Wi-Fi is never started. */
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int64_t remaining_us = deadline - esp_timer_get_time();
|
int64_t remaining_us = deadline - esp_timer_get_time();
|
||||||
@@ -1015,24 +1185,45 @@ static void handle_expired_deadlines(manager_runtime_t *runtime)
|
|||||||
{
|
{
|
||||||
int64_t now = esp_timer_get_time();
|
int64_t now = esp_timer_get_time();
|
||||||
|
|
||||||
|
bool periodic = now >= runtime->reconcile_deadline;
|
||||||
|
if (periodic) {
|
||||||
|
runtime->reconcile_deadline = now + WIFI_MANAGER_RECONCILE_US;
|
||||||
|
/* Availability notifications have no queue/wakeup and may arrive while
|
||||||
|
* stopped. Reconcile on the sole owner, never gated by STA readiness.
|
||||||
|
* The service retains error status and retries record operations; no
|
||||||
|
* per-pass log spam or responder reinitialization on failure. */
|
||||||
|
if (runtime->online && runtime->mdns_reannounce_pending) {
|
||||||
|
runtime->mdns_reannounce_pending = mdns_service_reannounce() != ESP_OK;
|
||||||
|
} else {
|
||||||
|
(void)mdns_service_reconcile();
|
||||||
|
}
|
||||||
|
/* Retry failed/missed DHCP-name updates, including offline staging.
|
||||||
|
* Configuration is copied before entering TCP/IP context; neither
|
||||||
|
* project mutex nor a caller's stack pointer escapes that call. */
|
||||||
|
esp_err_t error = refresh_station_hostname();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
set_last_error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime->radio_started &&
|
||||||
|
(periodic ||
|
||||||
|
(runtime->attempt_deadline != 0 && now >= runtime->attempt_deadline) ||
|
||||||
|
(runtime->stable_deadline != 0 && now >= runtime->stable_deadline))) {
|
||||||
|
wifi_ap_record_t current_ap;
|
||||||
|
if (runtime->associated && esp_wifi_sta_get_ap_info(¤t_ap) != ESP_OK) {
|
||||||
|
manager_message_t lost = { .type = MESSAGE_STA_DISCONNECTED };
|
||||||
|
handle_sta_disconnected(runtime, &lost);
|
||||||
|
} else if (manager_is_started()) {
|
||||||
|
handle_got_ip(runtime, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime->attempt_deadline != 0 && now >= runtime->attempt_deadline) {
|
if (runtime->attempt_deadline != 0 && now >= runtime->attempt_deadline) {
|
||||||
esp_netif_ip_info_t current_ip;
|
/* Both families must be reconciled before deciding to abandon a profile. */
|
||||||
memset(¤t_ip, 0, sizeof(current_ip));
|
handle_got_ip(runtime, NULL);
|
||||||
if (esp_netif_get_ip_info(s_sta_netif, ¤t_ip) == ESP_OK &&
|
if (runtime->online) {
|
||||||
current_ip.ip.addr != 0U) {
|
return;
|
||||||
/* Recover if the bounded manager queue dropped GOT_IP. */
|
|
||||||
manager_message_t synthetic = {
|
|
||||||
.type = MESSAGE_STA_GOT_IP,
|
|
||||||
.data.got_ip = {
|
|
||||||
.ip = current_ip.ip.addr,
|
|
||||||
.netmask = current_ip.netmask.addr,
|
|
||||||
.gateway = current_ip.gw.addr,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
handle_got_ip(runtime, &synthetic);
|
|
||||||
if (runtime->online) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime->attempt_deadline = 0;
|
runtime->attempt_deadline = 0;
|
||||||
@@ -1195,13 +1386,19 @@ static void ip_event_callback(void *argument, esp_event_base_t event_base,
|
|||||||
|
|
||||||
if (event_id == IP_EVENT_STA_GOT_IP) {
|
if (event_id == IP_EVENT_STA_GOT_IP) {
|
||||||
const ip_event_got_ip_t *event = event_data;
|
const ip_event_got_ip_t *event = event_data;
|
||||||
if (event == NULL) {
|
if (event == NULL || event->esp_netif != s_sta_netif) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
message.type = MESSAGE_STA_GOT_IP;
|
message.type = MESSAGE_STA_GOT_IP;
|
||||||
message.data.got_ip.ip = event->ip_info.ip.addr;
|
message.data.got_ip.ip = event->ip_info.ip.addr;
|
||||||
message.data.got_ip.netmask = event->ip_info.netmask.addr;
|
message.data.got_ip.netmask = event->ip_info.netmask.addr;
|
||||||
message.data.got_ip.gateway = event->ip_info.gw.addr;
|
message.data.got_ip.gateway = event->ip_info.gw.addr;
|
||||||
|
} else if (event_id == IP_EVENT_GOT_IP6) {
|
||||||
|
const ip_event_got_ip6_t *event = event_data;
|
||||||
|
if (event == NULL || event->esp_netif != s_sta_netif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.type = MESSAGE_STA_GOT_IP6;
|
||||||
} else if (event_id == IP_EVENT_STA_LOST_IP) {
|
} else if (event_id == IP_EVENT_STA_LOST_IP) {
|
||||||
message.type = MESSAGE_STA_LOST_IP;
|
message.type = MESSAGE_STA_LOST_IP;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+19
-1
@@ -42,6 +42,14 @@ typedef struct {
|
|||||||
uint64_t queue_drops;
|
uint64_t queue_drops;
|
||||||
} wifi_manager_counters_t;
|
} wifi_manager_counters_t;
|
||||||
|
|
||||||
|
#define WIFI_MANAGER_IPV6_MAX_ADDRESSES 3U
|
||||||
|
|
||||||
|
/* Network-order words, compatible with IDF's IPV62STR/ESP_IP6_ADDR_BLOCK macros.
|
||||||
|
* No device-local zone: a remote client must select its own interface. */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t addr[4];
|
||||||
|
} wifi_manager_ipv6_address_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
bool initialized;
|
bool initialized;
|
||||||
bool started;
|
bool started;
|
||||||
@@ -54,6 +62,13 @@ typedef struct {
|
|||||||
uint32_t ip;
|
uint32_t ip;
|
||||||
uint32_t netmask;
|
uint32_t netmask;
|
||||||
uint32_t gateway;
|
uint32_t gateway;
|
||||||
|
/* Preferred IPv6 addresses only (DAD complete, not deprecated/expired).
|
||||||
|
* ONLINE means IPv4 or either IPv6 flag, not Internet reachability.
|
||||||
|
* routable includes ULA/GUA; it does not assert a default route exists. */
|
||||||
|
bool ipv6_linklocal;
|
||||||
|
bool ipv6_routable;
|
||||||
|
uint8_t ipv6_count;
|
||||||
|
wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];
|
||||||
uint8_t sta_channel;
|
uint8_t sta_channel;
|
||||||
int8_t sta_rssi;
|
int8_t sta_rssi;
|
||||||
wifi_auth_mode_t sta_auth;
|
wifi_auth_mode_t sta_auth;
|
||||||
@@ -141,7 +156,10 @@ esp_err_t wifi_manager_stop(void);
|
|||||||
esp_err_t wifi_manager_reconnect(void);
|
esp_err_t wifi_manager_reconnect(void);
|
||||||
/* Advance to the next enabled station profile in priority order, wrapping safely. */
|
/* Advance to the next enabled station profile in priority order, wrapping safely. */
|
||||||
esp_err_t wifi_manager_next_profile(void);
|
esp_err_t wifi_manager_next_profile(void);
|
||||||
/* Reannounce the configured hostname when the manager currently has a STA IP. */
|
/* Refresh DHCPv4 option 12 from the configured sak-<suffix> hostname, and
|
||||||
|
* reannounce mDNS when ONLINE (either IP family). No lease restart: the new
|
||||||
|
* name is used in subsequent DHCP exchanges, including renew/rebind; existing
|
||||||
|
* router/DNS records may remain until server policy expires or replaces them. */
|
||||||
esp_err_t wifi_manager_mdns_reannounce(void);
|
esp_err_t wifi_manager_mdns_reannounce(void);
|
||||||
|
|
||||||
/* Snapshot data never contains station or AP passwords. */
|
/* Snapshot data never contains station or AP passwords. */
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ static esp_err_t web_server_stop(void) { ++stops; return stop_result; }
|
|||||||
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||||
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
|
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
|
||||||
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
|
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
|
||||||
|
static bool reboot_busy;
|
||||||
|
static esp_err_t web_firmware_update_reserve_reboot(void) { return reboot_busy ? ESP_FAIL : ESP_OK; }
|
||||||
static void esp_restart(void) { ++reboots; }
|
static void esp_restart(void) { ++reboots; }
|
||||||
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
|
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
|
||||||
#define pdMS_TO_TICKS(ms) (ms)
|
#define pdMS_TO_TICKS(ms) (ms)
|
||||||
@@ -95,6 +97,9 @@ int main(void) {
|
|||||||
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
|
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
|
||||||
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
|
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
|
||||||
remote=false;
|
remote=false;
|
||||||
|
reboot_busy=true;
|
||||||
|
assert(command_reboot(1,NULL)==1 && !reboots && !waits && scheduled==5);
|
||||||
|
reboot_busy=false;
|
||||||
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
|
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
|
||||||
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
|
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
|
||||||
remote=web=true; schedule_result=ESP_OK;
|
remote=web=true; schedule_result=ESP_OK;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
|
||||||
|
static admin_ssh_console_token_t token = {
|
||||||
|
.slot_index=0, .session_id=7, .slot_generation=1,
|
||||||
|
.transport=ADMIN_CONSOLE_TRANSPORT_WEB,
|
||||||
|
};
|
||||||
|
static user_principal_t admin = {.role=USER_ROLE_ADMIN};
|
||||||
|
static bool live=true;
|
||||||
|
static unsigned scenario;
|
||||||
|
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
|
||||||
|
{ assert(!lock_depth && t->transport==ADMIN_CONSOLE_TRANSPORT_WEB && p->role==USER_ROLE_ADMIN); return live; }
|
||||||
|
static bool drained(const admin_ssh_console_token_t *t) { (void)t; return true; }
|
||||||
|
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||||
|
admin_ssh_deferred_action_type_t action, uint32_t arg)
|
||||||
|
{ (void)t; (void)action; (void)arg; assert(false); return ESP_FAIL; }
|
||||||
|
static const admin_console_owner_t owner = {
|
||||||
|
.is_current=is_current, .drained=drained, .perform=perform,
|
||||||
|
};
|
||||||
|
static void reply(void)
|
||||||
|
{
|
||||||
|
const char *text=scenario==1 ? "wifi-secret\x03" : scenario==5 ? "short\r" : "wifi-secret\t\r";
|
||||||
|
size_t used=0;
|
||||||
|
assert(s_sessions[0].prompt_hidden);
|
||||||
|
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)text,strlen(text),&used));
|
||||||
|
assert(used==strlen(text));
|
||||||
|
if (scenario==2) live=false;
|
||||||
|
if (scenario==3) principal_current=false;
|
||||||
|
if (scenario==4) admin_ssh_console_close(&token);
|
||||||
|
}
|
||||||
|
static void run_secret(void)
|
||||||
|
{
|
||||||
|
assert(admin_ssh_console_dispatch_is_web());
|
||||||
|
uint8_t greeting[4096]; size_t greeting_length;
|
||||||
|
assert(admin_ssh_console_read_output(&token,greeting,sizeof(greeting),&greeting_length)==ESP_OK);
|
||||||
|
unsigned before=applies, wiped=wipes;
|
||||||
|
uint8_t history[sizeof(s_sessions[0].history)];
|
||||||
|
memcpy(history,s_sessions[0].history,sizeof(history));
|
||||||
|
int result=(scenario%2 || scenario==6) ? set_ap_secret() : set_profile_secret("0");
|
||||||
|
bool success=scenario==0 || scenario==6;
|
||||||
|
assert(result==(success ? 0 : 1));
|
||||||
|
assert(applies==before+success);
|
||||||
|
assert(wipes>=wiped+2); /* Hidden local input and PSK-bearing candidate. */
|
||||||
|
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i)
|
||||||
|
assert(!s_sessions[0].prompt_input[i]);
|
||||||
|
if (scenario<2 || scenario>=5)
|
||||||
|
assert(!memcmp(history,s_sessions[0].history,sizeof(history)));
|
||||||
|
uint8_t output[4097]={0}; size_t n=0;
|
||||||
|
if (s_sessions[0].active) {
|
||||||
|
assert(admin_ssh_console_read_output(&token,output,sizeof(output)-1,&n)==ESP_OK);
|
||||||
|
assert(!strstr((char *)output,"wifi-secret"));
|
||||||
|
assert(!strstr((char *)output,"short"));
|
||||||
|
assert(!strstr((char *)output,"help")); /* Tab never invokes completion in a prompt. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
assert(admin_ssh_console_init()==ESP_OK);
|
||||||
|
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||||
|
user_principal_t ordinary={.role=USER_ROLE_USER};
|
||||||
|
assert(admin_ssh_console_open_owned(&token,&ordinary,&owner)!=ESP_OK);
|
||||||
|
working.profiles[0].ssid_len=1;
|
||||||
|
working.profiles[0].ssid[0]='x';
|
||||||
|
for (scenario=0;scenario<7;++scenario) {
|
||||||
|
live=true; principal_current=true; ++token.slot_generation;
|
||||||
|
assert(admin_ssh_console_open_owned(&token,&admin,&owner)==ESP_OK);
|
||||||
|
const char *line=(scenario%2 || scenario==6) ? "wifi ap secret\r" : "wifi profile secret 0\r";
|
||||||
|
size_t used=0;
|
||||||
|
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)line,strlen(line),&used));
|
||||||
|
unsigned before=runs;
|
||||||
|
prompt_hook=reply; command_hook=run_secret;
|
||||||
|
if (!setjmp(loop_done)) worker_task(NULL);
|
||||||
|
assert(runs==before+1);
|
||||||
|
admin_ssh_console_close(&token);
|
||||||
|
admin_session_t empty={0};
|
||||||
|
assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
|
||||||
|
}
|
||||||
|
assert(applies==2 && working.profiles[0].psk_len==11);
|
||||||
|
assert(working.ap_psk_len==11 && !memcmp(working.ap_psk,"wifi-secret",11));
|
||||||
|
assert(!memcmp(working.profiles[0].psk,"wifi-secret",11));
|
||||||
|
puts("PASS: browser Wi-Fi policy-to-dispatch, actual profile/AP secret handlers and shared IO; hidden input/history/Tab, cancel, owner/account revocation, close, short input and wiping; ordinary role denied");
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Actual Wi-Fi secret handlers through shared browser prompt IO; host fakes only."""
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||||
|
|
||||||
|
def stripped(path):
|
||||||
|
return "\n".join(line for line in (ROOT / path).read_text().splitlines()
|
||||||
|
if not line.startswith(("#include", "#pragma once")))
|
||||||
|
|
||||||
|
def function(source, signature):
|
||||||
|
start = source.index(signature)
|
||||||
|
return source[start:source.index("\n}", start) + 2] + "\n"
|
||||||
|
|
||||||
|
wifi = (ROOT / "src/wifi_console.c").read_text()
|
||||||
|
unit = (ROOT / "tests/admin_console_boundary/fakes.h").read_text() + "\n"
|
||||||
|
unit += stripped("src/admin_ssh_console.h") + "\n" + stripped("src/admin_ssh_console.c") + "\n"
|
||||||
|
unit += stripped("src/wifi_config.h") + r'''
|
||||||
|
#include <stdlib.h>
|
||||||
|
#define ESP_ERR_INVALID_SIZE 100
|
||||||
|
#define UART_NUM_0 0
|
||||||
|
#define WIFI_CONSOLE_SECRET_CAPACITY WIFI_CONFIG_PSK_MAX_LEN
|
||||||
|
static esp_err_t uart_flush_input(int port) { (void)port; assert(false); return ESP_FAIL; }
|
||||||
|
static int uart_read_bytes(int port, void *out, size_t n, unsigned wait)
|
||||||
|
{ (void)port; (void)out; (void)n; (void)wait; assert(false); return -1; }
|
||||||
|
static wifi_app_config_t working;
|
||||||
|
static unsigned applies, wipes;
|
||||||
|
void wifi_config_secure_wipe(void *p, size_t n) {
|
||||||
|
secure_wipe(p,n); ++wipes;
|
||||||
|
for (size_t i=0;i<n;++i) assert(!((uint8_t *)p)[i]);
|
||||||
|
}
|
||||||
|
static esp_err_t wifi_manager_get_working_config(wifi_app_config_t *out)
|
||||||
|
{ *out=working; return ESP_OK; }
|
||||||
|
static esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *in)
|
||||||
|
{ working=*in; ++applies; return ESP_OK; }
|
||||||
|
'''
|
||||||
|
unit += stripped("src/console_input.c") + "\n"
|
||||||
|
for signature in ("static bool parse_u32(", "static bool parse_slot(",
|
||||||
|
"static esp_err_t apply_candidate(", "static esp_err_t read_secret_no_echo(",
|
||||||
|
"static int set_profile_secret(", "static int set_ap_secret("):
|
||||||
|
unit += function(wifi, signature)
|
||||||
|
unit += (ROOT / "tests/admin_console_boundary/wifi.c").read_text()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="browser-wifi-prompts-") as directory:
|
||||||
|
path = Path(directory)
|
||||||
|
(path / "test.c").write_text(unit)
|
||||||
|
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable", str(path / "test.c"),
|
||||||
|
str(IDF / "components/console/split_argv.c"), "-o", str(path / "test")],
|
||||||
|
check=True, timeout=30)
|
||||||
|
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||||
@@ -66,7 +66,19 @@ int main(void) {
|
|||||||
"", " ", "help", "memory", "exit", "user", "user status", "user list",
|
"", " ", "help", "memory", "exit", "user", "user status", "user list",
|
||||||
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
||||||
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
|
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
|
||||||
"wifi status", "mdns status", "\"web\" \"status\"",
|
"mdns status", "\"web\" \"status\"",
|
||||||
|
"wifi", "wifi status", "wifi profiles", "wifi counters", "wifi clear-counters",
|
||||||
|
"wifi start", "wifi stop", "wifi reconnect", "wifi next-profile",
|
||||||
|
"wifi save", "wifi load", "wifi defaults", "wifi reset",
|
||||||
|
"wifi profile set 0 10 mixed \"Office Wi-Fi\"", "wifi profile secret 0",
|
||||||
|
"wifi profile enable 0", "wifi profile disable 0", "wifi profile delete 0",
|
||||||
|
"wifi ap policy fallback", "wifi ap ssid \"Recovery AP\"", "wifi ap channel 6",
|
||||||
|
"wifi ap secret", "wifi ap show-secret",
|
||||||
|
"wifi ping example.org", "wifi nslookup example.org", "wifi traceroute example.org",
|
||||||
|
" \"wifi\" ", " \"wifi\" \"stop\" ",
|
||||||
|
"\"wifi\" \"profile\" \"secret\" \"0\"", "\"wifi\" \"ap\" \"show-secret\"",
|
||||||
|
/* Admission is not syntax validation: canonical Wi-Fi rejects these. */
|
||||||
|
"wifi scan", "wifi status extra", "wifi profile secret 0 inline-secret", "wifix stop",
|
||||||
"user add other user", "user add other admin", "user password other",
|
"user add other user", "user add other admin", "user password other",
|
||||||
"user delete other --force", "user role other user --force",
|
"user delete other --force", "user role other user --force",
|
||||||
"user role other admin --force", "\"user\" \"password\" \"other\"",
|
"user role other admin --force", "\"user\" \"password\" \"other\"",
|
||||||
@@ -84,8 +96,7 @@ int main(void) {
|
|||||||
"web certificate rotate --forcex", "web certificate --force rotate",
|
"web certificate rotate --forcex", "web certificate --force rotate",
|
||||||
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
|
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
|
||||||
"web reset --force", "web status extra",
|
"web reset --force", "web status extra",
|
||||||
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
|
|
||||||
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
|
|
||||||
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
||||||
"reboot --force", "user bootstrap", "user recover --force",
|
"reboot --force", "user bootstrap", "user recover --force",
|
||||||
"user add other admin --generate", "user delete other",
|
"user add other admin --generate", "user delete other",
|
||||||
@@ -100,7 +111,7 @@ int main(void) {
|
|||||||
"user key delete admin 0 --force", "user list extra", "user show admin extra",
|
"user key delete admin 0 --force", "user list extra", "user show admin extra",
|
||||||
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
||||||
" \"user\" \"password\" \"admin\" \"--generate\"",
|
" \"user\" \"password\" \"admin\" \"--generate\"",
|
||||||
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
|
"\"web\" \"credentials\" \"show\"", "mdns status extra",
|
||||||
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
|
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
|
||||||
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
|
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# mDNS 1.12.0 multicast membership regression
|
||||||
|
|
||||||
|
This is a narrow dependency correctness fix for the lwIP backend, not a general
|
||||||
|
patch framework or a revival of the abandoned Phase 9 patches. Dependency
|
||||||
|
versions, the component manifest/lock, managed sources, socket backend, and
|
||||||
|
application mDNS lifecycle policy are unchanged.
|
||||||
|
|
||||||
|
## Defects and fix
|
||||||
|
|
||||||
|
In the inspected `mdns_networking_lwip.c`, upstream `pcb_if_deinit()` (original
|
||||||
|
lines 267–277) only leaves a group when the interface's **last** protocol bit is
|
||||||
|
cleared. Removing one family while the other remains active therefore leaks a
|
||||||
|
membership reference. Restoration joins again; repeated partial transitions
|
||||||
|
accumulate references in lwIP's bounded group-use counter. Deinitializing an
|
||||||
|
already-disabled family can also issue an unmatched leave.
|
||||||
|
|
||||||
|
The generated copy checks the requested family's active bit, returns immediately
|
||||||
|
if absent, and leaves that family's group before clearing its bit. It clears
|
||||||
|
`ready` only after the last family on that interface is removed, and frees the
|
||||||
|
shared PCB only if no other interface remains ready.
|
||||||
|
|
||||||
|
In `pcb_if_init()` (original lines 282–301), a successful group join followed by
|
||||||
|
`pcb_init()` failure was not unwound. The copy attempts a leave before returning
|
||||||
|
the **original** PCB error. No readiness or protocol bit is published on failure.
|
||||||
|
|
||||||
|
These operations still run through upstream's existing lwIP-thread wrappers.
|
||||||
|
No new locks, allocations, task ownership, retry state, or public API are added.
|
||||||
|
Upstream `join_group()` is unchanged, including its refusal to act when the netif
|
||||||
|
is absent/down. Leaves remain best effort: the patch guarantees balanced leave
|
||||||
|
**attempts**, not successful lwIP cleanup after an interface disappears. It does
|
||||||
|
not retry a failed leave or retain a PCB solely because leave failed; physical
|
||||||
|
netif teardown remains responsible for its own membership cleanup.
|
||||||
|
|
||||||
|
## Build integration
|
||||||
|
|
||||||
|
1. Root `CMakeLists.txt` includes `cmake/mdns_membership.cmake` **after** IDF's
|
||||||
|
`project()`, when component targets and resolved versions are available.
|
||||||
|
2. The include obtains `espressif__mdns`'s `COMPONENT_DIR`, `COMPONENT_LIB`, and
|
||||||
|
`COMPONENT_VERSION` via IDF component properties. Socket-backend builds skip
|
||||||
|
the overlay entirely; the fix is only relevant to the lwIP source.
|
||||||
|
3. The resolved component version and manifest version must both be exactly
|
||||||
|
`1.12.0`. Using IDF's configured Python interpreter, the helper verifies the
|
||||||
|
complete source SHA-256, then performs two exact, unique replacements.
|
||||||
|
4. It writes only `${CMAKE_BINARY_DIR}/mdns_membership/mdns_networking_lwip.c`.
|
||||||
|
Unchanged output is not rewritten, avoiding gratuitous rebuilds. Everything
|
||||||
|
outside the two replacement regions, including provenance/license headers,
|
||||||
|
remains byte-for-byte intact. The actual upstream file identifies itself as
|
||||||
|
Apache-2.0, copyright 2022–2025 Espressif; it is not relabeled as GPL. Existing
|
||||||
|
project GPL/license material is untouched.
|
||||||
|
5. The include replaces exactly one matching entry in the existing component
|
||||||
|
target's `SOURCES` property, accepting absolute or component-relative paths.
|
||||||
|
All other source entries and all component compile definitions, include paths,
|
||||||
|
dependencies, and target linkage are retained. The original file is not also
|
||||||
|
compiled. An unexpected source list fails configuration.
|
||||||
|
6. The helper, original source, manifest, and generated copy are registered as
|
||||||
|
configure dependencies. Reconfiguration regenerates/verifies the copy; a
|
||||||
|
clean build simply recreates it. The CMake include itself is automatically a
|
||||||
|
CMake input. Version/hash/replacement errors fail configure rather than
|
||||||
|
silently compiling an unpatched dependency.
|
||||||
|
|
||||||
|
The integration uses the component's existing target; no whole-component copy,
|
||||||
|
managed in-place edits, dependency overrides, manifest changes, or extra source
|
||||||
|
library are involved. The inspected upstream CMake file offers no per-source
|
||||||
|
substitution option; replacing the target source after `project()` is the local
|
||||||
|
integration point.
|
||||||
|
|
||||||
|
### Exact reviewed baseline
|
||||||
|
|
||||||
|
- Component: `espressif/mdns`, version `1.12.0`.
|
||||||
|
- Manifest repository: `espressif/esp-protocols`, `components/mdns`.
|
||||||
|
- Manifest commit: `db06b19b7be729c163d346f62ec0eba01047b7f1` (provenance;
|
||||||
|
guards are the resolved/manifest version and complete source hash).
|
||||||
|
- Source: `managed_components/espressif__mdns/mdns_networking_lwip.c`.
|
||||||
|
- SHA-256: `adc139fa504a925ab644f21f8dce3659927f534e390a176b72b0ae3206c6a3ea`.
|
||||||
|
|
||||||
|
## Run the host tests
|
||||||
|
|
||||||
|
From the repository root, on a POSIX host with Python 3, CMake >= 3.16, and a C11
|
||||||
|
compiler:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tests/mdns_membership/run.py
|
||||||
|
# Optional compiler selection (CC is an executable, not a shell command):
|
||||||
|
CC=clang ./tests/mdns_membership/run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The managed component must already be installed. The runner uses temporary
|
||||||
|
directories, does not fetch dependencies, does not build firmware, and disables
|
||||||
|
core dumps for deliberate negative tests. It generates the overlay with the
|
||||||
|
**same helper used by configure**, extracts the actual patched state declarations
|
||||||
|
and six functions (`pcb_init`, `pcb_deinit`, `mdns_priv_if_ready`,
|
||||||
|
`is_any_pcb_in_use`, `pcb_if_init`, `pcb_if_deinit`), and compiles them with
|
||||||
|
`-std=c11 -Wall -Wextra -Werror -pedantic`. Only group operations and low-level
|
||||||
|
UDP APIs are mocked; the lifecycle logic under test is not reimplemented.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
|
||||||
|
- 512 IPv4 loss/restoration cycles while IPv6 stays active, and 512 inverse
|
||||||
|
cycles; exact group references and readiness checked after each transition.
|
||||||
|
- Repeated disabled-family deinit, final-family teardown, and duplicate init.
|
||||||
|
- Join failure without an unmatched leave or PCB allocation.
|
||||||
|
- 512 allocation failures and 512 bind failures per family, with successful
|
||||||
|
joins unwound, no state publication, original error preserved, and recovery.
|
||||||
|
- Leave failure on teardown and unwind: one attempt, no repeated disabled leave,
|
||||||
|
original allocation error retained.
|
||||||
|
- 512 cycles per family with a second interface holding the shared PCB; exactly
|
||||||
|
one allocation, no premature free, and one final removal.
|
||||||
|
- Separate negative controls restoring each original buggy function must fail
|
||||||
|
the very same executable harness.
|
||||||
|
- Mock-IDF CMake configure fixtures for absolute/relative source replacement,
|
||||||
|
unchanged unrelated source, repeat configure without rewriting, socket bypass,
|
||||||
|
missing/duplicate networking source rejection, resolved-version rejection,
|
||||||
|
and full-source hash rejection. Manifest-version rejection is tested through
|
||||||
|
the helper CLI. Rejected fresh helper runs must not produce output.
|
||||||
|
- Original managed networking bytes are checked unchanged after the run.
|
||||||
|
|
||||||
|
These are host unit/configure tests, **not** a real ESP-IDF integration build or
|
||||||
|
hardware/network test. The real `join_group()`, lwIP IGMP/MLD counters, scheduling,
|
||||||
|
and network teardown are not exercised by mocks. Run the normal `pio run`
|
||||||
|
separately (never concurrently with another full build) to validate actual IDF
|
||||||
|
integration. In its generated `compile_commands.json`, the mDNS networking entry
|
||||||
|
must point to the build-local `mdns_membership/mdns_networking_lwip.c`, with no
|
||||||
|
original managed networking entry; other mDNS sources must remain managed paths.
|
||||||
|
On device, exercise repeated IPv4-only and IPv6-only loss/restoration while the
|
||||||
|
other family remains active, final network teardown/recovery, and continued
|
||||||
|
mDNS discovery. No hardware result is implied by the host PASS output.
|
||||||
|
|
||||||
|
## Maintenance / removal
|
||||||
|
|
||||||
|
Do **not** update the expected hash merely to make a new dependency build. On
|
||||||
|
any mismatch, review the entire changed networking source and component CMake,
|
||||||
|
especially protocol state, join/leave behavior, netif-down semantics, and shared
|
||||||
|
PCB lifetime. Establish whether upstream has fixed both defects first. A
|
||||||
|
version bump is a separate explicitly reviewed change; this overlay does not
|
||||||
|
select or upgrade the dependency.
|
||||||
|
|
||||||
|
If upstream fixes both paths, remove the root include and these narrowly scoped
|
||||||
|
helper/test files (or replace the tests with suitable upstream coverage), then
|
||||||
|
clean/reconfigure and check that only the upstream source compiles. If a local
|
||||||
|
fix is still necessary, re-audit the exact new source/version, update the guards
|
||||||
|
and exact replacements together, update this provenance record, rerun the host
|
||||||
|
suite with its negative controls, and run a serial full firmware build. Never
|
||||||
|
fall back to unguarded search/replace or silently skip a failed patch. A stale
|
||||||
|
build-local copy is not authority: it must always be reproducible from the
|
||||||
|
managed source plus the reviewed helper.
|
||||||
Executable
+157
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host-only regression and configure integration tests; no firmware build."""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import resource
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
COMPONENT = ROOT / "managed_components/espressif__mdns"
|
||||||
|
HELPER = ROOT / "cmake/mdns_membership.py"
|
||||||
|
|
||||||
|
|
||||||
|
def run(command, *, succeeds=True):
|
||||||
|
result = subprocess.run([str(item) for item in command], capture_output=True, text=True)
|
||||||
|
if (result.returncode == 0) != succeeds:
|
||||||
|
raise AssertionError(f"Unexpected result: {command}\n{result.stdout}\n{result.stderr}")
|
||||||
|
return result.stdout + result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def function(source, signature):
|
||||||
|
start = source.index(signature)
|
||||||
|
brace = source.index("{", start)
|
||||||
|
depth = 1
|
||||||
|
end = brace + 1
|
||||||
|
while depth:
|
||||||
|
depth += (source[end] == "{") - (source[end] == "}")
|
||||||
|
end += 1
|
||||||
|
return source[start:end] + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def extract(source):
|
||||||
|
# Keep the original provenance header and actual state declarations too.
|
||||||
|
header = source[:source.index("#include")]
|
||||||
|
state = source[source.index("enum interface_protocol"):source.index("static const char *TAG")]
|
||||||
|
signatures = ["static esp_err_t pcb_init(void)", "static void pcb_deinit(void)",
|
||||||
|
"bool mdns_priv_if_ready(", "static bool is_any_pcb_in_use(void)",
|
||||||
|
"static void pcb_if_deinit(", "static esp_err_t pcb_if_init("]
|
||||||
|
return header + state + "\n".join(function(source, name) for name in signatures)
|
||||||
|
|
||||||
|
|
||||||
|
def cmake_fixture(work, component, mode, succeeds=True):
|
||||||
|
fixture = work / f"cmake-{mode}"
|
||||||
|
fixture.mkdir()
|
||||||
|
(fixture / "dummy.c").write_text("int dummy;\n")
|
||||||
|
source = component / "mdns_networking_lwip.c"
|
||||||
|
sources = f'"{source}"'
|
||||||
|
if mode == "missing":
|
||||||
|
sources = ""
|
||||||
|
if mode == "duplicate":
|
||||||
|
sources += " " + sources
|
||||||
|
# Test relative as well as absolute target source properties.
|
||||||
|
if mode == "relative":
|
||||||
|
shutil.copyfile(source, fixture / source.name)
|
||||||
|
shutil.copyfile(component / "idf_component.yml", fixture / "idf_component.yml")
|
||||||
|
component = fixture
|
||||||
|
sources = source.name
|
||||||
|
version = "1.13.0" if mode == "version" else "1.12.0"
|
||||||
|
(fixture / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(mdns_overlay_fixture C)
|
||||||
|
add_library(mdns STATIC dummy.c {sources})
|
||||||
|
function(idf_component_get_property output component property)
|
||||||
|
if(property STREQUAL "COMPONENT_DIR")
|
||||||
|
set(value "{component}")
|
||||||
|
elseif(property STREQUAL "COMPONENT_LIB")
|
||||||
|
set(value mdns)
|
||||||
|
elseif(property STREQUAL "COMPONENT_VERSION")
|
||||||
|
set(value "{version}")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Unexpected component property")
|
||||||
|
endif()
|
||||||
|
set(${{output}} "${{value}}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
function(idf_build_get_property output property)
|
||||||
|
if(NOT property STREQUAL "PYTHON")
|
||||||
|
message(FATAL_ERROR "Unexpected build property")
|
||||||
|
endif()
|
||||||
|
set(${{output}} "{sys.executable}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
set(CONFIG_MDNS_NETWORKING_SOCKET {"ON" if mode == "socket" else "OFF"})
|
||||||
|
include("{ROOT / 'cmake/mdns_membership.cmake'}")
|
||||||
|
get_target_property(sources mdns SOURCES)
|
||||||
|
file(WRITE "${{CMAKE_BINARY_DIR}}/selected.txt" "${{sources}}")
|
||||||
|
''')
|
||||||
|
build = fixture / "build"
|
||||||
|
output = run(["cmake", "-S", fixture, "-B", build], succeeds=succeeds)
|
||||||
|
if not succeeds:
|
||||||
|
assert "mDNS" in output, output
|
||||||
|
return
|
||||||
|
selected = (build / "selected.txt").read_text().split(";")
|
||||||
|
if mode == "socket":
|
||||||
|
assert str(source) in selected
|
||||||
|
assert not (build / "mdns_membership").exists()
|
||||||
|
else:
|
||||||
|
overlay = build / "mdns_membership/mdns_networking_lwip.c"
|
||||||
|
assert selected == ["dummy.c", str(overlay)], selected
|
||||||
|
before = overlay.stat().st_mtime_ns
|
||||||
|
run(["cmake", "-S", fixture, "-B", build])
|
||||||
|
assert overlay.stat().st_mtime_ns == before
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||||
|
original = (COMPONENT / "mdns_networking_lwip.c").read_bytes()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mdns-membership-") as directory:
|
||||||
|
work = Path(directory)
|
||||||
|
overlay = work / "overlay/mdns_networking_lwip.c"
|
||||||
|
run([sys.executable, HELPER, COMPONENT, overlay])
|
||||||
|
patched = overlay.read_text()
|
||||||
|
assert patched[:patched.index("#include")] == original.decode()[:original.decode().index("#include")]
|
||||||
|
timestamp = overlay.stat().st_mtime_ns
|
||||||
|
run([sys.executable, HELPER, COMPONENT, overlay])
|
||||||
|
assert overlay.stat().st_mtime_ns == timestamp
|
||||||
|
(work / "actual_functions.inc").write_text(extract(patched))
|
||||||
|
executable = work / "test"
|
||||||
|
command = [os.environ.get("CC", "cc"), "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||||
|
"-pedantic", "-I", work, HERE / "test.c", "-o", executable]
|
||||||
|
run(command)
|
||||||
|
print(run([executable]), end="")
|
||||||
|
|
||||||
|
# Prove the harness detects each original bug independently.
|
||||||
|
for name in ("pcb_if_deinit", "pcb_if_init"):
|
||||||
|
signature = ("static void " if name.endswith("deinit") else "static esp_err_t ") + name + "("
|
||||||
|
mutated = patched.replace(function(patched, signature), function(original.decode(), signature))
|
||||||
|
(work / "actual_functions.inc").write_text(extract(mutated))
|
||||||
|
run(command)
|
||||||
|
run([executable], succeeds=False)
|
||||||
|
print("PASS: both original defects independently fail the same harness")
|
||||||
|
|
||||||
|
copied = work / "component"
|
||||||
|
copied.mkdir()
|
||||||
|
shutil.copyfile(COMPONENT / "idf_component.yml", copied / "idf_component.yml")
|
||||||
|
(copied / "mdns_networking_lwip.c").write_bytes(original + b"\n")
|
||||||
|
output = run([sys.executable, HELPER, copied, work / "rejected.c"], succeeds=False)
|
||||||
|
assert "SHA-256 mismatch" in output and not (work / "rejected.c").exists()
|
||||||
|
(copied / "mdns_networking_lwip.c").write_bytes(original)
|
||||||
|
manifest = (copied / "idf_component.yml").read_text()
|
||||||
|
(copied / "idf_component.yml").write_text(manifest.replace("version: 1.12.0", "version: 1.13.0"))
|
||||||
|
output = run([sys.executable, HELPER, copied, work / "rejected.c"], succeeds=False)
|
||||||
|
assert "exactly version 1.12.0" in output and not (work / "rejected.c").exists()
|
||||||
|
(copied / "idf_component.yml").write_text(manifest)
|
||||||
|
for mode in ("absolute", "relative", "socket", "missing", "duplicate", "version"):
|
||||||
|
cmake_fixture(work, copied, mode, succeeds=mode not in ("missing", "duplicate", "version"))
|
||||||
|
# Also exercise hash failure through configure, not only the helper CLI.
|
||||||
|
(copied / "mdns_networking_lwip.c").write_bytes(original + b"\n")
|
||||||
|
cmake_fixture(work, copied, "hash", succeeds=False)
|
||||||
|
print("PASS: source/version guards, CMake replacement, relative paths, socket bypass, repeat configure")
|
||||||
|
assert hashlib.sha256((COMPONENT / "mdns_networking_lwip.c").read_bytes()).digest() == hashlib.sha256(original).digest()
|
||||||
|
print("PASS: managed networking source unchanged")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
typedef int esp_err_t;
|
||||||
|
typedef int mdns_if_t;
|
||||||
|
typedef int mdns_ip_protocol_t;
|
||||||
|
enum { ESP_OK, ESP_ERR_NO_MEM, ESP_ERR_INVALID_STATE };
|
||||||
|
enum { MDNS_IP_PROTOCOL_V4, MDNS_IP_PROTOCOL_V6, MDNS_IP_PROTOCOL_MAX };
|
||||||
|
#define MDNS_MAX_INTERFACES 2
|
||||||
|
#define MDNS_SERVICE_PORT 5353
|
||||||
|
struct udp_pcb { int mcast_ttl, remote_port, remote_ip; };
|
||||||
|
static int any_address;
|
||||||
|
#define IP_ANY_TYPE (&any_address)
|
||||||
|
#define ip_addr_copy(to, from) ((to) = (from))
|
||||||
|
static void receive(void) {}
|
||||||
|
static struct udp_pcb storage;
|
||||||
|
static bool allocated, fail_alloc, fail_bind, fail_join, fail_leave;
|
||||||
|
static int allocations, removals, joins[2][2], leaves[2][2], references[2][2];
|
||||||
|
static struct udp_pcb *udp_new(void)
|
||||||
|
{
|
||||||
|
if (fail_alloc) return NULL;
|
||||||
|
assert(!allocated);
|
||||||
|
allocated = true;
|
||||||
|
allocations++;
|
||||||
|
return &storage;
|
||||||
|
}
|
||||||
|
static int udp_bind(struct udp_pcb *pcb, const int *address, int port)
|
||||||
|
{
|
||||||
|
assert(pcb == &storage && address == IP_ANY_TYPE && port == 5353);
|
||||||
|
return fail_bind;
|
||||||
|
}
|
||||||
|
static void udp_remove(struct udp_pcb *pcb)
|
||||||
|
{
|
||||||
|
assert(pcb == &storage && allocated);
|
||||||
|
allocated = false;
|
||||||
|
removals++;
|
||||||
|
}
|
||||||
|
static void udp_recv(struct udp_pcb *pcb, void (*callback)(void), void *arg)
|
||||||
|
{ assert(pcb == &storage); (void)callback; (void)arg; }
|
||||||
|
static void udp_disconnect(struct udp_pcb *pcb) { assert(pcb == &storage); }
|
||||||
|
static esp_err_t join_group(mdns_if_t interface, mdns_ip_protocol_t family, bool join)
|
||||||
|
{
|
||||||
|
if (join) {
|
||||||
|
joins[interface][family]++;
|
||||||
|
if (fail_join) return ESP_ERR_INVALID_STATE;
|
||||||
|
references[interface][family]++;
|
||||||
|
/* A real lwIP group has a bounded use count: never accumulate it. */
|
||||||
|
assert(references[interface][family] == 1);
|
||||||
|
} else {
|
||||||
|
leaves[interface][family]++;
|
||||||
|
if (fail_leave) return ESP_ERR_INVALID_STATE;
|
||||||
|
assert(references[interface][family] == 1);
|
||||||
|
references[interface][family]--;
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
#include "actual_functions.inc"
|
||||||
|
|
||||||
|
static void reset(void)
|
||||||
|
{
|
||||||
|
assert(!allocated && s_pcb_main == NULL);
|
||||||
|
memset(s_interfaces, 0, sizeof(s_interfaces));
|
||||||
|
memset(joins, 0, sizeof(joins));
|
||||||
|
memset(leaves, 0, sizeof(leaves));
|
||||||
|
memset(references, 0, sizeof(references));
|
||||||
|
allocations = removals = 0;
|
||||||
|
fail_alloc = fail_bind = fail_join = fail_leave = false;
|
||||||
|
}
|
||||||
|
static void check(int interface, int family, bool active)
|
||||||
|
{
|
||||||
|
assert(!!mdns_priv_if_ready(interface, family) == active);
|
||||||
|
assert(references[interface][family] == (int)active);
|
||||||
|
assert(joins[interface][family] - leaves[interface][family] == (int)active);
|
||||||
|
}
|
||||||
|
static void transitions(int family)
|
||||||
|
{
|
||||||
|
reset();
|
||||||
|
int other = 1 - family;
|
||||||
|
assert(pcb_if_init(0, family) == ESP_OK);
|
||||||
|
assert(pcb_if_init(0, other) == ESP_OK);
|
||||||
|
for (int i = 0; i < 512; i++) {
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
check(0, family, false);
|
||||||
|
check(0, other, true);
|
||||||
|
assert(s_interfaces[0].ready && allocated && removals == 0);
|
||||||
|
pcb_if_deinit(0, family); /* Already-disabled family must not leave again. */
|
||||||
|
check(0, family, false);
|
||||||
|
assert(pcb_if_init(0, family) == ESP_OK);
|
||||||
|
check(0, family, true);
|
||||||
|
int before = joins[0][family];
|
||||||
|
assert(pcb_if_init(0, family) == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(joins[0][family] == before);
|
||||||
|
}
|
||||||
|
pcb_if_deinit(0, other);
|
||||||
|
assert(s_interfaces[0].ready && allocated);
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
assert(!s_interfaces[0].ready && !allocated);
|
||||||
|
assert(allocations == 1 && removals == 1);
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
pcb_if_deinit(0, other);
|
||||||
|
check(0, family, false);
|
||||||
|
check(0, other, false);
|
||||||
|
}
|
||||||
|
static void failures(int family)
|
||||||
|
{
|
||||||
|
reset();
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
assert(leaves[0][family] == 0 && removals == 0);
|
||||||
|
fail_join = true;
|
||||||
|
assert(pcb_if_init(0, family) == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(!s_interfaces[0].ready && s_interfaces[0].proto == 0);
|
||||||
|
assert(allocations == 0 && leaves[0][family] == 0);
|
||||||
|
fail_join = false;
|
||||||
|
for (int mode = 0; mode < 2; mode++) {
|
||||||
|
fail_alloc = mode == 0;
|
||||||
|
fail_bind = mode == 1;
|
||||||
|
for (int i = 0; i < 512; i++) {
|
||||||
|
int before = leaves[0][family];
|
||||||
|
assert(pcb_if_init(0, family) == (fail_alloc ? ESP_ERR_NO_MEM : ESP_ERR_INVALID_STATE));
|
||||||
|
assert(leaves[0][family] == before + 1);
|
||||||
|
assert(references[0][family] == 0 && !allocated && !s_pcb_main);
|
||||||
|
assert(!s_interfaces[0].ready && s_interfaces[0].proto == 0);
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
assert(leaves[0][family] == before + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail_alloc = fail_bind = false;
|
||||||
|
assert(pcb_if_init(0, family) == ESP_OK);
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
assert(!allocated && references[0][family] == 0);
|
||||||
|
|
||||||
|
/* Preserve upstream best-effort leave semantics when netif has gone down. */
|
||||||
|
reset();
|
||||||
|
assert(pcb_if_init(0, family) == ESP_OK);
|
||||||
|
fail_leave = true;
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
assert(leaves[0][family] == 1 && !s_interfaces[0].ready && !allocated);
|
||||||
|
references[0][family] = 0; /* Model netif teardown clearing its memberships. */
|
||||||
|
reset();
|
||||||
|
fail_alloc = fail_leave = true;
|
||||||
|
assert(pcb_if_init(0, family) == ESP_ERR_NO_MEM);
|
||||||
|
assert(leaves[0][family] == 1 && !s_interfaces[0].ready && !allocated);
|
||||||
|
references[0][family] = 0;
|
||||||
|
}
|
||||||
|
static void shared_pcb(int family)
|
||||||
|
{
|
||||||
|
reset();
|
||||||
|
assert(pcb_if_init(1, 1 - family) == ESP_OK);
|
||||||
|
for (int i = 0; i < 512; i++) {
|
||||||
|
assert(pcb_if_init(0, family) == ESP_OK);
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
pcb_if_deinit(0, family);
|
||||||
|
check(0, family, false);
|
||||||
|
check(1, 1 - family, true);
|
||||||
|
assert(!s_interfaces[0].ready && allocated && removals == 0);
|
||||||
|
}
|
||||||
|
assert(allocations == 1);
|
||||||
|
pcb_if_deinit(1, 1 - family);
|
||||||
|
assert(!allocated && removals == 1);
|
||||||
|
}
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
for (int family = 0; family < 2; family++) {
|
||||||
|
transitions(family);
|
||||||
|
failures(family);
|
||||||
|
shared_pcb(family);
|
||||||
|
}
|
||||||
|
puts("PASS: actual mDNS functions, 512 cycles per family/scenario, failure unwind and shared PCB");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Phase 12 mDNS owner host regression
|
||||||
|
|
||||||
|
Run `python3 tests/mdns_phase12/run.py` from the repository root. Uses the host C11 compiler and temporary fake SDK headers; compiles the actual production module. No PlatformIO, device operations, or generated firmware assets.
|
||||||
|
|
||||||
|
Covers notifications before initialization and while the project mutex is held; default unavailable SSH; fixed HTTPS443/SSH22 and empty TXT; repeated starts without duplicate records or hostname churn; record add/remove failures and retry; offline withdrawal; offline rename and retry; latest-state coalescing; init/hostname/instance failure latches and cleanup. Family tests cover lost IPv4 with surviving IPv6, IPv6-only/link-local readiness, no valid IPv6, netif down, stop with stale addresses, missed GOT_IP6, explicitly failed and silently dropped action submission, delayed upstream disable, missing STA netif, and no enable churn during ordinary healthy polls. TCP/IP fakes assert address reads occur in TCP/IP context; netif actions assert neither project mutex nor publication critical section is held. Actual multicast, component tasks, DNS conflicts, and sockets are not simulated.
|
||||||
|
|
||||||
|
## Integration contract
|
||||||
|
|
||||||
|
- Listener owners call `void mdns_service_set_https_available(bool)` / `void mdns_service_set_ssh_available(bool)` after successful listener creation and on unavailability. Calls use tiny portMUX critical sections, safe before initialization, with no blocking semaphore, allocation, callbacks, or component calls. Xtensa does not promise lock-free C11 atomic bool, so no atomics are required.
|
||||||
|
- The sole Wi-Fi owner calls `esp_err_t mdns_service_reconcile(void)` periodically, even offline. No new arguments are needed: it looks up the permanent `WIFI_STA_DEF` netif and samples addresses via `esp_netif_tcpip_exec`. No project mutex or publication mux is held across SDK/component calls.
|
||||||
|
- Reconciliation never initializes the responder. Before module init it returns invalid-state; after module init but before responder startup it is a successful no-op. Once initialized it applies listener states and family readiness, retrying failed operations on subsequent passes. Notifications converge on a later pass, not synchronously; an in-flight pass can briefly reflect an older state.
|
||||||
|
- Existing start/reannounce also reconcile. Start remains gated by usable STA in either family. Stop now clears announcement expectation **and requests family disable**, even if netif still holds nonzero addresses; it does not destroy/reinitialize the responder. Errors are retained in the snapshot. Do not call lifecycle operations concurrently or while holding another service lock. The manager owns start/stop, not event callbacks.
|
||||||
|
- Required effective SDK options remain `CONFIG_MDNS_PREDEF_NETIF_STA=y`, AP/ETH predefined interfaces disabled, `CONFIG_LWIP_IPV4=y`, `CONFIG_LWIP_IPV6=y`, `CONFIG_MDNS_MAX_SERVICES >= 2`. No configuration change was made for the repair.
|
||||||
|
|
||||||
|
## Exact dependency contracts and limitations
|
||||||
|
|
||||||
|
Inspected installed Espressif mDNS **1.12.0** and ESP-IDF **5.5.0**:
|
||||||
|
|
||||||
|
- `mdns_netif.c` initializes IPv6 from `esp_netif_get_ip6_linklocal`, independently of IPv4. Its predefined handlers process disconnect and address acquisition, but not STA_LOST_IP. `mdns_networking_lwip.c` tracks family readiness separately from current addresses. `mdns_send.c` checks readiness before emitting A/AAAA, but can emit zero A if readiness remains true after IPv4 loss. Explicit family disable repairs that stale readiness, including when IPv6 survives.
|
||||||
|
- `mdns_send.c` uses `esp_netif_get_all_ip6`, whose IDF implementation includes **valid addresses, including deprecated addresses**, excludes invalid/tentative/zero addresses, and reads lwIP state directly. Our sampling therefore runs in TCP/IP context and intentionally follows valid-address semantics, not preferred-only semantics. No address cache is passed to mDNS; it still reads addresses when serializing records.
|
||||||
|
- `mdns_netif_action` is asynchronous, has no public readiness getter or acknowledgement, and `post_custom_action` returns `ESP_OK` even if action enqueue fails. A cached successful API return is not proof of applied state. Allocation errors retry on the next pass; silent losses require reassertion. Missing-family disables are idempotent and reasserted every poll. Present-family enables restart probes, so they are submitted on family-mask transitions and at a **30-second repair cadence**, not every healthy interval. This also repairs late upstream disconnect actions and failed internal PCB initialization. There is no responder restart or service-record churn.
|
||||||
|
- Public-only control cannot simultaneously guarantee acknowledged readiness and absolutely no periodic enable reprobes. Healthy families re-probe every 30 seconds as the explicit tradeoff. Recovery from silent dropped enables is on a subsequent repair cadence **once the component queue/network resources make progress**, not a hard deadline under sustained failure. Missing-family disable retries each poll. Polling, concurrent network changes, and component queuing leave a transient window in which stale/zero A responses can still escape; this is convergence, not an atomic packet-level filter. No upstream patch is included.
|
||||||
|
- `mdns_responder.c` adds/removes records synchronously under its own mutex. Hostname setting waits for the component worker; instance setting queues work. Work per pass is one bounded address scan, at most one family action, and two record decisions; no additional tasks, queues, dynamic application storage, or retry loops. This is not a hard wall-clock guarantee for upstream blocking calls.
|
||||||
|
|
||||||
|
## Target validation
|
||||||
|
|
||||||
|
In addition to the host suite, the updated `src/mdns_service.c` was compiled to a temporary object using its exact `.pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json` command and the installed Xtensa compiler / IDF 5.5 headers. Compilation passed. No full PlatformIO build, link, or hardware validation was performed.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compile the production mDNS owner against bounded host fakes; no IDF build."""
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mdns-phase12-") as tmp:
|
||||||
|
p = pathlib.Path(tmp)
|
||||||
|
(p / "freertos").mkdir()
|
||||||
|
headers = {
|
||||||
|
"esp_err.h": """#pragma once
|
||||||
|
typedef int esp_err_t;
|
||||||
|
#define ESP_OK 0
|
||||||
|
#define ESP_ERR_INVALID_ARG 1
|
||||||
|
#define ESP_ERR_INVALID_STATE 2
|
||||||
|
#define ESP_ERR_NO_MEM 3
|
||||||
|
#define ESP_ERR_NOT_FOUND 4
|
||||||
|
#define ESP_ERR_TIMEOUT 5
|
||||||
|
""",
|
||||||
|
"sdkconfig.h": """#define CONFIG_MDNS_PREDEF_NETIF_STA 1
|
||||||
|
#define CONFIG_MDNS_PREDEF_NETIF_AP 0
|
||||||
|
#define CONFIG_MDNS_PREDEF_NETIF_ETH 0
|
||||||
|
#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3
|
||||||
|
""",
|
||||||
|
"freertos/FreeRTOS.h": """#pragma once
|
||||||
|
#define portMAX_DELAY 100
|
||||||
|
#define pdTRUE 1
|
||||||
|
typedef int portMUX_TYPE;
|
||||||
|
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||||
|
void fake_enter(portMUX_TYPE *);
|
||||||
|
void fake_exit(portMUX_TYPE *);
|
||||||
|
#define portENTER_CRITICAL(m) fake_enter(m)
|
||||||
|
#define portEXIT_CRITICAL(m) fake_exit(m)
|
||||||
|
""",
|
||||||
|
"freertos/semphr.h": """#pragma once
|
||||||
|
typedef void *SemaphoreHandle_t;
|
||||||
|
SemaphoreHandle_t xSemaphoreCreateMutex(void);
|
||||||
|
int xSemaphoreTake(SemaphoreHandle_t, int);
|
||||||
|
int xSemaphoreGive(SemaphoreHandle_t);
|
||||||
|
""",
|
||||||
|
"esp_timer.h": """#pragma once
|
||||||
|
#include <stdint.h>
|
||||||
|
int64_t esp_timer_get_time(void);
|
||||||
|
""",
|
||||||
|
"esp_netif.h": """#pragma once
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "esp_err.h"
|
||||||
|
typedef struct { int unused; } esp_netif_t;
|
||||||
|
typedef struct { uint32_t addr; } esp_ip4_addr_t;
|
||||||
|
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
|
||||||
|
typedef struct { esp_ip4_addr_t ip; } esp_netif_ip_info_t;
|
||||||
|
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
|
||||||
|
bool esp_netif_is_netif_up(esp_netif_t *);
|
||||||
|
esp_err_t esp_netif_get_ip_info(esp_netif_t *, esp_netif_ip_info_t *);
|
||||||
|
int esp_netif_get_all_ip6(esp_netif_t *, esp_ip6_addr_t *);
|
||||||
|
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *), void *);
|
||||||
|
""",
|
||||||
|
"mdns.h": """#pragma once
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
typedef enum {
|
||||||
|
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
|
||||||
|
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
|
||||||
|
} mdns_event_actions_t;
|
||||||
|
esp_err_t mdns_netif_action(esp_netif_t *, mdns_event_actions_t);
|
||||||
|
esp_err_t mdns_init(void);
|
||||||
|
void mdns_free(void);
|
||||||
|
esp_err_t mdns_hostname_set(const char *);
|
||||||
|
esp_err_t mdns_instance_name_set(const char *);
|
||||||
|
esp_err_t mdns_service_add(const char *, const char *, const char *, uint16_t, void *, size_t);
|
||||||
|
esp_err_t mdns_service_remove(const char *, const char *);
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
for name, text in headers.items():
|
||||||
|
(p / name).write_text(text)
|
||||||
|
exe = p / "test"
|
||||||
|
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", tmp,
|
||||||
|
"-I", str(ROOT / "src"), str(pathlib.Path(__file__).with_name("test.c")),
|
||||||
|
"-o", str(exe)], check=True)
|
||||||
|
for case in ("normal", "init-failure", "hostname-failure", "instance-failure"):
|
||||||
|
subprocess.run([str(exe), case], check=True)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "mdns_service.c"
|
||||||
|
|
||||||
|
static int held, inits, frees, names, adds, removes;
|
||||||
|
static int fail_init, fail_name, fail_instance, fail_record;
|
||||||
|
static bool https, ssh;
|
||||||
|
static char hostname[64];
|
||||||
|
static int critical, tcpip, actions, enables, fail_action, drop_action;
|
||||||
|
static bool up = true, valid6 = true, present = true, zero6;
|
||||||
|
static uint32_t ipv4 = 1;
|
||||||
|
static unsigned ready;
|
||||||
|
static int64_t clock_us;
|
||||||
|
static esp_netif_t sta;
|
||||||
|
void fake_enter(portMUX_TYPE *m) { (void)m; assert(!critical); critical = 1; }
|
||||||
|
void fake_exit(portMUX_TYPE *m) { (void)m; assert(critical); critical = 0; }
|
||||||
|
int64_t esp_timer_get_time(void) { assert(!held && !critical); return clock_us; }
|
||||||
|
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
|
||||||
|
assert(!held && !critical && !strcmp(key, "WIFI_STA_DEF")); return present ? &sta : NULL;
|
||||||
|
}
|
||||||
|
bool esp_netif_is_netif_up(esp_netif_t *n) { assert(tcpip && n == &sta); return up; }
|
||||||
|
esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *ip) {
|
||||||
|
assert(tcpip && n == &sta); ip->ip.addr = ipv4; return ESP_OK;
|
||||||
|
}
|
||||||
|
int esp_netif_get_all_ip6(esp_netif_t *n, esp_ip6_addr_t *ip) {
|
||||||
|
assert(tcpip && n == &sta); memset(ip, 0, sizeof(*ip)); ip->addr[0] = zero6 ? 0 : 0xfe80;
|
||||||
|
return valid6 ? 1 : 0;
|
||||||
|
}
|
||||||
|
esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *arg) {
|
||||||
|
assert(!held && !critical && !tcpip); tcpip = 1;
|
||||||
|
esp_err_t error = fn(arg); tcpip = 0; return error;
|
||||||
|
}
|
||||||
|
esp_err_t mdns_netif_action(esp_netif_t *n, mdns_event_actions_t action) {
|
||||||
|
assert(!held && !critical && !tcpip && n == &sta); ++actions;
|
||||||
|
if (fail_action) return ESP_ERR_NO_MEM;
|
||||||
|
if (drop_action) return ESP_OK; /* Actual upstream full-queue behavior. */
|
||||||
|
if (action & MDNS_EVENT_ENABLE_IP4) { ready |= 1; ++enables; }
|
||||||
|
if (action & MDNS_EVENT_ENABLE_IP6) { ready |= 2; ++enables; }
|
||||||
|
if (action & MDNS_EVENT_DISABLE_IP4) ready &= ~1U;
|
||||||
|
if (action & MDNS_EVENT_DISABLE_IP6) ready &= ~2U;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
static void test_families(void) {
|
||||||
|
assert(ready == 3);
|
||||||
|
int before = actions;
|
||||||
|
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
|
||||||
|
assert(actions == before); /* Healthy dual-stack poll has no action. */
|
||||||
|
ipv4 = 0; /* Lost IPv4, surviving IPv6: A readiness must be removed. */
|
||||||
|
drop_action = 1;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||||
|
drop_action = 0;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
|
||||||
|
before = enables;
|
||||||
|
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
|
||||||
|
assert(enables == before); /* Disables do not restart the healthy family. */
|
||||||
|
zero6 = true; /* Defensive rejection even if an API supplied a zero entry. */
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||||
|
zero6 = false;
|
||||||
|
valid6 = false;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||||
|
valid6 = true; /* Missed GOT_IP6 and silently lost repair submission. */
|
||||||
|
drop_action = 1;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||||
|
drop_action = 0;
|
||||||
|
clock_us += MDNS_FAMILY_REPAIR_US;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
|
||||||
|
ipv4 = 1; fail_action = 1;
|
||||||
|
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && ready == 2);
|
||||||
|
fail_action = 0;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||||
|
ready = 0; /* Late upstream disconnect action after the last sample. */
|
||||||
|
clock_us += MDNS_FAMILY_REPAIR_US;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||||
|
up = false;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||||
|
up = true;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||||
|
mdns_service_stop();
|
||||||
|
assert(ready == 0); /* Even if stale nonzero addresses remain in netif. */
|
||||||
|
assert(mdns_service_start() == ESP_OK && ready == 3);
|
||||||
|
present = false;
|
||||||
|
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
|
||||||
|
present = true;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK);
|
||||||
|
}
|
||||||
|
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &held; }
|
||||||
|
int xSemaphoreTake(SemaphoreHandle_t m, int wait) {
|
||||||
|
(void)m;
|
||||||
|
assert(!critical);
|
||||||
|
if (held) { assert(!wait); return 0; }
|
||||||
|
held = 1; return pdTRUE;
|
||||||
|
}
|
||||||
|
int xSemaphoreGive(SemaphoreHandle_t m) { (void)m; assert(held); held = 0; return 1; }
|
||||||
|
esp_err_t mdns_config_validate(const mdns_config_t *c) { return c && c->suffix_len ? ESP_OK : ESP_ERR_INVALID_ARG; }
|
||||||
|
void mdns_config_defaults(mdns_config_t *c) { memset(c, 0, sizeof(*c)); strcpy(c->suffix, "default"); c->suffix_len = 7; }
|
||||||
|
esp_err_t mdns_config_load(mdns_config_t *c, bool *stored) { mdns_config_defaults(c); *stored = false; return ESP_OK; }
|
||||||
|
esp_err_t mdns_config_save(const mdns_config_t *c) { (void)c; return ESP_OK; }
|
||||||
|
esp_err_t mdns_init(void) { assert(!held && !critical); ++inits; return fail_init ? ESP_ERR_NO_MEM : ESP_OK; }
|
||||||
|
void mdns_free(void) { assert(!held && !critical); ++frees; }
|
||||||
|
esp_err_t mdns_hostname_set(const char *n) { assert(!held && !critical); ++names; if (fail_name) return ESP_ERR_NO_MEM; strcpy(hostname, n); return ESP_OK; }
|
||||||
|
esp_err_t mdns_instance_name_set(const char *n) { assert(!held && !critical && n); return fail_instance ? ESP_ERR_NO_MEM : ESP_OK; }
|
||||||
|
esp_err_t mdns_service_add(const char *instance, const char *type, const char *proto, uint16_t port, void *txt, size_t count) {
|
||||||
|
assert(!held && !critical && !instance && !txt && !count && !strcmp(proto, "_tcp"));
|
||||||
|
++adds;
|
||||||
|
if (fail_record) return ESP_ERR_NO_MEM;
|
||||||
|
bool *record = !strcmp(type, "_https") ? &https : &ssh;
|
||||||
|
assert(port == (record == &https ? 443 : 22));
|
||||||
|
assert(!*record); *record = true; return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t mdns_service_remove(const char *type, const char *proto) {
|
||||||
|
assert(!held && !critical && !strcmp(proto, "_tcp")); ++removes;
|
||||||
|
if (fail_record) return ESP_ERR_NO_MEM;
|
||||||
|
bool *record = !strcmp(type, "_https") ? &https : &ssh;
|
||||||
|
assert(*record); *record = false; return ESP_OK;
|
||||||
|
}
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
assert(argc == 2);
|
||||||
|
fail_init = !strcmp(argv[1], "init-failure");
|
||||||
|
fail_name = !strcmp(argv[1], "hostname-failure");
|
||||||
|
fail_instance = !strcmp(argv[1], "instance-failure");
|
||||||
|
mdns_service_set_https_available(true);
|
||||||
|
mdns_service_set_ssh_available(false);
|
||||||
|
assert(!inits && !adds);
|
||||||
|
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
|
||||||
|
mdns_config_t c; mdns_config_defaults(&c);
|
||||||
|
assert(mdns_service_init(&c) == ESP_OK);
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && !inits);
|
||||||
|
if (fail_init || fail_name || fail_instance) {
|
||||||
|
assert(mdns_service_start() == ESP_ERR_NO_MEM);
|
||||||
|
assert(frees == (fail_init ? 0 : 1));
|
||||||
|
fail_init = fail_name = fail_instance = 0;
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
assert(mdns_service_start() == ESP_ERR_NO_MEM);
|
||||||
|
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM);
|
||||||
|
assert(mdns_service_reannounce() == ESP_ERR_NO_MEM);
|
||||||
|
mdns_service_stop();
|
||||||
|
}
|
||||||
|
assert(inits == 1 && !adds);
|
||||||
|
} else {
|
||||||
|
assert(mdns_service_start() == ESP_OK && https && !ssh);
|
||||||
|
for (int i = 0; i < 10; ++i) assert(mdns_service_start() == ESP_OK);
|
||||||
|
assert(inits == 1 && names == 1 && adds == 1);
|
||||||
|
test_families();
|
||||||
|
held = 1; /* Notifications must never acquire the service mutex. */
|
||||||
|
mdns_service_set_https_available(false);
|
||||||
|
mdns_service_set_ssh_available(true);
|
||||||
|
held = 0;
|
||||||
|
fail_record = 1;
|
||||||
|
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && https && !ssh);
|
||||||
|
fail_record = 0;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && !https && ssh);
|
||||||
|
mdns_service_stop();
|
||||||
|
mdns_service_set_ssh_available(false);
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && !ssh);
|
||||||
|
strcpy(c.suffix, "offline"); c.suffix_len = 7;
|
||||||
|
assert(mdns_service_set_config(&c) == ESP_OK);
|
||||||
|
fail_name = 1;
|
||||||
|
assert(mdns_service_start() == ESP_ERR_NO_MEM);
|
||||||
|
fail_name = 0;
|
||||||
|
assert(mdns_service_start() == ESP_OK && !strcmp(hostname, "sak-offline"));
|
||||||
|
mdns_service_set_https_available(true);
|
||||||
|
mdns_service_set_https_available(false);
|
||||||
|
int before = adds;
|
||||||
|
assert(mdns_service_reconcile() == ESP_OK && adds == before);
|
||||||
|
assert(inits == 1 && !frees);
|
||||||
|
}
|
||||||
|
printf("PASS %s\n", argv[1]);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ static ssh_slot_t s_slots[2];
|
|||||||
static void *s_context;
|
static void *s_context;
|
||||||
static int s_listen_fd=-1, s_lock;
|
static int s_listen_fd=-1, s_lock;
|
||||||
static unsigned depth, frees, creates;
|
static unsigned depth, frees, creates;
|
||||||
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail;
|
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail, advertised;
|
||||||
|
static void mdns_service_set_ssh_available(bool available) { assert(!depth); assert(available == (s_listen_fd >= 0)); advertised=available; }
|
||||||
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while(0)
|
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while(0)
|
||||||
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while(0)
|
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while(0)
|
||||||
static void wolfSSH_CTX_free(void *p) { assert(!depth && p==s_context); for(unsigned i=0;i<2;++i) assert(!s_slots[i].state); ++frees; }
|
static void wolfSSH_CTX_free(void *p) { assert(!depth && p==s_context); for(unsigned i=0;i<2;++i) assert(!s_slots[i].state); ++frees; }
|
||||||
@@ -48,17 +49,17 @@ static esp_err_t create_listener(void) { if(listener_fail)return ESP_FAIL;s_list
|
|||||||
'''
|
'''
|
||||||
tests=r'''
|
tests=r'''
|
||||||
int main(void) {
|
int main(void) {
|
||||||
assert(start_runtime()==ESP_OK && creates==1);
|
assert(start_runtime()==ESP_OK && creates==1 && advertised);
|
||||||
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1 && !frees);
|
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1 && !frees);
|
||||||
s_slots[0].state=2;cleanup_fail=true;
|
s_slots[0].state=2;cleanup_fail=true;
|
||||||
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1);
|
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1 && !advertised);
|
||||||
s_cleanup_pending=true;s_running=false;
|
s_cleanup_pending=true;s_running=false;
|
||||||
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1);
|
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1);
|
||||||
process_slots();assert(s_context && s_cleanup_pending && !frees);
|
process_slots();assert(s_context && s_cleanup_pending && !frees);
|
||||||
cleanup_fail=false;process_slots();assert(!s_context && !s_cleanup_pending && frees==1);
|
cleanup_fail=false;process_slots();assert(!s_context && !s_cleanup_pending && frees==1);
|
||||||
process_slots();assert(frees==1);
|
process_slots();assert(frees==1);
|
||||||
assert(start_runtime()==ESP_OK && creates==2);assert(stop_runtime()==ESP_OK && frees==2);
|
assert(start_runtime()==ESP_OK && creates==2 && advertised);assert(stop_runtime()==ESP_OK && frees==2 && !advertised);
|
||||||
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1);
|
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1 && !advertised);
|
||||||
s_slots[1].state=2;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);s_slots[1].state=0;
|
s_slots[1].state=2;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);s_slots[1].state=0;
|
||||||
s_listen_fd=22;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);
|
s_listen_fd=22;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);
|
||||||
puts("PASS SSH actual runtime stop failure retains context, rejects orphan overwrite, owner retires only after all slots free, failed listener frees context exactly once");
|
puts("PASS SSH actual runtime stop failure retains context, rejects orphan overwrite, owner retires only after all slots free, failed listener frees context exactly once");
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Phase 12 SSH listener regression
|
||||||
|
|
||||||
|
Run `python3 tests/ssh_phase12/run.py` from the repository root. Requires Python 3
|
||||||
|
and a host C compiler (`cc`). It extracts the production functions without rewriting
|
||||||
|
them and compiles with `-std=c11 -Wall -Wextra -Werror`.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
|
||||||
|
- One AF_INET6/TCP wildcard listener, explicitly checked IPV6_V6ONLY=0 before bind.
|
||||||
|
- Failures at socket, dual-stack option, bind, listen and both nonblocking steps:
|
||||||
|
no published descriptor, exactly one close after socket allocation.
|
||||||
|
- Permanent accept errors withdraw availability, invalidate the service generation
|
||||||
|
without wrap, and request owner-side session cleanup; temporary errors retain
|
||||||
|
the listener. Capacity rejection respects the existing four-accept budget.
|
||||||
|
- IPv4, global IPv6, scoped link-local IPv6, mapped IPv4, maximum numeric scope,
|
||||||
|
unknown families, and bounded/truncated peer formatting.
|
||||||
|
|
||||||
|
`tests/ssh_management/runtime.py` additionally tests availability across real
|
||||||
|
production start/stop functions with context/listener/cleanup doubles, including
|
||||||
|
failed session cleanup retaining the context, successful restart, and failed start.
|
||||||
|
Existing management/security regressions remain responsible for authentication,
|
||||||
|
identity ownership and generation fencing.
|
||||||
|
|
||||||
|
## SDK contract checked during implementation
|
||||||
|
|
||||||
|
Read-only inspection of installed ESP-IDF **5.5.0**
|
||||||
|
(`framework-espidf@3.50500.0`, not the separately installed 5.5.3):
|
||||||
|
|
||||||
|
- `components/lwip/lwip/src/include/lwip/sockets.h`: `sockaddr_in6.sin6_scope_id`
|
||||||
|
is `u32_t`; `IPV6_V6ONLY` is supported.
|
||||||
|
- `components/lwip/lwip/src/include/lwip/inet.h`: `IN6ADDR_ANY_INIT`.
|
||||||
|
- `components/lwip/lwip/src/api/sockets.c`: `lwip_setsockopt_impl` applies
|
||||||
|
`netconn_set_ipv6only`; `IP6ADDR_PORT_TO_SOCKADDR` copies the interface zone;
|
||||||
|
`lwip_accept` publishes the peer address and maps a closed listener to EINVAL,
|
||||||
|
non-TCP sockets to EOPNOTSUPP, and descriptor exhaustion to ENFILE.
|
||||||
|
- `components/lwip/lwip/src/api/api_msg.c`: wildcard IPv6 bind/listen with V6ONLY
|
||||||
|
disabled selects `IPADDR_TYPE_ANY`, accepting both families on one listener.
|
||||||
|
|
||||||
|
These host doubles do **not** execute lwIP, actual networking, wolfSSH handshakes,
|
||||||
|
RTOS concurrency, discovery traffic or hardware. No PlatformIO build is required
|
||||||
|
or claimed. Device follow-up must verify IPv4-only, IPv6-only and dual-stack
|
||||||
|
connections, scoped link-local access, address changes/reconnects, simultaneous
|
||||||
|
clients and binary UART traffic, advertisement convergence after start/stop/error,
|
||||||
|
and independent UART0/native USB recovery.
|
||||||
|
|
||||||
|
## Resource and handoff notes
|
||||||
|
|
||||||
|
No extra socket, task, session, queue, I/O buffer or heap allocation is introduced.
|
||||||
|
Peer arrays grow from 48 to 65 bytes: 68 additional raw bytes across the two owner
|
||||||
|
slots and two published snapshots, plus ABI padding. Public snapshot copies also
|
||||||
|
grow. Exact linked RAM/flash and stack headroom were not measured without a target
|
||||||
|
build; host tests do not establish ESP32 memory headroom.
|
||||||
|
|
||||||
|
Project agent memory is intentionally not edited under the exclusive-write scope.
|
||||||
|
The parent should record the one-socket dual-stack contract, scoped peer capacity,
|
||||||
|
and owner-driven mDNS availability (eventual Wi-Fi-manager reconciliation) in the
|
||||||
|
relevant durable memory when integrating Phase 12.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compile exact SSH listener/peer/failure functions with deterministic socket doubles.
|
||||||
|
No target, network, wolfSSH handshake, or SDK build is exercised.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
source = (ROOT / 'src/ssh_transport.c').read_text()
|
||||||
|
header = (ROOT / 'src/ssh_transport.h').read_text()
|
||||||
|
|
||||||
|
def function(name):
|
||||||
|
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
|
||||||
|
assert match, name
|
||||||
|
return match.group() + '\n'
|
||||||
|
|
||||||
|
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', text, re.M).group()
|
||||||
|
for text, name in ((header, 'SSH_TRANSPORT_PORT'),
|
||||||
|
(header, 'SSH_TRANSPORT_PEER_CAPACITY'),
|
||||||
|
(source, 'SSH_TRANSPORT_LISTEN_BACKLOG')))
|
||||||
|
fakes = r'''
|
||||||
|
#include <assert.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <netinet/tcp.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#define ESP_OK 0
|
||||||
|
#define ESP_FAIL -1
|
||||||
|
#define SSH_TRANSPORT_MAX_SESSIONS 2U
|
||||||
|
typedef int esp_err_t;
|
||||||
|
static int s_listen_fd = -1;
|
||||||
|
static unsigned stage, fail_at, closes, depth;
|
||||||
|
static bool s_running, s_cleanup_pending;
|
||||||
|
static unsigned withdrawals;
|
||||||
|
static void mdns_service_set_ssh_available(bool available) { assert(!depth && !available && s_listen_fd == -1); ++withdrawals; }
|
||||||
|
static int s_last_error, s_lock;
|
||||||
|
static uint32_t s_management_generation;
|
||||||
|
typedef struct {
|
||||||
|
bool close_requested;
|
||||||
|
unsigned generation, state, session_id;
|
||||||
|
int socket_fd;
|
||||||
|
int64_t handshake_deadline_us;
|
||||||
|
char peer[SSH_TRANSPORT_PEER_CAPACITY];
|
||||||
|
void *ssh;
|
||||||
|
} ssh_slot_t;
|
||||||
|
static ssh_slot_t s_slots[2];
|
||||||
|
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while (0)
|
||||||
|
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while (0)
|
||||||
|
#define request_slot_close(slot, revoked) do { assert(!(revoked)); (slot)->close_requested=true; } while (0)
|
||||||
|
static bool step(void) { return ++stage == fail_at; }
|
||||||
|
static int fake_socket(int family, int type, int protocol) {
|
||||||
|
assert(family == AF_INET6 && type == SOCK_STREAM && protocol == IPPROTO_TCP);
|
||||||
|
return step() ? -1 : 42;
|
||||||
|
}
|
||||||
|
static int fake_setsockopt(int fd, int level, int option, const void *value, socklen_t size) {
|
||||||
|
assert(fd == 42 && size == sizeof(int));
|
||||||
|
if (level == SOL_SOCKET) { assert(option == SO_REUSEADDR && *(const int *)value == 1); return 0; }
|
||||||
|
assert(stage == 1 && level == IPPROTO_IPV6 && option == IPV6_V6ONLY && *(const int *)value == 0);
|
||||||
|
return step() ? -1 : 0;
|
||||||
|
}
|
||||||
|
static int fake_bind(int fd, const struct sockaddr *addr, socklen_t size) {
|
||||||
|
const struct sockaddr_in6 *v6 = (const struct sockaddr_in6 *)addr;
|
||||||
|
assert(fd == 42 && stage == 2 && size == sizeof(*v6));
|
||||||
|
assert(v6->sin6_family == AF_INET6 && ntohs(v6->sin6_port) == SSH_TRANSPORT_PORT);
|
||||||
|
assert(IN6_IS_ADDR_UNSPECIFIED(&v6->sin6_addr) && !v6->sin6_scope_id);
|
||||||
|
return step() ? -1 : 0;
|
||||||
|
}
|
||||||
|
static int fake_listen(int fd, int backlog) {
|
||||||
|
assert(fd == 42 && stage == 3 && backlog == SSH_TRANSPORT_LISTEN_BACKLOG);
|
||||||
|
return step() ? -1 : 0;
|
||||||
|
}
|
||||||
|
static int fake_fcntl(int fd, int op, int arg) {
|
||||||
|
assert(fd == 42);
|
||||||
|
assert((stage == 4 && op == F_GETFL && arg == 0) ||
|
||||||
|
(stage == 5 && op == F_SETFL && arg == (O_NONBLOCK | O_APPEND)));
|
||||||
|
return step() ? -1 : (op == F_GETFL ? O_APPEND : 0);
|
||||||
|
}
|
||||||
|
static int fake_close(int fd) { assert(fd == 42 && !depth); ++closes; return 0; }
|
||||||
|
static int fake_shutdown(int fd, int how) { assert(fd == 42 && how == SHUT_RDWR && !depth); return 0; }
|
||||||
|
#define socket fake_socket
|
||||||
|
#define setsockopt fake_setsockopt
|
||||||
|
#define bind fake_bind
|
||||||
|
#define listen fake_listen
|
||||||
|
#define fcntl fake_fcntl
|
||||||
|
#define close fake_close
|
||||||
|
#define shutdown fake_shutdown
|
||||||
|
'''
|
||||||
|
accept_fakes = r'''
|
||||||
|
#define SSH_TRANSPORT_ACCEPT_BUDGET 4U
|
||||||
|
#define SSH_TRANSPORT_SESSION_HANDSHAKE 1
|
||||||
|
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
|
||||||
|
#define WS_SUCCESS 0
|
||||||
|
static void *s_context = (void *)1;
|
||||||
|
static struct { uint64_t io_failures, tcp_connections, capacity_rejections, handshake_failures; } s_counters;
|
||||||
|
static unsigned accepts;
|
||||||
|
static int accept_error;
|
||||||
|
static void add_counter(uint64_t *counter, uint64_t value) { *counter += value; }
|
||||||
|
static int fake_accept(int fd, struct sockaddr *addr, socklen_t *length) {
|
||||||
|
assert(fd == 42 && addr && *length == sizeof(struct sockaddr_storage));
|
||||||
|
++accepts; errno = accept_error;
|
||||||
|
return accept_error ? -1 : 42;
|
||||||
|
}
|
||||||
|
#define accept fake_accept
|
||||||
|
static ssh_slot_t *find_free_slot(size_t *index) { (void)index; return NULL; }
|
||||||
|
static uint32_t make_session_id(size_t index, uint32_t generation) { (void)index; return generation; }
|
||||||
|
static int64_t esp_timer_get_time(void) { return 0; }
|
||||||
|
static void *wolfSSH_new(void *ctx) { (void)ctx; assert(0); return NULL; }
|
||||||
|
static int wolfSSH_set_fd(void *ssh, int fd) { (void)ssh; (void)fd; assert(0); return 0; }
|
||||||
|
static void set_ctx(void *ssh, void *slot) { (void)ssh; (void)slot; assert(0); }
|
||||||
|
#define wolfSSH_SetIOReadCtx set_ctx
|
||||||
|
#define wolfSSH_SetUserAuthCtx set_ctx
|
||||||
|
#define wolfSSH_SetUserAuthResultCtx set_ctx
|
||||||
|
#define wolfSSH_SetChannelReqCtx set_ctx
|
||||||
|
static bool cleanup_slot(ssh_slot_t *slot) { (void)slot; assert(0); return false; }
|
||||||
|
static void publish_slot(ssh_slot_t *slot, size_t index) { (void)slot; (void)index; assert(0); }
|
||||||
|
'''
|
||||||
|
tests = r'''
|
||||||
|
int main(void) {
|
||||||
|
for (fail_at = 1; fail_at <= 6; ++fail_at) {
|
||||||
|
stage = closes = 0;
|
||||||
|
assert(create_listener() == ESP_FAIL && s_listen_fd == -1);
|
||||||
|
assert(stage == fail_at && closes == (fail_at != 1));
|
||||||
|
}
|
||||||
|
fail_at = stage = closes = 0;
|
||||||
|
assert(create_listener() == ESP_OK && s_listen_fd == 42 && stage == 6 && !closes);
|
||||||
|
s_running = true; s_management_generation = 7;
|
||||||
|
listener_failed();
|
||||||
|
assert(s_listen_fd == -1 && closes == 1 && !s_running && s_cleanup_pending && withdrawals == 1);
|
||||||
|
assert(s_last_error == ESP_FAIL && s_management_generation == 8);
|
||||||
|
assert(s_slots[0].close_requested && s_slots[1].close_requested);
|
||||||
|
s_management_generation = UINT32_MAX;
|
||||||
|
listener_failed(); assert(s_management_generation == UINT32_MAX && closes == 1);
|
||||||
|
|
||||||
|
const int retry_errors[] = { EAGAIN, EWOULDBLOCK, EINTR, ENOMEM, ENOBUFS, ENFILE, ECONNABORTED };
|
||||||
|
for (unsigned i = 0; i < sizeof(retry_errors)/sizeof(retry_errors[0]); ++i) {
|
||||||
|
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = retry_errors[i];
|
||||||
|
unsigned before = withdrawals;
|
||||||
|
accept_connections();
|
||||||
|
assert(accepts == 1 && s_listen_fd == 42 && s_running && withdrawals == before);
|
||||||
|
}
|
||||||
|
const int fatal_errors[] = { EBADF, EINVAL, ENOTSOCK, EOPNOTSUPP };
|
||||||
|
for (unsigned i = 0; i < sizeof(fatal_errors)/sizeof(fatal_errors[0]); ++i) {
|
||||||
|
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = fatal_errors[i];
|
||||||
|
unsigned before = withdrawals;
|
||||||
|
accept_connections();
|
||||||
|
assert(accepts == 1 && s_listen_fd == -1 && !s_running && withdrawals == before + 1);
|
||||||
|
}
|
||||||
|
accepts = 0; accept_connections(); assert(!accepts);
|
||||||
|
s_listen_fd = 42; accepts = closes = 0; accept_error = 0;
|
||||||
|
accept_connections();
|
||||||
|
assert(accepts == SSH_TRANSPORT_ACCEPT_BUDGET && closes == accepts);
|
||||||
|
assert(s_counters.capacity_rejections == accepts && s_counters.tcp_connections == accepts);
|
||||||
|
|
||||||
|
struct sockaddr_storage storage = {0};
|
||||||
|
char output[SSH_TRANSPORT_PEER_CAPACITY];
|
||||||
|
struct sockaddr_in *v4 = (struct sockaddr_in *)&storage;
|
||||||
|
v4->sin_family = AF_INET; v4->sin_port = htons(65535);
|
||||||
|
assert(inet_pton(AF_INET, "192.0.2.1", &v4->sin_addr) == 1);
|
||||||
|
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "192.0.2.1:65535"));
|
||||||
|
memset(&storage, 0, sizeof(storage));
|
||||||
|
struct sockaddr_in6 *v6 = (struct sockaddr_in6 *)&storage;
|
||||||
|
v6->sin6_family = AF_INET6; v6->sin6_port = htons(65535);
|
||||||
|
assert(inet_pton(AF_INET6, "fe80::1", &v6->sin6_addr) == 1);
|
||||||
|
v6->sin6_scope_id = 3;
|
||||||
|
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[fe80::1%3]:65535"));
|
||||||
|
v6->sin6_scope_id = UINT32_MAX;
|
||||||
|
assert(inet_pton(AF_INET6, "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", &v6->sin6_addr) == 1);
|
||||||
|
format_peer(&storage, output, sizeof(output));
|
||||||
|
assert(!strcmp(output, "[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff%4294967295]:65535"));
|
||||||
|
v6->sin6_scope_id = 0;
|
||||||
|
assert(inet_pton(AF_INET6, "2001:db8::1", &v6->sin6_addr) == 1);
|
||||||
|
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[2001:db8::1]:65535"));
|
||||||
|
assert(inet_pton(AF_INET6, "::ffff:192.0.2.1", &v6->sin6_addr) == 1);
|
||||||
|
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[::ffff:192.0.2.1]:65535"));
|
||||||
|
format_peer(&storage, output, 8); assert(!strcmp(output, "unknown"));
|
||||||
|
output[0] = 'x'; format_peer(&storage, output, 0); assert(output[0] == 'x');
|
||||||
|
format_peer(&storage, output, 1); assert(output[0] == 0);
|
||||||
|
storage.ss_family = AF_UNSPEC;
|
||||||
|
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "unknown"));
|
||||||
|
puts("PASS SSH Phase12 dual-stack socket setup, all setup failures, owner listener failure, scoped/mapped peers and bounded formatting");
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
with tempfile.TemporaryDirectory(prefix='ssh-phase12-') as directory:
|
||||||
|
out = Path(directory)
|
||||||
|
(out / 'test.c').write_text(constants + '\n' + fakes + ''.join(function(name) for name in (
|
||||||
|
'close_socket', 'set_nonblocking', 'create_listener', 'format_peer', 'listener_failed')) + accept_fakes + function('accept_connections') + tests)
|
||||||
|
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(out / 'test.c'), '-o', str(out / 'test')], check=True, timeout=30)
|
||||||
|
subprocess.run([str(out / 'test')], check=True, timeout=10)
|
||||||
@@ -21,7 +21,7 @@ No firmware build, network access or device operation is performed.
|
|||||||
|
|
||||||
Implementer reports `run.py --tickets` PASS **25 transport / 12 ticket groups**, including certificate owner routing, currentness rejection and commit → stop → start short-circuit/error behavior. The owner's `dispatcher_actions` mask selects the existing 12 KiB dispatcher, not the 4 KiB control task. `tests/admin_console_boundary/run.py` (including `certificate.c`) separately covers typed deferred handoff/pending gate/executing reservation; `lifecycle.py` covers canonical handlers and unchanged SSH/UART0 behavior. Policy, server lifecycle **11**, cookie `--admin` and store `--serial` also pass as reported. Independent review has no actionable findings; sanitizer validation is unavailable due to missing libasan/libubsan. No hardware validation is claimed or performed by this documentation update.
|
Implementer reports `run.py --tickets` PASS **25 transport / 12 ticket groups**, including certificate owner routing, currentness rejection and commit → stop → start short-circuit/error behavior. The owner's `dispatcher_actions` mask selects the existing 12 KiB dispatcher, not the 4 KiB control task. `tests/admin_console_boundary/run.py` (including `certificate.c`) separately covers typed deferred handoff/pending gate/executing reservation; `lifecycle.py` covers canonical handlers and unchanged SSH/UART0 behavior. Policy, server lifecycle **11**, cookie `--admin` and store `--serial` also pass as reported. Independent review has no actionable findings; sanitizer validation is unavailable due to missing libasan/libubsan. No hardware validation is claimed or performed by this documentation update.
|
||||||
|
|
||||||
Current [browser-shell policy](../../docs/web_administration.md#browser-shell-policy) permits selected other-account mutations as well as exact parsed `web status`, `web stop`, `web certificate rotate --force`, and `reboot`/self-close; network mutations and restricted SSH lifecycle/key mutations remain blocked in the shell. Typed Settings permissions are separate. Certificate drain/acknowledgement bounds do not bound queued execution or prove browser receipt. [Acceptance evidence](../../docs/web_administration_acceptance.md) records M2 and the later 8D.22 signoff; earlier pending gates below are historical, not current acceptance blockers.
|
Current [browser-shell policy](../../docs/web_administration.md#browser-shell-policy) permits selected other-account mutations as well as exact parsed `web status`, `web stop`, `web certificate rotate --force`, and `reboot`/self-close; network mutations and restricted SSH lifecycle/key mutations remain blocked in the shell. Typed Settings permissions are separate. Certificate drain/acknowledgement bounds do not bound queued execution or prove browser receipt. [Acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence) records M2 and the later 8D.22 signoff; earlier pending gates below are historical, not current acceptance blockers.
|
||||||
|
|
||||||
## Earlier results recorded 2026-09-06
|
## Earlier results recorded 2026-09-06
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ below. Final admin closure uses direct HTTPD-owned `shutdown`, not queued IDF
|
|||||||
session-close work. Parent reports the sequential final firmware build after this
|
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
|
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
|
independent security integration review found no actionable findings. See
|
||||||
[terminal ownership](../../docs/web_administration.md#terminal-and-console-ownership) and [acceptance evidence](../../docs/web_administration_acceptance.md) for current contracts and signoff limits.
|
[terminal ownership](../../docs/web_administration.md#terminal-and-console-ownership) and [acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence) for current contracts and signoff limits.
|
||||||
|
|
||||||
After the production empty-frame, input-deadline and timer-generation fixes:
|
After the production empty-frame, input-deadline and timer-generation fixes:
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ static esp_err_t web_server_start(void) {
|
|||||||
OUTSIDE(); assert(!httpd_owner && rotations && web_stops == 1 && web_stop_result == ESP_OK);
|
OUTSIDE(); assert(!httpd_owner && rotations && web_stops == 1 && web_stop_result == ESP_OK);
|
||||||
++web_starts; return web_start_result;
|
++web_starts; return web_start_result;
|
||||||
}
|
}
|
||||||
|
static bool reboot_busy;
|
||||||
|
static esp_err_t web_firmware_update_reserve_reboot(void) {
|
||||||
|
OUTSIDE(); assert(!httpd_owner);
|
||||||
|
return reboot_busy ? ESP_ERR_INVALID_STATE : ESP_OK;
|
||||||
|
}
|
||||||
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
|
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
|
||||||
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
|
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
|
||||||
static esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
static esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
||||||
|
|||||||
@@ -180,20 +180,20 @@ static void pipeline_tests(void) {
|
|||||||
reset(); lifecycle_fail_at = failure; start();
|
reset(); lifecycle_fail_at = failure; start();
|
||||||
unsigned failed_route = (failure + 1) / 2;
|
unsigned failed_route = (failure + 1) / 2;
|
||||||
assert(lifecycle_calls == failed_route && lifecycle_allocations == failure);
|
assert(lifecycle_calls == failed_route && lifecycle_allocations == failure);
|
||||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
||||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
assert(!method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
||||||
assert(!!method_route("/api/settings/lifecycle", HTTP_GET) == (failed_route != 1));
|
assert(!!method_route("/api/settings/lifecycle", HTTP_GET) == (failed_route != 1));
|
||||||
other_domains_complete(); network_complete(); display_complete(); broker_complete(); ssh_complete();
|
other_domains_complete(); network_complete(); display_complete(); broker_complete(); ssh_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 39);
|
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 40);
|
||||||
assert(method_route("/api/settings/lifecycle-operation", HTTP_POST)->handler == web_lifecycle_operation_handler);
|
assert(method_route("/api/settings/lifecycle-operation", HTTP_POST)->handler == web_lifecycle_operation_handler);
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS all six lifecycle route allocation positions preserve other domains and restart recovers the complete optional API");
|
puts("PASS all six lifecycle route allocation positions preserve other domains and restart recovers the complete optional API");
|
||||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||||
reset(); lifecycle_fail_at = failure; unregister_fail = true; start();
|
reset(); lifecycle_fail_at = failure; unregister_fail = true; start();
|
||||||
assert(registered_count == 38 && unregister_calls == 1);
|
assert(registered_count == 39 && unregister_calls == 1);
|
||||||
assert(method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
assert(method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
||||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
||||||
other_domains_complete(); ssh_complete();
|
other_domains_complete(); ssh_complete();
|
||||||
@@ -201,7 +201,7 @@ static void pipeline_tests(void) {
|
|||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
||||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||||
unregister_fail = false; lifecycle_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; lifecycle_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39 && web_server_stop() == ESP_OK);
|
assert(registered_count == 40 && web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS lifecycle failed unregister leaves reads only; failed shutdown preserves ownership before successful restart");
|
puts("PASS lifecycle failed unregister leaves reads only; failed shutdown preserves ownership before successful restart");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Compile production server lifecycle and URI tables against fixed host fakes.
|
"""Compile production server lifecycle and URI tables against fixed host fakes.
|
||||||
|
|
||||||
No HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler is
|
No complete HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler
|
||||||
executed. Assertions cover server orchestration and values passed to registration
|
are executed. The Wi-Fi status JSON projection is compiled separately from its
|
||||||
|
production format and arguments. Assertions cover server orchestration and values passed to registration
|
||||||
and SSL-start fakes, not actual requests/101, socket eviction or concurrent stop.
|
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.
|
No firmware build, network access or device operation. CC selects the compiler.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
@@ -37,8 +39,8 @@ def define(path, name):
|
|||||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||||
source, re.M | re.S)
|
source, re.M | re.S)
|
||||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||||
if len(uri_tables) != 36:
|
if len(uri_tables) != 37:
|
||||||
raise RuntimeError('Review URI extraction: expected 34 descriptors and two tables')
|
raise RuntimeError('Review URI extraction: expected 35 descriptors and two tables')
|
||||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||||
source.index('static esp_err_t ensure_mutex(void)')]
|
source.index('static esp_err_t ensure_mutex(void)')]
|
||||||
header = (ROOT / 'src/web_server.h').read_text()
|
header = (ROOT / 'src/web_server.h').read_text()
|
||||||
@@ -46,6 +48,7 @@ header = '\n'.join(line for line in header.splitlines()
|
|||||||
if not line.startswith(('#include', '#pragma once')))
|
if not line.startswith(('#include', '#pragma once')))
|
||||||
constants = define(SOURCE, 'WEB_SERVER_PORT')
|
constants = define(SOURCE, 'WEB_SERVER_PORT')
|
||||||
for filename, names in {
|
for filename, names in {
|
||||||
|
'web_firmware_update.h': ('WEB_FIRMWARE_UPDATE_URI',),
|
||||||
'web_admin_transport.h': ('WEB_ADMIN_TICKET_URI', 'WEB_ADMIN_WS_URI'),
|
'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_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'),
|
'web_security.h': ('WEB_SECURITY_CERTIFICATE_DER_CAPACITY', 'WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY'),
|
||||||
@@ -90,6 +93,18 @@ static int mutex_storage, server_storage, locked;
|
|||||||
static void esp_restart(void);
|
static void esp_restart(void);
|
||||||
void web_lifecycle_settings_stopped(httpd_handle_t server);
|
void web_lifecycle_settings_stopped(httpd_handle_t server);
|
||||||
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live, mutex_busy;
|
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live, mutex_busy;
|
||||||
|
static bool https_available;
|
||||||
|
static unsigned https_publications, https_withdrawals;
|
||||||
|
static void mdns_service_set_https_available(bool available) {
|
||||||
|
assert(locked);
|
||||||
|
if (available) {
|
||||||
|
assert(auth_live && ssl_live && !https_available);
|
||||||
|
++https_publications;
|
||||||
|
} else {
|
||||||
|
++https_withdrawals;
|
||||||
|
}
|
||||||
|
https_available = available;
|
||||||
|
}
|
||||||
static void (*unlock_hook)(void);
|
static void (*unlock_hook)(void);
|
||||||
static esp_err_t serial_detach_error;
|
static esp_err_t serial_detach_error;
|
||||||
static unsigned ssl_stop_fail_at;
|
static unsigned ssl_stop_fail_at;
|
||||||
@@ -104,7 +119,7 @@ static bool unregister_fail;
|
|||||||
static bool settings_fail;
|
static bool settings_fail;
|
||||||
static unsigned settings_calls;
|
static unsigned settings_calls;
|
||||||
static unsigned operation_calls, operation_fail_at;
|
static unsigned operation_calls, operation_fail_at;
|
||||||
static const httpd_uri_t *registered[39];
|
static const httpd_uri_t *registered[40];
|
||||||
static char events[128]; static size_t event_length;
|
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 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 SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
|
||||||
@@ -134,6 +149,7 @@ static esp_err_t web_security_replace_reserved(uint32_t token) {
|
|||||||
}
|
}
|
||||||
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
|
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
|
||||||
#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; }
|
#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(web_firmware_update_handler)
|
||||||
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
|
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
|
||||||
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
|
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
|
||||||
HANDLER(traced_admin_ticket_handler) HANDLER(traced_admin_upgrade_handler)
|
HANDLER(traced_admin_ticket_handler) HANDLER(traced_admin_upgrade_handler)
|
||||||
@@ -269,7 +285,7 @@ static bool generation_fail;
|
|||||||
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 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_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 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 void web_cookie_auth_stop(void) { assert(!https_available); 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,
|
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) {
|
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;
|
assert(!locked && auth_live && nc && nk); cert[0] = 1; key[0] = 2; *lc = *lk = 1; return ESP_OK;
|
||||||
@@ -277,7 +293,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
|
|||||||
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
|
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(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
||||||
assert(config->httpd.max_uri_handlers == 39 && config->port_secure == 443);
|
assert(config->httpd.max_uri_handlers == 40 && config->port_secure == 443);
|
||||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||||
assert(config->tls_handshake_timeout_ms == 5000);
|
assert(config->tls_handshake_timeout_ms == 5000);
|
||||||
assert(config->user_cb == tls_session_callback);
|
assert(config->user_cb == tls_session_callback);
|
||||||
@@ -293,7 +309,7 @@ static esp_err_t register_one(httpd_handle_t server) {
|
|||||||
}
|
}
|
||||||
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
|
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||||
if (!strcmp(uri->uri, "/api/settings/serial")) {
|
if (!strcmp(uri->uri, "/api/settings/serial")) {
|
||||||
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 17);
|
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 18);
|
||||||
assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler);
|
assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler);
|
||||||
++settings_calls;
|
++settings_calls;
|
||||||
if (settings_fail) return ESP_ERR_NO_MEM;
|
if (settings_fail) return ESP_ERR_NO_MEM;
|
||||||
@@ -301,11 +317,11 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
|
|||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
|
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
|
||||||
assert(registration_calls >= 16);
|
assert(registration_calls >= 17);
|
||||||
assert(serial_init_error != ESP_OK || serial_live);
|
assert(serial_init_error != ESP_OK || serial_live);
|
||||||
} else assert(registration_calls < 14);
|
} else assert(registration_calls < 15);
|
||||||
esp_err_t error = register_one(s);
|
esp_err_t error = register_one(s);
|
||||||
if (error == ESP_OK) { assert(registered_count < 39); registered[registered_count++] = uri; }
|
if (error == ESP_OK) { assert(registered_count < 40); registered[registered_count++] = uri; }
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||||
@@ -355,7 +371,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
|||||||
}
|
}
|
||||||
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
|
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(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||||
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
assert((registration_calls == 19 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
||||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation") || !strcmp(uri, "/api/settings/lifecycle-operation")) && method == HTTP_GET));
|
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation") || !strcmp(uri, "/api/settings/lifecycle-operation")) && method == HTTP_GET));
|
||||||
++unregister_calls;
|
++unregister_calls;
|
||||||
for (unsigned i = 0; i < registered_count; ++i) {
|
for (unsigned i = 0; i < registered_count; ++i) {
|
||||||
@@ -372,15 +388,15 @@ static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri,
|
|||||||
}
|
}
|
||||||
static esp_err_t httpd_register_err_handler(httpd_handle_t s, httpd_err_code_t code,
|
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)) {
|
esp_err_t (*handler)(httpd_req_t *, httpd_err_code_t)) {
|
||||||
assert(registration_calls == 14 || registration_calls == 15);
|
assert(registration_calls == 15 || registration_calls == 16);
|
||||||
assert((code == 404 || code == 405) && handler == route_error_handler);
|
assert((code == 404 || code == 405) && handler == route_error_handler);
|
||||||
return register_one(s);
|
return register_one(s);
|
||||||
}
|
}
|
||||||
static esp_err_t web_serial_transport_attach_server(httpd_handle_t 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);
|
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 17);
|
||||||
++serial_attaches; serial_live = true; return ESP_OK;
|
++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_init(void) { assert(!locked && auth_live && registration_calls == 19); ++admin_inits; return admin_init_error; }
|
||||||
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
|
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
|
||||||
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
|
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
|
||||||
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
|
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
|
||||||
@@ -414,6 +430,7 @@ static void reset(void) {
|
|||||||
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
|
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
|
||||||
s_generation = 1U; mutex_busy = false; unlock_hook = NULL; serial_detach_error = ESP_OK;
|
s_generation = 1U; mutex_busy = false; unlock_hook = NULL; serial_detach_error = ESP_OK;
|
||||||
ssl_stop_fail_at = 0;
|
ssl_stop_fail_at = 0;
|
||||||
|
https_available = false; https_publications = https_withdrawals = 0;
|
||||||
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
|
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
|
||||||
s_serial_transport_attached = s_admin_transport_owned = false;
|
s_serial_transport_attached = s_admin_transport_owned = false;
|
||||||
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
|
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
|
||||||
@@ -449,6 +466,7 @@ static void start(void) {
|
|||||||
assert(web_server_start() == ESP_OK);
|
assert(web_server_start() == ESP_OK);
|
||||||
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
|
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
|
||||||
assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning && idle_owned);
|
assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning && idle_owned);
|
||||||
|
assert(https_available);
|
||||||
}
|
}
|
||||||
static const httpd_uri_t *route(const char *uri) {
|
static const httpd_uri_t *route(const char *uri) {
|
||||||
const httpd_uri_t *found = NULL;
|
const httpd_uri_t *found = NULL;
|
||||||
@@ -513,6 +531,32 @@ static void other_domains_complete(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
int main(void) {
|
int main(void) {
|
||||||
|
reset();
|
||||||
|
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(!https_available && !https_publications && !https_withdrawals);
|
||||||
|
start();
|
||||||
|
assert(https_publications == 1 && !https_withdrawals);
|
||||||
|
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(stop_server(s_generation + 1, false, false) == ESP_ERR_INVALID_STATE);
|
||||||
|
s_transitioning = true;
|
||||||
|
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
|
||||||
|
s_transitioning = false;
|
||||||
|
assert(https_available && https_publications == 1 && !https_withdrawals);
|
||||||
|
for (unsigned failure = 0; failure < 4; ++failure) {
|
||||||
|
idle_detach_error = failure == 0 ? ESP_ERR_TIMEOUT : ESP_OK;
|
||||||
|
admin_detach_error = failure == 1 ? ESP_ERR_TIMEOUT : ESP_OK;
|
||||||
|
serial_detach_error = failure == 2 ? ESP_FAIL : ESP_OK;
|
||||||
|
ssl_stop_error = failure == 3 ? ESP_FAIL : ESP_OK;
|
||||||
|
assert(web_server_stop() != ESP_OK);
|
||||||
|
assert(!https_available && https_publications == 1 && https_withdrawals == failure + 1);
|
||||||
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && !https_available);
|
||||||
|
}
|
||||||
|
ssl_stop_error = ESP_OK;
|
||||||
|
assert(web_server_stop() == ESP_OK && !https_available && https_withdrawals == 5);
|
||||||
|
fresh_registration(); start();
|
||||||
|
assert(https_publications == 2 && https_withdrawals == 5);
|
||||||
|
assert(web_server_stop() == ESP_OK && !https_available && https_withdrawals == 6);
|
||||||
|
puts("PASS mDNS HTTPS publication under mutex, rejected calls unchanged, withdrawal before cookie stop through all teardown failures/retry/restart");
|
||||||
reset(); mutex_fail = true;
|
reset(); mutex_fail = true;
|
||||||
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
|
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
|
||||||
mutex_fail = false; serial_init_error = ESP_FAIL;
|
mutex_fail = false; serial_init_error = ESP_FAIL;
|
||||||
@@ -531,7 +575,8 @@ int main(void) {
|
|||||||
}
|
}
|
||||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||||
|
|
||||||
reset(); start(); assert(registered_count == 39 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
reset(); start(); assert(registered_count == 40 && registration_calls == 19 && settings_calls == 1 && operation_calls == 2);
|
||||||
|
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
|
||||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||||
@@ -569,23 +614,24 @@ int main(void) {
|
|||||||
assert(admin_detaches == 2 && serial_detaches == 1 && admin_stoppeds == 1 && !s_admin_transport_owned);
|
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");
|
puts("PASS failed SSL stop retains admin ownership; stopped runs only after successful retry");
|
||||||
|
|
||||||
for (unsigned failure = 1; failure <= 16; ++failure) {
|
for (unsigned failure = 1; failure <= 17; ++failure) {
|
||||||
reset(); registration_fail_at = failure;
|
reset(); registration_fail_at = failure;
|
||||||
assert(web_server_start() == ESP_FAIL);
|
assert(web_server_start() == ESP_FAIL);
|
||||||
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
|
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(!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);
|
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
|
||||||
|
assert(!https_available && !https_publications && https_withdrawals == 1);
|
||||||
}
|
}
|
||||||
puts("PASS required registration positions 1..16 fail fatally before transport attachment");
|
puts("PASS required registration positions 1..17 fail fatally before transport attachment");
|
||||||
|
|
||||||
for (unsigned failure = 17; failure <= 18; ++failure) {
|
for (unsigned failure = 18; failure <= 19; ++failure) {
|
||||||
reset(); registration_fail_at = failure;
|
reset(); registration_fail_at = failure;
|
||||||
assert(web_server_start() == ESP_OK && registration_calls == failure);
|
assert(web_server_start() == ESP_OK && registration_calls == failure);
|
||||||
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
|
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
|
||||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
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(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
|
||||||
assert(registered_count == 37 && unregister_calls == failure - 17);
|
assert(registered_count == 38 && unregister_calls == failure - 18);
|
||||||
for (unsigned i = 0; i < registered_count; ++i)
|
for (unsigned i = 0; i < registered_count; ++i)
|
||||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||||
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
||||||
@@ -594,13 +640,13 @@ int main(void) {
|
|||||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||||
assert(!admin_detaches && !admin_stoppeds);
|
assert(!admin_detaches && !admin_stoppeds);
|
||||||
registration_fail_at = 0; fresh_registration(); start();
|
registration_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39 && admin_attaches == 1 && s_counters.starts == 2);
|
assert(registered_count == 40 && admin_attaches == 1 && s_counters.starts == 2);
|
||||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
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");
|
puts("PASS optional positions 18..19 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||||
|
|
||||||
reset(); registration_fail_at = 18; unregister_fail = true;
|
reset(); registration_fail_at = 19; unregister_fail = true;
|
||||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 38);
|
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 39);
|
||||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||||
ticket = route("/api/admin/ws-ticket");
|
ticket = route("/api/admin/ws-ticket");
|
||||||
@@ -612,12 +658,13 @@ int main(void) {
|
|||||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||||
assert(!admin_detaches && !admin_stoppeds);
|
assert(!admin_detaches && !admin_stoppeds);
|
||||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
assert(registered_count == 40 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
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;
|
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||||
assert(web_server_start() == ESP_FAIL && s_server == SERVER && ssl_live);
|
assert(web_server_start() == ESP_FAIL && s_server == SERVER && ssl_live);
|
||||||
assert(!s_admin_transport_owned && !admin_attaches && !auth_live);
|
assert(!s_admin_transport_owned && !admin_attaches && !auth_live);
|
||||||
|
assert(!https_available && !https_publications && https_withdrawals == 1);
|
||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||||
ssl_stop_error = ESP_OK; clear_events();
|
ssl_stop_error = ESP_OK; clear_events();
|
||||||
assert(web_server_stop() == ESP_OK && !strcmp(events, "AH") && !admin_stoppeds);
|
assert(web_server_stop() == ESP_OK && !strcmp(events, "AH") && !admin_stoppeds);
|
||||||
@@ -634,7 +681,7 @@ int main(void) {
|
|||||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||||
reset(); settings_fail = true; start();
|
reset(); settings_fail = true; start();
|
||||||
assert(settings_calls == 1 && registered_count == 38);
|
assert(settings_calls == 1 && registered_count == 39);
|
||||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||||
settings_fail = false; fresh_registration(); start();
|
settings_fail = false; fresh_registration(); start();
|
||||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||||
@@ -642,7 +689,7 @@ int main(void) {
|
|||||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||||
reset(); operation_fail_at = failure; start();
|
reset(); operation_fail_at = failure; start();
|
||||||
assert(registered_count == 37 && operation_calls == failure && unregister_calls == failure - 1);
|
assert(registered_count == 38 && operation_calls == failure && unregister_calls == failure - 1);
|
||||||
assert(auth_live && serial_live && admin_owned);
|
assert(auth_live && serial_live && admin_owned);
|
||||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
@@ -650,25 +697,27 @@ int main(void) {
|
|||||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||||
assert(account_calls == failure && registered_count == (failure == 1 ? 36 : 37));
|
assert(account_calls == failure && registered_count == (failure == 1 ? 37 : 38));
|
||||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
|
||||||
|
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||||
assert(auth_live && serial_live && admin_owned);
|
assert(auth_live && serial_live && admin_owned);
|
||||||
for (unsigned i = 0; i < registered_count; ++i)
|
for (unsigned i = 0; i < registered_count; ++i)
|
||||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39 && account_calls == 3);
|
assert(registered_count == 40 && account_calls == 3);
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||||
assert(registered_count == 38 && auth_live && serial_live && admin_owned);
|
assert(registered_count == 39 && auth_live && serial_live && admin_owned);
|
||||||
for (unsigned i = 0; i < registered_count; ++i)
|
for (unsigned i = 0; i < registered_count; ++i)
|
||||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||||
reset(); generation_fail = true; start();
|
reset(); generation_fail = true; start();
|
||||||
assert(generation_calls == 1 && registered_count == 38 && account_calls == 3);
|
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
|
||||||
|
assert(generation_calls == 1 && registered_count == 39 && account_calls == 3);
|
||||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||||
@@ -680,12 +729,12 @@ int main(void) {
|
|||||||
}
|
}
|
||||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||||
generation_fail = false; fresh_registration(); start();
|
generation_fail = false; fresh_registration(); start();
|
||||||
assert(generation_calls == 2 && registered_count == 39);
|
assert(generation_calls == 2 && registered_count == 40);
|
||||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||||
reset(); keys_fail = true; start();
|
reset(); keys_fail = true; start();
|
||||||
assert(keys_calls == 1 && registered_count == 38 && account_calls == 3 && generation_calls == 1);
|
assert(keys_calls == 1 && registered_count == 39 && account_calls == 3 && generation_calls == 1);
|
||||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||||
@@ -700,7 +749,7 @@ int main(void) {
|
|||||||
}
|
}
|
||||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||||
keys_fail = false; fresh_registration(); start();
|
keys_fail = false; fresh_registration(); start();
|
||||||
assert(keys_calls == 2 && registered_count == 39);
|
assert(keys_calls == 2 && registered_count == 40);
|
||||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
||||||
@@ -725,7 +774,7 @@ int main(void) {
|
|||||||
reset(); network_fail_at = failure; start();
|
reset(); network_fail_at = failure; start();
|
||||||
unsigned failed_route = (failure + 1) / 2;
|
unsigned failed_route = (failure + 1) / 2;
|
||||||
assert(network_calls == failed_route && network_allocations == failure);
|
assert(network_calls == failed_route && network_allocations == failure);
|
||||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||||
assert(!method_route("/api/settings/network-operation", HTTP_GET));
|
assert(!method_route("/api/settings/network-operation", HTTP_GET));
|
||||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||||
@@ -733,13 +782,13 @@ int main(void) {
|
|||||||
other_domains_complete();
|
other_domains_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
network_fail_at = 0; fresh_registration(); start();
|
network_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); network_complete();
|
assert(registered_count == 40); network_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
||||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||||
reset(); network_fail_at = failure; unregister_fail = true; start();
|
reset(); network_fail_at = failure; unregister_fail = true; start();
|
||||||
assert(registered_count == 38 && unregister_calls == 1);
|
assert(registered_count == 39 && unregister_calls == 1);
|
||||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||||
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
|
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
|
||||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||||
@@ -749,7 +798,7 @@ int main(void) {
|
|||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||||
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); network_complete();
|
assert(registered_count == 40); network_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||||
@@ -757,7 +806,7 @@ int main(void) {
|
|||||||
reset(); display_fail_at = failure; start();
|
reset(); display_fail_at = failure; start();
|
||||||
unsigned failed_route = (failure + 1) / 2;
|
unsigned failed_route = (failure + 1) / 2;
|
||||||
assert(display_calls == failed_route && display_allocations == failure);
|
assert(display_calls == failed_route && display_allocations == failure);
|
||||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||||
assert(!method_route("/api/settings/display-operation", HTTP_GET));
|
assert(!method_route("/api/settings/display-operation", HTTP_GET));
|
||||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||||
@@ -765,13 +814,13 @@ int main(void) {
|
|||||||
other_domains_complete(); network_complete();
|
other_domains_complete(); network_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
display_fail_at = 0; fresh_registration(); start();
|
display_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); display_complete();
|
assert(registered_count == 40); display_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
||||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||||
reset(); display_fail_at = failure; unregister_fail = true; start();
|
reset(); display_fail_at = failure; unregister_fail = true; start();
|
||||||
assert(registered_count == 38 && unregister_calls == 1);
|
assert(registered_count == 39 && unregister_calls == 1);
|
||||||
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
||||||
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
|
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
|
||||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||||
@@ -781,7 +830,7 @@ int main(void) {
|
|||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||||
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); display_complete();
|
assert(registered_count == 40); display_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
|
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||||
@@ -789,7 +838,7 @@ int main(void) {
|
|||||||
reset(); broker_fail_at = failure; start();
|
reset(); broker_fail_at = failure; start();
|
||||||
unsigned failed_route = (failure + 1) / 2;
|
unsigned failed_route = (failure + 1) / 2;
|
||||||
assert(broker_calls == failed_route && broker_allocations == failure);
|
assert(broker_calls == failed_route && broker_allocations == failure);
|
||||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||||
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
|
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
|
||||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||||
@@ -797,13 +846,13 @@ int main(void) {
|
|||||||
other_domains_complete(); network_complete(); display_complete();
|
other_domains_complete(); network_complete(); display_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
broker_fail_at = 0; fresh_registration(); start();
|
broker_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); broker_complete();
|
assert(registered_count == 40); broker_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
|
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
|
||||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||||
reset(); broker_fail_at = failure; unregister_fail = true; start();
|
reset(); broker_fail_at = failure; unregister_fail = true; start();
|
||||||
assert(registered_count == 38 && unregister_calls == 1);
|
assert(registered_count == 39 && unregister_calls == 1);
|
||||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||||
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
|
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
|
||||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||||
@@ -813,7 +862,7 @@ int main(void) {
|
|||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||||
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); broker_complete();
|
assert(registered_count == 40); broker_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
|
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||||
@@ -821,7 +870,7 @@ int main(void) {
|
|||||||
reset(); ssh_fail_at = failure; start();
|
reset(); ssh_fail_at = failure; start();
|
||||||
unsigned failed_route = (failure + 1) / 2;
|
unsigned failed_route = (failure + 1) / 2;
|
||||||
assert(ssh_calls == failed_route && ssh_allocations == failure);
|
assert(ssh_calls == failed_route && ssh_allocations == failure);
|
||||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||||
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
|
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
|
||||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||||
@@ -829,13 +878,13 @@ int main(void) {
|
|||||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
ssh_fail_at = 0; fresh_registration(); start();
|
ssh_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); ssh_complete();
|
assert(registered_count == 40); ssh_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
|
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
|
||||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||||
reset(); ssh_fail_at = failure; unregister_fail = true; start();
|
reset(); ssh_fail_at = failure; unregister_fail = true; start();
|
||||||
assert(registered_count == 38 && unregister_calls == 1);
|
assert(registered_count == 39 && unregister_calls == 1);
|
||||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||||
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
|
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
|
||||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||||
@@ -845,7 +894,7 @@ int main(void) {
|
|||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||||
unregister_fail = false; ssh_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; ssh_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 39); ssh_complete();
|
assert(registered_count == 40); ssh_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
|
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||||
@@ -861,7 +910,7 @@ int main(void) {
|
|||||||
puts("PASS every other settings route failure leaves the complete Network domain available");
|
puts("PASS every other settings route failure leaves the complete Network domain available");
|
||||||
management_tests();
|
management_tests();
|
||||||
pipeline_tests();
|
pipeline_tests();
|
||||||
puts("44 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups)");
|
puts("45 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups, 1 mDNS availability group)");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
'''
|
'''
|
||||||
@@ -927,7 +976,7 @@ static void management_tests(void) {
|
|||||||
assert(reserved_gaps == 1 && !unlock_hook && !strcmp(events, "ADSHR"));
|
assert(reserved_gaps == 1 && !unlock_hook && !strcmp(events, "ADSHR"));
|
||||||
assert(s_generation == original + 2 && s_server == SERVER && !s_transitioning);
|
assert(s_generation == original + 2 && s_server == SERVER && !s_transitioning);
|
||||||
assert(auth_live && ssl_live && admin_owned && serial_live && idle_owned);
|
assert(auth_live && ssl_live && admin_owned && serial_live && idle_owned);
|
||||||
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 39);
|
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 40);
|
||||||
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
|
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
|
||||||
assert(web_server_stop_current(s_generation) == ESP_OK);
|
assert(web_server_stop_current(s_generation) == ESP_OK);
|
||||||
puts("PASS conditional restart reserves stop-to-start gap against canonical and conditional callers");
|
puts("PASS conditional restart reserves stop-to-start gap against canonical and conditional callers");
|
||||||
@@ -1044,6 +1093,60 @@ with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as direct
|
|||||||
subprocess.run([str(executable)], check=True, timeout=15)
|
subprocess.run([str(executable)], check=True, timeout=15)
|
||||||
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
|
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
|
||||||
|
|
||||||
|
# Keep the production Wi-Fi JSON format and all its arguments together, without
|
||||||
|
# doubling every unrelated /api/status subsystem or claiming full HTTP coverage.
|
||||||
|
status = function('status_handler')
|
||||||
|
wifi_format = status[status.index(' " \\"wifi\\":'):status.index(' " \\"serial\\":')]
|
||||||
|
wifi_arguments = status[status.index(' wifi_available ? "true"'):status.index(' serial_config_available ?')].rstrip().removesuffix(',')
|
||||||
|
wifi_unit = r'''
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
static const char *wifi_manager_state_to_string(int state) {
|
||||||
|
assert(state == 1); return "connected";
|
||||||
|
}
|
||||||
|
int main(void) {
|
||||||
|
struct { bool ipv6_linklocal, ipv6_routable, ap_running;
|
||||||
|
int state, sta_rssi; unsigned sta_channel, ap_client_count; } wifi = {
|
||||||
|
.state = 1, .sta_rssi = -42, .sta_channel = 6,
|
||||||
|
.ap_running = true, .ap_client_count = 2 };
|
||||||
|
const char *ipv4 = "192.0.2.1";
|
||||||
|
char response[512];
|
||||||
|
for (unsigned available = 0; available < 2; ++available)
|
||||||
|
for (unsigned flags = 0; flags < 4; ++flags) {
|
||||||
|
bool wifi_available = available;
|
||||||
|
wifi.ipv6_linklocal = flags & 1;
|
||||||
|
wifi.ipv6_routable = flags & 2;
|
||||||
|
int written = snprintf(response, sizeof(response),
|
||||||
|
''' + wifi_format + ',\n' + wifi_arguments + r''');
|
||||||
|
assert(written > 0 && (size_t)written < sizeof(response));
|
||||||
|
fputs(response, stdout);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
with tempfile.TemporaryDirectory(prefix='web-status-wifi-') as directory:
|
||||||
|
temporary = Path(directory)
|
||||||
|
(temporary / 'status.c').write_text(wifi_unit)
|
||||||
|
executable = temporary / 'status'
|
||||||
|
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
|
||||||
|
str(temporary / 'status.c'), '-o', str(executable)], check=True, timeout=30)
|
||||||
|
result = subprocess.run([str(executable)], check=True, capture_output=True, text=True, timeout=15)
|
||||||
|
rows = result.stdout.splitlines()
|
||||||
|
assert len(rows) == 8
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
wifi = json.loads('{' + row.rstrip().removesuffix(',') + '}')['wifi']
|
||||||
|
available, flags = divmod(index, 4)
|
||||||
|
assert wifi == {
|
||||||
|
'available': bool(available), 'state': 'connected' if available else 'unavailable',
|
||||||
|
'sta_ipv4': '192.0.2.1', 'ipv6_linklocal': bool(available and flags & 1),
|
||||||
|
'ipv6_routable': bool(available and flags & 2), 'rssi': -42 if available else 0,
|
||||||
|
'channel': 6 if available else 0, 'ap_running': bool(available),
|
||||||
|
'ap_clients': 2 if available else 0,
|
||||||
|
}
|
||||||
|
assert type(wifi['ipv6_linklocal']) is bool and type(wifi['ipv6_routable']) is bool
|
||||||
|
print('PASS production /api/status Wi-Fi JSON projection: eight availability/IPv6 combinations, native booleans and unavailable masking')
|
||||||
|
|
||||||
# Second executable links the same production server functions to the COMPLETE
|
# Second executable links the same production server functions to the COMPLETE
|
||||||
# security implementation and real mbedTLS. Only NVS/HTTPD/scheduler are doubles.
|
# security implementation and real mbedTLS. Only NVS/HTTPD/scheduler are doubles.
|
||||||
import ast
|
import ast
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ int main(void) {
|
|||||||
assert(web_stops == 1 && !reboots && !closes);
|
assert(web_stops == 1 && !reboots && !closes);
|
||||||
web_stop_result = ESP_OK;
|
web_stop_result = ESP_OK;
|
||||||
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_OK && web_stops == 2);
|
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_OK && web_stops == 2);
|
||||||
|
reboot_busy = true;
|
||||||
|
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE && !reboots);
|
||||||
|
reboot_busy = false;
|
||||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_OK && reboots == 1);
|
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_OK && reboots == 1);
|
||||||
httpd_owner = true;
|
httpd_owner = true;
|
||||||
ok("HTTPS stop/reboot marshal to lifecycle APIs outside HTTPD/locks; stop failure propagates");
|
ok("HTTPS stop/reboot marshal to lifecycle APIs outside HTTPD/locks; stop failure propagates");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ parser substitutes, network access, or persistent build artifacts are used.
|
|||||||
Compilation errors and test failures produce nonzero exit status.
|
Compilation errors and test failures produce nonzero exit status.
|
||||||
|
|
||||||
Tables cover DNS/IPv4 origin canonicalization, case folding and optional `:443`,
|
Tables cover DNS/IPv4 origin canonicalization, case folding and optional `:443`,
|
||||||
malformed authorities/origins and unsupported IPv6; selected cookie presence,
|
bracketed IPv6 canonicalization and malformed authorities/origins; selected cookie presence,
|
||||||
uniqueness, exact lowercase 64-digit hex and surrounding cookie syntax; strict
|
uniqueness, exact lowercase 64-digit hex and surrounding cookie syntax; strict
|
||||||
login JSON, both field orders, escapes, Unicode/surrogate pairs, invalid UTF-8,
|
login JSON, both field orders, escapes, Unicode/surrogate pairs, invalid UTF-8,
|
||||||
NUL, duplicate/unknown fields, truncation and byte limits (512-byte body,
|
NUL, duplicate/unknown fields, truncation and byte limits (512-byte body,
|
||||||
@@ -31,7 +31,22 @@ bytes. Successful results check canonical/decoded bytes and termination.
|
|||||||
- This is a focused parser contract suite, not HTTP integration, authorization,
|
- This is a focused parser contract suite, not HTTP integration, authorization,
|
||||||
CSRF/session, duplicate HTTP header-line, TLS, credential-policy, or hardware
|
CSRF/session, duplicate HTTP header-line, TLS, credential-policy, or hardware
|
||||||
testing. Empty credentials are syntactically valid; database policy is separate.
|
testing. Empty credentials are syntactically valid; database policy is separate.
|
||||||
- IPv6 is intentionally rejected, not normalized or supported.
|
- IPv6 tests check expanded/compressed/case/default-port equivalence, longest
|
||||||
|
zero runs and ties, dotted tails, mapped-address separation from IPv4,
|
||||||
|
DNS separation, mismatched addresses, zones, malformed groups/brackets/ports,
|
||||||
|
userinfo, suffixes, controls and exact-span output wiping.
|
||||||
|
- Canonical IPv6 uses lowercase hex tails even for mapped addresses. No DNS
|
||||||
|
resolution, scope inference or network reachability is involved.
|
||||||
|
- To additionally audit the installed ESP-IDF conversion implementation, run:
|
||||||
|
```sh
|
||||||
|
WEB_AUTH_LWIP_SOURCE=/home/mscholz/.platformio/packages/framework-espidf/components/lwip/lwip/src/core/ipv6/ip6_addr.c python3 tests/web_auth_parse/run.py
|
||||||
|
```
|
||||||
|
Adjust the path for your installation. This compiles the actual extracted
|
||||||
|
`ip6addr_aton` body with host type/byte-order adapters and the same temporary
|
||||||
|
address-copy boundary as `lwip_inet_pton`. IPv4-tail and scope branches are
|
||||||
|
disabled: production validates/replaces dotted tails before conversion and
|
||||||
|
rejects zones. The production formatter is exercised, not substituted.
|
||||||
|
This is not a target build or full lwIP networking test.
|
||||||
- Python mirrors the public C struct and capacities; interface changes must
|
- Python mirrors the public C struct and capacities; interface changes must
|
||||||
update these tests. Shared-library loading assumes a Unix-like host/compiler.
|
update these tests. Shared-library loading assumes a Unix-like host/compiler.
|
||||||
- Tables are not exhaustive fuzzing, memory-safety instrumentation, or proof of
|
- Tables are not exhaustive fuzzing, memory-safety instrumentation, or proof of
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"""Dependency-free contract tests against the production parser, not a model."""
|
"""Dependency-free contract tests against the production parser, not a model."""
|
||||||
import ctypes as C
|
import ctypes as C
|
||||||
import json
|
import json
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -33,6 +35,40 @@ def main():
|
|||||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||||
"-shared", "-fPIC", "-I", str(ROOT / "src"),
|
"-shared", "-fPIC", "-I", str(ROOT / "src"),
|
||||||
str(ROOT / "src/web_auth_parse.c"), "-o", str(library)], check=True)
|
str(ROOT / "src/web_auth_parse.c"), "-o", str(library)], check=True)
|
||||||
|
# Optional audit uses installed target conversion code, not a mocked
|
||||||
|
# inet_pton that merely returns success or delegates IPv6 to host libc.
|
||||||
|
sdk = os.environ.get("WEB_AUTH_LWIP_SOURCE")
|
||||||
|
if sdk:
|
||||||
|
source = Path(sdk).read_text()
|
||||||
|
start = source.index("int\nip6addr_aton(")
|
||||||
|
end = source.index("\n/**", start)
|
||||||
|
shim = Path(temporary) / "lwip.c"
|
||||||
|
shim.write_text('''#include <arpa/inet.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <string.h>
|
||||||
|
typedef uint32_t u32_t;
|
||||||
|
typedef struct { uint32_t addr[4]; } ip6_addr_t;
|
||||||
|
#define LWIP_IPV4 0
|
||||||
|
#define LWIP_IPV6_SCOPES 0
|
||||||
|
#define lwip_htonl htonl
|
||||||
|
#define lwip_isxdigit isxdigit
|
||||||
|
#define lwip_isdigit isdigit
|
||||||
|
#define lwip_islower islower
|
||||||
|
#define ip6_addr_clear_zone(a) ((void)(a))
|
||||||
|
''' + source[start:end] + '''
|
||||||
|
int audit_inet_pton(int af, const char *src, void *dst) {
|
||||||
|
ip6_addr_t addr;
|
||||||
|
if (af != AF_INET6) return -1;
|
||||||
|
int result = ip6addr_aton(src, &addr);
|
||||||
|
if (result) memcpy(dst, addr.addr, sizeof(addr.addr));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||||
|
"-shared", "-fPIC", "-Dinet_pton=audit_inet_pton",
|
||||||
|
"-I", str(ROOT / "src"), str(ROOT / "src/web_auth_parse.c"),
|
||||||
|
str(shim), "-o", str(library)], check=True)
|
||||||
api = C.CDLL(str(library))
|
api = C.CDLL(str(library))
|
||||||
api.web_auth_parse_origin.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_void_p]
|
api.web_auth_parse_origin.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_void_p]
|
||||||
api.web_auth_parse_cookie.argtypes = [C.c_void_p, C.c_size_t, C.c_char_p, C.c_void_p]
|
api.web_auth_parse_cookie.argtypes = [C.c_void_p, C.c_size_t, C.c_char_p, C.c_void_p]
|
||||||
@@ -52,7 +88,7 @@ def main():
|
|||||||
if not result and raw != bytes(len(raw)):
|
if not result and raw != bytes(len(raw)):
|
||||||
failures.append(f"{kind}: {label}: failure did not wipe every output byte")
|
failures.append(f"{kind}: {label}: failure did not wipe every output byte")
|
||||||
if result and expected is not None and extract(output) != expected:
|
if result and expected is not None and extract(output) != expected:
|
||||||
failures.append(f"{kind}: {label}: incorrect decoded/canonical output")
|
failures.append(f"{kind}: {label}: decoded/canonical {extract(output)!r}, expected {expected!r}")
|
||||||
|
|
||||||
origins = [(b"EXAMPLE.Com", b"https://example.com", b"https://example.com"),
|
origins = [(b"EXAMPLE.Com", b"https://example.com", b"https://example.com"),
|
||||||
(b"a-b.local", b"https://A-B.LOCAL", b"https://a-b.local"),
|
(b"a-b.local", b"https://A-B.LOCAL", b"https://a-b.local"),
|
||||||
@@ -65,8 +101,7 @@ def main():
|
|||||||
b"https://" + host.lower()))
|
b"https://" + host.lower()))
|
||||||
bad_hosts = [b"", b" ", b"example.com ", b" example.com", b"a..b", b".a", b"a.",
|
bad_hosts = [b"", b" ", b"example.com ", b" example.com", b"a..b", b".a", b"a.",
|
||||||
b"-a", b"a-", b"a_b", b"a/b", b"a?b", b"a#b", b"u@a", b"a,b",
|
b"-a", b"a-", b"a_b", b"a/b", b"a?b", b"a#b", b"u@a", b"a,b",
|
||||||
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"[::1]", b"::1",
|
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"::1", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
|
||||||
b"[::1]:443", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
|
|
||||||
b"a:443:443", b"a" * 64 + b".com", b"a" * 130]
|
b"a:443:443", b"a" * 64 + b".com", b"a" * 130]
|
||||||
origins += [(h, b"https://" + h, None) for h in bad_hosts]
|
origins += [(h, b"https://" + h, None) for h in bad_hosts]
|
||||||
origins += [(b"example.com", o, None) for o in
|
origins += [(b"example.com", o, None) for o in
|
||||||
@@ -77,6 +112,39 @@ def main():
|
|||||||
b"https://example.com\0", b"https://example.com\r\n", b"https://[::1]",
|
b"https://example.com\0", b"https://example.com\r\n", b"https://[::1]",
|
||||||
b"https://example.com https://example.com", b"https:///example.com")]
|
b"https://example.com https://example.com", b"https:///example.com")]
|
||||||
origins += [(None, b"https://example.com", None)]
|
origins += [(None, b"https://example.com", None)]
|
||||||
|
literals = ["::", "::1", "2001:DB8::ABCD", "2001:0:0:1:0:0:0:1",
|
||||||
|
"1:0:0:2:0:0:3:4", "1:2:3:4:5:6:0:8", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
|
||||||
|
"::ffff:192.0.2.1", "::192.0.2.1", "2001:db8:1:2:3:4:192.0.2.1"]
|
||||||
|
for literal in literals:
|
||||||
|
address = ipaddress.IPv6Address(literal)
|
||||||
|
normalized = address.compressed
|
||||||
|
if address.ipv4_mapped:
|
||||||
|
value = int(address.ipv4_mapped)
|
||||||
|
normalized = f"::ffff:{value >> 16:x}:{value & 65535:x}"
|
||||||
|
canonical = b"[" + normalized.encode() + b"]"
|
||||||
|
for h in (literal.encode(), address.exploded.upper().encode()):
|
||||||
|
for hp in (b"", b":443"):
|
||||||
|
for op in (b"", b":443"):
|
||||||
|
origins.append((b"[" + h + b"]" + hp, b"https://" + canonical + op,
|
||||||
|
b"https://" + canonical))
|
||||||
|
invalid = [b"[]", b"[1]", b"[1:2:3:4:5:6:7]", b"[1:2:3:4:5:6:7:8:9]",
|
||||||
|
b"[1:2:3:4:5:6:7:8::]", b"[:::]", b"[1::2::3]", b"[:1]", b"[1:]",
|
||||||
|
b"[12345::]", b"[gg::]", b"[::1", b"::1]", b"[[::1]]",
|
||||||
|
b"[::ffff:192.00.2.1]", b"[::ffff:256.0.2.1]", b"[::ffff:192.2.1]",
|
||||||
|
b"[::ffff:0xc0.0.2.1]", b"[::ffff:192.0.2.1:1]",
|
||||||
|
b"[fe80::1%eth0]", b"[fe80::1%25eth0]", b"user@[::1]",
|
||||||
|
b"[::1]:80", b"[::1]:0443", b"[::1]:+443", b"[::1]:443:443",
|
||||||
|
b"[::1]:", b"[::1]/", b"[::1]?x", b"[::1]#x", b"[::1]@host"]
|
||||||
|
for c in list(range(33)) + [127, 128, 255]:
|
||||||
|
invalid.extend((b"[::1" + bytes([c]) + b"]", b"[::1]" + bytes([c])))
|
||||||
|
for h in invalid:
|
||||||
|
origins.extend(((h, b"https://" + h, None),
|
||||||
|
(h, b"https://[::1]", None),
|
||||||
|
(b"[::1]", b"https://" + h, None)))
|
||||||
|
for h, o in ((b"[::1]", b"[::2]"), (b"[::1]", b"localhost"),
|
||||||
|
(b"[::ffff:192.0.2.1]", b"192.0.2.1"),
|
||||||
|
(b"[::192.0.2.1]", b"[::ffff:192.0.2.1]")):
|
||||||
|
origins.extend(((h, b"https://" + o, None), (o, b"https://" + h, None)))
|
||||||
# 128 canonical bytes fit with the terminator; 129 do not.
|
# 128 canonical bytes fit with the terminator; 129 do not.
|
||||||
for n in (56, 57):
|
for n in (56, 57):
|
||||||
host = b"a" * 63 + b"." + b"b" * n
|
host = b"a" * 63 + b"." + b"b" * n
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Coverage includes challenge reuse/consumption/expiry, capacities without evictio
|
|||||||
|
|
||||||
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency require real-target evidence beyond this harness; M1 and the retained Phase 8D scope have since been accepted. No sanitizer or runtime memory-reserve result is implied.
|
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency require real-target evidence beyond this harness; M1 and the retained Phase 8D scope have since been accepted. No sanitizer or runtime memory-reserve result is implied.
|
||||||
|
|
||||||
See [authentication contracts](../../docs/web_administration.md#authentication-and-admission), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
See [authentication contracts](../../docs/web_administration.md#authentication-and-admission), [acceptance evidence and limits](../../docs/roadmap.md#phase8-acceptance-evidence), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
||||||
|
|
||||||
## Accounts (8D.10)
|
## Accounts (8D.10)
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ The second command separately exercises production conditional database mutation
|
|||||||
and zero-wait list bodies with NVS/RTOS doubles, including last-admin protection,
|
and zero-wait list bodies with NVS/RTOS doubles, including last-admin protection,
|
||||||
target generation/recreation checks and commit-failure cleanup. It retains the
|
target generation/recreation checks and commit-failure cleanup. It retains the
|
||||||
canonical CLI account tests. These are not end-to-end RTOS/flash/TLS tests.
|
canonical CLI account tests. These are not end-to-end RTOS/flash/TLS tests.
|
||||||
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/web_administration_acceptance.md). Timer doubles do not prove hard cleanup latency.
|
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence). Timer doubles do not prove hard cleanup latency.
|
||||||
|
|
||||||
## Read-only Serial Settings
|
## Read-only Serial Settings
|
||||||
|
|
||||||
@@ -62,4 +62,4 @@ registration. No SDK files are modified. Lifecycle registration/optional failure
|
|||||||
orchestration is separately tested by `tests/web_admin_transport/server_lifecycle.py`.
|
orchestration is separately tested by `tests/web_admin_transport/server_lifecycle.py`.
|
||||||
|
|
||||||
This does not run the full serial task/UART driver, TLS/network dispatcher or a real
|
This does not run the full serial task/UART driver, TLS/network dispatcher or a real
|
||||||
browser. See [typed Settings contracts](../../docs/web_administration.md#typed-settings-api-and-operation-lifetime) and [Serial semantics](../../docs/web_administration.md#serial-and-display). [Acceptance evidence](../../docs/web_administration_acceptance.md) records the 8D.22 signoff without inferring individual target comparisons or runtime memory/stack margins.
|
browser. See [typed Settings contracts](../../docs/web_administration.md#typed-settings-api-and-operation-lifetime) and [Serial semantics](../../docs/web_administration.md#serial-and-display). [Acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence) records the 8D.22 signoff without inferring individual target comparisons or runtime memory/stack margins.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include "admin_ssh_console.h"
|
#include "admin_ssh_console.h"
|
||||||
#include "web_admin_tickets.h"
|
#include "web_admin_tickets.h"
|
||||||
|
#include "web_firmware_update.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
#include "esp_heap_caps.h"
|
#include "esp_heap_caps.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
@@ -14,7 +15,13 @@ static void (*timer_poll)(void *), (*pending_poll)(void *);
|
|||||||
static void *pending_argument;
|
static void *pending_argument;
|
||||||
static httpd_req_t connected;
|
static httpd_req_t connected;
|
||||||
static unsigned admin_closes;
|
static unsigned admin_closes;
|
||||||
/* These endpoint tests never dispatch lifecycle commands. */
|
static bool reboot_reservation_expected;
|
||||||
|
esp_err_t web_firmware_update_reserve_reboot(void) {
|
||||||
|
assert(reboot_reservation_expected);
|
||||||
|
reboot_reservation_expected = false;
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
/* A rejected reboot reservation must never reach reset or other lifecycle effects. */
|
||||||
void esp_restart(void) { assert(false); }
|
void esp_restart(void) { assert(false); }
|
||||||
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
|
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
|
||||||
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||||
@@ -100,6 +107,23 @@ static void admin_tests(void) {
|
|||||||
user_principal_t administrator = alice; administrator.role = USER_ROLE_ADMIN;
|
user_principal_t administrator = alice; administrator.role = USER_ROLE_ADMIN;
|
||||||
issued_t user = mint(&bob), admin = mint(&administrator), other = mint(&administrator);
|
issued_t user = mint(&bob), admin = mint(&administrator), other = mint(&administrator);
|
||||||
unsigned before = upgrades;
|
unsigned before = upgrades;
|
||||||
|
issued_t ipv6_admin;
|
||||||
|
const char *ipv6_origin = "https://[2001:db8::1]";
|
||||||
|
assert(web_session_store_issue(&administrator, ipv6_origin, strlen(ipv6_origin),
|
||||||
|
ipv6_admin.token, &ipv6_admin.view) == ESP_OK);
|
||||||
|
for (unsigned mode = 0; mode < 3; ++mode) {
|
||||||
|
begin(WEB_ADMIN_TICKET_URI, HTTP_POST, NULL);
|
||||||
|
add("Host", mode == 2 ? "[2001:db8::2]" : "[2001:0db8:0:0:0:0:0:1]:443");
|
||||||
|
add("Origin", mode == 1 ? "https://[2001:db8::1]" : "https://[2001:db8::2]");
|
||||||
|
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", ipv6_admin.token);
|
||||||
|
add("Cookie", cookies); add("X-CSRF-Token", ipv6_admin.view.csrf);
|
||||||
|
assert(web_admin_transport_ticket_handler(&req) == ESP_OK);
|
||||||
|
assert(!strcmp(response_status, mode == 1 ? "200 OK" : mode == 2 ? "401 Unauthorized" : "403 Forbidden"));
|
||||||
|
assert(upgrades == before);
|
||||||
|
}
|
||||||
|
web_session_store_invalidate(ipv6_admin.view.id);
|
||||||
|
web_admin_tickets_revoke(0, NULL, 0);
|
||||||
|
puts("PASS: admin ticket IPv6 canonical Origin accepted, cross-address Origin rejected without upgrade");
|
||||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||||
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin,
|
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin,
|
||||||
WEB_ADMIN_TICKET_URI, true, mode != 2, mode != 3);
|
WEB_ADMIN_TICKET_URI, true, mode != 2, mode != 3);
|
||||||
@@ -131,6 +155,11 @@ static void admin_tests(void) {
|
|||||||
assert(web_admin_transport_upgrade_handler(&req) == ESP_OK && upgrades == ++before);
|
assert(web_admin_transport_upgrade_handler(&req) == ESP_OK && upgrades == ++before);
|
||||||
connected = req; assert(console_active && admin_owner->is_current(&admin_token, &administrator));
|
connected = req; assert(console_active && admin_owner->is_current(&admin_token, &administrator));
|
||||||
if (mode == 0) {
|
if (mode == 0) {
|
||||||
|
reboot_reservation_expected = true;
|
||||||
|
assert(admin_owner->perform(&admin_token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(!reboot_reservation_expected && console_active && !admin_closes);
|
||||||
|
assert(admin_owner->is_current(&admin_token, &administrator));
|
||||||
|
puts("PASS: live admin deferred reboot propagates firmware reservation refusal without reset or session closure");
|
||||||
admin_request(&admin, "/api/logout", true, true, true); expect("204 No Content");
|
admin_request(&admin, "/api/logout", true, true, true); expect("204 No Content");
|
||||||
assert(!console_active); present(&other);
|
assert(!console_active); present(&other);
|
||||||
} else if (mode == 1) stale_user = administrator.user_id;
|
} else if (mode == 1) stale_user = administrator.user_id;
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ static unsigned mutations, projections, timer_creates, timer_starts;
|
|||||||
static uint32_t queued;
|
static uint32_t queued;
|
||||||
static esp_err_t owner_error, mdns_queue_error;
|
static esp_err_t owner_error, mdns_queue_error;
|
||||||
static bool stored_mdns = true;
|
static bool stored_mdns = true;
|
||||||
|
static bool snapshot_ipv6_case, snapshot_ipv4, snapshot_linklocal, snapshot_routable;
|
||||||
|
static uint8_t snapshot_ipv6_count;
|
||||||
|
static const uint8_t snapshot_ipv6_bytes[3][16] = {
|
||||||
|
{0xfe,0x80,0,0,0,0,0,0,0xea,0x3d,0xc1,0xff,0xfe,0xfa,0x70,0x58},
|
||||||
|
{0xfd,0x39,0xb3,0x78,0x78,0xf2,0xc7,0x15,0xea,0x3d,0xc1,0xff,0xfe,0xfa,0x70,0x58},
|
||||||
|
{0x20,0x01,0x0d,0xb8,0,1,0,2,0,3,0,4,0,5,0,6},
|
||||||
|
};
|
||||||
static void (*timer_callback)(void *);
|
static void (*timer_callback)(void *);
|
||||||
static void (*owner_hook)(void);
|
static void (*owner_hook)(void);
|
||||||
static void (*queue_hook)(void);
|
static void (*queue_hook)(void);
|
||||||
@@ -65,8 +72,19 @@ esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *out) {
|
|||||||
out->profiles[i].ssid_len=32; memset(out->profiles[i].ssid,0xff,32);
|
out->profiles[i].ssid_len=32; memset(out->profiles[i].ssid,0xff,32);
|
||||||
out->profiles[i].priority=255; out->profiles[i].security=WIFI_CONFIG_SECURITY_MIXED;
|
out->profiles[i].priority=255; out->profiles[i].security=WIFI_CONFIG_SECURITY_MIXED;
|
||||||
}
|
}
|
||||||
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
|
out->runtime.ipv6_count=snapshot_ipv6_count;
|
||||||
|
for(unsigned i=0;i<snapshot_ipv6_count && i<3;++i)
|
||||||
|
memcpy(out->runtime.ipv6_addresses[i].addr,snapshot_ipv6_bytes[i],16);
|
||||||
|
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
|
||||||
memset(&out->runtime.ip,255,4); out->runtime.ap_client_count=255;
|
memset(&out->runtime.ip,255,4); out->runtime.ap_client_count=255;
|
||||||
|
if(snapshot_ipv6_case) {
|
||||||
|
const uint8_t ip[4]={192,0,2,8};
|
||||||
|
out->runtime.started=true; out->runtime.state=WIFI_MANAGER_STATE_ONLINE;
|
||||||
|
out->runtime.active_profile=0; out->runtime.ip=0;
|
||||||
|
if(snapshot_ipv4) memcpy(&out->runtime.ip,ip,sizeof(ip));
|
||||||
|
out->runtime.ipv6_linklocal=snapshot_linklocal;
|
||||||
|
out->runtime.ipv6_routable=snapshot_routable;
|
||||||
|
}
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
|
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
|
||||||
@@ -75,7 +93,7 @@ esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
|
|||||||
memset(out->suffix,'s',55); strcpy(out->hostname,"sak-"); memset(out->hostname+4,'s',55);
|
memset(out->suffix,'s',55); strcpy(out->hostname,"sak-"); memset(out->hostname+4,'s',55);
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { (void)state; return "waiting-ip"; }
|
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { return state==WIFI_MANAGER_STATE_ONLINE ? "online" : "waiting-ip"; }
|
||||||
static void network_begin(const issued_t *identity,const char *body) {
|
static void network_begin(const issued_t *identity,const char *body) {
|
||||||
begin("/api/settings/network-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
|
begin("/api/settings/network-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
|
||||||
if(body) add("Content-Type","application/json");
|
if(body) add("Content-Type","application/json");
|
||||||
@@ -117,8 +135,35 @@ static void network_settings_tests(void) {
|
|||||||
network_begin(&user,NULL); network_expect("403 Forbidden",false);
|
network_begin(&user,NULL); network_expect("403 Forbidden",false);
|
||||||
network_begin(&user,patch_body); network_expect("403 Forbidden",false);
|
network_begin(&user,patch_body); network_expect("403 Forbidden",false);
|
||||||
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
|
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
|
||||||
assert(strlen(output)<2048 && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
|
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
|
||||||
printf("PASS Network maximum escaped snapshot: %zu bytes, no secret fields\n",strlen(output));
|
assert(strstr(output,"\"ipv6_addresses\":[]"));
|
||||||
|
assert(strstr(output,"\"ipv6_linklocal\":false,\"ipv6_routable\":false"));
|
||||||
|
snapshot_ipv6_case=true;
|
||||||
|
for(unsigned flags=0;flags<8;++flags) {
|
||||||
|
snapshot_ipv4=(flags&4)!=0; snapshot_linklocal=(flags&1)!=0; snapshot_routable=(flags&2)!=0;
|
||||||
|
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
|
||||||
|
assert(strstr(output,"\"state\":\"online\""));
|
||||||
|
assert(strstr(output,snapshot_ipv4 ? "\"ip\":\"192.0.2.8\"" : "\"ip\":\"0.0.0.0\""));
|
||||||
|
assert(strstr(output,snapshot_linklocal ? "\"ipv6_linklocal\":true" : "\"ipv6_linklocal\":false"));
|
||||||
|
assert(strstr(output,snapshot_routable ? "\"ipv6_routable\":true" : "\"ipv6_routable\":false"));
|
||||||
|
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX);
|
||||||
|
}
|
||||||
|
snapshot_ipv6_case=false;
|
||||||
|
for(snapshot_ipv6_count=1;snapshot_ipv6_count<=3;++snapshot_ipv6_count) {
|
||||||
|
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
|
||||||
|
assert(strstr(output,"\"ipv6_addresses\":[\"fe80:0000:0000:0000:ea3d:c1ff:fefa:7058\""));
|
||||||
|
assert((strstr(output,"fd39:b378:78f2:c715:ea3d:c1ff:fefa:7058")!=NULL)==(snapshot_ipv6_count>=2));
|
||||||
|
assert((strstr(output,"2001:0db8:0001:0002:0003:0004:0005:0006")!=NULL)==(snapshot_ipv6_count==3));
|
||||||
|
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX);
|
||||||
|
}
|
||||||
|
printf("PASS Network maximum escaped SSIDs plus three IPv6 addresses: %zu bytes, no secret fields\n",strlen(output));
|
||||||
|
assert(strlen(output)>2048);
|
||||||
|
/* Corrupt owner counts fail closed instead of reading beyond the bounded copy. */
|
||||||
|
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("503 Service Unavailable",true);
|
||||||
|
snapshot_ipv6_count=0;
|
||||||
|
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
|
||||||
|
assert(strstr(output,"\"ipv6_addresses\":[]") && !strstr(output,"fd39:"));
|
||||||
|
puts("PASS Network IPv6-only/dual-stack: availability flags, bounded network-order address list and stale clearing, no writes");
|
||||||
snapshot_fail=true; network_begin(&admin,NULL); network_expect("503 Service Unavailable",true); snapshot_fail=false;
|
snapshot_fail=true; network_begin(&admin,NULL); network_expect("503 Service Unavailable",true); snapshot_fail=false;
|
||||||
network_begin(&user,NULL); network_expect("403 Forbidden",true);
|
network_begin(&user,NULL); network_expect("403 Forbidden",true);
|
||||||
network_begin(&admin,NULL); req.uri="/api/settings/network?secret=x"; network_expect("400 Bad Request",true);
|
network_begin(&admin,NULL); req.uri="/api/settings/network?secret=x"; network_expect("400 Bad Request",true);
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ static struct httpd_data server = {.config.max_resp_headers = 8};
|
|||||||
static struct sock_db socket_state;
|
static struct sock_db socket_state;
|
||||||
static struct resp_hdr response_headers[8];
|
static struct resp_hdr response_headers[8];
|
||||||
static char scratch[1024], cookie_values[2][200];
|
static char scratch[1024], cookie_values[2][200];
|
||||||
#if defined(HOST_NETWORK) || defined(HOST_BROKER)
|
#if defined(HOST_NETWORK)
|
||||||
|
#include "web_network_settings.h"
|
||||||
|
static char output[WEB_NETWORK_SNAPSHOT_MAX];
|
||||||
|
#elif defined(HOST_BROKER)
|
||||||
static char output[2048];
|
static char output[2048];
|
||||||
#else
|
#else
|
||||||
static char output[1024];
|
static char output[1024];
|
||||||
@@ -161,6 +164,21 @@ static void auth_reset(void) {
|
|||||||
int main(void) {
|
int main(void) {
|
||||||
assert(store_tests() == 0); auth_reset();
|
assert(store_tests() == 0); auth_reset();
|
||||||
char token[65], csrf[65], session[65], cookies[200];
|
char token[65], csrf[65], session[65], cookies[200];
|
||||||
|
begin("/api/login-challenge", HTTP_GET, NULL);
|
||||||
|
add("Host", "[2001:0DB8:0:0:0:0:0:1]:443"); add("X-Login-Bootstrap", "1");
|
||||||
|
expect("200 OK"); token_from(cookie_values[0], token); csrf_from(csrf);
|
||||||
|
for (unsigned mode = 0; mode < 3; ++mode) {
|
||||||
|
begin("/api/login", HTTP_POST, good_body);
|
||||||
|
add("Host", mode == 0 ? "[2001:db8::2]" : "[2001:db8::1]");
|
||||||
|
add("Origin", mode == 1 ? "https://[2001:db8::2]" :
|
||||||
|
mode == 0 ? "https://[2001:db8::2]" : "https://[2001:DB8::1]:443");
|
||||||
|
add("Content-Type", "application/json"); add("X-CSRF-Token", csrf);
|
||||||
|
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||||
|
expect(mode == 2 ? "200 OK" : "403 Forbidden");
|
||||||
|
assert(password_calls == (mode == 2 ? 1U : 0U));
|
||||||
|
}
|
||||||
|
puts("PASS: IPv6 challenge canonical binding, cross-address challenge replay and mismatched Origin rejected before password verification");
|
||||||
|
auth_reset();
|
||||||
challenge(token, csrf);
|
challenge(token, csrf);
|
||||||
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
|
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
|
||||||
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Firmware upload backend host tests
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tests/web_firmware_update/run.py
|
||||||
|
python3 tests/web_cookie_auth/run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The first compiles the unchanged production upload source, production auth
|
||||||
|
admission helpers/parser, and production server upload reservation/typed reboot
|
||||||
|
functions with `-Wall -Wextra -Werror` and trapping undefined-behavior checks.
|
||||||
|
It uses installed ESP-IDF image-layout headers (`IDF_PATH`, otherwise the normal
|
||||||
|
PlatformIO framework directory). It performs no firmware build or device action.
|
||||||
|
HTTP IO, private header-adapter admission, database, OTA/flash and FreeRTOS
|
||||||
|
scheduling are doubles. The existing cookie-auth suite separately exercises
|
||||||
|
production private-adapter framing/duplicate-header handling and session storage.
|
||||||
|
|
||||||
|
The 61 cases cover:
|
||||||
|
|
||||||
|
- Cookie, Origin, CSRF, admin, query/method and readiness rejection before reads.
|
||||||
|
- Raw content type, nonempty body, actual partition capacity and inactive OTA app
|
||||||
|
selection; bytewise partial image prefix and bounded/exact streaming writes.
|
||||||
|
- Wrong magic/chip/segment/descriptor/hash-header rejection before OTA erase.
|
||||||
|
- Service/identity busy, allocation/task creation and OTA begin/write/end/metadata/
|
||||||
|
boot-selection failures; handle consumption versus abort cleanup.
|
||||||
|
- Parsed SDK image length mismatch, final session revocation/currentness failure.
|
||||||
|
- EOF before/after OTA begin, stalled receive and total slow-drip deadlines.
|
||||||
|
- Reservation rejection of competing upload/typed reboot, including during
|
||||||
|
receive and boot selection; response failure at every response stage.
|
||||||
|
- Successful commit followed by delayed reboot-owner action; failed response
|
||||||
|
retains selection but does not reboot and releases reservations/resources.
|
||||||
|
|
||||||
|
These are deterministic failure-injection tests, not real SDK image-integrity,
|
||||||
|
flash, concurrent scheduler, TLS, network, power-loss or hardware test evidence.
|
||||||
|
Stop/start/rotation guard use is source-checked, not dynamically executed here.
|
||||||
|
|
||||||
|
## Parent integration contract
|
||||||
|
|
||||||
|
`POST /api/firmware`, raw `application/octet-stream`, known `Content-Length`,
|
||||||
|
existing cookie + same-origin `Origin` + `X-CSRF-Token`, admin only. Use the
|
||||||
|
existing browser CORS-mode/same-origin-credentials request pattern. No multipart,
|
||||||
|
chunked transfer, `Expect`, query parameters, signature/version policy or filename
|
||||||
|
validation. Standard ESP32-S3 application image with appended SDK SHA-256 digest
|
||||||
|
is required. A filename alone is never trusted.
|
||||||
|
|
||||||
|
`200 {"ok":true,"rebooting":true}` means SDK validation and boot selection
|
||||||
|
succeeded. After synchronous send success, the preallocated owner task waits
|
||||||
|
500 ms and calls `esp_restart()` without stopping HTTPD or retaining a request
|
||||||
|
or socket. It keeps service/identity reservations until reset. Send success is
|
||||||
|
not peer receipt. Send failure after selection schedules **no reboot**, releases
|
||||||
|
reservations, and leaves the accepted image selected for a later reset. A lost
|
||||||
|
acknowledgement is therefore uncertain; never automatically retry.
|
||||||
|
|
||||||
|
All errors use `{"error":"code"}`:
|
||||||
|
|
||||||
|
| HTTP | Codes |
|
||||||
|
| --- | --- |
|
||||||
|
| 400 | `invalid_request`, `invalid_firmware`, `firmware_incomplete` |
|
||||||
|
| 401 | `authentication_required` |
|
||||||
|
| 403 | `origin`, `csrf`, `admin_required` |
|
||||||
|
| 408 | `firmware_timeout` |
|
||||||
|
| 413 | `firmware_too_large` |
|
||||||
|
| 415 | `firmware_content_type` |
|
||||||
|
| 500 | `firmware_write_failed`, `firmware_commit_failed` |
|
||||||
|
| 503 | `unavailable`, `busy`, `firmware_unavailable`, `firmware_resources` |
|
||||||
|
|
||||||
|
Rejections with unread bodies close rather than asking HTTPD to discard the
|
||||||
|
remaining upload. Responses are JSON, no-store, nosniff and no-referrer. Errors
|
||||||
|
contain no SDK diagnostic text or secret material. A boot-selection API failure
|
||||||
|
is not retried or followed by reboot; flash/power failures at metadata commit
|
||||||
|
cannot offer transactional certainty beyond SDK guarantees.
|
||||||
|
|
||||||
|
The synchronous handler may block other HTTPD work for the upload duration.
|
||||||
|
A 4 KiB heap buffer and a 2 KiB reboot-owner stack are prepared before flashing;
|
||||||
|
no whole-image allocation. Receive checks enforce 10 seconds without progress
|
||||||
|
and 120 seconds total, using the server's existing one-second receive timeout.
|
||||||
|
These checks do not preempt SDK flash/validation calls or scheduler delays.
|
||||||
|
Only the inactive app and SDK OTA metadata are written, not NVS/data partitions.
|
||||||
|
|
||||||
|
### Integration work outside backend ownership
|
||||||
|
|
||||||
|
- Server URI capacity is now **40**; `server_lifecycle.py` currently fails its
|
||||||
|
hardcoded 36-initializer extraction check (now 37). Its old 39-handler capacity,
|
||||||
|
required-registration positions 1–16 and optional positions 17–18 also need
|
||||||
|
adjustment to 40, 1–17 and 18–19, respectively, plus a firmware-handler fake.
|
||||||
|
That existing fixture is intentionally not edited here.
|
||||||
|
- HTTPS stop/start/rotation and typed reboot share the upload reservation; direct
|
||||||
|
identity mutation is separately reserved. **Direct `esp_restart()` callers in
|
||||||
|
`system_console.c`, `ssh_transport.c`, `web_admin_transport.c`, and
|
||||||
|
`local_status_ui.c` do not share this fence.** Those files were outside the
|
||||||
|
backend's allowed ownership. The parent must coordinate those reboot paths
|
||||||
|
for global software-reboot exclusion; do not claim that property yet.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
#pragma once
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <strings.h>
|
||||||
|
|
||||||
|
typedef int esp_err_t;
|
||||||
|
#define ESP_OK 0
|
||||||
|
#define ESP_FAIL -1
|
||||||
|
#define ESP_ERR_NOT_FOUND 2
|
||||||
|
#define ESP_ERR_INVALID_STATE 3
|
||||||
|
#define ESP_ERR_TIMEOUT 4
|
||||||
|
#define ESP_ERR_INVALID_ARG 5
|
||||||
|
#define ESP_ERR_NO_MEM 6
|
||||||
|
#define ESP_ERR_INVALID_SIZE 7
|
||||||
|
#define ESP_ERR_HTTPD_INVALID_REQ 8
|
||||||
|
#define ESP_ERR_HTTPD_RESULT_TRUNC 9
|
||||||
|
#define HTTPD_SOCK_ERR_TIMEOUT -3
|
||||||
|
#define HTTP_POST 1
|
||||||
|
#define HTTP_GET 0
|
||||||
|
#define USER_ROLE_ADMIN 1
|
||||||
|
#define USER_ROLE_USER 0
|
||||||
|
#define pdPASS 1
|
||||||
|
#define pdTRUE 1
|
||||||
|
#define portMAX_DELAY UINT32_MAX
|
||||||
|
#define pdMS_TO_TICKS(n) (n)
|
||||||
|
#define taskENTER_CRITICAL(p) ((void)(p))
|
||||||
|
#define taskEXIT_CRITICAL(p) ((void)(p))
|
||||||
|
|
||||||
|
typedef void *httpd_handle_t;
|
||||||
|
typedef struct { const char *name, *value; } test_header_t;
|
||||||
|
struct httpd_req_aux { char *scratch; size_t scratch_cur_size; unsigned req_hdrs_count; };
|
||||||
|
typedef struct {
|
||||||
|
const char *uri;
|
||||||
|
int method;
|
||||||
|
size_t content_len, received;
|
||||||
|
httpd_handle_t handle;
|
||||||
|
struct httpd_req_aux *aux;
|
||||||
|
test_header_t headers[12];
|
||||||
|
size_t header_count;
|
||||||
|
bool headers_valid;
|
||||||
|
} httpd_req_t;
|
||||||
|
typedef struct { int role; uint32_t user_id; } user_principal_t;
|
||||||
|
typedef uint64_t web_session_id_t;
|
||||||
|
typedef struct { web_session_id_t id; user_principal_t principal; char csrf[65]; } web_session_view_t;
|
||||||
|
typedef struct {
|
||||||
|
uint32_t security_rejections, login_failures, throttled, capacity_rejections;
|
||||||
|
} web_cookie_auth_snapshot_t;
|
||||||
|
|
||||||
|
typedef void *TaskHandle_t;
|
||||||
|
typedef void *SemaphoreHandle_t;
|
||||||
|
#define eSetValueWithOverwrite 1
|
||||||
|
int xTaskCreate(void (*)(void *), const char *, unsigned, void *, unsigned, TaskHandle_t *);
|
||||||
|
int xTaskNotify(TaskHandle_t, uint32_t, int);
|
||||||
|
int xTaskNotifyWait(uint32_t, uint32_t, uint32_t *, uint32_t);
|
||||||
|
void vTaskDelay(unsigned);
|
||||||
|
void vTaskDelete(TaskHandle_t);
|
||||||
|
int xSemaphoreTake(SemaphoreHandle_t, unsigned);
|
||||||
|
void xSemaphoreGive(SemaphoreHandle_t);
|
||||||
|
void esp_restart(void);
|
||||||
|
int64_t esp_timer_get_time(void);
|
||||||
|
void secure_wipe(void *, size_t);
|
||||||
|
|
||||||
|
size_t httpd_req_get_hdr_value_len(httpd_req_t *, const char *);
|
||||||
|
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *, const char *, char *, size_t);
|
||||||
|
esp_err_t httpd_resp_set_status(httpd_req_t *, const char *);
|
||||||
|
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
|
||||||
|
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
|
||||||
|
esp_err_t httpd_resp_sendstr(httpd_req_t *, const char *);
|
||||||
|
int httpd_req_recv(httpd_req_t *, char *, size_t);
|
||||||
|
bool web_httpd_headers_valid(httpd_req_t *);
|
||||||
|
bool web_httpd_unread_body(httpd_req_t *);
|
||||||
|
void web_httpd_wipe_request(httpd_req_t *, bool);
|
||||||
|
esp_err_t web_session_store_lookup(const char *, size_t, const char *, size_t, web_session_view_t *);
|
||||||
|
esp_err_t web_session_store_check_principal(web_session_id_t, const user_principal_t *, bool *);
|
||||||
|
esp_err_t web_cookie_auth_require_body(httpd_req_t *, size_t, web_session_view_t *, bool *);
|
||||||
|
esp_err_t web_security_reserve_identity(uint32_t, bool, uint32_t *);
|
||||||
|
void web_security_release_identity(uint32_t);
|
||||||
|
|
||||||
|
typedef uint32_t esp_ota_handle_t;
|
||||||
|
typedef struct { uint32_t type, subtype, address, size, erase_size; } esp_partition_t;
|
||||||
|
#define ESP_PARTITION_TYPE_APP 0
|
||||||
|
#define ESP_PARTITION_SUBTYPE_APP_OTA_0 0x10
|
||||||
|
#define ESP_PARTITION_SUBTYPE_APP_OTA_15 0x1f
|
||||||
|
const esp_partition_t *esp_ota_get_running_partition(void);
|
||||||
|
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *);
|
||||||
|
esp_err_t esp_ota_begin(const esp_partition_t *, size_t, esp_ota_handle_t *);
|
||||||
|
esp_err_t esp_ota_write(esp_ota_handle_t, const void *, size_t);
|
||||||
|
esp_err_t esp_ota_end(esp_ota_handle_t);
|
||||||
|
esp_err_t esp_ota_abort(esp_ota_handle_t);
|
||||||
|
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host failure tests: real upload/auth/header admission and server reservation.
|
||||||
|
|
||||||
|
The backend uses OTA/HTTP IO/task/database doubles; a separate contract test
|
||||||
|
executes pinned SDK begin/abort with injected flash/allocation failures. SDK
|
||||||
|
headers/getters come only from verified IDF 5.5.0. No build or device operations.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parents[1]
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--idf-path", type=Path, help="Explicit ESP-IDF 5.5.0 source directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.idf_path:
|
||||||
|
IDF = args.idf_path.expanduser().resolve()
|
||||||
|
else:
|
||||||
|
# Only this project's active build tree; never candidate builds or the
|
||||||
|
# unversioned PlatformIO package, which may now contain another SDK.
|
||||||
|
paths = set()
|
||||||
|
for cache in (ROOT / ".pio/build").glob("*/CMakeCache.txt"):
|
||||||
|
match = re.search(r"^esp-idf_SOURCE_DIR:[^=]+=(.+)$", cache.read_text(), re.M)
|
||||||
|
if match:
|
||||||
|
paths.add(Path(match.group(1)).resolve())
|
||||||
|
if len(paths) != 1:
|
||||||
|
parser.error("Cannot identify one active build SDK; pass --idf-path for ESP-IDF 5.5.0")
|
||||||
|
IDF = paths.pop()
|
||||||
|
try:
|
||||||
|
version_header = (IDF / "components/esp_common/include/esp_idf_version.h").read_text()
|
||||||
|
version = tuple(int(re.search(r"^#define ESP_IDF_VERSION_" + part + r"\s+(\d+)\s*$",
|
||||||
|
version_header, re.M).group(1))
|
||||||
|
for part in ("MAJOR", "MINOR", "PATCH"))
|
||||||
|
except (OSError, AttributeError) as error:
|
||||||
|
parser.error(f"Cannot verify SDK version at {IDF}: {error}")
|
||||||
|
if version != (5, 5, 0):
|
||||||
|
parser.error(f"ESP-IDF 5.5.0 required, found {'.'.join(map(str, version))} at {IDF}")
|
||||||
|
print(f"Using ESP-IDF 5.5.0: {IDF}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def function(source, name):
|
||||||
|
match = re.search(r"^(?:static )?(?:bool|void|size_t|esp_err_t|ota_ops_entry_t\s*\*)\s*" + name + r"\([^;{}]*\)\n\{.*?^\}", source, re.M | re.S)
|
||||||
|
if not match:
|
||||||
|
raise RuntimeError("Production function shape changed: " + name)
|
||||||
|
return match.group() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="web-firmware-update-") as directory:
|
||||||
|
tmp = Path(directory)
|
||||||
|
shutil.copy(HERE / "fakes.h", tmp / "fakes.h")
|
||||||
|
shutil.copy(HERE / "test.c", tmp / "test.c")
|
||||||
|
for name in ("esp_http_server.h", "esp_err.h", "esp_ota_ops.h", "esp_system.h",
|
||||||
|
"esp_timer.h", "secure_random.h", "web_cookie_auth.h", "web_httpd_adapter.h",
|
||||||
|
"web_security.h", "freertos/FreeRTOS.h", "freertos/task.h"):
|
||||||
|
path = tmp / name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text('#pragma once\n#include "fakes.h"\n')
|
||||||
|
(tmp / "esp_assert.h").write_text('#define ESP_STATIC_ASSERT(c,m) _Static_assert(c,m)\n')
|
||||||
|
(tmp / "esp_attr.h").write_text('#define FORCE_INLINE_ATTR static inline\n')
|
||||||
|
(tmp / "esp_flash_partitions.h").write_text('#pragma once\n#include <stdint.h>\ntypedef struct { uint32_t offset, size; } esp_partition_pos_t;\n')
|
||||||
|
for component, name in (("bootloader_support", "esp_app_format.h"),
|
||||||
|
("bootloader_support", "esp_image_format.h"),
|
||||||
|
("esp_app_format", "esp_app_desc.h")):
|
||||||
|
shutil.copy(IDF / "components" / component / "include" / name, tmp / name)
|
||||||
|
for name in ("web_firmware_update.c", "web_firmware_update.h", "web_auth_parse.c", "web_auth_parse.h"):
|
||||||
|
shutil.copy(ROOT / "src" / name, tmp / name)
|
||||||
|
auth = (ROOT / "src/web_cookie_auth.c").read_text()
|
||||||
|
names = ("equal", "header", "origin", "cookie", "cookies_valid", "response", "failure",
|
||||||
|
"require", "web_cookie_auth_require_body")
|
||||||
|
constants = '\n'.join(line for line in auth.splitlines() if line.startswith(("#define SESSION_COOKIE", "#define PRELOGIN_COOKIE")))
|
||||||
|
(tmp / "auth_production.h").write_text(constants + "\n" + '\n'.join(function(auth, n) for n in names))
|
||||||
|
httpd = (IDF / "components/esp_http_server/src/httpd_parse.c").read_text()
|
||||||
|
adapter = (ROOT / "src/web_httpd_adapter.c").read_text()
|
||||||
|
(tmp / "httpd_production.h").write_text(
|
||||||
|
'\n'.join(function(httpd, n) for n in ("httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str")) +
|
||||||
|
'\n#define web_httpd_headers_valid adapter_headers_valid\n' +
|
||||||
|
function(adapter, "web_httpd_headers_valid") + '\n#undef web_httpd_headers_valid\n')
|
||||||
|
sdk_ota = (IDF / "components/app_update/esp_ota_ops.c").read_text()
|
||||||
|
# Extract the actual SDK registry/type and begin/abort implementations, not
|
||||||
|
# a reimplementation of their ordering. Flash and allocation are injected.
|
||||||
|
registry = sdk_ota[sdk_ota.index('typedef struct ota_ops_entry_'):
|
||||||
|
sdk_ota.index('const static char *TAG')]
|
||||||
|
(tmp / "sdk_ota_production.h").write_text(registry + '\n' + '\n'.join(
|
||||||
|
function(sdk_ota, n) for n in ("is_ota_partition", "esp_ota_init_entry", "esp_ota_begin",
|
||||||
|
"get_ota_ops_entry", "esp_ota_abort")))
|
||||||
|
shutil.copy(HERE / "sdk_contract.c", tmp / "sdk_contract.c")
|
||||||
|
server = (ROOT / "src/web_server.c").read_text()
|
||||||
|
(tmp / "server_production.h").write_text('\n'.join(function(server, n) for n in
|
||||||
|
("web_firmware_update_reserve", "web_firmware_update_release", "web_server_reboot_current")))
|
||||||
|
# Review guards used by the same reservation. Full lifecycle harness is owned
|
||||||
|
# elsewhere; its hardcoded URI count must change from 39 to 40 upstream.
|
||||||
|
assert "s_transitioning != reserved" in function(server, "stop_server")
|
||||||
|
assert "s_transitioning != reserved" in function(server, "start_server")
|
||||||
|
assert "if (s_transitioning ||" in function(server, "web_server_replace_identity")
|
||||||
|
assert '.handler = web_firmware_update_handler' in server
|
||||||
|
assert '&s_firmware_uri,' in server
|
||||||
|
compiler = os.environ.get("CC", "cc")
|
||||||
|
command = [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
|
||||||
|
"-fsanitize=undefined", "-fsanitize-undefined-trap-on-error", "-I", str(tmp),
|
||||||
|
str(tmp / "test.c"), str(tmp / "web_auth_parse.c"), "-o", str(tmp / "test")]
|
||||||
|
subprocess.run(command, check=True, timeout=30)
|
||||||
|
subprocess.run([str(tmp / "test")], check=True, timeout=30)
|
||||||
|
command = command[:command.index(str(tmp / "test.c"))] + [str(tmp / "sdk_contract.c"), "-o", str(tmp / "sdk_contract")]
|
||||||
|
subprocess.run(command, check=True, timeout=30)
|
||||||
|
subprocess.run([str(tmp / "sdk_contract")], check=True, timeout=30)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
/* Execute pinned SDK begin/abort and its real linked registry with injected
|
||||||
|
* allocation/erase failures. Not a flash or firmware-build test. */
|
||||||
|
#include "fakes.h"
|
||||||
|
#include <sys/queue.h>
|
||||||
|
#define WORD_ALIGNED_ATTR
|
||||||
|
#define ALIGN_UP(num, align) (((num) + ((align) - 1)) & ~((align) - 1))
|
||||||
|
#define ESP_PARTITION_SUBTYPE_APP_OTA_MAX 0x20
|
||||||
|
#define ESP_PARTITION_TYPE_BOOTLOADER 2
|
||||||
|
#define ESP_PARTITION_TYPE_PARTITION_TABLE 3
|
||||||
|
#define ESP_ERR_OTA_PARTITION_CONFLICT 10
|
||||||
|
#define OTA_WITH_SEQUENTIAL_WRITES 0xfffffffeU
|
||||||
|
#define OTA_SIZE_UNKNOWN 0xffffffffU
|
||||||
|
|
||||||
|
static const esp_partition_t running = {.type = ESP_PARTITION_TYPE_APP,
|
||||||
|
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0, .erase_size = 4096, .size = 0x400000};
|
||||||
|
static const esp_partition_t target = {.type = ESP_PARTITION_TYPE_APP,
|
||||||
|
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0 + 1, .erase_size = 4096, .size = 0x400000};
|
||||||
|
static bool fail_allocation, fail_erase;
|
||||||
|
static unsigned live_allocations, erase_calls;
|
||||||
|
static const esp_partition_t *esp_partition_verify(const esp_partition_t *p) { return p; }
|
||||||
|
const esp_partition_t *esp_ota_get_running_partition(void) { return &running; }
|
||||||
|
static void *sdk_calloc(size_t n, size_t size)
|
||||||
|
{
|
||||||
|
if (fail_allocation) return NULL;
|
||||||
|
void *p = calloc(n, size); assert(p); ++live_allocations; return p;
|
||||||
|
}
|
||||||
|
static void sdk_free(void *p) { assert(p && live_allocations); --live_allocations; free(p); }
|
||||||
|
static esp_err_t esp_partition_erase_range(const esp_partition_t *p, size_t offset, size_t size)
|
||||||
|
{
|
||||||
|
/* The actual registry allocation is still live when erase fails. */
|
||||||
|
assert(p == &target && !offset && size == 12288 && live_allocations == 1);
|
||||||
|
++erase_calls; return fail_erase ? ESP_FAIL : ESP_OK;
|
||||||
|
}
|
||||||
|
static void esp_image_bootloader_offset_set(uint32_t offset) { (void)offset; assert(0); }
|
||||||
|
static void *esp_flash_default_chip;
|
||||||
|
static void esp_flash_set_dangerous_write_protection(void *chip, bool enabled)
|
||||||
|
{
|
||||||
|
(void)chip; (void)enabled; assert(0);
|
||||||
|
}
|
||||||
|
#define calloc sdk_calloc
|
||||||
|
#define free sdk_free
|
||||||
|
#include "sdk_ota_production.h"
|
||||||
|
#undef calloc
|
||||||
|
#undef free
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
esp_ota_handle_t handle = 0;
|
||||||
|
assert(esp_ota_begin(NULL, 12000, &handle) == ESP_ERR_INVALID_ARG && !handle);
|
||||||
|
assert(esp_ota_begin(&running, 12000, &handle) == ESP_ERR_OTA_PARTITION_CONFLICT && !handle);
|
||||||
|
fail_allocation = true;
|
||||||
|
assert(esp_ota_begin(&target, 12000, &handle) == ESP_ERR_NO_MEM && !handle);
|
||||||
|
assert(!live_allocations && !erase_calls && LIST_EMPTY(&s_ota_ops_entries_head));
|
||||||
|
fail_allocation = false; fail_erase = true;
|
||||||
|
for (unsigned i = 0; i < 3; ++i) {
|
||||||
|
handle = 0;
|
||||||
|
assert(esp_ota_begin(&target, 12000, &handle) == ESP_FAIL && handle);
|
||||||
|
assert(get_ota_ops_entry(handle) && live_allocations == 1);
|
||||||
|
assert(esp_ota_abort(handle) == ESP_OK);
|
||||||
|
assert(!live_allocations && LIST_EMPTY(&s_ota_ops_entries_head));
|
||||||
|
assert(esp_ota_abort(handle) == ESP_ERR_NOT_FOUND);
|
||||||
|
}
|
||||||
|
fail_erase = false; handle = 0;
|
||||||
|
assert(esp_ota_begin(&target, 12000, &handle) == ESP_OK && handle && live_allocations == 1);
|
||||||
|
assert(esp_ota_abort(handle) == ESP_OK && !live_allocations);
|
||||||
|
puts("PASS actual ESP-IDF 5.5.0 begin/init/registry/abort: early failure has no handle; erase failure publishes live handle; abort frees it exactly once");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
#include "fakes.h"
|
||||||
|
#include "web_auth_parse.h"
|
||||||
|
#include "esp_app_format.h"
|
||||||
|
#include "esp_app_desc.h"
|
||||||
|
#include "esp_image_format.h"
|
||||||
|
#include "web_firmware_update.h"
|
||||||
|
|
||||||
|
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
|
||||||
|
static size_t strlcpy(char *out, const char *in, size_t size)
|
||||||
|
{
|
||||||
|
size_t length = strlen(in);
|
||||||
|
if (size) { size_t n = length < size - 1 ? length : size - 1; memcpy(out, in, n); out[n] = 0; }
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
/* IDF getters compare ptrdiff_t with size_t; retain their exact source. */
|
||||||
|
#pragma GCC diagnostic push
|
||||||
|
#pragma GCC diagnostic ignored "-Wsign-compare"
|
||||||
|
#include "httpd_production.h"
|
||||||
|
#pragma GCC diagnostic pop
|
||||||
|
|
||||||
|
static int s_lock;
|
||||||
|
static bool s_ready;
|
||||||
|
static web_cookie_auth_snapshot_t s_counts;
|
||||||
|
#include "auth_production.h"
|
||||||
|
|
||||||
|
static SemaphoreHandle_t s_server_mutex;
|
||||||
|
static httpd_handle_t s_server;
|
||||||
|
static bool s_transitioning;
|
||||||
|
static esp_err_t s_last_error;
|
||||||
|
static uint32_t s_generation;
|
||||||
|
#include "server_production.h"
|
||||||
|
|
||||||
|
static void *test_malloc(size_t);
|
||||||
|
static void test_free(void *);
|
||||||
|
#define malloc test_malloc
|
||||||
|
#define free test_free
|
||||||
|
#include "web_firmware_update.c"
|
||||||
|
#undef malloc
|
||||||
|
#undef free
|
||||||
|
|
||||||
|
static uint8_t image[12000], flash[12000];
|
||||||
|
static httpd_req_t request;
|
||||||
|
static esp_partition_t running, target;
|
||||||
|
static bool missing_target, missing_running;
|
||||||
|
static bool lookup_current, final_current, lookup_unavailable, final_unavailable;
|
||||||
|
static int role, stage_fail, response_fail, response_step;
|
||||||
|
static bool malloc_fail, task_fail, identity_busy, locked, mutex_busy;
|
||||||
|
static unsigned allocations, allocations_live, tasks_created, tasks_deleted;
|
||||||
|
static unsigned begins, writes, ends, aborts, metadata_reads, commits, receives, wipes;
|
||||||
|
static unsigned restarts, response_sends, releases, identity_releases;
|
||||||
|
static size_t flash_size, max_chunk, cutoff, stall_at, parsed_size;
|
||||||
|
static int64_t now_us, read_us;
|
||||||
|
static char response_status[64], response_body[160];
|
||||||
|
static uint32_t notification, identity_token;
|
||||||
|
static void (*task_entry)(void *);
|
||||||
|
static int task_storage, server_storage, mutex_storage;
|
||||||
|
static bool receive_race, commit_race, revoke_at_metadata;
|
||||||
|
static unsigned cases;
|
||||||
|
static bool ota_live;
|
||||||
|
static char raw_length[32], header_scratch[2048];
|
||||||
|
static struct httpd_req_aux request_aux;
|
||||||
|
|
||||||
|
static void *test_malloc(size_t size)
|
||||||
|
{
|
||||||
|
assert(size == BUFFER_SIZE); ++allocations;
|
||||||
|
if (malloc_fail) return NULL;
|
||||||
|
++allocations_live; return malloc(size);
|
||||||
|
}
|
||||||
|
static void test_free(void *p) { if (p) { assert(allocations_live == 1); --allocations_live; } free(p); }
|
||||||
|
void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||||
|
int64_t esp_timer_get_time(void) { return now_us; }
|
||||||
|
int xSemaphoreTake(SemaphoreHandle_t m, unsigned ticks)
|
||||||
|
{
|
||||||
|
assert(m == &mutex_storage && !locked);
|
||||||
|
if (mutex_busy) { assert(ticks == 0); return 0; }
|
||||||
|
locked = true; return pdTRUE;
|
||||||
|
}
|
||||||
|
void xSemaphoreGive(SemaphoreHandle_t m) { assert(m == &mutex_storage && locked); locked = false; }
|
||||||
|
|
||||||
|
int xTaskCreate(void (*entry)(void *), const char *name, unsigned stack, void *arg,
|
||||||
|
unsigned priority, TaskHandle_t *out)
|
||||||
|
{
|
||||||
|
assert(!strcmp(name, "fw_reboot") && stack == 2048 && !arg && priority == 5);
|
||||||
|
assert(s_transitioning && identity_token && !commits && !task_entry);
|
||||||
|
if (task_fail) return 0;
|
||||||
|
task_entry = entry; *out = &task_storage; ++tasks_created; return pdPASS;
|
||||||
|
}
|
||||||
|
int xTaskNotify(TaskHandle_t t, uint32_t value, int action)
|
||||||
|
{
|
||||||
|
assert(t == &task_storage && task_entry && !notification && action == eSetValueWithOverwrite);
|
||||||
|
assert(value == 1 || value == 2);
|
||||||
|
if (value == 1) assert(commits == 1 && response_sends == 1 && !response_fail && !aborts);
|
||||||
|
notification = value; return pdPASS;
|
||||||
|
}
|
||||||
|
int xTaskNotifyWait(uint32_t clear_in, uint32_t clear_out, uint32_t *value, uint32_t wait)
|
||||||
|
{
|
||||||
|
assert(!clear_in && clear_out == UINT32_MAX && wait == portMAX_DELAY && notification);
|
||||||
|
*value = notification; return pdTRUE;
|
||||||
|
}
|
||||||
|
void vTaskDelay(unsigned ticks) { assert(ticks == 500 && notification == 1); }
|
||||||
|
void vTaskDelete(TaskHandle_t task) { assert(!task); ++tasks_deleted; }
|
||||||
|
void esp_restart(void) { assert(!locked); ++restarts; }
|
||||||
|
static void finish_owner(void)
|
||||||
|
{
|
||||||
|
if (task_entry) {
|
||||||
|
assert(notification); task_entry(NULL); task_entry = NULL;
|
||||||
|
assert(tasks_deleted == tasks_created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void set_header(const char *name, const char *value)
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i < request.header_count; ++i) {
|
||||||
|
if (!strcmp(name, request.headers[i].name)) { request.headers[i].value = value; return; }
|
||||||
|
}
|
||||||
|
assert(request.header_count < 12);
|
||||||
|
request.headers[request.header_count++] = (test_header_t){name, value};
|
||||||
|
}
|
||||||
|
static void set_length(size_t length)
|
||||||
|
{
|
||||||
|
request.content_len = length;
|
||||||
|
snprintf(raw_length, sizeof(raw_length), "%zu", length);
|
||||||
|
set_header("Content-Length", raw_length);
|
||||||
|
}
|
||||||
|
bool web_httpd_headers_valid(httpd_req_t *r)
|
||||||
|
{
|
||||||
|
request_aux = (struct httpd_req_aux){.scratch = header_scratch};
|
||||||
|
for (size_t i = 0; i < r->header_count; ++i) {
|
||||||
|
if (!r->headers[i].value) continue;
|
||||||
|
size_t used = request_aux.scratch_cur_size;
|
||||||
|
int written = snprintf(header_scratch + used, sizeof(header_scratch) - used,
|
||||||
|
"%s:%s", r->headers[i].name, r->headers[i].value);
|
||||||
|
assert(written >= 0 && (size_t)written + 1 <= sizeof(header_scratch) - used);
|
||||||
|
request_aux.scratch_cur_size += (size_t)written + 1;
|
||||||
|
++request_aux.req_hdrs_count;
|
||||||
|
}
|
||||||
|
r->aux = &request_aux;
|
||||||
|
return r->headers_valid && adapter_headers_valid(r);
|
||||||
|
}
|
||||||
|
bool web_httpd_unread_body(httpd_req_t *r) { return r->received < r->content_len; }
|
||||||
|
void web_httpd_wipe_request(httpd_req_t *r, bool unread)
|
||||||
|
{
|
||||||
|
assert(unread == (r->received < r->content_len)); ++wipes;
|
||||||
|
}
|
||||||
|
static esp_err_t response_result(void) { return ++response_step == response_fail ? ESP_FAIL : ESP_OK; }
|
||||||
|
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s)
|
||||||
|
{
|
||||||
|
assert(r == &request && strlen(s) < sizeof(response_status)); strcpy(response_status, s);
|
||||||
|
return response_result();
|
||||||
|
}
|
||||||
|
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s)
|
||||||
|
{
|
||||||
|
assert(r == &request && !strcmp(s, "application/json; charset=utf-8")); return response_result();
|
||||||
|
}
|
||||||
|
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value)
|
||||||
|
{
|
||||||
|
assert(r == &request && name && value); return response_result();
|
||||||
|
}
|
||||||
|
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body)
|
||||||
|
{
|
||||||
|
assert(r == &request && strlen(body) < sizeof(response_body));
|
||||||
|
++response_sends; strcpy(response_body, body); return response_result();
|
||||||
|
}
|
||||||
|
static void competing_lifecycle(void)
|
||||||
|
{
|
||||||
|
assert(s_transitioning && !locked);
|
||||||
|
assert(web_firmware_update_reserve(request.handle) == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(!restarts);
|
||||||
|
uint32_t token = 0;
|
||||||
|
assert(web_security_reserve_identity(0, false, &token) == ESP_ERR_INVALID_STATE && !token);
|
||||||
|
}
|
||||||
|
int httpd_req_recv(httpd_req_t *r, char *out, size_t want)
|
||||||
|
{
|
||||||
|
assert(r == &request && want && want <= BUFFER_SIZE && s_transitioning && identity_token);
|
||||||
|
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||||
|
++receives; now_us += read_us;
|
||||||
|
if (receive_race) competing_lifecycle();
|
||||||
|
if (r->received >= stall_at) return HTTPD_SOCK_ERR_TIMEOUT;
|
||||||
|
if (r->received >= cutoff) return 0;
|
||||||
|
size_t count = want < max_chunk ? want : max_chunk;
|
||||||
|
if (count > cutoff - r->received) count = cutoff - r->received;
|
||||||
|
assert(r->received + count <= sizeof(image));
|
||||||
|
memcpy(out, image + r->received, count); r->received += count; return (int)count;
|
||||||
|
}
|
||||||
|
esp_err_t web_session_store_lookup(const char *token, size_t length, const char *origin,
|
||||||
|
size_t origin_length, web_session_view_t *view)
|
||||||
|
{
|
||||||
|
assert(length == 64 && token[0] == 'a' && origin_length == strlen(origin));
|
||||||
|
assert(!strcmp(origin, "https://device"));
|
||||||
|
if (lookup_unavailable) return ESP_FAIL;
|
||||||
|
if (!lookup_current) return ESP_ERR_NOT_FOUND;
|
||||||
|
view->id = 42; view->principal.role = role; view->principal.user_id = 9;
|
||||||
|
memset(view->csrf, 'b', 64); view->csrf[64] = 0; return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *principal, bool *current)
|
||||||
|
{
|
||||||
|
assert(id == 42 && principal->role == USER_ROLE_ADMIN && principal->user_id == 9);
|
||||||
|
assert(ends == 1 && metadata_reads == 1 && !commits);
|
||||||
|
*current = final_current; return final_unavailable ? ESP_FAIL : ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token)
|
||||||
|
{
|
||||||
|
assert(!expected && !reset && s_transitioning); *token = 0;
|
||||||
|
if (identity_busy || identity_token) return ESP_ERR_INVALID_STATE;
|
||||||
|
*token = identity_token = 7; return ESP_OK;
|
||||||
|
}
|
||||||
|
void web_security_release_identity(uint32_t token)
|
||||||
|
{
|
||||||
|
assert(token && token == identity_token && s_transitioning);
|
||||||
|
identity_token = 0; ++identity_releases;
|
||||||
|
}
|
||||||
|
const esp_partition_t *esp_ota_get_running_partition(void) { return missing_running ? NULL : &running; }
|
||||||
|
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *p)
|
||||||
|
{
|
||||||
|
assert(!p); return missing_target ? NULL : ⌖
|
||||||
|
}
|
||||||
|
esp_err_t esp_ota_begin(const esp_partition_t *p, size_t size, esp_ota_handle_t *handle)
|
||||||
|
{
|
||||||
|
assert(p == &target && p->address != running.address && size == request.content_len);
|
||||||
|
assert(size <= target.size && request.received == PREFIX_SIZE && s_transitioning && identity_token);
|
||||||
|
assert(tasks_created == 1 && !locked); ++begins;
|
||||||
|
assert(!ota_live && *handle == 0);
|
||||||
|
if (stage_fail == 1) return ESP_FAIL;
|
||||||
|
*handle = 123; ota_live = true;
|
||||||
|
/* IDF publishes the handle before erasing; this models erase failure. */
|
||||||
|
return stage_fail == 6 ? ESP_FAIL : ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_ota_write(esp_ota_handle_t handle, const void *data, size_t size)
|
||||||
|
{
|
||||||
|
assert(handle == 123 && s_transitioning && !locked && size <= BUFFER_SIZE);
|
||||||
|
assert(flash_size + size <= sizeof(flash)); ++writes;
|
||||||
|
if (stage_fail == 2) return ESP_FAIL;
|
||||||
|
memcpy(flash + flash_size, data, size); flash_size += size; return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_ota_end(esp_ota_handle_t handle)
|
||||||
|
{
|
||||||
|
assert(handle == 123 && flash_size == request.content_len && !memcmp(flash, image, flash_size));
|
||||||
|
assert(!aborts && !locked && s_transitioning && tasks_created == 1 && ota_live); ++ends;
|
||||||
|
ota_live = false; /* End consumes on success AND validation failure. */
|
||||||
|
return stage_fail == 3 ? ESP_FAIL : ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_ota_abort(esp_ota_handle_t handle)
|
||||||
|
{
|
||||||
|
assert(handle == 123 && begins == 1 && !ends && !commits && ota_live);
|
||||||
|
ota_live = false; ++aborts; return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_image_get_metadata(const esp_partition_pos_t *p, esp_image_metadata_t *metadata)
|
||||||
|
{
|
||||||
|
assert(p->offset == target.address && p->size == target.size && ends == 1);
|
||||||
|
++metadata_reads; metadata->image_len = (uint32_t)parsed_size;
|
||||||
|
if (revoke_at_metadata) final_current = false;
|
||||||
|
return stage_fail == 4 ? ESP_FAIL : ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *p)
|
||||||
|
{
|
||||||
|
assert(p == &target && ends == 1 && metadata_reads == 1 && final_current && !final_unavailable);
|
||||||
|
assert(tasks_created == 1 && !response_sends && s_transitioning && identity_token && !locked);
|
||||||
|
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||||
|
if (commit_race) competing_lifecycle();
|
||||||
|
if (stage_fail == 5) return ESP_FAIL;
|
||||||
|
++commits; return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reset(void)
|
||||||
|
{
|
||||||
|
assert(!allocations_live && !task_entry && !locked && !ota_live);
|
||||||
|
atomic_store(&s_reboot_gate, false);
|
||||||
|
s_firmware_selected = false;
|
||||||
|
++cases;
|
||||||
|
memset(&request, 0, sizeof(request));
|
||||||
|
request.uri = WEB_FIRMWARE_UPDATE_URI; request.method = HTTP_POST;
|
||||||
|
request.content_len = sizeof(image); request.handle = &server_storage; request.headers_valid = true;
|
||||||
|
set_header("Host", "device"); set_header("Origin", "https://device");
|
||||||
|
set_header("Content-Type", "application/octet-stream");
|
||||||
|
set_length(sizeof(image));
|
||||||
|
set_header("Cookie", SESSION_COOKIE "=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
set_header("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||||
|
memset(image, 0x55, sizeof(image)); memset(flash, 0, sizeof(flash));
|
||||||
|
esp_image_header_t header = {.magic = ESP_IMAGE_HEADER_MAGIC, .chip_id = ESP_CHIP_ID_ESP32S3,
|
||||||
|
.segment_count = 3, .hash_appended = 1};
|
||||||
|
esp_image_segment_header_t segment = {.data_len = sizeof(esp_app_desc_t)};
|
||||||
|
uint32_t magic = ESP_APP_DESC_MAGIC_WORD;
|
||||||
|
memcpy(image, &header, sizeof(header)); memcpy(image + sizeof(header), &segment, sizeof(segment));
|
||||||
|
memcpy(image + sizeof(header) + sizeof(segment), &magic, sizeof(magic));
|
||||||
|
running = (esp_partition_t){.type = 0, .subtype = 0x10, .address = 0x10000, .size = 0x400000};
|
||||||
|
target = (esp_partition_t){.type = 0, .subtype = 0x11, .address = 0x410000, .size = 0x400000};
|
||||||
|
missing_target = missing_running = false;
|
||||||
|
s_ready = lookup_current = final_current = true;
|
||||||
|
s_server_mutex = &mutex_storage; s_server = &server_storage;
|
||||||
|
s_transitioning = false; s_last_error = ESP_OK; s_generation = 11;
|
||||||
|
lookup_unavailable = final_unavailable = malloc_fail = task_fail = identity_busy = mutex_busy = false;
|
||||||
|
receive_race = commit_race = revoke_at_metadata = false;
|
||||||
|
role = USER_ROLE_ADMIN; stage_fail = response_fail = response_step = 0;
|
||||||
|
allocations = allocations_live = tasks_created = tasks_deleted = 0;
|
||||||
|
begins = writes = ends = aborts = metadata_reads = commits = receives = wipes = 0;
|
||||||
|
restarts = response_sends = releases = identity_releases = 0;
|
||||||
|
flash_size = 0; max_chunk = BUFFER_SIZE; cutoff = stall_at = SIZE_MAX; parsed_size = sizeof(image);
|
||||||
|
now_us = 0; read_us = 1000; notification = identity_token = 0;
|
||||||
|
response_status[0] = response_body[0] = 0;
|
||||||
|
}
|
||||||
|
static void rejected(const char *status, const char *code)
|
||||||
|
{
|
||||||
|
bool pre_reserved = s_transitioning;
|
||||||
|
bool gate_reserved = atomic_load(&s_reboot_gate);
|
||||||
|
esp_err_t result = web_firmware_update_handler(&request);
|
||||||
|
assert(!strcmp(response_status, status) && strstr(response_body, code));
|
||||||
|
assert(result == (request.received < request.content_len ? ESP_FAIL : ESP_OK));
|
||||||
|
assert(!commits && !allocations_live && !restarts && wipes == 1 && !ota_live);
|
||||||
|
assert(s_transitioning == pre_reserved && !identity_token);
|
||||||
|
assert(atomic_load(&s_reboot_gate) == gate_reserved);
|
||||||
|
if (task_entry) assert(notification == 2);
|
||||||
|
finish_owner(); assert(!restarts);
|
||||||
|
}
|
||||||
|
static void success(void)
|
||||||
|
{
|
||||||
|
assert(web_firmware_update_handler(&request) == ESP_OK);
|
||||||
|
assert(!strcmp(response_status, "200 OK") && !strcmp(response_body, "{\"ok\":true,\"rebooting\":true}"));
|
||||||
|
assert(commits == 1 && ends == 1 && !aborts && s_transitioning && identity_token);
|
||||||
|
assert(!allocations_live && notification == 1 && !restarts && wipes == 1);
|
||||||
|
assert(s_firmware_selected && web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||||
|
assert(request.received == request.content_len && !memcmp(image, flash, sizeof(image)));
|
||||||
|
finish_owner(); assert(restarts == 1);
|
||||||
|
}
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
reset();
|
||||||
|
assert(web_firmware_update_reserve_reboot() == ESP_OK);
|
||||||
|
rejected("503 Service Unavailable", "busy");
|
||||||
|
assert(!receives && !begins && !allocations);
|
||||||
|
reset(); s_server_mutex = NULL; s_server = NULL;
|
||||||
|
assert(web_firmware_update_reserve_reboot() == ESP_OK);
|
||||||
|
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||||
|
reset(); success();
|
||||||
|
reset(); max_chunk = 1; receive_race = commit_race = true; success();
|
||||||
|
assert(receives == sizeof(image));
|
||||||
|
reset(); max_chunk = 17; success();
|
||||||
|
puts("PASS bounded streaming, bytewise partial prefix, exact writes, delayed owner restart and reservation races");
|
||||||
|
|
||||||
|
reset(); lookup_current = false; rejected("401 Unauthorized", "authentication_required"); assert(!receives && !allocations);
|
||||||
|
reset(); lookup_unavailable = true; rejected("503 Service Unavailable", "unavailable");
|
||||||
|
reset(); role = USER_ROLE_USER; rejected("403 Forbidden", "admin_required"); assert(!receives && !allocations);
|
||||||
|
reset(); set_header("X-CSRF-Token", NULL); rejected("403 Forbidden", "csrf");
|
||||||
|
reset(); set_header("X-CSRF-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); rejected("403 Forbidden", "csrf");
|
||||||
|
reset(); set_header("Origin", NULL); rejected("403 Forbidden", "origin");
|
||||||
|
reset(); set_header("Origin", "null"); rejected("403 Forbidden", "origin");
|
||||||
|
reset(); set_header("Origin", "https://attacker"); rejected("403 Forbidden", "origin");
|
||||||
|
reset(); set_header("Sec-Fetch-Site", "cross-site"); rejected("403 Forbidden", "origin");
|
||||||
|
reset(); set_header("Cookie", NULL); rejected("401 Unauthorized", "authentication_required");
|
||||||
|
reset(); request.headers_valid = false; rejected("400 Bad Request", "invalid_request");
|
||||||
|
reset(); request.uri = "/api/firmware?x=1"; rejected("400 Bad Request", "invalid_request");
|
||||||
|
reset(); request.method = HTTP_GET; rejected("400 Bad Request", "invalid_request");
|
||||||
|
reset(); s_ready = false; rejected("503 Service Unavailable", "unavailable");
|
||||||
|
puts("PASS production cookie/Origin/CSRF/admin admission before receive or flash (SDK getters/adapter real, database doubled)");
|
||||||
|
|
||||||
|
const char *invalid_lengths[] = {NULL, "", " ", "+12000", "-12000", "12000x", "1.2e4",
|
||||||
|
"12 000", "12000,12000", "12000 ", "12000\t", "\t12000", "11999", "12001",
|
||||||
|
"000000000000000012000"};
|
||||||
|
for (size_t i = 0; i < sizeof(invalid_lengths) / sizeof(invalid_lengths[0]); ++i) {
|
||||||
|
reset(); set_header("Content-Length", invalid_lengths[i]);
|
||||||
|
rejected("400 Bad Request", "invalid_request");
|
||||||
|
assert(!receives && !begins && !allocations && !notification);
|
||||||
|
}
|
||||||
|
const char *oversized_lengths[] = {"4194305", "4296815136", "4294967296",
|
||||||
|
"18446744073709551615", "18446744073709551616", "99999999999999999999"};
|
||||||
|
for (size_t i = 0; i < sizeof(oversized_lengths) / sizeof(oversized_lengths[0]); ++i) {
|
||||||
|
reset(); set_header("Content-Length", oversized_lengths[i]);
|
||||||
|
/* Explicitly model HTTPD's 64-bit -> ESP32 size_t narrowing. */
|
||||||
|
request.content_len = i == 1 ? 1847840 : i == 2 ? 0 : sizeof(image);
|
||||||
|
rejected("413 Payload Too Large", "firmware_too_large");
|
||||||
|
assert(!receives && !begins && !allocations && !notification);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
request.headers[request.header_count++] = (test_header_t){"content-length", "12000"};
|
||||||
|
rejected("400 Bad Request", "invalid_request"); assert(!receives && !begins && !allocations);
|
||||||
|
reset(); set_header("Content-Length", " 12000"); success();
|
||||||
|
reset(); set_header("Content-Length", "00000000000000012000"); success();
|
||||||
|
puts("PASS raw Content-Length missing/duplicate/malformed/oversized/64-bit wrap and overflow reject before receive/erase; IDF leading-space semantics");
|
||||||
|
|
||||||
|
reset(); set_header("Content-Type", "multipart/form-data"); rejected("415 Unsupported Media Type", "firmware_content_type");
|
||||||
|
reset(); set_header("Content-Type", NULL); rejected("415 Unsupported Media Type", "firmware_content_type");
|
||||||
|
reset(); set_length(0); rejected("400 Bad Request", "invalid_firmware");
|
||||||
|
reset(); set_length(PREFIX_SIZE - 1); rejected("400 Bad Request", "invalid_firmware");
|
||||||
|
reset(); set_length(target.size + 1); rejected("413 Payload Too Large", "firmware_too_large"); assert(!allocations);
|
||||||
|
reset(); target.size = sizeof(image); success();
|
||||||
|
reset(); missing_target = true; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||||
|
reset(); missing_running = true; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||||
|
reset(); target.address = running.address; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||||
|
reset(); target.type = 1; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||||
|
reset(); target.subtype = 0; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||||
|
for (unsigned field = 0; field < 6; ++field) {
|
||||||
|
reset(); esp_image_header_t header; memcpy(&header, image, sizeof(header));
|
||||||
|
if (field == 0) header.magic = 0;
|
||||||
|
if (field == 1) header.chip_id = ESP_CHIP_ID_ESP32;
|
||||||
|
if (field == 2) header.segment_count = 0;
|
||||||
|
if (field == 3) header.hash_appended = 0;
|
||||||
|
memcpy(image, &header, sizeof(header));
|
||||||
|
if (field == 4) memset(image + sizeof(header) + sizeof(esp_image_segment_header_t), 0, 4);
|
||||||
|
if (field == 5) memset(image + sizeof(header), 0, sizeof(esp_image_segment_header_t));
|
||||||
|
rejected("400 Bad Request", "invalid_firmware"); assert(!begins && !aborts);
|
||||||
|
}
|
||||||
|
puts("PASS content type, length/capacity, inactive app selection, format/chip/descriptor/hash header rejection before erase");
|
||||||
|
|
||||||
|
reset(); s_transitioning = true; rejected("503 Service Unavailable", "busy");
|
||||||
|
reset(); identity_busy = true; rejected("503 Service Unavailable", "busy");
|
||||||
|
reset(); mutex_busy = true; rejected("503 Service Unavailable", "busy");
|
||||||
|
reset(); s_server = NULL; rejected("503 Service Unavailable", "busy");
|
||||||
|
reset(); malloc_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
|
||||||
|
reset(); task_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
|
||||||
|
for (int stage = 1; stage <= 5; ++stage) {
|
||||||
|
reset(); stage_fail = stage;
|
||||||
|
rejected(stage == 3 || stage == 4 ? "400 Bad Request" : "500 Internal Server Error",
|
||||||
|
stage == 3 || stage == 4 ? "invalid_firmware" : stage == 5 ? "firmware_commit_failed" : "firmware_write_failed");
|
||||||
|
assert(aborts == (unsigned)(stage == 2));
|
||||||
|
}
|
||||||
|
reset(); stage_fail = 6;
|
||||||
|
rejected("500 Internal Server Error", "firmware_write_failed");
|
||||||
|
assert(begins == 1 && aborts == 1 && !writes && !ends && !ota_live);
|
||||||
|
puts("PASS begin failure after handle publication aborts once; unpublished failure and consumed end never double-abort");
|
||||||
|
reset(); parsed_size--; rejected("400 Bad Request", "invalid_firmware");
|
||||||
|
reset(); parsed_size++; rejected("400 Bad Request", "invalid_firmware");
|
||||||
|
reset(); revoke_at_metadata = true; rejected("401 Unauthorized", "authentication_required");
|
||||||
|
reset(); final_unavailable = true; rejected("401 Unauthorized", "authentication_required");
|
||||||
|
puts("PASS busy/resources/OTA begin-write-end-metadata-commit failures, final auth revocation and exact SDK image length");
|
||||||
|
|
||||||
|
reset(); cutoff = 10; rejected("400 Bad Request", "firmware_incomplete"); assert(!begins);
|
||||||
|
reset(); cutoff = 300; rejected("400 Bad Request", "firmware_incomplete"); assert(aborts == 1);
|
||||||
|
reset(); stall_at = 0; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins && receives == 10);
|
||||||
|
reset(); stall_at = PREFIX_SIZE; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1);
|
||||||
|
reset(); read_us = STALL_US; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins);
|
||||||
|
reset(); max_chunk = 1; read_us = 40000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1 && now_us == TOTAL_US);
|
||||||
|
puts("PASS incomplete body, stalled receive and total slow-drip deadline; no selection/reboot, handle abort when live");
|
||||||
|
|
||||||
|
for (int step = 1; step <= 6; ++step) {
|
||||||
|
reset(); response_fail = step;
|
||||||
|
assert(web_firmware_update_handler(&request) == ESP_FAIL);
|
||||||
|
assert(commits == 1 && !s_transitioning && !identity_token && !allocations_live);
|
||||||
|
assert(notification == 2 && !restarts && !aborts); finish_owner(); assert(!restarts);
|
||||||
|
assert(s_firmware_selected && !atomic_load(&s_reboot_gate));
|
||||||
|
request.received = 0; response_fail = 0;
|
||||||
|
assert(web_firmware_update_handler(&request) == ESP_FAIL);
|
||||||
|
assert(!strcmp(response_status, "409 Conflict") && strstr(response_body, "firmware_selected_reboot_required"));
|
||||||
|
assert(commits == 1 && begins == 1 && !atomic_load(&s_reboot_gate));
|
||||||
|
assert(web_firmware_update_reserve_reboot() == ESP_OK);
|
||||||
|
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||||
|
}
|
||||||
|
puts("PASS postcommit response failure: selected image retained, no automatic reboot, resources/reservations released");
|
||||||
|
printf("PASS %u firmware backend cases; SDK validation, flash and scheduling are mocked, not device evidence\n", cases);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ A complete example (values are illustrative, never defaults to install):
|
|||||||
{"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
|
{"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ap_running": false, "ap_clients": 0, "last_error": 0},
|
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ipv6_linklocal": true, "ipv6_routable": true, "ap_running": false, "ap_clients": 0, "last_error": 0},
|
||||||
"mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
|
"mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -51,6 +51,14 @@ Runtime states are canonical `stopped`, `starting`, `connecting`, `waiting-ip`,
|
|||||||
`.local`. The existing responder is STA-only. `announced` is the service's
|
`.local`. The existing responder is STA-only. `announced` is the service's
|
||||||
expected-announcement status, not a client-observed DNS verification.
|
expected-announcement status, not a client-observed DNS verification.
|
||||||
|
|
||||||
|
`ip` is IPv4 only; `0.0.0.0` means absent, including when `state` is `online`.
|
||||||
|
The required boolean `ipv6_linklocal` and `ipv6_routable` fields report preferred
|
||||||
|
IPv6 address availability (link-local and ULA/GUA respectively). They do not
|
||||||
|
assert a default route or Internet reachability. No actual IPv6 literal addresses
|
||||||
|
are available in this snapshot; reporting is flags only. Settings, quick Network,
|
||||||
|
and the main status summary label IPv4 absence separately from IPv6 availability.
|
||||||
|
The OLED network header uses `LL` and `ULA/GUA` with `Y`/`N` (`?` if unavailable).
|
||||||
|
|
||||||
Wi-Fi working configuration and runtime are copied together under its mutex;
|
Wi-Fi working configuration and runtime are copied together under its mutex;
|
||||||
mDNS is a separate consistent projection, not an atomic cross-domain snapshot.
|
mDNS is a separate consistent projection, not an atomic cross-domain snapshot.
|
||||||
Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503
|
Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503
|
||||||
@@ -202,7 +210,7 @@ remain independent. No terminal lease/transport changes are made by this module.
|
|||||||
- 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte
|
- 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte
|
||||||
parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte
|
parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte
|
||||||
replacement plus the typed fields. No heap JSON tree/cJSON.
|
replacement plus the typed fields. No heap JSON tree/cJSON.
|
||||||
- 2,048-byte snapshot buffer. Maximum escaped fixture: 1,877 payload bytes
|
- 2,048-byte snapshot buffer. Escaped fixture remains below the fixed limit with both IPv6 booleans
|
||||||
(five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers,
|
(five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers,
|
||||||
55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
|
55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
|
||||||
- 128-byte operation response buffer; one static operation and one small timer.
|
- 128-byte operation response buffer; one static operation and one small timer.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sys.dont_write_bytecode = True
|
|||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
ROOT = HERE.parents[1]
|
ROOT = HERE.parents[1]
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
|
'sdkconfig.h': '#pragma once\n#define CONFIG_MDNS_PREDEF_NETIF_STA 1\n#define CONFIG_MDNS_PREDEF_NETIF_AP 0\n#define CONFIG_MDNS_PREDEF_NETIF_ETH 0\n#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3\n',
|
||||||
'esp_err.h': '''#pragma once
|
'esp_err.h': '''#pragma once
|
||||||
typedef int esp_err_t;
|
typedef int esp_err_t;
|
||||||
#define ESP_OK 0
|
#define ESP_OK 0
|
||||||
@@ -34,6 +35,12 @@ typedef int esp_err_t;
|
|||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#define pdTRUE 1
|
#define pdTRUE 1
|
||||||
#define portMAX_DELAY UINT32_MAX
|
#define portMAX_DELAY UINT32_MAX
|
||||||
|
typedef int portMUX_TYPE;
|
||||||
|
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||||
|
void fake_enter(portMUX_TYPE *);
|
||||||
|
void fake_exit(portMUX_TYPE *);
|
||||||
|
#define portENTER_CRITICAL(mux) fake_enter(mux)
|
||||||
|
#define portEXIT_CRITICAL(mux) fake_exit(mux)
|
||||||
''',
|
''',
|
||||||
'freertos/semphr.h': '''#pragma once
|
'freertos/semphr.h': '''#pragma once
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
@@ -62,8 +69,33 @@ void nvs_close(nvs_handle_t);
|
|||||||
#define ESP_MAC_WIFI_SOFTAP 1
|
#define ESP_MAC_WIFI_SOFTAP 1
|
||||||
esp_err_t esp_read_mac(uint8_t *,int);
|
esp_err_t esp_read_mac(uint8_t *,int);
|
||||||
''',
|
''',
|
||||||
'mdns.h': '''#pragma once
|
'esp_timer.h': '#pragma once\n#include <stdint.h>\nint64_t esp_timer_get_time(void);\n',
|
||||||
|
'esp_netif.h': '''#pragma once
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
|
typedef struct { int unused; } esp_netif_t;
|
||||||
|
typedef struct { uint32_t addr; } esp_ip4_addr_t;
|
||||||
|
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
|
||||||
|
typedef struct { esp_ip4_addr_t ip, netmask, gw; } esp_netif_ip_info_t;
|
||||||
|
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
|
||||||
|
bool esp_netif_is_netif_up(esp_netif_t *);
|
||||||
|
esp_err_t esp_netif_get_ip_info(esp_netif_t *,esp_netif_ip_info_t *);
|
||||||
|
int esp_netif_get_all_ip6(esp_netif_t *,esp_ip6_addr_t *);
|
||||||
|
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *),void *);
|
||||||
|
''',
|
||||||
|
'mdns.h': '''#pragma once
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
typedef enum {
|
||||||
|
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
|
||||||
|
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
|
||||||
|
} mdns_event_actions_t;
|
||||||
|
esp_err_t mdns_netif_action(esp_netif_t *,mdns_event_actions_t);
|
||||||
|
esp_err_t mdns_service_add(const char *,const char *,const char *,uint16_t,const void *,size_t);
|
||||||
|
esp_err_t mdns_service_remove(const char *,const char *);
|
||||||
esp_err_t mdns_init(void);
|
esp_err_t mdns_init(void);
|
||||||
esp_err_t mdns_hostname_set(const char *);
|
esp_err_t mdns_hostname_set(const char *);
|
||||||
esp_err_t mdns_instance_name_set(const char *);
|
esp_err_t mdns_instance_name_set(const char *);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/semphr.h"
|
#include "freertos/semphr.h"
|
||||||
#include "nvs.h"
|
#include "nvs.h"
|
||||||
|
#include "mdns.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
static int wifi_mutex, mdns_mutex;
|
static int wifi_mutex, mdns_mutex;
|
||||||
static SemaphoreHandle_t s_mutex=&wifi_mutex;
|
static SemaphoreHandle_t s_mutex=&wifi_mutex;
|
||||||
@@ -23,8 +25,15 @@ static void lock_shared(void) { assert(!wifi_mutex); wifi_mutex=1; }
|
|||||||
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
|
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
|
||||||
static int s_drop_mux, s_queue;
|
static int s_drop_mux, s_queue;
|
||||||
static uint64_t s_queue_drops;
|
static uint64_t s_queue_drops;
|
||||||
#define portENTER_CRITICAL(mux) do { assert((mux)==&s_drop_mux && !s_drop_mux); s_drop_mux=1; } while (0)
|
static portMUX_TYPE *critical;
|
||||||
#define portEXIT_CRITICAL(mux) do { assert((mux)==&s_drop_mux && s_drop_mux); s_drop_mux=0; } while (0)
|
void fake_enter(portMUX_TYPE *mux) {
|
||||||
|
assert(!critical && !*mux);
|
||||||
|
if(mux!=&s_drop_mux) assert(!wifi_mutex && !mdns_mutex);
|
||||||
|
critical=mux; *mux=1;
|
||||||
|
}
|
||||||
|
void fake_exit(portMUX_TYPE *mux) {
|
||||||
|
assert(critical==mux && *mux); *mux=0; critical=NULL;
|
||||||
|
}
|
||||||
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
|
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
|
||||||
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
|
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
|
||||||
if(queue_fail) return 0;
|
if(queue_fail) return 0;
|
||||||
@@ -32,6 +41,7 @@ static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait)
|
|||||||
}
|
}
|
||||||
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
|
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
|
||||||
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
|
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
|
||||||
|
assert(!critical);
|
||||||
if(wait==0 && (snapshot_contention || *mutex)) return 0;
|
if(wait==0 && (snapshot_contention || *mutex)) return 0;
|
||||||
assert(!*mutex); *mutex=1; return pdTRUE;
|
assert(!*mutex); *mutex=1; return pdTRUE;
|
||||||
}
|
}
|
||||||
@@ -63,6 +73,39 @@ esp_err_t nvs_set_blob(nvs_handle_t handle,const char *key,const void *data,size
|
|||||||
}
|
}
|
||||||
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
|
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
|
||||||
void nvs_close(nvs_handle_t handle) { (void)handle; }
|
void nvs_close(nvs_handle_t handle) { (void)handle; }
|
||||||
|
/* Minimal synchronous netif boundary; queue-loss/repair faults live in mdns_phase12. */
|
||||||
|
static esp_netif_t sta;
|
||||||
|
static bool tcpip;
|
||||||
|
static unsigned netif_families=3, requested_families;
|
||||||
|
static int64_t clock_us;
|
||||||
|
int64_t esp_timer_get_time(void) { assert(!mdns_mutex && !critical); return clock_us; }
|
||||||
|
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
|
||||||
|
assert(!mdns_mutex && !critical && !strcmp(key,"WIFI_STA_DEF")); return &sta;
|
||||||
|
}
|
||||||
|
bool esp_netif_is_netif_up(esp_netif_t *netif) { assert(tcpip && netif==&sta); return true; }
|
||||||
|
esp_err_t esp_netif_get_ip_info(esp_netif_t *netif,esp_netif_ip_info_t *ip) {
|
||||||
|
assert(tcpip && netif==&sta); memset(ip,0,sizeof(*ip));
|
||||||
|
ip->ip.addr=(netif_families&1) ? 1 : 0; return ESP_OK;
|
||||||
|
}
|
||||||
|
int esp_netif_get_all_ip6(esp_netif_t *netif,esp_ip6_addr_t *ip) {
|
||||||
|
assert(tcpip && netif==&sta);
|
||||||
|
if(!(netif_families&2)) return 0;
|
||||||
|
memset(ip,0,sizeof(*ip)); ip->addr[0]=0xfe80; return 1;
|
||||||
|
}
|
||||||
|
esp_err_t esp_netif_tcpip_exec(esp_err_t (*callback)(void *),void *context) {
|
||||||
|
assert(!mdns_mutex && !critical && !tcpip); tcpip=true;
|
||||||
|
esp_err_t error=callback(context); tcpip=false; return error;
|
||||||
|
}
|
||||||
|
esp_err_t mdns_netif_action(esp_netif_t *netif,mdns_event_actions_t action) {
|
||||||
|
assert(netif==&sta && !mdns_mutex && !critical && !tcpip);
|
||||||
|
assert(action && !(action & ~(MDNS_EVENT_ENABLE_IP4 | MDNS_EVENT_ENABLE_IP6 |
|
||||||
|
MDNS_EVENT_DISABLE_IP4 | MDNS_EVENT_DISABLE_IP6)));
|
||||||
|
if(action&MDNS_EVENT_ENABLE_IP4) requested_families|=1;
|
||||||
|
if(action&MDNS_EVENT_ENABLE_IP6) requested_families|=2;
|
||||||
|
if(action&MDNS_EVENT_DISABLE_IP4) requested_families&=~1U;
|
||||||
|
if(action&MDNS_EVENT_DISABLE_IP6) requested_families&=~2U;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
static char announced_hostname[60];
|
static char announced_hostname[60];
|
||||||
esp_err_t mdns_init(void) { return ESP_OK; }
|
esp_err_t mdns_init(void) { return ESP_OK; }
|
||||||
esp_err_t mdns_hostname_set(const char *hostname) {
|
esp_err_t mdns_hostname_set(const char *hostname) {
|
||||||
@@ -70,6 +113,13 @@ esp_err_t mdns_hostname_set(const char *hostname) {
|
|||||||
}
|
}
|
||||||
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
|
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
|
||||||
void mdns_free(void) {}
|
void mdns_free(void) {}
|
||||||
|
esp_err_t mdns_service_add(const char *name,const char *type,const char *proto,uint16_t port,const void *txt,size_t count) {
|
||||||
|
assert(!mdns_mutex && !name && type && !strcmp(proto,"_tcp") && (port==443 || port==22) && !txt && !count);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t mdns_service_remove(const char *type,const char *proto) {
|
||||||
|
assert(!mdns_mutex && type && !strcmp(proto,"_tcp")); return ESP_OK;
|
||||||
|
}
|
||||||
#include "manager_production.h"
|
#include "manager_production.h"
|
||||||
|
|
||||||
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
|
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
|
||||||
@@ -85,6 +135,20 @@ int main(void) {
|
|||||||
snapshot_contention=false;
|
snapshot_contention=false;
|
||||||
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
|
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
|
||||||
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
|
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
|
||||||
|
for(unsigned flags=0;flags<8;++flags) {
|
||||||
|
s_shared.snapshot.state=WIFI_MANAGER_STATE_ONLINE;
|
||||||
|
s_shared.snapshot.ip=(flags&4) ? UINT32_C(0x080200c0) : 0;
|
||||||
|
s_shared.snapshot.ipv6_linklocal=(flags&1)!=0;
|
||||||
|
s_shared.snapshot.ipv6_routable=(flags&2)!=0;
|
||||||
|
assert(wifi_manager_get_settings(&projection)==ESP_OK);
|
||||||
|
assert(projection.runtime.state==WIFI_MANAGER_STATE_ONLINE);
|
||||||
|
assert(projection.runtime.ip==s_shared.snapshot.ip);
|
||||||
|
assert(projection.runtime.ipv6_linklocal==s_shared.snapshot.ipv6_linklocal);
|
||||||
|
assert(projection.runtime.ipv6_routable==s_shared.snapshot.ipv6_routable);
|
||||||
|
}
|
||||||
|
s_shared.snapshot.state=WIFI_MANAGER_STATE_STOPPED; s_shared.snapshot.ip=0;
|
||||||
|
s_shared.snapshot.ipv6_linklocal=false; s_shared.snapshot.ipv6_routable=false;
|
||||||
|
puts("PASS real manager projection: IPv6-only/dual-stack preserve independent availability flags and zero IPv4 online");
|
||||||
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
|
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
|
||||||
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
|
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
|
||||||
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
|
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
|
||||||
@@ -188,12 +252,19 @@ int main(void) {
|
|||||||
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
||||||
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
|
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
|
||||||
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
|
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
|
||||||
|
assert(requested_families==3);
|
||||||
|
netif_families=2;
|
||||||
|
assert(mdns_service_reconcile()==ESP_OK && requested_families==2);
|
||||||
|
netif_families=3; clock_us+=30000000;
|
||||||
|
assert(mdns_service_reconcile()==ESP_OK && requested_families==3);
|
||||||
mdns_service_stop();
|
mdns_service_stop();
|
||||||
|
assert(requested_families==0);
|
||||||
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
|
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
|
||||||
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
|
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
|
||||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
||||||
unsigned calls=hostname_calls;
|
unsigned calls=hostname_calls;
|
||||||
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
|
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
|
||||||
|
assert(requested_families==3 && !critical && !tcpip);
|
||||||
assert(mdns_service_get_settings(&m)==ESP_OK);
|
assert(mdns_service_get_settings(&m)==ESP_OK);
|
||||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
|
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
|
||||||
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);
|
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ survives until its owner releases it after service stop/start.
|
|||||||
`python3 tests/web_admin_transport/server_lifecycle.py` additionally links real
|
`python3 tests/web_admin_transport/server_lifecycle.py` additionally links real
|
||||||
production security and mbedTLS to the production HTTPS owner, with NVS/HTTPD doubles,
|
production security and mbedTLS to the production HTTPS owner, with NVS/HTTPD doubles,
|
||||||
for unchanged identity/storage before commit and no rollback after stop/start failure.
|
for unchanged identity/storage before commit and no rollback after stop/start failure.
|
||||||
See [HTTPS identity contracts](../../docs/web_administration.md#service-lifecycle-and-identity-rotation) and [acceptance evidence and limits](../../docs/web_administration_acceptance.md).
|
See [HTTPS identity contracts](../../docs/web_administration.md#service-lifecycle-and-identity-rotation) and [acceptance evidence and limits](../../docs/roadmap.md#phase8-acceptance-evidence).
|
||||||
|
|
||||||
Removed: two credential functions (`show_credentials`, `rotate_credentials`),
|
Removed: two credential functions (`show_credentials`, `rotate_credentials`),
|
||||||
one credential struct type, three username/password capacity/length constants,
|
one credential struct type, three username/password capacity/length constants,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ and inline asset-failure script, not a separately maintained implementation.
|
|||||||
|
|
||||||
Historical 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
|
Historical 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
|
||||||
|
|
||||||
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 36 cases at 320/600/900/1200px: full settings and quick panels plus dashboard card hit-testing, overflow/ellipsis, compact controls, status visibility, and full-page restoration. Quick panels use height 360px. The 2026-09-14 cosmetic update passed these Chromium fixtures and all 161 browser behavior groups; fixtures are not live-device validation. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 36 cases at 320/600/900/1200px: full settings and quick panels plus dashboard card hit-testing, overflow/ellipsis, compact controls, status visibility, and full-page restoration. Quick panels use height 360px. The 2026-09-14 cosmetic update passed these Chromium fixtures and all 161 browser behavior groups; fixtures are not live-device validation. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/roadmap.md#phase8-acceptance-evidence), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
||||||
|
|
||||||
Coverage:
|
Coverage:
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do
|
|||||||
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
|
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
|
||||||
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
|
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
|
||||||
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
|
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
|
||||||
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/web_administration_acceptance.md). The following are historical 8D.10 slice 2 reports, not current totals: parent build PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash.
|
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence). The following are historical 8D.10 slice 2 reports, not current totals: parent build PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash.
|
||||||
The generated endpoint is independently optionally registered (23 handlers), with
|
The generated endpoint is independently optionally registered (23 handlers), with
|
||||||
route-agent lifecycle 15 PASS for failure isolation/restart. UI 57/CSP and lifecycle
|
route-agent lifecycle 15 PASS for failure isolation/restart. UI 57/CSP and lifecycle
|
||||||
15 results are agent-attributed, not claims of the parent's additional reruns.
|
15 results are agent-attributed, not claims of the parent's additional reruns.
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => se
|
|||||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
|
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
|
||||||
const fits = [];
|
const fits = [], uploads = [];
|
||||||
|
class Upload {
|
||||||
|
constructor() { this.upload = {}; this.headers = {}; uploads.push(this); }
|
||||||
|
open(method, url) { this.method = method; this.url = url; }
|
||||||
|
setRequestHeader(key, value) { this.headers[key] = value; }
|
||||||
|
send(file) { this.file = file; }
|
||||||
|
abort() { this.aborted = true; this.onabort?.(); }
|
||||||
|
reply(status, value) { this.status = status; this.responseText = typeof value === 'string' ? value : JSON.stringify(value); this.onload?.(); }
|
||||||
|
}
|
||||||
let serial = 0, now = Date.now();
|
let serial = 0, now = Date.now();
|
||||||
class Clock extends Date { static now() { return now; } }
|
class Clock extends Date { static now() { return now; } }
|
||||||
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
||||||
@@ -60,7 +68,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
|||||||
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
|
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
|
||||||
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
|
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
|
||||||
}},
|
}},
|
||||||
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
|
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket, XMLHttpRequest: Upload,
|
||||||
fetch: async (url, options) => {
|
fetch: async (url, options) => {
|
||||||
// Apply the Origin regression guard to every mutation, including logout.
|
// Apply the Origin regression guard to every mutation, including logout.
|
||||||
assert.ok(Object.hasOwn(queues, url));
|
assert.ok(Object.hasOwn(queues, url));
|
||||||
@@ -83,7 +91,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
|||||||
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
|
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
|
||||||
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
|
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
|
||||||
};
|
};
|
||||||
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire, uploads,
|
||||||
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
|
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
|
||||||
}
|
}
|
||||||
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
||||||
@@ -1399,5 +1407,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
|||||||
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||||
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||||
await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||||
|
await require('./firmware.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
|
||||||
|
const snapshot = {generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true};
|
||||||
|
const file = (extra={}) => ({name:'firmware.bin',size:4194304,type:'',...extra});
|
||||||
|
const n = (b,id) => b.document.getElementById('firmware-'+id);
|
||||||
|
async function open() {
|
||||||
|
const b=await adminBrowser(); b.click('select-settings'); await tick();
|
||||||
|
b.queues['/api/settings/lifecycle'].push(json(snapshot)); b.click('settings-lifecycle'); await tick();
|
||||||
|
n(b,'file').files=[file()]; return b;
|
||||||
|
}
|
||||||
|
async function upload(b) { b.click('firmware-upload'); await tick(); return b.uploads.at(-1); }
|
||||||
|
await test('Firmware admin-only card validates filename hint/size, not unreliable browser MIME, and requires explicit confirmation', async()=>{
|
||||||
|
assert.match(html,/accept="\.bin"/); assert.match(html,/firmware\.bin built for this device/); assert.match(html,/Saved settings are kept/);
|
||||||
|
const u=browser(); u.start(); await tick(); n(u,'file').files=[file()]; await upload(u); assert.equal(u.uploads.length,0);
|
||||||
|
for(const f of [null,file({name:'flash.txt'}),file({size:0}),file({size:4194305}),file({size:NaN}),file({size:1.5})]) {
|
||||||
|
const b=await open(); n(b,'file').files=f?[f]:[]; await upload(b); assert.equal(b.uploads.length,0); assert.match(n(b,'detail').textContent,/nonempty .bin/);
|
||||||
|
}
|
||||||
|
const b=await open(); let warning=''; b.window.confirm=s=>{warning=s;return false;}; await upload(b); assert.equal(b.uploads.length,0);
|
||||||
|
assert.equal(warning,'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.');
|
||||||
|
b.window.confirm=()=>true; n(b,'file').files=[file({name:'APP.BIN',type:'text/plain'})]; const x=await upload(b); assert.ok(x.file);
|
||||||
|
});
|
||||||
|
await test('Firmware sends original File raw with current CSRF and browser-managed headers; one flight and lifecycle controls gated',async()=>{
|
||||||
|
const b=await open(), f=n(b,'file').files[0], x=await upload(b);
|
||||||
|
assert.equal(x.method,'POST'); assert.equal(x.url,'/api/firmware'); assert.equal(x.file,f);
|
||||||
|
assert.deepEqual(x.headers,{'Content-Type':'application/octet-stream','X-CSRF-Token':token}); assert.equal(x.timeout,180000);
|
||||||
|
assert.ok(n(b,'file').disabled && n(b,'upload').disabled);
|
||||||
|
for(const action of ['stop','restart','rotate','reboot']) {assert.ok(b.nodes['lifecycle-'+action].disabled); b.click('lifecycle-'+action);}
|
||||||
|
await upload(b); assert.equal(b.uploads.length,1); assert.ok(!b.calls.some(c=>c.url.endsWith('lifecycle-operation')&&c.method==='POST'));
|
||||||
|
x.upload.onprogress({lengthComputable:true,loaded:25,total:100}); assert.equal(n(b,'progress').value,25);
|
||||||
|
x.upload.onprogress({lengthComputable:true,loaded:100,total:100}); assert.match(n(b,'detail').textContent,/Validating firmware/);
|
||||||
|
for(const e of [{lengthComputable:false},{lengthComputable:true,loaded:NaN,total:100},{lengthComputable:true,loaded:1,total:0}]) { x.upload.onprogress(e); assert.match(n(b,'detail').textContent,/unavailable/); }
|
||||||
|
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/accepted; rebooting/); assert.match(n(b,'detail').textContent,/Reconnect and sign in/);
|
||||||
|
await upload(b); assert.equal(b.uploads.length,1); assert.ok(n(b,'upload').disabled);
|
||||||
|
x.onerror(); assert.match(n(b,'detail').textContent,/accepted; rebooting/);
|
||||||
|
});
|
||||||
|
await test('Firmware fences delayed session validation, changed identity/role/CSRF and logout before sending',async()=>{
|
||||||
|
for(const response of [failure(401),session({role:'user'}),session({role:'admin',username:'changed'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
|
||||||
|
const b=await open(); b.queues['/api/session'].push(response); await upload(b); assert.equal(b.uploads.length,0); assert.ok(b.redirects.length);
|
||||||
|
}
|
||||||
|
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); await upload(b); await upload(b); assert.equal(b.uploads.length,0);
|
||||||
|
b.click('sign-out'); await tick(); d.resolve(session({role:'admin'})); await tick(); assert.equal(b.uploads.length,0);
|
||||||
|
});
|
||||||
|
await test('Firmware session loss/pagehide/logout abort once and fence all late progress/success/error/401 callbacks',async()=>{
|
||||||
|
for(const end of ['logout','pagehide','expiry','identity']) {
|
||||||
|
const b=await open(), x=await upload(b);
|
||||||
|
if(end==='logout') b.click('sign-out');
|
||||||
|
else if(end==='pagehide') b.emit('pagehide');
|
||||||
|
else if(end==='expiry') x.reply(401,{error:'authentication_required'});
|
||||||
|
else { b.queues['/api/session'].push(session({role:'admin',username:'changed'})); b.click('select-serial'); b.click('connection-toggle'); await tick(); b.click('connection-toggle'); }
|
||||||
|
await tick(); assert.ok(x.aborted,end); const detail=n(b,'detail').textContent, redirects=b.redirects.length;
|
||||||
|
assert.match(detail,/may already be installed/);
|
||||||
|
x.upload.onprogress({lengthComputable:true,loaded:50,total:100}); x.reply(200,{ok:true,rebooting:true}); x.reply(401,{}); x.onerror();
|
||||||
|
assert.equal(n(b,'detail').textContent,detail); assert.equal(b.redirects.length,redirects); assert.equal(b.uploads.length,1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await test('Firmware navigation retains single upload and lifecycle gate without resending',async()=>{
|
||||||
|
const b=await open(), x=await upload(b); b.click('settings-serial'); await tick(); b.click('settings-lifecycle'); await tick();
|
||||||
|
assert.ok(!x.aborted); assert.ok(b.nodes['lifecycle-reboot'].disabled); await upload(b); assert.equal(b.uploads.length,1);
|
||||||
|
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/rebooting/);
|
||||||
|
});
|
||||||
|
await test('Firmware maps every backend error safely, never displays arbitrary response text or retries',async()=>{
|
||||||
|
const groups={400:['invalid_request','invalid_firmware','firmware_incomplete'],403:['origin','csrf','admin_required'],408:['firmware_timeout'],413:['firmware_too_large'],415:['firmware_content_type'],500:['firmware_write_failed','firmware_commit_failed'],503:['unavailable','busy','firmware_unavailable','firmware_resources']};
|
||||||
|
for(const [status,codes] of Object.entries(groups)) for(const error of codes) {
|
||||||
|
const b=await open(), x=await upload(b); x.reply(Number(status),{error}); assert.doesNotMatch(n(b,'detail').textContent,/Update status unknown/); assert.ok(n(b,'detail').textContent.length > 0 && n(b,'detail').textContent.length < 100); assert.equal(b.uploads.length,1);
|
||||||
|
}
|
||||||
|
for(const response of ['SECRET ERROR BODY','x'.repeat(129),{ok:true},{ok:true,rebooting:true,secret:'bad'},{error:'<script>'},{error:'toString'}]) {
|
||||||
|
const b=await open(), x=await upload(b); x.reply(200,response); assert.match(n(b,'detail').textContent,/Update status unknown/); assert.doesNotMatch(n(b,'detail').textContent,/SECRET|script|bad/); assert.ok(n(b,'upload').disabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await test('Firmware refuses competing lifecycle work and stale prior-upload callbacks cannot affect an explicit retry',async()=>{
|
||||||
|
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.click('lifecycle-reboot'); await tick();
|
||||||
|
await upload(b); assert.equal(b.uploads.length,0); assert.ok(n(b,'upload').disabled);
|
||||||
|
b.queues['/api/settings/lifecycle-operation'].push(new Response(JSON.stringify({id:1,action:'reboot',state:'pending'}),{status:202}));
|
||||||
|
d.resolve(session({role:'admin'})); await tick(); await upload(b); assert.equal(b.uploads.length,0);
|
||||||
|
const retry=await open(), first=await upload(retry); first.reply(503,{error:'busy'});
|
||||||
|
assert.equal(n(retry,'upload').disabled,false); n(retry,'file').files=[file()]; const second=await upload(retry);
|
||||||
|
const message=n(retry,'detail').textContent; first.reply(401,{}); first.onerror(); first.upload.onprogress({lengthComputable:true,loaded:1,total:2});
|
||||||
|
assert.equal(retry.redirects.length,0); assert.equal(n(retry,'detail').textContent,message); assert.ok(!second.aborted);
|
||||||
|
second.reply(200,{ok:true,rebooting:true}); assert.match(n(retry,'detail').textContent,/accepted; rebooting/);
|
||||||
|
const preflight=await open(); preflight.queues['/api/session'].push(()=>{throw Error('offline');}); await upload(preflight);
|
||||||
|
assert.equal(preflight.uploads.length,0); assert.match(n(preflight,'detail').textContent,/no upload sent/); assert.equal(n(preflight,'upload').disabled,false);
|
||||||
|
});
|
||||||
|
await test('Firmware network/timeout/abort and lost acknowledgement are uncertain, locked, and never auto-replayed',async()=>{
|
||||||
|
for(const event of ['onerror','ontimeout','onabort']) {
|
||||||
|
const b=await open(), x=await upload(b); x[event](); assert.match(n(b,'detail').textContent,/Reconnect and check/); assert.ok(n(b,'upload').disabled);
|
||||||
|
await upload(b); assert.equal(b.uploads.length,1); x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/Update status unknown/);
|
||||||
|
}
|
||||||
|
const fresh=await open(); assert.equal(fresh.uploads.length,0); assert.equal(n(fresh,'upload').disabled,false);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
ap: {policy: 'fallback', channel: 6, ssid: 'access', password_configured: true},
|
ap: {policy: 'fallback', channel: 6, ssid: 'access', password_configured: true},
|
||||||
profiles: Array.from({length: 4}, (_, index) => ({index, enabled: index === 0, priority: index * 10,
|
profiles: Array.from({length: 4}, (_, index) => ({index, enabled: index === 0, priority: index * 10,
|
||||||
security: 'mixed', ssid: index === 0 ? 'office' : '', password_configured: index === 0}))},
|
security: 'mixed', ssid: index === 0 ? 'office' : '', password_configured: index === 0}))},
|
||||||
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ap_running: true, ap_clients: 1, last_error: 0},
|
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ipv6_linklocal: false, ipv6_routable: false, ipv6_addresses: [], ap_running: true, ap_clients: 1, last_error: 0},
|
||||||
mdns: {generation: 3, suffix: 'example', hostname: 'sak-example', announced: false, last_error: 0}});
|
mdns: {generation: 3, suffix: 'example', hostname: 'sak-example', announced: false, last_error: 0}});
|
||||||
const reply = (id = 42, state = 'pending', action = 'wifi-patch', status = 200, error = 0) => new Response(JSON.stringify({id, action, state, error}), {status});
|
const reply = (id = 42, state = 'pending', action = 'wifi-patch', status = 200, error = 0) => new Response(JSON.stringify({id, action, state, error}), {status});
|
||||||
const ack = action => reply(42, 'pending', action, 202);
|
const ack = action => reply(42, 'pending', action, 202);
|
||||||
@@ -28,6 +28,90 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
||||||
b.fire(1000); await tick();
|
b.fire(1000); await tick();
|
||||||
}
|
}
|
||||||
|
await test('IPv6-only and dual-stack availability renders in Settings and quick Network without a route claim', async () => {
|
||||||
|
for (const quick of [false, true]) for (const ip of ['0.0.0.0', '192.0.2.8']) {
|
||||||
|
for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false]]) {
|
||||||
|
const v = fixture(); Object.assign(v.runtime, {state: 'online', ip, ipv6_linklocal: linklocal, ipv6_routable: routable});
|
||||||
|
const b = quick ? await adminBrowser() : await open(v);
|
||||||
|
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
|
||||||
|
assert.equal(n(b, 'edit').hidden, false);
|
||||||
|
const rows = n(b, 'summary').children;
|
||||||
|
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
||||||
|
assert.equal(values.IPv4, ip === '0.0.0.0' ? 'none' : ip);
|
||||||
|
assert.equal(values['IPv6 link-local'], linklocal ? 'available' : 'none');
|
||||||
|
assert.equal(values['IPv6 ULA/GUA'], routable ? 'available' : 'none');
|
||||||
|
assert.match(values['IPv6 reporting'], /Preferred addresses; no route or Internet reachability guarantee/);
|
||||||
|
assert.ok(!n(b, 'summary').textContent.includes('0.0.0.0'));
|
||||||
|
if (quick) {
|
||||||
|
assert.ok(n(b, 'detail').textContent.includes('IPv4: ' + values.IPv4));
|
||||||
|
assert.ok(n(b, 'detail').textContent.includes('IPv6 link-local: ' + values['IPv6 link-local']));
|
||||||
|
assert.ok(n(b, 'detail').textContent.includes('ULA/GUA: ' + values['IPv6 ULA/GUA']));
|
||||||
|
}
|
||||||
|
assert.equal(posts(b).length, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const address = (prefix, tail = '0001') => prefix + ':0000:0000:0000:0000:0000:0000:' + tail;
|
||||||
|
const groups = {'IPv6 link-local addresses': ['fe80', 'febf'], 'IPv6 ULA addresses': ['fc00', 'fd12'], 'IPv6 GUA addresses': ['2001', '3fff']};
|
||||||
|
await test('Network preferred IPv6 addresses group every entry as safe text, including empty and same-kind slots', async () => {
|
||||||
|
const samples = [[], ['fe80', 'fd12', '2001'], ...Object.values(groups).map(([prefix, other]) => [prefix, other, prefix])];
|
||||||
|
for (const quick of [false, true]) for (const prefixes of samples) {
|
||||||
|
const v = fixture(); v.runtime.ipv6_addresses = prefixes.map((p, i) => address(p, '000' + (i + 1)));
|
||||||
|
const b = quick ? await adminBrowser() : await open(v);
|
||||||
|
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
|
||||||
|
const rows = n(b, 'summary').children;
|
||||||
|
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
||||||
|
for (const [label, kinds] of Object.entries(groups)) {
|
||||||
|
assert.equal(values[label], v.runtime.ipv6_addresses.filter(a => kinds.some(p => a.startsWith(p))).join(', ') || 'none');
|
||||||
|
}
|
||||||
|
rows.forEach(node => assert.equal(node.children.length, 0));
|
||||||
|
assert.match(values['IPv6 reporting'], /Link-local access requires the client interface scope/);
|
||||||
|
assert.equal(posts(b).length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await test('Network rejects missing, malformed, oversized and injected IPv6 address arrays in both controllers', async () => {
|
||||||
|
const a = address('fe80');
|
||||||
|
const bad = [undefined, null, {}, a, 3, [a, a, a, a], [null], [1], [true], [{}], [[a]], [''],
|
||||||
|
['fe80::1'], [a.toUpperCase()], [a + '\n'], [a + '0'], [a.slice(1)], [a.replace('0001', '000g')],
|
||||||
|
[a + '%eth0'], ['[' + a + ']'], ['<img onerror="SECRET">'], [a, '<script>SECRET</script>']];
|
||||||
|
for (const quick of [false, true]) {
|
||||||
|
const b = quick ? await adminBrowser() : await open();
|
||||||
|
if (quick) { b.queues[path].push(json(fixture())); b.click('quick-network'); await tick(); }
|
||||||
|
const before = n(b, 'summary').textContent;
|
||||||
|
for (const value of bad) {
|
||||||
|
const v = fixture(); v.runtime.ipv6_addresses = value;
|
||||||
|
b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||||
|
assert.match(n(b, 'detail').textContent, /stale.*invalid/);
|
||||||
|
assert.equal(n(b, 'summary').textContent, before); assert.ok(n(b, 'apply').disabled); safe(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await test('Both Network controllers accept exactly 2304 bytes and reject 2305 bytes', async () => {
|
||||||
|
for (const quick of [false, true]) {
|
||||||
|
const v = fixture(); v.runtime.ipv6_addresses = ['fe80', 'fd12', '2001'].map(p => address(p));
|
||||||
|
const encoded = JSON.stringify(v);
|
||||||
|
const b = quick ? await adminBrowser() : await open();
|
||||||
|
b.queues[path].push(new Response(encoded.padEnd(2304, ' ')));
|
||||||
|
b.click(quick ? 'quick-network' : 'network-refresh'); await tick();
|
||||||
|
assert.equal(n(b, 'apply').disabled, false);
|
||||||
|
assert.ok(n(b, 'summary').textContent.includes(address('2001')));
|
||||||
|
b.queues[path].push(new Response(encoded.padEnd(2305, ' '))); b.click('network-refresh'); await tick();
|
||||||
|
assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await test('Main status reports IPv6-only/dual-stack flags and treats malformed or missing flags as unknown', async () => {
|
||||||
|
for (const ip of ['0.0.0.0', '192.0.2.8']) for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false], [1, true], [true, 'false'], [null, false], [false, undefined]]) {
|
||||||
|
const b = browser();
|
||||||
|
b.queues['/api/status'].push(json({wifi: {available: true, state: 'online', sta_ipv4: ip, ipv6_linklocal: linklocal, ipv6_routable: routable}}));
|
||||||
|
b.start(); await tick(); const text = b.nodes['wifi-summary'].textContent;
|
||||||
|
assert.ok(text.includes('IPv4: ' + (ip === '0.0.0.0' ? 'none' : ip)));
|
||||||
|
assert.ok(!text.includes('0.0.0.0'));
|
||||||
|
if (typeof linklocal === 'boolean' && typeof routable === 'boolean') {
|
||||||
|
assert.ok(text.includes('IPv6 link-local: ' + (linklocal ? 'available' : 'none')));
|
||||||
|
assert.ok(text.includes('ULA/GUA: ' + (routable ? 'available' : 'none')));
|
||||||
|
} else assert.ok(text.includes('IPv6 availability: unknown'));
|
||||||
|
}
|
||||||
|
});
|
||||||
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
|
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
|
||||||
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
|
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
|
||||||
b.click('quick-network'); await tick();
|
b.click('quick-network'); await tick();
|
||||||
@@ -148,7 +232,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
|
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
|
||||||
const b = await open(v), summary = n(b, 'summary');
|
const b = await open(v), summary = n(b, 'summary');
|
||||||
const rows = summary.children;
|
const rows = summary.children;
|
||||||
assert.equal(rows.length, 58);
|
assert.equal(rows.length, 70);
|
||||||
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
|
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
|
||||||
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
||||||
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
|
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
|
||||||
@@ -162,7 +246,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
assert.ok(rows.every(node => node.parentNode === null));
|
assert.ok(rows.every(node => node.parentNode === null));
|
||||||
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
|
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
|
||||||
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
|
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
|
||||||
assert.equal(summary.children.length, 58); assert.ok(!summary.textContent.includes('<img'));
|
assert.equal(summary.children.length, 70); assert.ok(!summary.textContent.includes('<img'));
|
||||||
});
|
});
|
||||||
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
|
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
|
||||||
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
|
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
|
||||||
@@ -176,6 +260,10 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
v => v.runtime.state = '<img>', v => v.runtime.started = 1, v => v.runtime.active_profile = 4, v => v.runtime.active_profile = -2,
|
v => v.runtime.state = '<img>', v => v.runtime.started = 1, v => v.runtime.active_profile = 4, v => v.runtime.active_profile = -2,
|
||||||
v => v.runtime.ip = '256.1.1.1', v => v.runtime.ip = '<img>', v => v.runtime.ap_running = 1, v => v.runtime.ap_clients = 256, v => v.runtime.last_error = 2147483648,
|
v => v.runtime.ip = '256.1.1.1', v => v.runtime.ip = '<img>', v => v.runtime.ap_running = 1, v => v.runtime.ap_clients = 256, v => v.runtime.last_error = 2147483648,
|
||||||
v => v.mdns.suffix = 'A', v => v.mdns.suffix = '-x', v => v.mdns.suffix = 'x-', v => v.mdns.hostname = 'not-matching', v => v.mdns.announced = 0, v => v.mdns.last_error = null];
|
v => v.mdns.suffix = 'A', v => v.mdns.suffix = '-x', v => v.mdns.suffix = 'x-', v => v.mdns.hostname = 'not-matching', v => v.mdns.announced = 0, v => v.mdns.last_error = null];
|
||||||
|
for (const key of ['ipv6_linklocal', 'ipv6_routable']) {
|
||||||
|
edits.push(v => delete v.runtime[key]);
|
||||||
|
for (const value of [null, 0, 1, 'true', 'false', [], {}]) edits.push(v => v.runtime[key] = value);
|
||||||
|
}
|
||||||
const b = await open(), before = n(b, 'summary').textContent;
|
const b = await open(), before = n(b, 'summary').textContent;
|
||||||
for (const change of edits) {
|
for (const change of edits) {
|
||||||
const v = fixture(); change(v); b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
const v = fixture(); change(v); b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||||
@@ -185,16 +273,16 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); assert.ok(n(b, 'apply').disabled);
|
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); assert.ok(n(b, 'apply').disabled);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await test('Network snapshot 2048-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
|
await test('Network snapshot 2304-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
|
||||||
const b = await open();
|
const b = await open();
|
||||||
for (const response of [new Response(' '.repeat(2049)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
|
for (const response of [new Response(' '.repeat(2305)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
|
||||||
b.queues[path].push(response); b.click('network-refresh'); await tick(); assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled); safe(b);
|
b.queues[path].push(response); b.click('network-refresh'); await tick(); assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled); safe(b);
|
||||||
}
|
}
|
||||||
const v = fixture(); v.wifi.ap.ssid = '\xff'.repeat(32); v.wifi.generation = v.mdns.generation = 4294967295;
|
const v = fixture(); v.wifi.ap.ssid = '\xff'.repeat(32); v.wifi.generation = v.mdns.generation = 4294967295;
|
||||||
for (const p of v.wifi.profiles) p.ssid = '\xff'.repeat(32);
|
for (const p of v.wifi.profiles) p.ssid = '\xff'.repeat(32);
|
||||||
v.mdns.suffix = 'a'.repeat(55); v.mdns.hostname = 'sak-' + v.mdns.suffix;
|
v.mdns.suffix = 'a'.repeat(55); v.mdns.hostname = 'sak-' + v.mdns.suffix;
|
||||||
const encoded = JSON.stringify(v).replace(/[\x7f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
const encoded = JSON.stringify(v).replace(/[\x7f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||||
assert.ok(Buffer.byteLength(encoded) < 2048); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
|
assert.ok(Buffer.byteLength(encoded) < 2304); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
|
||||||
assert.equal(n(b, 'edit').hidden, false); assert.equal(n(b, 'apply').disabled, false); assert.equal(n(b, 'ssid-mode').value, 'hex');
|
assert.equal(n(b, 'edit').hidden, false); assert.equal(n(b, 'apply').disabled, false); assert.equal(n(b, 'ssid-mode').value, 'hex');
|
||||||
v.wifi.ap.ssid = '<img onerror="x">'; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
v.wifi.ap.ssid = '<img onerror="x">'; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||||
assert.equal(n(b, 'ssid').value, '<img onerror="x">'); assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify('<img onerror="x">')));
|
assert.equal(n(b, 'ssid').value, '<img onerror="x">'); assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify('<img onerror="x">')));
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Wi-Fi Phase 12 focused host regression
|
||||||
|
|
||||||
|
Run `python3 tests/wifi_phase12/run.py`. The renderer tests use the installed public `esp_netif_ip_addr.h` at `~/.platformio/packages/framework-espidf/components/esp_netif/include/` with host endian/config shims (no SDK modification). Optional `--sanitize` requires host ASan/UBSan runtime libraries. No PlatformIO build or device access is performed. Compilation/executables use a temporary directory.
|
||||||
|
|
||||||
|
The harness extracts production hostname, address reconciliation, IP callback, and deadline functions verbatim. Real manager/config headers define the data contracts. Host fakes model driver association, netif-up state, IPv6 slot states, TCP/IP execution, and clock; surrounding failover/driver operations are call-count fakes, not a full scheduler/network simulation. Wiring assertions cover pre-connect hostname installation and message dispatch.
|
||||||
|
|
||||||
|
Coverage includes:
|
||||||
|
|
||||||
|
- Configured hostname and rename without disconnect; full 59-byte hostname; config-read failure.
|
||||||
|
- STA-only IPv6 event filtering, null events, and IPv4 netif filtering.
|
||||||
|
- Explicit STA SLAAC enablement, link-local creation, no repeated tentative/duplicate DAD.
|
||||||
|
- Link-local-only, ULA/GUA, IPv4-only, and dual-stack readiness.
|
||||||
|
- IPv4 loss while IPv6 remains, IPv6 deprecation/removal and restoration.
|
||||||
|
- No IPv4 deadline failover on usable IPv6; no perpetual fallback stability reset.
|
||||||
|
- Dropped address/association events, dropped online disconnect, recreated LL after missed disconnect/reconnect, and stale events during stop/profile advance.
|
||||||
|
- Permanent owner wakeup, including boot-disabled and stopped Wi-Fi; offline service withdrawal, failed record updates and pending reannouncement retries at one-second cadence.
|
||||||
|
- Busy-loop calls do not repeat periodic work; persistent mDNS failure does not generate per-pass warnings.
|
||||||
|
- Permanent hostname pointer lifetime, failed offline rename retry, and mutex/TCP-IP-context assertions for address and hostname access.
|
||||||
|
- Address-read failure/uncertain callback completion withdraws stale readiness without retiring the recovery AP; later successful reads restore readiness.
|
||||||
|
- Verbatim production console rendering of simultaneous preferred link-local, ULA and GUA addresses, with non-symmetric network-order words and IDF public formatting macros; client-interface zone guidance never renders the ESP32 zone.
|
||||||
|
- Full numeric list and count clearing on failed callback admission, skipped callback, IP-info error, netif-down and the production disconnect-clear helper; wiring assertions require intentional disconnect, stop and disconnect-event paths to call that helper. Tentative, duplicate and deprecated slots are excluded by the preferred-getter fake matching SDK semantics.
|
||||||
|
- Increasing configured SDK slots to four fails the production compile-time capacity assertion.
|
||||||
|
|
||||||
|
## Contract and SDK evidence
|
||||||
|
|
||||||
|
`ONLINE` means a current association/netif with IPv4 or a **preferred** IPv6 link-local/ULA/GUA address. It is not an Internet/default-route check. Deprecated-only addresses do not qualify for new service readiness. In particular, link-local-only operation suppresses DHCPv4-driven profile failover and can retire the fallback AP after the existing stability interval. IPv6 link-local clients need an interface scope. Snapshot IPv4 fields remain zero on IPv6-only networks; `ipv6_linklocal` / `ipv6_routable` booleans report preferred-address availability, not routing success. The console also lists up to three numeric preferred IPv6 addresses from the exact same TCP/IP observation as those flags. Count and all address storage are published/cleared together under the manager mutex. The configured lwIP slot count must not exceed three (compile-time contract, no silent truncation). Web/OLED continue consuming the existing flags without adding list output.
|
||||||
|
|
||||||
|
Inspected installed ESP-IDF 5.5.0 sources:
|
||||||
|
|
||||||
|
- `esp_netif/lwip/esp_netif_lwip.c`: netif-up and address getters access lwIP directly; all manager netif-up/address checks now execute inside the TCP/IP callback. Preferred getter excludes tentative/duplicate/deprecated/invalid addresses. Disconnect invalidates/clears IPv6 slots. `esp_netif_tcpip_exec` supplies synchronous TCP/IP context, with no retained stack request on return. Its IDF 5.5 wrapper ignores the underlying `tcpip_send_msg_wait_sem` result: explicit completion markers therefore detect a callback that was never executed despite an apparent `ESP_OK`. No hard wall-clock guarantee is made if upstream stalls.
|
||||||
|
- `lwip/port/include/lwipopts.h`, `lwip/src/include/lwip/opt.h`, and `esp_netif_start_api`: saved `CONFIG_LWIP_IPV6_AUTOCONFIG` is disabled, but lwIP SLAAC remains compiled via `LWIP_IPV6_AUTOCONFIG`. Explicit per-STA `netif_set_ip6_autoconfig_enabled` is needed; AP policy is untouched.
|
||||||
|
- `esp_netif_set_hostname_api` has a 32-byte limit, below the existing configured hostname maximum of 59. The manager therefore installs permanent bounded storage through `netif_set_hostname` in TCP/IP context, without transferring ownership to esp-netif. `netif_add` does not clear the hostname; `esp_netif/lwip/netif/wlanif.c` preserves it via `esp_netif_get_hostname` during initialization.
|
||||||
|
- `lwip/src/core/ipv4/dhcp.c`: option 12 is read from the current netif hostname in subsequent outgoing DHCP exchanges (request/renew/rebind included). Rename does not force release/reacquisition or promise immediate router/DNS cache replacement.
|
||||||
|
|
||||||
|
Reverify these boundaries for an SDK upgrade. Tests do not emulate DHCP wire packets, router advertisements, actual DAD timers, mDNS component behavior, or socket listeners. A target syntax check is not a linked firmware build. Device and integration validation remain separate.
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
|
||||||
|
The owner calls the new `mdns_service_reconcile` every second even offline/stopped. A pending online reannouncement uses `mdns_service_reannounce`, which also reconciles records. Listener owners' lock-free availability stores need no queue wakeup; changes and failures converge on subsequent passes. Reconciliation never initializes the responder; startup still requires either-family STA readiness. Failures remain nonfatal, visible through service error status, and do not log each retry. Upstream mDNS calls may block: the cadence bounds attempt frequency, not upstream execution time.
|
||||||
|
|
||||||
|
`wifi_manager_mdns_reannounce` also refreshes DHCP option 12 while offline. Periodic hostname refresh retries failed/missed DHCP-name changes without reconnecting. The permanent hostname buffer is only read/written in TCP/IP context; no application status caller borrows that pointer (use the copied mDNS snapshot). Pending mDNS rename errors retry online. Other owners should consume the IPv6 snapshot flags rather than infer readiness from `snapshot.ip != 0`.
|
||||||
|
|
||||||
|
## Diagnostic addition resource budget and handoff
|
||||||
|
|
||||||
|
The numeric payload is three arrays of four network-order `uint32_t` words (48 bytes), plus a one-byte count that fits existing snapshot padding. No strings, device zones, task, socket, heap allocation or queue-message growth are introduced. Host layout measurements with production headers: snapshot 232 → 280 bytes; settings projection 424 → 472 bytes; manager shared storage 760 → 808 bytes; temporary address observation 16 → 64 bytes. Thus the manager's static storage grows by 48 bytes, and each existing snapshot/settings copy (console, web, local UI) grows by 48 bytes. The owner observation adds 48 bytes of transient stack payload; the existing three-entry SDK enumeration scratch is unchanged. Actual compiler stack-frame/high-water effects and linked target RAM remain for the parent's build/device checks; host sizes are not runtime headroom evidence.
|
||||||
|
|
||||||
|
Scoped validation: normal host suite and slot-cap negative compile PASS. `--sanitize` could not link on this host because `libasan.so.8.0.0` / `libubsan.so.1.0.0` are missing. No dependency changes, PlatformIO build or hardware operation performed. Parent owns durable documentation updates (the flags-only statements in agent memory/command docs are now stale for console status) and firmware build. Production edits are limited to `src/wifi_manager.c`, `src/wifi_manager.h`, and `src/wifi_console.c`; tests/docs for this addition stay here.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compile verbatim manager address/deadline paths with bounded host fakes."""
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parents[1]
|
||||||
|
source = (ROOT / 'src/wifi_manager.c').read_text()
|
||||||
|
|
||||||
|
def function(name):
|
||||||
|
# Definitions end at a column-zero brace; skip forward declarations.
|
||||||
|
import re
|
||||||
|
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\)\n\{', source, re.M)
|
||||||
|
assert match, name
|
||||||
|
start = match.start()
|
||||||
|
return source[start:source.index('\n}', match.end()) + 2]
|
||||||
|
|
||||||
|
types = source[source.index('typedef enum {'):source.index('static SemaphoreHandle_t')]
|
||||||
|
addresses = source[source.index('typedef struct {\n esp_netif_ip_info_t ip4;'):source.index('/* IDF\'s IPv6 getters')]
|
||||||
|
hostname_request = source[source.index('typedef struct {\n const char *hostname;'):source.index('static esp_err_t set_station_hostname')]
|
||||||
|
names = ['set_station_hostname', 'refresh_station_hostname', 'start_mdns_announcement',
|
||||||
|
'read_station_addresses', 'handle_got_ip', 'clear_station_network_snapshot',
|
||||||
|
'handle_expired_deadlines', 'next_runtime_deadline', 'runtime_wait_ticks',
|
||||||
|
'ip_event_callback']
|
||||||
|
# Check wiring not present in the extracted paths.
|
||||||
|
connect = function('start_next_profile')
|
||||||
|
assert connect.index('refresh_station_hostname()') < connect.index('esp_wifi_connect()')
|
||||||
|
message = function('handle_message')
|
||||||
|
assert 'case MESSAGE_STA_GOT_IP6:' in message
|
||||||
|
assert 'refresh_station_hostname()' in message
|
||||||
|
assert 'handle_got_ip(runtime, message);' in message
|
||||||
|
assert '#define WIFI_MANAGER_QUEUE_LENGTH 16U' in source
|
||||||
|
assert '#define WIFI_MANAGER_TASK_STACK_SIZE 6144U' in source
|
||||||
|
assert 'esp_netif_tcpip_exec(read_station_addresses, &addresses)' in source
|
||||||
|
assert 'esp_netif_is_netif_up' not in function('handle_got_ip')
|
||||||
|
assert 'mdns_service_reconcile()' in function('handle_expired_deadlines')
|
||||||
|
assert 'runtime->radio_started ? runtime->reconcile_deadline' not in source
|
||||||
|
for name in ['handle_sta_disconnected', 'mark_intentional_disconnect', 'stop_radio']:
|
||||||
|
assert 'clear_station_network_snapshot();' in function(name)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix='wifi-phase12-') as directory:
|
||||||
|
tmp = Path(directory)
|
||||||
|
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n#define ESP_OK 0\n#define ESP_FAIL -1\n#define ESP_ERR_INVALID_STATE 1\n#define ESP_ERR_INVALID_ARG 2\n')
|
||||||
|
(tmp / 'esp_wifi_types.h').write_text('#pragma once\ntypedef int wifi_auth_mode_t;\n')
|
||||||
|
# Use the installed IDF public address types/macros, not a reimplementation.
|
||||||
|
sdk_header = Path.home() / '.platformio/packages/framework-espidf/components/esp_netif/include/esp_netif_ip_addr.h'
|
||||||
|
(tmp / 'esp_netif_ip_addr.h').write_text(sdk_header.read_text())
|
||||||
|
(tmp / 'machine').mkdir()
|
||||||
|
(tmp / 'machine/endian.h').write_text('#include <endian.h>\n')
|
||||||
|
(tmp / 'sdkconfig.h').write_text('#define CONFIG_LWIP_IPV6 1\n')
|
||||||
|
(tmp / 'types.inc').write_text(types + addresses + hostname_request)
|
||||||
|
console = (ROOT / 'src/wifi_console.c').read_text()
|
||||||
|
start = console.index('static void print_ipv6_addresses(')
|
||||||
|
(tmp / 'console.inc').write_text(console[start:console.index('\n}', start) + 2])
|
||||||
|
assert 'print_ipv6_addresses(&snapshot);' in console
|
||||||
|
(tmp / 'production.inc').write_text('\n\n'.join(function(name) for name in names))
|
||||||
|
sanitizer = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else []
|
||||||
|
subprocess.run(['cc', '-std=gnu17', '-Wall', '-Wextra', '-Werror', *sanitizer,
|
||||||
|
'-I' + str(tmp), '-I' + str(ROOT / 'src'), str(HERE / 'test.c'),
|
||||||
|
'-o', str(tmp / 'test')], check=True, timeout=30)
|
||||||
|
subprocess.run([str(tmp / 'test')], check=True, timeout=20)
|
||||||
|
# Increasing SDK slots must fail compilation rather than truncate/overrun.
|
||||||
|
oversized = (HERE / 'test.c').read_text().replace('#define LWIP_IPV6_NUM_ADDRESSES 3', '#define LWIP_IPV6_NUM_ADDRESSES 4')
|
||||||
|
(tmp / 'oversized.c').write_text(oversized)
|
||||||
|
result = subprocess.run(['cc', '-std=gnu17', '-I' + str(tmp), '-I' + str(ROOT / 'src'),
|
||||||
|
'-fsyntax-only', str(tmp / 'oversized.c')], capture_output=True, text=True)
|
||||||
|
assert result.returncode != 0 and 'IPv6 snapshot capacity' in result.stderr
|
||||||
|
print('IPv6 slot-capacity compile contract PASS')
|
||||||
|
header = (ROOT / 'src/wifi_manager.h').read_text()
|
||||||
|
baseline = header.replace(' uint8_t ipv6_count;\n', '').replace(
|
||||||
|
' wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];\n', '')
|
||||||
|
(tmp / 'baseline.h').write_text(baseline)
|
||||||
|
(tmp / 'sizes.c').write_text('#include <stdio.h>\n#include "baseline.h"\n'
|
||||||
|
'int main(void) { printf("baseline snapshot=%zu settings=%zu bytes\\n", '
|
||||||
|
'sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t)); }\n')
|
||||||
|
subprocess.run(['cc', '-I' + str(tmp), '-I' + str(ROOT / 'src'), str(tmp / 'sizes.c'),
|
||||||
|
'-o', str(tmp / 'sizes')], check=True, timeout=30)
|
||||||
|
subprocess.run([str(tmp / 'sizes')], check=True, timeout=20)
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include "esp_netif_ip_addr.h"
|
||||||
|
#define WIFI_AUTH_OPEN 0
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
#include "mdns_service.h"
|
||||||
|
|
||||||
|
#define LWIP_NETIF_HOSTNAME 1
|
||||||
|
#define CONFIG_LWIP_IPV6 1
|
||||||
|
static char s_station_hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
|
||||||
|
#define LWIP_IPV6_AUTOCONFIG 1
|
||||||
|
#define LWIP_IPV6_NUM_ADDRESSES 3
|
||||||
|
#define IP6_ADDR_INVALID 0
|
||||||
|
#define TENTATIVE 1
|
||||||
|
#define PREFERRED 2
|
||||||
|
#define DEPRECATED 3
|
||||||
|
#define DUPLICATE 4
|
||||||
|
#define WIFI_MANAGER_ATTEMPT_US 12000000LL
|
||||||
|
#define WIFI_MANAGER_STABLE_US 30000000LL
|
||||||
|
#define WIFI_MANAGER_RECONCILE_US 1000000LL
|
||||||
|
#define WIFI_MANAGER_DISCONNECT_SETTLE_US 1000000LL
|
||||||
|
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2
|
||||||
|
#define ESP_LOGW(...) (++warnings)
|
||||||
|
#define portMAX_DELAY UINT32_MAX
|
||||||
|
#define pdMS_TO_TICKS(x) (x)
|
||||||
|
typedef uint32_t TickType_t;
|
||||||
|
typedef struct { uint32_t addr; } ip4_t;
|
||||||
|
typedef struct { ip4_t ip, netmask, gw; } esp_netif_ip_info_t;
|
||||||
|
|
||||||
|
typedef struct { uint8_t ssid[33], primary; int8_t rssi; int authmode; } wifi_ap_record_t;
|
||||||
|
typedef struct { int unused; } esp_netif_t;
|
||||||
|
static esp_netif_t sta, other;
|
||||||
|
static esp_netif_t *s_sta_netif = &sta;
|
||||||
|
typedef const char *esp_event_base_t;
|
||||||
|
static const char *IP_EVENT = "ip";
|
||||||
|
enum { IP_EVENT_STA_GOT_IP, IP_EVENT_STA_LOST_IP, IP_EVENT_GOT_IP6 };
|
||||||
|
typedef struct { esp_netif_t *esp_netif; esp_netif_ip_info_t ip_info; } ip_event_got_ip_t;
|
||||||
|
typedef struct { esp_netif_t *esp_netif; } ip_event_got_ip6_t;
|
||||||
|
#include "types.inc"
|
||||||
|
static manager_shared_t s_shared;
|
||||||
|
struct netif { int state[3]; bool autoconfig; esp_ip6_addr_t addresses[3]; const char *hostname; };
|
||||||
|
static struct netif netif;
|
||||||
|
static bool up, associated, matching, in_tcpip, byte_ssid;
|
||||||
|
static int64_t now;
|
||||||
|
static esp_netif_ip_info_t ip4;
|
||||||
|
static int mdns_starts, mdns_stops, disconnects, next_profiles, cycles, ap_disables;
|
||||||
|
static int creations, messages, mdns_reconciles, mdns_reannounces, tcpip_calls;
|
||||||
|
static int shared_locks, warnings;
|
||||||
|
static esp_err_t tcpip_error, mdns_error, ip_info_error;
|
||||||
|
static bool https_available, https_registered, tcpip_skip;
|
||||||
|
static manager_message_t last_message;
|
||||||
|
static char hostname[64], configured[64];
|
||||||
|
static esp_err_t hostname_error;
|
||||||
|
static void lock_shared(void) { assert(shared_locks == 0); ++shared_locks; }
|
||||||
|
static void unlock_shared(void) { assert(shared_locks == 1); --shared_locks; }
|
||||||
|
static bool manager_is_started(void) { return s_shared.snapshot.started; }
|
||||||
|
static void set_state(wifi_manager_state_t state) { s_shared.snapshot.state = state; }
|
||||||
|
static void set_last_error(esp_err_t error) { s_shared.snapshot.last_error = error; }
|
||||||
|
static int64_t esp_timer_get_time(void) { return now; }
|
||||||
|
static bool esp_netif_is_netif_up(esp_netif_t *n) { assert(n == &sta && in_tcpip); return up; }
|
||||||
|
static esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *out)
|
||||||
|
{ assert(n == &sta && in_tcpip); *out = ip4; return ip_info_error; }
|
||||||
|
static void *esp_netif_get_netif_impl(esp_netif_t *n)
|
||||||
|
{ assert(n == &sta && in_tcpip); return &netif; }
|
||||||
|
static void netif_set_ip6_autoconfig_enabled(struct netif *n, int enabled)
|
||||||
|
{ assert(in_tcpip); n->autoconfig = enabled; }
|
||||||
|
static int netif_ip6_addr_state(struct netif *n, int slot) { return n->state[slot]; }
|
||||||
|
static void netif_create_ip6_linklocal_address(struct netif *n, int mac)
|
||||||
|
{ assert(in_tcpip && mac == 1); ++creations; n->state[0] = TENTATIVE; }
|
||||||
|
static int esp_netif_get_all_preferred_ip6(esp_netif_t *n, esp_ip6_addr_t *out)
|
||||||
|
{
|
||||||
|
assert(n == &sta && in_tcpip);
|
||||||
|
int count = 0;
|
||||||
|
for (int i = 0; i < 3; ++i) if (netif.state[i] == PREFERRED) out[count++] = netif.addresses[i];
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
static esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *context)
|
||||||
|
{ assert(!in_tcpip && shared_locks == 0); ++tcpip_calls;
|
||||||
|
if (tcpip_error != ESP_OK) return tcpip_error;
|
||||||
|
if (tcpip_skip) return ESP_OK; /* IDF wrapper ignores failed enqueue. */
|
||||||
|
in_tcpip = true; esp_err_t e = fn(context); in_tcpip = false; return e; }
|
||||||
|
static esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *out)
|
||||||
|
{ memset(out, 0, sizeof(*out)); memcpy(out->ssid, "test", 4);
|
||||||
|
if (byte_ssid) out->ssid[1] = 0;
|
||||||
|
return associated ? ESP_OK : ESP_FAIL; }
|
||||||
|
static bool connected_event_matches_active_profile(const manager_message_t *m)
|
||||||
|
{ assert(m->data.connected.ssid_len == 4); return matching; }
|
||||||
|
static void copy_working_config(wifi_app_config_t *out) { *out = s_shared.config; }
|
||||||
|
void wifi_config_secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||||
|
esp_err_t mdns_service_start(void) { ++mdns_starts; return mdns_error; }
|
||||||
|
esp_err_t mdns_service_reconcile(void)
|
||||||
|
{
|
||||||
|
assert(!in_tcpip && shared_locks == 0);
|
||||||
|
++mdns_reconciles;
|
||||||
|
if (mdns_error == ESP_OK) https_registered = https_available;
|
||||||
|
return mdns_error;
|
||||||
|
}
|
||||||
|
esp_err_t mdns_service_reannounce(void)
|
||||||
|
{ ++mdns_reannounces; return mdns_service_reconcile(); }
|
||||||
|
void mdns_service_stop(void) { ++mdns_stops; }
|
||||||
|
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *out)
|
||||||
|
{ memset(out, 0, sizeof(*out)); strcpy(out->hostname, configured); return hostname_error; }
|
||||||
|
static void netif_set_hostname(struct netif *n, const char *name)
|
||||||
|
{ assert(n == &netif && in_tcpip); n->hostname = name; strcpy(hostname, name); }
|
||||||
|
static void handle_sta_disconnected(manager_runtime_t *r, const manager_message_t *m)
|
||||||
|
{ (void)m; r->online = r->associated = false; ++disconnects; }
|
||||||
|
static void mark_intentional_disconnect(manager_runtime_t *r)
|
||||||
|
{ ++disconnects; ++r->intentional_disconnects; r->online = r->associated = false; }
|
||||||
|
static void start_next_profile(manager_runtime_t *r) { (void)r; ++next_profiles; }
|
||||||
|
static void start_profile_cycle(manager_runtime_t *r) { (void)r; ++cycles; }
|
||||||
|
static void start_radio_and_policy(manager_runtime_t *r) { (void)r; }
|
||||||
|
static esp_err_t esp_wifi_disconnect(void) { ++disconnects; return ESP_OK; }
|
||||||
|
static esp_err_t set_runtime_ap_enabled(manager_runtime_t *r, bool enable)
|
||||||
|
{ r->ap_enabled = enable; if (!enable) ++ap_disables; return ESP_OK; }
|
||||||
|
static bool enqueue_message(const manager_message_t *m) { last_message = *m; ++messages; return true; }
|
||||||
|
#include "production.inc"
|
||||||
|
|
||||||
|
static char rendered[2048];
|
||||||
|
static int capture_printf(const char *format, ...)
|
||||||
|
{
|
||||||
|
size_t used = strlen(rendered);
|
||||||
|
va_list args;
|
||||||
|
va_start(args, format);
|
||||||
|
int n = vsnprintf(rendered + used, sizeof(rendered) - used, format, args);
|
||||||
|
va_end(args);
|
||||||
|
assert(n >= 0 && (size_t)n < sizeof(rendered) - used);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
#define printf capture_printf
|
||||||
|
#include "console.inc"
|
||||||
|
#undef printf
|
||||||
|
|
||||||
|
static void assert_empty_addresses(void)
|
||||||
|
{
|
||||||
|
const wifi_manager_ipv6_address_t zero[WIFI_MANAGER_IPV6_MAX_ADDRESSES] = {0};
|
||||||
|
assert(s_shared.snapshot.ipv6_count == 0);
|
||||||
|
assert(!s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
|
||||||
|
assert(memcmp(zero, s_shared.snapshot.ipv6_addresses, sizeof(zero)) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static manager_runtime_t reset(void)
|
||||||
|
{
|
||||||
|
memset(&s_shared, 0, sizeof(s_shared)); memset(&netif, 0, sizeof(netif));
|
||||||
|
memset(&ip4, 0, sizeof(ip4));
|
||||||
|
up = associated = matching = true; now = 1000000; byte_ssid = false;
|
||||||
|
mdns_starts = mdns_stops = disconnects = next_profiles = cycles = ap_disables = 0;
|
||||||
|
creations = messages = mdns_reconciles = mdns_reannounces = tcpip_calls = 0;
|
||||||
|
hostname_error = tcpip_error = mdns_error = ip_info_error = ESP_OK;
|
||||||
|
warnings = 0;
|
||||||
|
https_available = https_registered = tcpip_skip = false;
|
||||||
|
strcpy(configured, "sak-test");
|
||||||
|
s_shared.snapshot.started = true;
|
||||||
|
s_shared.snapshot.sta_ssid_len = 4;
|
||||||
|
s_shared.config.ap_policy = WIFI_CONFIG_AP_POLICY_FALLBACK;
|
||||||
|
manager_runtime_t r = { .radio_started = true, .attempt_deadline = now + 12000000 };
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
static void address(int slot, uint8_t first, uint8_t second, int state)
|
||||||
|
{
|
||||||
|
memset(&netif.addresses[slot], 0, sizeof(netif.addresses[slot]));
|
||||||
|
uint8_t *bytes = (uint8_t *)netif.addresses[slot].addr;
|
||||||
|
bytes[0] = first; bytes[1] = second; bytes[15] = 1;
|
||||||
|
netif.state[slot] = state;
|
||||||
|
}
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
manager_runtime_t r = reset();
|
||||||
|
strcpy(configured, "sak-first"); assert(refresh_station_hostname() == ESP_OK);
|
||||||
|
assert(!strcmp(hostname, "sak-first"));
|
||||||
|
strcpy(configured, "sak-renamed"); assert(refresh_station_hostname() == ESP_OK);
|
||||||
|
assert(!strcmp(hostname, "sak-renamed") && disconnects == 0);
|
||||||
|
memset(configured, 'a', 59); memcpy(configured, "sak-", 4); configured[59] = 0;
|
||||||
|
assert(refresh_station_hostname() == ESP_OK && strlen(hostname) == 59);
|
||||||
|
assert(netif.hostname == s_station_hostname); /* No captured stack pointer. */
|
||||||
|
assert(!strcmp(netif.hostname, configured));
|
||||||
|
hostname_error = ESP_FAIL; assert(refresh_station_hostname() == ESP_FAIL);
|
||||||
|
|
||||||
|
r = reset(); byte_ssid = true; ip4.ip.addr = 7;
|
||||||
|
handle_got_ip(&r, NULL); assert(r.online); /* Embedded NUL keeps profile length. */
|
||||||
|
|
||||||
|
r = reset(); handle_got_ip(&r, NULL);
|
||||||
|
assert(netif.autoconfig && creations == 1 && !r.online);
|
||||||
|
handle_got_ip(&r, NULL); assert(creations == 1); /* Do not restart DAD. */
|
||||||
|
address(0, 0xfe, 0x80, PREFERRED); now = r.attempt_deadline;
|
||||||
|
handle_expired_deadlines(&r); /* dropped CONNECTED and GOT_IP6 */
|
||||||
|
assert(r.online && r.attempt_deadline == 0 && disconnects == 0);
|
||||||
|
assert(s_shared.snapshot.ipv6_linklocal && s_shared.snapshot.ip == 0);
|
||||||
|
assert(mdns_starts == 1);
|
||||||
|
int64_t stable = r.stable_deadline;
|
||||||
|
now += 1000000; handle_expired_deadlines(&r);
|
||||||
|
assert(r.stable_deadline == stable && mdns_starts == 1);
|
||||||
|
now = stable; handle_expired_deadlines(&r); assert(ap_disables == 1);
|
||||||
|
assert(runtime_wait_ticks(&r) <= 1000); /* Never sleep indefinitely online. */
|
||||||
|
|
||||||
|
address(1, 0xfd, 0x12, PREFERRED); handle_got_ip(&r, NULL);
|
||||||
|
assert(s_shared.snapshot.ipv6_routable);
|
||||||
|
ip4.ip.addr = 123; handle_got_ip(&r, NULL);
|
||||||
|
assert(s_shared.snapshot.ip == 123 && s_shared.snapshot.counters.got_ip == 1);
|
||||||
|
ip4.ip.addr = 0; handle_got_ip(&r, NULL);
|
||||||
|
assert(r.online && !s_shared.snapshot.ip && mdns_stops == 0);
|
||||||
|
address(0, 0xfe, 0x80, DEPRECATED); address(1, 0xfd, 0x12, DEPRECATED);
|
||||||
|
now += 1000000; handle_expired_deadlines(&r);
|
||||||
|
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
|
||||||
|
assert(mdns_stops == 1 && r.attempt_deadline > now);
|
||||||
|
address(1, 0x20, 0x01, PREFERRED); handle_got_ip(&r, NULL);
|
||||||
|
assert(r.online && s_shared.snapshot.ipv6_routable && mdns_starts == 2);
|
||||||
|
|
||||||
|
r = reset(); address(0, 0xfe, 0x80, DUPLICATE);
|
||||||
|
now = r.attempt_deadline; handle_expired_deadlines(&r);
|
||||||
|
assert(!r.online && creations == 0 && disconnects == 1);
|
||||||
|
assert(r.advance_after_disconnect && r.disconnect_deadline > now);
|
||||||
|
r = reset(); address(0, 0xfe, 0x80, TENTATIVE); ip4.ip.addr = 7;
|
||||||
|
handle_got_ip(&r, NULL); assert(r.online && !s_shared.snapshot.ipv6_linklocal);
|
||||||
|
ip4.ip.addr = 0; associated = false;
|
||||||
|
now += 1000000; handle_expired_deadlines(&r); assert(disconnects == 1 && !r.online);
|
||||||
|
|
||||||
|
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
|
||||||
|
r.stop_pending = true; handle_got_ip(&r, NULL); assert(!r.online);
|
||||||
|
r.stop_pending = false; r.advance_after_disconnect = true;
|
||||||
|
handle_got_ip(&r, NULL); assert(!r.online);
|
||||||
|
r.advance_after_disconnect = false; matching = false;
|
||||||
|
handle_got_ip(&r, NULL); assert(!r.online);
|
||||||
|
matching = true; up = false; handle_got_ip(&r, NULL); assert(!r.online);
|
||||||
|
up = true; handle_got_ip(&r, NULL); assert(r.online);
|
||||||
|
netif.state[0] = IP6_ADDR_INVALID; /* missed disconnect+connect: re-create LL */
|
||||||
|
handle_got_ip(&r, NULL); assert(creations == 1 && !r.online);
|
||||||
|
|
||||||
|
ip_event_got_ip6_t event6 = { .esp_netif = &other };
|
||||||
|
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6); assert(messages == 0);
|
||||||
|
event6.esp_netif = &sta;
|
||||||
|
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6);
|
||||||
|
assert(messages == 1 && last_message.type == MESSAGE_STA_GOT_IP6);
|
||||||
|
ip_event_got_ip_t event4 = { .esp_netif = &other };
|
||||||
|
ip_event_callback(NULL, IP_EVENT, IP_EVENT_STA_GOT_IP, &event4); assert(messages == 1);
|
||||||
|
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, NULL); assert(messages == 1);
|
||||||
|
r = reset(); mdns_error = ESP_FAIL;
|
||||||
|
start_mdns_announcement(&r); start_mdns_announcement(&r);
|
||||||
|
assert(warnings == 1 && r.mdns_reannounce_pending);
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
|
||||||
|
}
|
||||||
|
assert(warnings == 1); /* No per-pass warning on persistent failure. */
|
||||||
|
|
||||||
|
r = reset(); r.radio_started = false; r.attempt_deadline = 0;
|
||||||
|
s_shared.snapshot.started = false; associated = up = false;
|
||||||
|
assert(runtime_wait_ticks(&r) == 0); /* Boot with Wi-Fi disabled. */
|
||||||
|
https_registered = true;
|
||||||
|
handle_expired_deadlines(&r);
|
||||||
|
assert(mdns_reconciles == 1 && !https_registered && creations == 0);
|
||||||
|
assert(runtime_wait_ticks(&r) == 1000);
|
||||||
|
for (int i = 0; i < 100; ++i) handle_expired_deadlines(&r);
|
||||||
|
assert(mdns_reconciles == 1); /* Busy owner queue cannot cause retry spam. */
|
||||||
|
mdns_error = ESP_FAIL; https_available = true;
|
||||||
|
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
|
||||||
|
assert(mdns_reconciles == 2 && !https_registered);
|
||||||
|
mdns_error = ESP_OK;
|
||||||
|
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
|
||||||
|
assert(mdns_reconciles == 3 && https_registered && !r.radio_started);
|
||||||
|
/* Failed offline rename is retried without a lifecycle command. */
|
||||||
|
tcpip_error = ESP_FAIL; strcpy(configured, "sak-retry");
|
||||||
|
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
|
||||||
|
assert(strcmp(hostname, configured) != 0);
|
||||||
|
tcpip_error = ESP_OK;
|
||||||
|
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
|
||||||
|
assert(!strcmp(hostname, configured) && netif.hostname == s_station_hostname);
|
||||||
|
assert(disconnects == 0 && !r.radio_started);
|
||||||
|
|
||||||
|
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
|
||||||
|
handle_got_ip(&r, NULL); assert(r.online);
|
||||||
|
r.mdns_reannounce_pending = true; mdns_error = ESP_FAIL;
|
||||||
|
handle_expired_deadlines(&r);
|
||||||
|
assert(r.mdns_reannounce_pending && mdns_reannounces == 1);
|
||||||
|
mdns_error = ESP_OK;
|
||||||
|
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
|
||||||
|
assert(!r.mdns_reannounce_pending && mdns_reannounces == 2);
|
||||||
|
tcpip_error = ESP_FAIL; now = r.stable_deadline;
|
||||||
|
handle_expired_deadlines(&r);
|
||||||
|
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && ap_disables == 0);
|
||||||
|
assert(mdns_stops == 1 && r.attempt_deadline > now);
|
||||||
|
tcpip_error = ESP_OK;
|
||||||
|
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
|
||||||
|
assert(r.online && s_shared.snapshot.ipv6_linklocal && disconnects == 0);
|
||||||
|
tcpip_skip = true;
|
||||||
|
assert(refresh_station_hostname() == ESP_FAIL);
|
||||||
|
handle_got_ip(&r, NULL);
|
||||||
|
assert(!r.online && s_shared.snapshot.last_error == ESP_FAIL);
|
||||||
|
tcpip_skip = false; handle_got_ip(&r, NULL); assert(r.online);
|
||||||
|
/* Netif-down with a stale driver association must withdraw readiness. */
|
||||||
|
up = false; handle_got_ip(&r, NULL);
|
||||||
|
assert(!r.online && !s_shared.snapshot.ipv6_linklocal);
|
||||||
|
assert_empty_addresses();
|
||||||
|
r = reset();
|
||||||
|
address(0, 0xfe, 0x80, PREFERRED);
|
||||||
|
address(1, 0xfd, 0x12, PREFERRED);
|
||||||
|
address(2, 0x20, 0x01, PREFERRED);
|
||||||
|
netif.addresses[2].addr[1] = esp_netif_htonl(0x12345678);
|
||||||
|
netif.addresses[2].addr[2] = esp_netif_htonl(0x9abcdef0);
|
||||||
|
netif.addresses[2].zone = 42;
|
||||||
|
handle_got_ip(&r, NULL);
|
||||||
|
assert(s_shared.snapshot.ipv6_count == 3);
|
||||||
|
for (int i = 0; i < 3; ++i)
|
||||||
|
assert(memcmp(s_shared.snapshot.ipv6_addresses[i].addr, netif.addresses[i].addr, 16) == 0);
|
||||||
|
print_ipv6_addresses(&s_shared.snapshot);
|
||||||
|
assert(strstr(rendered, "IPv6 preferred link-local: fe80:0000:0000:0000:0000:0000:0000:0001\n"));
|
||||||
|
assert(strstr(rendered, "IPv6 preferred ULA: fd12:0000:0000:0000:0000:0000:0000:0001\n"));
|
||||||
|
assert(strstr(rendered, "IPv6 preferred GUA: 2001:0000:1234:5678:9abc:def0:0000:0001\n"));
|
||||||
|
assert(strstr(rendered, "client's interface") && !strstr(rendered, "%42"));
|
||||||
|
netif.state[0] = TENTATIVE; netif.state[1] = DUPLICATE; netif.state[2] = DEPRECATED;
|
||||||
|
handle_got_ip(&r, NULL); assert_empty_addresses();
|
||||||
|
rendered[0] = 0; print_ipv6_addresses(&s_shared.snapshot);
|
||||||
|
assert(strstr(rendered, "IPv6 preferred addresses: none") && !strstr(rendered, "IPv6 preferred GUA:"));
|
||||||
|
for (int failure = 0; failure < 5; ++failure) {
|
||||||
|
address(0, 0xfe, 0x80, PREFERRED); address(1, 0xfd, 0x12, PREFERRED);
|
||||||
|
handle_got_ip(&r, NULL); assert(s_shared.snapshot.ipv6_count == 2);
|
||||||
|
if (failure == 0) tcpip_error = ESP_FAIL;
|
||||||
|
if (failure == 1) tcpip_skip = true;
|
||||||
|
if (failure == 2) up = false;
|
||||||
|
if (failure == 4) ip_info_error = ESP_FAIL;
|
||||||
|
if (failure == 3) clear_station_network_snapshot();
|
||||||
|
else handle_got_ip(&r, NULL);
|
||||||
|
assert_empty_addresses();
|
||||||
|
tcpip_error = ip_info_error = ESP_OK; tcpip_skip = false; up = true;
|
||||||
|
}
|
||||||
|
printf("snapshot=%zu settings=%zu shared=%zu observation=%zu bytes\n",
|
||||||
|
sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t),
|
||||||
|
sizeof(manager_shared_t), sizeof(station_addresses_t));
|
||||||
|
puts("wifi_phase12: hostname, IPv4/IPv6 lifecycle, deadlines, offline mDNS retries, TCP/IP ownership and event filtering PASS");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user