onion-grater 29.6 KB
Newer Older
1
#!/usr/bin/python3 -u
2

3
# This filter proxy allows fine-grained access whitelists of commands
4
5
# (and their argunents) and events on a per-application basis, stored
# in:
6
#
7
#     /etc/onion-grater.d/
8
#
9
10
# that are pretty self-explanatory as long as you understand the Tor
# ControlPort language. The format is expressed in YAML where the
anonym's avatar
anonym committed
11
12
# top-level is supposed to be a list, where each element is a
# dictionary looking something like this:
13
#
14
#     - name: blabla
15
16
17
18
#       apparmor-profiles:
#         - path_to_executable_if_that_is_the_name_of_the_apparmor_profile
#         # or
#         - explicit_apparmor_profile_name
19
#         ...
20
#       users:
anonym's avatar
anonym committed
21
22
#         - user
#         ...
23
#       hosts:
anonym's avatar
anonym committed
24
#         - host
25
26
#         ...
#       commands:
anonym's avatar
anonym committed
27
#         command:
28
#           - command_arg_rule
29
30
#           ...
#         ...
31
32
33
#       confs:
#         conf:
#           - conf_arg_rule
34
#           ...
35
#         ...
36
#       events:
anonym's avatar
anonym committed
37
38
39
#         event:
#           event_option: event_option_value
#           ...
40
41
#         ...
#
42
43
44
45
46
47
# `name` (optional) is a string which gives an internal name, useful
# for debugging. When not given, filters will default to the name of
# the file (excluding extension) they were read from (so there can be
# duplicates!). It is advisable to define one filter per file, and
# give helpful filenames instead of using this field.
#
48
# A filter is matched if for each of the relevant qualifiers at
anonym's avatar
anonym committed
49
# least one of the elements match the client. For local (loopback)
50
# clients the following qualifiers are relevant:
anonym's avatar
anonym committed
51
#
52
53
54
55
# * `apparmor-profiles`: a list of strings, each being the name
#   of the AppArmor profile applied to the binary or script of the client,
#   with `*` matching anything. While this matcher always works for binaries,
#   it only works for scripts with an enabled AppArmor profile (not
anonym's avatar
anonym committed
56
57
#   necessarily enforced, complain mode is good enough).
#
58
# * `users`: a list of strings, each describing the user of the
anonym's avatar
anonym committed
59
60
#   client with `*` matching anything.
#
61
62
# For remote (non-local) clients, the following qualifiers are
# relevant:
anonym's avatar
anonym committed
63
#
64
# * hosts: a list of strings, each describing the IPv4 address
anonym's avatar
anonym committed
65
66
#   of the client with `*` matching anything.
#
67
68
# A filter can serve both local and remote clients by having
# qualifiers of both types.
anonym's avatar
anonym committed
69
70
71
72
73
74
#
# `commands` (optional) is a list where each item is a dictionary with
# the obligatory `pattern` key, which is a regular expression that is
# matched against the full argument part of the command. The default
# behavior is to just proxy the line through if matched, but it can be
# altered with these keys:
anonym's avatar
anonym committed
75
76
77
#
# * `replacement`: this rewrites the arguments. The value is a Python
#   format string (str.format()) which will be given the match groups
anonym's avatar
anonym committed
78
#   from the match of `pattern`. The rewritten command is then proxied
79
80
81
82
83
84
85
#   without the need to match any rule. There are also some special
#   patterns that will be replaced as follows:
#
#   - {client-address}: the client's IP address
#   - {client-port}: the client's port
#   - {server-address}: the server's IP address
#   - {server-port}: the server's (listening) port
anonym's avatar
anonym committed
86
#
87
88
89
90
91
92
# * `response`: a list of dictionaries, where the `pattern` and
#   `replacement` keys work exactly as for commands arguments, but now
#   for the response. Note that this means that the response is left
#   intact if `pattern` doesn't match it, and if many `pattern`:s
#   match, only the first one (in the order listed) will trigger a
#   replacement.
anonym's avatar
anonym committed
93
94
95
#
# If a simple regex (as string) is given, it is assumed to be the
# `pattern` which allows a short-hand for this common type of rule.
anonym's avatar
anonym committed
96
#
anonym's avatar
anonym committed
97
98
99
# Note that to allow a command to be run without arguments, the empty
# string must be explicitly given as a `pattern`. Hence, an empty
# argument list does not allow any use of the command.
100
#
101
102
103
# `confs` (optional) is a dictionary, and it's just syntactic sugar to
# generate GETCONF/SETCONF rules. If a key exists, GETCONF of the
# keyname is allowed, and if it has a non-empty list as value, those
104
# values are allowed to be set. The empty string means that resetting
105
106
107
# it is allowed. This is very useful for applications that like to
# SETCONF on multiple configurations at the same time.
#
anonym's avatar
anonym committed
108
# `events` (optional) is a dictionary where the key represents the
109
# event. If a key exists the event is allowed. The value is another
anonym's avatar
anonym committed
110
111
112
113
114
115
# dictionary of options:
#
# * `suppress`: a boolean determining whether we should just fool the
#   client that it has subscribed to the event (i.e. the client
#   request is not filtered) while we suppress them.
#
116
117
118
119
# * `response`: a dictionary, where the `pattern` and `replacement`
#   keys work exactly as for `response` for commands, but now for the
#   events.
#
anonym's avatar
anonym committed
120
121
122
# `restrict-stream-events` (optional) is a boolean, and if set any
# STREAM events sent to the client (after it has subscribed to them)
# will be restricted to those belonging to the client itself. This
123
124
# option only works for local clients and will be unset for remote
# clients.
125

anonym's avatar
anonym committed
126
import argparse
127
import fcntl
128
import glob
129
import ipaddress
130
import os.path
anonym's avatar
anonym committed
131
132
import psutil
import re
133
import socket
anonym's avatar
anonym committed
134
import socketserver
135
136
import stem
import stem.control
137
import stem.connection
138
import struct
139
import sys
140
import textwrap
anonym's avatar
anonym committed
141
import yaml
142

143
DEFAULT_LISTEN_ADDRESS = 'localhost'
144
DEFAULT_LISTEN_PORT = 9051
145
146
DEFAULT_COOKIE_PATH = '/run/tor/control.authcookie'
DEFAULT_CONTROL_SOCKET_PATH = '/run/tor/control'
anonym's avatar
anonym committed
147

148

149
class NoRewriteMatch(RuntimeError):
150
151
152
    """
    Error when no matching rewrite rule was found but one was expected.
    """
153
154
    pass

155

156
157
158
159
160
def log(msg):
    print(msg, file=sys.stderr)
    sys.stderr.flush()


161
162
def pid_of_laddr(address):
    try:
163
        return next(conn for conn in psutil.net_connections()
164
165
166
167
168
                    if conn.laddr == address).pid
    except StopIteration:
        return None


169
def apparmor_profile_of_pid(pid):
170
171
172
173
174
    # Here we leverage AppArmor's in-kernel solution for determining
    # the exact executable invoked. Looking at /proc/pid/exe when an
    # interpreted script is running will just point to the
    # interpreter's binary, which is not fine-grained enough, but
    # AppArmor will be aware of which script is running for processes
175
176
177
    # using one of its profiles. However, we fallback to /proc/pid/exe
    # in case there is no AppArmor profile, so the only unsupported
    # mode here is unconfined scripts.
178
    enabled_aa_profile_re = r'^(.+) \((?:complain|enforce)\)$'
179
180
    with open('/proc/{}/attr/current'.format(str(pid)), "rb") as fh:
        aa_profile_status = str(fh.read().strip(), 'UTF-8')
181
182
183
        apparmor_profile_match = re.match(enabled_aa_profile_re, aa_profile_status)
        if apparmor_profile_match:
            return apparmor_profile_match.group(1)
184
185
        else:
            return psutil.Process(pid).exe()
186
187


188
189
190
191
192
193
194
195
196
def get_ip_address(ifname):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    return socket.inet_ntoa(fcntl.ioctl(
        s.fileno(),
        0x8915,  # SIOCGIFADDR
        struct.pack('256s', bytes(ifname[:15], 'utf-8'))
    )[20:24])


197
class FilteredControlPortProxySession:
198
199
200
201
202
203
204
    """
    Class used to deal with a single session, delegated from the handler
    (FilteredControlPortProxyHandler). Its main job is proxying the traffic
    between the client and the real control port, blocking or rewriting
    it as dictated by the filter rule set.
    """

205
206
    # Limit the length of a line, to prevent DoS attacks trying to
    # crash this filter proxy by sending infinitely long lines.
207
    MAX_LINESIZE = 10*1024
208
209
210
211

    def __init__(self, handler):
        self.allowed_commands = handler.allowed_commands
        self.allowed_events = handler.allowed_events
212
        self.client_address = handler.client_address
213
214
215
216
217
218
219
220
221
222
        self.client_pid = handler.client_pid
        self.controller = handler.controller
        self.debug_log = handler.debug_log
        self.filter_name = handler.filter_name
        self.restrict_stream_events = handler.restrict_stream_events
        self.rfile = handler.rfile
        self.server_address = handler.server_address
        self.wfile = handler.wfile
        self.client_streams = set()
        self.subscribed_event_listeners = []
223

224
    def debug_log_send(self, line):
225
        if global_args.print_responses:
226
            self.debug_log(line, format_multiline=True, sep=': <- ')
227

228
    def debug_log_recv(self, line):
229
        if global_args.print_requests:
230
            self.debug_log(line, format_multiline=True, sep=': -> ')
231

232
    def debug_log_rewrite(self, kind, old, new):
233
234
        if kind not in ['command', 'received event', 'response'] or \
           (kind == 'command' and not global_args.print_responses) or \
235
236
           (kind in ['received event', 'response']
            and not global_args.print_requests):
237
238
239
240
            return
        if new != old:
            old = textwrap.indent(old.strip(), ' '*4)
            new = textwrap.indent(new.strip(), ' '*4)
241
242
            self.debug_log("rewrote {}:\n{}\nto:\n{}".format(kind, old, new),
                           format_multiline=False)
243

244
    def respond(self, line, raw=False):
245
246
        if line.isspace():
            return
247
248
        self.debug_log_send(line)
        self.wfile.write(bytes(line, 'ascii'))
249
250
        if not raw:
            self.wfile.write(bytes("\r\n", 'ascii'))
251
        self.wfile.flush()
252

253
254
    def get_rule(self, cmd, arg_str):
        allowed_args = self.allowed_commands.get(cmd, [])
255
        return next((rule for rule in allowed_args
256
                     if re.match(rule['pattern'] + "$", arg_str)), None)
257

258
    def proxy_line(self, line, args_rewriter=None, response_rewriter=None):
259
260
        if args_rewriter:
            new_line = args_rewriter(line)
261
            self.debug_log_rewrite('command', line, new_line)
262
            line = new_line
263
        response = self.controller.msg(line.strip()).raw_content()
264
        if response_rewriter:
265
            new_response = response_rewriter(response)
266
            self.debug_log_rewrite('response', response, new_response)
267
            response = new_response
268
        self.respond(response, raw=True)
269

270
271
272
273
    def filter_line(self, line):
        self.debug_log("command filtered: {}".format(line))
        self.respond("510 Command filtered")

274
    def rewrite_line(self, replacers, line):
275
        builtin_replacers = {
276
277
278
279
            'client-address': self.client_address[0],
            'client-port':    str(self.client_address[1]),
            'server-address': self.server_address[0],
            'server-port':    str(self.server_address[1]),
280
        }
281
282
283
284
        terminator = ''
        if line[-2:] == "\r\n":
            terminator = "\r\n"
            line = line[:-2]
285
286
287
        for r in replacers:
            match = re.match(r['pattern'] + "$", line)
            if match:
288
289
290
                return r['replacement'].format(
                    *match.groups(), **builtin_replacers
                ) + terminator
291
        raise NoRewriteMatch()
292

293
    def rewrite_matched_line(self, replacers, line):
294
        try:
295
            return self.rewrite_line(replacers, line)
296
        except NoRewriteMatch:
297
298
            return line

299
    def rewrite_matched_lines(self, replacers, lines):
300
        split_lines = lines.strip().split("\r\n")
301
        return "\r\n".join([self.rewrite_matched_line(replacers, line)
302
                            for line in split_lines]) + "\r\n"
303

304
    def event_cb(self, event, event_rewriter=None):
305
        if self.restrict_stream_events and \
306
           isinstance(event, stem.response.events.StreamEvent) and \
307
           not global_args.disable_filtering:
308
            if event.id not in self.client_streams:
309
310
                if event.status in [stem.StreamStatus.NEW,
                                    stem.StreamStatus.NEWRESOLVE] and \
311
312
313
                   self.client_pid == pid_of_laddr((event.source_address,
                                                    event.source_port)):
                    self.client_streams.add(event.id)
314
315
316
317
                else:
                    return
            elif event.status in [stem.StreamStatus.FAILED,
                                  stem.StreamStatus.CLOSED]:
318
                self.client_streams.remove(event.id)
319
320
        raw_event_content = event.raw_content()
        if event_rewriter:
321
            new_raw_event_content = event_rewriter(raw_event_content)
322
            self.debug_log_rewrite(
323
324
325
                'received event', raw_event_content, new_raw_event_content
            )
            raw_event_content = new_raw_event_content
326
327
            if raw_event_content.strip() == '':
                return
328
        self.respond(raw_event_content, raw=True)
329

330
331
    def update_event_subscriptions(self, events):
        for listener, event in self.subscribed_event_listeners:
332
            if event not in events:
333
334
                self.controller.remove_event_listener(listener)
                self.subscribed_event_listeners.remove((listener, event))
335
                if global_args.print_responses:
336
                    self.debug_log("unsubscribed from event '{}'".format(event))
anonym's avatar
anonym committed
337
        for event in events:
338
            if any(event == event_ for _, event_ in self.subscribed_event_listeners):
339
                if global_args.print_responses:
340
341
                    self.debug_log("already subscribed to event '{}'"
                                   .format(event))
342
                continue
343
            rule = self.allowed_events.get(event, {}) or {}
anonym's avatar
anonym committed
344
345
346
347
            if not rule.get('suppress', False) or \
               global_args.disable_filtering:
                event_rewriter = None
                if 'response' in rule:
348
                    replacers = rule['response']
anonym's avatar
anonym committed
349
                    def _event_rewriter(line):
350
                        return self.rewrite_matched_line(replacers, line)
anonym's avatar
anonym committed
351
352
                    event_rewriter = _event_rewriter
                def _event_cb(event):
353
354
                    self.event_cb(event, event_rewriter=event_rewriter)
                self.controller.add_event_listener(
anonym's avatar
anonym committed
355
356
                    _event_cb, getattr(stem.control.EventType, event)
                )
357
                self.subscribed_event_listeners.append((_event_cb, event))
358
                if global_args.print_responses:
359
                    self.debug_log("subscribed to event '{}'".format(event))
360
361
            else:
                if global_args.print_responses:
362
                    self.debug_log("suppressed subscription to event '{}'"
363
                                   .format(event))
364
        self.respond("250 OK")
365

366
367
368
369
370
371
372
373
    def handle(self):
        while True:
            binary_line = self.rfile.readline(self.MAX_LINESIZE)
            if binary_line == b'':
                # Deal with clients that close the socket without a QUIT.
                break
            line = str(binary_line, 'ascii')
            if line.isspace():
374
375
                self.debug_log('ignoring received empty (or whitespace-only) '
                               + 'line')
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
                continue
            match = re.match(
                r'(?P<cmd>\S+)(?P<cmd_arg_sep>\s*)(?P<arg_str>[^\r\n]*)\r?\n$',
                line
            )
            if not match:
                self.debug_log("received bad line (escapes made explicit): " +
                               repr(line))
                # Hopefully the next line is ok...
                continue
            self.debug_log_recv(line)
            cmd         = match.group('cmd')
            cmd_arg_sep = match.group('cmd_arg_sep')
            arg_str     = match.group('arg_str')
            args = arg_str.split()
            cmd = cmd.upper()

            if cmd == "PROTOCOLINFO":
                # Stem calls PROTOCOLINFO before authenticating. Tell the
                # client that there is no authentication.
                self.respond("250-PROTOCOLINFO 1")
                self.respond("250-AUTH METHODS=NULL")
                self.respond("250-VERSION Tor=\"{}\""
                             .format(self.controller.get_version()))
                self.respond("250 OK")

            elif cmd == "AUTHENTICATE":
                # We have already authenticated, and the filtered port is
                # access-restricted according to our filter instead.
                self.respond("250 OK")

            elif cmd == "QUIT":
                self.respond("250 closing connection")
                break

            elif cmd == "SETEVENTS":
                # The control language doesn't care about case for
                # the event type.
                events = [event.upper() for event in args]
415
416
417
418
419
                if not global_args.disable_filtering and \
                   any(event not in self.allowed_events for event in events):
                    self.filter_line(line)
                else:
                    self.update_event_subscriptions(events)
420

421
            else:
422
                rule = self.get_rule(cmd, arg_str)
423
                if rule is None and global_args.disable_filtering:
424
                    rule = {}
425
                if rule is not None:
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
                    args_rewriter = None
                    response_rewriter = None

                    if 'response' in rule:
                        def _response_rewriter(lines):
                            return self.rewrite_matched_lines(rule['response'],
                                                              lines)
                        response_rewriter = _response_rewriter

                    if 'replacement' in rule:
                        def _args_rewriter(line):
                            # We also want to match the command in `line`
                            # and add it back to the replacement string.
                            # We make sure to keep the exact white spaces
                            # separating the command and arguments, to not
                            # rewrite the line unnecessarily.
                            prefix = cmd + cmd_arg_sep
                            replacer = {
                                'pattern':     prefix + rule['pattern'],
                                'replacement': prefix + rule['replacement']
                            }
                            return self.rewrite_line([replacer], line)
                        args_rewriter = _args_rewriter

                    self.proxy_line(line, args_rewriter=args_rewriter,
                                    response_rewriter=response_rewriter)
                else:
453
                    self.filter_line(line)
454
455


456
class FilteredControlPortProxyHandler(socketserver.StreamRequestHandler):
457
458
459
460
461
462
463
    """
    Class handing each control port connection and collecting information
    about the origin and using it to find a matching filter rule set. It
    then delegates the session handling (the actual filtering) to a
    FilteredControlPortProxySession object.
    """

464
    def debug_log(self, line, format_multiline=False, sep=': '):
465
466
467
468
469
470
        line = line.strip()
        if format_multiline and "\n" in line:
            sep += "(multi-line)\n"
            line = textwrap.indent(line, ' '*4)
        log(self.client_desc + sep + line)

471
472
    def setup(self):
        super(type(self), self).setup()
473
474
475
476
477
478
479
        self.allowed_commands = {}
        self.allowed_events = {}
        self.client_desc = None
        self.client_pid = None
        self.client_streams = set()
        self.controller = None
        self.filter_name = None
480
        self.filters = []
481
482
483
        self.restrict_stream_events = False
        self.server_address = self.server.server_address
        self.subscribed_event_listeners = []
484
        for filter_file in glob.glob('/etc/onion-grater.d/*.yml'):
485
486
            try:
                with open(filter_file, "rb") as fh:
487
                    filters = yaml.safe_load(fh.read())
488
489
490
491
492
                    name = re.sub(r'\.yml$', '', os.path.basename(filter_file))
                    for filter_ in filters:
                        if name not in filter_:
                            filter_['name'] = name
                    self.filters += filters
493
494
495
            except (yaml.parser.ParserError, yaml.scanner.ScannerError) as err:
                log("filter '{}' has bad YAML and was not loaded: {}"
                    .format(filter_file, str(err)))
496

497
    def add_allowed_commands(self, commands):
498
499
500
501
502
        for cmd in commands:
            allowed_args = commands[cmd]
            # An empty argument list allows nothing, but will
            # make some code below easier than if it can be
            # None as well.
503
            if allowed_args is None:
504
505
506
507
                allowed_args = []
            for i in range(len(allowed_args)):
                if isinstance(allowed_args[i], str):
                    allowed_args[i] = {'pattern': allowed_args[i]}
508
            self.allowed_commands[cmd.upper()] = allowed_args
509

510
    def add_allowed_confs_commands(self, confs):
511
512
513
        combined_getconf_rule = {'pattern': "(" + "|".join([
            key for key in confs]) + ")"}
        setconf_reset_part = "\s*|\s*".join([
514
515
            key for key in confs
            if isinstance(confs[key], list) and '' in confs[key]]
516
517
518
519
        )
        setconf_assignment_part = "\s*|\s*".join([
            "{}=({})".format(
                key, "|".join(confs[key])
520
521
522
            )
            for key in confs
            if isinstance(confs[key], list) and len(confs[key]) > 0])
523
524
525
526
527
528
529
530
531
532
        setconf_parts = []
        for part in [setconf_reset_part, setconf_assignment_part]:
            if part and part != '':
                setconf_parts.append(part)
        combined_setconf_rule = {
            'pattern': "({})+".format("\s*|\s*".join(setconf_parts))
        }
        for cmd, rule in [('GETCONF', combined_getconf_rule),
                          ('SETCONF', combined_setconf_rule)]:
            if rule['pattern'] != "()+":
533
534
535
                if cmd not in self.allowed_commands:
                    self.allowed_commands[cmd] = []
                self.allowed_commands[cmd].append(rule)
536

537
    def add_allowed_events(self, events):
538
539
540
541
        for event in events:
            opts = events[event]
            # Same as for the `commands` argument list, let's
            # add an empty dict to simplify later code.
542
            if opts is None:
543
                opts = {}
544
545
546
            self.allowed_events[event.upper()] = opts

    def match_and_parse_filter(self, matchers):
547
548
549
        matched_filters = [filter_ for filter_ in self.filters
                           if all(any(val == expected_val or val == '*'
                                      for val in filter_.get(key, []))
550
551
                                  for key, expected_val in matchers)]
        if len(matched_filters) == 0:
552
            return
553
554
555
556
        elif len(matched_filters) > 1:
            raise RuntimeError('multiple filters matched: ' +
                               ', '.join(matched_filters))
        matched_filter = matched_filters[0]
557
        self.filter_name = matched_filter['name']
558
        commands = matched_filter.get('commands', {}) or {}
559
        self.add_allowed_commands(commands)
560
        confs = matched_filter.get('confs', {}) or {}
561
        self.add_allowed_confs_commands(confs)
562
        events = matched_filter.get('events', {}) or {}
563
564
        self.add_allowed_events(events)
        self.restrict_stream_events = bool(matched_filter.get(
565
566
567
            'restrict-stream-events', False
        ))

568
    def connect_to_real_control_port(self):
569
        controller = None
570
571
572
573
        tries = 0
        # If tor isn't running this would just loop endlessly as fast
        # as possible, so let's rate limit it so it at least cannot
        # become a performance issue.
574
        while not controller:
575
576
            if tries >= 3:
                time.sleep(1)
577
            controller = stem.connection.connect(control_socket=global_args.control_socket_path)
578
            tries += 1
579
        stem.connection.authenticate_cookie(controller, cookie_path=global_args.control_cookie_path)
anonym's avatar
anonym committed
580
        return controller
581

582
    def handle(self):
583
584
585
        client_host = self.client_address[0]
        local_connection = ipaddress.ip_address(client_host).is_loopback
        if local_connection:
586
            self.client_pid = pid_of_laddr(self.client_address)
587
588
            # Deal with the race between looking up the PID, and the
            # client being killed before we find the PID.
589
590
            if not self.client_pid:
                return
591
            client_apparmor_profile = apparmor_profile_of_pid(self.client_pid)
592
            client_user = psutil.Process(self.client_pid).username()
anonym's avatar
anonym committed
593
            matchers = [
594
595
                ('apparmor-profiles', client_apparmor_profile),
                ('users',             client_user),
anonym's avatar
anonym committed
596
            ]
597
        else:
598
            self.client_pid = None
anonym's avatar
anonym committed
599
            matchers = [
600
                ('hosts', client_host),
anonym's avatar
anonym committed
601
            ]
602
        self.match_and_parse_filter(matchers)
603
        if local_connection:
604
            self.client_desc = '{aa_profile} (pid: {pid}, user: {user}, ' \
605
                               'port: {port}, filter: {filter_name})'.format(
606
                                   aa_profile=client_apparmor_profile,
607
608
609
610
                                   pid=self.client_pid,
                                   user=client_user,
                                   port=self.client_address[1],
                                   filter_name=self.filter_name
611
                               )
612
        else:
613
614
            self.client_desc = '{1}:{2} (filter: {0})'.format(
                self.filter_name, *self.client_address
615
            )
616
617
        if self.restrict_stream_events and not local_connection:
            self.debug_log(
618
                "filter '{}' has `restrict-stream-events` set "
anonym's avatar
anonym committed
619
                "and we are remote so the option was disabled"
620
                .format(self.filter_name)
anonym's avatar
anonym committed
621
            )
622
623
624
625
626
627
628
            self.restrict_stream_events = False

        if len(self.filters) == 0:
            status = 'no matching filter found, using an empty one'
        else:
            status = 'loaded filter: {}'.format(self.filter_name)
        log('{} connected: {}'.format(self.client_desc, status))
629
        if global_args.debug:
630
            log('Final rules:')
631
            log(yaml.dump({
632
633
634
                'commands': self.allowed_commands,
                'events': self.allowed_events,
                'restrict-stream-events': self.restrict_stream_events,
635
            }))
636
        disconnect_reason = "client quit"
637
        try:
638
639
640
            self.controller = self.connect_to_real_control_port()
            session = FilteredControlPortProxySession(self)
            session.handle()
641
        except (ConnectionResetError, BrokenPipeError) as err:
642
            # Handle clients disconnecting abruptly
643
            disconnect_reason = str(err)
644
645
646
        except stem.SocketError:
            # Handle client closing its socket abruptly
            disconnect_reason = "Client closed its socket"
647
        except stem.SocketClosed:
anonym's avatar
anonym committed
648
            # Handle Tor closing its socket abruptly
649
            disconnect_reason = "Tor closed its socket"
650
        finally:
651
652
653
654
            if self.controller:
                self.controller.close()
            log('{} disconnected: {}'.format(self.client_desc,
                                             disconnect_reason))
655
656


657
class FilteredControlPortProxy(socketserver.ThreadingTCPServer):
658
659
660
661
    """
    Simple subclass just setting some defaults differently.
    """

anonym's avatar
anonym committed
662
    # So we can restart when the listening port if in TIME_WAIT state
663
664
665
666
667
668
669
    # after an abrupt shutdown.
    allow_reuse_address = True
    # So all server threads immediately quit when the main thread
    # quits.
    daemon_threads = True


670
def main():
anonym's avatar
anonym committed
671
    parser = argparse.ArgumentParser()
672
    parser.add_argument(
673
        "--listen-address",
674
675
        type=str, metavar='ADDR', default=DEFAULT_LISTEN_ADDRESS,
        help="specifies the address on which the server listens " +
676
677
             "(default: {})".format(DEFAULT_LISTEN_ADDRESS)
    )
anonym's avatar
anonym committed
678
    parser.add_argument(
679
        "--listen-port",
680
681
        type=int, metavar='PORT', default=DEFAULT_LISTEN_PORT,
        help="specifies the port on which the server listens " +
682
683
             "(default: {})".format(DEFAULT_LISTEN_PORT)
    )
684
685
686
687
688
689
    parser.add_argument(
        "--listen-interface",
        type=str, metavar='INTERFACE',
        help="specifies the interface on which the server listens " +
             "(default: NULL)"
    )
anonym's avatar
anonym committed
690
    parser.add_argument(
691
        "--control-cookie-path",
692
693
        type=str, metavar='PATH', default=DEFAULT_COOKIE_PATH,
        help="specifies the path to Tor's control authentication cookie " +
694
695
             "(default: {})".format(DEFAULT_COOKIE_PATH)
    )
anonym's avatar
anonym committed
696
    parser.add_argument(
697
        "--control-socket-path",
698
699
        type=str, metavar='PATH', default=DEFAULT_CONTROL_SOCKET_PATH,
        help="specifies the path to Tor's control socket " +
700
701
             "(default: {})".format(DEFAULT_CONTROL_SOCKET_PATH)
    )
702
703
    parser.add_argument(
        "--complain",
704
705
        action='store_true', default=False,
        help="disables all filtering and just prints the commands sent " +
706
707
             "by the client"
    )
708
709
710
    parser.add_argument(
        "--debug",
        action='store_true', default=False,
711
712
        help="prints all requests and responses"
    )
713
714
715
    # We put the argparse results in the global scope since it's
    # awkward to extend socketserver so additional data can be sent to
    # the request handler, where we need access to the arguments.
anonym's avatar
anonym committed
716
717
    global global_args
    global_args = parser.parse_args()
718
719
720
721
722
    # Deal with overlapping functionality between arguments
    global_args.__dict__['disable_filtering'] = global_args.complain
    global_args.__dict__['print_requests'] = global_args.complain or \
                                             global_args.debug
    global_args.__dict__['print_responses'] = global_args.debug
723
724
725
726
727
728
729
730
    if global_args.listen_interface:
        ip_address = get_ip_address(global_args.listen_interface)
        if global_args.debug:
            log("IP address for interface {} : {}".format(
                global_args.listen_interface,ip_address))
    else:
        ip_address = global_args.listen_address
    address = (ip_address, global_args.listen_port)
anonym's avatar
anonym committed
731
732
    server = FilteredControlPortProxy(address, FilteredControlPortProxyHandler)
    log("Tor control port filter started, listening on {}:{}".format(*address))
733
734
735
736
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
737
738
739


if __name__ == "__main__":
740
    main()