diff --git a/operator/controller/integrationroute-controller.yaml b/operator/controller/integrationroute-controller.yaml index c47cc3d..56089d4 100644 --- a/operator/controller/integrationroute-controller.yaml +++ b/operator/controller/integrationroute-controller.yaml @@ -50,7 +50,7 @@ spec: spec: containers: - name: webhook - image: ghcr.io/octoconsulting/keip/route-webhook:0.5.0 + image: ghcr.io/octoconsulting/keip/route-webhook:0.6.0 ports: - containerPort: 7080 name: webhook-http diff --git a/operator/crd/crd.yaml b/operator/crd/crd.yaml index 043829c..3283ccb 100644 --- a/operator/crd/crd.yaml +++ b/operator/crd/crd.yaml @@ -89,22 +89,46 @@ spec: - claimName - mountPath tls: - description: "Configure client TLS connections using a JKS or PKCS12 truststore. A JKS truststore should have its password set to 'changeit', while a PKCS12 truststore should have an empty password." + description: "Configure server and client TLS connections." type: object properties: - configMapName: - type: string - key: - type: string - type: - type: string - enum: - - jks - - pkcs12 - required: - - configMapName - - key - - type + truststore: + description: "Configure client TLS connections using a JKS or PKCS12 truststore. A JKS truststore should have its password set to 'changeit', while a PKCS12 truststore should have an empty password." + type: object + properties: + configMapName: + type: string + key: + type: string + type: + type: string + enum: + - jks + - pkcs12 + required: + - configMapName + - key + - type + keystore: + description: "Configure HTTP server TLS connections using a JKS or PKCS12 keystore. The keystore password should be stored in a secret and referenced in the route Custom Resource. The format of the secret should be `password=`." + type: object + properties: + secretName: + type: string + key: + type: string + type: + type: string + enum: + - jks + - pkcs12 + passwordSecretRef: + type: string + required: + - secretName + - key + - type + - passwordSecretRef required: - routeConfigMap required: diff --git a/operator/example/README.md b/operator/example/README.md index 60ffb78..7726cce 100644 --- a/operator/example/README.md +++ b/operator/example/README.md @@ -8,9 +8,18 @@ Prerequisites: - Metacontroller - Keip CRDs and controller - Access to a `keip-integration` image +- Cert-Manager -Run example: - +Running the example: +1. Install Cert-Manager: +```shell +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.0/cert-manager.yaml +``` +2. Create JKS password secret: +```shell +kubectl create secret generic --from-literal=password=password jks-password +``` +3. Run example: ```shell kubectl apply -k example ``` @@ -24,6 +33,8 @@ This should result in the creation of the following resources: - ConfigMap `testroute-props`: Contains the application's configurable properties. - Secret `testroute-secret`: Confidential information that will be mounted as volume in the running pod. +- Self-Signed Certificate `certificate.cert-manager.io/selfsigned-cert`: A certificate resource that creates the `test-keystore` secret using the `jks-password` secret +- Cluster Issuer `clusterissuer.cert-manager.io/test-selfsigned`: A self-signed cluster issuer used to sign certificates. Check for the running `testroute` pod: @@ -43,6 +54,11 @@ GenericMessage [payload=Hello. I have a secret to share: (pass123), headers={id= ``` +Port-forward the pod and verify `https://localhost:8443` returns the default Spring whitelabel error page. +```shell +kubectl port-forward testroute-74d574bf85-tbv9m 8443:8443 +``` + Clean up ```shell diff --git a/operator/example/cert.yaml b/operator/example/cert.yaml new file mode 100644 index 0000000..f3253ac --- /dev/null +++ b/operator/example/cert.yaml @@ -0,0 +1,35 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: test-selfsigned +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: selfsigned-cert + namespace: default +spec: + secretName: test-keystore + dnsNames: + - test + issuerRef: + group: cert-manager.io + kind: ClusterIssuer + name: test-selfsigned + keystores: + jks: + create: true + passwordSecretRef: + name: jks-password + key: password + subject: + countries: + - US + localities: + - A Park + organizationalUnits: + - Parks and Recreation + provinces: + - FL diff --git a/operator/example/kustomization.yaml b/operator/example/kustomization.yaml index 5d23367..02ccd0b 100644 --- a/operator/example/kustomization.yaml +++ b/operator/example/kustomization.yaml @@ -1,5 +1,6 @@ resources: - testroute.yaml + - cert.yaml generatorOptions: disableNameSuffixHash: true diff --git a/operator/example/testroute.yaml b/operator/example/testroute.yaml index c906053..276bebb 100644 --- a/operator/example/testroute.yaml +++ b/operator/example/testroute.yaml @@ -11,3 +11,9 @@ spec: # group: abc secretSources: - testroute-secret + tls: + keystore: + secretName: test-keystore + type: jks + key: keystore.jks + passwordSecretRef: jks-password diff --git a/operator/webhook/Makefile b/operator/webhook/Makefile index 6dbfcca..1a59d7b 100644 --- a/operator/webhook/Makefile +++ b/operator/webhook/Makefile @@ -1,4 +1,4 @@ -VERSION ?= 0.5.0 +VERSION ?= 0.6.0 HOST_PORT ?= 7080 IMG_REGISTRY := ghcr.io/octoconsulting @@ -27,7 +27,7 @@ start-dev-server: .PHONY: test test: - cd test && mkdir -p $(TEST_COVERAGE_DIR) && coverage run --data-file=$(TEST_COVERAGE_FILE) -m pytest + cd test && mkdir -p $(TEST_COVERAGE_DIR) && coverage run --data-file=$(TEST_COVERAGE_FILE) -m pytest -vv .PHONY: report-test-coverage report-test-coverage: test diff --git a/operator/webhook/introute/sync.py b/operator/webhook/introute/sync.py index f4c5d76..124d9f6 100644 --- a/operator/webhook/introute/sync.py +++ b/operator/webhook/introute/sync.py @@ -11,6 +11,7 @@ TRUSTSTORE_PATH = "/etc/cabundle" +KEYSTORE_PATH = "/etc/keystore" class VolumeConfig: """ @@ -21,7 +22,8 @@ class VolumeConfig: """ _route_vol_name = "integration-route-config" - _tls_vol_name = "truststore" + _tls_truststore_name = "truststore" + _tls_keystore_name = "keystore" def __init__(self, parent_spec) -> None: self._route_config = parent_spec["routeConfigMap"] @@ -62,20 +64,39 @@ def get_volumes(self) -> List[Mapping]: ) if self._tls_config: - volumes.append( - { - "name": self._tls_vol_name, - "configMap": { - "name": self._tls_config["configMapName"], - "items": [ - { - "key": self._tls_config["key"], - "path": self._tls_config["key"], - } - ], - }, - } - ) + truststore = self._tls_config.get('truststore') + if truststore: + volumes.append( + { + "name": self._tls_truststore_name, + "configMap": { + "name": truststore["configMapName"], + "items": [ + { + "key": truststore["key"], + "path": truststore["key"], + } + ], + }, + } + ) + + keystore = self._tls_config.get('keystore') + if keystore: + volumes.append( + { + "name": self._tls_keystore_name, + "secret": { + "secretName": keystore["secretName"], + "items": [ + { + "key": keystore["key"], + "path": keystore["key"], + } + ], + }, + } + ) return volumes @@ -111,15 +132,23 @@ def get_mounts(self) -> List[Mapping]: "mountPath": cm_spec["mountPath"], } ) - if self._tls_config: - volume_mounts.append( - { - "name": self._tls_vol_name, - "readOnly": True, - "mountPath": TRUSTSTORE_PATH, - } - ) + if self._tls_config.get('truststore'): + volume_mounts.append( + { + "name": self._tls_truststore_name, + "readOnly": True, + "mountPath": TRUSTSTORE_PATH, + } + ) + if self._tls_config.get('keystore'): + volume_mounts.append( + { + "name": self._tls_keystore_name, + "readOnly": True, + "mountPath": KEYSTORE_PATH, + } + ) return volume_mounts @@ -145,7 +174,27 @@ def _spring_cloud_k8s_config(parent) -> Optional[Mapping]: } -def _spring_app_config_env_var(parent) -> Mapping[str, str]: +def _get_server_ssl_config(parent) -> Optional[Mapping]: + tls_config = parent["spec"].get("tls") + if not tls_config: + return None + + keystore = tls_config.get("keystore") + + if not keystore: + return None + + return { + "ssl": { + "key-alias": "certificate", + "key-store": str(Path(KEYSTORE_PATH, keystore["key"])), + "key-store-type": keystore["type"].upper() + }, + "port": 8443 + } + + +def _spring_app_config_env_var(parent) -> Optional[Mapping]: metadata = parent["metadata"] app_config = { "spring": { @@ -153,6 +202,9 @@ def _spring_app_config_env_var(parent) -> Mapping[str, str]: } } + if tls_config := _get_server_ssl_config(parent): + app_config["server"] = tls_config + if cloud_config := _spring_cloud_k8s_config(parent): app_config["spring"]["config.import"] = "kubernetes:" app_config["spring"]["cloud"] = cloud_config @@ -163,22 +215,37 @@ def _spring_app_config_env_var(parent) -> Mapping[str, str]: } -def _get_java_jdk_options(parent) -> Optional[Mapping[str, str]]: - tls_config = parent["spec"].get("tls") +def _get_keystore_password_env(tls) -> Mapping[str, str]: - if not tls_config: + keystore = tls.get("keystore") + + if not keystore: return None - tls_type = tls_config['type'] - assert tls_type in ["jks", "pkcs12"], \ - f"({tls_type}) is not a supported TLS type. Supported types: ('jks', 'pkcs12')" + return { + "name": "SERVER_SSL_KEYSTOREPASSWORD", + "valueFrom": { + "secretKeyRef": { + "name": keystore["passwordSecretRef"], + "key": "password" + } + } + } + + +def _get_java_jdk_options(tls) -> Optional[Mapping[str, str]]: + truststore = tls.get("truststore") + + if not truststore: + return None + + tls_type = truststore["type"] truststore_password = "changeit" if tls_type == "jks" else "" - jdk_options = f"-Djavax.net.ssl.trustStore={str(Path(TRUSTSTORE_PATH, tls_config['key']))} -Djavax.net.ssl.trustStorePassword={truststore_password} -Djavax.net.ssl.trustStoreType={tls_type.upper()}" return { "name": "JDK_JAVA_OPTIONS", - "value": jdk_options, + "value": f"-Djavax.net.ssl.trustStore={str(Path(TRUSTSTORE_PATH, truststore['key']))} -Djavax.net.ssl.trustStorePassword={truststore_password} -Djavax.net.ssl.trustStoreType={tls_type.upper()}" } @@ -188,8 +255,13 @@ def _generate_container_env_vars(parent) -> List[Mapping[str, str]]: if spring_app_config := _spring_app_config_env_var(parent): env_vars.append(spring_app_config) - if jdk_options := _get_java_jdk_options(parent): - env_vars.append(jdk_options) + if tls := parent["spec"].get("tls"): + if jdk_options := _get_java_jdk_options(tls): + env_vars.append(jdk_options) + + if keystore_password_env := _get_keystore_password_env(tls): + env_vars.append(keystore_password_env) + return env_vars @@ -199,6 +271,11 @@ def _create_pod_template(parent, labels, integration_image): vol_config = VolumeConfig(parent["spec"]) + has_tls = "tls" in parent["spec"] and "keystore" in parent["spec"]["tls"] + + scheme = "HTTPS" if has_tls else "HTTP" + management_port = 8443 if has_tls else 8080 + pod_template = { "metadata": {"labels": labels}, "spec": { @@ -211,14 +288,16 @@ def _create_pod_template(parent, labels, integration_image): "livenessProbe": { "httpGet": { "path": "/actuator/health/liveness", - "port": 8080, + "port": management_port, + "scheme": scheme }, "initialDelaySeconds": 10, }, "readinessProbe": { "httpGet": { "path": "/actuator/health/readiness", - "port": 8080, + "port": management_port, + "scheme": scheme }, "initialDelaySeconds": 10, }, diff --git a/operator/webhook/test/json/full-iroute-request.json b/operator/webhook/test/json/full-iroute-request.json index d1d0ca4..700d97e 100644 --- a/operator/webhook/test/json/full-iroute-request.json +++ b/operator/webhook/test/json/full-iroute-request.json @@ -41,9 +41,17 @@ "testroute-secret" ], "tls": { - "configMapName": "test-tls-cm", - "key": "test-truststore.p12", - "type": "pkcs12" + "keystore": { + "secretName": "test-tls-secret", + "key": "test-keystore.jks", + "type": "jks", + "passwordSecretRef": "keystore-password-ref" + }, + "truststore": { + "configMapName": "test-tls-cm", + "key": "test-truststore.p12", + "type": "pkcs12" + } }, "configMaps": [ { diff --git a/operator/webhook/test/json/full-response.json b/operator/webhook/test/json/full-response.json index bede673..e1aacce 100644 --- a/operator/webhook/test/json/full-response.json +++ b/operator/webhook/test/json/full-response.json @@ -69,30 +69,46 @@ "name": "truststore", "readOnly": true, "mountPath": "/etc/cabundle" + }, + { + "name": "keystore", + "readOnly": true, + "mountPath": "/etc/keystore" } ], "livenessProbe": { "httpGet": { "path": "/actuator/health/liveness", - "port": 8080 + "port": 8443, + "scheme": "HTTPS" }, "initialDelaySeconds": 10 }, "readinessProbe": { "httpGet": { "path": "/actuator/health/readiness", - "port": 8080 + "port": 8443, + "scheme": "HTTPS" }, "initialDelaySeconds": 10 }, "env": [ { "name": "SPRING_APPLICATION_JSON", - "value": "{\"spring\": {\"application\": {\"name\": \"testroute\"}, \"config.import\": \"kubernetes:\", \"cloud\": {\"kubernetes\": {\"config\": {\"fail-fast\": true, \"namespace\": \"testspace\", \"sources\": [{\"name\": \"testroute-props\"}, {\"labels\": {\"group\": \"ir-common\"}}]}, \"secrets\": {\"paths\": \"/etc/secrets\"}}}}}" + "value": "{\"spring\": {\"application\": {\"name\": \"testroute\"}, \"config.import\": \"kubernetes:\", \"cloud\": {\"kubernetes\": {\"config\": {\"fail-fast\": true, \"namespace\": \"testspace\", \"sources\": [{\"name\": \"testroute-props\"}, {\"labels\": {\"group\": \"ir-common\"}}]}, \"secrets\": {\"paths\": \"/etc/secrets\"}}}}, \"server\": {\"ssl\": {\"key-alias\": \"certificate\", \"key-store\": \"/etc/keystore/test-keystore.jks\", \"key-store-type\": \"JKS\"}, \"port\": 8443}}" }, { "name": "JDK_JAVA_OPTIONS", "value": "-Djavax.net.ssl.trustStore=/etc/cabundle/test-truststore.p12 -Djavax.net.ssl.trustStorePassword= -Djavax.net.ssl.trustStoreType=PKCS12" + }, + { + "name": "SERVER_SSL_KEYSTOREPASSWORD", + "valueFrom": { + "secretKeyRef": { + "name": "keystore-password-ref", + "key": "password" + } + } } ] } @@ -139,6 +155,18 @@ } ] } + }, + { + "name": "keystore", + "secret": { + "secretName": "test-tls-secret", + "items": [ + { + "key": "test-keystore.jks", + "path": "test-keystore.jks" + } + ] + } } ] } @@ -146,4 +174,4 @@ } } ] -} \ No newline at end of file +} diff --git a/operator/webhook/test/test_sync.py b/operator/webhook/test/test_sync.py index 606bc77..a2eb23a 100644 --- a/operator/webhook/test/test_sync.py +++ b/operator/webhook/test/test_sync.py @@ -12,11 +12,12 @@ _new_deployment, _spring_app_config_env_var, _get_java_jdk_options, + _generate_container_env_vars + ) JDK_OPTIONS_ENV_NAME = "JDK_JAVA_OPTIONS" - def test_empty_parent_raises_exception(full_route): with pytest.raises(KeyError): sync({}) @@ -70,38 +71,9 @@ def test_spring_app_config_json_missing_props_and_secret_sources(full_route): assert spring_conf["name"] == "SPRING_APPLICATION_JSON" - expected_json = {"spring": {"application": {"name": "testroute"}}} - assert json_props == expected_json - - -def test_jdk_options_pkcs12_type(full_route): - options = _get_java_jdk_options(full_route) - - assert options["name"] == JDK_OPTIONS_ENV_NAME - - expected_options = "-Djavax.net.ssl.trustStore=/etc/cabundle/test-truststore.p12 -Djavax.net.ssl.trustStorePassword= -Djavax.net.ssl.trustStoreType=PKCS12" - assert options["value"] == expected_options - - -def test_jdk_options_jks_type(full_route): - tls_config = full_route["spec"]["tls"] - tls_config["type"] = "jks" - tls_config["key"] = "test-truststore.jks" + expected_json = {"spring": {"application": {"name": "testroute"}}, "server": {"ssl": {"key-alias": "certificate", "key-store": "/etc/keystore/test-keystore.jks", "key-store-type": "JKS"}, "port": 8443}} - options = _get_java_jdk_options(full_route) - - assert options["name"] == JDK_OPTIONS_ENV_NAME - - expected_options = "-Djavax.net.ssl.trustStore=/etc/cabundle/test-truststore.jks -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=JKS" - assert options["value"] == expected_options - - -def test_jdk_options_unknown_type(full_route): - tls_config = full_route["spec"]["tls"] - tls_config["type"] = "pem" - - with pytest.raises(AssertionError): - _get_java_jdk_options(full_route) + assert json_props == expected_json def test_pod_template_no_annotations(full_route): @@ -127,11 +99,63 @@ def test_pod_template_no_tls(full_route): deployment = _new_deployment(full_route) - check_env_var_absent(deployment, JDK_OPTIONS_ENV_NAME) + check_pod_probe_protocol(deployment, "HTTP", 8080) + check_volume_absent(deployment, "truststore") + check_volume_mounts_absent(deployment, "truststore") + check_volume_absent(deployment, "keystore") + check_volume_mounts_absent(deployment, "keystore") + + +def test_pod_template_no_truststore(full_route): + del full_route["spec"]["tls"]["truststore"] + + deployment = _new_deployment(full_route) + + check_pod_probe_protocol(deployment, "HTTPS", 8443) check_volume_absent(deployment, "truststore") check_volume_mounts_absent(deployment, "truststore") +def test_pod_template_no_keystore(full_route): + del full_route["spec"]["tls"]["keystore"] + + deployment = _new_deployment(full_route) + + check_pod_probe_protocol(deployment, "HTTP", 8080) + check_volume_absent(deployment, "keystore") + check_volume_mounts_absent(deployment, "keystore") + + +def test_jdk_options_pkcs12_type(full_route): + tls_config = full_route["spec"]["tls"] + options = _get_java_jdk_options(tls_config) + + assert options["name"] == JDK_OPTIONS_ENV_NAME + + expected_options = "-Djavax.net.ssl.trustStore=/etc/cabundle/test-truststore.p12 -Djavax.net.ssl.trustStorePassword= -Djavax.net.ssl.trustStoreType=PKCS12" + assert options["value"] == expected_options + + +def test_jdk_options_jks_type(full_route): + tls_config = full_route["spec"]["tls"] + tls_config["truststore"]["type"] = "jks" + tls_config["truststore"]["key"] = "test-truststore.jks" + + options = _get_java_jdk_options(tls_config) + assert options["name"] == JDK_OPTIONS_ENV_NAME + + expected_options = "-Djavax.net.ssl.trustStore=/etc/cabundle/test-truststore.jks -Djavax.net.ssl.trustStorePassword=changeit -Djavax.net.ssl.trustStoreType=JKS" + assert options["value"] == expected_options + + +def test_env_vars_no_keystore(full_route): + del full_route["spec"]["tls"]["keystore"] + + options = _generate_container_env_vars(full_route) + + assert not any(x for x in options if x.get('name') == 'SERVER_SSL_KEYSTOREPASSWORD') + + def test_deployment_missing_labels(full_route): del full_route["spec"]["labels"] @@ -196,6 +220,16 @@ def check_volume_absent(deployment: Mapping, name: str): vol_names = [v["name"] for v in vols] assert name not in vol_names +def check_pod_probe_protocol(deployment: Mapping, scheme: str, port: int): + liveness_probe = get_container(deployment)["livenessProbe"] + readiness_probe = get_container(deployment)["readinessProbe"] + + assert liveness_probe["httpGet"]["port"] == port + assert liveness_probe["httpGet"]["scheme"] == scheme + + assert readiness_probe["httpGet"]["port"] == port + assert readiness_probe["httpGet"]["scheme"] == scheme + def check_volume_mounts_absent(deployment: Mapping, name: str): mounts = get_container(deployment)["volumeMounts"]