Skip to content

Commit

Permalink
feat: add support for TLS configuration at MAAS charm (#220)
Browse files Browse the repository at this point in the history
  • Loading branch information
wyattrees authored Nov 13, 2024
1 parent dac96d3 commit ddbc0fd
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 7 deletions.
14 changes: 13 additions & 1 deletion maas-region/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ parts:
config:
options:
tls_mode:
default: "disabled"
description: Whether to enable TLS termination at HA Proxy ('termination'), at MAAS ('passthrough'), or no TLS ('disabled')
type: string
ssl_cert_content:
default: ""
description: SSL certificate for tls_mode='passthrough'
type: string
ssl_key_content:
default: ""
description: SSL private key for tls_mode='passthrough'
type: string
ssl_cacert_content:
default: ""
description: Whether to enable TLS termination at HA Proxy ('termination'), or no TLS ('')
description: CA Certificates chain in PEM format
type: string
41 changes: 36 additions & 5 deletions maas-region/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ class MaasRegionCharm(ops.CharmBase):
"""Charm the application."""

_TLS_MODES = [
"",
"disabled",
"termination",
] # no TLS, termination at HA Proxy
"passthrough",
] # no TLS, termination at HA Proxy, passthrough to MAAS

def __init__(self, *args):
super().__init__(*args)
Expand Down Expand Up @@ -247,6 +248,9 @@ def _initialize_maas(self) -> bool:
MaasHelper.setup_region(
self.maas_api_url, self.connection_string, self.get_operational_mode()
)
# check maas_api_url existence in case MAAS isn't ready yet
if self.maas_api_url and self.unit.is_leader():
self._update_tls_config()
return True
except subprocess.CalledProcessError:
return False
Expand All @@ -270,6 +274,9 @@ def _get_regions(self) -> List[str]:
return list(set(eps))

def _update_ha_proxy(self) -> None:
region_port = (
MAAS_HTTPS_PORT if self.config["tls_mode"] == "passthrough" else MAAS_HTTP_PORT
)
if relation := self.model.get_relation(MAAS_API_RELATION):
app_name = f"api-{self.app.name}"
data = [
Expand All @@ -282,13 +289,13 @@ def _update_ha_proxy(self) -> None:
(
f"{app_name}-{self.unit.name.replace('/', '-')}",
self.bind_address,
MAAS_HTTP_PORT,
region_port,
[],
)
],
},
]
if self.config["tls_mode"] == "termination":
if self.config["tls_mode"] != "disabled":
data.append(
{
"service_name": "agent_service",
Expand All @@ -304,9 +311,22 @@ def _update_ha_proxy(self) -> None:
],
}
)
# TODO: Implement passthrough configuration
relation.data[self.unit]["services"] = yaml.safe_dump(data)

def _update_tls_config(self) -> None:
"""Enable or disable TLS in MAAS."""
if (tls_enabled := MaasHelper.is_tls_enabled()) is not None:
if not tls_enabled and self.config["tls_mode"] == "passthrough":
MaasHelper.create_tls_files(
self.config["ssl_cert_content"], # type: ignore
self.config["ssl_key_content"], # type: ignore
self.config["ssl_cacert_content"], # type: ignore
)
MaasHelper.enable_tls()
MaasHelper.delete_tls_files()
elif tls_enabled and self.config["tls_mode"] in ["disabled", "termination"]:
MaasHelper.disable_tls()

def _on_start(self, _event: ops.StartEvent) -> None:
"""Handle the MAAS controller startup.
Expand Down Expand Up @@ -447,12 +467,23 @@ def _on_get_api_endpoint_action(self, event: ops.ActionEvent):
event.fail("MAAS is not initialized yet")

def _on_config_changed(self, event: ops.ConfigChangedEvent):
# validate tls_mode
tls_mode = self.config["tls_mode"]
if tls_mode not in self._TLS_MODES:
msg = f"Invalid tls_mode configuration: '{tls_mode}'. Valid options are: {self._TLS_MODES}"
self.unit.status = ops.BlockedStatus(msg)
raise ValueError(msg)
# validate certificate and key
if tls_mode == "passthrough":
cert = self.config["ssl_cert_content"]
key = self.config["ssl_key_content"]
if not cert or not key:
raise ValueError(
"Both ssl_cert_content and ssl_key_content must be defined when using tls_mode=passthrough"
)
self._update_ha_proxy()
if self.unit.is_leader():
self._update_tls_config()


if __name__ == "__main__": # pragma: nocover
Expand Down
83 changes: 83 additions & 0 deletions maas-region/src/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

"""Helper functions for MAAS management."""

import logging
import subprocess
from os import remove
from pathlib import Path
from typing import Union

Expand All @@ -14,6 +16,13 @@
MAAS_SECRET = Path("/var/snap/maas/common/maas/secret")
MAAS_ID = Path("/var/snap/maas/common/maas/maas_id")
MAAS_SERVICE = "pebble"
MAAS_SSL_CERT_FILEPATH = Path("/var/snap/maas/common/cert.pem")
MAAS_SSL_KEY_FILEPATH = Path("/var/snap/maas/common/key.pem")
MAAS_CACERT_FILEPATH = Path("/var/snap/maas/common/cacert.pem")
NGINX_CFG_FILEPATH = Path("/var/snap/maas/current/http/regiond.nginx.conf")
MAAS_HTTPS_PORT = 5443

logger = logging.getLogger(__name__)


class MaasHelper:
Expand Down Expand Up @@ -186,6 +195,80 @@ def setup_region(maas_url: str, dsn: str, mode: str) -> None:
]
subprocess.check_call(cmd)

@staticmethod
def is_tls_enabled() -> Union[bool, None]:
"""Check whether MAAS currently has TLS enabled.
Returns:
bool | None: True if MAAS has TLS enabled, False if not, None if MAAS is not initialized
"""
try:
return f"listen {MAAS_HTTPS_PORT}" in NGINX_CFG_FILEPATH.read_text()
except FileNotFoundError:
# MAAS is not initialized yet, don't give false hope
return None

@staticmethod
def delete_tls_files() -> None:
"""Delete the TLS files used for setting configuring tls."""
if MAAS_SSL_CERT_FILEPATH.exists():
remove(MAAS_SSL_CERT_FILEPATH)
if MAAS_SSL_KEY_FILEPATH.exists():
remove(MAAS_SSL_KEY_FILEPATH)
if MAAS_CACERT_FILEPATH.exists():
remove(MAAS_CACERT_FILEPATH)

@staticmethod
def create_tls_files(
ssl_certificate: str, ssl_key: str, cacert: str = "", overwrite: bool = False
) -> None:
"""Ensure that the SSL certificate and private key exist.
Args:
ssl_certificate (str): contents of the certificate file
ssl_key (str): contents of the private key file
cacert (str): optionally, contents of cacert chain for a self-signed ssl_certificate
overwrite (bool): Whether to overwrite the files if they exist already
"""
if not MAAS_SSL_CERT_FILEPATH.exists() or overwrite:
MAAS_SSL_CERT_FILEPATH.write_text(ssl_certificate)
if not MAAS_SSL_KEY_FILEPATH.exists() or overwrite:
MAAS_SSL_KEY_FILEPATH.write_text(ssl_key)
if cacert and (not MAAS_CACERT_FILEPATH.exists() or overwrite):
MAAS_CACERT_FILEPATH.write_text(cacert)

@staticmethod
def enable_tls(cacert: bool = False) -> None:
"""Set up TLS for the Region controller.
Raises:
CalledProcessError: if "maas config-tls enable" command failed for any reason
"""
cmd = [
"/snap/bin/maas",
"config-tls",
"enable",
"--yes",
]
if cacert:
cmd.extend(["--cacert", str(MAAS_CACERT_FILEPATH)])
cmd.extend([str(MAAS_SSL_KEY_FILEPATH), str(MAAS_SSL_CERT_FILEPATH)])
subprocess.check_call(cmd)

@staticmethod
def disable_tls() -> None:
"""Disable TLS for the Region controller.
Raises:
CalledProcessError: if "maas config-tls disable" command failed for any reason
"""
cmd = [
"/snap/bin/maas",
"config-tls",
"disable",
]
subprocess.check_call(cmd)

@staticmethod
def get_maas_secret() -> Union[str, None]:
"""Get MAAS enrollment secret token.
Expand Down
38 changes: 37 additions & 1 deletion maas-region/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def test_ha_proxy_data(self, mock_helper):
self.assertEqual(ha_data[0]["servers"][0][1], "10.0.0.10")

@patch("charm.MaasHelper", autospec=True)
def test_ha_proxy_data_tls(self, mock_helper):
def test_ha_proxy_data_tls_termination(self, mock_helper):
self.harness.set_leader(True)
self.harness.update_config({"tls_mode": "termination"})
self.harness.begin()
Expand All @@ -134,6 +134,30 @@ def test_ha_proxy_data_tls(self, mock_helper):
self.assertIn("service_host", ha_data[1]) # codespell:ignore
self.assertEqual(len(ha_data[1]["servers"]), 1)
self.assertEqual(ha_data[1]["servers"][0][1], "10.0.0.10")
self.assertEqual(ha_data[0]["servers"][0][2], 5240)

@patch("charm.MaasHelper", autospec=True)
def test_ha_proxy_data_tls_passthrough(self, mock_helper):
self.harness.set_leader(True)
self.harness.update_config(
{
"tls_mode": "passthrough",
"ssl_cert_content": "BEGIN CERTIFICATE",
"ssl_key_content": "BEGIN_PRIVATE_KEY",
}
)
self.harness.begin()
ha = self.harness.add_relation(
MAAS_API_RELATION, "haproxy", unit_data={"public-address": "proxy.maas"}
)

ha_data = yaml.safe_load(self.harness.get_relation_data(ha, "maas-region/0")["services"])
self.assertEqual(len(ha_data), 2)
self.assertIn("service_name", ha_data[1]) # codespell:ignore
self.assertIn("service_host", ha_data[1]) # codespell:ignore
self.assertEqual(len(ha_data[1]["servers"]), 1)
self.assertEqual(ha_data[1]["servers"][0][1], "10.0.0.10")
self.assertEqual(ha_data[0]["servers"][0][2], 5443)

@patch("charm.MaasHelper", autospec=True)
def test_invalid_tls_mode(self, mock_helper):
Expand All @@ -148,6 +172,18 @@ def test_invalid_tls_mode(self, mock_helper):
ha_data = yaml.safe_load(self.harness.get_relation_data(ha, "maas-region/0")["services"])
self.assertEqual(len(ha_data), 1)

@patch("charm.MaasHelper", autospec=True)
def test_bad_ssl_cert_key_config(self, mock_helper):
self.harness.set_leader(True)
self.harness.begin()
self.harness.add_relation(
MAAS_API_RELATION, "haproxy", unit_data={"public-address": "proxy.maas"}
)
with self.assertRaises(ValueError):
self.harness.update_config(
{"tls_mode": "passthrough", "ssl_cert_content": "test_cert"}
)

@patch("charm.MaasHelper", autospec=True)
def test_on_maas_cluster_changed_new_agent(self, mock_helper):
mock_helper.get_maas_mode.return_value = "region"
Expand Down
14 changes: 14 additions & 0 deletions maas-region/tests/unit/test_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ def test_get_maas_secret(self, _):
def test_get_maas_secret_not_initialised(self, _):
self.assertIsNone(MaasHelper.get_maas_secret())

@patch("pathlib.Path.read_text")
def test_is_tls_enabled_no(self, mock_read_text):
mock_read_text.return_value = "listen 80"
self.assertEqual(MaasHelper.is_tls_enabled(), False)

@patch("pathlib.Path.read_text")
def test_is_tls_enabled_yes(self, mock_read_text):
mock_read_text.return_value = "listen 5443"
self.assertEqual(MaasHelper.is_tls_enabled(), True)

@patch("pathlib.Path.read_text", side_effect=FileNotFoundError)
def test_is_tls_enabled_not_initalized(self, _):
self.assertEqual(MaasHelper.is_tls_enabled(), None)


class TestHelperSetup(unittest.TestCase):
@patch("helper.subprocess.check_call")
Expand Down

0 comments on commit ddbc0fd

Please sign in to comment.