diff --git a/.clog.toml b/.clog.toml old mode 100644 new mode 100755 diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 4cf0f835..de0ad857 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -5,19 +5,19 @@ on: jobs: build-test: - runs-on: [self-hosted, build] + runs-on: ubuntu-latest steps: - name: Workflow run cleanup action uses: rokroskar/workflow-run-cleanup-action@ee1451b869ba1e381729b3d40489997021f0d562 # v0.3.3 env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build and publish image to OCI id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: - file: ./compose/production/django/Dockerfile + file: docker/Dockerfile push: false load: true tags: localbuild/testimage:latest diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index feabfe5a..8029ad4f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,11 +35,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: languages: ${{ matrix.language }} queries: +security-extended, security-and-quality @@ -51,7 +51,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -65,4 +65,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 diff --git a/.github/workflows/dummy-renovate.yml b/.github/workflows/dummy-renovate.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/pip_audit.yml b/.github/workflows/pip_audit.yml index 97252033..53a4e5cf 100644 --- a/.github/workflows/pip_audit.yml +++ b/.github/workflows/pip_audit.yml @@ -10,15 +10,15 @@ jobs: runs-on: self-hosted steps: - name: Checkout PR branch - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.14 - - uses: pypa/gh-action-pip-audit@v1.1.0 + - uses: pypa/gh-action-pip-audit@1220774d901786e6f652ae159f7b6bc8fea6d266 # v1.1.0 with: inputs: FastapiOpenRestyConfigurator/requirements.txt diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index de8fb4f0..083fb0cc 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -8,8 +8,7 @@ on: - 'hotfix/**' jobs: build: - runs-on: [self-hosted,bielefeld] - concurrency: api + runs-on: ubuntu-latest steps: - name: Clean up repository directory (with elevated privileges) -.pytest_cache shell: bash @@ -41,6 +40,6 @@ jobs: - name: Build and publish image to OCI uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: - file: compose/production/django/Dockerfile + file: docker/Dockerfile push: true tags: oci.bi.denbi.de/simplevm/forc:${{ steps.tag.outputs.TAG }} diff --git a/.github/workflows/release_image.yml b/.github/workflows/release_image.yml index bc15207a..786844ef 100644 --- a/.github/workflows/release_image.yml +++ b/.github/workflows/release_image.yml @@ -5,7 +5,7 @@ on: - '*' jobs: build_release: - runs-on: [self-hosted, production, build] + runs-on: ubuntu-latest steps: - name: Clean up repository directory (with elevated privileges) -.pytest_cache @@ -31,7 +31,7 @@ jobs: - name: Build and publish image to OCI uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: - file: compose/production/django/Dockerfile + file: docker/Dockerfile push: true tags: oci.bi.denbi.de/simplevm/forc:${{ steps.get_version.outputs.VERSION }} load: true diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index e0fea293..d06e8a59 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ ansible/test.json */.python-version FastapiOpenRestyConfigurator/.env template_path -backend_path \ No newline at end of file +backend_path +*/plans \ No newline at end of file diff --git a/.python-version b/.python-version index a6d9ada0..3f0a10fd 100755 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14.5 +3.14.6 diff --git a/FastapiOpenRestyConfigurator/.env.in b/FastapiOpenRestyConfigurator/.env.in old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/__init__.py b/FastapiOpenRestyConfigurator/app/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/__init__.py b/FastapiOpenRestyConfigurator/app/main/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/config.py b/FastapiOpenRestyConfigurator/app/main/config.py old mode 100644 new mode 100755 index 2c861703..b047eb52 --- a/FastapiOpenRestyConfigurator/app/main/config.py +++ b/FastapiOpenRestyConfigurator/app/main/config.py @@ -1,8 +1,10 @@ import os from functools import lru_cache -from pydantic_settings import BaseSettings -from pydantic import SecretStr, validator, DirectoryPath +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import SecretStr, field_validator, DirectoryPath +from pydantic import field_validator, ValidationInfo + basedir = os.path.abspath(os.path.dirname(__file__)) @@ -11,40 +13,40 @@ class Settings(BaseSettings): Settings object. Reads settings from .env file. """ - FORC_VERSION: str = '0.2' + + FORC_VERSION: str = "0.2" DEBUG: bool = False LOG_LEVEL: str = "INFO" FORC_API_KEY: SecretStr - FORC_SECRET_KEY: SecretStr = 'my_precious_secret_key' + FORC_SECRET_KEY: SecretStr = "my_precious_secret_key" FORC_BACKEND_PATH: DirectoryPath FORC_TEMPLATE_PATH: DirectoryPath FORC_USER_PATH: str = "users" + CONTAINERIZED: bool = False - @validator('FORC_USER_PATH', pre=True) - def apply_backend_path(cls, v, values): + @field_validator("FORC_USER_PATH", mode="before") + @classmethod + def apply_backend_path(cls, v, info: ValidationInfo): """ Validates forc user path, as it depends on forc backend path. :param v: Value for forc user path. - :param values: Values already read for settings object. + :param info: Validation context containing already validated fields. :return: Updated FORC_USER_PATH. """ - # := assigns and compares a value. (if a := b:) == (a = b; if a:) - if FORC_BACKEND_PATH := values.get('FORC_BACKEND_PATH'): - return f"{FORC_BACKEND_PATH}/{v}" - else: - # should only happen when there was an error with FORC_BACKEND_PATH - return "/var/forc/backend_path/users" - - class Config: - """ - Config for settings object. - """ - # Enabled case sensitive for reading variables from .env file - case_sensitive = True - # Path to .env file - env_file = ".env" - # Encoding of .env file - env_file_encoding = "utf-8" + forc_backend_path = info.data.get("FORC_BACKEND_PATH") + + if forc_backend_path: + return f"{forc_backend_path}/{v}" + + # should only happen when there was an error with FORC_BACKEND_PATH + return "/var/forc/backend_path/users" + + model_config = SettingsConfigDict( + case_sensitive=True, + env_file=".env", + env_file_encoding="utf-8", + extra="ignore" + ) @lru_cache() diff --git a/FastapiOpenRestyConfigurator/app/main/model/__init__.py b/FastapiOpenRestyConfigurator/app/main/model/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/model/serializers.py b/FastapiOpenRestyConfigurator/app/main/model/serializers.py old mode 100644 new mode 100755 index 8afbb404..b359e29c --- a/FastapiOpenRestyConfigurator/app/main/model/serializers.py +++ b/FastapiOpenRestyConfigurator/app/main/model/serializers.py @@ -28,9 +28,11 @@ owner_regex = r'^[a-zA-Z0-9@.-]{30,}$' user_key_url_regex = r"^[a-zA-Z0-9_-]{3,25}$" -upstream_url_regex = r"^(https?)://(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})$" +upstream_url_regex = r"^(https?)://(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})(/[a-zA-Z0-9_-]+/)?$" +# TODO: needs refactoring to comply with python 3.10 and pydantic v2 + class BackendBase(BaseModel): """ Base class for backend. @@ -53,6 +55,12 @@ class BackendBase(BaseModel): description="Version of the template the backend refers to.", example="v04" ) + auth_enabled: bool = Field( + True, + title="Authorization for the research environment", + description="If set to true, only the owner of the backend is allowed to access it.", + example=False + ) @validator("owner") def owner_validation(cls, owner): @@ -82,7 +90,7 @@ class BackendIn(BackendBase): ..., title="Upstream URL", description="Inject the full url (with protocol) for the real location of the backend service in the template.", - example="http://192.168.0.1:8787/" + example="http://192.168.0.1:8787/guacamole/" ) @validator("user_key_url") @@ -113,7 +121,7 @@ class BackendOut(BackendBase): """ Backend class which holds information needed when returning a backend. """ - id: int = Field( + id: int = Field( # TODO: needs refactoring: change type to int and rename to backend_id ..., title="ID", description="ID of the backend.", @@ -136,13 +144,15 @@ class BackendTemp(BackendIn, BackendOut): """ Backend class to temporarily save information. Links BackendIn with BackendOut. """ - id: int = None + id: int = None # TODO: also needs refactoring: change type to int and rename to backend_id owner: str = None location_url: str = None template: str = None template_version: str = None user_key_url: str = None upstream_url: str = None + auth_enabled: bool = None + file_path: str = None class Template(BaseModel): diff --git a/FastapiOpenRestyConfigurator/app/main/service/__init__.py b/FastapiOpenRestyConfigurator/app/main/service/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/service/backend.py b/FastapiOpenRestyConfigurator/app/main/service/backend.py old mode 100644 new mode 100755 index 51e425f5..488d662d --- a/FastapiOpenRestyConfigurator/app/main/service/backend.py +++ b/FastapiOpenRestyConfigurator/app/main/service/backend.py @@ -15,58 +15,135 @@ from ..config import get_settings logger = logging.getLogger("service") -settings = get_settings() -file_regex = r"(\d*)%([a-z0-9\-\@.]*?)%([^%]*)%([^%]*)%([^%]*)\.conf" +# format of filename saves information of BackendOut by this schema: +# {id}%{owner}%{location_url}%{template}%{template_version}%{auth_enabled}.conf +filename_regex = r"(\d*)%([a-z0-9\-\@.]*?)%([^%]*)%([^%]*)%([^%]*)(%([01]))?\.conf" -async def random_with_n_digits(n): + + +# HELPER FUNCTIONS + +def random_with_n_digits(n): + # used for backend id generation (n=10), never starts with 0 range_start = 10 ** (n - 1) range_end = (10 ** n) - 1 return randint(range_start, range_end) +# TODO: unlikely but potential error cause, if two users have same randomly generated user_key_url! +async def generate_suffix_number(location_url: str | None = None) -> int: + if location_url is None: + return 100 + # extract current suffix number, check validity + current_suffix_number: int = int(location_url.split("_")[1]) + if current_suffix_number < 100 or current_suffix_number > 999: + logger.error("Invalid user_key_url provided for suffix generation: " + location_url) + raise InternalServerError("Invalid user_key_url provided for suffix generation.") + + # determine suffix number + highest_id = await get_highest_suffix_number(location_url) + + if highest_id == 999: + logger.warning("Reached max index number for requested user_key_url: " + location_url) + raise InternalServerError("Reached max index number for requested user_key_url (limit=999).") + return highest_id + 1 + +async def get_highest_suffix_number(location_url: str) -> int: + """ + Looks for backends with same user_key_url and returns the highest suffix number. See generate_suffix_number(). + """ + # look for backends with same user_key_url and extract suffix numbers, if they exist + backends: List[BackendOut] = await get_backends() + same_name_backend_suffixes: List[int] = [] + for backend in backends: + if backend.location_url == location_url: + suffix: int = int(backend.location_url.split("_")[1]) + same_name_backend_suffixes.append(suffix) + if len(same_name_backend_suffixes) == 0: + return 100 + + # return highest found suffix number + 1 to iterate + same_name_backend_suffixes.sort() + highest_id: int = same_name_backend_suffixes[-1] + return highest_id + + +def generate_backend_filename(backend: BackendOut) -> str: + b: BackendOut = backend + if b.id and b.owner and b.location_url and b.template and b.template_version and b.auth_enabled is not None: + return f"{str(b.id)}%{b.owner}%{b.location_url}%{b.template}%{b.template_version}%{str(int(b.auth_enabled))}.conf" + else: + logger.error("Not all necessary backend fields are set for filename generation: " + str(backend)) + raise InternalServerError("Filename generation failed.") + + + +# CORE GETTER FUNCTIONS + async def get_backends() -> List[BackendOut]: + settings = get_settings() if not os.path.exists(settings.FORC_BACKEND_PATH) and not os.access(settings.FORC_BACKEND_PATH, os.W_OK): logger.error("Not able to access configured backend path.") return [] if len(os.listdir(settings.FORC_BACKEND_PATH)) == 0: return [] - backend_path_files = os.listdir(settings.FORC_BACKEND_PATH) - logger.info(backend_path_files) + backend_path_filenames = os.listdir(settings.FORC_BACKEND_PATH) + logger.debug(f"Files in backend_path: {backend_path_filenames}") valid_backends = [] - for file in backend_path_files: - match = re.fullmatch(file_regex, file) + for filename in backend_path_filenames: + match = re.fullmatch(filename_regex, filename) if not match: - logger.warning("Found a backend file with wrong naming, skipping it: " + str(file)) + if filename == "users" or filename == "scripts": + continue + logger.debug("Found a backend file with wrong naming, skipping it: " + str(filename)) continue + backend: BackendOut = BackendOut( - id=match.group(1), - owner=match.group(2), - location_url=match.group(3), - template=match.group(4), - template_version=match.group(5), - file_path=os.path.join(settings.FORC_BACKEND_PATH, file) + id = int(match.group(1)), + owner = match.group(2), + location_url = match.group(3), + template = match.group(4), + template_version = match.group(5), + auth_enabled = bool(int(match.group(6)[1])) if match.group(6) is not None else True, + file_path = os.path.join(settings.FORC_BACKEND_PATH, filename) ) valid_backends.append(backend) return valid_backends -async def get_backends_upstream_urls() -> Dict[str, List[BackendOut]]: +async def get_backend_by_id(backend_id: int) -> BackendOut: + valid_backends: List[BackendOut] = await get_backends() + for backend in valid_backends: + if int(backend.id) == int(backend_id): + return backend + raise NotFound(f"Backend with id {backend_id} was not found.") + + +async def get_backends_proxy_pass() -> Dict[str, List[BackendOut]]: + """ + Returns a dictionary mapping proxy_pass values to lists of BackendOuts that use them. + """ valid_backends: List[BackendOut] = await get_backends() - upstream_urls = {} + proxy_passes = {} for backend in valid_backends: - upstream_url = extract_proxy_pass(backend.file_path) - if upstream_url: - if upstream_url not in upstream_urls: - upstream_urls[upstream_url] = [] - upstream_urls[upstream_url].append(backend) + proxy_pass = extract_proxy_pass(backend.file_path) + if proxy_pass: + if proxy_pass not in proxy_passes: + proxy_passes[proxy_pass] = [] + proxy_passes[proxy_pass].append(backend) - return upstream_urls + return proxy_passes -def extract_proxy_pass(file_path): + +# FURTHER GETTER FUNCTIONS + +def extract_proxy_pass(file_path) -> str | None: + # proxy_pass consists of upstream_url with potential trailing path, see guacamole template + # TODO: add error handling, e.g. file_path = None with open(file_path, 'r') as file: content = file.read() @@ -75,79 +152,299 @@ def extract_proxy_pass(file_path): if match: return match.group(1) else: + logger.error(f"Could not extract proxy_pass from {file_path}") return None -async def generate_suffix_number(user_key_url): - current_backends: List[BackendOut] = await get_backends() - same_name_backend_ids = [] +def get_upstream_url(file_path) -> str | None: + """ + Extracts upstream_url from proxy_pass and removes trailing path, see guacamole template + """ + proxy_pass = extract_proxy_pass(file_path) + if proxy_pass is None or proxy_pass == "": + return None - for backend in current_backends: - if backend.location_url.split("_")[0] == user_key_url: - same_name_backend_ids.append(int(backend.location_url.split("_")[1])) + # split and rejoin to remove trailing path to remove potential trailing path, unaffected if no trailing path + upstream_url = "/".join(proxy_pass.split("/", 3)[:3]) + return upstream_url + + +def get_basekey_from_backend(backend: BackendOut) -> str | None: + """ + Extracts basekey from location_url by removing suffix pattern. + """ + try: + if backend is None or backend.location_url is None: + logger.error("backend or backend.location_url is None.") + return None + if "_" not in backend.location_url: + logger.error(f"location_url has no suffix pattern: {backend.location_url}") + return None + # split and return basekey + base_key = backend.location_url.split("_")[0] + return base_key + except ValueError: + logger.error(f"could not get basekey from location_url: {backend.location_url}") + return None - if not same_name_backend_ids: - return "100" - same_name_backend_ids.sort() - highest_id = same_name_backend_ids[-1] - if highest_id == 999: - logger.warning("Reached max index number for requested user_key_url: " + user_key_url) - raise InternalServerError("Reached max index number for requested user_key_url (limit=999).") - return str(highest_id + 1) +# CORE MUTATOR AND SERVICE FUNCTIONS +async def create_backend(payload_input: BackendIn, **kwargs) -> BackendTemp: + logger.info(f"Attempting to create backend with user_key_url: {payload_input.user_key_url}") + settings = get_settings() -async def create_backend(payload: BackendIn): - payload: BackendTemp = BackendTemp(**payload.dict()) - suffix_number = await generate_suffix_number(payload.user_key_url) + # overwrite payload as BackendTemp for generate_backend_by_template() + payload: BackendTemp = BackendTemp(**payload_input.model_dump()) - payload.id = str(await random_with_n_digits(10)) + # generate or reuse backend id and suffix number + payload, suffix_number = await set_backend_id_and_suffix(payload, **kwargs) + # generate backend and location_url backend_file_contents = await generate_backend_by_template(payload, suffix_number) if not backend_file_contents: raise InternalServerError("Server was not able to template a new backend.") payload.location_url = f"{payload.user_key_url}_{suffix_number}" - # check for duplicated in ip and port: - upstream_urls: Dict[str, List[BackendOut]] = await get_backends_upstream_urls() - matching_urls_backends: List[BackendOut] = upstream_urls.get(payload.upstream_url, []) - for backend in matching_urls_backends: - logger.info(f"Deleting existing Backend with same Upstream Url - {payload.upstream_url}") - await delete_backend(backend.id) + # check for duplicates and delete them before creating new backend + if not await delete_duplicate_backends(payload): + raise InternalServerError("Server was not able to delete duplicate backends before creating a new one.") - # create backend file in filesystem - filename = f"{payload.id}%{payload.owner}%{payload.location_url}%{payload.template}%{payload.template_version}.conf" + # save BackendOut info in filename, create backend file in filesystem + filename = generate_backend_filename(payload) with open(f"{settings.FORC_BACKEND_PATH}/{filename}", 'w') as backend_file: backend_file.write(backend_file_contents) + backend_file.close() - # attempt to reload openrest + # attempt to reload openresty await reload_openresty() return payload async def delete_backend(backend_id) -> bool: - if not os.path.exists(settings.FORC_BACKEND_PATH) and not os.access(settings.FORC_BACKEND_PATH, os.W_OK): + settings = get_settings() + backend_path_filenames = get_valid_backend_filenames() + if not backend_path_filenames: + logger.error(f"Backend {backend_id} was not found.") + raise NotFound(f"Backend {backend_id} was not found.") + + matching_backend_filenames = filter_backend_filenames_by_id(backend_path_filenames, backend_id) + + number_of_files = len(matching_backend_filenames) + if number_of_files == 0: + logger.error(f"Backend {backend_id} was not found") + raise NotFound(f"Backend {backend_id} was not found.") + if number_of_files > 1: + logger.error(f"Found multiple backend files for backend id: {backend_id}, cannot delete.") + raise InternalServerError("Found multiple backend files for backend id: {backend_id}, cannot delete.") + if not number_of_files == 1: # fallback + logger.error(f"Something went wrong. Did not expect multiple files for deletion. Backend id: {backend_id}") + raise InternalServerError(f"Something went wrong. Did not expect multiple files for deletion. Backend id: {backend_id}") + + filename = matching_backend_filenames[0] + + logger.info(f"Attempting to delete backend with id: {backend_id} as file: {settings.FORC_BACKEND_PATH}/{filename}") + try: + os.remove(f"{settings.FORC_BACKEND_PATH}/{filename}") + logger.info(f"Deleted backend with id: {backend_id}") + await reload_openresty() + return True + except OSError as e: + logger.warning(f"Was not able to delete backend with id: {backend_id} ERROR: {e}") + raise InternalServerError("Server was not able to delete this backend. Contact the admin.") + + +async def update_backend_authorization(backend_id: int, auth_enable: bool) -> BackendOut | None: + # get existing BackendOut by id to fetch necessary information for payload building and filename generation + backend = await get_backend_by_id(backend_id) + if not backend: + return None + + # build temporary payload as BackendIn for create_backend() + temp_payload = build_payload_for_auth_update(backend, auth_enable) + if not temp_payload: + return None + + # generate new backend contents with temp_payload, additional kwargs to persist backend_id and location_url + new_contents = await create_backend(temp_payload, id=str(backend_id), location_url=backend.location_url) + if not new_contents: + logger.error("Templating returned empty result.") + return None + + logger.info(f"Updated backend authorization to {temp_payload.auth_enabled} for backend id: {backend_id}") + + # convert BackendTemp to BackendOut for returning + returning_backend = await convert_backend_temp_to_out(new_contents) + + return returning_backend + + + +# HELPER FUNCTIONS FOR MUTATORS + +async def set_backend_id_and_suffix(backend: BackendTemp, **kwargs) -> tuple[BackendTemp, int]: + """ + Sets backend id and suffix number for a new backend. + If id and location_url are provided in kwargs, they are used to override. + Otherwise, a new id and suffix number are generated. + """ + # override id and suffix if provided from update_backend_authorization() + if 'id' in kwargs and 'location_url' in kwargs: + id: str = str(kwargs.get('id')) + suffix: int = int(str(kwargs.get('location_url')).split("_")[1]) + if not isinstance(id, str) or not isinstance(suffix, int): + logger.error("Provided id or location_url have wrong type.") + raise InternalServerError("Provided id or location_url have wrong type.") + + backend = backend.model_copy(update={'id': id}) + suffix_number = suffix + # if no id provided, generate id and suffix + else: + if kwargs != {}: + logger.warning(f"set_backend_id_and_suffix() received unexpected kwargs: {kwargs}") + raise InternalServerError("Unexpected kwargs provided to set_backend_id_and_suffix().") # @reviewer: should we really error here? + backend = backend.model_copy(update={'id': str(random_with_n_digits(10))}) + suffix_number = await generate_suffix_number(backend.location_url) + return backend, suffix_number + + +# TODO: handle cases where same proxy_pass exists with and without trailing path (guacamole) +async def delete_duplicate_backends(backend_with_proxy_pass: BackendIn) -> bool: + """ + Checks for duplicates in proxy_pass (namely upstream_url) for a given BackendIn and deletes all of them. + Returns only False, if a deletion failed. Returns True if no backends were found and thus deleted. + """ + # get proxy_pass from provided backend + proxy_pass = backend_with_proxy_pass.upstream_url + # get all backends linked to the given proxy_pass + backends_proxy_passes: Dict[str, List[BackendOut]] = await get_backends_proxy_pass() + matching_backends: List[BackendOut] = backends_proxy_passes.get(proxy_pass, []) + logger.info(f"Matching backends: {matching_backends}") + # delete all matching backends + if len(matching_backends) == 0: + logger.warning("No matching backends found with proxy_pass: " + str(proxy_pass)) + else: + for backend in matching_backends: + if await delete_backend(backend_id = backend.id): + logger.info(f"Deleted existing backend with same proxy_pass: {proxy_pass}, backend id: {backend.id}") + else: + logger.error(f"Failed to delete existing backend with same proxy_pass: {proxy_pass}, backend id: {backend.id}") + return False + return True + + +async def convert_backend_temp_to_out(backend_temp: BackendTemp) -> BackendOut | None: + """ + Converts a given BackendTemp to BackendOut. Returns None otherwise. + """ + # needed for returning backends to client + try: + backend_out = BackendOut( + id = backend_temp.id, + owner = backend_temp.owner, + location_url = backend_temp.location_url, + template = backend_temp.template, + template_version = backend_temp.template_version, + auth_enabled = backend_temp.auth_enabled, + file_path = (await get_backend_by_id(backend_temp.id)).file_path + ) + return backend_out + except Exception as e: + logger.error(f"Error converting BackendTemp to BackendOut: {e}") + return None + + +def check_backend_path() -> bool: + """ + Checks whether a backend path exists, is accessible and has files + """ + forc_backend_path = get_settings().FORC_BACKEND_PATH + if not os.path.exists(forc_backend_path) or not os.access(forc_backend_path, os.W_OK): logger.error("Not able to access configured backend path.") return False - if len(os.listdir(settings.FORC_BACKEND_PATH)) == 0: + if len(os.listdir(forc_backend_path)) == 0: + logger.error("No files found in backend path.") return False - backend_path_files = os.listdir(settings.FORC_BACKEND_PATH) - for file in backend_path_files: - match = re.fullmatch(file_regex, file) - if not match: - logger.warning(f"Found a backend file with wrong naming, skipping it: {file}") + return True + + +def check_backend_file_naming(backend_path_filename: str) -> bool: + """ + Checks for correct naming of the file in the backend path + """ + match = re.fullmatch(filename_regex, backend_path_filename) + # skip files with wrong naming and log warning + if match: + return True + else: + # exclude expected files from warning + if not backend_path_filename == "users" and not backend_path_filename == "scripts": + logger.debug(f"Found a backend file with wrong naming, skipping it: {backend_path_filename}") + return False + + +def get_backend_path_filenames() -> List[str] | None: + """ + Returns a list of all the filenames in the backend path, or None if failed. + """ + if not check_backend_path(): + return None + + return os.listdir(get_settings().FORC_BACKEND_PATH) + + +def get_valid_backend_filenames() -> List[str] | None: + """ + Returns a list of all valid filenames in the backend path, or None if failed. + """ + + # get list of valid backend filenames in backend path + backend_path_filenames = get_backend_path_filenames() + if not backend_path_filenames: + return None + + valid_backend_filenames = [] + for filename in backend_path_filenames: + # check naming, skip invalid filenames + if not check_backend_file_naming(filename): continue - if int(match.group(1)) == int(backend_id): - logger.info(f"Attempting to delete backend with id: {backend_id} as file: {settings.FORC_BACKEND_PATH}/{file}") - try: - os.remove(f"{settings.FORC_BACKEND_PATH}/{file}") - logger.info(f"Deleted backend with id: {backend_id}") - await reload_openresty() - return True - except OSError as e: - logger.warning(f"Was not able to delete backend with id: {backend_id} ERROR: {e}") - raise InternalServerError("Server was not able to delete this backend. Contact the admin.") - raise NotFound("Backend was not found.") + + # add valid filenames to list and return them + valid_backend_filenames.append(filename) + return valid_backend_filenames + + +def filter_backend_filenames_by_id(backend_path_filenames: List[str], backend_id: int) -> List[str]: + """ + Filters and returns a list of backend filenames for matching backend ids. Provided filenames must be checked for correct naming separately before. + """ + return [filename for filename in backend_path_filenames if int(filename.split("%")[0]) == int(backend_id)] + + +def build_payload_for_auth_update(backend: BackendOut, auth_enabled: bool) -> BackendIn | None: + """ + Build BackendIn payload by fetching required information from existing BackendOut. For backend_service.create_backend(), see update_backend_authorization(). + """ + upstream_url = get_upstream_url(backend.file_path) + base_key = get_basekey_from_backend(backend) + if not upstream_url or not base_key: + logger.error(f"Could not extract necessary info (upstream_url and base_key) from backend id: {backend.id} for updating authorization") + return None + + # build new temporary payload as BackendIn + try: + temp_payload = BackendIn( + owner = backend.owner, + template = backend.template, + template_version = backend.template_version, + user_key_url = base_key, # add fetched fields + upstream_url = upstream_url, + auth_enabled = auth_enabled, # set new auth flag + ) + return temp_payload + except Exception as e: + logger.error(f"Error building temp payload for backend update: {e}") + return None diff --git a/FastapiOpenRestyConfigurator/app/main/service/openresty.py b/FastapiOpenRestyConfigurator/app/main/service/openresty.py old mode 100644 new mode 100755 index e5053f4d..b3cf8931 --- a/FastapiOpenRestyConfigurator/app/main/service/openresty.py +++ b/FastapiOpenRestyConfigurator/app/main/service/openresty.py @@ -1,17 +1,39 @@ """ Service to reload openresty by starting a process. """ -import os + +import asyncio import logging +from app.main.config import get_settings + +settings = get_settings() + logger = logging.getLogger("service") async def reload_openresty(): logger.info("Reloading openresty config after backend change.") + try: - os.popen("sudo openresty -s reload") - logger.info("Reload succesful.") - except OSError as e: + if settings.CONTAINERIZED: + cmd = ["openresty", "-s", "reload"] + else: + cmd = ["sudo", "openresty", "-s", "reload"] + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + raise RuntimeError(stderr.decode().strip()) + + logger.info("Reload successful.") + + except Exception as e: logger.exception(f"Was not able to reload OpenResty: {e}") diff --git a/FastapiOpenRestyConfigurator/app/main/service/template.py b/FastapiOpenRestyConfigurator/app/main/service/template.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/service/user.py b/FastapiOpenRestyConfigurator/app/main/service/user.py old mode 100644 new mode 100755 index d0da9762..d785bf5f --- a/FastapiOpenRestyConfigurator/app/main/service/user.py +++ b/FastapiOpenRestyConfigurator/app/main/service/user.py @@ -11,10 +11,10 @@ from ..model.serializers import User logger = logging.getLogger("service") -settings = get_settings() async def get_users(backend_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) user_id_path = f"{settings.FORC_USER_PATH}/{backend_id}" if not os.path.exists(user_id_path) and not os.access(user_id_path, os.R_OK): @@ -28,6 +28,7 @@ async def get_users(backend_id): async def add_user(backend_id, user_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) if "@" in user_id: user_id_parts = user_id.split("@") @@ -59,6 +60,7 @@ async def add_user(backend_id, user_id): async def delete_user(backend_id, user_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) if "@" in user_id: user_id_parts = user_id.split("@") @@ -93,6 +95,7 @@ async def delete_user(backend_id, user_id): async def delete_all(backend_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) user_id_path = f"{settings.FORC_USER_PATH}/{backend_id}" if not os.path.exists(user_id_path): diff --git a/FastapiOpenRestyConfigurator/app/main/tests/conftest.py b/FastapiOpenRestyConfigurator/app/main/tests/conftest.py new file mode 100644 index 00000000..b0c5e1d7 --- /dev/null +++ b/FastapiOpenRestyConfigurator/app/main/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest +import os + +@pytest.fixture(scope="session", autouse=True) +def test_dirs(tmp_path_factory): + """ + See config.get_settings(). Creates the path structure and sets environment variables. + """ + backend = tmp_path_factory.mktemp("backend") + templates = tmp_path_factory.mktemp("templates") + + os.environ["FORC_BACKEND_PATH"] = str(backend) + os.environ["FORC_TEMPLATE_PATH"] = str(templates) + os.environ["FORC_API_KEY"] = "test-api-key" + + yield diff --git a/FastapiOpenRestyConfigurator/app/main/tests/test_service_backend.py b/FastapiOpenRestyConfigurator/app/main/tests/test_service_backend.py new file mode 100644 index 00000000..b1eff6db --- /dev/null +++ b/FastapiOpenRestyConfigurator/app/main/tests/test_service_backend.py @@ -0,0 +1,818 @@ +import pytest +from unittest.mock import call, patch + +from werkzeug.exceptions import NotFound, InternalServerError + +from app.main.model.serializers import BackendIn, BackendOut, BackendTemp + +from app.main.service import backend as backend_service + +file_path_example_1 = "1234567890%testuser%animal_100%testtemplate%v01%0.conf" +file_path_example_2 = "9876543210%otheruser%cat_200%othertemplate%v02%1.conf" + +# TEST DATA + +# backend test cases: (exception_expected, filename, backend), see test_generate_backend_filename() +test_backends_for_generate_backend_filename = [ + (False, "123%testuser%dog_100%testtemplate%v01%0.conf", + BackendOut.model_construct( + id = 123, + owner = "testuser", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + auth_enabled = False + )), + (False, "456%otheruser%ant_300%anothertemplate%v03%1.conf", + BackendOut.model_construct( + id = 456, + owner = "otheruser", + location_url = "ant_300", + template = "anothertemplate", + template_version = "v03", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = None, + owner = "otheruser", + location_url = "cat_200", + template = "othertemplate", + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = None, + location_url = "cat_200", + template = "othertemplate", + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = None, + template = "othertemplate", + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = "cat_200", + template = None, + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = "cat_200", + template = "othertemplate", + template_version = None, + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = "cat_200", + template = "othertemplate", + template_version = "v02", + auth_enabled = None + )) + ] +# backend test cases: (exception_expected, backend), see test_convert_backend_temp_to_out() +test_backends_for_convert_backend_temp_to_out = [ + (False, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (False, + BackendTemp.model_construct( + id = 456, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "ant_300", + template = "anothertemplate", + template_version = "v03", + user_key_url = "myRstudio", + upstream_url = "http://1.1.1.1:8002", + auth_enabled = True, + file_path = "another_file_path" + )), + (True, + BackendTemp.model_construct( + id = None, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = None, + location_url = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = None, + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = None, + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = None, + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = None, + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = None, + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = None, + file_path = None + )), + ] + + +# HELPER FUNCTIONS + +@pytest.mark.parametrize( + "exception_expected, expected_suffix, user_key_url", + [ + (False, 100, None), + (False, 201, "test_200"), + (True, None, "test_-10"), + (True, None, "test_999"), + (True, None, "test_1000"), + (True, None, "test_150.5"), + (True, None, "test_not-an-int") + ] +) +@pytest.mark.asyncio +async def test_generate_suffix_number(exception_expected, expected_suffix, user_key_url): + + with patch( + "app.main.service.backend.get_highest_suffix_number", + return_value = (expected_suffix - 1) if expected_suffix else None + ) as mock_get_highest_suffix_number: + + try: + response_suffix: int = await backend_service.generate_suffix_number(user_key_url) + # success case + if not exception_expected: + assert response_suffix == expected_suffix + if user_key_url is not None: + mock_get_highest_suffix_number.assert_awaited_once() + # fail case + except Exception as e: + if not exception_expected: + raise e + + +@pytest.mark.parametrize( + "expected_suffix, user_key_url, backends", + [ + (100, "unusual_123", []), + (123, "test_123", [BackendOut.model_construct(location_url = "test_123"), BackendOut.model_construct(location_url = "animal_100")]), + (250, "test_250", [BackendOut.model_construct(location_url = "test_250"), BackendOut.model_construct(location_url = "animal_100")]), + (499, "test_499", [BackendOut.model_construct(location_url = "test_499"), BackendOut.model_construct(location_url = "animal_100")]) + ] +) +@pytest.mark.asyncio +async def test_get_highest_suffix_number(expected_suffix, user_key_url, backends): + + with patch( + "app.main.service.backend.get_backends", + return_value = backends + ) as mock_get_backends: + + highest_id = await backend_service.get_highest_suffix_number(user_key_url) + mock_get_backends.assert_awaited_once() + assert highest_id == expected_suffix + + + +@pytest.mark.parametrize( + "exception_expected, filename, backend", + test_backends_for_generate_backend_filename +) +@pytest.mark.asyncio +async def test_generate_backend_filename(exception_expected, filename, backend): + try: + response_filename: str = backend_service.generate_backend_filename(backend) + # success case + if not exception_expected: + assert response_filename == filename + # fail case + except Exception as e: + if not exception_expected: + raise e + + + +# CORE GETTER FUNCTIONS + +""" +async def test_get_backends(): +""" + + +@pytest.mark.parametrize( + "exception_expected, backend_id", + [ + (False, 123), + (True, None) + ] +) +@pytest.mark.asyncio +async def test_get_backend_by_id(exception_expected, backend_id): + + with patch( + "app.main.service.backend.get_backends", + return_value = [BackendOut.model_construct(id = backend_id), BackendOut.model_construct(id = 456)] + ) as mock_get_backends: + + try: + response_backend: BackendOut = await backend_service.get_backend_by_id(backend_id) + # success case + if not exception_expected: + assert response_backend.id == backend_id + mock_get_backends.assert_awaited_once() + # fail case + except Exception as e: + if not exception_expected: + raise e + + + +# FURTHER GETTER FUNCTIONS + +@pytest.mark.parametrize( + "exception_expected, proxy_pass, expected_upstream_url", + [ + (False, "http://1.1.1.1:1000/guacamole/", "http://1.1.1.1:1000"), + (False, "http://200.100.50.10:4200/other/", "http://200.100.50.10:4200"), + (True, None, None) + ] +) +@pytest.mark.asyncio +async def test_get_upstream_url(exception_expected, proxy_pass, expected_upstream_url): + + with patch( + "app.main.service.backend.extract_proxy_pass", + return_value = proxy_pass + ) as mock_extract_proxy_pass: + + response_upstream_url = backend_service.get_upstream_url("test_path") + mock_extract_proxy_pass.assert_called_once() + + # success case + if not exception_expected: + assert response_upstream_url == expected_upstream_url + # fail case + else: + assert response_upstream_url is None + + +@pytest.mark.parametrize( + "exception_expected, backend, expected_basekey", + [ + (False, BackendOut.model_construct(location_url = "test_100"), "test"), + (False, BackendOut.model_construct(location_url = "example_200"), "example"), + (True, BackendOut.model_construct(location_url = "corrupted"), None), + (True, BackendOut.model_construct(location_url = "123"), None), + (True, None, None) + ] +) +def test_get_basekey_from_backend(exception_expected, backend, expected_basekey): + + response_basekey = backend_service.get_basekey_from_backend(backend) + # success case + if not exception_expected: + assert response_basekey == expected_basekey + # fail case + else: + assert response_basekey is None + + + +# CORE MUTATOR AND SERVICE FUNCTIONS + +@pytest.mark.parametrize( + "backend_file_contents, delete_duplicate_backends", + [ + ("something", True), + ("something", False), + (None, True), + (None, False) + ] +) +@pytest.mark.asyncio +async def test_create_backend(backend_file_contents, delete_duplicate_backends): + with patch( + "app.main.config.get_settings", + ) as mock_get_settings, patch( + "app.main.service.backend.set_backend_id_and_suffix", + return_value = (BackendTemp.model_construct(user_key_url="olddragon"), 100) + ) as mock_set_backend_id_and_suffix, patch( + "app.main.service.backend.generate_backend_by_template", + return_value = backend_file_contents + ) as mock_generate_backend_by_template, patch( + "app.main.service.backend.delete_duplicate_backends", + return_value = delete_duplicate_backends + ) as mock_delete_duplicate_backends, patch( + "app.main.service.backend.generate_backend_filename" + ) as mock_generate_backend_filename, patch( + "os.open" + ) as mock_os_open, patch( + "os.write" + ) as mock_os_write, patch( + "os.close" + ) as mock_os_close, patch( + "app.main.service.backend.reload_openresty" + ) as mock_reload_openresty: + backend_in = BackendIn( + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb7", + template = "theiaide", + template_version = "v03", + auth_enabled = True, + user_key_url = "olddragon", + upstream_url = "http://192.168.0.1:8787/guacamole/" + ) + try: + result = await backend_service.create_backend(backend_in) + # mock_get_settings.assert_called_once() + mock_set_backend_id_and_suffix.assert_awaited_once() + mock_generate_backend_by_template.assert_awaited_once() + mock_delete_duplicate_backends.assert_awaited_once() + mock_generate_backend_filename.assert_called_once() + # mock_os_open.assert_called_once() + # mock_os_write.assert_called_once() + # mock_os_close.assert_called_once() @ reviewer: these 4 mocks are not called + mock_reload_openresty.assert_awaited_once() + assert result.location_url == "olddragon_100" + except Exception as e: + assert not backend_file_contents or not delete_duplicate_backends + + +@pytest.mark.parametrize( + "exception_expected, backend_path_filenames, matching_backend_filenames", + [ + (False, [file_path_example_1, file_path_example_2], [file_path_example_1]), + (False, [file_path_example_1, file_path_example_2], [file_path_example_2]), + (True, [file_path_example_1, file_path_example_2], []), + (True, [], []), + (True, [file_path_example_2], []) + ] +) +@pytest.mark.asyncio +async def test_delete_backend(exception_expected, backend_path_filenames, matching_backend_filenames): + with patch( + "app.main.config.get_settings" + ) as mock_get_settings, patch( + "app.main.service.backend.get_valid_backend_filenames", + return_value = backend_path_filenames + ) as mock_get_valid_backend_filenames, patch( + "app.main.service.backend.filter_backend_filenames_by_id", + return_value = matching_backend_filenames + ) as mock_filter_backend_filenames_by_id, patch( + "os.remove" + ) as mock_os_remove, patch( + "app.main.service.backend.reload_openresty" + ) as mock_reload_openresty: + + try: + number_of_files = len(matching_backend_filenames) + result = await backend_service.delete_backend(0) # backend_id irrelevant due to mocks + # mock_get_settings.assert_called_once() + mock_get_valid_backend_filenames.assert_called_once() + mock_filter_backend_filenames_by_id.assert_called_once() + mock_os_remove.assert_called_once() + mock_reload_openresty.assert_awaited_once() + assert result is True + except NotFound as e: + assert exception_expected and (not backend_path_filenames or number_of_files == 0) + except InternalServerError as e: + assert exception_expected and backend_path_filenames and number_of_files != 1 + except OSError as e: + assert exception_expected and (not backend_path_filenames or number_of_files == 0) + except Exception as e: + raise e + + +@pytest.mark.parametrize( + "exception_expected, auth_enable, get_backend, temp_payload, new_contents, returning_backend", + [ + (False, True, True, True, True, True), + (False, False, True, True, True, True), + (True, True, False, True, True, True), + (True, True, True, False, True, True), + (True, True, True, True, False, True), + (True, True, True, True, True, False) + ] +) +@pytest.mark.asyncio +async def test_update_backend_authorization(exception_expected, auth_enable, get_backend, temp_payload, new_contents, returning_backend): + with patch( + "app.main.service.backend.get_backend_by_id", + return_value = BackendOut.model_construct(location_url="test_100") if get_backend else None + ) as mock_get_backend_by_id, patch( + "app.main.service.backend.build_payload_for_auth_update", + return_value = BackendIn.model_construct() if temp_payload else None + ) as mock_build_payload_for_auth_update, patch( + "app.main.service.backend.create_backend", + return_value = BackendTemp.model_construct() if new_contents else None + ) as mock_create_backend, patch( + "app.main.service.backend.convert_backend_temp_to_out", + return_value = BackendOut.model_construct() if returning_backend else None + ) as mock_convert_backend_temp_to_out: + + result = await backend_service.update_backend_authorization(0, auth_enable) # backend_id irrelevant due to mocks + if not exception_expected: + mock_get_backend_by_id.assert_awaited_once_with(0) + mock_build_payload_for_auth_update.assert_called_once_with(mock_get_backend_by_id.return_value, auth_enable) + mock_create_backend.assert_awaited_once_with(mock_build_payload_for_auth_update.return_value, id='0', location_url='test_100') + mock_convert_backend_temp_to_out.assert_awaited_once_with(mock_create_backend.return_value) + assert result == mock_convert_backend_temp_to_out.return_value + else: + assert result is None + + + +# HELPER FUNCTIONS FOR MUTATORS + +@pytest.mark.parametrize( + "exception_expected, kwargs", + [ + # SUCCESS CASES + (False, {}), + (False, {"id": 123, "location_url": "test_200"}), + # FAIL CASES + # one param is missing + (True, {"id": 123}), + (True, {"location_url": "test_200"}), + # required param is None + (True, {"id": None, "location_url": "test_200"}), + (True, {"id": 123, "location_url": None}), + # wrong type param + (True, {"id": "not-an-int", "location_url": "test_200"}), + (True, {"id": 123, "location_url": 111}) + ] +) +@pytest.mark.asyncio +async def test_set_backend_id_and_suffix(exception_expected, kwargs): + with patch( + "app.main.service.backend.generate_suffix_number", + ) as mock_generate_suffix_number: + + try: + backend, suffix_number = await backend_service.set_backend_id_and_suffix(BackendTemp.model_construct(), **kwargs) + backend: BackendTemp + suffix_number: int + # success case + if not exception_expected: + assert backend.id + assert suffix_number + if kwargs == {}: + mock_generate_suffix_number.assert_awaited_once() + else: + mock_generate_suffix_number.assert_not_awaited() + assert int(backend.id) == int(kwargs["id"]) + # fail case + except Exception as e: + if not exception_expected: + raise e + + +@pytest.mark.parametrize( + "delete_succeeded, proxy_pass, expected_delete_backend_ids", + [ + (True, "http://192.168.0.1:8787/guacamole/", [12, 34]), + (True, "http://192.168.0.1:8787", [56, 78]), + (False, "http://192.168.0.1:8787", [56, 78]), + (True, "http://1.1.1.1:4000", []) + ] + # not able to test cases like None, "", "not_valid" without a validator, TODO: can we use serializer.BackendIn validator? +) +@pytest.mark.asyncio +async def test_delete_duplicate_backends(delete_succeeded, proxy_pass, expected_delete_backend_ids): + with patch( + "app.main.service.backend.get_backends_proxy_pass", + return_value = { + "http://192.168.0.1:8787/guacamole/": [ + BackendOut.model_construct( + id = 12, + upstream_url = "http://192.168.0.1:8787/guacamole/", + ), + BackendOut.model_construct( + id = 34, + upstream_url = "http://192.168.0.1:8787/guacamole/", + ), + ], + "http://192.168.0.1:8787": [ + BackendOut.model_construct( + id = 56, + upstream_url = "http://192.168.0.1:8787", + ), + BackendOut.model_construct( + id = 78, + upstream_url = "http://192.168.0.1:8787", + ), + ], + "http://1.1.1.1:4000": [ + BackendOut.model_construct( + id = 90, + upstream_url = "http://1.1.1.1:4000/", + ), + ], + } + ) as mock_get_backends_upstream_urls, patch( + "app.main.service.backend.delete_backend", + return_value = delete_succeeded + ) as mock_delete_backend: + + response_success: bool = await backend_service.delete_duplicate_backends(BackendIn.model_construct(upstream_url = proxy_pass)) + mock_get_backends_upstream_urls.assert_awaited_once() + # success case + if delete_succeeded: + assert response_success is True + if len(expected_delete_backend_ids) != 0: + mock_delete_backend.assert_has_awaits( + [call(backend_id=id) for id in expected_delete_backend_ids], + any_order = True + ) + # fail case + elif not delete_succeeded: + assert response_success is False + mock_delete_backend.assert_awaited() + + +@pytest.mark.parametrize( + "exception_expected, backend", + test_backends_for_convert_backend_temp_to_out +) +@pytest.mark.asyncio +async def test_convert_backend_temp_to_out(exception_expected, backend): + with patch( + "app.main.service.backend.get_backend_by_id", + return_value = BackendOut.model_construct(file_path = backend.file_path) + ) as mock_get_backend_by_id: + try: # @reviewer: is there an easier way? + backend_out = await backend_service.convert_backend_temp_to_out(backend) + if not exception_expected: + assert isinstance(backend_out, BackendOut) + mock_get_backend_by_id.assert_awaited_once_with(backend.id) + assert backend_out.id == backend.id + assert backend_out.owner == backend.owner + assert backend_out.location_url == backend.location_url + assert backend_out.template == backend.template + assert backend_out.template_version == backend.template_version + assert backend_out.auth_enabled == backend.auth_enabled + assert backend_out.file_path == backend.file_path + except Exception as e: + if not exception_expected: + raise e + + +@pytest.mark.parametrize( + "expected, path_exists, access, listdir", + [ + (True, True, True, ["first"]), + (True, True, True, ["first", "second"]), + (True, True, True, ["first", "second", "third"]), + (False, False, False, ["first"]), + (False, False, True, ["first"]), + (False, True, False, ["first"]), + (False, True, True, []), + + ] +) +def test_check_backend_path(expected, path_exists, access, listdir): + + with patch( + "app.main.service.backend.get_settings" # imported + ) as mock_get_settings, patch( + "os.path.exists", + return_value = path_exists + ) as mock_os_path_exists, patch( + "os.access", + return_value = access + ) as mock_os_access, patch( + "os.listdir", + return_value = listdir + ) as mock_os_listdir: + assert backend_service.check_backend_path() == expected + mock_get_settings.assert_called_once() + mock_os_path_exists.assert_called_once() + if path_exists: + mock_os_access.assert_called_once() + if access: + mock_os_listdir.assert_called_once() + + +@pytest.mark.parametrize( + "expected, filename", + [ + (True, file_path_example_1), + (True, file_path_example_2), + (False, "users"), + (False, "scripts"), + (False, "obviously_wrong"), + (False, 37) + ] +) +def test_check_backend_file_naming(expected, filename): + try: + assert backend_service.check_backend_file_naming(filename) is expected + except TypeError as e: + assert not expected + if expected: + raise e + +@pytest.mark.parametrize( + "returning, backend_check", + [ + (["something"], True), + (None, False) + ] +) +def test_get_backend_path_filenames(returning, backend_check): + + with patch( + "app.main.service.backend.get_settings" # imported + ) as mock_get_settings, patch( + "app.main.service.backend.check_backend_path", + return_value = backend_check + ) as mock_check_backend_path, patch( + "os.listdir", + return_value = returning + ) as mock_os_listdir: + assert backend_service.get_backend_path_filenames() == returning + mock_check_backend_path.assert_called_once() + if backend_check: + mock_os_listdir.assert_called_once() + mock_get_settings.assert_called_once() + + +@pytest.mark.parametrize( + "filenames, naming_check", + [ + ([file_path_example_1, file_path_example_2], True), + (["invalid_file_1", "invalid_file_2"], False), + (["users", "scripts"], False), + ([], False) + ] +) +def test_get_valid_backend_filenames(filenames, naming_check): + + with patch( + "app.main.service.backend.get_backend_path_filenames", + return_value = filenames + ) as mock_get_backend_path_filenames, patch( + "app.main.service.backend.check_backend_file_naming", + return_value = naming_check + ) as mock_check_backend_file_naming: + + expected = None + if filenames: + expected = filenames if naming_check else [] + + assert backend_service.get_valid_backend_filenames() == expected + + mock_get_backend_path_filenames.assert_called_once() + if filenames: + mock_check_backend_file_naming.assert_called() + + +@pytest.mark.parametrize( + "backend_id, filenames, expected", + [ + (1234567890, [file_path_example_1, file_path_example_2], [file_path_example_1]), + (9876543210, [file_path_example_1, file_path_example_2], [file_path_example_2]), + (None, [], []) + ] +) +def test_filter_backend_filenames_by_id(backend_id, filenames, expected): + assert backend_service.filter_backend_filenames_by_id(filenames, backend_id) == expected + + +@pytest.mark.parametrize( + "exception_expected, upstream_url, base_key, auth_enabled", + [ + (False, "http://192.168.0.1:8787/guacamole/", "olddragon", True), + (False, "http://192.168.0.1:4000/guacamole/", "youngmonkey", False), + (True, None, "crazydog", True), + (True, "http://192.168.0.1:1000/", None, False), + (True, "http://192.168.0.1:1234/", "test_100", None) + ] +) +def test_build_payload_for_auth_update(exception_expected, upstream_url, base_key, auth_enabled): + with patch( + "app.main.service.backend.get_upstream_url", + return_value = upstream_url + ) as mock_get_upstream_url, patch( + "app.main.service.backend.get_basekey_from_backend", + return_value = base_key + ) as mock_get_basekey_from_backend: + backend: BackendOut = BackendOut( + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb7", + template = "theiaide", + template_version = "v03", + auth_enabled = True, + id = 3872943384, + location_url = "tropicalantelope_100", + file_path = "3872943384%4d2e5e17-a378-4df0-ba9e-4fb710f0eeb7%tropicalantelope_100%theiaide%v03%1.conf" + ) + + result = backend_service.build_payload_for_auth_update(backend, auth_enabled) + mock_get_upstream_url.assert_called_once_with(backend.file_path) + mock_get_basekey_from_backend.assert_called_once_with(backend) + if not upstream_url or not base_key or exception_expected: + assert result is None + return + + assert isinstance(result, BackendIn) + assert result.owner == backend.owner + assert result.template == backend.template + assert result.template_version == backend.template_version + assert result.user_key_url == base_key + assert result.upstream_url == upstream_url + assert result.auth_enabled == auth_enabled diff --git a/FastapiOpenRestyConfigurator/app/main/tests/test_views_backend.py b/FastapiOpenRestyConfigurator/app/main/tests/test_views_backend.py new file mode 100644 index 00000000..ba799f8a --- /dev/null +++ b/FastapiOpenRestyConfigurator/app/main/tests/test_views_backend.py @@ -0,0 +1,46 @@ +import pytest +from unittest.mock import patch +from fastapi import HTTPException + +from app.main.views import backend as backend_views + + +@pytest.mark.parametrize( + "exception_expected, backend_id, body", + [ + # success cases + (False, 123, {"auth_enabled": True}), + (False, 123, {"auth_enabled": 1}), + (False, 123, {"auth_enabled": 0}), + (False, 123, {"auth_enabled": False}), + # corrupted backend_id + # TODO: more tests when there is further validation on backend_id + (True, "not an int", {"auth_enabled": True}), + (True, None, {"auth_enabled": True}), + # corrupted body + (True, 123, {"auth_enabled": "not a boolean"}), + (True, 123, {"auth_enabled": ""}), + (True, 123, {"differrent_value": True}), + (True, 123, None), + (True, 123, {}), + ] +) +@pytest.mark.asyncio +async def test_backend_update_auth(exception_expected, backend_id, body): + + with patch( + "app.main.service.backend.update_backend_authorization" + ) as mock_update_backend_authorization: + try: + await backend_views.backend_update_auth( + backend_id = backend_id, + body = body, + api_key = object() # type: ignore[reportArgumentType] + ) + # success case + if not exception_expected: + mock_update_backend_authorization.assert_called_once_with(backend_id = 123, auth_enabled = True) + # fail case + except Exception: + if exception_expected: + mock_update_backend_authorization.assert_not_awaited() diff --git a/FastapiOpenRestyConfigurator/app/main/util/__init__.py b/FastapiOpenRestyConfigurator/app/main/util/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/util/auth.py b/FastapiOpenRestyConfigurator/app/main/util/auth.py old mode 100644 new mode 100755 index b69a5a40..711daba2 --- a/FastapiOpenRestyConfigurator/app/main/util/auth.py +++ b/FastapiOpenRestyConfigurator/app/main/util/auth.py @@ -10,12 +10,12 @@ API_KEY_NAME = "X-API-KEY" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) -settings = get_settings() async def get_api_key( api_key_header_in: str = Security(api_key_header), ): + settings = get_settings() if api_key_header_in == settings.FORC_API_KEY.get_secret_value(): return api_key_header_in else: diff --git a/FastapiOpenRestyConfigurator/app/main/util/logging.py b/FastapiOpenRestyConfigurator/app/main/util/logging.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/util/templating.py b/FastapiOpenRestyConfigurator/app/main/util/templating.py old mode 100644 new mode 100755 index 34d143c5..7877f8ba --- a/FastapiOpenRestyConfigurator/app/main/util/templating.py +++ b/FastapiOpenRestyConfigurator/app/main/util/templating.py @@ -4,37 +4,65 @@ import jinja2 import logging import os +from functools import lru_cache from ..model.serializers import BackendTemp from ..config import get_settings logger = logging.getLogger("util") -settings = get_settings() -logger.info("Loading the templating engine.") -try: - templateLoader = jinja2.FileSystemLoader(searchpath=settings.FORC_TEMPLATE_PATH) - templateEnv = jinja2.Environment(loader=templateLoader, autoescape=True) -except jinja2.exceptions.TemplatesNotFound: - logger.error("Was not able to load template engine. Adjust the templates_path in the config.") +@lru_cache +def _template_env(): + """ + Lazily initialize and cache the Jinja environment. + Called only at runtime, never at import time. + """ + settings = get_settings() + logger.info("Loading the templating engine.") -async def generate_backend_by_template(backend_temp: BackendTemp, suffix_number): - if not templateLoader or not templateEnv: - logger.error("The template engine is not loaded. Can't generate backend.") - return None - assembled_template_file_name = f"{backend_temp.template}%{backend_temp.template_version}.conf" - if not os.path.isfile(f"{settings.FORC_TEMPLATE_PATH}/{assembled_template_file_name}"): - logger.error(f"Not able to find {settings.FORC_TEMPLATE_PATH}/{assembled_template_file_name}") + loader = jinja2.FileSystemLoader( + searchpath=settings.FORC_TEMPLATE_PATH + ) + + env = jinja2.Environment( + loader=loader, + autoescape=True + ) + + return env, settings + + +async def generate_backend_by_template( + backend_temp: BackendTemp, + suffix_number: int +) -> str | None: + + env, settings = _template_env() + + assembled_template_filename = ( + f"{backend_temp.template}%{backend_temp.template_version}.conf" + ) + + template_path = os.path.join( + settings.FORC_TEMPLATE_PATH, + assembled_template_filename + ) + + if not os.path.isfile(template_path): + logger.error(f"Template not found: {template_path}") return None - template = templateEnv.get_template(assembled_template_file_name) + + template = env.get_template(assembled_template_filename) rendered_backend = template.render( key_url=f"{backend_temp.user_key_url}_{suffix_number}", owner=backend_temp.owner, backend_id=backend_temp.id, forc_backend_path=settings.FORC_BACKEND_PATH, - location_url=backend_temp.upstream_url + location_url=backend_temp.upstream_url, + auth_enabled=backend_temp.auth_enabled, ) + return rendered_backend diff --git a/FastapiOpenRestyConfigurator/app/main/views/__init__.py b/FastapiOpenRestyConfigurator/app/main/views/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/views/backend.py b/FastapiOpenRestyConfigurator/app/main/views/backend.py old mode 100644 new mode 100755 index 662064b0..0bdd583c --- a/FastapiOpenRestyConfigurator/app/main/views/backend.py +++ b/FastapiOpenRestyConfigurator/app/main/views/backend.py @@ -4,7 +4,7 @@ import logging from typing import List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Body from fastapi.responses import JSONResponse from fastapi.openapi.models import APIKey from werkzeug.exceptions import NotFound, InternalServerError @@ -46,6 +46,39 @@ async def create_backend(backend_in: BackendIn, api_key: APIKey = Depends(get_ap raise HTTPException(status_code=400) +@router.post( + "/backends/{backend_id}/auth/", + response_model=BackendOut, + tags=["Backends"], + summary="Set owner authorization to true/false for an existing backend." +) +async def backend_update_auth(backend_id: int, body: dict = Body(...), api_key: APIKey = Depends(get_api_key)): + # process inputs TODO: should we validate further? + logger.debug(f"Received request to update backend authorization with backend_id: {backend_id} and body: {body}") + backend_id = int(secure_filename(str(backend_id))) # TODO: are secure_filename and str necessary? validation? + if backend_id is None: + raise HTTPException(status_code=400, detail="backend_id is required") + enable_auth = bool(body.get("auth_enabled", None)) + logger.debug(f"Attempting to update backend authorization to {enable_auth} for backend id: {backend_id}") + + # check inputs and raise error + if backend_id is None or enable_auth is None or not isinstance(enable_auth, bool): + logger.error( + f"Received faulty data. backend_id: {backend_id}, auth_enabled: {enable_auth}, {type(enable_auth)}") + raise HTTPException(status_code=422, detail = + f"auth_enabled is required and must be a boolean, \ + backend_id: {backend_id}, auth_enabled: {enable_auth}, {type(enable_auth)}") + # forward to service layer + else: + try: + return await backend_service.update_backend_authorization(backend_id, enable_auth) + # TODO: the exceptions are raised in the service layer already, do we still need this? + except NotFound: + raise HTTPException(status_code=404, detail=f"Backend with id {backend_id} not found.") + except InternalServerError: + raise HTTPException(status_code=500, detail="Internal server error.") + + @router.get( "/backends/{backend_id}", response_model=BackendOut, @@ -75,6 +108,8 @@ async def get_backend(backend_id: int, api_key: APIKey = Depends(get_api_key)): async def delete_backend(backend_id: int, api_key: APIKey = Depends(get_api_key)): try: backend_id = int(secure_filename(str(backend_id))) + if backend_id is None: + raise HTTPException(status_code=400, detail="backend_id is required") await backend_service.delete_backend(backend_id) await user_service.delete_all(backend_id) except NotFound: diff --git a/FastapiOpenRestyConfigurator/app/main/views/template.py b/FastapiOpenRestyConfigurator/app/main/views/template.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/views/user.py b/FastapiOpenRestyConfigurator/app/main/views/user.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/views/utils.py b/FastapiOpenRestyConfigurator/app/main/views/utils.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/gunicorn_conf.py b/FastapiOpenRestyConfigurator/gunicorn_conf.py new file mode 100755 index 00000000..d3db1294 --- /dev/null +++ b/FastapiOpenRestyConfigurator/gunicorn_conf.py @@ -0,0 +1,11 @@ +# Socket Path +bind = "unix:/var/run/forc.sock" + +# Worker Options +workers = 5 +worker_class = "uvicorn.workers.UvicornWorker" + +# Logging Options +loglevel = "info" +accesslog = "/var/log/forc.access.log" +errorlog = "/var/log/forc.error.log" diff --git a/FastapiOpenRestyConfigurator/main.py b/FastapiOpenRestyConfigurator/main.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/pytest.ini b/FastapiOpenRestyConfigurator/pytest.ini new file mode 100644 index 00000000..5ee64771 --- /dev/null +++ b/FastapiOpenRestyConfigurator/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/FastapiOpenRestyConfigurator/requirements.txt b/FastapiOpenRestyConfigurator/requirements.txt index c54571e4..3cedfe8b 100644 --- a/FastapiOpenRestyConfigurator/requirements.txt +++ b/FastapiOpenRestyConfigurator/requirements.txt @@ -1,7 +1,11 @@ -fastapi==0.136.1 -uvicorn==0.47.0 +fastapi==0.141.1 +uvicorn==0.52.0 werkzeug==3.1.8 Jinja2==3.1.6 python-dotenv==1.2.2 gunicorn==26.0.0 pydantic-settings +factory-boy==3.3.3 +# testing +pytest==9.1.1 +pytest-asyncio==1.4.0 diff --git a/FastapiOpenRestyConfigurator/tests/__init__.py b/FastapiOpenRestyConfigurator/tests/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/FastapiOpenRestyConfigurator/tests/conftest.py b/FastapiOpenRestyConfigurator/tests/conftest.py new file mode 100644 index 00000000..d647da51 --- /dev/null +++ b/FastapiOpenRestyConfigurator/tests/conftest.py @@ -0,0 +1,23 @@ +import pytest +import os + +from .factories.backendOutFactory import build_backend_out + +@pytest.fixture(scope="session", autouse=True) +def test_dirs(tmp_path_factory): + backend = tmp_path_factory.mktemp("backend") + templates = tmp_path_factory.mktemp("templates") + + os.environ["FORC_BACKEND_PATH"] = str(backend) + os.environ["FORC_TEMPLATE_PATH"] = str(templates) + os.environ["FORC_API_KEY"] = "test-api-key" + + yield + + +@pytest.fixture +def backend_out_factory(): + """ + Returns a callable factory. + """ + return build_backend_out diff --git a/FastapiOpenRestyConfigurator/tests/factories/__init__.py b/FastapiOpenRestyConfigurator/tests/factories/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/FastapiOpenRestyConfigurator/tests/factories/backendOutFactory.py b/FastapiOpenRestyConfigurator/tests/factories/backendOutFactory.py new file mode 100644 index 00000000..5dfd5f42 --- /dev/null +++ b/FastapiOpenRestyConfigurator/tests/factories/backendOutFactory.py @@ -0,0 +1,32 @@ +from app.main.model.serializers import BackendOut +import factory +from faker import Faker +import string +import random +fake = Faker() +def random_owner(): + allowed = string.ascii_letters + string.digits + "@" + # mind. 30 chars + return "".join(random.choices(allowed, k=30)) + +class BackendOutFactory(factory.DictFactory): + id = factory.Sequence(lambda n: n + 1) + + owner = factory.LazyFunction(random_owner) + template = "testtemplate" + template_version = "v01" + location_url = factory.Faker("url") + config_path = factory.LazyAttribute( + lambda o: f"{o.template}%{o.template_version}%0.conf" + ) + + class Meta: + model = dict + +def build_backend_out(**kwargs) -> BackendOut: + """ + Helper that returns a validated BackendOut model. + """ + return BackendOut.model_validate( + BackendOutFactory.build(**kwargs) + ) \ No newline at end of file diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/docker/Dockerfile b/docker/Dockerfile old mode 100644 new mode 100755 index 2fbcd102..95994f58 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,26 +1,71 @@ -FROM ubuntu:24.04 -MAINTAINER awalende +# --- Build Stage --- +FROM openresty/openresty:1.31-alpine-slim AS build-stage +# Install build-time dependencies +RUN apk add --no-cache \ + bash \ + gcc \ + make \ + musl-dev \ + lua5.1-dev \ + git \ + python3 \ + python3-dev \ + py3-pip \ + luarocks5.1 +# Install Lua modules into OpenResty lualib directory +RUN luarocks-5.1 install lua-resty-openidc \ + && cp -r /usr/local/share/lua/5.1/* /usr/local/openresty/lualib/ + +# Prepare Python virtual environment +COPY FastapiOpenRestyConfigurator/requirements.txt /tmp/requirements.txt +RUN python3 -m venv /opt/venv \ + && /opt/venv/bin/pip install --upgrade pip \ + && /opt/venv/bin/pip install -r /tmp/requirements.txt \ + && /opt/venv/bin/pip install gunicorn uvicorn + +# --- Runtime Stage --- +FROM openresty/openresty:1.31-alpine-slim AS runtime-stage + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV CONTAINERIZED=true ENV FORC_BACKEND_PATH=/var/forc/backend_path/ ENV FORC_TEMPLATE_PATH=/var/forc/template_path/ +ENV PATH="/opt/venv/bin:$PATH" + +# Install runtime-only dependencies +RUN apk add --no-cache \ + bash \ + curl \ + wget \ + ca-certificates \ + python3 -RUN mkdir -p /var/forc/backend_path/; mkdir -p /var/forc/template_path/ +# Path Mapping: Link /etc/openresty to the default OpenResty config directory +# This maintains compatibility with render_nginx.py and scripts +RUN ln -snf /usr/local/openresty/nginx/conf /etc/openresty -RUN apt-get update; apt-get install -y sudo git wget gnupg ca-certificates software-properties-common python3 python3-pip python-setuptools; pip3 install uwsgi -RUN wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add - -RUN add-apt-repository -y "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" -RUN apt-get update -RUN apt-get -y install openresty -RUN opm install zmartzone/lua-resty-openidc -RUN mkdir -p /opt/simpleVMWebGateway -RUN git clone https://github.com/deNBI/simpleVMWebGateway.git /opt/simpleVMWebGateway/ -RUN pip3 install -r /opt/simpleVMWebGateway/FastapiOpenRestyConfigurator/requirements.txt +# Copy artifacts from build-stage +COPY --from=build-stage /opt/venv /opt/venv +COPY --from=build-stage /usr/local/openresty/lualib /usr/local/openresty/lualib +# Copy application code and configurations WORKDIR /opt/simpleVMWebGateway/FastapiOpenRestyConfigurator +COPY FastapiOpenRestyConfigurator . +# Copy Docker-specific files (build context is project root) +COPY docker/nginx.conf /etc/openresty/nginx.conf.j2 +COPY docker/render_nginx.py . +COPY docker/gunicorn_conf.py . +COPY docker/launch.sh . +COPY docker/generate_ip_blocklists.sh docker/ip_blocklists.txt /opt/scripts/ +COPY docker/html /usr/local/openresty/nginx/html -COPY uwsgi.ini uwsgi.ini -COPY launch.sh launch.sh -RUN chmod +x launch.sh +# Setup permissions +RUN mkdir -p ${FORC_BACKEND_PATH} ${FORC_TEMPLATE_PATH} /opt/scripts \ + && chmod +x launch.sh /opt/scripts/generate_ip_blocklists.sh +COPY examples/templates ${FORC_TEMPLATE_PATH} -CMD ./launch.sh +EXPOSE 5000 +CMD ["./launch.sh"] diff --git a/docker/README.md b/docker/README.md old mode 100644 new mode 100755 index f27372e5..0216fa92 --- a/docker/README.md +++ b/docker/README.md @@ -1,114 +1,104 @@ -## FORC+OpenResty in Docker - -### Guide - -This folder contains the setup for FORC+OpenResty in Docker. - -In order to run this container, you need to provide it with a ready configured `nginx.conf` file. - -A minimal file could look this: - -```` -worker_processes 10; -# /usr/local/openresty/nginx/logs/ -error_log logs/error.log; -error_log logs/error.log notice; -error_log logs/error.log info; -error_log logs/error.log debug; -events { - worker_connections 1024; -} -http { - include mime.types; - default_type application/octet-stream; - #For some reason, nginx wants a hardcoded Name Resolver - resolver 8.8.8.8; - sendfile on; - keepalive_timeout 65; - #LUA caches for various session modules - lua_shared_dict discovery 1m; - lua_shared_dict jwks 1m; - lua_code_cache off; - - #Allow websockets by allowing general connection upgrade requests, theia needs websockets - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - #Create global LUA variable which keeps our ELIXIR AAI Configuration dict - init_by_lua_block { - opts2 = { - redirect_uri = "http://reverseproxy.bibiserv.projects.bi.denbi.de/redirect_uri", - discovery = "https://login.elixir-czech.org/oidc/.well-known/openid-configuration", - client_id = "CLIENTID", - client_secret = "CLIENTSECRET", - logout_path = "/logout", - ssl_verify = "no" - } - } - - - server { - listen 5000; - location / { - include uwsgi_params; - uwsgi_pass unix:/var/run/forc.sock; - } - } - -server { - listen 0.0.0.0:80; - server_name reverseproxy.bibiserv.projects.bi.denbi.de; - set $session_secret fdhtzzu45z34t32g24f43; - - location / { - - access_by_lua_block { - -- Start actual openid authentication procedure - local res, err = require("resty.openidc").authenticate(opts2) - -- If it fails for some reason, escape via HTTP 500 - if err then - ngx.status = 500 - ngx.say(err) - ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) - end - - } - - } - - #Load all dynamicaly created locations. - include /var/forc/backend_path//*.conf; - - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root html; - } - } -} - -```` - -In this config file, adjust your oidc-client credentials in the `opts2` field and make changes to `servername`. - -This container pushes port `80` for reverse-proxied webcontent and port `5000` for the forc api. - -Generate a strong API Key for forc. - -Summing up, you can then build the container and run it afterwards: - -```` -docker build -t forc . - -docker run -p 5656:5000 -p 5657:80 -v nginx.conf:/etc/openresty/nginx.conf -e "FORC_API_KEY=somerandompw" forc -```` - -Path for forc backends and templates are the default ones: - -```` -/var/forc/backend_path/ -/var/forc/template_path/ -```` - -For convenience, it is recommended to mount these folders as a volume to the container. +# Docker Deployment for FORC + +This directory contains the configuration and scripts necessary to build and run the FORC (FastAPI OpenResty Configurator) gateway in a Docker container. + +## Overview + +FORC is a hybrid gateway that leverages **OpenResty** (an extended Nginx) for high-performance request handling and **FastAPI** for dynamic configuration and management. + +The container automates the setup of: +- A dynamically rendered Nginx configuration based on environment variables. +- An automated IP blocklist update system. +- A FastAPI backend running via Gunicorn. + +## File Descriptions + +| File | Description | +| :--- | :--- | +| `Dockerfile` | Multi-stage build definition based on Ubuntu 24.04. Installs OpenResty, Python, and necessary Lua modules. | +| `docker-compose.yml` | Service orchestration defining ports, volumes, and environment variable sources. | +| `launch.sh` | Container entrypoint. Orchestrates the startup sequence: blocklist generation $\rightarrow$ Nginx rendering $\rightarrow$ OpenResty start $\rightarrow$ Background blocklist loop $\rightarrow$ FastAPI start. | +| `render_nginx.py` | A utility script that renders the Nginx template (`nginx.conf.j2`) using all current environment variables. | +| `generate_ip_blocklists.sh` | Downloads and processes IP blocklists from sources defined in `ip_blocklists.txt` into a format OpenResty can use. | +| `ip_blocklists.txt` | A list of external URLs providing IP blocklists. | +| `gunicorn_conf.py` | Configuration settings for the Gunicorn WSGI server. | +| `nginx.conf` | The Jinja2 template used by `render_nginx.py` to generate the final Nginx configuration. | + +## Container Internals + +### Architecture +- **OS**: Ubuntu 24.04 +- **Web Server**: OpenResty (Nginx + Lua) +- **Application**: FastAPI (Python 3) +- **WSGI Server**: Gunicorn + +### Key Directories +- `/opt/simpleVMWebGateway/FastapiOpenRestyConfigurator`: The application root. +- `/etc/openresty/`: Contains the generated `nginx.conf` and `block_ips_geo.conf`. +- `/var/forc/backend_path/`: Used for backend persistence. +- `/var/forc/template_path/`: Stores configuration templates. +- `/opt/scripts/`: Contains the blocklist generation scripts. + +### Startup Sequence +1. **Blocklist Generation**: `generate_ip_blocklists.sh` is run to create the initial `/etc/openresty/block_ips_geo.conf`. +2. **Configuration Rendering**: `render_nginx.py` takes all system environment variables and applies them to the Nginx template. +3. **OpenResty Startup**: The OpenResty server is started to handle incoming traffic on ports 80 and 443. +4. **Blocklist Updater**: A background process is spawned that refreshes the IP blocklists every 2 hours and reloads OpenResty. +5. **FastAPI Startup**: The FastAPI application is started via Gunicorn on port 5000. + +## Configuration + +### Environment Variables (`.env`) + +FORC uses a single `.env` file for both the OpenResty configuration and the FastAPI application. + +#### 1. OpenResty / Gateway Settings +These variables are used by `render_nginx.py` to generate the final Nginx configuration. + +| Variable | Description | Example | +| :--- | :--- | :--- | +| `DOMAIN` | The primary domain name for the gateway. | `gateway.example.com` | +| `FORC_SERVICE_PORT` | Port for the internal service. | `443` | +| `FORC_SERVICE_USE_HTTPS` | Whether to use HTTPS for the internal service. | `True` / `False` | +| `FORC_LOCAL_IP` | Local IP to bind to if HTTPS is disabled. | `0.0.0.0` | +| `OPENRESTY_DNS_SERVER` | DNS server for OpenResty resolver. | `8.8.8.8` | +| `OPENRESTY_WORKER_PROCESSES` | Number of Nginx worker processes. | `auto` or `10` | +| `FORC_OIDC_DISCOVERY_URL` | OIDC discovery endpoint. | `https://auth.example.com/...` | +| `FORC_OIDC_CLIENT_ID` | Client ID assigned by OIDC provider. | `my-client-id` | +| `FORC_OIDC_CLIENT_SECRET` | Client secret assigned by OIDC provider. | `secret-string` | +| `FORC_SECRET_KEY` | Secret key for session signing. | `random-long-string` | + +#### 2. FastAPI Application Settings +Required for the management API and backend logic. + +| Variable | Description | Example | +| :--- | :--- | :--- | +| `FORC_API_KEY` | API key for authenticating with the FORC API. | `secure-api-key` | +| `FORC_SECRET_KEY` | Secret key for session signing. | `random-long-string` | +| `FORC_BACKEND_PATH` | Path for backend persistence. | `/var/forc/backend_path` | +| `FORC_TEMPLATE_PATH` | Path for configuration templates. | `/var/forc/template_path` | +| `DEBUG` | Enable debug mode. | `True` / `False` | +| `LOG_LEVEL` | Logging verbosity. | `INFO`, `DEBUG`, `WARNING` | + +### Mounts & Volumes + +The following mounts are configured in `docker-compose.yml`: + +| Host Path | Container Path | Mode | Purpose | +| :--- | :--- | :--- | :--- | +| `/var/forc/backend_path/` | `/var/forc/backend_path/` | `rw` | Persistence for the backend database/files. | +| `/etc/letsencrypt/` | `/etc/letsencrypt/` | `r` | SSL certificates for HTTPS. | +| `.env` | `/opt/simpleVMWebGateway/FastapiOpenRestyConfigurator/.env` | `rw` | Unified environment configuration. | + +## Usage + +1. Create a `.env` file containing all required variables from the tables above. +2. Start the gateway: +```bash +docker-compose up -d +``` + +To view logs: +```bash +docker-compose logs -f +``` diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..cd84be59 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,15 @@ +services: + forc: + image: forc + container_name: forc + env_file: + - ../FastapiOpenRestyConfigurator/.env # envs for openresty + volumes: + - /var/forc/backend_path/:/var/forc/backend_path/:rw # for persistence should be mounted + #- /var/forc/template_path/:/var/forc/template_path/:rw # optional default has the exmaples/templates + - /etc/letsencrypt/:/etc/letsencrypt/:r #needs to provided for cert + - .env.forc:/opt/simpleVMWebGateway/FastapiOpenRestyConfigurator/.env # needs to be mounted for roc + ports: + - 0.0.0.0:5000:5000 + - 0.0.0.0:80:80 + - 0.0.0.0:443:443 diff --git a/docker/generate_ip_blocklists.sh b/docker/generate_ip_blocklists.sh new file mode 100644 index 00000000..c1c54dee --- /dev/null +++ b/docker/generate_ip_blocklists.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Paths relative to script directory +INPUT="$SCRIPT_DIR/ip_blocklists.txt" +OUTPUT="/etc/openresty/block_ips_geo.conf" +TEMP="$SCRIPT_DIR/block_tmp.txt" + +rm -f "$TEMP" "$OUTPUT" + +# Check that list exists +if [[ ! -f $INPUT ]]; then + echo "ERROR: $INPUT does not exist!" + exit 1 +fi + +# Download all blocklists +while IFS= read -r url; do + + # Skip empty or commented lines + [[ -z "$url" ]] && continue + [[ "$url" =~ ^# ]] && continue + + echo "Downloading: $url" + + curl -fsSL -m 6 "$url" >> "$TEMP" || echo "Failed: $url" + +done < "$INPUT" + +# Check if anything was downloaded +if [[ ! -s "$TEMP" ]]; then + echo "ERROR: download failed — TEMP file is empty!" + exit 1 +fi + +echo "Cleaning downloaded data…" + +grep -vE '^\s*#|^\s*;' "$TEMP" \ +| sed '/^\s*$/d' \ +| sed 's/[#;].*$//' \ +| awk '{print $1}' \ +| grep -E '^[0-9a-fA-F:.]+(/[0-9]{1,3})?$' \ +| sort -u \ +| awk '{print $1 " 1;"}' \ +> "$OUTPUT" + +rm "$TEMP" + +echo "✔ Done" +echo "Final list: $OUTPUT" +wc -l "$OUTPUT" diff --git a/docker/gunicorn_conf.py b/docker/gunicorn_conf.py new file mode 100644 index 00000000..d3898e79 --- /dev/null +++ b/docker/gunicorn_conf.py @@ -0,0 +1,11 @@ +# Socket Path +bind = "unix:/var/run/forc.sock" + +# Worker Options +workers = 5 +worker_class = "uvicorn.workers.UvicornWorker" + +# Logging Options +loglevel = "info" +accesslog = "/var/log/forc.access.log" +errorlog = "/var/log/forc.error.log" \ No newline at end of file diff --git a/docker/html/403.html b/docker/html/403.html new file mode 100644 index 00000000..7fddcaa5 --- /dev/null +++ b/docker/html/403.html @@ -0,0 +1,47 @@ + + +
+ + + +