feat: add temporary UI service with logging and session management; update dependencies and Dockerfile

This commit is contained in:
Théophile Diot 2024-12-09 11:44:54 +01:00
parent 10c9f29f13
commit f56524a35d
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
17 changed files with 550 additions and 71 deletions

View file

@ -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"

View file

@ -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 <contact@bunkerity.com>"
LABEL version="1.6.0-beta"

View file

@ -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", [])

View file

@ -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

View file

@ -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);
}
});

View file

@ -99,11 +99,11 @@
{% endif %}
<!-- Page CSS -->
<!-- Page -->
{% if current_endpoint in ("setup", "login", "totp", "loading") %}
{% if starting or current_endpoint in ("setup", "login", "totp", "loading") %}
<link rel="stylesheet"
href="{{ url_for('static', filename='css/pages/login.css') }}"
nonce="{{ style_nonce }}" />
{% elif current_endpoint == "loading" %}
{% elif current_endpoint in ("loading", "starting") %}
<link rel="stylesheet"
href="{{ url_for('static', filename='css/pages/loading.css') }}"
nonce="{{ style_nonce }}" />
@ -124,7 +124,9 @@
nonce="{{ script_nonce }}"></script>
</head>
<body>
{% if not starting %}
<input type="hidden" id="home-path" value="{{ url_for('home') }}" />
{% endif %}
<input type="hidden" id="is-read-only" value="{{ is_readonly }}" />
<input type="hidden" id="theme" value="{{ theme }}" />
<input type="hidden"
@ -140,7 +142,7 @@
id="avatar-url-white"
value="{{ url_for('static', filename='img/avatar_profil_BW-white.png') }}" />
<!-- prettier-ignore -->
{% if current_endpoint != "loading" %}
{% if not starting and current_endpoint != "loading" %}
{% include "flash.html" %}
{% endif %}
{% block page %}{% endblock %}
@ -179,7 +181,7 @@
<script src="{{ url_for('static', filename='libs/ace/src-min/ace.js') }}"
nonce="{{ script_nonce }}"></script>
{% 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) %}
<script src="{{ url_for('static', filename='libs/lottie-player/lottie-player.min.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
@ -191,7 +193,7 @@
nonce="{{ script_nonce }}"></script>
<script src="{{ url_for('static', filename='js/common.js') }}"
nonce="{{ script_nonce }}"></script>
{% if current_endpoint in ("setup", "login", "totp", "loading") %}
{% if starting or current_endpoint in ("setup", "login", "totp", "loading") %}
<script src="{{ url_for('static', filename='js/pages/login.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
@ -208,6 +210,10 @@
nonce="{{ script_nonce }}"></script>
{% endif %}
<!-- Page JS -->
{% if starting %}
<script src="{{ url_for('static', filename='js/pages/starting.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
{% if current_endpoint == "setup" %}
<script src="{{ url_for('static', filename='js/pages/setup.js') }}"
nonce="{{ script_nonce }}"></script>
@ -262,6 +268,6 @@
<script src="{{ url_for('static', filename='js/pages/plugin_page.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
<script async defer src="{{ url_for('static', filename='js/buttons.js') }}"></script>
<script async defer src="{{ url_for('static', filename='js/buttons.js') }}" nonce="{{ script_nonce }}"></script>
</body>
</html>

View file

@ -0,0 +1,49 @@
{% set starting = True %}
{% extends "base.html" %}
{% block page %}
<div class="bg-{% if theme == 'light' %}light{% else %}dark{% endif %}-subtle">
<div class="login-background d-flex justify-content-center">
<div class="position-absolute top-0 p-3 w-100 w-lg-70">
<div class="bg-bw-green position-relative w-100 p-2 text-white rounded fw-bold overflow-hidden">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1 overflow-hidden me-2">
<div id="banner-container">
<p id="banner-text" class="mb-0 slide-in">
Get the most of BunkerWeb by upgrading to the PRO version. More info and free trial <a class="light-href text-white-80"
target="_blank"
rel="noopener"
href="https://panel.bunkerweb.io/?utm_campaign=self&utm_source=banner#pro">here</a>.
</p>
</div>
</div>
<i id="next-news" role="button" class='bx bx-sm bx-chevron-right'></i>
</div>
</div>
</div>
<div class="container-xxl">
<div class="authentication-wrapper authentication-basic container-p-y">
<div class="container-xxl d-flex justify-content-center align-items-center">
<div class="authentication-inner">
<div class="layout-main-wrapper mt-0 mb-0">
<div class="layout-main-placeholder d-flex justify-content-center align-items-center">
<lottie-player src="{{ url_for('static', filename='json/blockhaus.min.json') }}" background="transparent" speed="1" class="img-fluid" loop autoplay></lottie-player>
</div>
{% if message %}
<div class="layout-main-info mt-1 mb-1">
<h3 class="mb-0 don-jose">{{ message }}</h3>
</div>
{% endif %}
{% if error %}
<div id="ui-error-message" class="layout-main-info mt-1 mb-1">
<h5 class="mb-0 don-jose text-danger">{{ error }}</h5>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -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)

View file

@ -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

View file

@ -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;"
)
)

View file

@ -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

View file

@ -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 \

107
src/ui/temp.py Normal file
View file

@ -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("/<path:path>")
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)

0
src/ui/utils/__init__.py Normal file
View file

View file

@ -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")

42
src/ui/utils/logger.py Normal file
View file

@ -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)

View file

@ -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)