rrsync improvements

- Convert rrsync to python.
- Enhance security of arg & option checking.
- Reject `-L` (`--copy-links`) by default.
- Add `-munge` and `-no-del` options.
- Tweak the logfile line format.
- Created an rrsync man page.
- Use `configure --with-rrsync` if you want `make install` to install
  rrsync and its man page.
- Give lsh more rrsync testing support.
This commit is contained in:
Wayne Davison
2021-12-26 12:29:00 -08:00
parent 73ceea6ad2
commit 72adf49ba8
9 changed files with 477 additions and 283 deletions

View File

@@ -6,6 +6,7 @@ exec_prefix=@exec_prefix@
bindir=@bindir@
libdir=@libdir@/rsync
mandir=@mandir@
with_rrsync=@with_rrsync@
LIBS=@LIBS@
CC=@CC@
@@ -80,6 +81,10 @@ install: all
if test -f rsync.1; then $(INSTALLMAN) -m 644 rsync.1 $(DESTDIR)$(mandir)/man1; fi
if test -f rsync-ssl.1; then $(INSTALLMAN) -m 644 rsync-ssl.1 $(DESTDIR)$(mandir)/man1; fi
if test -f rsyncd.conf.5; then $(INSTALLMAN) -m 644 rsyncd.conf.5 $(DESTDIR)$(mandir)/man5; fi
if test "$(with_rrsync)" = yes; then \
$(INSTALLCMD) -m 755 $(srcdir)/support/rrsync $(DESTDIR)$(bindir); \
if test -f rrsync.1; then $(INSTALLMAN) -m 644 rrsync.1 $(DESTDIR)$(mandir)/man1; fi; \
fi
install-ssl-daemon: stunnel-rsyncd.conf
-$(MKDIR_P) $(DESTDIR)/etc/stunnel
@@ -247,7 +252,7 @@ proto.h-tstamp: $(srcdir)/*.c $(srcdir)/lib/compat.c daemon-parm.h
$(AWK) -f $(srcdir)/mkproto.awk $(srcdir)/*.c $(srcdir)/lib/compat.c daemon-parm.h
.PHONY: man
man: rsync.1 rsync-ssl.1 rsyncd.conf.5
man: rsync.1 rsync-ssl.1 rsyncd.conf.5 rrsync.1
rsync.1: rsync.1.md md2man version.h Makefile
@$(srcdir)/maybe-make-man $(srcdir) rsync.1.md
@@ -258,6 +263,9 @@ rsync-ssl.1: rsync-ssl.1.md md2man version.h Makefile
rsyncd.conf.5: rsyncd.conf.5.md md2man version.h Makefile
@$(srcdir)/maybe-make-man $(srcdir) rsyncd.conf.5.md
rrsync.1: support/rrsync.1.md md2man Makefile
@$(srcdir)/maybe-make-man $(srcdir) support/rrsync.1.md
.PHONY: clean
clean: cleantests
rm -f *~ $(OBJS) $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_SYMLINKS) \

18
NEWS.md
View File

@@ -97,9 +97,15 @@
- More ASM optimizations from Shark64.
- Make rrsync pass --munge-links to rsync by default to make the restricted
dir extra safe (with an option to turn it off if you trust your users).
Also updated the known options list.
- Transformed rrsync into a python script with improvements: security has been
beefed up; the known rsync options were updated to include recent additions;
rrsync rejects `-L` (`--copy-links`) by default to make it harder to exploit
any out-of-subdir symlinks; a new rrsync option of `-munge` tells rrsync to
always enable the `--munge-links` rsync option on the server side; a new
rrsync option of `-no-del` disables all `--remove*` and `--delete*` rsync
options on the server side; the log format has been tweaked slightly to add
seconds to the timestamp and output the command executed as a tuple; an
rrsync.1 manpage is now created.
- Work around a glibc bug where lchmod() breaks in a chroot w/o /proc mounted.
@@ -107,6 +113,12 @@
### PACKAGING RELATED:
- Give configure the --with-rrsync option if you want `make install` to
install the (now python3) rrsync script and its (new) man page.
- If the rrsync script is installed, make its package depend on python3 and
(suggested but not required) the python3 braceexpand lib.
- When creating a package from a non-release version (w/o a git checkout), the
packager can elect to create git-version.h and define RSYNC_GITVER to the
string they want `--version` to output. (The file is still auto-generated

View File

@@ -136,6 +136,13 @@ if test x"$GCC" = x"yes"; then
CFLAGS="$CFLAGS -Wall -W"
fi
AC_ARG_WITH(rrsync,
AS_HELP_STRING([--with-rrsync],[also install the rrsync script and its man page]))
if test x"$with_rrsync" != x"yes"; then
with_rrsync=no
fi
AC_SUBST(with_rrsync)
AC_ARG_WITH(included-popt,
AS_HELP_STRING([--with-included-popt],[use bundled popt library, not from system]))

View File

@@ -37,4 +37,4 @@ if [ ! -f "$flagfile" ]; then
fi
fi
"$srcdir/md2man" "$srcdir/$inname"
"$srcdir/md2man" -s "$srcdir" "$srcdir/$inname"

13
md2man
View File

@@ -85,7 +85,9 @@ def main():
die('Failed to parse NAME.NUM.md out of input file:', args.mdfile)
fi = argparse.Namespace(**fi.groupdict())
if not fi.srcdir:
if args.srcdir:
fi.srcdir = args.srcdir + '/'
elif not fi.srcdir:
fi.srcdir = './'
fi.title = fi.prog + '(' + fi.sect + ') man page'
@@ -105,7 +107,7 @@ def main():
for fn in (fi.srcdir + 'version.h', 'Makefile'):
try:
st = os.lstat(fn)
except:
except OSError:
die('Failed to find', fi.srcdir + fn)
if not fi.mtime:
fi.mtime = st.st_mtime
@@ -129,6 +131,10 @@ def main():
if var == 'srcdir':
break
fi.prog_ver = 'rsync ' + env_subs['VERSION']
if fi.prog != 'rsync':
fi.prog_ver = fi.prog + ' from ' + fi.prog_ver
with open(fi.fn, 'r', encoding='utf-8') as fh:
txt = fh.read()
@@ -140,7 +146,7 @@ def main():
txt = None
fi.date = time.strftime('%d %b %Y', time.localtime(fi.mtime))
fi.man_headings = (fi.prog, fi.sect, fi.date, fi.prog + ' ' + env_subs['VERSION'], env_subs['prefix'])
fi.man_headings = (fi.prog, fi.sect, fi.date, fi.prog_ver, env_subs['prefix'])
HtmlToManPage(fi)
@@ -374,6 +380,7 @@ def die(*msg):
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Transform a NAME.NUM.md markdown file into a NAME.NUM.html web page & a NAME.NUM man page.', add_help=False)
parser.add_argument('--srcdir', '-s', help='Specify the source dir if the input file is not in it.')
parser.add_argument('--test', action='store_true', help='Test if we can parse the input w/o updating any files.')
parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")

View File

@@ -7,7 +7,7 @@ import re, argparse
short_no_arg = { }
short_with_num = { '@': 1 };
long_opt = { # These include some extra long-args that BackupPC uses:
long_opts = { # These include some extra long-args that BackupPC uses:
'block-size': 1,
'daemon': -1,
'debug': 1,
@@ -25,6 +25,7 @@ long_opt = { # These include some extra long-args that BackupPC uses:
'owner': 0,
'perms': 0,
'recursive': 0,
'stderr': 1,
'times': 0,
'write-devices': -1,
}
@@ -49,8 +50,8 @@ def main():
m = re.search(r'args\[ac\+\+\] = "--([^"=]+)"', line)
if m:
last_long_opt = m.group(1)
if last_long_opt not in long_opt:
long_opt[last_long_opt] = 0
if last_long_opt not in long_opts:
long_opts[last_long_opt] = 0
else:
last_long_opt = None
continue
@@ -58,13 +59,13 @@ def main():
if last_long_opt:
m = re.search(r'args\[ac\+\+\] = ([^["\s]+);', line)
if m:
long_opt[last_long_opt] = 2
long_opts[last_long_opt] = 2
last_long_opt = None
continue
m = re.search(r'return "--([^"]+-dest)";', line)
if m:
long_opt[m.group(1)] = 2
long_opts[m.group(1)] = 2
last_long_opt = None
continue
@@ -74,19 +75,18 @@ def main():
if not m:
m = re.search(r'fmt = .*: "--([^"=]+)=', line)
if m:
long_opt[m.group(1)] = 1
long_opts[m.group(1)] = 1
last_long_opt = None
long_opt['files-from'] = 3
long_opts['files-from'] = 3
txt = """
# These options are the only options that rsync might send to the server,
# and only in the option format that the stock rsync produces.
txt = """\
### START of options data produced by the cull_options script. ###
# To disable a short-named option, add its letter to this string:
"""
txt += str_assign('short_disabled', 's') + "\n"
txt += str_assign('short_disabled', 'Ls') + "\n"
txt += str_assign('short_no_arg', ''.join(sorted(short_no_arg)), 'DO NOT REMOVE ANY')
txt += str_assign('short_with_num', ''.join(sorted(short_with_num)), 'DO NOT REMOVE ANY')
@@ -99,24 +99,24 @@ def main():
print(txt, end='')
if args.python:
print("long_opt = {")
print("long_opts = {")
sep = ':'
else:
print("our %long_opt = (")
sep = ' =>'
for opt in sorted(long_opt):
for opt in sorted(long_opts):
if opt.startswith(('min-', 'max-')):
val = 1
else:
val = long_opt[opt]
val = long_opts[opt]
print(' ', repr(opt) + sep, str(val) + ',')
if args.python:
print("}")
else:
print(");")
print('')
print("\n### END of options data produced by the cull_options script. ###")
def str_assign(name, val, comment=None):
@@ -129,10 +129,12 @@ def str_assign(name, val, comment=None):
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Output culled rsync options for rrsync.", add_help=False)
out_group = parser.add_mutually_exclusive_group()
out_group.add_argument('--perl', action='store_true', help="Output perl code (the default).")
out_group.add_argument('--python', action='store_true', help="Output python code.")
out_group.add_argument('--perl', action='store_true', help="Output perl code.")
out_group.add_argument('--python', action='store_true', help="Output python code (the default).")
parser.add_argument('--help', '-h', action='help', help="Output this help message and exit.")
args = parser.parse_args()
if not args.perl:
args.python = True
main()
# vim: sw=4 et

View File

@@ -18,6 +18,8 @@ GetOptions(
'rrsync=s' => \( my $rrsync_dir ),
'ro' => \( my $rrsync_ro = '' ),
'wo' => \( my $rrsync_wo = '' ),
'munge' => \( my $rrsync_munge = '' ),
'no-del' => \( my $rrsync_no_del = '' ),
) or &usage;
&usage unless @ARGV > 1;
@@ -71,16 +73,12 @@ unless ($no_chdir) {
}
if ($rrsync_dir) {
my $cmd = '';
foreach (@ARGV) {
(my $arg = $_) =~ s/(['";|()\[\]{}\$!*?<> \t&~\\])/\\$1/g;
$cmd .= ' ' . $arg;
}
$cmd =~ s/^\s+//;
$ENV{SSH_ORIGINAL_COMMAND} = $cmd;
$ENV{SSH_ORIGINAL_COMMAND} = join(' ', @ARGV);
push @cmd, 'rrsync';
push @cmd, '-ro' if $rrsync_ro;
push @cmd, '-wo' if $rrsync_wo;
push @cmd, '-munge' if $rrsync_munge;
push @cmd, '-no-del' if $rrsync_no_del;
push @cmd, $rrsync_dir;
} else {
push @cmd, '/bin/sh', '-c', "@ARGV";

View File

@@ -1,282 +1,353 @@
#!/usr/bin/env perl
# Name: /usr/local/bin/rrsync (should also have a symlink in /usr/bin)
# Purpose: Restricts rsync to subdirectory declared in .ssh/authorized_keys
# Author: Joe Smith <js-cgi@inwap.com> 30-Sep-2004
# Modified by: Wayne Davison <wayne@opencoder.net>
use strict;
#!/usr/bin/env python3
use Socket;
use Cwd 'abs_path';
use File::Glob ':glob';
# Restricts rsync to subdirectory declared in .ssh/authorized_keys. See
# the rrsync man page for details of how to make use of this script.
# You may configure these values to your liking. See also the section
# of options if you want to disable any options that rsync accepts.
use constant RSYNC => '/usr/bin/rsync';
use constant LOGFILE => 'rrsync.log';
# NOTE: install python3 braceexpand to support brace expansion in the args!
my $Usage = <<EOM;
Use 'command="$0 [-ro|-wo|-no-munge] SUBDIR"'
in front of lines in $ENV{HOME}/.ssh/authorized_keys
EOM
# Originally a perl script by: Joe Smith <js-cgi@inwap.com> 30-Sep-2004
# Python version by: Wayne Davison <wayne@opencoder.net>
# Handle the -ro, -wo, & -no-munge options.
our $only = '';
our $force_munge = 1;
while (@ARGV) {
if ($ARGV[0] =~ /^-([rw])o$/) {
my $r_or_w = $1;
if ($only && $only ne $r_or_w) {
die "$0: the -ro and -wo options conflict.\n";
}
$only = $r_or_w;
} elsif ($ARGV[0] eq '-no-munge') {
$force_munge = 0;
} else {
last;
}
shift;
}
# You may configure these 2 values to your liking. See also the section of
# short & long options if you want to disable any options that rsync accepts.
RSYNC = '/usr/bin/rsync'
LOGFILE = 'rrsync.log' # NOTE: the file must exist for a line to be appended!
our $subdir = shift;
die "$0: No subdirectory specified\n$Usage" unless defined $subdir;
$subdir = abs_path($subdir);
die "$0: Restricted directory does not exist!\n" if $subdir ne '/' && !-d $subdir;
# The following options are mainly the options that a client rsync can send
# to the server, and usually just in the one option format that the stock
# rsync produces. However, there are some additional convenience options
# added as well, and thus a few options are present in both the short and
# long lists (such as --group, --owner, and --perms).
# The client uses "rsync -av -e ssh src/ server:dir/", and sshd on the server
# executes this program when .ssh/authorized_keys has 'command="..."'.
# For example:
# command="rrsync logs/client" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAzGhEeNlPr...
# command="rrsync -ro results" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAmkHG1WCjC...
#
# Format of the environment variables set by sshd:
# SSH_ORIGINAL_COMMAND=rsync --server -vlogDtpr --partial . ARG # push
# SSH_ORIGINAL_COMMAND=rsync --server --sender -vlogDtpr --partial . ARGS # pull
# SSH_CONNECTION=client_addr client_port server_port
my $command = $ENV{SSH_ORIGINAL_COMMAND};
die "$0: Not invoked via sshd\n$Usage" unless defined $command;
die "$0: SSH_ORIGINAL_COMMAND='$command' is not rsync\n" unless $command =~ s/^rsync\s+//;
die "$0: --server option is not first\n" unless $command =~ /^--server\s/;
our $am_sender = $command =~ /^--server\s+--sender\s/; # Restrictive on purpose!
die "$0 sending to read-only server not allowed\n" if $only eq 'r' && !$am_sender;
die "$0 reading from write-only server not allowed\n" if $only eq 'w' && $am_sender;
# NOTE when disabling: check for both a short & long version of the option!
### START of options data produced by the cull_options script. ###
# These options are the only options that rsync might send to the server,
# and only in the option format that the stock rsync produces.
# To disable a short-named option, add its letter to this string:
our $short_disabled = 's';
short_disabled = 'Ls'
our $short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz'; # DO NOT REMOVE ANY
our $short_with_num = '@B'; # DO NOT REMOVE ANY
short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz' # DO NOT REMOVE ANY
short_with_num = '@B' # DO NOT REMOVE ANY
# To disable a long-named option, change its value to a -1. The values mean:
# 0 = the option has no arg; 1 = the arg doesn't need any checking; 2 = only
# check the arg when receiving; and 3 = always check the arg.
our %long_opt = (
'append' => 0,
'backup-dir' => 2,
'block-size' => 1,
'bwlimit' => 1,
'checksum-choice' => 1,
'checksum-seed' => 1,
'compare-dest' => 2,
'compress-choice' => 1,
'compress-level' => 1,
'copy-dest' => 2,
'copy-unsafe-links' => 0,
'daemon' => -1,
'debug' => 1,
'delay-updates' => 0,
'delete' => 0,
'delete-after' => 0,
'delete-before' => 0,
'delete-delay' => 0,
'delete-during' => 0,
'delete-excluded' => 0,
'delete-missing-args' => 0,
'existing' => 0,
'fake-super' => 0,
'files-from' => 3,
'force' => 0,
'from0' => 0,
'fsync' => 2,
'fuzzy' => 0,
'group' => 0,
'groupmap' => 1,
'hard-links' => 0,
'iconv' => 1,
'ignore-errors' => 0,
'ignore-existing' => 0,
'ignore-missing-args' => 0,
'ignore-times' => 0,
'info' => 1,
'inplace' => 0,
'link-dest' => 2,
'links' => 0,
'list-only' => 0,
'log-file' => 3,
'log-format' => 1,
'max-alloc' => 1,
'max-delete' => 1,
'max-size' => 1,
'min-size' => 1,
'mkpath' => 0,
'modify-window' => 1,
'msgs2stderr' => 0,
'munge-links' => 0,
'new-compress' => 0,
'no-W' => 0,
'no-implied-dirs' => 0,
'no-msgs2stderr' => 0,
'no-munge-links' => -1,
'no-r' => 0,
'no-relative' => 0,
'no-specials' => 0,
'numeric-ids' => 0,
'old-compress' => 0,
'one-file-system' => 0,
'only-write-batch' => 1,
'open-noatime' => 0,
'owner' => 0,
'partial' => 0,
'partial-dir' => 2,
'perms' => 0,
'preallocate' => 0,
'recursive' => 0,
'remove-sent-files' => 0,
'remove-source-files' => 0,
'safe-links' => 0,
'sender' => 0,
'server' => 0,
'size-only' => 0,
'skip-compress' => 1,
'specials' => 0,
'stats' => 0,
'suffix' => 1,
'super' => 0,
'temp-dir' => 2,
'timeout' => 1,
'times' => 0,
'use-qsort' => 0,
'usermap' => 1,
'write-devices' => -1,
);
long_opts = {
'append': 0,
'backup-dir': 2,
'block-size': 1,
'bwlimit': 1,
'checksum-choice': 1,
'checksum-seed': 1,
'compare-dest': 2,
'compress-choice': 1,
'compress-level': 1,
'copy-dest': 2,
'copy-unsafe-links': 0,
'daemon': -1,
'debug': 1,
'delay-updates': 0,
'delete': 0,
'delete-after': 0,
'delete-before': 0,
'delete-delay': 0,
'delete-during': 0,
'delete-excluded': 0,
'delete-missing-args': 0,
'existing': 0,
'fake-super': 0,
'files-from': 3,
'force': 0,
'from0': 0,
'fsync': 2,
'fuzzy': 0,
'group': 0,
'groupmap': 1,
'hard-links': 0,
'iconv': 1,
'ignore-errors': 0,
'ignore-existing': 0,
'ignore-missing-args': 0,
'ignore-times': 0,
'info': 1,
'inplace': 0,
'link-dest': 2,
'links': 0,
'list-only': 0,
'log-file': 3,
'log-format': 1,
'max-alloc': 1,
'max-delete': 1,
'max-size': 1,
'min-size': 1,
'mkpath': 0,
'modify-window': 1,
'msgs2stderr': 0,
'munge-links': 0,
'new-compress': 0,
'no-W': 0,
'no-implied-dirs': 0,
'no-msgs2stderr': 0,
'no-munge-links': -1,
'no-r': 0,
'no-relative': 0,
'no-specials': 0,
'numeric-ids': 0,
'old-compress': 0,
'one-file-system': 0,
'only-write-batch': 1,
'open-noatime': 0,
'owner': 0,
'partial': 0,
'partial-dir': 2,
'perms': 0,
'preallocate': 0,
'recursive': 0,
'remove-sent-files': 0,
'remove-source-files': 0,
'safe-links': 0,
'sender': 0,
'server': 0,
'size-only': 0,
'skip-compress': 1,
'specials': 0,
'stats': 0,
'stderr': 1,
'suffix': 1,
'super': 0,
'temp-dir': 2,
'timeout': 1,
'times': 0,
'use-qsort': 0,
'usermap': 1,
'write-devices': -1,
}
### END of options data produced by the cull_options script. ###
if ($only eq 'r') {
foreach my $opt (keys %long_opt) {
if ($opt =~ /^(remove-|log-file)/) {
$long_opt{$opt} = -1;
}
}
} elsif ($only eq 'w') {
$long_opt{'sender'} = -1;
}
import os, sys, re, argparse, glob, socket, time
from argparse import RawTextHelpFormatter
if ($short_disabled ne '') {
$short_no_arg =~ s/[$short_disabled]//go;
$short_with_num =~ s/[$short_disabled]//go;
}
$short_no_arg = "[$short_no_arg]" if length($short_no_arg) > 1;
$short_with_num = "[$short_with_num]" if length($short_with_num) > 1;
try:
from braceexpand import braceexpand
except:
braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ]
my $write_log = -f LOGFILE && open(LOG, '>>', LOGFILE);
HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)')
LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$')
DE_BACKSLASH_RE = re.compile(r'\\(.)')
chdir($subdir) or die "$0: Unable to chdir to restricted dir: $!\n";
def main():
if not os.path.isdir(args.dir):
die("Restricted directory does not exist!")
my(@opts, @args);
my $in_options = 1;
my $last_opt = '';
my $check_type;
while ($command =~ /((?:[^\s\\]+|\\.[^\s\\]*)+)/g) {
$_ = $1;
if ($check_type) {
push(@opts, check_arg($last_opt, $_, $check_type));
$check_type = 0;
} elsif ($in_options) {
if ($_ eq '.') {
$in_options = 0;
} else {
die "$0: invalid option: '-'\n" if $_ eq '-';
push(@opts, $_);
next if /^-$short_no_arg*(e\d*\.\w*)?$/o || /^-$short_with_num\d+$/o;
# The format of the environment variables set by sshd:
# SSH_ORIGINAL_COMMAND:
# rsync --server -vlogDtpre.iLsfxCIvu --etc . ARG # push
# rsync --server --sender -vlogDtpre.iLsfxCIvu --etc . ARGS # pull
# SSH_CONNECTION (client_ip client_port server_ip server_port):
# 192.168.1.100 64106 192.168.1.2 22
my($opt,$arg) = /^--([^=]+)(?:=(.*))?$/;
my $disabled;
if (defined $opt) {
my $ct = $long_opt{$opt};
last unless defined $ct;
next if $ct == 0;
if ($ct > 0) {
if (!defined $arg) {
$check_type = $ct;
$last_opt = $opt;
next;
}
$arg = check_arg($opt, $arg, $ct);
$opts[-1] =~ s/=.*/=$arg/;
next;
}
$disabled = 1;
$opt = "--$opt";
} elsif ($short_disabled ne '') {
$disabled = /^-$short_no_arg*([$short_disabled])/o;
$opt = "-$1";
}
command = os.environ.get('SSH_ORIGINAL_COMMAND', None)
if not command:
die("Not invoked via sshd")
command = command.split(' ', 2)
if command[0:1] != ['rsync']:
die("SSH_ORIGINAL_COMMAND does not run rsync")
if command[1:2] != ['--server']:
die("--server option is not the first arg")
command = '' if len(command) < 3 else command[2]
last unless $disabled; # Generate generic failure
die "$0: option $opt has been disabled on this server.\n";
}
} else {
if ($subdir ne '/') {
# Validate args to ensure they don't try to leave our restricted dir.
s{//+}{/}g;
s{^/}{};
s{^$}{.};
}
push(@args, bsd_glob($_, GLOB_LIMIT|GLOB_NOCHECK|GLOB_BRACE|GLOB_QUOTE));
}
}
die "$0: invalid rsync-command syntax or options\n" if $in_options;
global am_sender
am_sender = command.startswith("--sender ") # Restrictive on purpose!
if args.ro and not am_sender:
die("sending to read-only server is not allowed")
if args.wo and am_sender:
die("reading from write-only server is not allowed")
if ($subdir ne '/') {
die "$0: do not use .. in any path!\n" if grep m{(^|/)\.\.(/|$)}, @args;
}
if args.wo or not am_sender:
long_opts['sender'] = -1
if args.no_del:
for opt in long_opts:
if opt.startswith(('remove', 'delete')):
long_opts[opt] = -1
if args.ro:
long_opts['log-file'] = -1
if ($force_munge) {
push(@opts, '--munge-links');
}
short_no_arg_re = short_no_arg
short_with_num_re = short_with_num
if short_disabled:
for ltr in short_disabled:
short_no_arg_re = short_no_arg_re.replace(ltr, '')
short_with_num_re = short_with_num_re.replace(ltr, '')
short_disabled_re = re.compile(r'^-[%s]*([%s])' % (short_no_arg_re, short_disabled))
short_no_arg_re = re.compile(r'^-(?=.)[%s]*(e\d*\.\w*)?$' % short_no_arg_re)
short_with_num_re = re.compile(r'^-[%s]\d+$' % short_with_num_re)
@args = ( '.' ) if !@args;
log_fh = open(LOGFILE, 'a') if os.path.isfile(LOGFILE) else None
if ($write_log) {
my ($mm,$hh) = (localtime)[1,2];
my $host = $ENV{SSH_CONNECTION} || 'unknown';
$host =~ s/ .*//; # Keep only the client's IP addr
$host =~ s/^::ffff://;
$host = gethostbyaddr(inet_aton($host),AF_INET) || $host;
printf LOG "%02d:%02d %-13s [%s]\n", $hh, $mm, $host, "@opts @args";
close LOG;
}
try:
os.chdir(args.dir)
except OSError as e:
die('unable to chdir to restricted dir:', str(e))
# Note: This assumes that the rsync protocol will not be maliciously hijacked.
exec(RSYNC, @opts, '--', '.', @args) or die "exec(rsync @opts -- . @args) failed: $? $!";
rsync_opts = [ '--server' ]
rsync_args = [ ]
saw_the_dot_arg = False
last_opt = check_type = None
sub check_arg
{
my($opt, $arg, $type) = @_;
$arg =~ s/\\(.)/$1/g;
if ($subdir ne '/' && ($type == 3 || ($type == 2 && !$am_sender))) {
$arg =~ s{//}{/}g;
die "Do not use .. in --$opt; anchor the path at the root of your restricted dir.\n"
if $arg =~ m{(^|/)\.\.(/|$)};
$arg =~ s{^/}{$subdir/};
}
$arg;
}
for arg in re.findall(r'(?:[^\s\\]+|\\.[^\s\\]*)+', command):
if check_type:
rsync_opts.append(validated_arg(last_opt, arg, check_type))
check_type = None
elif saw_the_dot_arg:
# NOTE: an arg that starts with a '-' is safe due to our use of "--" in the cmd tuple.
try:
b_e = braceexpand(arg) # Also removes backslashes
except: # Handle errors such as unbalanced braces by just de-backslashing the arg:
b_e = [ DE_BACKSLASH_RE.sub(r'\1', arg) ]
for xarg in b_e:
rsync_args += validated_arg('arg', xarg, wild=True)
else: # parsing the option args
if arg == '.':
saw_the_dot_arg = True
continue
rsync_opts.append(arg)
if short_no_arg_re.match(arg) or short_with_num_re.match(arg):
continue
disabled = False
m = LONG_OPT_RE.match(arg)
if m:
opt = m.group(1)
opt_arg = m.group(2)
ct = long_opts.get(opt, None)
if ct is None:
break # Generate generic failure due to unfinished arg parsing
if ct == 0:
continue
opt = '--' + opt
if ct > 0:
if opt_arg is not None:
rsync_opts[-1] = opt + '=' + validated_arg(opt, opt_arg, ct)
else:
check_type = ct
last_opt = opt
continue
disabled = True
elif short_disabled:
m = short_disabled_re.match(arg)
if m:
disabled = True
opt = '-' + m.group(1)
# vim: sw=2
if disabled:
die("option", opt, "has been disabled on this server.")
break # Generate a generic failure
if not saw_the_dot_arg:
die("invalid rsync-command syntax or options")
if args.munge:
rsync_opts.append('--munge-links')
if not rsync_args:
rsync_args = [ '.' ]
cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args)
if log_fh:
now = time.localtime()
host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr
if host.startswith('::ffff:'):
host = host[7:]
try:
host = socket.gethostbyaddr(socket.inet_aton(host))
except:
pass
log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd)))
log_fh.close()
# NOTE: This assumes that the rsync protocol will not be maliciously hijacked.
os.execlp(RSYNC, *cmd)
die("execlp(", RSYNC, *cmd, ') failed')
def validated_arg(opt, arg, typ=3, wild=False):
if opt != 'arg': # arg values already have their backslashes removed.
arg = DE_BACKSLASH_RE.sub(r'\1', arg)
orig_arg = arg
if arg.startswith('./'):
arg = arg[1:]
arg = arg.replace('//', '/')
if args.dir != '/':
if HAS_DOT_DOT_RE.search(arg):
die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)")
if arg.startswith('/'):
arg = args.dir + arg
if wild:
got = glob.glob(arg)
if not got:
got = [ arg ]
else:
got = [ arg ]
ret = [ ]
for arg in got:
if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)):
arg_has_trailing_slash = arg.endswith('/')
if arg_has_trailing_slash:
arg = arg[:-1]
else:
arg_has_trailing_slash_dot = arg.endswith('/.')
if arg_has_trailing_slash_dot:
arg = arg[:-2]
real_arg = os.path.realpath(arg)
if arg != real_arg and not real_arg.startswith(args.dir_slash):
die('unsafe arg:', orig_arg, [arg, real_arg])
if arg_has_trailing_slash:
arg += '/'
elif arg_has_trailing_slash_dot:
arg += '/.'
if opt == 'arg' and arg.startswith(args.dir_slash):
arg = arg[args.dir_slash_len:]
if arg == '':
arg = '.'
ret.append(arg)
return ret if wild else ret[0]
def die(*msg):
print(sys.argv[0], 'error:', *msg, file=sys.stderr)
if sys.stdin.isatty():
arg_parser.print_help(sys.stderr)
sys.exit(1)
# This class displays the --help to the user on argparse error IFF they're running it interactively.
class OurArgParser(argparse.ArgumentParser):
def error(self, msg):
die(msg)
if __name__ == '__main__':
our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command."""
arg_parser = OurArgParser(description=our_desc, add_help=False)
only_group = arg_parser.add_mutually_exclusive_group()
only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del.")
only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.")
arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.")
arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
args = arg_parser.parse_args()
args.dir = os.path.realpath(args.dir)
args.dir_slash = args.dir + '/'
args.dir_slash_len = len(args.dir)
if args.ro:
args.no_del = True
main()
# vim: sw=4 et

89
support/rrsync.1.md Normal file
View File

@@ -0,0 +1,89 @@
# NAME
rrsync - a script to setup restricted rsync users via ssh logins
# SYNOPSIS
```
rrsync [-ro|-rw] [-munge] [-no-del] DIR
```
# DESCRIPTION
A user's ssh login can be restricted to only allow the running of an rsync
transfer in one of two easy ways: forcing the running of the rrsync script
or forcing the running of an rsync daemon-over-ssh command.
To use the rrsync script, add a prefix like one of the following (followed by a
space) in front of each ssh-key line in the user's `~/.ssh/authorized_keys`
file that should be restricted:
> ```
> command="rrsync DIR"
> command="rrsync -ro DIR"
> command="rrsync -munge -no-del DIR"
> ```
Then, ensure that the rrsync script has your desired option restrictions. You
may want to copy the script to a local bin dir with a unique name if you want
to have multiple configurations. One or more rrsync options can be specified
prior to the `DIR` if you want to further restrict the transfer.
To use an rsync daemon setup, add one of the following prefixes (followed by a
space) in front of each ssh-key line in the user's `~/.ssh/authorized_keys`
file that should be restricted:
> ```
> command="rsync --server --daemon ."
> command="rsync --server --daemon --config=/PATH/TO/rsyncd.conf ."
> ```
Then, ensure that the rsyncd.conf file is created with one or more module names
with the appropriate path and option restrictions. If the `--config` option is
omitted, it defaults to `~/rsyncd.conf`. See the `rsyncd.conf` man page for
details of how to configure an rsync daemon.
The remainder of this man page is dedicated to using the rrsync script.
# OPTION SUMMARY
```
-ro Allow only reading from the DIR. Implies -no-del.
-wo Allow only writing to the DIR.
-no-del Disable rsync's --delete* and --remove* options.
-munge Enable rsync's --munge-links on the server side.
-help, -h Output this help message and exit.
```
A single non-option argument specifies the restricted DIR to use. It can be
relative to the user's home directory or an absolute path.
# SECURITY RESTRICTIONS
The rrsync script validates the path arguments it is sent to try to restrict
them to staying within the specified DIR.
The rrsync script rejects rsync's `--copy-links`` option (by default) so that a
copy cannot dereference a symlink within the DIR to get to a file outside the
DIR.
The rrsync script rejects rsync's `--protect-args` (`-s`) option because it
would allow options to be sent to the server-side that the script could not
check. If you want to support `--protect-args`, use a daemon-over-ssh setup.
The rrsync script accepts just a subset of rsync's options that the real rsync
uses when running the server command. A few extra convenience options are also
included to help it to interact with BackupPC and accept some convenient user
overrides.
The script (or a copy of it) can be manually edited if you want it to customize
the option handling.
# EXAMPLES
The `.ssh/authorized_keys` file might have lines in it like this:
> ```
> command="rrsync client/logs" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAzG...
> command="rrsync -ro results" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAmk...
> ```