#!/usr/bin/perl
#
# mkchroot-rush.pl
#
# Helper script to produce a small chrootable directory.
# Intended to be used with GNU Rush.
#
# Author: Mats Erik Andersson
#
# Copyright: 2010 - 2013, Mats Erik Andersson
#
# 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 3, or (at your option)
# any later version.
#
#
# The choice of contents for a minimal chroot directory were
# influenced by suggestions from Sergey Poznyakoff.
#
# $Rev: 84 $
#
# vim: set sw=4 ts=4

use warnings;
use strict;

use Getopt::Long;
use Pod::Usage;
use File::Path qw(make_path);

# Control variables and constants.
#
my $os;

my $elf_loader = '/libexec/ld-elf.so.1';	# Help FreeBSD

# Keep the ordering in this list.
#
my @tasks_avail = qw/dev etc home lib bin/;

# Variables imported from configuration files.
#
our @binaries = ();
our @binpaths = qw[ /bin /usr/bin ];
our $chrootdir = '';
our @tasks = qw/bin dev etc lib/;
our @userlist = ();
our @liblist = qw( /lib/libnss_files.so.2
		/lib/libnss_dns.so.2 );

# Runtime variables.
#
my $binaries_override = '';
my $binpaths_override = '';
my $chrootdir_override = '';
my $configfile = '/etc/mkchroot-rush.conf';
my $force_exec = '';
my $tasks_override = '';
my $userlist_override = '';

our ($opt_help, $opt_usage, $opt_verb);

# Subroutines
#
sub init_chroot;
sub build_etc;
sub build_dev;
sub build_home;
sub build_bin;
sub span_lib_calls;
sub build_lib;

# Method hash for the builtin tasks.
my %tasks = ( 'etc' => \&build_etc,
			'dev' => \&build_dev,
			'home' => \&build_home,
			'bin' => \&build_bin,
			'lib' => \&build_lib);

# Commence program execution.
GetOptions( 'b|binaries=s' => \$binaries_override,
			'binpaths=s' => \$binpaths_override,
			'chrootdir=s' => \$chrootdir_override,
			'config=s' => \$configfile,
			'f|force' => \$force_exec,
			'h|help' => \$opt_help,
			'usage' => \$opt_usage,
			't|tasks=s' => \$tasks_override,
			'userlist=s' => \$userlist_override,
			'v|verbose' => \$opt_verb )
	or pod2usage(-verbose => 1);

pod2usage(-verbose => 2, -noperldoc => 1)	if ($opt_help);
pod2usage(-verbose => 1, -noperldoc => 1)	if ($opt_usage);

# Detect our environment.
$os = `uname -s`;
chomp $os;

# Augment the library list with the dynamic loader and Libc.
# This must be system independent in order to satisfy GNU/Linux
# and GNU/kFreeBSD.

open (LDD, "/usr/bin/ldd /bin/sh |")
	or die("Unable to establish libraries for /bin/sh. Aborting.\n");

while (<LDD>) {
	next	unless ( m[libc\.|ld-] );
	chomp;
	my @row = split /\s+/;

	if (defined($row[3]) and ($row[3] =~ m[^/])) {
		unshift @liblist, $row[3];
	} elsif  ($row[1] =~ m[^/]) {
		unshift @liblist, $row[1];
	}
}

# Fetch the configuration.
#
if (-f "$configfile") {
	do "$configfile";
}

# Produce a warning when being run without sufficient priviliges.
#
if ($> != 0) {
	print STDERR <<AVLYKT
!=====================================================================
! Running as underprivileged user. Due to insufficient capabilities  !
! some objects are likely to loose their intended properties.        !
! ====================================================================

AVLYKT
}

# Arrange overriding settings.
#
@binaries = split(',', "$binaries_override")	if (length("$binaries_override") > 0);
@binpaths = split(',', "$binpaths_override")	if (length("$binpaths_override") > 0);
@userlist = split(',', "$userlist_override")	if (length("$userlist_override") > 0);
@tasks = split(',', "$tasks_override")		if (length("$tasks_override") > 0);
$chrootdir = $chrootdir_override		if (length($chrootdir_override) > 0);

# Reorder the tasks to conform to internal dependencies.
my @do_tasks = ();

foreach my $task (@tasks_avail) {
	if (!defined($tasks{$task})) {
		printf STDERR "\"%s\" is not a legal task.\n", $task;
		next;
	}
	if (grep(/^$task$/, @tasks)) {
		push @do_tasks, $task;
	}
}

# Report on settings
if ($opt_verb) {
	printf <<AVLYKT
   config file:  $configfile
   chrootdir:    $chrootdir
   tasks:        @do_tasks
   users:        @userlist
   binaries:     @binaries
   paths:        @binpaths

AVLYKT
}

# Initial consistency tests.
#
die "No chrootdir specified! Aborting.\n"
		if (length("$chrootdir") == 0);

die "The chrootdir already exists. Aborting.\nUse '--force' to override.\n"
		if (-d "$chrootdir" and not $force_exec);

# Create chroot directory if need be.
init_chroot;

# Execute whatever tasks were desired.
#
foreach my $task (@do_tasks) {
	&{$tasks{$task}};
}

#################
sub init_chroot {
#################
	return	if (-d "$chrootdir");

	make_path("$chrootdir", { mode => 0755 })
		or die("Could not create directory \"%s\". Aborting.\n", "$chrootdir");
} # sub init_chroot

###############
sub build_etc {
###############

	make_path("$chrootdir/etc", { mode => 0755 })
		unless (-d "$chrootdir/etc");

	# Populate "etc/nsswitch.conf".
	#
	if (! -s "$chrootdir/etc/nsswitch.conf") {
		# We need to create this file.
		open(NSS, "> $chrootdir/etc/nsswitch.conf");

		print NSS <<'AVLYKT';
passwd	files
group	files
hosts	files dns
AVLYKT
		close NSS;
	} else {
		printf "An old 'etc/nsswitch.conf' is present. " .
			"No changes were implemented.\n";
	}

	# Populate "etc/passwd" and "etc/group".
	#
	my (@olduser, @newuser, @checkgroup);

	system('touch', "$chrootdir/etc/passwd");
	system('touch', "$chrootdir/etc/group");

	open(PASSWD, "< $chrootdir/etc/passwd");
	while (<PASSWD>) {
		my ($name, @rest) = split(/:/);
		if ( grep /$name/, @userlist ) {
			push @olduser, $name;
		}
	}
	close PASSWD;

	# Detect the new users
	foreach my $name (@userlist) {
		if (! grep /$name/, @olduser) {
			push @newuser, $name;
		}
	}

	open(PASSWD, ">> $chrootdir/etc/passwd");
	open(GROUP, ">> $chrootdir/etc/group");

	# Only new users are inserted!
	foreach my $name (@newuser) {
		my @entry = getpwnam $name;

		if (not scalar(@entry)) {
			printf STDERR "Warning: user \"%s\" is missing. Skipping!\n", $name;
			next;
		}

		print PASSWD "$entry[0]:x:$entry[2]:$entry[3]";
		#print PASSWD join(':', @entry[0,1,2,3]);
		print PASSWD ":Rush user:$entry[7]:/bin/false\n";

		my @group = getgrgid($entry[3]);

		# Must prevent duplicate group enties!
		open(PEEKGROUP, "< $chrootdir/etc/group");
		my $group_is_present = 0;
		my $head = join(':', @group[0,1,2]);

		while (<PEEKGROUP>) {
			next	unless m/^$head/;
			$group_is_present = 1;
			last;
		}
		close PEEKGROUP;

		print GROUP join(':', @group[0,1,2]) . ":\n"
			unless ($group_is_present);
	};

	close PASSWD;
	close GROUP;
} # sub build_etc

###############
sub build_dev {
###############
	make_path("$chrootdir/dev", { mode => 0755 })
		unless (-d "$chrootdir/dev");

	if ($os eq 'Linux') {
		if ($ENV{'USER'} eq 'root') {
			system("mknod", '-m', '666', "$chrootdir/dev/null", 'c', 1, 3);
		} else {
			printf STDERR "NOTICE: Lacking root\'s capabilities: " .
				"\"$chrootdir/dev/null\" is not created.\n";
		}
	} elsif ($os =~ 'FreeBSD') {
		# Need devfs for runtime setup on GNU/kFreeBSD
		# and FreeBSD.
		if ($opt_verb) {
			print STDERR <<AVLYKT;
----------------------------------------------------------
WARNING: FreeBSD and GNU/kFreeBSD

The administrator must somehow arrange the execution of

  # mount_devfs devfs $chrootdir/dev/

Otherwise some services will fail. This pseudo file system
is the only method of generating the device '/dev/null' at
this location.

----------------------------------------------------------
AVLYKT
		} else {
			printf STDERR <<AVLYKT
----------------------------------------------------------
WARNING: For full functionality, remember the execution of
  # mount_devfs devfs $chrootdir/dev/
----------------------------------------------------------
AVLYKT
		}
	}
} # sub build_dev

################
sub build_home {
################
	foreach my $name (@userlist) {
		my @entry = getpwnam $name;

		if (scalar(@entry)) {
			if (! -d "$chrootdir$entry[7]") {
				make_path("$chrootdir$entry[7]", { mode => 0755 });
				chown($entry[2], $entry[3], "$chrootdir$entry[7]")
					or printf STDERR "NOTICE: Could not set owner on \"$chrootdir$entry[7]\".\n";
			}
		} else {
			printf STDERR "Warning: user \"%s\" is missing. " .
					"Not creating home directory!\n", $name;
			next;
		}
	}
} # sub build_home

################
sub build_bin {
################
	foreach my $path (@binpaths) {
		make_path("$chrootdir/$path", { mode => 0755 })
			if (-d "$path");
	}

	# FreeBSD needs help with the ELF loader.
	if ($os eq 'FreeBSD') {
		make_path("$chrootdir/libexec", { mode => 0755 })
			unless (-d "$chrootdir/libexec");
		system('cp', "$elf_loader", "$chrootdir/libexec/")
			and printf STDERR "Unable to copy \"$elf_loader\".\n";
	}

	# Create the fallback directory if it does not exist.
	make_path("$chrootdir/usr/bin", { mode => 0755 })
		unless (-d "$chrootdir/usr/bin");

	foreach my $pgm (@binaries) {
		if (!stat "$pgm") {
			printf "Executable \"$pgm\" is missing. Ignoring.\n";
			next;
		}

		my $ret = 1;

		foreach my $path (@binpaths) {
			next unless ($pgm =~ m[^$path/]);

			# Return value of system() is zero at success!
			$ret = system('cp', "$pgm", "$chrootdir/$path/");
			last;
		}

		next if ($ret == 0);

		# Unsuccessful in copying. Fall back to '/usr/bin/'.
		print STDERR "NOTICE: Copying \"$pgm\" to \"$chrootdir/usr/bin/\".\n";
		system('cp', "$pgm", "$chrootdir/usr/bin/")
			and printf STDERR "Unable to copy \"$pgm\".\n";
	}
} # sub build_bin

################
sub build_lib {
################
	make_path("$chrootdir/lib", { mode => 0755 });
	make_path("$chrootdir/usr/lib", { mode => 0755 });

	# Take care of 64-bit systems.
	system('ln', '-s', 'lib', "$chrootdir/lib64")
		unless (-s "$chrootdir/lib64" or -d "$chrootdir/lib64");
	system('ln', '-s', 'usr/lib', "$chrootdir/lib64")
		unless (-s "$chrootdir/usr/lib64" or -d "$chrootdir/lib64");

	# Initially do just a minimal library support,
	# based on informed guesswork.
	foreach my $lib (@liblist) {
		if (-f "$lib") {
			my $dirpart = $lib;

			$dirpart =~ s,/[^/]*$,,;

			make_path($chrootdir . $dirpart, { mode => 0755 })
				unless (-d $chrootdir . $dirpart);

			system('cp', "$lib", "$chrootdir" . "$lib");

			printf "Copying '$lib'.\n"
				if ($opt_verb);
		} elsif ($opt_verb) {
			printf STDERR "Missing '$lib' is ignored.\n";
		}
	}

	# TODO: Prove that the sensing of binaries in '$chrootdir/bin/',
	# as implemented by "sub span_lib_calls", is sufficient to run all
	# installed binaries.
	#
	my $hashref = span_lib_calls @binaries;

	foreach my $name (keys %$hashref) {
		if (-f $hashref->{"$name"}) {
			unless ( -s $chrootdir . $hashref->{"$name"} ) {
				my $dirpart = $hashref->{"$name"};

				$dirpart =~ s|/[^/]*$||;

				make_path($chrootdir . $dirpart,
					  { mode => 0755 })
					unless (-d $chrootdir . $dirpart);

				system('cp', $hashref->{"$name"},
					$chrootdir . $hashref->{"$name"});

				printf "Copying '" . $name . "'.\n"
					if ($opt_verb);
			}
		}
	}
} # sub build_lib

#########################
sub span_lib_calls (@) {
#########################
	# Input: array of file names.
	# Return: hash of library calls.
	#
	my %libhashes = ();
	my @namelist = @_;

	foreach my $name (@namelist) {
		open (LDD, "/usr/bin/ldd $name |")	or next;
		while (<LDD>) {
			next	if ( m[/ld-] or not m[ => /] );
			chomp;
			my ($indents, $bin, $discard, $target) = split /\s+/;

			if (!defined $libhashes{"$bin"}) {
				$libhashes{"$bin"} = "$target";
			}
		}
	}

	return \%libhashes;
} # sub span_lib_calls

__END__


=head1 NAME

mkchroot-rush.pl - a small chroot builder

=head1 SYNOPSIS

mkchroot-rush.pl [I<options>]

=over 4

=item B<--binaries> I<string>

Install these executables.

=item B<--binpaths> I<string>

Paths to binaries being acceptable in chroot.

=item B<--chrootdir> I<path>

Build below this directory.

=item B<--config> I<file>

Use this configuration file.

=item B<--force>

Enforce actions, overriding some tests.

=item B<--tasks> I<string>

Execute the subtasks.

=item B<--userlist> I<string>

Override the user setup with this CVS list.

=item B<--verbose>

Make some select printout.

=back

Here I<string> is a comma separated list.

=head1 OPTIONS

=over 4

=item B<--binaries> I<csv-string>

Install these binaries into the chroot. Their library dependencies
will hopefully be tracked down by the task I<lib>. Beware that any
interpreter for scripts should be included in this list.

Config file setting name: @binaries.

=item B<--binpaths> I<csv-string>

Create these paths below the chroot directory. Any executable imported
from either of these paths under the host root file system, will be
installed in the chroot with the original path prepended. The fallback
directory is F<$chrootdir/usr/bin/>.

Config file setting name: I<@binpaths>.

=item B<--chrootdir> I<path>

Build the chroot skeleton below this path. The designated directory
must __not__ exist at invokation time; this is for rudimentary security.
This test for non-existence can be overridden using the option '--force'.

Config file setting name: I<$chrootdir>.

=item B<--config> I<file>

Use this configuration file. The default location is
F</etc/rush-mkchroot.conf>.
This location can only be side-stepped on the command line.

=item B<--force>

Relax the runtime checks.

Config file setting name: I<$force>.

=item B<--tasks> I<csv-string>

Comma separated list of desired task. The default is to build each of
the tasks I<bin>, I<dev>, I<etc>, and I<lib>, but not to execute I<home>.

Config file setting name: I<@tasks>.

=item B<--userlist> I<csv-string>

A comma separated list of user names. The resulting collection of user names
overrides any specification in the configuration file.

Config file setting name: I<@userlist>.

=item B<--verbose>

Print more informational text during execution.

=back

=head1 DESCRIPTION

This program builds a chroot skeleton directory tailored for use with
B<GNU Rush>.
The process can be broken down into simpler subtasks:

=over 8

=item I<etc>

Create F<$chroot/etc/nsswitch.conf> and populate
F<$chroot/etc/{passwd,group}>.

=item I<dev>

Create F<$chroot/dev/null>.

=item I<home>

Build distinct home directories for each of the given users.

=item I<bin>

Install the given executables. Their installation paths can
be influenced with I<@binpaths>. The fallback location is
in F<$chrootdir/usr/bin/>.

=item I<lib>

Install any libraries, on which the binaries depend.
They all go into F<$chrootdir/lib/>.

=back

There is a way to preseed the collection of installed libraries:
the array I<@liblist> can be used in the configuration file. The hardcoded
default is to include the dependencies for F</bin/sh>, thus including
the dynamic loader and the active Libc variant, and then to augment these
with F<libnss_files.so.2> and F<libnss_dns.so.2>. This way the needs for
I<GNU/Linux> and I<GNU/kFreeBSD> are covered. For I<FreeBSD>, the loader
F<ld-elf.se.1> is added behind the scene.  The array I<@liblist> can be
replaced in any configuration file.

=head1 FILES

=over 4

=item F</etc/mkchroot-rush.conf>

Default configuration file.

=back

=head1 VERSION

Version 0.3. Aiming at I<FreeBSD>, I<GNU/Linux>, and I<GNU/kFreeBSD>.

=head1 BUGS

The detection of dynamic library dependencies is not complete. Presently
the method is to use B<ldd> on any executable to be installed, and from
that outcome add libraries as need may be. This should catch any first
strata dependency.

The fallback directory for depositing binaries is F<$chrootdir/usr/bin/>,
giving a notice if even this fails. Such a notice could be considered
as an annoying bug.

That all dynamic libraries go into F<$chrootdir/lib/> might be considered
as an even larger bug.

=head1 SEE ALSO

I<rush>(1), or B<info rush> for full information.

=head1 AUTHOR

Mats Erik Andersson, 2010 - 2013, for the Debian packaging of B<GNU Rush>.

=head1 COPYRIGHT

Copyright 2010 - 2013, Mats Erik Andersson

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 3, or (at your option)
any later version.

=cut
