feature: add digest auth on power meter
Power Meter -> HTTP(S) + Jason configuration allows now basic and digest authentication (all Shelly Gen2 devices)
This commit is contained in:
parent
9a4eb75160
commit
006f63ed02
@ -31,6 +31,8 @@
|
||||
|
||||
#define POWERMETER_MAX_PHASES 3
|
||||
#define POWERMETER_MAX_HTTP_URL_STRLEN 1024
|
||||
#define POWERMETER_MAX_USERNAME_STRLEN 64
|
||||
#define POWERMETER_MAX_PASSWORD_STRLEN 64
|
||||
#define POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN 64
|
||||
#define POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN 256
|
||||
#define POWERMETER_MAX_HTTP_JSON_PATH_STRLEN 256
|
||||
@ -55,9 +57,13 @@ struct INVERTER_CONFIG_T {
|
||||
CHANNEL_CONFIG_T channel[INV_MAX_CHAN_COUNT];
|
||||
};
|
||||
|
||||
enum Auth { none, basic, digest };
|
||||
struct POWERMETER_HTTP_PHASE_CONFIG_T {
|
||||
bool Enabled;
|
||||
char Url[POWERMETER_MAX_HTTP_URL_STRLEN + 1];
|
||||
Auth AuthType;
|
||||
char Username[POWERMETER_MAX_USERNAME_STRLEN +1];
|
||||
char Password[POWERMETER_MAX_USERNAME_STRLEN +1];
|
||||
char HeaderKey[POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN + 1];
|
||||
char HeaderValue[POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN + 1];
|
||||
uint16_t Timeout;
|
||||
|
||||
@ -8,12 +8,14 @@ public:
|
||||
void init();
|
||||
bool updateValues();
|
||||
float getPower(int8_t phase);
|
||||
bool httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout,
|
||||
bool httpRequest(const char* url, Auth authType, const char* username, const char* password, const char* httpHeader, const char* httpValue, uint32_t timeout,
|
||||
char* response, size_t responseSize, char* error, size_t errorSize);
|
||||
float getFloatValueByJsonPath(const char* jsonString, const char* jsonPath, float &value);
|
||||
|
||||
private:
|
||||
float power[POWERMETER_MAX_PHASES];
|
||||
void extractUrlComponents(const String& url, String& protocol, String& hostname, String& uri);
|
||||
String sha256(const String& data);
|
||||
};
|
||||
|
||||
extern HttpPowerMeterClass HttpPowerMeter;
|
||||
|
||||
@ -40,6 +40,7 @@ lib_deps =
|
||||
https://github.com/coryjfowler/MCP_CAN_lib
|
||||
plerup/EspSoftwareSerial@^8.0.1
|
||||
mobizt/FirebaseJson @ ^3.0.6
|
||||
rweather/Crypto@^0.4.0
|
||||
|
||||
extra_scripts =
|
||||
pre:pio-scripts/auto_firmware_version.py
|
||||
|
||||
@ -140,6 +140,9 @@ bool ConfigurationClass::write()
|
||||
|
||||
powermeter_phase["enabled"] = config.Powermeter_Http_Phase[i].Enabled;
|
||||
powermeter_phase["url"] = config.Powermeter_Http_Phase[i].Url;
|
||||
powermeter_phase["auth_type"] = config.Powermeter_Http_Phase[i].AuthType;
|
||||
powermeter_phase["username"] = config.Powermeter_Http_Phase[i].Username;
|
||||
powermeter_phase["password"] = config.Powermeter_Http_Phase[i].Password;
|
||||
powermeter_phase["header_key"] = config.Powermeter_Http_Phase[i].HeaderKey;
|
||||
powermeter_phase["header_value"] = config.Powermeter_Http_Phase[i].HeaderValue;
|
||||
powermeter_phase["timeout"] = config.Powermeter_Http_Phase[i].Timeout;
|
||||
@ -346,6 +349,9 @@ bool ConfigurationClass::read()
|
||||
|
||||
config.Powermeter_Http_Phase[i].Enabled = powermeter_phase["enabled"] | (i == 0);
|
||||
strlcpy(config.Powermeter_Http_Phase[i].Url, powermeter_phase["url"] | "", sizeof(config.Powermeter_Http_Phase[i].Url));
|
||||
config.Powermeter_Http_Phase[i].AuthType = powermeter_phase["auth_type"] | Auth::none;
|
||||
strlcpy(config.Powermeter_Http_Phase[i].Username, powermeter_phase["username"] | "", sizeof(config.Powermeter_Http_Phase[i].Username));
|
||||
strlcpy(config.Powermeter_Http_Phase[i].Password, powermeter_phase["password"] | "", sizeof(config.Powermeter_Http_Phase[i].Password));
|
||||
strlcpy(config.Powermeter_Http_Phase[i].HeaderKey, powermeter_phase["header_key"] | "", sizeof(config.Powermeter_Http_Phase[i].HeaderKey));
|
||||
strlcpy(config.Powermeter_Http_Phase[i].HeaderValue, powermeter_phase["header_value"] | "", sizeof(config.Powermeter_Http_Phase[i].HeaderValue));
|
||||
config.Powermeter_Http_Phase[i].Timeout = powermeter_phase["timeout"] | POWERMETER_HTTP_TIMEOUT;
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <FirebaseJson.h>
|
||||
#include <Crypto.h>
|
||||
#include <SHA256.h>
|
||||
|
||||
void HttpPowerMeterClass::init()
|
||||
{
|
||||
@ -32,7 +34,7 @@ bool HttpPowerMeterClass::updateValues()
|
||||
}
|
||||
|
||||
if (i == 0 || config.PowerMeter_HttpIndividualRequests) {
|
||||
if (!httpRequest(phaseConfig.Url, phaseConfig.HeaderKey, phaseConfig.HeaderValue, phaseConfig.Timeout,
|
||||
if (!httpRequest(phaseConfig.Url, phaseConfig.AuthType, phaseConfig.Username, phaseConfig.Password, phaseConfig.HeaderKey, phaseConfig.HeaderValue, phaseConfig.Timeout,
|
||||
response, sizeof(response), errorMessage, sizeof(errorMessage))) {
|
||||
MessageOutput.printf("[HttpPowerMeter] Getting the power of phase %d failed. Error: %s\r\n",
|
||||
i + 1, errorMessage);
|
||||
@ -49,24 +51,41 @@ bool HttpPowerMeterClass::updateValues()
|
||||
return success;
|
||||
}
|
||||
|
||||
bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout,
|
||||
bool HttpPowerMeterClass::httpRequest(const char* url, Auth authType, const char* username, const char* password, const char* httpHeader, const char* httpValue, uint32_t timeout,
|
||||
char* response, size_t responseSize, char* error, size_t errorSize)
|
||||
{
|
||||
WiFiClient* wifiClient = NULL;
|
||||
HTTPClient httpClient;
|
||||
|
||||
String newUrl = url;
|
||||
String urlProtocol;
|
||||
String urlHostname;
|
||||
String urlUri;
|
||||
extractUrlComponents(url, urlProtocol, urlHostname, urlUri);
|
||||
|
||||
response[0] = '\0';
|
||||
error[0] = '\0';
|
||||
|
||||
if (String(url).substring(0, 6) == "https:") {
|
||||
if (authType == Auth::basic) {
|
||||
newUrl = urlProtocol;
|
||||
newUrl += "://";
|
||||
newUrl += username;
|
||||
newUrl += ":";
|
||||
newUrl += password;
|
||||
newUrl += "@";
|
||||
newUrl += urlHostname;
|
||||
newUrl += urlUri;
|
||||
}
|
||||
|
||||
if (urlProtocol == "https") {
|
||||
wifiClient = new WiFiClientSecure;
|
||||
reinterpret_cast<WiFiClientSecure*>(wifiClient)->setInsecure();
|
||||
} else {
|
||||
wifiClient = new WiFiClient;
|
||||
}
|
||||
|
||||
if (!httpClient.begin(*wifiClient, url)) {
|
||||
snprintf_P(error, errorSize, "httpClient.begin failed");
|
||||
if (!httpClient.begin(*wifiClient, newUrl)) {
|
||||
snprintf_P(error, errorSize, "httpClient.begin(%s) failed", newUrl.c_str());
|
||||
delete wifiClient;
|
||||
return false;
|
||||
}
|
||||
@ -82,8 +101,67 @@ bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, c
|
||||
httpClient.addHeader(httpHeader, httpValue);
|
||||
}
|
||||
|
||||
if (authType == Auth::digest) {
|
||||
const char *headers[1] = {"WWW-Authenticate"};
|
||||
httpClient.collectHeaders(headers, 1);
|
||||
}
|
||||
|
||||
int httpCode = httpClient.GET();
|
||||
|
||||
if (httpCode == HTTP_CODE_UNAUTHORIZED && authType == Auth::digest) {
|
||||
// Handle authentication challenge
|
||||
char realm[256]; // Buffer to store the realm received from the server
|
||||
char nonce[256]; // Buffer to store the nonce received from the server
|
||||
if (httpClient.hasHeader("WWW-Authenticate")) {
|
||||
String authHeader = httpClient.header("WWW-Authenticate");
|
||||
if (authHeader.indexOf("Digest") != -1) {
|
||||
int realmIndex = authHeader.indexOf("realm=\"");
|
||||
int nonceIndex = authHeader.indexOf("nonce=\"");
|
||||
if (realmIndex != -1 && nonceIndex != -1) {
|
||||
int realmEndIndex = authHeader.indexOf("\"", realmIndex + 7);
|
||||
int nonceEndIndex = authHeader.indexOf("\"", nonceIndex + 7);
|
||||
if (realmEndIndex != -1 && nonceEndIndex != -1) {
|
||||
authHeader.substring(realmIndex + 7, realmEndIndex).toCharArray(realm, sizeof(realm));
|
||||
authHeader.substring(nonceIndex + 7, nonceEndIndex).toCharArray(nonce, sizeof(nonce));
|
||||
}
|
||||
}
|
||||
String cnonce = String(random(1000)); // Generate client nonce
|
||||
String str = username;
|
||||
str += ":";
|
||||
str += realm;
|
||||
str += ":";
|
||||
str += password;
|
||||
String ha1 = sha256(str);
|
||||
str = "GET:";
|
||||
str += urlUri;
|
||||
String ha2 = sha256(str);
|
||||
str = ha1;
|
||||
str += ":";
|
||||
str += nonce;
|
||||
str += ":00000001:";
|
||||
str += cnonce;
|
||||
str += ":auth:";
|
||||
str += ha2;
|
||||
String response = sha256(str);
|
||||
|
||||
String authorization = "Digest username=\"";
|
||||
authorization += username;
|
||||
authorization += "\", realm=\"";
|
||||
authorization += realm;
|
||||
authorization += "\", nonce=\"";
|
||||
authorization += nonce;
|
||||
authorization += "\", uri=\"";
|
||||
authorization += urlUri;
|
||||
authorization += "\", cnonce=\"";
|
||||
authorization += cnonce;
|
||||
authorization += "\", nc=00000001, qop=auth, response=\"";
|
||||
authorization += response;
|
||||
authorization += "\", algorithm=SHA-256";
|
||||
httpClient.addHeader("Authorization", authorization);
|
||||
httpCode = httpClient.GET();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
String responseBody = httpClient.getString();
|
||||
@ -95,7 +173,7 @@ bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, c
|
||||
snprintf(response, responseSize, responseBody.c_str());
|
||||
}
|
||||
} else if (httpCode <= 0) {
|
||||
snprintf_P(error, errorSize, "Error: %s", httpClient.errorToString(httpCode).c_str());
|
||||
snprintf_P(error, errorSize, "Error(%s): %s", newUrl.c_str(), httpClient.errorToString(httpCode).c_str());
|
||||
} else if (httpCode != HTTP_CODE_OK) {
|
||||
snprintf_P(error, errorSize, "Bad HTTP code: %d", httpCode);
|
||||
}
|
||||
@ -127,4 +205,54 @@ float HttpPowerMeterClass::getFloatValueByJsonPath(const char* jsonString, const
|
||||
return true;
|
||||
}
|
||||
|
||||
void HttpPowerMeterClass::extractUrlComponents(const String& url, String& protocol, String& hostname, String& uri) {
|
||||
// Find protocol delimiter
|
||||
int protocolEndIndex = url.indexOf(":");
|
||||
if (protocolEndIndex != -1) {
|
||||
protocol = url.substring(0, protocolEndIndex);
|
||||
|
||||
// Find double slash delimiter
|
||||
int doubleSlashIndex = url.indexOf("//", protocolEndIndex);
|
||||
if (doubleSlashIndex != -1) {
|
||||
// Find slash after double slash delimiter
|
||||
int slashIndex = url.indexOf("/", doubleSlashIndex + 2);
|
||||
if (slashIndex != -1) {
|
||||
// Extract hostname and uri
|
||||
hostname = url.substring(doubleSlashIndex + 2, slashIndex);
|
||||
uri = url.substring(slashIndex);
|
||||
} else {
|
||||
// No slash after double slash delimiter, so the whole remaining part is the hostname
|
||||
hostname = url.substring(doubleSlashIndex + 2);
|
||||
uri = "/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove username:password if present in the hostname
|
||||
int atIndex = hostname.indexOf("@");
|
||||
if (atIndex != -1) {
|
||||
hostname = hostname.substring(atIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
String HttpPowerMeterClass::sha256(const String& data) {
|
||||
SHA256 sha256;
|
||||
uint8_t hash[sha256.HASH_SIZE];
|
||||
|
||||
sha256.reset();
|
||||
sha256.update(data.c_str(), data.length());
|
||||
sha256.finalize(hash, sha256.HASH_SIZE);
|
||||
|
||||
String hashStr = "";
|
||||
for (int i = 0; i < sha256.HASH_SIZE; i++) {
|
||||
String hex = String(hash[i], HEX);
|
||||
if (hex.length() == 1) {
|
||||
hashStr += "0";
|
||||
}
|
||||
hashStr += hex;
|
||||
}
|
||||
|
||||
return hashStr;
|
||||
}
|
||||
|
||||
HttpPowerMeterClass HttpPowerMeter;
|
||||
|
||||
@ -56,6 +56,9 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request)
|
||||
phaseObject[F("index")] = i + 1;
|
||||
phaseObject[F("enabled")] = config.Powermeter_Http_Phase[i].Enabled;
|
||||
phaseObject[F("url")] = String(config.Powermeter_Http_Phase[i].Url);
|
||||
phaseObject[F("auth_type")]= config.Powermeter_Http_Phase[i].AuthType;
|
||||
phaseObject[F("username")] = String(config.Powermeter_Http_Phase[i].Username);
|
||||
phaseObject[F("password")] = String(config.Powermeter_Http_Phase[i].Password);
|
||||
phaseObject[F("header_key")] = String(config.Powermeter_Http_Phase[i].HeaderKey);
|
||||
phaseObject[F("header_value")] = String(config.Powermeter_Http_Phase[i].HeaderValue);
|
||||
phaseObject[F("json_path")] = String(config.Powermeter_Http_Phase[i].JsonPath);
|
||||
@ -137,6 +140,14 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request)
|
||||
return;
|
||||
}
|
||||
|
||||
if ((phase[F("auth_type")].as<Auth>() != Auth::none)
|
||||
&& ( phase[F("username")].as<String>().length() == 0 || phase[F("password")].as<String>().length() == 0)) {
|
||||
retMsg[F("message")] = F("Username or password must not be empty!");
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!phase.containsKey("timeout")
|
||||
|| phase[F("timeout")].as<uint16_t>() <= 0) {
|
||||
retMsg[F("message")] = F("Timeout must be greater than 0 ms!");
|
||||
@ -173,6 +184,9 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request)
|
||||
|
||||
config.Powermeter_Http_Phase[i].Enabled = (i == 0 ? true : phase[F("enabled")].as<bool>());
|
||||
strlcpy(config.Powermeter_Http_Phase[i].Url, phase[F("url")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].Url));
|
||||
config.Powermeter_Http_Phase[i].AuthType = phase[F("auth_type")].as<Auth>();
|
||||
strlcpy(config.Powermeter_Http_Phase[i].Username, phase[F("username")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].Username));
|
||||
strlcpy(config.Powermeter_Http_Phase[i].Password, phase[F("password")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].Password));
|
||||
strlcpy(config.Powermeter_Http_Phase[i].HeaderKey, phase[F("header_key")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderKey));
|
||||
strlcpy(config.Powermeter_Http_Phase[i].HeaderValue, phase[F("header_value")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderValue));
|
||||
config.Powermeter_Http_Phase[i].Timeout = phase[F("timeout")].as<uint16_t>();
|
||||
@ -229,7 +243,8 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.containsKey("url") || !root.containsKey("header_key") || !root.containsKey("header_value")
|
||||
if (!root.containsKey("url") || !root.containsKey("auth_type") || !root.containsKey("username") || !root.containsKey("password")
|
||||
|| !root.containsKey("header_key") || !root.containsKey("header_value")
|
||||
|| !root.containsKey("timeout") || !root.containsKey("json_path")) {
|
||||
retMsg[F("message")] = F("Missing fields!");
|
||||
asyncJsonResponse->setLength();
|
||||
@ -241,8 +256,9 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request)
|
||||
errorMessage[256];
|
||||
char response[200];
|
||||
|
||||
if (HttpPowerMeter.httpRequest(root[F("url")].as<String>().c_str(), root[F("header_key")].as<String>().c_str(),
|
||||
root[F("header_value")].as<String>().c_str(), root[F("timeout")].as<uint16_t>(),
|
||||
if (HttpPowerMeter.httpRequest(root[F("url")].as<String>().c_str(),
|
||||
root[F("auth_type")].as<Auth>(), root[F("username")].as<String>().c_str(), root[F("password")].as<String>().c_str(),
|
||||
root[F("header_key")].as<String>().c_str(), root[F("header_value")].as<String>().c_str(), root[F("timeout")].as<uint16_t>(),
|
||||
powerMeterResponse, sizeof(powerMeterResponse), errorMessage, sizeof(errorMessage))) {
|
||||
float power;
|
||||
|
||||
|
||||
@ -519,6 +519,9 @@
|
||||
"httpEnabled": "Phase enabled",
|
||||
"httpUrl": "URL",
|
||||
"httpUrlDescription": "URL must start with http:// or https://. Some characters like spaces and = have to be encoded with URL encoding (%xx). Warning: SSL server certificate check is not implemented (MITM attacks are possible)! See below for some examples.",
|
||||
"httpAuthorization": "Authorization Type",
|
||||
"httpUsername": "Username",
|
||||
"httpPassword": "Password",
|
||||
"httpHeaderKey": "Optional: HTTP request header - Key",
|
||||
"httpHeaderKeyDescription": "A custom HTTP request header can be defined. Might be useful if you have to send something like a custom Authorization header.",
|
||||
"httpHeaderValue": "Optional: HTTP request header - Value",
|
||||
|
||||
@ -2,11 +2,14 @@ export interface PowerMeterHttpPhaseConfig {
|
||||
index: number;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
auth_type: number;
|
||||
username: string;
|
||||
password: string;
|
||||
header_key: string;
|
||||
header_value: string;
|
||||
json_path: string;
|
||||
timeout: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PowerMeterConfig {
|
||||
enabled: boolean;
|
||||
|
||||
@ -120,6 +120,26 @@
|
||||
placeholder="http://admin:supersecret@mypowermeter.home/status"
|
||||
prefix="GET "
|
||||
:tooltip="$t('powermeteradmin.httpUrlDescription')" />
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="inputTimezone" class="col-sm-2 col-form-label">{{ $t('powermeteradmin.httpAuthorization') }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-select" v-model="http_phase.auth_type">
|
||||
<option v-for="source in powerMeterAuthList" :key="source.key" :value="source.key">
|
||||
{{ source.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="http_phase.auth_type != 0">
|
||||
<InputElement :label="$t('powermeteradmin.httpUsername')"
|
||||
v-model="http_phase.username"
|
||||
type="text" maxlength="64"/>
|
||||
|
||||
<InputElement :label="$t('powermeteradmin.httpPassword')"
|
||||
v-model="http_phase.password"
|
||||
type="password" maxlength="64"/>
|
||||
</div>
|
||||
|
||||
<InputElement :label="$t('powermeteradmin.httpHeaderKey')"
|
||||
v-model="http_phase.header_key"
|
||||
@ -209,6 +229,11 @@ export default defineComponent({
|
||||
{ key: 3, value: this.$t('powermeteradmin.typeHTTP') },
|
||||
{ key: 4, value: this.$t('powermeteradmin.typeSML') },
|
||||
],
|
||||
powerMeterAuthList: [
|
||||
{ key: 0, value: "None" },
|
||||
{ key: 1, value: "Basic" },
|
||||
{ key: 2, value: "Digest" },
|
||||
],
|
||||
alertMessage: "",
|
||||
alertType: "info",
|
||||
showAlert: false,
|
||||
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user