Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cda4288
Migrate to UV+Ruff
weiiwang01 Nov 12, 2025
8a4aebf
Fix linting issues found by ruff
weiiwang01 Nov 12, 2025
c05298a
Update .licenserc.yaml
weiiwang01 Nov 12, 2025
5b6c9ff
Add git as build package
weiiwang01 Nov 12, 2025
6284bad
Apply suggestions from review comments
weiiwang01 Nov 12, 2025
05cbf4a
Apply suggestions from review comments
weiiwang01 Nov 12, 2025
ac072db
add `allow_http` attribute, update test, improve rendering logic
Thanhphan1147 Nov 12, 2025
33cfcf6
update spelling
Thanhphan1147 Nov 13, 2025
fb2ee66
add haproxy-spoe-auth snap (#224)
Thanhphan1147 Nov 12, 2025
5de433f
specify snap folder for build step (#226)
Thanhphan1147 Nov 12, 2025
aeebb35
Get network information from relation endpoint binding (#223)
Thanhphan1147 Nov 12, 2025
920e8d3
chore: update charm libraries (#206)
github-actions[bot] Nov 13, 2025
08fc285
update integration test to test for allow HTTP
Thanhphan1147 Nov 18, 2025
968697b
Merge branch 'main' into allow_enabling_http_for_haproxy_route_backend
Thanhphan1147 Nov 18, 2025
d454a40
update test to reduce depth
Thanhphan1147 Nov 20, 2025
08d03e6
add change artifact file
Thanhphan1147 Nov 20, 2025
43814ba
Merge branch 'main' into allow_enabling_http_for_haproxy_route_backend
Thanhphan1147 Nov 20, 2025
de9890c
Merge branch 'main' into allow_enabling_http_for_haproxy_route_backend
Thanhphan1147 Nov 21, 2025
1273b10
Merge branch 'main' into allow_enabling_http_for_haproxy_route_backend
Thanhphan1147 Nov 24, 2025
3ed6c62
update name for acl attribute
Thanhphan1147 Nov 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
14 changes: 14 additions & 0 deletions docs/release-notes/artifacts/pr0230.yaml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 27 additions & 1 deletion lib/charms/haproxy/v1/haproxy_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -1023,6 +1032,7 @@ def __init__(
queue_timeout,
server_maxconn,
http_server_close,
allow_http,
)
self._unit_address = unit_address

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1148,6 +1162,7 @@ def provide_haproxy_route_requirements(
queue_timeout,
server_maxconn,
http_server_close,
allow_http,
)
self.update_relation_data()

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
):
Expand Down
1 change: 1 addition & 0 deletions src/haproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions src/state/haproxy_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion templates/haproxy_route.cfg.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
9 changes: 9 additions & 0 deletions tests/integration/test_haproxy_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def test_haproxy_route_protocol_https(
"load_balancing_consistent_hashing": True,
"http_server_close": True,
"protocol": "https",
"allow_http": True,
}
]
),
Expand All @@ -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!"
122 changes: 122 additions & 0 deletions tests/unit/test_haproxy_route.py
Original file line number Diff line number Diff line change
@@ -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 }"
]
Loading