Skip to content
Snippets Groups Projects
Commit 51dc1a3c authored by teor's avatar teor
Browse files

Implement chutney performance testing

The following environmental variables affect chutney verify:
CHUTNEY_DATA_BYTES=n        sends n bytes per test connection (10 KBytes)
CHUTNEY_CONNECTIONS=n       makes n test connections per client (1)
CHUTNEY_HS_MULTI_CLIENT=1   makes each client connect to each HS (0)

When enough data is transmitted, chutney verify reports:
Single Stream Bandwidth: the speed of the slowest stream, end-to-end
Overall tor Bandwidth: the sum of the bandwidth across each tor instance
This approximates the CPU-bound tor performance on the current machine,
assuming everything is multithreaded and network performance is infinite.

These new features are all documented in the README.
parent bbc3ecbb
No related branches found
No related tags found
No related merge requests found
......@@ -7,7 +7,7 @@ It is supposed to be a good tool for:
- Launching and monitoring a testing tor network
- Running tests on a testing tor network
Right now it only sorta does the first two.
Right now it only sorta does these things.
You will need, at the moment:
- Tor installed somewhere in your path or the location of the 'tor' and
......@@ -16,12 +16,49 @@ You will need, at the moment:
- Python 2.7 or later
Stuff to try:
Standard Actions:
./chutney configure networks/basic
./chutney start networks/basic
./chutney status networks/basic
./chutney verify networks/basic
./chutney hup networks/basic
./chutney stop networks/basic
Bandwidth Tests:
./chutney configure networks/basic-min
./chutney start networks/basic-min
./chutney status networks/basic-min
CHUTNEY_DATA_BYTES=104857600 ./chutney verify networks/basic-min
# Send 100MB of data per client connection
# verify produces performance figures for:
# Single Stream Bandwidth: the speed of the slowest stream, end-to-end
# Overall tor Bandwidth: the sum of the bandwidth across each tor instance
# This approximates the CPU-bound tor performance on the current machine,
# assuming everything is multithreaded and network performance is infinite.
./chutney stop networks/basic-min
Connection Tests:
./chutney configure networks/basic-025
./chutney start networks/basic-025
./chutney status networks/basic-025
CHUTNEY_CONNECTIONS=5 ./chutney verify networks/basic-025
# Make 5 connections from each client through a random exit
./chutney stop networks/basic-025
Note: If you create 7 or more connections to a hidden service from a single
client, you'll likely get a verification failure due to
https://trac.torproject.org/projects/tor/ticket/15937
HS Connection Tests:
./chutney configure networks/hs-025
./chutney start networks/hs-025
./chutney status networks/hs-025
CHUTNEY_HS_MULTI_CLIENT=1 ./chutney verify networks/hs-025
# Make a connection from each client to each hs
# Default behavior is one client connects to each HS
./chutney stop networks/hs-025
The configuration files:
networks/basic holds the configuration for the network you're configuring
above. It refers to some torrc template files in torrc_templates/.
......
......@@ -690,6 +690,14 @@ DEFAULTS = {
# Used when poll_launch_time is None, but RunAsDaemon is not set
# Set low so that we don't interfere with the voting interval
'poll_launch_time_default': 0.1,
# the number of bytes of random data we send on each connection
'data_bytes': int(os.environ.get('CHUTNEY_DATA_BYTES', 10 * 1024)),
# the number of times each client will connect
'connection_count': int(os.environ.get('CHUTNEY_CONNECTIONS', 1)),
# Do we want every client to connect to every HS, or one client
# to connect to each HS?
# (Clients choose an exit at random, so this doesn't apply to exits.)
'hs_multi_client': int(os.environ.get('CHUTNEY_HS_MULTI_CLIENT', 0)),
}
......@@ -908,46 +916,201 @@ class Network(object):
# HSs must have a HiddenServiceDir with
# "HiddenServicePort <HS_PORT> 127.0.0.1:<LISTEN_PORT>"
HS_PORT = 5858
DATALEN = 10 * 1024 # Octets.
TIMEOUT = 3 # Seconds.
with open('/dev/urandom', 'r') as randfp:
tmpdata = randfp.read(DATALEN)
# The amount of data to send between each source-sink pair,
# each time the source connects.
# We create a source-sink pair for each (bridge) client to an exit,
# and a source-sink pair for a (bridge) client to each hidden service
DATALEN = self._dfltEnv['data_bytes']
# Print a dot each time a sink verifies this much data
DOTDATALEN = 5 * 1024 * 1024 # Octets.
TIMEOUT = 3 # Seconds.
# Calculate the amount of random data we should use
randomlen = self._calculate_randomlen(DATALEN)
reps = self._calculate_reps(DATALEN, randomlen)
# sanity check
if reps == 0:
DATALEN = 0
# Get the random data
if randomlen > 0:
# print a dot after every DOTDATALEN data is verified, rounding up
dot_reps = self._calculate_reps(DOTDATALEN, randomlen)
# make sure we get at least one dot per transmission
dot_reps = min(reps, dot_reps)
with open('/dev/urandom', 'r') as randfp:
tmpdata = randfp.read(randomlen)
else:
dot_reps = 0
tmpdata = {}
# now make the connections
bind_to = ('127.0.0.1', LISTEN_PORT)
tt = chutney.Traffic.TrafficTester(bind_to, tmpdata, TIMEOUT)
tt = chutney.Traffic.TrafficTester(bind_to,
tmpdata,
TIMEOUT,
reps,
dot_reps)
client_list = filter(lambda n:
n._env['tag'] == 'c' or n._env['tag'] == 'bc',
self._nodes)
exit_list = filter(lambda n:
('exit' in n._env.keys()) and n._env['exit'] == 1,
self._nodes)
hs_list = filter(lambda n:
n._env['tag'] == 'h',
self._nodes)
if len(client_list) == 0:
print(" Unable to verify network: no client nodes available")
return False
# Each client binds directly to 127.0.0.1:LISTEN_PORT via an Exit relay
for op in client_list:
print(" Exit to %s:%d via client %s:%s"
% ('127.0.0.1', LISTEN_PORT,
'localhost', op._env['socksport']))
tt.add(chutney.Traffic.Source(tt, bind_to, tmpdata,
('localhost',
int(op._env['socksport']))))
# The HS redirects .onion connections made to hs_hostname:HS_PORT
# to the Traffic Tester's 127.0.0.1:LISTEN_PORT
# We must have at least one working client for the hs test to succeed
for hs in filter(lambda n:
n._env['tag'] == 'h',
self._nodes):
# Instead of binding directly to LISTEN_PORT via an Exit relay,
# we bind to hs_hostname:HS_PORT via a hidden service connection
# through the first available client
bind_to = (hs._env['hs_hostname'], HS_PORT)
# Just choose the first client
client = client_list[0]
print(" HS to %s:%d (%s:%d) via client %s:%s"
% (hs._env['hs_hostname'], HS_PORT,
if len(exit_list) == 0 and len(hs_list) == 0:
print(" Unable to verify network: no exit/hs nodes available")
print(" Exit nodes must be declared 'relay=1, exit=1'")
print(" HS nodes must be declared 'tag=\"hs\"'")
return False
print("Connecting:")
# the number of tor nodes in paths which will send DATALEN data
# if a node is used in two paths, we count it twice
# this is a lower bound, as cannabilised circuits are one node longer
total_path_node_count = 0
total_path_node_count += self._configure_exits(tt, bind_to,
tmpdata, reps,
client_list, exit_list,
LISTEN_PORT)
total_path_node_count += self._configure_hs(tt,
tmpdata, reps,
client_list, hs_list,
HS_PORT,
LISTEN_PORT)
print("Transmitting Data:")
start_time = time.clock()
status = tt.run()
end_time = time.clock()
# if we fail, don't report the bandwidth
if not status:
return status
# otherwise, report bandwidth used, if sufficient data was transmitted
self._report_bandwidth(DATALEN, total_path_node_count,
start_time, end_time)
return status
# In order to performance test a tor network, we need to transmit
# several hundred megabytes of data or more. Passing around this
# much data in Python has its own performance impacts, so we provide
# a smaller amount of random data instead, and repeat it to DATALEN
def _calculate_randomlen(self, datalen):
MAX_RANDOMLEN = 128 * 1024 # Octets.
if datalen > MAX_RANDOMLEN:
return MAX_RANDOMLEN
else:
return datalen
def _calculate_reps(self, datalen, replen):
# sanity checks
if datalen == 0 or replen == 0:
return 0
# effectively rounds datalen up to the nearest replen
if replen < datalen:
return (datalen + replen - 1) / replen
else:
return 1
# if there are any exits, each client / bridge client transmits
# via 4 nodes (including the client) to an arbitrary exit
# Each client binds directly to 127.0.0.1:LISTEN_PORT via an Exit relay
def _configure_exits(self, tt, bind_to,
tmpdata, reps,
client_list, exit_list,
LISTEN_PORT):
CLIENT_EXIT_PATH_NODES = 4
connection_count = self._dfltEnv['connection_count']
exit_path_node_count = 0
if len(exit_list) > 0:
exit_path_node_count += (len(client_list)
* CLIENT_EXIT_PATH_NODES
* connection_count)
for op in client_list:
print(" Exit to %s:%d via client %s:%s"
% ('127.0.0.1', LISTEN_PORT,
'localhost', op._env['socksport']))
for i in range(connection_count):
tt.add(chutney.Traffic.Source(tt,
bind_to,
tmpdata,
('localhost',
int(op._env['socksport'])),
reps))
return exit_path_node_count
# The HS redirects .onion connections made to hs_hostname:HS_PORT
# to the Traffic Tester's 127.0.0.1:LISTEN_PORT
# an arbitrary client / bridge client transmits via 8 nodes
# (including the client and hs) to each hidden service
# Instead of binding directly to LISTEN_PORT via an Exit relay,
# we bind to hs_hostname:HS_PORT via a hidden service connection
def _configure_hs(self, tt,
tmpdata, reps,
client_list, hs_list,
HS_PORT,
LISTEN_PORT):
CLIENT_HS_PATH_NODES = 8
connection_count = self._dfltEnv['connection_count']
hs_path_node_count = (len(hs_list)
* CLIENT_HS_PATH_NODES
* connection_count)
# Each client in hs_client_list connects to each hs
if self._dfltEnv['hs_multi_client']:
hs_client_list = client_list
hs_path_node_count *= len(client_list)
else:
# only use the first client in the list
hs_client_list = client_list[:1]
# Setup the connections from each client in hs_client_list to each hs
for hs in hs_list:
hs_bind_to = (hs._env['hs_hostname'], HS_PORT)
for client in hs_client_list:
print(" HS to %s:%d (%s:%d) via client %s:%s"
% (hs._env['hs_hostname'], HS_PORT,
'127.0.0.1', LISTEN_PORT,
'localhost', client._env['socksport']))
tt.add(chutney.Traffic.Source(tt, bind_to, tmpdata,
for i in range(connection_count):
tt.add(chutney.Traffic.Source(tt,
hs_bind_to,
tmpdata,
('localhost',
int(client._env['socksport']))))
return tt.run()
int(client._env['socksport'])),
reps))
return hs_path_node_count
# calculate the single stream bandwidth and overall tor bandwidth
# the single stream bandwidth is the bandwidth of the
# slowest stream of all the simultaneously transmitted streams
# the overall bandwidth estimates the simultaneous bandwidth between
# all tor nodes over all simultaneous streams, assuming:
# * minimum path lengths (no cannibalized circuits)
# * unlimited network bandwidth (that is, localhost)
# * tor performance is CPU-limited
# This be used to estimate the bandwidth capacity of a CPU-bound
# tor relay running on this machine
def _report_bandwidth(self, data_length, total_path_node_count,
start_time, end_time):
# otherwise, if we sent at least 5 MB cumulative total, and
# it took us at least a second to send, report bandwidth
MIN_BWDATA = 5 * 1024 * 1024 # Octets.
MIN_ELAPSED_TIME = 1.0 # Seconds.
cumulative_data_sent = total_path_node_count * data_length
elapsed_time = end_time - start_time
if (cumulative_data_sent >= MIN_BWDATA
and elapsed_time >= MIN_ELAPSED_TIME):
# Report megabytes per second
BWDIVISOR = 1024*1024
single_stream_bandwidth = (data_length
/ elapsed_time
/ BWDIVISOR)
overall_bandwidth = (cumulative_data_sent
/ elapsed_time
/ BWDIVISOR)
print("Single Stream Bandwidth: %.2f MBytes/s"
% single_stream_bandwidth)
print("Overall tor Bandwidth: %.2f MBytes/s"
% overall_bandwidth)
def ConfigureNodes(nodelist):
......
......@@ -141,6 +141,7 @@ class Sink(Peer):
def __init__(self, tt, s):
super(Sink, self).__init__(Peer.SINK, tt, s)
self.inbuf = ''
self.repetitions = self.tt.repetitions
def on_readable(self):
"""Invoked when the socket becomes readable.
......@@ -151,13 +152,35 @@ class Sink(Peer):
return self.verify(self.tt.data)
def verify(self, data):
# shortcut read when we don't ever expect any data
if self.repetitions == 0 or len(self.tt.data) == 0:
debug("no verification required - no data")
return 0;
self.inbuf += self.s.recv(len(data) - len(self.inbuf))
assert(len(self.inbuf) <= len(data))
if len(self.inbuf) == len(data):
if self.inbuf != data:
debug("successfully received (bytes=%d)" % len(self.inbuf))
while len(self.inbuf) >= len(data):
assert(len(self.inbuf) <= len(data) or self.repetitions > 1)
if self.inbuf[:len(data)] != data:
debug("receive comparison failed (bytes=%d)" % len(data))
return -1 # Failed verification.
# if we're not debugging, print a dot every dot_repetitions reps
elif (not debug_flag
and self.tt.dot_repetitions > 0
and self.repetitions % self.tt.dot_repetitions == 0):
sys.stdout.write('.')
sys.stdout.flush()
# repeatedly check data against self.inbuf if required
debug("receive comparison success (bytes=%d)" % len(data))
self.inbuf = self.inbuf[len(data):]
debug("receive leftover bytes (bytes=%d)" % len(self.inbuf))
self.repetitions -= 1
debug("receive remaining repetitions (reps=%d)" % self.repetitions)
if self.repetitions == 0 and len(self.inbuf) == 0:
debug("successful verification")
return len(data) - len(self.inbuf)
# calculate the actual length of data remaining, including reps
debug("receive remaining bytes (bytes=%d)"
% (self.repetitions*len(data) - len(self.inbuf)))
return self.repetitions*len(data) - len(self.inbuf)
class Source(Peer):
......@@ -169,13 +192,19 @@ class Source(Peer):
CONNECTING_THROUGH_PROXY = 2
CONNECTED = 5
def __init__(self, tt, server, buf, proxy=None):
def __init__(self, tt, server, buf, proxy=None, repetitions=1):
super(Source, self).__init__(Peer.SOURCE, tt)
self.state = self.NOT_CONNECTED
self.data = buf
self.outbuf = ''
self.inbuf = ''
self.proxy = proxy
self.repetitions = repetitions
# sanity checks
if len(self.data) == 0:
self.repetitions = 0
if self.repetitions == 0:
self.data = {}
self.connect(server)
def connect(self, endpoint):
......@@ -200,9 +229,14 @@ class Source(Peer):
debug("proxy handshake successful (fd=%d)" % self.fd())
self.state = self.CONNECTED
self.inbuf = ''
self.outbuf = self.data
debug("successfully connected (fd=%d)" % self.fd())
return 1 # Keep us around for writing.
# if we have no reps or no data, skip sending actual data
if self.want_to_write():
return 1 # Keep us around for writing.
else:
# shortcut write when we don't ever expect any data
debug("no connection required - no data")
return 0
else:
debug("proxy handshake failed (0x%x)! (fd=%d)" %
(ord(self.inbuf[1]), self.fd()))
......@@ -210,10 +244,11 @@ class Source(Peer):
return -1
assert(8 - len(self.inbuf) > 0)
return 8 - len(self.inbuf)
return 1 # Keep us around for writing.
return self.want_to_write() # Keep us around for writing if needed
def want_to_write(self):
return self.state == self.CONNECTING or len(self.outbuf) > 0
return (self.state == self.CONNECTING or len(self.outbuf) > 0
or (self.repetitions > 0 and len(self.data) > 0))
def on_writable(self):
"""Invoked when the socket becomes writable.
......@@ -224,11 +259,21 @@ class Source(Peer):
if self.state == self.CONNECTING:
if self.proxy is None:
self.state = self.CONNECTED
self.outbuf = self.data
debug("successfully connected (fd=%d)" % self.fd())
else:
self.state = self.CONNECTING_THROUGH_PROXY
self.outbuf = socks_cmd(self.dest)
# we write socks_cmd() to the proxy, then read the response
# if we get the correct response, we're CONNECTED
if self.state == self.CONNECTED:
# repeat self.data into self.outbuf if required
if (len(self.outbuf) < len(self.data) and self.repetitions > 0):
self.outbuf += self.data
self.repetitions -= 1
debug("adding more data to send (bytes=%d)" % len(self.data))
debug("now have data to send (bytes=%d)" % len(self.outbuf))
debug("send repetitions remaining (reps=%d)"
% self.repetitions)
try:
n = self.s.send(self.outbuf)
except socket.error as e:
......@@ -236,10 +281,19 @@ class Source(Peer):
debug("connection refused (fd=%d)" % self.fd())
return -1
raise
# sometimes, this debug statement prints 0
# it should print length of the data sent
# but the code works regardless of this error
debug("successfully sent (bytes=%d)" % n)
self.outbuf = self.outbuf[n:]
if self.state == self.CONNECTING_THROUGH_PROXY:
return 1 # Keep us around.
return len(self.outbuf) # When 0, we're being removed.
debug("bytes remaining on outbuf (bytes=%d)" % len(self.outbuf))
# calculate the actual length of data remaining, including reps
# When 0, we're being removed.
debug("bytes remaining overall (bytes=%d)"
% (self.repetitions*len(self.data) + len(self.outbuf)))
return self.repetitions*len(self.data) + len(self.outbuf)
class TrafficTester():
......@@ -252,12 +306,24 @@ class TrafficTester():
Return True if all tests succeed, else False.
"""
def __init__(self, endpoint, data={}, timeout=3):
def __init__(self,
endpoint,
data={},
timeout=3,
repetitions=1,
dot_repetitions=0):
self.listener = Listener(self, endpoint)
self.pending_close = []
self.timeout = timeout
self.tests = TestSuite()
self.data = data
self.repetitions = repetitions
# sanity checks
if len(self.data) == 0:
self.repetitions = 0
if self.repetitions == 0:
self.data = {}
self.dot_repetitions = dot_repetitions
debug("listener fd=%d" % self.listener.fd())
self.peers = {} # fd:Peer
......@@ -318,9 +384,16 @@ class TrafficTester():
self.tests.failure()
self.remove(p)
for fd in self.peers:
peer = self.peers[fd]
debug("peer fd=%d never pending close, never read or wrote" % fd)
self.pending_close.append(peer.s)
self.listener.s.close()
for s in self.pending_close:
s.close()
if not debug_flag:
sys.stdout.write('\n')
sys.stdout.flush()
return self.tests.all_done() and self.tests.failure_count() == 0
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment