Skip to content

Commit 129630f

Browse files
committed
add a crappy admin system
1 parent e6a5664 commit 129630f

File tree

8 files changed

+197
-14
lines changed

8 files changed

+197
-14
lines changed

nightwatch/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.8.2"
1+
__version__ = "0.8.3"

nightwatch/client/extra/commands/__init__.py

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
# Copyright (c) 2024 iiPython
22

33
# Modules
4-
from typing import List
5-
from types import FunctionType
4+
from typing import Callable
65

76
from nightwatch import __version__
87
from nightwatch.config import config
98

109
# Main class
1110
class BaseCommand():
12-
def __init__(self, name: str, ui, add_message: FunctionType) -> None:
11+
def __init__(self, name: str, ui, add_message: Callable) -> None:
1312
self.name, self.ui = name, ui
1413
self.add_message = add_message
1514

@@ -21,14 +20,14 @@ class ShrugCommand(BaseCommand):
2120
def __init__(self, *args) -> None:
2221
super().__init__("shrug", *args)
2322

24-
def on_execute(self, args: List[str]) -> str:
25-
return "¯\_(ツ)_/¯"
23+
def on_execute(self, args: list[str]) -> str:
24+
return r"¯\_(ツ)_/¯"
2625

2726
class ConfigCommand(BaseCommand):
2827
def __init__(self, *args) -> None:
2928
super().__init__("config", *args)
3029

31-
def on_execute(self, args: List[str]) -> None:
30+
def on_execute(self, args: list[str]) -> None:
3231
if not args:
3332
for line in [
3433
"Nightwatch client configuration",
@@ -54,7 +53,7 @@ class HelpCommand(BaseCommand):
5453
def __init__(self, *args) -> None:
5554
super().__init__("help", *args)
5655

57-
def on_execute(self, args: List[str]) -> None:
56+
def on_execute(self, args: list[str]) -> None:
5857
self.print(f"✨ Nightwatch v{__version__}")
5958
self.print("Available commands:")
6059
for command in self.ui.commands:
@@ -64,15 +63,94 @@ class MembersCommand(BaseCommand):
6463
def __init__(self, *args) -> None:
6564
super().__init__("members", *args)
6665

67-
def on_execute(self, args: List[str]) -> None:
66+
def on_execute(self, args: list[str]) -> None:
6867
def members_callback(response: dict):
6968
self.print(", ".join(response["data"]["list"]))
7069

7170
self.ui.websocket.callback({"type": "members"}, members_callback)
7271

72+
class AdminCommand(BaseCommand):
73+
def __init__(self, *args) -> None:
74+
self.admin = False
75+
super().__init__("admin", *args)
76+
77+
def on_execute(self, args: list[str]) -> None:
78+
match args:
79+
case [] if not self.admin:
80+
self.ui.websocket.send({"type": "admin"})
81+
self.print("Run /admin <code> with the admin code in your server console.")
82+
83+
case [] | ["help"]:
84+
self.print("Available commands:")
85+
if not self.admin:
86+
self.print(" /admin <admin code>")
87+
88+
self.print(" /admin ban <username>")
89+
self.print(" /admin unban <username>")
90+
self.print(" /admin ip <username>")
91+
self.print(" /admin banlist")
92+
self.print(" /admin say <message>")
93+
94+
case ["ban", username]:
95+
def on_ban_response(response: dict):
96+
if not response["data"]["success"]:
97+
return self.print(f"(fail) {response['data']['error']}")
98+
99+
self.print(f"(success) {username} has been banned.")
100+
101+
self.ui.websocket.callback({"type": "admin", "data": {"command": args}}, on_ban_response)
102+
103+
case ["unban", username]:
104+
def on_unban_response(response: dict):
105+
if not response["data"]["success"]:
106+
return self.print(f"(fail) {response['data']['error']}")
107+
108+
self.print(f"(success) {username} has been unbanned.")
109+
110+
self.ui.websocket.callback({"type": "admin", "data": {"command": args}}, on_unban_response)
111+
112+
case ["ip", username]:
113+
def on_ip_response(response: dict):
114+
if not response["data"]["success"]:
115+
return self.print(f"(fail) {response['data']['error']}")
116+
117+
self.print(f"(success) {username}'s IP address is {response['data']['ip']}.")
118+
119+
self.ui.websocket.callback({"type": "admin", "data": {"command": args}}, on_ip_response)
120+
121+
case ["banlist"]:
122+
def on_banlist_response(response: dict):
123+
if not response["data"]["banlist"]:
124+
return self.print("(fail) Nobody is banned on this server.")
125+
126+
self.print("Current banlist:")
127+
self.print(f"{', '.join(f'{v} ({k})' for k, v in response['data']['banlist'].items())}")
128+
129+
self.ui.websocket.callback({"type": "admin", "data": {"command": args}}, on_banlist_response)
130+
131+
case ["say", _]:
132+
self.ui.websocket.send({"type": "admin", "data": {"command": args}})
133+
134+
case [code]:
135+
if self.admin:
136+
return self.print("(fail) Privileges already escalated.")
137+
138+
def on_admin_response(response: dict):
139+
if response["data"]["success"] is False:
140+
return self.print("(fail) Invalid admin code specified.")
141+
142+
self.print("(success) Privileges escalated.")
143+
self.admin = True
144+
145+
self.ui.websocket.callback({"type": "admin", "data": {"code": code}}, on_admin_response)
146+
147+
case _:
148+
self.print("Admin command not recognized, try /admin help.")
149+
73150
commands = [
74151
ShrugCommand,
75152
ConfigCommand,
76153
HelpCommand,
77-
MembersCommand
154+
MembersCommand,
155+
AdminCommand
78156
]

nightwatch/client/extra/ui.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ def on_message(self, data: dict) -> None:
107107
# Push message to screen
108108
self.add_message(user["name"], data["text"], color_code)
109109

110+
case "error":
111+
exit(f"Nightwatch Exception\n{'=' * 50}\n\n{data['text']}")
112+
110113
def on_ready(self, loop: urwid.MainLoop, payload: dict) -> None:
111114
self.loop = loop
112115
self.construct_message("Nightwatch", f"Welcome to {payload['name']}. There are {payload['online']} user(s) online.")

nightwatch/server/__init__.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
import orjson
55
from pydantic import ValidationError
66
from websockets import WebSocketCommonProtocol
7-
from websockets.exceptions import ConnectionClosedError
7+
from websockets.exceptions import ConnectionClosed
88

99
from .utils.commands import registry
1010
from .utils.websocket import NightwatchClient
11+
from .utils.modules.admin import admin_module
1112

1213
from nightwatch.logging import log
1314

@@ -18,6 +19,7 @@ def __init__(self) -> None:
1819

1920
def add_client(self, client: WebSocketCommonProtocol) -> None:
2021
self.clients[client] = None
22+
setattr(client, "ip", client.request_headers.get("CF-Connecting-IP", client.remote_address[0]))
2123

2224
def remove_client(self, client: WebSocketCommonProtocol) -> None:
2325
if client in self.clients:
@@ -28,11 +30,18 @@ def remove_client(self, client: WebSocketCommonProtocol) -> None:
2830
# Socket entrypoint
2931
async def connection(websocket: WebSocketCommonProtocol) -> None:
3032
client = NightwatchClient(state, websocket)
33+
if websocket.ip in admin_module.banned_users: # type: ignore
34+
return await client.send("error", text = "You have been banned from this server.")
35+
3136
try:
3237
log.info(client.id, "Client connected!")
3338

3439
async for message in websocket:
3540
message = orjson.loads(message)
41+
if not isinstance(message, dict):
42+
await client.send("error", text = "Expected payload is an object.")
43+
continue
44+
3645
if message.get("type") not in registry.commands:
3746
await client.send("error", text = "Specified command type does not exist or is missing.")
3847
continue
@@ -55,7 +64,7 @@ async def connection(websocket: WebSocketCommonProtocol) -> None:
5564
except orjson.JSONDecodeError:
5665
log.warn(client.id, "Failed to decode JSON from client.")
5766

58-
except ConnectionClosedError:
67+
except ConnectionClosed:
5968
log.info(client.id, "Client disconnected!")
6069

6170
state.remove_client(websocket)

nightwatch/server/utils/commands.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# Copyright (c) 2024 iiPython
22

33
# Modules
4+
import random
45
from typing import Callable
56

67
import orjson
78
import websockets
89

910
from . import models
1011
from .websocket import NightwatchClient
12+
from .modules.admin import admin_module
1113

1214
from nightwatch.logging import log
1315
from nightwatch.config import config
@@ -16,6 +18,7 @@
1618
class Constant:
1719
SERVER_USER: dict[str, str] = {"name": "Nightwatch", "color": "gray"}
1820
SERVER_NAME: str = config["server.name"] or "Untitled Server"
21+
ADMIN_CODE: str = str(random.randint(100000, 999999))
1922

2023
# Handle command registration
2124
class CommandRegistry():
@@ -73,3 +76,65 @@ async def command_members(state, client: NightwatchClient) -> None:
7376
@registry.command("ping")
7477
async def command_ping(state, client: NightwatchClient) -> None:
7578
return await client.send("pong")
79+
80+
# New commands (coming back to this branch)
81+
@registry.command("admin")
82+
async def command_admin(state, client: NightwatchClient, data: models.AdminModel) -> None:
83+
if not client.identified:
84+
return await client.send("error", text = "You cannot enter admin mode while anonymous.")
85+
86+
# Handle admin commands
87+
if client.admin:
88+
match data.command:
89+
case ["ban", username]:
90+
for client_object, client_username in state.clients.items():
91+
if client_username == username:
92+
await client_object.send(orjson.dumps({
93+
"type": "message",
94+
"data": {"text": "You have been banned from this server.", "user": Constant.SERVER_USER}
95+
}).decode())
96+
await client_object.close()
97+
admin_module.add_ban(client_object.ip, username)
98+
return await client.send("admin", success = True)
99+
100+
await client.send("admin", success = False, error = "Specified username couldn't be found.")
101+
102+
case ["unban", username]:
103+
for ip, client_username in admin_module.banned_users.items():
104+
if client_username == username:
105+
admin_module.unban(ip)
106+
return await client.send("admin", success = True)
107+
108+
await client.send("admin", success = False, error = "Specified banned user couldn't be found.")
109+
110+
case ["ip", username]:
111+
for client_object, client_username in state.clients.items():
112+
if client_username == username:
113+
return await client.send("admin", success = True, ip = client_object.ip)
114+
115+
await client.send("admin", success = False, error = "Specified username couldn't be found.")
116+
117+
case ["banlist"]:
118+
await client.send("admin", banlist = admin_module.banned_users)
119+
120+
case ["say", message]:
121+
websockets.broadcast(state.clients, orjson.dumps({
122+
"type": "message",
123+
"data": {"text": message, "user": Constant.SERVER_USER}
124+
}).decode())
125+
126+
case _:
127+
await client.send("error", text = "Invalid admin command sent, your client might be outdated.")
128+
129+
return
130+
131+
# Handle becoming admin
132+
if data.code is None:
133+
return log.info("admin", f"Admin code is {Constant.ADMIN_CODE}")
134+
135+
if data.code != Constant.ADMIN_CODE:
136+
return await client.send("admin", success = False)
137+
138+
client.admin = True
139+
log.info("admin", f"{client.user_data['name']} ({client.id}) is now an administrator.")
140+
return await client.send("admin", success = True)

nightwatch/server/utils/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) 2024 iiPython
22

33
# Modules
4-
from typing import Annotated
4+
from typing import Annotated, Optional
55
from pydantic import BaseModel, PlainSerializer, StringConstraints
66
from pydantic_extra_types.color import Color
77

@@ -12,3 +12,7 @@ class IdentifyModel(BaseModel):
1212

1313
class MessageModel(BaseModel):
1414
text: Annotated[str, StringConstraints(min_length = 1, max_length = 300)]
15+
16+
class AdminModel(BaseModel):
17+
code: Optional[str] = None
18+
command: Optional[list[str]] = None
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright (c) 2024 iiPython
2+
3+
# Modules
4+
import json
5+
from nightwatch.config import config_path
6+
7+
# Main module
8+
class AdminModule:
9+
def __init__(self) -> None:
10+
self.banfile = config_path.parent / "bans.json"
11+
self.banned_users = json.loads(self.banfile.read_text()) if self.banfile.is_file() else {}
12+
13+
def save(self) -> None:
14+
self.banfile.write_text(json.dumps(self.banned_users, indent = 4))
15+
16+
def add_ban(self, ip: str, username: str) -> None:
17+
self.banned_users[ip] = username
18+
self.save()
19+
20+
def unban(self, ip: str) -> None:
21+
del self.banned_users[ip]
22+
self.save()
23+
24+
admin_module = AdminModule()

nightwatch/server/utils/websocket.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class NightwatchClient():
1414
data serialization through orjson."""
1515
def __init__(self, state, client: WebSocketCommonProtocol) -> None:
1616
self.client = client
17-
self.identified, self.callback = False, None
17+
self.admin, self.identified, self.callback = False, False, None
1818

1919
self.state = state
2020
self.state.add_client(client)

0 commit comments

Comments
 (0)