#!/usr/bin/env perl use strict; use warnings; use YAML; use File::Basename qw(fileparse); use List::MoreUtils qw(uniq); use Getopt::Std; use Data::Dumper; use File::HomeDir; use File::Path qw(make_path); use Sys::Syslog qw(:standard :macros); use 5.010; my $isOpenBSD = ( $^O eq 'openbsd' ) ? 1 : 0; my $verbose = 0; # display usage and quit sub usage { say "usage: $0 [-v] -d directory -u user (start|stop|restart|status)"; say " $0 -t conf"; exit 1; } # display a warning unconditionally sub saywarning { my $msg = shift; say STDERR "WARNING: $msg"; syslog( LOG_WARNING, "$msg" ); } # display a message only if verbose flag is used sub saydebug { my $msg = shift; say STDERR "DEBUG: $msg" if $verbose; syslog( LOG_INFO, "$msg" ); } # display a message before exiting with status 2 sub trap_error { my $msg = shift; say STDERR "FATAL: $msg"; syslog( LOG_ERR, "$msg" ); exit 2; } # return a perl structure by reading a YAML file sub read_yml_file_to_struct { my $file = shift; open my $fh, '<', $file or die; $/ = undef; my $data = <$fh>; close $fh; return Load($data); } # some files or directories may be included in a listed directory # remove those from the list and print a warning sub remove_transclusion { my $data = shift; my @files = uniq @{ $data->{"files"} }; my @directories = @{ $data->{"directories"} }; foreach (@directories) { if ( $_ =~ m|/$| ) { saywarning("removing trailing slash in $_"); $_ =~ s|/$||; } } @directories = uniq @directories; for ( my $i = 0 ; $i <= $#directories ; $i++ ) { my $dir = $directories[$i]; next if ( $dir eq "" ); # check if a file is contained by a directory foreach my $file (@files) { my ( $filename, $dirs, $suffix ) = fileparse($file); if ( $dirs =~ m/^$dir/ ) { saywarning("WARNING: $dir contains $file"); $file = ""; } } # check if a directory is contained by another directory # if so, mark them for deletion later # we can't remove directly from the array because we # are iterating in it in the main loop for ( my $j = 0 ; $j <= $#directories ; $j++ ) { if ( $j != $i ) { my $dir2 = $directories[$j]; next if ( $dir2 eq "" ); if ( $dir =~ m/\/$dir2/ ) { saywarning("$dir2 contains $dir"); $directories[$i] = ""; } } } } # remove empty entries @directories = sort grep { /^./ } @directories; @files = sort grep { /^./ } @files; $data->{"files"} = \@files; $data->{"directories"} = \@directories; return $data; } # create all the links in the ramdisk sub populate_ramdisk { my ( $data, $persist_home, $impermanence_home, $user ) = @_; saydebug("create the symlinks for files set"); create_links( $data->{"files"}, $persist_home, $impermanence_home, $user ); saydebug("create the symlinks for directories set"); create_links( $data->{"directories"}, $persist_home, $impermanence_home, $user ); saydebug("create the skeleton directories set"); create_skeleton( $data->{"skeleton"}, $impermanence_home, $user ); saydebug("call chezmoi to create config files"); call_chezmoi($user); } # check if the mountpoint is already mounted with mfs sub is_mounted { my $impermanence_home = shift; my $mounted = 0; my @output; # is this already mounted? @output = split( "\n", `/bin/df $impermanence_home` ); @output = split( " ", $output[$#output] ); if ( $output[5] eq $impermanence_home && $output[0] =~ m/^(mfs|tmpfs)/ ) { $mounted = 1; } return $mounted; } # OpenBSD: mount the destination with a ramdisk only if not currently mounted sub mount_mfs { my ( $user, $impermanence_home, $data ) = @_; my $ret; my $filesystem; saydebug("finding a ffs mountpoint to use for mfs"); if ( is_mounted($impermanence_home) ) { trap_error("ERROR: $impermanence_home is already mounted with MFS"); } my @fs = split( "\n", `/sbin/swapctl` ); @fs = split( " ", $fs[$#fs] ); $filesystem = $fs[0]; if ( $filesystem !~ m|^/dev/| ) { trap_error("found swap device $filesystem doesn't start with /dev"); } saydebug("mount the destination using mount_mfs from $filesystem"); $ret = system( "/sbin/mount_mfs", "-s", $data->{size}, $filesystem, $impermanence_home ); if ( $ret != 0 ) { trap_error("ERROR: mounting the mfs filesystem errored with error $ret"); } else { saydebug("mount_mfs done on $impermanence_home"); } } # Linux: mount the destination with a ramdisk only if not currently mounted sub mount_tmpfs { my ( $user, $impermanence_home, $data ) = @_; my $ret; saydebug("mount the destination tmpfs"); $ret = system( "/bin/mount", "-t", "tmpfs", "tmpfs", "-o", "size=$data->{size}", $impermanence_home ); if ( $ret != 0 ) { trap_error("ERROR: mounting the tmpfs filesystem errored with error $ret"); } else { saydebug("tmpfs done on $impermanence_home"); } } # create the symbolic links listed in the yml file into the ramdisk destination sub create_links { my $list = shift; my ( $persist_home, $impermanence_home, $user ) = @_; foreach ( @{$list} ) { my $old_file = $persist_home . "/" . $user . "/" . $_; my $new_file = $impermanence_home . "/" . $_; my ( $filename, $dirs, $suffix ) = fileparse($new_file); # recursively create missing directories to hold files if ( !-e $dirs ) { make_path( $dirs, { chmod => 0750, owner => $user } ); } if ( !-e $old_file ) { saywarning("$old_file doesn't exist"); } if ( symlink( $old_file, $new_file ) == 0 ) { trap_error("symlink $old_file to $new_file"); } else { saydebug("ln -s $old_file $new_file"); } } } sub create_skeleton { my $list = shift; my ( $impermanence_home, $user ) = @_; foreach ( @{$list} ) { my $dir = $impermanence_home . "/" . $_; saydebug("Create directory $dir"); make_path( $dir, { chmod => 0750, owner => $user } ); } } sub call_chezmoi { my $user = shift; my $ret = system("su -l $user -c \"chezmoi --force apply\""); if ( $ret != 0 ) { trap_error("ERROR: Calling chezmoi apply errored with error $ret"); } else { saydebug("chezmoi finished successfully"); } } sub get_uid { my $user = shift; my $s = `userinfo $user`; if ( $s =~ /uid\t([0-9]+)/s ) { my $uid = $1; return $uid; } else { trap_error("ERROR: userinfo did not return uid"); } } sub main { my %opts; my ( $impermanence_home, $persist_home, $configuration_file, $data, $ret ); my ( $start, $stop ) = ( 0, 0 ); # define command line parameters getopts( "vt:d:u:", \%opts ); # verbose mode for debug output if ( defined $opts{v} ) { $verbose = 1; } # check if using test mode to validate a configuration file if ( defined $opts{t} ) { say("test mode enabled"); $configuration_file = $opts{t}; # non-test mode, mount the ramdisk and populates it } else { # -d and -u flags are mandatory if ( !defined $opts{d} || !defined $opts{u} ) { usage(); } # test if the script is running as root if ( $< != 0 ) { trap_error("$0 must be run as root."); } $impermanence_home = File::HomeDir->users_home( $opts{u} ); if ( $impermanence_home !~ m|^/| ) { trap_error( "The user \$HOME doesn't start with / , its value is $impermanence_home" ); } if ( !-d $impermanence_home ) { trap_error("The user \$HOME $impermanence_home doesn't exist"); } $persist_home = $opts{d}; if ( $persist_home !~ m|^/| ) { trap_error( "The persistent directory $persist_home must be an absolute path"); } if ( !-d $persist_home ) { trap_error("The persistent directory $persist_home doesn't exist"); } $configuration_file = $persist_home . "/" . $opts{u} . "/impermanence.yml"; } # exit if the configuration file is not available if ( !-f $configuration_file ) { trap_error( "The file " . $configuration_file . " can't be found" ); } # read file and sanitize content $data = read_yml_file_to_struct($configuration_file); if ( !defined $data->{size} || $data->{size} eq 0 ) { trap_error("size not defined in configuration file"); } $data = remove_transclusion($data); # display result and stop if in test mode if ( defined $opts{t} ) { print Dumper $data; exit 0; } if ( $ARGV[0] eq "start" ) { $start = 1; } elsif ( $ARGV[0] eq "restart" ) { $stop = 1; $start = 1; } elsif ( $ARGV[0] eq "stop" ) { $stop = 1; } elsif ( $ARGV[0] eq "status" ) { exit !is_mounted($impermanence_home); } else { usage(); } if ( $stop && is_mounted($impermanence_home) ) { my $uid = get_uid( $opts{u} ); system( "pkill", "-U $uid" ); my $status = system( "umount", $impermanence_home ); if ( $status != 0 ) { trap_error("umount did exit with status $status"); } } if ($start) { if ($isOpenBSD) { mount_mfs( $opts{u}, $impermanence_home, $data ); populate_ramdisk( $data, $persist_home, $impermanence_home, $opts{u} ); } else { mount_tmpfs( $opts{u}, $impermanence_home, $data ); populate_ramdisk( $data, $persist_home, $impermanence_home, $opts{u} ); } } } openlog( "impermanence", 'ndelay', LOG_DAEMON ); main(); closelog();