diff options
Diffstat (limited to 'features/support')
-rw-r--r-- | features/support/config.rb | 34 | ||||
-rw-r--r-- | features/support/env.rb | 53 | ||||
-rw-r--r-- | features/support/extra_hooks.rb | 45 | ||||
-rw-r--r-- | features/support/helpers/display_helper.rb | 51 | ||||
-rw-r--r-- | features/support/helpers/exec_helper.rb | 61 | ||||
-rw-r--r-- | features/support/helpers/firewall_helper.rb | 100 | ||||
-rw-r--r-- | features/support/helpers/misc_helpers.rb | 121 | ||||
-rw-r--r-- | features/support/helpers/net_helper.rb | 42 | ||||
-rw-r--r-- | features/support/helpers/sikuli_helper.rb | 145 | ||||
-rw-r--r-- | features/support/helpers/storage_helper.rb | 143 | ||||
-rw-r--r-- | features/support/helpers/vm_helper.rb | 426 | ||||
-rw-r--r-- | features/support/hooks.rb | 156 |
12 files changed, 1377 insertions, 0 deletions
diff --git a/features/support/config.rb b/features/support/config.rb new file mode 100644 index 00000000..b5f6fcd9 --- /dev/null +++ b/features/support/config.rb @@ -0,0 +1,34 @@ +require 'fileutils' +require "#{Dir.pwd}/features/support/helpers/misc_helpers.rb" + +# Dynamic +$tails_iso = ENV['ISO'] || get_newest_iso +$old_tails_iso = ENV['OLD_ISO'] || get_oldest_iso +$tmp_dir = ENV['TEMP_DIR'] || "/tmp/TailsToaster" +$vm_xml_path = ENV['VM_XML_PATH'] || "#{Dir.pwd}/features/domains" +$misc_files_dir = "#{Dir.pwd}/features/misc_files" +$keep_snapshots = !ENV['KEEP_SNAPSHOTS'].nil? +$x_display = ENV['DISPLAY'] +$debug = !ENV['DEBUG'].nil? +$pause_on_fail = !ENV['PAUSE_ON_FAIL'].nil? +$time_at_start = Time.now +$live_user = cmd_helper(". config/chroot_local-includes/etc/live/config.d/username.conf; echo ${LIVE_USERNAME}").chomp +$sikuli_retry_findfailed = !ENV['SIKULI_RETRY_FINDFAILED'].nil? + +# Static +$configured_keyserver_hostname = 'hkps.pool.sks-keyservers.net' +$services_expected_on_all_ifaces = + [ + ["cupsd", "0.0.0.0", "631"], + ["dhclient", "0.0.0.0", "*"] + ] +$tor_authorities = + # List grabbed from Tor's sources, src/or/config.c:~750. + [ + "128.31.0.39", "86.59.21.38", "194.109.206.212", + "82.94.251.203", "76.73.17.194", "212.112.245.170", + "193.23.244.244", "208.83.223.34", "171.25.193.9", + "154.35.32.5" + ] +# OpenDNS +$some_dns_server = "208.67.222.222" diff --git a/features/support/env.rb b/features/support/env.rb new file mode 100644 index 00000000..523a1d1c --- /dev/null +++ b/features/support/env.rb @@ -0,0 +1,53 @@ +require 'rubygems' +require "#{Dir.pwd}/features/support/extra_hooks.rb" +require 'time' +require 'rspec' + +def fatal_system(str) + unless system(str) + raise StandardError.new("Command exited with #{$?}") + end +end + +def git_exists? + File.exists? '.git' +end + +def create_git + Dir.mkdir 'debian' + File.open('debian/changelog', 'w') do |changelog| + changelog.write(<<END_OF_CHANGELOG) +tails (0) stable; urgency=low + + * First release. + + -- Tails developers <tails@boum.org> Mon, 30 Jan 2012 01:00:00 +0000 +END_OF_CHANGELOG + end + + fatal_system "git init --quiet" + fatal_system "git config user.email 'tails@boum.org'" + fatal_system "git config user.name 'Tails developers'" + fatal_system "git add debian/changelog" + fatal_system "git commit --quiet debian/changelog -m 'First release'" + fatal_system "git branch -M stable" + fatal_system "git branch testing stable" + fatal_system "git branch devel stable" + fatal_system "git branch experimental devel" +end + +RSpec::Matchers.define :have_suite do |suite| + match do |string| + # e.g.: `deb http://deb.tails.boum.org/ 0.10 main contrib non-free` + %r{^deb +http://deb\.tails\.boum\.org/ +#{Regexp.escape(suite)} main}.match(string) + end + failure_message_for_should do |string| + "expected the sources to include #{suite}\nCurrent sources : #{string}" + end + failure_message_for_should_not do |string| + "expected the sources to exclude #{suite}\nCurrent sources : #{string}" + end + description do + "expected an output with #{suite}" + end +end diff --git a/features/support/extra_hooks.rb b/features/support/extra_hooks.rb new file mode 100644 index 00000000..a8addb35 --- /dev/null +++ b/features/support/extra_hooks.rb @@ -0,0 +1,45 @@ +require 'cucumber/formatter/pretty' + +# Sort of inspired by Cucumber::RbSupport::RbHook, but really we just +# want an object with a 'tag_expressions' attribute to make +# accept_hook?() (used below) happy. +class SimpleHook + attr_reader :tag_expressions + + def initialize(tag_expressions, proc) + @tag_expressions = tag_expressions + @proc = proc + end + + def invoke(arg) + @proc.call(arg) + end +end + +def BeforeFeature(*tag_expressions, &block) + $before_feature_hooks ||= [] + $before_feature_hooks << SimpleHook.new(tag_expressions, block) +end + +def AfterFeature(*tag_expressions, &block) + $after_feature_hooks ||= [] + $after_feature_hooks << SimpleHook.new(tag_expressions, block) +end + +module ExtraHooks + class Pretty < Cucumber::Formatter::Pretty + def before_feature(feature) + for hook in $before_feature_hooks do + hook.invoke(feature) if feature.accept_hook?(hook) + end + super if defined?(super) + end + + def after_feature(feature) + for hook in $after_feature_hooks do + hook.invoke(feature) if feature.accept_hook?(hook) + end + super if defined?(super) + end + end +end diff --git a/features/support/helpers/display_helper.rb b/features/support/helpers/display_helper.rb new file mode 100644 index 00000000..354935f0 --- /dev/null +++ b/features/support/helpers/display_helper.rb @@ -0,0 +1,51 @@ + +class Display + + def initialize(domain, x_display) + @domain = domain + @x_display = x_display + end + + def start + start_virtviewer(@domain) + # We wait for the display to be active to not lose actions + # (e.g. key presses via sikuli) that come immediately after + # starting (or restoring) a vm + try_for(20, { :delay => 0.1, :msg => "virt-viewer failed to start"}) { + active? + } + end + + def stop + stop_virtviewer + end + + def restart + stop_virtviewer + start_virtviewer(@domain) + end + + def start_virtviewer(domain) + # virt-viewer forks, so we cannot (easily) get the child pid + # and use it in active? and stop_virtviewer below... + IO.popen(["virt-viewer", "-d", + "-f", + "-r", + "-c", "qemu:///system", + ["--display=", @x_display].join(''), + domain, + "&"].join(' ')) + end + + def active? + p = IO.popen("xprop -display #{@x_display} " + + "-name '#{@domain} (1) - Virt Viewer' 2>/dev/null") + Process.wait(p.pid) + p.close + $? == 0 + end + + def stop_virtviewer + system("killall virt-viewer") + end +end diff --git a/features/support/helpers/exec_helper.rb b/features/support/helpers/exec_helper.rb new file mode 100644 index 00000000..b0d3a9cd --- /dev/null +++ b/features/support/helpers/exec_helper.rb @@ -0,0 +1,61 @@ +require 'json' +require 'socket' + +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 = 30) + begin + Timeout::timeout(timeout) do + VMCommand.execute(vm, "true", { :user => "root", :spawn => false }) + end + rescue Timeout::Error + raise "Remote shell seems to be down" + 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 the + # vidalia-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) + STDERR.puts "#{type}ing as #{options[:user]}: #{cmd}" if $debug + begin + socket.puts(JSON.dump([type, options[:user], cmd])) + s = socket.readline(sep = "\0").chomp("\0") + ensure + socket.close + end + STDERR.puts "#{type} returned: #{s}" if $debug + begin + return JSON.load(s) + rescue JSON::ParserError + # The server often returns something unparsable for the very + # first execute() command issued after a VM start/restore + # (generally from wait_until_remote_shell_is_up()) presumably + # because the TCP -> serial link isn't properly setup yet. All + # will be well after that initial hickup, so we just retry. + return VMCommand.execute(vm, cmd, options) + end + end + + def success? + return @returncode == 0 + end + +end diff --git a/features/support/helpers/firewall_helper.rb b/features/support/helpers/firewall_helper.rb new file mode 100644 index 00000000..400965a5 --- /dev/null +++ b/features/support/helpers/firewall_helper.rb @@ -0,0 +1,100 @@ +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"), # private + ] + + def private? + if self.ipv4? + PrivateIPv4Ranges.each do |ipr| + return true if ipr.include?(self) + end + return false + else + PrivateIPv6Ranges.each do |ipr| + return true if ipr.include?(self) + end + return false + end + end + + def public? + !private? + end +end + +class FirewallLeakCheck + attr_reader :ipv4_tcp_leaks, :ipv4_nontcp_leaks, :ipv6_leaks, :nonip_leaks + + def initialize(pcap_file, tor_relays) + packets = PacketFu::PcapFile.new.file_to_array(:filename => pcap_file) + @tor_relays = tor_relays + ipv4_tcp_packets = [] + ipv4_nontcp_packets = [] + ipv6_packets = [] + nonip_packets = [] + packets.each do |p| + 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 + end + ipv4_tcp_hosts = get_public_hosts_from_ippackets ipv4_tcp_packets + tor_nodes = Set.new(get_all_tor_contacts) + @ipv4_tcp_leaks = ipv4_tcp_hosts.select{|host| !tor_nodes.member?(host)} + @ipv4_nontcp_leaks = get_public_hosts_from_ippackets ipv4_nontcp_packets + @ipv6_leaks = get_public_hosts_from_ippackets ipv6_packets + @nonip_leaks = nonip_packets + end + + # Returns a list of all unique non-LAN destination IP addresses + # found in `packets`. + def get_public_hosts_from_ippackets(packets) + 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 IPAddr.new(candidate).public? + hosts << candidate + end + end + hosts.uniq + end + + # Returns an array of all Tor relays and authorities, i.e. all + # Internet hosts Tails ever should contact. + def get_all_tor_contacts + @tor_relays + $tor_authorities + end + + def empty? + @ipv4_tcp_leaks.empty? and @ipv4_nontcp_leaks.empty? and @ipv6_leaks.empty? and @nonip_leaks.empty? + end + +end diff --git a/features/support/helpers/misc_helpers.rb b/features/support/helpers/misc_helpers.rb new file mode 100644 index 00000000..caf64b80 --- /dev/null +++ b/features/support/helpers/misc_helpers.rb @@ -0,0 +1,121 @@ +require 'date' +require 'timeout' +require 'test/unit' + +# Make all the assert_* methods easily accessible in any context. +include Test::Unit::Assertions + +def assert_vmcommand_success(p, msg = nil) + assert(p.success?, msg.nil? ? "Command failed: #{p.cmd}\n" + \ + "error code: #{p.returncode}\n" \ + "stderr: #{p.stderr}" : \ + msg) +end + +# Call block (ignoring any exceptions it may throw) repeatedly with one +# second breaks until it returns true, or until `t` seconds have +# passed when we throw Timeout::Error. As a precondition, the code +# block cannot throw Timeout::Error. +def try_for(t, options = {}) + options[:delay] ||= 1 + begin + Timeout::timeout(t) do + loop do + begin + return true if yield + rescue Timeout::Error => e + if options[:msg] + raise RuntimeError, options[:msg], caller + else + raise e + end + rescue Exception + # noop + end + sleep options[:delay] + end + end + rescue Timeout::Error => e + if options[:msg] + raise RuntimeError, options[:msg], caller + else + raise e + end + end +end + +def wait_until_tor_is_working + try_for(240) { @vm.execute( + '. /usr/local/lib/tails-shell-library/tor.sh; tor_is_working').success? } +end + +def convert_bytes_mod(unit) + case unit + when "bytes", "b" then mod = 1 + when "KB" then mod = 10**3 + when "k", "KiB" then mod = 2**10 + when "MB" then mod = 10**6 + when "M", "MiB" then mod = 2**20 + when "GB" then mod = 10**9 + when "G", "GiB" then mod = 2**30 + when "TB" then mod = 10**12 + when "T", "TiB" then mod = 2**40 + else + raise "invalid memory unit '#{unit}'" + end + return mod +end + +def convert_to_bytes(size, unit) + return (size*convert_bytes_mod(unit)).to_i +end + +def convert_to_MiB(size, unit) + return (size*convert_bytes_mod(unit) / (2**20)).to_i +end + +def convert_from_bytes(size, unit) + return size.to_f/convert_bytes_mod(unit).to_f +end + +def cmd_helper(cmd) + IO.popen(cmd + " 2>&1") do |p| + out = p.readlines.join("\n") + p.close + ret = $? + assert_equal(0, ret, "Command failed (returned #{ret}): #{cmd}:\n#{out}") + return out + end +end + +def tails_iso_creation_date(path) + label = cmd_helper("/sbin/blkid -p -s LABEL -o value #{path}") + assert(label[/^TAILS \d+(\.\d+)+(~rc\d+)? - \d+$/], + "Got invalid label '#{label}' from Tails image '#{path}'") + return label[/\d+$/] +end + +def sort_isos_by_creation_date + Dir.glob("#{Dir.pwd}/*.iso").sort_by {|f| tails_iso_creation_date(f)} +end + +def get_newest_iso + return sort_isos_by_creation_date.last +end + +def get_oldest_iso + return sort_isos_by_creation_date.first +end + +# This command will grab all router IP addresses from the Tor +# consensus in the VM. +def get_tor_relays + cmd = 'awk "/^r/ { print \$6 }" /var/lib/tor/cached-microdesc-consensus' + @vm.execute(cmd).stdout.chomp.split("\n") +end + +def save_pcap_file + pcap_copy = "#{$tmp_dir}/pcap_with_leaks-#{DateTime.now}" + FileUtils.cp(@sniffer.pcap_file, pcap_copy) + puts "Full network capture available at: #{pcap_copy}" +end diff --git a/features/support/helpers/net_helper.rb b/features/support/helpers/net_helper.rb new file mode 100644 index 00000000..29119195 --- /dev/null +++ b/features/support/helpers/net_helper.rb @@ -0,0 +1,42 @@ +# +# Sniffer is a very dumb wrapper to start and stop tcpdumps instances, possibly +# with customized filters. Captured traffic is stored in files whose name +# depends on the sniffer name. The resulting captured packets for each sniffers +# can be accessed as an array through its `packets` method. +# +# Use of more rubyish internal ways to sniff a network like with pcap-able gems +# is waaay to much resource consumming, notmuch reliable and soooo slow. Let's +# not bother too much with that. :) +# +# Should put all that in a Module. + +class Sniffer + + attr_reader :name, :pcap_file, :pid + + def initialize(name, bridge_name) + @name = name + @bridge_name = bridge_name + @bridge_mac = File.open("/sys/class/net/#{@bridge_name}/address", "rb").read.chomp + @pcap_file = "#{$tmp_dir}/#{name}.pcap" + end + + def capture(filter="not ether src host #{@bridge_mac} and not ether proto \\arp and not ether proto \\rarp") + job = IO.popen("/usr/sbin/tcpdump -n -i #{@bridge_name} -w #{@pcap_file} -U '#{filter}' >/dev/null 2>&1") + @pid = job.pid + end + + def stop + begin + Process.kill("TERM", @pid) + rescue + # noop + end + end + + def clear + if File.exist?(@pcap_file) + File.delete(@pcap_file) + end + end +end diff --git a/features/support/helpers/sikuli_helper.rb b/features/support/helpers/sikuli_helper.rb new file mode 100644 index 00000000..f6211be7 --- /dev/null +++ b/features/support/helpers/sikuli_helper.rb @@ -0,0 +1,145 @@ +require 'rjb' +require 'rjbextension' +$LOAD_PATH << ENV['SIKULI_HOME'] +require 'sikuli-script.jar' +Rjb::load + +package_members = [ + "org.sikuli.script.Finder", + "org.sikuli.script.Key", + "org.sikuli.script.KeyModifier", + "org.sikuli.script.Location", + "org.sikuli.script.Match", + "org.sikuli.script.Pattern", + "org.sikuli.script.Region", + "org.sikuli.script.Screen", + "org.sikuli.script.Settings", + ] + +translations = Hash[ + "org.sikuli.script", "Sikuli", + ] + +for p in package_members + imported_class = Rjb::import(p) + package, ignore, class_name = p.rpartition(".") + next if ! translations.include? package + mod_name = translations[package] + mod = mod_name.split("::").inject(Object) do |parent_obj, child_name| + if parent_obj.const_defined?(child_name, false) + parent_obj.const_get(child_name, false) + else + child_obj = Module.new + parent_obj.const_set(child_name, child_obj) + end + end + mod.const_set(class_name, imported_class) +end + +def findfailed_hook(pic) + STDERR.puts "" + STDERR.puts "FindFailed for: #{pic}" + STDERR.puts "" + STDERR.puts "Update the image and press RETURN to retry" + STDIN.gets +end + +# Since rjb imports Java classes without creating a corresponding +# Ruby class (it's just an instance of Rjb_JavaProxy) we can't +# monkey patch any class, so additional methods must be added +# to each Screen object. +# +# All Java classes' methods are immediately available in the proxied +# Ruby classes, but care has to be given to match their type. For a +# list of methods, see: <http://doc.sikuli.org/javadoc/index.html>. +# The type "PRSML" is a union of Pattern, Region, Screen, Match and +# Location. +# +# Also, due to limitations in Ruby's syntax we can't do: +# def Sikuli::Screen.new +# so we work around it with the following vairable. +sikuli_script_proxy = Sikuli::Screen +$_original_sikuli_screen_new ||= Sikuli::Screen.method :new + +def sikuli_script_proxy.new(*args) + s = $_original_sikuli_screen_new.call(*args) + + if $sikuli_retry_findfailed + # The usage of `_invoke()` below exemplifies how one can wrap + # around Java objects' methods when they're imported using RJB. It + # isn't pretty. The seconds argument is the parameter signature, + # which can be obtained by creating the intended Java object using + # RJB, and then calling its `java_methods` method. + + def s.wait(pic, time) + self._invoke('wait', 'Ljava.lang.Object;D', pic, time) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('wait', 'Ljava.lang.Object;D', pic, time) + end + + def s.find(pic) + self._invoke('find', 'Ljava.lang.Object;', pic) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('find', 'Ljava.lang.Object;', pic) + end + + def s.waitVanish(pic, time) + self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time) + end + + def s.click(pic) + self._invoke('click', 'Ljava.lang.Object;', pic) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('click', 'Ljava.lang.Object;', pic) + end + end + + def s.click_point(x, y) + self.click(Sikuli::Location.new(x, y)) + end + + def s.wait_and_click(pic, time) + self.click(self.wait(pic, time)) + end + + def s.wait_and_double_click(pic, time) + self.doubleClick(self.wait(pic, time)) + end + + def s.hover_point(x, y) + self.hover(Sikuli::Location.new(x, y)) + end + + def s.hide_cursor + self.hover_point(self.w, self.h/2) + end + + s +end + +# Configure sikuli +java.lang.System.setProperty("SIKULI_IMAGE_PATH", "#{Dir.pwd}/features/images/") + +# ruby and rjb doesn't play well together when it comes to static +# fields (and possibly methods) so we instantiate and access the field +# via objects instead. It actually works inside this file, but when +# it's required from "outside", and the file has been completely +# required, ruby's require method complains that the method for the +# field accessor is missing. +sikuli_settings = Sikuli::Settings.new +sikuli_settings.OcrDataPath = $tmp_dir +# sikuli_ruby, which we used before, defaulted to 0.9 minimum +# similarity, so all our current images are adapted to that value. +# Also, Sikuli's default of 0.7 is simply too low (many false +# positives). +sikuli_settings.MinSimilarity = 0.9 +sikuli_settings.ActionLogs = $debug +sikuli_settings.DebugLogs = $debug +sikuli_settings.InfoLogs = $debug +sikuli_settings.ProfileLogs = $debug diff --git a/features/support/helpers/storage_helper.rb b/features/support/helpers/storage_helper.rb new file mode 100644 index 00000000..80a1e1e0 --- /dev/null +++ b/features/support/helpers/storage_helper.rb @@ -0,0 +1,143 @@ +# Helper class for manipulating VM storage *volumes*, i.e. it deals +# only with creation of images and keeps a name => volume path lookup +# table (plugging drives or getting info of plugged devices is done in +# the VM class). We'd like better coupling, but given the ridiculous +# disconnect between Libvirt::StoragePool and Libvirt::Domain (hint: +# they have nothing with each other to do whatsoever) it's what makes +# sense. + +require 'libvirt' +require 'rexml/document' +require 'etc' + +class VMStorage + + @@virt = nil + + def initialize(virt, xml_path) + @@virt ||= virt + @xml_path = xml_path + pool_xml = REXML::Document.new(File.read("#{@xml_path}/storage_pool.xml")) + pool_name = pool_xml.elements['pool/name'].text + begin + @pool = @@virt.lookup_storage_pool_by_name(pool_name) + rescue Libvirt::RetrieveError + # There's no pool with that name, so we don't have to clear it + else + VMStorage.clear_storage_pool(@pool) + end + @pool_path = "#{$tmp_dir}/#{pool_name}" + pool_xml.elements['pool/target/path'].text = @pool_path + @pool = @@virt.define_storage_pool_xml(pool_xml.to_s) + @pool.build + @pool.create + @pool.refresh + end + + def VMStorage.clear_storage_pool_volumes(pool) + was_not_active = !pool.active? + if was_not_active + pool.create + end + pool.list_volumes.each do |vol_name| + vol = pool.lookup_volume_by_name(vol_name) + vol.delete + end + if was_not_active + pool.destroy + end + rescue + # Some of the above operations can fail if the pool's path was + # deleted by external means; let's ignore that. + end + + def VMStorage.clear_storage_pool(pool) + VMStorage.clear_storage_pool_volumes(pool) + pool.destroy if pool.active? + pool.undefine + end + + def clear_pool + VMStorage.clear_storage_pool(@pool) + end + + def clear_volumes + VMStorage.clear_storage_pool_volumes(@pool) + end + + def create_new_disk(name, options = {}) + options[:size] ||= 2 + options[:unit] ||= "GiB" + options[:type] ||= "qcow2" + begin + old_vol = @pool.lookup_volume_by_name(name) + rescue Libvirt::RetrieveError + # noop + else + old_vol.delete + end + uid = Etc::getpwnam("libvirt-qemu").uid + gid = Etc::getgrnam("libvirt-qemu").gid + vol_xml = REXML::Document.new(File.read("#{@xml_path}/volume.xml")) + vol_xml.elements['volume/name'].text = name + size_b = convert_to_bytes(options[:size].to_f, options[:unit]) + vol_xml.elements['volume/capacity'].text = size_b.to_s + vol_xml.elements['volume/target/format'].attributes["type"] = options[:type] + vol_xml.elements['volume/target/path'].text = "#{@pool_path}/#{name}" + vol_xml.elements['volume/target/permissions/owner'].text = uid.to_s + vol_xml.elements['volume/target/permissions/group'].text = gid.to_s + vol = @pool.create_volume_xml(vol_xml.to_s) + @pool.refresh + end + + def clone_to_new_disk(from, to) + begin + old_to_vol = @pool.lookup_volume_by_name(to) + rescue Libvirt::RetrieveError + # noop + else + old_to_vol.delete + end + from_vol = @pool.lookup_volume_by_name(from) + xml = REXML::Document.new(from_vol.xml_desc) + pool_path = REXML::Document.new(@pool.xml_desc).elements['pool/target/path'].text + xml.elements['volume/name'].text = to + xml.elements['volume/target/path'].text = "#{pool_path}/#{to}" + @pool.create_volume_xml_from(xml.to_s, from_vol) + end + + def disk_format(name) + vol = @pool.lookup_volume_by_name(name) + vol_xml = REXML::Document.new(vol.xml_desc) + return vol_xml.elements['volume/target/format'].attributes["type"] + end + + def disk_path(name) + @pool.lookup_volume_by_name(name).path + end + + # We use parted for the disk_mk* functions since it can format + # partitions "inside" the super block device; mkfs.* need a + # partition device (think /dev/sdaX), so we'd have to use something + # like losetup or kpartx, which would require administrative + # privileges. These functions only work for raw disk images. + + # TODO: We should switch to guestfish/libguestfs (which has + # ruby-bindings) so we could use qcow2 instead of raw, and more + # easily use LVM volumes. + + # For type, see label-type for mklabel in parted(8) + def disk_mklabel(name, type) + assert_equal("raw", disk_format(name)) + path = disk_path(name) + cmd_helper("/sbin/parted -s '#{path}' mklabel #{type}") + end + + # For fstype, see fs-type for mkfs in parted(8) + def disk_mkpartfs(name, fstype) + assert(disk_format(name), "raw") + path = disk_path(name) + cmd_helper("/sbin/parted -s '#{path}' mkpartfs primary '#{fstype}' 0% 100%") + end + +end diff --git a/features/support/helpers/vm_helper.rb b/features/support/helpers/vm_helper.rb new file mode 100644 index 00000000..2b5ad291 --- /dev/null +++ b/features/support/helpers/vm_helper.rb @@ -0,0 +1,426 @@ +require 'libvirt' +require 'rexml/document' + +class VM + + # These class attributes will be lazily initialized during the first + # instantiation: + # This is the libvirt connection, of which we only want one and + # which can persist for different VM instances (even in parallel) + @@virt = nil + # This is a storage helper that deals with volume manipulation. The + # storage it deals with persists across VMs, by necessity. + @@storage = nil + + def VM.storage + return @@storage + end + + def storage + return @@storage + end + + attr_reader :domain, :display, :ip, :net + + def initialize(xml_path, x_display) + @@virt ||= Libvirt::open("qemu:///system") + @xml_path = xml_path + default_domain_xml = File.read("#{@xml_path}/default.xml") + update_domain(default_domain_xml) + default_net_xml = File.read("#{@xml_path}/default_net.xml") + update_net(default_net_xml) + @display = Display.new(@domain_name, x_display) + set_cdrom_boot($tails_iso) + plug_network + # unlike the domain and net the storage pool should survive VM + # teardown (so a new instance can use e.g. a previously created + # USB drive), so we only create a new one if there is none. + @@storage ||= VMStorage.new(@@virt, xml_path) + rescue Exception => e + clean_up_net + clean_up_domain + raise e + end + + def update_domain(xml) + domain_xml = REXML::Document.new(xml) + @domain_name = domain_xml.elements['domain/name'].text + clean_up_domain + @domain = @@virt.define_domain_xml(xml) + end + + def update_net(xml) + net_xml = REXML::Document.new(xml) + @net_name = net_xml.elements['network/name'].text + @ip = net_xml.elements['network/ip/dhcp/host/'].attributes['ip'] + clean_up_net + @net = @@virt.define_network_xml(xml) + @net.create + end + + def clean_up_domain + begin + domain = @@virt.lookup_domain_by_name(@domain_name) + domain.destroy if domain.active? + domain.undefine + rescue + end + end + + def clean_up_net + begin + net = @@virt.lookup_network_by_name(@net_name) + net.destroy if net.active? + net.undefine + rescue + end + 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 + if is_running? + @domain.update_device(domain_xml.elements['domain/devices/interface'].to_s) + else + update_domain(domain_xml.to_s) + end + end + + def plug_network + set_network_link_state('up') + end + + def unplug_network + set_network_link_state('down') + end + + def set_cdrom_tray_state(state) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + if e.attribute('device').to_s == "cdrom" + e.elements['target'].attributes['tray'] = state + if is_running? + @domain.update_device(e.to_s) + else + update_domain(domain_xml.to_s) + end + end + end + end + + def eject_cdrom + set_cdrom_tray_state('open') + end + + def close_cdrom + set_cdrom_tray_state('closed') + end + + def set_boot_device(dev) + if is_running? + raise "boot settings can only be set for inactive vms" + end + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/os/boot'].attributes['dev'] = dev + update_domain(domain_xml.to_s) + end + + def set_cdrom_image(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 ! e.elements['source'] + e.add_element('source') + end + e.elements['source'].attributes['file'] = image + if is_running? + @domain.update_device(e.to_s, Libvirt::Domain::DEVICE_MODIFY_FORCE) + else + update_domain(domain_xml.to_s) + end + end + end + end + + def remove_cdrom + set_cdrom_image('') + end + + def set_cdrom_boot(image) + if is_running? + raise "boot settings can only be set for inactice vms" + end + set_boot_device('cdrom') + set_cdrom_image(image) + close_cdrom + end + + def plug_drive(name, type) + # Get the next free /dev/sdX on guest + used_devs = [] + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk/target') do |e| + used_devs <<= e.attribute('dev').to_s + end + letter = 'a' + dev = "sd" + letter + while used_devs.include? dev + letter = (letter[0].ord + 1).chr + dev = "sd" + letter + end + assert letter <= 'z' + + xml = REXML::Document.new(File.read("#{@xml_path}/disk.xml")) + xml.elements['disk/source'].attributes['file'] = @@storage.disk_path(name) + xml.elements['disk/driver'].attributes['type'] = @@storage.disk_format(name) + xml.elements['disk/target'].attributes['dev'] = dev + xml.elements['disk/target'].attributes['bus'] = type + if type == "usb" + xml.elements['disk/target'].attributes['removable'] = 'on' + end + + 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(domain_xml.to_s) + end + end + + def disk_xml_desc(name) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + begin + if e.elements['source'].attribute('file').to_s == @@storage.disk_path(name) + return e.to_s + end + rescue + next + end + end + return nil + end + + def unplug_drive(name) + xml = disk_xml_desc(name) + @domain.detach_device(xml) + end + + def disk_dev(name) + xml = REXML::Document.new(disk_xml_desc(name)) + return "/dev/" + xml.elements['disk/target'].attribute('dev').to_s + end + + def disk_detected?(name) + return execute("test -b #{disk_dev(name)}").success? + end + + def set_disk_boot(name, type) + if is_running? + raise "boot settings can only be set for inactive vms" + end + plug_drive(name, type) + set_boot_device('hd') + # For some reason setting the boot device doesn't prevent cdrom + # boot unless it's empty + remove_cdrom + end + + # XXX-9p: Shares don't work together with snapshot save+restore. See + # XXX-9p in common_steps.rb for more information. + def add_share(source, tag) + if is_running? + raise "shares can only be added to inactice vms" + end + xml = REXML::Document.new(File.read("#{@xml_path}/fs_share.xml")) + xml.elements['filesystem/source'].attributes['dir'] = source + xml.elements['filesystem/target'].attributes['dir'] = tag + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/devices'].add_element(xml) + update_domain(domain_xml.to_s) + end + + def list_shares + list = [] + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/filesystem') do |e| + list << e.elements['target'].attribute('dir').to_s + end + return list + end + + def set_ram_size(size, unit = "KiB") + raise "System memory can only be added to inactice 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(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 inactice vms" if is_running? + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/os/type'].attributes['arch'] = arch + update_domain(domain_xml.to_s) + end + + def add_hypervisor_feature(feature) + raise "Hypervisor features can only be added to inactice vms" if is_running? + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/features'].add_element(feature) + update_domain(domain_xml.to_s) + end + + def drop_hypervisor_feature(feature) + raise "Hypervisor features can only be fropped from inactice vms" if is_running? + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/features'].delete_element(feature) + update_domain(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 + <qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'> + <qemu:arg value='-cpu'/> + <qemu:arg value='pentium,-pae'/> + </qemu:commandline> +EOF + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain'].add_element(REXML::Document.new(xml)) + update_domain(domain_xml.to_s) + end + + def set_os_loader(type) + if is_running? + raise "boot settings can only be set for inactice vms" + end + if type == 'UEFI' + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/os'].add_element(REXML::Document.new( + '<loader>/usr/share/ovmf/OVMF.fd</loader>' + )) + update_domain(domain_xml.to_s) + else + raise "unsupported OS loader type" + end + end + + def is_running? + begin + return @domain.active? + rescue + return false + end + end + + def execute(cmd, user = "root") + return VMCommand.new(self, cmd, { :user => user, :spawn => false }) + end + + def execute_successfully(cmd, user = "root") + p = execute(cmd, user) + assert_vmcommand_success(p) + return p + end + + def spawn(cmd, user = "root") + return VMCommand.new(self, cmd, { :user => user, :spawn => true }) + end + + def wait_until_remote_shell_is_up(timeout = 30) + VMCommand.wait_until_remote_shell_is_up(self, timeout) + end + + def host_to_guest_time_sync + host_time= DateTime.now.strftime("%s").to_s + execute("date -s '@#{host_time}'").success? + end + + def has_network? + return execute("/sbin/ifconfig eth0 | grep -q 'inet addr'").success? + end + + def has_process?(process) + return execute("pidof -x -o '%PPID' " + process).success? + end + + def pidof(process) + return execute("pidof -x -o '%PPID' " + process).stdout.chomp.split + end + + def file_exist?(file) + execute("test -e #{file}").success? + end + + def file_content(file, user = 'root') + # We don't quote #{file} on purpose: we sometimes pass environment variables + # or globs that we want to be interpreted by the shell. + cmd = execute("cat #{file}", user) + assert(cmd.success?, + "Could not cat '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}") + return cmd.stdout + end + + def save_snapshot(path) + @domain.save(path) + @display.stop + end + + def restore_snapshot(path) + # Clean up current domain so its snapshot can be restored + clean_up_domain + Libvirt::Domain::restore(@@virt, path) + @domain = @@virt.lookup_domain_by_name(@domain_name) + @display.start + end + + def start + return if is_running? + @domain.create + @display.start + end + + def reset + # ruby-libvirt 0.4 does not support the reset method. + # XXX: Once we use Jessie, use @domain.reset instead. + system("virsh -c qemu:///system reset " + @domain_name) if is_running? + end + + def power_off + @domain.destroy if is_running? + @display.stop + end + + def destroy + clean_up_domain + clean_up_net + power_off + end + + def take_screenshot(description) + @display.take_screenshot(description) + end + + def get_remote_shell_port + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/serial') do |e| + if e.attribute('type').to_s == "tcp" + return e.elements['source'].attribute('service').to_s.to_i + end + end + end + +end diff --git a/features/support/hooks.rb b/features/support/hooks.rb new file mode 100644 index 00000000..2f2f98c1 --- /dev/null +++ b/features/support/hooks.rb @@ -0,0 +1,156 @@ +require 'fileutils' +require 'time' +require 'tmpdir' + +# For @product tests +#################### + +def delete_snapshot(snapshot) + if snapshot and File.exist?(snapshot) + File.delete(snapshot) + end +rescue Errno::EACCES => e + STDERR.puts "Couldn't delete background snapshot: #{e.to_s}" +end + +def delete_all_snapshots + Dir.glob("#{$tmp_dir}/*.state").each do |snapshot| + delete_snapshot(snapshot) + end +end + +BeforeFeature('@product') do |feature| + if File.exist?($tmp_dir) + if !File.directory?($tmp_dir) + raise "Temporary directory '#{$tmp_dir}' exists but is not a " + + "directory" + end + if !File.owned?($tmp_dir) + raise "Temporary directory '#{$tmp_dir}' must be owned by the " + + "current user" + end + FileUtils.chmod(0755, $tmp_dir) + else + begin + Dir.mkdir($tmp_dir) + rescue Errno::EACCES => e + raise "Cannot create temporary directory: #{e.to_s}" + end + end + delete_all_snapshots if !$keep_snapshots + if $tails_iso.nil? + raise "No Tails ISO image specified, and none could be found in the " + + "current directory" + end + if File.exist?($tails_iso) + # Workaround: when libvirt takes ownership of the ISO image it may + # become unreadable for the live user inside the guest in the + # host-to-guest share used for some tests. + + if !File.world_readable?($tails_iso) + if File.owned?($tails_iso) + File.chmod(0644, $tails_iso) + else + raise "warning: the Tails ISO image must be world readable or be " + + "owned by the current user to be available inside the guest " + + "VM via host-to-guest shares, which is required by some tests" + end + end + else + raise "The specified Tails ISO image '#{$tails_iso}' does not exist" + end + puts "Testing ISO image: #{File.basename($tails_iso)}" + base = File.basename(feature.file, ".feature").to_s + $background_snapshot = "#{$tmp_dir}/#{base}_background.state" +end + +AfterFeature('@product') do + delete_snapshot($background_snapshot) if !$keep_snapshots + VM.storage.clear_volumes if VM.storage +end + +BeforeFeature('@product', '@old_iso') do + if $old_tails_iso.nil? + raise "No old Tails ISO image specified, and none could be found in the " + + "current directory" + end + if !File.exist?($old_tails_iso) + raise "The specified old Tails ISO image '#{$old_tails_iso}' does not exist" + end + if $tails_iso == $old_tails_iso + raise "The old Tails ISO is the same as the Tails ISO we're testing" + end + puts "Using old ISO image: #{File.basename($old_tails_iso)}" +end + +# BeforeScenario +Before('@product') do + @screen = Sikuli::Screen.new + if File.size?($background_snapshot) + @skip_steps_while_restoring_background = true + else + @skip_steps_while_restoring_background = false + end + @theme = "gnome" + @os_loader = "MBR" +end + +# AfterScenario +After('@product') do |scenario| + if (scenario.status != :passed) + time_of_fail = Time.now - $time_at_start + secs = "%02d" % (time_of_fail % 60) + mins = "%02d" % ((time_of_fail / 60) % 60) + hrs = "%02d" % (time_of_fail / (60*60)) + STDERR.puts "Scenario failed at time #{hrs}:#{mins}:#{secs}" + base = File.basename(scenario.feature.file, ".feature").to_s + tmp = @screen.capture.getFilename + out = "#{$tmp_dir}/#{base}-#{DateTime.now}.png" + FileUtils.mv(tmp, out) + STDERR.puts("Took screenshot \"#{out}\"") + if $pause_on_fail + STDERR.puts "" + STDERR.puts "Press ENTER to continue running the test suite" + STDIN.gets + end + end + if @sniffer + @sniffer.stop + @sniffer.clear + end + @vm.destroy if @vm +end + +After('@product', '~@keep_volumes') do + VM.storage.clear_volumes +end + +# For @source tests +################### + +# BeforeScenario +Before('@source') do + @orig_pwd = Dir.pwd + @git_clone = Dir.mktmpdir 'tails-apt-tests' + Dir.chdir @git_clone +end + +# AfterScenario +After('@source') do + Dir.chdir @orig_pwd + FileUtils.remove_entry_secure @git_clone +end + + +# Common +######## + +BeforeFeature('@product', '@source') do |feature| + raise "Feature #{feature.file} is tagged both @product and @source, " + + "which is an impossible combination" +end + +at_exit do + delete_all_snapshots if !$keep_snapshots + VM.storage.clear_pool if VM.storage +end |