From 4342aa6371eaac53313a73d5df3c2fe62b195fe5 Mon Sep 17 00:00:00 2001 From: "Alexander A. Klimov" Date: Fri, 28 Aug 2026 16:59:15 +0200 Subject: [PATCH] Introduce AccessLogger --- etc/CMakeLists.txt | 1 + etc/icinga2/features-available/accesslog.conf | 7 + lib/base/CMakeLists.txt | 2 + lib/base/accesslogger.cpp | 268 ++++++++++++++++++ lib/base/accesslogger.hpp | 73 +++++ lib/base/accesslogger.ti | 20 ++ lib/base/filelogger.cpp | 2 +- lib/base/filelogger.hpp | 2 +- lib/base/logger.cpp | 14 +- lib/base/logger.hpp | 3 + lib/base/streamlogger.hpp | 2 +- lib/remote/eventshandler.cpp | 4 + lib/remote/httpmessage.cpp | 11 + lib/remote/httpmessage.hpp | 14 +- lib/remote/httpserverconnection.cpp | 18 ++ 15 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 etc/icinga2/features-available/accesslog.conf create mode 100644 lib/base/accesslogger.cpp create mode 100644 lib/base/accesslogger.hpp create mode 100644 lib/base/accesslogger.ti diff --git a/etc/CMakeLists.txt b/etc/CMakeLists.txt index bee39464bf9..55cec8cbedd 100644 --- a/etc/CMakeLists.txt +++ b/etc/CMakeLists.txt @@ -35,6 +35,7 @@ install_if_not_exists(icinga2/conf.d/notifications.conf ${ICINGA2_CONFIGDIR}/con install_if_not_exists(icinga2/conf.d/templates.conf ${ICINGA2_CONFIGDIR}/conf.d) install_if_not_exists(icinga2/conf.d/timeperiods.conf ${ICINGA2_CONFIGDIR}/conf.d) install_if_not_exists(icinga2/conf.d/users.conf ${ICINGA2_CONFIGDIR}/conf.d) +install_if_not_exists(icinga2/features-available/accesslog.conf ${ICINGA2_CONFIGDIR}/features-available) install_if_not_exists(icinga2/features-available/api.conf ${ICINGA2_CONFIGDIR}/features-available) install_if_not_exists(icinga2/features-available/debuglog.conf ${ICINGA2_CONFIGDIR}/features-available) install_if_not_exists(icinga2/features-available/mainlog.conf ${ICINGA2_CONFIGDIR}/features-available) diff --git a/etc/icinga2/features-available/accesslog.conf b/etc/icinga2/features-available/accesslog.conf new file mode 100644 index 00000000000..b2d82f5316d --- /dev/null +++ b/etc/icinga2/features-available/accesslog.conf @@ -0,0 +1,7 @@ +/** + * The AccessLogger type writes API access information to a file. + */ + +object AccessLogger "access-log" { + path = LogDir + "/access.log" +} diff --git a/lib/base/CMakeLists.txt b/lib/base/CMakeLists.txt index f26c5e6a2b9..b1bcdc1adec 100644 --- a/lib/base/CMakeLists.txt +++ b/lib/base/CMakeLists.txt @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2012 Icinga GmbH # SPDX-License-Identifier: GPL-2.0-or-later +mkclass_target(accesslogger.ti accesslogger-ti.cpp accesslogger-ti.hpp) mkclass_target(application.ti application-ti.cpp application-ti.hpp) mkclass_target(configobject.ti configobject-ti.cpp configobject-ti.hpp) mkclass_target(configuration.ti configuration-ti.cpp configuration-ti.hpp) @@ -15,6 +16,7 @@ mkclass_target(sysloglogger.ti sysloglogger-ti.cpp sysloglogger-ti.hpp) set(base_SOURCES i2-base.hpp + accesslogger.cpp accesslogger.hpp accesslogger-ti.hpp application.cpp application.hpp application-ti.hpp application-version.cpp application-environment.cpp array.cpp array.hpp array-script.cpp atomic.hpp diff --git a/lib/base/accesslogger.cpp b/lib/base/accesslogger.cpp new file mode 100644 index 00000000000..72ee8dd4088 --- /dev/null +++ b/lib/base/accesslogger.cpp @@ -0,0 +1,268 @@ +/* Icinga 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +#include "base/accesslogger.hpp" +#include "base/accesslogger-ti.cpp" +#include "base/configtype.hpp" +#include "base/statsfunction.hpp" +#include "base/application.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace icinga; +namespace http = boost::beast::http; + +static std::set l_AccessLoggers; +static boost::mutex l_AccessLoggersMutex; + +REGISTER_TYPE(AccessLogger); + +REGISTER_STATSFUNCTION(AccessLogger, &AccessLogger::StatsFunc); + +void AccessLogger::StatsFunc(const Dictionary::Ptr& status, const Array::Ptr&) +{ + DictionaryData nodes; + + for (const AccessLogger::Ptr& accesslogger : ConfigType::GetObjectsByType()) { + nodes.emplace_back(accesslogger->GetName(), 1); //add more stats + } + + status->Set("accesslogger", new Dictionary(std::move(nodes))); +} + +LogAccess::~LogAccess() +{ + decltype(l_AccessLoggers) loggers; + + { + boost::mutex::scoped_lock lock (l_AccessLoggersMutex); + loggers = l_AccessLoggers; + } + + for (auto& logger : loggers) { + ObjectLock oLock (logger); + logger->m_Formatter(*this, *logger->m_Stream); + } +} + +void AccessLogger::Register() +{ + boost::mutex::scoped_lock lock (l_AccessLoggersMutex); + l_AccessLoggers.insert(this); +} + +void AccessLogger::Unregister() +{ + boost::mutex::scoped_lock lock (l_AccessLoggersMutex); + l_AccessLoggers.erase(this); +} + +void AccessLogger::OnAllConfigLoaded() +{ + ObjectImpl::OnAllConfigLoaded(); + + m_Formatter = ParseFormatter(GetFormat()); +} + +void AccessLogger::ValidateFormat(const Lazy& lvalue, const ValidationUtils& utils) +{ + ObjectImpl::ValidateFormat(lvalue, utils); + + try { + ParseFormatter(lvalue()); + } catch (const std::invalid_argument& ex) { + BOOST_THROW_EXCEPTION(ValidationError(this, { "format" }, ex.what())); + } +} + +template +static inline +void StreamHttpProtocol(const Message& in, std::ostream& out) +{ + auto protocol (in.version()); + auto minor (protocol % 10u); + + out << (protocol / 10u); + + if (minor || protocol != 20u) { + out << '.' << minor; + } +} + +template +static inline +void StreamHttpCLength(const Message& in, std::ostream& out) +{ + if (in.count(http::field::content_length)) { + out << in[http::field::content_length]; + } else { + out << '-'; + } +} + +template +static inline +void StreamHttpHeader(const Message& in, const Header& header, std::ostream& out) +{ + if (in.count(header)) { + out << in[header]; + } else { + out << '-'; + } +} + +static boost::regex l_ALFTime (R"EOF(\Atime.(.*)\z)EOF", boost::regex::mod_s); +static boost::regex l_ALFHeaders (R"EOF(\A(request|response).headers.(.*)\z)EOF", boost::regex::mod_s); + +static std::unordered_map l_ALFormatters ({ + { "local.address", [](const LogAccess& in, std::ostream& out) { + out << in.Stream.lowest_layer().local_endpoint().address(); + } }, + { "local.port", [](const LogAccess& in, std::ostream& out) { + out << in.Stream.lowest_layer().local_endpoint().port(); + } }, + { "remote.address", [](const LogAccess& in, std::ostream& out) { + out << in.Stream.lowest_layer().remote_endpoint().address(); + } }, + { "remote.port", [](const LogAccess& in, std::ostream& out) { + out << in.Stream.lowest_layer().remote_endpoint().port(); + } }, + { "remote.user", [](const LogAccess& in, std::ostream& out) { + if (in.User.IsEmpty()) { + out << '-'; + } else { + out << in.User; + } + } }, + { "request.method", [](const LogAccess& in, std::ostream& out) { + out << in.Request.method(); + } }, + { "request.uri", [](const LogAccess& in, std::ostream& out) { + out << in.Request.target(); + } }, + { "request.protocol", [](const LogAccess& in, std::ostream& out) { + StreamHttpProtocol(in.Request, out); + } }, + { "request.size", [](const LogAccess& in, std::ostream& out) { + StreamHttpCLength(in.Request, out); + } }, + { "response.protocol", [](const LogAccess& in, std::ostream& out) { + StreamHttpProtocol(in.Response, out); + } }, + { "response.status", [](const LogAccess& in, std::ostream& out) { + out << (int)in.Response.result(); + } }, + { "response.reason", [](const LogAccess& in, std::ostream& out) { + out << in.Response.reason(); + } }, + { "response.size", [](const LogAccess& in, std::ostream& out) { + StreamHttpCLength(in.Response, out); + } } +}); + +AccessLogger::Formatter AccessLogger::ParseFormatter(const String& format) +{ + std::vector tokens; + boost::algorithm::split(tokens, format.GetData(), boost::algorithm::is_any_of("$")); + + if (tokens.size() % 2u == 0u) { + throw std::invalid_argument("Closing $ not found in macro format string '" + format + "'."); + } + + std::vector formatters; + std::string literal; + bool isLiteral = true; + + for (auto& token : tokens) { + if (isLiteral) { + literal += token; + } else if (token.empty()) { + literal += "$"; + } else { + if (!literal.empty()) { + formatters.emplace_back([literal](const LogAccess&, std::ostream& out) { + out << literal; + }); + + literal = ""; + } + + auto formatter (l_ALFormatters.find(token)); + + if (formatter == l_ALFormatters.end()) { + boost::smatch what; + + if (boost::regex_search(token, what, l_ALFTime)) { + auto spec (what[1].str()); + + formatters.emplace_back([spec](const LogAccess&, std::ostream& out) { + time_t now; + struct tm tmNow; + + (void)time(&now); + +#ifdef _WIN32 + (void)localtime_s(&tmNow, &now); +#else + (void)localtime_r(&now, &tmNow); +#endif + + for (std::vector::size_type size = 64;; size *= 2u) { + std::vector buf (size); + + if (strftime(buf.data(), size, spec.data(), &tmNow)) { + out << buf.data(); + break; + } else if (!strlen(spec.data())) { + break; + } + } + }); + } else if (boost::regex_search(token, what, l_ALFHeaders)) { + auto header (what[2].str()); + + if (what[1] == "request") { + formatters.emplace_back([header](const LogAccess& in, std::ostream& out) { + StreamHttpHeader(in.Request, header, out); + }); + } else { + formatters.emplace_back([header](const LogAccess& in, std::ostream& out) { + StreamHttpHeader(in.Response, header, out); + }); + } + } else { + throw std::invalid_argument("Bad macro '" + token + "'."); + } + } else { + formatters.emplace_back(formatter->second); + } + } + + isLiteral = !isLiteral; + } + + if (!literal.empty()) { + formatters.emplace_back([literal](const LogAccess&, std::ostream& out) { + out << literal; + }); + } + + switch (formatters.size()) { + case 0u: + return [](const LogAccess&, std::ostream&) { }; + case 1u: + return std::move(formatters[0]); + default: + return [formatters](const LogAccess& in, std::ostream& out) { + for (auto& formatter : formatters) { + formatter(in, out); + } + }; + } +} diff --git a/lib/base/accesslogger.hpp b/lib/base/accesslogger.hpp new file mode 100644 index 00000000000..a41f6efb663 --- /dev/null +++ b/lib/base/accesslogger.hpp @@ -0,0 +1,73 @@ +/* Icinga 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +#ifndef ACCESSLOGGER_H +#define ACCESSLOGGER_H + +#include "base/i2-base.hpp" +#include "base/accesslogger-ti.hpp" +#include "base/shared.hpp" +#include "base/tlsstream.hpp" +#include +#include +#include +#include + +namespace icinga +{ + +class LogAccess +{ +public: + inline LogAccess( + const AsioTlsStream& stream, + const boost::beast::http::request_header<>& request, + String user, + const boost::beast::http::response_header<>& response + ) : Stream(stream), Request(request), User(std::move(user)), Response(response) + {} + + LogAccess(const LogAccess&) = delete; + LogAccess(LogAccess&&) = delete; + LogAccess& operator=(const LogAccess&) = delete; + LogAccess& operator=(LogAccess&&) = delete; + ~LogAccess(); + + const AsioTlsStream& Stream; + const boost::beast::http::request_header<>& Request; + String User; + const boost::beast::http::response_header<>& Response; +}; + +/** + * A file logger that logs API access. + * + * @ingroup base + */ +class AccessLogger final : public ObjectImpl +{ + friend LogAccess; + +public: + typedef void FormatterFunc(const LogAccess& in, std::ostream& out); + typedef std::function Formatter; + + DECLARE_OBJECT(AccessLogger); + DECLARE_OBJECTNAME(AccessLogger); + + static void StatsFunc(const Dictionary::Ptr& status, const Array::Ptr& perfdata); + +protected: + void OnAllConfigLoaded() override; + void ValidateFormat(const Lazy& lvalue, const ValidationUtils& utils) override; + void Register() override; + void Unregister() override; + +private: + Formatter ParseFormatter(const String& format); + + Formatter m_Formatter; +}; + +} + +#endif /* ACCESSLOGGER_H */ diff --git a/lib/base/accesslogger.ti b/lib/base/accesslogger.ti new file mode 100644 index 00000000000..1326bf0fc17 --- /dev/null +++ b/lib/base/accesslogger.ti @@ -0,0 +1,20 @@ +/* Icinga 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +#include "base/filelogger.hpp" + +library base; + +namespace icinga +{ + +class AccessLogger : FileLogger +{ + activation_priority -99; + + [config] String format { + default {{{ return R"EOF($remote.address$ - $remote.user$ [$time.%d/%b/%Y:%T %z$] "$request.method$ $request.uri$ HTTP/$request.protocol$" $response.status$ $response.size$ +)EOF"; }}} + }; +}; + +} diff --git a/lib/base/filelogger.cpp b/lib/base/filelogger.cpp index dd5b41c5aea..f91bdfb1a27 100644 --- a/lib/base/filelogger.cpp +++ b/lib/base/filelogger.cpp @@ -36,7 +36,7 @@ void FileLogger::Start(bool runtimeCreated) ObjectImpl::Start(runtimeCreated); - Log(LogInformation, "FileLogger") + Log(LogInformation, GetReflectionType()->GetName()) << "'" << GetName() << "' started."; } diff --git a/lib/base/filelogger.hpp b/lib/base/filelogger.hpp index 7e6aa39b3dc..d5c8354b0f2 100644 --- a/lib/base/filelogger.hpp +++ b/lib/base/filelogger.hpp @@ -15,7 +15,7 @@ namespace icinga * * @ingroup base */ -class FileLogger final : public ObjectImpl +class FileLogger : public ObjectImpl { public: DECLARE_OBJECT(FileLogger); diff --git a/lib/base/logger.cpp b/lib/base/logger.cpp index 5172e3a83f2..a65984d46dc 100644 --- a/lib/base/logger.cpp +++ b/lib/base/logger.cpp @@ -55,7 +55,17 @@ INITIALIZE_ONCE([]() { void Logger::Start(bool runtimeCreated) { ObjectImpl::Start(runtimeCreated); + Register(); +} + +void Logger::Stop(bool runtimeRemoved) +{ + Unregister(); + ObjectImpl::Stop(runtimeRemoved); +} +void Logger::Register() +{ { std::unique_lock lock(m_Mutex); m_Loggers.insert(this); @@ -64,7 +74,7 @@ void Logger::Start(bool runtimeCreated) UpdateMinLogSeverity(); } -void Logger::Stop(bool runtimeRemoved) +void Logger::Unregister() { { std::unique_lock lock(m_Mutex); @@ -72,8 +82,6 @@ void Logger::Stop(bool runtimeRemoved) } UpdateMinLogSeverity(); - - ObjectImpl::Stop(runtimeRemoved); } std::set Logger::GetLoggers() diff --git a/lib/base/logger.hpp b/lib/base/logger.hpp index 0e929d3000a..f7f1bccd3fd 100644 --- a/lib/base/logger.hpp +++ b/lib/base/logger.hpp @@ -95,6 +95,9 @@ class Logger : public ObjectImpl void Start(bool runtimeCreated) override; void Stop(bool runtimeRemoved) override; + virtual void Register(); + virtual void Unregister(); + private: static void UpdateMinLogSeverity(); diff --git a/lib/base/streamlogger.hpp b/lib/base/streamlogger.hpp index ddf944fdf0e..53530b5a821 100644 --- a/lib/base/streamlogger.hpp +++ b/lib/base/streamlogger.hpp @@ -32,10 +32,10 @@ class StreamLogger : public ObjectImpl protected: void ProcessLogEntry(const LogEntry& entry) final; void Flush() final; + std::ostream *m_Stream{nullptr}; private: static std::mutex m_Mutex; - std::ostream *m_Stream{nullptr}; bool m_OwnsStream{false}; Timer::Ptr m_FlushLogTimer; diff --git a/lib/remote/eventshandler.cpp b/lib/remote/eventshandler.cpp index 1430d7217b6..ffe67946802 100644 --- a/lib/remote/eventshandler.cpp +++ b/lib/remote/eventshandler.cpp @@ -6,6 +6,7 @@ #include "remote/filterutility.hpp" #include "config/configcompiler.hpp" #include "config/expression.hpp" +#include "base/accesslogger.hpp" #include "base/defer.hpp" #include "base/io-engine.hpp" #include "base/objectlock.hpp" @@ -107,6 +108,9 @@ bool EventsHandler::HandleRequest( response.result(http::status::ok); response.set(http::field::content_type, "application/json"); + + LogAccess(request.Stream(), request, user ? user->GetName() : "", response); + response.StartStreaming(true); // Send response headers before waiting for the first event. response.Flush(yc); diff --git a/lib/remote/httpmessage.cpp b/lib/remote/httpmessage.cpp index be5ceee4b49..16846da2dd5 100644 --- a/lib/remote/httpmessage.cpp +++ b/lib/remote/httpmessage.cpp @@ -8,6 +8,7 @@ #include #include #include +#include using namespace icinga; @@ -105,6 +106,11 @@ HttpApiRequest::HttpApiRequest(Shared::Ptr stream) : IncomingHttp { } +const AsioTlsStream& HttpApiRequest::Stream() const +{ + return *std::get::Ptr>(m_Stream); +} + ApiUser::Ptr HttpApiRequest::User() const { return m_User; @@ -203,6 +209,11 @@ HttpApiResponse::HttpApiResponse(Shared::Ptr stream, HttpServerCo { } +const AsioTlsStream& HttpApiResponse::Stream() const +{ + return *std::get::Ptr>(m_Stream); +} + void HttpApiResponse::StartStreaming(bool checkForDisconnect) { OutgoingHttpMessage::StartStreaming(); diff --git a/lib/remote/httpmessage.hpp b/lib/remote/httpmessage.hpp index 80e61de392a..bc1c13f292b 100644 --- a/lib/remote/httpmessage.hpp +++ b/lib/remote/httpmessage.hpp @@ -188,10 +188,11 @@ class IncomingHttpMessage : public boost::beast::http::message ParserType& Parser() { return m_Parser; } +protected: + StreamVariant m_Stream; + private: ParserType m_Parser; - - StreamVariant m_Stream; }; using IncomingHttpRequest = IncomingHttpMessage; @@ -296,12 +297,13 @@ class OutgoingHttpMessage : public boost::beast::http::message m_CpuBoundWork.emplace(yc, strand); } +protected: + StreamVariant m_Stream; + private: Serializer m_Serializer{*this}; bool m_SerializationStarted = false; std::optional m_CpuBoundWork; - - StreamVariant m_Stream; }; using OutgoingHttpRequest = OutgoingHttpMessage; @@ -313,6 +315,8 @@ class HttpApiRequest public: explicit HttpApiRequest(Shared::Ptr stream); + [[nodiscard]] const AsioTlsStream& Stream() const; + [[nodiscard]] ApiUser::Ptr User() const; void User(const ApiUser::Ptr& user); @@ -339,6 +343,8 @@ class HttpApiResponse public: explicit HttpApiResponse(Shared::Ptr stream, HttpServerConnection::Ptr server = nullptr); + [[nodiscard]] const AsioTlsStream& Stream() const; + /** * Enables chunked encoding. * diff --git a/lib/remote/httpserverconnection.cpp b/lib/remote/httpserverconnection.cpp index e3bd5d7c92a..00ccfd0b9e5 100644 --- a/lib/remote/httpserverconnection.cpp +++ b/lib/remote/httpserverconnection.cpp @@ -7,6 +7,7 @@ #include "remote/apilistener.hpp" #include "remote/apifunction.hpp" #include "remote/jsonrpc.hpp" +#include "base/accesslogger.hpp" #include "base/application.hpp" #include "base/base64.hpp" #include "base/convert.hpp" @@ -206,6 +207,8 @@ bool EnsureValidHeaders( response.set(http::field::connection, "close"); + LogAccess(request.Stream(), request, request.User() ? request.User()->GetName() : "", response); + response.Flush(yc); return false; @@ -226,6 +229,9 @@ void HandleExpect100( if (request[http::field::expect] == "100-continue") { HttpApiResponse response{stream}; response.result(http::status::continue_); + + LogAccess(request.Stream(), request, request.User() ? request.User()->GetName() : "", response); + response.Flush(yc); } } @@ -263,6 +269,8 @@ bool HandleAccessControl( response.body() << "Preflight OK"; response.set(http::field::connection, "close"); + LogAccess(request.Stream(), request, request.User() ? request.User()->GetName() : "", response); + response.Flush(yc); return false; @@ -289,6 +297,8 @@ bool EnsureAcceptHeader( response.body() << "

Accept header is missing or not set to 'application/json'.

"; response.set(http::field::connection, "close"); + LogAccess(request.Stream(), request, request.User() ? request.User()->GetName() : "", response); + response.Flush(yc); return false; @@ -322,6 +332,8 @@ bool EnsureAuthenticatedUser( response.set(http::field::www_authenticate, "Basic realm=\"Icinga 2\""); response.set(http::field::connection, "close"); + LogAccess(request.Stream(), request, "", response); + response.Flush(yc); return false; @@ -405,6 +417,8 @@ bool EnsureValidBody( response.set(http::field::connection, "close"); + LogAccess(request.Stream(), request, request.User() ? request.User()->GetName() : "", response); + response.Flush(yc); return false; @@ -442,6 +456,10 @@ void ProcessRequest( HttpUtility::SendJsonError(response, request.Params(), 500, "Unhandled exception", std::current_exception()); } + if (!response.HasSerializationStarted()) { + LogAccess(request.Stream(), request, request.User() ? request.User()->GetName() : "", response); + } + response.Flush(yc); }