diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index faf17f65e..caaa80f09 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,6 +63,8 @@ jobs: target: esp32 - path: 'components/display_drivers/example' target: esp32 + - path: 'components/dns_server/example' + target: esp32 - path: 'components/drv2605/example' target: esp32 - path: 'components/encoder/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index fae93e79a..7f2ef95a0 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -54,6 +54,7 @@ jobs: components/csv components/display components/display_drivers + components/dns_server components/drv2605 components/encoder components/esp-box diff --git a/components/dns_server/CMakeLists.txt b/components/dns_server/CMakeLists.txt new file mode 100644 index 000000000..26d16f4c5 --- /dev/null +++ b/components/dns_server/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "src/dns_server.cpp" + INCLUDE_DIRS "include" + REQUIRES base_component logger socket +) diff --git a/components/dns_server/README.md b/components/dns_server/README.md new file mode 100644 index 000000000..fd681cc35 --- /dev/null +++ b/components/dns_server/README.md @@ -0,0 +1,67 @@ +# DNS Server + +Simple DNS server component for implementing captive portals on ESP32 devices. + +## Features + +- Responds to all DNS queries with a configured IP address +- Lightweight implementation suitable for embedded systems +- Built on top of espp::UdpSocket for efficient UDP communication +- Useful for captive portal implementations where all domains should resolve to the device + +## Usage + +```cpp +#include "dns_server.hpp" + +// Create DNS server that responds with the AP's IP +espp::DnsServer::Config dns_config{ + .ip_address = "192.168.4.1", + .log_level = espp::Logger::Verbosity::INFO +}; +espp::DnsServer dns_server(dns_config); + +// Start the DNS server +if (dns_server.start()) { + fmt::print("DNS server started\n"); +} else { + fmt::print("Failed to start DNS server\n"); +} + +// ... server runs in background ... + +// Stop when done +dns_server.stop(); +``` + +## How It Works + +The DNS server listens on UDP port 53 (the standard DNS port) and responds to all A record queries with the configured IP address. This creates a "captive portal" effect where: + +1. When a device connects to your WiFi AP, it tries to reach the internet +2. All DNS queries are answered with your device's IP address +3. The device's captive portal detection triggers +4. The user is directed to your web interface + +## Integration with Provisioning + +This component is designed to work seamlessly with the `espp::Provisioning` component to create a complete captive portal experience for WiFi provisioning. + +## API + +### Configuration + +- `ip_address`: The IP address to respond with for all DNS queries (typically your AP's IP) +- `log_level`: Logging verbosity level + +### Methods + +- `start()`: Start the DNS server +- `stop()`: Stop the DNS server +- `is_running()`: Check if the server is currently running + +## Limitations + +- Only responds to A record queries (IPv4) +- Does not support AAAA records (IPv6) +- Minimal DNS implementation focused on captive portal use case diff --git a/components/dns_server/example/CMakeLists.txt b/components/dns_server/example/CMakeLists.txt new file mode 100644 index 000000000..98297575a --- /dev/null +++ b/components/dns_server/example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py nvs_flash dns_server wifi" + CACHE STRING + "List of components to include" + ) + +project(dns_server_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/dns_server/example/README.md b/components/dns_server/example/README.md new file mode 100644 index 000000000..43ef5b1b7 --- /dev/null +++ b/components/dns_server/example/README.md @@ -0,0 +1,45 @@ +# DNS Server Example + +This example demonstrates the use of the `dns_server` component to create a simple DNS server that responds to all queries with a single IP address. This is commonly used for captive portal implementations. + +## How to use example + +### Hardware Required + +This example can be run on any ESP32 development board. + +### Build and Flash + +Build the project and flash it to the board, then run monitor tool to view serial output: + +``` +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +## Example Output + +``` +[DNS Server Example/I][0.739]: Starting DNS Server Example +[DNS Server Example/I][0.739]: Starting WiFi AP: ESP-DNS-Test +[DNS Server Example/I][0.889]: WiFi AP started successfully +[DNS Server Example/I][0.889]: Connect to SSID: ESP-DNS-Test with password: testpassword +[DNS Server Example/I][0.899]: AP IP Address: 192.168.4.1 +[DNS Server Example/I][0.899]: Starting DNS server on 192.168.4.1:53 +[DNS Server Example/I][0.909]: DNS server started successfully +[DNS Server Example/I][0.909]: All DNS queries will resolve to: 192.168.4.1 +``` + +When a client connects and makes DNS queries, you'll see output like: + +``` +[DnsServer/I][15.234]: Resolved 'www.google.com' to 192.168.4.1 +[DnsServer/I][15.456]: Resolved 'www.apple.com' to 192.168.4.1 +``` + +## Testing + +1. Connect your phone or computer to the WiFi network "ESP-DNS-Test" with password "testpassword" +2. Try to ping any domain: `ping google.com` - all domains should resolve to 192.168.4.1 +3. The captive portal detection should trigger automatically on most devices diff --git a/components/dns_server/example/main/CMakeLists.txt b/components/dns_server/example/main/CMakeLists.txt new file mode 100644 index 000000000..a941e22ba --- /dev/null +++ b/components/dns_server/example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS ".") diff --git a/components/dns_server/example/main/dns_server_example.cpp b/components/dns_server/example/main/dns_server_example.cpp new file mode 100644 index 000000000..81bcac1a1 --- /dev/null +++ b/components/dns_server/example/main/dns_server_example.cpp @@ -0,0 +1,68 @@ +#include +#include + +#include "dns_server.hpp" +#include "logger.hpp" +#include "wifi.hpp" + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "DNS Server Example", .level = espp::Logger::Verbosity::INFO}); + + logger.info("Starting DNS Server Example"); + +#if CONFIG_ESP32_WIFI_NVS_ENABLED + // Initialize NVS + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); +#endif + + // Initialize WiFi in AP mode + std::string ap_ssid = "ESP-DNS-Test"; + std::string ap_password = "testpassword"; + + logger.info("Starting WiFi AP: {}", ap_ssid); + + espp::WifiAp ap{espp::WifiAp::Config{ + .ssid = ap_ssid, + .password = ap_password, + .channel = 1, + .max_number_of_stations = 4, + }}; + + logger.info("WiFi AP started successfully"); + logger.info("Connect to SSID: {} with password: {}", ap_ssid, ap_password); + + // Get the AP IP address + std::string ap_ip = ap.get_ip_address(); + logger.info("AP IP Address: {}", ap_ip); + + // Create and start DNS server + logger.info("Starting DNS server on {}:53", ap_ip); + + espp::DnsServer::Config dns_config{.log_level = espp::Logger::Verbosity::INFO}; + + espp::DnsServer dns_server(dns_config); + if (!dns_server.start()) { + logger.error("Failed to start DNS server"); + return; + } + + logger.info("DNS server started successfully"); + logger.info("All DNS queries will resolve to: {}", ap_ip); + logger.info(""); + logger.info("To test:"); + logger.info("1. Connect your device to WiFi network '{}'", ap_ssid); + logger.info("2. Try pinging any domain (e.g., ping google.com)"); + logger.info("3. All domains should resolve to {}", ap_ip); + + // Run forever + while (true) { + std::this_thread::sleep_for(1s); + } +} diff --git a/components/dns_server/idf_component.yml b/components/dns_server/idf_component.yml new file mode 100644 index 000000000..3f89b47b0 --- /dev/null +++ b/components/dns_server/idf_component.yml @@ -0,0 +1,20 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "DNS server component for captive portal functionality - responds to all DNS queries with a configured IP address" +url: "https://github.com/esp-cpp/espp/tree/main/components/dns_server" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/network/dns_server.html" +tas: + - cpp + - DNS + - Captive Portal +dependencies: + idf: + version: ">=5.0" + espp/base_component: ">=1.0" + espp/logger: ">=1.0" + espp/socket: ">=1.0" +examples: + - path: example diff --git a/components/dns_server/include/dns_server.hpp b/components/dns_server/include/dns_server.hpp new file mode 100644 index 000000000..37c9b08d8 --- /dev/null +++ b/components/dns_server/include/dns_server.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +#include "base_component.hpp" +#include "udp_socket.hpp" + +namespace espp { +/** + * @brief Simple DNS server for captive portal support. + * + * This component implements a minimal DNS server that responds to all DNS queries + * with a configured IP address. This is useful for captive portals where you want + * all DNS requests to resolve to the ESP32's IP address. + * + * The server listens on UDP port 53 (standard DNS port) and responds to A record + * queries with the configured IP address. + * + * \section dns_server_ex1 DNS Server Example + * \snippet dns_server_example.cpp dns server example + */ +class DnsServer : public BaseComponent { +public: + /** + * @brief Configuration for the DNS server + */ + struct Config { + std::string ip_address; /**< IP address to respond with for all DNS queries */ + espp::Logger::Verbosity log_level = espp::Logger::Verbosity::WARN; /**< Log verbosity */ + }; + + /** + * @brief Construct a new DNS Server + * @param config Configuration for the DNS server + */ + explicit DnsServer(const Config &config); + + /** + * @brief Destroy the DNS Server + */ + ~DnsServer(); + + /** + * @brief Start the DNS server + * @return true if server started successfully, false otherwise + */ + bool start(); + + /** + * @brief Stop the DNS server + */ + void stop(); + + /** + * @brief Check if the server is running + * @return true if running, false otherwise + */ + bool is_running() const; + +protected: + /** + * @brief Parse DNS query and generate response + * @param query The DNS query packet + * @param query_len Length of the query + * @param response Buffer to write the response to + * @param response_len Length of the response buffer + * @return Number of bytes written to response buffer + */ + size_t process_dns_query(const uint8_t *query, size_t query_len, uint8_t *response, + size_t response_len); + + std::string ip_address_; + std::unique_ptr socket_; + bool running_{false}; +}; +} // namespace espp diff --git a/components/dns_server/src/dns_server.cpp b/components/dns_server/src/dns_server.cpp new file mode 100644 index 000000000..38b2472ad --- /dev/null +++ b/components/dns_server/src/dns_server.cpp @@ -0,0 +1,174 @@ +#include "dns_server.hpp" + +#include +#include + +using namespace espp; + +DnsServer::DnsServer(const Config &config) + : BaseComponent("DnsServer", config.log_level) + , ip_address_(config.ip_address) { + logger_.info("DNS Server initialized, will respond with IP: {}", ip_address_); +} + +DnsServer::~DnsServer() { stop(); } + +bool DnsServer::start() { + if (running_) { + logger_.warn("DNS server already running"); + return false; + } + + logger_.info("Starting DNS server on port 53"); + + // Create UDP socket for DNS + UdpSocket::Config socket_config{.log_level = get_log_level()}; + socket_ = std::make_unique(socket_config); + + // Configure task for receiving + Task::BaseConfig task_config{ + .name = "dns_server", .stack_size_bytes = 4096, .priority = 5, .core_id = 0}; + + // Configure receive settings + UdpSocket::ReceiveConfig receive_config{ + .port = 53, // DNS port + .buffer_size = 512, // Standard DNS packet size + .is_multicast_endpoint = false, + .on_receive_callback = [this](auto &data, + auto &source) -> std::optional> { + logger_.debug("Received DNS query from {}:{}, {} bytes", source.address, source.port, + data.size()); + + // Process DNS query and generate response + std::vector response(512); + size_t response_len = + process_dns_query(data.data(), data.size(), response.data(), response.size()); + + if (response_len > 0) { + response.resize(response_len); + logger_.debug("Sending DNS response, {} bytes", response_len); + return response; // Return response to be sent back + } + + return std::nullopt; // No response + }}; + + if (!socket_->start_receiving(task_config, receive_config)) { + logger_.error("Failed to start DNS server"); + socket_.reset(); + return false; + } + + running_ = true; + logger_.info("DNS server started successfully"); + return true; +} + +void DnsServer::stop() { + if (!running_) { + return; + } + + logger_.info("Stopping DNS server"); + running_ = false; + socket_.reset(); + logger_.info("DNS server stopped"); +} + +bool DnsServer::is_running() const { return running_; } + +size_t DnsServer::process_dns_query(const uint8_t *query, size_t query_len, uint8_t *response, + size_t response_len) { + if (query_len < 12 || response_len < 512) { + logger_.error("Invalid DNS packet size"); + return 0; + } + + // DNS header structure + struct __attribute__((packed)) DnsHeader { + uint16_t id; + uint16_t flags; + uint16_t qdcount; + uint16_t ancount; + uint16_t nscount; + uint16_t arcount; + }; + + const DnsHeader *query_header = reinterpret_cast(query); + DnsHeader *response_header = reinterpret_cast(response); + + // Copy query to response + std::memcpy(response, query, query_len); + + // Set response flags: standard query response, no error + uint16_t flags = ntohs(query_header->flags); + flags |= 0x8000; // Set response bit + flags &= ~0x0F; // Clear error code + response_header->flags = htons(flags); + + // Set answer count to 1 + response_header->ancount = htons(1); + response_header->nscount = 0; + response_header->arcount = 0; + + // Find end of question section + size_t pos = 12; // After header + + // Skip domain name in question + while (pos < query_len && query[pos] != 0) { + uint8_t len = query[pos]; + if (len > 63) { // Check for compression or invalid length + logger_.error("Invalid domain name format"); + return 0; + } + pos += len + 1; + } + pos++; // Skip null terminator + + // Skip QTYPE and QCLASS (4 bytes) + pos += 4; + + if (pos > query_len) { + logger_.error("Malformed DNS query"); + return 0; + } + + // Build answer section + size_t answer_start = pos; + + // Name pointer (points back to question name) + response[answer_start++] = 0xC0; + response[answer_start++] = 0x0C; + + // Type: A record + response[answer_start++] = 0x00; + response[answer_start++] = 0x01; + + // Class: IN + response[answer_start++] = 0x00; + response[answer_start++] = 0x01; + + // TTL: 60 seconds + response[answer_start++] = 0x00; + response[answer_start++] = 0x00; + response[answer_start++] = 0x00; + response[answer_start++] = 0x3C; + + // Data length: 4 bytes (IPv4 address) + response[answer_start++] = 0x00; + response[answer_start++] = 0x04; + + // Parse IP address + in_addr addr; + if (inet_pton(AF_INET, ip_address_.c_str(), &addr) != 1) { + logger_.error("Failed to parse IP address: {}", ip_address_); + return 0; + } + + // Copy IP address bytes + std::memcpy(&response[answer_start], &addr.s_addr, 4); + answer_start += 4; + + logger_.debug("DNS response prepared, {} bytes", answer_start); + return answer_start; +} diff --git a/components/provisioning/example/CMakeLists.txt b/components/provisioning/example/CMakeLists.txt index d8042dba3..bd2f996c2 100644 --- a/components/provisioning/example/CMakeLists.txt +++ b/components/provisioning/example/CMakeLists.txt @@ -12,7 +12,7 @@ set(EXTRA_COMPONENT_DIRS set( COMPONENTS - "main esptool_py provisioning wifi task" + "main esptool_py provisioning wifi task dns_server" CACHE STRING "List of components to include" ) diff --git a/components/provisioning/example/main/provisioning_example.cpp b/components/provisioning/example/main/provisioning_example.cpp index 14726b870..836eacf99 100644 --- a/components/provisioning/example/main/provisioning_example.cpp +++ b/components/provisioning/example/main/provisioning_example.cpp @@ -1,6 +1,7 @@ #include #include +#include "dns_server.hpp" #include "logger.hpp" #include "provisioning.hpp" @@ -78,6 +79,16 @@ extern "C" void app_main(void) { logger.info("Connect to WiFi network: {}", provisioning.get_ap_ssid()); logger.info("Open browser to: http://192.168.4.1"); + // Start DNS server for captive portal (redirects all DNS queries to AP IP) + espp::DnsServer::Config dns_config{.ip_address = "192.168.4.1", + .log_level = espp::Logger::Verbosity::INFO}; + espp::DnsServer dns_server(dns_config); + if (!dns_server.start()) { + logger.error("Failed to start DNS server"); + } else { + logger.info("DNS server started for captive portal"); + } + // Keep running until user completes provisioning while (!provisioning.is_completed()) { std::this_thread::sleep_for(1s); diff --git a/components/provisioning/include/provisioning.hpp b/components/provisioning/include/provisioning.hpp index cb7dc444b..edf74d17a 100644 --- a/components/provisioning/include/provisioning.hpp +++ b/components/provisioning/include/provisioning.hpp @@ -125,6 +125,7 @@ class Provisioning : public BaseComponent { static esp_err_t status_handler(httpd_req_t *req); static esp_err_t saved_handler(httpd_req_t *req); static esp_err_t delete_handler(httpd_req_t *req); + static esp_err_t captive_portal_handler(httpd_req_t *req); // Helper methods std::string generate_html() const; diff --git a/components/provisioning/src/provisioning.cpp b/components/provisioning/src/provisioning.cpp index 2e38f3572..2ed821a55 100644 --- a/components/provisioning/src/provisioning.cpp +++ b/components/provisioning/src/provisioning.cpp @@ -318,6 +318,8 @@ bool Provisioning::start_server() { server_config.max_uri_handlers = 8; server_config.lru_purge_enable = true; server_config.stack_size = 8192; + // set the uri_match_fn to use wildcard matching + server_config.uri_match_fn = httpd_uri_match_wildcard; if (httpd_start(&server_, &server_config) != ESP_OK) { logger_.error("Failed to start HTTP server"); @@ -358,6 +360,14 @@ bool Provisioning::start_server() { .uri = "/delete", .method = HTTP_POST, .handler = delete_handler, .user_ctx = this}; httpd_register_uri_handler(server_, &delete_net); + // Wildcard handler catches all captive portal detection URIs + // Handles Android (/generate_204, /gen_204), iOS/macOS (/hotspot-detect.html, + // /library/test/success.html), Windows (/ncsi.txt, /connecttest.txt), Ubuntu (/canonical.html, + // /connectivity-check), Firefox (/success.txt), and various others (e.g., /mmtls/*) + httpd_uri_t catchall = { + .uri = "/*", .method = HTTP_GET, .handler = captive_portal_handler, .user_ctx = this}; + httpd_register_uri_handler(server_, &catchall); + return true; } @@ -424,6 +434,9 @@ bool Provisioning::test_connection(const std::string &ssid, const std::string &p logger_.info("Cleaning up previous test STA"); test_sta_.reset(); std::this_thread::sleep_for(500ms); // Give WiFi stack time to clean up + } else { + // Even if no previous test STA, give WiFi a moment to stabilize + std::this_thread::sleep_for(100ms); } std::atomic connected{false}; @@ -622,6 +635,14 @@ esp_err_t Provisioning::saved_handler(httpd_req_t *req) { return ESP_OK; } +esp_err_t Provisioning::captive_portal_handler(httpd_req_t *req) { + // Redirect to the main provisioning page + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", "/"); + httpd_resp_send(req, nullptr, 0); + return ESP_OK; +} + esp_err_t Provisioning::delete_handler(httpd_req_t *req) { auto *prov = static_cast(req->user_ctx); diff --git a/doc/Doxyfile b/doc/Doxyfile index 44c99e35f..404591115 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -91,6 +91,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/cst816/example/main/cst816_example.cpp \ $(PROJECT_PATH)/components/csv/example/main/csv_example.cpp \ $(PROJECT_PATH)/components/display_drivers/example/main/display_drivers_example.cpp \ + $(PROJECT_PATH)/components/dns_server/example/main/dns_server_example.cpp \ $(PROJECT_PATH)/components/drv2605/example/main/drv2605_example.cpp \ $(PROJECT_PATH)/components/encoder/example/main/encoder_example.cpp \ $(PROJECT_PATH)/components/esp32-timer-cam/example/main/esp_timer_cam_example.cpp \ @@ -215,6 +216,7 @@ INPUT = \ $(PROJECT_PATH)/components/display_drivers/include/ssd1351.hpp \ $(PROJECT_PATH)/components/display_drivers/include/st7789.hpp \ $(PROJECT_PATH)/components/display_drivers/include/sh8601.hpp \ + $(PROJECT_PATH)/components/dns_server/include/dns_server.hpp \ $(PROJECT_PATH)/components/drv2605/include/drv2605.hpp \ $(PROJECT_PATH)/components/drv2605/include/drv2605_menu.hpp \ $(PROJECT_PATH)/components/encoder/include/abi_encoder.hpp \ diff --git a/doc/en/network/dns_server.rst b/doc/en/network/dns_server.rst new file mode 100644 index 000000000..7b6af348d --- /dev/null +++ b/doc/en/network/dns_server.rst @@ -0,0 +1,7 @@ +DNS Server +========== + +.. doxygenclass:: espp::DnsServer + :members: + :protected-members: + :undoc-members: diff --git a/doc/en/network/dns_server_example.md b/doc/en/network/dns_server_example.md new file mode 100644 index 000000000..c87c66031 --- /dev/null +++ b/doc/en/network/dns_server_example.md @@ -0,0 +1,37 @@ +# DNS Server Example + +This example demonstrates using the `espp::DnsServer` component to create a DNS server that responds to all DNS queries. This is useful for creating captive portals or local network services. + +## How to use example + +### Hardware Required + +This example can be run on any ESP32 development board. + +### Build and Flash + +Build the project and flash it to the board, then run monitor tool to view serial output: + +``` +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. + +## Example Output + +![image](https://github.com/user-attachments/assets/dns_server_example_output.png) + +The example will: +1. Start a WiFi Access Point +2. Start a DNS server that responds to all queries +3. Log all DNS requests and responses + +You can test the DNS server by: +1. Connecting a device to the WiFi AP +2. Trying to access any domain (e.g., `ping google.com`) +3. All domains will resolve to the ESP32's IP address diff --git a/doc/en/network/index.rst b/doc/en/network/index.rst index c2f7d258d..8b181f356 100644 --- a/doc/en/network/index.rst +++ b/doc/en/network/index.rst @@ -4,6 +4,7 @@ Network APIs .. toctree:: :maxdepth: 1 + dns_server ping provisioning socket_example