diff options
Diffstat (limited to 'lib/Sbuild/ChrootUnshare.pm')
-rw-r--r-- | lib/Sbuild/ChrootUnshare.pm | 398 |
1 files changed, 398 insertions, 0 deletions
diff --git a/lib/Sbuild/ChrootUnshare.pm b/lib/Sbuild/ChrootUnshare.pm new file mode 100644 index 0000000..91a7fa4 --- /dev/null +++ b/lib/Sbuild/ChrootUnshare.pm @@ -0,0 +1,398 @@ +# +# ChrootUnshare.pm: chroot library for sbuild +# Copyright © 2018 Johannes Schauer Marin Rodrigues <josch@debian.org> +# +# 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 +# <http://www.gnu.org/licenses/>. +# +####################################################################### + +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; + + # 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; + if ($disable_network) { + $unshare |= CLONE_NEWNET; + $network_setup = 'ip link set lo up;> "$rootdir/etc/resolv.conf";'; + } + + 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\" /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->{'DISABLE_NETWORK'}) && $options->{'DISABLE_NETWORK'}) { + $disable_network = 1; + } + + my @cmdline = $self->_get_exec_argv($dir, $user, $disable_network); + 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; |