initial commit
This commit is contained in:
commit
212216b101
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
58
java-api/README.md
Normal 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
65
java-api/pom.xml
Normal 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
17
java-api/schema.sql
Normal 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;
|
||||||
212
java-api/src/main/java/cz/kamma/processmonitor/Main.java
Normal file
212
java-api/src/main/java/cz/kamma/processmonitor/Main.java
Normal 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("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
212
java-api/src/main/java/local/processmonitor/api/Main.java
Normal file
212
java-api/src/main/java/local/processmonitor/api/Main.java
Normal 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("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
8
java-api/src/main/resources/application.properties
Normal file
8
java-api/src/main/resources/application.properties
Normal 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
19
service/CMakeLists.txt
Normal 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
91
service/README.md
Normal 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
|
||||||
8
service/process-monitor.conf
Normal file
8
service/process-monitor.conf
Normal 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
465
service/src/main.cpp
Normal 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, ¤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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user