From 6510b97c1e9399be457a6bd3912f25520900aae3 Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Sat, 25 Dec 2021 16:31:34 +0100
Subject: [PATCH] outposts: add remote docker integration via SSH
Signed-off-by: Jens Langhammer
---
authentik/outposts/controllers/base.py | 29 ++++++-
authentik/outposts/controllers/docker.py | 69 +++++++++++++--
authentik/outposts/controllers/kubernetes.py | 42 ++++++++-
authentik/outposts/docker_ssh.py | 77 +++++++++++++++++
authentik/outposts/docker_tls.py | 10 +++
authentik/outposts/models.py | 65 --------------
authentik/outposts/tasks.py | 34 +++++---
poetry.lock | 86 ++++++++++++++++++-
pyproject.toml | 1 +
web/src/locales/en.po | 20 ++++-
web/src/locales/fr_FR.po | 20 ++++-
web/src/locales/pseudo-LOCALE.po | 16 +++-
.../outposts/ServiceConnectionDockerForm.ts | 7 +-
website/docs/outposts/integrations/docker.md | 24 +++++-
14 files changed, 397 insertions(+), 103 deletions(-)
create mode 100644 authentik/outposts/docker_ssh.py
diff --git a/authentik/outposts/controllers/base.py b/authentik/outposts/controllers/base.py
index 6f00015dd..fbd665dad 100644
--- a/authentik/outposts/controllers/base.py
+++ b/authentik/outposts/controllers/base.py
@@ -9,7 +9,11 @@ from structlog.testing import capture_logs
from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.lib.config import CONFIG
from authentik.lib.sentry import SentryIgnoredException
-from authentik.outposts.models import Outpost, OutpostServiceConnection
+from authentik.outposts.models import (
+ Outpost,
+ OutpostServiceConnection,
+ OutpostServiceConnectionState,
+)
FIELD_MANAGER = "goauthentik.io"
@@ -28,11 +32,25 @@ class DeploymentPort:
inner_port: Optional[int] = None
+class BaseClient:
+ """Base class for custom clients"""
+
+ def fetch_state(self) -> OutpostServiceConnectionState:
+ """Get state, version info"""
+ raise NotImplementedError
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ """Cleanup after usage"""
+
+
class BaseController:
"""Base Outpost deployment controller"""
deployment_ports: list[DeploymentPort]
-
+ client: BaseClient
outpost: Outpost
connection: OutpostServiceConnection
@@ -63,6 +81,13 @@ class BaseController:
self.down()
return [x["event"] for x in logs]
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ """Cleanup after usage"""
+ self.client.__exit__()
+
def get_static_deployment(self) -> str:
"""Return a static deployment configuration"""
raise NotImplementedError
diff --git a/authentik/outposts/controllers/docker.py b/authentik/outposts/controllers/docker.py
index adae08ef7..0567e6464 100644
--- a/authentik/outposts/controllers/docker.py
+++ b/authentik/outposts/controllers/docker.py
@@ -1,17 +1,75 @@
"""Docker controller"""
from time import sleep
+from typing import Optional
+from urllib.parse import urlparse
from django.conf import settings
from django.utils.text import slugify
-from docker import DockerClient
+from docker import DockerClient as UpstreamDockerClient
from docker.errors import DockerException, NotFound
from docker.models.containers import Container
+from docker.utils.utils import kwargs_from_env
+from structlog.stdlib import get_logger
from yaml import safe_dump
from authentik import __version__
-from authentik.outposts.controllers.base import BaseController, ControllerException
+from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
+from authentik.outposts.docker_ssh import DockerInlineSSH
+from authentik.outposts.docker_tls import DockerInlineTLS
from authentik.outposts.managed import MANAGED_OUTPOST
-from authentik.outposts.models import DockerServiceConnection, Outpost, ServiceConnectionInvalid
+from authentik.outposts.models import (
+ DockerServiceConnection,
+ Outpost,
+ OutpostServiceConnectionState,
+ ServiceConnectionInvalid,
+)
+
+
+class DockerClient(UpstreamDockerClient, BaseClient):
+ """Custom docker client, which can handle TLS and SSH from a database."""
+
+ tls: Optional[DockerInlineTLS]
+ ssh: Optional[DockerInlineSSH]
+
+ def __init__(self, connection: DockerServiceConnection):
+ self.tls = None
+ self.ssh = None
+ if connection.local:
+ # Same result as DockerClient.from_env
+ super().__init__(kwargs_from_env())
+ else:
+ parsed_url = urlparse(connection.url)
+ tls_config = False
+ if parsed_url.scheme == "ssh":
+ self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
+ self.ssh.write()
+ else:
+ self.tls = DockerInlineTLS(
+ verification_kp=connection.tls_verification,
+ authentication_kp=connection.tls_authentication,
+ )
+ tls_config = self.tls.write()
+ super().__init__(
+ base_url=connection.url,
+ tls=tls_config,
+ )
+ self.logger = get_logger()
+ # Ensure the client actually works
+ self.containers.list()
+
+ def fetch_state(self) -> OutpostServiceConnectionState:
+ try:
+ return OutpostServiceConnectionState(version=self.info()["ServerVersion"], healthy=True)
+ except (ServiceConnectionInvalid, DockerException):
+ return OutpostServiceConnectionState(version="", healthy=False)
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ if self.tls:
+ self.logger.debug("Cleaning up TLS")
+ self.tls.cleanup()
+ if self.ssh:
+ self.logger.debug("Cleaning up SSH")
+ self.ssh.cleanup()
class DockerController(BaseController):
@@ -27,8 +85,9 @@ class DockerController(BaseController):
if outpost.managed == MANAGED_OUTPOST:
return
try:
- self.client = connection.client()
- except ServiceConnectionInvalid as exc:
+ self.client = DockerClient(connection)
+ except DockerException as exc:
+ self.logger.warning(exc)
raise ControllerException from exc
@property
diff --git a/authentik/outposts/controllers/kubernetes.py b/authentik/outposts/controllers/kubernetes.py
index 8fd6a1b57..45c966b2e 100644
--- a/authentik/outposts/controllers/kubernetes.py
+++ b/authentik/outposts/controllers/kubernetes.py
@@ -2,19 +2,53 @@
from io import StringIO
from typing import Type
+from kubernetes.client import VersionApi, VersionInfo
from kubernetes.client.api_client import ApiClient
+from kubernetes.client.configuration import Configuration
from kubernetes.client.exceptions import OpenApiException
+from kubernetes.config.config_exception import ConfigException
+from kubernetes.config.incluster_config import load_incluster_config
+from kubernetes.config.kube_config import load_kube_config_from_dict
from structlog.testing import capture_logs
from urllib3.exceptions import HTTPError
from yaml import dump_all
-from authentik.outposts.controllers.base import BaseController, ControllerException
+from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
from authentik.outposts.controllers.k8s.secret import SecretReconciler
from authentik.outposts.controllers.k8s.service import ServiceReconciler
from authentik.outposts.controllers.k8s.service_monitor import PrometheusServiceMonitorReconciler
-from authentik.outposts.models import KubernetesServiceConnection, Outpost, ServiceConnectionInvalid
+from authentik.outposts.models import (
+ KubernetesServiceConnection,
+ Outpost,
+ OutpostServiceConnectionState,
+ ServiceConnectionInvalid,
+)
+
+
+class KubernetesClient(ApiClient, BaseClient):
+ """Custom kubernetes client based on service connection"""
+
+ def __init__(self, connection: KubernetesServiceConnection):
+ config = Configuration()
+ try:
+ if connection.local:
+ load_incluster_config(client_configuration=config)
+ else:
+ load_kube_config_from_dict(connection.kubeconfig, client_configuration=config)
+ super().__init__(config)
+ except ConfigException as exc:
+ raise ServiceConnectionInvalid from exc
+
+ def fetch_state(self) -> OutpostServiceConnectionState:
+ """Get version info"""
+ try:
+ api_instance = VersionApi(self)
+ version: VersionInfo = api_instance.get_code()
+ return OutpostServiceConnectionState(version=version.git_version, healthy=True)
+ except (OpenApiException, HTTPError, ServiceConnectionInvalid):
+ return OutpostServiceConnectionState(version="", healthy=False)
class KubernetesController(BaseController):
@@ -23,12 +57,12 @@ class KubernetesController(BaseController):
reconcilers: dict[str, Type[KubernetesObjectReconciler]]
reconcile_order: list[str]
- client: ApiClient
+ client: KubernetesClient
connection: KubernetesServiceConnection
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection) -> None:
super().__init__(outpost, connection)
- self.client = connection.client()
+ self.client = KubernetesClient(connection)
self.reconcilers = {
"secret": SecretReconciler,
"deployment": DeploymentReconciler,
diff --git a/authentik/outposts/docker_ssh.py b/authentik/outposts/docker_ssh.py
new file mode 100644
index 000000000..9f0191244
--- /dev/null
+++ b/authentik/outposts/docker_ssh.py
@@ -0,0 +1,77 @@
+"""Docker SSH helper"""
+import os
+from pathlib import Path
+from tempfile import gettempdir
+
+from authentik.crypto.models import CertificateKeyPair
+
+HEADER = "### Managed by authentik"
+FOOTER = "### End Managed by authentik"
+
+
+def opener(path, flags):
+ """File opener to create files as 700 perms"""
+ return os.open(path, flags, 0o700)
+
+
+class DockerInlineSSH:
+ """Create paramiko ssh config from CertificateKeyPair"""
+
+ host: str
+ keypair: CertificateKeyPair
+
+ key_path: str
+ config_path: Path
+ header: str
+
+ def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
+ self.host = host
+ self.keypair = keypair
+ self.config_path = Path("~/.ssh/config").expanduser()
+ self.header = f"{HEADER} - {self.host}\n"
+
+ def write_config(self, key_path: str) -> bool:
+ """Update the local user's ssh config file"""
+ with open(self.config_path, "a+", encoding="utf-8") as ssh_config:
+ if self.header in ssh_config.readlines():
+ return False
+ ssh_config.writelines(
+ [
+ self.header,
+ f"Host {self.host}\n",
+ f" IdentityFile {key_path}\n",
+ f"{FOOTER}\n",
+ "\n",
+ ]
+ )
+ return True
+
+ def write_key(self):
+ """Write keypair's private key to a temporary file"""
+ path = Path(gettempdir(), f"{self.keypair.pk}_private.pem")
+ with open(path, "w", encoding="utf8", opener=opener) as _file:
+ _file.write(self.keypair.key_data)
+ return str(path)
+
+ def write(self):
+ """Write keyfile and update ssh config"""
+ self.key_path = self.write_key()
+ was_written = self.write_config(self.key_path)
+ if not was_written:
+ self.cleanup()
+
+ def cleanup(self):
+ """Cleanup when we're done"""
+ os.unlink(self.key_path)
+ with open(self.config_path, "r+", encoding="utf-8") as ssh_config:
+ start = 0
+ end = 0
+ lines = ssh_config.readlines()
+ for idx, line in enumerate(lines):
+ if line == self.header:
+ start = idx
+ if start != 0 and line == f"{FOOTER}\n":
+ end = idx
+ with open(self.config_path, "w+", encoding="utf-8") as ssh_config:
+ lines = lines[:start] + lines[end + 2 :]
+ ssh_config.writelines(lines)
diff --git a/authentik/outposts/docker_tls.py b/authentik/outposts/docker_tls.py
index 0630571f3..79e9bdaca 100644
--- a/authentik/outposts/docker_tls.py
+++ b/authentik/outposts/docker_tls.py
@@ -1,4 +1,5 @@
"""Create Docker TLSConfig from CertificateKeyPair"""
+from os import unlink
from pathlib import Path
from tempfile import gettempdir
from typing import Optional
@@ -14,6 +15,8 @@ class DockerInlineTLS:
verification_kp: Optional[CertificateKeyPair]
authentication_kp: Optional[CertificateKeyPair]
+ _paths: list[str]
+
def __init__(
self,
verification_kp: Optional[CertificateKeyPair],
@@ -21,14 +24,21 @@ class DockerInlineTLS:
) -> None:
self.verification_kp = verification_kp
self.authentication_kp = authentication_kp
+ self._paths = []
def write_file(self, name: str, contents: str) -> str:
"""Wrapper for mkstemp that uses fdopen"""
path = Path(gettempdir(), name)
with open(path, "w", encoding="utf8") as _file:
_file.write(contents)
+ self._paths.append(str(path))
return str(path)
+ def cleanup(self):
+ """Clean up certificates when we're done"""
+ for path in self._paths:
+ unlink(path)
+
def write(self) -> TLSConfig:
"""Create TLSConfig with Certificate Key pairs"""
# So yes, this is quite ugly. But sadly, there is no clean way to pass
diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py
index 7f73b4510..0b0afe63d 100644
--- a/authentik/outposts/models.py
+++ b/authentik/outposts/models.py
@@ -11,21 +11,11 @@ from django.core.cache import cache
from django.db import IntegrityError, models, transaction
from django.db.models.base import Model
from django.utils.translation import gettext_lazy as _
-from docker.client import DockerClient
-from docker.errors import DockerException
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
-from kubernetes.client import VersionApi, VersionInfo
-from kubernetes.client.api_client import ApiClient
-from kubernetes.client.configuration import Configuration
-from kubernetes.client.exceptions import OpenApiException
-from kubernetes.config.config_exception import ConfigException
-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.stdlib import get_logger
-from urllib3.exceptions import HTTPError
from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.core.models import (
@@ -44,7 +34,6 @@ from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string
from authentik.managed.models import ManagedModel
from authentik.outposts.controllers.k8s.utils import get_namespace
-from authentik.outposts.docker_tls import DockerInlineTLS
from authentik.tenants.models import Tenant
OUR_VERSION = parse(__version__)
@@ -150,10 +139,6 @@ class OutpostServiceConnection(models.Model):
return OutpostServiceConnectionState("", False)
return state
- def fetch_state(self) -> OutpostServiceConnectionState:
- """Fetch current Service Connection state"""
- raise NotImplementedError
-
@property
def component(self) -> str:
"""Return component used to edit this object"""
@@ -211,35 +196,6 @@ class DockerServiceConnection(OutpostServiceConnection):
def __str__(self) -> str:
return f"Docker Service-Connection {self.name}"
- def client(self) -> DockerClient:
- """Get DockerClient"""
- try:
- client = None
- if self.local:
- client = DockerClient.from_env()
- else:
- client = DockerClient(
- base_url=self.url,
- tls=DockerInlineTLS(
- verification_kp=self.tls_verification,
- authentication_kp=self.tls_authentication,
- ).write(),
- )
- client.containers.list()
- except DockerException as exc:
- LOGGER.warning(exc)
- raise ServiceConnectionInvalid from exc
- return client
-
- def fetch_state(self) -> OutpostServiceConnectionState:
- try:
- client = self.client()
- return OutpostServiceConnectionState(
- version=client.info()["ServerVersion"], healthy=True
- )
- except ServiceConnectionInvalid:
- return OutpostServiceConnectionState(version="", healthy=False)
-
class Meta:
verbose_name = _("Docker Service-Connection")
@@ -266,27 +222,6 @@ class KubernetesServiceConnection(OutpostServiceConnection):
def __str__(self) -> str:
return f"Kubernetes Service-Connection {self.name}"
- def fetch_state(self) -> OutpostServiceConnectionState:
- try:
- client = self.client()
- api_instance = VersionApi(client)
- version: VersionInfo = api_instance.get_code()
- return OutpostServiceConnectionState(version=version.git_version, healthy=True)
- except (OpenApiException, HTTPError, ServiceConnectionInvalid):
- return OutpostServiceConnectionState(version="", healthy=False)
-
- def client(self) -> ApiClient:
- """Get Kubernetes client configured from kubeconfig"""
- config = Configuration()
- try:
- if self.local:
- load_incluster_config(client_configuration=config)
- else:
- load_kube_config_from_dict(self.kubeconfig, client_configuration=config)
- return ApiClient(config)
- except ConfigException as exc:
- raise ServiceConnectionInvalid from exc
-
class Meta:
verbose_name = _("Kubernetes Service-Connection")
diff --git a/authentik/outposts/tasks.py b/authentik/outposts/tasks.py
index 2b9c9e07e..64acd1c1a 100644
--- a/authentik/outposts/tasks.py
+++ b/authentik/outposts/tasks.py
@@ -25,6 +25,8 @@ from authentik.events.monitored_tasks import (
)
from authentik.lib.utils.reflection import path_to_class
from authentik.outposts.controllers.base import BaseController, ControllerException
+from authentik.outposts.controllers.docker import DockerClient
+from authentik.outposts.controllers.kubernetes import KubernetesClient
from authentik.outposts.models import (
DockerServiceConnection,
KubernetesServiceConnection,
@@ -45,21 +47,21 @@ LOGGER = get_logger()
CACHE_KEY_OUTPOST_DOWN = "outpost_teardown_%s"
-def controller_for_outpost(outpost: Outpost) -> Optional[BaseController]:
+def controller_for_outpost(outpost: Outpost) -> Optional[type[BaseController]]:
"""Get a controller for the outpost, when a service connection is defined"""
if not outpost.service_connection:
return None
service_connection = outpost.service_connection
if outpost.type == OutpostType.PROXY:
if isinstance(service_connection, DockerServiceConnection):
- return ProxyDockerController(outpost, service_connection)
+ return ProxyDockerController
if isinstance(service_connection, KubernetesServiceConnection):
- return ProxyKubernetesController(outpost, service_connection)
+ return ProxyKubernetesController
if outpost.type == OutpostType.LDAP:
if isinstance(service_connection, DockerServiceConnection):
- return LDAPDockerController(outpost, service_connection)
+ return LDAPDockerController
if isinstance(service_connection, KubernetesServiceConnection):
- return LDAPKubernetesController(outpost, service_connection)
+ return LDAPKubernetesController
return None
@@ -71,7 +73,12 @@ def outpost_service_connection_state(connection_pk: Any):
)
if not connection:
return
- state = connection.fetch_state()
+ if isinstance(connection, DockerServiceConnection):
+ cls = DockerClient
+ if isinstance(connection, KubernetesServiceConnection):
+ cls = KubernetesClient
+ with cls(connection) as client:
+ state = client.fetch_state()
cache.set(connection.state_key, state, timeout=None)
@@ -114,14 +121,15 @@ def outpost_controller(
return
self.set_uid(slugify(outpost.name))
try:
- controller = controller_for_outpost(outpost)
- if not controller:
+ controller_type = controller_for_outpost(outpost)
+ if not controller_type:
return
- logs = getattr(controller, f"{action}_with_logs")()
- LOGGER.debug("---------------Outpost Controller logs starting----------------")
- for log in logs:
- LOGGER.debug(log)
- LOGGER.debug("-----------------Outpost Controller logs end-------------------")
+ with controller_type(outpost, outpost.service_connection) as controller:
+ logs = getattr(controller, f"{action}_with_logs")()
+ LOGGER.debug("---------------Outpost Controller logs starting----------------")
+ for log in logs:
+ LOGGER.debug(log)
+ LOGGER.debug("-----------------Outpost Controller logs end-------------------")
except (ControllerException, ServiceConnectionInvalid) as exc:
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
else:
diff --git a/poetry.lock b/poetry.lock
index cfa547753..d3ec9302c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -175,6 +175,22 @@ GitPython = ">=1.0.1"
PyYAML = ">=5.3.1"
stevedore = ">=1.20.0"
+[[package]]
+name = "bcrypt"
+version = "3.2.0"
+description = "Modern password hashing for your software and your servers"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+cffi = ">=1.1"
+six = ">=1.4.1"
+
+[package.extras]
+tests = ["pytest (>=3.2.1,!=3.3.0)"]
+typecheck = ["mypy"]
+
[[package]]
name = "billiard"
version = "3.6.4.0"
@@ -1162,6 +1178,25 @@ python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+[[package]]
+name = "paramiko"
+version = "2.9.1"
+description = "SSH2 protocol library"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+bcrypt = ">=3.1.3"
+cryptography = ">=2.5"
+pynacl = ">=1.0.1"
+
+[package.extras]
+all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
+ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"]
+gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
+invoke = ["invoke (>=1.3)"]
+
[[package]]
name = "pathspec"
version = "0.9.0"
@@ -1332,6 +1367,22 @@ python-versions = "*"
[package.dependencies]
pylint = ">=1.7"
+[[package]]
+name = "pynacl"
+version = "1.4.0"
+description = "Python binding to the Networking and Cryptography (NaCl) library"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+cffi = ">=1.4.1"
+six = "*"
+
+[package.extras]
+docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
+tests = ["pytest (>=3.2.1,!=3.3.0)", "hypothesis (>=3.27.0)"]
+
[[package]]
name = "pyopenssl"
version = "21.0.0"
@@ -2024,7 +2075,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
-content-hash = "2caacecbae1850c6cd20e52ce70723b2e21e57cc54a9a3cd9dd8e00e6a6da481"
+content-hash = "39b437e0cbd49396c867f8a7cfd3d0581facfbb830e069f758c71c89be09d1f6"
[metadata.files]
aiohttp = [
@@ -2152,6 +2203,15 @@ bandit = [
{file = "bandit-1.7.1-py3-none-any.whl", hash = "sha256:f5acd838e59c038a159b5c621cf0f8270b279e884eadd7b782d7491c02add0d4"},
{file = "bandit-1.7.1.tar.gz", hash = "sha256:a81b00b5436e6880fa8ad6799bc830e02032047713cbb143a12939ac67eb756c"},
]
+bcrypt = [
+ {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"},
+ {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"},
+ {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"},
+ {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"},
+ {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"},
+ {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"},
+ {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"},
+]
billiard = [
{file = "billiard-3.6.4.0-py3-none-any.whl", hash = "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b"},
{file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"},
@@ -2903,6 +2963,10 @@ packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
+paramiko = [
+ {file = "paramiko-2.9.1-py2.py3-none-any.whl", hash = "sha256:db5d3f19607941b1c90233588d60213c874392c4961c6297037da989c24f8070"},
+ {file = "paramiko-2.9.1.tar.gz", hash = "sha256:a1fdded3b55f61d23389e4fe52d9ae428960ac958d2edf50373faa5d8926edd0"},
+]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
@@ -3051,6 +3115,26 @@ pylint-plugin-utils = [
{file = "pylint-plugin-utils-0.6.tar.gz", hash = "sha256:57625dcca20140f43731311cd8fd879318bf45a8b0fd17020717a8781714a25a"},
{file = "pylint_plugin_utils-0.6-py3-none-any.whl", hash = "sha256:2f30510e1c46edf268d3a195b2849bd98a1b9433229bb2ba63b8d776e1fc4d0a"},
]
+pynacl = [
+ {file = "PyNaCl-1.4.0-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff"},
+ {file = "PyNaCl-1.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514"},
+ {file = "PyNaCl-1.4.0-cp27-cp27m-win32.whl", hash = "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574"},
+ {file = "PyNaCl-1.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80"},
+ {file = "PyNaCl-1.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7"},
+ {file = "PyNaCl-1.4.0-cp35-abi3-macosx_10_10_x86_64.whl", hash = "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122"},
+ {file = "PyNaCl-1.4.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d"},
+ {file = "PyNaCl-1.4.0-cp35-abi3-win32.whl", hash = "sha256:4e10569f8cbed81cb7526ae137049759d2a8d57726d52c1a000a3ce366779634"},
+ {file = "PyNaCl-1.4.0-cp35-abi3-win_amd64.whl", hash = "sha256:c914f78da4953b33d4685e3cdc7ce63401247a21425c16a39760e282075ac4a6"},
+ {file = "PyNaCl-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4"},
+ {file = "PyNaCl-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25"},
+ {file = "PyNaCl-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4"},
+ {file = "PyNaCl-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6"},
+ {file = "PyNaCl-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f"},
+ {file = "PyNaCl-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f"},
+ {file = "PyNaCl-1.4.0-cp38-cp38-win32.whl", hash = "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96"},
+ {file = "PyNaCl-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420"},
+ {file = "PyNaCl-1.4.0.tar.gz", hash = "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505"},
+]
pyopenssl = [
{file = "pyOpenSSL-21.0.0-py2.py3-none-any.whl", hash = "sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6"},
{file = "pyOpenSSL-21.0.0.tar.gz", hash = "sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3"},
diff --git a/pyproject.toml b/pyproject.toml
index c7fe987b3..24f8a3698 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -145,6 +145,7 @@ webauthn = "*"
xmlsec = "*"
flower = "*"
wsproto = "*"
+paramiko = "^2.9.1"
[tool.poetry.dev-dependencies]
bandit = "*"
diff --git a/web/src/locales/en.po b/web/src/locales/en.po
index 770d13eb0..06b5de472 100644
--- a/web/src/locales/en.po
+++ b/web/src/locales/en.po
@@ -666,8 +666,12 @@ msgid "Callback URL"
msgstr "Callback URL"
#: src/pages/outposts/ServiceConnectionDockerForm.ts
-msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
-msgstr "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
+#~ msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
+#~ msgstr "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
+
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, using 'ssh://' to connect via SSH, or 'https://:2376' when connecting to a remote system."
+msgstr "Can be in the format of 'unix://' when connecting to a local docker daemon, using 'ssh://' to connect via SSH, or 'https://:2376' when connecting to a remote system."
#: src/elements/forms/ConfirmationForm.ts
#: src/elements/forms/DeleteBulkForm.ts
@@ -4885,8 +4889,12 @@ msgid "System task execution"
msgstr "System task execution"
#: src/pages/outposts/ServiceConnectionDockerForm.ts
-msgid "TLS Authentication Certificate"
-msgstr "TLS Authentication Certificate"
+#~ msgid "TLS Authentication Certificate"
+#~ msgstr "TLS Authentication Certificate"
+
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "TLS Authentication Certificate/SSH Keypair"
+msgstr "TLS Authentication Certificate/SSH Keypair"
#:
#~ msgid "TLS Server name"
@@ -5879,6 +5887,10 @@ msgstr "When a valid username/email has been entered, and this option is enabled
msgid "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate."
msgstr "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate."
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "When connecting via SSH, this keypair is used for authentication."
+msgstr "When connecting via SSH, this keypair is used for authentication."
+
#: src/pages/stages/email/EmailStageForm.ts
msgid "When enabled, global Email connection settings will be used and connection settings below will be ignored."
msgstr "When enabled, global Email connection settings will be used and connection settings below will be ignored."
diff --git a/web/src/locales/fr_FR.po b/web/src/locales/fr_FR.po
index 190f4ca88..038038ddf 100644
--- a/web/src/locales/fr_FR.po
+++ b/web/src/locales/fr_FR.po
@@ -669,8 +669,12 @@ msgid "Callback URL"
msgstr "URL de rappel"
#: src/pages/outposts/ServiceConnectionDockerForm.ts
-msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
-msgstr "Peut être au format \"unix://\" pour une connexion à un service docker local, ou \"https://:2376\" pour une connexion à un système distant."
+#~ msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
+#~ msgstr "Peut être au format \"unix://\" pour une connexion à un service docker local, ou \"https://:2376\" pour une connexion à un système distant."
+
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, using 'ssh://' to connect via SSH, or 'https://:2376' when connecting to a remote system."
+msgstr ""
#: src/elements/forms/ConfirmationForm.ts
#: src/elements/forms/DeleteBulkForm.ts
@@ -4841,8 +4845,12 @@ msgid "System task execution"
msgstr "Exécution de tâche système"
#: src/pages/outposts/ServiceConnectionDockerForm.ts
-msgid "TLS Authentication Certificate"
-msgstr "Certificat TLS d'authentification"
+#~ msgid "TLS Authentication Certificate"
+#~ msgstr "Certificat TLS d'authentification"
+
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "TLS Authentication Certificate/SSH Keypair"
+msgstr ""
#~ msgid "TLS Server name"
#~ msgstr "Nom TLS du serveur"
@@ -5818,6 +5826,10 @@ msgstr "Lorsqu'un nom d'utilisateur/email valide a été saisi, et si cette opti
msgid "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate."
msgstr ""
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "When connecting via SSH, this keypair is used for authentication."
+msgstr ""
+
#: src/pages/stages/email/EmailStageForm.ts
msgid "When enabled, global Email connection settings will be used and connection settings below will be ignored."
msgstr "Si activé, les paramètres globaux de connexion courriel seront utilisés et les paramètres de connexion ci-dessous seront ignorés."
diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po
index 179cdecce..e9403bf46 100644
--- a/web/src/locales/pseudo-LOCALE.po
+++ b/web/src/locales/pseudo-LOCALE.po
@@ -662,7 +662,11 @@ msgid "Callback URL"
msgstr ""
#: src/pages/outposts/ServiceConnectionDockerForm.ts
-msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
+#~ msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system."
+#~ msgstr ""
+
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "Can be in the format of 'unix://' when connecting to a local docker daemon, using 'ssh://' to connect via SSH, or 'https://:2376' when connecting to a remote system."
msgstr ""
#: src/elements/forms/ConfirmationForm.ts
@@ -4875,7 +4879,11 @@ msgid "System task execution"
msgstr ""
#: src/pages/outposts/ServiceConnectionDockerForm.ts
-msgid "TLS Authentication Certificate"
+#~ msgid "TLS Authentication Certificate"
+#~ msgstr ""
+
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "TLS Authentication Certificate/SSH Keypair"
msgstr ""
#:
@@ -5859,6 +5867,10 @@ msgstr ""
msgid "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate."
msgstr ""
+#: src/pages/outposts/ServiceConnectionDockerForm.ts
+msgid "When connecting via SSH, this keypair is used for authentication."
+msgstr ""
+
#: src/pages/stages/email/EmailStageForm.ts
msgid "When enabled, global Email connection settings will be used and connection settings below will be ignored."
msgstr ""
diff --git a/web/src/pages/outposts/ServiceConnectionDockerForm.ts b/web/src/pages/outposts/ServiceConnectionDockerForm.ts
index 7700c5a81..f2884956a 100644
--- a/web/src/pages/outposts/ServiceConnectionDockerForm.ts
+++ b/web/src/pages/outposts/ServiceConnectionDockerForm.ts
@@ -72,7 +72,7 @@ export class ServiceConnectionDockerForm extends ModelForm
- ${t`Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system.`}
+ ${t`Can be in the format of 'unix://' when connecting to a local docker daemon, using 'ssh://' to connect via SSH, or 'https://:2376' when connecting to a remote system.`}
+
+ ${t`When connecting via SSH, this keypair is used for authentication.`}
+
`;
}
diff --git a/website/docs/outposts/integrations/docker.md b/website/docs/outposts/integrations/docker.md
index 8c5a90b21..fdf9d9c05 100644
--- a/website/docs/outposts/integrations/docker.md
+++ b/website/docs/outposts/integrations/docker.md
@@ -39,7 +39,7 @@ To minimise the potential risks of mapping the docker socket into a container/gi
- Containers/Kill: Cleanup during upgrades
- Containers/Remove: Removal of outposts
-## Remote hosts
+## Remote hosts (TLS)
To connect remote hosts, you can follow this Guide from Docker [Use TLS (HTTPS) to protect the Docker daemon socket](https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket) to configure Docker.
@@ -49,3 +49,25 @@ Afterwards, create two Certificate-keypairs in authentik:
- `Docker Cert`, with the contents of `~/.docker/cert.pem` as Certificate and `~/.docker/key.pem` as Private key.
Create an integration with `Docker CA` as *TLS Verification Certificate* and `Docker Cert` as *TLS Authentication Certificate*.
+
+## Remote hosts (SSH)
+
+Starting with authentik 2021.12.5, you can connect to remote docker hosts using SSH. To configure this, create a new SSH keypair using these commands:
+
+```
+# Generate the keypair itself, using RSA keys in the PEM format
+ssh-keygen -t rsa -f authentik -N "" -m pem
+# Generate a certificate from the private key, required by authentik.
+# The values that openssl prompts you for are not relevant
+openssl req -x509 -sha256 -nodes -days 365 -out certificate.pem -key authentik
+```
+
+You'll end up with three files:
+
+- `authentik.pub` is the public key, this should be added to the `~/.ssh/authorized_keys` file on the target host and user.
+- `authentik` is the private key, which should be imported into a Keypair in authentik.
+- `certificate.pem` is the matching certificate for the keypair above.
+
+Modify/create a new Docker integration, and set your *Docker URL* to `ssh://hostname`, and select the keypair you created above as *TLS Authentication Certificate/SSH Keypair*.
+
+The *Docker URL* field include a user, if none is specified authentik connects with the user `authentik`.