upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.example12
-rw-r--r--.gitignore15
-rw-r--r--CHECKLIST.md57
-rw-r--r--CMakeLists.txt4
-rw-r--r--PLAN.md99
-rw-r--r--main/CMakeLists.txt9
-rw-r--r--main/config.c142
-rw-r--r--main/config.h39
-rw-r--r--main/dns_server.c270
-rw-r--r--main/dns_server.h13
-rw-r--r--main/firewall.c96
-rw-r--r--main/firewall.h18
-rw-r--r--sdkconfig.defaults26
13 files changed, 800 insertions, 0 deletions
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..497bf81
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,12 @@
1WIFI_SSID=YourUpstreamWiFi
2WIFI_PASSWORD=YourWiFiPassword
3AP_SSID=TollGate
4AP_PASSWORD=
5PORT_A=/dev/ttyACM0
6PORT_B=/dev/ttyACM1
7MINT_URL=https://nofee.testnut.cashu.space
8LNURL_URL=https://redeem.cashu.me/.well-known/lnurlp/tollgate
9TEST_MINT=nofee.testnut.cashu.space
10PRICE_PER_STEP=21
11STEP_UNIT=milliseconds
12STEP_SIZE=60000
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7663a94
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
1.env
2build/
3sdkconfig.old
4managed_components/
5dependencies.lock
6test-results/
7playwright-report/
8node_modules/
9*.pyc
10__pycache__/
11*.o
12*.bin
13*.elf
14*.map
15 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 @@
1# TollGate ESP32 — Progress Checklist
2
3## Phase 0: Bootstrap
4- [x] Create project directory and git repo
5- [x] Create .env, .env.example, .gitignore
6- [x] Persist PLAN.md and CHECKLIST.md
7- [ ] Create ESP-IDF project skeleton
8- [ ] Create Makefile with detect targets
9- [ ] Run `make detect-all` — identify ESP32 boards
10
11## Phase 1: Captive Portal + Firewall
12- [ ] Implement tollgate_main.c (WiFi AP+STA, event loop)
13- [ ] Implement config.c/h (JSON config loading)
14- [ ] Implement dns_server.c/h (DNS hijack/forward)
15- [ ] Implement captive_portal.c/h (HTTP :80, portal HTML)
16- [ ] Implement firewall.c/h (NAPT, per-IP auth)
17- [ ] Set up test infrastructure (Playwright, helpers)
18- [ ] Test 1: Boot and AP appears
19- [ ] Test 2: DHCP lease
20- [ ] Test 3: Captive portal serves HTML
21- [ ] Test 4: Captive detection URIs work
22- [ ] Test 5: DNS hijack before auth
23- [ ] Test 6: No internet before auth
24- [ ] Test 7: /whoami returns MAC
25- [ ] Test 8: /usage returns no session
26- [ ] Test 9: Grant access via API
27- [ ] Test 10: DNS forward after auth
28- [ ] Test 11: Internet after auth
29- [ ] Test 12: HTTP browsing works
30- [ ] Test 13: Reset auth
31- [ ] Test 14: Internet blocked after reset
32
33## Phase 2: E-Cash Payments (Simple Melt)
34- [ ] Implement payment.c/h (Cashu token parse + melt)
35- [ ] Implement session.c/h (time-based metering)
36- [ ] Implement tollgate_api.c/h (:2121 endpoints)
37- [ ] Update captive portal HTML with payment form
38- [ ] Test 15: Advertisement valid
39- [ ] Test 16: Valid payment
40- [ ] Test 17: Usage tracking
41- [ ] Test 18: Internet after payment
42- [ ] Test 19: Invalid token rejected
43- [ ] Test 20: Spent token rejected
44- [ ] Test 21: Wrong mint rejected
45- [ ] Test 22: Session expiry
46- [ ] Test 23: Session renewal
47- [ ] Test 24: Portal payment form
48- [ ] Test 25: Two clients pay independently
49- [ ] Test 26: Client isolation
50- [ ] Test 27: Full e2e browser flow
51
52## Phase 3: nucula Wallet + Reseller
53- [ ] Extract nucula wallet into components/cashu_wallet/
54- [ ] Replace simple melt with Wallet::receive()
55- [ ] Implement payout.c/h (background melt-to-LN)
56- [ ] Implement upstream_client.c/h (reseller mode)
57- [ ] 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 @@
1cmake_minimum_required(VERSION 3.16)
2set(EXTRA_COMPONENT_DIRS "components")
3include($ENV{IDF_PATH}/tools/cmake/project.cmake)
4project(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 @@
1# TollGate ESP32 — Test-Driven Development Plan
2
3## Overview
4
5Build 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.
6
7## Architecture Decision: C/C++ (ESP-IDF)
8
9- Existing working captive portal is in C (ESP-IDF)
10- Nucula Cashu wallet is in C/C++ (ESP-IDF)
11- ESP-IDF is already installed at `~/esp/esp-idf`
12- No Rust/ESP32 toolchain installed
13
14## Technology Stack
15
16| Layer | Technology |
17|-------|-----------|
18| Framework | ESP-IDF v5.4.1 (C/C++) |
19| Cashu wallet | nucula `Wallet` class (Phase 3) |
20| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API) |
21| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) |
22| NAT | lwIP NAPT |
23| Testing | Playwright + curl + pyserial |
24| Build | Makefile |
25
26## Three-Phase Plan
27
28### Phase 1: Captive Portal + Firewall (No Payments)
29
30**Goal:** WiFi repeater with captive portal that gates internet access. Validates DNS hijack, NAT, DHCP, firewall.
31
32**Endpoints:**
33- `GET /whoami` — returns client MAC
34- `GET /usage` — returns `-1/-1`
35- Captive portal HTML on port 80
36
37**14 Test Cases:**
38| # | Test | Method | Pass Criteria |
39|---|------|--------|---------------|
40| 1 | Boot and AP appears | Serial + nmcli | SSID visible in scan |
41| 2 | DHCP lease | nmcli connect | Gets IP in 192.168.4.0/24 |
42| 3 | Captive portal serves HTML | GET / | 200, contains "TollGate" |
43| 4 | Captive detection URIs work | GET /generate_204 etc. | All return portal HTML |
44| 5 | DNS hijack before auth | nslookup google.com | Resolves to 192.168.4.1 |
45| 6 | No internet before auth | ping 8.8.8.8 | Fails |
46| 7 | /whoami returns MAC | GET /whoami | Returns mac=XX:XX:... |
47| 8 | /usage returns no session | GET /usage | Returns -1/-1 |
48| 9 | Grant access via API | GET /grant_access | 200, status granted |
49| 10 | DNS forward after auth | nslookup google.com | Resolves to real IP |
50| 11 | Internet after auth | ping 8.8.8.8 | Succeeds |
51| 12 | HTTP browsing works | Playwright | Page loads |
52| 13 | Reset auth | GET /reset_authentication | 200 |
53| 14 | Internet blocked after reset | ping 8.8.8.8 | Fails |
54
55### Phase 2: E-Cash Payments (Simple Melt-to-LNURL)
56
57**Goal:** Replace free access with Cashu payment. ESP32 parses token, melts via mint API to operator's LNURL.
58
59**New Endpoints:**
60- `GET /` on :2121 — TollGate advertisement (kind=10021)
61- `POST /` on :2121 — Accept Cashu token, melt, return session (kind=1022) or notice (kind=21023)
62
63**13 Additional Test Cases:**
64| # | Test | Method | Pass Criteria |
65|---|------|--------|---------------|
66| 15 | Advertisement valid | GET :2121/ | kind=10021 with price_per_step |
67| 16 | Valid payment | POST :2121/ with token | kind=1022 session |
68| 17 | Usage tracking | GET :2121/usage | 0/allotment |
69| 18 | Internet after payment | ping | Succeeds |
70| 19 | Invalid token | POST :2121/ garbage | kind=21023 error |
71| 20 | Spent token | Reuse token | kind=21023 spent error |
72| 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error |
73| 22 | Session expiry | Wait for allotment | Internet blocked |
74| 23 | Session renewal | Second payment | Allotment extended |
75| 24 | Portal payment form | Playwright paste token | Checkmark shown |
76| 25 | Two clients pay independently | Two POSTs | Both authenticated |
77| 26 | Client isolation | Only payer gets internet | Non-payer blocked |
78| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow |
79
80### Phase 3: nucula Wallet Integration + Reseller
81
82**Goal:** Integrate nucula's full Cashu wallet. ESP32 holds balance, can be a reseller.
83
84**11 Additional Test Cases:**
85| # | Test | Method | Pass Criteria |
86|---|------|--------|---------------|
87| 28 | Wallet boot | Serial | Keysets loaded |
88| 29 | Receive via wallet | POST :2121/ | Balance incremented |
89| 30 | Balance persists | Reboot | Same balance |
90| 31 | Payout routine | Wait + serial | Tokens melted to LN |
91| 32 | Reseller discover | Serial | Upstream TollGate found |
92| 33 | Reseller pay | Serial + API | Token POSTed upstream |
93| 34 | Multi-hop internet | Ping from laptop | laptop→A→B→internet |
94| 35 | P2PK receive | Post P2PK token | Auto-signed, accepted |
95| 36 | DLEQ verified | Post token with DLEQ | Verified, accepted |
96| 37 | 5 consecutive payments | Loop | All authenticated |
97| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak |
98
99## 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 @@
1idf_component_register(SRCS "tollgate_main.c"
2 "config.c"
3 "dns_server.c"
4 "captive_portal.c"
5 "firewall.c"
6 INCLUDE_DIRS "."
7 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
8 lwip json esp_http_client esp_tls log
9 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 @@
1#include "config.h"
2#include "esp_log.h"
3#include "esp_spiffs.h"
4#include "cJSON.h"
5#include <string.h>
6
7static const char *TAG = "tollgate_config";
8static tollgate_config_t g_config;
9
10esp_err_t tollgate_config_init(void)
11{
12 memset(&g_config, 0, sizeof(g_config));
13 g_config.max_retry = 5;
14 g_config.ap_channel = 1;
15 g_config.ap_max_conn = 4;
16 g_config.price_per_step = 21;
17 g_config.step_size_ms = 60000;
18
19 esp_vfs_spiffs_conf_t conf = {
20 .base_path = "/spiffs",
21 .partition_label = NULL,
22 .max_files = 5,
23 .format_if_mount_failed = true,
24 };
25 esp_err_t ret = esp_vfs_spiffs_register(&conf);
26 if (ret != ESP_OK) {
27 ESP_LOGE(TAG, "Failed to mount SPIFFS: %s", esp_err_to_name(ret));
28 return ret;
29 }
30
31 FILE *f = fopen("/spiffs/config.json", "r");
32 if (!f) {
33 ESP_LOGW(TAG, "No config.json found, generating default");
34 const char *default_json = "{"
35 "\"wifi_networks\":["
36 "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}"
37 "],"
38 "\"ap_ssid\":\"TollGate\","
39 "\"ap_password\":\"\","
40 "\"ap_channel\":1,"
41 "\"mint_url\":\"https://nofee.testnut.cashu.space\","
42 "\"lnurl_url\":\"https://redeem.cashu.me/.well-known/lnurlp/tollgate\","
43 "\"price_per_step\":21,"
44 "\"step_size_ms\":60000"
45 "}";
46 f = fopen("/spiffs/config.json", "w");
47 if (f) {
48 fputs(default_json, f);
49 fclose(f);
50 }
51 f = fopen("/spiffs/config.json", "r");
52 }
53
54 if (!f) {
55 ESP_LOGE(TAG, "Failed to open config.json");
56 return ESP_FAIL;
57 }
58
59 fseek(f, 0, SEEK_END);
60 long fsize = ftell(f);
61 fseek(f, 0, SEEK_SET);
62 char *buf = malloc(fsize + 1);
63 if (!buf) {
64 fclose(f);
65 return ESP_ERR_NO_MEM;
66 }
67 fread(buf, 1, fsize, f);
68 buf[fsize] = '\0';
69 fclose(f);
70
71 cJSON *root = cJSON_Parse(buf);
72 free(buf);
73 if (!root) {
74 ESP_LOGE(TAG, "Failed to parse config.json");
75 return ESP_FAIL;
76 }
77
78 cJSON *networks = cJSON_GetObjectItem(root, "wifi_networks");
79 if (networks && cJSON_IsArray(networks)) {
80 int count = cJSON_GetArraySize(networks);
81 if (count > TOLLGATE_MAX_WIFI_NETWORKS) count = TOLLGATE_MAX_WIFI_NETWORKS;
82 for (int i = 0; i < count; i++) {
83 cJSON *net = cJSON_GetArrayItem(networks, i);
84 cJSON *ssid = cJSON_GetObjectItem(net, "ssid");
85 cJSON *pass = cJSON_GetObjectItem(net, "password");
86 if (ssid && pass) {
87 strncpy(g_config.networks[i].ssid, ssid->valuestring, sizeof(g_config.networks[i].ssid) - 1);
88 strncpy(g_config.networks[i].password, pass->valuestring, sizeof(g_config.networks[i].password) - 1);
89 g_config.network_count++;
90 }
91 }
92 }
93
94 cJSON *ap_ssid = cJSON_GetObjectItem(root, "ap_ssid");
95 if (ap_ssid) strncpy(g_config.ap_ssid, ap_ssid->valuestring, sizeof(g_config.ap_ssid) - 1);
96 else strncpy(g_config.ap_ssid, "TollGate", sizeof(g_config.ap_ssid) - 1);
97
98 cJSON *ap_pass = cJSON_GetObjectItem(root, "ap_password");
99 if (ap_pass) strncpy(g_config.ap_password, ap_pass->valuestring, sizeof(g_config.ap_password) - 1);
100
101 cJSON *ap_ch = cJSON_GetObjectItem(root, "ap_channel");
102 if (ap_ch) g_config.ap_channel = ap_ch->valueint;
103
104 cJSON *mint = cJSON_GetObjectItem(root, "mint_url");
105 if (mint) strncpy(g_config.mint_url, mint->valuestring, sizeof(g_config.mint_url) - 1);
106
107 cJSON *lnurl = cJSON_GetObjectItem(root, "lnurl_url");
108 if (lnurl) strncpy(g_config.lnurl_url, lnurl->valuestring, sizeof(g_config.lnurl_url) - 1);
109
110 cJSON *price = cJSON_GetObjectItem(root, "price_per_step");
111 if (price) g_config.price_per_step = price->valueint;
112
113 cJSON *step = cJSON_GetObjectItem(root, "step_size_ms");
114 if (step) g_config.step_size_ms = step->valueint;
115
116 cJSON_Delete(root);
117 ESP_LOGI(TAG, "Config loaded: AP='%s', %d WiFi networks, price=%d sats/%dms",
118 g_config.ap_ssid, g_config.network_count, g_config.price_per_step, g_config.step_size_ms);
119 return ESP_OK;
120}
121
122const tollgate_config_t *tollgate_config_get(void)
123{
124 return &g_config;
125}
126
127esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config)
128{
129 if (g_config.network_count == 0) return ESP_ERR_NOT_FOUND;
130 int idx = g_config.current_network % g_config.network_count;
131 memset(wifi_config, 0, sizeof(wifi_config_t));
132 strncpy((char *)wifi_config->sta.ssid, g_config.networks[idx].ssid, sizeof(wifi_config->sta.ssid) - 1);
133 strncpy((char *)wifi_config->sta.password, g_config.networks[idx].password, sizeof(wifi_config->sta.password) - 1);
134 wifi_config->sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
135 return ESP_OK;
136}
137
138esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config)
139{
140 g_config.current_network = (g_config.current_network + 1) % g_config.network_count;
141 return tollgate_config_get_wifi(wifi_config);
142}
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 @@
1#ifndef CONFIG_H
2#define CONFIG_H
3
4#include "esp_err.h"
5#include "esp_wifi.h"
6
7#define TOLLGATE_MAX_WIFI_NETWORKS 5
8#define TOLLGATE_MAX_MINT_URLS 3
9#define TOLLGATE_MAX_AP_SSID_LEN 32
10#define TOLLGATE_MAX_AP_PASS_LEN 64
11
12typedef struct {
13 char ssid[32];
14 char password[64];
15} wifi_network_t;
16
17typedef struct {
18 wifi_network_t networks[TOLLGATE_MAX_WIFI_NETWORKS];
19 int network_count;
20 int current_network;
21 int max_retry;
22
23 char ap_ssid[TOLLGATE_MAX_AP_SSID_LEN];
24 char ap_password[TOLLGATE_MAX_AP_PASS_LEN];
25 uint8_t ap_channel;
26 uint8_t ap_max_conn;
27
28 char mint_url[256];
29 char lnurl_url[256];
30 int price_per_step;
31 int step_size_ms;
32} tollgate_config_t;
33
34esp_err_t tollgate_config_init(void);
35const tollgate_config_t *tollgate_config_get(void);
36esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config);
37esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config);
38
39#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 @@
1#include "dns_server.h"
2#include "esp_log.h"
3#include "freertos/FreeRTOS.h"
4#include "freertos/task.h"
5#include "lwip/sockets.h"
6#include "lwip/netdb.h"
7#include <string.h>
8#include <sys/param.h>
9
10#define MAX_AUTH_IPS 10
11#define MAX_PENDING 50
12#define DNS_BUF_SIZE 512
13#define DNS_PORT 53
14#define DNS_TASK_STACK 4096
15#define DNS_TASK_PRIO 5
16#define DNS_FORWARD_TIMEOUT_MS 2000
17#define NXDOMAIN_TTL 30
18
19static const char *TAG = "dns_server";
20
21#pragma pack(push, 1)
22typedef struct {
23 uint16_t id;
24 uint16_t flags;
25 uint16_t qdcount;
26 uint16_t ancount;
27 uint16_t nscount;
28 uint16_t arcount;
29} dns_header_t;
30#pragma pack(pop)
31
32#pragma pack(push, 1)
33typedef struct {
34 uint16_t name;
35 uint16_t type;
36 uint16_t class;
37 uint32_t ttl;
38 uint16_t len;
39 uint32_t addr;
40} dns_answer_t;
41#pragma pack(pop)
42
43typedef struct {
44 uint32_t ip;
45} auth_entry_t;
46
47static auth_entry_t s_auth_list[MAX_AUTH_IPS];
48static int s_auth_count = 0;
49static TaskHandle_t s_dns_task = NULL;
50static volatile bool s_dns_running = false;
51static esp_ip4_addr_t s_ap_ip;
52static esp_ip4_addr_t s_upstream_dns;
53
54static bool is_authenticated(uint32_t ip)
55{
56 for (int i = 0; i < s_auth_count; i++) {
57 if (s_auth_list[i].ip == ip) return true;
58 }
59 return false;
60}
61
62static void parse_dns_name(const uint8_t *buf, int buf_len, int offset, char *out, int out_len)
63{
64 int pos = offset;
65 int out_pos = 0;
66 int jumped = 0;
67 int jump_pos = 0;
68 while (pos < buf_len && out_pos < out_len - 1) {
69 uint8_t len = buf[pos];
70 if (len == 0) break;
71 if ((len & 0xC0) == 0xC0) {
72 if (!jumped) jump_pos = pos + 2;
73 pos = ((len & 0x3F) << 8) | buf[pos + 1];
74 jumped = 1;
75 continue;
76 }
77 if (out_pos > 0 && out_pos < out_len - 1) out[out_pos++] = '.';
78 pos++;
79 for (int i = 0; i < len && pos < buf_len && out_pos < out_len - 1; i++) {
80 out[out_pos++] = buf[pos++];
81 }
82 }
83 out[out_pos] = '\0';
84}
85
86static int build_nxdomain(uint8_t *response, int req_len)
87{
88 memcpy(response, response, req_len);
89 dns_header_t *hdr = (dns_header_t *)response;
90 hdr->flags = htons(0x8403);
91 hdr->ancount = 0;
92 hdr->nscount = 0;
93 hdr->arcount = 0;
94 return req_len;
95}
96
97static int build_redirect_response(uint8_t *response, int req_len)
98{
99 memcpy(response, response, req_len);
100 dns_header_t *hdr = (dns_header_t *)response;
101 hdr->flags = htons(0x8180);
102 hdr->ancount = htons(1);
103 hdr->nscount = 0;
104 hdr->arcount = 0;
105 int resp_len = req_len;
106 dns_answer_t ans;
107 ans.name = htons(0xC00C);
108 ans.type = htons(1);
109 ans.class = htons(1);
110 ans.ttl = htonl(NXDOMAIN_TTL);
111 ans.len = htons(4);
112 ans.addr = s_ap_ip.addr;
113 memcpy(response + resp_len, &ans, sizeof(ans));
114 resp_len += sizeof(ans);
115 return resp_len;
116}
117
118static int forward_dns(const uint8_t *req, int req_len, uint8_t *resp, int resp_buf_len,
119 const struct sockaddr_in *client_addr, uint16_t txn_id)
120{
121 int upstream_sock = socket(AF_INET, SOCK_DGRAM, 0);
122 if (upstream_sock < 0) return -1;
123
124 struct timeval tv = { .tv_sec = DNS_FORWARD_TIMEOUT_MS / 1000, .tv_usec = (DNS_FORWARD_TIMEOUT_MS % 1000) * 1000 };
125 setsockopt(upstream_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
126
127 struct sockaddr_in upstream_addr = {
128 .sin_family = AF_INET,
129 .sin_port = htons(DNS_PORT),
130 .sin_addr.s_addr = s_upstream_dns.addr,
131 };
132
133 sendto(upstream_sock, req, req_len, 0, (struct sockaddr *)&upstream_addr, sizeof(upstream_addr));
134
135 int n = recvfrom(upstream_sock, resp, resp_buf_len, 0, NULL, NULL);
136 close(upstream_sock);
137
138 if (n > 0) {
139 if (n >= sizeof(dns_header_t)) {
140 dns_header_t *hdr = (dns_header_t *)resp;
141 hdr->id = htons(txn_id);
142 }
143 }
144 return n;
145}
146
147static void dns_server_task(void *arg)
148{
149 int sock = socket(AF_INET, SOCK_DGRAM, 0);
150 if (sock < 0) {
151 ESP_LOGE(TAG, "Failed to create DNS socket");
152 s_dns_running = false;
153 vTaskDelete(NULL);
154 return;
155 }
156
157 struct sockaddr_in bind_addr = {
158 .sin_family = AF_INET,
159 .sin_port = htons(DNS_PORT),
160 .sin_addr.s_addr = INADDR_ANY,
161 };
162 if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
163 ESP_LOGE(TAG, "Failed to bind DNS socket");
164 close(sock);
165 s_dns_running = false;
166 vTaskDelete(NULL);
167 return;
168 }
169
170 ESP_LOGI(TAG, "DNS server started on port %d, AP IP=" IPSTR ", upstream DNS=" IPSTR,
171 DNS_PORT, IP2STR(&s_ap_ip), IP2STR(&s_upstream_dns));
172
173 uint8_t rx_buf[DNS_BUF_SIZE];
174 uint8_t tx_buf[DNS_BUF_SIZE + sizeof(dns_answer_t)];
175
176 while (s_dns_running) {
177 struct sockaddr_in client_addr;
178 socklen_t client_len = sizeof(client_addr);
179 int n = recvfrom(sock, rx_buf, sizeof(rx_buf), 0,
180 (struct sockaddr *)&client_addr, &client_len);
181 if (n < (int)sizeof(dns_header_t)) continue;
182
183 uint32_t client_ip = client_addr.sin_addr.s_addr;
184 dns_header_t *hdr = (dns_header_t *)rx_buf;
185 uint16_t txn_id = ntohs(hdr->id);
186 bool is_query = (ntohs(hdr->flags) & 0x8000) == 0;
187 uint16_t qdcount = ntohs(hdr->qdcount);
188
189 if (!is_query || qdcount == 0) continue;
190
191 int q_offset = sizeof(dns_header_t);
192 while (q_offset < n && rx_buf[q_offset] != 0) {
193 q_offset += rx_buf[q_offset] + 1;
194 }
195 if (q_offset + 5 > n) continue;
196 uint16_t qtype = (rx_buf[q_offset + 1] << 8) | rx_buf[q_offset + 2];
197 int req_len = q_offset + 5;
198
199 if (is_authenticated(client_ip)) {
200 int resp_len = forward_dns(rx_buf, req_len, tx_buf, sizeof(tx_buf), &client_addr, txn_id);
201 if (resp_len > 0) {
202 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
203 }
204 } else {
205 if (qtype == 1) {
206 int resp_len = build_redirect_response(rx_buf, req_len);
207 memcpy(tx_buf, rx_buf, resp_len);
208 dns_header_t *resp_hdr = (dns_header_t *)tx_buf;
209 resp_hdr->id = htons(txn_id);
210 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
211 } else if (qtype == 28) {
212 int resp_len = build_nxdomain(rx_buf, req_len);
213 memcpy(tx_buf, rx_buf, resp_len);
214 dns_header_t *resp_hdr = (dns_header_t *)tx_buf;
215 resp_hdr->id = htons(txn_id);
216 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
217 } else {
218 int resp_len = forward_dns(rx_buf, req_len, tx_buf, sizeof(tx_buf), &client_addr, txn_id);
219 if (resp_len > 0) {
220 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
221 }
222 }
223 }
224 }
225
226 close(sock);
227 ESP_LOGI(TAG, "DNS server stopped");
228 vTaskDelete(NULL);
229}
230
231esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns)
232{
233 if (s_dns_running) return ESP_OK;
234 s_ap_ip = ap_ip;
235 s_upstream_dns = upstream_dns;
236 s_dns_running = true;
237 xTaskCreate(dns_server_task, "dns_server", DNS_TASK_STACK, NULL, DNS_TASK_PRIO, &s_dns_task);
238 return ESP_OK;
239}
240
241void dns_server_stop(void)
242{
243 s_dns_running = false;
244 vTaskDelay(pdMS_TO_TICKS(200));
245 s_dns_task = NULL;
246}
247
248void dns_server_set_client_authenticated(uint32_t client_ip, bool authenticated)
249{
250 if (authenticated) {
251 if (is_authenticated(client_ip)) return;
252 if (s_auth_count < MAX_AUTH_IPS) {
253 s_auth_list[s_auth_count].ip = client_ip;
254 s_auth_count++;
255 }
256 } else {
257 for (int i = 0; i < s_auth_count; i++) {
258 if (s_auth_list[i].ip == client_ip) {
259 s_auth_list[i] = s_auth_list[s_auth_count - 1];
260 s_auth_count--;
261 return;
262 }
263 }
264 }
265}
266
267bool dns_server_is_running(void)
268{
269 return s_dns_running;
270}
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 @@
1#ifndef DNS_SERVER_H
2#define DNS_SERVER_H
3
4#include "esp_err.h"
5#include "esp_netif.h"
6#include <stdbool.h>
7
8esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns);
9void dns_server_stop(void);
10void dns_server_set_client_authenticated(uint32_t client_ip, bool authenticated);
11bool dns_server_is_running(void);
12
13#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 @@
1#include "firewall.h"
2#include "dns_server.h"
3#include "esp_log.h"
4#include "lwip/lwip_napt.h"
5#include <string.h>
6
7#define MAX_CLIENTS 10
8
9static const char *TAG = "firewall";
10static esp_ip4_addr_t s_ap_ip;
11static bool s_nat_enabled = false;
12
13typedef struct {
14 uint32_t ip;
15} fw_client_t;
16
17static fw_client_t s_clients[MAX_CLIENTS];
18static int s_client_count = 0;
19
20esp_err_t firewall_init(esp_ip4_addr_t ap_ip)
21{
22 s_ap_ip = ap_ip;
23 memset(s_clients, 0, sizeof(s_clients));
24 s_client_count = 0;
25 ESP_LOGI(TAG, "Firewall initialized with AP IP=" IPSTR, IP2STR(&s_ap_ip));
26 return ESP_OK;
27}
28
29void firewall_enable_nat(void)
30{
31 if (s_nat_enabled) return;
32 ip_napt_enable(s_ap_ip.addr, 1);
33 s_nat_enabled = true;
34 ESP_LOGI(TAG, "NAT enabled");
35}
36
37void firewall_disable_nat(void)
38{
39 if (!s_nat_enabled) return;
40 ip_napt_enable(s_ap_ip.addr, 0);
41 s_nat_enabled = false;
42 ESP_LOGI(TAG, "NAT disabled");
43}
44
45void firewall_grant_access(uint32_t client_ip)
46{
47 for (int i = 0; i < s_client_count; i++) {
48 if (s_clients[i].ip == client_ip) return;
49 }
50 if (s_client_count >= MAX_CLIENTS) {
51 ESP_LOGW(TAG, "Max clients reached, cannot grant access");
52 return;
53 }
54 s_clients[s_client_count].ip = client_ip;
55 s_client_count++;
56 dns_server_set_client_authenticated(client_ip, true);
57
58 esp_ip4_addr_t ip_addr = { .addr = client_ip };
59 ESP_LOGI(TAG, "Access granted to " IPSTR, IP2STR(&ip_addr));
60}
61
62void firewall_revoke_access(uint32_t client_ip)
63{
64 for (int i = 0; i < s_client_count; i++) {
65 if (s_clients[i].ip == client_ip) {
66 s_clients[i] = s_clients[s_client_count - 1];
67 s_client_count--;
68 dns_server_set_client_authenticated(client_ip, false);
69 esp_ip4_addr_t ip_addr = { .addr = client_ip };
70 ESP_LOGI(TAG, "Access revoked for " IPSTR, IP2STR(&ip_addr));
71 return;
72 }
73 }
74}
75
76void firewall_revoke_all(void)
77{
78 for (int i = 0; i < s_client_count; i++) {
79 dns_server_set_client_authenticated(s_clients[i].ip, false);
80 }
81 s_client_count = 0;
82 ESP_LOGI(TAG, "All client access revoked");
83}
84
85bool firewall_is_client_allowed(uint32_t client_ip)
86{
87 for (int i = 0; i < s_client_count; i++) {
88 if (s_clients[i].ip == client_ip) return true;
89 }
90 return false;
91}
92
93int firewall_client_count(void)
94{
95 return s_client_count;
96}
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 @@
1#ifndef FIREWALL_H
2#define FIREWALL_H
3
4#include "esp_err.h"
5#include "esp_netif.h"
6#include <stdbool.h>
7#include <stdint.h>
8
9esp_err_t firewall_init(esp_ip4_addr_t ap_ip);
10void firewall_enable_nat(void);
11void firewall_disable_nat(void);
12void firewall_grant_access(uint32_t client_ip);
13void firewall_revoke_access(uint32_t client_ip);
14void firewall_revoke_all(void);
15bool firewall_is_client_allowed(uint32_t client_ip);
16int firewall_client_count(void);
17
18#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 @@
1# WiFi
2CONFIG_ESP_WIFI_ENABLED=y
3CONFIG_ESP_WIFI_SOFTAP_SUPPORT=y
4
5# NAPT
6CONFIG_LWIP_IP_FORWARD=y
7CONFIG_LWIP_IPV4_NAPT=y
8
9# Disable IPv6 for simplicity
10CONFIG_LWIP_IPV6=n
11
12# Increase main task stack for HTTP server + DNS
13CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
14
15# FreeRTOS
16CONFIG_FREERTOS_HZ=1000
17
18# Logging
19CONFIG_LOG_DEFAULT_LEVEL_INFO=y
20
21# HTTP server
22CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
23CONFIG_HTTPD_MAX_URI_LEN=512
24
25# mbedTLS (needed for HTTPS to mint)
26CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y