# # ChrootUnshare.pm: chroot library for sbuild # Copyright © 2018 Johannes Schauer Marin Rodrigues # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see # . # ####################################################################### package Sbuild::ChrootUnshare; use strict; use warnings; use English; use Sbuild::Utility; use File::Temp qw(mkdtemp tempfile); use File::Copy; use Cwd qw(abs_path); use Sbuild qw(shellescape); BEGIN { use Exporter (); use Sbuild::Chroot; our (@ISA, @EXPORT); @ISA = qw(Exporter Sbuild::Chroot); @EXPORT = qw(); } sub new { my $class = shift; my $conf = shift; my $chroot_id = shift; my $self = $class->SUPER::new($conf, $chroot_id); bless($self, $class); return $self; } sub begin_session { my $self = shift; my $chroot = $self->get('Chroot ID'); return 0 if !defined $chroot; my $namespace = undef; if ($chroot =~ m/^(chroot|source):(.+)$/) { $namespace = $1; $chroot = $2; } my $tarball = undef; if ($chroot =~ '/') { if (! -e $chroot) { print STDERR "Chroot $chroot does not exist\n"; return 0; } $tarball = abs_path($chroot); } else { my $xdg_cache_home = $self->get_conf('HOME') . "/.cache/sbuild"; if (defined($ENV{'XDG_CACHE_HOME'})) { $xdg_cache_home = $ENV{'XDG_CACHE_HOME'} . '/sbuild'; } if (opendir my $dh, $xdg_cache_home) { while (defined(my $file = readdir $dh)) { next if $file eq '.' || $file eq '..'; my $path = "$xdg_cache_home/$file"; # FIXME: support directory chroots #if (-d $path) { # if ($file eq $chroot) { # $tarball = $path; # last; # } #} else { if ($file =~ /^$chroot\.t.+$/) { $tarball = $path; last; } #} } closedir $dh; } if (!defined($tarball)) { print STDERR "Unable to find $chroot in $xdg_cache_home\n"; return 0; } } my @idmap = read_subuid_subgid; # sanity check if ( scalar(@idmap) != 2 || $idmap[0][0] ne 'u' || $idmap[1][0] ne 'g' || length $idmap[0][1] == 0 || length $idmap[0][2] == 0 || length $idmap[1][1] == 0 || length $idmap[1][2] == 0) { printf STDERR "invalid idmap\n"; return 0; } $self->set('Uid Gid Map', \@idmap); my @cmd; my $exit; if(!test_unshare) { print STDERR "E: unable to to unshare\n"; return 0; } my @unshare_cmd = get_unshare_cmd({IDMAP => \@idmap}); my $rootdir = mkdtemp($self->get_conf('UNSHARE_TMPDIR_TEMPLATE')); # $REAL_GROUP_ID is a space separated list of all groups the current user # is in with the first group being the result of getgid(). We reduce the # list to the first group by forcing it to be numeric my $outer_gid = $REAL_GROUP_ID+0; @cmd = (get_unshare_cmd({ IDMAP => [['u', '0', $REAL_USER_ID, '1'], ['g', '0', $outer_gid, '1'], ['u', '1', $idmap[0][2], '1'], ['g', '1', $idmap[1][2], '1'], ] }), 'chown', '1:1', $rootdir); if ($self->get_conf('DEBUG')) { printf STDERR "running @cmd\n"; } system(@cmd); $exit = $? >> 8; if ($exit) { print STDERR "bad exit status ($exit): @cmd\n"; return 0; } if (! -e $tarball) { print STDERR "$tarball does not exist, check \$unshare_tarball config option\n"; return 0; } # The tarball might be in a location where it cannot be accessed by the # user from within the unshared namespace if (! -r $tarball) { print STDERR "$tarball is not readable\n"; return 0; } print STDOUT "Unpacking $tarball to $rootdir...\n"; @cmd = (@unshare_cmd, 'tar', '--exclude=./dev/urandom', '--exclude=./dev/random', '--exclude=./dev/full', '--exclude=./dev/null', '--exclude=./dev/console', '--exclude=./dev/zero', '--exclude=./dev/tty', '--exclude=./dev/ptmx', '--directory', $rootdir, '--extract' ); push @cmd, get_tar_compress_options($tarball); if ($self->get_conf('DEBUG')) { printf STDERR "running @cmd\n"; } my $pid = open(my $out, '|-', @cmd); if (!defined($pid)) { print STDERR "Can't fork: $!\n"; return 0; } if (copy($tarball, $out) != 1) { print STDERR "copy() failed: $!\n"; return 0; } close($out); $exit = $? >> 8; if ($exit) { print STDERR "bad exit status ($exit): @cmd\n"; return 0; } $self->set('Session ID', $rootdir); $self->set('Location', '/sbuild-unshare-dummy-location'); $self->set('Session Purged', 1); # if a source type chroot was requested, then we need to memorize the # tarball location for when the session is ended if (defined($namespace) && $namespace eq "source") { $self->set('Tarball', $tarball); } return 0 if !$self->_setup_options(); return 1; } sub end_session { my $self = shift; return if $self->get('Session ID') eq ""; if (defined($self->get('Tarball'))) { my ($tmpfh, $tmpfile) = tempfile("XXXXXX"); my @program_list = ("/bin/tar", "-c", "-C", $self->get('Session ID')); push @program_list, get_tar_compress_options($self->get('Tarball')); push @program_list, './'; print "I: Creating tarball...\n"; open(my $in, '-|', get_unshare_cmd( {IDMAP => $self->get('Uid Gid Map')}), @program_list ) // die "could not exec tar"; if (copy($in, $tmpfile) != 1 ) { die "unable to copy: $!\n"; } close($in) or die "Could not create chroot tarball: $?\n"; move("$tmpfile", $self->get('Tarball')); chmod 0644, $self->get('Tarball'); print "I: Done creating " . $self->get('Tarball') . "\n"; } print STDERR "Cleaning up chroot (session id " . $self->get('Session ID') . ")\n" if $self->get_conf('DEBUG'); # this looks like a recipe for disaster, but since we execute "rm -rf" with # lxc-usernsexec, we only have permission to delete the files that were # created with the fake root user my @cmd = (get_unshare_cmd({IDMAP => $self->get('Uid Gid Map')}), 'rm', '-rf', $self->get('Session ID')); if ($self->get_conf('DEBUG')) { printf STDERR "running @cmd\n"; } system(@cmd); # we ignore the exit status, because the command will fail to remove the # unpack directory itself because of insufficient permissions if(-d $self->get('Session ID') && !rmdir($self->get('Session ID'))) { print STDERR "unable to remove " . $self->get('Session ID') . ": $!\n"; $self->set('Session ID', ""); return 0; } $self->set('Session ID', ""); return 1; } sub _get_exec_argv { my $self = shift; my $dir = shift; my $user = shift; my $disable_network = shift // 0; my $build_inside_init = shift // 0; # On systems with libnss-resolve installed there is no need for a # /etc/resolv.conf. This works around this by adding 127.0.0.53 (default # for systemd-resolved) in that case. my $network_setup = '[ -f /etc/resolv.conf ] && cat /etc/resolv.conf > "$rootdir/etc/resolv.conf" || echo "nameserver 127.0.0.53" > "$rootdir/etc/resolv.conf";'; my $unshare = CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPC; my $init = ""; if ($disable_network) { $unshare |= CLONE_NEWNET; $network_setup = 'ip link set lo up;> "$rootdir/etc/resolv.conf";'; } if ($build_inside_init) { $init = "/usr/bin/dumb-init"; } my @bind_mounts = (); for my $entry (@{$self->get_conf('UNSHARE_BIND_MOUNTS')}) { push @bind_mounts, $entry->{directory}, $entry->{mountpoint}; } return ( 'env', 'PATH=' . $self->get_conf('PATH'), get_unshare_cmd({UNSHARE_FLAGS => $unshare, FORK => 1, IDMAP => $self->get('Uid Gid Map')}), 'sh', '-c', " rootdir=\"\$1\"; shift; user=\"\$1\"; shift; dir=\"\$1\"; shift; while [ \$# -gt 0 ]; do if [ \"\$1\" = \"--\" ]; then shift; break; fi; mkdir -p \"\$rootdir\$2\"; mount -o rbind \"\$1\" \"\$rootdir\$2\"; shift; shift; done; hostname sbuild; echo \"127.0.0.1 localhost\\n127.0.1.1 sbuild\" > \"\$rootdir/etc/hosts\"; $network_setup mkdir -p \"\$rootdir/dev\"; for f in null zero full random urandom tty console; do touch \"\$rootdir/dev/\$f\"; chmod -rwx \"\$rootdir/dev/\$f\"; mount -o bind \"/dev/\$f\" \"\$rootdir/dev/\$f\"; done; ln -sfT /proc/self/fd \"\$rootdir/dev/fd\"; ln -sfT /proc/self/fd/0 \"\$rootdir/dev/stdin\"; ln -sfT /proc/self/fd/1 \"\$rootdir/dev/stdout\"; ln -sfT /proc/self/fd/2 \"\$rootdir/dev/stderr\"; mkdir -p \"\$rootdir/dev/pts\"; mount -o noexec,nosuid,gid=5,mode=620,ptmxmode=666 -t devpts none \"\$rootdir/dev/pts\"; ln -sfT /dev/pts/ptmx \"\$rootdir/dev/ptmx\"; mkdir -p \"\$rootdir/dev/shm\"; mount -t tmpfs tmpfs \"\$rootdir/dev/shm\"; mkdir -p \"\$rootdir/sys\"; mount -o rbind /sys \"\$rootdir/sys\"; mkdir -p \"\$rootdir/proc\"; mount -t proc proc \"\$rootdir/proc\"; exec /usr/sbin/chroot \"\$rootdir\" $init /sbin/runuser -u \"\$user\" -- sh -c \"cd \\\"\\\$1\\\" && shift && \\\"\\\$@\\\"\" -- \"\$dir\" \"\$@\"; ", '--', $self->get('Session ID'), $user, $dir, @bind_mounts, '--' ); } sub get_internal_exec_string { my $self = shift; return join " ", (map { shellescape $_ } $self->_get_exec_argv('/', 'root')); } sub get_command_internal { my $self = shift; my $options = shift; # Command to run. If I have a string, use it. Otherwise use the list-ref my $command = $options->{'INTCOMMAND_STR'} // $options->{'INTCOMMAND'}; my $user = $options->{'USER'}; # User to run command under my $dir; # Directory to use (optional) $dir = $self->get('Defaults')->{'DIR'} if (defined($self->get('Defaults')) && defined($self->get('Defaults')->{'DIR'})); $dir = $options->{'DIR'} if defined($options->{'DIR'}) && $options->{'DIR'}; if (!defined $user || $user eq "") { $user = $self->get_conf('USERNAME'); } if (!defined($dir)) { $dir = '/'; } my $disable_network = 0; if (defined($options->{'ENABLE_NETWORK'}) && $options->{'ENABLE_NETWORK'} == 0) { $disable_network = 1; } my $build_inside_init = 0; if (defined($options->{'BUILD_INSIDE_INIT'}) && $options->{'BUILD_INSIDE_INIT'}) { $build_inside_init = 1; } my @cmdline = $self->_get_exec_argv($dir, $user, $disable_network, $build_inside_init); if (ref $command) { push @cmdline, @$command; } else { push @cmdline, ('/bin/sh', '-c', $command); $command = [split(/\s+/, $command)]; } $options->{'USER'} = $user; $options->{'COMMAND'} = $command; $options->{'EXPCOMMAND'} = \@cmdline; $options->{'CHDIR'} = undef; $options->{'DIR'} = $dir; } # create users from outside the chroot so we don't need user/groupadd inside. sub useradd { my $self = shift; my @args = @_; my $rootdir = $self->get('Session ID'); my @idmap = read_subuid_subgid; my @unshare_cmd = get_unshare_cmd({IDMAP => \@idmap}); return system(@unshare_cmd, "/usr/sbin/useradd", "--root", $rootdir, @args); } sub groupadd { my $self = shift; my @args = @_; my $rootdir = $self->get('Session ID'); my @idmap = read_subuid_subgid; my @unshare_cmd = get_unshare_cmd({IDMAP => \@idmap}); return system(@unshare_cmd, "/usr/sbin/groupadd", "--root", $rootdir, @args); } 1;