Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ LABEL maintainer="aptalca"
RUN \
echo "**** install runtime packages ****" && \
apk add --no-cache --upgrade \
google-authenticator \
logrotate \
nano \
netcat-openbsd \
Expand All @@ -28,6 +29,7 @@ RUN \
printf "Linuxserver.io version: ${VERSION}\nBuild-date: ${BUILD_DATE}" > /build_version && \
echo "**** setup openssh environment ****" && \
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config && \
cp /etc/pam.d/sshd /etc/pam.d/sshd.default && \
usermod --shell /bin/bash abc && \
rm -rf \
/tmp/* \
Expand All @@ -38,4 +40,7 @@ COPY /root /

EXPOSE 2222

HEALTHCHECK --start-period=30s --interval=30s --timeout=10s --retries=3 \
CMD /healthcheck.sh

VOLUME /config
5 changes: 5 additions & 0 deletions Dockerfile.aarch64
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ LABEL maintainer="aptalca"
RUN \
echo "**** install runtime packages ****" && \
apk add --no-cache --upgrade \
google-authenticator \
logrotate \
nano \
netcat-openbsd \
Expand All @@ -28,6 +29,7 @@ RUN \
printf "Linuxserver.io version: ${VERSION}\nBuild-date: ${BUILD_DATE}" > /build_version && \
echo "**** setup openssh environment ****" && \
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config && \
cp /etc/pam.d/sshd /etc/pam.d/sshd.default && \
usermod --shell /bin/bash abc && \
rm -rf \
/tmp/* \
Expand All @@ -38,4 +40,7 @@ COPY /root /

EXPOSE 2222

HEALTHCHECK --start-period=30s --interval=30s --timeout=10s --retries=3 \
CMD /healthcheck.sh

VOLUME /config
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ It is also possible to run multiple copies of this container with different port
You can volume map your own text file to `/etc/motd` to override the message displayed upon connection.
You can optionally set the docker argument `hostname`

## OTP (2FA)

Set `OTP_ACCESS` to `true` to require a time based one time password on top of the ssh key, or on top of the password when `PASSWORD_ACCESS` is enabled as well. Any TOTP application can generate the codes. The secret is stored in `/config/.google_authenticator`, so it survives recreating the container.

Until that secret exists, the key alone still gets you in. That is what makes the first login possible, and a banner points you at the command to run:
```
otp-setup
```

Scan the QR code with your authenticator application, then write down the scratch codes. They are the only way back in if you lose the application. From the next login on, sshd asks for a verification code.

`otp-setup --show` prints the secret again. Running `otp-setup` a second time replaces it, after asking for confirmation.

## Key Generation

This container has a helper script to generate an ssh private/public key. In order to generate a key please run:
Expand Down Expand Up @@ -116,6 +129,7 @@ services:
- PUBLIC_KEY_URL=https://github.com/username.keys #optional
- SUDO_ACCESS=false #optional
- PASSWORD_ACCESS=false #optional
- OTP_ACCESS=false #optional
- USER_PASSWORD=password #optional
- USER_PASSWORD_FILE=/path/to/file #optional
- USER_NAME=linuxserver.io #optional
Expand All @@ -142,6 +156,7 @@ docker run -d \
-e PUBLIC_KEY_URL=https://github.com/username.keys `#optional` \
-e SUDO_ACCESS=false `#optional` \
-e PASSWORD_ACCESS=false `#optional` \
-e OTP_ACCESS=false `#optional` \
-e USER_PASSWORD=password `#optional` \
-e USER_PASSWORD_FILE=/path/to/file `#optional` \
-e USER_NAME=linuxserver.io `#optional` \
Expand Down Expand Up @@ -169,6 +184,7 @@ Containers are configured using parameters passed at runtime (such as those abov
| `-e PUBLIC_KEY_URL=https://github.com/username.keys` | Optionally specify a URL containing the public key. |
| `-e SUDO_ACCESS=false` | Set to `true` to allow `linuxserver.io`, the ssh user, sudo access. Without `USER_PASSWORD` set, this will allow passwordless sudo access. |
| `-e PASSWORD_ACCESS=false` | Set to `true` to allow user/password ssh access. You will want to set `USER_PASSWORD` or `USER_PASSWORD_FILE` as well. |
| `-e OTP_ACCESS=false` | Set to `true` to require a one time password (2FA) in addition to the ssh key or the password. See the OTP section below. |
| `-e USER_PASSWORD=password` | Optionally set a sudo password for `linuxserver.io`, the ssh user. If this or `USER_PASSWORD_FILE` are not set but `SUDO_ACCESS` is set to true, the user will have passwordless sudo access. |
| `-e USER_PASSWORD_FILE=/path/to/file` | Optionally specify a file that contains the password. This setting supersedes the `USER_PASSWORD` option (works with docker secrets). |
| `-e USER_NAME=linuxserver.io` | Optionally specify a user name (Default:`linuxserver.io`) |
Expand Down Expand Up @@ -337,6 +353,8 @@ Once registered you can define the dockerfile to use with `-f Dockerfile.aarch64

## Versions

* **03.08.26:** - Add optional OTP (2FA) support via `OTP_ACCESS`.
* **03.08.26:** - Add a healthcheck reporting whether sshd is listening.
* **05.07.26:** - Rebase to Alpine 3.24.
* **28.12.25:** - Rebase to Alpine 3.23.
* **05.07.25:** - Rebase to Alpine 3.22.
Expand Down
16 changes: 16 additions & 0 deletions readme-vars.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ opt_param_env_vars:
- {env_var: "PUBLIC_KEY_URL", env_value: "https://github.com/username.keys", desc: "Optionally specify a URL containing the public key."}
- {env_var: "SUDO_ACCESS", env_value: "false", desc: "Set to `true` to allow `linuxserver.io`, the ssh user, sudo access. Without `USER_PASSWORD` set, this will allow passwordless sudo access."}
- {env_var: "PASSWORD_ACCESS", env_value: "false", desc: "Set to `true` to allow user/password ssh access. You will want to set `USER_PASSWORD` or `USER_PASSWORD_FILE` as well."}
- {env_var: "OTP_ACCESS", env_value: "false", desc: "Set to `true` to require a one time password (2FA) in addition to the ssh key or the password. See the OTP section below."}
- {env_var: "USER_PASSWORD", env_value: "password", desc: "Optionally set a sudo password for `linuxserver.io`, the ssh user. If this or `USER_PASSWORD_FILE` are not set but `SUDO_ACCESS` is set to true, the user will have passwordless sudo access."}
- {env_var: "USER_PASSWORD_FILE", env_value: "/path/to/file", desc: "Optionally specify a file that contains the password. This setting supersedes the `USER_PASSWORD` option (works with docker secrets)."}
- {env_var: "USER_NAME", env_value: "linuxserver.io", desc: "Optionally specify a user name (Default:`linuxserver.io`)"}
Expand Down Expand Up @@ -63,6 +64,19 @@ app_setup_block: |
You can volume map your own text file to `/etc/motd` to override the message displayed upon connection.
You can optionally set the docker argument `hostname`

## OTP (2FA)

Set `OTP_ACCESS` to `true` to require a time based one time password on top of the ssh key, or on top of the password when `PASSWORD_ACCESS` is enabled as well. Any TOTP application can generate the codes. The secret is stored in `/config/.google_authenticator`, so it survives recreating the container.

Until that secret exists, the key alone still gets you in. That is what makes the first login possible, and a banner points you at the command to run:
```
otp-setup
```

Scan the QR code with your authenticator application, then write down the scratch codes. They are the only way back in if you lose the application. From the next login on, sshd asks for a verification code.

`otp-setup --show` prints the secret again. Running `otp-setup` a second time replaces it, after asking for confirmation.

## Key Generation

This container has a helper script to generate an ssh private/public key. In order to generate a key please run:
Expand Down Expand Up @@ -117,6 +131,8 @@ init_diagram: |
"openssh-server:latest" <- Base Images
# changelog
changelogs:
- {date: "03.08.26:", desc: "Add optional OTP (2FA) support via `OTP_ACCESS`."}
- {date: "03.08.26:", desc: "Add a healthcheck reporting whether sshd is listening."}
- {date: "05.07.26:", desc: "Rebase to Alpine 3.24."}
- {date: "28.12.25:", desc: "Rebase to Alpine 3.23."}
- {date: "05.07.25:", desc: "Rebase to Alpine 3.22."}
Expand Down
22 changes: 22 additions & 0 deletions root/etc/profile.d/otp-setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Point a user that OTP applies to, but who has not enrolled yet, at otp-setup.
# Until they do, pam_google_authenticator lets them in without asking for a
# code, so nothing else would tell them the secret is still missing.

[ -z "$SSH_CONNECTION" ] && return 0
grep -q "pam_google_authenticator.so" /etc/pam.d/sshd 2>/dev/null || return 0
[ -f /config/.google_authenticator ] && return 0

cat << 'BANNER'

────────────────────────────────────────────────────────────
Two factor authentication is enabled on this server, but
you have not set up your authenticator application yet.

Run:
otp-setup

to get your QR code and your scratch codes. Your next
login then asks for a 6 digit code on top of your key.
────────────────────────────────────────────────────────────

BANNER
69 changes: 69 additions & 0 deletions root/etc/s6-overlay/s6-rc.d/init-openssh-server-config/run
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,75 @@ else
echo "User/password ssh access is disabled."
fi

# otp (2FA) access
OTP_SECRET=/config/.google_authenticator

# rebuilt from a known state on every start so the variable can be turned back
# off again. sshd resolves the plain sshd PAM service, not sshd.pam.
if [[ "$OTP_ACCESS" == "true" ]] || grep -q "pam_google_authenticator.so" /etc/pam.d/sshd; then
cp /etc/pam.d/sshd.default /etc/pam.d/sshd

if [[ "$OTP_ACCESS" == "true" ]]; then
{
echo "#%PAM-1.0"

# the whole auth stack runs for keyboard-interactive, so the unix
# password prompt is only carried over when password access is on
if [[ "$PASSWORD_ACCESS" == "true" ]]; then
grep -E '^[[:space:]]*auth[[:space:]]' /etc/pam.d/sshd.default
fi

# nullok lets a user that has not enrolled yet log in, which is what
# makes the first otp-setup run possible. The module says so by
# answering PAM_IGNORE, and a stack whose only module is ignored has
# nothing that succeeded, so it is the trailing pam_permit that
# carries the login. A wrong code dies before reaching it.
echo "auth [success=ok ignore=ignore default=die] pam_google_authenticator.so nullok secret=${OTP_SECRET}"
echo "auth required pam_permit.so"

# the stock account and session checks read /etc/shadow, which sshd
# cannot do here because it runs as the ssh user rather than root.
# Nothing is lost by leaving them out: without OTP this image does
# not enable PAM at all, so none of them run today either.
echo "account required pam_permit.so"
echo "password required pam_permit.so"
echo "session required pam_permit.so"
} > /etc/pam.d/sshd
fi
fi
sed -i '/^# >>> OTP_ACCESS >>>$/,/^# <<< OTP_ACCESS <<<$/d' /config/sshd/sshd_config

if [[ "$OTP_ACCESS" == "true" ]]; then
if [[ "$PASSWORD_ACCESS" == "true" ]]; then
AUTH_METHODS="keyboard-interactive publickey,keyboard-interactive"
else
AUTH_METHODS="publickey,keyboard-interactive"
fi

# sshd keeps the first value it reads for a keyword, so an active setting
# earlier in the file silently wins over the block appended below
if grep -qE '^[[:space:]]*(UsePAM|KbdInteractiveAuthentication|AuthenticationMethods)[[:space:]]' /config/sshd/sshd_config; then
echo "*** WARNING: /config/sshd/sshd_config already sets UsePAM, KbdInteractiveAuthentication or AuthenticationMethods. Remove those lines, otherwise OTP will not be enforced. ***"
fi

cat >> /config/sshd/sshd_config << EOF
# >>> OTP_ACCESS >>>
UsePAM yes
KbdInteractiveAuthentication yes
AuthenticationMethods ${AUTH_METHODS}
# <<< OTP_ACCESS <<<
EOF

if [[ -f "$OTP_SECRET" ]]; then
echo "OTP (2FA) ssh access is enabled."
else
echo "OTP (2FA) ssh access is enabled, but ${USER_NAME} has no secret yet and is not prompted for a code."
echo "Log in and run 'otp-setup' to enroll."
fi
else
echo "OTP (2FA) ssh access is disabled."
fi

# set umask for sftp
UMASK=${UMASK:-022}
sed -i "s|/usr/lib/ssh/sftp-server$|/usr/lib/ssh/sftp-server -u ${UMASK}|g" /config/sshd/sshd_config
Expand Down
21 changes: 21 additions & 0 deletions root/healthcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#! /bin/bash

# The port set in the running config wins over the env var, so a port edited
# directly in sshd_config is still picked up.
PORT=$(awk '$1 == "Port" { print $2; exit }' /config/sshd/sshd_config 2>/dev/null)
PORT=${PORT:-${LISTEN_PORT:-2222}}
if [[ ! "$PORT" =~ ^[0-9]+$ ]]; then
PORT=2222
fi

# The kernel socket table is read instead of opening a connection, so the check
# does not log an aborted handshake in the ssh log on every run. The local port
# is listed in hex and 0A is the listening state.
LISTENING="^[[:space:]]*[0-9]+:[[:space:]]+[0-9A-F]+:$(printf '%04X' "${PORT}")[[:space:]]+[0-9A-F]+:0+[[:space:]]+0A"

if grep -qE "${LISTENING}" /proc/net/tcp 2>/dev/null || grep -qE "${LISTENING}" /proc/net/tcp6 2>/dev/null; then
exit 0
fi

echo "sshd is not listening on port ${PORT}"
exit 1
74 changes: 74 additions & 0 deletions root/usr/local/bin/otp-setup
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#! /bin/bash
# Generate, regenerate or display the TOTP secret used by OTP_ACCESS.

set -e

# absolute, and matching the secret= passed to pam_google_authenticator, so the
# helper and the PAM module always agree on the file regardless of $HOME
SECRET_FILE=/config/.google_authenticator

usage() {
cat << EOF
Usage:
otp-setup Generate a TOTP secret, or regenerate it after asking.
Prints the QR code, the secret and the scratch codes.
otp-setup --show Print the current secret file again.
otp-setup --help Show this help.
EOF
}

case "${1:-}" in
--show|-s)
if [[ ! -f "$SECRET_FILE" ]]; then
echo "No OTP secret yet. Run 'otp-setup' to create one."
exit 1
fi
echo "This prints your secret and scratch codes in clear text. Make sure"
echo "nobody is watching and that this session is not being recorded."
echo
echo "=== ${SECRET_FILE} ==="
echo "First line is the base32 secret, the trailing digit lines are the"
echo "emergency scratch codes."
echo
cat "$SECRET_FILE"
;;
--help|-h)
usage
;;
"")
if [[ -f "$SECRET_FILE" ]]; then
read -r -p "A secret already exists in ${SECRET_FILE}. Regenerating invalidates the old one. Continue? [y/N] " answer || answer=""
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
backup="${SECRET_FILE}.bak.$(date +%s)"
cp "$SECRET_FILE" "$backup"
echo "Old secret kept in ${backup}"
fi

# typing the first code back catches a QR that was scanned wrong, but
# there is nothing to type it with when this runs without a terminal,
# as it does under docker exec
confirm=()
if [[ ! -t 0 ]]; then
confirm=(--no-confirm)
fi

# -t time based, -d one use per code, -f write without asking,
# -r 3 -R 30 three attempts per 30s, -W narrow the accepted time window
google-authenticator -t -d -f -r 3 -R 30 -W "${confirm[@]}" -s "$SECRET_FILE"
chmod 600 "$SECRET_FILE"

echo
echo "=== setup complete ==="
echo "Scan the QR code above with your authenticator application."
echo "Write down the scratch codes too. They are the only way back in if"
echo "you lose the application."
echo "Your next login asks for a 6 digit code."
;;
*)
usage
exit 2
;;
esac
Loading