Web based SSH Instant Access
Zero-PasswordWebshell Per-User(webshell.drheck.dev) Setup & Rollout Guide
This i a setup guide for rolling out webshell.drheck.dev, a browser-based SSH Authenticationterminal service authenticated via Authentik SSOSingle &Sign-On Ephemeral(SSO) SSHand CAshort-lived OpenSSH certificates signed by Smallstep (step-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.
[ WebBrowser BrowserClient ]
│
│▼ 1. (HTTPS Request/ ▼WebSocket)
[ Nginx Reverse Proxy (/etc/nginx/sites-available/webshell) ]
│
├───2. auth_request (Cookie check)───► [ Authentik Embedded Outpost ] (SSO Auth & Claims)
│
▼ (HTTP / WebSocket + X-Authentik-Username Header)
[ Webshell Container (ttyd + login-wrapper.sh) on eth77 fd77:...:771d:7681 ]
│
│├── │Step 3.SSH ForwardCertificate Header:Sign X-Authentik-Username │ 2b. HTTP 200 OK + User Headers
▼ ▼──► [ ttyd Web TerminalStep-CA Container on fd77:...:ca:9000 ]
│
│▼ 4. Spawns Wrapper(SSH with -HEphemeral X-authentik-usernameEd25519 ▼Certificate over IPv6)
[ ttyd-ssh-wrapper.shHost ]
│
│ 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
▼
[ul 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 $USERfd77:7777:5010:0:1337:f1ac:d00d:337]:22 ]
Key
2. Authentication & Execution Lifecycle
Step 1: Web Request & Identity InjectionComponents
Userstep-ca:visitsSmallstep Certificate Authority issuing 5-minute ephemeral Ed25519 user certificates, running as userhttps://ttyd.drheck.dev/akadmin.
/outpost.goauthentik.io/auth/nginx1101:1101).
webshell: ttyd step login-wrapper.sh.
step-ca webshell.drheck.dev).
Nginx: X-Authentik-Username: <username>Username).
sshd): ttydstep-ca http: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]:76817681: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 themode :X-authentik-username700
sudo set.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: ttydHost Processul SpawningOpenSSH 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:
ttydreceivesExtract theconnectionCAanduserreadspublicthekey:reversedockerproxyexecauthstep-caheaderstepspecifiedcertificatebyinspectflag--format-HjsonX-authentik-username/home/step/certs/user_ca_key.pub.ttydsets the environment variableTTYD_USER=<username>(and forwards HTTP request headers) into the sub-process environment.
ttyd/usr/local/bin/ttyd-ssh-wrapper.shStep 3: Certificate Issuance & SSH Connection
ttyd-ssh-wrapper.shUSER="${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-...}}"$USERAccess Denied: No authenticated identity provided by Authentik.exit 1/tmpmktemp -dtrapssh-keygen -q -t ed25519 -N "" -f "$KEY"/keys/ttyd_ca_user_keyecdsa-sha2-nistp256 ... or ssh-ed25519 ...)ssh-keygen -s /keys/ttyd_ca_user_key -I "ttyd-$USER" -n "$USER" -V +2m "$KEY.pub"
-I "ttyd-$USER"-n "$USER"$USER/etc/ssh/drheck_ca_user_key.pub.
-VVerify +2mssh -i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$USER@10.0.0.3"Step 4: SSH Verification & Shell Teardown
sshd10.0.0.3:22sshd/etc/ssh/sshd_config.d/10-drheck-certs.confTrustedUserCAKeys /etc/ssh/trusted_user_ca_keys.drheck_ca_user_key.pub.
sshdtrusted_user_ca_keys.pub$USERsshdsudo systemctl reload ssh
trap/tmp3.5. ConfigurationStep &3: SourceWeb CodeTerminal ReferenceBackend (ttyd + login-wrapper.sh) Setup
A. Container Dockerfile (/data/docker/ttyd/webshell/Dockerfile)
BakesMandatory: Include openssh-client-6intoand the-i :: in CMD so ttyd imagebinds to avoidIPv6 runtimeinside aptthe execution:container when connected to an IPv6 Docker bridge network.
FROM tsl0922/ttyd:latestdebian: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/*
# B.Install Docker Compose Spec (/data/docker/ttyd/docker-compose.yml)
services:
ttyd:
build: .
image: ttyd-ssh:local
container_name:static ttyd restart:1.7.4 unless-stopped& volumes:step CLI 0.25.0
RUN curl -sSL .https:/ttyd_ca_user_key:/keys/ttyd_ca_user_key:rogithub.com/tsl0922/ttyd/releases/download/1.7.4/ttyd.x86_64 -o ./ttyd-ssh-wrapper.sh:/usr/local/bin/ttyd-ssh-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:rosh command:/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", "-W", "-p", "7681"Authentik-Username", "/usr/local/bin/ttyd-ssh-login-wrapper.sh"]
ports:
- "[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681:7681"
C.B. Backend Login Wrapper Script (/data/docker/ttyd/ttyd-ssh-webshell/login-wrapper.sh)
#!/usr/bin/env bash
set -euo pipefail
# ReadResolve username forwardedacross strictlyCGI-style fromenvironment Nginx/Authentikvariables authexported headersby USER=ttyd/proxy
TARGET_USER="${X_AUTHENTIK_USERNAME:-${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-${HTTP_X_AUTHENTIK_META_USERNAME:AUTHENTIK_USERNAME:-${HTTP_X_FORWARDED_USER:-${HTTP_X_WEBAUTH_USER:-}}}}}"
if [[ -z "$USER"{TARGET_USER}" ]]; then
echo "AccessAuthentication Denied:Error: NoMissing authenticatedAuthentik identity provided by Authentik.identity." >&2
sleep 3
exit 1
fi
TMP_DIR=# Strict alphanumeric regex check
if [[ ! "$(mktemp{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 -dm 700 "${KEY_DIR}"
KEY_PATH="${KEY_DIR}/tmp/ttyd_ssh_XXXXXX)id_ed25519"
trapcleanup() '{
rm -rf "$TMP_DIR"'{KEY_DIR}"
}
trap cleanup EXIT KEY="$TMP_DIR/id_ed25519"INT TERM
# Generate temporaryephemeral keyEd25519 pairkeypair
ssh-keygen -q -t ed25519 -f "${KEY_PATH}" -N "" -f "$KEY"q
# Sign SSHuser certificate restrictingvia principalJWK strictlyprovisioner to(--sign $USERflag validis mandatory for 2non-interactive minutesexecution)
ssh-keygenif command -sv /keys/ttyd_ca_user_key -I "ttyd-$USER" -n "$USER" -V +2m "$KEY.pub"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
# ConnectLogin viapasswordless to host ul SSH asdaemon $USERover tolocal 10.0.0.3ULA IPv6
exec ssh -i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "$USER@10.0.0.3"{KEY_PATH}" "${TARGET_USER}@fd77:7777:5010:0:1337:f1ac:d00d:337" "$@"
D.
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/ttydwebshell)
map $http_upgrade $proxy_target_connection_webshell {
default upgrade;
"" "";
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ttyd.webshell.drheck.dev;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
locationclient_max_body_size /64M;
{# proxy_passAuthentik http://[fd77:7777:5010:0:1337:f1ac:d00d:771d]:7681;ForwardAuth proxy_http_versionSubrequest 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;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-authentik-usernameReal-IP $authentik_username;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-Forwarded-UserAuthentik-Email $authentik_username;
proxy_set_header Remote-User $authentik_username;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;
}
}
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
/etc/ssh/trusted_user_ca_keys.pub/data/docker/ttyd/ttyd_ca_user_key.pub4.8. Step 6: Backup & DisasterRestore Recovery ProceduresScripts
Backup
Every Scriptservice (on host ul requires shellcheck-verified <service>_backup and <service>_restore scripts located in /home/luke/git/backups/ul/scripts/ttyd_backup)
with 10% par2 redundancy.
/home/luke/git/backups/ul/scripts/webshell_backup: Archives/data/docker/ttydwebshell/ Dockerfile.env, docker-compose.yml, ttyd-ssh-wrapper.shttyd_ca_user_key*step-ca/) #!/usr/bin/env bash set -euo pipefail TIMESTAMP=$(date +%Y-%m-%d_%H%M.%S) CURRENT_DIR="/data/backups/services/current/ttyd"webshell/YYYY-MM-DD_HHMM.SS/webshell_config.tar.xzARCHIVE_DIR="/data/backups/services/archive/ttyd"andmkdirgenerates-p.par2"${CURRENT_DIR}"files"${ARCHIVE_DIR}"viafor 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.
Restore Script (
/home/luke/git/backups/ul/scripts/ttyd_restorewebshell_restoreRestoresVerifies thearchives servicewith datapar2 verify and automaticallyextracts registersconfigurations theback SSHinto 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/ttydwebshell/.
5.9. SecurityInitial Assumptions & ThreatAttempts Modelvs. AnalysisActual Working Configuration
500 NoInternal masterServer SSHError)
ProxyProvider in -n $USERalicebob404 Not Found from /outpost.goauthentik.io/auth/nginx, which Nginx converts to 500 Internal Server Error.
Explicitly attach providers to authentik Embedded Outpost (outpost.providers.add(provider)) before enabling Nginx traffic.
Redirect URIs (Redirect URI Error)
Instantiating ProxyProvider sets default authorization callback URLs.
The Django ORM defaults redirect_uris to []. Authentik rejects callback URIs against an empty database list.
Explicitly define RedirectURI objects for both /outpost.goauthentik.io/callback and root / in ProxyProvider.redirect_uris.
Cookie Domain & Scopes (ERR_TOO_MANY_REDIRECTS)
Providers operate without defining cookie_domain, grant_types, or property_mappings.
Without property_mappings, the ak_proxy). Without explicit cookie_domain='drheck.dev', Authentik refuses to grant ForwardAuth proxy tokens, causing an endless redirect loop.
Clone property_mappings from cockpit and explicitly set cookie_domain='drheck.dev' and grant_types.
CGI Env Mapping (Missing Authentik identity)
ttyd -H X-Authentik-Username exports X_AUTHENTIK_USERNAME directly to child scripts.
ttyd transforms HTTP headers into standard CGI environment variables (HTTP_X_AUTHENTIK_USERNAME).
Always check CGI-style fallback variations: TARGET_USER="${X_AUTHENTIK_USERNAME:-${TTYD_USER:-${HTTP_X_AUTHENTIK_USERNAME:-${AUTHENTIK_USERNAME:-}}}}".
IPv6 Dual-Stack Listening (502 Bad Gateway)
Default ttyd invocation listens on all interfaces when connected to an IPv6 Docker network.
Without -6 -i ::, ttyd binds only to IPv4 0.0.0.0. When Docker Engine forwards published IPv6 ports to the container's IPv6 address, Nginx connections are rejected (Connection refused).
Always pass "-6", "-i", "::" to ttyd in Dockerfile old script execution)
Modifying login-wrapper.sh on host ul and running docker compose restart runs the updated script.
docker compose restart only restarts containers without re-reading COPYed image files.
Always bind-mount operational scripts (./login-wrapper.sh:/usr/local/bin/login-wrapper.sh:ro) in docker-compose.yml.
CA Secret Privilege Restrictions
User luke (1000:1000) owns the CA keys and JWK provisioner secret.
Standard unprivileged user accounts should not have read access to /tmppasswords.
/data/docker/ttyd/ttyd_ca_user_keywebshell/step-caakadmin:akadmin (06001101:1101) and step-ca lukedocker-data1101:1101.