/* 配网热点的 ESP 胶水(0.1.19)。**只进 IDF 目标**;什么时候开关、为什么安全在 prov.h。 ---- 三样东西,跟着热点一起开关 --------------------------------------------- | 东西 | 说明 | |---|---| | SoftAP | `WIFI_MODE_STA` ↔ `WIFI_MODE_APSTA`。STA 那一半照常由 mlink_esp.c 重连 | | HTTP 服务(esp_http_server,自己一个任务) | `/` 网页、`/s` 状态、`/scan` 扫描、`/c` 提交;其余一律 302 回 `/` | | DNS 劫持(自己一个任务,起了就不退) | 所有 A 查询都答热点地址 ⇒ 手机连上热点自动弹出网页(captive portal) | ---- 配置怎么写进去 --------------------------------------------------------- 网页 POST 的正文经 `prov_build_line()` 拼成一行命令,**投进命令队列**(`CMD_LINK_AP`), 由执行器任务里那个唯一的命令处理器执行(cmd.h)—— 校验、存 NVS、通知 mlink_esp 重连, 走的是和 USB 完全相同的路。HTTP 任务只是等回应(最多 3 s)再原样转给浏览器。 ---- 只认热点那一侧 --------------------------------------------------------- HTTP 服务绑的是所有接口,而热点在 STA 连上之后还会留一会儿(`linger`)—— 那段时间路由器那一侧也能访问到 80 端口。所以每个处理函数先看**这条连接的本地地址** 是不是热点地址,不是就 403;DNS 那边看来源网段。 */ #include "prov_esp.h" #include #include "cmd_queue.h" #include "esp_event.h" #include "esp_http_server.h" #include "esp_log.h" #include "esp_random.h" /* 表单命令的 seq */ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #include "lwip/sockets.h" #include "phonerail.h" #include "prov.h" #include "tasks.h" static const char *TAG = "prov"; #define AP_CHANNEL 1 /* STA 一旦连上别的信道,热点会跟过去(单射频) */ #define AP_MAX_STA 2 #define SCAN_MAX 16 #define CMD_WAIT_MS 3000 extern const char prov_html_start[] asm("_binary_prov_html_start"); extern const char prov_html_end[] asm("_binary_prov_html_end"); static esp_netif_t *s_netif; static char s_ssid[PROV_SSID_MAX]; static httpd_handle_t s_httpd; static volatile bool s_on; static volatile int s_sta; static volatile bool s_touch; static uint32_t s_ip; /* 热点地址,网络字节序 */ static bool s_dns_started; /* 等命令回应用的。回应是执行器任务送来的(cmd_queue.h),HTTP 任务在等: `s_rgen` 对不上 = 这条回应来晚了(HTTP 那边已经超时走人),丢掉。 */ static SemaphoreHandle_t s_rmtx; static SemaphoreHandle_t s_rdone; static char s_rbuf[CMD_OUT_MAX]; static size_t s_rlen; static uint32_t s_rgen; /* ---- 命令队列 ---------------------------------------------------------------- */ static void on_reply(void *ctx, const char *line, size_t len) { xSemaphoreTake(s_rmtx, portMAX_DELAY); /* 持锁期间只有一次 memcpy:不会把执行器拖住 */ if ((uint32_t)(uintptr_t)ctx == s_rgen && len < sizeof(s_rbuf)) { memcpy(s_rbuf, line, len); s_rbuf[len] = '\0'; s_rlen = len; xSemaphoreGive(s_rdone); } xSemaphoreGive(s_rmtx); } /* 投一行命令、等回应。回应在 s_rbuf(只有 HTTP 任务读)。返回长度,0 = 队列满 / 超时。 */ static size_t run_cmd(const char *line) { xSemaphoreTake(s_rmtx, portMAX_DELAY); const uint32_t gen = ++s_rgen; s_rlen = 0; xSemaphoreGive(s_rmtx); (void)xSemaphoreTake(s_rdone, 0); bool ok = cmd_queue_submit(CMD_LINK_AP, line, on_reply, (void *)(uintptr_t)gen); ok = ok && xSemaphoreTake(s_rdone, pdMS_TO_TICKS(CMD_WAIT_MS)) == pdTRUE; xSemaphoreTake(s_rmtx, portMAX_DELAY); s_rgen++; /* 从这里起,迟到的回应不许再碰 s_rbuf */ const size_t n = ok ? s_rlen : 0; xSemaphoreGive(s_rmtx); return n; } /* ---- HTTP ------------------------------------------------------------------ */ /* 这条连接是不是从热点那一侧进来的(文件头「只认热点那一侧」)。 */ static bool from_ap(httpd_req_t *r) { struct sockaddr_storage a; socklen_t n = sizeof(a); if (getsockname(httpd_req_to_sockfd(r), (struct sockaddr *)&a, &n) != 0) { return false; } uint32_t ip = 0; if (a.ss_family == AF_INET) { ip = ((const struct sockaddr_in *)&a)->sin_addr.s_addr; #if CONFIG_LWIP_IPV6 } else if (a.ss_family == AF_INET6) { /* 监听口是 v6 的,v4 连接以 ::ffff:a.b.c.d 出现 */ const struct sockaddr_in6 *a6 = (const struct sockaddr_in6 *)&a; static const uint8_t mapped[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF }; if (memcmp(a6->sin6_addr.s6_addr, mapped, sizeof(mapped)) != 0) { return false; } memcpy(&ip, &a6->sin6_addr.s6_addr[12], sizeof(ip)); #endif } return s_on && ip != 0 && ip == s_ip; } static esp_err_t deny(httpd_req_t *r) { httpd_resp_set_status(r, "403 Forbidden"); return httpd_resp_send(r, "403", HTTPD_RESP_USE_STRLEN); } static void json_hdr(httpd_req_t *r) { httpd_resp_set_type(r, "application/json"); httpd_resp_set_hdr(r, "Cache-Control", "no-store"); } static esp_err_t h_root(httpd_req_t *r) { if (!from_ap(r)) { return deny(r); } s_touch = true; httpd_resp_set_type(r, "text/html; charset=utf-8"); httpd_resp_set_hdr(r, "Cache-Control", "no-store"); /* EMBED_TXTFILES 结尾补了一个 0,不发出去。 */ return httpd_resp_send(r, prov_html_start, (ssize_t)(prov_html_end - prov_html_start - 1)); } /* 状态轮询(3 s 一次):不算「人的动作」,否则页面开着就永远在放慢重连。 */ static esp_err_t h_status(httpd_req_t *r) { if (!from_ap(r)) { return deny(r); } json_hdr(r); const size_t n = run_cmd("{\"cmd\":\"net\"}"); if (n == 0) { httpd_resp_set_status(r, "503 Service Unavailable"); return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}"); } httpd_resp_sendstr_chunk(r, "{\"fw\":\"" PHONERAIL_FW_VERSION "\",\"net\":"); httpd_resp_send_chunk(r, s_rbuf, (ssize_t)n); httpd_resp_sendstr_chunk(r, "}"); return httpd_resp_sendstr_chunk(r, NULL); } static esp_err_t h_scan(httpd_req_t *r) { if (!from_ap(r)) { return deny(r); } s_touch = true; json_hdr(r); /* 阻塞扫描 ≈2~3 s,卡的是 HTTP 任务自己。STA 正在连的那一两秒里驱动会拒(WIFI_STATE)—— 如实告诉网页「过几秒再点」,不在这里重试。 */ if (esp_wifi_scan_start(NULL, true) != ESP_OK) { return httpd_resp_sendstr(r, "{\"err\":\"busy\"}"); } static wifi_ap_record_t rec[SCAN_MAX]; /* 只有 HTTP 任务进来 */ uint16_t n = SCAN_MAX; if (esp_wifi_scan_get_ap_records(&n, rec) != ESP_OK) { /* 必须取:不取驱动不放那块内存 */ n = 0; } httpd_resp_sendstr_chunk(r, "{\"aps\":["); bool first = true; for (int i = 0; i < n; i++) { /* 驱动已按信号强弱排好 */ const char *ssid = (const char *)rec[i].ssid; bool dup = ssid[0] == '\0'; /* 隐藏的不列 */ for (int k = 0; k < i && !dup; k++) { dup = strcmp((const char *)rec[k].ssid, ssid) == 0; /* 同名的多个 AP 只列最强的 */ } char esc[32 * 6 + 1]; if (dup || prov_json_esc(ssid, esc, sizeof(esc)) == 0) { continue; } char item[sizeof(esc) + 48]; snprintf(item, sizeof(item), "%s{\"ssid\":\"%s\",\"rssi\":%d,\"open\":%s}", first ? "" : ",", esc, (int)rec[i].rssi, rec[i].authmode == WIFI_AUTH_OPEN ? "true" : "false"); httpd_resp_sendstr_chunk(r, item); first = false; } httpd_resp_sendstr_chunk(r, "]}"); return httpd_resp_sendstr_chunk(r, NULL); } static esp_err_t h_cfg(httpd_req_t *r) { if (!from_ap(r)) { return deny(r); } s_touch = true; json_hdr(r); char body[CMD_LINE_MAX]; if (r->content_len == 0 || r->content_len >= sizeof(body)) { return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"too_long\"}"); } size_t got = 0; while (got < r->content_len) { const int k = httpd_req_recv(r, body + got, r->content_len - got); if (k <= 0) { return ESP_FAIL; /* 连接断了 / 超时:关掉这条连接 */ } got += (size_t)k; } body[got] = '\0'; if (strlen(body) != got) { return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"bad_char\"}"); /* 正文里有 NUL */ } /* 会动东西的命令必须带 seq,而判重窗口是全局按值比的(cmd.h):用随机数, 和服务器那边的序号撞上的机会是 32 条 / 2^32。 */ uint32_t seq = esp_random(); if (seq == 0) { seq = 1; } char line[CMD_LINE_MAX]; const char *err = NULL; if (prov_build_line(body, seq, line, sizeof(line), &err) == 0) { char out[48]; snprintf(out, sizeof(out), "{\"ok\":false,\"err\":\"%s\"}", err); return httpd_resp_sendstr(r, out); } const size_t n = run_cmd(line); memset(body, 0, sizeof(body)); /* 口令别留在栈上 */ memset(line, 0, sizeof(line)); if (n == 0) { return httpd_resp_sendstr(r, "{\"ok\":false,\"err\":\"timeout\"}"); } return httpd_resp_send(r, s_rbuf, (ssize_t)n); } /* 别的路径(手机的联网探测 generate_204 / hotspot-detect.html 之类)一律引回网页。 */ static esp_err_t h_404(httpd_req_t *r, httpd_err_code_t e) { (void)e; httpd_resp_set_status(r, "302 Found"); httpd_resp_set_hdr(r, "Location", "http://192.168.4.1/"); httpd_resp_set_hdr(r, "Cache-Control", "no-store"); return httpd_resp_send(r, "PhoneRail", HTTPD_RESP_USE_STRLEN); } static void http_start(void) { httpd_config_t hc = HTTPD_DEFAULT_CONFIG(); hc.task_priority = task_desc(TASK_LINK)->prio; /* §10.3:链路最低 */ hc.stack_size = 6144; hc.max_open_sockets = 3; /* lwIP 一共 10 个 socket,MQTT / OTA / DNS 也要用 */ hc.lru_purge_enable = true; hc.max_uri_handlers = 4; if (httpd_start(&s_httpd, &hc) != ESP_OK) { s_httpd = NULL; ESP_LOGE(TAG, "HTTP 服务起不来 —— 热点开着但没有网页,只能走 USB"); return; } static const httpd_uri_t k_uris[] = { { .uri = "/", .method = HTTP_GET, .handler = h_root }, { .uri = "/s", .method = HTTP_GET, .handler = h_status }, { .uri = "/scan", .method = HTTP_GET, .handler = h_scan }, { .uri = "/c", .method = HTTP_POST, .handler = h_cfg }, }; for (size_t i = 0; i < sizeof(k_uris) / sizeof(k_uris[0]); i++) { httpd_register_uri_handler(s_httpd, &k_uris[i]); } httpd_register_err_handler(s_httpd, HTTPD_404_NOT_FOUND, h_404); } /* ---- DNS 劫持 --------------------------------------------------------------- */ /* 任务起了就不退(热点关着时收到的包直接丢):省掉「关热点时怎么把它从 recvfrom 里叫出来」。 */ static void dns_task(void *arg) { (void)arg; int fd = -1; for (;;) { if (fd < 0) { fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); struct sockaddr_in me = { .sin_family = AF_INET, .sin_port = htons(53), .sin_addr.s_addr = htonl(INADDR_ANY) }; const struct timeval tv = { .tv_sec = 1, .tv_usec = 0 }; if (fd < 0 || bind(fd, (struct sockaddr *)&me, sizeof(me)) != 0 || setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) { if (fd >= 0) { close(fd); fd = -1; } vTaskDelay(pdMS_TO_TICKS(5000)); /* socket 用光了:没有自动弹窗,网页照样能手输地址打开 */ continue; } } uint8_t q[256 + 16]; struct sockaddr_in from; socklen_t fl = sizeof(from); const int n = recvfrom(fd, q, 256, 0, (struct sockaddr *)&from, &fl); if (n < 0) { continue; /* 1 s 超时 */ } /* 只答热点网段来的、热点开着时的、单问题的标准查询。 */ if (!s_on || n < 17 || ((from.sin_addr.s_addr ^ s_ip) & htonl(0xFFFFFF00u)) != 0 || (q[2] & 0xF8) != 0 || q[4] != 0 || q[5] != 1) { continue; } int p = 12; while (p < n && q[p] != 0) { p += q[p] + 1; } p += 5; /* 结尾的 0 + QTYPE + QCLASS */ if (p > n) { continue; } const bool is_a = q[p - 4] == 0 && q[p - 3] == 1; q[2] = 0x81; /* 回应 + 递归可用 */ q[3] = 0x80; q[6] = 0; q[7] = is_a ? 1 : 0; /* 别的类型(AAAA / HTTPS):没有记录 */ q[8] = q[9] = q[10] = q[11] = 0; if (is_a) { static const uint8_t ans[12] = { 0xC0, 0x0C, 0, 1, 0, 1, 0, 0, 0, 10, 0, 4 }; /* TTL 10 s */ memcpy(q + p, ans, sizeof(ans)); memcpy(q + p + sizeof(ans), &s_ip, 4); p += (int)sizeof(ans) + 4; } (void)sendto(fd, q, (size_t)p, 0, (struct sockaddr *)&from, fl); } } /* ---- 热点 ------------------------------------------------------------------ */ static void on_ap(void *arg, esp_event_base_t base, int32_t id, void *data) { (void)arg; (void)base; (void)data; if (id == WIFI_EVENT_AP_STACONNECTED) { s_sta = s_sta + 1; } else if (id == WIFI_EVENT_AP_STADISCONNECTED && s_sta > 0) { s_sta = s_sta - 1; } } void prov_esp_init(esp_netif_t *ap, const char *id) { s_netif = ap; s_rmtx = xSemaphoreCreateMutex(); s_rdone = xSemaphoreCreateBinary(); prov_ssid(id, s_ssid); esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STACONNECTED, on_ap, NULL); esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STADISCONNECTED, on_ap, NULL); } void prov_esp_set(bool on) { if (on == s_on || s_netif == NULL || s_rmtx == NULL || s_rdone == NULL) { return; } if (!on) { s_on = false; /* 先置:正在处理的请求下一步就 403 */ if (s_httpd != NULL) { httpd_stop(s_httpd); /* 等 HTTP 任务退出(最多等完一次扫描 / 一条命令) */ s_httpd = NULL; } esp_wifi_set_mode(WIFI_MODE_STA); s_sta = 0; ESP_LOGI(TAG, "配网热点已关"); return; } wifi_config_t ac = { 0 }; memcpy(ac.ap.ssid, s_ssid, strlen(s_ssid)); ac.ap.ssid_len = (uint8_t)strlen(s_ssid); memcpy(ac.ap.password, PROV_PASS, strlen(PROV_PASS)); ac.ap.channel = AP_CHANNEL; ac.ap.authmode = WIFI_AUTH_WPA2_PSK; ac.ap.max_connection = AP_MAX_STA; if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK || esp_wifi_set_config(WIFI_IF_AP, &ac) != ESP_OK) { ESP_LOGE(TAG, "配网热点开不起来 —— 只能走 USB"); esp_wifi_set_mode(WIFI_MODE_STA); return; /* s_on 仍是 false:t_link 过 10 s 再试(mlink_esp.c 的 prov_poll) */ } esp_netif_ip_info_t ip; s_ip = (esp_netif_get_ip_info(s_netif, &ip) == ESP_OK) ? ip.ip.addr : 0; s_sta = 0; s_touch = false; s_on = true; http_start(); if (!s_dns_started) { s_dns_started = xTaskCreate(dns_task, "prov_dns", 3072, NULL, task_desc(TASK_LINK)->prio, NULL) == pdPASS; } ESP_LOGW(TAG, "配网热点已开:「%s」,连上后开 http://192.168.4.1/", s_ssid); /* 只打名字,不打口令 */ } bool prov_esp_on(void) { return s_on; } int prov_esp_sta_count(void) { return s_sta; } bool prov_esp_take_touch(void) { const bool t = s_touch; s_touch = false; return t; } const char *prov_esp_ssid(void) { return s_ssid; } const char *prov_esp_pass(void) { return PROV_PASS; }