Complete Phase 12 dual-stack networking

Add IPv6-aware Wi-Fi state, HTTPS/SSH listeners, mDNS service
reconciliation, and browser Wi-Fi administration.

Include a guarded build-local fix for mDNS 1.12.0 membership handling,
focused regression suites, and Phase 12 acceptance documentation.
This commit is contained in:
2026-09-20 22:35:34 +02:00
parent ece4ba77e3
commit 8902b25d78
52 changed files with 3042 additions and 163 deletions
+4 -4
View File
@@ -556,9 +556,9 @@ static bool remote_command_allowed(const admin_request_t *request)
strcmp(argv[1], "recover") == 0) {
allowed = false;
}
/* Temporary browser policy until lifecycle acknowledgements/revocation are
* coordinated (8D.7). Classify parsed canonical arguments, not raw prefixes.
* User mutations remain available through UART0/SSH, subject to their policy.
/* Classify parsed canonical arguments, not raw prefixes. Wi-Fi uses the
* canonical handler, including hidden prompts and disruptive changes;
* unrelated browser restrictions remain narrower than UART0/SSH.
*/
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
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)) ||
(argc == 4U && strcmp(argv[1], "certificate") == 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;
} else if (strcmp(argv[0], "user") == 0) {
allowed = admin_ssh_console_web_user_command_allowed(
+11 -7
View File
@@ -271,14 +271,14 @@ static char parity_letter(serial_config_parity_t parity)
static void format_ipv4(uint32_t address, char output[16])
{
if (address == 0U) {
memcpy(output, "0.0.0.0", sizeof("0.0.0.0"));
memcpy(output, "none", sizeof("none"));
return;
}
esp_ip4_addr_t ip = {.addr = address};
int written = snprintf(output, 16U, IPSTR, IP2STR(&ip));
if (written < 0 || written >= 16) {
memcpy(output, "0.0.0.0", sizeof("0.0.0.0"));
memcpy(output, "unknown", sizeof("unknown"));
}
}
@@ -565,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);
format_ipv4(snapshot->wifi.ip, address);
format_text(line, "IP:%s", address);
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 32U, line);
format_text(line, "IPv4:%s", snapshot->wifi_available ? address : "unknown");
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 32U, line);
format_text(line, "Web:%u SSH:%u USB:%u",
snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U,
@@ -664,7 +664,11 @@ static void render_network_page(const local_status_snapshot_t *snapshot)
const char *ssid;
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);
format_ssid(ssid, ssid_display);
format_text(line, "WiFi:%s Ch:%u", ssid_display, channel);
@@ -672,8 +676,8 @@ static void render_network_page(const local_status_snapshot_t *snapshot)
wifi_snapshot_bitmap(snapshot));
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 8U, line);
format_ipv4(snapshot->wifi.ip, address);
format_text(line, "IP:%s", address);
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 16U, line);
format_text(line, "IPv4:%s", snapshot->wifi_available ? address : "unknown");
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),
snapshot->wifi.ap_client_count);
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line);
+142 -4
View File
@@ -4,6 +4,9 @@
#include "mdns_service.h"
#include <stdio.h>
#include "esp_netif.h"
#include "esp_timer.h"
#include "sdkconfig.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
@@ -17,6 +20,125 @@ static bool s_component_initialized;
static bool s_initialization_failed;
static bool s_announced;
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)
{
@@ -149,9 +271,16 @@ esp_err_t mdns_service_start(void)
return error;
}
mdns_config_t config = s_config;
uint32_t generation = s_config_generation;
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();
#endif
if (error == ESP_OK) {
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
make_hostname(&config, hostname, sizeof(hostname));
@@ -165,12 +294,13 @@ esp_err_t mdns_service_start(void)
}
lock_service();
if (error == ESP_OK) s_applied_generation = generation;
s_component_initialized = error == ESP_OK;
s_initialization_failed = error != ESP_OK;
s_announced = error == ESP_OK;
s_last_error = error;
unlock_service();
return error;
return error == ESP_OK ? mdns_service_reconcile() : error;
}
void mdns_service_stop(void)
@@ -181,6 +311,7 @@ void mdns_service_stop(void)
lock_service();
s_announced = false;
unlock_service();
(void)mdns_service_reconcile();
}
esp_err_t mdns_service_reannounce(void)
@@ -195,11 +326,18 @@ esp_err_t mdns_service_reannounce(void)
return error;
}
mdns_config_t config = s_config;
uint32_t generation = s_config_generation;
unlock_service();
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
make_hostname(&config, hostname, sizeof(hostname));
esp_err_t error = mdns_hostname_set(hostname);
esp_err_t error = ESP_OK;
if (generation != s_applied_generation) {
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();
s_last_error = error;
+17 -1
View File
@@ -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,
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);
void mdns_service_stop(void);
esp_err_t mdns_service_reannounce(void);
+56 -14
View File
@@ -21,6 +21,7 @@
#include "lwip/inet.h"
#include "lwip/sockets.h"
#include "lwip/tcp.h"
#include "mdns_service.h"
#include "sdkconfig.h"
#include "secure_random.h"
#include "serial_service.h"
@@ -80,7 +81,7 @@ typedef struct {
size_t tx_length;
uint8_t rx_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;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
@@ -675,7 +676,7 @@ static esp_err_t create_context(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) {
return ESP_FAIL;
}
@@ -683,12 +684,17 @@ static esp_err_t create_listener(void)
int enabled = 1;
(void)setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR,
&enabled, sizeof(enabled));
struct sockaddr_in address = {
.sin_family = AF_INET,
.sin_port = htons(SSH_TRANSPORT_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY),
/* IDF lwIP binds :: to IPADDR_TYPE_ANY when V6ONLY is disabled.
* One listener preserves the shared accept budget and socket footprint. */
int ipv6_only = 0;
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 ||
set_nonblocking(socket_fd) != ESP_OK) {
close(socket_fd);
@@ -717,12 +723,14 @@ static esp_err_t start_runtime(void)
s_context = NULL;
}
}
mdns_service_set_ssh_available(error == ESP_OK);
return error;
}
static esp_err_t stop_runtime(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);
}
@@ -850,28 +858,56 @@ static ssh_slot_t *find_free_slot(size_t *slot_index)
static void format_peer(const struct sockaddr_storage *address,
char *output, size_t output_size)
{
if (output_size == 0U) return;
output[0] = '\0';
int written = -1;
if (address->ss_family == AF_INET) {
const struct sockaddr_in *ipv4 = (const struct sockaddr_in *)address;
char host[INET_ADDRSTRLEN] = {0};
if (inet_ntop(AF_INET, &ipv4->sin_addr, host, sizeof(host)) != NULL) {
(void)snprintf(output, output_size, "%s:%u", host,
(unsigned int)ntohs(ipv4->sin_port));
written = snprintf(output, output_size, "%s:%u", host,
(unsigned int)ntohs(ipv4->sin_port));
}
} else if (address->ss_family == AF_INET6) {
const struct sockaddr_in6 *ipv6 = (const struct sockaddr_in6 *)address;
char host[INET6_ADDRSTRLEN] = {0};
if (inet_ntop(AF_INET6, &ipv6->sin6_addr, host, sizeof(host)) != NULL) {
(void)snprintf(output, output_size, "[%s]:%u", host,
(unsigned int)ntohs(ipv6->sin6_port));
/* lwIP accept supplies the interface zone in sin6_scope_id.
* 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);
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)
{
if (s_listen_fd < 0 || s_context == NULL) {
@@ -881,13 +917,19 @@ static void accept_connections(void)
for (unsigned int accepted_count = 0U;
accepted_count < SSH_TRANSPORT_ACCEPT_BUDGET;
++accepted_count) {
struct sockaddr_storage peer_address;
struct sockaddr_storage peer_address = {0};
socklen_t peer_length = sizeof(peer_address);
int socket_fd = accept(s_listen_fd, (struct sockaddr *)&peer_address,
&peer_length);
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);
/* Resource pressure/aborted peers do not invalidate a listener. */
if (error == EBADF || error == EINVAL || error == ENOTSOCK ||
error == EOPNOTSUPP) {
listener_failed();
}
}
return;
}
+4 -1
View File
@@ -16,6 +16,8 @@ extern "C" {
#endif
#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_IO_BUFFER_SIZE 512U
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
@@ -82,7 +84,7 @@ typedef struct {
user_role_t user_role;
user_auth_method_t auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
char peer[48];
char peer[SSH_TRANSPORT_PEER_CAPACITY];
} ssh_transport_session_snapshot_t;
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. */
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_stop(void);
+120
View File
@@ -2,6 +2,125 @@
#include "web_auth_parse.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)
{
@@ -18,6 +137,7 @@ static bool alnum_ascii(unsigned char c)
static bool authority(const char *text, size_t length, char *out)
{
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 || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
size_t label = 0;
+5 -3
View File
@@ -21,9 +21,11 @@ typedef struct {
} web_auth_credentials_t;
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
* IPv6 literals are deliberately rejected until the device supports that route.
* Only optional :443 is accepted. Origin is mandatory and must match Host.
* alias. Failures clear output. Host supports ASCII DNS/IPv4 and bracketed IPv6.
* IPv6 uses lowercase hex, longest zero-run compression (first on ties), and
* 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,
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
bool web_auth_parse_origin(const char *host, size_t host_length,
+16 -3
View File
@@ -334,7 +334,8 @@ static esp_err_t snapshot_response(httpd_req_t *request)
wifi_manager_settings_t wifi;
mdns_service_snapshot_t mdns;
/* 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\"}");
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)
@@ -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. */
const uint8_t *ip = (const uint8_t *)&r->ip;
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,
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}}",
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
#undef SSID
+3 -1
View File
@@ -4,7 +4,7 @@
#include "esp_http_server.h"
#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;
* 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
* 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.
* 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
* preserves current bytes; clear_password:true is distinct from replacement.
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
+9 -2
View File
@@ -40,6 +40,7 @@
#include "web_diagnostics.h"
#include "web_ui.h"
#include "wifi_manager.h"
#include "mdns_service.h"
#define WEB_SERVER_PORT 443U
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
@@ -271,7 +272,7 @@ static esp_err_t status_handler(httpd_req_t *request)
"{\n"
" \"uptime_ms\":%" PRIu64 ",\n"
" \"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 ","
"\"data_bits\":\"%s\",\"parity\":\"%s\",\"stop_bits\":\"%s\","
"\"flow\":\"%s\",\"rx_bytes\":%" PRIu64 ",\"rx_dropped\":%" PRIu64 ","
@@ -289,7 +290,10 @@ static esp_err_t status_handler(httpd_req_t *request)
(uint64_t)(esp_timer_get_time() / 1000),
wifi_available ? "true" : "false",
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 && wifi.ap_running ? "true" : "false",
wifi_available ? (unsigned int)wifi.ap_client_count : 0U,
@@ -786,6 +790,7 @@ static esp_err_t start_server(bool reserved)
s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached;
s_admin_transport_owned = admin_transport_owned;
mdns_service_set_https_available(error == ESP_OK);
if (error == ESP_OK) {
s_server = server;
++s_counters.starts;
@@ -824,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;
s_transitioning = true;
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);
web_cookie_auth_stop();
+11 -5
View File
@@ -1308,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"
" 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"
" 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"
"}\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 clearNetworkSecret() {\n"
" window.clearTimeout(networkSecretTimer); networkSecretTimer = null; networkSecretUntil = 0; networkSecretContext = '';\n"
@@ -1399,7 +1402,7 @@ static const char s_app_js[] =
" net('detail').textContent = 'Reading network. Previous snapshot is stale; refresh discards drafts.';\n"
" try {\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"
" networkSnapshot = payload; networkFresh = true;\n"
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
@@ -1407,13 +1410,15 @@ static const char s_app_js[] =
" ['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"
" ...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"
" ['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"
" 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"
" 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"
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
"}\n"
@@ -2061,7 +2066,8 @@ static const char s_app_js[] =
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
" if (wifi && wifi.available) {\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.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"
+25
View File
@@ -118,6 +118,28 @@ static void print_ipv4(uint32_t address)
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)
{
wifi_manager_snapshot_t snapshot;
@@ -157,7 +179,10 @@ static int show_status(void)
printf(" gateway=");
print_ipv4(snapshot.gateway);
putchar('\n');
} else {
printf("IPv4: none\n");
}
print_ipv6_addresses(&snapshot);
printf("AP: policy=%s running=%s clients=%u channel=%u SSID=",
wifi_config_ap_policy_to_string(snapshot.ap_policy),
+260 -63
View File
@@ -19,11 +19,14 @@
#include "freertos/task.h"
#include "mdns_service.h"
#include "nvs.h"
#include "esp_netif_net_stack.h"
#include "lwip/netif.h"
#define WIFI_MANAGER_QUEUE_LENGTH 16U
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
#define WIFI_MANAGER_TASK_PRIORITY 5U
#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_DISCONNECT_SETTLE_US (1LL * 1000LL * 1000LL)
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2U
@@ -42,6 +45,7 @@ typedef enum {
MESSAGE_STA_DISCONNECTED,
MESSAGE_STA_GOT_IP,
MESSAGE_STA_LOST_IP,
MESSAGE_STA_GOT_IP6,
MESSAGE_STA_STOPPED,
MESSAGE_AP_STOPPED,
MESSAGE_AP_CLIENT_JOINED,
@@ -78,6 +82,9 @@ typedef struct {
bool ap_enabled;
bool associated;
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_count;
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_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();
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. */
ESP_LOGW(TAG, "mDNS announcement unavailable: %s", esp_err_to_name(error));
}
runtime->mdns_failure_reported = error != ESP_OK;
}
static void lock_shared(void)
@@ -177,6 +243,10 @@ static void clear_station_network_snapshot(void)
s_shared.snapshot.ip = 0U;
s_shared.snapshot.netmask = 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_rssi = 0;
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->online = false;
mdns_service_stop();
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_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) {
error = esp_wifi_connect();
}
@@ -662,6 +736,7 @@ static void start_radio_and_policy(manager_runtime_t *runtime)
}
runtime->radio_started = true;
runtime->ap_enabled = want_ap;
set_last_error(ESP_OK);
note_ap_running(want_ap, &config);
@@ -709,24 +784,127 @@ static void update_connected_snapshot(const manager_message_t *message,
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,
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;
memset(&current_ip, 0, sizeof(current_ip));
memset(&ap_record, 0, sizeof(ap_record));
/* Driver/netif state is authoritative when old queued events arrive late. */
if (esp_netif_get_ip_info(s_sta_netif, &current_ip) != ESP_OK ||
current_ip.ip.addr == 0U ||
current_ip.ip.addr != message->data.got_ip.ip ||
if (!runtime->radio_started || runtime->stop_pending ||
runtime->advance_after_disconnect || runtime->backoff_deadline != 0 ||
esp_wifi_sta_get_ap_info(&ap_record) != ESP_OK) {
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->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->advance_after_disconnect = false;
runtime->attempt_deadline = 0;
@@ -736,14 +914,13 @@ static void handle_got_ip(manager_runtime_t *runtime,
wifi_app_config_t config;
copy_working_config(&config);
runtime->stable_deadline = config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK
? esp_timer_get_time() + WIFI_MANAGER_STABLE_US
: 0;
if (!was_online) {
runtime->stable_deadline = config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK
? esp_timer_get_time() + WIFI_MANAGER_STABLE_US
: 0;
}
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_rssi = ap_record.rssi;
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.last_error = ESP_OK;
s_shared.snapshot.state = WIFI_MANAGER_STATE_ONLINE;
++s_shared.snapshot.counters.got_ip;
unlock_shared();
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,
@@ -884,13 +1062,16 @@ static void handle_message(manager_runtime_t *runtime,
break;
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) {
ESP_LOGW(TAG, "mDNS reannouncement unavailable: %s",
esp_err_to_name(error));
set_last_error(error);
}
}
runtime->mdns_reannounce_pending = true;
if (runtime->online) {
runtime->mdns_reannounce_pending = mdns_service_reannounce() != ESP_OK;
}
break;
case MESSAGE_STA_CONNECTED:
@@ -898,18 +1079,17 @@ static void handle_message(manager_runtime_t *runtime,
!connected_event_matches_active_profile(message)) {
break;
}
runtime->associated = true;
runtime->online = false;
if (runtime->attempt_deadline == 0) {
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
if (!runtime->associated) {
update_connected_snapshot(message, runtime->ap_enabled);
}
update_connected_snapshot(message, runtime->ap_enabled);
handle_got_ip(runtime, message);
break;
case MESSAGE_STA_DISCONNECTED:
handle_sta_disconnected(runtime, message);
break;
case MESSAGE_STA_GOT_IP6:
case MESSAGE_STA_GOT_IP:
if (manager_is_started()) {
handle_got_ip(runtime, message);
@@ -917,20 +1097,8 @@ static void handle_message(manager_runtime_t *runtime,
break;
case MESSAGE_STA_LOST_IP:
if (manager_is_started() && runtime->online) {
esp_netif_ip_info_t current_ip;
memset(&current_ip, 0, sizeof(current_ip));
if (esp_netif_get_ip_info(s_sta_netif, &current_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);
if (manager_is_started()) {
handle_got_ip(runtime, message);
}
break;
@@ -984,6 +1152,7 @@ static int64_t next_runtime_deadline(const manager_runtime_t *runtime)
runtime->restart_deadline,
runtime->backoff_deadline,
runtime->stable_deadline,
runtime->reconcile_deadline,
};
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);
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();
@@ -1015,24 +1185,45 @@ static void handle_expired_deadlines(manager_runtime_t *runtime)
{
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(&current_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) {
esp_netif_ip_info_t current_ip;
memset(&current_ip, 0, sizeof(current_ip));
if (esp_netif_get_ip_info(s_sta_netif, &current_ip) == ESP_OK &&
current_ip.ip.addr != 0U) {
/* 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;
}
/* Both families must be reconciled before deciding to abandon a profile. */
handle_got_ip(runtime, NULL);
if (runtime->online) {
return;
}
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) {
const ip_event_got_ip_t *event = event_data;
if (event == NULL) {
if (event == NULL || event->esp_netif != s_sta_netif) {
return;
}
message.type = MESSAGE_STA_GOT_IP;
message.data.got_ip.ip = event->ip_info.ip.addr;
message.data.got_ip.netmask = event->ip_info.netmask.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) {
message.type = MESSAGE_STA_LOST_IP;
} else {
+19 -1
View File
@@ -42,6 +42,14 @@ typedef struct {
uint64_t queue_drops;
} 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 {
bool initialized;
bool started;
@@ -54,6 +62,13 @@ typedef struct {
uint32_t ip;
uint32_t netmask;
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;
int8_t sta_rssi;
wifi_auth_mode_t sta_auth;
@@ -141,7 +156,10 @@ esp_err_t wifi_manager_stop(void);
esp_err_t wifi_manager_reconnect(void);
/* Advance to the next enabled station profile in priority order, wrapping safely. */
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);
/* Snapshot data never contains station or AP passwords. */