From 0d706ae72ceefd74053ad6cb0900ecce6cf1f085 Mon Sep 17 00:00:00 2001 From: auric Date: Sat, 21 Feb 2026 11:08:36 -0600 Subject: Add Umbrella 0.1.5 --- src/client.c | 320 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/client.h | 42 +++++++ src/console/rcon.c | 238 +++++++++++++++++++++++++++++++++++++++ src/console/rcon.h | 39 +++++++ src/daemon.c | 148 +++++++++++++++++++++++++ src/daemon.h | 27 +++++ src/log.c | 60 ++++++++++ src/log.h | 22 ++++ src/log_tail.c | 118 ++++++++++++++++++++ src/log_tail.h | 31 ++++++ src/main.c | 240 ++++++++++++++++++++++++++++++++++++++++ src/proto.c | 183 ++++++++++++++++++++++++++++++ src/proto.h | 70 ++++++++++++ src/umbrella.h | 121 ++++++++++++++++++++ src/unit.c | 295 ++++++++++++++++++++++++++++++++++++++++++++++++ src/unit.h | 45 ++++++++ 16 files changed, 1999 insertions(+) create mode 100644 src/client.c create mode 100644 src/client.h create mode 100644 src/console/rcon.c create mode 100644 src/console/rcon.h create mode 100644 src/daemon.c create mode 100644 src/daemon.h create mode 100644 src/log.c create mode 100644 src/log.h create mode 100644 src/log_tail.c create mode 100644 src/log_tail.h create mode 100644 src/main.c create mode 100644 src/proto.c create mode 100644 src/proto.h create mode 100644 src/umbrella.h create mode 100644 src/unit.c create mode 100644 src/unit.h (limited to 'src') 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ── 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 +#include +#include +#include +#include +#include +#include +#include +#include + +/* ── 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 +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * 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 +#include +#include +#include + +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 + +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 +#include +#include +#include +#include +#include +#include +#include + +#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 +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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 +#include +#include +#include +#include +#include + +/* ── 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 + +/* + * 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 +#include + +/* ── 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 +#include +#include +#include +#include +#include + +/* ── 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 */ -- cgit v1.2.3