Optimize a few things on the web ui:

* Add multiple workers support
* Reduce prompt of loading page
* Update Database directly instead of calling a subprocess
This commit is contained in:
Théophile Diot 2024-04-24 12:52:25 +02:00
parent 7134f1733e
commit 6c0ed2f85e
No known key found for this signature in database
GPG key ID: 248FEA4BAE400D06
5 changed files with 254 additions and 229 deletions

View file

@ -68,7 +68,7 @@ def set_sqlite_pragma(dbapi_connection, _):
class Database:
DB_STRING_RX = re_compile(r"^(?P<database>(mariadb|mysql)(\+pymysql)?|sqlite(\+pysqlite)?|postgresql(\+psycopg)?):/+(?P<path>/[^\s]+)")
def __init__(self, logger: Logger, sqlalchemy_string: Optional[str] = None, *, ui: bool = False, pool: Optional[bool] = None) -> None:
def __init__(self, logger: Logger, sqlalchemy_string: Optional[str] = None, *, ui: bool = False, pool: Optional[bool] = None, log: bool = True) -> None:
"""Initialize the database"""
self.logger = logger
@ -90,7 +90,8 @@ class Database:
db_path = Path(match.group("path"))
if ui:
while not db_path.is_file():
self.logger.warning(f"Waiting for the database file to be created: {db_path}")
if log:
self.logger.warning(f"Waiting for the database file to be created: {db_path}")
sleep(1)
else:
db_path.parent.mkdir(parents=True, exist_ok=True)
@ -111,8 +112,8 @@ class Database:
"poolclass": QueuePool,
"pool_pre_ping": True,
"pool_recycle": 1800,
"pool_size": 20,
"max_overflow": 10,
"pool_size": 40,
"max_overflow": 20,
}
try:
@ -150,13 +151,14 @@ class Database:
_exit(1)
if "attempt to write a readonly database" in str(e):
self.logger.warning("The database is read-only, waiting for it to become writable. Retrying in 5 seconds ...")
if log:
self.logger.warning("The database is read-only, waiting for it to become writable. Retrying in 5 seconds ...")
self.sql_engine.dispose(close=True)
self.sql_engine = create_engine(sqlalchemy_string, **engine_kwargs)
if "Unknown table" in str(e):
not_connected = False
continue
else:
elif log:
self.logger.warning(
"Can't connect to database, retrying in 5 seconds ...",
)
@ -167,7 +169,8 @@ class Database:
exit(1)
self.suffix_rx = re_compile(r"_\d+$")
self.logger.info("✅ Database connection established")
if log:
self.logger.info("✅ Database connection established")
def __del__(self) -> None:
"""Close the database"""

View file

@ -1,20 +1,122 @@
from os import sep
from contextlib import suppress
from hashlib import sha256
from os import cpu_count, getenv, getpid, sep, urandom
from os.path import join
from pathlib import Path
from regex import compile as re_compile
from sys import path as sys_path
from time import sleep
deps_path = join(sep, "usr", "share", "bunkerweb", "deps", "python")
if deps_path not in sys_path:
sys_path.append(deps_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:
sys_path.append(deps_path)
from common_utils import get_integration # type: ignore
from Database import Database # type: ignore
from logger import setup_logger # type: ignore
from src.User import User
TMP_DIR = Path(sep, "var", "tmp", "bunkerweb")
MAX_WORKERS = int(getenv("MAX_WORKERS", max((cpu_count() or 1) - 1, 1)))
LOG_LEVEL = getenv("LOG_LEVEL", "info")
wsgi_app = "main:app"
accesslog = "/var/log/bunkerweb/ui-access.log"
errorlog = "/var/log/bunkerweb/ui.log"
loglevel = "info"
proc_name = "bunkerweb-ui"
preload_app = True
accesslog = "/var/log/bunkerweb/ui-access.log"
access_log_format = '%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
errorlog = "/var/log/bunkerweb/ui.log"
loglevel = LOG_LEVEL.lower()
reuse_port = True
worker_tmp_dir = join(sep, "dev", "shm")
tmp_upload_dir = join(sep, "var", "tmp", "bunkerweb", "ui")
worker_class = "gthread"
graceful_timeout = 0
secure_scheme_headers = {}
workers = MAX_WORKERS
worker_class = "gthread"
threads = int(getenv("MAX_THREADS", MAX_WORKERS * 2))
max_requests_jitter = min(8, MAX_WORKERS)
graceful_timeout = 5
def on_starting(server):
if not getenv("FLASK_SECRET") and not TMP_DIR.joinpath(".flask_secret").is_file():
TMP_DIR.mkdir(parents=True, exist_ok=True)
TMP_DIR.joinpath(".flask_secret").write_text(sha256(urandom(32)).hexdigest(), encoding="utf-8")
LOGGER = setup_logger("UI")
db = Database(LOGGER, ui=True)
INTEGRATION = get_integration()
if INTEGRATION in ("Swarm", "Kubernetes", "Autoconf"):
while not db.is_autoconf_loaded():
LOGGER.warning("Autoconf is not loaded yet in the database, retrying in 5s ...")
sleep(5)
while not db.is_initialized():
LOGGER.warning("Database is not initialized, retrying in 5s ...")
sleep(5)
USER_PASSWORD_RX = re_compile(r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_Letter})(?=.*?\d)(?=.*?[ !\"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-]).{8,}$")
USER = "Error"
while USER == "Error":
with suppress(Exception):
USER = db.get_ui_user()
if USER:
USER = User(**USER)
if getenv("ADMIN_USERNAME") or getenv("ADMIN_PASSWORD"):
if USER.method == "manual":
updated = False
if getenv("ADMIN_USERNAME", "") and USER.get_id() != getenv("ADMIN_USERNAME", ""):
USER.id = getenv("ADMIN_USERNAME", "")
updated = True
if getenv("ADMIN_PASSWORD", "") and not USER.check_password(getenv("ADMIN_PASSWORD", "")):
USER.update_password(getenv("ADMIN_PASSWORD", ""))
updated = True
if updated:
ret = db.update_ui_user(USER.get_id(), USER.password_hash, USER.is_two_factor_enabled, USER.secret_token)
if ret:
LOGGER.error(f"Couldn't update the admin user in the database: {ret}")
exit(1)
LOGGER.info("The admin user was updated successfully")
else:
LOGGER.warning("The admin user wasn't created manually. You can't change it from the environment variables.")
elif getenv("ADMIN_USERNAME") and getenv("ADMIN_PASSWORD"):
if not getenv("FLASK_DEBUG", False):
if len(getenv("ADMIN_USERNAME", "admin")) > 256:
LOGGER.error("The admin username is too long. It must be less than 256 characters.")
exit(1)
elif not USER_PASSWORD_RX.match(getenv("ADMIN_PASSWORD", "changeme")):
LOGGER.error(
"The admin password is not strong enough. It must contain at least 8 characters, including at least 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character (#@?!$%^&*-)."
)
exit(1)
user_name = getenv("ADMIN_USERNAME", "admin")
USER = User(user_name, getenv("ADMIN_PASSWORD", "changeme"))
ret = db.create_ui_user(user_name, USER.password_hash)
if ret:
LOGGER.error(f"Couldn't create the admin user in the database: {ret}")
exit(1)
LOGGER.info("UI is ready")
def when_ready(server):
if not TMP_DIR.joinpath(".ui.json").is_file():
TMP_DIR.joinpath(".ui.json").write_text("{}", encoding="utf-8")
TMP_DIR.joinpath("ui.pid").write_text(str(getpid()), encoding="utf-8")
TMP_DIR.joinpath("ui.healthy").write_text("ok", encoding="utf-8")
def on_exit(server):
TMP_DIR.joinpath("ui.pid").unlink(missing_ok=True)
TMP_DIR.joinpath("ui.healthy").unlink(missing_ok=True)

View file

@ -2,7 +2,7 @@
from contextlib import suppress
from math import floor
from os import _exit, getenv, getpid, listdir, sep, urandom
from os import _exit, getenv, listdir, sep, urandom
from os.path import basename, dirname, isabs, join
from secrets import choice
from string import ascii_letters, digits
@ -40,9 +40,9 @@ from shutil import move, rmtree
from signal import SIGINT, signal, SIGTERM
from subprocess import PIPE, Popen, call
from tarfile import CompressionError, HeaderError, ReadError, TarError, open as tar_open
from threading import Thread
from threading import Thread, Lock
from tempfile import NamedTemporaryFile
from time import sleep, time
from time import time
from werkzeug.utils import secure_filename
from zipfile import BadZipFile, ZipFile
@ -53,9 +53,16 @@ from src.ReverseProxied import ReverseProxied
from src.User import AnonymousUser, User
from utils import check_settings, get_b64encoded_qr_image, path_to_dict, get_remain
from common_utils import get_integration, get_version # type: ignore
from Database import Database # type: ignore
from logger import setup_logger # type: ignore
from logging import getLogger
TMP_DIR = Path(sep, "var", "tmp", "bunkerweb")
TMP_DIR.mkdir(parents=True, exist_ok=True)
TMP_DATA_FILE = TMP_DIR.joinpath(".ui.json")
LOCK = Lock()
def stop_gunicorn():
@ -66,8 +73,8 @@ def stop_gunicorn():
def stop(status, _stop=True):
Path(sep, "var", "run", "bunkerweb", "ui.pid").unlink(missing_ok=True)
Path(sep, "var", "tmp", "bunkerweb", "ui.healthy").unlink(missing_ok=True)
TMP_DIR.joinpath("ui.pid").unlink(missing_ok=True)
TMP_DIR.joinpath("ui.healthy").unlink(missing_ok=True)
if _stop is True:
stop_gunicorn()
_exit(status)
@ -86,18 +93,20 @@ sbin_nginx_path = Path(sep, "usr", "sbin", "nginx")
# Flask app
app = Flask(__name__, static_url_path="/", static_folder="static", template_folder="templates")
app.config["SECRET_KEY"] = getenv("FLASK_SECRET", urandom(32))
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")
gunicorn_access_logger = getLogger("gunicorn.access")
gunicorn_access_logger.setLevel(app.logger.level)
gunicorn_logger = getLogger("gunicorn.error")
gunicorn_logger.setLevel(app.logger.level)
werkzeug_logger = getLogger("werkzeug")
werkzeug_logger.setLevel(app.logger.level)
FLASK_SECRET = getenv("FLASK_SECRET")
if not FLASK_SECRET:
if not TMP_DIR.joinpath(".flask_secret").is_file():
app.logger.error("The .flask_secret file is missing")
stop(1)
FLASK_SECRET = TMP_DIR.joinpath(".flask_secret").read_text(encoding="utf-8").strip()
app.config["SECRET_KEY"] = FLASK_SECRET
login_manager = LoginManager()
login_manager.init_app(app)
@ -105,18 +114,7 @@ login_manager.login_view = "login"
login_manager.anonymous_user = AnonymousUser
PLUGIN_KEYS = ["id", "name", "description", "version", "stream", "settings"]
INTEGRATION = "Linux"
integration_path = Path(sep, "usr", "share", "bunkerweb", "INTEGRATION")
if getenv("KUBERNETES_MODE", "no").lower() == "yes":
INTEGRATION = "Kubernetes"
elif getenv("SWARM_MODE", "no").lower() == "yes":
INTEGRATION = "Swarm"
elif getenv("AUTOCONF_MODE", "no").lower() == "yes":
INTEGRATION = "Autoconf"
elif integration_path.is_file():
INTEGRATION = integration_path.read_text(encoding="utf-8").strip()
del integration_path
INTEGRATION = get_integration()
docker_client = None
kubernetes_client = None
@ -129,83 +127,28 @@ elif INTEGRATION == "Kubernetes":
kube_config.load_incluster_config()
kubernetes_client = kube_client.CoreV1Api()
db = Database(app.logger, ui=True)
if INTEGRATION in ("Swarm", "Kubernetes", "Autoconf"):
while not db.is_autoconf_loaded():
app.logger.warning("Autoconf is not loaded yet in the database, retrying in 5s ...")
sleep(5)
while not db.is_initialized():
app.logger.warning("Database is not initialized, retrying in 5s ...")
sleep(5)
db = Database(app.logger, ui=True, log=False)
USER = "Error"
while USER == "Error":
with suppress(Exception):
USER = db.get_ui_user()
USER_PASSWORD_RX = re_compile(r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_Letter})(?=.*?\d)(?=.*?[ !\"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-]).{8,}$")
if USER:
USER = User(**USER)
if getenv("ADMIN_USERNAME") or getenv("ADMIN_PASSWORD"):
if USER.method == "manual":
updated = False
if getenv("ADMIN_USERNAME", "") and USER.get_id() != getenv("ADMIN_USERNAME", ""):
USER.id = getenv("ADMIN_USERNAME", "")
updated = True
if getenv("ADMIN_PASSWORD", "") and not USER.check_password(getenv("ADMIN_PASSWORD", "")):
USER.update_password(getenv("ADMIN_PASSWORD", ""))
updated = True
USER_PASSWORD_RX = re_compile(r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_Letter})(?=.*?\d)(?=.*?[ !\"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-]).{8,}$")
if updated:
ret = db.update_ui_user(USER.get_id(), USER.password_hash, USER.is_two_factor_enabled, USER.secret_token)
if ret:
app.logger.error(f"Couldn't update the admin user in the database: {ret}")
stop(1)
app.logger.info("The admin user was updated successfully")
else:
app.logger.warning("The admin user wasn't created manually. You can't change it from the environment variables.")
elif getenv("ADMIN_USERNAME") and getenv("ADMIN_PASSWORD"):
if not getenv("FLASK_DEBUG", False):
if len(getenv("ADMIN_USERNAME", "admin")) > 256:
app.logger.error("The admin username is too long. It must be less than 256 characters.")
stop(1)
elif not USER_PASSWORD_RX.match(getenv("ADMIN_PASSWORD", "changeme")):
app.logger.error(
"The admin password is not strong enough. It must contain at least 8 characters, including at least 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character (#@?!$%^&*-)."
)
stop(1)
user_name = getenv("ADMIN_USERNAME", "admin")
USER = User(user_name, getenv("ADMIN_PASSWORD", "changeme"))
ret = db.create_ui_user(user_name, USER.password_hash)
if ret:
app.logger.error(f"Couldn't create the admin user in the database: {ret}")
stop(1)
app.logger.info("Database is ready")
Path(sep, "var", "run", "bunkerweb", "ui.pid").write_text(str(getpid()), encoding="utf-8")
Path(sep, "var", "tmp", "bunkerweb", "ui.healthy").write_text("ok", encoding="utf-8")
bw_version = Path(sep, "usr", "share", "bunkerweb", "VERSION").read_text(encoding="utf-8").strip()
bw_version = get_version()
try:
app.config.update(
DEBUG=True,
INSTANCES=Instances(docker_client, kubernetes_client, INTEGRATION, db),
CONFIG=Config(db),
CONFIGFILES=ConfigFiles(),
WTF_CSRF_SSL_STRICT=False,
USER=USER,
SEND_FILE_MAX_AGE_DEFAULT=86400,
RELOADING=False,
LAST_RELOAD=0,
TO_FLASH=[],
DARK_MODE=False,
CURRENT_TOTP_TOKEN=None,
SCRIPT_NONCE=sha256(urandom(32)).hexdigest(),
DB=db,
)
@ -226,9 +169,22 @@ LOG_RX = re_compile(r"^(?P<date>\d+/\d+/\d+\s\d+:\d+:\d+)\s\[(?P<level>[a-z]+)\]
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})))?)$")
def get_ui_data():
ui_data = "Error"
while ui_data == "Error":
with suppress(JSONDecodeError):
ui_data = json_loads(TMP_DATA_FILE.read_text(encoding="utf-8"))
return ui_data
def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: bool = False, was_draft: bool = False):
# Do the operation
error = False
ui_data = get_ui_data()
if "TO_FLASH" not in ui_data:
ui_data["TO_FLASH"] = []
if method == "services":
service_custom_confs = glob(join(sep, "etc", "bunkerweb", "configs", "*", args[1].split(" ")[0]))
moved = False
@ -253,18 +209,16 @@ def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: b
operation, error = app.config["CONFIG"].delete_service(args[2], check_changes=(was_draft != is_draft or not is_draft) and not deleted)
if error:
app.config["TO_FLASH"].append({"content": operation, "type": "error"})
ui_data["TO_FLASH"].append({"content": operation, "type": "error"})
else:
app.config["TO_FLASH"].append({"content": operation, "type": "success"})
ui_data["TO_FLASH"].append({"content": operation, "type": "success"})
if (was_draft != is_draft or not is_draft) and (moved or deleted):
# update changes in db
ret = db.checked_changes(["config", "custom_configs"], value=True)
if ret:
app.logger.error(f"Couldn't set the changes to checked in the database: {ret}")
app.config["TO_FLASH"].append(
{"content": f"An error occurred when setting the changes to checked in the database : {ret}", "type": "error"}
)
ui_data["TO_FLASH"].append({"content": f"An error occurred when setting the changes to checked in the database : {ret}", "type": "error"})
if method == "global_config":
operation = app.config["CONFIG"].edit_global_conf(args[0])
elif method == "plugins":
@ -286,13 +240,16 @@ def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: b
if operation:
if isinstance(operation, list):
for op in operation:
app.config["TO_FLASH"].append({"content": f"Reload failed for the instance {op}", "type": "error"})
ui_data["TO_FLASH"].append({"content": f"Reload failed for the instance {op}", "type": "error"})
elif operation.startswith("Can't"):
app.config["TO_FLASH"].append({"content": operation, "type": "error"})
ui_data["TO_FLASH"].append({"content": operation, "type": "error"})
else:
app.config["TO_FLASH"].append({"content": operation, "type": "success"})
ui_data["TO_FLASH"].append({"content": operation, "type": "success"})
app.config["RELOADING"] = False
ui_data = get_ui_data()
ui_data["RELOADING"] = False
with LOCK:
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
# UTILS
@ -309,7 +266,7 @@ def run_action(plugin: str, function_name: str = ""):
try:
# Try to import the custom plugin
if obfuscation:
tmp_dir = Path(sep, "var", "tmp", "bunkerweb", "ui", "action", str(uuid4()))
tmp_dir = TMP_DIR.joinpath("ui", "action", str(uuid4()))
tmp_dir.mkdir(parents=True, exist_ok=True)
action_file = tmp_dir.joinpath("actions.py")
@ -409,17 +366,33 @@ def error_message(msg: str):
@app.before_request
def generate_nonce():
def before_each_request():
db_user = db.get_ui_user()
if db_user:
app.config["USER"] = User(**db_user)
ui_data = get_ui_data()
for f in ui_data.get("TO_FLASH", []):
if f["type"] == "error":
flash(f["content"], "error")
else:
flash(f["content"])
ui_data["TO_FLASH"] = []
with LOCK:
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
app.config["SCRIPT_NONCE"] = sha256(urandom(32)).hexdigest()
@app.context_processor
def inject_variables():
metadata = db.get_metadata()
ui_data = get_ui_data()
# check that is value is in tuple
return dict(
dark_mode=app.config["DARK_MODE"],
dark_mode=ui_data.get("DARK_MODE", False),
script_nonce=app.config["SCRIPT_NONCE"],
is_pro_version=metadata["is_pro"],
pro_status=metadata["pro_status"],
@ -449,12 +422,10 @@ def set_csp_header(response):
@login_manager.user_loader
def load_user(user_id):
db_user = db.get_ui_user()
if not db_user:
if not app.config["USER"]:
app.logger.warning("Couldn't get the admin user from the database.")
return None
user = User(**db_user)
return user if user_id == user.get_id() else None
return app.config["USER"] if user_id == app.config["USER"].get_id() else None
@app.errorhandler(CSRFError)
@ -533,7 +504,6 @@ def setup():
return redirect(url_for("home"))
db_config = app.config["CONFIG"].get_config(methods=False)
db_user = db.get_ui_user()
for server_name in db_config["SERVER_NAME"].split(" "):
if db_config.get(f"{server_name}_USE_UI", "no") == "yes":
@ -543,13 +513,13 @@ def setup():
is_request_form("setup")
required_keys = ["server_name", "ui_host", "ui_url"]
if not db_user:
if not app.config["USER"]:
required_keys.extend(["admin_username", "admin_password", "admin_password_check"])
if not any(key in request.form for key in required_keys):
return redirect_flash_error(f"Missing either one of the following parameters: {', '.join(required_keys)}.", "setup")
if not db_user:
if not app.config["USER"]:
if len(request.form["admin_username"]) > 256:
return redirect_flash_error("The admin username is too long. It must be less than 256 characters.", "setup")
@ -573,7 +543,7 @@ def setup():
if not REVERSE_PROXY_PATH.match(request.form["ui_host"]):
return redirect_flash_error("The hostname is not valid.", "setup")
if not db_user:
if not app.config["USER"]:
app.config["USER"] = User(request.form["admin_username"], request.form["admin_password"], method="ui")
ret = db.create_ui_user(request.form["admin_username"], app.config["USER"].password_hash, method="ui")
@ -582,8 +552,9 @@ def setup():
flash("The admin user was created successfully", "success")
app.config["RELOADING"] = True
app.config["LAST_RELOAD"] = time()
ui_data = get_ui_data()
ui_data["RELOADING"] = True
ui_data["LAST_RELOAD"] = time()
# deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish
Thread(
target=manage_bunkerweb,
@ -599,6 +570,8 @@ def setup():
"AUTO_LETS_ENCRYPT": request.form.get("auto_lets_encrypt", "no"),
"INTERCEPTED_ERROR_CODES": "400 404 405 413 429 500 501 502 503 504",
"MAX_CLIENT_SIZE": "50m",
"LIMIT_REQ_URL_1": request.form["ui_url"] or "/",
"LIMIT_REQ_RATE_1": "10r/s",
},
request.form["server_name"],
request.form["server_name"],
@ -606,11 +579,14 @@ def setup():
kwargs={"operation": "new"},
).start()
with LOCK:
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
return Response(status=200)
return render_template(
"setup.html",
ui_user=db_user,
ui_user=app.config["USER"],
username=getenv("ADMIN_USERNAME", ""),
password=getenv("ADMIN_PASSWORD", ""),
ui_host=db_config.get("UI_HOST", getenv("UI_HOST", "")),
@ -735,27 +711,11 @@ def account():
db.set_pro_metadata(metadata)
# Reload instances
app.config["RELOADING"] = True
app.config["LAST_RELOAD"] = time()
# deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=(
"global_config",
variable,
),
).start()
manage_bunkerweb("global_config", variable)
flash("Checking license key to upgrade.", "success")
return redirect(
url_for(
"loading",
next=url_for("account"),
message="Saving license key",
)
)
return redirect(url_for("account"))
is_request_params(["operation", "curr_password"], "account")
@ -800,13 +760,18 @@ def account():
is_request_params(["totp_token"], "account")
if not current_user.check_otp(request.form["totp_token"], secret=app.config["CURRENT_TOTP_TOKEN"]):
ui_data = get_ui_data()
if not current_user.check_otp(request.form["totp_token"], secret=ui_data.get("CURRENT_TOTP_TOKEN", None)):
return redirect_flash_error("The totp token is invalid. (totp)", "account")
session["totp_validated"] = not current_user.is_two_factor_enabled
is_two_factor_enabled = session["totp_validated"]
secret_token = None if current_user.is_two_factor_enabled else app.config["CURRENT_TOTP_TOKEN"]
app.config["CURRENT_TOTP_TOKEN"] = None
secret_token = None if current_user.is_two_factor_enabled else ui_data.get("CURRENT_TOTP_TOKEN", None)
ui_data["CURRENT_TOTP_TOKEN"] = None
with LOCK:
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
user = User(username, password, is_two_factor_enabled=is_two_factor_enabled, secret_token=secret_token, method=current_user.method)
ret = db.update_ui_user(
@ -832,7 +797,11 @@ def account():
current_user.refresh_totp()
secret_token = current_user.secret_token
totp_qr_image = get_b64encoded_qr_image(current_user.get_authentication_setup_uri())
app.config["CURRENT_TOTP_TOKEN"] = secret_token
ui_data = get_ui_data()
ui_data["CURRENT_TOTP_TOKEN"] = secret_token
with LOCK:
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
return render_template(
"account.html",
@ -859,15 +828,7 @@ def instances():
"restart",
):
return redirect_flash_error("Missing operation parameter on /instances.", "instances")
app.config["RELOADING"] = True
app.config["LAST_RELOAD"] = time()
# deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=("instances", request.form["INSTANCE_ID"]),
kwargs={"operation": request.form["operation"]},
).start()
manage_bunkerweb("instances", request.form["INSTANCE_ID"], operation=request.form["operation"])
return redirect(
url_for(
@ -970,15 +931,15 @@ def services():
error = 0
# Reload instances
app.config["RELOADING"] = True
app.config["LAST_RELOAD"] = time()
# deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=("services", variables, request.form.get("OLD_SERVER_NAME", ""), variables.get("SERVER_NAME", "")),
kwargs={"operation": request.form["operation"], "is_draft": is_draft, "was_draft": was_draft},
).start()
manage_bunkerweb(
"services",
variables,
request.form.get("OLD_SERVER_NAME", ""),
variables.get("SERVER_NAME", ""),
operation=request.form["operation"],
is_draft=is_draft,
was_draft=was_draft,
)
message = ""
@ -1079,17 +1040,7 @@ def global_config():
variables[f"{service}_{variable}"] = value
# Reload instances
app.config["RELOADING"] = True
app.config["LAST_RELOAD"] = time()
# deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=(
"global_config",
variables,
),
).start()
manage_bunkerweb("global_config", variables)
with suppress(BaseException):
if config["PRO_LICENSE_KEY"]["value"] != variables["PRO_LICENSE_KEY"]:
@ -1232,7 +1183,7 @@ def configs():
@app.route("/plugins", methods=["GET", "POST"])
@login_required
def plugins():
tmp_ui_path = Path(sep, "var", "tmp", "bunkerweb", "ui")
tmp_ui_path = TMP_DIR.joinpath("ui")
if request.method == "POST":
error = 0
@ -1440,14 +1391,7 @@ def plugins():
flash("Plugins uploaded successfully")
# Reload instances
app.config["RELOADING"] = True
app.config["LAST_RELOAD"] = time()
# deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=("plugins",),
).start()
manage_bunkerweb("plugins")
return redirect(url_for("loading", next=url_for("plugins"), message="Reloading plugins"))
@ -1483,7 +1427,7 @@ def upload_plugin():
if not request.files:
return {"status": "ko"}, 400
tmp_ui_path = Path(sep, "var", "tmp", "bunkerweb", "ui")
tmp_ui_path = TMP_DIR.joinpath("ui")
tmp_ui_path.mkdir(parents=True, exist_ok=True)
for uploaded_file in request.files.values():
@ -2182,18 +2126,16 @@ def login():
fail = False
if request.method == "POST" and "username" in request.form and "password" in request.form:
app.logger.warning(f"Login attempt from {request.remote_addr} with username \"{request.form['username']}\"")
db_user = db.get_ui_user()
if not db_user:
if not app.config["USER"]:
app.logger.error("Couldn't get user from database")
stop(1)
user = User(**db_user)
if user.get_id() == request.form["username"] and user.check_password(request.form["password"]):
if app.config["USER"].get_id() == request.form["username"] and app.config["USER"].check_password(request.form["password"]):
# log the user in
session["ip"] = request.remote_addr
session["user_agent"] = request.headers.get("User-Agent")
session["totp_validated"] = False
login_user(user, duration=timedelta(hours=1))
login_user(app.config["USER"], duration=timedelta(hours=1), force=True)
# redirect him to the page he originally wanted or to the home page
return redirect(url_for("loading", next=request.form.get("next") or url_for("home")))
@ -2215,7 +2157,10 @@ def darkmode():
return jsonify({"status": "ko", "message": "invalid request"}), 400
if "darkmode" in request.json:
app.config["DARK_MODE"] = request.json["darkmode"] == "true"
ui_data = get_ui_data()
ui_data["DARK_MODE"] = request.json["darkmode"] == "true"
with LOCK:
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
else:
return jsonify({"status": "ko", "message": "darkmode is required"}), 422
@ -2225,21 +2170,18 @@ def darkmode():
@app.route("/check_reloading")
@login_required
def check_reloading():
if not app.config["RELOADING"] or app.config["LAST_RELOAD"] + 60 < time():
if app.config["RELOADING"]:
ui_data = get_ui_data()
if not ui_data.get("RELOADING", False) or ui_data.get("LAST_RELOAD", 0) + 60 < time():
if ui_data.get("RELOADING", False):
app.logger.warning("Reloading took too long, forcing the state to be reloaded")
flash("Forced the status to be reloaded", "error")
app.config["RELOADING"] = False
ui_data["RELOADING"] = False
for f in app.config["TO_FLASH"]:
if f["type"] == "error":
flash(f["content"], "error")
else:
flash(f["content"])
with LOCK:
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
app.config["TO_FLASH"].clear()
return jsonify({"reloading": app.config["RELOADING"]})
return jsonify({"reloading": ui_data.get("RELOADING", False)})
@app.route("/logout")

View file

@ -3,14 +3,11 @@
from copy import deepcopy
from operator import itemgetter
from os import sep
from os.path import join
from flask import flash
from json import loads as json_loads
from pathlib import Path
from re import search as re_search
from subprocess import run, DEVNULL, STDOUT
from typing import List, Literal, Optional, Tuple
from uuid import uuid4
class Config:
@ -51,30 +48,11 @@ class Config:
conf["SERVER_NAME"] = " ".join(servers)
conf["DATABASE_URI"] = self.__db.database_uri
env_file = Path(sep, "tmp", f"{uuid4()}.env")
env_file.write_text(
"\n".join(f"{k}={conf[k]}" for k in sorted(conf)),
encoding="utf-8",
)
proc = run(
[
"python3",
join(sep, "usr", "share", "bunkerweb", "gen", "save_config.py"),
"--variables",
str(env_file),
"--method",
"ui",
]
+ (["--no-check-changes"] if not check_changes else []),
stdin=DEVNULL,
stderr=STDOUT,
check=False,
)
err = self.__db.save_config(conf, "ui", changed=check_changes)
env_file.unlink()
if proc.returncode != 0:
raise Exception(f"Error from generator (return code = {proc.returncode})")
if err:
self.__db.logger.warning(f"Couldn't save config to database : {err}, config may not work as expected")
def get_plugins_settings(self) -> dict:
return {

View file

@ -36,7 +36,7 @@
<div class="w-full">
<a aria-label="link to home"
class="flex justify-center px-8 m-0 text-sm whitespace-nowrap dark:text-white text-slate-700"
href="{% if current_endpoint == 'home' %}#{% else %}loading?next={{ url_for("home") }}{% endif %}">
href="{% if current_endpoint == 'home' %}#{% else %}{{ url_for("home") }}{% endif %}">
<img src="images/logo-menu-2.png"
class="hidden dark:inline w-28 sm:w-36 transition-all duration-200 h-8 sm:h-10"
alt="main logo" />
@ -50,7 +50,7 @@
{{ username }}
</h1>
<a class="block underline mb-2 text-gray-600 dark:text-gray-400 text-sm text-center hover:brightness-90"
href="{% if current_endpoint == 'account' %}#{% else %}loading?next={{ url_for("account") }}{% endif %}">manage account
href="{% if current_endpoint == 'account' %}#{% else %}{{ url_for("account") }}{% endif %}">manage account
</a>
</div>
<hr class="h-px mt-0 bg-transparent bg-gradient-to-r from-transparent via-black/40 to-transparent dark:bg-gradient-to-r dark:from-transparent dark:via-white dark:to-transparent" />
@ -77,7 +77,7 @@
{% for path in paths %}
<li class="mt-0.5 w-full">
<a class="{% if current_endpoint == path %} font-semibold text-slate-700 dark:bg-primary/50 rounded-lg dark:hover:bg-primary/60 bg-primary/10 hover:bg-primary/30 {% else %} dark:hover:bg-primary/20 hover:bg-primary/5 {% endif %} dark:text-gray-200 py-1 ease-nav-brand my-0 mx-2 flex items-center whitespace-nowrap rounded-lg px-4 transition text-sm"
href="{% if current_endpoint == path %}#{% else %}loading?next={{ url_for(path) }}{% endif %}">
href="{% if current_endpoint == path %}#{% else %}{{ url_for(path) }}{% endif %}">
<div class="mr-2 flex items-center justify-center rounded-lg bg-center stroke-0 text-center p-1 xl:p-1.5">
{% if path == "home" %}
<svg class="stroke-sky-500 h-6 w-6 relative"