diff --git a/.gitmodules b/.gitmodules index e3b8fe19d..bb945a793 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,7 +13,9 @@ [submodule "third_party/googletest"] path = third_party/googletest url = https://github.com/google/googletest.git +[submodule "third_party/spdlog"] + path = third_party/spdlog + url = https://github.com/gabime/spdlog.git [submodule "third_party/ni-apis"] path = third_party/ni-apis - url = https://github.com/ni/ni-apis - \ No newline at end of file + url = https://github.com/ni/ni-apis \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 51aa54b5c..b91844544 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,9 @@ if(USE_SUBMODULE_LIBS) add_subdirectory(third_party/json ${CMAKE_CURRENT_BINARY_DIR}/json) add_subdirectory(third_party/utfcpp ${CMAKE_CURRENT_BINARY_DIR}/utfcpp) add_subdirectory(third_party/grpc-sideband ${CMAKE_CURRENT_BINARY_DIR}/grpc-sideband) + if(WIN32) + add_subdirectory(third_party/spdlog ${CMAKE_CURRENT_BINARY_DIR}/spdlog EXCLUDE_FROM_ALL) + endif() set(_PROTOBUF_PROTOC $) set(_REFLECTION grpc++_reflection) @@ -80,6 +83,9 @@ if(USE_SUBMODULE_LIBS) set(_PROTOBUF_LIBPROTOBUF libprotobuf) set(_UTF8CPP utf8cpp) set(_GRPC_SIDEBAND ni_grpc_sideband) + if(WIN32) + set(_SPDLOG spdlog::spdlog) + endif() else() find_program(_PROTOBUF_PROTOC protoc) find_program(_GRPC_CPP_PLUGIN_EXECUTABLE grpc_cpp_plugin) @@ -89,6 +95,7 @@ else() if(USE_NILRT_LEGACY_TOOLCHAIN) # The archetypical NILRT SDK toolchain build case add_subdirectory(third_party/grpc ${CMAKE_CURRENT_BINARY_DIR}/grpc EXCLUDE_FROM_ALL) + set(_REFLECTION grpc++_reflection) set(_GRPC_GRPCPP grpc++) set(_PROTOBUF_LIBPROTOBUF libprotobuf) @@ -548,6 +555,7 @@ add_executable(ni_grpc_device_server "imports/include/nierr_Status.cpp" "source/server/calibration_operations_restricted_service_registrar.cpp" "source/server/calibration_operations_restricted_service.cpp" + "source/server/client_connection_logger.cpp" "source/server/core_server.cpp" "source/server/core_services_registrar.cpp" "source/server/data_moniker_service.cpp" @@ -657,6 +665,7 @@ set(server_lib_deps ${_UTF8CPP} ${CMAKE_DL_LIBS} ${_GRPC_SIDEBAND} + ${_SPDLOG} nlohmann_json::nlohmann_json ) @@ -830,10 +839,13 @@ add_executable(UnitTestsRunner "source/tests/unit/xnet_converters_tests.cpp" "source/tests/unit/xnet_socket_converters_tests.cpp" "source/tests/unit/tls_config_loader_tests.cpp" + "source/tests/unit/client_connection_logger_tests.cpp" "source/server/calibration_operations_restricted_service.cpp" + "source/server/client_connection_logger.cpp" "source/server/debug_session_properties_restricted_service.cpp" "source/server/device_enumerator.cpp" "source/server/feature_toggles.cpp" + "source/server/logging.cpp" "source/server/tls_config_loader.cpp" "source/server/semaphore.cpp" "source/server/server_configuration_parser.cpp" @@ -898,6 +910,11 @@ target_include_directories(UnitTestsRunner PRIVATE "${service_output_dir}/nifake_non_ivi" PRIVATE "source/server") +if(CMAKE_SYSTEM_NAME STREQUAL Linux) + target_sources(UnitTestsRunner + PRIVATE "source/server/linux/syslog_logging.cpp") +endif() + target_link_libraries(UnitTestsRunner ${CMAKE_DL_LIBS} ${server_lib_deps} diff --git a/README.md b/README.md index a238ac8a0..3c854dd42 100644 --- a/README.md +++ b/README.md @@ -191,3 +191,17 @@ With this setting the server reads TLS configuration at startup from the per-ser If `"security": "ni-tls-config"` is configured but the `ni-tls-config` library is not installed, the server logs an error and exits instead of starting insecurely. Once `ni-tls-config` is enabled, use NI Hardware Configuration Utility on each client machine to configure the desired security settings. + +### Audit Logging + +The server logs audit messages to the Windows Event Log and Linux Syslog. On Windows, events are logged from the `ni-grpc-device-server` source. These events will be automatically placed under the default Application log (seen under the Windows folder in the Event Viewer app). It is recommended to add the following registry key to properly register it as a source: + +`HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\EventLog\National Instruments\ni-grpc-device-server` + +Then add the following values: + - `EventMessageFile (REG_EXPAND_SZ): %systemroot%\System32\mscoree.dll` + - `TypesSupported (REG_DWORD): 7` + +This recommendation is taken directly from [spdlog's documentation](https://github.com/gabime/spdlog/blob/v1.x/include/spdlog/sinks/win_eventlog_sink.h). + +NI's official installer for `grpc-device` automatically sets this registry key. This installer is included with installations of InstrumentStudio, several MI drivers, and more. diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 28b8645c0..24361843f 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -43,6 +43,55 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +****** spdlog (https://github.com/gabime/spdlog) ****** + +The MIT License (MIT) + +Copyright (c) 2016 - present, Gabi Melman and spdlog contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +-- NOTE: Third party dependency used by this software -- +This software depends on the fmt lib (MIT License), +and users must comply to its license: + +Copyright (c) 2012 - present, Victor Zverovich and {fmt} contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ****** utfcpp (https://github.com/nemtrif/utfcpp) ****** Boost Software License - Version 1.0 - August 17th, 2003 diff --git a/source/server/client_connection_logger.cpp b/source/server/client_connection_logger.cpp new file mode 100644 index 000000000..8f23a5a1b --- /dev/null +++ b/source/server/client_connection_logger.cpp @@ -0,0 +1,103 @@ +#include "client_connection_logger.h" +#include "logging.h" + +#include + +namespace nidevice_grpc { + +namespace { + +std::string describe_authentication(const grpc::AuthContext& auth_context) +{ + if (!auth_context.IsPeerAuthenticated()) + return "Unauthenticated"; + + const auto transport_type = auth_context.FindPropertyValues(GRPC_TRANSPORT_SECURITY_TYPE_PROPERTY_NAME); + const auto common_names = auth_context.FindPropertyValues(GRPC_X509_CN_PROPERTY_NAME); + + std::string description = "Authenticated via "; + description += transport_type.empty() ? "unknown transport" : std::string(transport_type[0].data(), transport_type[0].size()); + + if (!common_names.empty()) + description += ", client cert CN:" + std::string(common_names[0].data(), common_names[0].size()); + + return description; +} + +} + +bool parse_peer(const std::string& peer, std::string& ip, std::string& port) +{ + if (peer.rfind("ipv4:", 0) != 0 && peer.rfind("ipv6:", 0) != 0) + return false; + + const auto scheme_end = peer.find(':'); + const auto addr_start = scheme_end + 1; + if (scheme_end == std::string::npos) + return false; + + // Find the start of the port; IPv6 addresses are enclosed in brackets, so we need to find the closing bracket first. + size_t port_pos; + if (peer[addr_start] == '[') { + const auto bracket_end = peer.find(']', addr_start); + if (bracket_end == std::string::npos || bracket_end == addr_start + 1) + return false; + port_pos = bracket_end + 1; + if (port_pos >= peer.size() || peer[port_pos] != ':') + return false; + } + else { + port_pos = peer.rfind(':'); + if (port_pos <= scheme_end) + return false; + } + + ip = peer.substr(addr_start, port_pos - addr_start); + port = peer.substr(port_pos + 1); + + return !ip.empty() && !port.empty(); +} + +void ClientConnectionLogger::PreSynchronousRequest(grpc::ServerContext* context) +{ + std::string ip, port; + const auto peer = context->peer(); + const bool parsed = parse_peer(peer, ip, port); + + // Only log the first connection seen from a given IP. + { + const auto& key = parsed ? ip : peer; + std::lock_guard lock(seen_ips_mutex_); + if (!seen_ips_.insert(key).second) + return; + + // If the client cache is full, evict the oldest entry. + seen_ips_order_.push_back(key); + if (seen_ips_order_.size() > kMaxSeenIps) { + seen_ips_.erase(seen_ips_order_.front()); + seen_ips_order_.pop_front(); + } + } + + const auto auth_description = describe_authentication(*context->auth_context()); + + // Try to parse the IP + Port out of the peer uri so that the log message can be formatted nicely. If it fails, just log the raw string. + if (parsed) + nidevice_grpc::logging::log_to_audit_source(nidevice_grpc::logging::Level_Info, "Remote client successfully connected from %s:%s (%s)", ip.c_str(), port.c_str(), auth_description.c_str()); + else + nidevice_grpc::logging::log_to_audit_source(nidevice_grpc::logging::Level_Info, "Remote client successfully connected from %s (%s)", peer.c_str(), auth_description.c_str()); +} + +void ClientConnectionLogger::PostSynchronousRequest(grpc::ServerContext*) +{ +} + +void register_client_connection_logger() +{ + // gRPC stores this in an owning shared_ptr (see Server::SetGlobalCallbacks inserver_cc.cc) and deletes it at static destruction. Even + // if that changes to non-owning, intentionally leaking one process-wide object is correct and avoids a static-destruction-order hazard + // against grpc::Server. + grpc::Server::SetGlobalCallbacks(new ClientConnectionLogger()); +} + +} // namespace nidevice_grpc diff --git a/source/server/client_connection_logger.h b/source/server/client_connection_logger.h new file mode 100644 index 000000000..18e504a12 --- /dev/null +++ b/source/server/client_connection_logger.h @@ -0,0 +1,38 @@ +#ifndef NIDEVICE_GRPC_CLIENT_CONNECTION_LOGGER_H +#define NIDEVICE_GRPC_CLIENT_CONNECTION_LOGGER_H + +#include + +#include +#include +#include +#include +#include + +namespace nidevice_grpc { + +// Parses a gRPC peer URI (e.g. "ipv4:127.0.0.1:12345" or "ipv6:[::1]:12345") into its ip and port components. +// Returns false if the peer string doesn't match a recognized format. +bool parse_peer(const std::string& peer, std::string& ip, std::string& port); + +// Implementation of callbacks that the grpc::Server will invoke for individual client RPC calls. +class ClientConnectionLogger : public grpc::Server::GlobalCallbacks { + public: + void PreSynchronousRequest(grpc::ServerContext* context) override; + void PostSynchronousRequest(grpc::ServerContext* context) override; + + private: + // We cache a set number of seen clients to avoid noise from repeated calls + static constexpr std::size_t kMaxSeenIps = 3000; + + std::mutex seen_ips_mutex_; + std::unordered_set seen_ips_; + std::deque seen_ips_order_; +}; + +// Registers a process-wide ClientConnectionLogger with gRPC, must be called before any grpc::Server is built. +void register_client_connection_logger(); + +} // namespace nidevice_grpc + +#endif // NIDEVICE_GRPC_CLIENT_CONNECTION_LOGGER_H diff --git a/source/server/core_server.cpp b/source/server/core_server.cpp index 327b5f1b9..69e32d83b 100644 --- a/source/server/core_server.cpp +++ b/source/server/core_server.cpp @@ -7,6 +7,7 @@ #include #include +#include "client_connection_logger.h" #include "feature_toggles.h" #include "logging.h" #include "tls_config_loader.h" @@ -99,6 +100,8 @@ static void RunServer(const ServerConfiguration& config) config.config_file_path.c_str()); } + nidevice_grpc::register_client_connection_logger(); + grpc::EnableDefaultHealthCheckService(true); grpc::reflection::InitProtoReflectionServerBuilderPlugin(); @@ -310,8 +313,9 @@ int main(int argc, char** argv) auto config = GetConfiguration(options.config_file_path); setlocale(LC_ALL, ""); #if defined(__GNUC__) + // syslog is always needed for audit logging even when general output goes to the terminal + nidevice_grpc::logging::setup_syslog(options.daemonize, options.identity); if (options.use_syslog) { - nidevice_grpc::logging::setup_syslog(options.daemonize, options.identity); nidevice_grpc::logging::set_logger(&nidevice_grpc::logging::log_syslog); } diff --git a/source/server/logging.cpp b/source/server/logging.cpp index 6e8a082cb..3b5672c44 100644 --- a/source/server/logging.cpp +++ b/source/server/logging.cpp @@ -2,6 +2,16 @@ #include #include +#include + +#if defined(_WIN32) + #include + + #include + #include +#else + #include "linux/syslog_logging.h" +#endif namespace nidevice_grpc { namespace logging { @@ -37,5 +47,67 @@ void log(Level level, const char* fmt, ...) va_end(args); } +#if defined(_WIN32) +namespace { + +std::shared_ptr get_event_log_logger() +{ + static std::shared_ptr audit_logger = []() { + auto sink = std::make_shared("ni-grpc-device-server"); + auto logger = std::make_shared("Server", sink); + logger->set_pattern("[ni-grpc-device-server][%n] %v"); + return logger; + }(); + return audit_logger; +} + +std::string format_message(const char* fmt, va_list args) +{ + va_list args_copy; + va_copy(args_copy, args); + const int size = std::vsnprintf(nullptr, 0, fmt, args_copy); + va_end(args_copy); + if (size <= 0) { + return std::string(); + } + std::string result(static_cast(size), '\0'); + std::vsnprintf(&result[0], result.size() + 1, fmt, args); + return result; +} + +} // namespace +#endif + +void log_to_audit_source(Level level, const char* fmt, ...) +{ +#if defined(_WIN32) + va_list args; + va_start(args, fmt); + const std::string message = format_message(fmt, args); + va_end(args); + + auto audit_logger = get_event_log_logger(); + switch (level) { + case Level_Info: + audit_logger->info("{}", message); + break; + case Level_Warning: + audit_logger->warn("{}", message); + break; + case Level_Error: + audit_logger->error("{}", message); + break; + } +#else + // We're manually prefixing audit logs in the Windows spdlog path, so manually add it here on the Linux path before sending it over to syslog. + const std::string message = "[ni-grpc-device-server][Server] " + std::string(fmt); + + va_list args; + va_start(args, fmt); + log_syslog(level, message.c_str(), args); + va_end(args); +#endif +} + } // namespace logging } // namespace nidevice_grpc diff --git a/source/server/logging.h b/source/server/logging.h index a95d6aa79..76b7b77be 100644 --- a/source/server/logging.h +++ b/source/server/logging.h @@ -18,6 +18,9 @@ typedef void (*log_fn_impl)(Level level, const char* fmt, va_list args); void set_logger(log_fn_impl impl); void log(Level level, const char* fmt, ...); +// Special log option that routes to the platform audit log (Windows Event Log or Syslog) +void log_to_audit_source(Level level, const char* fmt, ...); + } // namespace logging } // namespace nidevice_grpc diff --git a/source/tests/unit/client_connection_logger_tests.cpp b/source/tests/unit/client_connection_logger_tests.cpp new file mode 100644 index 000000000..628ff58a0 --- /dev/null +++ b/source/tests/unit/client_connection_logger_tests.cpp @@ -0,0 +1,112 @@ +#include +#include + +namespace ni { +namespace tests { +namespace unit { + +namespace { +::testing::AssertionResult parses_to(const std::string& peer, const std::string& expected_ip, const std::string& expected_port) +{ + std::string ip; + std::string port; + if (!nidevice_grpc::parse_peer(peer, ip, port)) { + return ::testing::AssertionFailure() << "parse_peer(\"" << peer << "\") returned false"; + } + if (ip != expected_ip || port != expected_port) { + return ::testing::AssertionFailure() + << "parse_peer(\"" << peer << "\") returned ip=\"" << ip << "\", port=\"" << port + << "\", expected ip=\"" << expected_ip << "\", port=\"" << expected_port << "\""; + } + return ::testing::AssertionSuccess(); +} +} // namespace + +TEST(ClientConnectionLoggerTests, Ipv4Peer_ParsePeer_ReturnsTrueAndSplitsIpAndPort) +{ + EXPECT_TRUE(parses_to("ipv4:127.0.0.1:12345", "127.0.0.1", "12345")); +} + +TEST(ClientConnectionLoggerTests, Ipv6Peer_ParsePeer_ReturnsTrueAndSplitsIpAndPort) +{ + EXPECT_TRUE(parses_to("ipv6:[::1]:12345", "[::1]", "12345")); +} + +TEST(ClientConnectionLoggerTests, Ipv6PeerWithFullAddress_ParsePeer_ReturnsTrueAndSplitsIpAndPort) +{ + EXPECT_TRUE(parses_to("ipv6:[2001:db8::1]:443", "[2001:db8::1]", "443")); +} + +TEST(ClientConnectionLoggerTests, UnrecognizedScheme_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("unix:/tmp/socket", ip, port)); +} + +TEST(ClientConnectionLoggerTests, EmptyPeer_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv4MissingPort_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv4:127.0.0.1", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv4EmptyPort_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv4:127.0.0.1:", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv4EmptyIp_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv4::12345", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv4SchemeOnly_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv4:", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv6MissingPort_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv6:[::1]", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv6EmptyPort_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv6:[::1]:", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv6EmptyIp_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv6:[]:12345", ip, port)); +} + +TEST(ClientConnectionLoggerTests, IPv6SchemeOnly_ParsePeer_ReturnsFalse) +{ + std::string ip, port; + + EXPECT_FALSE(nidevice_grpc::parse_peer("ipv6:", ip, port)); +} + +} // namespace unit +} // namespace tests +} // namespace ni diff --git a/third_party/spdlog b/third_party/spdlog new file mode 160000 index 000000000..79524ddd0 --- /dev/null +++ b/third_party/spdlog @@ -0,0 +1 @@ +Subproject commit 79524ddd08a4ec981b7fea76afd08ee05f83755d