Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ build_flags =
-D BUILD_TARGET=\"$PIOENV\"
-D APP_NAME=\"MoonLight\" ; 🌙 Must only contain characters from [a-zA-Z0-9-_] as this is converted into a filename
-D APP_VERSION=\"0.9.1\" ; semver compatible version string
-D APP_DATE=\"20260504\" ; 🌙
-D APP_DATE=\"20260505\" ; 🌙

-D PLATFORM_VERSION=\"pioarduino-55.03.37\" ; 🌙 make sure it matches with above plaftform

Expand Down
67 changes: 48 additions & 19 deletions src/MoonBase/Modules/ModuleDevices.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ class ModuleDevices : public Module {
false);
}

void begin() override {
Module::begin(); // loads state from filesystem
// Device list is dynamic — rebuilt from UDP every 10 s.
// Clear any stale or garbled entries that may have been persisted.
_state.data["devices"].to<JsonArray>();
EXT_LOGD(MB_TAG, "cleared persisted device list — will rebuild from UDP");
}

void setupDefinition(const JsonArray& controls) override {
EXT_LOGV(MB_TAG, "");
JsonObject control; // state.data has one or more properties
Expand Down Expand Up @@ -206,16 +214,18 @@ class ModuleDevices : public Module {

// Update device table from a full MoonLight discovery packet
void updateDevices(const UDPMessage& message, IPAddress ip) {
// Validate here (not just in receiveUDP) so the sendUDP(false) self-update path is also guarded.
if (!isValidHostname(message.header.name, sizeof(message.header.name))) {
EXT_LOGW(MB_TAG, "Skipping device update with invalid name from ...%d", ip[3]);
return;
}

// EXT_LOGD(MB_TAG, "updateDevices ...%d %s", ip[3], message.header.name);
if (_state.data["devices"].isNull()) _state.data["devices"].to<JsonArray>();

// set the doc
// deep-copy current state so we can modify it independently of _state.data
JsonDocument doc;
if (_sveltekit->getSocket()->getActiveClients()) { // rebuild the devices array
doc.set(_state.data); // copy
} else {
doc = _state.data; // reference
}
doc.set(_state.data);

// set the devices array
JsonArray devices = doc["devices"];
Expand All @@ -240,9 +250,12 @@ class ModuleDevices : public Module {

device["ip"] = ip.toString();
device["lastSync"] = time(nullptr); // time will change, triggering update
device["name"] = message.header.name;
device["version"] = message.versionStr;
device["build"] = message.build;
// String() forces ArduinoJson to copy bytes into its pool rather than linking a const char*
// pointer to the stack-allocated message struct. A linked pointer becomes dangling after
// updateDevices() returns, causing compareRecursive() to read garbage on the next update cycle.
device["name"] = String(message.header.name);
device["version"] = String(message.versionStr);
device["build"] = String(message.build);
device["uptime"] = message.uptime;
device["packageSize"] = message.packageSize;
device["lightsOn"] = (message.header.type & 0x80) != 0;
Expand All @@ -257,13 +270,9 @@ class ModuleDevices : public Module {
void updateDevicesWLED(const UDPWLEDHeader& header, IPAddress ip) {
if (_state.data["devices"].isNull()) _state.data["devices"].to<JsonArray>();

// set the doc
// deep-copy current state so we can modify it independently of _state.data
JsonDocument doc;
if (_sveltekit->getSocket()->getActiveClients()) { // rebuild the devices array
doc.set(_state.data); // copy
} else {
doc = _state.data; // reference
}
doc.set(_state.data);

// set the devices array
JsonArray devices = doc["devices"];
Expand All @@ -287,7 +296,7 @@ class ModuleDevices : public Module {

device["ip"] = ip.toString();
device["lastSync"] = time(nullptr); // time will change, triggering update
device["name"] = header.name;
device["name"] = String(header.name); // force copy — same reason as updateDevices()
char verBuf[12];
snprintf(verBuf, sizeof(verBuf), "%lu", (unsigned long)header.version);
device["version"] = verBuf;
Expand Down Expand Up @@ -342,10 +351,17 @@ class ModuleDevices : public Module {
message.header.name[sizeof(message.header.name) - 1] = '\0';
message.versionStr[sizeof(message.versionStr) - 1] = '\0';
message.build[sizeof(message.build) - 1] = '\0';
if (message.header.token == 255 && message.header.id == 1)
if (message.header.token != 255 || message.header.id != 1) {
EXT_LOGW(MB_TAG, "Bad MoonLight header from ...%d (token=%d id=%d)", deviceUDP.remoteIP()[3], message.header.token, message.header.id);
} else if (message.packageSize != sizeof(UDPMessage)) {
// packageSize is a self-describing field: rejects foreign devices that happen to send
// exactly 101 bytes but with a different struct layout (wrong field at bytes 96-97)
EXT_LOGW(MB_TAG, "Struct mismatch from ...%d: got packageSize=%d, expected %d", deviceUDP.remoteIP()[3], message.packageSize, sizeof(UDPMessage));
} else if (isValidHostname(message.header.name, sizeof(message.header.name))) {
updateDevices(message, deviceUDP.remoteIP());
else
EXT_LOGW(MB_TAG, "Bad MoonLight header from ...%d", deviceUDP.remoteIP()[3]);
} else {
EXT_LOGW(MB_TAG, "Garbled name in packet from ...%d, rejecting", deviceUDP.remoteIP()[3]);
}
} else {
EXT_LOGW(MB_TAG, "Unknown packet size on port %d: %d (WLED=%d ML=%d)",
deviceUDPPort, packetSize, sizeof(UDPWLEDHeader), sizeof(UDPMessage));
Expand Down Expand Up @@ -409,6 +425,19 @@ class ModuleDevices : public Module {
}

private:
// Validate hostname: non-empty, [a-zA-Z0-9-] only.
// isprint() is NOT used — ESP32 newlib treats 0xA0-0xFF as printable (ISO-8859-1 locale),
// so garbled high-byte chars like ò/ô/ñ would pass an isprint() check.
static bool isValidHostname(const char* name, size_t maxLen) {
if (name[0] == '\0') return false;
for (size_t j = 0; j < maxLen - 1 && name[j]; j++) {
uint8_t c = (uint8_t)name[j];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'))
return false;
}
return true;
}

// Fill the 44-byte WLED-compatible header from local device info
void infoToHeader(UDPWLEDHeader& header, bool lightsOn) {
IPAddress localIP = networkLocalIP();
Expand Down
Loading