wordguess/pubnix.py

312 lines
8.5 KiB
Python
Raw Normal View History

2024-01-05 22:30:58 -05:00
"""
2024-01-05 23:42:44 -05:00
Pubnix Server/Client Library
2024-01-05 22:30:58 -05:00
Author: Brandon Rozek
2024-01-05 23:42:44 -05:00
The pubnix library contains server
and client code needed to communicate
over unix domain sockets on a single
machine.
For authentication, we rely on challenge
tokens and the unix permission system as
both server and client run on the same
machine.
"""
from contextlib import contextmanager
2024-01-07 21:24:51 -05:00
from dataclasses import dataclass, asdict
2024-01-05 23:42:44 -05:00
from pathlib import Path
2024-01-07 11:20:19 -05:00
from threading import Thread
2024-01-05 23:42:44 -05:00
from typing import Union
import binascii
2024-01-05 22:30:58 -05:00
import json
import os
2024-01-05 23:42:44 -05:00
import pwd
2024-01-05 22:30:58 -05:00
import sys
import socket
2024-01-07 11:20:19 -05:00
__all__ = ['run_simple_server', 'run_simple_client']
2024-01-05 22:30:58 -05:00
MESSAGE_BUFFER_LEN = 1024
TOKEN_LENGTH = 50
TIMEOUT = 5 * 60 # 5 minutes
2024-01-07 18:57:03 -05:00
SERVER_FOLDER = Path(__file__).parent.absolute()
2024-01-05 23:42:44 -05:00
###
# Server
###
2024-01-05 22:30:58 -05:00
2024-01-05 23:42:44 -05:00
def run_simple_server(address, fn, force_auth=True):
"""
This function can act as the main entrypoint
for the server. It takes a function that interacts
with a connected user (potentially authenticated)
Example
=======
if __name__ == "__main__":
run_simple_server(
"/home/project/.pubnix.sock",
lambda connection, user: connection.sendall(f"Hello {user}".encode())
)
"""
with start_server(address) as sock:
print("Started server at", address)
try:
while True:
2024-01-07 11:20:19 -05:00
connection, _ = sock.accept()
connection.settimeout(TIMEOUT)
t = Thread(target=thread_connection, args=[connection, force_auth, fn])
t.daemon = True # TODO: Implement graceful cleanup instead
t.start()
2024-01-05 23:42:44 -05:00
except KeyboardInterrupt:
print("Stopping server...")
2024-01-05 22:30:58 -05:00
2024-01-07 11:20:19 -05:00
def thread_connection(connection, force_auth, fn):
try:
user = None
if force_auth:
user = authenticate(connection)
receive_message(connection, StartMessage)
fn(connection, user)
except (
ProtocolException,
BrokenPipeError,
TimeoutError,
ConnectionResetError) as e:
# Ignore as client can reconnect
pass
finally: # clean up the connection
if connection is not None:
connection.close()
2024-01-05 22:30:58 -05:00
@contextmanager
2024-01-06 13:58:48 -05:00
def start_server(address, allow_other=True):
2024-01-05 23:42:44 -05:00
"""
Opens up a unix domain socket at the specified address
and listens for connections.
2024-01-06 13:58:48 -05:00
allow_other: Allow other users on the system to connect
to the unix domain socket
2024-01-05 23:42:44 -05:00
"""
if os.path.exists(address):
print(f"{address} exists -- server already running")
2024-01-05 22:30:58 -05:00
sys.exit(1)
2024-01-05 23:42:44 -05:00
# Create a unix domain socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(address)
sock.listen()
2024-01-05 22:30:58 -05:00
2024-01-06 13:58:48 -05:00
if allow_other:
# 33279 = '-rwxrwxrwx.'
2024-01-07 18:57:03 -05:00
os.chmod(f"{SERVER_FOLDER}/game.sock", 33279)
if os.path.exists(f"{SERVER_FOLDER}/challenges"):
os.chmod(f"{SERVER_FOLDER}/challenges", 33279)
2024-01-06 13:58:48 -05:00
2024-01-05 22:30:58 -05:00
try:
2024-01-05 23:42:44 -05:00
yield sock
2024-01-05 22:30:58 -05:00
finally:
2024-01-05 23:42:44 -05:00
# Delete game.sock when finished
os.unlink(address)
2024-01-05 22:30:58 -05:00
def generate_challenge(user):
2024-01-06 13:58:48 -05:00
Path(f"{SERVER_FOLDER}/challenges").mkdir(mode=33279, exist_ok=True)
2024-01-05 22:30:58 -05:00
return ChallengeMessage(
username=user,
token=generate_token(TOKEN_LENGTH),
2024-01-06 13:58:48 -05:00
location=f"{SERVER_FOLDER}/challenges/.{user}_challenge"
2024-01-05 22:30:58 -05:00
)
def authenticate(connection):
# First message should be an authentication message
message = receive_message(connection, AuthenticateMessage)
user = message.username
# Send challenge message
challenge = generate_challenge(user)
send_message(connection, challenge)
# Second message should be validation message
message = receive_message(connection, ValidationMessage)
# Check that challenge file exists
if not os.path.exists(challenge.location):
2024-01-06 13:58:48 -05:00
close_with_error(connection, f"Authentication Error: Challange file doesn't exist at {challenge.location}")
2024-01-05 22:30:58 -05:00
# Check if user owns the file
if find_owner(challenge.location) != user:
close_with_error(connection, "Challange file not owned by user")
# Make sure we can read the file
if not os.access(challenge.location, os.R_OK):
close_with_error(connection, "Challange file cannot be read by server")
# Check contents of challenge file
with open(challenge.location, "r") as file:
contents = file.read()
if contents != challenge.token:
close_with_error(connection, "Token within challange file is incorrect")
# Send authentication successful message
send_message(connection, AuthSuccessMessage())
return user
2024-01-05 23:42:44 -05:00
def generate_token(length):
# From https://stackoverflow.com/a/41354711
return binascii.hexlify(os.urandom(length // 2)).decode()
def find_owner(path: Union[str, Path]) -> str:
return Path(path).owner()
###
# Client
###
def run_simple_client(address, fn, force_auth=True):
"""
This function can act as the main entrypoint
for the client. It takes a function that interacts
with the server. If force_auth is enabled, then it
first authenticates as the effect user running the
program.
Example
=======
if __name__ == "__main__":
run_simple_client(
"/home/project/.pubnix.sock",
lambda connection, user: connection.sendall(f"Hello server, I'm {user}".encode())
)
"""
with start_client(address) as client:
if force_auth:
2024-01-06 13:58:48 -05:00
user, success = login(client)
if not force_auth or success:
send_message(client, StartMessage())
fn(client, user)
2024-01-05 23:42:44 -05:00
@contextmanager
def start_client(address):
# Create the Unix socket client
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
# Connect to the server
try:
client.connect(address)
except FileNotFoundError:
print("Server is not running at location", address)
sys.exit(1)
try:
yield client
finally:
client.close()
2024-01-05 22:30:58 -05:00
def login(connection):
# Send authentication message
user = pwd.getpwuid(os.geteuid()).pw_name
message = AuthenticateMessage(username=user)
send_message(connection, message)
# Receive challenge message
challenge = receive_message(connection, ChallengeMessage)
# Write to challenge file
with open(challenge.location, "w") as file:
file.write(challenge.token)
# Tell server to check the challenge file
send_message(connection, ValidationMessage())
2024-01-05 23:42:44 -05:00
# On success, delete challenge file
2024-01-06 13:58:48 -05:00
success = True
2024-01-05 22:30:58 -05:00
try:
message = receive_message(connection, AuthSuccessMessage)
2024-01-06 13:58:48 -05:00
except ProtocolException as e:
print(e)
success = False
2024-01-05 22:30:58 -05:00
finally:
os.unlink(challenge.location)
2024-01-06 13:58:48 -05:00
return user, success
2024-01-05 22:30:58 -05:00
2024-01-05 23:42:44 -05:00
##
# Messages
##
2024-01-05 22:30:58 -05:00
2024-01-07 21:24:51 -05:00
class DataclassEncoder(json.JSONEncoder):
2024-01-05 22:30:58 -05:00
def default(self, o):
2024-01-07 21:24:51 -05:00
return asdict(o)
2024-01-05 22:30:58 -05:00
def send_message(connection, message):
2024-01-07 21:24:51 -05:00
contents = json.dumps(message, cls=DataclassEncoder).encode()
2024-01-05 22:30:58 -05:00
connection.sendall(contents)
def receive_message(connection, cls=None):
message = connection.recv(MESSAGE_BUFFER_LEN).decode()
2024-01-07 11:20:19 -05:00
if len(message) == 0:
raise ProtocolException("Sender closed the connection")
2024-01-05 22:30:58 -05:00
try:
message = json.loads(message)
except Exception:
close_with_error(connection, "Invalid Message Received")
if cls is not None:
try:
message = cls(**message)
except (TypeError, AssertionError):
2024-01-06 13:58:48 -05:00
if "type" in message and message['type'] == "error":
raise ProtocolException(message.get("message"))
else:
close_with_error(connection, f"Expected message of type {cls}")
2024-01-05 22:30:58 -05:00
return message
class ProtocolException(Exception):
pass
def close_with_error(connection, content: str):
message = dict(type="error", message=content)
connection.sendall(json.dumps(message).encode())
raise ProtocolException()
@dataclass
class ChallengeMessage:
username: str
token: str
location: str
action: str = "challenge"
def __post_init__(self):
assert self.action == "challenge"
@dataclass
class AuthenticateMessage:
username: str
action: str = "authenticate"
def __post_init__(self):
assert self.action == "authenticate"
assert len(self.username) > 0
@dataclass
class ValidationMessage:
action: str = "validate"
def __post_init__(self):
assert self.action == "validate"
@dataclass
class AuthSuccessMessage:
type: str = "authentication_success"
def __post_init__(self):
assert self.type == "authentication_success"
@dataclass
class StartMessage:
action: str = "start"
def __post_init__(self):
assert self.action == "start"