diff --git a/.changes/ap-name-and-password.md b/.changes/ap-name-and-password.md new file mode 100644 index 00000000..a0920db0 --- /dev/null +++ b/.changes/ap-name-and-password.md @@ -0,0 +1,15 @@ +--- +kind: added +--- +Set the captive portal's network name and password, protected by default + +## Release Note +The captive portal's WiFi network can now have its own name and a WPA2/WPA3 password, set in the captive portal's new Access Point section or over serial with `ap ssid` and `ap password` + +The portal's network is now protected by default with the password `openshock`, including on hubs that update from an older version. It is public, so it only keeps out passers-by. Phones and computers that support WPA3 get a connection that someone listening nearby can't decrypt even with the password, but anyone who knows it can set up a fake network with the same name. Set your own password or open the network with `ap open`. A factory reset restores the default password, so a forgotten one can always be recovered over USB. + +- Use the stored `wifi.accessPointSSID` for the access point; it was saved but never applied. The default name still gets the hub's MAC appended +- Add `wifi.accessPointPassword` (NVS key `ap_pass` in `wifi.cfg`, exported in jsonconfig only with sensitive data): defaulting to the new Kconfig option `OPENSHOCK_FW_AP_PASSWORD` (`openshock`, checked at compile time), empty for an open network, else 8-63 printable ASCII characters, with WPA2/WPA3-PSK transition mode (WPA3 clients use SAE, WPA2 clients still connect) +- Add the `ap` serial command: `ap` shows the name and whether it is protected, `ap ssid `, `ap password ` and `ap open` +- Add `PUT /api/ap` to the captive portal API (form body: `ssid`, empty for the default; `protection` `default`, `open` or `custom` with `password`; answers with the saved `ssid` and `protection`), and the access point's name and protection, never its password, to the portal's Ready message +- Changes apply the next time the captive portal starts diff --git a/components/captive_portal/src/captiveportal/ApiHandlers.cpp b/components/captive_portal/src/captiveportal/ApiHandlers.cpp index 2b2aca29..50bf8ccc 100644 --- a/components/captive_portal/src/captiveportal/ApiHandlers.cpp +++ b/components/captive_portal/src/captiveportal/ApiHandlers.cpp @@ -19,6 +19,9 @@ const char* const TAG = "CaptivePortalApi"; #include "OtaUpdateConfig.h" #include "RateLimiter.h" #include "util/TaskUtils.h" +#include "wifi/AccessPoint.h" +#include "wifi/WiFiConfig.h" +#include "wifi/WiFiConstants.h" #include "wifi/WiFiManager.h" #include "wifi/WiFiScanManager.h" @@ -43,6 +46,8 @@ static const char* const JSON_ERR_PASSWORD_LONG = "{\"error\":\"PasswordTooLon static const char* const JSON_ERR_CODE_REQUIRED = "{\"error\":\"CodeRequired\"}"; static const char* const JSON_ERR_INVALID_CHANNEL = "{\"error\":\"InvalidChannel\"}"; static const char* const JSON_ERR_RATE_LIMITED = "{\"error\":\"RateLimited\"}"; +static const char* const JSON_ERR_INVALID_AP_SSID = "{\"error\":\"InvalidApSsid\"}"; +static const char* const JSON_ERR_INVALID_AP_PASS = "{\"error\":\"InvalidApPassword\"}"; using namespace OpenShock; using namespace OpenShock::CaptivePortal; @@ -227,7 +232,7 @@ static esp_err_t apiWifiNetworksAdd(httpd_req_t* req) if (!ssid && ssid.error() == ParamError::Invalid) { return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_SSID); } - if (!ssid || ssid->empty() || ssid->length() > 32) { + if (!ssid || ssid->empty() || ssid->length() > WiFiConstants::kSsidMaxLength) { return sendResp(req, S400, HTTP::ContentType::JSON, !ssid || ssid->empty() ? JSON_ERR_MISSING_SSID : JSON_ERR_INVALID_SSID); } @@ -238,10 +243,10 @@ static esp_err_t apiWifiNetworksAdd(httpd_req_t* req) } std::string password = std::move(passwordParam).value_or(std::string()); if (!password.empty()) { - if (password.length() < 8) { + if (password.length() < WiFiConstants::kPassphraseMinLength) { return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_SHORT); } - if (password.length() > 63) { + if (password.length() > WiFiConstants::kPassphraseMaxLength) { return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_LONG); } } @@ -385,6 +390,121 @@ static esp_err_t apiOtaCheck(httpd_req_t* req) return sendResp(req, S200, nullptr, {}); } +// PUT /api/ap, form body: any of +// ssid= the access point's name; empty for the default (the prefix with the hub's MAC) +// protection=default|open|custom the build's default password, none, or `password` +// password=<8-63 printable ASCII> with protection=custom +// Applied the next time the captive portal starts: applying it now would drop the client that asked, which is most +// likely on this access point. +// +// Answers {"ssid": , "protection": "default"|"open"|"custom"} as the hub reads the saved +// settings, since a name equal to the bare prefix or a password equal to the build's default are the defaults. +static esp_err_t sendAccessPointSettings(httpd_req_t* req) +{ + std::string ssid = Config::GetWiFiAccessPointSSID(); + std::string password = Config::GetWiFiAccessPointPassword(); + + const char* protection = "custom"; + switch (AccessPoint::ProtectionOf(password)) { + case AccessPoint::Protection::Open: + protection = "open"; + break; + case AccessPoint::Protection::DefaultPassword: + protection = "default"; + break; + case AccessPoint::Protection::CustomPassword: + break; + } + + OpenShock::JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + OpenShock::JSON::objSetString(gen, "ssid", AccessPoint::IsDefaultSSID(ssid) ? std::string_view() : std::string_view(ssid)); + OpenShock::JSON::objSetString(gen, "protection", protection); + json_gen_end_object(gen); + std::string json = writer.finish(); + return sendResp(req, S200, HTTP::ContentType::JSON, json); +} + +static esp_err_t apiAccessPoint(httpd_req_t* req) +{ + auto body = recvBody(req); + if (!body) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); + } + + bool resetSsid = false; + std::optional ssid; + auto ssidParam = getFormParam(*body, "ssid"); + if (!ssidParam && ssidParam.error() != ParamError::Missing) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_AP_SSID); + } + if (ssidParam) { + if (ssidParam->empty()) { + resetSsid = true; + } else if (!AccessPoint::IsValidSSID(*ssidParam)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_AP_SSID); + } else { + ssid = std::move(*ssidParam); + } + } + + bool resetPassword = false; + std::optional password; + auto protection = getFormParam(*body, "protection"); + if (!protection && protection.error() != ParamError::Missing) { + return sendParamError(req, protection.error()); + } + if (protection) { + if (*protection == "default") { + resetPassword = true; + } else if (*protection == "open") { + password = std::string(); + } else if (*protection == "custom") { + auto passwordParam = getFormParam(*body, "password"); + if (!passwordParam) { + return sendResp(req, S400, HTTP::ContentType::JSON, passwordParam.error() == ParamError::Missing ? JSON_ERR_PASSWORD_SHORT : JSON_ERR_INVALID_AP_PASS); + } + if (passwordParam->length() < WiFiConstants::kPassphraseMinLength) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_SHORT); + } + if (passwordParam->length() > WiFiConstants::kPassphraseMaxLength) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_LONG); + } + if (!AccessPoint::IsValidPassword(*passwordParam)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_AP_PASS); + } + password = std::move(*passwordParam); + } else { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_PARAM); + } + } + + if (!resetSsid && !ssid && !resetPassword && !password) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); + } + + // Both passed the checks above, so a refusal here is the config store failing to save + bool saved = true; + if (resetSsid) { + saved = Config::ResetWiFiAccessPointSSID(); + } else if (ssid) { + saved = Config::SetWiFiAccessPointSSID(std::move(*ssid)); + } + if (!saved) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + if (resetPassword) { + saved = Config::ResetWiFiAccessPointPassword(); + } else if (password) { + saved = Config::SetWiFiAccessPointPassword(std::move(*password)); + } + if (!saved) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendAccessPointSettings(req); +} + void CaptivePortal::RegisterApiHandlers(httpd_handle_t server, CaptivePortalInstance* instance) { registerHandler(server, "/api/board", HTTP_GET, instance); @@ -402,4 +522,5 @@ void CaptivePortal::RegisterApiHandlers(httpd_handle_t server, CaptivePortalInst // changing it would let anyone in range install firmware. It can only be changed over serial (jsonconfig). registerHandler(server, "/api/ota/settings", HTTP_PUT, instance); registerHandler(server, "/api/ota/check", HTTP_POST, instance); + registerHandler(server, "/api/ap", HTTP_PUT, instance); } diff --git a/components/captive_portal/src/captiveportal/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp index f499ca98..9335cfba 100644 --- a/components/captive_portal/src/captiveportal/Manager.cpp +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -11,14 +11,13 @@ const char* const TAG = "CaptivePortal"; #include "captiveportal/CaptivePortalConfig.h" #include "captiveportal/CaptivePortalInstance.h" #include "CommandHandler.h" -#include "FormatHelpers.h" #include "GatewayConnectionManager.h" #include "Logging.h" #include "Temporal.h" +#include "wifi/AccessPoint.h" #include "wifi/WiFiConfig.h" #include -#include #include #include #include @@ -29,6 +28,7 @@ const char* const TAG = "CaptivePortal"; #include "util/RetiredList.h" #include "util/TaskUtils.h" +#include #include #include #include @@ -82,6 +82,15 @@ static SemaphoreHandle_t s_apStarted = nullptr; static char s_apIfName[8] = {}; static constexpr TickType_t kApStartTimeout = pdMS_TO_TICKS(2000); +// A marker posted to the default event loop: once its handler runs, every event posted before it has been handled +ESP_EVENT_DEFINE_BASE(CAPTIVEPORTAL_PRIVATE_EVENTS); +enum { + CAPTIVEPORTAL_PRIVATE_EVENT_LOOP_FLUSH, +}; +static SemaphoreHandle_t s_loopFlushed = nullptr; +static std::atomic s_loopFlushSeen {0}; // the latest marker the loop has handled +static uint32_t s_loopFlushPosted = 0; // markers posted so far; manager task only + static_assert(IPv4Utils::IsSlash24Host(CONFIG_OPENSHOCK_PORTAL_AP_IP), "CONFIG_OPENSHOCK_PORTAL_AP_IP must be a dotted-decimal IPv4 address with a host octet of 1-254"); static void sendCommand(uint32_t command) @@ -153,6 +162,59 @@ static void captiveportal_onapstart(void*, esp_event_base_t, int32_t, void*) xSemaphoreGive(s_apStarted); } +static void captiveportal_onloopflush(void*, esp_event_base_t, int32_t, void* data) +{ + s_loopFlushSeen.store(*static_cast(data)); + xSemaphoreGive(s_loopFlushed); +} + +// Waits until the default event loop has handled everything posted to it before this call +static bool waitForEventLoop(TickType_t timeout) +{ + // Each call posts its own marker and waits for that one: a marker left over from a call that timed out can still be + // handled later, and must not pass for this call's. + uint32_t marker = ++s_loopFlushPosted; + xSemaphoreTake(s_loopFlushed, 0); + if (esp_event_post(CAPTIVEPORTAL_PRIVATE_EVENTS, CAPTIVEPORTAL_PRIVATE_EVENT_LOOP_FLUSH, &marker, sizeof(marker), timeout) != ESP_OK) { + return false; + } + + TickType_t deadline = xTaskGetTickCount() + timeout; + while (static_cast(s_loopFlushSeen.load() - marker) < 0) { + TickType_t remaining = deadline - xTaskGetTickCount(); + if (static_cast(remaining) <= 0 || xSemaphoreTake(s_loopFlushed, remaining) != pdTRUE) { + return false; + } + } + return true; +} + +// The settings are validated by the wifi section (Normalize), so they fit the driver's fields +static wifi_config_t buildApConfig() +{ + const std::string ssid = AccessPoint::BroadcastSSID(Config::GetWiFiAccessPointSSID()); + const std::string password = Config::GetWiFiAccessPointPassword(); + + wifi_config_t apConfig = {}; + apConfig.ap.ssid_len = std::min(ssid.size(), sizeof(apConfig.ap.ssid)); + memcpy(apConfig.ap.ssid, ssid.data(), apConfig.ap.ssid_len); + apConfig.ap.channel = 1; + if (password.empty()) { + apConfig.ap.authmode = WIFI_AUTH_OPEN; + } else { + // WPA3 clients use SAE, which a sniffer that knows the password still can't decrypt; WPA2 clients keep working. + // PMF stays optional because WPA2 clients may lack it, and the zeroed config would otherwise pick hunt-and-peck only. + apConfig.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK; + apConfig.ap.pmf_cfg.required = false; + apConfig.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH; + std::size_t passwordLen = std::min(password.size(), sizeof(apConfig.ap.password) - 1); + memcpy(apConfig.ap.password, password.data(), passwordLen); + } + apConfig.ap.max_connection = CONFIG_OPENSHOCK_PORTAL_MAX_CLIENTS; + apConfig.ap.beacon_interval = 100; + return apConfig; +} + static bool captiveportal_start() { if (GetInstance() != nullptr) { @@ -196,27 +258,19 @@ static bool captiveportal_start() static bool apStartHandlerRegistered = false; if (!apStartHandlerRegistered) { esp_err_t evtErr = esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_START, captiveportal_onapstart, nullptr); + if (evtErr == ESP_OK) { + evtErr = esp_event_handler_register(CAPTIVEPORTAL_PRIVATE_EVENTS, CAPTIVEPORTAL_PRIVATE_EVENT_LOOP_FLUSH, captiveportal_onloopflush, nullptr); + } if (evtErr != ESP_OK) { - OS_LOGE(TAG, "Failed to register the AP start handler: %s", esp_err_to_name(evtErr)); + OS_LOGE(TAG, "Failed to register the AP start handlers: %s", esp_err_to_name(evtErr)); return false; } apStartHandlerRegistered = true; } - // AP SSID = prefix + this device's STA MAC (matches the old Arduino naming). - uint8_t mac[6]; - esp_read_mac(mac, ESP_MAC_WIFI_STA); - - wifi_config_t apConfig = {}; - snprintf(reinterpret_cast(apConfig.ap.ssid), sizeof(apConfig.ap.ssid), "%s" BSSID_FMT, CONFIG_OPENSHOCK_FW_AP_PREFIX, BSSID_ARG(mac)); - apConfig.ap.ssid_len = strlen(reinterpret_cast(apConfig.ap.ssid)); - apConfig.ap.channel = 1; - apConfig.ap.authmode = WIFI_AUTH_OPEN; - apConfig.ap.max_connection = CONFIG_OPENSHOCK_PORTAL_MAX_CLIENTS; - apConfig.ap.beacon_interval = 100; + wifi_config_t apConfig = buildApConfig(); s_apIfName[0] = '\0'; - xSemaphoreTake(s_apStarted, 0); // drop a start left over from an earlier run esp_err_t err = esp_wifi_set_mode(WIFI_MODE_APSTA); if (err != ESP_OK) { @@ -233,10 +287,21 @@ static bool captiveportal_start() // The servers bind to the AP's lwIP interface, which only exists once the AP has started. Reading its name earlier // returns another interface's (the STA's), and would put the portal on the LAN. - if (!esp_netif_is_netif_up(s_apNetif) && xSemaphoreTake(s_apStarted, kApStartTimeout) != pdTRUE) { - OS_LOGE(TAG, "AP did not start"); - esp_wifi_set_mode(WIFI_MODE_STA); - return false; + // + // Enabling the AP starts it with the config it had, and setting a different config (a new name or password, or the + // first start after boot) restarts it: stopped, its netif removed from lwIP, then started again under a new name. + // Both starts are queued on the event loop by now. Reading the name between them gets nothing (the netif is gone) + // or the first start's (which no longer exists), so let the loop handle them first, then take the start that stays. + if (!waitForEventLoop(kApStartTimeout)) { + OS_LOGW(TAG, "The event loop did not catch up with the AP start, the interface name may be stale"); + } + if (!esp_netif_is_netif_up(s_apNetif)) { + xSemaphoreTake(s_apStarted, 0); // a start that has since been stopped again + if (!esp_netif_is_netif_up(s_apNetif) && xSemaphoreTake(s_apStarted, kApStartTimeout) != pdTRUE) { + OS_LOGE(TAG, "AP did not start"); + esp_wifi_set_mode(WIFI_MODE_STA); + return false; + } } err = esp_netif_get_netif_impl_name(s_apNetif, s_apIfName); @@ -412,7 +477,8 @@ bool CaptivePortal::Init() s_forceCloseDone = xSemaphoreCreateBinary(); s_apStarted = xSemaphoreCreateBinary(); - if (s_forceCloseDone == nullptr || s_apStarted == nullptr) { + s_loopFlushed = xSemaphoreCreateBinary(); + if (s_forceCloseDone == nullptr || s_apStarted == nullptr || s_loopFlushed == nullptr) { OS_LOGE(TAG, "Failed to create captive portal semaphores"); delete initialState; return false; diff --git a/components/common/Kconfig b/components/common/Kconfig index a2f947e2..28f56a66 100644 --- a/components/common/Kconfig +++ b/components/common/Kconfig @@ -38,6 +38,15 @@ menu "OpenShock defaults" help Prefix for the captive-portal SoftAP SSID (the device MAC is appended). + config OPENSHOCK_FW_AP_PASSWORD + string "Default SoftAP password" + default "openshock" + help + Default password for the captive-portal SoftAP, which runs WPA2/WPA3 transition mode. + It is public, but WPA3 (SAE) clients still get traffic a sniffer can't decrypt with it. + Empty for an open network, else 8-63 characters (checked at compile time). + Overridable at runtime; a factory reset restores it. + endmenu # Every OpenShock task's stack and priority, in one place so the priority order between them diff --git a/components/common/host_test/main/test_stringutils.cpp b/components/common/host_test/main/test_stringutils.cpp index e241133e..bc69624a 100644 --- a/components/common/host_test/main/test_stringutils.cpp +++ b/components/common/host_test/main/test_stringutils.cpp @@ -17,6 +17,7 @@ TEST_CASE("StringTrim variants", "[common][strhelpers]") TEST_ASSERT_TRUE(StringTrim(" ") == ""); TEST_ASSERT_TRUE(StringTrim("nospace") == "nospace"); TEST_ASSERT_TRUE(StringTrim("\t\n x \r\n") == "x"); + static_assert(StringTrim(" x ") == "x"); } TEST_CASE("StringHasPrefix / HasSuffix", "[common][strhelpers]") @@ -81,6 +82,17 @@ TEST_CASE("StringIContains / StringHasPrefixIC", "[common][strhelpers]") TEST_ASSERT_FALSE(StringHasPrefixIC("hello", "world")); } +TEST_CASE("StringIsPrintableAscii", "[common][strhelpers]") +{ + TEST_ASSERT_TRUE(StringIsPrintableAscii("")); + TEST_ASSERT_TRUE(StringIsPrintableAscii(" correct horse ~battery!")); + TEST_ASSERT_FALSE(StringIsPrintableAscii("tab\there")); + TEST_ASSERT_FALSE(StringIsPrintableAscii(std::string_view("nul\0byte", 8))); + TEST_ASSERT_FALSE(StringIsPrintableAscii("del\x7F")); + TEST_ASSERT_FALSE(StringIsPrintableAscii("caf\xC3\xA9")); // UTF-8, so bytes >= 0x80 on a signed char + static_assert(StringIsPrintableAscii("abc")); +} + TEST_CASE("StringTrimRight on all-whitespace input", "[util][string]") { TEST_ASSERT_TRUE(StringTrimRight(" ").empty()); diff --git a/components/common/include/StringHelpers.h b/components/common/include/StringHelpers.h index 2db04362..dcb8bd7c 100644 --- a/components/common/include/StringHelpers.h +++ b/components/common/include/StringHelpers.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -20,28 +21,30 @@ namespace OpenShock { { return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } + constexpr bool CharIsPrint(char c) + { + return c >= ' ' && c <= '~'; + } constexpr char CharToLower(char c) { return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; } - constexpr std::string_view StringTrimLeft(std::string_view view) + // Whether every byte is printable ASCII (space to '~'): no control characters, no NUL, no UTF-8. + constexpr bool StringIsPrintableAscii(std::string_view view) { - std::size_t pos = 0; - while (pos < view.size() && CharIsSpace(view[pos])) { - ++pos; - } + return std::ranges::all_of(view, CharIsPrint); + } - return view.substr(pos); + constexpr std::string_view StringTrimLeft(std::string_view view) + { + auto first = std::ranges::find_if_not(view, CharIsSpace); + return view.substr(static_cast(first - view.begin())); } constexpr std::string_view StringTrimRight(std::string_view view) { - std::size_t len = view.size(); - while (len > 0 && CharIsSpace(view[len - 1])) { - --len; - } - - return view.substr(0, len); + auto last = std::ranges::find_if_not(view.rbegin(), view.rend(), CharIsSpace); + return view.substr(0, static_cast(last.base() - view.begin())); } constexpr std::string_view StringTrim(std::string_view view) { diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index b4b96b33..8908ef93 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -29,7 +29,7 @@ namespace ConfigTest { home.bssid = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; Config::WiFiCredentials guest(2, "Guest", "", WIFI_AUTH_OPEN); Config::WiFiCredentials office(7, "Office \"5G\"", "p@ss w0rd/\\", WIFI_AUTH_WPA3_PSK); - config.wifi = Config::WiFiConfig("MyHubAP", "my-hub", {home, guest, office}); + config.wifi = Config::WiFiConfig("MyHubAP", "ap-s3cret!", "my-hub", {home, guest, office}); config.captivePortal = Config::CaptivePortalConfig(true); config.backend = Config::BackendConfig("api.example.org", "tok_0123456789abcdef"); @@ -107,6 +107,7 @@ namespace ConfigTest { TEST_ASSERT_EQUAL_MESSAGE(expected.rf.keepAliveEnabled, actual.rf.keepAliveEnabled, "rf.keepAliveEnabled"); TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.wifi.accessPointSSID.c_str(), actual.wifi.accessPointSSID.c_str(), "wifi.accessPointSSID"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(withSensitiveData ? expected.wifi.accessPointPassword.c_str() : "", actual.wifi.accessPointPassword.c_str(), "wifi.accessPointPassword"); TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.wifi.hostname.c_str(), actual.wifi.hostname.c_str(), "wifi.hostname"); TEST_ASSERT_EQUAL_size_t_MESSAGE(expected.wifi.credentialsList.size(), actual.wifi.credentialsList.size(), "wifi.credentialsList size"); for (std::size_t i = 0; i < expected.wifi.credentialsList.size() && i < actual.wifi.credentialsList.size(); ++i) { diff --git a/components/config/host_test/main/test_corrupt.cpp b/components/config/host_test/main/test_corrupt.cpp index c9af874d..c80a1541 100644 --- a/components/config/host_test/main/test_corrupt.cpp +++ b/components/config/host_test/main/test_corrupt.cpp @@ -119,6 +119,8 @@ TEST_CASE("jsonconfig set rejects malformed JSON without touching the store", "[ "{\"wifi\":{\"credentials\":[{\"id\":1,\"ssid\":\"x\"}]", "{\"estop\":{\"enabled\":\"yes\"}}", "{\"captivePortal\":[]}", + "{\"wifi\":{\"accessPointPassword\":\"1234567\",\"credentials\":[]}}", + "{\"wifi\":{\"accessPointPassword\":\"caf\\u00e9-latte\",\"credentials\":[]}}", }; InitWith(MakeSampleConfig()); diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 10bf08b6..8d7c4be2 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -7,6 +7,7 @@ #include "config_test_helpers.h" #include "BoardDescriptor.h" +#include "wifi/AccessPoint.h" #include @@ -40,6 +41,7 @@ TEST_CASE("Default values come from Kconfig and the board descriptor", "[config] TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, config.rf.txPin); TEST_ASSERT_TRUE(config.rf.keepAliveEnabled); TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PREFIX, config.wifi.accessPointSSID.c_str()); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PASSWORD, config.wifi.accessPointPassword.c_str()); TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_HOSTNAME, config.wifi.hostname.c_str()); TEST_ASSERT_TRUE(config.wifi.credentialsList.empty()); TEST_ASSERT_FALSE(config.captivePortal.alwaysEnabled); @@ -257,3 +259,66 @@ TEST_CASE("jsonconfig set requires the wifi credentials array when wifi is given TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"wifi":{"hostname":"my-hub","credentials":{}}})")); TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); } + +TEST_CASE("A stored access point password WPA2 can't use falls back to the default", "[config][defaults]") +{ + WipeStore(); + { + auto wifi = Open(Keys::Ns::kWifiCfg); + TEST_ASSERT_TRUE(wifi.SetStr("ap_ssid", "MyHubAP")); + TEST_ASSERT_TRUE(wifi.SetStr("ap_pass", "short")); // under WPA2's 8 characters + TEST_ASSERT_TRUE(wifi.Commit()); + } + Reload(); + + TEST_ASSERT_EQUAL_STRING("MyHubAP", Config::GetWiFiAccessPointSSID().c_str()); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PASSWORD, Config::GetWiFiAccessPointPassword().c_str()); +} + +TEST_CASE("An open access point stays open across a reload", "[config][defaults]") +{ + WipeStore(); + { + auto wifi = Open(Keys::Ns::kWifiCfg); + TEST_ASSERT_TRUE(wifi.SetStr("ap_pass", "")); // stored empty, not missing + TEST_ASSERT_TRUE(wifi.Commit()); + } + Reload(); + + TEST_ASSERT_TRUE(Config::GetWiFiAccessPointPassword().empty()); +} + +TEST_CASE("A stored access point password that is not printable ASCII falls back to the default", "[config][defaults]") +{ + // The driver reads the password as a C string, so a NUL would cut it short. + WipeStore(); + { + auto wifi = Open(Keys::Ns::kWifiCfg); + TEST_ASSERT_TRUE(wifi.SetStr("ap_pass", "pass\tword")); + TEST_ASSERT_TRUE(wifi.Commit()); + } + Reload(); + + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PASSWORD, Config::GetWiFiAccessPointPassword().c_str()); + TEST_ASSERT_FALSE(AccessPoint::IsValidPassword(std::string("abc\0defgh", 9))); + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointPassword("caf\xC3\xA9-latte")); + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("correct horse battery")); +} + +TEST_CASE("A stored access point SSID with '|' falls back to the default", "[config][defaults]") +{ + // `ap` prints the SSID as a '|'-separated field. + WipeStore(); + { + auto wifi = Open(Keys::Ns::kWifiCfg); + TEST_ASSERT_TRUE(wifi.SetStr("ap_ssid", "My|Hub")); + TEST_ASSERT_TRUE(wifi.Commit()); + } + Reload(); + + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PREFIX, Config::GetWiFiAccessPointSSID().c_str()); + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointSSID("My|Hub")); + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointSSID("My\r\nHub")); + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointSSID("My\tHub")); + TEST_ASSERT_TRUE(AccessPoint::IsValidSSID("Caf\xC3\xA9 Hub")); +} diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 729352e5..a435695c 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -31,6 +31,8 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt const uint8_t bssid[6] = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; TEST_ASSERT_TRUE(Config::PinWiFiCredentialsBSSID(1, bssid)); TEST_ASSERT_TRUE(Config::SetWiFiHostname("my-hub")); + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointSSID("MyHubAP")); + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("ap-s3cret!")); TEST_ASSERT_TRUE(Config::SetCaptivePortalConfig(Config::CaptivePortalConfig(true))); TEST_ASSERT_TRUE(Config::SetBackendDomain("api.example.org")); TEST_ASSERT_TRUE(Config::SetBackendAuthToken("tok_0123456789abcdef")); @@ -46,6 +48,8 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt Reload(); AssertConfigEqual(before, Snapshot()); + TEST_ASSERT_EQUAL_STRING("MyHubAP", Config::GetWiFiAccessPointSSID().c_str()); + TEST_ASSERT_EQUAL_STRING("ap-s3cret!", Config::GetWiFiAccessPointPassword().c_str()); TEST_ASSERT_TRUE(Config::HasBackendAuthToken()); TEST_ASSERT_EQUAL_STRING("tok_0123456789abcdef", Config::GetBackendAuthToken().c_str()); @@ -266,9 +270,12 @@ TEST_CASE("GetAsJSON without sensitive data omits secrets", "[config][roundtrip] TEST_ASSERT_NOT_EQUAL(std::string::npos, full.find("hunter22")); TEST_ASSERT_NOT_EQUAL(std::string::npos, full.find("tok_0123456789abcdef")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, full.find("ap-s3cret!")); TEST_ASSERT_EQUAL(std::string::npos, redacted.find("hunter22")); TEST_ASSERT_EQUAL(std::string::npos, redacted.find("password")); TEST_ASSERT_EQUAL(std::string::npos, redacted.find("authToken")); + TEST_ASSERT_EQUAL(std::string::npos, redacted.find("accessPointPassword")); + TEST_ASSERT_EQUAL(std::string::npos, redacted.find("ap-s3cret!")); TEST_ASSERT_NOT_EQUAL(std::string::npos, redacted.find("\"HomeNet\"")); TEST_ASSERT_NOT_EQUAL(std::string::npos, redacted.find("\"api.example.org\"")); } @@ -319,3 +326,34 @@ TEST_CASE("Factory reset wipes every setting and survives a reload", "[config][r Reload(); AssertConfigEqual(Full(), Snapshot()); } + +TEST_CASE("Access point setters refuse what WPA2 can't use", "[config][roundtrip]") +{ + InitDefault(); + + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointSSID("")); + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointSSID(std::string(33, 'a'))); + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointPassword("1234567")); + TEST_ASSERT_FALSE(Config::SetWiFiAccessPointPassword(std::string(64, 'a'))); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PREFIX, Config::GetWiFiAccessPointSSID().c_str()); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PASSWORD, Config::GetWiFiAccessPointPassword().c_str()); + + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("12345678")); + TEST_ASSERT_EQUAL_STRING("12345678", Config::GetWiFiAccessPointPassword().c_str()); + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("")); // back to an open access point + Reload(); + TEST_ASSERT_TRUE(Config::GetWiFiAccessPointPassword().empty()); +} + +TEST_CASE("Access point resets restore the build defaults", "[config][roundtrip]") +{ + InitDefault(); + + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointSSID("MyHubAP")); + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("ap-s3cret!")); + TEST_ASSERT_TRUE(Config::ResetWiFiAccessPointSSID()); + TEST_ASSERT_TRUE(Config::ResetWiFiAccessPointPassword()); + Reload(); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PREFIX, Config::GetWiFiAccessPointSSID().c_str()); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PASSWORD, Config::GetWiFiAccessPointPassword().c_str()); +} diff --git a/components/core/include/wifi/AccessPoint.h b/components/core/include/wifi/AccessPoint.h new file mode 100644 index 00000000..daf2a029 --- /dev/null +++ b/components/core/include/wifi/AccessPoint.h @@ -0,0 +1,44 @@ +#pragma once + +// The captive portal's access point as the wifi config describes it, read one way by everything that shows or starts +// it (the portal, the serial console's `ap`, the Ready message): +// - the default name is stored as the bare CONFIG_OPENSHOCK_FW_AP_PREFIX and broadcast with the hub's MAC appended +// - an empty password is an open network, CONFIG_OPENSHOCK_FW_AP_PASSWORD is the build's default (which a factory +// reset restores), anything else is the user's own + +#include "StringHelpers.h" +#include "wifi/WiFiConstants.h" + +#include +#include +#include +#include + +namespace OpenShock::AccessPoint { + enum class Protection : uint8_t { + Open = 0, + DefaultPassword = 1, + CustomPassword = 2, + }; + + /// @brief Whether `ssid` can name the access point: 1-32 bytes, no '|' or control characters. The serial console's + /// `ap` prints it as a field of a CRLF-framed record, so either would split the response. + [[nodiscard]] constexpr bool IsValidSSID(std::string_view ssid) + { + return !ssid.empty() && ssid.size() <= WiFiConstants::kSsidMaxLength && std::ranges::none_of(ssid, [](char c) { return c == '|' || static_cast(c) < 0x20 || c == 0x7F; }); + } + /// @brief Whether `password` can protect it: empty (open), or 8-63 printable ASCII characters for WPA2/WPA3. + /// The driver reads it as a C string, so a NUL would cut it short. + [[nodiscard]] constexpr bool IsValidPassword(std::string_view password) + { + return password.empty() || (password.size() >= WiFiConstants::kPassphraseMinLength && password.size() <= WiFiConstants::kPassphraseMaxLength && StringIsPrintableAscii(password)); + } + + [[nodiscard]] bool IsDefaultSSID(std::string_view configuredSsid); + [[nodiscard]] Protection ProtectionOf(std::string_view password); + + /// @brief The default name: the prefix with this hub's STA MAC, so hubs side by side stay apart + [[nodiscard]] std::string DefaultSSID(); + /// @brief The name the access point broadcasts for a configured SSID: itself, or DefaultSSID() for the default + [[nodiscard]] std::string BroadcastSSID(std::string_view configuredSsid); +} // namespace OpenShock::AccessPoint diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index 41de6d4a..7c096033 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -1,8 +1,8 @@ #pragma once -// The `wifi` config section: the soft-AP SSID, the hostname and the saved networks. Owned by core (WiFiManager) and -// kept in NVS through the config framework (config/Section.h): -// namespace "wifi.cfg": ap_ssid (str), hostname (str) +// The `wifi` config section: the soft-AP SSID and password, the hostname and the saved networks. Owned by core +// (WiFiManager) and kept in NVS through the config framework (config/Section.h): +// namespace "wifi.cfg": ap_ssid (str), ap_pass (str), hostname (str) // namespace "wifi": one blob per credential, key = its id in decimal ("1".."255"); enumerated, never counted #include "config/Section.h" @@ -19,14 +19,21 @@ namespace OpenShock::Config { struct WiFiConfig { WiFiConfig(); // The defaults: the one place they are defined WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList); + WiFiConfig(std::string_view accessPointSSID, std::string_view accessPointPassword, std::string_view hostname, const std::vector& credentialsList); + // The captive portal's network name. The default (CONFIG_OPENSHOCK_FW_AP_PREFIX) gets the hub's MAC appended, so + // hubs side by side stay apart; any other name is used as it is. std::string accessPointSSID; + // Empty: an open network. Otherwise its WPA2/WPA3 passphrase, 8-63 printable ASCII characters. + // Defaults to CONFIG_OPENSHOCK_FW_AP_PASSWORD, which a factory reset restores. + std::string accessPointPassword; std::string hostname; std::vector credentialsList; // in id order once loaded from NVS /// @brief The section runs it on every value it takes in: a hostname esp_netif would refuse (empty, over 32 - /// characters, or not letters, digits and hyphens) becomes the default, and a credential with no id (0) or - /// a repeated one gets the lowest free id (or is dropped when none is left), whichever way it came in. + /// characters, or not letters, digits and hyphens) becomes the default, as does an AP SSID that is empty or + /// over 32 bytes; so does an AP password WPA2 can't use; and a credential with no id (0) or a + /// repeated one gets the lowest free id (or is dropped when none is left), whichever way it came in. void Normalize(); bool LoadNvs(const char* partition); @@ -59,6 +66,17 @@ namespace OpenShock::Config { bool PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]); bool RemoveWiFiCredentials(uint8_t id); bool ClearWiFiCredentials(); + // The access point settings take effect the next time the captive portal starts. + [[nodiscard]] std::string GetWiFiAccessPointSSID(); + /// @brief Refuses an SSID AccessPoint::IsValidSSID rejects. + bool SetWiFiAccessPointSSID(std::string ssid); + /// @brief Back to the default name (CONFIG_OPENSHOCK_FW_AP_PREFIX, broadcast with the hub's MAC appended). + bool ResetWiFiAccessPointSSID(); + [[nodiscard]] std::string GetWiFiAccessPointPassword(); + /// @brief Refuses a password AccessPoint::IsValidPassword rejects. Empty makes the access point open. + bool SetWiFiAccessPointPassword(std::string password); + /// @brief Back to the build's default password (CONFIG_OPENSHOCK_FW_AP_PASSWORD), as a factory reset does. + bool ResetWiFiAccessPointPassword(); [[nodiscard]] std::string GetWiFiHostname(); bool SetWiFiHostname(std::string hostname); } // namespace OpenShock::Config diff --git a/components/core/include/wifi/WiFiConstants.h b/components/core/include/wifi/WiFiConstants.h new file mode 100644 index 00000000..5ac62b90 --- /dev/null +++ b/components/core/include/wifi/WiFiConstants.h @@ -0,0 +1,15 @@ +#pragma once + +// The limits 802.11 and WPA put on what the wifi module stores and sends to the driver + +#include + +#include + +namespace OpenShock::WiFiConstants { + inline constexpr std::size_t kSsidMaxLength = MAX_SSID_LEN; // 32 bytes + inline constexpr std::size_t kPassphraseMinLength = 8; // A WPA2/WPA3 passphrase + inline constexpr std::size_t kPassphraseMaxLength = 63; // One more is a raw PSK written as hex + inline constexpr std::size_t kPskHexLength = MAX_PASSPHRASE_LEN; // 64 + inline constexpr std::size_t kMacStringLength = sizeof("AA:BB:CC:DD:EE:FF") - 1; +} // namespace OpenShock::WiFiConstants diff --git a/components/core/include/wifi/WiFiNetwork.h b/components/core/include/wifi/WiFiNetwork.h index 81ae3d1b..00bd5913 100644 --- a/components/core/include/wifi/WiFiNetwork.h +++ b/components/core/include/wifi/WiFiNetwork.h @@ -1,5 +1,7 @@ #pragma once +#include "wifi/WiFiConstants.h" + #include #include @@ -9,13 +11,13 @@ namespace OpenShock { struct WiFiNetwork { WiFiNetwork(); WiFiNetwork(const wifi_ap_record_t* apRecord, uint8_t credentialsId); - WiFiNetwork(const char (&ssid)[33], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId); - WiFiNetwork(const uint8_t (&ssid)[33], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId); + WiFiNetwork(const char (&ssid)[WiFiConstants::kSsidMaxLength + 1], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId); + WiFiNetwork(const uint8_t (&ssid)[WiFiConstants::kSsidMaxLength + 1], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId); std::array GetHexBSSID() const; bool IsSaved() const; - char ssid[33]; + char ssid[WiFiConstants::kSsidMaxLength + 1]; uint8_t bssid[6]; uint8_t channel; int8_t rssi; diff --git a/components/core/src/serialization/WSLocal.cpp b/components/core/src/serialization/WSLocal.cpp index ede4a9c6..3f7bac12 100644 --- a/components/core/src/serialization/WSLocal.cpp +++ b/components/core/src/serialization/WSLocal.cpp @@ -7,6 +7,7 @@ #include "OtaUpdateConfig.h" #include "radio/RFConfig.h" #include "util/HexUtils.h" +#include "wifi/AccessPoint.h" #include "wifi/WiFiConfig.h" #include "wifi/WiFiNetwork.h" @@ -75,6 +76,24 @@ static flatbuffers::Offset createOtaSettings(flatbuffers::Fl return Local::CreateOtaSettings(builder, ota.isEnabled, cdnDomain, static_cast(ota.updateChannel), ota.checkInterval, ota.allowBackendManagement, ota.requireManualApproval); } +// The wire enum is the core one +static_assert(static_cast(Local::AccessPointProtection::Open) == static_cast(OpenShock::AccessPoint::Protection::Open)); +static_assert(static_cast(Local::AccessPointProtection::DefaultPassword) == static_cast(OpenShock::AccessPoint::Protection::DefaultPassword)); +static_assert(static_cast(Local::AccessPointProtection::CustomPassword) == static_cast(OpenShock::AccessPoint::Protection::CustomPassword)); + +// The access point's name and whether it is protected; never its password +static flatbuffers::Offset createAccessPointSettings(flatbuffers::FlatBufferBuilder& builder) +{ + const std::string ssid = OpenShock::Config::GetWiFiAccessPointSSID(); + const std::string password = OpenShock::Config::GetWiFiAccessPointPassword(); + + // Empty for the default name, which default_ssid carries with the MAC + auto ssidOffset = builder.CreateString(OpenShock::AccessPoint::IsDefaultSSID(ssid) ? std::string_view() : std::string_view(ssid)); + auto defaultOffset = builder.CreateString(OpenShock::AccessPoint::DefaultSSID()); + auto protection = static_cast(OpenShock::AccessPoint::ProtectionOf(password)); + return Local::CreateAccessPointSettings(builder, ssidOffset, defaultOffset, protection); +} + bool Local::SerializeErrorMessage(std::string_view message, Common::SerializationCallbackFn callback) { flatbuffers::FlatBufferBuilder builder(64 + message.length()); @@ -105,12 +124,14 @@ bool Local::SerializeReadyMessage(const WiFiNetwork* connectedNetwork, bool acco OpenShock::Config::ForEachWiFiSsid([&](std::string_view ssid) { savedSsids.push_back(builder.CreateString(ssid.data(), ssid.size())); }); auto savedSsidsOffset = builder.CreateVector(savedSsids); - auto otaOffset = createOtaSettings(builder); + auto otaOffset = createOtaSettings(builder); + auto accessPointOffset = createAccessPointSettings(builder); const auto rfTxPin = static_cast(OpenShock::Config::GetRFConfigTxPin()); const auto estop = OpenShock::Config::GetEStopConfig(); - auto readyMessageOffset = Serialization::Local::CreateReadyMessage(builder, true, fbsNetwork, accountLinked, inputPinsOffset, outputPinsOffset, false, rfTxPin, estop.enabled, static_cast(estop.gpioPin), savedSsidsOffset, otaOffset); + auto readyMessageOffset + = Serialization::Local::CreateReadyMessage(builder, true, fbsNetwork, accountLinked, inputPinsOffset, outputPinsOffset, false, rfTxPin, estop.enabled, static_cast(estop.gpioPin), savedSsidsOffset, otaOffset, accessPointOffset); return finishMessage(builder, Local::HubToLocalMessagePayload::ReadyMessage, readyMessageOffset, callback); } diff --git a/components/core/src/wifi/AccessPoint.cpp b/components/core/src/wifi/AccessPoint.cpp new file mode 100644 index 00000000..3a850c9a --- /dev/null +++ b/components/core/src/wifi/AccessPoint.cpp @@ -0,0 +1,44 @@ +#include + +#include "wifi/AccessPoint.h" + +#include "FormatHelpers.h" +#include "wifi/WiFiConstants.h" + +#include + +#include + +// The prefix gets whatever the MAC leaves of an SSID +static_assert(sizeof(CONFIG_OPENSHOCK_FW_AP_PREFIX) - 1 + OpenShock::WiFiConstants::kMacStringLength <= OpenShock::WiFiConstants::kSsidMaxLength, "CONFIG_OPENSHOCK_FW_AP_PREFIX must leave room for the MAC in the SSID"); + +bool OpenShock::AccessPoint::IsDefaultSSID(std::string_view configuredSsid) +{ + return configuredSsid == CONFIG_OPENSHOCK_FW_AP_PREFIX; +} + +OpenShock::AccessPoint::Protection OpenShock::AccessPoint::ProtectionOf(std::string_view password) +{ + if (password.empty()) { + return Protection::Open; + } + if (password == CONFIG_OPENSHOCK_FW_AP_PASSWORD) { + return Protection::DefaultPassword; + } + return Protection::CustomPassword; +} + +std::string OpenShock::AccessPoint::DefaultSSID() +{ + uint8_t mac[6] = {}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + + char ssid[WiFiConstants::kSsidMaxLength + 1]; + snprintf(ssid, sizeof(ssid), "%s" BSSID_FMT, CONFIG_OPENSHOCK_FW_AP_PREFIX, BSSID_ARG(mac)); + return ssid; +} + +std::string OpenShock::AccessPoint::BroadcastSSID(std::string_view configuredSsid) +{ + return IsDefaultSSID(configuredSsid) ? DefaultSSID() : std::string(configuredSsid); +} diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 770daab4..cf70e371 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -8,7 +8,9 @@ const char* const TAG = "Config::WiFiConfig"; #include "config/Parse.h" #include "Convert.h" #include "Logging.h" +#include "StringHelpers.h" #include "util/HostnameUtils.h" +#include "wifi/AccessPoint.h" #include "json/Json.h" @@ -20,10 +22,13 @@ const char* const TAG = "Config::WiFiConfig"; using namespace OpenShock::Config; static_assert(OpenShock::HostnameUtils::IsValid(CONFIG_OPENSHOCK_FW_HOSTNAME), "CONFIG_OPENSHOCK_FW_HOSTNAME must be 1-32 letters, digits or hyphens, not starting or ending with a hyphen"); +static_assert(OpenShock::AccessPoint::IsValidSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX), "CONFIG_OPENSHOCK_FW_AP_PREFIX must be 1-32 characters without '|' or control characters"); +static_assert(OpenShock::AccessPoint::IsValidPassword(CONFIG_OPENSHOCK_FW_AP_PASSWORD), "CONFIG_OPENSHOCK_FW_AP_PASSWORD must be empty or 8-63 printable ASCII characters"); static constexpr const char* kNamespaceCfg = "wifi.cfg"; static constexpr const char* kNamespaceCreds = "wifi"; static constexpr const char* kKeyApSsid = "ap_ssid"; +static constexpr const char* kKeyApPassword = "ap_pass"; static constexpr const char* kKeyHostname = "hostname"; static constexpr const char* kNamespaces[] = {kNamespaceCfg, kNamespaceCreds}; @@ -31,13 +36,20 @@ static Section s_section({.name = "wifi", .order = 1, .namespaces = WiFiConfig::WiFiConfig() : accessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX) + , accessPointPassword(CONFIG_OPENSHOCK_FW_AP_PASSWORD) , hostname(CONFIG_OPENSHOCK_FW_HOSTNAME) , credentialsList() { } WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList) + : WiFiConfig(accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PASSWORD, hostname, credentialsList) +{ +} + +WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view accessPointPassword, std::string_view hostname, const std::vector& credentialsList) : accessPointSSID(accessPointSSID) + , accessPointPassword(accessPointPassword) , hostname(hostname) , credentialsList(credentialsList) { @@ -51,6 +63,13 @@ bool WiFiConfig::FromJSON(JSON::JsonView json) accessPointSSID = json["accessPointSSID"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PREFIX); + accessPointPassword = json["accessPointPassword"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PASSWORD); + // Refused here, not left to Normalize(), which would save the build default in place of a password the import meant to set. + if (!AccessPoint::IsValidPassword(accessPointPassword)) { + OS_LOGE(TAG, "accessPointPassword is not empty or 8-63 printable ASCII characters"); + return false; + } + hostname = json["hostname"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_HOSTNAME); JSON::JsonView credentialsListJson = json["credentials"]; @@ -73,6 +92,9 @@ void WiFiConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiv { JSON::objBegin(gen, name); JSON::objSetString(gen, "accessPointSSID", accessPointSSID); + if (withSensitiveData) { + JSON::objSetString(gen, "accessPointPassword", accessPointPassword); + } JSON::objSetString(gen, "hostname", hostname); json_gen_push_array(gen, "credentials"); @@ -121,6 +143,15 @@ namespace { void WiFiConfig::Normalize() { + if (!AccessPoint::IsValidSSID(accessPointSSID)) { + OS_LOGW(TAG, "Access point SSID is not 1-32 characters without '|' or control characters, using the default"); + accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; + } + if (!AccessPoint::IsValidPassword(accessPointPassword)) { + OS_LOGW(TAG, "Access point password is not 8-63 printable ASCII characters, using the default"); + accessPointPassword = CONFIG_OPENSHOCK_FW_AP_PASSWORD; + } + if (!HostnameUtils::IsValid(hostname)) { OS_LOGW(TAG, "Hostname \"%s\" is not valid, using the default", hostname.c_str()); hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; @@ -155,6 +186,13 @@ bool WiFiConfig::LoadNvs(const char* partition) return false; } + // An unreadable AP password falls back to the default instead of failing the load. + // A failed load runs the section on an empty credentials list, and its next save would sweep every stored network. + if (!Nvs::Load(*handle, kKeyApPassword, accessPointPassword, CONFIG_OPENSHOCK_FW_AP_PASSWORD)) { + OS_LOGW(TAG, "Access point password is unreadable, using the default"); + accessPointPassword = CONFIG_OPENSHOCK_FW_AP_PASSWORD; + } + credentialsList.clear(); handle = Nvs::Handle::OpenForLoad(partition, kNamespaceCreds); if (!handle) { @@ -194,7 +232,7 @@ bool WiFiConfig::SaveNvs(const char* partition, const WiFiConfig* previous) cons if (!handle) { return false; } - if (!Nvs::Saver(*handle, *this, previous).Set(kKeyApSsid, &WiFiConfig::accessPointSSID).Set(kKeyHostname, &WiFiConfig::hostname).Commit()) { + if (!Nvs::Saver(*handle, *this, previous).Set(kKeyApSsid, &WiFiConfig::accessPointSSID).Set(kKeyApPassword, &WiFiConfig::accessPointPassword).Set(kKeyHostname, &WiFiConfig::hostname).Commit()) { return false; } } @@ -246,11 +284,11 @@ bool WiFiConfig::SaveNvs(const char* partition, const WiFiConfig* previous) cons // 802.11 SSIDs are 1-32 bytes; WPA passphrases are up to 63 characters, or 64 for a raw hex PSK. static bool isValidWiFiCredentials(std::string_view ssid, std::string_view password) { - if (ssid.empty() || ssid.size() > 32) { + if (ssid.empty() || ssid.size() > OpenShock::WiFiConstants::kSsidMaxLength) { OS_LOGE(TAG, "Invalid SSID length %zu", ssid.size()); return false; } - if (password.size() > 64) { + if (password.size() > OpenShock::WiFiConstants::kPskHexLength) { OS_LOGE(TAG, "Invalid password length %zu", password.size()); return false; } @@ -410,6 +448,58 @@ bool OpenShock::Config::ClearWiFiCredentials() }); } +std::string OpenShock::Config::GetWiFiAccessPointSSID() +{ + return s_section.Read([](const WiFiConfig& wifi) { return wifi.accessPointSSID; }); +} + +bool OpenShock::Config::SetWiFiAccessPointSSID(std::string ssid) +{ + if (!AccessPoint::IsValidSSID(ssid)) { + OS_LOGE(TAG, "Invalid access point SSID length %zu", ssid.size()); + return false; + } + + return s_section.Update([&](WiFiConfig& wifi) { + wifi.accessPointSSID = std::move(ssid); + return true; + }); +} + +bool OpenShock::Config::ResetWiFiAccessPointSSID() +{ + return s_section.Update([](WiFiConfig& wifi) { + wifi.accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; + return true; + }); +} + +std::string OpenShock::Config::GetWiFiAccessPointPassword() +{ + return s_section.Read([](const WiFiConfig& wifi) { return wifi.accessPointPassword; }); +} + +bool OpenShock::Config::SetWiFiAccessPointPassword(std::string password) +{ + if (!AccessPoint::IsValidPassword(password)) { + OS_LOGE(TAG, "Invalid access point password length %zu", password.size()); + return false; + } + + return s_section.Update([&](WiFiConfig& wifi) { + wifi.accessPointPassword = std::move(password); + return true; + }); +} + +bool OpenShock::Config::ResetWiFiAccessPointPassword() +{ + return s_section.Update([](WiFiConfig& wifi) { + wifi.accessPointPassword = CONFIG_OPENSHOCK_FW_AP_PASSWORD; + return true; + }); +} + std::string OpenShock::Config::GetWiFiHostname() { return s_section.Read([](const WiFiConfig& wifi) { return wifi.hostname; }); diff --git a/components/core/src/wifi/WiFiManager.cpp b/components/core/src/wifi/WiFiManager.cpp index 1d1d1a78..30617035 100644 --- a/components/core/src/wifi/WiFiManager.cpp +++ b/components/core/src/wifi/WiFiManager.cpp @@ -13,6 +13,7 @@ const char* const TAG = "WiFiManager"; #include "util/FunctionRef.h" #include "util/TaskUtils.h" #include "wifi/WiFiConfig.h" +#include "wifi/WiFiConstants.h" #include "wifi/WiFiNetwork.h" #include "wifi/WiFiScanManager.h" @@ -162,7 +163,7 @@ static bool strongerSignal(const WiFiNetwork& a, const WiFiNetwork& b) } // An event's SSID field is not guaranteed null-terminated, and ssid_len comes from the driver: clamp it to the field -static void copyEventSSID(const uint8_t (&eventSsid)[32], uint8_t eventSsidLen, char (&ssid)[33]) +static void copyEventSSID(const uint8_t (&eventSsid)[WiFiConstants::kSsidMaxLength], uint8_t eventSsidLen, char (&ssid)[WiFiConstants::kSsidMaxLength + 1]) { size_t ssidLen = std::min(static_cast(eventSsidLen), sizeof(eventSsid)); memcpy(ssid, eventSsid, ssidLen); @@ -312,7 +313,7 @@ static void evWiFiConnected(const wifi_event_sta_connected_t& info) s_wifiState.store(WiFiState::Connected, std::memory_order_relaxed); postWiFiState(OPENSHOCK_WIFI_STATE_CONNECTED, kPostWaitFromEventLoop); - char ssid[33]; + char ssid[WiFiConstants::kSsidMaxLength + 1]; copyEventSSID(info.ssid, info.ssid_len, ssid); uint8_t credentialsId; @@ -390,7 +391,7 @@ static void evWiFiDisconnected(const wifi_event_sta_disconnected_t& info) s_connectedCredentialsID.store(0, std::memory_order_relaxed); postWiFiState(OPENSHOCK_WIFI_STATE_DISCONNECTED, kPostWaitFromEventLoop); - char ssid[33]; + char ssid[WiFiConstants::kSsidMaxLength + 1]; copyEventSSID(info.ssid, info.ssid_len, ssid); OS_LOGI(TAG, "Disconnected from network %s (" BSSID_FMT ")", ssid, BSSID_ARG(info.bssid)); diff --git a/components/core/src/wifi/WiFiNetwork.cpp b/components/core/src/wifi/WiFiNetwork.cpp index 0e0cb292..c3638e53 100644 --- a/components/core/src/wifi/WiFiNetwork.cpp +++ b/components/core/src/wifi/WiFiNetwork.cpp @@ -40,7 +40,7 @@ WiFiNetwork::WiFiNetwork(const wifi_ap_record_t* apRecord, uint8_t credentialsId memcpy(bssid, apRecord->bssid, sizeof(bssid)); } -WiFiNetwork::WiFiNetwork(const char (&ssid)[33], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId) +WiFiNetwork::WiFiNetwork(const char (&ssid)[WiFiConstants::kSsidMaxLength + 1], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId) : ssid {0} , bssid {0} , channel(channel) @@ -59,8 +59,8 @@ WiFiNetwork::WiFiNetwork(const char (&ssid)[33], const uint8_t (&bssid)[6], uint memcpy(this->bssid, bssid, sizeof(bssid)); } -WiFiNetwork::WiFiNetwork(const uint8_t (&ssid)[33], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId) - : WiFiNetwork(reinterpret_cast(ssid), bssid, channel, rssi, authMode, credentialsId) +WiFiNetwork::WiFiNetwork(const uint8_t (&ssid)[WiFiConstants::kSsidMaxLength + 1], const uint8_t (&bssid)[6], uint8_t channel, int8_t rssi, wifi_auth_mode_t authMode, uint8_t credentialsId) + : WiFiNetwork(reinterpret_cast(ssid), bssid, channel, rssi, authMode, credentialsId) { } diff --git a/components/serial_console/include/serial_console/command_handlers/index.h b/components/serial_console/include/serial_console/command_handlers/index.h index 9096b092..d0a1a53e 100644 --- a/components/serial_console/include/serial_console/command_handlers/index.h +++ b/components/serial_console/include/serial_console/command_handlers/index.h @@ -16,6 +16,7 @@ namespace OpenShock::SerialCmds::CommandHandlers { OpenShock::SerialCmds::CommandGroup AuthTokenHandler(); OpenShock::SerialCmds::CommandGroup HostnameHandler(); OpenShock::SerialCmds::CommandGroup NetworksHandler(); + OpenShock::SerialCmds::CommandGroup AccessPointHandler(); OpenShock::SerialCmds::CommandGroup KeepAliveHandler(); OpenShock::SerialCmds::CommandGroup JsonConfigHandler(); OpenShock::SerialCmds::CommandGroup RfTransmitHandler(); @@ -24,6 +25,7 @@ namespace OpenShock::SerialCmds::CommandHandlers { inline std::vector AllCommandHandlers() { + // clang-format off: one command group per line return { VersionHandler(), RestartHandler(), @@ -36,11 +38,13 @@ namespace OpenShock::SerialCmds::CommandHandlers { AuthTokenHandler(), HostnameHandler(), NetworksHandler(), + AccessPointHandler(), KeepAliveHandler(), JsonConfigHandler(), RfTransmitHandler(), FactoryResetHandler(), LedTestHandler(), }; + // clang-format on } } // namespace OpenShock::SerialCmds::CommandHandlers diff --git a/components/serial_console/src/command_handlers/accesspoint.cpp b/components/serial_console/src/command_handlers/accesspoint.cpp new file mode 100644 index 00000000..710cf651 --- /dev/null +++ b/components/serial_console/src/command_handlers/accesspoint.cpp @@ -0,0 +1,105 @@ +#include + +#include "serial_console/command_handlers/common.h" + +#include "StringHelpers.h" +#include "wifi/AccessPoint.h" +#include "wifi/WiFiConfig.h" + +#include + +static void handleAccessPointGetCommand(std::string_view arg, bool isAutomated) +{ + (void)arg; + + std::string ssid = OpenShock::Config::GetWiFiAccessPointSSID(); + std::string password = OpenShock::Config::GetWiFiAccessPointPassword(); + + const char* protection = "custom-password"; + switch (OpenShock::AccessPoint::ProtectionOf(password)) { + case OpenShock::AccessPoint::Protection::Open: + protection = "open"; + break; + case OpenShock::AccessPoint::Protection::DefaultPassword: + protection = "default-password"; + break; + case OpenShock::AccessPoint::Protection::CustomPassword: + break; + } + + // A default SSID is shown as the prefix; the hub appends its MAC to it when the access point starts + SERPR_RESPONSE("AccessPoint|%s|%s|%s", ssid.c_str(), OpenShock::AccessPoint::IsDefaultSSID(ssid) ? "default" : "custom", protection); +} + +static void handleAccessPointSsidCommand(std::string_view arg, bool isAutomated) +{ + // "default" brings back the prefix + MAC name + bool saved; + if (OpenShock::StringIEquals(arg, "default")) { + saved = OpenShock::Config::ResetWiFiAccessPointSSID(); + } else { + if (!OpenShock::AccessPoint::IsValidSSID(arg)) { + SERPR_ERROR("Invalid SSID (must be 1-32 characters, without '|' or control characters)"); + return; + } + saved = OpenShock::Config::SetWiFiAccessPointSSID(std::string(arg)); + } + + if (!saved) { + SERPR_ERROR("Failed to save config"); + return; + } + + SERPR_SUCCESS("Saved config, applies the next time the captive portal starts"); +} + +static void handleAccessPointPasswordCommand(std::string_view arg, bool isAutomated) +{ + // "default" brings back the build's default password; it is too short to be a password itself + bool saved; + if (OpenShock::StringIEquals(arg, "default")) { + saved = OpenShock::Config::ResetWiFiAccessPointPassword(); + } else { + if (arg.empty() || !OpenShock::AccessPoint::IsValidPassword(arg)) { + SERPR_ERROR("Invalid password (must be 8-63 printable ASCII characters; use \"ap open\" to remove it)"); + return; + } + saved = OpenShock::Config::SetWiFiAccessPointPassword(std::string(arg)); + } + + if (!saved) { + SERPR_ERROR("Failed to save config"); + return; + } + + SERPR_SUCCESS("Saved config, applies the next time the captive portal starts"); +} + +static void handleAccessPointOpenCommand(std::string_view arg, bool isAutomated) +{ + (void)arg; + + if (!OpenShock::Config::SetWiFiAccessPointPassword({})) { + SERPR_ERROR("Failed to save config"); + return; + } + + SERPR_SUCCESS("Saved config, the access point is open from the next time the captive portal starts"); +} + +OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::AccessPointHandler() +{ + auto group = OpenShock::SerialCmds::CommandGroup("ap"); + + group.addCommand("Get the captive portal's access point name and whether it is open or uses the default or a custom password.", handleAccessPointGetCommand); + + auto& ssidCommand = group.addCommand("ssid", "Set the access point name; \"default\" restores the name with the hub's MAC.", handleAccessPointSsidCommand); + ssidCommand.addArgument("ssid", "1-32 characters, or default", "MyHub"); + + auto& passwordCommand = group.addCommand("password", "Set the access point's WPA2/WPA3 password; \"default\" restores the build's default, as a factory reset does.", handleAccessPointPasswordCommand); + passwordCommand.addArgument("password", "8-63 characters, or default", "hunter2hunter2"); + + group.addCommand("open", "Remove the access point password.", handleAccessPointOpenCommand); + + return group; +} diff --git a/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h b/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h index c12eadce..6275e820 100644 --- a/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h +++ b/components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h @@ -24,6 +24,9 @@ namespace Local { struct OtaSettings; struct OtaSettingsBuilder; +struct AccessPointSettings; +struct AccessPointSettingsBuilder; + struct ReadyMessage; struct ReadyMessageBuilder; @@ -81,6 +84,39 @@ inline const char *EnumNameOtaChannel(OtaChannel e) { return EnumNamesOtaChannel()[index]; } +enum class AccessPointProtection : uint8_t { + Open = 0, + DefaultPassword = 1, + CustomPassword = 2, + MIN = Open, + MAX = CustomPassword +}; + +inline const AccessPointProtection (&EnumValuesAccessPointProtection())[3] { + static const AccessPointProtection values[] = { + AccessPointProtection::Open, + AccessPointProtection::DefaultPassword, + AccessPointProtection::CustomPassword + }; + return values; +} + +inline const char * const *EnumNamesAccessPointProtection() { + static const char * const names[4] = { + "Open", + "DefaultPassword", + "CustomPassword", + nullptr + }; + return names; +} + +inline const char *EnumNameAccessPointProtection(AccessPointProtection e) { + if (::flatbuffers::IsOutRange(e, AccessPointProtection::Open, AccessPointProtection::CustomPassword)) return ""; + const size_t index = static_cast(e); + return EnumNamesAccessPointProtection()[index]; +} + enum class HubToLocalMessagePayload : uint8_t { NONE = 0, ReadyMessage = 1, @@ -288,6 +324,96 @@ inline ::flatbuffers::Offset CreateOtaSettingsDirect( require_manual_approval); } +/// The captive portal's access point as configured. The password never leaves the hub. +struct AccessPointSettings FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { + typedef AccessPointSettingsBuilder Builder; + struct Traits; + static FLATBUFFERS_CONSTEXPR_CPP11 const char *GetFullyQualifiedName() { + return "OpenShock.Serialization.Local.AccessPointSettings"; + } + enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE { + VT_SSID = 4, + VT_DEFAULT_SSID = 6, + VT_PROTECTION = 8 + }; + /// The name set for it; empty for the default, which is default_ssid. + const ::flatbuffers::String *ssid() const { + return GetPointer(VT_SSID); + } + /// The default name: the prefix with this hub's MAC. + const ::flatbuffers::String *default_ssid() const { + return GetPointer(VT_DEFAULT_SSID); + } + OpenShock::Serialization::Local::AccessPointProtection protection() const { + return static_cast(GetField(VT_PROTECTION, 0)); + } + template + bool Verify(::flatbuffers::VerifierTemplate &verifier) const { + return VerifyTableStart(verifier) && + VerifyOffset(verifier, VT_SSID) && + verifier.VerifyString(ssid()) && + VerifyOffset(verifier, VT_DEFAULT_SSID) && + verifier.VerifyString(default_ssid()) && + VerifyField(verifier, VT_PROTECTION, 1) && + verifier.EndTable(); + } +}; + +struct AccessPointSettingsBuilder { + typedef AccessPointSettings Table; + ::flatbuffers::FlatBufferBuilder &fbb_; + ::flatbuffers::uoffset_t start_; + void add_ssid(::flatbuffers::Offset<::flatbuffers::String> ssid) { + fbb_.AddOffset(AccessPointSettings::VT_SSID, ssid); + } + void add_default_ssid(::flatbuffers::Offset<::flatbuffers::String> default_ssid) { + fbb_.AddOffset(AccessPointSettings::VT_DEFAULT_SSID, default_ssid); + } + void add_protection(OpenShock::Serialization::Local::AccessPointProtection protection) { + fbb_.AddElement(AccessPointSettings::VT_PROTECTION, static_cast(protection), 0); + } + explicit AccessPointSettingsBuilder(::flatbuffers::FlatBufferBuilder &_fbb) + : fbb_(_fbb) { + start_ = fbb_.StartTable(); + } + ::flatbuffers::Offset Finish() { + const auto end = fbb_.EndTable(start_); + auto o = ::flatbuffers::Offset(end); + return o; + } +}; + +inline ::flatbuffers::Offset CreateAccessPointSettings( + ::flatbuffers::FlatBufferBuilder &_fbb, + ::flatbuffers::Offset<::flatbuffers::String> ssid = 0, + ::flatbuffers::Offset<::flatbuffers::String> default_ssid = 0, + OpenShock::Serialization::Local::AccessPointProtection protection = OpenShock::Serialization::Local::AccessPointProtection::Open) { + AccessPointSettingsBuilder builder_(_fbb); + builder_.add_default_ssid(default_ssid); + builder_.add_ssid(ssid); + builder_.add_protection(protection); + return builder_.Finish(); +} + +struct AccessPointSettings::Traits { + using type = AccessPointSettings; + static auto constexpr Create = CreateAccessPointSettings; +}; + +inline ::flatbuffers::Offset CreateAccessPointSettingsDirect( + ::flatbuffers::FlatBufferBuilder &_fbb, + const char *ssid = nullptr, + const char *default_ssid = nullptr, + OpenShock::Serialization::Local::AccessPointProtection protection = OpenShock::Serialization::Local::AccessPointProtection::Open) { + auto ssid__ = ssid ? _fbb.CreateString(ssid) : 0; + auto default_ssid__ = default_ssid ? _fbb.CreateString(default_ssid) : 0; + return OpenShock::Serialization::Local::CreateAccessPointSettings( + _fbb, + ssid__, + default_ssid__, + protection); +} + struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { typedef ReadyMessageBuilder Builder; struct Traits; @@ -305,7 +431,8 @@ struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { VT_ESTOP_ENABLED = 18, VT_ESTOP_GPIO_PIN = 20, VT_SAVED_SSIDS = 22, - VT_OTA = 24 + VT_OTA = 24, + VT_ACCESS_POINT = 26 }; bool poggies() const { return GetField(VT_POGGIES, 0) != 0; @@ -341,6 +468,9 @@ struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { const OpenShock::Serialization::Local::OtaSettings *ota() const { return GetPointer(VT_OTA); } + const OpenShock::Serialization::Local::AccessPointSettings *access_point() const { + return GetPointer(VT_ACCESS_POINT); + } template bool Verify(::flatbuffers::VerifierTemplate &verifier) const { return VerifyTableStart(verifier) && @@ -361,6 +491,8 @@ struct ReadyMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { verifier.VerifyVectorOfStrings(saved_ssids()) && VerifyOffset(verifier, VT_OTA) && verifier.VerifyTable(ota()) && + VerifyOffset(verifier, VT_ACCESS_POINT) && + verifier.VerifyTable(access_point()) && verifier.EndTable(); } }; @@ -402,6 +534,9 @@ struct ReadyMessageBuilder { void add_ota(::flatbuffers::Offset ota) { fbb_.AddOffset(ReadyMessage::VT_OTA, ota); } + void add_access_point(::flatbuffers::Offset access_point) { + fbb_.AddOffset(ReadyMessage::VT_ACCESS_POINT, access_point); + } explicit ReadyMessageBuilder(::flatbuffers::FlatBufferBuilder &_fbb) : fbb_(_fbb) { start_ = fbb_.StartTable(); @@ -425,8 +560,10 @@ inline ::flatbuffers::Offset CreateReadyMessage( bool estop_enabled = false, int8_t estop_gpio_pin = -1, ::flatbuffers::Offset<::flatbuffers::Vector<::flatbuffers::Offset<::flatbuffers::String>>> saved_ssids = 0, - ::flatbuffers::Offset ota = 0) { + ::flatbuffers::Offset ota = 0, + ::flatbuffers::Offset access_point = 0) { ReadyMessageBuilder builder_(_fbb); + builder_.add_access_point(access_point); builder_.add_ota(ota); builder_.add_saved_ssids(saved_ssids); builder_.add_gpio_valid_outputs(gpio_valid_outputs); @@ -458,7 +595,8 @@ inline ::flatbuffers::Offset CreateReadyMessageDirect( bool estop_enabled = false, int8_t estop_gpio_pin = -1, const std::vector<::flatbuffers::Offset<::flatbuffers::String>> *saved_ssids = nullptr, - ::flatbuffers::Offset ota = 0) { + ::flatbuffers::Offset ota = 0, + ::flatbuffers::Offset access_point = 0) { auto gpio_valid_inputs__ = gpio_valid_inputs ? _fbb.CreateVector(*gpio_valid_inputs) : 0; auto gpio_valid_outputs__ = gpio_valid_outputs ? _fbb.CreateVector(*gpio_valid_outputs) : 0; auto saved_ssids__ = saved_ssids ? _fbb.CreateVector<::flatbuffers::Offset<::flatbuffers::String>>(*saved_ssids) : 0; @@ -474,7 +612,8 @@ inline ::flatbuffers::Offset CreateReadyMessageDirect( estop_enabled, estop_gpio_pin, saved_ssids__, - ota); + ota, + access_point); } struct ErrorMessage FLATBUFFERS_FINAL_CLASS : private ::flatbuffers::Table { diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/access-point-protection.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/access-point-protection.ts new file mode 100644 index 00000000..267317eb --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/access-point-protection.ts @@ -0,0 +1,9 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +export enum AccessPointProtection { + Open = 0, + DefaultPassword = 1, + CustomPassword = 2 +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/access-point-settings.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/access-point-settings.ts new file mode 100644 index 00000000..e990ace6 --- /dev/null +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/access-point-settings.ts @@ -0,0 +1,84 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ + +import * as flatbuffers from 'flatbuffers'; + +import { AccessPointProtection } from '../../../open-shock/serialization/local/access-point-protection'; + + +/** + * The captive portal's access point as configured. The password never leaves the hub. + */ +export class AccessPointSettings { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AccessPointSettings { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAccessPointSettings(bb:flatbuffers.ByteBuffer, obj?:AccessPointSettings):AccessPointSettings { + return (obj || new AccessPointSettings()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAccessPointSettings(bb:flatbuffers.ByteBuffer, obj?:AccessPointSettings):AccessPointSettings { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AccessPointSettings()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +/** + * The name set for it; empty for the default, which is default_ssid. + */ +ssid():string|null +ssid(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +ssid(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +/** + * The default name: the prefix with this hub's MAC. + */ +defaultSsid():string|null +defaultSsid(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +defaultSsid(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +protection():AccessPointProtection { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readUint8(this.bb_pos + offset) : AccessPointProtection.Open; +} + +static startAccessPointSettings(builder:flatbuffers.Builder) { + builder.startObject(3); +} + +static addSsid(builder:flatbuffers.Builder, ssidOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, ssidOffset, 0); +} + +static addDefaultSsid(builder:flatbuffers.Builder, defaultSsidOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, defaultSsidOffset, 0); +} + +static addProtection(builder:flatbuffers.Builder, protection:AccessPointProtection) { + builder.addFieldInt8(2, protection, AccessPointProtection.Open); +} + +static endAccessPointSettings(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAccessPointSettings(builder:flatbuffers.Builder, ssidOffset:flatbuffers.Offset, defaultSsidOffset:flatbuffers.Offset, protection:AccessPointProtection):flatbuffers.Offset { + AccessPointSettings.startAccessPointSettings(builder); + AccessPointSettings.addSsid(builder, ssidOffset); + AccessPointSettings.addDefaultSsid(builder, defaultSsidOffset); + AccessPointSettings.addProtection(builder, protection); + return AccessPointSettings.endAccessPointSettings(builder); +} +} diff --git a/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts b/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts index 3fe0712b..d15a2d73 100644 --- a/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts +++ b/frontend/src/lib/_fbs/open-shock/serialization/local/ready-message.ts @@ -4,6 +4,7 @@ import * as flatbuffers from 'flatbuffers'; +import { AccessPointSettings } from '../../../open-shock/serialization/local/access-point-settings'; import { OtaSettings } from '../../../open-shock/serialization/local/ota-settings'; import { WifiNetwork } from '../../../open-shock/serialization/types/wifi-network'; @@ -111,8 +112,13 @@ ota(obj?:OtaSettings):OtaSettings|null { return offset ? (obj || new OtaSettings()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } +accessPoint(obj?:AccessPointSettings):AccessPointSettings|null { + const offset = this.bb!.__offset(this.bb_pos, 26); + return offset ? (obj || new AccessPointSettings()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startReadyMessage(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addPoggies(builder:flatbuffers.Builder, poggies:boolean) { @@ -205,6 +211,10 @@ static addOta(builder:flatbuffers.Builder, otaOffset:flatbuffers.Offset) { builder.addFieldOffset(10, otaOffset, 0); } +static addAccessPoint(builder:flatbuffers.Builder, accessPointOffset:flatbuffers.Offset) { + builder.addFieldOffset(11, accessPointOffset, 0); +} + static endReadyMessage(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 56a87b91..c706ac24 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import { OtaChannel } from '#lib/_fbs/open-shock/serialization/local/ota-channel.js'; -import type { OtaUpdateConfig } from '#lib/mappers/ConfigMapper.js'; +import type { AccessPointConfig, OtaUpdateConfig } from '#lib/mappers/ConfigMapper.js'; import { hubState } from '#lib/stores/index.js'; import { getApiBaseUrl } from '#lib/utils/localRedirect.js'; import { toast } from 'svelte-sonner'; @@ -30,6 +30,9 @@ const _errorMessages: Record = { PasswordTooLong: 'Password must be at most 63 characters', // OTA InvalidChannel: 'Invalid update channel', + // Access point + InvalidApSsid: "Network name must be 1-32 bytes, without '|' or control characters", + InvalidApPassword: 'Password must be printable ASCII characters (no accents or emoji)', // Generic MissingParam: 'Missing parameter', InvalidParam: 'Invalid value', @@ -298,3 +301,43 @@ export function checkOtaUpdates(channel: OtaChannel): Promise { 'Failed to check for OTA updates' ); } + +export interface AccessPointChange { + ssid?: string; // '' for the default name + protection?: AccessPointConfig['protection']; + password?: string; // With protection 'custom' +} + +const _protectionKinds: readonly AccessPointConfig['protection'][] = ['default', 'open', 'custom']; + +// The captive portal's access point (PUT /api/ap). Saved at once, applied the next time the portal starts. The hub +// answers with the settings as it reads them (a name equal to the bare prefix is the default, and so is the build's +// default password), which replace them in hubState.config, otherwise only sent once on connect. +export async function setAccessPoint(change: AccessPointChange): Promise { + const params = new URLSearchParams(); + if (change.ssid !== undefined) params.set('ssid', change.ssid); + if (change.protection !== undefined) params.set('protection', change.protection); + if (change.protection === 'custom' && change.password !== undefined) + params.set('password', change.password); + try { + const res = await apiFetch('/api/ap', { + method: 'PUT', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + if (!res.ok) { + toast.error('Failed to save the access point: ' + (await getErrorMessage(res))); + return false; + } + const ap = hubState.config?.accessPoint; + if (ap) { + const data = await res.json(); + if (typeof data.ssid === 'string') ap.ssid = data.ssid || null; + if (_protectionKinds.includes(data.protection)) ap.protection = data.protection; + } + return true; + } catch { + toast.error('Failed to save the access point'); + return false; + } +} diff --git a/frontend/src/lib/components/WiFiEntry.svelte b/frontend/src/lib/components/WiFiEntry.svelte index d9bc6bf9..d6eaceb1 100644 --- a/frontend/src/lib/components/WiFiEntry.svelte +++ b/frontend/src/lib/components/WiFiEntry.svelte @@ -197,7 +197,7 @@ > @@ -230,7 +230,10 @@ - diff --git a/frontend/src/lib/components/sections/AccessPointSection.svelte b/frontend/src/lib/components/sections/AccessPointSection.svelte new file mode 100644 index 00000000..12e7035a --- /dev/null +++ b/frontend/src/lib/components/sections/AccessPointSection.svelte @@ -0,0 +1,218 @@ + + +
+ + + {#if !hubState.config} +

Loading...

+ {:else if !ap} +

+ This firmware can't change the access point from here. Use the ap serial command. +

+ {:else} +
+

+ Saved name: + {shownName} + {#if !ap.ssid}(default){/if} +

+

+ Protection: + {protections.find((p) => p.value === ap.protection)?.label} +

+
+ +
+
+ + ssid, (v) => (ssidEdit = v)} + /> +

+ {ssidError ?? `Leave empty for the default, ${ap.defaultSsid}.`} +

+
+ +
+ +
+ {#each protections as p (p.value)} + {@const active = protection === p.value} + + {/each} +
+

+ {#if protection === 'open'}{/if} + {protections.find((p) => p.value === protection)?.description} +

+ + {#if protection === 'custom'} +
+ + +
+ {#if passwordError} +

{passwordError}

+ {/if} + {/if} +
+ +
+ + +
+
+ +
+ +

+ Changes apply the next time the hub opens this portal (after a restart, for one). The + network you are on now stays as it is until then, so this page keeps working. + {#if savedOnce} + Afterwards, join "{shownName}" with the new password to come back here. + {/if} + Forgot it? A factory reset over USB restores the default name and password. +

+
+ {/if} +
diff --git a/frontend/src/lib/mappers/ConfigMapper.test.ts b/frontend/src/lib/mappers/ConfigMapper.test.ts index 458edf9f..b921df4b 100644 --- a/frontend/src/lib/mappers/ConfigMapper.test.ts +++ b/frontend/src/lib/mappers/ConfigMapper.test.ts @@ -1,3 +1,5 @@ +import { AccessPointProtection } from '#lib/_fbs/open-shock/serialization/local/access-point-protection.js'; +import { AccessPointSettings } from '#lib/_fbs/open-shock/serialization/local/access-point-settings.js'; import { OtaChannel } from '#lib/_fbs/open-shock/serialization/local/ota-channel.js'; import { OtaSettings } from '#lib/_fbs/open-shock/serialization/local/ota-settings.js'; import { ReadyMessage } from '#lib/_fbs/open-shock/serialization/local/ready-message.js'; @@ -20,6 +22,12 @@ function buildReady(cdnDomain: string, ssids: string[]): ReadyMessage { b, ssids.map((ssid) => b.createString(ssid)) ); + const ap = AccessPointSettings.createAccessPointSettings( + b, + b.createString(''), + b.createString('OpenShock-30:30:F9:7B:80:58'), + AccessPointProtection.DefaultPassword + ); ReadyMessage.startReadyMessage(b); ReadyMessage.addRfTxPin(b, 15); @@ -27,6 +35,7 @@ function buildReady(cdnDomain: string, ssids: string[]): ReadyMessage { ReadyMessage.addEstopGpioPin(b, 13); ReadyMessage.addSavedSsids(b, saved); ReadyMessage.addOta(b, ota); + ReadyMessage.addAccessPoint(b, ap); return readBack(b, ReadyMessage.endReadyMessage(b)); } @@ -43,10 +52,25 @@ describe('mapConfig', () => { allowBackendManagement: false, requireManualApproval: true, }, + accessPoint: { + ssid: null, + defaultSsid: 'OpenShock-30:30:F9:7B:80:58', + protection: 'default', + }, savedSSIDs: ['HomeNet', 'Cafe'], }); }); + it('maps no access point settings from older firmware to null', () => { + const b = new Builder(64); + const domain = b.createString('fw.example.org'); + const ota = OtaSettings.createOtaSettings(b, true, domain, OtaChannel.Stable, 30, true, false); + ReadyMessage.startReadyMessage(b); + ReadyMessage.addOta(b, ota); + + expect(mapConfig(readBack(b, ReadyMessage.endReadyMessage(b))).accessPoint).toBeNull(); + }); + it('accepts an empty domain and no saved networks', () => { const config = mapConfig(buildReady('', [])); diff --git a/frontend/src/lib/mappers/ConfigMapper.ts b/frontend/src/lib/mappers/ConfigMapper.ts index 99e9dfe4..7a7cc80e 100644 --- a/frontend/src/lib/mappers/ConfigMapper.ts +++ b/frontend/src/lib/mappers/ConfigMapper.ts @@ -1,3 +1,4 @@ +import { AccessPointProtection } from '#lib/_fbs/open-shock/serialization/local/access-point-protection.js'; import type { OtaChannel } from '#lib/_fbs/open-shock/serialization/local/ota-channel.js'; import type { ReadyMessage } from '#lib/_fbs/open-shock/serialization/local/ready-message.js'; @@ -10,14 +11,41 @@ export interface OtaUpdateConfig { requireManualApproval: boolean; } +export type AccessPointProtectionKind = 'default' | 'open' | 'custom'; + +// The captive portal's access point as saved; the password never leaves the hub +export interface AccessPointConfig { + ssid: string | null; // The name set for it; null for the default + defaultSsid: string; // The default name: the prefix with the hub's MAC + protection: AccessPointProtectionKind; +} + // The settings the portal shows, as the hub reports them in its Ready message. export interface Config { rf: { txPin: number }; estop: { enabled: boolean; gpioPin: number }; otaUpdate: OtaUpdateConfig; + accessPoint: AccessPointConfig | null; // Null from firmware older than this frontend savedSSIDs: string[]; } +const _protections: Record = { + [AccessPointProtection.Open]: 'open', + [AccessPointProtection.DefaultPassword]: 'default', + [AccessPointProtection.CustomPassword]: 'custom', +}; + +function mapAccessPointConfig(ready: ReadyMessage): AccessPointConfig | null { + const ap = ready.accessPoint(); + if (!ap) return null; + return { + ssid: ap.ssid() || null, + defaultSsid: ap.defaultSsid() ?? '', + // An unknown protection is some password: never claim the network is open + protection: _protections[ap.protection()] ?? 'custom', + }; +} + function mapOtaUpdateConfig(ready: ReadyMessage): OtaUpdateConfig { const ota = ready.ota(); if (!ota) throw new Error('ready.ota is null'); @@ -45,6 +73,7 @@ export function mapConfig(ready: ReadyMessage): Config { rf: { txPin: ready.rfTxPin() }, estop: { enabled: ready.estopEnabled(), gpioPin: ready.estopGpioPin() }, otaUpdate: mapOtaUpdateConfig(ready), + accessPoint: mapAccessPointConfig(ready), savedSSIDs, }; } diff --git a/frontend/src/lib/utils/wifiValidation.test.ts b/frontend/src/lib/utils/wifiValidation.test.ts index 63573a98..fe976943 100644 --- a/frontend/src/lib/utils/wifiValidation.test.ts +++ b/frontend/src/lib/utils/wifiValidation.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { isValidWifiPassword, isValidWifiSsid } from './wifiValidation'; +import { + accessPointPassphraseError, + accessPointSsidError, + isValidWifiPassword, + isValidWifiSsid, +} from './wifiValidation'; describe('isValidWifiSsid', () => { it('accepts 1 to 32 bytes', () => { @@ -30,3 +35,38 @@ describe('isValidWifiPassword', () => { expect(isValidWifiPassword('a'.repeat(64))).toBe(false); }); }); + +describe('accessPointSsidError', () => { + it('accepts the default (empty) and names up to 32 bytes', () => { + expect(accessPointSsidError('')).toBeNull(); + expect(accessPointSsidError('My Hub')).toBeNull(); + expect(accessPointSsidError('é'.repeat(16))).toBeNull(); + }); + + it('rejects names over 32 bytes', () => { + expect(accessPointSsidError('é'.repeat(17))).toMatch(/32 bytes/); + }); + + it("rejects '|' and control characters", () => { + expect(accessPointSsidError('My|Hub')).not.toBeNull(); + expect(accessPointSsidError('My\tHub')).not.toBeNull(); + expect(accessPointSsidError('My\x7fHub')).not.toBeNull(); + }); +}); + +describe('accessPointPassphraseError', () => { + it('accepts 8 to 63 printable ASCII characters', () => { + expect(accessPointPassphraseError('12345678')).toBeNull(); + expect(accessPointPassphraseError('~'.repeat(63))).toBeNull(); + }); + + it('rejects short and long passphrases', () => { + expect(accessPointPassphraseError('')).toMatch(/at least 8/i); + expect(accessPointPassphraseError('a'.repeat(64))).toMatch(/at most 63/i); + }); + + it('rejects anything but printable ASCII', () => { + expect(accessPointPassphraseError('pass\tword')).not.toBeNull(); + expect(accessPointPassphraseError('café-latte')).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/utils/wifiValidation.ts b/frontend/src/lib/utils/wifiValidation.ts index 5899380c..cde064df 100644 --- a/frontend/src/lib/utils/wifiValidation.ts +++ b/frontend/src/lib/utils/wifiValidation.ts @@ -1,6 +1,11 @@ -// Mirrors the hub's checks (CaptivePortalInstance apiWifiNetworksAdd): lengths are in bytes, not UTF-16 units. +// Mirrors the hub's checks (the portal's WiFi and access point handlers, wifi/WiFiConfig.h): lengths are in bytes, +// not UTF-16 units. const encoder = new TextEncoder(); +export const SSID_MAX_BYTES = 32; // 802.11 +export const PASSPHRASE_MIN_LENGTH = 8; // WPA2/WPA3 +export const PASSPHRASE_MAX_LENGTH = 63; + function byteLength(value: string): number { return encoder.encode(value).length; } @@ -8,11 +13,30 @@ function byteLength(value: string): number { /** 802.11 SSIDs are 1-32 bytes. */ export function isValidWifiSsid(ssid: string): boolean { const length = byteLength(ssid); - return length >= 1 && length <= 32; + return length >= 1 && length <= SSID_MAX_BYTES; } /** WPA passphrases are 8-63 characters. */ export function isValidWifiPassword(password: string): boolean { const length = byteLength(password); - return length >= 8 && length <= 63; + return length >= PASSPHRASE_MIN_LENGTH && length <= PASSPHRASE_MAX_LENGTH; +} + +/** + * Why the hub would refuse `ssid` as its access point's name, or null. Empty is fine: it means the default name. + * The hub also refuses '|' and control characters, since its serial console prints the name as a field. + */ +export function accessPointSsidError(ssid: string): string | null { + if (byteLength(ssid) > SSID_MAX_BYTES) return `At most ${SSID_MAX_BYTES} bytes`; + if (/[|\x00-\x1f\x7f]/.test(ssid)) return "No '|' or control characters"; + return null; +} + +/** Why the hub would refuse `passphrase` for its access point, or null: it takes 8-63 printable ASCII characters. */ +export function accessPointPassphraseError(passphrase: string): string | null { + const length = byteLength(passphrase); + if (length < PASSPHRASE_MIN_LENGTH) return `At least ${PASSPHRASE_MIN_LENGTH} characters`; + if (length > PASSPHRASE_MAX_LENGTH) return `At most ${PASSPHRASE_MAX_LENGTH} characters`; + if (!/^[\x20-\x7e]*$/.test(passphrase)) return 'Only plain letters, digits, spaces and symbols'; + return null; } diff --git a/frontend/src/lib/views/Advanced.svelte b/frontend/src/lib/views/Advanced.svelte index d9edf2d3..5192f723 100644 --- a/frontend/src/lib/views/Advanced.svelte +++ b/frontend/src/lib/views/Advanced.svelte @@ -1,8 +1,9 @@