From ad29df951a488970766d7ef10535f02d742cd630 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Sun, 28 Apr 2024 08:31:12 +0200 Subject: [PATCH] Initial commit --- .perltidyrc | 4 + Makefile | 11 ++ README.md | 131 ++++++++++++++- impermanence.1 | 159 ++++++++++++++++++ impermanence.pl | 355 +++++++++++++++++++++++++++++++++++++++ openbsd/impermanence.rc | 22 +++ tests/errored.yml | 2 + tests/impermanence.yml | 25 +++ tests/size_undefined.yml | 4 + 9 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 .perltidyrc create mode 100644 Makefile create mode 100644 impermanence.1 create mode 100755 impermanence.pl create mode 100755 openbsd/impermanence.rc create mode 100644 tests/errored.yml create mode 100644 tests/impermanence.yml create mode 100644 tests/size_undefined.yml diff --git a/.perltidyrc b/.perltidyrc new file mode 100644 index 0000000..256331a --- /dev/null +++ b/.perltidyrc @@ -0,0 +1,4 @@ +-i=2 +-ce +-sbl +-wn diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7e5c397 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +PREFIX=/usr/local + +test: + ./impermanence.pl -v -t tests/impermanence.yml + -./impermanence.pl -t tests/errored.yml + -./impermanence.pl -t tests/size_undefined.yml + +install: + install -o root -g wheel -m 555 impermanence.pl ${PREFIX}/bin/impermanence + install -o root -g wheel -m 555 openbsd/impermanence.rc /etc/rc.d/impermanence + install -o root -g wheel -m 444 impermanence.1 ${PREFIX}/man/man1/impermanence.1 diff --git a/README.md b/README.md index 5a4a8ee..f0e958b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,132 @@ # home-impermanence -A fork of https://tildegit.org/solene/home-impermanence from Solène Rapenne \ No newline at end of file +OpenBSD and Linux compatible implementation of the [impermanence project from the NixOS community](https://nixos.wiki/wiki/Impermanence) + +Such a tool permits to have your $HOME mounted with a memory filesystem and populate it from an explicit list of files and directories hooked from a persistent storage directory (like a place in your /home partition), the point is to have a clean and reproducible environment every time you log in with only the content you selected. No more extra files when you start a program only once. + +# Installation + +Run `make install` as root, this will copy the program file in `/usr/local/bin/impermanence` and the service file in `/etc/rc.d/impermanence`. + +On OpenBSD, You need some packages as dependencies: +- p5-File-HomeDir +- p5-List-MoreUtils +- p5-YAML + +On Alpine Linux, you need the following packages: `apk add perl-file-homedir perl-list-moreutils perl-yaml`. + +# Configuration + +The configuration is done in two parts, system wide to configure the **impermanence** service that will mount the memory filesystem and populate it. + +## System wide + +### OpenBSD + +Using rcctl: `rcctl set impermanence flags -d /home/persist/ -u my-user` and `rcctl enable impermanence`. + +### Alpine Linux + +Lazy way is to enable the service local with `rc-update add local`, and create two files `/etc/local.d/99-impermanence.start` and `/etc/local.d/99-impermanence.stop` with the following content: + +``` +#!/bin/sh +/usr/local/bin/impermanence -d /home/persist -u my-user start +``` + +and + +``` +#!/bin/sh +/usr/local/bin/impermanence -d /home/persist -u my-user stop +``` + +## User configuration + +The user configuration will be done in `/home/persist/my-user/impermanence.yml` if you chose `-d /home/persist` for the service and `-u my-user`. + +The configuration file describes the size of the memory filesystem, the list of files and the list of directories that should be added to the filesystem as symbolic links from the persistent directory. + +There are currently three keys: +- **size**: which is a parameter to mount_mfs -s to give the ramdisk size +- **files**: which is a list of files relative to $HOME +- **directores**: which is a list of directories relative to $HOME + +Minimalistic example of `/home/persist/my-user/impermanence.yml`: + +``` +size: 200m +files: + - .bashrc + - .gitconfig + - .profile + - .tmux.conf + - .xsession +directories: + - .config + - .local/share + - .mozilla + - .ssh + - Data + - Documents + - Downloads + - dev +``` + +# home-impermanence rc service + +## restart + +The restart parameter to the service will unmount the device and recreate it, allowing a fresh restart. + +It is a bad idea to use while the user is connected. + +## start + +Creates and populates the home filesystem. + +## stop + +Umount the home filesystem. + +It is a bad idea to use while the user is connected. + +## status + +Tells if the mount is currently done. + +# Tips + +## I configured something in a GUI program, how do I know what changed on disk? + +If you want to add a file to the persistent area after a change, you may want to know exactly what changed on disk to add the file or directory to your configuration file. + +Using `find` it's easy to scan all the files from the ramdisk (excluding the symbolic links) and order them by date of change. + +This can be done with `find -x ~/ -type f -exec ls -altr {} +`, the last files are the most recently modified. + +## Beware file loss + +When using this way of life, you need to remember all changes that don't belong in the persistent areas will be lost. For example, this will happen for all new files or directories at the root of your $HOME. + +Impermanence requires the user to be aware of what files must stay over time, this is the point of impermanence after all. + +## I want to make a new file/directory persistent + +If you are using your system and want to keep a newly created file or directory, move it to your persistent area at the correct place and create a symbolic link, this will allow a drop-in replacement without rebooting. + +Then, update your configuration file to add the new entry. + +## How does a good configuration file look + +There are no good or bad configuration file content (except if it's invalid obviously). The whole point of impermanence is to hand-pick every directories and files you want to run your session, by admitting all others files will be thrown away at reboot. + +While you can list `.config` and `.local` which is a very large include, you could rather list only a subset of those, which will make a long list and require a few guess&fix sessions to get the things right. + +The less directories are at the top level, the more you will pinpoint the exact configuration you want to keep over time. + +## Restarting impermanence + +If you are actively tweaking your configuration file, you may have issues when impermanence is unmounting the ramdisk device even with your graphical session stopped, a process may still be running and using the partition. You would have to find the running PID by looking at processes and their owner and kill it. + +As a side effect, you shouldn't be able to stop impermanence while you are using your session because the system will prevent the ramdisk to be umounted. diff --git a/impermanence.1 b/impermanence.1 new file mode 100644 index 0000000..c544e37 --- /dev/null +++ b/impermanence.1 @@ -0,0 +1,159 @@ +.Dd $Mdocdate: March 14 2022 $ +.Dt IMPERMANENCE 1 +.Os +.Sh NAME +.Nm impermanence +.Nd manage a ramdisk with a collection of persistent files +.Sh SYNOPSIS +.Nm +.Fl t Ar conf +.Nm +.Op Fl v +.Fl d Ar directory +.Fl u Ar user +.Op Cm start | Cm stop | Cm status | Cm restart +.Sh DESCRIPTION +.Pp +.Nm +is a program to manage an user directory mounted as a volatile +ramdisk and filling it with symlinks to persistent files and +directories taken from an user managed configuration file. +.Pp +The advantages of such a setup is to have a reproducible home +environment that won't keep undesirable changes over time which +happen regularly when running new software or different desktop +environment. +.Pp +However, this requires the user to be mindful of the setup to prevent +data loss. +.Sh OPTIONS +.Bl -tag -width -u user -c /home/persistent +.It Fl t Ar conf +Enable test mode which will parse the configuration file +.Ar conf , +validates it and output its content in a human readable format +before exiting gracefully. +.It Op Fl v +Enable verbose mode adding debug information to stderr and syslog. +.It Fl d Ar directory +Tell +.Nm +where to look for the user persistent directory. +Note that it will look for a directory with the user name in the +directory given in this parameter. +.It Fl u Ar username +Tell +.Nm +which user will have its $HOME directory mounted as ramdisk. +.It Cm start +Create the ramdisk using +.Xr mount_mfs 8 +and fill it with the listed content using +.Xr symlink 2 +from the persistent directory for each file and directory listed +in the configuration file. +.It Cm stop +Umount the ramdisk using +.Xr umount 8 , +all data that wasn't listed in the configuration file at start time +will be lost. +.It Cm restart +Do a stop and a start. +.It Cm status +Returns 0 if the user directory is already mounted as mfs, returns +1 otherwise. +.El +.Sh FILES +.Nm +will look for a file +.Sy impermanence.yml +in the directory +.Ar user +found in the directory +.Ar directory. +.Pp +The +.Sy impermanence.yml +file is a YAML formatted configuration file using three keys. +.Bd -literal -offset indent +size: 200m +files: + - .bashrc + - .xsession +directories: + - .config + - .ssh + - Documents +.Ed +.Pp +With +.Cm size +being the size parameter given to +.Xr mount_mfs 8 +.Cm files +being the list of files relative to the user persistent directory +that must be symlinked to the ramdisk. +.Cm directories +being the list of directories relative to the user persistent +directory that must be symlinked to the ramdisk. +.Sh EXIT STATUS +.Pp +In case of a fatal error, +.Nm +will exit on a status code 2. +.Pp +In case of a misusage, +.Nm +will exit on a status code 1. +.Pp +In normal operations, +.Nm +will exit on a status code 0. +.Sh EXAMPLES +Let's say you want user +.Em alice +to have its +.Em /home/alice +$HOME directory mounted with a ram disk. +.Pp +You need to create a place where to store +.Cm alice +files, you can move +.Sy /home/alice +to +.Sy /home/persistent/alice +and recreate +.Sy /home/alice . +.Pp +Create a file in +.Sy /home/persistent/alice/impermanence.yml +following the file format to list every file and +directories that must be linked from +.Sy /home/persistent/alice/ +to +.Sy /home/alice/ +when +.Nm +is started using the following command line: +.Bd -literal -offset indent +impermanence -u alice -d /home/persistent start +.Ed +.Sh DIAGNOSTICS +.Nm +has a verbose mode to get more information. +Normal output and verbose output if enabled are both sent to +.Xr syslog 3 . +.Sh SEE ALSO +.Xr mount_mfs 8 , +.Xr symlink 2, +.Xr syslog 3, +.Xr umount 8 +.Sh HISTORY +This software tries to be an OpenBSD implementation of the NixOS +community impermanence module. +.Sh AUTHORS +.An See the LICENSE file for the authors . +.Sh LICENSE +See the LICENSE file for the terms of redistribution. +.Sh BUGS +Some programs may misbehave under these conditions. diff --git a/impermanence.pl b/impermanence.pl new file mode 100755 index 0000000..09ac409 --- /dev/null +++ b/impermanence.pl @@ -0,0 +1,355 @@ +#!/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 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 $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(); diff --git a/openbsd/impermanence.rc b/openbsd/impermanence.rc new file mode 100755 index 0000000..7ac5104 --- /dev/null +++ b/openbsd/impermanence.rc @@ -0,0 +1,22 @@ +#!/bin/ksh +# + +daemon="/usr/local/bin/impermanence" + +. /etc/rc.d/rc.subr + +rc_reload=NO + +rc_start() { + $daemon ${daemon_flags} start +} + +rc_stop() { + $daemon ${daemon_flags} stop +} + +rc_check() { + $daemon ${daemon_flags} status +} + +rc_cmd $1 diff --git a/tests/errored.yml b/tests/errored.yml new file mode 100644 index 0000000..92bcaf9 --- /dev/null +++ b/tests/errored.yml @@ -0,0 +1,2 @@ +key: +value diff --git a/tests/impermanence.yml b/tests/impermanence.yml new file mode 100644 index 0000000..180274c --- /dev/null +++ b/tests/impermanence.yml @@ -0,0 +1,25 @@ +size: 500m +files: + - .Xdefaults + - .Xresources + - .bashrc + - .gitconfig + - .kshrc + - .profile + - .xsession + - .config/fish/config.fish + - Datastore/Music/Band1/file.ogg +directories: + - .config/fish + - .config/kak + - .mozilla + - foo/bar/those + - Documents + - Downloads + - Datastore/Music + - Datastore + - Datastore/ + - Datastore/Music/Band1 + - .config + - foo/bar + - foo/bar/hello diff --git a/tests/size_undefined.yml b/tests/size_undefined.yml new file mode 100644 index 0000000..05fc3de --- /dev/null +++ b/tests/size_undefined.yml @@ -0,0 +1,4 @@ +files: + - foo +directories: + - bar