Merge PR #13 with gzip-only WebConfig assets

Keep the graceful final-ACK TCP close and optional FEM control guards from
pixitha's browser compatibility fixes. Replace Brotli negotiation and duplicate
page assets with one gzip representation compressed by Google Zopfli 0.4.3
using 1,000 iterations. Compress the bootstrap, captive success, compact OTA,
and static WiFi setup pages with the same validated build cache.

Honor gzip quality values and wildcard precedence. Validate compressed OTA
page evidence in the firmware capability gate, and add native and Python
regressions plus CI coverage. Remove the unused Brotli build dependency.

Validation: 54 native tests, 56 Python/browser checks, full V4.3 build and
verified application flash. Actual V4 WiFi responses matched the generated
gzip assets; 30 repeat downloads completed without reboot. Chromium loaded
the setup and settings pages without script or transfer errors. Physical
Android testing is pending because the Pi detects no phone over USB or mDNS.
This commit is contained in:
mikecarper
2026-09-30 18:55:17 -07:00
23 changed files with 2674 additions and 2084 deletions
+2
View File
@@ -257,6 +257,8 @@ jobs:
python3 -B test/test_bluetooth_mac_contract.py
python3 -B test/test_companion_settings_persistence_contract.py
python3 -B test/test_webconfig_ui.py
python3 -B test/test_webconfig_gzip.py
python3 -B test/test_webconfig_secret_masking.py
python3 -B test/test_webconfig_ui_runtime.py
- name: Upload Test Results
+5 -1
View File
@@ -4194,7 +4194,11 @@ write_build_capability_manifest() {
checker_args+=(--reduction "$item")
done
for item in "${BUILD_EXPECTATIONS[@]}"; do
checker_args+=(--expect "$item")
if [[ "$item" == *"=MeshCore firmware update" ]]; then
checker_args+=(--expect-gzip "$item")
else
checker_args+=(--expect "$item")
fi
done
for item in "${BUILD_APPLICATION_EXPECTATIONS[@]}"; do
checker_args+=(--expect-application "$item")
+9
View File
@@ -104,6 +104,15 @@ and memory limits.
## MQTT observer setup
WebConfig serves gzip only, including its bootstrap and captive-portal success
pages. Static WiFi setup and browser OTA pages use the same format. Builds use
Google Zopfli 0.4.3 with 1,000 iterations; validated results are cached under
`.pio/gzip-html`. There is no Brotli dependency or second WebConfig page copy.
Browsers decompress the pages automatically over ordinary HTTP. WebConfig
returns HTTP 406 when a client explicitly excludes gzip. Its large page closes
the TCP connection gracefully after the final acknowledged bytes so browsers
do not report a completed download as aborted.
Most full-size ESP32 MQTT observer builds have the shared WebConfig portal. On a
fresh device with no saved SSID:
-1
View File
@@ -1,3 +1,2 @@
# Reproducible compressors for firmware-resident and OTA assets.
brotli==1.2.0
zopfli==0.4.3
-31
View File
@@ -1,31 +0,0 @@
"""Pinned Google Brotli helper for the browser-decoded WebConfig asset."""
from __future__ import annotations
BROTLI_VERSION = "1.2.0"
QUALITY = 11
# Maximum RFC 7932/browser-compatible Brotli window (16 MiB). The page is
# smaller than the default 4 MiB window, but pin the requested maximum anyway.
LGWIN = 24
try:
import brotli
except ImportError as error:
raise RuntimeError(
"Google Brotli 1.2.0 is required to generate the MeshCore WebConfig "
"asset. Install it with 'python -m pip install -r "
"requirements-build.txt'."
) from error
if brotli.__version__ != BROTLI_VERSION:
raise RuntimeError(
"MeshCore requires Google Brotli %s, but %s is installed. Run "
"'python -m pip install -r requirements-build.txt'."
% (BROTLI_VERSION, brotli.__version__)
)
def compress_html(data: bytes) -> bytes:
"""Return a deterministic, maximum-quality RFC 7932 Brotli text stream."""
return brotli.compress(data, mode=brotli.MODE_TEXT, quality=QUALITY,
lgwin=LGWIN)
+29 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
import sys
import struct
import zipfile
import zlib
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "mota"))
from motalib import parse_nrf52_layout
@@ -74,6 +75,11 @@ def parse_args() -> argparse.Namespace:
metavar="CAPABILITY=TEXT",
help="Require TEXT to be present in the linked image.",
)
parser.add_argument(
"--expect-gzip", action="append", default=[],
metavar="CAPABILITY=TEXT",
help="Require TEXT inside a complete gzip asset in the linked image.",
)
parser.add_argument(
"--expect-application", action="append", default=[],
metavar="CAPABILITY=TEXT",
@@ -86,6 +92,24 @@ def stable_unique(values: list[str]) -> list[str]:
return list(dict.fromkeys(value for value in values if value))
def linked_gzip_assets(image):
"""Inspect bounded, CRC-validated assets; ELF strings alone are not proof."""
assets = []
offset = 0
while True:
offset = image.find(b"\x1f\x8b\x08", offset)
if offset < 0:
return assets
decoder = zlib.decompressobj(31)
try:
decoded = decoder.decompress(image[offset:], 1024 * 1024 + 1)
if decoder.eof and len(decoded) <= 1024 * 1024:
assets.append(decoded)
except zlib.error:
pass
offset += 3
def read_application(args):
if args.firmware_bin is not None:
return args.firmware_bin.read_bytes()
@@ -169,6 +193,8 @@ def main() -> int:
malformed = True
expectations = [(value, image, "linked image") for value in args.expect]
expectations += [(value, application, "packaged application") for value in args.expect_application]
gzip_assets = linked_gzip_assets(image) if args.expect_gzip else []
expectations += [(value, gzip_assets, "linked gzip asset") for value in args.expect_gzip]
for expectation, content, source in expectations:
if "=" not in expectation:
print(
@@ -178,7 +204,9 @@ def main() -> int:
malformed = True
continue
capability, needle = expectation.split("=", 1)
present = bool(needle) and needle.encode("utf-8") in content
encoded = needle.encode("utf-8")
present = bool(needle) and (any(encoded in asset for asset in content)
if source == "linked gzip asset" else encoded in content)
checks.append(
{
"capability": capability,
+58 -15
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python
#
# Pre-build script: Brotli-compress webui/index.html into a PROGMEM C header so the
# Pre-build script: compress webui/index.html into a PROGMEM C header so the
# webconfig portal can serve the page straight from flash with
# Content-Encoding: br. The generated header is tracked so ESP32 targets
# Content-Encoding: gzip. The generated header is tracked so ESP32 targets
# that replace extra_scripts still compile from a clean checkout; this script
# rewrites it only when the emitted firmware bytes actually change.
#
@@ -11,8 +11,8 @@
# flash, so the page ships smaller than it reads.
#
# Output: src/helpers/esp32/WebConfigHtml.h
# WEBCONFIG_HTML_LOADER[] - small bootstrap page (PROGMEM)
# WEBCONFIG_HTML_BR[] - complete Brotli-compressed page (PROGMEM)
# WEBCONFIG_HTML_LOADER[] - gzip-compressed bootstrap page (PROGMEM)
# WEBCONFIG_HTML_GZ[] - complete gzip-compressed page (PROGMEM)
# WEBCONFIG_HTML_ETAG - quoted strong ETag for the loader page
#
# Runnable outside SCons to inspect exactly what gets shipped:
@@ -20,6 +20,10 @@
# or served directly by the mock backend with its --minify flag.
import hashlib
import gzip
from pathlib import Path
import tempfile
import zlib
import os
import sys
@@ -35,7 +39,7 @@ HASH_MARKER = "// shipped-page-sha256: "
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from webconfig_minify import check_stripped, strip_source # noqa: E402
from brotli_compress import BROTLI_VERSION, LGWIN, QUALITY, compress_html # noqa: E402
from zopfli_compress import ZOPFLI_VERSION, MAXIMUM_ITERATIONS, gzip_compress # noqa: E402
def status(msg):
@@ -73,6 +77,27 @@ def append_byte_array(lines, name, data):
lines.append("};")
def compress_page(raw):
"""Cache validated 1,000-iteration output so normal builds stay fast."""
key = hashlib.sha256((ZOPFLI_VERSION + ":" + str(MAXIMUM_ITERATIONS)).encode()
+ raw).hexdigest()
cache = Path(".pio/gzip-html")
path = cache / (key + ".gz")
try:
encoded = path.read_bytes()
if gzip.decompress(encoded) == raw:
return encoded
except (OSError, EOFError, gzip.BadGzipFile, zlib.error):
pass
encoded = gzip_compress(raw, numiterations=MAXIMUM_ITERATIONS)
cache.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=cache, delete=False) as output:
output.write(encoded)
temporary = output.name
os.replace(temporary, path)
return encoded
def main():
if not os.path.isfile(SOURCE):
status("ERROR: %s not found" % SOURCE)
@@ -86,12 +111,27 @@ def main():
status("%s -> %s (%d -> %d bytes)" % (SOURCE, dest, len(src), len(stripped)))
return
# The compact uploader and fallback WiFi form share the same compressor.
static_lines = ["// Auto-generated by scripts/generate_webconfig_html.py.",
"// Google Zopfli %s (gzip, %d iterations)." %
(ZOPFLI_VERSION, MAXIMUM_ITERATIONS),
"#pragma once", "#include <stdint.h>", "#include <pgmspace.h>"]
for page in sorted(Path("webui/pages").glob("*.html")):
symbol = "MESH_HTML_" + page.stem.upper()
compressed = compress_page(page.read_bytes())
static_lines.append("const uint32_t %s_LEN = %d;" % (symbol, len(compressed)))
append_byte_array(static_lines, symbol, compressed)
static_header = Path("src/helpers/esp32/StaticHtml.h")
static_rendered = "\n".join(static_lines) + "\n"
if not static_header.exists() or static_header.read_text() != static_rendered:
static_header.write_text(static_rendered)
src, stripped = shipped_page()
raw = stripped.encode("utf-8")
src_hash = content_hash(raw)
br = compress_html(raw)
version = hashlib.sha256(br).hexdigest()[:16]
gz = compress_page(raw)
version = hashlib.sha256(gz).hexdigest()[:16]
loader = ("<!doctype html><meta charset=utf-8><meta name=viewport "
"content=\"width=device-width,initial-scale=1\"><title>MeshCore "
"WebConfig</title><body>Loading MeshCore WebConfig&hellip;<script>"
@@ -105,27 +145,30 @@ def main():
"document.write(p);document.close()})().catch(e=>"
"document.body.textContent=\"Unable to load WebConfig: \"+e+"
"\". Reload to retry.\")</script>" % version)
loader_bytes = loader.encode("utf-8")
loader_bytes = compress_page(loader.encode("utf-8"))
success_bytes = compress_page(b"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>")
etag = hashlib.sha256(loader_bytes).hexdigest()[:16]
lines = []
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
lines.append("// DO NOT EDIT - edit webui/index.html instead.")
lines.append("// Compressed with Google Brotli %s (quality %d, lgwin %d)." %
(BROTLI_VERSION, QUALITY, LGWIN))
lines.append("// Compressed with Google Zopfli %s (gzip, %d iterations)." %
(ZOPFLI_VERSION, MAXIMUM_ITERATIONS))
lines.append("%s%s" % (HASH_MARKER, src_hash))
lines.append("#pragma once")
lines.append("#include <stdint.h>")
lines.append("#include <pgmspace.h>")
lines.append("")
lines.append("const uint32_t WEBCONFIG_HTML_RAW_LEN = %d;" % len(raw))
lines.append("const uint32_t WEBCONFIG_HTML_BR_LEN = %d;" % len(br))
lines.append("const uint32_t WEBCONFIG_HTML_GZ_LEN = %d;" % len(gz))
lines.append('const char WEBCONFIG_HTML_ETAG[] = "\\"%s\\"";' % etag)
lines.append('const char WEBCONFIG_HTML_VERSION[] = "%s";' % version)
lines.append("const uint16_t WEBCONFIG_HTML_LOADER_LEN = %d;" % len(loader_bytes))
lines.append("const uint16_t WEBCONFIG_HTML_SUCCESS_LEN = %d;" % len(success_bytes))
append_byte_array(lines, "WEBCONFIG_HTML_LOADER", loader_bytes)
lines.append("")
append_byte_array(lines, "WEBCONFIG_HTML_BR", br)
append_byte_array(lines, "WEBCONFIG_HTML_GZ", gz)
lines.append("")
append_byte_array(lines, "WEBCONFIG_HTML_SUCCESS", success_bytes)
lines.append("")
rendered = "\n".join(lines)
@@ -140,8 +183,8 @@ def main():
with open(OUTPUT, "w", encoding="utf-8", newline="") as f:
f.write(rendered)
status("%s -> %s (%d bytes source, %d stripped, %d Brotli)"
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(br)))
status("%s -> %s (%d bytes source, %d stripped, %d gzip)"
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz)))
# This script is attached only to esp32_base. Generate unconditionally here:
+4 -4
View File
@@ -29,8 +29,8 @@ if zopfli.__version__ != ZOPFLI_VERSION:
)
# Above zlib's maximum level 9: Zopfli refines the LZ77 model repeatedly.
# Fifteen iterations is the upstream binding's practical default for assets
# of this size and keeps CI/build times reasonable.
# Fifteen iterations remains the practical default for raw OTA streams.
# Firmware HTML gzip assets use the 1,000-iteration setting below.
ITERATIONS = 15
# Zopfli has no finite algorithmic iteration ceiling. Its ``--i1000``
# convention is the maximum-effort setting used for host-cached mOTA transfer
@@ -49,9 +49,9 @@ def _options(numiterations: int = ITERATIONS) -> dict[str, int | bool]:
}
def gzip_compress(data: bytes) -> bytes:
def gzip_compress(data: bytes, *, numiterations: int = MAXIMUM_ITERATIONS) -> bytes:
"""Compress *data* as deterministic RFC 1952 gzip using Google Zopfli."""
encoded = bytearray(_gzip.compress(data, **_options()))
encoded = bytearray(_gzip.compress(data, **_options(numiterations)))
# The gzip OS byte is informational. Normalize it to RFC 1952's unknown
# value so a checked-in asset is identical across operating systems.
if len(encoded) < 10 or encoded[:3] != b"\x1f\x8b\x08":
+6 -14
View File
@@ -58,6 +58,7 @@ bool ESP32Board::isUserGpioAvailable(uint8_t pin) const {
#if defined(LIGHTWEIGHT_WIFI_OTA) && \
(defined(ADMIN_PASSWORD) || defined(COMPANION_RADIO_FULL))
#include <WiFi.h>
#include <helpers/esp32/StaticHtml.h>
#include <Update.h>
#include <esp_ota_ops.h>
#include <SPIFFS.h>
@@ -75,18 +76,6 @@ static constexpr uint16_t LIGHTWEIGHT_OTA_PORT = 8080;
static constexpr uint16_t LIGHTWEIGHT_OTA_PORT = 80;
#endif
static const char LIGHTWEIGHT_OTA_PAGE[] = R"HTML(<!doctype html>
<html><head><meta name="viewport" content="width=device-width"><title>MeshCore OTA</title>
<style>body{font-family:sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}button,input{font-size:1rem;margin:.5rem 0}pre{white-space:pre-wrap}</style></head>
<body><h2>MeshCore firmware update</h2><input id="file" type="file" accept=".bin,application/octet-stream"><br>
<button id="upload">Upload and reboot</button><pre id="status"></pre><script>
const f=document.getElementById('file'),s=document.getElementById('status'),b=document.getElementById('upload');
b.onclick=()=>{if(!f.files.length){s.textContent='Select a firmware .bin file.';return}b.disabled=true;
const x=new XMLHttpRequest();x.open('POST','/update');x.setRequestHeader('Content-Type','application/octet-stream');
x.upload.onprogress=e=>{if(e.lengthComputable)s.textContent='Uploading '+Math.round(e.loaded*100/e.total)+'%'};
x.onload=()=>{s.textContent=x.responseText;b.disabled=false};x.onerror=()=>{s.textContent='Upload failed';b.disabled=false};x.send(f.files[0])};
</script></body></html>)HTML";
class LightweightOTAServer {
WiFiServer server{LIGHTWEIGHT_OTA_PORT};
TaskHandle_t task = nullptr;
@@ -122,8 +111,11 @@ class LightweightOTAServer {
}
void sendPage(WiFiClient& client) {
sendResponse(client, 200, "OK", "text/html", LIGHTWEIGHT_OTA_PAGE,
strlen(LIGHTWEIGHT_OTA_PAGE));
client.printf("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
"Content-Encoding: gzip\r\nContent-Length: %u\r\n"
"Connection: close\r\nCache-Control: no-store\r\n\r\n",
static_cast<unsigned>(MESH_HTML_LIGHTWEIGHT_OTA_LEN));
client.write(MESH_HTML_LIGHTWEIGHT_OTA, MESH_HTML_LIGHTWEIGHT_OTA_LEN);
}
void sendLog(WiFiClient& client) {
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <stddef.h>
#include <string.h>
namespace mesh {
namespace http {
inline bool encodingTokenEquals(const char* first, const char* last, const char* token) {
while (first != last && *token) {
char c = *first++;
if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
if (c != *token++) return false;
}
return first == last && *token == 0;
}
inline void trimEncodingToken(const char*& first, const char*& last) {
while (first != last && (*first == ' ' || *first == '\t')) ++first;
while (first != last && (last[-1] == ' ' || last[-1] == '\t')) --last;
}
// A missing header permits any coding (RFC 9110). An empty header requests
// identity only. An explicit gzip preference takes precedence over '*'.
inline bool acceptsGzip(const char* header) {
if (!header) return true;
bool wildcard = false;
bool explicit_gzip = false;
bool gzip_allowed = false;
const char* item = header;
while (*item) {
const char* end = strchr(item, ',');
if (!end) end = item + strlen(item);
const char* semi = static_cast<const char*>(memchr(item, ';', end - item));
const char* first = item;
const char* last = semi ? semi : end;
trimEncodingToken(first, last);
const bool gzip = encodingTokenEquals(first, last, "gzip");
const bool star = encodingTokenEquals(first, last, "*");
bool allowed = true;
for (const char* param = semi; param && param != end;) {
first = param + 1;
param = static_cast<const char*>(memchr(first, ';', end - first));
last = param ? param : end;
trimEncodingToken(first, last);
const char* equal = static_cast<const char*>(memchr(first, '=', last - first));
if (!equal) continue;
const char* key_end = equal;
trimEncodingToken(first, key_end);
if (!encodingTokenEquals(first, key_end, "q")) continue;
first = equal + 1;
trimEncodingToken(first, last);
// qvalue is 0 or 1 with at most three fractional digits; do not let
// malformed parameters accidentally enable a compressed response.
allowed = false;
if (first == last || (*first != '0' && *first != '1')) continue;
const bool one = *first++ == '1';
bool nonzero = one;
bool valid = true;
if (first != last) {
if (*first++ != '.' || last - first > 3) valid = false;
while (first != last) {
if (*first < '0' || *first > '9' || (one && *first != '0')) valid = false;
if (*first != '0') nonzero = true;
++first;
}
}
allowed = valid && nonzero;
}
if (gzip) { explicit_gzip = true; gzip_allowed = allowed; }
if (star) wildcard = allowed;
item = *end ? end + 1 : end;
}
return explicit_gzip ? gzip_allowed : wildcard;
}
} // namespace http
} // namespace mesh
+6 -14
View File
@@ -3,6 +3,7 @@
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <WiFi.h>
#include <helpers/esp32/StaticHtml.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <esp_wifi.h>
@@ -24,19 +25,6 @@ static const uint32_t CONNECT_TIMEOUT_MS = 20000;
// disappears; allow one second for the HTTP response to flush first.
static const uint32_t AP_CLOSE_DELAY_MS = 1000;
static const char SETUP_PAGE[] = R"HTML(<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeshCore WiFi setup</title><style>
body{font-family:sans-serif;max-width:28rem;margin:2rem auto;padding:0 1rem;color:#222}
label{display:block;margin-top:1rem}input,button{box-sizing:border-box;width:100%;font-size:1rem;padding:.7rem;margin-top:.35rem}
button{margin-top:1.2rem}small{color:#555}</style></head><body>
<h2>MeshCore WiFi setup</h2><p>Enter the WiFi network this node should join.</p>
<form method="post" action="/save">
<label>SSID<input name="ssid" maxlength="31" required autocomplete="off"></label>
<label>Password<input name="password" type="password" maxlength="64" autocomplete="off"></label>
<small>Leave blank for an open network. A 64-character key must be hexadecimal.</small>
<button type="submit">Save and connect</button></form></body></html>)HTML";
struct PortalImpl {
WiFiServer server{80};
DNSServer dns;
@@ -257,7 +245,11 @@ static void handleClient(PortalImpl* impl, WiFiClient& client) {
} else if (get_request) {
// Serve the form for every GET path. Together with wildcard DNS this makes
// common captive-portal probes land on the setup page.
sendResponse(client, 200, "OK", "text/html", SETUP_PAGE);
client.printf("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
"Content-Encoding: gzip\r\nContent-Length: %u\r\n"
"Connection: close\r\nCache-Control: no-store\r\n\r\n",
static_cast<unsigned>(MESH_HTML_WIFI_SETUP_LEN));
client.write(MESH_HTML_WIFI_SETUP, MESH_HTML_WIFI_SETUP_LEN);
} else {
sendResponse(client, 404, "Not Found", "text/plain", "Not found.");
}
+83
View File
@@ -0,0 +1,83 @@
// Auto-generated by scripts/generate_webconfig_html.py.
// Google Zopfli 0.4.3 (gzip, 1000 iterations).
#pragma once
#include <stdint.h>
#include <pgmspace.h>
const uint32_t MESH_HTML_LIGHTWEIGHT_OTA_LEN = 619;
const uint8_t MESH_HTML_LIGHTWEIGHT_OTA[] PROGMEM = {
0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0x74,0x52,0xc5,0x9a,0xdb,0x30,
0x10,0xbe,0xe7,0x29,0xb4,0xe8,0x64,0xc1,0x5e,0x26,0x43,0x99,0x99,0x7b,0x95,0xad,
0x71,0xac,0xaf,0xb6,0xa4,0x4a,0xe3,0xc0,0xc2,0xbb,0x77,0x14,0xa5,0xdc,0x5e,0x44,
0x43,0x3f,0x28,0x5b,0x11,0xba,0xc2,0xb9,0x01,0xd6,0x60,0xd7,0x16,0x83,0x6c,0xb1,
0x65,0x0d,0x70,0x51,0x64,0x1d,0x20,0x67,0x8a,0x77,0x90,0xaf,0x4e,0x24,0x4c,0x8d,
0xb6,0xb8,0xca,0x2a,0xad,0x10,0x14,0xe6,0xab,0x53,0x29,0xb0,0xc9,0x05,0x4c,0x64,
0x05,0xbb,0x8b,0xcb,0x6a,0x91,0xa1,0xc4,0x16,0x8a,0x17,0xe0,0x9a,0x7b,0xda,0x02,
0x7b,0xf5,0xfe,0x4e,0x96,0x84,0xb7,0x41,0xe6,0x70,0x4e,0x7b,0xa9,0xc5,0xfc,0xaa,
0xa6,0x26,0xbb,0x35,0xef,0x64,0x3b,0xbf,0x70,0x5c,0xb9,0x5d,0x07,0x56,0xd6,0x69,
0xc7,0x67,0xa1,0xd3,0xc5,0xe1,0x81,0x85,0x8e,0xee,0x76,0x2c,0xd5,0xc5,0x21,0x9d,
0x19,0xef,0x51,0xa7,0x86,0x0b,0x21,0xd5,0xf8,0x62,0x8f,0xed,0xd3,0xdb,0x4d,0xd9,
0x23,0x6a,0xb5,0x23,0x95,0xe9,0x31,0xf4,0x74,0xf2,0x12,0x2e,0xf6,0x7f,0xa9,0x8d,
0x8f,0x7d,0xf1,0xde,0x8d,0xb1,0x70,0x35,0x6d,0x24,0xc2,0xae,0x33,0xbc,0x82,0x0b,
0x63,0x09,0xb4,0xe5,0xe6,0x26,0x4b,0x02,0xae,0x2c,0xf1,0xa4,0x09,0xa6,0x07,0x48,
0x0a,0x1c,0xfc,0x64,0x51,0x4b,0xdb,0x4d,0x39,0x1d,0x7a,0x23,0x38,0x42,0x96,0x50,
0x30,0x5b,0x0c,0x65,0x52,0xe4,0xab,0xb5,0x6c,0x61,0x95,0x79,0x11,0xbf,0x9f,0x79,
0x55,0x81,0x21,0x89,0xe2,0x52,0xaa,0x1d,0x6e,0x4c,0x2b,0x2b,0x8e,0x52,0xab,0x84,
0xb4,0x06,0x02,0x89,0x16,0x78,0x47,0x6a,0x95,0x96,0xe6,0x05,0x12,0x8b,0x4e,0xbd,
0x69,0x35,0x17,0xab,0xc5,0x87,0xc5,0xce,0xb8,0x12,0xcc,0x42,0xa9,0x35,0x66,0x49,
0xc8,0x2a,0x32,0xc2,0xbd,0x48,0x75,0xc8,0xb1,0x77,0xd4,0x23,0x31,0x96,0xc0,0xbb,
0xca,0x4a,0x83,0xc5,0x80,0xdc,0x71,0xc8,0xea,0x9c,0x5c,0xed,0x3b,0x50,0x18,0x8f,
0x01,0x1f,0xb4,0xe0,0x8f,0x77,0xe7,0x4f,0xc4,0x30,0xf2,0x00,0xa3,0xd1,0x8e,0xfb,
0x7f,0x46,0xe8,0x4c,0x39,0xe5,0xff,0x73,0x02,0xd0,0x68,0x94,0x0e,0xca,0x58,0xab,
0x8a,0xf8,0x7d,0xc9,0x87,0xa3,0xbc,0xb8,0x92,0xf5,0x70,0xa5,0x8e,0xfd,0x10,0x17,
0xb7,0xa0,0xc6,0xd8,0x8c,0xae,0x5c,0x8c,0x30,0xc3,0x7b,0xcb,0x7f,0x13,0xbd,0x83,
0x16,0x2a,0x64,0xfc,0xa7,0xac,0x5e,0x26,0xe6,0x6b,0xe2,0x28,0xb5,0x80,0xbd,0x55,
0x37,0x65,0x2c,0xa4,0xe3,0x65,0x0b,0x22,0x47,0xdb,0x43,0xba,0x24,0x36,0xcb,0x15,
0x4c,0xd9,0xe7,0x17,0xcf,0x1f,0x23,0x9a,0xb7,0xf0,0xb5,0x07,0x87,0xc3,0x51,0x3a,
0x8b,0xb5,0x01,0x35,0x8c,0x5e,0xbf,0x7a,0xf7,0x3e,0xda,0x89,0x92,0xe0,0x53,0xe4,
0x03,0x0e,0x70,0x99,0xf7,0x18,0xb8,0x00,0x3b,0x8c,0x96,0x40,0x76,0xdf,0x93,0x61,
0x94,0xfc,0x3f,0x7b,0x3c,0xb9,0x59,0x1c,0x88,0x12,0x47,0x63,0xf5,0xd8,0x82,0x73,
0x39,0x04,0x96,0xb0,0xa4,0x77,0x4f,0x77,0xa6,0x47,0x8f,0x74,0xf4,0x07,0xcf,0x60,
0x22,0xfd,0x56,0x16,0x6d,0xbf,0xe0,0xd8,0xc4,0x56,0xf7,0x4a,0xf8,0x42,0x7a,0x06,
0xb1,0xb5,0xbf,0xb7,0x97,0x40,0x8c,0x1a,0x79,0x3b,0xda,0x8e,0x36,0xa2,0x1b,0x3f,
0x4f,0x2b,0x1f,0x0d,0x5a,0xfe,0xde,0x6f,0x16,0xd3,0x78,0x43,0x2a,0xc0,0x7b,0x7a,
0x4d,0x7f,0x11,0xa8,0xe6,0xad,0x83,0x9b,0xd4,0x17,0x83,0xb5,0xda,0x86,0xea,0x7f,
0xa2,0x61,0x35,0x97,0x54,0x12,0xfd,0xb3,0xdc,0x01,0xc1,0x5b,0x9a,0xf7,0x6d,0xd1,
0x06,0xb1,0x9a,0x40,0x07,0xd9,0xe8,0x43,0x13,0x96,0x8d,0x3e,0x24,0x5b,0xe8,0x83,
0xcb,0x07,0x00,0x75,0x0d,0xa9,0x2f,0x36,0x04,0x00,0x00,
};
const uint32_t MESH_HTML_WIFI_SETUP_LEN = 517;
const uint8_t MESH_HTML_WIFI_SETUP[] PROGMEM = {
0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0x7d,0x53,0xc5,0x96,0xe3,0x3a,
0x10,0xdd,0xe7,0x2b,0xea,0xe9,0x9d,0xd9,0x75,0xb0,0xd9,0x71,0x3c,0xcc,0xcc,0x6b,
0xd9,0xaa,0xb4,0x35,0x91,0x25,0x8d,0x54,0x0e,0x9e,0xfe,0xf7,0x29,0xc3,0x30,0x6c,
0x2c,0xbe,0x98,0xa4,0xff,0x29,0x57,0xd0,0xce,0x23,0x94,0x54,0x99,0x6c,0x90,0xb6,
0x43,0x5a,0xa2,0x54,0x59,0x5a,0x21,0x49,0xb0,0xb2,0xc2,0x85,0x58,0x6b,0xdc,0x78,
0x17,0x48,0x40,0xe1,0x2c,0xa1,0xa5,0x85,0xd8,0x68,0x45,0xe5,0x42,0xe1,0x5a,0x17,
0x38,0x6c,0x17,0x47,0xda,0x6a,0xd2,0xd2,0x0c,0x63,0x21,0x0d,0x2e,0xa6,0x82,0xf1,
0x48,0x93,0xc1,0xec,0x39,0xc6,0xf2,0xae,0x0b,0x08,0x1f,0xf5,0x03,0x0d,0x11,0xa9,
0xf6,0xe9,0xb8,0x3b,0x4a,0x23,0xed,0x78,0x18,0xe4,0x4e,0xed,0x0e,0x4b,0x06,0x1f,
0x2e,0x65,0xa5,0xcd,0x2e,0x89,0xd2,0xc6,0x61,0xc4,0xa0,0x97,0xf3,0x4a,0x6e,0x3b,
0x86,0x64,0x76,0x11,0xb0,0xe2,0x75,0xb8,0xd2,0x36,0x99,0xf1,0x1c,0x64,0x4d,0x6e,
0xee,0xa5,0x52,0xda,0x5e,0x25,0x13,0x98,0x36,0xe7,0x85,0x33,0x2e,0x24,0xff,0xcf,
0x66,0xb3,0xeb,0x81,0x91,0x39,0x9a,0x83,0xd2,0xd1,0x1b,0xb9,0x4b,0x72,0xe3,0x8a,
0x55,0xff,0x7e,0x48,0xce,0x27,0xcd,0xfd,0x6b,0x6d,0x7d,0x4d,0x47,0x79,0x4d,0xe4,
0xec,0x21,0x77,0xdb,0x61,0xd4,0xfb,0x06,0x2e,0x77,0x41,0x61,0x18,0xf2,0xce,0xbc,
0xa3,0x9f,0x4e,0x26,0x37,0xe6,0xad,0x48,0xbe,0x81,0xed,0xe3,0x6f,0xdc,0xa3,0x73,
0x5e,0xfd,0x08,0x3d,0x3a,0x3e,0x6d,0xc0,0x07,0x3d,0xee,0x8f,0xa4,0xa3,0x59,0x73,
0x12,0x2b,0x69,0xcc,0xa1,0x17,0x7b,0x7a,0x7a,0x7a,0x9d,0x8e,0xbb,0x30,0xd2,0x71,
0x57,0x40,0x93,0x49,0xd3,0xc9,0xec,0xcf,0x01,0xf2,0x7e,0xea,0xb3,0xfb,0x96,0x30,
0x00,0x95,0xfd,0x99,0x45,0xda,0xb8,0xb0,0xe2,0x0d,0x1d,0xc1,0x3a,0x85,0x10,0x4b,
0x57,0x1b,0x05,0x9f,0x9c,0xb6,0xa3,0x74,0xec,0x19,0x70,0xe9,0x42,0x05,0xdc,0x6e,
0xe9,0xd4,0x42,0x78,0x17,0xb9,0x56,0x59,0x90,0x76,0x76,0x21,0xc6,0x51,0xae,0xb1,
0x29,0xae,0xcd,0x2d,0x7b,0xfb,0xf6,0xf1,0xbd,0xb4,0x8d,0xa7,0xff,0x21,0xc4,0xa8,
0x95,0x00,0x2e,0xc4,0xa0,0xbd,0xe2,0xfe,0xc5,0xf1,0x54,0x40,0xc0,0xcf,0xb5,0x0e,
0xa8,0xda,0x32,0x0a,0x57,0x79,0x83,0xc4,0x77,0xdd,0x72,0x29,0xd8,0x4b,0x87,0xf4,
0x15,0xf1,0x95,0x8c,0x71,0xc3,0xb9,0xfe,0x84,0xea,0xfb,0x4d,0x01,0xb4,0xf3,0x3f,
0xae,0x7f,0x64,0x3a,0x3b,0x11,0xff,0x26,0x68,0xf3,0xcc,0x9e,0x21,0x3b,0x80,0xdc,
0x48,0xbb,0x02,0x36,0x0a,0xd2,0x82,0xf3,0x68,0xbf,0x06,0x33,0x82,0xdb,0x70,0x76,
0x32,0x2c,0x4a,0x19,0xd8,0x33,0x06,0x58,0xe1,0x0e,0xaa,0x3a,0x12,0xe4,0x08,0x25,
0x6e,0xa5,0xc2,0x42,0x33,0x10,0x47,0xd5,0xe2,0x31,0x6e,0x57,0x60,0x2f,0x2d,0xd6,
0x79,0xa5,0x49,0x64,0x6f,0x99,0x85,0xb1,0x15,0x14,0xce,0x5a,0x2c,0x28,0x1d,0x77,
0xd7,0x58,0x4f,0x13,0x2f,0x0f,0x4d,0x7b,0x3c,0xb4,0xff,0xa8,0x2f,0x86,0x62,0xfc,
0x1a,0x68,0x03,0x00,0x00,
};
File diff suppressed because it is too large Load Diff
+37 -19
View File
@@ -31,6 +31,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#endif
#include "WebConfigHtml.h"
#include <helpers/HttpContentEncoding.h>
#include "WebTerminalStream.h"
#include <helpers/CLICommandUtils.h>
#include <new>
@@ -154,15 +155,11 @@ public:
if (_in_flight != 0) return 0;
if (_state == RESPONSE_WAIT_ACK) {
// The full Content-Length has now been acknowledged. ESPAsyncWebServer
// otherwise performs a graceful active close for every response, and
// this IDF 4.4 stack retains malloc-backed TCP PCBs in TIME_WAIT for about
// two minutes. Repeated WebConfig loads consume scarce internal heap and
// can also prevent AsyncTCP from retiring close events, wedging both
// infrastructure WiFi and ESP-NOW. Abort only after the final ACK so the
// peer has the complete page, while freeing this PCB immediately. Keep
// RESPONSE_WAIT_ACK: abort synchronously dispatches disconnect and the
// request's scoped owner then destroys this response.
if (client) client->abort();
// otherwise performs a graceful active close for every response. Keep
// the graceful close here too: browsers can report a complete,
// Content-Length-delimited response as a failed transfer when the peer
// force-aborts the TCP PCB immediately after the final ACK.
if (client) client->close();
return 0;
}
return queueNext(request);
@@ -1636,21 +1633,38 @@ bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
// Handlers (async_tcp task - no CLI/prefs writes, no radio access)
// ---------------------------------------------------------------------------
static bool webConfigClientAcceptsGzip(AsyncWebServerRequest* req) {
return req && mesh::http::acceptsGzip(req->hasHeader("Accept-Encoding")
? req->getHeader("Accept-Encoding")->value().c_str() : nullptr);
}
static void webConfigRejectEncoding(AsyncWebServerRequest* req) {
AsyncWebServerResponse* res = req->beginResponse(
406, "text/plain", "WebConfig requires gzip support");
res->addHeader("Vary", "Accept-Encoding");
res->addHeader("Cache-Control", "no-store");
req->send(res);
}
void WebConfigServer::handleRoot(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
if (!webConfigClientAcceptsGzip(req)) { webConfigRejectEncoding(req); return; }
if (req->hasHeader("If-None-Match") &&
req->getHeader("If-None-Match")->value() == WEBCONFIG_HTML_ETAG) {
req->send(304);
return;
}
AsyncWebServerResponse* res =
req->beginResponse(200, "text/html", WEBCONFIG_HTML_LOADER,
req->beginResponse(200, "text/html; charset=utf-8", WEBCONFIG_HTML_LOADER,
WEBCONFIG_HTML_LOADER_LEN);
res->addHeader("ETag", WEBCONFIG_HTML_ETAG);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("Vary", "Accept-Encoding");
req->send(res);
}
void WebConfigServer::handleUi(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
@@ -1664,13 +1678,11 @@ void WebConfigServer::handleUi(AsyncWebServerRequest* req) {
req->send(res);
return;
}
AsyncWebServerResponse* res =
new WebConfigPacedProgmemResponse("text/html; charset=utf-8",
WEBCONFIG_HTML_BR,
WEBCONFIG_HTML_BR_LEN);
// Keep exactly one whole-page asset in flash. Brotli is decoded by the
// browser; the ESP32 only streams these precompressed bytes.
res->addHeader("Content-Encoding", "br");
if (!webConfigClientAcceptsGzip(req)) { webConfigRejectEncoding(req); return; }
AsyncWebServerResponse* res = new WebConfigPacedProgmemResponse(
"text/html; charset=utf-8", WEBCONFIG_HTML_GZ, WEBCONFIG_HTML_GZ_LEN);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("Vary", "Accept-Encoding");
res->addHeader("Cache-Control", "public, max-age=31536000, immutable");
req->send(res);
}
@@ -2767,8 +2779,14 @@ void WebConfigServer::handleNotFound(AsyncWebServerRequest* req) {
if (url.indexOf("generate_204") >= 0 || url.indexOf("gen_204") >= 0) {
req->send(204); // Android
} else if (url.indexOf("hotspot-detect") >= 0 || url.indexOf("success") >= 0) {
req->send(200, "text/html", // Apple CNA
"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
if (!webConfigClientAcceptsGzip(req)) { webConfigRejectEncoding(req); return; }
AsyncWebServerResponse* res = req->beginResponse(
200, "text/html; charset=utf-8", WEBCONFIG_HTML_SUCCESS,
WEBCONFIG_HTML_SUCCESS_LEN);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("Vary", "Accept-Encoding");
res->addHeader("Cache-Control", "no-store");
req->send(res); // Apple CNA
} else if (url.indexOf("ncsi.txt") >= 0) {
req->send(200, "text/plain", "Microsoft NCSI"); // Windows
} else if (url.indexOf("connecttest.txt") >= 0) {
+16
View File
@@ -9,6 +9,7 @@ import unittest
import struct
import io
import zipfile
import gzip
ROOT = Path(__file__).resolve().parents[1]
@@ -75,6 +76,21 @@ class FirmwareCapabilityCheckerTest(unittest.TestCase):
self.assertFalse(manifest["verified"])
self.assertIn("promised", result.stderr)
def test_gzip_asset_requires_complete_validated_stream(self):
page = gzip.compress(b"<title>MeshCore firmware update</title>", mtime=0)
for image, accepted in [(b"ELF" + page + b"symbols", True),
(page[:-1], False),
(page[:-8] + b"\x00" * 8, False),
(b"MeshCore firmware update", False),
(gzip.compress(b"different page"), False),
(gzip.compress(b"MeshCore firmware update" + b"x" * 1048576), False)]:
with self.subTest(accepted=accepted, size=len(image)):
result, manifest = self.run_checker(
image, "--expect-gzip", "ota.update.wifi=MeshCore firmware update")
self.assertEqual(result.returncode, 0 if accepted else 1, result.stderr)
self.assertEqual(manifest["verified"], accepted)
self.assertEqual(manifest["verification"][0]["source"], "linked gzip asset")
def test_application_gate_rejects_elf_only_logging_evidence(self):
# Debug/symbol strings in the ELF must not qualify an image whose
# uploadable binary has had the implementation compiled or linked out.
@@ -0,0 +1,34 @@
#include <gtest/gtest.h>
#include <helpers/HttpContentEncoding.h>
TEST(HttpContentEncoding, DefaultAndIdentitySemantics) {
EXPECT_TRUE(mesh::http::acceptsGzip(nullptr));
for (const char* header : {"", " ", "identity", "br", "deflate, br"}) {
EXPECT_FALSE(mesh::http::acceptsGzip(header)) << header;
}
}
TEST(HttpContentEncoding, BrowserListsAndQualityValues) {
for (const char* header : {"gzip", "gzip, deflate, br", "GZip", " gzip ; q=0.1 ",
"gzip;q=1.000", "*", "*;q=.5, gzip;q=0.001"}) {
EXPECT_TRUE(mesh::http::acceptsGzip(header)) << header;
}
for (const char* header : {"gzip;q=0", "gzip;q=0.000", "gzip;q=0, br",
"gzip;q=2", "gzip;q=1.1", "gzip;q=0.1234", "gzip;q=oops",
"gzip;q=", "gzip;q=.5", "notgzip", "gzipish", "*;q=0"}) {
EXPECT_FALSE(mesh::http::acceptsGzip(header)) << header;
}
}
TEST(HttpContentEncoding, ExplicitCodingOverridesWildcardRegardlessOfOrder) {
EXPECT_FALSE(mesh::http::acceptsGzip("*;q=1, gzip;q=0"));
EXPECT_FALSE(mesh::http::acceptsGzip("gzip;q=0, *;q=1"));
EXPECT_TRUE(mesh::http::acceptsGzip("*;q=0, gzip;q=1"));
EXPECT_TRUE(mesh::http::acceptsGzip("gzip;q=1, *;q=0"));
EXPECT_TRUE(mesh::http::acceptsGzip("gzip;other=value;q=0.5"));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Qualify every generated HTML gzip stream and its compression settings."""
import ast
import gzip
import hashlib
import os
from pathlib import Path
import re
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import zopfli_compress
from webconfig_minify import strip_source
def arrays(header):
return {name: bytes(int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", body))
for name, body in re.findall(r"const uint8_t (\w+)\[\] PROGMEM = \{(.*?)\};",
header, re.S)}
class WebConfigGzipTest(unittest.TestCase):
def test_embedded_pages_round_trip_and_match_cache_keys(self):
header = (ROOT / "src/helpers/esp32/WebConfigHtml.h").read_text()
self.assertIn("gzip, 1000 iterations", header)
self.assertNotIn("WEBCONFIG_HTML_BR", header)
blobs = arrays(header)
self.assertEqual(set(blobs), {"WEBCONFIG_HTML_GZ", "WEBCONFIG_HTML_LOADER",
"WEBCONFIG_HTML_SUCCESS"})
page = gzip.decompress(blobs["WEBCONFIG_HTML_GZ"])
self.assertEqual(page, strip_source((ROOT / "webui/index.html").read_text()).encode())
version = hashlib.sha256(blobs["WEBCONFIG_HTML_GZ"]).hexdigest()[:16]
self.assertIn('WEBCONFIG_HTML_VERSION[] = "%s"' % version, header)
self.assertIn(("/ui?v=" + version).encode(), gzip.decompress(blobs["WEBCONFIG_HTML_LOADER"]))
self.assertEqual(gzip.decompress(blobs["WEBCONFIG_HTML_SUCCESS"]),
b"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>")
for name, blob in blobs.items():
declared = re.search(name + r"_LEN = (\d+);", header)
self.assertEqual(len(blob), int(declared[1]))
self.assertEqual(blob[:3], b"\x1f\x8b\x08")
self.assertEqual(blob[4:8], b"\0" * 4)
self.assertEqual(blob[9], 255)
def test_compact_uploader_and_wifi_form_are_precompressed(self):
header = (ROOT / "src/helpers/esp32/StaticHtml.h").read_text()
self.assertIn("gzip, 1000 iterations", header)
blobs = arrays(header)
for page in (ROOT / "webui/pages").glob("*.html"):
self.assertEqual(gzip.decompress(blobs["MESH_HTML_" + page.stem.upper()]), page.read_bytes())
def test_gzip_binding_uses_1000_iterations_without_changing_raw_ota_default(self):
with mock.patch.object(zopfli_compress._gzip, "compress",
return_value=gzip.compress(b"page", mtime=0)) as compress:
encoded = zopfli_compress.gzip_compress(b"page")
self.assertEqual(compress.call_args.kwargs["numiterations"], 1000)
self.assertEqual(gzip.decompress(encoded), b"page")
self.assertEqual(zopfli_compress.ITERATIONS, 15)
def test_cache_validates_contents_and_recovers_from_corruption(self):
# Load the production cache helper without invoking its build entry point.
source = ROOT / "scripts/generate_webconfig_html.py"
tree = ast.parse(source.read_text(), filename=str(source))
self.assertIsInstance(tree.body[-1], ast.Expr)
self.assertEqual(tree.body[-1].value.func.id, "main")
tree.body.pop()
namespace = {}
exec(compile(tree, str(source), "exec"), namespace)
original = Path.cwd()
with tempfile.TemporaryDirectory() as directory:
try:
os.chdir(directory)
compress = namespace["gzip_compress"]
with mock.patch.dict(namespace, gzip_compress=mock.Mock(wraps=compress)):
first = namespace["compress_page"](b"<html>cache test</html>")
self.assertEqual(first, namespace["compress_page"](b"<html>cache test</html>"))
self.assertEqual(namespace["gzip_compress"].call_count, 1)
cache = next(Path(".pio/gzip-html").glob("*.gz"))
cache.write_bytes(first[:-3])
self.assertEqual(first, namespace["compress_page"](b"<html>cache test</html>"))
self.assertEqual(namespace["gzip_compress"].call_count, 2)
finally:
os.chdir(original)
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
import brotli
import gzip
from pathlib import Path
import re
import unittest
@@ -19,14 +19,14 @@ def function_body(source: str, start: str, end: str) -> str:
def embedded_ui() -> str:
header = EMBEDDED_UI.read_text(encoding="utf-8")
length = int(re.search(r"WEBCONFIG_HTML_BR_LEN = (\d+);", header).group(1))
length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1))
array = header.split(
"const uint8_t WEBCONFIG_HTML_BR[] PROGMEM = {", 1
"const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1
)[1]
blob = bytes(
int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array)
)[:length]
return brotli.decompress(blob).decode("utf-8")
return gzip.decompress(blob).decode("utf-8")
class WebConfigSecretMaskingTest(unittest.TestCase):
+9 -9
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
import brotli
import gzip
from pathlib import Path
import re
import unittest
@@ -226,15 +226,15 @@ class WebConfigUiTest(unittest.TestCase):
def test_generated_asset_contains_show_controls(self):
header = HEADER.read_text(encoding="utf-8")
self.assertIn("Compressed with Google Brotli", header)
length = int(re.search(r"WEBCONFIG_HTML_BR_LEN = (\d+);", header).group(1))
self.assertIn("Compressed with Google Zopfli", header)
length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1))
array = header.split(
"const uint8_t WEBCONFIG_HTML_BR[] PROGMEM = {", 1
"const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1
)[1]
blob = bytes(
int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array)
)[:length]
page = brotli.decompress(blob).decode("utf-8")
page = gzip.decompress(blob).decode("utf-8")
self.assertEqual(page.count('onclick="toggleWiFiPassword(this)"'), 2)
self.assertIn("function toggleWiFiPassword(btn)", page)
self.assertIn("function wzSkipRadio()", page)
@@ -249,13 +249,13 @@ class WebConfigUiTest(unittest.TestCase):
self.assertIn('case"bluetooth.mac":return r.bluetooth_mac||"";', page)
self.assertIn("random-every-boot", page)
def test_webconfig_serves_the_single_brotli_asset(self):
def test_webconfig_serves_the_single_gzip_asset(self):
server = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text(
encoding="utf-8"
)
self.assertIn("WEBCONFIG_HTML_BR", server)
self.assertIn('res->addHeader("Content-Encoding", "br")', server)
self.assertNotIn("WEBCONFIG_HTML_GZ", server)
self.assertIn("WEBCONFIG_HTML_GZ", server)
self.assertIn('res->addHeader("Content-Encoding", "gzip")', server)
self.assertNotIn("WEBCONFIG_HTML_BR", server)
def test_skipped_mqtt_does_not_pin_display_in_setup_mode(self):
main = (ROOT / "examples" / "companion_radio" / "main.cpp").read_text(
+4 -4
View File
@@ -2,7 +2,7 @@
"""Optional real-browser checks for WebConfig's asynchronous setup races."""
import brotli
import gzip
import json
import os
from pathlib import Path
@@ -68,12 +68,12 @@ def run_browser(args):
def embedded_page():
header = HEADER.read_text(encoding="utf-8")
length = int(re.search(r"WEBCONFIG_HTML_BR_LEN = (\d+);", header).group(1))
array = header.split("const uint8_t WEBCONFIG_HTML_BR[] PROGMEM = {", 1)[1]
length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1))
array = header.split("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1)[1]
blob = bytes(
int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array)
)[:length]
return brotli.decompress(blob).decode("utf-8")
return gzip.decompress(blob).decode("utf-8")
class WebConfigUiRuntimeTest(unittest.TestCase):
+6 -2
View File
@@ -2016,8 +2016,12 @@ function setBoardCmds(v,tries){
// Board-specific rows in the settings form. Hidden unless the node answered for
// the command at startup, so the form never shows a control that cannot apply.
function applyBoardCmds(){
$("#sw-fem-rx").classList.toggle("hide",!boardHas("radio.fem.rxgain"));
$("#sw-fem-tx").classList.toggle("hide",!boardHas("radio.fem.txgain"));
var rx=$("[data-k=\"radio.fem.rxgain\"]");
var rxRow=rx&&rx.closest(".sw");
if(rxRow)rxRow.classList.toggle("hide",!boardHas("radio.fem.rxgain"));
var tx=$("[data-k=\"radio.fem.txgain\"]");
var txRow=tx&&tx.closest(".sw");
if(txRow)txRow.classList.toggle("hide",!boardHas("radio.fem.txgain"));
}
// Per-slot keys, expanded across the slots this board actually runs.
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html><head><meta name="viewport" content="width=device-width"><title>MeshCore OTA</title>
<style>body{font-family:sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}button,input{font-size:1rem;margin:.5rem 0}pre{white-space:pre-wrap}</style></head>
<body><h2>MeshCore firmware update</h2><input id="file" type="file" accept=".bin,application/octet-stream"><br>
<button id="upload">Upload and reboot</button><pre id="status"></pre><script>
const f=document.getElementById('file'),s=document.getElementById('status'),b=document.getElementById('upload');
b.onclick=()=>{if(!f.files.length){s.textContent='Select a firmware .bin file.';return}b.disabled=true;
const x=new XMLHttpRequest();x.open('POST','/update');x.setRequestHeader('Content-Type','application/octet-stream');
x.upload.onprogress=e=>{if(e.lengthComputable)s.textContent='Uploading '+Math.round(e.loaded*100/e.total)+'%'};
x.onload=()=>{s.textContent=x.responseText;b.disabled=false};x.onerror=()=>{s.textContent='Upload failed';b.disabled=false};x.send(f.files[0])};
</script></body></html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeshCore WiFi setup</title><style>
body{font-family:sans-serif;max-width:28rem;margin:2rem auto;padding:0 1rem;color:#222}
label{display:block;margin-top:1rem}input,button{box-sizing:border-box;width:100%;font-size:1rem;padding:.7rem;margin-top:.35rem}
button{margin-top:1.2rem}small{color:#555}</style></head><body>
<h2>MeshCore WiFi setup</h2><p>Enter the WiFi network this node should join.</p>
<form method="post" action="/save">
<label>SSID<input name="ssid" maxlength="31" required autocomplete="off"></label>
<label>Password<input name="password" type="password" maxlength="64" autocomplete="off"></label>
<small>Leave blank for an open network. A 64-character key must be hexadecimal.</small>
<button type="submit">Save and connect</button></form></body></html>