diff --git a/haproxy-operator/src/charm.py b/haproxy-operator/src/charm.py index 4142529f0..45472193d 100755 --- a/haproxy-operator/src/charm.py +++ b/haproxy-operator/src/charm.py @@ -135,7 +135,7 @@ def __init__(self, *args: typing.Any): self.haproxy_route_provider.on.data_available, self.haproxy_route_provider.on.data_removed, ], - mode=Mode.UNIT, + mode=Mode.APP, ) self._tls = TLSRelationService(self.model, self.certificates, self.recv_ca_certs) @@ -210,6 +210,9 @@ def __init__(self, *args: typing.Any): self.framework.observe( self.on[DDOS_PROTECTION_RELATION_NAME].relation_broken, self._on_config_changed ) + self.framework.observe( + self.on[HAPROXY_PEER_INTEGRATION].relation_changed, self._on_config_changed + ) @validate_config_and_tls(defer=False) def _on_install(self, _: typing.Any) -> None: @@ -292,14 +295,26 @@ def _reconcile(self) -> None: self._configure_haproxy_route(charm_state, ha_information) case _: if self.model.get_relation(TLS_CERT_RELATION): - # Reconcile certificates in case the certificates relation is present tls_information = TLSInformation.from_charm(self, self.certificates) - self._tls.certificate_available(tls_information) + if tls_information: + self._reconcile_certificates(tls_information) self.unit.set_ports(80) self.haproxy_service.reconcile_default(charm_state) self.unit.status = ops.ActiveStatus() + def _reconcile_certificates(self, tls_information: TLSInformation) -> None: + """Reconcile certificates: write to disk and share via peer relation if leader. + + Args: + tls_information: TLSInformation charm state component. + """ + self._tls.certificate_available(tls_information) + if self.unit.is_leader(): + peer_relation = self.model.get_relation(HAPROXY_PEER_INTEGRATION) + if peer_relation: + self._tls.share_certificates_via_peer_relation(peer_relation, tls_information) + def _configure_ingress( self, charm_state: CharmState, @@ -307,7 +322,10 @@ def _configure_ingress( ) -> None: """Configure the ingress or ingress-per-unit relation.""" tls_information = TLSInformation.from_charm(self, self.certificates) - self._tls.certificate_available(tls_information) + if not tls_information: + logger.info("TLS information not available yet, skipping ingress configuration.") + return + self._reconcile_certificates(tls_information) ingress_provider = ( self._ingress_provider @@ -329,9 +347,9 @@ def _configure_ingress( def _configure_legacy(self, charm_state: CharmState) -> None: """Configure the legacy mode.""" if self.model.get_relation(TLS_CERT_RELATION): - # Reconcile certificates in case the certificates relation is present tls_information = TLSInformation.from_charm(self, self.certificates) - self._tls.certificate_available(tls_information) + if tls_information: + self._reconcile_certificates(tls_information) legacy_invalid_requested_port: list[str] = [] required_ports: set[Port] = set() @@ -378,7 +396,11 @@ def _configure_haproxy_route( ) ) tls_information = TLSInformation.from_charm(self, self.certificates, allow_no_certificates) - self._tls.certificate_available(tls_information) + if not tls_information and not allow_no_certificates: + logger.info("TLS information not available yet, skipping haproxy-route configuration.") + return + if tls_information: + self._reconcile_certificates(tls_information) ddos_protection_config = DDosProtection.from_charm(self.ddos_requirer) spoe_oauth_info_list = SpoeAuthInformation.from_requirer(self.spoe_auth_requirer) @@ -491,6 +513,8 @@ def _on_ingress_per_unit_data_provided(self, _: IngressDataReadyEvent) -> None: self._reconcile() if self.unit.is_leader(): tls_information = TLSInformation.from_charm(self, self.certificates) + if not tls_information: + return for relation in self._ingress_per_unit_provider.relations: for unit in relation.units: if not self._ingress_per_unit_provider.is_unit_ready(relation, unit): @@ -517,6 +541,8 @@ def _on_ingress_data_provided(self, event: IngressPerAppDataProvidedEvent) -> No self._reconcile() if self.unit.is_leader(): tls_information = TLSInformation.from_charm(self, self.certificates) + if not tls_information: + return integration_data = self._ingress_provider.get_data(event.relation) path_prefix = f"{integration_data.app.model}-{integration_data.app.name}" self._ingress_provider.publish_url( diff --git a/haproxy-operator/src/state/tls.py b/haproxy-operator/src/state/tls.py index a7fd67d8b..a28d90f90 100644 --- a/haproxy-operator/src/state/tls.py +++ b/haproxy-operator/src/state/tls.py @@ -3,7 +3,9 @@ """haproxy-operator charm tls information.""" +import json import logging +import typing from dataclasses import dataclass import ops @@ -13,6 +15,10 @@ TLSCertificatesRequiresV4, ) +from .ha import HAPROXY_PEER_INTEGRATION + +PEER_TLS_KEY = "tls_certificate_data" + logger = logging.getLogger() @@ -47,9 +53,12 @@ def from_charm( charm: ops.CharmBase, certificates: TLSCertificatesRequiresV4, allow_no_certificates: bool = False, - ) -> "TLSInformation": + ) -> typing.Optional["TLSInformation"]: """Get TLS information from a charm instance. + On leader units, reads certificates from the TLS library. + On non-leader units, reads certificates shared via the peer relation. + Args: charm: The haproxy charm. certificates: TLS certificates requirer class. @@ -59,8 +68,11 @@ def from_charm( PrivateKeyNotGeneratedError: When waiting for the private key to be generated. Returns: - TLSInformation: Information about configured TLS certs. + TLSInformation if available, None if non-leader and peer data not yet available. """ + if not charm.unit.is_leader(): + return cls._from_peer_relation(charm) + cls.validate(charm, certificates, allow_no_certificates) hostnames = [ @@ -86,6 +98,39 @@ def from_charm( private_key=str(private_key), ) + @classmethod + def _from_peer_relation(cls, charm: ops.CharmBase) -> typing.Optional["TLSInformation"]: + """Read TLS certificate data from the peer relation app databag. + + Args: + charm: The haproxy charm. + + Returns: + TLSInformation if available, None if the peer relation does not exist + or the certificate data has not yet been shared by the leader. + """ + peer_relation = charm.model.get_relation(HAPROXY_PEER_INTEGRATION) + if not peer_relation: + logger.info("Peer relation not available, cannot read TLS data.") + return None + raw = peer_relation.data[charm.app].get(PEER_TLS_KEY) + if not raw: + logger.info("No TLS certificate data in peer relation yet.") + return None + data = json.loads(raw) + hostnames = data["hostnames"] + private_key = data["private_key"] + tls_cert_and_ca_chain: dict[str, tuple[Certificate, list[Certificate]]] = {} + for hostname, cert_data in data["certificates"].items(): + certificate = Certificate.from_string(cert_data["certificate"]) + chain = [Certificate.from_string(c) for c in cert_data["chain"]] + tls_cert_and_ca_chain[hostname] = (certificate, chain) + return cls( + hostnames=hostnames, + tls_cert_and_ca_chain=tls_cert_and_ca_chain, + private_key=private_key, + ) + # Validation is done in this method instead of using a pydantic model because # there are cases where we need to validate the state but we don't need the state instance. @classmethod diff --git a/haproxy-operator/src/tls_relation.py b/haproxy-operator/src/tls_relation.py index b9f5b1f30..7d981cb42 100644 --- a/haproxy-operator/src/tls_relation.py +++ b/haproxy-operator/src/tls_relation.py @@ -4,6 +4,7 @@ # mypy guesses the relations might be None about all of them. """Haproxy TLS relation business logic.""" +import json import logging import typing from pathlib import Path @@ -17,11 +18,11 @@ ProviderCertificate, TLSCertificatesRequiresV4, ) -from ops.model import Model +from ops.model import Model, Relation from haproxy import file_exists, read_file, render_file from state.haproxy_route import HAPROXY_CAS_DIR, HAPROXY_CAS_FILE -from state.tls import TLSInformation +from state.tls import PEER_TLS_KEY, TLSInformation TLS_CERT = "certificates" HAPROXY_CERTS_DIR = Path("/var/lib/haproxy/certs") @@ -80,9 +81,6 @@ def certificate_available(self, tls_information: TLSInformation) -> None: Args: tls_information: TLSInformation charm state component. """ - if len(self.certificates.certificate_requests) == 0: - logger.warning("No certificate was requested") - return for certificate, chain in tls_information.tls_cert_and_ca_chain.values(): if not self._certificate_matches_stored_content( certificate=certificate, @@ -95,6 +93,31 @@ def certificate_available(self, tls_information: TLSInformation) -> None: private_key=tls_information.private_key, ) + def share_certificates_via_peer_relation( + self, + peer_relation: Relation, + tls_information: TLSInformation, + ) -> None: + """Share TLS certificate data with peer units via the peer relation app databag. + + Args: + peer_relation: The haproxy-peers relation. + tls_information: TLSInformation charm state component. + """ + certificates_data: dict[str, dict[str, typing.Any]] = {} + for hostname, (certificate, chain) in tls_information.tls_cert_and_ca_chain.items(): + certificates_data[hostname] = { + "certificate": str(certificate), + "chain": [str(cert) for cert in chain], + } + data = { + "hostnames": tls_information.hostnames, + "certificates": certificates_data, + "private_key": str(tls_information.private_key), + } + peer_relation.data[self.application][PEER_TLS_KEY] = json.dumps(data) + logger.info("Shared TLS certificate data via peer relation.") + def update_trusted_cas(self) -> None: """Handle the change in the set of CAs to trust.""" ca_certificates = self.recv_ca_cert.get_all_certificates() diff --git a/haproxy-operator/tests/unit/conftest.py b/haproxy-operator/tests/unit/conftest.py index 5164a919d..cc6d40149 100644 --- a/haproxy-operator/tests/unit/conftest.py +++ b/haproxy-operator/tests/unit/conftest.py @@ -251,7 +251,7 @@ def certificates_integration_fixture(certificates_relation_data, csr_certificate return scenario.Relation( endpoint="certificates", remote_app_data=certificates_relation_data, - local_unit_data={ + local_app_data={ "certificate_signing_requests": json.dumps( [ { @@ -312,6 +312,7 @@ def base_state_with_ingress_fixture(peer_relation, ingress_integration, certific Yield: The modeled haproxy-peers relation. """ input_state = { + "leader": True, "relations": [peer_relation, ingress_integration, certificates_integration], "config": { "external-hostname": "ingress.local", @@ -345,6 +346,7 @@ def base_state_haproxy_route_fixture( Yield: The modeled haproxy-peers relation. """ input_state = { + "leader": True, "relations": [ peer_relation, certificates_integration, @@ -413,6 +415,7 @@ def base_state_with_ingress_per_unit_fixture( Yield: The modeled haproxy-peers relation. """ input_state = { + "leader": True, "relations": [peer_relation, ingress_per_unit_integration, certificates_integration], "config": { "external-hostname": "ingress.local", diff --git a/haproxy-operator/tests/unit/legacy/test_tls_relation.py b/haproxy-operator/tests/unit/legacy/test_tls_relation.py index d641e94b5..e8d59dcb7 100644 --- a/haproxy-operator/tests/unit/legacy/test_tls_relation.py +++ b/haproxy-operator/tests/unit/legacy/test_tls_relation.py @@ -21,11 +21,12 @@ def test_tls_information_integration_missing(harness: Harness): - """arrange: Given a charm with tls integration missing. + """arrange: Given a leader charm with tls integration missing. act: Initialize TLSInformation state component. assert: TLSNotReadyError is raised. """ harness.begin() + harness.set_leader(True) with pytest.raises(TLSNotReadyError): TLSInformation.from_charm(harness.charm, harness.charm.certificates) @@ -133,3 +134,102 @@ def test_write_certificate_to_unit( pem_file_content = f"{mock_certificate!s}\n{chain_string}\n{mock_private_key!s}" write_text_mock.assert_called_once_with(pem_file_content, encoding="utf-8") + + +def test_share_certificates_via_peer_relation( + harness: Harness, + mock_certificate_and_key: typing.Tuple[Certificate, PrivateKey], +): + """arrange: Given a TLSRelationService and TLS information. + act: Run share_certificates_via_peer_relation. + assert: Peer relation app databag contains serialized certificate data. + """ + mock_certificate, mock_private_key = mock_certificate_and_key + harness.begin() + harness.set_leader(True) + tls_relation_service = TLSRelationService( + harness.model, harness.charm.certificates, harness.charm.recv_ca_certs + ) + hostname = "haproxy.internal" + tls_information = TLSInformation( + hostnames=[hostname], + tls_cert_and_ca_chain={hostname: (mock_certificate, [mock_certificate])}, + private_key=str(mock_private_key), + ) + + peer_relation_id = harness.add_relation("haproxy-peers", "haproxy") + peer_relation = harness.model.get_relation("haproxy-peers", peer_relation_id) + assert peer_relation is not None + + tls_relation_service.share_certificates_via_peer_relation(peer_relation, tls_information) + + import json + + from tls_relation import PEER_TLS_KEY + + raw_data = peer_relation.data[harness.model.app].get(PEER_TLS_KEY) + assert raw_data is not None + data = json.loads(raw_data) + assert data["hostnames"] == [hostname] + assert hostname in data["certificates"] + assert data["certificates"][hostname]["certificate"] == str(mock_certificate) + assert data["private_key"] == str(mock_private_key) + + +def test_non_leader_from_charm_reads_peer_relation( + harness: Harness, + mock_certificate_and_key: typing.Tuple[Certificate, PrivateKey], +): + """arrange: Given a non-leader unit with certificate data in the peer relation app databag. + act: Run TLSInformation.from_charm. + assert: TLSInformation is correctly deserialized from the peer relation. + """ + import json + + from state.tls import PEER_TLS_KEY + + mock_certificate, mock_private_key = mock_certificate_and_key + harness.set_leader(False) + hostname = "haproxy.internal" + peer_data = json.dumps( + { + "hostnames": [hostname], + "certificates": { + hostname: { + "certificate": str(mock_certificate), + "chain": [str(mock_certificate)], + } + }, + "private_key": str(mock_private_key), + } + ) + peer_relation_id = harness.add_relation("haproxy-peers", "haproxy") + harness.update_relation_data( + peer_relation_id, harness.model.app.name, {PEER_TLS_KEY: peer_data} + ) + harness.begin() + + result = TLSInformation.from_charm(harness.charm, harness.charm.certificates) + + assert result is not None + assert result.hostnames == [hostname] + assert hostname in result.tls_cert_and_ca_chain + certificate, chain = result.tls_cert_and_ca_chain[hostname] + assert str(certificate) == str(mock_certificate) + assert len(chain) == 1 + assert result.private_key == str(mock_private_key) + + +def test_non_leader_from_charm_returns_none_without_peer_data( + harness: Harness, +): + """arrange: Given a non-leader unit with no certificate data in the peer relation. + act: Run TLSInformation.from_charm. + assert: None is returned. + """ + harness.set_leader(False) + harness.add_relation("haproxy-peers", "haproxy") + harness.begin() + + result = TLSInformation.from_charm(harness.charm, harness.charm.certificates) + assert result is None diff --git a/haproxy-operator/tests/unit/test_charm.py b/haproxy-operator/tests/unit/test_charm.py index 4575d0ca6..cacb9eb2b 100644 --- a/haproxy-operator/tests/unit/test_charm.py +++ b/haproxy-operator/tests/unit/test_charm.py @@ -338,7 +338,8 @@ def test_spoe_auth(monkeypatch: pytest.MonkeyPatch, certificates_integration): ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( - relations=[certificates_integration, spoe_auth_relation, haproxy_route_relation] + leader=True, + relations=[certificates_integration, spoe_auth_relation, haproxy_route_relation], ) out = ctx.run( ctx.on.relation_changed(spoe_auth_relation), @@ -387,13 +388,14 @@ def test_two_spoe_auth(monkeypatch: pytest.MonkeyPatch, certificates_integration ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( + leader=True, relations=[ certificates_integration, spoe_auth_relation_1, spoe_auth_relation_2, haproxy_route_relation_1, haproxy_route_relation_2, - ] + ], ) out = ctx.run( ctx.on.relation_changed(spoe_auth_relation_1), @@ -441,7 +443,8 @@ def test_spoe_auth_invalid_data(monkeypatch: pytest.MonkeyPatch, certificates_in ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( - relations=[certificates_integration, spoe_auth_relation, haproxy_route_relation] + leader=True, + relations=[certificates_integration, spoe_auth_relation, haproxy_route_relation], ) out = ctx.run( ctx.on.relation_changed(spoe_auth_relation), @@ -450,3 +453,171 @@ def test_spoe_auth_invalid_data(monkeypatch: pytest.MonkeyPatch, certificates_in assert render_file_mock.call_count == 0 assert out.unit_status.name == ops.testing.BlockedStatus.name assert spoe_auth_relation.remote_app_name in out.unit_status.message + + +@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls") +class TestCertificateSharingViaPeerRelation: + """Tests for sharing certificates between units via peer relation in APP mode.""" + + def test_leader_shares_certificates_to_peer_relation( + self, + monkeypatch: pytest.MonkeyPatch, + mock_certificate_and_key, + certificates_integration, + peer_relation, + ): + """ + arrange: Prepare a leader unit with haproxy-route and certificates. + act: Trigger config_changed. + assert: Certificate data is written to the peer relation app databag. + """ + _mock_certificate, mock_private_key = mock_certificate_and_key + monkeypatch.setattr("haproxy.render_file", MagicMock()) + monkeypatch.setattr("haproxy.HAProxyService.reconcile_haproxy_route", MagicMock()) + monkeypatch.setattr( + "tls_relation.TLSRelationService.write_certificate_to_unit", MagicMock() + ) + monkeypatch.setattr( + "charm.HAProxyCharm._get_unit_address", MagicMock(return_value="10.0.0.1") + ) + + haproxy_route_relation = build_haproxy_route_relation() + + ctx = ops.testing.Context(HAProxyCharm) + state = ops.testing.State( + leader=True, + relations=[peer_relation, certificates_integration, haproxy_route_relation], + config={"external-hostname": TEST_EXTERNAL_HOSTNAME_CONFIG}, + ) + out = ctx.run(ctx.on.config_changed(), state) + + out_peer_relation = next(r for r in out.relations if r.endpoint == "haproxy-peers") + peer_app_data = out_peer_relation.local_app_data + assert peer_app_data is not None + assert tls_relation.PEER_TLS_KEY in peer_app_data + + shared_data = json.loads(peer_app_data[tls_relation.PEER_TLS_KEY]) + assert "hostnames" in shared_data + assert "certificates" in shared_data + assert "private_key" in shared_data + assert shared_data["private_key"] == str(mock_private_key) + + def test_non_leader_reads_certificates_from_peer_relation( + self, + monkeypatch: pytest.MonkeyPatch, + csr_certificate_and_key, + ): + """ + arrange: Prepare a non-leader unit with certificate data in the peer relation. + act: Trigger config_changed event. + assert: Unit reaches active status with TLS data from peer relation. + """ + _, certificate, private_key = csr_certificate_and_key + hostname = TEST_EXTERNAL_HOSTNAME_CONFIG + + write_cert_mock = MagicMock() + monkeypatch.setattr( + "tls_relation.TLSRelationService.write_certificate_to_unit", write_cert_mock + ) + monkeypatch.setattr("haproxy.render_file", MagicMock()) + + peer_tls_data = json.dumps( + { + "hostnames": [hostname], + "certificates": { + hostname: { + "certificate": str(certificate), + "chain": [str(certificate)], + } + }, + "private_key": str(private_key), + } + ) + peer_relation_with_tls = scenario.PeerRelation( + endpoint="haproxy-peers", + local_app_data={tls_relation.PEER_TLS_KEY: peer_tls_data}, + ) + + ctx = ops.testing.Context(HAProxyCharm) + state = ops.testing.State( + leader=False, + relations=[peer_relation_with_tls], + ) + out = ctx.run(ctx.on.config_changed(), state) + assert out.unit_status == ops.testing.ActiveStatus("") + + def test_non_leader_without_peer_data_skips_tls( + self, + monkeypatch: pytest.MonkeyPatch, + peer_relation, + ): + """ + arrange: Prepare a non-leader unit with no TLS data in the peer relation. + act: Trigger config_changed. + assert: Unit reaches active status without error (default mode, no TLS needed). + """ + monkeypatch.setattr("haproxy.render_file", MagicMock()) + + ctx = ops.testing.Context(HAProxyCharm) + state = ops.testing.State( + leader=False, + relations=[peer_relation], + ) + out = ctx.run(ctx.on.config_changed(), state) + assert out.unit_status == ops.testing.ActiveStatus("") + + def test_non_leader_haproxy_route_reads_from_peer_relation( + self, + monkeypatch: pytest.MonkeyPatch, + csr_certificate_and_key, + certificates_integration, + ): + """ + arrange: Prepare a non-leader unit with haproxy-route and certificate data in peer. + act: Trigger config_changed. + assert: The haproxy config is rendered with data from peer relation certificates. + """ + _, certificate, private_key = csr_certificate_and_key + hostname = TEST_EXTERNAL_HOSTNAME_CONFIG + reconcile_mock = MagicMock() + monkeypatch.setattr("haproxy.HAProxyService.reconcile_haproxy_route", reconcile_mock) + monkeypatch.setattr( + "tls_relation.TLSRelationService.write_certificate_to_unit", MagicMock() + ) + monkeypatch.setattr("haproxy.HAProxyService.install", MagicMock()) + monkeypatch.setattr( + "charm.HAProxyCharm._get_unit_address", MagicMock(return_value="10.0.0.2") + ) + + peer_tls_data = json.dumps( + { + "hostnames": [hostname], + "certificates": { + hostname: { + "certificate": str(certificate), + "chain": [str(certificate)], + } + }, + "private_key": str(private_key), + } + ) + peer_relation_with_tls = scenario.PeerRelation( + endpoint="haproxy-peers", + local_app_data={tls_relation.PEER_TLS_KEY: peer_tls_data}, + ) + + haproxy_route_relation = build_haproxy_route_relation() + + ctx = ops.testing.Context(HAProxyCharm) + state = ops.testing.State( + leader=False, + relations=[ + peer_relation_with_tls, + certificates_integration, + haproxy_route_relation, + ], + config={"external-hostname": TEST_EXTERNAL_HOSTNAME_CONFIG}, + ) + out = ctx.run(ctx.on.config_changed(), state) + reconcile_mock.assert_called_once() + assert out.unit_status == ops.testing.ActiveStatus("") diff --git a/haproxy-operator/tests/unit/test_ddos_protection.py b/haproxy-operator/tests/unit/test_ddos_protection.py index ab8a98b59..a4180fd5e 100644 --- a/haproxy-operator/tests/unit/test_ddos_protection.py +++ b/haproxy-operator/tests/unit/test_ddos_protection.py @@ -26,6 +26,7 @@ def test_ddos_protection_enabled(monkeypatch: pytest.MonkeyPatch, certificates_i ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( + leader=True, relations=[certificates_integration, haproxy_route_relation], ) out = ctx.run( @@ -64,6 +65,7 @@ def test_ddos_protection_disabled(monkeypatch: pytest.MonkeyPatch, certificates_ ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( + leader=True, relations=[certificates_integration, haproxy_route_relation], config={"ddos-protection": False}, ) diff --git a/haproxy-operator/tests/unit/test_hsts.py b/haproxy-operator/tests/unit/test_hsts.py index 5098404e6..e72149688 100644 --- a/haproxy-operator/tests/unit/test_hsts.py +++ b/haproxy-operator/tests/unit/test_hsts.py @@ -29,6 +29,7 @@ def test_hsts_disabled(monkeypatch: pytest.MonkeyPatch, certificates_integration ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( + leader=True, relations=[certificates_integration, haproxy_route_relation], ) out = ctx.run( @@ -56,6 +57,7 @@ def test_hsts_enabled(monkeypatch: pytest.MonkeyPatch, certificates_integration) ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( + leader=True, relations=[certificates_integration, haproxy_route_relation], config={"enable-hsts": True}, ) @@ -88,6 +90,7 @@ def test_hsts_disabled_allow_http(monkeypatch: pytest.MonkeyPatch, certificates_ ctx = ops.testing.Context(HAProxyCharm) state = ops.testing.State( + leader=True, relations=[certificates_integration, haproxy_route_relation], config={"enable-hsts": True}, )