Calendar: add FT sprint
[tails/test.git] / features / step_definitions / tor.rb
blob1ff3daaacbdf34acd781297cdd48ba81867cc448
1 def iptables_chains_parse(iptables, table = 'filter')
2   assert(block_given?)
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|
7     yield(
8       element.attribute('name').to_s,
9       element.attribute('policy').to_s,
10       element.get_elements('rule')
11     )
12   end
13 end
15 def ip4tables_chains(table = 'filter', &block)
16   iptables_chains_parse('iptables', table, &block)
17 end
19 def ip6tables_chains(table = 'filter', &block)
20   iptables_chains_parse('ip6tables', table, &block)
21 end
23 def iptables_rules_parse(iptables, chain, table)
24   iptables_chains_parse(iptables, table) do |name, _, rules|
25     return rules if name == chain
26   end
27   nil
28 end
30 def iptables_rules(chain, table = 'filter')
31   iptables_rules_parse('iptables', chain, table)
32 end
34 def ip6tables_rules(chain, table = 'filter')
35   iptables_rules_parse('ip6tables', chain, table)
36 end
38 def ip4tables_packet_counter_sum(**filters)
39   pkts = 0
40   ip4tables_chains do |name, _, rules|
41     next if filters[:tables] && !filters[:tables].include?(name)
43     rules.each do |rule|
44       if filters[:uid] &&
45          !rule.elements["conditions/owner/uid-owner[text()=#{filters[:uid]}]"]
46         next
47       end
49       pkts += rule.attribute('packet-count').to_s.to_i
50     end
51   end
52   pkts
53 end
55 def try_xml_element_text(element, xpath, default = nil)
56   node = element.elements[xpath]
57   node.nil? || !node.has_text? ? default : node.text
58 end
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}")
66     end
67   end
68 end
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
73   users.each do |user|
74     expected_uids << $vm.execute_successfully("id -u #{user}").stdout.to_i
75   end
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)
80     end
81     is_maybe_accepted &&
82       (
83         # nil => match all interfaces according to iptables-xml
84         out_iface.nil? ||
85         ((out_iface.text == 'lo') \
86          == \
87          (out_iface.attribute('invert').to_s == '1'))
88       )
89   end
90   uids = Set.new
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" +
98                rule.to_s)
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
105           uids << uid
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}")
110         end
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" +
116                rule.to_s)
117       else
118         raise "Unexpected iptables OUTPUT chain rule:\n#{rule}"
119       end
120     end
121   end
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'
132   dns_port = '53'
133   tor_dns_port = '5353'
134   ip4tables_chains('nat') do |name, _, rules|
135     if name == 'OUTPUT'
136       good_rules = rules.select do |rule|
137         redirect = rule.get_elements('actions/*').all? do |action|
138           action.name == 'REDIRECT'
139         end
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)
147         redirect &&
148           (
149            (destination == tor_onion_addr_space && redirected_to_trans_port) ||
150            (destination == loopback_address && dns_redirected_to_tor_dns_port)
151          )
152       end
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}")
157     else
158       assert(rules.empty?,
159              "The NAT table contains unexpected rules for the #{name} " \
160              "chain:\n#{rules}")
161     end
162   end
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}"]
175       end \
176       ||
177         ['s', 'd'].all? do |x|
178           try_xml_element_text(rule, "conditions/match/#{x}") == ip6_loopback
179         end
180     end
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"))
185   end
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')
201   @conn_proto = proto
202   @conn_host = host
203   @conn_port = port
204   case proto
205   when 'TCP'
206     assert_not_nil(port)
207     cmd = "echo | nc.traditional #{host} #{port}"
208     user = LIVE_USER
209   when 'UDP'
210     assert_not_nil(port)
211     cmd = "echo | nc.traditional -u #{host} #{port}"
212     user = LIVE_USER
213   when 'ICMP'
214     cmd = "ping -c 5 #{host}"
215     user = 'root'
216   end
217   @conn_res = $vm.execute(cmd, user: user)
220 Then /^the untorified connection fails$/ do
221   case @conn_proto
222   when 'TCP'
223     expected_in_stderr = 'Connection refused'
224     conn_failed = !@conn_res.success? &&
225                   @conn_res.stderr.chomp.end_with?(expected_in_stderr)
226   when 'UDP', 'ICMP'
227     conn_failed = !@conn_res.success?
228   end
229   assert(conn_failed,
230          "The untorified #{@conn_proto} connection didn't fail as expected:\n" +
231          @conn_res.to_s)
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)
245   end
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 = {
252   'htpdate'                        => {
253     grep_monitor_expr: 'users:(("curl"',
254     socksport:         9062,
255   },
256   'tails-security-check'           => {
257     grep_monitor_expr: 'users:(("tails-security-"',
258     socksport:         9062,
259   },
260   'tails-upgrade-frontend-wrapper' => {
261     grep_monitor_expr: 'users:(("tails-iuk-get-u"',
262     socksport:         9062,
263   },
264   'Tor Browser'                    => {
265     grep_monitor_expr: 'users:(("firefox\.real"',
266     socksport:         9150,
267     controller:        true,
268   },
269   'SSH'                            => {
270     grep_monitor_expr: 'users:(("\(nc\|ssh\)"',
271     socksport:         9050,
272   },
273   'whois'                          => {
274     grep_monitor_expr: 'users:(("whois"',
275     socksport:         9050,
276   },
277 }.freeze
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]}'; " \
289             '  sleep 0.1; ' \
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}")
308   end
311 And /^I re-run tails-security-check$/ do
312   $vm.execute_successfully(
313     'systemctl --user restart tails-security-check.service',
314     user: LIVE_USER
315   )
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
337   @bridge_hosts = []
338   bridge_dirs = Dir.glob(
339     "#{$config['TMPDIR']}/chutney-data/nodes/*#{bridge_type}/"
340   )
341   bridge_dirs.each do |bridge_dir|
342     address = $vmnet.bridge_ip_addr
343     port = nil
344     fingerprint = nil
345     extra = nil
346     if bridge_type == 'bridge'
347       File.open(bridge_dir + '/torrc') do |f|
348         port = f.grep(/^OrPort\b/).first.split.last
349       end
350     else
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]
359       end
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=')
363         end
364       end
365     end
366     File.open(bridge_dir + '/fingerprint') do |f|
367       fingerprint = f.read.chomp.split.last
368     end
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'])
373   end
374   @screen.hide_cursor
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 })
385   end