diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index ad179b2..efccb8b 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -31,7 +31,7 @@ jobs: cache-to: type=gha,mode=max - name: Run Test Container run: | - docker run --rm -d -e SSHD_PORT=2222 -p 2222:2222 --name jumpbox jumpbox:policy-test + docker run --rm -d -e SSHD_PORT=2222 -p 2222:2222 --name jumpbox jumpbox:policy-test && sleep 10 - name: Setup Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/fail2ban.yml b/.github/workflows/fail2ban.yml new file mode 100644 index 0000000..5fd2c27 --- /dev/null +++ b/.github/workflows/fail2ban.yml @@ -0,0 +1,54 @@ +name: Release Fail2Ban + +on: + workflow_dispatch: + schedule: + - cron: "0 2 1 * *" + push: + tags: + - "v*" + +jobs: + docker-release: + name: Fail2Ban Docker Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup QEMU + uses: docker/setup-qemu-action@v2 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Container Metadata + id: metadata + uses: docker/metadata-action@v4 + with: + images: | + ghcr.io/${{ github.repository_owner }}/jumpbox-fail2ban + flavor: | + latest=false + prefix= + suffix= + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Build and Push Container + uses: docker/build-push-action@v4 + with: + context: . + file: build/fail2ban.Dockerfile + push: true + platforms: linux/amd64,linux/386,linux/arm64,linux/arm/v6,linux/arm/v7 + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7d6903..7b9e648 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release Jumpbox on: workflow_dispatch: diff --git a/.gitignore b/.gitignore index cd172d9..24d79fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ example/* -!example/authorized_keys.json +!example/.env +!example/docker-compose.yml diff --git a/README.md b/README.md index 40dd25b..276ea53 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Whilst the objective of this project was to keep things simple (a large motivato - **Policy**: To ensure the SSH server being deployed is hardened as desired, you can use [`ssh-audit`](https://github.com/jtesta/ssh-audit), a tool that can check if an SSH server meets a given configuration security policy. Included is a good starting policy for an SSH server using OpenSSH 9, ensuring all and only the recommended key types (Host, Kex, Macs) are supported. +- **Fail2Ban**: Writes logs to a file via a syslog server, in turn allowing [Fail2Ban](https://www.fail2ban.org/wiki/index.php/Main_Page) to block malicious connections. As Fail2Ban is expected to exist outside the jumpbox container (either on the host system or in a different container), the jumpbox itself needs no extra permissions to support this. See the example [docker-compose](./example/docker-compose.yml) for a way to set this up. + - **Actions**: The dockerized jumpbox here, along with [`watchtower`](https://containrrr.dev/watchtower/) can create an SSH server managed by GitHub Actions. Provided the SSH users and keys are [baked in](#full-usage), the Release workflow provided with this repository will: - Validate the `authorized_keys` JSON file - Ensure all provided users are valid usernames @@ -108,7 +110,7 @@ This is perhaps the best way to use Jumpbox, especially if it is for an organiza ## Configuration -- **SSH Server**: Configure the SSH server by modifying the [`sshd_config`](sshd/sshd_config) file. Included is a sensible default for a Jumpbox host as of November 2022. To modify the internal port used for the SSH server, make sure to use the `SSHD_PORT` environment variable. +- **SSH Server**: Configure the SSH server by modifying the [`sshd_config`](sshd/sshd_config) file. Included is a sensible default for a Jumpbox host as of November 2022. To modify the internal port used for the SSH server, make sure to use the `SSHD_PORT` environment variable. Also, always ensure that the internal SSH server port is the same as the exposed port. Having a port mapping where the internal and external ports are different will break some features. See the example docker-compose file for more on how to do this easily. - **Users & Keys**: Regardless of if you are using baked in keys or mounted, the format anc common issues are the same. See [here](./example/keys/README.md) for more. diff --git a/build/Dockerfile b/build/Dockerfile index 5284074..532e42d 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.17 as endlessh-builder +FROM alpine:3.18 as endlessh-builder RUN apk add --no-cache build-base git WORKDIR /src @@ -6,13 +6,20 @@ ARG ENDLESSH_VERSION=1.1 RUN git clone -b ${ENDLESSH_VERSION} https://github.com/skeeto/endlessh . RUN make -FROM alpine:3.17 -RUN apk add --no-cache curl \ +FROM alpine:3.18 + +RUN apk add --no-cache --progress --quiet \ + bash \ + curl \ gettext \ jq \ openssh-server \ - shadow + rsyslog \ + shadow \ + tzdata + +RUN sed -i '/imklog/s/^/#/' /etc/rsyslog.conf COPY --from=endlessh-builder /src/endlessh /usr/local/bin/endlessh @@ -27,7 +34,8 @@ COPY sshd/sshd_config /etc/ssh/sshd_config_template COPY keys/authorized_keys.json /etc/ssh/keys.d/authorized_keys.json RUN chown nobody:nogroup /etc/ssh/keys.d/authorized_keys.json -ENV SSHD_PORT 2222 -ENV ENDLESSH_PORT 22 +ENV TZ=Europe/London +ENV SSHD_PORT=2222 +ENV ENDLESSH_PORT=22 ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] diff --git a/build/entrypoint.sh b/build/entrypoint.sh index fa25b94..6789654 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -1,5 +1,15 @@ -#!/bin/ash -set -e +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail +if [[ "${TRACE-0}" == "1" ]]; then + set -o xtrace +fi + +echo "Setting timezone..." +TZ=${TZ:-UTC} +ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime +echo "${TZ}" > /etc/timezone echo "Setting up host keys..." hostkeys_dir=/etc/ssh/hostkeys.d @@ -10,6 +20,7 @@ for t in ed25519 rsa-sha2-256 rsa-sha2-512; do echo "Generating ${t} host key" rm -f "${file_name}" "${file_name}.pub" ssh-keygen -t $t -h -q -N "" -C "" -f "$file_name" + chmod 0600 "${file_name}" fi done @@ -17,20 +28,23 @@ echo "Setting up users..." keys_dir=/etc/ssh/keys.d mkdir -p ${keys_dir} echo "Creating users..." -jq -r 'keys_unsorted[]' ${keys_dir}/authorized_keys.json | while read u; do +jq -r 'keys_unsorted[]' ${keys_dir}/authorized_keys.json | while read -r u; do echo "Creating user ${u}" adduser -D -H -s /sbin/nologin "${u}" - sed -i s/${u}:!/"${u}:*"/g /etc/shadow + sed -i s/"${u}:!"/"${u}:*"/g /etc/shadow done echo "Creating sshd configuration..." envsubst < /etc/ssh/sshd_config_template > /etc/ssh/sshd_config_envsubst mv /etc/ssh/sshd_config_envsubst /etc/ssh/sshd_config -if [[ ${ENDLESSH_PORT} -ne "0" ]]; then +echo "Running syslog..." +rsyslogd + +if [[ "${ENDLESSH_PORT}" -ne "0" ]]; then echo "Running endlessh server..." - /usr/local/bin/endlessh -p ${ENDLESSH_PORT} -v & + /usr/local/bin/endlessh -p "${ENDLESSH_PORT}" -v & fi echo "Running SSH server..." -/usr/sbin/sshd -D -f /etc/ssh/sshd_config -e -p ${SSHD_PORT} +/usr/sbin/sshd -D -f /etc/ssh/sshd_config -p "${SSHD_PORT}" diff --git a/build/fail2ban.Dockerfile b/build/fail2ban.Dockerfile new file mode 100644 index 0000000..3146f57 --- /dev/null +++ b/build/fail2ban.Dockerfile @@ -0,0 +1,24 @@ +FROM alpine:3.18 + +RUN apk add --no-cache --progress --quiet \ + bash \ + fail2ban \ + ipset \ + iptables \ + ip6tables \ + kmod \ + nftables \ + tzdata + +RUN rm -r /etc/fail2ban/jail.d/* && \ + rm -rf /etc/fail2ban/action.d/nftables-common.local +COPY fail2ban/jail.d/* /etc/fail2ban/jail.d/ +COPY fail2ban/action.d/nftables-common.local /etc/fail2ban/action.d/nftables-common.local + +COPY build/fail2ban.entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENV TZ=Europe/London +ENV SSHD_PORT=2222 + +ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] diff --git a/build/fail2ban.entrypoint.sh b/build/fail2ban.entrypoint.sh new file mode 100644 index 0000000..ca0f3f7 --- /dev/null +++ b/build/fail2ban.entrypoint.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail +if [[ "${TRACE-0}" == "1" ]]; then + set -o xtrace +fi + +echo "Setting timezone..." +TZ=${TZ:-UTC} +ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime +echo "${TZ}" > /etc/timezone + +echo "Configuring fail2ban..." +sed -i "s/port = ssh/port = ${SSHD_PORT}/g" /etc/fail2ban/jail.d/sshd.conf + +echo "Running fail2ban..." +fail2ban-server -x -v -f start diff --git a/example/.env b/example/.env new file mode 100644 index 0000000..de5a862 --- /dev/null +++ b/example/.env @@ -0,0 +1,3 @@ +SSHD_PORT=2222 +ENDLESSH_PORT=22 +TZ=Europe/London diff --git a/example/docker-compose.yml b/example/docker-compose.yml index c998961..70f2399 100644 --- a/example/docker-compose.yml +++ b/example/docker-compose.yml @@ -4,26 +4,46 @@ services: jumpbox: container_name: jumpbox image: ghcr.io/willfantom/jumpbox:latest + restart: unless-stopped ports: - - 22:22/tcp - - 2222:2222/tcp + - ${ENDLESSH_PORT}:${ENDLESSH_PORT}/tcp + - ${SSHD_PORT}:${SSHD_PORT}/tcp volumes: - ./hostkeys:/etc/ssh/hostkeys.d + - ./logs:/var/log:rw # - ./keys:/etc/ssh/keys.d # Include to use mounted keys rather than the ones baked in environment: - - SSHD_PORT=2222 - - ENDLESSH_PORT=22 + - TZ=${TZ} + - SSHD_PORT=${SSHD_PORT} + - ENDLESSH_PORT=${ENDLESSH_PORT} labels: - "com.centurylinklabs.watchtower.scope=sshjumpbox" - "com.centurylinklabs.watchtower.enable=true" - # Required for auto-pulling updated images from github watchtower: - container_name: watchtower + container_name: jumpbox_watchtower image: containrrr/watchtower + restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock command: --interval 30 --scope sshjumpbox labels: - "com.centurylinklabs.watchtower.scope=sshjumpbox" - "com.centurylinklabs.watchtower.enable=false" + + fail2ban: + container_name: jumpbox_fail2ban + image: ghcr.io/willfantom/jumpbox-fail2ban:latest + restart: unless-stopped + cap_add: + - NET_ADMIN + - NET_RAW + network_mode: host + volumes: + - ./logs:/var/log:rw + environment: + - TZ=${TZ} + - SSHD_PORT=${SSHD_PORT} + labels: + - "com.centurylinklabs.watchtower.scope=sshjumpbox" + - "com.centurylinklabs.watchtower.enable=true" diff --git a/fail2ban/action.d/nftables-common.local b/fail2ban/action.d/nftables-common.local new file mode 100644 index 0000000..daf6c62 --- /dev/null +++ b/fail2ban/action.d/nftables-common.local @@ -0,0 +1,3 @@ +[Init] +table = f2b-table-docker +chain_hook = forward diff --git a/fail2ban/jail.d/default.conf b/fail2ban/jail.d/default.conf new file mode 100644 index 0000000..74cf886 --- /dev/null +++ b/fail2ban/jail.d/default.conf @@ -0,0 +1,2 @@ +[DEFAULT] +banaction = nftables[type=multiport] diff --git a/fail2ban/jail.d/sshd.conf b/fail2ban/jail.d/sshd.conf new file mode 100644 index 0000000..049b05b --- /dev/null +++ b/fail2ban/jail.d/sshd.conf @@ -0,0 +1,15 @@ +[sshd] +enabled = true +filter = alpine-sshd +logpath = /var/log/messages +maxretry = 10 +chain = DOCKER-USER +port = ssh + +[sshd-ddos] +enabled = true +filter = alpine-sshd-ddos +logpath = /var/log/messages +maxretry = 10 +chain = DOCKER-USER +port = ssh diff --git a/sshd/authorized_keys.sh b/sshd/authorized_keys.sh index 37f232e..3957afa 100755 --- a/sshd/authorized_keys.sh +++ b/sshd/authorized_keys.sh @@ -1,4 +1,10 @@ -#!/bin/ash +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail +if [[ "${TRACE-0}" == "1" ]]; then + set -o xtrace +fi # validate the username, sanitizing the lookup next regex="^[a-z_]([a-z0-9_-]{0,31}|[a-z0-9_-]{0,30}\$)$" @@ -7,7 +13,7 @@ if [[ ! $1 =~ ${regex} ]]; then fi # find keys for given user -jq -r -c .${1}'[]' /etc/ssh/keys.d/authorized_keys.json | while read k; do +jq -r -c ."${1}"'[]' /etc/ssh/keys.d/authorized_keys.json | while read -r k; do echo "$k" exit 0 done diff --git a/sshd/banner b/sshd/banner index 88fea57..db2ce63 100644 --- a/sshd/banner +++ b/sshd/banner @@ -10,4 +10,4 @@ F aaa r qqq uuu aaa aaa ddd ------------------------------------ -|> https://github.com/willfantom/jumpbox <| \ No newline at end of file +|> https://github.com/willfantom/jumpbox <| diff --git a/sshd/sshd_config b/sshd/sshd_config index 99ff163..ce61910 100644 --- a/sshd/sshd_config +++ b/sshd/sshd_config @@ -33,3 +33,6 @@ ClientAliveCountMax 20 ClientAliveInterval 30 PrintMotd no Banner /etc/ssh/banner + +# Use Syslog +SyslogFacility AUTH