diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root new file mode 120000 index 000000000..945c9b46d --- /dev/null +++ b/_codeql_detected_source_root @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 1f01a8445..88a8ef394 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -17,8 +17,12 @@ #include "rest_service.hpp" +#include +#include #include +#include + #include "error.hpp" #include "mtconnect/configuration/config_options.hpp" #include "mtconnect/entity/xml_parser.hpp" @@ -89,6 +93,10 @@ namespace mtconnect { loadStyle(config, "ErrorStyle", xmlPrinter, &XmlPrinter::setErrorStyle); loadTypes(config); + + // Track initial AllowPut state before loadAllowPut() potentially modifies it + m_initialAllowPut = IsOptionSet(options, config::AllowPut); + loadAllowPut(); m_server->addParameterDocumentation( @@ -118,6 +126,7 @@ namespace mtconnect { createAssetRoutings(); createProbeRoutings(); createPutObservationRoutings(); + createConfigRoutings(); createFileRoutings(); m_server->addCommands(); @@ -435,6 +444,262 @@ namespace mtconnect { session->writeResponse(std::move(response)); } + void RestService::createConfigRoutings() + { + using namespace rest_sink; + using json = nlohmann::json; + + // GET /config - return current configuration + auto getHandler = [this](SessionPtr session, const RequestPtr request) -> bool { + // Build JSON response with agent configuration + json config; + + for (const auto& [key, value] : m_options) + { + // Handle different variant types + std::visit([&config, &key](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) + { + config[key] = nullptr; + } + else if constexpr (std::is_same_v) + { + config[key] = arg; + } + else if constexpr (std::is_same_v) + { + config[key] = arg; + } + else if constexpr (std::is_same_v) + { + config[key] = arg; + } + else if constexpr (std::is_same_v) + { + config[key] = arg; + } + else if constexpr (std::is_same_v) + { + config[key] = arg.count(); + } + else if constexpr (std::is_same_v) + { + config[key] = arg.count(); + } + else if constexpr (std::is_same_v) + { + config[key] = arg; + } + else + { + // If compilation fails here, a new type has been added to ConfigOption + // and needs to be handled above + []() + { + static_assert(flag, "Unhandled type in ConfigOption variant"); + }(); + } + }, value); + } + + ResponsePtr response = make_unique( + rest_sink::status::ok, config.dump(), "application/json"); + respond(session, std::move(response), request->m_requestId); + return true; + }; + + m_server + ->addRouting({boost::beast::http::verb::get, "/config", getHandler}) + .document("Agent configuration request", + "Returns the current agent configuration as JSON") + .command("config"); + + // PUT /config - update configuration (only if AllowPut is enabled) + // Writes updated config to config.json, triggering warm start via file monitoring + if (m_server->arePutsAllowed()) + { + auto putHandler = [this](SessionPtr session, RequestPtr request) -> bool { + try + { + // Parse JSON body + json updates = json::parse(request->m_body); + + // Use the initial AllowPut state from config file (not current runtime state) + // This enforces "if the first config does not allow any, then it wont ever by design" + bool initialAllowPut = m_initialAllowPut; + + // Validate security constraints before updating + std::vector deniedKeys; + + for (auto& [key, value] : updates.items()) + { + // Security check: prevent modification of AllowPut or AllowPutFrom + // if PUTs weren't initially allowed (enforced by design) + if (key == config::AllowPut || key == config::AllowPutFrom) + { + if (!initialAllowPut) + { + deniedKeys.push_back(key); + LOG(warning) << "Denied config update for " << key + << ": AllowPut was not initially enabled"; + } + // Don't allow disabling AllowPut even if initially enabled + else if (key == config::AllowPut && value.is_boolean() && !value.get()) + { + deniedKeys.push_back(key); + LOG(warning) << "Denied config update: cannot disable AllowPut"; + } + } + } + + // If any security constraints were violated, reject the entire request + if (!deniedKeys.empty()) + { + json response; + response["status"] = "error"; + response["denied"] = deniedKeys; + response["message"] = initialAllowPut + ? "Cannot disable AllowPut once enabled" + : "Cannot modify AllowPut/AllowPutFrom when initially disabled"; + + ResponsePtr resp = make_unique( + rest_sink::status::forbidden, response.dump(), "application/json"); + respond(session, std::move(resp), request->m_requestId); + return true; + } + + // Get config directory from ConfigPath (first path is the config file's directory) + std::filesystem::path configDir; + auto configPaths = GetOption(m_options, config::ConfigPath); + + if (configPaths && !configPaths->empty()) + { + // Use the first path which is the config file's parent directory + configDir = configPaths->front(); + } + else + { + // Fallback to current working directory + configDir = std::filesystem::current_path(); + } + + // Build merged configuration (current + updates) + json mergedConfig; + for (const auto& [key, value] : m_options) + { + // Skip internal/runtime keys that shouldn't be persisted + // (ConfigPath is a list of search paths, not a config value to persist) + if (key == config::ConfigPath) + continue; + + std::visit([&mergedConfig, &key](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) + { + // Skip monostate + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg.count(); + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg.count(); + } + else if constexpr (std::is_same_v) + { + mergedConfig[key] = arg; + } + }, value); + } + + // Apply updates to merged config + std::vector updatedKeys; + for (auto& [key, value] : updates.items()) + { + // Update the merged config + mergedConfig[key] = value; + updatedKeys.push_back(key); + } + + // Write to config.json in the config directory + // The agent searches for config.json at startup and file monitor will detect changes + std::filesystem::path jsonConfigPath = configDir / "config.json"; + + std::ofstream configFile(jsonConfigPath); + if (!configFile.is_open()) + { + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + "Failed to open config.json for writing: " + jsonConfigPath.string()); + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + rest_sink::status::internal_server_error); + } + + // Write formatted JSON + configFile << mergedConfig.dump(2); // Pretty print with 2-space indent + configFile.close(); + + LOG(info) << "Updated configuration written to " << jsonConfigPath.string(); + LOG(info) << "File monitoring will detect change and trigger warm start"; + + // Build response + json response; + response["status"] = "ok"; + response["message"] = "Configuration updated. Agent will warm start shortly."; + response["updated"] = updatedKeys; + response["configFile"] = jsonConfigPath.string(); + + ResponsePtr resp = make_unique( + rest_sink::status::ok, response.dump(), "application/json"); + respond(session, std::move(resp), request->m_requestId); + return true; + } + catch (const json::parse_error& e) + { + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + "Invalid JSON in request body: "s + e.what()); + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + rest_sink::status::bad_request); + } + catch (const RestError&) + { + throw; // Re-throw RestError as-is + } + catch (const std::exception& e) + { + auto error = Error::make(Error::ErrorCode::INVALID_REQUEST, + "Failed to update configuration: "s + e.what()); + throw RestError(error, getPrinter(request->m_accepts, std::nullopt), + rest_sink::status::internal_server_error); + } + }; + + m_server + ->addRouting({boost::beast::http::verb::put, "/config", putHandler}) + .document("Update agent configuration", + "Updates configuration by writing to config.json, triggering warm start. " + "Requires AllowPut to be enabled. " + "Cannot modify AllowPut or AllowPutFrom if initially disabled."); + } + } + void RestService::createFileRoutings() { using namespace rest_sink; diff --git a/src/mtconnect/sink/rest_sink/rest_service.hpp b/src/mtconnect/sink/rest_sink/rest_service.hpp index 17437f2fb..bdbc50451 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.hpp +++ b/src/mtconnect/sink/rest_sink/rest_service.hpp @@ -284,6 +284,7 @@ namespace mtconnect { ///@{ auto instanceId() const { return m_instanceId; } void setInstanceId(uint64_t id) { m_instanceId = id; } + void setInitialAllowPut(bool allow) { m_initialAllowPut = allow; } ///@} protected: @@ -346,6 +347,8 @@ namespace mtconnect { void createAssetRoutings(); + void createConfigRoutings(); + // Current Data Collection std::string fetchCurrentData(const printer::Printer *printer, const FilterSetOpt &filterSet, const std::optional &at, bool pretty = false, @@ -382,6 +385,9 @@ namespace mtconnect { // Buffers FileCache m_fileCache; bool m_logStreamData {false}; + + // Track initial AllowPut state from config file (for security enforcement) + bool m_initialAllowPut {false}; }; } // namespace sink::rest_sink } // namespace mtconnect diff --git a/test_package/agent_test.cpp b/test_package/agent_test.cpp index 5039741e1..a6fd87505 100644 --- a/test_package/agent_test.cpp +++ b/test_package/agent_test.cpp @@ -29,6 +29,8 @@ #include #include +#include + #include "agent_test_helper.hpp" #include "mtconnect/agent.hpp" #include "mtconnect/asset/file_asset.hpp" @@ -2622,3 +2624,125 @@ TEST_F(AgentTest, should_initialize_observaton_to_initial_value_when_available) ASSERT_XML_PATH_EQUAL(doc, "//m:DeviceStream//m:PartCount", "0"); } } + +TEST_F(AgentTest, should_return_agent_configuration) +{ + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false); + + // Test the /config endpoint + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::get); + + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_TRUE(routing); + + routing->m_handler(session, std::move(request)); + + // Verify response is JSON + ASSERT_EQ(session->m_mimeType, "application/json"); + ASSERT_EQ(session->m_code, boost::beast::http::status::ok); + + // Parse JSON response + auto json = nlohmann::json::parse(session->m_body); + + // Verify key configuration values are present + ASSERT_TRUE(json.contains("Port")); + ASSERT_TRUE(json.contains("BufferSize")); + ASSERT_TRUE(json.contains("SchemaVersion")); +} + +TEST_F(AgentTest, should_reject_put_config_when_puts_not_allowed) +{ + // Create agent without AllowPut enabled + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false); + + // Test PUT /config endpoint when PUTs not allowed + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::put); + + // PUT routing should not exist when AllowPut is disabled + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_FALSE(routing); +} + +TEST_F(AgentTest, should_reject_security_setting_modifications_when_puts_not_initially_allowed) +{ + ConfigOptions options; + options[configuration::AllowPut] = true; + options[configuration::AllowPutFrom] = "127.0.0.1"s; + + // Create agent with AllowPut enabled + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false, true, options); + + // Simulate that AllowPut was NOT initially enabled in config file + auto restService = dynamic_cast(m_agentTestHelper->getAgent()->findSink("RestService")); + ASSERT_TRUE(restService); + restService->setInitialAllowPut(false); + + // Try to modify AllowPut or AllowPutFrom + nlohmann::json updates; + updates["AllowPut"] = false; + updates["Port"] = 5002; + + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::put); + request->m_body = updates.dump(); + + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_TRUE(routing); + + routing->m_handler(session, std::move(request)); + + // Verify response indicates denied keys + ASSERT_EQ(session->m_mimeType, "application/json"); + ASSERT_EQ(session->m_code, boost::beast::http::status::forbidden); + + auto json = nlohmann::json::parse(session->m_body); + ASSERT_EQ(json["status"], "error"); + ASSERT_TRUE(json.contains("denied")); +} + +TEST_F(AgentTest, should_update_config_and_write_json_file) +{ + ConfigOptions options; + options[configuration::AllowPut] = true; + options[configuration::AllowPutFrom] = "127.0.0.1"s; + + // Create agent with AllowPut enabled + m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.6", 4, false, true, options); + + // Update some config values + nlohmann::json updates; + updates["Port"] = 5555; + updates["BufferSize"] = 20; + + auto session = m_agentTestHelper->m_server->createSession(m_agentTestHelper->m_context); + auto request = make_unique("/config", boost::beast::http::verb::put); + request->m_body = updates.dump(); + + auto routing = m_agentTestHelper->m_server->getRouting(request->m_path, request->m_verb); + ASSERT_TRUE(routing); + + routing->m_handler(session, std::move(request)); + + // Verify response is successful + ASSERT_EQ(session->m_mimeType, "application/json"); + ASSERT_EQ(session->m_code, boost::beast::http::status::ok); + + auto json = nlohmann::json::parse(session->m_body); + ASSERT_EQ(json["status"], "ok"); + ASSERT_TRUE(json.contains("updated")); + ASSERT_TRUE(json.contains("configFile")); + + // Verify config.json was written + std::string configFilePath = json["configFile"]; + ASSERT_FALSE(configFilePath.empty()); + + // Read and verify the config file exists + std::ifstream configFile(configFilePath); + ASSERT_TRUE(configFile.is_open()); + + nlohmann::json savedConfig = nlohmann::json::parse(configFile); + ASSERT_EQ(savedConfig["Port"], 5555); + ASSERT_EQ(savedConfig["BufferSize"], 20); +}