bunkerweb/ui/main.py
2022-11-08 17:14:33 +01:00

1405 lines
50 KiB
Python
Executable file

from subprocess import DEVNULL, STDOUT, run
from sys import path as sys_path, exit as sys_exit, modules as sys_modules
sys_path.append("/opt/bunkerweb/ui/deps/python")
from bs4 import BeautifulSoup
from copy import deepcopy
from datetime import datetime, timezone
from dateutil.parser import parse as dateutil_parse
from docker import DockerClient
from docker.errors import (
NotFound as docker_NotFound,
APIError as docker_APIError,
DockerException,
)
from flask import (
Flask,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
url_for,
)
from flask_login import LoginManager, login_required, login_user, logout_user
from flask_wtf.csrf import CSRFProtect, CSRFError, generate_csrf
from json import JSONDecodeError, load as json_load
from jinja2 import Template
from os import chmod, getenv, getpid, listdir, mkdir, walk
from os.path import exists, isdir, isfile, join
from re import match as re_match
from requests import get
from requests.utils import default_headers
from shutil import rmtree, copytree, chown
from tarfile import CompressionError, HeaderError, ReadError, TarError, open as tar_open
from threading import Thread
from time import time
from traceback import format_exc
from typing import Optional
from uuid import uuid4
from zipfile import BadZipFile, ZipFile
sys_path.append("/opt/bunkerweb/utils")
sys_path.append("/opt/bunkerweb/api")
sys_path.append("/opt/bunkerweb/db")
from src.Instances import Instances
from src.ConfigFiles import ConfigFiles
from src.Config import Config
from src.ReverseProxied import ReverseProxied
from src.User import User
from utils import (
check_settings,
env_to_summary_class,
form_plugin_gen,
form_service_gen,
form_service_gen_multiple,
form_service_gen_multiple_values,
gen_folders_tree_html,
get_variables,
path_to_dict,
)
from logger import setup_logger
from Database import Database
logger = setup_logger("UI", getenv("LOG_LEVEL", "INFO"))
# Flask app
app = Flask(
__name__,
static_url_path="/",
static_folder="static",
template_folder="templates",
)
app.wsgi_app = ReverseProxied(app.wsgi_app)
# Set variables and instantiate objects
vars = get_variables()
if "ABSOLUTE_URI" not in vars:
logger.error("ABSOLUTE_URI is not set")
sys_exit(1)
elif "ADMIN_USERNAME" not in vars:
logger.error("ADMIN_USERNAME is not set")
sys_exit(1)
elif "ADMIN_PASSWORD" not in vars:
logger.error("ADMIN_PASSWORD is not set")
sys_exit(1)
if not vars["FLASK_ENV"] == "development" and vars["ADMIN_PASSWORD"] == "changeme":
logger.error("Please change the default admin password.")
sys_exit(1)
if not vars["ABSOLUTE_URI"].endswith("/"):
vars["ABSOLUTE_URI"] += "/"
if not vars["FLASK_ENV"] == "development" and vars["ABSOLUTE_URI"].endswith(
"/changeme/"
):
logger.error("Please change the default URL.")
sys_exit(1)
with open("/opt/bunkerweb/tmp/ui.pid", "w") as f:
f.write(str(getpid()))
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
user = User(vars["ADMIN_USERNAME"], vars["ADMIN_PASSWORD"])
PLUGIN_KEYS = [
"id",
"order",
"name",
"description",
"version",
"settings",
]
integration = "Linux"
if getenv("KUBERNETES_MODE", "no") == "yes":
integration = "Kubernetes"
elif getenv("SWARM_MODE", "no") == "yes":
integration = "Swarm"
elif getenv("AUTOCONF_MODE", "no") == "yes":
integration = "Autoconf"
try:
docker_client: DockerClient = DockerClient(
base_url=vars.get("DOCKER_HOST", "unix:///var/run/docker.sock")
)
integration = "Cluster"
except (docker_APIError, DockerException):
logger.warning("No docker host found")
docker_client = None
db = Database(logger)
try:
app.config.update(
DEBUG=True,
SECRET_KEY=vars["FLASK_SECRET"],
ABSOLUTE_URI=vars["ABSOLUTE_URI"],
INSTANCES=Instances(docker_client, integration),
CONFIG=Config(logger, db),
CONFIGFILES=ConfigFiles(logger, db),
SESSION_COOKIE_DOMAIN=vars["ABSOLUTE_URI"]
.replace("http://", "")
.replace("https://", "")
.split("/")[0],
WTF_CSRF_SSL_STRICT=False,
USER=user,
SEND_FILE_MAX_AGE_DEFAULT=86400,
PLUGIN_ARGS=None,
RELOADING=False,
TO_FLASH=[],
)
except FileNotFoundError as e:
logger.error(repr(e), e.filename)
sys_exit(1)
# Declare functions for jinja2
app.jinja_env.globals.update(env_to_summary_class=env_to_summary_class)
app.jinja_env.globals.update(form_plugin_gen=form_plugin_gen)
app.jinja_env.globals.update(form_service_gen=form_service_gen)
app.jinja_env.globals.update(form_service_gen_multiple=form_service_gen_multiple)
app.jinja_env.globals.update(
form_service_gen_multiple_values=form_service_gen_multiple_values
)
app.jinja_env.globals.update(gen_folders_tree_html=gen_folders_tree_html)
app.jinja_env.globals.update(check_settings=check_settings)
def manage_bunkerweb(method: str, operation: str = "reloads", *args):
# Do the operation
if method == "services":
if operation == "new":
operation, error = app.config["CONFIG"].new_service(args[0])
elif operation == "edit":
operation, error = app.config["CONFIG"].edit_service(args[1], args[0])
elif operation == "delete":
operation, error = app.config["CONFIG"].delete_service(args[2])
if error:
app.config["TO_FLASH"].append({"content": operation, "type": "error"})
else:
app.config["TO_FLASH"].append({"content": operation, "type": "success"})
if method == "global_config":
operation = app.config["CONFIG"].edit_global_conf(args[0])
app.config["TO_FLASH"].append({"content": operation, "type": "success"})
elif method == "plugins":
app.config["CONFIG"].reload_config()
if operation == "reload":
operation = app.config["INSTANCES"].reload_instance(args[0])
elif operation == "start":
operation = app.config["INSTANCES"].start_instance(args[0])
elif operation == "stop":
operation = app.config["INSTANCES"].stop_instance(args[0])
elif operation == "restart":
operation = app.config["INSTANCES"].restart_instance(args[0])
else:
operation = app.config["INSTANCES"].reload_instances()
if isinstance(operation, list):
for op in operation:
app.config["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"})
else:
app.config["TO_FLASH"].append({"content": operation, "type": "success"})
app.config["RELOADING"] = False
@login_manager.user_loader
def load_user(user_id):
return User(user_id, vars["ADMIN_PASSWORD"])
# CSRF protection
csrf = CSRFProtect()
csrf.init_app(app)
@app.errorhandler(CSRFError)
def handle_csrf_error(_):
"""
It takes a CSRFError exception as an argument, and returns a Flask response
:param e: The exception object
:return: A template with the error message and a 401 status code.
"""
logout_user()
flash("Wrong CSRF token !", "error")
return render_template("login.html"), 403
@app.route("/")
def index():
return redirect(url_for("login"))
@app.route("/loading")
@login_required
def loading():
next_url: str = request.values.get("next", None) or url_for("home")
message: Optional[str] = request.values.get("message", None)
return render_template(
"loading.html",
message=message if message is not None else "Loading",
next=next_url,
)
@app.route("/home")
@login_required
def home():
"""
It returns the home page
:return: The home.html template is being rendered with the following variables:
check_version: a boolean indicating whether the local version is the same as the remote version
remote_version: the remote version
version: the local version
instances_number: the number of instances
services_number: the number of services
posts: a list of posts
"""
r = get(
"https://raw.githubusercontent.com/bunkerity/bunkerweb/master/VERSION",
)
remote_version = None
if r.status_code == 200:
remote_version = r.text.strip()
with open("/opt/bunkerweb/VERSION", "r") as f:
version = f.read().strip()
headers = default_headers()
headers.update({"User-Agent": "bunkerweb-ui"})
r = get(
"https://www.bunkerity.com/wp-json/wp/v2/posts",
headers=headers,
)
formatted_posts = None
if r.status_code == 200:
posts = r.json()
formatted_posts = []
for post in posts[:5]:
formatted_posts.append(
{
"link": post["link"],
"title": post["title"]["rendered"],
"description": BeautifulSoup(
post["content"]["rendered"][
post["content"]["rendered"].index("<em>")
+ 4 : post["content"]["rendered"].index("</em>")
],
features="html.parser",
).get_text()[:256]
+ ("..." if len(post["content"]["rendered"]) > 256 else ""),
"date": dateutil_parse(post["date"]).strftime("%B %d, %Y"),
"image_url": post["yoast_head_json"]["og_image"][0]["url"].replace(
"wwwdev", "www"
),
"reading_time": post["yoast_head_json"]["twitter_misc"][
"Est. reading time"
],
}
)
instances_number = len(app.config["INSTANCES"].get_instances())
services_number = len(app.config["CONFIG"].get_services())
return render_template(
"home.html",
check_version=not remote_version or version == remote_version,
remote_version=remote_version,
version=version,
instances_number=instances_number,
services_number=services_number,
posts=formatted_posts,
)
@app.route("/instances", methods=["GET", "POST"])
@login_required
def instances():
# Manage instances
if request.method == "POST":
# Check operation
if not "operation" in request.form or not request.form["operation"] in [
"reload",
"start",
"stop",
"restart",
]:
flash("Missing operation parameter on /instances.", "error")
return redirect(url_for("loading", next=url_for("instances")))
# Check that all fields are present
if not "INSTANCE_ID" in request.form:
flash("Missing INSTANCE_ID parameter.", "error")
return redirect(url_for("loading", next=url_for("instances")))
app.config["RELOADING"] = True
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=("instances", request.form["operation"], request.form["INSTANCE_ID"]),
).start()
return redirect(
url_for(
"loading",
next=url_for("instances"),
message=(
f"{request.form['operation'].title()}ing"
if request.form["operation"] != "stop"
else "Stopping"
)
+ " instance",
)
)
# Display instances
instances = app.config["INSTANCES"].get_instances()
return render_template("instances.html", title="Instances", instances=instances)
@app.route("/services", methods=["GET", "POST"])
@login_required
def services():
if request.method == "POST":
# Check operation
if not "operation" in request.form or not request.form["operation"] in [
"new",
"edit",
"delete",
]:
flash("Missing operation parameter on /services.", "error")
return redirect(url_for("loading", next=url_for("services")))
# Check variables
variables = deepcopy(request.form.to_dict())
del variables["csrf_token"]
if (
not "OLD_SERVER_NAME" in request.form
and request.form["operation"] == "edit"
):
flash("Missing OLD_SERVER_NAME parameter.", "error")
return redirect(url_for("loading", next=url_for("services")))
if request.form["operation"] in ("new", "edit"):
del variables["operation"]
if request.form["operation"] == "edit":
del variables["OLD_SERVER_NAME"]
# Edit check fields and remove already existing ones
config = app.config["CONFIG"].get_config()
for variable in deepcopy(variables):
if variables[variable] == "on":
variables[variable] = "yes"
elif variables[variable] == "off":
variables[variable] = "no"
if (
request.form["operation"] == "edit"
and variable != "SERVER_NAME"
and variables[variable] == config.get(variable, None)
or not variables[variable].strip()
):
del variables[variable]
if not variables:
flash(
f"{variables['SERVER_NAME'].split(' ')[0]} was not edited because no values were changed."
)
return redirect(url_for("loading", next=url_for("services")))
error = app.config["CONFIG"].check_variables(variables)
if error:
return redirect(url_for("loading", next=url_for("services")))
# Delete
elif request.form["operation"] == "delete":
if not "SERVER_NAME" in request.form:
flash("Missing SERVER_NAME parameter.", "error")
return redirect(url_for("loading", next=url_for("services")))
error = app.config["CONFIG"].check_variables(
{"SERVER_NAME": request.form["SERVER_NAME"]}
)
if error:
return redirect(url_for("loading", next=url_for("services")))
error = 0
# Reload instances
app.config["RELOADING"] = True
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=(
"services",
request.form["operation"],
variables,
request.form.get("OLD_SERVER_NAME", None),
request.form.get("SERVER_NAME", None),
),
).start()
message = ""
if request.form["operation"] == "new":
message = f"Creating service {variables['SERVER_NAME'].split(' ')[0]}"
elif request.form["operation"] == "edit":
message = (
f"Saving configuration for service {request.form['OLD_SERVER_NAME']}"
)
elif request.form["operation"] == "delete":
message = f"Deleting service {request.form['SERVER_NAME']}"
return redirect(url_for("loading", next=url_for("services"), message=message))
# Display services
services = app.config["CONFIG"].get_services()
return render_template("services.html", services=services)
@app.route("/global_config", methods=["GET", "POST"])
@login_required
def global_config():
if request.method == "POST":
# Check variables
variables = deepcopy(request.form.to_dict())
del variables["csrf_token"]
# Edit check fields and remove already existing ones
config = app.config["CONFIG"].get_config()
for variable in deepcopy(variables):
if variables[variable] == "on":
variables[variable] = "yes"
elif variables[variable] == "off":
variables[variable] = "no"
if (
variables[variable] == config.get(variable, None)
or not variables[variable].strip()
):
del variables[variable]
if not variables:
flash(
f"The global configuration was not edited because no values were changed."
)
return redirect(url_for("loading", next=url_for("global_config")))
error = app.config["CONFIG"].check_variables(variables, True)
if error:
return redirect(url_for("loading", next=url_for("global_config")))
# Reload instances
app.config["RELOADING"] = True
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=(
"global_config",
"reloads",
variables,
),
).start()
return redirect(
url_for(
"loading",
next=url_for("global_config"),
message="Saving global configuration",
)
)
# Display services
services = app.config["CONFIG"].get_services()
return render_template("global_config.html", services=services)
@app.route("/configs", methods=["GET", "POST"])
@login_required
def configs():
if request.method == "POST":
operation = ""
# Check operation
if not "operation" in request.form or not request.form["operation"] in [
"new",
"edit",
"delete",
]:
flash("Missing operation parameter on /configs.", "error")
return redirect(url_for("loading", next=url_for("configs")))
# Check variables
variables = deepcopy(request.form.to_dict())
del variables["csrf_token"]
operation = app.config["CONFIGFILES"].check_path(variables["path"])
if operation:
flash(operation, "error")
return redirect(url_for("loading", next=url_for("configs"))), 500
if request.form["operation"] in ("new", "edit"):
if not app.config["CONFIGFILES"].check_name(variables["name"]):
flash(
f"Invalid {variables['type']} name. (Can only contain numbers, letters, underscores and hyphens (min 4 characters and max 32))",
"error",
)
return redirect(url_for("loading", next=url_for("configs")))
if variables["type"] == "file":
variables["name"] = f"{variables['name']}.conf"
variables["content"] = BeautifulSoup(
variables["content"], "html.parser"
).get_text()
if request.form["operation"] == "new":
if variables["type"] == "folder":
operation, error = app.config["CONFIGFILES"].create_folder(
variables["path"], variables["name"]
)
elif variables["type"] == "file":
operation, error = app.config["CONFIGFILES"].create_file(
variables["path"], variables["name"], variables["content"]
)
elif request.form["operation"] == "edit":
if variables["type"] == "folder":
operation, error = app.config["CONFIGFILES"].edit_folder(
variables["path"], variables["name"]
)
elif variables["type"] == "file":
operation, error = app.config["CONFIGFILES"].edit_file(
variables["path"], variables["name"], variables["content"]
)
if error:
flash(operation, "error")
return redirect(url_for("loading", next=url_for("configs")))
else:
operation, error = app.config["CONFIGFILES"].delete_path(variables["path"])
if error:
flash(operation, "error")
return redirect(url_for("loading", next=url_for("configs")))
flash(operation)
error = app.config["CONFIGFILES"].save_configs()
if error:
flash("Couldn't save custom configs to database", "error")
# Reload instances
app.config["RELOADING"] = True
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=("configs",),
).start()
return redirect(url_for("loading", next=url_for("configs")))
db_configs = db.get_custom_configs()
return render_template(
"configs.html",
folders=[path_to_dict("/opt/bunkerweb/configs", db_configs=db_configs)],
)
@app.route("/plugins", methods=["GET", "POST"])
@login_required
def plugins():
if request.method == "POST":
operation = ""
error = 0
if "operation" in request.form and request.form["operation"] == "delete":
# Check variables
variables = deepcopy(request.form.to_dict())
del variables["csrf_token"]
operation = app.config["CONFIGFILES"].check_path(
variables["path"], "/opt/bunkerweb/plugins/"
)
if operation:
flash(operation, "error")
return redirect(url_for("loading", next=url_for("plugins"))), 500
operation, error = app.config["CONFIGFILES"].delete_path(variables["path"])
if error:
flash(operation, "error")
return redirect(url_for("loading", next=url_for("plugins")))
else:
if not exists("/opt/bunkerweb/tmp/ui") or not listdir(
"/opt/bunkerweb/tmp/ui"
):
flash("Please upload new plugins to reload plugins", "error")
return redirect(url_for("loading", next=url_for("plugins")))
for file in listdir("/opt/bunkerweb/tmp/ui"):
if not isfile(f"/opt/bunkerweb/tmp/ui/{file}"):
continue
folder_name = ""
temp_folder_name = file.split(".")[0]
try:
if file.endswith(".zip"):
try:
with ZipFile(f"/opt/bunkerweb/tmp/ui/{file}") as zip_file:
try:
zip_file.getinfo("plugin.json")
zip_file.extractall(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}"
)
with open(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/plugin.json",
"r",
) as f:
plugin_file = json_load(f)
if not all(
key in plugin_file.keys() for key in PLUGIN_KEYS
):
raise ValueError
folder_name = plugin_file["id"]
if not app.config["CONFIGFILES"].check_name(
folder_name
):
error = 1
flash(
f"Invalid plugin name for {temp_folder_name}. (Can only contain numbers, letters, underscores and hyphens (min 4 characters and max 32))",
"error",
)
raise Exception
if exists(f"/opt/bunkerweb/plugins/{folder_name}"):
raise FileExistsError
copytree(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}",
f"/opt/bunkerweb/plugins/{folder_name}",
)
except KeyError:
zip_file.extractall(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}"
)
dirs = [
d
for d in listdir(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}"
)
if isdir(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{d}"
)
]
if (
not dirs
or len(dirs) > 1
or not exists(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{dirs[0]}/plugin.json"
)
):
raise KeyError
with open(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{dirs[0]}/plugin.json",
"r",
) as f:
plugin_file = json_load(f)
if not all(
key in plugin_file.keys() for key in PLUGIN_KEYS
):
raise ValueError
folder_name = plugin_file["id"]
if not app.config["CONFIGFILES"].check_name(
folder_name
):
error = 1
flash(
f"Invalid plugin name for {temp_folder_name}. (Can only contain numbers, letters, underscores and hyphens (min 4 characters and max 32))",
"error",
)
raise Exception
if exists(f"/opt/bunkerweb/plugins/{folder_name}"):
raise FileExistsError
copytree(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{dirs[0]}",
f"/opt/bunkerweb/plugins/{folder_name}",
)
except BadZipFile:
error = 1
flash(
f"{file} is not a valid zip file. ({folder_name if folder_name else temp_folder_name})",
"error",
)
else:
try:
with tar_open(
f"/opt/bunkerweb/tmp/ui/{file}",
errorlevel=2,
) as tar_file:
try:
tar_file.getmember("plugin.json")
tar_file.extractall(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}"
)
with open(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/plugin.json",
"r",
) as f:
plugin_file = json_load(f)
if not all(
key in plugin_file.keys() for key in PLUGIN_KEYS
):
raise ValueError
folder_name = plugin_file["id"]
if not app.config["CONFIGFILES"].check_name(
folder_name
):
error = 1
flash(
f"Invalid plugin name for {temp_folder_name}. (Can only contain numbers, letters, underscores and hyphens (min 4 characters and max 32))",
"error",
)
raise Exception
if exists(f"/opt/bunkerweb/plugins/{folder_name}"):
raise FileExistsError
copytree(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}",
f"/opt/bunkerweb/plugins/{folder_name}",
)
except KeyError:
tar_file.extractall(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}",
)
dirs = [
d
for d in listdir(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}"
)
if isdir(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{d}"
)
]
if (
not dirs
or len(dirs) > 1
or not exists(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{dirs[0]}/plugin.json"
)
):
raise KeyError
with open(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{dirs[0]}/plugin.json",
"r",
) as f:
plugin_file = json_load(f)
if not all(
key in plugin_file.keys() for key in PLUGIN_KEYS
):
raise ValueError
folder_name = plugin_file["id"]
if not app.config["CONFIGFILES"].check_name(
folder_name
):
error = 1
flash(
f"Invalid plugin name for {temp_folder_name}. (Can only contain numbers, letters, underscores and hyphens (min 4 characters and max 32))",
"error",
)
raise Exception
if exists(f"/opt/bunkerweb/plugins/{folder_name}"):
raise FileExistsError
copytree(
f"/opt/bunkerweb/tmp/ui/{temp_folder_name}/{dirs[0]}",
f"/opt/bunkerweb/plugins/{folder_name}",
)
except ReadError:
error = 1
flash(
f"Couldn't read file {file} ({folder_name if folder_name else temp_folder_name})",
"error",
)
except CompressionError:
error = 1
flash(
f"{file} is not a valid tar file ({folder_name if folder_name else temp_folder_name})",
"error",
)
except HeaderError:
error = 1
flash(
f"The file plugin.json in {file} is not valid ({folder_name if folder_name else temp_folder_name})",
"error",
)
except KeyError:
error = 1
flash(
f"{file} is not a valid plugin (plugin.json file is missing) ({folder_name if folder_name else temp_folder_name})",
"error",
)
except JSONDecodeError as e:
error = 1
flash(
f"The file plugin.json in {file} is not valid ({e.msg}: line {e.lineno} column {e.colno} (char {e.pos})) ({folder_name if folder_name else temp_folder_name})",
"error",
)
except ValueError:
error = 1
flash(
f"The file plugin.json is missing one or more of the following keys: <i>{', '.join(PLUGIN_KEYS)}</i> ({folder_name if folder_name else temp_folder_name})",
"error",
)
except FileExistsError:
error = 1
flash(
f"A plugin named {folder_name} already exists",
"error",
)
except (TarError, OSError) as e:
error = 1
flash(f"{e}", "error")
except Exception as e:
error = 1
flash(f"{e}", "error")
finally:
if error != 1:
flash(
f"Successfully created plugin: <b><i>{folder_name}</i></b>"
)
error = 0
# Fix permissions for plugins folders
for root, dirs, files in walk("/opt/bunkerweb/plugins", topdown=False):
for name in files + dirs:
chown(join(root, name), "nginx", "nginx")
chmod(join(root, name), 0o770)
if operation:
flash(operation)
# Reload instances
app.config["RELOADING"] = True
Thread(
target=manage_bunkerweb,
name="Reloading instances",
args=("plugins",),
).start()
# Remove tmp folder
if exists("/opt/bunkerweb/tmp/ui"):
try:
rmtree("/opt/bunkerweb/tmp/ui")
except OSError:
pass
app.config["CONFIG"].reload_plugins()
return redirect(
url_for("loading", next=url_for("plugins"), message="Reloading plugins")
)
# Initialize plugins tree
plugins = [
{
"name": "plugins",
"type": "folder",
"path": "/opt/bunkerweb/plugins",
"can_create_files": False,
"can_create_folders": False,
"can_edit": False,
"can_delete": False,
"children": [
{
"name": _dir,
"type": "folder",
"path": f"/opt/bunkerweb/plugins/{_dir}",
"can_create_files": False,
"can_create_folders": False,
"can_edit": False,
"can_delete": True,
}
for _dir in listdir("/opt/bunkerweb/plugins")
],
}
]
# Populate plugins tree
plugins_pages = app.config["CONFIG"].get_plugins_pages()
pages = []
active = True
for page in plugins_pages:
with open(
f"/opt/bunkerweb/"
+ (
"plugins"
if exists(f"/opt/bunkerweb/plugins/{page.lower()}/ui/template.html")
else "core"
)
+ f"/{page.lower()}/ui/template.html",
"r",
) as f:
# Convert the file content to a jinja2 template
template = Template(f.read())
pages.append(
{
"id": page.lower().replace(" ", "-"),
"name": page,
# Render the template with the plugin's data if it corresponds to the last submitted form else with the default data
"content": template.render(csrf_token=generate_csrf, url_for=url_for)
if app.config["PLUGIN_ARGS"] is None
or app.config["PLUGIN_ARGS"]["plugin"] != page.lower()
else template.render(
csrf_token=generate_csrf,
url_for=url_for,
**app.config["PLUGIN_ARGS"]["args"],
),
# Only the first plugin page is active
"active": active,
}
)
active = False
app.config["PLUGIN_ARGS"] = None
return render_template("plugins.html", folders=plugins, pages=pages)
@app.route("/plugins/upload", methods=["POST"])
@login_required
def upload_plugin():
if not request.files:
return {"status": "ko"}, 400
if not exists("/opt/bunkerweb/tmp/ui"):
mkdir("/opt/bunkerweb/tmp/ui")
for file in request.files.values():
if not file.filename.endswith((".zip", ".tar.gz", ".tar.xz")):
return {"status": "ko"}, 422
with open(
f"/opt/bunkerweb/tmp/ui/{uuid4()}{file.filename[file.filename.index('.'):]}",
"wb",
) as f:
f.write(file.read())
return {"status": "ok"}, 201
@app.route("/plugins/<plugin>", methods=["GET", "POST"])
@login_required
def custom_plugin(plugin):
if not re_match(r"^[a-zA-Z0-9_-]{1,64}$", plugin):
flash(
f"Invalid plugin id, <b>{plugin}</b> (must be between 1 and 64 characters, only letters, numbers, underscores and hyphens)",
"error",
)
return redirect(url_for("loading", next=url_for("plugins")))
if not exists(f"/opt/bunkerweb/plugins/{plugin}/ui/actions.py") and not exists(
f"/opt/bunkerweb/core/{plugin}/ui/actions.py"
):
flash(
f"The <i>actions.py</i> file for the plugin <b>{plugin}</b> does not exist",
"error",
)
return redirect(url_for("loading", next=url_for("plugins")))
# Add the custom plugin to sys.path
sys_path.append(
f"/opt/bunkerweb/"
+ (
"plugins"
if exists(f"/opt/bunkerweb/plugins/{plugin}/ui/actions.py")
else "core"
)
+ f"/{plugin}/ui/"
)
try:
# Try to import the custom plugin
import actions
except:
flash(
f"An error occurred while importing the plugin <b>{plugin}</b>:<br/>{format_exc()}",
"error",
)
return redirect(url_for("loading", next=url_for("plugins")))
error = False
res = None
try:
# Try to get the custom plugin custom function and call it
method = getattr(actions, plugin)
res = method()
except AttributeError:
flash(
f"The plugin <b>{plugin}</b> does not have a <i>{plugin}</i> method",
"error",
)
error = True
return redirect(url_for("loading", next=url_for("plugins")))
except:
flash(
f"An error occurred while executing the plugin <b>{plugin}</b>:<br/>{format_exc()}",
"error",
)
error = True
finally:
# Remove the custom plugin from the shared library
sys_path.pop()
del sys_modules["actions"]
del actions
if (
request.method != "POST"
or error is True
or res is None
or isinstance(res, dict) is False
):
return redirect(url_for("loading", next=url_for("plugins")))
app.config["PLUGIN_ARGS"] = {"plugin": plugin, "args": res}
flash(f"Your action <b>{plugin}</b> has been executed")
return redirect(url_for("loading", next=url_for("plugins")))
@app.route("/cache", methods=["GET"])
@login_required
def cache():
return render_template(
"cache.html", folders=[path_to_dict("/opt/bunkerweb/cache", is_cache=True)]
)
@app.route("/cache/download", methods=["GET"])
@login_required
def cache_download():
path = request.args.get("path")
if not path:
return redirect(url_for("loading", next=url_for("cache"))), 400
operation = app.config["CONFIGFILES"].check_path(path, "/opt/bunkerweb/cache/")
if operation:
flash(operation, "error")
return redirect(url_for("loading", next=url_for("plugins"))), 500
return send_file(path, as_attachment=True)
@app.route("/logs", methods=["GET"])
@login_required
def logs():
instances = app.config["INSTANCES"].get_instances()
first_instance = instances[0] if instances else None
return render_template(
"logs.html", first_instance=first_instance, instances=instances
)
@app.route("/logs/local", methods=["GET"])
@login_required
def logs_linux():
if not exists("/usr/sbin/nginx"):
return (
jsonify(
{
"status": "ko",
"message": "There are no linux instances running",
}
),
404,
)
last_update = request.args.get("last_update")
raw_logs_access = []
raw_logs_error = []
if last_update:
if exists("/var/log/nginx/error.log"):
with open("/var/log/nginx/error.log", "r") as f:
raw_logs_error = f.read().splitlines()[int(last_update.split(".")[0]) :]
if exists("/var/log/nginx/access.log"):
with open("/var/log/nginx/access.log", "r") as f:
raw_logs_access = f.read().splitlines()[
int(last_update.split(".")[1]) :
]
else:
if exists("/var/log/nginx/error.log"):
with open("/var/log/nginx/error.log", "r") as f:
raw_logs_error = f.read().splitlines()
if exists("/var/log/nginx/access.log"):
with open("/var/log/nginx/access.log", "r") as f:
raw_logs_access = f.read().splitlines()
logs_error = []
temp_multiple_lines = []
NGINX_LOG_LEVELS = [
"debug",
"notice",
"info",
"warn",
"error",
"crit",
"alert",
"emerg",
]
for line in raw_logs_error:
line_lower = line.lower()
if "[info]" in line.lower() and line.endswith(":") or "[error]" in line.lower():
if temp_multiple_lines:
logs_error.append("\n".join(temp_multiple_lines))
temp_multiple_lines = [
f"{datetime.strptime(' '.join(line.strip().split(' ')[0:2]), '%Y/%m/%d %H:%M:%S').replace(tzinfo=timezone.utc).timestamp()} {line}"
]
elif (
all(f"[{log_level}]" not in line_lower for log_level in NGINX_LOG_LEVELS)
and temp_multiple_lines
):
temp_multiple_lines.append(line)
else:
logs_error.append(
f"{datetime.strptime(' '.join(line.strip().split(' ')[0:2]), '%Y/%m/%d %H:%M:%S').replace(tzinfo=timezone.utc).timestamp()} {line}"
)
if temp_multiple_lines:
logs_error.append("\n".join(temp_multiple_lines))
logs_access = [
f"{datetime.strptime(line[line.find('[') + 1: line.find(']')], '%d/%b/%Y:%H:%M:%S %z').timestamp()} {line}"
for line in raw_logs_access
]
raw_logs = logs_error + logs_access
raw_logs.sort(
key=lambda x: float(x.split(" ")[0]) if x.split(" ")[0].isdigit() else 0
)
logs = []
for log in raw_logs:
log_lower = log.lower()
error_type = (
"error"
if "[error]" in log_lower
or "[crit]" in log_lower
or "[alert]" in log_lower
or "" in log_lower
else (
"warn"
if "[warn]" in log_lower
else ("info" if "[info]" in log_lower else "message")
)
)
if "\n" in log:
splitted_one_line = log.split("\n")
logs.append(
{
"content": " ".join(
splitted_one_line.pop(0).strip().split(" ")[1:]
),
"type": error_type,
"separator": True,
}
)
for splitted_log in splitted_one_line:
logs.append(
{
"content": splitted_log,
"type": error_type,
}
)
else:
logs.append(
{
"content": " ".join(log.strip().split(" ")[1:]),
"type": error_type,
}
)
count_error_logs = 0
for log in logs_error:
if "\n" in log:
for _ in log.split("\n"):
count_error_logs += 1
else:
count_error_logs += 1
return jsonify(
{
"logs": logs,
"last_update": f"{count_error_logs + int(last_update.split('.')[0])}.{len(logs_access) + int(last_update.split('.')[1])}"
if last_update
else f"{count_error_logs}.{len(logs_access)}",
}
)
@app.route("/logs/<container_id>", methods=["GET"])
@login_required
def logs_container(container_id):
last_update = request.args.get("last_update")
logs = []
if docker_client:
try:
if last_update:
if not last_update.isdigit():
return (
jsonify(
{
"status": "ko",
"message": "last_update must be an integer",
}
),
422,
)
docker_logs = docker_client.containers.get(container_id).logs(
stdout=True,
stderr=True,
since=datetime.fromtimestamp(int(last_update)),
)
else:
docker_logs = docker_client.containers.get(container_id).logs(
stdout=True,
stderr=True,
)
for log in docker_logs.decode("utf-8", errors="replace").split("\n")[0:-1]:
log_lower = log.lower()
logs.append(
{
"content": log,
"type": "error"
if "[error]" in log_lower
or "[crit]" in log_lower
or "[alert]" in log_lower
or "" in log_lower
else (
"warn"
if "[warn]" in log_lower
else ("info" if "[info]" in log_lower else "message")
),
}
)
except docker_NotFound:
return (
jsonify(
{
"status": "ko",
"message": f"Container with ID {container_id} not found!",
}
),
404,
)
return jsonify({"logs": logs, "last_update": int(time())})
@app.route("/login", methods=["GET", "POST"])
def login():
fail = False
if (
request.method == "POST"
and "username" in request.form
and "password" in request.form
):
if app.config["USER"].get_id() == request.form["username"] and app.config[
"USER"
].check_password(request.form["password"]):
# log the user in
next_url = request.form.get("next")
login_user(app.config["USER"])
# redirect him to the page he originally wanted or to the home page
return redirect(url_for("loading", next=next_url or url_for("home")))
else:
fail = True
if fail:
return (
render_template("login.html", error="Invalid username or password"),
401,
)
return render_template("login.html")
@app.route("/check_reloading")
@login_required
def check_reloading():
if app.config["RELOADING"] is False:
for f in app.config["TO_FLASH"]:
if f["type"] == "error":
flash(f["content"], "error")
else:
flash(f["content"])
app.config["TO_FLASH"].clear()
return jsonify({"reloading": app.config["RELOADING"]})
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))