diff --git a/www/caddy/pkg-descr b/www/caddy/pkg-descr index 01962ce7a..d2c94173f 100644 --- a/www/caddy/pkg-descr +++ b/www/caddy/pkg-descr @@ -16,6 +16,10 @@ Plugin Changelog 1.7.2 * Add: Directive in HTTP Handler can be chosen, "reverse_proxy" and "redir" +* Add: Layer4 routes feature. Routing Type "global" or "listener_wrapper" can be chosen +* Add: Any Layer4 TCP/UDP traffic can be proxied without choosing a Layer 7 protocol matcher +* Change: Layer4 routes are not ordered automatically anymore +* Change: Layer4 "not tls sni" matcher has been replaced by generalized "Invert Matchers" checkbox * Build: Update Caddy Layer4 module, fixes TLS matcher in chromium based browsers * Fix: When Apply takes longer than 20 seconds, Caddy will be forcefully restarted diff --git a/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/forms/dialogLayer4.xml b/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/forms/dialogLayer4.xml index acac47c8c..a2c73f6a0 100644 --- a/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/forms/dialogLayer4.xml +++ b/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/forms/dialogLayer4.xml @@ -5,6 +5,12 @@ checkbox + + layer4.Sequence + + text + + layer4.description @@ -13,21 +19,59 @@ header - + + true - layer4.FromDomain - - select_multiple - - true - + layer4.Type + + dropdown + + true + + + header + + true + + + layer4.Protocol + + dropdown + + true + + + layer4.FromPort + + text + + true + + + header + layer4.Matchers dropdown - + + + + layer4.FromDomain + + select_multiple + + true + + + + layer4.InvertMatchers + + checkbox + + true header @@ -64,7 +108,6 @@ header - true layer4.RemoteIp diff --git a/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.php b/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.php index ddb6f919f..aefc67b9b 100644 --- a/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.php +++ b/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.php @@ -37,11 +37,11 @@ use OPNsense\Core\Config; class Caddy extends BaseModel { // Check domain-port combinations - private function checkForUniquePortCombos($items, $messages) + private function checkForUniquePortCombos($messages) { $combos = []; - foreach ($items as $item) { - $key = $item->__reference; // Dynamic key based on item reference + foreach ($this->reverseproxy->reverse->iterateItems() as $item) { + $key = $item->__reference; $fromDomain = (string) $item->FromDomain; $fromPort = (string) $item->FromPort; @@ -52,12 +52,9 @@ class Caddy extends BaseModel } foreach ($defaultPorts as $port) { - // Create a unique key for domain-port combination $comboKey = $fromDomain . ':' . $port; - // Check for duplicate combinations if (isset($combos[$comboKey])) { - // Use dynamic $key for message referencing $messages->appendMessage(new Message( sprintf( gettext( @@ -76,48 +73,6 @@ class Caddy extends BaseModel } } - // Check that subdomains are under a wildcard or exact domain - private function checkSubdomainsAgainstDomains($subdomains, $domains, $messages) - { - $wildcardDomainList = []; - foreach ($domains as $domain) { - if ((string) $domain->enabled === '1') { - $domainName = (string) $domain->FromDomain; - if (str_starts_with($domainName, '*.')) { - $wildcardBase = substr($domainName, 2); - $wildcardDomainList[$wildcardBase] = $domainName; - } - } - } - - foreach ($subdomains as $subdomain) { - if ((string) $subdomain->enabled === '1') { - $subdomainName = (string) $subdomain->FromDomain; - $isValid = false; - foreach ($wildcardDomainList as $baseDomain => $wildcardDomain) { - if (str_ends_with($subdomainName, $baseDomain)) { - $isValid = true; - break; - } - } - - if (!$isValid) { - $key = $subdomain->__reference; // Dynamic key based on subdomain reference - $messages->appendMessage(new Message( - sprintf( - gettext( - 'Invalid subdomain configuration: %s does not fall ' . - 'under any configured wildcard domain.' - ), - $subdomainName - ), - $key . ".FromDomain" - )); - } - } - } - } - // Get the current OPNsense WebGUI ports and check for conflicts with Caddy private function getWebGuiPorts() { @@ -172,8 +127,7 @@ class Caddy extends BaseModel private function checkDisableTlsConflicts($messages) { foreach ($this->reverseproxy->reverse->iterateItems() as $item) { - // First check if the DisableTls field has been changed - if ($item->isFieldChanged('DisableTls')) { + if ($item->isFieldChanged()) { if ((string) $item->DisableTls === '1') { $conflictChecks = [ 'DnsChallenge' => (string) $item->DnsChallenge === '1', @@ -214,7 +168,7 @@ class Caddy extends BaseModel if ($httpPort < 1024) { $messages->appendMessage(new Message( gettext( - 'Superuser is disabled, HTTP port must not be empty and must be 1024 or above.' + 'www user is active, HTTP port must not be empty and must be 1024 or above.' ), "general.HttpPort" )); @@ -224,7 +178,7 @@ class Caddy extends BaseModel if ($httpsPort < 1024) { $messages->appendMessage(new Message( gettext( - 'Superuser is disabled, HTTPS port must not be empty and must be 1024 or above.' + 'www user is active, HTTPS port must not be empty and must be 1024 or above.' ), "general.HttpsPort" )); @@ -237,7 +191,7 @@ class Caddy extends BaseModel if ($fromPort !== null && $fromPort < 1024) { $messages->appendMessage(new Message( gettext( - 'Superuser is disabled, port must be empty or must be 1024 or above.' + 'www user is active, port must be empty or must be 1024 or above.' ), $item->__reference . ".FromPort" )); @@ -249,36 +203,111 @@ class Caddy extends BaseModel )); } } + + foreach ($this->reverseproxy->layer4->iterateItems() as $item) { + $fromPort = !empty((string)$item->FromPort) ? (string)$item->FromPort : null; + + if ($fromPort !== null && $fromPort < 1024) { + $messages->appendMessage(new Message( + gettext( + 'www user is active, port must be empty or must be 1024 or above.' + ), + $item->__reference . ".FromPort" + )); + $messages->appendMessage(new Message( + gettext( + 'Ports in "Reverse Proxy - Layer4 Routes" must be empty or must be 1024 or above.' + ), + "general.DisableSuperuser" + )); + } + } } } - /** - * Check that when certain Layer4 matchers are selected, only "*" is valid as FromDomain. - * This happens because they cannot be matched by host header or SNI, so they match all traffic. - * The "*" shows the user that all traffic will be matched, and that creating multiple - * matchers will not result in more routes for the same traffic type to work. - */ private function checkLayer4Matchers($messages) { foreach ($this->reverseproxy->layer4->iterateItems() as $item) { - $matchers = (string) $item->Matchers; - $fromDomain = (string) $item->FromDomain; - - // Check if matchers is not in the list of specific values - $isNotInSpecificMatchers = !in_array($matchers, ['httphost', 'tlssni', 'nottlssni']); - $isInvalidFromDomain = $fromDomain !== '*'; - - if ($isNotInSpecificMatchers && $isInvalidFromDomain) { + if ($item->isFieldChanged()) { $key = $item->__reference; - $messages->appendMessage(new Message( - sprintf( - gettext( - 'When "%s" matcher is selected, the only valid entry in Domain is "*".' + if (in_array((string)$item->Matchers, ['httphost', 'tlssni']) && empty((string)$item->FromDomain)) { + $messages->appendMessage(new Message( + sprintf( + gettext( + 'When "%s" matcher is selected, domain is required.' + ), + $item->Matchers ), - $matchers - ), - $key . ".FromDomain" - )); + $key . ".FromDomain" + )); + } elseif ( + !in_array((string)$item->Matchers, ['httphost', 'tlssni']) && + ( + !empty((string)$item->FromDomain) && + (string)$item->FromDomain != '*' + ) + ) { + $messages->appendMessage(new Message( + sprintf( + gettext( + 'When "%s" matcher is selected, domain must be empty or *.' + ), + $item->Matchers + ), + $key . ".FromDomain" + )); + } + + if ((string)$item->Type === 'global' && empty((string)$item->FromPort)) { + $messages->appendMessage(new Message( + sprintf( + gettext( + 'When routing type is "%s", port is required.' + ), + $item->Type + ), + $key . ".FromPort" + )); + } elseif ((string)$item->Type !== 'global' && !empty((string)$item->FromPort)) { + $messages->appendMessage(new Message( + sprintf( + gettext( + 'When routing type is "%s", port must be empty.' + ), + $item->Type + ), + $key . ".FromPort" + )); + } + + if ((string)$item->Type !== 'global' && ((string)$item->Protocol !== 'tcp')) { + $messages->appendMessage(new Message( + sprintf( + gettext( + 'When routing type is "%s", protocol must be TCP.' + ), + $item->Type + ), + $key . ".Protocol" + )); + } + + if ((string)$item->Type !== 'global' && + ( + (string)$item->Matchers == 'tls' || + (string)$item->Matchers == 'http' + ) + ) { + $messages->appendMessage(new Message( + sprintf( + gettext( + 'When routing type is "%s", matchers "HTTP" or "TLS" cannot be chosen.' + ), + $item->Type + ), + $key . ".Matchers" + )); + } } } } @@ -288,29 +317,10 @@ class Caddy extends BaseModel { $messages = parent::performValidation($validateFullModel); - // Check domain-port combinations - $this->checkForUniquePortCombos( - $this->reverseproxy->reverse->iterateItems(), - $messages - ); - - // Check that subdomains are under a wildcard or exact domain - $this->checkSubdomainsAgainstDomains( - $this->reverseproxy->subdomain->iterateItems(), - $this->reverseproxy->reverse->iterateItems(), - $messages - ); - - // Check WebGUI conflicts + $this->checkForUniquePortCombos($messages); $this->checkWebGuiSettings($messages); - - // Check for TLS conflicts in Domain $this->checkDisableTlsConflicts($messages); - - // Check DisableSuperuser Port conflicts $this->checkSuperuserPorts($messages); - - // Check Layer4 matchers $this->checkLayer4Matchers($messages); return $messages; diff --git a/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.xml b/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.xml index c663b3960..8e357ad8e 100644 --- a/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.xml +++ b/www/caddy/src/opnsense/mvc/app/models/OPNsense/Caddy/Caddy.xml @@ -1,7 +1,7 @@ //Pischem/caddy Caddy Reverse Proxy - 1.3.2 + 1.3.3 @@ -453,8 +453,35 @@ 1 Y - + + 1 + 99999 + Please enter a value between 1 and 99999 or leave empty. + + + Sequence value has to be unique or empty. + UniqueConstraint + + + + Y + listener_wrappers + + listener_wrappers + global + + + + Y + tcp + + TCP + UDP + + + + N Y Y @@ -466,7 +493,9 @@ Y tlssni + ANY DNS + HTTP HTTP (Host Header) Postgres Proxy Protocol @@ -474,11 +503,13 @@ SOCKSv4 SOCKSv5 SSH - TLS (SNI) - TLS (inverted SNI) + TLS + TLS (SNI Client Hello) + Wireguard XMPP + Y , diff --git a/www/caddy/src/opnsense/mvc/app/views/OPNsense/Caddy/reverse_proxy.volt b/www/caddy/src/opnsense/mvc/app/views/OPNsense/Caddy/reverse_proxy.volt index ee1b2f5ab..cd7cb0dc3 100644 --- a/www/caddy/src/opnsense/mvc/app/views/OPNsense/Caddy/reverse_proxy.volt +++ b/www/caddy/src/opnsense/mvc/app/views/OPNsense/Caddy/reverse_proxy.volt @@ -339,6 +339,14 @@ } }); + $("#layer4\\.Matchers").change(function() { + if ($(this).val() !== "tlssni" && $(this).val() !== "httphost") { + $(".style_matchers").closest('tr').hide(); + } else { + $(".style_matchers").closest('tr').show(); + } + }); + // Initialize tabs, service control and filter selectpicker initializeTabs(); updateServiceControlUI('caddy'); @@ -617,8 +625,13 @@ {{ lang._('ID') }} {{ lang._('Enabled') }} + {{ lang._('Sequence') }} + {{ lang._('Routing Type') }} + {{ lang._('Protocol') }} + {{ lang._('Local Port') }} {{ lang._('Domain') }} - {{ lang._('Matcher') }} + {{ lang._('Matchers') }} + {{ lang._('Invert Matchers') }} {{ lang._('Upstream Domain') }} {{ lang._('Upstream Port') }} {{ lang._('Remote IP') }} diff --git a/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/Caddyfile b/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/Caddyfile index 0fd8fc9fd..62f47f65d 100644 --- a/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/Caddyfile +++ b/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/Caddyfile @@ -90,13 +90,27 @@ {% endif %} {% if generalSettings.EnableLayer4|default("0") == "1" %} listener_wrappers { - {# Plug the Layer 4 template in #} - {% include "OPNsense/Caddy/includeLayer4" %} - + layer4 { + import /usr/local/etc/caddy/caddy.d/*.layer4listener + {% set context_var = "listener_wrappers" %} + {% include "OPNsense/Caddy/includeLayer4" %} + {# Empty Route that catches all other traffic #} + route + } + {# Route all other traffic to HTTP App #} + tls } {% endif %} } + {% if generalSettings.EnableLayer4|default("0") == "1" %} + layer4 { + import /usr/local/etc/caddy/caddy.d/*.layer4global + {% set context_var = "global" %} + {% include "OPNsense/Caddy/includeLayer4" %} + } + {% endif %} + {# # Section: Dynamic DNS Global Configuration # Purpose: Sets up global configuration for Dynamic DNS. Caddy needs to be compiled with diff --git a/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/includeLayer4 b/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/includeLayer4 index 7eb367b6b..defaf2528 100644 --- a/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/includeLayer4 +++ b/www/caddy/src/opnsense/service/templates/OPNsense/Caddy/includeLayer4 @@ -1,15 +1,31 @@ {# -# This file sets up the listener_wrapper for layer 4 routing support. -# - Section: Servers Global Configuration -# Also allows for custom configurations with the import statement. +# This file sets up layer4 routing support. +# There are two contexts: "listener_wrappers" and "global" +# +# "listener_wrappers" multiplexes on OSI Layer 7 on the default HTTP and HTTPS ports and requires a traffic matcher since +# otherwise the "reverse_proxy" would stop receiving any requests. The "any" Layer 7 matcher is not allowed here. +# This context allows for matching domains via SNI and route them without terminating TLS. +# +# "global" can set up custom ports and also route any OSI Layer 4 TCP/UDP traffic without a matcher. +# They will be grouped under the same protocol/port combination +# to allow multiple Layer 7 matchers inside the scope of the same Layer 4 matcher. +# This context is for advanced usecases where raw TCP/UDP traffic on custom ports should be proxied or load balanced. #} -{% set layer4_configs = helpers.toList('Pischem.caddy.reverseproxy.layer4') %} -{# Nested Macro for proxy definition #} -{% macro define_proxy(to_domains, to_port, fail_duration, proxy_protocol) %} +{% set unsorted_layer4_configs = helpers.toList('Pischem.caddy.reverseproxy.layer4') %} + +{# Ensure that 'Sequence' is present and converted to an integer in each item #} +{% for item in unsorted_layer4_configs %} + {% set _ = item.update({'Sequence': item.get('Sequence', '0') | int}) %} +{% endfor %} + +{# Sort the configurations based on 'Sequence' #} +{% set layer4_configs = unsorted_layer4_configs | sort(attribute='Sequence') %} + +{% macro define_proxy(layer4, to_domains, to_port, fail_duration, proxy_protocol) %} proxy {% for domain in to_domains.split(',') %} - {% set is_ipv6 = (':' in domain) %} {# Check if the domain contains a colon, typical in IPv6 addresses #} - {{ '[' if is_ipv6 }}{{ domain }}{{ ']' if is_ipv6 }}:{{ to_port }}{% if not loop.last %} {% endif %} + {% set is_ipv6 = (':' in domain) %} + {{ layer4.Protocol }}/{{ '[' if is_ipv6 }}{{ domain }}{{ ']' if is_ipv6 }}:{{ to_port }}{% if not loop.last %} {% endif %} {% endfor %} { {% if fail_duration %} fail_duration {{ fail_duration }}s @@ -20,64 +36,73 @@ } {% endmacro %} -{# Macro for configuring the proxy with additional remote IP access list #} -{% macro configure_proxy(to_domains, to_port, remote_ips, fail_duration, proxy_protocol) %} - {% if remote_ips %} - {% set ip_list = remote_ips.split(',') %} - subroute { - @allowed_ips remote_ip {{ ip_list|join(' ') }} - route @allowed_ips { - {# Call Nested Macro #} - {{ define_proxy(to_domains, to_port, fail_duration, proxy_protocol) }} +{% macro configure_proxy(layer4, to_domains, to_port, remote_ips, fail_duration, proxy_protocol) %} + {% set content %} + {% if remote_ips %} + {% set ip_list = remote_ips.split(',') %} + subroute { + @allowed_ips remote_ip {{ ip_list|join(' ') }} + route @allowed_ips { + {{ define_proxy(layer4, to_domains, to_port, fail_duration, proxy_protocol) }} + } } - } + {% else %} + {{ define_proxy(layer4, to_domains, to_port, fail_duration, proxy_protocol) }} + {% endif %} + {% endset %} + {{ content|trim }} +{% endmacro %} + +{% set grouped_configs = {} %} +{% for layer4 in layer4_configs %} + {% if layer4.FromPort and layer4.Protocol and layer4.enabled == "1" %} + {% set key = layer4.Protocol ~ '/:' ~ layer4.FromPort %} + {% if not key in grouped_configs %} + {% set _ = grouped_configs.update({key: []}) %} + {% endif %} + {% set _ = grouped_configs[key].append(layer4) %} + {% endif %} +{% endfor %} + +{% macro handle_special_matchers(layer4) %} + {% set invert_prefix = 'not ' if layer4.InvertMatchers == '1' else '' %} + {% if layer4.Matchers == 'httphost' %} + {{ invert_prefix }}http host {{ layer4.FromDomain.replace(',', ' ') }} + {% elif layer4.Matchers == 'tlssni' %} + {{ invert_prefix }}tls sni {{ layer4.FromDomain.replace(',', ' ') }} {% else %} - {# Call Nested Macro #} - {{ define_proxy(to_domains, to_port, fail_duration, proxy_protocol) }} + {{ invert_prefix }}{{ layer4.Matchers }} {% endif %} {% endmacro %} -{# Set up Layer4 App #} -layer4 { - import /usr/local/etc/caddy/caddy.d/*.layer4 - {# 1. loop to handle any traffic matchers that can only be added once since they match all specific protocol traffic. #} +{% if context_var == 'listener_wrappers' %} {% for layer4 in layer4_configs %} - {% if layer4.enabled == "1" and layer4.Matchers not in ['httphost', 'tlssni', 'nottlssni'] %} - @{{ layer4['@uuid'] }} {{ layer4.Matchers }} - route @{{ layer4['@uuid'] }} { - {{ configure_proxy(layer4.ToDomain, layer4.ToPort, layer4.RemoteIp, layer4.PassiveHealthFailDuration, layer4.ProxyProtocol) }} - } + {% if layer4.enabled == "1" and layer4.Type == 'listener_wrappers' %} + {% if layer4.Matchers != 'any' %} + @{{ layer4['@uuid'] }} {{ handle_special_matchers(layer4) }} + route @{{ layer4['@uuid'] }} { + {{ configure_proxy(layer4, layer4.ToDomain, layer4.ToPort, layer4.RemoteIp, layer4.PassiveHealthFailDuration, layer4.ProxyProtocol) }} + } + {% endif %} {% endif %} {% endfor %} - {# 2. loop to handle http host matchers #} - {% for layer4 in layer4_configs %} - {% if layer4.enabled == "1" and layer4.Matchers == 'httphost' %} - @{{ layer4['@uuid'] }} http host {{ layer4.FromDomain.replace(',', ' ') }} - route @{{ layer4['@uuid'] }} { - {{ configure_proxy(layer4.ToDomain, layer4.ToPort, layer4.RemoteIp, layer4.PassiveHealthFailDuration, layer4.ProxyProtocol) }} - } - {% endif %} +{% elif context_var == 'global' %} + {% for key, layers in grouped_configs.items() %} + {{ key }} { + {% for layer4 in layers %} + {% if layer4.enabled == "1" and layer4.Type == 'global' %} + {% if layer4.Matchers != 'any' %} + @{{ layer4['@uuid'] }} {{ handle_special_matchers(layer4) }} + route @{{ layer4['@uuid'] }} { + {{ configure_proxy(layer4, layer4.ToDomain, layer4.ToPort, layer4.RemoteIp, layer4.PassiveHealthFailDuration, layer4.ProxyProtocol) }} + } + {% else %} + route { + {{ configure_proxy(layer4, layer4.ToDomain, layer4.ToPort, layer4.RemoteIp, layer4.PassiveHealthFailDuration, layer4.ProxyProtocol) }} + } + {% endif %} + {% endif %} + {% endfor %} + } {% endfor %} - {# 3. loop to handle tls sni matchers #} - {% for layer4 in layer4_configs %} - {% if layer4.enabled == "1" and layer4.Matchers == 'tlssni' %} - @{{ layer4['@uuid'] }} tls sni {{ layer4.FromDomain.replace(',', ' ') }} - route @{{ layer4['@uuid'] }} { - {{ configure_proxy(layer4.ToDomain, layer4.ToPort, layer4.RemoteIp, layer4.PassiveHealthFailDuration, layer4.ProxyProtocol) }} - } - {% endif %} - {% endfor %} - {# 4. loop to handle not tls sni matchers #} - {% for layer4 in layer4_configs %} - {% if layer4.enabled == "1" and layer4.Matchers == 'nottlssni' %} - @{{ layer4['@uuid'] }} not tls sni {{ layer4.FromDomain.replace(',', ' ') }} - route @{{ layer4['@uuid'] }} { - {{ configure_proxy(layer4.ToDomain, layer4.ToPort, layer4.RemoteIp, layer4.PassiveHealthFailDuration, layer4.ProxyProtocol) }} - } - {% endif %} - {% endfor %} - {# Empty Route that catches all other traffic #} - route -} -{# Route all other traffic to HTTP App #} -tls +{% endif %}