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
+57
View File
@@ -0,0 +1,57 @@
# Phase 12 SSH listener regression
Run `python3 tests/ssh_phase12/run.py` from the repository root. Requires Python 3
and a host C compiler (`cc`). It extracts the production functions without rewriting
them and compiles with `-std=c11 -Wall -Wextra -Werror`.
Coverage:
- One AF_INET6/TCP wildcard listener, explicitly checked IPV6_V6ONLY=0 before bind.
- Failures at socket, dual-stack option, bind, listen and both nonblocking steps:
no published descriptor, exactly one close after socket allocation.
- Permanent accept errors withdraw availability, invalidate the service generation
without wrap, and request owner-side session cleanup; temporary errors retain
the listener. Capacity rejection respects the existing four-accept budget.
- IPv4, global IPv6, scoped link-local IPv6, mapped IPv4, maximum numeric scope,
unknown families, and bounded/truncated peer formatting.
`tests/ssh_management/runtime.py` additionally tests availability across real
production start/stop functions with context/listener/cleanup doubles, including
failed session cleanup retaining the context, successful restart, and failed start.
Existing management/security regressions remain responsible for authentication,
identity ownership and generation fencing.
## SDK contract checked during implementation
Read-only inspection of installed ESP-IDF **5.5.0**
(`framework-espidf@3.50500.0`, not the separately installed 5.5.3):
- `components/lwip/lwip/src/include/lwip/sockets.h`: `sockaddr_in6.sin6_scope_id`
is `u32_t`; `IPV6_V6ONLY` is supported.
- `components/lwip/lwip/src/include/lwip/inet.h`: `IN6ADDR_ANY_INIT`.
- `components/lwip/lwip/src/api/sockets.c`: `lwip_setsockopt_impl` applies
`netconn_set_ipv6only`; `IP6ADDR_PORT_TO_SOCKADDR` copies the interface zone;
`lwip_accept` publishes the peer address and maps a closed listener to EINVAL,
non-TCP sockets to EOPNOTSUPP, and descriptor exhaustion to ENFILE.
- `components/lwip/lwip/src/api/api_msg.c`: wildcard IPv6 bind/listen with V6ONLY
disabled selects `IPADDR_TYPE_ANY`, accepting both families on one listener.
These host doubles do **not** execute lwIP, actual networking, wolfSSH handshakes,
RTOS concurrency, discovery traffic or hardware. No PlatformIO build is required
or claimed. Device follow-up must verify IPv4-only, IPv6-only and dual-stack
connections, scoped link-local access, address changes/reconnects, simultaneous
clients and binary UART traffic, advertisement convergence after start/stop/error,
and independent UART0/native USB recovery.
## Resource and handoff notes
No extra socket, task, session, queue, I/O buffer or heap allocation is introduced.
Peer arrays grow from 48 to 65 bytes: 68 additional raw bytes across the two owner
slots and two published snapshots, plus ABI padding. Public snapshot copies also
grow. Exact linked RAM/flash and stack headroom were not measured without a target
build; host tests do not establish ESP32 memory headroom.
Project agent memory is intentionally not edited under the exclusive-write scope.
The parent should record the one-socket dual-stack contract, scoped peer capacity,
and owner-driven mDNS availability (eventual Wi-Fi-manager reconciliation) in the
relevant durable memory when integrating Phase 12.
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Compile exact SSH listener/peer/failure functions with deterministic socket doubles.
No target, network, wolfSSH handshake, or SDK build is exercised.
"""
from pathlib import Path
import re
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / 'src/ssh_transport.c').read_text()
header = (ROOT / 'src/ssh_transport.h').read_text()
def function(name):
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
assert match, name
return match.group() + '\n'
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', text, re.M).group()
for text, name in ((header, 'SSH_TRANSPORT_PORT'),
(header, 'SSH_TRANSPORT_PEER_CAPACITY'),
(source, 'SSH_TRANSPORT_LISTEN_BACKLOG')))
fakes = r'''
#include <assert.h>
#include <errno.h>
#include <netinet/tcp.h>
#include <stdbool.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define ESP_OK 0
#define ESP_FAIL -1
#define SSH_TRANSPORT_MAX_SESSIONS 2U
typedef int esp_err_t;
static int s_listen_fd = -1;
static unsigned stage, fail_at, closes, depth;
static bool s_running, s_cleanup_pending;
static unsigned withdrawals;
static void mdns_service_set_ssh_available(bool available) { assert(!depth && !available && s_listen_fd == -1); ++withdrawals; }
static int s_last_error, s_lock;
static uint32_t s_management_generation;
typedef struct {
bool close_requested;
unsigned generation, state, session_id;
int socket_fd;
int64_t handshake_deadline_us;
char peer[SSH_TRANSPORT_PEER_CAPACITY];
void *ssh;
} ssh_slot_t;
static ssh_slot_t s_slots[2];
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while (0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while (0)
#define request_slot_close(slot, revoked) do { assert(!(revoked)); (slot)->close_requested=true; } while (0)
static bool step(void) { return ++stage == fail_at; }
static int fake_socket(int family, int type, int protocol) {
assert(family == AF_INET6 && type == SOCK_STREAM && protocol == IPPROTO_TCP);
return step() ? -1 : 42;
}
static int fake_setsockopt(int fd, int level, int option, const void *value, socklen_t size) {
assert(fd == 42 && size == sizeof(int));
if (level == SOL_SOCKET) { assert(option == SO_REUSEADDR && *(const int *)value == 1); return 0; }
assert(stage == 1 && level == IPPROTO_IPV6 && option == IPV6_V6ONLY && *(const int *)value == 0);
return step() ? -1 : 0;
}
static int fake_bind(int fd, const struct sockaddr *addr, socklen_t size) {
const struct sockaddr_in6 *v6 = (const struct sockaddr_in6 *)addr;
assert(fd == 42 && stage == 2 && size == sizeof(*v6));
assert(v6->sin6_family == AF_INET6 && ntohs(v6->sin6_port) == SSH_TRANSPORT_PORT);
assert(IN6_IS_ADDR_UNSPECIFIED(&v6->sin6_addr) && !v6->sin6_scope_id);
return step() ? -1 : 0;
}
static int fake_listen(int fd, int backlog) {
assert(fd == 42 && stage == 3 && backlog == SSH_TRANSPORT_LISTEN_BACKLOG);
return step() ? -1 : 0;
}
static int fake_fcntl(int fd, int op, int arg) {
assert(fd == 42);
assert((stage == 4 && op == F_GETFL && arg == 0) ||
(stage == 5 && op == F_SETFL && arg == (O_NONBLOCK | O_APPEND)));
return step() ? -1 : (op == F_GETFL ? O_APPEND : 0);
}
static int fake_close(int fd) { assert(fd == 42 && !depth); ++closes; return 0; }
static int fake_shutdown(int fd, int how) { assert(fd == 42 && how == SHUT_RDWR && !depth); return 0; }
#define socket fake_socket
#define setsockopt fake_setsockopt
#define bind fake_bind
#define listen fake_listen
#define fcntl fake_fcntl
#define close fake_close
#define shutdown fake_shutdown
'''
accept_fakes = r'''
#define SSH_TRANSPORT_ACCEPT_BUDGET 4U
#define SSH_TRANSPORT_SESSION_HANDSHAKE 1
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
#define WS_SUCCESS 0
static void *s_context = (void *)1;
static struct { uint64_t io_failures, tcp_connections, capacity_rejections, handshake_failures; } s_counters;
static unsigned accepts;
static int accept_error;
static void add_counter(uint64_t *counter, uint64_t value) { *counter += value; }
static int fake_accept(int fd, struct sockaddr *addr, socklen_t *length) {
assert(fd == 42 && addr && *length == sizeof(struct sockaddr_storage));
++accepts; errno = accept_error;
return accept_error ? -1 : 42;
}
#define accept fake_accept
static ssh_slot_t *find_free_slot(size_t *index) { (void)index; return NULL; }
static uint32_t make_session_id(size_t index, uint32_t generation) { (void)index; return generation; }
static int64_t esp_timer_get_time(void) { return 0; }
static void *wolfSSH_new(void *ctx) { (void)ctx; assert(0); return NULL; }
static int wolfSSH_set_fd(void *ssh, int fd) { (void)ssh; (void)fd; assert(0); return 0; }
static void set_ctx(void *ssh, void *slot) { (void)ssh; (void)slot; assert(0); }
#define wolfSSH_SetIOReadCtx set_ctx
#define wolfSSH_SetUserAuthCtx set_ctx
#define wolfSSH_SetUserAuthResultCtx set_ctx
#define wolfSSH_SetChannelReqCtx set_ctx
static bool cleanup_slot(ssh_slot_t *slot) { (void)slot; assert(0); return false; }
static void publish_slot(ssh_slot_t *slot, size_t index) { (void)slot; (void)index; assert(0); }
'''
tests = r'''
int main(void) {
for (fail_at = 1; fail_at <= 6; ++fail_at) {
stage = closes = 0;
assert(create_listener() == ESP_FAIL && s_listen_fd == -1);
assert(stage == fail_at && closes == (fail_at != 1));
}
fail_at = stage = closes = 0;
assert(create_listener() == ESP_OK && s_listen_fd == 42 && stage == 6 && !closes);
s_running = true; s_management_generation = 7;
listener_failed();
assert(s_listen_fd == -1 && closes == 1 && !s_running && s_cleanup_pending && withdrawals == 1);
assert(s_last_error == ESP_FAIL && s_management_generation == 8);
assert(s_slots[0].close_requested && s_slots[1].close_requested);
s_management_generation = UINT32_MAX;
listener_failed(); assert(s_management_generation == UINT32_MAX && closes == 1);
const int retry_errors[] = { EAGAIN, EWOULDBLOCK, EINTR, ENOMEM, ENOBUFS, ENFILE, ECONNABORTED };
for (unsigned i = 0; i < sizeof(retry_errors)/sizeof(retry_errors[0]); ++i) {
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = retry_errors[i];
unsigned before = withdrawals;
accept_connections();
assert(accepts == 1 && s_listen_fd == 42 && s_running && withdrawals == before);
}
const int fatal_errors[] = { EBADF, EINVAL, ENOTSOCK, EOPNOTSUPP };
for (unsigned i = 0; i < sizeof(fatal_errors)/sizeof(fatal_errors[0]); ++i) {
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = fatal_errors[i];
unsigned before = withdrawals;
accept_connections();
assert(accepts == 1 && s_listen_fd == -1 && !s_running && withdrawals == before + 1);
}
accepts = 0; accept_connections(); assert(!accepts);
s_listen_fd = 42; accepts = closes = 0; accept_error = 0;
accept_connections();
assert(accepts == SSH_TRANSPORT_ACCEPT_BUDGET && closes == accepts);
assert(s_counters.capacity_rejections == accepts && s_counters.tcp_connections == accepts);
struct sockaddr_storage storage = {0};
char output[SSH_TRANSPORT_PEER_CAPACITY];
struct sockaddr_in *v4 = (struct sockaddr_in *)&storage;
v4->sin_family = AF_INET; v4->sin_port = htons(65535);
assert(inet_pton(AF_INET, "192.0.2.1", &v4->sin_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "192.0.2.1:65535"));
memset(&storage, 0, sizeof(storage));
struct sockaddr_in6 *v6 = (struct sockaddr_in6 *)&storage;
v6->sin6_family = AF_INET6; v6->sin6_port = htons(65535);
assert(inet_pton(AF_INET6, "fe80::1", &v6->sin6_addr) == 1);
v6->sin6_scope_id = 3;
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[fe80::1%3]:65535"));
v6->sin6_scope_id = UINT32_MAX;
assert(inet_pton(AF_INET6, "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output));
assert(!strcmp(output, "[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff%4294967295]:65535"));
v6->sin6_scope_id = 0;
assert(inet_pton(AF_INET6, "2001:db8::1", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[2001:db8::1]:65535"));
assert(inet_pton(AF_INET6, "::ffff:192.0.2.1", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[::ffff:192.0.2.1]:65535"));
format_peer(&storage, output, 8); assert(!strcmp(output, "unknown"));
output[0] = 'x'; format_peer(&storage, output, 0); assert(output[0] == 'x');
format_peer(&storage, output, 1); assert(output[0] == 0);
storage.ss_family = AF_UNSPEC;
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "unknown"));
puts("PASS SSH Phase12 dual-stack socket setup, all setup failures, owner listener failure, scoped/mapped peers and bounded formatting");
}
'''
with tempfile.TemporaryDirectory(prefix='ssh-phase12-') as directory:
out = Path(directory)
(out / 'test.c').write_text(constants + '\n' + fakes + ''.join(function(name) for name in (
'close_socket', 'set_nonblocking', 'create_listener', 'format_peer', 'listener_failed')) + accept_fakes + function('accept_connections') + tests)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(out / 'test.c'), '-o', str(out / 'test')], check=True, timeout=30)
subprocess.run([str(out / 'test')], check=True, timeout=10)