diff --git a/docs/configuration.rst b/docs/configuration.rst index 87945a3ea..721d450a2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -150,6 +150,14 @@ set the ``tub.location`` option described below. Lists of endpoint descriptor strings like the following ``tcp:12345,tcp6:12345`` are known to not work because an ``Address already in use.`` error. + If any descriptor begins with ``listen:tor``, or ``listen:i2p``, the + corresponding tor/i2p Provider object will construct additional endpoints + for the Tub to listen on. This allows the ``[tor]`` or ``[i2p]`` sections + in ``tahoe.cfg`` to customize the endpoint; e.g. to add I2CP control + options. If you use ``listen:i2p``, you should not also have an + ``i2p:..`` endpoint in ``tub.port``, as that would result in multiple + I2P-based listeners. + If ``tub.port`` is the string ``disabled``, the node will not listen at all, and thus cannot accept connections from other nodes. If ``[storage] enabled = true``, or ``[helper] enabled = true``, or the node is an diff --git a/src/allmydata/node.py b/src/allmydata/node.py index 1f056e639..c257868b4 100644 --- a/src/allmydata/node.py +++ b/src/allmydata/node.py @@ -400,7 +400,18 @@ class Node(service.MultiService): for port in tubport.split(","): if port in ("0", "tcp:0"): raise ValueError("tub.port cannot be 0: you must choose") - self.tub.listenOn(port) + if port == "listen:i2p": + # the I2P provider will read its section of tahoe.cfg and + # return either a fully-formed Endpoint, or a descriptor + # that will create one, so we don't have to stuff all the + # options into the tub.port string (which would need a lot + # of escaping) + port_or_endpoint = self._i2p_provider.get_listener() + elif port == "listen:tor": + port_or_endpoint = self._tor_provider.get_listener() + else: + port_or_endpoint = port + self.tub.listenOn(port_or_endpoint) self.tub.setLocation(location) self._tub_is_listening = True self.log("Tub location set to %s" % (location,)) diff --git a/src/allmydata/scripts/create_node.py b/src/allmydata/scripts/create_node.py index 81ab7c1f1..1d71996f0 100644 --- a/src/allmydata/scripts/create_node.py +++ b/src/allmydata/scripts/create_node.py @@ -251,12 +251,12 @@ def write_node_config(c, config): else: if "tor" in listeners: (tor_config, tor_port, tor_location) = \ - yield tor_provider.create_onion(reactor, config) + yield tor_provider.create_config(reactor, config) tub_ports.append(tor_port) tub_locations.append(tor_location) if "i2p" in listeners: (i2p_config, i2p_port, i2p_location) = \ - yield i2p_provider.create_dest(reactor, config) + yield i2p_provider.create_config(reactor, config) tub_ports.append(i2p_port) tub_locations.append(i2p_location) if "tcp" in listeners: diff --git a/src/allmydata/test/cli/test_create.py b/src/allmydata/test/cli/test_create.py index 174a7097e..75162c39e 100644 --- a/src/allmydata/test/cli/test_create.py +++ b/src/allmydata/test/cli/test_create.py @@ -261,7 +261,7 @@ class Config(unittest.TestCase): def test_node_slow_tor(self): basedir = self.mktemp() d = defer.Deferred() - with mock.patch("allmydata.util.tor_provider.create_onion", + with mock.patch("allmydata.util.tor_provider.create_config", return_value=d): d2 = run_cli("create-node", "--listen=tor", basedir) d.callback(({}, "port", "location")) @@ -274,7 +274,7 @@ class Config(unittest.TestCase): def test_node_slow_i2p(self): basedir = self.mktemp() d = defer.Deferred() - with mock.patch("allmydata.util.i2p_provider.create_dest", + with mock.patch("allmydata.util.i2p_provider.create_config", return_value=d): d2 = run_cli("create-node", "--listen=i2p", basedir) d.callback(({}, "port", "location")) @@ -325,9 +325,9 @@ class Tor(unittest.TestCase): tor_config = {"abc": "def"} tor_port = "ghi" tor_location = "jkl" - onion_d = defer.succeed( (tor_config, tor_port, tor_location) ) - with mock.patch("allmydata.util.tor_provider.create_onion", - return_value=onion_d) as co: + config_d = defer.succeed( (tor_config, tor_port, tor_location) ) + with mock.patch("allmydata.util.tor_provider.create_config", + return_value=config_d) as co: rc, out, err = self.successResultOf( run_cli("create-node", "--listen=tor", basedir)) self.assertEqual(len(co.mock_calls), 1) @@ -345,9 +345,9 @@ class Tor(unittest.TestCase): tor_config = {"abc": "def"} tor_port = "ghi" tor_location = "jkl" - onion_d = defer.succeed( (tor_config, tor_port, tor_location) ) - with mock.patch("allmydata.util.tor_provider.create_onion", - return_value=onion_d) as co: + config_d = defer.succeed( (tor_config, tor_port, tor_location) ) + with mock.patch("allmydata.util.tor_provider.create_config", + return_value=config_d) as co: rc, out, err = self.successResultOf( run_cli("create-node", "--listen=tor", "--tor-launch", basedir)) @@ -361,9 +361,9 @@ class Tor(unittest.TestCase): tor_config = {"abc": "def"} tor_port = "ghi" tor_location = "jkl" - onion_d = defer.succeed( (tor_config, tor_port, tor_location) ) - with mock.patch("allmydata.util.tor_provider.create_onion", - return_value=onion_d) as co: + config_d = defer.succeed( (tor_config, tor_port, tor_location) ) + with mock.patch("allmydata.util.tor_provider.create_config", + return_value=config_d) as co: rc, out, err = self.successResultOf( run_cli("create-node", "--listen=tor", "--tor-control-port=mno", basedir)) @@ -400,7 +400,7 @@ class I2P(unittest.TestCase): i2p_port = "ghi" i2p_location = "jkl" dest_d = defer.succeed( (i2p_config, i2p_port, i2p_location) ) - with mock.patch("allmydata.util.i2p_provider.create_dest", + with mock.patch("allmydata.util.i2p_provider.create_config", return_value=dest_d) as co: rc, out, err = self.successResultOf( run_cli("create-node", "--listen=i2p", basedir)) @@ -427,7 +427,7 @@ class I2P(unittest.TestCase): i2p_port = "ghi" i2p_location = "jkl" dest_d = defer.succeed( (i2p_config, i2p_port, i2p_location) ) - with mock.patch("allmydata.util.i2p_provider.create_dest", + with mock.patch("allmydata.util.i2p_provider.create_config", return_value=dest_d) as co: rc, out, err = self.successResultOf( run_cli("create-node", "--listen=i2p", "--i2p-sam-port=mno", diff --git a/src/allmydata/test/test_i2p_provider.py b/src/allmydata/test/test_i2p_provider.py index 242c7c444..c6117ea0c 100644 --- a/src/allmydata/test/test_i2p_provider.py +++ b/src/allmydata/test/test_i2p_provider.py @@ -124,7 +124,7 @@ class CreateDest(unittest.TestCase): def test_no_txi2p(self): with mock.patch("allmydata.util.i2p_provider._import_txi2p", return_value=None): - d = i2p_provider.create_dest("reactor", "cli_config") + d = i2p_provider.create_config("reactor", "cli_config") f = self.failureResultOf(d) self.assertIsInstance(f.value, ValueError) self.assertEqual(str(f.value), @@ -164,7 +164,7 @@ class CreateDest(unittest.TestCase): connect_to_i2p): with mock.patch("allmydata.util.i2p_provider.clientFromString", return_value=ep) as cfs: - d = i2p_provider.create_dest(reactor, cli_config) + d = i2p_provider.create_config(reactor, cli_config) tahoe_config_i2p, i2p_port, i2p_location = self.successResultOf(d) connect_to_i2p.assert_called_with(reactor, cli_config, txi2p) @@ -178,7 +178,7 @@ class CreateDest(unittest.TestCase): "i2p_dest.privkey"), } self.assertEqual(tahoe_config_i2p, expected) - self.assertEqual(i2p_port, "i2p:%s:3457:api=SAM:apiEndpoint=goodport" % privkeyfile) + self.assertEqual(i2p_port, "listen:i2p") self.assertEqual(i2p_location, "i2p:FOOBAR.b32.i2p:3457") _None = object() @@ -294,6 +294,33 @@ class Provider(unittest.TestCase): self.assertIs(h, handler) i2p.default.assert_called_with(reactor, keyfile=None) +class ProviderListener(unittest.TestCase): + def test_listener(self): + """Does the I2P Provider object's get_listener() method correctly + convert the [i2p] section of tahoe.cfg into an + endpoint/descriptor? + """ + + i2p = mock.Mock() + handler = object() + i2p.local_i2p = mock.Mock(return_value=handler) + reactor = object() + + privkeyfile = os.path.join("private", "i2p_dest.privkey") + with mock_i2p(i2p): + p = i2p_provider.Provider("basedir", + FakeConfig(**{ + "i2p.configdir": "configdir", + "sam.port": "good:port", + "dest": "true", + "dest.port": "3457", + "dest.private_key_file": privkeyfile, + }), + reactor) + endpoint_or_description = p.get_listener() + self.assertEqual(endpoint_or_description, + "i2p:%s:3457:api=SAM:apiEndpoint=good\\:port" % privkeyfile) + class Provider_CheckI2PConfig(unittest.TestCase): def test_default(self): # default config doesn't start an I2P service, so it should be diff --git a/src/allmydata/test/test_node.py b/src/allmydata/test/test_node.py index bb74c41c0..d861c300a 100644 --- a/src/allmydata/test/test_node.py +++ b/src/allmydata/test/test_node.py @@ -349,7 +349,7 @@ class FakeTub: def setLocation(self, location): pass def setServiceParent(self, parent): pass -class MultiplePorts(unittest.TestCase): +class Listeners(unittest.TestCase): def test_multiple_ports(self): n = EmptyNode() n.basedir = self.mktemp() @@ -381,6 +381,36 @@ class MultiplePorts(unittest.TestCase): ["tcp:%d:interface=127.0.0.1" % port1, "tcp:%d:interface=127.0.0.1" % port2]) + def test_tor_i2p_listeners(self): + n = EmptyNode() + n.basedir = self.mktemp() + n.config_fname = os.path.join(n.basedir, "tahoe.cfg") + os.mkdir(n.basedir) + os.mkdir(os.path.join(n.basedir, "private")) + with open(n.config_fname, "w") as f: + f.write(BASE_CONFIG) + f.write("tub.port = listen:i2p,listen:tor\n") + f.write("tub.location = tcp:example.org:1234\n") + # we're doing a lot of calling-into-setup-methods here, it might be + # better to just create a real Node instance, I'm not sure. + n.read_config() + n.check_privacy() + n.services = [] + i2p_ep = object() + tor_ep = object() + n._i2p_provider = mock.Mock() + n._i2p_provider.get_listener = mock.Mock(return_value=i2p_ep) + n._tor_provider = mock.Mock() + n._tor_provider.get_listener = mock.Mock(return_value=tor_ep) + n.init_connections() + n.set_tub_options() + t = FakeTub() + with mock.patch("allmydata.node.Tub", return_value=t): + n.create_main_tub() + self.assertEqual(n._i2p_provider.get_listener.mock_calls, [mock.call()]) + self.assertEqual(n._tor_provider.get_listener.mock_calls, [mock.call()]) + self.assertEqual(t.listening_ports, [i2p_ep, tor_ep]) + class ClientNotListening(unittest.TestCase): def test_disabled(self): basedir = "test_node/test_disabled" diff --git a/src/allmydata/test/test_tor_provider.py b/src/allmydata/test/test_tor_provider.py index dbb74e4b2..21bc5878e 100644 --- a/src/allmydata/test/test_tor_provider.py +++ b/src/allmydata/test/test_tor_provider.py @@ -152,7 +152,7 @@ class CreateOnion(unittest.TestCase): def test_no_txtorcon(self): with mock.patch("allmydata.util.tor_provider._import_txtorcon", return_value=None): - d = tor_provider.create_onion("reactor", "cli_config") + d = tor_provider.create_config("reactor", "cli_config") f = self.failureResultOf(d) self.assertIsInstance(f.value, ValueError) self.assertEqual(str(f.value), @@ -184,7 +184,7 @@ class CreateOnion(unittest.TestCase): launch_tor): with mock.patch("allmydata.util.tor_provider.allocate_tcp_port", return_value=999999): - d = tor_provider.create_onion(reactor, cli_config) + d = tor_provider.create_config(reactor, cli_config) tahoe_config_tor, tor_port, tor_location = self.successResultOf(d) launch_tor.assert_called_with(reactor, executable, @@ -238,7 +238,7 @@ class CreateOnion(unittest.TestCase): connect_to_tor): with mock.patch("allmydata.util.tor_provider.allocate_tcp_port", return_value=999999): - d = tor_provider.create_onion(reactor, cli_config) + d = tor_provider.create_config(reactor, cli_config) tahoe_config_tor, tor_port, tor_location = self.successResultOf(d) connect_to_tor.assert_called_with(reactor, cli_config, txtorcon) @@ -393,6 +393,29 @@ class Provider(unittest.TestCase): self.assertIs(h, handler) tor.default_socks.assert_called_with() +class ProviderListener(unittest.TestCase): + def test_listener(self): + """Does the Tor Provider object's get_listener() method correctly + convert the [tor] section of tahoe.cfg into an + endpoint/descriptor? + """ + tor = mock.Mock() + handler = object() + tor.socks_endpoint = mock.Mock(return_value=handler) + reactor = object() + + with mock_tor(tor): + p = tor_provider.Provider("basedir", + FakeConfig(**{"onion.local_port": "321"}), + reactor) + fake_ep = object() + with mock.patch("allmydata.util.tor_provider.TCP4ServerEndpoint", + return_value=fake_ep) as e: + endpoint_or_description = p.get_listener() + self.assertIs(endpoint_or_description, fake_ep) + self.assertEqual(e.mock_calls, [mock.call(reactor, 321, + interface="127.0.0.1")]) + class Provider_CheckOnionConfig(unittest.TestCase): def test_default(self): # default config doesn't start an onion service, so it should be diff --git a/src/allmydata/util/i2p_provider.py b/src/allmydata/util/i2p_provider.py index 10777e16a..a171a7164 100644 --- a/src/allmydata/util/i2p_provider.py +++ b/src/allmydata/util/i2p_provider.py @@ -65,7 +65,7 @@ def _connect_to_i2p(reactor, cli_config, txi2p): raise ValueError("unable to reach any default I2P SAM port") @inlineCallbacks -def create_dest(reactor, cli_config): +def create_config(reactor, cli_config): txi2p = _import_txi2p() if not txi2p: raise ValueError("Cannot create I2P Destination without txi2p. " @@ -88,9 +88,7 @@ def create_dest(reactor, cli_config): print("allocating .i2p address...", file=stdout) dest = yield txi2p.generateDestination(reactor, privkeyfile, 'SAM', sam_endpoint) print(".i2p address allocated", file=stdout) - escaped_sam_port = sam_port.replace(':', '\:') - i2p_port = "i2p:%s:%d:api=SAM:apiEndpoint=%s" % \ - (privkeyfile, external_port, escaped_sam_port) + i2p_port = "listen:i2p" # means "see [i2p]", calls Provider.get_listener() i2p_location = "i2p:%s:%d" % (dest.host, external_port) # in addition to the "how to launch/connect-to i2p" keys above, we also @@ -137,6 +135,20 @@ class Provider(service.MultiService): def _get_i2p_config(self, *args, **kwargs): return self._node_for_config.get_config("i2p", *args, **kwargs) + def get_listener(self): + # this is relative to BASEDIR, and our cwd should be BASEDIR + privkeyfile = self._get_i2p_config("dest.private_key_file") + external_port = self._get_i2p_config("dest.port") + sam_port = self._get_i2p_config("sam.port") + escaped_sam_port = sam_port.replace(':', '\:') + # for now, this returns a string, which then gets passed to + # endpoints.serverFromString . But it can also return an Endpoint + # directly, which means we don't need to encode all these options + # into a string + i2p_port = "i2p:%s:%s:api=SAM:apiEndpoint=%s" % \ + (privkeyfile, external_port, escaped_sam_port) + return i2p_port + def get_i2p_handler(self): enabled = self._get_i2p_config("enabled", True, boolean=True) if not enabled: diff --git a/src/allmydata/util/tor_provider.py b/src/allmydata/util/tor_provider.py index 1c930c14e..ccb963775 100644 --- a/src/allmydata/util/tor_provider.py +++ b/src/allmydata/util/tor_provider.py @@ -3,7 +3,7 @@ from __future__ import absolute_import, print_function, with_statement import os from twisted.internet.defer import inlineCallbacks, returnValue -from twisted.internet.endpoints import clientFromString +from twisted.internet.endpoints import clientFromString, TCP4ServerEndpoint from twisted.internet.error import ConnectionRefusedError, ConnectError from twisted.application import service @@ -124,7 +124,7 @@ def _connect_to_tor(reactor, cli_config, txtorcon): raise ValueError("unable to reach any default Tor control port") @inlineCallbacks -def create_onion(reactor, cli_config): +def create_config(reactor, cli_config): txtorcon = _import_txtorcon() if not txtorcon: raise ValueError("Cannot create onion without txtorcon. " @@ -216,6 +216,11 @@ class Provider(service.MultiService): def _get_tor_config(self, *args, **kwargs): return self._node_for_config.get_config("tor", *args, **kwargs) + def get_listener(self): + local_port = int(self._get_tor_config("onion.local_port")) + ep = TCP4ServerEndpoint(self._reactor, local_port, interface="127.0.0.1") + return ep + def get_tor_handler(self): enabled = self._get_tor_config("enabled", True, boolean=True) if not enabled: