Repository: goauthentik/authentik

Issue:
Allow Docker SSH Outposts to use short-lived SSH certificates and customize ./ssh/config
**Is your feature request related to a problem? Please describe.**
I am trying to configure Authentik to use Teleport-generated short-lived SSH certificates.
This is useful because:

1. the certificates are only valid for 60 minutes (configurable lifespan) reducing risks if they are compromised, 
2. once the certificate cycling system is set up, it doesn't require manual intervention,
3. all access and commands can be centrally audited and revoked if necessary,
4. using Teleport (or bastion hosts with custom SSH config files) allows us to access hosts which are normally inaccessible (they can 'phone home' to Teleport).

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

It looks like the current system deploys a new ssh private key onto the filesystem when the docker ssh session is initialized (?).

There are two components to this request:

1. Using a Certificate from the filesystem
I'd like to specify a filesystem path (mounted into the docker containers, likely the containing folder `/opt/machine-id`) for the target Docker Outpost's ssh private key (that way the most recent cycled ssh certificate is used each time).


2. configure my own ssh config file / section.
At the moment the code assembles a host-specific ssh config section and appends the configuration to container's   `~/.ssh/config` file path. I'd like the option to manually specify the block of text that is appended (teleport has some particular sshconfig settings that allow you to proxy the connection through their system).

There are many configuration options which are useful for hopping through bastions, etc.: https://goteleport.com/blog/ssh-config/ which are not currently available in the bare-bones ssh configuration being generated.

https://github.com/goauthentik/authentik/blob/d9d42020cce44599f05998d9cdb1d89aab2cc21f/authentik/outposts/docker_ssh.py#L42-L49


**Describe alternatives you've considered**

I have considered using the documented manual process for establishing ssh links between docker hosts but would prefer to use Teleport for the reasons above.

**Additional context**

I have read over the code and would be happy to take a swing at the modification, I'm comfortable hacking away with backend python, but have not done any web interface components before.

https://github.com/goauthentik/authentik/blob/d9d42020cce44599f05998d9cdb1d89aab2cc21f/authentik/outposts/docker_ssh.py#L29-L35

I stumbled across Authentik a month ago and have loved its ease of use compared to KeyCloak! Thank you 🔥 


Discussion:
I should note that the Teleport Machine-ID system is still a work in progress, though it's nearing it's production release in 9.3/10. I have it working and I really like it.

I'd give some examples of the ssh_config files they are generating but they are currently refactoring them:
https://github.com/gravitational/teleport/pull/11596

Resolving patch:
diff --git a/authentik/outposts/controllers/docker.py b/authentik/outposts/controllers/docker.py
--- a/authentik/outposts/controllers/docker.py
+++ b/authentik/outposts/controllers/docker.py
@@ -15,7 +15,7 @@
 
 from authentik import __version__
 from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
-from authentik.outposts.docker_ssh import DockerInlineSSH
+from authentik.outposts.docker_ssh import DockerInlineSSH, SSHManagedExternallyException
 from authentik.outposts.docker_tls import DockerInlineTLS
 from authentik.outposts.managed import MANAGED_OUTPOST
 from authentik.outposts.models import (
@@ -35,6 +35,7 @@ class DockerClient(UpstreamDockerClient, BaseClient):
     def __init__(self, connection: DockerServiceConnection):
         self.tls = None
         self.ssh = None
+        self.logger = get_logger()
         if connection.local:
             # Same result as DockerClient.from_env
             super().__init__(**kwargs_from_env())
@@ -42,8 +43,12 @@ def __init__(self, connection: DockerServiceConnection):
             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()
+                try:
+                    self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
+                    self.ssh.write()
+                except SSHManagedExternallyException as exc:
+                    # SSH config is managed externally
+                    self.logger.info(f"SSH Managed externally: {exc}")
             else:
                 self.tls = DockerInlineTLS(
                     verification_kp=connection.tls_verification,
@@ -57,7 +62,6 @@ def __init__(self, connection: DockerServiceConnection):
                 )
             except SSHException as exc:
                 raise ServiceConnectionInvalid from exc
-        self.logger = get_logger()
         # Ensure the client actually works
         self.containers.list()
 
diff --git a/authentik/outposts/docker_ssh.py b/authentik/outposts/docker_ssh.py
--- a/authentik/outposts/docker_ssh.py
+++ b/authentik/outposts/docker_ssh.py
@@ -16,6 +16,10 @@ def opener(path, flags):
     return os.open(path, flags, 0o700)
 
 
+class SSHManagedExternallyException(DockerException):
+    """Raised when the ssh config file is managed externally."""
+
+
 class DockerInlineSSH:
     """Create paramiko ssh config from CertificateKeyPair"""
 
@@ -29,9 +33,15 @@ class DockerInlineSSH:
     def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
         self.host = host
         self.keypair = keypair
+        self.config_path = Path("~/.ssh/config").expanduser()
+        if self.config_path.exists() and HEADER not in self.config_path.read_text(encoding="utf-8"):
+            # SSH Config file already exists and there's no header from us, meaning that it's
+            # been externally mapped into the container for more complex configs
+            raise SSHManagedExternallyException(
+                "SSH Config exists and does not contain authentik header"
+            )
         if not self.keypair:
             raise DockerException("keypair must be set for SSH connections")
-        self.config_path = Path("~/.ssh/config").expanduser()
         self.header = f"{HEADER} - {self.host}\n"
 
     def write_config(self, key_path: str) -> bool:


Test patch: