From edf2e5f97fb141e26d7636ca5422e09e41f0fbf9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 16:15:17 +0100 Subject: [PATCH 01/15] feat(wifi): apply the stored access point name and add an access point password The captive portal now uses wifi.accessPointSSID (saved but never applied before; the default still gets the MAC appended) and protects its network with WPA2 when wifi.accessPointPassword is set. A factory reset clears the password. Set over serial with ap ssid, ap password and ap open. Closes #242 --- .changes/ap-name-and-password.md | 14 ++++ .../src/captiveportal/Manager.cpp | 29 +++++-- .../host_test/main/config_test_helpers.cpp | 3 +- .../config/host_test/main/test_defaults.cpp | 16 ++++ .../config/host_test/main/test_roundtrip.cpp | 19 +++++ components/core/include/wifi/WiFiConfig.h | 26 ++++-- components/core/src/wifi/WiFiConfig.cpp | 72 ++++++++++++++++- .../serial_console/command_handlers/index.h | 4 + .../src/command_handlers/accesspoint.cpp | 81 +++++++++++++++++++ 9 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 .changes/ap-name-and-password.md create mode 100644 components/serial_console/src/command_handlers/accesspoint.cpp diff --git a/.changes/ap-name-and-password.md b/.changes/ap-name-and-password.md new file mode 100644 index 00000000..ae0632ca --- /dev/null +++ b/.changes/ap-name-and-password.md @@ -0,0 +1,14 @@ +--- +kind: added +--- +Set the captive portal's network name and password + +## Release Note +The captive portal's WiFi network can now have its own name and a WPA2 password, set over serial with `ap ssid` and `ap password` + +By default the portal's network stays open and named after the hub, as before. A factory reset removes the 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): empty for an open network, else 8-63 characters, with WPA2-PSK +- Add the `ap` serial command: `ap` shows the name and whether it is protected, `ap ssid `, `ap password ` and `ap open` +- Changes apply the next time the captive portal starts diff --git a/components/captive_portal/src/captiveportal/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp index a9da284a..0c1f04d8 100644 --- a/components/captive_portal/src/captiveportal/Manager.cpp +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -28,6 +28,7 @@ const char* const TAG = "CaptivePortal"; #include "util/RetiredList.h" #include "util/TaskUtils.h" +#include #include #include #include @@ -178,15 +179,29 @@ static bool captiveportal_start() esp_netif_dhcps_start(s_apNetif); } - // 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); + // The default SSID is the prefix + this device's STA MAC (matches the old Arduino naming); a name the user set is + // used as it is. Both are validated by the wifi section (Normalize), so they fit the config struct. + const std::string apSsid = Config::GetWiFiAccessPointSSID(); + const std::string apPassword = Config::GetWiFiAccessPointPassword(); 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; + if (apSsid == CONFIG_OPENSHOCK_FW_AP_PREFIX) { + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + 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)); + } else { + apConfig.ap.ssid_len = std::min(apSsid.size(), sizeof(apConfig.ap.ssid)); + memcpy(apConfig.ap.ssid, apSsid.data(), apConfig.ap.ssid_len); + } + apConfig.ap.channel = 1; + if (apPassword.empty()) { + apConfig.ap.authmode = WIFI_AUTH_OPEN; + } else { + apConfig.ap.authmode = WIFI_AUTH_WPA2_PSK; + std::size_t passwordLen = std::min(apPassword.size(), sizeof(apConfig.ap.password) - 1); + memcpy(apConfig.ap.password, apPassword.data(), passwordLen); + } apConfig.ap.max_connection = CONFIG_OPENSHOCK_PORTAL_MAX_CLIENTS; apConfig.ap.beacon_interval = 100; diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index bdd1400a..f2c5fee6 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_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index e8fc8114..c1e753dc 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -40,6 +40,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_TRUE(config.wifi.accessPointPassword.empty()); // An open access point 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); @@ -254,3 +255,18 @@ 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 leaves the access point open", "[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_TRUE(Config::GetWiFiAccessPointPassword().empty()); +} diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 729352e5..26aff9c0 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")); @@ -319,3 +321,20 @@ 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_TRUE(Config::GetWiFiAccessPointPassword().empty()); + + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("12345678")); + TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("")); // back to an open access point + Reload(); + TEST_ASSERT_TRUE(Config::GetWiFiAccessPointPassword().empty()); +} diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index 41de6d4a..2bc875bf 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,20 @@ 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 passphrase, 8-63 characters. A factory reset clears it. + 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; an AP password WPA2 can't use is dropped (an open AP); 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 +65,16 @@ namespace OpenShock::Config { bool PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]); bool RemoveWiFiCredentials(uint8_t id); bool ClearWiFiCredentials(); + /// @brief Whether `ssid` can name the captive portal's network: 1-32 bytes. + [[nodiscard]] bool IsValidAccessPointSSID(std::string_view ssid); + /// @brief Whether `password` can protect it: empty (open), or 8-63 characters for WPA2. + [[nodiscard]] bool IsValidAccessPointPassword(std::string_view password); + [[nodiscard]] std::string GetWiFiAccessPointSSID(); + /// @brief Refuses an SSID IsValidAccessPointSSID rejects. Takes effect the next time the captive portal starts. + bool SetWiFiAccessPointSSID(std::string ssid); + [[nodiscard]] std::string GetWiFiAccessPointPassword(); + /// @brief Refuses a password IsValidAccessPointPassword rejects. Takes effect the next time the captive portal starts. + bool SetWiFiAccessPointPassword(std::string password); [[nodiscard]] std::string GetWiFiHostname(); bool SetWiFiHostname(std::string hostname); } // namespace OpenShock::Config diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 770daab4..ac185421 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -24,6 +24,7 @@ static_assert(OpenShock::HostnameUtils::IsValid(CONFIG_OPENSHOCK_FW_HOSTNAME), " 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,18 +32,35 @@ static Section s_section({.name = "wifi", .order = 1, .namespaces = WiFiConfig::WiFiConfig() : accessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX) + , accessPointPassword() , hostname(CONFIG_OPENSHOCK_FW_HOSTNAME) , credentialsList() { } WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList) + : WiFiConfig(accessPointSSID, {}, 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) { } +bool OpenShock::Config::IsValidAccessPointSSID(std::string_view ssid) +{ + return !ssid.empty() && ssid.size() <= 32; +} + +bool OpenShock::Config::IsValidAccessPointPassword(std::string_view password) +{ + return password.empty() || (password.size() >= 8 && password.size() <= 63); +} + bool WiFiConfig::FromJSON(JSON::JsonView json) { if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { @@ -51,6 +69,8 @@ bool WiFiConfig::FromJSON(JSON::JsonView json) accessPointSSID = json["accessPointSSID"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PREFIX); + accessPointPassword = json["accessPointPassword"].tryGetStr().value_or(""); + hostname = json["hostname"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_HOSTNAME); JSON::JsonView credentialsListJson = json["credentials"]; @@ -73,6 +93,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 +144,15 @@ namespace { void WiFiConfig::Normalize() { + if (!IsValidAccessPointSSID(accessPointSSID)) { + OS_LOGW(TAG, "Access point SSID is not 1-32 characters, using the default"); + accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; + } + if (!IsValidAccessPointPassword(accessPointPassword)) { + OS_LOGW(TAG, "Access point password is not 8-63 characters, leaving the access point open"); + accessPointPassword.clear(); + } + if (!HostnameUtils::IsValid(hostname)) { OS_LOGW(TAG, "Hostname \"%s\" is not valid, using the default", hostname.c_str()); hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; @@ -151,7 +183,7 @@ void WiFiConfig::Normalize() bool WiFiConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::OpenForLoad(partition, kNamespaceCfg); - if (!handle || !Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { + if (!handle || !Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyApPassword, accessPointPassword, "") || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { return false; } @@ -194,7 +226,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; } } @@ -410,6 +442,42 @@ 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 (!IsValidAccessPointSSID(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; + }); +} + +std::string OpenShock::Config::GetWiFiAccessPointPassword() +{ + return s_section.Read([](const WiFiConfig& wifi) { return wifi.accessPointPassword; }); +} + +bool OpenShock::Config::SetWiFiAccessPointPassword(std::string password) +{ + if (!IsValidAccessPointPassword(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; + }); +} + std::string OpenShock::Config::GetWiFiHostname() { return s_section.Read([](const WiFiConfig& wifi) { return wifi.hostname; }); 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..caa0a234 --- /dev/null +++ b/components/serial_console/src/command_handlers/accesspoint.cpp @@ -0,0 +1,81 @@ +#include + +#include "serial_console/command_handlers/common.h" + +#include "StringHelpers.h" +#include "wifi/WiFiConfig.h" + +#include + +static void handleAccessPointGetCommand(std::string_view arg, bool isAutomated) +{ + (void)arg; + + std::string ssid = OpenShock::Config::GetWiFiAccessPointSSID(); + bool isDefault = ssid == CONFIG_OPENSHOCK_FW_AP_PREFIX; + bool hasPassword = !OpenShock::Config::GetWiFiAccessPointPassword().empty(); + + // 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(), isDefault ? "default" : "custom", hasPassword ? "protected" : "open"); +} + +static void handleAccessPointSsidCommand(std::string_view arg, bool isAutomated) +{ + // "default" brings back the prefix + MAC name + std::string ssid = OpenShock::StringIEquals(arg, "default") ? std::string(CONFIG_OPENSHOCK_FW_AP_PREFIX) : std::string(arg); + if (!OpenShock::Config::IsValidAccessPointSSID(ssid)) { + SERPR_ERROR("Invalid SSID (must be 1-32 characters)"); + return; + } + + if (!OpenShock::Config::SetWiFiAccessPointSSID(std::move(ssid))) { + 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) +{ + if (!OpenShock::Config::IsValidAccessPointPassword(arg) || arg.empty()) { + SERPR_ERROR("Invalid password (must be 8-63 characters; use \"ap open\" to remove it)"); + return; + } + + if (!OpenShock::Config::SetWiFiAccessPointPassword(std::string(arg))) { + 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 has a 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", "Protect the access point with a WPA2 password. A factory reset removes it.", handleAccessPointPasswordCommand); + passwordCommand.addArgument("password", "8-63 characters", "hunter2hunter2"); + + group.addCommand("open", "Remove the access point password.", handleAccessPointOpenCommand); + + return group; +} From 2665cc45e5c017f788c78a4bd0554d3fc2e7a7cb Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 5 Nov 2026 12:58:32 +0100 Subject: [PATCH 02/15] feat(wifi): protect the access point with WPA2/WPA3 and a default password A protected AP runs WPA2/WPA3 transition mode, so WPA3 clients get SAE even with a public password. The new Kconfig option OPENSHOCK_FW_AP_PASSWORD (default "openshock") applies when none is stored and after a factory reset; `ap open` still opens it. --- .changes/ap-name-and-password.md | 10 +++---- .../src/captiveportal/Manager.cpp | 8 ++++-- components/common/Kconfig | 9 +++++++ .../config/host_test/main/test_defaults.cpp | 17 ++++++++++-- .../config/host_test/main/test_roundtrip.cpp | 2 +- components/core/include/wifi/WiFiConfig.h | 7 ++--- components/core/src/wifi/WiFiConfig.cpp | 13 ++++----- .../src/command_handlers/accesspoint.cpp | 27 ++++++++++++------- 8 files changed, 65 insertions(+), 28 deletions(-) diff --git a/.changes/ap-name-and-password.md b/.changes/ap-name-and-password.md index ae0632ca..63fa1ebd 100644 --- a/.changes/ap-name-and-password.md +++ b/.changes/ap-name-and-password.md @@ -1,14 +1,14 @@ --- kind: added --- -Set the captive portal's network name and password +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 password, set over serial with `ap ssid` and `ap password` +The captive portal's WiFi network can now have its own name and a WPA2/WPA3 password, set over serial with `ap ssid` and `ap password` -By default the portal's network stays open and named after the hub, as before. A factory reset removes the password, so a forgotten one can always be recovered over USB. +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, but phones and computers that support WPA3 still get an encrypted connection nobody nearby can read. 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): empty for an open network, else 8-63 characters, with WPA2-PSK -- Add the `ap` serial command: `ap` shows the name and whether it is protected, `ap ssid `, `ap password ` and `ap open` +- 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 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` - Changes apply the next time the captive portal starts diff --git a/components/captive_portal/src/captiveportal/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp index 0c1f04d8..b0118f65 100644 --- a/components/captive_portal/src/captiveportal/Manager.cpp +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -198,8 +198,12 @@ static bool captiveportal_start() if (apPassword.empty()) { apConfig.ap.authmode = WIFI_AUTH_OPEN; } else { - apConfig.ap.authmode = WIFI_AUTH_WPA2_PSK; - std::size_t passwordLen = std::min(apPassword.size(), sizeof(apConfig.ap.password) - 1); + // 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(apPassword.size(), sizeof(apConfig.ap.password) - 1); memcpy(apConfig.ap.password, apPassword.data(), passwordLen); } apConfig.ap.max_connection = CONFIG_OPENSHOCK_PORTAL_MAX_CLIENTS; 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/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index c1e753dc..940ee7d8 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -40,7 +40,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_TRUE(config.wifi.accessPointPassword.empty()); // An open access point + 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); @@ -256,7 +256,7 @@ TEST_CASE("jsonconfig set requires the wifi credentials array when wifi is given TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); } -TEST_CASE("A stored access point password WPA2 can't use leaves the access point open", "[config][defaults]") +TEST_CASE("A stored access point password WPA2 can't use falls back to the default", "[config][defaults]") { WipeStore(); { @@ -268,5 +268,18 @@ TEST_CASE("A stored access point password WPA2 can't use leaves the access point 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()); } diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 26aff9c0..00542224 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -331,7 +331,7 @@ TEST_CASE("Access point setters refuse what WPA2 can't use", "[config][roundtrip 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_TRUE(Config::GetWiFiAccessPointPassword().empty()); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_AP_PASSWORD, Config::GetWiFiAccessPointPassword().c_str()); TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("12345678")); TEST_ASSERT_TRUE(Config::SetWiFiAccessPointPassword("")); // back to an open access point diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index 2bc875bf..a8220994 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -24,14 +24,15 @@ namespace OpenShock::Config { // 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 passphrase, 8-63 characters. A factory reset clears it. + // Empty: an open network. Otherwise its WPA2/WPA3 passphrase, 8-63 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, as does an AP SSID that is empty or - /// over 32 bytes; an AP password WPA2 can't use is dropped (an open AP); and a credential with no id (0) or a + /// 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(); @@ -67,7 +68,7 @@ namespace OpenShock::Config { bool ClearWiFiCredentials(); /// @brief Whether `ssid` can name the captive portal's network: 1-32 bytes. [[nodiscard]] bool IsValidAccessPointSSID(std::string_view ssid); - /// @brief Whether `password` can protect it: empty (open), or 8-63 characters for WPA2. + /// @brief Whether `password` can protect it: empty (open), or 8-63 characters for WPA2/WPA3. [[nodiscard]] bool IsValidAccessPointPassword(std::string_view password); [[nodiscard]] std::string GetWiFiAccessPointSSID(); /// @brief Refuses an SSID IsValidAccessPointSSID rejects. Takes effect the next time the captive portal starts. diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index ac185421..f32d05ab 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -20,6 +20,7 @@ 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(sizeof(CONFIG_OPENSHOCK_FW_AP_PASSWORD) == 1 || (sizeof(CONFIG_OPENSHOCK_FW_AP_PASSWORD) >= 9 && sizeof(CONFIG_OPENSHOCK_FW_AP_PASSWORD) <= 64), "CONFIG_OPENSHOCK_FW_AP_PASSWORD must be empty or 8-63 characters"); static constexpr const char* kNamespaceCfg = "wifi.cfg"; static constexpr const char* kNamespaceCreds = "wifi"; @@ -32,14 +33,14 @@ static Section s_section({.name = "wifi", .order = 1, .namespaces = WiFiConfig::WiFiConfig() : accessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX) - , accessPointPassword() + , 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, {}, hostname, credentialsList) + : WiFiConfig(accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PASSWORD, hostname, credentialsList) { } @@ -69,7 +70,7 @@ bool WiFiConfig::FromJSON(JSON::JsonView json) accessPointSSID = json["accessPointSSID"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PREFIX); - accessPointPassword = json["accessPointPassword"].tryGetStr().value_or(""); + accessPointPassword = json["accessPointPassword"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PASSWORD); hostname = json["hostname"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_HOSTNAME); @@ -149,8 +150,8 @@ void WiFiConfig::Normalize() accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; } if (!IsValidAccessPointPassword(accessPointPassword)) { - OS_LOGW(TAG, "Access point password is not 8-63 characters, leaving the access point open"); - accessPointPassword.clear(); + OS_LOGW(TAG, "Access point password is not 8-63 characters, using the default"); + accessPointPassword = CONFIG_OPENSHOCK_FW_AP_PASSWORD; } if (!HostnameUtils::IsValid(hostname)) { @@ -183,7 +184,7 @@ void WiFiConfig::Normalize() bool WiFiConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::OpenForLoad(partition, kNamespaceCfg); - if (!handle || !Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyApPassword, accessPointPassword, "") || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { + if (!handle || !Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyApPassword, accessPointPassword, CONFIG_OPENSHOCK_FW_AP_PASSWORD) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { return false; } diff --git a/components/serial_console/src/command_handlers/accesspoint.cpp b/components/serial_console/src/command_handlers/accesspoint.cpp index caa0a234..962a3af3 100644 --- a/components/serial_console/src/command_handlers/accesspoint.cpp +++ b/components/serial_console/src/command_handlers/accesspoint.cpp @@ -11,12 +11,19 @@ static void handleAccessPointGetCommand(std::string_view arg, bool isAutomated) { (void)arg; - std::string ssid = OpenShock::Config::GetWiFiAccessPointSSID(); - bool isDefault = ssid == CONFIG_OPENSHOCK_FW_AP_PREFIX; - bool hasPassword = !OpenShock::Config::GetWiFiAccessPointPassword().empty(); + std::string ssid = OpenShock::Config::GetWiFiAccessPointSSID(); + std::string password = OpenShock::Config::GetWiFiAccessPointPassword(); + bool isDefault = ssid == CONFIG_OPENSHOCK_FW_AP_PREFIX; + + const char* protection = "custom-password"; + if (password.empty()) { + protection = "open"; + } else if (password == CONFIG_OPENSHOCK_FW_AP_PASSWORD) { + protection = "default-password"; + } // 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(), isDefault ? "default" : "custom", hasPassword ? "protected" : "open"); + SERPR_RESPONSE("AccessPoint|%s|%s|%s", ssid.c_str(), isDefault ? "default" : "custom", protection); } static void handleAccessPointSsidCommand(std::string_view arg, bool isAutomated) @@ -38,12 +45,14 @@ static void handleAccessPointSsidCommand(std::string_view arg, bool isAutomated) static void handleAccessPointPasswordCommand(std::string_view arg, bool isAutomated) { - if (!OpenShock::Config::IsValidAccessPointPassword(arg) || arg.empty()) { + // "default" brings back the build's default password; it is too short to be a password itself + std::string password = OpenShock::StringIEquals(arg, "default") ? std::string(CONFIG_OPENSHOCK_FW_AP_PASSWORD) : std::string(arg); + if (!OpenShock::Config::IsValidAccessPointPassword(password) || arg.empty()) { SERPR_ERROR("Invalid password (must be 8-63 characters; use \"ap open\" to remove it)"); return; } - if (!OpenShock::Config::SetWiFiAccessPointPassword(std::string(arg))) { + if (!OpenShock::Config::SetWiFiAccessPointPassword(std::move(password))) { SERPR_ERROR("Failed to save config"); return; } @@ -67,13 +76,13 @@ OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::Acce { auto group = OpenShock::SerialCmds::CommandGroup("ap"); - group.addCommand("Get the captive portal's access point name and whether it has a password.", handleAccessPointGetCommand); + 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", "Protect the access point with a WPA2 password. A factory reset removes it.", handleAccessPointPasswordCommand); - passwordCommand.addArgument("password", "8-63 characters", "hunter2hunter2"); + 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); From 85809bae11beb0ef164c95527459cbea7cefdf5d Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 5 Nov 2026 15:43:29 +0100 Subject: [PATCH 03/15] fix(wifi): tighten access point name and password checks and keep networks when the password won't read The name can't contain '|', which `ap` prints as a field separator. The password must be printable ASCII, so a NUL can't cut it short in the driver. An unreadable stored password falls back to the default instead of failing the load and wiping saved networks. The release note no longer claims WPA3 protects against someone who knows the public password. --- .changes/ap-name-and-password.md | 4 +-- .../config/host_test/main/test_defaults.cpp | 32 +++++++++++++++++++ components/core/include/wifi/WiFiConfig.h | 6 ++-- components/core/src/wifi/WiFiConfig.cpp | 22 ++++++++++--- .../src/command_handlers/accesspoint.cpp | 4 +-- 5 files changed, 56 insertions(+), 12 deletions(-) diff --git a/.changes/ap-name-and-password.md b/.changes/ap-name-and-password.md index 63fa1ebd..6738376b 100644 --- a/.changes/ap-name-and-password.md +++ b/.changes/ap-name-and-password.md @@ -6,9 +6,9 @@ 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 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, but phones and computers that support WPA3 still get an encrypted connection nobody nearby can read. 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. +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 characters, with WPA2/WPA3-PSK transition mode (WPA3 clients use SAE, WPA2 clients still connect) +- 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` - Changes apply the next time the captive portal starts diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 940ee7d8..ec1a2843 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -283,3 +283,35 @@ TEST_CASE("An open access point stays open across a reload", "[config][defaults] 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(Config::IsValidAccessPointPassword(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")); +} diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index a8220994..d3c1c377 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -24,7 +24,7 @@ namespace OpenShock::Config { // 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 characters. + // 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; @@ -66,9 +66,9 @@ namespace OpenShock::Config { bool PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]); bool RemoveWiFiCredentials(uint8_t id); bool ClearWiFiCredentials(); - /// @brief Whether `ssid` can name the captive portal's network: 1-32 bytes. + /// @brief Whether `ssid` can name the captive portal's network: 1-32 bytes, no '|' since `ap` prints it as a field. [[nodiscard]] bool IsValidAccessPointSSID(std::string_view ssid); - /// @brief Whether `password` can protect it: empty (open), or 8-63 characters for WPA2/WPA3. + /// @brief Whether `password` can protect it: empty (open), or 8-63 printable ASCII characters for WPA2/WPA3. [[nodiscard]] bool IsValidAccessPointPassword(std::string_view password); [[nodiscard]] std::string GetWiFiAccessPointSSID(); /// @brief Refuses an SSID IsValidAccessPointSSID rejects. Takes effect the next time the captive portal starts. diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index f32d05ab..9842904e 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -54,12 +54,17 @@ WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view access bool OpenShock::Config::IsValidAccessPointSSID(std::string_view ssid) { - return !ssid.empty() && ssid.size() <= 32; + return !ssid.empty() && ssid.size() <= 32 && ssid.find('|') == std::string_view::npos; } bool OpenShock::Config::IsValidAccessPointPassword(std::string_view password) { - return password.empty() || (password.size() >= 8 && password.size() <= 63); + if (password.empty()) { + return true; + } + + // A WPA passphrase is printable ASCII; the driver also reads it as a C string, so a NUL would cut it short. + return password.size() >= 8 && password.size() <= 63 && std::ranges::all_of(password, [](char c) { return c >= 0x20 && c <= 0x7E; }); } bool WiFiConfig::FromJSON(JSON::JsonView json) @@ -146,11 +151,11 @@ namespace { void WiFiConfig::Normalize() { if (!IsValidAccessPointSSID(accessPointSSID)) { - OS_LOGW(TAG, "Access point SSID is not 1-32 characters, using the default"); + OS_LOGW(TAG, "Access point SSID is not 1-32 characters without '|', using the default"); accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; } if (!IsValidAccessPointPassword(accessPointPassword)) { - OS_LOGW(TAG, "Access point password is not 8-63 characters, using the default"); + OS_LOGW(TAG, "Access point password is not 8-63 printable ASCII characters, using the default"); accessPointPassword = CONFIG_OPENSHOCK_FW_AP_PASSWORD; } @@ -184,10 +189,17 @@ void WiFiConfig::Normalize() bool WiFiConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::OpenForLoad(partition, kNamespaceCfg); - if (!handle || !Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyApPassword, accessPointPassword, CONFIG_OPENSHOCK_FW_AP_PASSWORD) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { + if (!handle || !Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { 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) { diff --git a/components/serial_console/src/command_handlers/accesspoint.cpp b/components/serial_console/src/command_handlers/accesspoint.cpp index 962a3af3..8b89a165 100644 --- a/components/serial_console/src/command_handlers/accesspoint.cpp +++ b/components/serial_console/src/command_handlers/accesspoint.cpp @@ -31,7 +31,7 @@ static void handleAccessPointSsidCommand(std::string_view arg, bool isAutomated) // "default" brings back the prefix + MAC name std::string ssid = OpenShock::StringIEquals(arg, "default") ? std::string(CONFIG_OPENSHOCK_FW_AP_PREFIX) : std::string(arg); if (!OpenShock::Config::IsValidAccessPointSSID(ssid)) { - SERPR_ERROR("Invalid SSID (must be 1-32 characters)"); + SERPR_ERROR("Invalid SSID (must be 1-32 characters, without '|')"); return; } @@ -48,7 +48,7 @@ static void handleAccessPointPasswordCommand(std::string_view arg, bool isAutoma // "default" brings back the build's default password; it is too short to be a password itself std::string password = OpenShock::StringIEquals(arg, "default") ? std::string(CONFIG_OPENSHOCK_FW_AP_PASSWORD) : std::string(arg); if (!OpenShock::Config::IsValidAccessPointPassword(password) || arg.empty()) { - SERPR_ERROR("Invalid password (must be 8-63 characters; use \"ap open\" to remove it)"); + SERPR_ERROR("Invalid password (must be 8-63 printable ASCII characters; use \"ap open\" to remove it)"); return; } From f31c5a054c1f0916f3fa010aae53f97c2d70f507 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 5 Nov 2026 15:43:29 +0100 Subject: [PATCH 04/15] test(config): read back the access point settings and check the redacted export omits the AP password --- components/config/host_test/main/test_roundtrip.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 00542224..00ef485b 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -48,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()); @@ -268,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\"")); } @@ -334,6 +339,7 @@ TEST_CASE("Access point setters refuse what WPA2 can't use", "[config][roundtrip 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()); From 6e37f40ccba254e0a600b077528f6ad56b9917c9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 5 Nov 2026 15:57:20 +0100 Subject: [PATCH 05/15] refactor(common): add StringIsPrintableAscii and use it for the access point password --- .../common/host_test/main/test_stringutils.cpp | 11 +++++++++++ components/common/include/StringHelpers.h | 16 ++++++++++++++++ components/core/src/wifi/WiFiConfig.cpp | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/components/common/host_test/main/test_stringutils.cpp b/components/common/host_test/main/test_stringutils.cpp index e241133e..8cd79d28 100644 --- a/components/common/host_test/main/test_stringutils.cpp +++ b/components/common/host_test/main/test_stringutils.cpp @@ -81,6 +81,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..5b6d77c6 100644 --- a/components/common/include/StringHelpers.h +++ b/components/common/include/StringHelpers.h @@ -20,11 +20,27 @@ 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; } + // Whether every byte is printable ASCII (space to '~'): no control characters, no NUL, no UTF-8. + constexpr bool StringIsPrintableAscii(std::string_view view) + { + for (char c : view) { + if (!CharIsPrint(c)) { + return false; + } + } + + return true; + } + constexpr std::string_view StringTrimLeft(std::string_view view) { std::size_t pos = 0; diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 9842904e..bcc2f982 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -8,6 +8,7 @@ const char* const TAG = "Config::WiFiConfig"; #include "config/Parse.h" #include "Convert.h" #include "Logging.h" +#include "StringHelpers.h" #include "util/HostnameUtils.h" #include "json/Json.h" @@ -64,7 +65,7 @@ bool OpenShock::Config::IsValidAccessPointPassword(std::string_view password) } // A WPA passphrase is printable ASCII; the driver also reads it as a C string, so a NUL would cut it short. - return password.size() >= 8 && password.size() <= 63 && std::ranges::all_of(password, [](char c) { return c >= 0x20 && c <= 0x7E; }); + return password.size() >= 8 && password.size() <= 63 && OpenShock::StringIsPrintableAscii(password); } bool WiFiConfig::FromJSON(JSON::JsonView json) From 15e2b766a31056c68cd853347ce4266edae54b18 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 5 Nov 2026 16:00:07 +0100 Subject: [PATCH 06/15] refactor(common): use ranges algorithms in StringHelpers --- .../host_test/main/test_stringutils.cpp | 1 + components/common/include/StringHelpers.h | 25 +++++-------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/components/common/host_test/main/test_stringutils.cpp b/components/common/host_test/main/test_stringutils.cpp index 8cd79d28..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]") diff --git a/components/common/include/StringHelpers.h b/components/common/include/StringHelpers.h index 5b6d77c6..dcb8bd7c 100644 --- a/components/common/include/StringHelpers.h +++ b/components/common/include/StringHelpers.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -32,32 +33,18 @@ namespace OpenShock { // Whether every byte is printable ASCII (space to '~'): no control characters, no NUL, no UTF-8. constexpr bool StringIsPrintableAscii(std::string_view view) { - for (char c : view) { - if (!CharIsPrint(c)) { - return false; - } - } - - return true; + return std::ranges::all_of(view, CharIsPrint); } constexpr std::string_view StringTrimLeft(std::string_view view) { - std::size_t pos = 0; - while (pos < view.size() && CharIsSpace(view[pos])) { - ++pos; - } - - return view.substr(pos); + 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) { From 62c6ac05f64f295cbb711f56257aed8cbe034ae1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 6 Nov 2026 13:57:55 +0100 Subject: [PATCH 07/15] fix(wifi): validate the AP defaults at compile time and reject control characters in the SSID --- .../config/host_test/main/test_defaults.cpp | 3 +++ components/core/include/wifi/WiFiConfig.h | 16 ++++++++++++--- components/core/src/wifi/WiFiConfig.cpp | 20 +++---------------- .../src/command_handlers/accesspoint.cpp | 2 +- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index e8d2b3f2..7d789efb 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -317,4 +317,7 @@ TEST_CASE("A stored access point SSID with '|' falls back to the default", "[con 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(Config::IsValidAccessPointSSID("Caf\xC3\xA9 Hub")); } diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index d3c1c377..bfae884d 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -6,9 +6,11 @@ // namespace "wifi": one blob per credential, key = its id in decimal ("1".."255"); enumerated, never counted #include "config/Section.h" +#include "StringHelpers.h" #include "util/FunctionRef.h" #include "wifi/WiFiCredentials.h" +#include #include #include #include @@ -66,10 +68,18 @@ namespace OpenShock::Config { bool PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]); bool RemoveWiFiCredentials(uint8_t id); bool ClearWiFiCredentials(); - /// @brief Whether `ssid` can name the captive portal's network: 1-32 bytes, no '|' since `ap` prints it as a field. - [[nodiscard]] bool IsValidAccessPointSSID(std::string_view ssid); + /// @brief Whether `ssid` can name the captive portal's network: 1-32 bytes, no '|' or control characters. + /// `ap` prints it as a field of a CRLF-framed record, so either would split the response. + [[nodiscard]] constexpr bool IsValidAccessPointSSID(std::string_view ssid) + { + return !ssid.empty() && ssid.size() <= 32 && 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. - [[nodiscard]] bool IsValidAccessPointPassword(std::string_view password); + /// The driver reads it as a C string, so a NUL would cut it short. + [[nodiscard]] constexpr bool IsValidAccessPointPassword(std::string_view password) + { + return password.empty() || (password.size() >= 8 && password.size() <= 63 && OpenShock::StringIsPrintableAscii(password)); + } [[nodiscard]] std::string GetWiFiAccessPointSSID(); /// @brief Refuses an SSID IsValidAccessPointSSID rejects. Takes effect the next time the captive portal starts. bool SetWiFiAccessPointSSID(std::string ssid); diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index bcc2f982..fc823e4d 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -21,7 +21,8 @@ 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(sizeof(CONFIG_OPENSHOCK_FW_AP_PASSWORD) == 1 || (sizeof(CONFIG_OPENSHOCK_FW_AP_PASSWORD) >= 9 && sizeof(CONFIG_OPENSHOCK_FW_AP_PASSWORD) <= 64), "CONFIG_OPENSHOCK_FW_AP_PASSWORD must be empty or 8-63 characters"); +static_assert(OpenShock::Config::IsValidAccessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX), "CONFIG_OPENSHOCK_FW_AP_PREFIX must be 1-32 characters without '|' or control characters"); +static_assert(OpenShock::Config::IsValidAccessPointPassword(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"; @@ -53,21 +54,6 @@ WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view access { } -bool OpenShock::Config::IsValidAccessPointSSID(std::string_view ssid) -{ - return !ssid.empty() && ssid.size() <= 32 && ssid.find('|') == std::string_view::npos; -} - -bool OpenShock::Config::IsValidAccessPointPassword(std::string_view password) -{ - if (password.empty()) { - return true; - } - - // A WPA passphrase is printable ASCII; the driver also reads it as a C string, so a NUL would cut it short. - return password.size() >= 8 && password.size() <= 63 && OpenShock::StringIsPrintableAscii(password); -} - bool WiFiConfig::FromJSON(JSON::JsonView json) { if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { @@ -152,7 +138,7 @@ namespace { void WiFiConfig::Normalize() { if (!IsValidAccessPointSSID(accessPointSSID)) { - OS_LOGW(TAG, "Access point SSID is not 1-32 characters without '|', using the default"); + 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 (!IsValidAccessPointPassword(accessPointPassword)) { diff --git a/components/serial_console/src/command_handlers/accesspoint.cpp b/components/serial_console/src/command_handlers/accesspoint.cpp index 8b89a165..9bdffe27 100644 --- a/components/serial_console/src/command_handlers/accesspoint.cpp +++ b/components/serial_console/src/command_handlers/accesspoint.cpp @@ -31,7 +31,7 @@ static void handleAccessPointSsidCommand(std::string_view arg, bool isAutomated) // "default" brings back the prefix + MAC name std::string ssid = OpenShock::StringIEquals(arg, "default") ? std::string(CONFIG_OPENSHOCK_FW_AP_PREFIX) : std::string(arg); if (!OpenShock::Config::IsValidAccessPointSSID(ssid)) { - SERPR_ERROR("Invalid SSID (must be 1-32 characters, without '|')"); + SERPR_ERROR("Invalid SSID (must be 1-32 characters, without '|' or control characters)"); return; } From 4910bc2060fb6de6b14a45d84fc1fbb58e8628c1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 6 Nov 2026 14:23:45 +0100 Subject: [PATCH 08/15] fix(wifi): reject an invalid access point password in a JSON import --- components/config/host_test/main/test_corrupt.cpp | 2 ++ components/core/src/wifi/WiFiConfig.cpp | 5 +++++ 2 files changed, 7 insertions(+) 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/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index fc823e4d..15f6ebaf 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -63,6 +63,11 @@ 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 (!IsValidAccessPointPassword(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); From 12bc7e2455b0c4b77eadbbf6c8678b250d0e2002 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 5 Nov 2026 17:24:36 +0100 Subject: [PATCH 09/15] fix(portal): bind the servers to the AP's interface after a config change restarts it Enabling the AP starts it with its old config, and setting the configured name or password restarts it: stopped, its netif removed from lwIP, started again under a new name. The portal took the first start's signal and read the name mid-restart, got nothing, and failed its first start after every boot with a protected AP. It now lets the event loop handle both starts before reading the name. --- .../src/captiveportal/Manager.cpp | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/components/captive_portal/src/captiveportal/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp index e4d8ec86..c73dc1f7 100644 --- a/components/captive_portal/src/captiveportal/Manager.cpp +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -83,6 +83,13 @@ 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_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) @@ -154,6 +161,21 @@ 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*) +{ + xSemaphoreGive(s_loopFlushed); +} + +// Waits until the default event loop has handled everything posted to it before this call +static bool waitForEventLoop(TickType_t timeout) +{ + xSemaphoreTake(s_loopFlushed, 0); // drop a flush left over from an earlier call that timed out + if (esp_event_post(CAPTIVEPORTAL_PRIVATE_EVENTS, CAPTIVEPORTAL_PRIVATE_EVENT_LOOP_FLUSH, nullptr, 0, timeout) != ESP_OK) { + return false; + } + return xSemaphoreTake(s_loopFlushed, timeout) == pdTRUE; +} + static bool captiveportal_start() { if (GetInstance() != nullptr) { @@ -197,8 +219,11 @@ 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; @@ -235,7 +260,6 @@ static bool captiveportal_start() apConfig.ap.beacon_interval = 100; 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) { @@ -252,10 +276,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); @@ -431,7 +466,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; From da4c2bb79ae88d8460b8e6fd9241440ef66bf6dc Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 5 Nov 2026 17:31:17 +0100 Subject: [PATCH 10/15] feat(portal): change the access point's name and password from the captive portal A new Access Point section shows the saved name and protection and sets a name (empty for the default), the default password, an own password, or an open network. It saves through PUT /api/ap in one config write; the change applies the next time the portal starts, so the client on the access point keeps its connection. The Ready message carries the name and protection, never the password. --- .changes/ap-name-and-password.md | 3 +- .../src/captiveportal/ApiHandlers.cpp | 69 ++++++ .../src/captiveportal/Manager.cpp | 12 +- components/core/include/wifi/WiFiConfig.h | 6 + components/core/src/serialization/WSLocal.cpp | 25 +- components/core/src/wifi/WiFiConfig.cpp | 41 ++++ .../_fbs/HubToLocalMessage_generated.h | 147 +++++++++++- .../local/access-point-protection.ts | 9 + .../local/access-point-settings.ts | 84 +++++++ .../serialization/local/ready-message.ts | 12 +- frontend/src/lib/api.ts | 41 +++- .../sections/AccessPointSection.svelte | 220 ++++++++++++++++++ frontend/src/lib/mappers/ConfigMapper.test.ts | 24 ++ frontend/src/lib/mappers/ConfigMapper.ts | 29 +++ frontend/src/lib/views/Advanced.svelte | 14 +- schemas | 2 +- 16 files changed, 717 insertions(+), 21 deletions(-) create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/access-point-protection.ts create mode 100644 frontend/src/lib/_fbs/open-shock/serialization/local/access-point-settings.ts create mode 100644 frontend/src/lib/components/sections/AccessPointSection.svelte diff --git a/.changes/ap-name-and-password.md b/.changes/ap-name-and-password.md index 6738376b..2645fc0d 100644 --- a/.changes/ap-name-and-password.md +++ b/.changes/ap-name-and-password.md @@ -4,11 +4,12 @@ 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 over serial with `ap ssid` and `ap password` +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`), 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..f6f92854 100644 --- a/components/captive_portal/src/captiveportal/ApiHandlers.cpp +++ b/components/captive_portal/src/captiveportal/ApiHandlers.cpp @@ -19,6 +19,7 @@ const char* const TAG = "CaptivePortalApi"; #include "OtaUpdateConfig.h" #include "RateLimiter.h" #include "util/TaskUtils.h" +#include "wifi/WiFiConfig.h" #include "wifi/WiFiManager.h" #include "wifi/WiFiScanManager.h" @@ -43,6 +44,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; @@ -385,6 +388,71 @@ 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 +// Saved in one config write, 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. +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); + } + + 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) { + ssid = ssidParam->empty() ? std::string(CONFIG_OPENSHOCK_FW_AP_PREFIX) : std::move(*ssidParam); + if (!Config::IsValidAccessPointSSID(*ssid)) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_INVALID_AP_SSID); + } + } + + std::optional password; + auto protection = getFormParam(*body, "protection"); + if (!protection && protection.error() != ParamError::Missing) { + return sendParamError(req, protection.error()); + } + if (protection) { + if (*protection == "default") { + password = std::string(CONFIG_OPENSHOCK_FW_AP_PASSWORD); + } 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() < 8) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_SHORT); + } + if (passwordParam->length() > 63) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_PASSWORD_LONG); + } + if (!Config::IsValidAccessPointPassword(*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 (!ssid && !password) { + return sendResp(req, S400, HTTP::ContentType::JSON, JSON_ERR_MISSING_PARAM); + } + + if (!Config::SetWiFiAccessPoint(std::move(ssid), std::move(password))) { + return sendResp(req, S500, HTTP::ContentType::JSON, JSON_ERR_INTERNAL); + } + return sendResp(req, S200, nullptr, {}); +} + void CaptivePortal::RegisterApiHandlers(httpd_handle_t server, CaptivePortalInstance* instance) { registerHandler(server, "/api/board", HTTP_GET, instance); @@ -402,4 +470,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 c73dc1f7..4c24d818 100644 --- a/components/captive_portal/src/captiveportal/Manager.cpp +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -235,15 +235,9 @@ static bool captiveportal_start() const std::string apPassword = Config::GetWiFiAccessPointPassword(); wifi_config_t apConfig = {}; - if (apSsid == CONFIG_OPENSHOCK_FW_AP_PREFIX) { - uint8_t mac[6]; - esp_read_mac(mac, ESP_MAC_WIFI_STA); - 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)); - } else { - apConfig.ap.ssid_len = std::min(apSsid.size(), sizeof(apConfig.ap.ssid)); - memcpy(apConfig.ap.ssid, apSsid.data(), apConfig.ap.ssid_len); - } + const std::string ssid = apSsid == CONFIG_OPENSHOCK_FW_AP_PREFIX ? Config::GetDefaultAccessPointSSID() : apSsid; + 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 (apPassword.empty()) { apConfig.ap.authmode = WIFI_AUTH_OPEN; diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index bfae884d..ad6f6bd2 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -86,6 +86,12 @@ namespace OpenShock::Config { [[nodiscard]] std::string GetWiFiAccessPointPassword(); /// @brief Refuses a password IsValidAccessPointPassword rejects. Takes effect the next time the captive portal starts. bool SetWiFiAccessPointPassword(std::string password); + /// @brief Sets the name and the password in one config write; an unset one is kept. Refuses the whole change if + /// either is invalid. Takes effect the next time the captive portal starts. + bool SetWiFiAccessPoint(std::optional ssid, std::optional password); + /// @brief The name the access point broadcasts while its SSID is the default (CONFIG_OPENSHOCK_FW_AP_PREFIX): the + /// prefix with this hub's STA MAC. + [[nodiscard]] std::string GetDefaultAccessPointSSID(); [[nodiscard]] std::string GetWiFiHostname(); bool SetWiFiHostname(std::string hostname); } // namespace OpenShock::Config diff --git a/components/core/src/serialization/WSLocal.cpp b/components/core/src/serialization/WSLocal.cpp index ede4a9c6..8de47c7c 100644 --- a/components/core/src/serialization/WSLocal.cpp +++ b/components/core/src/serialization/WSLocal.cpp @@ -75,6 +75,25 @@ static flatbuffers::Offset createOtaSettings(flatbuffers::Fl return Local::CreateOtaSettings(builder, ota.isEnabled, cdnDomain, static_cast(ota.updateChannel), ota.checkInterval, ota.allowBackendManagement, ota.requireManualApproval); } +// 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(); + + auto protection = Local::AccessPointProtection::CustomPassword; + if (password.empty()) { + protection = Local::AccessPointProtection::Open; + } else if (password == CONFIG_OPENSHOCK_FW_AP_PASSWORD) { + protection = Local::AccessPointProtection::DefaultPassword; + } + + const bool isDefault = ssid == CONFIG_OPENSHOCK_FW_AP_PREFIX; + auto ssidOffset = builder.CreateString(isDefault ? std::string_view() : std::string_view(ssid)); + auto defaultOffset = builder.CreateString(OpenShock::Config::GetDefaultAccessPointSSID()); + 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/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 15f6ebaf..67e9bb18 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -7,6 +7,7 @@ const char* const TAG = "Config::WiFiConfig"; #include "config/Nvs.h" #include "config/Parse.h" #include "Convert.h" +#include "FormatHelpers.h" #include "Logging.h" #include "StringHelpers.h" #include "util/HostnameUtils.h" @@ -18,6 +19,8 @@ const char* const TAG = "Config::WiFiConfig"; #include #include +#include + 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"); @@ -483,6 +486,44 @@ bool OpenShock::Config::SetWiFiAccessPointPassword(std::string password) }); } +bool OpenShock::Config::SetWiFiAccessPoint(std::optional ssid, std::optional password) +{ + if (ssid.has_value() && !IsValidAccessPointSSID(*ssid)) { + OS_LOGE(TAG, "Invalid access point SSID length %zu", ssid->size()); + return false; + } + if (password.has_value() && !IsValidAccessPointPassword(*password)) { + OS_LOGE(TAG, "Invalid access point password length %zu", password->size()); + return false; + } + + return s_section.Update([&](WiFiConfig& wifi) { + bool changed = false; + if (ssid.has_value() && wifi.accessPointSSID != *ssid) { + wifi.accessPointSSID = std::move(*ssid); + changed = true; + } + if (password.has_value() && wifi.accessPointPassword != *password) { + wifi.accessPointPassword = std::move(*password); + changed = true; + } + return changed; + }); +} + +// The MAC takes 17 characters ("AA:BB:CC:DD:EE:FF") of an SSID's 32, so the prefix gets the other 15 +static_assert(sizeof(CONFIG_OPENSHOCK_FW_AP_PREFIX) - 1 + 17 <= 32, "CONFIG_OPENSHOCK_FW_AP_PREFIX must be at most 15 characters"); + +std::string OpenShock::Config::GetDefaultAccessPointSSID() +{ + uint8_t mac[6] = {}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + + char ssid[33]; // An SSID's 32 bytes and the NUL + snprintf(ssid, sizeof(ssid), "%s" BSSID_FMT, CONFIG_OPENSHOCK_FW_AP_PREFIX, BSSID_ARG(mac)); + return ssid; +} + std::string OpenShock::Config::GetWiFiHostname() { return s_section.Read([](const WiFiConfig& wifi) { return wifi.hostname; }); 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..4f0d6dd5 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 '|'", + InvalidApPassword: 'Password must be printable ASCII characters (no accents or emoji)', // Generic MissingParam: 'Missing parameter', InvalidParam: 'Invalid value', @@ -298,3 +301,39 @@ 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' +} + +// The captive portal's access point (PUT /api/ap). Saved at once, applied the next time the portal starts. On success +// the change is mirrored into hubState.config, which is 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) { + if (change.ssid !== undefined) ap.ssid = change.ssid || null; + if (change.protection !== undefined) ap.protection = change.protection; + } + return true; + } catch { + toast.error('Failed to save the access point'); + return false; + } +} diff --git a/frontend/src/lib/components/sections/AccessPointSection.svelte b/frontend/src/lib/components/sections/AccessPointSection.svelte new file mode 100644 index 00000000..c2981f3a --- /dev/null +++ b/frontend/src/lib/components/sections/AccessPointSection.svelte @@ -0,0 +1,220 @@ + + +
+ + + {#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/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 @@