1 def iptables_chains_parse(iptables, table = 'filter')
3 cmd = "#{iptables}-save -c -t #{table} | iptables-xml"
4 xml_str = $vm.execute_successfully(cmd).stdout
5 rexml = REXML::Document.new(xml_str)
6 rexml.get_elements('iptables-rules/table/chain').each do |element|
8 element.attribute('name').to_s,
9 element.attribute('policy').to_s,
10 element.get_elements('rule')
15 def ip4tables_chains(table = 'filter', &block)
16 iptables_chains_parse('iptables', table, &block)
19 def ip6tables_chains(table = 'filter', &block)
20 iptables_chains_parse('ip6tables', table, &block)
23 def iptables_rules_parse(iptables, chain, table)
24 iptables_chains_parse(iptables, table) do |name, _, rules|
25 return rules if name == chain
30 def iptables_rules(chain, table = 'filter')
31 iptables_rules_parse('iptables', chain, table)
34 def ip6tables_rules(chain, table = 'filter')
35 iptables_rules_parse('ip6tables', chain, table)
38 def ip4tables_packet_counter_sum(**filters)
40 ip4tables_chains do |name, _, rules|
41 next if filters[:tables] && !filters[:tables].include?(name)
45 !rule.elements["conditions/owner/uid-owner[text()=#{filters[:uid]}]"]
49 pkts += rule.attribute('packet-count').to_s.to_i
55 def try_xml_element_text(element, xpath, default = nil)
56 node = element.elements[xpath]
57 node.nil? || !node.has_text? ? default : node.text
60 Then /^the firewall's policy is to (.+) all IPv4 traffic$/ do |expected_policy|
61 expected_policy.upcase!
62 ip4tables_chains do |name, policy, _|
63 if ['INPUT', 'FORWARD', 'OUTPUT'].include?(name)
64 assert_equal(expected_policy, policy,
65 "Chain #{name} has unexpected policy #{policy}")
70 Then /^the firewall is configured to only allow the (.+) users? to connect directly to the Internet over IPv4$/ do |users_str|
71 users = users_str.split(/, | and /)
72 expected_uids = Set.new
74 expected_uids << $vm.execute_successfully("id -u #{user}").stdout.to_i
76 allowed_output = iptables_rules('OUTPUT').select do |rule|
77 out_iface = rule.elements['conditions/match/o']
78 is_maybe_accepted = rule.get_elements('actions/*').find do |action|
79 !['DROP', 'REJECT', 'LOG'].include?(action.name)
83 # nil => match all interfaces according to iptables-xml
85 ((out_iface.text == 'lo') \
87 (out_iface.attribute('invert').to_s == '1'))
91 allowed_output.each do |rule|
92 rule.elements.each('actions/*') do |action|
93 destination = try_xml_element_text(rule, 'conditions/match/d')
94 if action.name == 'ACCEPT'
95 # nil == 0.0.0.0/0 according to iptables-xml
96 assert(destination == '0.0.0.0/0' || destination.nil?,
97 "The following rule has an unexpected destination:\n" +
99 state_cond = try_xml_element_text(rule, 'conditions/state/state')
100 next if state_cond == 'ESTABLISHED'
102 assert_not_nil(rule.elements['conditions/owner/uid-owner'])
103 rule.elements.each('conditions/owner/uid-owner') do |owner|
104 uid = owner.text.to_i
106 assert(expected_uids.include?(uid),
107 "The following rule allows uid #{uid} to access the " \
108 "network, but we only expect uids #{expected_uids.to_a} " \
109 "(#{users_str}) to have such access:\n#{rule}")
111 elsif action.name == 'call' && action.elements[1].name == 'lan'
112 lan_subnets = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']
113 assert(lan_subnets.include?(destination),
114 "The following lan-targeted rule's destination is " \
115 "#{destination} which may not be a private subnet:\n" +
118 raise "Unexpected iptables OUTPUT chain rule:\n#{rule}"
122 uids_not_found = expected_uids - uids
123 assert(uids_not_found.empty?,
124 "Couldn't find rules allowing uids #{uids_not_found.to_a} " \
125 'access to the network')
128 Then /^the firewall's NAT rules only redirect traffic for Tor's TransPort and DNSPort$/ do
129 loopback_address = '127.0.0.1/32'
130 tor_onion_addr_space = '127.192.0.0/10'
131 tor_trans_port = '9040'
133 tor_dns_port = '5353'
134 ip4tables_chains('nat') do |name, _, rules|
136 good_rules = rules.select do |rule|
137 redirect = rule.get_elements('actions/*').all? do |action|
138 action.name == 'REDIRECT'
140 destination = try_xml_element_text(rule, 'conditions/match/d')
141 redir_port = try_xml_element_text(rule, 'actions/REDIRECT/to-ports')
142 redirected_to_trans_port = redir_port == tor_trans_port
143 udp_destination_port = try_xml_element_text(rule,
144 'conditions/udp/dport')
145 dns_redirected_to_tor_dns_port = (udp_destination_port == dns_port) &&
146 (redir_port == tor_dns_port)
149 (destination == tor_onion_addr_space && redirected_to_trans_port) ||
150 (destination == loopback_address && dns_redirected_to_tor_dns_port)
153 bad_rules = rules - good_rules
154 assert(bad_rules.empty?,
155 "The NAT table's OUTPUT chain contains some unexpected " \
156 "rules:\n#{bad_rules}")
159 "The NAT table contains unexpected rules for the #{name} " \
165 Then /^the firewall is configured to block all external IPv6 traffic$/ do
166 ip6_loopback = '::1/128'
167 expected_policy = 'DROP'
168 ip6tables_chains do |name, policy, rules|
169 assert_equal(expected_policy, policy,
170 "The IPv6 #{name} chain has policy #{policy} but we " \
171 "expected #{expected_policy}")
172 good_rules = rules.select do |rule|
173 ['DROP', 'REJECT', 'LOG'].any? do |target|
174 rule.elements["actions/#{target}"]
177 ['s', 'd'].all? do |x|
178 try_xml_element_text(rule, "conditions/match/#{x}") == ip6_loopback
181 bad_rules = rules - good_rules
182 assert(bad_rules.empty?,
183 "The IPv6 table's #{name} chain contains some unexpected rules:\n" +
184 bad_rules.map(&:to_s).join("\n"))
188 def firewall_has_dropped_packet_to?(proto, host, port)
189 regex = '^Dropped outbound packet: .* '
190 regex += "DST=#{Regexp.escape(host)} .* "
191 regex += "PROTO=#{Regexp.escape(proto)} "
192 regex += ".* DPT=#{port} " if port
193 $vm.execute("journalctl --dmesg --output=cat | grep -qP '#{regex}'").success?
196 When /^I open an untorified (TCP|UDP|ICMP) connection to (\S*)(?: on port (\d+))?$/ do |proto, host, port|
197 assert(!firewall_has_dropped_packet_to?(proto, host, port),
198 "A #{proto} packet to #{host}" +
199 (port.nil? ? '' : ":#{port}") +
200 ' has already been dropped by the firewall')
207 cmd = "echo | nc.traditional #{host} #{port}"
211 cmd = "echo | nc.traditional -u #{host} #{port}"
214 cmd = "ping -c 5 #{host}"
217 @conn_res = $vm.execute(cmd, user: user)
220 Then /^the untorified connection fails$/ do
223 expected_in_stderr = 'Connection refused'
224 conn_failed = !@conn_res.success? &&
225 @conn_res.stderr.chomp.end_with?(expected_in_stderr)
227 conn_failed = !@conn_res.success?
230 "The untorified #{@conn_proto} connection didn't fail as expected:\n" +
234 Then /^the untorified connection is logged as dropped by the firewall$/ do
235 assert(firewall_has_dropped_packet_to?(@conn_proto, @conn_host, @conn_port),
236 "No #{@conn_proto} packet to #{@conn_host}" +
237 (@conn_port.nil? ? '' : ":#{@conn_port}") +
238 ' was dropped by the firewall')
241 When /^the system DNS is(?: still)? using the local DNS resolver$/ do
242 resolvconf = $vm.file_content('/etc/resolv.conf')
243 bad_lines = resolvconf.split("\n").select do |line|
244 !line.start_with?('#') && !/^nameserver\s+127\.0\.0\.1$/.match(line)
246 assert_empty(bad_lines,
247 "The following bad lines were found in /etc/resolv.conf:\n" +
248 bad_lines.join("\n"))
251 STREAM_ISOLATION_INFO = {
253 grep_monitor_expr: 'users:(("curl"',
256 'tails-security-check' => {
257 grep_monitor_expr: 'users:(("tails-security-"',
260 'tails-upgrade-frontend-wrapper' => {
261 grep_monitor_expr: 'users:(("tails-iuk-get-u"',
265 grep_monitor_expr: 'users:(("firefox\.real"',
270 grep_monitor_expr: 'users:(("\(nc\|ssh\)"',
274 grep_monitor_expr: 'users:(("whois"',
279 def stream_isolation_info(application)
280 STREAM_ISOLATION_INFO[application] || \
281 raise("Unknown application '#{application}' for the stream isolation tests")
284 When /^I monitor the network connections of (.*)$/ do |application|
285 @process_monitor_log = '/tmp/ss.log'
286 info = stream_isolation_info(application)
287 $vm.spawn('while true; do ' \
288 " ss -taupen | grep '#{info[:grep_monitor_expr]}'; " \
290 "done > #{@process_monitor_log}")
293 Then /^I see that (.+) is properly stream isolated(?: after (\d+) seconds)?$/ do |application, delay|
294 sleep delay.to_i if delay
295 info = stream_isolation_info(application)
296 expected_ports = [info[:socksport]]
297 expected_ports << 9051 if info[:controller]
298 assert_not_nil(@process_monitor_log)
299 log_lines = $vm.file_content(@process_monitor_log).split("\n")
300 assert(!log_lines.empty?,
301 "Couldn't see any connection made by #{application} so " \
302 'something is wrong')
303 log_lines.each do |line|
304 ip_port = line.split(/\s+/)[5]
305 assert(expected_ports.map { |port| "127.0.0.1:#{port}" }.include?(ip_port),
306 "#{application} should only connect to #{expected_ports} but " \
307 "was seen connecting to #{ip_port}")
311 And /^I re-run tails-security-check$/ do
312 $vm.execute_successfully(
313 'systemctl --user restart tails-security-check.service',
318 And /^I re-run htpdate$/ do
319 $vm.execute_successfully('service htpdate stop && ' \
320 'rm -f /run/htpdate/* && ' \
321 'systemctl --no-block start htpdate.service')
322 step 'the time has synced'
325 And /^I re-run tails-upgrade-frontend-wrapper$/ do
326 $vm.execute_successfully('tails-upgrade-frontend-wrapper', user: LIVE_USER)
329 When /^the Tor Launcher autostarts$/ do
330 @screen.wait('TorLauncherWindow.png', 60)
333 When /^I configure some (\w+) pluggable transports in Tor Launcher$/ do |bridge_type|
334 @screen.wait('TorLauncherConfigureButton.png', 10).click
335 @screen.wait('TorLauncherBridgeCheckbox.png', 10).click
336 @screen.wait('TorLauncherBridgeList.png', 10).click
338 bridge_dirs = Dir.glob(
339 "#{$config['TMPDIR']}/chutney-data/nodes/*#{bridge_type}/"
341 bridge_dirs.each do |bridge_dir|
342 address = $vmnet.bridge_ip_addr
346 if bridge_type == 'bridge'
347 File.open(bridge_dir + '/torrc') do |f|
348 port = f.grep(/^OrPort\b/).first.split.last
351 # This is the pluggable transport case. While we could set a
352 # static port via ServerTransportListenAddr we instead let it be
353 # picked randomly so an already used port is not picked --
354 # Chutney already has issues with that for OrPort selection.
355 pt_re = /Registered server transport '#{bridge_type}' at '[^']*:(\d+)'/
356 File.open(bridge_dir + '/notice.log') do |f|
357 pt_lines = f.grep(pt_re)
358 port = pt_lines.last.match(pt_re)[1]
360 if bridge_type == 'obfs4'
361 File.open(bridge_dir + '/pt_state/obfs4_bridgeline.txt') do |f|
362 extra = f.readlines.last.chomp.sub(/^.* cert=/, 'cert=')
366 File.open(bridge_dir + '/fingerprint') do |f|
367 fingerprint = f.read.chomp.split.last
369 @bridge_hosts << { address: address, port: port.to_i }
370 bridge_line = bridge_type + ' ' + address + ':' + port
371 [fingerprint, extra].each { |e| bridge_line += ' ' + e.to_s if e }
372 @screen.type(bridge_line, ['Return'])
375 @screen.wait('TorLauncherFinishButton.png', 10).click
376 @screen.wait('TorLauncherConnectingWindow.png', 10)
377 @screen.wait_vanish('TorLauncherConnectingWindow.png', 120)
380 When /^all Internet traffic has only flowed through the configured pluggable transports$/ do
381 assert_not_nil(@bridge_hosts, 'No bridges has been configured via the ' \
382 "'I configure some ... bridges in Tor Launcher' step")
383 assert_all_connections(@sniffer.pcap_file) do |c|
384 @bridge_hosts.include?({ address: c.daddr, port: c.dport })