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 @@ + + + + + + + Access forbidden! + + + +

403: You are not authorized to access this research environment!

+ + + diff --git a/docker/html/404.html b/docker/html/404.html new file mode 100644 index 00000000..da01ee35 --- /dev/null +++ b/docker/html/404.html @@ -0,0 +1,40 @@ + + + + + + + No research environment found! + + + +

404: No research environment was found at this URL!

+ + + diff --git a/docker/html/50x.html b/docker/html/50x.html new file mode 100644 index 00000000..97200e19 --- /dev/null +++ b/docker/html/50x.html @@ -0,0 +1,42 @@ + + + + + + + An error occurred! + + + +

50x: An error occurred when trying to access this research environment!

+ + + diff --git a/docker/html/index.html b/docker/html/index.html new file mode 100644 index 00000000..0b3d5e3e --- /dev/null +++ b/docker/html/index.html @@ -0,0 +1,33 @@ + + + + + + + No research environment entered! + + + +

If you see this page, you have not entered a research environment URL!

+ + + diff --git a/docker/ip_blocklists.txt b/docker/ip_blocklists.txt new file mode 100644 index 00000000..a3900e56 --- /dev/null +++ b/docker/ip_blocklists.txt @@ -0,0 +1,6 @@ +https://rules.emergingthreats.net/blockrules/compromised-ips.txt +https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level2.netset +https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level3.netset +https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/ciarmy.ipset +https://lists.blocklist.de/lists/all.txt +https://www.spamhaus.org/drop/drop.txt diff --git a/docker/launch.sh b/docker/launch.sh old mode 100644 new mode 100755 index 07c87c51..02ee91c9 --- a/docker/launch.sh +++ b/docker/launch.sh @@ -1,4 +1,29 @@ #!/bin/bash +set -e -/usr/local/openresty/nginx/sbin/nginx -g 'daemon on; master_process on;' -uwsgi --ini uwsgi.ini \ No newline at end of file +echo "Generating Block IPs for startup.." +## only needed if not present +if [ ! -f /etc/openresty/block_ips_geo.conf ]; then + /opt/scripts/generate_ip_blocklists.sh +fi + +echo "Rendering OpenResty configuration..." +python3 render_nginx.py + +echo "Starting OpenResty..." +openresty + +echo "Starting Blocklist Updater Loop..." +( + while true; do + sleep 7200 # 2 hours + echo "$(date): Updating blocklists..." + /opt/scripts/generate_ip_blocklists.sh + openresty -s reload + done +) & + +echo "Starting FastAPI..." +exec gunicorn \ + -c gunicorn_conf.py \ + main:app diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000..8764ff7a --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,382 @@ +worker_processes {{ OPENRESTY_WORKER_PROCESSES }}; + +# /usr/local/openresty/nginx/logs/ +error_log logs/error.log notice; + +events { + worker_connections 1024; + multi_accept off; +} + +http { + include mime.types; + + lua_package_path "{{ FORC_BACKEND_PATH }}/scripts/?.lua;;"; + + default_type application/octet-stream; + + resolver {{ OPENRESTY_DNS_SERVER }} valid=300s ipv6=off; + + sendfile on; + keepalive_timeout 65; + + client_max_body_size 100M; + + # LUA shared dicts + lua_shared_dict discovery 1m; + lua_shared_dict jwks 1m; + lua_shared_dict sessions 10m; + + lua_code_cache on; + + + # Websocket support + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + + # SSL defaults + ssl_protocols TLSv1.2 TLSv1.3; + + + + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + ssl_session_tickets off; + + + # OIDC config +init_by_lua_block { + -- Monkey-patch lua-resty-session defaults + local session = require("resty.session") + local old_new = session.new + + session.new = function(opts) + opts = opts or {} + + opts.cookie_name = "sess_auth" + opts.cookie_secure = true + opts.secret = "{{ FORC_SECRET_KEY }}" + + return old_new(opts) + end + + opts2 = { + redirect_uri = "https://{{ DOMAIN }}/redirect_uri", + discovery = "{{ FORC_OIDC_DISCOVERY_URL }}", + client_id = "{{ FORC_OIDC_CLIENT_ID }}", + client_secret = "{{ FORC_OIDC_CLIENT_SECRET }}", + + logout_path = "/logout", + + ssl_verify = "no", + + iat_slack = 600, + + scope = "openid email profile offline_access", + + renew_access_token_on_expiry = true, + access_token_expires_leeway = 60, + session_contents = { + id_token = true, + access_token = true + } + } +} + + + # Blocked IPs + geo $blocked_ip { + default 0; + include /etc/openresty/block_ips_geo.conf; + } + + + # + # Internal HTTPS service + # +{% if FORC_SERVICE_USE_HTTPS %} + + server { + listen {{ FORC_SERVICE_PORT }} ssl; + http2 on; + + server_name {{ DOMAIN }}; + + + ssl_certificate + /etc/letsencrypt/live/{{ DOMAIN }}/fullchain.pem; + + ssl_certificate_key + /etc/letsencrypt/live/{{ DOMAIN }}/privkey.pem; + + ssl_trusted_certificate + /etc/letsencrypt/live/{{ DOMAIN }}/chain.pem; + + + add_header Strict-Transport-Security + "max-age=31536000; includeSubDomains; preload"; + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy "strict-origin"; + + + ssl_stapling on; + ssl_stapling_verify on; + + + if ($blocked_ip) { + return 444; + } + + + location / { + + proxy_pass http://unix:/var/run/forc.sock; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + proxy_set_header + X-Forwarded-For + $proxy_add_x_forwarded_for; + + proxy_set_header + X-Forwarded-Proto + https; + + + proxy_http_version 1.1; + + proxy_set_header + Upgrade + $http_upgrade; + + proxy_set_header + Connection + $connection_upgrade; + } + } + +{% else %} + + server { + + listen {{ FORC_LOCAL_IP }}:{{ FORC_SERVICE_PORT }}; + + + if ($blocked_ip) { + return 444; + } + + + location / { + + proxy_pass http://unix:/var/run/forc.sock; + + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + proxy_set_header + X-Forwarded-For + $proxy_add_x_forwarded_for; + + proxy_set_header + X-Forwarded-Proto + http; + } + } + +{% endif %} + + + + # + # HTTP redirect + # + server { + + listen 0.0.0.0:80 default_server; + + server_name {{ DOMAIN }}; + + + if ($blocked_ip) { + return 444; + } + + + return 301 https://$host$request_uri; + } + + + + # + # nginx status + # + server { + + listen 127.0.0.1:8080; + + + location /nginx_status { + + stub_status; + + allow 127.0.0.1; + + deny all; + } + } + + + + # + # Public HTTPS + OIDC + # + server { + + listen 0.0.0.0:443 ssl; + + http2 on; + + + server_name {{ DOMAIN }}; + + + ssl_certificate + /etc/letsencrypt/live/{{ DOMAIN }}/fullchain.pem; + + ssl_certificate_key + /etc/letsencrypt/live/{{ DOMAIN }}/privkey.pem; + + ssl_trusted_certificate + /etc/letsencrypt/live/{{ DOMAIN }}/chain.pem; + + + + add_header Strict-Transport-Security + "max-age=31536000; includeSubDomains; preload"; + + add_header X-Frame-Options "SAMEORIGIN"; + + add_header X-Content-Type-Options nosniff; + + add_header Referrer-Policy "strict-origin"; + + + + ssl_stapling on; + ssl_stapling_verify on; + + + + if ($blocked_ip) { + return 444; + } + + + + location / { + + + access_by_lua_block { + + local res, err = + require("resty.openidc") + .authenticate( + opts2, + nil, + nil, + opts_session + ) + + + if err then + + ngx.status = 500; + + ngx.say(err); + + ngx.exit( + ngx.HTTP_INTERNAL_SERVER_ERROR + ); + + end + + + + ngx.req.set_header( + "X-Auth-Audience", + res.id_token.aud + ) + + ngx.req.set_header( + "X-Auth-Email", + res.id_token.email + ) + + ngx.req.set_header( + "X-Auth-ExpiresIn", + res.id_token.exp + ) + + ngx.req.set_header( + "X-Auth-Name", + res.id_token.name + ) + + ngx.req.set_header( + "X-Auth-Subject", + res.id_token.sub + ) + + ngx.req.set_header( + "X-Auth-Userid", + res.id_token.preferred_username + ) + + ngx.req.set_header( + "X-Auth-Username", + res.id_token.preferred_username + ) + + ngx.req.set_header( + "X-Auth-Locale", + res.id_token.locale + ) + } + } + + + + include {{ FORC_BACKEND_PATH }}/*.conf; + + + + error_page 500 502 503 504 /50x.html; + + location = /50x.html { + root html; + } + + + error_page 404 /404.html; + + location = /404.html { + root html; + } + + + error_page 403 /403.html; + + location = /403.html { + root html; + } + } +} \ No newline at end of file diff --git a/docker/plans/graceful-tinkering-jellyfish.md b/docker/plans/graceful-tinkering-jellyfish.md new file mode 100644 index 00000000..fe7d1fb8 --- /dev/null +++ b/docker/plans/graceful-tinkering-jellyfish.md @@ -0,0 +1,41 @@ +# Migration of Session Config in Example Templates + +## Context +The `nginx.conf` template was migrated to use `lua-resty-session` 4.1.5 and `lua-resty-openidc` 1.9.0. In these versions, session configuration is moved from Nginx variables (`set $session_...`) to a structured Lua table passed within the OIDC options. + +The example templates in `examples/templates/` currently contain redundant and obsolete `set $session_...` directives. Since these templates use the global `opts2` configuration defined in the main `nginx.conf`, these local variable definitions are no longer needed and should be removed to ensure consistency and prevent potential conflicts. + +## Implementation Plan + +### 1. Identify Affected Files +The following files in `examples/templates/` contain the obsolete session configuration: +- `emgb%v01.conf` +- `guacamole%v03.conf` +- `jupyterlab%v03.conf` +- `rstudio%v04.conf` +- `theiaide%v03.conf` +- `vscode%v03.conf` + +### 2. Remove Obsolete Configuration +In each of the identified files, remove the block of lines starting with `set $session_`. + +The block to be removed typically looks like: +```nginx +set $session_cipher none; +set $session_storage shm; +set $session_cookie_persistent on; +set $session_cookie_renew 3500; +set $session_cookie_lifetime 86400; +set $session_name sess_auth; +set $session_shm_store sessions; +set $session_shm_uselocking off; +set $session_shm_lock_exptime 3; +set $session_shm_lock_timeout 2; +set $session_shm_lock_step 0.001; +set $session_shm_lock_ratio 1; +set $session_shm_lock_max_step 0.5; +``` + +### 3. Verification +- Run `grep -r "set \$session_" examples/templates/` to ensure no remaining session variable definitions exist in the templates directory. +- Verify that the `access_by_lua_block` still calls `require("resty.openidc").authenticate(opts2)`, which now inherits the correct session configuration from the main `nginx.conf`. diff --git a/docker/plans/ticklish-spinning-moth.md b/docker/plans/ticklish-spinning-moth.md new file mode 100644 index 00000000..35146ef0 --- /dev/null +++ b/docker/plans/ticklish-spinning-moth.md @@ -0,0 +1,32 @@ +# Implementation Plan: IP Blocklist Automation + +## Context +The goal is to automate the update of IP blocklists in the OpenResty gateway. Currently, `generate_ip_blocklists.sh` exists but is not integrated into the container's lifecycle. We need to ensure these lists are updated every two hours and that OpenResty reloads its configuration to apply the new blocks. + +## Proposed Changes + +### 1. Dockerfile Updates +- Update the `apt-get install` list to include `cron` and `curl`. +- Copy `generate_ip_blocklists.sh` and `ip_blocklists.txt` to `/opt/scripts/`. +- Ensure `generate_ip_blocklists.sh` has executable permissions. +- Create a cron job file in `/etc/cron.d/ip-blocklist` with the following schedule: + `0 */2 * * * root /opt/scripts/generate_ip_blocklists.sh && /usr/sbin/openresty -s reload` + +### 2. Launch Script Updates +- Modify `launch.sh` to start the `cron` daemon before starting OpenResty and FastAPI. + +### 3. File Renaming +- `ip_blocklists.sh` has already been renamed to `ip_blocklists.txt` to reflect its content as a list of URLs. + +## Critical Files +- `/home/ubuntu/workspace/denbi/simpleVMWebGateway/docker/Dockerfile` +- `/home/ubuntu/workspace/denbi/simpleVMWebGateway/docker/launch.sh` +- `/home/ubuntu/workspace/denbi/simpleVMWebGateway/docker/generate_ip_blocklists.sh` +- `/home/ubuntu/workspace/denbi/simpleVMWebGateway/docker/ip_blocklists.txt` + +## Verification Plan +1. Build the docker image. +2. Run the container. +3. Manually execute `/opt/scripts/generate_ip_blocklists.sh` and verify that `/etc/openresty/block_ips_geo.conf` is populated. +4. Verify that `cron` is running inside the container (`ps aux | grep cron`). +5. (Optional) Temporarily change the cron schedule to every minute to verify the automatic update and reload. diff --git a/docker/render_nginx.py b/docker/render_nginx.py new file mode 100644 index 00000000..2527dc88 --- /dev/null +++ b/docker/render_nginx.py @@ -0,0 +1,23 @@ +import os +from pathlib import Path +from jinja2 import Environment, FileSystemLoader + +TEMPLATE_DIR = "/etc/openresty/" +OUTPUT_FILE = "/etc/openresty/nginx.conf" + + +def main(): + env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + autoescape=False, + ) + + template = env.get_template("nginx.conf.j2") + + rendered = template.render(**os.environ) + + Path(OUTPUT_FILE).write_text(rendered) + + +if __name__ == "__main__": + main() diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini deleted file mode 100644 index 5eaaae2e..00000000 --- a/docker/uwsgi.ini +++ /dev/null @@ -1,11 +0,0 @@ -[uwsgi] -chdir = /opt/simpleVMWebGateway/FastapiOpenRestyConfigurator -module = manage:app -master = true -processes = 5 -logto = /var/log/forc.log - -socket = /var/run/forc.sock -vacuum = true - -chmod-socket = 776 \ No newline at end of file diff --git a/examples/openresty_configuration.md b/examples/openresty_configuration.md old mode 100644 new mode 100755 diff --git a/examples/scripts/user_service.lua b/examples/scripts/user_service.lua old mode 100644 new mode 100755 diff --git a/examples/templates/emgb%v01.conf b/examples/templates/emgb%v01.conf old mode 100644 new mode 100755 index 517dd3e8..8c6b53aa --- a/examples/templates/emgb%v01.conf +++ b/examples/templates/emgb%v01.conf @@ -1,20 +1,6 @@ # PROTECT FIRST THEIA CONTAINER location /{{ key_url }}/ { - set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier - set $session_storage shm; # use shared memory - set $session_cookie_persistent on; # persist cookie between browser sessions - set $session_cookie_renew 3500; # new cookie every hour - set $session_cookie_lifetime 86400; # lifetime for persistent cookies - set $session_name sess_auth; # name of the cookie to store the session identifier in - set $session_shm_store sessions; # name of the dict to store sessions in - # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options - set $session_shm_uselocking off; - set $session_shm_lock_exptime 3; - set $session_shm_lock_timeout 2; - set $session_shm_lock_step 0.001; - set $session_shm_lock_ratio 1; - set $session_shm_lock_max_step 0.5; set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) @@ -29,11 +15,14 @@ ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end + {% if auth_enabled %} -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - - ngx.exit(ngx.HTTP_FORBIDDEN) - end + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% else %} + -- AUTH DISABLED, ALLOW ANY USER WITH A VALID TOKEN + {% endif %} ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) diff --git a/examples/templates/guacamole%v03.conf b/examples/templates/guacamole%v03.conf old mode 100644 new mode 100755 index 34d8286d..65f358ad --- a/examples/templates/guacamole%v03.conf +++ b/examples/templates/guacamole%v03.conf @@ -1,45 +1,32 @@ location /{{ key_url }}/ { - set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier - set $session_storage shm; # use shared memory - set $session_cookie_persistent on; # persist cookie between browser sessions - set $session_cookie_renew 3500; # new cookie every hour - set $session_cookie_lifetime 86400; # lifetime for persistent cookies - set $session_name sess_auth; # name of the cookie to store the session identifier in - - set $session_shm_store sessions; # name of the dict to store sessions in - # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options - set $session_shm_uselocking off; - set $session_shm_lock_exptime 3; - set $session_shm_lock_timeout 2; - set $session_shm_lock_step 0.001; - set $session_shm_lock_ratio 1; - set $session_shm_lock_max_step 0.5; + set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) access_by_lua_block { - local user_service = require("user_service") - -- 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 + local user_service = require("user_service") + -- 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 - - -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end - ngx.req.set_header("X-Auth-Audience", res.id_token.aud) - ngx.req.set_header("X-Auth-Email", res.id_token.email) - ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) - ngx.req.set_header("X-Auth-Name", res.id_token.name) - ngx.req.set_header("X-Auth-Subject", res.id_token.sub) - ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Locale", res.id_token.locale) + end + + -- Protect this location and allow only one specific ELIXIR User + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) + ngx.req.set_header("X-Auth-Email", res.id_token.email) + ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) + ngx.req.set_header("X-Auth-Name", res.id_token.name) + ngx.req.set_header("X-Auth-Subject", res.id_token.sub) + ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Locale", res.id_token.locale) } diff --git a/examples/templates/guacamole_vnc%v03.conf b/examples/templates/guacamole_vnc%v03.conf new file mode 100644 index 00000000..c794c595 --- /dev/null +++ b/examples/templates/guacamole_vnc%v03.conf @@ -0,0 +1,56 @@ +location /{{ key_url }}/ { + set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier + set $session_storage shm; # use shared memory + set $session_cookie_persistent on; # persist cookie between browser sessions + set $session_cookie_renew 3500; # new cookie every hour + set $session_cookie_lifetime 86400; # lifetime for persistent cookies + set $session_name sess_auth; # name of the cookie to store the session identifier in + + set $session_shm_store sessions; # name of the dict to store sessions in + # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options + set $session_shm_uselocking off; + set $session_shm_lock_exptime 3; + set $session_shm_lock_timeout 2; + set $session_shm_lock_step 0.001; + set $session_shm_lock_ratio 1; + set $session_shm_lock_max_step 0.5; + + set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; + # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) + access_by_lua_block { + local user_service = require("user_service") + -- 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 + + -- Protect this location and allow only one specific ELIXIR User + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) + ngx.req.set_header("X-Auth-Email", res.id_token.email) + ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) + ngx.req.set_header("X-Auth-Name", res.id_token.name) + ngx.req.set_header("X-Auth-Subject", res.id_token.sub) + ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Locale", res.id_token.locale) + } + + + + + proxy_pass {{ location_url }}/guacamole/; + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + access_log off; + } diff --git a/examples/templates/jupyterlab%v03.conf b/examples/templates/jupyterlab%v03.conf old mode 100644 new mode 100755 index bd6a4288..993555fc --- a/examples/templates/jupyterlab%v03.conf +++ b/examples/templates/jupyterlab%v03.conf @@ -1,64 +1,50 @@ location /{{ key_url }} { - set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier - set $session_storage shm; # use shared memory - set $session_cookie_persistent on; # persist cookie between browser sessions - set $session_cookie_renew 3500; # new cookie every hour - set $session_cookie_lifetime 86400; # lifetime for persistent cookies - set $session_name sess_auth; # name of the cookie to store the session identifier in - set $session_shm_store sessions; # name of the dict to store sessions in - # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options - set $session_shm_uselocking off; - set $session_shm_lock_exptime 3; - set $session_shm_lock_timeout 2; - set $session_shm_lock_step 0.001; - set $session_shm_lock_ratio 1; - set $session_shm_lock_max_step 0.5; set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) access_by_lua_block { - local user_service = require("user_service") - -- 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 - - -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end - - ngx.req.set_header("X-Auth-Audience", res.id_token.aud) - ngx.req.set_header("X-Auth-Email", res.id_token.email) - ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) - ngx.req.set_header("X-Auth-Name", res.id_token.name) - ngx.req.set_header("X-Auth-Subject", res.id_token.sub) - ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Locale", res.id_token.locale) + local user_service = require("user_service") + -- 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 + + -- Protect this location and allow only one specific ELIXIR User + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) + ngx.req.set_header("X-Auth-Email", res.id_token.email) + ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) + ngx.req.set_header("X-Auth-Name", res.id_token.name) + ngx.req.set_header("X-Auth-Subject", res.id_token.sub) + ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Locale", res.id_token.locale) } - # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. + # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. - proxy_pass {{ location_url }}; - proxy_http_version 1.1; + proxy_pass {{ location_url }}; + proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_read_timeout 20d; - proxy_set_header X-Scheme $scheme; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 20d; + proxy_set_header X-Scheme $scheme; - client_max_body_size 0; - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; - add_header Referrer-Policy "same-origin" always; + client_max_body_size 0; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Referrer-Policy "same-origin" always; - } +} diff --git a/examples/templates/rstudio%v04.conf b/examples/templates/rstudio%v04.conf old mode 100644 new mode 100755 index d6ff16c8..1a170243 --- a/examples/templates/rstudio%v04.conf +++ b/examples/templates/rstudio%v04.conf @@ -1,46 +1,32 @@ location /{{ key_url }}/ { - set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier - set $session_storage shm; # use shared memory - set $session_cookie_persistent on; # persist cookie between browser sessions - set $session_cookie_renew 3500; # new cookie every hour - set $session_cookie_lifetime 86400; # lifetime for persistent cookies - set $session_name sess_auth; # name of the cookie to store the session identifier in - set $session_shm_store sessions; # name of the dict to store sessions in - # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options - set $session_shm_uselocking off; - set $session_shm_lock_exptime 3; - set $session_shm_lock_timeout 2; - set $session_shm_lock_step 0.001; - set $session_shm_lock_ratio 1; - set $session_shm_lock_max_step 0.5; set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) access_by_lua_block { - local user_service = require "user_service" - -- 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 + local user_service = require "user_service" + -- 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 - -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + -- Protect this location and allow only one specific ELIXIR User + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end - ngx.req.set_header("X-Auth-Audience", res.id_token.aud) - ngx.req.set_header("X-Auth-Email", res.id_token.email) - ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) - ngx.req.set_header("X-Auth-Name", res.id_token.name) - ngx.req.set_header("X-Auth-Subject", res.id_token.sub) - ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Locale", res.id_token.locale) + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) + ngx.req.set_header("X-Auth-Email", res.id_token.email) + ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) + ngx.req.set_header("X-Auth-Name", res.id_token.name) + ngx.req.set_header("X-Auth-Subject", res.id_token.sub) + ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Locale", res.id_token.locale) } diff --git a/examples/templates/theiaide%v03.conf b/examples/templates/theiaide%v03.conf old mode 100644 new mode 100755 index bfe3bb10..f5e2af17 --- a/examples/templates/theiaide%v03.conf +++ b/examples/templates/theiaide%v03.conf @@ -1,62 +1,51 @@ # PROTECT FIRST THEIA CONTAINER location /{{ key_url }}/ { - set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier - set $session_storage shm; # use shared memory - set $session_cookie_persistent on; # persist cookie between browser sessions - set $session_cookie_renew 3500; # new cookie every hour - set $session_cookie_lifetime 86400; # lifetime for persistent cookies - set $session_name sess_auth; # name of the cookie to store the session identifier in - - set $session_shm_store sessions; # name of the dict to store sessions in - # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options - set $session_shm_uselocking off; - set $session_shm_lock_exptime 3; - set $session_shm_lock_timeout 2; - set $session_shm_lock_step 0.001; - set $session_shm_lock_ratio 1; - set $session_shm_lock_max_step 0.5; + set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) access_by_lua_block { - local user_service = require("user_service") - -- 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 - - -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end - - ngx.req.set_header("X-Auth-Audience", res.id_token.aud) - ngx.req.set_header("X-Auth-Email", res.id_token.email) - ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) - ngx.req.set_header("X-Auth-Name", res.id_token.name) - ngx.req.set_header("X-Auth-Subject", res.id_token.sub) - ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Locale", res.id_token.locale) + local user_service = require("user_service") + -- 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 + + -- Protect this location and allow only one specific ELIXIR User + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) + ngx.req.set_header("X-Auth-Email", res.id_token.email) + ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) + ngx.req.set_header("X-Auth-Name", res.id_token.name) + ngx.req.set_header("X-Auth-Subject", res.id_token.sub) + ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Locale", res.id_token.locale) } # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. rewrite /{{ key_url }}/(?.*) /$path break; proxy_pass {{ location_url }}; + proxy_http_version 1.1; + proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 20d; + proxy_set_header X-Scheme $scheme; client_max_body_size 0; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Referrer-Policy "same-origin" always; - access_log logs/code.access.log; - error_log logs/code.error.log; - } \ No newline at end of file + +} diff --git a/examples/templates/vscode%v03.conf b/examples/templates/vscode%v03.conf old mode 100644 new mode 100755 index 5ca4b4df..f2f82b2a --- a/examples/templates/vscode%v03.conf +++ b/examples/templates/vscode%v03.conf @@ -1,47 +1,33 @@ # PROTECT FIRST THEIA CONTAINER location /{{ key_url }}/ { - set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier - set $session_storage shm; # use shared memory - set $session_cookie_persistent on; # persist cookie between browser sessions - set $session_cookie_renew 3500; # new cookie every hour - set $session_cookie_lifetime 86400; # lifetime for persistent cookies - set $session_name sess_auth; # name of the cookie to store the session identifier in - set $session_shm_store sessions; # name of the dict to store sessions in - # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options - set $session_shm_uselocking off; - set $session_shm_lock_exptime 3; - set $session_shm_lock_timeout 2; - set $session_shm_lock_step 0.001; - set $session_shm_lock_ratio 1; - set $session_shm_lock_max_step 0.5; set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) access_by_lua_block { - local user_service = require("user_service") - -- 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 + local user_service = require("user_service") + -- 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 + end - -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + -- Protect this location and allow only one specific ELIXIR User + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end - ngx.req.set_header("X-Auth-Audience", res.id_token.aud) - ngx.req.set_header("X-Auth-Email", res.id_token.email) - ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) - ngx.req.set_header("X-Auth-Name", res.id_token.name) - ngx.req.set_header("X-Auth-Subject", res.id_token.sub) - ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Locale", res.id_token.locale) + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) + ngx.req.set_header("X-Auth-Email", res.id_token.email) + ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) + ngx.req.set_header("X-Auth-Name", res.id_token.name) + ngx.req.set_header("X-Auth-Subject", res.id_token.sub) + ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Locale", res.id_token.locale) } # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. @@ -59,4 +45,4 @@ add_header Referrer-Policy "same-origin" always; access_log logs/code.access.log; error_log logs/code.error.log; - } +} diff --git a/examples/templates/webservice%v01.conf b/examples/templates/webservice%v01.conf new file mode 100755 index 00000000..acdc2007 --- /dev/null +++ b/examples/templates/webservice%v01.conf @@ -0,0 +1,65 @@ +# PROTECT FIRST THEIA CONTAINER +location /{{ key_url }}/ { + set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier + set $session_storage shm; # use shared memory + set $session_cookie_persistent on; # persist cookie between browser sessions + set $session_cookie_renew 3500; # new cookie every hour + set $session_cookie_lifetime 86400; # lifetime for persistent cookies + set $session_name sess_auth; # name of the cookie to store the session identifier in + + set $session_shm_store sessions; # name of the dict to store sessions in + # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options + set $session_shm_uselocking off; + set $session_shm_lock_exptime 3; + set $session_shm_lock_timeout 2; + set $session_shm_lock_step 0.001; + set $session_shm_lock_ratio 1; + set $session_shm_lock_max_step 0.5; + + set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; + # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) + access_by_lua_block { + local user_service = require("user_service") + -- 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 + + {% if auth_enabled %} + -- Protect this location and allow only one specific ELIXIR User + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% else %} + -- AUTH DISABLED, ALLOW ANY USER WITH A VALID TOKEN + {% endif %} + + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) + ngx.req.set_header("X-Auth-Email", res.id_token.email) + ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) + ngx.req.set_header("X-Auth-Name", res.id_token.name) + ngx.req.set_header("X-Auth-Subject", res.id_token.sub) + ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) + ngx.req.set_header("X-Auth-Locale", res.id_token.locale) + } + + # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. + proxy_pass {{ location_url }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + client_max_body_size 0; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Referrer-Policy "same-origin" always; + access_log logs/code.access.log; + error_log logs/code.error.log; +} diff --git a/examples/templating_guide.md b/examples/templating_guide.md old mode 100644 new mode 100755 index 2911786c..647c8a36 --- a/examples/templating_guide.md +++ b/examples/templating_guide.md @@ -29,19 +29,21 @@ This is an example Template for the research environment [RStudio](https://rstud location /{{ key_url }}/ { # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) 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 - - -- Protect this location and allow only one specific ELIXIR User - if res.id_token.sub ~= "{{ owner }}" then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + -- 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 + + -- Protect this location and allow only one specific ELIXIR User + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } diff --git a/gfx/forc_overview.drawio b/gfx/forc_overview.drawio old mode 100644 new mode 100755 diff --git a/gfx/forc_overview.png b/gfx/forc_overview.png old mode 100644 new mode 100755 diff --git a/renovate.json b/renovate.json old mode 100644 new mode 100755