QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
NTRIPHttpTransport.cc
Go to the documentation of this file.
2
3#include <QtCore/QDateTime>
4#include <QtCore/QRegularExpression>
5#include <QtNetwork/QSslError>
6#include <QtNetwork/QSslSocket>
7#include <chrono>
8
9#include "NMEAUtils.h"
10#include "NTRIPError.h"
12#include "QGCLoggingCategory.h"
13#include "QGCNetworkHelper.h"
14
15QGC_LOGGING_CATEGORY(NTRIPHttpTransportLog, "GPS.NTRIPHttpTransport")
16
18 : NTRIPTransport(parent), _config(config), _connectTimeoutTimer(this), _dataWatchdogTimer(this)
19{
20 const QVector<int> whitelist = NTRIPTransportConfig::parseWhitelist(_config.whitelist);
21 _rtcmParser.setWhitelist(whitelist);
22 qCDebug(NTRIPHttpTransportLog) << "RTCM message filter:" << whitelist;
23 if (whitelist.empty()) {
24 qCDebug(NTRIPHttpTransportLog) << "Message filter empty; all RTCM message IDs will be forwarded.";
25 }
26
27 _connectTimeoutTimer.setSingleShot(true);
28 _connectTimeoutTimer.setInterval(kConnectTimeout);
29 _connectTimeoutTimer.callOnTimeout(this, [this]() {
30 qCWarning(NTRIPHttpTransportLog) << "Connection timeout";
31 emit error(NTRIPError::ConnectionTimeout, QStringLiteral("Connection timeout"));
32 });
33
34 _dataWatchdogTimer.setSingleShot(true);
35 _dataWatchdogTimer.setInterval(kDataWatchdog);
36 _dataWatchdogTimer.callOnTimeout(this, [this]() {
37 const auto secs = std::chrono::duration_cast<std::chrono::seconds>(kDataWatchdog).count();
38 qCWarning(NTRIPHttpTransportLog) << "No data received for" << secs << "seconds";
39 emit error(NTRIPError::DataWatchdog, tr("No data received for %1 seconds").arg(secs));
40 });
41}
42
47
49{
50 _stopped = false;
51 _connect();
52}
53
55{
56 _stopped = true;
57 _connectTimeoutTimer.stop();
58 _dataWatchdogTimer.stop();
59
60 if (_socket) {
61 _socket->disconnect(this);
62 _socket->disconnectFromHost();
63 _socket->close();
64 _socket->deleteLater();
65 _socket = nullptr;
66 }
67
68 emit finished();
69}
70
72{
73 HttpRequest result;
74 QByteArray& req = result.bytes;
75 req += "GET /" + config.mountpoint.toUtf8() + " HTTP/1.1\r\n";
76 req += "Host: " + config.host.toUtf8() + "\r\n";
77 req += "Ntrip-Version: Ntrip/2.0\r\n";
78 req += "User-Agent: NTRIP QGroundControl/1.0\r\n";
79
80 if (!config.username.isEmpty() || !config.password.isEmpty()) {
82 const QByteArray authB64 =
84 req += "Authorization: Basic " + authB64 + "\r\n";
85 }
86
87 req += "\r\n";
88 return result;
89}
90
91void NTRIPHttpTransport::_sendHttpRequest()
92{
93 if (!_socket || _stopped) {
94 return;
95 }
96
97 // Host/mountpoint/username are validated up front in NTRIPTransportConfig::validationError().
98 if (!_config.mountpoint.isEmpty()) {
99 qCDebug(NTRIPHttpTransportLog) << "Sending HTTP request";
100 const HttpRequest request = buildHttpRequest(_config);
101
102 if (request.credentialsInClear) {
103 // Basic auth over plaintext HTTP. We warn (log + signal, surfaced
104 // by the UI) but proceed: some operators tunnel NTRIP over an
105 // already-encrypted link (VPN/SSH) where the wire looks plain.
106 // A hard refusal here misleadingly pretended there was a settings
107 // toggle to relax it; there wasn't. Let the operator see the
108 // warning and decide.
109 qCWarning(NTRIPHttpTransportLog) << "Sending credentials without TLS — data is not encrypted";
111 }
112
113 _socket->write(request.bytes);
114
115 qCDebug(NTRIPHttpTransportLog) << "HTTP request sent for mount:" << _config.mountpoint;
116 } else {
117 qCWarning(NTRIPHttpTransportLog) << "No mountpoint configured, connecting without RTCM stream request";
118 _httpHandshakeDone = true;
119 emit connected();
120 }
121
122 qCDebug(NTRIPHttpTransportLog) << "Socket connected"
123 << "local" << _socket->localAddress().toString() << ":" << _socket->localPort()
124 << "-> peer" << _socket->peerAddress().toString() << ":" << _socket->peerPort();
125}
126
127void NTRIPHttpTransport::_failFatal(NTRIPError code, const QString& msg, QAbstractSocket* socket)
128{
129 // Stop before abort(): abort() can drive errorOccurred/disconnected, whose
130 // handlers would otherwise emit a second error() after teardown.
131 _stopped = true;
132 _connectTimeoutTimer.stop();
133 _dataWatchdogTimer.stop();
134 emit error(code, msg);
135 socket->abort();
136}
137
138void NTRIPHttpTransport::_connect()
139{
140 if (_stopped) {
141 return;
142 }
143
144 if (_socket) {
145 qCWarning(NTRIPHttpTransportLog) << "Socket already exists, aborting connect";
146 return;
147 }
148
149 qCDebug(NTRIPHttpTransportLog) << "connectToHost" << _config.host << ":" << _config.port
150 << " mount=" << _config.mountpoint;
151
152 _httpHandshakeDone = false;
153 _httpResponseBuf.clear();
154 _rtcmParser.reset();
155
156 if (_config.useTls) {
157 QSslSocket* sslSocket = new QSslSocket(this);
158 _socket = sslSocket;
159 connect(sslSocket, &QSslSocket::sslErrors, this, [this, sslSocket](const QList<QSslError>& errors) {
160 QStringList msgs;
161 QList<QSslError> ignorable;
162 bool fatal = false;
163 for (const QSslError& e : errors) {
164 qCWarning(NTRIPHttpTransportLog) << "TLS error:" << e.errorString();
165 msgs.append(e.errorString());
166 if (e.error() == QSslError::SelfSignedCertificate ||
167 e.error() == QSslError::SelfSignedCertificateInChain) {
168 ignorable.append(e);
169 } else {
170 fatal = true;
171 }
172 }
173 if (fatal) {
174 _failFatal(NTRIPError::SslError, msgs.join(QStringLiteral("; ")), sslSocket);
175 } else if (_config.allowSelfSignedCerts) {
176 qCWarning(NTRIPHttpTransportLog) << "Accepting self-signed certificate (user opted in)";
177 // Only ignore the specific self-signed errors; all other SSL errors remain fatal.
178 sslSocket->ignoreSslErrors(ignorable);
179 } else {
180 qCWarning(NTRIPHttpTransportLog)
181 << "Rejecting self-signed certificate (enable 'Accept self-signed certificates' to allow)";
182 _failFatal(NTRIPError::SslError,
183 tr("Self-signed certificate rejected. Enable 'Accept self-signed "
184 "certificates' in NTRIP settings to allow."),
185 sslSocket);
186 }
187 });
188 } else {
189 _socket = new QTcpSocket(this);
190 }
191 _socket->setSocketOption(QAbstractSocket::KeepAliveOption, 1);
192 _socket->setSocketOption(QAbstractSocket::LowDelayOption, 1);
193 _socket->setReadBufferSize(0);
194
195 connect(_socket, &QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError code) {
196 if (_stopped || !_socket) {
197 return;
198 }
199 _connectTimeoutTimer.stop();
200
201 QString msg = _socket->errorString();
202 if (code == QAbstractSocket::RemoteHostClosedError && !_httpHandshakeDone) {
203 if (!_config.mountpoint.isEmpty()) {
204 msg += " (peer closed before HTTP response; check mountpoint and credentials)";
205 }
206 }
207
208 qCWarning(NTRIPHttpTransportLog) << "Socket error code:" << int(code) << " msg:" << msg;
209 emit error(NTRIPError::SocketError, msg);
210 });
211
212 connect(_socket, &QTcpSocket::disconnected, this,
213 [this]() {
214 if (_stopped || !_socket) {
215 return;
216 }
217 _connectTimeoutTimer.stop();
218
219 const QByteArray trailing = _socket->readAll();
220 QString reason;
221 if (!trailing.isEmpty()) {
222 reason = QString::fromUtf8(trailing).trimmed();
223 } else {
224 reason = QStringLiteral("Server disconnected");
225 }
226
227 qCWarning(NTRIPHttpTransportLog)
228 << "Disconnected:"
229 << "reason=" << reason << "ms_since_200="
230 << (_postOkTimestampMs > 0 ? QDateTime::currentMSecsSinceEpoch() - _postOkTimestampMs : -1);
231 emit error(NTRIPError::ServerDisconnected, reason);
232 });
233
234 connect(_socket, &QTcpSocket::readyRead, this, &NTRIPHttpTransport::_readBytes);
235
236 if (_config.useTls) {
237 QSslSocket* sslSocket = qobject_cast<QSslSocket*>(_socket);
238 connect(sslSocket, &QSslSocket::encrypted, this, [this]() {
239 _connectTimeoutTimer.stop();
240 _sendHttpRequest();
241 });
242 sslSocket->connectToHostEncrypted(_config.host, static_cast<quint16>(_config.port));
243 } else {
244 connect(_socket, &QTcpSocket::connected, this, [this]() {
245 _connectTimeoutTimer.stop();
246 _sendHttpRequest();
247 });
248 _socket->connectToHost(_config.host, static_cast<quint16>(_config.port));
249 }
250 _connectTimeoutTimer.start();
251}
252
253void NTRIPHttpTransport::_parseRtcm(const QByteArray& buffer)
254{
255 if (_stopped) {
256 return;
257 }
258
259 for (char ch : buffer) {
260 const uint8_t byte = static_cast<uint8_t>(static_cast<unsigned char>(ch));
261
262 if (!_rtcmParser.addByte(byte)) {
263 continue;
264 }
265
266 if (!_rtcmParser.validateCrc()) {
267 qCWarning(NTRIPHttpTransportLog) << "RTCM CRC mismatch, dropping message id" << _rtcmParser.messageId();
268 _rtcmParser.reset();
269 continue;
270 }
271
272 const QByteArray message = _rtcmParser.currentFrame();
273 const uint16_t id = _rtcmParser.messageId();
274
275 if (_rtcmParser.isWhitelisted(id)) {
276 qCDebug(NTRIPHttpTransportLog) << "RTCM packet id" << id << "len" << message.length();
277 emit RTCMDataUpdate(message, id);
278 } else {
279 qCDebug(NTRIPHttpTransportLog) << "Ignoring RTCM" << id;
280 }
281
282 _rtcmParser.reset();
283 }
284}
285
286void NTRIPHttpTransport::_readBytes()
287{
288 if (_stopped || !_socket) {
289 return;
290 }
291
292 if (!_httpHandshakeDone) {
293 _handleHttpResponse();
294 // The header read is bounded by kMaxHttpHeaderSize, so the handshake can
295 // complete with RTCM bytes still pending in the socket. Drain them now
296 // instead of stalling until the next readyRead.
297 if (!_stopped && _httpHandshakeDone && _socket && (_socket->bytesAvailable() > 0)) {
298 _handleRtcmData();
299 }
300 } else {
301 _handleRtcmData();
302 }
303}
304
305void NTRIPHttpTransport::_handleHttpResponse()
306{
307 // Bound reads so a single chunk can't overshoot kMaxHttpHeaderSize.
308 const qint64 budget = static_cast<qint64>(kMaxHttpHeaderSize) - _httpResponseBuf.size();
309 if (budget <= 0) {
310 qCWarning(NTRIPHttpTransportLog) << "HTTP response header too large, dropping";
311 _httpResponseBuf.clear();
312 emit error(NTRIPError::HeaderTooLarge, tr("HTTP response header too large"));
313 return;
314 }
315 _httpResponseBuf.append(_socket->read(budget));
316 if (_httpResponseBuf.isEmpty()) {
317 return;
318 }
319
320 // NTRIP v1 casters may reply with a bare "ICY 200 OK\r\n" (no header block
321 // and no blank-line terminator) before immediately streaming RTCM. Detect
322 // that pattern and complete the handshake without waiting for \r\n\r\n,
323 // otherwise we deadlock waiting for a terminator that never arrives.
324 int hdrEnd = _httpResponseBuf.indexOf("\r\n\r\n");
325 if (hdrEnd < 0) {
326 const int firstLineEnd = _httpResponseBuf.indexOf("\r\n");
327 if (firstLineEnd > 0) {
328 const QString firstLine = QString::fromUtf8(_httpResponseBuf.left(firstLineEnd));
329 if (firstLine.startsWith(QStringLiteral("ICY "), Qt::CaseInsensitive)) {
330 const HttpStatus icyStatus = parseHttpStatusLine(firstLine);
331 if (icyStatus.valid && isHttpSuccess(icyStatus.code)) {
332 qCDebug(NTRIPHttpTransportLog) << "NTRIP v1 ICY response:" << firstLine;
333 _postOkTimestampMs = QDateTime::currentMSecsSinceEpoch();
334 _httpHandshakeDone = true;
335 emit connected();
336 _dataWatchdogTimer.start();
337
338 const QByteArray remainingData = _httpResponseBuf.mid(firstLineEnd + 2);
339 _httpResponseBuf.clear();
340 if (!remainingData.isEmpty()) {
341 _parseRtcm(remainingData);
342 }
343 return;
344 }
345 }
346 }
347 if (_httpResponseBuf.size() >= kMaxHttpHeaderSize) {
348 qCWarning(NTRIPHttpTransportLog) << "HTTP response header too large, dropping";
349 _httpResponseBuf.clear();
350 emit error(NTRIPError::HeaderTooLarge, tr("HTTP response header too large"));
351 }
352 return;
353 }
354
355 const QString header = QString::fromUtf8(_httpResponseBuf.left(hdrEnd));
356 qCDebug(NTRIPHttpTransportLog) << "HTTP response received:" << header.left(200);
357
358 const QStringList lines = header.split('\n');
359 for (const QString& line : lines) {
360 const HttpStatus status = parseHttpStatusLine(line);
361 if (!status.valid) {
362 continue;
363 }
364
365 if (isHttpSuccess(status.code)) {
366 qCDebug(NTRIPHttpTransportLog) << "HTTP" << status.code << status.reason;
367 _postOkTimestampMs = QDateTime::currentMSecsSinceEpoch();
368 _httpHandshakeDone = true;
369
370 qCDebug(NTRIPHttpTransportLog) << "HTTP handshake complete";
371 emit connected();
372
373 _dataWatchdogTimer.start();
374
375 const QByteArray remainingData = _httpResponseBuf.mid(hdrEnd + 4);
376 _httpResponseBuf.clear();
377
378 if (!remainingData.isEmpty()) {
379 qCDebug(NTRIPHttpTransportLog) << "Processing trailing data:" << remainingData.size() << "bytes";
380 _parseRtcm(remainingData);
381 }
382 return;
383 }
384
385 const QString body = QString::fromUtf8(_httpResponseBuf.mid(hdrEnd + 4)).trimmed();
386 _httpResponseBuf.clear();
387
388 if (status.code == 401) {
389 qCWarning(NTRIPHttpTransportLog) << "Authentication failed:" << status.reason;
390 emit error(NTRIPError::AuthFailed, tr("Authentication failed (401): check username and password"));
391 return;
392 }
393
394 qCWarning(NTRIPHttpTransportLog) << "HTTP error" << status.code << status.reason << "body:" << body.left(200);
395 QString msg = status.reason.isEmpty() ? tr("HTTP %1").arg(status.code)
396 : tr("HTTP %1: %2").arg(status.code).arg(status.reason);
397 if (!body.isEmpty()) {
398 QString cleanBody = body.left(500);
399 static const QRegularExpression htmlTags(QStringLiteral("<[^>]*>"));
400 cleanBody.remove(htmlTags);
401 cleanBody = cleanBody.simplified().left(200);
402 if (!cleanBody.isEmpty()) {
403 msg += QStringLiteral(" — ") + cleanBody;
404 }
405 }
406 emit error(NTRIPError::HttpError, msg);
407 return;
408 }
409
410 qCWarning(NTRIPHttpTransportLog) << "No HTTP status line found in response. First line:"
411 << (lines.isEmpty() ? QStringLiteral("(empty)") : lines.first().left(120));
412 _httpResponseBuf.clear();
413 emit error(NTRIPError::InvalidHttpResponse, tr("Invalid HTTP response from caster"));
414}
415
416void NTRIPHttpTransport::_handleRtcmData()
417{
418 const QByteArray bytes = _socket->readAll();
419 if (!bytes.isEmpty()) {
420 _dataWatchdogTimer.start();
421 qCDebug(NTRIPHttpTransportLog) << "rx bytes:" << bytes.size();
422 _parseRtcm(bytes);
423 }
424}
425
426void NTRIPHttpTransport::sendNMEA(const QByteArray& nmea)
427{
428 if (_stopped) {
429 return;
430 }
431 if (!_socket || _socket->state() != QAbstractSocket::ConnectedState) {
432 return;
433 }
434
435 const QByteArray line = NMEAUtils::repairChecksum(nmea);
436 qCDebug(NTRIPHttpTransportLog) << "Sent NMEA:" << QString::fromUtf8(line.trimmed());
437 _socket->write(line);
438}
439
441{
442 static const QRegularExpression re(QStringLiteral("^\\S+\\s+(\\d{3})(?:\\s+(.*))?$"));
443 const QRegularExpressionMatch match = re.match(line.trimmed());
444
445 if (!match.hasMatch()) {
446 return HttpStatus{0, {}, false};
447 }
448
449 return HttpStatus{match.captured(1).toInt(), match.captured(2).trimmed(), true};
450}
Config config
Error error
#define QGC_LOGGING_CATEGORY(name, categoryStr)
static HttpStatus parseHttpStatusLine(const QString &line)
void sendNMEA(const QByteArray &nmea) override
static HttpRequest buildHttpRequest(const NTRIPTransportConfig &config)
const NTRIPTransportConfig & config() const
static bool isHttpSuccess(int code)
static constexpr int kMaxHttpHeaderSize
void plaintextCredentialsWarning()
void RTCMDataUpdate(const QByteArray &message, int messageId)
void reset()
Definition RTCMParser.cc:8
bool validateCrc() const
Definition RTCMParser.cc:91
QByteArray currentFrame() const
bool isWhitelisted(uint16_t id) const
Definition RTCMParser.h:19
uint16_t messageId() const
Definition RTCMParser.cc:67
bool addByte(uint8_t byte)
Definition RTCMParser.cc:17
QByteArray repairChecksum(const QByteArray &sentence)
Repair or append a valid NMEA checksum and ensure CRLF termination.
Definition NMEAUtils.cc:31
QString createBasicAuthCredentials(const QString &username, const QString &password)
bool credentialsInClear
Credentials are present and the channel is not TLS — caller must warn.
static QVector< int > parseWhitelist(const QString &csv)