#include "fakeinternet.h" #include "fixtures.h" #include #include #include #include #include #include #include #include #include #include #include struct FakeInternet::Connection { QSslSocket *socket = nullptr; QByteArray buffer; QString tunnelHost; // непусто — внутри CONNECT-туннеля (https) }; namespace { QByteArray reasonPhrase(int status) { switch (status) { case 200: return "OK"; case 201: return "Created"; case 204: return "No Content"; case 301: return "Moved Permanently"; case 302: return "Found"; case 304: return "Not Modified"; case 400: return "Bad Request"; case 401: return "Unauthorized"; case 403: return "Forbidden"; case 404: return "Not Found"; case 410: return "Gone"; case 429: return "Too Many Requests"; case 500: return "Internal Server Error"; case 502: return "Bad Gateway"; case 503: return "Service Unavailable"; default: return "Status"; } } } // namespace QUrl FakeRequest::url() const { QUrl result; result.setScheme(scheme); result.setHost(host); result.setPath(path); if (!query.isEmpty()) result.setQuery(query); return result; } FakeResponse FakeResponse::json(const QByteArray &body, int status) { FakeResponse response; response.status = status; response.body = body; response.contentType = "application/json"; return response; } FakeResponse FakeResponse::json(const QJsonObject &object, int status) { return json(QJsonDocument(object).toJson(QJsonDocument::Compact), status); } FakeResponse FakeResponse::text(const QByteArray &body, int status) { FakeResponse response; response.status = status; response.body = body; response.contentType = "text/plain; charset=utf-8"; return response; } FakeResponse FakeResponse::bytes(const QByteArray &body) { FakeResponse response; response.body = body; return response; } FakeResponse FakeResponse::error(int status, const QByteArray &body) { FakeResponse response = text(body.isEmpty() ? reasonPhrase(status) : body, status); return response; } FakeInternet::FakeInternet(QObject *parent) : QTcpServer(parent) { m_fallback = FakeResponse::error(404, "fake internet: no route"); } FakeInternet::~FakeInternet() { stop(); } bool FakeInternet::tlsAvailable(QString *why) { if (!QSslSocket::supportsSsl()) { if (why) *why = QStringLiteral("в этой сборке Qt нет рабочего TLS-бэкенда"); return false; } return true; } bool FakeInternet::start(QString *error) { if (m_started) return true; if (!tlsAvailable(error)) return false; m_certificate = Fixtures::read(QStringLiteral("tls/cert.pem")); m_key = Fixtures::read(QStringLiteral("tls/key.pem")); if (!listen(QHostAddress::LocalHost, 0)) { if (error) *error = errorString(); return false; } QNetworkProxy::setApplicationProxy( QNetworkProxy(QNetworkProxy::HttpProxy, QStringLiteral("127.0.0.1"), serverPort())); QSslConfiguration ssl = QSslConfiguration::defaultConfiguration(); ssl.setPeerVerifyMode(QSslSocket::VerifyNone); QSslConfiguration::setDefaultConfiguration(ssl); m_started = true; // Самопроверка: TLS-бэкенд может не уметь серверный режим (бывает на // отдельных сборках Qt под Windows/macOS). Лучше узнать сразу и внятно, // чем получить десятки непонятных падений в тестах сервисов. const QString probe = QStringLiteral("https://selftest.fake-internet.test/ping"); route(probe, FakeResponse::text("pong")); QNetworkAccessManager manager; QNetworkReply *reply = manager.get(QNetworkRequest(QUrl(probe))); QEventLoop loop; QTimer::singleShot(10000, &loop, &QEventLoop::quit); connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); if (!reply->isFinished()) loop.exec(); const bool ok = reply->isFinished() && reply->error() == QNetworkReply::NoError && reply->readAll() == "pong"; const QString why = reply->isFinished() ? reply->errorString() : QStringLiteral("таймаут 10 с"); delete reply; reset(); if (!ok) { stop(); if (error) *error = QStringLiteral("TLS через локальный прокси не заработал (%1), TLS-бэкенд: %2") .arg(why, QSslSocket::activeBackend()); return false; } return true; } void FakeInternet::stop() { if (!m_started) return; // Никуда наружу и после остановки: прокси на закрытый порт. close(); m_started = false; } void FakeInternet::route(const QString &pattern, const Handler &handler) { unroute(pattern); const QUrl url(pattern.endsWith(u'*') ? pattern.chopped(1) : pattern); Route route; route.pattern = pattern; route.host = url.host().toLower(); route.path = url.path().isEmpty() ? QStringLiteral("/") : url.path(); route.prefix = pattern.endsWith(u'*'); route.handler = handler; m_routes << route; } void FakeInternet::route(const QString &pattern, const FakeResponse &response) { route(pattern, [response](const FakeRequest &) { return response; }); } void FakeInternet::unroute(const QString &pattern) { m_routes.removeIf([&](const Route &route) { return route.pattern == pattern; }); } void FakeInternet::reset() { m_deferred.clear(); m_diagnostics.clear(); m_routes.clear(); m_requests.clear(); m_unmatched.clear(); m_fallback = FakeResponse::error(404, "fake internet: no route"); } bool FakeInternet::releaseResponse(const QString &url) { for (qsizetype i = 0; i < m_deferred.size(); ++i) { if (m_deferred.at(i).url == url) { const auto pending = m_deferred.takeAt(i); respond(pending.socket, pending.response, pending.keepAlive); return true; } } return false; } QString FakeInternet::diagnostics() const { return QStringLiteral("TLS backend: %1\nRequests: %2\n%3") .arg(QSslSocket::activeBackend(), requestedUrls().join(u' '), m_diagnostics.join(u'\n')); } QList FakeInternet::requests() const { return m_requests; } int FakeInternet::hits(const QString &url) const { const QUrl wanted(url); int count = 0; for (const FakeRequest &request : m_requests) { if (request.host.compare(wanted.host(), Qt::CaseInsensitive) == 0 && request.path == wanted.path()) ++count; } return count; } QStringList FakeInternet::unmatched() const { return m_unmatched; } QStringList FakeInternet::requestedUrls() const { QStringList result; for (const FakeRequest &request : m_requests) result << request.url().toString(); return result; } void FakeInternet::incomingConnection(qintptr descriptor) { auto *socket = new QSslSocket(this); if (!socket->setSocketDescriptor(descriptor)) { delete socket; return; } auto *connection = new Connection; connection->socket = socket; connect(socket, &QSslSocket::readyRead, this, [this, connection] { onReadyRead(connection); }); connect(socket, &QSslSocket::sslErrors, socket, [socket](const QList &) { socket->ignoreSslErrors(); }); // Удаляем отложенно: disconnected может прийти синхронно посреди processBuffer(). connect(socket, &QSslSocket::disconnected, this, [socket, connection] { socket->deleteLater(); QObject::connect(socket, &QObject::destroyed, [connection] { delete connection; }); }); } void FakeInternet::onReadyRead(Connection *connection) { connection->buffer += connection->socket->readAll(); processBuffer(connection); } void FakeInternet::processBuffer(Connection *connection) { for (;;) { const qsizetype headerEnd = connection->buffer.indexOf("\r\n\r\n"); if (headerEnd < 0) return; const QList lines = connection->buffer.left(headerEnd).split('\n'); const QList requestLine = lines.value(0).trimmed().split(' '); if (requestLine.size() < 2) { connection->socket->abort(); return; } FakeRequest request; request.method = requestLine.at(0); for (qsizetype i = 1; i < lines.size(); ++i) { const QByteArray line = lines.at(i).trimmed(); const qsizetype colon = line.indexOf(':'); if (colon > 0) request.headers.insert(line.left(colon).trimmed().toLower(), line.mid(colon + 1).trimmed()); } const qsizetype bodySize = request.headers.value("content-length", "0").toLongLong(); if (connection->buffer.size() < headerEnd + 4 + bodySize) return; // тело ещё не дошло request.body = connection->buffer.mid(headerEnd + 4, bodySize); connection->buffer.remove(0, headerEnd + 4 + bodySize); const QByteArray target = requestLine.at(1); if (request.method == "CONNECT" && connection->tunnelHost.isEmpty()) { QString host = QString::fromLatin1(target); host = host.left(host.lastIndexOf(u':')).toLower(); connection->tunnelHost = host; connection->socket->write("HTTP/1.1 200 Connection Established\r\n\r\n"); connection->socket->flush(); startTls(connection); continue; } if (!connection->tunnelHost.isEmpty()) { request.scheme = QStringLiteral("https"); request.host = connection->tunnelHost; const QUrl relative(QString::fromLatin1(target)); request.path = relative.path(QUrl::FullyDecoded); request.query = relative.query(QUrl::FullyEncoded); } else { // Простой http через прокси — адрес в absolute-form. const QUrl absolute(QString::fromLatin1(target)); request.scheme = absolute.scheme(); request.host = absolute.host().toLower(); request.path = absolute.path(QUrl::FullyDecoded); request.query = absolute.query(QUrl::FullyEncoded); } if (request.path.isEmpty()) request.path = QStringLiteral("/"); dispatch(connection, request); } } void FakeInternet::startTls(Connection *connection) { QSslSocket *socket = connection->socket; socket->setLocalCertificate(QSslCertificate(m_certificate, QSsl::Pem)); socket->setPrivateKey(QSslKey(m_key, QSsl::Rsa, QSsl::Pem)); socket->setPeerVerifyMode(QSslSocket::VerifyNone); socket->startServerEncryption(); } const FakeInternet::Route *FakeInternet::match(const FakeRequest &request) const { // Последний добавленный маршрут главнее: тест может перекрыть общий частным. for (qsizetype i = m_routes.size() - 1; i >= 0; --i) { const Route &route = m_routes.at(i); if (route.host != request.host) continue; if (route.prefix ? request.path.startsWith(route.path) : request.path == route.path) return &route; } return nullptr; } void FakeInternet::dispatch(Connection *connection, FakeRequest request) { m_requests << request; const QString url = request.url().toString(); FakeResponse response; if (const Route *route = match(request)) { response = route->handler(request); } else { m_unmatched << url; qWarning("FakeInternet: нет маршрута для %s %s", request.method.constData(), qPrintable(url)); response = m_fallback; } const bool keepAlive = request.header("connection").toLower() != "close"; QPointer socket(connection->socket); if (response.hang) return; if (response.deferred) { m_deferred.append({url, socket, response, keepAlive}); return; } if (response.delayMs > 0) { QTimer::singleShot(response.delayMs, this, [this, socket, response, keepAlive] { respond(socket, response, keepAlive); }); } else { respond(socket, response, keepAlive); } emit requestServed(url); } void FakeInternet::respond(QPointer socket, const FakeResponse &response, bool keepAlive) { if (!socket || socket->state() != QAbstractSocket::ConnectedState) return; if (response.drop) { socket->abort(); return; } QByteArray head = "HTTP/1.1 " + QByteArray::number(response.status) + ' ' + reasonPhrase(response.status) + "\r\n"; head += "Content-Type: " + response.contentType + "\r\n"; head += "Content-Length: " + QByteArray::number(response.body.size()) + "\r\n"; for (const auto &[name, value] : response.headers) head += name + ": " + value + "\r\n"; const bool close = !keepAlive || response.truncateTo >= 0; head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n"; head += "\r\n"; if (response.truncateTo >= 0) { m_diagnostics << QStringLiteral("truncate: declared=%1, sent=%2") .arg(response.body.size()).arg(response.body.left(response.truncateTo).size()); // Сначала отправляем TLS-записи с неполным телом, затем рвём TCP. // disconnectFromHost() выполняет штатное завершение TLS, а здесь // моделируется обрыв. Готовность определяется буферами, не задержкой. connect(socket, &QSslSocket::encryptedBytesWritten, socket, [this, socket](qint64) { QTimer::singleShot(0, socket, [this, socket] { if (!socket || socket->state() != QAbstractSocket::ConnectedState) return; if (socket->bytesToWrite() || socket->encryptedBytesToWrite()) return; m_diagnostics << QStringLiteral("truncate: TLS buffers drained, aborting TCP"); socket->abort(); m_diagnostics << QStringLiteral("truncate: state after abort=%1").arg(socket->state()); }); }); socket->write(head); socket->write(response.body.left(response.truncateTo)); return; } socket->write(head); socket->write(response.body); if (close) socket->disconnectFromHost(); }