diff options
| -rw-r--r-- | Makefile | 57 | ||||
| -rw-r--r-- | clients/umbrella-bot/bot.conf.example | 25 | ||||
| -rwxr-xr-x | clients/umbrella-bot/umbrella-bot.py | 631 | ||||
| -rw-r--r-- | clients/umbrella-bot/umbrella-bot.service | 16 | ||||
| -rw-r--r-- | clients/umbrella-cli/main.c | 410 | ||||
| -rw-r--r-- | src/client.c | 320 | ||||
| -rw-r--r-- | src/client.h | 42 | ||||
| -rw-r--r-- | src/console/rcon.c | 238 | ||||
| -rw-r--r-- | src/console/rcon.h | 39 | ||||
| -rw-r--r-- | src/daemon.c | 148 | ||||
| -rw-r--r-- | src/daemon.h | 27 | ||||
| -rw-r--r-- | src/log.c | 60 | ||||
| -rw-r--r-- | src/log.h | 22 | ||||
| -rw-r--r-- | src/log_tail.c | 118 | ||||
| -rw-r--r-- | src/log_tail.h | 31 | ||||
| -rw-r--r-- | src/main.c | 240 | ||||
| -rw-r--r-- | src/proto.c | 183 | ||||
| -rw-r--r-- | src/proto.h | 70 | ||||
| -rw-r--r-- | src/umbrella.h | 121 | ||||
| -rw-r--r-- | src/unit.c | 295 | ||||
| -rw-r--r-- | src/unit.h | 45 | ||||
| -rw-r--r-- | umbrella.conf.example | 14 | ||||
| -rw-r--r-- | units/tf2-novemen.yaml.example | 24 |
23 files changed, 3176 insertions, 0 deletions
diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7cfa4a1 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +# ── umbrella Makefile ───────────────────────────────────────────────────────── + +CC = gcc +CFLAGS = -Wall -Wextra -Wpedantic -std=c11 -D_GNU_SOURCE \ + -O2 -g +LDFLAGS = -lyaml + +DAEMON_SRCS = \ + src/main.c \ + src/daemon.c \ + src/unit.c \ + src/proto.c \ + src/client.c \ + src/log.c \ + src/log_tail.c \ + src/console/rcon.c + +CLI_SRCS = clients/umbrella-cli/main.c + +DAEMON_OBJS = $(DAEMON_SRCS:.c=.o) +CLI_OBJS = $(CLI_SRCS:.c=.o) + +.PHONY: all clean install + +all: umbrella umbrella-cli + +umbrella: $(DAEMON_OBJS) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +umbrella-cli: $(CLI_OBJS) + $(CC) $(CFLAGS) -o $@ $^ + +%.o: %.c + $(CC) $(CFLAGS) -c -o $@ $< + +# ── Install ─────────────────────────────────────────────────────────────────── +PREFIX ?= /usr/local +CONF_DIR = /etc/umbrella +UNITS_DIR = /etc/umbrella/units +RUN_DIR = /run/umbrella +LOG_DIR = /var/log/umbrella + +install: all + install -Dm755 umbrella $(PREFIX)/sbin/umbrella + install -Dm755 umbrella-cli $(PREFIX)/bin/umbrella-cli + install -dm755 $(CONF_DIR) + install -dm755 $(UNITS_DIR) + install -dm755 $(RUN_DIR) + install -dm755 $(LOG_DIR) + @if [ ! -f $(CONF_DIR)/umbrella.conf ]; then \ + install -Dm644 umbrella.conf.example $(CONF_DIR)/umbrella.conf; \ + echo "Installed default config to $(CONF_DIR)/umbrella.conf"; \ + fi + @echo "Done. Edit $(CONF_DIR)/umbrella.conf and add unit files to $(UNITS_DIR)/" + +clean: + rm -f $(DAEMON_OBJS) $(CLI_OBJS) umbrella umbrella-cli diff --git a/clients/umbrella-bot/bot.conf.example b/clients/umbrella-bot/bot.conf.example new file mode 100644 index 0000000..0cc07a1 --- /dev/null +++ b/clients/umbrella-bot/bot.conf.example @@ -0,0 +1,25 @@ +# /etc/umbrella/bot.conf +# umbrella-bot Matrix configuration +# chmod 600 this file — it contains your bot password + +# Your Synapse homeserver URL +homeserver = https://website.com + +# Bot Matrix user ID +user_id = @bot:website.com + +# Bot account password +password = yourpasswordhere + +# Room ID to listen in (not the alias, the internal ID) +# Find it in Element: Room Settings → Advanced → Internal room ID +room_id = !exampleid:website.com + +# Device ID — leave blank on first run, fill in after --setup +# device_id = UMBRELLA_BOT + +# Path to encryption key store (SQLite database) +store_path = /etc/umbrella/bot-store + +# Path to umbrella socket +socket_path = /run/umbrella/umbrella.sock diff --git a/clients/umbrella-bot/umbrella-bot.py b/clients/umbrella-bot/umbrella-bot.py new file mode 100755 index 0000000..001b94c --- /dev/null +++ b/clients/umbrella-bot/umbrella-bot.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +""" +umbrella-bot: Matrix bot client for the umbrella server manager. + +Connects to the umbrella Unix socket and bridges commands from a Matrix +room to umbrella units. Requires matrix-nio with encryption support. + +Dependencies: + pip install matrix-nio[e2e] aiofiles + +Setup: + 1. Create /etc/umbrella/bot.conf (see bot.conf.example) + 2. Run once with --setup to perform initial login and key verification + 3. Run normally or via systemd +""" + +import asyncio +import json +import os +import re +import signal +import socket +import struct +import sys +import logging +import argparse +from pathlib import Path +from typing import Optional + +from nio import ( + AsyncClient, + AsyncClientConfig, + LoginResponse, + MatrixRoom, + RoomMessageText, + SyncResponse, + crypto, +) +from nio.store import SqliteStore + +# ── Logging ─────────────────────────────────────────────────────────────────── + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +log = logging.getLogger("umbrella-bot") + +# ── Config ──────────────────────────────────────────────────────────────────── + +DEFAULT_CONF = "/etc/umbrella/bot.conf" +DEFAULT_STORE = "/etc/umbrella/bot-store" +SOCK_PATH = "/run/umbrella/umbrella.sock" +PROTO_MAX = 65536 + +HELP_TEXT = """\ +Umbrella bot commands: + + !status <unit> — Show unit status + !cmd <unit> <command> — Send a console command + !restart <unit> — Restart the unit's systemd service + !update <unit> — Run the update action + !action <unit> <action> — Run a named action + !attach <unit> — Stream live log output to this room + !detach <unit> — Stop streaming log output + !units — List all units + !help — Show this message +""" + +# ── Umbrella socket client ──────────────────────────────────────────────────── + +class UmbrellaClient: + """ + Synchronous umbrella socket client. + We use a plain blocking socket here since commands are short-lived + and we don't need async for the request/response pattern. + For attach (streaming), we use a separate async wrapper. + """ + + def __init__(self, sock_path: str = SOCK_PATH): + self.sock_path = sock_path + + def _connect(self) -> socket.socket: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(10) + try: + s.connect(self.sock_path) + except FileNotFoundError: + raise RuntimeError(f"Umbrella socket not found: {self.sock_path} " + f"(is umbrelld running?)") + except ConnectionRefusedError: + raise RuntimeError("Umbrella daemon is not running") + return s + + def _send(self, s: socket.socket, msg: dict) -> None: + data = json.dumps(msg).encode() + header = struct.pack(">I", len(data)) + s.sendall(header + data) + + def _recv(self, s: socket.socket) -> dict: + header = b"" + while len(header) < 4: + chunk = s.recv(4 - len(header)) + if not chunk: + raise RuntimeError("Connection closed by umbrella daemon") + header += chunk + length = struct.unpack(">I", header)[0] + if length > PROTO_MAX: + raise RuntimeError(f"Message too large: {length}") + data = b"" + while len(data) < length: + chunk = s.recv(length - len(data)) + if not chunk: + raise RuntimeError("Connection closed mid-message") + data += chunk + return json.loads(data.decode()) + + def command(self, msg: dict) -> dict: + """Send a command and return the response.""" + s = self._connect() + try: + self._send(s, msg) + return self._recv(s) + finally: + s.close() + + def list_units(self) -> list: + resp = self.command({"cmd": "list"}) + return resp.get("units", []) + + def status(self, unit: str) -> dict: + return self.command({"cmd": "status", "unit": unit}) + + def send_input(self, unit: str, data: str) -> dict: + return self.command({"cmd": "input", "unit": unit, + "data": data + "\n"}) + + def run_action(self, unit: str, action: str) -> dict: + return self.command({"cmd": "action", "unit": unit, "action": action}) + + +# ── Attach session: streams output from umbrella to a Matrix room ───────────── + +class AttachSession: + """ + Maintains a persistent connection to umbrella for a specific unit, + forwarding live output to a Matrix room. + """ + + def __init__(self, unit: str, room_id: str, + matrix_client: "AsyncClient", + sock_path: str = SOCK_PATH): + self.unit = unit + self.room_id = room_id + self.matrix_client = matrix_client + self.sock_path = sock_path + self._task: Optional[asyncio.Task] = None + self._sock: Optional[socket.socket] = None + + async def start(self): + if self._task and not self._task.done(): + return # already running + self._task = asyncio.create_task(self._stream()) + + async def stop(self): + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + if self._sock: + try: + self._sock.close() + except Exception: + pass + self._sock = None + + async def _stream(self): + loop = asyncio.get_event_loop() + + def connect_and_attach(): + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(10) + s.connect(self.sock_path) + # Send attach command + msg = json.dumps({"cmd": "attach", "unit": self.unit}).encode() + s.sendall(struct.pack(">I", len(msg)) + msg) + # Read ok response + hdr = b"" + while len(hdr) < 4: + hdr += s.recv(4 - len(hdr)) + length = struct.unpack(">I", hdr)[0] + resp_data = b"" + while len(resp_data) < length: + resp_data += s.recv(length - len(resp_data)) + s.settimeout(None) # non-blocking from here + s.setblocking(False) + return s + + try: + self._sock = await loop.run_in_executor(None, connect_and_attach) + except Exception as e: + log.error(f"attach: failed to connect for {self.unit}: {e}") + return + + buf = b"" + log.info(f"attach: streaming {self.unit} to room {self.room_id}") + + try: + while True: + # Read available data from socket + try: + chunk = await loop.run_in_executor( + None, lambda: self._recv_one_nonblock() + ) + if chunk is None: + await asyncio.sleep(0.05) + continue + if chunk == {}: + break # disconnected + except asyncio.CancelledError: + raise + except Exception as e: + log.error(f"attach: read error for {self.unit}: {e}") + break + + if chunk.get("type") == "output": + data = chunk.get("data", "") + history = chunk.get("history", False) + if data and not history: + # Send to Matrix room, wrapped in code block + await self.matrix_client.room_send( + self.room_id, + "m.room.message", + { + "msgtype": "m.text", + "body": f"```\n{data.rstrip()}\n```", + "format": "org.matrix.custom.html", + "formatted_body": f"<pre><code>{data.rstrip()}</code></pre>", + } + ) + except asyncio.CancelledError: + pass + finally: + if self._sock: + # Send detach + try: + msg = json.dumps({"cmd": "detach"}).encode() + self._sock.setblocking(True) + self._sock.sendall(struct.pack(">I", len(msg)) + msg) + except Exception: + pass + self._sock.close() + self._sock = None + + def _recv_one_nonblock(self) -> Optional[dict]: + """Try to read one message from the non-blocking socket. Returns None if no data yet.""" + try: + hdr = self._sock.recv(4) + if not hdr: + return {} # disconnected + if len(hdr) < 4: + return None + length = struct.unpack(">I", hdr)[0] + data = b"" + self._sock.setblocking(True) + while len(data) < length: + chunk = self._sock.recv(length - len(data)) + if not chunk: + return {} + data += chunk + self._sock.setblocking(False) + return json.loads(data.decode()) + except BlockingIOError: + return None + + +# ── Power level check ───────────────────────────────────────────────────────── + +async def get_user_power_level(client: AsyncClient, + room_id: str, user_id: str) -> int: + """ + Fetch the power level for a user in a room. + Returns 0 if the user is not found or on error. + """ + try: + resp = await client.room_get_state_event( + room_id, "m.room.power_levels", "" + ) + if hasattr(resp, "content"): + users = resp.content.get("users", {}) + default = resp.content.get("users_default", 0) + return users.get(user_id, default) + except Exception as e: + log.warning(f"Could not get power level for {user_id}: {e}") + return 0 + + +# ── Command dispatch ────────────────────────────────────────────────────────── + +async def handle_command( + client: AsyncClient, + umbrella: UmbrellaClient, + attach_sessions: dict, + room: MatrixRoom, + sender: str, + text: str, + config: dict, +) -> Optional[str]: + """ + Parse and dispatch a bot command. Returns a reply string or None. + """ + parts = text.strip().split(None, 3) + if not parts: + return None + + cmd = parts[0].lower() + + # Check power level + power = await get_user_power_level(client, room.room_id, sender) + if power < 50: + return f"❌ Permission denied. Requires power level ≥ 50 (you have {power})." + + try: + if cmd == "!help": + return HELP_TEXT + + elif cmd == "!units": + units = umbrella.list_units() + if not units: + return "No units loaded." + lines = ["**Units:**"] + for u in units: + lines.append(f" `{u['name']}` — {u['display']} [{u['state']}]") + return "\n".join(lines) + + elif cmd == "!status": + if len(parts) < 2: + return "Usage: `!status <unit>`" + unit = parts[1] + resp = umbrella.status(unit) + if resp.get("type") == "error": + return f"❌ {resp['message']}" + return ( + f"**{resp.get('display', unit)}**\n" + f"State: `{resp.get('state', 'unknown')}`" + ) + + elif cmd == "!cmd": + if len(parts) < 3: + return "Usage: `!cmd <unit> <command>`" + unit = parts[1] + command = " ".join(parts[2:]) + resp = umbrella.send_input(unit, command) + if resp.get("type") == "error": + return f"❌ {resp['message']}" + return f"✓ Sent: `{command}`" + + elif cmd == "!restart": + if len(parts) < 2: + return "Usage: `!restart <unit>`" + unit = parts[1] + resp = umbrella.run_action(unit, "restart") + if resp.get("type") == "error": + return f"❌ {resp['message']}" + return f"✓ Restart dispatched for `{unit}`" + + elif cmd == "!update": + if len(parts) < 2: + return "Usage: `!update <unit>`" + unit = parts[1] + resp = umbrella.run_action(unit, "update") + if resp.get("type") == "error": + return f"❌ {resp['message']}" + return f"✓ Update dispatched for `{unit}`" + + elif cmd == "!action": + if len(parts) < 3: + return "Usage: `!action <unit> <action>`" + unit = parts[1] + action = parts[2] + resp = umbrella.run_action(unit, action) + if resp.get("type") == "error": + return f"❌ {resp['message']}" + return f"✓ Action `{action}` dispatched for `{unit}`" + + elif cmd == "!attach": + if len(parts) < 2: + return "Usage: `!attach <unit>`" + unit = parts[1] + key = (room.room_id, unit) + if key in attach_sessions: + return f"Already streaming `{unit}` to this room." + session = AttachSession(unit, room.room_id, client) + attach_sessions[key] = session + await session.start() + return f"📡 Streaming `{unit}` output to this room. Use `!detach {unit}` to stop." + + elif cmd == "!detach": + if len(parts) < 2: + return "Usage: `!detach <unit>`" + unit = parts[1] + key = (room.room_id, unit) + if key not in attach_sessions: + return f"`{unit}` is not being streamed to this room." + await attach_sessions[key].stop() + del attach_sessions[key] + return f"⏹ Stopped streaming `{unit}`." + + except RuntimeError as e: + return f"❌ Umbrella error: {e}" + except Exception as e: + log.exception(f"Command error: {e}") + return f"❌ Internal error: {e}" + + return None + + +# ── Bot core ────────────────────────────────────────────────────────────────── + +async def run_bot(config: dict): + store_path = config.get("store_path", DEFAULT_STORE) + os.makedirs(store_path, exist_ok=True) + + client_config = AsyncClientConfig( + max_limit_exceeded=0, + max_timeouts=0, + store_sync_tokens=True, + encryption_enabled=True, + ) + + client = AsyncClient( + homeserver=config["homeserver"], + user=config["user_id"], + device_id=config.get("device_id", "UMBRELLA_BOT"), + store_path=store_path, + config=client_config, + ) + + umbrella = UmbrellaClient(config.get("socket_path", SOCK_PATH)) + attach_sessions = {} + room_id = config["room_id"] + + # Login + log.info(f"Logging in as {config['user_id']}...") + resp = await client.login( + password=config["password"], + device_name="umbrella-bot", + ) + if not isinstance(resp, LoginResponse): + log.error(f"Login failed: {resp}") + return + + log.info(f"Logged in. Device ID: {client.device_id}") + + # Load encryption keys + if client.should_upload_keys: + await client.keys_upload() + await client.keys_query() + + # Message callback + async def on_message(room: MatrixRoom, event: RoomMessageText): + # Only handle messages in our configured room + if room.room_id != room_id: + return + # Ignore our own messages + if event.sender == config["user_id"]: + return + # Only handle messages that start with ! + body = event.body.strip() + if not body.startswith("!"): + return + + log.info(f"Command from {event.sender}: {body}") + + reply = await handle_command( + client, umbrella, attach_sessions, + room, event.sender, body, config + ) + if reply: + await client.room_send( + room_id, + "m.room.message", + { + "msgtype": "m.text", + "body": reply, + "format": "org.matrix.custom.html", + "formatted_body": reply.replace("\n", "<br>"), + } + ) + + client.add_event_callback(on_message, RoomMessageText) + + # Graceful shutdown + loop = asyncio.get_event_loop() + stop_event = asyncio.Event() + + def _signal_handler(): + log.info("Shutting down...") + stop_event.set() + + loop.add_signal_handler(signal.SIGTERM, _signal_handler) + loop.add_signal_handler(signal.SIGINT, _signal_handler) + + log.info(f"Syncing, watching room {room_id}") + + # Initial sync + await client.sync(timeout=10000) + await client.keys_query() + + # Main sync loop + async def sync_loop(): + while not stop_event.is_set(): + resp = await client.sync(timeout=30000, full_state=False) + if isinstance(resp, SyncResponse): + # Upload any new keys + if client.should_upload_keys: + await client.keys_upload() + + sync_task = asyncio.create_task(sync_loop()) + + await stop_event.wait() + + sync_task.cancel() + try: + await sync_task + except asyncio.CancelledError: + pass + + # Stop all attach sessions + for session in attach_sessions.values(): + await session.stop() + + await client.close() + log.info("Bot stopped.") + + +# ── Setup mode ──────────────────────────────────────────────────────────────── + +async def run_setup(config: dict): + """ + First-run setup: login, generate device keys, print device ID. + Run this once before starting the bot normally. + """ + store_path = config.get("store_path", DEFAULT_STORE) + os.makedirs(store_path, exist_ok=True) + + client = AsyncClient( + homeserver=config["homeserver"], + user=config["user_id"], + device_id=config.get("device_id", "UMBRELLA_BOT"), + store_path=store_path, + config=AsyncClientConfig(encryption_enabled=True), + ) + + print(f"Logging in as {config['user_id']}...") + resp = await client.login( + password=config["password"], + device_name="umbrella-bot", + ) + if not isinstance(resp, LoginResponse): + print(f"Login failed: {resp}") + return + + print(f"Logged in successfully.") + print(f"Device ID: {client.device_id}") + print(f"Add this to your bot.conf: device_id = {client.device_id}") + + if client.should_upload_keys: + await client.keys_upload() + print("Encryption keys uploaded.") + + print("\nSetup complete. You can now run the bot normally.") + print("Note: you may need to verify this device from another session") + print("if your room requires verified devices.") + + await client.close() + + +# ── Config loading ──────────────────────────────────────────────────────────── + +def load_config(path: str) -> dict: + config = {} + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + config[key.strip()] = value.strip() + + required = ["homeserver", "user_id", "password", "room_id"] + for key in required: + if key not in config: + raise ValueError(f"Missing required config key: {key}") + + return config + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Umbrella Matrix bot") + parser.add_argument("-c", "--config", default=DEFAULT_CONF, + help=f"Config file (default: {DEFAULT_CONF})") + parser.add_argument("--setup", action="store_true", + help="Run first-time setup and exit") + args = parser.parse_args() + + try: + config = load_config(args.config) + except FileNotFoundError: + print(f"Config file not found: {args.config}") + print(f"Create it based on bot.conf.example") + sys.exit(1) + except ValueError as e: + print(f"Config error: {e}") + sys.exit(1) + + if args.setup: + asyncio.run(run_setup(config)) + else: + asyncio.run(run_bot(config)) + + +if __name__ == "__main__": + main() diff --git a/clients/umbrella-bot/umbrella-bot.service b/clients/umbrella-bot/umbrella-bot.service new file mode 100644 index 0000000..0473ea9 --- /dev/null +++ b/clients/umbrella-bot/umbrella-bot.service @@ -0,0 +1,16 @@ +[Unit] +Description=Umbrella Matrix Bot +After=network.target umbrella.service +Requires=umbrella.service + +[Service] +Type=simple +User=root +ExecStart=/usr/bin/python3 /usr/local/bin/umbrella-bot.py -c /etc/umbrella/bot.conf +Restart=on-failure +RestartSec=10s +StandardOutput=append:/var/log/umbrella/bot.log +StandardError=append:/var/log/umbrella/bot.log + +[Install] +WantedBy=multi-user.target diff --git a/clients/umbrella-cli/main.c b/clients/umbrella-cli/main.c new file mode 100644 index 0000000..69357e4 --- /dev/null +++ b/clients/umbrella-cli/main.c @@ -0,0 +1,410 @@ +/* + * umbrella-cli: command-line client for the umbrella daemon. + * + * Usage: + * umbrella-cli list + * umbrella-cli status <unit> + * umbrella-cli attach <unit> # interactive console session + * umbrella-cli input <unit> <cmd> # send a single command + * umbrella-cli action <unit> <action> + */ + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <errno.h> +#include <fcntl.h> +#include <termios.h> +#include <sys/socket.h> +#include <sys/un.h> +#include <sys/select.h> +#include <arpa/inet.h> + +#define SOCK_PATH "/run/umbrella/umbrella.sock" +#define PROTO_MAX 65536 +#define HDR_SIZE 4 + +/* ── Connection ──────────────────────────────────────────────────────────── */ + +static int connect_daemon(void) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { perror("socket"); return -1; } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + if (errno == ENOENT) + fprintf(stderr, "umbrella: daemon not running " + "(socket not found: %s)\n", SOCK_PATH); + else + perror("connect"); + close(fd); + return -1; + } + return fd; +} + +/* ── Protocol ────────────────────────────────────────────────────────────── */ + +static int send_msg(int fd, const char *json) { + uint32_t len = (uint32_t)strlen(json); + uint32_t net = htonl(len); + if (write(fd, &net, 4) != 4) return -1; + if (write(fd, json, len) != (ssize_t)len) return -1; + return 0; +} + +static int recv_msg(int fd, char *buf, int buf_size) { + uint32_t net; + int got = 0; + while (got < 4) { + int n = read(fd, (uint8_t*)&net + got, 4 - got); + if (n <= 0) return -1; + got += n; + } + uint32_t len = ntohl(net); + if (!len || (int)len >= buf_size) return -1; + got = 0; + while (got < (int)len) { + int n = read(fd, buf + got, len - got); + if (n <= 0) return -1; + got += n; + } + buf[len] = '\0'; + return (int)len; +} + +/* ── Minimal JSON helpers ────────────────────────────────────────────────── */ + +static int jget(const char *json, const char *key, char *out, int size) { + char needle[128]; + snprintf(needle, sizeof(needle), "\"%s\":", key); + const char *p = strstr(json, needle); + if (!p) return 0; + p += strlen(needle); + while (*p == ' ') p++; + if (*p == '"') { + p++; + int i = 0; + while (*p && *p != '"' && i < 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; + case '"': out[i++] = '"'; break; + default: out[i++] = *p; break; + } + p++; + } else { + out[i++] = *p++; + } + } + out[i] = '\0'; + return 1; + } else { + int i = 0; + while (*p && *p != ',' && *p != '}' && i < size - 1) + out[i++] = *p++; + out[i] = '\0'; + return i > 0; + } +} + +/* ── Commands ────────────────────────────────────────────────────────────── */ + +static int cmd_list(int fd) { + if (send_msg(fd, "{\"cmd\":\"list\"}") != 0) return 1; + + char buf[PROTO_MAX]; + if (recv_msg(fd, buf, sizeof(buf)) <= 0) return 1; + + /* Simple display: find all "name"/"display"/"state" triplets */ + printf("%-24s %-32s %s\n", "NAME", "DISPLAY", "STATE"); + printf("%-24s %-32s %s\n", + "────────────────────────", + "────────────────────────────────", + "───────"); + + /* Walk the units array manually */ + const char *p = buf; + while ((p = strstr(p, "\"name\":")) != NULL) { + char name[64] = {0}, display[128] = {0}, state[32] = {0}; + + /* Extract from this position forward */ + char snippet[512]; + strncpy(snippet, p, sizeof(snippet) - 1); + + jget(snippet, "name", name, sizeof(name)); + jget(snippet, "display", display, sizeof(display)); + jget(snippet, "state", state, sizeof(state)); + + if (name[0]) + printf("%-24s %-32s %s\n", name, display, state); + + p++; /* advance past current match */ + } + return 0; +} + +static int cmd_status(int fd, const char *unit) { + char msg[256]; + snprintf(msg, sizeof(msg), + "{\"cmd\":\"status\",\"unit\":\"%s\"}", unit); + if (send_msg(fd, msg) != 0) return 1; + + char buf[PROTO_MAX]; + if (recv_msg(fd, buf, sizeof(buf)) <= 0) return 1; + + char type[32] = {0}; + jget(buf, "type", type, sizeof(type)); + if (strcmp(type, "error") == 0) { + char errmsg[256] = {0}; + jget(buf, "message", errmsg, sizeof(errmsg)); + fprintf(stderr, "Error: %s\n", errmsg); + return 1; + } + + char display[128] = {0}, state[32] = {0}, pid[16] = {0}; + jget(buf, "display", display, sizeof(display)); + jget(buf, "state", state, sizeof(state)); + jget(buf, "pid", pid, sizeof(pid)); + + printf("Unit : %s\n", unit); + printf("Display : %s\n", display); + printf("State : %s\n", state); + if (pid[0] && strcmp(pid, "0") != 0) + printf("PID : %s\n", pid); + return 0; +} + +static int cmd_input(int fd, const char *unit, const char *input) { + char msg[1024]; + snprintf(msg, sizeof(msg), + "{\"cmd\":\"input\",\"unit\":\"%s\",\"data\":\"%s\\n\"}", + unit, input); + if (send_msg(fd, msg) != 0) return 1; + + char buf[PROTO_MAX]; + if (recv_msg(fd, buf, sizeof(buf)) <= 0) return 1; + + /* Print any output that came back */ + char type[32] = {0}; + jget(buf, "type", type, sizeof(type)); + if (strcmp(type, "output") == 0) { + char data[4096] = {0}; + jget(buf, "data", data, sizeof(data)); + printf("%s\n", data); + } else if (strcmp(type, "error") == 0) { + char errmsg[256] = {0}; + jget(buf, "message", errmsg, sizeof(errmsg)); + fprintf(stderr, "Error: %s\n", errmsg); + return 1; + } + return 0; +} + +static int cmd_action(int fd, const char *unit, const char *action) { + char msg[256]; + snprintf(msg, sizeof(msg), + "{\"cmd\":\"action\",\"unit\":\"%s\",\"action\":\"%s\"}", + unit, action); + if (send_msg(fd, msg) != 0) return 1; + + char buf[PROTO_MAX]; + if (recv_msg(fd, buf, sizeof(buf)) <= 0) return 1; + + char type[32] = {0}; + jget(buf, "type", type, sizeof(type)); + if (strcmp(type, "error") == 0) { + char errmsg[256] = {0}; + jget(buf, "message", errmsg, sizeof(errmsg)); + fprintf(stderr, "Error: %s\n", errmsg); + return 1; + } + printf("Action '%s' dispatched for unit '%s'\n", action, unit); + return 0; +} + +/* + * cmd_attach: interactive console session. + * Attaches to the unit, receives live output, sends typed input. + * Ctrl+D or Ctrl+C to detach. + */ +static int cmd_attach(int fd, const char *unit) { + char msg[256]; + snprintf(msg, sizeof(msg), + "{\"cmd\":\"attach\",\"unit\":\"%s\"}", unit); + if (send_msg(fd, msg) != 0) return 1; + + /* Set terminal to raw mode so we get input character by character */ + struct termios orig, raw; + int is_tty = isatty(STDIN_FILENO); + if (is_tty) { + tcgetattr(STDIN_FILENO, &orig); + raw = orig; + raw.c_lflag &= ~(ICANON | ECHO); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + tcsetattr(STDIN_FILENO, TCSANOW, &raw); + } + + printf("── Attached to %s (Ctrl+D to detach) ──\n", unit); + char drain[PROTO_MAX]; + recv_msg(fd, drain, sizeof(drain)); + fflush(stdout); + + char input_buf[1024]; + int input_pos = 0; + int ret = 0; + + while (1) { + fd_set rfds; + FD_ZERO(&rfds); + FD_SET(fd, &rfds); /* daemon messages */ + FD_SET(STDIN_FILENO, &rfds); /* keyboard input */ + int maxfd = fd > STDIN_FILENO ? fd : STDIN_FILENO; + + int n = select(maxfd + 1, &rfds, NULL, NULL, NULL); + if (n < 0) { + if (errno == EINTR) continue; + break; + } + + /* Daemon sent us something */ + if (FD_ISSET(fd, &rfds)) { + char buf[PROTO_MAX]; + int r = recv_msg(fd, buf, sizeof(buf)); + if (r <= 0) break; + + char type[32] = {0}; + jget(buf, "type", type, sizeof(type)); + + if (strcmp(type, "output") == 0) { + char data[PROTO_MAX] = {0}; + jget(buf, "data", data, sizeof(data)); + /* Print output, restore cursor line for input */ + printf("%s", data); + fflush(stdout); + } else if (strcmp(type, "error") == 0) { + char errmsg[256] = {0}; + jget(buf, "message", errmsg, sizeof(errmsg)); + fprintf(stderr, "\nError: %s\n", errmsg); + ret = 1; + break; + } + } + + /* User typed something */ + if (FD_ISSET(STDIN_FILENO, &rfds)) { + char ch; + ssize_t r = read(STDIN_FILENO, &ch, 1); + if (r <= 0) break; + + if (ch == 4) break; /* Ctrl+D — detach */ + + if (ch == '\n' || ch == '\r') { + /* Send the buffered line to the unit */ + input_buf[input_pos] = '\0'; + printf("\n"); + + char send_buf[1200]; + /* JSON-escape the input */ + char escaped[1100]; + int j = 0; + for (int i = 0; input_buf[i] && j < 1090; i++) { + if (input_buf[i] == '"') { escaped[j++] = '\\'; escaped[j++] = '"'; } + else if (input_buf[i] == '\\') { escaped[j++] = '\\'; escaped[j++] = '\\'; } + else escaped[j++] = input_buf[i]; + } + escaped[j] = '\0'; + + snprintf(send_buf, sizeof(send_buf), + "{\"cmd\":\"input\",\"unit\":\"%s\"," + "\"data\":\"%s\\n\"}", unit, escaped); + send_msg(fd, send_buf); + input_pos = 0; + } else if (ch == 127 || ch == '\b') { + /* Backspace */ + if (input_pos > 0) { + input_pos--; + printf("\b \b"); + fflush(stdout); + } + } else { + if (input_pos < (int)sizeof(input_buf) - 1) { + input_buf[input_pos++] = ch; + /* Echo the character */ + write(STDOUT_FILENO, &ch, 1); + } + } + } + } + + /* Restore terminal */ + if (is_tty) + tcsetattr(STDIN_FILENO, TCSANOW, &orig); + + /* Send detach */ + send_msg(fd, "{\"cmd\":\"detach\"}"); + printf("\n── Detached ──\n"); + return ret; +} + +/* ── Entry point ─────────────────────────────────────────────────────────── */ + +static void usage(void) { + fprintf(stderr, + "Usage: umbrella-cli <command> [args]\n" + "\n" + "Commands:\n" + " list List all units\n" + " status <unit> Show unit status\n" + " attach <unit> Interactive console session\n" + " input <unit> <cmd> Send a single command\n" + " action <unit> <action> Run a named action\n" + "\n" + "Examples:\n" + " umbrella-cli list\n" + " umbrella-cli attach tf2-novemen\n" + " umbrella-cli input tf2-novemen \"say Server restarting soon\"\n" + " umbrella-cli action tf2-novemen update\n"); +} + +int main(int argc, char *argv[]) { + if (argc < 2) { usage(); return 1; } + + int fd = connect_daemon(); + if (fd < 0) return 1; + + int ret = 0; + const char *cmd = argv[1]; + + if (strcmp(cmd, "list") == 0) { + ret = cmd_list(fd); + } else if (strcmp(cmd, "status") == 0) { + if (argc < 3) { fprintf(stderr, "status: need unit name\n"); ret = 1; } + else ret = cmd_status(fd, argv[2]); + } else if (strcmp(cmd, "attach") == 0) { + if (argc < 3) { fprintf(stderr, "attach: need unit name\n"); ret = 1; } + else ret = cmd_attach(fd, argv[2]); + } else if (strcmp(cmd, "input") == 0) { + if (argc < 4) { fprintf(stderr, "input: need unit and command\n"); ret = 1; } + else ret = cmd_input(fd, argv[2], argv[3]); + } else if (strcmp(cmd, "action") == 0) { + if (argc < 4) { fprintf(stderr, "action: need unit and action name\n"); ret = 1; } + else ret = cmd_action(fd, argv[2], argv[3]); + } else { + fprintf(stderr, "Unknown command: %s\n\n", cmd); + usage(); + ret = 1; + } + + close(fd); + return ret; +} 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 */ diff --git a/umbrella.conf.example b/umbrella.conf.example new file mode 100644 index 0000000..12c0a5e --- /dev/null +++ b/umbrella.conf.example @@ -0,0 +1,14 @@ +# /etc/umbrella/umbrella.conf +# umbrella daemon configuration + +# Path to the Unix socket clients connect to +socket_path = /run/umbrella/umbrella.sock + +# Log level: debug, info, warn, error +log_level = info + +# Log file path (used when running as daemon) +log_file = /var/log/umbrella/umbrella.log + +# Number of output lines to keep in memory per unit +ring_buffer_lines = 500 diff --git a/units/tf2-novemen.yaml.example b/units/tf2-novemen.yaml.example new file mode 100644 index 0000000..76985ba --- /dev/null +++ b/units/tf2-novemen.yaml.example @@ -0,0 +1,24 @@ +# /etc/umbrella/units/tf2-novemen.yaml +# TF2 dedicated server unit descriptor for umbrella + +name: tf2-novemen +display: "TF2 — novemen" +service: tf2-server.service + +console: + type: rcon + host: 127.0.0.1 + port: 27015 + password_env: RCON_PASSWORD # set this in umbrella's environment + +health: + type: a2s + host: 127.0.0.1 + port: 27015 + timeout_ms: 5000 + +logs: + - /ded/tf/novemen/tf2/logs/service.log + +actions: + update: /ded/tf/scripts/tf2_autoupdate.sh |
