410 lines
13 KiB
C++
410 lines
13 KiB
C++
#include "fakeinternet.h"
|
||||
|
|
|
|||
|
|
#include "fixtures.h"
|
|||
|
|
|
|||
|
|
#include <QEventLoop>
|
|||
|
|
#include <QHostAddress>
|
|||
|
|
#include <QNetworkAccessManager>
|
|||
|
|
#include <QNetworkReply>
|
|||
|
|
#include <QJsonDocument>
|
|||
|
|
#include <QJsonObject>
|
|||
|
|
#include <QNetworkProxy>
|
|||
|
|
#include <QSslConfiguration>
|
|||
|
|
#include <QSslKey>
|
|||
|
|
#include <QSslSocket>
|
|||
|
|
#include <QTimer>
|
|||
|
|
|
|||
|
|
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_routes.clear();
|
|||
|
|
m_requests.clear();
|
|||
|
|
m_unmatched.clear();
|
|||
|
|
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QList<FakeRequest> 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<QSslError> &) {
|
|||
|
|
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<QByteArray> lines = connection->buffer.left(headerEnd).split('\n');
|
|||
|
|
const QList<QByteArray> 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<QTcpSocket> socket(connection->socket);
|
|||
|
|
if (response.hang)
|
|||
|
|
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<QTcpSocket> 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";
|
|||
|
|
|
|||
|
|
socket->write(head);
|
|||
|
|
if (response.truncateTo >= 0) {
|
|||
|
|
socket->write(response.body.left(response.truncateTo));
|
|||
|
|
socket->flush();
|
|||
|
|
socket->disconnectFromHost();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
socket->write(response.body);
|
|||
|
|
if (close)
|
|||
|
|
socket->disconnectFromHost();
|
|||
|
|
}
|