diff --git a/group_vars/all/00_general.yml b/group_vars/all/00_general.yml index acf344f0..64a6536b 100644 --- a/group_vars/all/00_general.yml +++ b/group_vars/all/00_general.yml @@ -29,6 +29,9 @@ WEB_PORT: "{{ 443 if WEB_PROTOCOL == 'https' else 80 }}" # Defaul # Websocket WEBSOCKET_PROTOCOL: "{{ 'wss' if WEB_PROTOCOL == 'https' else 'ws' }}" +# WWW-Redirect to None WWW-Domains enabled +WWW_REDIRECT_ENABLED: "{{ ('web-opt-rdr-www' in group_names) | bool }}" + # Domain PRIMARY_DOMAIN: "localhost" # Primary Domain of the server diff --git a/group_vars/all/16_storage.yml b/group_vars/all/16_storage.yml index 993d8b9e..a3247c92 100644 --- a/group_vars/all/16_storage.yml +++ b/group_vars/all/16_storage.yml @@ -3,4 +3,3 @@ BACKUPS_FOLDER_PATH: "/Backups/" # Path to the backups folder # Storage Space-Related Configurations SIZE_PERCENT_MAXIMUM_BACKUP: 75 # Maximum storage space in percent for backups SIZE_PERCENT_CLEANUP_DISC_SPACE: 85 # Threshold for triggering cleanup actions -SIZE_PERCENT_DISC_SPACE_WARNING: 90 # Warning threshold in percent for free disk space \ No newline at end of file diff --git a/roles/sys-ctl-hlth-webserver/__init__.py b/roles/sys-ctl-hlth-webserver/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/sys-ctl-hlth-webserver/files/script.py b/roles/sys-ctl-hlth-webserver/files/script.py new file mode 100644 index 00000000..d49f3b5a --- /dev/null +++ b/roles/sys-ctl-hlth-webserver/files/script.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Ultra-thin checker: consume a JSON mapping of {domain: [expected_status_codes]} +and verify HTTP HEAD responses. All mapping logic is done in the filter +`web_health_expectations`. +""" + +import argparse +import json +import sys +from typing import Dict, List + +import requests + + +def parse_args(argv=None): + p = argparse.ArgumentParser(description="Web health checker (expects precomputed domain→codes mapping).") + p.add_argument("--web-protocol", default="https", choices=["http", "https"], help="Protocol to use") + p.add_argument("--expectations", required=True, help="JSON STRING: {\"domain\": [codes], ...}") + return p.parse_args(argv) + + +def _parse_json_mapping(name: str, value: str) -> Dict[str, List[int]]: + try: + obj = json.loads(value) + except json.JSONDecodeError as e: + raise SystemExit(f"--{name} must be a valid JSON string: {e}") + if not isinstance(obj, dict): + raise SystemExit(f"--{name} must be a JSON object (mapping)") + # sanitize list-of-ints shape + clean = {} + for k, v in obj.items(): + if isinstance(v, list): + try: + clean[k] = [int(x) for x in v] + except Exception: + clean[k] = [] + else: + clean[k] = [] + return clean + + +def main(argv=None) -> int: + args = parse_args(argv) + expectations = _parse_json_mapping("expectations", args.expectations) + + errors = 0 + for domain in sorted(expectations.keys()): + expected = expectations[domain] or [] + url = f"{args.web_protocol}://{domain}" + try: + r = requests.head(url, allow_redirects=False, timeout=10) + if expected and r.status_code in expected: + print(f"{domain}: OK") + elif not expected: + # If somehow empty list slipped through, treat as failure to be explicit + print(f"{domain}: ERROR: No expectations provided. Got {r.status_code}.") + errors += 1 + else: + print(f"{domain}: ERROR: Expected {expected}. Got {r.status_code}.") + errors += 1 + except requests.RequestException as e: + print(f"{domain}: error due to {e}") + errors += 1 + + if errors: + print(f"Warning: {errors} domains responded with an unexpected https status code.") + return errors + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/roles/sys-ctl-hlth-webserver/filter_plugins/__init__.py b/roles/sys-ctl-hlth-webserver/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/sys-ctl-hlth-webserver/filter_plugins/web_health_expectations.py b/roles/sys-ctl-hlth-webserver/filter_plugins/web_health_expectations.py new file mode 100644 index 00000000..cc36b4e6 --- /dev/null +++ b/roles/sys-ctl-hlth-webserver/filter_plugins/web_health_expectations.py @@ -0,0 +1,186 @@ +# roles/sys-ctl-hlth-webserver/filter_plugins/web_health_expectations.py +import os +import sys +from collections.abc import Mapping + +# Make repo-level module_utils importable (go up three levels from this file) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))) +from module_utils.config_utils import get_app_conf # reuse existing helper + + +DEFAULT_OK = [200, 302, 301] + + +def _to_list(x, *, allow_mapping: bool = True): + """Normalize into a flat list of **strings only**.""" + if x is None: + return [] + + if isinstance(x, bytes): + try: + return [x.decode("utf-8")] + except Exception: + return [] + if isinstance(x, str): + return [x] + + if isinstance(x, (list, tuple, set)): + out = [] + for v in x: + if isinstance(v, (list, tuple, set)): + out.extend(_to_list(v, allow_mapping=False)) + elif isinstance(v, bytes): + try: + out.append(v.decode("utf-8")) + except Exception: + pass + elif isinstance(v, str): + out.append(v) + elif isinstance(v, Mapping): + continue + return out + + if isinstance(x, Mapping) and allow_mapping: + out = [] + for v in x.values(): + out.extend(_to_list(v, allow_mapping=True)) + return out + + return [] + + +def _valid_http_code(x): + """Return int(x) if 100 <= code <= 599 else None.""" + try: + v = int(x) + except (TypeError, ValueError): + return None + return v if 100 <= v <= 599 else None + + +def _extract_redirect_sources(redirect_maps): + """Extract a set of source domains from redirect maps.""" + sources = set() + if not redirect_maps: + return sources + + def _add_one(obj): + if isinstance(obj, str) and obj: + sources.add(obj) + elif isinstance(obj, Mapping): + s = obj.get("source") + if isinstance(s, str) and s: + sources.add(s) + + if isinstance(redirect_maps, (list, tuple, set)): + for item in redirect_maps: + _add_one(item) + else: + _add_one(redirect_maps) + + return sources + + +def _normalize_selection(group_names): + """Return a non-empty set of group names, or raise ValueError.""" + if isinstance(group_names, (list, set, tuple)): + sel = {str(x) for x in group_names if str(x)} + elif isinstance(group_names, str): + sel = {g.strip() for g in group_names.split(",") if g.strip()} + else: + sel = set() + + if not sel: + raise ValueError("web_health_expectations: 'group_names' must be provided and non-empty") + return sel + + +def web_health_expectations(applications, www_enabled: bool = False, group_names=None, redirect_maps=None): + """Produce a **flat mapping**: domain -> [expected_status_codes]. + + Selection (REQUIRED): + - `group_names` must be provided and non-empty. + - Only include applications whose key is in `group_names`. + + Rules: + - Canonical domains (dict-key overrides, else default, else DEFAULT_OK). + - Flat canonical (default, else DEFAULT_OK). + - Aliases always [301]. + - No legacy fallbacks (ignore 'home'/'landingpage'). + - `redirect_maps`: force -> [301] and override app-derived entries. + - If `www_enabled`: add and/or force www.* -> [301] for all domains. + """ + if not isinstance(applications, Mapping): + return {} + + selection = _normalize_selection(group_names) + + expectations = {} + + for app_id in applications.keys(): + if app_id not in selection: + continue + + canonical_raw = get_app_conf( + applications, app_id, 'server.domains.canonical', + strict=False, default=[] + ) + aliases_raw = get_app_conf( + applications, app_id, 'server.domains.aliases', + strict=False, default=[] + ) + aliases = _to_list(aliases_raw, allow_mapping=True) + + sc_raw = get_app_conf( + applications, app_id, 'server.status_codes', + strict=False, default={} + ) + sc_map = {} + if isinstance(sc_raw, Mapping): + for k, v in sc_raw.items(): + code = _valid_http_code(v) + if code is not None: + sc_map[str(k)] = code + + if isinstance(canonical_raw, Mapping) and canonical_raw: + for key, domains in canonical_raw.items(): + domains_list = _to_list(domains, allow_mapping=False) + code = _valid_http_code(sc_map.get(key)) + if code is None: + code = _valid_http_code(sc_map.get("default")) + expected = [code] if code is not None else list(DEFAULT_OK) + for d in domains_list: + if d: + expectations[d] = expected + else: + for d in _to_list(canonical_raw, allow_mapping=True): + if not d: + continue + code = _valid_http_code(sc_map.get("default")) + expectations[d] = [code] if code is not None else list(DEFAULT_OK) + + for d in aliases: + if d: + expectations[d] = [301] + + for src in _extract_redirect_sources(redirect_maps): + expectations[src] = [301] + + if www_enabled: + add = {} + for d in expectations.keys(): + if not d.startswith("www."): + add[f"www.{d}"] = [301] + expectations.update(add) + for d in list(expectations.keys()): + if d.startswith("www."): + expectations[d] = [301] + + return expectations + + +class FilterModule(object): + def filters(self): + return { + 'web_health_expectations': web_health_expectations, + } diff --git a/roles/sys-ctl-hlth-webserver/tasks/01_core.yml b/roles/sys-ctl-hlth-webserver/tasks/01_core.yml index f2367f26..b02543d5 100644 --- a/roles/sys-ctl-hlth-webserver/tasks/01_core.yml +++ b/roles/sys-ctl-hlth-webserver/tasks/01_core.yml @@ -20,3 +20,7 @@ system_service_timer_enabled: true system_service_tpl_on_failure: "{{ SYS_SERVICE_ON_FAILURE_COMPOSE }}" system_service_tpl_timeout_start_sec: "{{ CURRENT_PLAY_DOMAINS_ALL | timeout_start_sec_for_domains }}" + system_service_tpl_exec_start: > + {{ system_service_script_exec }} + --web-protocol {{ WEB_PROTOCOL }} + --expectations '{{ applications | web_health_expectations(www_enabled=WWW_REDIRECT_ENABLED, group_names=group_names) | to_json }}' diff --git a/roles/sys-ctl-hlth-webserver/templates/script.py.j2 b/roles/sys-ctl-hlth-webserver/templates/script.py.j2 deleted file mode 100644 index 9a3a3825..00000000 --- a/roles/sys-ctl-hlth-webserver/templates/script.py.j2 +++ /dev/null @@ -1,69 +0,0 @@ -import os -import requests -import sys -import re - -def get_expected_statuses(domain: str, parts: list[str], redirected_domains: set[str]) -> list[int]: - """ - Determine the expected HTTP status codes based on the domain name. - - Args: - domain: The full domain string (e.g. 'example.com'). - parts: The domain split into its subcomponents (e.g. ['www', 'example', 'com']). - redirected_domains: A set of domains that should trigger a redirect. - - Returns: - A list of expected HTTP status codes. - """ - if domain == '{{ domains | get_domain('web-app-listmonk') }}': - return [404] - if (parts and parts[0] == 'www') or (domain in redirected_domains): - return [301] - if domain == '{{ domains | get_domain('web-app-yourls') }}': - return [{{ applications | get_app_conf('web-app-yourls', 'server.status_codes.landingpage') }}] - return [200, 302, 301] - -# file in which fqdn server configs are deposit -config_path = '{{ NGINX.DIRECTORIES.HTTP.SERVERS }}' - -# Initialize the error counter -error_counter = 0 - -# Regex pattern to match domain.tld or *.domain.tld -pattern = re.compile(r"^(?:[\w-]+\.)*[\w-]+\.[\w-]+\.conf$") - -# Iterate over each file in the configuration directory -for filename in os.listdir(config_path): - if filename.endswith('.conf') and pattern.match(filename): - # Extract the domain and subdomain from the filename - domain = filename.replace('.conf', '') - parts = domain.split('.') - - # Prepare the URL and expected status codes - url = f"{{ WEB_PROTOCOL }}://{domain}" - - redirected_domains = [domain['source'] for domain in {{ redirect_domain_mappings }}] - redirected_domains.append("{{domains | get_domain('web-app-mailu') }}") - - expected_statuses = get_expected_statuses(domain, parts, redirected_domains) - - try: - # Send a HEAD request to get only the response header - response = requests.head(url) - - # Check if the status code matches the expected statuses - if response.status_code in expected_statuses: - print(f"{domain}: OK") - else: - print(f"{domain}: ERROR: Expected {expected_statuses}. Got {response.status_code}.") - error_counter += 1 - except requests.RequestException as e: - # Handle exceptions for requests like connection errors - print(f"{domain}: error due to {e}") - error_counter += 1 - -if error_counter > 0: - print(f"Warning: {error_counter} domains responded with an unexpected https status code.") - -# Exit the script with the number of errors as the exit code -sys.exit(error_counter) diff --git a/roles/sys-service/tasks/04_files.yml b/roles/sys-service/tasks/04_files.yml index 24cec1fc..4a1bc9bc 100644 --- a/roles/sys-service/tasks/04_files.yml +++ b/roles/sys-service/tasks/04_files.yml @@ -12,6 +12,7 @@ src: "{{ system_service_script_src }}" dest: "{{ [system_service_script_dir, (system_service_script_src | basename | regex_replace('\\.j2$', ''))] | path_join }}" mode: "0755" + notify: refresh systemctl service # Just refresh service for testing that script is working in debug mode. In productive due to async it shouldn't make a difference when: system_service_script_src.endswith('.j2') - name: "copy raw file" @@ -20,4 +21,5 @@ dest: "{{ [system_service_script_dir, (system_service_script_src | basename)] | path_join }}" mode: "0755" when: not system_service_script_src.endswith('.j2') + notify: refresh systemctl service # Just refresh service for testing that script is working in debug mode. In productive due to async it shouldn't make a difference when: system_service_copy_files | bool diff --git a/roles/web-app-bluesky/config/main.yml b/roles/web-app-bluesky/config/main.yml index 4cdc567e..bc7b40cb 100644 --- a/roles/web-app-bluesky/config/main.yml +++ b/roles/web-app-bluesky/config/main.yml @@ -6,11 +6,13 @@ features: logout: true server: config_upstream_url: "https://ip.bsky.app/config" + status_codes: + web: 405 domains: canonical: web: "bskyweb.{{ PRIMARY_DOMAIN }}" api: "bluesky.{{ PRIMARY_DOMAIN }}" - view: "view.bluesky.{{ PRIMARY_DOMAIN }}" +# view: "view.bluesky.{{ PRIMARY_DOMAIN }}" csp: whitelist: connect-src: diff --git a/roles/web-app-confluence/config/main.yml b/roles/web-app-confluence/config/main.yml index 617d0a10..770445fe 100644 --- a/roles/web-app-confluence/config/main.yml +++ b/roles/web-app-confluence/config/main.yml @@ -2,13 +2,13 @@ credentials: {} docker: services: database: - enabled: true + enabled: true application: - image: atlassian/confluence - version: latest - name: confluence + image: atlassian/confluence + version: latest + name: confluence volumes: - data: "confluence_data" + data: "confluence_data" features: matomo: true css: true @@ -18,6 +18,8 @@ features: oidc: false # Not enabled for demo version ldap: false # Not enabled for demo version server: + status_codes: + default: 302 csp: whitelist: {} flags: @@ -27,7 +29,10 @@ server: unsafe-inline: true domains: canonical: + - "c.wiki.{{ PRIMARY_DOMAIN }}" + aliases: - "confluence.{{ PRIMARY_DOMAIN }}" + - "confluence.wiki.{{ PRIMARY_DOMAIN }}" rbac: roles: {} truststore_enabled: false \ No newline at end of file diff --git a/roles/web-app-confluence/meta/main.yml b/roles/web-app-confluence/meta/main.yml index 14aa4464..4150774e 100644 --- a/roles/web-app-confluence/meta/main.yml +++ b/roles/web-app-confluence/meta/main.yml @@ -8,7 +8,10 @@ galaxy_info: Kevin Veen-Birkenbach Consulting & Coaching Solutions https://www.veen.world - galaxy_tags: [] + galaxy_tags: + - wiki + - documentation + - confluence repository: "https://s.infinito.nexus/code" issue_tracker_url: "https://s.infinito.nexus/issues" documentation: "https://s.infinito.nexus/code/" diff --git a/roles/web-app-desktop/meta/main.yml b/roles/web-app-desktop/meta/main.yml index e969166c..e1118269 100644 --- a/roles/web-app-desktop/meta/main.yml +++ b/roles/web-app-desktop/meta/main.yml @@ -14,11 +14,9 @@ galaxy_info: versions: - latest galaxy_tags: - - docker + - homepage - portfolio - - ansible - - flask - - web + - landingpage repository: "https://github.com/kevinveenbirkenbach/portfolio" issue_tracker_url: "https://github.com/kevinveenbirkenbach/portfolio/issues" documentation: "https://github.com/kevinveenbirkenbach/portfolio#readme" diff --git a/roles/web-app-desktop/vars/menu_categories.yml b/roles/web-app-desktop/vars/menu_categories.yml index 80112450..d2aaa5a7 100644 --- a/roles/web-app-desktop/vars/menu_categories.yml +++ b/roles/web-app-desktop/vars/menu_categories.yml @@ -104,12 +104,20 @@ portfolio_menu_categories: - yourls Presentation: - description: "Presentation and Documentation Tools" + description: "Presentation Tools" icon: "fas fa-tools" tags: - presentation - sphinx - portfolio + + Documentation: + description: "Documentation and Wiki Applications" + icon: "fa fa-book" + tags: + - confluence + - xwiki + - mediawiki Finance & Accounting: description: "Financial and accounting software" @@ -174,5 +182,4 @@ portfolio_menu_categories: - publishing - website - joomla - - mediawiki - wordpress diff --git a/roles/web-app-jira/config/main.yml b/roles/web-app-jira/config/main.yml index 48d093d8..355f1a64 100644 --- a/roles/web-app-jira/config/main.yml +++ b/roles/web-app-jira/config/main.yml @@ -3,13 +3,13 @@ credentials: {} docker: services: database: - enabled: true + enabled: true application: - image: atlassian/jira-software - version: latest - name: jira + image: atlassian/jira-software + version: latest + name: jira volumes: - data: "jira_data" + data: "jira_data" features: matomo: true css: true @@ -31,5 +31,7 @@ server: domains: canonical: - "jira.{{ PRIMARY_DOMAIN }}" + status_codes: + default: 405 rbac: roles: {} diff --git a/roles/web-app-keycloak/templates/import/components/org.keycloak.storage.UserStorageProvider.json.j2 b/roles/web-app-keycloak/templates/import/components/org.keycloak.storage.UserStorageProvider.json.j2 index a528ca01..4131b5b7 100644 --- a/roles/web-app-keycloak/templates/import/components/org.keycloak.storage.UserStorageProvider.json.j2 +++ b/roles/web-app-keycloak/templates/import/components/org.keycloak.storage.UserStorageProvider.json.j2 @@ -245,7 +245,7 @@ {# Build objectClasses from structural + auxiliary definitions #} "userObjectClasses": [ - {{ KEYCLOAK_LDAP_USER_OBJECT_CLASSES | trim | tojson }} + {{ KEYCLOAK_LDAP_USER_OBJECT_CLASSES | trim | to_json }} ], "rdnLDAPAttribute": [ "{{ LDAP.USER.ATTRIBUTES.ID }}" ], diff --git a/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 b/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 index 8859d90c..213655cb 100644 --- a/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 +++ b/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 @@ -55,7 +55,7 @@ "providerId": "declarative-user-profile", "subComponents": {}, "config": { - "kc.user.profile.config": [{{ (user_profile | tojson) | tojson }}] + "kc.user.profile.config": [{{ (user_profile | to_json) | to_json }}] } } ] \ No newline at end of file diff --git a/roles/web-app-listmonk/config/main.yml b/roles/web-app-listmonk/config/main.yml index 16f712c6..7f240385 100644 --- a/roles/web-app-listmonk/config/main.yml +++ b/roles/web-app-listmonk/config/main.yml @@ -10,6 +10,8 @@ server: domains: canonical: - "newsletter.{{ PRIMARY_DOMAIN }}" + status_codes: + default: 404 docker: services: database: diff --git a/roles/web-app-listmonk/tasks/main.yml b/roles/web-app-listmonk/tasks/main.yml index 0dc4b2b5..62d9e140 100644 --- a/roles/web-app-listmonk/tasks/main.yml +++ b/roles/web-app-listmonk/tasks/main.yml @@ -13,7 +13,7 @@ - name: add config.toml template: src: "config.toml.j2" - dest: "{{docker_compose.directories.config}}config.toml" + dest: "{{ docker_compose.directories.config }}config.toml" notify: docker compose up - meta: flush_handlers diff --git a/roles/web-app-listmonk/templates/docker-compose.yml.j2 b/roles/web-app-listmonk/templates/docker-compose.yml.j2 index 61a1febf..313351b1 100644 --- a/roles/web-app-listmonk/templates/docker-compose.yml.j2 +++ b/roles/web-app-listmonk/templates/docker-compose.yml.j2 @@ -7,7 +7,7 @@ ports: - "127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}" volumes: - - {{docker_compose.directories.config}}config.toml:/listmonk/config.toml + - {{ docker_compose.directories.config }}config.toml:/listmonk/config.toml {% include 'roles/docker-container/templates/networks.yml.j2' %} {% include 'roles/docker-container/templates/depends_on/dmbs_excl.yml.j2' %} {% include 'roles/docker-container/templates/healthcheck/wget.yml.j2' %} diff --git a/roles/web-app-matrix/templates/synapse/homeserver.yaml.j2 b/roles/web-app-matrix/templates/synapse/homeserver.yaml.j2 index 89f1b066..c47c7f0a 100644 --- a/roles/web-app-matrix/templates/synapse/homeserver.yaml.j2 +++ b/roles/web-app-matrix/templates/synapse/homeserver.yaml.j2 @@ -25,7 +25,7 @@ report_stats: true macaroon_secret_key: "{{ applications | get_app_conf(application_id, 'credentials.macaroon_secret_key') }}" form_secret: "{{ applications | get_app_conf(application_id, 'credentials.form_secret') }}" signing_key_path: "/data/{{ MATRIX_SYNAPSE_DOMAIN }}.signing.key" -web_client_location: "{{ WEB_PROTOCOL }}://{{domains[application_id].element}}" +web_client_location: "{{ WEB_PROTOCOL }}://{{ domains[application_id].element}}" public_baseurl: "{{ MATRIX_SYNAPSE_URL }}" trusted_key_servers: - server_name: "matrix.org" diff --git a/roles/web-app-mediawiki/config/main.yml b/roles/web-app-mediawiki/config/main.yml index 9352a6e8..ac8827a2 100644 --- a/roles/web-app-mediawiki/config/main.yml +++ b/roles/web-app-mediawiki/config/main.yml @@ -2,6 +2,8 @@ sitename: "Wiki on {{ PRIMARY_DOMAIN | upper }}" server: domains: canonical: + - "m.wiki.{{ PRIMARY_DOMAIN }}" + aliases: - "media.wiki.{{ PRIMARY_DOMAIN }}" docker: services: diff --git a/roles/web-app-mediawiki/meta/main.yml b/roles/web-app-mediawiki/meta/main.yml index 588e6178..bafbd8f9 100644 --- a/roles/web-app-mediawiki/meta/main.yml +++ b/roles/web-app-mediawiki/meta/main.yml @@ -10,8 +10,6 @@ galaxy_info: https://www.veen.world galaxy_tags: - mediawiki - - docker - - cms - wiki - documentation repository: "https://s.infinito.nexus/code" diff --git a/roles/web-app-mobilizon/vars/main.yml b/roles/web-app-mobilizon/vars/main.yml index a64c4f17..759bfc9e 100644 --- a/roles/web-app-mobilizon/vars/main.yml +++ b/roles/web-app-mobilizon/vars/main.yml @@ -10,7 +10,7 @@ postgres_gis_enabled: true docker_compose_flush_handlers: false # Mobilizon -mobilizon_host_conf_exs_file: "{{docker_compose.directories.config}}config.exs" +mobilizon_host_conf_exs_file: "{{ docker_compose.directories.config }}config.exs" mobilizon_version: "{{ applications | get_app_conf(application_id, 'docker.services.mobilizon.version', True) }}" mobilizon_image: "{{ applications | get_app_conf(application_id, 'docker.services.mobilizon.image', True) }}" mobilizon_container: "{{ applications | get_app_conf(application_id, 'docker.services.mobilizon.name', True) }}" \ No newline at end of file diff --git a/roles/web-app-nextcloud/vars/plugins/user_ldap.yml b/roles/web-app-nextcloud/vars/plugins/user_ldap.yml index 217ba54d..8b42af3c 100644 --- a/roles/web-app-nextcloud/vars/plugins/user_ldap.yml +++ b/roles/web-app-nextcloud/vars/plugins/user_ldap.yml @@ -42,7 +42,7 @@ plugin_configuration: - appid: "user_ldap" configkey: "s01ldap_base_users" - configvalue: "{{LDAP.DN.OU.USERS}}" + configvalue: "{{ LDAP.DN.OU.USERS }}" - appid: "user_ldap" @@ -67,7 +67,7 @@ plugin_configuration: - appid: "user_ldap" configkey: "s01ldap_dn" - configvalue: "{{LDAP.DN.ADMINISTRATOR.DATA}}" + configvalue: "{{ LDAP.DN.ADMINISTRATOR.DATA }}" - appid: "user_ldap" configkey: "s01ldap_email_attr" diff --git a/roles/web-app-oauth2-proxy/templates/oauth2-proxy-keycloak.cfg.j2 b/roles/web-app-oauth2-proxy/templates/oauth2-proxy-keycloak.cfg.j2 index 419f5f5d..f6f3868c 100644 --- a/roles/web-app-oauth2-proxy/templates/oauth2-proxy-keycloak.cfg.j2 +++ b/roles/web-app-oauth2-proxy/templates/oauth2-proxy-keycloak.cfg.j2 @@ -17,7 +17,7 @@ provider_display_name = "{{ OIDC.BUTTON_TEXT }}" {# role based restrictions #} scope = "openid email profile {{ RBAC.GROUP.CLAIM }}" oidc_groups_claim = "{{ RBAC.GROUP.CLAIM }}" -allowed_groups = {{ applications | get_app_conf(oauth2_proxy_application_id, 'oauth2_proxy.allowed_groups', True) | tojson }} +allowed_groups = {{ applications | get_app_conf(oauth2_proxy_application_id, 'oauth2_proxy.allowed_groups', True) | to_json }} email_domains = ["*"] {% else %} email_domains = "{{ PRIMARY_DOMAIN }}" diff --git a/roles/web-app-yourls/config/main.yml b/roles/web-app-yourls/config/main.yml index 9d9b794a..7efc9693 100644 --- a/roles/web-app-yourls/config/main.yml +++ b/roles/web-app-yourls/config/main.yml @@ -30,7 +30,7 @@ server: locations: admin: "/admin/" status_codes: - landingpage: 301 + default: 301 docker: services: database: diff --git a/roles/web-app-yourls/vars/main.yml b/roles/web-app-yourls/vars/main.yml index 8d7b54b5..d4305572 100644 --- a/roles/web-app-yourls/vars/main.yml +++ b/roles/web-app-yourls/vars/main.yml @@ -12,7 +12,7 @@ YOURLS_VERSION: "{{ applications | get_app_conf(application_id, YOURLS_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.image') }}" YOURLS_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.yourls.name') }}" YOURLS_ADMIN_LOCATION: "{{ applications | get_app_conf(application_id, 'server.locations.admin') }}" -YOURLS_LANDINGPAGE_STATUS_CODE: "{{ applications | get_app_conf(application_id, 'server.status_codes.landingpage') }}" +YOURLS_LANDINGPAGE_STATUS_CODE: "{{ applications | get_app_conf(application_id, 'server.status_codes.default') }}" # Container container_port: 8080 diff --git a/roles/web-svc-simpleicons/vars/main.yml b/roles/web-svc-simpleicons/vars/main.yml index 57b52119..08f01e85 100644 --- a/roles/web-svc-simpleicons/vars/main.yml +++ b/roles/web-svc-simpleicons/vars/main.yml @@ -1,4 +1,4 @@ application_id: web-svc-simpleicons container_port: 3000 -simpleicons_host_server_file: "{{docker_compose.directories.config}}server.js" -simpleicons_host_package_file: "{{docker_compose.directories.config}}package.json" \ No newline at end of file +simpleicons_host_server_file: "{{ docker_compose.directories.config }}server.js" +simpleicons_host_package_file: "{{ docker_compose.directories.config }}package.json" \ No newline at end of file diff --git a/tasks/stages/01_constructor.yml b/tasks/stages/01_constructor.yml index 0a6c3fa8..650ac9d2 100644 --- a/tasks/stages/01_constructor.yml +++ b/tasks/stages/01_constructor.yml @@ -89,9 +89,7 @@ items2dict(key_name='source', value_name='source'), recursive=True )) | - generate_all_domains( - ('web-opt-rdr-www' in group_names) - ) + generate_all_domains(WWW_REDIRECT_ENABLED) }} - name: Merge networks definitions diff --git a/tests/unit/roles/sys-ctl-hlth-webserver/__init__.py b/tests/unit/roles/sys-ctl-hlth-webserver/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/sys-ctl-hlth-webserver/files/__init__.py b/tests/unit/roles/sys-ctl-hlth-webserver/files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/sys-ctl-hlth-webserver/files/test_script.py b/tests/unit/roles/sys-ctl-hlth-webserver/files/test_script.py new file mode 100644 index 00000000..13a5c9a3 --- /dev/null +++ b/tests/unit/roles/sys-ctl-hlth-webserver/files/test_script.py @@ -0,0 +1,119 @@ +# tests/unit/roles/sys-ctl-hlth-webserver/files/test_script.py +import os +import unittest +import importlib.util +from unittest.mock import patch + + +def load_module_from_path(mod_name: str, path: str): + """Dynamically load a module from a filesystem path.""" + spec = importlib.util.spec_from_file_location(mod_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[attr-defined] + return module + + +class TestStandaloneCheckerScript(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Compute repo root: tests/unit/roles/sys-ctl-hlth-webserver/files/test_script.py → 5 levels up + here = os.path.abspath(os.path.dirname(__file__)) + repo_root = os.path.abspath(os.path.join(here, "..", "..", "..", "..", "..")) + cls.script_path = os.path.join( + repo_root, "roles", "sys-ctl-hlth-webserver", "files", "script.py" + ) + if not os.path.isfile(cls.script_path): + raise FileNotFoundError(f"Cannot find script.py at {cls.script_path}") + cls.script = load_module_from_path("health_script", cls.script_path) + + # ------------- JSON parsing ------------------ + + def test_rejects_invalid_json(self): + with self.assertRaises(SystemExit): + self.script.main([ + "--expectations", '{"bad json": [200, 301]', # missing closing brace + ]) + + def test_rejects_non_mapping_json(self): + with self.assertRaises(SystemExit): + self.script.main([ + "--expectations", '["not", "a", "mapping"]', + ]) + + # ------------- Happy path / mismatches ------- + + @patch("requests.head") + def test_all_ok_returns_zero(self, mock_head): + def head_side_effect(url, allow_redirects=False, timeout=10): + class R: pass + r = R() + domain = url.split("://", 1)[1] + # both match expectations exactly + mapping = {"ok1.example.org": 200, "ok2.example.org": 301} + r.status_code = mapping.get(domain, 200) + return r + + mock_head.side_effect = head_side_effect + + exp = { + "ok1.example.org": [200, 302, 301], + "ok2.example.org": [301], + } + exit_code = self.script.main([ + "--web-protocol", "https", + "--expectations", self._to_json(exp), + ]) + self.assertEqual(exit_code, 0) + + @patch("requests.head") + def test_mismatches_counted(self, mock_head): + def head_side_effect(url, allow_redirects=False, timeout=10): + class R: pass + r = R() + domain = url.split("://", 1)[1] + mapping = {"bad.example.org": 200, "ok301.example.org": 301} + r.status_code = mapping.get(domain, 200) + return r + + mock_head.side_effect = head_side_effect + + exp = { + "bad.example.org": [404], # mismatch (got 200) + "ok301.example.org": [301], # OK + "never.example.org": [200], # will default to 200 in side effect? No mapping -> 200 -> OK + } + # Adjust side effect to ensure "never.example.org" is OK 200 + exit_code = self.script.main([ + "--expectations", self._to_json(exp), + ]) + # only 'bad.example.org' mismatched + self.assertEqual(exit_code, 1) + + @patch("requests.head") + def test_non_list_values_sanitize_to_empty_and_fail(self, mock_head): + # If a domain maps to a non-list, it becomes [] and is treated as a failure + def head_side_effect(url, allow_redirects=False, timeout=10): + class R: pass + r = R() + r.status_code = 200 + return r + + mock_head.side_effect = head_side_effect + + exp_json = '{"foo.example.org": "not-a-list", "bar.example.org": 200}' + # Both entries get empty expectations -> 2 errors + exit_code = self.script.main([ + "--expectations", exp_json, + ]) + self.assertEqual(exit_code, 2) + + # ------------- Helpers ----------------------- + + @staticmethod + def _to_json(obj) -> str: + import json + return json.dumps(obj, separators=(",", ":")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/roles/sys-ctl-hlth-webserver/filter_plugins/__init__.py b/tests/unit/roles/sys-ctl-hlth-webserver/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/sys-ctl-hlth-webserver/filter_plugins/test_web_health_expectations.py b/tests/unit/roles/sys-ctl-hlth-webserver/filter_plugins/test_web_health_expectations.py new file mode 100644 index 00000000..1eb828b6 --- /dev/null +++ b/tests/unit/roles/sys-ctl-hlth-webserver/filter_plugins/test_web_health_expectations.py @@ -0,0 +1,278 @@ +# tests/unit/roles/sys-ctl-hlth-webserver/filter_plugins/test_web_health_expectations.py +import os +import unittest +import importlib.util +from unittest.mock import patch + + +def load_module_from_path(mod_name: str, path: str): + """Dynamically load a module from a filesystem path.""" + spec = importlib.util.spec_from_file_location(mod_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[attr-defined] + return module + + +class TestWebHealthExpectationsFilter(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Compute repo root from this test file location + here = os.path.abspath(os.path.dirname(__file__)) + # tests/unit/roles/sys-ctl-hlth-webserver/filter_plugins/ -> repo root is 5 levels up + cls.ROOT = os.path.abspath(os.path.join(here, "..", "..", "..", "..", "..")) + + cls.module_path = os.path.join( + cls.ROOT, "roles", "sys-ctl-hlth-webserver", "filter_plugins", "web_health_expectations.py" + ) + if not os.path.isfile(cls.module_path): + raise FileNotFoundError(f"Cannot find web_health_expectations.py at {cls.module_path}") + + # Load the filter module once for all tests + cls.mod = load_module_from_path("web_health_expectations", cls.module_path) + + def setUp(self): + # Fresh mock for get_app_conf per test + self.get_app_conf_patch = patch.object(self.mod, "get_app_conf") + self.mock_get_app_conf = self.get_app_conf_patch.start() + + def tearDown(self): + self.get_app_conf_patch.stop() + + def _configure_returns(self, mapping): + """ + Provide a dict keyed by (app_id, key) -> value. + get_app_conf(...) will return mapping.get((app_id, key), default) + """ + def side_effect(applications, app_id, key, strict=False, default=None): + return mapping.get((app_id, key), default) + self.mock_get_app_conf.side_effect = side_effect + + # ------------ Required selection -------------- + + def test_raises_when_group_names_missing(self): + apps = {"app-a": {}} + with self.assertRaises(ValueError): + self.mod.web_health_expectations(apps, group_names=None) + + def test_raises_when_group_names_empty_variants(self): + apps = {"app-a": {}} + with self.assertRaises(ValueError): + self.mod.web_health_expectations(apps, group_names=[]) + with self.assertRaises(ValueError): + self.mod.web_health_expectations(apps, group_names="") + with self.assertRaises(ValueError): + self.mod.web_health_expectations(apps, group_names=" , ") + + # ---- Non-mapping apps short-circuit (but group_names still required) ---- + + def test_non_mapping_returns_empty_dict(self): + expectations = self.mod.web_health_expectations(applications=["not", "a", "mapping"], group_names=["any"]) + self.assertEqual(expectations, {}) + + # ------------ Flat canonical ----------------- + + def test_flat_canonical_with_default_status(self): + apps = {"app-a": {}} + self._configure_returns({ + ("app-a", "server.domains.canonical"): ["a.example.org"], + ("app-a", "server.domains.aliases"): [], + ("app-a", "server.status_codes"): {"default": 405}, + }) + out = self.mod.web_health_expectations(apps, group_names=["app-a"]) + self.assertEqual(out["a.example.org"], [405]) + + def test_flat_canonical_invalid_default_falls_back_to_DEFAULT_OK(self): + apps = {"app-x": {}} + self._configure_returns({ + ("app-x", "server.domains.canonical"): ["x.example.org"], + ("app-x", "server.domains.aliases"): [], + ("app-x", "server.status_codes"): {"default": 700}, # invalid HTTP code + }) + out = self.mod.web_health_expectations(apps, group_names=["app-x"]) + self.assertEqual(out["x.example.org"], [200, 302, 301]) + + # ------------ Keyed canonical ---------------- + + def test_keyed_canonical_with_per_key_overrides_and_default(self): + apps = {"app-d": {}} + self._configure_returns({ + ("app-d", "server.domains.canonical"): { + "api": "api.d.example.org", + "web": "web.d.example.org", + "view": ["v1.d.example.org", "v2.d.example.org"], + }, + ("app-d", "server.domains.aliases"): ["alias.d.example.org"], + ("app-d", "server.status_codes"): {"api": 404, "default": 405}, + }) + out = self.mod.web_health_expectations(apps, group_names=["app-d"]) + + self.assertEqual(out["api.d.example.org"], [404]) # per-key override wins + self.assertEqual(out["web.d.example.org"], [405]) # default used + self.assertEqual(out["v1.d.example.org"], [405]) # default used + self.assertEqual(out["v2.d.example.org"], [405]) # default used + self.assertEqual(out["alias.d.example.org"], [301]) # aliases always redirect + + def test_keyed_canonical_invalid_key_and_default_falls_back(self): + apps = {"app-y": {}} + self._configure_returns({ + ("app-y", "server.domains.canonical"): {"web": ["y.example.org"]}, + ("app-y", "server.domains.aliases"): [], + ("app-y", "server.status_codes"): {"web": 999}, # invalid; default missing + }) + out = self.mod.web_health_expectations(apps, group_names=["app-y"]) + self.assertEqual(out["y.example.org"], [200, 302, 301]) + + # ------------ Selection by group_names ------- + + def test_selection_by_group_names_list(self): + apps = {"app-a": {}, "app-b": {}, "app-c": {}} + self._configure_returns({ + ("app-a", "server.domains.canonical"): ["a.example.org"], + ("app-a", "server.domains.aliases"): [], + ("app-a", "server.status_codes"): {"default": 200}, + + ("app-b", "server.domains.canonical"): ["b.example.org"], + ("app-b", "server.domains.aliases"): [], + ("app-b", "server.status_codes"): {"default": 405}, + + ("app-c", "server.domains.canonical"): ["c.example.org"], + ("app-c", "server.domains.aliases"): ["alias.c.example.org"], + ("app-c", "server.status_codes"): {}, + }) + + out = self.mod.web_health_expectations(apps, group_names=["app-a", "app-c"]) + self.assertIn("a.example.org", out) + self.assertIn("c.example.org", out) + self.assertIn("alias.c.example.org", out) + self.assertNotIn("b.example.org", out) + + def test_selection_by_group_names_string(self): + apps = {"app-a": {}, "app-b": {}} + self._configure_returns({ + ("app-a", "server.domains.canonical"): ["a.example.org"], + ("app-a", "server.domains.aliases"): [], + ("app-a", "server.status_codes"): {"default": 200}, + + ("app-b", "server.domains.canonical"): ["b.example.org"], + ("app-b", "server.domains.aliases"): [], + ("app-b", "server.status_codes"): {"default": 405}, + }) + out = self.mod.web_health_expectations(apps, group_names="app-a, app-c ") + self.assertIn("a.example.org", out) + self.assertNotIn("b.example.org", out) + + # ------------ Aliases & filtering ------------ + + def test_aliases_are_always_301(self): + apps = {"app-f": {}} + self._configure_returns({ + ("app-f", "server.domains.canonical"): ["f.example.org"], + ("app-f", "server.domains.aliases"): ["alias1.example.org", "alias2.example.org"], + ("app-f", "server.status_codes"): {"default": 200}, + }) + out = self.mod.web_health_expectations(apps, group_names=["app-f"]) + self.assertEqual(out["alias1.example.org"], [301]) + self.assertEqual(out["alias2.example.org"], [301]) + self.assertEqual(out["f.example.org"], [200]) + + def test_non_string_entries_in_lists_are_dropped(self): + apps = {"app-g": {}} + self._configure_returns({ + ("app-g", "server.domains.canonical"): ["ok.g.example.org", None, 123, {"x": "y"}], + ("app-g", "server.domains.aliases"): [{"bad": "obj"}, "alias.g.example.org", None], + ("app-g", "server.status_codes"): {}, # → fallback + }) + out = self.mod.web_health_expectations(apps, group_names=["app-g"]) + self.assertIn("ok.g.example.org", out) + self.assertEqual(out["alias.g.example.org"], [301]) + self.assertNotIn(123, out) + + # ------------ WWW mapping (flag) -------------- + + def test_www_mapping_is_added_and_forced_to_301_when_enabled(self): + apps = {"app-h": {}} + # includes a canonical that already starts with www. + self._configure_returns({ + ("app-h", "server.domains.canonical"): ["h.example.org", "www.keep301.example.org"], + ("app-h", "server.domains.aliases"): ["alias.h.example.org"], + ("app-h", "server.status_codes"): {"default": 405}, + }) + out = self.mod.web_health_expectations(apps, group_names=["app-h"], www_enabled=True) + + # base domains + self.assertEqual(out["h.example.org"], [405]) + self.assertEqual(out["alias.h.example.org"], [301]) + + # auto-generated www.* entries always 301 + self.assertEqual(out["www.h.example.org"], [301]) + self.assertEqual(out["www.alias.h.example.org"], [301]) + + # any pre-existing www.* must be forced to 301 too + self.assertEqual(out["www.keep301.example.org"], [301]) + + def test_no_www_mapping_when_disabled(self): + apps = {"app-i": {}} + self._configure_returns({ + ("app-i", "server.domains.canonical"): ["i.example.org"], + ("app-i", "server.domains.aliases"): [], + ("app-i", "server.status_codes"): {"default": 200}, + }) + out = self.mod.web_health_expectations(apps, group_names=["app-i"], www_enabled=False) + self.assertIn("i.example.org", out) + self.assertNotIn("www.i.example.org", out) + + # ------------ redirect_maps ------------------- + + def test_redirect_maps_sources_are_included_as_301(self): + apps = {} + out = self.mod.web_health_expectations( + apps, + group_names=["any"], # required, even if no apps + redirect_maps=[{"source": "mail.example.org"}, "legacy.example.org"] + ) + self.assertEqual(out["mail.example.org"], [301]) + self.assertEqual(out["legacy.example.org"], [301]) + + def test_redirect_maps_override_app_expectations(self): + apps = {"conflict-app": {}} + self._configure_returns({ + ("conflict-app", "server.domains.canonical"): ["conflict.example.org"], + ("conflict-app", "server.domains.aliases"): [], + ("conflict-app", "server.status_codes"): {"default": 200}, + }) + out = self.mod.web_health_expectations( + apps, + group_names=["conflict-app"], + redirect_maps=[{"source": "conflict.example.org"}] + ) + self.assertEqual(out["conflict.example.org"], [301]) + + def test_redirect_maps_get_www_when_enabled(self): + apps = {} + out = self.mod.web_health_expectations( + apps, + group_names=["any"], + www_enabled=True, + redirect_maps=[{"source": "redir.example.org"}] + ) + self.assertEqual(out["redir.example.org"], [301]) + self.assertEqual(out["www.redir.example.org"], [301]) + + def test_redirect_maps_independent_of_group_filter(self): + apps = {"ignored-app": {}} + self._configure_returns({ + ("ignored-app", "server.domains.canonical"): ["ignored.example.org"], + ("ignored-app", "server.domains.aliases"): [], + ("ignored-app", "server.status_codes"): {"default": 200}, + }) + out = self.mod.web_health_expectations( + apps, + group_names=["some-other-app"], # excludes the only app + redirect_maps=[{"source": "manual.example.org"}] + ) + self.assertNotIn("ignored.example.org", out) + self.assertEqual(out["manual.example.org"], [301]) + + +if __name__ == "__main__": + unittest.main()