From a6f41c35e337db192e612ee6e1545fcae4c69ac7 Mon Sep 17 00:00:00 2001 From: Philip Hands Date: Thu, 29 Jun 2017 22:11:09 +0200 Subject: lvc: grab updates from tails (01371c19bd..6ae59c49e5) Signed-off-by: Holger Levsen --- cucumber/features/support/config.rb | 18 +- cucumber/features/support/env.rb | 36 ++++ cucumber/features/support/extra_hooks.rb | 54 +++-- cucumber/features/support/helpers/dogtail.rb | 233 +++++++++++++++++++++ cucumber/features/support/helpers/exec_helper.rb | 90 -------- .../features/support/helpers/firewall_helper.rb | 187 +++++++---------- cucumber/features/support/helpers/misc_helpers.rb | 139 +++++++++--- cucumber/features/support/helpers/remote_shell.rb | 171 +++++++++++++++ cucumber/features/support/helpers/sikuli_helper.rb | 32 ++- .../features/support/helpers/sniffing_helper.rb | 14 +- .../features/support/helpers/storage_helper.rb | 39 ++-- cucumber/features/support/helpers/vm_helper.rb | 206 +++++++++--------- cucumber/features/support/hooks.rb | 53 ++++- 13 files changed, 881 insertions(+), 391 deletions(-) create mode 100644 cucumber/features/support/helpers/dogtail.rb delete mode 100644 cucumber/features/support/helpers/exec_helper.rb create mode 100644 cucumber/features/support/helpers/remote_shell.rb (limited to 'cucumber/features/support') diff --git a/cucumber/features/support/config.rb b/cucumber/features/support/config.rb index 13578d55..54a0f1cd 100644 --- a/cucumber/features/support/config.rb +++ b/cucumber/features/support/config.rb @@ -74,25 +74,11 @@ LIBVIRT_REMOTE_SHELL_PORT = 13370 + Integer($executor_number) MISC_FILES_DIR = "/srv/jenkins/cucumber/features/misc_files" SERVICES_EXPECTED_ON_ALL_IFACES = [ - ["cupsd", "0.0.0.0", "631"], - ["dhclient", "0.0.0.0", "*"] + ["cupsd", "*", "631"], + ["dhclient", "*", "*"] ] # OpenDNS SOME_DNS_SERVER = "208.67.222.222" -TOR_AUTHORITIES = - # List grabbed from Tor's sources, src/or/config.c:~750. - [ - "86.59.21.38", - "128.31.0.39", - "194.109.206.212", - "82.94.251.203", - "199.254.238.52", - "131.188.40.189", - "193.23.244.244", - "208.83.223.34", - "171.25.193.9", - "154.35.175.225", - ] VM_XML_PATH = "/srv/jenkins/cucumber/features/domains" #TAILS_SIGNING_KEY = cmd_helper(". #{Dir.pwd}/config/amnesia; echo ${AMNESIA_DEV_KEYID}").tr(' ', '').chomp diff --git a/cucumber/features/support/env.rb b/cucumber/features/support/env.rb index 53f502e1..c52affff 100644 --- a/cucumber/features/support/env.rb +++ b/cucumber/features/support/env.rb @@ -23,6 +23,10 @@ def create_git Dir.mkdir 'config' FileUtils.touch('config/base_branch') Dir.mkdir('config/APT_overlays.d') + Dir.mkdir('config/APT_snapshots.d') + ['debian', 'debian-security', 'torproject'].map do |origin| + Dir.mkdir("config/APT_snapshots.d/#{origin}") + end Dir.mkdir 'debian' File.open('debian/changelog', 'w') do |changelog| changelog.write(<= 2.0. Once we stop # supporting <2.0 we should probably do this differently, but this way # we can easily support both at the same time. + begin if not(Cucumber::Core::Ast::Feature.instance_methods.include?(:accept_hook?)) - require 'gherkin/tag_expression' + if Gem::Version.new(Cucumber::VERSION) >= Gem::Version.new('2.4.0') + require 'cucumber/core/gherkin/tag_expression' + else + require 'gherkin/tag_expression' + Cucumber::Core::Gherkin = Gherkin + end class Cucumber::Core::Ast::Feature # Code inspired by Cucumber::Core::Test::Case.match_tags?() in # cucumber-ruby-core 1.1.3, lib/cucumber/core/test/case.rb:~59. def accept_hook?(hook) - tag_expr = Gherkin::TagExpression.new(hook.tag_expressions.flatten) - tags = @tags.map do |t| - Gherkin::Formatter::Model::Tag.new(t.name, t.line) - end - tag_expr.evaluate(tags) + tag_expr = Cucumber::Core::Gherkin::TagExpression.new(hook.tag_expressions.flatten) + tag_expr.evaluate(@tags) end end end @@ -53,10 +56,10 @@ if not($at_exit_print_artifacts_dir_patching_done) alias old_print_stats print_stats end def print_stats(*args) - if Dir.exists?(ARTIFACTS_DIR) and Dir.entries(ARTIFACTS_DIR).size > 2 - @io.puts "Artifacts directory: #{ARTIFACTS_DIR}" - @io.puts - end + @io.puts "Artifacts directory: #{ARTIFACTS_DIR}" + @io.puts + @io.puts "Debug log: #{ARTIFACTS_DIR}/debug.log" + @io.puts if self.class.method_defined?(:old_print_stats) old_print_stats(*args) end @@ -74,7 +77,16 @@ def info_log(message = "", options = {}) end def debug_log(message, options = {}) - $debug_log_fns.each { |fn| fn.call(message, options) } if $debug_log_fns + options[:timestamp] = true unless options.has_key?(:timestamp) + if $debug_log_fns + if options[:timestamp] + # Force UTC so the local timezone difference vs UTC won't be + # added to the result. + elapsed = (Time.now - TIME_AT_START.to_f).utc.strftime("%H:%M:%S.%9N") + message = "#{elapsed}: #{message}" + end + $debug_log_fns.each { |fn| fn.call(message, options) } + end end require 'cucumber/formatter/pretty' @@ -104,8 +116,11 @@ module ExtraFormatters # anything. We only use it do hook into the correct events so we can # add our extra hooks. class ExtraHooks - def initialize(*args) + def initialize(runtime, io, options) # We do not care about any of the arguments. + # XXX: We should be able to just have `*args` for the arguments + # in the prototype, but since moving to cucumber 2.4 that breaks + # this formatter for some unknown reason. end def before_feature(feature) @@ -127,8 +142,8 @@ module ExtraFormatters # The pretty formatter with debug logging mixed into its output. class PrettyDebug < Cucumber::Formatter::Pretty - def initialize(*args) - super(*args) + def initialize(runtime, io, options) + super(runtime, io, options) $debug_log_fns ||= [] $debug_log_fns << self.method(:debug_log) end @@ -160,6 +175,13 @@ AfterConfiguration do |config| # AfterConfiguration hook multiple times. We only want our # ExtraHooks formatter to be loaded once, otherwise the hooks would # be run miltiple times. - extra_hooks = ['ExtraFormatters::ExtraHooks', '/dev/null'] - config.formats << extra_hooks if not(config.formats.include?(extra_hooks)) + extra_hooks = [ + ['ExtraFormatters::ExtraHooks', '/dev/null'], + ['Cucumber::Formatter::Pretty', "#{ARTIFACTS_DIR}/pretty.log"], + ['Cucumber::Formatter::Json', "#{ARTIFACTS_DIR}/cucumber.json"], + ['ExtraFormatters::PrettyDebug', "#{ARTIFACTS_DIR}/debug.log"], + ] + extra_hooks.each do |hook| + config.formats << hook if not(config.formats.include?(hook)) + end end diff --git a/cucumber/features/support/helpers/dogtail.rb b/cucumber/features/support/helpers/dogtail.rb new file mode 100644 index 00000000..2a92649b --- /dev/null +++ b/cucumber/features/support/helpers/dogtail.rb @@ -0,0 +1,233 @@ +module Dogtail + module Mouse + LEFT_CLICK = 1 + MIDDLE_CLICK = 2 + RIGHT_CLICK = 3 + end + + TREE_API_NODE_SEARCHES = [ + :button, + :child, + :childLabelled, + :childNamed, + :dialog, + :menu, + :menuItem, + :tab, + :textentry, + ] + + TREE_API_NODE_SEARCH_FIELDS = [ + :parent, + ] + + TREE_API_NODE_ACTIONS = [ + :click, + :doubleClick, + :grabFocus, + :keyCombo, + :point, + :typeText, + ] + + TREE_API_APP_SEARCHES = TREE_API_NODE_SEARCHES + [ + :dialog, + :window, + ] + + # We want to keep this class immutable so that handles always are + # left intact when doing new (proxied) method calls. This way we + # can support stuff like: + # + # app = Dogtail::Application.new('gedit') + # menu = app.menu('Menu') + # menu.click() + # menu.something_else() + # menu.click() + # + # i.e. the object referenced by `menu` is never modified by method + # calls and can be used as expected. + + class Application + @@node_counter ||= 0 + + def initialize(app_name, opts = {}) + @var = "node#{@@node_counter += 1}" + @app_name = app_name + @opts = opts + @opts[:user] ||= LIVE_USER + @find_code = "dogtail.tree.root.application('#{@app_name}')" + script_lines = [ + "import dogtail.config", + "import dogtail.tree", + "import dogtail.predicate", + "dogtail.config.logDebugToFile = False", + "dogtail.config.logDebugToStdOut = False", + "dogtail.config.blinkOnActions = True", + "dogtail.config.searchShowingOnly = True", + "#{@var} = #{@find_code}", + ] + run(script_lines) + end + + def to_s + @var + end + + def run(code) + code = code.join("\n") if code.class == Array + c = RemoteShell::PythonCommand.new($vm, code, user: @opts[:user]) + if c.failure? + raise RuntimeError.new("The Dogtail script raised: #{c.exception}") + end + return c + end + + def child?(*args) + !!child(*args) + rescue + false + end + + def exist? + run("dogtail.config.searchCutoffCount = 0") + run(@find_code) + return true + rescue + return false + ensure + run("dogtail.config.searchCutoffCount = 20") + end + + def self.value_to_s(v) + if v == true + 'True' + elsif v == false + 'False' + elsif v.class == String + "'#{v}'" + elsif [Fixnum, Float].include?(v.class) + v.to_s + else + raise "#{self.class.name} does not know how to handle argument type '#{v.class}'" + end + end + + # Generates a Python-style parameter list from `args`. If the last + # element of `args` is a Hash, it's used as Python's kwargs dict. + # In the end, the resulting string should be possible to copy-paste + # into the parentheses of a Python function call. + # Example: [42, {:foo => 'bar'}] => "42, foo = 'bar'" + def self.args_to_s(args) + return "" if args.size == 0 + args_list = args + args_hash = nil + if args_list.class == Array && args_list.last.class == Hash + *args_list, args_hash = args_list + end + ( + (args_list.nil? ? [] : args_list.map { |e| self.value_to_s(e) }) + + (args_hash.nil? ? [] : args_hash.map { |k, v| "#{k}=#{self.value_to_s(v)}" }) + ).join(', ') + end + + # Equivalent to the Tree API's Node.findChildren(), with the + # arguments constructing a GenericPredicate to use as parameter. + def children(*args) + non_predicates = [:recursive, :showingOnly] + findChildren_opts = [] + findChildren_opts_hash = Hash.new + if args.last.class == Hash + args_hash = args.last + non_predicates.each do |opt| + if args_hash.has_key?(opt) + findChildren_opts_hash[opt] = args_hash[opt] + args_hash.delete(opt) + end + end + end + findChildren_opts = "" + if findChildren_opts_hash.size > 0 + findChildren_opts = ", " + self.class.args_to_s([findChildren_opts_hash]) + end + predicate_opts = self.class.args_to_s(args) + nodes_var = "nodes#{@@node_counter += 1}" + find_script_lines = [ + "#{nodes_var} = #{@var}.findChildren(dogtail.predicate.GenericPredicate(#{predicate_opts})#{findChildren_opts})", + "print(len(#{nodes_var}))", + ] + size = run(find_script_lines).stdout.chomp.to_i + return size.times.map do |i| + Node.new("#{nodes_var}[#{i}]", @opts) + end + end + + def get_field(key) + run("print(#{@var}.#{key})").stdout.chomp + end + + def set_field(key, value) + run("#{@var}.#{key} = #{self.class.value_to_s(value)}") + end + + def text + get_field('text') + end + + def text=(value) + set_field('text', value) + end + + def name + get_field('name') + end + + def roleName + get_field('roleName') + end + + TREE_API_APP_SEARCHES.each do |method| + define_method(method) do |*args| + args_str = self.class.args_to_s(args) + method_call = "#{method.to_s}(#{args_str})" + Node.new("#{@var}.#{method_call}", @opts) + end + end + + TREE_API_NODE_SEARCH_FIELDS.each do |field| + define_method(field) do + Node.new("#{@var}.#{field}", @opts) + end + end + + end + + class Node < Application + + def initialize(expr, opts = {}) + @expr = expr + @opts = opts + @opts[:user] ||= LIVE_USER + @find_code = expr + @var = "node#{@@node_counter += 1}" + run("#{@var} = #{@find_code}") + end + + TREE_API_NODE_SEARCHES.each do |method| + define_method(method) do |*args| + args_str = self.class.args_to_s(args) + method_call = "#{method.to_s}(#{args_str})" + Node.new("#{@var}.#{method_call}", @opts) + end + end + + TREE_API_NODE_ACTIONS.each do |method| + define_method(method) do |*args| + args_str = self.class.args_to_s(args) + method_call = "#{method.to_s}(#{args_str})" + run("#{@var}.#{method_call}") + end + end + + end +end diff --git a/cucumber/features/support/helpers/exec_helper.rb b/cucumber/features/support/helpers/exec_helper.rb deleted file mode 100644 index 70d22d37..00000000 --- a/cucumber/features/support/helpers/exec_helper.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'json' -require 'socket' -require 'io/wait' - -class VMCommand - - attr_reader :cmd, :returncode, :stdout, :stderr - - def initialize(vm, cmd, options = {}) - @cmd = cmd - @returncode, @stdout, @stderr = VMCommand.execute(vm, cmd, options) - end - - def VMCommand.wait_until_remote_shell_is_up(vm, timeout = 180) - try_for(timeout, :msg => "Remote shell seems to be down") do - Timeout::timeout(20) do - VMCommand.execute(vm, "echo 'hello?'") - end - end - end - - # The parameter `cmd` cannot contain newlines. Separate multiple - # commands using ";" instead. - # If `:spawn` is false the server will block until it has finished - # executing `cmd`. If it's true the server won't block, and the - # response will always be [0, "", ""] (only used as an - # ACK). execute() will always block until a response is received, - # though. Spawning is useful when starting processes in the - # background (or running scripts that does the same) like our - # onioncircuits wrapper, or any application we want to interact with. - def VMCommand.execute(vm, cmd, options = {}) - options[:user] ||= "root" - options[:spawn] ||= false - type = options[:spawn] ? "spawn" : "call" - socket = TCPSocket.new("127.0.0.1", vm.get_remote_shell_port) - debug_log("#{type}ing as #{options[:user]}: #{cmd}") - begin - sleep 0.5 - while socket.ready? - s = socket.recv(1024) - debug_log("#{type} pre-exit-debris: #{s}") if not(options[:spawn]) - end - socket.puts( "\nexit\n") - sleep 1 - s = socket.readline(sep = "\007") - debug_log("#{type} post-exit-read: #{s}") if not(options[:spawn]) - while socket.ready? - s = socket.recv(1024) - debug_log("#{type} post-exit-debris: #{s}") if not(options[:spawn]) - end - socket.puts( cmd + "\n") - s = socket.readline(sep = "\000") - debug_log("#{type} post-cmd-read: #{s}") if not(options[:spawn]) - s.chomp!("\000") - ensure - debug_log("closing the remote-command socket") if not(options[:spawn]) - socket.close - end - (s, s_err, x) = s.split("\037") - s_err = "" if s_err.nil? - (s, s_retcode, y) = s.split("\003") - (s, s_out, z) = s.split("\002") - s_out = "" if s_out.nil? - - if (s_retcode.to_i.to_s == s_retcode.to_s && x.nil? && y.nil? && z.nil?) then - debug_log("returning [returncode=`#{s_retcode.to_i}`,\n\toutput=`#{s_out}`,\n\tstderr=`#{s_err}`]\nwhile discarding `#{s}`.") if not(options[:spawn]) - return [s_retcode.to_i, s_out, s_err] - else - debug_log("failed to parse results, retrying\n") - return VMCommand.execute(vm, cmd, options) - end - end - - def success? - return @returncode == 0 - end - - def failure? - return not(success?) - end - - def to_s - "Return status: #{@returncode}\n" + - "STDOUT:\n" + - @stdout + - "STDERR:\n" + - @stderr - end - -end diff --git a/cucumber/features/support/helpers/firewall_helper.rb b/cucumber/features/support/helpers/firewall_helper.rb index fce363c5..f88091de 100644 --- a/cucumber/features/support/helpers/firewall_helper.rb +++ b/cucumber/features/support/helpers/firewall_helper.rb @@ -1,121 +1,94 @@ require 'packetfu' -require 'ipaddr' -# Extent IPAddr with a private/public address space checks -class IPAddr - PrivateIPv4Ranges = [ - IPAddr.new("10.0.0.0/8"), - IPAddr.new("172.16.0.0/12"), - IPAddr.new("192.168.0.0/16"), - IPAddr.new("255.255.255.255/32") - ] - - PrivateIPv6Ranges = [ - IPAddr.new("fc00::/7") - ] - - def private? - private_ranges = self.ipv4? ? PrivateIPv4Ranges : PrivateIPv6Ranges - private_ranges.any? { |range| range.include?(self) } - end - - def public? - !private? - end +def looks_like_dhcp_packet?(eth_packet, protocol, sport, dport, ip_packet) + protocol == "udp" && sport == 68 && dport == 67 && + eth_packet.eth_daddr == "ff:ff:ff:ff:ff:ff" && + ip_packet && ip_packet.ip_daddr == "255.255.255.255" end -class FirewallLeakCheck - attr_reader :ipv4_tcp_leaks, :ipv4_nontcp_leaks, :ipv6_leaks, :nonip_leaks, :mac_leaks - - def initialize(pcap_file, options = {}) - options[:accepted_hosts] ||= [] - options[:ignore_lan] ||= true - @pcap_file = pcap_file - packets = PacketFu::PcapFile.new.file_to_array(:filename => @pcap_file) - mac_leaks = Set.new - ipv4_tcp_packets = [] - ipv4_nontcp_packets = [] - ipv6_packets = [] - nonip_packets = [] - packets.each do |p| - if PacketFu::EthPacket.can_parse?(p) - packet = PacketFu::EthPacket.parse(p) - mac_leaks << packet.eth_saddr - mac_leaks << packet.eth_daddr - end - - if PacketFu::TCPPacket.can_parse?(p) - ipv4_tcp_packets << PacketFu::TCPPacket.parse(p) - elsif PacketFu::IPPacket.can_parse?(p) - ipv4_nontcp_packets << PacketFu::IPPacket.parse(p) - elsif PacketFu::IPv6Packet.can_parse?(p) - ipv6_packets << PacketFu::IPv6Packet.parse(p) - elsif PacketFu::Packet.can_parse?(p) - nonip_packets << PacketFu::Packet.parse(p) - else - save_pcap_file - raise "Found something in the pcap file that cannot be parsed" - end +# Returns the unique edges (based on protocol, source/destination +# address/port) in the graph of all network flows. +def pcap_connections_helper(pcap_file, opts = {}) + opts[:ignore_dhcp] = true unless opts.has_key?(:ignore_dhcp) + connections = Array.new + packets = PacketFu::PcapFile.new.file_to_array(:filename => pcap_file) + packets.each do |p| + if PacketFu::EthPacket.can_parse?(p) + eth_packet = PacketFu::EthPacket.parse(p) + else + raise 'Found something that is not an ethernet packet' + end + sport = nil + dport = nil + if PacketFu::IPv6Packet.can_parse?(p) + ip_packet = PacketFu::IPv6Packet.parse(p) + protocol = 'ipv6' + elsif PacketFu::TCPPacket.can_parse?(p) + ip_packet = PacketFu::TCPPacket.parse(p) + protocol = 'tcp' + sport = ip_packet.tcp_sport + dport = ip_packet.tcp_dport + elsif PacketFu::UDPPacket.can_parse?(p) + ip_packet = PacketFu::UDPPacket.parse(p) + protocol = 'udp' + sport = ip_packet.udp_sport + dport = ip_packet.udp_dport + elsif PacketFu::ICMPPacket.can_parse?(p) + ip_packet = PacketFu::ICMPPacket.parse(p) + protocol = 'icmp' + elsif PacketFu::IPPacket.can_parse?(p) + ip_packet = PacketFu::IPPacket.parse(p) + protocol = 'ip' + else + raise "Found something that cannot be parsed" end - ipv4_tcp_hosts = filter_hosts_from_ippackets(ipv4_tcp_packets, - options[:ignore_lan]) - accepted = Set.new(options[:accepted_hosts]) - @mac_leaks = mac_leaks - @ipv4_tcp_leaks = ipv4_tcp_hosts.select { |host| !accepted.member?(host) } - @ipv4_nontcp_leaks = filter_hosts_from_ippackets(ipv4_nontcp_packets, - options[:ignore_lan]) - @ipv6_leaks = filter_hosts_from_ippackets(ipv6_packets, - options[:ignore_lan]) - @nonip_leaks = nonip_packets - end - def save_pcap_file - save_failure_artifact("Network capture", @pcap_file) - end + next if opts[:ignore_dhcp] && + looks_like_dhcp_packet?(eth_packet, protocol, + sport, dport, ip_packet) - # Returns a list of all unique destination IP addresses found in - # `packets`. Exclude LAN hosts if ignore_lan is set. - def filter_hosts_from_ippackets(packets, ignore_lan) - hosts = [] - packets.each do |p| - candidate = nil - if p.kind_of?(PacketFu::IPPacket) - candidate = p.ip_daddr - elsif p.kind_of?(PacketFu::IPv6Packet) - candidate = p.ipv6_header.ipv6_daddr - else - save_pcap_file - raise "Expected an IP{v4,v6} packet, but got something else:\n" + - p.peek_format - end - if candidate != nil and (not(ignore_lan) or IPAddr.new(candidate).public?) - hosts << candidate + packet_info = { + mac_saddr: eth_packet.eth_saddr, + mac_daddr: eth_packet.eth_daddr, + protocol: protocol, + sport: sport, + dport: dport, + } + + begin + packet_info[:saddr] = ip_packet.ip_saddr + packet_info[:daddr] = ip_packet.ip_daddr + rescue NoMethodError, NameError + begin + packet_info[:saddr] = ip_packet.ipv6_saddr + packet_info[:daddr] = ip_packet.ipv6_daddr + rescue NoMethodError, NameError + puts "We were hit by #11508. PacketFu bug? Packet info: #{ip_packet}" + packet_info[:saddr] = nil + packet_info[:daddr] = nil end end - hosts.uniq + connections << packet_info end + connections.uniq.map { |p| OpenStruct.new(p) } +end - def assert_no_leaks - err = "" - if !@ipv4_tcp_leaks.empty? - err += "The following IPv4 TCP non-Tor Internet hosts were " + - "contacted:\n" + ipv4_tcp_leaks.join("\n") - end - if !@ipv4_nontcp_leaks.empty? - err += "The following IPv4 non-TCP Internet hosts were contacted:\n" + - ipv4_nontcp_leaks.join("\n") - end - if !@ipv6_leaks.empty? - err += "The following IPv6 Internet hosts were contacted:\n" + - ipv6_leaks.join("\n") - end - if !@nonip_leaks.empty? - err += "Some non-IP packets were sent\n" - end - if !err.empty? - save_pcap_file - raise err - end +class FirewallAssertionFailedError < Test::Unit::AssertionFailedError +end + +# These assertions are made from the perspective of the system under +# testing when it comes to the concepts of "source" and "destination". +def assert_all_connections(pcap_file, opts = {}, &block) + all = pcap_connections_helper(pcap_file, opts) + good = all.find_all(&block) + bad = all - good + unless bad.empty? + raise FirewallAssertionFailedError.new( + "Unexpected connections were made:\n" + + bad.map { |e| " #{e}" } .join("\n")) end +end +def assert_no_connections(pcap_file, opts = {}, &block) + assert_all_connections(pcap_file, opts) { |*args| not(block.call(*args)) } end diff --git a/cucumber/features/support/helpers/misc_helpers.rb b/cucumber/features/support/helpers/misc_helpers.rb index 7e09411f..865d2978 100644 --- a/cucumber/features/support/helpers/misc_helpers.rb +++ b/cucumber/features/support/helpers/misc_helpers.rb @@ -1,4 +1,6 @@ require 'date' +require 'io/console' +require 'pry' require 'timeout' require 'test/unit' @@ -28,8 +30,12 @@ end # Call block (ignoring any exceptions it may throw) repeatedly with # one second breaks until it returns true, or until `timeout` seconds have -# passed when we throw a Timeout::Error exception. +# passed when we throw a Timeout::Error exception. If `timeout` is `nil`, +# then we just run the code block with no timeout. def try_for(timeout, options = {}) + if block_given? && timeout.nil? + return yield + end options[:delay] ||= 1 last_exception = nil # Create a unique exception used only for this particular try_for @@ -76,11 +82,12 @@ def try_for(timeout, options = {}) # ends up there immediately. rescue unique_timeout_exception => e msg = options[:msg] || 'try_for() timeout expired' + exc_class = options[:exception] || Timeout::Error if last_exception msg += "\nLast ignored exception was: " + "#{last_exception.class}: #{last_exception}" end - raise Timeout::Error.new(msg) + raise exc_class.new(msg) end class TorFailure < StandardError @@ -89,6 +96,19 @@ end class MaxRetriesFailure < StandardError end +def force_new_tor_circuit() + debug_log("Forcing new Tor circuit...") + # Tor rate limits NEWNYM to at most one per 10 second period. + interval = 10 + if $__last_newnym + elapsed = Time.now - $__last_newnym + # We sleep an extra second to avoid tight timings. + sleep interval - elapsed + 1 if 0 < elapsed && elapsed < interval + end + $vm.execute_successfully('tor_control_send "signal NEWNYM"', :libs => 'tor') + $__last_newnym = Time.now +end + # This will retry the block up to MAX_NEW_TOR_CIRCUIT_RETRIES # times. The block must raise an exception for a run to be considered # as a failure. After a failure recovery_proc will be called (if @@ -105,11 +125,6 @@ def retry_tor(recovery_proc = nil, &block) :operation_name => 'Tor operation', &block) end -def retry_i2p(recovery_proc = nil, &block) - retry_action(15, :recovery_proc => recovery_proc, - :operation_name => 'I2P operation', &block) -end - def retry_action(max_retries, options = {}, &block) assert(max_retries.is_a?(Integer), "max_retries must be an integer") options[:recovery_proc] ||= nil @@ -120,6 +135,10 @@ def retry_action(max_retries, options = {}, &block) begin block.call return + rescue NameError => e + # NameError most likely means typos, and hiding that is rarely + # (never?) a good idea, so we rethrow them. + raise e rescue Exception => e if retries <= max_retries debug_log("#{options[:operation_name]} failed (Try #{retries} of " + @@ -136,16 +155,15 @@ def retry_action(max_retries, options = {}, &block) end end +alias :retry_times :retry_action + +class TorBootstrapFailure < StandardError +end + def wait_until_tor_is_working try_for(270) { $vm.execute('/usr/local/sbin/tor-has-bootstrapped').success? } -rescue Timeout::Error => e - c = $vm.execute("journalctl SYSLOG_IDENTIFIER=restart-tor") - if c.success? - debug_log("From the journal:\n" + c.stdout.sub(/^/, " ")) - else - debug_log("Nothing was in the journal about 'restart-tor'") - end - raise e +rescue Timeout::Error + raise TorBootstrapFailure.new('Tor failed to bootstrap') end def convert_bytes_mod(unit) @@ -177,13 +195,14 @@ def convert_from_bytes(size, unit) return size.to_f/convert_bytes_mod(unit).to_f end -def cmd_helper(cmd) +def cmd_helper(cmd, env = {}) if cmd.instance_of?(Array) cmd << {:err => [:child, :out]} elsif cmd.instance_of?(String) cmd += " 2>&1" end - IO.popen(cmd) do |p| + env = ENV.to_h.merge(env) + IO.popen(env, cmd) do |p| out = p.readlines.join("\n") p.close ret = $? @@ -192,11 +211,23 @@ def cmd_helper(cmd) end end -# This command will grab all router IP addresses from the Tor -# consensus in the VM + the hardcoded TOR_AUTHORITIES. -def get_all_tor_nodes - cmd = 'awk "/^r/ { print \$6 }" /var/lib/tor/cached-microdesc-consensus' - $vm.execute(cmd).stdout.chomp.split("\n") + TOR_AUTHORITIES +def all_tor_hosts + nodes = Array.new + chutney_torrcs = Dir.glob( + "#{$config['TMPDIR']}/chutney-data/nodes/*/torrc" + ) + chutney_torrcs.each do |torrc| + open(torrc) do |f| + nodes += f.grep(/^(Or|Dir)Port\b/).map do |line| + { address: $vmnet.bridge_ip_addr, port: line.split.last.to_i } + end + end + end + return nodes +end + +def allowed_hosts_under_tor_enforcement + all_tor_hosts + @lan_hosts end def get_free_space(machine, path) @@ -246,8 +277,68 @@ def info_log_artifact_location(type, path) info_log("#{type.capitalize}: #{path}") end +def notify_user(message) + alarm_script = $config['NOTIFY_USER_COMMAND'] + return if alarm_script.nil? || alarm_script.empty? + cmd_helper(alarm_script.gsub('%m', message)) +end + def pause(message = "Paused") + notify_user(message) + STDERR.puts + STDERR.puts message + # Ring the ASCII bell for a helpful notification in most terminal + # emulators. + STDOUT.write "\a" STDERR.puts - STDERR.puts "#{message} (Press ENTER to continue!)" - STDIN.gets + loop do + STDERR.puts "Return: Continue; d: Debugging REPL" + c = STDIN.getch + case c + when "\r" + return + when "d" + binding.pry(quiet: true) + end + end +end + +def dbus_send(service, object_path, method, *args, **opts) + opts ||= {} + ruby_type_to_dbus_type = { + String => 'string', + Fixnum => 'int32', + } + typed_args = args.map do |arg| + type = ruby_type_to_dbus_type[arg.class] + assert_not_nil(type, "No DBus type conversion for Ruby type '#{arg.class}'") + "#{type}:#{arg}" + end + ret = $vm.execute_successfully( + "dbus-send --print-reply --dest=#{service} #{object_path} " + + " #{method} #{typed_args.join(' ')}", + **opts + ).stdout.lines + # The first line written is about timings and other stuff we don't + # care about; we only care about the return values. + ret.shift + ret.map! do |s| + type, val = /^\s*(\S+)\s+(\S+)$/.match(s)[1,2] + case type + when 'string' + # Unquote + val[1, val.length - 2] + when 'int32' + val.to_i + else + raise "No Ruby type conversion for DBus type '#{type}'" + end + end + if ret.size == 0 + return nil + elsif ret.size == 1 + return ret.first + else + return ret + end end diff --git a/cucumber/features/support/helpers/remote_shell.rb b/cucumber/features/support/helpers/remote_shell.rb new file mode 100644 index 00000000..b890578b --- /dev/null +++ b/cucumber/features/support/helpers/remote_shell.rb @@ -0,0 +1,171 @@ +require 'base64' +require 'json' +require 'socket' +require 'timeout' + +module RemoteShell + class ServerFailure < StandardError + end + + # Used to differentiate vs Timeout::Error, which is thrown by + # try_for() (by default) and often wraps around remote shell usage + # -- in that case we don't want to catch that "outer" exception in + # our handling of remote shell timeouts below. + class Timeout < ServerFailure + end + + DEFAULT_TIMEOUT = 20*60 + + # Counter providing unique id:s for each communicate() call. + @@request_id ||= 0 + + def communicate(vm, *args, **opts) + opts[:timeout] ||= DEFAULT_TIMEOUT + socket = TCPSocket.new("127.0.0.1", vm.get_remote_shell_port) + id = (@@request_id += 1) + # Since we already have defined our own Timeout in the current + # scope, we have to be more careful when referring to the Timeout + # class from the 'timeout' module. However, note that we want it + # to throw our own Timeout exception. + Object::Timeout.timeout(opts[:timeout], Timeout) do + socket.puts(JSON.dump([id] + args)) + socket.flush + loop do + line = socket.readline("\n").chomp("\n") + response_id, status, *rest = JSON.load(line) + if response_id == id + if status != "success" + if status == "error" and rest.class == Array and rest.size == 1 + msg = rest.first + raise ServerFailure.new("#{msg}") + else + raise ServerFailure.new("Uncaught exception: #{status}: #{rest}") + end + end + return rest + else + debug_log("Dropped out-of-order remote shell response: " + + "got id #{response_id} but expected id #{id}") + end + end + end + ensure + socket.close if defined?(socket) && socket + end + + module_function :communicate + private :communicate + + class ShellCommand + # If `:spawn` is false the server will block until it has finished + # executing `cmd`. If it's true the server won't block, and the + # response will always be [0, "", ""] (only used as an + # ACK). execute() will always block until a response is received, + # though. Spawning is useful when starting processes in the + # background (or running scripts that does the same) or any + # application we want to interact with. + def self.execute(vm, cmd, **opts) + opts[:user] ||= "root" + opts[:spawn] = false unless opts.has_key?(:spawn) + type = opts[:spawn] ? "spawn" : "call" + debug_log("#{type}ing as #{opts[:user]}: #{cmd}") + ret = RemoteShell.communicate(vm, 'sh_' + type, opts[:user], cmd, **opts) + debug_log("#{type} returned: #{ret}") if not(opts[:spawn]) + return ret + end + + attr_reader :cmd, :returncode, :stdout, :stderr + + def initialize(vm, cmd, **opts) + @cmd = cmd + @returncode, @stdout, @stderr = self.class.execute(vm, cmd, **opts) + end + + def success? + return @returncode == 0 + end + + def failure? + return not(success?) + end + + def to_s + "Return status: #{@returncode}\n" + + "STDOUT:\n" + + @stdout + + "STDERR:\n" + + @stderr + end + end + + class PythonCommand + def self.execute(vm, code, **opts) + opts[:user] ||= "root" + show_code = code.chomp + if show_code["\n"] + show_code = "\n" + show_code.lines.map { |l| " "*4 + l.chomp } .join("\n") + end + debug_log("executing Python as #{opts[:user]}: #{show_code}") + ret = RemoteShell.communicate( + vm, 'python_execute', opts[:user], code, **opts + ) + debug_log("execution complete") + return ret + end + + attr_reader :code, :exception, :stdout, :stderr + + def initialize(vm, code, **opts) + @code = code + @exception, @stdout, @stderr = self.class.execute(vm, code, **opts) + end + + def success? + return @exception == nil + end + + def failure? + return not(success?) + end + + def to_s + "Exception: #{@exception}\n" + + "STDOUT:\n" + + @stdout + + "STDERR:\n" + + @stderr + end + end + + # An IO-like object that is more or less equivalent to a File object + # opened in rw mode. + class File + def self.open(vm, mode, path, *args, **opts) + debug_log("opening file #{path} in '#{mode}' mode") + ret = RemoteShell.communicate(vm, 'file_' + mode, path, *args, **opts) + if ret.size != 1 + raise ServerFailure.new("expected 1 value but got #{ret.size}") + end + debug_log("#{mode} complete") + return ret.first + end + + attr_reader :vm, :path + + def initialize(vm, path) + @vm, @path = vm, path + end + + def read() + Base64.decode64(self.class.open(@vm, 'read', @path)) + end + + def write(data) + self.class.open(@vm, 'write', @path, Base64.encode64(data)) + end + + def append(data) + self.class.open(@vm, 'append', @path, Base64.encode64(data)) + end + end +end diff --git a/cucumber/features/support/helpers/sikuli_helper.rb b/cucumber/features/support/helpers/sikuli_helper.rb index 553abd97..167eded3 100644 --- a/cucumber/features/support/helpers/sikuli_helper.rb +++ b/cucumber/features/support/helpers/sikuli_helper.rb @@ -1,9 +1,19 @@ require 'rjb' require 'rjbextension' $LOAD_PATH << ENV['SIKULI_HOME'] -require 'sikuli-script.jar' +begin + require 'sikulixapi.jar' + USING_SIKULIX = true +rescue LoadError + require 'sikuli-script.jar' + USING_SIKULIX = false +end Rjb::load +def using_sikulix? + USING_SIKULIX +end + package_members = [ "java.io.FileOutputStream", "java.io.PrintStream", @@ -16,11 +26,18 @@ package_members = [ "org.sikuli.script.Pattern", "org.sikuli.script.Region", "org.sikuli.script.Screen", - "org.sikuli.script.Settings", ] +if using_sikulix? + package_members << "org.sikuli.basics.Settings" + package_members << "org.sikuli.script.ImagePath" +else + package_members << "org.sikuli.script.Settings" +end + translations = Hash[ "org.sikuli.script", "Sikuli", + "org.sikuli.basics", "Sikuli", "java.lang", "Java::Lang", "java.io", "Java::Io", ] @@ -186,13 +203,20 @@ def sikuli_script_proxy.new(*args) end def s.hide_cursor - self.hover_point(self.w, self.h/2) + self.hover_point(self.w - 1, self.h/2) end s end # Configure sikuli +if using_sikulix? + Sikuli::ImagePath.add("#{Dir.pwd}/features/images/") +else + java.lang.System.setProperty("SIKULI_IMAGE_PATH", + "#{Dir.pwd}/features/images/") + ENV["SIKULI_IMAGE_PATH"] = "#{Dir.pwd}/features/images/" +end # ruby and rjb doesn't play well together when it comes to static # fields (and possibly methods) so we instantiate and access the field @@ -210,5 +234,5 @@ sikuli_settings.MinSimilarity = 0.9 sikuli_settings.ActionLogs = true sikuli_settings.DebugLogs = false sikuli_settings.InfoLogs = true -sikuli_settings.ProfileLogs = true +sikuli_settings.ProfileLogs = false sikuli_settings.WaitScanRate = 0.25 diff --git a/cucumber/features/support/helpers/sniffing_helper.rb b/cucumber/features/support/helpers/sniffing_helper.rb index 213411eb..38b13820 100644 --- a/cucumber/features/support/helpers/sniffing_helper.rb +++ b/cucumber/features/support/helpers/sniffing_helper.rb @@ -22,8 +22,18 @@ class Sniffer end def capture(filter="not ether src host #{@vmnet.bridge_mac} and not ether proto \\arp and not ether proto \\rarp") - job = IO.popen(["/usr/sbin/tcpdump", "-n", "-i", @vmnet.bridge_name, "-w", - @pcap_file, "-U", filter, :err => ["/dev/null", "w"]]) + job = IO.popen( + [ + "/usr/sbin/tcpdump", + "-n", + "-U", + "--immediate-mode", + "-i", @vmnet.bridge_name, + "-w", @pcap_file, + filter, + :err => ["/dev/null", "w"] + ] + ) @pid = job.pid end diff --git a/cucumber/features/support/helpers/storage_helper.rb b/cucumber/features/support/helpers/storage_helper.rb index de782eed..3bbdb69c 100644 --- a/cucumber/features/support/helpers/storage_helper.rb +++ b/cucumber/features/support/helpers/storage_helper.rb @@ -25,7 +25,8 @@ class VMStorage rescue Libvirt::RetrieveError @pool = nil end - if @pool and not(KEEP_SNAPSHOTS) + if @pool and (not(KEEP_SNAPSHOTS) or + (KEEP_SNAPSHOTS and not(Dir.exists?(@pool_path)))) VMStorage.clear_storage_pool(@pool) @pool = nil end @@ -79,6 +80,10 @@ class VMStorage VMStorage.clear_storage_pool_volumes(@pool) end + def list_volumes + @pool.list_volumes + end + def delete_volume(name) @pool.lookup_volume_by_name(name).delete end @@ -144,13 +149,7 @@ class VMStorage end def disk_mklabel(name, parttype) - disk = { - :path => disk_path(name), - :opts => { - :format => disk_format(name) - } - } - guestfs_disk_helper(disk) do |g, disk_handle| + guestfs_disk_helper(name) do |g, disk_handle| g.part_init(disk_handle, parttype) end end @@ -158,13 +157,7 @@ class VMStorage def disk_mkpartfs(name, parttype, fstype, opts = {}) opts[:label] ||= nil opts[:luks_password] ||= nil - disk = { - :path => disk_path(name), - :opts => { - :format => disk_format(name) - } - } - guestfs_disk_helper(disk) do |g, disk_handle| + guestfs_disk_helper(name) do |g, disk_handle| g.part_disk(disk_handle, parttype) g.part_set_name(disk_handle, 1, opts[:label]) if opts[:label] primary_partition = g.list_partitions()[0] @@ -182,13 +175,7 @@ class VMStorage end def disk_mkswap(name, parttype) - disk = { - :path => disk_path(name), - :opts => { - :format => disk_format(name) - } - } - guestfs_disk_helper(disk) do |g, disk_handle| + guestfs_disk_helper(name) do |g, disk_handle| g.part_disk(disk_handle, parttype) primary_partition = g.list_partitions()[0] g.mkswap(primary_partition) @@ -206,7 +193,13 @@ class VMStorage Guestfs::EVENT_TRACE) g.set_autosync(1) disks.each do |disk| - g.add_drive_opts(disk[:path], disk[:opts]) + if disk.class == String + g.add_drive_opts(disk_path(disk), format: disk_format(disk)) + elsif disk.class == Hash + g.add_drive_opts(disk[:path], disk[:opts]) + else + raise "cannot handle type '#{disk.class}'" + end end g.launch() yield(g, *g.list_devices()) diff --git a/cucumber/features/support/helpers/vm_helper.rb b/cucumber/features/support/helpers/vm_helper.rb index 5d02c115..be3ae5ff 100644 --- a/cucumber/features/support/helpers/vm_helper.rb +++ b/cucumber/features/support/helpers/vm_helper.rb @@ -1,3 +1,4 @@ +require 'ipaddr' require 'libvirt' require 'rexml/document' @@ -55,11 +56,6 @@ class VMNet IPAddr.new(net_xml.elements['network/ip'].attributes['address']).to_s end - def guest_real_mac - net_xml = REXML::Document.new(@net.xml_desc) - net_xml.elements['network/ip/dhcp/host/'].attributes['mac'] - end - def bridge_mac File.open("/sys/class/net/#{bridge_name}/address", "rb").read.chomp end @@ -68,7 +64,7 @@ end class VM - attr_reader :domain, :display, :vmnet, :storage + attr_reader :domain, :domain_name, :display, :vmnet, :storage def initialize(virt, xml_path, vmnet, storage, x_display) @virt = virt @@ -114,8 +110,20 @@ class VM end end - def real_mac - @vmnet.guest_real_mac + def real_mac(alias_name) + REXML::Document.new(@domain.xml_desc) + .elements["domain/devices/interface[@type='network']/" + + "alias[@name='#{alias_name}']"] + .parent.elements['mac'].attributes['address'].to_s + end + + def all_real_macs + macs = [] + REXML::Document.new(@domain.xml_desc) + .elements.each("domain/devices/interface[@type='network']") do |nic| + macs << nic.elements['mac'].attributes['address'].to_s + end + macs end def set_hardware_clock(time) @@ -131,6 +139,11 @@ class VM update(domain_rexml.to_s) end + def network_link_state + REXML::Document.new(@domain.xml_desc) + .elements['domain/devices/interface/link'].attributes['state'] + end + def set_network_link_state(state) domain_xml = REXML::Document.new(@domain.xml_desc) domain_xml.elements['domain/devices/interface/link'].attributes['state'] = state @@ -158,30 +171,44 @@ class VM update(domain_xml.to_s) end - def set_cdrom_image(image) - image = nil if image == '' - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements.each('domain/devices/disk') do |e| - if e.attribute('device').to_s == "cdrom" - if image.nil? - e.elements.delete('source') - else - if ! e.elements['source'] - e.add_element('source') - end - e.elements['source'].attributes['file'] = image - end - if is_running? - @domain.update_device(e.to_s) - else - update(domain_xml.to_s) - end - end + def add_cdrom_device + if is_running? + raise "Can't attach a CDROM device to a running domain" + end + domain_rexml = REXML::Document.new(@domain.xml_desc) + if domain_rexml.elements["domain/devices/disk[@device='cdrom']"] + raise "A CDROM device already exists" end + cdrom_rexml = REXML::Document.new(File.read("#{@xml_path}/cdrom.xml")).root + domain_rexml.elements['domain/devices'].add_element(cdrom_rexml) + update(domain_rexml.to_s) end - def remove_cdrom - set_cdrom_image(nil) + def remove_cdrom_device + if is_running? + raise "Can't detach a CDROM device to a running domain" + end + domain_rexml = REXML::Document.new(@domain.xml_desc) + cdrom_el = domain_rexml.elements["domain/devices/disk[@device='cdrom']"] + if cdrom_el.nil? + raise "No CDROM device is present" + end + domain_rexml.elements["domain/devices"].delete_element(cdrom_el) + update(domain_rexml.to_s) + end + + def eject_cdrom + execute_successfully('/usr/bin/eject -m') + end + + def remove_cdrom_image + domain_rexml = REXML::Document.new(@domain.xml_desc) + cdrom_el = domain_rexml.elements["domain/devices/disk[@device='cdrom']"] + if cdrom_el.nil? + raise "No CDROM device is present" + end + cdrom_el.delete_element('source') + update(domain_rexml.to_s) rescue Libvirt::Error => e # While the CD-ROM is removed successfully we still get this # error, so let's ignore it. @@ -192,12 +219,27 @@ class VM raise e if not(Regexp.new(acceptable_error).match(e.to_s)) end + def set_cdrom_image(image) + if image.nil? or image == '' + raise "Can't set cdrom image to an empty string" + end + remove_cdrom_image + domain_rexml = REXML::Document.new(@domain.xml_desc) + cdrom_el = domain_rexml.elements["domain/devices/disk[@device='cdrom']"] + cdrom_el.add_element('source', { 'file' => image }) + update(domain_rexml.to_s) + end + def set_cdrom_boot(image) if is_running? raise "boot settings can only be set for inactive vms" end - set_boot_device('cdrom') + domain_rexml = REXML::Document.new(@domain.xml_desc) + if not domain_rexml.elements["domain/devices/disk[@device='cdrom']"] + add_cdrom_device + end set_cdrom_image(image) + set_boot_device('cdrom') end def list_disk_devs @@ -209,6 +251,16 @@ class VM return ret end + def plug_device(xml) + if is_running? + @domain.attach_device(xml.to_s) + else + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/devices'].add_element(xml) + update(domain_xml.to_s) + end + end + def plug_drive(name, type) if disk_plugged?(name) raise "disk '#{name}' already plugged" @@ -238,13 +290,7 @@ class VM xml.elements['disk/target'].attributes['bus'] = type xml.elements['disk/target'].attributes['removable'] = removable_usb if removable_usb - if is_running? - @domain.attach_device(xml.to_s) - else - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements['domain/devices'].add_element(xml) - update(domain_xml.to_s) - end + plug_device(xml) end def disk_xml_desc(name) @@ -320,9 +366,16 @@ class VM end plug_drive(name, type) if not(disk_plugged?(name)) set_boot_device('hd') - # For some reason setting the boot device doesn't prevent cdrom - # boot unless it's empty - remove_cdrom + # XXX:Stretch: since our isotesters upgraded QEMU from + # 2.5+dfsg-4~bpo8+1 to 2.6+dfsg-3.1~bpo8+1 it seems we must remove + # the CDROM device to allow disk boot. This is not the case with the same + # version on Debian Sid. Let's hope we can remove this ugly + # workaround when we only support running the automated test suite + # on Stretch. + domain_rexml = REXML::Document.new(@domain.xml_desc) + if domain_rexml.elements["domain/devices/disk[@device='cdrom']"] + remove_cdrom_device + end end # XXX-9p: Shares don't work together with snapshot save+restore. See @@ -353,59 +406,6 @@ class VM return list end - def set_ram_size(size, unit = "KiB") - raise "System memory can only be added to inactive vms" if is_running? - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements['domain/memory'].text = size - domain_xml.elements['domain/memory'].attributes['unit'] = unit - domain_xml.elements['domain/currentMemory'].text = size - domain_xml.elements['domain/currentMemory'].attributes['unit'] = unit - update(domain_xml.to_s) - end - - def get_ram_size_in_bytes - domain_xml = REXML::Document.new(@domain.xml_desc) - unit = domain_xml.elements['domain/memory'].attribute('unit').to_s - size = domain_xml.elements['domain/memory'].text.to_i - return convert_to_bytes(size, unit) - end - - def set_arch(arch) - raise "System architecture can only be set to inactive vms" if is_running? - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements['domain/os/type'].attributes['arch'] = arch - update(domain_xml.to_s) - end - - def add_hypervisor_feature(feature) - raise "Hypervisor features can only be added to inactive vms" if is_running? - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements['domain/features'].add_element(feature) - update(domain_xml.to_s) - end - - def drop_hypervisor_feature(feature) - raise "Hypervisor features can only be fropped from inactive vms" if is_running? - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements['domain/features'].delete_element(feature) - update(domain_xml.to_s) - end - - def disable_pae_workaround - # add_hypervisor_feature("nonpae") results in a libvirt error, and - # drop_hypervisor_feature("pae") alone won't disable pae. Hence we - # use this workaround. - xml = < - - - -EOF - domain_xml = REXML::Document.new(@domain.xml_desc) - domain_xml.elements['domain'].add_element(REXML::Document.new(xml)) - update(domain_xml.to_s) - end - def set_os_loader(type) if is_running? raise "boot settings can only be set for inactive vms" @@ -431,7 +431,7 @@ EOF def execute(cmd, options = {}) options[:user] ||= "root" - options[:spawn] ||= false + options[:spawn] = false unless options.has_key?(:spawn) if options[:libs] libs = options[:libs] options.delete(:libs) @@ -442,7 +442,7 @@ EOF cmds << cmd cmd = cmds.join(" && ") end - return VMCommand.new(self, cmd, options) + return RemoteShell::ShellCommand.new(self, cmd, options) end def execute_successfully(*args) @@ -470,7 +470,9 @@ EOF end def has_network? - return execute("/sbin/ifconfig eth0 | grep -q 'inet addr'").success? + nmcli_info = execute('nmcli device show eth0').stdout + has_ipv4_addr = /^IP4.ADDRESS(\[\d+\])?:\s*([0-9.\/]+)$/.match(nmcli_info) + network_link_state == 'up' && has_ipv4_addr end def has_process?(process) @@ -483,7 +485,7 @@ EOF def select_virtual_desktop(desktop_number, user = LIVE_USER) assert(desktop_number >= 0 && desktop_number <=3, - "Only values between 0 and 3 are valid virtual desktop numbers") + "Only values between 0 and 1 are valid virtual desktop numbers") execute_successfully( "xdotool set_desktop '#{desktop_number}'", :user => user @@ -504,11 +506,17 @@ EOF # Often when xdotool fails to focus a window it'll work when retried # after redrawing the screen. Switching to a new virtual desktop then # back seems to be a reliable way to handle this. - select_virtual_desktop(3) + # Sadly we have to rely on a lot of sleep() here since there's + # little on the screen etc that we truly can rely on. + sleep 5 + select_virtual_desktop(1) + sleep 5 select_virtual_desktop(0) - sleep 5 # there aren't any visual indicators which can be used here + sleep 5 do_focus(window_title, user) end + rescue + # noop end def file_exist?(file) diff --git a/cucumber/features/support/hooks.rb b/cucumber/features/support/hooks.rb index 1bb6cfd5..a55d361a 100644 --- a/cucumber/features/support/hooks.rb +++ b/cucumber/features/support/hooks.rb @@ -14,18 +14,23 @@ AfterConfiguration do |config| prioritized_features = [ # Features not using snapshots but using large amounts of scratch # space for other reasons: - 'features/erase_memory.feature', 'features/untrusted_partitions.feature', # Features using temporary snapshots: 'features/apt.feature', - 'features/i2p.feature', 'features/root_access_control.feature', 'features/time_syncing.feature', 'features/tor_bridges.feature', + # Features using large amounts of scratch space for other reasons: + 'features/erase_memory.feature', # This feature needs the almost biggest snapshot (USB install, # excluding persistence) and will create yet another disk and # install Tails on it. This should be the peak of disk usage. 'features/usb_install.feature', + # This feature needs a copy of the ISO and creates a new disk. + 'features/usb_upgrade.feature', + # This feature needs a very big snapshot (USB install with persistence) + # and another, network-enabled snapshot. + 'features/emergency_shutdown.feature', ] feature_files = config.feature_files # The &-intersection is specified to keep the element ordering of @@ -127,6 +132,21 @@ def save_failure_artifact(type, path) $failure_artifacts << [type, path] end +# Due to Tails' Tor enforcement, we only allow contacting hosts that +# are Tor nodes or located on the LAN. However, when we try +# to verify that only such hosts are contacted we have a problem -- +# we run all Tor nodes (via Chutney) *and* LAN hosts (used on some +# tests) on the same host, the one running the test suite. Hence we +# need to always explicitly track which nodes are LAN or not. +# +# Warning: when a host is added via this function, it is only added +# for the current scenario. As such, if this is done before saving a +# snapshot, it will not remain after the snapshot is loaded. +def add_lan_host(ipaddr, port) + @lan_hosts ||= [] + @lan_hosts << { address: ipaddr, port: port } +end + BeforeFeature('@product') do |feature| if TAILS_ISO.nil? raise "No ISO image specified, and none could be found in the " + @@ -159,6 +179,7 @@ BeforeFeature('@product') do |feature| $vmstorage = VMStorage.new($virt, VM_XML_PATH) $started_first_product_feature = true end + ensure_chutney_is_running end AfterFeature('@product') do @@ -169,6 +190,10 @@ AfterFeature('@product') do end end end + $vmstorage.list_volumes.each do |vol_name| + next if vol_name == '__internal' + $vmstorage.delete_volume(vol_name) + end end # Cucumber Before hooks are executed in the order they are listed, and @@ -198,6 +223,8 @@ Before('@product') do |scenario| @os_loader = "MBR" @sudo_password = "asdf" @persistence_password = "asdf" + # See comment for add_lan_host() above. + @lan_hosts ||= [] end # Cucumber After hooks are executed in the *reverse* order they are @@ -224,6 +251,11 @@ After('@product') do |scenario| info_log("Scenario failed at time #{elapsed}") screen_capture = @screen.capture save_failure_artifact("Screenshot", screen_capture.getFilename) + if scenario.exception.kind_of?(FirewallAssertionFailedError) + Dir.glob("#{$config["TMPDIR"]}/*.pcap").each do |pcap_file| + save_failure_artifact("Network capture", pcap_file) + end + end $failure_artifacts.sort! $failure_artifacts.each do |type, file| artifact_name = sanitize_filename("#{elapsed}_#{scenario.name}#{File.extname(file)}") @@ -233,7 +265,12 @@ After('@product') do |scenario| info_log info_log_artifact_location(type, artifact_path) end - pause("Scenario failed") if $config["PAUSE_ON_FAIL"] + if $config["INTERACTIVE_DEBUGGING"] + pause( + "Scenario failed: #{scenario.name}. " + + "The error was: #{scenario.exception.class.name}: #{scenario.exception}" + ) + end else if @video_path && File.exist?(@video_path) && not($config['CAPTURE_ALL']) FileUtils.rm(@video_path) @@ -252,14 +289,10 @@ end After('@product', '@check_tor_leaks') do |scenario| @tor_leaks_sniffer.stop if scenario.passed? - if @bridge_hosts.nil? - expected_tor_nodes = get_all_tor_nodes - else - expected_tor_nodes = @bridge_hosts + allowed_nodes = @bridge_hosts ? @bridge_hosts : allowed_hosts_under_tor_enforcement + assert_all_connections(@tor_leaks_sniffer.pcap_file) do |c| + allowed_nodes.include?({ address: c.daddr, port: c.dport }) end - leaks = FirewallLeakCheck.new(@tor_leaks_sniffer.pcap_file, - :accepted_hosts => expected_tor_nodes) - leaks.assert_no_leaks end end -- cgit v1.2.3-54-g00ecf