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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(¤t_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, ¤t_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(¤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);
|
||||
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(¤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) {
|
||||
esp_netif_ip_info_t current_ip;
|
||||
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) {
|
||||
/* 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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user