More tweaks for Actions.
[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 args.no_overwrite:
263       rsync_opts.append('--ignore-existing')
264
265     if not rsync_args:
266         rsync_args = [ '.' ]
267
268     cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args)
269
270     if log_fh:
271         now = time.localtime()
272         host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr
273         if host.startswith('::ffff:'):
274             host = host[7:]
275         try:
276             host = socket.gethostbyaddr(socket.inet_aton(host))
277         except:
278             pass
279         log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd)))
280         log_fh.close()
281
282     # NOTE: This assumes that the rsync protocol will not be maliciously hijacked.
283     if args.no_lock:
284         os.execlp(RSYNC, *cmd)
285         die("execlp(", RSYNC, *cmd, ')  failed')
286     child = subprocess.run(cmd)
287     if child.returncode != 0:
288         sys.exit(child.returncode)
289
290
291 def validated_arg(opt, arg, typ=3, wild=False):
292     if opt != 'arg': # arg values already have their backslashes removed.
293         arg = DE_BACKSLASH_RE.sub(r'\1', arg)
294
295     orig_arg = arg
296     if arg.startswith('./'):
297         arg = arg[1:]
298     arg = arg.replace('//', '/')
299     if args.dir != '/':
300         if HAS_DOT_DOT_RE.search(arg):
301             die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)")
302         if arg.startswith('/'):
303             arg = args.dir + arg
304
305     if wild:
306         got = glob.glob(arg)
307         if not got:
308             got = [ arg ]
309     else:
310         got = [ arg ]
311
312     ret = [ ]
313     for arg in got:
314         if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)):
315             arg_has_trailing_slash = arg.endswith('/')
316             if arg_has_trailing_slash:
317                 arg = arg[:-1]
318             else:
319                 arg_has_trailing_slash_dot = arg.endswith('/.')
320                 if arg_has_trailing_slash_dot:
321                     arg = arg[:-2]
322             real_arg = os.path.realpath(arg)
323             if arg != real_arg and not real_arg.startswith(args.dir_slash):
324                 die('unsafe arg:', orig_arg, [arg, real_arg])
325             if arg_has_trailing_slash:
326                 arg += '/'
327             elif arg_has_trailing_slash_dot:
328                 arg += '/.'
329             if opt == 'arg' and arg.startswith(args.dir_slash):
330                 arg = arg[args.dir_slash_len:]
331                 if arg == '':
332                     arg = '.'
333         ret.append(arg)
334
335     return ret if wild else ret[0]
336
337
338 def lock_or_die(dirname):
339     import fcntl
340     global lock_handle
341     lock_handle = os.open(dirname, os.O_RDONLY)
342     try:
343         fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
344     except:
345         die('Another instance of rrsync is already accessing this directory.')
346
347
348 def die(*msg):
349     print(sys.argv[0], 'error:', *msg, file=sys.stderr)
350     if sys.stdin.isatty():
351         arg_parser.print_help(sys.stderr)
352     sys.exit(1)
353
354
355 # This class displays the --help to the user on argparse error IFF they're running it interactively.
356 class OurArgParser(argparse.ArgumentParser):
357     def error(self, msg):
358         die(msg)
359
360
361 if __name__ == '__main__':
362     our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command."""
363     arg_parser = OurArgParser(description=our_desc, add_help=False)
364     only_group = arg_parser.add_mutually_exclusive_group()
365     only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del and -no-lock.")
366     only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.")
367     arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.")
368     arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
369     arg_parser.add_argument('-no-lock', action='store_true', help="Avoid the single-run (per-user) lock check.")
370     arg_parser.add_argument('-no-overwrite', action='store_true', help="Prevent overwriting existing files by enforcing --ignore-existing")
371     arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
372     arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
373     args = arg_parser.parse_args()
374     args.dir = os.path.realpath(args.dir)
375     args.dir_slash = args.dir + '/'
376     args.dir_slash_len = len(args.dir_slash)
377     if args.ro:
378         args.no_del = True
379     elif not args.no_lock:
380         lock_or_die(args.dir)
381     main()
382
383 # vim: sw=4 et