e8b0cc0d2254efc22e3a4e931ede38398fea7ad7
[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     if command == 'true':
160         # Allow checking connectivity with "ssh <host> true".  (For example,
161         # rsbackup uses this.)
162         sys.exit(0)
163     command = command.split(' ', 2)
164     if command[0:1] != ['rsync']:
165         die("SSH_ORIGINAL_COMMAND does not run rsync")
166     if command[1:2] != ['--server']:
167         die("--server option is not the first arg")
168     command = '' if len(command) < 3 else command[2]
169
170     global am_sender
171     am_sender = command.startswith("--sender ") # Restrictive on purpose!
172     if args.ro and not am_sender:
173         die("sending to read-only server is not allowed")
174     if args.wo and am_sender:
175         die("reading from write-only server is not allowed")
176
177     if args.wo or not am_sender:
178         long_opts['sender'] = -1
179     if args.no_del:
180         for opt in long_opts:
181             if opt.startswith(('remove', 'delete')):
182                 long_opts[opt] = -1
183     if args.ro:
184         long_opts['log-file'] = -1
185
186     if args.dir != '/':
187         global short_disabled
188         short_disabled += short_disabled_subdir
189
190     short_no_arg_re = short_no_arg
191     short_with_num_re = short_with_num
192     if short_disabled:
193         for ltr in short_disabled:
194             short_no_arg_re = short_no_arg_re.replace(ltr, '')
195             short_with_num_re = short_with_num_re.replace(ltr, '')
196         short_disabled_re = re.compile(r'^-[%s]*([%s])' % (short_no_arg_re, short_disabled))
197     short_no_arg_re = re.compile(r'^-(?=.)[%s]*(e\d*\.\w*)?$' % short_no_arg_re)
198     short_with_num_re = re.compile(r'^-[%s]\d+$' % short_with_num_re)
199
200     log_fh = open(LOGFILE, 'a') if os.path.isfile(LOGFILE) else None
201
202     try:
203         os.chdir(args.dir)
204     except OSError as e:
205         die('unable to chdir to restricted dir:', str(e))
206
207     rsync_opts = [ '--server' ]
208     rsync_args = [ ]
209     saw_the_dot_arg = False
210     last_opt = check_type = None
211
212     for arg in re.findall(r'(?:[^\s\\]+|\\.[^\s\\]*)+', command):
213         if check_type:
214             rsync_opts.append(validated_arg(last_opt, arg, check_type))
215             check_type = None
216         elif saw_the_dot_arg:
217             # NOTE: an arg that starts with a '-' is safe due to our use of "--" in the cmd tuple.
218             try:
219                 b_e = braceexpand(arg) # Also removes backslashes
220             except: # Handle errors such as unbalanced braces by just de-backslashing the arg:
221                 b_e = [ DE_BACKSLASH_RE.sub(r'\1', arg) ]
222             for xarg in b_e:
223                 rsync_args += validated_arg('arg', xarg, wild=True)
224         else: # parsing the option args
225             if arg == '.':
226                 saw_the_dot_arg = True
227                 continue
228             rsync_opts.append(arg)
229             if short_no_arg_re.match(arg) or short_with_num_re.match(arg):
230                 continue
231             disabled = False
232             m = LONG_OPT_RE.match(arg)
233             if m:
234                 opt = m.group(1)
235                 opt_arg = m.group(2)
236                 ct = long_opts.get(opt, None)
237                 if ct is None:
238                     break # Generate generic failure due to unfinished arg parsing
239                 if ct == 0:
240                     continue
241                 opt = '--' + opt
242                 if ct > 0:
243                     if opt_arg is not None:
244                         rsync_opts[-1] = opt + '=' + validated_arg(opt, opt_arg, ct)
245                     else:
246                         check_type = ct
247                         last_opt = opt
248                     continue
249                 disabled = True
250             elif short_disabled:
251                 m = short_disabled_re.match(arg)
252                 if m:
253                     disabled = True
254                     opt = '-' + m.group(1)
255
256             if disabled:
257                 die("option", opt, "has been disabled on this server.")
258             break # Generate a generic failure
259
260     if not saw_the_dot_arg:
261         die("invalid rsync-command syntax or options")
262
263     if args.munge:
264         rsync_opts.append('--munge-links')
265     
266     if args.no_overwrite:
267       rsync_opts.append('--ignore-existing')
268
269     if not rsync_args:
270         rsync_args = [ '.' ]
271
272     cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args)
273
274     if log_fh:
275         now = time.localtime()
276         host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr
277         if host.startswith('::ffff:'):
278             host = host[7:]
279         try:
280             host = socket.gethostbyaddr(socket.inet_aton(host))
281         except:
282             pass
283         log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd)))
284         log_fh.close()
285
286     # NOTE: This assumes that the rsync protocol will not be maliciously hijacked.
287     if args.no_lock:
288         os.execlp(RSYNC, *cmd)
289         die("execlp(", RSYNC, *cmd, ')  failed')
290     child = subprocess.run(cmd)
291     if child.returncode != 0:
292         sys.exit(child.returncode)
293
294
295 def validated_arg(opt, arg, typ=3, wild=False):
296     if opt != 'arg': # arg values already have their backslashes removed.
297         arg = DE_BACKSLASH_RE.sub(r'\1', arg)
298
299     orig_arg = arg
300     if arg.startswith('./'):
301         arg = arg[1:]
302     arg = arg.replace('//', '/')
303     if args.dir != '/':
304         if HAS_DOT_DOT_RE.search(arg):
305             die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)")
306         if arg.startswith('/'):
307             arg = args.dir + arg
308
309     if wild:
310         got = glob.glob(arg)
311         if not got:
312             got = [ arg ]
313     else:
314         got = [ arg ]
315
316     ret = [ ]
317     for arg in got:
318         if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)):
319             arg_has_trailing_slash = arg.endswith('/')
320             if arg_has_trailing_slash:
321                 arg = arg[:-1]
322             else:
323                 arg_has_trailing_slash_dot = arg.endswith('/.')
324                 if arg_has_trailing_slash_dot:
325                     arg = arg[:-2]
326             real_arg = os.path.realpath(arg)
327             if arg != real_arg and not real_arg.startswith(args.dir_slash):
328                 die('unsafe arg:', orig_arg, [arg, real_arg])
329             if arg_has_trailing_slash:
330                 arg += '/'
331             elif arg_has_trailing_slash_dot:
332                 arg += '/.'
333             if opt == 'arg' and arg.startswith(args.dir_slash):
334                 arg = arg[args.dir_slash_len:]
335                 if arg == '':
336                     arg = '.'
337         ret.append(arg)
338
339     return ret if wild else ret[0]
340
341
342 def lock_or_die(dirname):
343     import fcntl
344     global lock_handle
345     lock_handle = os.open(dirname, os.O_RDONLY)
346     try:
347         fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
348     except:
349         die('Another instance of rrsync is already accessing this directory.')
350
351
352 def die(*msg):
353     print(sys.argv[0], 'error:', *msg, file=sys.stderr)
354     if sys.stdin.isatty():
355         arg_parser.print_help(sys.stderr)
356     sys.exit(1)
357
358
359 # This class displays the --help to the user on argparse error IFF they're running it interactively.
360 class OurArgParser(argparse.ArgumentParser):
361     def error(self, msg):
362         die(msg)
363
364
365 if __name__ == '__main__':
366     our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command."""
367     arg_parser = OurArgParser(description=our_desc, add_help=False)
368     only_group = arg_parser.add_mutually_exclusive_group()
369     only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del and -no-lock.")
370     only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.")
371     arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.")
372     arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
373     arg_parser.add_argument('-no-lock', action='store_true', help="Avoid the single-run (per-user) lock check.")
374     arg_parser.add_argument('-no-overwrite', action='store_true', help="Prevent overwriting existing files by enforcing --ignore-existing")
375     arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
376     arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
377     args = arg_parser.parse_args()
378     args.dir = os.path.realpath(args.dir)
379     args.dir_slash = args.dir + '/'
380     args.dir_slash_len = len(args.dir_slash)
381     if args.ro:
382         args.no_del = True
383     elif not args.no_lock:
384         lock_or_die(args.dir)
385     main()
386
387 # vim: sw=4 et