4 # Tails: The Amnesic Incognito Live System
5 # Copyright © 2012 Tails developers <tails@boum.org>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
27 require_relative 'vagrant/lib/tails_build_settings'
29 # Path to the directory which holds our Vagrantfile
30 VAGRANT_PATH = File.expand_path('vagrant', __dir__)
32 # Branches that are considered 'stable' (used to select SquashFS compression)
33 STABLE_BRANCH_NAMES = ['stable', 'testing'].freeze
35 EXPORTED_VARIABLES = [
37 'APT_SNAPSHOTS_SERIALS',
39 'TAILS_BUILD_FAILURE_RESCUE',
41 'TAILS_MERGE_BASE_BRANCH',
46 'TAILS_WEBSITE_CACHE',
49 'BASE_BRANCH_GIT_COMMIT',
51 ENV['EXPORTED_VARIABLES'] = EXPORTED_VARIABLES.join(' ')
53 EXTERNAL_HTTP_PROXY = ENV['http_proxy']
56 INTERNAL_HTTP_PROXY = "http://#{VIRTUAL_MACHINE_HOSTNAME}:3142".freeze
58 ENV['ARTIFACTS'] ||= '.'
60 ENV['APT_SNAPSHOTS_SERIALS'] ||= ''
62 class CommandError < StandardError
63 attr_reader :status, :stderr
65 def initialize(message, **opts)
68 @status = opts[:status]
69 @stderr = opts[:stderr]
70 super(format(message, status: @status, stderr: @stderr))
74 def run_command(*args)
75 Process.wait Kernel.spawn(*args)
76 return if $CHILD_STATUS.exitstatus.zero?
78 raise CommandError.new("command #{args} failed with exit status %<status>s",
79 status: $CHILD_STATUS.exitstatus)
82 def capture_command(*args)
83 stdout, stderr, proc_status = Open3.capture3(*args)
84 if proc_status.exitstatus != 0
85 raise CommandError.new("command #{args} failed with exit status " \
86 '%<status>s: %<stderr>s',
87 stderr: stderr, status: proc_status.exitstatus)
93 question = args.first.end_with?('?')
94 args.first.sub!(/\?$/, '')
98 stdout, = capture_command('auto/scripts/utils.sh', *args)
99 rescue CommandError => e
102 question ? status.zero? : stdout.chomp
105 class VagrantCommandError < CommandError
108 # Runs the vagrant command, letting stdout/stderr through. Throws an
109 # exception unless the vagrant command succeeds.
110 def run_vagrant(*args)
111 run_command('vagrant', *args, chdir: './vagrant')
112 rescue CommandError => e
113 raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " \
114 "status #{e.status}")
117 # Runs the vagrant command, not letting stdout/stderr through, and
118 # returns [stdout, stderr, Process::Status].
119 def capture_vagrant(*args)
120 capture_command('vagrant', *args, chdir: './vagrant')
121 rescue CommandError => e
122 raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " \
123 "status #{e.status}: #{e.stderr}")
126 [:run_vagrant, :capture_vagrant].each do |m|
127 define_method "#{m}_ssh" do |*args|
128 method(m).call('ssh', '-c', *args, '--', '-q')
132 def vagrant_ssh_config(key)
134 if $vagrant_ssh_config.nil?
135 $vagrant_ssh_config = capture_vagrant('ssh-config')
137 .map { |line| line.strip.split(/\s+/, 2) } .to_h
138 # The path in the ssh-config output is quoted, which is not what
139 # is expected outside of a shell, so let's get rid of the quotes.
140 $vagrant_ssh_config['IdentityFile'].gsub!(/^"|"$/, '')
142 $vagrant_ssh_config[key]
146 capture_vagrant_ssh('grep -c "^processor\s*:" /proc/cpuinfo').first.chomp.to_i
150 out, = capture_vagrant('status')
151 status_line = out.split("\n")[2]
152 if status_line['not created']
154 elsif status_line['shutoff']
156 elsif status_line['running']
159 raise 'could not determine VM state'
163 def enough_free_host_memory_for_ram_build?
164 return false unless RbConfig::CONFIG['host_os'] =~ /linux/i
167 usable_free_mem = `free`.split[12].to_i
168 usable_free_mem > VM_MEMORY_FOR_RAM_BUILDS * 1024
175 capture_vagrant_ssh('free').first.chomp.split[12].to_i
178 def enough_free_vm_memory_for_ram_build?
179 free_vm_memory > BUILD_SPACE_REQUIREMENT * 1024
182 def enough_free_memory_for_ram_build?
183 if vm_state == :running
184 enough_free_vm_memory_for_ram_build?
186 enough_free_host_memory_for_ram_build?
191 git_helper('git_on_a_tag?')
195 return unless RbConfig::CONFIG['host_os'] =~ /linux/i
198 File.read('/proc/cpuinfo').scan(/^processor\s+:/).count
204 ENV['TAILS_WEBSITE_CACHE'] = releasing? ? '0' : '1'
206 task :parse_build_options do
209 # Default to in-memory builds if there is enough RAM available
210 options << 'ram' if enough_free_memory_for_ram_build?
211 # Default to build using the in-VM proxy
213 # Default to fast compression on development branches
214 options << 'fastcomp' unless releasing?
215 # Default to the number of system CPUs when we can figure it out
217 options << "cpus=#{cpus}" if cpus
219 options += ENV['TAILS_BUILD_OPTIONS'].split if ENV['TAILS_BUILD_OPTIONS']
221 options.uniq.each do |opt| # rubocop:disable Metrics/BlockLength
223 # Memory build settings
225 ENV['TAILS_RAM_BUILD'] = '1'
227 ENV['TAILS_RAM_BUILD'] = nil
228 # Bootstrap cache settings
229 # HTTP proxy settings
231 unless EXTERNAL_HTTP_PROXY
232 abort 'No HTTP proxy set, but one is required by ' \
233 'TAILS_BUILD_OPTIONS. Aborting.'
235 ENV['TAILS_PROXY'] = EXTERNAL_HTTP_PROXY
236 ENV['TAILS_PROXY_TYPE'] = 'extproxy'
237 when 'vmproxy', 'vmproxy+extproxy'
238 ENV['TAILS_PROXY'] = INTERNAL_HTTP_PROXY
239 ENV['TAILS_PROXY_TYPE'] = 'vmproxy'
240 if opt == 'vmproxy+extproxy'
241 unless EXTERNAL_HTTP_PROXY
242 abort 'No HTTP proxy set, but one is required by ' \
243 'TAILS_BUILD_OPTIONS. Aborting.'
245 ENV['TAILS_ACNG_PROXY'] = EXTERNAL_HTTP_PROXY
248 ENV['TAILS_PROXY'] = nil
249 ENV['TAILS_PROXY_TYPE'] = 'noproxy'
251 ENV['TAILS_OFFLINE_MODE'] = '1'
252 when /cachewebsite(?:=([a-z]+))?/
253 value = Regexp.last_match(1)
255 warn "Building a release ⇒ ignoring #{opt} build option"
256 ENV['TAILS_WEBSITE_CACHE'] = '0'
258 value = 'yes' if value.nil?
261 ENV['TAILS_WEBSITE_CACHE'] = '1'
263 ENV['TAILS_WEBSITE_CACHE'] = '0'
265 raise "Unsupported value for cachewebsite option: #{value}"
268 # SquashFS compression settings
269 when 'fastcomp', 'gzipcomp'
271 warn "Building a release ⇒ ignoring #{opt} build option"
272 ENV['MKSQUASHFS_OPTIONS'] = nil
274 ENV['MKSQUASHFS_OPTIONS'] = '-comp xz -no-exports'
277 ENV['MKSQUASHFS_OPTIONS'] = nil
278 # Virtual hardware settings
279 when /machinetype=([a-zA-Z0-9_.-]+)/
280 ENV['TAILS_BUILD_MACHINE_TYPE'] = Regexp.last_match(1)
282 ENV['TAILS_BUILD_CPUS'] = Regexp.last_match(1)
283 when /cpumodel=([a-zA-Z0-9_-]+)/
284 ENV['TAILS_BUILD_CPU_MODEL'] = Regexp.last_match(1)
287 ENV['TAILS_BUILD_IGNORE_CHANGES'] = '1'
288 when /dateoffset=([-+]\d+)/
289 ENV['TAILS_DATE_OFFSET'] = Regexp.last_match(1)
290 # Developer convenience features
293 $force_cleanup = false
295 $force_cleanup = true
296 $keep_running = false
299 ENV['TAILS_BUILD_FAILURE_RESCUE'] = '1'
301 when 'mergebasebranch'
302 ENV['TAILS_MERGE_BASE_BRANCH'] = '1'
304 raise "Unknown Tails build option '#{opt}'"
308 if ENV['TAILS_OFFLINE_MODE'] == '1'
309 if ENV['TAILS_PROXY'].nil?
310 abort 'You must use a caching proxy when building offline'
315 task :ensure_clean_repository do
316 git_status = `git status --porcelain`
317 unless git_status.empty?
318 if ENV['TAILS_BUILD_IGNORE_CHANGES']
319 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
321 You have uncommitted changes in the Git repository. They will
322 be ignored for the upcoming build:
327 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
329 You have uncommitted changes in the Git repository. Due to limitations
330 of the build system, you need to commit them before building Tails:
333 If you don't care about those changes and want to build Tails nonetheless,
334 please add `ignorechanges` to the TAILS_BUILD_OPTIONS environment
338 abort 'Uncommitted changes. Aborting.'
344 user = vagrant_ssh_config('User')
345 stdout = capture_vagrant_ssh("find '/home/#{user}/amnesia/' -maxdepth 1 " \
346 "-name 'tails-amd64-*' " \
347 '-o -name tails-build-env.list').first
349 rescue VagrantCommandError
354 list_artifacts.each do |artifact|
355 run_vagrant_ssh("sudo rm -f '#{artifact}'")
359 task ensure_clean_home_directory: ['vm:up'] do
363 task :validate_http_proxy do
364 if ENV['TAILS_PROXY']
365 proxy_host = URI.parse(ENV['TAILS_PROXY']).host
368 ENV['TAILS_PROXY'] = nil
369 warn 'Ignoring invalid HTTP proxy.'
373 if ['localhost', '[::1]'].include?(proxy_host) \
374 || proxy_host.start_with?('127.0.0.')
375 abort 'Using an HTTP proxy listening on the loopback is doomed ' \
379 warn "Using HTTP proxy: #{ENV['TAILS_PROXY']}"
381 warn 'No HTTP proxy set.'
385 task :validate_git_state do
386 if git_helper('git_in_detached_head?') && !git_helper('git_on_a_tag?')
387 raise 'We are in detached head but the current commit is not tagged'
391 task setup_environment: ['validate_git_state'] do
392 ENV['GIT_COMMIT'] ||= git_helper('git_current_commit')
393 ENV['GIT_REF'] ||= git_helper('git_current_head_name')
395 jenkins_branch = (ENV['GIT_BRANCH'] || '').sub(%r{^origin/}, '')
396 if !releasing? && jenkins_branch != ENV['GIT_REF']
397 raise "We expected to build the Git ref '#{ENV['GIT_REF']}', " \
398 "but GIT_REF in the environment says '#{jenkins_branch}'. Aborting!"
402 ENV['BASE_BRANCH_GIT_COMMIT'] ||= git_helper('git_base_branch_head')
403 ['GIT_COMMIT', 'GIT_REF', 'BASE_BRANCH_GIT_COMMIT'].each do |var|
404 next unless ENV[var].empty?
406 raise "Variable '#{var}' is empty, which should not be possible: " \
407 "either validate_git_state is buggy or the 'origin' remote " \
408 'does not point to the official Tails Git repository.'
412 task :maybe_clean_up_builder_vms do
413 clean_up_builder_vms if $force_cleanup
416 task :ensure_correct_permissions do
417 FileUtils.chmod('go+x', '.')
418 FileUtils.chmod_R('go+rX', ['.git', 'submodules', 'vagrant'])
420 # Changing permissions outside of the working copy, in particular on
421 # parent directories such as $HOME, feels too blunt and can have
422 # problematic security consequences, so we don't forcibly do that.
423 # Instead, when the permissions are not OK, display a nicer error
424 # message than "Virtio-9p Failed to initialize fs-driver […]"
426 capture_command('sudo', '-u', 'libvirt-qemu', 'stat', '.git')
428 abort <<-END_OF_MESSAGE.gsub(/^ /, '')
430 Incorrect permissions: the libvirt-qemu user needs to be allowed
431 to traverse the filesystem up to #{ENV['PWD']}.
433 To fix this, you can for example run the following command
434 on every parent directory of #{ENV['PWD']} up to #{ENV['HOME']}
437 chmod g+rx DIR && setfacl -m user:libvirt-qemu:rx DIR
445 'parse_build_options',
446 'ensure_clean_repository',
447 'maybe_clean_up_builder_vms',
448 'validate_git_state',
450 'validate_http_proxy',
451 'ensure_correct_permissions',
453 'ensure_clean_home_directory',
456 if ENV['TAILS_RAM_BUILD'] && !enough_free_memory_for_ram_build?
457 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
459 The virtual machine is not currently set with enough memory to
460 perform an in-memory build. Either remove the `ram` option from
461 the TAILS_BUILD_OPTIONS environment variable, or shut the
462 virtual machine down using `rake vm:halt` before trying again.
465 abort 'Not enough memory for the virtual machine to run an in-memory ' \
469 if ENV['TAILS_BUILD_CPUS'] \
470 && current_vm_cpus != ENV['TAILS_BUILD_CPUS'].to_i
471 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
473 The virtual machine is currently running with #{current_vm_cpus}
474 virtual CPU(s). In order to change that number, you need to
475 stop the VM first, using `rake vm:halt`. Otherwise, please
476 adjust the `cpus` options accordingly.
479 abort 'The virtual machine needs to be reloaded to change the number ' \
483 exported_env = EXPORTED_VARIABLES
484 .select { |k| ENV[k] }
485 .map { |k| "#{k}='#{ENV[k]}'" }.join(' ')
488 retrieved_artifacts = false
489 run_vagrant_ssh("#{exported_env} build-tails")
490 rescue VagrantCommandError
491 retrieve_artifacts(missing_ok: true)
492 retrieved_artifacts = true
494 retrieve_artifacts(missing_ok: false) unless retrieved_artifacts
495 clean_up_builder_vms unless $keep_running
498 clean_up_builder_vms if $force_cleanup
502 desc 'Retrieve build artifacts from the Vagrant box'
503 task :retrieve_artifacts do
507 def retrieve_artifacts(missing_ok: false)
508 artifacts = list_artifacts
510 msg = 'No build artifacts were found!'
511 raise msg unless missing_ok
516 user = vagrant_ssh_config('User')
517 hostname = vagrant_ssh_config('HostName')
518 key_file = vagrant_ssh_config('IdentityFile')
519 warn 'Retrieving artifacts from Vagrant build box.'
521 "sudo chown #{user} " + artifacts.map { |a| "'#{a}'" } .join(' ')
526 # We need this since the user will not necessarily have a
527 # known_hosts entry. It is safe since an attacker must
528 # compromise libvirt's network config or the user running the
529 # command to modify the #{hostname} below.
530 '-o', 'StrictHostKeyChecking=no',
531 '-o', 'UserKnownHostsFile=/dev/null',
533 '-o', 'Compression=no',
535 fetch_command += artifacts.map { |a| "#{user}@#{hostname}:#{a}" }
536 fetch_command << ENV['ARTIFACTS']
537 run_command(*fetch_command)
541 !capture_vagrant('box', 'list').grep(/^#{box_name}\s+\(libvirt,/).empty?
545 "#{box_name}_default"
548 # XXX: giving up on a few worst offenders for now
549 # rubocop:disable Metrics/AbcSize
550 # rubocop:disable Metrics/MethodLength
551 def clean_up_builder_vms
552 libvirt = Libvirt.open('qemu:///system')
554 clean_up_domain = proc do |domain|
557 domain.destroy if domain.active?
561 .lookup_storage_pool_by_name('default')
562 .lookup_volume_by_name("#{domain.name}.img")
564 rescue Libvirt::RetrieveError
565 # Expected if the pool or disk does not exist
569 # Let's ensure that the VM we are about to create is cleaned up ...
570 previous_domain = libvirt.list_all_domains.find { |d| d.name == domain_name }
571 if previous_domain&.active?
573 run_vagrant_ssh('mountpoint -q /var/cache/apt-cacher-ng')
574 rescue VagrantCommandError
575 # Nothing to unmount.
577 run_vagrant_ssh('sudo systemctl stop apt-cacher-ng.service')
578 run_vagrant_ssh('sudo umount /var/cache/apt-cacher-ng')
579 run_vagrant_ssh('sudo sync')
582 run_vagrant_ssh('mountpoint -q /var/cache/tails-website')
583 rescue VagrantCommandError
584 # Nothing to unmount.
586 run_vagrant_ssh('sudo umount /var/cache/tails-website')
587 run_vagrant_ssh('sudo sync')
590 clean_up_domain.call(previous_domain)
592 # ... and the same for any residual VM based on another box (=>
593 # another domain name) that Vagrant still keeps track of.
597 open('vagrant/.vagrant/machines/default/libvirt/id', 'r', &:read)
599 libvirt.lookup_domain_by_uuid(old_domain_uuid)
600 rescue Errno::ENOENT, Libvirt::RetrieveError
601 # Expected if we don't have vagrant/.vagrant, or if the VM was
602 # undefined for other reasons (e.g. manually).
605 clean_up_domain.call(old_domain)
607 # We could use `vagrant destroy` here but due to vagrant-libvirt's
608 # upstream issue #746 we then risk losing the apt-cacher-ng data.
609 # Since we essentially implement `vagrant destroy` without this bug
610 # above, but in a way so it works even if `vagrant/.vagrant` does
611 # not exist, let's just do what is safest, i.e. avoiding `vagrant
612 # destroy`. For details, see the upstream issue:
613 # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/746
614 FileUtils.rm_rf('vagrant/.vagrant')
618 # rubocop:enable Metrics/AbcSize
619 # rubocop:enable Metrics/MethodLength
621 desc 'Remove all libvirt volumes named tails-builder-* (run at your own risk!)'
622 task :clean_up_libvirt_volumes do
623 libvirt = Libvirt.open('qemu:///system')
625 pool = libvirt.lookup_storage_pool_by_name('default')
626 rescue Libvirt::RetrieveError
627 # Expected if the pool does not exist
629 pool.list_volumes.each do |disk|
630 next unless /^tails-builder-/.match(disk)
633 pool.lookup_volume_by_name(disk).delete
634 rescue Libvirt::RetrieveError
635 # Expected if the disk does not exist
644 !ENV['JENKINS_URL'].nil?
647 desc 'Clean up all build related files'
648 task clean_all: ['vm:destroy', 'basebox:clean_all']
651 desc 'Start the build virtual machine'
653 'parse_build_options',
654 'validate_http_proxy',
663 run_vagrant('up', '--provision')
664 rescue VagrantCommandError => e
665 clean_up_builder_vms if $force_cleanup
670 desc 'SSH into the builder VM'
675 desc 'Stop the build virtual machine'
680 desc 'Re-run virtual machine setup'
682 'parse_build_options',
683 'validate_http_proxy',
686 run_vagrant('provision')
689 desc 'Destroy build virtual machine (clean up all files except the ' \
690 "vmproxy's apt-cacher-ng data and the website cache)"
696 namespace :basebox do
697 desc 'Create and import the base box unless already done'
701 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
703 This is the first time we are using this Vagrant base box so we
704 will have to bootstrap by building it from scratch. This will
705 take around 20 minutes (depending on your hardware) plus the
706 time needed for downloading around 250 MiB of Debian packages.
709 box_dir = VAGRANT_PATH + '/definitions/tails-builder'
710 run_command("#{box_dir}/generate-tails-builder-box.sh")
711 # Let's use an absolute path since run_vagrant changes the working
712 # directory but File.delete doesn't
713 box_path = "#{box_dir}/#{box_name}.box"
714 run_vagrant('box', 'add', '--name', box_name, box_path)
715 File.delete(box_path)
718 def basebox_date(box)
719 Date.parse(/^tails-builder-[^-]+-[^-]+-(\d{8})/.match(box)[1])
723 capture_vagrant('box', 'list')
725 .grep(/^tails-builder-.*/)
726 .map { |x| x.chomp.sub(/\s.*$/, '') }
729 def clean_up_basebox(box)
730 run_vagrant('box', 'remove', '--force', box)
732 libvirt = Libvirt.open('qemu:///system')
734 .lookup_storage_pool_by_name('default')
735 .lookup_volume_by_name("#{box}_vagrant_box_image_0.img")
737 rescue Libvirt::RetrieveError
738 # Expected if the pool or disk does not exist
744 desc 'Remove all base boxes'
746 baseboxes.each { |box| clean_up_basebox(box) }
749 desc 'Remove all base boxes older than six months'
752 # We always want to keep the newest basebox
753 boxes.sort! { |a, b| basebox_date(a) <=> basebox_date(b) }
756 clean_up_basebox(box) if basebox_date(box) < Date.today - 365.0 / 2.0