Merge branch 'feature/API' into 'dev'

Add API v1 for 4.0 - Fix DB Lock

See merge request crafty-controller/crafty-commander!238
This commit is contained in:
Andrew 2022-04-10 19:39:31 +00:00
commit d3d8cee83c
10 changed files with 362 additions and 43 deletions

View File

@ -161,9 +161,14 @@ class Users_Controller:
@staticmethod @staticmethod
def get_user_by_api_token(token: str): def get_user_by_api_token(token: str):
_, user = authentication.check(token) _, _, user = authentication.check(token)
return user return user
@staticmethod
def get_api_key_by_token(token: str):
key, _, _ = authentication.check(token)
return key
# ********************************************************************************** # **********************************************************************************
# User Roles Methods # User Roles Methods
# ********************************************************************************** # **********************************************************************************

View File

@ -2,7 +2,7 @@ import logging
from app.classes.shared.helpers import helper from app.classes.shared.helpers import helper
from app.classes.shared.permission_helper import permission_helper from app.classes.shared.permission_helper import permission_helper
from app.classes.models.users import Users, ApiKeys from app.classes.models.users import Users, ApiKeys, users_helper
try: try:
from peewee import ( from peewee import (
@ -213,13 +213,16 @@ class Permissions_Crafty:
@staticmethod @staticmethod
def get_api_key_permissions_list(key: ApiKeys): def get_api_key_permissions_list(key: ApiKeys):
user = key.user user = users_helper.get_user(key.user_id)
if user.superuser and key.superuser: if user["superuser"] and key.superuser:
return crafty_permissions.get_permissions_list() return crafty_permissions.get_permissions_list()
else: else:
user_permissions_mask = crafty_permissions.get_crafty_permissions_mask( if user["superuser"]:
user.user_id user_permissions_mask = "111"
) else:
user_permissions_mask = crafty_permissions.get_crafty_permissions_mask(
user["user_id"]
)
key_permissions_mask: str = key.crafty_permissions key_permissions_mask: str = key.crafty_permissions
permissions_mask = permission_helper.combine_masks( permissions_mask = permission_helper.combine_masks(
user_permissions_mask, key_permissions_mask user_permissions_mask, key_permissions_mask

View File

@ -263,8 +263,8 @@ class Permissions_Servers:
@staticmethod @staticmethod
def get_api_key_permissions_list(key: ApiKeys, server_id: str): def get_api_key_permissions_list(key: ApiKeys, server_id: str):
user = key.user user = users_helper.get_user(key.user_id)
if user.superuser and key.superuser: if user["superuser"] and key.superuser:
return server_permissions.get_permissions_list() return server_permissions.get_permissions_list()
else: else:
roles_list = users_helper.get_user_roles_id(user["user_id"]) roles_list = users_helper.get_user_roles_id(user["user_id"])

View File

@ -22,16 +22,18 @@ class Authentication:
if self.secret is None or self.secret == "random": if self.secret is None or self.secret == "random":
self.secret = helper.random_string_generator(64) self.secret = helper.random_string_generator(64)
helper.set_setting("apikey_secret", self.secret)
@staticmethod @staticmethod
def generate(user_id, extra=None): def generate(user_id, extra=None):
if extra is None: if extra is None:
extra = {} extra = {}
return jwt.encode( jwt_encoded = jwt.encode(
{"user_id": user_id, "iat": int(time.time()), **extra}, {"user_id": user_id, "iat": int(time.time()), **extra},
authentication.secret, authentication.secret,
algorithm="HS256", algorithm="HS256",
) )
return jwt_encoded
@staticmethod @staticmethod
def read(token): def read(token):

View File

@ -206,6 +206,31 @@ class Helpers:
return default_return return default_return
def set_setting(self, key, new_value, default_return=False):
try:
with open(self.settings_file, "r", encoding="utf-8") as f:
data = json.load(f)
if key in data.keys():
data[key] = new_value
else:
logger.error(f"Config File Error: setting {key} does not exist")
console.error(f"Config File Error: setting {key} does not exist")
return default_return
with open(self.settings_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=1)
except Exception as e:
logger.critical(
f"Config File Error: Unable to read {self.settings_file} due to {e}"
)
console.critical(
f"Config File Error: Unable to read {self.settings_file} due to {e}"
)
def get_local_ip(self): def get_local_ip(self):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try: try:

View File

@ -10,7 +10,8 @@ Users = Users
try: try:
# pylint: disable=unused-import # pylint: disable=unused-import
from peewee import SqliteDatabase, fn from peewee import fn
from playhouse.sqliteq import SqliteQueueDatabase
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
except ModuleNotFoundError as err: except ModuleNotFoundError as err:
@ -19,8 +20,12 @@ except ModuleNotFoundError as err:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
peewee_logger = logging.getLogger("peewee") peewee_logger = logging.getLogger("peewee")
peewee_logger.setLevel(logging.INFO) peewee_logger.setLevel(logging.INFO)
database = SqliteDatabase( database = SqliteQueueDatabase(
helper.db_path, pragmas={"journal_mode": "wal", "cache_size": -1024 * 10} helper.db_path
# This is commented out after presenting issues when
# moving from SQLiteDatabase to SqliteQueueDatabase
# //TODO Enable tuning
# pragmas={"journal_mode": "wal", "cache_size": -1024 * 10}
) )

View File

@ -1,6 +1,9 @@
from datetime import datetime
import logging import logging
import re import re
from app.classes.controllers.crafty_perms_controller import Enum_Permissions_Crafty
from app.classes.controllers.server_perms_controller import Enum_Permissions_Server
from app.classes.web.base_handler import BaseHandler from app.classes.web.base_handler import BaseHandler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -13,6 +16,10 @@ class ApiHandler(BaseHandler):
self.set_status(status) self.set_status(status)
self.write(data) self.write(data)
def check_xsrf_cookie(self):
# Disable CSRF protection on API routes
pass
def access_denied(self, user, reason=""): def access_denied(self, user, reason=""):
if reason: if reason:
reason = " because " + reason reason = " because " + reason
@ -34,10 +41,24 @@ class ApiHandler(BaseHandler):
) )
def authenticate_user(self) -> bool: def authenticate_user(self) -> bool:
self.permissions = {
"Commands": Enum_Permissions_Server.Commands,
"Terminal": Enum_Permissions_Server.Terminal,
"Logs": Enum_Permissions_Server.Logs,
"Schedule": Enum_Permissions_Server.Schedule,
"Backup": Enum_Permissions_Server.Backup,
"Files": Enum_Permissions_Server.Files,
"Config": Enum_Permissions_Server.Config,
"Players": Enum_Permissions_Server.Players,
"Server_Creation": Enum_Permissions_Crafty.Server_Creation,
"User_Config": Enum_Permissions_Crafty.User_Config,
"Roles_Config": Enum_Permissions_Crafty.Roles_Config,
}
try: try:
logger.debug("Searching for specified token") logger.debug("Searching for specified token")
api_token = self.get_argument("token", "") api_token = self.get_argument("token", "")
self.api_token = api_token
if api_token is None and self.request.headers.get("Authorization"): if api_token is None and self.request.headers.get("Authorization"):
api_token = bearer_pattern.sub( api_token = bearer_pattern.sub(
"", self.request.headers.get("Authorization") "", self.request.headers.get("Authorization")
@ -50,7 +71,6 @@ class ApiHandler(BaseHandler):
if user_data: if user_data:
# Login successful! Check perms # Login successful! Check perms
logger.info(f"User {user_data['username']} has authenticated to API") logger.info(f"User {user_data['username']} has authenticated to API")
# TODO: Role check
return True # This is to set the "authenticated" return True # This is to set the "authenticated"
else: else:
@ -77,10 +97,20 @@ class ServersStats(ApiHandler):
authenticated = self.authenticate_user() authenticated = self.authenticate_user()
if not authenticated: if not authenticated:
return return
raw_stats = self.controller.servers.get_all_servers_stats()
stats = []
for rs in raw_stats:
s = {}
for k, v in rs["server_data"].items():
if isinstance(v, datetime):
s[k] = v.timestamp()
else:
s[k] = v
stats.append(s)
# Get server stats # Get server stats
# TODO Check perms # TODO Check perms
self.finish(self.write({"servers": self.controller.stats.get_servers_stats()})) self.finish(self.write({"servers": stats}))
class NodeStats(ApiHandler): class NodeStats(ApiHandler):
@ -92,5 +122,229 @@ class NodeStats(ApiHandler):
# Get node stats # Get node stats
node_stats = self.controller.stats.get_node_stats() node_stats = self.controller.stats.get_node_stats()
node_stats.pop("servers") self.return_response(200, {"code": node_stats["node_stats"]})
self.finish(self.write(node_stats))
class SendCommand(ApiHandler):
def post(self):
user = self.authenticate_user()
if user is None:
self.access_denied("unknown")
server_id = self.get_argument("id")
if not self.permissions[
"Commands"
] in self.controller.server_perms.get_api_key_permissions_list(
self.controller.users.get_api_key_by_token(self.api_token), server_id
):
self.access_denied(user)
command = self.get_argument("command", default=None, strip=True)
server_id = self.get_argument("id")
if command:
server = self.controller.get_server_obj(server_id)
if server.check_running:
server.send_command(command)
self.return_response(200, {"run": True})
else:
self.return_response(200, {"error": "SER_NOT_RUNNING"})
else:
self.return_response(200, {"error": "NO_COMMAND"})
class ServerBackup(ApiHandler):
def post(self):
user = self.authenticate_user()
if user is None:
self.access_denied("unknown")
server_id = self.get_argument("id")
if not self.permissions[
"Backup"
] in self.controller.server_perms.get_api_key_permissions_list(
self.controller.users.get_api_key_by_token(self.api_token), server_id
):
self.access_denied(user)
server = self.controller.get_server_obj(server_id)
server.backup_server()
self.return_response(200, {"code": "SER_BAK_CALLED"})
class StartServer(ApiHandler):
def post(self):
user = self.authenticate_user()
remote_ip = self.get_remote_ip()
user_obj = self.controller.users.get_user_by_api_token(self.api_token)
if user is None:
self.access_denied("unknown")
server_id = self.get_argument("id")
if not self.permissions[
"Commands"
] in self.controller.server_perms.get_api_key_permissions_list(
self.controller.users.get_api_key_by_token(self.api_token), server_id
):
self.access_denied(user)
server = self.controller.get_server_obj(server_id)
if not server.check_running():
self.controller.management.send_command(
user_obj["user_id"], server_id, remote_ip, "start_server"
)
self.return_response(200, {"code": "SER_START_CALLED"})
else:
self.return_response(500, {"error": "SER_RUNNING"})
class StopServer(ApiHandler):
def post(self):
user = self.authenticate_user()
remote_ip = self.get_remote_ip()
if user is None:
self.access_denied("unknown")
server_id = self.get_argument("id")
if not self.permissions[
"Commands"
] in self.controller.server_perms.get_api_key_permissions_list(
self.controller.users.get_api_key_by_token(self.api_token), server_id
):
self.access_denied(user)
server = self.controller.get_server_obj(server_id)
if server.check_running():
self.controller.management.send_command(
user, server_id, remote_ip, "stop_server"
)
self.return_response(200, {"code": "SER_STOP_CALLED"})
else:
self.return_response(500, {"error": "SER_NOT_RUNNING"})
class RestartServer(ApiHandler):
def post(self):
user = self.authenticate_user()
remote_ip = self.get_remote_ip()
server_id = self.get_argument("id")
if user is None:
self.access_denied("unknown")
if not self.permissions[
"Commands"
] in self.controller.server_perms.get_api_key_permissions_list(
self.controller.users.get_api_key_by_token(self.api_token), server_id
):
self.access_denied(user)
self.controller.management.send_command(
user, server_id, remote_ip, "restart_server"
)
self.return_response(200, {"code": "SER_RESTART_CALLED"})
class CreateUser(ApiHandler):
def post(self):
user = self.authenticate_user()
if user is None:
self.access_denied("unknown")
if not self.permissions[
"User_Config"
] in self.controller.crafty_perms.get_api_key_permissions_list(
self.controller.users.get_api_key_by_token(self.api_token)
):
self.access_denied(user)
new_username = self.get_argument("username")
new_pass = self.get_argument("password")
if new_username:
self.controller.users.add_user(
new_username, new_pass, "default@example.com", True, False
)
self.return_response(
200,
{
"code": "COMPLETE",
"username": new_username,
"password": new_pass,
},
)
else:
self.return_response(
500,
{
"error": "MISSING_PARAMS",
"info": "Some paramaters failed validation",
},
)
class DeleteUser(ApiHandler):
def post(self):
user = self.authenticate_user()
if user is None:
self.access_denied("unknown")
if not self.permissions[
"User_Config"
] in self.controller.crafty_perms.get_api_key_permissions_list(
self.controller.users.get_api_key_by_token(self.api_token)
):
self.access_denied(user)
user_id = self.get_argument("user_id", None, True)
user_to_del = self.controller.users.get_user_by_id(user_id)
if user_to_del["superuser"]:
self.return_response(
500,
{"error": "NOT_ALLOWED", "info": "You cannot delete a super user"},
)
else:
if user_id:
self.controller.users.remove_user(user_id)
self.return_response(200, {"code": "COMPLETED"})
class ListServers(ApiHandler):
def get(self):
user = self.authenticate_user()
user_obj = self.controller.users.get_user_by_api_token(self.api_token)
if user is None:
self.access_denied("unknown")
if self.api_token is None:
self.access_denied("unknown")
if user_obj["superuser"]:
servers = self.controller.servers.get_all_defined_servers()
servers = [str(i) for i in servers]
else:
servers = self.controller.servers.get_all_defined_servers()
servers = [str(i) for i in servers]
self.return_response(
200,
{
"code": "COMPLETED",
"servers": servers,
},
)

View File

@ -1856,8 +1856,8 @@ class PanelHandler(BaseHandler):
name, name,
user_id, user_id,
superuser, superuser,
crafty_permissions_mask,
server_permissions_mask, server_permissions_mask,
crafty_permissions_mask,
) )
self.controller.management.add_to_audit_log( self.controller.management.add_to_audit_log(
@ -1886,13 +1886,13 @@ class PanelHandler(BaseHandler):
self.controller.management.add_to_audit_log( self.controller.management.add_to_audit_log(
exec_user["user_id"], exec_user["user_id"],
f"Generated a new API token for the key {key.name} " f"Generated a new API token for the key {key.name} "
f"from user with UID: {key.user.user_id}", f"from user with UID: {key.user_id}",
server_id=0, server_id=0,
source_ip=self.get_remote_ip(), source_ip=self.get_remote_ip(),
) )
self.write( self.write(
authentication.generate(key.user.user_id, {"token_id": key.token_id}) authentication.generate(key.user_id.user_id, {"token_id": key.token_id})
) )
self.finish() self.finish()

View File

@ -13,7 +13,18 @@ from app.classes.web.panel_handler import PanelHandler
from app.classes.web.default_handler import DefaultHandler from app.classes.web.default_handler import DefaultHandler
from app.classes.web.server_handler import ServerHandler from app.classes.web.server_handler import ServerHandler
from app.classes.web.ajax_handler import AjaxHandler from app.classes.web.ajax_handler import AjaxHandler
from app.classes.web.api_handler import ServersStats, NodeStats from app.classes.web.api_handler import (
ServersStats,
NodeStats,
ServerBackup,
StartServer,
StopServer,
RestartServer,
CreateUser,
DeleteUser,
ListServers,
SendCommand,
)
from app.classes.web.websocket_handler import SocketHandler from app.classes.web.websocket_handler import SocketHandler
from app.classes.web.static_handler import CustomStaticHandler from app.classes.web.static_handler import CustomStaticHandler
from app.classes.web.upload_handler import UploadHandler from app.classes.web.upload_handler import UploadHandler
@ -139,11 +150,20 @@ class Webserver:
(r"/server/(.*)", ServerHandler, handler_args), (r"/server/(.*)", ServerHandler, handler_args),
(r"/ajax/(.*)", AjaxHandler, handler_args), (r"/ajax/(.*)", AjaxHandler, handler_args),
(r"/files/(.*)", FileHandler, handler_args), (r"/files/(.*)", FileHandler, handler_args),
(r"/api/stats/servers", ServersStats, handler_args),
(r"/api/stats/node", NodeStats, handler_args),
(r"/ws", SocketHandler, handler_args), (r"/ws", SocketHandler, handler_args),
(r"/upload", UploadHandler, handler_args), (r"/upload", UploadHandler, handler_args),
(r"/status", StatusHandler, handler_args), (r"/status", StatusHandler, handler_args),
# API Routes
(r"/api/v1/stats/servers", ServersStats, handler_args),
(r"/api/v1/stats/node", NodeStats, handler_args),
(r"/api/v1/server/send_command", SendCommand, handler_args),
(r"/api/v1/server/backup", ServerBackup, handler_args),
(r"/api/v1/server/start", StartServer, handler_args),
(r"/api/v1/server/stop", StopServer, handler_args),
(r"/api/v1/server/restart", RestartServer, handler_args),
(r"/api/v1/list_servers", ListServers, handler_args),
(r"/api/v1/users/create_user", CreateUser, handler_args),
(r"/api/v1/users/delete_user", DeleteUser, handler_args),
] ]
app = tornado.web.Application( app = tornado.web.Application(

View File

@ -1,21 +1,26 @@
{ {
"https": true, "http_port": 8000,
"http_port": 8000, "https_port": 8443,
"https_port": 8443, "language": "en_EN",
"language": "en_EN", "cookie_expire": 30,
"cookie_expire": 30, "cookie_secret": "random",
"cookie_secret": "random", "apikey_secret": "random",
"apikey_secret": "random", "show_errors": true,
"show_errors": true, "history_max_age": 7,
"history_max_age": 7, "stats_update_frequency": 30,
"stats_update_frequency": 30, "delete_default_json": false,
"delete_default_json": false, "show_contribute_link": true,
"show_contribute_link": true, "virtual_terminal_lines": 70,
"virtual_terminal_lines": 70, "max_log_lines": 700,
"max_log_lines": 700, "max_audit_entries": 300,
"max_audit_entries": 300, "disabled_language_files": [
"disabled_language_files": ["lol_EN.json", ""], "lol_EN.json",
"stream_size_GB": 1, ""
"keywords": ["help", "chunk"], ],
"allow_nsfw_profile_pictures": false "stream_size_GB": 1,
"keywords": [
"help",
"chunk"
],
"allow_nsfw_profile_pictures": false
} }