diff options
author | Philip Hands <phil@hands.com> | 2016-05-11 17:11:01 +0200 |
---|---|---|
committer | Philip Hands <phil@hands.com> | 2016-05-11 17:11:01 +0200 |
commit | a5d56e3b5443263b53b0487c81125123411bd0cf (patch) | |
tree | 71b1bdafc0a5978bca9073609eff33e228e29a12 /cucumber | |
parent | 555d9414f758cc0062eff700a0352ae177fd9be5 (diff) | |
download | jenkins.debian.net-a5d56e3b5443263b53b0487c81125123411bd0cf.tar.xz |
move cucumber things under cucumber/
Diffstat (limited to 'cucumber')
96 files changed, 7968 insertions, 0 deletions
diff --git a/cucumber/README-sikuli-cucumber b/cucumber/README-sikuli-cucumber new file mode 100644 index 00000000..24d90aaf --- /dev/null +++ b/cucumber/README-sikuli-cucumber @@ -0,0 +1,13 @@ +Key names: + + http://doc.sikuli.org/keys.html + +Running the thing by hand: + + ./bin/lvc/run_test_suite --view --iso /var/lib/libvirt/images/debian-8.3.0-amd64-i386-netinst.iso --old-iso /var/lib/libvirt/images/debian-8.3.0-amd64-i386-netinst.iso DebianLive/apt.feature + +FIXME - I added a git repo to: + + /var/lib/jenkins/workspace/lvc_debian-installer_jessie_standard_apt + +in order to shut-up Tail's script that looks for git repos to populate some variables -- need to strip that out, or make it overridable. diff --git a/cucumber/bin/run_test_suite b/cucumber/bin/run_test_suite new file mode 100755 index 00000000..5b40c389 --- /dev/null +++ b/cucumber/bin/run_test_suite @@ -0,0 +1,276 @@ +#!/bin/bash + +set -e +set -u +set -o pipefail + +NAME=$(basename ${0}) + +GENERAL_DEPENDENCIES=" +cucumber +devscripts +dnsmasq-base +gawk +git +i18nspector +libav-tools +libcap2-bin +libsikuli-script-java +libvirt-clients +libvirt-daemon-system +libvirt-dev +libvirt0 +openjdk-7-jre +openssh-server +ovmf +python-jabberbot +python-potr +qemu-kvm +qemu-system-x86 +ruby-guestfs +ruby-json +ruby-libvirt +ruby-net-irc +ruby-packetfu +ruby-rb-inotify +ruby-rjb +ruby-rspec +ruby-test-unit +seabios +tcpdump +unclutter +virt-viewer +xvfb +" + +usage() { + echo "Usage: $NAME [OPTION]... [--] [CUCUMBER_ARGS]... +Sets up an appropriate environment and invokes cucumber. Note that this script +must be run from the Tails source directory root. + +Options for '@product' features: + --artifacts-base-uri URI + Pretend that the artifact is located at URI when printing + its location during a scenario failure. This is useful if + you intend to serve the artifacts via the web, for + instance. + --capture Captures failed scenarios into videos stored in the + temporary directory (see --tmpdir below) using x264 + 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. + --keep-snapshots Don't ever delete any snapshots (including ones marked as + temporary). This can be a big time saver when debugging new + features. + --retry-find Print a warning whenever Sikuli fails to find an image + and allow *one* retry after pressing ENTER. This is useful + for updating outdated images. + --tmpdir Directory where various temporary files are written + during a test, e.g. VM snapshots and memory dumps, + failure screenshots, pcap files and disk images + (default is TMPDIR in the environment, and if unset, + /tmp/DebianToaster). + --view Shows the test session in a windows. Requires x11vnc + and xtightvncviewer. + --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 + an older version of Tails, which this options sets to + IMAGE. If none is given, it defaults to the same IMAGE + given by --iso, which will be good enough for most testing + purposes. + +Note that '@source' features has no relevant options. + +CUCUMBER_ARGS can be used to specify which features to be run, but also any +cucumber option, although then you must pass \`--\` first to let this wrapper +script know that we're done with *its* options. For debugging purposes, a +'debug' formatter has been added so pretty debugging can be enabled with +\`--format debug\`. You could even combine the default (pretty) formatter with +pretty debugging printed to a file with \`--format pretty --format debug +--out debug.log\`. +" +} + +error() { + echo "${NAME}: error: ${*}" >&2 + usage + exit 1 +} + +package_installed() { + local ret + set +o pipefail + if dpkg -s "${1}" 2>/dev/null | grep -q "^Status:.*installed"; then + ret=0 + else + ret=1 + fi + set -o pipefail + return ${ret} +} + +check_dependencies() { + while [ -n "${1:-}" ]; do + if ! which "${1}" >/dev/null && ! package_installed "${1}" ; then + error "'${1}' is missing, please install it and run again." + fi + shift + done +} + +display_in_use() { + [ -e "/tmp/.X${1#:}-lock" ] || [ -e "/tmp/.X11-unix/X${1#:}" ] +} + +next_free_display() { + display_nr=0 + while display_in_use ":${display_nr}"; do + display_nr=$((display_nr+1)) + done + echo ":${display_nr}" +} + +test_suite_cleanup() { + (kill -0 ${XVFB_PID} 2>/dev/null && kill ${XVFB_PID}) || /bin/true +} + +start_xvfb() { + Xvfb $TARGET_DISPLAY -screen 0 1024x768x24+32 >/dev/null 2>&1 & + XVFB_PID=$! + # Wait for Xvfb to run on TARGET_DISPLAY + until display_in_use $TARGET_DISPLAY; do + sleep 1 + done + echo "Virtual X framebuffer started on display ${TARGET_DISPLAY}" + # Hide the mouse cursor so it won't mess up Sikuli's screen scanning + unclutter -display $TARGET_DISPLAY -root -idle 0 >/dev/null 2>&1 & +} + +start_vnc_server() { + check_dependencies x11vnc + VNC_SERVER_PORT="$(x11vnc -listen localhost -display ${TARGET_DISPLAY} \ + -bg -nopw -forever 2>&1 | \ + grep -m 1 "^PORT=[0-9]\+" | sed 's/^PORT=//')" + echo "VNC server running on: localhost:${VNC_SERVER_PORT}" +} + +start_vnc_viewer() { + check_dependencies xtightvncviewer + xtightvncviewer -viewonly localhost:${VNC_SERVER_PORT} 1>/dev/null 2>&1 & +} + +capture_session() { + check_dependencies libvpx1 + echo "Capturing guest display into ${CAPTURE_FILE}" + avconv -f x11grab -s 1024x768 -r 15 -i ${TARGET_DISPLAY}.0 -an \ + -vcodec libvpx -y "${CAPTURE_FILE}" >/dev/null 2>&1 & +} + +# main script + +# Unset all environment variables used by this script to pass options +# to cucumber, except TMPDIR since we explicitly want to support +# setting it that way. +ARTIFACTS_BASE_URI= +CAPTURE= +CAPTURE_ALL= +LOG_FILE= +VNC_VIEWER= +VNC_SERVER= +PAUSE_ON_FAIL= +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" +OPTS=$(getopt -o "" --longoptions $LONGOPTS -n "${NAME}" -- "$@") +eval set -- "$OPTS" +while [ $# -gt 0 ]; do + case $1 in + --artifacts-base-uri) + shift + export ARTIFACTS_BASE_URI="${1}" + ;; + --view) + VNC_VIEWER=yes + VNC_SERVER=yes + ;; + --vnc-server-only) + VNC_VIEWER= + VNC_SERVER=yes + ;; + --capture) + check_dependencies x264 + export CAPTURE="yes" + ;; + --capture-all) + check_dependencies x264 + export CAPTURE="yes" + export CAPTURE_ALL="yes" + ;; + --pause-on-fail) + export PAUSE_ON_FAIL="yes" + ;; + --keep-snapshots) + export KEEP_SNAPSHOTS="yes" + ;; + --retry-find) + export SIKULI_RETRY_FINDFAILED="yes" + ;; + --tmpdir) + shift + export TMPDIR="$(readlink -f $1)" + ;; + --iso) + shift + export ISO="$(readlink -f $1)" + ;; + --old-iso) + shift + export OLD_ISO="$(readlink -f $1)" + ;; + --help) + usage + exit 0 + ;; + --) + shift + break + ;; + esac + shift +done + +trap "test_suite_cleanup" EXIT HUP INT QUIT TERM + +check_dependencies ${GENERAL_DEPENDENCIES} + +TARGET_DISPLAY=$(next_free_display) + +start_xvfb + +if [ -n "${VNC_SERVER:-}" ]; then + start_vnc_server +fi +if [ -n "${VNC_VIEWER:-}" ]; then + start_vnc_viewer +fi + +export SIKULI_HOME="/usr/share/java" +export SIKULI_IMAGE_PATH="/srv/jenkins/features/images/" +export RUBYLIB="/srv/jenkins" +export VM_XML_PATH="/srv/jenkins/features/domains" +export DISPLAY=${TARGET_DISPLAY} +check_dependencies cucumber + +# cludge ruby to stop buffering output +RUBY_STDOUT_SYNC=$TMPDIR/.stdout-sync.rb +echo STDOUT.sync = true > $RUBY_STDOUT_SYNC +export RUBYOPT="-r $RUBY_STDOUT_SYNC" + +cucumber ${@} diff --git a/cucumber/features/config/defaults.yml b/cucumber/features/config/defaults.yml new file mode 100644 index 00000000..9c312146 --- /dev/null +++ b/cucumber/features/config/defaults.yml @@ -0,0 +1,36 @@ +CAPTURE: false +CAPTURE_ALL: false +MAX_NEW_TOR_CIRCUIT_RETRIES: 10 +PAUSE_ON_FAIL: false +SIKULI_RETRY_FINDFAILED: false +TMPDIR: "/tmp/DebianToaster" + +Unsafe_SSH_private_key: | + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAvMUNgUUM/kyuo26m+Xw7igG6zgGFMFbS3u8m5StGsJOn7zLi + J8P5Mml/R+4tdOS6owVU4RaZTPsNZZK/ClYmOPhmNvJ04pVChk2DZ8AARg/TANj3 + qjKs3D+MeKbk1bt6EsA55kgGsTUky5Ti8cc2Wna25jqjagIiyM822PGG9mmI6/zL + YR6QLUizNaciXrRM3Q4R4sQkEreVlHeonPEiGUs9zx0swCpLtPM5UIYte1PVHgkw + ePsU6vM8UqVTK/VwtLLgLanXnsMFuzq7DTAXPq49+XSFNq4JlxbEF6+PQXZvYZ5N + eW00Gq7NSpPP8uoHr6f1J+mMxxnM85jzYtRx+QIDAQABAoIBAA8Bs1MlhCTrP67q + awfGYo1UGd+qq0XugREL/hGV4SbEdkNDzkrO/46MaHv1aVOzo0q2b8r9Gu7NvoDm + q51Mv/kjdizEFZq1tvYqT1n+H4dyVpnopbe4E5nmy2oECokbQFchRPkTnMSVrvko + OupxpdaHPX8MBlW1GcLRBlE00j/gfK1SXX5rcxkF5EHVND1b6iHddTPearDbU8yr + wga1XO6WeohAYzqmGtMD0zk6lOk0LmnTNG6WvHiFTAc/0yTiKub6rNOIEMS/82+V + l437H0hKcIN/7/mf6FpqRNPJTuhOVFf+L4G/ZQ8zHoMGVIbhuTiIPqZ/KMu3NaUF + R634jckCgYEA+jJ31hom/d65LfxWPkmiSkNTEOTfjbfcgpfc7sS3enPsYnfnmn5L + O3JJzAKShSVP8NVuPN5Mg5FGp9QLKrN3kV6QWQ3EnqeW748DXMU6zKGJQ5wo7ZVm + w2DhJ/3PAuBTL/5X4mjPQL+dr86Aq2JBDC7LHJs40I8O7UbhnsdMxKcCgYEAwSXc + 3znAkAX8o2g37RiAl36HdONgxr2eaGK7OExp03pbKmoISw6bFbVpicBy6eTytn0A + 2PuFcBKJRfKrViHyiE8UfAJ31JbUaxpg4bFF6UEszN4CmgKS8fnwEe1aX0qSjvkE + NQSuhN5AfykXY/1WVIaWuC500uB7Ow6M16RDyF8CgYEAqFTeNYlg5Hs+Acd9SukF + rItBTuN92P5z+NUtyuNFQrjNuK5Nf68q9LL/Hag5ZiVldHZUddVmizpp3C6Y2MDo + WEDUQ2Y0/D1rGoAQ1hDIb7bbAEcHblmPSzJaKirkZV4B+g9Yl7bGghypfggkn6o6 + c3TkKLnybrdhZpjC4a3bY48CgYBnWRYdD27c4Ycz/GDoaZLs/NQIFF5FGVL4cdPR + pPl/IdpEEKZNWwxaik5lWedjBZFlWe+pKrRUqmZvWhCZruJyUzYXwM5Tnz0b7epm + +Q76Z1hMaoKj27q65UyymvkfQey3ucCpic7D45RJNjiA1R5rbfSZqqnx6BGoIPn1 + rLxkKwKBgDXiWeUKJCydj0NfHryGBkQvaDahDE3Yigcma63b8vMZPBrJSC4SGAHJ + NWema+bArbaF0rKVJpwvpkZWGcr6qRn94Ts0kJAzR+VIVTOjB9sVwdxjadwWHRs5 + kKnpY0tnSF7hyVRwN7GOsNDJEaFjCW7k4+55D2ZNBy2iN3beW8CZ + -----END RSA PRIVATE KEY----- +Unsafe_SSH_public_key: = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8xQ2BRQz+TK6jbqb5fDuKAbrOAYUwVtLe7yblK0awk6fvMuInw/kyaX9H7i105LqjBVThFplM+w1lkr8KViY4+GY28nTilUKGTYNnwABGD9MA2PeqMqzcP4x4puTVu3oSwDnmSAaxNSTLlOLxxzZadrbmOqNqAiLIzzbY8Yb2aYjr/MthHpAtSLM1pyJetEzdDhHixCQSt5WUd6ic8SIZSz3PHSzAKku08zlQhi17U9UeCTB4+xTq8zxSpVMr9XC0suAtqdeewwW7OrsNMBc+rj35dIU2rgmXFsQXr49Bdm9hnk15bTQars1Kk8/y6gevp/Un6YzHGczzmPNi1HH5 amnesia@amnesia" diff --git a/cucumber/features/domains/default.xml b/cucumber/features/domains/default.xml new file mode 100644 index 00000000..f1004dcf --- /dev/null +++ b/cucumber/features/domains/default.xml @@ -0,0 +1,59 @@ +<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> + <os> + <type arch='x86_64' machine='pc-0.15'>hvm</type> + <boot dev='cdrom'/> + </os> + <features> + <acpi/> + <apic/> + <pae/> + </features> + <cpu mode='host-model'/> + <clock offset='utc'/> + <on_poweroff>destroy</on_poweroff> + <on_reboot>restart</on_reboot> + <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='virtio-serial' index='0'/> + <interface type='network'> + <mac address='52:54:00:ac:dd:ee'/> + <source network='DebianToasterNet'/> + <model type='virtio'/> + <link state='up'/> + </interface> + <serial type='tcp'> + <source mode="bind" host='127.0.0.1' service='1337'/> + <target port='0'/> + </serial> + <input type='tablet' bus='usb'/> + <channel type='spicevmc'> + <target type='virtio' name='com.redhat.spice.0'/> + </channel> + <graphics type='spice' port='-1' tlsPort='-1' autoport='yes'> + <mouse mode='client'/> + </graphics> + <sound model='ich6'/> + <video> + <model type='qxl' ram='65536' vram='131072' vgamem='16384' heads='1'/> + <address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0'/> + </video> + <memballoon model='virtio'/> + </devices> +</domain> + diff --git a/cucumber/features/domains/default_net.xml b/cucumber/features/domains/default_net.xml new file mode 100644 index 00000000..fd2966eb --- /dev/null +++ b/cucumber/features/domains/default_net.xml @@ -0,0 +1,13 @@ +<network> + <name>DebianToasterNet</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" /> + </dhcp> + </ip> + <ip family="ipv6" address="fc00::1" prefix="7" /> +</network> + diff --git a/cucumber/features/domains/disk.xml b/cucumber/features/domains/disk.xml new file mode 100644 index 00000000..8193fea3 --- /dev/null +++ b/cucumber/features/domains/disk.xml @@ -0,0 +1,5 @@ +<disk type='file' device='disk'> + <driver name='qemu' type=''/> + <source file=''/> + <target dev='' bus=''/> +</disk> diff --git a/cucumber/features/domains/fs_share.xml b/cucumber/features/domains/fs_share.xml new file mode 100644 index 00000000..718755ea --- /dev/null +++ b/cucumber/features/domains/fs_share.xml @@ -0,0 +1,6 @@ +<filesystem type='mount' accessmode='passthrough'> + <driver type='path' wrpolicy='immediate'/> + <source dir=''/> + <target dir=''/> + <readonly/> +</filesystem> diff --git a/cucumber/features/domains/storage_pool.xml b/cucumber/features/domains/storage_pool.xml new file mode 100644 index 00000000..ce0a6915 --- /dev/null +++ b/cucumber/features/domains/storage_pool.xml @@ -0,0 +1,6 @@ +<pool type="dir"> + <name>DebianToasterStorage</name> + <target> + <path></path> + </target> +</pool> diff --git a/cucumber/features/domains/volume.xml b/cucumber/features/domains/volume.xml new file mode 100644 index 00000000..702d5a05 --- /dev/null +++ b/cucumber/features/domains/volume.xml @@ -0,0 +1,14 @@ +<volume> + <name></name> + <allocation>0</allocation> + <capacity unit="b"></capacity> + <target> + <path></path> + <format type='qcow2'/> + <permissions> + <owner></owner> + <group></group> + <mode>0664</mode> + </permissions> + </target> +</volume> diff --git a/cucumber/features/images/DebianInstallerCountrySelection.png b/cucumber/features/images/DebianInstallerCountrySelection.png Binary files differnew file mode 100644 index 00000000..fe130993 --- /dev/null +++ b/cucumber/features/images/DebianInstallerCountrySelection.png diff --git a/cucumber/features/images/DebianInstallerDomainPrompt.png b/cucumber/features/images/DebianInstallerDomainPrompt.png Binary files differnew file mode 100644 index 00000000..d7fca5f8 --- /dev/null +++ b/cucumber/features/images/DebianInstallerDomainPrompt.png diff --git a/cucumber/features/images/DebianInstallerHostnamePrompt.png b/cucumber/features/images/DebianInstallerHostnamePrompt.png Binary files differnew file mode 100644 index 00000000..f1325c8d --- /dev/null +++ b/cucumber/features/images/DebianInstallerHostnamePrompt.png diff --git a/cucumber/features/images/DebianInstallerHttpProxy.png b/cucumber/features/images/DebianInstallerHttpProxy.png Binary files differnew file mode 100644 index 00000000..04b3e13e --- /dev/null +++ b/cucumber/features/images/DebianInstallerHttpProxy.png diff --git a/cucumber/features/images/DebianInstallerInstallingBaseSystem.png b/cucumber/features/images/DebianInstallerInstallingBaseSystem.png Binary files differnew file mode 100644 index 00000000..0b9e1c7f --- /dev/null +++ b/cucumber/features/images/DebianInstallerInstallingBaseSystem.png diff --git a/cucumber/features/images/DebianInstallerMirrorCountry.png b/cucumber/features/images/DebianInstallerMirrorCountry.png Binary files differnew file mode 100644 index 00000000..9b4df5ea --- /dev/null +++ b/cucumber/features/images/DebianInstallerMirrorCountry.png diff --git a/cucumber/features/images/DebianInstallerNameOfUser.png b/cucumber/features/images/DebianInstallerNameOfUser.png Binary files differnew file mode 100644 index 00000000..e37c7ec4 --- /dev/null +++ b/cucumber/features/images/DebianInstallerNameOfUser.png diff --git a/cucumber/features/images/DebianInstallerNoDiskFound.png b/cucumber/features/images/DebianInstallerNoDiskFound.png Binary files differnew file mode 100644 index 00000000..671f52d6 --- /dev/null +++ b/cucumber/features/images/DebianInstallerNoDiskFound.png diff --git a/cucumber/features/images/DebianInstallerPartitioningMethod.png b/cucumber/features/images/DebianInstallerPartitioningMethod.png Binary files differnew file mode 100644 index 00000000..9e44360e --- /dev/null +++ b/cucumber/features/images/DebianInstallerPartitioningMethod.png diff --git a/cucumber/features/images/DebianInstallerPartitioningScheme.png b/cucumber/features/images/DebianInstallerPartitioningScheme.png Binary files differnew file mode 100644 index 00000000..97105b62 --- /dev/null +++ b/cucumber/features/images/DebianInstallerPartitioningScheme.png diff --git a/cucumber/features/images/DebianInstallerRootPassword.png b/cucumber/features/images/DebianInstallerRootPassword.png Binary files differnew file mode 100644 index 00000000..27368fd7 --- /dev/null +++ b/cucumber/features/images/DebianInstallerRootPassword.png diff --git a/cucumber/features/images/DebianInstallerSelectDiskToPartition.png b/cucumber/features/images/DebianInstallerSelectDiskToPartition.png Binary files differnew file mode 100644 index 00000000..1f14bb1a --- /dev/null +++ b/cucumber/features/images/DebianInstallerSelectDiskToPartition.png diff --git a/cucumber/features/images/DebianInstallerSelectLangEnglish.png b/cucumber/features/images/DebianInstallerSelectLangEnglish.png Binary files differnew file mode 100644 index 00000000..85f848d5 --- /dev/null +++ b/cucumber/features/images/DebianInstallerSelectLangEnglish.png diff --git a/cucumber/features/images/DebianInstallerSelectLangEnglishUK.png b/cucumber/features/images/DebianInstallerSelectLangEnglishUK.png Binary files differnew file mode 100644 index 00000000..c0da761f --- /dev/null +++ b/cucumber/features/images/DebianInstallerSelectLangEnglishUK.png diff --git a/cucumber/features/images/DebianInstallerUserPassword.png b/cucumber/features/images/DebianInstallerUserPassword.png Binary files differnew file mode 100644 index 00000000..bf9964aa --- /dev/null +++ b/cucumber/features/images/DebianInstallerUserPassword.png diff --git a/cucumber/features/images/DebianLive7BootSplash.png b/cucumber/features/images/DebianLive7BootSplash.png Binary files differnew file mode 100644 index 00000000..b64353aa --- /dev/null +++ b/cucumber/features/images/DebianLive7BootSplash.png diff --git a/cucumber/features/images/DebianLive7BootSplashTabMsg.png b/cucumber/features/images/DebianLive7BootSplashTabMsg.png Binary files differnew file mode 100644 index 00000000..150830b7 --- /dev/null +++ b/cucumber/features/images/DebianLive7BootSplashTabMsg.png diff --git a/cucumber/features/images/DebianLive7Greeter.png b/cucumber/features/images/DebianLive7Greeter.png Binary files differnew file mode 100644 index 00000000..f1afaabe --- /dev/null +++ b/cucumber/features/images/DebianLive7Greeter.png diff --git a/cucumber/features/images/DebianLiveBootSplash.png b/cucumber/features/images/DebianLiveBootSplash.png Binary files differnew file mode 100644 index 00000000..11ee1494 --- /dev/null +++ b/cucumber/features/images/DebianLiveBootSplash.png diff --git a/cucumber/features/images/DebianLiveBootSplashTabMsg.png b/cucumber/features/images/DebianLiveBootSplashTabMsg.png Binary files differnew file mode 100644 index 00000000..cdddaf1d --- /dev/null +++ b/cucumber/features/images/DebianLiveBootSplashTabMsg.png diff --git a/cucumber/features/images/DebianLoginPromptVT.png b/cucumber/features/images/DebianLoginPromptVT.png Binary files differnew file mode 100644 index 00000000..ec267820 --- /dev/null +++ b/cucumber/features/images/DebianLoginPromptVT.png diff --git a/cucumber/features/images/d-i8_bootsplash.png b/cucumber/features/images/d-i8_bootsplash.png Binary files differnew file mode 100644 index 00000000..086c65cb --- /dev/null +++ b/cucumber/features/images/d-i8_bootsplash.png diff --git a/cucumber/features/images/d-i_ArchiveMirror.png b/cucumber/features/images/d-i_ArchiveMirror.png Binary files differnew file mode 100644 index 00000000..7e53f189 --- /dev/null +++ b/cucumber/features/images/d-i_ArchiveMirror.png diff --git a/cucumber/features/images/d-i_ChooseSoftware.png b/cucumber/features/images/d-i_ChooseSoftware.png Binary files differnew file mode 100644 index 00000000..93447158 --- /dev/null +++ b/cucumber/features/images/d-i_ChooseSoftware.png diff --git a/cucumber/features/images/d-i_DesktopTask_No.png b/cucumber/features/images/d-i_DesktopTask_No.png Binary files differnew file mode 100644 index 00000000..6dbf9df4 --- /dev/null +++ b/cucumber/features/images/d-i_DesktopTask_No.png diff --git a/cucumber/features/images/d-i_DesktopTask_Yes.png b/cucumber/features/images/d-i_DesktopTask_Yes.png Binary files differnew file mode 100644 index 00000000..02cbaa5d --- /dev/null +++ b/cucumber/features/images/d-i_DesktopTask_Yes.png diff --git a/cucumber/features/images/d-i_F12BootMenu.png b/cucumber/features/images/d-i_F12BootMenu.png Binary files differnew file mode 100644 index 00000000..67a21856 --- /dev/null +++ b/cucumber/features/images/d-i_F12BootMenu.png diff --git a/cucumber/features/images/d-i_FinishPartitioning.png b/cucumber/features/images/d-i_FinishPartitioning.png Binary files differnew file mode 100644 index 00000000..50396500 --- /dev/null +++ b/cucumber/features/images/d-i_FinishPartitioning.png diff --git a/cucumber/features/images/d-i_GRUBEnterDev.png b/cucumber/features/images/d-i_GRUBEnterDev.png Binary files differnew file mode 100644 index 00000000..6df484ed --- /dev/null +++ b/cucumber/features/images/d-i_GRUBEnterDev.png diff --git a/cucumber/features/images/d-i_GRUB_Debian.png b/cucumber/features/images/d-i_GRUB_Debian.png Binary files differnew file mode 100644 index 00000000..3b67cfbe --- /dev/null +++ b/cucumber/features/images/d-i_GRUB_Debian.png diff --git a/cucumber/features/images/d-i_GRUBdev.png b/cucumber/features/images/d-i_GRUBdev.png Binary files differnew file mode 100644 index 00000000..9d554d74 --- /dev/null +++ b/cucumber/features/images/d-i_GRUBdev.png diff --git a/cucumber/features/images/d-i_HttpProxy.png b/cucumber/features/images/d-i_HttpProxy.png Binary files differnew file mode 100644 index 00000000..4163a5b3 --- /dev/null +++ b/cucumber/features/images/d-i_HttpProxy.png diff --git a/cucumber/features/images/d-i_InstallComplete.png b/cucumber/features/images/d-i_InstallComplete.png Binary files differnew file mode 100644 index 00000000..a8564464 --- /dev/null +++ b/cucumber/features/images/d-i_InstallComplete.png diff --git a/cucumber/features/images/d-i_InstallGRUB.png b/cucumber/features/images/d-i_InstallGRUB.png Binary files differnew file mode 100644 index 00000000..e491fbd1 --- /dev/null +++ b/cucumber/features/images/d-i_InstallGRUB.png diff --git a/cucumber/features/images/d-i_No.png b/cucumber/features/images/d-i_No.png Binary files differnew file mode 100644 index 00000000..1108addc --- /dev/null +++ b/cucumber/features/images/d-i_No.png diff --git a/cucumber/features/images/d-i_ScanCD.png b/cucumber/features/images/d-i_ScanCD.png Binary files differnew file mode 100644 index 00000000..5790bcce --- /dev/null +++ b/cucumber/features/images/d-i_ScanCD.png diff --git a/cucumber/features/images/d-i_SelectBootDev.png b/cucumber/features/images/d-i_SelectBootDev.png Binary files differnew file mode 100644 index 00000000..7abef3ec --- /dev/null +++ b/cucumber/features/images/d-i_SelectBootDev.png diff --git a/cucumber/features/images/d-i_UseNetMirror.png b/cucumber/features/images/d-i_UseNetMirror.png Binary files differnew file mode 100644 index 00000000..2b41228b --- /dev/null +++ b/cucumber/features/images/d-i_UseNetMirror.png diff --git a/cucumber/features/images/d-i_Yes.png b/cucumber/features/images/d-i_Yes.png Binary files differnew file mode 100644 index 00000000..17fab5b9 --- /dev/null +++ b/cucumber/features/images/d-i_Yes.png diff --git a/cucumber/features/images/d-i_popcon.png b/cucumber/features/images/d-i_popcon.png Binary files differnew file mode 100644 index 00000000..ed0ba618 --- /dev/null +++ b/cucumber/features/images/d-i_popcon.png diff --git a/cucumber/features/install.feature b/cucumber/features/install.feature new file mode 100644 index 00000000..3b71b6c2 --- /dev/null +++ b/cucumber/features/install.feature @@ -0,0 +1,14 @@ +@product +Feature: Doing a trivial d-i install + As a normal user + I should be able to do a text-mode install + + Scenario Outline: Install Debian and boot to login prompt + Given I have installed <type> Debian + And I start the computer + Then I wait for a Login Prompt + + Examples: + | type | + | Minimal | + | Gnome Desktop | diff --git a/cucumber/features/misc_files/sample.pdf b/cucumber/features/misc_files/sample.pdf Binary files differnew file mode 100644 index 00000000..d0cc9502 --- /dev/null +++ b/cucumber/features/misc_files/sample.pdf diff --git a/cucumber/features/misc_files/sample.tex b/cucumber/features/misc_files/sample.tex new file mode 100644 index 00000000..043faaec --- /dev/null +++ b/cucumber/features/misc_files/sample.tex @@ -0,0 +1,8 @@ +\documentclass[12pt]{article} +\title{Sample PDF document} +\author{Tails developers} +\date{March 12, 2013} +\begin{document} +\maketitle +Does this PDF still have metadata? +\end{document} diff --git a/cucumber/features/scripts/otr-bot.py b/cucumber/features/scripts/otr-bot.py new file mode 100755 index 00000000..0afd15a4 --- /dev/null +++ b/cucumber/features/scripts/otr-bot.py @@ -0,0 +1,206 @@ +#!/usr/bin/python +import sys +import jabberbot +import xmpp +import potr +import logging +from argparse import ArgumentParser + +class OtrContext(potr.context.Context): + + def __init__(self, account, peer): + super(OtrContext, self).__init__(account, peer) + + def getPolicy(self, key): + return True + + def inject(self, msg, appdata = None): + mess = appdata["base_reply"] + mess.setBody(msg) + appdata["send_raw_message_fn"](mess) + + +class BotAccount(potr.context.Account): + + def __init__(self, jid, keyFilePath): + protocol = 'xmpp' + max_message_size = 10*1024 + super(BotAccount, self).__init__(jid, protocol, max_message_size) + self.keyFilePath = keyFilePath + + def loadPrivkey(self): + with open(self.keyFilePath, 'rb') as keyFile: + return potr.crypt.PK.parsePrivateKey(keyFile.read())[0] + + +class OtrContextManager: + + def __init__(self, jid, keyFilePath): + self.account = BotAccount(jid, keyFilePath) + self.contexts = {} + + def start_context(self, other): + if not other in self.contexts: + self.contexts[other] = OtrContext(self.account, other) + return self.contexts[other] + + def get_context_for_user(self, other): + return self.start_context(other) + + +class OtrBot(jabberbot.JabberBot): + + PING_FREQUENCY = 60 + + def __init__(self, account, password, otr_key_path, + connect_server = None, log_file = None): + self.__connect_server = connect_server + self.__password = password + self.__log_file = log_file + super(OtrBot, self).__init__(account, password) + self.__otr_manager = OtrContextManager(account, otr_key_path) + self.send_raw_message_fn = super(OtrBot, self).send_message + self.__default_otr_appdata = { + "send_raw_message_fn": self.send_raw_message_fn + } + + def __otr_appdata_for_mess(self, mess): + appdata = self.__default_otr_appdata.copy() + appdata["base_reply"] = mess + return appdata + + # Unfortunately Jabberbot's connect() is not very friendly to + # overriding in subclasses so we have to re-implement it + # completely (copy-paste mostly) in order to add support for using + # an XMPP "Connect Server". + def connect(self): + logging.basicConfig(filename = self.__log_file, + level = logging.DEBUG) + if not self.conn: + conn = xmpp.Client(self.jid.getDomain(), debug=[]) + if self.__connect_server: + try: + conn_server, conn_port = self.__connect_server.split(":", 1) + except ValueError: + conn_server = self.__connect_server + conn_port = 5222 + conres = conn.connect((conn_server, int(conn_port))) + else: + conres = conn.connect() + if not conres: + return None + authres = conn.auth(self.jid.getNode(), self.__password, self.res) + if not authres: + return None + self.conn = conn + self.conn.sendInitPresence() + self.roster = self.conn.Roster.getRoster() + for (handler, callback) in self.handlers: + self.conn.RegisterHandler(handler, callback) + return self.conn + + # Wrap OTR encryption around Jabberbot's most low-level method for + # sending messages. + def send_message(self, mess): + body = str(mess.getBody()) + user = str(mess.getTo().getStripped()) + otrctx = self.__otr_manager.get_context_for_user(user) + if otrctx.state == potr.context.STATE_ENCRYPTED: + otrctx.sendMessage(potr.context.FRAGMENT_SEND_ALL, body, + appdata = self.__otr_appdata_for_mess(mess)) + else: + self.send_raw_message_fn(mess) + + # Wrap OTR decryption around Jabberbot's callback mechanism. + def callback_message(self, conn, mess): + body = str(mess.getBody()) + user = str(mess.getFrom().getStripped()) + otrctx = self.__otr_manager.get_context_for_user(user) + if mess.getType() == "chat": + try: + appdata = self.__otr_appdata_for_mess(mess.buildReply()) + decrypted_body, tlvs = otrctx.receiveMessage(body, + appdata = appdata) + otrctx.processTLVs(tlvs) + except potr.context.NotEncryptedError: + otrctx.authStartV2(appdata = appdata) + return + except (potr.context.UnencryptedMessage, potr.context.NotOTRMessage): + decrypted_body = body + else: + decrypted_body = body + if decrypted_body == None: + return + if mess.getType() == "groupchat": + bot_prefix = self.jid.getNode() + ": " + if decrypted_body.startswith(bot_prefix): + decrypted_body = decrypted_body[len(bot_prefix):] + else: + return + mess.setBody(decrypted_body) + super(OtrBot, self).callback_message(conn, mess) + + # Override Jabberbot quitting on keep alive failure. + def on_ping_timeout(self): + self.__lastping = None + + @jabberbot.botcmd + def ping(self, mess, args): + """Why not just test it?""" + return "pong" + + @jabberbot.botcmd + def say(self, mess, args): + """Unleash my inner parrot""" + return args + + @jabberbot.botcmd + def clear_say(self, mess, args): + """Make me speak in the clear even if we're in an OTR chat""" + self.send_raw_message_fn(mess.buildReply(args)) + return "" + + @jabberbot.botcmd + def start_otr(self, mess, args): + """Make me *initiate* (but not refresh) an OTR session""" + if mess.getType() == "groupchat": + return + return "?OTRv2?" + + @jabberbot.botcmd + def end_otr(self, mess, args): + """Make me gracefully end the OTR session if there is one""" + if mess.getType() == "groupchat": + return + user = str(mess.getFrom().getStripped()) + self.__otr_manager.get_context_for_user(user).disconnect(appdata = + self.__otr_appdata_for_mess(mess.buildReply())) + return "" + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument("account", + help = "the user account, given as user@domain") + parser.add_argument("password", + help = "the user account's password") + parser.add_argument("otr_key_path", + help = "the path to the account's OTR key file") + parser.add_argument("-c", "--connect-server", metavar = 'ADDRESS', + help = "use a Connect Server, given as host[:port] " + + "(port defaults to 5222)") + parser.add_argument("-j", "--auto-join", nargs = '+', metavar = 'ROOMS', + help = "auto-join multi-user chatrooms on start") + parser.add_argument("-l", "--log-file", metavar = 'LOGFILE', + help = "Log to file instead of stderr") + args = parser.parse_args() + otr_bot_opt_args = dict() + if args.connect_server: + otr_bot_opt_args["connect_server"] = args.connect_server + if args.log_file: + otr_bot_opt_args["log_file"] = args.log_file + otr_bot = OtrBot(args.account, args.password, args.otr_key_path, + **otr_bot_opt_args) + if args.auto_join: + for room in args.auto_join: + otr_bot.join_room(room) + otr_bot.serve_forever() diff --git a/cucumber/features/scripts/vm-execute b/cucumber/features/scripts/vm-execute new file mode 100755 index 00000000..fc1bf459 --- /dev/null +++ b/cucumber/features/scripts/vm-execute @@ -0,0 +1,52 @@ +#!/usr/bin/env ruby + +require 'optparse' +begin + require "#{`git rev-parse --show-toplevel`.chomp}/features/support/helpers/exec_helper.rb" +rescue LoadError => e + raise "This script must be run from within Tails' Git directory." +end +$config = Hash.new + +def debug_log(*args) ; end + +class FakeVM + def get_remote_shell_port + 1337 + end +end + +cmd_opts = { + :spawn => false, + :user => "root" +} + +opt_parser = OptionParser.new do |opts| + opts.banner = "Usage: features/scripts/vm-execute [opts] COMMAND" + opts.separator "" + opts.separator "Runs commands in the VM guest being tested. This script " \ + "must be run from within Tails' Git directory." + opts.separator "" + opts.separator "Options:" + + opts.on("-h", "--help", "Show this message") do + puts opts + exit + end + + opts.on("-u", "--user USER", "Run command as USER") do |user| + cmd_opts[:user] = user + end + + opts.on("-s", "--spawn", + "Run command in non-blocking mode") do |type| + cmd_opts[:spawn] = true + end +end +opt_parser.parse!(ARGV) +cmd = ARGV.join(" ") +c = VMCommand.new(FakeVM.new, cmd, cmd_opts) +puts "Return status: #{c.returncode}" +puts "STDOUT:\n#{c.stdout}" +puts "STDERR:\n#{c.stderr}" +exit c.returncode diff --git a/cucumber/features/step_definitions/apt.rb b/cucumber/features/step_definitions/apt.rb new file mode 100644 index 00000000..c69d2598 --- /dev/null +++ b/cucumber/features/step_definitions/apt.rb @@ -0,0 +1,56 @@ +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| + 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 + +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) + 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) + 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) +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) + 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) +end diff --git a/cucumber/features/step_definitions/browser.rb b/cucumber/features/step_definitions/browser.rb new file mode 100644 index 00000000..84ef1d35 --- /dev/null +++ b/cucumber/features/step_definitions/browser.rb @@ -0,0 +1,195 @@ +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) +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" +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" +end + +When /^I close the (?:Unsafe|I2P) 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'] + case application + when "Tor Browser" + user = LIVE_USER + cmd_regex = "#{binary} .* -profile /home/#{user}/\.tor-browser/profile\.default" + chroot = "" + new_tab_button_image = "TorBrowserNewTabButton.png" + when "Unsafe Browser" + user = "clearnet" + 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. + binary = "#{binary}-unconfined" + tor_launcher_install = $vm.execute_successfully( + 'echo ${TOR_LAUNCHER_INSTALL}', :libs => 'tor-browser' + ).stdout.chomp + cmd_regex = "#{binary}\s+-app #{tor_launcher_install}/application\.ini.*" + chroot = "" + new_tab_button_image = nil + address_bar_image = nil + # The standalone Tor Launcher uses fewer libs than the full + # browser. + unused_tbb_libs.concat(["libfreebl3.so", "libnssckbi.so", "libsoftokn3.so"]) + else + raise "Invalid browser or XUL application: #{application}" + end + return { + :user => user, + :cmd_regex => cmd_regex, + :chroot => chroot, + :new_tab_button_image => new_tab_button_image, + :address_bar_image => address_bar_image, + :unused_tbb_libs => unused_tbb_libs, + } +end + +When /^I open a new tab in the (.*)$/ do |browser| + info = xul_application_info(browser) + @screen.click(info[:new_tab_button_image]) + @screen.wait(info[:address_bar_image], 10) +end + +When /^I open the address "([^"]*)" in the (.*)$/ do |address, browser| + step "I open a new tab in the #{browser}" + info = xul_application_info(browser) + open_address = Proc.new do + @screen.click(info[:address_bar_image]) + # This static here since we have no reliable visual indicators + # that we can watch to know when typing is "safe". + sleep 5 + # The browser sometimes loses keypresses when suggestions are + # shown, which we work around by pasting the address from the + # clipboard, in one go. + $vm.set_clipboard(address) + @screen.type('v', Sikuli::KeyModifier.CTRL) + @screen.type(Sikuli::Key.ENTER) + end + open_address.call + 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 + end +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" +end + +def xul_app_shared_lib_check(pid, chroot, expected_absent_tbb_libs = []) + absent_tbb_libs = [] + unwanted_native_libs = [] + tbb_libs = $vm.execute_successfully("ls -1 #{chroot}${TBB_INSTALL}/*.so", + :libs => 'tor-browser').stdout.split + firefox_pmap_info = $vm.execute("pmap --show-path #{pid}").stdout + for lib in tbb_libs do + lib_name = File.basename lib + if not /\W#{lib}$/.match firefox_pmap_info + absent_tbb_libs << lib_name + end + native_libs = $vm.execute_successfully( + "find /usr/lib /lib -name \"#{lib_name}\"" + ).stdout.split + for native_lib in native_libs do + if /\W#{native_lib}$"/.match firefox_pmap_info + unwanted_native_libs << lib_name + end + end + end + absent_tbb_libs -= expected_absent_tbb_libs + assert(absent_tbb_libs.empty? && unwanted_native_libs.empty?, + "The loaded shared libraries for the firefox process are not the " + + "way we expect them.\n" + + "Expected TBB libs that are absent: #{absent_tbb_libs}\n" + + "Native libs that we don't want: #{unwanted_native_libs}") +end + +Then /^the (.*) uses all expected TBB shared libraries$/ do |application| + info = xul_application_info(application) + pid = $vm.execute_successfully("pgrep --uid #{info[:user]} --full --exact '#{info[:cmd_regex]}'").stdout.chomp + assert(/\A\d+\z/.match(pid), "It seems like #{application} is not running") + xul_app_shared_lib_check(pid, info[:chroot], info[:unused_tbb_libs]) +end + +Then /^the (.*) chroot is torn down$/ do |browser| + info = xul_application_info(browser) + try_for(30, :msg => "The #{browser} chroot '#{info[:chroot]}' was " \ + "not removed") do + !$vm.execute("test -d '#{info[:chroot]}'").success? + end +end + +Then /^the (.*) runs as the expected user$/ do |browser| + info = xul_application_info(browser) + assert_vmcommand_success($vm.execute( + "pgrep --full --exact '#{info[:cmd_regex]}'"), + "The #{browser} is not running") + assert_vmcommand_success($vm.execute( + "pgrep --uid #{info[:user]} --full --exact '#{info[:cmd_regex]}'"), + "The #{browser} is not running as the #{info[:user]} user") +end + +When /^I download some file in the Tor Browser$/ do + @some_file = 'tails-signing.key' + some_url = "https://tails.boum.org/#{@some_file}" + step "I open the address \"#{some_url}\" in the Tor Browser" +end + +Then /^I get the browser download dialog$/ do + @screen.wait('BrowserDownloadDialog.png', 60) + @screen.wait('BrowserDownloadDialogSaveAsButton.png', 10) +end + +When /^I save the file to the default Tor Browser download directory$/ do + @screen.click('BrowserDownloadDialogSaveAsButton.png') + @screen.wait('BrowserDownloadFileToDialog.png', 10) + @screen.type(Sikuli::Key.ENTER) +end + +Then /^the file is saved to the default Tor Browser download directory$/ do + assert_not_nil(@some_file) + expected_path = "/home/#{LIVE_USER}/Tor Browser/#{@some_file}" + try_for(10) { $vm.file_exist?(expected_path) } +end diff --git a/cucumber/features/step_definitions/build.rb b/cucumber/features/step_definitions/build.rb new file mode 100644 index 00000000..fd001ff4 --- /dev/null +++ b/cucumber/features/step_definitions/build.rb @@ -0,0 +1,115 @@ +Given /^Tails ([[:alnum:].]+) has been released$/ do |version| + create_git unless git_exists? + + old_branch = current_branch + + fatal_system "git checkout --quiet stable" + old_entries = File.open('debian/changelog') { |f| f.read } + File.open('debian/changelog', 'w') do |changelog| + changelog.write(<<END_OF_CHANGELOG) +tails (#{version}) stable; urgency=low + + * New upstream release. + + -- Tails developers <tails@boum.org> Tue, 31 Jan 2012 15:12:57 +0100 + +#{old_entries} +END_OF_CHANGELOG + end + fatal_system "git commit --quiet debian/changelog -m 'Release #{version}'" + fatal_system "git tag '#{version}'" + + if old_branch != 'stable' + fatal_system "git checkout --quiet '#{old_branch}'" + fatal_system "git merge --quiet 'stable'" + end +end + +Given /^Tails ([[:alnum:].-]+) has been tagged$/ do |version| + fatal_system "git tag '#{version}'" +end + +Given /^Tails ([[:alnum:].]+) has not been released yet$/ do |version| + !File.exists? ".git/refs/tags/#{version}" +end + +Given /^the last version mentioned in debian\/changelog is ([[:alnum:]~.]+)$/ do |version| + last = `dpkg-parsechangelog | awk '/^Version: / { print $2 }'`.strip + raise StandardError.new('dpkg-parsechangelog failed.') if $? != 0 + + if last != version + fatal_system "debchange -v '#{version}' 'New upstream release'" + end +end + +Given %r{I am working on the ([[:alnum:]./_-]+) base branch$} do |branch| + create_git unless git_exists? + + if current_branch != branch + fatal_system "git checkout --quiet '#{branch}'" + end + + File.open('config/base_branch', 'w+') do |base_branch_file| + base_branch_file.write("#{branch}\n") + end +end + +Given %r{I am working on the ([[:alnum:]./_-]+) branch based on ([[:alnum:]./_-]+)$} do |branch, base| + create_git unless git_exists? + + if current_branch != branch + fatal_system "git checkout --quiet -b '#{branch}' '#{base}'" + end + + File.open('config/base_branch', 'w+') do |base_branch_file| + base_branch_file.write("#{base}\n") + end +end + +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| + @output = `#{File.expand_path("../../../auto/scripts/#{command}", __FILE__)}` + @exit_code = $?.exitstatus +end + +Then /^I should see the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite| + @output.should have_suite(suite) +end + +Then /^I should see only the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite| + assert_equal(1, @output.lines.count) + @output.should have_suite(suite) +end + +Then /^I should not see the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite| + @output.should_not have_suite(suite) +end + +Given(/^the config\/APT_overlays\.d directory is empty$/) do + Dir.glob('config/APT_overlays.d/*').empty? \ + or raise "config/APT_overlays.d/ is not empty" +end + +Given(/^config\/APT_overlays\.d contains ['"]?([[:alnum:].-]+)['"]?$/) do |suite| + FileUtils.touch("config/APT_overlays.d/#{suite}") +end + +Then(/^it should fail$/) do + assert_not_equal(0, @exit_code) +end + +Given(/^the (config\/base_branch) file does not exist$/) do |file| + File.delete(file) +end + +Given(/^the (config\/APT_overlays\.d) directory does not exist$/) do |dir| + Dir.rmdir(dir) +end + +Given(/^the config\/base_branch file is empty$/) do + File.truncate('config/base_branch', 0) +end diff --git a/cucumber/features/step_definitions/checks.rb b/cucumber/features/step_definitions/checks.rb new file mode 100644 index 00000000..423b8390 --- /dev/null +++ b/cucumber/features/step_definitions/checks.rb @@ -0,0 +1,252 @@ +def shipped_openpgp_keys + shipped_gpg_keys = $vm.execute_successfully('gpg --batch --with-colons --fingerprint --list-key', :user => LIVE_USER).stdout + openpgp_fingerprints = shipped_gpg_keys.scan(/^fpr:::::::::([A-Z0-9]+):$/).flatten + return openpgp_fingerprints +end + +Then /^the OpenPGP keys shipped with Tails will be valid for the next (\d+) months$/ do |months| + invalid = Array.new + shipped_openpgp_keys.each do |key| + begin + step "the shipped OpenPGP key #{key} will be valid for the next #{months} months" + rescue Test::Unit::AssertionFailedError + invalid << key + next + end + end + assert(invalid.empty?, "The following key(s) will not be valid in #{months} months: #{invalid.join(', ')}") +end + +Then /^the shipped (?:Debian repository key|OpenPGP key ([A-Z0-9]+)) will be valid for the next (\d+) months$/ do |fingerprint, max_months| + if fingerprint + cmd = 'gpg' + user = LIVE_USER + else + fingerprint = TAILS_DEBIAN_REPO_KEY + cmd = 'apt-key adv' + user = 'root' + end + shipped_sig_key_info = $vm.execute_successfully("#{cmd} --batch --list-key #{fingerprint}", :user => user).stdout + m = /\[expire[ds]: ([0-9-]*)\]/.match(shipped_sig_key_info) + if m + expiration_date = Date.parse(m[1]) + assert((expiration_date << max_months.to_i) > DateTime.now, + "The shipped key #{fingerprint} will not be valid #{max_months} months from now.") + 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") + actual_username = $vm.execute(". /etc/live/config/username.conf; " + + "echo $LIVE_USERNAME").stdout.chomp + assert_equal(LIVE_USER, actual_username) +end + +Then /^the live user is a member of only its own group and "(.*?)"$/ do |groups| + expected_groups = groups.split(" ") << LIVE_USER + actual_groups = $vm.execute("groups #{LIVE_USER}").stdout.chomp.sub(/^#{LIVE_USER} : /, "").split(" ") + unexpected = actual_groups - expected_groups + missing = expected_groups - actual_groups + assert_equal(0, unexpected.size, + "live user in unexpected groups #{unexpected}") + assert_equal(0, missing.size, + "live user not in expected groups #{missing}") +end + +Then /^the live user owns its home dir and it has normal permissions$/ do + home = "/home/#{LIVE_USER}" + assert($vm.execute("test -d #{home}").success?, + "The live user's home doesn't exist or is not a directory") + owner = $vm.execute("stat -c %U:%G #{home}").stdout.chomp + perms = $vm.execute("stat -c %a #{home}").stdout.chomp + assert_equal("#{LIVE_USER}:#{LIVE_USER}", owner) + assert_equal("700", perms) +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 + 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] + # 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 + SERVICES_EXPECTED_ON_ALL_IFACES.include? [proc, laddr, "*"] + puts "Service '#{proc}' is listening on #{laddr}:#{lport} " + + "but has an exception" + else + raise "Unexpected service '#{proc}' listening on #{laddr}:#{lport}" + end + end + end +end + +When /^Tails has booted a 64-bit kernel$/ do + assert($vm.execute("uname -r | grep -qs 'amd64$'").success?, + "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}'", + :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}'", + :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}'") + end +end + +Then /^AppArmor is enabled$/ do + assert($vm.execute("aa-status").success?, "AppArmor is not enabled") +end + +Then /^some AppArmor profiles are enforced$/ do + assert($vm.execute("aa-status --enforced").stdout.chomp.to_i > 0, + "No AppArmor profile is enforced") +end + +def get_seccomp_status(process) + assert($vm.has_process?(process), "Process #{process} not running.") + pid = $vm.pidof(process)[0] + status = $vm.file_content("/proc/#{pid}/status") + return status.match(/^Seccomp:\s+([0-9])/)[1].chomp.to_i +end + +def get_apparmor_status(pid) + apparmor_status = $vm.file_content("/proc/#{pid}/attr/current").chomp + if apparmor_status.include?(')') + # matches something like /usr/sbin/cupsd (enforce) + # and only returns what's in the parentheses + return apparmor_status.match(/[^\s]+\s+\((.+)\)$/)[1].chomp + else + return apparmor_status + end +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_equal(mode, get_apparmor_status(pid)) +end + +Then /^the running process "(.+)" is confined with Seccomp in (filter|strict) mode$/ do |process,mode| + status = get_seccomp_status(process) + if mode == 'strict' + assert_equal(1, status, "#{process} not confined with Seccomp in strict mode") + elsif mode == 'filter' + assert_equal(2, status, "#{process} not confined with Seccomp in filter mode") + else + raise "Unsupported mode #{mode} passed" + end +end + +Then /^tails-debugging-info is not susceptible to symlink attacks$/ do + secret_file = '/secret' + secret_contents = 'T0P S3Cr1t -- 3yEs oN1y' + $vm.file_append(secret_file, secret_contents) + $vm.execute_successfully("chmod u=rw,go= #{secret_file}") + $vm.execute_successfully("chown root:root #{secret_file}") + script_path = '/usr/local/sbin/tails-debugging-info' + script_lines = $vm.file_content(script_path).split("\n") + script_lines.grep(/^debug_file\s+/).each do |line| + _, user, debug_file = line.split + # root can always mount symlink attacks + next if user == 'root' + # Remove quoting around the file + debug_file.gsub!(/["']/, '') + # Skip files that do not exist, or cannot be removed (e.g. the + # ones in /proc). + next if not($vm.execute("rm #{debug_file}").success?) + # Check what would happen *if* the amnesia user managed to replace + # the debugging file with a symlink to the secret. + $vm.execute_successfully("ln -s #{secret_file} #{debug_file}") + $vm.execute_successfully("chown --no-dereference #{LIVE_USER}:#{LIVE_USER} #{debug_file}") + if $vm.execute("sudo /usr/local/sbin/tails-debugging-info | " + + "grep '#{secret_contents}'", + :user => LIVE_USER).success? + raise "The secret was leaked by tails-debugging-info via '#{debug_file}'" + end + # Remove the secret so it cannot possibly interfere with the + # following iterations (even though it should not). + $vm.execute_successfully("echo > #{debug_file}") + end +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 +end + +Then /^the Tor Status icon tells me that Tor is( not)? usable$/ do |not_usable| + picture = not_usable ? 'TorStatusNotUsable' : 'TorStatusUsable' + @screen.find("#{picture}.png") +end diff --git a/cucumber/features/step_definitions/common_steps.rb b/cucumber/features/step_definitions/common_steps.rb new file mode 100644 index 00000000..bd03cebb --- /dev/null +++ b/cucumber/features/step_definitions/common_steps.rb @@ -0,0 +1,1086 @@ +require 'fileutils' + +def post_vm_start_hook + # Sometimes the first click is lost (presumably it's used to give + # focus to virt-viewer or similar) so we do that now rather than + # having an important click lost. The point we click should be + # somewhere where no clickable elements generally reside. + @screen.click_point(@screen.w, @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) + b = @screen.wait(bottom, 10) + # In Sikuli, lower x == closer to the left, lower y == closer to the top + assert(t.y < b.y) + center = Sikuli::Location.new(((t.x + t.w) + b.x)/2, + ((t.y + t.h) + b.y)/2) + @screen.right_click(center) + @screen.hide_cursor + @screen.wait_and_click(menu_item, 10) + return + 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 + $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. + # Time jumps and incorrect clocks also confuses Tor in many ways. + #if $vm.has_network? + # if $vm.execute("systemctl --quiet is-active tor@default.service").success? + # $vm.execute("systemctl stop tor@default.service") + # $vm.execute("rm -f /var/log/tor/log") + # $vm.execute("systemctl --no-block restart tails-tor-has-bootstrapped.target") + # $vm.host_to_guest_time_sync + # $vm.spawn("restart-tor") + # 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 + #end +end + +Given /^a computer$/ do + $vm.destroy_and_undefine if $vm + $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 + +Given /^the computer is set to boot from the Tails DVD$/ do + $vm.set_cdrom_boot(TAILS_ISO) +end + +Given /^the computer is set to boot from (.+?) drive "(.+?)"$/ do |type, name| + $vm.set_disk_boot(name, type.downcase) +end + +Given /^I (temporarily )?create a (\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 +end + +Given /^I plug (.+) drive "([^"]+)"$/ do |bus, name| + $vm.plug_drive(name, bus.downcase) + if $vm.is_running? + step "drive \"#{name}\" is detected by Tails" + end +end + +Then /^drive "([^"]+)" is detected by Tails$/ do |name| + raise "Tails is not running" unless $vm.is_running? + try_for(10, :msg => "Drive '#{name}' is not detected by Tails") do + $vm.disk_detected?(name) + end +end + +Given /^the network is plugged$/ do + $vm.plug_network +end + +Given /^the network is unplugged$/ do + $vm.unplug_network +end + +Given /^the hardware clock is set to "([^"]*)"$/ do |time| + $vm.set_hardware_clock(DateTime.parse(time).to_time) +end + +Given /^I capture all network traffic$/ do + @sniffer = Sniffer.new("sniffer", $vmnet) + @sniffer.capture + add_after_scenario_hook do + @sniffer.stop + @sniffer.clear + end +end + +Given /^I set Tails to boot with options "([^"]*)"$/ do |options| + @boot_options = options +end + +When /^I start the computer$/ do + assert(!$vm.is_running?, + "Trying to start a VM that is already running") + $vm.start + post_vm_start_hook +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 + step "the network is unplugged" + 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" + step "all notifications have disappeared" + step "available upgrades have been checked" + else + step "all notifications have disappeared" + 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| + step "the computer is set to boot from #{drive_type} drive \"#{drive_name}\"" + if network_unplugged.empty? + step "the network is plugged" + else + step "the network is unplugged" + 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 log in to a new session" + step "Tails seems to have booted normally" + if network_unplugged.empty? + step "Tor is ready" + step "all notifications have disappeared" + step "available upgrades have been checked" + else + step "all notifications have disappeared" + end + end +end + +When /^I power off the computer$/ do + assert($vm.is_running?, + "Trying to power off an already powered off VM") + $vm.power_off +end + +When /^I cold reboot the computer$/ do + step "I power off the computer" + step "I start the computer" +end + +When /^I destroy the computer$/ do + $vm.destroy_and_undefine +end + +Given /^the computer (re)?boots DebianInstaller(|\d+)$/ do |reboot,version| + + boot_timeout = 30 + # We need some extra time for memory wiping if rebooting + + @screen.wait("d-i8_bootsplash.png", boot_timeout) + @screen.type(Sikuli::Key.TAB) + + @screen.type(' preseed/early_command="echo ttyS0::askfirst:-/bin/sh>>/etc/inittab;kill -HUP 1"' + " blacklist=psmouse #{@boot_options}" + + Sikuli::Key.ENTER) + $vm.wait_until_remote_shell_is_up +end + +Given /^I select British English$/ do + @screen.wait("DebianInstallerSelectLangEnglish.png", 30) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("DebianInstallerCountrySelection.png", 10) + @screen.type(Sikuli::Key.UP) + @screen.waitVanish("DebianInstallerCountrySelection.png", 10) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("DebianInstallerSelectLangEnglishUK.png", 10) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I accept the hostname, using "([^"]*)" as the domain$/ do |domain| + @screen.wait("DebianInstallerHostnamePrompt.png", 5*60) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("DebianInstallerDomainPrompt.png", 10) + @screen.type(domain + Sikuli::Key.ENTER) + @screen.waitVanish("DebianInstallerDomainPrompt.png", 10) +end + +Given /^I set the root password to "([^"]*)"$/ do |rootpw| +# Root Password, twice + @screen.wait("DebianInstallerRootPassword.png", 30) + @screen.type(rootpw + Sikuli::Key.ENTER) + @screen.waitVanish("DebianInstallerRootPassword.png", 10) + @screen.type(rootpw + Sikuli::Key.ENTER) +end + +Given /^I set the password for "([^"]*)" to be "([^"]*)"$/ do |fullname,password| +# Username, and password twice + @screen.wait("DebianInstallerNameOfUser.png", 10) + @screen.type(fullname + Sikuli::Key.ENTER) + @screen.waitVanish("DebianInstallerNameOfUser.png", 10) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("DebianInstallerUserPassword.png", 10) + @screen.type(password + Sikuli::Key.ENTER) + @screen.waitVanish("DebianInstallerUserPassword.png", 10) + @screen.type(password + Sikuli::Key.ENTER) +end + + #@screen.wait("DebianInstallerNoDiskFound.png", 60) + +Given /^I select full-disk, single-filesystem partitioning$/ do + @screen.wait("DebianInstallerPartitioningMethod.png", 60) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("DebianInstallerSelectDiskToPartition.png", 10) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("DebianInstallerPartitioningScheme.png", 10) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("d-i_FinishPartitioning.png", 10) + sleep(5) # FIXME -- why do we need this? It's weird that the wait is not enough + @screen.type(Sikuli::Key.ENTER) + # prompt about Writing Partitions to disk: + @screen.wait("d-i_No.png", 10) + @screen.type(Sikuli::Key.TAB) + @screen.wait("d-i_Yes.png", 10) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I note that the Base system is being installed$/ do + @screen.wait("DebianInstallerInstallingBaseSystem.png", 30) + @screen.waitVanish("DebianInstallerInstallingBaseSystem.png", 15 * 60) +end + +Given /^I accept the default mirror$/ do + @screen.wait("DebianInstallerMirrorCountry.png", 10 * 60) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("d-i_ArchiveMirror.png", 5) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("d-i_HttpProxy.png", 5) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I neglect to scan more CDs$/ do + @screen.wait("d-i_ScanCD.png", 15 * 60) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("d-i_UseNetMirror.png", 10) + @screen.wait("d-i_Yes.png", 10) + @screen.type(Sikuli::Key.TAB) + @screen.wait("d-i_No.png", 10) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I ignore Popcon$/ do + #@screen.wait("d-i_popcon.png", 10 * 60) + @screen.wait("d-i_No.png", 10 * 60) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^we reach the Tasksel prompt$/ do + @screen.wait("d-i_ChooseSoftware.png", 5 * 60) +end + +Given /^I hit ENTER$/ do + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I select the Desktop task$/ do + @screen.wait("d-i_ChooseSoftware.png", 10) + @screen.type(Sikuli::Key.SPACE) + @screen.type(Sikuli::Key.DOWN) + @screen.type(Sikuli::Key.SPACE) + @screen.wait("d-i_DesktopTask_Yes.png", 10) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I install GRUB$/ do + @screen.wait("d-i_InstallGRUB.png", 80 * 60) + #@screen.wait("Install the GRUB", 80 * 60) + @screen.type(Sikuli::Key.ENTER) + @screen.wait("d-i_GRUBEnterDev.png", 10 * 60) + @screen.type(Sikuli::Key.DOWN) + @screen.wait("d-i_GRUBdev.png", 10) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I allow reboot after the install is complete$/ do + @screen.wait("d-i_InstallComplete.png", 2 * 60) + @screen.type(Sikuli::Key.ENTER) +end + +Given /^I wait for the reboot$/ do + @screen.wait(bootsplash, 10 * 60) +end + +Given /^I make sure that we boot from disk$/ do + @screen.wait("d-i_GRUB_Debian.png", 5 * 60) +end + +Given /^I wait for a Login Prompt$/ do + @screen.wait("DebianLoginPromptVT.png", 2 * 60) +end + +def bootsplash + case @os_loader + when "UEFI" + 'TailsBootSplashUEFI.png' + else + 'd-i8_bootsplash.png' + end +end + +def bootsplash_tab_msg + case @os_loader + when "UEFI" + 'TailsBootSplashTabMsgUEFI.png' + else + #if reboot + # bootsplash = 'TailsBootSplashPostReset.png' + # bootsplash_tab_msg = 'TailsBootSplashTabMsgPostReset.png' + # boot_timeout = 120 + #else + #bootsplash = "DebianLive#{version}BootSplash.png" + bootsplash = "DebianLiveBootSplash.png" + bootsplash_tab_msg = "DebianLiveBootSplashTabMsg.png" + boot_timeout = 30 + #end + end +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) + + @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 +end + +Given /^I log in to a new session(?: in )?(|German)$/ do |lang| + case lang + when 'German' + @language = "German" + @screen.wait_and_click('TailsGreeterLanguage.png', 10) + @screen.wait_and_click("TailsGreeterLanguage#{@language}.png", 10) + @screen.wait_and_click("TailsGreeterLoginButton#{@language}.png", 10) + when '' + @screen.wait_and_click('TailsGreeterLoginButton.png', 10) + else + raise "Unsupported language: #{lang}" + end +end + +Given /^I set sudo password "([^"]*)"$/ do |password| + @sudo_password = password + next if @skip_steps_while_restoring_background + #@screen.wait("TailsGreeterAdminPassword.png", 20) + @screen.type(@sudo_password) + @screen.type(Sikuli::Key.TAB) + @screen.type(@sudo_password) +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? + } +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) + # 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. + $vm.execute_successfully( + 'gsettings set org.gnome.desktop.session idle-delay 0', + :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) +end + +Given /^Tor is ready$/ do + step "Tor has built a circuit" + step "the time has synced" + if $vm.execute('systemctl is-system-running').failure? + units_status = $vm.execute('systemctl').stdout + raise "At least one system service failed to start:\n#{units_status}" + end +end + +Given /^Tor has built a circuit$/ do + wait_until_tor_is_working +end + +Given /^the time has synced$/ do + ["/var/run/tordate/done", "/var/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? + } +end + +Given /^the Tor Browser has started$/ do + tor_browser_picture = "TorBrowserWindow.png" + @screen.wait(tor_browser_picture, 60) +end + +Given /^the Tor Browser (?:has started and )?load(?:ed|s) the (startup page|Tails roadmap)$/ do |page| + case page + when "startup page" + picture = "TorBrowserStartupPage.png" + when "Tails roadmap" + picture = "TorBrowserTailsRoadmap.png" + else + raise "Unsupported page: #{page}" + end + step "the Tor Browser has started" + @screen.wait(picture, 120) +end + +Given /^the Tor Browser has started in offline mode$/ do + @screen.wait("TorBrowserOffline.png", 60) +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) + @screen.type("d", Sikuli::KeyModifier.CTRL) + @screen.wait("TorBrowserBookmarkPrompt.png", 10) + @screen.type(url + Sikuli::Key.ENTER) +end + +Given /^the Tor Browser has a bookmark to eff.org$/ do + @screen.type("b", Sikuli::KeyModifier.ALT) + @screen.wait("TorBrowserEFFBookmark.png", 10) +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) + end + rescue FindFailed + # No notifications, so we're good to go. + end + @screen.hide_cursor + # Click anywhere to close the notification applet + @screen.click("GnomeApplicationsMenu.png") + @screen.hide_cursor +end + +Then /^I (do not )?see "([^"]*)" after at most (\d+) seconds$/ do |negation, image, time| + begin + @screen.wait(image, time.to_i) + raise "found '#{image}' while expecting not to" if negation + rescue FindFailed => e + raise e if not(negation) + end +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 +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) + @screen.wait(image, 60) + @screen.type(password) + @screen.type(Sikuli::Key.ENTER) + @screen.waitVanish(image, 10) +end + +Given /^I enter the "([^"]*)" password in the pkexec prompt$/ do |password| + deal_with_polkit_prompt('PolicyKitAuthPrompt.png', password) +end + +Given /^process "([^"]+)" is (not )?running$/ do |process, not_running| + if not_running + assert(!$vm.has_process?(process), "Process '#{process}' is running") + else + assert($vm.has_process?(process), "Process '#{process}' is not running") + end +end + +Given /^process "([^"]+)" is running within (\d+) seconds$/ do |process, time| + try_for(time.to_i, :msg => "Process '#{process}' is not running after " + + "waiting for #{time} seconds") do + $vm.has_process?(process) + end +end + +Given /^process "([^"]+)" has stopped running after at most (\d+) seconds$/ do |process, time| + try_for(time.to_i, :msg => "Process '#{process}' is still running after " + + "waiting for #{time} seconds") do + not $vm.has_process?(process) + end +end + +Given /^I kill the process "([^"]+)"$/ do |process| + $vm.execute("killall #{process}") + try_for(10, :msg => "Process '#{process}' could not be killed") { + !$vm.has_process?(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? + 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' +end + +When /^I request a shutdown using the emergency shutdown applet$/ do + @screen.hide_cursor + @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10) + @screen.wait_and_click('TailsEmergencyShutdownHalt.png', 10) +end + +When /^I warm reboot the computer$/ do + $vm.spawn("reboot") +end + +When /^I request a reboot using the emergency shutdown applet$/ do + @screen.hide_cursor + @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10) + @screen.wait_and_click('TailsEmergencyShutdownReboot.png', 10) +end + +Given /^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 + +[connection] +id=#{con_name} +uuid=bbc60668-1be0-11e4-a9c6-2f1ce0e75bf1 +type=802-3-ethernet +timestamp=1395406011 + +[ipv6] +method=auto + +[ipv4] +method=auto +EOF + con_content.split("\n").each do |line| + $vm.execute("echo '#{line}' >> /tmp/NM.#{con_name}") + 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}" + } +end + +Given /^I switch to the "([^"]+)" NetworkManager connection$/ do |con_name| + $vm.execute("nmcli connection up id #{con_name}") + try_for(60) do + $vm.execute("nmcli --terse --fields NAME,STATE connection show").stdout.chomp.split("\n").include?("#{con_name}:activated") + end +end + +When /^I start and focus GNOME Terminal$/ do + step 'I start "Terminal" via the GNOME "Utilities" applications menu' + @screen.wait('GnomeTerminalWindow.png', 20) +end + +When /^I run "([^"]+)" in GNOME Terminal$/ do |command| + if !$vm.has_process?("gnome-terminal-server") + step "I start and focus GNOME Terminal" + else + @screen.wait_and_click('GnomeTerminalWindow.png', 20) + end + @screen.type(command + Sikuli::Key.ENTER) +end + +When /^the file "([^"]+)" exists(?:| after at most (\d+) seconds)$/ do |file, timeout| + timeout = 0 if timeout.nil? + try_for( + timeout.to_i, + :msg => "The file #{file} does not exist after #{timeout} seconds" + ) { + $vm.file_exist?(file) + } +end + +When /^the file "([^"]+)" does not exist$/ do |file| + assert(! ($vm.file_exist?(file))) +end + +When /^the directory "([^"]+)" exists$/ do |directory| + assert($vm.directory_exist?(directory)) +end + +When /^the directory "([^"]+)" does not exist$/ do |directory| + assert(! ($vm.directory_exist?(directory))) +end + +When /^I copy "([^"]+)" to "([^"]+)" as user "([^"]+)"$/ do |source, destination, user| + c = $vm.execute("cp \"#{source}\" \"#{destination}\"", :user => LIVE_USER) + assert(c.success?, "Failed to copy file:\n#{c.stdout}\n#{c.stderr}") +end + +def is_persistent?(app) + conf = get_persistence_presets(true)["#{app}"] + c = $vm.execute("findmnt --noheadings --output SOURCE --target '#{conf}'") + # This check assumes that we haven't enabled read-only persistence. + c.success? and c.stdout.chomp != "aufs" +end + +Then /^persistence for "([^"]+)" is (|not )enabled$/ do |app, enabled| + case enabled + when '' + assert(is_persistent?(app), "Persistence should be enabled.") + when 'not ' + assert(!is_persistent?(app), "Persistence should not be 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 +end + +When /^I type "([^"]+)"$/ do |string| + @screen.type(string) +end + +When /^I press the "([^"]+)" key$/ do |key| + begin + @screen.type(eval("Sikuli::Key.#{key}")) + rescue RuntimeError + raise "unsupported key #{key}" + end +end + +Then /^the (amnesiac|persistent) Tor Browser directory (exists|does not exist)$/ do |persistent_or_not, mode| + case persistent_or_not + when "amnesiac" + dir = "/home/#{LIVE_USER}/Tor Browser" + when "persistent" + dir = "/home/#{LIVE_USER}/Persistent/Tor Browser" + end + step "the directory \"#{dir}\" #{mode}" +end + +Then /^there is a GNOME bookmark for the (amnesiac|persistent) Tor Browser directory$/ do |persistent_or_not| + case persistent_or_not + when "amnesiac" + bookmark_image = 'TorBrowserAmnesicFilesBookmark.png' + when "persistent" + bookmark_image = 'TorBrowserPersistentFilesBookmark.png' + end + @screen.wait_and_click('GnomePlaces.png', 10) + @screen.wait(bookmark_image, 40) + @screen.type(Sikuli::Key.ESC) +end + +Then /^there is no GNOME bookmark for the persistent Tor Browser directory$/ do + try_for(65) do + @screen.wait_and_click('GnomePlaces.png', 10) + @screen.wait("GnomePlacesWithoutTorBrowserPersistent.png", 10) + @screen.type(Sikuli::Key.ESC) + end +end + +def pulseaudio_sink_inputs + pa_info = $vm.execute_successfully('pacmd info', :user => LIVE_USER).stdout + sink_inputs_line = pa_info.match(/^\d+ sink input\(s\) available\.$/)[0] + return sink_inputs_line.match(/^\d+/)[0].to_i +end + +When /^(no|\d+) application(?:s?) (?:is|are) playing audio(?:| after (\d+) seconds)$/ do |nb, wait_time| + nb = 0 if nb == "no" + sleep wait_time.to_i if ! wait_time.nil? + 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) +end + +When /^I click the blocked video icon$/ do + @screen.wait_and_click("TorBrowserBlockedVideo.png", 30) +end + +When /^I accept to temporarily allow playing this video$/ do + @screen.wait_and_click("TorBrowserOkButton.png", 10) +end + +When /^I click the HTML5 play button$/ do + @screen.wait_and_click("TorBrowserHtml5PlayButton.png", 30) +end + +When /^I (can|cannot) save the current page as "([^"]+[.]html)" to the (.*) directory$/ do |should_work, output_file, output_dir| + should_work = should_work == 'can' ? true : false + @screen.type("s", Sikuli::KeyModifier.CTRL) + @screen.wait("TorBrowserSaveDialog.png", 10) + if output_dir == "persistent Tor Browser" + output_dir = "/home/#{LIVE_USER}/Persistent/Tor Browser" + @screen.wait_and_click("GtkTorBrowserPersistentBookmark.png", 10) + @screen.wait("GtkTorBrowserPersistentBookmarkSelected.png", 10) + # The output filename (without its extension) is already selected, + # let's use the keyboard shortcut to focus its field + @screen.type("n", Sikuli::KeyModifier.ALT) + @screen.wait("TorBrowserSaveOutputFileSelected.png", 10) + elsif output_dir == "default downloads" + output_dir = "/home/#{LIVE_USER}/Tor Browser" + else + @screen.type(output_dir + '/') + end + # Only the part of the filename before the .html extension can be easily replaced + # so we have to remove it before typing it into the arget filename entry widget. + @screen.type(output_file.sub(/[.]html$/, '')) + @screen.type(Sikuli::Key.ENTER) + if should_work + try_for(10, :msg => "The page was not saved to #{output_dir}/#{output_file}") { + $vm.file_exist?("#{output_dir}/#{output_file}") + } + else + @screen.wait("TorBrowserCannotSavePage.png", 10) + end +end + +When /^I can print the current page as "([^"]+[.]pdf)" to the (default downloads|persistent Tor Browser) directory$/ do |output_file, output_dir| + if output_dir == "persistent Tor Browser" + output_dir = "/home/#{LIVE_USER}/Persistent/Tor Browser" + else + output_dir = "/home/#{LIVE_USER}/Tor Browser" + end + @screen.type("p", Sikuli::KeyModifier.CTRL) + @screen.wait("TorBrowserPrintDialog.png", 20) + @screen.wait_and_click("BrowserPrintToFile.png", 10) + @screen.wait_and_double_click("TorBrowserPrintOutputFile.png", 10) + @screen.hide_cursor + @screen.wait("TorBrowserPrintOutputFileSelected.png", 10) + # Only the file's basename is selected by double-clicking, + # so we type only the desired file's basename to replace it + @screen.type(output_dir + '/' + output_file.sub(/[.]pdf$/, '') + Sikuli::Key.ENTER) + try_for(30, :msg => "The page was not printed to #{output_dir}/#{output_file}") { + $vm.file_exist?("#{output_dir}/#{output_file}") + } +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_hello_msg = "Welcome to the LAN web server!" + + # I've tested ruby Thread:s, fork(), etc. but nothing works due to + # various strange limitations in the ruby interpreter. For instance, + # apparently concurrent IO has serious limits in the thread + # scheduler (e.g. sikuli's wait() would block WEBrick from reading + # from its socket), and fork():ing results in a lot of complex + # cucumber stuff (like our hooks!) ending up in the child process, + # breaking stuff in the parent process. After asking some supposed + # ruby pros, I've settled on the following. + code = <<-EOF + 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}, + :DocumentRoot => "/dev/null") + server.mount_proc("/") do |req, res| + res.body = "#{web_server_hello_msg}" + end + server.start +EOF + 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 + end + + add_after_scenario_hook { Process.kill("TERM", proc.pid) } + + # It seems necessary to actually check that the LAN server is + # serving, possibly because it isn't doing so reliably when setting + # up. If e.g. the Unsafe Browser (which *should* be able to access + # the web server) tries to access it too early, Firefox seems to + # take some random amount of time to retry fetching. Curl gives a + # more consistent result, so let's rely on that instead. Note that + # this forces us to capture traffic *after* this step in case + # accessing this server matters, like when testing the Tor Browser.. + try_for(30, :msg => "Something is wrong with the LAN web server") do + msg = $vm.execute_successfully("curl #{@web_server_url}", + :user => LIVE_USER).stdout.chomp + web_server_hello_msg == msg + end +end + +When /^I open a page on the LAN web server in the (.*)$/ do |browser| + step "I open the address \"#{@web_server_url}\" in the #{browser}" +end + +Given /^I wait (?:between (\d+) and )?(\d+) seconds$/ do |min, max| + if min + time = rand(max.to_i - min.to_i + 1) + min.to_i + else + time = max.to_i + end + puts "Slept for #{time} seconds" + sleep(time) +end + +Given /^I (?:re)?start monitoring the AppArmor log of "([^"]+)"$/ do |profile| + # AppArmor log entries may be dropped if printk rate limiting is + # enabled. + $vm.execute_successfully('sysctl -w kernel.printk_ratelimit=0') + # We will only care about entries for this profile from this time + # and on. + guest_time = $vm.execute_successfully( + 'date +"%Y-%m-%d %H:%M:%S"').stdout.chomp + @apparmor_profile_monitoring_start ||= Hash.new + @apparmor_profile_monitoring_start[profile] = guest_time +end + +When /^AppArmor has (not )?denied "([^"]+)" from opening "([^"]+)"(?: after at most (\d+) seconds)?$/ do |anti_test, profile, file, time| + assert(@apparmor_profile_monitoring_start && + @apparmor_profile_monitoring_start[profile], + "It seems the profile '#{profile}' isn't being monitored by the " + + "'I monitor the AppArmor log of ...' step") + audit_line_regex = 'apparmor="DENIED" operation="open" profile="%s" name="%s"' % [profile, file] + block = Proc.new do + audit_log = $vm.execute( + "journalctl --full --no-pager " + + "--since='#{@apparmor_profile_monitoring_start[profile]}' " + + "SYSLOG_IDENTIFIER=kernel | grep -w '#{audit_line_regex}'" + ).stdout.chomp + assert(audit_log.empty? == (anti_test ? true : false)) + true + end + begin + if time + try_for(time.to_i) { block.call } + else + block.call + end + rescue Timeout::Error, Test::Unit::AssertionFailedError => e + raise e, "AppArmor has #{anti_test ? "" : "not "}denied the operation" + end +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') +end + +When /^I eject the boot medium$/ do + dev = boot_device + dev_type = device_info(dev)['ID_TYPE'] + case dev_type + when 'cd' + $vm.remove_cdrom + when 'disk' + boot_disk_name = $vm.disk_name(dev) + $vm.unplug_drive(boot_disk_name) + else + raise "Unsupported medium type '#{dev_type}' for boot device '#{dev}'" + end +end diff --git a/cucumber/features/step_definitions/dhcp.rb b/cucumber/features/step_definitions/dhcp.rb new file mode 100644 index 00000000..ef4d9e15 --- /dev/null +++ b/cucumber/features/step_definitions/dhcp.rb @@ -0,0 +1,19 @@ +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" + end + end +end diff --git a/cucumber/features/step_definitions/electrum.rb b/cucumber/features/step_definitions/electrum.rb new file mode 100644 index 00000000..447983d4 --- /dev/null +++ b/cucumber/features/step_definitions/electrum.rb @@ -0,0 +1,52 @@ +Then /^I start Electrum through the GNOME menu$/ do + step "I start \"Electrum\" via the GNOME \"Internet\" applications menu" +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" + when "not " + step "the file \"#{wallet}\" does not exist" + else + raise "Unknown value specified for #{existing}" + end +end + +When /^I create a new bitcoin wallet$/ do + @screen.wait("ElectrumNoWallet.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.type(seed) # Confirm seed + @screen.wait_and_click("ElectrumNextButton.png", 10) + @screen.wait_and_click("ElectrumEncryptWallet.png", 10) + @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 + +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) +end + +Then /^I see the main Electrum client window$/ do + @screen.wait('ElectrumPreferencesButton.png', 20) +end + +Then /^Electrum successfully connects to the network$/ do + @screen.wait('ElectrumStatus.png', 180) +end diff --git a/cucumber/features/step_definitions/encryption.rb b/cucumber/features/step_definitions/encryption.rb new file mode 100644 index 00000000..9f7f1b96 --- /dev/null +++ b/cucumber/features/step_definitions/encryption.rb @@ -0,0 +1,133 @@ +def seahorse_menu_click_helper(main, sub, verify = nil) + try_for(60) do + step "process \"#{verify}\" is running" if verify + @screen.hide_cursor + @screen.wait_and_click(main, 10) + @screen.wait_and_click(sub, 10) + return + end +end + +Given /^I generate an OpenPGP key named "([^"]+)" with password "([^"]+)"$/ do |name, pwd| + @passphrase = pwd + @key_name = name + gpg_key_recipie = <<EOF + Key-Type: RSA + Key-Length: 4096 + Subkey-Type: RSA + Subkey-Length: 4096 + Name-Real: #{@key_name} + Name-Comment: Blah + Name-Email: #{@key_name}@test.org + Expire-Date: 0 + 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", + :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' + @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 + # keystrokes to go missing. + sleep 5 + @screen.type("ATTACK AT DAWN") +end + +def maybe_deal_with_pinentry + begin + @screen.wait_and_click("PinEntryPrompt.png", 10) + # Without this sleep here (and reliable visual indicators) we can sometimes + # miss keystrokes by typing too soon. This sleep prevents this problem from + # coming up. + sleep 5 + @screen.type(@passphrase + Sikuli::Key.ENTER) + rescue FindFailed + # The passphrase was cached or we wasn't prompted at all (e.g. when + # only encrypting to a public key) + end +end + +def gedit_copy_all_text + context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditSelectAll.png') + context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditCopy.png') +end + +def paste_into_a_new_tab + @screen.wait_and_click("GeditNewTab.png", 20) + context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditPaste.png') +end + +def encrypt_sign_helper + gedit_copy_all_text + seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletSignEncrypt.png') + @screen.wait_and_click("GpgAppletChooseKeyWindow.png", 30) + # We don't have a good visual indicator for when we can continue without + # keystrokes being lost. + sleep 5 + yield + maybe_deal_with_pinentry + paste_into_a_new_tab +end + +def decrypt_verify_helper(icon) + gedit_copy_all_text + seahorse_menu_click_helper(icon, 'GpgAppletDecryptVerify.png') + maybe_deal_with_pinentry + @screen.wait("GpgAppletResults.png", 20) + @screen.wait("GpgAppletResultsMsg.png", 20) +end + +When /^I encrypt the message using my OpenPGP key$/ do + encrypt_sign_helper do + @screen.type(@key_name + Sikuli::Key.ENTER + Sikuli::Key.ENTER) + end +end + +Then /^I can decrypt the encrypted message$/ do + decrypt_verify_helper("GpgAppletIconEncrypted.png") + @screen.wait("GpgAppletResultsEncrypted.png", 20) +end + +When /^I sign the message using my OpenPGP key$/ do + encrypt_sign_helper do + @screen.type(Sikuli::Key.TAB + Sikuli::Key.DOWN + Sikuli::Key.ENTER) + end +end + +Then /^I can verify the message's signature$/ do + decrypt_verify_helper("GpgAppletIconSigned.png") + @screen.wait("GpgAppletResultsSigned.png", 20) +end + +When /^I both encrypt and sign the message using my OpenPGP key$/ do + encrypt_sign_helper do + @screen.wait_and_click('GpgAppletEncryptionKey.png', 20) + @screen.type(Sikuli::Key.SPACE) + @screen.wait('GpgAppletKeySelected.png', 10) + @screen.type(Sikuli::Key.TAB + Sikuli::Key.DOWN + Sikuli::Key.ENTER) + @screen.type(Sikuli::Key.ENTER) + end +end + +Then /^I can decrypt and verify the encrypted message$/ do + decrypt_verify_helper("GpgAppletIconEncrypted.png") + @screen.wait("GpgAppletResultsEncrypted.png", 20) + @screen.wait("GpgAppletResultsSigned.png", 20) +end + +When /^I symmetrically encrypt the message with password "([^"]+)"$/ do |pwd| + @passphrase = pwd + gedit_copy_all_text + 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 +end diff --git a/cucumber/features/step_definitions/evince.rb b/cucumber/features/step_definitions/evince.rb new file mode 100644 index 00000000..9411ac4d --- /dev/null +++ b/cucumber/features/step_definitions/evince.rb @@ -0,0 +1,25 @@ +When /^I(?:| try to) open "([^"]+)" with Evince$/ do |filename| + step "I run \"evince #{filename}\" in GNOME Terminal" +end + +Then /^I can print the current document to "([^"]+)"$/ do |output_file| + @screen.type("p", Sikuli::KeyModifier.CTRL) + @screen.wait("EvincePrintDialog.png", 10) + @screen.wait_and_click("EvincePrintToFile.png", 10) + @screen.wait_and_click("EvincePrintOutputFileButton.png", 10) + @screen.wait("EvincePrintFileDialog.png", 10) + # Only the file's basename is selected by double-clicking, + # so we type only the desired file's basename to replace it + $vm.set_clipboard(output_file.sub(/[.]pdf$/, '')) + @screen.type('v', Sikuli::KeyModifier.CTRL) + @screen.type(Sikuli::Key.ENTER) + @screen.wait_and_click("EvincePrintButton.png", 10) + try_for(10, :msg => "The document was not printed to #{output_file}") { + $vm.file_exist?(output_file) + } +end + +When /^I close Evince$/ do + @screen.type("w", Sikuli::KeyModifier.CTRL) + step 'process "evince" has stopped running after at most 20 seconds' +end diff --git a/cucumber/features/step_definitions/firewall_leaks.rb b/cucumber/features/step_definitions/firewall_leaks.rb new file mode 100644 index 00000000..942d00b8 --- /dev/null +++ b/cucumber/features/step_definitions/firewall_leaks.rb @@ -0,0 +1,56 @@ +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}'" + end +end + +Given(/^I disable Tails' firewall$/) do + $vm.execute("/usr/local/lib/do_not_ever_run_me") + iptables = $vm.execute("iptables -L -n -v").stdout.chomp.split("\n") + for line in iptables do + if !line[/Chain (INPUT|OUTPUT|FORWARD) \(policy ACCEPT/] and + !line[/pkts[[:blank:]]+bytes[[:blank:]]+target/] and + !line.empty? + raise "The Tails firewall was not successfully disabled:\n#{iptables}" + end + end +end + +When(/^I do a TCP DNS lookup of "(.*?)"$/) do |host| + lookup = $vm.execute("host -T #{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) + assert(lookup.success?, "Failed to resolve #{host}:\n#{lookup.stdout}") +end + +When(/^I send some ICMP pings$/) do + # We ping an IP address to avoid a DNS lookup + ping = $vm.execute("ping -c 5 #{SOME_DNS_SERVER}") + assert(ping.success?, "Failed to ping #{SOME_DNS_SERVER}:\n#{ping.stderr}") +end diff --git a/cucumber/features/step_definitions/git.rb b/cucumber/features/step_definitions/git.rb new file mode 100644 index 00000000..bf6f869d --- /dev/null +++ b/cucumber/features/step_definitions/git.rb @@ -0,0 +1,6 @@ +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")) + $vm.execute_successfully("cd '/home/#{LIVE_USER}/#{repo}/' && git status", + :user => LIVE_USER) +end diff --git a/cucumber/features/step_definitions/icedove.rb b/cucumber/features/step_definitions/icedove.rb new file mode 100644 index 00000000..d3672895 --- /dev/null +++ b/cucumber/features/step_definitions/icedove.rb @@ -0,0 +1,94 @@ +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 new file mode 100644 index 00000000..a4aa8714 --- /dev/null +++ b/cucumber/features/step_definitions/mac_spoofing.rb @@ -0,0 +1,108 @@ +def all_ethernet_nics + $vm.execute_successfully( + "get_all_ethernet_nics", :libs => 'hardware' + ).stdout.split +end + +When /^I disable MAC spoofing in Tails Greeter$/ do + @screen.wait_and_click("TailsGreeterMACSpoofing.png", 30) +end + +Then /^the network device has (its default|a spoofed) MAC address configured$/ do |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 + 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" + end + 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}" + end + 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") +end + +Given /^no network interface modules can be unloaded$/ do + # Note that the real /sbin/modprobe is a symlink to /bin/kmod, and + # for it to run in modprobe compatibility mode the name must be + # exactly "modprobe", so we just move it somewhere our of the path + # instead of renaming it ".real" or whatever we usuablly do when + # diverting executables for wrappers. + modprobe_divert = "/usr/local/lib/modprobe" + $vm.execute_successfully( + "dpkg-divert --add --rename --divert '#{modprobe_divert}' /sbin/modprobe" + ) + fake_modprobe_wrapper = <<EOF +#!/bin/sh +if echo "${@}" | grep -q -- -r; then + exit 1 +fi +exec '#{modprobe_divert}' "${@}" +EOF + $vm.file_append('/sbin/modprobe', fake_modprobe_wrapper) + $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 + nr_nics = all_ethernet_nics.size + assert_equal(expected_nr_nics, nr_nics) +end + +Then /^the MAC spoofing panic mode disabled networking$/ do + nm_state = $vm.execute_successfully('systemctl show NetworkManager').stdout + nm_is_disabled = $vm.pidof('NetworkManager').empty? && + nm_state[/^LoadState=masked$/] && + nm_state[/^ActiveState=inactive$/] + assert(nm_is_disabled, "NetworkManager was not disabled") + all_ethernet_nics.each do |nic| + ["nic_ipv4_addr", "nic_ipv6_addr"].each do |function| + addr = $vm.execute_successfully( + "#{function} #{nic}", :libs => 'hardware' + ).stdout.chomp + assert_equal("", addr, "NIC #{nic} was assigned address #{addr}") + end + end +end diff --git a/cucumber/features/step_definitions/pidgin.rb b/cucumber/features/step_definitions/pidgin.rb new file mode 100644 index 00000000..3f5ed931 --- /dev/null +++ b/cucumber/features/step_definitions/pidgin.rb @@ -0,0 +1,467 @@ +# Extracts the secrets for the XMMP account `account_name`. +def xmpp_account(account_name, required_options = []) + begin + account = $config["Pidgin"]["Accounts"]["XMPP"][account_name] + check_keys = ["username", "domain", "password"] + required_options + for key in check_keys do + assert(account.has_key?(key)) + assert_not_nil(account[key]) + assert(!account[key].empty?) + end + rescue NoMethodError, Test::Unit::AssertionFailedError + raise( +<<EOF +Your Pidgin:Accounts:XMPP:#{account} is incorrect or missing from your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format. +EOF +) + end + return account +end + +def wait_and_focus(img, time = 10, window) + begin + @screen.wait(img, time) + rescue FindFailed + $vm.focus_window(window) + @screen.wait(img, time) + end +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 + end +end + +When /^I create my XMPP account$/ do + account = xmpp_account("Tails_account") + @screen.click("PidginAccountManagerAddButton.png") + @screen.wait("PidginAddAccountWindow.png", 20) + @screen.click_mid_right_edge("PidginAddAccountProtocolLabel.png") + @screen.click("PidginAddAccountProtocolXMPP.png") + # We first wait for some field that is shown for XMPP but not the + # default (IRC) since we otherwise may decide where we click before + # the GUI has updated after switching protocol. + @screen.wait("PidginAddAccountXMPPDomain.png", 5) + @screen.click_mid_right_edge("PidginAddAccountXMPPUsername.png") + @screen.type(account["username"]) + @screen.click_mid_right_edge("PidginAddAccountXMPPDomain.png") + @screen.type(account["domain"]) + @screen.click_mid_right_edge("PidginAddAccountXMPPPassword.png") + @screen.type(account["password"]) + @screen.click("PidginAddAccountXMPPRememberPassword.png") + if account["connect_server"] + @screen.click("PidginAddAccountXMPPAdvancedTab.png") + @screen.click_mid_right_edge("PidginAddAccountXMPPConnectServer.png") + @screen.type(account["connect_server"]) + end + @screen.click("PidginAddAccountXMPPAddButton.png") +end + +Then /^Pidgin automatically enables my XMPP account$/ do + $vm.focus_window('Buddy List') + @screen.wait("PidginAvailableStatus.png", 60*3) +end + +Given /^my XMPP friend goes online( and joins the multi-user chat)?$/ do |join_chat| + account = xmpp_account("Friend_account", ["otr_key"]) + bot_opts = account.select { |k, v| ["connect_server"].include?(k) } + if join_chat + bot_opts["auto_join"] = [@chat_room_jid] + end + @friend_name = account["username"] + @chatbot = ChatBot.new(account["username"] + "@" + account["domain"], + account["password"], account["otr_key"], bot_opts) + @chatbot.start + add_after_scenario_hook { @chatbot.stop } + $vm.focus_window('Buddy List') + @screen.wait("PidginFriendOnline.png", 60) +end + +When /^I start a conversation with my friend$/ do + $vm.focus_window('Buddy List') + # Clicking the middle, bottom of this image should query our + # friend, given it's the only subscribed user that's online, which + # we assume. + r = @screen.find("PidginFriendOnline.png") + bottom_left = r.getBottomLeft() + x = bottom_left.getX + r.getW/2 + y = bottom_left.getY + @screen.doubleClick_point(x, y) + # Since Pidgin sets the window name to the contact, we have no good + # way to identify the conversation window. Let's just look for the + # expected menu bar. + @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 + if multi_chat + $vm.focus_window(@chat_room_jid.split("@").first) + msg = @friend_name + ": " + msg + else + $vm.focus_window(@friend_name) + end + @screen.type(msg) +end + +Then /^I receive a response from my friend( in the multi-user chat)?$/ do |multi_chat| + if multi_chat + $vm.focus_window(@chat_room_jid.split("@").first) + else + $vm.focus_window(@friend_name) + end + @screen.wait("PidginFriendExpectedAnswer.png", 20) +end + +When /^I start an OTR session with my friend$/ do + $vm.focus_window(@friend_name) + @screen.click("PidginConversationOTRMenu.png") + @screen.hide_cursor + @screen.click("PidginOTRMenuStartSession.png") +end + +Then /^Pidgin automatically generates an OTR key$/ do + @screen.wait("PidginOTRKeyGenPrompt.png", 30) + @screen.wait_and_click("PidginOTRKeyGenPromptDoneButton.png", 30) +end + +Then /^an OTR session was successfully started with my friend$/ do + $vm.focus_window(@friend_name) + @screen.wait("PidginConversationOTRUnverifiedSessionStarted.png", 10) +end + +# The reason the chat must be empty is to guarantee that we don't mix +# up messages/events from other users with the ones we expect from the +# bot. +When /^I join some empty multi-user chat$/ do + $vm.focus_window('Buddy List') + @screen.click("PidginBuddiesMenu.png") + @screen.wait_and_click("PidginBuddiesMenuJoinChat.png", 10) + @screen.wait_and_click("PidginJoinChatWindow.png", 10) + @screen.click_mid_right_edge("PidginJoinChatRoomLabel.png") + account = xmpp_account("Tails_account") + if account.has_key?("chat_room") && \ + !account["chat_room"].nil? && \ + !account["chat_room"].empty? + chat_room = account["chat_room"] + else + chat_room = random_alnum_string(10, 15) + end + @screen.type(chat_room) + + # We will need the conference server later, when starting the bot. + @screen.click_mid_right_edge("PidginJoinChatServerLabel.png") + @screen.type("a", Sikuli::KeyModifier.CTRL) + @screen.type("c", Sikuli::KeyModifier.CTRL) + conference_server = + $vm.execute_successfully("xclip -o", :user => LIVE_USER).stdout.chomp + @chat_room_jid = chat_room + "@" + conference_server + + @screen.click("PidginJoinChatButton.png") + # The following will both make sure that the we joined the chat, and + # that it is empty. We'll also deal with the *potential* "Create New + # Room" prompt that Pidgin shows for some server configurations. + images = ["PidginCreateNewRoomPrompt.png", + "PidginChat1UserInRoom.png"] + image_found, _ = @screen.waitAny(images, 30) + if image_found == "PidginCreateNewRoomPrompt.png" + @screen.click("PidginCreateNewRoomAcceptDefaultsButton.png") + end + $vm.focus_window(@chat_room_jid) + @screen.wait("PidginChat1UserInRoom.png", 10) +end + +# Since some servers save the scrollback, and sends it when joining, +# it's safer to clear it so we do not get false positives from old +# messages when looking for a particular response, or similar. +When /^I clear the multi-user chat's scrollback$/ do + $vm.focus_window(@chat_room_jid) + @screen.click("PidginConversationMenu.png") + @screen.wait_and_click("PidginConversationMenuClearScrollback.png", 10) +end + +Then /^I can see that my friend joined the multi-user chat$/ do + $vm.focus_window(@chat_room_jid) + @screen.wait("PidginChat2UsersInRoom.png", 60) +end + +def configured_pidgin_accounts + accounts = Hash.new + xml = REXML::Document.new($vm.file_content('$HOME/.purple/accounts.xml', + LIVE_USER)) + 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 + accounts[network] = { + 'name' => account_name, + 'network' => network, + 'protocol' => protocol, + 'port' => port, + 'nickname' => nickname, + 'real_name' => real_name, + } + end + + return accounts +end + +def chan_image (account, channel, image) + images = { + 'irc.oftc.net' => { + '#tails' => { + 'roster' => 'PidginTailsChannelEntry', + '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', + } + return chans[account] +end + +def pidgin_otr_keys + return $vm.file_content('$HOME/.purple/otr.private_key', LIVE_USER) +end + +Given /^Pidgin has the expected accounts configured with random nicknames$/ do + expected = [ + ["irc.oftc.net", "prpl-irc", "6697"], + ["127.0.0.1", "prpl-irc", "6668"], + ] + configured_pidgin_accounts.values.each() do |account| + assert(account['nickname'] != "XXX_NICK_XXX", "Nickname was no randomised") + assert_equal(account['nickname'], account['real_name'], + "Nickname and real name are not identical: " + + account['nickname'] + " vs. " + account['real_name']) + assert_equal(account['name'], account['nickname'], + "Account name and nickname are not identical: " + + account['name'] + " vs. " + account['nickname']) + candidate = [account['network'], account['protocol'], account['port']] + assert(expected.include?(candidate), "Unexpected account: #{candidate}") + expected.delete(candidate) + end + assert(expected.empty?, "These Pidgin accounts are not configured: " + + "#{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) + step "I see Pidgin's account manager window" +end + +When /^I see Pidgin's account manager window$/ do + @screen.wait("PidginAccountWindow.png", 40) +end + +When /^I close Pidgin's account manager window$/ do + @screen.wait_and_click("PidginAccountManagerCloseButton.png", 10) +end + +When /^I (de)?activate the "([^"]+)" Pidgin account$/ do |deactivate, account| + @screen.click("PidginAccount_#{account}.png") + @screen.type(Sikuli::Key.LEFT + Sikuli::Key.SPACE) + if deactivate + @screen.waitVanish('PidginAccountEnabledCheckbox.png', 5) + else + # wait for the Pidgin to be connecting, otherwise sometimes the step + # that closes the account management dialog happens before the account + # is actually enabled + @screen.waitAny(['PidginConnecting.png', 'PidginAvailableStatus.png'], 5) + end +end + +def deactivate_and_activate_pidgin_account(account) + debug_log("Deactivating and reactivating Pidgin account #{account}") + step "I open Pidgin's account manager window" + step "I deactivate the \"#{account}\" Pidgin account" + step "I close Pidgin's account manager window" + step "I open Pidgin's account manager window" + step "I activate the \"#{account}\" Pidgin account" + step "I close Pidgin's account manager window" +end + + + +Then /^Pidgin successfully connects to the "([^"]+)" account$/ do |account| + expected_channel_entry = chan_image(account, default_chan(account), 'roster') + reconnect_button = 'PidginReconnect.png' + recovery_on_failure = Proc.new do + if @screen.exists('PidginReconnect.png') + @screen.click('PidginReconnect.png') + else + 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 + begin + $vm.focus_window('Buddy List') + rescue ExecutionFailedInVM + # Sometimes focusing the window with xdotool will fail with the + # conversation window right on top of it. We'll try to close the + # conversation window. At worst, the test will still fail... + close_pidgin_conversation_window(account) + end + on_screen, _ = @screen.waitAny([expected_channel_entry, reconnect_button], 60) + unless on_screen == expected_channel_entry + raise "Connecting to account #{account} failed." + end + end +end + +Then /^the "([^"]*)" account only responds to PING and VERSION CTCP requests$/ do |irc_server| + ctcp_cmds = [ + "CLIENTINFO", "DATE", "ERRMSG", "FINGER", "PING", "SOURCE", "TIME", + "USERINFO", "VERSION" + ] + expected_ctcp_replies = { + "PING" => /^\d+$/, + "VERSION" => /^Purple IRC$/ + } + spam_target = configured_pidgin_accounts[irc_server]["nickname"] + ctcp_check = CtcpChecker.new(irc_server, 6667, spam_target, ctcp_cmds, + expected_ctcp_replies) + ctcp_check.verify_ctcp_responses +end + +Then /^I can join the "([^"]+)" channel on "([^"]+)"$/ do |channel, account| + @screen.doubleClick( chan_image(account, channel, 'roster')) + @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) + rescue FindFailed => e + # If the channel tab can't be found it could be because there were + # multiple connection attempts and the channel tab we want is off the + # screen. We'll try closing tabs until the one we want can be found. + @screen.type("w", Sikuli::KeyModifier.CTRL) + raise e + end + end + @screen.hide_cursor + @screen.wait( chan_image(account, channel, 'welcome'), 10) +end + +Then /^I take note of the configured Pidgin accounts$/ do + @persistent_pidgin_accounts = configured_pidgin_accounts +end + +Then /^I take note of the OTR key for Pidgin's "([^"]+)" account$/ do |account_name| + @persistent_pidgin_otr_keys = pidgin_otr_keys +end + +Then /^Pidgin has the expected persistent accounts configured$/ do + current_accounts = configured_pidgin_accounts + assert(current_accounts <=> @persistent_pidgin_accounts, + "Currently configured Pidgin accounts do not match the persistent ones:\n" + + "Current:\n#{current_accounts}\n" + + "Persistent:\n#{@persistent_pidgin_accounts}" + ) +end + +Then /^Pidgin has the expected persistent OTR keys$/ do + assert_equal(pidgin_otr_keys, @persistent_pidgin_otr_keys) +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\"" + + $vm.focus_window('Buddy List') + @screen.wait_and_click('PidginToolsMenu.png', 10) + @screen.wait_and_click('PidginCertificatesMenuItem.png', 10) + @screen.wait('PidginCertificateManagerDialog.png', 10) + @screen.wait_and_click('PidginCertificateAddButton.png', 10) + begin + @screen.wait_and_click('GtkFileChooserDesktopButton.png', 10) + rescue FindFailed + # The first time we're run, the file chooser opens in the Recent + # view, so we have to browse a directory before we can use the + # "Type file name" button. But on subsequent runs, the file + # chooser is already in the Desktop directory, so we don't need to + # do anything. Hence, this noop exception handler. + end + @screen.wait_and_click('GtkFileTypeFileNameButton.png', 10) + @screen.type("l", Sikuli::KeyModifier.ALT) # "Location" field + @screen.type(cert_file + Sikuli::Key.ENTER) +end + +Then /^I can add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir| + pidgin_add_certificate_from("#{cert_dir}/test.crt") + wait_and_focus('PidginCertificateAddHostnameDialog.png', 10, 'Certificate Import') + @screen.type("XXX test XXX" + Sikuli::Key.ENTER) + wait_and_focus('PidginCertificateTestItem.png', 10, 'Certificate Manager') +end + +Then /^I cannot add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir| + pidgin_add_certificate_from("#{cert_dir}/test.crt") + wait_and_focus('PidginCertificateImportFailed.png', 10, 'Import Error') +end + +When /^I close Pidgin's certificate manager$/ do + wait_and_focus('PidginCertificateManagerDialog.png', 10, 'Certificate Manager') + @screen.type(Sikuli::Key.ESC) + # @screen.wait_and_click('PidginCertificateManagerClose.png', 10) + @screen.waitVanish('PidginCertificateManagerDialog.png', 10) +end + +When /^I close Pidgin's certificate import failure dialog$/ do + @screen.type(Sikuli::Key.ESC) + # @screen.wait_and_click('PidginCertificateManagerClose.png', 10) + @screen.waitVanish('PidginCertificateImportFailed.png', 10) +end + +When /^I see the Tails roadmap URL$/ do + try_for(60) do + begin + @screen.find('PidginTailsRoadmapUrl.png') + rescue FindFailed => e + @screen.type(Sikuli::Key.PAGE_UP) + raise e + end + end +end + +When /^I click on the Tails roadmap URL$/ do + @screen.click('PidginTailsRoadmapUrl.png') +end diff --git a/cucumber/features/step_definitions/po.rb b/cucumber/features/step_definitions/po.rb new file mode 100644 index 00000000..c73bacef --- /dev/null +++ b/cucumber/features/step_definitions/po.rb @@ -0,0 +1,8 @@ +Given /^I am in the Git branch being tested$/ do + Dir.chdir(GIT_DIR) +end + +Then /^all the PO files should be correct$/ do + File.exists?('./submodules/jenkins-tools/slaves/check_po') + cmd_helper(['./submodules/jenkins-tools/slaves/check_po']) +end diff --git a/cucumber/features/step_definitions/root_access_control.rb b/cucumber/features/step_definitions/root_access_control.rb new file mode 100644 index 00000000..ff1bdfcc --- /dev/null +++ b/cucumber/features/step_definitions/root_access_control.rb @@ -0,0 +1,42 @@ +Then /^I should be able to run administration commands as the live user$/ do + stdout = $vm.execute("echo #{@sudo_password} | sudo -S whoami", + :user => LIVE_USER).stdout + actual_user = stdout.sub(/^\[sudo\] password for #{LIVE_USER}: /, "").chomp + assert_equal("root", actual_user, "Could not use sudo") +end + +Then /^I should not be able to run administration commands as the live user with the "([^"]*)" password$/ do |password| + stderr = $vm.execute("echo #{password} | sudo -S whoami", + :user => LIVE_USER).stderr + sudo_failed = stderr.include?("The administration password is disabled") || stderr.include?("is not allowed to execute") + assert(sudo_failed, "The administration password is not disabled:" + stderr) +end + +When /^running a command as root with pkexec requires PolicyKit administrator privileges$/ do + action = 'org.freedesktop.policykit.exec' + action_details = $vm.execute("pkaction --verbose --action-id #{action}").stdout + assert(action_details[/\s+implicit any:\s+auth_admin$/], + "Expected 'auth_admin' for 'any':\n#{action_details}") + assert(action_details[/\s+implicit inactive:\s+auth_admin$/], + "Expected 'auth_admin' for 'inactive':\n#{action_details}") + assert(action_details[/\s+implicit active:\s+auth_admin$/], + "Expected 'auth_admin' for 'active':\n#{action_details}") +end + +Then /^I should be able to run a command as root with pkexec$/ do + step "I run \"pkexec touch /root/pkexec-test\" in GNOME Terminal" + step 'I enter the sudo password in the pkexec prompt' + try_for(10, :msg => 'The /root/pkexec-test file was not created.') { + $vm.execute('ls /root/pkexec-test').success? + } +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) + end + @screen.type(Sikuli::Key.ESC) + @screen.wait('PolicyKitAuthCompleteFailure.png', 20) +end diff --git a/cucumber/features/step_definitions/snapshots.rb b/cucumber/features/step_definitions/snapshots.rb new file mode 100644 index 00000000..13e4a5b6 --- /dev/null +++ b/cucumber/features/step_definitions/snapshots.rb @@ -0,0 +1,257 @@ +def checkpoints + { + 'boot-d-i-to-tasksel' => { + :description => "I have started Debian Installer and stopped at the Tasksel prompt", + #:parent_checkpoint => 'no-network-logged-in', + :steps => [ + 'I create a 8 GiB disk named "target"', + 'I plug ide drive "target"', + 'I start the computer', + 'the computer boots DebianInstaller', + 'I select British English', + 'I accept the hostname, using "example.com" as the domain', + 'I set the root password to "rootme"', + 'I set the password for "Philip Hands" to be "verysecret"', + 'I select full-disk, single-filesystem partitioning', + 'I note that the Base system is being installed', + 'I accept the default mirror', + 'I ignore Popcon', + 'we reach the Tasksel prompt', + ], + }, + + 'debian-minimal-install' => { + :description => "I have installed Minimal Debian", + :parent_checkpoint => 'boot-d-i-to-tasksel', + :steps => [ + 'I hit ENTER', + 'I install GRUB', + '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 "target"', + ], + }, + + 'debian-gnome-install' => { + :description => "I have installed Gnome Desktop Debian", + :parent_checkpoint => 'boot-d-i-to-tasksel', + :steps => [ + 'I select the Desktop task', + 'I install GRUB', + '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 "target"', + ], + }, + + 'tails-greeter' => { + :description => "I have started Tails from DVD without network and stopped at Tails Greeter's login screen", + :parent_checkpoint => nil, + :steps => [ + 'the network is unplugged', + 'I start the computer', + 'the computer boots Tails' + ], + }, + + 'no-network-logged-in' => { + :description => "I have started Tails from DVD without network and logged in", + :parent_checkpoint => "tails-greeter", + :steps => [ + 'I log in to a new session', + 'Tails Greeter has dealt with the sudo password', + 'the Tails desktop is ready', + ], + }, + + 'with-no-network-and-i2p' => { + :temporary => true, + :description => 'I have started Tails from DVD with I2P enabled and logged in', + :steps => [ + 'I set Tails to boot with options "i2p"', + 'the network is unplugged', + 'I start the computer', + 'the computer boots Tails', + 'I log in to a new session', + 'the Tails desktop is ready', + ], + }, + + 'with-network-and-i2p' => { + :temporary => true, + :description => 'I have started Tails from DVD with I2P enabled and logged in and the network is connected', + :parent_checkpoint => "with-no-network-and-i2p", + :steps => [ + 'the network is plugged', + 'Tor is ready', + 'I2P is running', + 'all notifications have disappeared', + 'available upgrades have been checked', + "I2P's reseeding completed", + ], + }, + + 'with-network-logged-in' => { + :description => "I have started Tails from DVD and logged in and the network is connected", + :parent_checkpoint => "no-network-logged-in", + :steps => [ + 'the network is plugged', + 'Tor is ready', + 'all notifications have disappeared', + 'available upgrades have been checked', + ], + }, + + 'no-network-bridge-mode' => { + :temporary => true, + :description => "I have started Tails from DVD without network and logged in with bridge mode enabled", + :parent_checkpoint => "tails-greeter", + :steps => [ + 'I enable more Tails Greeter options', + 'I enable the specific Tor configuration option', + 'I log in to a new session', + 'Tails Greeter has dealt with the sudo password', + 'the Tails desktop is ready', + 'all notifications have disappeared', + ], + }, + + 'no-network-logged-in-sudo-passwd' => { + :temporary => true, + :description => "I have started Tails from DVD without network and logged in with an administration password", + :parent_checkpoint => "tails-greeter", + :steps => [ + 'I enable more Tails Greeter options', + 'I set an administration password', + 'I log in to a new session', + 'Tails Greeter has dealt with the sudo password', + 'the Tails desktop is ready', + ], + }, + + 'with-network-logged-in-sudo-passwd' => { + :temporary => true, + :description => "I have started Tails from DVD and logged in with an administration password and the network is connected", + :parent_checkpoint => "no-network-logged-in-sudo-passwd", + :steps => [ + 'the network is plugged', + 'Tor is ready', + 'all notifications have disappeared', + 'available upgrades have been checked', + ], + }, + + 'usb-install-tails-greeter' => { + :description => "I have started Tails without network from a USB drive without a persistent partition and stopped at Tails Greeter's login screen", + :parent_checkpoint => 'no-network-logged-in', + :steps => [ + 'I create a 4 GiB disk named "__internal"', + 'I plug USB drive "__internal"', + 'I "Clone & Install" Tails to USB drive "__internal"', + 'the running Tails is installed on USB drive "__internal"', + 'there is no persistence partition on USB drive "__internal"', + 'I shutdown Tails and wait for the computer to power off', + 'I start Tails from USB drive "__internal" with network unplugged', + 'the boot device has safe access rights', + 'Tails is running from USB drive "__internal"', + 'there is no persistence partition on USB drive "__internal"', + 'process "udev-watchdog" is running', + 'udev-watchdog is monitoring the correct device', + ], + }, + + 'usb-install-logged-in' => { + :description => "I have started Tails without network from a USB drive without a persistent partition and logged in", + :parent_checkpoint => 'usb-install-tails-greeter', + :steps => [ + 'I log in to a new session', + 'the Tails desktop is ready', + ], + }, + + 'usb-install-with-persistence-tails-greeter' => { + :description => "I have started Tails without network from a USB drive with a persistent partition and stopped at Tails Greeter's login screen", + :parent_checkpoint => 'usb-install-logged-in', + :steps => [ + 'I create a persistent partition', + 'a Tails persistence partition exists on USB drive "__internal"', + 'I shutdown Tails and wait for the computer to power off', + 'I start Tails from USB drive "__internal" with network unplugged', + 'the boot device has safe access rights', + 'Tails is running from USB drive "__internal"', + 'process "udev-watchdog" is running', + 'udev-watchdog is monitoring the correct device', + ], + }, + + 'usb-install-with-persistence-logged-in' => { + :description => "I have started Tails without network from a USB drive with a persistent partition enabled and logged in", + :parent_checkpoint => 'usb-install-with-persistence-tails-greeter', + :steps => [ + 'I enable persistence', + 'I log in to a new session', + 'the Tails desktop is ready', + 'all persistence presets are enabled', + 'all persistent filesystems have safe access rights', + 'all persistence configuration files have safe access rights', + 'all persistent directories have safe access rights', + ], + }, + } +end + +def reach_checkpoint(name) + scenario_indent = " "*4 + step_indent = " "*6 + + step "a computer" + if VM.snapshot_exists?(name) + $vm.restore_snapshot(name) + post_snapshot_restore_hook + else + checkpoint = checkpoints[name] + checkpoint_description = checkpoint[:description] + parent_checkpoint = checkpoint[:parent_checkpoint] + steps = checkpoint[:steps] + if parent_checkpoint + if VM.snapshot_exists?(parent_checkpoint) + $vm.restore_snapshot(parent_checkpoint) + else + reach_checkpoint(parent_checkpoint) + end + post_snapshot_restore_hook + end + debug_log(scenario_indent + "Checkpoint: #{checkpoint_description}", + :color => :white) + step_action = "Given" + if parent_checkpoint + parent_description = checkpoints[parent_checkpoint][:description] + debug_log(step_indent + "#{step_action} #{parent_description}", + :color => :green) + step_action = "And" + end + steps.each do |s| + begin + step(s) + rescue Exception => e + debug_log(scenario_indent + + "Step failed while creating checkpoint: #{s}", + :color => :red) + raise e + end + debug_log(step_indent + "#{step_action} #{s}", :color => :green) + step_action = "And" + end + $vm.save_snapshot(name) + end +end + +# For each checkpoint we generate a step to reach it. +checkpoints.each do |name, desc| + step_regex = Regexp.new("^#{Regexp.escape(desc[:description])}$") + Given step_regex do + reach_checkpoint(name) + end +end diff --git a/cucumber/features/step_definitions/ssh.rb b/cucumber/features/step_definitions/ssh.rb new file mode 100644 index 00000000..038b2977 --- /dev/null +++ b/cucumber/features/step_definitions/ssh.rb @@ -0,0 +1,122 @@ +require 'socket' + +def assert_not_ipaddr(s) + err_msg = "'#{s}' looks like a LAN IP address." + assert_raise(IPAddr::InvalidAddressError, err_msg) do + IPAddr.new(s) + end +end + +def read_and_validate_ssh_config srv_type + conf = $config[srv_type] + begin + required_settings = ["private_key", "public_key", "username", "hostname"] + required_settings.each do |key| + assert(conf.has_key?(key)) + assert_not_nil(conf[key]) + assert(!conf[key].empty?) + end + rescue NoMethodError + raise( + <<EOF +Your #{srv_type} config is incorrect or missing from your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format. +EOF + ) + end + + case srv_type + when 'SSH' + @ssh_host = conf["hostname"] + @ssh_port = conf["port"].to_i if conf["port"] + @ssh_username = conf["username"] + assert_not_ipaddr(@ssh_host) + when 'SFTP' + @sftp_host = conf["hostname"] + @sftp_port = conf["port"].to_i if conf["port"] + @sftp_username = conf["username"] + assert_not_ipaddr(@sftp_host) + end +end + +Given /^I have the SSH key pair for an? (Git|SSH|SFTP) (?:repository|server)( on the LAN)?$/ do |server_type, lan| + $vm.execute_successfully("install -m 0700 -d '/home/#{LIVE_USER}/.ssh/'", + :user => LIVE_USER) + unless server_type == 'Git' || lan + read_and_validate_ssh_config server_type + secret_key = $config[server_type]["private_key"] + public_key = $config[server_type]["public_key"] + else + secret_key = $config["Unsafe_SSH_private_key"] + public_key = $config["Unsafe_SSH_public_key"] + end + + $vm.execute_successfully("echo '#{secret_key}' > '/home/#{LIVE_USER}/.ssh/id_rsa'", + :user => LIVE_USER) + $vm.execute_successfully("echo '#{public_key}' > '/home/#{LIVE_USER}/.ssh/id_rsa.pub'", + :user => LIVE_USER) + $vm.execute_successfully("chmod 0600 '/home/#{LIVE_USER}/.ssh/'id*", + :user => LIVE_USER) +end + +Given /^I (?:am prompted to )?verify the SSH fingerprint for the (?:Git|SSH) (?:repository|server)$/ do + @screen.wait("SSHFingerprint.png", 60) + @screen.type('yes' + Sikuli::Key.ENTER) +end + +def get_free_tcp_port + server = TCPServer.new('127.0.0.1', 0) + return server.addr[1] +ensure + server.close +end + +Given /^an SSH server is running on the LAN$/ do + @sshd_server_port = get_free_tcp_port + @sshd_server_host = $vmnet.bridge_ip_addr + sshd = SSHServer.new(@sshd_server_host, @sshd_server_port) + sshd.start + add_after_scenario_hook { sshd.stop } +end + +When /^I connect to an SSH server on the (Internet|LAN)$/ do |location| + + case location + when 'Internet' + read_and_validate_ssh_config "SSH" + when 'LAN' + @ssh_port = @sshd_server_port + @ssh_username = 'user' + @ssh_host = @sshd_server_host + end + + ssh_port_suffix = "-p #{@ssh_port}" if @ssh_port + + 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' +end + +Then /^I have sucessfully logged into the SSH server$/ do + @screen.wait('SSHLoggedInPrompt.png', 60) +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) +end + +Then /^I verify the SSH fingerprint for the SFTP server$/ do + @screen.wait_and_click("GnomeSSHVerificationConfirm.png", 60) +end + +Then /^I successfully connect to the SFTP server$/ do + @screen.wait("GnomeSSHSuccess.png", 60) +end diff --git a/cucumber/features/step_definitions/time_syncing.rb b/cucumber/features/step_definitions/time_syncing.rb new file mode 100644 index 00000000..319fb521 --- /dev/null +++ b/cucumber/features/step_definitions/time_syncing.rb @@ -0,0 +1,86 @@ +# In some steps below we allow some slack when verifying that the date +# was set appropriately because it may take time to send the `date` +# command over the remote shell and get the answer back, parsing and +# post-processing of the result, etc. +def max_time_drift + 10 +end + +When /^I set the system time to "([^"]+)"$/ do |time| + $vm.execute_successfully("date -s '#{time}'") + new_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time + expected_time_lower_bound = DateTime.parse(time).to_time + expected_time_upper_bound = expected_time_lower_bound + max_time_drift + assert(expected_time_lower_bound <= new_time && + new_time <= expected_time_upper_bound, + "The guest's time was supposed to be set to " \ + "'#{expected_time_lower_bound}' but is '#{new_time}'") +end + +When /^I bump the (hardware clock's|system) time with "([^"]+)"$/ do |clock_type, timediff| + case clock_type + when "hardware clock's" + old_time = DateTime.parse($vm.execute_successfully("hwclock -r").stdout).to_time + $vm.execute_successfully("hwclock --set --date 'now #{timediff}'") + new_time = DateTime.parse($vm.execute_successfully("hwclock -r").stdout).to_time + when 'system' + old_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time + $vm.execute_successfully("date -s 'now #{timediff}'") + new_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time + end + expected_time_lower_bound = DateTime.parse( + cmd_helper(["date", "-d", "#{old_time} #{timediff}"])).to_time + expected_time_upper_bound = expected_time_lower_bound + max_time_drift + assert(expected_time_lower_bound <= new_time && + new_time <= expected_time_upper_bound, + "The #{clock_type} time was supposed to be bumped to " \ + "'#{expected_time_lower_bound}' but is '#{new_time}'") +end + +Then /^Tails clock is less than (\d+) minutes incorrect$/ do |max_diff_mins| + guest_time_str = $vm.execute("date --rfc-2822").stdout.chomp + guest_time = Time.rfc2822(guest_time_str) + host_time = Time.now + diff = (host_time - guest_time).abs + assert(diff < max_diff_mins.to_i*60, + "The guest's clock is off by #{diff} seconds (#{guest_time})") + puts "Time was #{diff} seconds off" +end + +Then /^the system clock is just past Tails' build 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 + # 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})") + assert(diff <= max_diff, + "The system time (#{system_time}) is more than #{max_diff} seconds " + + "past the build date (#{build_time})") +end + +Then /^Tails' hardware clock is close to the host system's time$/ do + host_time = Time.now + hwclock_time_str = $vm.execute('hwclock -r').stdout.chomp + hwclock_time = DateTime.parse(hwclock_time_str).to_time + diff = (hwclock_time - host_time).abs + assert(diff <= max_time_drift) +end + +Then /^the hardware clock is still off by "([^"]+)"$/ do |timediff| + hwclock = DateTime.parse($vm.execute_successfully("hwclock -r").stdout.chomp).to_time + expected_time_lower_bound = DateTime.parse( + cmd_helper(["date", "-d", "now #{timediff}"])).to_time - max_time_drift + expected_time_upper_bound = expected_time_lower_bound + max_time_drift + assert(expected_time_lower_bound <= hwclock && + hwclock <= expected_time_upper_bound, + "The host's hwclock should be approximately " \ + "'#{expected_time_lower_bound}' but is actually '#{hwclock}'") +end diff --git a/cucumber/features/step_definitions/tor.rb b/cucumber/features/step_definitions/tor.rb new file mode 100644 index 00000000..ac12fd4c --- /dev/null +++ b/cucumber/features/step_definitions/tor.rb @@ -0,0 +1,402 @@ +def iptables_chains_parse(iptables, table = "filter", &block) + assert(block_given?) + cmd = "#{iptables}-save -c -t #{table} | iptables-xml" + xml_str = $vm.execute_successfully(cmd).stdout + rexml = REXML::Document.new(xml_str) + rexml.get_elements('iptables-rules/table/chain').each do |element| + yield( + element.attribute('name').to_s, + element.attribute('policy').to_s, + element.get_elements('rule') + ) + end +end + +def ip4tables_chains(table = "filter", &block) + iptables_chains_parse('iptables', table, &block) +end + +def ip6tables_chains(table = "filter", &block) + iptables_chains_parse('ip6tables', table, &block) +end + +def iptables_rules_parse(iptables, chain, table) + iptables_chains_parse(iptables, table) do |name, _, rules| + return rules if name == chain + end + return nil +end + +def iptables_rules(chain, table = "filter") + iptables_rules_parse("iptables", chain, table) +end + +def ip6tables_rules(chain, table = "filter") + iptables_rules_parse("ip6tables", chain, table) +end + +def ip4tables_packet_counter_sum(filters = {}) + pkts = 0 + ip4tables_chains do |name, _, rules| + next if filters[:tables] && not(filters[:tables].include?(name)) + rules.each do |rule| + next if filters[:uid] && not(rule.elements["conditions/owner/uid-owner[text()=#{filters[:uid]}]"]) + pkts += rule.attribute('packet-count').to_s.to_i + end + end + return pkts +end + +def try_xml_element_text(element, xpath, default = nil) + node = element.elements[xpath] + (node.nil? or not(node.has_text?)) ? default : node.text +end + +Then /^the firewall's policy is to (.+) all IPv4 traffic$/ do |expected_policy| + expected_policy.upcase! + ip4tables_chains do |name, policy, _| + if ["INPUT", "FORWARD", "OUTPUT"].include?(name) + assert_equal(expected_policy, policy, + "Chain #{name} has unexpected policy #{policy}") + end + end +end + +Then /^the firewall is configured to only allow the (.+) users? to connect directly to the Internet over IPv4$/ do |users_str| + users = users_str.split(/, | and /) + expected_uids = Set.new + users.each do |user| + expected_uids << $vm.execute_successfully("id -u #{user}").stdout.to_i + end + allowed_output = iptables_rules("OUTPUT").find_all do |rule| + out_iface = rule.elements['conditions/match/o'] + is_maybe_accepted = rule.get_elements('actions/*').find do |action| + not(["DROP", "REJECT", "LOG"].include?(action.name)) + end + is_maybe_accepted && + ( + # nil => match all interfaces according to iptables-xml + out_iface.nil? || + ((out_iface.text == 'lo') == (out_iface.attribute('invert').to_s == '1')) + ) + end + uids = Set.new + allowed_output.each do |rule| + rule.elements.each('actions/*') do |action| + destination = try_xml_element_text(rule, "conditions/match/d") + if action.name == "ACCEPT" + # nil == 0.0.0.0/0 according to iptables-xml + assert(destination == '0.0.0.0/0' || destination.nil?, + "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" + assert_not_nil(rule.elements['conditions/owner/uid-owner']) + rule.elements.each('conditions/owner/uid-owner') do |owner| + uid = owner.text.to_i + uids << uid + assert(expected_uids.include?(uid), + "The following rule allows uid #{uid} to access the " + + "network, but we only expect uids #{expected_uids.to_a} " + + "(#{users_str}) to have such access:\n#{rule.to_s}") + end + elsif action.name == "call" && action.elements[1].name == "lan" + lan_subnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] + assert(lan_subnets.include?(destination), + "The following lan-targeted rule's destination is " + + "#{destination} which may not be a private subnet:\n" + + rule.to_s) + else + raise "Unexpected iptables OUTPUT chain rule:\n#{rule.to_s}" + end + end + end + uids_not_found = expected_uids - uids + assert(uids_not_found.empty?, + "Couldn't find rules allowing uids #{uids_not_found.to_a.to_s} " \ + "access to the network") +end + +Then /^the firewall's NAT rules only redirect traffic for Tor's TransPort and DNSPort$/ do + loopback_address = "127.0.0.1/32" + tor_onion_addr_space = "127.192.0.0/10" + tor_trans_port = "9040" + dns_port = "53" + tor_dns_port = "5353" + ip4tables_chains('nat') do |name, _, rules| + if name == "OUTPUT" + good_rules = rules.find_all do |rule| + redirect = rule.get_elements('actions/*').all? do |action| + action.name == "REDIRECT" + end + destination = try_xml_element_text(rule, "conditions/match/d") + redir_port = try_xml_element_text(rule, "actions/REDIRECT/to-ports") + redirected_to_trans_port = redir_port == tor_trans_port + udp_destination_port = try_xml_element_text(rule, "conditions/udp/dport") + dns_redirected_to_tor_dns_port = (udp_destination_port == dns_port) && + (redir_port == tor_dns_port) + redirect && + ( + (destination == tor_onion_addr_space && redirected_to_trans_port) || + (destination == loopback_address && dns_redirected_to_tor_dns_port) + ) + end + bad_rules = rules - good_rules + assert(bad_rules.empty?, + "The NAT table's OUTPUT chain contains some unexpected " + + "rules:\n#{bad_rules}") + else + assert(rules.empty?, + "The NAT table contains unexpected rules for the #{name} " + + "chain:\n#{rules}") + end + end +end + +Then /^the firewall is configured to block all external IPv6 traffic$/ do + ip6_loopback = '::1/128' + expected_policy = "DROP" + ip6tables_chains do |name, policy, rules| + assert_equal(expected_policy, policy, + "The IPv6 #{name} chain has policy #{policy} but we " \ + "expected #{expected_policy}") + good_rules = rules.find_all do |rule| + ["DROP", "REJECT", "LOG"].any? do |target| + rule.elements["actions/#{target}"] + end \ + || + ["s", "d"].all? do |x| + try_xml_element_text(rule, "conditions/match/#{x}") == ip6_loopback + end + end + bad_rules = rules - good_rules + assert(bad_rules.empty?, + "The IPv6 table's #{name} chain contains some unexpected rules:\n" + + (bad_rules.map { |r| r.to_s }).join("\n")) + end +end + +def firewall_has_dropped_packet_to?(proto, host, port) + regex = "^Dropped outbound packet: .* " + regex += "DST=#{Regexp.escape(host)} .* " + regex += "PROTO=#{Regexp.escape(proto)} " + regex += ".* DPT=#{port} " if 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| + assert(!firewall_has_dropped_packet_to?(proto, host, port), + "A #{proto} packet to #{host}" + + (port.nil? ? "" : ":#{port}") + + " has already been dropped by the firewall") + @conn_proto = proto + @conn_host = host + @conn_port = port + case proto + when "TCP" + assert_not_nil(port) + cmd = "echo | netcat #{host} #{port}" + user = LIVE_USER + when "UDP" + assert_not_nil(port) + cmd = "echo | netcat -u #{host} #{port}" + user = LIVE_USER + when "ICMP" + cmd = "ping -c 5 #{host}" + user = 'root' + end + @conn_res = $vm.execute(cmd, :user => user) +end + +Then /^the untorified connection fails$/ do + case @conn_proto + when "TCP" + expected_in_stderr = "Connection refused" + conn_failed = !@conn_res.success? && + @conn_res.stderr.chomp.end_with?(expected_in_stderr) + when "UDP", "ICMP" + conn_failed = !@conn_res.success? + end + assert(conn_failed, + "The untorified #{@conn_proto} connection didn't fail as expected:\n" + + @conn_res.to_s) +end + +Then /^the untorified connection is logged as dropped by the firewall$/ do + assert(firewall_has_dropped_packet_to?(@conn_proto, @conn_host, @conn_port), + "No #{@conn_proto} packet to #{@conn_host}" + + (@conn_port.nil? ? "" : ":#{@conn_port}") + + " was dropped by the firewall") +end + +When /^the system DNS is(?: still)? using the local DNS resolver$/ do + resolvconf = $vm.file_content("/etc/resolv.conf") + bad_lines = resolvconf.split("\n").find_all do |line| + !line.start_with?("#") && !/^nameserver\s+127\.0\.0\.1$/.match(line) + end + assert_empty(bad_lines, + "The following bad lines were found in /etc/resolv.conf:\n" + + bad_lines.join("\n")) +end + +def stream_isolation_info(application) + case application + when "htpdate" + { + :grep_monitor_expr => '/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 + { + :grep_monitor_expr => '\<ESTABLISHED\>.\+/perl\>', + :socksport => 9062 + } + when "Tor Browser" + { + :grep_monitor_expr => '/firefox\>', + :socksport => 9150 + } + when "Gobby" + { + :grep_monitor_expr => '/gobby\>', + :socksport => 9050 + } + when "SSH" + { + :grep_monitor_expr => '/\(connect-proxy\|ssh\)\>', + :socksport => 9050 + } + when "whois" + { + :grep_monitor_expr => '/whois\>', + :socksport => 9050 + } + else + raise "Unknown application '#{application}' for the stream isolation tests" + end +end + +When /^I monitor the network connections of (.*)$/ do |application| + @process_monitor_log = "/tmp/netstat.log" + info = stream_isolation_info(application) + $vm.spawn("while true; do " + + " netstat -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] + 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}") + end +end + +And /^I re-run tails-security-check$/ do + $vm.execute_successfully("tails-security-check", :user => LIVE_USER) +end + +And /^I re-run htpdate$/ do + $vm.execute_successfully("service htpdate stop && " \ + "rm -f /var/run/htpdate/* && " \ + "systemctl --no-block start htpdate.service") + step "the time has synced" +end + +And /^I re-run tails-upgrade-frontend-wrapper$/ do + $vm.execute_successfully("tails-upgrade-frontend-wrapper", :user => LIVE_USER) +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) + # 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) + @screen.type("t", Sikuli::KeyModifier.CTRL) + @screen.wait("GobbyConnectPrompt.png", 10) + @screen.type(host + Sikuli::Key.ENTER) + @screen.wait("GobbyConnectionComplete.png", 60) +end + +When /^the Tor Launcher autostarts$/ do + @screen.wait('TorLauncherWindow.png', 60) +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"] + @screen.type(bridge_line + Sikuli::Key.ENTER) + end + @screen.wait_and_click('TorLauncherNextButton.png', 10) + @screen.hide_cursor + @screen.wait_and_click('TorLauncherFinishButton.png', 10) + @screen.wait('TorLauncherConnectingWindow.png', 10) + @screen.waitVanish('TorLauncherConnectingWindow.png', 120) +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 + 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 new file mode 100644 index 00000000..c8f3ff1d --- /dev/null +++ b/cucumber/features/step_definitions/torified_browsing.rb @@ -0,0 +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}") +end diff --git a/cucumber/features/step_definitions/torified_gnupg.rb b/cucumber/features/step_definitions/torified_gnupg.rb new file mode 100644 index 00000000..4b4cc040 --- /dev/null +++ b/cucumber/features/step_definitions/torified_gnupg.rb @@ -0,0 +1,208 @@ +class OpenPGPKeyserverCommunicationError < StandardError +end + +def count_gpg_signatures(key) + output = $vm.execute_successfully("gpg --batch --list-sigs #{key}", + :user => LIVE_USER).stdout + output.scan(/^sig/).count +end + +def check_for_seahorse_error + if @screen.exists('GnomeCloseButton.png') + raise OpenPGPKeyserverCommunicationError.new( + "Found GnomeCloseButton.png' on the screen" + ) + end +end + +def start_or_restart_seahorse + assert_not_nil(@withgpgapplet) + if @withgpgapplet + seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletManageKeys.png') + else + step 'I start "Seahorse" via the GNOME "Utilities" applications menu' + end + step 'Seahorse has opened' +end + +Then /^the key "([^"]+)" has (only|more than) (\d+) signatures$/ do |key, qualifier, num| + count = count_gpg_signatures(key) + case qualifier + when 'only' + assert_equal(count, num.to_i, "Expected #{num} signatures but instead found #{count}") + when 'more than' + assert(count > num.to_i, "Expected more than #{num} signatures but found #{count}") + else + raise "Unknown operator #{qualifier} passed" + end +end + +When /^the "([^"]+)" OpenPGP key is not in the live user's public keyring$/ do |keyid| + assert(!$vm.execute("gpg --batch --list-keys '#{keyid}'", + :user => LIVE_USER).success?, + "The '#{keyid}' key is in the live user's public keyring.") +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. + @fetched_openpgp_keyid = keyid + if without + importopts = '--keyserver-options import-clean' + else + importopts = '' + end + retry_tor do + @gnupg_recv_key_res = $vm.execute_successfully( + "timeout 120 gpg --batch #{importopts} --recv-key '#{@fetched_openpgp_keyid}'", + :user => LIVE_USER) + if @gnupg_recv_key_res.failure? + raise "Fetching keys with the GnuPG CLI failed with:\n" + + "#{@gnupg_recv_key_res.stdout}\n" + + "#{@gnupg_recv_key_res.stderr}" + end + end +end + +When /^the GnuPG fetch is successful$/ do + assert(@gnupg_recv_key_res.success?, + "gpg keyserver fetch failed:\n#{@gnupg_recv_key_res.stderr}") +end + +When /^the Seahorse operation is successful$/ do + !@screen.exists('GnomeCloseButton.png') + $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") { + $vm.execute("gpg --batch --list-keys '#{keyid}'", + :user => LIVE_USER).success? + } +end + +When /^I start Seahorse( via the Tails OpenPGP Applet)?$/ do |withgpgapplet| + @withgpgapplet = !!withgpgapplet + start_or_restart_seahorse +end + +Then /^Seahorse has opened$/ do + @screen.wait('SeahorseWindow.png', 20) +end + +Then /^I enable key synchronization in Seahorse$/ do + step 'process "seahorse" is running' + @screen.wait_and_click("SeahorseWindow.png", 10) + seahorse_menu_click_helper('GnomeEditMenu.png', 'SeahorseEditPreferences.png', 'seahorse') + @screen.wait('SeahorsePreferences.png', 20) + @screen.type("p", Sikuli::KeyModifier.ALT) # Option: "Publish keys to...". + @screen.type(Sikuli::Key.DOWN) # select HKP server + @screen.type("c", Sikuli::KeyModifier.ALT) # Button: "Close" +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 + # 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. + # + # We'll kill the Seahorse process to avoid waiting for the inevitable + # segfault. We'll also make sure the process is still running (= hasn't + # yet segfaulted) before terminating it. + if @screen.exists('GnomeCloseButton.png') || !$vm.has_process?('seahorse') + step 'I kill the process "seahorse"' if $vm.has_process?('seahorse') + debug_log('Restarting Seahorse.') + start_or_restart_seahorse + end + end + + def change_of_status? + # Due to a lack of visual feedback in Seahorse we'll break out of the + # try_for loop below by returning "true" when there's something we can act + # upon. + if count_gpg_signatures(@fetched_openpgp_keyid) > 2 || \ + @screen.exists('GnomeCloseButton.png') || \ + !$vm.has_process?('seahorse') + true + end + end + + retry_tor(recovery_proc) do + @screen.wait_and_click("SeahorseWindow.png", 10) + seahorse_menu_click_helper('SeahorseRemoteMenu.png', + 'SeahorseRemoteMenuSync.png', + 'seahorse') + @screen.wait('SeahorseSyncKeys.png', 20) + @screen.type("s", Sikuli::KeyModifier.ALT) # Button: Sync + # There's no visual feedback of Seahorse in Tails/Jessie, except on error. + try_for(120) { + change_of_status? + } + check_for_seahorse_error + raise OpenPGPKeyserverCommunicationError.new( + 'Seahorse crashed with a segfault.') unless $vm.has_process?('seahorse') + end +end + +When /^I fetch the "([^"]+)" OpenPGP key using Seahorse( via the Tails OpenPGP Applet)?$/ do |keyid, withgpgapplet| + step "I start Seahorse#{withgpgapplet}" + + def change_of_status?(keyid) + # Due to a lack of visual feedback in Seahorse we'll break out of the + # try_for loop below by returning "true" when there's something we can act + # upon. + if $vm.execute_successfully( + "gpg --batch --list-keys '#{keyid}'", :user => LIVE_USER) || + @screen.exists('GnomeCloseButton.png') + true + end + end + + recovery_proc = Proc.new do + @screen.click('GnomeCloseButton.png') if @screen.exists('GnomeCloseButton.png') + @screen.type("w", Sikuli::KeyModifier.CTRL) + end + retry_tor(recovery_proc) do + @screen.wait_and_click("SeahorseWindow.png", 10) + seahorse_menu_click_helper('SeahorseRemoteMenu.png', + 'SeahorseRemoteMenuFind.png', + 'seahorse') + @screen.wait('SeahorseFindKeysWindow.png', 10) + # Seahorse doesn't seem to support searching for fingerprints + @screen.type(keyid + Sikuli::Key.ENTER) + begin + @screen.waitAny(['SeahorseFoundKeyResult.png', + 'GnomeCloseButton.png'], 120) + rescue FindAnyFailed + # We may end up here if Seahorse appears to be "frozen". + # Sometimes--but not always--if we click another window + # the main Seahorse window will unfreeze, allowing us + # to continue normally. + @screen.click("SeahorseSearch.png") + end + check_for_seahorse_error + @screen.click("SeahorseKeyResultWindow.png") + @screen.click("SeahorseFoundKeyResult.png") + @screen.click("SeahorseImport.png") + try_for(120) do + change_of_status?(keyid) + end + check_for_seahorse_error + 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]) +end diff --git a/cucumber/features/step_definitions/torified_misc.rb b/cucumber/features/step_definitions/torified_misc.rb new file mode 100644 index 00000000..7112776a --- /dev/null +++ b/cucumber/features/step_definitions/torified_misc.rb @@ -0,0 +1,41 @@ +When /^I query the whois directory service for "([^"]+)"$/ do |domain| + retry_tor do + @vm_execute_res = $vm.execute("whois '#{domain}'", :user => LIVE_USER) + if @vm_execute_res.failure? || @vm_execute_res.stdout['LIMIT EXCEEDED'] + raise "Looking up whois info for #{domain} failed with:\n" + + "#{@vm_execute_res.stdout}\n" + + "#{@vm_execute_res.stderr}" + end + end +end + +When /^I wget "([^"]+)" to stdout(?:| with the '([^']+)' options)$/ do |url, options| + arguments = "-O - '#{url}'" + arguments = "#{options} #{arguments}" if options + retry_tor do + @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" + + "#{@vm_execute_res.stdout}\n" + + "#{@vm_execute_res.stderr}" + end + end +end + +Then /^the (wget|whois) command is successful$/ do |command| + assert( + @vm_execute_res.success?, + "#{command} failed:\n" + + "#{@vm_execute_res.stdout}\n" + + "#{@vm_execute_res.stderr}" + ) +end + +Then /^the (wget|whois) standard output contains "([^"]+)"$/ do |command, text| + assert( + @vm_execute_res.stdout[text], + "The #{command} standard output does not contain #{text}:\n" + + "#{@vm_execute_res.stdout}\n" + + "#{@vm_execute_res.stderr}" + ) +end diff --git a/cucumber/features/step_definitions/totem.rb b/cucumber/features/step_definitions/totem.rb new file mode 100644 index 00000000..72698dde --- /dev/null +++ b/cucumber/features/step_definitions/totem.rb @@ -0,0 +1,43 @@ +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) } + fatal_system("avconv -loop 1 -t 30 -f image2 " + + "-i 'features/images/TailsBootSplash.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") +end + +Given /^I setup a filesystem share containing sample videos$/ do + $vm.add_share(@shared_video_dir_on_host, @shared_video_dir_on_guest) +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 + video_name = File.basename(video_on_host) + src_on_guest = "#{@shared_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 +end + +When /^I(?:| try to) open "([^"]+)" with Totem$/ do |filename| + step "I run \"totem #{filename}\" in GNOME Terminal" +end + +When /^I close Totem$/ do + step 'I kill the process "totem"' +end + +Then /^I can watch a WebM video over HTTPs$/ do + test_url = 'https://webm.html5.org/test.webm' + recovery_on_failure = Proc.new do + step 'I close Totem' + end + retry_tor(recovery_on_failure) do + step "I open \"#{test_url}\" with Totem" + @screen.wait("SampleRemoteWebMVideoFrame.png", 120) + end +end diff --git a/cucumber/features/step_definitions/unsafe_browser.rb b/cucumber/features/step_definitions/unsafe_browser.rb new file mode 100644 index 00000000..b8c04983 --- /dev/null +++ b/cucumber/features/step_definitions/unsafe_browser.rb @@ -0,0 +1,189 @@ +When /^I see and accept the Unsafe Browser start verification$/ do + @screen.wait('GnomeQuestionDialogIcon.png', 30) + @screen.type(Sikuli::Key.ESC) +end + +def supported_torbrowser_languages + localization_descriptions = "#{Dir.pwd}/config/chroot_local-includes/usr/share/tails/browser-localization/descriptions" + 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", + "#{first}_#{second}", first] + when_not_found = Proc.new { raise "Could not find a locale for '#{line}'" } + candidates.find(when_not_found) do |candidate| + $vm.directory_exist?("/usr/lib/locale/#{candidate}") + end + end +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" +end + +Then /^the Unsafe Browser works in all supported languages$/ do + failed = Array.new + supported_torbrowser_languages.each do |lang| + step "I start the Unsafe Browser in the \"#{lang}\" locale" + begin + step "the Unsafe Browser has started" + rescue RuntimeError + failed << lang + next + end + step "I close the Unsafe Browser" + step "the Unsafe Browser chroot is torn down" + end + assert(failed.empty?, "Unsafe Browser failed to launch in the following locale(s): #{failed.join(', ')}") +end + +Then /^the Unsafe Browser has no add-ons installed$/ do + step "I open the address \"about:addons\" in the Unsafe Browser" + step "I see \"UnsafeBrowserNoAddons.png\" after at most 30 seconds" +end + +Then /^the Unsafe Browser has only Firefox's default bookmarks configured$/ do + info = xul_application_info("Unsafe Browser") + # "Show all bookmarks" + @screen.type("o", Sikuli::KeyModifier.SHIFT + Sikuli::KeyModifier.CTRL) + @screen.wait_and_click("UnsafeBrowserExportBookmarksButton.png", 20) + @screen.wait_and_click("UnsafeBrowserExportBookmarksMenuEntry.png", 20) + @screen.wait("UnsafeBrowserExportBookmarksSavePrompt.png", 20) + path = "/home/#{info[:user]}/bookmarks" + @screen.type(path + Sikuli::Key.ENTER) + chroot_path = "#{info[:chroot]}/#{path}.json" + try_for(10) { $vm.file_exist?(chroot_path) } + dump = JSON.load($vm.file_content(chroot_path)) + + def check_bookmarks_helper(a) + mozilla_uris_counter = 0 + places_uris_counter = 0 + a.each do |h| + h.each_pair do |k, v| + if k == "children" + m, p = check_bookmarks_helper(v) + mozilla_uris_counter += m + places_uris_counter += p + elsif k == "uri" + uri = v + if uri.match("^https://www\.mozilla\.org/") + mozilla_uris_counter += 1 + elsif uri.match("^place:(sort|folder|type)=") + places_uris_counter += 1 + else + raise "Unexpected Unsafe Browser bookmark for '#{uri}'" + end + end + end + end + return [mozilla_uris_counter, places_uris_counter] + end + + mozilla_uris_counter, places_uris_counter = + check_bookmarks_helper(dump["children"]) + assert_equal(5, mozilla_uris_counter, + "Unexpected number (#{mozilla_uris_counter}) of mozilla " \ + "bookmarks") + assert_equal(3, places_uris_counter, + "Unexpected number (#{places_uris_counter}) of places " \ + "bookmarks") + @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT) +end + +Then /^the Unsafe Browser has a red theme$/ do + @screen.wait("UnsafeBrowserRedTheme.png", 10) +end + +Then /^the Unsafe Browser shows a warning as its start page$/ do + @screen.wait("UnsafeBrowserStartPage.png", 10) +end + +Then /^I see a warning about another instance already running$/ do + @screen.wait('UnsafeBrowserWarnAlreadyRunning.png', 10) +end + +Then /^I can start the Unsafe Browser again$/ do + step "I start the Unsafe Browser" +end + +Then /^I cannot configure the Unsafe Browser to use any local proxies$/ do + socks_proxy = 'c' # Alt+c for socks proxy + no_proxy = 'y' # Alt+y for no proxy + proxies = [[no_proxy, nil, nil]] + socksport_lines = + $vm.execute_successfully('grep -w "^SocksPort" /etc/tor/torrc').stdout + assert(socksport_lines.size >= 4, "We got fewer than four Tor SocksPorts") + socksports = socksport_lines.scan(/^SocksPort\s([^:]+):(\d+)/) + proxies += socksports.map { |host, port| [socks_proxy, host, port] } + + proxies.each do |proxy_type, proxy_host, proxy_port| + @screen.hide_cursor + + # Open proxy settings and select manual proxy configuration + @screen.click('UnsafeBrowserMenuButton.png') + @screen.wait_and_click('UnsafeBrowserPreferencesButton.png', 10) + @screen.wait_and_click('UnsafeBrowserAdvancedSettingsButton.png', 10) + hit, _ = @screen.waitAny(['UnsafeBrowserNetworkTabAlreadySelected.png', + 'UnsafeBrowserNetworkTab.png'], 10) + @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 + + # 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" + if proxy_type == no_proxy + @screen.wait('UnsafeBrowserTorCheckFail.png', 60) + else + @screen.wait('UnsafeBrowserProxyRefused.png', 60) + end + end +end + +Then /^the Unsafe Browser has no proxy configured$/ do + @screen.click('UnsafeBrowserMenuButton.png') + @screen.wait_and_click('UnsafeBrowserPreferencesButton.png', 10) + @screen.wait_and_click('UnsafeBrowserAdvancedSettingsButton.png', 10) + @screen.wait_and_click('UnsafeBrowserNetworkTab.png', 10) + @screen.wait_and_click('UnsafeBrowserNetworkTabSettingsButton.png', 10) + @screen.wait('UnsafeBrowserProxySettingsWindow.png', 10) + @screen.wait('UnsafeBrowserNoProxySelected.png', 10) + @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT) + @screen.type("w", Sikuli::KeyModifier.CTRL) +end + +Then /^the Unsafe Browser complains that no DNS server is configured$/ do + @screen.wait("UnsafeBrowserDNSError.png", 30) +end + +Then /^I configure the Unsafe Browser to check for updates more frequently$/ do + prefs = '/usr/share/tails/chroot-browsers/unsafe-browser/prefs.js' + $vm.file_append(prefs, 'pref("app.update.idletime", 1);') + $vm.file_append(prefs, 'pref("app.update.promptWaitTime", 1);') + $vm.file_append(prefs, 'pref("app.update.interval", 5);') +end + +But /^checking for updates is disabled in the Unsafe Browser's configuration$/ do + prefs = '/usr/share/tails/chroot-browsers/common/prefs.js' + assert($vm.file_content(prefs).include?('pref("app.update.enabled", false)')) +end + +Then /^the clearnet user has (|not )sent packets out to the Internet$/ do |sent| + uid = $vm.execute_successfully("id -u clearnet").stdout.chomp.to_i + pkts = ip4tables_packet_counter_sum(:tables => ['OUTPUT'], :uid => uid) + case sent + when '' + assert(pkts > 0, "Packets have not gone out to the internet.") + when 'not' + assert_equal(pkts, 0, "Packets have gone out to the internet.") + end +end diff --git a/cucumber/features/step_definitions/untrusted_partitions.rb b/cucumber/features/step_definitions/untrusted_partitions.rb new file mode 100644 index 00000000..43453b2f --- /dev/null +++ b/cucumber/features/step_definitions/untrusted_partitions.rb @@ -0,0 +1,61 @@ +Given /^I create an? ([[:alnum:]]+) swap partition on disk "([^"]+)"$/ do |parttype, name| + $vm.storage.disk_mkswap(name, parttype) +end + +Then /^an? "([^"]+)" partition was detected by Tails on drive "([^"]+)"$/ do |type, name| + part_info = $vm.execute_successfully( + "blkid '#{$vm.disk_dev(name)}'").stdout.strip + assert(part_info.split.grep(/^TYPE=\"#{Regexp.escape(type)}\"$/), + "No #{type} partition was detected by Tails on disk '#{name}'") +end + +Then /^Tails has no disk swap enabled$/ do + # Skip first line which contain column headers + swap_info = $vm.execute_successfully("tail -n+2 /proc/swaps").stdout + assert(swap_info.empty?, + "Disk swapping is enabled according to /proc/swaps:\n" + swap_info) + mem_info = $vm.execute_successfully("grep '^Swap' /proc/meminfo").stdout + assert(mem_info.match(/^SwapTotal:\s+0 kB$/), + "Disk swapping is enabled according to /proc/meminfo:\n" + + mem_info) +end + +Given /^I create an? ([[:alnum:]]+) partition( labeled "([^"]+)")? with an? ([[:alnum:]]+) filesystem( encrypted with password "([^"]+)")? on disk "([^"]+)"$/ do |parttype, has_label, label, fstype, is_encrypted, luks_password, name| + opts = {} + opts.merge!(:label => label) if has_label + opts.merge!(:luks_password => luks_password) if is_encrypted + $vm.storage.disk_mkpartfs(name, parttype, fstype, opts) +end + +Given /^I cat an ISO of the Tails image to disk "([^"]+)"$/ do |name| + src_disk = { + :path => TAILS_ISO, + :opts => { + :format => "raw", + :readonly => true + } + } + dest_disk = { + :path => $vm.storage.disk_path(name), + :opts => { + :format => $vm.storage.disk_format(name) + } + } + $vm.storage.guestfs_disk_helper(src_disk, dest_disk) do |g, src_disk_handle, dest_disk_handle| + g.copy_device_to_device(src_disk_handle, dest_disk_handle, {}) + end +end + +Then /^drive "([^"]+)" is not mounted$/ do |name| + dev = $vm.disk_dev(name) + assert(!$vm.execute("grep -qs '^#{dev}' /proc/mounts").success?, + "an untrusted partition from drive '#{name}' was automounted") +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? + 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 new file mode 100644 index 00000000..76f94d2f --- /dev/null +++ b/cucumber/features/step_definitions/usb.rb @@ -0,0 +1,596 @@ +# Returns a hash that for each preset the running Tails is aware of +# maps the source to the destination. +def get_persistence_presets(skip_links = false) + # Perl script that prints all persistence presets (one per line) on + # the form: <mount_point>:<comma-separated-list-of-options> + script = <<-EOF + use strict; + use warnings FATAL => "all"; + use Tails::Persistence::Configuration::Presets; + foreach my $preset (Tails::Persistence::Configuration::Presets->new()->all) { + say $preset->destination, ":", join(",", @{$preset->options}); + } +EOF + # VMCommand:s cannot handle newlines, and they're irrelevant in the + # above perl script any way + script.delete!("\n") + presets = $vm.execute_successfully("perl -E '#{script}'").stdout.chomp.split("\n") + assert presets.size >= 10, "Got #{presets.size} persistence presets, " + + "which is too few" + persistence_mapping = Hash.new + for line in presets + destination, options_str = line.split(":") + options = options_str.split(",") + is_link = options.include? "link" + next if is_link and skip_links + source_str = options.find { |option| /^source=/.match option } + # If no source is given as an option, live-boot's persistence + # feature defaults to the destination minus the initial "/". + if source_str.nil? + source = destination.partition("/").last + else + source = source_str.split("=")[1] + end + persistence_mapping[source] = destination + end + return persistence_mapping +end + +def persistent_dirs + get_persistence_presets +end + +def persistent_mounts + get_persistence_presets(true) +end + +def persistent_volumes_mountpoints + $vm.execute("ls -1 -d /live/persistence/*_unlocked/").stdout.chomp.split +end + +Given /^I clone USB drive "([^"]+)" to a new USB drive "([^"]+)"$/ do |from, to| + $vm.storage.clone_to_new_disk(from, to) +end + +Given /^I unplug USB drive "([^"]+)"$/ do |name| + $vm.unplug_drive(name) +end + +Given /^the computer is set to boot from the old Tails DVD$/ do + $vm.set_cdrom_boot(OLD_TAILS_ISO) +end + +Given /^the computer is set to boot in UEFI mode$/ do + $vm.set_os_loader('UEFI') + @os_loader = 'UEFI' +end + +class UpgradeNotSupported < StandardError +end + +def usb_install_helper(name) + @screen.wait('USBTailsLogo.png', 10) + if @screen.exists("USBCannotUpgrade.png") + 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) +end + +When /^I start Tails Installer$/ do + step 'I start "TailsInstaller" via the GNOME "Tails" applications menu' + @screen.wait('USBCloneAndInstall.png', 30) +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}'" + end +end + +Then /^Tails Installer detects that a device is too small$/ do + @screen.wait('TailsInstallerTooSmallDevice.png', 10) +end + +When /^I "Clone & Install" Tails to USB drive "([^"]+)"$/ do |name| + step 'I start Tails Installer in "Clone & Install" mode' + usb_install_helper(name) +end + +When /^I "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name| + step 'I start Tails Installer in "Clone & Upgrade" mode' + usb_install_helper(name) +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" + end +end + +When /^I try to "Upgrade from ISO" USB drive "([^"]+)"$/ do |name| + begin + step "I do a \"Upgrade from ISO\" on USB drive \"#{name}\"" + rescue UpgradeNotSupported + # this is what we expect + else + raise "The USB installer should not succeed" + 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) +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) + @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) + usb_install_helper(name) +end + +Given /^I enable all persistence presets$/ do + @screen.wait('PersistenceWizardPresets.png', 20) + # Select the "Persistent" folder preset, which is checked by default. + @screen.type(Sikuli::Key.TAB) + # Check all non-default persistence presets, i.e. all *after* the + # "Persistent" folder, which are unchecked by default. + (persistent_dirs.size - 1).times do + @screen.type(Sikuli::Key.TAB + Sikuli::Key.SPACE) + end + @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) + @screen.type(@persistence_password + "\t" + @persistence_password + Sikuli::Key.ENTER) + @screen.wait('PersistenceWizardPresets.png', 300) + step "I enable all persistence presets" +end + +def check_disk_integrity(name, dev, scheme) + info = $vm.execute("udisksctl info --block-device '#{dev}'").stdout + info_split = info.split("\n org\.freedesktop\.UDisks2\.PartitionTable:\n") + dev_info = info_split[0] + part_table_info = info_split[1] + assert(part_table_info.match("^ Type: +#{scheme}$"), + "Unexpected partition scheme on USB drive '#{name}', '#{dev}'") +end + +def check_part_integrity(name, dev, usage, fs_type, part_label, part_type = nil) + info = $vm.execute("udisksctl info --block-device '#{dev}'").stdout + info_split = info.split("\n org\.freedesktop\.UDisks2\.Partition:\n") + dev_info = info_split[0] + part_info = info_split[1] + assert(dev_info.match("^ IdUsage: +#{usage}$"), + "Unexpected device field 'usage' on USB drive '#{name}', '#{dev}'") + assert(dev_info.match("^ IdType: +#{fs_type}$"), + "Unexpected device field 'IdType' on USB drive '#{name}', '#{dev}'") + assert(part_info.match("^ Name: +#{part_label}$"), + "Unexpected partition label on USB drive '#{name}', '#{dev}'") + if part_type + assert(part_info.match("^ Type: +#{part_type}$"), + "Unexpected partition type on USB drive '#{name}', '#{dev}'") + end +end + +def tails_is_installed_helper(name, tails_root, loader) + disk_dev = $vm.disk_dev(name) + part_dev = disk_dev + "1" + check_disk_integrity(name, disk_dev, "gpt") + check_part_integrity(name, part_dev, "filesystem", "vfat", "Tails", + # EFI System Partition + 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b') + + target_root = "/mnt/new" + $vm.execute("mkdir -p #{target_root}") + $vm.execute("mount #{part_dev} #{target_root}") + + c = $vm.execute("diff -qr '#{tails_root}/live' '#{target_root}/live'") + assert(c.success?, + "USB drive '#{name}' has differences in /live:\n#{c.stdout}\n#{c.stderr}") + + syslinux_files = $vm.execute("ls -1 #{target_root}/syslinux").stdout.chomp.split + # We deal with these files separately + ignores = ["syslinux.cfg", "exithelp.cfg", "ldlinux.c32", "ldlinux.sys"] + for f in syslinux_files - ignores do + c = $vm.execute("diff -q '#{tails_root}/#{loader}/#{f}' " + + "'#{target_root}/syslinux/#{f}'") + assert(c.success?, "USB drive '#{name}' has differences in " + + "'/syslinux/#{f}'") + end + + # The main .cfg is named differently vs isolinux + c = $vm.execute("diff -q '#{tails_root}/#{loader}/#{loader}.cfg' " + + "'#{target_root}/syslinux/syslinux.cfg'") + assert(c.success?, "USB drive '#{name}' has differences in " + + "'/syslinux/syslinux.cfg'") + + $vm.execute("umount #{target_root}") + $vm.execute("sync") +end + +Then /^the running Tails is installed on USB drive "([^"]+)"$/ do |target_name| + loader = boot_device_type == "usb" ? "syslinux" : "isolinux" + tails_is_installed_helper(target_name, "/lib/live/mount/medium", loader) +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}") + tails_is_installed_helper(target_name, iso_root, "isolinux") + $vm.execute("umount #{iso_root}") +end + +Then /^there is no persistence partition on USB drive "([^"]+)"$/ do |name| + data_part_dev = $vm.disk_dev(name) + "2" + assert(!$vm.execute("test -b #{data_part_dev}").success?, + "USB drive #{name} has a partition '#{data_part_dev}'") +end + +Then /^a Tails persistence partition exists on USB drive "([^"]+)"$/ do |name| + dev = $vm.disk_dev(name) + "2" + check_part_integrity(name, dev, "crypto", "crypto_LUKS", "TailsData") + + # 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/") + if c.success? + for candidate in c.stdout.split("\n") + 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 + end + end + end + if luks_dev.nil? + c = $vm.execute("echo #{@persistence_password} | " + + "cryptsetup luksOpen #{dev} #{name}") + assert(c.success?, "Couldn't open LUKS device '#{dev}' on drive '#{name}'") + luks_dev = "/dev/mapper/#{name}" + end + + # Adapting check_part_integrity() seems like a bad idea so here goes + info = $vm.execute("udisksctl info --block-device '#{luks_dev}'").stdout + assert info.match("^ CryptoBackingDevice: +'/[a-zA-Z0-9_/]+'$") + assert info.match("^ IdUsage: +filesystem$") + assert info.match("^ IdType: +ext[34]$") + assert info.match("^ IdLabel: +TailsData$") + + mount_dir = "/mnt/#{name}" + $vm.execute("mkdir -p #{mount_dir}") + c = $vm.execute("mount #{luks_dev} #{mount_dir}") + assert(c.success?, + "Couldn't mount opened LUKS device '#{dev}' on drive '#{name}'") + + $vm.execute("umount #{mount_dir}") + $vm.execute("sync") + $vm.execute("cryptsetup luksClose #{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) +end + +def tails_persistence_enabled? + persistence_state_file = "/var/lib/live/config/tails.persistence" + return $vm.execute("test -e '#{persistence_state_file}'").success? && + $vm.execute(". '#{persistence_state_file}' && " + + 'test "$TAILS_PERSISTENCE_ENABLED" = true').success? +end + +Given /^all persistence presets(| from the old Tails version) are enabled$/ do |old_tails| + try_for(120, :msg => "Persistence is disabled") do + tails_persistence_enabled? + end + # Check that all persistent directories are mounted + if old_tails.empty? + expected_mounts = persistent_mounts + else + assert_not_nil($remembered_persistence_mounts) + expected_mounts = $remembered_persistence_mounts + end + mount = $vm.execute("mount").stdout.chomp + for _, dir in expected_mounts do + assert(mount.include?("on #{dir} "), + "Persistent directory '#{dir}' is not 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 + boot_dev_id = $vm.execute("udevadm info --device-id-of-file=/lib/live/mount/medium").stdout.chomp + boot_dev = $vm.execute("readlink -f /dev/block/'#{boot_dev_id}'").stdout.chomp + return boot_dev +end + +def device_info(dev) + # Approach borrowed from + # config/chroot_local_includes/lib/live/config/998-permissions + info = $vm.execute("udevadm info --query=property --name='#{dev}'").stdout.chomp + info.split("\n").map { |e| e.split('=') } .to_h +end + +def boot_device_type + device_info(boot_device)['ID_BUS'] +end + +Then /^Tails is running from (.*) drive "([^"]+)"$/ do |bus, name| + bus = bus.downcase + case bus + when "ide" + 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, + "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)") +end + +Then /^the boot device has safe access rights$/ do + + super_boot_dev = boot_device.sub(/[[:digit:]]+$/, "") + devs = $vm.execute("ls -1 #{super_boot_dev}*").stdout.chomp.split + assert(devs.size > 0, "Could not determine boot device") + all_users = $vm.execute("cut -d':' -f1 /etc/passwd").stdout.chomp.split + all_users_with_groups = all_users.collect do |user| + groups = $vm.execute("groups #{user}").stdout.chomp.sub(/^#{user} : /, "").split(" ") + [user, groups] + end + for dev in devs do + dev_owner = $vm.execute("stat -c %U #{dev}").stdout.chomp + dev_group = $vm.execute("stat -c %G #{dev}").stdout.chomp + dev_perms = $vm.execute("stat -c %a #{dev}").stdout.chomp + assert_equal("root", dev_owner) + assert(dev_group == "disk" || dev_group == "root", + "Boot device '#{dev}' owned by group '#{dev_group}', expected " + + "'disk' or 'root'.") + assert_equal("660", dev_perms) + for user, groups in all_users_with_groups do + next if user == "root" + assert(!(groups.include?(dev_group)), + "Unprivileged user '#{user}' is in group '#{dev_group}' which " + + "owns boot device '#{dev}'") + end + end + + info = $vm.execute("udisksctl info --block-device '#{super_boot_dev}'").stdout + assert(info.match("^ HintSystem: +true$"), + "Boot device '#{super_boot_dev}' is not system internal for udisks") +end + +Then /^all persistent filesystems have safe access rights$/ do + persistent_volumes_mountpoints.each do |mountpoint| + fs_owner = $vm.execute("stat -c %U #{mountpoint}").stdout.chomp + fs_group = $vm.execute("stat -c %G #{mountpoint}").stdout.chomp + fs_perms = $vm.execute("stat -c %a #{mountpoint}").stdout.chomp + assert_equal("root", fs_owner) + assert_equal("root", fs_group) + assert_equal('775', fs_perms) + end +end + +Then /^all persistence configuration files have safe access rights$/ do + persistent_volumes_mountpoints.each do |mountpoint| + assert($vm.execute("test -e #{mountpoint}/persistence.conf").success?, + "#{mountpoint}/persistence.conf does not exist, while it should") + assert($vm.execute("test ! -e #{mountpoint}/live-persistence.conf").success?, + "#{mountpoint}/live-persistence.conf does exist, while it should not") + $vm.execute( + "ls -1 #{mountpoint}/persistence.conf #{mountpoint}/live-*.conf" + ).stdout.chomp.split.each do |f| + file_owner = $vm.execute("stat -c %U '#{f}'").stdout.chomp + file_group = $vm.execute("stat -c %G '#{f}'").stdout.chomp + file_perms = $vm.execute("stat -c %a '#{f}'").stdout.chomp + assert_equal("tails-persistence-setup", file_owner) + assert_equal("tails-persistence-setup", file_group) + assert_equal("600", file_perms) + end + end +end + +Then /^all persistent directories(| from the old Tails version) have safe access rights$/ do |old_tails| + if old_tails.empty? + expected_dirs = persistent_dirs + else + assert_not_nil($remembered_persistence_dirs) + expected_dirs = $remembered_persistence_dirs + end + persistent_volumes_mountpoints.each do |mountpoint| + expected_dirs.each do |src, dest| + full_src = "#{mountpoint}/#{src}" + assert_vmcommand_success $vm.execute("test -d #{full_src}") + dir_perms = $vm.execute_successfully("stat -c %a '#{full_src}'").stdout.chomp + dir_owner = $vm.execute_successfully("stat -c %U '#{full_src}'").stdout.chomp + if dest.start_with?("/home/#{LIVE_USER}") + expected_perms = "700" + expected_owner = LIVE_USER + else + expected_perms = "755" + expected_owner = "root" + end + assert_equal(expected_perms, dir_perms, + "Persistent source #{full_src} has permission " \ + "#{dir_perms}, expected #{expected_perms}") + assert_equal(expected_owner, dir_owner, + "Persistent source #{full_src} has owner " \ + "#{dir_owner}, expected #{expected_owner}") + end + end +end + +When /^I write some files expected to persist$/ do + persistent_mounts.each do |_, dir| + owner = $vm.execute("stat -c %U #{dir}").stdout.chomp + assert($vm.execute("touch #{dir}/XXX_persist", :user => owner).success?, + "Could not create file in persistent directory #{dir}") + end +end + +When /^I remove some files expected to persist$/ do + persistent_mounts.each do |_, dir| + owner = $vm.execute("stat -c %U #{dir}").stdout.chomp + assert($vm.execute("rm #{dir}/XXX_persist", :user => owner).success?, + "Could not remove file in persistent directory #{dir}") + end +end + +When /^I write some files not expected to persist$/ do + persistent_mounts.each do |_, dir| + owner = $vm.execute("stat -c %U #{dir}").stdout.chomp + assert($vm.execute("touch #{dir}/XXX_gone", :user => owner).success?, + "Could not create file in persistent directory #{dir}") + end +end + +When /^I take note of which persistence presets are available$/ do + $remembered_persistence_mounts = persistent_mounts + $remembered_persistence_dirs = persistent_dirs +end + +Then /^the expected persistent files(| created with the old Tails version) are present in the filesystem$/ do |old_tails| + if old_tails.empty? + expected_mounts = persistent_mounts + else + assert_not_nil($remembered_persistence_mounts) + expected_mounts = $remembered_persistence_mounts + end + expected_mounts.each do |_, dir| + assert($vm.execute("test -e #{dir}/XXX_persist").success?, + "Could not find expected file in persistent directory #{dir}") + assert(!$vm.execute("test -e #{dir}/XXX_gone").success?, + "Found file that should not have persisted in persistent directory #{dir}") + end +end + +Then /^only the expected files are present on the persistence partition on USB drive "([^"]+)"$/ do |name| + assert(!$vm.is_running?) + disk = { + :path => $vm.storage.disk_path(name), + :opts => { + :format => $vm.storage.disk_format(name), + :readonly => true + } + } + $vm.storage.guestfs_disk_helper(disk) do |g, disk_handle| + partitions = g.part_list(disk_handle).map do |part_desc| + disk_handle + part_desc["part_num"].to_s + end + partition = partitions.find do |part| + g.blkid(part)["PART_ENTRY_NAME"] == "TailsData" + end + assert_not_nil(partition, "Could not find the 'TailsData' partition " \ + "on disk '#{disk_handle}'") + luks_mapping = File.basename(partition) + "_unlocked" + g.luks_open(partition, @persistence_password, luks_mapping) + luks_dev = "/dev/mapper/#{luks_mapping}" + mount_point = "/" + g.mount(luks_dev, mount_point) + assert_not_nil($remembered_persistence_mounts) + $remembered_persistence_mounts.each do |dir, _| + # Guestfs::exists may have a bug; if the file exists, 1 is + # returned, but if it doesn't exist false is returned. It seems + # the translation of C types into Ruby types is glitchy. + assert(g.exists("/#{dir}/XXX_persist") == 1, + "Could not find expected file in persistent directory #{dir}") + assert(g.exists("/#{dir}/XXX_gone") != 1, + "Found file that should not have persisted in persistent directory #{dir}") + end + g.umount(mount_point) + g.luks_close(luks_dev) + end +end + +When /^I delete the persistent partition$/ do + step 'I start "DeletePersistentVolume" via the GNOME "Tails" applications menu' + @screen.wait("PersistenceWizardDeletionStart.png", 20) + @screen.type(" ") + @screen.wait("PersistenceWizardDone.png", 120) +end + +Then /^Tails has started in UEFI mode$/ do + assert($vm.execute("test -d /sys/firmware/efi").success?, + "/sys/firmware/efi does not exist") + end + +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) +end + +Then /^the "(?:[^"]+)" USB drive is selected$/ do + @screen.wait("TailsInstallerQEMUHardDisk.png", 30) +end + +Then /^no USB drive is selected$/ do + @screen.wait("TailsInstallerNoQEMUHardDisk.png", 30) +end diff --git a/cucumber/features/support/config.rb b/cucumber/features/support/config.rb new file mode 100644 index 00000000..25c107b4 --- /dev/null +++ b/cucumber/features/support/config.rb @@ -0,0 +1,100 @@ +require 'fileutils' +require 'yaml' +require "features/support/helpers/misc_helpers.rb" + +# These files deal with options like some of the settings passed +# to the `run_test_suite` script, and "secrets" like credentials +# (passwords, SSH keys) to be used in tests. +CONFIG_DIR = "/srv/jenkins/features/config" +DEFAULTS_CONFIG_FILE = "#{CONFIG_DIR}/defaults.yml" +LOCAL_CONFIG_FILE = "#{CONFIG_DIR}/local.yml" +LOCAL_CONFIG_DIRS_FILES_GLOB = "#{CONFIG_DIR}/*.d/*.yml" + +# Dynamic +$tails_iso = ENV['ISO'] || get_newest_iso +$old_tails_iso = ENV['OLD_ISO'] || get_oldest_iso +$tmp_dir = ENV['PWD'] +$vm_xml_path = ENV['VM_XML_PATH'] +$misc_files_dir = "features/misc_files" +$keep_snapshots = !ENV['KEEP_SNAPSHOTS'].nil? +$x_display = ENV['DISPLAY'] +$debug = !ENV['DEBUG'].nil? +$pause_on_fail = !ENV['PAUSE_ON_FAIL'].nil? +$time_at_start = Time.now +$live_user = "user" +$sikuli_retry_findfailed = !ENV['SIKULI_RETRY_FINDFAILED'].nil? + +assert File.exists?(DEFAULTS_CONFIG_FILE) +$config = YAML.load(File.read(DEFAULTS_CONFIG_FILE)) +config_files = Dir.glob(LOCAL_CONFIG_DIRS_FILES_GLOB).sort +config_files.insert(0, LOCAL_CONFIG_FILE) if File.exists?(LOCAL_CONFIG_FILE) +config_files.each do |config_file| + yaml_struct = YAML.load(File.read(config_file)) || Hash.new + if not(yaml_struct.instance_of?(Hash)) + raise "Local configuration file '#{config_file}' is malformed" + end + $config.merge!(yaml_struct) +end +# Options passed to the `run_test_suite` script will always take +# precedence. The way we import these keys is only safe for values +# with types boolean or string. If we need more, we'll have to invoke +# YAML's type autodetection on ENV some how. +$config.merge!(ENV) + +# Export TMPDIR back to the environment for subprocesses that we start +# (e.g. guestfs). Note that this export will only make a difference if +# TMPDIR wasn't already set and --tmpdir wasn't passed, i.e. only when +# we use the default. +ENV['TMPDIR'] = $config['TMPDIR'] + +# Dynamic constants initialized through the environment or similar, +# e.g. options we do not want to be configurable through the YAML +# configuration files. +DEBUG_LOG_PSEUDO_FIFO = "#{$config["TMPDIR"]}/debug_log_pseudo_fifo" +DISPLAY = ENV['DISPLAY'] +GIT_DIR = ENV['PWD'] +KEEP_SNAPSHOTS = !ENV['KEEP_SNAPSHOTS'].nil? +LIVE_USER = "live_user" +TAILS_ISO = ENV['ISO'] +OLD_TAILS_ISO = ENV['OLD_ISO'] || TAILS_ISO +TIME_AT_START = Time.now +loop do + ARTIFACTS_DIR = $config['TMPDIR'] + "/results" + if not(File.exist?(ARTIFACTS_DIR)) + FileUtils.mkdir_p(ARTIFACTS_DIR) + break + end +end + +# Constants that are statically initialized. +CONFIGURED_KEYSERVER_HOSTNAME = 'hkps.pool.sks-keyservers.net' +LIBVIRT_DOMAIN_NAME = "DebianToaster" +LIBVIRT_DOMAIN_UUID = "203552d5-819c-41f3-800e-2c8ef2545404" +LIBVIRT_NETWORK_NAME = "DebianToasterNet" +LIBVIRT_NETWORK_UUID = "f2305af3-2a64-4f16-afe6-b9dbf02a597e" +MISC_FILES_DIR = "/srv/jenkins/features/misc_files" +SERVICES_EXPECTED_ON_ALL_IFACES = + [ + ["cupsd", "0.0.0.0", "631"], + ["dhclient", "0.0.0.0", "*"] + ] +# 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/features/domains" + +#TAILS_SIGNING_KEY = cmd_helper(". #{Dir.pwd}/config/amnesia; echo ${AMNESIA_DEV_KEYID}").tr(' ', '').chomp +TAILS_DEBIAN_REPO_KEY = "221F9A3C6FA3E09E182E060BC7988EA7A358D82E" diff --git a/cucumber/features/support/env.rb b/cucumber/features/support/env.rb new file mode 100644 index 00000000..2e17ae76 --- /dev/null +++ b/cucumber/features/support/env.rb @@ -0,0 +1,90 @@ +require 'rubygems' +require "features/support/extra_hooks.rb" +require 'time' +require 'rspec' + +# Force UTF-8. Ruby will default to the system locale, and if it is +# non-UTF-8, String-methods will fail when operating on non-ASCII +# strings. +Encoding.default_external = Encoding::UTF_8 +Encoding.default_internal = Encoding::UTF_8 + +def fatal_system(str) + unless system(str) + raise StandardError.new("Command exited with #{$?}") + end +end + +def git_exists? + File.exists? '.git' +end + +def create_git + Dir.mkdir 'config' + FileUtils.touch('config/base_branch') + Dir.mkdir('config/APT_overlays.d') + Dir.mkdir 'debian' + File.open('debian/changelog', 'w') do |changelog| + changelog.write(<<END_OF_CHANGELOG) +tails (0) stable; urgency=low + + * First release. + + -- Tails developers <tails@boum.org> Mon, 30 Jan 2012 01:00:00 +0000 +END_OF_CHANGELOG + end + + fatal_system "git init --quiet" + fatal_system "git config user.email 'tails@boum.org'" + fatal_system "git config user.name 'Tails developers'" + fatal_system "git add debian/changelog" + fatal_system "git commit --quiet debian/changelog -m 'First release'" + fatal_system "git branch -M stable" + fatal_system "git branch testing stable" + fatal_system "git branch devel stable" + fatal_system "git branch feature/jessie devel" +end + +def current_branch + cmd = 'git rev-parse --symbolic-full-name --abbrev-ref HEAD'.split + branch = cmd_helper(cmd).strip + assert_not_equal("HEAD", branch, "We are in 'detached HEAD' state") + return branch +end + +# In order: if git HEAD is tagged, return its name; if a branch is +# checked out, return its name; otherwise we are in 'detached HEAD' +# state, and we return the empty string. +def describe_git_head + cmd_helper("git describe --tags --exact-match #{current_commit}".split).strip +rescue Test::Unit::AssertionFailedError + begin + current_branch + rescue Test::Unit::AssertionFailedError + "" + end +end + +def current_commit + cmd_helper('git rev-parse HEAD'.split).strip +end + +def current_short_commit + current_commit[0, 7] +end + +RSpec::Matchers.define :have_suite do |suite| + match do |string| + # e.g.: `deb http://deb.tails.boum.org/ 0.10 main contrib non-free` + %r{^deb +http://deb\.tails\.boum\.org/ +#{Regexp.escape(suite)} main}.match(string) + end + failure_message_for_should do |string| + "expected the sources to include #{suite}\nCurrent sources : #{string}" + end + failure_message_for_should_not do |string| + "expected the sources to exclude #{suite}\nCurrent sources : #{string}" + end + description do + "expected an output with #{suite}" + end +end diff --git a/cucumber/features/support/extra_hooks.rb b/cucumber/features/support/extra_hooks.rb new file mode 100644 index 00000000..16196a55 --- /dev/null +++ b/cucumber/features/support/extra_hooks.rb @@ -0,0 +1,165 @@ +# 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' + 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) + end + end + end +rescue NameError => e + raise e if e.to_s != "uninitialized constant Cucumber::Core" +end + +# Sort of inspired by Cucumber::RbSupport::RbHook (from cucumber +# < 2.0) but really we just want an object with a 'tag_expressions' +# attribute to make accept_hook?() (used below) happy. +class SimpleHook + attr_reader :tag_expressions + + def initialize(tag_expressions, proc) + @tag_expressions = tag_expressions + @proc = proc + end + + def invoke(arg) + @proc.call(arg) + end +end + +def BeforeFeature(*tag_expressions, &block) + $before_feature_hooks ||= [] + $before_feature_hooks << SimpleHook.new(tag_expressions, block) +end + +def AfterFeature(*tag_expressions, &block) + $after_feature_hooks ||= [] + $after_feature_hooks << SimpleHook.new(tag_expressions, block) +end + +require 'cucumber/formatter/console' +if not($at_exit_print_artifacts_dir_patching_done) + module Cucumber::Formatter::Console + if method_defined?(:print_stats) + 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 + if self.class.method_defined?(:old_print_stats) + old_print_stats(*args) + end + end + end + $at_exit_print_artifacts_dir_patching_done = true +end + +def info_log(message = "", options = {}) + options[:color] = :clear + # This trick allows us to use a module's (~private) method on a + # one-off basis. + cucumber_console = Class.new.extend(Cucumber::Formatter::Console) + puts cucumber_console.format_string(message, options[:color]) +end + +def debug_log(message, options = {}) + $debug_log_fns.each { |fn| fn.call(message, options) } if $debug_log_fns +end + +require 'cucumber/formatter/pretty' +# Backport part of commit af940a8 from the cucumber-ruby repo. This +# fixes the "out hook output" for the Pretty formatter so stuff +# written via `puts` after a Scenario has run its last step will be +# written, instead of delayed to the next Feature/Scenario (if any) or +# dropped completely (if not). +# XXX: This can be removed once we stop supporting Debian Jessie +# around when Debian Stretch is released. +if Gem::Version.new(Cucumber::VERSION) < Gem::Version.new('2.0.0.beta.4') + module Cucumber + module Formatter + class Pretty + def after_feature_element(feature_element) + print_messages + @io.puts + @io.flush + end + end + end + end +end + +module ExtraFormatters + # This is a null formatter in the sense that it doesn't ever output + # anything. We only use it do hook into the correct events so we can + # add our extra hooks. + class ExtraHooks + def initialize(*args) + # We do not care about any of the arguments. + end + + def before_feature(feature) + if $before_feature_hooks + $before_feature_hooks.each do |hook| + hook.invoke(feature) if feature.accept_hook?(hook) + end + end + end + + def after_feature(feature) + if $after_feature_hooks + $after_feature_hooks.reverse.each do |hook| + hook.invoke(feature) if feature.accept_hook?(hook) + end + end + end + end + + # The pretty formatter with debug logging mixed into its output. + class PrettyDebug < Cucumber::Formatter::Pretty + def initialize(*args) + super(*args) + $debug_log_fns ||= [] + $debug_log_fns << self.method(:debug_log) + end + + def debug_log(message, options) + options[:color] ||= :blue + @io.puts(format_string(message, options[:color])) + @io.flush + end + end + +end + +module Cucumber + module Cli + class Options + BUILTIN_FORMATS['pretty_debug'] = + [ + 'ExtraFormatters::PrettyDebug', + 'Prints the feature with debugging information - in colours.' + ] + BUILTIN_FORMATS['debug'] = BUILTIN_FORMATS['pretty_debug'] + end + end +end + +AfterConfiguration do |config| + # Cucumber may read this file multiple times, and hence run this + # 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)) +end diff --git a/cucumber/features/support/helpers/chatbot_helper.rb b/cucumber/features/support/helpers/chatbot_helper.rb new file mode 100644 index 00000000..23ce3e1a --- /dev/null +++ b/cucumber/features/support/helpers/chatbot_helper.rb @@ -0,0 +1,59 @@ +require 'tempfile' + +class ChatBot + + def initialize(account, password, otr_key, opts = Hash.new) + @account = account + @password = password + @otr_key = otr_key + @opts = opts + @pid = nil + @otr_key_file = nil + end + + def start + @otr_key_file = Tempfile.new("otr_key.", $config["TMPDIR"]) + @otr_key_file << @otr_key + @otr_key_file.close + + cmd_helper(['/usr/bin/convertkey', @otr_key_file.path]) + cmd_helper(["mv", "#{@otr_key_file.path}3", @otr_key_file.path]) + + cmd = [ + "#{GIT_DIR}/features/scripts/otr-bot.py", + @account, + @password, + @otr_key_file.path + ] + cmd += ["--connect-server", @opts["connect_server"]] if @opts["connect_server"] + cmd += ["--auto-join"] + @opts["auto_join"] if @opts["auto_join"] + cmd += ["--log-file", DEBUG_LOG_PSEUDO_FIFO] + + job = IO.popen(cmd) + @pid = job.pid + end + + def stop + @otr_key_file.delete + begin + Process.kill("TERM", @pid) + rescue + # noop + end + end + + def active? + begin + ret = Process.kill(0, @pid) + rescue Errno::ESRCH => e + if e.message == "No such process" + return false + else + raise e + end + end + assert_equal(1, ret, "This shouldn't happen") + return true + end + +end diff --git a/cucumber/features/support/helpers/ctcp_helper.rb b/cucumber/features/support/helpers/ctcp_helper.rb new file mode 100644 index 00000000..ee5180ab --- /dev/null +++ b/cucumber/features/support/helpers/ctcp_helper.rb @@ -0,0 +1,126 @@ +require 'net/irc' +require 'timeout' + +class CtcpChecker < Net::IRC::Client + + CTCP_SPAM_DELAY = 5 + + # `spam_target`: the nickname of the IRC user to CTCP spam. + # `ctcp_cmds`: the Array of CTCP commands to send. + # `expected_ctcp_replies`: Hash where the keys are the exact set of replies + # we expect, and their values a regex the reply data must match. + def initialize(host, port, spam_target, ctcp_cmds, expected_ctcp_replies) + @spam_target = spam_target + @ctcp_cmds = ctcp_cmds + @expected_ctcp_replies = expected_ctcp_replies + nickname = self.class.random_irc_nickname + opts = { + :nick => nickname, + :user => nickname, + :real => nickname, + } + opts[:logger] = Logger.new(DEBUG_LOG_PSEUDO_FIFO) + super(host, port, opts) + end + + # Makes sure that only the expected CTCP replies are received. + def verify_ctcp_responses + @sent_ctcp_cmds = Set.new + @received_ctcp_replies = Set.new + + # Give 60 seconds for connecting to the server and other overhead + # beyond the expected time to spam all CTCP commands. + expected_ctcp_spam_time = @ctcp_cmds.length * CTCP_SPAM_DELAY + timeout = expected_ctcp_spam_time + 60 + + begin + Timeout::timeout(timeout) do + start + end + rescue Timeout::Error + # Do nothing as we'll check for errors below. + ensure + finish + end + + ctcp_cmds_not_sent = @ctcp_cmds - @sent_ctcp_cmds.to_a + expected_ctcp_replies_not_received = + @expected_ctcp_replies.keys - @received_ctcp_replies.to_a + + if !ctcp_cmds_not_sent.empty? || !expected_ctcp_replies_not_received.empty? + raise "Failed to spam all CTCP commands and receive the expected " + + "replies within #{timeout} seconds.\n" + + (ctcp_cmds_not_sent.empty? ? "" : + "CTCP commands not sent: #{ctcp_cmds_not_sent}\n") + + (expected_ctcp_replies_not_received.empty? ? "" : + "Expected CTCP replies not received: " + + expected_ctcp_replies_not_received.to_s) + end + + end + + # Generate a random IRC nickname, in this case an alpha-numeric + # string with length 10 to 15. To make it legal, the first character + # is forced to be alpha. + def self.random_irc_nickname + random_alpha_string(1) + random_alnum_string(9, 14) + end + + def spam(spam_target) + post(NOTICE, spam_target, "Hi! I'm gonna test your CTCP capabilities now.") + @ctcp_cmds.each do |cmd| + sleep CTCP_SPAM_DELAY + full_cmd = cmd + case cmd + when "PING" + full_cmd += " #{Time.now.to_i}" + when "ACTION" + full_cmd += " barfs on the floor." + when "ERRMSG" + full_cmd += " Pidgin should not respond to this." + end + post(PRIVMSG, spam_target, ctcp_encode(full_cmd)) + @sent_ctcp_cmds << cmd + end + end + + def on_rpl_welcome(m) + super + Thread.new { spam(@spam_target) } + end + + def on_message(m) + if m.command == ERR_NICKNAMEINUSE + finish + new_nick = self.class.random_irc_nickname + @opts.marshal_load({ + :nick => new_nick, + :user => new_nick, + :real => new_nick, + }) + start + return + end + + if m.ctcp? and /^:#{Regexp.escape(@spam_target)}!/.match(m) + m.ctcps.each do |ctcp_reply| + reply_type, _, reply_data = ctcp_reply.partition(" ") + if @expected_ctcp_replies.has_key?(reply_type) + if @expected_ctcp_replies[reply_type].match(reply_data) + @received_ctcp_replies << reply_type + else + raise "Received expected CTCP reply '#{reply_type}' but with " + + "unexpected data '#{reply_data}' " + end + else + raise "Received unexpected CTCP reply '#{reply_type}' with " + + "data '#{reply_data}'" + end + end + end + if Set.new(@ctcp_cmds) == @sent_ctcp_cmds && \ + Set.new(@expected_ctcp_replies.keys) == @received_ctcp_replies + finish + end + end +end diff --git a/cucumber/features/support/helpers/display_helper.rb b/cucumber/features/support/helpers/display_helper.rb new file mode 100644 index 00000000..b4dce733 --- /dev/null +++ b/cucumber/features/support/helpers/display_helper.rb @@ -0,0 +1,48 @@ + +class Display + + def initialize(domain, x_display) + @domain = domain + @x_display = x_display + end + + def active? + p = IO.popen(["xprop", "-display", @x_display, + "-name", "#{@domain} (1) - Virt Viewer", + :err => ["/dev/null", "w"]]) + Process.wait(p.pid) + $?.success? + end + + def start + @virtviewer = IO.popen(["virt-viewer", "--direct", + "--kiosk", + "--reconnect", + "--connect", "qemu:///system", + "--display", @x_display, + @domain, + :err => ["/dev/null", "w"]]) + # We wait for the display to be active to not lose actions + # (e.g. key presses via sikuli) that come immediately after + # starting (or restoring) a vm + try_for(20, { :delay => 0.1, :msg => "virt-viewer failed to start"}) { + active? + } + end + + def stop + return if @virtviewer.nil? + Process.kill("TERM", @virtviewer.pid) + @virtviewer.close + rescue IOError + # IO.pid throws this if the process wasn't started yet. Possibly + # there's a race when doing a start() and then quickly running + # stop(). + end + + def restart + stop + start + end + +end diff --git a/cucumber/features/support/helpers/exec_helper.rb b/cucumber/features/support/helpers/exec_helper.rb new file mode 100644 index 00000000..14e12269 --- /dev/null +++ b/cucumber/features/support/helpers/exec_helper.rb @@ -0,0 +1,79 @@ +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 = 90) + try_for(timeout, :msg => "Remote shell seems to be down") do + sleep(20) + Timeout::timeout(10) do + VMCommand.execute(vm, "echo 'true'") + 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 + #socket.puts(JSON.dump([type, options[:user], cmd])) + socket.puts( "\n") + sleep(1) + socket.puts( "\003") + sleep(1) + socket.puts( cmd + "\n") + sleep(1) + while socket.ready? + s = socket.readline(sep = "\n").chomp("\n") + debug_log("#{type} read: #{s}") if not(options[:spawn]) + if ('true' == s) then + break + end + end + ensure + socket.close + end + if ('true' == s) + return true + else + 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 new file mode 100644 index 00000000..fce363c5 --- /dev/null +++ b/cucumber/features/support/helpers/firewall_helper.rb @@ -0,0 +1,121 @@ +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 +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 + 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 + + # 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 + end + end + hosts.uniq + 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 + end + +end diff --git a/cucumber/features/support/helpers/misc_helpers.rb b/cucumber/features/support/helpers/misc_helpers.rb new file mode 100644 index 00000000..7e09411f --- /dev/null +++ b/cucumber/features/support/helpers/misc_helpers.rb @@ -0,0 +1,253 @@ +require 'date' +require 'timeout' +require 'test/unit' + +# Test::Unit adds an at_exit hook which, among other things, consumes +# the command-line arguments that were intended for cucumber. If +# e.g. `--format` was passed it will throw an error since it's not a +# valid option for Test::Unit, and it throwing an error at this time +# (at_exit) will make Cucumber think it failed and consequently exit +# with an error. Fooling Test::Unit that this hook has already run +# works around this craziness. +Test::Unit.run = true + +# Make all the assert_* methods easily accessible in any context. +include Test::Unit::Assertions + +def assert_vmcommand_success(p, msg = nil) + assert(p.success?, msg.nil? ? "Command failed: #{p.cmd}\n" + \ + "error code: #{p.returncode}\n" \ + "stderr: #{p.stderr}" : \ + msg) +end + +# It's forbidden to throw this exception (or subclasses) in anything +# but try_for() below. Just don't use it anywhere else! +class UniqueTryForTimeoutError < Exception +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. +def try_for(timeout, options = {}) + options[:delay] ||= 1 + last_exception = nil + # Create a unique exception used only for this particular try_for + # call's Timeout to allow nested try_for:s. If we used the same one, + # the innermost try_for would catch all outer ones', creating a + # really strange situation. + unique_timeout_exception = Class.new(UniqueTryForTimeoutError) + Timeout::timeout(timeout, unique_timeout_exception) do + loop do + begin + return if yield + rescue NameError, UniqueTryForTimeoutError => e + # NameError most likely means typos, and hiding that is rarely + # (never?) a good idea, so we rethrow them. See below why we + # also rethrow *all* the unique exceptions. + raise e + rescue Exception => e + # All other exceptions are ignored while trying the + # block. Well we save the last exception so we can print it in + # case of a timeout. + last_exception = e + end + sleep options[:delay] + end + end + # At this point the block above either succeeded and we'll return, + # or we are throwing an exception. If the latter, we either have a + # NameError that we'll not catch (and will any try_for below us in + # the stack), or we have a unique exception. That can mean one of + # two things: + # 1. it's the one unique to this try_for, and in that case we'll + # catch it, rethrowing it as something that will be ignored by + # inside the blocks of all try_for:s below us in the stack. + # 2. it's an exception unique to another try_for. Assuming that we + # do not throw the unique exceptions in any other place or way + # than we do it in this function, this means that there is a + # try_for below us in the stack to which this exception must be + # unique to. + # Let 1 be the base step, and 2 the inductive step, and we sort of + # an inductive proof for the correctness of try_for when it's + # nested. It shows that for an infinite stack of try_for:s, any of + # the unique exceptions will be caught only by the try_for instance + # it is unique to, and all try_for:s in between will ignore it so it + # ends up there immediately. +rescue unique_timeout_exception => e + msg = options[:msg] || 'try_for() timeout expired' + if last_exception + msg += "\nLast ignored exception was: " + + "#{last_exception.class}: #{last_exception}" + end + raise Timeout::Error.new(msg) +end + +class TorFailure < StandardError +end + +class MaxRetriesFailure < StandardError +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 +# given) and the intention with it is to bring us back to the state +# expected by the block, so it can be retried. +def retry_tor(recovery_proc = nil, &block) + tor_recovery_proc = Proc.new do + force_new_tor_circuit + recovery_proc.call if recovery_proc + end + + retry_action($config['MAX_NEW_TOR_CIRCUIT_RETRIES'], + :recovery_proc => tor_recovery_proc, + :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 + options[:operation_name] ||= 'Operation' + + retries = 1 + loop do + begin + block.call + return + rescue Exception => e + if retries <= max_retries + debug_log("#{options[:operation_name]} failed (Try #{retries} of " + + "#{max_retries}) with:\n" + + "#{e.class}: #{e.message}") + options[:recovery_proc].call if options[:recovery_proc] + retries += 1 + else + raise MaxRetriesFailure.new("#{options[:operation_name]} failed (despite retrying " + + "#{max_retries} times) with\n" + + "#{e.class}: #{e.message}") + end + end + end +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 +end + +def convert_bytes_mod(unit) + case unit + when "bytes", "b" then mod = 1 + when "KB" then mod = 10**3 + when "k", "KiB" then mod = 2**10 + when "MB" then mod = 10**6 + when "M", "MiB" then mod = 2**20 + when "GB" then mod = 10**9 + when "G", "GiB" then mod = 2**30 + when "TB" then mod = 10**12 + when "T", "TiB" then mod = 2**40 + else + raise "invalid memory unit '#{unit}'" + end + return mod +end + +def convert_to_bytes(size, unit) + return (size*convert_bytes_mod(unit)).to_i +end + +def convert_to_MiB(size, unit) + return (size*convert_bytes_mod(unit) / (2**20)).to_i +end + +def convert_from_bytes(size, unit) + return size.to_f/convert_bytes_mod(unit).to_f +end + +def cmd_helper(cmd) + if cmd.instance_of?(Array) + cmd << {:err => [:child, :out]} + elsif cmd.instance_of?(String) + cmd += " 2>&1" + end + IO.popen(cmd) do |p| + out = p.readlines.join("\n") + p.close + ret = $? + assert_equal(0, ret, "Command failed (returned #{ret}): #{cmd}:\n#{out}") + return out + end +end + +# 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 +end + +def get_free_space(machine, path) + case machine + when 'host' + assert(File.exists?(path), "Path '#{path}' not found on #{machine}.") + free = cmd_helper(["df", path]) + when 'guest' + assert($vm.file_exist?(path), "Path '#{path}' not found on #{machine}.") + free = $vm.execute_successfully("df '#{path}'") + else + raise 'Unsupported machine type #{machine} passed.' + end + output = free.split("\n").last + return output.match(/[^\s]\s+[0-9]+\s+[0-9]+\s+([0-9]+)\s+.*/)[1].chomp.to_i +end + +def random_string_from_set(set, min_len, max_len) + len = (min_len..max_len).to_a.sample + len ||= min_len + (0..len-1).map { |n| set.sample }.join +end + +def random_alpha_string(min_len, max_len = 0) + alpha_set = ('A'..'Z').to_a + ('a'..'z').to_a + random_string_from_set(alpha_set, min_len, max_len) +end + +def random_alnum_string(min_len, max_len = 0) + alnum_set = ('A'..'Z').to_a + ('a'..'z').to_a + (0..9).to_a.map { |n| n.to_s } + random_string_from_set(alnum_set, min_len, max_len) +end + +# Sanitize the filename from unix-hostile filename characters +def sanitize_filename(filename, options = {}) + options[:replacement] ||= '_' + bad_unix_filename_chars = Regexp.new("[^A-Za-z0-9_\\-.,+:]") + filename.gsub(bad_unix_filename_chars, options[:replacement]) +end + +def info_log_artifact_location(type, path) + if $config['ARTIFACTS_BASE_URI'] + # Remove any trailing slashes, we'll add one ourselves + base_url = $config['ARTIFACTS_BASE_URI'].gsub(/\/*$/, "") + path = "#{base_url}/#{File.basename(path)}" + end + info_log("#{type.capitalize}: #{path}") +end + +def pause(message = "Paused") + STDERR.puts + STDERR.puts "#{message} (Press ENTER to continue!)" + STDIN.gets +end diff --git a/cucumber/features/support/helpers/sikuli_helper.rb b/cucumber/features/support/helpers/sikuli_helper.rb new file mode 100644 index 00000000..486b0e2e --- /dev/null +++ b/cucumber/features/support/helpers/sikuli_helper.rb @@ -0,0 +1,213 @@ +require 'rjb' +require 'rjbextension' +$LOAD_PATH << ENV['SIKULI_HOME'] +require 'sikuli-script.jar' +Rjb::load + +package_members = [ + "java.io.FileOutputStream", + "java.io.PrintStream", + "java.lang.System", + "org.sikuli.script.Finder", + "org.sikuli.script.Key", + "org.sikuli.script.KeyModifier", + "org.sikuli.script.Location", + "org.sikuli.script.Match", + "org.sikuli.script.Pattern", + "org.sikuli.script.Region", + "org.sikuli.script.Screen", + "org.sikuli.script.Settings", + ] + +translations = Hash[ + "org.sikuli.script", "Sikuli", + "java.lang", "Java::Lang", + "java.io", "Java::Io", + ] + +for p in package_members + imported_class = Rjb::import(p) + package, ignore, class_name = p.rpartition(".") + next if ! translations.include? package + mod_name = translations[package] + mod = mod_name.split("::").inject(Object) do |parent_obj, child_name| + if parent_obj.const_defined?(child_name, false) + parent_obj.const_get(child_name, false) + else + child_obj = Module.new + parent_obj.const_set(child_name, child_obj) + end + end + mod.const_set(class_name, imported_class) +end + +# Bind Java's stdout to debug_log() via our magical pseudo fifo +# logger. +def bind_java_to_pseudo_fifo_logger + file_output_stream = Java::Io::FileOutputStream.new(DEBUG_LOG_PSEUDO_FIFO) + print_stream = Java::Io::PrintStream.new(file_output_stream) + Java::Lang::System.setOut(print_stream) +end + +def findfailed_hook(pic) + pause("FindFailed for: '#{pic}'") +end + +# Since rjb imports Java classes without creating a corresponding +# Ruby class (it's just an instance of Rjb_JavaProxy) we can't +# monkey patch any class, so additional methods must be added +# to each Screen object. +# +# All Java classes' methods are immediately available in the proxied +# Ruby classes, but care has to be given to match their type. For a +# list of methods, see: <http://doc.sikuli.org/javadoc/index.html>. +# The type "PRSML" is a union of Pattern, Region, Screen, Match and +# Location. +# +# Also, due to limitations in Ruby's syntax we can't do: +# def Sikuli::Screen.new +# so we work around it with the following vairable. +sikuli_script_proxy = Sikuli::Screen +$_original_sikuli_screen_new ||= Sikuli::Screen.method :new + +# For waitAny()/findAny() we are forced to throw this exception since +# Rjb::throw doesn't block until the Java exception has been received +# by Ruby, so strange things can happen. +class FindAnyFailed < StandardError +end + +def sikuli_script_proxy.new(*args) + s = $_original_sikuli_screen_new.call(*args) + + if $config["SIKULI_RETRY_FINDFAILED"] + # The usage of `_invoke()` below exemplifies how one can wrap + # around Java objects' methods when they're imported using RJB. It + # isn't pretty. The seconds argument is the parameter signature, + # which can be obtained by creating the intended Java object using + # RJB, and then calling its `java_methods` method. + + def s.wait(pic, time) + self._invoke('wait', 'Ljava.lang.Object;D', pic, time) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('wait', 'Ljava.lang.Object;D', pic, time) + end + + def s.find(pic) + self._invoke('find', 'Ljava.lang.Object;', pic) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('find', 'Ljava.lang.Object;', pic) + end + + def s.waitVanish(pic, time) + self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time) + end + + def s.click(pic) + self._invoke('click', 'Ljava.lang.Object;', pic) + rescue FindFailed => e + findfailed_hook(pic) + self._invoke('click', 'Ljava.lang.Object;', pic) + end + end + + def s.click_point(x, y) + self.click(Sikuli::Location.new(x, y)) + end + + def s.doubleClick_point(x, y) + self.doubleClick(Sikuli::Location.new(x, y)) + end + + def s.click_mid_right_edge(pic) + r = self.find(pic) + top_right = r.getTopRight() + x = top_right.getX + y = top_right.getY + r.getH/2 + self.click_point(x, y) + end + + def s.wait_and_click(pic, time) + self.click(self.wait(pic, time)) + end + + def s.wait_and_double_click(pic, time) + self.doubleClick(self.wait(pic, time)) + end + + def s.wait_and_right_click(pic, time) + self.rightClick(self.wait(pic, time)) + end + + def s.wait_and_hover(pic, time) + self.hover(self.wait(pic, time)) + end + + def s.existsAny(images) + images.each do |image| + region = self.exists(image) + return [image, region] if region + end + return nil + end + + def s.findAny(images) + images.each do |image| + begin + return [image, self.find(image)] + rescue FindFailed + # Ignore. We deal we'll throw an appropriate exception after + # having looped through all images and found none of them. + end + end + # If we've reached this point, none of the images could be found. + raise FindAnyFailed.new("can not find any of the images #{images} on the " + + "screen") + end + + def s.waitAny(images, time) + Timeout::timeout(time) do + loop do + result = self.existsAny(images) + return result if result + end + end + rescue Timeout::Error + raise FindAnyFailed.new("can not find any of the images #{images} on the " + + "screen") + end + + def s.hover_point(x, y) + self.hover(Sikuli::Location.new(x, y)) + end + + def s.hide_cursor + self.hover_point(self.w, self.h/2) + end + + s +end + +# Configure sikuli + +# ruby and rjb doesn't play well together when it comes to static +# fields (and possibly methods) so we instantiate and access the field +# via objects instead. It actually works inside this file, but when +# it's required from "outside", and the file has been completely +# required, ruby's require method complains that the method for the +# field accessor is missing. +sikuli_settings = Sikuli::Settings.new +sikuli_settings.OcrDataPath = $config["TMPDIR"] +# sikuli_ruby, which we used before, defaulted to 0.9 minimum +# similarity, so all our current images are adapted to that value. +# Also, Sikuli's default of 0.7 is simply too low (many false +# positives). +sikuli_settings.MinSimilarity = 0.9 +sikuli_settings.ActionLogs = true +sikuli_settings.DebugLogs = false +sikuli_settings.InfoLogs = true +sikuli_settings.ProfileLogs = true diff --git a/cucumber/features/support/helpers/sniffing_helper.rb b/cucumber/features/support/helpers/sniffing_helper.rb new file mode 100644 index 00000000..213411eb --- /dev/null +++ b/cucumber/features/support/helpers/sniffing_helper.rb @@ -0,0 +1,43 @@ +# +# Sniffer is a very dumb wrapper to start and stop tcpdumps instances, possibly +# with customized filters. Captured traffic is stored in files whose name +# depends on the sniffer name. The resulting captured packets for each sniffers +# can be accessed as an array through its `packets` method. +# +# Use of more rubyish internal ways to sniff a network like with pcap-able gems +# is waaay to much resource consumming, notmuch reliable and soooo slow. Let's +# not bother too much with that. :) +# +# Should put all that in a Module. + +class Sniffer + + attr_reader :name, :pcap_file, :pid + + def initialize(name, vmnet) + @name = name + @vmnet = vmnet + pcap_name = sanitize_filename("#{name}.pcap") + @pcap_file = "#{$config["TMPDIR"]}/#{pcap_name}" + 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"]]) + @pid = job.pid + end + + def stop + begin + Process.kill("TERM", @pid) + rescue + # noop + end + end + + def clear + if File.exist?(@pcap_file) + File.delete(@pcap_file) + end + end +end diff --git a/cucumber/features/support/helpers/sshd_helper.rb b/cucumber/features/support/helpers/sshd_helper.rb new file mode 100644 index 00000000..2e0069c0 --- /dev/null +++ b/cucumber/features/support/helpers/sshd_helper.rb @@ -0,0 +1,67 @@ +require 'tempfile' + +class SSHServer + def initialize(sshd_host, sshd_port, authorized_keys = nil) + @sshd_host = sshd_host + @sshd_port = sshd_port + @authorized_keys = authorized_keys + @pid = nil + end + + def start + @sshd_key_file = Tempfile.new("ssh_host_rsa_key", $config["TMPDIR"]) + # 'hack' to prevent ssh-keygen from prompting to overwrite the file + File.delete(@sshd_key_file.path) + cmd_helper(['ssh-keygen', '-t', 'rsa', '-N', "", '-f', "#{@sshd_key_file.path}"]) + @sshd_key_file.close + + sshd_config =<<EOF +Port #{@sshd_port} +ListenAddress #{@sshd_host} +UsePrivilegeSeparation no +HostKey #{@sshd_key_file.path} +Pidfile #{$config['TMPDIR']}/ssh.pid +EOF + + @sshd_config_file = Tempfile.new("sshd_config", $config["TMPDIR"]) + @sshd_config_file.write(sshd_config) + + if @authorized_keys + @authorized_keys_file = Tempfile.new("authorized_keys", $config['TMPDIR']) + @authorized_keys_file.write(@authorized_keys) + @authorized_keys_file.close + @sshd_config_file.write("AuthorizedKeysFile #{@authorized_keys_file.path}") + end + + @sshd_config_file.close + + cmd = ["/usr/sbin/sshd", "-4", "-f", @sshd_config_file.path, "-D"] + + job = IO.popen(cmd) + @pid = job.pid + end + + def stop + File.delete("#{@sshd_key_file.path}.pub") + File.delete("#{$config['TMPDIR']}/ssh.pid") + begin + Process.kill("TERM", @pid) + rescue + # noop + end + end + + def active? + begin + ret = Process.kill(0, @pid) + rescue Errno::ESRCH => e + if e.message == "No such process" + return false + else + raise e + end + end + assert_equal(1, ret, "This shouldn't happen") + return true + end +end diff --git a/cucumber/features/support/helpers/storage_helper.rb b/cucumber/features/support/helpers/storage_helper.rb new file mode 100644 index 00000000..21537a92 --- /dev/null +++ b/cucumber/features/support/helpers/storage_helper.rb @@ -0,0 +1,216 @@ +# Helper class for manipulating VM storage *volumes*, i.e. it deals +# only with creation of images and keeps a name => volume path lookup +# table (plugging drives or getting info of plugged devices is done in +# the VM class). We'd like better coupling, but given the ridiculous +# disconnect between Libvirt::StoragePool and Libvirt::Domain (hint: +# they have nothing with each other to do whatsoever) it's what makes +# sense. + +require 'libvirt' +require 'guestfs' +require 'rexml/document' +require 'etc' + +class VMStorage + + def initialize(virt, xml_path) + @virt = virt + @xml_path = xml_path + pool_xml = REXML::Document.new(File.read("#{@xml_path}/storage_pool.xml")) + pool_name = pool_xml.elements['pool/name'].text + @pool_path = "#{$config["TMPDIR"]}/#{pool_name}" + begin + @pool = @virt.lookup_storage_pool_by_name(pool_name) + rescue Libvirt::RetrieveError + @pool = nil + end + if @pool and not(KEEP_SNAPSHOTS) + VMStorage.clear_storage_pool(@pool) + @pool = nil + end + unless @pool + pool_xml.elements['pool/target/path'].text = @pool_path + @pool = @virt.define_storage_pool_xml(pool_xml.to_s) + if not(Dir.exists?(@pool_path)) + # We'd like to use @pool.build, which will just create the + # @pool_path directory, but it does so with root:root as owner + # (at least with libvirt 1.2.21-2). libvirt itself can handle + # that situation, but guestfs (at least with <= + # 1:1.28.12-1+b3) cannot when invoked by a non-root user, + # which we want to support. + FileUtils.mkdir(@pool_path) + FileUtils.chown(nil, 'libvirt-qemu', @pool_path) + FileUtils.chmod("ug+wrx", @pool_path) + end + end + @pool.create unless @pool.active? + @pool.refresh + end + + def VMStorage.clear_storage_pool_volumes(pool) + was_not_active = !pool.active? + if was_not_active + pool.create + end + pool.list_volumes.each do |vol_name| + vol = pool.lookup_volume_by_name(vol_name) + vol.delete + end + if was_not_active + pool.destroy + end + rescue + # Some of the above operations can fail if the pool's path was + # deleted by external means; let's ignore that. + end + + def VMStorage.clear_storage_pool(pool) + VMStorage.clear_storage_pool_volumes(pool) + pool.destroy if pool.active? + pool.undefine + end + + def clear_pool + VMStorage.clear_storage_pool(@pool) + end + + def clear_volumes + VMStorage.clear_storage_pool_volumes(@pool) + end + + def delete_volume(name) + @pool.lookup_volume_by_name(name).delete + end + + def create_new_disk(name, options = {}) + options[:size] ||= 2 + options[:unit] ||= "GiB" + options[:type] ||= "qcow2" + # Require 'slightly' more space to be available to give a bit more leeway + # with rounding, temp file creation, etc. + reserved = 500 + needed = convert_to_MiB(options[:size].to_i, options[:unit]) + avail = convert_to_MiB(get_free_space('host', @pool_path), "KiB") + assert(avail - reserved >= needed, + "Error creating disk \"#{name}\" in \"#{@pool_path}\". " \ + "Need #{needed} MiB but only #{avail} MiB is available of " \ + "which #{reserved} MiB is reserved for other temporary files.") + begin + old_vol = @pool.lookup_volume_by_name(name) + rescue Libvirt::RetrieveError + # noop + else + old_vol.delete + end + uid = Etc::getpwnam("libvirt-qemu").uid + gid = Etc::getgrnam("libvirt-qemu").gid + vol_xml = REXML::Document.new(File.read("#{@xml_path}/volume.xml")) + vol_xml.elements['volume/name'].text = name + size_b = convert_to_bytes(options[:size].to_f, options[:unit]) + vol_xml.elements['volume/capacity'].text = size_b.to_s + vol_xml.elements['volume/target/format'].attributes["type"] = options[:type] + vol_xml.elements['volume/target/path'].text = "#{@pool_path}/#{name}" + vol_xml.elements['volume/target/permissions/owner'].text = uid.to_s + vol_xml.elements['volume/target/permissions/group'].text = gid.to_s + vol = @pool.create_volume_xml(vol_xml.to_s) + @pool.refresh + end + + def clone_to_new_disk(from, to) + begin + old_to_vol = @pool.lookup_volume_by_name(to) + rescue Libvirt::RetrieveError + # noop + else + old_to_vol.delete + end + from_vol = @pool.lookup_volume_by_name(from) + xml = REXML::Document.new(from_vol.xml_desc) + pool_path = REXML::Document.new(@pool.xml_desc).elements['pool/target/path'].text + xml.elements['volume/name'].text = to + xml.elements['volume/target/path'].text = "#{pool_path}/#{to}" + @pool.create_volume_xml_from(xml.to_s, from_vol) + end + + def disk_format(name) + vol = @pool.lookup_volume_by_name(name) + vol_xml = REXML::Document.new(vol.xml_desc) + return vol_xml.elements['volume/target/format'].attributes["type"] + end + + def disk_path(name) + @pool.lookup_volume_by_name(name).path + end + + def disk_mklabel(name, parttype) + disk = { + :path => disk_path(name), + :opts => { + :format => disk_format(name) + } + } + guestfs_disk_helper(disk) do |g, disk_handle| + g.part_init(disk_handle, parttype) + end + end + + 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| + g.part_disk(disk_handle, parttype) + g.part_set_name(disk_handle, 1, opts[:label]) if opts[:label] + primary_partition = g.list_partitions()[0] + if opts[:luks_password] + g.luks_format(primary_partition, opts[:luks_password], 0) + luks_mapping = File.basename(primary_partition) + "_unlocked" + g.luks_open(primary_partition, opts[:luks_password], luks_mapping) + luks_dev = "/dev/mapper/#{luks_mapping}" + g.mkfs(fstype, luks_dev) + g.luks_close(luks_dev) + else + g.mkfs(fstype, primary_partition) + end + end + end + + def disk_mkswap(name, parttype) + disk = { + :path => disk_path(name), + :opts => { + :format => disk_format(name) + } + } + guestfs_disk_helper(disk) do |g, disk_handle| + g.part_disk(disk_handle, parttype) + primary_partition = g.list_partitions()[0] + g.mkswap(primary_partition) + end + end + + def guestfs_disk_helper(*disks) + assert(block_given?) + g = Guestfs::Guestfs.new() + g.set_trace(1) + message_callback = Proc.new do |event, _, message, _| + debug_log("libguestfs: #{Guestfs.event_to_string(event)}: #{message}") + end + g.set_event_callback(message_callback, + Guestfs::EVENT_TRACE) + g.set_autosync(1) + disks.each do |disk| + g.add_drive_opts(disk[:path], disk[:opts]) + end + g.launch() + yield(g, *g.list_devices()) + ensure + g.close + end + +end diff --git a/cucumber/features/support/helpers/vm_helper.rb b/cucumber/features/support/helpers/vm_helper.rb new file mode 100644 index 00000000..6d7204d4 --- /dev/null +++ b/cucumber/features/support/helpers/vm_helper.rb @@ -0,0 +1,676 @@ +require 'libvirt' +require 'rexml/document' + +class ExecutionFailedInVM < StandardError +end + +class VMNet + + attr_reader :net_name, :net + + def initialize(virt, xml_path) + @virt = virt + @net_name = LIBVIRT_NETWORK_NAME + net_xml = File.read("#{xml_path}/default_net.xml") + rexml = REXML::Document.new(net_xml) + rexml.elements['network'].add_element('name') + rexml.elements['network/name'].text = @net_name + rexml.elements['network'].add_element('uuid') + rexml.elements['network/uuid'].text = LIBVIRT_NETWORK_UUID + update(rexml.to_s) + rescue Exception => e + destroy_and_undefine + raise e + end + + # We lookup by name so we also catch networks from previous test + # suite runs that weren't properly cleaned up (e.g. aborted). + def destroy_and_undefine + begin + old_net = @virt.lookup_network_by_name(@net_name) + old_net.destroy if old_net.active? + old_net.undefine + rescue + end + end + + def update(xml) + destroy_and_undefine + @net = @virt.define_network_xml(xml) + @net.create + end + + def bridge_name + @net.bridge_name + end + + def bridge_ip_addr + net_xml = REXML::Document.new(@net.xml_desc) + 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 +end + + +class VM + + attr_reader :domain, :display, :vmnet, :storage + + def initialize(virt, xml_path, vmnet, storage, x_display) + @virt = virt + @xml_path = xml_path + @vmnet = vmnet + @storage = storage + @domain_name = LIBVIRT_DOMAIN_NAME + default_domain_xml = File.read("#{@xml_path}/default.xml") + rexml = REXML::Document.new(default_domain_xml) + rexml.elements['domain'].add_element('name') + rexml.elements['domain/name'].text = @domain_name + rexml.elements['domain'].add_element('uuid') + rexml.elements['domain/uuid'].text = LIBVIRT_DOMAIN_UUID + update(rexml.to_s) + @display = Display.new(@domain_name, x_display) + set_cdrom_boot(TAILS_ISO) + plug_network + rescue Exception => e + destroy_and_undefine + raise e + end + + def update(xml) + destroy_and_undefine + @domain = @virt.define_domain_xml(xml) + end + + # We lookup by name so we also catch domains from previous test + # suite runs that weren't properly cleaned up (e.g. aborted). + def destroy_and_undefine + @display.stop if @display && @display.active? + begin + old_domain = @virt.lookup_domain_by_name(@domain_name) + old_domain.destroy if old_domain.active? + old_domain.undefine + rescue + end + end + + def real_mac + @vmnet.guest_real_mac + end + + def set_hardware_clock(time) + assert(not(is_running?), 'The hardware clock cannot be set when the ' + + 'VM is running') + assert(time.instance_of?(Time), "Argument must be of type 'Time'") + adjustment = (time - Time.now).to_i + domain_rexml = REXML::Document.new(@domain.xml_desc) + clock_rexml_element = domain_rexml.elements['domain'].add_element('clock') + clock_rexml_element.add_attributes('offset' => 'variable', + 'basis' => 'utc', + 'adjustment' => adjustment.to_s) + update(domain_rexml.to_s) + end + + def set_network_link_state(state) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/devices/interface/link'].attributes['state'] = state + if is_running? + @domain.update_device(domain_xml.elements['domain/devices/interface'].to_s) + else + update(domain_xml.to_s) + end + end + + def plug_network + set_network_link_state('up') + end + + def unplug_network + set_network_link_state('down') + end + + def set_boot_device(dev) + if is_running? + raise "boot settings can only be set for inactive vms" + end + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/os/boot'].attributes['dev'] = dev + update(domain_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 + end + end + + def remove_cdrom + set_cdrom_image(nil) + rescue Libvirt::Error => e + # While the CD-ROM is removed successfully we still get this + # error, so let's ignore it. + acceptable_error = + "Call to virDomainUpdateDeviceFlags failed: internal error: unable to " + + "execute QEMU command 'eject': (Tray of device '.*' is not open|" + + "Device '.*' is locked)" + raise e if not(Regexp.new(acceptable_error).match(e.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') + set_cdrom_image(image) + end + + def list_disk_devs + ret = [] + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + ret << e.elements['target'].attribute('dev').to_s + end + return ret + end + + def plug_drive(name, type) + if disk_plugged?(name) + raise "disk '#{name}' already plugged" + end + removable_usb = nil + case type + when "removable usb", "usb" + type = "usb" + removable_usb = "on" + when "non-removable usb" + type = "usb" + removable_usb = "off" + end + # Get the next free /dev/sdX on guest + letter = 'a' + dev = "sd" + letter + while list_disk_devs.include?(dev) + letter = (letter[0].ord + 1).chr + dev = "sd" + letter + end + assert letter <= 'z' + + xml = REXML::Document.new(File.read("#{@xml_path}/disk.xml")) + xml.elements['disk/source'].attributes['file'] = @storage.disk_path(name) + xml.elements['disk/driver'].attributes['type'] = @storage.disk_format(name) + xml.elements['disk/target'].attributes['dev'] = dev + xml.elements['disk/target'].attributes['bus'] = type + 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 + end + + def disk_xml_desc(name) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + begin + if e.elements['source'].attribute('file').to_s == @storage.disk_path(name) + return e.to_s + end + rescue + next + end + end + return nil + end + + def disk_rexml_desc(name) + xml = disk_xml_desc(name) + if xml + return REXML::Document.new(xml) + else + return nil + end + end + + def unplug_drive(name) + xml = disk_xml_desc(name) + @domain.detach_device(xml) + end + + def disk_type(dev) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + if e.elements['target'].attribute('dev').to_s == dev + return e.elements['driver'].attribute('type').to_s + end + end + raise "No such disk device '#{dev}'" + end + + def disk_dev(name) + rexml = disk_rexml_desc(name) or return nil + return "/dev/" + rexml.elements['disk/target'].attribute('dev').to_s + end + + def disk_name(dev) + dev = File.basename(dev) + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + if /^#{e.elements['target'].attribute('dev').to_s}/.match(dev) + return File.basename(e.elements['source'].attribute('file').to_s) + end + end + raise "No such disk device '#{dev}'" + end + + def udisks_disk_dev(name) + return disk_dev(name).gsub('/dev/', '/org/freedesktop/UDisks/devices/') + end + + def disk_detected?(name) + dev = disk_dev(name) or return false + return execute("test -b #{dev}").success? + end + + def disk_plugged?(name) + return not(disk_xml_desc(name).nil?) + end + + def set_disk_boot(name, type) + if is_running? + raise "boot settings can only be set for inactive vms" + end + plug_drive(name, type) 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 + end + + # XXX-9p: Shares don't work together with snapshot save+restore. See + # XXX-9p in common_steps.rb for more information. + def add_share(source, tag) + if is_running? + raise "shares can only be added to inactive vms" + end + # The complete source directory must be group readable by the user + # running the virtual machine, and world readable so the user inside + # the VM can access it (since we use the passthrough security model). + FileUtils.chown_R(nil, "libvirt-qemu", source) + FileUtils.chmod_R("go+rX", source) + xml = REXML::Document.new(File.read("#{@xml_path}/fs_share.xml")) + xml.elements['filesystem/source'].attributes['dir'] = source + xml.elements['filesystem/target'].attributes['dir'] = tag + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/devices'].add_element(xml) + update(domain_xml.to_s) + end + + def list_shares + list = [] + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/filesystem') do |e| + list << e.elements['target'].attribute('dir').to_s + end + return list + end + + def set_ram_size(size, unit = "KiB") + raise "System memory can only be added to 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" + end + if type == 'UEFI' + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements['domain/os'].add_element(REXML::Document.new( + '<loader>/usr/share/ovmf/OVMF.fd</loader>' + )) + update(domain_xml.to_s) + else + raise "unsupported OS loader type" + end + end + + def is_running? + begin + return @domain.active? + rescue + return false + end + end + + def execute(cmd, options = {}) + options[:user] ||= "root" + options[:spawn] ||= false + if options[:libs] + libs = options[:libs] + options.delete(:libs) + libs = [libs] if not(libs.methods.include? :map) + cmds = libs.map do |lib_name| + ". /usr/local/lib/tails-shell-library/#{lib_name}.sh" + end + cmds << cmd + cmd = cmds.join(" && ") + end + return VMCommand.new(self, cmd, options) + end + + def execute_successfully(*args) + p = execute(*args) + begin + assert_vmcommand_success(p) + rescue Test::Unit::AssertionFailedError => e + raise ExecutionFailedInVM.new(e) + end + return p + end + + def spawn(cmd, options = {}) + options[:spawn] = true + return execute(cmd, options) + end + + def wait_until_remote_shell_is_up(timeout = 90) + VMCommand.wait_until_remote_shell_is_up(self, timeout) + end + + def host_to_guest_time_sync + host_time= DateTime.now.strftime("%s").to_s + execute("date -s '@#{host_time}'").success? + end + + def has_network? + return execute("/sbin/ifconfig eth0 | grep -q 'inet addr'").success? + end + + def has_process?(process) + return execute("pidof -x -o '%PPID' " + process).success? + end + + def pidof(process) + return execute("pidof -x -o '%PPID' " + process).stdout.chomp.split + end + + def 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") + execute_successfully( + "xdotool set_desktop '#{desktop_number}'", + :user => user + ) + end + + def focus_window(window_title, user = LIVE_USER) + def do_focus(window_title, user) + execute_successfully( + "xdotool search --name '#{window_title}' windowactivate --sync", + :user => user + ) + end + + begin + do_focus(window_title, user) + rescue ExecutionFailedInVM + # 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) + select_virtual_desktop(0) + sleep 5 # there aren't any visual indicators which can be used here + do_focus(window_title, user) + end + end + + def file_exist?(file) + execute("test -e '#{file}'").success? + end + + def directory_exist?(directory) + execute("test -d '#{directory}'").success? + end + + def file_content(file, user = 'root') + # We don't quote #{file} on purpose: we sometimes pass environment variables + # or globs that we want to be interpreted by the shell. + cmd = execute("cat #{file}", :user => user) + assert(cmd.success?, + "Could not cat '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}") + return cmd.stdout + end + + def file_append(file, lines, user = 'root') + lines = lines.split("\n") if lines.class == String + lines.each do |line| + cmd = execute("echo '#{line}' >> '#{file}'", :user => user) + assert(cmd.success?, + "Could not append to '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}") + end + end + + def set_clipboard(text) + execute_successfully("echo -n '#{text}' | xsel --input --clipboard", + :user => LIVE_USER) + end + + def get_clipboard + execute_successfully("xsel --output --clipboard", :user => LIVE_USER).stdout + end + + def internal_snapshot_xml(name) + disk_devs = list_disk_devs + disks_xml = " <disks>\n" + for dev in disk_devs + snapshot_type = disk_type(dev) == "qcow2" ? 'internal' : 'no' + disks_xml += + " <disk name='#{dev}' snapshot='#{snapshot_type}'></disk>\n" + end + disks_xml += " </disks>" + return <<-EOF +<domainsnapshot> + <name>#{name}</name> + <description>Snapshot for #{name}</description> +#{disks_xml} + </domainsnapshot> +EOF + end + + def VM.ram_only_snapshot_path(name) + return "#{$config["TMPDIR"]}/#{name}-snapshot.memstate" + end + + def save_snapshot(name) + # If we have no qcow2 disk device, we'll use "memory state" + # snapshots, and if we have at least one qcow2 disk device, we'll + # use internal "system checkpoint" (memory + disks) snapshots. We + # have to do this since internal snapshots don't work when no + # such disk is available. We can do this with external snapshots, + # which are better in many ways, but libvirt doesn't know how to + # restore (revert back to) them yet. + # WARNING: If only transient disks, i.e. disks that were plugged + # after starting the domain, are used then the memory state will + # be dropped. External snapshots would also fix this. + internal_snapshot = false + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/disk') do |e| + if e.elements['driver'].attribute('type').to_s == "qcow2" + internal_snapshot = true + break + end + end + + # Note: In this case the "opposite" of `internal_snapshot` is not + # anything relating to external snapshots, but actually "memory + # state"(-only) snapshots. + if internal_snapshot + xml = internal_snapshot_xml(name) + @domain.snapshot_create_xml(xml) + else + snapshot_path = VM.ram_only_snapshot_path(name) + @domain.save(snapshot_path) + # For consistency with the internal snapshot case (which is + # "live", so the domain doesn't go down) we immediately restore + # the snapshot. + # Assumption: that *immediate* save + restore doesn't mess up + # with network state and similar, and is fast enough to not make + # the clock drift too much. + restore_snapshot(name) + end + end + + def restore_snapshot(name) + @domain.destroy if is_running? + @display.stop if @display and @display.active? + # See comment in save_snapshot() for details on why we use two + # different type of snapshots. + potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name) + if File.exist?(potential_ram_only_snapshot_path) + Libvirt::Domain::restore(@virt, potential_ram_only_snapshot_path) + @domain = @virt.lookup_domain_by_name(@domain_name) + else + begin + potential_internal_snapshot = @domain.lookup_snapshot_by_name(name) + @domain.revert_to_snapshot(potential_internal_snapshot) + rescue Libvirt::RetrieveError + raise "No such (internal nor external) snapshot #{name}" + end + end + @display.start + end + + def VM.remove_snapshot(name) + old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME) + potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name) + if File.exist?(potential_ram_only_snapshot_path) + File.delete(potential_ram_only_snapshot_path) + else + snapshot = old_domain.lookup_snapshot_by_name(name) + snapshot.delete + end + end + + def VM.snapshot_exists?(name) + return true if File.exist?(VM.ram_only_snapshot_path(name)) + old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME) + snapshot = old_domain.lookup_snapshot_by_name(name) + return snapshot != nil + rescue Libvirt::RetrieveError + return false + end + + def VM.remove_all_snapshots + Dir.glob("#{$config["TMPDIR"]}/*-snapshot.memstate").each do |file| + File.delete(file) + end + old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME) + old_domain.list_all_snapshots.each { |snapshot| snapshot.delete } + rescue Libvirt::RetrieveError + # No such domain, so no snapshots either. + end + + def start + return if is_running? + @domain.create + @display.start + end + + def reset + @domain.reset if is_running? + end + + def power_off + @domain.destroy if is_running? + @display.stop + end + + def take_screenshot(description) + @display.take_screenshot(description) + end + + def get_remote_shell_port + domain_xml = REXML::Document.new(@domain.xml_desc) + domain_xml.elements.each('domain/devices/serial') do |e| + if e.attribute('type').to_s == "tcp" + return e.elements['source'].attribute('service').to_s.to_i + end + end + end + +end diff --git a/cucumber/features/support/hooks.rb b/cucumber/features/support/hooks.rb new file mode 100644 index 00000000..b3bdecef --- /dev/null +++ b/cucumber/features/support/hooks.rb @@ -0,0 +1,280 @@ +require 'fileutils' +require 'rb-inotify' +require 'time' +require 'tmpdir' + +# Run once, before any feature +AfterConfiguration do |config| + # Reorder the execution of some features. As we progress through a + # run we accumulate more and more snapshots and hence use more and + # more disk space, but some features will leave nothing behind + # and/or possibly use large amounts of disk space temporarily for + # various reasons. By running these first we minimize the amount of + # disk space needed. + 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', + # 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', + ] + feature_files = config.feature_files + # The &-intersection is specified to keep the element ordering of + # the *left* operand. + intersection = prioritized_features & feature_files + if not intersection.empty? + feature_files -= intersection + feature_files = intersection + feature_files + config.define_singleton_method(:feature_files) { feature_files } + end + + # Used to keep track of when we start our first @product feature, when + # we'll do some special things. + $started_first_product_feature = false + + if File.exist?($config["TMPDIR"]) + if !File.directory?($config["TMPDIR"]) + raise "Temporary directory '#{$config["TMPDIR"]}' exists but is not a " + + "directory" + end + if !File.owned?($config["TMPDIR"]) + raise "Temporary directory '#{$config["TMPDIR"]}' must be owned by the " + + "current user" + end + FileUtils.chmod(0755, $config["TMPDIR"]) + else + begin + FileUtils.mkdir_p($config["TMPDIR"]) + rescue Errno::EACCES => e + raise "Cannot create temporary directory: #{e.to_s}" + end + end + + # Start a thread that monitors a pseudo fifo file and debug_log():s + # anything written to it "immediately" (well, as fast as inotify + # detects it). We're forced to a convoluted solution like this + # because CRuby's thread support is horribly as soon as IO is mixed + # in (other threads get blocked). + FileUtils.rm(DEBUG_LOG_PSEUDO_FIFO) if File.exist?(DEBUG_LOG_PSEUDO_FIFO) + FileUtils.touch(DEBUG_LOG_PSEUDO_FIFO) + at_exit do + FileUtils.rm(DEBUG_LOG_PSEUDO_FIFO) if File.exist?(DEBUG_LOG_PSEUDO_FIFO) + end + Thread.new do + File.open(DEBUG_LOG_PSEUDO_FIFO) do |fd| + watcher = INotify::Notifier.new + watcher.watch(DEBUG_LOG_PSEUDO_FIFO, :modify) do + line = fd.read.chomp + debug_log(line) if line and line.length > 0 + end + watcher.run + end + end + # Fix Sikuli's debug_log():ing. + bind_java_to_pseudo_fifo_logger +end + +# Common +######## + +After do + if @after_scenario_hooks + @after_scenario_hooks.each { |block| block.call } + end + @after_scenario_hooks = Array.new +end + +BeforeFeature('@product', '@source') do |feature| + raise "Feature #{feature.file} is tagged both @product and @source, " + + "which is an impossible combination" +end + +at_exit do + $vm.destroy_and_undefine if $vm + if $virt + unless KEEP_SNAPSHOTS + VM.remove_all_snapshots + $vmstorage.clear_pool + end + $vmnet.destroy_and_undefine + $virt.close + end + # The artifacts directory is empty (and useless) if it contains + # nothing but the mandatory . and .. + if Dir.entries(ARTIFACTS_DIR).size <= 2 + FileUtils.rmdir(ARTIFACTS_DIR) + end +end + +# For @product tests +#################### + +def add_after_scenario_hook(&block) + @after_scenario_hooks ||= Array.new + @after_scenario_hooks << block +end + +def save_failure_artifact(type, path) + $failure_artifacts << [type, path] +end + +BeforeFeature('@product') do |feature| + if TAILS_ISO.nil? + raise "No ISO image specified, and none could be found in the " + + "current directory" + end + if File.exist?(TAILS_ISO) + # Workaround: when libvirt takes ownership of the ISO image it may + # become unreadable for the live user inside the guest in the + # host-to-guest share used for some tests. + + if !File.world_readable?(TAILS_ISO) + if File.owned?(TAILS_ISO) + File.chmod(0644, TAILS_ISO) + else + raise "warning: the Tails ISO image must be world readable or be " + + "owned by the current user to be available inside the guest " + + "VM via host-to-guest shares, which is required by some tests" + end + end + else + raise "The specified Tails ISO image '#{TAILS_ISO}' does not exist" + end + if !File.exist?(OLD_TAILS_ISO) + raise "The specified old Tails ISO image '#{OLD_TAILS_ISO}' does not exist" + end + if not($started_first_product_feature) + $virt = Libvirt::open("qemu:///system") + VM.remove_all_snapshots if !KEEP_SNAPSHOTS + $vmnet = VMNet.new($virt, VM_XML_PATH) + $vmstorage = VMStorage.new($virt, VM_XML_PATH) + $started_first_product_feature = true + end +end + +AfterFeature('@product') do + unless KEEP_SNAPSHOTS + checkpoints.each do |name, vals| + if vals[:temporary] and VM.snapshot_exists?(name) + VM.remove_snapshot(name) + end + end + end +end + +# Cucumber Before hooks are executed in the order they are listed, and +# we want this hook to always run first, so it must always be the +# *first* Before hook matching @product listed in this file. +Before('@product') do |scenario| + $failure_artifacts = Array.new + if $config["CAPTURE"] + video_name = sanitize_filename("#{scenario.name}.mkv") + @video_path = "#{ARTIFACTS_DIR}/#{video_name}" + capture = IO.popen(['avconv', + '-f', 'x11grab', + '-s', '1024x768', + '-r', '15', + '-i', "#{$config['DISPLAY']}.0", + '-an', + '-c:v', 'libx264', + '-y', + @video_path, + :err => ['/dev/null', 'w'], + ]) + @video_capture_pid = capture.pid + end + @screen = Sikuli::Screen.new + # English will be assumed if this is not overridden + @language = "" + @os_loader = "MBR" + @sudo_password = "asdf" + @persistence_password = "asdf" +end + +# Cucumber After hooks are executed in the *reverse* order they are +# listed, and we want this hook to always run second last, so it must always +# be the *second* After hook matching @product listed in this file -- +# hooks added dynamically via add_after_scenario_hook() are supposed to +# truly be last. +After('@product') do |scenario| + if @video_capture_pid + # We can be incredibly fast at detecting errors sometimes, so the + # screen barely "settles" when we end up here and kill the video + # capture. Let's wait a few seconds more to make it easier to see + # what the error was. + sleep 3 if scenario.failed? + Process.kill("INT", @video_capture_pid) + save_failure_artifact("Video", @video_path) + end + if scenario.failed? + time_of_fail = Time.now - TIME_AT_START + secs = "%02d" % (time_of_fail % 60) + mins = "%02d" % ((time_of_fail / 60) % 60) + hrs = "%02d" % (time_of_fail / (60*60)) + elapsed = "#{hrs}:#{mins}:#{secs}" + info_log("Scenario failed at time #{elapsed}") + screen_capture = @screen.capture + save_failure_artifact("Screenshot", screen_capture.getFilename) + $failure_artifacts.sort! + $failure_artifacts.each do |type, file| + artifact_name = sanitize_filename("#{elapsed}_#{scenario.name}#{File.extname(file)}") + artifact_path = "#{ARTIFACTS_DIR}/#{artifact_name}" + assert(File.exist?(file)) + FileUtils.mv(file, artifact_path) + info_log + info_log_artifact_location(type, artifact_path) + end + pause("Scenario failed") if $config["PAUSE_ON_FAIL"] + else + if @video_path && File.exist?(@video_path) && not($config['CAPTURE_ALL']) + FileUtils.rm(@video_path) + end + end +end + +Before('@product', '@check_tor_leaks') do |scenario| + @tor_leaks_sniffer = Sniffer.new(sanitize_filename(scenario.name), $vmnet) + @tor_leaks_sniffer.capture + add_after_scenario_hook do + @tor_leaks_sniffer.clear + end +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 + end + leaks = FirewallLeakCheck.new(@tor_leaks_sniffer.pcap_file, + :accepted_hosts => expected_tor_nodes) + leaks.assert_no_leaks + end +end + +# For @source tests +################### + +# BeforeScenario +Before('@source') do + @orig_pwd = Dir.pwd + @git_clone = Dir.mktmpdir 'tails-apt-tests' + Dir.chdir @git_clone +end + +# AfterScenario +After('@source') do + Dir.chdir @orig_pwd + FileUtils.remove_entry_secure @git_clone +end |