Browse Source

Changes to support multiple servers

Steven Engler 4 years ago
parent
commit
2d75fbf6d7
3 changed files with 795 additions and 20 deletions
  1. 788 20
      lib/chutney/TorNet.py
  2. 2 0
      tools/test-network-impl.sh
  3. 5 0
      torrc_templates/common.i

+ 788 - 20
lib/chutney/TorNet.py

@@ -167,10 +167,15 @@ def _warnMissingTor(tor_path, cmdline, tor_name="tor"):
     """Log a warning that the binary tor_name can't be found at tor_path
     """Log a warning that the binary tor_name can't be found at tor_path
        while running cmdline.
        while running cmdline.
     """
     """
-    print(("Cannot find the {} binary at '{}' for the command line '{}'. " +
-           "Set the TOR_DIR environment variable to the directory " +
-           "containing {}.")
-          .format(tor_name, tor_path, " ".join(cmdline), tor_name))
+    help_msg_fmt = "Set the '{}' environment variable to the directory containing '{}'."
+    if tor_name == "tor":
+        help_msg = help_msg_fmt.format("CHUTNEY_TOR", tor_name)
+    elif tor_name == "tor-gencert":
+        help_msg = help_msg_fmt.format("CHUTNEY_TOR_GENCERT", tor_name)
+    else:
+        help_msg = ""
+    print(("Cannot find the {} binary at '{}' for the command line '{}'. {}")
+          .format(tor_name, tor_path, " ".join(cmdline), help_msg))
 
 
 def run_tor(cmdline, exit_on_missing=True):
 def run_tor(cmdline, exit_on_missing=True):
     """Run the tor command line cmdline, which must start with the path or
     """Run the tor command line cmdline, which must start with the path or
@@ -257,7 +262,7 @@ def run_tor_gencert(cmdline, passphrase):
                         tor_name="tor-gencert",
                         tor_name="tor-gencert",
                         stdin=subprocess.PIPE)
                         stdin=subprocess.PIPE)
     (stdouterr, empty_stderr) = p.communicate(passphrase + "\n")
     (stdouterr, empty_stderr) = p.communicate(passphrase + "\n")
-    debug(stdouterr)
+    print(stdouterr)
     assert p.returncode == 0  # XXXX BAD!
     assert p.returncode == 0  # XXXX BAD!
     assert empty_stderr is None
     assert empty_stderr is None
     return stdouterr
     return stdouterr
@@ -282,14 +287,19 @@ def tor_gencert_exists(gencert):
         return False
         return False
 
 
 @chutney.Util.memoized
 @chutney.Util.memoized
-def get_tor_version(tor):
+def get_tor_version(tor, remote_hostname=None):
     """Return the version of the tor binary.
     """Return the version of the tor binary.
        Versions are cached for each unique tor path.
        Versions are cached for each unique tor path.
     """
     """
-    cmdline = [
+    cmdline = []
+
+    if remote_hostname != None:
+        cmdline.extend(['ssh', remote_hostname])
+
+    cmdline.extend([
         tor,
         tor,
         "--version",
         "--version",
-    ]
+    ])
     tor_version = run_tor(cmdline)
     tor_version = run_tor(cmdline)
     # clean it up a bit
     # clean it up a bit
     tor_version = tor_version.strip()
     tor_version = tor_version.strip()
@@ -301,14 +311,19 @@ def get_tor_version(tor):
     return tor_version
     return tor_version
 
 
 @chutney.Util.memoized
 @chutney.Util.memoized
-def get_torrc_options(tor):
+def get_torrc_options(tor, remote_hostname=None):
     """Return the torrc options supported by the tor binary.
     """Return the torrc options supported by the tor binary.
        Options are cached for each unique tor path.
        Options are cached for each unique tor path.
     """
     """
-    cmdline = [
+    cmdline = []
+
+    if remote_hostname != None:
+        cmdline.extend(['ssh', remote_hostname])
+
+    cmdline.extend([
         tor,
         tor,
         "--list-torrc-options",
         "--list-torrc-options",
-    ]
+    ])
     opts = run_tor(cmdline)
     opts = run_tor(cmdline)
     # check we received a list of options, and nothing else
     # check we received a list of options, and nothing else
     assert re.match(r'(^\w+$)+', opts, flags=re.MULTILINE)
     assert re.match(r'(^\w+$)+', opts, flags=re.MULTILINE)
@@ -397,7 +412,10 @@ class Node(object):
            node can be run by a NodeController).
            node can be run by a NodeController).
         """
         """
         if self._builder is None:
         if self._builder is None:
-            self._builder = LocalNodeBuilder(self._env)
+            if self._env['remote_hostname'] != None:
+                self._builder = RemoteNodeBuilder(self._env)
+            else:
+                self._builder = LocalNodeBuilder(self._env)
         return self._builder
         return self._builder
 
 
     def getController(self):
     def getController(self):
@@ -405,7 +423,10 @@ class Node(object):
            to start it, stop it, see if it's running, etc.)
            to start it, stop it, see if it's running, etc.)
         """
         """
         if self._controller is None:
         if self._controller is None:
-            self._controller = LocalNodeController(self._env)
+            if self._env['remote_hostname'] != None:
+                self._controller = RemoteNodeController(self._env)
+            else:
+                self._controller = LocalNodeController(self._env)
         return self._controller
         return self._controller
 
 
     def setNodenum(self, num):
     def setNodenum(self, num):
@@ -502,11 +523,423 @@ class NodeController(_NodeCommon):
         """Try to start this node; return True if we succeeded or it was
         """Try to start this node; return True if we succeeded or it was
            already running, False if we failed."""
            already running, False if we failed."""
 
 
-    def stop(self, sig=signal.SIGINT):
-        """Try to stop this node by sending it the signal 'sig'."""
+    def stop(self, sig=signal.SIGINT):
+        """Try to stop this node by sending it the signal 'sig'."""
+
+
+class LocalNodeBuilder(NodeBuilder):
+
+    # Environment members used:
+    # torrc -- which torrc file to use
+    # torrc_template_path -- path to search for torrc files and include files
+    # authority -- bool -- are we an authority?
+    # bridgeauthority -- bool -- are we a bridge authority?
+    # relay -- bool -- are we a relay?
+    # bridge -- bool -- are we a bridge?
+    # hs -- bool -- are we a hidden service?
+    # nodenum -- int -- set by chutney -- which unique node index is this?
+    # dir -- path -- set by chutney -- data directory for this tor
+    # tor_gencert -- path to tor_gencert binary
+    # tor -- path to tor binary
+    # auth_cert_lifetime -- lifetime of authority certs, in months.
+    # ip -- primary IP address (usually IPv4) to listen on
+    # ipv6_addr -- secondary IP address (usually IPv6) to listen on
+    # orport, dirport -- used on authorities, relays, and bridges. The orport
+    #                    is used for both IPv4 and IPv6, if present
+    # fingerprint -- used only if authority
+    # dirserver_flags -- used only if authority
+    # nick -- nickname of this router
+
+    # Environment members set
+    # fingerprint -- hex router key fingerprint
+    # nodenum -- int -- set by chutney -- which unique node index is this?
+
+    def __init__(self, env):
+        NodeBuilder.__init__(self, env)
+        self._env = env
+
+    def _createTorrcFile(self, checkOnly=False):
+        """Write the torrc file for this node, disabling any options
+           that are not supported by env's tor binary using comments.
+           If checkOnly, just make sure that the formatting is indeed
+           possible.
+        """
+        global torrc_option_warn_count
+
+        fn_out = self._getTorrcFname()
+        torrc_template = self._getTorrcTemplate()
+        output = torrc_template.format(self._env)
+        if checkOnly:
+            # XXXX Is it time-consuming to format? If so, cache here.
+            return
+        # now filter the options we're about to write, commenting out
+        # the options that the current tor binary doesn't support
+        tor = self._env['tor']
+        tor_version = get_tor_version(tor)
+        torrc_opts = get_torrc_options(tor)
+        # check if each option is supported before writing it
+        # Unsupported option values may need special handling.
+        with open(fn_out, 'w') as f:
+            # we need to do case-insensitive option comparison
+            lower_opts = [opt.lower() for opt in torrc_opts]
+            # keep ends when splitting lines, so we can write them out
+            # using writelines() without messing around with "\n"s
+            for line in output.splitlines(True):
+                # check if the first word on the line is a supported option,
+                # preserving empty lines and comment lines
+                sline = line.strip()
+                if (len(sline) == 0 or
+                        sline[0] == '#' or
+                        sline.split()[0].lower() in lower_opts):
+                    pass
+                else:
+                    warn_msg = (("The tor binary at {} does not support " +
+                                "the option in the torrc line:\n{}")
+                                .format(tor, line.strip()))
+                    if torrc_option_warn_count < TORRC_OPTION_WARN_LIMIT:
+                        print(warn_msg)
+                        torrc_option_warn_count += 1
+                    else:
+                        debug(warn_msg)
+                    # always dump the full output to the torrc file
+                    line = ("# {} version {} does not support: {}"
+                            .format(tor, tor_version, line))
+                f.writelines([line])
+
+    def _getTorrcTemplate(self):
+        """Return the template used to write the torrc for this node."""
+        template_path = self._env['torrc_template_path']
+        return chutney.Templating.Template("$${include:$torrc}",
+                                           includePath=template_path)
+
+    def _getFreeVars(self):
+        """Return a set of the free variables in the torrc template for this
+           node.
+        """
+        template = self._getTorrcTemplate()
+        return template.freevars(self._env)
+
+    def checkConfig(self, net):
+        """Try to format our torrc; raise an exception if we can't.
+        """
+        self._createTorrcFile(checkOnly=True)
+
+    def preConfig(self, net):
+        """Called on all nodes before any nodes configure: generates keys and
+           hidden service directories as needed.
+        """
+        self._makeDataDir()
+        if self._env['authority']:
+            self._genAuthorityKey()
+        if self._env['relay']:
+            self._genRouterKey()
+        if self._env['hs']:
+            self._makeHiddenServiceDir()
+
+    def config(self, net):
+        """Called to configure a node: creates a torrc file for it."""
+        self._createTorrcFile()
+        # self._createScripts()
+
+    def postConfig(self, net):
+        """Called on each nodes after all nodes configure."""
+        # self.net.addNode(self)
+        pass
+
+    def isSupported(self, net):
+        """Return true if this node appears to have everything it needs;
+           false otherwise."""
+
+        if not tor_exists(self._env['tor']):
+            print("No binary found for %r"%self._env['tor'])
+            return False
+
+        if self._env['authority']:
+            if not tor_has_module(self._env['tor'], "dirauth"):
+                print("No dirauth support in %r"%self._env['tor'])
+                return False
+            if not tor_gencert_exists(self._env['tor-gencert']):
+                print("No binary found for tor-gencert %r"%self._env['tor-gencrrt'])
+
+    def _makeDataDir(self):
+        """Create the data directory (with keys subdirectory) for this node.
+        """
+        datadir = self._env['dir']
+        make_datadir_subdirectory(datadir, "keys")
+
+    def _makeHiddenServiceDir(self):
+        """Create the hidden service subdirectory for this node.
+
+          The directory name is stored under the 'hs_directory' environment
+          key. It is combined with the 'dir' data directory key to yield the
+          path to the hidden service directory.
+        """
+        datadir = self._env['dir']
+        make_datadir_subdirectory(datadir, self._env['hs_directory'])
+
+    def _genAuthorityKey(self):
+        """Generate an authority identity and signing key for this authority,
+           if they do not already exist."""
+        datadir = self._env['dir']
+        tor_gencert = self._env['tor_gencert']
+        lifetime = self._env['auth_cert_lifetime']
+        idfile = os.path.join(datadir, 'keys', "authority_identity_key")
+        skfile = os.path.join(datadir, 'keys', "authority_signing_key")
+        certfile = os.path.join(datadir, 'keys', "authority_certificate")
+        addr = self.expand("${ip}:${dirport}")
+        passphrase = self._env['auth_passphrase']
+        if all(os.path.exists(f) for f in [idfile, skfile, certfile]):
+            return
+        cmdline = [
+            tor_gencert,
+            '--create-identity-key',
+            '--passphrase-fd', '0',
+            '-i', idfile,
+            '-s', skfile,
+            '-c', certfile,
+            '-m', str(lifetime),
+            '-a', addr,
+            ]
+        # nicknames are testNNNaa[OLD], but we want them to look tidy
+        print("Creating identity key for {:12} with {}"
+              .format(self._env['nick'], cmdline[0]))
+        debug("Identity key path '{}', command '{}'"
+              .format(idfile, " ".join(cmdline)))
+        run_tor_gencert(cmdline, passphrase)
+
+    def _genRouterKey(self):
+        """Generate an identity key for this router, unless we already have,
+           and set up the 'fingerprint' entry in the Environ.
+        """
+        datadir = self._env['dir']
+        tor = self._env['tor']
+        torrc = self._getTorrcFname()
+        cmdline = [
+            tor,
+            "--ignore-missing-torrc",
+            "-f", torrc,
+            "--list-fingerprint",
+            "--orport", "1",
+            "--datadirectory", datadir,
+            ]
+        stdouterr = run_tor(cmdline)
+        fingerprint = "".join((stdouterr.rstrip().split('\n')[-1]).split()[1:])
+        if not re.match(r'^[A-F0-9]{40}$', fingerprint):
+            print("Error when getting fingerprint using '%r'. It output '%r'."
+                  .format(" ".join(cmdline), stdouterr))
+            sys.exit(1)
+        self._env['fingerprint'] = fingerprint
+
+    def _getAltAuthLines(self, hasbridgeauth=False):
+        """Return a combination of AlternateDirAuthority,
+        and AlternateBridgeAuthority lines for
+        this Node, appropriately.  Non-authorities return ""."""
+        if not self._env['authority']:
+            return ""
+
+        datadir = self._env['dir']
+        certfile = os.path.join(datadir, 'keys', "authority_certificate")
+        v3id = None
+        with open(certfile, 'r') as f:
+            for line in f:
+                if line.startswith("fingerprint"):
+                    v3id = line.split()[1].strip()
+                    break
+
+        assert v3id is not None
+
+        if self._env['bridgeauthority']:
+            # Bridge authorities return AlternateBridgeAuthority with
+            # the 'bridge' flag set.
+            options = ("AlternateBridgeAuthority",)
+            self._env['dirserver_flags'] += " bridge"
+        else:
+            # Directory authorities return AlternateDirAuthority with
+            # the 'v3ident' flag set.
+            # XXXX This next line is needed for 'bridges' but breaks
+            # 'basic'
+            if hasbridgeauth:
+                options = ("AlternateDirAuthority",)
+            else:
+                options = ("DirAuthority",)
+            self._env['dirserver_flags'] += " v3ident=%s" % v3id
+
+        authlines = ""
+        for authopt in options:
+            authlines += "%s %s orport=%s" % (
+                authopt, self._env['nick'], self._env['orport'])
+            # It's ok to give an authority's IPv6 address to an IPv4-only
+            # client or relay: it will and must ignore it
+            # and yes, the orport is the same on IPv4 and IPv6
+            if self._env['ipv6_addr'] is not None:
+                authlines += " ipv6=%s:%s" % (self._env['ipv6_addr'],
+                                              self._env['orport'])
+            authlines += " %s %s:%s %s\n" % (
+                self._env['dirserver_flags'], self._env['ip'],
+                self._env['dirport'], self._env['fingerprint'])
+        return authlines
+
+    def _getBridgeLines(self):
+        """Return potential Bridge line for this Node. Non-bridge
+        relays return "".
+        """
+        if not self._env['bridge']:
+            return ""
+
+        if self._env['pt_bridge']:
+            port = self._env['ptport']
+            transport = self._env['pt_transport']
+            extra = self._env['pt_extra']
+        else:
+            # the orport is the same on IPv4 and IPv6
+            port = self._env['orport']
+            transport = ""
+            extra = ""
+
+        BRIDGE_LINE_TEMPLATE = "Bridge %s %s:%s %s %s\n"
+
+        bridgelines = BRIDGE_LINE_TEMPLATE % (transport,
+                                              self._env['ip'],
+                                              port,
+                                              self._env['fingerprint'],
+                                              extra)
+        if self._env['ipv6_addr'] is not None:
+            bridgelines += BRIDGE_LINE_TEMPLATE % (transport,
+                                                   self._env['ipv6_addr'],
+                                                   port,
+                                                   self._env['fingerprint'],
+                                                   extra)
+        return bridgelines
 
 
 
 
-class LocalNodeBuilder(NodeBuilder):
+def scp_file(abs_filepath, host):
+    if not os.path.isabs(abs_filepath) or abs_filepath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('SCP path must be absolute and must be in /tmp')
+    assert(':' not in host)
+    assert(':' not in abs_filepath)
+    remote_filepath = os.path.dirname(abs_filepath)
+    cmd = ['scp', abs_filepath, ':'.join([host, remote_filepath])]
+    print('Transferring file: {}'.format(cmd))
+    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+
+def scp_dir(abs_dirpath, host):
+    if not os.path.isabs(abs_dirpath) or abs_dirpath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('SCP path must be absolute and must be in /tmp')
+    assert(':' not in host)
+    assert(':' not in abs_dirpath)
+    remote_dirpath = os.path.dirname(abs_dirpath)
+    cmd = ['scp', '-r', abs_dirpath, ':'.join([host, remote_dirpath])]
+    print('Transferring files: {}'.format(cmd))
+    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+
+def scp_dir_backwards(abs_dirpath, host):
+    if not os.path.isabs(abs_dirpath) or abs_dirpath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('SCP path must be absolute and must be in /tmp')
+    assert(':' not in host)
+    assert(':' not in abs_dirpath)
+    remote_dirpath = os.path.dirname(abs_dirpath)
+    cmd = ['scp', '-r', ':'.join([host, abs_dirpath]), remote_dirpath]
+    print('Transferring files backwards: {}'.format(cmd))
+    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+
+def ssh_mkdir_p(abs_dirpath, remote_hostname):
+    if not os.path.isabs(abs_dirpath) or abs_dirpath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('Path must be absolute and must be in /tmp')
+    cmd = ['ssh', remote_hostname, 'mkdir', '-p', abs_dirpath]
+    print('Making directory: {}'.format(cmd))
+    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+
+def ssh_rm_if_exists(abs_dirpath, remote_hostname):
+    if not os.path.isabs(abs_dirpath) or abs_dirpath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('Path must be absolute and must be in /tmp')
+    cmd = ['ssh', remote_hostname, 'rm', '-f', abs_dirpath]
+    print('Removing: {}'.format(cmd))
+    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+
+def ssh_file_exists(abs_filepath, remote_hostname):
+    if not os.path.isabs(abs_filepath) or abs_filepath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('Path must be absolute and must be in /tmp')
+    assert('"' not in abs_filepath)
+    assert('$' not in abs_filepath)
+    assert('\\' not in abs_filepath)
+    cmd = ['ssh', remote_hostname, '[ -f "{}" ] && exit 0 || exit 99'.format(abs_filepath)]
+    print('Checking file existence: {}'.format(cmd))
+    try:
+        subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+        if e.returncode == 99:
+            return False
+        raise
+    return True
+
+def ssh_symlink(abs_dirpath, abs_linkpath, remote_hostname):
+    if not os.path.isabs(abs_dirpath) or abs_dirpath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('Path must be absolute and must be in /tmp')
+    if not os.path.isabs(abs_linkpath) or abs_linkpath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('Link must be absolute and must be in /tmp')
+    cmd = ['ssh', remote_hostname, 'ln', '-s', abs_dirpath, abs_linkpath]
+    print('Making link: {}'.format(cmd))
+    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+
+def ssh_read_file(abs_filepath, remote_hostname):
+    if not os.path.isabs(abs_filepath) or abs_filepath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('Path must be absolute and must be in /tmp')
+    if not ssh_file_exists(abs_filepath, remote_hostname):
+        return None
+    cmd = ['ssh', remote_hostname, 'cat', abs_filepath]
+    print('Reading file: {}'.format(cmd))
+    try:
+        return subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+        # although something else may have gone wrong, we'll assume the file doesn't exist
+        print('File existed, but now returns an error; assuming it no longer exists')
+        return None
+
+def ssh_kill(pid, code, remote_hostname):
+    assert pid > 1
+    cmd = ['ssh', remote_hostname, 'kill', '-s', str(code), str(pid)]
+    print('Sending signal: {}'.format(cmd))
+    try:
+        subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+        if e.returncode != 1:
+            # the process might not exist
+            return False
+    return True
+
+def ssh_grep(pattern, abs_filepath, remote_hostname):
+    if not os.path.isabs(abs_filepath) or abs_filepath[0:5] != '/tmp/':
+        # this check for '/tmp' is in no way secure, but helps prevent me from shooting
+        # myself in the foot
+        raise Exception('Path must be absolute and must be in /tmp')
+    cmd = ['ssh', remote_hostname, 'egrep', '"{}"'.format(pattern), abs_filepath]
+    #print('Text search: {}'.format(cmd))
+    try:
+        return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('utf-8')
+    except subprocess.CalledProcessError as e:
+        if e.returncode == 1:
+            # egrep will exit with 1 if it doesn't find anything
+            return ''
+
+class RemoteNodeBuilder(NodeBuilder):
 
 
     # Environment members used:
     # Environment members used:
     # torrc -- which torrc file to use
     # torrc -- which torrc file to use
@@ -554,8 +987,8 @@ class LocalNodeBuilder(NodeBuilder):
         # now filter the options we're about to write, commenting out
         # now filter the options we're about to write, commenting out
         # the options that the current tor binary doesn't support
         # the options that the current tor binary doesn't support
         tor = self._env['tor']
         tor = self._env['tor']
-        tor_version = get_tor_version(tor)
-        torrc_opts = get_torrc_options(tor)
+        tor_version = get_tor_version(tor, remote_hostname=self._env['remote_hostname'])
+        torrc_opts = get_torrc_options(tor, remote_hostname=self._env['remote_hostname'])
         # check if each option is supported before writing it
         # check if each option is supported before writing it
         # Unsupported option values may need special handling.
         # Unsupported option values may need special handling.
         with open(fn_out, 'w') as f:
         with open(fn_out, 'w') as f:
@@ -623,7 +1056,8 @@ class LocalNodeBuilder(NodeBuilder):
     def postConfig(self, net):
     def postConfig(self, net):
         """Called on each nodes after all nodes configure."""
         """Called on each nodes after all nodes configure."""
         # self.net.addNode(self)
         # self.net.addNode(self)
-        pass
+        scp_dir(os.path.abspath(self._env['dir']), self._env['remote_hostname'])
+        shutil.rmtree(self._env['dir'])
 
 
     def isSupported(self, net):
     def isSupported(self, net):
         """Return true if this node appears to have everything it needs;
         """Return true if this node appears to have everything it needs;
@@ -811,7 +1245,10 @@ class LocalNodeController(NodeController):
             return None
             return None
 
 
         with open(pidfile, 'r') as f:
         with open(pidfile, 'r') as f:
-            return int(f.read())
+            try:
+                return int(f.read())
+            except ValueError:
+                return None
 
 
     def isRunning(self, pid=None):
     def isRunning(self, pid=None):
         """Return true iff this node is running.  (If 'pid' is provided, we
         """Return true iff this node is running.  (If 'pid' is provided, we
@@ -904,6 +1341,8 @@ class LocalNodeController(NodeController):
             add_environ_vars = add_environ_vars.copy()
             add_environ_vars = add_environ_vars.copy()
         #
         #
         if self._env['google_cpu_profiler'] is True:
         if self._env['google_cpu_profiler'] is True:
+            if add_environ_vars is None:
+                add_environ_vars = {}
             add_environ_vars['CPUPROFILE'] = os.path.join(self._env['dir'], 'cpu-prof.out')
             add_environ_vars['CPUPROFILE'] = os.path.join(self._env['dir'], 'cpu-prof.out')
         #
         #
         cmdline.extend([
         cmdline.extend([
@@ -1025,6 +1464,303 @@ class LocalNodeController(NodeController):
         pct, _, _ = self.getLastBootstrapStatus()
         pct, _, _ = self.getLastBootstrapStatus()
         return pct == 100
         return pct == 100
 
 
+    def getRemoteFiles(self):
+        pass
+
+class RemoteNodeController(NodeController):
+
+    def __init__(self, env):
+        NodeController.__init__(self, env)
+        self._env = env
+
+    def getNick(self):
+        """Return the nickname for this node."""
+        return self._env['nick']
+
+    def getPid(self):
+        """Assuming that this node has its pidfile in ${dir}/pid, return
+           the pid of the running process, or None if there is no pid in the
+           file.
+        """
+        pidfile = os.path.join(self._env['dir'], 'pid')
+        if self._env['remote_hostname'] is None:
+            if not os.path.exists(pidfile):
+                return None
+            with open(pidfile, 'r') as f:
+                try:
+                    return int(f.read())
+                except ValueError:
+                    return None
+        else:
+            pid = ssh_read_file(pidfile, self._env['remote_hostname'])
+            if pid is None:
+                return None
+            try:
+                return int(pid)
+            except ValueError:
+                return None
+
+    def isRunning(self, pid=None):
+        """Return true iff this node is running.  (If 'pid' is provided, we
+           assume that the pid provided is the one of this node.  Otherwise
+           we call getPid().
+        """
+        if pid is None:
+            pid = self.getPid()
+        if pid is None:
+            return False
+
+        if self._env['remote_hostname'] is None:
+            try:
+                os.kill(pid, 0)  # "kill 0" == "are you there?"
+            except OSError as e:
+                if e.errno == errno.ESRCH:
+                    return False
+                raise
+            # okay, so the process exists.  Say "True" for now.
+            # XXXX check if this is really tor!
+            return True
+        else:
+            return ssh_kill(pid, 0, self._env['remote_hostname'])
+
+    def check(self, listRunning=True, listNonRunning=False):
+        """See if this node is running, stopped, or crashed.  If it's running
+           and listRunning is set, print a short statement.  If it's
+           stopped and listNonRunning is set, then print a short statement.
+           If it's crashed, print a statement.  Return True if the
+           node is running, false otherwise.
+        """
+        # XXX Split this into "check" and "print" parts.
+        pid = self.getPid()
+        nick = self._env['nick']
+        datadir = self._env['dir']
+        corefile = os.path.join(datadir, "core.%s" % pid)
+        tor_version = get_tor_version(self._env['tor'], remote_hostname=self._env['remote_hostname'])
+
+        hostname_help_str = ''
+        if self._env['remote_hostname'] is not None:
+            hostname_help_str = ' on \'{}\''.format(self._env['remote_hostname'])
+
+        if self._env['remote_hostname'] is None:
+            corefile_exists = os.path.exists(corefile)
+        else:
+            corefile_exists = ssh_file_exists(corefile, self._env['remote_hostname'])
+
+        if self.isRunning(pid):
+            if listRunning:
+                # PIDs are typically 65535 or less
+                print("{:12} is running with PID {:5}{}: {}"
+                      .format(nick, pid, hostname_help_str, tor_version))
+            return True
+        elif corefile_exists:
+            if listNonRunning:
+                print("{:12} seems to have crashed{}, and left core file {}: {}"
+                      .format(nick, hostname_help_str, corefile, tor_version))
+            return False
+        else:
+            if listNonRunning:
+                print("{:12} is stopped{}: {}"
+                      .format(nick, hostname_help_str, tor_version))
+            return False
+
+    def hup(self):
+        """Send a SIGHUP to this node, if it's running."""
+        pid = self.getPid()
+        nick = self._env['nick']
+        if self.isRunning(pid):
+            print("Sending sighup to {}".format(nick))
+            if self._env['remote_hostname'] is None:
+                os.kill(pid, signal.SIGHUP)
+            else:
+                ssh_kill(pid, int(signal.SIGHUP), self._env['remote_hostname'])
+            return True
+        else:
+            print("{:12} is not running".format(nick))
+            return False
+
+    def start(self):
+        """Try to start this node; return True if we succeeded or it was
+           already running, False if we failed."""
+
+        if self.isRunning():
+            print("{:12} is already running".format(self._env['nick']))
+            return True
+        tor_path = self._env['tor']
+        torrc = self._getTorrcFname()
+        #
+        add_environ_vars = self._env['add_environ_vars']
+        if add_environ_vars is not None:
+            add_environ_vars = add_environ_vars.copy()
+        #
+        if self._env['google_cpu_profiler'] is True:
+            if add_environ_vars is None:
+                add_environ_vars = {}
+            add_environ_vars['CPUPROFILE'] = os.path.join(self._env['dir'], 'cpu-prof.out')
+        #
+        cmdline = []
+        if self._env['remote_hostname'] is not None:
+            cmdline.extend(['ssh', self._env['remote_hostname']])
+            if add_environ_vars is not None:
+                for x in add_environ_vars:
+                    cmdline.extend(['{}={}'.format(x, add_environ_vars[x])])
+                #
+            #
+        #
+        if self._env['numa_settings'] is not None:
+            (numa_node, processors) = self._env['numa_settings']
+            cmdline.extend([
+                'numactl',
+                '--membind={}'.format(numa_node),
+                '--physcpubind={}'.format(','.join(map(str, processors))),
+                ])
+        #
+        if self._env['valgrind_settings'] is not None:
+            cmdline.append('valgrind')
+            cmdline.extend(self._env['valgrind_settings'])
+            cmdline.append('--log-file={}'.format(self._env['valgrind_log']))
+        #
+        cmdline.extend([
+            tor_path,
+            "-f", torrc,
+            ])
+        if self._env['remote_hostname'] is not None:
+            print('Starting tor with: {}'.format(cmdline))
+        p = launch_process(cmdline, add_environ_vars=add_environ_vars)
+        if self.waitOnLaunch():
+            # this requires that RunAsDaemon is set
+            (stdouterr, empty_stderr) = p.communicate()
+            debug(stdouterr)
+            assert empty_stderr is None
+        else:
+            # this does not require RunAsDaemon to be set, but is slower.
+            #
+            # poll() only catches failures before the call itself
+            # so let's sleep a little first
+            # this does, of course, slow down process launch
+            # which can require an adjustment to the voting interval
+            #
+            # avoid writing a newline or space when polling
+            # so output comes out neatly
+            sys.stdout.write('.')
+            sys.stdout.flush()
+            time.sleep(self._env['poll_launch_time'])
+            p.poll()
+        if p.returncode is not None and p.returncode != 0:
+            if self._env['poll_launch_time'] is None:
+                print(("Couldn't launch {:12} command '{}': " +
+                       "exit {}, output '{}'")
+                      .format(self._env['nick'],
+                              " ".join(cmdline),
+                              p.returncode,
+                              stdouterr))
+            else:
+                print(("Couldn't poll {:12} command '{}' " +
+                       "after waiting {} seconds for launch: " +
+                       "exit {}").format(self._env['nick'],
+                                         " ".join(cmdline),
+                                         self._env['poll_launch_time'],
+                                         p.returncode))
+            return False
+        return True
+
+    def stop(self, sig=signal.SIGINT):
+        """Try to stop this node by sending it the signal 'sig'."""
+        pid = self.getPid()
+        if not self.isRunning(pid):
+            print("{:12} is not running".format(self._env['nick']))
+            return
+        if self._env['remote_hostname'] is None:
+            os.kill(pid, sig)
+        else:
+            ssh_kill(pid, int(sig), self._env['remote_hostname'])
+
+    def cleanup_lockfile(self):
+        lf = self._env['lockfile']
+        if not self.isRunning() and os.path.exists(lf):
+            debug("Removing stale lock file for {} ..."
+                  .format(self._env['nick']))
+            os.remove(lf)
+
+    def waitOnLaunch(self):
+        """Check whether we can wait() for the tor process to launch"""
+        # TODO: is this the best place for this code?
+        # RunAsDaemon default is 0
+        runAsDaemon = self._env['daemon']
+        '''
+        with open(self._getTorrcFname(), 'r') as f:
+            for line in f.readlines():
+                stline = line.strip()
+                # if the line isn't all whitespace or blank
+                if len(stline) > 0:
+                    splline = stline.split()
+                    # if the line has at least two tokens on it
+                    if (len(splline) > 0 and
+                            splline[0].lower() == "RunAsDaemon".lower() and
+                            splline[1] == "1"):
+                        # use the RunAsDaemon value from the torrc
+                        # TODO: multiple values?
+                        runAsDaemon = True
+        '''
+        if runAsDaemon:
+            # we must use wait() instead of poll()
+            self._env['poll_launch_time'] = None
+            return True
+        else:
+            # we must use poll() instead of wait()
+            if self._env['poll_launch_time'] is None:
+                self._env['poll_launch_time'] = \
+                    self._env['poll_launch_time_default']
+            return False
+
+    def getLogfile(self, info=False):
+        """Return the expected path to the logfile for this instance."""
+        datadir = self._env['dir']
+        if info:
+            logname = "info.log"
+        else:
+            logname = "notice.log"
+        return os.path.join(datadir, logname)
+
+    def getLastBootstrapStatus(self):
+        """Look through the logs and return the last bootstrap message
+           received as a 3-tuple of percentage complete, keyword
+           (optional), and message.
+        """
+        logfname = self.getLogfile()
+
+        def find_bootstrap_messages(lines):
+            percent, keyword, message = -100, "no_message", "No bootstrap messages yet."
+            for line in lines:
+                m = re.search(r'Bootstrapped (\d+)%(?: \(([^\)]*)\))?: (.*)',
+                              line)
+                if m:
+                    percent, keyword, message = m.groups()
+                    percent = int(percent)
+            return (percent, keyword, message)
+
+        if self._env['remote_hostname'] is None:
+            if not os.path.exists(logfname):
+                return (-200, "no_logfile", "There is no logfile yet.")
+            with open(logfname, 'r') as f:
+                return find_bootstrap_messages(f)
+        else:
+            messages = ssh_grep("Bootstrapped [0-9]+%", logfname, self._env['remote_hostname']).split('\n')
+            if messages is None:
+                return (-200, "no_logfile", "There is no logfile yet.")
+            return find_bootstrap_messages(messages)
+
+    def isBootstrapped(self):
+        """Return true iff the logfile says that this instance is
+           bootstrapped."""
+        pct, _, _ = self.getLastBootstrapStatus()
+        return pct == 100
+
+    def getRemoteFiles(self):
+        if self._env['remote_hostname'] is not None:
+            path = os.path.abspath(self._env['dir'])
+            ssh_rm_if_exists(os.path.join(path, 'control'), self._env['remote_hostname'])
+            scp_dir_backwards(path, self._env['remote_hostname'])
+
 # XXX: document these options
 # XXX: document these options
 DEFAULTS = {
 DEFAULTS = {
     'authority': False,
     'authority': False,
@@ -1109,6 +1845,9 @@ DEFAULTS = {
     'add_environ_vars': None,
     'add_environ_vars': None,
     'log_files': ['notice', 'info', 'debug'],
     'log_files': ['notice', 'info', 'debug'],
     'google_cpu_profiler': False,
     'google_cpu_profiler': False,
+    'remote_hostname': None,
+    'num_additional_eventloops': None,
+    'log_throughput': False,
 }
 }
 
 
 
 
@@ -1242,6 +1981,20 @@ class TorEnviron(chutney.Templating.Environ):
             num_cpus_line = '#' + num_cpus_line
             num_cpus_line = '#' + num_cpus_line
         return num_cpus_line
         return num_cpus_line
 
 
+    def _get_num_additional_eventloops_line(self, my):
+        num = my['num_additional_eventloops']
+        line = 'NumAdditionalEventloops {}'.format(num)
+        if num is None:
+            line = '#' + line
+        return line
+
+    def _get_throughput_log_file_line(self, my):
+        line = 'ThroughputLogFile {}'.format(os.path.join(self['dir'],
+                                                          'relay_throughput.log'))
+        if not my['log_throughput']:
+            line = '#' + line
+        return line
+
     def _get_owning_controller_process(self, my):
     def _get_owning_controller_process(self, my):
         cpid = my['controlling_pid']
         cpid = my['controlling_pid']
         ocp_line = ('__OwningControllerProcess %d' % (cpid))
         ocp_line = ('__OwningControllerProcess %d' % (cpid))
@@ -1351,6 +2104,10 @@ class Network(object):
         print("NOTE: creating %r, linking to %r" % (newnodesdir, nodeslink))
         print("NOTE: creating %r, linking to %r" % (newnodesdir, nodeslink))
         # this gets created with mode 0700, that's probably ok
         # this gets created with mode 0700, that's probably ok
         mkdir_p(newnodesdir)
         mkdir_p(newnodesdir)
+        remote_hostnames = list(set([x._env['remote_hostname'] for x in self._nodes if x._env['remote_hostname'] is not None]))
+        if len(remote_hostnames) != 0:
+            for x in remote_hostnames:
+                ssh_mkdir_p(newnodesdir, x)
         try:
         try:
             os.unlink(nodeslink)
             os.unlink(nodeslink)
         except OSError as e:
         except OSError as e:
@@ -1359,7 +2116,13 @@ class Network(object):
                 pass
                 pass
             else:
             else:
                 raise
                 raise
+        if len(remote_hostnames) != 0:
+            for x in remote_hostnames:
+                ssh_rm_if_exists(nodeslink, x)
         os.symlink(newnodesdir, nodeslink)
         os.symlink(newnodesdir, nodeslink)
+        if len(remote_hostnames) != 0:
+            for x in remote_hostnames:
+                ssh_symlink(newnodesdir, nodeslink, x)
 
 
     def _checkConfig(self):
     def _checkConfig(self):
         for n in self._nodes:
         for n in self._nodes:
@@ -1502,6 +2265,11 @@ class Network(object):
                 sys.stdout.write("\n")
                 sys.stdout.write("\n")
                 sys.stdout.flush()
                 sys.stdout.flush()
 
 
+    def get_remote_files(self):
+        controllers = [n.getController() for n in self._nodes]
+        for c in controllers:
+            c.getRemoteFiles()
+
 
 
 def Require(feature):
 def Require(feature):
     network = _THE_NETWORK
     network = _THE_NETWORK

+ 2 - 0
tools/test-network-impl.sh

@@ -86,5 +86,7 @@ else
     exit 0
     exit 0
 fi
 fi
 
 
+"$CHUTNEY" get_remote_files "$CHUTNEY_NETWORK"
+
 "$WARNINGS"
 "$WARNINGS"
 exit 0
 exit 0

+ 5 - 0
torrc_templates/common.i

@@ -17,6 +17,9 @@ V3AuthNIntervalsValid 2
 # This line will be commented out if 'num_cpus' is None
 # This line will be commented out if 'num_cpus' is None
 ${num_cpus_line}
 ${num_cpus_line}
 
 
+# This line will be commented out if 'num_additional_eventloops_line' is None
+${num_additional_eventloops_line}
+
 # measureme
 # measureme
 MeasuremeLogFile ${measureme_log_dir}/measureme-${nick}
 MeasuremeLogFile ${measureme_log_dir}/measureme-${nick}
 
 
@@ -45,6 +48,8 @@ PidFile ${dir}/pid
 
 
 ${log_file_lines}
 ${log_file_lines}
 
 
+${throughput_log_file_line}
+
 ProtocolWarnings 1
 ProtocolWarnings 1
 SafeLogging 0
 SafeLogging 0
 LogTimeGranularity 1
 LogTimeGranularity 1