initial commit

This commit is contained in:
Radek Davidek 2026-03-27 22:16:11 +01:00
commit 212216b101
11 changed files with 1181 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -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

58
java-api/README.md Normal file
View File

@ -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
```

65
java-api/pom.xml Normal file
View File

@ -0,0 +1,65 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cz.kamma.processmonitor</groupId>
<artifactId>process-monitor-api</artifactId>
<version>0.1.0</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.13.1</version>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.5.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>cz.kamma.processmonitor.Main</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>process-monitor-api</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>cz.kamma.processmonitor.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

17
java-api/schema.sql Normal file
View File

@ -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;

View File

@ -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<String> 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<String> 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("\"", "\\\"");
}
}

View File

@ -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<String> 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<String> 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("\"", "\\\"");
}
}

View File

@ -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

19
service/CMakeLists.txt Normal file
View File

@ -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$<$<CONFIG:Debug>:Debug>")
target_compile_options(process-monitor PRIVATE /W4 /permissive-)
else()
target_compile_options(process-monitor PRIVATE -Wall -Wextra -Wpedantic)
endif()

91
service/README.md Normal file
View File

@ -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 <token>` 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

View File

@ -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

465
service/src/main.cpp Normal file
View File

@ -0,0 +1,465 @@
#include <windows.h>
#include <tlhelp32.h>
#include <winhttp.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <ctime>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <mutex>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
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<std::string> 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<char>(std::tolower(ch));
});
return value;
}
std::vector<std::string> splitList(const std::string& value) {
std::vector<std::string> 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<std::size_t>(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<DWORD>(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<std::size_t>(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<std::string, std::string> 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<DWORD>(-1);
components.dwHostNameLength = static_cast<DWORD>(-1);
components.dwUrlPathLength = static_cast<DWORD>(-1);
components.dwExtraInfoLength = static_cast<DWORD>(-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, &current);
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<std::mutex> 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<int>(ch);
} else {
escaped << static_cast<char>(ch);
}
break;
}
}
return escaped.str();
}
std::vector<std::string> findMatchingProcesses(
const std::set<std::string>& runningProcesses,
const std::vector<std::string>& configuredPatterns) {
std::vector<std::string> 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<std::string>& 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<std::string> enumerateRunningProcesses() {
std::set<std::string> 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<std::string>& 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<DWORD>(wideHeaders.size()),
const_cast<char*>(payload.data()),
static_cast<DWORD>(payload.size()),
static_cast<DWORD>(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;
}
}