From af0c2da146702e269321909bab3a1cb15ad152ab Mon Sep 17 00:00:00 2001 From: Ronny Eia <3652665+eiaro@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:24:38 +0200 Subject: [PATCH] Add Wi-Fi commissioning: SoftAP captive portal with IPv6 support Implements first-boot Wi-Fi setup with no companion app required: a SoftAP + captive portal (DNS wildcard redirect, DHCP option 114, HTTP form) lets the user submit Wi-Fi credentials from any browser. On submit, credentials are saved to NVS and the device switches to STA mode; on subsequent boots it reconnects automatically via stored credentials. dns_server.c/.h are vendored from ESP-IDF's official captive_portal example (Unlicense/CC0). Requests both link-local and global IPv6 addresses once the STA connection has an IPv4 address (not on WIFI_EVENT_STA_CONNECTED -- esp_netif_create_ip6_linklocal() reliably fails there because esp-netif's own handler, which marks the lwIP netif "up", hasn't run yet due to event-handler registration order). Verified end-to-end on real ESP32-C6 hardware: SoftAP comes up, credential submission works, STA reconnects on reboot, and both a link-local and a router-assigned global IPv6 address are obtained via SLAAC. Co-Authored-By: Claude Sonnet 5 --- components/transport/CMakeLists.txt | 8 +- components/transport/README.md | 11 + components/transport/include/commissioning.h | 32 ++ components/transport/src/commissioning.cpp | 394 +++++++++++++++++++ components/transport/src/dns_server.c | 261 ++++++++++++ components/transport/src/dns_server.h | 49 +++ components/transport/src/portal.html | 25 ++ main/main.cpp | 11 + sdkconfig.defaults | 6 + 9 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 components/transport/include/commissioning.h create mode 100644 components/transport/src/commissioning.cpp create mode 100644 components/transport/src/dns_server.c create mode 100644 components/transport/src/dns_server.h create mode 100644 components/transport/src/portal.html diff --git a/components/transport/CMakeLists.txt b/components/transport/CMakeLists.txt index a96dced..97b67a7 100644 --- a/components/transport/CMakeLists.txt +++ b/components/transport/CMakeLists.txt @@ -1 +1,7 @@ -idf_component_register() +idf_component_register( + SRCS "src/commissioning.cpp" "src/dns_server.c" + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "src" + PRIV_REQUIRES esp_wifi esp_netif esp_event esp_http_server esp_timer nvs_flash + EMBED_FILES "src/portal.html" +) diff --git a/components/transport/README.md b/components/transport/README.md index 41c3167..fe54d67 100644 --- a/components/transport/README.md +++ b/components/transport/README.md @@ -6,6 +6,17 @@ Also owns Wi-Fi/MQTT configuration storage for now (not broken out into its own Future transports (Zigbee, Thread, Matter) should implement the same publish API as alternate backends behind this component's interface, without changing callers. +## Wi-Fi commissioning + +`include/commissioning.h` / `src/commissioning.cpp` implement first-time Wi-Fi setup, no companion app required: + +- On boot, `try_stored_credentials()` attempts to connect using credentials saved in NVS from a previous session. +- If none exist (or the caller chooses to), `start_commissioning()` opens a SoftAP (`Kvida-XXXXXX`, derived from the MAC) with a captive portal: a DNS server (`src/dns_server.c`/`.h`, vendored from ESP-IDF's official `captive_portal` example, Unlicense/CC0) answers every query with the AP's own IP, and DHCP option 114 plus a 404-redirects-to-`/` HTTP handler get most phones/laptops to auto-open the setup page (`src/portal.html`) in a plain browser. +- Submitting the form (`POST /connect`) saves the credentials to NVS and switches Wi-Fi to STA mode. The AP is open (no password) by design -- see the comment above `ap_config.ap.authmode` in `commissioning.cpp` for the reasoning and the tradeoff. +- A 5-minute `esp_timer` closes the commissioning window automatically if nobody submits credentials. +- **IPv6**: once STA gets its IPv4 address (`IP_EVENT_STA_GOT_IP` -- not `WIFI_EVENT_STA_CONNECTED`, see the comment in `commissioning.cpp` for why that timing matters), `esp_netif_create_ip6_linklocal()` requests a link-local address (`CONFIG_LWIP_IPV6=y` is set explicitly in `sdkconfig.defaults`); `IP_EVENT_GOT_IP6` is logged when it arrives. Verified end-to-end on real ESP32-C6 hardware. +- Not yet wired up: re-entering commissioning on an already-configured device needs a physical trigger (the user button) per AGENTS.md's "configuration mode requires physical user interaction" principle -- blocked on `drivers` having a real button implementation. Network scanning (SSID dropdown instead of free text) was left out of this first pass to keep scope tight. + TODO: built on ESP-IDF's own `esp_wifi` and `mqtt_client` (`esp-mqtt`) components once real code lands — both ship with ESP-IDF, no extra registry dependency needed. TODO: A/B OTA update-checking belongs here, driven by ESP-IDF's native `esp_ota_ops` against the `ota_0`/`ota_1` partitions in `partitions.csv` — no extra dependency needed either. diff --git a/components/transport/include/commissioning.h b/components/transport/include/commissioning.h new file mode 100644 index 0000000..dbbc63a --- /dev/null +++ b/components/transport/include/commissioning.h @@ -0,0 +1,32 @@ +#pragma once + +namespace kvida { + +enum class CommissioningState { + Idle, + ApActive, + Connecting, + Connected, + Failed, +}; + +// Starts a SoftAP + captive portal so the user can submit Wi-Fi +// credentials from any browser -- no companion app needed. Intended to +// be triggered by a physical action (e.g. a button press), per the +// "configuration mode requires physical user interaction" security +// principle in AGENTS.md. No-op if already active or already connected. +void start_commissioning(); + +// Stops the SoftAP/captive portal (e.g. on timeout, or once connected). +// Safe to call even if not active. +void stop_commissioning(); + +CommissioningState commissioning_state(); + +// Attempts to connect using credentials already stored in NVS from a +// previous commissioning session. Returns false immediately if none are +// stored; otherwise the result arrives asynchronously via +// commissioning_state(). +bool try_stored_credentials(); + +} // namespace kvida diff --git a/components/transport/src/commissioning.cpp b/components/transport/src/commissioning.cpp new file mode 100644 index 0000000..e5d9e96 --- /dev/null +++ b/components/transport/src/commissioning.cpp @@ -0,0 +1,394 @@ +#include "commissioning.h" + +#include +#include +#include + +#include "dns_server.h" + +#include "esp_event.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_netif_ip_addr.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "nvs.h" +#include "nvs_flash.h" + +namespace kvida { + +namespace { + +constexpr const char *TAG = "commissioning"; +constexpr const char *kNvsNamespace = "wifi_creds"; +constexpr const char *kNvsKeySsid = "ssid"; +constexpr const char *kNvsKeyPass = "pass"; +constexpr int64_t kCommissioningTimeoutUs = 5LL * 60 * 1000000; // 5 minutes + +std::atomic g_state{CommissioningState::Idle}; + +esp_netif_t *g_ap_netif = nullptr; +esp_netif_t *g_sta_netif = nullptr; +httpd_handle_t g_httpd = nullptr; +dns_server_handle_t g_dns = nullptr; +esp_timer_handle_t g_timeout_timer = nullptr; +bool g_handlers_registered = false; + +// Must outlive the DHCP server (esp_netif_dhcps_option only stores the +// pointer, it does not copy the string) -- see esp_netif.h. +char g_captive_portal_uri[32]; + +extern const uint8_t portal_html_start[] asm("_binary_portal_html_start"); +extern const uint8_t portal_html_end[] asm("_binary_portal_html_end"); + +void connect_sta(const char *ssid, const char *pass); + +void ensure_base_services() +{ + static bool done = false; + if (done) { + return; + } + + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + + ESP_ERROR_CHECK(esp_netif_init()); + + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_ERROR_CHECK(err); + } + + done = true; +} + +bool load_credentials(char *ssid, size_t ssid_cap, char *pass, size_t pass_cap) +{ + nvs_handle_t handle; + if (nvs_open(kNvsNamespace, NVS_READONLY, &handle) != ESP_OK) { + return false; + } + size_t ssid_len = ssid_cap; + size_t pass_len = pass_cap; + esp_err_t ssid_err = nvs_get_str(handle, kNvsKeySsid, ssid, &ssid_len); + esp_err_t pass_err = nvs_get_str(handle, kNvsKeyPass, pass, &pass_len); + nvs_close(handle); + return ssid_err == ESP_OK && pass_err == ESP_OK; +} + +void save_credentials(const char *ssid, const char *pass) +{ + nvs_handle_t handle; + ESP_ERROR_CHECK(nvs_open(kNvsNamespace, NVS_READWRITE, &handle)); + ESP_ERROR_CHECK(nvs_set_str(handle, kNvsKeySsid, ssid)); + ESP_ERROR_CHECK(nvs_set_str(handle, kNvsKeyPass, pass)); + ESP_ERROR_CHECK(nvs_commit(handle)); + nvs_close(handle); +} + +// Minimal '%XX' + '+' decoder for URL-encoded form fields (in place). +void url_decode(char *s) +{ + char *out = s; + while (*s) { + if (*s == '+') { + *out++ = ' '; + s++; + } else if (*s == '%' && s[1] && s[2]) { + const char hex[3] = {s[1], s[2], 0}; + *out++ = static_cast(strtol(hex, nullptr, 16)); + s += 3; + } else { + *out++ = *s++; + } + } + *out = '\0'; +} + +void ensure_event_handlers_registered(); + +void wifi_event_handler(void * /*arg*/, esp_event_base_t /*base*/, int32_t event_id, void * /*data*/) +{ + if (event_id == WIFI_EVENT_STA_DISCONNECTED) { + if (g_state.load() == CommissioningState::Connecting) { + ESP_LOGW(TAG, "Failed to connect to the configured network"); + g_state.store(CommissioningState::Failed); + } + } +} + +void ip_event_handler(void * /*arg*/, esp_event_base_t /*base*/, int32_t event_id, void *event_data) +{ + if (event_id == IP_EVENT_STA_GOT_IP) { + auto *event = static_cast(event_data); + ESP_LOGI(TAG, "Got IPv4 address: " IPSTR, IP2STR(&event->ip_info.ip)); + g_state.store(CommissioningState::Connected); + + // Requested here rather than on WIFI_EVENT_STA_CONNECTED: at that + // point esp-netif's own internal handler (which marks the lwIP + // netif "up") may not have run yet, since handlers for the same + // event fire in registration order and ours is registered before + // esp_netif_create_default_wifi_sta() runs -- esp_netif_create_ip6_linklocal() + // then fails with ESP_FAIL because the netif isn't up (confirmed + // on real hardware). By the time DHCP has produced an IPv4 + // address the netif is definitely up. + if (g_sta_netif) { + esp_err_t err = esp_netif_create_ip6_linklocal(g_sta_netif); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal -> %s", esp_err_to_name(err)); + } + } + } else if (event_id == IP_EVENT_GOT_IP6) { + auto *event = static_cast(event_data); + ESP_LOGI(TAG, "Got IPv6 address: " IPV6STR, IPV62STR(event->ip6_info.ip)); + } +} + +void ensure_event_handlers_registered() +{ + if (g_handlers_registered) { + return; + } + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, nullptr)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &ip_event_handler, nullptr)); + g_handlers_registered = true; +} + +void ensure_wifi_driver_initialized() +{ + wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t err = esp_wifi_init(&init_cfg); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_ERROR_CHECK(err); + } +} + +esp_err_t root_get_handler(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, reinterpret_cast(portal_html_start), + portal_html_end - portal_html_start); + return ESP_OK; +} + +esp_err_t connect_post_handler(httpd_req_t *req) +{ + char body[256]; + int len = httpd_req_recv(req, body, sizeof(body) - 1); + if (len <= 0) { + httpd_resp_send_500(req); + return ESP_FAIL; + } + body[len] = '\0'; + + char ssid[33] = {0}; + char pass[65] = {0}; + bool have_ssid = httpd_query_key_value(body, "ssid", ssid, sizeof(ssid)) == ESP_OK; + httpd_query_key_value(body, "password", pass, sizeof(pass)); // optional: open networks + + if (!have_ssid || ssid[0] == '\0') { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_send(req, "Missing SSID", HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + + url_decode(ssid); + url_decode(pass); + + save_credentials(ssid, pass); + + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, "

Connecting... you can close this page.

", HTTPD_RESP_USE_STRLEN); + + connect_sta(ssid, pass); + return ESP_OK; +} + +esp_err_t not_found_handler(httpd_req_t *req, httpd_err_code_t /*err*/) +{ + // Classic captive-portal trick: send every unknown path back to "/". + httpd_resp_set_status(req, "303 See Other"); + httpd_resp_set_hdr(req, "Location", "/"); + httpd_resp_send(req, "Redirect to the Kvida setup page", HTTPD_RESP_USE_STRLEN); + return ESP_OK; +} + +void start_captive_dns() +{ + // Not using the DNS_SERVER_CONFIG_SINGLE() convenience macro from + // dns_server.h: it doesn't initialize dns_entry_pair_t::ip, which + // trips -Werror=missing-field-initializers under this project's + // stricter warning flags. + dns_server_config_t config = {}; + config.num_of_entries = 1; + config.item[0].name = "*"; + config.item[0].if_key = "WIFI_AP_DEF"; + g_dns = start_dns_server(&config); +} + +void set_ap_captive_portal_url() +{ + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(g_ap_netif, &ip_info); + snprintf(g_captive_portal_uri, sizeof(g_captive_portal_uri), "http://" IPSTR, IP2STR(&ip_info.ip)); + + esp_netif_dhcps_stop(g_ap_netif); + ESP_ERROR_CHECK(esp_netif_dhcps_option(g_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, + g_captive_portal_uri, strlen(g_captive_portal_uri) + 1)); + ESP_ERROR_CHECK(esp_netif_dhcps_start(g_ap_netif)); +} + +void start_http_server() +{ + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.lru_purge_enable = true; + if (httpd_start(&g_httpd, &config) != ESP_OK) { + ESP_LOGE(TAG, "Failed to start HTTP server"); + return; + } + + static const httpd_uri_t root_uri = {.uri = "/", .method = HTTP_GET, .handler = root_get_handler, .user_ctx = nullptr}; + static const httpd_uri_t connect_uri = {.uri = "/connect", .method = HTTP_POST, .handler = connect_post_handler, .user_ctx = nullptr}; + httpd_register_uri_handler(g_httpd, &root_uri); + httpd_register_uri_handler(g_httpd, &connect_uri); + httpd_register_err_handler(g_httpd, HTTPD_404_NOT_FOUND, not_found_handler); +} + +void teardown_portal() +{ + if (g_timeout_timer) { + esp_timer_stop(g_timeout_timer); + esp_timer_delete(g_timeout_timer); + g_timeout_timer = nullptr; + } + if (g_httpd) { + httpd_stop(g_httpd); + g_httpd = nullptr; + } + if (g_dns) { + stop_dns_server(g_dns); + g_dns = nullptr; + } +} + +void on_commissioning_timeout(void * /*arg*/) +{ + ESP_LOGW(TAG, "Commissioning window timed out with no credentials submitted"); + stop_commissioning(); +} + +void connect_sta(const char *ssid, const char *pass) +{ + teardown_portal(); + g_state.store(CommissioningState::Connecting); + + esp_wifi_stop(); // ignore error: fine if Wi-Fi wasn't started yet + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + + if (!g_sta_netif) { + g_sta_netif = esp_netif_create_default_wifi_sta(); + } + + wifi_config_t wifi_config = {}; + strncpy(reinterpret_cast(wifi_config.sta.ssid), ssid, sizeof(wifi_config.sta.ssid)); + strncpy(reinterpret_cast(wifi_config.sta.password), pass, sizeof(wifi_config.sta.password)); + + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_connect()); +} + +} // namespace + +void start_commissioning() +{ + CommissioningState current = g_state.load(); + if (current == CommissioningState::ApActive || current == CommissioningState::Connected) { + return; + } + + ensure_base_services(); + ensure_event_handlers_registered(); + + if (!g_ap_netif) { + g_ap_netif = esp_netif_create_default_wifi_ap(); + } + ensure_wifi_driver_initialized(); + + uint8_t mac[6]; + esp_wifi_get_mac(WIFI_IF_AP, mac); + + wifi_config_t ap_config = {}; + int len = snprintf(reinterpret_cast(ap_config.ap.ssid), sizeof(ap_config.ap.ssid), + "Kvida-%02X%02X%02X", mac[3], mac[4], mac[5]); + ap_config.ap.ssid_len = static_cast(len); + ap_config.ap.max_connection = 4; + // Open AP by design: this is a short-lived, physically-local-only setup + // network with a mandatory timeout (kCommissioningTimeoutUs), not a + // permanent access point. Trades a small window of cleartext exposure + // for zero-friction setup from any browser -- no companion app or + // shared secret to distribute. Revisit if that tradeoff stops being + // acceptable (e.g. WPA2 with a per-device PSK printed on a label). + ap_config.ap.authmode = WIFI_AUTH_OPEN; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + set_ap_captive_portal_url(); + start_http_server(); + start_captive_dns(); + + const esp_timer_create_args_t timer_args = { + .callback = &on_commissioning_timeout, + .arg = nullptr, + .dispatch_method = ESP_TIMER_TASK, + .name = "commissioning_timeout", + .skip_unhandled_events = false, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &g_timeout_timer)); + ESP_ERROR_CHECK(esp_timer_start_once(g_timeout_timer, kCommissioningTimeoutUs)); + + g_state.store(CommissioningState::ApActive); + ESP_LOGI(TAG, "Commissioning AP \"%s\" active", ap_config.ap.ssid); +} + +void stop_commissioning() +{ + if (g_state.load() != CommissioningState::ApActive) { + return; + } + teardown_portal(); + esp_wifi_stop(); + g_state.store(CommissioningState::Idle); +} + +CommissioningState commissioning_state() +{ + return g_state.load(); +} + +bool try_stored_credentials() +{ + ensure_base_services(); + + char ssid[33] = {0}; + char pass[65] = {0}; + if (!load_credentials(ssid, sizeof(ssid), pass, sizeof(pass))) { + return false; + } + + ensure_event_handlers_registered(); + ensure_wifi_driver_initialized(); + + connect_sta(ssid, pass); + return true; +} + +} // namespace kvida diff --git a/components/transport/src/dns_server.c b/components/transport/src/dns_server.c new file mode 100644 index 0000000..e5bf19c --- /dev/null +++ b/components/transport/src/dns_server.c @@ -0,0 +1,261 @@ +/* + * Vendored from ESP-IDF's official captive_portal example + * (examples/protocols/http_server/captive_portal/components/dns_server), + * SPDX-License-Identifier: Unlicense OR CC0-1.0. Unmodified except for + * this note. + * + * SPDX-FileCopyrightText: 2021-2023 Espressif Systems (Shanghai) CO LTD + */ + +#include +#include + +#include "esp_log.h" +#include "esp_system.h" +#include "esp_check.h" +#include "esp_netif.h" + +#include "lwip/err.h" +#include "lwip/sockets.h" +#include "lwip/sys.h" +#include "lwip/netdb.h" +#include "dns_server.h" + +#define DNS_PORT (53) +#define DNS_MAX_LEN (256) + +#define OPCODE_MASK (0x7800) +#define QR_FLAG (1 << 7) +#define QD_TYPE_A (0x0001) +#define ANS_TTL_SEC (300) + +static const char *TAG = "dns_server"; + +typedef struct __attribute__((__packed__)) +{ + uint16_t id; + uint16_t flags; + uint16_t qd_count; + uint16_t an_count; + uint16_t ns_count; + uint16_t ar_count; +} dns_header_t; + +typedef struct { + uint16_t type; + uint16_t class; +} dns_question_t; + +typedef struct __attribute__((__packed__)) +{ + uint16_t ptr_offset; + uint16_t type; + uint16_t class; + uint32_t ttl; + uint16_t addr_len; + uint32_t ip_addr; +} dns_answer_t; + +struct dns_server_handle { + bool started; + TaskHandle_t task; + int num_of_entries; + dns_entry_pair_t entry[]; +}; + +static char *parse_dns_name(char *raw_name, char *parsed_name, size_t parsed_name_max_len) +{ + char *label = raw_name; + char *name_itr = parsed_name; + int name_len = 0; + + do { + int sub_name_len = *label; + name_len += (sub_name_len + 1); + if (name_len > parsed_name_max_len) { + return NULL; + } + + memcpy(name_itr, label + 1, sub_name_len); + name_itr[sub_name_len] = '.'; + name_itr += (sub_name_len + 1); + label += sub_name_len + 1; + } while (*label != 0); + + parsed_name[name_len - 1] = '\0'; + return label + 1; +} + +static int parse_dns_request(char *req, size_t req_len, char *dns_reply, size_t dns_reply_max_len, dns_server_handle_t h) +{ + if (req_len > dns_reply_max_len) { + return -1; + } + + memset(dns_reply, 0, dns_reply_max_len); + memcpy(dns_reply, req, req_len); + + dns_header_t *header = (dns_header_t *)dns_reply; + ESP_LOGD(TAG, "DNS query with header id: 0x%X, flags: 0x%X, qd_count: %d", + ntohs(header->id), ntohs(header->flags), ntohs(header->qd_count)); + + if ((header->flags & OPCODE_MASK) != 0) { + return 0; + } + + header->flags |= QR_FLAG; + + uint16_t qd_count = ntohs(header->qd_count); + header->an_count = htons(qd_count); + + int reply_len = qd_count * sizeof(dns_answer_t) + req_len; + if (reply_len > dns_reply_max_len) { + return -1; + } + + char *cur_ans_ptr = dns_reply + req_len; + char *cur_qd_ptr = dns_reply + sizeof(dns_header_t); + char name[128]; + + for (int qd_i = 0; qd_i < qd_count; qd_i++) { + char *name_end_ptr = parse_dns_name(cur_qd_ptr, name, sizeof(name)); + if (name_end_ptr == NULL) { + ESP_LOGE(TAG, "Failed to parse DNS question: %s", cur_qd_ptr); + return -1; + } + + dns_question_t *question = (dns_question_t *)(name_end_ptr); + uint16_t qd_type = ntohs(question->type); + uint16_t qd_class = ntohs(question->class); + + ESP_LOGD(TAG, "Received type: %d | Class: %d | Question for: %s", qd_type, qd_class, name); + + if (qd_type == QD_TYPE_A) { + esp_ip4_addr_t ip = { .addr = IPADDR_ANY }; + for (int i = 0; i < h->num_of_entries; ++i) { + if (strcmp(h->entry[i].name, "*") == 0 || strcmp(h->entry[i].name, name) == 0) { + if (h->entry[i].if_key) { + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey(h->entry[i].if_key), &ip_info); + ip.addr = ip_info.ip.addr; + break; + } else if (h->entry->ip.addr != IPADDR_ANY) { + ip.addr = h->entry[i].ip.addr; + break; + } + } + } + if (ip.addr == IPADDR_ANY) { + continue; + } + dns_answer_t *answer = (dns_answer_t *)cur_ans_ptr; + + answer->ptr_offset = htons(0xC000 | (cur_qd_ptr - dns_reply)); + answer->type = htons(qd_type); + answer->class = htons(qd_class); + answer->ttl = htonl(ANS_TTL_SEC); + + ESP_LOGD(TAG, "Answer with PTR offset: 0x%" PRIX16 " and IP 0x%" PRIX32, ntohs(answer->ptr_offset), ip.addr); + + answer->addr_len = htons(sizeof(ip.addr)); + answer->ip_addr = ip.addr; + } + } + return reply_len; +} + +void dns_server_task(void *pvParameters) +{ + char rx_buffer[128]; + char addr_str[128]; + int addr_family; + int ip_protocol; + dns_server_handle_t handle = pvParameters; + + while (handle->started) { + + struct sockaddr_in dest_addr; + dest_addr.sin_addr.s_addr = htonl(INADDR_ANY); + dest_addr.sin_family = AF_INET; + dest_addr.sin_port = htons(DNS_PORT); + addr_family = AF_INET; + ip_protocol = IPPROTO_IP; + inet_ntoa_r(dest_addr.sin_addr, addr_str, sizeof(addr_str) - 1); + + int sock = socket(addr_family, SOCK_DGRAM, ip_protocol); + if (sock < 0) { + ESP_LOGE(TAG, "Unable to create socket: errno %d", errno); + break; + } + ESP_LOGI(TAG, "Socket created"); + + int err = bind(sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr)); + if (err < 0) { + ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); + } + ESP_LOGI(TAG, "Socket bound, port %d", DNS_PORT); + + while (handle->started) { + struct sockaddr_in6 source_addr; + socklen_t socklen = sizeof(source_addr); + int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer) - 1, 0, (struct sockaddr *)&source_addr, &socklen); + + if (len < 0) { + ESP_LOGE(TAG, "recvfrom failed: errno %d", errno); + close(sock); + break; + } else { + if (source_addr.sin6_family == PF_INET) { + inet_ntoa_r(((struct sockaddr_in *)&source_addr)->sin_addr.s_addr, addr_str, sizeof(addr_str) - 1); + } else if (source_addr.sin6_family == PF_INET6) { + inet6_ntoa_r(source_addr.sin6_addr, addr_str, sizeof(addr_str) - 1); + } + + rx_buffer[len] = 0; + + char reply[DNS_MAX_LEN]; + int reply_len = parse_dns_request(rx_buffer, len, reply, DNS_MAX_LEN, handle); + + ESP_LOGD(TAG, "Received %d bytes from %s | DNS reply with len: %d", len, addr_str, reply_len); + if (reply_len <= 0) { + ESP_LOGE(TAG, "Failed to prepare a DNS reply"); + } else { + int err = sendto(sock, reply, reply_len, 0, (struct sockaddr *)&source_addr, sizeof(source_addr)); + if (err < 0) { + ESP_LOGE(TAG, "Error occurred during sending: errno %d", errno); + break; + } + } + } + } + + if (sock != -1) { + ESP_LOGE(TAG, "Shutting down socket"); + shutdown(sock, 0); + close(sock); + } + } + vTaskDelete(NULL); +} + +dns_server_handle_t start_dns_server(dns_server_config_t *config) +{ + dns_server_handle_t handle = calloc(1, sizeof(struct dns_server_handle) + config->num_of_entries * sizeof(dns_entry_pair_t)); + ESP_RETURN_ON_FALSE(handle, NULL, TAG, "Failed to allocate dns server handle"); + + handle->started = true; + handle->num_of_entries = config->num_of_entries; + memcpy(handle->entry, config->item, config->num_of_entries * sizeof(dns_entry_pair_t)); + + xTaskCreate(dns_server_task, "dns_server", 4096, handle, 5, &handle->task); + return handle; +} + +void stop_dns_server(dns_server_handle_t handle) +{ + if (handle) { + handle->started = false; + vTaskDelete(handle->task); + free(handle); + } +} diff --git a/components/transport/src/dns_server.h b/components/transport/src/dns_server.h new file mode 100644 index 0000000..b04da02 --- /dev/null +++ b/components/transport/src/dns_server.h @@ -0,0 +1,49 @@ +/* + * Vendored from ESP-IDF's official captive_portal example + * (examples/protocols/http_server/captive_portal/components/dns_server), + * SPDX-License-Identifier: Unlicense OR CC0-1.0. Unmodified except for + * this note -- kept as a private implementation detail of `transport`'s + * commissioning captive portal, not a standalone component. + * + * SPDX-FileCopyrightText: 2021-2023 Espressif Systems (Shanghai) CO LTD + */ + +#pragma once + +// Not in the original example file (it relied on an includer pulling this +// in first) -- added so this header is self-contained when included +// directly from commissioning.cpp. +#include "esp_netif_ip_addr.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef DNS_SERVER_MAX_ITEMS +#define DNS_SERVER_MAX_ITEMS 1 +#endif + +#define DNS_SERVER_CONFIG_SINGLE(queried_name, netif_key) { \ + .num_of_entries = 1, \ + .item = { { .name = queried_name, .if_key = netif_key } } \ + } + +typedef struct dns_entry_pair { + const char* name; + const char* if_key; + esp_ip4_addr_t ip; +} dns_entry_pair_t; + +typedef struct dns_server_config { + int num_of_entries; + dns_entry_pair_t item[DNS_SERVER_MAX_ITEMS]; +} dns_server_config_t; + +typedef struct dns_server_handle *dns_server_handle_t; + +dns_server_handle_t start_dns_server(dns_server_config_t *config); +void stop_dns_server(dns_server_handle_t handle); + +#ifdef __cplusplus +} +#endif diff --git a/components/transport/src/portal.html b/components/transport/src/portal.html new file mode 100644 index 0000000..7fc34de --- /dev/null +++ b/components/transport/src/portal.html @@ -0,0 +1,25 @@ + + + + + Kvida setup + + + +

Connect your Kvida device

+
+ + + +
+ + diff --git a/main/main.cpp b/main/main.cpp index d50b1ba..bc339b9 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -1,3 +1,4 @@ +#include "commissioning.h" #include "esp_log.h" namespace { @@ -7,4 +8,14 @@ constexpr const char *TAG = "kvida_os"; extern "C" void app_main(void) { ESP_LOGI(TAG, "kvida-os starting"); + + // First boot / no stored Wi-Fi credentials -> fall straight into + // commissioning (nothing to protect yet, so no physical-interaction + // gate needed here). Re-entering commissioning on an already-configured + // device should require a physical trigger (e.g. the user button) per + // AGENTS.md's security principle -- not wired up yet since `drivers` + // doesn't have a real button implementation. + if (!kvida::try_stored_credentials()) { + kvida::start_commissioning(); + } } diff --git a/sdkconfig.defaults b/sdkconfig.defaults index fb7f7b7..08bac24 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -8,3 +8,9 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" # Matches the 4MB layout assumed in partitions.csv (TODO: adjust both # together once real hardware's actual flash size is confirmed). CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# IPv6 is required (not just the ESP-IDF default) -- transport/commissioning +# requests a link-local IPv6 address once Wi-Fi STA connects. Explicit here +# so it can't silently regress even though CONFIG_LWIP_IPV6 already +# defaults to y in this ESP-IDF version. +CONFIG_LWIP_IPV6=y