Files
ebusd/src/lib/utils/httpclient.cpp
T
2023-06-17 10:24:08 +02:00

599 lines
17 KiB
C++
Executable File

/*
* ebusd - daemon for communication with eBUS heating systems.
* Copyright (C) 2018-2023 John Baier <ebusd@ebusd.eu>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "lib/utils/httpclient.h"
#include <cstring>
#include <cstdlib>
#include <sstream>
#include <csignal>
#ifdef HAVE_SSL
#if OPENSSL_VERSION_NUMBER < 0x10101000L
#include <sys/stat.h>
#endif
#endif // HAVE_SSL
#include "lib/utils/log.h"
namespace ebusd {
using std::string;
using std::ostringstream;
using std::dec;
using std::hex;
#ifdef HAVE_SSL
#if OPENSSL_VERSION_NUMBER < 0x10101000L
// default CA path
#define DEFAULT_CAFILE "/etc/ssl/certs/ca-certificates.crt"
// default CA path
#define DEFAULT_CAPATH "/etc/ssl/certs"
#endif
// the time slice to sleep between repeated SSL reads/writes
#define SLEEP_NANOS 20000
bool checkError(const char* call) {
unsigned long err = ERR_get_error();
if (err) {
const char *const str = ERR_reason_error_string(err);
logError(lf_network, "HTTP %s: %ld=%s", call, err, str);
return true;
}
return false;
}
bool isError(const char* call, bool result) {
if (checkError(call)) {
return true;
}
if (!result) {
logError(lf_network, "HTTP %s: invalid result", call);
return true;
}
return false;
}
bool isError(const char* call, long result, long expected) {
if (checkError(call)) {
return true;
}
if (result != expected) {
logError(lf_network, "HTTP %s: invalid result %d", call, result);
return true;
}
return false;
}
SSLSocket::~SSLSocket() {
BIO_free_all(m_bio);
}
ssize_t SSLSocket::send(const char* data, size_t len) {
time_t now = 0;
do {
#if OPENSSL_VERSION_NUMBER >= 0x10101000L
size_t part = 0;
int res = BIO_write_ex(m_bio, data, len, &part);
if (res == 1) {
return static_cast<signed>(part);
}
#else
int res = BIO_write(m_bio, data, static_cast<int>(len));
if (res > 0) {
return static_cast<ssize_t>(res);
}
#endif
if (!BIO_should_retry(m_bio) && now > 0) { // always repeat on first failure
if (isError("send", true)) {
return -1;
}
return 0;
}
if ((now=time(nullptr)) > m_until) {
logError(lf_network, "HTTP send: timed out after %d sec", now-m_until);
break;
}
usleep(SLEEP_NANOS);
} while (true);
return -1; // timeout
}
ssize_t SSLSocket::recv(char* data, size_t len) {
time_t now = 0;
do {
#if OPENSSL_VERSION_NUMBER >= 0x10101000L
size_t part = 0;
int res = BIO_read_ex(m_bio, data, len, &part);
if (res == 1) {
return static_cast<signed>(part);
}
#else
int res = BIO_read(m_bio, data, static_cast<int>(len));
if (res > 0) {
return static_cast<ssize_t>(res);
}
#endif
if (!BIO_should_retry(m_bio) && now > 0) { // always repeat on first failure
if (isError("recv", true)) {
return -1;
}
return 0;
}
if ((now=time(nullptr)) > m_until) {
logError(lf_network, "HTTP recv: timed out after %d sec", now-m_until);
break;
}
usleep(SLEEP_NANOS);
} while (true);
return -1; // timeout
}
bool SSLSocket::isValid() {
return time(nullptr) < m_until && !BIO_eof(m_bio);
}
void sslInfoCallback(const SSL *ssl, int type, int val) {
if (!needsLog(lf_network, (val == 0) ? ll_error : ll_debug)) {
return;
}
logWrite(lf_network,
(val == 0) ? ll_error : ll_debug,
"SSL state %d=%s: type 0x%x=%s%s%s%s%s%s%s%s%s val %d=%s",
SSL_get_state(ssl),
SSL_state_string_long(ssl),
type,
(type & SSL_CB_LOOP) ? "loop," : "",
(type & SSL_CB_EXIT) ? "exit," : "",
(type & SSL_CB_READ) ? "read," : "",
(type & SSL_CB_WRITE) ? "write," : "",
(type & SSL_CB_ALERT) ? "alert," : "",
(type & SSL_ST_ACCEPT) ? "accept," : "",
(type & SSL_ST_CONNECT) ? "connect," : "",
(type & SSL_CB_HANDSHAKE_START) ? "start," : "",
(type & SSL_CB_HANDSHAKE_DONE) ? "done," : "",
val,
(type & SSL_CB_ALERT) ? SSL_alert_desc_string_long(val) : "?");
}
int bioInfoCallback(BIO *bio, int state, int res) {
logDebug(lf_network,
"SSL BIO state %d res %d",
state,
res);
return 1;
}
SSLSocket* SSLSocket::connect(const string& host, const uint16_t& port, bool https, int timeout, const char* caFile,
const char* caPath) {
ostringstream ostr;
ostr << host << ':' << static_cast<unsigned>(port);
const string hostPort = ostr.str();
time_t until = time(nullptr) + 1 + (timeout <= 5 ? 5 : timeout); // at least 5 seconds, 1 extra for rounding
if (!https) {
do {
BIO *bio = BIO_new_connect(static_cast<const char*>(hostPort.c_str()));
if (isError("connect", bio != nullptr)) {
break;
}
BIO_set_nbio(bio, 1); // set non-blocking
return new SSLSocket(bio, until);
} while (false);
return nullptr;
}
BIO *bio = nullptr;
static SSL_CTX *ctx = nullptr;
static int sslContextInitTries = 0;
do {
// const SSL_METHOD *method = TLS_client_method();
static bool verifyPeer = true;
if (ctx == nullptr) { // according to openssl manpage, ctx is global and should be created once only
if (sslContextInitTries > 2) { // give it up to 3 tries to initialize the context
break;
}
sslContextInitTries++;
const SSL_METHOD *method = SSLv23_method();
if (isError("method", method != nullptr)) {
break;
}
ctx = SSL_CTX_new(method);
if (isError("ctx_new", ctx != nullptr)) {
break;
}
SSL_CTX_set_info_callback(ctx, sslInfoCallback);
verifyPeer = !caFile || strcmp(caFile, "#") != 0;
SSL_CTX_set_verify(ctx, verifyPeer ? SSL_VERIFY_PEER : SSL_VERIFY_NONE, nullptr);
if (verifyPeer) {
#if OPENSSL_VERSION_NUMBER >= 0x10101000L
SSL_CTX_set_default_verify_paths(ctx);
#else
struct stat stat_buf = {};
if (!caFile && stat(DEFAULT_CAFILE, &stat_buf) == 0) {
caFile = DEFAULT_CAFILE; // use default CA file
}
if (!caPath && stat(DEFAULT_CAPATH, &stat_buf) == 0) {
caPath = DEFAULT_CAPATH; // use default CA path
}
#endif
if ((caFile || caPath) && isError("verify_loc", SSL_CTX_load_verify_locations(ctx, caFile, caPath), 1)) {
SSL_CTX_free(ctx);
ctx = nullptr;
break;
}
}
SSL_CTX_set_options(ctx, SSL_OP_ALL | SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3 | SSL_OP_NO_COMPRESSION);
}
bio = BIO_new_ssl_connect(ctx);
if (isError("new_ssl_conn", bio != nullptr)) {
break;
}
BIO_set_info_callback(bio, bioInfoCallback);
BIO_set_conn_ip_family(bio, 4); // force IPv4 to circumvent docker IPv6 routing issues
if (isError("conn_hostname", BIO_set_conn_hostname(bio, hostPort.c_str()), 1)) {
break;
}
isError("set_nbio", BIO_set_nbio(bio, 1), 1); // set non-blocking
SSL *ssl = nullptr;
BIO_get_ssl(bio, &ssl);
if (isError("get_ssl", ssl != nullptr)) {
break;
}
SSL_clear_mode(ssl, SSL_MODE_AUTO_RETRY);
const char *hostname = host.c_str();
if (isError("tls_host", SSL_set_tlsext_host_name(ssl, hostname), 1)) {
break;
}
long res = BIO_do_connect(bio);
time_t now = 0;
while (res <= 0 && (BIO_should_retry(bio) || now == 0)) { // always repeat on first failure
if ((now=time(nullptr)) > until) {
break;
}
usleep(SLEEP_NANOS);
res = BIO_do_connect(bio);
}
if (res <= 0 && now > until) {
logError(lf_network, "HTTP connect: timed out after %d sec", now-until);
break;
}
if (isError("connect", res, 1)) {
break;
}
X509 *cert = SSL_get_peer_certificate(ssl);
if (isError("peer_cert", cert != nullptr)) {
break;
}
X509_free(cert); // decrement reference count incremented by above call
if (verifyPeer && isError("verify", SSL_get_verify_result(ssl), X509_V_OK)) {
break;
}
// check hostname
X509_NAME *sname = X509_get_subject_name(cert);
if (isError("get_subject", sname != nullptr)) {
break;
}
char peerName[64];
if (isError("subject name", X509_NAME_get_text_by_NID(sname, NID_commonName, peerName, sizeof(peerName)) > 0)) {
break;
}
if (strcmp(peerName, hostname) != 0) {
char* dotpos = NULL;
if (peerName[0] == '*' && peerName[1] == '.' && (dotpos=strchr((char*)hostname, '.'))
&& strcmp(peerName+2, dotpos+1) == 0) {
// wildcard matches
} else if (isError("subject", 1, 0)) {
break;
}
}
return new SSLSocket(bio, until);
} while (false);
if (bio) {
BIO_free_all(bio);
}
return nullptr;
}
bool HttpClient::s_initialized = false;
const char* HttpClient::s_caFile = nullptr;
const char* HttpClient::s_caPath = nullptr;
void HttpClient::initialize(const char* caFile, const char* caPath) {
if (s_initialized) {
return;
}
s_initialized = true;
s_caFile = caFile;
s_caPath = caPath;
SSL_library_init();
SSL_load_error_strings();
signal(SIGPIPE, SIG_IGN); // needed to avoid SIGPIPE when writing to a closed pipe
}
#else // HAVE_SSL
void HttpClient::initialize(const char* caFile, const char* caPath) {
// empty
}
#endif // HAVE_SSL
bool HttpClient::parseUrl(const string& url, string* proto, string* host, uint16_t* port, string* uri) {
size_t hostPos = url.find("://");
if (hostPos == string::npos) {
return false;
}
*proto = url.substr(0, hostPos);
hostPos += 3;
bool isSsl = *proto == "https";
#ifdef HAVE_SSL
if (!isSsl && *proto != "http") {
return false;
}
#else
if (*proto != "http") {
return false;
}
#endif
size_t pos = url.find('/', hostPos);
if (pos == hostPos) {
return false;
}
if (pos == string::npos) {
*host = url.substr(hostPos);
*uri = "/";
} else {
*host = url.substr(hostPos, pos - hostPos);
*uri = url.substr(pos);
if ((*uri)[uri->length()-1] != '/') {
*uri += "/";
}
}
pos = host->find(':');
if (pos == 0) {
return false;
}
*port = isSsl ? 443 : 80;
if (pos != string::npos) {
char* strEnd = nullptr;
unsigned long value = strtoul(host->c_str()+pos+1, &strEnd, 10);
if (strEnd == nullptr || *strEnd != '\0' || value < 1 || value > 65535) {
return false;
}
*port = static_cast<uint16_t>(value);
*host = host->substr(0, pos);
}
return true;
}
bool HttpClient::connect(const string& host, const uint16_t port, bool https, const string& userAgent,
const int timeout) {
initialize();
disconnect();
#ifdef HAVE_SSL
m_socket = SSLSocket::connect(host, port, https, timeout, s_caFile, s_caPath);
m_https = https;
#else
if (https) {
return false;
}
m_socket = TCPSocket::connect(host, port, timeout);
#endif
if (!m_socket) {
return false;
}
m_host = host;
m_port = port;
m_timeout = timeout;
m_userAgent = userAgent;
return true;
}
bool HttpClient::reconnect() {
disconnect();
if (m_host.empty() || !m_port) {
return false;
}
#ifdef HAVE_SSL
m_socket = SSLSocket::connect(m_host, m_port, m_https, m_timeout, s_caFile, s_caPath);
#else
m_socket = TCPSocket::connect(m_host, m_port, m_timeout);
#endif
if (!m_socket) {
return false;
}
return true;
}
bool HttpClient::ensureConnected() {
if (m_socket && m_socket->isValid()) {
return true;
}
return reconnect();
}
void HttpClient::disconnect() {
if (m_socket) {
delete m_socket;
m_socket = nullptr;
}
}
bool HttpClient::get(const string& uri, const string& body, string* response, bool* repeatable, time_t* time) {
return request("GET", uri, body, response, repeatable, time);
}
bool HttpClient::post(const string& uri, const string& body, string* response, bool* repeatable) {
return request("POST", uri, body, response, repeatable);
}
const int indexToMonth[] = {
-1, -1, 2, 12, -1, -1, 1, -1, // 0-7
-1, -1, -1, -1, 7, -1, 6, 8, // 8-15
9, 5, 3, -1, 10, -1, 11, -1, // 16-23
-1, -1, 4, -1, -1, -1, -1, -1, // 24-31
};
bool HttpClient::request(const string& method, const string& uri, const string& body, string* response,
bool* repeatable, time_t* time) {
if (!ensureConnected()) {
*response = "not connected";
if (repeatable) {
*repeatable = true;
}
return false;
}
ostringstream ostr;
ostr << method << " " << uri << " HTTP/1.0\r\n"
<< "Host: " << m_host << "\r\n";
if (!m_userAgent.empty()) {
ostr << "User-Agent: " << m_userAgent << "\r\n";
}
if (body.empty()) {
ostr << "\r\n";
} else {
ostr << "Content-Type: application/json; charset=utf-8\r\n"
<< "Content-Length: " << dec << body.length() << "\r\n"
<< "\r\n"
<< body;
}
string str = ostr.str();
size_t len = str.size();
const char* cstr = str.c_str();
for (size_t pos = 0; pos < len; ) {
ssize_t sent = m_socket->send(cstr + pos, len - pos);
if (sent < 0) {
disconnect();
*response = "send error";
if (repeatable) {
*repeatable = true;
}
return false;
}
pos += sent;
}
string result;
size_t pos = readUntil(" ", 4 * 1024, &result); // max 4k headers
if (pos == string::npos || pos > 8 || result.substr(0, 5) != "HTTP/") {
disconnect();
*response = "receive error (headers)";
return false;
}
if (result.substr(pos+1, 6) != "200 OK") {
disconnect();
size_t endpos = result.find("\r\n", pos+1);
*response = "receive error: " + result.substr(pos+1, endpos == string::npos ? endpos : endpos-pos-1);
return false;
}
pos = readUntil("\r\n\r\n", 4 * 1024, &result); // max 4k headers
if (pos == string::npos) {
disconnect();
*response = "receive error (headers)";
return false;
}
string headers = result.substr(0, pos+2); // including final \r\n
const char* hdrs = headers.c_str();
*response = result.substr(pos+4);
#ifdef HAVE_TIME_H
if (time) {
pos = headers.find("\r\nLast-Modified: ");
if (pos != string::npos && headers.substr(pos+42, 4) == " GMT") {
// Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
struct tm t;
pos += strlen("\r\nLast-Modified: ") + 5;
char* strEnd = nullptr;
t.tm_mday = static_cast<int>(strtol(hdrs + pos, &strEnd, 10));
if (strEnd != hdrs + pos + 2 || t.tm_mday < 1 || t.tm_mday > 31) {
t.tm_mday = -1;
}
t.tm_mon = indexToMonth[((hdrs[pos+4]&0x10)>>1) | (hdrs[pos+5]&0x17)] - 1;
strEnd = nullptr;
t.tm_year = static_cast<int>(strtol(hdrs + pos + 7, &strEnd, 10));
if (strEnd != hdrs + pos + 11 || t.tm_year < 1970 || t.tm_year >= 3000) {
t.tm_year = -1;
} else {
t.tm_year -= 1900;
}
strEnd = nullptr;
t.tm_hour = static_cast<int>(strtol(hdrs + pos + 12, &strEnd, 10));
if (strEnd != hdrs + pos + 14 || t.tm_hour > 23) {
t.tm_hour = -1;
}
strEnd = nullptr;
t.tm_min = static_cast<int>(strtol(hdrs + pos + 15, &strEnd, 10));
if (strEnd != hdrs + pos + 17 || t.tm_min > 59) {
t.tm_min = -1;
}
strEnd = nullptr;
t.tm_sec = static_cast<int>(strtol(hdrs + pos + 18, &strEnd, 10));
if (strEnd != hdrs + pos + 20 || t.tm_sec > 59) {
t.tm_sec = -1;
}
if (t.tm_mday > 0 && t.tm_mon >= 0 && t.tm_year >= 0 && t.tm_hour >= 0 && t.tm_min >=0 && t.tm_sec >= 0) {
*time = timegm(&t);
}
}
}
#endif
pos = headers.find("\r\nContent-Length: ");
if (pos == string::npos) {
disconnect();
return true;
}
char* strEnd = nullptr;
unsigned long length = strtoul(hdrs + pos + strlen("\r\nContent-Length: "), &strEnd, 10);
if (strEnd == nullptr || *strEnd != '\r') {
disconnect();
*response = "invalid content length ";
return false;
}
pos = readUntil("", length, response);
disconnect();
return pos == length;
}
size_t HttpClient::readUntil(const string& delim, size_t length, string* result) {
if (!m_buffer) {
m_buffer = reinterpret_cast<char*>(malloc(1024+1)); // 1 extra for final terminator
if (!m_buffer) {
return string::npos;
}
m_bufferSize = 1024;
}
bool findDelim = !delim.empty();
size_t pos = findDelim ? result->find(delim) : string::npos;
while (pos == string::npos && result->length() < length) {
ssize_t received = m_socket->recv(m_buffer, m_bufferSize);
if (received < 0) {
return string::npos;
}
if (received == 0) {
break;
}
if (static_cast<unsigned>(received) > m_bufferSize) {
return string::npos; // error in recv
}
m_buffer[received] = 0;
size_t oldLength = result->length();
*result += m_buffer;
if (findDelim) {
pos = result->find(delim, oldLength - (delim.length() - 1));
}
}
return findDelim ? pos : result->length();
}
} // namespace ebusd