diff --git a/INSTALL.rst b/INSTALL.rst index 435b257f1c739028aceef6c0b841855a120e6d49..04cd2b011e077cfc529e0f33cbe01c798915dc92 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -23,6 +23,7 @@ System requirements -------------------- - Tor (last stable version is recommended) + To use only exits that implement congestion control, tor >= 0.4.7.4-alpha-dev - Python 3 (>= 3.7) Python dependencies diff --git a/docs/source/activity_second_relay.puml b/docs/source/activity_second_relay.puml index d088d68a3c2625309d7023ddc45864d61bf0a19e..a84a428d2972ef993b150d46cc79b39aaee9333f 100644 --- a/docs/source/activity_second_relay.puml +++ b/docs/source/activity_second_relay.puml @@ -2,13 +2,29 @@ start -if (relay to measure is exit?) then (yes) - :obtain non-exits; +if (consensus has `cc_alg=2`) then (yes) + if (consensus has `bwscanner_cc>=1`) then (yes) + :obtain exits + with proto `FlowCtrol=2` + without bad flag + that can exit + to port 443; + else (no) + :obtain exits + with proto `FlowCtrol!=2` + without bad flag + that can exit + to port 443; + endif else (no) - :obtain an exits - without bad flag - that can exit - to port 443; + if (relay to measure is exit?) then (yes) + :obtain non-exits; + else (no) + :obtain an exits + without bad flag + that can exit + to port 443; + endif endif :potential second relays; :obtain a relay @@ -27,4 +43,4 @@ whith exit as second hop; stop -@enduml \ No newline at end of file +@enduml diff --git a/docs/source/how_works.rst b/docs/source/how_works.rst index d45e4caa297a7a2e6f82d5c6e09874bed2a1919c..a36295047c0bcacdc3a68319637df3c15950d0ff 100644 --- a/docs/source/how_works.rst +++ b/docs/source/how_works.rst @@ -85,6 +85,8 @@ Source code: :func:`sbws.core.scanner.measure_relay` Selecting a second relay ------------------------ +#. If the consensus has `cc_alg=2` param, use the exits that have `2` in the + field `FlowCtrl` in their `proto` line, otherwise #. If the relay to measure is an exit, use it as an exit and obtain the non-exits. #. If the relay to measure is not an exit, use it as first hop and obtain diff --git a/docs/source/images/activity_second_relay.svg b/docs/source/images/activity_second_relay.svg index a996c1e02bc6329872eafe2796598842b3bd44a0..58ab762f13c192a1463fae27ccad02ed3f1b57e3 100644 --- a/docs/source/images/activity_second_relay.svg +++ b/docs/source/images/activity_second_relay.svg @@ -1 +1,56 @@ -relay to measure is exit?yesnoobtain non-exitsobtain an exitswithout bad flagthat can exitto port 443potential second relaysobtain a relayfrom potentialsencond relaysrandomlyyessecond relay has 2x bandwidth?yesother second relay has 1.5x bandwidth?yesother second relay has 1x bandwidth?nothingsecond relay selected!Build a circuitwhith exit assecond hop \ No newline at end of file +consensus has `cc_alg=2`yesnoconsensus has `bwscanner_cc>=1`yesnoobtain exitswith proto `FlowCtrol=2`without bad flagthat can exitto port 443obtain exitswith proto `FlowCtrol!=2`without bad flagthat can exitto port 443relay to measure is exit?yesnoobtain non-exitsobtain an exitswithout bad flagthat can exitto port 443potential second relaysobtain a relayfrom potentialsencond relaysrandomlyyessecond relay has 2x bandwidth?yesother second relay has 1.5x bandwidth?yesother second relay has 1x bandwidth?nothingsecond relay selected!Build a circuitwhith exit assecond hop \ No newline at end of file diff --git a/sbws/core/scanner.py b/sbws/core/scanner.py index 4df664f06b1bd6dd0b0eb0ff6df8a15e8841e7a6..56a51c91b7af4cacdfd4a3693c74d1fa6672341c 100644 --- a/sbws/core/scanner.py +++ b/sbws/core/scanner.py @@ -223,10 +223,27 @@ def _pick_ideal_second_hop(relay, dest, rl, cont, is_exit): # In the case that a concrete exit can't exit to the Web server, it is not # a problem since the relay will be measured in the next loop with other # random exit. - candidates = ( - rl.exits_not_bad_allowing_port(dest.port) if is_exit else rl.non_exits - ) + + # #40125 + if rl.is_consensus_cc_alg_2: + if rl.is_consensus_bwscanner_cc_gte_1: + log.debug("Congestion control enabled.") + candidates = rl.exits_with_2_in_flowctrl(dest.port) + else: # bwscanner_cc != 1 + log.debug( + "Congestion control enabled but not using exits with " + "congestion control enabled." + ) + candidates = rl.exits_without_2_in_flowctrl(dest.port) + else: + log.debug("Congestion control disabled.") + candidates = ( + rl.exits_not_bad_allowing_port(dest.port) + if is_exit + else rl.non_exits + ) if not len(candidates): + log.debug("No candidates.") return None # In the case the helper is an exit, the entry could be an exit too # (#40041), so ensure the helper is not the same as the entry, likely to @@ -235,6 +252,8 @@ def _pick_ideal_second_hop(relay, dest, rl, cont, is_exit): candidates = [ c for c in candidates if c.fingerprint != relay.fingerprint ] + # While not all exits implement congestion control, the min bw might not + # correspond to the subset that implement it. min_relay_bw = rl.exit_min_bw() if is_exit else rl.non_exit_min_bw() log.debug( "Picking a 2nd hop to measure %s from %d choices. is_exit=%s", @@ -295,9 +314,11 @@ def create_path_relay(relay, dest, rl, cb, relay_as_entry=True): # is True when the relay is the entry (helper has to be exit) # and False when the relay is not the entry, ie. is the exit (helper does # not have to be an exit) + helper = _pick_ideal_second_hop( relay, dest, rl, cb.controller, is_exit=relay_as_entry ) + if not helper: return error_no_helper(relay, dest) if relay_as_entry: diff --git a/sbws/globals.py b/sbws/globals.py index d628bf032f89fd00118b9f62b259eadfa8e97e71..d95d510b93b9370b8882390ff6edff9a6c4bea4b 100644 --- a/sbws/globals.py +++ b/sbws/globals.py @@ -61,7 +61,7 @@ TORRC_OPTIONS_CAN_FAIL = OrderedDict( { # Since currently scanner anonymity is not the goal, ConnectionPadding # is disable to do not send extra traffic - "ConnectionPadding": "0" + "ConnectionPadding": "0", } ) diff --git a/sbws/lib/relaylist.py b/sbws/lib/relaylist.py index 698fb8e5f4fb7a9d723f7a5799bdc4c8625b7ee7..2c93f5db62c7cf268b336d52742ac6590941d1b1 100644 --- a/sbws/lib/relaylist.py +++ b/sbws/lib/relaylist.py @@ -125,6 +125,23 @@ class Relay: def observed_bandwidth(self): return self._from_desc("observed_bandwidth") + @property + def has_2_in_flowctrl(self): + """ + Return True if the `FlowCtrl` field in the relay's protover consensus + line include a value of 2 and False otherwise. + + """ + # NOTE: stem doesn't seem to obtain `pr`` nor create `protocols`` + # protocols = self._from_ns("protocols") + protocols = self._from_desc("protocols") + if protocols: + if 2 in protocols.get("FlowCtrl", []): + log.debug("Exit %s has 2 in `FlowCtrl`.", self.nickname) + return True + log.debug("Exit %s does not have 2 in `FlowCtrl`.", self.nickname) + return False + @property def consensus_bandwidth(self): """Return the consensus bandwidth in Bytes. @@ -484,12 +501,121 @@ class RelayList: # Calculate minimum bandwidth value for 2nd hop after we refreshed # our available relays. self._calculate_min_bw_second_hop() + self.set_consensus_params() @property def recent_consensus_count(self): """Number of times a new consensus was obtained.""" return len(self._recent_consensus) + def exits_with_2_in_flowctrl(self, port): + """ + Return the exits that include a value of 2 in the `FlowCtrl` field + in their protover consensus line. + + """ + return [ + r + for r in self.exits_not_bad_allowing_port(port) + if r.has_2_in_flowctrl + ] + + def exits_without_2_in_flowctrl(self, port): + """ + Return the exits that do not include a value of 2 in the `FlowCtrl` + field in their protover consensus line or the field is missing. + + """ + return [ + r + for r in self.exits_not_bad_allowing_port(port) + if not r.has_2_in_flowctrl + ] + + def set_consensus_params(self): + """Obtain current consensus params fields and store them as an attr. + + It is not possible to obtain them from `get_network_statuses` via + control port, only via a cached file. + + """ + if self._controller.get_conf("TestingTorNetwork") == "1": + log.debug("In a testing network.") + self.consensus_params_dict = {} + return + log.debug("Not in a testing network.") + consensus = self._controller.get_info( + "dir/status-vote/current/consensus" + ) + from unittest import mock + + if isinstance(consensus, mock.Mock): + log.debug("Mocked consensus.") + self.consensus_params_dict = {} + return + # Create a dictionary from all the consensus lines. + consensus_dict = dict( + [ + (line.split(" ")[0], line.split()[1:]) + for line in consensus.split("\n") + ] + ) + # Create a dictionary from the consensus `params` line. + self.consensus_params_dict = dict( + [ + (p.split("=")[0], p.split("=")[1]) + for p in consensus_dict.get("params", []) + ] + ) + log.debug("Consensus params: %s", self.consensus_params_dict.keys()) + + @property + def is_consensus_cc_alg_2(self): + """ + Return True if the consensus document has a value of 2 in the `cc_alg` + field. + + From proposals/324-rtt-congestion-control.txt spec:: + + 6.5.1. Parameters common to all algorithms + [...] + cc_alg: + - Description: + Specifies which congestion control algorithm clients should + use, as an integer. + - Range: [0,3] (0=fixed, 1=Westwood, 2=Vegas, 3=NOLA) + - Default: 2 + + """ + if ( + self.consensus_params_dict + and self.consensus_params_dict.get("cc_alg", None) == 2 + ): + log.info("The consensus implements congestion control.") + return True + log.info("The consensus does not implement congestion control.") + return False + + @property + def is_consensus_bwscanner_cc_gte_1(self): + """ + Return True if the consensus document has a value of 1 or greater in + the `bwscanner_cc` field.""" + if ( + self.consensus_params_dict + and self.consensus_params_dict.get("bwscanner_cc", None) >= 1 + ): + log.info( + "The consensus says to use exits that support congestion" + " control." + ) + return True + log.info( + "The consensus says to use exits that do not support congestion" + " control." + ) + return False + def exits_not_bad_allowing_port(self, port, strict=False): return [ r diff --git a/tests/conftest.py b/tests/conftest.py index a289ade60dc2f28ccfa4214c5cdfb75e0bfb3753..0dc525d04664a8208a8e32df2eda8ca9deb504f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,6 +79,7 @@ def router_statuses_5days_later(root_data_path): @pytest.fixture(scope="session") def controller(router_statuses): controller = mock.Mock() + controller.get_info.return_value = "params foo=bar" controller.get_network_statuses.return_value = router_statuses return controller diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9a08fca440512f3312d1f757939b95f0b2459ea9..b3d489abf0189dce3fce44b2152cdc599657b075 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,6 +3,7 @@ import argparse import os import pytest +from stem.version import Version from sbws.lib.circuitbuilder import GapsCircuitBuilder as CB from sbws.lib.destination import DestinationList @@ -93,7 +94,8 @@ def persistent_launch_tor(conf): @pytest.fixture(scope="session") def rl(args, conf, persistent_launch_tor): - return RelayList(args, conf, persistent_launch_tor) + temp_rl = RelayList(args, conf, persistent_launch_tor) + return temp_rl @pytest.fixture(scope="session") @@ -110,4 +112,8 @@ def dests(args, conf, persistent_launch_tor, cb, rl): return dests -# @pytest.fixture(scope='session') +@pytest.fixture(scope="session") +def is_cc_tor_version(persistent_launch_tor): + version = persistent_launch_tor.get_version() + print("version", version) + return version >= Version("0.4.7.4-alpha-dev") diff --git a/tests/integration/core/test_scanner.py b/tests/integration/core/test_scanner.py index 0a2196ad21e8db9e240341748e45c918a4bb720a..f10a507a30d12a6d54ab3529504dc7baf5566258 100644 --- a/tests/integration/core/test_scanner.py +++ b/tests/integration/core/test_scanner.py @@ -2,7 +2,7 @@ import logging import pytest -from sbws.core.scanner import measure_relay +from sbws.core.scanner import _pick_ideal_second_hop, measure_relay from sbws.lib.resultdump import ResultSuccess @@ -70,3 +70,22 @@ def test_measure_relay_with_relaybandwidthrate( dls = result.downloads for dl in dls: assert_within(dl["amount"] / dl["duration"], one_mbyte, allowed_error) + + +def test_second_hop_has_2_in_flowctrl( + is_cc_tor_version, dests, rl, persistent_launch_tor +): + if not is_cc_tor_version: + import pytest + + pytest.skip("This test can't be run with this tor version") + return + rl.consensus_params_dict = {"cc_alg": 2, "bwscanner_cc": 1} + assert rl.is_consensus_cc_alg_2 + assert rl.is_consensus_bwscanner_cc_gte_1 + dest = dests._all_dests[0] + relay = rl._relays[0] + helper = _pick_ideal_second_hop( + relay, dest, rl, persistent_launch_tor, False + ) + assert helper.has_2_in_flowctrl diff --git a/tests/integration/lib/test_circuitbuilder.py b/tests/integration/lib/test_circuitbuilder.py index 0b9d9e77b84841ad7b5cdedb83a32e72d79e016b..b8951aac6eb2b8e8e103953cf2a36be941661686 100644 --- a/tests/integration/lib/test_circuitbuilder.py +++ b/tests/integration/lib/test_circuitbuilder.py @@ -21,3 +21,28 @@ def test_build_circuit(cb, rl): path = [entry.fingerprint, exit_relay.fingerprint] circuit_id, _ = cb.build_circuit(path) assert circuit_id + + +def test_build_circuit_with_exit_flowctrl(is_cc_tor_version, cb, rl): + if not is_cc_tor_version: + import pytest + + pytest.skip("This test can't be run with this tor version") + return + rl.consensus_params_dict = {"cc_alg": 2, "bwscanner_cc": 1} + # Path is empty + path = [] + circuit_id, _ = cb.build_circuit(path) + assert not circuit_id + # Valid path, not valid exit + exits = rl.exits_with_2_in_flowctrl(port=443) + # See https://gitlab.torproject.org/tpo/core/chutney/-/issues/40013: + # Work around to get supposed non-exits because chutney is putting Exit + # flag to all relays + non_exits = list(set(rl.exits).difference(set(exits))) + entry = random.choice(non_exits) + # Valid path and relays + exit_relay = random.choice(exits) + path = [entry.fingerprint, exit_relay.fingerprint] + circuit_id, _ = cb.build_circuit(path) + assert circuit_id