summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorauric <auric@japegames.com>2026-02-21 11:08:36 -0600
committerauric <auric@japegames.com>2026-02-21 11:08:36 -0600
commit0d706ae72ceefd74053ad6cb0900ecce6cf1f085 (patch)
tree6faf7d3919182b8838a6ae69ad1a2a0fac468740
Add Umbrella 0.1.5
-rw-r--r--Makefile57
-rw-r--r--clients/umbrella-bot/bot.conf.example25
-rwxr-xr-xclients/umbrella-bot/umbrella-bot.py631
-rw-r--r--clients/umbrella-bot/umbrella-bot.service16
-rw-r--r--clients/umbrella-cli/main.c410
-rw-r--r--src/client.c320
-rw-r--r--src/client.h42
-rw-r--r--src/console/rcon.c238
-rw-r--r--src/console/rcon.h39
-rw-r--r--src/daemon.c148
-rw-r--r--src/daemon.h27
-rw-r--r--src/log.c60
-rw-r--r--src/log.h22
-rw-r--r--src/log_tail.c118
-rw-r--r--src/log_tail.h31
-rw-r--r--src/main.c240
-rw-r--r--src/proto.c183
-rw-r--r--src/proto.h70
-rw-r--r--src/umbrella.h121
-rw-r--r--src/unit.c295
-rw-r--r--src/unit.h45
-rw-r--r--umbrella.conf.example14
-rw-r--r--units/tf2-novemen.yaml.example24
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