Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ add_library(moqx_core STATIC
src/admin/BuiltinRoutes.cpp
src/admin/CachePurgeHandler.cpp
src/admin/ConfigHandler.cpp
src/admin/ConnectionLogsHandler.cpp
src/admin/MetricsHandler.cpp
src/admin/TrackMetricsHandler.cpp
src/admin/StateHandler.cpp
Expand Down
242 changes: 242 additions & 0 deletions src/admin/ConnectionLogsHandler.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*
* Copyright (c) OpenMOQ contributors.
* This source code is licensed under the Apache 2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

#include "admin/ConnectionLogsHandler.h"

#include <cctype>
#include <string>
#include <string_view>

#include <fcntl.h>
#include <sys/stat.h>

#include <folly/CancellationToken.h>
#include <folly/File.h>
#include <folly/FileUtil.h>
#include <folly/coro/Task.h>
#include <folly/coro/WithCancellation.h>
#include <folly/executors/GlobalExecutor.h>
#include <folly/futures/Future.h>
#include <folly/io/IOBuf.h>
#include <folly/io/async/EventBaseManager.h>
#include <folly/logging/xlog.h>
#include <proxygen/httpserver/ResponseBuilder.h>
#include <proxygen/lib/http/HTTPMessage.h>

#include "admin/AdminResponse.h"
#include "admin/AdminServer.h"

namespace openmoq::moqx::admin {

namespace {

constexpr size_t kMaxDownloadBytes = 512ULL * 1024 * 1024; // 512 MB hard cap
constexpr size_t kChunkSize = 64 * 1024;

// Normalize a raw connection ID string:
// - strip 0x/0X prefix
// - lowercase hex digits
// - validate hex-only, 1–40 chars
std::optional<std::string> normalizeConnectionId(std::string_view raw) {
if (raw.size() >= 2 && raw[0] == '0' && (raw[1] == 'x' || raw[1] == 'X')) {
raw.remove_prefix(2);
}
std::string result;
result.reserve(raw.size());
for (char c : raw) {
if (!std::isxdigit(static_cast<unsigned char>(c)))
return std::nullopt;
result += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (result.empty() || result.size() > 40)
return std::nullopt;
return result;
}

// Blocking; must run off the event-loop thread. Returns nullptr if the file
// cannot be opened, is not a regular file, is empty, or exceeds maxBytes.
// maxBytes is a policy limit on what a client may pull, not a memory bound:
// the body is streamed, so it never lands in the process whole.
std::unique_ptr<folly::File> openLogFile(const std::string& path, size_t maxBytes) {
std::unique_ptr<folly::File> file;
try {
file = std::make_unique<folly::File>(path, O_RDONLY);
} catch (const std::exception&) {
return nullptr;
}

struct stat st{};
if (::fstat(file->fd(), &st) != 0 || !S_ISREG(st.st_mode))
return nullptr;
const auto size = static_cast<size_t>(st.st_size);
if (size == 0 || size > maxBytes)
return nullptr;

return file;
}

// Blocking; must run off the event-loop thread. Returns a zero-length buffer
// at EOF, nullptr on read error.
std::unique_ptr<folly::IOBuf> readChunk(int fd) {
auto buf = folly::IOBuf::create(kChunkSize);
const auto rc = folly::readNoInt(fd, buf->writableTail(), kChunkSize);
if (rc < 0)
return nullptr;
buf->append(static_cast<size_t>(rc));
return buf;
}

// Runs on the admin event base. Every resumption point must re-check
// cancelToken: downstream is destroyed as soon as cancellation fires.
folly::coro::Task<void> streamLogFile(
std::string filePath,
std::string fileName,
proxygen::ResponseHandler* downstream,
folly::CancellationToken cancelToken
) {
if (cancelToken.isCancellationRequested())
co_return;

// Open on the global CPU pool: open(2)/fstat(2) block.
auto openResult =
co_await folly::coro::co_awaitTry(folly::via(folly::getGlobalCPUExecutor(), [&filePath] {
return openLogFile(filePath, kMaxDownloadBytes);
}));
if (openResult.hasException()) {
XLOG(ERR) << "ConnectionLogsHandler: file open threw: " << openResult.exception().what();
if (!cancelToken.isCancellationRequested()) {
sendError(downstream, 500, "internal error\n");
}
co_return;
}

if (cancelToken.isCancellationRequested())
co_return;

std::unique_ptr<folly::File> file = std::move(openResult.value());
if (!file) {
sendError(downstream, 404, "log file not found or exceeds size limit\n");
co_return;
}

// No Content-Length: the log is still being appended to while we read it, so
// a length from fstat(2) would be stale by EOF. The body streams chunked.
proxygen::ResponseBuilder(downstream)
.status(200, proxygen::HTTPMessage::getDefaultReason(200))
.header("Content-Type", "application/json")
.header("Content-Disposition", "attachment; filename=\"" + fileName + "\"")
.send();

// Read on the CPU pool, send from the event-loop thread: each disk read
// overlaps the network write of the chunk before it.
for (;;) {
auto chunkResult = co_await folly::coro::co_awaitTry(
folly::via(folly::getGlobalCPUExecutor(), [fd = file->fd()] { return readChunk(fd); })
);

if (cancelToken.isCancellationRequested())
co_return;

if (chunkResult.hasException() || !chunkResult.value()) {
// Headers are already out, so the only way left to signal failure is to
// tear the response down.
XLOG(ERR) << "ConnectionLogsHandler: read failed for " << filePath;
downstream->sendAbort();
co_return;
}

auto chunk = std::move(chunkResult.value());
if (chunk->empty()) {
proxygen::ResponseBuilder(downstream).sendWithEOM();
co_return;
}
proxygen::ResponseBuilder(downstream).body(std::move(chunk)).send();
}
}

} // namespace

void registerConnectionLogsRoutes(
AdminServer& adminServer,
const std::optional<config::LoggingConfig>& logging
) {
std::string mlogDir, qlogDir;
if (logging) {
if (logging->mlog && !logging->mlog->dir.empty()) {
mlogDir = logging->mlog->dir;
}
if (logging->qlog && !logging->qlog->dir.empty()) {
qlogDir = logging->qlog->dir;
}
}

// ── GET /logs?connection_id=<hex>&type=mlog|qlog
//
// Path is constructed directly as {dir}/{normalized_cid}.{ext}
adminServer.addRoute(
"GET",
"/logs",
[mlogDir = std::move(mlogDir), qlogDir = std::move(qlogDir)](
std::unique_ptr<proxygen::HTTPMessage> req,
std::unique_ptr<folly::IOBuf> /*body*/,
proxygen::ResponseHandler* downstream,
folly::CancellationToken cancelToken
) {
// Resolve type → directory, file extension, Content-Type.
const auto& typeStr = req->getQueryParam("type");
const std::string* dir = nullptr;
const char* ext = nullptr;
if (typeStr == "mlog") {
dir = &mlogDir;
ext = ".mlog";
} else if (typeStr == "qlog") {
dir = &qlogDir;
ext = ".qlog";
} else {
sendError(downstream, 400, "type must be 'mlog' or 'qlog'\n");
return;
}

if (dir->empty()) {
sendError(downstream, 503, "that log type is not configured\n");
return;
}

const auto& rawCid = req->getQueryParam("connection_id");
if (rawCid.empty()) {
sendError(downstream, 400, "missing connection_id\n");
return;
}

auto normCid = normalizeConnectionId(rawCid);
if (!normCid) {
sendError(downstream, 400, "invalid connection_id\n");
return;
}

// {dir}/{normalizedCid}.{ext}
auto filePath = *dir + "/" + *normCid + ext;
auto fileName = *normCid + ext;

auto* evb = folly::EventBaseManager::get()->getEventBase();
folly::coro::co_withCancellation(
cancelToken,
folly::coro::co_withExecutor(
evb,
streamLogFile(
std::move(filePath),
std::move(fileName),
downstream,
std::move(cancelToken)
)
)
)
.start();
}
);
}

} // namespace openmoq::moqx::admin
30 changes: 30 additions & 0 deletions src/admin/ConnectionLogsHandler.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (c) OpenMOQ contributors.
* This source code is licensed under the Apache 2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

#pragma once

#include <optional>

#include "config/Config.h"

namespace openmoq::moqx::admin {

class AdminServer;

// Registers GET /logs on the admin server.
//
// GET /logs?connection_id=<hex>&type=mlog|qlog
// Resolves the file path as {log_dir}/{normalized_cid}.{ext} and streams
// the file directly. No index or disk scan is required — files written
// after startup are immediately reachable.
// Responds 400 for missing/invalid params, 503 if the requested type is
// not configured, 404 if the file does not exist.
void registerConnectionLogsRoutes(
AdminServer& adminServer,
const std::optional<config::LoggingConfig>& logging
);

} // namespace openmoq::moqx::admin
6 changes: 5 additions & 1 deletion src/config/ConfigResolver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,11 @@ folly::Expected<ResolvedConfig, std::string> resolveConfig(const ParsedConfig& c
if (!mlogConfig.dir.empty()) {
std::error_code ec;
const auto st = std::filesystem::status(mlogConfig.dir, ec);
if (ec) {
// A missing directory is not an error here: LogSetup creates it
// (via create_directories) before logging starts. Only reject
// genuine access failures (e.g. permission denied on a parent
// directory).
if (ec && st.type() != std::filesystem::file_type::not_found) {
return folly::makeUnexpected(
"Failed to access mlog directory '" + mlogConfig.dir + "': " + ec.message()
);
Expand Down
2 changes: 2 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "admin/BuiltinRoutes.h"
#include "admin/CachePurgeHandler.h"
#include "admin/ConfigHandler.h"
#include "admin/ConnectionLogsHandler.h"
#include "admin/MetricsHandler.h"
#include "admin/StateHandler.h"
#include "admin/TrackMetricsHandler.h"
Expand Down Expand Up @@ -204,6 +205,7 @@ int main(int argc, char* argv[]) {
}
admin::registerTrackMetricsRoute(adminServer, context, trackLimits);
admin::registerConfigRoute(adminServer, std::make_shared<const cfg::Config>(config));
admin::registerConnectionLogsRoutes(adminServer, config.logging);

// === 8. Start serving ===
for (auto& server : servers) {
Expand Down
4 changes: 4 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ add_test(
NAME admin_config_endpoint
COMMAND bash ${PROJECT_SOURCE_DIR}/test/test_admin_config.sh $<TARGET_FILE:moqx>
)
add_test(
NAME admin_connection_logs_endpoint
COMMAND bash ${PROJECT_SOURCE_DIR}/test/test_admin_connection_logs.sh $<TARGET_FILE:moqx>
)
add_test(
NAME admin_cache_purge_concurrency_test
COMMAND bash ${PROJECT_SOURCE_DIR}/test/test_admin_cache_purge_race.sh $<TARGET_FILE:moqx>
Expand Down
Loading
Loading