Compare commits

...

10 Commits

31 changed files with 771 additions and 145 deletions

View File

@ -8,10 +8,6 @@ RESPONSE_TIMEOUT=5
# within this timeframe
CLIENT_IDLE_TIMEOUT=60
# A valid port number
# If not provided or invalid, defaults to 65526
PORT=65526
# DEBUG, INFO, WARNING, ERROR, CRITICAL are valid
# If an invalid value is provided, the app defaults to INFO
VERBOSITY=DEBUG
@ -20,3 +16,6 @@ VERBOSITY=DEBUG
# Only two port numbers must be provided
# Anything else results in an exit
SCAN_PORT_RANGE=65525:65535
# Manually set the IP address
# IP=127.0.0.1

1
.gitignore vendored
View File

@ -168,3 +168,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
bank.db

103
README.md
View File

@ -1,14 +1,113 @@
# ksi
# nu (ν)
# Running
## Configuration
The application can be configured using a `.env` file. Here are the available options:
`RESPONSE_TIMEOUT`
- Specifies the response timeout in seconds.
- Default: `5`
- Example: `RESPONSE_TIMEOUT=5`
`CLIENT_IDLE_TIMEOUT`
- Defines the client idle timeout in seconds.
- Default: `60`
- Example: `CLIENT_IDLE_TIMEOUT=60`
`VERBOSITY`
- Determines the logging verbosity level.
- Valid values: DEBUG, INFO, WARNING, ERROR, CRITICAL.
- Default: `INFO`
- Example: `VERBOSITY=DEBUG`
`SCAN_PORT_RANGE`
- Defines the port range to scan as `PORT:PORT` separated by colon (`:`).
- The ports must be valid and there must be exactly two of them.
- Example: `SCAN_PORT_RANGE=65525:65535`
`IP`
- Manually set the IP address.
- Example: `IP=127.0.0.1`
## Running
The application requires a few dependenices, specified in `requirements.txt`.
To install these dependencies run
```bash
pip install -r requirements.txt
```
Upon successful installation, the program is ready to run. The database used is SQLite, hence no SQL structure is needed to be imported, nor are any credentials required.
To run the app, run
```bash
python src/app.py
```
If the verbosity set in `.env` is `DEBUG`, the program will print many useful descriptions of it's actions together with a detailed location of the action including file, line and process name.
## Definitions
The program uses the following protocol over TCP
| Operation | Request Format | Response Format |
| ---------------------- | -------------------------------- | --------------- |
| Bank code | BC | BC \<ip\> |
| Account create | AC \<account\>/\<ip\> | AC |
| Account deposit | AD \<account\>/\<ip\> \<number\> | AD |
| Account withdrawal | AW \<account\>/\<ip\> \<number\> | AW |
| Account balance | AB \<account\>/\<ip\> | AB \<number\> |
| Account remove | AR \<account\>/\<ip\> | AR |
| Bank (total) amount | BA | BA \<number\> |
| Bank number of clients | BN | BN \<number\> |
Any of these commands may return an error message in format
`ER <message>`
## Sources
### Signal catching
- [Catch SIGTERM](https://dnmtechs.com/graceful-sigterm-signal-handling-in-python-3-best-practices-and-implementation/)
- [Get ENUM name from value](https://stackoverflow.com/a/38716384)
- [Windows termination signals](https://stackoverflow.com/a/35792192)
- ~~[Get ENUM name from value](https://stackoverflow.com/a/38716384)~~
Unused because of required compatibility with lower version python (3.9)
### Networking
- [Dynamically finding host IP address](https://stackoverflow.com/a/28950776)
- [IP Regex](https://ihateregex.io/expr/ip/)
### Database
- [SqlAlchemy session generator](https://stackoverflow.com/a/71053353)
- [Error handling decorator](https://chatgpt.com/share/67a46109-d38c-8005-ac36-677e6511ddcd)
### Platform specific problems
- [Windows sends partial data](https://stackoverflow.com/a/31754798)
### Threading
- [Events to stop scan](https://www.instructables.com/Starting-and-Stopping-Python-Threads-With-Events-i/)

46
poetry.lock generated
View File

@ -135,6 +135,7 @@ files = [
[package.dependencies]
mypy_extensions = ">=1.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing_extensions = ">=4.6.0"
[package.extras]
@ -265,6 +266,47 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"]
[[package]]
name = "tomli"
version = "2.2.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
files = [
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
{file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
{file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
{file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
{file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
{file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
{file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
{file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
{file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
{file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
{file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
{file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
{file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
@ -278,5 +320,5 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "c924c9033c266a86318eb310c7ef433b346d0ad4ff1574e44f7c8c664653106f"
python-versions = "^3.8"
content-hash = "822be0591477b901b0d6f8a34048f570227733ebf8eda1cae6a079ab2f5a1415"

View File

@ -6,7 +6,7 @@ authors = ["Thastertyn <thastertyn@thastertyn.xyz>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.6"
python = "^3.8"
sqlalchemy = "^2.0.37"
python-dotenv = "^1.0.1"

View File

@ -1,4 +1,4 @@
greenlet==3.1.1 ; python_version < "3.14" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and python_version >= "3.12"
python-dotenv==1.0.1 ; python_version >= "3.12" and python_version < "4.0"
sqlalchemy==2.0.37 ; python_version >= "3.12" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.12" and python_version < "4.0"
greenlet==3.1.1
python-dotenv==1.0.1
sqlalchemy==2.0.37
typing-extensions==4.12.2

View File

@ -2,6 +2,7 @@ import socket
import signal
import sys
import logging
import errno
from core.config import BankNodeConfig
from core.exceptions import ConfigError
@ -46,28 +47,37 @@ class BankNode():
# Handle windows related signals
if sys.platform == "win32":
signal.signal(signal.CTRL_C_EVENT, self.gracefully_exit)
signal.signal(signal.CTRL_BREAK_EVENT, self.gracefully_exit)
signal.signal(signal.SIGBREAK, self.gracefully_exit)
def start(self):
for port in range(self.config.scan_port_start, self.config.scan_port_end + 1):
self.logger.debug("Trying port %d", port)
try:
self.config.used_port = port
self.__start_server(port)
return
except socket.error as e:
if e.errno == 98: # Address is in use
self.logger.info("Port %d in use, trying next port", port)
try:
for port in range(self.config.scan_port_start, self.config.scan_port_end + 1):
self.logger.debug("Trying port %d", port)
try:
self.config.used_port = port
self.__start_server(port)
return
except socket.error as e:
if e.errno == 98: # Address is in use
self.logger.info("Port %d in use, trying next port", port)
else:
raise
self.logger.error("All ports are in use")
self.exit_with_error()
self.logger.error("All ports are in use")
self.exit_with_error()
except socket.error as e:
if e.errno == 98 or e.errno == errno.WSAEADDRINUSE: # Cannot assign to requested address
self.logger.critical("Cannot use the IP address %s", self.config.ip)
else:
self.logger.critical("Unknown error: %s", e)
self.exit_with_error()
def __start_server(self, port: int):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as socket_server:
socket_server.bind((self.config.ip, port))
socket_server.listen()
socket_server.setblocking(True)
self.socket_server = socket_server
self.logger.info("Listening on %s:%s", self.config.ip, port)
@ -75,7 +85,7 @@ class BankNode():
client_socket, address = socket_server.accept()
self.logger.info("%s connected", address[0])
process = BankWorker(client_socket, address, self.config.to_dict())
process = BankWorker(client_socket, address, self.config)
process.start()
def exit_with_error(self):
@ -95,3 +105,5 @@ class BankNode():
def cleanup(self):
self.logger.debug("Closing socket server")
self.socket_server.close()
self.logger.debug("Closing database connection")
self.database_manager.cleanup()

View File

@ -1,28 +1,28 @@
import socket
import multiprocessing
import logging
from typing import Tuple, Dict
from typing import Tuple
import signal
import sys
import time
from bank_protocol.command_handler import CommandHandler
from core import Request, Response
from core import Request, Response, BankNodeConfig
from core.exceptions import BankNodeError
from utils.logger import setup_logger
from database import DatabaseManager
class BankWorker(multiprocessing.Process):
def __init__(self, client_socket: socket.socket, client_address: Tuple, config: Dict):
def __init__(self, client_socket: socket.socket, client_address: Tuple, config: BankNodeConfig):
super().__init__()
self.logger = logging.getLogger(__name__)
self.client_socket = client_socket
self.client_socket.settimeout(config["client_idle_timeout"])
self.client_address = client_address
self.command_handler = CommandHandler(config)
self.config = config
self.logger = None
self.command_handler = None
def __setup_signals(self):
self.logger.debug("Setting up exit signal hooks for worker")
@ -32,45 +32,91 @@ class BankWorker(multiprocessing.Process):
# Handle windows related signals
if sys.platform == "win32":
signal.signal(signal.CTRL_C_EVENT, self.gracefully_exit_worker)
signal.signal(signal.CTRL_BREAK_EVENT, self.gracefully_exit_worker)
signal.signal(signal.SIGBREAK, self.gracefully_exit_worker)
def run(self):
# Logging behaves weirdly with processes on windows
# and loses its configuration by default
# -> Set it up again in the fresh process
if sys.platform == "win32":
DatabaseManager()
setup_logger(self.config.verbosity)
self.logger = logging.getLogger(__name__)
self.command_handler = CommandHandler(self.config)
self.client_socket.setblocking(True)
self.client_socket.settimeout(self.config.client_idle_timeout)
self.__setup_signals()
with self.client_socket:
while True:
try:
raw_request = self.client_socket.recv(1024).decode("utf-8")
if not raw_request:
self.logger.debug("%s disconnected", self.client_address[0])
break
request = Request(raw_request)
self.logger.debug("Received request from %s - %s", self.client_address[0], request)
response: Response = self.command_handler.execute(request) + "\n\r"
self.client_socket.sendall(response.encode("utf-8"))
except socket.timeout:
self.logger.debug("Client was idle for too long. Ending connection")
response = "ER Idle too long\n\r"
self.client_socket.sendall(response.encode("utf-8"))
self.client_socket.shutdown(socket.SHUT_RDWR)
self.client_socket.close()
break
except BankNodeError as e:
response = "ER " + e.message + "\n\r"
self.client_socket.sendall(response.encode("utf-8"))
except socket.error as e:
response = "ER Internal server error\n\r"
self.logger.error(e)
break
self.serve_client()
self.logger.debug("Closing process for %s", self.client_address[0])
def serve_client(self):
buffer = ""
ending = None
while True:
try:
data = self.client_socket.recv(1024).decode("utf-8")
if not data:
self.logger.debug("%s disconnected", self.client_address[0])
break
buffer += data
self.logger.debug("Buffer updated: %r", buffer)
if "\r\n" in buffer:
ending = "\r\n"
self.logger.debug("CRLF detected")
elif "\n" in buffer:
ending = "\n"
self.logger.debug("LF detected")
elif "\r" in buffer:
ending = "\r"
self.logger.debug("CR detected")
if ending is None:
self.logger.debug("No line ending found. Assuming another part will come")
continue
response = self.process_request(buffer, ending)
self.client_socket.sendall(response.encode("utf-8"))
self.logger.debug("Response sent to %s", self.client_address[0])
buffer = ""
ending = None
except socket.timeout:
self.logger.debug("Client was idle for too long. Ending connection")
response = "ER Idle too long" + (ending or "\n")
self.client_socket.sendall(response.encode("utf-8"))
self.client_socket.shutdown(socket.SHUT_RDWR)
self.client_socket.close()
break
except UnicodeDecodeError:
self.logger.warning("Received a non utf-8 message")
response = "ER Not utf-8 message" + (ending or "\n")
self.client_socket.sendall(response.encode("utf-8"))
break
except BankNodeError as e:
response = "ER " + e.message + (ending or "\n")
self.client_socket.sendall(response.encode("utf-8"))
buffer = ""
except socket.error as e:
self.logger.error(e)
response = "ER Internal server error" + (ending or "\n")
break
def process_request(self, buffer: str, line_ending: str) -> Response:
self.logger.debug("Processing request: %r", buffer)
request = Request(buffer)
response: Response = self.command_handler.execute(request) + line_ending
return response
def gracefully_exit_worker(self, signum, _):
"""Log the signal caught and exit with status 0"""

View File

@ -0,0 +1,54 @@
import socket
import threading
import logging
import errno
from core.peer import BankPeer
class BankScanner(threading.Thread):
def __init__(self, host: str, port: str, result_peer: BankPeer, bank_found_event: threading.Event, lock: threading.Lock, timeout: int):
super().__init__(name="BankScannerThread-{self.host}:{port}")
self.logger = logging.getLogger(__name__)
self.host = host
self.port = port
self.result_peer = result_peer
self.bank_found_event = bank_found_event
self.lock = lock
self.timeout = timeout
def run(self):
if self.bank_found_event.is_set():
return
self.__probe_for_open_ports(self.host, self.port)
def __probe_for_open_ports(self, host: str, port: int):
try:
connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
connection.settimeout(self.timeout)
connection.connect((host, port))
self.__scan_for_bank(connection)
except socket.timeout:
self.logger.debug("Connection for port %d timed out", port)
except socket.error as e:
if e.errno == 111 or e.errno == errno.WSAECONNREFUSED: # Connection refused
self.logger.debug("Port %d not open", port)
else:
self.logger.debug("Unknown error occurred when probing port: %s", e)
def __scan_for_bank(self, connection: socket.socket):
ping_command = "BC\r\n"
connection.sendall(ping_command.encode("utf-8"))
response = connection.recv(1024).decode("utf-8")
if response.strip() == f"BC {self.host}":
self.logger.debug("Bank application found on %s:%s", self.host, self.port)
with self.lock:
if not self.bank_found_event.is_set():
self.result_peer.port = self.port
self.result_peer.bank_socket = connection
self.bank_found_event.set()
else:
self.logger.debug("Port is open, but no bank application found")

View File

@ -3,6 +3,7 @@ from typing import Dict, Callable
from core import Request, Response
from core.exceptions import BankNodeError
from database.exceptions import DatabaseError
from bank_protocol.commands import (account_balance,
account_create,
@ -35,8 +36,14 @@ class CommandHandler:
self.logger.warning("Unknown command %s", request.command_code)
raise BankNodeError(f"Unknown command {request.command_code}")
self.logger.debug("Serving %s", request.command_code)
command = self.registered_commands[request.command_code]
response = command(request, self.config)
return f"{request.command_code} {response}"
try:
response = command(request, self.config)
if response is not None:
return str(response).strip()
else:
return request.command_code
except DatabaseError as e:
return f"ER {e.message}"

View File

@ -1,8 +1,39 @@
from core.request import Request
import re
import logging
from core import Request, BankNodeConfig, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import get_account_balance
from bank_protocol.proxy_handler import BankProxy
def account_balance(request: Request):
pass
def account_balance(request: Request, config: BankNodeConfig) -> Response:
logger = logging.getLogger(__name__)
if request.body is None:
raise InvalidRequest("Invalid request format")
try:
split_body = request.body.split("/")
account = split_body[0]
ip = split_body[1]
except IndexError as e:
raise InvalidRequest("Invalid request format") from e
if ip != config.ip:
bank_proxy = BankProxy(request, ip, config)
return bank_proxy.proxy_request()
if not account.isdigit():
raise InvalidRequest("Account must be a number")
account_parsed = int(account)
if account_parsed < 10_000 or account_parsed > 99_999:
raise InvalidRequest("Account number out of range")
balance = get_account_balance(account_parsed)
return f"AB {str(balance)}"
__all__ = ["account_balance"]

View File

@ -1,7 +1,16 @@
from core.request import Request
from core import Request, Response, BankNodeConfig
from bank_protocol.exceptions import InvalidRequest
from services.account_service import create_account
def account_create(request: Request, config: BankNodeConfig) -> Response:
if request.body is not None:
raise InvalidRequest("Incorrect usage")
account_number = create_account()
return f"AC {account_number}/{config.ip}"
def account_create(request: Request):
pass
__all__ = ["account_create"]

View File

@ -1,7 +1,48 @@
from core.request import Request
import logging
from core import Request, BankNodeConfig, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import deposit_into_account
from utils.constants import MONEY_AMOUNT_MAXIMUM
from bank_protocol.proxy_handler import BankProxy
def account_deposit(request: Request):
pass
def account_deposit(request: Request, config: BankNodeConfig) -> Response:
if request.body is None:
raise InvalidRequest("Invalid request format")
try:
split_body = request.body.split("/")
split_ip = split_body[1].split(" ")
account = split_body[0]
ip = split_ip[0]
amount = split_ip[1]
except IndexError as e:
raise InvalidRequest("Invalid request format") from e
if ip != config.ip:
bank_proxy = BankProxy(request, ip, config)
return bank_proxy.proxy_request()
if not account.isdigit():
raise InvalidRequest("Account must be a number")
account_parsed = int(account)
if account_parsed < 10_000 or account_parsed > 99_999:
raise InvalidRequest("Account number out of range")
if not amount.isdigit():
raise InvalidRequest("Deposit amount must be a number")
amount_parsed = int(amount)
if amount_parsed > MONEY_AMOUNT_MAXIMUM:
raise InvalidRequest("Cannot deposit this much")
deposit_into_account(account_parsed, amount_parsed)
return "AD"
__all__ = ["account_deposit"]

View File

@ -1,7 +1,37 @@
from core.request import Request
from core import Request, BankNodeConfig, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import delete_account
from bank_protocol.proxy_handler import BankProxy
def account_remove(request: Request):
pass
def account_remove(request: Request, config: BankNodeConfig) -> Response:
if request.body is None:
raise InvalidRequest("Invalid request format")
try:
split_body = request.body.split("/")
account = split_body[0]
ip = split_body[1]
except IndexError as e:
raise InvalidRequest("Invalid request format") from e
if ip != config.ip:
bank_proxy = BankProxy(request, ip, config)
return bank_proxy.proxy_request()
if not account.isdigit():
raise InvalidRequest("Account must be a number")
account_parsed = int(account)
if account_parsed < 10_000 or account_parsed > 99_999:
raise InvalidRequest("Account number out of range")
delete_account(account_parsed)
return "AR"
__all__ = ["account_remove"]

View File

@ -1,7 +1,48 @@
from core.request import Request
from core import Request, BankNodeConfig, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import withdraw_from_account
from utils.constants import MONEY_AMOUNT_MAXIMUM
from bank_protocol.proxy_handler import BankProxy
def account_withdrawal(request: Request):
pass
def account_withdrawal(request: Request, config: BankNodeConfig) -> Response:
if request.body is None:
raise InvalidRequest("Invalid request format")
try:
split_body = request.body.split("/")
split_ip = split_body[1].split(" ")
account = split_body[0]
ip = split_ip[0]
amount = split_ip[1]
except IndexError as e:
raise InvalidRequest("Invalid request format") from e
if ip != config.ip:
bank_proxy = BankProxy(request, ip, config)
return bank_proxy.proxy_request()
if not account.isdigit():
raise InvalidRequest("Account must be a number")
account_parsed = int(account)
if account_parsed < 10_000 or account_parsed > 99_999:
raise InvalidRequest("Account number out of range")
if not amount.isdigit():
raise InvalidRequest("Deposit amount must be a number")
amount_parsed = int(amount)
if amount_parsed > MONEY_AMOUNT_MAXIMUM:
raise InvalidRequest("Cannot deposit this much")
withdraw_from_account(account_parsed, amount_parsed)
return "AW"
__all__ = ["account_withdrawal"]

View File

@ -1,12 +1,10 @@
from typing import Dict
from core import Request, Response
from core import Request, Response, BankNodeConfig
from bank_protocol.exceptions import InvalidRequest
def bank_code(request: Request, config: Dict) -> Response:
def bank_code(request: Request, config: BankNodeConfig) -> Response:
if request.body is not None:
raise InvalidRequest("Incorrect usage")
return config["ip"]
return f"BC {config.ip}"
__all__ = ["bank_code"]

View File

@ -1,7 +1,12 @@
from core.request import Request
from core import Request, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import get_account_count
def bank_number_of_clients(request: Request):
pass
def bank_number_of_clients(request: Request, _) -> Response:
if request.body is not None:
raise InvalidRequest("Incorrect usage")
number_of_clients = get_account_count()
return f"BN {number_of_clients}"
__all__ = ["bank_number_of_clients"]

View File

@ -1,7 +1,13 @@
from core.request import Request
from core import Request, Response
from bank_protocol.exceptions import InvalidRequest
from services.account_service import get_total_balance
def bank_total_amount(request: Request):
pass
def bank_total_amount(request: Request, _) -> Response:
if request.body is not None:
raise InvalidRequest("Incorrect usage")
total_balace = get_total_balance()
return f"BA {total_balace}"
__all__ = ["bank_total_amount"]

View File

@ -2,7 +2,31 @@
from core.exceptions import BankNodeError
class ProxyError(BankNodeError):
def __init__(self, message):
super().__init__(message)
self.message = message
class InvalidRequest(BankNodeError):
def __init__(self, message):
super().__init__(message)
self.message = message
class RequestTimeoutError(BankNodeError):
def __init__(self, message):
super().__init__(message)
self.message = message
class HostUnreachableError(BankNodeError):
def __init__(self, message):
super().__init__(message)
self.message = message
class NoPortsOpenError(BankNodeError):
def __init__(self, message):
super().__init__(message)
self.message = message

View File

@ -0,0 +1,45 @@
import logging
import threading
from core import Request, Response, BankNodeConfig
from core.peer import BankPeer
from bank_protocol.exceptions import NoPortsOpenError
from bank_protocol.bank_scanner import BankScanner
class BankProxy():
def __init__(self, request: Request, host: str, config: BankNodeConfig):
self.request = request
self.host = host
self.config = config
self.peer = BankPeer(self.host, None, None)
self.bank_found_event = threading.Event()
self.lock = threading.Lock()
self.logger = logging.getLogger(__name__)
self.logger.info("Proxying request")
def proxy_request(self) -> Response:
scanner_threads = []
for port in range(self.config.scan_port_start, self.config.scan_port_end + 1):
if self.bank_found_event.is_set():
break
t = BankScanner(self.host, port, self.peer, self.bank_found_event, self.lock, self.config.timeout)
t.start()
scanner_threads.append(t)
for t in scanner_threads:
t.join()
if not self.bank_found_event.is_set():
self.logger.warning("No bank application found on host %s", self.host)
raise NoPortsOpenError("Bank is unreachable")
# with self.peer.bank_socket as bank_socket:
self.peer.bank_socket.sendall((str(self.request) + "\r\n").encode("utf-8"))
response = self.peer.bank_socket.recv(1024).decode("utf-8")
return response

View File

@ -1,7 +1,9 @@
from .request import *
from .response import *
from .config import BankNodeConfig
__all__ = [
*request.__all__,
*response.__all__
*response.__all__,
*config.__all__
]

View File

@ -26,13 +26,11 @@ class BankNodeConfig:
# Port validation
if not scan_port_range or scan_port_range == "":
self.logger.error("Scan port range not defined")
raise ConfigError("Scan port range not defined")
range_split = scan_port_range.split(":")
if len(range_split) != 2 or not range_split[0].isdigit() or not range_split[1].isdigit():
self.logger.error("Scan port range is not in valid format")
raise ConfigError("Scan port range is not in valid format")
# Timeout validation
@ -48,8 +46,7 @@ class BankNodeConfig:
# IP validation
if not re.match(IP_REGEX, ip):
self.logger.error("Invalid IP in configuration")
raise ConfigError("Invalid IP in configuration")
raise ConfigError(f"Invalid IP {ip} in configuration")
self.used_port: int
self.ip = ip
@ -69,3 +66,6 @@ class BankNodeConfig:
"scan_port_start": self.scan_port_start,
"scan_port_end": self.scan_port_end,
}
__all__ = ["BankNodeConfig"]

View File

@ -0,0 +1,8 @@
import socket
from dataclasses import dataclass
@dataclass
class BankPeer():
host: str
port: int
bank_socket: socket.socket

View File

@ -1,17 +1,23 @@
import re
from bank_protocol.exceptions import InvalidRequest
import logging
class Request():
def __init__(self, raw_request: str):
logger = logging.getLogger(__name__)
raw_request = raw_request.strip()
if re.match(r"^[A-Z]{2}$", raw_request):
self.command_code = raw_request[0:2] # Still take the first 2 characters, because of lingering crlf
logger.debug("Found 2 char command")
self.command_code = raw_request[0:2] # Still take the first 2 characters, because of lingering crlf
self.body = None
elif re.match(r"^[A-Z]{2} .+", raw_request):
logger.debug("Found command with arguments")
command_code: str = raw_request[0:2]
body: str = raw_request[3:-1] or ""
body: str = raw_request[3:] or ""
if len(body.split("\n")) > 1:
raise InvalidRequest("Multiline requests are not supported")
@ -22,7 +28,10 @@ class Request():
raise InvalidRequest("Invalid request")
def __str__(self):
return f"{self.command_code} {self.body}"
if self.body:
return f"{self.command_code} {self.body}"
else:
return self.command_code
__all__ = ["Request"]

View File

@ -1,12 +1,12 @@
import logging
from typing import Generator
from contextlib import contextmanager
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import create_engine, text
from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError
from sqlalchemy.exc import DatabaseError
from database.exceptions import DatabaseConnectionError
from database.exceptions import DatabaseError
from models.base_model import Base
@ -29,13 +29,13 @@ class DatabaseManager():
self.engine = create_engine('sqlite:///bank.db')
self.Session = sessionmaker(bind=self.engine)
self.create_tables()
def create_tables(self):
self.logger.debug("Creating tables")
Base.metadata.create_all(self.engine)
def cleanup(self) -> None:
self.logger.debug("Closing connection")
self.engine.dispose()
def test_connection(self) -> bool:
@ -45,14 +45,15 @@ class DatabaseManager():
connection.execute(text("select 1"))
self.logger.debug("Database connection successful")
return True
except DatabaseError as e:
except SqlAlchemyDatabaseError as e:
self.logger.critical("Database connection failed: %s", e)
raise DatabaseConnectionError("Database connection failed") from e
raise DatabaseError("Database connection failed", DatabaseError.CONNECTION_ERROR) from e
return False
@classmethod
def get_session(cls) -> Generator:
@contextmanager
def get_session(cls) -> Generator[Session, None, None]:
session = cls._instance.Session()
try:
yield session

View File

@ -0,0 +1,13 @@
from functools import wraps
from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError
from database.exceptions import DatabaseError
def handle_database_errors(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except SqlAlchemyDatabaseError as e:
raise DatabaseError(str(e), -1) from e
return wrapper

View File

@ -1,28 +1,25 @@
class DatabaseError(Exception):
def __init__(self, message: str):
# Inspired by OSError which also uses errno's
# It's a better approach than using a class for each error
UNKNOWN_ERROR = -1
CONNECTION_ERROR = 1
EMPTY_CONFIG = 2
DUPLICATE_ENTRY = 3
NONEXISTENT_ACCOUNT = 4
OUT_OF_ACCOUNT_SPACE = 5
INSUFFICIENT_BALANCE = 6
INVALID_OPERATION = 7
MONEY_OVERFLOW = 8
def __init__(self, message: str, errno: int, **kwargs):
super().__init__(message)
self.message = message
self.errno = errno
for key, value in kwargs.items():
setattr(self, key, value)
class DatabaseConnectionError(DatabaseError):
def __init__(self, message: str):
super().__init__(message)
self.message = message
class EmptyDatabaseConfigError(Exception):
def __init__(self, message: str, config_name: str):
super().__init__(message)
self.message = message
self.config_name = config_name
class DuplicateEntryError(DatabaseError):
def __init__(self, duplicate_entry_name: str, message: str):
super().__init__(message)
self.duplicate_entry_name = duplicate_entry_name
self.message = message
__all__ = ["DatabaseError", "DatabaseConnectionError", "DuplicateEntryError"]
__all__ = ["DatabaseError"]

View File

@ -1,13 +1,13 @@
from sqlalchemy import Column, Integer, CheckConstraint
from sqlalchemy import Column, BigInteger, Integer, CheckConstraint
from .base_model import Base
class Account(Base):
__tablename__ = 'account'
__table_args__ = (CheckConstraint('account_number > 10000 and account_number <= 99999'),)
__table_args__ = (CheckConstraint('account_number >= 10000 and account_number <= 99999'),)
account_number = Column(Integer, nullable=False, primary_key=True)
account_number = Column(BigInteger, nullable=False, primary_key=True)
balance = Column(Integer)

View File

@ -0,0 +1,102 @@
from sqlalchemy import func
from models import Account
from database import DatabaseManager
from database.exceptions import DatabaseError
from database.exception_catcher_decorator import handle_database_errors
from utils.constants import MIN_ACCOUNT_NUMBER, MAX_ACCOUNT_NUMBER, MONEY_AMOUNT_MAXIMUM
@handle_database_errors
def get_next_id() -> int:
with DatabaseManager.get_session() as session:
current_max_id = session.query(func.max(Account.account_number)).scalar()
current_max_id = current_max_id + 1 if current_max_id is not None else MIN_ACCOUNT_NUMBER
if current_max_id > MAX_ACCOUNT_NUMBER:
raise DatabaseError("Too many users already exist, cannot open new account", DatabaseError.OUT_OF_ACCOUNT_SPACE)
return current_max_id
@handle_database_errors
def create_account() -> int:
new_id = get_next_id()
with DatabaseManager.get_session() as session:
new_account = Account(account_number=new_id, balance=0)
session.add(new_account)
session.commit()
return new_id
@handle_database_errors
def get_account_balance(account_number: int) -> int:
with DatabaseManager.get_session() as session:
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
if account is None:
raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
return account.balance
@handle_database_errors
def withdraw_from_account(account_number: int, amount: int):
modify_balance(account_number, amount, False)
@handle_database_errors
def deposit_into_account(account_number: int, amount: int):
modify_balance(account_number, amount, True)
@handle_database_errors
def modify_balance(account_number: int, amount: int, add: bool):
with DatabaseManager.get_session() as session:
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
if account is None:
raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
if add:
if account.balance == MONEY_AMOUNT_MAXIMUM:
raise DatabaseError("Reached maximum funds amount", DatabaseError.MONEY_OVERFLOW)
elif account.balance + amount > MONEY_AMOUNT_MAXIMUM:
account.balance = MONEY_AMOUNT_MAXIMUM
else:
account.balance += amount
else:
if account.balance - amount < 0:
raise DatabaseError("Not enough funds on account to withdraw this much", DatabaseError.INSUFFICIENT_BALANCE)
account.balance -= amount
session.commit()
@handle_database_errors
def delete_account(account_number: int):
with DatabaseManager.get_session() as session:
account: Account = session.query(Account).where(Account.account_number == account_number).one_or_none()
if account is None:
raise DatabaseError(f"Account with number {account_number} doesn't exist", DatabaseError.NONEXISTENT_ACCOUNT)
if account.balance > 0:
raise DatabaseError("Cannot delete an account with leftover funds", DatabaseError.INVALID_OPERATION)
session.delete(account)
session.commit()
@handle_database_errors
def get_total_balance() -> int:
with DatabaseManager.get_session() as session:
total_sum = session.query(func.sum(Account.balance)).scalar()
return total_sum if total_sum is not None else 0
@handle_database_errors
def get_account_count() -> int:
with DatabaseManager.get_session() as session:
total_sum = session.query(func.count(Account.account_number)).scalar()
return total_sum if total_sum is not None else 0

View File

@ -1,5 +1,9 @@
import re
import sys
IP_REGEX = r"^[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$"
ACCOUNT_NUMBER_REGEX = r"[0-9]{9}"
MONEY_AMOUNT_MAXIMUM = (2 ^ 63) - 1
IP_REGEX = r"^(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$"
ACCOUNT_NUMBER_REGEX = r"^(1[0-9]{8})|([2-9][0-9]{8})$"
MONEY_AMOUNT_MAXIMUM = 2**63 - 1
MIN_ACCOUNT_NUMBER = 10_000
MAX_ACCOUNT_NUMBER = 99_999

View File

@ -4,7 +4,7 @@ import logging
def setup_logger(verbosity: str):
if verbosity == "DEBUG":
log_format = "[ %(levelname)s / %(processName)s ] - %(name)s:%(lineno)d - %(message)s"
log_format = "[ %(levelname)s / %(processName)s ] - %(filename)s:%(lineno)d - %(message)s"
else:
log_format = "[ %(levelname)s ] - %(message)s"