Refactor web UI when running actions and sort out dependencies

This commit is contained in:
Théophile Diot 2024-08-19 16:26:14 +01:00
parent d4751a9d3f
commit a681284bf7
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
39 changed files with 716 additions and 688 deletions

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
data = kwargs["app"].bw_instances_utils.get_metrics("antibot")
data = kwargs["bw_instances_utils"].get_metrics("antibot")
return {
"counter_failed_challenges": {
"value": data.get("counter_failed_challenges", 0),

View file

@ -4,9 +4,9 @@ from logging import debug
from traceback import format_exc
def pre_render(app, *args, **kwargs):
def pre_render(*args, **kwargs):
try:
backup_file = app.db.get_job_cache_file("backup-data", "backup.json")
backup_file = kwargs["db"].get_job_cache_file("backup-data", "backup.json")
debug(f"backup_file: {backup_file}")
data = loads(backup_file or "{}")

View file

@ -5,7 +5,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
# Here we will have a list { 'counter_403': X, 'counter_401': Y ... }
data = kwargs["app"].bw_instances_utils.get_metrics("badbehavior")
data = kwargs["bw_instances_utils"].get_metrics("badbehavior")
# Format to fit [{code: 403, count: X}, {code: 401, count: Y} ...]
format_data = [{"code": int(key.split("_")[1]), "count": int(value)} for key, value in data.items()]
format_data.sort(key=itemgetter("count"), reverse=True)

View file

@ -11,7 +11,7 @@ def pre_render(**kwargs):
}
try:
data = kwargs["app"].bw_instances_utils.get_metrics("blacklist")
data = kwargs["bw_instances_utils"].get_metrics("blacklist")
for key in metrics:
metrics[key]["value"] = data.get(key, 0)
return metrics

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
ping_data = kwargs["app"].bw_instances_utils.get_ping("bunkernet")
ping_data = kwargs["bw_instances_utils"].get_ping("bunkernet")
return {"ping_status": {"title": "BUNKERNET STATUS", "value": ping_data["status"]}}
except BaseException:
print(format_exc(), flush=True)

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
data = kwargs["app"].bw_instances_utils.get_metrics("cors")
data = kwargs["bw_instances_utils"].get_metrics("cors")
return {
"counter_failed_cors": {
"value": data.get("counter_failed_cors", 0),

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
data = kwargs["app"].bw_instances_utils.get_metrics("country")
data = kwargs["bw_instances_utils"].get_metrics("country")
return {
"counter_failed_country": {
"value": data.get("counter_failed_country", 0),

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
data = kwargs["app"].bw_instances_utils.get_metrics("dnsbl")
data = kwargs["bw_instances_utils"].get_metrics("dnsbl")
return {
"counter_failed_dnsbl": {
"value": data.get("counter_failed_dnsbl", 0),

View file

@ -5,7 +5,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
# Here we will have a list { 'counter_403': X, 'counter_401': Y ... }
data = kwargs["app"].bw_instances_utils.get_metrics("errors")
data = kwargs["bw_instances_utils"].get_metrics("errors")
# Format to fit [{code: 403, count: X}, {code: 401, count: Y} ...]
format_data = [{"code": int(key.split("_")[1]), "count": int(value)} for key, value in data.items()]
format_data.sort(key=itemgetter("count"), reverse=True)

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
data = kwargs["app"].bw_instances_utils.get_metrics("greylist")
data = kwargs["bw_instances_utils"].get_metrics("greylist")
return {
"counter_failed_greylist": {
"value": data.get("counter_failed_greylist", 0),

View file

@ -5,7 +5,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
# Here we will have a list { 'limit_uri_url1': X, 'limit_uri_url2': Y ... }
data = kwargs["app"].bw_instances_utils.get_metrics("limit")
data = kwargs["bw_instances_utils"].get_metrics("limit")
format_data = []
# Format to fit [{url: "url1", count: X}, {url: "url2", count: Y} ...]
for key, value in data.items():

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
data = kwargs["app"].bw_instances_utils.get_metrics("misc")
data = kwargs["bw_instances_utils"].get_metrics("misc")
return {
"counter_failed_default": {

View file

@ -6,7 +6,7 @@ def pre_render(**kwargs):
data = {}
error = ""
try:
ping_data = kwargs["app"].bw_instances_utils.get_ping("redis")
ping_data = kwargs["bw_instances_utils"].get_ping("redis")
ping = {"ping_status": {"title": "REDIS STATUS", "value": ping_data["status"]}}
except BaseException:
print(format_exc(), flush=True)
@ -14,7 +14,7 @@ def pre_render(**kwargs):
ping = {"ping_status": {"title": "REDIS STATUS", "value": "error"}}
try:
metrics = kwargs["app"].bw_instances_utils.get_metrics("redis")
metrics = kwargs["bw_instances_utils"].get_metrics("redis")
data = {
"counter_redis_nb_keys": {
"value": metrics.get("redis_nb_keys", 0),

View file

@ -5,7 +5,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
# Here we will have a list { 'counter_403': X, 'counter_401': Y ... }
data = kwargs["app"].bw_instances_utils.get_metrics("reversescan")
data = kwargs["bw_instances_utils"].get_metrics("reversescan")
# Format to fit [{code: 403, count: X}, {code: 401, count: Y} ...]
format_data = [{"port": int(key.split("_")[-1]), "count": value} for key, value in data.items()]
format_data.sort(key=itemgetter("count"), reverse=True)

View file

@ -3,7 +3,7 @@ from traceback import format_exc
def pre_render(**kwargs):
try:
data = kwargs["app"].bw_instances_utils.get_metrics("whitelist")
data = kwargs["bw_instances_utils"].get_metrics("whitelist")
return {
"counter_passed_whitelist": {
"value": data.get("counter_passed_whitelist", 0),

View file

@ -2078,7 +2078,9 @@ class Database:
return ""
def update_external_plugins(self, plugins: List[Dict[str, Any]], *, _type: Literal["external", "pro"] = "external", delete_missing: bool = True) -> str:
def update_external_plugins(
self, plugins: List[Dict[str, Any]], *, _type: Literal["external", "ui", "pro"] = "external", delete_missing: bool = True
) -> str:
"""Update external plugins from the database"""
to_put = []
changes = False
@ -2869,6 +2871,38 @@ class Database:
return ""
def delete_plugin(self, plugin_id: str, method: str) -> str:
"""Delete a plugin from the database."""
with self._db_session() as session:
plugin = session.query(Plugins).filter_by(id=plugin_id, method=method).first()
if not plugin:
return f"Plugin with id {plugin_id} and method {method} not found"
session.query(Plugins).filter_by(id=plugin_id, method=method).delete()
session.query(Settings).filter_by(plugin_id=plugin_id).delete()
session.query(Selects).filter(Selects.setting_id.in_(session.query(Settings).filter_by(plugin_id=plugin_id).with_entities(Settings.id))).delete()
session.query(Jobs).filter_by(plugin_id=plugin_id).delete()
session.query(Jobs_cache).filter_by(plugin_id=plugin_id).delete()
session.query(Jobs_runs).filter_by(plugin_id=plugin_id).delete()
session.query(Plugin_pages).filter_by(plugin_id=plugin_id).delete()
session.query(Bw_cli_commands).filter_by(plugin_id=plugin_id).delete()
session.query(Templates).filter_by(plugin_id=plugin_id).delete()
session.query(Template_steps).filter(
Template_steps.template_id.in_(session.query(Templates).filter_by(plugin_id=plugin_id).with_entities(Templates.id))
).delete()
session.query(Template_settings).filter(
Template_settings.template_id.in_(session.query(Templates).filter_by(plugin_id=plugin_id).with_entities(Templates.id))
).delete()
session.query(Template_custom_configs).filter(
Template_custom_configs.template_id.in_(session.query(Templates).filter_by(plugin_id=plugin_id).with_entities(Templates.id))
).delete()
try:
session.commit()
except BaseException as e:
return str(e)
return ""
def get_plugins(self, *, _type: Literal["all", "external", "pro"] = "all", with_data: bool = False) -> List[Dict[str, Any]]:
"""Get all plugins from the database."""
plugins = []

View file

@ -32,7 +32,7 @@ INTEGRATIONS_ENUM = Enum(
name="integrations_enum",
)
STREAM_TYPES_ENUM = Enum("no", "yes", "partial", name="stream_types_enum")
PLUGIN_TYPES_ENUM = Enum("core", "external", "pro", name="plugin_types_enum")
PLUGIN_TYPES_ENUM = Enum("core", "external", "ui", "pro", name="plugin_types_enum")
PRO_STATUS_ENUM = Enum("active", "invalid", "expired", "suspended", name="pro_status_enum")
INSTANCE_TYPE_ENUM = Enum("static", "container", "pod", name="instance_type_enum")
INSTANCE_STATUS_ENUM = Enum("loading", "up", "down", name="instance_status_enum")

15
src/ui/dependencies.py Normal file
View file

@ -0,0 +1,15 @@
from logging import getLogger
from os import sep
from pathlib import Path
from src.config import Config
from src.instance import InstancesUtils
from src.ui_data import UIData
from ui_database import UIDatabase
DB = UIDatabase(getLogger("UI"), log=False)
DATA = UIData(Path(sep, "var", "tmp", "bunkerweb").joinpath("ui_data.json"))
BW_CONFIG = Config(DB)
BW_INSTANCES_UTILS = InstancesUtils(DB)

View file

@ -7,7 +7,6 @@ from secrets import token_urlsafe
from sys import exit, path as sys_path
from time import sleep
for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("api",), ("db",))]:
if deps_path not in sys_path:
sys_path.append(deps_path)
@ -56,6 +55,8 @@ def on_starting(server):
RUN_DIR.mkdir(parents=True, exist_ok=True)
LIB_DIR.mkdir(parents=True, exist_ok=True)
TMP_DIR.joinpath("ui_data.json").write_text("{}", encoding="utf-8")
LOGGER = setup_logger("UI", getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO")))
FLASK_SECRET = getenv("FLASK_SECRET")
@ -191,3 +192,4 @@ def on_exit(server):
RUN_DIR.joinpath("ui.pid").unlink(missing_ok=True)
TMP_DIR.joinpath("ui.healthy").unlink(missing_ok=True)
TMP_DIR.joinpath(".flask_secret").unlink(missing_ok=True)
TMP_DIR.joinpath("ui_data.json").unlink(missing_ok=True)

View file

@ -1,10 +1,9 @@
#!/usr/bin/env python3
from contextlib import suppress
from os import _exit, getenv, sep
from os import getenv, sep
from os.path import join
from secrets import token_urlsafe
from sys import path as sys_path
from pathlib import Path
for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("api",), ("db",))]:
if deps_path not in sys_path:
@ -15,19 +14,11 @@ from flask import Flask, Response, flash, jsonify, make_response, redirect, rend
from flask_login import current_user, LoginManager, login_required, logout_user
from flask_principal import ActionNeed, identity_loaded, Permission, Principal, RoleNeed, TypeNeed, UserNeed
from flask_wtf.csrf import CSRFProtect, CSRFError
from json import JSONDecodeError, dumps, loads as json_loads
from json import dumps
from signal import SIGINT, signal, SIGTERM
from subprocess import PIPE, Popen, call
from time import time
from logger import setup_logger # type: ignore
from src.instance import InstancesUtils
from src.custom_config import CustomConfig
from src.config import Config
from src.reverse_proxied import ReverseProxied
from src.totp import Totp
from src.ui_data import UIData
from pages.bans import bans
from pages.cache import cache
@ -47,75 +38,27 @@ from pages.services import services
from pages.setup import setup
from pages.totp import totp
from dependencies import BW_CONFIG, DATA, DB
from models import AnonymousUser
from ui_database import UIDatabase
from utils import check_settings
TMP_DIR = Path(sep, "var", "tmp", "bunkerweb")
LIB_DIR = Path(sep, "var", "lib", "bunkerweb")
def stop_gunicorn():
p = Popen(["pgrep", "-f", "gunicorn"], stdout=PIPE)
out, _ = p.communicate()
pid = out.strip().decode().split("\n")[0]
call(["kill", "-SIGTERM", pid])
def stop(status, _stop=True):
Path(sep, "var", "run", "bunkerweb", "ui.pid").unlink(missing_ok=True)
TMP_DIR.joinpath("ui.healthy").unlink(missing_ok=True)
if _stop is True:
stop_gunicorn()
_exit(status)
def handle_stop(signum, frame):
app.logger.info("Caught stop operation")
app.logger.info("Stopping web ui ...")
stop(0, False)
from utils import TMP_DIR, LOGGER, check_settings, handle_stop, stop
signal(SIGINT, handle_stop)
signal(SIGTERM, handle_stop)
sbin_nginx_path = Path(sep, "usr", "sbin", "nginx")
# Flask app
app = Flask(__name__, static_url_path="/", static_folder="static", template_folder="templates")
with app.app_context():
PROXY_NUMBERS = int(getenv("PROXY_NUMBERS", "1"))
app.wsgi_app = ReverseProxied(app.wsgi_app, x_for=PROXY_NUMBERS, x_proto=PROXY_NUMBERS, x_host=PROXY_NUMBERS, x_prefix=PROXY_NUMBERS)
app.logger = setup_logger("UI", getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO")))
FLASK_SECRET = getenv("FLASK_SECRET")
if not FLASK_SECRET:
if not TMP_DIR.joinpath(".flask_secret").is_file():
app.logger.error("The FLASK_SECRET environment variable is missing and the .flask_secret file is missing, exiting ...")
LOGGER.error("The FLASK_SECRET environment variable is missing and the .flask_secret file is missing, exiting ...")
stop(1)
FLASK_SECRET = TMP_DIR.joinpath(".flask_secret").read_text(encoding="utf-8").strip()
TOTP_SECRETS = getenv("TOTP_SECRETS", "")
if TOTP_SECRETS:
try:
TOTP_SECRETS = json_loads(TOTP_SECRETS)
except JSONDecodeError:
x = 1
tmp_secrets = {}
for secret in TOTP_SECRETS.strip().split(" "):
if secret:
tmp_secrets[x] = secret
x += 1
TOTP_SECRETS = tmp_secrets.copy()
del tmp_secrets
if not TOTP_SECRETS:
if not LIB_DIR.joinpath(".totp_secrets.json").is_file():
app.logger.error("The TOTP_SECRETS environment variable is missing and the .totp_secrets.json file is missing, exiting ...")
stop(1)
TOTP_SECRETS = json_loads(LIB_DIR.joinpath(".totp_secrets.json").read_text(encoding="utf-8"))
app.config["SECRET_KEY"] = FLASK_SECRET
app.config["SESSION_COOKIE_NAME"] = "__Host-bw_ui_session"
@ -142,8 +85,6 @@ with app.app_context():
login_manager.login_view = "login.login_page"
login_manager.anonymous_user = AnonymousUser
app.db = UIDatabase(app.logger, log=False)
# Declare functions for jinja2
app.jinja_env.globals.update(
check_settings=check_settings,
@ -156,38 +97,32 @@ with app.app_context():
csrf = CSRFProtect()
csrf.init_app(app)
app.bw_instances_utils = InstancesUtils(app.db)
app.bw_config = Config(app.db)
app.bw_custom_configs = CustomConfig()
app.data = UIData(TMP_DIR.joinpath("ui_data.json"))
app.totp = Totp(app, TOTP_SECRETS)
@app.context_processor
def inject_variables():
app.data.load_from_file()
metadata = app.db.get_metadata()
DATA.load_from_file()
metadata = DB.get_metadata()
changes_ongoing = any(
v
for k, v in app.db.get_metadata().items()
for k, v in DB.get_metadata().items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
)
if not changes_ongoing and app.data.get("PRO_LOADING"):
app.data["PRO_LOADING"] = False
if not changes_ongoing and DATA.get("PRO_LOADING"):
DATA["PRO_LOADING"] = False
if not changes_ongoing and metadata["failover"]:
flash(
"The last changes could not be applied because it creates a configuration error on NGINX, please check the logs for more information. The configured fell back to the last working one.",
"error",
)
elif not changes_ongoing and not metadata["failover"] and app.data.get("CONFIG_CHANGED", False):
elif not changes_ongoing and not metadata["failover"] and DATA.get("CONFIG_CHANGED", False):
flash("The last changes have been applied successfully.", "success")
app.data["CONFIG_CHANGED"] = False
DATA["CONFIG_CHANGED"] = False
# Keep only plugins with a page to display on sidebar
plugins_page = [{"id": plugin.get("id"), "name": plugin.get("name")} for plugin in app.bw_config.get_plugins() if plugin.get("page", False)]
plugins_page = [{"id": plugin.get("id"), "name": plugin.get("name")} for plugin in BW_CONFIG.get_plugins() if plugin.get("page", False)]
# check that is value is in tuple
return dict(
@ -198,10 +133,10 @@ def inject_variables():
pro_services=metadata["pro_services"],
pro_expire=metadata["pro_expire"].strftime("%Y-%m-%d") if metadata["pro_expire"] else "Unknown",
pro_overlapped=metadata["pro_overlapped"],
plugins=app.bw_config.get_plugins(),
pro_loading=app.data.get("PRO_LOADING", False),
plugins=BW_CONFIG.get_plugins(),
pro_loading=DATA.get("PRO_LOADING", False),
bw_version=metadata["version"],
is_readonly=app.data.get("READONLY_MODE", False),
is_readonly=DATA.get("READONLY_MODE", False),
username=current_user.get_id() if current_user.is_authenticated else "",
)
@ -244,14 +179,14 @@ def set_security_headers(response):
@login_manager.user_loader
def load_user(username):
ui_user = app.db.get_ui_user(username=username)
ui_user = DB.get_ui_user(username=username)
if not ui_user:
app.logger.warning(f"Couldn't get the user {username} from the database.")
LOGGER.warning(f"Couldn't get the user {username} from the database.")
return None
ui_user.list_roles = [role.role_name for role in ui_user.roles]
for role in ui_user.list_roles:
ui_user.list_permissions.extend(app.db.get_ui_role_permissions(role))
ui_user.list_permissions.extend(DB.get_ui_role_permissions(role))
if ui_user.totp_secret:
ui_user.list_recovery_codes = [recovery_code.code for recovery_code in ui_user.recovery_codes]
@ -285,7 +220,7 @@ def handle_csrf_error(_):
:param e: The exception object
:return: A template with the error message and a 401 status code.
"""
app.logger.error(f"CSRF token is missing or invalid for {request.path} by {current_user.get_id()}")
LOGGER.error(f"CSRF token is missing or invalid for {request.path} by {current_user.get_id()}")
session.clear()
logout_user()
flash("Wrong CSRF token !", "error")
@ -296,8 +231,8 @@ def handle_csrf_error(_):
@app.before_request
def before_request():
app.data.load_from_file()
if app.data.get("SERVER_STOPPING", False):
DATA.load_from_file()
if DATA.get("SERVER_STOPPING", False):
response = make_response(jsonify({"message": "Server is shutting down, try again later."}), 503)
response.headers["Retry-After"] = 30 # Clients should retry after 30 seconds # type: ignore
return response
@ -306,51 +241,45 @@ def before_request():
if not request.path.startswith(("/css", "/images", "/js", "/json", "/webfonts")):
if (
app.db.database_uri
and app.db.readonly
DB.database_uri
and DB.readonly
and (
datetime.now(timezone.utc) - datetime.fromisoformat(app.data.get("LAST_DATABASE_RETRY", "1970-01-01T00:00:00")).replace(tzinfo=timezone.utc)
datetime.now(timezone.utc) - datetime.fromisoformat(DATA.get("LAST_DATABASE_RETRY", "1970-01-01T00:00:00")).replace(tzinfo=timezone.utc)
> timedelta(minutes=1)
)
):
try:
app.db.retry_connection(pool_timeout=1)
app.db.retry_connection(log=False)
app.data["READONLY_MODE"] = False
app.logger.info("The database is no longer read-only, defaulting to read-write mode")
DB.retry_connection(pool_timeout=1)
DB.retry_connection(log=False)
DATA["READONLY_MODE"] = False
LOGGER.info("The database is no longer read-only, defaulting to read-write mode")
except BaseException:
try:
app.db.retry_connection(readonly=True, pool_timeout=1)
app.db.retry_connection(readonly=True, log=False)
DB.retry_connection(readonly=True, pool_timeout=1)
DB.retry_connection(readonly=True, log=False)
except BaseException:
if app.db.database_uri_readonly:
if DB.database_uri_readonly:
with suppress(BaseException):
app.db.retry_connection(fallback=True, pool_timeout=1)
app.db.retry_connection(fallback=True, log=False)
app.data["READONLY_MODE"] = True
app.data["LAST_DATABASE_RETRY"] = (
app.db.last_connection_retry.isoformat() if app.db.last_connection_retry else datetime.now(timezone.utc).isoformat()
)
elif not app.data.get("READONLY_MODE", False) and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
DB.retry_connection(fallback=True, pool_timeout=1)
DB.retry_connection(fallback=True, log=False)
DATA["READONLY_MODE"] = True
DATA["LAST_DATABASE_RETRY"] = DB.last_connection_retry.isoformat() if DB.last_connection_retry else datetime.now(timezone.utc).isoformat()
elif not DATA.get("READONLY_MODE", False) and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
try:
app.db.test_write()
app.data["READONLY_MODE"] = False
DB.test_write()
DATA["READONLY_MODE"] = False
except BaseException:
app.data["READONLY_MODE"] = True
app.data["LAST_DATABASE_RETRY"] = (
app.db.last_connection_retry.isoformat() if app.db.last_connection_retry else datetime.now(timezone.utc).isoformat()
)
DATA["READONLY_MODE"] = True
DATA["LAST_DATABASE_RETRY"] = DB.last_connection_retry.isoformat() if DB.last_connection_retry else datetime.now(timezone.utc).isoformat()
else:
try:
app.db.test_read()
DB.test_read()
except BaseException:
app.data["LAST_DATABASE_RETRY"] = (
app.db.last_connection_retry.isoformat() if app.db.last_connection_retry else datetime.now(timezone.utc).isoformat()
)
DATA["LAST_DATABASE_RETRY"] = DB.last_connection_retry.isoformat() if DB.last_connection_retry else datetime.now(timezone.utc).isoformat()
app.db.readonly = app.data.get("READONLY_MODE", False)
DB.readonly = DATA.get("READONLY_MODE", False)
if app.db.readonly:
if DB.readonly:
flash("Database connection is in read-only mode : no modification possible.", "error")
if current_user.is_authenticated:
@ -362,10 +291,10 @@ def before_request():
return redirect(url_for("totp.totp_page", next=request.form.get("next")))
passed = False
elif current_user.last_login_ip != request.remote_addr:
app.logger.warning(f"User {current_user.get_id()} tried to access his session with a different IP address.")
LOGGER.warning(f"User {current_user.get_id()} tried to access his session with a different IP address.")
passed = False
elif session.get("user_agent") != request.headers.get("User-Agent"):
app.logger.warning(f"User {current_user.get_id()} tried to access his session with a different User-Agent.")
LOGGER.warning(f"User {current_user.get_id()} tried to access his session with a different User-Agent.")
passed = False
if not passed:
@ -377,7 +306,7 @@ def before_request():
@app.route("/", strict_slashes=False)
def index():
if app.db.get_ui_user():
if DB.get_ui_user():
if current_user.is_authenticated: # type: ignore
return redirect(url_for("home.home_page"))
return redirect(url_for("login.login_page"), 301)
@ -399,23 +328,23 @@ def check():
@app.route("/check_reloading")
@login_required
def check_reloading():
app.data.load_from_file()
DATA.load_from_file()
if not app.data.get("RELOADING", False) or app.data.get("LAST_RELOAD", 0) + 60 < time():
if app.data.get("RELOADING", False):
app.logger.warning("Reloading took too long, forcing the state to be reloaded")
if not DATA.get("RELOADING", False) or DATA.get("LAST_RELOAD", 0) + 60 < time():
if DATA.get("RELOADING", False):
LOGGER.warning("Reloading took too long, forcing the state to be reloaded")
flash("Forced the status to be reloaded", "error")
app.data["RELOADING"] = False
DATA["RELOADING"] = False
for f in app.data.get("TO_FLASH", []):
for f in DATA.get("TO_FLASH", []):
if f["type"] == "error":
flash(f["content"], "error")
else:
flash(f["content"])
app.data["TO_FLASH"] = []
DATA["TO_FLASH"] = []
return jsonify({"reloading": app.data.get("RELOADING", False)})
return jsonify({"reloading": DATA.get("RELOADING", False)})
BLUEPRINTS = (bans, cache, configs, global_config, home, instances, jobs, modes, login, logout, logs, plugins, profile, reports, services, setup, totp)

View file

@ -4,12 +4,15 @@ from json import dumps, loads as json_loads
from math import floor
from time import time
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from redis import Redis, Sentinel
from builder.bans import bans_builder # type: ignore
from dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DB
from utils import LOGGER
from pages.utils import get_remain, handle_error, verify_data_in_form
bans = Blueprint("bans", __name__)
@ -20,7 +23,7 @@ bans = Blueprint("bans", __name__)
def bans_page():
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "bans")
# Check variables
@ -28,7 +31,7 @@ def bans_page():
verify_data_in_form(data={"data": None}, err_message="Missing data parameter on /bans.", redirect_url="bans")
redis_client = None
db_config = current_app.bw_config.get_config(
db_config = BW_CONFIG.get_config(
global_only=True,
methods=False,
filtered_settings=(
@ -131,7 +134,7 @@ def bans_page():
unban = json_loads(unban.replace('"', '"').replace("'", '"'))
except BaseException:
flash(f"Invalid unban: {unban}, skipping it ...", "error")
current_app.logger.exception(f"Couldn't unban {unban['ip']}")
LOGGER.exception(f"Couldn't unban {unban['ip']}")
continue
if "ip" not in unban:
@ -142,7 +145,7 @@ def bans_page():
if not redis_client.delete(f"bans_ip_{unban['ip']}"):
flash(f"Couldn't unban {unban['ip']} on redis", "error")
resp = current_app.bw_instances_utils.unban(unban["ip"])
resp = BW_INSTANCES_UTILS.unban(unban["ip"])
if resp:
flash(f"Couldn't unban {unban['ip']} on the following instances: {', '.join(resp)}", "error")
else:
@ -173,7 +176,7 @@ def bans_page():
flash(f"Couldn't ban {ban['ip']} on redis", "error")
redis_client.expire(f"bans_ip_{ban['ip']}", int(ban_end))
resp = current_app.bw_instances_utils.ban(ban["ip"], ban_end, reason)
resp = BW_INSTANCES_UTILS.ban(ban["ip"], ban_end, reason)
if resp:
flash(f"Couldn't ban {ban['ip']} on the following instances: {', '.join(resp)}", "error")
else:
@ -190,7 +193,7 @@ def bans_page():
continue
exp = redis_client.ttl(key)
bans.append({"ip": ip, "exp": exp} | json_loads(data)) # type: ignore
instance_bans = current_app.bw_instances_utils.get_bans()
instance_bans = BW_INSTANCES_UTILS.get_bans()
# Prepare data
timestamp_now = time()

View file

@ -1,8 +1,9 @@
from os.path import join, sep
from flask import Blueprint, current_app, render_template
from flask import Blueprint, render_template
from flask_login import login_required
from dependencies import BW_CONFIG, DB
from utils import path_to_dict
@ -18,10 +19,8 @@ def cache_page(): # TODO: refactor this function
path_to_dict(
join(sep, "var", "cache", "bunkerweb"),
is_cache=True,
db_data=current_app.db.get_jobs_cache_files(),
services=current_app.bw_config.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME",))
.get("SERVER_NAME", "")
.split(" "),
db_data=DB.get_jobs_cache_files(),
services=BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME",)).get("SERVER_NAME", "").split(" "),
)
],
)

View file

@ -2,10 +2,12 @@ from copy import deepcopy
from os.path import join, sep
from bs4 import BeautifulSoup
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from utils import path_to_dict
from dependencies import BW_CONFIG, DATA, DB
from utils import LOGGER, PLUGIN_NAME_RX, path_to_dict
from pages.utils import handle_error, verify_data_in_form
@ -15,10 +17,10 @@ configs = Blueprint("configs", __name__)
@configs.route("/configs", methods=["GET", "POST"])
@login_required
def configs_page(): # TODO: refactor this function
db_configs = current_app.db.get_custom_configs()
db_configs = DB.get_custom_configs()
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "configs")
operation = ""
@ -39,10 +41,10 @@ def configs_page(): # TODO: refactor this function
# TODO: revamp this to use a path but a form to edit the content
operation = current_app.bw_custom_configs.check_path(variables["path"])
# operation = BW_CUSTOM_CONFIGS.check_path(variables["path"])
if operation:
return handle_error(operation, "configs", True)
# if operation:
# return handle_error(operation, "configs", True)
old_name = variables.get("old_name", "").replace(".conf", "")
name = variables.get("name", old_name).replace(".conf", "")
@ -69,7 +71,7 @@ def configs_page(): # TODO: refactor this function
# New or edit a config
if request.form["operation"] in ("new", "edit"):
if not current_app.bw_custom_configs.check_name(name):
if not PLUGIN_NAME_RX.match(name):
return handle_error(
f"Invalid {variables['type']} name. (Can only contain numbers, letters, underscores, dots and hyphens (min 4 characters and max 64))",
"configs",
@ -107,12 +109,12 @@ def configs_page(): # TODO: refactor this function
del db_configs[index]
operation = f"Deleted config {name}{f' for service {service_id}' if service_id else ''}"
error = current_app.db.save_custom_configs([config for config in db_configs if config["method"] == "ui"], "ui")
error = DB.save_custom_configs([config for config in db_configs if config["method"] == "ui"], "ui")
if error:
current_app.logger.error(f"Could not save custom configs: {error}")
LOGGER.error(f"Could not save custom configs: {error}")
return handle_error("Couldn't save custom configs", "configs", True)
current_app.data["CONFIG_CHANGED"] = True
DATA["CONFIG_CHANGED"] = True
flash(operation)
@ -124,9 +126,7 @@ def configs_page(): # TODO: refactor this function
path_to_dict(
join(sep, "etc", "bunkerweb", "configs"),
db_data=db_configs,
services=current_app.bw_config.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME",))
.get("SERVER_NAME", "")
.split(" "),
services=BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME",)).get("SERVER_NAME", "").split(" "),
)
],
)

View file

@ -4,11 +4,13 @@ from json import dumps
from threading import Thread
from time import time
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from builder.global_config import global_config_builder # type: ignore
from dependencies import BW_CONFIG, DATA, DB
from pages.utils import handle_error, manage_bunkerweb, wait_applying
@ -19,7 +21,7 @@ global_config = Blueprint("global_config", __name__)
@login_required
def global_config_page():
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "global_config")
# Check variables
@ -27,7 +29,7 @@ def global_config_page():
del variables["csrf_token"]
# Edit check fields and remove already existing ones
config = current_app.db.get_config(methods=True, with_drafts=True)
config = DB.get_config(methods=True, with_drafts=True)
services = config["SERVER_NAME"]["value"].split(" ")
for variable, value in variables.copy().items():
setting = config.get(variable, {"value": None, "global": True})
@ -35,7 +37,7 @@ def global_config_page():
del variables[variable]
continue
variables = current_app.bw_config.check_variables(variables, config)
variables = BW_CONFIG.check_variables(variables, config)
if not variables:
return handle_error("The global configuration was not edited because no values were changed.", "global_config", True)
@ -46,7 +48,7 @@ def global_config_page():
if setting and setting["global"] and (setting["value"] != value or setting["value"] == config.get(variable, {"value": None})["value"]):
variables[f"{service}_{variable}"] = value
db_metadata = current_app.db.get_metadata()
db_metadata = DB.get_metadata()
def update_global_config(threaded: bool = False):
wait_applying()
@ -54,20 +56,20 @@ def global_config_page():
manage_bunkerweb("global_config", variables, threaded=threaded)
if "PRO_LICENSE_KEY" in variables:
current_app.data["PRO_LOADING"] = True
DATA["PRO_LOADING"] = True
if any(
v
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
current_app.data["RELOADING"] = True
current_app.data["LAST_RELOAD"] = time()
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(target=update_global_config, args=(True,)).start()
else:
update_global_config()
current_app.data["CONFIG_CHANGED"] = True
DATA["CONFIG_CHANGED"] = True
with suppress(BaseException):
if config["PRO_LICENSE_KEY"]["value"] != variables["PRO_LICENSE_KEY"]:
@ -81,7 +83,7 @@ def global_config_page():
)
)
global_config = current_app.bw_config.get_config(global_only=True, methods=True)
plugins = current_app.bw_config.get_plugins()
global_config = BW_CONFIG.get_config(global_only=True, methods=True)
plugins = BW_CONFIG.get_plugins()
builder = global_config_builder({}, plugins, global_config)
return render_template("global-config.html", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))

View file

@ -2,7 +2,7 @@ from base64 import b64encode
from json import dumps
from os.path import basename
from flask import Blueprint, current_app, render_template
from flask import Blueprint, render_template
from flask_login import login_required
from requests import get
@ -10,6 +10,7 @@ from common_utils import get_version # type: ignore
from builder.home import home_builder # type: ignore
from dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DB
home = Blueprint("home", __name__)
@ -37,8 +38,8 @@ def home_page():
if r and r.status_code == 200:
remote_version = basename(r.url).strip().replace("v", "")
config = current_app.bw_config.get_config(with_drafts=True, filtered_settings=("SERVER_NAME",))
instances = current_app.bw_instances_utils.get_instances()
config = BW_CONFIG.get_config(with_drafts=True, filtered_settings=("SERVER_NAME",))
instances = BW_INSTANCES_UTILS.get_instances()
instance_health_count = 0
@ -62,7 +63,7 @@ def home_page():
services_autoconf_count += 1
services += 1
metadata = current_app.db.get_metadata()
metadata = DB.get_metadata()
data = {
"check_version": not remote_version or get_version() == remote_version,
@ -78,8 +79,8 @@ def home_page():
"pro_status": metadata["pro_status"],
"pro_services": metadata["pro_services"],
"pro_overlapped": metadata["pro_overlapped"],
"plugins_number": len(current_app.bw_config.get_plugins()),
"plugins_errors": current_app.db.get_plugins_errors(),
"plugins_number": len(BW_CONFIG.get_plugins()),
"plugins_errors": DB.get_plugins_errors(),
}
builder = home_builder(data)

View file

@ -3,11 +3,13 @@ from json import dumps
from threading import Thread
from time import time
from typing import Literal
from flask import Blueprint, current_app, redirect, render_template, request, url_for
from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import login_required
from builder.instances import instances_builder # type: ignore
from dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DATA, DB
from pages.utils import handle_error, manage_bunkerweb, verify_data_in_form
@ -22,7 +24,7 @@ def instances_page():
instances_methods = set()
instances_healths = set()
for instance in current_app.bw_instances_utils.get_instances():
for instance in BW_INSTANCES_UTILS.get_instances():
instances.append(
{
"hostname": instance.hostname,
@ -65,7 +67,7 @@ def instances_new():
next=True,
)
db_config = current_app.bw_config.get_config(global_only=True, methods=False, filtered_settings=("API_HTTP_PORT", "API_SERVER_NAME"))
db_config = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("API_HTTP_PORT", "API_SERVER_NAME"))
instance = {
"hostname": request.form["instance_hostname"].replace("http://", "").replace("https://", ""),
@ -75,11 +77,11 @@ def instances_new():
"method": "ui",
}
for db_instance in current_app.bw_instances_utils.get_instances():
for db_instance in BW_INSTANCES_UTILS.get_instances():
if db_instance.hostname == instance["hostname"]:
return handle_error(f"The hostname {instance['hostname']} is already in use.", "instances", True)
ret = current_app.db.add_instance(**instance)
ret = DB.add_instance(**instance)
if ret:
return handle_error(f"Couldn't create the instance in the database: {ret}", "instances", True)
@ -97,7 +99,7 @@ def instances_delete(instance_hostname: str):
)
delete_instance = None
for instance in current_app.bw_instances_utils.get_instances():
for instance in BW_INSTANCES_UTILS.get_instances():
if instance.hostname == instance_hostname:
delete_instance = instance
break
@ -107,7 +109,7 @@ def instances_delete(instance_hostname: str):
if delete_instance.method != "ui":
return handle_error(f"Instance {instance_hostname} is not a UI instance.", "instances", True)
ret = current_app.db.delete_instance(instance_hostname)
ret = DB.delete_instance(instance_hostname)
if ret:
return handle_error(f"Couldn't delete the instance in the database: {ret}", "instances", True)
@ -124,8 +126,8 @@ def instances_action(action: Literal["ping", "reload", "stop"]): # TODO: see if
next=True,
)
current_app.data["RELOADING"] = True
current_app.data["LAST_RELOAD"] = time()
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(
target=manage_bunkerweb,
name=f"Reloading instance {request.form['instance_hostname']}",

View file

@ -2,19 +2,21 @@ from base64 import b64encode
from io import BytesIO
from json import dumps
from flask import Blueprint, current_app, jsonify, render_template, request, send_file
from flask import Blueprint, jsonify, render_template, request, send_file
from flask_login import login_required
from werkzeug.utils import secure_filename
from builder.jobs import jobs_builder # type: ignore
from dependencies import DB
jobs = Blueprint("jobs", __name__)
@jobs.route("/jobs", methods=["GET"])
@login_required
def jobs_page():
builder = jobs_builder(current_app.db.get_jobs())
builder = jobs_builder(DB.get_jobs())
return render_template("jobs.html", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))
@ -29,7 +31,7 @@ def jobs_download():
if not plugin_id or not job_name or not file_name:
return jsonify({"status": "ko", "message": "plugin_id, job_name and file_name are required"}), 422
cache_file = current_app.db.get_job_cache_file(job_name, file_name, service_id=service_id, plugin_id=plugin_id)
cache_file = DB.get_job_cache_file(job_name, file_name, service_id=service_id, plugin_id=plugin_id)
if not cache_file:
return jsonify({"status": "ko", "message": "file not found"}), 404

View file

@ -1,15 +1,17 @@
from datetime import datetime, timezone
from flask import Blueprint, current_app, flash, redirect, render_template, request, session, url_for
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from flask_login import current_user, login_user
from dependencies import DB
from utils import LOGGER
login = Blueprint("login", __name__)
@login.route("/login", methods=["GET", "POST"])
def login_page():
admin_user = current_app.db.get_ui_user()
admin_user = DB.get_ui_user()
if not admin_user:
return redirect(url_for("setup.setup_page"))
elif current_user.is_authenticated: # type: ignore
@ -17,9 +19,9 @@ def login_page():
fail = False
if request.method == "POST" and "username" in request.form and "password" in request.form:
current_app.logger.warning(f"Login attempt from {request.remote_addr} with username \"{request.form['username']}\"")
LOGGER.warning(f"Login attempt from {request.remote_addr} with username \"{request.form['username']}\"")
ui_user = current_app.db.get_ui_user(username=request.form["username"])
ui_user = DB.get_ui_user(username=request.form["username"])
if ui_user and ui_user.username == request.form["username"] and ui_user.check_password(request.form["password"]):
# log the user in
session["user_agent"] = request.headers.get("User-Agent")
@ -29,13 +31,13 @@ def login_page():
ui_user.last_login_ip = request.remote_addr
ui_user.login_count += 1
current_app.db.mark_ui_user_login(ui_user.username, ui_user.last_login_at, ui_user.last_login_ip)
DB.mark_ui_user_login(ui_user.username, ui_user.last_login_at, ui_user.last_login_ip)
if not login_user(ui_user, remember=request.form.get("remember") == "on"):
flash("Couldn't log you in, please try again", "error")
return (render_template("login.html", error="Couldn't log you in, please try again"),)
current_app.logger.info(
LOGGER.info(
f"User {ui_user.username} logged in successfully for the {str(ui_user.login_count) + ('th' if 10 <= ui_user.login_count % 100 <= 20 else {1: 'st', 2: 'nd', 3: 'rd'}.get(ui_user.login_count % 10, 'th'))} time"
+ (" with remember me" if request.form.get("remember") == "on" else "")
)

View file

@ -1,15 +1,16 @@
from base64 import b64encode
from json import dumps
from flask import Blueprint, current_app, redirect, render_template, request, url_for
from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import login_required
from builder.advanced_mode import advanced_mode_builder # type: ignore
from builder.easy_mode import easy_mode_builder # type: ignore
from builder.raw_mode import raw_mode_builder # type: ignore
from pages.utils import get_service_data, handle_error, update_service
from dependencies import BW_CONFIG, DB
from pages.utils import get_service_data, handle_error, update_service
modes = Blueprint("modes", __name__)
@ -18,7 +19,7 @@ modes = Blueprint("modes", __name__)
@login_required
def services_modes():
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "services")
config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode = get_service_data("modes")
@ -37,17 +38,17 @@ def services_modes():
mode = request.args.get("mode")
service_name = request.args.get("service_name")
total_config = current_app.db.get_config(methods=True, with_drafts=True)
total_config = DB.get_config(methods=True, with_drafts=True)
service_names = total_config["SERVER_NAME"]["value"].split(" ")
if service_name and service_name not in service_names:
return handle_error("Service name not found to access advanced mode.", "services")
global_config = current_app.bw_config.get_config(global_only=True, methods=True)
plugins = current_app.bw_config.get_plugins()
global_config = BW_CONFIG.get_config(global_only=True, methods=True)
plugins = BW_CONFIG.get_plugins()
builder = None
templates_db = current_app.db.get_templates()
templates_db = DB.get_templates()
if mode == "raw":
builder = raw_mode_builder(templates_db, plugins, global_config, total_config, service_name or "new", not service_name)

View file

@ -1,14 +1,17 @@
from base64 import b64encode
from copy import deepcopy
from importlib.machinery import SourceFileLoader
from io import BytesIO
from json import JSONDecodeError, dumps, loads as json_loads
from os import listdir
from os.path import basename, dirname, isabs, join, sep
from pathlib import Path
from shutil import move, rmtree
from sys import path as sys_path
from tarfile import CompressionError, HeaderError, ReadError, TarError, open as tar_open
from threading import Thread
from time import time
from typing import Optional, Union
from uuid import uuid4
from zipfile import BadZipFile, ZipFile
@ -21,283 +24,320 @@ from common_utils import bytes_hash # type: ignore
from builder.plugins import plugins_builder # type: ignore
from pages.utils import PLUGIN_ID_RX, PLUGIN_KEYS, TMP_DIR, error_message, handle_error, run_action, verify_data_in_form, wait_applying
from dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DATA, DB
from utils import LOGGER, PLUGIN_NAME_RX, TMP_DIR
from pages.utils import PLUGIN_ID_RX, PLUGIN_KEYS, error_message, handle_error, verify_data_in_form, wait_applying
plugins = Blueprint("plugins", __name__)
def run_action(plugin: str, function_name: str = "", *, tmp_dir: Optional[Path] = None) -> Union[dict, Response]:
message = ""
if not tmp_dir:
page = DB.get_plugin_page(plugin)
if not page:
return {"status": "ko", "code": 404, "message": "The plugin does not have a page"}
try:
# Try to import the plugin's custom page
tmp_dir = TMP_DIR.joinpath("ui", "action", str(uuid4()))
tmp_dir.mkdir(parents=True, exist_ok=True)
with tar_open(fileobj=BytesIO(page), mode="r:gz") as tar:
tar.extractall(tmp_dir)
tmp_dir = tmp_dir.joinpath("ui")
except BaseException as e:
LOGGER.error(f"An error occurred while extracting the plugin: {e}")
return {"status": "ko", "code": 500, "message": "An error occurred while extracting the plugin, see logs for more details"}
try:
action_file = tmp_dir.joinpath("actions.py")
if not action_file.is_file():
return {"status": "ko", "code": 404, "message": "The plugin does not have an action file"}
sys_path.append(tmp_dir.as_posix())
loader = SourceFileLoader("actions", action_file.as_posix())
actions = loader.load_module()
except BaseException as e:
sys_path.pop()
if function_name != "pre_render":
rmtree(tmp_dir, ignore_errors=True)
LOGGER.error(f"An error occurred while importing the plugin: {e}")
return {"status": "ko", "code": 500, "message": "An error occurred while importing the plugin, see logs for more details"}
exception = None
res = None
message = None
try:
# Try to get the custom plugin custom function and call it
method = getattr(actions, function_name or plugin)
queries = request.args.to_dict()
try:
data = request.json or {}
except BaseException:
data = {}
res = method(app=current_app, db=DB, instances_utils=BW_INSTANCES_UTILS, args=queries, data=data)
except AttributeError as e:
if function_name == "pre_render":
sys_path.pop()
return {"status": "ok", "code": 200, "message": "The plugin does not have a pre_render method"}
message = "The plugin does not have a method"
exception = e
except BaseException as e:
message = "An error occurred while executing the plugin"
exception = e
finally:
sys_path.pop()
if function_name != "pre_render":
rmtree(tmp_dir, ignore_errors=True)
if message:
LOGGER.error(message + (f": {exception}" if exception else ""))
if message or not isinstance(res, dict) and not res:
return {
"status": "ko",
"code": 500,
"message": message + ", see logs for more details" if message else "The plugin did not return a valid response",
}
if isinstance(res, Response):
return res
return {"status": "ok", "code": 200, "data": res}
@plugins.route("/plugins", methods=["GET", "POST"])
@login_required
def plugins_page():
tmp_ui_path = TMP_DIR.joinpath("ui")
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "plugins")
verify_data_in_form(
data={"operation": ("delete"), "type": None},
err_message="Missing type parameter for operation delete on /plugins.",
data={"csrf_token": None},
err_message="Missing csrf_token parameter on /plugins.",
redirect_url="plugins",
next=True,
)
error = 0
# Delete plugin
if request.form["operation"] == "delete":
# Upload plugins
if not tmp_ui_path.exists() or not listdir(str(tmp_ui_path)):
return handle_error("Please upload new plugins to reload plugins", "plugins", True)
# Check variables
variables = deepcopy(request.form.to_dict())
del variables["csrf_token"]
errors = 0
files_count = 0
new_plugins = []
new_plugins_ids = []
if variables["type"] in ("core", "pro"):
return handle_error(f"Can't delete {variables['type']} plugin {variables['name']}", "plugins", True)
for file in listdir(str(tmp_ui_path)):
if not tmp_ui_path.joinpath(file).is_file():
continue
db_metadata = current_app.db.get_metadata()
files_count += 1
folder_name = ""
temp_folder_name = file.split(".")[0]
temp_folder_path = tmp_ui_path.joinpath(temp_folder_name)
is_dir = False
def update_plugins(threaded: bool = False): # type: ignore
wait_applying()
plugins = current_app.bw_config.get_plugins(_type="external", with_data=True)
for x, plugin in enumerate(plugins):
if plugin["id"] == variables["name"]:
del plugins[x]
err = current_app.db.update_external_plugins(plugins)
if err:
message = f"Couldn't update external plugins to database: {err}"
if threaded:
current_app.data["TO_FLASH"].append({"content": message, "type": "error"})
else:
error_message(message)
else:
message = f"Deleted plugin {variables['name']} successfully"
if threaded:
current_app.data["TO_FLASH"].append({"content": message, "type": "success"})
else:
flash(message)
current_app.data["RELOADING"] = False
if any(
v
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
current_app.data["RELOADING"] = True
current_app.data["LAST_RELOAD"] = time()
Thread(target=update_plugins, args=(True,)).start()
else:
update_plugins()
else:
# Upload plugins
if not tmp_ui_path.exists() or not listdir(str(tmp_ui_path)):
return handle_error("Please upload new plugins to reload plugins", "plugins", True)
errors = 0
files_count = 0
new_plugins = []
new_plugins_ids = []
for file in listdir(str(tmp_ui_path)):
if not tmp_ui_path.joinpath(file).is_file():
continue
files_count += 1
folder_name = ""
temp_folder_name = file.split(".")[0]
temp_folder_path = tmp_ui_path.joinpath(temp_folder_name)
is_dir = False
try:
if file.endswith(".zip"):
try:
with ZipFile(str(tmp_ui_path.joinpath(file))) as zip_file:
try:
zip_file.getinfo("plugin.json")
except KeyError:
is_dir = True
zip_file.extractall(str(temp_folder_path))
except BadZipFile:
errors += 1
error = 1
message = f"{file} is not a valid zip file. ({folder_name or temp_folder_name})"
current_app.logger.exception(message)
flash(message, "error")
else:
try:
with tar_open(str(tmp_ui_path.joinpath(file)), errorlevel=2) as tar_file:
try:
tar_file.getmember("plugin.json")
except KeyError:
is_dir = True
try:
# deepcode ignore TarSlip: We don't need to check for tar slip as we are checking the files when they are uploaded
tar_file.extractall(str(temp_folder_path), filter="data")
except TypeError:
# deepcode ignore TarSlip: We don't need to check for tar slip as we are checking the files when they are uploaded
tar_file.extractall(str(temp_folder_path))
except ReadError:
errors += 1
error = 1
message = f"Couldn't read file {file} ({folder_name or temp_folder_name})"
current_app.logger.exception(message)
flash(message, "error")
except CompressionError:
errors += 1
error = 1
message = f"{file} is not a valid tar file ({folder_name or temp_folder_name})"
current_app.logger.exception(message)
flash(message, "error")
except HeaderError:
errors += 1
error = 1
message = f"The file plugin.json in {file} is not valid ({folder_name or temp_folder_name})"
current_app.logger.exception(message)
flash(message, "error")
if is_dir:
dirs = [d for d in listdir(str(temp_folder_path)) if temp_folder_path.joinpath(d).is_dir()]
if not dirs or len(dirs) > 1 or not temp_folder_path.joinpath(dirs[0], "plugin.json").is_file():
raise KeyError
for file_name in listdir(str(temp_folder_path.joinpath(dirs[0]))):
move(
str(temp_folder_path.joinpath(dirs[0], file_name)),
str(temp_folder_path.joinpath(file_name)),
)
rmtree(
str(temp_folder_path.joinpath(dirs[0])),
ignore_errors=True,
)
plugin_file = json_loads(temp_folder_path.joinpath("plugin.json").read_text(encoding="utf-8"))
if not all(key in plugin_file.keys() for key in PLUGIN_KEYS):
raise ValueError
folder_name = plugin_file["id"]
if not current_app.bw_custom_configs.check_name(folder_name):
try:
if file.endswith(".zip"):
try:
with ZipFile(str(tmp_ui_path.joinpath(file))) as zip_file:
try:
zip_file.getinfo("plugin.json")
except KeyError:
is_dir = True
zip_file.extractall(str(temp_folder_path))
except BadZipFile:
errors += 1
error = 1
flash(
f"Invalid plugin name for {temp_folder_name}. (Can only contain numbers, letters, underscores and hyphens (min 4 characters and max 64))",
"error",
)
raise Exception
plugin_content = BytesIO()
with tar_open(
fileobj=plugin_content,
mode="w:gz",
compresslevel=9,
) as tar:
tar.add(
str(temp_folder_path),
arcname=temp_folder_name,
recursive=True,
)
plugin_content.seek(0)
value = plugin_content.getvalue()
new_plugins.append(
plugin_file
| {
"type": "external",
"page": "ui" in listdir(str(temp_folder_path)),
"method": "ui",
"data": value,
"checksum": bytes_hash(value, algorithm="sha256"),
}
)
new_plugins_ids.append(folder_name)
except KeyError:
errors += 1
error = 1
flash(
f"{file} is not a valid plugin (plugin.json file is missing) ({folder_name or temp_folder_name})",
"error",
)
except JSONDecodeError as e:
errors += 1
error = 1
flash(
f"The file plugin.json in {file} is not valid ({e.msg}: line {e.lineno} column {e.colno} (char {e.pos})) ({folder_name or temp_folder_name})",
"error",
)
except ValueError:
errors += 1
error = 1
flash(
f"The file plugin.json is missing one or more of the following keys: <i>{', '.join(PLUGIN_KEYS)}</i> ({folder_name or temp_folder_name})",
"error",
)
except FileExistsError:
errors += 1
error = 1
flash(
f"A plugin named {folder_name} already exists",
"error",
)
except (TarError, OSError) as e:
errors += 1
error = 1
flash(str(e), "error")
except Exception as e:
errors += 1
error = 1
flash(str(e), "error")
finally:
if error != 1:
flash(f"Successfully created plugin: <b><i>{folder_name}</i></b>")
error = 0
if errors >= files_count:
return redirect(url_for("loading", next=url_for("plugins.plugins_page")))
db_metadata = current_app.db.get_metadata()
def update_plugins(threaded: bool = False):
wait_applying()
plugins = current_app.bw_config.get_plugins(_type="external", with_data=True)
for plugin in deepcopy(plugins):
if plugin["id"] in new_plugins_ids:
flash(f"Plugin {plugin['id']} already exists", "error")
del new_plugins[new_plugins_ids.index(plugin["id"])]
err = current_app.db.update_external_plugins(new_plugins, delete_missing=False)
if err:
message = f"Couldn't update external plugins to database: {err}"
if threaded:
current_app.data["TO_FLASH"].append({"content": message, "type": "error"})
else:
message = f"{file} is not a valid zip file. ({folder_name or temp_folder_name})"
LOGGER.exception(message)
flash(message, "error")
else:
message = "Plugins uploaded successfully"
if threaded:
current_app.data["TO_FLASH"].append({"content": message, "type": "success"})
else:
flash("Plugins uploaded successfully")
try:
with tar_open(str(tmp_ui_path.joinpath(file)), errorlevel=2) as tar_file:
try:
tar_file.getmember("plugin.json")
except KeyError:
is_dir = True
try:
# deepcode ignore TarSlip: We don't need to check for tar slip as we are checking the files when they are uploaded
tar_file.extractall(str(temp_folder_path), filter="data")
except TypeError:
# deepcode ignore TarSlip: We don't need to check for tar slip as we are checking the files when they are uploaded
tar_file.extractall(str(temp_folder_path))
except ReadError:
errors += 1
error = 1
message = f"Couldn't read file {file} ({folder_name or temp_folder_name})"
LOGGER.exception(message)
flash(message, "error")
except CompressionError:
errors += 1
error = 1
message = f"{file} is not a valid tar file ({folder_name or temp_folder_name})"
LOGGER.exception(message)
flash(message, "error")
except HeaderError:
errors += 1
error = 1
message = f"The file plugin.json in {file} is not valid ({folder_name or temp_folder_name})"
LOGGER.exception(message)
flash(message, "error")
current_app.data["RELOADING"] = False
if is_dir:
dirs = [d for d in listdir(str(temp_folder_path)) if temp_folder_path.joinpath(d).is_dir()]
if any(
v
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
current_app.data["RELOADING"] = True
current_app.data["LAST_RELOAD"] = time()
if not dirs or len(dirs) > 1 or not temp_folder_path.joinpath(dirs[0], "plugin.json").is_file():
raise KeyError
Thread(target=update_plugins, args=(True,)).start()
for file_name in listdir(str(temp_folder_path.joinpath(dirs[0]))):
move(
str(temp_folder_path.joinpath(dirs[0], file_name)),
str(temp_folder_path.joinpath(file_name)),
)
rmtree(
str(temp_folder_path.joinpath(dirs[0])),
ignore_errors=True,
)
plugin_file = json_loads(temp_folder_path.joinpath("plugin.json").read_text(encoding="utf-8"))
if not all(key in plugin_file.keys() for key in PLUGIN_KEYS):
raise ValueError
folder_name = plugin_file["id"]
if not PLUGIN_NAME_RX.match(folder_name):
errors += 1
error = 1
flash(
f"Invalid plugin name for {temp_folder_name}. (Can only contain numbers, letters, underscores and hyphens (min 4 characters and max 64))",
"error",
)
raise Exception
plugin_content = BytesIO()
with tar_open(
fileobj=plugin_content,
mode="w:gz",
compresslevel=9,
) as tar:
tar.add(
str(temp_folder_path),
arcname=temp_folder_name,
recursive=True,
)
plugin_content.seek(0)
value = plugin_content.getvalue()
new_plugins.append(
plugin_file
| {
"type": "external",
"page": "ui" in listdir(str(temp_folder_path)),
"method": "ui",
"data": value,
"checksum": bytes_hash(value, algorithm="sha256"),
}
)
new_plugins_ids.append(folder_name)
except KeyError:
errors += 1
error = 1
flash(
f"{file} is not a valid plugin (plugin.json file is missing) ({folder_name or temp_folder_name})",
"error",
)
except JSONDecodeError as e:
errors += 1
error = 1
flash(
f"The file plugin.json in {file} is not valid ({e.msg}: line {e.lineno} column {e.colno} (char {e.pos})) ({folder_name or temp_folder_name})",
"error",
)
except ValueError:
errors += 1
error = 1
flash(
f"The file plugin.json is missing one or more of the following keys: <i>{', '.join(PLUGIN_KEYS)}</i> ({folder_name or temp_folder_name})",
"error",
)
except FileExistsError:
errors += 1
error = 1
flash(
f"A plugin named {folder_name} already exists",
"error",
)
except (TarError, OSError) as e:
errors += 1
error = 1
flash(str(e), "error")
except Exception as e:
errors += 1
error = 1
flash(str(e), "error")
finally:
if error != 1:
flash(f"Successfully created plugin: <b><i>{folder_name}</i></b>")
error = 0
if errors >= files_count:
return redirect(url_for("loading", next=url_for("plugins.plugins_page")))
db_metadata = DB.get_metadata()
def update_plugins(threaded: bool = False):
wait_applying()
plugins = BW_CONFIG.get_plugins(_type="ui", with_data=True)
for plugin in deepcopy(plugins):
if plugin["id"] in new_plugins_ids:
flash(f"Plugin {plugin['id']} already exists", "error")
del new_plugins[new_plugins_ids.index(plugin["id"])]
err = DB.update_external_plugins(new_plugins, _type="ui", delete_missing=False)
if err:
message = f"Couldn't update ui plugins to database: {err}"
if threaded:
DATA["TO_FLASH"].append({"content": message, "type": "error"})
else:
flash(message, "error")
else:
update_plugins()
message = "Plugins uploaded successfully"
if threaded:
DATA["TO_FLASH"].append({"content": message, "type": "success"})
else:
flash("Plugins uploaded successfully")
DATA["RELOADING"] = False
if any(
v
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(target=update_plugins, args=(True,)).start()
else:
update_plugins()
return redirect(url_for("loading", next=url_for("plugins.plugins_page"), message="Reloading plugins"))
@ -305,14 +345,68 @@ def plugins_page():
if tmp_ui_path.is_dir():
rmtree(tmp_ui_path, ignore_errors=True)
builder = plugins_builder(current_app.db.get_plugins())
builder = plugins_builder(DB.get_plugins())
return render_template("plugins.html", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))
@plugins.route("/plugins/delete", methods=["POST"])
@login_required
def delete_plugin():
if DB.readonly:
return {"status": "ko", "message": "Database is in read-only mode"}, 403
verify_data_in_form(
data={"plugin_name": None},
err_message="Missing plugin name parameter on /plugins/delete.",
redirect_url="plugins",
next=True,
)
plugin = request.form["plugin_name"]
db_metadata = DB.get_metadata()
def update_plugins(threaded: bool = False): # type: ignore
wait_applying()
err = DB.delete_plugin(plugins, "ui")
if err:
if not err.startswith("Plugin with id"):
message = f"Couldn't delete plugin {plugin} in database: {err}"
else:
message = err
if threaded:
DATA["TO_FLASH"].append({"content": message, "type": "error"})
else:
error_message(message)
else:
message = f"Deleted plugin {plugin} successfully"
if threaded:
DATA["TO_FLASH"].append({"content": message, "type": "success"})
else:
flash(message)
DATA["RELOADING"] = False
if any(
v
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(target=update_plugins, args=(True,)).start()
else:
update_plugins()
return redirect(url_for("loading", next=url_for("plugins.plugins_page"), message=f"Deleting plugin {plugin}"))
@plugins.route("/plugins/upload", methods=["POST"])
@login_required
def upload_plugin():
if current_app.db.readonly:
if DB.readonly:
return {"status": "ko", "message": "Database is in read-only mode"}, 403
if not request.files:
@ -392,7 +486,7 @@ def custom_plugin(plugin: str):
if request.method == "GET":
# Check plugin's page
page = current_app.db.get_plugin_page(plugin)
page = DB.get_plugin_page(plugin)
if not page:
return error_message("The plugin does not have a page"), 404
@ -405,10 +499,10 @@ def custom_plugin(plugin: str):
tmp_page_dir = tmp_page_dir.joinpath("ui")
current_app.logger.debug(f"Plugin {plugin} page extracted successfully")
LOGGER.debug(f"Plugin {plugin} page extracted successfully")
# Case template, prepare data
plugins = current_app.bw_config.get_plugins()
plugins = BW_CONFIG.get_plugins()
plugin_id = None
curr_plugin = {}
is_used = False
@ -426,7 +520,7 @@ def custom_plugin(plugin: str):
if plugin_id is None:
return error_message("Plugin not found"), 404
config = current_app.db.get_config()
config = DB.get_config()
# Check if we are using metrics
for service in config.get("SERVER_NAME", "").split(" "):
@ -514,14 +608,14 @@ def custom_plugin(plugin: str):
action_result = run_action(plugin)
if isinstance(action_result, Response):
current_app.logger.info(f"Plugin {plugin} action executed successfully")
LOGGER.info(f"Plugin {plugin} action executed successfully")
return action_result
# case error
if action_result["status"] == "ko":
return error_message(action_result["message"]), action_result["code"]
current_app.logger.info(f"Plugin {plugin} action executed successfully")
LOGGER.info(f"Plugin {plugin} action executed successfully")
if request.content_type == "application/x-www-form-urlencoded":
return redirect(f"{url_for('plugins.plugins_page')}/{plugin}", code=303)

View file

@ -2,16 +2,18 @@ from base64 import b64encode
from json import dumps
from threading import Thread
from time import time
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for, session
from flask import Blueprint, flash, redirect, render_template, request, url_for, session
from flask_login import current_user, login_required, logout_user
from builder.profile import profile_builder # type: ignore
from utils import USER_PASSWORD_RX, gen_password_hash
from src.totp import totp as TOTP
from dependencies import BW_CONFIG, DATA, DB
from utils import LOGGER, USER_PASSWORD_RX, gen_password_hash
from pages.utils import handle_error, manage_bunkerweb, verify_data_in_form, wait_applying
profile = Blueprint("profile", __name__)
@ -20,7 +22,7 @@ profile = Blueprint("profile", __name__)
def profile_page():
totp_recovery_codes = None
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "profile")
verify_data_in_form(
@ -38,18 +40,18 @@ def profile_page():
variables = {"PRO_LICENSE_KEY": request.form["license"]}
variables = current_app.bw_config.check_variables(variables, {"PRO_LICENSE_KEY": request.form["license"]})
variables = BW_CONFIG.check_variables(variables, {"PRO_LICENSE_KEY": request.form["license"]})
if not variables:
return handle_error("The license key variable checks returned error", "profile", True)
# Force job to contact PRO API
# by setting the last check to None
metadata = current_app.db.get_metadata()
metadata = DB.get_metadata()
metadata["last_pro_check"] = None
current_app.db.set_pro_metadata(metadata)
DB.set_pro_metadata(metadata)
curr_changes = current_app.db.check_changes()
curr_changes = DB.check_changes()
# Reload instances
def update_global_config(threaded: bool = False):
@ -58,16 +60,16 @@ def profile_page():
if not manage_bunkerweb("global_config", variables, threaded=threaded):
message = "Checking license key to upgrade."
if threaded:
current_app.data["TO_FLASH"].append({"content": message, "type": "success"})
DATA["TO_FLASH"].append({"content": message, "type": "success"})
else:
flash(message)
current_app.data["PRO_LOADING"] = True
current_app.data["CONFIG_CHANGED"] = True
DATA["PRO_LOADING"] = True
DATA["CONFIG_CHANGED"] = True
if any(curr_changes.values()):
current_app.data["RELOADING"] = True
current_app.data["LAST_RELOAD"] = time()
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(target=update_global_config, args=(True,)).start()
else:
update_global_config()
@ -119,25 +121,25 @@ def profile_page():
if request.form["operation"] == "totp":
verify_data_in_form(data={"totp_token": None}, err_message="Missing totp token parameter on /account.", redirect_url="profile")
if not current_app.totp.verify_totp(
if not TOTP.verify_totp(
request.form["totp_token"], totp_secret=session.get("tmp_totp_secret", ""), user=current_user
) and not current_app.totp.verify_recovery_code(request.form["totp_token"], user=current_user):
) and not TOTP.verify_recovery_code(request.form["totp_token"], user=current_user):
return handle_error("The totp token is invalid. (totp)", "profile")
session["totp_validated"] = not bool(current_user.totp_secret)
totp_secret = None if bool(current_user.totp_secret) else session.pop("tmp_totp_secret", "")
if totp_secret and totp_secret != current_user.totp_secret:
totp_recovery_codes = current_app.totp.generate_recovery_codes()
totp_recovery_codes = TOTP.generate_recovery_codes()
flash(
"The recovery codes have been refreshed.\nPlease save them in a safe place. They will not be displayed again."
+ "\n".join(totp_recovery_codes),
"info",
) # TODO: Remove this when we have a way to display the recovery codes
current_app.logger.debug(f"totp recovery codes: {totp_recovery_codes or current_user.list_recovery_codes}")
LOGGER.debug(f"totp recovery codes: {totp_recovery_codes or current_user.list_recovery_codes}")
ret = current_app.db.update_ui_user(
ret = DB.update_ui_user(
username,
gen_password_hash(password),
totp_secret,
@ -159,8 +161,8 @@ def profile_page():
totp_qr_image = ""
if not bool(current_user.totp_secret):
session["tmp_totp_secret"] = current_app.totp.generate_totp_secret()
totp_qr_image = current_app.totp.generate_qrcode(current_user.get_id(), session["tmp_totp_secret"])
session["tmp_totp_secret"] = TOTP.generate_totp_secret()
totp_qr_image = TOTP.generate_qrcode(current_user.get_id(), session["tmp_totp_secret"])
builder = profile_builder(
current_user if current_user.is_authenticated else None,
@ -169,7 +171,7 @@ def profile_page():
"totp_image": totp_qr_image,
"totp_recovery_codes": totp_recovery_codes or current_user.list_recovery_codes,
"is_recovery_refreshed": bool(totp_recovery_codes),
"totp_secret": current_app.totp.get_totp_pretty_key(session.get("tmp_totp_secret", "")),
"totp_secret": TOTP.get_totp_pretty_key(session.get("tmp_totp_secret", "")),
},
)

View file

@ -2,9 +2,11 @@ from base64 import b64encode
from json import dumps
from math import floor
from flask import Blueprint, current_app, render_template
from flask import Blueprint, render_template
from flask_login import login_required
from dependencies import BW_INSTANCES_UTILS
from builder.reports import reports_builder # type: ignore
reports = Blueprint("reports", __name__)
@ -13,7 +15,7 @@ reports = Blueprint("reports", __name__)
@reports.route("/reports", methods=["GET"])
@login_required
def reports_page():
reports = current_app.bw_instances_utils.get_reports()
reports = BW_INSTANCES_UTILS.get_reports()
reasons = set()
countries = set()
methods = set()

View file

@ -1,13 +1,14 @@
from base64 import b64encode
from json import dumps
from flask import Blueprint, current_app, redirect, render_template, request, url_for
from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import login_required
from builder.services import services_builder # type: ignore
from pages.utils import get_service_data, handle_error, update_service
from dependencies import DB
from pages.utils import get_service_data, handle_error, update_service
services = Blueprint("services", __name__)
@ -16,7 +17,7 @@ services = Blueprint("services", __name__)
@login_required
def services_page():
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "services")
config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode = get_service_data("services")
@ -27,7 +28,7 @@ def services_page():
# Display services
services = []
tmp_config = current_app.db.get_config(methods=True, with_drafts=True).copy()
tmp_config = DB.get_config(methods=True, with_drafts=True).copy()
service_names = tmp_config["SERVER_NAME"]["value"].split(" ")
table_settings = (

View file

@ -4,8 +4,9 @@ from string import ascii_letters, digits
from threading import Thread
from time import time
from flask import Blueprint, Response, current_app, flash, redirect, render_template, request, url_for
from flask import Blueprint, Response, flash, redirect, render_template, request, url_for
from dependencies import BW_CONFIG, DATA, DB
from utils import USER_PASSWORD_RX, gen_password_hash
from pages.utils import REVERSE_PROXY_PATH, handle_error, manage_bunkerweb
@ -15,9 +16,9 @@ setup = Blueprint("setup", __name__)
@setup.route("/setup", methods=["GET", "POST"])
def setup_page():
db_config = current_app.bw_config.get_config(methods=False, filtered_settings=("SERVER_NAME", "MULTISITE", "USE_UI", "UI_HOST", "AUTO_LETS_ENCRYPT"))
db_config = BW_CONFIG.get_config(methods=False, filtered_settings=("SERVER_NAME", "MULTISITE", "USE_UI", "UI_HOST", "AUTO_LETS_ENCRYPT"))
admin_user = current_app.db.get_ui_user()
admin_user = DB.get_ui_user()
ui_reverse_proxy = False
for server_name in db_config["SERVER_NAME"].split(" "):
@ -28,7 +29,7 @@ def setup_page():
break
if request.method == "POST":
if current_app.db.readonly:
if DB.readonly:
return handle_error("Database is in read-only mode", "setup")
required_keys = []
@ -53,9 +54,7 @@ def setup_page():
"setup",
)
ret = current_app.db.create_ui_user(
request.form["admin_username"], gen_password_hash(request.form["admin_password"]), ["admin"], method="ui", admin=True
)
ret = DB.create_ui_user(request.form["admin_username"], gen_password_hash(request.form["admin_password"]), ["admin"], method="ui", admin=True)
if ret:
return handle_error(f"Couldn't create the admin user in the database: {ret}", "setup", False, "error")
@ -73,8 +72,8 @@ def setup_page():
if not REVERSE_PROXY_PATH.match(request.form["ui_host"]):
return handle_error("The hostname is not valid.", "setup")
current_app.data["RELOADING"] = True
current_app.data["LAST_RELOAD"] = time()
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
config = {
"SERVER_NAME": request.form["server_name"],
@ -95,7 +94,7 @@ def setup_page():
config["SELF_SIGNED_SSL_SUBJ"] = f"/CN={request.form['server_name']}/"
if not config.get("MULTISITE", "no") == "yes":
current_app.bw_config.edit_global_conf({"MULTISITE": "yes"}, check_changes=False)
BW_CONFIG.edit_global_conf({"MULTISITE": "yes"}, check_changes=False)
# deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish
Thread(

View file

@ -1,8 +1,11 @@
from flask import Blueprint, current_app, redirect, render_template, request, session, url_for
from flask import Blueprint, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required
from pages.utils import handle_error, verify_data_in_form
from src.totp import totp as TOTP
from dependencies import DB
from pages.utils import handle_error, verify_data_in_form
totp = Blueprint("totp", __name__)
@ -13,11 +16,11 @@ def totp_page():
if request.method == "POST":
verify_data_in_form(data={"totp_token": None}, err_message="No token provided on /totp.", redirect_url="totp")
if not current_app.totp.verify_totp(request.form["totp_token"], user=current_user):
recovery_code = current_app.totp.verify_recovery_code(request.form["totp_token"], user=current_user)
if not TOTP.verify_totp(request.form["totp_token"], user=current_user):
recovery_code = TOTP.verify_recovery_code(request.form["totp_token"], user=current_user)
if not recovery_code:
return handle_error("The token is invalid.", "totp")
current_app.db.use_ui_user_recovery_code(current_user.get_id(), recovery_code)
DB.use_ui_user_recovery_code(current_user.get_id(), recovery_code)
session["totp_validated"] = True
redirect(url_for("loading", next=request.form.get("next") or url_for("home.home_page"), message="Validating TOTP token."))

View file

@ -1,25 +1,20 @@
from base64 import b64encode
from copy import deepcopy
from datetime import datetime, timezone
from importlib.machinery import SourceFileLoader
from io import BytesIO
from os.path import sep
from pathlib import Path
from shutil import rmtree
from sys import path as sys_path
from tarfile import open as tar_open
from threading import Thread
from time import sleep, time
from typing import Any, Dict, Optional, Tuple, Union
from uuid import uuid4
from flask import Response, current_app, flash, redirect, request, url_for
from flask import Response, flash, redirect, request, url_for
from qrcode.main import QRCode
from regex import compile as re_compile
from src.instance import Instance
TMP_DIR = Path(sep, "var", "tmp", "bunkerweb")
from dependencies import BW_CONFIG, DATA, DB
from utils import LOGGER
LOG_RX = re_compile(r"^(?P<date>\d+/\d+/\d+\s\d+:\d+:\d+)\s\[(?P<level>[a-z]+)\]\s\d+#\d+:\s(?P<message>[^\n]+)$")
REVERSE_PROXY_PATH = re_compile(r"^(?P<host>https?://.{1,255}(:((6553[0-5])|(655[0-2]\d)|(65[0-4]\d{2})|(6[0-4]\d{3})|([1-5]\d{4})|([0-5]{0,5})|(\d{1,4})))?)$")
@ -31,9 +26,9 @@ def wait_applying():
current_time = datetime.now(timezone.utc)
ready = False
while not ready and (datetime.now(timezone.utc) - current_time).seconds < 120:
db_metadata = current_app.db.get_metadata()
db_metadata = DB.get_metadata()
if isinstance(db_metadata, str):
current_app.logger.error(f"An error occurred when checking for changes in the database : {db_metadata}")
LOGGER.error(f"An error occurred when checking for changes in the database : {db_metadata}")
elif not any(
v
for k, v in db_metadata.items()
@ -42,11 +37,11 @@ def wait_applying():
ready = True
continue
else:
current_app.logger.warning("Scheduler is already applying a configuration, retrying in 1s ...")
LOGGER.warning("Scheduler is already applying a configuration, retrying in 1s ...")
sleep(1)
if not ready:
current_app.logger.error("Too many retries while waiting for scheduler to apply configuration...")
LOGGER.error("Too many retries while waiting for scheduler to apply configuration...")
# TODO: Find a more elegant way to handle this
@ -54,45 +49,45 @@ def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: b
# Do the operation
error = 0
if "TO_FLASH" not in current_app.data:
current_app.data["TO_FLASH"] = []
if "TO_FLASH" not in DATA:
DATA["TO_FLASH"] = []
if method == "services":
if operation == "new":
operation, error = current_app.bw_config.new_service(args[0], is_draft=is_draft)
operation, error = BW_CONFIG.new_service(args[0], is_draft=is_draft)
elif operation == "edit":
operation, error = current_app.bw_config.edit_service(args[1], args[0], check_changes=(was_draft != is_draft or not is_draft), is_draft=is_draft)
operation, error = BW_CONFIG.edit_service(args[1], args[0], check_changes=(was_draft != is_draft or not is_draft), is_draft=is_draft)
elif operation == "delete":
operation, error = current_app.bw_config.delete_service(args[2], check_changes=(was_draft != is_draft or not is_draft))
operation, error = BW_CONFIG.delete_service(args[2], check_changes=(was_draft != is_draft or not is_draft))
elif method == "global_config":
operation, error = current_app.bw_config.edit_global_conf(args[0], check_changes=True)
operation, error = BW_CONFIG.edit_global_conf(args[0], check_changes=True)
if operation == "reload":
instance = Instance.from_hostname(args[0], current_app.db)
instance = Instance.from_hostname(args[0], DB)
if instance:
operation = instance.reload()
else:
operation = "The instance does not exist."
elif operation == "start":
instance = Instance.from_hostname(args[0], current_app.db)
instance = Instance.from_hostname(args[0], DB)
if instance:
operation = instance.start()
else:
operation = "The instance does not exist."
elif operation == "stop":
instance = Instance.from_hostname(args[0], current_app.db)
instance = Instance.from_hostname(args[0], DB)
if instance:
operation = instance.stop()
else:
operation = "The instance does not exist."
elif operation == "restart":
instance = Instance.from_hostname(args[0], current_app.db)
instance = Instance.from_hostname(args[0], DB)
if instance:
operation = instance.restart()
else:
operation = "The instance does not exist."
elif operation == "ping":
instance = Instance.from_hostname(args[0], current_app.db)
instance = Instance.from_hostname(args[0], DB)
if instance:
operation = instance.ping()[0]
else:
@ -103,125 +98,46 @@ def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: b
if operation:
if isinstance(operation, list):
for op in operation:
current_app.data["TO_FLASH"].append({"content": f"Reload failed for the instance {op}", "type": "error"})
DATA["TO_FLASH"].append({"content": f"Reload failed for the instance {op}", "type": "error"})
elif operation.startswith(("Can't", "The database is read-only")):
current_app.data["TO_FLASH"].append({"content": operation, "type": "error"})
DATA["TO_FLASH"].append({"content": operation, "type": "error"})
else:
current_app.data["TO_FLASH"].append({"content": operation, "type": "success"})
DATA["TO_FLASH"].append({"content": operation, "type": "success"})
if not threaded:
for f in current_app.data.get("TO_FLASH", []):
for f in DATA.get("TO_FLASH", []):
if f["type"] == "error":
flash(f["content"], "error")
else:
flash(f["content"])
current_app.data["TO_FLASH"] = []
DATA["TO_FLASH"] = []
current_app.data["RELOADING"] = False
DATA["RELOADING"] = False
return error
def run_action(plugin: str, function_name: str = "", *, tmp_dir: Optional[Path] = None) -> Union[dict, Response]:
message = ""
if not tmp_dir:
page = current_app.db.get_plugin_page(plugin)
if not page:
return {"status": "ko", "code": 404, "message": "The plugin does not have a page"}
try:
# Try to import the plugin's custom page
tmp_dir = TMP_DIR.joinpath("ui", "action", str(uuid4()))
tmp_dir.mkdir(parents=True, exist_ok=True)
with tar_open(fileobj=BytesIO(page), mode="r:gz") as tar:
tar.extractall(tmp_dir)
tmp_dir = tmp_dir.joinpath("ui")
except BaseException as e:
current_app.logger.error(f"An error occurred while extracting the plugin: {e}")
return {"status": "ko", "code": 500, "message": "An error occurred while extracting the plugin, see logs for more details"}
try:
action_file = tmp_dir.joinpath("actions.py")
if not action_file.is_file():
return {"status": "ko", "code": 404, "message": "The plugin does not have an action file"}
sys_path.append(tmp_dir.as_posix())
loader = SourceFileLoader("actions", action_file.as_posix())
actions = loader.load_module()
except BaseException as e:
sys_path.pop()
if function_name != "pre_render":
rmtree(tmp_dir, ignore_errors=True)
current_app.logger.error(f"An error occurred while importing the plugin: {e}")
return {"status": "ko", "code": 500, "message": "An error occurred while importing the plugin, see logs for more details"}
exception = None
res = None
message = None
try:
# Try to get the custom plugin custom function and call it
method = getattr(actions, function_name or plugin)
queries = request.args.to_dict()
try:
data = request.json or {}
except BaseException:
data = {}
res = method(current_app=current_app, args=queries, data=data)
except AttributeError as e:
if function_name == "pre_render":
sys_path.pop()
return {"status": "ok", "code": 200, "message": "The plugin does not have a pre_render method"}
message = "The plugin does not have a method"
exception = e
except BaseException as e:
message = "An error occurred while executing the plugin"
exception = e
finally:
sys_path.pop()
if function_name != "pre_render":
rmtree(tmp_dir, ignore_errors=True)
if message:
current_app.logger.error(message + (f": {exception}" if exception else ""))
if message or not isinstance(res, dict) and not res:
return {
"status": "ko",
"code": 500,
"message": message + ", see logs for more details" if message else "The plugin did not return a valid response",
}
if isinstance(res, Response):
return res
return {"status": "ok", "code": 200, "data": res}
def verify_data_in_form(
data: Optional[Dict[str, Union[Tuple, Any]]] = None, err_message: str = "", redirect_url: str = "", next: bool = False
) -> Union[bool, Response]:
current_app.logger.debug(f"Verifying data in form: {data}")
current_app.logger.debug(f"Request form: {request.form}")
if not request.json:
return handle_error("Invalid request", redirect_url, next, "error")
LOGGER.debug(f"Verifying data in form: {data}")
LOGGER.debug(f"Request form: {request.form}")
# Loop on each key in data
for key, values in (data or {}).items():
if key not in request.form:
return handle_error(f"Missing {key} in form", f"{redirect_url}.{redirect_url}_page", next, "error")
return handle_error(f"Missing {key} in form", redirect_url, next, "error")
# Case we want to only check if key is in form, we can skip the values check by setting values to falsy value
if not values:
continue
if request.form[key] not in values:
return handle_error(err_message, f"{redirect_url}.{redirect_url}_page", next, "error")
return handle_error(err_message, redirect_url, next, "error")
return True
@ -231,10 +147,10 @@ def handle_error(err_message: str = "", redirect_url: str = "", next: bool = Fal
flash(err_message, "error")
if log == "error":
current_app.logger.error(err_message)
LOGGER.error(err_message)
if log == "exception":
current_app.logger.exception(err_message)
LOGGER.exception(err_message)
if not redirect_url:
return False
@ -246,7 +162,7 @@ def handle_error(err_message: str = "", redirect_url: str = "", next: bool = Fal
def error_message(msg: str):
current_app.logger.error(msg)
LOGGER.error(msg)
return {"status": "ko", "message": msg}
@ -315,7 +231,7 @@ def get_service_data(page_name: str):
redirect_url="services",
)
config = current_app.db.get_config(methods=True, with_drafts=True)
config = DB.get_config(methods=True, with_drafts=True)
# Check variables
variables = deepcopy(request.form.to_dict())
mode = variables.pop("mode", None)
@ -387,7 +303,7 @@ def get_service_data(page_name: str):
):
del variables[variable]
variables = current_app.bw_config.check_variables(variables, config)
variables = BW_CONFIG.check_variables(variables, config)
return config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode
@ -402,7 +318,7 @@ def update_service(config, variables, format_configs, server_name, old_server_na
# Delete
if request.form["operation"] == "delete":
is_service = current_app.bw_config.check_variables({"SERVER_NAME": request.form["SERVER_NAME"]}, config)
is_service = BW_CONFIG.check_variables({"SERVER_NAME": request.form["SERVER_NAME"]}, config)
if not is_service:
error_message(f"Error while deleting the service {request.form['SERVER_NAME']}")
@ -410,7 +326,7 @@ def update_service(config, variables, format_configs, server_name, old_server_na
if config.get(f"{request.form['SERVER_NAME'].split(' ')[0]}_SERVER_NAME", {"method": "scheduler"})["method"] != "ui":
return handle_error("The service cannot be deleted because it has not been created with the UI.", "services", True)
db_metadata = current_app.db.get_metadata()
db_metadata = DB.get_metadata()
def update_services(threaded: bool = False):
wait_applying()
@ -431,13 +347,13 @@ def update_service(config, variables, format_configs, server_name, old_server_na
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
current_app.data["RELOADING"] = True
current_app.data["LAST_RELOAD"] = time()
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(target=update_services, args=(True,)).start()
else:
update_services()
current_app.data["CONFIG_CHANGED"] = True
DATA["CONFIG_CHANGED"] = True
message = ""

View file

@ -1,40 +0,0 @@
#!/usr/bin/env python3
from os import sep
from os.path import join
from pathlib import Path
from re import compile as re_compile
from utils import path_to_dict
class CustomConfig:
def __init__(self):
self.__name_regex = re_compile(r"^[\w.-]{4,64}$")
self.__root_dirs = [child["name"] for child in path_to_dict(join(sep, "etc", "bunkerweb", "configs"))["children"]]
self.__file_creation_blacklist = ["http", "stream"]
def check_name(self, name: str) -> bool:
return self.__name_regex.match(name) is not None
def check_path(self, path: str, root_path: str = join(sep, "etc", "bunkerweb", "configs")) -> str:
root_dir: str = path.split("/")[4]
if not (
path.startswith(root_path)
or root_path == join(sep, "etc", "bunkerweb", "configs")
and path.startswith(root_path)
and root_dir in self.__root_dirs
and (not path.endswith(".conf") or root_dir not in self.__file_creation_blacklist or len(path.split("/")) > 5)
):
return f"{path} is not a valid path"
if root_path == join(sep, "etc", "bunkerweb", "configs"):
dirs = path.split("/")[5:]
nbr_children = len(dirs)
dirs = "/".join(dirs)
if len(dirs) > 1:
for x in range(nbr_children - 1):
if not Path(root_path, root_dir, "/".join(dirs.split("/")[0:-x])).exists():
return f"{join(root_path, root_dir, '/'.join(dirs.split('/')[0:-x]))} doesn't exist"
return ""

View file

@ -1,25 +1,47 @@
from base64 import b64encode
from io import BytesIO
from json import JSONDecodeError, loads as json_loads
from os import getenv
from bcrypt import checkpw
from typing import Dict, List, Optional, Union
from flask import Flask
from typing import List, Optional
from passlib.totp import TOTP, MalformedTokenError, TokenError, TotpMatch
from passlib.pwd import genword
from qrcode import make
from qrcode.image.svg import SvgImage
from models import Users
from dependencies import DATA
from utils import LIB_DIR, LOGGER, stop
TOTP_SECRETS = getenv("TOTP_SECRETS", "")
if TOTP_SECRETS:
try:
TOTP_SECRETS = json_loads(TOTP_SECRETS)
except JSONDecodeError:
x = 1
tmp_secrets = {}
for secret in TOTP_SECRETS.strip().split(" "):
if secret:
tmp_secrets[x] = secret
x += 1
TOTP_SECRETS = tmp_secrets.copy()
del tmp_secrets
if not TOTP_SECRETS:
if not LIB_DIR.joinpath(".totp_secrets.json").is_file():
LOGGER.error("The TOTP_SECRETS environment variable is missing and the .totp_secrets.json file is missing, exiting ...")
stop(1)
TOTP_SECRETS = json_loads(LIB_DIR.joinpath(".totp_secrets.json").read_text(encoding="utf-8"))
class Totp:
def __init__(self, app: Flask, secrets: Dict[Union[str, int], str]):
def __init__(self):
"""Initialize a totp factory.
secrets are used to encrypt the per-user totp_secret on disk.
recovery_codes_keys are used to encrypt the per-user recovery codes on disk.
"""
# This should be a dict with at least one entry
self.app = app
self._totp = TOTP.using(secrets=secrets, issuer="BunkerWeb UI")
self._totp = TOTP.using(secrets=TOTP_SECRETS, issuer="BunkerWeb UI")
def generate_totp_secret(self) -> str:
"""Create new user-unique totp_secret."""
@ -73,10 +95,15 @@ class Totp:
def get_last_counter(self, user: Users) -> Optional[int]:
"""Fetch stored last_counter from cache."""
return self.app.data.get("totp_last_counter", {}).get(user.get_id())
return DATA.get("totp_last_counter", {}).get(user.get_id())
def set_last_counter(self, user: Users, tmatch: TotpMatch) -> None:
"""Cache last_counter."""
if "totp_last_counter" not in self.app.data:
self.app.data["totp_last_counter"] = {}
self.app.data["totp_last_counter"][user.get_id()] = tmatch.counter
if "totp_last_counter" not in DATA:
DATA["totp_last_counter"] = {}
DATA["totp_last_counter"][user.get_id()] = tmatch.counter
totp = Totp()
__all__ = ("totp",)

View file

@ -1,13 +1,45 @@
#!/usr/bin/env python3
from os.path import join
from os import _exit, getenv
from os.path import join, sep
from pathlib import Path
from subprocess import PIPE, Popen, call
from typing import List, Optional
from bcrypt import checkpw, gensalt, hashpw
from magic import Magic
from regex import compile as re_compile
from logger import setup_logger # type: ignore
TMP_DIR = Path(sep, "var", "tmp", "bunkerweb")
LIB_DIR = Path(sep, "var", "lib", "bunkerweb")
LOGGER = setup_logger("UI", getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO")))
USER_PASSWORD_RX = re_compile(r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_Letter})(?=.*?\d)(?=.*?[ !\"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-]).{8,}$")
PLUGIN_NAME_RX = re_compile(r"^[\w.-]{4,64}$")
def stop_gunicorn():
p = Popen(["pgrep", "-f", "gunicorn"], stdout=PIPE)
out, _ = p.communicate()
pid = out.strip().decode().split("\n")[0]
call(["kill", "-SIGTERM", pid])
def stop(status, _stop=True):
Path(sep, "var", "run", "bunkerweb", "ui.pid").unlink(missing_ok=True)
TMP_DIR.joinpath("ui.healthy").unlink(missing_ok=True)
if _stop is True:
stop_gunicorn()
_exit(status)
def handle_stop(signum, frame):
LOGGER.info("Caught stop operation")
LOGGER.info("Stopping web ui ...")
stop(0, False)
def check_settings(settings: dict, check: str) -> bool: