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/mods
/src/plugins /src/plugins
/test/ /test/
*test.py

View File

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

View File

@ -1,3 +1,8 @@
PyYAML~=6.0 PyYAML~=6.0
prompt-toolkit~=3.0.38 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" __license__ = "FPA"
__copyright__ = 'Copyright 2023 © SantaSpeen (Maxim Khomutov)' __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 asyncio
import builtins import builtins
import os import os
@ -31,14 +24,20 @@ import webbrowser
import prompt_toolkit.shortcuts as shortcuts import prompt_toolkit.shortcuts as shortcuts
from .utils import get_logger from .utils import get_logger
from core.core import Core
from main import parser
from modules import ConfigProvider, EventsSystem, PluginsLoader from modules import ConfigProvider, EventsSystem, PluginsLoader
from modules import Console from modules import Console
from modules import MultiLanguage 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() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
log = get_logger("init") log = get_logger("core.init")
# Config file init # Config file init
config_path = "kuitoi.yml" config_path = "kuitoi.yml"
@ -49,7 +48,7 @@ config = config_provider.open_config()
if config.Server['debug'] is True: if config.Server['debug'] is True:
utils.set_debug_status() utils.set_debug_status()
log.info("Debug enabled!") log.info("Debug enabled!")
log = get_logger("init") log = get_logger("core.init")
log.debug("Debug mode enabled!") log.debug("Debug mode enabled!")
log.debug(f"Server config: {config}") 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"): if not os.path.exists("mods"):
os.mkdir("mods") os.mkdir("mods")
log.debug("Initializing PluginsLoader...") log.debug("Initializing PluginsLoader...")
if not os.path.exists("plugins"): if not os.path.exists("plugins"):
os.mkdir("plugins") os.mkdir("plugins")

View File

@ -5,11 +5,16 @@
# Licence: FPA # Licence: FPA
# (c) kuitoi.su 2023 # (c) kuitoi.su 2023
import asyncio import asyncio
import time
import zlib import zlib
from threading import Thread
import uvicorn
from core import utils from core import utils
from .tcp_server import TCPServer from modules.WebAPISystem import app as webapp
from .udp_server import UDPServer from core.tcp_server import TCPServer
from core.udp_server import UDPServer
class Client: class Client:
@ -145,6 +150,9 @@ class Core:
self.server_port = config.Server["server_port"] self.server_port = config.Server["server_port"]
self.tcp = TCPServer self.tcp = TCPServer
self.udp = UDPServer 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): def get_client(self, sock=None, cid=None):
if cid: if cid:
@ -172,11 +180,34 @@ class Core:
if d: if d:
self.log.debug(f"Client ID: {cl.id} died...") 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): async def main(self):
self.tcp = self.tcp(self, self.server_ip, self.server_port) self.tcp = self.tcp(self, self.server_ip, self.server_port)
self.udp = self.udp(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) 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) self.log.info(i18n.start)
# TODO: Server auth # TODO: Server auth
ev.call_event("on_started") ev.call_event("on_started")
@ -198,4 +229,5 @@ class Core:
def stop(self): def stop(self):
self.log.info(i18n.stop) self.log.info(i18n.stop)
asyncio.run(self.web_stop())
exit(0) exit(0)

View File

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

View File

@ -8,15 +8,16 @@
import logging import logging
import os 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_file = "server.log"
log_level = logging.INFO log_level = logging.INFO
# Инициализируем логирование # Инициализируем логирование
logging.basicConfig(level=log_level, format=log_format) logging.basicConfig(level=log_level, format=log_format)
# Настройка логирование в файл. # Настройка логирование в файл.
if os.path.exists(log_file): # if os.path.exists(log_file):
os.remove(log_file) # os.remove(log_file)
fh = logging.FileHandler(log_file) fh = logging.FileHandler(log_file, encoding='utf-8')
fh.setFormatter(logging.Formatter(log_format)) 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('--config', help='Patch to config file.', nargs='?', default=None, type=str)
parser.add_argument('--language', help='Setting localisation.', 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 from core import Core
core = Core() core = Core()
while run:
try: try:
core.start() core.start()
except KeyboardInterrupt: except KeyboardInterrupt:
pass run = False
finally:
core.stop() core.stop()
if __name__ == '__main__':
main()

View File

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

View File

@ -18,8 +18,8 @@ class Config:
self.Game = game or {"map": "gridmap_v2", "players": 8, "max_cars": 1} 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", 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} "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, self.WebAPI = web or {"enabled": False, "server_ip": "127.0.0.1", "server_port": 8433,
# "secret_key": secrets.token_hex(16)} "secret_key": secrets.token_hex(16)}
def __repr__(self): def __repr__(self):
return "%s(Auth=%r, Game=%r, Server=%r)" % (self.__class__.__name__, self.Auth, self.Game, self.Server) 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 .i18n import MultiLanguage
from .EventsSystem import EventsSystem from .EventsSystem import EventsSystem
from .PluginsLoader import PluginsLoader 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_enter_key_message": "Please type your key:",
"GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}", "GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}",
"": "Web phases",
"web_start": "WebAPI running on {} (Press CTRL+C to quit)",
"": "Command: man", "": "Command: man",
"man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND", "man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND",
"help_message_man": "Display the manual page for COMMAND.", "help_message_man": "Display the manual page for COMMAND.",

View File

@ -21,6 +21,9 @@
"GUI_enter_key_message": "Пожалуйста введите ключ:", "GUI_enter_key_message": "Пожалуйста введите ключ:",
"GUI_cannot_open_browser": "Не получилось открыть браузер.\nИспользуй эту ссылку: {}", "GUI_cannot_open_browser": "Не получилось открыть браузер.\nИспользуй эту ссылку: {}",
"": "Web phases",
"web_start": "WebAPI запустился на {} (CTRL+C для выключения)",
"": "Command: man", "": "Command: man",
"man_message_man": "man - Показывает страничку помощи для COMMAND.\nИспользование: man COMMAND", "man_message_man": "man - Показывает страничку помощи для COMMAND.\nИспользование: man COMMAND",
"help_message_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_enter_key_message: str = data["GUI_enter_key_message"]
GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"] GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"]
# Web phases
web_start: str = data["web_start"]
# Command: man # Command: man
man_message_man: str = data["man_message_man"] man_message_man: str = data["man_message_man"]
help_message_man: str = data["help_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_enter_key_message: str = data["GUI_enter_key_message"]
self.GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"] self.GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"]
# Web phases
self.web_start: str = data["web_start"]
# Command: man # Command: man
self.man_message_man: str = data["man_message_man"] self.man_message_man: str = data["man_message_man"]
self.help_message_man: str = data["help_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_enter_key_message": "Please type your key:",
"GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}", "GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}",
"": "Web phases",
"web_start": "WebAPI running on {} (Press CTRL+C to quit)",
"": "Command: man", "": "Command: man",
"man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND", "man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND",
"help_message_man": "Display the manual page for COMMAND.", "help_message_man": "Display the manual page for COMMAND.",