diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/README.md b/README.md index 6925e86..dda74e0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,90 @@ # Watchman -Our internal project for managing docker containers via Discord. +Our internal project for managing compose stacks via Discord slash commands. -For the config, just check out `_config.json`! It's quite self-documenting and includes two example bots. +## Root Configuration (`config.json`) -If you use the containerized version, make sure to mount `/var/run/docker.sock`! +To enable Keycloak SSO RBAC, add the following parameters to your root `config.json`: +```json +{ + "token": "YOUR_DISCORD_BOT_TOKEN", + "botGroupId": "YOUR_DISCORD_GUILD_ID", + "services": { + "main-bot": { + "root": "/etc/buildtheearth/main-bot" + } + }, + "roles": [], + "users": [], + + "keycloak_enabled": true, + "keycloak_server_url": "https://sso.example.com", + "keycloak_realm": "staff", + "keycloak_client_id": "watchman", + "keycloak_client_secret": "YOUR_KEYCLOAK_CLIENT_SECRET", + "keycloak_idp_alias": "discord", + "keycloak_root_role": "watchman-user", + "keycloak_admin_role": "watchman-admin" +} +``` + +--- + +## Service Configs (`watchman.json`) + +Each service root folder must contain a `watchman.json` and a `compose.yml` (or `docker-compose.yml`) file. + +### Permissions Syntax + +Permissions must be defined under the nested `"permissions"` key. You can also specify `status_commands_require_access` to control visibility on a per-service level: + +```json +{ + "name": "main-bot", + "icon": "https://example.com/icon.png", + "permissions": { + "roles": ["main-bot-operator", "sso-role-name"], + "users": ["123456789012345678"], + "status_commands_require_access": false + }, + "hooks": { + "pre_up": "infisical run -- docker compose config > /dev/null", + "pre_update": "infisical run -- env | head -n 1", + "strict": true + }, + "compose_commands": { + "up": "infisical run -- docker compose up -d", + "pull": "infisical run -- docker compose pull" + } +} +``` + +- **`permissions.roles`** (`list`): Keycloak realm or client roles that grant mutation access (start, stop, restart, update) for this service. +- **`permissions.users`** (`list`): Discord user IDs for local bypass or fallback access. +- **`permissions.status_commands_require_access`** (`bool`): + - If `true`, only users with the mutation role (or admins) can check this service's status or see it listed. + - If `false` (default), anyone with the Keycloak `root_role` can see the service in `/wm status` and `/wm service list` as read-only. Read-only services are decorated with a `🔒` emoji, while write-accessible services are decorated with a `🔓` emoji. + +--- + +## Slash Commands + +- `/wm help`: Displays the help menu. +- `/wm status [service]`: Check the status of all stacks or filter by a specific service. +- `/wm start `: Start a compose stack. +- `/wm stop `: Stop a compose stack. +- `/wm restart `: Restart a compose stack. +- `/wm update `: Pull images and update a stack. +- `/wm service list`: List registered services and roots. +- `/wm service validate `: Check validation of `watchman.json` and compose file. +- `/wm service add `: Add a service (Admins only). +- `/wm service remove `: Remove a service (Admins only). +- `/wm debug`: Prints diagnostic info showing your Keycloak identity, roles, gateway permissions, and service-by-service access breakdown. + +--- + +## Notes + +- Mount `/var/run/docker.sock` if running Watchman containerized. +- Watchman tries `docker compose` first, then falls back to `docker-compose`. diff --git a/_config.json b/_config.json index a98b51c..ccee1f6 100644 --- a/_config.json +++ b/_config.json @@ -1,39 +1,25 @@ { - "prefix": "=wm ", "token": "", "botGroupId": "", - "bots": { + "services": { "main-bot": { - "icon": "", - "image": "buildtheearth/main-bot", - "network": "bot-network", - "volumes": { - "/etc/buildtheearth/main-bot/config/config.json5": "/etc/buildtheearth/main-bot/config/config.json5" - }, - "ports": { - }, - "restart_policy": "unless-stopped" + "root": "/etc/buildtheearth/main-bot" }, "support-bot": { - "icon": "", - "image": "buildtheearth/support-bot", - "network": "bot-network", - "volumes": { - "/etc/buildtheearth/support-bot/config.ini": "/etc/buildtheearth/support-bot/config.ini" - }, - "ports": { - "8890/tcp": "8890/tcp" - }, - "restart_policy": "unless-stopped" + "root": "/etc/buildtheearth/support-bot" } }, - "roles": [ - "id1", - "id2" - ], "users": [ "user1id", "user2id" ], - "error_channel": "watchman_log" + "keycloak_enabled": false, + "keycloak_server_url": "https://sso.example.com", + "keycloak_realm": "staff", + "keycloak_client_id": "watchman", + "keycloak_client_secret": "client-secret-here", + "keycloak_idp_alias": "discord", + "keycloak_root_role": "watchman-user", + "keycloak_admin_role": "watchman-admin", + "status_commands_require_access": false } \ No newline at end of file diff --git a/bot.py b/bot.py index 0787dd1..015bfd1 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,9 @@ +import subprocess +import json +import shlex + import interactions -import docker -import interactions + from config_loader import Config status_dict = { @@ -11,226 +14,733 @@ "paused": ":white_circle: Paused", "exited": ":red_circle: Exited", "dead": ":red_circle: Dead", - "none": ":white_circle: No container was found!" + "none": ":white_circle: No service stack was found!", } generic_reason = "This is an extremely confidential bot for confidential purposes, scram." base = interactions.SlashCommand(name="wm", description=generic_reason) config = Config("config.json") -bot_option = interactions.slash_str_option("bot", True, False, None, None, None, "container") -def no_container_embed(): - return interactions.Embed(title="Error", description="No container found. Please specify a valid container.", - color=0xff0000) + +def no_service_embed(): + return interactions.Embed( + title="Error", + description="No service found. Please specify a valid service.", + color=0xFF0000, + ) + def no_perms_embed(): - return interactions.Embed(title="Error", description="No permissions for this container. Please specify a container you have permissions for.", - color=0xff0000) + return interactions.Embed( + title="Error", + description="No permissions for this service.", + color=0xFF0000, + ) -class Watchman(interactions.Extension): +def command_failed_embed(operation, err): + return interactions.Embed( + title=f"{operation} Failed", + description=f"```\n{err}\n```", + color=0xFF0000, + ) + + +class Watchman(interactions.Extension): def __init__(self, bot): self.bot = bot - self.client = docker.from_env() - - def fetch_container(self, name): - if name is None: - return None - for container in self.client.containers.list(all=True): - if container.name == name and name in config.list_bots(): - return container + + def _service_icon(self, service_name): + try: + cfg = config.load_service_config(service_name) + icon = cfg.get("icon") + if isinstance(icon, str) and icon.strip(): + return icon + except Exception: + pass return None - def container_embed(self, container, title, description, color): + def service_embed(self, service_name, title, description, color): embed = interactions.Embed( title=title, description=description, color=color) - embed.set_author( - name=container, icon_url=config.get_bot(container)['icon']) + icon = self._service_icon(service_name) + if icon: + embed.set_author(name=service_name, icon_url=icon) + else: + embed.set_author(name=service_name) return embed def command_name(self, name): - return "`" + config.prefix + name + "` " + return f"`/wm {name}`" + + @interactions.listen() + async def on_startup(self): + print("Bot is ready!") @interactions.listen(interactions.api.events.Error) async def on_error(self, error: interactions.api.events.Error): - embed = interactions.Embed(title="Watchman Error", description=f"```\n{error.source}\n{error.error}\n```", color=0xFF0000) - await bot.fetch_channel(config.error_channel).send(embeds=[embed]) - - @interactions.listen(interactions.api.events.InteractionCreate) - async def on_interaction_create(self, ctx: interactions.api.events.InteractionCreate): - embed = interactions.Embed(description=f'[{ctx.guild.name}] {ctx.author.name} ran \'{ctx.data.options[0].name}\' command.', color=0xFF0000) - await bot.fetch_channel(config.error_channel).send(embeds=[embed]) + print( + f"Watchman Error from source {error.source}: {error.error}", flush=True) + + def _compose_base_cmd(self, root): + docker_compose_probe = subprocess.run( + ["docker", "compose", "version"], + cwd=root, + capture_output=True, + text=True, + ) + if docker_compose_probe.returncode == 0: + return ["docker", "compose"] + + compose_v1_probe = subprocess.run( + ["docker-compose", "version"], + cwd=root, + capture_output=True, + text=True, + ) + if compose_v1_probe.returncode == 0: + return ["docker-compose"] + + return None + + def _run_compose(self, service_name, compose_args): + root = config.get_service_root(service_name) + if not root: + return False, "Service root missing in config." + + compose_file = config.resolve_compose_file(root) + if compose_file is None: + return False, "compose.yml or docker-compose.yml is missing." + + base_cmd = self._compose_base_cmd(root) + if base_cmd is None: + return False, "Neither 'docker compose' nor 'docker-compose' is available on host." + + command = base_cmd + ["-f", compose_file] + compose_args + try: + service_cfg = config.load_service_config(service_name) + pre_cmd_str = service_cfg.get("pre_command").strip() + except Exception: + pre_cmd_str = "" + + if pre_cmd_str: + pre_cmd = shlex.split(pre_cmd_str) + command = pre_cmd + command + + proc = subprocess.run( + command, + cwd=root, + capture_output=True, + text=True, + ) + output = (proc.stdout or "") + (proc.stderr or "") + output = output.strip() + if len(output) > 1800: + output = output[-1800:] + if proc.returncode != 0: + return False, output or "Compose command failed without output." + return True, output or "OK" + + def _run_custom_command(self, service_name, command): + root = config.get_service_root(service_name) + if not root: + return False, "Service root missing in config." + + proc = subprocess.run( + command, + cwd=root, + shell=True, + capture_output=True, + text=True, + ) + output = (proc.stdout or "") + (proc.stderr or "") + output = output.strip() + if len(output) > 1800: + output = output[-1800:] + if proc.returncode != 0: + return False, output or "Custom command failed without output." + return True, output or "OK" + + def _run_service_step(self, service_name, step, default_compose_args): + cfg = config.load_service_config(service_name) + compose_commands = cfg.get( + "compose_commands", {}) if isinstance(cfg, dict) else {} + if isinstance(compose_commands, dict): + custom = compose_commands.get(step) + if isinstance(custom, str) and custom.strip(): + return self._run_custom_command(service_name, custom) + return self._run_compose(service_name, default_compose_args) + + def _run_hook(self, service_name, hook_name): + cfg = config.load_service_config(service_name) + hooks = cfg.get("hooks", {}) if isinstance(cfg, dict) else {} + if not isinstance(hooks, dict): + return True, "" + + command = hooks.get(hook_name) + if not isinstance(command, str) or not command.strip(): + return True, "" + + strict = bool(hooks.get("strict", True)) + root = config.get_service_root(service_name) + proc = subprocess.run( + command, + cwd=root, + shell=True, + capture_output=True, + text=True, + ) + output = (proc.stdout or "") + (proc.stderr or "") + output = output.strip() + if len(output) > 1200: + output = output[-1200:] + + if proc.returncode != 0 and strict: + return False, output or f"Hook '{hook_name}' failed." + + return True, output + + async def _ensure_service_access(self, ctx, service_name): + if service_name not in config.list_services(): + return False, no_service_embed() + if not await config.check_service_specific_perms(ctx, service_name): + return False, no_perms_embed() + return True, None + + def _service_status_summary(self, service_name): + # Prefer structured output when supported by Compose v2. + ok_json, output_json = self._run_compose( + service_name, ["ps", "--format", "json"]) + if ok_json: + try: + rows = json.loads(output_json) + if isinstance(rows, dict): + rows = [rows] + if isinstance(rows, list): + total = len(rows) + running = 0 + exited = 0 + restarting = 0 + for row in rows: + state = str(row.get("State", "")).lower() + status = str(row.get("Status", "")).lower() + if state == "running" or status.startswith("up") or "running" in status: + running += 1 + elif "restart" in state or "restart" in status: + restarting += 1 + elif "exit" in state or "dead" in state or "exit" in status: + exited += 1 + return True, { + "total": total, + "running": running, + "restarting": restarting, + "exited": exited, + } + except Exception: + pass + + # Fallback parser for docker-compose / non-json output. + ok_ps, output_ps = self._run_compose(service_name, ["ps"]) + if not ok_ps: + return False, output_ps + + lines = [line for line in output_ps.splitlines() if line.strip()] + if len(lines) <= 1: + return True, {"total": 0, "running": 0, "restarting": 0, "exited": 0} + + rows = lines[1:] + total = len(rows) + running = 0 + exited = 0 + restarting = 0 + for row in rows: + lower_row = row.lower() + if " up " in f" {lower_row} " or "running" in lower_row: + running += 1 + elif "restart" in lower_row: + restarting += 1 + elif "exit" in lower_row or "dead" in lower_row: + exited += 1 + + return True, { + "total": total, + "running": running, + "restarting": restarting, + "exited": exited, + } + + async def _service_autocomplete_choices(self, ctx, is_status=False): + query = (ctx.input_text or "").lower() + visible = [] + for service_name in config.list_services(): + require_access = config.get_service_status_commands_require_access( + service_name) + if not is_status or require_access: + if not await config.check_service_specific_perms(ctx, service_name): + continue + if query and query not in service_name.lower(): + continue + visible.append({"name": service_name, "value": service_name}) + return visible[:25] @base.subcommand(sub_cmd_name="help", sub_cmd_description=generic_reason) @interactions.check(config.has_perms_async) async def help(self, ctx: interactions.SlashContext): - # Shows all commands for watchman - - embed = interactions.Embed(title="Watchman Help", description="Commands:", color=0x21304a) - embed.add_field(name=self.command_name("info"), value="Get system information.", inline=False) - embed.add_field(name=self.command_name("status"), value="Check the status of the bots.", inline=False) - embed.add_field(name=self.command_name("start "), value="Start a bot.", inline=False) - embed.add_field(name=self.command_name("stop "), value="Stop a bot.", inline=False) - embed.add_field(name=self.command_name("kill "), value="Kill a bot.", inline=False) - embed.add_field(name=self.command_name("restart "), value="Start a bot.", inline=False) - embed.add_field(name=self.command_name("pull "), value="Pull a new image for the bot.", inline=False) + embed = interactions.Embed( + title="Watchman Help", description="Commands:", color=0x21304A) + embed.add_field(name=self.command_name( + "status [service]"), value="Check status of all configured service stacks or one service.", inline=False) + embed.add_field(name=self.command_name("start "), + value="Run docker compose up -d.", inline=False) + embed.add_field(name=self.command_name("stop "), + value="Run docker compose stop.", inline=False) + embed.add_field(name=self.command_name("restart "), + value="Run docker compose restart.", inline=False) + embed.add_field(name=self.command_name("update "), + value="Run docker compose pull && docker compose up -d.", inline=False) + embed.add_field(name=self.command_name("service list"), + value="List known services and roots.", inline=False) + embed.add_field(name=self.command_name("service add "), + value="Validate and register a new service root.", inline=False) + embed.add_field(name=self.command_name( + "service remove "), value="Unregister a service.", inline=False) + embed.add_field(name=self.command_name("service validate "), + value="Validate watchman.json and compose file presence.", inline=False) + embed.add_field(name=self.command_name( + "debug"), value="Print out RBAC diagnostics and services you have access to.", inline=False) return await ctx.send(embeds=[embed]) - @base.subcommand(sub_cmd_name="info", sub_cmd_description=generic_reason) + @base.subcommand(sub_cmd_name="debug", sub_cmd_description=generic_reason) @interactions.check(config.has_perms_async) - async def info(self, ctx: interactions.SlashContext): - # Shows info for watchman host machine - embed = interactions.Embed(title="Docker Info", description="", color=0x21304a) - version = self.client.version() - embed.add_field(name="Platform", value=version['Platform']['Name'], inline=False) - embed.add_field(name="Version", value=version['Version'], inline=False) - embed.add_field(name="API Version", value=version['ApiVersion'], inline=False) - return await ctx.send(embeds=[embed]) + async def debug(self, ctx: interactions.SlashContext): + author_id = str(ctx.author.id) + embed = interactions.Embed(title="Watchman RBAC Debug", color=0x21304A) + + embed.add_field( + name="User", value=f"{ctx.author.name} (ID: {author_id})", inline=False) + + if config.keycloak_enabled: + embed.add_field(name="Keycloak Integration", + value="Enabled", inline=True) + embed.add_field(name="Keycloak Server", + value=config.keycloak_server_url, inline=True) + embed.add_field(name="Keycloak Realm", + value=config.keycloak_realm, inline=True) + + roles = await config.keycloak.get_user_roles(author_id) + roles_list = sorted(list(roles)) if roles else [] + roles_str = ", ".join( + f"`{r}`" for r in roles_list) if roles_list else "*None*" + embed.add_field(name="Keycloak Roles", + value=roles_str, inline=False) + + has_root = config.keycloak_root_role in roles if config.keycloak_root_role else True + has_admin = config.keycloak_admin_role in roles if config.keycloak_admin_role else False + + root_status = f"Root Role (`{config.keycloak_root_role}`): {'✅ Yes' if has_root else '❌ No'}" + admin_status = f"Admin Role (`{config.keycloak_admin_role}`): {'✅ Yes' if has_admin else '❌ No'}" + embed.add_field( + name="Role Checks", value=f"{root_status}\n{admin_status}", inline=False) + + services_status = [] + for service_name in config.list_services(): + has_access = await config.check_service_specific_perms(ctx, service_name) + service_users, service_roles = config._service_permission_sets( + service_name) + + reason = "Denied" + if has_access: + if has_admin: + reason = "Allowed (Admin Role)" + elif author_id in service_users: + reason = "Allowed (User Bypass)" + elif service_roles and len(roles.intersection(service_roles)) > 0: + matching = roles.intersection(service_roles) + reason = f"Allowed (Service Role: {', '.join(matching)})" + else: + reason = "Allowed (Local Bypass)" + + status_char = "🟢" if has_access else "🔴" + services_status.append( + f"{status_char} **{service_name}**: {reason}") + + services_str = "\n".join( + services_status) if services_status else "No services configured." + embed.add_field(name="Service Access Details", + value=services_str, inline=False) + else: + embed.add_field(name="Keycloak Integration", + value="Disabled", inline=False) + + services_status = [] + has_base = await config.has_perms_base(ctx) + for service_name in config.list_services(): + has_access = await config.check_service_specific_perms(ctx, service_name) + service_users, service_roles = config._service_permission_sets( + service_name) + + reason = "Denied" + if has_access: + if has_base: + reason = "Allowed (Base/Global Perms)" + elif author_id in service_users: + reason = "Allowed (User Bypass)" + else: + reason = "Allowed (Service Role)" + + status_char = "🟢" if has_access else "🔴" + services_status.append( + f"{status_char} **{service_name}**: {reason}") + + services_str = "\n".join( + services_status) if services_status else "No services configured." + embed.add_field(name="Service Access Details", + value=services_str, inline=False) @base.subcommand(sub_cmd_name="status", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "optional service filter", + opt_type=interactions.OptionType.STRING, + required=False, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def status(self, ctx: interactions.SlashContext): - # Displays current status of bot containers - embed = interactions.Embed(title="Container Status", description="", color=0x21304a) - for b in config.list_bots(): - container = self.fetch_container(b) - if container: - desc = status_dict[container.status] + "\n\n" + async def status(self, ctx: interactions.SlashContext, service: str = None): + embed = interactions.Embed( + title="Compose Status", description="", color=0x21304A) + + if service: + services = [service] + else: + services = config.list_services() + + if not services: + embed.description = "No services configured." + return await ctx.send(embeds=[embed]) + + for service_name in services: + has_access = await config.check_service_specific_perms(ctx, service_name) + require_access = config.get_service_status_commands_require_access( + service_name) + if require_access and not has_access: + continue + + ok, summary = self._service_status_summary(service_name) + access_emoji = "🔓" if has_access else "🔒" + if ok: + value = ( + f"{access_emoji} Reachable\n" + f"Services: {summary['total']}\n" + f"Running: {summary['running']}\n" + f"Restarting: {summary['restarting']}\n" + f"Exited/Dead: {summary['exited']}" + ) else: - desc = ":white_circle: No container was found!\n\n" - embed.add_field(name="**" + b + "**", value=desc, inline=False) + short_err = str(summary).replace("\n", " ") + if len(short_err) > 300: + short_err = short_err[:300] + "..." + value = f"{access_emoji} Unavailable\nReason: {short_err}" + embed.add_field(name=f"**{service_name}**", + value=value, inline=False) + + if len(embed.fields) == 0: + return await ctx.send(embeds=[no_perms_embed()]) return await ctx.send(embeds=[embed]) @base.subcommand(sub_cmd_name="start", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def start(self, ctx: interactions.SlashContext, bot: bot_option): - # Starts a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def start(self, ctx: interactions.SlashContext, service: str): + allowed, error = await self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) + + hook_ok, hook_output = self._run_hook(service, "pre_up") + if not hook_ok: + return await ctx.send(embeds=[command_failed_embed("Pre-up Hook", hook_output)]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Start Container", "Starting...", 0x21304a)]) - container.start() - await message.edit(embeds=[self.container_embed(bot, "Start Container", "Successfully started container.", 0x00ff00)]) + message = await ctx.send(embeds=[self.service_embed(service, "Start Service", "Starting...", 0x21304A)]) + ok, output = self._run_service_step(service, "up", ["up", "-d"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("Start Service", output)]) + + desc = "Successfully started service stack." + if hook_output: + desc += f"\n\nHook output:\n```\n{hook_output[:500]}\n```" + await message.edit(embeds=[self.service_embed(service, "Start Service", desc, 0x00FF00)]) @base.subcommand(sub_cmd_name="stop", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def stop(self, ctx: interactions.SlashContext, bot: bot_option): - # Stops a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def stop(self, ctx: interactions.SlashContext, service: str): + allowed, error = await self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Stop Container", "Stopping...", 0x21304a)]) - container.stop() - await message.edit(embeds=[self.container_embed(bot, "Stop Container", "Successfully stopped container.", 0x00ff00)]) + message = await ctx.send(embeds=[self.service_embed(service, "Stop Service", "Stopping...", 0x21304A)]) + ok, output = self._run_compose(service, ["stop"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("Stop Service", output)]) + await message.edit(embeds=[self.service_embed(service, "Stop Service", "Successfully stopped service stack.", 0x00FF00)]) - @base.subcommand(sub_cmd_name="kill", sub_cmd_description=generic_reason) + @base.subcommand(sub_cmd_name="restart", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def kill(self, ctx: interactions.SlashContext, bot: bot_option): - # Kills a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def restart(self, ctx: interactions.SlashContext, service: str): + allowed, error = await self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Kill Container", "Killing...", 0x21304a)]) - container.kill() - await message.edit(embeds=[self.container_embed(bot, "Kill Container", "Successfully killed container.", 0x00ff00)]) + message = await ctx.send(embeds=[self.service_embed(service, "Restart Service", "Restarting...", 0x21304A)]) + ok, output = self._run_compose(service, ["restart"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("Restart Service", output)]) + await message.edit(embeds=[self.service_embed(service, "Restart Service", "Successfully restarted service stack.", 0x00FF00)]) - @base.subcommand(sub_cmd_name="restart", sub_cmd_description=generic_reason) + @base.subcommand(sub_cmd_name="update", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def restart(self, ctx: interactions.SlashContext, bot: bot_option): - # Restarts a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def update(self, ctx: interactions.SlashContext, service: str): + allowed, error = await self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Restart Container", "Restarting...", 0x21304a)]) - container.restart() - await message.edit( - embeds=[self.container_embed(bot, "Restart Container", "Successfully restarted bot.", 0x00ff00)]) + hook_ok, hook_output = self._run_hook(service, "pre_update") + if not hook_ok: + return await ctx.send(embeds=[command_failed_embed("Pre-update Hook", hook_output)]) - @base.subcommand(sub_cmd_name="pull", sub_cmd_description=generic_reason) + message = await ctx.send(embeds=[self.service_embed(service, "Update Service", "Pulling images...", 0x21304A)]) + ok_pull, pull_output = self._run_service_step( + service, "pull", ["pull"]) + if not ok_pull: + return await message.edit(embeds=[command_failed_embed("Update Service (pull)", pull_output)]) + + await message.edit(embeds=[self.service_embed(service, "Update Service", "Applying updated images...", 0x21304A)]) + ok_up, up_output = self._run_service_step(service, "up", ["up", "-d"]) + if not ok_up: + return await message.edit(embeds=[command_failed_embed("Update Service (up)", up_output)]) + + combined = "Successfully updated service stack." + if hook_output: + combined += f"\n\nHook output:\n```\n{hook_output[:300]}\n```" + await message.edit(embeds=[self.service_embed(service, "Update Service", combined, 0x00FF00)]) + + @base.subcommand(sub_cmd_name="logs", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def pull(self, ctx: interactions.SlashContext, bot: bot_option): - # Pulls any changes from the registry, and creates a new container - bot_info = config.get_bot(bot) - if not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): + async def logs(self, ctx: interactions.SlashContext, service: str): + allowed, error = await self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) + + message = await ctx.send(embeds=[self.service_embed(service, "View Logs", "Fetching logs...", 0x21304A)]) + ok, output = self._run_service_step(service, "logs", ["logs"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("View Logs", output)]) + + desc = "" + if output: + desc = f"\n\nLogs:\n```\n{output[:400]}\n```" + await message.edit(embeds=[self.service_embed(service, "View Logs", desc, 0x00FF00)]) + + @base.subcommand( + sub_cmd_name="list", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) + @interactions.check(config.has_perms_async) + async def service_list(self, ctx: interactions.SlashContext): + embed = interactions.Embed( + title="Configured Services", description="", color=0x21304A) + + if not config.list_services(): + embed.description = "No services configured." + return await ctx.send(embeds=[embed]) + + for service_name in config.list_services(): + root = config.get_service_root(service_name) or "" + has_access = await config.check_service_specific_perms(ctx, service_name) + require_access = config.get_service_status_commands_require_access( + service_name) + if has_access or not require_access: + access_emoji = "🔓" if has_access else "🔒" + embed.add_field( + name=f"{access_emoji} {service_name}", value=root, inline=False) + + if len(embed.fields) == 0: return await ctx.send(embeds=[no_perms_embed()]) - container = self.fetch_container(bot) - image = bot_info['image'] - message = await ctx.send(embeds=[self.container_embed(bot, "Pull Container", "Stopping: " - ":hourglass" - ":\nPulling: " - ":black_small_square:\nStarting: :black_small_square:", - 0x21304a)]) - try: - if container is not None: - container.stop() - container.remove() - await message.edit(embeds=[self.container_embed(bot, "Pull Container", "Stopping: :white_check_mark" - ":\nPulling: :hourglass:\nStarting: " - ":black_small_square:", 0x21304a)]) - real_tag = bot_info['tag'] if "tag" in bot_info else "latest" - print(bot_info) - print(real_tag) - self.client.images.pull(repository=image, tag=real_tag) - await message.edit(embeds=[self.container_embed(bot, "Pull Container", "Stopping: :white_check_mark" - ":\nPulling: " - ":white_check_mark:\nStarting: " - ":hourglass:", 0x21304a)]) - volumes = {} - ports = {} - restart_policy = { - "Name": bot_info['restart_policy'] - } - if bot_info['restart_policy'] == "never": restart_policy = None - for k in bot_info['volumes']: - volumes[k] = { - "bind": bot_info['volumes'][k], - "mode": "rw" - } - for k in bot_info['ports']: - ports[k] = bot_info['ports'][k] - labels = { - "io.portainer.accesscontrol.teams": config.bot_group - } - self.client.containers.run(name=bot, image=image+":"+real_tag, network=bot_info['network'], volumes=volumes, labels=labels, ports=ports, restart_policy=restart_policy, detach=True) - await message.edit(embeds=[self.container_embed(bot, "Pull Container", "Stopping: :white_check_mark" - ":\nPulling: " - ":white_check_mark:\nStarting: " - ":white_check_mark:\n\n" - ":white_check_mark: Successfully " - "built new image", 0x21304a)]) - except Exception as err: - await message.edit(embeds=[self.container_embed(bot, "Pull Container", ":x: Failed to pull new " - "container.\n```" + str(err) + - "```", 0x21304a)]) - + return await ctx.send(embeds=[embed]) + + @base.subcommand( + sub_cmd_name="add", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) + @interactions.slash_option( + "root", + "absolute service root directory", + opt_type=interactions.OptionType.STRING, + required=True, + ) + @interactions.check(config.has_perms_base) + async def service_add(self, ctx: interactions.SlashContext, root: str): + service_name, errors = config.add_service(root) + if errors: + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Add Failed", + description="\n".join(f"- {err}" for err in errors), + color=0xFF0000, + ) + ] + ) + + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Added", + description=f"Registered service '{service_name}' from root '{root}'.", + color=0x00FF00, + ) + ] + ) + + @base.subcommand( + sub_cmd_name="remove", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) + @interactions.check(config.has_perms_base) + async def service_remove(self, ctx: interactions.SlashContext, service: str): + removed = config.remove_service(service) + if not removed: + return await ctx.send(embeds=[no_service_embed()]) + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Removed", + description=f"Removed service '{service}' from config.", + color=0x00FF00, + ) + ] + ) + + @base.subcommand( + sub_cmd_name="validate", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) + @interactions.check(config.has_perms_async) + async def service_validate(self, ctx: interactions.SlashContext, service: str): + allowed, error = await self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) + + root = config.get_service_root(service) + ok, errors = config.validate_service_root(root) + + if not ok: + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Validation Failed", + description="\n".join(f"- {err}" for err in errors), + color=0xFF0000, + ) + ] + ) + + compose_file = config.resolve_compose_file(root) + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Valid", + description=f"watchman.json and compose file found.\nCompose: {compose_file}", + color=0x00FF00, + ) + ] + ) + + @start.autocomplete("service") + async def start_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(await self._service_autocomplete_choices(ctx)) + + @status.autocomplete("service") + async def status_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(await self._service_autocomplete_choices(ctx, is_status=True)) + + @stop.autocomplete("service") + async def stop_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(await self._service_autocomplete_choices(ctx)) + + @restart.autocomplete("service") + async def restart_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(await self._service_autocomplete_choices(ctx)) + + @update.autocomplete("service") + async def update_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(await self._service_autocomplete_choices(ctx)) + + @service_remove.autocomplete("service") + async def remove_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(await self._service_autocomplete_choices(ctx)) + + @service_validate.autocomplete("service") + async def validate_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(await self._service_autocomplete_choices(ctx)) + + def setup(client): Watchman(client) diff --git a/config_loader.py b/config_loader.py index 31ec5b5..ba648b5 100644 --- a/config_loader.py +++ b/config_loader.py @@ -1,40 +1,255 @@ import json +import os +import tempfile +from pathlib import Path + import interactions class Config: def __init__(self, config_file): - self.config_file = json.load(open(config_file, encoding="utf-8-sig")) + self.path = Path(config_file) + self.config_file = {} + self.registry_key = "services" + self.token = "" + self.bot_group = "" + self.prefix = "/wm " + self.users = set() + self.services = {} + from keycloak_client import KeycloakClient + self.keycloak = KeycloakClient(self) + self.reload() + + def reload(self): + if hasattr(self, "keycloak"): + self.keycloak.clear_cache() + with open(self.path, encoding="utf-8-sig") as f: + self.config_file = json.load(f) + self.token = self.config_file["token"] - self.bot_group = self.config_file["botGroup"] - self.bots = self.config_file["bots"] - self.prefix = self.config_file["prefix"] - self.roles = self.config_file["roles"] - self.users = set(self.config_file["users"]) #This is largely unnecessary, but S P E E D - self.error_channel = self.config_file["error_channel"] + self.bot_group = self.config_file.get("botGroupId", "") + self.prefix = self.config_file.get("prefix", "/wm ") + self.users = {str(user) for user in self.config_file.get("users", [])} + + # Keycloak integration settings + self.keycloak_enabled = bool(self.config_file.get("keycloak_enabled", False)) + self.keycloak_server_url = str(self.config_file.get("keycloak_server_url", "")) + self.keycloak_realm = str(self.config_file.get("keycloak_realm", "")) + self.keycloak_client_id = str(self.config_file.get("keycloak_client_id", "")) + self.keycloak_client_secret = str(self.config_file.get("keycloak_client_secret", "")) + self.keycloak_idp_alias = str(self.config_file.get("keycloak_idp_alias", "discord")) + self.keycloak_root_role = str(self.config_file.get("keycloak_root_role", "")) + self.keycloak_admin_role = str(self.config_file.get("keycloak_admin_role", "")) + + if "services" in self.config_file: + self.registry_key = "services" + self.services = self.config_file.get("services", {}) + elif "directories" in self.config_file: + self.registry_key = "directories" + self.services = self.config_file.get("directories", {}) + else: + self.registry_key = "services" + self.services = {} + + def save(self): + self.config_file[self.registry_key] = self.services + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=str(self.path.parent)) as tf: + json.dump(self.config_file, tf, indent=4) + tf.write("\n") + tmp_name = tf.name + os.replace(tmp_name, self.path) + + def list_services(self): + return list(self.services.keys()) + + def get_service(self, name): + return self.services.get(name) + + def get_service_root(self, name): + service = self.get_service(name) + if not service: + return None + return service.get("root") + + def resolve_compose_file(self, root): + compose_candidates = ["compose.yml", "docker-compose.yml"] + root_path = Path(root) + for candidate in compose_candidates: + candidate_path = root_path / candidate + if candidate_path.exists() and candidate_path.is_file(): + return str(candidate_path) + return None + + def load_service_config(self, name): + root = self.get_service_root(name) + if not root: + return {} + path = Path(root) / "watchman.json" + if not path.exists() or not path.is_file(): + return {} + with open(path, encoding="utf-8-sig") as f: + return json.load(f) + + def validate_service_root(self, root): + errors = [] + root_path = Path(root) + if not root_path.exists() or not root_path.is_dir(): + errors.append("Root directory does not exist.") + return False, errors + + watchman_json = root_path / "watchman.json" + if not watchman_json.exists() or not watchman_json.is_file(): + errors.append("watchman.json is missing in the service root.") + + compose_file = self.resolve_compose_file(str(root_path)) + if compose_file is None: + errors.append("Neither compose.yml nor docker-compose.yml was found.") + + return len(errors) == 0, errors + + def infer_service_name(self, root): + root_path = Path(root) + watchman_json = root_path / "watchman.json" + if watchman_json.exists() and watchman_json.is_file(): + try: + with open(watchman_json, encoding="utf-8-sig") as f: + data = json.load(f) + name = data.get("name") + if isinstance(name, str) and name.strip(): + return name.strip() + except Exception: + pass + return root_path.name + + def add_service(self, root): + valid, errors = self.validate_service_root(root) + if not valid: + return None, errors + + root_abs = str(Path(root).resolve()) + for service_name, service in self.services.items(): + if str(service.get("root", "")).strip() == root_abs: + return service_name, [] + + service_name = self.infer_service_name(root_abs) + if service_name in self.services: + return None, [f"Service '{service_name}' already exists in config."] + + self.services[service_name] = { + "root": root_abs + } + self.save() + return service_name, [] + + def remove_service(self, name): + if name not in self.services: + return False + del self.services[name] + self.save() + return True def list_bots(self): - return list(self.bots.keys()) + return self.list_services() def get_bot(self, name): - return self.bots.get(name) - - def has_perms_base(self, ctx): - return str(ctx.author.id) in self.users - - def has_perms_container(self, ctx): - for bot in self.bots.values(): - if 'users' in bot: - return str(ctx.author.id) in bot["users"] + return self.get_service(name) + + async def has_perms_base(self, ctx): + author_id = str(ctx.author.id) + if author_id in self.users: + return True + + if self.keycloak_enabled: + roles = await self.keycloak.get_user_roles(author_id) + if self.keycloak_root_role and self.keycloak_root_role not in roles: + return False + if self.keycloak_admin_role and self.keycloak_admin_role in roles: + return True + if not self.keycloak_admin_role and self.keycloak_root_role in roles: + return True return False - async def has_perms_async(self, ctx): - return self.has_perms_base(ctx) or self.has_perms_container(ctx) + def _service_permission_sets(self, service_name): + service_users = set() + service_roles = set() + service_cfg = self.load_service_config(service_name) + + perms = service_cfg.get("permissions", {}) + if isinstance(perms, dict): + users = perms.get("users", []) + roles = perms.get("roles", []) + if isinstance(users, list): + service_users.update(str(v) for v in users) + if isinstance(roles, list): + service_roles.update(str(v) for v in roles) + + return service_users, service_roles + + def get_service_status_commands_require_access(self, service_name): + service_cfg = self.load_service_config(service_name) + val = service_cfg.get("status_commands_require_access") + if val is None: + perms = service_cfg.get("permissions", {}) + if isinstance(perms, dict): + val = perms.get("status_commands_require_access") + return bool(val) + + async def has_perms_service(self, ctx, service_name): + author_id = str(ctx.author.id) + service_users, service_roles = self._service_permission_sets(service_name) + if author_id in service_users: + return True + + if self.keycloak_enabled: + roles = await self.keycloak.get_user_roles(author_id) + if self.keycloak_root_role and self.keycloak_root_role not in roles: + return False + if self.keycloak_admin_role and self.keycloak_admin_role in roles: + return True + if service_roles and len(roles.intersection(service_roles)) > 0: + return True + return False - def check_bot_specific_perms(self, ctx, bot_info): - if self.has_perms_base(ctx): + async def has_perms_async(self, ctx): + author_id = str(ctx.author.id) + if author_id in self.users: return True - elif 'users' in bot_info: - if str(ctx.author.id) in bot_info["users"]: + + if self.keycloak_enabled: + roles = await self.keycloak.get_user_roles(author_id) + if self.keycloak_root_role and self.keycloak_root_role not in roles: + return False + + if self.keycloak_admin_role and self.keycloak_admin_role in roles: + return True + if not self.keycloak_admin_role and self.keycloak_root_role in roles: return True - return False \ No newline at end of file + + for service_name in self.list_services(): + if not self.get_service_status_commands_require_access(service_name): + return True + if await self.has_perms_service(ctx, service_name): + return True + else: + if await self.has_perms_base(ctx): + return True + for service_name in self.list_services(): + if await self.has_perms_service(ctx, service_name): + return True + return False + + async def check_service_specific_perms(self, ctx, service_name): + if await self.has_perms_base(ctx): + return True + return await self.has_perms_service(ctx, service_name) + + async def check_bot_specific_perms(self, ctx, bot_info): + if not isinstance(bot_info, dict): + return False + root = bot_info.get("root") + if not root: + return False + for name, service in self.services.items(): + if service.get("root") == root: + return await self.check_service_specific_perms(ctx, name) + return False \ No newline at end of file diff --git a/keycloak_client.py b/keycloak_client.py new file mode 100644 index 0000000..9a3bd87 --- /dev/null +++ b/keycloak_client.py @@ -0,0 +1,111 @@ +from keycloak import KeycloakAdmin +import time +import logging + +logger = logging.getLogger("WatchmanKeycloak") + +class KeycloakClient: + def __init__(self, config): + self.config = config + self._admin_client = None + self._client_uuid = None + self._roles_cache = {} # discord_id -> (roles_set, expires_at) + self._cache_ttl = 30 # seconds + + def clear_cache(self): + """Clears all cached client UUIDs, tokens, and user roles.""" + self._roles_cache.clear() + self._admin_client = None + self._client_uuid = None + + def _get_admin_client(self): + if self._admin_client: + return self._admin_client + + # Instantiate KeycloakAdmin client + self._admin_client = KeycloakAdmin( + server_url=self.config.keycloak_server_url, + realm_name=self.config.keycloak_realm, + client_id=self.config.keycloak_client_id, + client_secret_key=self.config.keycloak_client_secret or None, + ) + return self._admin_client + + async def _get_client_uuid(self): + if self._client_uuid: + return self._client_uuid + try: + admin = self._get_admin_client() + # a_get_client_id returns the client UUID string given its human-readable clientId + self._client_uuid = await admin.a_get_client_id(self.config.keycloak_client_id) + return self._client_uuid + except Exception as e: + logger.error(f"Exception fetching client uuid via python-keycloak: {e}") + return None + + async def get_user_roles(self, discord_id): + """Retrieves combined set of realm and client roles for a user by Discord ID.""" + now = time.time() + if discord_id in self._roles_cache: + roles, expires_at = self._roles_cache[discord_id] + if now < expires_at: + return roles + + roles = await self._fetch_user_roles_from_api(discord_id) + if roles is not None: + self._roles_cache[discord_id] = (roles, now + self._cache_ttl) + return roles + return set() + + async def _fetch_user_roles_from_api(self, discord_id): + if not self.config.keycloak_server_url or not self.config.keycloak_realm or not self.config.keycloak_client_id: + logger.warning("Keycloak is enabled but server URL, realm, or client ID is missing in configuration.") + return set() + + try: + admin = self._get_admin_client() + + # Step 1: Find Keycloak user by Discord identity + idp_alias = self.config.keycloak_idp_alias or "discord" + query = { + "idpAlias": idp_alias, + "idpUserId": str(discord_id) + } + users = await admin.a_get_users(query=query) + if not users: + logger.info(f"No Keycloak user found linked with discord id: {discord_id}") + return set() + user_uuid = users[0].get("id") + + roles_set = set() + + # Step 2: Fetch Realm composite roles (includes inherited roles) + try: + realm_roles = await admin.a_get_composite_realm_roles_of_user(user_id=user_uuid) + for role in realm_roles: + name = role.get("name") + if name: + roles_set.add(name) + except Exception as e: + logger.error(f"Exception fetching realm composite roles via python-keycloak: {e}") + + # Step 3: Fetch Client composite roles (includes inherited roles) + client_uuid = await self._get_client_uuid() + if client_uuid: + try: + client_roles = await admin.a_get_composite_client_roles_of_user( + user_id=user_uuid, + client_id=client_uuid + ) + for role in client_roles: + name = role.get("name") + if name: + roles_set.add(name) + except Exception as e: + logger.error(f"Exception fetching client composite roles via python-keycloak: {e}") + + return roles_set + + except Exception as e: + logger.error(f"Exception in python-keycloak operations: {e}") + return None diff --git a/requirements.txt b/requirements.txt index bcde021..1336edf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ discord-py-interactions -docker +python-keycloak diff --git a/watchman.py b/watchman.py index 797f84b..053745d 100644 --- a/watchman.py +++ b/watchman.py @@ -1,14 +1,16 @@ import interactions from config_loader import Config +from interactions.client.errors import LoginError +import asyncio splash = """ __ __ _ _ - \ \ / / | | | | - \ \ /\ / /_ _| |_ ___| |__ _ __ ___ __ _ _ __ - \ \/ \/ / _` | __/ __| '_ \| '_ ` _ \ / _` | '_ \ - \ /\ / (_| | || (__| | | | | | | | | (_| | | | | - \/ \/ \__,_|\__\___|_| |_|_| |_| |_|\__,_|_| |_| - A minimal bot manager for BuildTheEarth™ + \\ \\ / / | | | | + \\ \\ /\\ / /_ _| |_ ___| |__ _ __ ___ __ _ _ __ + \\ \\/ \\/ / _` | __/ __| '_ \\| '_ ` _ \\ / _` | '_ \\ + \\ /\\ / (_| | || (__| | | | | | | | | (_| | | | | + \\/ \\/ \\__,_|\\__\\___|_| |_|_| |_| |_|\\__,_|_| |_| + A minimal container manager for BuildTheEarth™ """ @@ -18,4 +20,8 @@ print(splash) print("Starting Watchman") -bot.start() +try: + bot.start() +except LoginError: + print("Invalid Discord bot token in config.json") + asyncio.run(bot.http.close())