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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
32
components/transport/include/commissioning.h
Normal file
32
components/transport/include/commissioning.h
Normal file
@@ -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
|
||||
394
components/transport/src/commissioning.cpp
Normal file
394
components/transport/src/commissioning.cpp
Normal file
@@ -0,0 +1,394 @@
|
||||
#include "commissioning.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#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<CommissioningState> 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<char>(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<ip_event_got_ip_t *>(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<ip_event_got_ip6_t *>(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<const char *>(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, "<p>Connecting... you can close this page.</p>", 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<char *>(wifi_config.sta.ssid), ssid, sizeof(wifi_config.sta.ssid));
|
||||
strncpy(reinterpret_cast<char *>(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<char *>(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<uint8_t>(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
|
||||
261
components/transport/src/dns_server.c
Normal file
261
components/transport/src/dns_server.c
Normal file
@@ -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 <sys/param.h>
|
||||
#include <inttypes.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
49
components/transport/src/dns_server.h
Normal file
49
components/transport/src/dns_server.h
Normal file
@@ -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
|
||||
25
components/transport/src/portal.html
Normal file
25
components/transport/src/portal.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kvida setup</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 320px; margin: 2em auto; padding: 0 1em; }
|
||||
label { display: block; margin-top: 1em; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 0.5em; }
|
||||
button { margin-top: 1.5em; width: 100%; padding: 0.7em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Connect your Kvida device</h1>
|
||||
<form method="POST" action="/connect">
|
||||
<label>Wi-Fi network name (SSID)
|
||||
<input type="text" name="ssid" required maxlength="32" autofocus>
|
||||
</label>
|
||||
<label>Wi-Fi password
|
||||
<input type="password" name="password" maxlength="64">
|
||||
</label>
|
||||
<button type="submit">Connect</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user