From b6b67576c1420349d136c771af7dd2cede70c9bc Mon Sep 17 00:00:00 2001 From: J4YC33 Date: Sat, 19 Jul 2025 17:33:10 -0600 Subject: [PATCH] Initial Commit --- .kdev4/bbs.kdev4 | 2 + README.md | 2 +- auth.py | 68 +++++++++++++++++++++++++++++++++ bbs.kdev4 | 3 ++ bbs.py | 83 ++++++++++++++++++++++++++++++++++++++++ comms.py | 88 +++++++++++++++++++++++++++++++++++++++++++ config.ini | 27 +++++++++++++ docs/Configuration.md | 44 ++++++++++++++++++++++ docs/Roadmap.md | 37 ++++++++++++++++++ docs/user.schema.json | 35 +++++++++++++++++ functions.py | 57 ++++++++++++++++++++++++++++ menus.py | 51 +++++++++++++++++++++++++ pages/menu0.asc | 7 ++++ pages/splash.asc | 7 ++++ test.py | 10 +++++ users.json | 18 +++++++++ 16 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 .kdev4/bbs.kdev4 create mode 100644 auth.py create mode 100644 bbs.kdev4 create mode 100644 bbs.py create mode 100644 comms.py create mode 100644 config.ini create mode 100644 docs/Configuration.md create mode 100644 docs/Roadmap.md create mode 100644 docs/user.schema.json create mode 100644 functions.py create mode 100644 menus.py create mode 100644 pages/menu0.asc create mode 100644 pages/splash.asc create mode 100644 test.py create mode 100644 users.json diff --git a/.kdev4/bbs.kdev4 b/.kdev4/bbs.kdev4 new file mode 100644 index 0000000..2ffd5cd --- /dev/null +++ b/.kdev4/bbs.kdev4 @@ -0,0 +1,2 @@ +[Project] +VersionControlSupport=kdevgit diff --git a/README.md b/README.md index def5747..9370ac5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # Pyscii-BBS -A modern implementation of an ASCII bbs in Python with secure data storage options. \ No newline at end of file +An actual ascii-based BBS diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..e3cbf34 --- /dev/null +++ b/auth.py @@ -0,0 +1,68 @@ +''' +auth.py - an authentication library for adding users and checking them against a flat-file database + +part of Py-BBS. +''' +import sys, json, configparser, comms, hashlib, random, string, menus + + +config = configparser.ConfigParser() +config.read(sys.argv[1]) + +dbFile = config.get("Auth","userDb") + +def login(conn, username, password, node): + with open(dbFile) as userdb: + users = json.loads(userdb.read()) + for user in users['users']: + if user['name'] == username.replace('\x00',''): + if user['id'] == 0: + comms.sendString(conn, "\r\nThis account is not usable. This event has been logged.\r\n") + conn.close() + return + password = hashlib.sha256(''.join(password.replace('\x00','')).join(user['salt']).encode('utf-8')).hexdigest() + if user['password'] == password: + menus.printMenu(conn, 0, node, user['id']) + else: + comms.sendString(conn, "\r\nInvalid Username or Password!\r\n") + +def setProfile(conn): + comms.sendString(conn,"\r\nDo you want to set a Description?(Y/N) ") + if comms.getString(conn,2).replace("\x00",'')== "Y": + comms.sendString(conn,"\r\nWhat do you want to set as your Description?(max 150 char) ") + description = comms.getString(conn, 150) + else: + description = "You Exist!" + return description + +def setPassword(conn, salt): + comms.sendString(conn, "\r\nWhat do you want your password to be?(max 32 char) ") + comms.sendString(conn, "\r\nThis will be stored salted and hashed") + password = comms.getString(conn, 32).replace('\x00','') + password = hashlib.sha256(''.join(password).join(salt).encode('utf-8')).hexdigest() + return password + +def create(conn): + salt = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) + comms.sendString(conn, "\r\nWelcome to SB17's BBS!\r\nPowered by Pyscii-BBS!") + comms.sendString(conn, "\r\nWhat is your name? (max 16 char) ") + name = comms.getString(conn, 16) + comms.sendString(conn, "\r\nYou want your name to be " + name + "?(Y/N) ") + if comms.getChar(conn) == "N": + comms.sendString(conn, "\r\nRestarting... Press Enter to continue...") + comms.getChar(conn) + password = setPassword(conn, salt) + description = setProfile(conn) + with open(dbFile) as userdb: + users = json.load(userdb) + user = {} + user["id"] = users["users"][-1]["id"] + 1 + user["name"] = name.replace('\x00','').replace('\u0000','') + user["password"] = password + user["salt"] = salt + user["description"] = description + users["users"].append(user) + with open(dbFile,'w') as output: + json.dump(users, output, indent=4) + + comms.sendString(conn,"\r\nYour profile has been created" + name + "! Reconnect to login!") diff --git a/bbs.kdev4 b/bbs.kdev4 new file mode 100644 index 0000000..ca93399 --- /dev/null +++ b/bbs.kdev4 @@ -0,0 +1,3 @@ +[Project] +Manager=KDevGenericManager +Name=bbs diff --git a/bbs.py b/bbs.py new file mode 100644 index 0000000..c5a8d63 --- /dev/null +++ b/bbs.py @@ -0,0 +1,83 @@ +''' +BBS.py + +A BBS that was shamelessly based entirely on the blog post found: https://www.fsxnet.nz/tutorials/bbs/python-bbs/start +''' +import configparser +import sys +import socket +import threading + + +from comms import comms + +nodes = [] + +class bbsServer(): + def __init__(self, port, host='0.0.0.0', nodemax=1): + self.port = port + self.host = host + self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # try: + config = configparser.ConfigParser() + config.read("config.ini") + self.nodemax = config.getint("Main","MaxConnections") + # except: + # self.nodemax = nodemax + + for index in range(nodemax): + nodes.append(False) + + try: + self.server.bind((self.host, self.port)) + except socket.error: + print("Couldn't bind %s" % (socket.error)) + sys.exit() + + self.server.listen(10) + + def run_thread(self, conn, addr, node): + global nodes + + try: + comms(conn, addr, node + 1) + + + except RuntimeError: + print("Node %s hung up..." % (node + 1)) + + print("Node %s offline." % (node + 1)) + nodes[node] = False + conn.close() + sys.exit() + + def run(self): + print("Starting BBS on port %s" % (self.port)) + + while True: + conn, addr = self.server.accept() + + for index in range(self.nodemax): + if nodes[index] == False: + + nodes[index] = True + threading.Thread(target=self.run_thread, args=(conn, addr, index)).start() + break + else: + conn.sendall("BUSY\r\n") + conn.close() + +if __name__ == "__main__": + + if len(sys.argv) < 2: + + print("Usage python bbs.py config.ini") + exit(1) + + config = configparser.ConfigParser() + config.read(sys.argv[1]) + + server = bbsServer(config.getint("Main", "Port")) + + server.run() + diff --git a/comms.py b/comms.py new file mode 100644 index 0000000..bf43838 --- /dev/null +++ b/comms.py @@ -0,0 +1,88 @@ +import auth, sys +import configparser + +config = configparser.ConfigParser() +config.read(sys.argv[1]) + +def sendString(conn, text): + conn.sendall(text.encode('ascii')) + +def getString(conn, max): + + result = "" + index = 0 + + while index < max: + c = getChar(conn) + if (c[0] == '\b' or c[0] == 127) and index > 0: + result = result[:-1] + index -= 1 + sendString(conn, "\x1b[D \x1b[D") + continue + elif c[0] == '\b' or c[0] == 127: + continue + if c[0] == '\r': + return result + + result += c[0] + sendString(conn, c) + index += 1 + return result + +def getCharRaw(conn): + c = conn.recv(1) + if c == b'': + print("Connection dropped") + raise RuntimeError("Socket connection dropped") + + return c + +def getChar(conn): + + while True: + c = getCharRaw(conn) + while c[0] == 255: + c = getCharRaw(conn) + if c[0] == 251 or c[0] == 252 or c[0] == 253 or c[0] == 254: + c = getCharRaw(conn) + elif c[0] == 250: + c = getCharRaw(conn) + while c[0] != 240: + c = getCharRaw(conn) + + c = getCharRaw(conn) + + if c[0] != '\n': + break + + return str(c, 'ascii') + +def comms(conn, addr, node): + """ + comms - Initializes the communication with the user, authorizes them, and passes them to the menus + """ + iac_echo = bytes([255, 251, 1]) + iac_sga = bytes([255, 251, 3]) + + conn.sendall(iac_echo) + conn.sendall(iac_sga) + # Splash Screen + try: + with open("pages/splash.asc") as splash: + for line in splash.readlines(): + line = line.replace("%node%", str(node)) + sendString(conn, line + "\r") + getString(conn, 1) + except: + sendString(conn, config.get("Main","DefaultSplash").replace("%node%", str(node))) + + # Login Screen + sendString(conn, "\r\nEnter your username, or type NEW if a new user.") + sendString(conn, "\r\nLogin: ") + username = getString(conn, 16) + if username.replace('\x00','') == "NEW": + auth.create(conn) + else: + sendString(conn, "\r\nPassword: ") + password = getString(conn, 16) + auth.login(conn,username,password, node) diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..08b7420 --- /dev/null +++ b/config.ini @@ -0,0 +1,27 @@ +[Main] +Port = 2043 +DefaultSplash = "Welcome to the SB17 BBS on %node%!" +MaxConnections = 5 +Menus = 2 + +[Auth] +userDb = users.json + +[Menu0] +Title = "Main Menu" +Page = menus/main.asc +Groups = 0 +options = 3 +Option0 = Profile +Option0Key = P +Option1 = Edit Profile +Option1Key = E +Option2 = Logout +Option2Key = X + +[Menu1] +Title = "Admin Menu" +Groups = 99 +options = 1 +Option0 = Back +Option0Key = B diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 0000000..0cd666d --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,44 @@ +# Configuration + +This file contains the information necessary to configure Pyscii-BBS. The default configuration provided will run, but you will likely want to change many of these values. + +## String Replacements +Except where noted, these can be used in either the asc page files, or the config strings. + +* %node% + - Replaced with the node number of the connected party. + +### Messages +* %subject% + - The message's subject +* %content% + - The message's content +* %date% + - The date the message was posted + +## Config.ini Key-Value Pairs + +* [Main] - Main Configuration Information. + - Port = The integer value of the port to bind Pyscii BBS to + - DefaultSplash = The string that will greet users if no splash.asc page exists in the "pages" location + +* [Auth] - Configuration information related to Authentication and Authorization + - jsonFile = The string of the json file that holds the user database. This database is expected to follow the user.schema + +* [Menu#] - Configuration for a menu. Starts with 0 + - Title = The title of the menu, a string. + - Groups = A list of the groups that can see this menu + - options = an integer number of the count of options + - Option# = The option description to display to the user + - Option#Key = The key to press to use the menu option + +## Pages +The pages for the BBS are stored in the "pages" directory. + +* splash.asc + - Replaces the "DefaultSplash" Config Key/Value pair. + - Is shown to the users before login + +* menu#.asc + - The default display screen for a given menu. + - If this does not exist, the menu will be displayed from the configuration in the config.ini file diff --git a/docs/Roadmap.md b/docs/Roadmap.md new file mode 100644 index 0000000..3091d12 --- /dev/null +++ b/docs/Roadmap.md @@ -0,0 +1,37 @@ +# Pyscii-BBS Roadmap + +This file is the guiding roadmap for the Pyscii-BBS project + +## Project Mission +Pyscii-BBS is supposed to be a telnet-capable data-secure BBS solution. It should be extensible, well-documented, and easy to implement. + +## Project Stages +Right now the BBS is a barely functional bbs server. It requests Username and Password from the connecting system, and potentially returns an error or the user's description. + +The plan is to write the project in the following stages: + +Remember these rules for working on the following features: +* Everything must be extensible. There should be configuration options either in the form of asc page files or configuration key/values +* Data must be stored in a secure way. Just because the protocol is insecure, doesn't mean we should be. +* Anywhere data is stored, it must be in a file with a specific and valid JSON schema +* Document everything. Every configuration key/value, every asc page file purpose, every JSON schema. + +### Pre-Alpha +#### Complete +- BBS Server accepts multiple connections up to a configurable max ✓ +- BBS Server sends data to the client and gracefully terminates the connections ✓ +- BBS Server accepts responses from the client ✓ +- BBS Server can create users with passwords and profile information ✓ +- BBS Server can authenticate users ✓ +- BBS Server can show a menu to the user after logging in ✓ +- BBS Server menu allows user to modify their own profile ✓ +- BBS Server can display pre-coded messages in a messages space ✓ + +#### ToDo +- BBS Server can accept messages from users +- BBS Server can display user submitted messages to message boards +- BBS Server can show only certain users a different menu option (RBAC implementation) + +At this point we have the bare minimum of a functioning BBS. The user flow at this stage is they connect, log in, and are presented with a menu that will let them edit their profile or visit a message board. The message board will be a single flow of messages and the user will have to select what messages to read. No unread/read list exists. It is a simple text-based telnet message board with an auth system. + +### Alpha diff --git a/docs/user.schema.json b/docs/user.schema.json new file mode 100644 index 0000000..bebbd81 --- /dev/null +++ b/docs/user.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bbs.sb17.space/user.schema.json", + "title": "User", + "description": "A Pyscii-BBS user object", + "type": "object", + "properties": { + "id" : { + "description": "The unique ID of the user entity", + "type": "integer" + }, + "name": { + "description": "The unique username of the user entity", + "type": "string" + }, + "description": { + "description": "Description of the user Entity. Like a Profile String", + "type": "string" + }, + "password": { + "description": "The user entity's password hash.", + "type": "string", + }, + "salt":{ + "description": "The user entity's salt. This will be generated by the application.", + "type": "string" + } + }, + "required": [ + "id", + "name", + "password", + "salt" + ] +} diff --git a/functions.py b/functions.py new file mode 100644 index 0000000..5e89b98 --- /dev/null +++ b/functions.py @@ -0,0 +1,57 @@ +import configparser +import json +import sys +import comms +import menus +import auth + +config = configparser.ConfigParser() +config.read(sys.argv[1]) + +userDb = config.get("Auth","userDb") + +def printProfile(conn, userId, node, lastMenu): + comms.sendString(conn, "\u001B[2JProfile:\r\n") + with open(userDb) as userdb: + users = json.loads(userdb.read()) + for user in users['users']: + if user['id'] == userId: + name = "Name: " + user["name"] + description = "Description: " + user['description'] + profile = name + "\r\n" + description + "\r\n" + comms.sendString(conn, profile) + comms.sendString(conn, "Press Enter to go back to the Menu... ") + comms.getChar(conn) + menus.printMenu(conn, lastMenu, node, userId) + +def editProfile(conn, userID, node, lastMenu): + comms.sendString(conn, "\u001B[2JProfile:\r\n") + with open(userDb) as userdb: + users = json.loads(userdb.read()) + for user in users['users']: + if user['id'] == userID: + name = "Name: " + user["name"] + description = "Description: " + user['description'] + profile = name + "\r\n" + description + "\r\n" + comms.sendString(conn, profile) + comms.sendString(conn, "What would you like to edit?\r\n") + comms.sendString(conn, "D: Description\tP:Password\r\n? ") + response = comms.getString(conn,2) + if response.strip('\x00') == "D": + description = auth.setProfile(conn) + user['description'] = description + with open(userDb,'w') as output: + json.dump(users, output, indent=4) + if response.strip('\x00') == "P": + password = auth.setPassword(conn, user['salt']) + user['password'] = password + with open(userDb,'w') as output: + json.dump(users, output, indent=4) + menus.printMenu(conn, lastMenu, node, userID) + +def pubMessages(conn, userID, node, lastMenu): + comms.sendString(conn, "There are no messages!\r\nPress any key to go back... ") + comms.getChar(conn) + menus.printMenu(conn, lastMenu, node, userID) + + diff --git a/menus.py b/menus.py new file mode 100644 index 0000000..4072418 --- /dev/null +++ b/menus.py @@ -0,0 +1,51 @@ +import configparser, sys, comms, functions + +config = configparser.ConfigParser() +config.read(sys.argv[1]) + +menuCount = config.getint("Main","Menus") + +def printMenu(conn, menu, node, user): + comms.sendString(conn, "\u001B[2J\r") + try: + with open("pages/menu"+str(menu)+".asc") as menuAscii: + for line in menuAscii.readlines(): + line = line.replace("%node%", str(node)) + comms.sendString(conn, line + "\r") + response = comms.getString(conn, 2) + except: + dispMenu = "Menu"+str(menu) + options = [] + for i in range(0, config.getint(dispMenu, "Options")): + option = (config.get(dispMenu, "Option"+str(i)), config.get(dispMenu, "Option"+str(i)+"Key")) + options.append(option) + comms.sendString(conn,"Menu: \r\n") + for option in options: + comms.sendString(conn, option[0]+" : "+option[1]+"\r\n") + comms.sendString(conn, "What would you like to do? ") + response = comms.getString(conn, 2) + parseResponse(conn, menu, node, user, response) + + +def parseResponse(conn, menu, node, user, response): + # Add Menu functions and results to this function + # Menu 0 Functions + if menu == 0: + comms.sendString(conn, "\u001B[2J\r") + if response.strip('\x00') == "X": + comms.sendString(conn, "Goodbye!") + + if response.strip('\x00') == "P": + functions.printProfile(conn, user, node, menu) + + if response.strip('\x00') == "E": + functions.editProfile(conn, user, node, menu) + + if response.strip('\x00') == "M": + functions.pubMessages(conn, user, node, menu) + + # Menu 1 Functions + if menu == 1: + if response.strip('\x00') == "B": + printMenu(conn, 0, node, user) + diff --git a/pages/menu0.asc b/pages/menu0.asc new file mode 100644 index 0000000..e8f6e90 --- /dev/null +++ b/pages/menu0.asc @@ -0,0 +1,7 @@ +======================================*======================================== +| MAIN MENU | +| P: Profile M: Messages | +| E: Edit Profile X: Logout | +| | +=============================================================================== +What Do you want to do? diff --git a/pages/splash.asc b/pages/splash.asc new file mode 100644 index 0000000..5793a07 --- /dev/null +++ b/pages/splash.asc @@ -0,0 +1,7 @@ + ____ ____ _ _____ ____ ____ ____ + / ___|| __ )/ |___ | | __ )| __ ) ___| + \___ \| _ \| | / / | _ \| _ \___ \ + ___) | |_) | | / / | |_) | |_) |_) | + |____/|____/|_|/_/ |____/|____/____/ + You are connected on node: %node% + Press Enter to Continue... diff --git a/test.py b/test.py new file mode 100644 index 0000000..db941ae --- /dev/null +++ b/test.py @@ -0,0 +1,10 @@ +import json + +jstring = '{"users":[{"name": "J4YC33","description": "J4YC33\'s Just this person..."}]}' + +with open('users.json') as userdb: + users = json.loads(userdb.read()) + for user in users['users']: + if user['name'] == "J4YC33": + print(user['description']) + diff --git a/users.json b/users.json new file mode 100644 index 0000000..bf8af96 --- /dev/null +++ b/users.json @@ -0,0 +1,18 @@ +{ + "users": [ + { + "id": 0, + "name": "Default", + "password": "Unusable", + "description": "This account is not to be used", + "salt": "This account cannot be accessed" + }, + { + "id": 1, + "name": "J4YC33", + "password": "d32190c6322e27087c934b7bd9ff642bbe71d5782f2242028c3c15298c7aeac6", + "salt": "SI13YMJl8kWLBhNN", + "description": "J4YC33 IS SPARTACUS!" + } + ] +}