diff options
45 files changed, 2174 insertions, 1351 deletions
diff --git a/cucumber/bin/run_test_suite b/cucumber/bin/run_test_suite index 19128445..f7903639 100755 --- a/cucumber/bin/run_test_suite +++ b/cucumber/bin/run_test_suite @@ -20,13 +20,15 @@ libvirt-clients libvirt-daemon-system libvirt-dev libvirt0 -openjdk-7-jre +obfs4proxy openssh-server ovmf +pry python-jabberbot python-potr qemu-kvm qemu-system-x86 +redir ruby-guestfs ruby-json ruby-libvirt @@ -38,6 +40,7 @@ ruby-rspec ruby-test-unit seabios tcpdump +tor unclutter virt-viewer xvfb @@ -59,9 +62,10 @@ Options for '@product' features: encoding. Requires x264. --capture-all Keep videos for all scenarios, including those that succeed (implies --capture). - --pause-on-fail On failure, pause test suite until pressing Enter. This is - useful for investigating the state of the VM guest to see - exactly why a test failed. + --interactive-debugging + On failure, pause test suite until pressing Enter. Also + offer the option to open an interactive Ruby shell (pry) + in the Cucumber world's context. --keep-snapshots Don't ever delete any snapshots (including ones marked as temporary). This can be a big time saver when debugging new features. @@ -74,7 +78,7 @@ Options for '@product' features: (default is TMPDIR in the environment, and if unset, /tmp/DebianToaster). --view Shows the test session in a windows. Requires x11vnc - and xtightvncviewer. + and tigervnc-viewer. --vnc-server-only Starts a VNC server for the test session. Requires x11vnc. --iso IMAGE Test '@product' features using IMAGE. --old-iso IMAGE For some '@product' features (e.g. usb_install) we need @@ -159,8 +163,8 @@ start_vnc_server() { } start_vnc_viewer() { - check_dependencies xtightvncviewer - xtightvncviewer -viewonly localhost:${VNC_SERVER_PORT} 1>/dev/null 2>&1 & + check_dependencies tigervnc-viewer + xtigervncviewer -nojpeg -viewonly localhost:${VNC_SERVER_PORT} 1>/dev/null 2>&1 & } capture_session() { @@ -181,13 +185,13 @@ CAPTURE_ALL= LOG_FILE= VNC_VIEWER= VNC_SERVER= -PAUSE_ON_FAIL= +INTERACTIVE_DEBUGGING= KEEP_SNAPSHOTS= SIKULI_RETRY_FINDFAILED= ISO= OLD_ISO= -LONGOPTS="artifacts-base-uri:,view,vnc-server-only,capture,capture-all,help,tmpdir:,keep-snapshots,retry-find,iso:,old-iso:,pause-on-fail" +LONGOPTS="artifacts-base-uri:,view,vnc-server-only,capture,capture-all,help,tmpdir:,keep-snapshots,retry-find,iso:,old-iso:,interactive-debugging" OPTS=$(getopt -o "" --longoptions $LONGOPTS -n "${NAME}" -- "$@") eval set -- "$OPTS" while [ $# -gt 0 ]; do @@ -213,8 +217,8 @@ while [ $# -gt 0 ]; do export CAPTURE="yes" export CAPTURE_ALL="yes" ;; - --pause-on-fail) - export PAUSE_ON_FAIL="yes" + --interactive-debugging) + export INTERACTIVE_DEBUGGING="yes" ;; --keep-snapshots) export KEEP_SNAPSHOTS="yes" diff --git a/cucumber/features/config/defaults.yml b/cucumber/features/config/defaults.yml index 9c312146..bd063073 100644 --- a/cucumber/features/config/defaults.yml +++ b/cucumber/features/config/defaults.yml @@ -1,7 +1,7 @@ CAPTURE: false CAPTURE_ALL: false +INTERACTIVE_DEBUGGING: false MAX_NEW_TOR_CIRCUIT_RETRIES: 10 -PAUSE_ON_FAIL: false SIKULI_RETRY_FINDFAILED: false TMPDIR: "/tmp/DebianToaster" @@ -33,4 +33,4 @@ Unsafe_SSH_private_key: | NWema+bArbaF0rKVJpwvpkZWGcr6qRn94Ts0kJAzR+VIVTOjB9sVwdxjadwWHRs5 kKnpY0tnSF7hyVRwN7GOsNDJEaFjCW7k4+55D2ZNBy2iN3beW8CZ -----END RSA PRIVATE KEY----- -Unsafe_SSH_public_key: = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8xQ2BRQz+TK6jbqb5fDuKAbrOAYUwVtLe7yblK0awk6fvMuInw/kyaX9H7i105LqjBVThFplM+w1lkr8KViY4+GY28nTilUKGTYNnwABGD9MA2PeqMqzcP4x4puTVu3oSwDnmSAaxNSTLlOLxxzZadrbmOqNqAiLIzzbY8Yb2aYjr/MthHpAtSLM1pyJetEzdDhHixCQSt5WUd6ic8SIZSz3PHSzAKku08zlQhi17U9UeCTB4+xTq8zxSpVMr9XC0suAtqdeewwW7OrsNMBc+rj35dIU2rgmXFsQXr49Bdm9hnk15bTQars1Kk8/y6gevp/Un6YzHGczzmPNi1HH5 amnesia@amnesia" +Unsafe_SSH_public_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8xQ2BRQz+TK6jbqb5fDuKAbrOAYUwVtLe7yblK0awk6fvMuInw/kyaX9H7i105LqjBVThFplM+w1lkr8KViY4+GY28nTilUKGTYNnwABGD9MA2PeqMqzcP4x4puTVu3oSwDnmSAaxNSTLlOLxxzZadrbmOqNqAiLIzzbY8Yb2aYjr/MthHpAtSLM1pyJetEzdDhHixCQSt5WUd6ic8SIZSz3PHSzAKku08zlQhi17U9UeCTB4+xTq8zxSpVMr9XC0suAtqdeewwW7OrsNMBc+rj35dIU2rgmXFsQXr49Bdm9hnk15bTQars1Kk8/y6gevp/Un6YzHGczzmPNi1HH5 amnesia@amnesia" diff --git a/cucumber/features/domains/cdrom.xml b/cucumber/features/domains/cdrom.xml new file mode 100644 index 00000000..8bc3be7c --- /dev/null +++ b/cucumber/features/domains/cdrom.xml @@ -0,0 +1,5 @@ +<disk type='file' device='cdrom'> + <driver name='qemu' type='raw'/> + <target dev='hdc' bus='sata'/> + <readonly/> +</disk> diff --git a/cucumber/features/domains/default.xml b/cucumber/features/domains/default.xml index 040a5d8e..0966ef9a 100644 --- a/cucumber/features/domains/default.xml +++ b/cucumber/features/domains/default.xml @@ -1,10 +1,9 @@ <domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'> - <name>DebianToaster</name> <memory unit='KiB'>1310720</memory> <currentMemory unit='KiB'>1310720</currentMemory> - <vcpu>1</vcpu> + <vcpu>2</vcpu> <os> - <type arch='x86_64' machine='pc-i440fx-2.1'>hvm</type> + <type arch='x86_64' machine='pc-q35-2.8'>hvm</type> <boot dev='cdrom'/> </os> <features> @@ -21,19 +20,11 @@ <on_crash>restart</on_crash> <devices> <emulator>/usr/bin/qemu-system-x86_64</emulator> - <disk type='file' device='cdrom'> - <driver name='qemu' type='raw'/> - <source file=''/> - <target dev='hdc' bus='ide'/> - <readonly/> - </disk> - <controller type='usb' index='0' model='ich9-ehci1'/> - <controller type='usb' index='0' model='ich9-uhci1'> - <master startport='0'/> - </controller> - <controller type='ide' index='0'/> + <controller type='usb' index='0' model='nec-xhci'/> + <controller type='sata' index='0'/> <controller type='virtio-serial' index='0'/> <interface type='network'> + <alias name='net0'/> <!-- <mac address='52:54:00:ac:dd:ee'/> --> <source network='lvcNET'/> <model type='virtio'/> diff --git a/cucumber/features/domains/default_net.xml b/cucumber/features/domains/default_net.xml index 35a1c61e..e9a575df 100644 --- a/cucumber/features/domains/default_net.xml +++ b/cucumber/features/domains/default_net.xml @@ -1,11 +1,11 @@ <network> - <name>lvcNET</name> <forward mode='nat'/> <bridge name='virbr10' stp='on' delay='0' /> <ip address='10.2.1.1' netmask='255.255.255.0'> <dhcp> <range start='10.2.1.2' end='10.2.1.254' /> <!-- <host mac="52:54:00:ac:dd:ee" name="amnesia" ip="10.2.1.2" /> --> + <!-- <host mac="52:54:00:11:22:33" name="amnesia" ip="10.2.1.3" /> --> </dhcp> </ip> <ip family="ipv6" address="fc00::1" prefix="7" /> diff --git a/cucumber/features/domains/fs_share.xml b/cucumber/features/domains/fs_share.xml deleted file mode 100644 index 718755ea..00000000 --- a/cucumber/features/domains/fs_share.xml +++ /dev/null @@ -1,6 +0,0 @@ -<filesystem type='mount' accessmode='passthrough'> - <driver type='path' wrpolicy='immediate'/> - <source dir=''/> - <target dir=''/> - <readonly/> -</filesystem> diff --git a/cucumber/features/scripts/vm-execute b/cucumber/features/scripts/vm-execute index 79b6942b..f3d20f9f 100755 --- a/cucumber/features/scripts/vm-execute +++ b/cucumber/features/scripts/vm-execute @@ -2,7 +2,9 @@ require 'optparse' begin - require "#{`git rev-parse --show-toplevel`.chomp}/cucumber/features/support/helpers/exec_helper.rb" + require "#{`git rev-parse --show-toplevel`.chomp}/cucumber/features/support/helpers/remote_shell.rb" + require "#{`git rev-parse --show-toplevel`.chomp}/cucumber/features/support/helpers/misc_helpers.rb" + rescue LoadError => e raise "This script must be run from within Tails' Git directory." end @@ -46,7 +48,7 @@ opt_parser = OptionParser.new do |opts| end opt_parser.parse!(ARGV) cmd = ARGV.join(" ") -c = VMCommand.new(FakeVM.new, cmd, cmd_opts) +c = RemoteShell::ShellCommand.new(FakeVM.new, cmd, cmd_opts) puts "Return status: #{c.returncode}" puts "STDOUT:\n#{c.stdout}" puts "STDERR:\n#{c.stderr}" diff --git a/cucumber/features/step_definitions/apt.rb b/cucumber/features/step_definitions/apt.rb index c69d2598..52ef9f7f 100644 --- a/cucumber/features/step_definitions/apt.rb +++ b/cucumber/features/step_definitions/apt.rb @@ -2,55 +2,123 @@ require 'uri' Given /^the only hosts in APT sources are "([^"]*)"$/ do |hosts_str| hosts = hosts_str.split(',') - $vm.file_content("/etc/apt/sources.list /etc/apt/sources.list.d/*").chomp.each_line { |line| + apt_sources = $vm.execute_successfully( + "cat /etc/apt/sources.list /etc/apt/sources.list.d/*" + ).stdout.chomp + apt_sources.each_line do |line| next if ! line.start_with? "deb" source_host = URI(line.split[1]).host if !hosts.include?(source_host) raise "Bad APT source '#{line}'" end - } + end +end + +Given /^no proposed-updates APT suite is enabled$/ do + apt_sources = $vm.execute_successfully( + 'cat /etc/apt/sources.list /etc/apt/sources.list.d/*' + ).stdout + assert_no_match(/\s\S+-proposed-updates\s/, apt_sources) +end + +When /^I configure APT to use non-onion sources$/ do + script = <<-EOF + use strict; + use warnings FATAL => "all"; + s{vwakviie2ienjx6t[.]onion}{ftp.us.debian.org}; + s{sgvtcaew4bxjd7ln[.]onion}{security.debian.org}; + s{sdscoq7snqtznauu[.]onion}{deb.torproject.org}; + s{jenw7xbd6tf7vfhp[.]onion}{deb.tails.boum.org}; +EOF + # VMCommand:s cannot handle newlines, and they're irrelevant in the + # above perl script any way + script.delete!("\n") + $vm.execute_successfully( + "perl -pi -E '#{script}' /etc/apt/sources.list /etc/apt/sources.list.d/*" + ) end When /^I update APT using apt$/ do - Timeout::timeout(30*60) do - $vm.execute_successfully("echo #{@sudo_password} | " + - "sudo -S apt update", :user => LIVE_USER) + recovery_proc = Proc.new do + step 'I kill the process "apt"' + $vm.execute('rm -rf /var/lib/apt/lists/*') + end + retry_tor(recovery_proc) do + Timeout::timeout(15*60) do + $vm.execute_successfully("echo #{@sudo_password} | " + + "sudo -S apt update", :user => LIVE_USER) + end end end -Then /^I should be able to install a package using apt$/ do - package = "cowsay" - Timeout::timeout(120) do - $vm.execute_successfully("echo #{@sudo_password} | " + - "sudo -S apt install #{package}", - :user => LIVE_USER) +Then /^I install "(.+)" using apt$/ do |package_name| + recovery_proc = Proc.new do + step 'I kill the process "apt"' + $vm.execute("apt purge #{package_name}") + end + retry_tor(recovery_proc) do + Timeout::timeout(2*60) do + $vm.execute_successfully("echo #{@sudo_password} | " + + "sudo -S apt install #{package_name}", + :user => LIVE_USER) + end end - step "package \"#{package}\" is installed" end -When /^I update APT using Synaptic$/ do - @screen.click('SynapticReloadButton.png') - @screen.wait('SynapticReloadPrompt.png', 20) - @screen.waitVanish('SynapticReloadPrompt.png', 30*60) +When /^I start Synaptic$/ do + step 'I start "Synaptic Package Manager" via GNOME Activities Overview' + deal_with_polkit_prompt(@sudo_password) + @synaptic = Dogtail::Application.new('synaptic') + # The seemingly spurious space is needed because that is how this + # frame is named... + @synaptic.child( + 'Synaptic Package Manager ', roleName: 'frame', recursive: false + ) end -Then /^I should be able to install a package using Synaptic$/ do - package = "cowsay" - try_for(60) do - @screen.wait_and_click('SynapticSearchButton.png', 10) - @screen.wait_and_click('SynapticSearchWindow.png', 10) +When /^I update APT using Synaptic$/ do + recovery_proc = Proc.new do + step 'I kill the process "synaptic"' + step "I start Synaptic" + end + retry_tor(recovery_proc) do + @synaptic.button('Reload').click + sleep 10 # It might take some time before APT starts downloading + try_for(15*60, :msg => "Took too much time to download the APT data") { + !$vm.has_process?("/usr/lib/apt/methods/tor+http") + } + assert_raise(RuntimeError) do + @synaptic.child(roleName: 'dialog', recursive: false) + .child('Error', roleName: 'icon', retry: false) + end + if !$vm.has_process?("synaptic") + raise "Synaptic process vanished, did it segfault again?" + end end - @screen.type(package + Sikuli::Key.ENTER) - @screen.wait_and_double_click('SynapticCowsaySearchResult.png', 20) - @screen.wait_and_click('SynapticApplyButton.png', 10) - @screen.wait('SynapticApplyPrompt.png', 60) - @screen.type(Sikuli::Key.ENTER) - @screen.wait('SynapticChangesAppliedPrompt.png', 240) - step "package \"#{package}\" is installed" end -When /^I start Synaptic$/ do - step 'I start "Synaptic" via the GNOME "System" applications menu' - deal_with_polkit_prompt('PolicyKitAuthPrompt.png', @sudo_password) - @screen.wait('SynapticReloadButton.png', 30) +Then /^I install "(.+)" using Synaptic$/ do |package_name| + recovery_proc = Proc.new do + step 'I kill the process "synaptic"' + $vm.execute("apt -y purge #{package_name}") + step "I start Synaptic" + end + retry_tor(recovery_proc) do + @synaptic.button('Search').click + find_dialog = @synaptic.dialog('Find') + find_dialog.child(roleName: 'text').typeText(package_name) + find_dialog.button('Search').click + package_list = @synaptic.child('Installed Version', + roleName: 'table column header').parent + package_entry = package_list.child(package_name, roleName: 'table cell') + package_entry.doubleClick + @synaptic.button('Apply').click + apply_prompt = nil + try_for(60) { apply_prompt = @synaptic.dialog('Summary'); true } + apply_prompt.button('Apply').click + try_for(4*60) do + @synaptic.child('Changes applied', roleName: 'frame', recursive: false) + true + end + end end diff --git a/cucumber/features/step_definitions/browser.rb b/cucumber/features/step_definitions/browser.rb index 84ef1d35..68d1bca4 100644 --- a/cucumber/features/step_definitions/browser.rb +++ b/cucumber/features/step_definitions/browser.rb @@ -1,41 +1,28 @@ -Then /^I see the (Unsafe|I2P) Browser start notification and wait for it to close$/ do |browser_type| - robust_notification_wait("#{browser_type}BrowserStartNotification.png", 60) +Then /^the Unsafe Browser has started$/ do + @screen.wait("UnsafeBrowserHomepage.png", 360) end -Then /^the (Unsafe|I2P) Browser has started$/ do |browser_type| - case browser_type - when 'Unsafe' - @screen.wait("UnsafeBrowserHomepage.png", 360) - when 'I2P' - step 'the I2P router console is displayed in I2P Browser' - end -end - -When /^I start the (Unsafe|I2P) Browser(?: through the GNOME menu)?$/ do |browser_type| - step "I start \"#{browser_type}Browser\" via the GNOME \"Internet\" applications menu" +When /^I start the Unsafe Browser(?: through the GNOME menu)?$/ do + step "I start \"Unsafe Browser\" via GNOME Activities Overview" end -When /^I successfully start the (Unsafe|I2P) Browser$/ do |browser_type| - step "I start the #{browser_type} Browser" - step "I see and accept the Unsafe Browser start verification" unless browser_type == 'I2P' - step "I see the #{browser_type} Browser start notification and wait for it to close" - step "the #{browser_type} Browser has started" +When /^I successfully start the Unsafe Browser$/ do + step "I start the Unsafe Browser" + step "I see and accept the Unsafe Browser start verification" + step "I see the \"Starting the Unsafe Browser...\" notification after at most 60 seconds" + step "the Unsafe Browser has started" end -When /^I close the (?:Unsafe|I2P) Browser$/ do +When /^I close the Unsafe Browser$/ do @screen.type("q", Sikuli::KeyModifier.CTRL) end -Then /^I see the (Unsafe|I2P) Browser stop notification$/ do |browser_type| - robust_notification_wait("#{browser_type}BrowserStopNotification.png", 60) -end - def xul_application_info(application) binary = $vm.execute_successfully( 'echo ${TBB_INSTALL}/firefox', :libs => 'tor-browser' ).stdout.chomp address_bar_image = "BrowserAddressBar.png" - unused_tbb_libs = ['libnssdbm3.so'] + unused_tbb_libs = ['libnssdbm3.so', "libmozavcodec.so", "libmozavutil.so"] case application when "Tor Browser" user = LIVE_USER @@ -47,11 +34,6 @@ def xul_application_info(application) cmd_regex = "#{binary} .* -profile /home/#{user}/\.unsafe-browser/profile\.default" chroot = "/var/lib/unsafe-browser/chroot" new_tab_button_image = "UnsafeBrowserNewTabButton.png" - when "I2P Browser" - user = "i2pbrowser" - cmd_regex = "#{binary} .* -profile /home/#{user}/\.i2p-browser/profile\.default" - chroot = "/var/lib/i2p-browser/chroot" - new_tab_button_image = "I2PBrowserNewTabButton.png" when "Tor Launcher" user = "tor-launcher" # We do not enable AppArmor confinement for the Tor Launcher. @@ -100,19 +82,41 @@ When /^I open the address "([^"]*)" in the (.*)$/ do |address, browser| @screen.type('v', Sikuli::KeyModifier.CTRL) @screen.type(Sikuli::Key.ENTER) end - open_address.call + recovery_on_failure = Proc.new do + @screen.type(Sikuli::Key.ESC) + @screen.waitVanish('BrowserReloadButton.png', 3) + open_address.call + end if browser == "Tor Browser" - recovery_on_failure = Proc.new do - @screen.type(Sikuli::Key.ESC) - @screen.waitVanish('BrowserReloadButton.png', 3) - open_address.call - end - retry_tor(recovery_on_failure) do - @screen.wait('BrowserReloadButton.png', 120) - end + retry_method = method(:retry_tor) + else + retry_method = Proc.new { |p, &b| retry_action(10, recovery_proc: p, &b) } + end + open_address.call + retry_method.call(recovery_on_failure) do + @screen.wait('BrowserReloadButton.png', 120) end end +# This step is limited to the Tor Browser due to #7502 since dogtail +# uses the same interface. +Then /^"([^"]+)" has loaded in the Tor Browser$/ do |title| + if @language == 'German' + browser_name = 'Tor-Browser' + reload_action = 'Aktuelle Seite neu laden' + else + browser_name = 'Tor Browser' + reload_action = 'Reload current page' + end + expected_title = "#{title} - #{browser_name}" + try_for(60) { @torbrowser.child(expected_title, roleName: 'frame') } + # The 'Reload current page' button (graphically shown as a looping + # arrow) is only shown when a page has loaded, so once we see the + # expected title *and* this button has appeared, then we can be sure + # that the page has fully loaded. + try_for(60) { @torbrowser.child(reload_action, roleName: 'push button') } +end + Then /^the (.*) has no plugins installed$/ do |browser| step "I open the address \"about:plugins\" in the #{browser}" step "I see \"TorBrowserNoPlugins.png\" after at most 30 seconds" @@ -193,3 +197,23 @@ Then /^the file is saved to the default Tor Browser download directory$/ do expected_path = "/home/#{LIVE_USER}/Tor Browser/#{@some_file}" try_for(10) { $vm.file_exist?(expected_path) } end + +When /^I open Tails homepage in the (.+)$/ do |browser| + step "I open the address \"https://tails.boum.org\" in the #{browser}" +end + +Then /^Tails homepage loads in the Tor Browser$/ do + title = 'Tails - Privacy for anyone anywhere' + step "\"#{title}\" has loaded in the Tor Browser" +end + +Then /^Tails homepage loads in the Unsafe Browser$/ do + @screen.wait('TailsHomepage.png', 60) +end + +Then /^the Tor Browser shows the "([^"]+)" error$/ do |error| + page = @torbrowser.child("Problem loading page", roleName: "document frame") + headers = page.children(roleName: "heading") + found = headers.any? { |heading| heading.text == error } + raise "Could not find the '#{error}' error in the Tor Browser" unless found +end diff --git a/cucumber/features/step_definitions/build.rb b/cucumber/features/step_definitions/build.rb index fd001ff4..e02edc62 100644 --- a/cucumber/features/step_definitions/build.rb +++ b/cucumber/features/step_definitions/build.rb @@ -1,4 +1,4 @@ -Given /^Tails ([[:alnum:].]+) has been released$/ do |version| +Given /^Tails ([[:alnum:]~.]+) has been released$/ do |version| create_git unless git_exists? old_branch = current_branch @@ -17,7 +17,7 @@ tails (#{version}) stable; urgency=low END_OF_CHANGELOG end fatal_system "git commit --quiet debian/changelog -m 'Release #{version}'" - fatal_system "git tag '#{version}'" + fatal_system "git tag '#{version.gsub('~', '-')}'" if old_branch != 'stable' fatal_system "git checkout --quiet '#{old_branch}'" @@ -42,6 +42,31 @@ Given /^the last version mentioned in debian\/changelog is ([[:alnum:]~.]+)$/ do end end +Given /^the last versions mentioned in debian\/changelog are ([[:alnum:]~.]+) and ([[:alnum:]~.]+)$/ do |version_a, version_b| + step "the last version mentioned in debian/changelog is #{version_a}" + step "the last version mentioned in debian/changelog is #{version_b}" +end + +Given(/^no frozen APT snapshot is encoded in config\/APT_snapshots\.d$/) do + ['debian', 'debian-security', 'torproject'].map do |origin| + File.open("config/APT_snapshots.d/#{origin}/serial", 'w+') do |serial| + serial.write("latest\n") + end + end +end + +Given(/^frozen APT snapshots are encoded in config\/APT_snapshots\.d$/) do + ['debian', 'torproject'].map do |origin| + File.open("config/APT_snapshots.d/#{origin}/serial", 'w+') do |serial| + serial.write("2016060602\n") + end + end + # We never freeze debian-security + File.open("config/APT_snapshots.d/debian-security/serial", 'w+') do |serial| + serial.write("latest\n") + end +end + Given %r{I am working on the ([[:alnum:]./_-]+) base branch$} do |branch| create_git unless git_exists? @@ -54,6 +79,11 @@ Given %r{I am working on the ([[:alnum:]./_-]+) base branch$} do |branch| end end +Given %r{^I checkout the ([[:alnum:]~.-]+) tag$} do |tag| + create_git unless git_exists? + fatal_system "git checkout --quiet #{tag}" +end + Given %r{I am working on the ([[:alnum:]./_-]+) branch based on ([[:alnum:]./_-]+)$} do |branch, base| create_git unless git_exists? @@ -66,12 +96,12 @@ Given %r{I am working on the ([[:alnum:]./_-]+) branch based on ([[:alnum:]./_-] end end -When /^I successfully run ([[:alnum:]-]+)$/ do |command| +When /^I successfully run "?([[:alnum:] -]+)"?$/ do |command| @output = `#{File.expand_path("../../../auto/scripts/#{command}", __FILE__)}` raise StandardError.new("#{command} failed. Exit code: #{$?}") if $? != 0 end -When /^I run ([[:alnum:]-]+)$/ do |command| +When /^I run "?([[:alnum:] -]+)"?$/ do |command| @output = `#{File.expand_path("../../../auto/scripts/#{command}", __FILE__)}` @exit_code = $?.exitstatus end @@ -113,3 +143,11 @@ end Given(/^the config\/base_branch file is empty$/) do File.truncate('config/base_branch', 0) end + +Then(/^I should see the ([[:alnum:].-]+) tagged snapshot$/) do |tag| + @output.should have_tagged_snapshot(tag) +end + +Then(/^I should see a time\-based snapshot$/) do + @output.should have_time_based_snapshot() +end diff --git a/cucumber/features/step_definitions/checks.rb b/cucumber/features/step_definitions/checks.rb index 423b8390..142141a8 100644 --- a/cucumber/features/step_definitions/checks.rb +++ b/cucumber/features/step_definitions/checks.rb @@ -35,10 +35,6 @@ Then /^the shipped (?:Debian repository key|OpenPGP key ([A-Z0-9]+)) will be val end end -Then /^I double-click the Report an Error launcher on the desktop$/ do - @screen.wait_and_double_click('DesktopReportAnError.png', 30) -end - Then /^the live user has been setup by live\-boot$/ do assert($vm.execute("test -e /var/lib/live/config/user-setup").success?, "live-boot failed its user-setup") @@ -69,20 +65,12 @@ Then /^the live user owns its home dir and it has normal permissions$/ do end Then /^no unexpected services are listening for network connections$/ do - netstat_cmd = $vm.execute("netstat -ltupn") - assert netstat_cmd.success? - for line in netstat_cmd.stdout.chomp.split("\n") do + for line in $vm.execute_successfully("ss -ltupn").stdout.chomp.split("\n") do splitted = line.split(/[[:blank:]]+/) proto = splitted[0] - if proto == "tcp" - proc_index = 6 - elsif proto == "udp" - proc_index = 5 - else - next - end - laddr, lport = splitted[3].split(":") - proc = splitted[proc_index].split("/")[1] + next unless ['tcp', 'udp'].include?(proto) + laddr, lport = splitted[4].split(":") + proc = /users:\(\("([^"]+)"/.match(splitted[6])[1] # Services listening on loopback is not a threat if /127(\.[[:digit:]]{1,3}){3}/.match(laddr).nil? if SERVICES_EXPECTED_ON_ALL_IFACES.include? [proc, laddr, lport] or @@ -101,61 +89,58 @@ When /^Tails has booted a 64-bit kernel$/ do "Tails has not booted a 64-bit kernel.") end -Then /^there is no screenshot in the live user's Pictures directory$/ do - pictures_directory = "/home/#{LIVE_USER}/Pictures" - assert($vm.execute( - "find '#{pictures_directory}' -name 'Screenshot*.png' -maxdepth 1" - ).stdout.empty?, - "Existing screenshots were found in the live user's Pictures directory.") -end - -Then /^a screenshot is saved to the live user's Pictures directory$/ do - pictures_directory = "/home/#{LIVE_USER}/Pictures" - try_for(10, :msg=> "No screenshot was created in #{pictures_directory}") do - !$vm.execute( - "find '#{pictures_directory}' -name 'Screenshot*.png' -maxdepth 1" - ).stdout.empty? - end -end - Then /^the VirtualBox guest modules are available$/ do assert($vm.execute("modinfo vboxguest").success?, "The vboxguest module is not available.") end -Given /^I setup a filesystem share containing a sample PDF$/ do - shared_pdf_dir_on_host = "#{$config["TMPDIR"]}/shared_pdf_dir" - @shared_pdf_dir_on_guest = "/tmp/shared_pdf_dir" - FileUtils.mkdir_p(shared_pdf_dir_on_host) - Dir.glob("#{MISC_FILES_DIR}/*.pdf") do |pdf_file| - FileUtils.cp(pdf_file, shared_pdf_dir_on_host) - end - add_after_scenario_hook { FileUtils.rm_r(shared_pdf_dir_on_host) } - $vm.add_share(shared_pdf_dir_on_host, @shared_pdf_dir_on_guest) -end - Then /^the support documentation page opens in Tor Browser$/ do - @screen.wait("SupportDocumentation#{@language}.png", 120) -end - -Then /^MAT can clean some sample PDF file$/ do - for pdf_on_host in Dir.glob("#{MISC_FILES_DIR}/*.pdf") do - pdf_name = File.basename(pdf_on_host) - pdf_on_guest = "/home/#{LIVE_USER}/#{pdf_name}" - step "I copy \"#{@shared_pdf_dir_on_guest}/#{pdf_name}\" to \"#{pdf_on_guest}\" as user \"#{LIVE_USER}\"" - check_before = $vm.execute_successfully("mat --check '#{pdf_on_guest}'", + if @language == 'German' + expected_title = 'Tails - Hilfe & Support' + expected_heading = 'Die Dokumentation durchsuchen' + else + expected_title = 'Tails - Support' + expected_heading = 'Search the documentation' + end + step "\"#{expected_title}\" has loaded in the Tor Browser" + headings = @torbrowser + .child(expected_title, roleName: 'document frame') + .children(roleName: 'heading') + assert( + headings.any? { |heading| heading.text == expected_heading } + ) +end + +Given /^I plug and mount a USB drive containing a sample PNG$/ do + @png_dir = share_host_files(Dir.glob("#{MISC_FILES_DIR}/*.png")) +end + +Then /^MAT can clean some sample PNG file$/ do + for png_on_host in Dir.glob("#{MISC_FILES_DIR}/*.png") do + png_name = File.basename(png_on_host) + png_on_guest = "/home/#{LIVE_USER}/#{png_name}" + step "I copy \"#{@png_dir}/#{png_name}\" to \"#{png_on_guest}\" as user \"#{LIVE_USER}\"" + raw_check_cmd = "grep --quiet --fixed-strings --text " + + "'Created with GIMP' '#{png_on_guest}'" + assert($vm.execute(raw_check_cmd, user: LIVE_USER).success?, + 'The comment is not present in the PNG') + check_before = $vm.execute_successfully("mat --check '#{png_on_guest}'", :user => LIVE_USER).stdout - assert(check_before.include?("#{pdf_on_guest} is not clean"), - "MAT failed to see that '#{pdf_on_host}' is dirty") - $vm.execute_successfully("mat '#{pdf_on_guest}'", :user => LIVE_USER) - check_after = $vm.execute_successfully("mat --check '#{pdf_on_guest}'", + assert(check_before.include?("#{png_on_guest} is not clean"), + "MAT failed to see that '#{png_on_host}' is dirty") + $vm.execute_successfully("mat '#{png_on_guest}'", :user => LIVE_USER) + check_after = $vm.execute_successfully("mat --check '#{png_on_guest}'", :user => LIVE_USER).stdout - assert(check_after.include?("#{pdf_on_guest} is clean"), - "MAT failed to clean '#{pdf_on_host}'") - $vm.execute_successfully("rm '#{pdf_on_guest}'") + assert(check_after.include?("#{png_on_guest} is clean"), + "MAT failed to clean '#{png_on_host}'") + assert($vm.execute(raw_check_cmd, user: LIVE_USER).failure?, + 'The comment is still present in the PNG') + $vm.execute_successfully("rm '#{png_on_guest}'") end end + + Then /^AppArmor is enabled$/ do assert($vm.execute("aa-status").success?, "AppArmor is not enabled") end @@ -184,13 +169,8 @@ def get_apparmor_status(pid) end Then /^the running process "(.+)" is confined with AppArmor in (complain|enforce) mode$/ do |process, mode| - if process == 'i2p' - $vm.execute_successfully('service i2p status') - pid = $vm.file_content('/run/i2p/i2p.pid').chomp - else - assert($vm.has_process?(process), "Process #{process} not running.") - pid = $vm.pidof(process)[0] - end + assert($vm.has_process?(process), "Process #{process} not running.") + pid = $vm.pidof(process)[0] assert_equal(mode, get_apparmor_status(pid)) end @@ -238,12 +218,10 @@ Then /^tails-debugging-info is not susceptible to symlink attacks$/ do end When /^I disable all networking in the Tails Greeter$/ do - begin - @screen.click('TailsGreeterDisableAllNetworking.png') - rescue FindFailed - @screen.type(Sikuli::Key.PAGE_DOWN) - @screen.click('TailsGreeterDisableAllNetworking.png') - end + open_greeter_additional_settings() + @screen.wait_and_click('TailsGreeterNetworkConnection.png', 30) + @screen.wait_and_click('TailsGreeterDisableAllNetworking.png', 10) + @screen.wait_and_click("TailsGreeterAdditionalSettingsAdd.png", 10) end Then /^the Tor Status icon tells me that Tor is( not)? usable$/ do |not_usable| diff --git a/cucumber/features/step_definitions/common_steps.rb b/cucumber/features/step_definitions/common_steps.rb index feec90e2..ca7604f8 100644 --- a/cucumber/features/step_definitions/common_steps.rb +++ b/cucumber/features/step_definitions/common_steps.rb @@ -8,24 +8,6 @@ def post_vm_start_hook @screen.click_point(@screen.w-1, @screen.h/2) end -def activate_filesystem_shares - # XXX-9p: First of all, filesystem shares cannot be mounted while we - # do a snapshot save+restore, so unmounting+remounting them seems - # like a good idea. However, the 9p modules get into a broken state - # during the save+restore, so we also would like to unload+reload - # them, but loading of 9pnet_virtio fails after a restore with - # "probe of virtio2 failed with error -2" (in dmesg) which makes the - # shares unavailable. Hence we leave this code commented for now. - #for mod in ["9pnet_virtio", "9p"] do - # $vm.execute("modprobe #{mod}") - #end - - $vm.list_shares.each do |share| - $vm.execute("mkdir -p #{share}") - $vm.execute("mount -t 9p -o trans=virtio #{share} #{share}") - end -end - def context_menu_helper(top, bottom, menu_item) try_for(60) do t = @screen.wait(top, 10) @@ -41,64 +23,12 @@ def context_menu_helper(top, bottom, menu_item) end end -def deactivate_filesystem_shares - $vm.list_shares.each do |share| - $vm.execute("umount #{share}") - end - - # XXX-9p: See XXX-9p above - #for mod in ["9p", "9pnet_virtio"] do - # $vm.execute("modprobe -r #{mod}") - #end -end - -# This helper requires that the notification image is the one shown in -# the notification applet's list, not the notification pop-up. -def robust_notification_wait(notification_image, time_to_wait) - error_msg = "Didn't not manage to open the notification applet" - wait_start = Time.now - try_for(time_to_wait, :delay => 0, :msg => error_msg) do - @screen.hide_cursor - @screen.click("GnomeNotificationApplet.png") - @screen.wait("GnomeNotificationAppletOpened.png", 10) - end - - error_msg = "Didn't not see notification '#{notification_image}'" - time_to_wait -= (Time.now - wait_start).ceil - try_for(time_to_wait, :delay => 0, :msg => error_msg) do - found = false - entries = @screen.findAll("GnomeNotificationEntry.png") - while(entries.hasNext) do - entry = entries.next - @screen.hide_cursor - @screen.click(entry) - close_entry = @screen.wait("GnomeNotificationEntryClose.png", 10) - if @screen.exists(notification_image) - found = true - @screen.click(close_entry) - break - else - @screen.click(entry) - end - end - found - end - - # Click anywhere to close the notification applet - @screen.hide_cursor - @screen.click("GnomeApplicationsMenu.png") - @screen.hide_cursor -end - def post_snapshot_restore_hook # FIXME -- we've got a brain-damaged version of this, unlike Tails, so it breaks after restores at present # that being the case, let's not worry until we actually miss the feature #$vm.wait_until_remote_shell_is_up post_vm_start_hook - # XXX-9p: See XXX-9p above - #activate_filesystem_shares - # debian-TODO: move to tor feature # The guest's Tor's circuits' states are likely to get out of sync # with the other relays, so we ensure that we have fresh circuits. @@ -106,18 +36,10 @@ def post_snapshot_restore_hook #if $vm.has_network? # if $vm.execute("systemctl --quiet is-active tor@default.service").success? # $vm.execute("systemctl stop tor@default.service") - # $vm.execute("rm -f /var/log/tor/log") # $vm.execute("systemctl --no-block restart tails-tor-has-bootstrapped.target") # $vm.host_to_guest_time_sync - # $vm.spawn("restart-tor") + # $vm.execute("systemctl start tor@default.service") # wait_until_tor_is_working - # if $vm.file_content('/proc/cmdline').include?(' i2p') - # $vm.execute_successfully('/usr/local/sbin/tails-i2p stop') - # # we "killall tails-i2p" to prevent multiple - # # copies of the script from running - # $vm.execute_successfully('killall tails-i2p') - # $vm.spawn('/usr/local/sbin/tails-i2p start') - # end # end #else # $vm.host_to_guest_time_sync @@ -137,10 +59,6 @@ Given /^a computer$/ do $vm = VM.new($virt, VM_XML_PATH, $vmnet, $vmstorage, DISPLAY) end -Given /^the computer has (\d+) ([[:alpha:]]+) of RAM$/ do |size, unit| - $vm.set_ram_size(size, unit) -end - Then /^the VM shuts down within (\d+) minutes$/ do |mins| timeout = 60*mins.to_i try_for(timeout, :msg => "VM is still running after #{timeout} seconds") do @@ -156,7 +74,7 @@ Given /^the computer is set to boot from (.+?) drive$/ do |type| $vm.set_disk_boot(JOB_NAME, type.downcase) end -Given /^I (temporarily )?create a (\d+) ([[:alpha:]]+) disk named "([^"]+)"$/ do |temporary, size, unit, name| +Given /^I (temporarily )?create an? (\d+) ([[:alpha:]]+) disk named "([^"]+)"$/ do |temporary, size, unit, name| $vm.storage.create_new_disk(name, {:size => size, :unit => unit, :type => "qcow2"}) add_after_scenario_hook { $vm.storage.delete_volume(name) } if temporary @@ -184,6 +102,11 @@ Given /^the network is unplugged$/ do $vm.unplug_network end +Given /^the network connection is ready(?: within (\d+) seconds)?$/ do |timeout| + timeout ||= 30 + try_for(timeout.to_i) { $vm.has_network? } +end + Given /^the hardware clock is set to "([^"]*)"$/ do |time| $vm.set_hardware_clock(DateTime.parse(time).to_time) end @@ -220,51 +143,43 @@ end Given /^I start Tails( from DVD)?( with network unplugged)?( and I login)?$/ do |dvd_boot, network_unplugged, do_login| step "the computer is set to boot from the Tails DVD" if dvd_boot - if network_unplugged.nil? - step "the network is plugged" - else + if network_unplugged step "the network is unplugged" + else + step "the network is plugged" end step "I start the computer" step "the computer boots Tails" if do_login step "I log in to a new session" - step "Tails seems to have booted normally" - if network_unplugged.nil? - step "Tor is ready" + if network_unplugged step "all notifications have disappeared" - step "available upgrades have been checked" else + step "Tor is ready" step "all notifications have disappeared" + step "available upgrades have been checked" end end end -Given /^I start Tails from (.+?) drive "(.+?)"(| with network unplugged)( and I login(| with(| read-only) persistence enabled))?$/ do |drive_type, drive_name, network_unplugged, do_login, persistence_on, persistence_ro| +Given /^I start Tails from (.+?) drive "(.+?)"( with network unplugged)?( and I login( with persistence enabled)?)?$/ do |drive_type, drive_name, network_unplugged, do_login, persistence_on| step "the computer is set to boot from #{drive_type} drive \"#{drive_name}\"" - if network_unplugged.empty? - step "the network is plugged" - else + if network_unplugged step "the network is unplugged" + else + step "the network is plugged" end step "I start the computer" step "the computer boots Tails" if do_login - if ! persistence_on.empty? - if persistence_ro.empty? - step "I enable persistence" - else - step "I enable read-only persistence" - end - end + step "I enable persistence" if persistence_on step "I log in to a new session" - step "Tails seems to have booted normally" - if network_unplugged.empty? - step "Tor is ready" + if network_unplugged step "all notifications have disappeared" - step "available upgrades have been checked" else + step "Tor is ready" step "all notifications have disappeared" + step "available upgrades have been checked" end end end @@ -691,16 +606,16 @@ Given /^I should see a ([a-zA-Z]*) Login prompt$/ do |style| @screen.waitAny(loginPrompt[style], 20 * 60) end -def bootsplash +def boot_menu_cmdline_image case @os_loader when "UEFI" - 'TailsBootSplashUEFI.png' + 'TailsBootMenuKernelCmdlineUEFI.png' else 'd-i8_bootsplash.png' end end -def bootsplash_tab_msg +def boot_menu_tab_msg_image case @os_loader when "UEFI" 'TailsBootSplashTabMsgUEFI.png' @@ -719,21 +634,12 @@ def bootsplash_tab_msg end Given /^the computer (re)?boots Tails$/ do |reboot| - - boot_timeout = 30 - # We need some extra time for memory wiping if rebooting - boot_timeout += 90 if reboot - - @screen.wait(bootsplash, boot_timeout) - @screen.wait(bootsplash_tab_msg, 10) - @screen.type(Sikuli::Key.TAB) - @screen.waitVanish(bootsplash_tab_msg, 1) - + step "Tails is at the boot menu's cmdline" + (reboot ? ' after rebooting' : '') @screen.type(" autotest_never_use_this_option blacklist=psmouse #{@boot_options}" + Sikuli::Key.ENTER) - @screen.wait("DebianLive#{version}Greeter.png", 5*60) - @vm.wait_until_remote_shell_is_up - activate_filesystem_shares + @screen.wait('TailsGreeter.png', 5*60) + $vm.wait_until_remote_shell_is_up + step 'I configure Tails to use a simulated Tor network' end Given /^I log in to a new session(?: in )?(|German)$/ do |lang| @@ -741,37 +647,60 @@ Given /^I log in to a new session(?: in )?(|German)$/ do |lang| when 'German' @language = "German" @screen.wait_and_click('TailsGreeterLanguage.png', 10) - @screen.wait_and_click("TailsGreeterLanguage#{@language}.png", 10) + @screen.wait('TailsGreeterLanguagePopover.png', 10) + @screen.type(@language) + sleep(2) # Gtk needs some time to filter the results + @screen.type(Sikuli::Key.ENTER) @screen.wait_and_click("TailsGreeterLoginButton#{@language}.png", 10) when '' @screen.wait_and_click('TailsGreeterLoginButton.png', 10) else raise "Unsupported language: #{lang}" end + step 'Tails Greeter has applied all settings' + step 'the Tails desktop is ready' end -Given /^I set sudo password "([^"]*)"$/ do |password| - @sudo_password = password - next if @skip_steps_while_restoring_background - #@screen.wait("TailsGreeterAdminPassword.png", 20) +def open_greeter_additional_settings + @screen.click('TailsGreeterAddMoreOptions.png') + @screen.wait('TailsGreeterAdditionalSettingsDialog.png', 10) +end + +Given /^I open Tails Greeter additional settings dialog$/ do + open_greeter_additional_settings() +end + +Given /^I enable the specific Tor configuration option$/ do + open_greeter_additional_settings() + @screen.wait_and_click('TailsGreeterNetworkConnection.png', 30) + @screen.wait_and_click("TailsGreeterSpecificTorConfiguration.png", 10) + @screen.wait_and_click("TailsGreeterAdditionalSettingsAdd.png", 10) +end + +Given /^I set an administration password$/ do + open_greeter_additional_settings() + @screen.wait_and_click("TailsGreeterAdminPassword.png", 20) @screen.type(@sudo_password) @screen.type(Sikuli::Key.TAB) @screen.type(@sudo_password) + @screen.type(Sikuli::Key.ENTER) end -Given /^Tails Greeter has dealt with the sudo password$/ do - f1 = "/etc/sudoers.d/tails-greeter" - f2 = "#{f1}-no-password-lecture" - try_for(20) { - $vm.execute("test -e '#{f1}' -o -e '#{f2}'").success? +Given /^Tails Greeter has applied all settings$/ do + # I.e. it is done with PostLogin, which is ensured to happen before + # a logind session is opened for LIVE_USER. + try_for(120) { + $vm.execute_successfully("loginctl").stdout + .match(/^\s*\S+\s+\d+\s+#{LIVE_USER}\s+seat\d+\s+\S+\s*$/) != nil } end Given /^the Tails desktop is ready$/ do desktop_started_picture = "GnomeApplicationsMenu#{@language}.png" - # We wait for the Florence icon to be displayed to ensure reliable systray icon clicking. - @screen.wait("GnomeSystrayFlorence.png", 180) @screen.wait(desktop_started_picture, 180) + # We wait for the Florence icon to be displayed to ensure reliable systray icon clicking. + @screen.wait("GnomeSystrayFlorence.png", 30) + @screen.wait("DesktopTailsDocumentation.png", 30) # Disable screen blanking since we sometimes need to wait long # enough for it to activate, which can mess with Sikuli wait():ing # for some image. @@ -779,14 +708,22 @@ Given /^the Tails desktop is ready$/ do 'gsettings set org.gnome.desktop.session idle-delay 0', :user => LIVE_USER ) + # We need to enable the accessibility toolkit for dogtail. + $vm.execute_successfully( + 'gsettings set org.gnome.desktop.interface toolkit-accessibility true', + :user => LIVE_USER, + ) end -Then /^Tails seems to have booted normally$/ do - step "the Tails desktop is ready" -end - -When /^I see the 'Tor is ready' notification$/ do - robust_notification_wait('TorIsReadyNotification.png', 300) +When /^I see the "(.+)" notification(?: after at most (\d+) seconds)?$/ do |title, timeout| + timeout = timeout ? timeout.to_i : nil + gnome_shell = Dogtail::Application.new('gnome-shell') + notification_list = gnome_shell.child( + 'No Notifications', roleName: 'label', showingOnly: false + ).parent.parent + try_for(timeout) do + notification_list.child?(title, roleName: 'label', showingOnly: false) + end end Given /^Tor is ready$/ do @@ -803,43 +740,63 @@ Given /^Tor has built a circuit$/ do end Given /^the time has synced$/ do - ["/var/run/tordate/done", "/var/run/htpdate/success"].each do |file| + ["/run/tordate/done", "/run/htpdate/success"].each do |file| try_for(300) { $vm.execute("test -e #{file}").success? } end end Given /^available upgrades have been checked$/ do try_for(300) { - $vm.execute("test -e '/var/run/tails-upgrader/checked_upgrades'").success? + $vm.execute("test -e '/run/tails-upgrader/checked_upgrades'").success? } end -Given /^the Tor Browser has started$/ do - tor_browser_picture = "TorBrowserWindow.png" - @screen.wait(tor_browser_picture, 60) +When /^I start the Tor Browser( in offline mode)?$/ do |offline| + step 'I start "Tor Browser" via GNOME Activities Overview' + if offline + offline_prompt = Dogtail::Application.new('zenity') + .dialog('Tor is not ready') + offline_prompt.button('Start Tor Browser').click + end + step "the Tor Browser has started#{offline}" + if offline + step 'the Tor Browser shows the "The proxy server is refusing connections" error' + end end -Given /^the Tor Browser (?:has started and )?load(?:ed|s) the (startup page|Tails roadmap)$/ do |page| +Given /^the Tor Browser has started( in offline mode)?$/ do |offline| + try_for(60) do + @torbrowser = Dogtail::Application.new('Firefox') + @torbrowser.child?(roleName: 'frame', recursive: false) + end +end + +Given /^the Tor Browser loads the (startup page|Tails roadmap)$/ do |page| case page when "startup page" - picture = "TorBrowserStartupPage.png" + title = 'Tails - News' when "Tails roadmap" - picture = "TorBrowserTailsRoadmap.png" + title = 'Roadmap - Tails - RiseupLabs Code Repository' else raise "Unsupported page: #{page}" end - step "the Tor Browser has started" - @screen.wait(picture, 120) + step "\"#{title}\" has loaded in the Tor Browser" end -Given /^the Tor Browser has started in offline mode$/ do - @screen.wait("TorBrowserOffline.png", 60) +When /^I request a new identity using Torbutton$/ do + @screen.wait_and_click('TorButtonIcon.png', 30) + @screen.wait_and_click('TorButtonNewIdentity.png', 30) +end + +When /^I acknowledge Torbutton's New Identity confirmation prompt$/ do + @screen.wait('GnomeQuestionDialogIcon.png', 30) + step 'I type "y"' end Given /^I add a bookmark to eff.org in the Tor Browser$/ do url = "https://www.eff.org" step "I open the address \"#{url}\" in the Tor Browser" - @screen.wait("TorBrowserOffline.png", 5) + step 'the Tor Browser shows the "The proxy server is refusing connections" error' @screen.type("d", Sikuli::KeyModifier.CTRL) @screen.wait("TorBrowserBookmarkPrompt.png", 10) @screen.type(url + Sikuli::Key.ENTER) @@ -851,24 +808,18 @@ Given /^the Tor Browser has a bookmark to eff.org$/ do end Given /^all notifications have disappeared$/ do - next if not(@screen.exists("GnomeNotificationApplet.png")) - @screen.click("GnomeNotificationApplet.png") - @screen.wait("GnomeNotificationAppletOpened.png", 10) - begin - entries = @screen.findAll("GnomeNotificationEntry.png") - while(entries.hasNext) do - entry = entries.next - @screen.hide_cursor - @screen.click(entry) - @screen.wait_and_click("GnomeNotificationEntryClose.png", 10) + # These magic coordinates always locates GNOME's clock in the top + # bar, which when clicked opens the calendar. + x, y = 512, 10 + gnome_shell = Dogtail::Application.new('gnome-shell') + retry_action(10, recovery_proc: Proc.new { @screen.type(Sikuli::Key.ESC) }) do + @screen.click_point(x, y) + unless gnome_shell.child?('No Notifications', roleName: 'label') + @screen.click('GnomeCloseAllNotificationsButton.png') end - rescue FindFailed - # No notifications, so we're good to go. + gnome_shell.child?('No Notifications', roleName: 'label') end - @screen.hide_cursor - # Click anywhere to close the notification applet - @screen.click("GnomeApplicationsMenu.png") - @screen.hide_cursor + @screen.type(Sikuli::Key.ESC) end Then /^I (do not )?see "([^"]*)" after at most (\d+) seconds$/ do |negation, image, time| @@ -890,24 +841,31 @@ Then /^I (do not )?see the "([^"]*)" screen, after at most (\d+) seconds$/ do |n end Then /^all Internet traffic has only flowed through Tor$/ do - leaks = FirewallLeakCheck.new(@sniffer.pcap_file, - :accepted_hosts => get_all_tor_nodes) - leaks.assert_no_leaks + allowed_hosts = allowed_hosts_under_tor_enforcement + assert_all_connections(@sniffer.pcap_file) do |c| + allowed_hosts.include?({ address: c.daddr, port: c.dport }) + end end Given /^I enter the sudo password in the pkexec prompt$/ do step "I enter the \"#{@sudo_password}\" password in the pkexec prompt" end -def deal_with_polkit_prompt (image, password) +def deal_with_polkit_prompt(password, opts = {}) + opts[:expect_success] ||= true + image = 'PolicyKitAuthPrompt.png' @screen.wait(image, 60) @screen.type(password) @screen.type(Sikuli::Key.ENTER) - @screen.waitVanish(image, 10) + if opts[:expect_success] + @screen.waitVanish(image, 20) + else + @screen.wait('PolicyKitAuthFailure.png', 20) + end end Given /^I enter the "([^"]*)" password in the pkexec prompt$/ do |password| - deal_with_polkit_prompt('PolicyKitAuthPrompt.png', password) + deal_with_polkit_prompt(password) end Given /^process "([^"]+)" is (not )?running$/ do |process, not_running| @@ -939,19 +897,17 @@ Given /^I kill the process "([^"]+)"$/ do |process| } end -Then /^Tails eventually shuts down$/ do - nr_gibs_of_ram = convert_from_bytes($vm.get_ram_size_in_bytes, 'GiB').ceil - timeout = nr_gibs_of_ram*5*60 - try_for(timeout, :msg => "VM is still running after #{timeout} seconds") do - ! $vm.is_running? +Then /^Tails eventually (shuts down|restarts)$/ do |mode| + try_for(3*60) do + if mode == 'restarts' + @screen.find('TailsGreeter.png') + true + else + ! $vm.is_running? + end end end -Then /^Tails eventually restarts$/ do - nr_gibs_of_ram = convert_from_bytes($vm.get_ram_size_in_bytes, 'GiB').ceil - @screen.wait('TailsBootSplash.png', nr_gibs_of_ram*5*60) -end - Given /^I shutdown Tails and wait for the computer to power off$/ do $vm.spawn("poweroff") step 'Tails eventually shuts down' @@ -960,6 +916,11 @@ end When /^I request a shutdown using the emergency shutdown applet$/ do @screen.hide_cursor @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10) + # Sometimes the next button too fast, before the menu has settled + # down to its final size and the icon we want to click is in its + # final position. dogtail might allow us to fix that, but given how + # rare this problem is, it's not worth the effort. + step 'I wait 5 seconds' @screen.wait_and_click('TailsEmergencyShutdownHalt.png', 10) end @@ -970,57 +931,38 @@ end When /^I request a reboot using the emergency shutdown applet$/ do @screen.hide_cursor @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10) + # See comment on /^I request a shutdown using the emergency shutdown applet$/ + # that explains why we need to wait. + step 'I wait 5 seconds' @screen.wait_and_click('TailsEmergencyShutdownReboot.png', 10) end -Given /^package "([^"]+)" is installed$/ do |package| +Given /^the package "([^"]+)" is installed$/ do |package| assert($vm.execute("dpkg -s '#{package}' 2>/dev/null | grep -qs '^Status:.*installed$'").success?, "Package '#{package}' is not installed") end -When /^I start the Tor Browser$/ do - step 'I start "TorBrowser" via the GNOME "Internet" applications menu' -end - -When /^I request a new identity using Torbutton$/ do - @screen.wait_and_click('TorButtonIcon.png', 30) - @screen.wait_and_click('TorButtonNewIdentity.png', 30) -end - -When /^I acknowledge Torbutton's New Identity confirmation prompt$/ do - @screen.wait('GnomeQuestionDialogIcon.png', 30) - step 'I type "y"' -end - -When /^I start the Tor Browser in offline mode$/ do - step "I start the Tor Browser" - @screen.wait_and_click("TorBrowserOfflinePrompt.png", 10) - @screen.click("TorBrowserOfflinePromptStart.png") -end - -Given /^I add a wired DHCP NetworkManager connection called "([^"]+)"$/ do |con_name| - con_content = <<EOF -[802-3-ethernet] -duplex=full - +Given /^I add a ([a-z0-9.]+ |)wired DHCP NetworkManager connection called "([^"]+)"$/ do |version, con_name| + if version and version == '2.x' + con_content = <<EOF [connection] id=#{con_name} -uuid=bbc60668-1be0-11e4-a9c6-2f1ce0e75bf1 -type=802-3-ethernet -timestamp=1395406011 - -[ipv6] -method=auto - -[ipv4] -method=auto +uuid=b04afa94-c3a1-41bf-aa12-1a743d964162 +interface-name=eth0 +type=ethernet EOF - con_content.split("\n").each do |line| - $vm.execute("echo '#{line}' >> /tmp/NM.#{con_name}") + con_file = "/etc/NetworkManager/system-connections/#{con_name}" + $vm.file_overwrite(con_file, con_content) + $vm.execute_successfully("chmod 600 '#{con_file}'") + $vm.execute_successfully("nmcli connection load '#{con_file}'") + elsif version and version == '3.x' + raise "Unsupported version '#{version}'" + else + $vm.execute_successfully( + "nmcli connection add con-name #{con_name} " + \ + "type ethernet autoconnect yes ifname eth0" + ) end - con_file = "/etc/NetworkManager/system-connections/#{con_name}" - $vm.execute("install -m 0600 '/tmp/NM.#{con_name}' '#{con_file}'") - $vm.execute_successfully("nmcli connection load '#{con_file}'") try_for(10) { nm_con_list = $vm.execute("nmcli --terse --fields NAME connection show").stdout nm_con_list.split("\n").include? "#{con_name}" @@ -1035,8 +977,8 @@ Given /^I switch to the "([^"]+)" NetworkManager connection$/ do |con_name| end When /^I start and focus GNOME Terminal$/ do - step 'I start "Terminal" via the GNOME "Utilities" applications menu' - @screen.wait('GnomeTerminalWindow.png', 20) + step 'I start "GNOME Terminal" via GNOME Activities Overview' + @screen.wait('GnomeTerminalWindow.png', 40) end When /^I run "([^"]+)" in GNOME Terminal$/ do |command| @@ -1091,57 +1033,12 @@ Then /^persistence for "([^"]+)" is (|not )enabled$/ do |app, enabled| end end -def gnome_app_menu_click_helper(click_me, verify_me = nil) - try_for(30) do - @screen.hide_cursor - # The sensitivity for submenus to open by just hovering past them - # is extremely high, and may result in the wrong one - # opening. Hence we better avoid hovering over undesired submenus - # entirely by "approaching" the menu strictly horizontally. - r = @screen.wait(click_me, 10) - @screen.hover_point(@screen.w, r.getY) - @screen.click(r) - @screen.wait(verify_me, 10) if verify_me - return - end -end - -Given /^I start "([^"]+)" via the GNOME "([^"]+)" applications menu$/ do |app, submenu| - menu_button = "GnomeApplicationsMenu.png" - sub_menu_entry = "GnomeApplications" + submenu + ".png" - application_entry = "GnomeApplications" + app + ".png" - try_for(120) do - begin - gnome_app_menu_click_helper(menu_button, sub_menu_entry) - gnome_app_menu_click_helper(sub_menu_entry, application_entry) - gnome_app_menu_click_helper(application_entry) - rescue Exception => e - # Close menu, if still open - @screen.type(Sikuli::Key.ESC) - raise e - end - true - end -end - -Given /^I start "([^"]+)" via the GNOME "([^"]+)"\/"([^"]+)" applications menu$/ do |app, submenu, subsubmenu| - menu_button = "GnomeApplicationsMenu.png" - sub_menu_entry = "GnomeApplications" + submenu + ".png" - sub_sub_menu_entry = "GnomeApplications" + subsubmenu + ".png" - application_entry = "GnomeApplications" + app + ".png" - try_for(120) do - begin - gnome_app_menu_click_helper(menu_button, sub_menu_entry) - gnome_app_menu_click_helper(sub_menu_entry, sub_sub_menu_entry) - gnome_app_menu_click_helper(sub_sub_menu_entry, application_entry) - gnome_app_menu_click_helper(application_entry) - rescue Exception => e - # Close menu, if still open - @screen.type(Sikuli::Key.ESC) - raise e - end - true - end +Given /^I start "([^"]+)" via GNOME Activities Overview$/ do |app_name| + @screen.wait('GnomeApplicationsMenu.png', 10) + $vm.execute_successfully('xdotool key Super', user: LIVE_USER) + @screen.wait('GnomeActivitiesOverview.png', 10) + @screen.type(app_name) + @screen.type(Sikuli::Key.ENTER, Sikuli::KeyModifier.CTRL) end When /^I type "([^"]+)"$/ do |string| @@ -1198,8 +1095,14 @@ When /^(no|\d+) application(?:s?) (?:is|are) playing audio(?:| after (\d+) secon assert_equal(nb.to_i, pulseaudio_sink_inputs) end -When /^I double-click on the "Tails documentation" link on the Desktop$/ do - @screen.wait_and_double_click("DesktopTailsDocumentationIcon.png", 10) +When /^I double-click on the (Tails documentation|Report an Error) launcher on the desktop$/ do |launcher| + image = 'Desktop' + launcher.split.map { |s| s.capitalize } .join + '.png' + info = xul_application_info('Tor Browser') + # Sometimes the double-click is lost (#12131). + retry_action(10) do + @screen.wait_and_double_click(image, 10) if $vm.execute("pgrep --uid #{info[:user]} --full --exact '#{info[:cmd_regex]}'").failure? + step 'the Tor Browser has started' + end end When /^I click the blocked video icon$/ do @@ -1265,9 +1168,9 @@ When /^I can print the current page as "([^"]+[.]pdf)" to the (default downloads end Given /^a web server is running on the LAN$/ do - web_server_ip_addr = $vmnet.bridge_ip_addr - web_server_port = 8000 - @web_server_url = "http://#{web_server_ip_addr}:#{web_server_port}" + @web_server_ip_addr = $vmnet.bridge_ip_addr + @web_server_port = 8000 + @web_server_url = "http://#{@web_server_ip_addr}:#{@web_server_port}" web_server_hello_msg = "Welcome to the LAN web server!" # I've tested ruby Thread:s, fork(), etc. but nothing works due to @@ -1282,14 +1185,15 @@ Given /^a web server is running on the LAN$/ do require "webrick" STDOUT.reopen("/dev/null", "w") STDERR.reopen("/dev/null", "w") - server = WEBrick::HTTPServer.new(:BindAddress => "#{web_server_ip_addr}", - :Port => #{web_server_port}, + server = WEBrick::HTTPServer.new(:BindAddress => "#{@web_server_ip_addr}", + :Port => #{@web_server_port}, :DocumentRoot => "/dev/null") server.mount_proc("/") do |req, res| res.body = "#{web_server_hello_msg}" end server.start EOF + add_lan_host(@web_server_ip_addr, @web_server_port) proc = IO.popen(['ruby', '-e', code]) try_for(10, :msg => "It seems the LAN web server failed to start") do Process.kill(0, proc.pid) == 1 @@ -1365,8 +1269,7 @@ When /^AppArmor has (not )?denied "([^"]+)" from opening "([^"]+)"(?: after at m end Then /^I force Tor to use a new circuit$/ do - debug_log("Forcing new Tor circuit...") - $vm.execute_successfully('tor_control_send "signal NEWNYM"', :libs => 'tor') + force_new_tor_circuit end When /^I eject the boot medium$/ do @@ -1374,7 +1277,7 @@ When /^I eject the boot medium$/ do dev_type = device_info(dev)['ID_TYPE'] case dev_type when 'cd' - $vm.remove_cdrom + $vm.eject_cdrom when 'disk' boot_disk_name = $vm.disk_name(dev) $vm.unplug_drive(boot_disk_name) @@ -1382,3 +1285,103 @@ When /^I eject the boot medium$/ do raise "Unsupported medium type '#{dev_type}' for boot device '#{dev}'" end end + +Given /^Tails is fooled to think it is running version (.+)$/ do |version| + $vm.execute_successfully( + "sed -i " + + "'s/^TAILS_VERSION_ID=.*$/TAILS_VERSION_ID=\"#{version}\"/' " + + "/etc/os-release" + ) +end + +Then /^Tails is running version (.+)$/ do |version| + v1 = $vm.execute_successfully('tails-version').stdout.split.first + assert_equal(version, v1, "The version doesn't match tails-version's output") + v2 = $vm.file_content('/etc/os-release') + .scan(/TAILS_VERSION_ID="(#{version})"/).flatten.first + assert_equal(version, v2, "The version doesn't match /etc/os-release") +end + +def share_host_files(files) + files = [files] if files.class == String + assert_equal(Array, files.class) + disk_size = files.map { |f| File.new(f).size } .inject(0, :+) + # Let's add some extra space for filesysten overhead etc. + disk_size += [convert_to_bytes(1, 'MiB'), (disk_size * 0.10).ceil].max + disk = random_alpha_string(10) + step "I temporarily create an #{disk_size} bytes disk named \"#{disk}\"" + step "I create a gpt partition labeled \"#{disk}\" with an ext4 " + + "filesystem on disk \"#{disk}\"" + $vm.storage.guestfs_disk_helper(disk) do |g, _| + partition = g.list_partitions().first + g.mount(partition, "/") + files.each { |f| g.upload(f, "/" + File.basename(f)) } + end + step "I plug USB drive \"#{disk}\"" + mount_dir = $vm.execute_successfully('mktemp -d').stdout.chomp + dev = $vm.disk_dev(disk) + partition = dev + '1' + $vm.execute_successfully("mount #{partition} #{mount_dir}") + $vm.execute_successfully("chmod -R a+rX '#{mount_dir}'") + return mount_dir +end + +def mount_USB_drive(disk, fs_options = {}) + fs_options[:encrypted] ||= false + @tmp_usb_drive_mount_dir = $vm.execute_successfully('mktemp -d').stdout.chomp + dev = $vm.disk_dev(disk) + partition = dev + '1' + if fs_options[:encrypted] + password = fs_options[:password] + assert_not_nil(password) + luks_mapping = "#{disk}_unlocked" + $vm.execute_successfully( + "echo #{password} | " + + "cryptsetup luksOpen #{partition} #{luks_mapping}" + ) + $vm.execute_successfully( + "mount /dev/mapper/#{luks_mapping} #{@tmp_usb_drive_mount_dir}" + ) + else + $vm.execute_successfully("mount #{partition} #{@tmp_usb_drive_mount_dir}") + end + @tmp_filesystem_disk = disk + @tmp_filesystem_options = fs_options + @tmp_filesystem_partition = partition + return @tmp_usb_drive_mount_dir +end + +When(/^I plug and mount a (\d+) MiB USB drive with an? (.*)$/) do |size_MiB, fs| + disk_size = convert_to_bytes(size_MiB.to_i, 'MiB') + disk = random_alpha_string(10) + step "I temporarily create an #{disk_size} bytes disk named \"#{disk}\"" + step "I create a gpt partition labeled \"#{disk}\" with " + + "an #{fs} on disk \"#{disk}\"" + step "I plug USB drive \"#{disk}\"" + fs_options = {} + fs_options[:filesystem] = /(.*) filesystem/.match(fs)[1] + if /\bencrypted with password\b/.match(fs) + fs_options[:encrypted] = true + fs_options[:password] = /encrypted with password "([^"]+)"/.match(fs)[1] + end + mount_dir = mount_USB_drive(disk, fs_options) + @tmp_filesystem_size_b = convert_to_bytes( + avail_space_in_mountpoint_kB(mount_dir), + 'KB' + ) +end + +When(/^I mount the USB drive again$/) do + mount_USB_drive(@tmp_filesystem_disk, @tmp_filesystem_options) +end + +When(/^I umount the USB drive$/) do + $vm.execute_successfully("umount #{@tmp_usb_drive_mount_dir}") + if @tmp_filesystem_options[:encrypted] + $vm.execute_successfully("cryptsetup luksClose #{@tmp_filesystem_disk}_unlocked") + end +end + +When /^Tails system time is magically synchronized$/ do + $vm.host_to_guest_time_sync +end diff --git a/cucumber/features/step_definitions/dhcp.rb b/cucumber/features/step_definitions/dhcp.rb index ef4d9e15..3c834224 100644 --- a/cucumber/features/step_definitions/dhcp.rb +++ b/cucumber/features/step_definitions/dhcp.rb @@ -1,19 +1,23 @@ Then /^the hostname should not have been leaked on the network$/ do - hostname = $vm.execute("hostname").stdout.chomp - packets = PacketFu::PcapFile.new.file_to_array(:filename => @sniffer.pcap_file) - packets.each do |p| - # if PacketFu::TCPPacket.can_parse?(p) - # ipv4_tcp_packets << PacketFu::TCPPacket.parse(p) - if PacketFu::IPPacket.can_parse?(p) - payload = PacketFu::IPPacket.parse(p).payload - elsif PacketFu::IPv6Packet.can_parse?(p) - payload = PacketFu::IPv6Packet.parse(p).payload - else - @sniffer.save_pcap_file - raise "Found something in the pcap file that either is non-IP, or cannot be parsed" - end - if payload.match(hostname) - raise "Hostname leak detected" + begin + hostname = $vm.execute("hostname").stdout.chomp + packets = PacketFu::PcapFile.new.file_to_array(filename: @sniffer.pcap_file) + packets.each do |p| + # if PacketFu::TCPPacket.can_parse?(p) + # ipv4_tcp_packets << PacketFu::TCPPacket.parse(p) + if PacketFu::IPPacket.can_parse?(p) + payload = PacketFu::IPPacket.parse(p).payload + elsif PacketFu::IPv6Packet.can_parse?(p) + payload = PacketFu::IPv6Packet.parse(p).payload + else + raise "Found something in the pcap file that either is non-IP, or cannot be parsed" + end + if payload.match(hostname) + raise "Hostname leak detected" + end end + rescue Exception => e + save_failure_artifact("Network capture", @sniffer.pcap_file) + raise e end end diff --git a/cucumber/features/step_definitions/electrum.rb b/cucumber/features/step_definitions/electrum.rb index 447983d4..eaeb22aa 100644 --- a/cucumber/features/step_definitions/electrum.rb +++ b/cucumber/features/step_definitions/electrum.rb @@ -1,12 +1,12 @@ Then /^I start Electrum through the GNOME menu$/ do - step "I start \"Electrum\" via the GNOME \"Internet\" applications menu" + step "I start \"Electrum Bitcoin Wallet\" via GNOME Activities Overview" end When /^a bitcoin wallet is (|not )present$/ do |existing| wallet = "/home/#{LIVE_USER}/.electrum/wallets/default_wallet" case existing when "" - step "the file \"#{wallet}\" exists after at most 10 seconds" + step "the file \"#{wallet}\" exists after at most 30 seconds" when "not " step "the file \"#{wallet}\" does not exist" else @@ -17,20 +17,22 @@ end When /^I create a new bitcoin wallet$/ do @screen.wait("ElectrumNoWallet.png", 10) @screen.wait_and_click("ElectrumNextButton.png", 10) + @screen.wait("ElectrumCreateNewSeed.png", 10) + @screen.wait_and_click("ElectrumNextButton.png", 10) @screen.wait("ElectrumWalletGenerationSeed.png", 15) @screen.wait_and_click("ElectrumWalletSeedTextbox.png", 15) @screen.type('a', Sikuli::KeyModifier.CTRL) # select wallet seed @screen.type('c', Sikuli::KeyModifier.CTRL) # copy seed to clipboard seed = $vm.get_clipboard @screen.wait_and_click("ElectrumNextButton.png", 15) - @screen.wait("ElectrumWalletSeedTextbox.png", 15) + @screen.wait("ElectrumSeedVerificationPrompt.png", 15) + @screen.wait_and_click("ElectrumWalletSeedTextbox.png", 15) @screen.type(seed) # Confirm seed @screen.wait_and_click("ElectrumNextButton.png", 10) - @screen.wait_and_click("ElectrumEncryptWallet.png", 10) + @screen.wait("ElectrumEncryptWallet.png", 10) + @screen.type(Sikuli::Key.TAB) # focus first password field @screen.type("asdf" + Sikuli::Key.TAB) # set password @screen.type("asdf" + Sikuli::Key.TAB) # confirm password - @screen.type(Sikuli::Key.ENTER) - @screen.wait("ElectrumConnectServer.png", 20) @screen.wait_and_click("ElectrumNextButton.png", 10) @screen.wait("ElectrumPreferencesButton.png", 30) end @@ -39,8 +41,8 @@ Then /^I see a warning that Electrum is not persistent$/ do @screen.wait('GnomeQuestionDialogIcon.png', 30) end -Then /^I am prompted to create a new wallet$/ do - @screen.wait('ElectrumNoWallet.png', 60) +Then /^I am prompted to configure Electrum$/ do + @screen.wait("ElectrumNoWallet.png", 60) end Then /^I see the main Electrum client window$/ do diff --git a/cucumber/features/step_definitions/encryption.rb b/cucumber/features/step_definitions/encryption.rb index 9f7f1b96..3b20a5b4 100644 --- a/cucumber/features/step_definitions/encryption.rb +++ b/cucumber/features/step_definitions/encryption.rb @@ -23,16 +23,16 @@ Given /^I generate an OpenPGP key named "([^"]+)" with password "([^"]+)"$/ do | Passphrase: #{pwd} %commit EOF - gpg_key_recipie.split("\n").each do |line| - $vm.execute("echo '#{line}' >> /tmp/gpg_key_recipie", :user => LIVE_USER) - end - c = $vm.execute("gpg --batch --gen-key < /tmp/gpg_key_recipie", + recipe_path = '/tmp/gpg_key_recipe' + $vm.file_overwrite(recipe_path, gpg_key_recipie) + $vm.execute("chown #{LIVE_USER}:#{LIVE_USER} #{recipe_path}") + c = $vm.execute("gpg --batch --gen-key < #{recipe_path}", :user => LIVE_USER) assert(c.success?, "Failed to generate OpenPGP key:\n#{c.stderr}") end When /^I type a message into gedit$/ do - step 'I start "Gedit" via the GNOME "Accessories" applications menu' + step 'I start "gedit" via GNOME Activities Overview' @screen.wait_and_click("GeditWindow.png", 20) # We don't have a good visual indicator for when we can continue. Without the # sleep we may start typing in the gedit window far too soon, causing @@ -60,7 +60,7 @@ def gedit_copy_all_text context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditCopy.png') end -def paste_into_a_new_tab +def gedit_paste_into_a_new_tab @screen.wait_and_click("GeditNewTab.png", 20) context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditPaste.png') end @@ -74,7 +74,7 @@ def encrypt_sign_helper sleep 5 yield maybe_deal_with_pinentry - paste_into_a_new_tab + gedit_paste_into_a_new_tab end def decrypt_verify_helper(icon) @@ -129,5 +129,5 @@ When /^I symmetrically encrypt the message with password "([^"]+)"$/ do |pwd| seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletEncryptPassphrase.png') maybe_deal_with_pinentry # enter password maybe_deal_with_pinentry # confirm password - paste_into_a_new_tab + gedit_paste_into_a_new_tab end diff --git a/cucumber/features/step_definitions/firewall_leaks.rb b/cucumber/features/step_definitions/firewall_leaks.rb index 942d00b8..0cd94cca 100644 --- a/cucumber/features/step_definitions/firewall_leaks.rb +++ b/cucumber/features/step_definitions/firewall_leaks.rb @@ -1,29 +1,6 @@ -Then(/^the firewall leak detector has detected (.*?) leaks$/) do |type| - leaks = FirewallLeakCheck.new(@sniffer.pcap_file, - :accepted_hosts => get_all_tor_nodes) - case type.downcase - when 'ipv4 tcp' - if leaks.ipv4_tcp_leaks.empty? - leaks.save_pcap_file - raise "Couldn't detect any IPv4 TCP leaks" - end - when 'ipv4 non-tcp' - if leaks.ipv4_nontcp_leaks.empty? - leaks.save_pcap_file - raise "Couldn't detect any IPv4 non-TCP leaks" - end - when 'ipv6' - if leaks.ipv6_leaks.empty? - leaks.save_pcap_file - raise "Couldn't detect any IPv6 leaks" - end - when 'non-ip' - if leaks.nonip_leaks.empty? - leaks.save_pcap_file - raise "Couldn't detect any non-IP leaks" - end - else - raise "Incorrect packet type '#{type}'" +Then(/^the firewall leak detector has detected leaks$/) do + assert_raise(FirewallAssertionFailedError) do + step 'all Internet traffic has only flowed through Tor' end end @@ -40,12 +17,12 @@ Given(/^I disable Tails' firewall$/) do end When(/^I do a TCP DNS lookup of "(.*?)"$/) do |host| - lookup = $vm.execute("host -T #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER) + lookup = $vm.execute("host -T -t A #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER) assert(lookup.success?, "Failed to resolve #{host}:\n#{lookup.stdout}") end When(/^I do a UDP DNS lookup of "(.*?)"$/) do |host| - lookup = $vm.execute("host #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER) + lookup = $vm.execute("host -t A #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER) assert(lookup.success?, "Failed to resolve #{host}:\n#{lookup.stdout}") end diff --git a/cucumber/features/step_definitions/git.rb b/cucumber/features/step_definitions/git.rb index bf6f869d..bd8fcf7d 100644 --- a/cucumber/features/step_definitions/git.rb +++ b/cucumber/features/step_definitions/git.rb @@ -1,3 +1,29 @@ +When /^I clone the Git repository "([\S]+)" in GNOME Terminal$/ do |repo| + repo_directory = /[\S]+\/([\S]+)(\.git)?$/.match(repo)[1] + assert(!$vm.directory_exist?("/home/#{LIVE_USER}/#{repo_directory}")) + + recovery_proc = Proc.new do + $vm.execute("rm -rf /home/#{LIVE_USER}/#{repo_directory}", + :user => LIVE_USER) + step 'I kill the process "git"' + @screen.type('clear' + Sikuli::Key.ENTER) + end + + retry_tor(recovery_proc) do + step "I run \"git clone #{repo}\" in GNOME Terminal" + m = /^(https?|git):\/\//.match(repo) + unless m + step 'I verify the SSH fingerprint for the Git repository' + end + try_for(180, :msg => 'Git process took too long') { + !$vm.has_process?('/usr/bin/git') + } + Dogtail::Application.new('gnome-terminal-server') + .child('Terminal', roleName: 'terminal') + .text['Unpacking objects: 100%'] + end +end + Then /^the Git repository "([\S]+)" has been cloned successfully$/ do |repo| assert($vm.directory_exist?("/home/#{LIVE_USER}/#{repo}/.git")) assert($vm.file_exist?("/home/#{LIVE_USER}/#{repo}/.git/config")) diff --git a/cucumber/features/step_definitions/icedove.rb b/cucumber/features/step_definitions/icedove.rb deleted file mode 100644 index d3672895..00000000 --- a/cucumber/features/step_definitions/icedove.rb +++ /dev/null @@ -1,94 +0,0 @@ -Then /^Icedove has started$/ do - step 'process "icedove" is running within 30 seconds' - @screen.wait('IcedoveMainWindow.png', 60) -end - -When /^I have not configured an email account$/ do - icedove_prefs = $vm.file_content("/home/#{LIVE_USER}/.icedove/profile.default/prefs.js").chomp - assert(!icedove_prefs.include?('mail.accountmanager.accounts')) -end - -Then /^I am prompted to setup an email account$/ do - $vm.focus_window('Mail Account Setup') - @screen.wait('IcedoveMailAccountSetup.png', 30) -end - -Then /^IMAP is the default protocol$/ do - $vm.focus_window('Mail Account Setup') - @screen.wait('IcedoveProtocolIMAP.png', 10) -end - -Then /^I cancel setting up an email account$/ do - $vm.focus_window('Mail Account Setup') - @screen.type(Sikuli::Key.ESC) - @screen.waitVanish('IcedoveMailAccountSetup.png', 10) -end - -Then /^I open Icedove's Add-ons Manager$/ do - $vm.focus_window('Icedove') - @screen.wait_and_click('MozillaMenuButton.png', 10) - @screen.wait_and_click('IcedoveToolsMenuAddOns.png', 10) - @screen.wait('MozillaAddonsManagerExtensions.png', 30) -end - -Then /^I click the extensions tab$/ do - @screen.wait_and_click('MozillaAddonsManagerExtensions.png', 10) -end - -Then /^I see that Adblock is not installed in Icedove$/ do - if @screen.exists('MozillaExtensionsAdblockPlus.png') - raise 'Adblock should not be enabled within Icedove' - end -end - -When /^I go into Enigmail's preferences$/ do - $vm.focus_window('Icedove') - @screen.type("a", Sikuli::KeyModifier.ALT) - @screen.wait_and_click('IcedoveEnigmailPreferences.png', 10) - @screen.wait('IcedoveEnigmailPreferencesWindow.png', 10) - @screen.click('IcedoveEnigmailExpertSettingsButton.png') - @screen.wait('IcedoveEnigmailKeyserverTab.png', 10) -end - -When /^I click Enigmail's keyserver tab$/ do - @screen.wait_and_click('IcedoveEnigmailKeyserverTab.png', 10) -end - -Then /^I see that Enigmail is configured to use the correct keyserver$/ do - @screen.wait('IcedoveEnigmailKeyserver.png', 10) -end - -Then /^I click Enigmail's advanced tab$/ do - @screen.wait_and_click('IcedoveEnigmailAdvancedTab.png', 10) -end - -Then /^I see that Enigmail is configured to use the correct SOCKS proxy$/ do - @screen.click('IcedoveEnigmailAdvancedParameters.png') - @screen.type(Sikuli::Key.END) - @screen.wait('IcedoveEnigmailProxy.png', 10) -end - -Then /^I see that Torbirdy is configured to use Tor$/ do - @screen.wait('IcedoveTorbirdyEnabled.png', 10) -end - -When /^I open Torbirdy's preferences$/ do - step "I open Icedove's Add-ons Manager" - step 'I click the extensions tab' - @screen.wait_and_click('MozillaExtensionsTorbirdy.png', 10) - @screen.type(Sikuli::Key.TAB) # Select 'More' link - @screen.type(Sikuli::Key.TAB) # Select 'Preferences' button - @screen.type(Sikuli::Key.SPACE) # Press 'Preferences' button - @screen.wait('GnomeQuestionDialogIcon.png', 10) - @screen.type(Sikuli::Key.ENTER) -end - -When /^I test Torbirdy's proxy settings$/ do - @screen.wait('IcedoveTorbirdyPreferencesWindow.png', 10) - @screen.click('IcedoveTorbirdyTestProxySettingsButton.png') - @screen.wait('IcedoveTorbirdyCongratulationsTab.png', 180) -end - -Then /^Torbirdy's proxy test is successful$/ do - @screen.wait('IcedoveTorbirdyCongratulationsTab.png', 180) -end diff --git a/cucumber/features/step_definitions/mac_spoofing.rb b/cucumber/features/step_definitions/mac_spoofing.rb index a4aa8714..260b28fd 100644 --- a/cucumber/features/step_definitions/mac_spoofing.rb +++ b/cucumber/features/step_definitions/mac_spoofing.rb @@ -5,51 +5,51 @@ def all_ethernet_nics end When /^I disable MAC spoofing in Tails Greeter$/ do + open_greeter_additional_settings() @screen.wait_and_click("TailsGreeterMACSpoofing.png", 30) + @screen.wait_and_click("TailsGreeterDisableMACSpoofing.png", 10) + @screen.wait_and_click("TailsGreeterAdditionalSettingsAdd.png", 10) end -Then /^the network device has (its default|a spoofed) MAC address configured$/ do |mode| +Then /^the (\d+)(?:st|nd|rd|th) network device has (its real|a spoofed) MAC address configured$/ do |dev_nr, mode| is_spoofed = (mode == "a spoofed") - nic = "eth0" - assert_equal([nic], all_ethernet_nics, - "We only expected NIC #{nic} but these are present: " + - all_ethernet_nics.join(", ")) - nic_real_mac = $vm.real_mac + alias_name = "net#{dev_nr.to_i - 1}" + nic_real_mac = $vm.real_mac(alias_name) + nic = "eth#{dev_nr.to_i - 1}" nic_current_mac = $vm.execute_successfully( "get_current_mac_of_nic #{nic}", :libs => 'hardware' ).stdout.chomp - if is_spoofed - if nic_real_mac == nic_current_mac - save_pcap_file - raise "The MAC address was expected to be spoofed but wasn't" - end - else - if nic_real_mac != nic_current_mac - save_pcap_file - raise "The MAC address is spoofed but was expected to not be" + begin + if is_spoofed + if nic_real_mac == nic_current_mac + raise "The MAC address was expected to be spoofed but wasn't" + end + else + if nic_real_mac != nic_current_mac + raise "The MAC address is spoofed but was expected to not be" + end end + rescue Exception => e + save_failure_artifact("Network capture", @sniffer.pcap_file) + raise e end end -Then /^the real MAC address was (not )?leaked$/ do |mode| - is_leaking = mode.nil? - leaks = FirewallLeakCheck.new(@sniffer.pcap_file) - mac_leaks = leaks.mac_leaks - if is_leaking - if !mac_leaks.include?($vm.real_mac) - save_pcap_file - raise "The real MAC address was expected to leak but didn't. We " + - "observed the following MAC addresses: #{mac_leaks}" - end - else - if mac_leaks.include?($vm.real_mac) - save_pcap_file - raise "The real MAC address was leaked but was expected not to. We " + - "observed the following MAC addresses: #{mac_leaks}" +Then /^no network device leaked the real MAC address$/ do + macs = $vm.all_real_macs + assert_all_connections(@sniffer.pcap_file) do |c| + macs.all? do |mac| + not [c.mac_saddr, c.mac_daddr].include?(mac) end end end +Then /^some network device leaked the real MAC address$/ do + assert_raise(FirewallAssertionFailedError) do + step 'no network device leaked the real MAC address' + end +end + Given /^macchanger will fail by not spoofing and always returns ([\S]+)$/ do |mode| $vm.execute_successfully("mv /usr/bin/macchanger /usr/bin/macchanger.orig") $vm.execute_successfully("ln -s /bin/#{mode} /usr/bin/macchanger") @@ -76,14 +76,6 @@ EOF $vm.execute_successfully("chmod a+rx /sbin/modprobe") end -When /^see the "Network card disabled" notification$/ do - robust_notification_wait("MACSpoofNetworkCardDisabled.png", 60) -end - -When /^see the "All networking disabled" notification$/ do - robust_notification_wait("MACSpoofNetworkingDisabled.png", 60) -end - Then /^(\d+|no) network interface(?:s)? (?:is|are) enabled$/ do |expected_nr_nics| # note that "no".to_i => 0 in Ruby. expected_nr_nics = expected_nr_nics.to_i @@ -106,3 +98,22 @@ Then /^the MAC spoofing panic mode disabled networking$/ do end end end + +When /^I hotplug a network device( and wait for it to be initialized)?$/ do |wait| + initial_nr_nics = wait ? all_ethernet_nics.size : nil + xml = <<-EOF + <interface type='network'> + <alias name='net1'/> + <mac address='52:54:00:11:22:33'/> + <source network='TailsToasterNet'/> + <model type='virtio'/> + <link state='up'/> + </interface> + EOF + $vm.plug_device(xml) + if wait + try_for(20) do + all_ethernet_nics.size >= initial_nr_nics + 1 + end + end +end diff --git a/cucumber/features/step_definitions/pidgin.rb b/cucumber/features/step_definitions/pidgin.rb index 3f5ed931..43949b68 100644 --- a/cucumber/features/step_definitions/pidgin.rb +++ b/cucumber/features/step_definitions/pidgin.rb @@ -28,26 +28,26 @@ def wait_and_focus(img, time = 10, window) end def focus_pidgin_irc_conversation_window(account) - if account == 'I2P' - # After connecting to Irc2P messages are sent from services. Most of the - # time the services will send their messages right away. If there's lag we - # may in fact join the channel _before_ the message is received. We'll look - # for a message from InfoServ first then default to looking for '#i2p' - try_for(20) do - begin - $vm.focus_window('InfoServ') - rescue ExecutionFailedInVM - $vm.focus_window('#i2p') - end - end - else - account = account.sub(/^irc\./, '') - try_for(20) do - $vm.focus_window(".*#{Regexp.escape(account)}$") - end + account = account.sub(/^irc\./, '') + try_for(20) do + $vm.focus_window(".*#{Regexp.escape(account)}$") end end +def pidgin_dbus_call(method, *args) + dbus_send( + 'im.pidgin.purple.PurpleService', + '/im/pidgin/purple/PurpleObject', + "im.pidgin.purple.PurpleInterface.#{method}", + *args, user: LIVE_USER + ) +end + +def pidgin_account_connected?(account, prpl_protocol) + account_id = pidgin_dbus_call('PurpleAccountsFind', account, prpl_protocol) + pidgin_dbus_call('PurpleAccountIsConnected', account_id) == 1 +end + When /^I create my XMPP account$/ do account = xmpp_account("Tails_account") @screen.click("PidginAccountManagerAddButton.png") @@ -74,6 +74,11 @@ When /^I create my XMPP account$/ do end Then /^Pidgin automatically enables my XMPP account$/ do + account = xmpp_account("Tails_account") + jid = account["username"] + '@' + account["domain"] + try_for(3*60) do + pidgin_account_connected?(jid, 'prpl-jabber') + end $vm.focus_window('Buddy List') @screen.wait("PidginAvailableStatus.png", 60*3) end @@ -109,8 +114,9 @@ When /^I start a conversation with my friend$/ do @screen.wait("PidginConversationWindowMenuBar.png", 10) end -And /^I say something to my friend( in the multi-user chat)?$/ do |multi_chat| - msg = "ping" + Sikuli::Key.ENTER +And /^I say (.*) to my friend( in the multi-user chat)?$/ do |msg, multi_chat| + msg = "ping" if msg == "something" + msg = msg + Sikuli::Key.ENTER if multi_chat $vm.focus_window(@chat_room_jid.split("@").first) msg = @friend_name + ": " + msg @@ -126,7 +132,12 @@ Then /^I receive a response from my friend( in the multi-user chat)?$/ do |multi else $vm.focus_window(@friend_name) end - @screen.wait("PidginFriendExpectedAnswer.png", 20) + try_for(60) do + if @screen.exists('PidginServerMessage.png') + @screen.click('PidginDialogCloseButton.png') + end + @screen.find('PidginFriendExpectedAnswer.png') + end end When /^I start an OTR session with my friend$/ do @@ -203,15 +214,26 @@ end def configured_pidgin_accounts accounts = Hash.new - xml = REXML::Document.new($vm.file_content('$HOME/.purple/accounts.xml', - LIVE_USER)) + xml = REXML::Document.new( + $vm.file_content("/home/#{LIVE_USER}/.purple/accounts.xml") + ) xml.elements.each("account/account") do |e| account = e.elements["name"].text account_name, network = account.split("@") protocol = e.elements["protocol"].text port = e.elements["settings/setting[@name='port']"].text - nickname = e.elements["settings/setting[@name='username']"].text - real_name = e.elements["settings/setting[@name='realname']"].text + username_element = e.elements["settings/setting[@name='username']"] + realname_elemenet = e.elements["settings/setting[@name='realname']"] + if username_element + nickname = username_element.text + else + nickname = nil + end + if realname_elemenet + real_name = realname_elemenet.text + else + real_name = nil + end accounts[network] = { 'name' => account_name, 'network' => network, @@ -227,34 +249,25 @@ end def chan_image (account, channel, image) images = { - 'irc.oftc.net' => { - '#tails' => { - 'roster' => 'PidginTailsChannelEntry', + 'conference.riseup.net' => { + 'tails' => { 'conversation_tab' => 'PidginTailsConversationTab', 'welcome' => 'PidginTailsChannelWelcome', } }, - 'I2P' => { - '#i2p' => { - 'roster' => 'PidginI2PChannelEntry', - 'conversation_tab' => 'PidginI2PConversationTab', - 'welcome' => 'PidginI2PChannelWelcome', - } - } } return images[account][channel][image] + ".png" end def default_chan (account) chans = { - 'irc.oftc.net' => '#tails', - 'I2P' => '#i2p', + 'conference.riseup.net' => 'tails', } return chans[account] end def pidgin_otr_keys - return $vm.file_content('$HOME/.purple/otr.private_key', LIVE_USER) + return $vm.file_content("/home/#{LIVE_USER}/.purple/otr.private_key") end Given /^Pidgin has the expected accounts configured with random nicknames$/ do @@ -278,10 +291,6 @@ Given /^Pidgin has the expected accounts configured with random nicknames$/ do "#{expected}") end -When /^I start Pidgin through the GNOME menu$/ do - step 'I start "Pidgin" via the GNOME "Internet" applications menu' -end - When /^I open Pidgin's account manager window$/ do @screen.wait_and_click('PidginMenuAccounts.png', 20) @screen.wait_and_click('PidginMenuManageAccounts.png', 20) @@ -293,7 +302,13 @@ When /^I see Pidgin's account manager window$/ do end When /^I close Pidgin's account manager window$/ do - @screen.wait_and_click("PidginAccountManagerCloseButton.png", 10) + @screen.wait_and_click("PidginDialogCloseButton.png", 10) +end + +When /^I close Pidgin$/ do + $vm.focus_window('Buddy List') + @screen.type("q", Sikuli::KeyModifier.CTRL) + @screen.waitVanish('PidginAvailableStatus.png', 10) end When /^I (de)?activate the "([^"]+)" Pidgin account$/ do |deactivate, account| @@ -331,8 +346,7 @@ Then /^Pidgin successfully connects to the "([^"]+)" account$/ do |account| deactivate_and_activate_pidgin_account(account) end end - retrier_method = account == 'I2P' ? method(:retry_i2p) : method(:retry_tor) - retrier_method.call(recovery_on_failure) do + retry_tor(recovery_on_failure) do begin $vm.focus_window('Buddy List') rescue ExecutionFailedInVM @@ -363,10 +377,22 @@ Then /^the "([^"]*)" account only responds to PING and VERSION CTCP requests$/ d ctcp_check.verify_ctcp_responses end -Then /^I can join the "([^"]+)" channel on "([^"]+)"$/ do |channel, account| - @screen.doubleClick( chan_image(account, channel, 'roster')) +Then /^I can join the( pre-configured)? "([^"]+)" channel on "([^"]+)"$/ do |preconfigured, channel, account| + if preconfigured + @screen.doubleClick(chan_image(account, channel, 'roster')) + focus_pidgin_irc_conversation_window(account) + else + $vm.focus_window('Buddy List') + @screen.wait_and_click("PidginBuddiesMenu.png", 20) + @screen.wait_and_click("PidginBuddiesMenuJoinChat.png", 10) + @screen.wait_and_click("PidginJoinChatWindow.png", 10) + @screen.click_mid_right_edge("PidginJoinChatRoomLabel.png") + @screen.type(channel) + @screen.click("PidginJoinChatButton.png") + @chat_room_jid = channel + "@" + account + $vm.focus_window(@chat_room_jid) + end @screen.hide_cursor - focus_pidgin_irc_conversation_window(account) try_for(60) do begin @screen.wait_and_click(chan_image(account, channel, 'conversation_tab'), 5) @@ -405,7 +431,7 @@ end def pidgin_add_certificate_from (cert_file) # Here, we need a certificate that is not already in the NSS database - step "I copy \"/usr/share/ca-certificates/spi-inc.org/spi-cacert-2008.crt\" to \"#{cert_file}\" as user \"amnesia\"" + step "I copy \"/usr/share/ca-certificates/mozilla/CNNIC_ROOT.crt\" to \"#{cert_file}\" as user \"amnesia\"" $vm.focus_window('Buddy List') @screen.wait_and_click('PidginToolsMenu.png', 10) @@ -453,6 +479,9 @@ end When /^I see the Tails roadmap URL$/ do try_for(60) do + if @screen.exists('PidginServerMessage.png') + @screen.click('PidginDialogCloseButton.png') + end begin @screen.find('PidginTailsRoadmapUrl.png') rescue FindFailed => e @@ -464,4 +493,5 @@ end When /^I click on the Tails roadmap URL$/ do @screen.click('PidginTailsRoadmapUrl.png') + try_for(60) { @torbrowser = Dogtail::Application.new('Firefox') } end diff --git a/cucumber/features/step_definitions/root_access_control.rb b/cucumber/features/step_definitions/root_access_control.rb index ff1bdfcc..8362342d 100644 --- a/cucumber/features/step_definitions/root_access_control.rb +++ b/cucumber/features/step_definitions/root_access_control.rb @@ -34,8 +34,7 @@ end Then /^I should not be able to run a command as root with pkexec and the standard passwords$/ do step "I run \"pkexec touch /root/pkexec-test\" in GNOME Terminal" ['', 'live', 'amnesia'].each do |password| - step "I enter the \"#{password}\" password in the pkexec prompt" - @screen.wait('PolicyKitAuthFailure.png', 20) + deal_with_polkit_prompt(password, expect_success: false) end @screen.type(Sikuli::Key.ESC) @screen.wait('PolicyKitAuthCompleteFailure.png', 20) diff --git a/cucumber/features/step_definitions/snapshots.rb b/cucumber/features/step_definitions/snapshots.rb index 74c60d20..16e59a4b 100644 --- a/cucumber/features/step_definitions/snapshots.rb +++ b/cucumber/features/step_definitions/snapshots.rb @@ -6,7 +6,7 @@ def checkpoints :parent_checkpoint => nil, :steps => [ 'I create a 8 GiB disk named "'+JOB_NAME+'"', - 'I plug ide drive "'+JOB_NAME+'"', + 'I plug sata drive "'+JOB_NAME+'"', ] } @@ -16,7 +16,7 @@ def checkpoints :parent_checkpoint => nil, :steps => [ 'I create a 64 GiB disk named "'+JOB_NAME+'"', - 'I plug ide drive "'+JOB_NAME+'"', + 'I plug sata drive "'+JOB_NAME+'"', ] } @@ -54,7 +54,7 @@ def checkpoints 'I allow reboot after the install is complete', 'I wait for the reboot', 'I power off the computer', - 'the computer is set to boot from ide drive', + 'the computer is set to boot from sata drive', ] } end @@ -85,12 +85,12 @@ def reach_checkpoint(name) post_snapshot_restore_hook end debug_log(scenario_indent + "Checkpoint: #{checkpoint_description}", - :color => :white) + color: :white, timestamp: false) step_action = "Given" if parent_checkpoint parent_description = checkpoints[parent_checkpoint][:description] debug_log(step_indent + "#{step_action} #{parent_description}", - :color => :green) + color: :green, timestamp: false) step_action = "And" end steps.each do |s| @@ -99,10 +99,11 @@ def reach_checkpoint(name) rescue Exception => e debug_log(scenario_indent + "Step failed while creating checkpoint: #{s}", - :color => :red) + color: :red, timestamp: false) raise e end - debug_log(step_indent + "#{step_action} #{s}", :color => :green) + debug_log(step_indent + "#{step_action} #{s}", + color: :green, timestamp: false) step_action = "And" end $vm.save_snapshot(name) diff --git a/cucumber/features/step_definitions/ssh.rb b/cucumber/features/step_definitions/ssh.rb index 038b2977..1fd0efaf 100644 --- a/cucumber/features/step_definitions/ssh.rb +++ b/cucumber/features/step_definitions/ssh.rb @@ -60,6 +60,7 @@ end Given /^I (?:am prompted to )?verify the SSH fingerprint for the (?:Git|SSH) (?:repository|server)$/ do @screen.wait("SSHFingerprint.png", 60) + sleep 1 # brief pause to ensure that the following keystrokes do not get lost @screen.type('yes' + Sikuli::Key.ENTER) end @@ -75,6 +76,7 @@ Given /^an SSH server is running on the LAN$/ do @sshd_server_host = $vmnet.bridge_ip_addr sshd = SSHServer.new(@sshd_server_host, @sshd_server_port) sshd.start + add_lan_host(@sshd_server_host, @sshd_server_port) add_after_scenario_hook { sshd.stop } end @@ -94,8 +96,17 @@ When /^I connect to an SSH server on the (Internet|LAN)$/ do |location| cmd = "ssh #{@ssh_username}@#{@ssh_host} #{ssh_port_suffix}" step 'process "ssh" is not running' - step "I run \"#{cmd}\" in GNOME Terminal" - step 'process "ssh" is running within 10 seconds' + + recovery_proc = Proc.new do + step 'I kill the process "ssh"' if $vm.has_process?("ssh") + step 'I run "clear" in GNOME Terminal' + end + + retry_tor(recovery_proc) do + step "I run \"#{cmd}\" in GNOME Terminal" + step 'process "ssh" is running within 10 seconds' + step 'I verify the SSH fingerprint for the SSH server' + end end Then /^I have sucessfully logged into the SSH server$/ do @@ -104,19 +115,42 @@ end Then /^I connect to an SFTP server on the Internet$/ do read_and_validate_ssh_config "SFTP" + @sftp_port ||= 22 @sftp_port = @sftp_port.to_s - step 'I start "Files" via the GNOME "Accessories" applications menu' - @screen.wait_and_click("GnomeFilesConnectToServer.png", 10) - @screen.wait("GnomeConnectToServerWindow.png", 10) - @screen.type("sftp://" + @sftp_username + "@" + @sftp_host + ":" + @sftp_port) - @screen.wait_and_click("GnomeConnectToServerConnectButton.png", 10) + + recovery_proc = Proc.new do + step 'I kill the process "ssh"' + step 'I kill the process "nautilus"' + end + + retry_tor(recovery_proc) do + step 'I start "Nautilus" via GNOME Activities Overview' + nautilus = Dogtail::Application.new('nautilus') + nautilus.child(roleName: 'frame') + nautilus.child('Other Locations', roleName: 'label').click + connect_bar = nautilus.child('Connect to Server', roleName: 'label').parent + connect_bar + .child(roleName: 'filler', recursive: false) + .child(roleName: 'text', recursive: false) + .text = "sftp://" + @sftp_username + "@" + @sftp_host + ":" + @sftp_port + connect_bar.button('Connect', recursive: false).click + step "I verify the SSH fingerprint for the SFTP server" + end end Then /^I verify the SSH fingerprint for the SFTP server$/ do - @screen.wait_and_click("GnomeSSHVerificationConfirm.png", 60) + try_for(30) do + Dogtail::Application.new('gnome-shell').child?('Log In Anyway') + end + # Here we'd like to click on the button using Dogtail, but something + # is buggy so let's just use the keyboard. + @screen.type(Sikuli::Key.ENTER) end Then /^I successfully connect to the SFTP server$/ do - @screen.wait("GnomeSSHSuccess.png", 60) + try_for(60) do + Dogtail::Application.new('nautilus') + .child?("#{@sftp_username} on #{@sftp_host}") + end end diff --git a/cucumber/features/step_definitions/time_syncing.rb b/cucumber/features/step_definitions/time_syncing.rb index 319fb521..d1b81073 100644 --- a/cucumber/features/step_definitions/time_syncing.rb +++ b/cucumber/features/step_definitions/time_syncing.rb @@ -47,23 +47,23 @@ Then /^Tails clock is less than (\d+) minutes incorrect$/ do |max_diff_mins| puts "Time was #{diff} seconds off" end -Then /^the system clock is just past Tails' build date$/ do +Then /^the system clock is just past Tails' source date$/ do system_time_str = $vm.execute_successfully('date').to_s system_time = DateTime.parse(system_time_str).to_time - build_time_cmd = 'sed -n -e "1s/^.* - \([0-9]\+\)$/\1/p;q" ' + - '/etc/amnesia/version' - build_time_str = $vm.execute_successfully(build_time_cmd).to_s - build_time = DateTime.parse(build_time_str).to_time - diff = system_time - build_time # => in seconds + source_time_cmd = 'sed -n -e "1s/^.* - \([0-9]\+\)$/\1/p;q" ' + + '/etc/amnesia/version' + source_time_str = $vm.execute_successfully(source_time_cmd).to_s + source_time = DateTime.parse(source_time_str).to_time + diff = system_time - source_time # => in seconds # Half an hour should be enough to boot Tails on any reasonable # hardware and VM setup. max_diff = 30*60 assert(diff > 0, "The system time (#{system_time}) is before the Tails " + - "build date (#{build_time})") + "source date (#{source_time})") assert(diff <= max_diff, "The system time (#{system_time}) is more than #{max_diff} seconds " + - "past the build date (#{build_time})") + "past the source date (#{source_time})") end Then /^Tails' hardware clock is close to the host system's time$/ do diff --git a/cucumber/features/step_definitions/tor.rb b/cucumber/features/step_definitions/tor.rb index ac12fd4c..04852f76 100644 --- a/cucumber/features/step_definitions/tor.rb +++ b/cucumber/features/step_definitions/tor.rb @@ -90,7 +90,7 @@ Then /^the firewall is configured to only allow the (.+) users? to connect direc "The following rule has an unexpected destination:\n" + rule.to_s) state_cond = try_xml_element_text(rule, "conditions/state/state") - next if state_cond == "RELATED,ESTABLISHED" + next if state_cond == "ESTABLISHED" assert_not_nil(rule.elements['conditions/owner/uid-owner']) rule.elements.each('conditions/owner/uid-owner') do |owner| uid = owner.text.to_i @@ -184,7 +184,7 @@ def firewall_has_dropped_packet_to?(proto, host, port) $vm.execute("journalctl --dmesg --output=cat | grep -qP '#{regex}'").success? end -When /^I open an untorified (TCP|UDP|ICMP) connections to (\S*)(?: on port (\d+))? that is expected to fail$/ do |proto, host, port| +When /^I open an untorified (TCP|UDP|ICMP) connection to (\S*)(?: on port (\d+))?$/ do |proto, host, port| assert(!firewall_has_dropped_packet_to?(proto, host, port), "A #{proto} packet to #{host}" + (port.nil? ? "" : ":#{port}") + @@ -195,11 +195,11 @@ When /^I open an untorified (TCP|UDP|ICMP) connections to (\S*)(?: on port (\d+) case proto when "TCP" assert_not_nil(port) - cmd = "echo | netcat #{host} #{port}" + cmd = "echo | nc.traditional #{host} #{port}" user = LIVE_USER when "UDP" assert_not_nil(port) - cmd = "echo | netcat -u #{host} #{port}" + cmd = "echo | nc.traditional -u #{host} #{port}" user = LIVE_USER when "ICMP" cmd = "ping -c 5 #{host}" @@ -243,34 +243,38 @@ def stream_isolation_info(application) case application when "htpdate" { - :grep_monitor_expr => '/curl\>', + :grep_monitor_expr => 'users:(("curl"', :socksport => 9062 } - when "tails-security-check", "tails-upgrade-frontend-wrapper" - # We only grep connections with ESTABLISHED state since `perl` - # is also used by monkeysphere's validation agent, which LISTENs + when "tails-security-check" { - :grep_monitor_expr => '\<ESTABLISHED\>.\+/perl\>', + :grep_monitor_expr => 'users:(("tails-security-"', + :socksport => 9062 + } + when "tails-upgrade-frontend-wrapper" + { + :grep_monitor_expr => 'users:(("tails-iuk-get-u"', :socksport => 9062 } when "Tor Browser" { - :grep_monitor_expr => '/firefox\>', - :socksport => 9150 + :grep_monitor_expr => 'users:(("firefox"', + :socksport => 9150, + :controller => true, } when "Gobby" { - :grep_monitor_expr => '/gobby\>', + :grep_monitor_expr => 'users:(("gobby-0.5"', :socksport => 9050 } when "SSH" { - :grep_monitor_expr => '/\(connect-proxy\|ssh\)\>', + :grep_monitor_expr => 'users:(("\(nc\|ssh\)"', :socksport => 9050 } when "whois" { - :grep_monitor_expr => '/whois\>', + :grep_monitor_expr => 'users:(("whois"', :socksport => 9050 } else @@ -279,26 +283,28 @@ def stream_isolation_info(application) end When /^I monitor the network connections of (.*)$/ do |application| - @process_monitor_log = "/tmp/netstat.log" + @process_monitor_log = "/tmp/ss.log" info = stream_isolation_info(application) $vm.spawn("while true; do " + - " netstat -taupen | grep \"#{info[:grep_monitor_expr]}\"; " + + " ss -taupen | grep '#{info[:grep_monitor_expr]}'; " + " sleep 0.1; " + "done > #{@process_monitor_log}") end Then /^I see that (.+) is properly stream isolated$/ do |application| - expected_port = stream_isolation_info(application)[:socksport] + info = stream_isolation_info(application) + expected_ports = [info[:socksport]] + expected_ports << 9051 if info[:controller] assert_not_nil(@process_monitor_log) log_lines = $vm.file_content(@process_monitor_log).split("\n") assert(log_lines.size > 0, "Couldn't see any connection made by #{application} so " \ "something is wrong") log_lines.each do |line| - addr_port = line.split(/\s+/)[4] - assert_equal("127.0.0.1:#{expected_port}", addr_port, - "#{application} should use SocksPort #{expected_port} but " \ - "was seen connecting to #{addr_port}") + ip_port = line.split(/\s+/)[5] + assert(expected_ports.map { |port| "127.0.0.1:#{port}" }.include?(ip_port), + "#{application} should only connect to #{expected_ports} but " \ + "was seen connecting to #{ip_port}") end end @@ -308,7 +314,7 @@ end And /^I re-run htpdate$/ do $vm.execute_successfully("service htpdate stop && " \ - "rm -f /var/run/htpdate/* && " \ + "rm -f /run/htpdate/* && " \ "systemctl --no-block start htpdate.service") step "the time has synced" end @@ -318,18 +324,22 @@ And /^I re-run tails-upgrade-frontend-wrapper$/ do end When /^I connect Gobby to "([^"]+)"$/ do |host| - @screen.wait("GobbyWindow.png", 30) - @screen.wait("GobbyWelcomePrompt.png", 10) - @screen.click("GnomeCloseButton.png") - @screen.wait("GobbyWindow.png", 10) + gobby = Dogtail::Application.new('gobby-0.5') + gobby.child('Welcome to Gobby', roleName: 'label') + gobby.button('Close').click # This indicates that Gobby has finished initializing itself # (generating DH parameters, etc.) -- before, the UI is not responsive # and our CTRL-t is lost. - @screen.wait("GobbyFailedToShareDocuments.png", 30) + gobby.child('Failed to share documents', roleName: 'label') + gobby.menu('File').click + gobby.menuItem('Connect to Server...').click @screen.type("t", Sikuli::KeyModifier.CTRL) - @screen.wait("GobbyConnectPrompt.png", 10) - @screen.type(host + Sikuli::Key.ENTER) - @screen.wait("GobbyConnectionComplete.png", 60) + connect_dialog = gobby.dialog('Connect to Server') + connect_dialog.child('', roleName: 'text').typeText(host) + connect_dialog.button('Connect').click + # This looks for the live user's presence entry in the chat, which + # will only be shown if the connection succeeded. + try_for(60) { gobby.child(LIVE_USER, roleName: 'table cell'); true } end When /^the Tor Launcher autostarts$/ do @@ -337,35 +347,47 @@ When /^the Tor Launcher autostarts$/ do end When /^I configure some (\w+) pluggable transports in Tor Launcher$/ do |bridge_type| - bridge_type.downcase! - bridge_type.capitalize! - begin - @bridges = $config["Tor"]["Transports"][bridge_type] - assert_not_nil(@bridges) - assert(!@bridges.empty?) - rescue NoMethodError, Test::Unit::AssertionFailedError - raise( -<<EOF -It seems no '#{bridge_type}' pluggable transports are defined in your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format. -EOF -) - end - @bridge_hosts = [] - for bridge in @bridges do - @bridge_hosts << bridge["ipv4_address"] - end - @screen.wait_and_click('TorLauncherConfigureButton.png', 10) @screen.wait('TorLauncherBridgePrompt.png', 10) @screen.wait_and_click('TorLauncherYesRadioOption.png', 10) @screen.wait_and_click('TorLauncherNextButton.png', 10) @screen.wait_and_click('TorLauncherBridgeList.png', 10) - for bridge in @bridges do - bridge_line = bridge_type.downcase + " " + - bridge["ipv4_address"] + ":" + - bridge["ipv4_port"].to_s - bridge_line += " " + bridge["fingerprint"].to_s if bridge["fingerprint"] - bridge_line += " " + bridge["extra"].to_s if bridge["extra"] + @bridge_hosts = [] + chutney_src_dir = "#{GIT_DIR}/submodules/chutney" + bridge_dirs = Dir.glob( + "#{$config['TMPDIR']}/chutney-data/nodes/*#{bridge_type}/" + ) + bridge_dirs.each do |bridge_dir| + address = $vmnet.bridge_ip_addr + port = nil + fingerprint = nil + extra = nil + if bridge_type == 'bridge' + open(bridge_dir + "/torrc") do |f| + port = f.grep(/^OrPort\b/).first.split.last + end + else + # This is the pluggable transport case. While we could set a + # static port via ServerTransportListenAddr we instead let it be + # picked randomly so an already used port is not picked -- + # Chutney already has issues with that for OrPort selection. + pt_re = /Registered server transport '#{bridge_type}' at '[^']*:(\d+)'/ + open(bridge_dir + "/notice.log") do |f| + pt_lines = f.grep(pt_re) + port = pt_lines.last.match(pt_re)[1] + end + if bridge_type == 'obfs4' + open(bridge_dir + "/pt_state/obfs4_bridgeline.txt") do |f| + extra = f.readlines.last.chomp.sub(/^.* cert=/, 'cert=') + end + end + end + open(bridge_dir + "/fingerprint") do |f| + fingerprint = f.read.chomp.split.last + end + @bridge_hosts << { address: address, port: port.to_i } + bridge_line = bridge_type + " " + address + ":" + port + [fingerprint, extra].each { |e| bridge_line += " " + e.to_s if e } @screen.type(bridge_line + Sikuli::Key.ENTER) end @screen.wait_and_click('TorLauncherNextButton.png', 10) @@ -378,25 +400,7 @@ end When /^all Internet traffic has only flowed through the configured pluggable transports$/ do assert_not_nil(@bridge_hosts, "No bridges has been configured via the " + "'I configure some ... bridges in Tor Launcher' step") - leaks = FirewallLeakCheck.new(@sniffer.pcap_file, - :accepted_hosts => @bridge_hosts) - leaks.assert_no_leaks -end - -Then /^the Tor binary is configured to use the expected Tor authorities$/ do - tor_auths = Set.new - tor_binary_orport_strings = $vm.execute_successfully( - "strings /usr/bin/tor | grep -E 'orport=[0-9]+'").stdout.chomp.split("\n") - tor_binary_orport_strings.each do |potential_auth_string| - auth_regex = /^\S+ orport=\d+( bridge)?( no-v2)?( v3ident=[A-Z0-9]{40})? ([0-9\.]+):\d+( [A-Z0-9]{4}){10}$/ - m = auth_regex.match(potential_auth_string) - if m - auth_ipv4_addr = m[4] - tor_auths << auth_ipv4_addr - end + assert_all_connections(@sniffer.pcap_file) do |c| + @bridge_hosts.include?({ address: c.daddr, port: c.dport }) end - expected_tor_auths = Set.new(TOR_AUTHORITIES) - assert_equal(expected_tor_auths, tor_auths, - "The Tor binary does not have the expected Tor authorities " + - "configured") end diff --git a/cucumber/features/step_definitions/torified_browsing.rb b/cucumber/features/step_definitions/torified_browsing.rb index c8f3ff1d..76760789 100644 --- a/cucumber/features/step_definitions/torified_browsing.rb +++ b/cucumber/features/step_definitions/torified_browsing.rb @@ -1,5 +1,5 @@ -When /^no traffic has flowed to the LAN$/ do - leaks = FirewallLeakCheck.new(@sniffer.pcap_file, :ignore_lan => false) - assert(not(leaks.ipv4_tcp_leaks.include?(@lan_host)), - "Traffic was sent to LAN host #{@lan_host}") +Then /^no traffic was sent to the web server on the LAN$/ do + assert_no_connections(@sniffer.pcap_file) do |c| + c.daddr == @web_server_ip_addr and c.dport == @web_server_port + end end diff --git a/cucumber/features/step_definitions/torified_gnupg.rb b/cucumber/features/step_definitions/torified_gnupg.rb index 4b4cc040..f5f61cef 100644 --- a/cucumber/features/step_definitions/torified_gnupg.rb +++ b/cucumber/features/step_definitions/torified_gnupg.rb @@ -1,3 +1,5 @@ +require 'resolv' + class OpenPGPKeyserverCommunicationError < StandardError end @@ -20,7 +22,7 @@ def start_or_restart_seahorse if @withgpgapplet seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletManageKeys.png') else - step 'I start "Seahorse" via the GNOME "Utilities" applications menu' + step 'I start "Passwords and Keys" via GNOME Activities Overview' end step 'Seahorse has opened' end @@ -43,6 +45,18 @@ When /^the "([^"]+)" OpenPGP key is not in the live user's public keyring$/ do | "The '#{keyid}' key is in the live user's public keyring.") end +def setup_onion_keyserver + resolver = Resolv::DNS.new + keyservers = resolver.getaddresses('pool.sks-keyservers.net').select do |addr| + addr.class == Resolv::IPv4 + end + onion_keyserver_address = keyservers.sample + hkp_port = 11371 + @onion_keyserver_job = chutney_onionservice_redir( + onion_keyserver_address, hkp_port + ) +end + When /^I fetch the "([^"]+)" OpenPGP key using the GnuPG CLI( without any signatures)?$/ do |keyid, without| # Make keyid an instance variable so we can reference it in the Seahorse # keysyncing step. @@ -52,7 +66,7 @@ When /^I fetch the "([^"]+)" OpenPGP key using the GnuPG CLI( without any signat else importopts = '' end - retry_tor do + retry_tor(Proc.new { setup_onion_keyserver }) do @gnupg_recv_key_res = $vm.execute_successfully( "timeout 120 gpg --batch #{importopts} --recv-key '#{@fetched_openpgp_keyid}'", :user => LIVE_USER) @@ -74,11 +88,6 @@ When /^the Seahorse operation is successful$/ do $vm.has_process?('seahorse') end -When /^GnuPG uses the configured keyserver$/ do - assert(@gnupg_recv_key_res.stderr[CONFIGURED_KEYSERVER_HOSTNAME], - "GnuPG's stderr did not mention keyserver #{CONFIGURED_KEYSERVER_HOSTNAME}") -end - When /^the "([^"]+)" key is in the live user's public keyring(?: after at most (\d) seconds)?$/ do |keyid, delay| delay = 10 unless delay try_for(delay.to_i, :msg => "The '#{keyid}' key is not in the live user's public keyring") { @@ -87,7 +96,7 @@ When /^the "([^"]+)" key is in the live user's public keyring(?: after at most ( } end -When /^I start Seahorse( via the Tails OpenPGP Applet)?$/ do |withgpgapplet| +When /^I start Seahorse( via the OpenPGP Applet)?$/ do |withgpgapplet| @withgpgapplet = !!withgpgapplet start_or_restart_seahorse end @@ -108,7 +117,8 @@ end Then /^I synchronize keys in Seahorse$/ do recovery_proc = Proc.new do - # The versions of Seahorse in Wheezy and Jessie will abort with a + setup_onion_keyserver + # The version of Seahorse in Jessie will abort with a # segmentation fault whenever there's any sort of network error while # syncing keys. This will usually happens after clicking away the error # message. This does not appear to be a problem in Stretch. @@ -151,7 +161,7 @@ Then /^I synchronize keys in Seahorse$/ do end end -When /^I fetch the "([^"]+)" OpenPGP key using Seahorse( via the Tails OpenPGP Applet)?$/ do |keyid, withgpgapplet| +When /^I fetch the "([^"]+)" OpenPGP key using Seahorse( via the OpenPGP Applet)?$/ do |keyid, withgpgapplet| step "I start Seahorse#{withgpgapplet}" def change_of_status?(keyid) @@ -166,6 +176,7 @@ When /^I fetch the "([^"]+)" OpenPGP key using Seahorse( via the Tails OpenPGP A end recovery_proc = Proc.new do + setup_onion_keyserver @screen.click('GnomeCloseButton.png') if @screen.exists('GnomeCloseButton.png') @screen.type("w", Sikuli::KeyModifier.CTRL) end @@ -198,11 +209,55 @@ When /^I fetch the "([^"]+)" OpenPGP key using Seahorse( via the Tails OpenPGP A end end -Then /^Seahorse is configured to use the correct keyserver$/ do - @gnome_keyservers = YAML.load($vm.execute_successfully('gsettings get org.gnome.crypto.pgp keyservers', - :user => LIVE_USER).stdout) - assert_equal(1, @gnome_keyservers.count, 'Seahorse should only have one keyserver configured.') - # Seahorse doesn't support hkps so that part of the domain is stripped out. - # We also insert hkp:// to the beginning of the domain. - assert_equal(CONFIGURED_KEYSERVER_HOSTNAME.sub('hkps.', 'hkp://'), @gnome_keyservers[0]) +Given /^(GnuPG|Seahorse) is configured to use Chutney's onion keyserver$/ do |app| + setup_onion_keyserver unless @onion_keyserver_job + _, _, onion_address, onion_port = chutney_onionservice_info + case app + when 'GnuPG' + # Validate the shipped configuration ... + server = /keyserver\s+(\S+)$/.match($vm.file_content("/home/#{LIVE_USER}/.gnupg/dirmngr.conf"))[1] + assert_equal( + "hkp://#{CONFIGURED_KEYSERVER_HOSTNAME}", server, + "GnuPG's dirmngr does not use the correct keyserver" + ) + # ... before replacing it + $vm.execute_successfully( + "sed -i 's/#{CONFIGURED_KEYSERVER_HOSTNAME}/#{onion_address}:#{onion_port}/' " + + "'/home/#{LIVE_USER}/.gnupg/dirmngr.conf'" + ) + when 'Seahorse' + # Validate the shipped configuration ... + @gnome_keyservers = YAML.load( + $vm.execute_successfully( + 'gsettings get org.gnome.crypto.pgp keyservers', + user: LIVE_USER + ).stdout + ) + assert_equal(1, @gnome_keyservers.count, + 'Seahorse should only have one keyserver configured.') + assert_equal( + 'hkp://' + CONFIGURED_KEYSERVER_HOSTNAME, @gnome_keyservers[0], + "GnuPG's dirmngr does not use the correct keyserver" + ) + # ... before replacing it + $vm.execute_successfully( + "gsettings set org.gnome.crypto.pgp keyservers \"['hkp://#{onion_address}:#{onion_port}']\"", + user: LIVE_USER + ) + end +end + +Then /^GnuPG's dirmngr uses the configured keyserver$/ do + _, _, onion_keyserver_address, _ = chutney_onionservice_info + dirmngr_request = $vm.execute_successfully( + 'gpg-connect-agent --dirmngr "keyserver --hosttable" /bye', user: LIVE_USER + ) + server = dirmngr_request.stdout.chomp.lines[1].split[4] + server = /keyserver\s+(\S+)$/.match( + $vm.file_content("/home/#{LIVE_USER}/.gnupg/dirmngr.conf") + )[1] + assert_equal( + "hkp://#{onion_keyserver_address}:5858", server, + "GnuPG's dirmngr does not use the correct keyserver" + ) end diff --git a/cucumber/features/step_definitions/torified_misc.rb b/cucumber/features/step_definitions/torified_misc.rb index 7112776a..7ccdb227 100644 --- a/cucumber/features/step_definitions/torified_misc.rb +++ b/cucumber/features/step_definitions/torified_misc.rb @@ -1,3 +1,5 @@ +require 'resolv' + When /^I query the whois directory service for "([^"]+)"$/ do |domain| retry_tor do @vm_execute_res = $vm.execute("whois '#{domain}'", :user => LIVE_USER) @@ -9,10 +11,18 @@ When /^I query the whois directory service for "([^"]+)"$/ do |domain| end end -When /^I wget "([^"]+)" to stdout(?:| with the '([^']+)' options)$/ do |url, options| - arguments = "-O - '#{url}'" - arguments = "#{options} #{arguments}" if options +When /^I wget "([^"]+)" to stdout(?:| with the '([^']+)' options)$/ do |target, options| retry_tor do + if target == "some Tails mirror" + host = 'dl.amnesia.boum.org' + address = Resolv.new.getaddresses(host).sample + puts "Resolved #{host} to #{address}" + url = "http://#{address}/tails/stable/" + else + url = target + end + arguments = "-O - '#{url}'" + arguments = "#{options} #{arguments}" if options @vm_execute_res = $vm.execute("wget #{arguments}", :user => LIVE_USER) if @vm_execute_res.failure? raise "wget:ing #{url} with options #{options} failed with:\n" + diff --git a/cucumber/features/step_definitions/totem.rb b/cucumber/features/step_definitions/totem.rb index 72698dde..a5b88d14 100644 --- a/cucumber/features/step_definitions/totem.rb +++ b/cucumber/features/step_definitions/totem.rb @@ -1,23 +1,24 @@ Given /^I create sample videos$/ do - @shared_video_dir_on_host = "#{$config["TMPDIR"]}/shared_video_dir" - @shared_video_dir_on_guest = "/tmp/shared_video_dir" - FileUtils.mkdir_p(@shared_video_dir_on_host) - add_after_scenario_hook { FileUtils.rm_r(@shared_video_dir_on_host) } + @video_dir_on_host = "#{$config["TMPDIR"]}/video_dir" + FileUtils.mkdir_p(@video_dir_on_host) + add_after_scenario_hook { FileUtils.rm_r(@video_dir_on_host) } fatal_system("avconv -loop 1 -t 30 -f image2 " + - "-i 'features/images/TailsBootSplash.png' " + + "-i 'features/images/USBTailsLogo.png' " + "-an -vcodec libx264 -y " + '-filter:v "crop=in_w-mod(in_w\,2):in_h-mod(in_h\,2)" ' + - "'#{@shared_video_dir_on_host}/video.mp4' >/dev/null 2>&1") + "'#{@video_dir_on_host}/video.mp4' >/dev/null 2>&1") end -Given /^I setup a filesystem share containing sample videos$/ do - $vm.add_share(@shared_video_dir_on_host, @shared_video_dir_on_guest) +Given /^I plug and mount a USB drive containing sample videos$/ do + @video_dir_on_guest = share_host_files( + Dir.glob("#{@video_dir_on_host}/*") + ) end Given /^I copy the sample videos to "([^"]+)" as user "([^"]+)"$/ do |destination, user| - for video_on_host in Dir.glob("#{@shared_video_dir_on_host}/*.mp4") do + for video_on_host in Dir.glob("#{@video_dir_on_host}/*.mp4") do video_name = File.basename(video_on_host) - src_on_guest = "#{@shared_video_dir_on_guest}/#{video_name}" + src_on_guest = "#{@video_dir_on_guest}/#{video_name}" dst_on_guest = "#{destination}/#{video_name}" step "I copy \"#{src_on_guest}\" to \"#{dst_on_guest}\" as user \"amnesia\"" end @@ -32,7 +33,7 @@ When /^I close Totem$/ do end Then /^I can watch a WebM video over HTTPs$/ do - test_url = 'https://webm.html5.org/test.webm' + test_url = 'https://tails.boum.org/lib/test_suite/test.webm' recovery_on_failure = Proc.new do step 'I close Totem' end diff --git a/cucumber/features/step_definitions/unsafe_browser.rb b/cucumber/features/step_definitions/unsafe_browser.rb index b8c04983..160279ca 100644 --- a/cucumber/features/step_definitions/unsafe_browser.rb +++ b/cucumber/features/step_definitions/unsafe_browser.rb @@ -1,6 +1,11 @@ -When /^I see and accept the Unsafe Browser start verification$/ do +When /^I see and accept the Unsafe Browser start verification(?:| in the "([^"]+)" locale)$/ do |locale| @screen.wait('GnomeQuestionDialogIcon.png', 30) - @screen.type(Sikuli::Key.ESC) + if ['ar_EG.utf8', 'fa_IR'].include?(locale) + # Take into account button ordering in RTL languages + @screen.type(Sikuli::Key.LEFT + Sikuli::Key.ENTER) + else + @screen.type(Sikuli::Key.RIGHT + Sikuli::Key.ENTER) + end end def supported_torbrowser_languages @@ -8,7 +13,8 @@ def supported_torbrowser_languages File.read(localization_descriptions).split("\n").map do |line| # The line will be of the form "xx:YY:..." or "xx-YY:YY:..." first, second = line.sub('-', '_').split(':') - candidates = ["#{first}_#{second}.utf8", "#{first}.utf8", + candidates = ["#{first}_#{second}.UTF-8", "#{first}_#{second}.utf8", + "#{first}.UTF-8", "#{first}.utf8", "#{first}_#{second}", first] when_not_found = Proc.new { raise "Could not find a locale for '#{line}'" } candidates.find(when_not_found) do |candidate| @@ -19,12 +25,12 @@ end Then /^I start the Unsafe Browser in the "([^"]+)" locale$/ do |loc| step "I run \"LANG=#{loc} LC_ALL=#{loc} sudo unsafe-browser\" in GNOME Terminal" - step "I see and accept the Unsafe Browser start verification" + step "I see and accept the Unsafe Browser start verification in the \"#{loc}\" locale" end Then /^the Unsafe Browser works in all supported languages$/ do failed = Array.new - supported_torbrowser_languages.each do |lang| + supported_torbrowser_languages.sample(3).each do |lang| step "I start the Unsafe Browser in the \"#{lang}\" locale" begin step "the Unsafe Browser has started" @@ -85,7 +91,7 @@ Then /^the Unsafe Browser has only Firefox's default bookmarks configured$/ do assert_equal(5, mozilla_uris_counter, "Unexpected number (#{mozilla_uris_counter}) of mozilla " \ "bookmarks") - assert_equal(3, places_uris_counter, + assert_equal(2, places_uris_counter, "Unexpected number (#{places_uris_counter}) of places " \ "bookmarks") @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT) @@ -108,7 +114,7 @@ Then /^I can start the Unsafe Browser again$/ do end Then /^I cannot configure the Unsafe Browser to use any local proxies$/ do - socks_proxy = 'c' # Alt+c for socks proxy + socks_proxy = 'C' # Alt+Shift+c for socks proxy no_proxy = 'y' # Alt+y for no proxy proxies = [[no_proxy, nil, nil]] socksport_lines = @@ -120,7 +126,7 @@ Then /^I cannot configure the Unsafe Browser to use any local proxies$/ do proxies.each do |proxy_type, proxy_host, proxy_port| @screen.hide_cursor - # Open proxy settings and select manual proxy configuration + # Open proxy settings @screen.click('UnsafeBrowserMenuButton.png') @screen.wait_and_click('UnsafeBrowserPreferencesButton.png', 10) @screen.wait_and_click('UnsafeBrowserAdvancedSettingsButton.png', 10) @@ -129,20 +135,25 @@ Then /^I cannot configure the Unsafe Browser to use any local proxies$/ do @screen.click(hit) if hit == 'UnsafeBrowserNetworkTab.png' @screen.wait_and_click('UnsafeBrowserNetworkTabSettingsButton.png', 10) @screen.wait_and_click('UnsafeBrowserProxySettingsWindow.png', 10) - @screen.type("m", Sikuli::KeyModifier.ALT) - # Configure the proxy - @screen.type(proxy_type, Sikuli::KeyModifier.ALT) # Select correct proxy type - @screen.type(proxy_host + Sikuli::Key.TAB + proxy_port) if proxy_type != no_proxy + # Ensure the desired proxy configuration + if proxy_type == no_proxy + @screen.type(proxy_type, Sikuli::KeyModifier.ALT) + @screen.wait('UnsafeBrowserNoProxySelected.png', 10) + else + @screen.type("M", Sikuli::KeyModifier.ALT) + @screen.type(proxy_type, Sikuli::KeyModifier.ALT) + @screen.type(proxy_host + Sikuli::Key.TAB + proxy_port) + end # Close settings @screen.click('UnsafeBrowserProxySettingsOkButton.png') @screen.waitVanish('UnsafeBrowserProxySettingsWindow.png', 10) # Test that the proxy settings work as they should - step "I open the address \"https://check.torproject.org\" in the Unsafe Browser" + step 'I open Tails homepage in the Unsafe Browser' if proxy_type == no_proxy - @screen.wait('UnsafeBrowserTorCheckFail.png', 60) + step 'Tails homepage loads in the Unsafe Browser' else @screen.wait('UnsafeBrowserProxyRefused.png', 60) end @@ -162,7 +173,11 @@ Then /^the Unsafe Browser has no proxy configured$/ do end Then /^the Unsafe Browser complains that no DNS server is configured$/ do - @screen.wait("UnsafeBrowserDNSError.png", 30) + assert_not_nil( + Dogtail::Application.new('zenity') + .child(roleName: 'label') + .text['No DNS server was obtained'] + ) end Then /^I configure the Unsafe Browser to check for updates more frequently$/ do diff --git a/cucumber/features/step_definitions/untrusted_partitions.rb b/cucumber/features/step_definitions/untrusted_partitions.rb index 43453b2f..603c8b4f 100644 --- a/cucumber/features/step_definitions/untrusted_partitions.rb +++ b/cucumber/features/step_definitions/untrusted_partitions.rb @@ -27,7 +27,7 @@ Given /^I create an? ([[:alnum:]]+) partition( labeled "([^"]+)")? with an? ([[: $vm.storage.disk_mkpartfs(name, parttype, fstype, opts) end -Given /^I cat an ISO of the Tails image to disk "([^"]+)"$/ do |name| +Given /^I write the Tails ISO image to disk "([^"]+)"$/ do |name| src_disk = { :path => TAILS_ISO, :opts => { @@ -55,7 +55,7 @@ end Then /^Tails Greeter has( not)? detected a persistence partition$/ do |no_persistence| expecting_persistence = no_persistence.nil? @screen.find('TailsGreeter.png') - found_persistence = ! @screen.exists('TailsGreeterPersistence.png').nil? + found_persistence = ! @screen.exists('TailsGreeterPersistencePassphrase.png').nil? assert_equal(expecting_persistence, found_persistence, "Persistence is unexpectedly#{no_persistence} enabled") end diff --git a/cucumber/features/step_definitions/usb.rb b/cucumber/features/step_definitions/usb.rb index 76f94d2f..e030f68e 100644 --- a/cucumber/features/step_definitions/usb.rb +++ b/cucumber/features/step_definitions/usb.rb @@ -48,6 +48,14 @@ def persistent_volumes_mountpoints $vm.execute("ls -1 -d /live/persistence/*_unlocked/").stdout.chomp.split end +def recover_from_upgrader_failure + $vm.execute('killall tails-upgrade-frontend tails-upgrade-frontend-wrapper zenity') + # Remove unnecessary sleep for retry + $vm.execute_successfully('sed -i "/^sleep 30$/d" ' + + '/usr/local/bin/tails-upgrade-frontend-wrapper') + $vm.spawn('tails-upgrade-frontend-wrapper', user: LIVE_USER) +end + Given /^I clone USB drive "([^"]+)" to a new USB drive "([^"]+)"$/ do |from, to| $vm.storage.clone_to_new_disk(from, to) end @@ -65,66 +73,105 @@ Given /^the computer is set to boot in UEFI mode$/ do @os_loader = 'UEFI' end +def tails_installer_selected_device + @installer.child('Target Device:', roleName: 'label').parent + .child('', roleName: 'combo box', recursive: false).name +end + +def tails_installer_is_device_selected?(name) + device = $vm.disk_dev(name) + tails_installer_selected_device[/#{device}\d*$/] +end + +def tails_installer_match_status(pattern) + @installer.child('', roleName: 'text').text[pattern] +end + class UpgradeNotSupported < StandardError end def usb_install_helper(name) - @screen.wait('USBTailsLogo.png', 10) - if @screen.exists("USBCannotUpgrade.png") + if tails_installer_match_status(/It is impossible to upgrade the device .+ #{$vm.disk_dev(name)}\d* /) raise UpgradeNotSupported end - @screen.wait_and_click('USBCreateLiveUSB.png', 10) - @screen.wait('USBCreateLiveUSBConfirmWindow.png', 10) - @screen.wait_and_click('USBCreateLiveUSBConfirmYes.png', 10) - @screen.wait('USBInstallationComplete.png', 30*60) + assert(tails_installer_is_device_selected?(name)) + begin + @installer.button('Install Tails').click + @installer.child('Question', roleName: 'alert').button('Yes').click + try_for(30*60) do + @installer + .child('Information', roleName: 'alert') + .child('Installation complete!', roleName: 'label') + true + end + rescue FindFailed => e + path = $vm.execute_successfully('ls -1 /tmp/tails-installer-*').stdout.chomp + debug_log("Tails Installer debug log:\n" + $vm.file_content(path)) + raise e + end end -When /^I start Tails Installer$/ do - step 'I start "TailsInstaller" via the GNOME "Tails" applications menu' - @screen.wait('USBCloneAndInstall.png', 30) +When /^I start Tails Installer in "([^"]+)" mode$/ do |mode| + step 'I run "export DEBUG=1 ; tails-installer-launcher" in GNOME Terminal' + installer_launcher = Dogtail::Application.new('tails-installer-launcher') + .child('Tails Installer', roleName: 'frame') + # Sometimes Dogtail will find the button and click it before it is + # shown (searchShowingOnly is not perfect) which generally means + # clicking somewhere on the Terminal => the click is lost *and* the + # installer does no go to the foreground. So let's wait a bit extra. + sleep 3 + installer_launcher.button(mode).click + @installer = Dogtail::Application.new('tails-installer') + @installer.child('Tails Installer', roleName: 'frame') + # ... and something similar (for consecutive steps) again. + sleep 3 + $vm.focus_window('Tails Installer') end -When /^I start Tails Installer in "([^"]+)" mode$/ do |mode| - step 'I start Tails Installer' - case mode - when 'Clone & Install' - @screen.wait_and_click('USBCloneAndInstall.png', 10) - when 'Clone & Upgrade' - @screen.wait_and_click('USBCloneAndUpgrade.png', 10) - when 'Upgrade from ISO' - @screen.wait_and_click('USBUpgradeFromISO.png', 10) - else - raise "Unsupported mode '#{mode}'" +Then /^Tails Installer detects that a device is too small$/ do + try_for(10) do + tails_installer_match_status(/^The device .* is too small to install Tails/) end end -Then /^Tails Installer detects that a device is too small$/ do - @screen.wait('TailsInstallerTooSmallDevice.png', 10) +When /^I am told that the destination device cannot be upgraded$/ do + try_for(10) do + tails_installer_match_status(/^It is impossible to upgrade the device/) + end end -When /^I "Clone & Install" Tails to USB drive "([^"]+)"$/ do |name| - step 'I start Tails Installer in "Clone & Install" mode' - usb_install_helper(name) +When /^I am suggested to do a "Install by cloning"$/ do + try_for(10) do + tails_installer_match_status( + /You should instead use "Install by cloning" to upgrade Tails/ + ) + end end -When /^I "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name| - step 'I start Tails Installer in "Clone & Upgrade" mode' - usb_install_helper(name) +Then /^a suitable USB device is (?:still )?not found$/ do + @installer.child( + 'No device suitable to install Tails could be found', roleName: 'label' + ) end -When /^I try a "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name| - begin - step "I \"Clone & Upgrade\" Tails to USB drive \"#{name}\"" - rescue UpgradeNotSupported - # this is what we expect - else - raise "The USB installer should not succeed" +Then /^(no|the "([^"]+)") USB drive is selected$/ do |mode, name| + try_for(30) do + if mode == 'no' + tails_installer_selected_device == '' + else + tails_installer_is_device_selected?(name) + end end end -When /^I try to "Upgrade from ISO" USB drive "([^"]+)"$/ do |name| +When /^I "([^"]*)" Tails to USB drive "([^"]+)"$/ do |mode, name| + step "I start Tails Installer in \"#{mode}\" mode" + usb_install_helper(name) +end + +When /^I fail to "([^"]*)" Tails to USB drive "([^"]+)"$/ do |mode, name| begin - step "I do a \"Upgrade from ISO\" on USB drive \"#{name}\"" + step "I \"#{mode}\" Tails to USB drive \"#{name}\"" rescue UpgradeNotSupported # this is what we expect else @@ -132,35 +179,20 @@ When /^I try to "Upgrade from ISO" USB drive "([^"]+)"$/ do |name| end end -When /^I am suggested to do a "Clone & Install"$/ do - @screen.find("USBCannotUpgrade.png") -end - -When /^I am told that the destination device cannot be upgraded$/ do - @screen.find("USBCannotUpgrade.png") -end - -Given /^I setup a filesystem share containing the Tails ISO$/ do - shared_iso_dir_on_host = "#{$config["TMPDIR"]}/shared_iso_dir" - @shared_iso_dir_on_guest = "/tmp/shared_iso_dir" - FileUtils.mkdir_p(shared_iso_dir_on_host) - FileUtils.cp(TAILS_ISO, shared_iso_dir_on_host) - add_after_scenario_hook { FileUtils.rm_r(shared_iso_dir_on_host) } - $vm.add_share(shared_iso_dir_on_host, @shared_iso_dir_on_guest) +Given /^I plug and mount a USB drive containing the Tails ISO$/ do + iso_dir = share_host_files(TAILS_ISO) + @iso_path = "#{iso_dir}/#{File.basename(TAILS_ISO)}" end When /^I do a "Upgrade from ISO" on USB drive "([^"]+)"$/ do |name| step 'I start Tails Installer in "Upgrade from ISO" mode' - @screen.wait('USBUseLiveSystemISO.png', 10) - match = @screen.find('USBUseLiveSystemISO.png') - @screen.click(match.getCenter.offset(0, match.h*2)) - @screen.wait('USBSelectISO.png', 10) - @screen.wait_and_click('GnomeFileDiagHome.png', 10) + @installer.child('Use existing Live system ISO:', roleName: 'label') + .parent.button('(None)').click + file_chooser = @installer.child('Select a File', roleName: 'file chooser') @screen.type("l", Sikuli::KeyModifier.CTRL) - @screen.wait('GnomeFileDiagTypeFilename.png', 10) - iso = "#{@shared_iso_dir_on_guest}/#{File.basename(TAILS_ISO)}" - @screen.type(iso) - @screen.wait_and_click('GnomeFileDiagOpenButton.png', 10) + # The only visible text element will be the path entry + file_chooser.child(roleName: 'text').typeText(@iso_path + '\n') + file_chooser.button('Open').click usb_install_helper(name) end @@ -174,13 +206,22 @@ Given /^I enable all persistence presets$/ do @screen.type(Sikuli::Key.TAB + Sikuli::Key.SPACE) end @screen.wait_and_click('PersistenceWizardSave.png', 10) + @screen.wait('PersistenceWizardDone.png', 60) + @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT) +end + +When /^I disable the first persistence preset$/ do + step 'I start "Configure persistent volume" via GNOME Activities Overview' + @screen.wait('PersistenceWizardPresets.png', 300) + @screen.type(Sikuli::Key.SPACE) + @screen.wait_and_click('PersistenceWizardSave.png', 10) @screen.wait('PersistenceWizardDone.png', 30) @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT) end Given /^I create a persistent partition$/ do - step 'I start "ConfigurePersistentVolume" via the GNOME "Tails" applications menu' - @screen.wait('PersistenceWizardStart.png', 20) + step 'I start "Configure persistent volume" via GNOME Activities Overview' + @screen.wait('PersistenceWizardStart.png', 60) @screen.type(@persistence_password + "\t" + @persistence_password + Sikuli::Key.ENTER) @screen.wait('PersistenceWizardPresets.png', 300) step "I enable all persistence presets" @@ -254,10 +295,9 @@ Then /^the running Tails is installed on USB drive "([^"]+)"$/ do |target_name| end Then /^the ISO's Tails is installed on USB drive "([^"]+)"$/ do |target_name| - iso = "#{@shared_iso_dir_on_guest}/#{File.basename(TAILS_ISO)}" iso_root = "/mnt/iso" $vm.execute("mkdir -p #{iso_root}") - $vm.execute("mount -o loop #{iso} #{iso_root}") + $vm.execute("mount -o loop #{@iso_path} #{iso_root}") tails_is_installed_helper(target_name, iso_root, "isolinux") $vm.execute("umount #{iso_root}") end @@ -274,10 +314,10 @@ Then /^a Tails persistence partition exists on USB drive "([^"]+)"$/ do |name| # The LUKS container may already be opened, e.g. by udisks after # we've run tails-persistence-setup. - c = $vm.execute("ls -1 /dev/mapper/") + c = $vm.execute("ls -1 --hide 'control' /dev/mapper/") if c.success? for candidate in c.stdout.split("\n") - luks_info = $vm.execute("cryptsetup status #{candidate}") + luks_info = $vm.execute("cryptsetup status '#{candidate}'") if luks_info.success? and luks_info.stdout.match("^\s+device:\s+#{dev}$") luks_dev = "/dev/mapper/#{candidate}" break @@ -300,7 +340,7 @@ Then /^a Tails persistence partition exists on USB drive "([^"]+)"$/ do |name| mount_dir = "/mnt/#{name}" $vm.execute("mkdir -p #{mount_dir}") - c = $vm.execute("mount #{luks_dev} #{mount_dir}") + c = $vm.execute("mount '#{luks_dev}' #{mount_dir}") assert(c.success?, "Couldn't mount opened LUKS device '#{dev}' on drive '#{name}'") @@ -310,12 +350,9 @@ Then /^a Tails persistence partition exists on USB drive "([^"]+)"$/ do |name| end Given /^I enable persistence$/ do - @screen.wait('TailsGreeterPersistence.png', 10) - @screen.type(Sikuli::Key.SPACE) - @screen.wait('TailsGreeterPersistencePassphrase.png', 10) - match = @screen.find('TailsGreeterPersistencePassphrase.png') - @screen.click(match.getCenter.offset(match.w*2, match.h/2)) - @screen.type(@persistence_password) + @screen.wait_and_click('TailsGreeterPersistencePassphrase.png', 10) + @screen.type(@persistence_password + Sikuli::Key.ENTER) + @screen.wait('TailsGreeterPersistenceUnlocked.png', 30) end def tails_persistence_enabled? @@ -325,13 +362,21 @@ def tails_persistence_enabled? 'test "$TAILS_PERSISTENCE_ENABLED" = true').success? end -Given /^all persistence presets(| from the old Tails version) are enabled$/ do |old_tails| +Given /^all persistence presets(| from the old Tails version)(| but the first one) are enabled$/ do |old_tails, except_first| + assert(old_tails.empty? || except_first.empty?, "Unsupported case.") try_for(120, :msg => "Persistence is disabled") do tails_persistence_enabled? end + unexpected_mounts = Array.new # Check that all persistent directories are mounted if old_tails.empty? expected_mounts = persistent_mounts + if ! except_first.empty? + first_expected_mount_source = expected_mounts.keys[0] + first_expected_mount_destination = expected_mounts[first_expected_mount_source] + expected_mounts.delete(first_expected_mount_source) + unexpected_mounts = [first_expected_mount_destination] + end else assert_not_nil($remembered_persistence_mounts) expected_mounts = $remembered_persistence_mounts @@ -341,17 +386,16 @@ Given /^all persistence presets(| from the old Tails version) are enabled$/ do | assert(mount.include?("on #{dir} "), "Persistent directory '#{dir}' is not mounted") end + for dir in unexpected_mounts do + assert(! mount.include?("on #{dir} "), + "Persistent directory '#{dir}' is mounted") + end end Given /^persistence is disabled$/ do assert(!tails_persistence_enabled?, "Persistence is enabled") end -Given /^I enable read-only persistence$/ do - step "I enable persistence" - @screen.wait_and_click('TailsGreeterPersistenceReadOnly.png', 10) -end - def boot_device # Approach borrowed from # config/chroot_local_includes/lib/live/config/998-permissions @@ -374,23 +418,21 @@ end Then /^Tails is running from (.*) drive "([^"]+)"$/ do |bus, name| bus = bus.downcase case bus - when "ide" + when "sata" expected_bus = "ata" else expected_bus = bus end assert_equal(expected_bus, boot_device_type) actual_dev = boot_device - # The boot partition differs between a "normal" install using the - # USB installer and isohybrid installations - expected_dev_normal = $vm.disk_dev(name) + "1" - expected_dev_isohybrid = $vm.disk_dev(name) + "4" - assert(actual_dev == expected_dev_normal || - actual_dev == expected_dev_isohybrid, + # The boot partition differs between an using Tails installer and + # isohybrids. There's also a strange case isohybrids are thought to + # be booting from the "raw" device, and not a partition of it + # (#10504). + expected_devs = ['', '1', '4'].map { |e| $vm.disk_dev(name) + e } + assert(expected_devs.include?(actual_dev), "We are running from device #{actual_dev}, but for #{bus} drive " + - "'#{name}' we expected to run from either device " + - "#{expected_dev_normal} (when installed via the USB installer) " + - "or #{expected_dev_isohybrid} (when installed from an isohybrid)") + "'#{name}' we expected to run from one of #{expected_devs}") end Then /^the boot device has safe access rights$/ do @@ -493,6 +535,12 @@ When /^I write some files expected to persist$/ do end end +When /^I write some dotfile expected to persist$/ do + assert($vm.execute("touch /live/persistence/TailsData_unlocked/dotfiles/.XXX_persist", + :user => LIVE_USER).success?, + "Could not create a file in the dotfiles persistence.") +end + When /^I remove some files expected to persist$/ do persistent_mounts.each do |_, dir| owner = $vm.execute("stat -c %U #{dir}").stdout.chomp @@ -529,6 +577,14 @@ Then /^the expected persistent files(| created with the old Tails version) are p end end +Then /^the expected persistent dotfile is present in the filesystem$/ do + expected_dirs = persistent_dirs + assert($vm.execute("test -L #{expected_dirs['dotfiles']}/.XXX_persist").success?, + "Could not find expected persistent dotfile link.") + assert($vm.execute("test -e $(readlink -f #{expected_dirs['dotfiles']}/.XXX_persist)").success?, + "Could not find expected persistent dotfile link target.") +end + Then /^only the expected files are present on the persistence partition on USB drive "([^"]+)"$/ do |name| assert(!$vm.is_running?) disk = { @@ -568,8 +624,8 @@ Then /^only the expected files are present on the persistence partition on USB d end When /^I delete the persistent partition$/ do - step 'I start "DeletePersistentVolume" via the GNOME "Tails" applications menu' - @screen.wait("PersistenceWizardDeletionStart.png", 20) + step 'I start "Delete persistent volume" via GNOME Activities Overview' + @screen.wait("PersistenceWizardDeletionStart.png", 120) @screen.type(" ") @screen.wait("PersistenceWizardDone.png", 120) end @@ -583,14 +639,109 @@ Given /^I create a ([[:alpha:]]+) label on disk "([^"]+)"$/ do |type, name| $vm.storage.disk_mklabel(name, type) end -Then /^a suitable USB device is (?:still )?not found$/ do - @screen.wait("TailsInstallerNoQEMUHardDisk.png", 30) +Given /^the file system changes introduced in version (.+) are (not )?present(?: in the (\S+) Browser's chroot)?$/ do |version, not_present, chroot_browser| + assert_equal('1.1~test', version) + upgrade_applied = not_present.nil? + chroot_browser = "#{chroot_browser.downcase}-browser" if chroot_browser + changes = [ + { + filesystem: :rootfs, + path: 'some_new_file', + status: :added, + new_content: <<-EOF +Some content + EOF + }, + { + filesystem: :rootfs, + path: 'etc/amnesia/version', + status: :modified, + new_content: <<-EOF +#{version} - 20380119 +ffffffffffffffffffffffffffffffffffffffff +live-build: 3.0.5+really+is+2.0.12-0.tails2 +live-boot: 4.0.2-1 +live-config: 4.0.4-1 + EOF + }, + { + filesystem: :rootfs, + path: 'etc/os-release', + status: :modified, + new_content: <<-EOF +TAILS_PRODUCT_NAME="Tails" +TAILS_VERSION_ID="#{version}" + EOF + }, + { + filesystem: :rootfs, + path: 'usr/share/common-licenses/BSD', + status: :removed + }, + { + filesystem: :medium, + path: 'utils/linux/syslinux', + status: :removed + }, + ] + changes.each do |change| + case change[:filesystem] + when :rootfs + path = '/' + path += "var/lib/#{chroot_browser}/chroot/" if chroot_browser + path += change[:path] + when :medium + path = '/lib/live/mount/medium/' + change[:path] + else + raise "Unknown filesysten '#{change[:filesystem]}'" + end + case change[:status] + when :removed + assert_equal(!upgrade_applied, $vm.file_exist?(path)) + when :added + assert_equal(upgrade_applied, $vm.file_exist?(path)) + if upgrade_applied && change[:new_content] + assert_equal(change[:new_content], $vm.file_content(path)) + end + when :modified + assert($vm.file_exist?(path)) + if upgrade_applied + assert_not_nil(change[:new_content]) + assert_equal(change[:new_content], $vm.file_content(path)) + end + else + raise "Unknown status '#{change[:status]}'" + end + end +end + +Then /^I am proposed to install an incremental upgrade to version (.+)$/ do |version| + recovery_proc = Proc.new do + recover_from_upgrader_failure + end + failure_pic = 'TailsUpgraderFailure.png' + success_pic = "TailsUpgraderUpgradeTo#{version}.png" + retry_tor(recovery_proc) do + match, _ = @screen.waitAny([success_pic, failure_pic], 2*60) + assert_equal(success_pic, match) + end end -Then /^the "(?:[^"]+)" USB drive is selected$/ do - @screen.wait("TailsInstallerQEMUHardDisk.png", 30) +When /^I agree to install the incremental upgrade$/ do + @screen.click('TailsUpgraderUpgradeNowButton.png') end -Then /^no USB drive is selected$/ do - @screen.wait("TailsInstallerNoQEMUHardDisk.png", 30) +Then /^I can successfully install the incremental upgrade to version (.+)$/ do |version| + step 'I agree to install the incremental upgrade' + recovery_proc = Proc.new do + recover_from_upgrader_failure + step "I am proposed to install an incremental upgrade to version #{version}" + step 'I agree to install the incremental upgrade' + end + failure_pic = 'TailsUpgraderFailure.png' + success_pic = "TailsUpgraderDone.png" + retry_tor(recovery_proc) do + match, _ = @screen.waitAny([success_pic, failure_pic], 2*60) + assert_equal(success_pic, match) + end end 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(<<END_OF_CHANGELOG) @@ -88,3 +92,35 @@ RSpec::Matchers.define :have_suite do |suite| "expected an output with #{suite}" end end + +RSpec::Matchers.define :have_tagged_snapshot do |tag| + match do |string| + # e.g.: `http://tagged.snapshots.deb.tails.boum.org/0.10` + %r{^http://tagged\.snapshots\.deb\.tails\.boum\.org/#{Regexp.escape(tag)}/[a-z-]+$}.match(string) + end + failure_message_for_should do |string| + "expected the mirror to be #{tag}\nCurrent mirror: #{string}" + end + failure_message_for_should_not do |string| + "expected the mirror not to be #{tag}\nCurrent mirror: #{string}" + end + description do + "expected an output with #{tag}" + end +end + +RSpec::Matchers.define :have_time_based_snapshot do |tag| + match do |string| + # e.g.: `http://time-based.snapshots.deb.tails.boum.org/debian/2016060602` + %r{^http://time\-based\.snapshots\.deb\.tails\.boum\.org/[^/]+/\d+}.match(string) + end + failure_message_for_should do |string| + "expected the mirror to be a time-based snapshot\nCurrent mirror: #{string}" + end + failure_message_for_should_not do |string| + "expected the mirror not to be a time-based snapshot\nCurrent mirror: #{string}" + end + description do + "expected a time-based snapshot" + end +end diff --git a/cucumber/features/support/extra_hooks.rb b/cucumber/features/support/extra_hooks.rb index 16196a55..c2c57494 100644 --- a/cucumber/features/support/extra_hooks.rb +++ b/cucumber/features/support/extra_hooks.rb @@ -1,18 +1,21 @@ # Make the code below work with cucumber >= 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 - <qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'> - <qemu:arg value='-cpu'/> - <qemu:arg value='qemu32,-pae'/> - </qemu:commandline> -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 |