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
39 changes: 33 additions & 6 deletions rsync-ssl
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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=' = '
;;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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 <<EOF 10<&0 0<&11 11<&-
Expand Down Expand Up @@ -177,7 +204,7 @@ function path_search {

if [[ "$#" == 0 ]]; then
echo "Usage: rsync-ssl [--type=SSL_TYPE] RSYNC_ARG [...]" 1>&2
echo "The SSL_TYPE can be openssl or stunnel"
echo "The SSL_TYPE can be openssl, socat, or stunnel"
exit 1
fi

Expand Down
23 changes: 17 additions & 6 deletions rsync-ssl.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
190 changes: 190 additions & 0 deletions testsuite/rsync-ssl-socat_test.py
Original file line number Diff line number Diff line change
@@ -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')
Loading