From 26031385344cc641e8c7fa0a8cc80db1b7283c21 Mon Sep 17 00:00:00 2001 From: Anton Lydike Date: Tue, 29 Nov 2022 15:47:43 +0000 Subject: [PATCH] initial commit --- .mounts | 3 + LICENSE | 7 ++ README.md | 85 +++++++++++++ functions/coalesce.fish | 8 ++ functions/mnt.fish | 239 ++++++++++++++++++++++++++++++++++++ functions/mount-luks.fish | 35 ++++++ functions/mount-vc.fish | 39 ++++++ functions/shorten_path.fish | 17 +++ install.fish | 191 ++++++++++++++++++++++++++++ 9 files changed, 624 insertions(+) create mode 100644 .mounts create mode 100644 LICENSE create mode 100644 README.md create mode 100644 functions/coalesce.fish create mode 100644 functions/mnt.fish create mode 100644 functions/mount-luks.fish create mode 100644 functions/mount-vc.fish create mode 100644 functions/shorten_path.fish create mode 100755 install.fish diff --git a/.mounts b/.mounts new file mode 100644 index 0000000..56f201d --- /dev/null +++ b/.mounts @@ -0,0 +1,3 @@ +# mounts file, read by the mnt script. See README for more info. +# columns are tab separated +# blockdevice local path where it is usually mounted to command to mount command to unmount name diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..af7f008 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright © 2022 Anton Lydike + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dd5dfa --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# mnt - A mount/unmount utility for the fish shell + +`mnt` wants to be the fastest way to mount/unmount external media from the command line. You can configure custom mount commands, it has autocompletion and some user-friendly matching logic. + +## Install + +Run the `install.fish` script to install `mnt.fish` into your fish functions (and completions). It install all dependencies as well, which are the `coalesce` and `shorten_path` fish functions, they are: + + * `coalesce ARGS...` prints the first non-empty argument. + * `shorten_path /path/to/somewhere` shortens all path segments except the last one to be one character, smilar to fishs prompt. + +The install script expects your fish config folder to reside in `~/.config/fish/`. If it cant find `~/.config/fish/config.fish` it will abort the installation. You can either pass the path to your fish config to the install script, or install it manually. The install script symlinks the files from this repo into your fish config folder, which allows for easy updating using `git pull` in this repo. If you don't want that, you can specify the `--copy` flag to the install script to copy the files instead. + +### External Dependencies + +This script uses `ripgrep`, `blkid`, `jq` and `udisksctl`. Make sure they are installed on your system, or you'll get weird errors! + +### Extra Scripts + +This repo also contains two more scripts, one named `mount-luks` and one called `mount-vc`. They: + + - `mount-luks DEVICE PASS_KEY MAPPER_NAME` mount a luks-encrypted volume using a password from the pass password manager (unmount using `mount-luks -u MAPPER_NAME`) + - mount a veracrypt volume using either a keyfile, or a password. Note that since the password is passed as an argument to the veracrypt process, it can be observed by other processes until the veracrypt process terminates (I am not exactly sure how long that is though, probably only until it finished mounting, but it could be longer.) + +You can copy and modify these scripts as needed. + +## Usage + +Run `mnt` to get a status listing for all mounts (Same as `mnt -l`). Example output: + +``` +> mnt +[-] /d/d/b/4dfea5ae-d72a-4c9c-b3e5-5695f6ac40a0 NVME USB +[m] /d/s/r/m/a/writable (54,8G) +[u] /d/sdc1 ??? (3,1G) +[u] /d/sdc2 ??? (3,9M) +``` + +The status of each mount is indicated by the color and symbol in front: + + - `[-]` (red) - This volume is currently unavailable, it cannot be mounted + - `[u]` (yellow) - This volume is unmounted, you can mount it + - `[m]` (green) - This volume is mounted, you can unmount it + +It also prints the device, the devices label, if available (otherwise `???`) and the devices size (if it can be determined). + +You can mount a volume by simply specifying `mnt IDENTIFIER`, it will autocomplete available block devices for you, but you can also specify (parts of) labels or size, basically anything that identifies the volume uniquely. (The volume is selected by grepping for `IDENTIFIER` in the output of `mnt_core_list_mounts`) + +Unmounting is just as easy, you can run `mnt -u IDENTIFIER`, with the same rules for the identifier. The autocompletion will only offer you already mounted devices. + +### Configuration + +The script mounts block devices using `udisksctl` by default. If you have a more involved setup, you can edit the `.mounts` file in your home directory. The format is roughly documented in the file, but I'll go into more detail here: + +The file contains five columns, separated by tabs (real tab characters). Empty lines, or lines starting with a `#` are skipped. + +The columns are: + + - block device + - path where it would mount to + - command to mount (can be a fish function, can use arguments, just be careful with tab characters. You currently cannot escape them in the config file). the variables `$device` and `$path` can be used here, they will be replaced with the block device, and mount path respectively. + - command to unmount (see above) + - display name + +You can use the `mount-vc` and `mount-luks` scripts here for ease of use. e.g. + +``` +> cat .mounts +# my luks-encrypted USB +/dev/disk/by-uuid/ /run/media// mount-luks $device devices/my_luks_usb luks_usb mount-luks -u luks_usb My Luks USB +# my veracrypt HDD using a key in ~/.keys +/dev/disk/by-uuid/ /run/media// mount-vc $device my-hdd-key $path mount-vc -u $path Veracrypt HDD +``` + +You can then mount/unmount these using `mnt Luks` or `mnt HDD` (because of the way the `IDENTIFIER` is resolved to an entry in the volume table). + +## TODO: + +This script currently ignores /dev/sda and /dev/nvme0n1 and all their partitions. This is non-configurable at the moment, the only way to change this behaviour is to edit the `functions/mnt.fish` file and change the `set _MNT_SEEN_DEVICES` line. + +You can add ignroed devices using the `MNT_IGNORE_DEVICES` environment variable. We might be able to phase out the `_MNT_SEEN_DEVICES` variable in favour of this. On the other hand, it's questionable if we want to pollute the environment with some info that might be better suited to a config file. But if we have *two* config files, we should probably move them into a `.config/mnt/` folder or something. That's why I haven't done this refactor yet. + +## License + +These scripts are licensed under the MIT license diff --git a/functions/coalesce.fish b/functions/coalesce.fish new file mode 100644 index 0000000..5ad924e --- /dev/null +++ b/functions/coalesce.fish @@ -0,0 +1,8 @@ +function coalesce + for x in $argv + if test -n "$x" + echo "$x" + return + end + end +end diff --git a/functions/mnt.fish b/functions/mnt.fish new file mode 100644 index 0000000..45eb6c4 --- /dev/null +++ b/functions/mnt.fish @@ -0,0 +1,239 @@ +set _MNT_SEEN_DEVICES /dev/sda /dev/nvme0n1 + +function mnt + + argparse --name=mnt 'h/help' 'l/list' 'f/full-paths' 'u/unmount' -- $argv + + if set -q _flag_help + echo "mnt - A mounting utility" + echo + echo "Usage: mnt -hlfu [IDENTIFIER]" + echo + echo "Flags" + echo " -l/--list List information on all available mounts (default behaviour if no IDENTIFIER" + echo " was specified)" + echo + echo " -h/--help Print this help page" + echo + echo " -u/--unmount Unmount specified path" + echo + echo " -f/--full-paths Print full paths instead of the shorter versions" + echo + echo "IDENTIFIER" + echo " The IDENTIFIER can be anything that identifies a line in the output of" + echo " mnt_core_list_mounts when using grep" + return 0 + end + + if set -q _flag_list; or ! count $argv > /dev/null + mnt_core_pretty_list_mounts $_flag_full_paths | sort + return 0 + end + + set -l selected_mount (mnt_core_list_mounts | rg -- (string trim -rc '/' -- $argv[1])) + + if test (count $selected_mount) -gt 1 + echo '"'"$argv[1]"'" is ambigous, it matched:' + for line in $selected_mount + echo " - "(string split \t $line)[1] + end + echo "Please be a little bit more precise!" + # TODO: allow the user to select one of the options + return 1 + end + + if test -z "$selected_mount" + echo "Mount point not found!" + return 1 + end + + + set -l info (string split \t $selected_mount) + set -l device $info[1] + set -l path $info[2] + + set -l mount_point (mnt_core_mount_point $info[1]) + + if set -q _flag_unmount + if test -z "$mount_point" + set_color red + echo $argv[1] "might not actually be mounted!" + set_color normal + end + echo $info[4] + eval $info[4] + else + if test -n "$mount_point" + echo $argv[1] "is already mounted at $mount_point!" + return 1 + end + + echo $info[3] + eval $info[3] + end + +end + + +function mnt_core_pretty_list_mounts + argparse 'f/full-paths' -- $argv + + for line in (mnt_core_list_mounts) + set -l info (string split \t $line) + set -l pretty_print_mode mount + if test -d $info[2] + set_color green + echo -n "[m] " + set pretty_print_mode unmount + else if test -b $info[1] + set_color yellow + echo -n "[u] " + else + set_color red + echo -n "[-] " + end + + set pretty (string split '\t' (mnt_core_pretty_print_line $_flag_full_paths $pretty_print_mode $line)) + + if ! set -q _flag_full_paths + set pretty[1] (shorten_path $pretty[1]) + end + + echo $pretty + end +end + + +function mnt_core_pretty_print_line + argparse 'f/full-paths' -- $argv + + set -l info (string split \t $argv[2]) + + set -l path_printer shorten_path + if set -q _flag_full_paths + set path_printer echo + end + + # unpack info + set -l device $info[1] + set -l mount_path $info[2] + set -l mount_cmd $info[3] + set -l unmount_cmd $info[4] + set -l name (coalesce "$info[5]" ($path_printer (string replace '-' '' $mount_path)) "???") + set -l size $info[6] + set -l cmd "$unmount_cmd" + + # decide if to print the mount or unmount cmd + if test $argv[1] = 'mount' + set cmd "$mount_cmd" + end + + set -l tab \t + + # print #name (size), but leave out missing parts + if test -n "$size" + if test -n "$name" + echo "$device"\t"$name ($size)" + else + echo "$device"\t"($size)" + end + else + if test -n "$name" + echo "$device"\t"$name" + else + echo "$device"\t"$cmd" + end + end +end + +function mnt_core_list_mounts + # list things defined in ~/.mounts + set -l seen $_MNT_SEEN_DEVICES $MNT_IGNORE_DEVICES + for line in (cat ~/.mounts) + # filter out empty lines + if test -z "$line" + continue + end + # filter out comments + if string match -erq '^\s*#.+' "$line" 2> /dev/null + continue + end + + set -l info (string split \t $line) + set -a seen $info[1] + set -l size (mnt_core_get_blockdevice_size $info[1]) + + echo $line\t"$size" + end + + mnt_core_list_block_dev $seen +end + +function mnt_core_list_block_dev + set -l seen $argv + # iterate normal block devices (sdXN) + for device in /dev/sd? + if string match -q -- $device $seen + continue + end + + for part in $device? + if string match -q -- $device $seen + continue + end + # get the mount point, or - if it doesn't exist + set -l mount_point (coalesce (mnt_core_mount_point $part) -) + # get label and size + set -l label (mnt_core_get_blockdevice_label $part) + set -l size (mnt_core_get_blockdevice_size $part) + + echo "$part"\t"$mount_point"\tudisksctl mount -b "'$part'"\tudisksctl unmount -b $part\t"$label"\t"$size" + end + end +end + + +function mnt_core_filter + while read line + set -l info (string split \t $line) + + switch $argv[1] + case available avail + if test -b $info[1] + echo $line + end + case mounted + if mnt_core_mount_point $info[1] > /dev/null + echo $line + end + case unmounted + if test -b $info[1]; and ! mnt_core_mount_point $info[1] > /dev/null + echo $line + end + case '*' + echo $line + end + end +end + +function mnt_core_mount_point + # get block device mount point or children mount point (if child is of type crypt) + # it was introduced to better handle encrypted setups, where not the block device, but the crypt container is mounted + set -l res (lsblk -J $argv 2>/dev/null | jq -r '.blockdevices[0].mountpoints[0] // ( if .blockdevices[0].children then (.blockdevices[0].children[] | select(.type == "crypt") | .mountpoints[0]) else "" end) // ""') + if test -z "$res" + return 1 + end + echo $res +end + +function mnt_core_get_blockdevice_size + set -l res (lsblk -J $argv 2>/dev/null | jq -r '.blockdevices[0].size // ""') + if test -z "$res" + return 1 + end + echo $res +end + +function mnt_core_get_blockdevice_label + blkid -o value --match-tag LABEL $argv +end \ No newline at end of file diff --git a/functions/mount-luks.fish b/functions/mount-luks.fish new file mode 100644 index 0000000..a35e59e --- /dev/null +++ b/functions/mount-luks.fish @@ -0,0 +1,35 @@ +function mount-luks + + argparse 'u/unmount' 'h/help' -- $argv + + if set -q _flag_help + echo "mount-luks - mount and unmount luks containers" + echo + echo "Usage for mounting: mount-luks BLOCK_DEVICE PASS_NAME MAPPER_NAME" + echo " where PASS_NAME is the name of the pass(1) key that contains the volume password" + echo " and MAPPER_NAME is the name of the luks mapper (required for unmounting)" + return 0 + end + + if set -q _flag_unmount + if test (count $argv) -lt 1 -o -z "$argv[1]" + echo "Usage: mount-luks -u MAPPTER_NAME" + end + + set -l mapper $argv[1] + + udisksctl unmount -b /dev/mapper/$mapper + sudo cryptsetup luksClose $mapper + else + if test (count $argv) -lt 3 + echo "Usage: mount-luks BLOCK_DEVICE PASS_NAME MAPPER_NAME" + end + + set -l device $argv[1] + set -l pass_name $argv[2] + set -l mapper $argv[3] + + pass $PASS_NAME | head -n 1 | sudo cryptsetup luksOpen $device $mapper - + udisksctl mount -b /dev/mapper/$mapper + end +end diff --git a/functions/mount-vc.fish b/functions/mount-vc.fish new file mode 100644 index 0000000..e1aea0f --- /dev/null +++ b/functions/mount-vc.fish @@ -0,0 +1,39 @@ +# mount veracrypt volumes using keyfiles or passphrases +function mount-vc --argument device key_name target -d "Mount a veracrypt container with a keyfile stored in /home/anton/.keys" + if test -z "$argv[1]" -o \( "$argv[1]" = "--help" \) -o \( "$argv[1]" = "-h" \) + echo -e "mount-vc: Mount or dismount veracrypt volumes\n\ +\n\ +Usage:\n\ +mount a volume using a keyfile:\n\ +\n\ + mount-vc \n\ +\n\ +mount a volume using a password: (will be prompted for)\n\ +\n\ + mount-vc \n\ +\n\ +dismount a volume:\n\ +\n\ + mount-vc -u \n" + return + end + + + if test "$argv[1]" = "-u" + sudo veracrypt -d "$argv[2]" + return + end + + set dev "$argv[1]" + set target "$argv[3]" + set opts "-t" "--non-interactive" + + if test (count $argv) = 2 + read -s -P "Volume password: " pass + set target "$argv[2]" + set -a opts "--password=$pass" + else + set -a opts "-k" "$HOME/.keys/$argv[2]" "-p" "" + end + sudo veracrypt $opts "$dev" "$target" +end diff --git a/functions/shorten_path.fish b/functions/shorten_path.fish new file mode 100644 index 0000000..2ee4c71 --- /dev/null +++ b/functions/shorten_path.fish @@ -0,0 +1,17 @@ +function shorten_path + if ! string match -q -- '*/*' $argv[1] + echo $argv[1] + return + end + + set -l segments (string split '/' (string trim -r -c '/' $argv[1])) + + if test -n $segments[1] + echo -n (string split '' $segments[1])[1] + end + + for seg in $segments[2..-2] + echo -n '/'(string split '' $seg)[1] + end + echo '/'$segments[-1] +end \ No newline at end of file diff --git a/install.fish b/install.fish new file mode 100755 index 0000000..a1a155c --- /dev/null +++ b/install.fish @@ -0,0 +1,191 @@ +#!/usr/bin/env fish + +function print_warn + echo (set_color yellow)"WARN: $argv" (set_color normal) +end + +function print_err + echo (set_color red)"ERROR: $argv" (set_color normal) +end + +function to_stderr + while read -l line + echo $line 1>&2 + end +end + +function check_program_installed + # check if program $argv[1] is installed, if not, print a warning + if ! command -vq $argv[1] + print_err "External program $argv[1] is required, please install it using your package manager of choice!" | to_stderr + return 1 + end + return 0 +end + +function install_symlink + # install $argv[1] to $argv[2] using a symlink + # return 0 if it worked or if the target exists but is a symlink to $argv[1] + # otherwise return 1 + # (--force overwrites the behavour to overwrite install) + + argparse 'f/force' -- $argv + + if ! ln -s $flag_force $argv[1] $argv[2] 2> /dev/null + if test (realpath $argv[2]) = $argv[1] + return 0 + end + print_warn "File $argv[2] already exists and is not a symlink to $argv[1]!" | to_stderr + return 1 + end +end + +function install_copy + # install $argv[1] to $argv[2] by copying it, doesn't overwrite if no -f/--force flag is given + # return 1 if the file couldn't be copied + + argparse 'f/force' -- $argv + + if test -f $argv[2]; and ! set -q _flag_force + print_warn "File $argv[2] already exists, use --force to overwrite existing files." | to_stderr + return 1 + end + + cp $argv[1] $argv[2] +end + +function install + argparse 'h/help' 'c/copy' 'f/force' 'e/extras' 'C/clean' -- $argv + + # get path to this script, which is the repo path + set DIR (cd (dirname (status -f)); and pwd) + + if set -q _flag_help + echo "mnt install script:" + echo + echo "Usage: ./install.sh -h/--help -c/--copy -f/--force -e/--extras -C/--clean" + echo + echo "Flags:" + echo " -h/--help Show this messasge" + echo + echo " -c/--copy Copy files instead of symlinking" + echo + echo " -f/--force Overwrite existing files" + echo + echo " -e/--extras Copy helpful extras such as mount-luks and mount-vc" + echo + echo " -C/--clean Uninstall all installed files. Uninstalls extras when used with -e" + echo + return 0 + end + + set -l external_dependencies rg blkid jq udisksctl + set -l core_files functions/mnt.fish completions/mnt.fish + set -l dependencies functions/{coalesce,shorten_path}.fish + set -l extras functions/{mount-luks,mount-vc}.fish + set -l fish_config_path $HOME/.config/fish + + # get fish config path from argv[1] + if test (count $argv) -gt 0 + set fish_config_path $argv[1] + end + + # figure out the command we use for installing files + set -l install_cmd install_symlink $_flag_force + if set -q _flag_copy + set install_cmd install_copy $_flag_force + end + + # find path to fish config + if ! test -f $fish_config_path/config.fish + print_err "Could not find fish config at $fish_config_path/config.fish" | to_stderr + return 1 + end + + # check for uninstall flag + if set -q _flag_clean + # uninstall mode! + echo "Uninstalling mnt..." + for file in $core_files $dependencies + if test -f $fish_config_path/$file + rm $fish_config_path/$file + echo " Removed $fish_config_path/$file" + else + echo " $file was not installed." + end + end + if set -q _flag_extras + echo "Uninstalling extras..." + for file in $extras + if test -f $fish_config_path/$file + rm $fish_config_path/$file + echo " Removed $fish_config_path/$file" + else + echo " $file was not installed." + end + end + end + return 0 + end + + # check external dependencies + for dep in $external_dependencies + check_program_installed $dep; or return 1 + end + + # install core files + for file in $core_files + if ! $install_cmd $DIR/$file $fish_config_path/$file + print_err "Aborting installation..." + return 1 + end + set_color green + echo "Installed $file..." + set_color normal + end + + # install deps + for file in $dependencies + if $install_cmd $DIR/$file $fish_config_path/$file + set_color green + echo "Installed $file..." + set_color normal + else + print_warn "Could not install dependency "(basename $file)", installer will continue, but installation might be incomplete." | to_stderr + end + end + + # install extras if requested + if set -q _flag_extras + for file in $extras + if $install_cmd $DIR/$file $fish_config_path/$file + set_color green + echo "Installed $file..." + set_color normal + else + print_warn "Could not install extra "(basename $file)", installer will continue, but installation might be incomplete." | to_stderr + end + end + end + + # install .mount file + if test -f $HOME/.mounts + set_color green + echo ".mounts file already installed!" + set_color normal + else + cp $DIR/.mounts $HOME/.mounts + echo "Provided a clean .mounts file in $HOME/.mounts" + end + + echo + set_color green + echo ">>> Installation complete! <<<" + set_color normal +end + + +if test "$_" != source + install $argv + exit $status +end \ No newline at end of file