From 8a2307a5ced6da94cc674602219d5a68a1246264 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 15 May 2026 13:06:25 +0530 Subject: initiall commit --- .env.example | 12 +++ .gitignore | 15 +++ CHECKLIST.md | 57 +++++++++++ CMakeLists.txt | 4 + PLAN.md | 99 +++++++++++++++++++ main/CMakeLists.txt | 9 ++ main/config.c | 142 +++++++++++++++++++++++++++ main/config.h | 39 ++++++++ main/dns_server.c | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main/dns_server.h | 13 +++ main/firewall.c | 96 +++++++++++++++++++ main/firewall.h | 18 ++++ sdkconfig.defaults | 26 +++++ 13 files changed, 800 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHECKLIST.md create mode 100644 CMakeLists.txt create mode 100644 PLAN.md create mode 100644 main/CMakeLists.txt create mode 100644 main/config.c create mode 100644 main/config.h create mode 100644 main/dns_server.c create mode 100644 main/dns_server.h create mode 100644 main/firewall.c create mode 100644 main/firewall.h create mode 100644 sdkconfig.defaults diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..497bf81 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +WIFI_SSID=YourUpstreamWiFi +WIFI_PASSWORD=YourWiFiPassword +AP_SSID=TollGate +AP_PASSWORD= +PORT_A=/dev/ttyACM0 +PORT_B=/dev/ttyACM1 +MINT_URL=https://nofee.testnut.cashu.space +LNURL_URL=https://redeem.cashu.me/.well-known/lnurlp/tollgate +TEST_MINT=nofee.testnut.cashu.space +PRICE_PER_STEP=21 +STEP_UNIT=milliseconds +STEP_SIZE=60000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7663a94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +build/ +sdkconfig.old +managed_components/ +dependencies.lock +test-results/ +playwright-report/ +node_modules/ +*.pyc +__pycache__/ +*.o +*.bin +*.elf +*.map + NAND/ diff --git a/CHECKLIST.md b/CHECKLIST.md new file mode 100644 index 0000000..e5f99a9 --- /dev/null +++ b/CHECKLIST.md @@ -0,0 +1,57 @@ +# TollGate ESP32 — Progress Checklist + +## Phase 0: Bootstrap +- [x] Create project directory and git repo +- [x] Create .env, .env.example, .gitignore +- [x] Persist PLAN.md and CHECKLIST.md +- [ ] Create ESP-IDF project skeleton +- [ ] Create Makefile with detect targets +- [ ] Run `make detect-all` — identify ESP32 boards + +## Phase 1: Captive Portal + Firewall +- [ ] Implement tollgate_main.c (WiFi AP+STA, event loop) +- [ ] Implement config.c/h (JSON config loading) +- [ ] Implement dns_server.c/h (DNS hijack/forward) +- [ ] Implement captive_portal.c/h (HTTP :80, portal HTML) +- [ ] Implement firewall.c/h (NAPT, per-IP auth) +- [ ] Set up test infrastructure (Playwright, helpers) +- [ ] Test 1: Boot and AP appears +- [ ] Test 2: DHCP lease +- [ ] Test 3: Captive portal serves HTML +- [ ] Test 4: Captive detection URIs work +- [ ] Test 5: DNS hijack before auth +- [ ] Test 6: No internet before auth +- [ ] Test 7: /whoami returns MAC +- [ ] Test 8: /usage returns no session +- [ ] Test 9: Grant access via API +- [ ] Test 10: DNS forward after auth +- [ ] Test 11: Internet after auth +- [ ] Test 12: HTTP browsing works +- [ ] Test 13: Reset auth +- [ ] Test 14: Internet blocked after reset + +## Phase 2: E-Cash Payments (Simple Melt) +- [ ] Implement payment.c/h (Cashu token parse + melt) +- [ ] Implement session.c/h (time-based metering) +- [ ] Implement tollgate_api.c/h (:2121 endpoints) +- [ ] Update captive portal HTML with payment form +- [ ] Test 15: Advertisement valid +- [ ] Test 16: Valid payment +- [ ] Test 17: Usage tracking +- [ ] Test 18: Internet after payment +- [ ] Test 19: Invalid token rejected +- [ ] Test 20: Spent token rejected +- [ ] Test 21: Wrong mint rejected +- [ ] Test 22: Session expiry +- [ ] Test 23: Session renewal +- [ ] Test 24: Portal payment form +- [ ] Test 25: Two clients pay independently +- [ ] Test 26: Client isolation +- [ ] Test 27: Full e2e browser flow + +## Phase 3: nucula Wallet + Reseller +- [ ] Extract nucula wallet into components/cashu_wallet/ +- [ ] Replace simple melt with Wallet::receive() +- [ ] Implement payout.c/h (background melt-to-LN) +- [ ] Implement upstream_client.c/h (reseller mode) +- [ ] Test 28-38: All Phase 3 tests diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..ae5284d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) +set(EXTRA_COMPONENT_DIRS "components") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(esp32-tollgate) diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c4373fb --- /dev/null +++ b/PLAN.md @@ -0,0 +1,99 @@ +# TollGate ESP32 — Test-Driven Development Plan + +## Overview + +Build a TollGate firmware for two ESP32 devices, following the [TollGate protocol spec](https://github.com/OpenTollGate/tollgate) (TIP-01, TIP-02, HTTP-01/02/03). The implementation uses ESP-IDF (C/C++) and integrates the nucula Cashu wallet. + +## Architecture Decision: C/C++ (ESP-IDF) + +- Existing working captive portal is in C (ESP-IDF) +- Nucula Cashu wallet is in C/C++ (ESP-IDF) +- ESP-IDF is already installed at `~/esp/esp-idf` +- No Rust/ESP32 toolchain installed + +## Technology Stack + +| Layer | Technology | +|-------|-----------| +| Framework | ESP-IDF v5.4.1 (C/C++) | +| Cashu wallet | nucula `Wallet` class (Phase 3) | +| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API) | +| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) | +| NAT | lwIP NAPT | +| Testing | Playwright + curl + pyserial | +| Build | Makefile | + +## Three-Phase Plan + +### Phase 1: Captive Portal + Firewall (No Payments) + +**Goal:** WiFi repeater with captive portal that gates internet access. Validates DNS hijack, NAT, DHCP, firewall. + +**Endpoints:** +- `GET /whoami` — returns client MAC +- `GET /usage` — returns `-1/-1` +- Captive portal HTML on port 80 + +**14 Test Cases:** +| # | Test | Method | Pass Criteria | +|---|------|--------|---------------| +| 1 | Boot and AP appears | Serial + nmcli | SSID visible in scan | +| 2 | DHCP lease | nmcli connect | Gets IP in 192.168.4.0/24 | +| 3 | Captive portal serves HTML | GET / | 200, contains "TollGate" | +| 4 | Captive detection URIs work | GET /generate_204 etc. | All return portal HTML | +| 5 | DNS hijack before auth | nslookup google.com | Resolves to 192.168.4.1 | +| 6 | No internet before auth | ping 8.8.8.8 | Fails | +| 7 | /whoami returns MAC | GET /whoami | Returns mac=XX:XX:... | +| 8 | /usage returns no session | GET /usage | Returns -1/-1 | +| 9 | Grant access via API | GET /grant_access | 200, status granted | +| 10 | DNS forward after auth | nslookup google.com | Resolves to real IP | +| 11 | Internet after auth | ping 8.8.8.8 | Succeeds | +| 12 | HTTP browsing works | Playwright | Page loads | +| 13 | Reset auth | GET /reset_authentication | 200 | +| 14 | Internet blocked after reset | ping 8.8.8.8 | Fails | + +### Phase 2: E-Cash Payments (Simple Melt-to-LNURL) + +**Goal:** Replace free access with Cashu payment. ESP32 parses token, melts via mint API to operator's LNURL. + +**New Endpoints:** +- `GET /` on :2121 — TollGate advertisement (kind=10021) +- `POST /` on :2121 — Accept Cashu token, melt, return session (kind=1022) or notice (kind=21023) + +**13 Additional Test Cases:** +| # | Test | Method | Pass Criteria | +|---|------|--------|---------------| +| 15 | Advertisement valid | GET :2121/ | kind=10021 with price_per_step | +| 16 | Valid payment | POST :2121/ with token | kind=1022 session | +| 17 | Usage tracking | GET :2121/usage | 0/allotment | +| 18 | Internet after payment | ping | Succeeds | +| 19 | Invalid token | POST :2121/ garbage | kind=21023 error | +| 20 | Spent token | Reuse token | kind=21023 spent error | +| 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | +| 22 | Session expiry | Wait for allotment | Internet blocked | +| 23 | Session renewal | Second payment | Allotment extended | +| 24 | Portal payment form | Playwright paste token | Checkmark shown | +| 25 | Two clients pay independently | Two POSTs | Both authenticated | +| 26 | Client isolation | Only payer gets internet | Non-payer blocked | +| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | + +### Phase 3: nucula Wallet Integration + Reseller + +**Goal:** Integrate nucula's full Cashu wallet. ESP32 holds balance, can be a reseller. + +**11 Additional Test Cases:** +| # | Test | Method | Pass Criteria | +|---|------|--------|---------------| +| 28 | Wallet boot | Serial | Keysets loaded | +| 29 | Receive via wallet | POST :2121/ | Balance incremented | +| 30 | Balance persists | Reboot | Same balance | +| 31 | Payout routine | Wait + serial | Tokens melted to LN | +| 32 | Reseller discover | Serial | Upstream TollGate found | +| 33 | Reseller pay | Serial + API | Token POSTed upstream | +| 34 | Multi-hop internet | Ping from laptop | laptop→A→B→internet | +| 35 | P2PK receive | Post P2PK token | Auto-signed, accepted | +| 36 | DLEQ verified | Post token with DLEQ | Verified, accepted | +| 37 | 5 consecutive payments | Loop | All authenticated | +| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | + +## Total: 38 Tests across 3 phases diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..2c94ff1 --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register(SRCS "tollgate_main.c" + "config.c" + "dns_server.c" + "captive_portal.c" + "firewall.c" + INCLUDE_DIRS "." + REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server + lwip json esp_http_client esp_tls log + PRIV_REQUIRES lwip) diff --git a/main/config.c b/main/config.c new file mode 100644 index 0000000..f78bc8b --- /dev/null +++ b/main/config.c @@ -0,0 +1,142 @@ +#include "config.h" +#include "esp_log.h" +#include "esp_spiffs.h" +#include "cJSON.h" +#include + +static const char *TAG = "tollgate_config"; +static tollgate_config_t g_config; + +esp_err_t tollgate_config_init(void) +{ + memset(&g_config, 0, sizeof(g_config)); + g_config.max_retry = 5; + g_config.ap_channel = 1; + g_config.ap_max_conn = 4; + g_config.price_per_step = 21; + g_config.step_size_ms = 60000; + + esp_vfs_spiffs_conf_t conf = { + .base_path = "/spiffs", + .partition_label = NULL, + .max_files = 5, + .format_if_mount_failed = true, + }; + esp_err_t ret = esp_vfs_spiffs_register(&conf); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to mount SPIFFS: %s", esp_err_to_name(ret)); + return ret; + } + + FILE *f = fopen("/spiffs/config.json", "r"); + if (!f) { + ESP_LOGW(TAG, "No config.json found, generating default"); + const char *default_json = "{" + "\"wifi_networks\":[" + "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}" + "]," + "\"ap_ssid\":\"TollGate\"," + "\"ap_password\":\"\"," + "\"ap_channel\":1," + "\"mint_url\":\"https://nofee.testnut.cashu.space\"," + "\"lnurl_url\":\"https://redeem.cashu.me/.well-known/lnurlp/tollgate\"," + "\"price_per_step\":21," + "\"step_size_ms\":60000" + "}"; + f = fopen("/spiffs/config.json", "w"); + if (f) { + fputs(default_json, f); + fclose(f); + } + f = fopen("/spiffs/config.json", "r"); + } + + if (!f) { + ESP_LOGE(TAG, "Failed to open config.json"); + return ESP_FAIL; + } + + fseek(f, 0, SEEK_END); + long fsize = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = malloc(fsize + 1); + if (!buf) { + fclose(f); + return ESP_ERR_NO_MEM; + } + fread(buf, 1, fsize, f); + buf[fsize] = '\0'; + fclose(f); + + cJSON *root = cJSON_Parse(buf); + free(buf); + if (!root) { + ESP_LOGE(TAG, "Failed to parse config.json"); + return ESP_FAIL; + } + + cJSON *networks = cJSON_GetObjectItem(root, "wifi_networks"); + if (networks && cJSON_IsArray(networks)) { + int count = cJSON_GetArraySize(networks); + if (count > TOLLGATE_MAX_WIFI_NETWORKS) count = TOLLGATE_MAX_WIFI_NETWORKS; + for (int i = 0; i < count; i++) { + cJSON *net = cJSON_GetArrayItem(networks, i); + cJSON *ssid = cJSON_GetObjectItem(net, "ssid"); + cJSON *pass = cJSON_GetObjectItem(net, "password"); + if (ssid && pass) { + strncpy(g_config.networks[i].ssid, ssid->valuestring, sizeof(g_config.networks[i].ssid) - 1); + strncpy(g_config.networks[i].password, pass->valuestring, sizeof(g_config.networks[i].password) - 1); + g_config.network_count++; + } + } + } + + cJSON *ap_ssid = cJSON_GetObjectItem(root, "ap_ssid"); + if (ap_ssid) strncpy(g_config.ap_ssid, ap_ssid->valuestring, sizeof(g_config.ap_ssid) - 1); + else strncpy(g_config.ap_ssid, "TollGate", sizeof(g_config.ap_ssid) - 1); + + cJSON *ap_pass = cJSON_GetObjectItem(root, "ap_password"); + if (ap_pass) strncpy(g_config.ap_password, ap_pass->valuestring, sizeof(g_config.ap_password) - 1); + + cJSON *ap_ch = cJSON_GetObjectItem(root, "ap_channel"); + if (ap_ch) g_config.ap_channel = ap_ch->valueint; + + cJSON *mint = cJSON_GetObjectItem(root, "mint_url"); + if (mint) strncpy(g_config.mint_url, mint->valuestring, sizeof(g_config.mint_url) - 1); + + cJSON *lnurl = cJSON_GetObjectItem(root, "lnurl_url"); + if (lnurl) strncpy(g_config.lnurl_url, lnurl->valuestring, sizeof(g_config.lnurl_url) - 1); + + cJSON *price = cJSON_GetObjectItem(root, "price_per_step"); + if (price) g_config.price_per_step = price->valueint; + + cJSON *step = cJSON_GetObjectItem(root, "step_size_ms"); + if (step) g_config.step_size_ms = step->valueint; + + cJSON_Delete(root); + ESP_LOGI(TAG, "Config loaded: AP='%s', %d WiFi networks, price=%d sats/%dms", + g_config.ap_ssid, g_config.network_count, g_config.price_per_step, g_config.step_size_ms); + return ESP_OK; +} + +const tollgate_config_t *tollgate_config_get(void) +{ + return &g_config; +} + +esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config) +{ + if (g_config.network_count == 0) return ESP_ERR_NOT_FOUND; + int idx = g_config.current_network % g_config.network_count; + memset(wifi_config, 0, sizeof(wifi_config_t)); + strncpy((char *)wifi_config->sta.ssid, g_config.networks[idx].ssid, sizeof(wifi_config->sta.ssid) - 1); + strncpy((char *)wifi_config->sta.password, g_config.networks[idx].password, sizeof(wifi_config->sta.password) - 1); + wifi_config->sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + return ESP_OK; +} + +esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config) +{ + g_config.current_network = (g_config.current_network + 1) % g_config.network_count; + return tollgate_config_get_wifi(wifi_config); +} diff --git a/main/config.h b/main/config.h new file mode 100644 index 0000000..d26b7ae --- /dev/null +++ b/main/config.h @@ -0,0 +1,39 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include "esp_err.h" +#include "esp_wifi.h" + +#define TOLLGATE_MAX_WIFI_NETWORKS 5 +#define TOLLGATE_MAX_MINT_URLS 3 +#define TOLLGATE_MAX_AP_SSID_LEN 32 +#define TOLLGATE_MAX_AP_PASS_LEN 64 + +typedef struct { + char ssid[32]; + char password[64]; +} wifi_network_t; + +typedef struct { + wifi_network_t networks[TOLLGATE_MAX_WIFI_NETWORKS]; + int network_count; + int current_network; + int max_retry; + + char ap_ssid[TOLLGATE_MAX_AP_SSID_LEN]; + char ap_password[TOLLGATE_MAX_AP_PASS_LEN]; + uint8_t ap_channel; + uint8_t ap_max_conn; + + char mint_url[256]; + char lnurl_url[256]; + int price_per_step; + int step_size_ms; +} tollgate_config_t; + +esp_err_t tollgate_config_init(void); +const tollgate_config_t *tollgate_config_get(void); +esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config); +esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config); + +#endif diff --git a/main/dns_server.c b/main/dns_server.c new file mode 100644 index 0000000..f7977c6 --- /dev/null +++ b/main/dns_server.c @@ -0,0 +1,270 @@ +#include "dns_server.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/sockets.h" +#include "lwip/netdb.h" +#include +#include + +#define MAX_AUTH_IPS 10 +#define MAX_PENDING 50 +#define DNS_BUF_SIZE 512 +#define DNS_PORT 53 +#define DNS_TASK_STACK 4096 +#define DNS_TASK_PRIO 5 +#define DNS_FORWARD_TIMEOUT_MS 2000 +#define NXDOMAIN_TTL 30 + +static const char *TAG = "dns_server"; + +#pragma pack(push, 1) +typedef struct { + uint16_t id; + uint16_t flags; + uint16_t qdcount; + uint16_t ancount; + uint16_t nscount; + uint16_t arcount; +} dns_header_t; +#pragma pack(pop) + +#pragma pack(push, 1) +typedef struct { + uint16_t name; + uint16_t type; + uint16_t class; + uint32_t ttl; + uint16_t len; + uint32_t addr; +} dns_answer_t; +#pragma pack(pop) + +typedef struct { + uint32_t ip; +} auth_entry_t; + +static auth_entry_t s_auth_list[MAX_AUTH_IPS]; +static int s_auth_count = 0; +static TaskHandle_t s_dns_task = NULL; +static volatile bool s_dns_running = false; +static esp_ip4_addr_t s_ap_ip; +static esp_ip4_addr_t s_upstream_dns; + +static bool is_authenticated(uint32_t ip) +{ + for (int i = 0; i < s_auth_count; i++) { + if (s_auth_list[i].ip == ip) return true; + } + return false; +} + +static void parse_dns_name(const uint8_t *buf, int buf_len, int offset, char *out, int out_len) +{ + int pos = offset; + int out_pos = 0; + int jumped = 0; + int jump_pos = 0; + while (pos < buf_len && out_pos < out_len - 1) { + uint8_t len = buf[pos]; + if (len == 0) break; + if ((len & 0xC0) == 0xC0) { + if (!jumped) jump_pos = pos + 2; + pos = ((len & 0x3F) << 8) | buf[pos + 1]; + jumped = 1; + continue; + } + if (out_pos > 0 && out_pos < out_len - 1) out[out_pos++] = '.'; + pos++; + for (int i = 0; i < len && pos < buf_len && out_pos < out_len - 1; i++) { + out[out_pos++] = buf[pos++]; + } + } + out[out_pos] = '\0'; +} + +static int build_nxdomain(uint8_t *response, int req_len) +{ + memcpy(response, response, req_len); + dns_header_t *hdr = (dns_header_t *)response; + hdr->flags = htons(0x8403); + hdr->ancount = 0; + hdr->nscount = 0; + hdr->arcount = 0; + return req_len; +} + +static int build_redirect_response(uint8_t *response, int req_len) +{ + memcpy(response, response, req_len); + dns_header_t *hdr = (dns_header_t *)response; + hdr->flags = htons(0x8180); + hdr->ancount = htons(1); + hdr->nscount = 0; + hdr->arcount = 0; + int resp_len = req_len; + dns_answer_t ans; + ans.name = htons(0xC00C); + ans.type = htons(1); + ans.class = htons(1); + ans.ttl = htonl(NXDOMAIN_TTL); + ans.len = htons(4); + ans.addr = s_ap_ip.addr; + memcpy(response + resp_len, &ans, sizeof(ans)); + resp_len += sizeof(ans); + return resp_len; +} + +static int forward_dns(const uint8_t *req, int req_len, uint8_t *resp, int resp_buf_len, + const struct sockaddr_in *client_addr, uint16_t txn_id) +{ + int upstream_sock = socket(AF_INET, SOCK_DGRAM, 0); + if (upstream_sock < 0) return -1; + + struct timeval tv = { .tv_sec = DNS_FORWARD_TIMEOUT_MS / 1000, .tv_usec = (DNS_FORWARD_TIMEOUT_MS % 1000) * 1000 }; + setsockopt(upstream_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + struct sockaddr_in upstream_addr = { + .sin_family = AF_INET, + .sin_port = htons(DNS_PORT), + .sin_addr.s_addr = s_upstream_dns.addr, + }; + + sendto(upstream_sock, req, req_len, 0, (struct sockaddr *)&upstream_addr, sizeof(upstream_addr)); + + int n = recvfrom(upstream_sock, resp, resp_buf_len, 0, NULL, NULL); + close(upstream_sock); + + if (n > 0) { + if (n >= sizeof(dns_header_t)) { + dns_header_t *hdr = (dns_header_t *)resp; + hdr->id = htons(txn_id); + } + } + return n; +} + +static void dns_server_task(void *arg) +{ + int sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock < 0) { + ESP_LOGE(TAG, "Failed to create DNS socket"); + s_dns_running = false; + vTaskDelete(NULL); + return; + } + + struct sockaddr_in bind_addr = { + .sin_family = AF_INET, + .sin_port = htons(DNS_PORT), + .sin_addr.s_addr = INADDR_ANY, + }; + if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) { + ESP_LOGE(TAG, "Failed to bind DNS socket"); + close(sock); + s_dns_running = false; + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "DNS server started on port %d, AP IP=" IPSTR ", upstream DNS=" IPSTR, + DNS_PORT, IP2STR(&s_ap_ip), IP2STR(&s_upstream_dns)); + + uint8_t rx_buf[DNS_BUF_SIZE]; + uint8_t tx_buf[DNS_BUF_SIZE + sizeof(dns_answer_t)]; + + while (s_dns_running) { + struct sockaddr_in client_addr; + socklen_t client_len = sizeof(client_addr); + int n = recvfrom(sock, rx_buf, sizeof(rx_buf), 0, + (struct sockaddr *)&client_addr, &client_len); + if (n < (int)sizeof(dns_header_t)) continue; + + uint32_t client_ip = client_addr.sin_addr.s_addr; + dns_header_t *hdr = (dns_header_t *)rx_buf; + uint16_t txn_id = ntohs(hdr->id); + bool is_query = (ntohs(hdr->flags) & 0x8000) == 0; + uint16_t qdcount = ntohs(hdr->qdcount); + + if (!is_query || qdcount == 0) continue; + + int q_offset = sizeof(dns_header_t); + while (q_offset < n && rx_buf[q_offset] != 0) { + q_offset += rx_buf[q_offset] + 1; + } + if (q_offset + 5 > n) continue; + uint16_t qtype = (rx_buf[q_offset + 1] << 8) | rx_buf[q_offset + 2]; + int req_len = q_offset + 5; + + if (is_authenticated(client_ip)) { + int resp_len = forward_dns(rx_buf, req_len, tx_buf, sizeof(tx_buf), &client_addr, txn_id); + if (resp_len > 0) { + sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len); + } + } else { + if (qtype == 1) { + int resp_len = build_redirect_response(rx_buf, req_len); + memcpy(tx_buf, rx_buf, resp_len); + dns_header_t *resp_hdr = (dns_header_t *)tx_buf; + resp_hdr->id = htons(txn_id); + sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len); + } else if (qtype == 28) { + int resp_len = build_nxdomain(rx_buf, req_len); + memcpy(tx_buf, rx_buf, resp_len); + dns_header_t *resp_hdr = (dns_header_t *)tx_buf; + resp_hdr->id = htons(txn_id); + sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len); + } else { + int resp_len = forward_dns(rx_buf, req_len, tx_buf, sizeof(tx_buf), &client_addr, txn_id); + if (resp_len > 0) { + sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len); + } + } + } + } + + close(sock); + ESP_LOGI(TAG, "DNS server stopped"); + vTaskDelete(NULL); +} + +esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns) +{ + if (s_dns_running) return ESP_OK; + s_ap_ip = ap_ip; + s_upstream_dns = upstream_dns; + s_dns_running = true; + xTaskCreate(dns_server_task, "dns_server", DNS_TASK_STACK, NULL, DNS_TASK_PRIO, &s_dns_task); + return ESP_OK; +} + +void dns_server_stop(void) +{ + s_dns_running = false; + vTaskDelay(pdMS_TO_TICKS(200)); + s_dns_task = NULL; +} + +void dns_server_set_client_authenticated(uint32_t client_ip, bool authenticated) +{ + if (authenticated) { + if (is_authenticated(client_ip)) return; + if (s_auth_count < MAX_AUTH_IPS) { + s_auth_list[s_auth_count].ip = client_ip; + s_auth_count++; + } + } else { + for (int i = 0; i < s_auth_count; i++) { + if (s_auth_list[i].ip == client_ip) { + s_auth_list[i] = s_auth_list[s_auth_count - 1]; + s_auth_count--; + return; + } + } + } +} + +bool dns_server_is_running(void) +{ + return s_dns_running; +} diff --git a/main/dns_server.h b/main/dns_server.h new file mode 100644 index 0000000..9cae10b --- /dev/null +++ b/main/dns_server.h @@ -0,0 +1,13 @@ +#ifndef DNS_SERVER_H +#define DNS_SERVER_H + +#include "esp_err.h" +#include "esp_netif.h" +#include + +esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns); +void dns_server_stop(void); +void dns_server_set_client_authenticated(uint32_t client_ip, bool authenticated); +bool dns_server_is_running(void); + +#endif diff --git a/main/firewall.c b/main/firewall.c new file mode 100644 index 0000000..9ef3be0 --- /dev/null +++ b/main/firewall.c @@ -0,0 +1,96 @@ +#include "firewall.h" +#include "dns_server.h" +#include "esp_log.h" +#include "lwip/lwip_napt.h" +#include + +#define MAX_CLIENTS 10 + +static const char *TAG = "firewall"; +static esp_ip4_addr_t s_ap_ip; +static bool s_nat_enabled = false; + +typedef struct { + uint32_t ip; +} fw_client_t; + +static fw_client_t s_clients[MAX_CLIENTS]; +static int s_client_count = 0; + +esp_err_t firewall_init(esp_ip4_addr_t ap_ip) +{ + s_ap_ip = ap_ip; + memset(s_clients, 0, sizeof(s_clients)); + s_client_count = 0; + ESP_LOGI(TAG, "Firewall initialized with AP IP=" IPSTR, IP2STR(&s_ap_ip)); + return ESP_OK; +} + +void firewall_enable_nat(void) +{ + if (s_nat_enabled) return; + ip_napt_enable(s_ap_ip.addr, 1); + s_nat_enabled = true; + ESP_LOGI(TAG, "NAT enabled"); +} + +void firewall_disable_nat(void) +{ + if (!s_nat_enabled) return; + ip_napt_enable(s_ap_ip.addr, 0); + s_nat_enabled = false; + ESP_LOGI(TAG, "NAT disabled"); +} + +void firewall_grant_access(uint32_t client_ip) +{ + for (int i = 0; i < s_client_count; i++) { + if (s_clients[i].ip == client_ip) return; + } + if (s_client_count >= MAX_CLIENTS) { + ESP_LOGW(TAG, "Max clients reached, cannot grant access"); + return; + } + s_clients[s_client_count].ip = client_ip; + s_client_count++; + dns_server_set_client_authenticated(client_ip, true); + + esp_ip4_addr_t ip_addr = { .addr = client_ip }; + ESP_LOGI(TAG, "Access granted to " IPSTR, IP2STR(&ip_addr)); +} + +void firewall_revoke_access(uint32_t client_ip) +{ + for (int i = 0; i < s_client_count; i++) { + if (s_clients[i].ip == client_ip) { + s_clients[i] = s_clients[s_client_count - 1]; + s_client_count--; + dns_server_set_client_authenticated(client_ip, false); + esp_ip4_addr_t ip_addr = { .addr = client_ip }; + ESP_LOGI(TAG, "Access revoked for " IPSTR, IP2STR(&ip_addr)); + return; + } + } +} + +void firewall_revoke_all(void) +{ + for (int i = 0; i < s_client_count; i++) { + dns_server_set_client_authenticated(s_clients[i].ip, false); + } + s_client_count = 0; + ESP_LOGI(TAG, "All client access revoked"); +} + +bool firewall_is_client_allowed(uint32_t client_ip) +{ + for (int i = 0; i < s_client_count; i++) { + if (s_clients[i].ip == client_ip) return true; + } + return false; +} + +int firewall_client_count(void) +{ + return s_client_count; +} diff --git a/main/firewall.h b/main/firewall.h new file mode 100644 index 0000000..91a89b0 --- /dev/null +++ b/main/firewall.h @@ -0,0 +1,18 @@ +#ifndef FIREWALL_H +#define FIREWALL_H + +#include "esp_err.h" +#include "esp_netif.h" +#include +#include + +esp_err_t firewall_init(esp_ip4_addr_t ap_ip); +void firewall_enable_nat(void); +void firewall_disable_nat(void); +void firewall_grant_access(uint32_t client_ip); +void firewall_revoke_access(uint32_t client_ip); +void firewall_revoke_all(void); +bool firewall_is_client_allowed(uint32_t client_ip); +int firewall_client_count(void); + +#endif diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..fdbf0da --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,26 @@ +# WiFi +CONFIG_ESP_WIFI_ENABLED=y +CONFIG_ESP_WIFI_SOFTAP_SUPPORT=y + +# NAPT +CONFIG_LWIP_IP_FORWARD=y +CONFIG_LWIP_IPV4_NAPT=y + +# Disable IPv6 for simplicity +CONFIG_LWIP_IPV6=n + +# Increase main task stack for HTTP server + DNS +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# FreeRTOS +CONFIG_FREERTOS_HZ=1000 + +# Logging +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# HTTP server +CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 +CONFIG_HTTPD_MAX_URI_LEN=512 + +# mbedTLS (needed for HTTPS to mint) +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y -- cgit v1.2.3