diff --git a/docs/changelog.md b/docs/changelog.md index 346c37ce8..82a2e1817 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,10 @@ Each revision is versioned by the date of the revision. - Added GitHub workflow that checks whether a pull request contains a change artifact. +## 2025-11-12 + +- Updated the haproxy-route library to add the `allow_http` attribute. + ## 2025-10-14 - Added action `get-proxied-endpoints`. diff --git a/docs/release-notes/artifacts/pr0230.yaml b/docs/release-notes/artifacts/pr0230.yaml new file mode 100644 index 000000000..5e20bc4e2 --- /dev/null +++ b/docs/release-notes/artifacts/pr0230.yaml @@ -0,0 +1,14 @@ +schema_version: 1 +changes: + - title: Allow enabling http for haproxy route backend. + author: tphan025 + type: minor + description: | + Add a new allow_http attribute to allow disabling mandatory HTTPS redirection for backends. + Add logic to build the required ACL and rendering logic in the j2 template. + urls: + pr: https://github.com/canonical/haproxy-operator/pull/230 + related_doc: + related_issue: + visibility: public + highlight: false diff --git a/lib/charms/haproxy/v1/haproxy_route.py b/lib/charms/haproxy/v1/haproxy_route.py index 8abb25983..a96d39eec 100644 --- a/lib/charms/haproxy/v1/haproxy_route.py +++ b/lib/charms/haproxy/v1/haproxy_route.py @@ -151,7 +151,7 @@ def _on_haproxy_route_data_available(self, event: EventBase) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 8 +LIBPATCH = 9 logger = logging.getLogger(__name__) HAPROXY_ROUTE_RELATION_NAME = "haproxy-route" @@ -538,6 +538,8 @@ class RequirerApplicationData(_DatabagModel): timeout: Configuration for server, client, and queue timeouts. server_maxconn: Optional maximum number of connections per server. http_server_close: Configure server close after request. + allow_http: Whether to allow HTTP traffic in addition to HTTPS. Defaults to False. + Warning: enabling HTTP is a security risk, make sure you apply the necessary precautions. """ service: VALIDSTR = Field(description="The name of the service.") @@ -589,6 +591,9 @@ class RequirerApplicationData(_DatabagModel): http_server_close: bool = Field( description="Configure server close after request", default=False ) + allow_http: bool = Field( + description="Whether to allow HTTP traffic in addition to HTTPS.", default=False + ) @field_validator("load_balancing") @classmethod @@ -945,6 +950,7 @@ def __init__( server_maxconn: Optional[int] = None, unit_address: Optional[str] = None, http_server_close: bool = False, + allow_http: bool = False, ) -> None: """Initialize the HaproxyRouteRequirer. @@ -983,6 +989,9 @@ def __init__( server_maxconn: Maximum connections per server. unit_address: IP address of the unit (if not provided, will use binding address). http_server_close: Configure server close after request. + allow_http: Whether to allow HTTP traffic in addition to HTTPS. + Warning: enabling HTTP is a security risk, + make sure you apply the necessary precautions. """ super().__init__(charm, relation_name) @@ -1023,6 +1032,7 @@ def __init__( queue_timeout, server_maxconn, http_server_close, + allow_http, ) self._unit_address = unit_address @@ -1079,6 +1089,7 @@ def provide_haproxy_route_requirements( server_maxconn: Optional[int] = None, unit_address: Optional[str] = None, http_server_close: bool = False, + allow_http: bool = False, ) -> None: """Update haproxy-route requirements data in the relation. @@ -1115,6 +1126,9 @@ def provide_haproxy_route_requirements( server_maxconn: Maximum connections per server. unit_address: IP address of the unit (if not provided, will use binding address). http_server_close: Configure server close after request. + allow_http: Whether to allow HTTP traffic in addition to HTTPS. + Warning: enabling HTTP is a security risk, + make sure you apply the necessary precautions. """ self._unit_address = unit_address self._application_data = self._generate_application_data( @@ -1148,6 +1162,7 @@ def provide_haproxy_route_requirements( queue_timeout, server_maxconn, http_server_close, + allow_http, ) self.update_relation_data() @@ -1184,6 +1199,7 @@ def _generate_application_data( # noqa: C901 queue_timeout: int = 60, server_maxconn: Optional[int] = None, http_server_close: bool = False, + allow_http: bool = False, ) -> dict[str, Any]: """Generate the complete application data structure. @@ -1219,6 +1235,9 @@ def _generate_application_data( # noqa: C901 queue_timeout: Timeout for requests waiting in queue in seconds. server_maxconn: Maximum connections per server. http_server_close: Configure server close after request. + allow_http: Whether to allow HTTP traffic in addition to HTTPS. + Warning: enabling HTTP is a security risk, + make sure you apply the necessary precautions. Returns: dict: A dictionary containing the complete application data structure. @@ -1271,8 +1290,15 @@ def _generate_application_data( # noqa: C901 header_rewrite_expressions, ), "http_server_close": http_server_close, + "allow_http": allow_http, } + if allow_http: + logger.warning( + "HTTP traffic is allowed alongside HTTPS. " + "This is a security risk, make sure you apply the necessary precautions." + ) + if check := self._generate_server_healthcheck_configuration( check_interval, check_rise, check_fall, check_path, check_port ): diff --git a/src/haproxy.py b/src/haproxy.py index 9501309b6..fe672f2a3 100644 --- a/src/haproxy.py +++ b/src/haproxy.py @@ -163,6 +163,7 @@ def reconcile_haproxy_route( "peer_units_address": haproxy_route_requirers_information.peers, "haproxy_crt_dir": HAPROXY_CERTS_DIR, "haproxy_cas_file": HAPROXY_CAS_FILE, + "acls_for_allow_http": haproxy_route_requirers_information.acls_for_allow_http, } template = ( HAPROXY_ROUTE_TCP_CONFIG_TEMPLATE diff --git a/src/state/haproxy_route.py b/src/state/haproxy_route.py index a8d4af773..1384b4ac6 100644 --- a/src/state/haproxy_route.py +++ b/src/state/haproxy_route.py @@ -310,16 +310,34 @@ def check_backend_paths(self) -> Self: if len(requirers_paths) != len(set(requirers_paths)): logger.warning( "Requirers defined path(s) that map to multiple backends." - "This can cause unintended behaviours." + "This can cause unintended behaviors." ) if len(requirers_hostnames) != len(set(requirers_hostnames)): logger.warning( "Requirers defined hostname(s) that map to multiple backends." - "This can cause unintended behaviours." + "This can cause unintended behaviors." ) return self + @property + def acls_for_allow_http(self) -> list[str]: + """Get the list of all allow_http ACLs from all backends. + + Returns: + list[str]: List of allow_http ACLs. + """ + allow_http_acls: list[str] = [] + for backend in self.backends: + if backend.application_data.allow_http: + acl = f"{{ req.hdr(Host) -m str {' '.join(backend.hostname_acls)} }}" + if backend.path_acl_required: + acl += f" {{ path_beg -i {' '.join(backend.application_data.paths)} }}" + if backend.deny_path_acl_required: + acl += f" !{{ path_beg -i {' '.join(backend.application_data.deny_paths)} }}" + allow_http_acls.append(acl) + return allow_http_acls + def get_servers_definition_from_requirer_data( requirer: HaproxyRouteRequirerData, diff --git a/templates/haproxy_route.cfg.j2 b/templates/haproxy_route.cfg.j2 index c172129c8..13cb81d5a 100644 --- a/templates/haproxy_route.cfg.j2 +++ b/templates/haproxy_route.cfg.j2 @@ -6,7 +6,7 @@ frontend haproxy bind [::]:80 v4v6 bind [::]:443 v4v6 ssl crt {{ haproxy_crt_dir }} # Redirect HTTP to HTTPS - http-request redirect scheme https unless { ssl_fc } + http-request redirect scheme https unless { ssl_fc } {% for acl in acls_for_allow_http %} || {{ acl }}{% endfor %} {% endif %} {% for backend in backends %} diff --git a/tests/integration/test_haproxy_route.py b/tests/integration/test_haproxy_route.py index bddf67441..19672a60e 100644 --- a/tests/integration/test_haproxy_route.py +++ b/tests/integration/test_haproxy_route.py @@ -132,6 +132,7 @@ def test_haproxy_route_protocol_https( "load_balancing_consistent_hashing": True, "http_server_close": True, "protocol": "https", + "allow_http": True, } ] ), @@ -153,3 +154,11 @@ def test_haproxy_route_protocol_https( verify=False, # nosec: B501 ) assert response.text == "ok!" + + # Make HTTP request to verify allow_http works + response = requests.get( + f"http://{haproxy_ip_address}", + headers={"Host": TEST_EXTERNAL_HOSTNAME_CONFIG}, + timeout=5, + ) + assert response.text == "ok!" diff --git a/tests/unit/test_haproxy_route.py b/tests/unit/test_haproxy_route.py new file mode 100644 index 000000000..39b8d6cf6 --- /dev/null +++ b/tests/unit/test_haproxy_route.py @@ -0,0 +1,122 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for haproxy-route interface library HaproxyRouteRequirerData.""" + +import ipaddress +import typing +from unittest.mock import MagicMock + +import pytest +from charms.haproxy.v0.haproxy_route_tcp import HaproxyRouteTcpRequirersData +from charms.haproxy.v1.haproxy_route import ( + HaproxyRouteRequirerData, + HaproxyRouteRequirersData, + RequirerApplicationData, + RequirerUnitData, +) + +from state.haproxy_route import HaproxyRouteRequirersInformation + + +@pytest.fixture(name="mock_requirer_app_data_with_allow_http") +def mock_requirer_app_data_with_allow_http_fixture(): + """Create mock requirer application data with allow_http enabled.""" + return RequirerApplicationData( + service="test-service", + ports=[8080], + hosts=[ipaddress.ip_address("10.0.0.1")], + allow_http=True, + ) + + +@pytest.fixture(name="mock_haproxy_route_requirer_data") +def mock_haproxy_route_requirer_data_fixture(): + """Create mock requirer application data with allow_http enabled.""" + return HaproxyRouteRequirerData( + relation_id=1, + application_data=typing.cast( + RequirerApplicationData, + RequirerApplicationData.from_dict( + { + "service": "service", + "ports": [80], + "allow_http": True, + "hostname": "example.com", + "paths": ["/path"], + "deny_paths": ["/private"], + } + ), + ), + units_data=[ + typing.cast(RequirerUnitData, RequirerUnitData.from_dict({"address": "10.0.0.1"})) + ], + ) + + +@pytest.fixture(name="mock_haproxy_route_relation_data") +def mock_haproxy_route_relation_data_fixture( + mock_haproxy_route_requirer_data: HaproxyRouteRequirerData, +) -> HaproxyRouteRequirersData: + """Create mock requirer application data with allow_http enabled.""" + return HaproxyRouteRequirersData( + requirers_data=[mock_haproxy_route_requirer_data], + relation_ids_with_invalid_data=[], + ) + + +def test_requirer_application_data_allow_http_default_is_false(): + """ + arrange: Create a RequirerApplicationData model without specifying allow_http. + act: Check the allow_http value. + assert: allow_http defaults to False. + """ + data = RequirerApplicationData( + service="test-service", + ports=[8080], + ) + + assert data.allow_http is False + + +def test_haproxy_route_requirer_data_with_allow_http_true(mock_requirer_app_data_with_allow_http): + """ + arrange: Create a HaproxyRouteRequirerData with RequirerApplicationData having allow_http=True. + act: Instantiate HaproxyRouteRequirerData. + assert: Object is created successfully and allow_http is True. + """ + requirer_data = HaproxyRouteRequirerData( + relation_id=2, + application_data=mock_requirer_app_data_with_allow_http, + units_data=[RequirerUnitData(address=ipaddress.ip_address("10.0.0.1"))], + ) + + assert requirer_data.application_data.allow_http is True + + +def test_haproxy_route_requirer_information( + mock_haproxy_route_relation_data: HaproxyRouteRequirersData, +): + """ + arrange: Setup all relation providers mock. + act: Initialize the charm state. + assert: The proxy mode is correctly set to HAPROXY_ROUTE. + """ + haproxy_route_tcp_provider_mock = MagicMock() + haproxy_route_tcp_provider_mock.get_data = MagicMock( + return_value=HaproxyRouteTcpRequirersData( + requirers_data=[], relation_ids_with_invalid_data=[] + ) + ) + haproxy_route_provider_mock = MagicMock() + haproxy_route_provider_mock.get_data = MagicMock(return_value=mock_haproxy_route_relation_data) + haproxy_route_information = HaproxyRouteRequirersInformation.from_provider( + haproxy_route=haproxy_route_provider_mock, + haproxy_route_tcp=haproxy_route_tcp_provider_mock, + external_hostname=None, + peers=[], + ca_certs_configured=False, + ) + assert haproxy_route_information.acls_for_allow_http == [ + "{ req.hdr(Host) -m str example.com } { path_beg -i /path } !{ path_beg -i /private }" + ]