1021 lines
42 KiB
C
1021 lines
42 KiB
C
#include "trainlog/web_server.h"
|
|
|
|
#include <arpa/inet.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <limits.h>
|
|
#include <microhttpd.h>
|
|
#include <netinet/in.h>
|
|
#include <sys/random.h>
|
|
#include <stdbool.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/select.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
#include <uuid/uuid.h>
|
|
#include <yyjson.h>
|
|
|
|
#include "trainlog/web_dashboard.h"
|
|
#include "trainlog/dashboard_layout.h"
|
|
#include "web_assets.h"
|
|
|
|
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
|
|
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
|
|
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
|
|
#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U)
|
|
#define TRAINLOG_HTTP_BODY_LIMIT (4U * 1024U)
|
|
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
|
|
#define TRAINLOG_HTTP_BACKLOG 32U
|
|
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
|
|
#define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U
|
|
|
|
typedef struct TrainlogWebContext {
|
|
uint16_t port;
|
|
TrainlogDatabase *database;
|
|
const char *database_path;
|
|
char state_path[PATH_MAX + 32U];
|
|
pid_t worker_pid;
|
|
char csrf_token[65];
|
|
bool invalid_layout_reported;
|
|
} TrainlogWebContext;
|
|
|
|
typedef struct TrainlogHttpRequestState {
|
|
size_t body_size;
|
|
bool body_too_large;
|
|
char body[TRAINLOG_HTTP_BODY_LIMIT + 1U];
|
|
} TrainlogHttpRequestState;
|
|
|
|
static bool valid_sync_id(const char *value) {
|
|
return value != NULL && strlen(value) == 39U && strncmp(value, "sy_", 3U) == 0 &&
|
|
uuid_parse(value + 3U, (unsigned char[16]){0}) == 0;
|
|
}
|
|
|
|
bool trainlog_web_sync_accepted_serialize(const char *request_id,
|
|
const char *run_id,
|
|
char *output,
|
|
size_t capacity,
|
|
size_t *output_size) {
|
|
int written;
|
|
|
|
if (output == NULL || capacity == 0U || output_size == NULL) {
|
|
return false;
|
|
}
|
|
output[0] = '\0';
|
|
*output_size = 0U;
|
|
if (!valid_sync_id(request_id) || !valid_sync_id(run_id)) {
|
|
return false;
|
|
}
|
|
written = snprintf(output,
|
|
capacity,
|
|
"{\"api_version\":1,\"phase\":\"requested\",\"result\":\"running\","
|
|
"\"progress_revision\":1,\"request_id\":\"%s\",\"run_id\":\"%s\","
|
|
"\"status_url\":\"/api/v1/sync/status\"}\n",
|
|
request_id,
|
|
run_id);
|
|
if (written < 0 || (size_t)written >= capacity) {
|
|
output[0] = '\0';
|
|
return false;
|
|
}
|
|
*output_size = (size_t)written;
|
|
return true;
|
|
}
|
|
|
|
static void web_diagnostic(char *output, size_t capacity, const char *message) {
|
|
if (output != NULL && capacity > 0U) {
|
|
(void)snprintf(output, capacity, "%s", message);
|
|
}
|
|
}
|
|
|
|
static enum MHD_Result queue_layout_json(struct MHD_Connection *connection,
|
|
unsigned int status,
|
|
const char *body,
|
|
uint64_t revision,
|
|
const char *csrf_token) {
|
|
struct MHD_Response *response;
|
|
enum MHD_Result result;
|
|
char etag[32];
|
|
response = MHD_create_response_from_buffer(strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY);
|
|
if (response == NULL) {
|
|
return MHD_NO;
|
|
}
|
|
(void)snprintf(etag, sizeof(etag), "\"%llu\"", (unsigned long long)revision);
|
|
if (MHD_add_response_header(
|
|
response, MHD_HTTP_HEADER_CONTENT_TYPE, "application/json; charset=utf-8") != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store") != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") !=
|
|
MHD_YES ||
|
|
MHD_add_response_header(response,
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") !=
|
|
MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, etag) != MHD_YES ||
|
|
MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) {
|
|
MHD_destroy_response(response);
|
|
return MHD_NO;
|
|
}
|
|
result = MHD_queue_response(connection, status, response);
|
|
MHD_destroy_response(response);
|
|
return result;
|
|
}
|
|
|
|
static enum MHD_Result queue_json(struct MHD_Connection *connection,
|
|
unsigned int status,
|
|
const char *body,
|
|
const char *allow) {
|
|
struct MHD_Response *response;
|
|
enum MHD_Result result;
|
|
response = MHD_create_response_from_buffer(strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY);
|
|
if (response == NULL) {
|
|
return MHD_NO;
|
|
}
|
|
if (MHD_add_response_header(
|
|
response, MHD_HTTP_HEADER_CONTENT_TYPE, "application/json; charset=utf-8") != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") !=
|
|
MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store") != MHD_YES ||
|
|
MHD_add_response_header(response,
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") !=
|
|
MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) {
|
|
MHD_destroy_response(response);
|
|
return MHD_NO;
|
|
}
|
|
if (allow != NULL &&
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_ALLOW, allow) != MHD_YES) {
|
|
MHD_destroy_response(response);
|
|
return MHD_NO;
|
|
}
|
|
result = MHD_queue_response(connection, status, response);
|
|
MHD_destroy_response(response);
|
|
return result;
|
|
}
|
|
|
|
static enum MHD_Result queue_sync_json_sized(struct MHD_Connection *connection,
|
|
unsigned int status,
|
|
const char *body,
|
|
size_t body_size,
|
|
const char *csrf_token,
|
|
const char *location) {
|
|
struct MHD_Response *response =
|
|
MHD_create_response_from_buffer(body_size, (void *)body, MHD_RESPMEM_MUST_COPY);
|
|
enum MHD_Result result;
|
|
if (response == NULL) {
|
|
return MHD_NO;
|
|
}
|
|
if (MHD_add_response_header(
|
|
response, MHD_HTTP_HEADER_CONTENT_TYPE, "application/json; charset=utf-8") != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store") != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") !=
|
|
MHD_YES ||
|
|
MHD_add_response_header(response,
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") !=
|
|
MHD_YES ||
|
|
MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES ||
|
|
(location != NULL &&
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_LOCATION, location) != MHD_YES)) {
|
|
MHD_destroy_response(response);
|
|
return MHD_NO;
|
|
}
|
|
result = MHD_queue_response(connection, status, response);
|
|
MHD_destroy_response(response);
|
|
return result;
|
|
}
|
|
|
|
static enum MHD_Result queue_sync_json(struct MHD_Connection *connection,
|
|
unsigned int status,
|
|
const char *body,
|
|
const char *csrf_token,
|
|
const char *location) {
|
|
return queue_sync_json_sized(connection, status, body, strlen(body), csrf_token, location);
|
|
}
|
|
|
|
static bool read_bounded_file(const char *path, char *output, size_t capacity) {
|
|
int descriptor;
|
|
struct stat info;
|
|
size_t used = 0U;
|
|
if (path == NULL || output == NULL || capacity < 2U) {
|
|
return false;
|
|
}
|
|
descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
|
|
if (descriptor < 0) {
|
|
return false;
|
|
}
|
|
if (fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode) || info.st_size < 0 ||
|
|
(uintmax_t)info.st_size >= (uintmax_t)capacity) {
|
|
(void)close(descriptor);
|
|
return false;
|
|
}
|
|
while (used < (size_t)info.st_size) {
|
|
ssize_t count = read(descriptor, output + used, (size_t)info.st_size - used);
|
|
if (count < 0 && errno == EINTR) {
|
|
continue;
|
|
}
|
|
if (count <= 0) {
|
|
(void)close(descriptor);
|
|
return false;
|
|
}
|
|
used += (size_t)count;
|
|
}
|
|
output[used] = '\0';
|
|
(void)close(descriptor);
|
|
return true;
|
|
}
|
|
|
|
static bool sync_state_active(const char *json, char request_id[64]) {
|
|
yyjson_doc *document = yyjson_read(json, strlen(json), 0U);
|
|
yyjson_val *root;
|
|
yyjson_val *phase;
|
|
yyjson_val *request;
|
|
const char *text;
|
|
bool active = false;
|
|
if (document == NULL) {
|
|
return false;
|
|
}
|
|
root = yyjson_doc_get_root(document);
|
|
phase = yyjson_obj_get(root, "phase");
|
|
request = yyjson_obj_get(root, "request_id");
|
|
if (yyjson_is_str(request) && strlen(yyjson_get_str(request)) < 64U) {
|
|
(void)snprintf(request_id, 64U, "%s", yyjson_get_str(request));
|
|
}
|
|
if (yyjson_is_str(phase)) {
|
|
text = yyjson_get_str(phase);
|
|
active = strcmp(text, "requested") == 0 ||
|
|
strcmp(text, "waiting_android_publication") == 0 || strcmp(text, "running") == 0 ||
|
|
strcmp(text, "local_import_committed") == 0 || strcmp(text, "published") == 0 ||
|
|
strcmp(text, "waiting_acknowledgement") == 0 || strcmp(text, "peer_consumed") == 0;
|
|
}
|
|
yyjson_doc_free(document);
|
|
return active;
|
|
}
|
|
|
|
static bool valid_sync_request(const char *body, size_t size, char request_id[64]) {
|
|
yyjson_doc *document = yyjson_read(body, size, 0U);
|
|
yyjson_val *root;
|
|
yyjson_val *request;
|
|
yyjson_val *trigger;
|
|
const char *id;
|
|
if (document == NULL) {
|
|
return false;
|
|
}
|
|
root = yyjson_doc_get_root(document);
|
|
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != 2U) {
|
|
yyjson_doc_free(document);
|
|
return false;
|
|
}
|
|
request = yyjson_obj_get(root, "request_id");
|
|
trigger = yyjson_obj_get(root, "trigger");
|
|
if (!yyjson_is_str(request) || !yyjson_is_str(trigger) ||
|
|
strcmp(yyjson_get_str(trigger), "web") != 0) {
|
|
yyjson_doc_free(document);
|
|
return false;
|
|
}
|
|
id = yyjson_get_str(request);
|
|
if (strlen(id) != 39U || strncmp(id, "sy_", 3U) != 0 ||
|
|
uuid_parse(id + 3U, (unsigned char[16]){0}) != 0) {
|
|
yyjson_doc_free(document);
|
|
return false;
|
|
}
|
|
(void)snprintf(request_id, 64U, "%s", id);
|
|
yyjson_doc_free(document);
|
|
return true;
|
|
}
|
|
|
|
static void generate_sync_run_id(char run_id[64]) {
|
|
uuid_t identifier;
|
|
char uuid[37];
|
|
uuid_generate_random(identifier);
|
|
uuid_unparse_lower(identifier, uuid);
|
|
(void)snprintf(run_id, 64U, "sy_%s", uuid);
|
|
}
|
|
|
|
static bool write_initial_sync_state(const TrainlogWebContext *context,
|
|
const char *request_id,
|
|
const char *run_id,
|
|
char output[2048]) {
|
|
char temporary[PATH_MAX + 64U];
|
|
char timestamp[40];
|
|
time_t now = time(NULL);
|
|
struct tm broken_down;
|
|
int descriptor;
|
|
int written;
|
|
ssize_t count;
|
|
if (now == (time_t)-1 || gmtime_r(&now, &broken_down) == NULL ||
|
|
strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &broken_down) == 0U) {
|
|
return false;
|
|
}
|
|
written = snprintf(
|
|
output,
|
|
2048U,
|
|
"{\"ai_midpoint\":{\"result\":\"not_configured\"},\"ai_post_sync\":{\"result\":\"not_"
|
|
"configured\"},"
|
|
"\"diagnostic\":\"\",\"domains\":{},\"drafts\":[],\"effective_mode\":\"full_generation_"
|
|
"v1\","
|
|
"\"finished_at\":null,\"inbound_generation_id\":null,\"missing_capabilities\":[],"
|
|
"\"outbound_generation_id\":null,\"phase\":\"requested\",\"producer_peer_id\":null,"
|
|
"\"consumer_peer_id\":null,\"progress_revision\":1,\"request_id\":\"%s\","
|
|
"\"requested_mode\":\"full_generation_v1\",\"result\":\"running\",\"run_id\":\"%s\","
|
|
"\"sessions_reconciled\":null,\"started_at\":\"%s\",\"trigger\":\"web\",\"updated_at\":\"%"
|
|
"s\"}\n",
|
|
request_id,
|
|
run_id,
|
|
timestamp,
|
|
timestamp);
|
|
if (written < 0 || written >= 2048) {
|
|
return false;
|
|
}
|
|
if (snprintf(temporary, sizeof(temporary), "%s.new.%ld", context->state_path, (long)getpid()) <
|
|
0) {
|
|
return false;
|
|
}
|
|
descriptor = open(temporary, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
|
|
if (descriptor < 0) {
|
|
return false;
|
|
}
|
|
count = write(descriptor, output, (size_t)written);
|
|
if (count != written || fsync(descriptor) != 0 || close(descriptor) != 0 ||
|
|
rename(temporary, context->state_path) != 0) {
|
|
(void)close(descriptor);
|
|
(void)unlink(temporary);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool launch_sync_worker(TrainlogWebContext *context,
|
|
const char *config,
|
|
const char *run_id,
|
|
const char *request_id) {
|
|
char orchestrator[PATH_MAX];
|
|
const char *tools = getenv("TRAINLOG_SYNC_TOOLS_DIR");
|
|
int path_length;
|
|
if (tools == NULL) {
|
|
tools = TRAINLOG_TOOLS_DIR;
|
|
}
|
|
/* CONTRACT: deployment may relocate the fixed shipped helper directory,
|
|
* but an HTTP request can never select this executable or its arguments. */
|
|
path_length = snprintf(orchestrator, sizeof(orchestrator), "%s/sync_orchestrator.py", tools);
|
|
if (tools[0] != '/' || path_length < 0 || (size_t)path_length >= sizeof(orchestrator)) {
|
|
return false;
|
|
}
|
|
pid_t child = fork();
|
|
if (child < 0) {
|
|
return false;
|
|
}
|
|
if (child == 0) {
|
|
(void)setpgid(0, 0);
|
|
execlp("python3",
|
|
"python3",
|
|
orchestrator,
|
|
"--database",
|
|
context->database_path,
|
|
"--state",
|
|
context->state_path,
|
|
"--config",
|
|
config,
|
|
"--run-id",
|
|
run_id,
|
|
"--request-id",
|
|
request_id,
|
|
(char *)NULL);
|
|
_exit(127);
|
|
}
|
|
context->worker_pid = child;
|
|
return true;
|
|
}
|
|
|
|
static enum MHD_Result queue_asset(struct MHD_Connection *connection,
|
|
const TrainlogWebAsset *asset) {
|
|
static const char CSP[] = "default-src 'self'; script-src 'self'; "
|
|
"style-src 'self'; connect-src 'self'; img-src 'self'; "
|
|
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
|
|
const char *cache = asset->immutable ? "public, max-age=31536000, immutable" : "no-cache";
|
|
struct MHD_Response *response =
|
|
MHD_create_response_from_buffer(asset->size, (void *)asset->bytes, MHD_RESPMEM_PERSISTENT);
|
|
enum MHD_Result result;
|
|
if (response == NULL) {
|
|
return MHD_NO;
|
|
}
|
|
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, asset->mime) != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") !=
|
|
MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, cache) != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, asset->etag) != MHD_YES ||
|
|
MHD_add_response_header(response, "Content-Security-Policy", CSP) != MHD_YES ||
|
|
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) {
|
|
MHD_destroy_response(response);
|
|
return MHD_NO;
|
|
}
|
|
result = MHD_queue_response(connection, MHD_HTTP_OK, response);
|
|
MHD_destroy_response(response);
|
|
return result;
|
|
}
|
|
|
|
static bool is_ui_route(const char *url) {
|
|
static const char *const routes[] = {"/", "/analyse", "/programmes", "/seances", "/exercices"};
|
|
size_t index;
|
|
for (index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index) {
|
|
if (strcmp(url, routes[index]) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool valid_host(const TrainlogWebContext *context, const char *host) {
|
|
char expected[32];
|
|
int written;
|
|
if (host == NULL) {
|
|
return false;
|
|
}
|
|
if (strcmp(host, "127.0.0.1") == 0) {
|
|
return true;
|
|
}
|
|
written = snprintf(expected, sizeof(expected), "127.0.0.1:%u", (unsigned int)context->port);
|
|
return written > 0 && (size_t)written < sizeof(expected) && strcmp(host, expected) == 0;
|
|
}
|
|
|
|
static bool declared_body_too_large(struct MHD_Connection *connection) {
|
|
const char *value =
|
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_LENGTH);
|
|
char *end = NULL;
|
|
unsigned long long size;
|
|
if (value == NULL) {
|
|
return false;
|
|
}
|
|
errno = 0;
|
|
size = strtoull(value, &end, 10);
|
|
return errno != 0 || end == value || *end != '\0' || size > TRAINLOG_HTTP_BODY_LIMIT;
|
|
}
|
|
|
|
static bool valid_origin(const TrainlogWebContext *context, const char *origin) {
|
|
char direct[48];
|
|
if (origin == NULL) {
|
|
return false;
|
|
}
|
|
(void)snprintf(direct, sizeof(direct), "http://127.0.0.1:%u", (unsigned int)context->port);
|
|
return strcmp(origin, direct) == 0 || strcmp(origin, "http://trainlog.perf") == 0;
|
|
}
|
|
|
|
static bool valid_csrf(const TrainlogWebContext *context, const char *provided) {
|
|
size_t index;
|
|
unsigned char difference = 0U;
|
|
if (provided == NULL || strlen(provided) != 64U) {
|
|
return false;
|
|
}
|
|
for (index = 0U; index < 64U; ++index) {
|
|
difference |= (unsigned char)(provided[index] ^ context->csrf_token[index]);
|
|
}
|
|
return difference == 0U;
|
|
}
|
|
|
|
static bool generate_csrf_token(char output[65]) {
|
|
unsigned char random_bytes[32];
|
|
size_t used = 0U;
|
|
size_t index;
|
|
while (used < sizeof(random_bytes)) {
|
|
ssize_t count = getrandom(random_bytes + used, sizeof(random_bytes) - used, 0);
|
|
if (count < 0 && errno == EINTR) {
|
|
continue;
|
|
}
|
|
if (count <= 0) {
|
|
return false;
|
|
}
|
|
used += (size_t)count;
|
|
}
|
|
for (index = 0U; index < sizeof(random_bytes); ++index) {
|
|
(void)snprintf(output + index * 2U, 3U, "%02x", random_bytes[index]);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool parse_if_match(const char *value, uint64_t *revision) {
|
|
char *end = NULL;
|
|
unsigned long long parsed;
|
|
if (value == NULL || value[0] != '"') {
|
|
return false;
|
|
}
|
|
errno = 0;
|
|
parsed = strtoull(value + 1, &end, 10);
|
|
if (errno != 0 || end == value + 1 || end[0] != '"' || end[1] != '\0' ||
|
|
parsed > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) {
|
|
return false;
|
|
}
|
|
*revision = (uint64_t)parsed;
|
|
return true;
|
|
}
|
|
|
|
static enum MHD_Result handle_request(void *closure,
|
|
struct MHD_Connection *connection,
|
|
const char *url,
|
|
const char *method,
|
|
const char *version,
|
|
const char *upload_data,
|
|
size_t *upload_data_size,
|
|
void **request_closure) {
|
|
static const char HEALTH[] = "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
|
|
"\"version\":\"" TRAINLOG_VERSION "\"}\n";
|
|
TrainlogWebContext *context = closure;
|
|
TrainlogHttpRequestState *state = *request_closure;
|
|
const union MHD_ConnectionInfo *header_info;
|
|
const TrainlogWebAsset *asset;
|
|
const char *host;
|
|
bool is_get;
|
|
bool is_head;
|
|
(void)version;
|
|
if (state == NULL) {
|
|
state = calloc(1U, sizeof(*state));
|
|
if (state == NULL) {
|
|
return MHD_NO;
|
|
}
|
|
state->body_too_large = declared_body_too_large(connection);
|
|
*request_closure = state;
|
|
return MHD_YES;
|
|
}
|
|
if (*upload_data_size > 0U) {
|
|
if (*upload_data_size >
|
|
TRAINLOG_HTTP_BODY_LIMIT - (state->body_size > TRAINLOG_HTTP_BODY_LIMIT
|
|
? TRAINLOG_HTTP_BODY_LIMIT
|
|
: state->body_size)) {
|
|
state->body_too_large = true;
|
|
} else {
|
|
(void)memcpy(state->body + state->body_size, upload_data, *upload_data_size);
|
|
state->body_size += *upload_data_size;
|
|
state->body[state->body_size] = '\0';
|
|
}
|
|
*upload_data_size = 0U;
|
|
return MHD_YES;
|
|
}
|
|
header_info = MHD_get_connection_info(connection, MHD_CONNECTION_INFO_REQUEST_HEADER_SIZE);
|
|
if (header_info == NULL || header_info->header_size > TRAINLOG_HTTP_HEADER_LIMIT) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE,
|
|
"{\"error\":\"headers_too_large\"}\n",
|
|
NULL);
|
|
}
|
|
if (state->body_too_large) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_CONTENT_TOO_LARGE, "{\"error\":\"body_too_large\"}\n", NULL);
|
|
}
|
|
host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_HOST);
|
|
if (!valid_host(context, host)) {
|
|
return queue_json(connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_host\"}\n", NULL);
|
|
}
|
|
is_get = strcmp(method, MHD_HTTP_METHOD_GET) == 0;
|
|
is_head = strcmp(method, MHD_HTTP_METHOD_HEAD) == 0;
|
|
if (strcmp(url, "/api/v1/health") == 0) {
|
|
if (!is_get) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
|
"{\"error\":\"method_not_allowed\"}\n",
|
|
"GET");
|
|
}
|
|
return queue_json(connection, MHD_HTTP_OK, HEALTH, NULL);
|
|
}
|
|
if (strcmp(url, "/api/v1/sync/status") == 0) {
|
|
static const char IDLE_DISABLED[] =
|
|
"{\"api_version\":1,\"enabled\":false,\"phase\":\"idle\","
|
|
"\"result\":\"disabled\",\"last_success\":null}\n";
|
|
static const char IDLE_ENABLED[] = "{\"api_version\":1,\"enabled\":true,\"phase\":\"idle\","
|
|
"\"result\":\"idle\",\"last_success\":null}\n";
|
|
char status_json[65537];
|
|
if (!is_get) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
|
"{\"error\":\"method_not_allowed\"}\n",
|
|
"GET");
|
|
}
|
|
if (!read_bounded_file(context->state_path, status_json, sizeof(status_json))) {
|
|
return queue_sync_json(connection,
|
|
MHD_HTTP_OK,
|
|
getenv("TRAINLOG_SYNC_GENERATION_CONFIG") == NULL ? IDLE_DISABLED
|
|
: IDLE_ENABLED,
|
|
context->csrf_token,
|
|
NULL);
|
|
}
|
|
return queue_sync_json(connection, MHD_HTTP_OK, status_json, context->csrf_token, NULL);
|
|
}
|
|
if (strcmp(url, "/api/v1/sync") == 0) {
|
|
const char *origin;
|
|
const char *csrf;
|
|
const char *content_type;
|
|
const char *config = getenv("TRAINLOG_SYNC_GENERATION_CONFIG");
|
|
char request_id[64] = "";
|
|
char existing_id[64] = "";
|
|
char existing[65537];
|
|
char initial[2048];
|
|
char run_id[64];
|
|
char accepted[TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY];
|
|
size_t accepted_size;
|
|
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
|
"{\"error\":\"method_not_allowed\"}\n",
|
|
"POST");
|
|
}
|
|
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
|
|
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
|
|
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
|
}
|
|
content_type =
|
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
|
|
if (content_type == NULL || strcmp(content_type, "application/json") != 0) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
|
|
"{\"error\":\"unsupported_media_type\"}\n",
|
|
NULL);
|
|
}
|
|
if (!valid_sync_request(state->body, state->body_size, request_id)) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_sync_request\"}\n", NULL);
|
|
}
|
|
if (config == NULL || config[0] != '/' || strlen(config) >= PATH_MAX) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_CONFLICT, "{\"error\":\"full_generation_disabled\"}\n", NULL);
|
|
}
|
|
if (read_bounded_file(context->state_path, existing, sizeof(existing))) {
|
|
bool active = sync_state_active(existing, existing_id);
|
|
if (strcmp(existing_id, request_id) == 0) {
|
|
return queue_sync_json(connection,
|
|
active ? MHD_HTTP_ACCEPTED : MHD_HTTP_OK,
|
|
existing,
|
|
context->csrf_token,
|
|
"/api/v1/sync/status");
|
|
}
|
|
if (active) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_CONFLICT, "{\"error\":\"sync_already_running\"}\n", NULL);
|
|
}
|
|
}
|
|
generate_sync_run_id(run_id);
|
|
/* INVARIANT: a request is made durable only after its complete 202 response is known to
|
|
* fit, so no admitted job can become unreportable because of response truncation. */
|
|
if (!trainlog_web_sync_accepted_serialize(
|
|
request_id, run_id, accepted, sizeof(accepted), &accepted_size) ||
|
|
!write_initial_sync_state(context, request_id, run_id, initial) ||
|
|
!launch_sync_worker(context, config, run_id, request_id)) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
|
"{\"error\":\"sync_worker_start_failed\"}\n",
|
|
NULL);
|
|
}
|
|
return queue_sync_json_sized(connection,
|
|
MHD_HTTP_ACCEPTED,
|
|
accepted,
|
|
accepted_size,
|
|
context->csrf_token,
|
|
"/api/v1/sync/status");
|
|
}
|
|
if (strcmp(url, "/api/v1/dashboard") == 0) {
|
|
TrainlogWebDashboardQuery query;
|
|
TrainlogWebDashboardSnapshot snapshot;
|
|
char json[TRAINLOG_WEB_JSON_CAPACITY];
|
|
size_t json_size;
|
|
time_t now;
|
|
if (!is_get) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
|
"{\"error\":\"method_not_allowed\"}\n",
|
|
"GET");
|
|
}
|
|
now = time(NULL);
|
|
if (now == (time_t)-1 || (int64_t)now != (int64_t)(time_t)now) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
|
"{\"error\":\"clock_unavailable\"}\n",
|
|
NULL);
|
|
}
|
|
query.reference_unix_second = (int64_t)now;
|
|
if (trainlog_web_dashboard_load(context->database, &query, &snapshot) !=
|
|
TRAINLOG_STATUS_OK ||
|
|
trainlog_web_dashboard_serialize(&snapshot, json, sizeof(json), &json_size) !=
|
|
TRAINLOG_STATUS_OK ||
|
|
json_size == 0U) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
|
"{\"error\":\"dashboard_unavailable\"}\n",
|
|
NULL);
|
|
}
|
|
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
|
}
|
|
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
|
|
TrainlogDashboardLayout layout;
|
|
TrainlogDashboardLayout saved;
|
|
TrainlogDashboardLayoutSource source;
|
|
TrainlogDashboardLayoutResult layout_result;
|
|
char json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
|
|
size_t json_size;
|
|
uint64_t expected;
|
|
const char *origin;
|
|
const char *csrf;
|
|
const char *if_match;
|
|
/* WHY: loopback alone does not stop a hostile webpage from submitting
|
|
* requests to a local service. CONTRACT: every mutation needs exact
|
|
* trusted Origin, the startup-random header token and an If-Match ETag.
|
|
* INVARIANT: proxy Host rewriting remains strict and no CORS is added. */
|
|
if (is_get) {
|
|
layout_result = trainlog_dashboard_layout_load(&layout, &source);
|
|
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
|
|
!trainlog_dashboard_layout_serialize(
|
|
&layout, source, true, json, sizeof(json), &json_size) ||
|
|
json_size == 0U) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
|
"{\"error\":\"layout_unavailable\"}\n",
|
|
NULL);
|
|
}
|
|
if (source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED &&
|
|
!context->invalid_layout_reported) {
|
|
(void)fprintf(stderr,
|
|
"Trainlog Web : agencement persisté invalide, défaut utilisé\n");
|
|
context->invalid_layout_reported = true;
|
|
}
|
|
return queue_layout_json(
|
|
connection, MHD_HTTP_OK, json, layout.revision, context->csrf_token);
|
|
}
|
|
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
|
|
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
|
"{\"error\":\"method_not_allowed\"}\n",
|
|
"GET, PUT, DELETE");
|
|
}
|
|
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
|
|
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
|
|
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
|
}
|
|
if_match =
|
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
|
|
if (!parse_if_match(if_match, &expected)) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_PRECONDITION_REQUIRED,
|
|
"{\"error\":\"precondition_required\"}\n",
|
|
NULL);
|
|
}
|
|
if (strcmp(method, MHD_HTTP_METHOD_DELETE) == 0) {
|
|
if (state->body_size != 0U) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"unexpected_body\"}\n", NULL);
|
|
}
|
|
layout_result = trainlog_dashboard_layout_delete(expected);
|
|
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_PRECONDITION_FAILED,
|
|
"{\"error\":\"revision_conflict\"}\n",
|
|
NULL);
|
|
}
|
|
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
|
"{\"error\":\"layout_delete_failed\"}\n",
|
|
NULL);
|
|
}
|
|
trainlog_dashboard_layout_default(&layout);
|
|
if (!trainlog_dashboard_layout_serialize(&layout,
|
|
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT,
|
|
true,
|
|
json,
|
|
sizeof(json),
|
|
&json_size)) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
|
"{\"error\":\"layout_unavailable\"}\n",
|
|
NULL);
|
|
}
|
|
return queue_layout_json(connection, MHD_HTTP_OK, json, 0U, context->csrf_token);
|
|
}
|
|
{
|
|
const char *content_type = MHD_lookup_connection_value(
|
|
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
|
|
if (content_type == NULL || strcmp(content_type, "application/json") != 0) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
|
|
"{\"error\":\"unsupported_media_type\"}\n",
|
|
NULL);
|
|
}
|
|
}
|
|
layout_result = trainlog_dashboard_layout_parse(state->body, state->body_size, &layout);
|
|
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || layout.revision != expected) {
|
|
return queue_json(
|
|
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_layout\"}\n", NULL);
|
|
}
|
|
layout_result = trainlog_dashboard_layout_save(&layout, expected, &saved);
|
|
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_PRECONDITION_FAILED,
|
|
"{\"error\":\"revision_conflict\"}\n",
|
|
NULL);
|
|
}
|
|
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
|
|
!trainlog_dashboard_layout_serialize(&saved,
|
|
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED,
|
|
true,
|
|
json,
|
|
sizeof(json),
|
|
&json_size)) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
|
"{\"error\":\"layout_save_failed\"}\n",
|
|
NULL);
|
|
}
|
|
return queue_layout_json(
|
|
connection, MHD_HTTP_OK, json, saved.revision, context->csrf_token);
|
|
}
|
|
/* INVARIANT: API paths never fall through to the SPA index. */
|
|
if (strncmp(url, "/api/", 5U) == 0) {
|
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
|
}
|
|
if (strstr(url, "..") != NULL) {
|
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
|
}
|
|
asset = is_ui_route(url) ? trainlog_web_index_asset() : trainlog_web_asset_find(url);
|
|
if (asset == NULL) {
|
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
|
}
|
|
if (!is_get && !is_head) {
|
|
return queue_json(connection,
|
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
|
"{\"error\":\"method_not_allowed\"}\n",
|
|
"GET, HEAD");
|
|
}
|
|
return queue_asset(connection, asset);
|
|
}
|
|
|
|
static void request_completed(void *closure,
|
|
struct MHD_Connection *connection,
|
|
void **request_closure,
|
|
enum MHD_RequestTerminationCode termination) {
|
|
(void)closure;
|
|
(void)connection;
|
|
(void)termination;
|
|
free(*request_closure);
|
|
*request_closure = NULL;
|
|
}
|
|
|
|
static enum MHD_Result
|
|
accept_loopback_only(void *closure, const struct sockaddr *address, socklen_t address_length) {
|
|
const struct sockaddr_in *ipv4;
|
|
(void)closure;
|
|
if (address == NULL || address_length < (socklen_t)sizeof(*ipv4) ||
|
|
address->sa_family != AF_INET) {
|
|
return MHD_NO;
|
|
}
|
|
ipv4 = (const struct sockaddr_in *)address;
|
|
return ipv4->sin_addr.s_addr == htonl(INADDR_LOOPBACK) ? MHD_YES : MHD_NO;
|
|
}
|
|
|
|
int trainlog_web_server_run(TrainlogDatabase *database,
|
|
const char *database_path,
|
|
uint16_t port,
|
|
const volatile sig_atomic_t *stop_requested,
|
|
char *diagnostic,
|
|
size_t diagnostic_capacity) {
|
|
struct sockaddr_in bind_address;
|
|
struct MHD_Daemon *daemon;
|
|
TrainlogWebContext context;
|
|
int result = 0;
|
|
if (database == NULL || database_path == NULL || database_path[0] != '/' ||
|
|
stop_requested == NULL) {
|
|
return -1;
|
|
}
|
|
web_diagnostic(diagnostic, diagnostic_capacity, "");
|
|
if (!trainlog_web_assets_available()) {
|
|
web_diagnostic(diagnostic, diagnostic_capacity, "support frontend absent de ce binaire");
|
|
return -1;
|
|
}
|
|
(void)memset(&bind_address, 0, sizeof(bind_address));
|
|
bind_address.sin_family = AF_INET;
|
|
bind_address.sin_port = htons(port);
|
|
bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
context.port = port;
|
|
context.database = database;
|
|
context.database_path = database_path;
|
|
context.worker_pid = -1;
|
|
if (snprintf(
|
|
context.state_path, sizeof(context.state_path), "%s.sync-run.json", database_path) <
|
|
0 ||
|
|
strlen(context.state_path) >= sizeof(context.state_path) - 1U) {
|
|
web_diagnostic(diagnostic, diagnostic_capacity, "chemin d'état sync trop long");
|
|
return -1;
|
|
}
|
|
context.invalid_layout_reported = false;
|
|
if (!generate_csrf_token(context.csrf_token)) {
|
|
web_diagnostic(diagnostic, diagnostic_capacity, "impossible de générer la protection CSRF");
|
|
return -1;
|
|
}
|
|
errno = 0;
|
|
daemon = MHD_start_daemon(MHD_USE_ERROR_LOG | MHD_USE_NO_THREAD_SAFETY,
|
|
0U,
|
|
accept_loopback_only,
|
|
NULL,
|
|
handle_request,
|
|
&context,
|
|
MHD_OPTION_SOCK_ADDR_LEN,
|
|
(socklen_t)sizeof(bind_address),
|
|
&bind_address,
|
|
MHD_OPTION_CONNECTION_LIMIT,
|
|
TRAINLOG_HTTP_CONNECTION_LIMIT,
|
|
MHD_OPTION_PER_IP_CONNECTION_LIMIT,
|
|
TRAINLOG_HTTP_PER_IP_LIMIT,
|
|
MHD_OPTION_CONNECTION_MEMORY_LIMIT,
|
|
(size_t)TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT,
|
|
MHD_OPTION_CONNECTION_TIMEOUT,
|
|
TRAINLOG_HTTP_TIMEOUT_SECONDS,
|
|
MHD_OPTION_LISTEN_BACKLOG_SIZE,
|
|
TRAINLOG_HTTP_BACKLOG,
|
|
MHD_OPTION_NOTIFY_COMPLETED,
|
|
request_completed,
|
|
NULL,
|
|
MHD_OPTION_END);
|
|
if (daemon == NULL) {
|
|
char message[256];
|
|
(void)snprintf(message,
|
|
sizeof(message),
|
|
"impossible d'écouter sur 127.0.0.1:%u%s%s",
|
|
(unsigned int)port,
|
|
errno == 0 ? "" : " : ",
|
|
errno == 0 ? "" : strerror(errno));
|
|
web_diagnostic(diagnostic, diagnostic_capacity, message);
|
|
return -1;
|
|
}
|
|
(void)printf("Trainlog Web : http://127.0.0.1:%u/\n", (unsigned int)port);
|
|
(void)fflush(stdout);
|
|
while (*stop_requested == 0) {
|
|
fd_set read_set;
|
|
fd_set write_set;
|
|
fd_set except_set;
|
|
struct timeval timeout;
|
|
unsigned long long timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS;
|
|
int maximum_fd = 0;
|
|
int selected;
|
|
if (context.worker_pid > 0) {
|
|
int worker_status;
|
|
if (waitpid(context.worker_pid, &worker_status, WNOHANG) == context.worker_pid) {
|
|
context.worker_pid = -1;
|
|
}
|
|
}
|
|
FD_ZERO(&read_set);
|
|
FD_ZERO(&write_set);
|
|
FD_ZERO(&except_set);
|
|
if (MHD_get_fdset(daemon, &read_set, &write_set, &except_set, &maximum_fd) != MHD_YES) {
|
|
web_diagnostic(
|
|
diagnostic, diagnostic_capacity, "impossible de préparer la boucle HTTP");
|
|
result = -1;
|
|
break;
|
|
}
|
|
if (MHD_get_timeout(daemon, &timeout_ms) != MHD_YES ||
|
|
timeout_ms > TRAINLOG_HTTP_POLL_MAX_MS) {
|
|
timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS;
|
|
}
|
|
timeout.tv_sec = (time_t)(timeout_ms / 1000ULL);
|
|
timeout.tv_usec = (suseconds_t)((timeout_ms % 1000ULL) * 1000ULL);
|
|
selected = select(maximum_fd + 1, &read_set, &write_set, &except_set, &timeout);
|
|
if (selected < 0) {
|
|
if (errno == EINTR) {
|
|
continue;
|
|
}
|
|
web_diagnostic(diagnostic, diagnostic_capacity, "échec de la boucle HTTP locale");
|
|
result = -1;
|
|
break;
|
|
}
|
|
if (*stop_requested == 0 &&
|
|
MHD_run_from_select(daemon, &read_set, &write_set, &except_set) != MHD_YES) {
|
|
web_diagnostic(diagnostic, diagnostic_capacity, "échec du traitement HTTP local");
|
|
result = -1;
|
|
break;
|
|
}
|
|
}
|
|
MHD_stop_daemon(daemon);
|
|
if (context.worker_pid > 0) {
|
|
int worker_status;
|
|
pid_t waited = waitpid(context.worker_pid, &worker_status, WNOHANG);
|
|
if (waited == 0) {
|
|
size_t attempt;
|
|
struct timespec delay = {0, 100000000L};
|
|
(void)kill(-context.worker_pid, SIGTERM);
|
|
for (attempt = 0U; attempt < 50U; ++attempt) {
|
|
waited = waitpid(context.worker_pid, &worker_status, WNOHANG);
|
|
if (waited == context.worker_pid || waited < 0) {
|
|
break;
|
|
}
|
|
(void)nanosleep(&delay, NULL);
|
|
}
|
|
if (waited == 0) {
|
|
(void)kill(-context.worker_pid, SIGKILL);
|
|
(void)waitpid(context.worker_pid, &worker_status, 0);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|