From 0a8d4eecae097533315c8110253e5948937014aa Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 19 Nov 2020 00:53:33 +0100 Subject: [PATCH] outposts: add docker TLS authentication and verification --- Dockerfile | 2 +- passbook/outposts/api.py | 9 ++- passbook/outposts/apps.py | 1 - passbook/outposts/docker_tls.py | 56 +++++++++++++++++++ passbook/outposts/forms.py | 12 +++- .../migrations/0010_service_connection.py | 4 -- .../migrations/0011_docker_tls_auth.py | 45 +++++++++++++++ .../0012_service_connection_non_unique.py | 21 +++++++ passbook/outposts/models.py | 37 +++++++++++- swagger.yaml | 18 ++++-- 10 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 passbook/outposts/docker_tls.py create mode 100644 passbook/outposts/migrations/0011_docker_tls_auth.py create mode 100644 passbook/outposts/migrations/0012_service_connection_non_unique.py diff --git a/Dockerfile b/Dockerfile index 8c7c6e4fbc..1ab59048db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,5 +43,5 @@ COPY ./lifecycle/ /lifecycle USER passbook STOPSIGNAL SIGINT - +ENV TMPDIR /dev/shm/ ENTRYPOINT [ "/lifecycle/bootstrap.sh" ] diff --git a/passbook/outposts/api.py b/passbook/outposts/api.py index e86f4472db..18d6a0bae7 100644 --- a/passbook/outposts/api.py +++ b/passbook/outposts/api.py @@ -33,7 +33,14 @@ class DockerServiceConnectionSerializer(ModelSerializer): class Meta: model = DockerServiceConnection - fields = ["pk", "name", "local", "url", "tls"] + fields = [ + "pk", + "name", + "local", + "url", + "tls_verification", + "tls_authentication", + ] class DockerServiceConnectionViewSet(ModelViewSet): diff --git a/passbook/outposts/apps.py b/passbook/outposts/apps.py index c5934a1d52..c39cdaae4f 100644 --- a/passbook/outposts/apps.py +++ b/passbook/outposts/apps.py @@ -70,5 +70,4 @@ class PassbookOutpostConfig(AppConfig): name="Local Docker connection", local=True, url=unix_socket_path, - tls=True, ) diff --git a/passbook/outposts/docker_tls.py b/passbook/outposts/docker_tls.py new file mode 100644 index 0000000000..3090c797d1 --- /dev/null +++ b/passbook/outposts/docker_tls.py @@ -0,0 +1,56 @@ +"""Create Docker TLSConfig from CertificateKeyPair""" +from pathlib import Path +from tempfile import gettempdir +from typing import Optional + +from docker.tls import TLSConfig + +from passbook.crypto.models import CertificateKeyPair + + +class DockerInlineTLS: + """Create Docker TLSConfig from CertificateKeyPair""" + + verification_kp: Optional[CertificateKeyPair] + authentication_kp: Optional[CertificateKeyPair] + + def __init__( + self, + verification_kp: Optional[CertificateKeyPair], + authentication_kp: Optional[CertificateKeyPair], + ) -> None: + self.verification_kp = verification_kp + self.authentication_kp = authentication_kp + + def write_file(self, name: str, contents: str) -> str: + """Wrapper for mkstemp that uses fdopen""" + path = Path(gettempdir(), name) + with open(path, "w") as _file: + _file.write(contents) + return str(path) + + def write(self) -> TLSConfig: + """Create TLSConfig with Certificate Keypairs""" + # So yes, this is quite ugly. But sadly, there is no clean way to pass + # docker-py (which is using requests (which is using urllib3)) a certificate + # for verification or authentication as string. + # Because we run in docker, and our tmpfs is isolated to us, we can just + # write out the certificates and keys to files and use their paths + config_args = {} + if self.verification_kp: + ca_cert_path = self.write_file( + f"{self.verification_kp.pk.hex}-cert.pem", + self.verification_kp.certificate_data, + ) + config_args["ca_cert"] = ca_cert_path + if self.authentication_kp: + auth_cert_path = self.write_file( + f"{self.authentication_kp.pk.hex}-cert.pem", + self.authentication_kp.certificate_data, + ) + auth_key_path = self.write_file( + f"{self.authentication_kp.pk.hex}-key.pem", + self.authentication_kp.key_data, + ) + config_args["client_cert"] = (auth_cert_path, auth_key_path) + return TLSConfig(**config_args) diff --git a/passbook/outposts/forms.py b/passbook/outposts/forms.py index 3e1b1f98ad..7b925935ff 100644 --- a/passbook/outposts/forms.py +++ b/passbook/outposts/forms.py @@ -4,6 +4,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from passbook.admin.fields import CodeMirrorWidget, YAMLField +from passbook.crypto.models import CertificateKeyPair from passbook.outposts.models import ( DockerServiceConnection, KubernetesServiceConnection, @@ -46,17 +47,24 @@ class OutpostForm(forms.ModelForm): class DockerServiceConnectionForm(forms.ModelForm): """Docker service-connection form""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["tls_authentication"].queryset = CertificateKeyPair.objects.filter( + key_data__isnull=False + ) + class Meta: model = DockerServiceConnection - fields = ["name", "local", "url", "tls"] + fields = ["name", "local", "url", "tls_verification", "tls_authentication"] widgets = { "name": forms.TextInput, "url": forms.TextInput, } labels = { "url": _("URL"), - "tls": _("TLS"), + "tls_verification": _("TLS Verification Certificate"), + "tls_authentication": _("TLS Authentication Certificate"), } diff --git a/passbook/outposts/migrations/0010_service_connection.py b/passbook/outposts/migrations/0010_service_connection.py index 1b8d1035ac..f6ac59f8a1 100644 --- a/passbook/outposts/migrations/0010_service_connection.py +++ b/passbook/outposts/migrations/0010_service_connection.py @@ -20,10 +20,6 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE KubernetesServiceConnection = apps.get_model( "passbook_outposts", "KubernetesServiceConnection" ) - from passbook.outposts.apps import PassbookOutpostConfig - - # Ensure that local connection have been created - PassbookOutpostConfig.init_local_connection(None) docker = DockerServiceConnection.objects.filter(local=True).first() k8s = KubernetesServiceConnection.objects.filter(local=True).first() diff --git a/passbook/outposts/migrations/0011_docker_tls_auth.py b/passbook/outposts/migrations/0011_docker_tls_auth.py new file mode 100644 index 0000000000..584d7f5ceb --- /dev/null +++ b/passbook/outposts/migrations/0011_docker_tls_auth.py @@ -0,0 +1,45 @@ +# Generated by Django 3.1.3 on 2020-11-18 21:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_crypto", "0002_create_self_signed_kp"), + ("passbook_outposts", "0010_service_connection"), + ] + + operations = [ + migrations.RemoveField( + model_name="dockerserviceconnection", + name="tls", + ), + migrations.AddField( + model_name="dockerserviceconnection", + name="tls_authentication", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Certificate/Key used for authentication. Can be left empty for no authentication.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="passbook_crypto.certificatekeypair", + ), + ), + migrations.AddField( + model_name="dockerserviceconnection", + name="tls_verification", + field=models.ForeignKey( + blank=True, + default=None, + help_text="CA which the endpoint's Certificate is verified against. Can be left empty for no validation.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="passbook_crypto.certificatekeypair", + ), + ), + ] diff --git a/passbook/outposts/migrations/0012_service_connection_non_unique.py b/passbook/outposts/migrations/0012_service_connection_non_unique.py new file mode 100644 index 0000000000..87305fcb74 --- /dev/null +++ b/passbook/outposts/migrations/0012_service_connection_non_unique.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.3 on 2020-11-18 21:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_outposts", "0011_docker_tls_auth"), + ] + + operations = [ + migrations.AlterField( + model_name="outpostserviceconnection", + name="local", + field=models.BooleanField( + default=False, + help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration", + ), + ), + ] diff --git a/passbook/outposts/models.py b/passbook/outposts/models.py index a91b21b44f..461acab680 100644 --- a/passbook/outposts/models.py +++ b/passbook/outposts/models.py @@ -24,17 +24,21 @@ from kubernetes.config.incluster_config import load_incluster_config from kubernetes.config.kube_config import load_kube_config_from_dict from model_utils.managers import InheritanceManager from packaging.version import LegacyVersion, Version, parse +from structlog import get_logger from urllib3.exceptions import HTTPError from passbook import __version__ from passbook.core.models import Provider, Token, TokenIntents, User +from passbook.crypto.models import CertificateKeyPair from passbook.lib.config import CONFIG from passbook.lib.models import InheritanceForeignKey from passbook.lib.sentry import SentryIgnoredException from passbook.lib.utils.template import render_to_string +from passbook.outposts.docker_tls import DockerInlineTLS OUR_VERSION = parse(__version__) OUTPOST_HELLO_INTERVAL = 10 +LOGGER = get_logger() class ServiceConnectionInvalid(SentryIgnoredException): @@ -99,7 +103,6 @@ class OutpostServiceConnection(models.Model): local = models.BooleanField( default=False, - unique=True, help_text=_( ( "If enabled, use the local connection. Required Docker " @@ -138,7 +141,31 @@ class DockerServiceConnection(OutpostServiceConnection): """Service Connection to a Docker endpoint""" url = models.TextField() - tls = models.BooleanField() + tls_verification = models.ForeignKey( + CertificateKeyPair, + null=True, + blank=True, + default=None, + related_name="+", + on_delete=models.SET_DEFAULT, + help_text=_( + ( + "CA which the endpoint's Certificate is verified against. " + "Can be left empty for no validation." + ) + ), + ) + tls_authentication = models.ForeignKey( + CertificateKeyPair, + null=True, + blank=True, + default=None, + related_name="+", + on_delete=models.SET_DEFAULT, + help_text=_( + "Certificate/Key used for authentication. Can be left empty for no authentication." + ), + ) @property def form(self) -> Type[ModelForm]: @@ -158,10 +185,14 @@ class DockerServiceConnection(OutpostServiceConnection): else: client = DockerClient( base_url=self.url, - tls=self.tls, + tls=DockerInlineTLS( + verification_kp=self.tls_verification, + authentication_kp=self.tls_authentication, + ).write(), ) client.containers.list() except DockerException as exc: + LOGGER.error(exc) raise ServiceConnectionInvalid from exc return client diff --git a/swagger.yaml b/swagger.yaml index 9b962cd809..2bf026fb3c 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -6860,7 +6860,6 @@ definitions: required: - name - url - - tls type: object properties: pk: @@ -6881,9 +6880,20 @@ definitions: title: Url type: string minLength: 1 - tls: - title: Tls - type: boolean + tls_verification: + title: Tls verification + description: CA which the endpoint's Certificate is verified against. Can + be left empty for no validation. + type: string + format: uuid + x-nullable: true + tls_authentication: + title: Tls authentication + description: Certificate/Key used for authentication. Can be left empty for + no authentication. + type: string + format: uuid + x-nullable: true KubernetesServiceConnection: description: KubernetesServiceConnection Serializer required: