• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

traintastic / traintastic / 21101426787

17 Jan 2026 09:52PM UTC coverage: 28.005% (+0.01%) from 27.995%
21101426787

push

github

reinder
[traintastic] fixed restart crash (out of memory)

12 of 38 new or added lines in 11 files covered. (31.58%)

5 existing lines in 2 files now uncovered.

8165 of 29156 relevant lines covered (28.0%)

193.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/server/src/network/server.cpp
1
/**
2
 * This file is part of Traintastic,
3
 * see <https://github.com/traintastic/traintastic>.
4
 *
5
 * Copyright (C) 2022-2026 Reinder Feenstra
6
 *
7
 * This program is free software; you can redistribute it and/or
8
 * modify it under the terms of the GNU General Public License
9
 * as published by the Free Software Foundation; either version 2
10
 * of the License, or (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License
18
 * along with this program; if not, write to the Free Software
19
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
20
 */
21

22
#include "server.hpp"
23
#include <boost/beast/http/buffer_body.hpp>
24
#include <boost/beast/http/file_body.hpp>
25
#include <boost/url/url_view.hpp>
26
#include <boost/url/parse.hpp>
27
#include <span>
28
#include <traintastic/network/message.hpp>
29
#include <traintastic/utils/standardpaths.hpp>
30
#include <version.hpp>
31
#include "clientconnection.hpp"
32
#include "httpconnection.hpp"
33
#include "webthrottleconnection.hpp"
34
#include "../core/eventloop.hpp"
35
#include "../log/log.hpp"
36
#include "../log/logmessageexception.hpp"
37
#include "../utils/endswith.hpp"
38
#include "../utils/setthreadname.hpp"
39
#include "../utils/startswith.hpp"
40
#include "../utils/stripprefix.hpp"
41

42
//#define SERVE_FROM_FS // Development option, NOT for production!
43
#ifdef SERVE_FROM_FS
44
  #include "../utils/readfile.hpp"
45

46
  static const auto www = std::filesystem::absolute(std::filesystem::path(__FILE__).parent_path() / ".." / ".." / "www");
47
#else
48
  #include <resource/www/throttle.html.hpp>
49
  #include <resource/www/css/throttle.css.hpp>
50
  #include <resource/www/js/throttle.js.hpp>
51
#endif
52
#include <resource/www/css/normalize.css.hpp>
53
#include <resource/shared/gfx/appicon.ico.hpp>
54

55
#define IS_SERVER_THREAD (std::this_thread::get_id() == m_threadId)
56

57
namespace beast = boost::beast;
58
namespace http = beast::http;
59

60
namespace
61
{
62

63
static constexpr std::string_view serverHeader{"Traintastic-server/" TRAINTASTIC_VERSION_FULL};
64
static constexpr std::string_view contentTypeTextPlain{"text/plain"};
65
static constexpr std::string_view contentTypeTextHtml{"text/html"};
66
static constexpr std::string_view contentTypeTextCss{"text/css"};
67
static constexpr std::string_view contentTypeTextJavaScript{"text/javascript"};
68
static constexpr std::string_view contentTypeImageXIcon{"image/x-icon"};
69
static constexpr std::string_view contentTypeImagePng{"image/png"};
70
static constexpr std::string_view contentTypeApplicationGzip{"application/gzip"};
71
static constexpr std::string_view contentTypeApplicationJson{"application/json"};
72
static constexpr std::string_view contentTypeApplicationXml{"application/xml"};
73

74
static constexpr std::array<std::string_view, 7> manualAllowedExtensions{{
75
  ".html",
76
  ".css",
77
  ".js",
78
  ".json",
79
  ".png",
80
  ".xml",
81
  ".xml.gz",
82
}};
83

84
std::string_view getContentType(std::string_view filename)
×
85
{
86
  if(endsWith(filename, ".html"))
×
87
  {
88
    return contentTypeTextHtml;
×
89
  }
90
  else if(endsWith(filename, ".png"))
×
91
  {
92
    return contentTypeImagePng;
×
93
  }
94
  else if(endsWith(filename, ".css"))
×
95
  {
96
    return contentTypeTextCss;
×
97
  }
98
  else if(endsWith(filename, ".js"))
×
99
  {
100
    return contentTypeTextJavaScript;
×
101
  }
102
  else if(endsWith(filename, ".json"))
×
103
  {
104
    return contentTypeApplicationJson;
×
105
  }
106
  else if(endsWith(filename, ".xml"))
×
107
  {
108
    return contentTypeApplicationXml;
×
109
  }
110
  else if(endsWith(filename, ".gz"))
×
111
  {
112
    return contentTypeApplicationGzip;
×
113
  }
114
  return {};
×
115
}
116

117
http::message_generator notFound(const http::request<http::string_body>& request)
×
118
{
119
  http::response<http::string_body> response{http::status::not_found, request.version()};
×
120
  response.set(http::field::server, serverHeader);
×
121
  response.set(http::field::content_type, contentTypeTextPlain);
×
122
  response.keep_alive(request.keep_alive());
×
123
  response.body() = "404 Not Found";
×
124
  response.prepare_payload();
×
125
  return response;
×
126
}
×
127

128
http::message_generator methodNotAllowed(const http::request<http::string_body>& request, std::initializer_list<http::verb> allowedMethods)
×
129
{
130
  std::string allow;
×
131
  for(auto method : allowedMethods)
×
132
  {
133
    allow.append(http::to_string(method)).append(" ");
×
134
  }
135
  http::response<http::string_body> response{http::status::method_not_allowed, request.version()};
×
136
  response.set(http::field::server, serverHeader);
×
137
  response.set(http::field::content_type, contentTypeTextPlain);
×
138
  response.set(http::field::allow, allow);
×
139
  response.keep_alive(request.keep_alive());
×
140
  response.body() = "405 Method Not Allowed";
×
141
  response.prepare_payload();
×
142
  return response;
×
143
}
×
144

145
http::message_generator binary(const http::request<http::string_body>& request, std::string_view contentType, std::span<const std::byte> body)
×
146
{
147
  if(request.method() != http::verb::get && request.method() != http::verb::head)
×
148
  {
149
    return methodNotAllowed(request, {http::verb::get, http::verb::head});
×
150
  }
151
  http::response<http::buffer_body> response{http::status::ok, request.version()};
×
152
  response.set(http::field::server, serverHeader);
×
153
  response.set(http::field::content_type, contentType);
×
154
  response.keep_alive(request.keep_alive());
×
155
  if(request.method() == http::verb::head)
×
156
  {
157
    response.content_length(body.size());
×
158
  }
159
  else
160
  {
161
    response.body().data = const_cast<std::byte*>(body.data());
×
162
    response.body().size = body.size();
×
163
  }
164
  response.body().more = false;
×
165
  response.prepare_payload();
×
166
  return response;
×
167
}
×
168

169
http::message_generator text(const http::request<http::string_body>& request, std::string_view contentType, std::string_view body)
×
170
{
171
  if(request.method() != http::verb::get && request.method() != http::verb::head)
×
172
  {
173
    return methodNotAllowed(request, {http::verb::get, http::verb::head});
×
174
  }
175
  http::response<http::string_body> response{http::status::ok, request.version()};
×
176
  response.set(http::field::server, serverHeader);
×
177
  response.set(http::field::content_type, contentType);
×
178
  response.keep_alive(request.keep_alive());
×
179
  if(request.method() == http::verb::head)
×
180
  {
181
    response.content_length(body.size());
×
182
  }
183
  else
184
  {
185
    response.body() = body;
×
186
  }
187
  response.prepare_payload();
×
188
  return response;
×
189
}
×
190

191
http::message_generator textPlain(const http::request<http::string_body>& request, std::string_view body)
×
192
{
193
  return text(request, contentTypeTextPlain, body);
×
194
}
195

196
http::message_generator textHtml(const http::request<http::string_body>& request, std::string_view body)
×
197
{
198
  return text(request, contentTypeTextHtml, body);
×
199
}
200

201
http::message_generator textCss(const http::request<http::string_body>& request, std::string_view body)
×
202
{
203
  return text(request, contentTypeTextCss, body);
×
204
}
205

206
http::message_generator textJavaScript(const http::request<http::string_body>& request, std::string_view body)
×
207
{
208
  return text(request, contentTypeTextJavaScript, body);
×
209
}
210

211
http::message_generator serveFileFromFileSystem(const http::request<http::string_body>& request, std::string_view target, const std::filesystem::path& root, std::span<const std::string_view> allowedExtensions)
×
212
{
213
  if(request.method() != http::verb::get && request.method() != http::verb::head)
×
214
  {
215
    return methodNotAllowed(request, {http::verb::get, http::verb::head});
×
216
  }
217

218
  if(const auto url = boost::urls::parse_origin_form(target))
×
219
  {
220
    const std::filesystem::path path = std::filesystem::weakly_canonical(root / url->path().substr(1));
×
221

222
    if(std::mismatch(path.begin(), path.end(), root.begin(), root.end()).second == root.end() && std::filesystem::exists(path))
×
223
    {
224
      const auto filename = path.string();
×
225

226
      if(endsWith(filename, allowedExtensions))
×
227
      {
228
        http::file_body::value_type file;
×
229
        boost::system::error_code ec;
×
230

231
        file.open(filename.c_str(), boost::beast::file_mode::scan, ec);
×
232
        if(!ec)
×
233
        {
234
          http::response<http::file_body> response{
235
            std::piecewise_construct,
236
            std::make_tuple(std::move(file)),
×
237
            std::make_tuple(http::status::ok, request.version())};
×
238

239
          response.set(http::field::server, serverHeader);
×
240
          response.set(http::field::content_type, getContentType(filename));
×
241
          response.content_length(file.size());
×
242
          response.keep_alive(request.keep_alive());
×
243

244
          if(request.method() == http::verb::head)
×
245
          {
246
            response.body().close(); // don’t send file contents
×
247
          }
248

249
          return response;
×
250
        }
×
251
      }
×
252
    }
×
253
  }
×
254

255
  return notFound(request);
×
256
}
257

258
}
259

260
Server::Server(bool localhostOnly, uint16_t port, bool discoverable)
×
261
  : m_ioContext{1}
×
262
  , m_acceptor{m_ioContext}
×
263
  , m_socketUDP{m_ioContext}
×
264
  , m_localhostOnly{localhostOnly}
×
265
  , m_manualPath{getManualPath()}
×
266
{
267
  assert(isEventLoopThread());
×
268

269
  boost::system::error_code ec;
×
270
  boost::asio::ip::tcp::endpoint endpoint(localhostOnly ? boost::asio::ip::address_v4::loopback() : boost::asio::ip::address_v4::any(), port);
×
271

272
  m_acceptor.open(endpoint.protocol(), ec);
×
273
  if(ec)
×
274
    throw LogMessageException(LogMessage::F1001_OPENING_TCP_SOCKET_FAILED_X, ec);
×
275

276
  m_acceptor.set_option(boost::asio::socket_base::reuse_address(true), ec);
×
277
  if(ec)
×
278
    throw LogMessageException(LogMessage::F1002_TCP_SOCKET_ADDRESS_REUSE_FAILED_X, ec);
×
279

280
  m_acceptor.bind(endpoint, ec);
×
281
  if(ec)
×
282
    throw LogMessageException(LogMessage::F1003_BINDING_TCP_SOCKET_FAILED_X, ec);
×
283

284
  m_acceptor.listen(5, ec);
×
285
  if(ec)
×
286
    throw LogMessageException(LogMessage::F1004_TCP_SOCKET_LISTEN_FAILED_X, ec);
×
287

288
  if(discoverable)
×
289
  {
290
    if(port == defaultPort)
×
291
    {
292
      m_socketUDP.open(boost::asio::ip::udp::v4(), ec);
×
293
      if(ec)
×
294
        throw LogMessageException(LogMessage::F1005_OPENING_UDP_SOCKET_FAILED_X, ec);
×
295

296
      m_socketUDP.set_option(boost::asio::socket_base::reuse_address(true), ec);
×
297
      if(ec)
×
298
        throw LogMessageException(LogMessage::F1006_UDP_SOCKET_ADDRESS_REUSE_FAILED_X, ec);
×
299

300
      m_socketUDP.bind(boost::asio::ip::udp::endpoint(boost::asio::ip::address_v4::any(), defaultPort), ec);
×
301
      if(ec)
×
302
        throw LogMessageException(LogMessage::F1007_BINDING_UDP_SOCKET_FAILED_X, ec);
×
303

304
      Log::log(id, LogMessage::N1005_DISCOVERY_ENABLED);
×
305
    }
306
    else
307
    {
308
      Log::log(id, LogMessage::W1001_DISCOVERY_DISABLED_ONLY_ALLOWED_ON_PORT_X, defaultPort);
×
309
      discoverable = false;
×
310
    }
311
  }
312
  else
313
    Log::log(id, LogMessage::N1006_DISCOVERY_DISABLED);
×
314

315
  Log::log(id, LogMessage::N1007_LISTENING_AT_X_X, m_acceptor.local_endpoint().address().to_string(), m_acceptor.local_endpoint().port());
×
316

317
  m_ioContext.post(
×
318
    [this, discoverable]()
×
319
    {
320
      if(discoverable)
×
321
        doReceive();
×
322

323
      doAccept();
×
324
    });
×
325

NEW
326
  m_thread = std::thread(
×
NEW
327
    [this]()
×
328
    {
329
#ifndef NDEBUG
NEW
330
      m_threadId = std::this_thread::get_id();
×
331
#endif
NEW
332
      setThreadName("server");
×
NEW
333
      m_ioContext.run();
×
NEW
334
    });
×
UNCOV
335
}
×
336

337
Server::~Server()
×
338
{
339
  assert(isEventLoopThread());
×
340

341
  if(!m_ioContext.stopped())
×
342
  {
NEW
343
    for(const auto& connection : m_connections)
×
344
    {
NEW
345
      connection->disconnect();
×
346
    }
347

348
    m_ioContext.post(
×
349
      [this]()
×
350
      {
351
        boost::system::error_code ec;
×
352
        if(m_acceptor.cancel(ec))
×
353
          Log::log(id, LogMessage::E1008_SOCKET_ACCEPTOR_CANCEL_FAILED_X, ec);
×
354

355
        m_acceptor.close();
×
356

357
        m_socketUDP.close();
×
358
      });
×
359
  }
360

361
  if(m_thread.joinable())
×
362
    m_thread.join();
×
UNCOV
363
}
×
364

365
void Server::connectionGone(const std::shared_ptr<WebSocketConnection>& connection)
×
366
{
367
  assert(isEventLoopThread());
×
368

369
  m_connections.erase(std::find(m_connections.begin(), m_connections.end(), connection));
×
370
}
×
371

372
void Server::doReceive()
×
373
{
374
  assert(IS_SERVER_THREAD);
×
375

376
  m_socketUDP.async_receive_from(boost::asio::buffer(m_udpBuffer), m_remoteEndpoint,
×
377
    [this](const boost::system::error_code& ec, std::size_t bytesReceived)
×
378
    {
379
      if(!ec)
×
380
      {
381
        if(bytesReceived == sizeof(Message::Header))
×
382
        {
383
          Message message(*reinterpret_cast<Message::Header*>(m_udpBuffer.data()));
×
384

385
          if(!m_localhostOnly || m_remoteEndpoint.address().is_loopback())
×
386
          {
387
            if(message.dataSize() == 0)
×
388
            {
389
              std::unique_ptr<Message> response = processMessage(message);
×
390
              if(response)
×
391
              {
392
                m_socketUDP.async_send_to(boost::asio::buffer(**response, response->size()), m_remoteEndpoint,
×
393
                  [this](const boost::system::error_code& /*ec*/, std::size_t /*bytesTransferred*/)
×
394
                  {
395
                    doReceive();
×
396
                  });
×
397
                return;
×
398
              }
399
            }
×
400
          }
401
        }
×
402
        doReceive();
×
403
      }
NEW
404
      else if(ec != boost::asio::error::operation_aborted)
×
405
      {
UNCOV
406
        Log::log(id, LogMessage::E1003_UDP_RECEIVE_ERROR_X, ec.message());
×
407
      }
408
    });
409
}
×
410

411
std::unique_ptr<Message> Server::processMessage(const Message& message)
×
412
{
413
  if(message.command() == Message::Command::Discover && message.isRequest())
×
414
  {
415
    std::unique_ptr<Message> response = Message::newResponse(message.command(), message.requestId());
×
416
    response->write(boost::asio::ip::host_name());
×
417
    response->write<uint16_t>(TRAINTASTIC_VERSION_MAJOR);
×
418
    response->write<uint16_t>(TRAINTASTIC_VERSION_MINOR);
×
419
    response->write<uint16_t>(TRAINTASTIC_VERSION_PATCH);
×
420
    assert(response->size() <= 1500); // must fit in a UDP packet
×
421
    return response;
×
422
  }
×
423

424
  return {};
×
425
}
426

427
void Server::doAccept()
×
428
{
429
  assert(IS_SERVER_THREAD);
×
430

431
  m_acceptor.async_accept(
×
432
    [this](boost::system::error_code ec, boost::asio::ip::tcp::socket socket)
×
433
    {
434
      if(!ec)
×
435
      {
436
        std::make_shared<HTTPConnection>(shared_from_this(), std::move(socket))->start();
×
437

438
        doAccept();
×
439
      }
NEW
440
      else if(ec != boost::asio::error::operation_aborted)
×
441
      {
442
        Log::log(id, LogMessage::E1004_TCP_ACCEPT_ERROR_X, ec.message());
×
443
      }
444
    });
×
445
}
×
446

447
http::message_generator Server::handleHTTPRequest(http::request<http::string_body>&& request)
×
448
{
449
  const auto target = request.target();
×
450
  if(target == "/")
×
451
  {
452
    return textHtml(request,
×
453
      "<!DOCTYPE html>"
454
      "<html>"
455
      "<head>"
456
        "<meta charset=\"utf-8\">"
457
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">"
458
        "<title>Traintastic v" TRAINTASTIC_VERSION_FULL "</title>"
459
      "</head>"
460
      "<body>"
461
        "<h1>Traintastic <small>v" TRAINTASTIC_VERSION_FULL "</small></h1>"
462
        "<ul>"
463
          "<li><a href=\"/manual/en/index.html\">Manual</a></li>"
464
          "<li><a href=\"/throttle\">Web throttle</a></li>"
465
        "</ul>"
466
      "</body>"
467
      "</html>");
×
468
  }
469
  if(target == "/favicon.ico")
×
470
  {
471
    return binary(request, contentTypeImageXIcon, Resource::shared::gfx::appicon_ico);
×
472
  }
473
  if(request.target() == "/css/normalize.css")
×
474
  {
475
    return textCss(request, Resource::www::css::normalize_css);
×
476
  }
477
  if(request.target() == "/css/throttle.css")
×
478
  {
479
#ifdef SERVE_FROM_FS
480
    const auto css = readFile(www / "css" / "throttle.css");
481
    return css ? textCss(request, *css) : notFound(request);
482
#else
483
    return textCss(request, Resource::www::css::throttle_css);
×
484
#endif
485
  }
486
  if(request.target() == "/js/throttle.js")
×
487
  {
488
#ifdef SERVE_FROM_FS
489
    const auto js = readFile(www / "js" / "throttle.js");
490
    return js ? textJavaScript(request, *js) : notFound(request);
491
#else
492
    return textJavaScript(request, Resource::www::js::throttle_js);
×
493
#endif
494
  }
495
  if(request.target() == "/throttle")
×
496
  {
497
#ifdef SERVE_FROM_FS
498
    const auto html = readFile(www / "throttle.html");
499
    return html ? textHtml(request, *html) : notFound(request);
500
#else
501
    return textHtml(request, Resource::www::throttle_html);
×
502
#endif
503
  }
504
  if(target == "/version")
×
505
  {
506
    return textPlain(request, TRAINTASTIC_VERSION_FULL);
×
507
  }
508
  if(startsWith(target, "/manual"))
×
509
  {
510
    return serveFileFromFileSystem(
511
      request,
512
      stripPrefix(target, "/manual"),
×
513
      m_manualPath,
×
514
      manualAllowedExtensions);
×
515
  }
516
  return notFound(request);
×
517
}
518

519
bool Server::handleWebSocketUpgradeRequest(http::request<http::string_body>&& request, beast::tcp_stream& stream)
×
520
{
521
  if(request.target() == "/client")
×
522
  {
523
    return acceptWebSocketUpgradeRequest<ClientConnection>(std::move(request), stream);
×
524
  }
525
  if(request.target() == "/throttle")
×
526
  {
527
    return acceptWebSocketUpgradeRequest<WebThrottleConnection>(std::move(request), stream);
×
528
  }
529
  return false;
×
530
}
531

532
template<class T>
533
bool Server::acceptWebSocketUpgradeRequest(http::request<http::string_body>&& request, beast::tcp_stream& stream)
×
534
{
535
  namespace websocket = beast::websocket;
536

537
  beast::get_lowest_layer(stream).expires_never(); // disable HTTP timeout
×
538

539
  auto ws = std::make_shared<websocket::stream<beast::tcp_stream>>(std::move(stream));
×
540
  ws->set_option(websocket::stream_base::timeout::suggested(beast::role_type::server));
×
541
  ws->set_option(websocket::stream_base::decorator(
×
542
    [](websocket::response_type& response)
×
543
    {
544
      response.set(beast::http::field::server, serverHeader);
×
545
    }));
546

547
  ws->async_accept(request,
×
548
    [this, ws](beast::error_code ec)
×
549
    {
550
      if(!ec)
×
551
      {
552
        auto connection = std::make_shared<T>(*this, ws);
×
553
        connection->start();
×
554

555
        EventLoop::call(
×
556
          [this, connection]()
×
557
          {
558
            Log::log(connection->id, LogMessage::I1003_NEW_CONNECTION);
×
559
            m_connections.push_back(connection);
×
560
          });
561
      }
×
NEW
562
      else if(ec != boost::asio::error::operation_aborted)
×
563
      {
564
        Log::log(id, LogMessage::E1004_TCP_ACCEPT_ERROR_X, ec.message());
×
565
      }
566
    });
567

568
  return true;
×
569
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc