diff --git a/rsync-ssl b/rsync-ssl index 56ee7dfe0..69e1d5eaf 100755 --- a/rsync-ssl +++ b/rsync-ssl @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# This script uses openssl, gnutls, or stunnel to secure an rsync daemon connection. +# This script uses openssl, gnutls, socat, or stunnel to secure an rsync daemon connection. # By default this script takes rsync args and hands them off to the actual # rsync command with an --rsh option that makes it open an SSL connection to an @@ -31,13 +31,16 @@ function rsync_ssl_run { function rsync_ssl_helper { if [[ -z "$RSYNC_SSL_TYPE" ]]; then - found=`path_search openssl stunnel4 stunnel` || exit 1 + found=$(path_search openssl socat stunnel4 stunnel) || exit 1 if [[ "$found" == */openssl ]]; then RSYNC_SSL_TYPE=openssl RSYNC_SSL_OPENSSL="$found" elif [[ "$found" == */gnutls-cli ]]; then RSYNC_SSL_TYPE=gnutls RSYNC_SSL_GNUTLS="$found" + elif [[ "$found" == */socat ]]; then + RSYNC_SSL_TYPE=socat + RSYNC_SSL_SOCAT="$found" else RSYNC_SSL_TYPE=stunnel RSYNC_SSL_STUNNEL="$found" @@ -47,19 +50,25 @@ function rsync_ssl_helper { case "$RSYNC_SSL_TYPE" in openssl) if [[ -z "$RSYNC_SSL_OPENSSL" ]]; then - RSYNC_SSL_OPENSSL=`path_search openssl` || exit 1 + RSYNC_SSL_OPENSSL=$(path_search openssl) || exit 1 fi optsep=' ' ;; gnutls) if [[ -z "$RSYNC_SSL_GNUTLS" ]]; then - RSYNC_SSL_GNUTLS=`path_search gnutls-cli` || exit 1 + RSYNC_SSL_GNUTLS=$(path_search gnutls-cli) || exit 1 + fi + optsep=' ' + ;; + socat) + if [[ -z "$RSYNC_SSL_SOCAT" ]]; then + RSYNC_SSL_SOCAT=$(path_search socat) || exit 1 fi optsep=' ' ;; stunnel) if [[ -z "$RSYNC_SSL_STUNNEL" ]]; then - RSYNC_SSL_STUNNEL=`path_search stunnel4 stunnel` || exit 1 + RSYNC_SSL_STUNNEL=$(path_search stunnel4 stunnel) || exit 1 fi optsep=' = ' ;; @@ -72,17 +81,21 @@ function rsync_ssl_helper { if [[ -z "$RSYNC_SSL_CERT" ]]; then certopt="" gnutls_cert_opt="" + socat_cert_opt="" else certopt="-cert$optsep$RSYNC_SSL_CERT" gnutls_cert_opt="--x509certfile=$RSYNC_SSL_CERT" + socat_cert_opt=",cert=$RSYNC_SSL_CERT" fi if [[ -z "$RSYNC_SSL_KEY" ]]; then keyopt="" gnutls_key_opt="" + socat_key_opt="" else keyopt="-key$optsep$RSYNC_SSL_KEY" gnutls_key_opt="--x509keyfile=$RSYNC_SSL_KEY" + socat_key_opt=",key=$RSYNC_SSL_KEY" fi if [[ -z ${RSYNC_SSL_CA_CERT+x} ]]; then @@ -91,6 +104,8 @@ function rsync_ssl_helper { caopt="-verify_return_error -verify 4" # gnutls: gnutls_opts="" + # socat: + socat_opts="verify=1" # stunnel: # Since there is no way of using the default CA certificate collection, # we cannot do any verification. Thus, stunnel should really only be @@ -103,6 +118,8 @@ function rsync_ssl_helper { caopt="-verify 1" # gnutls: gnutls_opts="--insecure" + # socat: + socat_opts="verify=0" # stunnel: cafile="" verify="verifyChain = no" @@ -112,6 +129,8 @@ function rsync_ssl_helper { caopt="-CAfile $RSYNC_SSL_CA_CERT -verify_return_error -verify 4" # gnutls: gnutls_opts="--x509cafile=$RSYNC_SSL_CA_CERT" + # socat: + socat_opts="cafile=$RSYNC_SSL_CA_CERT,verify=1" # stunnel: cafile="CAfile = $RSYNC_SSL_CA_CERT" verify="verifyChain = yes" @@ -136,10 +155,18 @@ function rsync_ssl_helper { exit 1 fi + if [[ "$hostname" =~ ^[0-9.]+$ || "$hostname" == *:* ]]; then + socat_sni_opt=",no-sni=1" + else + socat_sni_opt=",snihost=$hostname" + fi + if [[ $RSYNC_SSL_TYPE == openssl ]]; then exec $RSYNC_SSL_OPENSSL s_client $caopt $certopt $keyopt -quiet -verify_quiet -servername $hostname -verify_hostname $hostname -connect $hostname:$port elif [[ $RSYNC_SSL_TYPE == gnutls ]]; then exec $RSYNC_SSL_GNUTLS --logfile=/dev/null $gnutls_cert_opt $gnutls_key_opt $gnutls_opts $hostname:$port + elif [[ $RSYNC_SSL_TYPE == socat ]]; then + exec $RSYNC_SSL_SOCAT - "OPENSSL:$hostname:$port,commonname=$hostname$socat_sni_opt,$socat_opts$socat_cert_opt$socat_key_opt" else # devzero@web.de came up with this no-tmpfile calling syntax: exec $RSYNC_SSL_STUNNEL -fd 10 11<&0 <&2 - echo "The SSL_TYPE can be openssl or stunnel" + echo "The SSL_TYPE can be openssl, socat, or stunnel" exit 1 fi diff --git a/rsync-ssl.1.md b/rsync-ssl.1.md index a6f1e3d39..a32756b1a 100644 --- a/rsync-ssl.1.md +++ b/rsync-ssl.1.md @@ -27,10 +27,10 @@ rsync version to be at least 3.2.0. If the **first** arg is a `--type=SSL_TYPE` option, the script will only use that particular program to open an ssl connection instead of trying to find an -openssl or stunnel executable via a simple heuristic (assuming that the -`RSYNC_SSL_TYPE` environment variable is not set as well -- see below). This -option must specify one of `openssl` or `stunnel`. The equal sign is -required for this particular option. +openssl, socat, or stunnel executable via a simple heuristic (assuming that +the `RSYNC_SSL_TYPE` environment variable is not set as well -- see below). +This option must specify one of `openssl`, `socat`, or `stunnel`. The equal +sign is required for this particular option. All the other options are passed through to the rsync command, so consult the **rsync**(1) manpage for more information on how it works. @@ -42,8 +42,8 @@ The ssl helper scripts are affected by the following environment variables: 0. `RSYNC_SSL_TYPE` Specifies the program type that should be used to open the ssl connection. - It must be one of `openssl` or `stunnel`. The `--type=SSL_TYPE` option - overrides this, when specified. + It must be one of `openssl`, `socat`, or `stunnel`. The `--type=SSL_TYPE` + option overrides this, when specified. 0. `RSYNC_SSL_PORT` @@ -78,6 +78,11 @@ The ssl helper scripts are affected by the following environment variables: Specifies the gnutls-cli executable to run when the connection type is set to gnutls. If unspecified, the $PATH is searched for "gnutls-cli". +0. `RSYNC_SSL_SOCAT` + + Specifies the socat executable to run when the connection type is set to + socat. If unspecified, the $PATH is searched for "socat". + 0. `RSYNC_SSL_STUNNEL` Specifies the stunnel executable to run when the connection type is set to @@ -90,6 +95,8 @@ The ssl helper scripts are affected by the following environment variables: > rsync-ssl --type=openssl -aiv example.com::mod/ dest +> rsync-ssl --type=socat -aiv example.com::mod/ dest + > rsync-ssl -aiv --port 9874 example.com::mod/ dest > rsync-ssl -aiv rsync://example.com:9874/mod/ dest @@ -111,6 +118,10 @@ connection against the CA certificate collection, so it only encrypts the connection without any cert validation unless you have specified the certificate environment options. +The `openssl` type uses `openssl s_client`, which is retained for +compatibility. If your OpenSSL version's `s_client` has trouble handling +rsync traffic, try `--type=socat` or `--type=stunnel`. + This script also supports a `--type=gnutls` option, but at the time of this release the gnutls-cli command was dropping output, making it unusable. If that bug has been fixed in your version, feel free to put gnutls into an diff --git a/testsuite/rsync-ssl-socat_test.py b/testsuite/rsync-ssl-socat_test.py new file mode 100644 index 000000000..18945a792 --- /dev/null +++ b/testsuite/rsync-ssl-socat_test.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""rsync-ssl socat transport anti-regression tests. + +These tests exercise the wrapper/helper contract without requiring a live TLS +server. Fake helper binaries capture argv so the test can verify the intended +transport selection and OPENSSL address construction. +""" + +import os +import shutil +import subprocess +import sys + +from rsyncfns import SCRATCHDIR, SRCDIR, test_fail + + +RSYNC_SSL = SRCDIR / 'rsync-ssl' +BASH = shutil.which('bash') +HELPER_ARGV = SCRATCHDIR / 'helper.argv' +RSYNC_ARGV = SCRATCHDIR / 'rsync.argv' +OPENSSL_ARGV = SCRATCHDIR / 'openssl.argv' +FAKEBIN = SCRATCHDIR / 'fakebin' +FAKEBIN.mkdir() + +if BASH is None: + test_fail('bash is required to run rsync-ssl') + + +def script(path, text): + path.write_text(text) + path.chmod(0o755) + return path + + +def argv_capture_script(path, output_path, env_name=None): + env_capture = '' + if env_name: + env_capture = ( + f" out.write({env_name + '='!r} + " + f"os.environ.get({env_name!r}, '') + '\\n')\n" + ) + return script(path, f'''#!{sys.executable} +import os +import sys + +with open({str(output_path)!r}, 'w', encoding='utf-8') as out: +{env_capture} for arg in sys.argv[1:]: + out.write(arg + '\\n') +''') + + +fake_socat = argv_capture_script(FAKEBIN / 'socat', HELPER_ARGV) + +FALLBACKBIN = SCRATCHDIR / 'fallbackbin' +FALLBACKBIN.mkdir() +fallback_helper_argv = SCRATCHDIR / 'fallback-helper.argv' + +argv_capture_script(FALLBACKBIN / 'socat', fallback_helper_argv) + +argv_capture_script(FAKEBIN / 'openssl', OPENSSL_ARGV) + +argv_capture_script(FAKEBIN / 'rsync', RSYNC_ARGV, 'RSYNC_SSL_TYPE') + + +def clean_env(**updates): + env = os.environ.copy() + for key in list(env): + if key.startswith('RSYNC_SSL_') or key == 'RSYNC_PORT': + del env[key] + env['PATH'] = f'{FAKEBIN}:{env["PATH"]}' + for key, value in updates.items(): + env[key] = value + return env + + +def fallback_env(**updates): + env = clean_env(**updates) + env['PATH'] = f'{FALLBACKBIN}' + return env + + +def run_helper(host, **env_updates): + HELPER_ARGV.unlink(missing_ok=True) + proc = subprocess.run( + [str(RSYNC_SSL), '--HELPER', host, 'rsync', '--server', '--daemon', '.'], + env=clean_env(RSYNC_SSL_TYPE='socat', RSYNC_SSL_SOCAT=str(fake_socat), + **env_updates), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if proc.returncode != 0: + test_fail(f'rsync-ssl socat helper failed: {proc.stderr}') + if not HELPER_ARGV.exists(): + test_fail('fake socat helper was not executed') + return HELPER_ARGV.read_text().splitlines() + + +# --- --type=socat is consumed by the wrapper and passed via helper env ------- +proc = subprocess.run( + [str(RSYNC_SSL), '--type=socat', 'example.com::module'], + env=clean_env(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, +) +if proc.returncode != 0: + test_fail(f'rsync-ssl --type=socat wrapper failed: {proc.stderr}') +rsync_argv = RSYNC_ARGV.read_text().splitlines() +if rsync_argv[0] != 'RSYNC_SSL_TYPE=socat': + test_fail('--type=socat did not set RSYNC_SSL_TYPE for the rsync wrapper') +if '--type=socat' in rsync_argv: + test_fail('--type=socat leaked through to the real rsync argv') +if not any(arg.startswith('--rsh=') and arg.endswith(' --HELPER') + for arg in rsync_argv): + test_fail('rsync-ssl did not install itself as the rsync --rsh helper') + + +# --- socat helper uses default verification and SNI for host names ----------- +argv = run_helper('example.com') +want = [ + '-', + 'OPENSSL:example.com:874,commonname=example.com,snihost=example.com,verify=1', +] +if argv != want: + test_fail(f'unexpected socat argv for host name: {argv!r}') + + +# --- explicit CA/cert/key/port are preserved and IP addresses disable SNI ---- +argv = run_helper( + '127.0.0.1', + RSYNC_PORT='8873', + RSYNC_SSL_CA_CERT='/tmp/ca.pem', + RSYNC_SSL_CERT='/tmp/cert.pem', + RSYNC_SSL_KEY='/tmp/key.pem', +) +want = [ + '-', + ('OPENSSL:127.0.0.1:8873,commonname=127.0.0.1,no-sni=1,' + 'cafile=/tmp/ca.pem,verify=1,cert=/tmp/cert.pem,key=/tmp/key.pem'), +] +if argv != want: + test_fail(f'unexpected socat argv for IP address: {argv!r}') + + +# --- empty RSYNC_SSL_CA_CERT deliberately disables socat verification -------- +argv = run_helper('example.net', RSYNC_SSL_CA_CERT='') +want = [ + '-', + 'OPENSSL:example.net:874,commonname=example.net,snihost=example.net,verify=0', +] +if argv != want: + test_fail(f'unexpected socat argv for disabled verification: {argv!r}') + + +# --- default helper selection keeps existing openssl-first behaviour --------- +OPENSSL_ARGV.unlink(missing_ok=True) +proc = subprocess.run( + [str(RSYNC_SSL), '--HELPER', 'example.org', 'rsync', '--server', + '--daemon', '.'], + env=clean_env(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, +) +if proc.returncode != 0: + test_fail(f'rsync-ssl default helper failed: {proc.stderr}') +if not OPENSSL_ARGV.exists(): + test_fail('default rsync-ssl helper selection did not execute openssl') +openssl_argv = OPENSSL_ARGV.read_text().splitlines() +if not openssl_argv or openssl_argv[0] != 's_client': + test_fail(f'default helper selection did not use openssl s_client: {openssl_argv!r}') + + +# --- if openssl is unavailable, default selection prefers socat over stunnel - +fallback_helper_argv.unlink(missing_ok=True) +proc = subprocess.run( + [BASH, str(RSYNC_SSL), '--HELPER', 'fallback.example', 'rsync', + '--server', '--daemon', '.'], + env=fallback_env(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, +) +if proc.returncode != 0: + test_fail(f'rsync-ssl fallback helper failed: {proc.stderr}') +if not fallback_helper_argv.exists(): + test_fail('default rsync-ssl fallback selection did not execute socat') +fallback_argv = fallback_helper_argv.read_text().splitlines() +want = [ + '-', + ('OPENSSL:fallback.example:874,commonname=fallback.example,' + 'snihost=fallback.example,verify=1'), +] +if fallback_argv != want: + test_fail(f'unexpected socat argv for fallback selection: {fallback_argv!r}') + +print('rsync-ssl-socat: wrapper and helper transport behaviour verified')