diff --git a/src/autoconf/Config.py b/src/autoconf/Config.py index c9858d166..9f1b9acb9 100644 --- a/src/autoconf/Config.py +++ b/src/autoconf/Config.py @@ -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 diff --git a/src/common/db/Database.py b/src/common/db/Database.py index fbaf8cc2d..55ce41c11 100644 --- a/src/common/db/Database.py +++ b/src/common/db/Database.py @@ -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, diff --git a/src/common/gen/save_config.py b/src/common/gen/save_config.py index b14ac0973..4b38649b7 100644 --- a/src/common/gen/save_config.py +++ b/src/common/gen/save_config.py @@ -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: diff --git a/src/scheduler/JobScheduler.py b/src/scheduler/JobScheduler.py index 639302dc4..018d6193e 100644 --- a/src/scheduler/JobScheduler.py +++ b/src/scheduler/JobScheduler.py @@ -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 diff --git a/src/scheduler/main.py b/src/scheduler/main.py index 8f1434e3b..925d9ecb1 100644 --- a/src/scheduler/main.py +++ b/src/scheduler/main.py @@ -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}") diff --git a/src/ui/main.py b/src/ui/main.py index 31aa4200d..d8bae6c07 100755 --- a/src/ui/main.py +++ b/src/ui/main.py @@ -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(): diff --git a/src/ui/src/Config.py b/src/ui/src/Config.py index 8fed54421..01197e2e4 100644 --- a/src/ui/src/Config.py +++ b/src/ui/src/Config.py @@ -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