Skip to content

Commit 4a06734

Browse files
Allow enabling http for haproxy route backend (#230)
1 parent 5a4077c commit 4a06734

8 files changed

Lines changed: 198 additions & 4 deletions

File tree

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ Each revision is versioned by the date of the revision.
1010

1111
- Added GitHub workflow that checks whether a pull request contains a change artifact.
1212

13+
## 2025-11-12
14+
15+
- Updated the haproxy-route library to add the `allow_http` attribute.
16+
1317
## 2025-10-14
1418

1519
- Added action `get-proxied-endpoints`.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
schema_version: 1
2+
changes:
3+
- title: Allow enabling http for haproxy route backend.
4+
author: tphan025
5+
type: minor
6+
description: |
7+
Add a new allow_http attribute to allow disabling mandatory HTTPS redirection for backends.
8+
Add logic to build the required ACL and rendering logic in the j2 template.
9+
urls:
10+
pr: https://github.com/canonical/haproxy-operator/pull/230
11+
related_doc:
12+
related_issue:
13+
visibility: public
14+
highlight: false

lib/charms/haproxy/v1/haproxy_route.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def _on_haproxy_route_data_available(self, event: EventBase) -> None:
151151

152152
# Increment this PATCH version before using `charmcraft publish-lib` or reset
153153
# to 0 if you are raising the major API version
154-
LIBPATCH = 8
154+
LIBPATCH = 9
155155

156156
logger = logging.getLogger(__name__)
157157
HAPROXY_ROUTE_RELATION_NAME = "haproxy-route"
@@ -538,6 +538,8 @@ class RequirerApplicationData(_DatabagModel):
538538
timeout: Configuration for server, client, and queue timeouts.
539539
server_maxconn: Optional maximum number of connections per server.
540540
http_server_close: Configure server close after request.
541+
allow_http: Whether to allow HTTP traffic in addition to HTTPS. Defaults to False.
542+
Warning: enabling HTTP is a security risk, make sure you apply the necessary precautions.
541543
"""
542544

543545
service: VALIDSTR = Field(description="The name of the service.")
@@ -589,6 +591,9 @@ class RequirerApplicationData(_DatabagModel):
589591
http_server_close: bool = Field(
590592
description="Configure server close after request", default=False
591593
)
594+
allow_http: bool = Field(
595+
description="Whether to allow HTTP traffic in addition to HTTPS.", default=False
596+
)
592597

593598
@field_validator("load_balancing")
594599
@classmethod
@@ -945,6 +950,7 @@ def __init__(
945950
server_maxconn: Optional[int] = None,
946951
unit_address: Optional[str] = None,
947952
http_server_close: bool = False,
953+
allow_http: bool = False,
948954
) -> None:
949955
"""Initialize the HaproxyRouteRequirer.
950956
@@ -983,6 +989,9 @@ def __init__(
983989
server_maxconn: Maximum connections per server.
984990
unit_address: IP address of the unit (if not provided, will use binding address).
985991
http_server_close: Configure server close after request.
992+
allow_http: Whether to allow HTTP traffic in addition to HTTPS.
993+
Warning: enabling HTTP is a security risk,
994+
make sure you apply the necessary precautions.
986995
"""
987996
super().__init__(charm, relation_name)
988997

@@ -1023,6 +1032,7 @@ def __init__(
10231032
queue_timeout,
10241033
server_maxconn,
10251034
http_server_close,
1035+
allow_http,
10261036
)
10271037
self._unit_address = unit_address
10281038

@@ -1079,6 +1089,7 @@ def provide_haproxy_route_requirements(
10791089
server_maxconn: Optional[int] = None,
10801090
unit_address: Optional[str] = None,
10811091
http_server_close: bool = False,
1092+
allow_http: bool = False,
10821093
) -> None:
10831094
"""Update haproxy-route requirements data in the relation.
10841095
@@ -1115,6 +1126,9 @@ def provide_haproxy_route_requirements(
11151126
server_maxconn: Maximum connections per server.
11161127
unit_address: IP address of the unit (if not provided, will use binding address).
11171128
http_server_close: Configure server close after request.
1129+
allow_http: Whether to allow HTTP traffic in addition to HTTPS.
1130+
Warning: enabling HTTP is a security risk,
1131+
make sure you apply the necessary precautions.
11181132
"""
11191133
self._unit_address = unit_address
11201134
self._application_data = self._generate_application_data(
@@ -1148,6 +1162,7 @@ def provide_haproxy_route_requirements(
11481162
queue_timeout,
11491163
server_maxconn,
11501164
http_server_close,
1165+
allow_http,
11511166
)
11521167
self.update_relation_data()
11531168

@@ -1184,6 +1199,7 @@ def _generate_application_data( # noqa: C901
11841199
queue_timeout: int = 60,
11851200
server_maxconn: Optional[int] = None,
11861201
http_server_close: bool = False,
1202+
allow_http: bool = False,
11871203
) -> dict[str, Any]:
11881204
"""Generate the complete application data structure.
11891205
@@ -1219,6 +1235,9 @@ def _generate_application_data( # noqa: C901
12191235
queue_timeout: Timeout for requests waiting in queue in seconds.
12201236
server_maxconn: Maximum connections per server.
12211237
http_server_close: Configure server close after request.
1238+
allow_http: Whether to allow HTTP traffic in addition to HTTPS.
1239+
Warning: enabling HTTP is a security risk,
1240+
make sure you apply the necessary precautions.
12221241
12231242
Returns:
12241243
dict: A dictionary containing the complete application data structure.
@@ -1271,8 +1290,15 @@ def _generate_application_data( # noqa: C901
12711290
header_rewrite_expressions,
12721291
),
12731292
"http_server_close": http_server_close,
1293+
"allow_http": allow_http,
12741294
}
12751295

1296+
if allow_http:
1297+
logger.warning(
1298+
"HTTP traffic is allowed alongside HTTPS. "
1299+
"This is a security risk, make sure you apply the necessary precautions."
1300+
)
1301+
12761302
if check := self._generate_server_healthcheck_configuration(
12771303
check_interval, check_rise, check_fall, check_path, check_port
12781304
):

src/haproxy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def reconcile_haproxy_route(
163163
"peer_units_address": haproxy_route_requirers_information.peers,
164164
"haproxy_crt_dir": HAPROXY_CERTS_DIR,
165165
"haproxy_cas_file": HAPROXY_CAS_FILE,
166+
"acls_for_allow_http": haproxy_route_requirers_information.acls_for_allow_http,
166167
}
167168
template = (
168169
HAPROXY_ROUTE_TCP_CONFIG_TEMPLATE

src/state/haproxy_route.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,16 +310,34 @@ def check_backend_paths(self) -> Self:
310310
if len(requirers_paths) != len(set(requirers_paths)):
311311
logger.warning(
312312
"Requirers defined path(s) that map to multiple backends."
313-
"This can cause unintended behaviours."
313+
"This can cause unintended behaviors."
314314
)
315315

316316
if len(requirers_hostnames) != len(set(requirers_hostnames)):
317317
logger.warning(
318318
"Requirers defined hostname(s) that map to multiple backends."
319-
"This can cause unintended behaviours."
319+
"This can cause unintended behaviors."
320320
)
321321
return self
322322

323+
@property
324+
def acls_for_allow_http(self) -> list[str]:
325+
"""Get the list of all allow_http ACLs from all backends.
326+
327+
Returns:
328+
list[str]: List of allow_http ACLs.
329+
"""
330+
allow_http_acls: list[str] = []
331+
for backend in self.backends:
332+
if backend.application_data.allow_http:
333+
acl = f"{{ req.hdr(Host) -m str {' '.join(backend.hostname_acls)} }}"
334+
if backend.path_acl_required:
335+
acl += f" {{ path_beg -i {' '.join(backend.application_data.paths)} }}"
336+
if backend.deny_path_acl_required:
337+
acl += f" !{{ path_beg -i {' '.join(backend.application_data.deny_paths)} }}"
338+
allow_http_acls.append(acl)
339+
return allow_http_acls
340+
323341

324342
def get_servers_definition_from_requirer_data(
325343
requirer: HaproxyRouteRequirerData,

templates/haproxy_route.cfg.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ frontend haproxy
66
bind [::]:80 v4v6
77
bind [::]:443 v4v6 ssl crt {{ haproxy_crt_dir }}
88
# Redirect HTTP to HTTPS
9-
http-request redirect scheme https unless { ssl_fc }
9+
http-request redirect scheme https unless { ssl_fc } {% for acl in acls_for_allow_http %} || {{ acl }}{% endfor %}
1010
{% endif %}
1111

1212
{% for backend in backends %}

tests/integration/test_haproxy_route.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def test_haproxy_route_protocol_https(
132132
"load_balancing_consistent_hashing": True,
133133
"http_server_close": True,
134134
"protocol": "https",
135+
"allow_http": True,
135136
}
136137
]
137138
),
@@ -153,3 +154,11 @@ def test_haproxy_route_protocol_https(
153154
verify=False, # nosec: B501
154155
)
155156
assert response.text == "ok!"
157+
158+
# Make HTTP request to verify allow_http works
159+
response = requests.get(
160+
f"http://{haproxy_ip_address}",
161+
headers={"Host": TEST_EXTERNAL_HOSTNAME_CONFIG},
162+
timeout=5,
163+
)
164+
assert response.text == "ok!"

tests/unit/test_haproxy_route.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""Unit tests for haproxy-route interface library HaproxyRouteRequirerData."""
5+
6+
import ipaddress
7+
import typing
8+
from unittest.mock import MagicMock
9+
10+
import pytest
11+
from charms.haproxy.v0.haproxy_route_tcp import HaproxyRouteTcpRequirersData
12+
from charms.haproxy.v1.haproxy_route import (
13+
HaproxyRouteRequirerData,
14+
HaproxyRouteRequirersData,
15+
RequirerApplicationData,
16+
RequirerUnitData,
17+
)
18+
19+
from state.haproxy_route import HaproxyRouteRequirersInformation
20+
21+
22+
@pytest.fixture(name="mock_requirer_app_data_with_allow_http")
23+
def mock_requirer_app_data_with_allow_http_fixture():
24+
"""Create mock requirer application data with allow_http enabled."""
25+
return RequirerApplicationData(
26+
service="test-service",
27+
ports=[8080],
28+
hosts=[ipaddress.ip_address("10.0.0.1")],
29+
allow_http=True,
30+
)
31+
32+
33+
@pytest.fixture(name="mock_haproxy_route_requirer_data")
34+
def mock_haproxy_route_requirer_data_fixture():
35+
"""Create mock requirer application data with allow_http enabled."""
36+
return HaproxyRouteRequirerData(
37+
relation_id=1,
38+
application_data=typing.cast(
39+
RequirerApplicationData,
40+
RequirerApplicationData.from_dict(
41+
{
42+
"service": "service",
43+
"ports": [80],
44+
"allow_http": True,
45+
"hostname": "example.com",
46+
"paths": ["/path"],
47+
"deny_paths": ["/private"],
48+
}
49+
),
50+
),
51+
units_data=[
52+
typing.cast(RequirerUnitData, RequirerUnitData.from_dict({"address": "10.0.0.1"}))
53+
],
54+
)
55+
56+
57+
@pytest.fixture(name="mock_haproxy_route_relation_data")
58+
def mock_haproxy_route_relation_data_fixture(
59+
mock_haproxy_route_requirer_data: HaproxyRouteRequirerData,
60+
) -> HaproxyRouteRequirersData:
61+
"""Create mock requirer application data with allow_http enabled."""
62+
return HaproxyRouteRequirersData(
63+
requirers_data=[mock_haproxy_route_requirer_data],
64+
relation_ids_with_invalid_data=[],
65+
)
66+
67+
68+
def test_requirer_application_data_allow_http_default_is_false():
69+
"""
70+
arrange: Create a RequirerApplicationData model without specifying allow_http.
71+
act: Check the allow_http value.
72+
assert: allow_http defaults to False.
73+
"""
74+
data = RequirerApplicationData(
75+
service="test-service",
76+
ports=[8080],
77+
)
78+
79+
assert data.allow_http is False
80+
81+
82+
def test_haproxy_route_requirer_data_with_allow_http_true(mock_requirer_app_data_with_allow_http):
83+
"""
84+
arrange: Create a HaproxyRouteRequirerData with RequirerApplicationData having allow_http=True.
85+
act: Instantiate HaproxyRouteRequirerData.
86+
assert: Object is created successfully and allow_http is True.
87+
"""
88+
requirer_data = HaproxyRouteRequirerData(
89+
relation_id=2,
90+
application_data=mock_requirer_app_data_with_allow_http,
91+
units_data=[RequirerUnitData(address=ipaddress.ip_address("10.0.0.1"))],
92+
)
93+
94+
assert requirer_data.application_data.allow_http is True
95+
96+
97+
def test_haproxy_route_requirer_information(
98+
mock_haproxy_route_relation_data: HaproxyRouteRequirersData,
99+
):
100+
"""
101+
arrange: Setup all relation providers mock.
102+
act: Initialize the charm state.
103+
assert: The proxy mode is correctly set to HAPROXY_ROUTE.
104+
"""
105+
haproxy_route_tcp_provider_mock = MagicMock()
106+
haproxy_route_tcp_provider_mock.get_data = MagicMock(
107+
return_value=HaproxyRouteTcpRequirersData(
108+
requirers_data=[], relation_ids_with_invalid_data=[]
109+
)
110+
)
111+
haproxy_route_provider_mock = MagicMock()
112+
haproxy_route_provider_mock.get_data = MagicMock(return_value=mock_haproxy_route_relation_data)
113+
haproxy_route_information = HaproxyRouteRequirersInformation.from_provider(
114+
haproxy_route=haproxy_route_provider_mock,
115+
haproxy_route_tcp=haproxy_route_tcp_provider_mock,
116+
external_hostname=None,
117+
peers=[],
118+
ca_certs_configured=False,
119+
)
120+
assert haproxy_route_information.acls_for_allow_http == [
121+
"{ req.hdr(Host) -m str example.com } { path_beg -i /path } !{ path_beg -i /private }"
122+
]

0 commit comments

Comments
 (0)