summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client.c320
-rw-r--r--src/client.h42
-rw-r--r--src/console/rcon.c238
-rw-r--r--src/console/rcon.h39
-rw-r--r--src/daemon.c148
-rw-r--r--src/daemon.h27
-rw-r--r--src/log.c60
-rw-r--r--src/log.h22
-rw-r--r--src/log_tail.c118
-rw-r--r--src/log_tail.h31
-rw-r--r--src/main.c240
-rw-r--r--src/proto.c183
-rw-r--r--src/proto.h70
-rw-r--r--src/umbrella.h121
-rw-r--r--src/unit.c295
-rw-r--r--src/unit.h45
16 files changed, 1999 insertions, 0 deletions
diff --git a/src/client.c b/src/client.c
new file mode 100644
index 0000000..034ea74
--- /dev/null
+++ b/src/client.c
@@ -0,0 +1,320 @@
+#include "client.h"
+#include "proto.h"
+#include "unit.h"
+#include "log.h"
+#include "umbrella.h"
+#include "console/rcon.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+#include <sys/epoll.h>
+#include <sys/stat.h>
+
+/* ── Socket setup ────────────────────────────────────────────────────────── */
+
+int client_listen(void) {
+ /* Remove stale socket if present */
+ unlink(UMBRELLA_SOCK_PATH);
+
+ int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
+ if (fd < 0) {
+ log_error("socket: %s", strerror(errno));
+ return -1;
+ }
+
+ struct sockaddr_un addr;
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, UMBRELLA_SOCK_PATH, sizeof(addr.sun_path) - 1);
+
+ if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ log_error("bind %s: %s", UMBRELLA_SOCK_PATH, strerror(errno));
+ close(fd);
+ return -1;
+ }
+
+ /* Owner read/write, group read/write, others nothing */
+ chmod(UMBRELLA_SOCK_PATH, 0660);
+
+ if (listen(fd, 16) != 0) {
+ log_error("listen: %s", strerror(errno));
+ close(fd);
+ return -1;
+ }
+
+ log_info("Listening on %s", UMBRELLA_SOCK_PATH);
+ return fd;
+}
+
+/* ── Client lifecycle ────────────────────────────────────────────────────── */
+
+int client_accept(int listen_fd) {
+ if (g.client_count >= MAX_CLIENTS) {
+ log_warn("Max clients reached, rejecting connection");
+ /* Accept and immediately close */
+ int tmp = accept(listen_fd, NULL, NULL);
+ if (tmp >= 0) close(tmp);
+ return -1;
+ }
+
+ int fd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
+ if (fd < 0) {
+ if (errno != EAGAIN && errno != EWOULDBLOCK)
+ log_error("accept: %s", strerror(errno));
+ return -1;
+ }
+
+ /* Get peer credentials for access control */
+ struct ucred cred;
+ socklen_t cred_len = sizeof(cred);
+ int uid = -1;
+ if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &cred_len) == 0)
+ uid = (int)cred.uid;
+
+ /* Find a free client slot */
+ int slot = -1;
+ for (int i = 0; i < MAX_CLIENTS; i++) {
+ if (g.clients[i].fd == 0) { slot = i; break; }
+ }
+ if (slot < 0) {
+ close(fd);
+ return -1;
+ }
+
+ g.clients[slot].fd = fd;
+ g.clients[slot].uid = uid;
+ g.clients[slot].attached[0] = '\0';
+ g.client_count++;
+
+ /* Register with epoll */
+ struct epoll_event ev;
+ ev.events = EPOLLIN | EPOLLRDHUP;
+ ev.data.fd = fd;
+ epoll_ctl(g.epoll_fd, EPOLL_CTL_ADD, fd, &ev);
+
+ log_debug("Client connected (fd=%d uid=%d)", fd, uid);
+ return 0;
+}
+
+Client *client_find(int fd) {
+ for (int i = 0; i < MAX_CLIENTS; i++) {
+ if (g.clients[i].fd == fd)
+ return &g.clients[i];
+ }
+ return NULL;
+}
+
+void client_remove(int fd) {
+ for (int i = 0; i < MAX_CLIENTS; i++) {
+ if (g.clients[i].fd == fd) {
+ log_debug("Client disconnected (fd=%d)", fd);
+ epoll_ctl(g.epoll_fd, EPOLL_CTL_DEL, fd, NULL);
+ close(fd);
+ memset(&g.clients[i], 0, sizeof(Client));
+ g.client_count--;
+ return;
+ }
+ }
+}
+
+void client_broadcast_output(const char *unit_name,
+ const char *data, int history) {
+ for (int i = 0; i < MAX_CLIENTS; i++) {
+ if (g.clients[i].fd == 0) continue;
+ if (strcmp(g.clients[i].attached, unit_name) == 0)
+ proto_send_output(g.clients[i].fd, unit_name, data, history);
+ }
+}
+
+/* ── Command handlers ────────────────────────────────────────────────────── */
+
+static void cmd_list(Client *c) {
+ /* Build a JSON array of unit summaries */
+ char buf[PROTO_MAX_MSG];
+ int pos = 0;
+
+ pos += snprintf(buf + pos, sizeof(buf) - pos,
+ "{\"type\":\"list\",\"units\":[");
+
+ for (int i = 0; i < g.unit_count; i++) {
+ Unit *u = &g.units[i];
+ if (i > 0)
+ pos += snprintf(buf + pos, sizeof(buf) - pos, ",");
+ pos += snprintf(buf + pos, sizeof(buf) - pos,
+ "{\"name\":\"%s\",\"display\":\"%s\","
+ "\"state\":\"%s\"}",
+ u->name, u->display, unit_state_str(u->state));
+ }
+
+ snprintf(buf + pos, sizeof(buf) - pos, "]}");
+ proto_send(c->fd, buf);
+}
+
+static const char *probe_rcon_state(Unit *u) {
+ if (u->console.type != CONSOLE_RCON)
+ return unit_state_str(u->state);
+
+ /* Check systemd service state first -- cheap, no network */
+ if (u->service[0]) {
+ char cmd[256];
+ snprintf(cmd, sizeof(cmd),
+ "systemctl is-active --quiet %s 2>/dev/null", u->service);
+ if (system(cmd) != 0)
+ return "stopped";
+ }
+
+ /* Service is active -- probe RCON to confirm reachability */
+ char response[64] = {0};
+ int r = rcon_exec(u->console.host, u->console.port,
+ u->console.password, "echo umbrella_probe",
+ response, sizeof(response));
+ return (r == 0) ? "running" : "unreachable";
+}
+
+static void cmd_status(Client *c, const char *unit_name) {
+ Unit *u = unit_find(unit_name);
+ if (!u) { proto_send_error(c->fd, "unit not found"); return; }
+
+ const char *state = probe_rcon_state(u);
+
+ char buf[512];
+ snprintf(buf, sizeof(buf),
+ "{\"type\":\"status\",\"unit\":\"%s\",\"display\":\"%s\","
+ "\"state\":\"%s\",\"pid\":%d}",
+ u->name, u->display, state, (int)u->pid);
+ proto_send(c->fd, buf);
+}
+static void cmd_attach(Client *c, const char *unit_name) {
+ Unit *u = unit_find(unit_name);
+ if (!u) { proto_send_error(c->fd, "unit not found"); return; }
+
+ strncpy(c->attached, unit_name, MAX_NAME - 1);
+ log_info("Client fd=%d attached to %s", c->fd, unit_name);
+
+ /* Send ring buffer history first */
+ RingBuffer *rb = u->output;
+ if (rb && rb->count > 0) {
+ int start = (rb->head - rb->count + RING_BUF_LINES) % RING_BUF_LINES;
+ for (int i = 0; i < rb->count; i++) {
+ int idx = (start + i) % RING_BUF_LINES;
+ proto_send_output(c->fd, unit_name, rb->lines[idx], 1);
+ }
+ }
+
+ proto_send_ok(c->fd);
+}
+
+static void cmd_detach(Client *c) {
+ if (c->attached[0])
+ log_info("Client fd=%d detached from %s", c->fd, c->attached);
+ c->attached[0] = '\0';
+ proto_send_ok(c->fd);
+}
+
+static void cmd_input(Client *c, const char *unit_name, const char *data) {
+ Unit *u = unit_find(unit_name);
+ if (!u) { proto_send_error(c->fd, "unit not found"); return; }
+
+ if (u->console.type == CONSOLE_RCON) {
+ /* Send via RCON — open a connection, send, close */
+ char response[4096] = {0};
+ int r = rcon_exec(u->console.host, u->console.port,
+ u->console.password, data,
+ response, sizeof(response));
+ if (r != 0) {
+ proto_send_error(c->fd, "RCON command failed");
+ return;
+ }
+ /* Echo the response back as output */
+ if (response[0])
+ proto_send_output(c->fd, unit_name, response, 0);
+ proto_send_ok(c->fd);
+
+ } else if (u->console.type == CONSOLE_STDIN) {
+ if (u->stdin_fd < 0) {
+ proto_send_error(c->fd, "process not running");
+ return;
+ }
+ ssize_t n = write(u->stdin_fd, data, strlen(data));
+ if (n < 0) {
+ proto_send_error(c->fd, "write to stdin failed");
+ return;
+ }
+ proto_send_ok(c->fd);
+ }
+}
+
+static void cmd_action(Client *c, const char *unit_name,
+ const char *action_name) {
+ Unit *u = unit_find(unit_name);
+ if (!u) { proto_send_error(c->fd, "unit not found"); return; }
+
+ /* Find the action */
+ const char *script = NULL;
+ for (int i = 0; i < u->action_count; i++) {
+ if (strcmp(u->actions[i].name, action_name) == 0) {
+ script = u->actions[i].script;
+ break;
+ }
+ }
+ if (!script) { proto_send_error(c->fd, "action not found"); return; }
+
+ log_info("Running action '%s' for unit %s: %s",
+ action_name, unit_name, script);
+
+ /* Fork a child to run the script */
+ pid_t pid = fork();
+ if (pid < 0) {
+ proto_send_error(c->fd, "fork failed");
+ return;
+ }
+ if (pid == 0) {
+ /* Child: exec the script */
+ execl("/bin/bash", "bash", script, NULL);
+ _exit(127);
+ }
+
+ /* Parent: don't wait — SIGCHLD handler will reap it */
+ proto_send_ok(c->fd);
+}
+
+/* ── Main dispatch ───────────────────────────────────────────────────────── */
+
+int client_handle(int fd) {
+ Client *c = client_find(fd);
+ if (!c) return -1;
+
+ char buf[PROTO_MAX_MSG];
+ int n = proto_recv(fd, buf, sizeof(buf));
+
+ if (n <= 0) return -1; /* 0 = disconnect, -1 = error */
+
+ /* Extract command and fields */
+ char cmd[32] = {0};
+ char unit[MAX_NAME] = {0};
+ char action[MAX_NAME] = {0};
+ char data[RING_BUF_LINE_MAX] = {0};
+
+ json_get_str(buf, "cmd", cmd, sizeof(cmd));
+ json_get_str(buf, "unit", unit, sizeof(unit));
+ json_get_str(buf, "action", action, sizeof(action));
+ json_get_str(buf, "data", data, sizeof(data));
+
+ log_debug("Client fd=%d cmd=%s unit=%s", fd, cmd, unit);
+
+ if (strcmp(cmd, "list") == 0) cmd_list(c);
+ else if (strcmp(cmd, "status") == 0) cmd_status(c, unit);
+ else if (strcmp(cmd, "attach") == 0) cmd_attach(c, unit);
+ else if (strcmp(cmd, "detach") == 0) cmd_detach(c);
+ else if (strcmp(cmd, "input") == 0) cmd_input(c, unit, data);
+ else if (strcmp(cmd, "action") == 0) cmd_action(c, unit, action);
+ else proto_send_error(fd, "unknown command");
+
+ return 0;
+}
diff --git a/src/client.h b/src/client.h
new file mode 100644
index 0000000..fa607e1
--- /dev/null
+++ b/src/client.h
@@ -0,0 +1,42 @@
+#ifndef UMBRELLA_CLIENT_H
+#define UMBRELLA_CLIENT_H
+
+#include "umbrella.h"
+
+/*
+ * client_listen: create and bind the Unix domain socket.
+ * Returns the listening fd, or -1 on error.
+ */
+int client_listen(void);
+
+/*
+ * client_accept: accept a new client connection from listen_fd.
+ * Adds the client to g.clients and registers it with epoll.
+ * Returns 0 on success, -1 on error.
+ */
+int client_accept(int listen_fd);
+
+/*
+ * client_handle: read and dispatch one message from a client fd.
+ * Returns 0 to keep the connection, -1 to close it.
+ */
+int client_handle(int fd);
+
+/*
+ * client_remove: close a client connection and remove from g.clients.
+ */
+void client_remove(int fd);
+
+/*
+ * client_find: find a Client by fd.
+ * Returns pointer into g.clients, or NULL.
+ */
+Client *client_find(int fd);
+
+/*
+ * client_broadcast_output: send output to all clients attached to unit_name.
+ */
+void client_broadcast_output(const char *unit_name,
+ const char *data, int history);
+
+#endif /* UMBRELLA_CLIENT_H */
diff --git a/src/console/rcon.c b/src/console/rcon.c
new file mode 100644
index 0000000..5e958e1
--- /dev/null
+++ b/src/console/rcon.c
@@ -0,0 +1,238 @@
+#include "rcon.h"
+#include "../log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+#include <netdb.h>
+#include <arpa/inet.h>
+#include <sys/socket.h>
+#include <sys/time.h>
+
+/* ── Packet structure ────────────────────────────────────────────────────── */
+/*
+ * On the wire, each RCON packet is:
+ * [int32 size] [int32 id] [int32 type] [string\0] [\0]
+ *
+ * 'size' = sizeof(id) + sizeof(type) + strlen(body) + 2
+ * = 4 + 4 + strlen(body) + 2
+ *
+ * We use a fixed-size buffer — RCON responses for console commands are
+ * generally small (< 4KB), but we handle multi-packet responses.
+ */
+
+#define RCON_BUF_SIZE 4096
+#define RCON_HDR_SIZE 12 /* size(4) + id(4) + type(4) */
+#define RCON_TIMEOUT_S 5
+
+typedef struct {
+ int32_t size;
+ int32_t id;
+ int32_t type;
+ char body[RCON_BUF_SIZE];
+} RconPacket;
+
+/* ── Low-level helpers ───────────────────────────────────────────────────── */
+
+static int tcp_connect(const char *host, int port) {
+ struct addrinfo hints, *res, *rp;
+ char port_str[8];
+ int fd = -1;
+
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+
+ snprintf(port_str, sizeof(port_str), "%d", port);
+ if (getaddrinfo(host, port_str, &hints, &res) != 0) {
+ log_error("rcon: getaddrinfo(%s): %s", host, strerror(errno));
+ return -1;
+ }
+
+ for (rp = res; rp; rp = rp->ai_next) {
+ fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
+ if (fd < 0) continue;
+
+ /* Set connect timeout */
+ struct timeval tv = { .tv_sec = RCON_TIMEOUT_S, .tv_usec = 0 };
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+
+ if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0)
+ break;
+
+ close(fd);
+ fd = -1;
+ }
+
+ freeaddrinfo(res);
+ if (fd < 0)
+ log_error("rcon: could not connect to %s:%d", host, port);
+ return fd;
+}
+
+static int rcon_send(int fd, int32_t id, int32_t type, const char *body) {
+ int body_len = body ? (int)strlen(body) : 0;
+ int32_t size = 4 + 4 + body_len + 2; /* id + type + body + two nulls */
+
+ /* Build packet in a local buffer */
+ uint8_t buf[RCON_BUF_SIZE + RCON_HDR_SIZE];
+ int32_t le_size = htole32(size);
+ int32_t le_id = htole32(id);
+ int32_t le_type = htole32(type);
+
+ memcpy(buf, &le_size, 4);
+ memcpy(buf + 4, &le_id, 4);
+ memcpy(buf + 8, &le_type, 4);
+ if (body_len > 0)
+ memcpy(buf + 12, body, body_len);
+ buf[12 + body_len] = '\0';
+ buf[12 + body_len + 1] = '\0';
+
+ int total = 4 + size;
+ ssize_t n = write(fd, buf, total);
+ return (n == total) ? 0 : -1;
+}
+
+static int rcon_recv(int fd, RconPacket *pkt) {
+ uint8_t hdr[12];
+ ssize_t n;
+
+ /* Read the fixed 12-byte header */
+ int got = 0;
+ while (got < 12) {
+ n = read(fd, hdr + got, 12 - got);
+ if (n <= 0) return -1;
+ got += n;
+ }
+
+ int32_t size, id, type;
+ memcpy(&size, hdr, 4); size = le32toh(size);
+ memcpy(&id, hdr + 4, 4); id = le32toh(id);
+ memcpy(&type, hdr + 8, 4); type = le32toh(type);
+
+ pkt->size = size;
+ pkt->id = id;
+ pkt->type = type;
+
+ /* Body length = size - sizeof(id) - sizeof(type) - 2 trailing nulls */
+ int body_len = size - 4 - 4 - 2;
+ if (body_len < 0 || body_len >= RCON_BUF_SIZE)
+ return -1;
+
+ if (body_len > 0) {
+ got = 0;
+ while (got < body_len) {
+ n = read(fd, pkt->body + got, body_len - got);
+ if (n <= 0) return -1;
+ got += n;
+ }
+ }
+ pkt->body[body_len] = '\0';
+
+ /* Consume the two trailing null bytes */
+ uint8_t nulls[2];
+ (void)read(fd, nulls, 2);
+
+ return 0;
+}
+
+/* ── Public interface ────────────────────────────────────────────────────── */
+
+int rcon_exec(const char *host, int port, const char *password,
+ const char *command, char *response, int resp_len) {
+ int fd = tcp_connect(host, port);
+ if (fd < 0) return -1;
+
+ RconPacket pkt;
+ int auth_id = 1;
+ int exec_id = 2;
+
+ /* Step 1: Send auth request */
+ if (rcon_send(fd, auth_id, RCON_AUTH_REQUEST, password) != 0) {
+ log_error("rcon: send auth failed");
+ close(fd);
+ return -1;
+ }
+
+ /*
+ * Step 2: Read auth response.
+ *
+ * srcds sends packets in this order after an auth request:
+ * 1. SERVERDATA_RESPONSE_VALUE (type=0, id=auth_id) -- empty body
+ * 2. SERVERDATA_AUTH_RESPONSE (type=2, id=auth_id) -- success
+ * OR
+ * SERVERDATA_AUTH_RESPONSE (type=2, id=-1) -- bad password
+ *
+ * Loop ignoring type=0 packets until we see type=2.
+ * 8 attempts handles servers that send extra interleaved packets.
+ */
+ int authed = 0;
+ for (int attempt = 0; attempt < 8; attempt++) {
+ if (rcon_recv(fd, &pkt) != 0) {
+ log_error("rcon: recv auth response failed (attempt %d)", attempt);
+ close(fd);
+ return -1;
+ }
+
+ log_debug("rcon: auth packet type=%d id=%d", pkt.type, pkt.id);
+
+ if (pkt.type == 2) {
+ if (pkt.id == -1) {
+ log_error("rcon: bad password -- verify rcon_password in "
+ "server.cfg matches the unit file configuration");
+ close(fd);
+ return -1;
+ }
+ authed = 1;
+ break;
+ }
+ /* type=0: empty value packet, keep reading */
+ }
+
+ if (!authed) {
+ log_error("rcon: no auth response after 8 packets");
+ close(fd);
+ return -1;
+ }
+
+ /* Step 3: Send command */
+ if (rcon_send(fd, exec_id, RCON_EXEC_REQUEST, command) != 0) {
+ log_error("rcon: send command failed");
+ close(fd);
+ return -1;
+ }
+
+ /*
+ * Step 4: Read response.
+ * Large responses may be split across multiple packets.
+ * We send a known "terminator" packet and collect until we see its id.
+ * This is the standard trick for handling multi-packet responses.
+ */
+ int term_id = 3;
+ rcon_send(fd, term_id, RCON_EXEC_REQUEST, "");
+
+ int pos = 0;
+ if (response) response[0] = '\0';
+
+ for (int i = 0; i < 64; i++) { /* safety limit */
+ if (rcon_recv(fd, &pkt) != 0) break;
+
+ if (pkt.id == term_id)
+ break; /* saw our terminator response — done */
+
+ if (response && pkt.body[0]) {
+ int chunk = (int)strlen(pkt.body);
+ if (pos + chunk < resp_len - 1) {
+ memcpy(response + pos, pkt.body, chunk);
+ pos += chunk;
+ response[pos] = '\0';
+ }
+ }
+ }
+
+ close(fd);
+ return 0;
+}
diff --git a/src/console/rcon.h b/src/console/rcon.h
new file mode 100644
index 0000000..ffcbde7
--- /dev/null
+++ b/src/console/rcon.h
@@ -0,0 +1,39 @@
+#ifndef UMBRELLA_RCON_H
+#define UMBRELLA_RCON_H
+
+/*
+ * Valve RCON protocol implementation.
+ * https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
+ *
+ * RCON uses a simple TCP framing:
+ * [int32 size][int32 id][int32 type][string body][null][null]
+ *
+ * Types:
+ * SERVERDATA_AUTH = 3
+ * SERVERDATA_AUTH_RESPONSE = 2
+ * SERVERDATA_EXECCOMMAND = 2 (same value as AUTH_RESPONSE — context-dependent)
+ * SERVERDATA_RESPONSE_VALUE = 0
+ */
+
+#define RCON_AUTH_REQUEST 3
+#define RCON_AUTH_RESPONSE 2
+#define RCON_EXEC_REQUEST 2
+#define RCON_EXEC_RESPONSE 0
+
+/*
+ * rcon_exec: connect to an RCON server, authenticate, run a command,
+ * collect the response, and disconnect.
+ *
+ * host - server hostname or IP
+ * port - RCON port (usually 27015)
+ * password - RCON password
+ * command - command string to execute
+ * response - output buffer
+ * resp_len - size of response buffer
+ *
+ * Returns 0 on success, -1 on failure.
+ */
+int rcon_exec(const char *host, int port, const char *password,
+ const char *command, char *response, int resp_len);
+
+#endif /* UMBRELLA_RCON_H */
diff --git a/src/daemon.c b/src/daemon.c
new file mode 100644
index 0000000..9714a65
--- /dev/null
+++ b/src/daemon.c
@@ -0,0 +1,148 @@
+//#include "daemon.h"
+#include "umbrella.h"
+#include "log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <signal.h>
+#include <sys/stat.h>
+#include <sys/signalfd.h>
+
+/*
+ * Create a directory and all parents, like mkdir -p.
+ * Returns 0 if the directory exists or was created, -1 on error.
+ */
+static int mkdirp(const char *path, mode_t mode) {
+ char tmp[MAX_PATH];
+ char *p;
+ size_t len;
+
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (tmp[len - 1] == '/')
+ tmp[len - 1] = '\0';
+
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ if (mkdir(tmp, mode) != 0 && errno != EEXIST)
+ return -1;
+ *p = '/';
+ }
+ }
+ if (mkdir(tmp, mode) != 0 && errno != EEXIST)
+ return -1;
+ return 0;
+}
+
+int daemon_daemonize(int foreground) {
+ if (foreground)
+ return 0;
+
+ pid_t pid = fork();
+ if (pid < 0) {
+ perror("fork");
+ return -1;
+ }
+ if (pid > 0)
+ exit(0); /* parent exits */
+
+ /* Child: become session leader */
+ if (setsid() < 0) {
+ perror("setsid");
+ return -1;
+ }
+
+ /* Fork again so we're not a session leader (can't acquire a tty) */
+ pid = fork();
+ if (pid < 0) {
+ perror("fork");
+ return -1;
+ }
+ if (pid > 0)
+ exit(0);
+
+ /* Redirect stdin/stdout/stderr to /dev/null */
+ int dev_null = open("/dev/null", O_RDWR);
+ if (dev_null >= 0) {
+ dup2(dev_null, STDIN_FILENO);
+ dup2(dev_null, STDOUT_FILENO);
+ dup2(dev_null, STDERR_FILENO);
+ if (dev_null > STDERR_FILENO)
+ close(dev_null);
+ }
+
+ umask(027);
+ return 0;
+}
+
+int daemon_write_pid(void) {
+ /* Ensure the run directory exists */
+ if (mkdirp("/run/umbrella", 0755) != 0 && errno != EEXIST) {
+ log_error("Could not create /run/umbrella: %s", strerror(errno));
+ return -1;
+ }
+
+ FILE *f = fopen(UMBRELLA_PID_FILE, "w");
+ if (!f) {
+ log_error("Could not write pid file %s: %s",
+ UMBRELLA_PID_FILE, strerror(errno));
+ return -1;
+ }
+ fprintf(f, "%d\n", (int)getpid());
+ fclose(f);
+ return 0;
+}
+
+int daemon_init(void) {
+ /* Ensure runtime and log directories exist */
+ if (mkdirp("/run/umbrella", 0755) != 0 && errno != EEXIST) {
+ log_error("mkdirp /run/umbrella: %s", strerror(errno));
+ return -1;
+ }
+ if (mkdirp("/var/log/umbrella", 0755) != 0 && errno != EEXIST) {
+ log_error("mkdirp /var/log/umbrella: %s", strerror(errno));
+ return -1;
+ }
+
+ if (daemon_write_pid() != 0)
+ return -1;
+
+ /*
+ * Set up a signalfd so signals arrive as readable events on the
+ * epoll loop rather than interrupting syscalls unpredictably.
+ * We block the signals first, then read them via the fd.
+ */
+ sigset_t mask;
+ sigemptyset(&mask);
+ sigaddset(&mask, SIGINT);
+ sigaddset(&mask, SIGTERM);
+ sigaddset(&mask, SIGCHLD); /* child process state changes */
+ sigaddset(&mask, SIGHUP); /* reload config */
+ sigaddset(&mask, SIGPIPE); /* ignore broken pipe */
+
+ if (sigprocmask(SIG_BLOCK, &mask, NULL) != 0) {
+ log_error("sigprocmask: %s", strerror(errno));
+ return -1;
+ }
+
+ int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
+ if (sfd < 0) {
+ log_error("signalfd: %s", strerror(errno));
+ return -1;
+ }
+
+ g.signal_fd = sfd;
+ log_info("Daemon initialized (pid %d)", (int)getpid());
+ return 0;
+}
+
+void daemon_cleanup(void) {
+ log_info("Cleaning up...");
+ unlink(UMBRELLA_PID_FILE);
+ unlink(UMBRELLA_SOCK_PATH);
+}
diff --git a/src/daemon.h b/src/daemon.h
new file mode 100644
index 0000000..b992ab3
--- /dev/null
+++ b/src/daemon.h
@@ -0,0 +1,27 @@
+#ifndef UMBRELLA_DAEMON_H
+#define UMBRELLA_DAEMON_H
+
+/*
+ * daemon_init: set up signal handling, pid file, and socket directory.
+ * Returns 0 on success, -1 on error.
+ */
+int daemon_init(void);
+
+/*
+ * daemon_daemonize: fork into background, detach from terminal.
+ * Call before daemon_init if running as a true background daemon.
+ * Pass 0 to run in foreground (useful for debugging/systemd).
+ */
+int daemon_daemonize(int foreground);
+
+/*
+ * daemon_cleanup: remove pid file and socket on exit.
+ */
+void daemon_cleanup(void);
+
+/*
+ * daemon_write_pid: write our pid to UMBRELLA_PID_FILE.
+ */
+int daemon_write_pid(void);
+
+#endif /* UMBRELLA_DAEMON_H */
diff --git a/src/log.c b/src/log.c
new file mode 100644
index 0000000..1c140be
--- /dev/null
+++ b/src/log.c
@@ -0,0 +1,60 @@
+#include "log.h"
+#include <stdio.h>
+#include <stdarg.h>
+#include <time.h>
+#include <string.h>
+
+static FILE *log_file = NULL;
+static LogLevel min_log_level = LOG_INFO;
+
+static const char *level_str(LogLevel level) {
+ switch (level) {
+ case LOG_DEBUG: return "DEBUG";
+ case LOG_INFO: return "INFO ";
+ case LOG_WARN: return "WARN ";
+ case LOG_ERROR: return "ERROR";
+ default: return "?????";
+ }
+}
+
+void log_init(const char *path, LogLevel min_level) {
+ min_log_level = min_level;
+ if (path) {
+ log_file = fopen(path, "a");
+ if (!log_file) {
+ /* Fall back to stderr if we can't open the log file */
+ fprintf(stderr, "umbrella: could not open log file %s, using stderr\n", path);
+ log_file = stderr;
+ }
+ } else {
+ log_file = stderr;
+ }
+}
+
+void log_close(void) {
+ if (log_file && log_file != stderr) {
+ fclose(log_file);
+ log_file = NULL;
+ }
+}
+
+void log_write(LogLevel level, const char *fmt, ...) {
+ if (level < min_log_level) return;
+ if (!log_file) log_file = stderr;
+
+ /* Timestamp */
+ time_t now = time(NULL);
+ struct tm *tm = localtime(&now);
+ char ts[32];
+ strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", tm);
+
+ fprintf(log_file, "[%s] [%s] ", ts, level_str(level));
+
+ va_list args;
+ va_start(args, fmt);
+ vfprintf(log_file, fmt, args);
+ va_end(args);
+
+ fprintf(log_file, "\n");
+ fflush(log_file);
+}
diff --git a/src/log.h b/src/log.h
new file mode 100644
index 0000000..c3c73a2
--- /dev/null
+++ b/src/log.h
@@ -0,0 +1,22 @@
+#ifndef UMBRELLA_LOG_H
+#define UMBRELLA_LOG_H
+
+#include <stdio.h>
+
+typedef enum {
+ LOG_DEBUG = 0,
+ LOG_INFO = 1,
+ LOG_WARN = 2,
+ LOG_ERROR = 3,
+} LogLevel;
+
+void log_init(const char *path, LogLevel min_level);
+void log_close(void);
+void log_write(LogLevel level, const char *fmt, ...);
+
+#define log_debug(...) log_write(LOG_DEBUG, __VA_ARGS__)
+#define log_info(...) log_write(LOG_INFO, __VA_ARGS__)
+#define log_warn(...) log_write(LOG_WARN, __VA_ARGS__)
+#define log_error(...) log_write(LOG_ERROR, __VA_ARGS__)
+
+#endif /* UMBRELLA_LOG_H */
diff --git a/src/log_tail.c b/src/log_tail.c
new file mode 100644
index 0000000..d352f8f
--- /dev/null
+++ b/src/log_tail.c
@@ -0,0 +1,118 @@
+#include "log_tail.h"
+#include "client.h"
+#include "unit.h"
+#include "log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <sys/epoll.h>
+#include <sys/inotify.h>
+
+#define MAX_LOG_WATCHES (MAX_UNITS * 4)
+
+typedef struct {
+ int inotify_fd;
+ int watch_wd;
+ int log_fd;
+ Unit *unit;
+ char path[MAX_PATH];
+} LogWatch;
+
+static LogWatch watches[MAX_LOG_WATCHES];
+static int watch_count = 0;
+
+void log_tail_init(void) {
+ watch_count = 0;
+ log_info("log_tail_init: checking %d unit(s) for log paths", g.unit_count);
+ for (int i = 0; i < g.unit_count; i++) {
+ Unit *u = &g.units[i];
+ log_info("log_tail_init: unit '%s' has %d log path(s)",
+ u->name, u->log_count);
+ for (int j = 0; j < u->log_count; j++) {
+ const char *path = u->log_paths[j];
+ if (!path[0]) continue;
+ int log_fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC);
+ if (log_fd < 0) {
+ log_warn("log_tail: cannot open %s: %s", path, strerror(errno));
+ continue;
+ }
+ lseek(log_fd, 0, SEEK_END);
+ int ifd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
+ if (ifd < 0) {
+ log_error("inotify_init1: %s", strerror(errno));
+ close(log_fd);
+ continue;
+ }
+ int wd = inotify_add_watch(ifd, path, IN_MODIFY);
+ if (wd < 0) {
+ log_warn("log_tail: inotify_add_watch %s: %s",
+ path, strerror(errno));
+ close(ifd);
+ close(log_fd);
+ continue;
+ }
+ if (watch_count >= MAX_LOG_WATCHES) {
+ log_warn("log_tail: max watches reached, skipping %s", path);
+ close(ifd);
+ close(log_fd);
+ continue;
+ }
+ watches[watch_count].inotify_fd = ifd;
+ watches[watch_count].watch_wd = wd;
+ watches[watch_count].log_fd = log_fd;
+ watches[watch_count].unit = u;
+ strncpy(watches[watch_count].path, path, MAX_PATH - 1);
+ watch_count++;
+ struct epoll_event ev;
+ ev.events = EPOLLIN;
+ ev.data.fd = ifd;
+ epoll_ctl(g.epoll_fd, EPOLL_CTL_ADD, ifd, &ev);
+ log_info("log_tail: watching %s for unit %s", path, u->name);
+ }
+ }
+}
+
+Unit *log_tail_fd_to_unit(int fd) {
+ for (int i = 0; i < watch_count; i++) {
+ if (watches[i].inotify_fd == fd)
+ return watches[i].unit;
+ }
+ return NULL;
+}
+
+static LogWatch *watch_for_inotify_fd(int fd) {
+ for (int i = 0; i < watch_count; i++) {
+ if (watches[i].inotify_fd == fd)
+ return &watches[i];
+ }
+ return NULL;
+}
+
+void log_tail_handle(int inotify_fd) {
+ LogWatch *w = watch_for_inotify_fd(inotify_fd);
+ if (!w) return;
+ char evbuf[4096];
+ while (read(inotify_fd, evbuf, sizeof(evbuf)) > 0)
+ ;
+ char buf[RING_BUF_LINE_MAX];
+ ssize_t n;
+ while ((n = read(w->log_fd, buf, sizeof(buf) - 1)) > 0) {
+ buf[n] = '\0';
+ ring_push(w->unit->output, buf, (int)n);
+ client_broadcast_output(w->unit->name, buf, 0);
+ }
+}
+
+void log_tail_cleanup(void) {
+ for (int i = 0; i < watch_count; i++) {
+ epoll_ctl(g.epoll_fd, EPOLL_CTL_DEL, watches[i].inotify_fd, NULL);
+ inotify_rm_watch(watches[i].inotify_fd, watches[i].watch_wd);
+ close(watches[i].inotify_fd);
+ close(watches[i].log_fd);
+ }
+ watch_count = 0;
+}
diff --git a/src/log_tail.h b/src/log_tail.h
new file mode 100644
index 0000000..3131ff6
--- /dev/null
+++ b/src/log_tail.h
@@ -0,0 +1,31 @@
+#ifndef UMBRELLA_LOG_TAIL_H
+#define UMBRELLA_LOG_TAIL_H
+
+#include "umbrella.h"
+
+/*
+ * log_tail_init: open log files for all units that have them defined,
+ * seek to end, and register fds with epoll.
+ * Call once after units are loaded.
+ */
+void log_tail_init(void);
+
+/*
+ * log_tail_handle: called when a log fd becomes readable.
+ * Reads new lines, pushes to the unit's ring buffer, and broadcasts
+ * to all attached clients.
+ */
+void log_tail_handle(int fd);
+
+/*
+ * log_tail_cleanup: close all open log fds.
+ */
+void log_tail_cleanup(void);
+
+/*
+ * log_tail_fd_to_unit: find which unit owns a given log fd.
+ * Returns NULL if not found.
+ */
+Unit *log_tail_fd_to_unit(int fd);
+
+#endif /* UMBRELLA_LOG_TAIL_H */
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..f242489
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,240 @@
+#include "umbrella.h"
+#include "daemon.h"
+#include "unit.h"
+#include "client.h"
+#include "log.h"
+#include "log_tail.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+#include <signal.h>
+#include <sys/epoll.h>
+#include <sys/signalfd.h>
+#include <sys/wait.h>
+
+/* Global daemon state — defined here, declared extern in umbrella.h */
+Daemon g;
+
+/* ── Signal handling ─────────────────────────────────────────────────────── */
+
+static void handle_signal(void) {
+ struct signalfd_siginfo info;
+ ssize_t n = read(g.signal_fd, &info, sizeof(info));
+ if (n != sizeof(info)) return;
+
+ switch (info.ssi_signo) {
+ case SIGTERM:
+ case SIGINT:
+ log_info("Received signal %d — shutting down", info.ssi_signo);
+ g.running = 0;
+ break;
+
+ case SIGCHLD:
+ /* Reap all finished child processes (action scripts, etc.) */
+ while (1) {
+ int status;
+ pid_t pid = waitpid(-1, &status, WNOHANG);
+ if (pid <= 0) break;
+
+ /* Check if any managed STDIN unit matches this pid */
+ for (int i = 0; i < g.unit_count; i++) {
+ Unit *u = &g.units[i];
+ if (u->console.type == CONSOLE_STDIN && u->pid == pid) {
+ if (WIFEXITED(status))
+ log_warn("Unit %s exited with code %d",
+ u->name, WEXITSTATUS(status));
+ else if (WIFSIGNALED(status))
+ log_warn("Unit %s killed by signal %d",
+ u->name, WTERMSIG(status));
+ u->state = STATE_CRASHED;
+ u->pid = 0;
+ break;
+ }
+ }
+ }
+ break;
+
+ case SIGHUP:
+ log_info("SIGHUP received — reloading units");
+ /* Reload unit descriptors. Existing runtime state is preserved. */
+ g.unit_count = 0;
+ unit_load_all();
+ log_tail_init();
+ break;
+ }
+}
+
+/* ── Process stdout handling ─────────────────────────────────────────────── */
+/*
+ * Called when a STDIN-type unit's stdout fd is readable.
+ * Reads available output, pushes to ring buffer, broadcasts to clients.
+ */
+static void handle_process_output(int fd) {
+ /* Find which unit owns this fd */
+ Unit *u = NULL;
+ for (int i = 0; i < g.unit_count; i++) {
+ if (g.units[i].stdout_fd == fd) {
+ u = &g.units[i];
+ break;
+ }
+ }
+ if (!u) return;
+
+ char buf[RING_BUF_LINE_MAX];
+ ssize_t n = read(fd, buf, sizeof(buf) - 1);
+ if (n <= 0) {
+ /* Process closed its stdout — mark as stopped */
+ log_info("Unit %s stdout closed", u->name);
+ epoll_ctl(g.epoll_fd, EPOLL_CTL_DEL, fd, NULL);
+ close(fd);
+ u->stdout_fd = -1;
+ return;
+ }
+
+ buf[n] = '\0';
+ ring_push(u->output, buf, n);
+ client_broadcast_output(u->name, buf, 0);
+}
+
+/* ── Main event loop ─────────────────────────────────────────────────────── */
+
+static void event_loop(void) {
+ struct epoll_event events[64];
+
+ while (g.running) {
+ int n = epoll_wait(g.epoll_fd, events, 64, -1);
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ log_error("epoll_wait: %s", strerror(errno));
+ break;
+ }
+
+ for (int i = 0; i < n; i++) {
+ int fd = events[i].data.fd;
+ uint32_t ev = events[i].events;
+
+ if (fd == g.signal_fd) {
+ handle_signal();
+
+ } else if (fd == g.listen_fd) {
+ client_accept(g.listen_fd);
+
+ } else if (log_tail_fd_to_unit(fd) != NULL) {
+ log_tail_handle(fd);
+
+ } else {
+ /* Check if this is a unit stdout fd */
+ int is_unit_stdout = 0;
+ for (int j = 0; j < g.unit_count; j++) {
+ if (g.units[j].stdout_fd == fd) {
+ is_unit_stdout = 1;
+ break;
+ }
+ }
+
+ if (is_unit_stdout) {
+ handle_process_output(fd);
+ } else {
+ /* It's a client fd */
+ if (ev & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
+ client_remove(fd);
+ } else if (ev & EPOLLIN) {
+ if (client_handle(fd) < 0)
+ client_remove(fd);
+ }
+ }
+ }
+ }
+ }
+}
+
+/* ── Entry point ─────────────────────────────────────────────────────────── */
+
+static void usage(const char *prog) {
+ fprintf(stderr,
+ "Usage: %s [options]\n"
+ " -f Run in foreground (don't daemonize)\n"
+ " -d Enable debug logging\n"
+ " -h Show this help\n",
+ prog);
+}
+
+int main(int argc, char *argv[]) {
+ int foreground = 0;
+ int debug = 0;
+ int opt;
+
+ while ((opt = getopt(argc, argv, "fdh")) != -1) {
+ switch (opt) {
+ case 'f': foreground = 1; break;
+ case 'd': debug = 1; break;
+ case 'h': usage(argv[0]); return 0;
+ default: usage(argv[0]); return 1;
+ }
+ }
+
+ /* Initialize logging before anything else */
+ log_init(foreground ? NULL : UMBRELLA_LOG_FILE,
+ debug ? LOG_DEBUG : LOG_INFO);
+ log_info("umbrella %s starting", UMBRELLA_VERSION);
+
+ /* Daemonize if not running in foreground */
+ if (daemon_daemonize(foreground) != 0) {
+ log_error("daemonize failed");
+ return 1;
+ }
+
+ /* Set up epoll */
+ g.epoll_fd = epoll_create1(EPOLL_CLOEXEC);
+ if (g.epoll_fd < 0) {
+ log_error("epoll_create1: %s", strerror(errno));
+ return 1;
+ }
+
+ /* Initialize daemon (signals, pid file, directories) */
+ if (daemon_init() != 0) {
+ log_error("daemon_init failed");
+ return 1;
+ }
+
+ /* Register signal fd with epoll */
+ struct epoll_event ev;
+ ev.events = EPOLLIN;
+ ev.data.fd = g.signal_fd;
+ epoll_ctl(g.epoll_fd, EPOLL_CTL_ADD, g.signal_fd, &ev);
+
+ log_tail_init();
+
+ /* Load unit descriptors */
+ if (unit_load_all() < 0) {
+ log_error("Failed to load units");
+ daemon_cleanup();
+ return 1;
+ }
+
+ /* Set up listening socket */
+ g.listen_fd = client_listen();
+ if (g.listen_fd < 0) {
+ log_error("Failed to create listen socket");
+ daemon_cleanup();
+ return 1;
+ }
+
+ ev.events = EPOLLIN;
+ ev.data.fd = g.listen_fd;
+ epoll_ctl(g.epoll_fd, EPOLL_CTL_ADD, g.listen_fd, &ev);
+
+ log_info("Ready. %d unit(s) loaded.", g.unit_count);
+ g.running = 1;
+
+ event_loop();
+
+ log_info("Shutting down");
+ log_tail_cleanup();
+ daemon_cleanup();
+ log_close();
+ return 0;
+}
diff --git a/src/proto.c b/src/proto.c
new file mode 100644
index 0000000..6c608d3
--- /dev/null
+++ b/src/proto.c
@@ -0,0 +1,183 @@
+#include "proto.h"
+#include "umbrella.h"
+#include "log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+#include <arpa/inet.h>
+
+/* ── Low-level I/O ───────────────────────────────────────────────────────── */
+
+/*
+ * write_all: keep writing until all bytes are sent or an error occurs.
+ * Returns 0 on success, -1 on error.
+ */
+static int write_all(int fd, const void *buf, size_t len) {
+ const uint8_t *p = buf;
+ while (len > 0) {
+ ssize_t n = write(fd, p, len);
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ return -1;
+ }
+ p += n;
+ len -= n;
+ }
+ return 0;
+}
+
+/*
+ * read_all: keep reading until exactly len bytes are received or error.
+ * Returns 0 on success, -1 on error, 1 on clean EOF.
+ */
+static int read_all(int fd, void *buf, size_t len) {
+ uint8_t *p = buf;
+ while (len > 0) {
+ ssize_t n = read(fd, p, len);
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ return -1;
+ }
+ if (n == 0)
+ return 1; /* clean disconnect */
+ p += n;
+ len -= n;
+ }
+ return 0;
+}
+
+/* ── Protocol ────────────────────────────────────────────────────────────── */
+
+int proto_send(int fd, const char *json) {
+ uint32_t len = (uint32_t)strlen(json);
+ uint32_t net_len = htonl(len); /* big-endian on the wire */
+
+ if (write_all(fd, &net_len, 4) != 0) {
+ log_debug("proto_send: write header failed: %s", strerror(errno));
+ return -1;
+ }
+ if (write_all(fd, json, len) != 0) {
+ log_debug("proto_send: write body failed: %s", strerror(errno));
+ return -1;
+ }
+ return 0;
+}
+
+int proto_recv(int fd, char *buf, int buf_size) {
+ uint32_t net_len;
+ int r = read_all(fd, &net_len, 4);
+ if (r != 0) return r == 1 ? 0 : -1; /* 0=disconnect, -1=error */
+
+ uint32_t len = ntohl(net_len);
+ if (len == 0 || (int)len >= buf_size) {
+ log_warn("proto_recv: bad message length %u", len);
+ return -1;
+ }
+
+ r = read_all(fd, buf, len);
+ if (r != 0) return r == 1 ? 0 : -1;
+
+ buf[len] = '\0';
+ return (int)len;
+}
+
+/* ── Convenience senders ─────────────────────────────────────────────────── */
+
+int proto_send_ok(int fd) {
+ return proto_send(fd, "{\"type\":\"ok\"}");
+}
+
+int proto_send_error(int fd, const char *message) {
+ char buf[512];
+ /* Simple JSON escaping: replace " with \" */
+ char escaped[256];
+ int j = 0;
+ for (int i = 0; message[i] && j < 250; i++) {
+ if (message[i] == '"') escaped[j++] = '\\';
+ escaped[j++] = message[i];
+ }
+ escaped[j] = '\0';
+
+ snprintf(buf, sizeof(buf),
+ "{\"type\":\"error\",\"message\":\"%s\"}", escaped);
+ return proto_send(fd, buf);
+}
+
+int proto_send_output(int fd, const char *unit_name,
+ const char *data, int history) {
+ /*
+ * We build the JSON manually to avoid a dependency on a JSON library.
+ * The data field needs escaping — we handle the common cases.
+ */
+ char escaped[RING_BUF_LINE_MAX * 2];
+ int j = 0;
+ for (int i = 0; data[i] && j < (int)sizeof(escaped) - 2; i++) {
+ switch (data[i]) {
+ case '"': escaped[j++] = '\\'; escaped[j++] = '"'; break;
+ case '\\': escaped[j++] = '\\'; escaped[j++] = '\\'; break;
+ case '\n': escaped[j++] = '\\'; escaped[j++] = 'n'; break;
+ case '\r': escaped[j++] = '\\'; escaped[j++] = 'r'; break;
+ default: escaped[j++] = data[i]; break;
+ }
+ }
+ escaped[j] = '\0';
+
+ char buf[PROTO_MAX_MSG];
+ snprintf(buf, sizeof(buf),
+ "{\"type\":\"output\",\"unit\":\"%s\",\"data\":\"%s\","
+ "\"history\":%s}",
+ unit_name, escaped, history ? "true" : "false");
+
+ return proto_send(fd, buf);
+}
+
+/* ── Minimal JSON value extractor ────────────────────────────────────────── */
+/*
+ * Finds "key":"value" or "key":value in a flat JSON object.
+ * Not a full JSON parser — sufficient for our simple command messages.
+ */
+int json_get_str(const char *json, const char *key,
+ char *out, int out_size) {
+ char needle[MAX_NAME + 4];
+ snprintf(needle, sizeof(needle), "\"%s\":", key);
+
+ const char *p = strstr(json, needle);
+ if (!p) return 0;
+
+ p += strlen(needle);
+
+ /* Skip whitespace */
+ while (*p == ' ' || *p == '\t') p++;
+
+ if (*p == '"') {
+ /* Quoted string */
+ p++;
+ int i = 0;
+ while (*p && *p != '"' && i < out_size - 1) {
+ if (*p == '\\' && *(p+1)) {
+ p++;
+ switch (*p) {
+ case 'n': out[i++] = '\n'; break;
+ case 'r': out[i++] = '\r'; break;
+ case 't': out[i++] = '\t'; break;
+ default: out[i++] = *p; break;
+ }
+ } else {
+ out[i++] = *p;
+ }
+ p++;
+ }
+ out[i] = '\0';
+ return 1;
+ } else {
+ /* Unquoted value (number, bool) — read until delimiter */
+ int i = 0;
+ while (*p && *p != ',' && *p != '}' && *p != ' ' && i < out_size - 1)
+ out[i++] = *p++;
+ out[i] = '\0';
+ return i > 0 ? 1 : 0;
+ }
+}
diff --git a/src/proto.h b/src/proto.h
new file mode 100644
index 0000000..8846682
--- /dev/null
+++ b/src/proto.h
@@ -0,0 +1,70 @@
+#ifndef UMBRELLA_PROTO_H
+#define UMBRELLA_PROTO_H
+
+#include <stdint.h>
+
+/*
+ * Wire format: 4-byte big-endian length prefix followed by a JSON payload.
+ *
+ * [uint32 length][JSON bytes...]
+ *
+ * All messages are JSON objects with at minimum a "cmd" or "type" field.
+ *
+ * Client → Daemon commands:
+ * { "cmd": "list" }
+ * { "cmd": "status", "unit": "name" }
+ * { "cmd": "attach", "unit": "name" }
+ * { "cmd": "detach" }
+ * { "cmd": "input", "unit": "name", "data": "text\n" }
+ * { "cmd": "action", "unit": "name", "action": "update" }
+ *
+ * Daemon → Client responses:
+ * { "type": "ok" }
+ * { "type": "error", "message": "..." }
+ * { "type": "list", "units": [...] }
+ * { "type": "status", "unit": "name", "state": "running", "pid": 123 }
+ * { "type": "output", "unit": "name", "data": "...", "history": true/false }
+ */
+
+#define PROTO_HDR_SIZE 4 /* bytes for the length prefix */
+
+/*
+ * proto_send: send a length-prefixed JSON message to fd.
+ * json must be a null-terminated string.
+ * Returns 0 on success, -1 on error.
+ */
+int proto_send(int fd, const char *json);
+
+/*
+ * proto_recv: receive one complete message from fd into buf.
+ * buf must be PROTO_MAX_MSG bytes.
+ * Returns number of bytes read (>=1), 0 on clean disconnect, -1 on error.
+ */
+int proto_recv(int fd, char *buf, int buf_size);
+
+/*
+ * proto_send_ok: convenience — send {"type":"ok"}
+ */
+int proto_send_ok(int fd);
+
+/*
+ * proto_send_error: convenience — send {"type":"error","message":"..."}
+ */
+int proto_send_error(int fd, const char *message);
+
+/*
+ * proto_send_output: send a chunk of console output to a client.
+ * history=1 means this is buffered history, 0 means live output.
+ */
+int proto_send_output(int fd, const char *unit_name,
+ const char *data, int history);
+
+/*
+ * json_get_str: extract a string value from a flat JSON object.
+ * Writes into out (null-terminated), up to out_size bytes.
+ * Returns 1 if found, 0 if not.
+ * This is a minimal parser — does not handle nested objects or arrays.
+ */
+int json_get_str(const char *json, const char *key, char *out, int out_size);
+
+#endif /* UMBRELLA_PROTO_H */
diff --git a/src/umbrella.h b/src/umbrella.h
new file mode 100644
index 0000000..e44d626
--- /dev/null
+++ b/src/umbrella.h
@@ -0,0 +1,121 @@
+#ifndef UMBRELLA_H
+#define UMBRELLA_H
+
+#include <stdint.h>
+#include <sys/types.h>
+
+/* ── Version ─────────────────────────────────────────────────────────────── */
+#define UMBRELLA_VERSION "0.1.0"
+
+/* ── Paths ───────────────────────────────────────────────────────────────── */
+#define UMBRELLA_CONF_DIR "/etc/umbrella"
+#define UMBRELLA_UNITS_DIR "/etc/umbrella/units"
+#define UMBRELLA_CONF_FILE "/etc/umbrella/umbrella.conf"
+#define UMBRELLA_SOCK_PATH "/run/umbrella/umbrella.sock"
+#define UMBRELLA_PID_FILE "/run/umbrella/umbrella.pid"
+#define UMBRELLA_LOG_FILE "/var/log/umbrella/umbrella.log"
+
+/* ── Limits ──────────────────────────────────────────────────────────────── */
+#define MAX_UNITS 64
+#define MAX_CLIENTS 32
+#define MAX_NAME 64
+#define MAX_PATH 256
+#define MAX_DISPLAY 128
+#define MAX_ACTIONS 16
+#define RING_BUF_LINES 500 /* output lines buffered per unit */
+#define RING_BUF_LINE_MAX 1024 /* max bytes per buffered line */
+#define PROTO_MAX_MSG 65536 /* max wire message size */
+
+/* ── Console types ───────────────────────────────────────────────────────── */
+typedef enum {
+ CONSOLE_RCON = 0,
+ CONSOLE_STDIN = 1,
+} ConsoleType;
+
+/* ── Health check types ──────────────────────────────────────────────────── */
+typedef enum {
+ HEALTH_A2S = 0,
+ HEALTH_TCP = 1,
+ HEALTH_NONE = 2,
+} HealthType;
+
+/* ── Process state ───────────────────────────────────────────────────────── */
+typedef enum {
+ STATE_STOPPED = 0,
+ STATE_STARTING = 1,
+ STATE_RUNNING = 2,
+ STATE_CRASHED = 3,
+} ProcessState;
+
+/* ── Action: a named script ──────────────────────────────────────────────── */
+typedef struct {
+ char name[MAX_NAME];
+ char script[MAX_PATH];
+} Action;
+
+/* ── Console config ──────────────────────────────────────────────────────── */
+typedef struct {
+ ConsoleType type;
+ char host[64];
+ uint16_t port;
+ char password[128]; /* RCON password; empty for stdin */
+ char password_env[64]; /* env var name to read password from */
+} ConsoleConfig;
+
+/* ── Health config ───────────────────────────────────────────────────────── */
+typedef struct {
+ HealthType type;
+ char host[64];
+ uint16_t port;
+ int timeout_ms;
+} HealthConfig;
+
+/* ── Ring buffer: circular store of recent output lines ──────────────────── */
+typedef struct {
+ char lines[RING_BUF_LINES][RING_BUF_LINE_MAX];
+ int head; /* next write position */
+ int count; /* how many lines are valid */
+} RingBuffer;
+
+/* ── Unit: a loaded descriptor ───────────────────────────────────────────── */
+typedef struct {
+ char name[MAX_NAME];
+ char display[MAX_DISPLAY];
+ char service[MAX_NAME]; /* systemd unit name, informational */
+ ConsoleConfig console;
+ HealthConfig health;
+ char log_paths[4][MAX_PATH];
+ int log_count;
+ Action actions[MAX_ACTIONS];
+ int action_count;
+
+ /* Runtime state (populated by process/rcon layer, not yaml) */
+ ProcessState state;
+ pid_t pid; /* only for CONSOLE_STDIN */
+ int stdin_fd; /* only for CONSOLE_STDIN */
+ int stdout_fd; /* only for CONSOLE_STDIN */
+ RingBuffer *output;
+} Unit;
+
+/* ── Client: a connected socket client ───────────────────────────────────── */
+typedef struct {
+ int fd;
+ char attached[MAX_NAME]; /* unit name, or empty if not attached */
+ int uid; /* peer uid from SO_PEERCRED */
+} Client;
+
+/* ── Global daemon state ─────────────────────────────────────────────────── */
+typedef struct {
+ Unit units[MAX_UNITS];
+ int unit_count;
+ Client clients[MAX_CLIENTS];
+ int client_count;
+ int epoll_fd;
+ int listen_fd;
+ int signal_fd;
+ int running;
+} Daemon;
+
+extern Daemon g;
+
+#endif /* UMBRELLA_H */
diff --git a/src/unit.c b/src/unit.c
new file mode 100644
index 0000000..3dd9d26
--- /dev/null
+++ b/src/unit.c
@@ -0,0 +1,295 @@
+#include "unit.h"
+#include "log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <dirent.h>
+#include <errno.h>
+#include <yaml.h>
+
+/* ── Ring buffer ─────────────────────────────────────────────────────────── */
+
+RingBuffer *ring_init(void) {
+ RingBuffer *rb = calloc(1, sizeof(RingBuffer));
+ return rb;
+}
+
+void ring_push(RingBuffer *rb, const char *line, int len) {
+ if (!rb) return;
+ if (len <= 0) return;
+ if (len >= RING_BUF_LINE_MAX)
+ len = RING_BUF_LINE_MAX - 1;
+
+ memcpy(rb->lines[rb->head], line, len);
+ rb->lines[rb->head][len] = '\0';
+
+ rb->head = (rb->head + 1) % RING_BUF_LINES;
+ if (rb->count < RING_BUF_LINES)
+ rb->count++;
+}
+
+void ring_free(RingBuffer *rb) {
+ free(rb);
+}
+
+/* ── Helpers ─────────────────────────────────────────────────────────────── */
+
+const char *unit_state_str(ProcessState state) {
+ switch (state) {
+ case STATE_STOPPED: return "stopped";
+ case STATE_STARTING: return "starting";
+ case STATE_RUNNING: return "running";
+ case STATE_CRASHED: return "crashed";
+ default: return "unknown";
+ }
+}
+
+Unit *unit_find(const char *name) {
+ for (int i = 0; i < g.unit_count; i++) {
+ if (strcmp(g.units[i].name, name) == 0)
+ return &g.units[i];
+ }
+ return NULL;
+}
+
+/* ── YAML parsing ────────────────────────────────────────────────────────── */
+/*
+ * We parse the YAML manually using libyaml's event-based parser.
+ * This avoids a dependency on a higher-level YAML library and keeps
+ * us in control of memory. The unit format is shallow enough that
+ * a simple key-value state machine handles it cleanly.
+ *
+ * Expected format:
+ *
+ * name: tf2-novemen
+ * display: "TF2 — novemen"
+ * service: tf2-server.service
+ * console:
+ * type: rcon
+ * host: 127.0.0.1
+ * port: 27015
+ * password_env: RCON_PASSWORD
+ * health:
+ * type: a2s
+ * host: 127.0.0.1
+ * port: 27015
+ * logs:
+ * - /ded/tf/novemen/tf2/logs/service.log
+ * actions:
+ * update: /ded/tf/scripts/tf2_autoupdate.sh
+ * backup: /ded/tf/scripts/backup.sh
+ */
+
+typedef enum {
+ SECTION_ROOT,
+ SECTION_CONSOLE,
+ SECTION_HEALTH,
+ SECTION_LOGS,
+ SECTION_ACTIONS,
+} ParseSection;
+
+int unit_load_file(const char *path, Unit *out) {
+ memset(out, 0, sizeof(Unit));
+ out->state = STATE_STOPPED;
+ out->stdin_fd = -1;
+ out->stdout_fd = -1;
+ out->health.timeout_ms = 5000;
+
+ FILE *f = fopen(path, "r");
+ if (!f) {
+ log_error("Cannot open unit file %s: %s", path, strerror(errno));
+ return -1;
+ }
+
+ yaml_parser_t parser;
+ yaml_event_t event;
+
+ if (!yaml_parser_initialize(&parser)) {
+ log_error("yaml_parser_initialize failed");
+ fclose(f);
+ return -1;
+ }
+ yaml_parser_set_input_file(&parser, f);
+
+ ParseSection section = SECTION_ROOT;
+ char last_key[MAX_NAME] = {0};
+ int in_value = 0; /* expecting a value next */
+ int ok = 1;
+
+ while (ok) {
+ if (!yaml_parser_parse(&parser, &event)) {
+ log_error("YAML parse error in %s: %s", path, parser.problem);
+ ok = 0;
+ break;
+ }
+
+ if (event.type == YAML_STREAM_END_EVENT ||
+ event.type == YAML_DOCUMENT_END_EVENT) {
+ yaml_event_delete(&event);
+ break;
+ }
+
+ if (event.type == YAML_SCALAR_EVENT) {
+ const char *val = (const char *)event.data.scalar.value;
+
+ if (!in_value) {
+ /* This is a key */
+ strncpy(last_key, val, sizeof(last_key) - 1);
+
+ /* Section transitions */
+ if (strcmp(val, "console") == 0)
+ section = SECTION_CONSOLE;
+ else if (strcmp(val, "health") == 0)
+ section = SECTION_HEALTH;
+ else if (strcmp(val, "logs") == 0)
+ section = SECTION_LOGS;
+ else if (strcmp(val, "actions") == 0)
+ section = SECTION_ACTIONS;
+ else
+ in_value = 1;
+ } else {
+ /* This is a value */
+ in_value = 0;
+
+ switch (section) {
+ case SECTION_ROOT:
+ if (strcmp(last_key, "name") == 0)
+ strncpy(out->name, val, MAX_NAME - 1);
+ else if (strcmp(last_key, "display") == 0)
+ strncpy(out->display, val, MAX_DISPLAY - 1);
+ else if (strcmp(last_key, "service") == 0)
+ strncpy(out->service, val, MAX_NAME - 1);
+ break;
+
+ case SECTION_CONSOLE:
+ if (strcmp(last_key, "type") == 0) {
+ if (strcmp(val, "rcon") == 0)
+ out->console.type = CONSOLE_RCON;
+ else if (strcmp(val, "stdin") == 0)
+ out->console.type = CONSOLE_STDIN;
+ else
+ log_warn("Unknown console type '%s' in %s", val, path);
+ } else if (strcmp(last_key, "host") == 0)
+ strncpy(out->console.host, val, 63);
+ else if (strcmp(last_key, "port") == 0)
+ out->console.port = (uint16_t)atoi(val);
+ else if (strcmp(last_key, "password") == 0)
+ strncpy(out->console.password, val, 127);
+ else if (strcmp(last_key, "password_env") == 0)
+ strncpy(out->console.password_env, val, 63);
+ break;
+
+ case SECTION_HEALTH:
+ if (strcmp(last_key, "type") == 0) {
+ if (strcmp(val, "a2s") == 0)
+ out->health.type = HEALTH_A2S;
+ else if (strcmp(val, "tcp") == 0)
+ out->health.type = HEALTH_TCP;
+ else if (strcmp(val, "none") == 0)
+ out->health.type = HEALTH_NONE;
+ } else if (strcmp(last_key, "host") == 0)
+ strncpy(out->health.host, val, 63);
+ else if (strcmp(last_key, "port") == 0)
+ out->health.port = (uint16_t)atoi(val);
+ else if (strcmp(last_key, "timeout_ms") == 0)
+ out->health.timeout_ms = atoi(val);
+ break;
+
+ case SECTION_LOGS:
+ /* List item — last_key is "-" or the index */
+ if (out->log_count < 4)
+ strncpy(out->log_paths[out->log_count++], val, MAX_PATH - 1);
+ break;
+
+ case SECTION_ACTIONS:
+ /* last_key is the action name, val is the script path */
+ if (out->action_count < MAX_ACTIONS) {
+ strncpy(out->actions[out->action_count].name,
+ last_key, MAX_NAME - 1);
+ strncpy(out->actions[out->action_count].script,
+ val, MAX_PATH - 1);
+ out->action_count++;
+ }
+ break;
+ }
+ }
+ } else if (event.type == YAML_MAPPING_END_EVENT ||
+ event.type == YAML_SEQUENCE_END_EVENT) {
+ /* Pop back to root when leaving a nested section */
+ if (section != SECTION_ROOT)
+ section = SECTION_ROOT;
+ in_value = 0;
+ } else if (event.type == YAML_SEQUENCE_START_EVENT) {
+ in_value = 0;
+ }
+
+ yaml_event_delete(&event);
+ }
+
+ yaml_parser_delete(&parser);
+ fclose(f);
+
+ if (!out->name[0]) {
+ log_error("Unit file %s has no 'name' field", path);
+ return -1;
+ }
+
+ /* Resolve password from env if password_env is set */
+ if (out->console.password_env[0] && !out->console.password[0]) {
+ const char *env_val = getenv(out->console.password_env);
+ if (env_val)
+ strncpy(out->console.password, env_val, 127);
+ else
+ log_warn("Unit %s: password_env '%s' not set in environment",
+ out->name, out->console.password_env);
+ }
+
+ /* Allocate ring buffer */
+ out->output = ring_init();
+ if (!out->output) {
+ log_error("Failed to allocate ring buffer for unit %s", out->name);
+ return -1;
+ }
+
+ log_info("Loaded unit: %s (%s)", out->name, out->display);
+ return ok ? 0 : -1;
+}
+
+int unit_load_all(void) {
+ DIR *d = opendir(UMBRELLA_UNITS_DIR);
+ if (!d) {
+ log_error("Cannot open units directory %s: %s",
+ UMBRELLA_UNITS_DIR, strerror(errno));
+ return -1;
+ }
+
+ struct dirent *ent;
+ int count = 0;
+
+ while ((ent = readdir(d)) != NULL) {
+ /* Only load .yaml files */
+ const char *name = ent->d_name;
+ size_t len = strlen(name);
+ if (len < 6 || strcmp(name + len - 5, ".yaml") != 0)
+ continue;
+
+ if (g.unit_count >= MAX_UNITS) {
+ log_warn("Maximum unit count (%d) reached, skipping %s",
+ MAX_UNITS, name);
+ break;
+ }
+
+ char path[MAX_PATH];
+ snprintf(path, sizeof(path), "%s/%s", UMBRELLA_UNITS_DIR, name);
+
+ if (unit_load_file(path, &g.units[g.unit_count]) == 0) {
+ g.unit_count++;
+ count++;
+ }
+ }
+
+ closedir(d);
+ log_info("Loaded %d unit(s)", count);
+ return count;
+}
diff --git a/src/unit.h b/src/unit.h
new file mode 100644
index 0000000..d709b77
--- /dev/null
+++ b/src/unit.h
@@ -0,0 +1,45 @@
+#ifndef UMBRELLA_UNIT_H
+#define UMBRELLA_UNIT_H
+
+#include "umbrella.h"
+
+/*
+ * unit_load_all: scan UMBRELLA_UNITS_DIR and load all *.yaml files.
+ * Populates g.units and g.unit_count.
+ * Returns number of units loaded, -1 on fatal error.
+ */
+int unit_load_all(void);
+
+/*
+ * unit_load_file: load a single unit yaml file.
+ * Returns 0 on success, -1 on error.
+ */
+int unit_load_file(const char *path, Unit *out);
+
+/*
+ * unit_find: look up a unit by name.
+ * Returns pointer into g.units, or NULL if not found.
+ */
+Unit *unit_find(const char *name);
+
+/*
+ * unit_state_str: human-readable state name.
+ */
+const char *unit_state_str(ProcessState state);
+
+/*
+ * ring_init: allocate and zero a ring buffer.
+ */
+RingBuffer *ring_init(void);
+
+/*
+ * ring_push: append a line to the ring buffer.
+ */
+void ring_push(RingBuffer *rb, const char *line, int len);
+
+/*
+ * ring_free: free a ring buffer.
+ */
+void ring_free(RingBuffer *rb);
+
+#endif /* UMBRELLA_UNIT_H */