#include #include "controller.h" #include "logger.h" Controller::Controller(const QString &configFile) : HOMEd(SERVICE_VERSION, configFile), m_database(new Database(getConfig(), this)), m_tcpServer(new QTcpServer(this)), m_webSocket(new QWebSocketServer("HOMEd", QWebSocketServer::NonSecureMode, this)), m_timer(new QTimer(this)), m_commands(QMetaEnum::fromType ()) { m_frontend = getConfig()->value("server/frontend", basePath().append("share/homed-web")).toString(); m_title = getConfig()->value("server/title", "HOMEd").toString(); m_username = getConfig()->value("server/username").toString(); m_password = getConfig()->value("server/password").toString(); m_guest = getConfig()->value("server/guest").toString(); m_debug = getConfig()->value("server/debug", false).toBool(); m_auth = m_username.isEmpty() || m_password.isEmpty() ? false : true; m_retained = {"device", "expose", "service", "status"}; m_types = {"css", "js", "json", "png", "svg", "woff2"}; connect(m_tcpServer, &QTcpServer::newConnection, this, &Controller::socketConnected); connect(m_webSocket, &QWebSocketServer::newConnection, this, &Controller::clientConnected); connect(m_timer, &QTimer::timeout, this, &Controller::pingClients); if (!m_tcpServer->listen(QHostAddress(getConfig()->value("server/address", "0.0.0.0").toString()), static_cast (getConfig()->value("server/port", 8086).toInt()))) { logWarning << "Failed to start server, error:" << m_tcpServer->errorString(); return; } m_timer->start(10000); m_database->init(); } QString Controller::includeList(const QString &path, const QString &type, const QString &pattern) { QList list = QDir(QString(m_frontend).append(path)).entryList({QString("*.").append(type)}, QDir::Files, QDir::Name), items; for (int i = 0; i < list.count(); i++) items.append(pattern.arg(QString(path).mid(1).append('/').append(list.at(i)))); return items.join(QString("\n").append(QString(8, 0x20))); } QString Controller::ticketToken(const QString &ticket) { auto it = m_tickets.find(ticket); QString token; if (it != m_tickets.end()) { if (it.value().expire >= QDateTime::currentSecsSinceEpoch()) token = it.value().token; m_tickets.erase(it); } return token; } void Controller::httpResponse(QTcpSocket *socket, quint16 code, const QMap &headers, const QByteArray &response) { QByteArray data; switch (code) { case 200: data = "HTTP/1.1 200 OK"; break; case 301: data = "HTTP/1.1 301 Moved Permanently"; break; case 404: data = "HTTP/1.1 404 Not Found"; break; case 405: data = "HTTP/1.1 405 Method Not Allowed"; break; case 500: data = "HTTP/1.1 500 Internal Server Error"; break; } for (auto it = headers.begin(); it != headers.end(); it++) data.append(QString("\r\n%1: %2").arg(it.key(), it.value()).toUtf8()); socket->write(data.append("\r\nConnection: close\r\n\r\n").append(response)); socket->close(); } void Controller::fileResponse(QTcpSocket *socket, const QString &fileName) { QFile file(QString(m_frontend).append(fileName)); QMap headers; QByteArray type, data; if (!file.exists()) { httpResponse(socket, 404); return; } if (!file.open(QFile::ReadOnly)) { httpResponse(socket, 500); return; } switch (m_types.indexOf(fileName.mid(fileName.lastIndexOf('.') + 1))) { case 0: type = "text/css"; break; // css case 1: type = "text/javascript"; break; // js case 2: type = "application/json"; break; // json case 3: type = "image/png"; break; // png case 4: type = "image/svg+xml"; break; // svg case 5: type = "font/woff2"; break; // woff2 default: type = "text/html"; break; } data = file.readAll(); if (fileName == "/index.html" || fileName == "/login.html") { headers.insert("Cache-Control", "no-cache, no-store"); data = (fileName == "/index.html" ? QString(data).arg(m_title, includeList("/plugin/css", "css", ""), includeList("/plugin/js", "js", ""), SERVICE_VERSION, m_auth ? " LOGOUT" : QString()).remove(QRegExp("\n[ ]+(?=\n)")) : QString(data).arg(m_title)).toUtf8(); } headers.insert("Content-Type", type); headers.insert("Content-Length", QString::number(data.length())); httpResponse(socket, 200, headers, data); file.close(); } void Controller::quit(void) { m_webSocket->close(); for (auto it = m_clients.begin(); it != m_clients.end(); it++) it.key()->deleteLater(); HOMEd::quit(); } void Controller::mqttConnected(void) { for (auto it = m_clients.begin(); it != m_clients.end(); it++) for (int i = 0; i < it.value().count(); i++) mqttSubscribe(mqttTopic(it.value().at(i))); if (m_database->passive()) { logWarning << "Running in passive mode"; return; } mqttSubscribe(mqttTopic("command/web")); m_database->store(); mqttPublishService(); } void Controller::mqttDisconnected(void) { m_messages.clear(); } void Controller::mqttReceived(const QByteArray &message, const QMqttTopicName &topic) { QString subTopic = topic.name().replace(0, mqttTopic().length(), QString()); QJsonObject json = QJsonDocument::fromJson(message).object(); if (subTopic == "command/web") { switch (static_cast (m_commands.keyToValue(json.value("action").toString().toUtf8().constData()))) { case Command::restartService: { logWarning << "Restart request received..."; mqttPublish(topic.name(), QJsonObject(), true); QCoreApplication::exit(EXIT_RESTART); break; } case Command::updateDashboards: { m_database->updateDasboards(json.value("data").toArray()); m_database->store(true); break; } case Command::updateIcons: { m_database->updateIcons(json.value("data").toObject()); m_database->store(true); break; } case Command::updateNames: { m_database->updateNames(json.value("data").toObject()); m_database->store(true); break; } } } if (m_retained.contains(subTopic.split('/').value(0))) m_messages.insert(subTopic, message); for (auto it = m_clients.begin(); it != m_clients.end(); it++) { for (int i = 0; i < it.value().count(); i++) { const QString &item = it.value().at(i); if (item.endsWith('#') ? !subTopic.startsWith(item.mid(0, item.indexOf("#"))) : subTopic != item) continue; it.key()->sendTextMessage(QJsonDocument({{"topic", subTopic}, {"message", json.isEmpty() ? QJsonValue::Null : QJsonValue(json)}}).toJson(QJsonDocument::Compact)); break; } } } void Controller::socketConnected(void) { QTcpSocket *socket = m_tcpServer->nextPendingConnection(); connect(socket, &QTcpSocket::disconnected, this, &Controller::socketDisconnected); connect(socket, &QTcpSocket::readyRead, this, &Controller::readyRead); m_sockets.append(socket); } void Controller::socketDisconnected(void) { QTcpSocket *socket = reinterpret_cast (sender()); m_sockets.removeAll(socket); socket->deleteLater(); } void Controller::readyRead(void) { QTcpSocket *socket = reinterpret_cast (sender()); QByteArray request = socket->peek(socket->bytesAvailable()); QList list = QString(request).split("\r\n\r\n"), head = list.value(0).split("\r\n"), target = head.value(0).split(0x20), cookieList, itemList; QByteArray method = target.value(0).toUtf8(), url = target.value(1).toUtf8(), content = list.value(1).toUtf8(); QMap headers, cookies, items; bool upgrade = false, guest = false; disconnect(socket, &QTcpSocket::readyRead, this, &Controller::readyRead); logDebug(m_debug) << "Request" << head.value(0) << "received from" << socket->peerAddress().toString(); for (int i = 1; i < head.count(); i++) { int index = head.at(i).indexOf(':'); if (index < 0) continue; headers.insert(head.at(i).mid(0, index).trimmed().toLower(), head.at(i).mid(index + 1).trimmed()); logDebug(m_debug) << "Header received:" << head.at(i); } cookieList = headers.value("cookie").split(';'); for (int i = 0; i < cookieList.count(); i++) { QList cookie = cookieList.at(i).split('='); if (cookie.count() < 2) continue; cookies.insert(cookie.value(0).trimmed(), cookie.value(1).trimmed()); logDebug(m_debug) << "Cookie received:" << cookieList.at(i); } if (method == "POST") { socket->read(request.length()); while (content.length() < headers.value("content-length").toInt() && socket->waitForReadyRead(REQUEST_TIMEOUT)) content.append(socket->readAll()); } itemList = QString(method == "GET" && url.contains('?') ? url.mid(url.indexOf('?') + 1) : content).split('&'); for (int i = 0; i < itemList.count(); i++) { QList item = itemList.at(i).split('='); if (item.count() < 2) continue; items.insert(item.value(0), QUrl::fromPercentEncoding(item.value(1).toUtf8())); logDebug(m_debug) << "Data received:" << itemList.at(i); } url = url.mid(0, url.indexOf('?')); upgrade = headers.value("upgrade") == "websocket"; if (m_auth) { QString address = !headers.value("x-real-ip").isEmpty() ? headers.value("x-real-ip") : !headers.value("x-forwarded-for").isEmpty() ? headers.value("x-forwarded-for").split(',').value(0).trimmed() : socket->peerAddress().toString(), token = upgrade && items.contains("ticket") ? ticketToken(items.value("ticket")) : cookies.value("homed-auth-token"); if (address.startsWith("::ffff:")) address = address.mid(7); if (token != m_database->adminToken() && token != m_database->guestToken() && (upgrade || (url != "/manifest.json" && !url.startsWith("/css/") && !url.startsWith("/font/") && !url.startsWith("/img/")))) { if (method == "POST") { QString username = items.value("username"), password = items.value("password"); if (username == m_username && password == m_password) { logInfo << "User" << username << "successfully logged in, address:" << address; token = m_database->adminToken(); } else if (!m_guest.isEmpty() && username == "guest" && password == m_guest) { logInfo << "Guest user successfully logged in, address:" << address; token = m_database->guestToken(); } if (token == m_database->adminToken() || token == m_database->guestToken()) { if (items.value("ticket") == "true") { QByteArray ticket = m_database->randomData(32).toHex(), data = QJsonDocument(QJsonObject {{"ticket", QString(ticket)}}).toJson(QJsonDocument::Compact); httpResponse(socket, 200, {{"Cache-Control", "no-cache, no-store"}, {"Content-Type", "application/json"}, {"Content-Length", QString::number(data.length())}}, data); m_tickets.insert(ticket, {token, QDateTime::currentSecsSinceEpoch() + TICKET_MAX_AGE}); } else httpResponse(socket, 301, {{"Location", QString(headers.value("x-ingress-path")).append('/')}, {"Cache-Control", "no-cache, no-store"}, {"Set-Cookie", QString("homed-auth-token=%1; path=/; max-age=%2").arg(token).arg(COOKIE_MAX_AGE)}}); return; } logWarning << "User" << (username.isEmpty() ? "(empty)" : username) << "login failed, address:" << address; } fileResponse(socket, "/login.html"); return; } guest = token != m_database->adminToken(); } if (url == "/logout") { httpResponse(socket, 301, {{"Location", QString(headers.value("x-ingress-path")).append('/')}, {"Cache-Control", "no-cache, no-store"}, {"Set-Cookie", "homed-auth-token=deleted; path=/; max-age=0"}}); if (guest || items.value("session") != "all") return; for (auto it = m_clients.begin(); it != m_clients.end(); it++) it.key()->deleteLater(); m_database->resetAdminToken(); m_database->resetGuestToken(); m_database->store(true); return; } if (method != "GET") { httpResponse(socket, 405); return; } if (upgrade) { socket->setProperty("guest", guest); m_webSocket->handleConnection(socket); m_sockets.removeAll(socket); return; } fileResponse(socket, url != "/" ? url : "/index.html"); } void Controller::clientConnected(void) { QWebSocket *client = m_webSocket->nextPendingConnection(); connect(client, &QWebSocket::disconnected, this, &Controller::clientDisconnected); connect(client, &QWebSocket::textMessageReceived, this, &Controller::textMessageReceived); if (mqttStatus()) client->sendTextMessage(QJsonDocument({{"topic", "setup"}, {"message", QJsonObject {{"guest", client->parent() ? client->parent()->property("guest").toBool() : false}}}}).toJson(QJsonDocument::Compact)); else client->sendTextMessage(QJsonDocument({{"topic", "error"}, {"message", "mqtt disconnected"}}).toJson(QJsonDocument::Compact)); m_clients.insert(client, QList ()); } void Controller::clientDisconnected(void) { QWebSocket *client = reinterpret_cast (sender()); m_clients.remove(client); client->deleteLater(); } void Controller::textMessageReceived(const QString &message) { auto it = m_clients.find(reinterpret_cast (sender())); QJsonObject json = QJsonDocument::fromJson(message.toUtf8()).object(); QString action = json.value("action").toString(), subTopic = json.value("topic").toString(); if (it == m_clients.end() || subTopic.isEmpty()) return; if (action == "subscribe") { QWebSocket* client = it.key(); if (!it.value().contains(subTopic)) it.value().append(subTopic); for (auto it = m_messages.begin(); it != m_messages.end(); it++) { if (subTopic.endsWith('#') ? !it.key().startsWith(subTopic.mid(0, subTopic.indexOf("#"))) : it.key() != subTopic) continue; client->sendTextMessage(QJsonDocument({{"topic", it.key()}, {"message", QJsonDocument::fromJson(it.value()).object()}}).toJson(QJsonDocument::Compact)); } mqttSubscribe(mqttTopic(subTopic)); } else if (action == "publish") { QJsonObject message = json.value("message").toObject(); QString command = message.value("action").toString(); if ((it.key()->parent() ? it.key()->parent()->property("guest").toBool() : false) && (subTopic.startsWith("command/") ? !command.startsWith("get") && command != "callScene" : !subTopic.startsWith("td/"))) { it.key()->sendTextMessage(QJsonDocument({{"topic", "error"}, {"message", "access denied"}}).toJson(QJsonDocument::Compact)); return; } mqttPublish(mqttTopic(subTopic), message); } else if (action == "unsubscribe") it.value().removeAll(subTopic); } void Controller::pingClients(void) { qint64 now = QDateTime::currentSecsSinceEpoch(); for (auto it = m_clients.begin(); it != m_clients.end(); it++) it.key()->ping(); for (auto it = m_tickets.begin(); it != m_tickets.end(); NULL) { if (now > it.value().expire) { it = m_tickets.erase(it); continue; } it++; } }