rrsync improvements
authorWayne Davison <wayne@opencoder.net>
Sun, 26 Dec 2021 20:29:00 +0000 (12:29 -0800)
committerWayne Davison <wayne@opencoder.net>
Sun, 26 Dec 2021 20:29:00 +0000 (12:29 -0800)
- 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.

Makefile.in
NEWS.md
configure.ac
maybe-make-man
md2man
packaging/cull_options
support/lsh
support/rrsync
support/rrsync.1.md [new file with mode: 0644]

index 3c8c22405b7b5a11781df6427108a50e0743d6e6..5eed339e36ba0a0902703fe43ab9ace2a207c2d9 100644 (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) \
diff --git a/NEWS.md b/NEWS.md
index eaa82b3959cffa9a8baa221fe18ce1b6ae09c0f3..b3002e893b6b52c57924d755690bfe07620c710b 100644 (file)
--- a/NEWS.md
+++ b/NEWS.md
 
  - 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.
 
 
 ### 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
index 9e7338cfce2516bcc24d04be8d49255f8094a4f1..84111de8b0326ac9011fe873f7114835c487be8c 100644 (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]))
 
index b7f0a9f1a4814a210a32cbfcbd3f5ec5a605cc3f..59f2dce42a28ad5caa615b3ab5405264d9f650e7 100755 (executable)
@@ -37,4 +37,4 @@ if [ ! -f "$flagfile" ]; then
     fi
 fi
 
-"$srcdir/md2man" "$srcdir/$inname"
+"$srcdir/md2man" -s "$srcdir" "$srcdir/$inname"
diff --git a/md2man b/md2man
index fa1d2e82479ca4b1f32d5cfb0ae59619e61de0f6..fd546f19060740afb28418e9180e371a9f5e0846 100755 (executable)
--- a/md2man
+++ b/md2man
@@ -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.")
index 85311c7c7f90ecd83b2e144e44c98246a0b4dea6..d4e1c626d2f43baca211d2eb9498b8d147f11d95 100755 (executable)
@@ -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
index ebfe898c4a1fca30db799cbaa0b0b509cb704874..40fe3d73850d08f8df4f52dd6aab4f17ff93aad2 100755 (executable)
@@ -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";
index 4c5dd2aa7d83e59e22a9a1f9ac33fea322499794..5b43a819855e2bf81738e88c328a047db6aacf13 100755 (executable)
-#!/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;
-
-use Socket;
-use Cwd 'abs_path';
-use File::Glob ':glob';
-
-# 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';
-
-my $Usage = <<EOM;
-Use 'command="$0 [-ro|-wo|-no-munge] SUBDIR"'
-        in front of lines in $ENV{HOME}/.ssh/authorized_keys
-EOM
-
-# 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;
-}
+#!/usr/bin/env python3
 
-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 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;
+# Restricts rsync to subdirectory declared in .ssh/authorized_keys.  See
+# the rrsync man page for details of how to make use of this script.
 
-### START of options data produced by the cull_options script. ###
+# NOTE: install python3 braceexpand to support brace expansion in the args!
+
+# Originally a perl script by: Joe Smith <js-cgi@inwap.com> 30-Sep-2004
+# Python version by: Wayne Davison <wayne@opencoder.net>
+
+# 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!
+
+# 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).
 
-# These options are the only options that rsync might send to the server,
-# and only in the option format that the stock rsync produces.
+# NOTE when disabling: check for both a short & long version of the option!
+
+### START of options data produced by the cull_options script. ###
 
 # 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;
-
-my $write_log = -f LOGFILE && open(LOG, '>>', LOGFILE);
-
-chdir($subdir) or die "$0: Unable to chdir to restricted dir: $!\n";
-
-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;
-
-      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";
-      }
-
-      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;
+try:
+    from braceexpand import braceexpand
+except:
+    braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ]
 
-if ($subdir ne '/') {
-  die "$0: do not use .. in any path!\n" if grep m{(^|/)\.\.(/|$)}, @args;
-}
+HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)')
+LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$')
+DE_BACKSLASH_RE = re.compile(r'\\(.)')
 
-if ($force_munge) {
-  push(@opts, '--munge-links');
-}
+def main():
+    if not os.path.isdir(args.dir):
+        die("Restricted directory does not exist!")
 
-@args = ( '.' ) if !@args;
+    # 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
 
-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;
-}
+    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]
 
-# Note: This assumes that the rsync protocol will not be maliciously hijacked.
-exec(RSYNC, @opts, '--', '.', @args) or die "exec(rsync @opts -- . @args) failed: $? $!";
-
-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;
-}
+    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 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
+
+    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)
+
+    log_fh = open(LOGFILE, 'a') if os.path.isfile(LOGFILE) else None
+
+    try:
+        os.chdir(args.dir)
+    except OSError as e:
+        die('unable to chdir to restricted dir:', str(e))
+
+    rsync_opts = [ '--server' ]
+    rsync_args = [ ]
+    saw_the_dot_arg = False
+    last_opt = check_type = None
+
+    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)
+
+            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=2
+# vim: sw=4 et
diff --git a/support/rrsync.1.md b/support/rrsync.1.md
new file mode 100644 (file)
index 0000000..b945ecf
--- /dev/null
@@ -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...
+> ```