diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d0a9e2..2aa9863 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,7 @@ add_subdirectory(.github) add_subdirectory(http) add_subdirectory(mdns) add_subdirectory(qnc) +add_subdirectory(ssdp) if (NOT IOS) # FIXME Figure out code signing on Github add_subdirectory(tests) diff --git a/README.md b/README.md index be58eee..9ed687c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,58 @@ # QtNetworkCrumbs -## What's this? +## What is this? -This are some tiny networking toys written in C++17 for [Qt](https://qt.io). +This are some tiny networking toys written in C++17 for [Qt][qt-opensource]. -- a [tiny mDNS resolver](mdns/mdnsresolver.h) ([usage demo](mdns/mdnsresolverdemo.cpp)) -- a [compressing HTTP/1.1 server](http/compressingserver.cpp) +### A minimal mDNS-SD resolver -## What's needed? +[DNS Service Discovery][DNS-SD], also known as "DNS-SD" and "Bonjour", is an efficient method to discover +network services based. Using [Multicast DNS][mDNS] it's a "Zeroconf" technique for local networks. Via +traditional [Unicast DNS][DNS] it also works over the Internet. + +With this library discovering DNS-SD services is as simple as this: + +```C++ +const auto resolver = new mdns::Resolver; +connect(resolver, &mdns::Resolver::serviceResolved, this, [](const auto &service) { + qInfo() << "mDNS service found:" << service; +}); +resolver->lookupServices({"_http._tcp"_L1, "_googlecast._tcp"_L1}); +``` + +The C++ definition of the mDNS resolver can be found in [mdnsresolver.h](mdns/mdnsresolver.h). +A slightly more complex example can be found in [mdnsresolverdemo.cpp](mdns/mdnsresolverdemo.cpp). + +### A minimal SSDP resolver + +The [Simple Service Discovery Protocol (SSDP)][SSDP] is another "Zeroconf" technique based on multicast messages. +It's also the basis [Univeral Plug and Play (UPnP)][UPnP]. + +With this library discovering SSDP services is as simple as this: + +```C++ +const auto resolver = new ssdp::Resolver; +connect(resolver, &ssdp::Resolver::serviceFound, this, [](const auto &service) { + qInfo() << "SSDP service found:" << service; +}); +resolver->lookupService("ssdp:all"_L1); +``` + +The C++ definition of the SSDP resolver can be found in [ssdpresolver.h](ssdp/ssdpresolver.h). +A slightly more complex example can be found in [ssdpresolverdemo.cpp](ssdp/ssdpresolverdemo.cpp). + +### A compressing HTTP server + +This library also contains a very, very minimal [compressing HTTP/1.1 server](http/compressingserver.cpp). +More a demonstration of the concept, than a real implementation. + +## What is needed? - [CMake 3.10](https://cmake.org/) -- [Qt 6.5](https://qt.io), or Qt 5.15 +- [Qt 6.5][qt-opensource], or Qt 5.15 - C++17 -## What's supported? +## What is supported? This is build and tested on all the major platforms supported by Qt: Android, iOS, Linux, macOS, Windows. @@ -28,3 +67,11 @@ Licensed under MIT License [build-status.svg]: https://github.com/hasselmm/QtNetworkCrumbs/actions/workflows/integration.yaml/badge.svg [build-status]: https://github.com/hasselmm/QtNetworkCrumbs/actions/workflows/integration.yaml + +[qt-opensource]: https://www.qt.io/download-open-source + +[DNS-SD]: https://en.wikipedia.org/wiki/Zero-configuration_networking#DNS-SD +[DNS]: https://en.wikipedia.org/wiki/Domain_Name_System#DNS_message_format +[mDNS]: https://en.wikipedia.org/wiki/Multicast_DNS +[SSDP]: https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol +[UPnP]: https://en.wikipedia.org/wiki/Universal_Plug_and_Play diff --git a/http/CMakeLists.txt b/http/CMakeLists.txt index d848e4d..9f1a26e 100644 --- a/http/CMakeLists.txt +++ b/http/CMakeLists.txt @@ -1,3 +1,11 @@ +qnc_add_library( + QncHttp STATIC + httpparser.cpp + httpparser.h +) + +target_link_libraries(QncHttp PUBLIC QncCore) + if (NOT IOS) # FIXME Figure out code signing on Github qnc_add_executable(HttpCompressingServer TYPE tool compressingserver.cpp) target_link_libraries(HttpCompressingServer PUBLIC Qt::Network QtNetworkCrumbs::Zlib) diff --git a/http/httpparser.cpp b/http/httpparser.cpp new file mode 100644 index 0000000..5322f5a --- /dev/null +++ b/http/httpparser.cpp @@ -0,0 +1,158 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#include "httpparser.h" + +// QtNetworkCrumbs headers +#include "qncliterals.h" +#include "qncparse.h" + +// Qt headers +#include +#include +#include + +namespace qnc::http { + +namespace { + +Q_LOGGING_CATEGORY(lcHttpParser, "qnc.http.parser") + +// examples from https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7 +constexpr auto s_rfc1123DateFormat = "ddd, dd MMM yyyy hh:mm:ss 'GMT'"_L1; // e.g. "Sun, 06 Nov 1994 08:49:37 GMT" +constexpr auto s_rfc850DateFormat = "dddd, dd-MMM-yy hh:mm:ss 'GMT'"_L1; // e.g. "Sunday, 06-Nov-94 08:49:37 GMT" +constexpr auto s_ascTimeDateFormat = "ddd MMM d hh:mm:ss yyyy"_L1; // e.g. "Sun Nov 6 08:49:37 1994" + +constexpr auto s_cacheControlNoCache = "no-cache"_baview; +constexpr auto s_cacheControlMaxAge = "max-age="_baview; + +template +Iterator findPrefix(const Container &container, const ValueType &prefix) +{ + return std::find_if(container.cbegin(), container.cend(), + [prefix](const ValueType &token) { + return token.startsWith(prefix); + }); +} + +template ::type> +StringView suffixView(const String &text, qsizetype offset) +{ + return {text.cbegin() + offset, static_cast(text.length() - offset)}; +} + +} // namespace + +Message Message::parse(const QByteArray &data) +{ + auto buffer = QBuffer{}; + buffer.setData(data); + + if (!buffer.open(QBuffer::ReadOnly)) + return {}; + + return parse(&buffer); +} + +Message Message::parse(QIODevice *device) +{ + if (!device->canReadLine()) + return {}; + + const auto &statusLine = device->readLine().trimmed().split(' '); + + if (statusLine.size() != 3) + return {}; + + auto message = Message { + statusLine[0], + statusLine[1], + statusLine[2], + }; + + while (device->canReadLine()) { + const auto &line = device->readLine(); + const auto &trimmedLine = line.trimmed(); + + if (trimmedLine.isEmpty()) + break; + + if (line[0] == ' ') { + if (message.headers.isEmpty()) { + qCWarning(lcHttpParser, "Ignoring invalid header line: %s", line.constData()); + continue; + } + + message.headers.last().second.append(trimmedLine); + } else if (const auto colon = line.indexOf(':'); colon > 0) { + auto name = line.left(colon).trimmed(); + auto value = line.mid(colon + 1).trimmed(); + +#if QT_VERSION_MAJOR >= 6 + message.headers.emplaceBack(std::move(name), std::move(value)); +#else // QT_VERSION_MAJOR < 6 + message.headers.append({std::move(name), std::move(value)}); +#endif // QT_VERSION_MAJOR < 6 + } else { + qCWarning(lcHttpParser, "Ignoring invalid header line: %s", line.constData()); + continue; + } + } + + return message; +} + +QDateTime parseDateTime(const QByteArray &text) +{ + return parseDateTime(QString::fromUtf8(text)); +} + +QDateTime parseDateTime(const QString &text) +{ + const auto locale = QLocale::c(); // for language neutral (that is English) weekdays + + if (auto dt = locale.toDateTime(text, s_rfc1123DateFormat); dt.isValid()) { + dt.setTimeSpec(Qt::UTC); + return dt; + } else if (auto dt = locale.toDateTime(text, s_rfc850DateFormat); dt.isValid()) { + dt.setTimeSpec(Qt::UTC); + return dt; + } else if (auto dt = locale.toDateTime(QString{text}.replace(" "_L1, " "_L1), + s_ascTimeDateFormat); dt.isValid()) { + dt.setTimeSpec(Qt::UTC); + return dt; + } + + return {}; +} + +QDateTime expiryDateTime(const QByteArray &cacheControl, const QByteArray &expires, const QDateTime &now) +{ + auto cacheControlWithoutSpaces = QByteArray{cacheControl}.replace(' ', compat::ByteArrayView{}); + const auto &cacheControlList = cacheControlWithoutSpaces.split(','); + + if (cacheControlList.contains(CaseInsensitive{s_cacheControlNoCache})) + return now; + + if (const auto maxAge = findPrefix(cacheControlList, CaseInsensitive{s_cacheControlMaxAge}); + maxAge != cacheControlList.cend()) { + const auto &value = suffixView(*maxAge, s_cacheControlMaxAge.length()); + + if (const auto seconds = qnc::parse(value)) + return now.addSecs(*seconds); + } + + if (!expires.isEmpty()) + return parseDateTime(expires); + + return {}; +} + +QDateTime expiryDateTime(const QByteArray &cacheControl, const QByteArray &expires) +{ + return expiryDateTime(cacheControl, expires, QDateTime::currentDateTimeUtc()); +} + +} // namespace qnc::http diff --git a/http/httpparser.h b/http/httpparser.h new file mode 100644 index 0000000..47596b0 --- /dev/null +++ b/http/httpparser.h @@ -0,0 +1,126 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#ifndef QNC_HTTPPARSER_H +#define QNC_HTTPPARSER_H + +// QtNetworkCrumbs headers +#include "qncliterals.h" + +// Qt headers +#include +#include + +class QDateTime; +class QIODevice; + +namespace qnc::http { + +namespace detail { + +template struct view_trait; +template <> struct view_trait { using type = compat::ByteArrayView; }; +template <> struct view_trait { using type = compat::ByteArrayView; }; +template <> struct view_trait { using type = QStringView; }; +template <> struct view_trait { using type = QStringView; }; +template <> struct view_trait { using type = QStringView; }; + +template +struct hasStartsWith : std::false_type {}; +template +struct hasStartsWith> : std::true_type {}; + +template +struct hasEndsWith : std::false_type {}; +template +struct hasEndsWith> : std::true_type {}; + +static_assert(hasStartsWith() == false); +static_assert(hasEndsWith() == false); + +static_assert(hasStartsWith() == true); +static_assert(hasEndsWith() == true); + +} // namespace detail + +template +class CaseInsensitive : public T +{ +public: + using T::T; + + using view_type = typename detail::view_trait::type; + + CaseInsensitive(const T &init) : T{init} {} + CaseInsensitive(T &&init) : T{std::move(init)} {} + + template + friend bool operator==(const CaseInsensitive &l, const U &r) + { return l.compare(r, Qt::CaseInsensitive) == 0; } + + template + friend bool operator==(const U &l, const CaseInsensitive &r) + { return l.compare(r, Qt::CaseInsensitive) == 0; } + + friend bool operator==(const CaseInsensitive &l, const CaseInsensitive &r) + { return l.compare(r, Qt::CaseInsensitive) == 0; } + + template + bool startsWith(const U &r) const + { + if constexpr (detail::hasStartsWith()) { + return T::startsWith(r, Qt::CaseInsensitive); + } else { + if (T::size() < r.size()) + return false; + + const auto view = view_type{T::cbegin(), static_cast(r.size())}; + return view.compare(r, Qt::CaseInsensitive) == 0; + } + } + + template + bool endsWith(const U &r) const + { + if constexpr (detail::hasEndsWith()) { + return T::endsWith(r, Qt::CaseInsensitive); + } else { + if (T::size() < r.size()) + return false; + + return view_type{T::cend() - r.size(), r.size()}.compare(r, Qt::CaseInsensitive) == 0; + } + } +}; + +template CaseInsensitive(const T &) -> CaseInsensitive; +template CaseInsensitive(T &&) -> CaseInsensitive; + +static_assert(std::is_same_v::view_type>); +static_assert(std::is_same_v::view_type>); +static_assert(std::is_same_v::view_type>); +static_assert(std::is_same_v::view_type>); +static_assert(std::is_same_v::view_type>); + +struct Message +{ + using HeaderList = QList, QByteArray>>; + + QByteArray verb; + QByteArray resource; + QByteArray protocol; + HeaderList headers = {}; + + [[nodiscard]] static Message parse(const QByteArray &data); + [[nodiscard]] static Message parse(QIODevice *device); +}; + +QDateTime parseDateTime(const QByteArray &text); +QDateTime parseDateTime(const QString &text); + +QDateTime expiryDateTime(const QByteArray &cacheControl, const QByteArray &expires, const QDateTime &now); +QDateTime expiryDateTime(const QByteArray &cacheControl, const QByteArray &expires); + +} // namespace qnc::http + +#endif // QNC_HTTPPARSER_H diff --git a/mdns/mdnsresolver.cpp b/mdns/mdnsresolver.cpp index 5b639c8..a4f1dc7 100755 --- a/mdns/mdnsresolver.cpp +++ b/mdns/mdnsresolver.cpp @@ -22,62 +22,12 @@ namespace qnc::mdns { namespace { -using namespace std::chrono_literals; - -Q_LOGGING_CATEGORY(lcResolver, "mdns.resolver") +Q_LOGGING_CATEGORY(lcResolver, "qnc.mdns.resolver") constexpr auto s_mdnsUnicastIPv4 = "224.0.0.251"_L1; constexpr auto s_mdnsUnicastIPv6 = "ff02::fb"_L1; constexpr auto s_mdnsPort = 5353; -auto multicastGroup(QUdpSocket *socket) -{ - switch (socket->localAddress().protocol()) { - case QUdpSocket::IPv4Protocol: - return QHostAddress{s_mdnsUnicastIPv4}; - case QUdpSocket::IPv6Protocol: - return QHostAddress{s_mdnsUnicastIPv6}; - - case QUdpSocket::AnyIPProtocol: - case QUdpSocket::UnknownNetworkLayerProtocol: - break; - } - - Q_UNREACHABLE(); - return QHostAddress{}; -}; - -bool isSupportedInterfaceType(QNetworkInterface::InterfaceType type) -{ - return type == QNetworkInterface::Ethernet - || type == QNetworkInterface::Wifi; -} - -bool isMulticastInterface(const QNetworkInterface &iface) -{ - return iface.flags().testFlag(QNetworkInterface::IsRunning) - && iface.flags().testFlag(QNetworkInterface::CanMulticast); -} - -bool isSupportedInterface(const QNetworkInterface &iface) -{ - return isSupportedInterfaceType(iface.type()) - && isMulticastInterface(iface); -} - -bool isLinkLocalAddress(const QHostAddress &address) -{ - return address.protocol() == QAbstractSocket::IPv4Protocol - || address.isLinkLocal(); -} - -auto isSocketForAddress(QHostAddress address) -{ - return [address = std::move(address)](QUdpSocket *socket) { - return socket->localAddress() == address; - }; -} - auto normalizedHostName(QByteArray name, QString domain) { auto normalizedName = QString::fromLatin1(name); @@ -142,14 +92,9 @@ ServiceDescription::ServiceDescription(QString domain, QByteArray name, ServiceR } Resolver::Resolver(QObject *parent) - : QObject{parent} - , m_timer{new QTimer{this}} + : core::MulticastResolver{parent} , m_domain{"local"_L1} -{ - m_timer->callOnTimeout(this, &Resolver::onTimeout); - QTimer::singleShot(0, this, &Resolver::onTimeout); - m_timer->start(2s); -} +{} void Resolver::setDomain(QString domain) { @@ -162,42 +107,21 @@ QString Resolver::domain() const return m_domain; } -void Resolver::setScanInterval(std::chrono::milliseconds ms) -{ - if (scanIntervalAsDuration() != ms) { - m_timer->setInterval(ms); - emit scanIntervalChanged(scanInterval()); - } -} - -void Resolver::setScanInterval(int ms) -{ - if (scanInterval() != ms) { - m_timer->setInterval(ms); - emit scanIntervalChanged(scanInterval()); - } -} - -std::chrono::milliseconds Resolver::scanIntervalAsDuration() const -{ - return m_timer->intervalAsDuration(); -} - -int Resolver::scanInterval() const +QHostAddress Resolver::multicastGroup(const QHostAddress &address) const { - return m_timer->interval(); -} - -bool Resolver::lookupHostNames(QStringList hostNames) -{ - auto message = qnc::mdns::Message{}; + switch (address.protocol()) { + case QUdpSocket::IPv4Protocol: + return QHostAddress{s_mdnsUnicastIPv4}; + case QUdpSocket::IPv6Protocol: + return QHostAddress{s_mdnsUnicastIPv6}; - for (const auto &name: hostNames) { - message.addQuestion({qualifiedHostName(name, m_domain).toLatin1(), qnc::mdns::Message::A}); - message.addQuestion({qualifiedHostName(name, m_domain).toLatin1(), qnc::mdns::Message::AAAA}); + case QUdpSocket::AnyIPProtocol: + case QUdpSocket::UnknownNetworkLayerProtocol: + break; } - return lookup(message); + Q_UNREACHABLE(); + return {}; } bool Resolver::lookupServices(QStringList serviceTypes) @@ -212,146 +136,57 @@ bool Resolver::lookupServices(QStringList serviceTypes) bool Resolver::lookup(Message query) { - if (const auto data = query.data(); !m_queries.contains(data)) { - m_queries.append(std::move(data)); - return true; - } - - return false; + return addQuery(query.data()); } -bool Resolver::isOwnMessage(QNetworkDatagram message) const +quint16 Resolver::port() const { - if (message.senderPort() != s_mdnsPort) - return false; - if (socketForAddress(message.senderAddress())) - return false; - - return m_queries.contains(message.data()); + return s_mdnsPort; } -QUdpSocket *Resolver::socketForAddress(QHostAddress address) const +bool Resolver::lookupHostNames(QStringList hostNames) { - const auto it = std::find_if(m_sockets.begin(), m_sockets.end(), isSocketForAddress(std::move(address))); - if (it != m_sockets.end()) - return *it; - - return nullptr; -} + auto message = qnc::mdns::Message{}; -void Resolver::scanNetworkInterfaces() -{ - auto newSocketList = QList{}; - - const auto allInterfaces = QNetworkInterface::allInterfaces(); - for (const auto &iface: allInterfaces) { - if (!isSupportedInterface(iface)) - continue; - - const auto addressEntries = iface.addressEntries(); - for (const auto &entry: addressEntries) { - if (!isLinkLocalAddress(entry.ip())) - continue; - - if (const auto socket = socketForAddress(entry.ip())) { - newSocketList.append(socket); - continue; - } - - qCInfo(lcResolver) << "Creating socket for" << entry.ip(); - auto socket = std::make_unique(this); - - if (!socket->bind(entry.ip(), s_mdnsPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint)) { - qCWarning(lcResolver, "Could not bind mDNS socket to %ls: %ls", - qUtf16Printable(entry.ip().toString()), - qUtf16Printable(socket->errorString())); - continue; - } - - const auto group = multicastGroup(socket.get()); - if (socket->joinMulticastGroup(group, iface)) { - qCDebug(lcResolver, "Multicast group %ls joined on %ls", - qUtf16Printable(group.toString()), - qUtf16Printable(iface.name())); - } else if (socket->error() != QUdpSocket::UnknownSocketError) { - qCWarning(lcResolver, "Could not join multicast group %ls on %ls: %ls", - qUtf16Printable(group.toString()), - qUtf16Printable(iface.name()), - qUtf16Printable(socket->errorString())); - continue; - } - - connect(socket.get(), &QUdpSocket::readyRead, - this, [this, socket = socket.get()] { - onReadyRead(socket); - }); - - socket->setMulticastInterface(iface); - newSocketList.append(socket.release()); - } + for (const auto &name: hostNames) { + message.addQuestion({qualifiedHostName(name, m_domain).toLatin1(), qnc::mdns::Message::A}); + message.addQuestion({qualifiedHostName(name, m_domain).toLatin1(), qnc::mdns::Message::AAAA}); } - const auto oldSocketList = std::exchange(m_sockets, newSocketList); - for (const auto socket: oldSocketList) { - if (!m_sockets.contains(socket)) { - qCDebug(lcResolver, "Destroying socket for address %ls", - qUtf16Printable(socket->localAddress().toString())); - delete socket; - } - } + return lookup(message); } -void Resolver::submitQueries() const +void Resolver::processDatagram(const QNetworkDatagram &datagram) { - for (const auto socket: m_sockets) { - const auto group = multicastGroup(socket); + const auto message = qnc::mdns::Message{datagram.data()}; - for (const auto &data: m_queries) - socket->writeDatagram(data, group, 5353); - } -} + auto resolvedAddresses = std::unordered_map>{}; + auto resolvedServices = std::unordered_map{}; + auto resolvedText = std::unordered_map{}; -void Resolver::onReadyRead(QUdpSocket *socket) -{ - while (socket->hasPendingDatagrams()) { - if (const auto received = socket->receiveDatagram(); !isOwnMessage(received)) { - const auto message = qnc::mdns::Message{received.data()}; - - auto resolvedAddresses = std::unordered_map>{}; - auto resolvedServices = std::unordered_map{}; - auto resolvedText = std::unordered_map{}; - - for (auto i = 0; i < message.responseCount(); ++i) { - const auto response = message.response(i); - - if (const auto address = response.address(); !address.isNull()) { - auto &knownAddresses = resolvedAddresses[response.name().toByteArray()]; - if (!knownAddresses.contains(address)) - knownAddresses.append(address); - } else if (const auto service = response.service(); !service.isNull()) { - resolvedServices.insert({response.name().toByteArray(), service}); - } else if (const auto text = response.text(); !text.isNull()) { - resolvedText.insert({response.name().toByteArray(), text}); - } - } - - for (const auto &[name, service]: resolvedServices) { - auto info = parseTxtRecord(resolvedText[name]); - emit serviceResolved({m_domain, name, service, std::move(info)}); - } - - for (const auto &[name, addresses]: resolvedAddresses) - emit hostNameResolved(normalizedHostName(name, m_domain), addresses); - - emit messageReceived(std::move(message)); + for (auto i = 0; i < message.responseCount(); ++i) { + const auto response = message.response(i); + + if (const auto address = response.address(); !address.isNull()) { + auto &knownAddresses = resolvedAddresses[response.name().toByteArray()]; + if (!knownAddresses.contains(address)) + knownAddresses.append(address); + } else if (const auto service = response.service(); !service.isNull()) { + resolvedServices.insert({response.name().toByteArray(), service}); + } else if (const auto text = response.text(); !text.isNull()) { + resolvedText.insert({response.name().toByteArray(), text}); } } -} -void Resolver::onTimeout() -{ - scanNetworkInterfaces(); - submitQueries(); + for (const auto &[name, service]: resolvedServices) { + auto info = parseTxtRecord(resolvedText[name]); + emit serviceResolved({m_domain, name, service, std::move(info)}); + } + + for (const auto &[name, addresses]: resolvedAddresses) + emit hostNameResolved(normalizedHostName(name, m_domain), addresses); + + emit messageReceived(std::move(message)); } } // namespace qnc::mdns diff --git a/mdns/mdnsresolver.h b/mdns/mdnsresolver.h index 8d20d75..69f95b1 100755 --- a/mdns/mdnsresolver.h +++ b/mdns/mdnsresolver.h @@ -4,11 +4,10 @@ #ifndef MDNS_MDNSRESOLVER_H #define MDNS_MDNSRESOLVER_H -#include +#include "qncresolver.h" class QHostAddress; class QNetworkDatagram; -class QTimer; class QUdpSocket; namespace qnc::mdns { @@ -43,24 +42,18 @@ class ServiceDescription QStringList m_info; }; -class Resolver : public QObject +class Resolver : public core::MulticastResolver { Q_OBJECT Q_PROPERTY(QString domain READ domain WRITE setDomain NOTIFY domainChanged FINAL) - Q_PROPERTY(int scanInterval READ scanInterval WRITE setScanInterval NOTIFY scanIntervalChanged FINAL) public: explicit Resolver(QObject *parent = {}); QString domain() const; - int scanInterval() const; - std::chrono::milliseconds scanIntervalAsDuration() const; - void setScanInterval(std::chrono::milliseconds ms); - public slots: void setDomain(QString domain); - void setScanInterval(int ms); bool lookupHostNames(QStringList hostNames); bool lookupServices(QStringList serviceTypes); @@ -68,27 +61,18 @@ public slots: signals: void domainChanged(QString domain); - void scanIntervalChanged(int interval); void hostNameResolved(QString hostname, QList addresses); void serviceResolved(qnc::mdns::ServiceDescription service); void messageReceived(qnc::mdns::Message message); -private: - bool isOwnMessage(QNetworkDatagram message) const; - QUdpSocket *socketForAddress(QHostAddress address) const; - - void scanNetworkInterfaces(); - void submitQueries() const; - - void onReadyRead(QUdpSocket *socket); - void onTimeout(); - - QList m_sockets; - QTimer *const m_timer; +protected: + [[nodiscard]] virtual quint16 port() const override; + [[nodiscard]] QHostAddress multicastGroup(const QHostAddress &address) const override; + void processDatagram(const QNetworkDatagram &datagram) override; +private: QString m_domain; - QByteArrayList m_queries; }; } // namespace qnc::mdns diff --git a/qnc/CMakeLists.txt b/qnc/CMakeLists.txt index 6b4ecdf..75c365f 100644 --- a/qnc/CMakeLists.txt +++ b/qnc/CMakeLists.txt @@ -4,6 +4,8 @@ qnc_add_library( qncliterals.h qncparse.cpp qncparse.h + qncresolver.cpp + qncresolver.h ) target_link_libraries(QncCore PUBLIC Qt::Network) diff --git a/qnc/qncliterals.h b/qnc/qncliterals.h index 462c75e..d62b68e 100644 --- a/qnc/qncliterals.h +++ b/qnc/qncliterals.h @@ -19,9 +19,41 @@ using lentype = qsizetype; #else // QT_VERSION < QT_VERSION_CHECK(6,0,0) -using ByteArrayView = QLatin1String; using lentype = int; +// This is not an efficient implementation of QByteArrayView. +// This is just some glue to make this code work with Qt5. +// Use this this library with Qt6 if performance matters. +class ByteArrayView +{ +public: + constexpr ByteArrayView() : m_str{} {} + constexpr ByteArrayView(const char *str, int len) : m_str{str, len} {} + ByteArrayView(const QByteArray &ba) : m_str{ba.constData(), ba.size()} {} + + QByteArray toByteArray() const { return {m_str.data(), m_str.size()}; } + operator QByteArray() const { return toByteArray(); } + + constexpr qsizetype length() const { return m_str.size(); } + constexpr qsizetype size() const { return m_str.size(); } + + const char *cbegin() const { return m_str.cbegin(); } + const char *cend() const { return m_str.cend(); } + const char *data() const { return m_str.data(); } + + int toInt (bool *ok = nullptr, int base = 10) const { return toByteArray().toInt (ok, base); } + uint toUInt(bool *ok = nullptr, int base = 10) const { return toByteArray().toUInt(ok, base); } + + int compare(const QByteArray &r, Qt::CaseSensitivity cs = Qt::CaseSensitive) const + { return m_str.compare(QLatin1String{r.data(), r.size()}, cs); } + + int indexOf(char c, qsizetype from = 0) const + { return m_str.indexOf(QChar::fromLatin1(c), static_cast(from)); } + +private: + QLatin1String m_str; +}; + #endif // QT_VERSION < QT_VERSION_CHECK(6,0,0) } // namespace compat @@ -54,6 +86,11 @@ constexpr auto operator ""_L1(const char *str, size_t len) return QLatin1String{str, static_cast(len)}; } +inline auto operator ""_ba(const char *str, size_t len) +{ + return QByteArray{str, static_cast(len)}; +} + #endif // QT_VERSION < QT_VERSION_CHECK(6,4,0) constexpr auto operator ""_baview(const char *str, size_t len) diff --git a/qnc/qncresolver.cpp b/qnc/qncresolver.cpp new file mode 100644 index 0000000..912167c --- /dev/null +++ b/qnc/qncresolver.cpp @@ -0,0 +1,242 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2023 Mathias Hasselmann + */ +#include "qncresolver.h" + +// Qt headers +#include +#include +#include +#include +#include +#include + +namespace qnc::core { + +namespace { + +using namespace std::chrono_literals; + +Q_LOGGING_CATEGORY(lcResolver, "qnc.core.resolver") +Q_LOGGING_CATEGORY(lcMulticast, "qnc.core.resolver.multicast") + +QHostAddress wildcardAddress(const QHostAddress &address) +{ + switch(address.protocol()) { + case QUdpSocket::IPv4Protocol: + return QHostAddress::AnyIPv4; + + case QUdpSocket::IPv6Protocol: + return QHostAddress::AnyIPv6; + + case QUdpSocket::AnyIPProtocol: + case QUdpSocket::UnknownNetworkLayerProtocol: + break; + } + + Q_UNREACHABLE(); + return {}; +} + +} // namespace + +GenericResolver::GenericResolver(QObject *parent) + : QObject{parent} + , m_timer{new QTimer{this}} +{ + m_timer->callOnTimeout(this, &GenericResolver::onTimeout); + QTimer::singleShot(0, this, &GenericResolver::onTimeout); + m_timer->start(15s); +} + +void GenericResolver::setScanInterval(std::chrono::milliseconds ms) +{ + if (scanIntervalAsDuration() != ms) { + m_timer->setInterval(ms); + emit scanIntervalChanged(scanInterval()); + } +} + +void GenericResolver::setScanInterval(int ms) +{ + if (scanInterval() != ms) { + m_timer->setInterval(ms); + emit scanIntervalChanged(scanInterval()); + } +} + +std::chrono::milliseconds GenericResolver::scanIntervalAsDuration() const +{ + return m_timer->intervalAsDuration(); +} + +int GenericResolver::scanInterval() const +{ + return m_timer->interval(); +} + +GenericResolver::SocketPointer +GenericResolver::socketForAddress(const QHostAddress &address) const +{ + return m_sockets[address]; +} + +void GenericResolver::scanNetworkInterfaces() +{ + auto newSockets = SocketTable{}; + + const auto &allInterfaces = QNetworkInterface::allInterfaces(); + + for (const auto &iface : allInterfaces) { + if (!isSupportedInterface(iface)) + continue; + + const auto addressEntries = iface.addressEntries(); + for (const auto &entry : addressEntries) { + if (!isSupportedAddress(entry.ip())) + continue; + + if (const auto socket = socketForAddress(entry.ip())) { + newSockets.insert(entry.ip(), socket); + continue; + } + + qCInfo(lcResolver, "Creating socket for %ls on %ls", + qUtf16Printable(entry.ip().toString()), + qUtf16Printable(iface.humanReadableName())); + + if (const auto socket = createSocket(iface, entry.ip())) + newSockets.insert(entry.ip(), socket); + } + } + + std::exchange(m_sockets, newSockets); +} + +void GenericResolver::onTimeout() +{ + scanNetworkInterfaces(); + submitQueries(m_sockets); +} + +bool MulticastResolver::isSupportedInterface(const QNetworkInterface &iface) const +{ + return isSupportedInterfaceType(iface) + && isMulticastInterface(iface); +} + +bool MulticastResolver::isSupportedInterfaceType(const QNetworkInterface &iface) +{ + return iface.type() == QNetworkInterface::Ethernet + || iface.type() == QNetworkInterface::Wifi; +} + +bool MulticastResolver::isMulticastInterface(const QNetworkInterface &iface) +{ + return iface.flags().testFlag(QNetworkInterface::IsRunning) + && iface.flags().testFlag(QNetworkInterface::CanMulticast); +} + +bool MulticastResolver::isSupportedAddress(const QHostAddress &address) const +{ + return isLinkLocalAddress(address); +} + +bool MulticastResolver::isLinkLocalAddress(const QHostAddress &address) +{ + return address.protocol() == QAbstractSocket::IPv4Protocol + || address.isLinkLocal(); +} + +bool MulticastResolver::addQuery(QByteArray &&query) +{ + if (!m_queries.contains(query)) { + m_queries.append(std::move(query)); + return true; + } + + return false; +} + +MulticastResolver::SocketPointer +MulticastResolver::createSocket(const QNetworkInterface &iface, const QHostAddress &address) +{ + auto socket = std::make_shared(this); + + const auto &bindAddress = wildcardAddress(address); + const auto &bindMode = QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint; + + if (!socket->bind(bindAddress, port(), bindMode)) { + qCWarning(lcMulticast, "Could not bind multicast socket to %ls: %ls", + qUtf16Printable(address.toString()), + qUtf16Printable(socket->errorString())); + return nullptr; + } + + const auto &group = multicastGroup(address); + + if (socket->joinMulticastGroup(group, iface)) { + qCDebug(lcMulticast, "Multicast group %ls joined on %ls", + qUtf16Printable(group.toString()), + qUtf16Printable(iface.humanReadableName())); + } else if (socket->error() != QUdpSocket::UnknownSocketError) { + qCWarning(lcMulticast, "Could not join multicast group %ls on %ls: %ls", + qUtf16Printable(group.toString()), + qUtf16Printable(iface.name()), + qUtf16Printable(socket->errorString())); + return nullptr; + } + + connect(socket.get(), &QUdpSocket::readyRead, + this, [this, socket = socket.get()] { + onDatagramReceived(socket); + }); + + socket->setMulticastInterface(iface); + socket->setSocketOption(QUdpSocket::MulticastTtlOption, 4); + + return socket; +} + +void MulticastResolver::submitQueries(const SocketTable &sockets) +{ + for (auto it = sockets.cbegin(); it != sockets.cend(); ++it) { + Q_ASSERT(dynamic_cast(it->get())); + + const auto &address = it.key(); + const auto socket = static_cast(it->get()); + const auto group = multicastGroup(address); + + for (const auto &data: m_queries) { + const auto query = finalizeQuery(address, data); + socket->writeDatagram(query, group, port()); + } + } +} + +QByteArray MulticastResolver::finalizeQuery(const QHostAddress &/*address*/, const QByteArray &query) const +{ + return query; +} + +void MulticastResolver::onDatagramReceived(QUdpSocket *socket) +{ + while (socket->hasPendingDatagrams()) { + const auto datagram = socket->receiveDatagram(); + + if (!isOwnMessage(datagram)) + processDatagram(datagram); + } +} + +bool MulticastResolver::isOwnMessage(const QNetworkDatagram &message) const +{ + if (message.senderPort() != port()) + return false; + if (socketForAddress(message.senderAddress()) == nullptr) + return false; + + return m_queries.contains(message.data()); +} + +} // namespace qnc diff --git a/qnc/qncresolver.h b/qnc/qncresolver.h new file mode 100644 index 0000000..53fbda5 --- /dev/null +++ b/qnc/qncresolver.h @@ -0,0 +1,96 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#ifndef QNC_QNCRESOLVER_H +#define QNC_QNCRESOLVER_H + +#include +#include +#include +#include + +class QNetworkDatagram; +class QNetworkInterface; +class QTimer; +class QUdpSocket; + +namespace qnc::core { + +class GenericResolver : public QObject +{ + Q_OBJECT + Q_PROPERTY(int scanInterval READ scanInterval WRITE setScanInterval NOTIFY scanIntervalChanged FINAL) + +public: + GenericResolver(QObject *parent = nullptr); + + [[nodiscard]] int scanInterval() const; + [[nodiscard]] std::chrono::milliseconds scanIntervalAsDuration() const; + void setScanInterval(std::chrono::milliseconds ms); + +public slots: + void setScanInterval(int ms); + +signals: + void scanIntervalChanged(int interval); + +protected: + using SocketPointer = std::shared_ptr; + using SocketTable = QHash; + + [[nodiscard]] virtual bool isSupportedInterface(const QNetworkInterface &iface) const = 0; + [[nodiscard]] virtual bool isSupportedAddress(const QHostAddress &address) const = 0; + + [[nodiscard]] virtual SocketPointer createSocket(const QNetworkInterface &iface, + const QHostAddress &address) = 0; + + [[nodiscard]] SocketPointer socketForAddress(const QHostAddress &address) const; + + virtual void submitQueries(const SocketTable &sockets) = 0; + +private: + void onTimeout(); + void scanNetworkInterfaces(); + + QTimer *const m_timer; + SocketTable m_sockets; +}; + +class MulticastResolver : public GenericResolver +{ + Q_OBJECT + +public: + using GenericResolver::GenericResolver; + +protected: + [[nodiscard]] bool isSupportedInterface(const QNetworkInterface &iface) const override; + [[nodiscard]] bool isSupportedAddress(const QHostAddress &address) const override; + + [[nodiscard]] SocketPointer createSocket(const QNetworkInterface &iface, + const QHostAddress &address) override; + + void submitQueries(const SocketTable &sockets) override; + + [[nodiscard]] virtual quint16 port() const = 0; + [[nodiscard]] virtual QHostAddress multicastGroup(const QHostAddress &address) const = 0; + [[nodiscard]] virtual QByteArray finalizeQuery(const QHostAddress &address, const QByteArray &query) const; + + virtual void processDatagram(const QNetworkDatagram &message) = 0; + + [[nodiscard]] static bool isSupportedInterfaceType(const QNetworkInterface &iface); + [[nodiscard]] static bool isMulticastInterface(const QNetworkInterface &iface); + [[nodiscard]] static bool isLinkLocalAddress(const QHostAddress &address); + + bool addQuery(QByteArray &&query); + +private: + void onDatagramReceived(QUdpSocket *socket); + bool isOwnMessage(const QNetworkDatagram &message) const; + + QByteArrayList m_queries; +}; + +} // namespace qnc::core + +#endif // QNC_QNCRESOLVER_H diff --git a/ssdp/CMakeLists.txt b/ssdp/CMakeLists.txt new file mode 100644 index 0000000..adf944e --- /dev/null +++ b/ssdp/CMakeLists.txt @@ -0,0 +1,12 @@ +qnc_add_library( + QncSsdp STATIC + ssdpresolver.cpp + ssdpresolver.h +) + +target_link_libraries(QncSsdp PUBLIC QncHttp) + +if (NOT IOS) # FIXME Figure out code signing on Github + qnc_add_executable(SSDPResolverDemo TYPE tool ssdpresolverdemo.cpp) + target_link_libraries(SSDPResolverDemo PRIVATE QncSsdp) +endif() diff --git a/ssdp/ssdpresolver.cpp b/ssdp/ssdpresolver.cpp new file mode 100644 index 0000000..27680a2 --- /dev/null +++ b/ssdp/ssdpresolver.cpp @@ -0,0 +1,229 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#include "ssdpresolver.h" + +// QtNetworkCrumbs headers +#include "httpparser.h" +#include "qncliterals.h" + +// Qt headers +#include +#include +#include +#include + +namespace qnc::ssdp { + +namespace { + +Q_LOGGING_CATEGORY(lcResolver, "qnc.ssdp.resolver") + +constexpr auto s_ssdpUnicastIPv4 = "239.255.255.250"_L1; +constexpr auto s_ssdpUnicastIPv6 = "ff02::c"_L1; +constexpr auto s_ssdpPort = quint16{1900}; + +constexpr auto s_ssdpKeyMulticastGroup = "{multicast-group}"_baview; +constexpr auto s_ssdpKeyUdpPort = "{udp-port}"_baview; +constexpr auto s_ssdpKeyMinimumDelay = "{minimum-delay}"_baview; +constexpr auto s_ssdpKeyMaximumDelay = "{maximum-delay}"_baview; +constexpr auto s_ssdpKeyServiceType = "{service-type}"_baview; + +constexpr auto s_ssdpQueryTemplate = "M-SEARCH * HTTP/1.1\r\n" + "HOST: {multicast-group}:{udp-port}\r\n" + "MAN: \"ssdp:discover\"\r\n" + "MM: {minimum-delay}\r\n" + "MX: {maximum-delay}\r\n" + "ST: {service-type}\r\n" + "\r\n"_baview; + +QList parseAlternativeLocations(compat::ByteArrayView text) +{ + auto locations = QList{}; + + for (auto offset = qsizetype{0};;) { + const auto start = text.indexOf('<', offset); + + if (start < 0) + break; + + const auto end = text.indexOf('>', start + 1); + + if (end < 0) + break; + + locations += QUrl::fromEncoded({text.data() + start + 1, end - start - 1}); + offset = end + 1; + } + + return locations; +} + +} // namespace + +bool Resolver::lookupService(const ServiceLookupRequest &request) +{ + const auto minimumDelaySeconds = static_cast(request.minimumDelay.count()); + const auto maximumDelaySeconds = static_cast(request.maximumDelay.count()); + + auto query = + s_ssdpQueryTemplate.toByteArray(). + replace(s_ssdpKeyUdpPort, QByteArray::number(s_ssdpPort)). + replace(s_ssdpKeyMinimumDelay, QByteArray::number(minimumDelaySeconds)). + replace(s_ssdpKeyMaximumDelay, QByteArray::number(maximumDelaySeconds)). + replace(s_ssdpKeyServiceType, request.serviceType.toUtf8()); + + return addQuery(std::move(query)); +} + +bool Resolver::lookupService(const QString &serviceType) +{ + auto request = ServiceLookupRequest{}; + request.serviceType = serviceType; + return lookupService(request); +} + +QHostAddress Resolver::multicastGroup(const QHostAddress &address) const +{ + switch (address.protocol()) { + case QAbstractSocket::IPv4Protocol: + return QHostAddress{s_ssdpUnicastIPv4}; + + case QAbstractSocket::IPv6Protocol: + return QHostAddress{s_ssdpUnicastIPv6}; + + case QAbstractSocket::AnyIPProtocol: + case QAbstractSocket::UnknownNetworkLayerProtocol: + break; + } + + Q_UNREACHABLE(); + return {}; +} + +quint16 Resolver::port() const +{ + return s_ssdpPort; +} + +QByteArray Resolver::finalizeQuery(const QHostAddress &address, const QByteArray &query) const +{ + const auto &group = multicastGroup(address); + return QByteArray{query}.replace(s_ssdpKeyMulticastGroup, group.toString().toLatin1()); +} + +NotifyMessage NotifyMessage::parse(const QByteArray &data, const QDateTime &now) +{ + constexpr auto s_ssdpVerbNotify = "NOTIFY"_baview; + constexpr auto s_ssdpResourceAny = "*"_baview; + constexpr auto s_ssdpProtocolHttp11 = "HTTP/1.1"_baview; + constexpr auto s_ssdpHeaderCacheControl = "Cache-Control"_baview; + constexpr auto s_ssdpHeaderExpires = "Expires"_baview; + constexpr auto s_ssdpHeaderLocation = "Location"_baview; + constexpr auto s_ssdpHeaderAlternativeLocation = "AL"_baview; + constexpr auto s_ssdpHeaderNotifySubType = "NTS"_baview; + constexpr auto s_ssdpHeaderNotifyType = "NT"_baview; + constexpr auto s_ssdpHeaderUniqueServiceName = "USN"_baview; + constexpr auto s_ssdpNotifySubTypeAlive = "ssdp:alive"_baview; + constexpr auto s_ssdpNotifySubTypeByeBye = "ssdp:byebye"_baview; + + const auto &message = http::Message::parse(data); + + if (message.verb.isEmpty()) { + qCWarning(lcResolver, "Ignoring message with malformed HTTP header"); + return {}; + } + + if (message.protocol != s_ssdpProtocolHttp11) { + qCWarning(lcResolver, "Ignoring unknown protocol: %s", message.protocol.constData()); + return {}; + } + + if (message.verb != s_ssdpVerbNotify) { + qCDebug(lcResolver, "Ignoring %s message", message.verb.constData()); + return {}; + } + + if (message.resource != s_ssdpResourceAny) { + qCDebug(lcResolver, "Ignoring unknown resource: %s", message.resource.constData()); + return {}; + } + + auto response = NotifyMessage{}; + auto notifyType = QByteArray{}; + auto cacheControl = QByteArray{}; + auto expires = QByteArray{}; + + for (const auto &[name, value] : message.headers) { + if (name == s_ssdpHeaderUniqueServiceName) + response.serviceName = QUrl::fromPercentEncoding(value); + else if (name == s_ssdpHeaderNotifyType) + response.serviceType = QUrl::fromPercentEncoding(value); + else if (name == s_ssdpHeaderNotifySubType) + notifyType = value; + else if (name == s_ssdpHeaderCacheControl) + cacheControl = value; + else if (name == s_ssdpHeaderExpires) + expires = value; + else if (name == s_ssdpHeaderLocation) + response.locations += QUrl::fromEncoded(value); + else if (name == s_ssdpHeaderAlternativeLocation) + response.altLocations += parseAlternativeLocations(value); + } + + if (notifyType == s_ssdpNotifySubTypeAlive) + response.type = NotifyMessage::Type::Alive; + else if (notifyType == s_ssdpNotifySubTypeByeBye) + response.type = NotifyMessage::Type::ByeBye; + else + return {}; + + response.expiry = http::expiryDateTime(cacheControl, expires, now); + + return response; +} + +NotifyMessage NotifyMessage::parse(const QByteArray &data) +{ + return parse(data, QDateTime::currentDateTimeUtc()); +} + +void Resolver::processDatagram(const QNetworkDatagram &datagram) +{ + const auto response = NotifyMessage::parse(datagram.data()); + + switch (response.type) { + case NotifyMessage::Type::Alive: + emit serviceFound({response.serviceName, response.serviceType, + response.locations, response.altLocations, + response.expiry}); + break; + + case NotifyMessage::Type::ByeBye: + emit serviceLost(response.serviceName); + break; + + case NotifyMessage::Type::Invalid: + break; + } +} + +// namespace + +} // namespace qnc::ssdp + +QDebug operator<<(QDebug debug, const qnc::ssdp::ServiceDescription &service) +{ + const auto _ = QDebugStateSaver{debug}; + + if (debug.verbosity() >= QDebug::DefaultVerbosity) + debug.nospace() << service.staticMetaObject.className(); + + return debug.nospace() + << "(" << service.name() + << ", type=" << service.type() + << ", location=" << service.locations() + << ", alt-location=" << service.alternativeLocations() + << ", expires=" << service.expires() + << ")"; +} diff --git a/ssdp/ssdpresolver.h b/ssdp/ssdpresolver.h new file mode 100644 index 0000000..1327d57 --- /dev/null +++ b/ssdp/ssdpresolver.h @@ -0,0 +1,107 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ +#ifndef QNCSSDP_RESOLVER_H +#define QNCSSDP_RESOLVER_H + +#include "qncresolver.h" + +#include +#include +#include + +namespace qnc::ssdp { + +class ServiceDescription +{ + Q_GADGET + Q_PROPERTY(QString name READ name CONSTANT FINAL) + Q_PROPERTY(QString type READ type CONSTANT FINAL) + Q_PROPERTY(QList locations READ locations CONSTANT FINAL) + Q_PROPERTY(QList alternativeLocations READ alternativeLocations CONSTANT FINAL) + Q_PROPERTY(QDateTime expires READ expires CONSTANT FINAL) + +public: + ServiceDescription(const QString &name, + const QString &type, + const QList &locations, + const QList &alternativeLocations, + const QDateTime &expires) + : m_name {name} + , m_type {type} + , m_locations {locations} + , m_alternativeLocations{alternativeLocations} + , m_expires {expires} + {} + + + QString name() const { return m_name; } + QString type() const { return m_type; } + QList locations() const { return m_locations; } + QList alternativeLocations() const { return m_alternativeLocations; } + QDateTime expires() const { return m_expires; } + +private: + QString m_name; + QString m_type; + QList m_locations; + QList m_alternativeLocations; + QDateTime m_expires; +}; + +struct NotifyMessage +{ + enum class Type { + Invalid, + Alive, + ByeBye, + }; + + Type type = Type::Invalid; + QString serviceName = {}; + QString serviceType = {}; + QList locations = {}; + QList altLocations = {}; + QDateTime expiry = {}; + + static NotifyMessage parse(const QByteArray &data, const QDateTime &now); + static NotifyMessage parse(const QByteArray &data); +}; + +struct ServiceLookupRequest +{ + using seconds = std::chrono::seconds; + + QString serviceType; + seconds minimumDelay = seconds{0}; + seconds maximumDelay = seconds{5}; +}; + +class Resolver : public core::MulticastResolver +{ + Q_OBJECT + +public: + using core::MulticastResolver::MulticastResolver; + +public slots: + bool lookupService(const ServiceLookupRequest &request); + bool lookupService(const QString &serviceType); + +signals: + void serviceFound(const qnc::ssdp::ServiceDescription &service); + void serviceLost(const QString &uniqueServiceName); + +protected: + [[nodiscard]] quint16 port() const override; + [[nodiscard]] QHostAddress multicastGroup(const QHostAddress &address) const override; + [[nodiscard]] QByteArray finalizeQuery(const QHostAddress &address, const QByteArray &query) const override; + + void processDatagram(const QNetworkDatagram &message) override; +}; + +} // namespace qnc::ssdp + +QDebug operator<<(QDebug debug, const qnc::ssdp::ServiceDescription &service); + +#endif // QNCSSDP_RESOLVER_H diff --git a/ssdp/ssdpresolverdemo.cpp b/ssdp/ssdpresolverdemo.cpp new file mode 100644 index 0000000..3ad3357 --- /dev/null +++ b/ssdp/ssdpresolverdemo.cpp @@ -0,0 +1,54 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ + +// QtNetworkCrumbs headers +#include "qncliterals.h" +#include "ssdpresolver.h" + +// Qt headers +#include +#include +#include + +namespace qnc::ssdp::demo { +namespace { + +Q_LOGGING_CATEGORY(lcDemo, "ssdp.demo.resolver", QtInfoMsg) + +class ResolverDemo : public QCoreApplication +{ +public: + using QCoreApplication::QCoreApplication; + + int run() + { + const auto resolver = new Resolver{this}; + + connect(resolver, &Resolver::serviceFound, + this, [](const auto &service) { + qCInfo(lcDemo).verbosity(QDebug::MinimumVerbosity) + << "service resolved:" + << service; + }); + + connect(resolver, &Resolver::serviceLost, + this, [](const auto &serviceName) { + qCInfo(lcDemo).verbosity(QDebug::MinimumVerbosity) + << "service lost:" + << serviceName; + }); + + resolver->lookupService("ssdp:all"_L1); + + return exec(); + } +}; + +} // namespace +} // namespace qnc::ssdp::demo + +int main(int argc, char *argv[]) +{ + return qnc::ssdp::demo::ResolverDemo{argc, argv}.run(); +} diff --git a/tests/auto/CMakeLists.txt b/tests/auto/CMakeLists.txt index 885f68c..fed2a7d 100644 --- a/tests/auto/CMakeLists.txt +++ b/tests/auto/CMakeLists.txt @@ -21,6 +21,8 @@ function(add_testcase SOURCE_FILENAME) # [SOURCES...] add_test(NAME "${TESTCASE_NAME}" COMMAND ${TEST_RUNNER} "$") endfunction() +add_testcase(tst_httpparser.cpp LIBRARIES QncHttp) add_testcase(tst_mdnsmessages.cpp LIBRARIES QncMdns) add_testcase(tst_mdnsresolver.cpp LIBRARIES QncMdns) add_testcase(tst_qncparse.cpp LIBRARIES QncCore) +add_testcase(tst_ssdpresolver.cpp LIBRARIES QncSsdp) diff --git a/tests/auto/tst_httpparser.cpp b/tests/auto/tst_httpparser.cpp new file mode 100644 index 0000000..abf29d2 --- /dev/null +++ b/tests/auto/tst_httpparser.cpp @@ -0,0 +1,170 @@ +#include "httpparser.h" +#include "qncliterals.h" + +#include + +namespace qnc::http::tests { + +class ParserTest : public QObject +{ + Q_OBJECT + +public: + using QObject::QObject; + +private slots: + void testCaseInsensitiveEqual() + { + for (const auto &a : m_samples) { + for (const auto &b : m_samples) { + QCOMPARE(CaseInsensitive{a}, b); + QCOMPARE(a, CaseInsensitive{b}); + + QCOMPARE(CaseInsensitive{QString::fromUtf8(a)}, QString::fromUtf8(b)); + QCOMPARE(QString::fromUtf8(a), CaseInsensitive{QString::fromUtf8(b)}); + } + } + } + + void testCaseInsensitiveNotEqual() + { + for (const auto &a : m_samples) { + QVERIFY(CaseInsensitive{a} != "whatever"_baview); + QVERIFY(a != CaseInsensitive{"whatever"_ba}); + + QVERIFY(CaseInsensitive{QString::fromUtf8(a)} != "whatever"_L1); + QVERIFY(QString::fromUtf8(a) != CaseInsensitive{"whatever"_L1}); + } + } + + void testCaseInsensitiveStartsWith() + { + for (const auto &a : m_samples) { + for (const auto &b : m_samples) { + QVERIFY2(CaseInsensitive{a}.startsWith(b.left(5)), (a + " / " + b).constData()); + + const auto astr = QString::fromUtf8(a); + const auto bstr = QString::fromUtf8(b); + const auto substr = QStringView{bstr.cbegin(), 5}; + + QVERIFY2(CaseInsensitive{astr}.startsWith(substr), qPrintable(astr + " / "_L1 + bstr)); + } + } + } + + void testCaseInsensitiveEndsWith() + { + for (const auto &a : m_samples) { + for (const auto &b : m_samples) { + QVERIFY(CaseInsensitive{a}.endsWith(b.mid(5))); + + const auto astr = QString::fromUtf8(a); + const auto bstr = QString::fromUtf8(b); + const auto substr = QStringView{bstr.cbegin() + 5, bstr.cend()}; + + QVERIFY(CaseInsensitive{astr}.endsWith(substr)); + } + } + } + + void testCaseInsensitiveContains() + { + auto strList = QStringList{}; + auto istrList = QList>{}; + auto ibaList = QList>{}; + + std::transform(m_samples.cbegin(), m_samples.cend(), std::back_inserter(strList), + [](const QByteArray &ba) { return QString::fromUtf8(ba); }); + std::copy(m_samples.cbegin(), m_samples.cend(), std::back_inserter(ibaList)); + std::copy(strList.cbegin(), strList.cend(), std::back_inserter(istrList)); + + for (const auto &a : m_samples) { + QVERIFY(m_samples.contains(CaseInsensitive{a})); + QVERIFY(ibaList.contains(a)); + + const auto astr = QString::fromUtf8(a); + + QVERIFY(strList.contains(CaseInsensitive{astr})); + QVERIFY(istrList.contains(astr)); + } + } + + void testDateTime_data() + { + QTest::addColumn("text"); + QTest::addColumn("expectedDateTime"); + + // examples from https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7 + QTest::newRow("RFC1123") << "Sun, 06 Nov 1994 08:49:37 GMT" << "1994-11-06T08:49:37Z"_iso8601; + QTest::newRow("RFC850") << "Sunday, 06-Nov-94 08:49:37 GMT" << "1994-11-06T08:49:37Z"_iso8601; + QTest::newRow("asctime") << "Sun Nov 6 08:49:37 1994" << "1994-11-06T08:49:37Z"_iso8601; + } + + void testDateTime() + { + const QFETCH(QString, text); + const QFETCH(QDateTime, expectedDateTime); + QCOMPARE(parseDateTime(text), expectedDateTime); + } + + void testExpiryDateTime_data() + { + QTest::addColumn("now"); + QTest::addColumn("cacheControl"); + QTest::addColumn("expires"); + QTest::addColumn("expectedDateTime"); + + const auto now = "1994-11-06T08:49:37Z"_iso8601; + const auto expires = "Sun, 06 Nov 1994 08:54:37 GMT"_ba; + const auto empty = ""_ba; + + QTest::addRow("nothing") << now << empty << empty << QDateTime{}; + QTest::addRow("no-cache") << now << "no-cache"_ba << empty << now; + QTest::addRow("max-age") << now << "max-age=60"_ba << empty << now.addSecs(60); + QTest::addRow("expires") << now << empty << expires << now.addSecs(300); + QTest::addRow("all") << now << "max-age=60, no-cache"_ba << expires << now; + } + + void testExpiryDateTime() + { + const QFETCH(QByteArray, cacheControl); + const QFETCH(QByteArray, expires); + const QFETCH(QDateTime, now); + const QFETCH(QDateTime, expectedDateTime); + + QCOMPARE(expiryDateTime(cacheControl, expires, now), expectedDateTime); + } + + void testParseMessage() + { + const auto &message = Message::parse("M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "MAN: \"ssdp:discover\"\r\n" + "MX: 1\r\n" + "ST: upnp:rootdevice\r\n" + "\r\n"_ba); + + QCOMPARE(message.verb, "M-SEARCH"); + QCOMPARE(message.resource, "*"); + QCOMPARE(message.protocol, "HTTP/1.1"); + QCOMPARE(message.headers.size(), 4); + + QCOMPARE(message.headers[0].first, "Host"); + QCOMPARE(message.headers[0].second, "239.255.255.250:1900"); + QCOMPARE(message.headers[1].first, "MAN"); + QCOMPARE(message.headers[1].second, "\"ssdp:discover\""); + QCOMPARE(message.headers[2].first, "MX"); + QCOMPARE(message.headers[2].second, "1"); + QCOMPARE(message.headers[3].first, "ST"); + QCOMPARE(message.headers[3].second, "upnp:rootdevice"); + } + +private: + const QByteArrayList m_samples = { "cache-control"_ba, "Cache-Control"_ba, "CACHE-CONTROL"_ba }; +}; + +} // namespace qnc::mdns::tests + +QTEST_GUILESS_MAIN(qnc::http::tests::ParserTest) + +#include "tst_httpparser.moc" diff --git a/tests/auto/tst_mdnsresolver.cpp b/tests/auto/tst_mdnsresolver.cpp index e96352c..e4ec909 100644 --- a/tests/auto/tst_mdnsresolver.cpp +++ b/tests/auto/tst_mdnsresolver.cpp @@ -80,7 +80,7 @@ private slots: auto intervalChanges = QSignalSpy{&resolver, &Resolver::scanIntervalChanged}; auto expectedIntervalChanges = QList{}; - constexpr auto interval0 = 2s; + constexpr auto interval0 = 15s; constexpr auto interval1 = 3s; constexpr auto interval2 = 3500ms; diff --git a/tests/auto/tst_ssdpresolver.cpp b/tests/auto/tst_ssdpresolver.cpp new file mode 100644 index 0000000..d02c740 --- /dev/null +++ b/tests/auto/tst_ssdpresolver.cpp @@ -0,0 +1,92 @@ +/* QtNetworkCrumbs - Some networking toys for Qt + * Copyright (C) 2019-2024 Mathias Hasselmann + */ + +// QtNetworkCrumbs headers +#include "ssdpresolver.h" +#include "qncliterals.h" + +// Qt headers +#include + +Q_DECLARE_METATYPE(qnc::ssdp::NotifyMessage) + +namespace qnc::ssdp::tests { + +class ResolverTest : public QObject +{ + Q_OBJECT + +public: + using QObject::QObject; + +private slots: + void testParseNotifyMessage_data() + { + QTest::addColumn("now"); + QTest::addColumn("data"); + QTest::addColumn("expectedMessage"); + + const auto now = "2024-09-10T22:34:33Z"_iso8601; + + QTest::newRow("empty") + << now + << ""_ba + << NotifyMessage{}; + + QTest::newRow("alive") + << now + << "NOTIFY * HTTP/1.1\r\n" + "Host: 239.255.255.250:1900\r\n" + "NT: blenderassociation:blender\r\n" + "NTS: ssdp:alive\r\n" + "USN: someunique:idscheme3\r\n" + "LOCATION: http://192.168.123.45:7890/dd.xml\r\n" + "LOCATION: http://192.168.123.45:7890/icon.png\r\n" + "AL: \r\n" + "Cache-Control: max-age = 7393\r\n" + "\r\n"_ba + << NotifyMessage{ + NotifyMessage::Type::Alive, + "someunique:idscheme3"_L1, + "blenderassociation:blender"_L1, + {"http://192.168.123.45:7890/dd.xml"_url, "http://192.168.123.45:7890/icon.png"_url}, + {"blender:ixl"_url, "http://foo/bar"_url}, + now.addSecs(7393)}; + + QTest::newRow("byebye") + << now + << "NOTIFY * HTTP/1.1\r\n" + "Host: 239.255.255.250:1900\r\n" + "NT: blenderassociation:blender\r\n" + "NTS: ssdp:byebye\r\n" + "USN: someunique:idscheme3\r\n" + "\r\n"_ba + << NotifyMessage{ + NotifyMessage::Type::ByeBye, + "someunique:idscheme3"_L1, + "blenderassociation:blender"_L1}; + } + + void testParseNotifyMessage() + { + const QFETCH(QDateTime, now); + const QFETCH(QByteArray, data); + const QFETCH(NotifyMessage, expectedMessage); + + const auto &message = NotifyMessage::parse(data, now); + + QCOMPARE(message.type, expectedMessage.type); + QCOMPARE(message.serviceName, expectedMessage.serviceName); + QCOMPARE(message.serviceType, expectedMessage.serviceType); + QCOMPARE(message.locations, expectedMessage.locations); + QCOMPARE(message.altLocations, expectedMessage.altLocations); + QCOMPARE(message.expiry, expectedMessage.expiry); + } +}; + +} // namespace qnc::ssdp::tests + +QTEST_GUILESS_MAIN(qnc::ssdp::tests::ResolverTest) + +#include "tst_ssdpresolver.moc"