Some "use chroot" improvements.
[rsync.git] / support / rrsync
1 #!/usr/bin/env python3
2
3 # Restricts rsync to subdirectory declared in .ssh/authorized_keys.  See
4 # the rrsync man page for details of how to make use of this script.
5
6 # NOTE: install python3 braceexpand to support brace expansion in the args!
7
8 # Originally a perl script by: Joe Smith <js-cgi@inwap.com> 30-Sep-2004
9 # Python version by: Wayne Davison <wayne@opencoder.net>
10
11 # You may configure these 2 values to your liking.  See also the section of
12 # short & long options if you want to disable any options that rsync accepts.
13 RSYNC = '/usr/bin/rsync'
14 LOGFILE = 'rrsync.log' # NOTE: the file must exist for a line to be appended!
15
16 # The following options are mainly the options that a client rsync can send
17 # to the server, and usually just in the one option format that the stock
18 # rsync produces. However, there are some additional convenience options
19 # added as well, and thus a few options are present in both the short and
20 # long lists (such as --group, --owner, and --perms).
21
22 # NOTE when disabling: check for both a short & long version of the option!
23
24 ### START of options data produced by the cull-options script. ###
25
26 # To disable a short-named option, add its letter to this string:
27 short_disabled = 's'
28
29 # These are also disabled when the restricted dir is not "/":
30 short_disabled_subdir = 'KLk'
31
32 # These are all possible short options that we will accept (when not disabled above):
33 short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz' # DO NOT REMOVE ANY
34 short_with_num = '@B' # DO NOT REMOVE ANY
35
36 # To disable a long-named option, change its value to a -1.  The values mean:
37 # 0 = the option has no arg; 1 = the arg doesn't need any checking; 2 = only
38 # check the arg when receiving; and 3 = always check the arg.
39 long_opts = {
40   'append': 0,
41   'backup-dir': 2,
42   'block-size': 1,
43   'bwlimit': 1,
44   'checksum-choice': 1,
45   'checksum-seed': 1,
46   'compare-dest': 2,
47   'compress-choice': 1,
48   'compress-level': 1,
49   'copy-dest': 2,
50   'copy-devices': -1,
51   'copy-unsafe-links': 0,
52   'daemon': -1,
53   'debug': 1,
54   'delay-updates': 0,
55   'delete': 0,
56   'delete-after': 0,
57   'delete-before': 0,
58   'delete-delay': 0,
59   'delete-during': 0,
60   'delete-excluded': 0,
61   'delete-missing-args': 0,
62   'existing': 0,
63   'fake-super': 0,
64   'files-from': 3,
65   'force': 0,
66   'from0': 0,
67   'fsync': 0,
68   'fuzzy': 0,
69   'group': 0,
70   'groupmap': 1,
71   'hard-links': 0,
72   'iconv': 1,
73   'ignore-errors': 0,
74   'ignore-existing': 0,
75   'ignore-missing-args': 0,
76   'ignore-times': 0,
77   'info': 1,
78   'inplace': 0,
79   'link-dest': 2,
80   'links': 0,
81   'list-only': 0,
82   'log-file': 3,
83   'log-format': 1,
84   'max-alloc': 1,
85   'max-delete': 1,
86   'max-size': 1,
87   'min-size': 1,
88   'mkpath': 0,
89   'modify-window': 1,
90   'msgs2stderr': 0,
91   'munge-links': 0,
92   'new-compress': 0,
93   'no-W': 0,
94   'no-implied-dirs': 0,
95   'no-msgs2stderr': 0,
96   'no-munge-links': -1,
97   'no-r': 0,
98   'no-relative': 0,
99   'no-specials': 0,
100   'numeric-ids': 0,
101   'old-compress': 0,
102   'one-file-system': 0,
103   'only-write-batch': 1,
104   'open-noatime': 0,
105   'owner': 0,
106   'partial': 0,
107   'partial-dir': 2,
108   'perms': 0,
109   'preallocate': 0,
110   'recursive': 0,
111   'remove-sent-files': 0,
112   'remove-source-files': 0,
113   'safe-links': 0,
114   'sender': 0,
115   'server': 0,
116   'size-only': 0,
117   'skip-compress': 1,
118   'specials': 0,
119   'stats': 0,
120   'stderr': 1,
121   'suffix': 1,
122   'super': 0,
123   'temp-dir': 2,
124   'timeout': 1,
125   'times': 0,
126   'use-qsort': 0,
127   'usermap': 1,
128   'write-devices': -1,
129 }
130
131 ### END of options data produced by the cull-options script. ###
132
133 import os, sys, re, argparse, glob, socket, time, subprocess
134 from argparse import RawTextHelpFormatter
135
136 try:
137     from braceexpand import braceexpand
138 except:
139     braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ]
140
141 HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)')
142 LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$')
143 DE_BACKSLASH_RE = re.compile(r'\\(.)')
144
145 def main():
146     if not os.path.isdir(args.dir):
147         die("Restricted directory does not exist!")
148
149     # The format of the environment variables set by sshd:
150     #   SSH_ORIGINAL_COMMAND:
151     #     rsync --server          -vlogDtpre.iLsfxCIvu --etc . ARG  # push
152     #     rsync --server --sender -vlogDtpre.iLsfxCIvu --etc . ARGS # pull
153     #   SSH_CONNECTION (client_ip client_port server_ip server_port):
154     #     192.168.1.100 64106 192.168.1.2 22
155
156     command = os.environ.get('SSH_ORIGINAL_COMMAND', None)
157     if not command:
158         die("Not invoked via sshd")
159     command = command.split(' ', 2)
160     if command[0:1] != ['rsync']:
161         die("SSH_ORIGINAL_COMMAND does not run rsync")
162     if command[1:2] != ['--server']:
163         die("--server option is not the first arg")
164     command = '' if len(command) < 3 else command[2]
165
166     global am_sender
167     am_sender = command.startswith("--sender ") # Restrictive on purpose!
168     if args.ro and not am_sender:
169         die("sending to read-only server is not allowed")
170     if args.wo and am_sender:
171         die("reading from write-only server is not allowed")
172
173     if args.wo or not am_sender:
174         long_opts['sender'] = -1
175     if args.no_del:
176         for opt in long_opts:
177             if opt.startswith(('remove', 'delete')):
178                 long_opts[opt] = -1
179     if args.ro:
180         long_opts['log-file'] = -1
181
182     if args.dir != '/':
183         global short_disabled
184         short_disabled += short_disabled_subdir
185
186     short_no_arg_re = short_no_arg
187     short_with_num_re = short_with_num
188     if short_disabled:
189         for ltr in short_disabled:
190             short_no_arg_re = short_no_arg_re.replace(ltr, '')
191             short_with_num_re = short_with_num_re.replace(ltr, '')
192         short_disabled_re = re.compile(r'^-[%s]*([%s])' % (short_no_arg_re, short_disabled))
193     short_no_arg_re = re.compile(r'^-(?=.)[%s]*(e\d*\.\w*)?$' % short_no_arg_re)
194     short_with_num_re = re.compile(r'^-[%s]\d+$' % short_with_num_re)
195
196     log_fh = open(LOGFILE, 'a') if os.path.isfile(LOGFILE) else None
197
198     try:
199         os.chdir(args.dir)
200     except OSError as e:
201         die('unable to chdir to restricted dir:', str(e))
202
203     rsync_opts = [ '--server' ]
204     rsync_args = [ ]
205     saw_the_dot_arg = False
206     last_opt = check_type = None
207
208     for arg in re.findall(r'(?:[^\s\\]+|\\.[^\s\\]*)+', command):
209         if check_type:
210             rsync_opts.append(validated_arg(last_opt, arg, check_type))
211             check_type = None
212         elif saw_the_dot_arg:
213             # NOTE: an arg that starts with a '-' is safe due to our use of "--" in the cmd tuple.
214             try:
215                 b_e = braceexpand(arg) # Also removes backslashes
216             except: # Handle errors such as unbalanced braces by just de-backslashing the arg:
217                 b_e = [ DE_BACKSLASH_RE.sub(r'\1', arg) ]
218             for xarg in b_e:
219                 rsync_args += validated_arg('arg', xarg, wild=True)
220         else: # parsing the option args
221             if arg == '.':
222                 saw_the_dot_arg = True
223                 continue
224             rsync_opts.append(arg)
225             if short_no_arg_re.match(arg) or short_with_num_re.match(arg):
226                 continue
227             disabled = False
228             m = LONG_OPT_RE.match(arg)
229             if m:
230                 opt = m.group(1)
231                 opt_arg = m.group(2)
232                 ct = long_opts.get(opt, None)
233                 if ct is None:
234                     break # Generate generic failure due to unfinished arg parsing
235                 if ct == 0:
236                     continue
237                 opt = '--' + opt
238                 if ct > 0:
239                     if opt_arg is not None:
240                         rsync_opts[-1] = opt + '=' + validated_arg(opt, opt_arg, ct)
241                     else:
242                         check_type = ct
243                         last_opt = opt
244                     continue
245                 disabled = True
246             elif short_disabled:
247                 m = short_disabled_re.match(arg)
248                 if m:
249                     disabled = True
250                     opt = '-' + m.group(1)
251
252             if disabled:
253                 die("option", opt, "has been disabled on this server.")
254             break # Generate a generic failure
255
256     if not saw_the_dot_arg:
257         die("invalid rsync-command syntax or options")
258
259     if args.munge:
260         rsync_opts.append('--munge-links')
261
262     if not rsync_args:
263         rsync_args = [ '.' ]
264
265     cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args)
266
267     if log_fh:
268         now = time.localtime()
269         host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr
270         if host.startswith('::ffff:'):
271             host = host[7:]
272         try:
273             host = socket.gethostbyaddr(socket.inet_aton(host))
274         except:
275             pass
276         log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd)))
277         log_fh.close()
278
279     # NOTE: This assumes that the rsync protocol will not be maliciously hijacked.
280     if args.no_lock:
281         os.execlp(RSYNC, *cmd)
282         die("execlp(", RSYNC, *cmd, ')  failed')
283     child = subprocess.run(cmd)
284     if child.returncode != 0:
285         sys.exit(child.returncode)
286
287
288 def validated_arg(opt, arg, typ=3, wild=False):
289     if opt != 'arg': # arg values already have their backslashes removed.
290         arg = DE_BACKSLASH_RE.sub(r'\1', arg)
291
292     orig_arg = arg
293     if arg.startswith('./'):
294         arg = arg[1:]
295     arg = arg.replace('//', '/')
296     if args.dir != '/':
297         if HAS_DOT_DOT_RE.search(arg):
298             die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)")
299         if arg.startswith('/'):
300             arg = args.dir + arg
301
302     if wild:
303         got = glob.glob(arg)
304         if not got:
305             got = [ arg ]
306     else:
307         got = [ arg ]
308
309     ret = [ ]
310     for arg in got:
311         if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)):
312             arg_has_trailing_slash = arg.endswith('/')
313             if arg_has_trailing_slash:
314                 arg = arg[:-1]
315             else:
316                 arg_has_trailing_slash_dot = arg.endswith('/.')
317                 if arg_has_trailing_slash_dot:
318                     arg = arg[:-2]
319             real_arg = os.path.realpath(arg)
320             if arg != real_arg and not real_arg.startswith(args.dir_slash):
321                 die('unsafe arg:', orig_arg, [arg, real_arg])
322             if arg_has_trailing_slash:
323                 arg += '/'
324             elif arg_has_trailing_slash_dot:
325                 arg += '/.'
326             if opt == 'arg' and arg.startswith(args.dir_slash):
327                 arg = arg[args.dir_slash_len:]
328                 if arg == '':
329                     arg = '.'
330         ret.append(arg)
331
332     return ret if wild else ret[0]
333
334
335 def lock_or_die(dirname):
336     import fcntl
337     global lock_handle
338     lock_handle = os.open(dirname, os.O_RDONLY)
339     try:
340         fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
341     except:
342         die('Another instance of rrsync is already accessing this directory.')
343
344
345 def die(*msg):
346     print(sys.argv[0], 'error:', *msg, file=sys.stderr)
347     if sys.stdin.isatty():
348         arg_parser.print_help(sys.stderr)
349     sys.exit(1)
350
351
352 # This class displays the --help to the user on argparse error IFF they're running it interactively.
353 class OurArgParser(argparse.ArgumentParser):
354     def error(self, msg):
355         die(msg)
356
357
358 if __name__ == '__main__':
359     our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command."""
360     arg_parser = OurArgParser(description=our_desc, add_help=False)
361     only_group = arg_parser.add_mutually_exclusive_group()
362     only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del and -no-lock.")
363     only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.")
364     arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.")
365     arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
366     arg_parser.add_argument('-no-lock', action='store_true', help="Avoid the single-run (per-user) lock check.")
367     arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
368     arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
369     args = arg_parser.parse_args()
370     args.dir = os.path.realpath(args.dir)
371     args.dir_slash = args.dir + '/'
372     args.dir_slash_len = len(args.dir_slash)
373     if args.ro:
374         args.no_del = True
375     elif not args.no_lock:
376         lock_or_die(args.dir)
377     main()
378
379 # vim: sw=4 et