From 212216b101e3907a0151776287e6e01eb6a14aa0 Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Fri, 27 Mar 2026 22:16:11 +0100 Subject: [PATCH] initial commit --- .gitignore | 26 + java-api/README.md | 58 +++ java-api/pom.xml | 65 +++ java-api/schema.sql | 17 + .../java/cz/kamma/processmonitor/Main.java | 212 ++++++++ .../java/local/processmonitor/api/Main.java | 212 ++++++++ .../src/main/resources/application.properties | 8 + service/CMakeLists.txt | 19 + service/README.md | 91 ++++ service/process-monitor.conf | 8 + service/src/main.cpp | 465 ++++++++++++++++++ 11 files changed, 1181 insertions(+) create mode 100644 .gitignore create mode 100644 java-api/README.md create mode 100644 java-api/pom.xml create mode 100644 java-api/schema.sql create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/Main.java create mode 100644 java-api/src/main/java/local/processmonitor/api/Main.java create mode 100644 java-api/src/main/resources/application.properties create mode 100644 service/CMakeLists.txt create mode 100644 service/README.md create mode 100644 service/process-monitor.conf create mode 100644 service/src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2860bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.classpath +.project +.settings/ + +# OS +.DS_Store +Thumbs.db +build \ No newline at end of file diff --git a/java-api/README.md b/java-api/README.md new file mode 100644 index 0000000..6d415e0 --- /dev/null +++ b/java-api/README.md @@ -0,0 +1,58 @@ +# Process Monitor API (Java 11) + +Small Java 11 application that exposes `POST /hb/api` using `com.sun.net.httpserver.HttpServer` +and stores received heartbeat records into MariaDB. JSON parsing uses `Gson` +and the app logs incoming requests plus insert results to stdout. + +## Request shape + +Expected JSON body: + +```json +{ + "machine_name": "PC-01", + "status": "running", + "detected_at": "2026-03-27T21:00:00Z", + "processes": ["chrome.exe", "discord.exe"] +} +``` + +If `processes` is omitted, the application still stores one row with `process_name = NULL`. + +## Table DDL + +```sql +CREATE DATABASE IF NOT EXISTS process_monitor + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE process_monitor; + +CREATE TABLE IF NOT EXISTS process_heartbeat ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + machine_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + detected_at DATETIME(3) NOT NULL, + process_name VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_process_heartbeat_machine_detected (machine_name, detected_at), + KEY idx_process_heartbeat_process_detected (process_name, detected_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +## Configure + +Edit `src/main/resources/application.properties`. + +## Run + +```powershell +mvn exec:java +``` + +## Package + +```powershell +mvn package +``` diff --git a/java-api/pom.xml b/java-api/pom.xml new file mode 100644 index 0000000..dfea96b --- /dev/null +++ b/java-api/pom.xml @@ -0,0 +1,65 @@ + + 4.0.0 + + cz.kamma.processmonitor + process-monitor-api + 0.1.0 + + 11 + 11 + UTF-8 + + + + + com.google.code.gson + gson + 2.13.1 + + + org.mariadb.jdbc + mariadb-java-client + 3.5.3 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + cz.kamma.processmonitor.Main + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + process-monitor-api + + + cz.kamma.processmonitor.Main + + + + + + + + + diff --git a/java-api/schema.sql b/java-api/schema.sql new file mode 100644 index 0000000..ee591ca --- /dev/null +++ b/java-api/schema.sql @@ -0,0 +1,17 @@ +CREATE DATABASE IF NOT EXISTS process_monitor + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE process_monitor; + +CREATE TABLE IF NOT EXISTS process_heartbeat ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + machine_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + detected_at DATETIME(3) NOT NULL, + process_name VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_process_heartbeat_machine_detected (machine_name, detected_at), + KEY idx_process_heartbeat_process_detected (process_name, detected_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/java-api/src/main/java/cz/kamma/processmonitor/Main.java b/java-api/src/main/java/cz/kamma/processmonitor/Main.java new file mode 100644 index 0000000..1e8a74d --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/Main.java @@ -0,0 +1,212 @@ +package cz.kamma.processmonitor; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +public class Main { + private static final Gson GSON = new Gson(); + + public static void main(String[] args) throws Exception { + AppConfig config = AppConfig.load(); + Database database = new Database(config); + HttpServer server = HttpServer.create(new InetSocketAddress(config.listenHost, config.listenPort), 0); + server.createContext(config.apiPath, new HeartbeatHandler(database)); + server.setExecutor(null); + server.start(); + + log("Listening on http://" + config.listenHost + ":" + config.listenPort + config.apiPath); + } + + private static final class HeartbeatHandler implements HttpHandler { + private final Database database; + + private HeartbeatHandler(Database database) { + this.database = database; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 405, "{\"error\":\"method_not_allowed\"}"); + return; + } + + Headers headers = exchange.getRequestHeaders(); + String contentType = headers.getFirst("Content-Type"); + if (contentType == null || !contentType.toLowerCase().contains("application/json")) { + sendJson(exchange, 415, "{\"error\":\"unsupported_media_type\"}"); + return; + } + + String body = readBody(exchange.getRequestBody()); + log("Incoming heartbeat request: " + body); + HeartbeatRequest request = HeartbeatRequest.parse(body); + int insertedRows = database.saveHeartbeat(request); + log("Stored heartbeat for machine " + request.machineName + ", inserted rows: " + insertedRows); + sendJson(exchange, 200, String.format("{\"ok\":true,\"inserted\":%d}", insertedRows)); + } catch (IllegalArgumentException ex) { + log("Request validation failed: " + ex.getMessage()); + sendJson(exchange, 400, "{\"error\":\"" + escapeJson(ex.getMessage()) + "\"}"); + } catch (Exception ex) { + ex.printStackTrace(); + log("Internal server error: " + ex.getMessage()); + sendJson(exchange, 500, "{\"error\":\"internal_error\"}"); + } + } + + private static String readBody(InputStream inputStream) throws IOException { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + + private static void sendJson(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(bytes); + } + } + } + + private static final class AppConfig { + private final String listenHost; + private final int listenPort; + private final String apiPath; + private final String jdbcUrl; + private final String dbUser; + private final String dbPassword; + + private AppConfig(String listenHost, int listenPort, String apiPath, String jdbcUrl, String dbUser, String dbPassword) { + this.listenHost = listenHost; + this.listenPort = listenPort; + this.apiPath = apiPath; + this.jdbcUrl = jdbcUrl; + this.dbUser = dbUser; + this.dbPassword = dbPassword; + } + + private static AppConfig load() throws IOException { + Properties properties = new Properties(); + try (InputStream inputStream = Main.class.getClassLoader().getResourceAsStream("application.properties")) { + if (inputStream == null) { + throw new IOException("Missing application.properties"); + } + properties.load(inputStream); + } + + String listenHost = properties.getProperty("server.host", "0.0.0.0"); + int listenPort = Integer.parseInt(properties.getProperty("server.port", "8080")); + String apiPath = properties.getProperty("server.path", "/hb/api"); + String jdbcUrl = required(properties, "db.url"); + String dbUser = required(properties, "db.user"); + String dbPassword = required(properties, "db.password"); + return new AppConfig(listenHost, listenPort, apiPath, jdbcUrl, dbUser, dbPassword); + } + + private static String required(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing config key: " + key); + } + return value.trim(); + } + } + + private static final class Database { + private final AppConfig config; + + private Database(AppConfig config) { + this.config = config; + } + + private int saveHeartbeat(HeartbeatRequest request) throws SQLException { + List processes = request.processes.isEmpty() ? Collections.singletonList(null) : request.processes; + String sql = "INSERT INTO process_heartbeat (machine_name, status, detected_at, process_name) VALUES (?, ?, ?, ?)"; + + try (Connection connection = DriverManager.getConnection(config.jdbcUrl, config.dbUser, config.dbPassword); + PreparedStatement statement = connection.prepareStatement(sql)) { + int inserted = 0; + for (String processName : processes) { + statement.setString(1, request.machineName); + statement.setString(2, request.status); + statement.setTimestamp(3, Timestamp.from(request.detectedAt)); + statement.setString(4, processName); + inserted += statement.executeUpdate(); + } + return inserted; + } + } + } + + private static final class HeartbeatRequest { + private String machine_name; + private String status; + private String detected_at; + private List processes; + + private transient String machineName; + private transient Instant detectedAt; + + private static HeartbeatRequest parse(String json) { + try { + HeartbeatRequest request = GSON.fromJson(json, HeartbeatRequest.class); + if (request == null) { + throw new IllegalArgumentException("Empty request body"); + } + request.validate(); + return request; + } catch (JsonSyntaxException ex) { + throw new IllegalArgumentException("Invalid JSON"); + } + } + + private void validate() { + machineName = required(machine_name, "machine_name"); + status = required(status, "status"); + try { + detectedAt = Instant.parse(required(detected_at, "detected_at")); + } catch (DateTimeParseException ex) { + throw new IllegalArgumentException("Invalid detected_at"); + } + if (processes == null) { + processes = Collections.emptyList(); + } + } + + private static String required(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing field: " + fieldName); + } + return value.trim(); + } + } + + private static void log(String message) { + System.out.printf("[%s] %s%n", Instant.now(), message); + } + + private static String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/java-api/src/main/java/local/processmonitor/api/Main.java b/java-api/src/main/java/local/processmonitor/api/Main.java new file mode 100644 index 0000000..ff434a4 --- /dev/null +++ b/java-api/src/main/java/local/processmonitor/api/Main.java @@ -0,0 +1,212 @@ +package local.processmonitor.api; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +public class Main { + private static final Gson GSON = new Gson(); + + public static void main(String[] args) throws Exception { + AppConfig config = AppConfig.load(); + Database database = new Database(config); + HttpServer server = HttpServer.create(new InetSocketAddress(config.listenHost, config.listenPort), 0); + server.createContext(config.apiPath, new HeartbeatHandler(database)); + server.setExecutor(null); + server.start(); + + log("Listening on http://" + config.listenHost + ":" + config.listenPort + config.apiPath); + } + + private static final class HeartbeatHandler implements HttpHandler { + private final Database database; + + private HeartbeatHandler(Database database) { + this.database = database; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 405, "{\"error\":\"method_not_allowed\"}"); + return; + } + + Headers headers = exchange.getRequestHeaders(); + String contentType = headers.getFirst("Content-Type"); + if (contentType == null || !contentType.toLowerCase().contains("application/json")) { + sendJson(exchange, 415, "{\"error\":\"unsupported_media_type\"}"); + return; + } + + String body = readBody(exchange.getRequestBody()); + log("Incoming heartbeat request: " + body); + HeartbeatRequest request = HeartbeatRequest.parse(body); + int insertedRows = database.saveHeartbeat(request); + log("Stored heartbeat for machine " + request.machineName + ", inserted rows: " + insertedRows); + sendJson(exchange, 200, String.format("{\"ok\":true,\"inserted\":%d}", insertedRows)); + } catch (IllegalArgumentException ex) { + log("Request validation failed: " + ex.getMessage()); + sendJson(exchange, 400, "{\"error\":\"" + escapeJson(ex.getMessage()) + "\"}"); + } catch (Exception ex) { + ex.printStackTrace(); + log("Internal server error: " + ex.getMessage()); + sendJson(exchange, 500, "{\"error\":\"internal_error\"}"); + } + } + + private static String readBody(InputStream inputStream) throws IOException { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + + private static void sendJson(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(bytes); + } + } + } + + private static final class AppConfig { + private final String listenHost; + private final int listenPort; + private final String apiPath; + private final String jdbcUrl; + private final String dbUser; + private final String dbPassword; + + private AppConfig(String listenHost, int listenPort, String apiPath, String jdbcUrl, String dbUser, String dbPassword) { + this.listenHost = listenHost; + this.listenPort = listenPort; + this.apiPath = apiPath; + this.jdbcUrl = jdbcUrl; + this.dbUser = dbUser; + this.dbPassword = dbPassword; + } + + private static AppConfig load() throws IOException { + Properties properties = new Properties(); + try (InputStream inputStream = Main.class.getClassLoader().getResourceAsStream("application.properties")) { + if (inputStream == null) { + throw new IOException("Missing application.properties"); + } + properties.load(inputStream); + } + + String listenHost = properties.getProperty("server.host", "0.0.0.0"); + int listenPort = Integer.parseInt(properties.getProperty("server.port", "8080")); + String apiPath = properties.getProperty("server.path", "/hb/api"); + String jdbcUrl = required(properties, "db.url"); + String dbUser = required(properties, "db.user"); + String dbPassword = required(properties, "db.password"); + return new AppConfig(listenHost, listenPort, apiPath, jdbcUrl, dbUser, dbPassword); + } + + private static String required(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing config key: " + key); + } + return value.trim(); + } + } + + private static final class Database { + private final AppConfig config; + + private Database(AppConfig config) { + this.config = config; + } + + private int saveHeartbeat(HeartbeatRequest request) throws SQLException { + List processes = request.processes.isEmpty() ? Collections.singletonList(null) : request.processes; + String sql = "INSERT INTO process_heartbeat (machine_name, status, detected_at, process_name) VALUES (?, ?, ?, ?)"; + + try (Connection connection = DriverManager.getConnection(config.jdbcUrl, config.dbUser, config.dbPassword); + PreparedStatement statement = connection.prepareStatement(sql)) { + int inserted = 0; + for (String processName : processes) { + statement.setString(1, request.machineName); + statement.setString(2, request.status); + statement.setTimestamp(3, Timestamp.from(request.detectedAt)); + statement.setString(4, processName); + inserted += statement.executeUpdate(); + } + return inserted; + } + } + } + + private static final class HeartbeatRequest { + private String machine_name; + private String status; + private String detected_at; + private List processes; + + private transient String machineName; + private transient Instant detectedAt; + + private static HeartbeatRequest parse(String json) { + try { + HeartbeatRequest request = GSON.fromJson(json, HeartbeatRequest.class); + if (request == null) { + throw new IllegalArgumentException("Empty request body"); + } + request.validate(); + return request; + } catch (JsonSyntaxException ex) { + throw new IllegalArgumentException("Invalid JSON"); + } + } + + private void validate() { + machineName = required(machine_name, "machine_name"); + status = required(status, "status"); + try { + detectedAt = Instant.parse(required(detected_at, "detected_at")); + } catch (DateTimeParseException ex) { + throw new IllegalArgumentException("Invalid detected_at"); + } + if (processes == null) { + processes = Collections.emptyList(); + } + } + + private static String required(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing field: " + fieldName); + } + return value.trim(); + } + } + + private static void log(String message) { + System.out.printf("[%s] %s%n", Instant.now(), message); + } + + private static String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/java-api/src/main/resources/application.properties b/java-api/src/main/resources/application.properties new file mode 100644 index 0000000..37416a5 --- /dev/null +++ b/java-api/src/main/resources/application.properties @@ -0,0 +1,8 @@ +server.host=0.0.0.0 +server.port=80 +server.path=/hb/api + +db.url=jdbc:mariadb://127.0.0.1:3306/process_monitor?useUnicode=true&characterEncoding=utf8 +# Change these credentials before running. +db.user=process_monitor +db.password=process_monitor_secret diff --git a/service/CMakeLists.txt b/service/CMakeLists.txt new file mode 100644 index 0000000..0832083 --- /dev/null +++ b/service/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.20) + +project(process_monitor VERSION 0.1.0 LANGUAGES CXX) + +add_executable(process-monitor + src/main.cpp +) + +target_compile_features(process-monitor PRIVATE cxx_std_17) +target_compile_definitions(process-monitor PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(process-monitor PRIVATE winhttp) + +if(MSVC) + set_property(TARGET process-monitor PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + target_compile_options(process-monitor PRIVATE /W4 /permissive-) +else() + target_compile_options(process-monitor PRIVATE -Wall -Wextra -Wpedantic) +endif() diff --git a/service/README.md b/service/README.md new file mode 100644 index 0000000..e7beca5 --- /dev/null +++ b/service/README.md @@ -0,0 +1,91 @@ +# Process Monitor + +Simple Windows C++ application that checks running processes every 30 seconds +and sends one HTTP heartbeat with all matching processes. + +## What it does + +- Enumerates running Windows processes via ToolHelp API +- Finds processes by partial name match +- Sends one JSON payload with all currently matched processes +- Builds with CMake without external runtime dependencies + +## Expected payload + +The application sends HTTP `POST` with `Content-Type: application/json`. + +```json +{ + "machine_name": "PC-01", + "status": "running", + "detected_at": "2026-03-27T12:34:56Z", + "processes": ["notepad.exe", "notepad++.exe"] +} +``` + +If `api_token` is set, request header `Authorization: Bearer ` is added. + +If no process matches in a cycle, the application still sends a heartbeat, but without the `processes` field: + +```json +{ + "machine_name": "PC-01", + "status": "running", + "detected_at": "2026-03-27T12:34:56Z" +} +``` + +## Configuration + +Edit `process-monitor.conf`. + +```ini +api_url=http://10.0.0.147/hb/api +api_token= +machine_name= +interval_seconds=30 +request_timeout_seconds=2 +process_names=fortnite,chrome,discord,steam +``` + +Notes: + +- `machine_name` is optional; if empty, Windows computer name is used +- `process_names` is a comma-separated list of substrings to search in executable names +- `interval_seconds` can be changed from the default `30` +- `request_timeout_seconds` sets WinHTTP connect/send/receive timeout in seconds + +## Build + +Developer Command Prompt for Visual Studio: + +```powershell +cmake -S . -B build +cmake --build build --config Release +``` + +Or with Ninja if you have a compiler environment ready: + +```powershell +cmake -S . -B build -G Ninja +cmake --build build +``` + +## Run + +```powershell +.\build\Release\process-monitor.exe +``` + +Or specify custom config path: + +```powershell +.\build\Release\process-monitor.exe .\my-config.conf +``` + +## Next useful improvements + +- Run as Windows service +- Add retry/backoff for failed API calls +- Add richer payload items if your API needs both matched pattern and actual process name +- Load config from JSON/YAML if richer metadata is needed diff --git a/service/process-monitor.conf b/service/process-monitor.conf new file mode 100644 index 0000000..7d3c396 --- /dev/null +++ b/service/process-monitor.conf @@ -0,0 +1,8 @@ +# Copy this file to process-monitor.conf and adjust values. + +api_url=http://10.0.0.147/hb/api +api_token= +machine_name= +interval_seconds=30 +request_timeout_seconds=2 +process_names=fortnite,chrome,discord,steam diff --git a/service/src/main.cpp b/service/src/main.cpp new file mode 100644 index 0000000..80e364e --- /dev/null +++ b/service/src/main.cpp @@ -0,0 +1,465 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::mutex g_logMutex; +const char* kLogFilePath = "process-monitor.log"; + +struct Config { + std::string apiUrl; + std::string apiToken; + std::string machineName; + int intervalSeconds = 30; + int requestTimeoutSeconds = 2; + std::vector processNames; +}; + +std::string trim(const std::string& value) { + const auto begin = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }); + const auto end = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }).base(); + + if (begin >= end) { + return {}; + } + + return std::string(begin, end); +} + +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; +} + +std::vector splitList(const std::string& value) { + std::vector items; + std::stringstream stream(value); + std::string item; + + while (std::getline(stream, item, ',')) { + const auto normalized = toLower(trim(item)); + if (!normalized.empty()) { + items.push_back(normalized); + } + } + + return items; +} + +std::wstring toWide(const std::string& value) { + if (value.empty()) { + return {}; + } + + const int sizeNeeded = MultiByteToWideChar(CP_UTF8, 0, value.c_str(), -1, nullptr, 0); + if (sizeNeeded <= 0) { + throw std::runtime_error("Failed to convert UTF-8 to UTF-16."); + } + + std::wstring wide(static_cast(sizeNeeded), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value.c_str(), -1, wide.data(), sizeNeeded); + wide.pop_back(); + return wide; +} + +std::string getComputerNameUtf8() { + if (const char* envComputerName = std::getenv("COMPUTERNAME")) { + const std::string value = trim(envComputerName); + if (!value.empty()) { + return value; + } + } + + char buffer[MAX_COMPUTERNAME_LENGTH + 1] = {}; + DWORD size = static_cast(std::size(buffer)); + + if (!GetComputerNameA(buffer, &size)) { + return "unknown-host"; + } + + return std::string(buffer, size); +} + +std::string toUtf8(const std::wstring& value) { + if (value.empty()) { + return {}; + } + + const int sizeNeeded = WideCharToMultiByte(CP_UTF8, 0, value.c_str(), -1, nullptr, 0, nullptr, nullptr); + if (sizeNeeded <= 0) { + throw std::runtime_error("Failed to convert UTF-16 to UTF-8."); + } + + std::string narrow(static_cast(sizeNeeded), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.c_str(), -1, narrow.data(), sizeNeeded, nullptr, nullptr); + narrow.pop_back(); + return narrow; +} + +Config loadConfig(const std::string& path) { + std::ifstream input(path); + if (!input) { + throw std::runtime_error("Cannot open config file: " + path); + } + + std::map values; + std::string line; + + while (std::getline(input, line)) { + const auto cleaned = trim(line); + if (cleaned.empty() || cleaned[0] == '#' || cleaned[0] == ';') { + continue; + } + + const auto separator = cleaned.find('='); + if (separator == std::string::npos) { + continue; + } + + const auto key = toLower(trim(cleaned.substr(0, separator))); + const auto value = trim(cleaned.substr(separator + 1)); + values[key] = value; + } + + Config config; + config.apiUrl = values["api_url"]; + config.apiToken = values["api_token"]; + config.machineName = values["machine_name"].empty() ? getComputerNameUtf8() : values["machine_name"]; + config.processNames = splitList(values["process_names"]); + + if (values.count("interval_seconds") > 0) { + config.intervalSeconds = std::max(1, std::atoi(values["interval_seconds"].c_str())); + } + + if (values.count("request_timeout_seconds") > 0) { + config.requestTimeoutSeconds = std::max(1, std::atoi(values["request_timeout_seconds"].c_str())); + } + + if (config.apiUrl.empty()) { + throw std::runtime_error("Missing required config key: api_url"); + } + + if (config.processNames.empty()) { + throw std::runtime_error("Missing required config key: process_names"); + } + + return config; +} + +struct ParsedUrl { + bool secure = false; + INTERNET_PORT port = 0; + std::wstring host; + std::wstring path; +}; + +ParsedUrl parseUrl(const std::string& rawUrl) { + const auto wideUrl = toWide(rawUrl); + + URL_COMPONENTS components = {}; + components.dwStructSize = sizeof(components); + components.dwSchemeLength = static_cast(-1); + components.dwHostNameLength = static_cast(-1); + components.dwUrlPathLength = static_cast(-1); + components.dwExtraInfoLength = static_cast(-1); + + if (!WinHttpCrackUrl(wideUrl.c_str(), 0, 0, &components)) { + throw std::runtime_error("Invalid api_url: " + rawUrl); + } + + ParsedUrl parsed; + parsed.secure = (components.nScheme == INTERNET_SCHEME_HTTPS); + parsed.port = components.nPort; + parsed.host.assign(components.lpszHostName, components.dwHostNameLength); + parsed.path.assign(components.lpszUrlPath, components.dwUrlPathLength); + + if (components.dwExtraInfoLength > 0) { + parsed.path.append(components.lpszExtraInfo, components.dwExtraInfoLength); + } + + if (parsed.path.empty()) { + parsed.path = L"/"; + } + + return parsed; +} + +std::string iso8601NowUtc() { + using clock = std::chrono::system_clock; + const auto now = clock::now(); + const std::time_t current = clock::to_time_t(now); + + std::tm utcTime {}; + gmtime_s(&utcTime, ¤t); + + std::ostringstream output; + output << std::put_time(&utcTime, "%Y-%m-%dT%H:%M:%SZ"); + return output.str(); +} + +void logMessage(const std::string& message, bool isError = false) { + const std::string line = "[" + iso8601NowUtc() + "] " + message; + std::lock_guard lock(g_logMutex); + + std::ofstream logFile(kLogFilePath, std::ios::app); + if (logFile) { + logFile << line << std::endl; + } + + if (isError) { + std::cerr << line << std::endl; + } else { + std::cout << line << std::endl; + } +} + +std::string escapeJson(const std::string& value) { + std::ostringstream escaped; + + for (const unsigned char ch : value) { + switch (ch) { + case '\\': + escaped << "\\\\"; + break; + case '"': + escaped << "\\\""; + break; + case '\b': + escaped << "\\b"; + break; + case '\f': + escaped << "\\f"; + break; + case '\n': + escaped << "\\n"; + break; + case '\r': + escaped << "\\r"; + break; + case '\t': + escaped << "\\t"; + break; + default: + if (ch < 0x20) { + escaped << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast(ch); + } else { + escaped << static_cast(ch); + } + break; + } + } + + return escaped.str(); +} + +std::vector findMatchingProcesses( + const std::set& runningProcesses, + const std::vector& configuredPatterns) { + std::vector matches; + + for (const auto& runningProcess : runningProcesses) { + for (const auto& pattern : configuredPatterns) { + if (runningProcess.find(pattern) != std::string::npos) { + matches.push_back(runningProcess); + break; + } + } + } + + return matches; +} + +std::string buildPayload(const Config& config, const std::vector& processNames) { + std::ostringstream payload; + payload + << "{" + << "\"machine_name\":\"" << escapeJson(config.machineName) << "\"," + << "\"status\":\"running\"," + << "\"detected_at\":\"" << escapeJson(iso8601NowUtc()) << "\""; + + if (!processNames.empty()) { + payload << ",\"processes\":["; + for (std::size_t index = 0; index < processNames.size(); ++index) { + if (index > 0) { + payload << ","; + } + payload << "\"" << escapeJson(processNames[index]) << "\""; + } + payload << "]"; + } + + payload << "}"; + return payload.str(); +} + +std::string narrowUrlPath(const ParsedUrl& url) { + return toUtf8(url.path); +} + +std::set enumerateRunningProcesses() { + std::set processNames; + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + throw std::runtime_error("CreateToolhelp32Snapshot failed."); + } + + PROCESSENTRY32W entry = {}; + entry.dwSize = sizeof(entry); + + if (Process32FirstW(snapshot, &entry)) { + do { + processNames.insert(toLower(toUtf8(entry.szExeFile))); + } while (Process32NextW(snapshot, &entry)); + } + + CloseHandle(snapshot); + return processNames; +} + +bool postHeartbeat(const Config& config, const ParsedUrl& url, const std::vector& processNames) { + try { + const auto userAgent = L"process-monitor/0.1"; + const auto payload = buildPayload(config, processNames); + const auto urlForLog = toUtf8(url.host) + narrowUrlPath(url); + const auto wideHeaders = [&config]() { + std::wstring headers = L"Content-Type: application/json\r\n"; + if (!config.apiToken.empty()) { + headers += L"Authorization: Bearer "; + headers += toWide(config.apiToken); + headers += L"\r\n"; + } + return headers; + }(); + + logMessage("API request -> POST " + urlForLog + " payload: " + payload); + + HINTERNET session = WinHttpOpen(userAgent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!session) { + logMessage("API request failed before connect: WinHttpOpen failed", true); + return false; + } + + const int requestTimeoutMs = config.requestTimeoutSeconds * 1000; + WinHttpSetTimeouts( + session, + requestTimeoutMs, + requestTimeoutMs, + requestTimeoutMs, + requestTimeoutMs); + + HINTERNET connection = WinHttpConnect(session, url.host.c_str(), url.port, 0); + if (!connection) { + WinHttpCloseHandle(session); + logMessage("API request failed before send: WinHttpConnect failed", true); + return false; + } + + const DWORD flags = url.secure ? WINHTTP_FLAG_SECURE : 0; + HINTERNET request = WinHttpOpenRequest(connection, L"POST", url.path.c_str(), + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (!request) { + WinHttpCloseHandle(connection); + WinHttpCloseHandle(session); + logMessage("API request failed before send: WinHttpOpenRequest failed", true); + return false; + } + + const BOOL sendResult = WinHttpSendRequest( + request, + wideHeaders.c_str(), + static_cast(wideHeaders.size()), + const_cast(payload.data()), + static_cast(payload.size()), + static_cast(payload.size()), + 0); + + bool success = false; + if (sendResult && WinHttpReceiveResponse(request, nullptr)) { + DWORD statusCode = 0; + DWORD statusCodeSize = sizeof(statusCode); + if (WinHttpQueryHeaders(request, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &statusCode, + &statusCodeSize, + WINHTTP_NO_HEADER_INDEX)) { + success = (statusCode >= 200 && statusCode < 300); + logMessage("API response <- HTTP " + std::to_string(statusCode) + + (success ? " success" : " failure")); + } + } else { + logMessage("API request failed during send/receive", true); + } + + WinHttpCloseHandle(request); + WinHttpCloseHandle(connection); + WinHttpCloseHandle(session); + return success; + } catch (const std::exception& ex) { + logMessage(std::string("Heartbeat request failed: ") + ex.what(), true); + return false; + } + catch (...) { + logMessage("Heartbeat request failed: unknown error", true); + return false; + } +} + +void monitorProcesses(const Config& config) { + const auto url = parseUrl(config.apiUrl); + logMessage("Monitoring " + std::to_string(config.processNames.size()) + " patterns every " + + std::to_string(config.intervalSeconds) + "s"); + + while (true) { + try { + const auto running = enumerateRunningProcesses(); + const auto matches = findMatchingProcesses(running, config.processNames); + const bool sent = postHeartbeat(config, url, matches); + logMessage(std::to_string(matches.size()) + " matching process(es) -> " + + (sent ? "reported" : "failed")); + } catch (const std::exception& ex) { + logMessage(std::string("Monitoring cycle failed: ") + ex.what(), true); + } + + std::this_thread::sleep_for(std::chrono::seconds(config.intervalSeconds)); + } +} + +} // namespace + +int main(int argc, char* argv[]) { + try { + const std::string configPath = (argc > 1) ? argv[1] : "process-monitor.conf"; + const auto config = loadConfig(configPath); + monitorProcesses(config); + return 0; + } catch (const std::exception& ex) { + logMessage(std::string("Startup failed: ") + ex.what(), true); + return 1; + } +}