Skip to main content

Web based SSH Instant Access

Zero-Password Per-User SSH Authentication via Authentik SSO & Ephemeral SSH CA

1. Architectural Overview

This document details the design, configuration, and invocation flow for securing the ttyd web terminal service (https://ttyd.drheck.dev) on server ul.

The system integrates Authentik SSO (Identity Provider) with OpenSSH User Certificate Authority (CA) to grant authenticated web users instant, passwordless SSH shell access exclusively to their matching Linux user account.

[ Web Browser ]
      │
      │ 1. HTTPS Request
      ▼
[ Nginx Reverse Proxy ] ───2. auth_request (Cookie check)───► [ Authentik Outpost ]
      │                                                              │
      │ 3. Forward Header: X-Authentik-Username                      │ 2b. HTTP 200 OK + User Headers
      ▼                                                              ▼
[ ttyd Web Terminal Container ]
      │
      │ 4. Spawns Wrapper with -H X-authentik-username
      ▼
[ ttyd-ssh-wrapper.sh ]
      │
      │ 5. Reads $TTYD_USER / $HTTP_X_AUTHENTIK_USERNAME
      │ 6. Generates ephemeral Ed25519 key pair in /tmp
      │ 7. Signs key using ttyd_ca_user_key (Principal = $USER, Valid = +2m)
      │
      │ 8. ssh -i <ephemeral_key> $USER@10.0.0.3
      ▼
[ OpenSSH Daemon (sshd on ul) ]
      │
      │ 9. Validates signature against /etc/ssh/trusted_user_ca_keys.pub
      │ 10. Verifies Principal == target Linux user account
      ▼
[ Native Shell Session for $USER ]

2. Authentication & Execution Lifecycle

Step 1: Web Request & Identity Injection

  1. User visits https://ttyd.drheck.dev/.
  2. Nginx intercepts the request and issues an subrequest to Authentik (/outpost.goauthentik.io/auth/nginx).
  3. If unauthenticated, Nginx redirects the user to the Authentik login portal.
  4. Once authenticated, Authentik returns HTTP 200 OK to Nginx along with identity headers (X-Authentik-Username: <username>).
  5. Nginx forwards the request to ttyd at http://[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681 with the X-authentik-username header set.

Step 2: ttyd Process Spawning

  1. ttyd receives the connection and reads the reverse proxy auth header specified by flag -H X-authentik-username.
  2. ttyd sets the environment variable TTYD_USER=<username> (and forwards HTTP request headers) into the sub-process environment.
  3. ttyd spawns /usr/local/bin/ttyd-ssh-wrapper.sh.

Step 3: Certificate Issuance & SSH Connection

  1. ttyd-ssh-wrapper.sh extracts the authenticated username (USER="${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-...}}").
  2. Security Gate: If $USER is empty or missing, the wrapper outputs Access Denied: No authenticated identity provided by Authentik. and hard-exits (exit 1). Manual prompt input is strictly prohibited to prevent account impersonation.
  3. The wrapper creates a secure temporary directory in /tmp using mktemp -d and sets a bash trap to guarantee cleanup on exit.
  4. Generates an in-memory temporary SSH key pair: ssh-keygen -q -t ed25519 -N "" -f "$KEY".
  5. Signs the public key with the SSH CA private key (/keys/ttyd_ca_user_key):
    ssh-keygen -s /keys/ttyd_ca_user_key -I "ttyd-$USER" -n "$USER" -V +2m "$KEY.pub"
    
    • -I "ttyd-$USER": Log Identifier for OpenSSH audit trails.
    • -n "$USER": Principal Restriction. OpenSSH will ONLY permit this certificate to authenticate as user $USER.
    • -V +2m: Certificate validity is strictly capped at 2 minutes.
  6. Execs ssh -i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$USER@10.0.0.3".

Step 4: SSH Verification & Shell Teardown

  1. OpenSSH (sshd) on 10.0.0.3:22 receives the certificate.
  2. sshd checks /etc/ssh/sshd_config.d/10-drheck-certs.conf, referencing TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys.pub.
  3. sshd verifies that:
    • The certificate was signed by a trusted CA key in trusted_user_ca_keys.pub.
    • Current time falls within the 2-minute validity window.
    • The requested target account matches the certificate principal ($USER).
  4. sshd grants interactive shell access and drops the user into their native home directory.
  5. When the user exits the terminal or closes the browser tab, the wrapper process terminates, triggering the trap that shreds and deletes the temporary key files in /tmp.

3. Configuration & Source Code Reference

A. Container Dockerfile (/data/docker/ttyd/Dockerfile)

Bakes openssh-client into the ttyd image to avoid runtime apt execution:

FROM tsl0922/ttyd:latest
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/*

B. Docker Compose Spec (/data/docker/ttyd/docker-compose.yml)

services:
  ttyd:
    build: .
    image: ttyd-ssh:local
    container_name: ttyd
    restart: unless-stopped
    volumes:
      - ./ttyd_ca_user_key:/keys/ttyd_ca_user_key:ro
      - ./ttyd-ssh-wrapper.sh:/usr/local/bin/ttyd-ssh-wrapper.sh:ro
    command: ["ttyd", "-H", "X-authentik-username", "-W", "-p", "7681", "/usr/local/bin/ttyd-ssh-wrapper.sh"]
    ports:
      - "[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681:7681"

C. Wrapper Script (/data/docker/ttyd/ttyd-ssh-wrapper.sh)

#!/usr/bin/env bash
set -euo pipefail

# Read username forwarded strictly from Nginx/Authentik auth headers
USER="${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-${HTTP_X_AUTHENTIK_META_USERNAME:-${HTTP_X_FORWARDED_USER:-${HTTP_X_WEBAUTH_USER:-}}}}}"

if [ -z "$USER" ]; then
    echo "Access Denied: No authenticated identity provided by Authentik." >&2
    sleep 3
    exit 1
fi

TMP_DIR=$(mktemp -d /tmp/ttyd_ssh_XXXXXX)
trap 'rm -rf "$TMP_DIR"' EXIT

KEY="$TMP_DIR/id_ed25519"

# Generate temporary key pair
ssh-keygen -q -t ed25519 -N "" -f "$KEY"

# Sign SSH certificate restricting principal strictly to $USER valid for 2 minutes
ssh-keygen -s /keys/ttyd_ca_user_key -I "ttyd-$USER" -n "$USER" -V +2m "$KEY.pub" >/dev/null 2>&1

# Connect via SSH as $USER to 10.0.0.3
exec ssh -i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$USER@10.0.0.3" "$@"

D. Nginx Proxy Configuration (/etc/nginx/sites-available/ttyd)

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name ttyd.drheck.dev;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    location / {
        proxy_pass http://[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        auth_request     /outpost.goauthentik.io/auth/nginx;
        error_page       401 = @goauthentik_proxy_signin;
        auth_request_set $auth_cookie $upstream_http_set_cookie;
        add_header       Set-Cookie $auth_cookie;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        proxy_set_header X-authentik-username $authentik_username;
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Forwarded-User $authentik_username;
        proxy_set_header Remote-User $authentik_username;
    }

    location /outpost.goauthentik.io {
        proxy_pass              https://auth.drheck.dev/outpost.goauthentik.io;
        proxy_set_header        Host auth.drheck.dev;
        proxy_set_header        X-Original-URL $scheme://$http_host$request_uri;
        proxy_set_header        X-Forwarded-Host $http_host;
        proxy_set_header        X-Forwarded-Proto $scheme;
        add_header              Set-Cookie $auth_cookie;
        auth_request_set        $auth_cookie $upstream_http_set_cookie;
        proxy_pass_request_body off;
        proxy_set_header        Content-Length "";
    }

    location @goauthentik_proxy_signin {
        internal;
        add_header Set-Cookie $auth_cookie;
        return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
    }
}

E. OpenSSH Configuration (/etc/ssh/sshd_config.d/10-drheck-certs.conf)

TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys.pub
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
  • Note: /etc/ssh/trusted_user_ca_keys.pub contains the public keys for both general user CAs and /data/docker/ttyd/ttyd_ca_user_key.pub.

4. Backup & Disaster Recovery Procedures

Backup Script (/home/luke/git/backups/ul/scripts/ttyd_backup)

Archives the entire /data/docker/ttyd directory (including Dockerfile, docker-compose.yml, ttyd-ssh-wrapper.sh, and ttyd_ca_user_key*) into a PAR2-verified XZ archive:

#!/usr/bin/env bash
set -euo pipefail

TIMESTAMP=$(date +%Y-%m-%d_%H%M.%S)
CURRENT_DIR="/data/backups/services/current/ttyd"
ARCHIVE_DIR="/data/backups/services/archive/ttyd"
mkdir -p "${CURRENT_DIR}" "${ARCHIVE_DIR}"

for existing in "${CURRENT_DIR}"/*; do
  if [ -d "${existing}" ]; then
    mv "${existing}" "${ARCHIVE_DIR}/"
  fi
done

BACKUP_DIR="${CURRENT_DIR}/${TIMESTAMP}"
mkdir -p "${BACKUP_DIR}"

tar -cJf "${BACKUP_DIR}/ttyd_data.tar.xz" -C /data/docker/ttyd .
par2 create -r10 "${BACKUP_DIR}/ttyd_data.tar.xz" >/dev/null

echo "ttyd backup completed: ${BACKUP_DIR}"

Restore Script (/home/luke/git/backups/ul/scripts/ttyd_restore)

Restores the service data and automatically registers the SSH CA key with OpenSSH upon recovery:

#!/usr/bin/env bash
set -euo pipefail

# [Target backup selection logic...]

par2 verify "${TARGET}/ttyd_data.tar.xz.par2"
mkdir -p /data/docker/ttyd
tar -xJf "${TARGET}/ttyd_data.tar.xz" -C /data/docker/ttyd

# Automated CA Key Restoration Hook
if [ -f /data/docker/ttyd/ttyd_ca_user_key.pub ]; then
  cp -f /data/docker/ttyd/ttyd_ca_user_key.pub /etc/ssh/ttyd_ca_user_key.pub
  chmod 644 /etc/ssh/ttyd_ca_user_key.pub
  cat /etc/ssh/drheck_ca_user_key.pub /etc/ssh/ttyd_ca_user_key.pub > /etc/ssh/trusted_user_ca_keys.pub 2>/dev/null || true
  systemctl reload sshd 2>/dev/null || true
fi

echo "ttyd restore completed from ${TARGET}"

5. Security & Threat Model Analysis

  1. Zero Shared Credentials: No master SSH key or administrative credential is ever stored in containers or used for connection initiation.
  2. Strict Identity Isolation: Because -n $USER is set during certificate signing, user alice cannot connect to user bob's account. OpenSSH rejects any connection attempt where the target account does not match the principal signed in the certificate.
  3. Short-Lived Impact Window: Ephemeral certificates expire in 120 seconds. Even if an attacker were to capture a signed certificate from memory, it becomes invalid almost immediately.
  4. Immediate Key Destruction: Temporary private keys exist in /tmp for milliseconds during process startup and are scrubbed via process exit traps.
  5. Privilege-Separated CA: The private CA key (/data/docker/ttyd/ttyd_ca_user_key) is permissions-restricted (0600) and owned by system user luke / docker-data.