manpage: clarify remote-shell daemon user@ handling
[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   'compress-threads': 1,
50   'copy-dest': 2,
51   'copy-devices': -1,
52   'copy-unsafe-links': 0,
53   'daemon': -1,
54   'debug': 1,
55   'delay-updates': 0,
56   'delete': 0,
57   'delete-after': 0,
58   'delete-before': 0,
59   'delete-delay': 0,
60   'delete-during': 0,
61   'delete-excluded': 0,
62   'delete-missing-args': 0,
63   'dirs': 0,
64   'existing': 0,
65   'fake-super': 0,
66   'files-from': 3,
67   'force': 0,
68   'from0': 0,
69   'fsync': 0,
70   'fuzzy': 0,
71   'group': 0,
72   'groupmap': 1,
73   'hard-links': 0,
74   'iconv': 1,
75   'ignore-errors': 0,
76   'ignore-existing': 0,
77   'ignore-missing-args': 0,
78   'ignore-times': 0,
79   'info': 1,
80   'inplace': 0,
81   'link-dest': 2,
82   'links': 0,
83   'list-only': 0,
84   'log-file': 3,
85   'log-format': 1,
86   'max-alloc': 1,
87   'max-delete': 1,
88   'max-size': 1,
89   'min-size': 1,
90   'mkpath': 0,
91   'modify-window': 1,
92   'msgs2stderr': 0,
93   'munge-links': 0,
94   'new-compress': 0,
95   'no-W': 0,
96   'no-implied-dirs': 0,
97   'no-msgs2stderr': 0,
98   'no-munge-links': -1,
99   'no-r': 0,
100   'no-relative': 0,
101   'no-specials': 0,
102   'numeric-ids': 0,
103   'old-compress': 0,
104   'one-file-system': 0,
105   'only-write-batch': 1,
106   'open-noatime': 0,
107   'owner': 0,
108   'partial': 0,
109   'partial-dir': 2,
110   'perms': 0,
111   'preallocate': 0,
112   'recursive': 0,
113   'remove-sent-files': 0,
114   'remove-source-files': 0,
115   'safe-links': 0,
116   'sender': 0,
117   'server': 0,
118   'size-only': 0,
119   'skip-compress': 1,
120   'specials': 0,
121   'stats': 0,
122   'stderr': 1,
123   'suffix': 1,
124   'super': 0,
125   'temp-dir': 2,
126   'timeout': 1,
127   'times': 0,
128   'use-qsort': 0,
129   'usermap': 1,
130   'write-devices': -1,
131 }
132
133 ### END of options data produced by the cull-options script. ###
134
135 import os, sys, re, argparse, glob, socket, time, subprocess
136 from argparse import RawTextHelpFormatter
137
138 try:
139     from braceexpand import braceexpand
140 except:
141     braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ]
142
143 HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)')
144 LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$')
145 DE_BACKSLASH_RE = re.compile(r'\\(.)')
146
147 def main():
148     if not os.path.isdir(args.dir):
149         die("Restricted directory does not exist!")
150
151     # The format of the environment variables set by sshd:
152     #   SSH_ORIGINAL_COMMAND:
153     #     rsync --server          -vlogDtpre.iLsfxCIvu --etc . ARG  # push
154     #     rsync --server --sender -vlogDtpre.iLsfxCIvu --etc . ARGS # pull
155     #   SSH_CONNECTION (client_ip client_port server_ip server_port):
156     #     192.168.1.100 64106 192.168.1.2 22
157
158     command = os.environ.get('SSH_ORIGINAL_COMMAND', None)
159     if not command:
160         die("Not invoked via sshd")
161     if command == 'true':
162         # Allow checking connectivity with "ssh <host> true".  (For example,
163         # rsbackup uses this.)
164         sys.exit(0)
165     command = command.split(' ', 2)
166     if command[0:1] != ['rsync']:
167         die("SSH_ORIGINAL_COMMAND does not run rsync")
168     if command[1:2] != ['--server']:
169         die("--server option is not the first arg")
170     command = '' if len(command) < 3 else command[2]
171
172     global am_sender
173     am_sender = command.startswith("--sender ") # Restrictive on purpose!
174     if args.ro and not am_sender:
175         die("sending to read-only server is not allowed")
176     if args.wo and am_sender:
177         die("reading from write-only server is not allowed")
178
179     if args.wo or not am_sender:
180         long_opts['sender'] = -1
181     if args.no_del:
182         for opt in long_opts:
183             if opt.startswith(('remove', 'delete')):
184                 long_opts[opt] = -1
185     if args.ro:
186         long_opts['log-file'] = -1
187
188     if args.dir != '/':
189         global short_disabled
190         short_disabled += short_disabled_subdir
191
192     short_no_arg_re = short_no_arg
193     short_with_num_re = short_with_num
194     if short_disabled:
195         for ltr in short_disabled:
196             short_no_arg_re = short_no_arg_re.replace(ltr, '')
197             short_with_num_re = short_with_num_re.replace(ltr, '')
198         short_disabled_re = re.compile(r'^-[%s]*([%s])' % (short_no_arg_re, short_disabled))
199     short_no_arg_re = re.compile(r'^-(?=.)[%s]*(e\d*\.\w*)?$' % short_no_arg_re)
200     short_with_num_re = re.compile(r'^-[%s]\d+$' % short_with_num_re)
201
202     log_fh = open(LOGFILE, 'a') if os.path.isfile(LOGFILE) else None
203
204     try:
205         os.chdir(args.dir)
206     except OSError as e:
207         die('unable to chdir to restricted dir:', str(e))
208
209     rsync_opts = [ '--server' ]
210     rsync_args = [ ]
211     saw_the_dot_arg = False
212     last_opt = check_type = None
213
214     for arg in re.findall(r'(?:[^\s\\]+|\\.[^\s\\]*)+', command):
215         if check_type:
216             rsync_opts.append(validated_arg(last_opt, arg, check_type))
217             check_type = None
218         elif saw_the_dot_arg:
219             # NOTE: an arg that starts with a '-' is safe due to our use of "--" in the cmd tuple.
220             try:
221                 b_e = braceexpand(arg) # Also removes backslashes
222             except: # Handle errors such as unbalanced braces by just de-backslashing the arg:
223                 b_e = [ DE_BACKSLASH_RE.sub(r'\1', arg) ]
224             for xarg in b_e:
225                 rsync_args += validated_arg('arg', xarg, wild=True)
226         else: # parsing the option args
227             if arg == '.':
228                 saw_the_dot_arg = True
229                 continue
230             rsync_opts.append(arg)
231             if short_no_arg_re.match(arg) or short_with_num_re.match(arg):
232                 continue
233             disabled = False
234             m = LONG_OPT_RE.match(arg)
235             if m:
236                 opt = m.group(1)
237                 opt_arg = m.group(2)
238                 ct = long_opts.get(opt, None)
239                 if ct is None:
240                     break # Generate generic failure due to unfinished arg parsing
241                 if ct == 0:
242                     continue
243                 opt = '--' + opt
244                 if ct > 0:
245                     if opt_arg is not None:
246                         rsync_opts[-1] = opt + '=' + validated_arg(opt, opt_arg, ct)
247                     else:
248                         check_type = ct
249                         last_opt = opt
250                     continue
251                 disabled = True
252             elif short_disabled:
253                 m = short_disabled_re.match(arg)
254                 if m:
255                     disabled = True
256                     opt = '-' + m.group(1)
257
258             if disabled:
259                 die("option", opt, "has been disabled on this server.")
260             break # Generate a generic failure
261
262     if not saw_the_dot_arg:
263         die("invalid rsync-command syntax or options")
264
265     if args.munge:
266         rsync_opts.append('--munge-links')
267     
268     if args.no_overwrite:
269       rsync_opts.append('--ignore-existing')
270
271     if not rsync_args:
272         rsync_args = [ '.' ]
273
274     cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args)
275
276     if log_fh:
277         now = time.localtime()
278         host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr
279         if host.startswith('::ffff:'):
280             host = host[7:]
281         try:
282             host = socket.gethostbyaddr(socket.inet_aton(host))
283         except:
284             pass
285         log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd)))
286         log_fh.close()
287
288     # NOTE: This assumes that the rsync protocol will not be maliciously hijacked.
289     if args.no_lock:
290         os.execlp(RSYNC, *cmd)
291         die("execlp(", RSYNC, *cmd, ')  failed')
292     child = subprocess.run(cmd)
293     if child.returncode != 0:
294         sys.exit(child.returncode)
295
296
297 def validated_arg(opt, arg, typ=3, wild=False):
298     if opt != 'arg': # arg values already have their backslashes removed.
299         arg = DE_BACKSLASH_RE.sub(r'\1', arg)
300
301     orig_arg = arg
302     if arg.startswith('./'):
303         arg = arg[1:]
304     arg = arg.replace('//', '/')
305     is_absolute_arg = args.absolute and opt == 'arg' and args.dir != '/' and (arg == args.dir or arg.startswith(args.dir_slash))
306     if not is_absolute_arg:
307         arg = arg.lstrip('/')
308     if args.dir != '/':
309         if HAS_DOT_DOT_RE.search(arg):
310             die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)")
311
312     if wild:
313         got = glob.glob(arg)
314         if not got:
315             got = [ arg ]
316     else:
317         got = [ arg ]
318
319     ret = [ ]
320     for arg in got:
321         if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)):
322             arg_has_trailing_slash = arg.endswith('/')
323             if arg_has_trailing_slash:
324                 arg = arg[:-1]
325             else:
326                 arg_has_trailing_slash_dot = arg.endswith('/.')
327                 if arg_has_trailing_slash_dot:
328                     arg = arg[:-2]
329             real_arg = os.path.realpath(arg)
330             if arg != real_arg and not real_arg.startswith(args.dir_slash):
331                 if not (is_absolute_arg and real_arg == args.dir):
332                     die('unsafe arg:', orig_arg, [arg, real_arg])
333             if arg_has_trailing_slash:
334                 arg += '/'
335             elif arg_has_trailing_slash_dot:
336                 arg += '/.'
337             if is_absolute_arg and arg == args.dir:
338                 arg = '.'
339             elif opt == 'arg' and arg.startswith(args.dir_slash):
340                 arg = arg[args.dir_slash_len:]
341                 if arg == '':
342                     arg = '.'
343         ret.append(arg)
344
345     return ret if wild else ret[0]
346
347
348 def lock_or_die(dirname):
349     import fcntl
350     global lock_handle
351     lock_handle = os.open(dirname, os.O_RDONLY)
352     try:
353         fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
354     except:
355         die('Another instance of rrsync is already accessing this directory.')
356
357
358 def die(*msg):
359     print(sys.argv[0], 'error:', *msg, file=sys.stderr)
360     if sys.stdin.isatty():
361         arg_parser.print_help(sys.stderr)
362     sys.exit(1)
363
364
365 # This class displays the --help to the user on argparse error IFF they're running it interactively.
366 class OurArgParser(argparse.ArgumentParser):
367     def error(self, msg):
368         die(msg)
369
370
371 if __name__ == '__main__':
372     our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command."""
373     arg_parser = OurArgParser(description=our_desc, add_help=False)
374     only_group = arg_parser.add_mutually_exclusive_group()
375     only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del and -no-lock.")
376     only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.")
377     arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.")
378     arg_parser.add_argument('-absolute', action='store_true', help="Allow transfer args to use absolute server paths under DIR.")
379     arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
380     arg_parser.add_argument('-no-lock', action='store_true', help="Avoid the single-run (per-user) lock check.")
381     arg_parser.add_argument('-no-overwrite', action='store_true', help="Prevent overwriting existing files by enforcing --ignore-existing")
382     arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
383     arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
384     args = arg_parser.parse_args()
385     args.dir = os.path.realpath(args.dir)
386     args.dir_slash = args.dir + '/'
387     args.dir_slash_len = len(args.dir_slash)
388     if args.ro:
389         args.no_del = True
390     elif not args.no_lock:
391         lock_or_die(args.dir)
392     main()
393
394 # vim: sw=4 et