Compare commits

..

3 Commits

Author SHA1 Message Date
3701bc441c Update TODOs 2023-07-11 18:32:28 +03:00
90da1d4cea Clear code 2023-07-11 18:27:44 +03:00
c428479110 Add WebAPI 2023-07-11 18:26:26 +03:00
19 changed files with 332 additions and 39 deletions

1
.gitignore vendored
View File

@ -136,3 +136,4 @@ dmypy.json
/src/mods
/src/plugins
/test/
*test.py

View File

@ -25,15 +25,23 @@ BeamingDrive Multiplayer (BeamMP) server compatible with BeamMP clients.
- [ ] Players synchronizations
- [ ] Ping
- [ ] Player counter
- [x] Additional:
- [x] Events System
- [x] Plugins support
- [x] MultiLanguage (i18n support)
- [ ] HTTP REST API Server
- [ ] Console:
- [x] Tabulation
- [ ] _(Deferred)_ Normal text scroll
- [x] Additional:
- [x] Console:
- [x] Tabulation
- [ ] _(Deferred)_ Static text
- [x] Events System
- [x] Call events
- [x] Create custom events
- [ ] Return from events
- [x] Plugins support
- [x] MultiLanguage (i18n support)
- [x] Core
- [x] Console
- [x] WebAPI
- [x] HTTP API Server (fastapi)
- [x] Stop and Start with core
- [x] Custom logger
- [ ] Sync with event system
- [ ] [Documentation](docs/en/readme.md)
## Installation

View File

@ -1,3 +1,8 @@
PyYAML~=6.0
prompt-toolkit~=3.0.38
aiohttp~=3.8.4
aiohttp~=3.8.4
uvicorn~=0.22.0
fastapi~=0.100.0
starlette~=0.27.0
pydantic~=2.0.2
click~=8.1.4

View File

@ -16,13 +16,6 @@ __author_email__ = 'admin@kuitoi.su'
__license__ = "FPA"
__copyright__ = 'Copyright 2023 © SantaSpeen (Maxim Khomutov)'
from main import parser
args = parser.parse_args()
if args.version:
print(f"{__title__}:\n\tVersion: {__version__}\n\tBuild: {__build__}")
exit(0)
import asyncio
import builtins
import os
@ -31,14 +24,20 @@ import webbrowser
import prompt_toolkit.shortcuts as shortcuts
from .utils import get_logger
from core.core import Core
from main import parser
from modules import ConfigProvider, EventsSystem, PluginsLoader
from modules import Console
from modules import MultiLanguage
from core.core import Core
args = parser.parse_args()
if args.version:
print(f"{__title__}:\n\tVersion: {__version__}\n\tBuild: {__build__}")
exit(0)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
log = get_logger("init")
log = get_logger("core.init")
# Config file init
config_path = "kuitoi.yml"
@ -49,7 +48,7 @@ config = config_provider.open_config()
if config.Server['debug'] is True:
utils.set_debug_status()
log.info("Debug enabled!")
log = get_logger("init")
log = get_logger("core.init")
log.debug("Debug mode enabled!")
log.debug(f"Server config: {config}")
@ -111,7 +110,6 @@ console.add_command("exit", console.stop, i18n.man_message_exit, i18n.help_messa
if not os.path.exists("mods"):
os.mkdir("mods")
log.debug("Initializing PluginsLoader...")
if not os.path.exists("plugins"):
os.mkdir("plugins")

View File

@ -5,11 +5,16 @@
# Licence: FPA
# (c) kuitoi.su 2023
import asyncio
import time
import zlib
from threading import Thread
import uvicorn
from core import utils
from .tcp_server import TCPServer
from .udp_server import UDPServer
from modules.WebAPISystem import app as webapp
from core.tcp_server import TCPServer
from core.udp_server import UDPServer
class Client:
@ -145,6 +150,9 @@ class Core:
self.server_port = config.Server["server_port"]
self.tcp = TCPServer
self.udp = UDPServer
self.web_thread = None
self.web_pool = webapp.data_pool
self.web_stop = None
def get_client(self, sock=None, cid=None):
if cid:
@ -172,11 +180,34 @@ class Core:
if d:
self.log.debug(f"Client ID: {cl.id} died...")
@staticmethod
def start_web():
global uvserver
uvconfig = uvicorn.Config("modules.WebAPISystem.app:web_app",
host=config.WebAPI["server_ip"],
port=config.WebAPI["server_port"],
loop="asyncio")
uvserver = uvicorn.Server(uvconfig)
webapp.uvserver = uvserver
uvserver.run()
@staticmethod
async def stop_me():
while webapp.data_run[0]:
await asyncio.sleep(1)
raise KeyboardInterrupt
async def main(self):
self.tcp = self.tcp(self, self.server_ip, self.server_port)
self.udp = self.udp(self, self.server_ip, self.server_port)
tasks = [self.tcp.start(), self.udp.start(), console.start()] # self.check_alive()
tasks = [self.tcp.start(), self.udp.start(), console.start(), self.stop_me()] # self.check_alive()
t = asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
if config.WebAPI["enabled"]:
self.log.debug("Initializing WebAPI...")
web_thread = Thread(target=self.start_web)
web_thread.start()
self.web_thread = web_thread
self.web_stop = webapp._stop
self.log.info(i18n.start)
# TODO: Server auth
ev.call_event("on_started")
@ -198,4 +229,5 @@ class Core:
def stop(self):
self.log.info(i18n.stop)
asyncio.run(self.web_stop())
exit(0)

View File

@ -6,6 +6,8 @@
# (c) kuitoi.su 2023
import asyncio
from asyncio import StreamWriter, StreamReader
from threading import Thread
from typing import Callable
from core import utils
from .tcp_server import TCPServer
@ -46,10 +48,15 @@ class Core:
self.loop = asyncio.get_event_loop()
self.tcp = TCPServer
self.udp = UDPServer
self.web_thread: Thread = None
self.web_stop: Callable = lambda: None
def insert_client(self, client: Client) -> None: ...
def create_client(self, *args, **kwargs) -> Client: ...
async def check_alive(self) -> None: ...
@staticmethod
def start_web() -> None: ...
@staticmethod
def stop_me(self) -> None: ...
async def main(self) -> None: ...
def start(self) -> None: ...
def stop(self) -> None: ...

View File

@ -8,15 +8,16 @@
import logging
import os
log_format = "[%(asctime)s | %(name)s | %(levelname)-5s] %(message)s"
log_format = "[%(asctime)s | %(name)-14s | %(levelname)-5s] %(message)s"
log_format_access = '[%(asctime)s | %(name)-14s | %(levelname)-5s] %(client_addr)s - "%(request_line)s" %(status_code)s'
log_file = "server.log"
log_level = logging.INFO
# Инициализируем логирование
logging.basicConfig(level=log_level, format=log_format)
# Настройка логирование в файл.
if os.path.exists(log_file):
os.remove(log_file)
fh = logging.FileHandler(log_file)
# if os.path.exists(log_file):
# os.remove(log_file)
fh = logging.FileHandler(log_file, encoding='utf-8')
fh.setFormatter(logging.Formatter(log_format))

View File

@ -14,13 +14,20 @@ parser.add_argument('-v', '--version', action="store_true", help='Print version
parser.add_argument('--config', help='Patch to config file.', nargs='?', default=None, type=str)
parser.add_argument('--language', help='Setting localisation.', nargs='?', default=None, type=str)
if __name__ == '__main__':
run = True
def main():
global run
from core import Core
core = Core()
try:
core.start()
except KeyboardInterrupt:
pass
finally:
core.stop()
while run:
try:
core.start()
except KeyboardInterrupt:
run = False
core.stop()
if __name__ == '__main__':
main()

View File

@ -2,8 +2,7 @@ class Config:
Auth: dict
Game: dict
Server: dict
WebAPI: dict
def __repr__(self):
return "%s(Auth=%r, Game=%r, Server=%r)" % (self.__class__.__name__, self.Auth, self.Game, self.Server)
class config (Config): ...

View File

@ -18,8 +18,8 @@ class Config:
self.Game = game or {"map": "gridmap_v2", "players": 8, "max_cars": 1}
self.Server = server or {"name": "KuiToi-Server", "description": "Welcome to KuiToi Server!", "language": "en",
"server_ip": "0.0.0.0", "server_port": 30814, "debug": False}
# self.WebAPI = web or {"enabled": False, "server_ip": "127.0.0.1", "server_port": 8433,
# "secret_key": secrets.token_hex(16)}
self.WebAPI = web or {"enabled": False, "server_ip": "127.0.0.1", "server_port": 8433,
"secret_key": secrets.token_hex(16)}
def __repr__(self):
return "%s(Auth=%r, Game=%r, Server=%r)" % (self.__class__.__name__, self.Auth, self.Game, self.Server)

View File

@ -0,0 +1,2 @@
from .app import web_app
from .app import _stop

View File

@ -0,0 +1,129 @@
import asyncio
from asyncio import CancelledError
import uvicorn
from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from starlette import status
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse
from uvicorn.config import LOGGING_CONFIG
import core.utils
from . import utils
# from .models import SecretKey
web_app = FastAPI()
log = core.utils.get_logger("web")
uvserver = None
data_pool = []
data_run = [True]
LOGGING_CONFIG["formatters"]["default"]['fmt'] = core.utils.log_format
LOGGING_CONFIG["formatters"]["access"]["fmt"] = core.utils.log_format_access
LOGGING_CONFIG["formatters"].update({
"file_default": {
"fmt": core.utils.log_format
},
"file_access": {
"fmt": core.utils.log_format_access
}
})
LOGGING_CONFIG["handlers"]["default"]['stream'] = "ext://sys.stdout"
LOGGING_CONFIG["handlers"].update({
"file_default": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "webserver.log"
},
"file_access": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "webserver.log"
}
})
LOGGING_CONFIG["loggers"]["uvicorn"]["handlers"].append("file_default")
LOGGING_CONFIG["loggers"]["uvicorn.access"]["handlers"].append("file_access")
def response(data=None, code=status.HTTP_200_OK, error_code=0, error_message=None):
if 200 >= code <= 300:
return JSONResponse(content={"result": data, "error": None}, status_code=code)
return JSONResponse(
content={"error": {"code": error_code if error_code else code, "message": f"{error_message}"}, "result": None},
status_code=code)
@web_app.get("/")
async def index():
log.debug("Request IndexPage;")
return response("Index page")
@web_app.get("/method/{method}")
async def _method(method, secret_key: str = None):
# log.debug(f"Request method; kwargs: {kwargs}")
is_auth = secret_key == config.WebAPI["secret_key"]
spl = method.split(".")
if len(spl) != 2:
raise StarletteHTTPException(405)
api_class, api_method = spl
match api_class:
case "events":
match api_method, is_auth:
case "get", False:
return response(data_pool)
raise StarletteHTTPException(404)
async def _stop():
await asyncio.sleep(1)
uvserver.should_exit = True
data_run[0] = False
@web_app.get("/stop")
async def stop(secret_key: str):
log.debug(f"Request stop; secret key: {secret_key}")
if secret_key == config.WebAPI["secret_key"]:
log.info("Stopping Web server")
asyncio.create_task(_stop())
return response("Web server stopped")
@web_app.exception_handler(HTTPException)
async def default_exception_handler(request: Request, exc: HTTPException):
return response(
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
error_code=exc.status_code, error_message=f"Internal Server Error: {exc.status_code}"
)
@web_app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
code = exc.status_code
if code == status.HTTP_405_METHOD_NOT_ALLOWED:
return response(code=code, error_message="Method Not Allowed")
if code == status.HTTP_404_NOT_FOUND:
return response(code=code, error_message="Method not Found")
return response(code=code, error_message="Unhandled error..")
@web_app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(request: Request, exc: RequestValidationError):
code = status.HTTP_422_UNPROCESSABLE_ENTITY
return response(code=code, error_message="Request Validation Error")
utils.hack_fastapi()
if __name__ == '__main__':
try:
uvconfig = uvicorn.Config(web_app,
host=config.WebAPI["server_ip"],
port=config.WebAPI["server_port"],
loop="asyncio")
uvserver = uvicorn.Server(uvconfig)
uvserver.run()
except KeyboardInterrupt or CancelledError:
pass

View File

View File

@ -0,0 +1,87 @@
import asyncio
import sys
import click
from uvicorn.server import Server, logger
from uvicorn.lifespan import on
def ev_log_started_message(self, listeners) -> None:
cfg = self.config
if cfg.fd is not None:
sock = listeners[0]
logger.info(i18n.web_start.format(sock.getsockname()))
elif cfg.uds is not None:
logger.info(i18n.web_start.format(cfg.uds))
else:
addr_format = "%s://%s:%d"
host = "0.0.0.0" if cfg.host is None else cfg.host
if ":" in host:
addr_format = "%s://[%s]:%d"
port = cfg.port
if port == 0:
port = listeners[0].getsockname()[1]
protocol_name = "https" if cfg.ssl else "http"
message = i18n.web_start.format(addr_format)
color_message = (i18n.web_start.format(click.style(addr_format, bold=True)))
logger.info(message, protocol_name, host, port, extra={"color_message": color_message})
async def ev_shutdown(self, sockets=None) -> None:
logger.debug("Shutting down")
for server in self.servers:
server.close()
for sock in sockets or []:
sock.close()
for server in self.servers:
await server.wait_closed()
for connection in list(self.server_state.connections):
connection.shutdown()
await asyncio.sleep(0.1)
try:
await asyncio.wait_for(self._wait_tasks_to_complete(), timeout=self.config.timeout_graceful_shutdown)
except asyncio.TimeoutError:
logger.error("Cancel %s running task(s), timeout graceful shutdown exceeded",len(self.server_state.tasks))
for t in self.server_state.tasks:
if sys.version_info < (3, 9):
t.cancel()
else:
t.cancel(msg="Task cancelled, timeout graceful shutdown exceeded")
if not self.force_exit:
await self.lifespan.shutdown()
async def on_startup(self) -> None:
self.logger.debug("Waiting for application startup.")
loop = asyncio.get_event_loop()
main_lifespan_task = loop.create_task(self.main()) # noqa: F841
startup_event = {"type": "lifespan.startup"}
await self.receive_queue.put(startup_event)
await self.startup_event.wait()
if self.startup_failed or (self.error_occured and self.config.lifespan == "on"):
self.logger.error("Application startup failed. Exiting.")
self.should_exit = True
else:
self.logger.debug("Application startup complete.")
async def on_shutdown(self) -> None:
if self.error_occured:
return
self.logger.debug("Waiting for application shutdown.")
shutdown_event = {"type": "lifespan.shutdown"}
await self.receive_queue.put(shutdown_event)
await self.shutdown_event.wait()
if self.shutdown_failed or (self.error_occured and self.config.lifespan == "on"):
self.logger.error("Application shutdown failed. Exiting.")
self.should_exit = True
else:
self.logger.debug("Application shutdown complete.")
def hack_fastapi():
Server.shutdown = ev_shutdown
Server._log_started_message = ev_log_started_message
on.LifespanOn.startup = on_startup
on.LifespanOn.shutdown = on_shutdown

View File

@ -11,3 +11,5 @@ from .ConfigProvider import ConfigProvider, Config
from .i18n import MultiLanguage
from .EventsSystem import EventsSystem
from .PluginsLoader import PluginsLoader
from .WebAPISystem import web_app
from .WebAPISystem import _stop as stop_web

View File

@ -21,6 +21,9 @@
"GUI_enter_key_message": "Please type your key:",
"GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}",
"": "Web phases",
"web_start": "WebAPI running on {} (Press CTRL+C to quit)",
"": "Command: man",
"man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND",
"help_message_man": "Display the manual page for COMMAND.",

View File

@ -21,6 +21,9 @@
"GUI_enter_key_message": "Пожалуйста введите ключ:",
"GUI_cannot_open_browser": "Не получилось открыть браузер.\nИспользуй эту ссылку: {}",
"": "Web phases",
"web_start": "WebAPI запустился на {} (CTRL+C для выключения)",
"": "Command: man",
"man_message_man": "man - Показывает страничку помощи для COMMAND.\nИспользование: man COMMAND",
"help_message_man": "Показывает страничку помощи для COMMAND.",

View File

@ -21,6 +21,9 @@ class i18n:
GUI_enter_key_message: str = data["GUI_enter_key_message"]
GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"]
# Web phases
web_start: str = data["web_start"]
# Command: man
man_message_man: str = data["man_message_man"]
help_message_man: str = data["help_message_man"]

View File

@ -38,6 +38,9 @@ class i18n:
self.GUI_enter_key_message: str = data["GUI_enter_key_message"]
self.GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"]
# Web phases
self.web_start: str = data["web_start"]
# Command: man
self.man_message_man: str = data["man_message_man"]
self.help_message_man: str = data["help_message_man"]
@ -108,6 +111,9 @@ class MultiLanguage:
"GUI_enter_key_message": "Please type your key:",
"GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}",
"": "Web phases",
"web_start": "WebAPI running on {} (Press CTRL+C to quit)",
"": "Command: man",
"man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND",
"help_message_man": "Display the manual page for COMMAND.",