[Qa-jenkins-scm] [jenkins.debian.net] 01/11: lvc: grab updates from tails (01371c19bd..6ae59c49e5)

Holger Levsen holger at layer-acht.org
Tue Aug 1 04:54:50 UTC 2017


This is an automated email from the git hooks/post-receive script.

holger pushed a commit to branch master
in repository jenkins.debian.net.

commit a6f41c35e337db192e612ee6e1545fcae4c69ac7
Author: Philip Hands <phil at hands.com>
Date:   Thu Jun 29 22:11:09 2017 +0200

    lvc: grab updates from tails (01371c19bd..6ae59c49e5)
    
    Signed-off-by: Holger Levsen <holger at layer-acht.org>
---
 cucumber/bin/run_test_suite                        |  26 +-
 cucumber/features/config/defaults.yml              |   4 +-
 cucumber/features/domains/cdrom.xml                |   5 +
 cucumber/features/domains/default.xml              |  19 +-
 cucumber/features/domains/default_net.xml          |   2 +-
 cucumber/features/domains/fs_share.xml             |   6 -
 cucumber/features/scripts/vm-execute               |   6 +-
 cucumber/features/step_definitions/apt.rb          | 132 +++--
 cucumber/features/step_definitions/browser.rb      | 100 ++--
 cucumber/features/step_definitions/build.rb        |  46 +-
 cucumber/features/step_definitions/checks.rb       | 124 ++---
 cucumber/features/step_definitions/common_steps.rb | 579 +++++++++++----------
 cucumber/features/step_definitions/dhcp.rb         |  34 +-
 cucumber/features/step_definitions/electrum.rb     |  18 +-
 cucumber/features/step_definitions/encryption.rb   |  16 +-
 .../features/step_definitions/firewall_leaks.rb    |  33 +-
 cucumber/features/step_definitions/git.rb          |  26 +
 cucumber/features/step_definitions/icedove.rb      |  94 ----
 cucumber/features/step_definitions/mac_spoofing.rb |  87 ++--
 cucumber/features/step_definitions/pidgin.rb       | 126 +++--
 .../step_definitions/root_access_control.rb        |   3 +-
 cucumber/features/step_definitions/snapshots.rb    |  15 +-
 cucumber/features/step_definitions/ssh.rb          |  52 +-
 cucumber/features/step_definitions/time_syncing.rb |  16 +-
 cucumber/features/step_definitions/tor.rb          | 152 +++---
 .../features/step_definitions/torified_browsing.rb |   8 +-
 .../features/step_definitions/torified_gnupg.rb    |  89 +++-
 .../features/step_definitions/torified_misc.rb     |  16 +-
 cucumber/features/step_definitions/totem.rb        |  23 +-
 .../features/step_definitions/unsafe_browser.rb    |  45 +-
 .../step_definitions/untrusted_partitions.rb       |   4 +-
 cucumber/features/step_definitions/usb.rb          | 347 ++++++++----
 cucumber/features/support/config.rb                |  18 +-
 cucumber/features/support/env.rb                   |  36 ++
 cucumber/features/support/extra_hooks.rb           |  54 +-
 cucumber/features/support/helpers/dogtail.rb       | 233 +++++++++
 cucumber/features/support/helpers/exec_helper.rb   |  90 ----
 .../features/support/helpers/firewall_helper.rb    | 187 +++----
 cucumber/features/support/helpers/misc_helpers.rb  | 139 ++++-
 cucumber/features/support/helpers/remote_shell.rb  | 171 ++++++
 cucumber/features/support/helpers/sikuli_helper.rb |  32 +-
 .../features/support/helpers/sniffing_helper.rb    |  14 +-
 .../features/support/helpers/storage_helper.rb     |  39 +-
 cucumber/features/support/helpers/vm_helper.rb     | 206 ++++----
 cucumber/features/support/hooks.rb                 |  53 +-
 45 files changed, 2174 insertions(+), 1351 deletions(-)

diff --git a/cucumber/bin/run_test_suite b/cucumber/bin/run_test_suite
index 1912844..f790363 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 9c31214..bd06307 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 at amnesia"
+Unsafe_SSH_public_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8xQ2BRQz+TK6jbqb5fDuKAbrOAYUwVtLe7yblK0awk6fvMuInw/kyaX9H7i105LqjBVThFplM+w1lkr8KViY4+GY28nTilUKGTYNnwABGD9MA2PeqMqzcP4x4puTVu3oSwDnmSAaxNSTLlOLxxzZadrbmOqNqAiLIzzbY8Yb2aYjr/MthHpAtSLM1pyJetEzdDhHixCQSt5WUd6ic8SIZSz3PHSzAKku08zlQhi17U9UeCTB4+xTq8zxSpVMr9XC0suAtqdeewwW7OrsNMBc+rj35dIU2rgmXFsQXr49Bdm9hnk15bTQars1Kk8/y6gevp/Un6YzHGczzmPNi1HH5 amnesia at amnesia"
diff --git a/cucumber/features/domains/cdrom.xml b/cucumber/features/domains/cdrom.xml
new file mode 100644
index 0000000..8bc3be7
--- /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 040a5d8..0966ef9 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 35a1c61..e9a575d 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 718755e..0000000
--- 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 79b6942..f3d20f9 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 c69d259..52ef9f7 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 84ef1d3..68d1bca 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 fd001ff..e02edc6 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 423b839..142141a 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 feec90e..ca7604f 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 at default.service").success?
   #    $vm.execute("systemctl stop tor at 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 at 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 ef4d9e1..3c83422 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 447983d..eaeb22a 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 9f7f1b9..3b20a5b 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 942d00b..0cd94cc 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 bf6f869..bd8fcf7 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 d367289..0000000
--- 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 a4aa871..260b28f 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 3f5ed93..43949b6 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 ff1bdfc..8362342 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 74c60d2..16e59a4 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 038b297..1fd0efa 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 319fb52..d1b8107 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 ac12fd4..04852f7 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 c8f3ff1..7676078 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 4b4cc04..f5f61ce 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 7112776..7ccdb22 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 72698dd..a5b88d1 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 b8c0498..160279c 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 43453b2..603c8b4 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 76f94d2..e030f68 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 13578d5..54a0f1c 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 53f502e..c52afff 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 16196a5..c2c5749 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 0000000..2a92649
--- /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 70d22d3..0000000
--- 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 fce363c..f88091d 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 7e09411..865d297 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 0000000..b890578
--- /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 553abd9..167eded 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 213411e..38b1382 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 de782ee..3bbdb69 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 5d02c11..be3ae5f 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 1bb6cfd..a55d361 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
 

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/qa/jenkins.debian.net.git



More information about the Qa-jenkins-scm mailing list