1"""
2Tests the stem.process functions with various use cases.
3"""
4
5from __future__ import absolute_import
6
7import binascii
8import hashlib
9import os
10import random
11import re
12import shutil
13import subprocess
14import tempfile
15import threading
16import time
17import unittest
18
19import stem.prereq
20import stem.process
21import stem.socket
22import stem.util.str_tools
23import stem.util.system
24import stem.util.test_tools
25import stem.util.tor_tools
26import stem.version
27import test
28import test.require
29
30from contextlib import contextmanager
31from stem.util.test_tools import asynchronous, assert_equal, assert_in, skip
32
33try:
34  # added in python 3.3
35  from unittest.mock import patch, Mock
36except ImportError:
37  from mock import patch, Mock
38
39BASIC_RELAY_TORRC = """\
40SocksPort 9089
41ExtORPort 6001
42Nickname stemIntegTest
43ExitPolicy reject *:*
44PublishServerDescriptor 0
45DataDirectory %s
46"""
47
48TOR_CMD = 'tor'
49
50
51def random_port():
52  while True:
53    port = random.randint(1024, 65535)
54
55    if stem.util.system.pid_by_port(port) is None:
56      return str(port)
57
58
59@contextmanager
60def tmp_directory():
61  tmp_dir = tempfile.mkdtemp()
62
63  try:
64    yield tmp_dir
65  finally:
66    shutil.rmtree(tmp_dir)
67
68
69@contextmanager
70def torrc():
71  with tmp_directory() as data_directory:
72    torrc_path = os.path.join(data_directory, 'torrc')
73
74    with open(torrc_path, 'w') as torrc_file:
75      torrc_file.write(BASIC_RELAY_TORRC % data_directory)
76
77    yield torrc_path
78
79
80def run_tor(tor_cmd, *args, **kwargs):
81  # python doesn't allow us to have individual keyword arguments when there's
82  # an arbitrary number of positional arguments, so explicitly checking
83
84  expect_failure = kwargs.pop('expect_failure', False)
85  with_torrc = kwargs.pop('with_torrc', False)
86  stdin = kwargs.pop('stdin', None)
87
88  if kwargs:
89    raise ValueError('Got unexpected keyword arguments: %s' % kwargs)
90
91  with torrc() as torrc_path:
92    if with_torrc:
93      args = ['-f', torrc_path] + list(args)
94
95    args = [tor_cmd] + list(args)
96    tor_process = subprocess.Popen(args, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE)
97
98    if stdin:
99      tor_process.stdin.write(stem.util.str_tools._to_bytes(stdin))
100
101    stdout = tor_process.communicate()[0]
102    exit_status = tor_process.poll()
103
104    if exit_status and not expect_failure:
105      raise AssertionError("Tor failed to start when we ran: %s\n%s" % (' '.join(args), stdout))
106    elif not exit_status and expect_failure:
107      raise AssertionError("Didn't expect tor to be able to start when we run: %s\n%s" % (' '.join(args), stdout))
108
109    return stem.util.str_tools._to_unicode(stdout) if stem.prereq.is_python_3() else stdout
110
111
112class TestProcess(unittest.TestCase):
113  @staticmethod
114  def run_tests(args):
115    global TOR_CMD
116    TOR_CMD = args.tor_cmd
117
118    for func, async_test in stem.util.test_tools.ASYNC_TESTS.items():
119      if func.startswith('test.integ.process.'):
120        async_test.run(TOR_CMD)
121
122  @asynchronous
123  def test_version_argument(tor_cmd):
124    """
125    Check that 'tor --version' matches 'GETINFO version'.
126    """
127
128    assert_equal('Tor version %s.\n' % test.tor_version(), run_tor(tor_cmd, '--version'))
129
130  @asynchronous
131  def test_help_argument(tor_cmd):
132    """
133    Check that 'tor --help' provides the expected output.
134    """
135
136    help_output = run_tor(tor_cmd, '--help')
137
138    if not help_output.startswith('Copyright (c) 2001') or not help_output.endswith('tor -f <torrc> [args]\nSee man page for options, or https://www.torproject.org/ for documentation.\n'):
139      raise AssertionError("Help output didn't have the expected strings: %s" % help_output)
140
141    assert_equal(help_output, run_tor(tor_cmd, '-h'), "'tor -h' should simply be an alias for 'tor --help'")
142
143  @asynchronous
144  def test_quiet_argument(tor_cmd):
145    """
146    Check that we don't provide anything on stdout when running 'tor --quiet'.
147    """
148
149    quiet_output = run_tor(tor_cmd, '--quiet', '--invalid_argument', 'true', expect_failure = True)
150    assert_equal('', quiet_output, 'No output should be provided with the --quiet argument')
151
152  @asynchronous
153  def test_hush_argument(tor_cmd):
154    """
155    Check that we only get warnings and errors when running 'tor --hush'.
156    """
157
158    output = run_tor(tor_cmd, '--hush', '--invalid_argument', with_torrc = True, expect_failure = True)
159    assert_in("[warn] Command-line option '--invalid_argument' with no value. Failing.", output)
160
161    output = run_tor(tor_cmd, '--hush', '--invalid_argument', 'true', with_torrc = True, expect_failure = True)
162    assert_in("[warn] Failed to parse/validate config: Unknown option 'invalid_argument'.  Failing.", output)
163
164  @asynchronous
165  def test_hash_password(tor_cmd):
166    """
167    Hash a controller password. It's salted so can't assert that we get a
168    particular value. Also, tor's output is unnecessarily verbose so including
169    hush to cut it down.
170    """
171
172    output = run_tor(tor_cmd, '--hush', '--hash-password', 'my_password').splitlines()[-1]
173
174    if not re.match('^16:[0-9A-F]{58}$', output):
175      raise AssertionError("Unexpected response from 'tor --hash-password my_password': %s" % output)
176
177    # I'm not gonna even pretend to understand the following. Ported directly
178    # from tor's test_cmdline_args.py.
179
180    if stem.prereq.is_python_3():
181      output_hex = binascii.a2b_hex(stem.util.str_tools._to_bytes(output).strip()[3:])
182      salt, how, hashed = output_hex[:8], output_hex[8], output_hex[9:]
183    else:
184      output_hex = binascii.a2b_hex(output.strip()[3:])
185      salt, how, hashed = output_hex[:8], ord(output_hex[8]), output_hex[9:]
186
187    count = (16 + (how & 15)) << ((how >> 4) + 6)
188    stuff = salt + b'my_password'
189    repetitions = count // len(stuff) + 1
190    inp = (stuff * repetitions)[:count]
191    assert_equal(hashlib.sha1(inp).digest(), hashed)
192
193  @asynchronous
194  def test_hash_password_requires_argument(tor_cmd):
195    """
196    Check that 'tor --hash-password' balks if not provided with something to
197    hash.
198    """
199
200    output = run_tor(tor_cmd, '--hash-password', expect_failure = True)
201    assert_in("[warn] Command-line option '--hash-password' with no value. Failing.", output)
202
203  @asynchronous
204  def test_dump_config_argument(tor_cmd):
205    """
206    Exercises our 'tor --dump-config' arugments.
207    """
208
209    short_output = run_tor(tor_cmd, '--dump-config', 'short', with_torrc = True)
210    non_builtin_output = run_tor(tor_cmd, '--dump-config', 'non-builtin', with_torrc = True)
211    full_output = run_tor(tor_cmd, '--dump-config', 'full', with_torrc = True)
212    run_tor(tor_cmd, '--dump-config', 'invalid_option', with_torrc = True, expect_failure = True)
213
214    assert_in('Nickname stemIntegTest', short_output)
215    assert_in('Nickname stemIntegTest', non_builtin_output)
216    assert_in('Nickname stemIntegTest', full_output)
217
218  @asynchronous
219  def test_validate_config_argument(tor_cmd):
220    """
221    Exercises our 'tor --validate-config' argument.
222    """
223
224    valid_output = run_tor(tor_cmd, '--verify-config', with_torrc = True)
225    assert_in('Configuration was valid\n', valid_output, 'Expected configuration to be valid')
226    run_tor(tor_cmd, '--verify-config', '-f', __file__, expect_failure = True)
227
228  @asynchronous
229  def test_list_fingerprint_argument(tor_cmd):
230    """
231    Exercise our 'tor --list-fingerprint' argument.
232    """
233
234    # This command should only work with a relay (which our test instance isn't).
235
236    output = run_tor(tor_cmd, '--list-fingerprint', with_torrc = True, expect_failure = True)
237    assert_in("Clients don't have long-term identity keys. Exiting.", output, 'Should fail to start due to lacking an ORPort')
238
239    with tmp_directory() as data_directory:
240      torrc_path = os.path.join(data_directory, 'torrc')
241
242      with open(torrc_path, 'w') as torrc_file:
243        torrc_file.write(BASIC_RELAY_TORRC % data_directory + '\nORPort 6954')
244
245      output = run_tor(tor_cmd, '--list-fingerprint', '-f', torrc_path)
246      nickname, fingerprint_with_spaces = output.splitlines()[-1].split(' ', 1)
247      fingerprint = fingerprint_with_spaces.replace(' ', '')
248
249      assert_equal('stemIntegTest', nickname)
250      assert_equal(49, len(fingerprint_with_spaces))
251
252      if not stem.util.tor_tools.is_valid_fingerprint(fingerprint):
253        raise AssertionError('We should have a valid fingerprint: %s' % fingerprint)
254
255      with open(os.path.join(data_directory, 'fingerprint')) as fingerprint_file:
256        expected = 'stemIntegTest %s\n' % fingerprint
257        assert_equal(expected, fingerprint_file.read())
258
259  @asynchronous
260  def test_list_torrc_options_argument(tor_cmd):
261    """
262    Exercise our 'tor --list-torrc-options' argument.
263    """
264
265    output = run_tor(tor_cmd, '--list-torrc-options')
266
267    if len(output.splitlines()) < 50:
268      raise AssertionError("'tor --list-torrc-options' should have numerous entries, but only had %i" % len(output.splitlines()))
269    elif 'UseBridges' not in output or 'SocksPort' not in output:
270      raise AssertionError("'tor --list-torrc-options' didn't have options we expect")
271
272  @asynchronous
273  def test_no_orphaned_process(tor_cmd):
274    """
275    Check that when an exception arises in the middle of spawning tor that we
276    don't leave a lingering process.
277    """
278
279    if not stem.util.system.is_available('sleep'):
280      skip('(sleep unavailable)')
281
282    with patch('re.compile', Mock(side_effect = KeyboardInterrupt('nope'))):
283      # We don't need to actually run tor for this test. Rather, any process will
284      # do the trick. Picking sleep so this'll clean itself up if our test fails.
285
286      mock_tor_process = subprocess.Popen(['sleep', '60'])
287
288      with patch('subprocess.Popen', Mock(return_value = mock_tor_process)):
289        try:
290          stem.process.launch_tor(tor_cmd)
291          raise AssertionError("tor shoudn't have started")
292        except KeyboardInterrupt as exc:
293          if os.path.exists('/proc/%s' % mock_tor_process.pid):
294            raise AssertionError('launch_tor() left a lingering tor process')
295
296          assert_equal('nope', str(exc))
297
298  @asynchronous
299  def test_torrc_arguments(tor_cmd):
300    """
301    Pass configuration options on the commandline.
302    """
303
304    with torrc() as torrc_path:
305      config_args = [
306        '+SocksPort', '9090',  # append an extra SocksPort
307        '/ExtORPort',  # drops our ExtORPort
308        '/TransPort',  # drops a port we didn't originally have
309        '+ControlPort', '9005',  # appends a ControlPort where we didn't have any before
310      ]
311
312      output = run_tor(tor_cmd, '-f', torrc_path, '--dump-config', 'short', *config_args)
313      result = [line for line in output.splitlines() if not line.startswith('DataDirectory')]
314
315      expected = [
316        'ControlPort 9005',
317        'ExitPolicy reject *:*',
318        'Nickname stemIntegTest',
319        'PublishServerDescriptor 0',
320        'SocksPort 9089',
321        'SocksPort 9090',
322      ]
323
324      assert_equal(expected, result)
325
326  @asynchronous
327  def test_torrc_arguments_via_stdin(tor_cmd):
328    """
329    Pass configuration options via stdin.
330    """
331
332    if test.tor_version() < stem.version.Requirement.TORRC_VIA_STDIN:
333      skip('(requires %s)' % stem.version.Requirement.TORRC_VIA_STDIN)
334
335    with tmp_directory() as data_directory:
336      torrc = BASIC_RELAY_TORRC % data_directory
337      output = run_tor(tor_cmd, '-f', '-', '--dump-config', 'short', stdin = torrc)
338      assert_equal(sorted(torrc.splitlines()), sorted(output.splitlines()))
339
340  @asynchronous
341  def test_with_missing_torrc(tor_cmd):
342    """
343    Provide a torrc path that doesn't exist.
344    """
345
346    output = run_tor(tor_cmd, '-f', '/path/that/really/shouldnt/exist', '--verify-config', expect_failure = True)
347    assert_in('[warn] Unable to open configuration file "/path/that/really/shouldnt/exist".', output, 'Tor should refuse to read a non-existant torrc file')
348
349    output = run_tor(tor_cmd, '-f', '/path/that/really/shouldnt/exist', '--verify-config', '--ignore-missing-torrc')
350    assert_in('[notice] Configuration file "/path/that/really/shouldnt/exist" not present, using reasonable defaults.', output, 'Missing torrc should be allowed with --ignore-missing-torrc')
351
352  @asynchronous
353  def test_unanonymous_hidden_service_config_must_match(tor_cmd):
354    """
355    Checking that startup fails if HiddenServiceNonAnonymousMode and
356    HiddenServiceSingleHopMode don't match.
357    """
358
359    try:
360      stem.process.launch_tor_with_config(
361        tor_cmd = tor_cmd,
362        config = {'HiddenServiceNonAnonymousMode': '1'},
363      )
364
365      raise AssertionError("Tor shouldn't start with 'HiddenServiceNonAnonymousMode' set but not 'HiddenServiceSingleHopMode'")
366    except OSError as exc:
367      if test.tor_version() >= stem.version.Requirement.ADD_ONION_NON_ANONYMOUS:
368        assert_equal('Process terminated: HiddenServiceNonAnonymousMode does not provide any server anonymity. It must be used with HiddenServiceSingleHopMode set to 1.', str(exc))
369      else:
370        assert_equal("Process terminated: Unknown option 'HiddenServiceNonAnonymousMode'.  Failing.", str(exc))
371
372    try:
373      stem.process.launch_tor_with_config(
374        tor_cmd = tor_cmd,
375        config = {'HiddenServiceSingleHopMode': '1'},
376      )
377
378      raise AssertionError("Tor shouldn't start with 'HiddenServiceSingleHopMode' set but not 'HiddenServiceNonAnonymousMode'")
379    except OSError as exc:
380      if test.tor_version() >= stem.version.Requirement.ADD_ONION_NON_ANONYMOUS:
381        assert_equal('Process terminated: HiddenServiceSingleHopMode does not provide any server anonymity. It must be used with HiddenServiceNonAnonymousMode set to 1.', str(exc))
382      else:
383        assert_equal("Process terminated: Unknown option 'HiddenServiceSingleHopMode'.  Failing.", str(exc))
384
385  @asynchronous
386  def test_can_run_multithreaded(tor_cmd):
387    """
388    Our launch_tor() function uses signal to support its timeout argument.
389    This only works in the main thread so ensure we give a useful message when
390    it isn't.
391    """
392
393    with tmp_directory() as data_directory:
394      # Tries running tor in another thread with the given timeout argument. This
395      # issues an invalid torrc so we terminate right away if we get to the point
396      # of actually invoking tor.
397      #
398      # Returns None if launching tor is successful, and otherwise returns the
399      # exception we raised.
400
401      def launch_async_with_timeout(timeout_arg):
402        raised_exc = [None]
403
404        def short_launch():
405          try:
406            stem.process.launch_tor_with_config(
407              tor_cmd = tor_cmd,
408              config = {
409                'SocksPort': 'invalid',
410                'DataDirectory': data_directory,
411              },
412              completion_percent = 100,
413              timeout = timeout_arg,
414            )
415          except Exception as exc:
416            raised_exc[0] = exc
417
418        t = threading.Thread(target = short_launch)
419        t.start()
420        t.join()
421
422        if 'Invalid SocksPort' in str(raised_exc[0]):
423          return None  # got to the point of invoking tor
424        else:
425          return raised_exc[0]
426
427      exc = launch_async_with_timeout(0.5)
428      assert_equal(OSError, type(exc))
429      assert_equal('Launching tor with a timeout can only be done in the main thread', str(exc))
430
431      # We should launch successfully if no timeout is specified or we specify it
432      # to be 'None'.
433
434      if launch_async_with_timeout(None) is not None:
435        raise AssertionError('Launching tor without a timeout should be successful')
436
437      if launch_async_with_timeout(stem.process.DEFAULT_INIT_TIMEOUT) is not None:
438        raise AssertionError('Launching tor with the default timeout should be successful')
439
440  @asynchronous
441  def test_launch_tor_with_config_via_file(tor_cmd):
442    """
443    Exercises launch_tor_with_config when we write a torrc to disk.
444    """
445
446    with tmp_directory() as data_directory:
447      control_port = random_port()
448      control_socket, tor_process = None, None
449
450      try:
451        # Launch tor without a torrc, but with a control port. Confirms that this
452        # works by checking that we're still able to access the new instance.
453
454        with patch('stem.version.get_system_tor_version', Mock(return_value = stem.version.Version('0.0.0.1'))):
455          tor_process = stem.process.launch_tor_with_config(
456            tor_cmd = tor_cmd,
457            config = {
458              'SocksPort': random_port(),
459              'ControlPort': control_port,
460              'DataDirectory': data_directory,
461            },
462            completion_percent = 0
463          )
464
465        control_socket = stem.socket.ControlPort(port = int(control_port))
466        stem.connection.authenticate(control_socket)
467
468        # exercises the socket
469        control_socket.send('GETCONF ControlPort')
470        getconf_response = control_socket.recv()
471
472        assert_equal('ControlPort=%s' % control_port, str(getconf_response))
473      finally:
474        if control_socket:
475          control_socket.close()
476
477        if tor_process:
478          tor_process.kill()
479          tor_process.wait()
480
481  @asynchronous
482  def test_launch_tor_with_config_via_stdin(tor_cmd):
483    """
484    Exercises launch_tor_with_config when we provide our torrc via stdin.
485    """
486
487    if test.tor_version() < stem.version.Requirement.TORRC_VIA_STDIN:
488      skip('(requires %s)' % stem.version.Requirement.TORRC_VIA_STDIN)
489
490    with tmp_directory() as data_directory:
491      control_port = random_port()
492      control_socket, tor_process = None, None
493
494      try:
495        tor_process = stem.process.launch_tor_with_config(
496          tor_cmd = tor_cmd,
497          config = {
498            'SocksPort': random_port(),
499            'ControlPort': control_port,
500            'DataDirectory': data_directory,
501          },
502          completion_percent = 0
503        )
504
505        control_socket = stem.socket.ControlPort(port = int(control_port))
506        stem.connection.authenticate(control_socket)
507
508        # exercises the socket
509        control_socket.send('GETCONF ControlPort')
510        getconf_response = control_socket.recv()
511
512        assert_equal('ControlPort=%s' % control_port, str(getconf_response))
513      finally:
514        if control_socket:
515          control_socket.close()
516
517        if tor_process:
518          tor_process.kill()
519          tor_process.wait()
520
521  @asynchronous
522  def test_with_invalid_config(tor_cmd):
523    """
524    Spawn a tor process with a configuration that should make it dead on arrival.
525    """
526
527    # Set the same SocksPort and ControlPort, this should fail with...
528    #
529    #   [warn] Failed to parse/validate config: Failed to bind one of the listener ports.
530    #   [err] Reading config failed--see warnings above.
531
532    with tmp_directory() as data_directory:
533      both_ports = random_port()
534
535      try:
536        stem.process.launch_tor_with_config(
537          tor_cmd = tor_cmd,
538          config = {
539            'SocksPort': both_ports,
540            'ControlPort': both_ports,
541            'DataDirectory': data_directory,
542          },
543        )
544
545        raise AssertionError('Tor should fail to launch')
546      except OSError as exc:
547        assert_equal('Process terminated: Failed to bind one of the listener ports.', str(exc))
548
549  def test_launch_tor_with_timeout(self):
550    """
551    Runs launch_tor where it times out before completing.
552    """
553
554    with tmp_directory() as data_directory:
555      start_time = time.time()
556
557      try:
558        stem.process.launch_tor_with_config(
559          tor_cmd = TOR_CMD,
560          timeout = 0.05,
561          config = {
562            'SocksPort': random_port(),
563            'DataDirectory': data_directory,
564          },
565        )
566
567        raise AssertionError('Tor should fail to launch')
568      except OSError:
569        runtime = time.time() - start_time
570
571        if not (runtime > 0.05 and runtime < 3):
572          raise AssertionError('Test should have taken 0.05-3 seconds, took %0.1f instead' % runtime)
573
574  @asynchronous
575  def test_take_ownership_via_pid(tor_cmd):
576    """
577    Checks that the tor process quits after we do if we set take_ownership. To
578    test this we spawn a process and trick tor into thinking that it is us.
579    """
580
581    if not stem.util.system.is_available('sleep'):
582      skip('(sleep unavailable)')
583    elif test.tor_version() < stem.version.Requirement.TAKEOWNERSHIP:
584      skip('(requires %s)' % stem.version.Requirement.TAKEOWNERSHIP)
585
586    with tmp_directory() as data_directory:
587      sleep_process = subprocess.Popen(['sleep', '60'])
588
589      tor_process = stem.process.launch_tor_with_config(
590        tor_cmd = tor_cmd,
591        config = {
592          'SocksPort': random_port(),
593          'ControlPort': random_port(),
594          'DataDirectory': data_directory,
595          '__OwningControllerProcess': str(sleep_process.pid),
596        },
597        completion_percent = 0,
598      )
599
600      # Kill the sleep command. Tor should quit shortly after.
601
602      sleep_process.kill()
603      sleep_process.communicate()
604
605      # tor polls for the process every fifteen seconds so this may take a
606      # while...
607      #
608      #   https://trac.torproject.org/projects/tor/ticket/21281
609
610      start_time = time.time()
611
612      while time.time() - start_time < 30:
613        if tor_process.poll() == 0:
614          return  # tor exited
615
616        time.sleep(0.01)
617
618      raise AssertionError("tor didn't quit after the process that owned it terminated")
619
620  @asynchronous
621  def test_take_ownership_via_controller(tor_cmd):
622    """
623    Checks that the tor process quits after the controller that owns it
624    connects, then disconnects..
625    """
626
627    if test.tor_version() < stem.version.Requirement.TAKEOWNERSHIP:
628      skip('(requires %s)' % stem.version.Requirement.TAKEOWNERSHIP)
629
630    with tmp_directory() as data_directory:
631      control_port = random_port()
632
633      tor_process = stem.process.launch_tor_with_config(
634        tor_cmd = tor_cmd,
635        config = {
636          'SocksPort': random_port(),
637          'ControlPort': control_port,
638          'DataDirectory': data_directory,
639        },
640        completion_percent = 0,
641        take_ownership = True,
642      )
643
644      # We're the controlling process. Just need to connect then disconnect.
645
646      controller = stem.control.Controller.from_port(port = int(control_port))
647      controller.authenticate()
648      controller.close()
649
650      # give tor a few seconds to quit
651      start_time = time.time()
652
653      while time.time() - start_time < 5:
654        if tor_process.poll() == 0:
655          return  # tor exited
656
657        time.sleep(0.01)
658
659      raise AssertionError("tor didn't quit after the controller that owned it disconnected")
660