Skip to content

Commit ee1db2d

Browse files
committed
Add optional OTP (2FA) access
OTP_ACCESS=true requires 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. The secret lives in /config so it survives a container recreation, and until it exists the user is let in on the key alone, which is what makes the initial enrollment possible. Two details of this image shape the PAM stack. The module answers PAM_IGNORE rather than PAM_SUCCESS when nullok applies, and a stack whose only module is ignored has nothing that succeeded, so an explicit action list runs it and a trailing pam_permit carries the login; a wrong code hits default=die before reaching it. And sshd runs as the ssh user here, so it cannot read /etc/shadow, which the stock account and session checks need. Those are left out rather than inherited: without OTP this image does not enable PAM at all, so none of them run today either. The sshd_config block is delimited by markers and rebuilt on every start, so removing the variable cleans the persisted config back up. Closes #121
1 parent 4936dc6 commit ee1db2d

5 files changed

Lines changed: 101 additions & 0 deletions

File tree

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ LABEL maintainer="aptalca"
1212
RUN \
1313
echo "**** install runtime packages ****" && \
1414
apk add --no-cache --upgrade \
15+
google-authenticator \
1516
logrotate \
1617
nano \
1718
netcat-openbsd \
@@ -28,6 +29,7 @@ RUN \
2829
printf "Linuxserver.io version: ${VERSION}\nBuild-date: ${BUILD_DATE}" > /build_version && \
2930
echo "**** setup openssh environment ****" && \
3031
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config && \
32+
cp /etc/pam.d/sshd /etc/pam.d/sshd.default && \
3133
usermod --shell /bin/bash abc && \
3234
rm -rf \
3335
/tmp/* \

Dockerfile.aarch64

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ LABEL maintainer="aptalca"
1212
RUN \
1313
echo "**** install runtime packages ****" && \
1414
apk add --no-cache --upgrade \
15+
google-authenticator \
1516
logrotate \
1617
nano \
1718
netcat-openbsd \
@@ -28,6 +29,7 @@ RUN \
2829
printf "Linuxserver.io version: ${VERSION}\nBuild-date: ${BUILD_DATE}" > /build_version && \
2930
echo "**** setup openssh environment ****" && \
3031
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config && \
32+
cp /etc/pam.d/sshd /etc/pam.d/sshd.default && \
3133
usermod --shell /bin/bash abc && \
3234
rm -rf \
3335
/tmp/* \

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ It is also possible to run multiple copies of this container with different port
8080
You can volume map your own text file to `/etc/motd` to override the message displayed upon connection.
8181
You can optionally set the docker argument `hostname`
8282

83+
## OTP (2FA)
84+
85+
Setting `OTP_ACCESS` to `true` requires 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. The codes are generated by any TOTP application, and the secret is kept in `/config/.google_authenticator` so it survives a container recreation.
86+
87+
As long as that secret does not exist, the user is let in without being asked for a code, which is what makes the enrollment possible. Connect as usual and run:
88+
```
89+
google-authenticator -t -d -f -r 3 -R 30 -W -s /config/.google_authenticator
90+
```
91+
92+
Scan the QR code with your authenticator application and write down the scratch codes, they are the only way back in if you lose it. Every login from that point on asks for a verification code.
93+
8394
## Key Generation
8495

8596
This container has a helper script to generate an ssh private/public key. In order to generate a key please run:
@@ -116,6 +127,7 @@ services:
116127
- PUBLIC_KEY_URL=https://github.com/username.keys #optional
117128
- SUDO_ACCESS=false #optional
118129
- PASSWORD_ACCESS=false #optional
130+
- OTP_ACCESS=false #optional
119131
- USER_PASSWORD=password #optional
120132
- USER_PASSWORD_FILE=/path/to/file #optional
121133
- USER_NAME=linuxserver.io #optional
@@ -142,6 +154,7 @@ docker run -d \
142154
-e PUBLIC_KEY_URL=https://github.com/username.keys `#optional` \
143155
-e SUDO_ACCESS=false `#optional` \
144156
-e PASSWORD_ACCESS=false `#optional` \
157+
-e OTP_ACCESS=false `#optional` \
145158
-e USER_PASSWORD=password `#optional` \
146159
-e USER_PASSWORD_FILE=/path/to/file `#optional` \
147160
-e USER_NAME=linuxserver.io `#optional` \
@@ -169,6 +182,7 @@ Containers are configured using parameters passed at runtime (such as those abov
169182
| `-e PUBLIC_KEY_URL=https://github.com/username.keys` | Optionally specify a URL containing the public key. |
170183
| `-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. |
171184
| `-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. |
185+
| `-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. |
172186
| `-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. |
173187
| `-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). |
174188
| `-e USER_NAME=linuxserver.io` | Optionally specify a user name (Default:`linuxserver.io`) |
@@ -337,6 +351,7 @@ Once registered you can define the dockerfile to use with `-f Dockerfile.aarch64
337351

338352
## Versions
339353

354+
* **03.08.26:** - Add optional OTP (2FA) support via `OTP_ACCESS`.
340355
* **03.08.26:** - Add a healthcheck reporting whether sshd is listening.
341356
* **05.07.26:** - Rebase to Alpine 3.24.
342357
* **28.12.25:** - Rebase to Alpine 3.23.

readme-vars.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ opt_param_env_vars:
3232
- {env_var: "PUBLIC_KEY_URL", env_value: "https://github.com/username.keys", desc: "Optionally specify a URL containing the public key."}
3333
- {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."}
3434
- {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."}
35+
- {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."}
3536
- {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."}
3637
- {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)."}
3738
- {env_var: "USER_NAME", env_value: "linuxserver.io", desc: "Optionally specify a user name (Default:`linuxserver.io`)"}
@@ -63,6 +64,17 @@ app_setup_block: |
6364
You can volume map your own text file to `/etc/motd` to override the message displayed upon connection.
6465
You can optionally set the docker argument `hostname`
6566
67+
## OTP (2FA)
68+
69+
Setting `OTP_ACCESS` to `true` requires 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. The codes are generated by any TOTP application, and the secret is kept in `/config/.google_authenticator` so it survives a container recreation.
70+
71+
As long as that secret does not exist, the user is let in without being asked for a code, which is what makes the enrollment possible. Connect as usual and run:
72+
```
73+
google-authenticator -t -d -f -r 3 -R 30 -W -s /config/.google_authenticator
74+
```
75+
76+
Scan the QR code with your authenticator application and write down the scratch codes, they are the only way back in if you lose it. Every login from that point on asks for a verification code.
77+
6678
## Key Generation
6779
6880
This container has a helper script to generate an ssh private/public key. In order to generate a key please run:
@@ -117,6 +129,7 @@ init_diagram: |
117129
"openssh-server:latest" <- Base Images
118130
# changelog
119131
changelogs:
132+
- {date: "03.08.26:", desc: "Add optional OTP (2FA) support via `OTP_ACCESS`."}
120133
- {date: "03.08.26:", desc: "Add a healthcheck reporting whether sshd is listening."}
121134
- {date: "05.07.26:", desc: "Rebase to Alpine 3.24."}
122135
- {date: "28.12.25:", desc: "Rebase to Alpine 3.23."}

root/etc/s6-overlay/s6-rc.d/init-openssh-server-config/run

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,75 @@ else
8686
echo "User/password ssh access is disabled."
8787
fi
8888

89+
# otp (2FA) access
90+
OTP_SECRET=/config/.google_authenticator
91+
92+
# rebuilt from a known state on every start so the variable can be turned back
93+
# off again. sshd resolves the plain sshd PAM service, not sshd.pam.
94+
if [[ "$OTP_ACCESS" == "true" ]] || grep -q "pam_google_authenticator.so" /etc/pam.d/sshd; then
95+
cp /etc/pam.d/sshd.default /etc/pam.d/sshd
96+
97+
if [[ "$OTP_ACCESS" == "true" ]]; then
98+
{
99+
echo "#%PAM-1.0"
100+
101+
# the whole auth stack runs for keyboard-interactive, so the unix
102+
# password prompt is only carried over when password access is on
103+
if [[ "$PASSWORD_ACCESS" == "true" ]]; then
104+
grep -E '^[[:space:]]*auth[[:space:]]' /etc/pam.d/sshd.default
105+
fi
106+
107+
# nullok lets a user that has not enrolled yet log in, which is what
108+
# makes the first otp-setup run possible. The module says so by
109+
# answering PAM_IGNORE, and a stack whose only module is ignored has
110+
# nothing that succeeded, so it is the trailing pam_permit that
111+
# carries the login. A wrong code dies before reaching it.
112+
echo "auth [success=ok ignore=ignore default=die] pam_google_authenticator.so nullok secret=${OTP_SECRET}"
113+
echo "auth required pam_permit.so"
114+
115+
# the stock account and session checks read /etc/shadow, which sshd
116+
# cannot do here because it runs as the ssh user rather than root.
117+
# Nothing is lost by leaving them out: without OTP this image does
118+
# not enable PAM at all, so none of them run today either.
119+
echo "account required pam_permit.so"
120+
echo "password required pam_permit.so"
121+
echo "session required pam_permit.so"
122+
} > /etc/pam.d/sshd
123+
fi
124+
fi
125+
sed -i '/^# >>> OTP_ACCESS >>>$/,/^# <<< OTP_ACCESS <<<$/d' /config/sshd/sshd_config
126+
127+
if [[ "$OTP_ACCESS" == "true" ]]; then
128+
if [[ "$PASSWORD_ACCESS" == "true" ]]; then
129+
AUTH_METHODS="keyboard-interactive publickey,keyboard-interactive"
130+
else
131+
AUTH_METHODS="publickey,keyboard-interactive"
132+
fi
133+
134+
# sshd keeps the first value it reads for a keyword, so an active setting
135+
# earlier in the file silently wins over the block appended below
136+
if grep -qE '^[[:space:]]*(UsePAM|KbdInteractiveAuthentication|AuthenticationMethods)[[:space:]]' /config/sshd/sshd_config; then
137+
echo "*** WARNING: /config/sshd/sshd_config already sets UsePAM, KbdInteractiveAuthentication or AuthenticationMethods. Remove those lines, otherwise OTP will not be enforced. ***"
138+
fi
139+
140+
cat >> /config/sshd/sshd_config << EOF
141+
# >>> OTP_ACCESS >>>
142+
UsePAM yes
143+
KbdInteractiveAuthentication yes
144+
AuthenticationMethods ${AUTH_METHODS}
145+
# <<< OTP_ACCESS <<<
146+
EOF
147+
148+
if [[ -f "$OTP_SECRET" ]]; then
149+
echo "OTP (2FA) ssh access is enabled."
150+
else
151+
echo "OTP (2FA) ssh access is enabled, but ${USER_NAME} has no secret yet and is not prompted for a code."
152+
echo "Run 'google-authenticator -t -d -f -r 3 -R 30 -W -s ${OTP_SECRET}' as ${USER_NAME} to enroll."
153+
fi
154+
else
155+
echo "OTP (2FA) ssh access is disabled."
156+
fi
157+
89158
# set umask for sftp
90159
UMASK=${UMASK:-022}
91160
sed -i "s|/usr/lib/ssh/sftp-server$|/usr/lib/ssh/sftp-server -u ${UMASK}|g" /config/sshd/sshd_config

0 commit comments

Comments
 (0)