Merge pull request #1214 from bunkerity/dev

Merge branch "dev" into branch "staging"
This commit is contained in:
Théophile Diot 2024-05-28 09:28:38 +01:00 committed by GitHub
commit 199057beac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 146 additions and 78 deletions

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python3
from contextlib import suppress
from os import getenv
from time import sleep
from copy import deepcopy
@ -119,6 +120,10 @@ class Config(ConfigCaller):
}
)
err = self.try_database_readonly()
if err:
return False
while not self._db.is_initialized():
self.__logger.warning("Database is not initialized, retrying in 5 seconds ...")
sleep(5)
@ -140,6 +145,15 @@ class Config(ConfigCaller):
if err:
self.__logger.error(f"Failed to update instances: {err}")
# save config to database
changed_plugins = []
if "config" in changes:
err = self._db.save_config(self.__config, "autoconf", changed=False)
if isinstance(err, str):
success = False
self.__logger.error(f"Can't save config in database: {err}, config may not work as expected")
changed_plugins = err
# save custom configs to database
if "custom_configs" in changes:
err = self._db.save_custom_configs(custom_configs, "autoconf", changed=False)
@ -147,16 +161,39 @@ class Config(ConfigCaller):
success = False
self.__logger.error(f"Can't save autoconf custom configs in database: {err}, custom configs may not work as expected")
# save config to database
if "config" in changes:
err = self._db.save_config(self.__config, "autoconf")
if err:
success = False
self.__logger.error(f"Can't save config in database: {err}, config may not work as expected")
else:
# update changes in db
ret = self._db.checked_changes(changes, value=True)
if ret:
self.__logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
# update changes in db
ret = self._db.checked_changes(changes, plugins_changes=changed_plugins, value=True)
if ret:
self.__logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
return success
def _try_database_readonly(self) -> bool:
if not self.db.readonly:
try:
self.db.test_write()
except BaseException:
self.db.readonly = True
return True
if self.db.database_uri and self.db.readonly:
try:
self.db.retry_connection(pool_timeout=1)
self.db.retry_connection(log=False)
self.db.readonly = False
self.__logger.info("The database is no longer read-only, defaulting to read-write mode")
except BaseException:
try:
self.db.retry_connection(readonly=True, pool_timeout=1)
self.db.retry_connection(readonly=True, log=False)
except BaseException:
if self.db.database_uri_readonly:
with suppress(BaseException):
self.db.retry_connection(fallback=True, pool_timeout=1)
self.db.retry_connection(fallback=True, log=False)
self.db.readonly = True
if self.db.readonly:
self.__logger.error("Database is in read-only mode, configuration will not be saved")
return self.db.readonly

View file

@ -10,7 +10,7 @@ from os.path import join
from pathlib import Path
from re import compile as re_compile
from sys import argv, path as sys_path
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union
from time import sleep
from uuid import uuid4
from zipfile import ZIP_DEFLATED, ZipFile
@ -75,7 +75,6 @@ class Database:
"""Initialize the database"""
self.logger = logger
self.readonly = False
self.last_fallback = None
if pool:
self.logger.warning("The pool parameter is deprecated, it will be removed in the next version")
@ -130,6 +129,7 @@ class Database:
"pool_recycle": 1800,
"pool_size": 40,
"max_overflow": 20,
"pool_timeout": 5,
} | kwargs
try:
@ -180,7 +180,6 @@ class Database:
self.sql_engine.dispose(close=True)
self.sql_engine = create_engine(self.database_uri_readonly, **self._engine_kwargs)
self.readonly = True
self.last_fallback = datetime.now()
fallback = True
continue
self.logger.error(f"Can't connect to database after {DATABASE_RETRY_TIMEOUT} seconds: {e}")
@ -192,7 +191,6 @@ class Database:
self.sql_engine.dispose(close=True)
self.sql_engine = create_engine(sqlalchemy_string, **self._engine_kwargs)
self.readonly = True
self.last_fallback = datetime.now()
if "Unknown table" in str(e):
not_connected = False
continue
@ -218,16 +216,19 @@ class Database:
def test_write(self):
"""Test the write access to the database"""
self.logger.debug("Testing write access to the database ...")
self.retry_connection(pool_timeout=1)
with self.__db_session() as session:
table_name = uuid4().hex
session.execute(text(f"CREATE TABLE IF NOT EXISTS test_{table_name} (id INT)"))
session.execute(text(f"DROP TABLE IF EXISTS test_{table_name}"))
session.commit()
self.retry_connection()
def retry_connection(self, *, readonly: bool = False, fallback: bool = False, **kwargs) -> None:
def retry_connection(self, *, readonly: bool = False, fallback: bool = False, log: bool = True, **kwargs) -> None:
"""Retry the connection to the database"""
self.logger.debug(f"Retrying the connection to the database {'in read-only mode' if readonly else ''}{' with fallback' if fallback else ''} ...")
if log:
self.logger.debug(f"Retrying the connection to the database{' in read-only mode' if readonly else ''}{' with fallback' if fallback else ''} ...")
assert self.sql_engine is not None
@ -254,21 +255,6 @@ class Database:
self.logger.error("The database engine is not initialized")
_exit(1)
if self.database_uri and self.readonly and self.last_fallback and (datetime.now() - self.last_fallback).total_seconds() > 30:
# ? If the database is forced to be read-only, we try to connect as a non read-only user every time until the database is writable
try:
self.retry_connection(pool_timeout=1)
self.readonly = False
self.logger.info("The database is no longer read-only, defaulting to read-write mode")
except (OperationalError, DatabaseError):
try:
self.retry_connection(readonly=True, pool_timeout=1)
except (OperationalError, DatabaseError):
if self.database_uri_readonly:
with suppress(OperationalError, DatabaseError):
self.retry_connection(fallback=True, pool_timeout=1)
self.readonly = True
session = None
try:
with self.sql_engine.connect() as conn:
@ -283,19 +269,20 @@ class Database:
self.logger.warning("The database is read-only, retrying in read-only mode ...")
try:
self.retry_connection(readonly=True, pool_timeout=1)
self.retry_connection(readonly=True, log=False)
except (OperationalError, DatabaseError):
if self.database_uri_readonly:
self.logger.warning("Can't connect to the database in read-only mode, falling back to read-only one")
with suppress(OperationalError, DatabaseError):
self.retry_connection(fallback=True, pool_timeout=1)
self.retry_connection(fallback=True, log=False)
self.readonly = True
self.last_fallback = datetime.now()
elif isinstance(e, (ConnectionRefusedError, OperationalError)) and self.database_uri_readonly:
self.logger.warning("Can't connect to the database, falling back to read-only one ...")
with suppress(OperationalError, DatabaseError):
self.retry_connection(fallback=True, pool_timeout=1)
self.retry_connection(fallback=True, log=False)
self.readonly = True
self.last_fallback = datetime.now()
raise
finally:
if session:
@ -512,9 +499,15 @@ class Database:
except BaseException as e:
return str(e)
def checked_changes(self, changes: Optional[List[str]] = None, value: Optional[bool] = False) -> str:
def checked_changes(
self,
changes: Optional[List[str]] = None,
plugins_changes: Optional[Union[Literal["all"], Set[str], List[str], Tuple[str]]] = None,
value: Optional[bool] = False,
) -> str:
"""Set changed bit for config, custom configs, instances and plugins"""
changes = changes or ["config", "custom_configs", "external_plugins", "pro_plugins", "instances"]
plugins_changes = plugins_changes or set()
with self.__db_session() as session:
if self.readonly:
return "The database is read-only, the changes will not be saved"
@ -536,25 +529,13 @@ class Database:
metadata.pro_plugins_changed = value
if "instances" in changes:
metadata.instances_changed = value
session.commit()
except BaseException as e:
return str(e)
return ""
if plugins_changes:
if plugins_changes == "all":
session.query(Plugins).update({Plugins.config_changed: value})
else:
session.query(Plugins).filter(Plugins.id.in_(plugins_changes)).update({Plugins.config_changed: value})
def checked_plugins_changes(self, plugins: Optional[List[str]] = None, value: Optional[bool] = False) -> str:
"""Set changed bit for plugins"""
with self.__db_session() as session:
if self.readonly:
return "The database is read-only, the changes will not be saved"
plugins = plugins or []
try:
query = session.query(Plugins)
if plugins:
query = query.filter(Plugins.id.in_(plugins))
query.update({Plugins.config_changed: value})
session.commit()
except BaseException as e:
return str(e)
@ -1130,7 +1111,7 @@ class Database:
return True, ""
def save_config(self, config: Dict[str, Any], method: str, changed: Optional[bool] = True) -> str:
def save_config(self, config: Dict[str, Any], method: str, changed: Optional[bool] = True) -> Union[str, Set[str]]:
"""Save the config in the database"""
to_put = []
with self.__db_session() as session:
@ -1331,6 +1312,9 @@ class Database:
continue
query.update({Global_values.value: value})
if changed_services:
changed_plugins = set(plugin.id for plugin in session.query(Plugins).with_entities(Plugins.id).all())
if changed:
with suppress(ProgrammingError, OperationalError):
metadata = session.query(Metadata).get(1)
@ -1338,9 +1322,7 @@ class Database:
if not metadata.first_config_saved:
metadata.first_config_saved = True
if changed_services:
session.query(Plugins).update({Plugins.config_changed: True})
elif changed_plugins:
if changed_plugins:
session.query(Plugins).filter(Plugins.id.in_(changed_plugins)).update({Plugins.config_changed: True})
try:
@ -1349,7 +1331,7 @@ class Database:
except BaseException as e:
return str(e)
return ""
return changed_plugins
def save_custom_configs(
self,

View file

@ -15,6 +15,8 @@ for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in ((
sys_path.append(deps_path)
from docker import DockerClient
from kubernetes import client as kube_client
from kubernetes import config as kube_config
from common_utils import get_integration # type: ignore
from logger import setup_logger # type: ignore
@ -142,6 +144,8 @@ if __name__ == "__main__":
logger.error(f"Missing RX rights on directory : {path}")
sys_exit(1)
tmp_config = {}
if args.variables:
variables_path = Path(args.variables)
logger.info(f"Variables : {variables_path}")
@ -178,7 +182,6 @@ if __name__ == "__main__":
api_http_port = None
api_server_name = None
tmp_config = {}
custom_confs = []
apis = []
@ -216,6 +219,49 @@ if __name__ == "__main__":
host=api_server_name or getenv("API_SERVER_NAME", "bwapi"),
)
)
else:
kube_config.load_incluster_config()
kubernetes_client = kube_client.CoreV1Api()
api_http_port = None
api_server_name = None
custom_confs = []
apis = []
for pod in kubernetes_client.list_pod_for_all_namespaces(watch=False).items:
if pod.metadata.annotations is not None and "bunkerweb.io/INSTANCE" in pod.metadata.annotations:
for env in pod.env:
if custom_confs_rx.match(env.name):
custom_conf = custom_confs_rx.search(env.name).groups()
custom_confs.append(
{
"value": f"# CREATED BY ENV\n{env.value}",
"exploded": (
custom_conf[0],
custom_conf[1],
custom_conf[2].replace(".conf", ""),
),
}
)
logger.info(
f"Found custom conf env var {'for service ' + custom_conf[0] if custom_conf[0] else 'without service'} with type {custom_conf[1]} and name {custom_conf[2]}"
)
else:
tmp_config[env.name] = env.value
if not db and env.name == "DATABASE_URI":
db = Database(logger, sqlalchemy_string=env.value)
elif env.name == "API_HTTP_PORT":
api_http_port = env.value
elif env.name == "API_SERVER_NAME":
api_server_name = env.value
apis.append(
API(
f"http://{pod.status.pod_ip or pod.metadata.name}:{api_http_port or getenv('API_HTTP_PORT', '5000')}",
host=api_server_name or getenv("API_SERVER_NAME", "bwapi"),
)
)
if not db:
db = Database(logger)
@ -278,11 +324,13 @@ if __name__ == "__main__":
sys_exit(0)
changes = []
changed_plugins = set()
err = db.save_config(config_files, args.method, changed=False)
if err:
if isinstance(err, str):
logger.warning(f"Couldn't save config to database : {err}, config may not work as expected")
else:
changed_plugins = err
changes.append("config")
logger.info("Config successfully saved to database")
@ -327,7 +375,7 @@ if __name__ == "__main__":
if not args.no_check_changes:
# update changes in db
ret = db.checked_changes(changes, value=True)
ret = db.checked_changes(changes, plugins_changes=changed_plugins, value=True)
if ret:
logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
except SystemExit as e:

View file

@ -367,19 +367,21 @@ class JobScheduler(ApiCaller):
if self.db.database_uri and self.db.readonly:
try:
self.db.retry_connection(pool_timeout=1)
self.db.retry_connection(log=False)
self.db.readonly = False
self.__logger.info("The database is no longer read-only, defaulting to read-write mode")
except BaseException:
try:
self.db.retry_connection(readonly=True, pool_timeout=1)
self.db.retry_connection(readonly=True, log=False)
except BaseException:
if self.db.database_uri_readonly:
with suppress(BaseException):
self.db.retry_connection(fallback=True, pool_timeout=1)
self.db.retry_connection(fallback=True, log=False)
self.db.readonly = True
if self.db.readonly:
self.__logger.error("Database is in read-only mode, jobs will not be executed")
return True
return self.db.readonly

View file

@ -747,12 +747,9 @@ if __name__ == "__main__":
logger.error(f"Exception while reloading after running jobs once scheduling : {format_exc()}")
try:
ret = SCHEDULER.db.checked_changes(CHANGES)
ret = SCHEDULER.db.checked_changes(CHANGES, plugins_changes="all")
if ret:
logger.error(f"An error occurred when setting the changes to checked in the database : {ret}")
ret = SCHEDULER.db.checked_plugins_changes(changed_plugins)
if ret:
logger.error(f"An error occurred when setting the plugins changes to checked in the database : {ret}")
except BaseException as e:
logger.error(f"Error while setting changes to checked in the database: {e}")

View file

@ -407,8 +407,6 @@ def set_csp_header(response):
+ " base-uri 'self';"
+ (" connect-src *;" if request.path.startswith(("/check", "/setup")) else "")
)
if app.config["DB"].readonly:
flash("Database connection is in read-only mode : no modification possible.", "error")
return response
@ -447,18 +445,20 @@ def before_request():
if app.config["DB"].database_uri and app.config["DB"].readonly:
try:
app.config["DB"].retry_connection(pool_timeout=1)
app.config["DB"].retry_connection(log=False)
app.config["DB"].readonly = False
app.logger.info("The database is no longer read-only, defaulting to read-write mode")
except BaseException:
try:
app.config["DB"].retry_connection(readonly=True, pool_timeout=1)
app.config["DB"].retry_connection(readonly=True, log=False)
except BaseException:
if app.config["DB"].database_uri_readonly:
with suppress(BaseException):
app.config["DB"].retry_connection(fallback=True, pool_timeout=1)
app.config["DB"].retry_connection(fallback=True, log=False)
app.config["DB"].readonly = True
if not app.config["DB"].readonly and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
elif not app.config["DB"].readonly and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
try:
app.config["DB"].test_write()
except BaseException:
@ -481,6 +481,9 @@ def before_request():
logout_user()
session.clear()
if app.config["DB"].readonly:
flash("Database connection is in read-only mode : no modification possible.", "error")
@app.route("/", strict_slashes=False)
def index():

View file

@ -7,7 +7,7 @@ from flask import flash
from json import loads as json_loads
from pathlib import Path
from re import error as RegexError, search as re_search
from typing import List, Literal, Optional, Tuple
from typing import List, Literal, Optional, Set, Tuple, Union
class Config:
@ -15,7 +15,9 @@ class Config:
self.__settings = json_loads(Path(sep, "usr", "share", "bunkerweb", "settings.json").read_text(encoding="utf-8"))
self.__db = db
def __gen_conf(self, global_conf: dict, services_conf: list[dict], *, check_changes: bool = True, changed_service: Optional[str] = None) -> None:
def __gen_conf(
self, global_conf: dict, services_conf: list[dict], *, check_changes: bool = True, changed_service: Optional[str] = None
) -> Union[str, Set[str]]:
"""Generates the nginx configuration file from the given configuration
Parameters
@ -136,9 +138,6 @@ class Config:
return error
def reload_config(self) -> Optional[str]:
return self.__gen_conf(self.get_config(methods=False), self.get_services(methods=False))
def new_service(self, variables: dict, is_draft: bool = False) -> Tuple[str, int]:
"""Creates a new service from the given variables
@ -165,7 +164,7 @@ class Config:
services.append(variables | {"IS_DRAFT": "yes" if is_draft else "no"})
ret = self.__gen_conf(self.get_config(methods=False), services, check_changes=not is_draft)
if ret:
if isinstance(ret, str):
return ret, 1
return f"Configuration for {variables['SERVER_NAME'].split(' ')[0]} has been generated.", 0
@ -205,7 +204,7 @@ class Config:
config.pop(k)
ret = self.__gen_conf(config, services, check_changes=check_changes, changed_service=server_name_splitted[0])
if ret:
if isinstance(ret, str):
return ret, 1
return f"Configuration for {old_server_name_splitted[0]} has been edited.", 0
@ -223,7 +222,7 @@ class Config:
the confirmation message
"""
ret = self.__gen_conf(self.get_config(methods=False) | variables, self.get_services(methods=False))
if ret:
if isinstance(ret, str):
return ret, 1
return "The global configuration has been edited.", 0
@ -273,6 +272,6 @@ class Config:
service.pop(k)
ret = self.__gen_conf(new_env, new_services, check_changes=check_changes)
if ret:
if isinstance(ret, str):
return ret, 1
return f"Configuration for {service_name} has been deleted.", 0