diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..28f64f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +Dockerfile +LICENSE +*.md +.git* +.github* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8447008 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +tab_width = 4 +indent_size = 4 +indent_style = space +max_line_length = 9999 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +tab_width = 2 +indent_size = 2 diff --git a/Dockerfile b/Dockerfile index b6ab743..ae6e8c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,14 @@ -# drinternet/rsync@v1.5.1 -FROM drinternet/rsync@sha256:e61f4047577b566872764fa39299092adeab691efb3884248dbd6495dc926527 +FROM alpine:3.23.0@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375 AS base + +RUN apk update && apk add --no-cache --upgrade rsync openssh openssl busybox -# always force-upgrade rsync to get the latest security fixes -RUN apk update && apk add --no-cache --upgrade rsync openssl RUN rm -rf /var/cache/apk/* -# Copy entrypoint +COPY docker-rsync/* /bin/ +RUN chmod +x /bin/agent-* + +FROM base AS build + COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/LICENSE b/LICENSE index aa38d49..0e45d68 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2019-2022 Contention +Copyright (c) 2019-2025 Joshua Piper (Dr Internet) Copyright (c) 2019-2025 Burnett01 Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/README.md b/README.md index c08303a..7eacdd5 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,28 @@ [![Dependabot Updates](https://github.com/Burnett01/rsync-deployments/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/Burnett01/rsync-deployments/actions/workflows/dependabot/dependabot-updates) -This GitHub Action (amd64) deploys files in `GITHUB_WORKSPACE` to a remote folder via rsync over ssh. +This cross-platform GitHub Action deploys files in [`path`](#inputs) (relative to `GITHUB_WORKSPACE`) to a remote folder via rsync over ssh. -Use this action in a CD workflow which leaves deployable code in `GITHUB_WORKSPACE`. +Use this action in a CD workflow which leaves deployable code in `GITHUB_WORKSPACE`, such [actions/checkout](https://github.com/actions/checkout). -The base-image [drinternet/rsync](https://github.com/JoshPiper/rsync-docker/) of this action is very small and is based on Alpine 3.22.1 (no cache) which results in fast deployments. +The base-image of this action is very small and based on **Alpine 3.23.0** (no cache) which results in fast deployments. -Alpine version: [3.22.1](https://alpinelinux.org/posts/Alpine-3.19.8-3.20.7-3.21.4-3.22.1-released.html) -Rsync version: [3.4.1-r0](https://download.samba.org/pub/rsync/NEWS#3.4.1) +Alpine version: [3.23.0](https://www.alpinelinux.org/posts/Alpine-3.23.0-released.html) +Rsync version: [3.4.1-r1](https://download.samba.org/pub/rsync/NEWS#3.4.1) --- ## Inputs +- `debug`* - Whether to enable debug output. ("true" / "false") - Default: "false" + - `switches`* - The first is for any initial/required rsync flags, eg: `-avzr --delete` - `rsh` - Remote shell commands -- `legacy_allow_rsa_hostkeys` - Enables support for legacy RSA host keys on OpenSSH 8.8+. ("true" / "false") +- `strict_hostkeys_checking` - Enables support for strict hostkeys (fingerprint) checking. ("true" / "false") - Default: "false" + +- `legacy_allow_rsa_hostkeys` - Enables support for legacy RSA host keys on OpenSSH 8.8+. ("true" / "false") - Default: "false" - `path` - The source path. Defaults to GITHUB_WORKSPACE and is relative to it @@ -49,7 +53,17 @@ This action needs secret variables for the ssh private key of your key pair. The For simplicity, we are using `REMOTE_*` as the secret variables throughout the examples. -## Current Version: 7.1.0 +## Current Version: v8 (8.0.0) + +### Release channels: + +| Version | Purpose | Immutable | +| ------- | ------------------ | ------------------ | +| ``v8`` | latest release (pointer to 8.x.x) | no, points to latest MINOR,PATCH | +| 8.0.0 | latest major release | yes | +| 7.1.0 | previous release | yes | + +Check [SECURITY.md](SECURITY.md) for support cycles. ## Example usage @@ -66,9 +80,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: rsync deployments - uses: burnett01/rsync-deployments@7.1.0 + uses: burnett01/rsync-deployments@v8 with: switches: -avzr --delete path: src/ @@ -85,9 +99,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: rsync deployments - uses: burnett01/rsync-deployments@7.1.0 + uses: burnett01/rsync-deployments@v8 with: switches: -avzr --delete --exclude="" --include="" --filter="" path: src/ @@ -105,9 +119,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: rsync deployments - uses: burnett01/rsync-deployments@7.1.0 + uses: burnett01/rsync-deployments@v8 with: switches: -avzr --delete path: src/ @@ -125,9 +139,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: rsync deployments - uses: burnett01/rsync-deployments@7.1.0 + uses: burnett01/rsync-deployments@v8 with: switches: -avzr --delete path: src/ @@ -151,9 +165,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: rsync deployments - uses: burnett01/rsync-deployments@7.1.0 + uses: burnett01/rsync-deployments@v8 with: switches: -avzr --delete legacy_allow_rsa_hostkeys: "true" @@ -263,20 +277,27 @@ sudo apk add rsync ## Versions -## Version 7.0.2 +## Version 7.1.0 Check here: -- https://github.com/Burnett01/rsync-deployments/tree/7.0.2 (alpine 3.19.1) +- https://github.com/Burnett01/rsync-deployments/tree/7.1.0 (alpine 3.22.1) + + +## Version 7.0.2 (DEPRECATED) + +Check here: + +- https://github.com/Burnett01/rsync-deployments/tree/7.0.2 (alpine 3.22.1) --- -## Version 7.0.0 & 7.0.1 (DEPRECATED) +## Version 7.0.0 & 7.0.1 (EOL) Check here: - https://github.com/Burnett01/rsync-deployments/tree/7.0.0 (alpine 3.19.1) -- https://github.com/Burnett01/rsync-deployments/tree/7.0.1 (alpine 3.19.1) +- https://github.com/Burnett01/rsync-deployments/tree/7.0.1 (alpine 3.22.1) --- diff --git a/SECURITY.md b/SECURITY.md index 6e47c84..9da51a4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,30 @@ # Security Policy +The Docker image and code quality are regularly checked for vulnerabilities and CVEs by Snyk and CodeQL. + ## Supported Versions The following versions are currently being supported with security updates: -| Version | Supported | Rsync version | -| ------- | ------------------ | ------------------ | -| 7.1.0 | :white_check_mark: | >= 3.4.1 | -| 7.0.2 | :white_check_mark: | >= 3.4.0 | -| 7.0.1 | :warning: DEPRECATED | < 3.4.0 | -| 7.0.0 | :warning: DEPRECATED | < 3.4.0| -| 6.x | :x: EOL |< 3.4.0| -| 5.x | :x: EOL |< 3.4.0| -| 4.x | :x: EOL |< 3.4.0| -| 3.0 | :x: EOL |< 3.4.0| -| 2.0 | :x: EOL |< 3.4.0| -| 1.0 | :x: EOL |< 3.4.0| +| Version | Supported | Rsync version | Alpine version | +| ------- | ------------------ | ------------------ | ------------------ | +| 8.0.0 | :white_check_mark: | >= 3.4.1-r1 | 3.23.0 | +| 7.1.0 | :white_check_mark: | >= 3.4.1-r0 | 3.22.1 | +| 7.0.2 | :warning: DEPRECATED | >= 3.4.0-r0 | 3.22.1 | +| 7.0.1 | :x: EOL | < 3.4.0 | 3.22.1 | +| 7.0.0 | :x: EOL | < 3.4.0| 3.19.1 | +| 6.x | :x: EOL |< 3.4.0| 3.17.2 | +| 5.x | :x: EOL |< 3.4.0| 3.11 - 3.14.1 - 3.15 - 3.16 - 3.17.2 | +| 4.x | :x: EOL |< 3.4.0| 3.11 | +| 3.0 | :x: EOL |< 3.4.0| N/A | +| 2.0 | :x: EOL |< 3.4.0| Ubuntu | +| 1.0 | :x: EOL |< 3.4.0| Ubuntu | + +### Terminology + +EOL = End of life (no support/no updates) + +DEPRECATED = Close to EOL (support/no updates) ## Reporting a Vulnerability diff --git a/action.yml b/action.yml index db35730..d8741c7 100644 --- a/action.yml +++ b/action.yml @@ -13,6 +13,10 @@ inputs: description: 'Enables support for legacy RSA host keys on OpenSSH 8.8+' required: false default: 'false' + strict_hostkeys_checking: + description: 'Controls strict host keys checking' + required: false + default: 'false' path: description: 'The local path' required: false @@ -37,6 +41,10 @@ inputs: description: 'The remote key passphrase' required: false default: '' + debug: + description: 'Debug the action' + required: false + default: 'false' runs: using: 'docker' image: 'Dockerfile' diff --git a/docker-rsync/agent-add b/docker-rsync/agent-add new file mode 100755 index 0000000..357f378 --- /dev/null +++ b/docker-rsync/agent-add @@ -0,0 +1,6 @@ +#!/bin/sh + +set -eu + +source agent-start "${1:-default}" +cat - | tr -d '\r' | DISPLAY=1 SSH_ASKPASS=agent-askpass ssh-add - >/dev/null diff --git a/docker-rsync/agent-askpass b/docker-rsync/agent-askpass new file mode 100755 index 0000000..f54a052 --- /dev/null +++ b/docker-rsync/agent-askpass @@ -0,0 +1,5 @@ +#!/bin/sh + +set -eu + +echo "$SSH_PASS" diff --git a/docker-rsync/agent-start b/docker-rsync/agent-start new file mode 100755 index 0000000..c064b51 --- /dev/null +++ b/docker-rsync/agent-start @@ -0,0 +1,21 @@ +#!/bin/sh + +set -eu + +FOLDER=${1:-default} +STORE_PATH="/tmp/ssh-agent/$FOLDER" +mkdir -p "$STORE_PATH" + +if [ -z "${SSH_AGENT_PID:-}" ]; then + if [ -f "$STORE_PATH/id" ]; then + SSH_AGENT_PID=$(cat "$STORE_PATH/id") + export SSH_AGENT_PID + + SSH_AUTH_SOCK=$(cat "$STORE_PATH/sock") + export SSH_AUTH_SOCK + else + eval "$(ssh-agent)" > /dev/null + echo "$SSH_AGENT_PID" > "$STORE_PATH"/id + echo "$SSH_AUTH_SOCK" > "$STORE_PATH"/sock + fi +fi diff --git a/docker-rsync/agent-stop b/docker-rsync/agent-stop new file mode 100755 index 0000000..b6b0fa9 --- /dev/null +++ b/docker-rsync/agent-stop @@ -0,0 +1,37 @@ +#!/bin/sh + +set -eu + +if [ ! -z "$SSH_AGENT_PID" ]; then + # Here, the environment is set already, just kill the script. + eval $(ssh-agent -k) >/dev/null + exit $? +else + # The env isn't set, construct the file path. + FOLDER=${1:-default} + STORE_PATH="/tmp/ssh-agent/$FOLDER" + if [ ! -d "$STORE_PATH" ]; then + echo "Store Path $STORE_PATH doesn't exist!" >&2 + exit 1 + fi + + # And check our files exist. + if [ -f "$STORE_PATH/id" ]; then + # Grab our PID and socket. + SSH_AGENT_PID=$(cat "$STORE_PATH/id") + export SSH_AGENT_PID + rm "$STORE_PATH/id" + + SSH_AUTH_SOCK=$(cat "$STORE_PATH/sock") + export SSH_AUTH_SOCK + rm "$STORE_PATH/sock" + + + rmdir "$STORE_PATH" + eval $(ssh-agent -k) >/dev/null + exit $? + else + echo "SSH_AGENT_PID not set, $STORE_PATH/id doesn't exist!" >&2 + exit 1 + fi +fi diff --git a/docker-rsync/hosts-add b/docker-rsync/hosts-add new file mode 100755 index 0000000..a2c5e79 --- /dev/null +++ b/docker-rsync/hosts-add @@ -0,0 +1,5 @@ +#!/bin/sh + +set -eu + +printf '%s\n' "$@" >> $HOME/.ssh/known_hosts diff --git a/docker-rsync/hosts-clear b/docker-rsync/hosts-clear new file mode 100755 index 0000000..8579f7d --- /dev/null +++ b/docker-rsync/hosts-clear @@ -0,0 +1,5 @@ +#!/bin/sh + +set -eu + +truncate -s 0 $HOME/.ssh/known_hosts diff --git a/docker-rsync/hosts-init b/docker-rsync/hosts-init new file mode 100755 index 0000000..fa4dbe3 --- /dev/null +++ b/docker-rsync/hosts-init @@ -0,0 +1,6 @@ +#!/bin/sh + +set -eu + +touch $HOME/.ssh/known_hosts +chmod 600 $HOME/.ssh/known_hosts diff --git a/docker-rsync/ssh-init b/docker-rsync/ssh-init new file mode 100755 index 0000000..c35c903 --- /dev/null +++ b/docker-rsync/ssh-init @@ -0,0 +1,5 @@ +#!/bin/sh + +set -eu + +mkdir -m 700 $HOME/.ssh diff --git a/entrypoint.sh b/entrypoint.sh index b854a54..3ad0a96 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,25 +1,55 @@ #!/bin/sh +set -eu + +if [ "${INPUT_DEBUG:-false}" = "true" ]; then + set -x +fi + if [ -z "$(echo "$INPUT_REMOTE_PATH" | awk '{$1=$1};1')" ]; then echo "The remote_path can not be empty. see: github.com/Burnett01/rsync-deployments/issues/44" exit 1 fi +# Initialize SSH and known hosts. +source ssh-init +source hosts-init + # Start the SSH agent and load key. source agent-start "$GITHUB_ACTION" -echo "$INPUT_REMOTE_KEY" | SSH_PASS="$INPUT_REMOTE_KEY_PASS" agent-add - -# Add strict errors. -set -eu +printf '%s' "$INPUT_REMOTE_KEY" | SSH_PASS="${INPUT_REMOTE_KEY_PASS}" agent-add >/dev/null 2>&1 # Variables. -LEGACY_RSA_HOSTKEYS="-o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedKeyTypes=+ssh-rsa" -LEGACY_RSA_HOSTKEYS=$([ "$INPUT_LEGACY_ALLOW_RSA_HOSTKEYS" = "true" ] && echo "$LEGACY_RSA_HOSTKEYS" || echo "") +LEGACY_RSA_HOSTKEYS="" +if [ "${INPUT_LEGACY_ALLOW_RSA_HOSTKEYS:-false}" = "true" ]; then + LEGACY_RSA_HOSTKEYS="-o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedKeyTypes=+ssh-rsa" +fi -SWITCHES="$INPUT_SWITCHES" -RSH="ssh -o StrictHostKeyChecking=no $LEGACY_RSA_HOSTKEYS -p $INPUT_REMOTE_PORT $INPUT_RSH" +STRICT_HOSTKEYS_CHECKING="-o StrictHostKeyChecking=no" +if [ "${INPUT_STRICT_HOSTKEYS_CHECKING:-false}" = "true" ]; then + STRICT_HOSTKEYS_CHECKING="-o UserKnownHostsFile=$HOME/.ssh/known_hosts -o StrictHostKeyChecking=yes" + + key="$(ssh-keyscan -p "$INPUT_REMOTE_PORT" "$INPUT_REMOTE_HOST" 2>/dev/null | sed '/^#/d')" || key="" + if [ -n "$key" ]; then + # fingerprint verification + echo "$key" | ssh-keygen -lf - + # add to known hosts + echo "$key" | while IFS= read -r line; do hosts-add "$line"; done + else + echo "Warning: failed to fetch host key for $INPUT_REMOTE_HOST" >&2 + exit 1 + fi +fi + +RSH="ssh $STRICT_HOSTKEYS_CHECKING $LEGACY_RSA_HOSTKEYS -p $INPUT_REMOTE_PORT $INPUT_RSH" LOCAL_PATH="$GITHUB_WORKSPACE/$INPUT_PATH" DSN="$INPUT_REMOTE_USER@$INPUT_REMOTE_HOST" # Deploy. -sh -c "rsync $SWITCHES -e '$RSH' $LOCAL_PATH $DSN:$INPUT_REMOTE_PATH" +sh -c "rsync $INPUT_SWITCHES -e '$RSH' $LOCAL_PATH $DSN:$INPUT_REMOTE_PATH" + +# Clean up. +source agent-stop "$GITHUB_ACTION" +source hosts-clear + +exit 0 diff --git a/test/entrypoint.bats b/test/entrypoint.bats index 80dfba9..53ed313 100644 --- a/test/entrypoint.bats +++ b/test/entrypoint.bats @@ -1,12 +1,13 @@ #!/usr/bin/env bats setup() { - # Create a dummy ssh agent and agent-add for sourcing + # Create dummy binaries for sourcing + echo 'echo "source"' > source echo 'echo "agent started"' > agent-start echo 'echo "key added"' > agent-add - chmod +x agent-start agent-add + chmod +x source agent-start agent-add - # Create a dummy rsync to capture its arguments + # Create dummy rsync binary to capture its arguments echo 'echo "rsync $@"' > rsync chmod +x rsync @@ -14,7 +15,7 @@ setup() { } teardown() { - rm -f agent-start agent-add rsync + rm -f source agent-start agent-add rsync ssh-keyscan hosts-add } @test "fails if INPUT_REMOTE_PATH is empty" { @@ -35,13 +36,14 @@ teardown() { export INPUT_RSH="" export INPUT_PATH="" export INPUT_REMOTE_USER="user" - export INPUT_REMOTE_HOST="host" + export INPUT_REMOTE_HOST="localhost.local" export GITHUB_WORKSPACE="/tmp" - export DSN="user@host" + export DSN="user@localhost.local" export LOCAL_PATH="/tmp/" run ./entrypoint.sh - [[ "${output}" == *"HostKeyAlgorithms=+ssh-rsa"* ]] + + [[ "${output}" == *"rsync -avz -e ssh -o StrictHostKeyChecking=no -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedKeyTypes=+ssh-rsa -p 22 /tmp/ user@localhost.local:remote/"* ]] } @test "does not include legacy RSA switches when not allowed" { @@ -55,11 +57,72 @@ teardown() { export INPUT_RSH="" export INPUT_PATH="" export INPUT_REMOTE_USER="user" - export INPUT_REMOTE_HOST="host" + export INPUT_REMOTE_HOST="localhost.local" export GITHUB_WORKSPACE="/tmp" - export DSN="user@host" + export DSN="user@localhost.local" export LOCAL_PATH="/tmp/" run ./entrypoint.sh - [[ "${output}" != *"HostKeyAlgorithms=+ssh-rsa"* ]] + [[ "${output}" == *"rsync -avz -e ssh -o StrictHostKeyChecking=no -p 22 /tmp/ user@localhost.local:remote/"* ]] +} + +@test "includes STRICT_HOSTKEYS_CHECKING switches when allowed" { + # Set a fake HOME dir + local -r HOME="/tmp" + + export INPUT_LEGACY_ALLOW_RSA_HOSTKEYS="false" + export INPUT_STRICT_HOSTKEYS_CHECKING="true" + export INPUT_REMOTE_PATH="remote/" + export INPUT_REMOTE_KEY="dummy" + export INPUT_REMOTE_KEY_PASS="dummy" + export GITHUB_ACTION="dummy" + export INPUT_SWITCHES="-avz" + export INPUT_REMOTE_PORT="22" + export INPUT_RSH="" + export INPUT_PATH="" + export INPUT_REMOTE_USER="user" + export INPUT_REMOTE_HOST="localhost.local" + export GITHUB_WORKSPACE="/tmp" + export DSN="user@localhost.local" + export LOCAL_PATH="/tmp/" + + # Generate a mock key pair to test ssh-keyscan (entrypoint.sh:32) + rm -f "$HOME/mockKeyPair" "$HOME/mockKeyPair.pub" \ + && ssh-keygen -t ed25519 -f "$HOME/mockKeyPair" -N '' -q -C '' \ + && mockPublicKey=$(< "$HOME/mockKeyPair.pub") + + # Create dummy ssh-keyscan binary to return $mockPublicKey + echo "echo 'localhost.local $mockPublicKey #Mock 1'" > ssh-keyscan + chmod +x ssh-keyscan + + # Create dummy hosts-add binary to capture its arguments + echo 'echo "hosts-add $@"' > hosts-add + chmod +x hosts-add + + run ./entrypoint.sh + + [[ "${output}" == *"hosts-add localhost.local ssh-ed25519"* ]] + [[ "${output}" == *"rsync -avz -e ssh -o UserKnownHostsFile=/tmp/.ssh/known_hosts -o StrictHostKeyChecking=yes -p 22 /tmp/ user@localhost.local:remote/"* ]] +} + +@test "does not includes STRICT_HOSTKEYS_CHECKING switches when not allowed" { + export INPUT_LEGACY_ALLOW_RSA_HOSTKEYS="false" + export INPUT_STRICT_HOSTKEYS_CHECKING="false" + export INPUT_REMOTE_PATH="remote/" + export INPUT_REMOTE_KEY="dummy" + export INPUT_REMOTE_KEY_PASS="dummy" + export GITHUB_ACTION="dummy" + export INPUT_SWITCHES="-avz" + export INPUT_REMOTE_PORT="22" + export INPUT_RSH="" + export INPUT_PATH="" + export INPUT_REMOTE_USER="user" + export INPUT_REMOTE_HOST="localhost.local" + export GITHUB_WORKSPACE="/tmp" + export DSN="user@localhost.local" + export LOCAL_PATH="/tmp/" + + run ./entrypoint.sh + + [[ "${output}" == *"rsync -avz -e ssh -o StrictHostKeyChecking=no -p 22 /tmp/ user@localhost.local:remote/"* ]] }