Add plugin custom command execution functionality to bwcli

This commit is contained in:
Théophile Diot 2024-04-02 12:39:22 +01:00
parent 8f253c3f2a
commit 46ab8432d4
No known key found for this signature in database
GPG key ID: 248FEA4BAE400D06
5 changed files with 264 additions and 45 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 907 KiB

After

Width:  |  Height:  |  Size: 910 KiB

View file

@ -5,10 +5,11 @@ from json import dumps, loads
from operator import itemgetter
from time import time
from dotenv import dotenv_values
from os import getenv, sep
from os import environ, getenv, sep
from os.path import join
from pathlib import Path
from redis import StrictRedis, Sentinel
from subprocess import STDOUT, run
from sys import path as sys_path
from typing import Any, Optional, Tuple
@ -279,3 +280,44 @@ class CLI(ApiCaller):
cli_str += "\n"
return True, cli_str
def custom(self, plugin_id: str, command: str, *args: str, debug: bool = False) -> Tuple[bool, str]:
if not Path(sep, "usr", "share", "bunkerweb", "db").exists():
raise Exception("This command can only be executed on the scheduler")
from Database import Database # type: ignore
db = Database(self.__logger, sqlalchemy_string=self.__get_variable("DATABASE_URI", None))
found = False
plugin_type = "core"
file_name = None
for db_plugin in db.get_plugins():
if db_plugin["id"] == plugin_id:
found = True
plugin_type = db_plugin["type"]
file_name = db_plugin["bwcli"].get(command, None)
break
if not found:
return False, f"Plugin {plugin_id} not found"
elif not file_name:
return False, f"Command {command} not found for plugin {plugin_id}"
command_path = (
Path(sep, "usr", "share", "bunkerweb", "core", plugin_id)
if plugin_type == "core"
else (
Path(sep, "etc", "bunkerweb", "plugins", plugin_id) if plugin_type == "external" else Path(sep, "etc", "bunkerweb", "pro", "plugins", plugin_id)
)
).joinpath("bwcli", file_name)
if not command_path.is_file():
return False, f"Command {command} not found for plugin {plugin_id} (file {command_path} not found)"
proc = run([command_path, *args], stdout=STDOUT, stderr=STDOUT, check=False, env=db.get_config() | environ | ({"LOG_LEVEL": "DEBUG"} if debug else {}))
if proc.returncode != 0:
return False, f"Command {command} failed"
return True, ""

View file

@ -50,6 +50,13 @@ if __name__ == "__main__":
# Bans subparser
parser_bans = subparsers.add_parser("bans", help="list current bans")
# Plugin subparser
parser_plugin = subparsers.add_parser("plugin", help="execute a custom command from a plugin")
parser_plugin.add_argument("plugin_id", help="the plugin id that you want to execute the command on")
parser_plugin.add_argument("command", type=str, help="the command to execute on the plugin")
parser_plugin.add_argument("arg", nargs="*", help="the arguments to pass to the command")
parser_plugin.add_argument("-d", "--debug", action="store_true", help="sets the LOG_LEVEL env variable to DEBUG")
# Parse args
args = parser.parse_args()
@ -64,12 +71,17 @@ if __name__ == "__main__":
ret, err = cli.ban(args.ip, args.exp, args.reason)
elif args.command == "bans":
ret, err = cli.bans()
else:
ret, err = cli.custom(args.plugin_id, args.command, *args.arg, debug=args.debug)
if not ret:
logger.error(f"CLI command status : ❌ (fail)\n{err}")
_exit(1)
else:
logger.info(f"CLI command status : ✔️ (success)\n{err}")
if err:
err = f"\n{err}"
logger.info(f"CLI command status : ✔️ (success){err}")
_exit(0)
except SystemExit as se:

View file

@ -29,6 +29,7 @@ from model import (
Custom_configs,
Selects,
Users,
BwcliCommands,
Metadata,
)
@ -489,6 +490,30 @@ class Database:
to_put = []
with self.__db_session() as session:
db_plugins = session.query(Plugins).with_entities(Plugins.id).all()
db_ids = []
if db_plugins:
db_ids = [plugin.id for plugin in db_plugins]
ids = [plugin["id"] for plugin in default_plugins]
missing_ids = [plugin for plugin in db_ids if plugin not in ids]
if missing_ids:
# Remove plugins that are no longer in the list
session.query(Plugins).filter(Plugins.id.in_(missing_ids)).delete()
session.query(Plugin_pages).filter(Plugin_pages.plugin_id.in_(missing_ids)).delete()
session.query(BwcliCommands).filter(BwcliCommands.plugin_id.in_(missing_ids)).delete()
for plugin_job in session.query(Jobs).with_entities(Jobs.name).filter(Jobs.plugin_id.in_(missing_ids)):
session.query(Jobs_cache).filter(Jobs_cache.job_name == plugin_job.name).delete()
session.query(Jobs).filter(Jobs.name == plugin_job.name).delete()
for plugin_setting in session.query(Settings).with_entities(Settings.id).filter(Settings.plugin_id.in_(missing_ids)):
session.query(Selects).filter(Selects.setting_id == plugin_setting.id).delete()
session.query(Services_settings).filter(Services_settings.setting_id == plugin_setting.id).delete()
session.query(Global_values).filter(Global_values.setting_id == plugin_setting.id).delete()
session.query(Settings).filter(Settings.id == plugin_setting.id).delete()
for plugins in default_plugins:
if not isinstance(plugins, list):
plugins = [plugins]
@ -496,6 +521,7 @@ class Database:
for plugin in plugins:
settings = {}
jobs = []
commands = {}
if "id" not in plugin:
settings = plugin
plugin = {
@ -509,6 +535,9 @@ class Database:
settings = plugin.pop("settings", {})
jobs = plugin.pop("jobs", [])
plugin.pop("page", False)
commands = plugin.pop("bwcli", {})
if not isinstance(commands, dict):
commands = {}
db_plugin = session.query(Plugins).filter_by(id=plugin["id"]).first()
if db_plugin:
@ -664,21 +693,32 @@ class Database:
session.query(Jobs_cache).filter(Jobs_cache.job_name == job["name"]).delete()
session.query(Jobs).filter(Jobs.name == job["name"]).update(updates)
core_ui_path = Path(sep, "usr", "share", "bunkerweb", "core", plugin["id"], "ui")
path_ui = core_ui_path if core_ui_path.exists() else Path(sep, "etc", "bunkerweb", "plugins", plugin["id"], "ui")
path_ui = path_ui if path_ui.exists() else Path(sep, "etc", "bunkerweb", "pro", "plugins", plugin["id"], "ui")
plugin_path = (
Path(sep, "usr", "share", "bunkerweb", "core", plugin["id"])
if plugin.get("type", "core") == "core"
else (
Path(sep, "etc", "bunkerweb", "plugins", plugin["id"])
if plugin.get("type", "core") == "external"
else Path(sep, "etc", "bunkerweb", "pro", "plugins", plugin["id"])
)
)
if path_ui.exists():
path_ui = plugin_path.joinpath("ui")
db_plugin_page = (
session.query(Plugin_pages)
.with_entities(
Plugin_pages.template_checksum,
Plugin_pages.actions_checksum,
)
.filter_by(plugin_id=plugin["id"])
.first()
)
remove = not path_ui.is_dir() and db_plugin_page
if path_ui.is_dir():
remove = True
if {"template.html", "actions.py"}.issubset(listdir(str(path_ui))):
db_plugin_page = (
session.query(Plugin_pages)
.with_entities(
Plugin_pages.template_checksum,
Plugin_pages.actions_checksum,
)
.filter_by(plugin_id=plugin["id"])
.first()
)
template = path_ui.joinpath("template.html").read_bytes()
actions = path_ui.joinpath("actions.py").read_bytes()
template_checksum = sha256(template).hexdigest()
@ -705,20 +745,60 @@ class Database:
if updates:
self.logger.warning(f'Page for plugin "{plugin["id"]}" already exists, updating it with the new values')
session.query(Plugin_pages).filter(Plugin_pages.plugin_id == plugin["id"]).update(updates)
remove = False
else:
if db_plugin:
self.logger.warning(f'Page for plugin "{plugin["id"]}" does not exist, creating it')
to_put.append(
Plugin_pages(
plugin_id=plugin["id"],
template_file=template,
template_checksum=template_checksum,
actions_file=actions,
actions_checksum=actions_checksum,
)
)
remove = False
if db_plugin_page and remove:
self.logger.warning(f'Removing page for plugin "{plugin["id"]}" as it no longer exists')
session.query(Plugin_pages).filter_by(plugin_id=plugin["id"]).delete()
db_names = [command.name for command in session.query(BwcliCommands).with_entities(BwcliCommands.name).filter_by(plugin_id=plugin["id"])]
missing_names = [command for command in db_names if command not in commands]
if missing_names:
# Remove commands that are no longer in the list
self.logger.warning(f'Removing {len(missing_names)} commands from plugin "{plugin["id"]}" as they are no longer in the list')
session.query(BwcliCommands).filter(BwcliCommands.name.in_(missing_names), BwcliCommands.plugin_id == plugin["id"]).delete()
for command, file_name in commands.items():
db_command = session.query(BwcliCommands).with_entities(BwcliCommands.file_name).filter_by(name=command, plugin_id=plugin["id"]).first()
command_path = plugin_path.joinpath("bwcli", file_name)
if command not in db_names or not db_command:
if db_plugin:
self.logger.warning(f'Command "{command}" does not exist, creating it')
if not command_path.is_file():
self.logger.warning(f'Command "{command}"\'s file "{file_name}" does not exist in the plugin directory, skipping it')
continue
if db_plugin:
self.logger.warning(f'Page for plugin "{plugin["id"]}" does not exist, creating it')
to_put.append(BwcliCommands(name=command, plugin_id=plugin["id"], file_name=file_name))
else:
updates = {}
to_put.append(
Plugin_pages(
plugin_id=plugin["id"],
template_file=template,
template_checksum=template_checksum,
actions_file=actions,
actions_checksum=actions_checksum,
)
)
if file_name != db_command.file_name:
updates[BwcliCommands.file_name] = file_name
if updates:
self.logger.warning(f'Command "{command}" already exists, updating it with the new values')
if not command_path.is_file():
self.logger.warning(f'Command "{command}"\'s file "{file_name}" does not exist in the plugin directory, removing it')
session.query(BwcliCommands).filter_by(name=command, plugin_id=plugin["id"]).delete()
continue
session.query(BwcliCommands).filter_by(name=command, plugin_id=plugin["id"]).update(updates)
try:
session.add_all(to_put)
@ -1267,6 +1347,7 @@ class Database:
# Remove plugins that are no longer in the list
session.query(Plugins).filter(Plugins.id.in_(missing_ids)).delete()
session.query(Plugin_pages).filter(Plugin_pages.plugin_id.in_(missing_ids)).delete()
session.query(BwcliCommands).filter(BwcliCommands.plugin_id.in_(missing_ids)).delete()
for plugin_job in session.query(Jobs).with_entities(Jobs.name).filter(Jobs.plugin_id.in_(missing_ids)):
session.query(Jobs_cache).filter(Jobs_cache.job_name == plugin_job.name).delete()
@ -1282,6 +1363,9 @@ class Database:
settings = plugin.pop("settings", {})
jobs = plugin.pop("jobs", [])
page = plugin.pop("page", False)
commands = plugin.pop("bwcli", {})
if not isinstance(commands, dict):
commands = {}
plugin["type"] = _type
db_plugin = (
session.query(Plugins)
@ -1458,22 +1542,33 @@ class Database:
session.query(Jobs_cache).filter(Jobs_cache.job_name == job["name"]).delete()
session.query(Jobs).filter(Jobs.name == job["name"]).update(updates)
tmp_ui_path = Path(sep, "var", "tmp", "bunkerweb", "ui", plugin["id"], "ui")
path_ui = tmp_ui_path if tmp_ui_path.exists() else Path(sep, "etc", "bunkerweb", "plugins", plugin["id"], "ui")
path_ui = path_ui if path_ui.exists() else Path(sep, "etc", "bunkerweb", "pro", "plugins", plugin["id"], "ui")
plugin_path = Path(sep, "var", "tmp", "bunkerweb", "ui", plugin["id"])
plugin_path = (
plugin_path
if plugin_path.is_dir()
else (
Path(sep, "etc", "bunkerweb", "plugins", plugin["id"])
if _type == "external"
else Path(sep, "etc", "bunkerweb", "pro", "plugins", plugin["id"])
)
)
if path_ui.exists():
path_ui = plugin_path.joinpath("ui")
db_plugin_page = (
session.query(Plugin_pages)
.with_entities(
Plugin_pages.template_checksum,
Plugin_pages.actions_checksum,
)
.filter_by(plugin_id=plugin["id"])
.first()
)
remove = not path_ui.is_dir() and db_plugin_page
if path_ui.is_dir():
remove = True
if {"template.html", "actions.py"}.issubset(listdir(str(path_ui))):
db_plugin_page = (
session.query(Plugin_pages)
.with_entities(
Plugin_pages.template_checksum,
Plugin_pages.actions_checksum,
)
.filter_by(plugin_id=plugin["id"])
.first()
)
if not db_plugin_page:
changes = True
template = path_ui.joinpath("template.html").read_bytes()
@ -1488,6 +1583,7 @@ class Database:
actions_checksum=sha256(actions).hexdigest(),
)
)
remove = False
else:
updates = {}
template_path = path_ui.joinpath("template.html")
@ -1515,6 +1611,43 @@ class Database:
changes = True
session.query(Plugin_pages).filter(Plugin_pages.plugin_id == plugin["id"]).update(updates)
remove = False
if db_plugin_page and remove:
changes = True
session.query(Plugin_pages).filter(Plugin_pages.plugin_id == plugin["id"]).delete()
db_names = [command.name for command in session.query(BwcliCommands).with_entities(BwcliCommands.name).filter_by(plugin_id=plugin["id"])]
missing_names = [command for command in db_names if command not in commands]
if missing_names:
# Remove commands that are no longer in the list
session.query(BwcliCommands).filter(BwcliCommands.name.in_(missing_names), BwcliCommands.plugin_id == plugin["id"]).delete()
for command, file_name in commands.items():
db_command = session.query(BwcliCommands).with_entities(BwcliCommands.file_name).filter_by(name=command, plugin_id=plugin["id"]).first()
command_path = plugin_path.joinpath("bwcli", file_name)
if command not in db_names or not db_command:
if not command_path.is_file():
self.logger.warning(f'Command "{command}"\'s file "{file_name}" does not exist in the plugin directory, skipping it')
continue
changes = True
to_put.append(BwcliCommands(name=command, plugin_id=plugin["id"], file_name=file_name))
else:
updates = {}
if file_name != db_command.file_name:
updates[BwcliCommands.file_name] = file_name
if updates:
changes = True
if not command_path.is_file():
session.query(BwcliCommands).filter_by(name=command, plugin_id=plugin["id"]).delete()
continue
session.query(BwcliCommands).filter_by(name=command, plugin_id=plugin["id"]).update(updates)
continue
changes = True
@ -1559,11 +1692,19 @@ class Database:
job["reload"] = job.get("reload", False)
to_put.append(Jobs(plugin_id=plugin["id"], **job))
if page:
tmp_ui_path = Path(sep, "var", "tmp", "bunkerweb", "ui", plugin["id"], "ui")
path_ui = tmp_ui_path if tmp_ui_path.exists() else Path(sep, "etc", "bunkerweb", "plugins", plugin["id"], "ui")
path_ui = path_ui if path_ui.exists() else Path(sep, "etc", "bunkerweb", "pro", "plugins", plugin["id"], "ui")
plugin_path = Path(sep, "var", "tmp", "bunkerweb", "ui", plugin["id"])
plugin_path = (
plugin_path
if plugin_path.is_dir()
else (
Path(sep, "etc", "bunkerweb", "plugins", plugin["id"])
if _type == "external"
else Path(sep, "etc", "bunkerweb", "pro", "plugins", plugin["id"])
)
)
if page:
path_ui = plugin_path.joinpath("ui")
if path_ui.exists():
if {"template.html", "actions.py"}.issubset(listdir(str(path_ui))):
db_plugin_page = (
@ -1615,6 +1756,13 @@ class Database:
if updates:
session.query(Plugin_pages).filter(Plugin_pages.plugin_id == plugin["id"]).update(updates)
for command, file_name in plugin.get("bwcli", {}).items():
if not plugin_path.joinpath("bwcli", file_name).is_file():
self.logger.warning(f'Command "{command}"\'s file "{file_name}" does not exist in the plugin directory, skipping it')
continue
to_put.append(BwcliCommands(name=command, plugin_id=plugin["id"], file_name=file_name))
if changes:
with suppress(ProgrammingError, OperationalError):
metadata = session.query(Metadata).get(1)
@ -1656,6 +1804,7 @@ class Database:
"method": plugin.method,
"page": page is not None,
"settings": {},
"bwcli": {},
"checksum": plugin.checksum,
} | ({"data": plugin.data} if with_data else {})
@ -1689,6 +1838,9 @@ class Database:
select.value for select in session.query(Selects).with_entities(Selects.value).filter_by(setting_id=setting.id)
]
for command in session.query(BwcliCommands).with_entities(BwcliCommands.name, BwcliCommands.file_name).filter_by(plugin_id=plugin.id):
data["bwcli"][command.name] = command.file_name
plugins.append(data)
return plugins

View file

@ -62,6 +62,7 @@ class Plugins(Base):
settings = relationship("Settings", back_populates="plugin", cascade="all, delete-orphan")
jobs = relationship("Jobs", back_populates="plugin", cascade="all, delete-orphan")
pages = relationship("Plugin_pages", back_populates="plugin", cascade="all")
commands = relationship("BwcliCommands", back_populates="plugin", cascade="all")
class Settings(Base):
@ -210,6 +211,18 @@ class Users(Base):
method = Column(METHODS_ENUM, nullable=False, default="manual")
class BwcliCommands(Base):
__tablename__ = "bw_cli_commands"
__table_args__ = (UniqueConstraint("plugin_id", "name"),)
id = Column(Integer, primary_key=True, default=1)
name = Column(String(64), nullable=False)
plugin_id = Column(String(64), ForeignKey("bw_plugins.id", onupdate="cascade", ondelete="cascade"), nullable=False)
file_name = Column(String(256), nullable=False)
plugin = relationship("Plugins", back_populates="commands")
class Metadata(Base):
__tablename__ = "bw_metadata"