diff --git a/src/linux/scripts/bunkerweb-ui.sh b/src/linux/scripts/bunkerweb-ui.sh index 22e2634a6..5034fc09d 100644 --- a/src/linux/scripts/bunkerweb-ui.sh +++ b/src/linux/scripts/bunkerweb-ui.sh @@ -29,12 +29,21 @@ start() { export CAPTURE_OUTPUT="yes" # shellcheck disable=SC2046 export $(cat /etc/bunkerweb/ui.env) - sudo -E -u nginx -g nginx /bin/bash -c "PYTHONPATH=$PYTHONPATH python3 -m gunicorn --chdir /usr/share/bunkerweb/ui --config /usr/share/bunkerweb/ui/gunicorn.conf.py" + sudo -E -u nginx -g nginx /bin/bash -c "PYTHONPATH=$PYTHONPATH python3 -m gunicorn --chdir /usr/share/bunkerweb/ui --logger-class utils.logger.TmpUiLogger --config utils/tmp-gunicorn.conf.py" + sudo -E -u nginx -g nginx /bin/bash -c "PYTHONPATH=$PYTHONPATH python3 -m gunicorn --chdir /usr/share/bunkerweb/ui --logger-class utils.logger.UiLogger --config utils/gunicorn.conf.py" } # Function to stop the UI stop() { echo "Stopping UI service..." + + if [ -f "/var/run/bunkerweb/tmp-ui.pid" ]; then + pid="$(cat /var/run/bunkerweb/tmp-ui.pid)" + kill -s TERM "$pid" + else + echo "Temporary UI service is not running or the pid file doesn't exist." + fi + if [ -f "/var/run/bunkerweb/ui.pid" ]; then pid="$(cat /var/run/bunkerweb/ui.pid)" kill -s TERM "$pid" diff --git a/src/ui/Dockerfile b/src/ui/Dockerfile index 5889e6352..368f6c710 100644 --- a/src/ui/Dockerfile +++ b/src/ui/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-alpine@sha256:fcbcbbecdeae71d3b77445d9144d1914df55110f825ab62b04a66c7c33c09373 AS builder +FROM python:3.12-alpine@sha256:bb94273467caf397de28b4e6dd09ca4a2dd1b53fa9b130d5b2c7c82719258356 AS builder # Export var for specific actions on linux/arm/v7 ARG TARGETPLATFORM @@ -35,7 +35,7 @@ COPY src/common/helpers helpers COPY src/VERSION VERSION COPY src/ui ui -FROM python:3.13-alpine@sha256:fcbcbbecdeae71d3b77445d9144d1914df55110f825ab62b04a66c7c33c09373 +FROM python:3.12-alpine@sha256:bb94273467caf397de28b4e6dd09ca4a2dd1b53fa9b130d5b2c7c82719258356 # Set default umask to prevent huge recursive chmod increasing the final image size RUN umask 027 @@ -73,7 +73,9 @@ RUN echo "Docker" > INTEGRATION && \ find ui/ -type f -name "*.py" -print0 | xargs -0 chmod 750 && \ chmod 660 INTEGRATION && \ ln -s /proc/1/fd/1 /var/log/bunkerweb/ui-access.log && \ - ln -s /proc/1/fd/2 /var/log/bunkerweb/ui.log + ln -s /proc/1/fd/2 /var/log/bunkerweb/ui.log && \ + ln -s /proc/1/fd/1 /var/log/bunkerweb/tmp-ui-access.log && \ + ln -s /proc/1/fd/2 /var/log/bunkerweb/tmp-ui.log LABEL maintainer="Bunkerity " LABEL version="1.6.0-beta" diff --git a/src/ui/app/routes/home.py b/src/ui/app/routes/home.py index 695e2cabb..788330ce8 100644 --- a/src/ui/app/routes/home.py +++ b/src/ui/app/routes/home.py @@ -10,7 +10,7 @@ from app.dependencies import BW_INSTANCES_UTILS, DB home = Blueprint("home", __name__) -@home.route("/home") +@home.route("/home", methods=["GET"]) @login_required def home_page(): blocked_requests = BW_INSTANCES_UTILS.get_metrics("requests").get("requests", []) diff --git a/src/ui/app/routes/login.py b/src/ui/app/routes/login.py index e9bf26426..d8c6d931a 100644 --- a/src/ui/app/routes/login.py +++ b/src/ui/app/routes/login.py @@ -1,7 +1,7 @@ from datetime import datetime from os import getenv -from flask import Blueprint, flash as flask_flash, redirect, render_template, request, session, url_for +from flask import Blueprint, current_app, flash as flask_flash, redirect, render_template, request, session, url_for from flask_login import current_user, login_user from app.dependencies import DB @@ -20,10 +20,14 @@ def login_page(): fail = False if request.method == "POST" and "username" in request.form and "password" in request.form: - LOGGER.warning(f"Login attempt from {request.remote_addr}") + LOGGER.warning(f"Login attempt from {request.remote_addr} with 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"]): + # Regenerate the session to mitigate session fixation + session.clear() # Clear the current session + current_app.session_interface.regenerate(session) # Regenerate the session ID + # log the user in session["creation_date"] = datetime.now().astimezone() session["ip"] = request.remote_addr diff --git a/src/ui/app/static/js/pages/starting.js b/src/ui/app/static/js/pages/starting.js new file mode 100644 index 000000000..0f2825c2e --- /dev/null +++ b/src/ui/app/static/js/pages/starting.js @@ -0,0 +1,10 @@ +$(document).ready(() => { + const $uiErrorMessage = $("#ui-error-message"); + + if (!$uiErrorMessage.length) { + // Automatically refresh the page after 3 seconds + setTimeout(() => { + window.location.reload(); + }, 3000); + } +}); diff --git a/src/ui/app/templates/base.html b/src/ui/app/templates/base.html index 59dd7d128..882ff0341 100644 --- a/src/ui/app/templates/base.html +++ b/src/ui/app/templates/base.html @@ -99,11 +99,11 @@ {% endif %} - {% if current_endpoint in ("setup", "login", "totp", "loading") %} + {% if starting or current_endpoint in ("setup", "login", "totp", "loading") %} - {% elif current_endpoint == "loading" %} + {% elif current_endpoint in ("loading", "starting") %} @@ -124,7 +124,9 @@ nonce="{{ script_nonce }}"> + {% if not starting %} + {% endif %} - {% if current_endpoint != "loading" %} + {% if not starting and current_endpoint != "loading" %} {% include "flash.html" %} {% endif %} {% block page %}{% endblock %} @@ -179,7 +181,7 @@ {% endif %} - {% if current_endpoint in ("setup", "loading", "instances") or current_endpoint != "plugins" and "plugins" in request.path %} + {% if starting or current_endpoint in ("setup", "loading", "instances") or (current_endpoint != "plugins" and "plugins" in request.path) %} {% endif %} @@ -191,7 +193,7 @@ nonce="{{ script_nonce }}"> - {% if current_endpoint in ("setup", "login", "totp", "loading") %} + {% if starting or current_endpoint in ("setup", "login", "totp", "loading") %} {% endif %} @@ -208,6 +210,10 @@ nonce="{{ script_nonce }}"> {% endif %} + {% if starting %} + + {% endif %} {% if current_endpoint == "setup" %} @@ -262,6 +268,6 @@ {% endif %} - + diff --git a/src/ui/app/templates/starting.html b/src/ui/app/templates/starting.html new file mode 100644 index 000000000..2bc36774b --- /dev/null +++ b/src/ui/app/templates/starting.html @@ -0,0 +1,49 @@ +{% set starting = True %} +{% extends "base.html" %} +{% block page %} +
+ +
+ +{% endblock %} diff --git a/src/ui/app/utils.py b/src/ui/app/utils.py index 4cff8bbe1..a92156a54 100644 --- a/src/ui/app/utils.py +++ b/src/ui/app/utils.py @@ -81,18 +81,15 @@ COLUMNS_PREFERENCES_DEFAULTS = { } -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() +def stop(status, _stop: bool = True): + if _stop: + pid_file = Path(sep, "var", "run", "bunkerweb", "ui.pid") + if pid_file.is_file(): + pid = pid_file.read_bytes() + else: + p = Popen(["pgrep", "-f", "gunicorn"], stdout=PIPE) + pid, _ = p.communicate() + call(["kill", "-SIGTERM", pid.strip().decode().split("\n")[0]]) _exit(status) diff --git a/src/ui/entrypoint.sh b/src/ui/entrypoint.sh index 8c6d6023b..6a4d452ac 100644 --- a/src/ui/entrypoint.sh +++ b/src/ui/entrypoint.sh @@ -1,47 +1,99 @@ #!/bin/bash -set -e +# Load utility functions from a shared helper script. # shellcheck disable=SC1091 . /usr/share/bunkerweb/helpers/utils.sh -# trap SIGTERM and SIGINT +# Define a function to handle SIGTERM and SIGINT signals. function trap_exit() { + # Log that the script caught a termination signal. # shellcheck disable=SC2317 log "ENTRYPOINT" "ℹ️ " "Caught stop operation" + + # Check if the temporary web UI process exists and stop it gracefully. + # The PID is stored in a temporary file. # shellcheck disable=SC2317 - if [ -f "/var/run/bunkerweb/ui.pid" ] ; then + if [ -f "/var/run/bunkerweb/tmp-ui.pid" ]; then + log "ENTRYPOINT" "ℹ️ " "Stopping temporary web UI ..." + + # Verify the process is running before attempting to stop it. + if kill -0 "$(cat /var/run/bunkerweb/tmp-ui.pid)" 2> /dev/null; then + # Send a TERM signal to stop the temporary web UI. + kill -s TERM "$(cat /var/run/bunkerweb/tmp-ui.pid)" + fi + + # Log that the temporary web UI process has been stopped. + log "ENTRYPOINT" "ℹ️ " "Temporary web UI stopped" + fi + + # Similarly, stop the main web UI process if it exists. + # shellcheck disable=SC2317 + if [ -f "/var/run/bunkerweb/ui.pid" ]; then # shellcheck disable=SC2317 log "ENTRYPOINT" "ℹ️ " "Stopping web UI ..." + + # Verify the main web UI process is running before stopping it. # shellcheck disable=SC2317 - kill -s TERM "$(cat /var/run/bunkerweb/ui.pid)" + if kill -0 "$(cat /var/run/bunkerweb/ui.pid)" 2> /dev/null; then + # Send a TERM signal to stop the main web UI. + # shellcheck disable=SC2317 + kill -s TERM "$(cat /var/run/bunkerweb/ui.pid)" + fi + + # Log that the main web UI process has been stopped. + # shellcheck disable=SC2317 + log "ENTRYPOINT" "ℹ️ " "Web UI stopped" fi } + +# Register the trap_exit function to handle SIGTERM, SIGINT, and SIGQUIT signals. trap "trap_exit" TERM INT QUIT -if [ -f /var/run/bunkerweb/ui.pid ] ; then +# Remove any existing PID file for the main web UI to avoid stale state issues. +if [ -f /var/run/bunkerweb/ui.pid ]; then rm -f /var/run/bunkerweb/ui.pid fi +# Log the startup of the web UI, including the version being launched. log "ENTRYPOINT" "ℹ️" "Starting the web UI v$(cat /usr/share/bunkerweb/VERSION) ..." -# setup and check /data folder +# Set up and validate the /data folder, ensuring required configurations are present. /usr/share/bunkerweb/helpers/data.sh "ENTRYPOINT" -if [[ $(echo "$SWARM_MODE" | awk '{print tolower($0)}') == "yes" ]] ; then +# Determine the deployment mode (Swarm, Kubernetes, Autoconf, or Docker) and record it. +if [[ $(echo "$SWARM_MODE" | awk '{print tolower($0)}') == "yes" ]]; then echo "Swarm" > /usr/share/bunkerweb/INTEGRATION -elif [[ $(echo "$KUBERNETES_MODE" | awk '{print tolower($0)}') == "yes" ]] ; then +elif [[ $(echo "$KUBERNETES_MODE" | awk '{print tolower($0)}') == "yes" ]]; then echo "Kubernetes" > /usr/share/bunkerweb/INTEGRATION -elif [[ $(echo "$AUTOCONF_MODE" | awk '{print tolower($0)}') == "yes" ]] ; then +elif [[ $(echo "$AUTOCONF_MODE" | awk '{print tolower($0)}') == "yes" ]]; then echo "Autoconf" > /usr/share/bunkerweb/INTEGRATION else - echo "Docker" > /usr/share/bunkerweb/INTEGRATION + echo "Docker" > /usr/share/bunkerweb/INTEGRATION fi -python3 -m gunicorn --config gunicorn.conf.py +# Start a temporary Gunicorn process with a special logger configuration. +python3 -m gunicorn --logger-class utils.logger.TmpUiLogger --config utils/tmp-gunicorn.conf.py -if [ -f /var/tmp/bunkerweb/ui.healthy ] ; then - rm /var/tmp/bunkerweb/ui.healthy +# Start the main Gunicorn process with the standard logger configuration. +python3 -m gunicorn --logger-class utils.logger.UiLogger --config utils/gunicorn.conf.py + +# Capture the exit code of the main Gunicorn process. +exit_code=$? + +# Log the exit status of the main web UI process for debugging purposes. +log "ENTRYPOINT" "ℹ️" "Web UI stopped with exit code $exit_code" + +# If the temporary web UI process is still running, wait for it to stop. +if [ -f "/var/run/bunkerweb/tmp-ui.pid" ]; then + log "ENTRYPOINT" "ℹ️" "Waiting for temporary Web UI to stop ..." + + # Wait in a loop until the temporary web UI process exits. + while [ -f "/var/run/bunkerweb/tmp-ui.pid" ]; do + sleep 1 + done + + log "ENTRYPOINT" "ℹ️" "Temporary Web UI stopped" fi -log "ENTRYPOINT" "ℹ️" "Web UI stopped" -exit 0 +# Exit the script with the same exit code as the main web UI process. +exit $exit_code diff --git a/src/ui/main.py b/src/ui/main.py index b913aeabf..6693ae438 100644 --- a/src/ui/main.py +++ b/src/ui/main.py @@ -15,10 +15,12 @@ for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (( if deps_path not in sys_path: sys_path.append(deps_path) +from cachelib import FileSystemCache from flask import Flask, Response, flash as flask_flash, jsonify, make_response, redirect, render_template, request, session, url_for from flask_executor import Executor 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_session import Session from flask_wtf.csrf import CSRFProtect, CSRFError from werkzeug.routing.exceptions import BuildError @@ -77,25 +79,28 @@ with app.app_context(): app.config["CHECK_PRIVATE_IP"] = getenv("CHECK_PRIVATE_IP", "yes").lower() == "yes" app.config["SECRET_KEY"] = FLASK_SECRET - app.config["SESSION_COOKIE_NAME"] = "__Host-bw_ui_session" app.config["SESSION_COOKIE_PATH"] = "/" - app.config["SESSION_COOKIE_SECURE"] = True - app.config["SESSION_COOKIE_PARTITIONED"] = True app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" - app.config["REMEMBER_COOKIE_NAME"] = "__Host-bw_ui_remember_token" app.config["REMEMBER_COOKIE_PATH"] = "/" - app.config["REMEMBER_COOKIE_SECURE"] = True app.config["REMEMBER_COOKIE_HTTPONLY"] = True app.config["REMEMBER_COOKIE_SAMESITE"] = "Lax" - app.config["WTF_CSRF_SSL_STRICT"] = False app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 86400 app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB app.config["SCRIPT_NONCE"] = "" - app.config["EXECUTOR_MAX_WORKERS"] = 4 + # Session management + app.config["SESSION_TYPE"] = "cachelib" + app.config["SESSION_ID_LENGTH"] = 64 + app.config["SESSION_CACHELIB"] = FileSystemCache(threshold=500, cache_dir=LIB_DIR.joinpath("ui_sessions_cache")) + sess = Session() + sess.init_app(app) + + # CSRF protection + csrf = CSRFProtect() + csrf.init_app(app) principal = Principal() principal.init_app(app) @@ -131,11 +136,8 @@ with app.app_context(): url_for=custom_url_for, ) - # CSRF protection - csrf = CSRFProtect() - csrf.init_app(app) - # Executor + app.config["EXECUTOR_MAX_WORKERS"] = 4 executor = Executor(app) @@ -319,6 +321,20 @@ def before_request(): response.headers["Retry-After"] = 30 # Clients should retry after 30 seconds # type: ignore return response + if request.environ.get("HTTP_X_FORWARDED_FOR") is not None: + # Requests from the reverse proxy + app.config["SESSION_COOKIE_NAME"] = "__Host-bw_ui_session" + app.config["SESSION_COOKIE_SECURE"] = True + app.config["REMEMBER_COOKIE_NAME"] = "__Host-bw_ui_remember_token" + app.config["REMEMBER_COOKIE_SECURE"] = True + else: + # Requests from other sources + app.config["SESSION_COOKIE_NAME"] = "bw_ui_session" + app.config["SESSION_COOKIE_SECURE"] = False + app.config["REMEMBER_COOKIE_NAME"] = "bw_ui_remember_token" + app.config["REMEMBER_COOKIE_SECURE"] = False + app.config["WTF_CSRF_SSL_STRICT"] = False + app.config["SCRIPT_NONCE"] = token_urlsafe(32) if not request.path.startswith(("/css/", "/img/", "/js/", "/json/", "/fonts/", "/libs/")): @@ -372,8 +388,8 @@ def set_security_headers(response): response.headers["Content-Security-Policy"] = ( "object-src 'none';" + " frame-ancestors 'self';" - + " default-src 'self' https://www.bunkerweb.io https://assets.bunkerity.com https://bunkerity.us1.list-manage.com https://api.github.com;" - + f" script-src 'self' 'nonce-{app.config['SCRIPT_NONCE']}';" + + " default-src https: http: 'self' https://www.bunkerweb.io https://assets.bunkerity.com https://bunkerity.us1.list-manage.com https://api.github.com;" + + f" script-src https: http: 'self' 'nonce-{app.config['SCRIPT_NONCE']}' 'strict-dynamic' 'unsafe-inline';" + " style-src 'self' 'unsafe-inline';" + " img-src 'self' data: blob: https://assets.bunkerity.com https://*.tile.openstreetmap.org;" + " font-src 'self' data:;" @@ -382,7 +398,7 @@ def set_security_headers(response): + ( " connect-src *;" if request.path.startswith(("/check", "/setup")) - else " connect-src 'self' https://api.github.com/repos/bunkerity/bunkerweb https://www.bunkerweb.io/api/posts/0/3;" + else " connect-src https: http: 'self' https://api.github.com/repos/bunkerity/bunkerweb https://www.bunkerweb.io/api/posts/0/3;" ) ) diff --git a/src/ui/requirements.in b/src/ui/requirements.in index 964db240d..e9b1dfd4f 100644 --- a/src/ui/requirements.in +++ b/src/ui/requirements.in @@ -4,6 +4,7 @@ Flask==3.1.0 Flask-executor==1.0.0 Flask-Login==0.6.3 Flask-Principal==0.4.0 +Flask-Session[cachelib]==0.8.0 Flask-WTF==1.2.2 gunicorn[gthread]==23.0.0 python-magic==0.4.27 diff --git a/src/ui/requirements.txt b/src/ui/requirements.txt index cad6b80ad..0563e9c1d 100644 --- a/src/ui/requirements.txt +++ b/src/ui/requirements.txt @@ -41,6 +41,10 @@ blinker==1.9.0 \ # via # flask # flask-principal +cachelib==0.13.0 \ + --hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \ + --hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516 + # via flask-session click==8.1.7 \ --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de @@ -53,6 +57,7 @@ flask==3.1.0 \ # flask-executor # flask-login # flask-principal + # flask-session # flask-wtf flask-executor==1.0.0 \ --hash=sha256:4bc113def5d9f1c7ff272ff7ba09f843468eab80469bdbd21625a111b2c8ae7b \ @@ -65,6 +70,12 @@ flask-login==0.6.3 \ flask-principal==0.4.0 \ --hash=sha256:f5d6134b5caebfdbb86f32d56d18ee44b080876a27269560a96ea35f75c99453 # via -r requirements.in +flask-session==0.8.0 \ + --hash=sha256:20e045eb01103694e70be4a49f3a80dbb1b57296a22dc6f44bbf3f83ef0742ff \ + --hash=sha256:5dae6e9ddab334f8dc4dea4305af37851f4e7dc0f484caf3351184001195e3b7 + # via + # -r requirements.in + # flask-session flask-wtf==1.2.2 \ --hash=sha256:79d2ee1e436cf570bccb7d916533fa18757a2f18c290accffab1b9a0b684666b \ --hash=sha256:e93160c5c5b6b571cf99300b6e01b72f9a101027cab1579901f8b10c5daf0b70 @@ -153,6 +164,44 @@ markupsafe==3.0.2 \ # jinja2 # werkzeug # wtforms +msgspec==0.18.6 \ + --hash=sha256:06acbd6edf175bee0e36295d6b0302c6de3aaf61246b46f9549ca0041a9d7177 \ + --hash=sha256:0e24539b25c85c8f0597274f11061c102ad6b0c56af053373ba4629772b407be \ + --hash=sha256:1003c20bfe9c6114cc16ea5db9c5466e49fae3d7f5e2e59cb70693190ad34da0 \ + --hash=sha256:1a76b60e501b3932782a9da039bd1cd552b7d8dec54ce38332b87136c64852dd \ + --hash=sha256:37f67c1d81272131895bb20d388dd8d341390acd0e192a55ab02d4d6468b434c \ + --hash=sha256:3ac4dd63fd5309dd42a8c8c36c1563531069152be7819518be0a9d03be9788e4 \ + --hash=sha256:40a4df891676d9c28a67c2cc39947c33de516335680d1316a89e8f7218660410 \ + --hash=sha256:41cf758d3f40428c235c0f27bc6f322d43063bc32da7b9643e3f805c21ed57b4 \ + --hash=sha256:46eb2f6b22b0e61c137e65795b97dc515860bf6ec761d8fb65fdb62aa094ba61 \ + --hash=sha256:6aa85198f8f154cf35d6f979998f6dadd3dc46a8a8c714632f53f5d65b315c07 \ + --hash=sha256:7481355a1adcf1f08dedd9311193c674ffb8bf7b79314b4314752b89a2cf7f1c \ + --hash=sha256:77f30b0234eceeff0f651119b9821ce80949b4d667ad38f3bfed0d0ebf9d6d8f \ + --hash=sha256:9080eb12b8f59e177bd1eb5c21e24dd2ba2fa88a1dbc9a98e05ad7779b54c681 \ + --hash=sha256:974d3520fcc6b824a6dedbdf2b411df31a73e6e7414301abac62e6b8d03791b4 \ + --hash=sha256:9da21f804c1a1471f26d32b5d9bc0480450ea77fbb8d9db431463ab64aaac2cf \ + --hash=sha256:a59fc3b4fcdb972d09138cb516dbde600c99d07c38fd9372a6ef500d2d031b4e \ + --hash=sha256:a6896f4cd5b4b7d688018805520769a8446df911eb93b421c6c68155cdf9dd5a \ + --hash=sha256:ad237100393f637b297926cae1868b0d500f764ccd2f0623a380e2bcfb2809ca \ + --hash=sha256:b5c390b0b0b7da879520d4ae26044d74aeee5144f83087eb7842ba59c02bc090 \ + --hash=sha256:c3232fabacef86fe8323cecbe99abbc5c02f7698e3f5f2e248e3480b66a3596b \ + --hash=sha256:c61ee4d3be03ea9cd089f7c8e36158786cd06e51fbb62529276452bbf2d52ece \ + --hash=sha256:c8355b55c80ac3e04885d72db515817d9fbb0def3bab936bba104e99ad22cf46 \ + --hash=sha256:cc001cf39becf8d2dcd3f413a4797c55009b3a3cdbf78a8bf5a7ca8fdb76032c \ + --hash=sha256:ce13981bfa06f5eb126a3a5a38b1976bddb49a36e4f46d8e6edecf33ccf11df1 \ + --hash=sha256:d0feb7a03d971c1c0353de1a8fe30bb6579c2dc5ccf29b5f7c7ab01172010492 \ + --hash=sha256:d5351afb216b743df4b6b147691523697ff3a2fc5f3d54f771e91219f5c23aaa \ + --hash=sha256:d70cb3d00d9f4de14d0b31d38dfe60c88ae16f3182988246a9861259c6722af6 \ + --hash=sha256:d86f5071fe33e19500920333c11e2267a31942d18fed4d9de5bc2fbab267d28c \ + --hash=sha256:db1d8626748fa5d29bbd15da58b2d73af25b10aa98abf85aab8028119188ed57 \ + --hash=sha256:e3b524df6ea9998bbc99ea6ee4d0276a101bcc1aa8d14887bb823914d9f60d07 \ + --hash=sha256:e77e56ffe2701e83a96e35770c6adb655ffc074d530018d1b584a8e635b4f36f \ + --hash=sha256:e97dec6932ad5e3ee1e3c14718638ba333befc45e0661caa57033cd4cc489466 \ + --hash=sha256:f7d9faed6dfff654a9ca7d9b0068456517f63dbc3aa704a527f493b9200b210a \ + --hash=sha256:fac5834e14ac4da1fca373753e0c4ec9c8069d1fe5f534fa5208453b6065d5be \ + --hash=sha256:fd62e5818731a66aaa8e9b0a1e5543dc979a46278da01e85c3c9a1a4f047ef7e \ + --hash=sha256:fda4c357145cf0b760000c4ad597e19b53adf01382b711f281720a10a0fe72b7 + # via flask-session packaging==24.2 \ --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \ --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f @@ -265,9 +314,9 @@ regex==2024.11.6 \ --hash=sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9 \ --hash=sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91 # via -r requirements.in -six==1.16.0 \ - --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ - --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via python-dateutil soupsieve==2.6 \ --hash=sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb \ diff --git a/src/ui/temp.py b/src/ui/temp.py new file mode 100644 index 000000000..667c93bd8 --- /dev/null +++ b/src/ui/temp.py @@ -0,0 +1,107 @@ +from os import _exit, getenv, sep +from os.path import join +from pathlib import Path +from secrets import token_urlsafe +from signal import SIGINT, SIGTERM, signal +from subprocess import PIPE, Popen, call +from sys import path as sys_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 flask import Flask, render_template, request + +from logger import setup_logger # type: ignore + +LOGGER = setup_logger("TMP-UI", getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO"))) + +TMP_DIR = Path(sep, "var", "tmp", "bunkerweb") +ERROR_FILE = TMP_DIR.joinpath("ui.error") + + +def stop(status): + pid_file = Path(sep, "var", "run", "bunkerweb", "tmp-ui.pid") + if pid_file.is_file(): + pid = pid_file.read_bytes() + else: + p = Popen(["pgrep", "-f", "gunicorn"], stdout=PIPE) + pid, _ = p.communicate() + call(["kill", "-SIGTERM", pid.strip().decode().split("\n")[0]]) + _exit(status) + + +def handle_stop(signum, frame): + LOGGER.info("Caught stop operation") + LOGGER.info("Stopping web ui ...") + stop(0, False) + + +signal(SIGINT, handle_stop) +signal(SIGTERM, handle_stop) + +app = Flask(__name__, static_url_path="/", static_folder="app/static", template_folder="app/templates") +app.url_map.strict_slashes = False + +app.config["SCRIPT_NONCE"] = "" + + +@app.before_request +def before_request(): + app.config["SCRIPT_NONCE"] = token_urlsafe(32) + + +@app.context_processor +def inject_variables(): + return dict( + current_endpoint=request.path.split("/")[-1], + script_nonce=app.config["SCRIPT_NONCE"], + ) + + +@app.after_request +def set_security_headers(response): + """Set the security headers.""" + # * Content-Security-Policy header to prevent XSS attacks + response.headers["Content-Security-Policy"] = ( + "object-src 'none';" + + " frame-ancestors 'self';" + + " default-src 'self'" + + f" script-src https: http: 'self' 'nonce-{app.config['SCRIPT_NONCE']}' 'strict-dynamic' 'unsafe-inline';" + + " style-src 'self' 'unsafe-inline';" + + " img-src 'self' data: blob: https://assets.bunkerity.com;" + + " base-uri 'self';" + + " block-all-mixed-content;" + ) + + # * X-Frames-Options header to prevent clickjacking + response.headers["X-Frame-Options"] = "DENY" + + # * X-Content-Type-Options header to prevent MIME sniffing + response.headers["X-Content-Type-Options"] = "nosniff" + + # * Referrer-Policy header to prevent leaking of sensitive data + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + return response + + +@app.errorhandler(404) +def not_found_handler(error): + message = "BunkerWeb UI is starting..." + error = "" + if ERROR_FILE.is_file(): + message = "BunkerWeb UI encountered an error while starting." + error = ERROR_FILE.read_text() + return render_template("starting.html", message=message, error=error) + + +@app.route("/", defaults={"path": ""}) +@app.route("/") +def catch_all(path): + message = "BunkerWeb UI is starting..." + error = "" + if ERROR_FILE.is_file(): + message = "BunkerWeb UI encountered an error while starting." + error = ERROR_FILE.read_text() + return render_template("starting.html", message=message, error=error) diff --git a/src/ui/utils/__init__.py b/src/ui/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ui/gunicorn.conf.py b/src/ui/utils/gunicorn.conf.py similarity index 80% rename from src/ui/gunicorn.conf.py rename to src/ui/utils/gunicorn.conf.py index 5d1aa1aab..60bdf0ef2 100644 --- a/src/ui/gunicorn.conf.py +++ b/src/ui/utils/gunicorn.conf.py @@ -6,8 +6,10 @@ from os.path import join from pathlib import Path from secrets import token_hex from stat import S_IRUSR, S_IWUSR +from subprocess import call from sys import exit, path as sys_path from time import sleep +from traceback import format_exc 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: @@ -26,9 +28,13 @@ LIB_DIR = Path(sep, "var", "lib", "bunkerweb") UI_DATA_FILE = TMP_DIR.joinpath("ui_data.json") HEALTH_FILE = TMP_DIR.joinpath("ui.healthy") +ERROR_FILE = TMP_DIR.joinpath("ui.error") +TMP_PID_FILE = RUN_DIR.joinpath("tmp-ui.pid") PID_FILE = RUN_DIR.joinpath("ui.pid") +UI_SESSIONS_CACHE = LIB_DIR.joinpath("ui_sessions_cache") + FLASK_SECRET_FILE = LIB_DIR.joinpath(".flask_secret") FLASK_SECRET_HASH_FILE = FLASK_SECRET_FILE.with_suffix(".hash") # File to store hash of Flask secret TOTP_SECRETS_FILE = LIB_DIR.joinpath(".totp_secrets.json") @@ -51,6 +57,7 @@ limit_request_line = 0 limit_request_fields = 32768 limit_request_field_size = 0 reuse_port = True +daemon = False chdir = join(sep, "usr", "share", "bunkerweb", "ui") umask = 0x027 pidfile = PID_FILE.as_posix() @@ -86,6 +93,8 @@ def on_starting(server): RUN_DIR.mkdir(parents=True, exist_ok=True) LIB_DIR.mkdir(parents=True, exist_ok=True) + ERROR_FILE.unlink(missing_ok=True) + LOGGER = setup_logger("UI", getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO"))) def set_secure_permissions(file_path: Path): @@ -137,9 +146,12 @@ def on_starting(server): set_secure_permissions(FLASK_SECRET_HASH_FILE) LOGGER.info("Flask secret securely stored.") - LOGGER.info("Flask secret hash stored for change detection.") + LOGGER.info("Flask secret hash securely stored for change detection.") except Exception as e: - LOGGER.critical(f"An error occurred while handling the Flask secret: {e}") + message = f"An error occurred while handling the Flask secret: {e}" + LOGGER.debug(format_exc()) + LOGGER.critical(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) # * Handle TOTP secrets @@ -201,15 +213,25 @@ def on_starting(server): set_secure_permissions(TOTP_HASH_FILE) LOGGER.info("TOTP secrets securely stored.") - LOGGER.info("TOTP environment hash stored for change detection.") + LOGGER.info("TOTP environment hash securely stored for change detection.") except Exception as e: - LOGGER.critical(f"An error occurred while handling TOTP secrets: {e}") + message = f"An error occurred while handling TOTP secrets: {e}" + LOGGER.debug(format_exc()) + LOGGER.critical(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) DB = UIDatabase(LOGGER) + current_time = datetime.now().astimezone() ready = False while not ready: + if (datetime.now().astimezone() - current_time).total_seconds() > 60: + message = "Timed out while waiting for the database to be initialized." + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") + exit(1) + db_metadata = DB.get_metadata() ui_roles = DB.get_ui_roles(as_dict=True) if isinstance(db_metadata, str) or not db_metadata["is_initialized"] or (isinstance(ui_roles, str) and "doesn't exist" in ui_roles): @@ -222,21 +244,35 @@ def on_starting(server): if not ui_roles: ret = DB.create_ui_role("admin", "Admins can create new users, edit and read the data.", ["manage", "write", "read"]) if ret: - LOGGER.error(f"Couldn't create the admin role in the database: {ret}") + message = f"Couldn't create the admin role in the database: {ret}" + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) ret = DB.create_ui_role("writer", "Writers can edit and read the data but can't create new users.", ["write", "read"]) if ret: - LOGGER.error(f"Couldn't create the admin role in the database: {ret}") + message = f"Couldn't create the writer role in the database: {ret}" + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) ret = DB.create_ui_role("reader", "Readers can only read the data.", ["read"]) if ret: - LOGGER.error(f"Couldn't create the admin role in the database: {ret}") + message = f"Couldn't create the reader role in the database: {ret}" + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) + current_time = datetime.now().astimezone() + ADMIN_USER = "Error" while ADMIN_USER == "Error": + if (datetime.now().astimezone() - current_time).total_seconds() > 60: + message = "Timed out while waiting for the admin user." + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") + exit(1) + try: ADMIN_USER = DB.get_ui_user(as_dict=True) except BaseException as e: @@ -277,7 +313,7 @@ def on_starting(server): if err: LOGGER.error(f"Couldn't update the admin user in the database: {err}") else: - LOGGER.info("The admin user was updated successfully") + 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 env_admin_username and env_admin_password: @@ -285,17 +321,21 @@ def on_starting(server): if not DEBUG: if len(user_name) > 256: - LOGGER.error("The admin username is too long. It must be less than 256 characters.") + message = "The admin username is too long. It must be less than 256 characters." + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) elif not USER_PASSWORD_RX.match(env_admin_password): - 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 (#@?!$%^&*-)." - ) + message = "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 (#@?!$%^&*-)." + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) ret = DB.create_ui_user(user_name, gen_password_hash(env_admin_password), ["admin"], admin=True) if ret and "already exists" not in ret: - LOGGER.error(f"Couldn't create the admin user in the database: {ret}") + message = f"Couldn't create the admin user in the database: {ret}" + LOGGER.critical(message) + ERROR_FILE.write_text(message, encoding="utf-8") exit(1) latest_release = None @@ -325,9 +365,24 @@ def on_starting(server): LOGGER.info( "UI will disconnect users that have their IP address changed during a session" - + (" except for private IP addresses" if getenv("CHECK_PRIVATE_IP", "yes").lower() == "no" else "") + + (" except for private IP addresses." if getenv("CHECK_PRIVATE_IP", "yes").lower() == "no" else ".") ) + UI_SESSIONS_CACHE.mkdir(parents=True, exist_ok=True) + + if TMP_PID_FILE.is_file(): + LOGGER.info("Stopping temporary UI...") + call(["kill", "-SIGQUIT", TMP_PID_FILE.read_text().strip()]) + current_time = datetime.now().astimezone() + while TMP_PID_FILE.is_file(): + if (datetime.now().astimezone() - current_time).total_seconds() > 60: + message = "Timed out while waiting for the temporary UI to stop." + LOGGER.error(message) + ERROR_FILE.write_text(message, encoding="utf-8") + exit(1) + sleep(1) + LOGGER.info("Temporary UI is stopped") + LOGGER.info("UI is ready") diff --git a/src/ui/utils/logger.py b/src/ui/utils/logger.py new file mode 100644 index 000000000..261639a3c --- /dev/null +++ b/src/ui/utils/logger.py @@ -0,0 +1,42 @@ +from logging import getLogger +from os.path import join, sep +from sys import path as sys_path +from threading import Lock + +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 gunicorn.glogging import Logger + +from logger import DATE_FORMAT, LOG_FORMAT # type: ignore + + +class UiLogger(Logger): + + error_log = None + access_log = None + + error_fmt = LOG_FORMAT + datefmt = DATE_FORMAT + + def __init__(self, cfg): + if not self.error_log: + self.error_log = getLogger("UI") + self.error_log.propagate = False + if not self.access_log: + self.access_log = getLogger("UI.access") + self.access_log.propagate = False + self.error_handlers = [] + self.access_handlers = [] + self.logfile = None + self.lock = Lock() + self.cfg = cfg + self.setup(cfg) + + +class TmpUiLogger(UiLogger): + def __init__(self, cfg): + self.error_log = getLogger("TMP-UI") + self.access_log = getLogger("TMP-UI.access") + super().__init__(cfg) diff --git a/src/ui/utils/tmp-gunicorn.conf.py b/src/ui/utils/tmp-gunicorn.conf.py new file mode 100644 index 000000000..77b5735bc --- /dev/null +++ b/src/ui/utils/tmp-gunicorn.conf.py @@ -0,0 +1,80 @@ +from os import getenv, sep +from os.path import join +from pathlib import Path +from sys import path as sys_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 logger import setup_logger # type: ignore + +TMP_DIR = Path(sep, "var", "tmp", "bunkerweb") +RUN_DIR = Path(sep, "var", "run", "bunkerweb") +LIB_DIR = Path(sep, "var", "lib", "bunkerweb") + +HEALTH_FILE = TMP_DIR.joinpath("tmp-ui.healthy") +PID_FILE = RUN_DIR.joinpath("tmp-ui.pid") + +LOG_LEVEL = getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "info")) +LISTEN_ADDR = getenv("LISTEN_ADDR", "0.0.0.0") +LISTEN_PORT = getenv("LISTEN_PORT", "7000") +FORWARDED_ALLOW_IPS = getenv("FORWARDED_ALLOW_IPS", "*") +CAPTURE_OUTPUT = getenv("CAPTURE_OUTPUT", "no").lower() == "yes" + +wsgi_app = "temp:app" +proc_name = "bunkerweb-tmp-ui" +accesslog = join(sep, "var", "log", "bunkerweb", "tmp-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 = join(sep, "var", "log", "bunkerweb", "tmp-ui.log") +capture_output = CAPTURE_OUTPUT +limit_request_line = 0 +limit_request_fields = 32768 +limit_request_field_size = 0 +reuse_port = True +daemon = True +chdir = join(sep, "usr", "share", "bunkerweb", "ui") +umask = 0x027 +pidfile = PID_FILE.as_posix() +worker_tmp_dir = join(sep, "dev", "shm") +tmp_upload_dir = join(sep, "var", "tmp", "bunkerweb", "ui") +secure_scheme_headers = {} +forwarded_allow_ips = FORWARDED_ALLOW_IPS +pythonpath = join(sep, "usr", "share", "bunkerweb", "deps", "python") + "," + join(sep, "usr", "share", "bunkerweb", "ui") +proxy_allow_ips = "*" +casefold_http_method = True +workers = 1 +bind = f"{LISTEN_ADDR}:{LISTEN_PORT}" +worker_class = "gthread" +threads = 2 +max_requests_jitter = 0 +graceful_timeout = 0 + +DEBUG = getenv("DEBUG", False) + +loglevel = "debug" if DEBUG else LOG_LEVEL.lower() + +if DEBUG: + reload = True + reload_extra_files = [ + file.as_posix() + for file in Path(sep, "usr", "share", "bunkerweb", "ui", "app").rglob("*") + if "__pycache__" not in file.parts and "static" not in file.parts + ] + + +def on_starting(server): + TMP_DIR.mkdir(parents=True, exist_ok=True) + RUN_DIR.mkdir(parents=True, exist_ok=True) + LIB_DIR.mkdir(parents=True, exist_ok=True) + + LOGGER = setup_logger("TMP-UI", getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO"))) + LOGGER.info("TMP-UI is ready") + + +def when_ready(server): + HEALTH_FILE.write_text("ok", encoding="utf-8") + + +def on_exit(server): + HEALTH_FILE.unlink(missing_ok=True)