From 6c340c511156ca48017fcdc34da79e39c6710d2a Mon Sep 17 00:00:00 2001 From: uumas Date: Sat, 5 Jul 2025 15:38:17 +0300 Subject: [PATCH] vhost: Add support for custom matchers and specifying response status --- roles/vhost/defaults/main.yaml | 3 + roles/vhost/meta/argument_specs.yaml | 334 ++++++++++++++++++++++- roles/vhost/templates/Caddyfile_block.j2 | 94 ++++--- roles/vhost/vars/main.yaml | 47 +++- 4 files changed, 429 insertions(+), 49 deletions(-) diff --git a/roles/vhost/defaults/main.yaml b/roles/vhost/defaults/main.yaml index ac5fb79..8b532de 100644 --- a/roles/vhost/defaults/main.yaml +++ b/roles/vhost/defaults/main.yaml @@ -23,3 +23,6 @@ vhost_redirect_preserve_path: false vhost_redirect_preserve_query: "{{ vhost_redirect_preserve_path }}" vhost_respond_content_type: plain +vhost_respond_status: 200 + +vhost_matchers: [] diff --git a/roles/vhost/meta/argument_specs.yaml b/roles/vhost/meta/argument_specs.yaml index 2471c03..3f1f5e6 100644 --- a/roles/vhost/meta/argument_specs.yaml +++ b/roles/vhost/meta/argument_specs.yaml @@ -116,7 +116,7 @@ argument_specs: default: [] vhost_proxy_pass_host_header: description: Whether to pass the host header unchanged (true) or change it to the proxy target host (false) - trpe: bool + type: bool required: false default: true @@ -155,6 +155,178 @@ argument_specs: choices: - plain - json + vhost_respond_status: + description: Status code of response + type: int + required: false + default: 200 + + vhost_matchers: + description: > + List of matchers to handle differently from the default for vhost. + A matcher matches if all of its conditions match + type: list + elements: dict + required: false + default: [] + options: + name: + description: Name of the matcher used to reference it + type: str + required: true + + match_methods: + description: HTTP methods to match against. Matching one method is enough. + type: list + elements: str + choices: + - GET + - HEAD + - OPTIONS + - TRACE + - PUT + - DELETE + - POST + - PATCH + - CONNECT + required: false + default: [] + match_headers: + description: >- + Headers to match against. + If the value begins with ^ and end with $, the value is matched as regex. + type: dict + required: false + default: {} + + type: + type: str + required: false + default: "{{ vhost_type }}" + choices: + - reverse_proxy + - redirect + - respond + headers: + description: Dict of response headers and their values + type: dict + required: false + default: "{{ vhost_headers }}" + delete_headers: + description: List of response headers to delete + type: list + elements: str + required: false + default: "{{ vhost_delete_headers }}" + + basicauth: + description: Whether to require basic auth for the location + type: bool + required: false + default: "{{ vhost_basicauth }}" + basicauth_users: + description: A dict of basic auth users and their password hashes. Required if basicauth is true + type: dict + default: "{{ vhost_basicauth_users }}" + + proxy_target_netproto: + description: + - Network protocol to use for proxy requests. + - Only applicable if type is reverse_proxy. + type: str + required: false + default: "{{ vhost_proxy_target_netproto }}" + choices: + - tcp + - unix + proxy_target_protocol: + description: + - Transport protocol (scheme) to use for proxy requests. + - Only applicable if type is reverse_proxy. + type: str + required: false + default: "{{ vhost_proxy_target_protocol }}" + choices: + - http + - https + proxy_target_host: + description: Host where to proxy requests to. Only applicable if type is reverse_proxy + type: str + required: false + default: "{{ vhost_proxy_target_host }}" + proxy_target_port: + description: Port where to proxy requests to. Only applicable if type is reverse_proxy. + type: int + required: false + default: "{{ vhost_proxy_target_port if vhost_type == 'reverse_proxy' and vhost_proxy_target_netproto == 'tcp' else 0 }}" + proxy_target_socket: + description: + - Unix socket path to proxy requests to. + - Only applicable if type is reverse_proxy and proxy_target_netproto is unix. + type: str + required: false + default: "{{ vhost_proxy_target_socket if vhost_type == 'reverse_proxy' and vhost_proxy_target_netproto == 'unix' else '' }}" + proxy_headers: + description: Dict of request headers and their values to set for proxied requests + type: dict + required: false + default: "{{ vhost_proxy_headers }}" + proxy_delete_headers: + description: List of request headers to delete from proxied requests + type: list + elements: str + required: false + default: "{{ vhost_proxy_delete_headers }}" + proxy_pass_host_header: + description: Whether to pass the host header unchanged (true) or change it to the proxy target host (false) + type: bool + required: false + default: "{{ vhost_proxy_pass_host_header }}" + + redirect_target: + description: "Only applicable if vhost_type is redirect. Example: https://www.domain.tld/location" + type: str + required: false + default: "{{ vhost_redirect_target if vhost_type == 'redirect' else '' }}" + redirect_preserve_path: + description: Whether to keep the original request path + type: bool + required: false + default: "{{ vhost_redirect_preserve_path }}" + redirect_preserve_query: + description: Whether to keep the original request query string + type: bool + required: false + default: "{{ vhost_redirect_preserve_query }}" + redirect_type: + description: Only applicable if vhost_type is redirect + type: str + required: false + default: "{{ vhost_redirect_type }}" + choices: + - temporary + - permanent + + respond_content: + description: >- + Content to respond with. + Json content can be set as yaml as long as respond_content_type is set to json. + type: str + required: false + default: "{{ vhost_respond_content if vhost_type == 'respond' else '' }}" + respond_content_type: + description: Type of the respond content + type: str + required: false + default: "{{ vhost_respond_content_type }}" + choices: + - plain + - json + respond_status: + description: Status code of response + type: int + required: false + default: "{{ vhost_respond_status }}" vhost_locations: description: List of locations to handle differently from the default for vhost @@ -247,7 +419,7 @@ argument_specs: default: "{{ vhost_proxy_delete_headers }}" proxy_pass_host_header: description: Whether to pass the host header unchanged (true) or change it to the proxy target host (false) - trpe: bool + type: bool required: false default: "{{ vhost_proxy_pass_host_header }}" @@ -276,7 +448,9 @@ argument_specs: - permanent respond_content: - description: Content to respond with. Json content can be set as yaml as long as respond_content_type is set to json + description: >- + Content to respond with. + Json content can be set as yaml as long as respond_content_type is set to json. type: str required: false default: "{{ vhost_respond_content if vhost_type == 'respond' else '' }}" @@ -288,3 +462,157 @@ argument_specs: choices: - plain - json + respond_status: + description: Status code of response + type: int + required: false + default: "{{ vhost_respond_status }}" + + matchers: + description: > + List of matchers to handle differently from the default for vhost. + A matcher matches if all of its conditions match. + Options without a specified default will default to location's corresponding option. + type: list + elements: dict + required: false + default: "{{ vhost_matchers }}" + options: + name: + description: Name of the matcher used to reference it + type: str + required: true + + match_methods: + description: HTTP methods to match against. Matching one method is enough. + type: list + elements: str + choices: + - GET + - HEAD + - OPTIONS + - TRACE + - PUT + - DELETE + - POST + - PATCH + - CONNECT + required: false + default: [] + match_headers: + description: >- + Headers to match against. + The value is matched as regex. + ^ and $ are implied, so don't add them yourself. + type: dict + required: false + default: {} + + type: + type: str + required: false + choices: + - reverse_proxy + - redirect + - respond + headers: + description: Dict of response headers and their values + type: dict + required: false + delete_headers: + description: List of response headers to delete + type: list + elements: str + required: false + + basicauth: + description: Whether to require basic auth for the location + type: bool + required: false + basicauth_users: + description: A dict of basic auth users and their password hashes. Required if basicauth is true + type: dict + + proxy_target_netproto: + description: + - Network protocol to use for proxy requests. + - Only applicable if type is reverse_proxy. + type: str + required: false + choices: + - tcp + - unix + proxy_target_protocol: + description: + - Transport protocol (scheme) to use for proxy requests. + - Only applicable if type is reverse_proxy. + type: str + required: false + choices: + - http + - https + proxy_target_host: + description: Host where to proxy requests to. Only applicable if type is reverse_proxy + type: str + required: false + proxy_target_port: + description: Port where to proxy requests to. Only applicable if type is reverse_proxy. + type: int + required: false + proxy_target_socket: + description: + - Unix socket path to proxy requests to. + - Only applicable if type is reverse_proxy and proxy_target_netproto is unix. + type: str + required: false + proxy_headers: + description: Dict of request headers and their values to set for proxied requests + type: dict + required: false + proxy_delete_headers: + description: List of request headers to delete from proxied requests + type: list + elements: str + required: false + proxy_pass_host_header: + description: Whether to pass the host header unchanged (true) or change it to the proxy target host (false) + type: bool + required: false + + redirect_target: + description: "Only applicable if vhost_type is redirect. Example: https://www.domain.tld/location" + type: str + required: false + redirect_preserve_path: + description: Whether to keep the original request path + type: bool + required: false + redirect_preserve_query: + description: Whether to keep the original request query string + type: bool + required: false + redirect_type: + description: Only applicable if vhost_type is redirect + type: str + required: false + choices: + - temporary + - permanent + + respond_content: + description: >- + Content to respond with. + Json content can be set as yaml as long as respond_content_type is set to json. + type: str + required: false + respond_content_type: + description: Type of the respond content + type: str + required: false + choices: + - plain + - json + respond_status: + description: Status code of response + type: int + required: false diff --git a/roles/vhost/templates/Caddyfile_block.j2 b/roles/vhost/templates/Caddyfile_block.j2 index a9e5b47..b3a5714 100644 --- a/roles/vhost/templates/Caddyfile_block.j2 +++ b/roles/vhost/templates/Caddyfile_block.j2 @@ -2,53 +2,67 @@ {{ vhost_domains | join(' ') }} { {% for location in _vhost_locations_complete %} handle {{ location.path }} { - {% for header in location.delete_headers %} - header -{{ header }} - {% endfor %} - {% for header in location.headers | dict2items %} - header {{ header.key }} `{{ header.value }}` - {% endfor %} - {% if location.basicauth %} - basicauth { - {% for user in location.basicauth_users | dict2items %} - {{ user.key }} {{ user.value }} + {% for matcher in location.matchers %} + {% if matcher.name != '' %} + @{{ matcher.name }} { + {% if matcher.match_methods | length > 0 %} + method {{ matcher.match_methods | join(' ') }} + {% endif %} + {% for header in matcher.match_headers | dict2items %} + header{{ '_regexp' if header.value.startswith('^') and header.value.endswith('$') else '' }} {{ header.key }} {{ header.value }} {% endfor %} } {% endif %} - {% if location.type == 'reverse_proxy' %} - reverse_proxy { - {% if location.proxy_target_netproto == 'tcp' %} - to tcp/{{ location.proxy_target_host }}:{{ location.proxy_target_port }} - {% else %} - to unix/{{ location.proxy_target_socket }} - {% endif %} - {% if location.proxy_target_protocol == 'https' %} - transport http { - tls - {% if location.proxy_target_host == 'localhost' %} - tls_insecure_skip_verify - {% endif %} + handle{{ ' @' ~ matcher.name if matcher.name != '' else '' }} { + {% for header in matcher.delete_headers %} + header -{{ header }} + {% endfor %} + {% for header in matcher.headers | dict2items %} + header {{ header.key }} `{{ header.value }}` + {% endfor %} + {% if matcher.basicauth %} + basicauth { + {% for user in matcher.basicauth_users | dict2items %} + {{ user.key }} {{ user.value }} + {% endfor %} } {% endif %} - {% for header in location.proxy_delete_headers %} - header_up -{{ header }} - {% endfor %} - {% for header in location.proxy_headers | dict2items %} - header_up {{ header.key }} `{{ header.value }}` - {% endfor %} - {% if (not location.proxy_pass_host_header) and ('host' not in location.proxy_headers | map('lower')) %} - header_up Host {upstream_hostport} + {% if matcher.type == 'reverse_proxy' %} + reverse_proxy { + {% if matcher.proxy_target_netproto == 'tcp' %} + to tcp/{{ matcher.proxy_target_host }}:{{ matcher.proxy_target_port }} + {% else %} + to unix/{{ matcher.proxy_target_socket }} + {% endif %} + {% if matcher.proxy_target_protocol == 'https' %} + transport http { + tls + {% if matcher.proxy_target_host == 'localhost' %} + tls_insecure_skip_verify + {% endif %} + } + {% endif %} + {% for header in matcher.proxy_delete_headers %} + header_up -{{ header }} + {% endfor %} + {% for header in matcher.proxy_headers | dict2items %} + header_up {{ header.key }} `{{ header.value }}` + {% endfor %} + {% if (not matcher.proxy_pass_host_header) and ('host' not in matcher.proxy_headers | map('lower')) %} + header_up Host {upstream_hostport} + {% endif %} + } + {% elif matcher.type == 'redirect' %} + redir * {{ matcher.redirect_target }}{{ '{path}' if matcher.redirect_preserve_path }}{{ '?{query}' if matcher.redirect_preserve_query }} {{ matcher.redirect_type }} + {% elif matcher.type == 'respond' %} + {% if matcher.respond_content_type == 'json' %} + respond `{{ matcher.respond_content | to_json }}` + {% else %} + respond `{{ matcher.respond_content }}` {{ matcher.respond_status }} + {% endif %} {% endif %} } - {% elif location.type == 'redirect' %} - redir * {{ location.redirect_target }}{{ '{path}' if location.redirect_preserve_path }}{{ '?{query}' if location.redirect_preserve_query }} {{ location.redirect_type }} - {% elif location.type == 'respond' %} - {% if location.respond_content_type == 'json' %} - respond `{{ location.respond_content | to_json }}` - {% else %} - respond `{{ location.respond_content }}` - {% endif %} - {% endif %} + {% endfor %} } {% endfor %} } diff --git a/roles/vhost/vars/main.yaml b/roles/vhost/vars/main.yaml index dd04db7..6aa1e3f 100644 --- a/roles/vhost/vars/main.yaml +++ b/roles/vhost/vars/main.yaml @@ -1,4 +1,15 @@ --- +_vhost_matcher_defaults: + match_headers: {} + match_method: [] +_vhost_matchers: >- + {{ + vhost_matchers + | map('combine', _vhost_matcher_defaults) + | zip(vhost_matchers) + | map('combine') + }} + _vhost_location_defaults: type: "{{ vhost_type }}" headers: "{{ vhost_headers }}" @@ -25,12 +36,36 @@ _vhost_location_defaults: respond_content: "{{ vhost_respond_content if vhost_type == 'respond' else '' }}" respond_content_type: "{{ vhost_respond_content_type }}" + respond_status: "{{ vhost_respond_status }}" + + matchers: "{{ _vhost_matchers }}" _vhost_locations: "{{ vhost_locations + [{'path': ''}] }}" -_vhost_locations_complete: "{{ - _vhost_locations - | map('combine', _vhost_location_defaults) - | zip(_vhost_locations) - | map('combine') -}}" +_vhost_locations_withdefaults: >- + {{ + _vhost_locations + | map('combine', _vhost_location_defaults) + | zip( + _vhost_locations + ) + | map('combine') + | map('combine', {'matchers': [{'name': ''}]}, list_merge='append') + }} + +_vhost_locations_complete: >- + {{ + _vhost_locations_withdefaults | + sort(attribute='path') | + zip( + _vhost_locations_withdefaults | + subelements('matchers') | + map('combine') | + groupby('path') | + map('last') | + map('community.general.remove_keys', ['matchers', 'path']) | + map('community.general.dict_kv', 'matchers') + ) | + map('combine') | + reverse + }}