QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
NTRIPHttpCodec.cc
Go to the documentation of this file.
1#include "NTRIPHttpCodec.h"
2
3#include <algorithm>
4#include <utility>
5
6#include <QtCore/QCoreApplication>
7#include <QtCore/QRegularExpression>
8#include <QtCore/QUrl>
9
10#include "NTRIPConfiguration.h"
11#include "QGCNetworkClient.h"
12
13namespace {
14bool isToken(char ch)
15{
16 return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
17 QByteArrayView("!#$%&'*+-.^_`|~").contains(ch);
18}
19
20bool isFieldValue(char ch)
21{
22 const auto byte = static_cast<unsigned char>(ch);
23 return ch == '\t' || (byte >= 32 && byte != 127);
24}
25
27std::optional<quint64> unsignedNumber(QByteArrayView text, int base = 10)
28{
29 const auto digit = [base](char ch) {
30 return (ch >= '0' && ch <= '9') || (base == 16 && ((ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')));
31 };
32 bool ok = false;
33 const quint64 value = text.toULongLong(&ok, base);
34 return ok && std::all_of(text.begin(), text.end(), digit) ? std::optional(value) : std::nullopt;
35}
36
37std::chrono::milliseconds retryAfter(const QHttpHeaders& headers, const QDateTime& utcNow)
38{
39 const QByteArrayView value = headers.value(QHttpHeaders::WellKnownHeader::RetryAfter);
40 if (const auto seconds = unsignedNumber(value)) {
41 const auto maxSeconds = std::chrono::duration_cast<std::chrono::seconds>(NTRIPFailure::MAX_RETRY_AFTER).count();
42 return std::chrono::seconds{(std::min<quint64>) (*seconds, maxSeconds)};
43 }
44 auto deadline = headers.dateTimeValue(QHttpHeaders::WellKnownHeader::RetryAfter);
45 if (!deadline || !utcNow.isValid()) {
46 return {};
47 }
48 // Qt reads the two-digit RFC 850 year as 19yy; RFC 9110 wants the latest century not over 50 years ahead.
49 if (value.indexOf(',') > 3) {
50 *deadline = deadline->addYears(utcNow.date().year() / 100 * 100 - 1900);
51 if (*deadline > utcNow.addYears(50)) {
52 *deadline = deadline->addYears(-100);
53 }
54 }
55 const qint64 maxMs = NTRIPFailure::MAX_RETRY_AFTER.count();
56 return std::chrono::milliseconds{std::clamp(utcNow.msecsTo(*deadline), qint64(0), maxMs)};
57}
58
59QString tr(const char* text)
60{
61 return QCoreApplication::translate("NTRIPHttpTransport", text);
62}
63} // namespace
64
66{
67 NTRIPHttpRequest result;
68 result.error = purpose == NTRIPHttpPurpose::Corrections ? config.streamValidationError() : config.validationError();
69 if (!result.error.isEmpty()) {
70 return result;
71 }
72
73 const QUrl url = config.url(purpose == NTRIPHttpPurpose::Corrections);
74 if (!url.isValid() || url.host().isEmpty()) {
75 result.error = QCoreApplication::translate("NTRIPHttpTransport", "Invalid NTRIP endpoint");
76 return result;
77 }
78
79 // Some legacy casters match these header spellings case-sensitively. The fully encoded URL parts and the
80 // Base64 credentials are always valid field values.
81 result.bytes = "GET " + url.path(QUrl::FullyEncoded).toLatin1() + " HTTP/1.1\r\n" +
82 "Host: " + url.authority(QUrl::FullyEncoded).toLatin1() + "\r\n" +
83 "Ntrip-Version: Ntrip/2.0\r\nUser-Agent: NTRIP " + QGCNetworkHelper::defaultUserAgent().toLatin1() +
84 "\r\n";
85 if (!config.username.isEmpty() || !config.password.isEmpty()) {
86 result.bytes += "Authorization: Basic " +
87 QGCNetworkHelper::createBasicAuthCredentials(config.username, config.password).toLatin1() +
88 "\r\n";
89 }
90 result.bytes += "\r\n";
91 return result;
92}
93
95{
96 *this = NTRIPHttpDecoder{_purpose};
97}
98
100{
101 static const QRegularExpression pattern(
102 QStringLiteral("^(HTTP/1\\.[01]|(?i:ICY|SOURCETABLE)) ([1-5][0-9]{2})(?: ([\\t\\x20-\\x7e\\x80-\\xff]*))?\\z"));
103 const auto match = pattern.match(QString::fromLatin1(line));
104 return match.hasMatch() ? Status{match.captured(2).toInt(), match.captured(3), true} : Status{};
105}
106
107void NTRIPHttpDecoder::_fail(Result& result, const QString& detail, NTRIPError code)
108{
109 if (_pendingFailure) {
110 _finishError(result);
111 return;
112 }
113 _state = State::Failed;
114 _line.clear();
115 result.complete = false;
116 result.failure = NTRIPFailure{code, detail};
117}
118
119void NTRIPHttpDecoder::_finishError(Result& result)
120{
121 static const QRegularExpression htmlTags(QStringLiteral("<[^>]*(?:>|$)"));
122 static const QRegularExpression controls(QStringLiteral("[\\x00-\\x08\\x0e-\\x1f\\x7f]"));
123 QString preview = QString::fromUtf8(_errorBody);
124 preview.remove(htmlTags);
125 preview.remove(controls);
126 preview = preview.simplified().left(MAX_ERROR_PREVIEW_CHARS);
127 result.failure = std::exchange(_pendingFailure, std::nullopt);
128 if (!preview.isEmpty()) {
129 result.failure->detail += QStringLiteral(" \u2014 ") + preview;
130 }
131 _state = State::Failed;
132 _line.clear();
133 _errorBody.clear();
134 result.body.clear();
135 result.connected = false;
136 result.complete = false;
137}
138
139void NTRIPHttpDecoder::_beginIcyBody(Result& result)
140{
141 _beginBody(result);
142 result.body += std::exchange(_line, {});
143}
144
145NTRIPHttpDecoder::Result NTRIPHttpDecoder::feed(QByteArrayView bytes, const QDateTime& utcNow)
146{
147 Result result;
148 while (!bytes.isEmpty() && _state != State::Failed) {
149 if (_state == State::Complete) {
150 _fail(result, tr("Unexpected data after HTTP response"));
151 break;
152 }
153 if (_state == State::IcyHeaders) {
154 const auto first = static_cast<unsigned char>(bytes.front());
155 const bool binary = first >= 127 || (first < 32 && first != '\t' && first != '\r' && first != '\n');
156 // A legacy stream may start mid-frame, not at the RTCM preamble.
157 // Probe optional ASCII headers only until binary or non-header input arrives.
158 if (_headers.isEmpty() &&
159 (binary || (first == '\n' && !_line.endsWith('\r')) || (_line.endsWith('\r') && first != '\n'))) {
160 _beginIcyBody(result);
161 } else if (!_headers.isEmpty() && _line.isEmpty() && binary) {
162 // ICY casters may omit the blank separator even after optional headers.
163 _lineReceived(result, utcNow);
164 continue;
165 }
166 }
167 if (_state == State::Identity || _state == State::ChunkData) {
168 const bool bounded = _state == State::ChunkData || _contentLength.has_value();
169 const auto count =
170 bounded ? static_cast<qsizetype>((std::min<quint64>) (bytes.size(), _remaining)) : bytes.size();
171 if (_pendingFailure) {
172 _errorBody.append(bytes.data(), (std::min) (count, MAX_ERROR_BODY_BYTES - _errorBody.size()));
173 } else {
174 result.body.append(bytes.data(), count);
175 }
176 bytes = bytes.sliced(count);
177 if (bounded && (_remaining -= count) == 0) {
178 _state = _state == State::ChunkData ? State::ChunkEnd : State::Complete;
179 result.complete = _state == State::Complete;
180 }
181 if (_pendingFailure && (_state == State::Complete || _errorBody.size() == MAX_ERROR_BODY_BYTES)) {
182 _finishError(result);
183 }
184 continue;
185 }
186 if (_state == State::ChunkEnd) {
187 if (bytes.front() != (_chunkEndBytes == 0 ? '\r' : '\n')) {
188 _fail(result, tr("Invalid HTTP chunk terminator"));
189 break;
190 }
191 bytes = bytes.sliced(1);
192 if (++_chunkEndBytes == 2) {
193 _chunkEndBytes = 0;
194 _state = State::ChunkSize;
195 }
196 continue;
197 }
198 const char ch = bytes.front();
199 bytes = bytes.sliced(1);
200 if ((ch == '\n' && !_line.endsWith('\r')) || (_line.endsWith('\r') && ch != '\n')) {
201 _fail(result, tr("Invalid HTTP line ending"));
202 break;
203 }
204 _line.append(ch);
205 if ((_state != State::ChunkSize && ++_headerBytes > MAX_HEADER_BYTES) || _line.size() > MAX_LINE_BYTES) {
206 _fail(result, tr("HTTP response header too large"), NTRIPError::HeaderTooLarge);
207 break;
208 }
209 if (ch == '\n') {
210 _line.chop(2);
211 _lineReceived(result, utcNow);
212 _line.clear();
213 }
214 }
215 return result;
216}
217
218void NTRIPHttpDecoder::_lineReceived(Result& result, const QDateTime& utcNow)
219{
220 if (_state == State::Status) {
221 _status = parseStatusLine(_line);
222 if (!_status.valid) {
223 _fail(result, tr("Invalid HTTP status line"));
224 } else if (_line.left(11).compare("SOURCETABLE", Qt::CaseInsensitive) == 0) {
225 if (_purpose == NTRIPHttpPurpose::SourceTable && _status.code == 200) {
226 _state = State::IcyHeaders;
227 result.connected = true;
228 } else {
229 _fail(result, tr("Caster returned a source table; select a valid mountpoint"),
230 NTRIPError::InvalidMountpoint);
231 }
232 } else if (_line.first(3).compare("ICY", Qt::CaseInsensitive) == 0 && _status.code == 200) {
233 _state = State::IcyHeaders;
234 result.connected = true;
235 } else {
236 _http10 = _line.startsWith("HTTP/1.0");
237 _state = State::Headers;
238 }
239 return;
240 }
241 if (_state == State::ChunkSize) {
242 // NTRIP uses no chunk extensions, so everything after ';' (and the whitespace before it) is ignored.
243 QByteArrayView size(_line);
244 if (const qsizetype separator = size.indexOf(';'); separator >= 0) {
245 size = size.first(separator);
246 while (size.endsWith(' ') || size.endsWith('\t')) {
247 size.chop(1);
248 }
249 }
250 const auto length = unsignedNumber(size, 16);
251 if (!length || *length > MAX_CHUNK_BYTES) {
252 _fail(result, tr("Invalid or oversized HTTP chunk"));
253 } else {
254 _remaining = *length;
255 _state = _remaining == 0 ? State::Trailers : State::ChunkData;
256 }
257 return;
258 }
259 if (_line.isEmpty()) {
260 if (_state == State::Trailers) {
261 if (_pendingFailure) {
262 _finishError(result);
263 } else {
264 _state = State::Complete;
265 result.complete = true;
266 }
267 return;
268 }
269 if (_status.code >= 300) {
270 const int status = _status.code;
271 const auto code = status == 401 ? NTRIPError::AuthFailed
272 : status == 400 || status == 403 ? NTRIPError::RequestRejected
273 : NTRIPError::HttpError;
274 const QString detail = status == 401 ? tr("Authentication failed (401): check username and password")
275 : tr("HTTP %1: %2").arg(status).arg(_status.reason);
276 _pendingFailure = NTRIPFailure{code, detail};
277 if (_headers.values(QHttpHeaders::WellKnownHeader::RetryAfter).size() == 1) {
278 _pendingFailure->retryAfter = retryAfter(_headers, utcNow);
279 }
280 if (code == NTRIPError::AuthFailed) {
281 _finishError(result);
282 return;
283 }
284 }
285 const auto lengths = _headers.values(QHttpHeaders::WellKnownHeader::ContentLength);
286 for (const auto& field : lengths) {
287 for (const auto& value : field.split(',')) {
288 const auto length = unsignedNumber(QByteArrayView(value).trimmed());
289 if (!length || (_contentLength && *_contentLength != *length)) {
290 _fail(result, tr("Invalid or conflicting HTTP content length"));
291 return;
292 }
293 _contentLength = *length;
294 }
295 }
296 const auto transfers = _headers.values(QHttpHeaders::WellKnownHeader::TransferEncoding);
297 _chunked = !transfers.isEmpty();
298 if (_chunked && (_http10 || transfers.size() != 1 ||
299 transfers.first().compare("chunked", Qt::CaseInsensitive) != 0 || _contentLength)) {
300 _fail(result, tr("Unsupported or conflicting HTTP transfer encoding"));
301 return;
302 }
303 if (_status.code >= 100 && _status.code < 200) {
304 if (_status.code == 101 || _contentLength || _chunked || ++_informationalResponses > 4) {
305 _fail(result, tr("Unsupported HTTP informational response"));
306 } else {
307 _headers.clear();
308 _state = State::Status;
309 }
310 return;
311 }
312 const auto encodings = _headers.values(QHttpHeaders::WellKnownHeader::ContentEncoding);
313 if (!encodings.isEmpty() &&
314 (encodings.size() != 1 || encodings.first().compare("identity", Qt::CaseInsensitive) != 0)) {
315 _fail(result, tr("Unsupported HTTP content encoding"));
316 return;
317 }
318 if (_pendingFailure) {
319 _beginBody(result);
320 return;
321 }
322 for (const auto& value : _headers.values(QHttpHeaders::WellKnownHeader::ContentType)) {
323 if (_purpose == NTRIPHttpPurpose::Corrections &&
324 value.split(';').first().trimmed().compare("gnss/sourcetable", Qt::CaseInsensitive) == 0) {
325 _fail(result, tr("Caster returned a source table; select a valid mountpoint"),
326 NTRIPError::InvalidMountpoint);
327 return;
328 }
329 }
330 if (_status.code == 204 || _status.code == 205) {
331 if (_chunked || (_contentLength && *_contentLength != 0)) {
332 _fail(result, tr("Unexpected framing for an empty HTTP response"));
333 return;
334 }
335 _contentLength = 0;
336 }
337 result.connected = result.connected || _state != State::IcyHeaders;
338 _beginBody(result);
339 return;
340 }
341
342 const qsizetype colon = _line.indexOf(':');
343 const QByteArrayView line(_line);
344 if (colon <= 0 || !std::all_of(line.begin(), line.begin() + colon, isToken) ||
345 !std::all_of(line.begin() + colon + 1, line.end(), isFieldValue)) {
346 if (_state == State::IcyHeaders && _headers.isEmpty()) {
347 _line += "\r\n";
348 _beginIcyBody(result);
349 return;
350 }
351 _fail(result, tr("Invalid HTTP response header"));
352 return;
353 }
354 if (_headers.size() >= 128) {
355 _fail(result, tr("Too many HTTP response headers"), NTRIPError::HeaderTooLarge);
356 return;
357 }
358 const auto name = line.first(colon);
359 const auto value = line.sliced(colon + 1);
360 if (_state == State::Trailers &&
361 (name.compare("content-length", Qt::CaseInsensitive) == 0 ||
362 name.compare("transfer-encoding", Qt::CaseInsensitive) == 0 ||
363 name.compare("content-encoding", Qt::CaseInsensitive) == 0 ||
364 name.compare("content-type", Qt::CaseInsensitive) == 0 ||
365 name.compare("retry-after", Qt::CaseInsensitive) == 0 || name.compare("host", Qt::CaseInsensitive) == 0)) {
366 _fail(result, tr("Forbidden HTTP trailer"));
367 return;
368 }
369 // Validated above, so append() cannot reject the field.
370 (void) _headers.append(QLatin1StringView(name.data(), name.size()), QLatin1StringView(value.data(), value.size()));
371}
372
373void NTRIPHttpDecoder::_beginBody(Result& result)
374{
375 _remaining = _contentLength.value_or(0);
376 _state = _chunked ? State::ChunkSize : State::Identity;
377 if (!_chunked && _contentLength && _remaining == 0) {
378 if (_pendingFailure) {
379 _finishError(result);
380 } else {
381 _state = State::Complete;
382 result.complete = true;
383 }
384 }
385}
386
388{
389 Result result;
390 if (_pendingFailure) {
391 _finishError(result);
392 } else if ((_state == State::Identity && !_contentLength) ||
393 (_state == State::IcyHeaders && _line.isEmpty() && _headers.isEmpty()) || _state == State::Complete) {
394 _state = State::Complete;
395 result.complete = true;
396 } else if (_state == State::Identity || _state == State::ChunkSize || _state == State::ChunkData ||
397 _state == State::ChunkEnd || _state == State::Trailers) {
398 _fail(result, tr("Caster closed the connection mid-transfer"));
399 } else if (_state != State::Failed) {
400 _fail(result, tr("Caster disconnected before completing the HTTP response"));
401 }
402 return result;
403}
Config config
NTRIPHttpPurpose
Private transport parser; buffers framing, never correction payloads.
static constexpr quint64 MAX_CHUNK_BYTES
static constexpr qsizetype MAX_ERROR_PREVIEW_CHARS
Result feed(QByteArrayView bytes, const QDateTime &utcNow)
static constexpr qsizetype MAX_ERROR_BODY_BYTES
static constexpr qsizetype MAX_LINE_BYTES
static constexpr qsizetype MAX_HEADER_BYTES
static Status parseStatusLine(QByteArrayView line)
QByteArray line(std::string_view command)
command as one CR/LF-terminated line.
QString defaultUserAgent()
"<application>/<version> (Qt <version>)".
QString createBasicAuthCredentials(const QString &username, const QString &password)
Base64-encoded UTF-8 username:password, without the authentication scheme.
GPSCommandSequence base(QByteArrayView port, const GPSBaseStationConfig &base)
Streams RTCM3 corrections and the PVTGeodetic survey status over port in the mode base selects.
Definition SBFPlan.cc:49
static constexpr std::chrono::milliseconds MAX_RETRY_AFTER
Longest caster Retry-After hint that may delay a reconnect.
Definition NTRIPError.h:37
QByteArray body
Retains valid prefixes when subsequent framing fails.
static NTRIPHttpRequest build(const NTRIPConnectionConfig &config, NTRIPHttpPurpose purpose=NTRIPHttpPurpose::Corrections)