From 8ba0398d25e7728e8d38f1b12f7a4f3cc44906b2 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Fri, 27 Feb 2026 04:40:36 -0500 Subject: [PATCH] Add reboot and version slash commands with permission guard --- client/public/help.json | 2 +- client/public/version.js | 2 +- docs/controls.md | 2 + server/app/auth_service.py | 2 + server/app/server.py | 68 ++++++++++++++++ server/tests/test_server_message_handling.py | 86 ++++++++++++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) diff --git a/client/public/help.json b/client/public/help.json index c2e456a..87b84d5 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -42,7 +42,7 @@ }, { "keys": "Slash commands", - "description": "In chat, use /me for action text or /up for server uptime" + "description": "In chat, use /me , /up for uptime, /version for server version, and /reboot [message] if allowed" }, { "keys": "Comma / Period", diff --git a/client/public/version.js b/client/public/version.js index 2df5228..7ee4608 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.27 R293"; +window.CHGRID_WEB_VERSION = "2026.02.27 R294"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/docs/controls.md b/docs/controls.md index 9bdf8de..3372fcb 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -19,6 +19,8 @@ This document is the authoritative keymap for the client. - In chat, commands are supported when `/` is the first character: - `/me `: Send action text without `name:` - `/up`: Show server uptime (self only) + - `/version`: Show server version (self only) + - `/reboot [message]`: Schedule reboot in 5 seconds (admin permission: `server.allow_reboot`) - `Shift+Z`: Admin menu (when role permissions allow) - `,` / `.`: Previous/next message - `<` / `>`: First/last message diff --git a/server/app/auth_service.py b/server/app/auth_service.py index fe554f6..ce0abe0 100644 --- a/server/app/auth_service.py +++ b/server/app/auth_service.py @@ -44,6 +44,7 @@ PERMISSIONS: tuple[str, ...] = ( "user.change_role", "role.manage", "server.manage_settings", + "server.allow_reboot", ) PERMISSION_DESCRIPTIONS: dict[str, str] = { @@ -63,6 +64,7 @@ PERMISSION_DESCRIPTIONS: dict[str, str] = { "user.change_role": "Allow assigning user roles.", "role.manage": "Allow creating, editing, and deleting roles.", "server.manage_settings": "Allow changing server settings.", + "server.allow_reboot": "Allow scheduling a server reboot from chat command.", } DEFAULT_ROLE_PERMISSIONS: dict[str, set[str]] = { diff --git a/server/app/server.py b/server/app/server.py index 69cbb51..829b9c2 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -14,6 +14,7 @@ import logging import os import random import re +import signal import ssl import time import uuid @@ -185,6 +186,7 @@ class SignalingServer: self._clock_top_of_hour_markers: dict[str, str] = {} self._clock_alarm_markers: dict[str, str] = {} self._started_at_monotonic = time.monotonic() + self._pending_reboot_task: asyncio.Task[None] | None = None @staticmethod def _resolve_server_version() -> str: @@ -1218,6 +1220,11 @@ class SignalingServer: ): await asyncio.Future() finally: + if self._pending_reboot_task is not None: + self._pending_reboot_task.cancel() + with suppress(asyncio.CancelledError): + await self._pending_reboot_task + self._pending_reboot_task = None if self._clock_announce_task is not None: self._clock_announce_task.cancel() with suppress(asyncio.CancelledError): @@ -1598,6 +1605,24 @@ class SignalingServer: elapsed_seconds = int(max(0.0, time.monotonic() - self._started_at_monotonic)) return self._format_duration(elapsed_seconds) + async def _run_delayed_reboot(self, requested_by: str, message: str) -> None: + """Wait for reboot delay, then terminate process for supervisor restart.""" + + try: + await asyncio.sleep(5) + except asyncio.CancelledError: + return + LOGGER.warning("server reboot requested by=%s message=%s", requested_by, message) + os.kill(os.getpid(), signal.SIGTERM) + + def _schedule_reboot(self, requested_by: str, message: str) -> bool: + """Schedule one delayed reboot; return False when one is already pending.""" + + if self._pending_reboot_task is not None and not self._pending_reboot_task.done(): + return False + self._pending_reboot_task = asyncio.create_task(self._run_delayed_reboot(requested_by, message)) + return True + async def _handle_chat_command(self, client: ClientConnection, message: str) -> bool: """Handle slash commands in chat input; return True when handled.""" @@ -1638,6 +1663,49 @@ class SignalingServer: ), ) return True + if command == "version": + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message=f"Server version: {self.server_version}", + system=True, + ), + ) + return True + if command == "reboot": + if not self._client_has_permission(client, "server.allow_reboot"): + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message="Not authorized to reboot server.", + system=True, + ), + ) + return True + reboot_message = remainder if separator else "" + if not self._schedule_reboot(client.username or client.nickname, reboot_message): + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message="Server reboot already scheduled.", + system=True, + ), + ) + return True + announcement = "Server rebooting in 5 seconds." + if reboot_message: + announcement = f"{announcement} {reboot_message}" + await self._broadcast( + BroadcastChatMessagePacket( + type="chat_message", + message=announcement, + system=True, + ) + ) + return True await self._send( client.websocket, BroadcastChatMessagePacket( diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index 5c37101..32c9de7 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -497,3 +497,89 @@ async def test_chat_command_requires_leading_slash(monkeypatch: pytest.MonkeyPat assert packet.system is False assert packet.action is False assert packet.message == " /up" + + +@pytest.mark.asyncio +async def test_chat_version_command_is_sender_only(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="Tester") + server.clients[ws] = client + server.server_version = "2026.02.27 R293" + + broadcast_payloads: list[object] = [] + send_payloads: list[object] = [] + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_payloads.append(packet) + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + monkeypatch.setattr(server, "_send", fake_send) + + await server._handle_message(client, json.dumps({"type": "chat_message", "message": "/version"})) + + assert broadcast_payloads == [] + assert len(send_payloads) == 1 + packet = send_payloads[0] + assert getattr(packet, "type", "") == "chat_message" + assert packet.system is True + assert packet.message == "Server version: 2026.02.27 R293" + + +@pytest.mark.asyncio +async def test_chat_reboot_requires_permission(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="Tester", authenticated=True, user_id="1", permissions={"chat.send"}) + server.clients[ws] = client + + send_payloads: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_schedule_reboot", lambda _requested_by, _message: True) + + await server._handle_message(client, json.dumps({"type": "chat_message", "message": "/reboot patching"})) + + assert send_payloads + packet = send_payloads[-1] + assert getattr(packet, "type", "") == "chat_message" + assert packet.system is True + assert "not authorized" in packet.message.lower() + + +@pytest.mark.asyncio +async def test_chat_reboot_schedules_and_broadcasts_message(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection( + websocket=ws, + id="u1", + nickname="Tester", + authenticated=True, + user_id="1", + username="tester", + permissions={"chat.send", "server.allow_reboot"}, + ) + server.clients[ws] = client + + broadcast_payloads: list[object] = [] + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_payloads.append(packet) + + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + monkeypatch.setattr(server, "_schedule_reboot", lambda requested_by, message: requested_by == "tester" and message == "maintenance") + + await server._handle_message(client, json.dumps({"type": "chat_message", "message": "/reboot maintenance"})) + + assert len(broadcast_payloads) == 1 + packet = broadcast_payloads[0] + assert getattr(packet, "type", "") == "chat_message" + assert packet.system is True + assert packet.message == "Server rebooting in 5 seconds. maintenance"