diff options
| author | Your Name <you@example.com> | 2026-05-15 17:03:40 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-15 17:03:40 +0530 |
| commit | a7d0a672d59bf8985a6fc0e61b49015fabd96513 (patch) | |
| tree | 46814d1757649a640f53805a8d9dfc1b0f354289 /main/captive_portal.c | |
| parent | 8a2307a5ced6da94cc674602219d5a68a1246264 (diff) | |
Phase 1 working: captive portal, DNS hijack, NAT-based access control
- Fix WiFi init order: netif creation before esp_wifi_init, set mode before set_config
- Replace broken netif input filter with NAPT on/off per authentication state
- NAPT disabled by default, enabled when client granted, disabled on revoke
- Fix test helpers: use -I wlp59s0 for ping, handle nslookup exit code 1
- All 20 API tests pass, all 6 smoke tests pass
Diffstat (limited to 'main/captive_portal.c')
| -rw-r--r-- | main/captive_portal.c | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/main/captive_portal.c b/main/captive_portal.c new file mode 100644 index 0000000..acff9c2 --- /dev/null +++ b/main/captive_portal.c | |||
| @@ -0,0 +1,224 @@ | |||
| 1 | #include "captive_portal.h" | ||
| 2 | #include "firewall.h" | ||
| 3 | #include "config.h" | ||
| 4 | #include "esp_log.h" | ||
| 5 | #include "esp_wifi.h" | ||
| 6 | #include "cJSON.h" | ||
| 7 | #include "lwip/sockets.h" | ||
| 8 | #include "lwip/netdb.h" | ||
| 9 | #include <string.h> | ||
| 10 | #include <sys/param.h> | ||
| 11 | |||
| 12 | static const char *TAG = "captive_portal"; | ||
| 13 | static httpd_handle_t s_server = NULL; | ||
| 14 | |||
| 15 | static const char PORTAL_HTML[] = \ | ||
| 16 | "<!DOCTYPE html>" | ||
| 17 | "<html><head>" | ||
| 18 | "<meta charset='utf-8'>" | ||
| 19 | "<meta name='viewport' content='width=device-width, initial-scale=1'>" | ||
| 20 | "<title>TollGate</title>" | ||
| 21 | "<style>" | ||
| 22 | "*{box-sizing:border-box;margin:0;padding:0}" | ||
| 23 | "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" | ||
| 24 | "background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;" | ||
| 25 | "min-height:100vh;padding:20px}" | ||
| 26 | ".card{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:32px;" | ||
| 27 | "max-width:400px;width:100%;text-align:center}" | ||
| 28 | "h1{font-size:28px;margin-bottom:8px;color:#f7931a}" | ||
| 29 | ".subtitle{color:#888;margin-bottom:24px;font-size:14px}" | ||
| 30 | ".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:24px}" | ||
| 31 | ".price-amount{font-size:36px;font-weight:bold;color:#f7931a}" | ||
| 32 | ".price-unit{color:#888;font-size:14px}" | ||
| 33 | "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" | ||
| 34 | "#status.success{display:block;background:#1a472a;color:#4caf50}" | ||
| 35 | "#status.error{display:block;background:#471a1a;color:#f44336}" | ||
| 36 | "#status.processing{display:block;background:#1a3a47;color:#2196f3}" | ||
| 37 | ".btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px;" | ||
| 38 | "font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}" | ||
| 39 | ".btn:hover{background:#e8850f}" | ||
| 40 | ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" | ||
| 41 | "</style>" | ||
| 42 | "</head><body>" | ||
| 43 | "<div class='card'>" | ||
| 44 | "<h1>TollGate</h1>" | ||
| 45 | "<p class='subtitle'>Pay for internet access with ecash</p>" | ||
| 46 | "<div class='price'>" | ||
| 47 | "<div class='price-amount' id='price'>Loading...</div>" | ||
| 48 | "<div class='price-unit'>sats per minute</div>" | ||
| 49 | "</div>" | ||
| 50 | "<button class='btn' id='grantBtn' onclick='grantAccess()'>Grant Free Access</button>" | ||
| 51 | "<div id='status'></div>" | ||
| 52 | "</div>" | ||
| 53 | "<script>" | ||
| 54 | "const priceEl=document.getElementById('price');" | ||
| 55 | "const statusEl=document.getElementById('status');" | ||
| 56 | "const grantBtn=document.getElementById('grantBtn');" | ||
| 57 | "fetch('/api/status').then(r=>r.json()).then(d=>{priceEl.textContent=d.price||'21';}).catch(()=>{priceEl.textContent='21';});" | ||
| 58 | "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" | ||
| 59 | "function grantAccess(){" | ||
| 60 | " grantBtn.disabled=true;" | ||
| 61 | " showStatus('Connecting...','processing');" | ||
| 62 | " fetch('/grant_access').then(r=>r.json()).then(d=>{" | ||
| 63 | " if(d.status==='granted'){" | ||
| 64 | " showStatus('Connected! You have internet access.','success');" | ||
| 65 | " grantBtn.textContent='Connected!';" | ||
| 66 | " setTimeout(()=>{window.location.href='http://detectportal.firefox.com/success.txt';},2000);" | ||
| 67 | " }else{showStatus('Error: '+d.message,'error');grantBtn.disabled=false;}" | ||
| 68 | " }).catch(e=>{showStatus('Connection error','error');grantBtn.disabled=false;});" | ||
| 69 | "}" | ||
| 70 | "</script>" | ||
| 71 | "</body></html>"; | ||
| 72 | |||
| 73 | static esp_err_t get_client_ip(httpd_req_t *req, uint32_t *ip_out) | ||
| 74 | { | ||
| 75 | int sockfd = httpd_req_to_sockfd(req); | ||
| 76 | struct sockaddr_in addr; | ||
| 77 | socklen_t addr_len = sizeof(addr); | ||
| 78 | if (getpeername(sockfd, (struct sockaddr *)&addr, &addr_len) == 0) { | ||
| 79 | *ip_out = addr.sin_addr.s_addr; | ||
| 80 | return ESP_OK; | ||
| 81 | } | ||
| 82 | return ESP_FAIL; | ||
| 83 | } | ||
| 84 | |||
| 85 | static bool is_captive_detection_uri(const char *uri) | ||
| 86 | { | ||
| 87 | return strcmp(uri, "/generate_204") == 0 || | ||
| 88 | strcmp(uri, "/hotspot-detect.html") == 0 || | ||
| 89 | strcmp(uri, "/canonical.html") == 0 || | ||
| 90 | strcmp(uri, "/success.txt") == 0 || | ||
| 91 | strcmp(uri, "/ncsi.txt") == 0 || | ||
| 92 | strcmp(uri, "/connecttest.txt") == 0 || | ||
| 93 | strcmp(uri, "/wpad.dat") == 0 || | ||
| 94 | strcmp(uri, "/redirect") == 0; | ||
| 95 | } | ||
| 96 | |||
| 97 | static esp_err_t portal_handler(httpd_req_t *req) | ||
| 98 | { | ||
| 99 | httpd_resp_set_type(req, "text/html"); | ||
| 100 | httpd_resp_send(req, PORTAL_HTML, strlen(PORTAL_HTML)); | ||
| 101 | return ESP_OK; | ||
| 102 | } | ||
| 103 | |||
| 104 | static esp_err_t grant_access_handler(httpd_req_t *req) | ||
| 105 | { | ||
| 106 | uint32_t client_ip; | ||
| 107 | if (get_client_ip(req, &client_ip) == ESP_OK) { | ||
| 108 | firewall_grant_access(client_ip); | ||
| 109 | } | ||
| 110 | const char *resp = "{\"status\":\"granted\"}"; | ||
| 111 | httpd_resp_set_type(req, "application/json"); | ||
| 112 | httpd_resp_send(req, resp, strlen(resp)); | ||
| 113 | return ESP_OK; | ||
| 114 | } | ||
| 115 | |||
| 116 | static esp_err_t status_handler(httpd_req_t *req) | ||
| 117 | { | ||
| 118 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 119 | cJSON *root = cJSON_CreateObject(); | ||
| 120 | cJSON_AddBoolToObject(root, "connected", true); | ||
| 121 | cJSON_AddNumberToObject(root, "price", cfg->price_per_step); | ||
| 122 | char *json = cJSON_PrintUnformatted(root); | ||
| 123 | httpd_resp_set_type(req, "application/json"); | ||
| 124 | httpd_resp_send(req, json, strlen(json)); | ||
| 125 | cJSON_free(json); | ||
| 126 | cJSON_Delete(root); | ||
| 127 | return ESP_OK; | ||
| 128 | } | ||
| 129 | |||
| 130 | static esp_err_t whoami_handler(httpd_req_t *req) | ||
| 131 | { | ||
| 132 | uint32_t client_ip; | ||
| 133 | char resp[64]; | ||
| 134 | if (get_client_ip(req, &client_ip) == ESP_OK) { | ||
| 135 | esp_ip4_addr_t ip = { .addr = client_ip }; | ||
| 136 | snprintf(resp, sizeof(resp), "mac=" IPSTR, IP2STR(&ip)); | ||
| 137 | } else { | ||
| 138 | snprintf(resp, sizeof(resp), "mac=unknown"); | ||
| 139 | } | ||
| 140 | httpd_resp_set_type(req, "text/plain"); | ||
| 141 | httpd_resp_send(req, resp, strlen(resp)); | ||
| 142 | return ESP_OK; | ||
| 143 | } | ||
| 144 | |||
| 145 | static esp_err_t usage_handler(httpd_req_t *req) | ||
| 146 | { | ||
| 147 | uint32_t client_ip; | ||
| 148 | char resp[32]; | ||
| 149 | if (get_client_ip(req, &client_ip) == ESP_OK && firewall_is_client_allowed(client_ip)) { | ||
| 150 | snprintf(resp, sizeof(resp), "0/0"); | ||
| 151 | } else { | ||
| 152 | snprintf(resp, sizeof(resp), "-1/-1"); | ||
| 153 | } | ||
| 154 | httpd_resp_set_type(req, "text/plain"); | ||
| 155 | httpd_resp_send(req, resp, strlen(resp)); | ||
| 156 | return ESP_OK; | ||
| 157 | } | ||
| 158 | |||
| 159 | static esp_err_t reset_auth_handler(httpd_req_t *req) | ||
| 160 | { | ||
| 161 | firewall_revoke_all(); | ||
| 162 | const char *resp = "{\"status\":\"reset\"}"; | ||
| 163 | httpd_resp_set_type(req, "application/json"); | ||
| 164 | httpd_resp_send(req, resp, strlen(resp)); | ||
| 165 | return ESP_OK; | ||
| 166 | } | ||
| 167 | |||
| 168 | static esp_err_t catchall_handler(httpd_req_t *req) | ||
| 169 | { | ||
| 170 | if (is_captive_detection_uri(req->uri)) { | ||
| 171 | return portal_handler(req); | ||
| 172 | } | ||
| 173 | httpd_resp_set_status(req, "302 Found"); | ||
| 174 | httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); | ||
| 175 | httpd_resp_send(req, NULL, 0); | ||
| 176 | return ESP_OK; | ||
| 177 | } | ||
| 178 | |||
| 179 | static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; | ||
| 180 | static const httpd_uri_t uri_grant = { .uri = "/grant_access", .method = HTTP_GET, .handler = grant_access_handler }; | ||
| 181 | static const httpd_uri_t uri_status = { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler }; | ||
| 182 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; | ||
| 183 | static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler }; | ||
| 184 | static const httpd_uri_t uri_reset = { .uri = "/reset_authentication", .method = HTTP_GET, .handler = reset_auth_handler }; | ||
| 185 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; | ||
| 186 | |||
| 187 | esp_err_t captive_portal_start(void) | ||
| 188 | { | ||
| 189 | if (s_server) return ESP_OK; | ||
| 190 | |||
| 191 | httpd_config_t config = HTTPD_DEFAULT_CONFIG(); | ||
| 192 | config.max_uri_handlers = 10; | ||
| 193 | config.uri_match_fn = httpd_uri_match_wildcard; | ||
| 194 | |||
| 195 | esp_err_t ret = httpd_start(&s_server, &config); | ||
| 196 | if (ret != ESP_OK) { | ||
| 197 | ESP_LOGE(TAG, "Failed to start HTTP server: %s", esp_err_to_name(ret)); | ||
| 198 | return ret; | ||
| 199 | } | ||
| 200 | |||
| 201 | httpd_register_uri_handler(s_server, &uri_portal); | ||
| 202 | httpd_register_uri_handler(s_server, &uri_grant); | ||
| 203 | httpd_register_uri_handler(s_server, &uri_status); | ||
| 204 | httpd_register_uri_handler(s_server, &uri_whoami); | ||
| 205 | httpd_register_uri_handler(s_server, &uri_usage); | ||
| 206 | httpd_register_uri_handler(s_server, &uri_reset); | ||
| 207 | httpd_register_uri_handler(s_server, &uri_catchall); | ||
| 208 | |||
| 209 | ESP_LOGI(TAG, "Captive portal started on port 80"); | ||
| 210 | return ESP_OK; | ||
| 211 | } | ||
| 212 | |||
| 213 | void captive_portal_stop(void) | ||
| 214 | { | ||
| 215 | if (s_server) { | ||
| 216 | httpd_stop(s_server); | ||
| 217 | s_server = NULL; | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | httpd_handle_t captive_portal_get_server(void) | ||
| 222 | { | ||
| 223 | return s_server; | ||
| 224 | } | ||