diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:34:54 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:34:54 +0000 |
commit | 0915b3ef56dfac3113cce55a59a5765dc94976be (patch) | |
tree | a8fea11d50b4f083e1bf0f90025ece7f0824784a /lib/remote | |
parent | Initial commit. (diff) | |
download | icinga2-0915b3ef56dfac3113cce55a59a5765dc94976be.tar.xz icinga2-0915b3ef56dfac3113cce55a59a5765dc94976be.zip |
Adding upstream version 2.13.6.upstream/2.13.6upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
78 files changed, 12465 insertions, 0 deletions
diff --git a/lib/remote/CMakeLists.txt b/lib/remote/CMakeLists.txt new file mode 100644 index 0000000..2c5a032 --- /dev/null +++ b/lib/remote/CMakeLists.txt @@ -0,0 +1,66 @@ +# Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ + +mkclass_target(apilistener.ti apilistener-ti.cpp apilistener-ti.hpp) +mkclass_target(apiuser.ti apiuser-ti.cpp apiuser-ti.hpp) +mkclass_target(endpoint.ti endpoint-ti.cpp endpoint-ti.hpp) +mkclass_target(zone.ti zone-ti.cpp zone-ti.hpp) + +set(remote_SOURCES + i2-remote.hpp + actionshandler.cpp actionshandler.hpp + apiaction.cpp apiaction.hpp + apifunction.cpp apifunction.hpp + apilistener.cpp apilistener.hpp apilistener-ti.hpp apilistener-configsync.cpp apilistener-filesync.cpp + apilistener-authority.cpp + apiuser.cpp apiuser.hpp apiuser-ti.hpp + configfileshandler.cpp configfileshandler.hpp + configobjectutility.cpp configobjectutility.hpp + configpackageshandler.cpp configpackageshandler.hpp + configpackageutility.cpp configpackageutility.hpp + configstageshandler.cpp configstageshandler.hpp + consolehandler.cpp consolehandler.hpp + createobjecthandler.cpp createobjecthandler.hpp + deleteobjecthandler.cpp deleteobjecthandler.hpp + endpoint.cpp endpoint.hpp endpoint-ti.hpp + eventqueue.cpp eventqueue.hpp + eventshandler.cpp eventshandler.hpp + filterutility.cpp filterutility.hpp + httphandler.cpp httphandler.hpp + httpserverconnection.cpp httpserverconnection.hpp + httputility.cpp httputility.hpp + infohandler.cpp infohandler.hpp + jsonrpc.cpp jsonrpc.hpp + jsonrpcconnection.cpp jsonrpcconnection.hpp jsonrpcconnection-heartbeat.cpp jsonrpcconnection-pki.cpp + messageorigin.cpp messageorigin.hpp + modifyobjecthandler.cpp modifyobjecthandler.hpp + objectqueryhandler.cpp objectqueryhandler.hpp + pkiutility.cpp pkiutility.hpp + statushandler.cpp statushandler.hpp + templatequeryhandler.cpp templatequeryhandler.hpp + typequeryhandler.cpp typequeryhandler.hpp + url.cpp url.hpp url-characters.hpp + variablequeryhandler.cpp variablequeryhandler.hpp + zone.cpp zone.hpp zone-ti.hpp +) + +if(ICINGA2_UNITY_BUILD) + mkunity_target(remote remote remote_SOURCES) +endif() + +add_library(remote OBJECT ${remote_SOURCES}) + +add_dependencies(remote base config) + +set_target_properties ( + remote PROPERTIES + FOLDER Lib +) + +#install(CODE "file(MAKE_DIRECTORY \"\$ENV{DESTDIR}${ICINGA2_FULL_DATADIR}/api\")") +install(CODE "file(MAKE_DIRECTORY \"\$ENV{DESTDIR}${ICINGA2_FULL_DATADIR}/api/log\")") +install(CODE "file(MAKE_DIRECTORY \"\$ENV{DESTDIR}${ICINGA2_FULL_DATADIR}/api/zones\")") +install(CODE "file(MAKE_DIRECTORY \"\$ENV{DESTDIR}${ICINGA2_FULL_DATADIR}/api/zones-stage\")") +install(CODE "file(MAKE_DIRECTORY \"\$ENV{DESTDIR}${ICINGA2_FULL_DATADIR}/certs\")") +install(CODE "file(MAKE_DIRECTORY \"\$ENV{DESTDIR}${ICINGA2_FULL_DATADIR}/certificate-requests\")") + + diff --git a/lib/remote/actionshandler.cpp b/lib/remote/actionshandler.cpp new file mode 100644 index 0000000..80f06e6 --- /dev/null +++ b/lib/remote/actionshandler.cpp @@ -0,0 +1,139 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/actionshandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "remote/apiaction.hpp" +#include "base/defer.hpp" +#include "base/exception.hpp" +#include "base/logger.hpp" +#include <set> + +using namespace icinga; + +thread_local ApiUser::Ptr ActionsHandler::AuthenticatedApiUser; + +REGISTER_URLHANDLER("/v1/actions", ActionsHandler); + +bool ActionsHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() != 3) + return false; + + if (request.method() != http::verb::post) + return false; + + String actionName = url->GetPath()[2]; + + ApiAction::Ptr action = ApiAction::GetByName(actionName); + + if (!action) { + HttpUtility::SendJsonError(response, params, 404, "Action '" + actionName + "' does not exist."); + return true; + } + + QueryDescription qd; + + const std::vector<String>& types = action->GetTypes(); + std::vector<Value> objs; + + String permission = "actions/" + actionName; + + if (!types.empty()) { + qd.Types = std::set<String>(types.begin(), types.end()); + qd.Permission = permission; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No objects found.", + DiagnosticInformation(ex)); + return true; + } + } else { + FilterUtility::CheckPermission(user, permission); + objs.emplace_back(nullptr); + } + + ArrayData results; + + Log(LogNotice, "ApiActionHandler") + << "Running action " << actionName; + + bool verbose = false; + + ActionsHandler::AuthenticatedApiUser = user; + Defer a ([]() { + ActionsHandler::AuthenticatedApiUser = nullptr; + }); + + if (params) + verbose = HttpUtility::GetLastParameter(params, "verbose"); + + for (const ConfigObject::Ptr& obj : objs) { + try { + results.emplace_back(action->Invoke(obj, params)); + } catch (const std::exception& ex) { + Dictionary::Ptr fail = new Dictionary({ + { "code", 500 }, + { "status", "Action execution failed: '" + DiagnosticInformation(ex, false) + "'." } + }); + + /* Exception for actions. Normally we would handle this inside SendJsonError(). */ + if (verbose) + fail->Set("diagnostic_information", DiagnosticInformation(ex)); + + results.emplace_back(std::move(fail)); + } + } + + int statusCode = 500; + std::set<int> okStatusCodes, nonOkStatusCodes; + + for (const Dictionary::Ptr& res : results) { + if (!res->Contains("code")) { + continue; + } + + auto code = res->Get("code"); + + if (code >= 200 && code <= 299) { + okStatusCodes.insert(code); + } else { + nonOkStatusCodes.insert(code); + } + } + + size_t okSize = okStatusCodes.size(); + size_t nonOkSize = nonOkStatusCodes.size(); + + if (okSize == 1u && nonOkSize == 0u) { + statusCode = *okStatusCodes.begin(); + } else if (nonOkSize == 1u) { + statusCode = *nonOkStatusCodes.begin(); + } else if (okSize >= 2u && nonOkSize == 0u) { + statusCode = 200; + } + + response.result(statusCode); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + HttpUtility::SendJsonBody(response, params, result); + + return true; +} diff --git a/lib/remote/actionshandler.hpp b/lib/remote/actionshandler.hpp new file mode 100644 index 0000000..ca662ca --- /dev/null +++ b/lib/remote/actionshandler.hpp @@ -0,0 +1,32 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef ACTIONSHANDLER_H +#define ACTIONSHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class ActionsHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(ActionsHandler); + + static thread_local ApiUser::Ptr AuthenticatedApiUser; + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* ACTIONSHANDLER_H */ diff --git a/lib/remote/apiaction.cpp b/lib/remote/apiaction.cpp new file mode 100644 index 0000000..4da91f0 --- /dev/null +++ b/lib/remote/apiaction.cpp @@ -0,0 +1,40 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/apiaction.hpp" +#include "base/singleton.hpp" + +using namespace icinga; + +ApiAction::ApiAction(std::vector<String> types, Callback action) + : m_Types(std::move(types)), m_Callback(std::move(action)) +{ } + +Value ApiAction::Invoke(const ConfigObject::Ptr& target, const Dictionary::Ptr& params) +{ + return m_Callback(target, params); +} + +const std::vector<String>& ApiAction::GetTypes() const +{ + return m_Types; +} + +ApiAction::Ptr ApiAction::GetByName(const String& name) +{ + return ApiActionRegistry::GetInstance()->GetItem(name); +} + +void ApiAction::Register(const String& name, const ApiAction::Ptr& action) +{ + ApiActionRegistry::GetInstance()->Register(name, action); +} + +void ApiAction::Unregister(const String& name) +{ + ApiActionRegistry::GetInstance()->Unregister(name); +} + +ApiActionRegistry *ApiActionRegistry::GetInstance() +{ + return Singleton<ApiActionRegistry>::GetInstance(); +} diff --git a/lib/remote/apiaction.hpp b/lib/remote/apiaction.hpp new file mode 100644 index 0000000..f2719c1 --- /dev/null +++ b/lib/remote/apiaction.hpp @@ -0,0 +1,69 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef APIACTION_H +#define APIACTION_H + +#include "remote/i2-remote.hpp" +#include "base/registry.hpp" +#include "base/value.hpp" +#include "base/dictionary.hpp" +#include "base/configobject.hpp" +#include <vector> +#include <boost/algorithm/string/replace.hpp> + +namespace icinga +{ + +/** + * An action available over the external HTTP API. + * + * @ingroup remote + */ +class ApiAction final : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(ApiAction); + + typedef std::function<Value(const ConfigObject::Ptr& target, const Dictionary::Ptr& params)> Callback; + + ApiAction(std::vector<String> registerTypes, Callback function); + + Value Invoke(const ConfigObject::Ptr& target, const Dictionary::Ptr& params); + + const std::vector<String>& GetTypes() const; + + static ApiAction::Ptr GetByName(const String& name); + static void Register(const String& name, const ApiAction::Ptr& action); + static void Unregister(const String& name); + +private: + std::vector<String> m_Types; + Callback m_Callback; +}; + +/** + * A registry for API actions. + * + * @ingroup remote + */ +class ApiActionRegistry : public Registry<ApiActionRegistry, ApiAction::Ptr> +{ +public: + static ApiActionRegistry *GetInstance(); +}; + +#define REGISTER_APIACTION(name, types, callback) \ + INITIALIZE_ONCE([]() { \ + String registerName = #name; \ + boost::algorithm::replace_all(registerName, "_", "-"); \ + std::vector<String> registerTypes; \ + String typeNames = types; \ + if (!typeNames.IsEmpty()) \ + registerTypes = typeNames.Split(";"); \ + ApiAction::Ptr action = new ApiAction(registerTypes, callback); \ + ApiActionRegistry::GetInstance()->Register(registerName, action); \ + }) + +} + +#endif /* APIACTION_H */ diff --git a/lib/remote/apifunction.cpp b/lib/remote/apifunction.cpp new file mode 100644 index 0000000..5b855cc --- /dev/null +++ b/lib/remote/apifunction.cpp @@ -0,0 +1,35 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/apifunction.hpp" +#include "base/singleton.hpp" + +using namespace icinga; + +ApiFunction::ApiFunction(Callback function) + : m_Callback(std::move(function)) +{ } + +Value ApiFunction::Invoke(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& arguments) +{ + return m_Callback(origin, arguments); +} + +ApiFunction::Ptr ApiFunction::GetByName(const String& name) +{ + return ApiFunctionRegistry::GetInstance()->GetItem(name); +} + +void ApiFunction::Register(const String& name, const ApiFunction::Ptr& function) +{ + ApiFunctionRegistry::GetInstance()->Register(name, function); +} + +void ApiFunction::Unregister(const String& name) +{ + ApiFunctionRegistry::GetInstance()->Unregister(name); +} + +ApiFunctionRegistry *ApiFunctionRegistry::GetInstance() +{ + return Singleton<ApiFunctionRegistry>::GetInstance(); +} diff --git a/lib/remote/apifunction.hpp b/lib/remote/apifunction.hpp new file mode 100644 index 0000000..e611320 --- /dev/null +++ b/lib/remote/apifunction.hpp @@ -0,0 +1,59 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef APIFUNCTION_H +#define APIFUNCTION_H + +#include "remote/i2-remote.hpp" +#include "remote/messageorigin.hpp" +#include "base/registry.hpp" +#include "base/value.hpp" +#include "base/dictionary.hpp" +#include <vector> + +namespace icinga +{ + +/** + * A function available over the internal cluster API. + * + * @ingroup base + */ +class ApiFunction final : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(ApiFunction); + + typedef std::function<Value(const MessageOrigin::Ptr& origin, const Dictionary::Ptr&)> Callback; + + ApiFunction(Callback function); + + Value Invoke(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& arguments); + + static ApiFunction::Ptr GetByName(const String& name); + static void Register(const String& name, const ApiFunction::Ptr& function); + static void Unregister(const String& name); + +private: + Callback m_Callback; +}; + +/** + * A registry for API functions. + * + * @ingroup base + */ +class ApiFunctionRegistry : public Registry<ApiFunctionRegistry, ApiFunction::Ptr> +{ +public: + static ApiFunctionRegistry *GetInstance(); +}; + +#define REGISTER_APIFUNCTION(name, ns, callback) \ + INITIALIZE_ONCE([]() { \ + ApiFunction::Ptr func = new ApiFunction(callback); \ + ApiFunctionRegistry::GetInstance()->Register(#ns "::" #name, func); \ + }) + +} + +#endif /* APIFUNCTION_H */ diff --git a/lib/remote/apilistener-authority.cpp b/lib/remote/apilistener-authority.cpp new file mode 100644 index 0000000..f33a190 --- /dev/null +++ b/lib/remote/apilistener-authority.cpp @@ -0,0 +1,84 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/zone.hpp" +#include "remote/apilistener.hpp" +#include "base/configtype.hpp" +#include "base/utility.hpp" +#include "base/convert.hpp" + +using namespace icinga; + +std::atomic<bool> ApiListener::m_UpdatedObjectAuthority (false); + +void ApiListener::UpdateObjectAuthority() +{ + /* Always run this, even if there is no 'api' feature enabled. */ + if (auto listener = ApiListener::GetInstance()) { + Log(LogNotice, "ApiListener") + << "Updating object authority for objects at endpoint '" << listener->GetIdentity() << "'."; + } else { + Log(LogNotice, "ApiListener") + << "Updating object authority for local objects."; + } + + Zone::Ptr my_zone = Zone::GetLocalZone(); + + std::vector<Endpoint::Ptr> endpoints; + Endpoint::Ptr my_endpoint; + + if (my_zone) { + my_endpoint = Endpoint::GetLocalEndpoint(); + + int num_total = 0; + + for (const Endpoint::Ptr& endpoint : my_zone->GetEndpoints()) { + num_total++; + + if (endpoint != my_endpoint && !endpoint->GetConnected()) + continue; + + endpoints.push_back(endpoint); + } + + double startTime = Application::GetStartTime(); + + /* 30 seconds cold startup, don't update any authority to give the secondary endpoint time to reconnect. */ + if (num_total > 1 && endpoints.size() <= 1 && (startTime == 0 || Utility::GetTime() - startTime < 30)) + return; + + std::sort(endpoints.begin(), endpoints.end(), + [](const ConfigObject::Ptr& a, const ConfigObject::Ptr& b) { + return a->GetName() < b->GetName(); + } + ); + } + + for (const Type::Ptr& type : Type::GetAllTypes()) { + auto *dtype = dynamic_cast<ConfigType *>(type.get()); + + if (!dtype) + continue; + + for (const ConfigObject::Ptr& object : dtype->GetObjects()) { + if (!object->IsActive() || object->GetHAMode() != HARunOnce) + continue; + + bool authority; + + if (!my_zone) + authority = true; + else + authority = endpoints[Utility::SDBM(object->GetName()) % endpoints.size()] == my_endpoint; + +#ifdef I2_DEBUG +// //Enable on demand, causes heavy logging on each run. +// Log(LogDebug, "ApiListener") +// << "Setting authority '" << Convert::ToString(authority) << "' for object '" << object->GetName() << "' of type '" << object->GetReflectionType()->GetName() << "'."; +#endif /* I2_DEBUG */ + + object->SetAuthority(authority); + } + } + + m_UpdatedObjectAuthority.store(true); +} diff --git a/lib/remote/apilistener-configsync.cpp b/lib/remote/apilistener-configsync.cpp new file mode 100644 index 0000000..6271411 --- /dev/null +++ b/lib/remote/apilistener-configsync.cpp @@ -0,0 +1,474 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/apilistener.hpp" +#include "remote/apifunction.hpp" +#include "remote/configobjectutility.hpp" +#include "remote/jsonrpc.hpp" +#include "base/configtype.hpp" +#include "base/json.hpp" +#include "base/convert.hpp" +#include "config/vmops.hpp" +#include <fstream> + +using namespace icinga; + +REGISTER_APIFUNCTION(UpdateObject, config, &ApiListener::ConfigUpdateObjectAPIHandler); +REGISTER_APIFUNCTION(DeleteObject, config, &ApiListener::ConfigDeleteObjectAPIHandler); + +INITIALIZE_ONCE([]() { + ConfigObject::OnActiveChanged.connect(&ApiListener::ConfigUpdateObjectHandler); + ConfigObject::OnVersionChanged.connect(&ApiListener::ConfigUpdateObjectHandler); +}); + +void ApiListener::ConfigUpdateObjectHandler(const ConfigObject::Ptr& object, const Value& cookie) +{ + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) + return; + + if (object->IsActive()) { + /* Sync object config */ + listener->UpdateConfigObject(object, cookie); + } else if (!object->IsActive() && object->GetExtension("ConfigObjectDeleted")) { + /* Delete object */ + listener->DeleteConfigObject(object, cookie); + } +} + +Value ApiListener::ConfigUpdateObjectAPIHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + Log(LogNotice, "ApiListener") + << "Received config update for object: " << JsonEncode(params); + + /* check permissions */ + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) + return Empty; + + String objType = params->Get("type"); + String objName = params->Get("name"); + + Endpoint::Ptr endpoint = origin->FromClient->GetEndpoint(); + + String identity = origin->FromClient->GetIdentity(); + + /* discard messages if the client is not configured on this node */ + if (!endpoint) { + Log(LogNotice, "ApiListener") + << "Discarding 'config update object' message from '" << identity << "': Invalid endpoint origin (client not allowed)."; + return Empty; + } + + Zone::Ptr endpointZone = endpoint->GetZone(); + + /* discard messages if the sender is in a child zone */ + if (!Zone::GetLocalZone()->IsChildOf(endpointZone)) { + Log(LogNotice, "ApiListener") + << "Discarding 'config update object' message" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << objName << "' of type '" << objType << "'. Sender is in a child zone."; + return Empty; + } + + String objZone = params->Get("zone"); + + if (!objZone.IsEmpty() && !Zone::GetByName(objZone)) { + Log(LogNotice, "ApiListener") + << "Discarding 'config update object' message" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << objName << "' of type '" << objType << "'. Objects zone '" << objZone << "' isn't known locally."; + return Empty; + } + + /* ignore messages if the endpoint does not accept config */ + if (!listener->GetAcceptConfig()) { + Log(LogWarning, "ApiListener") + << "Ignoring config update" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << objName << "' of type '" << objType << "'. '" << listener->GetName() << "' does not accept config."; + return Empty; + } + + /* update the object */ + double objVersion = params->Get("version"); + + Type::Ptr ptype = Type::GetByName(objType); + auto *ctype = dynamic_cast<ConfigType *>(ptype.get()); + + if (!ctype) { + // This never happens with icinga cluster endpoints, only with development errors. + Log(LogCritical, "ApiListener") + << "Config type '" << objType << "' does not exist."; + return Empty; + } + + ConfigObject::Ptr object = ctype->GetObject(objName); + + String config = params->Get("config"); + + bool newObject = false; + + if (!object && !config.IsEmpty()) { + newObject = true; + + /* object does not exist, create it through the API */ + Array::Ptr errors = new Array(); + + /* + * Create the config object through our internal API. + * IMPORTANT: Pass the origin to prevent cluster sync loops. + */ + if (!ConfigObjectUtility::CreateObject(ptype, objName, config, errors, nullptr, origin)) { + Log(LogCritical, "ApiListener") + << "Could not create object '" << objName << "':"; + + ObjectLock olock(errors); + for (const String& error : errors) { + Log(LogCritical, "ApiListener", error); + } + + return Empty; + } + + object = ctype->GetObject(objName); + + if (!object) + return Empty; + + /* object was created, update its version */ + object->SetVersion(objVersion, false, origin); + } + + if (!object) + return Empty; + + /* update object attributes if version was changed or if this is a new object */ + if (newObject || objVersion <= object->GetVersion()) { + Log(LogNotice, "ApiListener") + << "Discarding config update" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << object->GetName() + << "': Object version " << std::fixed << object->GetVersion() + << " is more recent than the received version " << std::fixed << objVersion << "."; + + return Empty; + } + + Log(LogNotice, "ApiListener") + << "Processing config update" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << object->GetName() + << "': Object version " << object->GetVersion() + << " is older than the received version " << objVersion << "."; + + Dictionary::Ptr modified_attributes = params->Get("modified_attributes"); + + if (modified_attributes) { + ObjectLock olock(modified_attributes); + for (const Dictionary::Pair& kv : modified_attributes) { + /* update all modified attributes + * but do not update the object version yet. + * This triggers cluster events otherwise. + */ + object->ModifyAttribute(kv.first, kv.second, false); + } + } + + /* check whether original attributes changed and restore them locally */ + Array::Ptr newOriginalAttributes = params->Get("original_attributes"); + Dictionary::Ptr objOriginalAttributes = object->GetOriginalAttributes(); + + if (newOriginalAttributes && objOriginalAttributes) { + std::vector<String> restoreAttrs; + + { + ObjectLock xlock(objOriginalAttributes); + for (const Dictionary::Pair& kv : objOriginalAttributes) { + /* original attribute was removed, restore it */ + if (!newOriginalAttributes->Contains(kv.first)) + restoreAttrs.push_back(kv.first); + } + } + + for (const String& key : restoreAttrs) { + /* do not update the object version yet. */ + object->RestoreAttribute(key, false); + } + } + + /* keep the object version in sync with the sender */ + object->SetVersion(objVersion, false, origin); + + return Empty; +} + +Value ApiListener::ConfigDeleteObjectAPIHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + Log(LogNotice, "ApiListener") + << "Received config delete for object: " << JsonEncode(params); + + /* check permissions */ + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) + return Empty; + + String objType = params->Get("type"); + String objName = params->Get("name"); + + Endpoint::Ptr endpoint = origin->FromClient->GetEndpoint(); + + String identity = origin->FromClient->GetIdentity(); + + if (!endpoint) { + Log(LogNotice, "ApiListener") + << "Discarding 'config delete object' message from '" << identity << "': Invalid endpoint origin (client not allowed)."; + return Empty; + } + + Zone::Ptr endpointZone = endpoint->GetZone(); + + /* discard messages if the sender is in a child zone */ + if (!Zone::GetLocalZone()->IsChildOf(endpointZone)) { + Log(LogNotice, "ApiListener") + << "Discarding 'config delete object' message" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << objName << "' of type '" << objType << "'. Sender is in a child zone."; + return Empty; + } + + if (!listener->GetAcceptConfig()) { + Log(LogWarning, "ApiListener") + << "Ignoring config delete" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << objName << "' of type '" << objType << "'. '" << listener->GetName() << "' does not accept config."; + return Empty; + } + + /* delete the object */ + Type::Ptr ptype = Type::GetByName(objType); + auto *ctype = dynamic_cast<ConfigType *>(ptype.get()); + + if (!ctype) { + // This never happens with icinga cluster endpoints, only with development errors. + Log(LogCritical, "ApiListener") + << "Config type '" << objType << "' does not exist."; + return Empty; + } + + ConfigObject::Ptr object = ctype->GetObject(objName); + + if (!object) { + Log(LogNotice, "ApiListener") + << "Could not delete non-existent object '" << objName << "' with type '" << params->Get("type") << "'."; + return Empty; + } + + if (object->GetPackage() != "_api") { + Log(LogCritical, "ApiListener") + << "Could not delete object '" << objName << "': Not created by the API."; + return Empty; + } + + Log(LogNotice, "ApiListener") + << "Processing config delete" + << " from '" << identity << "' (endpoint: '" << endpoint->GetName() << "', zone: '" << endpointZone->GetName() << "')" + << " for object '" << object->GetName() << "'."; + + Array::Ptr errors = new Array(); + + /* + * Delete the config object through our internal API. + * IMPORTANT: Pass the origin to prevent cluster sync loops. + */ + if (!ConfigObjectUtility::DeleteObject(object, true, errors, nullptr, origin)) { + Log(LogCritical, "ApiListener", "Could not delete object:"); + + ObjectLock olock(errors); + for (const String& error : errors) { + Log(LogCritical, "ApiListener", error); + } + } + + return Empty; +} + +void ApiListener::UpdateConfigObject(const ConfigObject::Ptr& object, const MessageOrigin::Ptr& origin, + const JsonRpcConnection::Ptr& client) +{ + /* only send objects to zones which have access to the object */ + if (client) { + Zone::Ptr target_zone = client->GetEndpoint()->GetZone(); + + if (target_zone && !target_zone->CanAccessObject(object)) { + Log(LogDebug, "ApiListener") + << "Not sending 'update config' message to unauthorized zone '" << target_zone->GetName() << "'" + << " for object: '" << object->GetName() << "'."; + + return; + } + } + + if (object->GetPackage() != "_api" && object->GetVersion() == 0) + return; + + Dictionary::Ptr params = new Dictionary(); + + Dictionary::Ptr message = new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "config::UpdateObject" }, + { "params", params } + }); + + params->Set("name", object->GetName()); + params->Set("type", object->GetReflectionType()->GetName()); + params->Set("version", object->GetVersion()); + + String zoneName = object->GetZoneName(); + + if (!zoneName.IsEmpty()) + params->Set("zone", zoneName); + + if (object->GetPackage() == "_api") { + String file; + + try { + file = ConfigObjectUtility::GetObjectConfigPath(object->GetReflectionType(), object->GetName()); + } catch (const std::exception& ex) { + Log(LogNotice, "ApiListener") + << "Cannot sync object '" << object->GetName() << "': " << ex.what(); + return; + } + + std::ifstream fp(file.CStr(), std::ifstream::binary); + if (!fp) + return; + + String content((std::istreambuf_iterator<char>(fp)), std::istreambuf_iterator<char>()); + params->Set("config", content); + } + + Dictionary::Ptr original_attributes = object->GetOriginalAttributes(); + Dictionary::Ptr modified_attributes = new Dictionary(); + ArrayData newOriginalAttributes; + + if (original_attributes) { + ObjectLock olock(original_attributes); + for (const Dictionary::Pair& kv : original_attributes) { + std::vector<String> tokens = kv.first.Split("."); + + Value value = object; + for (const String& token : tokens) { + value = VMOps::GetField(value, token); + } + + modified_attributes->Set(kv.first, value); + + newOriginalAttributes.push_back(kv.first); + } + } + + params->Set("modified_attributes", modified_attributes); + + /* only send the original attribute keys */ + params->Set("original_attributes", new Array(std::move(newOriginalAttributes))); + +#ifdef I2_DEBUG + Log(LogDebug, "ApiListener") + << "Sent update for object '" << object->GetName() << "': " << JsonEncode(params); +#endif /* I2_DEBUG */ + + if (client) + client->SendMessage(message); + else { + Zone::Ptr target = static_pointer_cast<Zone>(object->GetZone()); + + if (!target) + target = Zone::GetLocalZone(); + + RelayMessage(origin, target, message, false); + } +} + + +void ApiListener::DeleteConfigObject(const ConfigObject::Ptr& object, const MessageOrigin::Ptr& origin, + const JsonRpcConnection::Ptr& client) +{ + if (object->GetPackage() != "_api") + return; + + /* only send objects to zones which have access to the object */ + if (client) { + Zone::Ptr target_zone = client->GetEndpoint()->GetZone(); + + if (target_zone && !target_zone->CanAccessObject(object)) { + Log(LogDebug, "ApiListener") + << "Not sending 'delete config' message to unauthorized zone '" << target_zone->GetName() << "'" + << " for object: '" << object->GetName() << "'."; + + return; + } + } + + Dictionary::Ptr params = new Dictionary(); + + Dictionary::Ptr message = new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "config::DeleteObject" }, + { "params", params } + }); + + params->Set("name", object->GetName()); + params->Set("type", object->GetReflectionType()->GetName()); + params->Set("version", object->GetVersion()); + + +#ifdef I2_DEBUG + Log(LogDebug, "ApiListener") + << "Sent delete for object '" << object->GetName() << "': " << JsonEncode(params); +#endif /* I2_DEBUG */ + + if (client) + client->SendMessage(message); + else { + Zone::Ptr target = static_pointer_cast<Zone>(object->GetZone()); + + if (!target) + target = Zone::GetLocalZone(); + + RelayMessage(origin, target, message, true); + } +} + +/* Initial sync on connect for new endpoints */ +void ApiListener::SendRuntimeConfigObjects(const JsonRpcConnection::Ptr& aclient) +{ + Endpoint::Ptr endpoint = aclient->GetEndpoint(); + ASSERT(endpoint); + + Zone::Ptr azone = endpoint->GetZone(); + + Log(LogInformation, "ApiListener") + << "Syncing runtime objects to endpoint '" << endpoint->GetName() << "'."; + + for (const Type::Ptr& type : Type::GetAllTypes()) { + auto *dtype = dynamic_cast<ConfigType *>(type.get()); + + if (!dtype) + continue; + + for (const ConfigObject::Ptr& object : dtype->GetObjects()) { + /* don't sync objects for non-matching parent-child zones */ + if (!azone->CanAccessObject(object)) + continue; + + /* send the config object to the connected client */ + UpdateConfigObject(object, nullptr, aclient); + } + } + + Log(LogInformation, "ApiListener") + << "Finished syncing runtime objects to endpoint '" << endpoint->GetName() << "'."; +} diff --git a/lib/remote/apilistener-filesync.cpp b/lib/remote/apilistener-filesync.cpp new file mode 100644 index 0000000..c181c6d --- /dev/null +++ b/lib/remote/apilistener-filesync.cpp @@ -0,0 +1,887 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/apilistener.hpp" +#include "remote/apifunction.hpp" +#include "config/configcompiler.hpp" +#include "base/tlsutility.hpp" +#include "base/json.hpp" +#include "base/configtype.hpp" +#include "base/logger.hpp" +#include "base/convert.hpp" +#include "base/application.hpp" +#include "base/exception.hpp" +#include "base/shared.hpp" +#include "base/utility.hpp" +#include <fstream> +#include <iomanip> +#include <thread> + +using namespace icinga; + +REGISTER_APIFUNCTION(Update, config, &ApiListener::ConfigUpdateHandler); + +std::mutex ApiListener::m_ConfigSyncStageLock; + +/** + * Entrypoint for updating all authoritative configs from /etc/zones.d, packages, etc. + * into var/lib/icinga2/api/zones + */ +void ApiListener::SyncLocalZoneDirs() const +{ + for (const Zone::Ptr& zone : ConfigType::GetObjectsByType<Zone>()) { + try { + SyncLocalZoneDir(zone); + } catch (const std::exception&) { + continue; + } + } +} + +/** + * Sync a zone directory where we have an authoritative copy (zones.d, packages, etc.) + * + * This function collects the registered zone config dirs from + * the config compiler and reads the file content into the config + * information structure. + * + * Returns early when there are no updates. + * + * @param zone Pointer to the zone object being synced. + */ +void ApiListener::SyncLocalZoneDir(const Zone::Ptr& zone) const +{ + if (!zone) + return; + + ConfigDirInformation newConfigInfo; + newConfigInfo.UpdateV1 = new Dictionary(); + newConfigInfo.UpdateV2 = new Dictionary(); + newConfigInfo.Checksums = new Dictionary(); + + String zoneName = zone->GetName(); + + // Load registered zone paths, e.g. '_etc', '_api' and user packages. + for (const ZoneFragment& zf : ConfigCompiler::GetZoneDirs(zoneName)) { + ConfigDirInformation newConfigPart = LoadConfigDir(zf.Path); + + // Config files '*.conf'. + { + ObjectLock olock(newConfigPart.UpdateV1); + for (const Dictionary::Pair& kv : newConfigPart.UpdateV1) { + String path = "/" + zf.Tag + kv.first; + + newConfigInfo.UpdateV1->Set(path, kv.second); + newConfigInfo.Checksums->Set(path, GetChecksum(kv.second)); + } + } + + // Meta files. + { + ObjectLock olock(newConfigPart.UpdateV2); + for (const Dictionary::Pair& kv : newConfigPart.UpdateV2) { + String path = "/" + zf.Tag + kv.first; + + newConfigInfo.UpdateV2->Set(path, kv.second); + newConfigInfo.Checksums->Set(path, GetChecksum(kv.second)); + } + } + } + + size_t sumUpdates = newConfigInfo.UpdateV1->GetLength() + newConfigInfo.UpdateV2->GetLength(); + + // Return early if there are no updates. + if (sumUpdates == 0) + return; + + String productionZonesDir = GetApiZonesDir() + zoneName; + + Log(LogInformation, "ApiListener") + << "Copying " << sumUpdates << " zone configuration files for zone '" << zoneName << "' to '" << productionZonesDir << "'."; + + // Purge files to allow deletion via zones.d. + if (Utility::PathExists(productionZonesDir)) + Utility::RemoveDirRecursive(productionZonesDir); + + Utility::MkDirP(productionZonesDir, 0700); + + // Copy content and add additional meta data. + size_t numBytes = 0; + + /* Note: We cannot simply copy directories here. + * + * Zone directories are registered from everywhere and we already + * have read their content into memory with LoadConfigDir(). + */ + Dictionary::Ptr newConfig = MergeConfigUpdate(newConfigInfo); + + { + ObjectLock olock(newConfig); + + for (const Dictionary::Pair& kv : newConfig) { + String dst = productionZonesDir + "/" + kv.first; + + Utility::MkDirP(Utility::DirName(dst), 0755); + + Log(LogInformation, "ApiListener") + << "Updating configuration file: " << dst; + + String content = kv.second; + + std::ofstream fp(dst.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + + fp << content; + fp.close(); + + numBytes += content.GetLength(); + } + } + + // Additional metadata. + String tsPath = productionZonesDir + "/.timestamp"; + + if (!Utility::PathExists(tsPath)) { + std::ofstream fp(tsPath.CStr(), std::ofstream::out | std::ostream::trunc); + + fp << std::fixed << Utility::GetTime(); + fp.close(); + } + + String authPath = productionZonesDir + "/.authoritative"; + + if (!Utility::PathExists(authPath)) { + std::ofstream fp(authPath.CStr(), std::ofstream::out | std::ostream::trunc); + fp.close(); + } + + // Checksums. + String checksumsPath = productionZonesDir + "/.checksums"; + + if (Utility::PathExists(checksumsPath)) + Utility::Remove(checksumsPath); + + std::ofstream fp(checksumsPath.CStr(), std::ofstream::out | std::ostream::trunc); + + fp << std::fixed << JsonEncode(newConfigInfo.Checksums); + fp.close(); + + Log(LogNotice, "ApiListener") + << "Updated meta data for cluster config sync. Checksum: '" << checksumsPath + << "', timestamp: '" << tsPath << "', auth: '" << authPath << "'."; +} + +/** + * Entrypoint for sending a file based config update to a cluster client. + * This includes security checks for zone relations. + * Loads the zone config files where this client belongs to + * and sends the 'config::Update' JSON-RPC message. + * + * @param aclient Connected JSON-RPC client. + */ +void ApiListener::SendConfigUpdate(const JsonRpcConnection::Ptr& aclient) +{ + Endpoint::Ptr endpoint = aclient->GetEndpoint(); + ASSERT(endpoint); + + Zone::Ptr clientZone = endpoint->GetZone(); + Zone::Ptr localZone = Zone::GetLocalZone(); + + // Don't send config updates to parent zones + if (!clientZone->IsChildOf(localZone)) + return; + + Dictionary::Ptr configUpdateV1 = new Dictionary(); + Dictionary::Ptr configUpdateV2 = new Dictionary(); + Dictionary::Ptr configUpdateChecksums = new Dictionary(); // new since 2.11 + + String zonesDir = GetApiZonesDir(); + + for (const Zone::Ptr& zone : ConfigType::GetObjectsByType<Zone>()) { + String zoneName = zone->GetName(); + String zoneDir = zonesDir + zoneName; + + // Only sync child and global zones. + if (!zone->IsChildOf(clientZone) && !zone->IsGlobal()) + continue; + + // Zone was configured, but there's no configuration directory. + if (!Utility::PathExists(zoneDir)) + continue; + + Log(LogInformation, "ApiListener") + << "Syncing configuration files for " << (zone->IsGlobal() ? "global " : "") + << "zone '" << zoneName << "' to endpoint '" << endpoint->GetName() << "'."; + + ConfigDirInformation config = LoadConfigDir(zoneDir); + + configUpdateV1->Set(zoneName, config.UpdateV1); + configUpdateV2->Set(zoneName, config.UpdateV2); + configUpdateChecksums->Set(zoneName, config.Checksums); // new since 2.11 + } + + Dictionary::Ptr message = new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "config::Update" }, + { "params", new Dictionary({ + { "update", configUpdateV1 }, + { "update_v2", configUpdateV2 }, // Since 2.4.2. + { "checksums", configUpdateChecksums } // Since 2.11.0. + }) } + }); + + aclient->SendMessage(message); +} + +static bool CompareTimestampsConfigChange(const Dictionary::Ptr& productionConfig, const Dictionary::Ptr& receivedConfig, + const String& stageConfigZoneDir) +{ + double productionTimestamp; + double receivedTimestamp; + + // Missing production timestamp means that something really broke. Always trigger a config change then. + if (!productionConfig->Contains("/.timestamp")) + productionTimestamp = 0; + else + productionTimestamp = productionConfig->Get("/.timestamp"); + + // Missing received config timestamp means that something really broke. Always trigger a config change then. + if (!receivedConfig->Contains("/.timestamp")) + receivedTimestamp = Utility::GetTime() + 10; + else + receivedTimestamp = receivedConfig->Get("/.timestamp"); + + bool configChange; + + // Skip update if our configuration files are more recent. + if (productionTimestamp >= receivedTimestamp) { + + Log(LogInformation, "ApiListener") + << "Our production configuration is more recent than the received configuration update." + << " Ignoring configuration file update for path '" << stageConfigZoneDir << "'. Current timestamp '" + << Utility::FormatDateTime("%Y-%m-%d %H:%M:%S %z", productionTimestamp) << "' (" + << std::fixed << std::setprecision(6) << productionTimestamp + << ") >= received timestamp '" + << Utility::FormatDateTime("%Y-%m-%d %H:%M:%S %z", receivedTimestamp) << "' (" + << receivedTimestamp << ")."; + + configChange = false; + + } else { + configChange = true; + } + + // Update the .timestamp file inside the staging directory. + String tsPath = stageConfigZoneDir + "/.timestamp"; + + if (!Utility::PathExists(tsPath)) { + std::ofstream fp(tsPath.CStr(), std::ofstream::out | std::ostream::trunc); + fp << std::fixed << receivedTimestamp; + fp.close(); + } + + return configChange; +} + +/** + * Registered handler when a new config::Update message is received. + * + * Checks destination and permissions first, locks the transaction and analyses the update. + * The newly received configuration is not copied to production immediately, + * but into the staging directory first. + * Last, the async validation and restart is triggered. + * + * @param origin Where this message came from. + * @param params Message parameters including the config updates. + * @returns Empty, required by the interface. + */ +Value ApiListener::ConfigUpdateHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + // Verify permissions and trust relationship. + if (!origin->FromClient->GetEndpoint() || (origin->FromZone && !Zone::GetLocalZone()->IsChildOf(origin->FromZone))) + return Empty; + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) { + Log(LogCritical, "ApiListener", "No instance available."); + return Empty; + } + + if (!listener->GetAcceptConfig()) { + Log(LogWarning, "ApiListener") + << "Ignoring config update. '" << listener->GetName() << "' does not accept config."; + return Empty; + } + + std::thread([origin, params, listener]() { + try { + listener->HandleConfigUpdate(origin, params); + } catch (const std::exception& ex) { + auto msg ("Exception during config sync: " + DiagnosticInformation(ex)); + + Log(LogCritical, "ApiListener") << msg; + listener->UpdateLastFailedZonesStageValidation(msg); + } + }).detach(); + return Empty; +} + +void ApiListener::HandleConfigUpdate(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + /* Only one transaction is allowed, concurrent message handlers need to wait. + * This affects two parent endpoints sending the config in the same moment. + */ + std::lock_guard<std::mutex> lock(m_ConfigSyncStageLock); + + String apiZonesStageDir = GetApiZonesStageDir(); + String fromEndpointName = origin->FromClient->GetEndpoint()->GetName(); + String fromZoneName = GetFromZoneName(origin->FromZone); + + Log(LogInformation, "ApiListener") + << "Applying config update from endpoint '" << fromEndpointName + << "' of zone '" << fromZoneName << "'."; + + // Config files. + Dictionary::Ptr updateV1 = params->Get("update"); + // Meta data files: .timestamp, etc. + Dictionary::Ptr updateV2 = params->Get("update_v2"); + + // New since 2.11.0. + Dictionary::Ptr checksums; + + if (params->Contains("checksums")) + checksums = params->Get("checksums"); + + bool configChange = false; + + // Keep track of the relative config paths for later validation and copying. TODO: Find a better algorithm. + std::vector<String> relativePaths; + + /* + * We can and must safely purge the staging directory, as the difference is taken between + * runtime production config and newly received configuration. + * This is needed to not mix deleted/changed content between received and stage + * config. + */ + if (Utility::PathExists(apiZonesStageDir)) + Utility::RemoveDirRecursive(apiZonesStageDir); + + Utility::MkDirP(apiZonesStageDir, 0700); + + // Analyse and process the update. + size_t count = 0; + + ObjectLock olock(updateV1); + + for (const Dictionary::Pair& kv : updateV1) { + + // Check for the configured zones. + String zoneName = kv.first; + Zone::Ptr zone = Zone::GetByName(zoneName); + + if (!zone) { + Log(LogWarning, "ApiListener") + << "Ignoring config update from endpoint '" << fromEndpointName + << "' for unknown zone '" << zoneName << "'."; + + continue; + } + + // Ignore updates where we have an authoritive copy in etc/zones.d, packages, etc. + if (ConfigCompiler::HasZoneConfigAuthority(zoneName)) { + Log(LogInformation, "ApiListener") + << "Ignoring config update from endpoint '" << fromEndpointName + << "' for zone '" << zoneName << "' because we have an authoritative version of the zone's config."; + + continue; + } + + // Put the received configuration into our stage directory. + String productionConfigZoneDir = GetApiZonesDir() + zoneName; + String stageConfigZoneDir = GetApiZonesStageDir() + zoneName; + + Utility::MkDirP(productionConfigZoneDir, 0700); + Utility::MkDirP(stageConfigZoneDir, 0700); + + // Merge the config information. + ConfigDirInformation newConfigInfo; + newConfigInfo.UpdateV1 = kv.second; + + // Load metadata. + if (updateV2) + newConfigInfo.UpdateV2 = updateV2->Get(kv.first); + + // Load checksums. New since 2.11. + if (checksums) + newConfigInfo.Checksums = checksums->Get(kv.first); + + // Load the current production config details. + ConfigDirInformation productionConfigInfo = LoadConfigDir(productionConfigZoneDir); + + // Merge updateV1 and updateV2 + Dictionary::Ptr productionConfig = MergeConfigUpdate(productionConfigInfo); + Dictionary::Ptr newConfig = MergeConfigUpdate(newConfigInfo); + + bool timestampChanged = false; + + if (CompareTimestampsConfigChange(productionConfig, newConfig, stageConfigZoneDir)) { + timestampChanged = true; + } + + /* If we have received 'checksums' via cluster message, go for it. + * Otherwise do the old timestamp dance for versions < 2.11. + */ + if (checksums) { + Log(LogInformation, "ApiListener") + << "Received configuration for zone '" << zoneName << "' from endpoint '" + << fromEndpointName << "'. Comparing the timestamp and checksums."; + + if (timestampChanged) { + + if (CheckConfigChange(productionConfigInfo, newConfigInfo)) + configChange = true; + } + + } else { + /* Fallback to timestamp handling when the parent endpoint didn't send checks. + * This can happen when the satellite is 2.11 and the master is 2.10. + * + * TODO: Deprecate and remove this behaviour in 2.13+. + */ + + Log(LogWarning, "ApiListener") + << "Received configuration update without checksums from parent endpoint " + << fromEndpointName << ". This behaviour is deprecated. Please upgrade the parent endpoint to 2.11+"; + + if (timestampChanged) { + configChange = true; + } + + // Keep another hack when there's a timestamp file missing. + { + ObjectLock olock(newConfig); + + for (const Dictionary::Pair &kv : newConfig) { + + // This is super expensive with a string content comparison. + if (productionConfig->Get(kv.first) != kv.second) { + if (!Utility::Match("*/.timestamp", kv.first)) + configChange = true; + } + } + } + } + + // Dump the received configuration for this zone into the stage directory. + size_t numBytes = 0; + + { + ObjectLock olock(newConfig); + + for (const Dictionary::Pair& kv : newConfig) { + + /* Store the relative config file path for later validation and activation. + * IMPORTANT: Store this prior to any filters. + * */ + relativePaths.push_back(zoneName + "/" + kv.first); + + String path = stageConfigZoneDir + "/" + kv.first; + + if (Utility::Match("*.conf", path)) { + Log(LogInformation, "ApiListener") + << "Stage: Updating received configuration file '" << path << "' for zone '" << zoneName << "'."; + } + + // Parent nodes < 2.11 always send this, avoid this bug and deny its receival prior to writing it on disk. + if (Utility::BaseName(path) == ".authoritative") + continue; + + // Sync string content only. + String content = kv.second; + + // Generate a directory tree (zones/1/2/3 might not exist yet). + Utility::MkDirP(Utility::DirName(path), 0755); + + // Write the content to file. + std::ofstream fp(path.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fp << content; + fp.close(); + + numBytes += content.GetLength(); + } + } + + Log(LogInformation, "ApiListener") + << "Applying configuration file update for path '" << stageConfigZoneDir << "' (" + << numBytes << " Bytes)."; + + if (timestampChanged) { + // If the update removes a path, delete it on disk and signal a config change. + ObjectLock xlock(productionConfig); + + for (const Dictionary::Pair& kv : productionConfig) { + if (!newConfig->Contains(kv.first)) { + configChange = true; + + String path = stageConfigZoneDir + "/" + kv.first; + Utility::Remove(path); + } + } + } + + count++; + } + + /* + * We have processed all configuration files and stored them in the staging directory. + * + * We need to store them locally for later analysis. A config change means + * that we will validate the configuration in a separate process sandbox, + * and only copy the configuration to production when everything is ok. + * + * A successful validation also triggers the final restart. + */ + if (configChange) { + Log(LogInformation, "ApiListener") + << "Received configuration updates (" << count << ") from endpoint '" << fromEndpointName + << "' are different to production, triggering validation and reload."; + TryActivateZonesStage(relativePaths); + } else { + Log(LogInformation, "ApiListener") + << "Received configuration updates (" << count << ") from endpoint '" << fromEndpointName + << "' are equal to production, skipping validation and reload."; + ClearLastFailedZonesStageValidation(); + } +} + +/** + * Spawns a new validation process with 'System.ZonesStageVarDir' set to override the config validation zone dirs with + * our current stage. Then waits for the validation result and if it was successful, the configuration is copied from + * stage to production and a restart is triggered. On validation failure, there is no restart and this is logged. + * + * The caller of this function must hold m_ConfigSyncStageLock. + * + * @param relativePaths Collected paths including the zone name, which are copied from stage to current directories. + */ +void ApiListener::TryActivateZonesStage(const std::vector<String>& relativePaths) +{ + VERIFY(Application::GetArgC() >= 1); + + /* Inherit parent process args. */ + Array::Ptr args = new Array({ + Application::GetExePath(Application::GetArgV()[0]), + }); + + for (int i = 1; i < Application::GetArgC(); i++) { + String argV = Application::GetArgV()[i]; + + if (argV == "-d" || argV == "--daemonize") + continue; + + args->Add(argV); + } + + args->Add("--validate"); + + // Set the ZonesStageDir. This creates our own local chroot without any additional automated zone includes. + args->Add("--define"); + args->Add("System.ZonesStageVarDir=" + GetApiZonesStageDir()); + + Process::Ptr process = new Process(Process::PrepareCommand(args)); + process->SetTimeout(Application::GetReloadTimeout()); + + process->Run(); + const ProcessResult& pr = process->WaitForResult(); + + String apiDir = GetApiDir(); + String apiZonesDir = GetApiZonesDir(); + String apiZonesStageDir = GetApiZonesStageDir(); + + String logFile = apiDir + "/zones-stage-startup.log"; + std::ofstream fpLog(logFile.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fpLog << pr.Output; + fpLog.close(); + + String statusFile = apiDir + "/zones-stage-status"; + std::ofstream fpStatus(statusFile.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fpStatus << pr.ExitStatus; + fpStatus.close(); + + // Validation went fine, copy stage and reload. + if (pr.ExitStatus == 0) { + Log(LogInformation, "ApiListener") + << "Config validation for stage '" << apiZonesStageDir << "' was OK, replacing into '" << apiZonesDir << "' and triggering reload."; + + // Purge production before copying stage. + if (Utility::PathExists(apiZonesDir)) + Utility::RemoveDirRecursive(apiZonesDir); + + Utility::MkDirP(apiZonesDir, 0700); + + // Copy all synced configuration files from stage to production. + for (const String& path : relativePaths) { + if (!Utility::PathExists(apiZonesStageDir + path)) + continue; + + Log(LogInformation, "ApiListener") + << "Copying file '" << path << "' from config sync staging to production zones directory."; + + String stagePath = apiZonesStageDir + path; + String currentPath = apiZonesDir + path; + + Utility::MkDirP(Utility::DirName(currentPath), 0700); + + Utility::CopyFile(stagePath, currentPath); + } + + // Clear any failed deployment before + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (listener) + listener->ClearLastFailedZonesStageValidation(); + + Application::RequestRestart(); + + // All good, return early. + return; + } + + String failedLogFile = apiDir + "/zones-stage-startup-last-failed.log"; + std::ofstream fpFailedLog(failedLogFile.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fpFailedLog << pr.Output; + fpFailedLog.close(); + + // Error case. + Log(LogCritical, "ApiListener") + << "Config validation failed for staged cluster config sync in '" << apiZonesStageDir + << "'. Aborting. Logs: '" << failedLogFile << "'"; + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (listener) + listener->UpdateLastFailedZonesStageValidation(pr.Output); +} + +/** + * Update the structure from the last failed validation output. + * Uses the current timestamp. + * + * @param log The process output from the config validation. + */ +void ApiListener::UpdateLastFailedZonesStageValidation(const String& log) +{ + Dictionary::Ptr lastFailedZonesStageValidation = new Dictionary({ + { "log", log }, + { "ts", Utility::GetTime() } + }); + + SetLastFailedZonesStageValidation(lastFailedZonesStageValidation); +} + +/** + * Clear the structure for the last failed reload. + * + */ +void ApiListener::ClearLastFailedZonesStageValidation() +{ + SetLastFailedZonesStageValidation(Dictionary::Ptr()); +} + +/** + * Generate a config checksum. + * + * @param content String content used for generating the checksum. + * @returns The checksum as string. + */ +String ApiListener::GetChecksum(const String& content) +{ + return SHA256(content); +} + +bool ApiListener::CheckConfigChange(const ConfigDirInformation& oldConfig, const ConfigDirInformation& newConfig) +{ + Dictionary::Ptr oldChecksums = oldConfig.Checksums; + Dictionary::Ptr newChecksums = newConfig.Checksums; + + // TODO: Figure out whether normal users need this for debugging. + Log(LogDebug, "ApiListener") + << "Checking for config change between stage and production. Old (" << oldChecksums->GetLength() << "): '" + << JsonEncode(oldChecksums) + << "' vs. new (" << newChecksums->GetLength() << "): '" + << JsonEncode(newChecksums) << "'."; + + /* Since internal files are synced here too, we can not depend on length. + * So we need to go through both checksum sets to cover the cases"everything is new" and "everything was deleted". + */ + { + ObjectLock olock(oldChecksums); + for (const Dictionary::Pair& kv : oldChecksums) { + String path = kv.first; + String oldChecksum = kv.second; + + /* Ignore internal files, especially .timestamp and .checksums. + * + * If we don't, this results in "always change" restart loops. + */ + if (Utility::Match("/.*", path)) { + Log(LogDebug, "ApiListener") + << "Ignoring old internal file '" << path << "'."; + + continue; + } + + Log(LogDebug, "ApiListener") + << "Checking " << path << " for old checksum: " << oldChecksum << "."; + + // Check if key exists first for more verbose logging. + // Note: Don't do this later on. + if (!newChecksums->Contains(path)) { + Log(LogDebug, "ApiListener") + << "File '" << path << "' was deleted by remote."; + + return true; + } + + String newChecksum = newChecksums->Get(path); + + if (newChecksum != kv.second) { + Log(LogDebug, "ApiListener") + << "Path '" << path << "' doesn't match old checksum '" + << oldChecksum << "' with new checksum '" << newChecksum << "'."; + + return true; + } + } + } + + { + ObjectLock olock(newChecksums); + for (const Dictionary::Pair& kv : newChecksums) { + String path = kv.first; + String newChecksum = kv.second; + + /* Ignore internal files, especially .timestamp and .checksums. + * + * If we don't, this results in "always change" restart loops. + */ + if (Utility::Match("/.*", path)) { + Log(LogDebug, "ApiListener") + << "Ignoring new internal file '" << path << "'."; + + continue; + } + + Log(LogDebug, "ApiListener") + << "Checking " << path << " for new checksum: " << newChecksum << "."; + + // Check if the checksum exists, checksums in both sets have already been compared + if (!oldChecksums->Contains(path)) { + Log(LogDebug, "ApiListener") + << "File '" << path << "' was added by remote."; + + return true; + } + } + } + + return false; +} + +/** + * Load the given config dir and read their file content into the config structure. + * + * @param dir Path to the config directory. + * @returns ConfigDirInformation structure. + */ +ConfigDirInformation ApiListener::LoadConfigDir(const String& dir) +{ + ConfigDirInformation config; + config.UpdateV1 = new Dictionary(); + config.UpdateV2 = new Dictionary(); + config.Checksums = new Dictionary(); + + Utility::GlobRecursive(dir, "*", [&config, dir](const String& file) { ConfigGlobHandler(config, dir, file); }, GlobFile); + return config; +} + +/** + * Read the given file and store it in the config information structure. + * Callback function for Glob(). + * + * @param config Reference to the config information object. + * @param path File path. + * @param file Full file name. + */ +void ApiListener::ConfigGlobHandler(ConfigDirInformation& config, const String& path, const String& file) +{ + // Avoid loading the authoritative marker for syncs at all cost. + if (Utility::BaseName(file) == ".authoritative") + return; + + CONTEXT("Creating config update for file '" + file + "'"); + + Log(LogNotice, "ApiListener") + << "Creating config update for file '" << file << "'."; + + std::ifstream fp(file.CStr(), std::ifstream::binary); + if (!fp) + return; + + String content((std::istreambuf_iterator<char>(fp)), std::istreambuf_iterator<char>()); + + Dictionary::Ptr update; + String relativePath = file.SubStr(path.GetLength()); + + /* + * 'update' messages contain conf files. 'update_v2' syncs everything else (.timestamp). + * + * **Keep this intact to stay compatible with older clients.** + */ + String sanitizedContent = Utility::ValidateUTF8(content); + + if (Utility::Match("*.conf", file)) { + update = config.UpdateV1; + + // Configuration files should be automatically sanitized with UTF8. + update->Set(relativePath, sanitizedContent); + } else { + update = config.UpdateV2; + + /* + * Ensure that only valid UTF8 content is being read for the cluster config sync. + * Binary files are not supported when wrapped into JSON encoded messages. + * Rationale: https://github.com/Icinga/icinga2/issues/7382 + */ + if (content != sanitizedContent) { + Log(LogCritical, "ApiListener") + << "Ignoring file '" << file << "' for cluster config sync: Does not contain valid UTF8. Binary files are not supported."; + return; + } + + update->Set(relativePath, content); + } + + /* Calculate a checksum for each file (and a global one later). + * + * IMPORTANT: Ignore the .authoritative file above, this must not be synced. + * */ + config.Checksums->Set(relativePath, GetChecksum(content)); +} + +/** + * Compatibility helper for merging config update v1 and v2 into a global result. + * + * @param config Config information structure. + * @returns Dictionary which holds the merged information. + */ +Dictionary::Ptr ApiListener::MergeConfigUpdate(const ConfigDirInformation& config) +{ + Dictionary::Ptr result = new Dictionary(); + + if (config.UpdateV1) + config.UpdateV1->CopyTo(result); + + if (config.UpdateV2) + config.UpdateV2->CopyTo(result); + + return result; +} diff --git a/lib/remote/apilistener.cpp b/lib/remote/apilistener.cpp new file mode 100644 index 0000000..472bd90 --- /dev/null +++ b/lib/remote/apilistener.cpp @@ -0,0 +1,1929 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/apilistener.hpp" +#include "remote/apilistener-ti.cpp" +#include "remote/jsonrpcconnection.hpp" +#include "remote/endpoint.hpp" +#include "remote/jsonrpc.hpp" +#include "remote/apifunction.hpp" +#include "remote/configpackageutility.hpp" +#include "remote/configobjectutility.hpp" +#include "base/convert.hpp" +#include "base/defer.hpp" +#include "base/io-engine.hpp" +#include "base/netstring.hpp" +#include "base/json.hpp" +#include "base/configtype.hpp" +#include "base/logger.hpp" +#include "base/objectlock.hpp" +#include "base/stdiostream.hpp" +#include "base/perfdatavalue.hpp" +#include "base/application.hpp" +#include "base/context.hpp" +#include "base/statsfunction.hpp" +#include "base/exception.hpp" +#include "base/tcpsocket.hpp" +#include <boost/asio/buffer.hpp> +#include <boost/asio/io_context_strand.hpp> +#include <boost/asio/ip/tcp.hpp> +#include <boost/asio/spawn.hpp> +#include <boost/asio/ssl/context.hpp> +#include <boost/date_time/posix_time/posix_time_duration.hpp> +#include <boost/lexical_cast.hpp> +#include <boost/regex.hpp> +#include <boost/system/error_code.hpp> +#include <boost/thread/locks.hpp> +#include <climits> +#include <cstdint> +#include <fstream> +#include <memory> +#include <openssl/ssl.h> +#include <openssl/tls1.h> +#include <openssl/x509.h> +#include <sstream> +#include <utility> + +using namespace icinga; + +REGISTER_TYPE(ApiListener); + +boost::signals2::signal<void(bool)> ApiListener::OnMasterChanged; +ApiListener::Ptr ApiListener::m_Instance; + +REGISTER_STATSFUNCTION(ApiListener, &ApiListener::StatsFunc); + +REGISTER_APIFUNCTION(Hello, icinga, &ApiListener::HelloAPIHandler); + +ApiListener::ApiListener() +{ + m_RelayQueue.SetName("ApiListener, RelayQueue"); + m_SyncQueue.SetName("ApiListener, SyncQueue"); +} + +String ApiListener::GetApiDir() +{ + return Configuration::DataDir + "/api/"; +} + +String ApiListener::GetApiZonesDir() +{ + return GetApiDir() + "zones/"; +} + +String ApiListener::GetApiZonesStageDir() +{ + return GetApiDir() + "zones-stage/"; +} + +String ApiListener::GetCertsDir() +{ + return Configuration::DataDir + "/certs/"; +} + +String ApiListener::GetCaDir() +{ + return Configuration::DataDir + "/ca/"; +} + +String ApiListener::GetCertificateRequestsDir() +{ + return Configuration::DataDir + "/certificate-requests/"; +} + +String ApiListener::GetDefaultCertPath() +{ + return GetCertsDir() + "/" + ScriptGlobal::Get("NodeName") + ".crt"; +} + +String ApiListener::GetDefaultKeyPath() +{ + return GetCertsDir() + "/" + ScriptGlobal::Get("NodeName") + ".key"; +} + +String ApiListener::GetDefaultCaPath() +{ + return GetCertsDir() + "/ca.crt"; +} + +double ApiListener::GetTlsHandshakeTimeout() const +{ + return Configuration::TlsHandshakeTimeout; +} + +void ApiListener::SetTlsHandshakeTimeout(double value, bool suppress_events, const Value& cookie) +{ + Configuration::TlsHandshakeTimeout = value; +} + +void ApiListener::CopyCertificateFile(const String& oldCertPath, const String& newCertPath) +{ + struct stat st1, st2; + + if (!oldCertPath.IsEmpty() && stat(oldCertPath.CStr(), &st1) >= 0 && (stat(newCertPath.CStr(), &st2) < 0 || st1.st_mtime > st2.st_mtime)) { + Log(LogWarning, "ApiListener") + << "Copying '" << oldCertPath << "' certificate file to '" << newCertPath << "'"; + + Utility::MkDirP(Utility::DirName(newCertPath), 0700); + Utility::CopyFile(oldCertPath, newCertPath); + } +} + +void ApiListener::OnConfigLoaded() +{ + if (m_Instance) + BOOST_THROW_EXCEPTION(ScriptError("Only one ApiListener object is allowed.", GetDebugInfo())); + + m_Instance = this; + + String defaultCertPath = GetDefaultCertPath(); + String defaultKeyPath = GetDefaultKeyPath(); + String defaultCaPath = GetDefaultCaPath(); + + /* Migrate certificate location < 2.8 to the new default path. */ + String oldCertPath = GetCertPath(); + String oldKeyPath = GetKeyPath(); + String oldCaPath = GetCaPath(); + + CopyCertificateFile(oldCertPath, defaultCertPath); + CopyCertificateFile(oldKeyPath, defaultKeyPath); + CopyCertificateFile(oldCaPath, defaultCaPath); + + if (!oldCertPath.IsEmpty() && !oldKeyPath.IsEmpty() && !oldCaPath.IsEmpty()) { + Log(LogWarning, "ApiListener", "Please read the upgrading documentation for v2.8: https://icinga.com/docs/icinga2/latest/doc/16-upgrading-icinga-2/"); + } + + /* Create the internal API object storage. */ + ConfigObjectUtility::CreateStorage(); + + /* Cache API packages and their active stage name. */ + UpdateActivePackageStagesCache(); + + /* set up SSL context */ + std::shared_ptr<X509> cert; + try { + cert = GetX509Certificate(defaultCertPath); + } catch (const std::exception&) { + BOOST_THROW_EXCEPTION(ScriptError("Cannot get certificate from cert path: '" + + defaultCertPath + "'.", GetDebugInfo())); + } + + try { + SetIdentity(GetCertificateCN(cert)); + } catch (const std::exception&) { + BOOST_THROW_EXCEPTION(ScriptError("Cannot get certificate common name from cert path: '" + + defaultCertPath + "'.", GetDebugInfo())); + } + + Log(LogInformation, "ApiListener") + << "My API identity: " << GetIdentity(); + + UpdateSSLContext(); +} + +std::shared_ptr<X509> ApiListener::RenewCert(const std::shared_ptr<X509>& cert) +{ + std::shared_ptr<EVP_PKEY> pubkey (X509_get_pubkey(cert.get()), EVP_PKEY_free); + auto subject (X509_get_subject_name(cert.get())); + auto cacert (GetX509Certificate(GetDefaultCaPath())); + auto newcert (CreateCertIcingaCA(pubkey.get(), subject)); + + /* verify that the new cert matches the CA we're using for the ApiListener; + * this ensures that the CA we have in /var/lib/icinga2/ca matches the one + * we're using for cluster connections (there's no point in sending a client + * a certificate it wouldn't be able to use to connect to us anyway) */ + try { + if (!VerifyCertificate(cacert, newcert, GetCrlPath())) { + Log(LogWarning, "ApiListener") + << "The CA in '" << GetDefaultCaPath() << "' does not match the CA which Icinga uses " + << "for its own cluster connections. This is most likely a configuration problem."; + + return nullptr; + } + } catch (const std::exception&) { } /* Swallow the exception on purpose, cacert will never be a non-CA certificate. */ + + return newcert; +} + +void ApiListener::UpdateSSLContext() +{ + auto ctx (SetupSslContext(GetDefaultCertPath(), GetDefaultKeyPath(), GetDefaultCaPath(), GetCrlPath(), GetCipherList(), GetTlsProtocolmin(), GetDebugInfo())); + + { + boost::unique_lock<decltype(m_SSLContextMutex)> lock (m_SSLContextMutex); + + m_SSLContext = std::move(ctx); + } + + for (const Endpoint::Ptr& endpoint : ConfigType::GetObjectsByType<Endpoint>()) { + for (const JsonRpcConnection::Ptr& client : endpoint->GetClients()) { + client->Disconnect(); + } + } + + for (const JsonRpcConnection::Ptr& client : m_AnonymousClients) { + client->Disconnect(); + } +} + +void ApiListener::OnAllConfigLoaded() +{ + m_LocalEndpoint = Endpoint::GetByName(GetIdentity()); + + if (!m_LocalEndpoint) + BOOST_THROW_EXCEPTION(ScriptError("Endpoint object for '" + GetIdentity() + "' is missing.", GetDebugInfo())); +} + +/** + * Starts the component. + */ +void ApiListener::Start(bool runtimeCreated) +{ + Log(LogInformation, "ApiListener") + << "'" << GetName() << "' started."; + + SyncLocalZoneDirs(); + + m_RenewOwnCertTimer = new Timer(); + + if (Utility::PathExists(GetIcingaCADir() + "/ca.key")) { + RenewOwnCert(); + m_RenewOwnCertTimer->OnTimerExpired.connect([this](const Timer * const&) { RenewOwnCert(); }); + } else { + m_RenewOwnCertTimer->OnTimerExpired.connect([this](const Timer * const&) { + JsonRpcConnection::SendCertificateRequest(nullptr, nullptr, String()); + }); + } + + m_RenewOwnCertTimer->SetInterval(RENEW_INTERVAL); + m_RenewOwnCertTimer->Start(); + + ObjectImpl<ApiListener>::Start(runtimeCreated); + + { + std::unique_lock<std::mutex> lock(m_LogLock); + OpenLogFile(); + } + + /* create the primary JSON-RPC listener */ + if (!AddListener(GetBindHost(), GetBindPort())) { + Log(LogCritical, "ApiListener") + << "Cannot add listener on host '" << GetBindHost() << "' for port '" << GetBindPort() << "'."; + Application::Exit(EXIT_FAILURE); + } + + m_Timer = new Timer(); + m_Timer->OnTimerExpired.connect([this](const Timer * const&) { ApiTimerHandler(); }); + m_Timer->SetInterval(5); + m_Timer->Start(); + m_Timer->Reschedule(0); + + m_ReconnectTimer = new Timer(); + m_ReconnectTimer->OnTimerExpired.connect([this](const Timer * const&) { ApiReconnectTimerHandler(); }); + m_ReconnectTimer->SetInterval(10); + m_ReconnectTimer->Start(); + m_ReconnectTimer->Reschedule(0); + + /* Keep this in relative sync with the cold startup in UpdateObjectAuthority() and the reconnect interval above. + * Previous: 60s reconnect, 30s OA, 60s cold startup. + * Now: 10s reconnect, 10s OA, 30s cold startup. + */ + m_AuthorityTimer = new Timer(); + m_AuthorityTimer->OnTimerExpired.connect([](const Timer * const&) { UpdateObjectAuthority(); }); + m_AuthorityTimer->SetInterval(10); + m_AuthorityTimer->Start(); + + m_CleanupCertificateRequestsTimer = new Timer(); + m_CleanupCertificateRequestsTimer->OnTimerExpired.connect([this](const Timer * const&) { CleanupCertificateRequestsTimerHandler(); }); + m_CleanupCertificateRequestsTimer->SetInterval(3600); + m_CleanupCertificateRequestsTimer->Start(); + m_CleanupCertificateRequestsTimer->Reschedule(0); + + m_ApiPackageIntegrityTimer = new Timer(); + m_ApiPackageIntegrityTimer->OnTimerExpired.connect([this](const Timer * const&) { CheckApiPackageIntegrity(); }); + m_ApiPackageIntegrityTimer->SetInterval(300); + m_ApiPackageIntegrityTimer->Start(); + + OnMasterChanged(true); +} + +void ApiListener::RenewOwnCert() +{ + auto certPath (GetDefaultCertPath()); + auto cert (GetX509Certificate(certPath)); + + if (IsCertUptodate(cert)) { + return; + } + + Log(LogInformation, "ApiListener") + << "Our certificate will expire soon, but we own the CA. Renewing."; + + cert = RenewCert(cert); + + if (!cert) { + return; + } + + std::fstream certfp; + auto tempCertPath (Utility::CreateTempFile(certPath + ".XXXXXX", 0644, certfp)); + + certfp.exceptions(std::ofstream::failbit | std::ofstream::badbit); + certfp << CertificateToString(cert); + certfp.close(); + + Utility::RenameFile(tempCertPath, certPath); + UpdateSSLContext(); +} + +void ApiListener::Stop(bool runtimeDeleted) +{ + ObjectImpl<ApiListener>::Stop(runtimeDeleted); + + Log(LogInformation, "ApiListener") + << "'" << GetName() << "' stopped."; + + { + std::unique_lock<std::mutex> lock(m_LogLock); + CloseLogFile(); + RotateLogFile(); + } + + RemoveStatusFile(); +} + +ApiListener::Ptr ApiListener::GetInstance() +{ + return m_Instance; +} + +Endpoint::Ptr ApiListener::GetMaster() const +{ + Zone::Ptr zone = Zone::GetLocalZone(); + + if (!zone) + return nullptr; + + std::vector<String> names; + + for (const Endpoint::Ptr& endpoint : zone->GetEndpoints()) + if (endpoint->GetConnected() || endpoint->GetName() == GetIdentity()) + names.push_back(endpoint->GetName()); + + std::sort(names.begin(), names.end()); + + return Endpoint::GetByName(*names.begin()); +} + +bool ApiListener::IsMaster() const +{ + Endpoint::Ptr master = GetMaster(); + + if (!master) + return false; + + return master == GetLocalEndpoint(); +} + +/** + * Creates a new JSON-RPC listener on the specified port. + * + * @param node The host the listener should be bound to. + * @param service The port to listen on. + */ +bool ApiListener::AddListener(const String& node, const String& service) +{ + namespace asio = boost::asio; + namespace ip = asio::ip; + using ip::tcp; + + ObjectLock olock(this); + + if (!m_SSLContext) { + Log(LogCritical, "ApiListener", "SSL context is required for AddListener()"); + return false; + } + + auto& io (IoEngine::Get().GetIoContext()); + auto acceptor (Shared<tcp::acceptor>::Make(io)); + + try { + tcp::resolver resolver (io); + tcp::resolver::query query (node, service, tcp::resolver::query::passive); + + auto result (resolver.resolve(query)); + auto current (result.begin()); + + for (;;) { + try { + acceptor->open(current->endpoint().protocol()); + + { + auto fd (acceptor->native_handle()); + + const int optFalse = 0; + setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, reinterpret_cast<const char *>(&optFalse), sizeof(optFalse)); + + const int optTrue = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char *>(&optTrue), sizeof(optTrue)); +#ifdef SO_REUSEPORT + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, reinterpret_cast<const char *>(&optTrue), sizeof(optTrue)); +#endif /* SO_REUSEPORT */ + } + + acceptor->bind(current->endpoint()); + + break; + } catch (const std::exception&) { + if (++current == result.end()) { + throw; + } + + if (acceptor->is_open()) { + acceptor->close(); + } + } + } + } catch (const std::exception& ex) { + Log(LogCritical, "ApiListener") + << "Cannot bind TCP socket for host '" << node << "' on port '" << service << "': " << ex.what(); + return false; + } + + acceptor->listen(INT_MAX); + + auto localEndpoint (acceptor->local_endpoint()); + + Log(LogInformation, "ApiListener") + << "Started new listener on '[" << localEndpoint.address() << "]:" << localEndpoint.port() << "'"; + + IoEngine::SpawnCoroutine(io, [this, acceptor](asio::yield_context yc) { ListenerCoroutineProc(yc, acceptor); }); + + UpdateStatusFile(localEndpoint); + + return true; +} + +void ApiListener::ListenerCoroutineProc(boost::asio::yield_context yc, const Shared<boost::asio::ip::tcp::acceptor>::Ptr& server) +{ + namespace asio = boost::asio; + + auto& io (IoEngine::Get().GetIoContext()); + + time_t lastModified = -1; + const String crlPath = GetCrlPath(); + + if (!crlPath.IsEmpty()) { + lastModified = Utility::GetFileCreationTime(crlPath); + } + + for (;;) { + try { + asio::ip::tcp::socket socket (io); + + server->async_accept(socket.lowest_layer(), yc); + + if (!crlPath.IsEmpty()) { + time_t currentCreationTime = Utility::GetFileCreationTime(crlPath); + + if (lastModified != currentCreationTime) { + UpdateSSLContext(); + + lastModified = currentCreationTime; + } + } + + boost::shared_lock<decltype(m_SSLContextMutex)> lock (m_SSLContextMutex); + auto sslConn (Shared<AsioTlsStream>::Make(io, *m_SSLContext)); + + lock.unlock(); + sslConn->lowest_layer() = std::move(socket); + + auto strand (Shared<asio::io_context::strand>::Make(io)); + + IoEngine::SpawnCoroutine(*strand, [this, strand, sslConn](asio::yield_context yc) { + Timeout::Ptr timeout(new Timeout(strand->context(), *strand, boost::posix_time::microseconds(int64_t(GetConnectTimeout() * 1e6)), + [sslConn](asio::yield_context yc) { + Log(LogWarning, "ApiListener") + << "Timeout while processing incoming connection from " + << sslConn->lowest_layer().remote_endpoint(); + + boost::system::error_code ec; + sslConn->lowest_layer().cancel(ec); + } + )); + Defer cancelTimeout([timeout]() { timeout->Cancel(); }); + + NewClientHandler(yc, strand, sslConn, String(), RoleServer); + }); + } catch (const std::exception& ex) { + Log(LogCritical, "ApiListener") + << "Cannot accept new connection: " << ex.what(); + } + } +} + +/** + * Creates a new JSON-RPC client and connects to the specified endpoint. + * + * @param endpoint The endpoint. + */ +void ApiListener::AddConnection(const Endpoint::Ptr& endpoint) +{ + namespace asio = boost::asio; + using asio::ip::tcp; + + if (!m_SSLContext) { + Log(LogCritical, "ApiListener", "SSL context is required for AddConnection()"); + return; + } + + auto& io (IoEngine::Get().GetIoContext()); + auto strand (Shared<asio::io_context::strand>::Make(io)); + + IoEngine::SpawnCoroutine(*strand, [this, strand, endpoint, &io](asio::yield_context yc) { + String host = endpoint->GetHost(); + String port = endpoint->GetPort(); + + Log(LogInformation, "ApiListener") + << "Reconnecting to endpoint '" << endpoint->GetName() << "' via host '" << host << "' and port '" << port << "'"; + + try { + boost::shared_lock<decltype(m_SSLContextMutex)> lock (m_SSLContextMutex); + auto sslConn (Shared<AsioTlsStream>::Make(io, *m_SSLContext, endpoint->GetName())); + + lock.unlock(); + + Timeout::Ptr timeout(new Timeout(strand->context(), *strand, boost::posix_time::microseconds(int64_t(GetConnectTimeout() * 1e6)), + [sslConn, endpoint, host, port](asio::yield_context yc) { + Log(LogCritical, "ApiListener") + << "Timeout while reconnecting to endpoint '" << endpoint->GetName() << "' via host '" << host + << "' and port '" << port << "', cancelling attempt"; + + boost::system::error_code ec; + sslConn->lowest_layer().cancel(ec); + } + )); + Defer cancelTimeout([&timeout]() { timeout->Cancel(); }); + + Connect(sslConn->lowest_layer(), host, port, yc); + + NewClientHandler(yc, strand, sslConn, endpoint->GetName(), RoleClient); + + endpoint->SetConnecting(false); + Log(LogInformation, "ApiListener") + << "Finished reconnecting to endpoint '" << endpoint->GetName() << "' via host '" << host << "' and port '" << port << "'"; + } catch (const std::exception& ex) { + endpoint->SetConnecting(false); + + Log(LogCritical, "ApiListener") + << "Cannot connect to host '" << host << "' on port '" << port << "': " << ex.what(); + } + }); +} + +void ApiListener::NewClientHandler( + boost::asio::yield_context yc, const Shared<boost::asio::io_context::strand>::Ptr& strand, + const Shared<AsioTlsStream>::Ptr& client, const String& hostname, ConnectionRole role +) +{ + try { + NewClientHandlerInternal(yc, strand, client, hostname, role); + } catch (const std::exception& ex) { + Log(LogCritical, "ApiListener") + << "Exception while handling new API client connection: " << DiagnosticInformation(ex, false); + + Log(LogDebug, "ApiListener") + << "Exception while handling new API client connection: " << DiagnosticInformation(ex); + } +} + +static const auto l_AppVersionInt (([]() -> unsigned long { + auto appVersion (Application::GetAppVersion()); + boost::regex rgx (R"EOF(^[rv]?(\d+)\.(\d+)\.(\d+))EOF"); + boost::smatch match; + + if (!boost::regex_search(appVersion.GetData(), match, rgx)) { + return 0; + } + + return 100u * 100u * boost::lexical_cast<unsigned long>(match[1].str()) + + 100u * boost::lexical_cast<unsigned long>(match[2].str()) + + boost::lexical_cast<unsigned long>(match[3].str()); +})()); + +static const auto l_MyCapabilities (ApiCapabilities::ExecuteArbitraryCommand); + +/** + * Processes a new client connection. + * + * @param client The new client. + */ +void ApiListener::NewClientHandlerInternal( + boost::asio::yield_context yc, const Shared<boost::asio::io_context::strand>::Ptr& strand, + const Shared<AsioTlsStream>::Ptr& client, const String& hostname, ConnectionRole role +) +{ + namespace asio = boost::asio; + namespace ssl = asio::ssl; + + String conninfo; + + { + std::ostringstream conninfo_; + + if (role == RoleClient) { + conninfo_ << "to"; + } else { + conninfo_ << "from"; + } + + auto endpoint (client->lowest_layer().remote_endpoint()); + + conninfo_ << " [" << endpoint.address() << "]:" << endpoint.port(); + + conninfo = conninfo_.str(); + } + + auto& sslConn (client->next_layer()); + + boost::system::error_code ec; + + { + Timeout::Ptr handshakeTimeout (new Timeout( + strand->context(), + *strand, + boost::posix_time::microseconds(intmax_t(Configuration::TlsHandshakeTimeout * 1000000)), + [strand, client](asio::yield_context yc) { + boost::system::error_code ec; + client->lowest_layer().cancel(ec); + } + )); + + sslConn.async_handshake(role == RoleClient ? sslConn.client : sslConn.server, yc[ec]); + + handshakeTimeout->Cancel(); + } + + if (ec) { + // https://github.com/boostorg/beast/issues/915 + // Google Chrome 73+ seems not close the connection properly, https://stackoverflow.com/questions/56272906/how-to-fix-certificate-unknown-error-from-chrome-v73 + if (ec == asio::ssl::error::stream_truncated) { + Log(LogNotice, "ApiListener") + << "TLS stream was truncated, ignoring connection from " << conninfo; + return; + } + + Log(LogCritical, "ApiListener") + << "Client TLS handshake failed (" << conninfo << "): " << ec.message(); + return; + } + + bool willBeShutDown = false; + + Defer shutDownIfNeeded ([&sslConn, &willBeShutDown, &yc]() { + if (!willBeShutDown) { + // Ignore the error, but do not throw an exception being swallowed at all cost. + // https://github.com/Icinga/icinga2/issues/7351 + boost::system::error_code ec; + sslConn.async_shutdown(yc[ec]); + } + }); + + std::shared_ptr<X509> cert (sslConn.GetPeerCertificate()); + bool verify_ok = false; + String identity; + Endpoint::Ptr endpoint; + + if (cert) { + verify_ok = sslConn.IsVerifyOK(); + + String verifyError = sslConn.GetVerifyError(); + + try { + identity = GetCertificateCN(cert); + } catch (const std::exception&) { + Log(LogCritical, "ApiListener") + << "Cannot get certificate common name from cert path: '" << GetDefaultCertPath() << "'."; + return; + } + + if (!hostname.IsEmpty()) { + if (identity != hostname) { + Log(LogWarning, "ApiListener") + << "Unexpected certificate common name while connecting to endpoint '" + << hostname << "': got '" << identity << "'"; + return; + } else if (!verify_ok) { + Log(LogWarning, "ApiListener") + << "Certificate validation failed for endpoint '" << hostname + << "': " << verifyError; + } + } + + if (verify_ok) { + endpoint = Endpoint::GetByName(identity); + } + + Log log(LogInformation, "ApiListener"); + + log << "New client connection for identity '" << identity << "' " << conninfo; + + if (!verify_ok) { + log << " (certificate validation failed: " << verifyError << ")"; + } else if (!endpoint) { + log << " (no Endpoint object found for identity)"; + } + } else { + Log(LogInformation, "ApiListener") + << "New client connection " << conninfo << " (no client certificate)"; + } + + ClientType ctype; + + if (role == RoleClient) { + JsonRpc::SendMessage(client, new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "icinga::Hello" }, + { "params", new Dictionary({ + { "version", (double)l_AppVersionInt }, + { "capabilities", (double)l_MyCapabilities } + }) } + }), yc); + + client->async_flush(yc); + + ctype = ClientJsonRpc; + } else { + { + boost::system::error_code ec; + + if (client->async_fill(yc[ec]) == 0u) { + if (identity.IsEmpty()) { + Log(LogInformation, "ApiListener") + << "No data received on new API connection " << conninfo << ". " + << "Ensure that the remote endpoints are properly configured in a cluster setup."; + } else { + Log(LogWarning, "ApiListener") + << "No data received on new API connection " << conninfo << " for identity '" << identity << "'. " + << "Ensure that the remote endpoints are properly configured in a cluster setup."; + } + + return; + } + } + + char firstByte = 0; + + { + asio::mutable_buffer firstByteBuf (&firstByte, 1); + client->peek(firstByteBuf); + } + + if (firstByte >= '0' && firstByte <= '9') { + JsonRpc::SendMessage(client, new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "icinga::Hello" }, + { "params", new Dictionary({ + { "version", (double)l_AppVersionInt }, + { "capabilities", (double)l_MyCapabilities } + }) } + }), yc); + + client->async_flush(yc); + + ctype = ClientJsonRpc; + } else { + ctype = ClientHttp; + } + } + + if (ctype == ClientJsonRpc) { + Log(LogNotice, "ApiListener", "New JSON-RPC client"); + + if (endpoint && endpoint->GetConnected()) { + Log(LogNotice, "ApiListener") + << "Ignoring JSON-RPC connection " << conninfo + << ". We're already connected to Endpoint '" << endpoint->GetName() << "'."; + return; + } + + JsonRpcConnection::Ptr aclient = new JsonRpcConnection(identity, verify_ok, client, role); + + if (endpoint) { + endpoint->AddClient(aclient); + + Utility::QueueAsyncCallback([this, aclient, endpoint]() { + SyncClient(aclient, endpoint, true); + }); + } else if (!AddAnonymousClient(aclient)) { + Log(LogNotice, "ApiListener") + << "Ignoring anonymous JSON-RPC connection " << conninfo + << ". Max connections (" << GetMaxAnonymousClients() << ") exceeded."; + + aclient = nullptr; + } + + if (aclient) { + aclient->Start(); + + willBeShutDown = true; + } + } else { + Log(LogNotice, "ApiListener", "New HTTP client"); + + HttpServerConnection::Ptr aclient = new HttpServerConnection(identity, verify_ok, client); + AddHttpClient(aclient); + aclient->Start(); + + willBeShutDown = true; + } +} + +void ApiListener::SyncClient(const JsonRpcConnection::Ptr& aclient, const Endpoint::Ptr& endpoint, bool needSync) +{ + Zone::Ptr eZone = endpoint->GetZone(); + + try { + { + ObjectLock olock(endpoint); + + endpoint->SetSyncing(true); + } + + Zone::Ptr myZone = Zone::GetLocalZone(); + auto parent (myZone->GetParent()); + + if (parent == eZone || !parent && eZone == myZone) { + JsonRpcConnection::SendCertificateRequest(aclient, nullptr, String()); + + if (Utility::PathExists(ApiListener::GetCertificateRequestsDir())) { + Utility::Glob(ApiListener::GetCertificateRequestsDir() + "/*.json", [aclient](const String& newPath) { + JsonRpcConnection::SendCertificateRequest(aclient, nullptr, newPath); + }, GlobFile); + } + } + + /* Make sure that the config updates are synced + * before the logs are replayed. + */ + + Log(LogInformation, "ApiListener") + << "Sending config updates for endpoint '" << endpoint->GetName() << "' in zone '" << eZone->GetName() << "'."; + + /* sync zone file config */ + SendConfigUpdate(aclient); + + Log(LogInformation, "ApiListener") + << "Finished sending config file updates for endpoint '" << endpoint->GetName() << "' in zone '" << eZone->GetName() << "'."; + + /* sync runtime config */ + SendRuntimeConfigObjects(aclient); + + Log(LogInformation, "ApiListener") + << "Finished sending runtime config updates for endpoint '" << endpoint->GetName() << "' in zone '" << eZone->GetName() << "'."; + + if (!needSync) { + ObjectLock olock2(endpoint); + endpoint->SetSyncing(false); + return; + } + + Log(LogInformation, "ApiListener") + << "Sending replay log for endpoint '" << endpoint->GetName() << "' in zone '" << eZone->GetName() << "'."; + + ReplayLog(aclient); + + if (eZone == Zone::GetLocalZone()) + UpdateObjectAuthority(); + + Log(LogInformation, "ApiListener") + << "Finished sending replay log for endpoint '" << endpoint->GetName() << "' in zone '" << eZone->GetName() << "'."; + } catch (const std::exception& ex) { + { + ObjectLock olock2(endpoint); + endpoint->SetSyncing(false); + } + + Log(LogCritical, "ApiListener") + << "Error while syncing endpoint '" << endpoint->GetName() << "': " << DiagnosticInformation(ex, false); + + Log(LogDebug, "ApiListener") + << "Error while syncing endpoint '" << endpoint->GetName() << "': " << DiagnosticInformation(ex); + } + + Log(LogInformation, "ApiListener") + << "Finished syncing endpoint '" << endpoint->GetName() << "' in zone '" << eZone->GetName() << "'."; +} + +void ApiListener::ApiTimerHandler() +{ + double now = Utility::GetTime(); + + std::vector<int> files; + Utility::Glob(GetApiDir() + "log/*", [&files](const String& file) { LogGlobHandler(files, file); }, GlobFile); + std::sort(files.begin(), files.end()); + + for (int ts : files) { + bool need = false; + auto localZone (GetLocalEndpoint()->GetZone()); + + for (const Endpoint::Ptr& endpoint : ConfigType::GetObjectsByType<Endpoint>()) { + if (endpoint == GetLocalEndpoint()) + continue; + + auto zone (endpoint->GetZone()); + + /* only care for endpoints in a) the same zone b) our parent zone c) immediate child zones */ + if (!(zone == localZone || zone == localZone->GetParent() || zone->GetParent() == localZone)) { + continue; + } + + if (endpoint->GetLogDuration() >= 0 && ts < now - endpoint->GetLogDuration()) + continue; + + if (ts > endpoint->GetLocalLogPosition()) { + need = true; + break; + } + } + + if (!need) { + String path = GetApiDir() + "log/" + Convert::ToString(ts); + Log(LogNotice, "ApiListener") + << "Removing old log file: " << path; + (void)unlink(path.CStr()); + } + } + + for (const Endpoint::Ptr& endpoint : ConfigType::GetObjectsByType<Endpoint>()) { + if (!endpoint->GetConnected()) + continue; + + double ts = endpoint->GetRemoteLogPosition(); + + if (ts == 0) + continue; + + Dictionary::Ptr lmessage = new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "log::SetLogPosition" }, + { "params", new Dictionary({ + { "log_position", ts } + }) } + }); + + double maxTs = 0; + + for (const JsonRpcConnection::Ptr& client : endpoint->GetClients()) { + if (client->GetTimestamp() > maxTs) + maxTs = client->GetTimestamp(); + } + + for (const JsonRpcConnection::Ptr& client : endpoint->GetClients()) { + if (client->GetTimestamp() == maxTs) { + client->SendMessage(lmessage); + } else { + client->Disconnect(); + } + } + + Log(LogNotice, "ApiListener") + << "Setting log position for identity '" << endpoint->GetName() << "': " + << Utility::FormatDateTime("%Y/%m/%d %H:%M:%S", ts); + } +} + +void ApiListener::ApiReconnectTimerHandler() +{ + Zone::Ptr my_zone = Zone::GetLocalZone(); + + for (const Zone::Ptr& zone : ConfigType::GetObjectsByType<Zone>()) { + /* don't connect to global zones */ + if (zone->GetGlobal()) + continue; + + /* only connect to endpoints in a) the same zone b) our parent zone c) immediate child zones */ + if (my_zone != zone && my_zone != zone->GetParent() && zone != my_zone->GetParent()) { + Log(LogDebug, "ApiListener") + << "Not connecting to Zone '" << zone->GetName() + << "' because it's not in the same zone, a parent or a child zone."; + continue; + } + + for (const Endpoint::Ptr& endpoint : zone->GetEndpoints()) { + /* don't connect to ourselves */ + if (endpoint == GetLocalEndpoint()) { + Log(LogDebug, "ApiListener") + << "Not connecting to Endpoint '" << endpoint->GetName() << "' because that's us."; + continue; + } + + /* don't try to connect to endpoints which don't have a host and port */ + if (endpoint->GetHost().IsEmpty() || endpoint->GetPort().IsEmpty()) { + Log(LogDebug, "ApiListener") + << "Not connecting to Endpoint '" << endpoint->GetName() + << "' because the host/port attributes are missing."; + continue; + } + + /* don't try to connect if there's already a connection attempt */ + if (endpoint->GetConnecting()) { + Log(LogDebug, "ApiListener") + << "Not connecting to Endpoint '" << endpoint->GetName() + << "' because we're already trying to connect to it."; + continue; + } + + /* don't try to connect if we're already connected */ + if (endpoint->GetConnected()) { + Log(LogDebug, "ApiListener") + << "Not connecting to Endpoint '" << endpoint->GetName() + << "' because we're already connected to it."; + continue; + } + + /* Set connecting state to prevent duplicated queue inserts later. */ + endpoint->SetConnecting(true); + + AddConnection(endpoint); + } + } + + Endpoint::Ptr master = GetMaster(); + + if (master) + Log(LogNotice, "ApiListener") + << "Current zone master: " << master->GetName(); + + std::vector<String> names; + for (const Endpoint::Ptr& endpoint : ConfigType::GetObjectsByType<Endpoint>()) + if (endpoint->GetConnected()) + names.emplace_back(endpoint->GetName() + " (" + Convert::ToString(endpoint->GetClients().size()) + ")"); + + Log(LogNotice, "ApiListener") + << "Connected endpoints: " << Utility::NaturalJoin(names); +} + +static void CleanupCertificateRequest(const String& path, double expiryTime) +{ +#ifndef _WIN32 + struct stat statbuf; + if (lstat(path.CStr(), &statbuf) < 0) + return; +#else /* _WIN32 */ + struct _stat statbuf; + if (_stat(path.CStr(), &statbuf) < 0) + return; +#endif /* _WIN32 */ + + if (statbuf.st_mtime < expiryTime) + (void) unlink(path.CStr()); +} + +void ApiListener::CleanupCertificateRequestsTimerHandler() +{ + String requestsDir = GetCertificateRequestsDir(); + + if (Utility::PathExists(requestsDir)) { + /* remove certificate requests that are older than a week */ + double expiryTime = Utility::GetTime() - 7 * 24 * 60 * 60; + Utility::Glob(requestsDir + "/*.json", [expiryTime](const String& path) { + CleanupCertificateRequest(path, expiryTime); + }, GlobFile); + } +} + +void ApiListener::RelayMessage(const MessageOrigin::Ptr& origin, + const ConfigObject::Ptr& secobj, const Dictionary::Ptr& message, bool log) +{ + if (!IsActive()) + return; + + m_RelayQueue.Enqueue([this, origin, secobj, message, log]() { SyncRelayMessage(origin, secobj, message, log); }, PriorityNormal, true); +} + +void ApiListener::PersistMessage(const Dictionary::Ptr& message, const ConfigObject::Ptr& secobj) +{ + double ts = message->Get("ts"); + + ASSERT(ts != 0); + + Dictionary::Ptr pmessage = new Dictionary(); + pmessage->Set("timestamp", ts); + + pmessage->Set("message", JsonEncode(message)); + + if (secobj) { + Dictionary::Ptr secname = new Dictionary(); + secname->Set("type", secobj->GetReflectionType()->GetName()); + secname->Set("name", secobj->GetName()); + pmessage->Set("secobj", secname); + } + + std::unique_lock<std::mutex> lock(m_LogLock); + if (m_LogFile) { + NetString::WriteStringToStream(m_LogFile, JsonEncode(pmessage)); + m_LogMessageCount++; + SetLogMessageTimestamp(ts); + + if (m_LogMessageCount > 50000) { + CloseLogFile(); + RotateLogFile(); + OpenLogFile(); + } + } +} + +void ApiListener::SyncSendMessage(const Endpoint::Ptr& endpoint, const Dictionary::Ptr& message) +{ + ObjectLock olock(endpoint); + + if (!endpoint->GetSyncing()) { + Log(LogNotice, "ApiListener") + << "Sending message '" << message->Get("method") << "' to '" << endpoint->GetName() << "'"; + + double maxTs = 0; + + for (const JsonRpcConnection::Ptr& client : endpoint->GetClients()) { + if (client->GetTimestamp() > maxTs) + maxTs = client->GetTimestamp(); + } + + for (const JsonRpcConnection::Ptr& client : endpoint->GetClients()) { + if (client->GetTimestamp() != maxTs) + continue; + + client->SendMessage(message); + } + } +} + +/** + * Relay a message to a directly connected zone or to a global zone. + * If some other zone is passed as the target zone, it is not relayed. + * + * @param targetZone The zone to relay to + * @param origin Information about where this message is relayed from (if it was not generated locally) + * @param message The message to relay + * @param currentZoneMaster The current master node of the local zone + * @return true if the message has been relayed to all relevant endpoints, + * false if it hasn't and must be persisted in the replay log + */ +bool ApiListener::RelayMessageOne(const Zone::Ptr& targetZone, const MessageOrigin::Ptr& origin, const Dictionary::Ptr& message, const Endpoint::Ptr& currentZoneMaster) +{ + ASSERT(targetZone); + + Zone::Ptr localZone = Zone::GetLocalZone(); + + /* only relay the message to a) the same local zone, b) the parent zone and c) direct child zones. Exception is a global zone. */ + if (!targetZone->GetGlobal() && + targetZone != localZone && + targetZone != localZone->GetParent() && + targetZone->GetParent() != localZone) { + return true; + } + + Endpoint::Ptr localEndpoint = GetLocalEndpoint(); + + std::vector<Endpoint::Ptr> skippedEndpoints; + + std::set<Zone::Ptr> allTargetZones; + if (targetZone->GetGlobal()) { + /* if the zone is global, the message has to be relayed to our local zone and direct children */ + allTargetZones.insert(localZone); + for (const Zone::Ptr& zone : ConfigType::GetObjectsByType<Zone>()) { + if (zone->GetParent() == localZone) { + allTargetZones.insert(zone); + } + } + } else { + /* whereas if it's not global, the message is just relayed to the zone itself */ + allTargetZones.insert(targetZone); + } + + bool needsReplay = false; + + for (const Zone::Ptr& currentTargetZone : allTargetZones) { + bool relayed = false, log_needed = false, log_done = false; + + for (const Endpoint::Ptr& targetEndpoint : currentTargetZone->GetEndpoints()) { + /* Don't relay messages to ourselves. */ + if (targetEndpoint == localEndpoint) + continue; + + log_needed = true; + + /* Don't relay messages to disconnected endpoints. */ + if (!targetEndpoint->GetConnected()) { + if (currentTargetZone == localZone) + log_done = false; + + continue; + } + + log_done = true; + + /* Don't relay the message to the zone through more than one endpoint unless this is our own zone. + * 'relayed' is set to true on success below, enabling the checks in the second iteration. + */ + if (relayed && currentTargetZone != localZone) { + skippedEndpoints.push_back(targetEndpoint); + continue; + } + + /* Don't relay messages back to the endpoint which we got the message from. */ + if (origin && origin->FromClient && targetEndpoint == origin->FromClient->GetEndpoint()) { + skippedEndpoints.push_back(targetEndpoint); + continue; + } + + /* Don't relay messages back to the zone which we got the message from. */ + if (origin && origin->FromZone && currentTargetZone == origin->FromZone) { + skippedEndpoints.push_back(targetEndpoint); + continue; + } + + /* Only relay message to the zone master if we're not currently the zone master. + * e1 is zone master, e2 and e3 are zone members. + * + * Message is sent from e2 or e3: + * !isMaster == true + * targetEndpoint e1 is zone master -> send the message + * targetEndpoint e3 is not zone master -> skip it, avoid routing loops + * + * Message is sent from e1: + * !isMaster == false -> send the messages to e2 and e3 being the zone routing master. + */ + bool isMaster = (currentZoneMaster == localEndpoint); + + if (!isMaster && targetEndpoint != currentZoneMaster) { + skippedEndpoints.push_back(targetEndpoint); + continue; + } + + relayed = true; + + SyncSendMessage(targetEndpoint, message); + } + + if (log_needed && !log_done) { + needsReplay = true; + } + } + + if (!skippedEndpoints.empty()) { + double ts = message->Get("ts"); + + for (const Endpoint::Ptr& skippedEndpoint : skippedEndpoints) + skippedEndpoint->SetLocalLogPosition(ts); + } + + return !needsReplay; +} + +void ApiListener::SyncRelayMessage(const MessageOrigin::Ptr& origin, + const ConfigObject::Ptr& secobj, const Dictionary::Ptr& message, bool log) +{ + double ts = Utility::GetTime(); + message->Set("ts", ts); + + Log(LogNotice, "ApiListener") + << "Relaying '" << message->Get("method") << "' message"; + + if (origin && origin->FromZone) + message->Set("originZone", origin->FromZone->GetName()); + + Zone::Ptr target_zone; + + if (secobj) { + if (secobj->GetReflectionType() == Zone::TypeInstance) + target_zone = static_pointer_cast<Zone>(secobj); + else + target_zone = static_pointer_cast<Zone>(secobj->GetZone()); + } + + if (!target_zone) + target_zone = Zone::GetLocalZone(); + + Endpoint::Ptr master = GetMaster(); + + bool need_log = !RelayMessageOne(target_zone, origin, message, master); + + for (const Zone::Ptr& zone : target_zone->GetAllParentsRaw()) { + if (!RelayMessageOne(zone, origin, message, master)) + need_log = true; + } + + if (log && need_log) + PersistMessage(message, secobj); +} + +/* must hold m_LogLock */ +void ApiListener::OpenLogFile() +{ + String path = GetApiDir() + "log/current"; + + Utility::MkDirP(Utility::DirName(path), 0750); + + auto *fp = new std::fstream(path.CStr(), std::fstream::out | std::ofstream::app); + + if (!fp->good()) { + Log(LogWarning, "ApiListener") + << "Could not open spool file: " << path; + return; + } + + m_LogFile = new StdioStream(fp, true); + m_LogMessageCount = 0; + SetLogMessageTimestamp(Utility::GetTime()); +} + +/* must hold m_LogLock */ +void ApiListener::CloseLogFile() +{ + if (!m_LogFile) + return; + + m_LogFile->Close(); + m_LogFile.reset(); +} + +/* must hold m_LogLock */ +void ApiListener::RotateLogFile() +{ + double ts = GetLogMessageTimestamp(); + + if (ts == 0) + ts = Utility::GetTime(); + + String oldpath = GetApiDir() + "log/current"; + String newpath = GetApiDir() + "log/" + Convert::ToString(static_cast<int>(ts)+1); + + // If the log is being rotated more than once per second, + // don't overwrite the previous one, but silently deny rotation. + if (!Utility::PathExists(newpath)) { + try { + Utility::RenameFile(oldpath, newpath); + } catch (const std::exception& ex) { + Log(LogCritical, "ApiListener") + << "Cannot rotate replay log file from '" << oldpath << "' to '" + << newpath << "': " << ex.what(); + } + } +} + +void ApiListener::LogGlobHandler(std::vector<int>& files, const String& file) +{ + String name = Utility::BaseName(file); + + if (name == "current") + return; + + int ts; + + try { + ts = Convert::ToLong(name); + } catch (const std::exception&) { + return; + } + + files.push_back(ts); +} + +void ApiListener::ReplayLog(const JsonRpcConnection::Ptr& client) +{ + Endpoint::Ptr endpoint = client->GetEndpoint(); + + if (endpoint->GetLogDuration() == 0) { + ObjectLock olock2(endpoint); + endpoint->SetSyncing(false); + return; + } + + CONTEXT("Replaying log for Endpoint '" + endpoint->GetName() + "'"); + + int count = -1; + double peer_ts = endpoint->GetLocalLogPosition(); + double logpos_ts = peer_ts; + bool last_sync = false; + + Endpoint::Ptr target_endpoint = client->GetEndpoint(); + ASSERT(target_endpoint); + + Zone::Ptr target_zone = target_endpoint->GetZone(); + + if (!target_zone) { + ObjectLock olock2(endpoint); + endpoint->SetSyncing(false); + return; + } + + for (;;) { + std::unique_lock<std::mutex> lock(m_LogLock); + + CloseLogFile(); + + if (count == -1 || count > 50000) { + OpenLogFile(); + lock.unlock(); + } else { + last_sync = true; + } + + count = 0; + + std::vector<int> files; + Utility::Glob(GetApiDir() + "log/*", [&files](const String& file) { LogGlobHandler(files, file); }, GlobFile); + std::sort(files.begin(), files.end()); + + std::vector<std::pair<int, String>> allFiles; + + for (int ts : files) { + if (ts >= peer_ts) { + allFiles.emplace_back(ts, GetApiDir() + "log/" + Convert::ToString(ts)); + } + } + + allFiles.emplace_back(Utility::GetTime() + 1, GetApiDir() + "log/current"); + + for (auto& file : allFiles) { + Log(LogNotice, "ApiListener") + << "Replaying log: " << file.second; + + auto *fp = new std::fstream(file.second.CStr(), std::fstream::in | std::fstream::binary); + StdioStream::Ptr logStream = new StdioStream(fp, true); + + String message; + StreamReadContext src; + while (true) { + Dictionary::Ptr pmessage; + + try { + StreamReadStatus srs = NetString::ReadStringFromStream(logStream, &message, src); + + if (srs == StatusEof) + break; + + if (srs != StatusNewItem) + continue; + + pmessage = JsonDecode(message); + } catch (const std::exception&) { + Log(LogWarning, "ApiListener") + << "Unexpected end-of-file for cluster log: " << file.second; + + /* Log files may be incomplete or corrupted. This is perfectly OK. */ + break; + } + + if (pmessage->Get("timestamp") <= peer_ts) + continue; + + Dictionary::Ptr secname = pmessage->Get("secobj"); + + if (secname) { + ConfigObject::Ptr secobj = ConfigObject::GetObject(secname->Get("type"), secname->Get("name")); + + if (!secobj) + continue; + + if (!target_zone->CanAccessObject(secobj)) + continue; + } + + try { + client->SendRawMessage(pmessage->Get("message")); + count++; + } catch (const std::exception& ex) { + Log(LogWarning, "ApiListener") + << "Error while replaying log for endpoint '" << endpoint->GetName() << "': " << DiagnosticInformation(ex, false); + + Log(LogDebug, "ApiListener") + << "Error while replaying log for endpoint '" << endpoint->GetName() << "': " << DiagnosticInformation(ex); + + break; + } + + peer_ts = pmessage->Get("timestamp"); + + if (file.first > logpos_ts + 10) { + logpos_ts = file.first; + + Dictionary::Ptr lmessage = new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "log::SetLogPosition" }, + { "params", new Dictionary({ + { "log_position", logpos_ts } + }) } + }); + + client->SendMessage(lmessage); + } + } + + logStream->Close(); + } + + if (count > 0) { + Log(LogInformation, "ApiListener") + << "Replayed " << count << " messages."; + } + else { + Log(LogNotice, "ApiListener") + << "Replayed " << count << " messages."; + } + + if (last_sync) { + { + ObjectLock olock2(endpoint); + endpoint->SetSyncing(false); + } + + OpenLogFile(); + + break; + } + } +} + +void ApiListener::StatsFunc(const Dictionary::Ptr& status, const Array::Ptr& perfdata) +{ + std::pair<Dictionary::Ptr, Dictionary::Ptr> stats; + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) + return; + + stats = listener->GetStatus(); + + ObjectLock olock(stats.second); + for (const Dictionary::Pair& kv : stats.second) + perfdata->Add(new PerfdataValue("api_" + kv.first, kv.second)); + + status->Set("api", stats.first); +} + +std::pair<Dictionary::Ptr, Dictionary::Ptr> ApiListener::GetStatus() +{ + Dictionary::Ptr perfdata = new Dictionary(); + + /* cluster stats */ + + double allEndpoints = 0; + Array::Ptr allNotConnectedEndpoints = new Array(); + Array::Ptr allConnectedEndpoints = new Array(); + + Zone::Ptr my_zone = Zone::GetLocalZone(); + + Dictionary::Ptr connectedZones = new Dictionary(); + + for (const Zone::Ptr& zone : ConfigType::GetObjectsByType<Zone>()) { + /* only check endpoints in a) the same zone b) our parent zone c) immediate child zones */ + if (my_zone != zone && my_zone != zone->GetParent() && zone != my_zone->GetParent()) { + Log(LogDebug, "ApiListener") + << "Not checking connection to Zone '" << zone->GetName() << "' because it's not in the same zone, a parent or a child zone."; + continue; + } + + bool zoneConnected = false; + int countZoneEndpoints = 0; + double zoneLag = 0; + + ArrayData zoneEndpoints; + + for (const Endpoint::Ptr& endpoint : zone->GetEndpoints()) { + zoneEndpoints.emplace_back(endpoint->GetName()); + + if (endpoint->GetName() == GetIdentity()) + continue; + + double eplag = CalculateZoneLag(endpoint); + + if (eplag > 0 && eplag > zoneLag) + zoneLag = eplag; + + allEndpoints++; + countZoneEndpoints++; + + if (!endpoint->GetConnected()) { + allNotConnectedEndpoints->Add(endpoint->GetName()); + } else { + allConnectedEndpoints->Add(endpoint->GetName()); + zoneConnected = true; + } + } + + /* if there's only one endpoint inside the zone, we're not connected - that's us, fake it */ + if (zone->GetEndpoints().size() == 1 && countZoneEndpoints == 0) + zoneConnected = true; + + String parentZoneName; + Zone::Ptr parentZone = zone->GetParent(); + if (parentZone) + parentZoneName = parentZone->GetName(); + + Dictionary::Ptr zoneStats = new Dictionary({ + { "connected", zoneConnected }, + { "client_log_lag", zoneLag }, + { "endpoints", new Array(std::move(zoneEndpoints)) }, + { "parent_zone", parentZoneName } + }); + + connectedZones->Set(zone->GetName(), zoneStats); + } + + /* connection stats */ + size_t jsonRpcAnonymousClients = GetAnonymousClients().size(); + size_t httpClients = GetHttpClients().size(); + size_t syncQueueItems = m_SyncQueue.GetLength(); + size_t relayQueueItems = m_RelayQueue.GetLength(); + double workQueueItemRate = JsonRpcConnection::GetWorkQueueRate(); + double syncQueueItemRate = m_SyncQueue.GetTaskCount(60) / 60.0; + double relayQueueItemRate = m_RelayQueue.GetTaskCount(60) / 60.0; + + Dictionary::Ptr status = new Dictionary({ + { "identity", GetIdentity() }, + { "num_endpoints", allEndpoints }, + { "num_conn_endpoints", allConnectedEndpoints->GetLength() }, + { "num_not_conn_endpoints", allNotConnectedEndpoints->GetLength() }, + { "conn_endpoints", allConnectedEndpoints }, + { "not_conn_endpoints", allNotConnectedEndpoints }, + + { "zones", connectedZones }, + + { "json_rpc", new Dictionary({ + { "anonymous_clients", jsonRpcAnonymousClients }, + { "sync_queue_items", syncQueueItems }, + { "relay_queue_items", relayQueueItems }, + { "work_queue_item_rate", workQueueItemRate }, + { "sync_queue_item_rate", syncQueueItemRate }, + { "relay_queue_item_rate", relayQueueItemRate } + }) }, + + { "http", new Dictionary({ + { "clients", httpClients } + }) } + }); + + /* performance data */ + perfdata->Set("num_endpoints", allEndpoints); + perfdata->Set("num_conn_endpoints", Convert::ToDouble(allConnectedEndpoints->GetLength())); + perfdata->Set("num_not_conn_endpoints", Convert::ToDouble(allNotConnectedEndpoints->GetLength())); + + perfdata->Set("num_json_rpc_anonymous_clients", jsonRpcAnonymousClients); + perfdata->Set("num_http_clients", httpClients); + perfdata->Set("num_json_rpc_sync_queue_items", syncQueueItems); + perfdata->Set("num_json_rpc_relay_queue_items", relayQueueItems); + + perfdata->Set("num_json_rpc_work_queue_item_rate", workQueueItemRate); + perfdata->Set("num_json_rpc_sync_queue_item_rate", syncQueueItemRate); + perfdata->Set("num_json_rpc_relay_queue_item_rate", relayQueueItemRate); + + return std::make_pair(status, perfdata); +} + +double ApiListener::CalculateZoneLag(const Endpoint::Ptr& endpoint) +{ + double remoteLogPosition = endpoint->GetRemoteLogPosition(); + double eplag = Utility::GetTime() - remoteLogPosition; + + if ((endpoint->GetSyncing() || !endpoint->GetConnected()) && remoteLogPosition != 0) + return eplag; + + return 0; +} + +bool ApiListener::AddAnonymousClient(const JsonRpcConnection::Ptr& aclient) +{ + std::unique_lock<std::mutex> lock(m_AnonymousClientsLock); + + if (GetMaxAnonymousClients() >= 0 && (long)m_AnonymousClients.size() + 1 > (long)GetMaxAnonymousClients()) + return false; + + m_AnonymousClients.insert(aclient); + return true; +} + +void ApiListener::RemoveAnonymousClient(const JsonRpcConnection::Ptr& aclient) +{ + std::unique_lock<std::mutex> lock(m_AnonymousClientsLock); + m_AnonymousClients.erase(aclient); +} + +std::set<JsonRpcConnection::Ptr> ApiListener::GetAnonymousClients() const +{ + std::unique_lock<std::mutex> lock(m_AnonymousClientsLock); + return m_AnonymousClients; +} + +void ApiListener::AddHttpClient(const HttpServerConnection::Ptr& aclient) +{ + std::unique_lock<std::mutex> lock(m_HttpClientsLock); + m_HttpClients.insert(aclient); +} + +void ApiListener::RemoveHttpClient(const HttpServerConnection::Ptr& aclient) +{ + std::unique_lock<std::mutex> lock(m_HttpClientsLock); + m_HttpClients.erase(aclient); +} + +std::set<HttpServerConnection::Ptr> ApiListener::GetHttpClients() const +{ + std::unique_lock<std::mutex> lock(m_HttpClientsLock); + return m_HttpClients; +} + +static void LogAppVersion(unsigned long version, Log& log) +{ + log << version / 100u << "." << version % 100u << ".x"; +} + +Value ApiListener::HelloAPIHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + if (origin) { + auto client (origin->FromClient); + + if (client) { + auto endpoint (client->GetEndpoint()); + + if (endpoint) { + unsigned long nodeVersion = params->Get("version"); + + endpoint->SetIcingaVersion(nodeVersion); + endpoint->SetCapabilities((double)params->Get("capabilities")); + + if (nodeVersion == 0u) { + nodeVersion = 21200; + } + + if (endpoint->GetZone()->GetParent() == Zone::GetLocalZone()) { + switch (l_AppVersionInt / 100 - nodeVersion / 100) { + case 0: + case 1: + break; + default: + Log log (LogWarning, "ApiListener"); + log << "Unexpected Icinga version of endpoint '" << endpoint->GetName() << "': "; + + LogAppVersion(nodeVersion / 100u, log); + log << " Expected one of: "; + + LogAppVersion(l_AppVersionInt / 100u, log); + log << ", "; + + LogAppVersion((l_AppVersionInt / 100u - 1u), log); + } + } + } + } + } + + return Empty; +} + +Endpoint::Ptr ApiListener::GetLocalEndpoint() const +{ + return m_LocalEndpoint; +} + +void ApiListener::UpdateActivePackageStagesCache() +{ + std::unique_lock<std::mutex> lock(m_ActivePackageStagesLock); + + for (auto package : ConfigPackageUtility::GetPackages()) { + String activeStage; + + try { + activeStage = ConfigPackageUtility::GetActiveStageFromFile(package); + } catch (const std::exception& ex) { + Log(LogCritical, "ApiListener") + << ex.what(); + continue; + } + + Log(LogNotice, "ApiListener") + << "Updating cache: Config package '" << package << "' has active stage '" << activeStage << "'."; + + m_ActivePackageStages[package] = activeStage; + } +} + +void ApiListener::CheckApiPackageIntegrity() +{ + std::unique_lock<std::mutex> lock(m_ActivePackageStagesLock); + + for (auto package : ConfigPackageUtility::GetPackages()) { + String activeStage; + try { + activeStage = ConfigPackageUtility::GetActiveStageFromFile(package); + } catch (const std::exception& ex) { + /* An error means that the stage is broken, try to repair it. */ + auto it = m_ActivePackageStages.find(package); + + if (it == m_ActivePackageStages.end()) + continue; + + String activeStageCached = it->second; + + Log(LogInformation, "ApiListener") + << "Repairing broken API config package '" << package + << "', setting active stage '" << activeStageCached << "'."; + + ConfigPackageUtility::SetActiveStageToFile(package, activeStageCached); + } + } +} + +void ApiListener::SetActivePackageStage(const String& package, const String& stage) +{ + std::unique_lock<std::mutex> lock(m_ActivePackageStagesLock); + m_ActivePackageStages[package] = stage; +} + +String ApiListener::GetActivePackageStage(const String& package) +{ + std::unique_lock<std::mutex> lock(m_ActivePackageStagesLock); + + if (m_ActivePackageStages.find(package) == m_ActivePackageStages.end()) + BOOST_THROW_EXCEPTION(ScriptError("Package " + package + " has no active stage.")); + + return m_ActivePackageStages[package]; +} + +void ApiListener::RemoveActivePackageStage(const String& package) +{ + /* This is the rare occassion when a package has been deleted. */ + std::unique_lock<std::mutex> lock(m_ActivePackageStagesLock); + + auto it = m_ActivePackageStages.find(package); + + if (it == m_ActivePackageStages.end()) + return; + + m_ActivePackageStages.erase(it); +} + +void ApiListener::ValidateTlsProtocolmin(const Lazy<String>& lvalue, const ValidationUtils& utils) +{ + ObjectImpl<ApiListener>::ValidateTlsProtocolmin(lvalue, utils); + + try { + ResolveTlsProtocolVersion(lvalue()); + } catch (const std::exception& ex) { + BOOST_THROW_EXCEPTION(ValidationError(this, { "tls_protocolmin" }, ex.what())); + } +} + +void ApiListener::ValidateTlsHandshakeTimeout(const Lazy<double>& lvalue, const ValidationUtils& utils) +{ + ObjectImpl<ApiListener>::ValidateTlsHandshakeTimeout(lvalue, utils); + + if (lvalue() <= 0) + BOOST_THROW_EXCEPTION(ValidationError(this, { "tls_handshake_timeout" }, "Value must be greater than 0.")); +} + +bool ApiListener::IsHACluster() +{ + Zone::Ptr zone = Zone::GetLocalZone(); + + if (!zone) + return false; + + return zone->IsSingleInstance(); +} + +/* Provide a helper function for zone origin name. */ +String ApiListener::GetFromZoneName(const Zone::Ptr& fromZone) +{ + String fromZoneName; + + if (fromZone) { + fromZoneName = fromZone->GetName(); + } else { + Zone::Ptr lzone = Zone::GetLocalZone(); + + if (lzone) + fromZoneName = lzone->GetName(); + } + + return fromZoneName; +} + +void ApiListener::UpdateStatusFile(boost::asio::ip::tcp::endpoint localEndpoint) +{ + String path = Configuration::CacheDir + "/api-state.json"; + + Utility::SaveJsonFile(path, 0644, new Dictionary({ + {"host", String(localEndpoint.address().to_string())}, + {"port", localEndpoint.port()} + })); +} + +void ApiListener::RemoveStatusFile() +{ + String path = Configuration::CacheDir + "/api-state.json"; + + Utility::Remove(path); +} diff --git a/lib/remote/apilistener.hpp b/lib/remote/apilistener.hpp new file mode 100644 index 0000000..1be2952 --- /dev/null +++ b/lib/remote/apilistener.hpp @@ -0,0 +1,263 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef APILISTENER_H +#define APILISTENER_H + +#include "remote/apilistener-ti.hpp" +#include "remote/jsonrpcconnection.hpp" +#include "remote/httpserverconnection.hpp" +#include "remote/endpoint.hpp" +#include "remote/messageorigin.hpp" +#include "base/configobject.hpp" +#include "base/process.hpp" +#include "base/shared.hpp" +#include "base/timer.hpp" +#include "base/workqueue.hpp" +#include "base/tcpsocket.hpp" +#include "base/tlsstream.hpp" +#include "base/threadpool.hpp" +#include <atomic> +#include <boost/asio/io_context.hpp> +#include <boost/asio/ip/tcp.hpp> +#include <boost/asio/spawn.hpp> +#include <boost/asio/ssl/context.hpp> +#include <boost/thread/shared_mutex.hpp> +#include <cstdint> +#include <mutex> +#include <set> + +namespace icinga +{ + +class JsonRpcConnection; + +/** + * @ingroup remote + */ +struct ConfigDirInformation +{ + Dictionary::Ptr UpdateV1; + Dictionary::Ptr UpdateV2; + Dictionary::Ptr Checksums; +}; + +/** + * If the version reported by icinga::Hello is not enough to tell whether + * the peer has a specific capability, add the latter to this bitmask. + * + * Note that due to the capability exchange via JSON-RPC and the state storage via JSON + * the bitmask numbers are stored in IEEE 754 64-bit floats. + * The latter have 53 digit bits which limit the bitmask. + * Not to run out of bits: + * + * Once all Icinga versions which don't have a specific capability are completely EOL, + * remove the respective capability checks and assume the peer has the capability. + * Once all Icinga versions which still check for the capability are completely EOL, + * remove the respective bit from icinga::Hello. + * Once all Icinga versions which still have the respective bit in icinga::Hello + * are completely EOL, remove the bit here. + * Once all Icinga versions which still have the respective bit here + * are completely EOL, feel free to re-use the bit. + * + * completely EOL = not supported, even if an important customer of us used it and + * not expected to appear in a multi-level cluster, e.g. a 4 level cluster with + * v2.11 -> v2.10 -> v2.9 -> v2.8 - v2.7 isn't here + * + * @ingroup remote + */ +enum class ApiCapabilities : uint_fast64_t +{ + ExecuteArbitraryCommand = 1u +}; + +/** +* @ingroup remote +*/ +class ApiListener final : public ObjectImpl<ApiListener> +{ +public: + DECLARE_OBJECT(ApiListener); + DECLARE_OBJECTNAME(ApiListener); + + static boost::signals2::signal<void(bool)> OnMasterChanged; + + ApiListener(); + + static String GetApiDir(); + static String GetApiZonesDir(); + static String GetApiZonesStageDir(); + static String GetCertsDir(); + static String GetCaDir(); + static String GetCertificateRequestsDir(); + + std::shared_ptr<X509> RenewCert(const std::shared_ptr<X509>& cert); + void UpdateSSLContext(); + + static ApiListener::Ptr GetInstance(); + + Endpoint::Ptr GetMaster() const; + bool IsMaster() const; + + Endpoint::Ptr GetLocalEndpoint() const; + + void SyncSendMessage(const Endpoint::Ptr& endpoint, const Dictionary::Ptr& message); + void RelayMessage(const MessageOrigin::Ptr& origin, const ConfigObject::Ptr& secobj, const Dictionary::Ptr& message, bool log); + + static void StatsFunc(const Dictionary::Ptr& status, const Array::Ptr& perfdata); + std::pair<Dictionary::Ptr, Dictionary::Ptr> GetStatus(); + + bool AddAnonymousClient(const JsonRpcConnection::Ptr& aclient); + void RemoveAnonymousClient(const JsonRpcConnection::Ptr& aclient); + std::set<JsonRpcConnection::Ptr> GetAnonymousClients() const; + + void AddHttpClient(const HttpServerConnection::Ptr& aclient); + void RemoveHttpClient(const HttpServerConnection::Ptr& aclient); + std::set<HttpServerConnection::Ptr> GetHttpClients() const; + + static double CalculateZoneLag(const Endpoint::Ptr& endpoint); + + /* filesync */ + static Value ConfigUpdateHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); + void HandleConfigUpdate(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); + + /* configsync */ + static void ConfigUpdateObjectHandler(const ConfigObject::Ptr& object, const Value& cookie); + static Value ConfigUpdateObjectAPIHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); + static Value ConfigDeleteObjectAPIHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); + + /* API config packages */ + void SetActivePackageStage(const String& package, const String& stage); + String GetActivePackageStage(const String& package); + void RemoveActivePackageStage(const String& package); + + static Value HelloAPIHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); + + static void UpdateObjectAuthority(); + + static bool IsHACluster(); + static String GetFromZoneName(const Zone::Ptr& fromZone); + + static String GetDefaultCertPath(); + static String GetDefaultKeyPath(); + static String GetDefaultCaPath(); + + static inline + bool UpdatedObjectAuthority() + { + return m_UpdatedObjectAuthority.load(); + } + + double GetTlsHandshakeTimeout() const override; + void SetTlsHandshakeTimeout(double value, bool suppress_events, const Value& cookie) override; + +protected: + void OnConfigLoaded() override; + void OnAllConfigLoaded() override; + void Start(bool runtimeCreated) override; + void Stop(bool runtimeDeleted) override; + + void ValidateTlsProtocolmin(const Lazy<String>& lvalue, const ValidationUtils& utils) override; + void ValidateTlsHandshakeTimeout(const Lazy<double>& lvalue, const ValidationUtils& utils) override; + +private: + Shared<boost::asio::ssl::context>::Ptr m_SSLContext; + boost::shared_mutex m_SSLContextMutex; + + mutable std::mutex m_AnonymousClientsLock; + mutable std::mutex m_HttpClientsLock; + std::set<JsonRpcConnection::Ptr> m_AnonymousClients; + std::set<HttpServerConnection::Ptr> m_HttpClients; + + Timer::Ptr m_Timer; + Timer::Ptr m_ReconnectTimer; + Timer::Ptr m_AuthorityTimer; + Timer::Ptr m_CleanupCertificateRequestsTimer; + Timer::Ptr m_ApiPackageIntegrityTimer; + Timer::Ptr m_RenewOwnCertTimer; + + Endpoint::Ptr m_LocalEndpoint; + + static ApiListener::Ptr m_Instance; + static std::atomic<bool> m_UpdatedObjectAuthority; + + void ApiTimerHandler(); + void ApiReconnectTimerHandler(); + void CleanupCertificateRequestsTimerHandler(); + void CheckApiPackageIntegrity(); + + bool AddListener(const String& node, const String& service); + void AddConnection(const Endpoint::Ptr& endpoint); + + void NewClientHandler( + boost::asio::yield_context yc, const Shared<boost::asio::io_context::strand>::Ptr& strand, + const Shared<AsioTlsStream>::Ptr& client, const String& hostname, ConnectionRole role + ); + void NewClientHandlerInternal( + boost::asio::yield_context yc, const Shared<boost::asio::io_context::strand>::Ptr& strand, + const Shared<AsioTlsStream>::Ptr& client, const String& hostname, ConnectionRole role + ); + void ListenerCoroutineProc(boost::asio::yield_context yc, const Shared<boost::asio::ip::tcp::acceptor>::Ptr& server); + + WorkQueue m_RelayQueue; + WorkQueue m_SyncQueue{0, 4}; + + std::mutex m_LogLock; + Stream::Ptr m_LogFile; + size_t m_LogMessageCount{0}; + + bool RelayMessageOne(const Zone::Ptr& zone, const MessageOrigin::Ptr& origin, const Dictionary::Ptr& message, const Endpoint::Ptr& currentZoneMaster); + void SyncRelayMessage(const MessageOrigin::Ptr& origin, const ConfigObject::Ptr& secobj, const Dictionary::Ptr& message, bool log); + void PersistMessage(const Dictionary::Ptr& message, const ConfigObject::Ptr& secobj); + + void OpenLogFile(); + void RotateLogFile(); + void CloseLogFile(); + static void LogGlobHandler(std::vector<int>& files, const String& file); + void ReplayLog(const JsonRpcConnection::Ptr& client); + + static void CopyCertificateFile(const String& oldCertPath, const String& newCertPath); + + void UpdateStatusFile(boost::asio::ip::tcp::endpoint localEndpoint); + void RemoveStatusFile(); + + /* filesync */ + static std::mutex m_ConfigSyncStageLock; + + void SyncLocalZoneDirs() const; + void SyncLocalZoneDir(const Zone::Ptr& zone) const; + void RenewOwnCert(); + + void SendConfigUpdate(const JsonRpcConnection::Ptr& aclient); + + static Dictionary::Ptr MergeConfigUpdate(const ConfigDirInformation& config); + + static ConfigDirInformation LoadConfigDir(const String& dir); + static void ConfigGlobHandler(ConfigDirInformation& config, const String& path, const String& file); + + static void TryActivateZonesStage(const std::vector<String>& relativePaths); + + static String GetChecksum(const String& content); + static bool CheckConfigChange(const ConfigDirInformation& oldConfig, const ConfigDirInformation& newConfig); + + void UpdateLastFailedZonesStageValidation(const String& log); + void ClearLastFailedZonesStageValidation(); + + /* configsync */ + void UpdateConfigObject(const ConfigObject::Ptr& object, const MessageOrigin::Ptr& origin, + const JsonRpcConnection::Ptr& client = nullptr); + void DeleteConfigObject(const ConfigObject::Ptr& object, const MessageOrigin::Ptr& origin, + const JsonRpcConnection::Ptr& client = nullptr); + void SendRuntimeConfigObjects(const JsonRpcConnection::Ptr& aclient); + + void SyncClient(const JsonRpcConnection::Ptr& aclient, const Endpoint::Ptr& endpoint, bool needSync); + + /* API Config Packages */ + mutable std::mutex m_ActivePackageStagesLock; + std::map<String, String> m_ActivePackageStages; + + void UpdateActivePackageStagesCache(); +}; + +} + +#endif /* APILISTENER_H */ diff --git a/lib/remote/apilistener.ti b/lib/remote/apilistener.ti new file mode 100644 index 0000000..8317abc --- /dev/null +++ b/lib/remote/apilistener.ti @@ -0,0 +1,66 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/i2-remote.hpp" +#include "base/configobject.hpp" +#include "base/application.hpp" +#include "base/tlsutility.hpp" + +library remote; + +namespace icinga +{ + +class ApiListener : ConfigObject +{ + activation_priority 50; + + [config, deprecated] String cert_path; + [config, deprecated] String key_path; + [config, deprecated] String ca_path; + [config] String crl_path; + [config] String cipher_list { + default {{{ return DEFAULT_TLS_CIPHERS; }}} + }; + [config] String tls_protocolmin { + default {{{ return DEFAULT_TLS_PROTOCOLMIN; }}} + }; + + [config] String bind_host { + default {{{ return Configuration::ApiBindHost; }}} + }; + [config] String bind_port { + default {{{ return Configuration::ApiBindPort; }}} + }; + + [config] bool accept_config; + [config] bool accept_commands; + [config] int max_anonymous_clients { + default {{{ return -1; }}} + }; + + [config, deprecated] double tls_handshake_timeout { + get; + set; + default {{{ return Configuration::TlsHandshakeTimeout; }}} + }; + + [config] double connect_timeout { + default {{{ return DEFAULT_CONNECT_TIMEOUT; }}} + }; + + [config, no_user_view, no_user_modify] String ticket_salt; + + [config] Array::Ptr access_control_allow_origin; + [config, deprecated] bool access_control_allow_credentials; + [config, deprecated] String access_control_allow_headers; + [config, deprecated] String access_control_allow_methods; + + + [state, no_user_modify] Timestamp log_message_timestamp; + + [no_user_modify] String identity; + + [state, no_user_modify] Dictionary::Ptr last_failed_zones_stage_validation; +}; + +} diff --git a/lib/remote/apiuser.cpp b/lib/remote/apiuser.cpp new file mode 100644 index 0000000..2959d89 --- /dev/null +++ b/lib/remote/apiuser.cpp @@ -0,0 +1,55 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/apiuser.hpp" +#include "remote/apiuser-ti.cpp" +#include "base/configtype.hpp" +#include "base/base64.hpp" +#include "base/tlsutility.hpp" +#include "base/utility.hpp" + +using namespace icinga; + +REGISTER_TYPE(ApiUser); + +ApiUser::Ptr ApiUser::GetByClientCN(const String& cn) +{ + for (const ApiUser::Ptr& user : ConfigType::GetObjectsByType<ApiUser>()) { + if (user->GetClientCN() == cn) + return user; + } + + return nullptr; +} + +ApiUser::Ptr ApiUser::GetByAuthHeader(const String& auth_header) +{ + String::SizeType pos = auth_header.FindFirstOf(" "); + String username, password; + + if (pos != String::NPos && auth_header.SubStr(0, pos) == "Basic") { + String credentials_base64 = auth_header.SubStr(pos + 1); + String credentials = Base64::Decode(credentials_base64); + + String::SizeType cpos = credentials.FindFirstOf(":"); + + if (cpos != String::NPos) { + username = credentials.SubStr(0, cpos); + password = credentials.SubStr(cpos + 1); + } + } + + const ApiUser::Ptr& user = ApiUser::GetByName(username); + + /* Deny authentication if: + * 1) user does not exist + * 2) given password is empty + * 2) configured password does not match. + */ + if (!user || password.IsEmpty()) + return nullptr; + else if (user && !Utility::ComparePasswords(password, user->GetPassword())) + return nullptr; + + return user; +} + diff --git a/lib/remote/apiuser.hpp b/lib/remote/apiuser.hpp new file mode 100644 index 0000000..fc132ee --- /dev/null +++ b/lib/remote/apiuser.hpp @@ -0,0 +1,27 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef APIUSER_H +#define APIUSER_H + +#include "remote/i2-remote.hpp" +#include "remote/apiuser-ti.hpp" + +namespace icinga +{ + +/** + * @ingroup remote + */ +class ApiUser final : public ObjectImpl<ApiUser> +{ +public: + DECLARE_OBJECT(ApiUser); + DECLARE_OBJECTNAME(ApiUser); + + static ApiUser::Ptr GetByClientCN(const String& cn); + static ApiUser::Ptr GetByAuthHeader(const String& auth_header); +}; + +} + +#endif /* APIUSER_H */ diff --git a/lib/remote/apiuser.ti b/lib/remote/apiuser.ti new file mode 100644 index 0000000..0b49a1d --- /dev/null +++ b/lib/remote/apiuser.ti @@ -0,0 +1,31 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "base/configobject.hpp" +#include "base/function.hpp" + +library remote; + +namespace icinga +{ + +class ApiUser : ConfigObject +{ + /* No show config */ + [config, no_user_view] String password; + [deprecated, config, no_user_view] String password_hash; + [config] String client_cn (ClientCN); + [config] array(Value) permissions; +}; + +validator ApiUser { + Array permissions { + String "*"; + Dictionary "*" { + required permission; + String permission; + Function filter; + }; + }; +}; + +} diff --git a/lib/remote/configfileshandler.cpp b/lib/remote/configfileshandler.cpp new file mode 100644 index 0000000..779ecd1 --- /dev/null +++ b/lib/remote/configfileshandler.cpp @@ -0,0 +1,94 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/configfileshandler.hpp" +#include "remote/configpackageutility.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "base/exception.hpp" +#include "base/utility.hpp" +#include <boost/algorithm/string/join.hpp> +#include <fstream> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/config/files", ConfigFilesHandler); + +bool ConfigFilesHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (request.method() != http::verb::get) + return false; + + const std::vector<String>& urlPath = url->GetPath(); + + if (urlPath.size() >= 4) + params->Set("package", urlPath[3]); + + if (urlPath.size() >= 5) + params->Set("stage", urlPath[4]); + + if (urlPath.size() >= 6) { + std::vector<String> tmpPath(urlPath.begin() + 5, urlPath.end()); + params->Set("path", boost::algorithm::join(tmpPath, "/")); + } + + if (request[http::field::accept] == "application/json") { + HttpUtility::SendJsonError(response, params, 400, "Invalid Accept header. Either remove the Accept header or set it to 'application/octet-stream'."); + return true; + } + + FilterUtility::CheckPermission(user, "config/query"); + + String packageName = HttpUtility::GetLastParameter(params, "package"); + String stageName = HttpUtility::GetLastParameter(params, "stage"); + + if (!ConfigPackageUtility::ValidatePackageName(packageName)) { + HttpUtility::SendJsonError(response, params, 400, "Invalid package name."); + return true; + } + + if (!ConfigPackageUtility::ValidateStageName(stageName)) { + HttpUtility::SendJsonError(response, params, 400, "Invalid stage name."); + return true; + } + + String relativePath = HttpUtility::GetLastParameter(params, "path"); + + if (ConfigPackageUtility::ContainsDotDot(relativePath)) { + HttpUtility::SendJsonError(response, params, 400, "Path contains '..' (not allowed)."); + return true; + } + + String path = ConfigPackageUtility::GetPackageDir() + "/" + packageName + "/" + stageName + "/" + relativePath; + + if (!Utility::PathExists(path)) { + HttpUtility::SendJsonError(response, params, 404, "Path not found."); + return true; + } + + try { + std::ifstream fp(path.CStr(), std::ifstream::in | std::ifstream::binary); + fp.exceptions(std::ifstream::badbit); + + String content((std::istreambuf_iterator<char>(fp)), std::istreambuf_iterator<char>()); + response.result(http::status::ok); + response.set(http::field::content_type, "application/octet-stream"); + response.body() = content; + response.content_length(response.body().size()); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 500, "Could not read file.", + DiagnosticInformation(ex)); + } + + return true; +} diff --git a/lib/remote/configfileshandler.hpp b/lib/remote/configfileshandler.hpp new file mode 100644 index 0000000..ea48b1e --- /dev/null +++ b/lib/remote/configfileshandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef CONFIGFILESHANDLER_H +#define CONFIGFILESHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class ConfigFilesHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(ConfigFilesHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* CONFIGFILESHANDLER_H */ diff --git a/lib/remote/configobjectutility.cpp b/lib/remote/configobjectutility.cpp new file mode 100644 index 0000000..1faeb9a --- /dev/null +++ b/lib/remote/configobjectutility.cpp @@ -0,0 +1,377 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/configobjectutility.hpp" +#include "remote/configpackageutility.hpp" +#include "remote/apilistener.hpp" +#include "config/configcompiler.hpp" +#include "config/configitem.hpp" +#include "base/configwriter.hpp" +#include "base/exception.hpp" +#include "base/dependencygraph.hpp" +#include "base/tlsutility.hpp" +#include "base/utility.hpp" +#include <boost/algorithm/string/case_conv.hpp> +#include <boost/filesystem.hpp> +#include <boost/system/error_code.hpp> +#include <fstream> +#include <utility> + +using namespace icinga; + +String ConfigObjectUtility::GetConfigDir() +{ + String prefix = ConfigPackageUtility::GetPackageDir() + "/_api/"; + String activeStage = ConfigPackageUtility::GetActiveStage("_api"); + + if (activeStage.IsEmpty()) + RepairPackage("_api"); + + return prefix + activeStage; +} + +String ConfigObjectUtility::GetObjectConfigPath(const Type::Ptr& type, const String& fullName) +{ + String typeDir = type->GetPluralName(); + boost::algorithm::to_lower(typeDir); + + /* This may throw an exception the caller above must handle. */ + String prefix = GetConfigDir() + "/conf.d/" + type->GetPluralName().ToLower() + "/"; + + String escapedName = EscapeName(fullName); + + String longPath = prefix + escapedName + ".conf"; + + /* + * The long path may cause trouble due to exceeding the allowed filename length of the filesystem. Therefore, the + * preferred solution would be to use the truncated and hashed version as returned at the end of this function. + * However, for compatibility reasons, we have to keep the old long version in some cases. Notably, this could lead + * to the creation of objects that can't be synced to child nodes if they are running an older version. Thus, for + * now, the fix is only enabled for comments and downtimes, as these are the object types for which the issue is + * most likely triggered but can't be worked around easily (you'd have to rename the host and/or service in order to + * be able to schedule a downtime or add an acknowledgement, which is not feasible) and the impact of not syncing + * these objects through the whole cluster is limited. For other object types, we currently prefer to fail the + * creation early so that configuration inconsistencies throughout the cluster are avoided. + */ + if ((type->GetName() != "Comment" && type->GetName() != "Downtime") || Utility::PathExists(longPath)) { + return std::move(longPath); + } + + /* Maximum length 80 bytes object name + 3 bytes "..." + 40 bytes SHA1 (hex-encoded) */ + return prefix + Utility::TruncateUsingHash<80+3+40>(escapedName) + ".conf"; +} + +void ConfigObjectUtility::RepairPackage(const String& package) +{ + /* Try to fix the active stage, whenever we find a directory in there. + * This automatically heals packages < 2.11 which remained broken. + */ + String dir = ConfigPackageUtility::GetPackageDir() + "/" + package + "/"; + + namespace fs = boost::filesystem; + + /* Use iterators to workaround VS builds on Windows. */ + fs::path path(dir.Begin(), dir.End()); + + fs::recursive_directory_iterator end; + + String foundActiveStage; + + for (fs::recursive_directory_iterator it(path); it != end; it++) { + boost::system::error_code ec; + + const fs::path d = *it; + if (fs::is_directory(d, ec)) { + /* Extract the relative directory name. */ + foundActiveStage = d.stem().string(); + + break; // Use the first found directory. + } + } + + if (!foundActiveStage.IsEmpty()) { + Log(LogInformation, "ConfigObjectUtility") + << "Repairing config package '" << package << "' with stage '" << foundActiveStage << "'."; + + ConfigPackageUtility::ActivateStage(package, foundActiveStage); + } else { + BOOST_THROW_EXCEPTION(std::invalid_argument("Cannot repair package '" + package + "', please check the troubleshooting docs.")); + } +} + +void ConfigObjectUtility::CreateStorage() +{ + std::unique_lock<std::mutex> lock(ConfigPackageUtility::GetStaticPackageMutex()); + + /* For now, we only use _api as our creation target. */ + String package = "_api"; + + if (!ConfigPackageUtility::PackageExists(package)) { + Log(LogNotice, "ConfigObjectUtility") + << "Package " << package << " doesn't exist yet, creating it."; + + ConfigPackageUtility::CreatePackage(package); + + String stage = ConfigPackageUtility::CreateStage(package); + ConfigPackageUtility::ActivateStage(package, stage); + } +} + +String ConfigObjectUtility::EscapeName(const String& name) +{ + return Utility::EscapeString(name, "<>:\"/\\|?*", true); +} + +String ConfigObjectUtility::CreateObjectConfig(const Type::Ptr& type, const String& fullName, + bool ignoreOnError, const Array::Ptr& templates, const Dictionary::Ptr& attrs) +{ + auto *nc = dynamic_cast<NameComposer *>(type.get()); + Dictionary::Ptr nameParts; + String name; + + if (nc) { + nameParts = nc->ParseName(fullName); + name = nameParts->Get("name"); + } else + name = fullName; + + Dictionary::Ptr allAttrs = new Dictionary(); + + if (attrs) { + attrs->CopyTo(allAttrs); + + ObjectLock olock(attrs); + for (const Dictionary::Pair& kv : attrs) { + int fid = type->GetFieldId(kv.first.SubStr(0, kv.first.FindFirstOf("."))); + + if (fid < 0) + BOOST_THROW_EXCEPTION(ScriptError("Invalid attribute specified: " + kv.first)); + + Field field = type->GetFieldInfo(fid); + + if (!(field.Attributes & FAConfig) || kv.first == "name") + BOOST_THROW_EXCEPTION(ScriptError("Attribute is marked for internal use only and may not be set: " + kv.first)); + } + } + + if (nameParts) + nameParts->CopyTo(allAttrs); + + allAttrs->Remove("name"); + + /* update the version for config sync */ + allAttrs->Set("version", Utility::GetTime()); + + std::ostringstream config; + ConfigWriter::EmitConfigItem(config, type->GetName(), name, false, ignoreOnError, templates, allAttrs); + ConfigWriter::EmitRaw(config, "\n"); + + return config.str(); +} + +bool ConfigObjectUtility::CreateObject(const Type::Ptr& type, const String& fullName, + const String& config, const Array::Ptr& errors, const Array::Ptr& diagnosticInformation, const Value& cookie) +{ + CreateStorage(); + + { + auto configType (dynamic_cast<ConfigType*>(type.get())); + + if (configType && configType->GetObject(fullName)) { + errors->Add("Object '" + fullName + "' already exists."); + return false; + } + } + + String path; + + try { + path = GetObjectConfigPath(type, fullName); + } catch (const std::exception& ex) { + errors->Add("Config package broken: " + DiagnosticInformation(ex, false)); + return false; + } + + Utility::MkDirP(Utility::DirName(path), 0700); + + std::ofstream fp(path.CStr(), std::ofstream::out | std::ostream::trunc); + fp << config; + fp.close(); + + std::unique_ptr<Expression> expr = ConfigCompiler::CompileFile(path, String(), "_api"); + + try { + ActivationScope ascope; + + ScriptFrame frame(true); + expr->Evaluate(frame); + expr.reset(); + + WorkQueue upq; + upq.SetName("ConfigObjectUtility::CreateObject"); + + std::vector<ConfigItem::Ptr> newItems; + + /* + * Disable logging for object creation, but do so ourselves later on. + * Duplicate the error handling for better logging and debugging here. + */ + if (!ConfigItem::CommitItems(ascope.GetContext(), upq, newItems, true)) { + if (errors) { + Log(LogNotice, "ConfigObjectUtility") + << "Failed to commit config item '" << fullName << "'. Aborting and removing config path '" << path << "'."; + + Utility::Remove(path); + + for (const boost::exception_ptr& ex : upq.GetExceptions()) { + errors->Add(DiagnosticInformation(ex, false)); + + if (diagnosticInformation) + diagnosticInformation->Add(DiagnosticInformation(ex)); + } + } + + return false; + } + + /* + * Activate the config object. + * uq, items, runtimeCreated, silent, withModAttrs, cookie + * IMPORTANT: Forward the cookie aka origin in order to prevent sync loops in the same zone! + */ + if (!ConfigItem::ActivateItems(newItems, true, false, false, cookie)) { + if (errors) { + Log(LogNotice, "ConfigObjectUtility") + << "Failed to activate config object '" << fullName << "'. Aborting and removing config path '" << path << "'."; + + Utility::Remove(path); + + for (const boost::exception_ptr& ex : upq.GetExceptions()) { + errors->Add(DiagnosticInformation(ex, false)); + + if (diagnosticInformation) + diagnosticInformation->Add(DiagnosticInformation(ex)); + } + } + + return false; + } + + /* if (type != Comment::TypeInstance && type != Downtime::TypeInstance) + * Does not work since this would require libicinga, which has a dependency on libremote + * Would work if these libs were static. + */ + if (type->GetName() != "Comment" && type->GetName() != "Downtime") + ApiListener::UpdateObjectAuthority(); + + // At this stage we should have a config object already. If not, it was ignored before. + auto *ctype = dynamic_cast<ConfigType *>(type.get()); + ConfigObject::Ptr obj = ctype->GetObject(fullName); + + if (obj) { + Log(LogInformation, "ConfigObjectUtility") + << "Created and activated object '" << fullName << "' of type '" << type->GetName() << "'."; + } else { + Log(LogNotice, "ConfigObjectUtility") + << "Object '" << fullName << "' was not created but ignored due to errors."; + } + + } catch (const std::exception& ex) { + Utility::Remove(path); + + if (errors) + errors->Add(DiagnosticInformation(ex, false)); + + if (diagnosticInformation) + diagnosticInformation->Add(DiagnosticInformation(ex)); + + return false; + } + + return true; +} + +bool ConfigObjectUtility::DeleteObjectHelper(const ConfigObject::Ptr& object, bool cascade, + const Array::Ptr& errors, const Array::Ptr& diagnosticInformation, const Value& cookie) +{ + std::vector<Object::Ptr> parents = DependencyGraph::GetParents(object); + + Type::Ptr type = object->GetReflectionType(); + + String name = object->GetName(); + + if (!parents.empty() && !cascade) { + if (errors) { + errors->Add("Object '" + name + "' of type '" + type->GetName() + + "' cannot be deleted because other objects depend on it. " + "Use cascading delete to delete it anyway."); + } + + return false; + } + + for (const Object::Ptr& pobj : parents) { + ConfigObject::Ptr parentObj = dynamic_pointer_cast<ConfigObject>(pobj); + + if (!parentObj) + continue; + + DeleteObjectHelper(parentObj, cascade, errors, diagnosticInformation, cookie); + } + + ConfigItem::Ptr item = ConfigItem::GetByTypeAndName(type, name); + + try { + /* mark this object for cluster delete event */ + object->SetExtension("ConfigObjectDeleted", true); + + /* + * Trigger deactivation signal for DB IDO and runtime object delections. + * IMPORTANT: Specify the cookie aka origin in order to prevent sync loops + * in the same zone! + */ + object->Deactivate(true, cookie); + + if (item) + item->Unregister(); + else + object->Unregister(); + + } catch (const std::exception& ex) { + if (errors) + errors->Add(DiagnosticInformation(ex, false)); + + if (diagnosticInformation) + diagnosticInformation->Add(DiagnosticInformation(ex)); + + return false; + } + + String path; + + try { + path = GetObjectConfigPath(object->GetReflectionType(), name); + } catch (const std::exception& ex) { + errors->Add("Config package broken: " + DiagnosticInformation(ex, false)); + return false; + } + + Utility::Remove(path); + + Log(LogInformation, "ConfigObjectUtility") + << "Deleted object '" << name << "' of type '" << type->GetName() << "'."; + + return true; +} + +bool ConfigObjectUtility::DeleteObject(const ConfigObject::Ptr& object, bool cascade, const Array::Ptr& errors, + const Array::Ptr& diagnosticInformation, const Value& cookie) +{ + if (object->GetPackage() != "_api") { + if (errors) + errors->Add("Object cannot be deleted because it was not created using the API."); + + return false; + } + + return DeleteObjectHelper(object, cascade, errors, diagnosticInformation, cookie); +} diff --git a/lib/remote/configobjectutility.hpp b/lib/remote/configobjectutility.hpp new file mode 100644 index 0000000..f383a21 --- /dev/null +++ b/lib/remote/configobjectutility.hpp @@ -0,0 +1,46 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef CONFIGOBJECTUTILITY_H +#define CONFIGOBJECTUTILITY_H + +#include "remote/i2-remote.hpp" +#include "base/array.hpp" +#include "base/configobject.hpp" +#include "base/dictionary.hpp" +#include "base/type.hpp" + +namespace icinga +{ + +/** + * Helper functions. + * + * @ingroup remote + */ +class ConfigObjectUtility +{ + +public: + static String GetConfigDir(); + static String GetObjectConfigPath(const Type::Ptr& type, const String& fullName); + static void RepairPackage(const String& package); + static void CreateStorage(); + + static String CreateObjectConfig(const Type::Ptr& type, const String& fullName, + bool ignoreOnError, const Array::Ptr& templates, const Dictionary::Ptr& attrs); + + static bool CreateObject(const Type::Ptr& type, const String& fullName, + const String& config, const Array::Ptr& errors, const Array::Ptr& diagnosticInformation, const Value& cookie = Empty); + + static bool DeleteObject(const ConfigObject::Ptr& object, bool cascade, const Array::Ptr& errors, + const Array::Ptr& diagnosticInformation, const Value& cookie = Empty); + +private: + static String EscapeName(const String& name); + static bool DeleteObjectHelper(const ConfigObject::Ptr& object, bool cascade, const Array::Ptr& errors, + const Array::Ptr& diagnosticInformation, const Value& cookie = Empty); +}; + +} + +#endif /* CONFIGOBJECTUTILITY_H */ diff --git a/lib/remote/configpackageshandler.cpp b/lib/remote/configpackageshandler.cpp new file mode 100644 index 0000000..98b3268 --- /dev/null +++ b/lib/remote/configpackageshandler.cpp @@ -0,0 +1,179 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/configpackageshandler.hpp" +#include "remote/configpackageutility.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "base/exception.hpp" + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/config/packages", ConfigPackagesHandler); + +bool ConfigPackagesHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() > 4) + return false; + + if (request.method() == http::verb::get) + HandleGet(user, request, url, response, params); + else if (request.method() == http::verb::post) + HandlePost(user, request, url, response, params); + else if (request.method() == http::verb::delete_) + HandleDelete(user, request, url, response, params); + else + return false; + + return true; +} + +void ConfigPackagesHandler::HandleGet( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params +) +{ + namespace http = boost::beast::http; + + FilterUtility::CheckPermission(user, "config/query"); + + std::vector<String> packages; + + try { + packages = ConfigPackageUtility::GetPackages(); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 500, "Could not retrieve packages.", + DiagnosticInformation(ex)); + return; + } + + ArrayData results; + + { + std::unique_lock<std::mutex> lock(ConfigPackageUtility::GetStaticPackageMutex()); + + for (const String& package : packages) { + String activeStage; + + try { + activeStage = ConfigPackageUtility::GetActiveStage(package); + } catch (const std::exception&) { } /* Should never happen. */ + + results.emplace_back(new Dictionary({ + { "name", package }, + { "stages", Array::FromVector(ConfigPackageUtility::GetStages(package)) }, + { "active-stage", activeStage } + })); + } + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); +} + +void ConfigPackagesHandler::HandlePost( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params +) +{ + namespace http = boost::beast::http; + + FilterUtility::CheckPermission(user, "config/modify"); + + if (url->GetPath().size() >= 4) + params->Set("package", url->GetPath()[3]); + + String packageName = HttpUtility::GetLastParameter(params, "package"); + + if (!ConfigPackageUtility::ValidatePackageName(packageName)) { + HttpUtility::SendJsonError(response, params, 400, "Invalid package name '" + packageName + "'."); + return; + } + + try { + std::unique_lock<std::mutex> lock(ConfigPackageUtility::GetStaticPackageMutex()); + + ConfigPackageUtility::CreatePackage(packageName); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 500, "Could not create package '" + packageName + "'.", + DiagnosticInformation(ex)); + return; + } + + Dictionary::Ptr result1 = new Dictionary({ + { "code", 200 }, + { "package", packageName }, + { "status", "Created package." } + }); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ result1 }) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); +} + +void ConfigPackagesHandler::HandleDelete( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params +) +{ + namespace http = boost::beast::http; + + FilterUtility::CheckPermission(user, "config/modify"); + + if (url->GetPath().size() >= 4) + params->Set("package", url->GetPath()[3]); + + String packageName = HttpUtility::GetLastParameter(params, "package"); + + if (!ConfigPackageUtility::ValidatePackageName(packageName)) { + HttpUtility::SendJsonError(response, params, 400, "Invalid package name '" + packageName + "'."); + return; + } + + try { + ConfigPackageUtility::DeletePackage(packageName); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 500, "Failed to delete package '" + packageName + "'.", + DiagnosticInformation(ex)); + return; + } + + Dictionary::Ptr result1 = new Dictionary({ + { "code", 200 }, + { "package", packageName }, + { "status", "Deleted package." } + }); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ result1 }) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); +} diff --git a/lib/remote/configpackageshandler.hpp b/lib/remote/configpackageshandler.hpp new file mode 100644 index 0000000..0a05ea1 --- /dev/null +++ b/lib/remote/configpackageshandler.hpp @@ -0,0 +1,54 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef CONFIGMODULESHANDLER_H +#define CONFIGMODULESHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class ConfigPackagesHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(ConfigPackagesHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; + +private: + void HandleGet( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params + ); + void HandlePost( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params + ); + void HandleDelete( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params + ); + +}; + +} + +#endif /* CONFIGMODULESHANDLER_H */ diff --git a/lib/remote/configpackageutility.cpp b/lib/remote/configpackageutility.cpp new file mode 100644 index 0000000..e795401 --- /dev/null +++ b/lib/remote/configpackageutility.cpp @@ -0,0 +1,413 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/configpackageutility.hpp" +#include "remote/apilistener.hpp" +#include "base/application.hpp" +#include "base/exception.hpp" +#include "base/utility.hpp" +#include <boost/algorithm/string.hpp> +#include <boost/regex.hpp> +#include <algorithm> +#include <cctype> +#include <fstream> + +using namespace icinga; + +String ConfigPackageUtility::GetPackageDir() +{ + return Configuration::DataDir + "/api/packages"; +} + +void ConfigPackageUtility::CreatePackage(const String& name) +{ + String path = GetPackageDir() + "/" + name; + + if (Utility::PathExists(path)) + BOOST_THROW_EXCEPTION(std::invalid_argument("Package already exists.")); + + Utility::MkDirP(path, 0700); + WritePackageConfig(name); +} + +void ConfigPackageUtility::DeletePackage(const String& name) +{ + String path = GetPackageDir() + "/" + name; + + if (!Utility::PathExists(path)) + BOOST_THROW_EXCEPTION(std::invalid_argument("Package does not exist.")); + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + /* config packages without API make no sense. */ + if (!listener) + BOOST_THROW_EXCEPTION(std::invalid_argument("No ApiListener instance configured.")); + + listener->RemoveActivePackageStage(name); + + Utility::RemoveDirRecursive(path); + Application::RequestRestart(); +} + +std::vector<String> ConfigPackageUtility::GetPackages() +{ + String packageDir = GetPackageDir(); + + std::vector<String> packages; + + /* Package directory does not exist, no packages have been created thus far. */ + if (!Utility::PathExists(packageDir)) + return packages; + + Utility::Glob(packageDir + "/*", [&packages](const String& path) { packages.emplace_back(Utility::BaseName(path)); }, GlobDirectory); + + return packages; +} + +bool ConfigPackageUtility::PackageExists(const String& name) +{ + auto packages (GetPackages()); + return std::find(packages.begin(), packages.end(), name) != packages.end(); +} + +String ConfigPackageUtility::CreateStage(const String& packageName, const Dictionary::Ptr& files) +{ + String stageName = Utility::NewUniqueID(); + + String path = GetPackageDir() + "/" + packageName; + + if (!Utility::PathExists(path)) + BOOST_THROW_EXCEPTION(std::invalid_argument("Package does not exist.")); + + path += "/" + stageName; + + Utility::MkDirP(path, 0700); + Utility::MkDirP(path + "/conf.d", 0700); + Utility::MkDirP(path + "/zones.d", 0700); + WriteStageConfig(packageName, stageName); + + bool foundDotDot = false; + + if (files) { + ObjectLock olock(files); + for (const Dictionary::Pair& kv : files) { + if (ContainsDotDot(kv.first)) { + foundDotDot = true; + break; + } + + String filePath = path + "/" + kv.first; + + Log(LogInformation, "ConfigPackageUtility") + << "Updating configuration file: " << filePath; + + // Pass the directory and generate a dir tree, if it does not already exist + Utility::MkDirP(Utility::DirName(filePath), 0750); + std::ofstream fp(filePath.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fp << kv.second; + fp.close(); + } + } + + if (foundDotDot) { + Utility::RemoveDirRecursive(path); + BOOST_THROW_EXCEPTION(std::invalid_argument("Path must not contain '..'.")); + } + + return stageName; +} + +void ConfigPackageUtility::WritePackageConfig(const String& packageName) +{ + String stageName = GetActiveStage(packageName); + + String includePath = GetPackageDir() + "/" + packageName + "/include.conf"; + std::ofstream fpInclude(includePath.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fpInclude << "include \"*/include.conf\"\n"; + fpInclude.close(); + + String activePath = GetPackageDir() + "/" + packageName + "/active.conf"; + std::ofstream fpActive(activePath.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fpActive << "if (!globals.contains(\"ActiveStages\")) {\n" + << " globals.ActiveStages = {}\n" + << "}\n" + << "\n" + << "if (globals.contains(\"ActiveStageOverride\")) {\n" + << " var arr = ActiveStageOverride.split(\":\")\n" + << " if (arr[0] == \"" << packageName << "\") {\n" + << " if (arr.len() < 2) {\n" + << " log(LogCritical, \"Config\", \"Invalid value for ActiveStageOverride\")\n" + << " } else {\n" + << " ActiveStages[\"" << packageName << "\"] = arr[1]\n" + << " }\n" + << " }\n" + << "}\n" + << "\n" + << "if (!ActiveStages.contains(\"" << packageName << "\")) {\n" + << " ActiveStages[\"" << packageName << "\"] = \"" << stageName << "\"\n" + << "}\n"; + fpActive.close(); +} + +void ConfigPackageUtility::WriteStageConfig(const String& packageName, const String& stageName) +{ + String path = GetPackageDir() + "/" + packageName + "/" + stageName + "/include.conf"; + std::ofstream fp(path.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fp << "include \"../active.conf\"\n" + << "if (ActiveStages[\"" << packageName << "\"] == \"" << stageName << "\") {\n" + << " include_recursive \"conf.d\"\n" + << " include_zones \"" << packageName << "\", \"zones.d\"\n" + << "}\n"; + fp.close(); +} + +void ConfigPackageUtility::ActivateStage(const String& packageName, const String& stageName) +{ + SetActiveStage(packageName, stageName); + + WritePackageConfig(packageName); +} + +void ConfigPackageUtility::TryActivateStageCallback(const ProcessResult& pr, const String& packageName, const String& stageName, + bool activate, bool reload, const Shared<Defer>::Ptr& resetPackageUpdates) +{ + String logFile = GetPackageDir() + "/" + packageName + "/" + stageName + "/startup.log"; + std::ofstream fpLog(logFile.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fpLog << pr.Output; + fpLog.close(); + + String statusFile = GetPackageDir() + "/" + packageName + "/" + stageName + "/status"; + std::ofstream fpStatus(statusFile.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); + fpStatus << pr.ExitStatus; + fpStatus.close(); + + /* validation went fine, activate stage and reload */ + if (pr.ExitStatus == 0) { + if (activate) { + { + std::unique_lock<std::mutex> lock(GetStaticPackageMutex()); + + ActivateStage(packageName, stageName); + } + + if (reload) { + /* + * Cancel the deferred callback before going out of scope so that the config stages handler + * flag isn't resetting earlier and allowing other clients to submit further requests while + * Icinga2 is reloading. Otherwise, the ongoing request will be cancelled halfway before the + * operation is completed once the new worker becomes ready. + */ + resetPackageUpdates->Cancel(); + + Application::RequestRestart(); + } + } + } else { + Log(LogCritical, "ConfigPackageUtility") + << "Config validation failed for package '" + << packageName << "' and stage '" << stageName << "'."; + } +} + +void ConfigPackageUtility::AsyncTryActivateStage(const String& packageName, const String& stageName, bool activate, bool reload, + const Shared<Defer>::Ptr& resetPackageUpdates) +{ + VERIFY(Application::GetArgC() >= 1); + + // prepare arguments + Array::Ptr args = new Array({ + Application::GetExePath(Application::GetArgV()[0]), + }); + + // copy all arguments of parent process + for (int i = 1; i < Application::GetArgC(); i++) { + String argV = Application::GetArgV()[i]; + + if (argV == "-d" || argV == "--daemonize") + continue; + + args->Add(argV); + } + + // add arguments for validation + args->Add("--validate"); + args->Add("--define"); + args->Add("ActiveStageOverride=" + packageName + ":" + stageName); + + Process::Ptr process = new Process(Process::PrepareCommand(args)); + process->SetTimeout(Application::GetReloadTimeout()); + process->Run([packageName, stageName, activate, reload, resetPackageUpdates](const ProcessResult& pr) { + TryActivateStageCallback(pr, packageName, stageName, activate, reload, resetPackageUpdates); + }); +} + +void ConfigPackageUtility::DeleteStage(const String& packageName, const String& stageName) +{ + String path = GetPackageDir() + "/" + packageName + "/" + stageName; + + if (!Utility::PathExists(path)) + BOOST_THROW_EXCEPTION(std::invalid_argument("Stage does not exist.")); + + if (GetActiveStage(packageName) == stageName) + BOOST_THROW_EXCEPTION(std::invalid_argument("Active stage cannot be deleted.")); + + Utility::RemoveDirRecursive(path); +} + +std::vector<String> ConfigPackageUtility::GetStages(const String& packageName) +{ + std::vector<String> stages; + Utility::Glob(GetPackageDir() + "/" + packageName + "/*", [&stages](const String& path) { stages.emplace_back(Utility::BaseName(path)); }, GlobDirectory); + return stages; +} + +String ConfigPackageUtility::GetActiveStageFromFile(const String& packageName) +{ + /* Lock the transaction, reading this only happens on startup or when something really is broken. */ + std::unique_lock<std::mutex> lock(GetStaticActiveStageMutex()); + + String path = GetPackageDir() + "/" + packageName + "/active-stage"; + + std::ifstream fp; + fp.open(path.CStr()); + + String stage; + std::getline(fp, stage.GetData()); + + fp.close(); + + if (fp.fail()) + return ""; /* Don't use exceptions here. The caller must deal with empty stages at this point. Happens on initial package creation for example. */ + + return stage.Trim(); +} + +void ConfigPackageUtility::SetActiveStageToFile(const String& packageName, const String& stageName) +{ + std::unique_lock<std::mutex> lock(GetStaticActiveStageMutex()); + + String activeStagePath = GetPackageDir() + "/" + packageName + "/active-stage"; + + std::ofstream fpActiveStage(activeStagePath.CStr(), std::ofstream::out | std::ostream::binary | std::ostream::trunc); //TODO: fstream exceptions + fpActiveStage << stageName; + fpActiveStage.close(); +} + +String ConfigPackageUtility::GetActiveStage(const String& packageName) +{ + String activeStage; + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + /* If we don't have an API feature, just use the file storage without caching this. + * This happens when ScheduledDowntime objects generate Downtime objects. + * TODO: Make the API a first class citizen. + */ + if (!listener) + return GetActiveStageFromFile(packageName); + + /* First use runtime state. */ + try { + activeStage = listener->GetActivePackageStage(packageName); + } catch (const std::exception& ex) { + /* Fallback to reading the file, happens on restarts. */ + activeStage = GetActiveStageFromFile(packageName); + + /* When we've read something, correct memory. */ + if (!activeStage.IsEmpty()) + listener->SetActivePackageStage(packageName, activeStage); + } + + return activeStage; +} + +void ConfigPackageUtility::SetActiveStage(const String& packageName, const String& stageName) +{ + /* Update the marker on disk for restarts. */ + SetActiveStageToFile(packageName, stageName); + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + /* No API, no caching. */ + if (!listener) + return; + + listener->SetActivePackageStage(packageName, stageName); +} + +std::vector<std::pair<String, bool> > ConfigPackageUtility::GetFiles(const String& packageName, const String& stageName) +{ + std::vector<std::pair<String, bool> > paths; + Utility::GlobRecursive(GetPackageDir() + "/" + packageName + "/" + stageName, "*", [&paths](const String& path) { + CollectPaths(path, paths); + }, GlobDirectory | GlobFile); + + return paths; +} + +void ConfigPackageUtility::CollectPaths(const String& path, std::vector<std::pair<String, bool> >& paths) +{ +#ifndef _WIN32 + struct stat statbuf; + int rc = lstat(path.CStr(), &statbuf); + if (rc < 0) + BOOST_THROW_EXCEPTION(posix_error() + << boost::errinfo_api_function("lstat") + << boost::errinfo_errno(errno) + << boost::errinfo_file_name(path)); + + paths.emplace_back(path, S_ISDIR(statbuf.st_mode)); +#else /* _WIN32 */ + struct _stat statbuf; + int rc = _stat(path.CStr(), &statbuf); + if (rc < 0) + BOOST_THROW_EXCEPTION(posix_error() + << boost::errinfo_api_function("_stat") + << boost::errinfo_errno(errno) + << boost::errinfo_file_name(path)); + + paths.emplace_back(path, ((statbuf.st_mode & S_IFMT) == S_IFDIR)); +#endif /* _WIN32 */ +} + +bool ConfigPackageUtility::ContainsDotDot(const String& path) +{ + std::vector<String> tokens = path.Split("/\\"); + + for (const String& part : tokens) { + if (part == "..") + return true; + } + + return false; +} + +bool ConfigPackageUtility::ValidatePackageName(const String& packageName) +{ + return ValidateFreshName(packageName) || PackageExists(packageName); +} + +bool ConfigPackageUtility::ValidateFreshName(const String& name) +{ + if (name.IsEmpty()) + return false; + + /* check for path injection */ + if (ContainsDotDot(name)) + return false; + + return std::all_of(name.Begin(), name.End(), [](char c) { + return std::isalnum(c, std::locale::classic()) || c == '_' || c == '-'; + }); +} + +std::mutex& ConfigPackageUtility::GetStaticPackageMutex() +{ + static std::mutex mutex; + return mutex; +} + +std::mutex& ConfigPackageUtility::GetStaticActiveStageMutex() +{ + static std::mutex mutex; + return mutex; +} diff --git a/lib/remote/configpackageutility.hpp b/lib/remote/configpackageutility.hpp new file mode 100644 index 0000000..240f591 --- /dev/null +++ b/lib/remote/configpackageutility.hpp @@ -0,0 +1,73 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef CONFIGMODULEUTILITY_H +#define CONFIGMODULEUTILITY_H + +#include "remote/i2-remote.hpp" +#include "base/application.hpp" +#include "base/dictionary.hpp" +#include "base/process.hpp" +#include "base/string.hpp" +#include "base/defer.hpp" +#include "base/shared.hpp" +#include <vector> + +namespace icinga +{ + +/** + * Helper functions. + * + * @ingroup remote + */ +class ConfigPackageUtility +{ + +public: + static String GetPackageDir(); + + static void CreatePackage(const String& name); + static void DeletePackage(const String& name); + static std::vector<String> GetPackages(); + static bool PackageExists(const String& name); + + static String CreateStage(const String& packageName, const Dictionary::Ptr& files = nullptr); + static void DeleteStage(const String& packageName, const String& stageName); + static std::vector<String> GetStages(const String& packageName); + static String GetActiveStageFromFile(const String& packageName); + static String GetActiveStage(const String& packageName); + static void SetActiveStage(const String& packageName, const String& stageName); + static void SetActiveStageToFile(const String& packageName, const String& stageName); + static void ActivateStage(const String& packageName, const String& stageName); + static void AsyncTryActivateStage(const String& packageName, const String& stageName, bool activate, bool reload, + const Shared<Defer>::Ptr& resetPackageUpdates); + + static std::vector<std::pair<String, bool> > GetFiles(const String& packageName, const String& stageName); + + static bool ContainsDotDot(const String& path); + static bool ValidatePackageName(const String& packageName); + + static inline + bool ValidateStageName(const String& stageName) + { + return ValidateFreshName(stageName); + } + + static std::mutex& GetStaticPackageMutex(); + static std::mutex& GetStaticActiveStageMutex(); + +private: + static void CollectPaths(const String& path, std::vector<std::pair<String, bool> >& paths); + + static void WritePackageConfig(const String& packageName); + static void WriteStageConfig(const String& packageName, const String& stageName); + + static void TryActivateStageCallback(const ProcessResult& pr, const String& packageName, const String& stageName, bool activate, + bool reload, const Shared<Defer>::Ptr& resetPackageUpdates); + + static bool ValidateFreshName(const String& name); +}; + +} + +#endif /* CONFIGMODULEUTILITY_H */ diff --git a/lib/remote/configstageshandler.cpp b/lib/remote/configstageshandler.cpp new file mode 100644 index 0000000..b5aaadd --- /dev/null +++ b/lib/remote/configstageshandler.cpp @@ -0,0 +1,225 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/configstageshandler.hpp" +#include "remote/configpackageutility.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "base/application.hpp" +#include "base/defer.hpp" +#include "base/exception.hpp" + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/config/stages", ConfigStagesHandler); + +std::atomic<bool> ConfigStagesHandler::m_RunningPackageUpdates (false); + +bool ConfigStagesHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() > 5) + return false; + + if (request.method() == http::verb::get) + HandleGet(user, request, url, response, params); + else if (request.method() == http::verb::post) + HandlePost(user, request, url, response, params); + else if (request.method() == http::verb::delete_) + HandleDelete(user, request, url, response, params); + else + return false; + + return true; +} + +void ConfigStagesHandler::HandleGet( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params +) +{ + namespace http = boost::beast::http; + + FilterUtility::CheckPermission(user, "config/query"); + + if (url->GetPath().size() >= 4) + params->Set("package", url->GetPath()[3]); + + if (url->GetPath().size() >= 5) + params->Set("stage", url->GetPath()[4]); + + String packageName = HttpUtility::GetLastParameter(params, "package"); + String stageName = HttpUtility::GetLastParameter(params, "stage"); + + if (!ConfigPackageUtility::ValidatePackageName(packageName)) + return HttpUtility::SendJsonError(response, params, 400, "Invalid package name '" + packageName + "'."); + + if (!ConfigPackageUtility::ValidateStageName(stageName)) + return HttpUtility::SendJsonError(response, params, 400, "Invalid stage name '" + stageName + "'."); + + ArrayData results; + + std::vector<std::pair<String, bool> > paths = ConfigPackageUtility::GetFiles(packageName, stageName); + + String prefixPath = ConfigPackageUtility::GetPackageDir() + "/" + packageName + "/" + stageName + "/"; + + for (const auto& kv : paths) { + results.push_back(new Dictionary({ + { "type", kv.second ? "directory" : "file" }, + { "name", kv.first.SubStr(prefixPath.GetLength()) } + })); + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); +} + +void ConfigStagesHandler::HandlePost( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params +) +{ + namespace http = boost::beast::http; + + FilterUtility::CheckPermission(user, "config/modify"); + + if (url->GetPath().size() >= 4) + params->Set("package", url->GetPath()[3]); + + String packageName = HttpUtility::GetLastParameter(params, "package"); + + if (!ConfigPackageUtility::ValidatePackageName(packageName)) + return HttpUtility::SendJsonError(response, params, 400, "Invalid package name '" + packageName + "'."); + + bool reload = true; + + if (params->Contains("reload")) + reload = HttpUtility::GetLastParameter(params, "reload"); + + bool activate = true; + + if (params->Contains("activate")) + activate = HttpUtility::GetLastParameter(params, "activate"); + + Dictionary::Ptr files = params->Get("files"); + + String stageName; + + try { + if (!files) + BOOST_THROW_EXCEPTION(std::invalid_argument("Parameter 'files' must be specified.")); + + if (reload && !activate) + BOOST_THROW_EXCEPTION(std::invalid_argument("Parameter 'reload' must be false when 'activate' is false.")); + + if (m_RunningPackageUpdates.exchange(true)) { + return HttpUtility::SendJsonError(response, params, 423, + "Conflicting request, there is already an ongoing package update in progress. Please try it again later."); + } + + auto resetPackageUpdates (Shared<Defer>::Make([]() { ConfigStagesHandler::m_RunningPackageUpdates.store(false); })); + + std::unique_lock<std::mutex> lock(ConfigPackageUtility::GetStaticPackageMutex()); + + stageName = ConfigPackageUtility::CreateStage(packageName, files); + + /* validate the config. on success, activate stage and reload */ + ConfigPackageUtility::AsyncTryActivateStage(packageName, stageName, activate, reload, resetPackageUpdates); + } catch (const std::exception& ex) { + return HttpUtility::SendJsonError(response, params, 500, + "Stage creation failed.", + DiagnosticInformation(ex)); + } + + + String responseStatus = "Created stage. "; + + if (reload) + responseStatus += "Reload triggered."; + else + responseStatus += "Reload skipped."; + + Dictionary::Ptr result1 = new Dictionary({ + { "package", packageName }, + { "stage", stageName }, + { "code", 200 }, + { "status", responseStatus } + }); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ result1 }) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); +} + +void ConfigStagesHandler::HandleDelete( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params +) +{ + namespace http = boost::beast::http; + + FilterUtility::CheckPermission(user, "config/modify"); + + if (url->GetPath().size() >= 4) + params->Set("package", url->GetPath()[3]); + + if (url->GetPath().size() >= 5) + params->Set("stage", url->GetPath()[4]); + + String packageName = HttpUtility::GetLastParameter(params, "package"); + String stageName = HttpUtility::GetLastParameter(params, "stage"); + + if (!ConfigPackageUtility::ValidatePackageName(packageName)) + return HttpUtility::SendJsonError(response, params, 400, "Invalid package name '" + packageName + "'."); + + if (!ConfigPackageUtility::ValidateStageName(stageName)) + return HttpUtility::SendJsonError(response, params, 400, "Invalid stage name '" + stageName + "'."); + + try { + ConfigPackageUtility::DeleteStage(packageName, stageName); + } catch (const std::exception& ex) { + return HttpUtility::SendJsonError(response, params, 500, + "Failed to delete stage '" + stageName + "' in package '" + packageName + "'.", + DiagnosticInformation(ex)); + } + + Dictionary::Ptr result1 = new Dictionary({ + { "code", 200 }, + { "package", packageName }, + { "stage", stageName }, + { "status", "Stage deleted." } + }); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ result1 }) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); +} + diff --git a/lib/remote/configstageshandler.hpp b/lib/remote/configstageshandler.hpp new file mode 100644 index 0000000..88f248c --- /dev/null +++ b/lib/remote/configstageshandler.hpp @@ -0,0 +1,56 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef CONFIGSTAGESHANDLER_H +#define CONFIGSTAGESHANDLER_H + +#include "remote/httphandler.hpp" +#include <atomic> + +namespace icinga +{ + +class ConfigStagesHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(ConfigStagesHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; + +private: + void HandleGet( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params + ); + void HandlePost( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params + ); + void HandleDelete( + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params + ); + + static std::atomic<bool> m_RunningPackageUpdates; +}; + +} + +#endif /* CONFIGSTAGESHANDLER_H */ diff --git a/lib/remote/consolehandler.cpp b/lib/remote/consolehandler.cpp new file mode 100644 index 0000000..b790626 --- /dev/null +++ b/lib/remote/consolehandler.cpp @@ -0,0 +1,319 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/consolehandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "config/configcompiler.hpp" +#include "base/configtype.hpp" +#include "base/configwriter.hpp" +#include "base/scriptglobal.hpp" +#include "base/logger.hpp" +#include "base/serializer.hpp" +#include "base/timer.hpp" +#include "base/namespace.hpp" +#include "base/initialize.hpp" +#include "base/utility.hpp" +#include <boost/thread/once.hpp> +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/console", ConsoleHandler); + +static std::mutex l_QueryMutex; +static std::map<String, ApiScriptFrame> l_ApiScriptFrames; +static Timer::Ptr l_FrameCleanupTimer; +static std::mutex l_ApiScriptMutex; + +static void ScriptFrameCleanupHandler() +{ + std::unique_lock<std::mutex> lock(l_ApiScriptMutex); + + std::vector<String> cleanup_keys; + + typedef std::pair<String, ApiScriptFrame> KVPair; + + for (const KVPair& kv : l_ApiScriptFrames) { + if (kv.second.Seen < Utility::GetTime() - 1800) + cleanup_keys.push_back(kv.first); + } + + for (const String& key : cleanup_keys) + l_ApiScriptFrames.erase(key); +} + +static void EnsureFrameCleanupTimer() +{ + static boost::once_flag once = BOOST_ONCE_INIT; + + boost::call_once(once, []() { + l_FrameCleanupTimer = new Timer(); + l_FrameCleanupTimer->OnTimerExpired.connect([](const Timer * const&) { ScriptFrameCleanupHandler(); }); + l_FrameCleanupTimer->SetInterval(30); + l_FrameCleanupTimer->Start(); + }); +} + +bool ConsoleHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() != 3) + return false; + + if (request.method() != http::verb::post) + return false; + + QueryDescription qd; + + String methodName = url->GetPath()[2]; + + FilterUtility::CheckPermission(user, "console"); + + String session = HttpUtility::GetLastParameter(params, "session"); + + if (session.IsEmpty()) + session = Utility::NewUniqueID(); + + String command = HttpUtility::GetLastParameter(params, "command"); + + bool sandboxed = HttpUtility::GetLastParameter(params, "sandboxed"); + + if (methodName == "execute-script") + return ExecuteScriptHelper(request, response, params, command, session, sandboxed); + else if (methodName == "auto-complete-script") + return AutocompleteScriptHelper(request, response, params, command, session, sandboxed); + + HttpUtility::SendJsonError(response, params, 400, "Invalid method specified: " + methodName); + return true; +} + +bool ConsoleHandler::ExecuteScriptHelper(boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, const String& command, const String& session, bool sandboxed) +{ + namespace http = boost::beast::http; + + Log(LogNotice, "Console") + << "Executing expression: " << command; + + EnsureFrameCleanupTimer(); + + ApiScriptFrame& lsf = l_ApiScriptFrames[session]; + lsf.Seen = Utility::GetTime(); + + if (!lsf.Locals) + lsf.Locals = new Dictionary(); + + String fileName = "<" + Convert::ToString(lsf.NextLine) + ">"; + lsf.NextLine++; + + lsf.Lines[fileName] = command; + + Dictionary::Ptr resultInfo; + std::unique_ptr<Expression> expr; + Value exprResult; + + try { + expr = ConfigCompiler::CompileText(fileName, command); + + ScriptFrame frame(true); + frame.Locals = lsf.Locals; + frame.Self = lsf.Locals; + frame.Sandboxed = sandboxed; + + exprResult = expr->Evaluate(frame); + + resultInfo = new Dictionary({ + { "code", 200 }, + { "status", "Executed successfully." }, + { "result", Serialize(exprResult, 0) } + }); + } catch (const ScriptError& ex) { + DebugInfo di = ex.GetDebugInfo(); + + std::ostringstream msgbuf; + + msgbuf << di.Path << ": " << lsf.Lines[di.Path] << "\n" + << String(di.Path.GetLength() + 2, ' ') + << String(di.FirstColumn, ' ') << String(di.LastColumn - di.FirstColumn + 1, '^') << "\n" + << ex.what() << "\n"; + + resultInfo = new Dictionary({ + { "code", 500 }, + { "status", String(msgbuf.str()) }, + { "incomplete_expression", ex.IsIncompleteExpression() }, + { "debug_info", new Dictionary({ + { "path", di.Path }, + { "first_line", di.FirstLine }, + { "first_column", di.FirstColumn }, + { "last_line", di.LastLine }, + { "last_column", di.LastColumn } + }) } + }); + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ resultInfo }) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} + +bool ConsoleHandler::AutocompleteScriptHelper(boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, const String& command, const String& session, bool sandboxed) +{ + namespace http = boost::beast::http; + + Log(LogInformation, "Console") + << "Auto-completing expression: " << command; + + EnsureFrameCleanupTimer(); + + ApiScriptFrame& lsf = l_ApiScriptFrames[session]; + lsf.Seen = Utility::GetTime(); + + if (!lsf.Locals) + lsf.Locals = new Dictionary(); + + + ScriptFrame frame(true); + frame.Locals = lsf.Locals; + frame.Self = lsf.Locals; + frame.Sandboxed = sandboxed; + + Dictionary::Ptr result1 = new Dictionary({ + { "code", 200 }, + { "status", "Auto-completed successfully." }, + { "suggestions", Array::FromVector(GetAutocompletionSuggestions(command, frame)) } + }); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ result1 }) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} + +static void AddSuggestion(std::vector<String>& matches, const String& word, const String& suggestion) +{ + if (suggestion.Find(word) != 0) + return; + + matches.push_back(suggestion); +} + +static void AddSuggestions(std::vector<String>& matches, const String& word, const String& pword, bool withFields, const Value& value) +{ + String prefix; + + if (!pword.IsEmpty()) + prefix = pword + "."; + + if (value.IsObjectType<Dictionary>()) { + Dictionary::Ptr dict = value; + + ObjectLock olock(dict); + for (const Dictionary::Pair& kv : dict) { + AddSuggestion(matches, word, prefix + kv.first); + } + } + + if (value.IsObjectType<Namespace>()) { + Namespace::Ptr ns = value; + + ObjectLock olock(ns); + for (const Namespace::Pair& kv : ns) { + AddSuggestion(matches, word, prefix + kv.first); + } + } + + if (withFields) { + Type::Ptr type = value.GetReflectionType(); + + for (int i = 0; i < type->GetFieldCount(); i++) { + Field field = type->GetFieldInfo(i); + + AddSuggestion(matches, word, prefix + field.Name); + } + + while (type) { + Object::Ptr prototype = type->GetPrototype(); + Dictionary::Ptr dict = dynamic_pointer_cast<Dictionary>(prototype); + + if (dict) { + ObjectLock olock(dict); + for (const Dictionary::Pair& kv : dict) { + AddSuggestion(matches, word, prefix + kv.first); + } + } + + type = type->GetBaseType(); + } + } +} + +std::vector<String> ConsoleHandler::GetAutocompletionSuggestions(const String& word, ScriptFrame& frame) +{ + std::vector<String> matches; + + for (const String& keyword : ConfigWriter::GetKeywords()) { + AddSuggestion(matches, word, keyword); + } + + { + ObjectLock olock(frame.Locals); + for (const Dictionary::Pair& kv : frame.Locals) { + AddSuggestion(matches, word, kv.first); + } + } + + { + ObjectLock olock(ScriptGlobal::GetGlobals()); + for (const Namespace::Pair& kv : ScriptGlobal::GetGlobals()) { + AddSuggestion(matches, word, kv.first); + } + } + + Namespace::Ptr systemNS = ScriptGlobal::Get("System"); + + AddSuggestions(matches, word, "", false, systemNS); + AddSuggestions(matches, word, "", true, systemNS->Get("Configuration")); + AddSuggestions(matches, word, "", false, ScriptGlobal::Get("Types")); + AddSuggestions(matches, word, "", false, ScriptGlobal::Get("Icinga")); + + String::SizeType cperiod = word.RFind("."); + + if (cperiod != String::NPos) { + String pword = word.SubStr(0, cperiod); + + Value value; + + try { + std::unique_ptr<Expression> expr = ConfigCompiler::CompileText("temp", pword); + + if (expr) + value = expr->Evaluate(frame); + + AddSuggestions(matches, word, pword, true, value); + } catch (...) { /* Ignore the exception */ } + } + + return matches; +} diff --git a/lib/remote/consolehandler.hpp b/lib/remote/consolehandler.hpp new file mode 100644 index 0000000..df0d77d --- /dev/null +++ b/lib/remote/consolehandler.hpp @@ -0,0 +1,50 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef CONSOLEHANDLER_H +#define CONSOLEHANDLER_H + +#include "remote/httphandler.hpp" +#include "base/scriptframe.hpp" + +namespace icinga +{ + +struct ApiScriptFrame +{ + double Seen{0}; + int NextLine{1}; + std::map<String, String> Lines; + Dictionary::Ptr Locals; +}; + +class ConsoleHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(ConsoleHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; + + static std::vector<String> GetAutocompletionSuggestions(const String& word, ScriptFrame& frame); + +private: + static bool ExecuteScriptHelper(boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, const String& command, const String& session, bool sandboxed); + static bool AutocompleteScriptHelper(boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, const String& command, const String& session, bool sandboxed); + +}; + +} + +#endif /* CONSOLEHANDLER_H */ diff --git a/lib/remote/createobjecthandler.cpp b/lib/remote/createobjecthandler.cpp new file mode 100644 index 0000000..c01b236 --- /dev/null +++ b/lib/remote/createobjecthandler.cpp @@ -0,0 +1,147 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/createobjecthandler.hpp" +#include "remote/configobjectutility.hpp" +#include "remote/httputility.hpp" +#include "remote/jsonrpcconnection.hpp" +#include "remote/filterutility.hpp" +#include "remote/apiaction.hpp" +#include "remote/zone.hpp" +#include "base/configtype.hpp" +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/objects", CreateObjectHandler); + +bool CreateObjectHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() != 4) + return false; + + if (request.method() != http::verb::put) + return false; + + Type::Ptr type = FilterUtility::TypeFromPluralName(url->GetPath()[2]); + + if (!type) { + HttpUtility::SendJsonError(response, params, 400, "Invalid type specified."); + return true; + } + + FilterUtility::CheckPermission(user, "objects/create/" + type->GetName()); + + String name = url->GetPath()[3]; + Array::Ptr templates = params->Get("templates"); + Dictionary::Ptr attrs = params->Get("attrs"); + + /* Put created objects into the local zone if not explicitly defined. + * This allows additional zone members to sync the + * configuration at some later point. + */ + Zone::Ptr localZone = Zone::GetLocalZone(); + String localZoneName; + + if (localZone) { + localZoneName = localZone->GetName(); + + if (!attrs) { + attrs = new Dictionary({ + { "zone", localZoneName } + }); + } else if (!attrs->Contains("zone")) { + attrs->Set("zone", localZoneName); + } + } + + /* Sanity checks for unique groups array. */ + if (attrs->Contains("groups")) { + Array::Ptr groups = attrs->Get("groups"); + + if (groups) + attrs->Set("groups", groups->Unique()); + } + + Dictionary::Ptr result1 = new Dictionary(); + String status; + Array::Ptr errors = new Array(); + Array::Ptr diagnosticInformation = new Array(); + + bool ignoreOnError = false; + + if (params->Contains("ignore_on_error")) + ignoreOnError = HttpUtility::GetLastParameter(params, "ignore_on_error"); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ result1 }) } + }); + + String config; + + bool verbose = false; + + if (params) + verbose = HttpUtility::GetLastParameter(params, "verbose"); + + /* Object creation can cause multiple errors and optionally diagnostic information. + * We can't use SendJsonError() here. + */ + try { + config = ConfigObjectUtility::CreateObjectConfig(type, name, ignoreOnError, templates, attrs); + } catch (const std::exception& ex) { + errors->Add(DiagnosticInformation(ex, false)); + diagnosticInformation->Add(DiagnosticInformation(ex)); + + if (verbose) + result1->Set("diagnostic_information", diagnosticInformation); + + result1->Set("errors", errors); + result1->Set("code", 500); + result1->Set("status", "Object could not be created."); + + response.result(http::status::internal_server_error); + HttpUtility::SendJsonBody(response, params, result); + + return true; + } + + if (!ConfigObjectUtility::CreateObject(type, name, config, errors, diagnosticInformation)) { + result1->Set("errors", errors); + result1->Set("code", 500); + result1->Set("status", "Object could not be created."); + + if (verbose) + result1->Set("diagnostic_information", diagnosticInformation); + + response.result(http::status::internal_server_error); + HttpUtility::SendJsonBody(response, params, result); + + return true; + } + + auto *ctype = dynamic_cast<ConfigType *>(type.get()); + ConfigObject::Ptr obj = ctype->GetObject(name); + + result1->Set("code", 200); + + if (obj) + result1->Set("status", "Object was created"); + else if (!obj && ignoreOnError) + result1->Set("status", "Object was not created but 'ignore_on_error' was set to true"); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} diff --git a/lib/remote/createobjecthandler.hpp b/lib/remote/createobjecthandler.hpp new file mode 100644 index 0000000..4bcf21b --- /dev/null +++ b/lib/remote/createobjecthandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef CREATEOBJECTHANDLER_H +#define CREATEOBJECTHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class CreateObjectHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(CreateObjectHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* CREATEOBJECTHANDLER_H */ diff --git a/lib/remote/deleteobjecthandler.cpp b/lib/remote/deleteobjecthandler.cpp new file mode 100644 index 0000000..2edb0e4 --- /dev/null +++ b/lib/remote/deleteobjecthandler.cpp @@ -0,0 +1,115 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/deleteobjecthandler.hpp" +#include "remote/configobjectutility.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "remote/apiaction.hpp" +#include "config/configitem.hpp" +#include "base/exception.hpp" +#include <boost/algorithm/string/case_conv.hpp> +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/objects", DeleteObjectHandler); + +bool DeleteObjectHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() < 3 || url->GetPath().size() > 4) + return false; + + if (request.method() != http::verb::delete_) + return false; + + Type::Ptr type = FilterUtility::TypeFromPluralName(url->GetPath()[2]); + + if (!type) { + HttpUtility::SendJsonError(response, params, 400, "Invalid type specified."); + return true; + } + + QueryDescription qd; + qd.Types.insert(type->GetName()); + qd.Permission = "objects/delete/" + type->GetName(); + + params->Set("type", type->GetName()); + + if (url->GetPath().size() >= 4) { + String attr = type->GetName(); + boost::algorithm::to_lower(attr); + params->Set(attr, url->GetPath()[3]); + } + + std::vector<Value> objs; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No objects found.", + DiagnosticInformation(ex)); + return true; + } + + bool cascade = HttpUtility::GetLastParameter(params, "cascade"); + bool verbose = HttpUtility::GetLastParameter(params, "verbose"); + + ArrayData results; + + bool success = true; + + for (const ConfigObject::Ptr& obj : objs) { + int code; + String status; + Array::Ptr errors = new Array(); + Array::Ptr diagnosticInformation = new Array(); + + if (!ConfigObjectUtility::DeleteObject(obj, cascade, errors, diagnosticInformation)) { + code = 500; + status = "Object could not be deleted."; + success = false; + } else { + code = 200; + status = "Object was deleted."; + } + + Dictionary::Ptr result = new Dictionary({ + { "type", type->GetName() }, + { "name", obj->GetName() }, + { "code", code }, + { "status", status }, + { "errors", errors } + }); + + if (verbose) + result->Set("diagnostic_information", diagnosticInformation); + + results.push_back(result); + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + if (!success) + response.result(http::status::internal_server_error); + else + response.result(http::status::ok); + + HttpUtility::SendJsonBody(response, params, result); + + return true; +} + diff --git a/lib/remote/deleteobjecthandler.hpp b/lib/remote/deleteobjecthandler.hpp new file mode 100644 index 0000000..19a46e4 --- /dev/null +++ b/lib/remote/deleteobjecthandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef DELETEOBJECTHANDLER_H +#define DELETEOBJECTHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class DeleteObjectHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(DeleteObjectHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* DELETEOBJECTHANDLER_H */ diff --git a/lib/remote/endpoint.cpp b/lib/remote/endpoint.cpp new file mode 100644 index 0000000..e534fc1 --- /dev/null +++ b/lib/remote/endpoint.cpp @@ -0,0 +1,138 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/endpoint.hpp" +#include "remote/endpoint-ti.cpp" +#include "remote/apilistener.hpp" +#include "remote/jsonrpcconnection.hpp" +#include "remote/zone.hpp" +#include "base/configtype.hpp" +#include "base/utility.hpp" +#include "base/exception.hpp" +#include "base/convert.hpp" + +using namespace icinga; + +REGISTER_TYPE(Endpoint); + +boost::signals2::signal<void(const Endpoint::Ptr&, const JsonRpcConnection::Ptr&)> Endpoint::OnConnected; +boost::signals2::signal<void(const Endpoint::Ptr&, const JsonRpcConnection::Ptr&)> Endpoint::OnDisconnected; + +void Endpoint::OnAllConfigLoaded() +{ + ObjectImpl<Endpoint>::OnAllConfigLoaded(); + + if (!m_Zone) + BOOST_THROW_EXCEPTION(ScriptError("Endpoint '" + GetName() + + "' does not belong to a zone.", GetDebugInfo())); +} + +void Endpoint::SetCachedZone(const Zone::Ptr& zone) +{ + if (m_Zone) + BOOST_THROW_EXCEPTION(ScriptError("Endpoint '" + GetName() + + "' is in more than one zone.", GetDebugInfo())); + + m_Zone = zone; +} + +void Endpoint::AddClient(const JsonRpcConnection::Ptr& client) +{ + bool was_master = ApiListener::GetInstance()->IsMaster(); + + { + std::unique_lock<std::mutex> lock(m_ClientsLock); + m_Clients.insert(client); + } + + bool is_master = ApiListener::GetInstance()->IsMaster(); + + if (was_master != is_master) + ApiListener::OnMasterChanged(is_master); + + OnConnected(this, client); +} + +void Endpoint::RemoveClient(const JsonRpcConnection::Ptr& client) +{ + bool was_master = ApiListener::GetInstance()->IsMaster(); + + { + std::unique_lock<std::mutex> lock(m_ClientsLock); + m_Clients.erase(client); + + Log(LogWarning, "ApiListener") + << "Removing API client for endpoint '" << GetName() << "'. " << m_Clients.size() << " API clients left."; + + SetConnecting(false); + } + + bool is_master = ApiListener::GetInstance()->IsMaster(); + + if (was_master != is_master) + ApiListener::OnMasterChanged(is_master); + + OnDisconnected(this, client); +} + +std::set<JsonRpcConnection::Ptr> Endpoint::GetClients() const +{ + std::unique_lock<std::mutex> lock(m_ClientsLock); + return m_Clients; +} + +Zone::Ptr Endpoint::GetZone() const +{ + return m_Zone; +} + +bool Endpoint::GetConnected() const +{ + std::unique_lock<std::mutex> lock(m_ClientsLock); + return !m_Clients.empty(); +} + +Endpoint::Ptr Endpoint::GetLocalEndpoint() +{ + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) + return nullptr; + + return listener->GetLocalEndpoint(); +} + +void Endpoint::AddMessageSent(int bytes) +{ + double time = Utility::GetTime(); + m_MessagesSent.InsertValue(time, 1); + m_BytesSent.InsertValue(time, bytes); + SetLastMessageSent(time); +} + +void Endpoint::AddMessageReceived(int bytes) +{ + double time = Utility::GetTime(); + m_MessagesReceived.InsertValue(time, 1); + m_BytesReceived.InsertValue(time, bytes); + SetLastMessageReceived(time); +} + +double Endpoint::GetMessagesSentPerSecond() const +{ + return m_MessagesSent.CalculateRate(Utility::GetTime(), 60); +} + +double Endpoint::GetMessagesReceivedPerSecond() const +{ + return m_MessagesReceived.CalculateRate(Utility::GetTime(), 60); +} + +double Endpoint::GetBytesSentPerSecond() const +{ + return m_BytesSent.CalculateRate(Utility::GetTime(), 60); +} + +double Endpoint::GetBytesReceivedPerSecond() const +{ + return m_BytesReceived.CalculateRate(Utility::GetTime(), 60); +} diff --git a/lib/remote/endpoint.hpp b/lib/remote/endpoint.hpp new file mode 100644 index 0000000..d641c2c --- /dev/null +++ b/lib/remote/endpoint.hpp @@ -0,0 +1,68 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef ENDPOINT_H +#define ENDPOINT_H + +#include "remote/i2-remote.hpp" +#include "remote/endpoint-ti.hpp" +#include "base/ringbuffer.hpp" +#include <set> + +namespace icinga +{ + +class JsonRpcConnection; +class Zone; + +/** + * An endpoint that can be used to send and receive messages. + * + * @ingroup remote + */ +class Endpoint final : public ObjectImpl<Endpoint> +{ +public: + DECLARE_OBJECT(Endpoint); + DECLARE_OBJECTNAME(Endpoint); + + static boost::signals2::signal<void(const Endpoint::Ptr&, const intrusive_ptr<JsonRpcConnection>&)> OnConnected; + static boost::signals2::signal<void(const Endpoint::Ptr&, const intrusive_ptr<JsonRpcConnection>&)> OnDisconnected; + + void AddClient(const intrusive_ptr<JsonRpcConnection>& client); + void RemoveClient(const intrusive_ptr<JsonRpcConnection>& client); + std::set<intrusive_ptr<JsonRpcConnection> > GetClients() const; + + intrusive_ptr<Zone> GetZone() const; + + bool GetConnected() const override; + + static Endpoint::Ptr GetLocalEndpoint(); + + void SetCachedZone(const intrusive_ptr<Zone>& zone); + + void AddMessageSent(int bytes); + void AddMessageReceived(int bytes); + + double GetMessagesSentPerSecond() const override; + double GetMessagesReceivedPerSecond() const override; + + double GetBytesSentPerSecond() const override; + double GetBytesReceivedPerSecond() const override; + +protected: + void OnAllConfigLoaded() override; + +private: + mutable std::mutex m_ClientsLock; + std::set<intrusive_ptr<JsonRpcConnection> > m_Clients; + intrusive_ptr<Zone> m_Zone; + + mutable RingBuffer m_MessagesSent{60}; + mutable RingBuffer m_MessagesReceived{60}; + mutable RingBuffer m_BytesSent{60}; + mutable RingBuffer m_BytesReceived{60}; +}; + +} + +#endif /* ENDPOINT_H */ diff --git a/lib/remote/endpoint.ti b/lib/remote/endpoint.ti new file mode 100644 index 0000000..78551ec --- /dev/null +++ b/lib/remote/endpoint.ti @@ -0,0 +1,59 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "base/configobject.hpp" +#include <cstdint> + +library remote; + +namespace icinga +{ + +class Endpoint : ConfigObject +{ + load_after Zone; + + [config] String host; + [config, required] String port { + default {{{ return "5665"; }}} + }; + [config] double log_duration { + default {{{ return 86400; }}} + }; + + [state] Timestamp local_log_position; + [state] Timestamp remote_log_position; + [state] "unsigned long" icinga_version { + default {{{ return 0; }}} + }; + [state] uint_fast64_t capabilities { + default {{{ return 0; }}} + }; + + [no_user_modify] bool connecting; + [no_user_modify] bool syncing; + + [no_user_modify, no_storage] bool connected { + get; + }; + + Timestamp last_message_sent; + Timestamp last_message_received; + + [no_user_modify, no_storage] double messages_sent_per_second { + get; + }; + + [no_user_modify, no_storage] double messages_received_per_second { + get; + }; + + [no_user_modify, no_storage] double bytes_sent_per_second { + get; + }; + + [no_user_modify, no_storage] double bytes_received_per_second { + get; + }; +}; + +} diff --git a/lib/remote/eventqueue.cpp b/lib/remote/eventqueue.cpp new file mode 100644 index 0000000..fd911f0 --- /dev/null +++ b/lib/remote/eventqueue.cpp @@ -0,0 +1,351 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "config/configcompiler.hpp" +#include "remote/eventqueue.hpp" +#include "remote/filterutility.hpp" +#include "base/io-engine.hpp" +#include "base/singleton.hpp" +#include "base/logger.hpp" +#include "base/utility.hpp" +#include <boost/asio/spawn.hpp> +#include <boost/date_time/posix_time/posix_time_duration.hpp> +#include <boost/date_time/posix_time/ptime.hpp> +#include <boost/system/error_code.hpp> +#include <chrono> +#include <utility> + +using namespace icinga; + +EventQueue::EventQueue(String name) + : m_Name(std::move(name)) +{ } + +bool EventQueue::CanProcessEvent(const String& type) const +{ + std::unique_lock<std::mutex> lock(m_Mutex); + + return m_Types.find(type) != m_Types.end(); +} + +void EventQueue::ProcessEvent(const Dictionary::Ptr& event) +{ + Namespace::Ptr frameNS = new Namespace(); + ScriptFrame frame(true, frameNS); + frame.Sandboxed = true; + + try { + if (!FilterUtility::EvaluateFilter(frame, m_Filter.get(), event, "event")) + return; + } catch (const std::exception& ex) { + Log(LogWarning, "EventQueue") + << "Error occurred while evaluating event filter for queue '" << m_Name << "': " << DiagnosticInformation(ex); + return; + } + + std::unique_lock<std::mutex> lock(m_Mutex); + + typedef std::pair<void *const, std::deque<Dictionary::Ptr> > kv_pair; + for (kv_pair& kv : m_Events) { + kv.second.push_back(event); + } + + m_CV.notify_all(); +} + +void EventQueue::AddClient(void *client) +{ + std::unique_lock<std::mutex> lock(m_Mutex); + + auto result = m_Events.insert(std::make_pair(client, std::deque<Dictionary::Ptr>())); + ASSERT(result.second); + +#ifndef I2_DEBUG + (void)result; +#endif /* I2_DEBUG */ +} + +void EventQueue::RemoveClient(void *client) +{ + std::unique_lock<std::mutex> lock(m_Mutex); + + m_Events.erase(client); +} + +void EventQueue::UnregisterIfUnused(const String& name, const EventQueue::Ptr& queue) +{ + std::unique_lock<std::mutex> lock(queue->m_Mutex); + + if (queue->m_Events.empty()) + Unregister(name); +} + +void EventQueue::SetTypes(const std::set<String>& types) +{ + std::unique_lock<std::mutex> lock(m_Mutex); + m_Types = types; +} + +void EventQueue::SetFilter(std::unique_ptr<Expression> filter) +{ + std::unique_lock<std::mutex> lock(m_Mutex); + m_Filter.swap(filter); +} + +Dictionary::Ptr EventQueue::WaitForEvent(void *client, double timeout) +{ + std::unique_lock<std::mutex> lock(m_Mutex); + + for (;;) { + auto it = m_Events.find(client); + ASSERT(it != m_Events.end()); + + if (!it->second.empty()) { + Dictionary::Ptr result = *it->second.begin(); + it->second.pop_front(); + return result; + } + + if (m_CV.wait_for(lock, std::chrono::duration<double>(timeout)) == std::cv_status::timeout) + return nullptr; + } +} + +std::vector<EventQueue::Ptr> EventQueue::GetQueuesForType(const String& type) +{ + EventQueueRegistry::ItemMap queues = EventQueueRegistry::GetInstance()->GetItems(); + + std::vector<EventQueue::Ptr> availQueues; + + typedef std::pair<String, EventQueue::Ptr> kv_pair; + for (const kv_pair& kv : queues) { + if (kv.second->CanProcessEvent(type)) + availQueues.push_back(kv.second); + } + + return availQueues; +} + +EventQueue::Ptr EventQueue::GetByName(const String& name) +{ + return EventQueueRegistry::GetInstance()->GetItem(name); +} + +void EventQueue::Register(const String& name, const EventQueue::Ptr& function) +{ + EventQueueRegistry::GetInstance()->Register(name, function); +} + +void EventQueue::Unregister(const String& name) +{ + EventQueueRegistry::GetInstance()->Unregister(name); +} + +EventQueueRegistry *EventQueueRegistry::GetInstance() +{ + return Singleton<EventQueueRegistry>::GetInstance(); +} + +std::mutex EventsInbox::m_FiltersMutex; +std::map<String, EventsInbox::Filter> EventsInbox::m_Filters ({{"", EventsInbox::Filter{1, Expression::Ptr()}}}); + +EventsRouter EventsRouter::m_Instance; + +EventsInbox::EventsInbox(String filter, const String& filterSource) + : m_Timer(IoEngine::Get().GetIoContext()) +{ + std::unique_lock<std::mutex> lock (m_FiltersMutex); + m_Filter = m_Filters.find(filter); + + if (m_Filter == m_Filters.end()) { + lock.unlock(); + + auto expr (ConfigCompiler::CompileText(filterSource, filter)); + + lock.lock(); + + m_Filter = m_Filters.find(filter); + + if (m_Filter == m_Filters.end()) { + m_Filter = m_Filters.emplace(std::move(filter), Filter{1, Expression::Ptr(expr.release())}).first; + } else { + ++m_Filter->second.Refs; + } + } else { + ++m_Filter->second.Refs; + } +} + +EventsInbox::~EventsInbox() +{ + std::unique_lock<std::mutex> lock (m_FiltersMutex); + + if (!--m_Filter->second.Refs) { + m_Filters.erase(m_Filter); + } +} + +const Expression::Ptr& EventsInbox::GetFilter() +{ + return m_Filter->second.Expr; +} + +void EventsInbox::Push(Dictionary::Ptr event) +{ + std::unique_lock<std::mutex> lock (m_Mutex); + + m_Queue.emplace(std::move(event)); + m_Timer.expires_at(boost::posix_time::neg_infin); +} + +Dictionary::Ptr EventsInbox::Shift(boost::asio::yield_context yc, double timeout) +{ + std::unique_lock<std::mutex> lock (m_Mutex, std::defer_lock); + + m_Timer.expires_at(boost::posix_time::neg_infin); + + { + boost::system::error_code ec; + + while (!lock.try_lock()) { + m_Timer.async_wait(yc[ec]); + } + } + + if (m_Queue.empty()) { + m_Timer.expires_from_now(boost::posix_time::milliseconds((unsigned long)(timeout * 1000.0))); + lock.unlock(); + + { + boost::system::error_code ec; + m_Timer.async_wait(yc[ec]); + + while (!lock.try_lock()) { + m_Timer.async_wait(yc[ec]); + } + } + + if (m_Queue.empty()) { + return nullptr; + } + } + + auto event (std::move(m_Queue.front())); + m_Queue.pop(); + return std::move(event); +} + +EventsSubscriber::EventsSubscriber(std::set<EventType> types, String filter, const String& filterSource) + : m_Types(std::move(types)), m_Inbox(new EventsInbox(std::move(filter), filterSource)) +{ + EventsRouter::GetInstance().Subscribe(m_Types, m_Inbox); +} + +EventsSubscriber::~EventsSubscriber() +{ + EventsRouter::GetInstance().Unsubscribe(m_Types, m_Inbox); +} + +const EventsInbox::Ptr& EventsSubscriber::GetInbox() +{ + return m_Inbox; +} + +EventsFilter::EventsFilter(std::map<Expression::Ptr, std::set<EventsInbox::Ptr>> inboxes) + : m_Inboxes(std::move(inboxes)) +{ +} + +EventsFilter::operator bool() +{ + return !m_Inboxes.empty(); +} + +void EventsFilter::Push(Dictionary::Ptr event) +{ + for (auto& perFilter : m_Inboxes) { + if (perFilter.first) { + ScriptFrame frame(true, new Namespace()); + frame.Sandboxed = true; + + try { + if (!FilterUtility::EvaluateFilter(frame, perFilter.first.get(), event, "event")) { + continue; + } + } catch (const std::exception& ex) { + Log(LogWarning, "EventQueue") + << "Error occurred while evaluating event filter for queue: " << DiagnosticInformation(ex); + continue; + } + } + + for (auto& inbox : perFilter.second) { + inbox->Push(event); + } + } +} + +EventsRouter& EventsRouter::GetInstance() +{ + return m_Instance; +} + +void EventsRouter::Subscribe(const std::set<EventType>& types, const EventsInbox::Ptr& inbox) +{ + const auto& filter (inbox->GetFilter()); + std::unique_lock<std::mutex> lock (m_Mutex); + + for (auto type : types) { + auto perType (m_Subscribers.find(type)); + + if (perType == m_Subscribers.end()) { + perType = m_Subscribers.emplace(type, decltype(perType->second)()).first; + } + + auto perFilter (perType->second.find(filter)); + + if (perFilter == perType->second.end()) { + perFilter = perType->second.emplace(filter, decltype(perFilter->second)()).first; + } + + perFilter->second.emplace(inbox); + } +} + +void EventsRouter::Unsubscribe(const std::set<EventType>& types, const EventsInbox::Ptr& inbox) +{ + const auto& filter (inbox->GetFilter()); + std::unique_lock<std::mutex> lock (m_Mutex); + + for (auto type : types) { + auto perType (m_Subscribers.find(type)); + + if (perType != m_Subscribers.end()) { + auto perFilter (perType->second.find(filter)); + + if (perFilter != perType->second.end()) { + perFilter->second.erase(inbox); + + if (perFilter->second.empty()) { + perType->second.erase(perFilter); + } + } + + if (perType->second.empty()) { + m_Subscribers.erase(perType); + } + } + } +} + +EventsFilter EventsRouter::GetInboxes(EventType type) +{ + std::unique_lock<std::mutex> lock (m_Mutex); + + auto perType (m_Subscribers.find(type)); + + if (perType == m_Subscribers.end()) { + return EventsFilter({}); + } + + return EventsFilter(perType->second); +} diff --git a/lib/remote/eventqueue.hpp b/lib/remote/eventqueue.hpp new file mode 100644 index 0000000..32bd34a --- /dev/null +++ b/lib/remote/eventqueue.hpp @@ -0,0 +1,177 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef EVENTQUEUE_H +#define EVENTQUEUE_H + +#include "remote/httphandler.hpp" +#include "base/object.hpp" +#include "config/expression.hpp" +#include <boost/asio/deadline_timer.hpp> +#include <boost/asio/spawn.hpp> +#include <condition_variable> +#include <cstddef> +#include <cstdint> +#include <mutex> +#include <set> +#include <map> +#include <deque> +#include <queue> + +namespace icinga +{ + +class EventQueue final : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(EventQueue); + + EventQueue(String name); + + bool CanProcessEvent(const String& type) const; + void ProcessEvent(const Dictionary::Ptr& event); + void AddClient(void *client); + void RemoveClient(void *client); + + void SetTypes(const std::set<String>& types); + void SetFilter(std::unique_ptr<Expression> filter); + + Dictionary::Ptr WaitForEvent(void *client, double timeout = 5); + + static std::vector<EventQueue::Ptr> GetQueuesForType(const String& type); + static void UnregisterIfUnused(const String& name, const EventQueue::Ptr& queue); + + static EventQueue::Ptr GetByName(const String& name); + static void Register(const String& name, const EventQueue::Ptr& function); + static void Unregister(const String& name); + +private: + String m_Name; + + mutable std::mutex m_Mutex; + std::condition_variable m_CV; + + std::set<String> m_Types; + std::unique_ptr<Expression> m_Filter; + + std::map<void *, std::deque<Dictionary::Ptr> > m_Events; +}; + +/** + * A registry for API event queues. + * + * @ingroup base + */ +class EventQueueRegistry : public Registry<EventQueueRegistry, EventQueue::Ptr> +{ +public: + static EventQueueRegistry *GetInstance(); +}; + +enum class EventType : uint_fast8_t +{ + AcknowledgementCleared, + AcknowledgementSet, + CheckResult, + CommentAdded, + CommentRemoved, + DowntimeAdded, + DowntimeRemoved, + DowntimeStarted, + DowntimeTriggered, + Flapping, + Notification, + StateChange, + ObjectCreated, + ObjectDeleted, + ObjectModified +}; + +class EventsInbox : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(EventsInbox); + + EventsInbox(String filter, const String& filterSource); + EventsInbox(const EventsInbox&) = delete; + EventsInbox(EventsInbox&&) = delete; + EventsInbox& operator=(const EventsInbox&) = delete; + EventsInbox& operator=(EventsInbox&&) = delete; + ~EventsInbox(); + + const Expression::Ptr& GetFilter(); + + void Push(Dictionary::Ptr event); + Dictionary::Ptr Shift(boost::asio::yield_context yc, double timeout = 5); + +private: + struct Filter + { + std::size_t Refs; + Expression::Ptr Expr; + }; + + static std::mutex m_FiltersMutex; + static std::map<String, Filter> m_Filters; + + std::mutex m_Mutex; + decltype(m_Filters.begin()) m_Filter; + std::queue<Dictionary::Ptr> m_Queue; + boost::asio::deadline_timer m_Timer; +}; + +class EventsSubscriber +{ +public: + EventsSubscriber(std::set<EventType> types, String filter, const String& filterSource); + EventsSubscriber(const EventsSubscriber&) = delete; + EventsSubscriber(EventsSubscriber&&) = delete; + EventsSubscriber& operator=(const EventsSubscriber&) = delete; + EventsSubscriber& operator=(EventsSubscriber&&) = delete; + ~EventsSubscriber(); + + const EventsInbox::Ptr& GetInbox(); + +private: + std::set<EventType> m_Types; + EventsInbox::Ptr m_Inbox; +}; + +class EventsFilter +{ +public: + EventsFilter(std::map<Expression::Ptr, std::set<EventsInbox::Ptr>> inboxes); + + operator bool(); + + void Push(Dictionary::Ptr event); + +private: + std::map<Expression::Ptr, std::set<EventsInbox::Ptr>> m_Inboxes; +}; + +class EventsRouter +{ +public: + static EventsRouter& GetInstance(); + + void Subscribe(const std::set<EventType>& types, const EventsInbox::Ptr& inbox); + void Unsubscribe(const std::set<EventType>& types, const EventsInbox::Ptr& inbox); + EventsFilter GetInboxes(EventType type); + +private: + static EventsRouter m_Instance; + + EventsRouter() = default; + EventsRouter(const EventsRouter&) = delete; + EventsRouter(EventsRouter&&) = delete; + EventsRouter& operator=(const EventsRouter&) = delete; + EventsRouter& operator=(EventsRouter&&) = delete; + ~EventsRouter() = default; + + std::mutex m_Mutex; + std::map<EventType, std::map<Expression::Ptr, std::set<EventsInbox::Ptr>>> m_Subscribers; +}; + +} + +#endif /* EVENTQUEUE_H */ diff --git a/lib/remote/eventshandler.cpp b/lib/remote/eventshandler.cpp new file mode 100644 index 0000000..e05ef22 --- /dev/null +++ b/lib/remote/eventshandler.cpp @@ -0,0 +1,137 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/eventshandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "config/configcompiler.hpp" +#include "config/expression.hpp" +#include "base/defer.hpp" +#include "base/io-engine.hpp" +#include "base/objectlock.hpp" +#include "base/json.hpp" +#include <boost/asio/buffer.hpp> +#include <boost/asio/write.hpp> +#include <boost/algorithm/string/replace.hpp> +#include <map> +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/events", EventsHandler); + +const std::map<String, EventType> l_EventTypes ({ + {"AcknowledgementCleared", EventType::AcknowledgementCleared}, + {"AcknowledgementSet", EventType::AcknowledgementSet}, + {"CheckResult", EventType::CheckResult}, + {"CommentAdded", EventType::CommentAdded}, + {"CommentRemoved", EventType::CommentRemoved}, + {"DowntimeAdded", EventType::DowntimeAdded}, + {"DowntimeRemoved", EventType::DowntimeRemoved}, + {"DowntimeStarted", EventType::DowntimeStarted}, + {"DowntimeTriggered", EventType::DowntimeTriggered}, + {"Flapping", EventType::Flapping}, + {"Notification", EventType::Notification}, + {"StateChange", EventType::StateChange}, + {"ObjectCreated", EventType::ObjectCreated}, + {"ObjectDeleted", EventType::ObjectDeleted}, + {"ObjectModified", EventType::ObjectModified} +}); + +const String l_ApiQuery ("<API query>"); + +bool EventsHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace asio = boost::asio; + namespace http = boost::beast::http; + + if (url->GetPath().size() != 2) + return false; + + if (request.method() != http::verb::post) + return false; + + if (request.version() == 10) { + HttpUtility::SendJsonError(response, params, 400, "HTTP/1.0 not supported for event streams."); + return true; + } + + Array::Ptr types = params->Get("types"); + + if (!types) { + HttpUtility::SendJsonError(response, params, 400, "'types' query parameter is required."); + return true; + } + + { + ObjectLock olock(types); + for (const String& type : types) { + FilterUtility::CheckPermission(user, "events/" + type); + } + } + + String queueName = HttpUtility::GetLastParameter(params, "queue"); + + if (queueName.IsEmpty()) { + HttpUtility::SendJsonError(response, params, 400, "'queue' query parameter is required."); + return true; + } + + std::set<EventType> eventTypes; + + { + ObjectLock olock(types); + for (const String& type : types) { + auto typeId (l_EventTypes.find(type)); + + if (typeId != l_EventTypes.end()) { + eventTypes.emplace(typeId->second); + } + } + } + + EventsSubscriber subscriber (std::move(eventTypes), HttpUtility::GetLastParameter(params, "filter"), l_ApiQuery); + + server.StartStreaming(); + + response.result(http::status::ok); + response.set(http::field::content_type, "application/json"); + + IoBoundWorkSlot dontLockTheIoThread (yc); + + http::async_write(stream, response, yc); + stream.async_flush(yc); + + asio::const_buffer newLine ("\n", 1); + + for (;;) { + auto event (subscriber.GetInbox()->Shift(yc)); + + if (event) { + CpuBoundWork buildingResponse (yc); + + String body = JsonEncode(event); + + boost::algorithm::replace_all(body, "\n", ""); + + asio::const_buffer payload (body.CStr(), body.GetLength()); + + buildingResponse.Done(); + + asio::async_write(stream, payload, yc); + asio::async_write(stream, newLine, yc); + stream.async_flush(yc); + } else if (server.Disconnected()) { + return true; + } + } +} + diff --git a/lib/remote/eventshandler.hpp b/lib/remote/eventshandler.hpp new file mode 100644 index 0000000..c823415 --- /dev/null +++ b/lib/remote/eventshandler.hpp @@ -0,0 +1,31 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef EVENTSHANDLER_H +#define EVENTSHANDLER_H + +#include "remote/httphandler.hpp" +#include "remote/eventqueue.hpp" + +namespace icinga +{ + +class EventsHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(EventsHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* EVENTSHANDLER_H */ diff --git a/lib/remote/filterutility.cpp b/lib/remote/filterutility.cpp new file mode 100644 index 0000000..9bcc60f --- /dev/null +++ b/lib/remote/filterutility.cpp @@ -0,0 +1,297 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/filterutility.hpp" +#include "remote/httputility.hpp" +#include "config/configcompiler.hpp" +#include "config/expression.hpp" +#include "base/namespace.hpp" +#include "base/json.hpp" +#include "base/configtype.hpp" +#include "base/logger.hpp" +#include "base/utility.hpp" +#include <boost/algorithm/string/case_conv.hpp> + +using namespace icinga; + +Type::Ptr FilterUtility::TypeFromPluralName(const String& pluralName) +{ + String uname = pluralName; + boost::algorithm::to_lower(uname); + + for (const Type::Ptr& type : Type::GetAllTypes()) { + String pname = type->GetPluralName(); + boost::algorithm::to_lower(pname); + + if (uname == pname) + return type; + } + + return nullptr; +} + +void ConfigObjectTargetProvider::FindTargets(const String& type, const std::function<void (const Value&)>& addTarget) const +{ + Type::Ptr ptype = Type::GetByName(type); + auto *ctype = dynamic_cast<ConfigType *>(ptype.get()); + + if (ctype) { + for (const ConfigObject::Ptr& object : ctype->GetObjects()) { + addTarget(object); + } + } +} + +Value ConfigObjectTargetProvider::GetTargetByName(const String& type, const String& name) const +{ + ConfigObject::Ptr obj = ConfigObject::GetObject(type, name); + + if (!obj) + BOOST_THROW_EXCEPTION(std::invalid_argument("Object does not exist.")); + + return obj; +} + +bool ConfigObjectTargetProvider::IsValidType(const String& type) const +{ + Type::Ptr ptype = Type::GetByName(type); + + if (!ptype) + return false; + + return ConfigObject::TypeInstance->IsAssignableFrom(ptype); +} + +String ConfigObjectTargetProvider::GetPluralName(const String& type) const +{ + return Type::GetByName(type)->GetPluralName(); +} + +bool FilterUtility::EvaluateFilter(ScriptFrame& frame, Expression *filter, + const Object::Ptr& target, const String& variableName) +{ + if (!filter) + return true; + + Type::Ptr type = target->GetReflectionType(); + String varName; + + if (variableName.IsEmpty()) + varName = type->GetName().ToLower(); + else + varName = variableName; + + Namespace::Ptr frameNS; + + if (frame.Self.IsEmpty()) { + frameNS = new Namespace(); + frame.Self = frameNS; + } else { + /* Enforce a namespace object for 'frame.self'. */ + ASSERT(frame.Self.IsObjectType<Namespace>()); + + frameNS = frame.Self; + + ASSERT(frameNS != ScriptGlobal::GetGlobals()); + } + + frameNS->Set("obj", target); + frameNS->Set(varName, target); + + for (int fid = 0; fid < type->GetFieldCount(); fid++) { + Field field = type->GetFieldInfo(fid); + + if ((field.Attributes & FANavigation) == 0) + continue; + + Object::Ptr joinedObj = target->NavigateField(fid); + + if (field.NavigationName) + frameNS->Set(field.NavigationName, joinedObj); + else + frameNS->Set(field.Name, joinedObj); + } + + return Convert::ToBool(filter->Evaluate(frame)); +} + +static void FilteredAddTarget(ScriptFrame& permissionFrame, Expression *permissionFilter, + ScriptFrame& frame, Expression *ufilter, std::vector<Value>& result, const String& variableName, const Object::Ptr& target) +{ + if (FilterUtility::EvaluateFilter(permissionFrame, permissionFilter, target, variableName)) { + if (FilterUtility::EvaluateFilter(frame, ufilter, target, variableName)) { + result.emplace_back(std::move(target)); + } + } +} + +/** + * Checks whether the given API user is granted the given permission + * + * When you desire an exception to be raised when the given user doesn't have the given permission, + * you need to use FilterUtility::CheckPermission(). + * + * @param user ApiUser pointer to the user object you want to check the permission of + * @param permission The actual permission you want to check the user permission against + * @param permissionFilter Expression pointer that is used as an output buffer for all the filter expressions of the + * individual permissions of the given user to be evaluated. It's up to the caller to delete + * this pointer when it's not needed any more. + * + * @return bool + */ +bool FilterUtility::HasPermission(const ApiUser::Ptr& user, const String& permission, Expression **permissionFilter) +{ + if (permissionFilter) + *permissionFilter = nullptr; + + if (permission.IsEmpty()) + return true; + + bool foundPermission = false; + String requiredPermission = permission.ToLower(); + + Array::Ptr permissions = user->GetPermissions(); + if (permissions) { + ObjectLock olock(permissions); + for (const Value& item : permissions) { + String permission; + Function::Ptr filter; + if (item.IsObjectType<Dictionary>()) { + Dictionary::Ptr dict = item; + permission = dict->Get("permission"); + filter = dict->Get("filter"); + } else + permission = item; + + permission = permission.ToLower(); + + if (!Utility::Match(permission, requiredPermission)) + continue; + + foundPermission = true; + + if (filter && permissionFilter) { + std::vector<std::unique_ptr<Expression> > args; + args.emplace_back(new GetScopeExpression(ScopeThis)); + std::unique_ptr<Expression> indexer{new IndexerExpression(std::unique_ptr<Expression>(MakeLiteral(filter)), std::unique_ptr<Expression>(MakeLiteral("call")))}; + FunctionCallExpression *fexpr = new FunctionCallExpression(std::move(indexer), std::move(args)); + + if (!*permissionFilter) + *permissionFilter = fexpr; + else + *permissionFilter = new LogicalOrExpression(std::unique_ptr<Expression>(*permissionFilter), std::unique_ptr<Expression>(fexpr)); + } + } + } + + if (!foundPermission) { + Log(LogWarning, "FilterUtility") + << "Missing permission: " << requiredPermission; + } + + return foundPermission; +} + +void FilterUtility::CheckPermission(const ApiUser::Ptr& user, const String& permission, Expression **permissionFilter) +{ + if (!HasPermission(user, permission, permissionFilter)) { + BOOST_THROW_EXCEPTION(ScriptError("Missing permission: " + permission.ToLower())); + } +} + +std::vector<Value> FilterUtility::GetFilterTargets(const QueryDescription& qd, const Dictionary::Ptr& query, const ApiUser::Ptr& user, const String& variableName) +{ + std::vector<Value> result; + + TargetProvider::Ptr provider; + + if (qd.Provider) + provider = qd.Provider; + else + provider = new ConfigObjectTargetProvider(); + + Expression *permissionFilter; + CheckPermission(user, qd.Permission, &permissionFilter); + + Namespace::Ptr permissionFrameNS = new Namespace(); + ScriptFrame permissionFrame(false, permissionFrameNS); + + for (const String& type : qd.Types) { + String attr = type; + boost::algorithm::to_lower(attr); + + if (attr == "type") + attr = "name"; + + if (query && query->Contains(attr)) { + String name = HttpUtility::GetLastParameter(query, attr); + Object::Ptr target = provider->GetTargetByName(type, name); + + if (!FilterUtility::EvaluateFilter(permissionFrame, permissionFilter, target, variableName)) + BOOST_THROW_EXCEPTION(ScriptError("Access denied to object '" + name + "' of type '" + type + "'")); + + result.emplace_back(std::move(target)); + } + + attr = provider->GetPluralName(type); + boost::algorithm::to_lower(attr); + + if (query && query->Contains(attr)) { + Array::Ptr names = query->Get(attr); + if (names) { + ObjectLock olock(names); + for (const String& name : names) { + Object::Ptr target = provider->GetTargetByName(type, name); + + if (!FilterUtility::EvaluateFilter(permissionFrame, permissionFilter, target, variableName)) + BOOST_THROW_EXCEPTION(ScriptError("Access denied to object '" + name + "' of type '" + type + "'")); + + result.emplace_back(std::move(target)); + } + } + } + } + + if ((query && query->Contains("filter")) || result.empty()) { + if (!query->Contains("type")) + BOOST_THROW_EXCEPTION(std::invalid_argument("Type must be specified when using a filter.")); + + String type = HttpUtility::GetLastParameter(query, "type"); + + if (!provider->IsValidType(type)) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid type specified.")); + + if (qd.Types.find(type) == qd.Types.end()) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid type specified for this query.")); + + Namespace::Ptr frameNS = new Namespace(); + ScriptFrame frame(false, frameNS); + frame.Sandboxed = true; + + if (query->Contains("filter")) { + String filter = HttpUtility::GetLastParameter(query, "filter"); + std::unique_ptr<Expression> ufilter = ConfigCompiler::CompileText("<API query>", filter); + + Dictionary::Ptr filter_vars = query->Get("filter_vars"); + if (filter_vars) { + ObjectLock olock(filter_vars); + for (const Dictionary::Pair& kv : filter_vars) { + frameNS->Set(kv.first, kv.second); + } + } + + provider->FindTargets(type, [&permissionFrame, permissionFilter, &frame, &ufilter, &result, variableName](const Object::Ptr& target) { + FilteredAddTarget(permissionFrame, permissionFilter, frame, &*ufilter, result, variableName, target); + }); + } else { + /* Ensure to pass a nullptr as filter expression. + * GCC 8.1.1 on F28 causes problems, see GH #6533. + */ + provider->FindTargets(type, [&permissionFrame, permissionFilter, &frame, &result, variableName](const Object::Ptr& target) { + FilteredAddTarget(permissionFrame, permissionFilter, frame, nullptr, result, variableName, target); + }); + } + } + + return result; +} + diff --git a/lib/remote/filterutility.hpp b/lib/remote/filterutility.hpp new file mode 100644 index 0000000..1cebffc --- /dev/null +++ b/lib/remote/filterutility.hpp @@ -0,0 +1,64 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef FILTERUTILITY_H +#define FILTERUTILITY_H + +#include "remote/i2-remote.hpp" +#include "remote/apiuser.hpp" +#include "config/expression.hpp" +#include "base/dictionary.hpp" +#include "base/configobject.hpp" +#include <set> + +namespace icinga +{ + +class TargetProvider : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(TargetProvider); + + virtual void FindTargets(const String& type, const std::function<void (const Value&)>& addTarget) const = 0; + virtual Value GetTargetByName(const String& type, const String& name) const = 0; + virtual bool IsValidType(const String& type) const = 0; + virtual String GetPluralName(const String& type) const = 0; +}; + +class ConfigObjectTargetProvider final : public TargetProvider +{ +public: + DECLARE_PTR_TYPEDEFS(ConfigObjectTargetProvider); + + void FindTargets(const String& type, const std::function<void (const Value&)>& addTarget) const override; + Value GetTargetByName(const String& type, const String& name) const override; + bool IsValidType(const String& type) const override; + String GetPluralName(const String& type) const override; +}; + +struct QueryDescription +{ + std::set<String> Types; + TargetProvider::Ptr Provider; + String Permission; +}; + +/** + * Filter utilities. + * + * @ingroup remote + */ +class FilterUtility +{ +public: + static Type::Ptr TypeFromPluralName(const String& pluralName); + static void CheckPermission(const ApiUser::Ptr& user, const String& permission, Expression **filter = nullptr); + static bool HasPermission(const ApiUser::Ptr& user, const String& permission, Expression **permissionFilter = nullptr); + static std::vector<Value> GetFilterTargets(const QueryDescription& qd, const Dictionary::Ptr& query, + const ApiUser::Ptr& user, const String& variableName = String()); + static bool EvaluateFilter(ScriptFrame& frame, Expression *filter, + const Object::Ptr& target, const String& variableName = String()); +}; + +} + +#endif /* FILTERUTILITY_H */ diff --git a/lib/remote/httphandler.cpp b/lib/remote/httphandler.cpp new file mode 100644 index 0000000..e1bb4f4 --- /dev/null +++ b/lib/remote/httphandler.cpp @@ -0,0 +1,129 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "base/logger.hpp" +#include "remote/httphandler.hpp" +#include "remote/httputility.hpp" +#include "base/singleton.hpp" +#include "base/exception.hpp" +#include <boost/algorithm/string/join.hpp> +#include <boost/beast/http.hpp> + +using namespace icinga; + +Dictionary::Ptr HttpHandler::m_UrlTree; + +void HttpHandler::Register(const Url::Ptr& url, const HttpHandler::Ptr& handler) +{ + if (!m_UrlTree) + m_UrlTree = new Dictionary(); + + Dictionary::Ptr node = m_UrlTree; + + for (const String& elem : url->GetPath()) { + Dictionary::Ptr children = node->Get("children"); + + if (!children) { + children = new Dictionary(); + node->Set("children", children); + } + + Dictionary::Ptr sub_node = children->Get(elem); + if (!sub_node) { + sub_node = new Dictionary(); + children->Set(elem, sub_node); + } + + node = sub_node; + } + + Array::Ptr handlers = node->Get("handlers"); + + if (!handlers) { + handlers = new Array(); + node->Set("handlers", handlers); + } + + handlers->Add(handler); +} + +void HttpHandler::ProcessRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + Dictionary::Ptr node = m_UrlTree; + std::vector<HttpHandler::Ptr> handlers; + + Url::Ptr url = new Url(request.target().to_string()); + auto& path (url->GetPath()); + + for (std::vector<String>::size_type i = 0; i <= path.size(); i++) { + Array::Ptr current_handlers = node->Get("handlers"); + + if (current_handlers) { + ObjectLock olock(current_handlers); + for (const HttpHandler::Ptr& current_handler : current_handlers) { + handlers.push_back(current_handler); + } + } + + Dictionary::Ptr children = node->Get("children"); + + if (!children) { + node.reset(); + break; + } + + if (i == path.size()) + break; + + node = children->Get(path[i]); + + if (!node) + break; + } + + std::reverse(handlers.begin(), handlers.end()); + + Dictionary::Ptr params; + + try { + params = HttpUtility::FetchRequestParameters(url, request.body()); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 400, "Invalid request body: " + DiagnosticInformation(ex, false)); + return; + } + + bool processed = false; + + /* + * HandleRequest may throw a permission exception. + * DO NOT return a specific permission error. This + * allows attackers to guess from words which objects + * do exist. + */ + try { + for (const HttpHandler::Ptr& handler : handlers) { + if (handler->HandleRequest(stream, user, request, url, response, params, yc, server)) { + processed = true; + break; + } + } + } catch (const std::exception& ex) { + Log(LogWarning, "HttpServerConnection") + << "Error while processing HTTP request: " << ex.what(); + + processed = false; + } + + if (!processed) { + HttpUtility::SendJsonError(response, params, 404, "The requested path '" + boost::algorithm::join(path, "/") + + "' could not be found or the request method is not valid for this path."); + return; + } +} + diff --git a/lib/remote/httphandler.hpp b/lib/remote/httphandler.hpp new file mode 100644 index 0000000..a6a7302 --- /dev/null +++ b/lib/remote/httphandler.hpp @@ -0,0 +1,74 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef HTTPHANDLER_H +#define HTTPHANDLER_H + +#include "remote/i2-remote.hpp" +#include "remote/url.hpp" +#include "remote/httpserverconnection.hpp" +#include "remote/apiuser.hpp" +#include "base/registry.hpp" +#include "base/tlsstream.hpp" +#include <vector> +#include <boost/asio/spawn.hpp> +#include <boost/beast/http.hpp> + +namespace icinga +{ + +/** + * HTTP handler. + * + * @ingroup remote + */ +class HttpHandler : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(HttpHandler); + + virtual bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) = 0; + + static void Register(const Url::Ptr& url, const HttpHandler::Ptr& handler); + static void ProcessRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + boost::asio::yield_context& yc, + HttpServerConnection& server + ); + +private: + static Dictionary::Ptr m_UrlTree; +}; + +/** + * Helper class for registering HTTP handlers. + * + * @ingroup remote + */ +class RegisterHttpHandler +{ +public: + RegisterHttpHandler(const String& url, const HttpHandler& function); +}; + +#define REGISTER_URLHANDLER(url, klass) \ + INITIALIZE_ONCE([]() { \ + Url::Ptr uurl = new Url(url); \ + HttpHandler::Ptr handler = new klass(); \ + HttpHandler::Register(uurl, handler); \ + }) + +} + +#endif /* HTTPHANDLER_H */ diff --git a/lib/remote/httpserverconnection.cpp b/lib/remote/httpserverconnection.cpp new file mode 100644 index 0000000..cb07557 --- /dev/null +++ b/lib/remote/httpserverconnection.cpp @@ -0,0 +1,609 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/httpserverconnection.hpp" +#include "remote/httphandler.hpp" +#include "remote/httputility.hpp" +#include "remote/apilistener.hpp" +#include "remote/apifunction.hpp" +#include "remote/jsonrpc.hpp" +#include "base/application.hpp" +#include "base/base64.hpp" +#include "base/convert.hpp" +#include "base/configtype.hpp" +#include "base/defer.hpp" +#include "base/exception.hpp" +#include "base/io-engine.hpp" +#include "base/logger.hpp" +#include "base/objectlock.hpp" +#include "base/timer.hpp" +#include "base/tlsstream.hpp" +#include "base/utility.hpp" +#include <limits> +#include <memory> +#include <stdexcept> +#include <boost/asio/error.hpp> +#include <boost/asio/io_context.hpp> +#include <boost/asio/spawn.hpp> +#include <boost/beast/core.hpp> +#include <boost/beast/http.hpp> +#include <boost/system/error_code.hpp> +#include <boost/system/system_error.hpp> +#include <boost/thread/once.hpp> + +using namespace icinga; + +auto const l_ServerHeader ("Icinga/" + Application::GetAppVersion()); + +HttpServerConnection::HttpServerConnection(const String& identity, bool authenticated, const Shared<AsioTlsStream>::Ptr& stream) + : HttpServerConnection(identity, authenticated, stream, IoEngine::Get().GetIoContext()) +{ +} + +HttpServerConnection::HttpServerConnection(const String& identity, bool authenticated, const Shared<AsioTlsStream>::Ptr& stream, boost::asio::io_context& io) + : m_Stream(stream), m_Seen(Utility::GetTime()), m_IoStrand(io), m_ShuttingDown(false), m_HasStartedStreaming(false), + m_CheckLivenessTimer(io) +{ + if (authenticated) { + m_ApiUser = ApiUser::GetByClientCN(identity); + } + + { + std::ostringstream address; + auto endpoint (stream->lowest_layer().remote_endpoint()); + + address << '[' << endpoint.address() << "]:" << endpoint.port(); + + m_PeerAddress = address.str(); + } +} + +void HttpServerConnection::Start() +{ + namespace asio = boost::asio; + + HttpServerConnection::Ptr keepAlive (this); + + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { ProcessMessages(yc); }); + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { CheckLiveness(yc); }); +} + +void HttpServerConnection::Disconnect() +{ + namespace asio = boost::asio; + + HttpServerConnection::Ptr keepAlive (this); + + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { + if (!m_ShuttingDown) { + m_ShuttingDown = true; + + Log(LogInformation, "HttpServerConnection") + << "HTTP client disconnected (from " << m_PeerAddress << ")"; + + /* + * Do not swallow exceptions in a coroutine. + * https://github.com/Icinga/icinga2/issues/7351 + * We must not catch `detail::forced_unwind exception` as + * this is used for unwinding the stack. + * + * Just use the error_code dummy here. + */ + boost::system::error_code ec; + + m_CheckLivenessTimer.cancel(); + + m_Stream->lowest_layer().cancel(ec); + + m_Stream->next_layer().async_shutdown(yc[ec]); + + m_Stream->lowest_layer().shutdown(m_Stream->lowest_layer().shutdown_both, ec); + + auto listener (ApiListener::GetInstance()); + + if (listener) { + CpuBoundWork removeHttpClient (yc); + + listener->RemoveHttpClient(this); + } + } + }); +} + +void HttpServerConnection::StartStreaming() +{ + namespace asio = boost::asio; + + m_HasStartedStreaming = true; + + HttpServerConnection::Ptr keepAlive (this); + + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { + if (!m_ShuttingDown) { + char buf[128]; + asio::mutable_buffer readBuf (buf, 128); + boost::system::error_code ec; + + do { + m_Stream->async_read_some(readBuf, yc[ec]); + } while (!ec); + + Disconnect(); + } + }); +} + +bool HttpServerConnection::Disconnected() +{ + return m_ShuttingDown; +} + +static inline +bool EnsureValidHeaders( + AsioTlsStream& stream, + boost::beast::flat_buffer& buf, + boost::beast::http::parser<true, boost::beast::http::string_body>& parser, + boost::beast::http::response<boost::beast::http::string_body>& response, + bool& shuttingDown, + boost::asio::yield_context& yc +) +{ + namespace http = boost::beast::http; + + if (shuttingDown) + return false; + + bool httpError = false; + String errorMsg; + + boost::system::error_code ec; + + http::async_read_header(stream, buf, parser, yc[ec]); + + if (ec) { + if (ec == boost::asio::error::operation_aborted) + return false; + + errorMsg = ec.message(); + httpError = true; + } else { + switch (parser.get().version()) { + case 10: + case 11: + break; + default: + errorMsg = "Unsupported HTTP version"; + } + } + + if (!errorMsg.IsEmpty() || httpError) { + response.result(http::status::bad_request); + + if (!httpError && parser.get()[http::field::accept] == "application/json") { + HttpUtility::SendJsonBody(response, nullptr, new Dictionary({ + { "error", 400 }, + { "status", String("Bad Request: ") + errorMsg } + })); + } else { + response.set(http::field::content_type, "text/html"); + response.body() = String("<h1>Bad Request</h1><p><pre>") + errorMsg + "</pre></p>"; + response.content_length(response.body().size()); + } + + response.set(http::field::connection, "close"); + + boost::system::error_code ec; + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + + return false; + } + + return true; +} + +static inline +void HandleExpect100( + AsioTlsStream& stream, + boost::beast::http::request<boost::beast::http::string_body>& request, + boost::asio::yield_context& yc +) +{ + namespace http = boost::beast::http; + + if (request[http::field::expect] == "100-continue") { + http::response<http::string_body> response; + + response.result(http::status::continue_); + + boost::system::error_code ec; + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + } +} + +static inline +bool HandleAccessControl( + AsioTlsStream& stream, + boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + boost::asio::yield_context& yc +) +{ + namespace http = boost::beast::http; + + auto listener (ApiListener::GetInstance()); + + if (listener) { + auto headerAllowOrigin (listener->GetAccessControlAllowOrigin()); + + if (headerAllowOrigin) { + CpuBoundWork allowOriginHeader (yc); + + auto allowedOrigins (headerAllowOrigin->ToSet<String>()); + + if (!allowedOrigins.empty()) { + auto& origin (request[http::field::origin]); + + if (allowedOrigins.find(origin.to_string()) != allowedOrigins.end()) { + response.set(http::field::access_control_allow_origin, origin); + } + + allowOriginHeader.Done(); + + response.set(http::field::access_control_allow_credentials, "true"); + + if (request.method() == http::verb::options && !request[http::field::access_control_request_method].empty()) { + response.result(http::status::ok); + response.set(http::field::access_control_allow_methods, "GET, POST, PUT, DELETE"); + response.set(http::field::access_control_allow_headers, "Authorization, Content-Type, X-HTTP-Method-Override"); + response.body() = "Preflight OK"; + response.content_length(response.body().size()); + response.set(http::field::connection, "close"); + + boost::system::error_code ec; + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + + return false; + } + } + } + } + + return true; +} + +static inline +bool EnsureAcceptHeader( + AsioTlsStream& stream, + boost::beast::http::request<boost::beast::http::string_body>& request, + boost::beast::http::response<boost::beast::http::string_body>& response, + boost::asio::yield_context& yc +) +{ + namespace http = boost::beast::http; + + if (request.method() != http::verb::get && request[http::field::accept] != "application/json") { + response.result(http::status::bad_request); + response.set(http::field::content_type, "text/html"); + response.body() = "<h1>Accept header is missing or not set to 'application/json'.</h1>"; + response.content_length(response.body().size()); + response.set(http::field::connection, "close"); + + boost::system::error_code ec; + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + + return false; + } + + return true; +} + +static inline +bool EnsureAuthenticatedUser( + AsioTlsStream& stream, + boost::beast::http::request<boost::beast::http::string_body>& request, + ApiUser::Ptr& authenticatedUser, + boost::beast::http::response<boost::beast::http::string_body>& response, + boost::asio::yield_context& yc +) +{ + namespace http = boost::beast::http; + + if (!authenticatedUser) { + Log(LogWarning, "HttpServerConnection") + << "Unauthorized request: " << request.method_string() << ' ' << request.target(); + + response.result(http::status::unauthorized); + response.set(http::field::www_authenticate, "Basic realm=\"Icinga 2\""); + response.set(http::field::connection, "close"); + + if (request[http::field::accept] == "application/json") { + HttpUtility::SendJsonBody(response, nullptr, new Dictionary({ + { "error", 401 }, + { "status", "Unauthorized. Please check your user credentials." } + })); + } else { + response.set(http::field::content_type, "text/html"); + response.body() = "<h1>Unauthorized. Please check your user credentials.</h1>"; + response.content_length(response.body().size()); + } + + boost::system::error_code ec; + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + + return false; + } + + return true; +} + +static inline +bool EnsureValidBody( + AsioTlsStream& stream, + boost::beast::flat_buffer& buf, + boost::beast::http::parser<true, boost::beast::http::string_body>& parser, + ApiUser::Ptr& authenticatedUser, + boost::beast::http::response<boost::beast::http::string_body>& response, + bool& shuttingDown, + boost::asio::yield_context& yc +) +{ + namespace http = boost::beast::http; + + { + size_t maxSize = 1024 * 1024; + Array::Ptr permissions = authenticatedUser->GetPermissions(); + + if (permissions) { + CpuBoundWork evalPermissions (yc); + + ObjectLock olock(permissions); + + for (const Value& permissionInfo : permissions) { + String permission; + + if (permissionInfo.IsObjectType<Dictionary>()) { + permission = static_cast<Dictionary::Ptr>(permissionInfo)->Get("permission"); + } else { + permission = permissionInfo; + } + + static std::vector<std::pair<String, size_t>> specialContentLengthLimits { + { "config/modify", 512 * 1024 * 1024 } + }; + + for (const auto& limitInfo : specialContentLengthLimits) { + if (limitInfo.second <= maxSize) { + continue; + } + + if (Utility::Match(permission, limitInfo.first)) { + maxSize = limitInfo.second; + } + } + } + } + + parser.body_limit(maxSize); + } + + if (shuttingDown) + return false; + + boost::system::error_code ec; + + http::async_read(stream, buf, parser, yc[ec]); + + if (ec) { + if (ec == boost::asio::error::operation_aborted) + return false; + + /** + * Unfortunately there's no way to tell an HTTP protocol error + * from an error on a lower layer: + * + * <https://github.com/boostorg/beast/issues/643> + */ + + response.result(http::status::bad_request); + + if (parser.get()[http::field::accept] == "application/json") { + HttpUtility::SendJsonBody(response, nullptr, new Dictionary({ + { "error", 400 }, + { "status", String("Bad Request: ") + ec.message() } + })); + } else { + response.set(http::field::content_type, "text/html"); + response.body() = String("<h1>Bad Request</h1><p><pre>") + ec.message() + "</pre></p>"; + response.content_length(response.body().size()); + } + + response.set(http::field::connection, "close"); + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + + return false; + } + + return true; +} + +static inline +bool ProcessRequest( + AsioTlsStream& stream, + boost::beast::http::request<boost::beast::http::string_body>& request, + ApiUser::Ptr& authenticatedUser, + boost::beast::http::response<boost::beast::http::string_body>& response, + HttpServerConnection& server, + bool& hasStartedStreaming, + boost::asio::yield_context& yc +) +{ + namespace http = boost::beast::http; + + try { + CpuBoundWork handlingRequest (yc); + + HttpHandler::ProcessRequest(stream, authenticatedUser, request, response, yc, server); + } catch (const std::exception& ex) { + if (hasStartedStreaming) { + return false; + } + + auto sysErr (dynamic_cast<const boost::system::system_error*>(&ex)); + + if (sysErr && sysErr->code() == boost::asio::error::operation_aborted) { + throw; + } + + http::response<http::string_body> response; + + HttpUtility::SendJsonError(response, nullptr, 500, "Unhandled exception" , DiagnosticInformation(ex)); + + boost::system::error_code ec; + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + + return true; + } + + if (hasStartedStreaming) { + return false; + } + + boost::system::error_code ec; + + http::async_write(stream, response, yc[ec]); + stream.async_flush(yc[ec]); + + return true; +} + +void HttpServerConnection::ProcessMessages(boost::asio::yield_context yc) +{ + namespace beast = boost::beast; + namespace http = beast::http; + + try { + /* Do not reset the buffer in the state machine. + * EnsureValidHeaders already reads from the stream into the buffer, + * EnsureValidBody continues. ProcessRequest() actually handles the request + * and needs the full buffer. + */ + beast::flat_buffer buf; + + for (;;) { + m_Seen = Utility::GetTime(); + + http::parser<true, http::string_body> parser; + http::response<http::string_body> response; + + parser.header_limit(1024 * 1024); + parser.body_limit(-1); + + response.set(http::field::server, l_ServerHeader); + + if (!EnsureValidHeaders(*m_Stream, buf, parser, response, m_ShuttingDown, yc)) { + break; + } + + m_Seen = Utility::GetTime(); + + auto& request (parser.get()); + + { + auto method (http::string_to_verb(request["X-Http-Method-Override"])); + + if (method != http::verb::unknown) { + request.method(method); + } + } + + HandleExpect100(*m_Stream, request, yc); + + auto authenticatedUser (m_ApiUser); + + if (!authenticatedUser) { + CpuBoundWork fetchingAuthenticatedUser (yc); + + authenticatedUser = ApiUser::GetByAuthHeader(request[http::field::authorization].to_string()); + } + + Log logMsg (LogInformation, "HttpServerConnection"); + + logMsg << "Request: " << request.method_string() << ' ' << request.target() + << " (from " << m_PeerAddress + << "), user: " << (authenticatedUser ? authenticatedUser->GetName() : "<unauthenticated>") + << ", agent: " << request[http::field::user_agent]; //operator[] - Returns the value for a field, or "" if it does not exist. + + Defer addRespCode ([&response, &logMsg]() { + logMsg << ", status: " << response.result() << ")."; + }); + + if (!HandleAccessControl(*m_Stream, request, response, yc)) { + break; + } + + if (!EnsureAcceptHeader(*m_Stream, request, response, yc)) { + break; + } + + if (!EnsureAuthenticatedUser(*m_Stream, request, authenticatedUser, response, yc)) { + break; + } + + if (!EnsureValidBody(*m_Stream, buf, parser, authenticatedUser, response, m_ShuttingDown, yc)) { + break; + } + + m_Seen = std::numeric_limits<decltype(m_Seen)>::max(); + + if (!ProcessRequest(*m_Stream, request, authenticatedUser, response, *this, m_HasStartedStreaming, yc)) { + break; + } + + if (request.version() != 11 || request[http::field::connection] == "close") { + break; + } + } + } catch (const std::exception& ex) { + if (!m_ShuttingDown) { + Log(LogCritical, "HttpServerConnection") + << "Unhandled exception while processing HTTP request: " << ex.what(); + } + } + + Disconnect(); +} + +void HttpServerConnection::CheckLiveness(boost::asio::yield_context yc) +{ + boost::system::error_code ec; + + for (;;) { + m_CheckLivenessTimer.expires_from_now(boost::posix_time::seconds(5)); + m_CheckLivenessTimer.async_wait(yc[ec]); + + if (m_ShuttingDown) { + break; + } + + if (m_Seen < Utility::GetTime() - 10) { + Log(LogInformation, "HttpServerConnection") + << "No messages for HTTP connection have been received in the last 10 seconds."; + + Disconnect(); + break; + } + } +} diff --git a/lib/remote/httpserverconnection.hpp b/lib/remote/httpserverconnection.hpp new file mode 100644 index 0000000..9c812e5 --- /dev/null +++ b/lib/remote/httpserverconnection.hpp @@ -0,0 +1,54 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef HTTPSERVERCONNECTION_H +#define HTTPSERVERCONNECTION_H + +#include "remote/apiuser.hpp" +#include "base/string.hpp" +#include "base/tlsstream.hpp" +#include <memory> +#include <boost/asio/deadline_timer.hpp> +#include <boost/asio/io_context.hpp> +#include <boost/asio/io_context_strand.hpp> +#include <boost/asio/spawn.hpp> + +namespace icinga +{ + +/** + * An API client connection. + * + * @ingroup remote + */ +class HttpServerConnection final : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(HttpServerConnection); + + HttpServerConnection(const String& identity, bool authenticated, const Shared<AsioTlsStream>::Ptr& stream); + + void Start(); + void Disconnect(); + void StartStreaming(); + + bool Disconnected(); + +private: + ApiUser::Ptr m_ApiUser; + Shared<AsioTlsStream>::Ptr m_Stream; + double m_Seen; + String m_PeerAddress; + boost::asio::io_context::strand m_IoStrand; + bool m_ShuttingDown; + bool m_HasStartedStreaming; + boost::asio::deadline_timer m_CheckLivenessTimer; + + HttpServerConnection(const String& identity, bool authenticated, const Shared<AsioTlsStream>::Ptr& stream, boost::asio::io_context& io); + + void ProcessMessages(boost::asio::yield_context yc); + void CheckLiveness(boost::asio::yield_context yc); +}; + +} + +#endif /* HTTPSERVERCONNECTION_H */ diff --git a/lib/remote/httputility.cpp b/lib/remote/httputility.cpp new file mode 100644 index 0000000..a2142e5 --- /dev/null +++ b/lib/remote/httputility.cpp @@ -0,0 +1,80 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/httputility.hpp" +#include "remote/url.hpp" +#include "base/json.hpp" +#include "base/logger.hpp" +#include <map> +#include <string> +#include <vector> +#include <boost/beast/http.hpp> + +using namespace icinga; + +Dictionary::Ptr HttpUtility::FetchRequestParameters(const Url::Ptr& url, const std::string& body) +{ + Dictionary::Ptr result; + + if (!body.empty()) { + Log(LogDebug, "HttpUtility") + << "Request body: '" << body << '\''; + + result = JsonDecode(body); + } + + if (!result) + result = new Dictionary(); + + std::map<String, std::vector<String>> query; + for (const auto& kv : url->GetQuery()) { + query[kv.first].emplace_back(kv.second); + } + + for (auto& kv : query) { + result->Set(kv.first, Array::FromVector(kv.second)); + } + + return result; +} + +Value HttpUtility::GetLastParameter(const Dictionary::Ptr& params, const String& key) +{ + Value varr = params->Get(key); + + if (!varr.IsObjectType<Array>()) + return varr; + + Array::Ptr arr = varr; + + if (arr->GetLength() == 0) + return Empty; + else + return arr->Get(arr->GetLength() - 1); +} + +void HttpUtility::SendJsonBody(boost::beast::http::response<boost::beast::http::string_body>& response, const Dictionary::Ptr& params, const Value& val) +{ + namespace http = boost::beast::http; + + response.set(http::field::content_type, "application/json"); + response.body() = JsonEncode(val, params && GetLastParameter(params, "pretty")); + response.content_length(response.body().size()); +} + +void HttpUtility::SendJsonError(boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, int code, const String& info, const String& diagnosticInformation) +{ + Dictionary::Ptr result = new Dictionary({ { "error", code } }); + + if (!info.IsEmpty()) { + result->Set("status", info); + } + + if (params && HttpUtility::GetLastParameter(params, "verbose") && !diagnosticInformation.IsEmpty()) { + result->Set("diagnostic_information", diagnosticInformation); + } + + response.result(code); + + HttpUtility::SendJsonBody(response, params, result); +} diff --git a/lib/remote/httputility.hpp b/lib/remote/httputility.hpp new file mode 100644 index 0000000..6465b4a --- /dev/null +++ b/lib/remote/httputility.hpp @@ -0,0 +1,33 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef HTTPUTILITY_H +#define HTTPUTILITY_H + +#include "remote/url.hpp" +#include "base/dictionary.hpp" +#include <boost/beast/http.hpp> +#include <string> + +namespace icinga +{ + +/** + * Helper functions. + * + * @ingroup remote + */ +class HttpUtility +{ + +public: + static Dictionary::Ptr FetchRequestParameters(const Url::Ptr& url, const std::string& body); + static Value GetLastParameter(const Dictionary::Ptr& params, const String& key); + + static void SendJsonBody(boost::beast::http::response<boost::beast::http::string_body>& response, const Dictionary::Ptr& params, const Value& val); + static void SendJsonError(boost::beast::http::response<boost::beast::http::string_body>& response, const Dictionary::Ptr& params, const int code, + const String& verbose = String(), const String& diagnosticInformation = String()); +}; + +} + +#endif /* HTTPUTILITY_H */ diff --git a/lib/remote/i2-remote.hpp b/lib/remote/i2-remote.hpp new file mode 100644 index 0000000..5755bef --- /dev/null +++ b/lib/remote/i2-remote.hpp @@ -0,0 +1,14 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef I2REMOTE_H +#define I2REMOTE_H + +/** + * @defgroup remote Remote library + * + * The Icinga library implements remote cluster functionality. + */ + +#include "base/i2-base.hpp" + +#endif /* I2REMOTE_H */ diff --git a/lib/remote/infohandler.cpp b/lib/remote/infohandler.cpp new file mode 100644 index 0000000..80ebba7 --- /dev/null +++ b/lib/remote/infohandler.cpp @@ -0,0 +1,100 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/infohandler.hpp" +#include "remote/httputility.hpp" +#include "base/application.hpp" + +using namespace icinga; + +REGISTER_URLHANDLER("/", InfoHandler); + +bool InfoHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() > 2) + return false; + + if (request.method() != http::verb::get) + return false; + + if (url->GetPath().empty()) { + response.result(http::status::found); + response.set(http::field::location, "/v1"); + return true; + } + + if (url->GetPath()[0] != "v1" || url->GetPath().size() != 1) + return false; + + response.result(http::status::ok); + + std::vector<String> permInfo; + Array::Ptr permissions = user->GetPermissions(); + + if (permissions) { + ObjectLock olock(permissions); + for (const Value& permission : permissions) { + String name; + bool hasFilter = false; + if (permission.IsObjectType<Dictionary>()) { + Dictionary::Ptr dpermission = permission; + name = dpermission->Get("permission"); + hasFilter = dpermission->Contains("filter"); + } else + name = permission; + + if (hasFilter) + name += " (filtered)"; + + permInfo.emplace_back(std::move(name)); + } + } + + if (request[http::field::accept] == "application/json") { + Dictionary::Ptr result1 = new Dictionary({ + { "user", user->GetName() }, + { "permissions", Array::FromVector(permInfo) }, + { "version", Application::GetAppVersion() }, + { "info", "More information about API requests is available in the documentation at https://icinga.com/docs/icinga2/latest/" } + }); + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array({ result1 }) } + }); + + HttpUtility::SendJsonBody(response, params, result); + } else { + response.set(http::field::content_type, "text/html"); + + String body = "<html><head><title>Icinga 2</title></head><h1>Hello from Icinga 2 (Version: " + Application::GetAppVersion() + ")!</h1>"; + body += "<p>You are authenticated as <b>" + user->GetName() + "</b>. "; + + if (!permInfo.empty()) { + body += "Your user has the following permissions:</p> <ul>"; + + for (const String& perm : permInfo) { + body += "<li>" + perm + "</li>"; + } + + body += "</ul>"; + } else + body += "Your user does not have any permissions.</p>"; + + body += R"(<p>More information about API requests is available in the <a href="https://icinga.com/docs/icinga2/latest/" target="_blank">documentation</a>.</p></html>)"; + response.body() = body; + response.content_length(response.body().size()); + } + + return true; +} + diff --git a/lib/remote/infohandler.hpp b/lib/remote/infohandler.hpp new file mode 100644 index 0000000..e1fe983 --- /dev/null +++ b/lib/remote/infohandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef INFOHANDLER_H +#define INFOHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class InfoHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(InfoHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* INFOHANDLER_H */ diff --git a/lib/remote/jsonrpc.cpp b/lib/remote/jsonrpc.cpp new file mode 100644 index 0000000..5828800 --- /dev/null +++ b/lib/remote/jsonrpc.cpp @@ -0,0 +1,157 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/jsonrpc.hpp" +#include "base/netstring.hpp" +#include "base/json.hpp" +#include "base/console.hpp" +#include "base/scriptglobal.hpp" +#include "base/convert.hpp" +#include "base/tlsstream.hpp" +#include <iostream> +#include <memory> +#include <utility> +#include <boost/asio/spawn.hpp> + +using namespace icinga; + +#ifdef I2_DEBUG +/** + * Determine whether the developer wants to see raw JSON messages. + * + * @return Internal.DebugJsonRpc boolean + */ +static bool GetDebugJsonRpcCached() +{ + static int debugJsonRpc = -1; + + if (debugJsonRpc != -1) + return debugJsonRpc; + + debugJsonRpc = false; + + Namespace::Ptr internal = ScriptGlobal::Get("Internal", &Empty); + + if (!internal) + return false; + + Value vdebug; + + if (!internal->Get("DebugJsonRpc", &vdebug)) + return false; + + debugJsonRpc = Convert::ToLong(vdebug); + + return debugJsonRpc; +} +#endif /* I2_DEBUG */ + +/** + * Sends a message to the connected peer and returns the bytes sent. + * + * @param message The message. + * + * @return The amount of bytes sent. + */ +size_t JsonRpc::SendMessage(const Shared<AsioTlsStream>::Ptr& stream, const Dictionary::Ptr& message) +{ + String json = JsonEncode(message); + +#ifdef I2_DEBUG + if (GetDebugJsonRpcCached()) + std::cerr << ConsoleColorTag(Console_ForegroundBlue) << ">> " << json << ConsoleColorTag(Console_Normal) << "\n"; +#endif /* I2_DEBUG */ + + return NetString::WriteStringToStream(stream, json); +} + +/** + * Sends a message to the connected peer and returns the bytes sent. + * + * @param message The message. + * + * @return The amount of bytes sent. + */ +size_t JsonRpc::SendMessage(const Shared<AsioTlsStream>::Ptr& stream, const Dictionary::Ptr& message, boost::asio::yield_context yc) +{ + return JsonRpc::SendRawMessage(stream, JsonEncode(message), yc); +} + + /** + * Sends a raw message to the connected peer. + * + * @param stream ASIO TLS Stream + * @param json message + * @param yc Yield context required for ASIO + * + * @return bytes sent + */ +size_t JsonRpc::SendRawMessage(const Shared<AsioTlsStream>::Ptr& stream, const String& json, boost::asio::yield_context yc) +{ +#ifdef I2_DEBUG + if (GetDebugJsonRpcCached()) + std::cerr << ConsoleColorTag(Console_ForegroundBlue) << ">> " << json << ConsoleColorTag(Console_Normal) << "\n"; +#endif /* I2_DEBUG */ + + return NetString::WriteStringToStream(stream, json, yc); +} + +/** + * Reads a message from the connected peer. + * + * @param stream ASIO TLS Stream + * @param maxMessageLength maximum size of bytes read. + * + * @return A JSON string + */ + +String JsonRpc::ReadMessage(const Shared<AsioTlsStream>::Ptr& stream, ssize_t maxMessageLength) +{ + String jsonString = NetString::ReadStringFromStream(stream, maxMessageLength); + +#ifdef I2_DEBUG + if (GetDebugJsonRpcCached()) + std::cerr << ConsoleColorTag(Console_ForegroundBlue) << "<< " << jsonString << ConsoleColorTag(Console_Normal) << "\n"; +#endif /* I2_DEBUG */ + + return std::move(jsonString); +} + +/** + * Reads a message from the connected peer. + * + * @param stream ASIO TLS Stream + * @param yc Yield Context for ASIO + * @param maxMessageLength maximum size of bytes read. + * + * @return A JSON string + */ +String JsonRpc::ReadMessage(const Shared<AsioTlsStream>::Ptr& stream, boost::asio::yield_context yc, ssize_t maxMessageLength) +{ + String jsonString = NetString::ReadStringFromStream(stream, yc, maxMessageLength); + +#ifdef I2_DEBUG + if (GetDebugJsonRpcCached()) + std::cerr << ConsoleColorTag(Console_ForegroundBlue) << "<< " << jsonString << ConsoleColorTag(Console_Normal) << "\n"; +#endif /* I2_DEBUG */ + + return std::move(jsonString); +} + +/** + * Decode message, enforce a Dictionary + * + * @param message JSON string + * + * @return Dictionary ptr + */ +Dictionary::Ptr JsonRpc::DecodeMessage(const String& message) +{ + Value value = JsonDecode(message); + + if (!value.IsObjectType<Dictionary>()) { + BOOST_THROW_EXCEPTION(std::invalid_argument("JSON-RPC" + " message must be a dictionary.")); + } + + return value; +} diff --git a/lib/remote/jsonrpc.hpp b/lib/remote/jsonrpc.hpp new file mode 100644 index 0000000..3f3cdec --- /dev/null +++ b/lib/remote/jsonrpc.hpp @@ -0,0 +1,39 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef JSONRPC_H +#define JSONRPC_H + +#include "base/stream.hpp" +#include "base/dictionary.hpp" +#include "base/tlsstream.hpp" +#include "remote/i2-remote.hpp" +#include <memory> +#include <boost/asio/spawn.hpp> + +namespace icinga +{ + +/** + * A JSON-RPC connection. + * + * @ingroup remote + */ +class JsonRpc +{ +public: + static size_t SendMessage(const Shared<AsioTlsStream>::Ptr& stream, const Dictionary::Ptr& message); + static size_t SendMessage(const Shared<AsioTlsStream>::Ptr& stream, const Dictionary::Ptr& message, boost::asio::yield_context yc); + static size_t SendRawMessage(const Shared<AsioTlsStream>::Ptr& stream, const String& json, boost::asio::yield_context yc); + + static String ReadMessage(const Shared<AsioTlsStream>::Ptr& stream, ssize_t maxMessageLength = -1); + static String ReadMessage(const Shared<AsioTlsStream>::Ptr& stream, boost::asio::yield_context yc, ssize_t maxMessageLength = -1); + + static Dictionary::Ptr DecodeMessage(const String& message); + +private: + JsonRpc(); +}; + +} + +#endif /* JSONRPC_H */ diff --git a/lib/remote/jsonrpcconnection-heartbeat.cpp b/lib/remote/jsonrpcconnection-heartbeat.cpp new file mode 100644 index 0000000..2474688 --- /dev/null +++ b/lib/remote/jsonrpcconnection-heartbeat.cpp @@ -0,0 +1,48 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/jsonrpcconnection.hpp" +#include "remote/messageorigin.hpp" +#include "remote/apifunction.hpp" +#include "base/initialize.hpp" +#include "base/configtype.hpp" +#include "base/logger.hpp" +#include "base/utility.hpp" +#include <boost/asio/spawn.hpp> +#include <boost/date_time/posix_time/posix_time_duration.hpp> +#include <boost/system/system_error.hpp> + +using namespace icinga; + +REGISTER_APIFUNCTION(Heartbeat, event, &JsonRpcConnection::HeartbeatAPIHandler); + +/** + * We still send a heartbeat without timeout here + * to keep the m_Seen variable up to date. This is to keep the + * cluster connection alive when there isn't much going on. + */ + +void JsonRpcConnection::HandleAndWriteHeartbeats(boost::asio::yield_context yc) +{ + boost::system::error_code ec; + + for (;;) { + m_HeartbeatTimer.expires_from_now(boost::posix_time::seconds(20)); + m_HeartbeatTimer.async_wait(yc[ec]); + + if (m_ShuttingDown) { + break; + } + + SendMessageInternal(new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "event::Heartbeat" }, + { "params", new Dictionary() } + })); + } +} + +Value JsonRpcConnection::HeartbeatAPIHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + return Empty; +} + diff --git a/lib/remote/jsonrpcconnection-pki.cpp b/lib/remote/jsonrpcconnection-pki.cpp new file mode 100644 index 0000000..d661dcf --- /dev/null +++ b/lib/remote/jsonrpcconnection-pki.cpp @@ -0,0 +1,402 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/jsonrpcconnection.hpp" +#include "remote/apilistener.hpp" +#include "remote/apifunction.hpp" +#include "remote/jsonrpc.hpp" +#include "base/configtype.hpp" +#include "base/objectlock.hpp" +#include "base/utility.hpp" +#include "base/logger.hpp" +#include "base/exception.hpp" +#include "base/convert.hpp" +#include <boost/thread/once.hpp> +#include <boost/regex.hpp> +#include <fstream> +#include <openssl/ssl.h> +#include <openssl/x509.h> + +using namespace icinga; + +static Value RequestCertificateHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); +REGISTER_APIFUNCTION(RequestCertificate, pki, &RequestCertificateHandler); +static Value UpdateCertificateHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); +REGISTER_APIFUNCTION(UpdateCertificate, pki, &UpdateCertificateHandler); + +Value RequestCertificateHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + String certText = params->Get("cert_request"); + + std::shared_ptr<X509> cert; + + Dictionary::Ptr result = new Dictionary(); + + /* Use the presented client certificate if not provided. */ + if (certText.IsEmpty()) { + auto stream (origin->FromClient->GetStream()); + cert = stream->next_layer().GetPeerCertificate(); + } else { + cert = StringToCertificate(certText); + } + + if (!cert) { + Log(LogWarning, "JsonRpcConnection") << "No certificate or CSR received"; + + result->Set("status_code", 1); + result->Set("error", "No certificate or CSR received."); + + return result; + } + + ApiListener::Ptr listener = ApiListener::GetInstance(); + std::shared_ptr<X509> cacert = GetX509Certificate(listener->GetDefaultCaPath()); + + String cn = GetCertificateCN(cert); + + bool signedByCA = false; + + { + Log logmsg(LogInformation, "JsonRpcConnection"); + logmsg << "Received certificate request for CN '" << cn << "'"; + + try { + signedByCA = VerifyCertificate(cacert, cert, listener->GetCrlPath()); + if (!signedByCA) { + logmsg << " not"; + } + logmsg << " signed by our CA."; + } catch (const std::exception &ex) { + logmsg << " not signed by our CA"; + if (const unsigned long *openssl_code = boost::get_error_info<errinfo_openssl_error>(ex)) { + logmsg << ": " << X509_verify_cert_error_string(long(*openssl_code)) << " (code " << *openssl_code << ")"; + } else { + logmsg << "."; + } + } + } + + if (signedByCA) { + if (IsCertUptodate(cert)) { + + Log(LogInformation, "JsonRpcConnection") + << "The certificate for CN '" << cn << "' is valid and uptodate. Skipping automated renewal."; + result->Set("status_code", 1); + result->Set("error", "The certificate for CN '" + cn + "' is valid and uptodate. Skipping automated renewal."); + return result; + } + } + + unsigned int n; + unsigned char digest[EVP_MAX_MD_SIZE]; + + if (!X509_digest(cert.get(), EVP_sha256(), digest, &n)) { + result->Set("status_code", 1); + result->Set("error", "Could not calculate fingerprint for the X509 certificate for CN '" + cn + "'."); + + Log(LogWarning, "JsonRpcConnection") + << "Could not calculate fingerprint for the X509 certificate requested for CN '" + << cn << "'."; + + return result; + } + + char certFingerprint[EVP_MAX_MD_SIZE*2+1]; + for (unsigned int i = 0; i < n; i++) + sprintf(certFingerprint + 2 * i, "%02x", digest[i]); + + result->Set("fingerprint_request", certFingerprint); + + String requestDir = ApiListener::GetCertificateRequestsDir(); + String requestPath = requestDir + "/" + certFingerprint + ".json"; + + result->Set("ca", CertificateToString(cacert)); + + JsonRpcConnection::Ptr client = origin->FromClient; + + /* If we already have a signed certificate request, send it to the client. */ + if (Utility::PathExists(requestPath)) { + Dictionary::Ptr request = Utility::LoadJsonFile(requestPath); + + String certResponse = request->Get("cert_response"); + + if (!certResponse.IsEmpty()) { + Log(LogInformation, "JsonRpcConnection") + << "Sending certificate response for CN '" << cn + << "' to endpoint '" << client->GetIdentity() << "'."; + + result->Set("cert", certResponse); + result->Set("status_code", 0); + + Dictionary::Ptr message = new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "pki::UpdateCertificate" }, + { "params", result } + }); + client->SendMessage(message); + + return result; + } + } else if (Utility::PathExists(requestDir + "/" + certFingerprint + ".removed")) { + Log(LogInformation, "JsonRpcConnection") + << "Certificate for CN " << cn << " has been removed. Ignoring signing request."; + result->Set("status_code", 1); + result->Set("error", "Ticket for CN " + cn + " declined by administrator."); + return result; + } + + std::shared_ptr<X509> newcert; + Dictionary::Ptr message; + String ticket; + + /* Check whether we are a signing instance or we + * must delay the signing request. + */ + if (!Utility::PathExists(GetIcingaCADir() + "/ca.key")) + goto delayed_request; + + if (!signedByCA) { + String salt = listener->GetTicketSalt(); + + ticket = params->Get("ticket"); + + // Auto-signing is disabled: Client did not include a ticket in its request. + if (ticket.IsEmpty()) { + Log(LogNotice, "JsonRpcConnection") + << "Certificate request for CN '" << cn + << "': No ticket included, skipping auto-signing and waiting for on-demand signing approval."; + + goto delayed_request; + } + + // Auto-signing is disabled: no TicketSalt + if (salt.IsEmpty()) { + Log(LogNotice, "JsonRpcConnection") + << "Certificate request for CN '" << cn + << "': This instance is the signing master for the Icinga CA." + << " The 'ticket_salt' attribute in the 'api' feature is not set." + << " Not signing the request. Please check the docs."; + + goto delayed_request; + } + + String realTicket = PBKDF2_SHA1(cn, salt, 50000); + + Log(LogDebug, "JsonRpcConnection") + << "Certificate request for CN '" << cn << "': Comparing received ticket '" + << ticket << "' with calculated ticket '" << realTicket << "'."; + + if (!Utility::ComparePasswords(ticket, realTicket)) { + Log(LogWarning, "JsonRpcConnection") + << "Ticket '" << ticket << "' for CN '" << cn << "' is invalid."; + + result->Set("status_code", 1); + result->Set("error", "Invalid ticket for CN '" + cn + "'."); + return result; + } + } + + newcert = listener->RenewCert(cert); + + if (!newcert) { + goto delayed_request; + } + + /* Send the signed certificate update. */ + Log(LogInformation, "JsonRpcConnection") + << "Sending certificate response for CN '" << cn << "' to endpoint '" + << client->GetIdentity() << "'" << (!ticket.IsEmpty() ? " (auto-signing ticket)" : "" ) << "."; + + result->Set("cert", CertificateToString(newcert)); + + result->Set("status_code", 0); + + message = new Dictionary({ + { "jsonrpc", "2.0" }, + { "method", "pki::UpdateCertificate" }, + { "params", result } + }); + client->SendMessage(message); + + return result; + +delayed_request: + /* Send a delayed certificate signing request. */ + Utility::MkDirP(requestDir, 0700); + + Dictionary::Ptr request = new Dictionary({ + { "cert_request", CertificateToString(cert) }, + { "ticket", params->Get("ticket") } + }); + + Utility::SaveJsonFile(requestPath, 0600, request); + + JsonRpcConnection::SendCertificateRequest(nullptr, origin, requestPath); + + result->Set("status_code", 2); + result->Set("error", "Certificate request for CN '" + cn + "' is pending. Waiting for approval from the parent Icinga instance."); + + Log(LogInformation, "JsonRpcConnection") + << "Certificate request for CN '" << cn << "' is pending. Waiting for approval."; + + if (origin) { + auto client (origin->FromClient); + + if (client && !client->GetEndpoint()) { + client->Disconnect(); + } + } + + return result; +} + +void JsonRpcConnection::SendCertificateRequest(const JsonRpcConnection::Ptr& aclient, const MessageOrigin::Ptr& origin, const String& path) +{ + Dictionary::Ptr message = new Dictionary(); + message->Set("jsonrpc", "2.0"); + message->Set("method", "pki::RequestCertificate"); + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) + return; + + Dictionary::Ptr params = new Dictionary(); + message->Set("params", params); + + /* Path is empty if this is our own request. */ + if (path.IsEmpty()) { + { + Log msg (LogInformation, "JsonRpcConnection"); + msg << "Requesting new certificate for this Icinga instance"; + + if (aclient) { + msg << " from endpoint '" << aclient->GetIdentity() << "'"; + } + + msg << "."; + } + + String ticketPath = ApiListener::GetCertsDir() + "/ticket"; + + std::ifstream fp(ticketPath.CStr()); + String ticket((std::istreambuf_iterator<char>(fp)), std::istreambuf_iterator<char>()); + fp.close(); + + params->Set("ticket", ticket); + } else { + Dictionary::Ptr request = Utility::LoadJsonFile(path); + + if (request->Contains("cert_response")) + return; + + params->Set("cert_request", request->Get("cert_request")); + params->Set("ticket", request->Get("ticket")); + } + + /* Send the request to a) the connected client + * or b) the local zone and all parents. + */ + if (aclient) + aclient->SendMessage(message); + else + listener->RelayMessage(origin, Zone::GetLocalZone(), message, false); +} + +Value UpdateCertificateHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + if (origin->FromZone && !Zone::GetLocalZone()->IsChildOf(origin->FromZone)) { + Log(LogWarning, "ClusterEvents") + << "Discarding 'update certificate' message from '" << origin->FromClient->GetIdentity() << "': Invalid endpoint origin (client not allowed)."; + + return Empty; + } + + String ca = params->Get("ca"); + String cert = params->Get("cert"); + + ApiListener::Ptr listener = ApiListener::GetInstance(); + + if (!listener) + return Empty; + + std::shared_ptr<X509> oldCert = GetX509Certificate(listener->GetDefaultCertPath()); + std::shared_ptr<X509> newCert = StringToCertificate(cert); + + String cn = GetCertificateCN(newCert); + + Log(LogInformation, "JsonRpcConnection") + << "Received certificate update message for CN '" << cn << "'"; + + /* Check if this is a certificate update for a subordinate instance. */ + std::shared_ptr<EVP_PKEY> oldKey = std::shared_ptr<EVP_PKEY>(X509_get_pubkey(oldCert.get()), EVP_PKEY_free); + std::shared_ptr<EVP_PKEY> newKey = std::shared_ptr<EVP_PKEY>(X509_get_pubkey(newCert.get()), EVP_PKEY_free); + + if (X509_NAME_cmp(X509_get_subject_name(oldCert.get()), X509_get_subject_name(newCert.get())) != 0 || + EVP_PKEY_cmp(oldKey.get(), newKey.get()) != 1) { + String certFingerprint = params->Get("fingerprint_request"); + + /* Validate the fingerprint format. */ + boost::regex expr("^[0-9a-f]+$"); + + if (!boost::regex_match(certFingerprint.GetData(), expr)) { + Log(LogWarning, "JsonRpcConnection") + << "Endpoint '" << origin->FromClient->GetIdentity() << "' sent an invalid certificate fingerprint: '" + << certFingerprint << "' for CN '" << cn << "'."; + return Empty; + } + + String requestDir = ApiListener::GetCertificateRequestsDir(); + String requestPath = requestDir + "/" + certFingerprint + ".json"; + + /* Save the received signed certificate request to disk. */ + if (Utility::PathExists(requestPath)) { + Log(LogInformation, "JsonRpcConnection") + << "Saved certificate update for CN '" << cn << "'"; + + Dictionary::Ptr request = Utility::LoadJsonFile(requestPath); + request->Set("cert_response", cert); + Utility::SaveJsonFile(requestPath, 0644, request); + } + + return Empty; + } + + /* Update CA certificate. */ + String caPath = listener->GetDefaultCaPath(); + + Log(LogInformation, "JsonRpcConnection") + << "Updating CA certificate in '" << caPath << "'."; + + std::fstream cafp; + String tempCaPath = Utility::CreateTempFile(caPath + ".XXXXXX", 0644, cafp); + cafp << ca; + cafp.close(); + + Utility::RenameFile(tempCaPath, caPath); + + /* Update signed certificate. */ + String certPath = listener->GetDefaultCertPath(); + + Log(LogInformation, "JsonRpcConnection") + << "Updating client certificate for CN '" << cn << "' in '" << certPath << "'."; + + std::fstream certfp; + String tempCertPath = Utility::CreateTempFile(certPath + ".XXXXXX", 0644, certfp); + certfp << cert; + certfp.close(); + + Utility::RenameFile(tempCertPath, certPath); + + /* Remove ticket for successful signing request. */ + String ticketPath = ApiListener::GetCertsDir() + "/ticket"; + + Utility::Remove(ticketPath); + + /* Update the certificates at runtime and reconnect all endpoints. */ + Log(LogInformation, "JsonRpcConnection") + << "Updating the client certificate for CN '" << cn << "' at runtime and reconnecting the endpoints."; + + listener->UpdateSSLContext(); + + return Empty; +} diff --git a/lib/remote/jsonrpcconnection.cpp b/lib/remote/jsonrpcconnection.cpp new file mode 100644 index 0000000..3bae3ca --- /dev/null +++ b/lib/remote/jsonrpcconnection.cpp @@ -0,0 +1,388 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/jsonrpcconnection.hpp" +#include "remote/apilistener.hpp" +#include "remote/apifunction.hpp" +#include "remote/jsonrpc.hpp" +#include "base/defer.hpp" +#include "base/configtype.hpp" +#include "base/io-engine.hpp" +#include "base/json.hpp" +#include "base/objectlock.hpp" +#include "base/utility.hpp" +#include "base/logger.hpp" +#include "base/exception.hpp" +#include "base/convert.hpp" +#include "base/tlsstream.hpp" +#include <memory> +#include <utility> +#include <boost/asio/io_context.hpp> +#include <boost/asio/spawn.hpp> +#include <boost/date_time/posix_time/posix_time_duration.hpp> +#include <boost/system/system_error.hpp> +#include <boost/thread/once.hpp> + +using namespace icinga; + +static Value SetLogPositionHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params); +REGISTER_APIFUNCTION(SetLogPosition, log, &SetLogPositionHandler); + +static RingBuffer l_TaskStats (15 * 60); + +JsonRpcConnection::JsonRpcConnection(const String& identity, bool authenticated, + const Shared<AsioTlsStream>::Ptr& stream, ConnectionRole role) + : JsonRpcConnection(identity, authenticated, stream, role, IoEngine::Get().GetIoContext()) +{ +} + +JsonRpcConnection::JsonRpcConnection(const String& identity, bool authenticated, + const Shared<AsioTlsStream>::Ptr& stream, ConnectionRole role, boost::asio::io_context& io) + : m_Identity(identity), m_Authenticated(authenticated), m_Stream(stream), m_Role(role), + m_Timestamp(Utility::GetTime()), m_Seen(Utility::GetTime()), m_NextHeartbeat(0), m_IoStrand(io), + m_OutgoingMessagesQueued(io), m_WriterDone(io), m_ShuttingDown(false), + m_CheckLivenessTimer(io), m_HeartbeatTimer(io) +{ + if (authenticated) + m_Endpoint = Endpoint::GetByName(identity); +} + +void JsonRpcConnection::Start() +{ + namespace asio = boost::asio; + + JsonRpcConnection::Ptr keepAlive (this); + + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { HandleIncomingMessages(yc); }); + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { WriteOutgoingMessages(yc); }); + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { HandleAndWriteHeartbeats(yc); }); + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { CheckLiveness(yc); }); +} + +void JsonRpcConnection::HandleIncomingMessages(boost::asio::yield_context yc) +{ + m_Stream->next_layer().SetSeen(&m_Seen); + + for (;;) { + String message; + + try { + message = JsonRpc::ReadMessage(m_Stream, yc, m_Endpoint ? -1 : 1024 * 1024); + } catch (const std::exception& ex) { + Log(m_ShuttingDown ? LogDebug : LogNotice, "JsonRpcConnection") + << "Error while reading JSON-RPC message for identity '" << m_Identity + << "': " << DiagnosticInformation(ex); + + break; + } + + m_Seen = Utility::GetTime(); + + try { + CpuBoundWork handleMessage (yc); + + MessageHandler(message); + } catch (const std::exception& ex) { + Log(m_ShuttingDown ? LogDebug : LogWarning, "JsonRpcConnection") + << "Error while processing JSON-RPC message for identity '" << m_Identity + << "': " << DiagnosticInformation(ex); + + break; + } + + CpuBoundWork taskStats (yc); + + l_TaskStats.InsertValue(Utility::GetTime(), 1); + } + + Disconnect(); +} + +void JsonRpcConnection::WriteOutgoingMessages(boost::asio::yield_context yc) +{ + Defer signalWriterDone ([this]() { m_WriterDone.Set(); }); + + do { + m_OutgoingMessagesQueued.Wait(yc); + + auto queue (std::move(m_OutgoingMessagesQueue)); + + m_OutgoingMessagesQueue.clear(); + m_OutgoingMessagesQueued.Clear(); + + if (!queue.empty()) { + try { + for (auto& message : queue) { + size_t bytesSent = JsonRpc::SendRawMessage(m_Stream, message, yc); + + if (m_Endpoint) { + m_Endpoint->AddMessageSent(bytesSent); + } + } + + m_Stream->async_flush(yc); + } catch (const std::exception& ex) { + Log(m_ShuttingDown ? LogDebug : LogWarning, "JsonRpcConnection") + << "Error while sending JSON-RPC message for identity '" + << m_Identity << "'\n" << DiagnosticInformation(ex); + + break; + } + } + } while (!m_ShuttingDown); + + Disconnect(); +} + +double JsonRpcConnection::GetTimestamp() const +{ + return m_Timestamp; +} + +String JsonRpcConnection::GetIdentity() const +{ + return m_Identity; +} + +bool JsonRpcConnection::IsAuthenticated() const +{ + return m_Authenticated; +} + +Endpoint::Ptr JsonRpcConnection::GetEndpoint() const +{ + return m_Endpoint; +} + +Shared<AsioTlsStream>::Ptr JsonRpcConnection::GetStream() const +{ + return m_Stream; +} + +ConnectionRole JsonRpcConnection::GetRole() const +{ + return m_Role; +} + +void JsonRpcConnection::SendMessage(const Dictionary::Ptr& message) +{ + Ptr keepAlive (this); + + m_IoStrand.post([this, keepAlive, message]() { SendMessageInternal(message); }); +} + +void JsonRpcConnection::SendRawMessage(const String& message) +{ + Ptr keepAlive (this); + + m_IoStrand.post([this, keepAlive, message]() { + m_OutgoingMessagesQueue.emplace_back(message); + m_OutgoingMessagesQueued.Set(); + }); +} + +void JsonRpcConnection::SendMessageInternal(const Dictionary::Ptr& message) +{ + m_OutgoingMessagesQueue.emplace_back(JsonEncode(message)); + m_OutgoingMessagesQueued.Set(); +} + +void JsonRpcConnection::Disconnect() +{ + namespace asio = boost::asio; + + JsonRpcConnection::Ptr keepAlive (this); + + IoEngine::SpawnCoroutine(m_IoStrand, [this, keepAlive](asio::yield_context yc) { + if (!m_ShuttingDown) { + m_ShuttingDown = true; + + Log(LogWarning, "JsonRpcConnection") + << "API client disconnected for identity '" << m_Identity << "'"; + + { + CpuBoundWork removeClient (yc); + + if (m_Endpoint) { + m_Endpoint->RemoveClient(this); + } else { + ApiListener::GetInstance()->RemoveAnonymousClient(this); + } + } + + m_OutgoingMessagesQueued.Set(); + + m_WriterDone.Wait(yc); + + /* + * Do not swallow exceptions in a coroutine. + * https://github.com/Icinga/icinga2/issues/7351 + * We must not catch `detail::forced_unwind exception` as + * this is used for unwinding the stack. + * + * Just use the error_code dummy here. + */ + boost::system::error_code ec; + + m_CheckLivenessTimer.cancel(); + m_HeartbeatTimer.cancel(); + + m_Stream->lowest_layer().cancel(ec); + + Timeout::Ptr shutdownTimeout (new Timeout( + m_IoStrand.context(), + m_IoStrand, + boost::posix_time::seconds(10), + [this, keepAlive](asio::yield_context yc) { + boost::system::error_code ec; + m_Stream->lowest_layer().cancel(ec); + } + )); + + m_Stream->next_layer().async_shutdown(yc[ec]); + + shutdownTimeout->Cancel(); + + m_Stream->lowest_layer().shutdown(m_Stream->lowest_layer().shutdown_both, ec); + } + }); +} + +void JsonRpcConnection::MessageHandler(const String& jsonString) +{ + Dictionary::Ptr message = JsonRpc::DecodeMessage(jsonString); + + if (m_Endpoint && message->Contains("ts")) { + double ts = message->Get("ts"); + + /* ignore old messages */ + if (ts < m_Endpoint->GetRemoteLogPosition()) + return; + + m_Endpoint->SetRemoteLogPosition(ts); + } + + MessageOrigin::Ptr origin = new MessageOrigin(); + origin->FromClient = this; + + if (m_Endpoint) { + if (m_Endpoint->GetZone() != Zone::GetLocalZone()) + origin->FromZone = m_Endpoint->GetZone(); + else + origin->FromZone = Zone::GetByName(message->Get("originZone")); + + m_Endpoint->AddMessageReceived(jsonString.GetLength()); + } + + Value vmethod; + + if (!message->Get("method", &vmethod)) { + Value vid; + + if (!message->Get("id", &vid)) + return; + + Log(LogWarning, "JsonRpcConnection", + "We received a JSON-RPC response message. This should never happen because we're only ever sending notifications."); + + return; + } + + String method = vmethod; + + Log(LogNotice, "JsonRpcConnection") + << "Received '" << method << "' message from identity '" << m_Identity << "'."; + + Dictionary::Ptr resultMessage = new Dictionary(); + + try { + ApiFunction::Ptr afunc = ApiFunction::GetByName(method); + + if (!afunc) { + Log(LogNotice, "JsonRpcConnection") + << "Call to non-existent function '" << method << "' from endpoint '" << m_Identity << "'."; + } else { + Dictionary::Ptr params = message->Get("params"); + if (params) + resultMessage->Set("result", afunc->Invoke(origin, params)); + else + resultMessage->Set("result", Empty); + } + } catch (const std::exception& ex) { + /* TODO: Add a user readable error message for the remote caller */ + String diagInfo = DiagnosticInformation(ex); + resultMessage->Set("error", diagInfo); + Log(LogWarning, "JsonRpcConnection") + << "Error while processing message for identity '" << m_Identity << "'\n" << diagInfo; + } + + if (message->Contains("id")) { + resultMessage->Set("jsonrpc", "2.0"); + resultMessage->Set("id", message->Get("id")); + + SendMessageInternal(resultMessage); + } +} + +Value SetLogPositionHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) +{ + double log_position = params->Get("log_position"); + Endpoint::Ptr endpoint = origin->FromClient->GetEndpoint(); + + if (!endpoint) + return Empty; + + if (log_position > endpoint->GetLocalLogPosition()) + endpoint->SetLocalLogPosition(log_position); + + return Empty; +} + +void JsonRpcConnection::CheckLiveness(boost::asio::yield_context yc) +{ + boost::system::error_code ec; + + if (!m_Authenticated) { + /* Anonymous connections are normally only used for requesting a certificate and are closed after this request + * is received. However, the request is only sent if the child has successfully verified the certificate of its + * parent so that it is an authenticated connection from its perspective. In case this verification fails, both + * ends view it as an anonymous connection and never actually use it but attempt a reconnect after 10 seconds + * leaking the connection. Therefore close it after a timeout. + */ + + m_CheckLivenessTimer.expires_from_now(boost::posix_time::seconds(10)); + m_CheckLivenessTimer.async_wait(yc[ec]); + + if (m_ShuttingDown) { + return; + } + + auto remote (m_Stream->lowest_layer().remote_endpoint()); + + Log(LogInformation, "JsonRpcConnection") + << "Closing anonymous connection [" << remote.address() << "]:" << remote.port() << " after 10 seconds."; + + Disconnect(); + } else { + for (;;) { + m_CheckLivenessTimer.expires_from_now(boost::posix_time::seconds(30)); + m_CheckLivenessTimer.async_wait(yc[ec]); + + if (m_ShuttingDown) { + break; + } + + if (m_Seen < Utility::GetTime() - 60 && (!m_Endpoint || !m_Endpoint->GetSyncing())) { + Log(LogInformation, "JsonRpcConnection") + << "No messages for identity '" << m_Identity << "' have been received in the last 60 seconds."; + + Disconnect(); + break; + } + } + } +} + +double JsonRpcConnection::GetWorkQueueRate() +{ + return l_TaskStats.UpdateAndGetValues(Utility::GetTime(), 60) / 60.0; +} diff --git a/lib/remote/jsonrpcconnection.hpp b/lib/remote/jsonrpcconnection.hpp new file mode 100644 index 0000000..591ddcb --- /dev/null +++ b/lib/remote/jsonrpcconnection.hpp @@ -0,0 +1,100 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef JSONRPCCONNECTION_H +#define JSONRPCCONNECTION_H + +#include "remote/i2-remote.hpp" +#include "remote/endpoint.hpp" +#include "base/io-engine.hpp" +#include "base/tlsstream.hpp" +#include "base/timer.hpp" +#include "base/workqueue.hpp" +#include <memory> +#include <vector> +#include <boost/asio/io_context.hpp> +#include <boost/asio/io_context_strand.hpp> +#include <boost/asio/spawn.hpp> + +namespace icinga +{ + +enum ClientRole +{ + ClientInbound, + ClientOutbound +}; + +enum ClientType +{ + ClientJsonRpc, + ClientHttp +}; + +class MessageOrigin; + +/** + * An API client connection. + * + * @ingroup remote + */ +class JsonRpcConnection final : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(JsonRpcConnection); + + JsonRpcConnection(const String& identity, bool authenticated, const Shared<AsioTlsStream>::Ptr& stream, ConnectionRole role); + + void Start(); + + double GetTimestamp() const; + String GetIdentity() const; + bool IsAuthenticated() const; + Endpoint::Ptr GetEndpoint() const; + Shared<AsioTlsStream>::Ptr GetStream() const; + ConnectionRole GetRole() const; + + void Disconnect(); + + void SendMessage(const Dictionary::Ptr& request); + void SendRawMessage(const String& request); + + static Value HeartbeatAPIHandler(const intrusive_ptr<MessageOrigin>& origin, const Dictionary::Ptr& params); + + static double GetWorkQueueRate(); + + static void SendCertificateRequest(const JsonRpcConnection::Ptr& aclient, const intrusive_ptr<MessageOrigin>& origin, const String& path); + +private: + String m_Identity; + bool m_Authenticated; + Endpoint::Ptr m_Endpoint; + Shared<AsioTlsStream>::Ptr m_Stream; + ConnectionRole m_Role; + double m_Timestamp; + double m_Seen; + double m_NextHeartbeat; + boost::asio::io_context::strand m_IoStrand; + std::vector<String> m_OutgoingMessagesQueue; + AsioConditionVariable m_OutgoingMessagesQueued; + AsioConditionVariable m_WriterDone; + bool m_ShuttingDown; + boost::asio::deadline_timer m_CheckLivenessTimer, m_HeartbeatTimer; + + JsonRpcConnection(const String& identity, bool authenticated, const Shared<AsioTlsStream>::Ptr& stream, ConnectionRole role, boost::asio::io_context& io); + + void HandleIncomingMessages(boost::asio::yield_context yc); + void WriteOutgoingMessages(boost::asio::yield_context yc); + void HandleAndWriteHeartbeats(boost::asio::yield_context yc); + void CheckLiveness(boost::asio::yield_context yc); + + bool ProcessMessage(); + void MessageHandler(const String& jsonString); + + void CertificateRequestResponseHandler(const Dictionary::Ptr& message); + + void SendMessageInternal(const Dictionary::Ptr& request); +}; + +} + +#endif /* JSONRPCCONNECTION_H */ diff --git a/lib/remote/messageorigin.cpp b/lib/remote/messageorigin.cpp new file mode 100644 index 0000000..7de0ca7 --- /dev/null +++ b/lib/remote/messageorigin.cpp @@ -0,0 +1,10 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/messageorigin.hpp" + +using namespace icinga; + +bool MessageOrigin::IsLocal() const +{ + return !FromClient; +} diff --git a/lib/remote/messageorigin.hpp b/lib/remote/messageorigin.hpp new file mode 100644 index 0000000..8a91ecc --- /dev/null +++ b/lib/remote/messageorigin.hpp @@ -0,0 +1,28 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef MESSAGEORIGIN_H +#define MESSAGEORIGIN_H + +#include "remote/zone.hpp" +#include "remote/jsonrpcconnection.hpp" + +namespace icinga +{ + +/** + * @ingroup remote + */ +class MessageOrigin final : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(MessageOrigin); + + Zone::Ptr FromZone; + JsonRpcConnection::Ptr FromClient; + + bool IsLocal() const; +}; + +} + +#endif /* MESSAGEORIGIN_H */ diff --git a/lib/remote/modifyobjecthandler.cpp b/lib/remote/modifyobjecthandler.cpp new file mode 100644 index 0000000..cc008b9 --- /dev/null +++ b/lib/remote/modifyobjecthandler.cpp @@ -0,0 +1,120 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/modifyobjecthandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "remote/apiaction.hpp" +#include "base/exception.hpp" +#include <boost/algorithm/string/case_conv.hpp> +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/objects", ModifyObjectHandler); + +bool ModifyObjectHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() < 3 || url->GetPath().size() > 4) + return false; + + if (request.method() != http::verb::post) + return false; + + Type::Ptr type = FilterUtility::TypeFromPluralName(url->GetPath()[2]); + + if (!type) { + HttpUtility::SendJsonError(response, params, 400, "Invalid type specified."); + return true; + } + + QueryDescription qd; + qd.Types.insert(type->GetName()); + qd.Permission = "objects/modify/" + type->GetName(); + + params->Set("type", type->GetName()); + + if (url->GetPath().size() >= 4) { + String attr = type->GetName(); + boost::algorithm::to_lower(attr); + params->Set(attr, url->GetPath()[3]); + } + + std::vector<Value> objs; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No objects found.", + DiagnosticInformation(ex)); + return true; + } + + Value attrsVal = params->Get("attrs"); + + if (attrsVal.GetReflectionType() != Dictionary::TypeInstance) { + HttpUtility::SendJsonError(response, params, 400, + "Invalid type for 'attrs' attribute specified. Dictionary type is required." + "Or is this a POST query and you missed adding a 'X-HTTP-Method-Override: GET' header?"); + return true; + } + + Dictionary::Ptr attrs = attrsVal; + + bool verbose = false; + + if (params) + verbose = HttpUtility::GetLastParameter(params, "verbose"); + + ArrayData results; + + for (const ConfigObject::Ptr& obj : objs) { + Dictionary::Ptr result1 = new Dictionary(); + + result1->Set("type", type->GetName()); + result1->Set("name", obj->GetName()); + + String key; + + try { + if (attrs) { + ObjectLock olock(attrs); + for (const Dictionary::Pair& kv : attrs) { + key = kv.first; + obj->ModifyAttribute(kv.first, kv.second); + } + } + + result1->Set("code", 200); + result1->Set("status", "Attributes updated."); + } catch (const std::exception& ex) { + result1->Set("code", 500); + result1->Set("status", "Attribute '" + key + "' could not be set: " + DiagnosticInformation(ex, false)); + + if (verbose) + result1->Set("diagnostic_information", DiagnosticInformation(ex)); + } + + results.push_back(std::move(result1)); + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} diff --git a/lib/remote/modifyobjecthandler.hpp b/lib/remote/modifyobjecthandler.hpp new file mode 100644 index 0000000..f469301 --- /dev/null +++ b/lib/remote/modifyobjecthandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef MODIFYOBJECTHANDLER_H +#define MODIFYOBJECTHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class ModifyObjectHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(ModifyObjectHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* MODIFYOBJECTHANDLER_H */ diff --git a/lib/remote/objectqueryhandler.cpp b/lib/remote/objectqueryhandler.cpp new file mode 100644 index 0000000..f059c03 --- /dev/null +++ b/lib/remote/objectqueryhandler.cpp @@ -0,0 +1,332 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/objectqueryhandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "base/serializer.hpp" +#include "base/dependencygraph.hpp" +#include "base/configtype.hpp" +#include <boost/algorithm/string/case_conv.hpp> +#include <set> +#include <unordered_map> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/objects", ObjectQueryHandler); + +Dictionary::Ptr ObjectQueryHandler::SerializeObjectAttrs(const Object::Ptr& object, + const String& attrPrefix, const Array::Ptr& attrs, bool isJoin, bool allAttrs) +{ + Type::Ptr type = object->GetReflectionType(); + + std::vector<int> fids; + + if (isJoin && attrs) { + ObjectLock olock(attrs); + for (const String& attr : attrs) { + if (attr == attrPrefix) { + allAttrs = true; + break; + } + } + } + + if (!isJoin && (!attrs || attrs->GetLength() == 0)) + allAttrs = true; + + if (allAttrs) { + for (int fid = 0; fid < type->GetFieldCount(); fid++) { + fids.push_back(fid); + } + } else if (attrs) { + ObjectLock olock(attrs); + for (const String& attr : attrs) { + String userAttr; + + if (isJoin) { + String::SizeType dpos = attr.FindFirstOf("."); + if (dpos == String::NPos) + continue; + + String userJoinAttr = attr.SubStr(0, dpos); + if (userJoinAttr != attrPrefix) + continue; + + userAttr = attr.SubStr(dpos + 1); + } else + userAttr = attr; + + int fid = type->GetFieldId(userAttr); + + if (fid < 0) + BOOST_THROW_EXCEPTION(ScriptError("Invalid field specified: " + userAttr)); + + fids.push_back(fid); + } + } + + DictionaryData resultAttrs; + resultAttrs.reserve(fids.size()); + + for (int fid : fids) { + Field field = type->GetFieldInfo(fid); + + Value val = object->GetField(fid); + + /* hide attributes which shouldn't be user-visible */ + if (field.Attributes & FANoUserView) + continue; + + /* hide internal navigation fields */ + if (field.Attributes & FANavigation && !(field.Attributes & (FAConfig | FAState))) + continue; + + Value sval = Serialize(val, FAConfig | FAState); + resultAttrs.emplace_back(field.Name, sval); + } + + return new Dictionary(std::move(resultAttrs)); +} + +bool ObjectQueryHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() < 3 || url->GetPath().size() > 4) + return false; + + if (request.method() != http::verb::get) + return false; + + Type::Ptr type = FilterUtility::TypeFromPluralName(url->GetPath()[2]); + + if (!type) { + HttpUtility::SendJsonError(response, params, 400, "Invalid type specified."); + return true; + } + + QueryDescription qd; + qd.Types.insert(type->GetName()); + qd.Permission = "objects/query/" + type->GetName(); + + Array::Ptr uattrs, ujoins, umetas; + + try { + uattrs = params->Get("attrs"); + } catch (const std::exception&) { + HttpUtility::SendJsonError(response, params, 400, + "Invalid type for 'attrs' attribute specified. Array type is required."); + return true; + } + + try { + ujoins = params->Get("joins"); + } catch (const std::exception&) { + HttpUtility::SendJsonError(response, params, 400, + "Invalid type for 'joins' attribute specified. Array type is required."); + return true; + } + + try { + umetas = params->Get("meta"); + } catch (const std::exception&) { + HttpUtility::SendJsonError(response, params, 400, + "Invalid type for 'meta' attribute specified. Array type is required."); + return true; + } + + bool allJoins = HttpUtility::GetLastParameter(params, "all_joins"); + + params->Set("type", type->GetName()); + + if (url->GetPath().size() >= 4) { + String attr = type->GetName(); + boost::algorithm::to_lower(attr); + params->Set(attr, url->GetPath()[3]); + } + + std::vector<Value> objs; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No objects found.", + DiagnosticInformation(ex)); + return true; + } + + ArrayData results; + results.reserve(objs.size()); + + std::set<String> joinAttrs; + std::set<String> userJoinAttrs; + + if (ujoins) { + ObjectLock olock(ujoins); + for (const String& ujoin : ujoins) { + userJoinAttrs.insert(ujoin.SubStr(0, ujoin.FindFirstOf("."))); + } + } + + for (int fid = 0; fid < type->GetFieldCount(); fid++) { + Field field = type->GetFieldInfo(fid); + + if (!(field.Attributes & FANavigation)) + continue; + + if (!allJoins && userJoinAttrs.find(field.NavigationName) == userJoinAttrs.end()) + continue; + + joinAttrs.insert(field.Name); + } + + std::unordered_map<Type*, std::pair<bool, Expression::Ptr>> typePermissions; + std::unordered_map<Object*, bool> objectAccessAllowed; + + for (const ConfigObject::Ptr& obj : objs) { + DictionaryData result1{ + { "name", obj->GetName() }, + { "type", obj->GetReflectionType()->GetName() } + }; + + DictionaryData metaAttrs; + + if (umetas) { + ObjectLock olock(umetas); + for (const String& meta : umetas) { + if (meta == "used_by") { + Array::Ptr used_by = new Array(); + metaAttrs.emplace_back("used_by", used_by); + + for (const Object::Ptr& pobj : DependencyGraph::GetParents((obj))) + { + ConfigObject::Ptr configObj = dynamic_pointer_cast<ConfigObject>(pobj); + + if (!configObj) + continue; + + used_by->Add(new Dictionary({ + { "type", configObj->GetReflectionType()->GetName() }, + { "name", configObj->GetName() } + })); + } + } else if (meta == "location") { + metaAttrs.emplace_back("location", obj->GetSourceLocation()); + } else { + HttpUtility::SendJsonError(response, params, 400, "Invalid field specified for meta: " + meta); + return true; + } + } + } + + result1.emplace_back("meta", new Dictionary(std::move(metaAttrs))); + + try { + result1.emplace_back("attrs", SerializeObjectAttrs(obj, String(), uattrs, false, false)); + } catch (const ScriptError& ex) { + HttpUtility::SendJsonError(response, params, 400, ex.what()); + return true; + } + + DictionaryData joins; + + for (const String& joinAttr : joinAttrs) { + Object::Ptr joinedObj; + int fid = type->GetFieldId(joinAttr); + + if (fid < 0) { + HttpUtility::SendJsonError(response, params, 400, "Invalid field specified for join: " + joinAttr); + return true; + } + + Field field = type->GetFieldInfo(fid); + + if (!(field.Attributes & FANavigation)) { + HttpUtility::SendJsonError(response, params, 400, "Not a joinable field: " + joinAttr); + return true; + } + + joinedObj = obj->NavigateField(fid); + + if (!joinedObj) + continue; + + Type::Ptr reflectionType = joinedObj->GetReflectionType(); + Expression::Ptr permissionFilter; + + auto it = typePermissions.find(reflectionType.get()); + bool granted; + + if (it == typePermissions.end()) { + String permission = "objects/query/" + reflectionType->GetName(); + + Expression *filter = nullptr; + granted = FilterUtility::HasPermission(user, permission, &filter); + permissionFilter = filter; + + typePermissions.insert({reflectionType.get(), std::make_pair(granted, permissionFilter)}); + } else { + std::tie(granted, permissionFilter) = it->second; + } + + if (!granted) { + // Not authorized + continue; + } + + auto relation = objectAccessAllowed.find(joinedObj.get()); + bool accessAllowed; + + if (relation == objectAccessAllowed.end()) { + ScriptFrame permissionFrame(false, new Namespace()); + + try { + accessAllowed = FilterUtility::EvaluateFilter(permissionFrame, permissionFilter.get(), joinedObj); + } catch (const ScriptError& err) { + accessAllowed = false; + } + + objectAccessAllowed.insert({joinedObj.get(), accessAllowed}); + } else { + accessAllowed = relation->second; + } + + if (!accessAllowed) { + // Access denied + continue; + } + + String prefix = field.NavigationName; + + try { + joins.emplace_back(prefix, SerializeObjectAttrs(joinedObj, prefix, ujoins, true, allJoins)); + } catch (const ScriptError& ex) { + HttpUtility::SendJsonError(response, params, 400, ex.what()); + return true; + } + } + + result1.emplace_back("joins", new Dictionary(std::move(joins))); + + results.push_back(new Dictionary(std::move(result1))); + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} diff --git a/lib/remote/objectqueryhandler.hpp b/lib/remote/objectqueryhandler.hpp new file mode 100644 index 0000000..691b2cf --- /dev/null +++ b/lib/remote/objectqueryhandler.hpp @@ -0,0 +1,34 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef OBJECTQUERYHANDLER_H +#define OBJECTQUERYHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class ObjectQueryHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(ObjectQueryHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; + +private: + static Dictionary::Ptr SerializeObjectAttrs(const Object::Ptr& object, const String& attrPrefix, + const Array::Ptr& attrs, bool isJoin, bool allAttrs); +}; + +} + +#endif /* OBJECTQUERYHANDLER_H */ diff --git a/lib/remote/pkiutility.cpp b/lib/remote/pkiutility.cpp new file mode 100644 index 0000000..00d6525 --- /dev/null +++ b/lib/remote/pkiutility.cpp @@ -0,0 +1,452 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/pkiutility.hpp" +#include "remote/apilistener.hpp" +#include "base/defer.hpp" +#include "base/io-engine.hpp" +#include "base/logger.hpp" +#include "base/application.hpp" +#include "base/tcpsocket.hpp" +#include "base/tlsutility.hpp" +#include "base/console.hpp" +#include "base/tlsstream.hpp" +#include "base/tcpsocket.hpp" +#include "base/json.hpp" +#include "base/utility.hpp" +#include "base/convert.hpp" +#include "base/exception.hpp" +#include "remote/jsonrpc.hpp" +#include <fstream> +#include <iostream> +#include <boost/asio/ssl/context.hpp> +#include <boost/filesystem/path.hpp> + +using namespace icinga; + +int PkiUtility::NewCa() +{ + String caDir = ApiListener::GetCaDir(); + String caCertFile = caDir + "/ca.crt"; + String caKeyFile = caDir + "/ca.key"; + + if (Utility::PathExists(caCertFile) && Utility::PathExists(caKeyFile)) { + Log(LogWarning, "cli") + << "CA files '" << caCertFile << "' and '" << caKeyFile << "' already exist."; + return 1; + } + + Utility::MkDirP(caDir, 0700); + + MakeX509CSR("Icinga CA", caKeyFile, String(), caCertFile, true); + + return 0; +} + +int PkiUtility::NewCert(const String& cn, const String& keyfile, const String& csrfile, const String& certfile) +{ + try { + MakeX509CSR(cn, keyfile, csrfile, certfile); + } catch(std::exception&) { + return 1; + } + + return 0; +} + +int PkiUtility::SignCsr(const String& csrfile, const String& certfile) +{ + char errbuf[256]; + + InitializeOpenSSL(); + + BIO *csrbio = BIO_new_file(csrfile.CStr(), "r"); + X509_REQ *req = PEM_read_bio_X509_REQ(csrbio, nullptr, nullptr, nullptr); + + if (!req) { + ERR_error_string_n(ERR_peek_error(), errbuf, sizeof errbuf); + Log(LogCritical, "SSL") + << "Could not read X509 certificate request from '" << csrfile << "': " << ERR_peek_error() << ", \"" << errbuf << "\""; + return 1; + } + + BIO_free(csrbio); + + std::shared_ptr<EVP_PKEY> pubkey = std::shared_ptr<EVP_PKEY>(X509_REQ_get_pubkey(req), EVP_PKEY_free); + std::shared_ptr<X509> cert = CreateCertIcingaCA(pubkey.get(), X509_REQ_get_subject_name(req)); + + X509_REQ_free(req); + + WriteCert(cert, certfile); + + return 0; +} + +std::shared_ptr<X509> PkiUtility::FetchCert(const String& host, const String& port) +{ + Shared<boost::asio::ssl::context>::Ptr sslContext; + + try { + sslContext = MakeAsioSslContext(); + } catch (const std::exception& ex) { + Log(LogCritical, "pki") + << "Cannot make SSL context."; + Log(LogDebug, "pki") + << "Cannot make SSL context:\n" << DiagnosticInformation(ex); + return std::shared_ptr<X509>(); + } + + auto stream (Shared<AsioTlsStream>::Make(IoEngine::Get().GetIoContext(), *sslContext, host)); + + try { + Connect(stream->lowest_layer(), host, port); + } catch (const std::exception& ex) { + Log(LogCritical, "pki") + << "Cannot connect to host '" << host << "' on port '" << port << "'"; + Log(LogDebug, "pki") + << "Cannot connect to host '" << host << "' on port '" << port << "':\n" << DiagnosticInformation(ex); + return std::shared_ptr<X509>(); + } + + auto& sslConn (stream->next_layer()); + + try { + sslConn.handshake(sslConn.client); + } catch (const std::exception& ex) { + Log(LogCritical, "pki") + << "Client TLS handshake failed. (" << ex.what() << ")"; + return std::shared_ptr<X509>(); + } + + Defer shutdown ([&sslConn]() { sslConn.shutdown(); }); + + return sslConn.GetPeerCertificate(); +} + +int PkiUtility::WriteCert(const std::shared_ptr<X509>& cert, const String& trustedfile) +{ + std::ofstream fpcert; + fpcert.open(trustedfile.CStr()); + fpcert << CertificateToString(cert); + fpcert.close(); + + if (fpcert.fail()) { + Log(LogCritical, "pki") + << "Could not write certificate to file '" << trustedfile << "'."; + return 1; + } + + Log(LogInformation, "pki") + << "Writing certificate to file '" << trustedfile << "'."; + + return 0; +} + +int PkiUtility::GenTicket(const String& cn, const String& salt, std::ostream& ticketfp) +{ + ticketfp << PBKDF2_SHA1(cn, salt, 50000) << "\n"; + + return 0; +} + +int PkiUtility::RequestCertificate(const String& host, const String& port, const String& keyfile, + const String& certfile, const String& cafile, const std::shared_ptr<X509>& trustedCert, const String& ticket) +{ + Shared<boost::asio::ssl::context>::Ptr sslContext; + + try { + sslContext = MakeAsioSslContext(certfile, keyfile); + } catch (const std::exception& ex) { + Log(LogCritical, "cli") + << "Cannot make SSL context for cert path: '" << certfile << "' key path: '" << keyfile << "' ca path: '" << cafile << "'."; + Log(LogDebug, "cli") + << "Cannot make SSL context for cert path: '" << certfile << "' key path: '" << keyfile << "' ca path: '" << cafile << "':\n" << DiagnosticInformation(ex); + return 1; + } + + auto stream (Shared<AsioTlsStream>::Make(IoEngine::Get().GetIoContext(), *sslContext, host)); + + try { + Connect(stream->lowest_layer(), host, port); + } catch (const std::exception& ex) { + Log(LogCritical, "cli") + << "Cannot connect to host '" << host << "' on port '" << port << "'"; + Log(LogDebug, "cli") + << "Cannot connect to host '" << host << "' on port '" << port << "':\n" << DiagnosticInformation(ex); + return 1; + } + + auto& sslConn (stream->next_layer()); + + try { + sslConn.handshake(sslConn.client); + } catch (const std::exception& ex) { + Log(LogCritical, "cli") + << "Client TLS handshake failed: " << DiagnosticInformation(ex, false); + return 1; + } + + Defer shutdown ([&sslConn]() { sslConn.shutdown(); }); + + auto peerCert (sslConn.GetPeerCertificate()); + + if (X509_cmp(peerCert.get(), trustedCert.get())) { + Log(LogCritical, "cli", "Peer certificate does not match trusted certificate."); + return 1; + } + + Dictionary::Ptr params = new Dictionary({ + { "ticket", String(ticket) } + }); + + String msgid = Utility::NewUniqueID(); + + Dictionary::Ptr request = new Dictionary({ + { "jsonrpc", "2.0" }, + { "id", msgid }, + { "method", "pki::RequestCertificate" }, + { "params", params } + }); + + Dictionary::Ptr response; + + try { + JsonRpc::SendMessage(stream, request); + stream->flush(); + + for (;;) { + response = JsonRpc::DecodeMessage(JsonRpc::ReadMessage(stream)); + + if (response && response->Contains("error")) { + Log(LogCritical, "cli", "Could not fetch valid response. Please check the master log (notice or debug)."); +#ifdef I2_DEBUG + /* we shouldn't expose master errors to the user in production environments */ + Log(LogCritical, "cli", response->Get("error")); +#endif /* I2_DEBUG */ + return 1; + } + + if (response && (response->Get("id") != msgid)) + continue; + + break; + } + } catch (...) { + Log(LogCritical, "cli", "Could not fetch valid response. Please check the master log."); + return 1; + } + + if (!response) { + Log(LogCritical, "cli", "Could not fetch valid response. Please check the master log."); + return 1; + } + + Dictionary::Ptr result = response->Get("result"); + + if (result->Contains("ca")) { + try { + StringToCertificate(result->Get("ca")); + } catch (const std::exception& ex) { + Log(LogCritical, "cli") + << "Could not write CA file: " << DiagnosticInformation(ex, false); + return 1; + } + + Log(LogInformation, "cli") + << "Writing CA certificate to file '" << cafile << "'."; + + std::ofstream fpca; + fpca.open(cafile.CStr()); + fpca << result->Get("ca"); + fpca.close(); + + if (fpca.fail()) { + Log(LogCritical, "cli") + << "Could not open CA certificate file '" << cafile << "' for writing."; + return 1; + } + } + + if (result->Contains("error")) { + LogSeverity severity; + + Value vstatus; + + if (!result->Get("status_code", &vstatus)) + vstatus = 1; + + int status = vstatus; + + if (status == 1) + severity = LogCritical; + else { + severity = LogInformation; + Log(severity, "cli", "!!!!!!"); + } + + Log(severity, "cli") + << "!!! " << result->Get("error"); + + if (status == 1) + return 1; + else { + Log(severity, "cli", "!!!!!!"); + return 0; + } + } + + try { + StringToCertificate(result->Get("cert")); + } catch (const std::exception& ex) { + Log(LogCritical, "cli") + << "Could not write certificate file: " << DiagnosticInformation(ex, false); + return 1; + } + + Log(LogInformation, "cli") + << "Writing signed certificate to file '" << certfile << "'."; + + std::ofstream fpcert; + fpcert.open(certfile.CStr()); + fpcert << result->Get("cert"); + fpcert.close(); + + if (fpcert.fail()) { + Log(LogCritical, "cli") + << "Could not write certificate to file '" << certfile << "'."; + return 1; + } + + return 0; +} + +String PkiUtility::GetCertificateInformation(const std::shared_ptr<X509>& cert) { + BIO *out = BIO_new(BIO_s_mem()); + String pre; + + pre = "\n Version: " + Convert::ToString(GetCertificateVersion(cert)); + BIO_write(out, pre.CStr(), pre.GetLength()); + + pre = "\n Subject: "; + BIO_write(out, pre.CStr(), pre.GetLength()); + X509_NAME_print_ex(out, X509_get_subject_name(cert.get()), 0, XN_FLAG_ONELINE & ~ASN1_STRFLGS_ESC_MSB); + + pre = "\n Issuer: "; + BIO_write(out, pre.CStr(), pre.GetLength()); + X509_NAME_print_ex(out, X509_get_issuer_name(cert.get()), 0, XN_FLAG_ONELINE & ~ASN1_STRFLGS_ESC_MSB); + + pre = "\n Valid From: "; + BIO_write(out, pre.CStr(), pre.GetLength()); + ASN1_TIME_print(out, X509_get_notBefore(cert.get())); + + pre = "\n Valid Until: "; + BIO_write(out, pre.CStr(), pre.GetLength()); + ASN1_TIME_print(out, X509_get_notAfter(cert.get())); + + pre = "\n Serial: "; + BIO_write(out, pre.CStr(), pre.GetLength()); + ASN1_INTEGER *asn1_serial = X509_get_serialNumber(cert.get()); + for (int i = 0; i < asn1_serial->length; i++) { + BIO_printf(out, "%02x%c", asn1_serial->data[i], ((i + 1 == asn1_serial->length) ? '\n' : ':')); + } + + pre = "\n Signature Algorithm: " + GetSignatureAlgorithm(cert); + BIO_write(out, pre.CStr(), pre.GetLength()); + + pre = "\n Subject Alt Names: " + GetSubjectAltNames(cert)->Join(" "); + BIO_write(out, pre.CStr(), pre.GetLength()); + + pre = "\n Fingerprint: "; + BIO_write(out, pre.CStr(), pre.GetLength()); + unsigned char md[EVP_MAX_MD_SIZE]; + unsigned int diglen; + X509_digest(cert.get(), EVP_sha256(), md, &diglen); + + char *data; + long length = BIO_get_mem_data(out, &data); + + std::stringstream info; + info << String(data, data + length); + + BIO_free(out); + + for (unsigned int i = 0; i < diglen; i++) { + info << std::setfill('0') << std::setw(2) << std::uppercase + << std::hex << static_cast<int>(md[i]) << ' '; + } + info << '\n'; + + return info.str(); +} + +static void CollectRequestHandler(const Dictionary::Ptr& requests, const String& requestFile) +{ + Dictionary::Ptr request = Utility::LoadJsonFile(requestFile); + + if (!request) + return; + + Dictionary::Ptr result = new Dictionary(); + + namespace fs = boost::filesystem; + fs::path file(requestFile.Begin(), requestFile.End()); + String fingerprint = file.stem().string(); + + String certRequestText = request->Get("cert_request"); + result->Set("cert_request", certRequestText); + + Value vcertResponseText; + + if (request->Get("cert_response", &vcertResponseText)) { + String certResponseText = vcertResponseText; + result->Set("cert_response", certResponseText); + } + + std::shared_ptr<X509> certRequest = StringToCertificate(certRequestText); + +/* XXX (requires OpenSSL >= 1.0.0) + time_t now; + time(&now); + ASN1_TIME *tm = ASN1_TIME_adj(nullptr, now, 0, 0); + + int day, sec; + ASN1_TIME_diff(&day, &sec, tm, X509_get_notBefore(certRequest.get())); + + result->Set("timestamp", static_cast<double>(now) + day * 24 * 60 * 60 + sec); */ + + BIO *out = BIO_new(BIO_s_mem()); + ASN1_TIME_print(out, X509_get_notBefore(certRequest.get())); + + char *data; + long length; + length = BIO_get_mem_data(out, &data); + + result->Set("timestamp", String(data, data + length)); + BIO_free(out); + + out = BIO_new(BIO_s_mem()); + X509_NAME_print_ex(out, X509_get_subject_name(certRequest.get()), 0, XN_FLAG_ONELINE & ~ASN1_STRFLGS_ESC_MSB); + + length = BIO_get_mem_data(out, &data); + + result->Set("subject", String(data, data + length)); + BIO_free(out); + + requests->Set(fingerprint, result); +} + +Dictionary::Ptr PkiUtility::GetCertificateRequests(bool removed) +{ + Dictionary::Ptr requests = new Dictionary(); + + String requestDir = ApiListener::GetCertificateRequestsDir(); + String ext = "json"; + + if (removed) + ext = "removed"; + + if (Utility::PathExists(requestDir)) + Utility::Glob(requestDir + "/*." + ext, [requests](const String& requestFile) { CollectRequestHandler(requests, requestFile); }, GlobFile); + + return requests; +} + diff --git a/lib/remote/pkiutility.hpp b/lib/remote/pkiutility.hpp new file mode 100644 index 0000000..50d47e0 --- /dev/null +++ b/lib/remote/pkiutility.hpp @@ -0,0 +1,41 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef PKIUTILITY_H +#define PKIUTILITY_H + +#include "remote/i2-remote.hpp" +#include "base/exception.hpp" +#include "base/dictionary.hpp" +#include "base/string.hpp" +#include <openssl/x509v3.h> +#include <memory> + +namespace icinga +{ + +/** + * @ingroup remote + */ +class PkiUtility +{ +public: + static int NewCa(); + static int NewCert(const String& cn, const String& keyfile, const String& csrfile, const String& certfile); + static int SignCsr(const String& csrfile, const String& certfile); + static std::shared_ptr<X509> FetchCert(const String& host, const String& port); + static int WriteCert(const std::shared_ptr<X509>& cert, const String& trustedfile); + static int GenTicket(const String& cn, const String& salt, std::ostream& ticketfp); + static int RequestCertificate(const String& host, const String& port, const String& keyfile, + const String& certfile, const String& cafile, const std::shared_ptr<X509>& trustedcert, + const String& ticket = String()); + static String GetCertificateInformation(const std::shared_ptr<X509>& certificate); + static Dictionary::Ptr GetCertificateRequests(bool removed = false); + +private: + PkiUtility(); + +}; + +} + +#endif /* PKIUTILITY_H */ diff --git a/lib/remote/statushandler.cpp b/lib/remote/statushandler.cpp new file mode 100644 index 0000000..1f3f618 --- /dev/null +++ b/lib/remote/statushandler.cpp @@ -0,0 +1,120 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/statushandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "base/serializer.hpp" +#include "base/statsfunction.hpp" +#include "base/namespace.hpp" + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/status", StatusHandler); + +class StatusTargetProvider final : public TargetProvider +{ +public: + DECLARE_PTR_TYPEDEFS(StatusTargetProvider); + + void FindTargets(const String& type, + const std::function<void (const Value&)>& addTarget) const override + { + Namespace::Ptr statsFunctions = ScriptGlobal::Get("StatsFunctions", &Empty); + + if (statsFunctions) { + ObjectLock olock(statsFunctions); + + for (const Namespace::Pair& kv : statsFunctions) + addTarget(GetTargetByName("Status", kv.first)); + } + } + + Value GetTargetByName(const String& type, const String& name) const override + { + Namespace::Ptr statsFunctions = ScriptGlobal::Get("StatsFunctions", &Empty); + + if (!statsFunctions) + BOOST_THROW_EXCEPTION(std::invalid_argument("No status functions are available.")); + + Value vfunc; + + if (!statsFunctions->Get(name, &vfunc)) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid status function name.")); + + Function::Ptr func = vfunc; + + if (!func) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid status function name.")); + + Dictionary::Ptr status = new Dictionary(); + Array::Ptr perfdata = new Array(); + func->Invoke({ status, perfdata }); + + return new Dictionary({ + { "name", name }, + { "status", status }, + { "perfdata", Serialize(perfdata, FAState) } + }); + } + + bool IsValidType(const String& type) const override + { + return type == "Status"; + } + + String GetPluralName(const String& type) const override + { + return "statuses"; + } +}; + +bool StatusHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() > 3) + return false; + + if (request.method() != http::verb::get) + return false; + + QueryDescription qd; + qd.Types.insert("Status"); + qd.Provider = new StatusTargetProvider(); + qd.Permission = "status/query"; + + params->Set("type", "Status"); + + if (url->GetPath().size() >= 3) + params->Set("status", url->GetPath()[2]); + + std::vector<Value> objs; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No objects found.", + DiagnosticInformation(ex)); + return true; + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(objs)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} + diff --git a/lib/remote/statushandler.hpp b/lib/remote/statushandler.hpp new file mode 100644 index 0000000..c722ab3 --- /dev/null +++ b/lib/remote/statushandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef STATUSHANDLER_H +#define STATUSHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class StatusHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(StatusHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* STATUSHANDLER_H */ diff --git a/lib/remote/templatequeryhandler.cpp b/lib/remote/templatequeryhandler.cpp new file mode 100644 index 0000000..e70dafb --- /dev/null +++ b/lib/remote/templatequeryhandler.cpp @@ -0,0 +1,136 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/templatequeryhandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "config/configitem.hpp" +#include "base/configtype.hpp" +#include "base/scriptglobal.hpp" +#include "base/logger.hpp" +#include <boost/algorithm/string/case_conv.hpp> +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/templates", TemplateQueryHandler); + +class TemplateTargetProvider final : public TargetProvider +{ +public: + DECLARE_PTR_TYPEDEFS(TemplateTargetProvider); + + static Dictionary::Ptr GetTargetForTemplate(const ConfigItem::Ptr& item) + { + DebugInfo di = item->GetDebugInfo(); + + return new Dictionary({ + { "name", item->GetName() }, + { "type", item->GetType()->GetName() }, + { "location", new Dictionary({ + { "path", di.Path }, + { "first_line", di.FirstLine }, + { "first_column", di.FirstColumn }, + { "last_line", di.LastLine }, + { "last_column", di.LastColumn } + }) } + }); + } + + void FindTargets(const String& type, + const std::function<void (const Value&)>& addTarget) const override + { + Type::Ptr ptype = Type::GetByName(type); + + for (const ConfigItem::Ptr& item : ConfigItem::GetItems(ptype)) { + if (item->IsAbstract()) + addTarget(GetTargetForTemplate(item)); + } + } + + Value GetTargetByName(const String& type, const String& name) const override + { + Type::Ptr ptype = Type::GetByName(type); + + ConfigItem::Ptr item = ConfigItem::GetByTypeAndName(ptype, name); + + if (!item || !item->IsAbstract()) + BOOST_THROW_EXCEPTION(std::invalid_argument("Template does not exist.")); + + return GetTargetForTemplate(item); + } + + bool IsValidType(const String& type) const override + { + Type::Ptr ptype = Type::GetByName(type); + + if (!ptype) + return false; + + return ConfigObject::TypeInstance->IsAssignableFrom(ptype); + } + + String GetPluralName(const String& type) const override + { + return Type::GetByName(type)->GetPluralName(); + } +}; + +bool TemplateQueryHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() < 3 || url->GetPath().size() > 4) + return false; + + if (request.method() != http::verb::get) + return false; + + Type::Ptr type = FilterUtility::TypeFromPluralName(url->GetPath()[2]); + + if (!type) { + HttpUtility::SendJsonError(response, params, 400, "Invalid type specified."); + return true; + } + + QueryDescription qd; + qd.Types.insert(type->GetName()); + qd.Permission = "templates/query/" + type->GetName(); + qd.Provider = new TemplateTargetProvider(); + + params->Set("type", type->GetName()); + + if (url->GetPath().size() >= 4) { + String attr = type->GetName(); + boost::algorithm::to_lower(attr); + params->Set(attr, url->GetPath()[3]); + } + + std::vector<Value> objs; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user, "tmpl"); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No templates found.", + DiagnosticInformation(ex)); + return true; + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(objs)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} diff --git a/lib/remote/templatequeryhandler.hpp b/lib/remote/templatequeryhandler.hpp new file mode 100644 index 0000000..503bc85 --- /dev/null +++ b/lib/remote/templatequeryhandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef TEMPLATEQUERYHANDLER_H +#define TEMPLATEQUERYHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class TemplateQueryHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(TemplateQueryHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* TEMPLATEQUERYHANDLER_H */ diff --git a/lib/remote/typequeryhandler.cpp b/lib/remote/typequeryhandler.cpp new file mode 100644 index 0000000..4e82653 --- /dev/null +++ b/lib/remote/typequeryhandler.cpp @@ -0,0 +1,156 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/typequeryhandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "base/configtype.hpp" +#include "base/scriptglobal.hpp" +#include "base/logger.hpp" +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/types", TypeQueryHandler); + +class TypeTargetProvider final : public TargetProvider +{ +public: + DECLARE_PTR_TYPEDEFS(TypeTargetProvider); + + void FindTargets(const String& type, + const std::function<void (const Value&)>& addTarget) const override + { + for (const Type::Ptr& target : Type::GetAllTypes()) { + addTarget(target); + } + } + + Value GetTargetByName(const String& type, const String& name) const override + { + Type::Ptr ptype = Type::GetByName(name); + + if (!ptype) + BOOST_THROW_EXCEPTION(std::invalid_argument("Type does not exist.")); + + return ptype; + } + + bool IsValidType(const String& type) const override + { + return type == "Type"; + } + + String GetPluralName(const String& type) const override + { + return "types"; + } +}; + +bool TypeQueryHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() > 3) + return false; + + if (request.method() != http::verb::get) + return false; + + QueryDescription qd; + qd.Types.insert("Type"); + qd.Permission = "types"; + qd.Provider = new TypeTargetProvider(); + + if (params->Contains("type")) + params->Set("name", params->Get("type")); + + params->Set("type", "Type"); + + if (url->GetPath().size() >= 3) + params->Set("name", url->GetPath()[2]); + + std::vector<Value> objs; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No objects found.", + DiagnosticInformation(ex)); + return true; + } + + ArrayData results; + + for (const Type::Ptr& obj : objs) { + Dictionary::Ptr result1 = new Dictionary(); + results.push_back(result1); + + Dictionary::Ptr resultAttrs = new Dictionary(); + result1->Set("name", obj->GetName()); + result1->Set("plural_name", obj->GetPluralName()); + if (obj->GetBaseType()) + result1->Set("base", obj->GetBaseType()->GetName()); + result1->Set("abstract", obj->IsAbstract()); + result1->Set("fields", resultAttrs); + + Dictionary::Ptr prototype = dynamic_pointer_cast<Dictionary>(obj->GetPrototype()); + Array::Ptr prototypeKeys = new Array(); + result1->Set("prototype_keys", prototypeKeys); + + if (prototype) { + ObjectLock olock(prototype); + for (const Dictionary::Pair& kv : prototype) { + prototypeKeys->Add(kv.first); + } + } + + int baseFieldCount = 0; + + if (obj->GetBaseType()) + baseFieldCount = obj->GetBaseType()->GetFieldCount(); + + for (int fid = baseFieldCount; fid < obj->GetFieldCount(); fid++) { + Field field = obj->GetFieldInfo(fid); + + Dictionary::Ptr fieldInfo = new Dictionary(); + resultAttrs->Set(field.Name, fieldInfo); + + fieldInfo->Set("id", fid); + fieldInfo->Set("type", field.TypeName); + if (field.RefTypeName) + fieldInfo->Set("ref_type", field.RefTypeName); + if (field.Attributes & FANavigation) + fieldInfo->Set("navigation_name", field.NavigationName); + fieldInfo->Set("array_rank", field.ArrayRank); + + fieldInfo->Set("attributes", new Dictionary({ + { "config", static_cast<bool>(field.Attributes & FAConfig) }, + { "state", static_cast<bool>(field.Attributes & FAState) }, + { "required", static_cast<bool>(field.Attributes & FARequired) }, + { "navigation", static_cast<bool>(field.Attributes & FANavigation) }, + { "no_user_modify", static_cast<bool>(field.Attributes & FANoUserModify) }, + { "no_user_view", static_cast<bool>(field.Attributes & FANoUserView) }, + { "deprecated", static_cast<bool>(field.Attributes & FADeprecated) } + })); + } + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} diff --git a/lib/remote/typequeryhandler.hpp b/lib/remote/typequeryhandler.hpp new file mode 100644 index 0000000..5489cb2 --- /dev/null +++ b/lib/remote/typequeryhandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef TYPEQUERYHANDLER_H +#define TYPEQUERYHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class TypeQueryHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(TypeQueryHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* TYPEQUERYHANDLER_H */ diff --git a/lib/remote/url-characters.hpp b/lib/remote/url-characters.hpp new file mode 100644 index 0000000..3cc4921 --- /dev/null +++ b/lib/remote/url-characters.hpp @@ -0,0 +1,29 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef URL_CHARACTERS_H +#define URL_CHARACTERS_H + +#define ALPHA "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +#define NUMERIC "0123456789" + +#define UNRESERVED ALPHA NUMERIC "-._~" "%" +#define GEN_DELIMS ":/?#[]@" +#define SUB_DELIMS "!$&'()*+,;=" +#define PCHAR UNRESERVED SUB_DELIMS ":@" +#define PCHAR_ENCODE UNRESERVED ":@" + +#define ACSCHEME ALPHA NUMERIC ".-+" + +//authority = [ userinfo "@" ] host [ ":" port ] +#define ACUSERINFO UNRESERVED SUB_DELIMS +#define ACHOST UNRESERVED SUB_DELIMS +#define ACPORT NUMERIC + +#define ACPATHSEGMENT PCHAR +#define ACPATHSEGMENT_ENCODE PCHAR_ENCODE +#define ACQUERY PCHAR "/?" +#define ACQUERY_ENCODE PCHAR_ENCODE "/?" +#define ACFRAGMENT PCHAR "/?" +#define ACFRAGMENT_ENCODE PCHAR_ENCODE "/?" + +#endif /* URL_CHARACTERS_H */ diff --git a/lib/remote/url.cpp b/lib/remote/url.cpp new file mode 100644 index 0000000..e87628e --- /dev/null +++ b/lib/remote/url.cpp @@ -0,0 +1,363 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "base/array.hpp" +#include "base/utility.hpp" +#include "base/objectlock.hpp" +#include "remote/url.hpp" +#include "remote/url-characters.hpp" +#include <boost/tokenizer.hpp> + +using namespace icinga; + +Url::Url(const String& base_url) +{ + String url = base_url; + + if (url.GetLength() == 0) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL Empty URL.")); + + size_t pHelper = String::NPos; + if (url[0] != '/') + pHelper = url.Find(":"); + + if (pHelper != String::NPos) { + if (!ParseScheme(url.SubStr(0, pHelper))) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL Scheme.")); + url = url.SubStr(pHelper + 1); + } + + if (*url.Begin() != '/') + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL: '/' expected after scheme.")); + + if (url.GetLength() == 1) { + return; + } + + if (*(url.Begin() + 1) == '/') { + pHelper = url.Find("/", 2); + + if (pHelper == String::NPos) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL: Missing '/' after authority.")); + + if (!ParseAuthority(url.SubStr(0, pHelper))) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL Authority")); + + url = url.SubStr(pHelper); + } + + if (*url.Begin() == '/') { + pHelper = url.FindFirstOf("#?"); + if (!ParsePath(url.SubStr(1, pHelper - 1))) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL Path")); + + if (pHelper != String::NPos) + url = url.SubStr(pHelper); + } else + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL: Missing path.")); + + if (*url.Begin() == '?') { + pHelper = url.Find("#"); + if (!ParseQuery(url.SubStr(1, pHelper - 1))) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL Query")); + + if (pHelper != String::NPos) + url = url.SubStr(pHelper); + } + + if (*url.Begin() == '#') { + if (!ParseFragment(url.SubStr(1))) + BOOST_THROW_EXCEPTION(std::invalid_argument("Invalid URL Fragment")); + } +} + +String Url::GetScheme() const +{ + return m_Scheme; +} + +String Url::GetAuthority() const +{ + if (m_Host.IsEmpty()) + return ""; + + String auth; + if (!m_Username.IsEmpty()) { + auth = m_Username; + if (!m_Password.IsEmpty()) + auth += ":" + m_Password; + auth += "@"; + } + + auth += m_Host; + + if (!m_Port.IsEmpty()) + auth += ":" + m_Port; + + return auth; +} + +String Url::GetUsername() const +{ + return m_Username; +} + +String Url::GetPassword() const +{ + return m_Password; +} + +String Url::GetHost() const +{ + return m_Host; +} + +String Url::GetPort() const +{ + return m_Port; +} + +const std::vector<String>& Url::GetPath() const +{ + return m_Path; +} + +const std::vector<std::pair<String, String>>& Url::GetQuery() const +{ + return m_Query; +} + +String Url::GetFragment() const +{ + return m_Fragment; +} + +void Url::SetScheme(const String& scheme) +{ + m_Scheme = scheme; +} + +void Url::SetUsername(const String& username) +{ + m_Username = username; +} + +void Url::SetPassword(const String& password) +{ + m_Password = password; +} + +void Url::SetHost(const String& host) +{ + m_Host = host; +} + +void Url::SetPort(const String& port) +{ + m_Port = port; +} + +void Url::SetPath(const std::vector<String>& path) +{ + m_Path = path; +} + +void Url::SetQuery(const std::vector<std::pair<String, String>>& query) +{ + m_Query = query; +} + +void Url::SetArrayFormatUseBrackets(bool useBrackets) +{ + m_ArrayFormatUseBrackets = useBrackets; +} + +void Url::AddQueryElement(const String& name, const String& value) +{ + m_Query.emplace_back(name, value); +} + +void Url::SetFragment(const String& fragment) { + m_Fragment = fragment; +} + +String Url::Format(bool onlyPathAndQuery, bool printCredentials) const +{ + String url; + + if (!onlyPathAndQuery) { + if (!m_Scheme.IsEmpty()) + url += m_Scheme + ":"; + + if (printCredentials && !GetAuthority().IsEmpty()) + url += "//" + GetAuthority(); + else if (!GetHost().IsEmpty()) + url += "//" + GetHost() + (!GetPort().IsEmpty() ? ":" + GetPort() : ""); + } + + if (m_Path.empty()) + url += "/"; + else { + for (const String& segment : m_Path) { + url += "/"; + url += Utility::EscapeString(segment, ACPATHSEGMENT_ENCODE, false); + } + } + + String param; + if (!m_Query.empty()) { + typedef std::pair<String, std::vector<String> > kv_pair; + + for (const auto& kv : m_Query) { + String key = Utility::EscapeString(kv.first, ACQUERY_ENCODE, false); + if (param.IsEmpty()) + param = "?"; + else + param += "&"; + + param += key; + param += kv.second.IsEmpty() ? + String() : "=" + Utility::EscapeString(kv.second, ACQUERY_ENCODE, false); + } + } + + url += param; + + if (!m_Fragment.IsEmpty()) + url += "#" + Utility::EscapeString(m_Fragment, ACFRAGMENT_ENCODE, false); + + return url; +} + +bool Url::ParseScheme(const String& scheme) +{ + m_Scheme = scheme; + + if (scheme.FindFirstOf(ALPHA) != 0) + return false; + + return (ValidateToken(scheme, ACSCHEME)); +} + +bool Url::ParseAuthority(const String& authority) +{ + String auth = authority.SubStr(2); + size_t pos = auth.Find("@"); + if (pos != String::NPos && pos != 0) { + if (!Url::ParseUserinfo(auth.SubStr(0, pos))) + return false; + auth = auth.SubStr(pos+1); + } + + pos = auth.Find(":"); + if (pos != String::NPos) { + if (pos == 0 || pos == auth.GetLength() - 1 || !Url::ParsePort(auth.SubStr(pos+1))) + return false; + } + + m_Host = auth.SubStr(0, pos); + return ValidateToken(m_Host, ACHOST); +} + +bool Url::ParseUserinfo(const String& userinfo) +{ + size_t pos = userinfo.Find(":"); + m_Username = userinfo.SubStr(0, pos); + if (!ValidateToken(m_Username, ACUSERINFO)) + return false; + m_Username = Utility::UnescapeString(m_Username); + if (pos != String::NPos && pos != userinfo.GetLength() - 1) { + m_Password = userinfo.SubStr(pos+1); + if (!ValidateToken(m_Username, ACUSERINFO)) + return false; + m_Password = Utility::UnescapeString(m_Password); + } else + m_Password = ""; + + return true; +} + +bool Url::ParsePort(const String& port) +{ + m_Port = Utility::UnescapeString(port); + if (!ValidateToken(m_Port, ACPORT)) + return false; + return true; +} + +bool Url::ParsePath(const String& path) +{ + const std::string& pathStr = path; + boost::char_separator<char> sep("/"); + boost::tokenizer<boost::char_separator<char> > tokens(pathStr, sep); + + for (const String& token : tokens) { + if (token.IsEmpty()) + continue; + + if (!ValidateToken(token, ACPATHSEGMENT)) + return false; + + m_Path.emplace_back(Utility::UnescapeString(token)); + } + + return true; +} + +bool Url::ParseQuery(const String& query) +{ + /* Tokenizer does not like String AT ALL */ + const std::string& queryStr = query; + boost::char_separator<char> sep("&"); + boost::tokenizer<boost::char_separator<char> > tokens(queryStr, sep); + + for (const String& token : tokens) { + size_t pHelper = token.Find("="); + + if (pHelper == 0) + // /?foo=bar&=bar == invalid + return false; + + String key = token.SubStr(0, pHelper); + String value = Empty; + + if (pHelper != String::NPos && pHelper != token.GetLength() - 1) + value = token.SubStr(pHelper+1); + + if (!ValidateToken(value, ACQUERY)) + return false; + + value = Utility::UnescapeString(value); + + pHelper = key.Find("[]"); + + if (pHelper == 0 || (pHelper != String::NPos && pHelper != key.GetLength()-2)) + return false; + + key = key.SubStr(0, pHelper); + + if (!ValidateToken(key, ACQUERY)) + return false; + + m_Query.emplace_back(Utility::UnescapeString(key), std::move(value)); + } + + return true; +} + +bool Url::ParseFragment(const String& fragment) +{ + m_Fragment = Utility::UnescapeString(fragment); + + return ValidateToken(fragment, ACFRAGMENT); +} + +bool Url::ValidateToken(const String& token, const String& symbols) +{ + for (const char ch : token) { + if (symbols.FindFirstOf(ch) == String::NPos) + return false; + } + + return true; +} + diff --git a/lib/remote/url.hpp b/lib/remote/url.hpp new file mode 100644 index 0000000..6012b2f --- /dev/null +++ b/lib/remote/url.hpp @@ -0,0 +1,78 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef URL_H +#define URL_H + +#include "remote/i2-remote.hpp" +#include "base/object.hpp" +#include "base/string.hpp" +#include "base/array.hpp" +#include "base/value.hpp" +#include <map> +#include <utility> +#include <vector> + +namespace icinga +{ + +/** + * A url class to use with the API + * + * @ingroup base + */ +class Url final : public Object +{ +public: + DECLARE_PTR_TYPEDEFS(Url); + + Url() = default; + Url(const String& url); + + String Format(bool onlyPathAndQuery = false, bool printCredentials = false) const; + + String GetScheme() const; + String GetAuthority() const; + String GetUsername() const; + String GetPassword() const; + String GetHost() const; + String GetPort() const; + const std::vector<String>& GetPath() const; + const std::vector<std::pair<String, String>>& GetQuery() const; + String GetFragment() const; + + void SetScheme(const String& scheme); + void SetUsername(const String& username); + void SetPassword(const String& password); + void SetHost(const String& host); + void SetPort(const String& port); + void SetPath(const std::vector<String>& path); + void SetQuery(const std::vector<std::pair<String, String>>& query); + void SetArrayFormatUseBrackets(bool useBrackets = true); + + void AddQueryElement(const String& name, const String& query); + void SetFragment(const String& fragment); + +private: + String m_Scheme; + String m_Username; + String m_Password; + String m_Host; + String m_Port; + std::vector<String> m_Path; + std::vector<std::pair<String, String>> m_Query; + bool m_ArrayFormatUseBrackets; + String m_Fragment; + + bool ParseScheme(const String& scheme); + bool ParseAuthority(const String& authority); + bool ParseUserinfo(const String& userinfo); + bool ParsePort(const String& port); + bool ParsePath(const String& path); + bool ParseQuery(const String& query); + bool ParseFragment(const String& fragment); + + static bool ValidateToken(const String& token, const String& symbols); +}; + +} +#endif /* URL_H */ diff --git a/lib/remote/variablequeryhandler.cpp b/lib/remote/variablequeryhandler.cpp new file mode 100644 index 0000000..aef896e --- /dev/null +++ b/lib/remote/variablequeryhandler.cpp @@ -0,0 +1,118 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/variablequeryhandler.hpp" +#include "remote/httputility.hpp" +#include "remote/filterutility.hpp" +#include "base/configtype.hpp" +#include "base/scriptglobal.hpp" +#include "base/logger.hpp" +#include "base/serializer.hpp" +#include "base/namespace.hpp" +#include <set> + +using namespace icinga; + +REGISTER_URLHANDLER("/v1/variables", VariableQueryHandler); + +class VariableTargetProvider final : public TargetProvider +{ +public: + DECLARE_PTR_TYPEDEFS(VariableTargetProvider); + + static Dictionary::Ptr GetTargetForVar(const String& name, const Value& value) + { + return new Dictionary({ + { "name", name }, + { "type", value.GetReflectionType()->GetName() }, + { "value", value } + }); + } + + void FindTargets(const String& type, + const std::function<void (const Value&)>& addTarget) const override + { + { + Namespace::Ptr globals = ScriptGlobal::GetGlobals(); + ObjectLock olock(globals); + for (const Namespace::Pair& kv : globals) { + addTarget(GetTargetForVar(kv.first, kv.second->Get())); + } + } + } + + Value GetTargetByName(const String& type, const String& name) const override + { + return GetTargetForVar(name, ScriptGlobal::Get(name)); + } + + bool IsValidType(const String& type) const override + { + return type == "Variable"; + } + + String GetPluralName(const String& type) const override + { + return "variables"; + } +}; + +bool VariableQueryHandler::HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server +) +{ + namespace http = boost::beast::http; + + if (url->GetPath().size() > 3) + return false; + + if (request.method() != http::verb::get) + return false; + + QueryDescription qd; + qd.Types.insert("Variable"); + qd.Permission = "variables"; + qd.Provider = new VariableTargetProvider(); + + params->Set("type", "Variable"); + + if (url->GetPath().size() >= 3) + params->Set("variable", url->GetPath()[2]); + + std::vector<Value> objs; + + try { + objs = FilterUtility::GetFilterTargets(qd, params, user, "variable"); + } catch (const std::exception& ex) { + HttpUtility::SendJsonError(response, params, 404, + "No variables found.", + DiagnosticInformation(ex)); + return true; + } + + ArrayData results; + + for (const Dictionary::Ptr& var : objs) { + results.emplace_back(new Dictionary({ + { "name", var->Get("name") }, + { "type", var->Get("type") }, + { "value", Serialize(var->Get("value"), 0) } + })); + } + + Dictionary::Ptr result = new Dictionary({ + { "results", new Array(std::move(results)) } + }); + + response.result(http::status::ok); + HttpUtility::SendJsonBody(response, params, result); + + return true; +} + diff --git a/lib/remote/variablequeryhandler.hpp b/lib/remote/variablequeryhandler.hpp new file mode 100644 index 0000000..48e73be --- /dev/null +++ b/lib/remote/variablequeryhandler.hpp @@ -0,0 +1,30 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef VARIABLEQUERYHANDLER_H +#define VARIABLEQUERYHANDLER_H + +#include "remote/httphandler.hpp" + +namespace icinga +{ + +class VariableQueryHandler final : public HttpHandler +{ +public: + DECLARE_PTR_TYPEDEFS(VariableQueryHandler); + + bool HandleRequest( + AsioTlsStream& stream, + const ApiUser::Ptr& user, + boost::beast::http::request<boost::beast::http::string_body>& request, + const Url::Ptr& url, + boost::beast::http::response<boost::beast::http::string_body>& response, + const Dictionary::Ptr& params, + boost::asio::yield_context& yc, + HttpServerConnection& server + ) override; +}; + +} + +#endif /* VARIABLEQUERYHANDLER_H */ diff --git a/lib/remote/zone.cpp b/lib/remote/zone.cpp new file mode 100644 index 0000000..5ae1468 --- /dev/null +++ b/lib/remote/zone.cpp @@ -0,0 +1,154 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "remote/zone.hpp" +#include "remote/zone-ti.cpp" +#include "remote/jsonrpcconnection.hpp" +#include "base/array.hpp" +#include "base/objectlock.hpp" +#include "base/logger.hpp" + +using namespace icinga; + +REGISTER_TYPE(Zone); + +void Zone::OnAllConfigLoaded() +{ + ObjectImpl<Zone>::OnAllConfigLoaded(); + + m_Parent = Zone::GetByName(GetParentRaw()); + + if (m_Parent && m_Parent->IsGlobal()) + BOOST_THROW_EXCEPTION(ScriptError("Zone '" + GetName() + "' can not have a global zone as parent.", GetDebugInfo())); + + Zone::Ptr zone = m_Parent; + int levels = 0; + + Array::Ptr endpoints = GetEndpointsRaw(); + + if (endpoints) { + ObjectLock olock(endpoints); + for (const String& endpoint : endpoints) { + Endpoint::Ptr ep = Endpoint::GetByName(endpoint); + + if (ep) + ep->SetCachedZone(this); + } + } + + while (zone) { + m_AllParents.push_back(zone); + + zone = Zone::GetByName(zone->GetParentRaw()); + levels++; + + if (levels > 32) + BOOST_THROW_EXCEPTION(ScriptError("Infinite recursion detected while resolving zone graph. Check your zone hierarchy.", GetDebugInfo())); + } +} + +Zone::Ptr Zone::GetParent() const +{ + return m_Parent; +} + +std::set<Endpoint::Ptr> Zone::GetEndpoints() const +{ + std::set<Endpoint::Ptr> result; + + Array::Ptr endpoints = GetEndpointsRaw(); + + if (endpoints) { + ObjectLock olock(endpoints); + + for (const String& name : endpoints) { + Endpoint::Ptr endpoint = Endpoint::GetByName(name); + + if (!endpoint) + continue; + + result.insert(endpoint); + } + } + + return result; +} + +std::vector<Zone::Ptr> Zone::GetAllParentsRaw() const +{ + return m_AllParents; +} + +Array::Ptr Zone::GetAllParents() const +{ + auto result (new Array); + + for (auto& parent : m_AllParents) + result->Add(parent->GetName()); + + return result; +} + +bool Zone::CanAccessObject(const ConfigObject::Ptr& object) +{ + Zone::Ptr object_zone; + + if (object->GetReflectionType() == Zone::TypeInstance) + object_zone = static_pointer_cast<Zone>(object); + else + object_zone = static_pointer_cast<Zone>(object->GetZone()); + + if (!object_zone) + object_zone = Zone::GetLocalZone(); + + if (object_zone->GetGlobal()) + return true; + + return object_zone->IsChildOf(this); +} + +bool Zone::IsChildOf(const Zone::Ptr& zone) +{ + Zone::Ptr azone = this; + + while (azone) { + if (azone == zone) + return true; + + azone = azone->GetParent(); + } + + return false; +} + +bool Zone::IsGlobal() const +{ + return GetGlobal(); +} + +bool Zone::IsSingleInstance() const +{ + Array::Ptr endpoints = GetEndpointsRaw(); + return !endpoints || endpoints->GetLength() < 2; +} + +Zone::Ptr Zone::GetLocalZone() +{ + Endpoint::Ptr local = Endpoint::GetLocalEndpoint(); + + if (!local) + return nullptr; + + return local->GetZone(); +} + +void Zone::ValidateEndpointsRaw(const Lazy<Array::Ptr>& lvalue, const ValidationUtils& utils) +{ + ObjectImpl<Zone>::ValidateEndpointsRaw(lvalue, utils); + + if (lvalue() && lvalue()->GetLength() > 2) { + Log(LogWarning, "Zone") + << "The Zone object '" << GetName() << "' has more than two endpoints." + << " Due to a known issue this type of configuration is strongly" + << " discouraged and may cause Icinga to use excessive amounts of CPU time."; + } +} diff --git a/lib/remote/zone.hpp b/lib/remote/zone.hpp new file mode 100644 index 0000000..897b18e --- /dev/null +++ b/lib/remote/zone.hpp @@ -0,0 +1,46 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#ifndef ZONE_H +#define ZONE_H + +#include "remote/i2-remote.hpp" +#include "remote/zone-ti.hpp" +#include "remote/endpoint.hpp" + +namespace icinga +{ + +/** + * @ingroup remote + */ +class Zone final : public ObjectImpl<Zone> +{ +public: + DECLARE_OBJECT(Zone); + DECLARE_OBJECTNAME(Zone); + + void OnAllConfigLoaded() override; + + Zone::Ptr GetParent() const; + std::set<Endpoint::Ptr> GetEndpoints() const; + std::vector<Zone::Ptr> GetAllParentsRaw() const; + Array::Ptr GetAllParents() const override; + + bool CanAccessObject(const ConfigObject::Ptr& object); + bool IsChildOf(const Zone::Ptr& zone); + bool IsGlobal() const; + bool IsSingleInstance() const; + + static Zone::Ptr GetLocalZone(); + +protected: + void ValidateEndpointsRaw(const Lazy<Array::Ptr>& lvalue, const ValidationUtils& utils) override; + +private: + Zone::Ptr m_Parent; + std::vector<Zone::Ptr> m_AllParents; +}; + +} + +#endif /* ZONE_H */ diff --git a/lib/remote/zone.ti b/lib/remote/zone.ti new file mode 100644 index 0000000..53fd4e6 --- /dev/null +++ b/lib/remote/zone.ti @@ -0,0 +1,25 @@ +/* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + +#include "base/configobject.hpp" + +library remote; + +namespace icinga +{ + +class Zone : ConfigObject +{ + [config, navigation] name(Zone) parent (ParentRaw) { + navigate {{{ + return Zone::GetByName(GetParentRaw()); + }}} + }; + + [config] array(name(Endpoint)) endpoints (EndpointsRaw); + [config] bool global; + [no_user_modify, no_storage] array(Value) all_parents { + get; + }; +}; + +} |