Skip to main content

Web based SSH Instant Access

Webshell (webshell.drheck.dev) Setup & Rollout Guide

This i a setup guide for rolling out webshell.drheck.dev, a browser-based SSH terminal service authenticated via Authentik Single Sign-On (SSO) and short-lived OpenSSH certificates signed by Smallstep (step-ca).


1. Architectural Overview

[ Browser Client ]
        │
        ▼ (HTTPS / WebSocket)
[ Nginx Reverse Proxy (/etc/nginx/sites-available/webshell) ]
        │
        ├── auth_request ──► [ Authentik Embedded Outpost ] (SSO Auth & Claims)
        │
        ▼ (HTTP / WebSocket + X-Authentik-Username Header)
[ Webshell Container (ttyd + login-wrapper.sh) on eth77 fd77:...:771d:7681 ]
        │
        ├── Step SSH Certificate Sign ──► [ Step-CA Container on fd77:...:ca:9000 ]
        │
        ▼ (SSH with Ephemeral Ed25519 Certificate over IPv6)
[ Host ul OpenSSH Daemon on [fd77:7777:5010:0:1337:f1ac:d00d:337]:22 ]

Key Components

  1. step-ca: Smallstep Certificate Authority issuing 5-minute ephemeral Ed25519 user certificates, running as user akadmin (1101:1101).
  2. webshell: Debian Bookworm container running static ttyd 1.7.4, step CLI 0.25.0, and login-wrapper.sh.
  3. Authentik: Provides OIDC integration for step-ca and ForwardAuth single sign-on (webshell.drheck.dev).
  4. Nginx: Terminates TLS and proxies HTTP/WebSocket connections while passing authenticated identity headers (X-Authentik-Username).
  5. OpenSSH (sshd): Configured to trust step-ca signed user certificates for passwordless OS login on host ul.

2. Network & ULA IPv6 Allocation

Every container on host ul binds to a unique ULA IPv6 address on interface eth77 (fd77:7777:5010:0:1337:f1ac:d00d:.../64).

  • step-ca IPv6: fd77:7777:5010:0:1337:f1ac:d00d:ca (Port 9000)
  • webshell IPv6: fd77:7777:5010:0:1337:f1ac:d00d:771d (Port 7681)
  • Host ul ULA IPv6 SSH Target: fd77:7777:5010:0:1337:f1ac:d00d:337 (Port 22)

3. Step 1: Step-CA Configuration & Provisioners

A. Docker Compose & Environment

In /data/docker/webshell/docker-compose.yml, define step-ca (running as 1101:1101) and webshell with dual-network connectivity:

services:
  step-ca:
    image: smallstep/step-ca:latest
    container_name: step-ca
    restart: unless-stopped
    user: "1101:1101"
    env_file: .env
    volumes:
      - ./step-ca:/home/step:rw
    ports:
      - "[fd77:7777:5010:0:1337:f1ac:d00d:ca]:9000:9000"
    networks:
      - infra_net

  webshell:
    build: .
    container_name: webshell
    restart: unless-stopped
    environment:
      - STEP_CA_URL=https://step-ca:9000
    volumes:
      - ./login-wrapper.sh:/usr/local/bin/login-wrapper.sh:ro
      - ./step-ca/certs/root_ca.crt:/etc/step-ca/root_ca.crt:ro
      - ./step-ca/secrets/webshell_svc_pwd:/etc/step-ca/webshell_svc_pwd:ro
    ports:
      - "[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681:7681"
    networks:
      - infra_net
      - webshell_net
    depends_on:
      - step-ca

networks:
  infra_net:
    external: true
  webshell_net:
    enable_ipv6: true
    ipam:
      config:
        - subnet: fd77:7777:5010:771d::/64

B. CA Directory Permissions

On host ul, restrict access to all CA keys and provisioner passwords to user akadmin:akadmin (1101:1101) with mode 700:

sudo chown -R 1101:1101 /data/docker/webshell/step-ca
sudo chmod -R o-rwx /data/docker/webshell/step-ca
sudo chmod 700 /data/docker/webshell/step-ca/secrets /data/docker/webshell/step-ca/secrets/*

4. Step 2: Host ul OpenSSH Daemon Trust Setup

To allow sshd on host ul ([fd77:7777:5010:0:1337:f1ac:d00d:337]:22) to accept certificates signed by step-ca:

  1. Extract the CA user public key:
    docker exec step-ca step certificate inspect --format json /home/step/certs/user_ca_key.pub
    
  2. Append the public key (ecdsa-sha2-nistp256 ... or ssh-ed25519 ...) to /etc/ssh/drheck_ca_user_key.pub.
  3. Verify that /etc/ssh/sshd_config.d/10-drheck-certs.conf contains:
    TrustedUserCAKeys /etc/ssh/drheck_ca_user_key.pub
    
  4. Reload OpenSSH daemon:
    sudo systemctl reload ssh
    

5. Step 3: Web Terminal Backend (ttyd + login-wrapper.sh) Setup

A. Dockerfile (/data/docker/webshell/Dockerfile)

Mandatory: Include -6 and -i :: in CMD so ttyd binds to IPv6 inside the container when connected to an IPv6 Docker bridge network.

FROM debian:bookworm-slim

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends     ca-certificates curl openssh-client bash procps     && rm -rf /var/lib/apt/lists/*

# Install static ttyd 1.7.4 & step CLI 0.25.0
RUN curl -sSL https://github.com/tsl0922/ttyd/releases/download/1.7.4/ttyd.x86_64 -o /usr/local/bin/ttyd     && chmod +x /usr/local/bin/ttyd     && curl -sSL https://dl.step.sm/gh-release/cli/docs-step-cli/v0.25.0/step_linux_0.25.0_amd64.tar.gz | tar -xz -C /tmp     && mv /tmp/step_0.25.0/bin/step /usr/local/bin/step     && chmod +x /usr/local/bin/step     && rm -rf /tmp/step_0.25.0

COPY login-wrapper.sh /usr/local/bin/login-wrapper.sh
RUN chmod +x /usr/local/bin/login-wrapper.sh

EXPOSE 7681
ENTRYPOINT ["/usr/local/bin/ttyd"]
CMD ["-6", "-i", "::", "-W", "-H", "X-Authentik-Username", "/usr/local/bin/login-wrapper.sh"]

B. Backend Login Wrapper (/data/docker/webshell/login-wrapper.sh)

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

# Resolve username across CGI-style environment variables exported by ttyd/proxy
TARGET_USER="${X_AUTHENTIK_USERNAME:-${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-${AUTHENTIK_USERNAME:-}}}}"

if [[ -z "${TARGET_USER}" ]]; then
  echo "Authentication Error: Missing Authentik identity." >&2
  exit 1
fi

# Strict alphanumeric regex check
if [[ ! "${TARGET_USER}" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
  echo "Authentication Error: Invalid username format." >&2
  exit 1
fi

# Reject privileged and system OS accounts
case "${TARGET_USER}" in
  root|admin|postgres|www-data|daemon|nobody|bin|sys|sync|games|man|lp|mail|news|uucp|proxy|backup|list|irc|gnats|systemd*|docker*)
    echo "Authentication Error: Access to privileged or system account is forbidden." >&2
    exit 1
    ;;
esac

KEY_DIR="/tmp/ssh_${RANDOM}_$$"
mkdir -m 700 "${KEY_DIR}"
KEY_PATH="${KEY_DIR}/id_ed25519"

cleanup() {
  rm -rf "${KEY_DIR}"
}
trap cleanup EXIT INT TERM

# Generate ephemeral Ed25519 keypair
ssh-keygen -t ed25519 -f "${KEY_PATH}" -N "" -q

# Sign user certificate via JWK provisioner (--sign flag is mandatory for non-interactive execution)
if command -v step >/dev/null 2>&1 && [[ -n "${STEP_CA_URL:-}" ]] && [[ -f "/etc/step-ca/webshell_svc_pwd" ]]; then
  step ssh certificate "${TARGET_USER}" "${KEY_PATH}.pub"     --sign     --provisioner="webshell-svc"     --provisioner-password-file="/etc/step-ca/webshell_svc_pwd"     --ca-url="${STEP_CA_URL}"     --root="/etc/step-ca/root_ca.crt"     --not-after=5m     --force >/dev/null 2>&1 || true
fi

# Login passwordless to host ul SSH daemon over local ULA IPv6
exec ssh -o StrictHostKeyChecking=no          -o UserKnownHostsFile=/dev/null          -i "${KEY_PATH}"          "${TARGET_USER}@fd77:7777:5010:0:1337:f1ac:d00d:337" "$@"

6. Step 4: Authentik Single Sign-On Provisioning (ak shell)

To configure Authentik ForwardAuth without encountering 500 Internal Server Error, Redirect URI Error, or ERR_TOO_MANY_REDIRECTS, execute the following Python provisioning script in authentik-server:

docker exec -i authentik-server ak shell << 'EOF'
from authentik.core.models import Application
from authentik.providers.proxy.models import ProxyProvider, ProxyMode
from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode, RedirectURIType
from authentik.outposts.models import Outpost

SERVICE_NAME = "webshell"
EXTERNAL_HOST = f"https://{SERVICE_NAME}.drheck.dev"

# 1. Inherit default scope property mappings from a working provider template
template_provider = ProxyProvider.objects.get(name="cockpit")

# 2. Create ProxyProvider with complete OAuth, redirect URI, and cookie settings
provider, _ = ProxyProvider.objects.get_or_create(
    name=SERVICE_NAME,
    defaults={
        "external_host": EXTERNAL_HOST,
        "mode": ProxyMode.FORWARD_SINGLE,
        "authorization_flow_id": "f9ad5326-f668-4d1f-861d-43b47f0a002e",
        "cookie_domain": "drheck.dev",
        "grant_types": ["authorization_code", "client_credentials", "password"],
        "redirect_uris": [
            RedirectURI(
                matching_mode=RedirectURIMatchingMode.STRICT,
                url=f"{EXTERNAL_HOST}/outpost.goauthentik.io/callback?X-authentik-auth-callback=true",
                redirect_uri_type=RedirectURIType.AUTHORIZATION
            ),
            RedirectURI(
                matching_mode=RedirectURIMatchingMode.STRICT,
                url=f"{EXTERNAL_HOST}?X-authentik-auth-callback=true",
                redirect_uri_type=RedirectURIType.AUTHORIZATION
            ),
        ]
    }
)

provider.property_mappings.set(template_provider.property_mappings.all())
provider.save()

# 3. Create Application
app, _ = Application.objects.get_or_create(
    slug=SERVICE_NAME,
    defaults={
        "name": SERVICE_NAME.capitalize(),
        "meta_launch_url": f"{EXTERNAL_HOST}/",
        "open_in_new_tab": True,
        "provider": provider,
    }
)

# 4. Mandatory: Assign provider to authentik Embedded Outpost
outpost = Outpost.objects.get(name="authentik Embedded Outpost")
if provider not in outpost.providers.all():
    outpost.providers.add(provider)
    outpost.save()

print(f"Successfully provisioned {SERVICE_NAME} and attached to Embedded Outpost.")
EOF

7. Step 5: Nginx Reverse Proxy Configuration (/etc/nginx/sites-available/webshell)

map $http_upgrade $proxy_target_connection_webshell {
    default upgrade;
    ""      "";
}

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

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

    client_max_body_size 64M;

    # Authentik ForwardAuth Subrequest Configuration
    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;
    auth_request_set $authentik_email $upstream_http_x_authentik_email;

    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 $proxy_target_connection_webshell;
        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;
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Email $authentik_email;
    }

    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;
    }
}

8. Step 6: Backup & Restore Scripts

Every service on host ul requires shellcheck-verified <service>_backup and <service>_restore scripts located in /home/luke/git/backups/ul/scripts/ with 10% par2 redundancy.

  • /home/luke/git/backups/ul/scripts/webshell_backup: Archives /data/docker/webshell/ (.env, docker-compose.yml, step-ca/) to /data/backups/services/webshell/YYYY-MM-DD_HHMM.SS/webshell_config.tar.xz and generates .par2 files via par2 create -r10.
  • /home/luke/git/backups/ul/scripts/webshell_restore: Verifies archives with par2 verify and extracts configurations back into /data/docker/webshell/.