commits
tags
#!/usr/bin/env bash
#
# gcfs — a minimal gocryptfs vault manager
#
# Config-driven, monochrome, dependency-light. Lets gocryptfs handle the
# password prompt itself, so no secret ever touches this script.
#
# Config: $XDG_CONFIG_HOME/gcfs/vaults.conf
# Format: name|cipherdir|mountpoint (# comments and blank lines ignored)
#
# Usage: gcfs interactive menu
# gcfs status show all vaults and their state (default)
# gcfs mount <name> mount a vault (name or number)
# gcfs umount <name> unmount a vault (name or number)
# gcfs mount-all mount every vault
# gcfs umount-all unmount every mounted vault
# gcfs add register a new vault interactively
# gcfs init create a fresh gocryptfs cipherdir
# gcfs edit open the config in $EDITOR
# gcfs help this text
set -euo pipefail
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gcfs"
CONFIG_FILE="$CONFIG_DIR/vaults.conf"
# ---- styling (degrades gracefully with no tty) --------------------------
if [[ -t 1 ]]; then
B=$(tput bold 2>/dev/null || true)
D=$(tput dim 2>/dev/null || true)
U=$(tput smul 2>/dev/null || true)
R=$(tput sgr0 2>/dev/null || true)
else
B=""; D=""; U=""; R=""
fi
ON="●" # mounted
OFF="○" # unmounted
BAD="✗" # broken (missing cipherdir)
# ---- output helpers -----------------------------------------------------
msg() { printf '%s\n' "$*"; }
info() { printf '%s\n' "${D}$*${R}"; }
warn() { printf '%s\n' "${B}!${R} $*" >&2; }
die() { printf '%s\n' "${B}✗${R} $*" >&2; exit 1; }
# ---- dependency + config bootstrap --------------------------------------
need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
need gocryptfs
need fusermount
ensure_config() {
[[ -f "$CONFIG_FILE" ]] && return
mkdir -p "$CONFIG_DIR"
cat > "$CONFIG_FILE" <<'EOF'
# gcfs vaults — one per line: name|cipherdir|mountpoint
# leading ~/ is expanded to your home. lines starting with # are ignored.
#
# example:
# docs|~/.vaults/docs|~/vault/docs
EOF
info "created $CONFIG_FILE — add a vault with 'gcfs add'"
}
# expand only a leading ~/ (safe, no eval)
expand() { case "$1" in "~/"*) printf '%s\n' "$HOME/${1#\~/}";; *) printf '%s\n' "$1";; esac; }
# ---- load vaults into parallel arrays -----------------------------------
declare -a NAMES CIPHERS MOUNTS
load_vaults() {
NAMES=(); CIPHERS=(); MOUNTS=()
local line name cipher mount
while IFS='|' read -r name cipher mount || [[ -n "$name" ]]; do
[[ -z "${name// }" ]] && continue
[[ "${name#\#}" != "$name" ]] && continue
name="${name## }"; name="${name%% }"
NAMES+=("$name")
CIPHERS+=("$(expand "${cipher## }")")
MOUNTS+=("$(expand "${mount## }")")
done < "$CONFIG_FILE"
}
is_mounted() { mountpoint -q -- "$1" 2>/dev/null; }
# resolve a user token (name or 1-based index) to an array index
resolve() {
local tok="$1" i
if [[ "$tok" =~ ^[0-9]+$ ]]; then
(( tok >= 1 && tok <= ${#NAMES[@]} )) || die "no vault #$tok"
printf '%s\n' "$(( tok - 1 ))"; return
fi
for i in "${!NAMES[@]}"; do
[[ "${NAMES[$i]}" == "$tok" ]] && { printf '%s\n' "$i"; return; }
done
die "no vault named '$tok'"
}
# ---- commands -----------------------------------------------------------
cmd_status() {
load_vaults
(( ${#NAMES[@]} )) || { info "no vaults configured — 'gcfs add'"; return; }
local w=4 i
for i in "${!NAMES[@]}"; do (( ${#NAMES[$i]} > w )) && w=${#NAMES[$i]}; done
printf '%s\n' "${B} # $(printf "%-${w}s" NAME) MOUNTPOINT${R}"
for i in "${!NAMES[@]}"; do
local mark clr
if [[ ! -f "${CIPHERS[$i]}/gocryptfs.conf" ]]; then
mark="$BAD"; clr="$D"
elif is_mounted "${MOUNTS[$i]}"; then
mark="$ON"; clr="$B"
else
mark="$OFF"; clr="$D"
fi
printf '%s %2d %s %s %s%s\n' \
"$clr$mark$R" "$((i+1))" "$B" \
"$(printf "%-${w}s" "${NAMES[$i]}")" \
"${D}${MOUNTS[$i]}${R}" "$R"
done
}
cmd_mount() {
local i; i=$(resolve "$1"); load_vaults
local cipher="${CIPHERS[$i]}" mount="${MOUNTS[$i]}" name="${NAMES[$i]}"
[[ -f "$cipher/gocryptfs.conf" ]] || die "'$name': no gocryptfs.conf in $cipher"
if is_mounted "$mount"; then info "'$name' already mounted"; return; fi
mkdir -p -- "$mount"
msg "${B}unlocking${R} $name → ${D}$mount${R}"
if gocryptfs -- "$cipher" "$mount"; then
msg "$ON mounted $name"
else
die "failed to mount $name"
fi
}
cmd_umount() {
local i; i=$(resolve "$1"); load_vaults
local mount="${MOUNTS[$i]}" name="${NAMES[$i]}"
is_mounted "$mount" || { info "'$name' not mounted"; return; }
if fusermount -u -- "$mount" 2>/dev/null; then
msg "$OFF unmounted $name"
else
warn "'$name' is busy — close open files, or force with: fusermount -uz -- '$mount'"
return 1
fi
}
cmd_mount_all() { load_vaults; local i; for i in "${!NAMES[@]}"; do cmd_mount "$((i+1))" || true; done; }
cmd_umount_all() { load_vaults; local i; for i in "${!NAMES[@]}"; do cmd_umount "$((i+1))" || true; done; }
cmd_add() {
local name cipher mount
read -rp "name: " name
read -rp "cipherdir: " cipher
read -rp "mountpoint: " mount
[[ -n "$name" && -n "$cipher" && -n "$mount" ]] || die "all fields required"
printf '%s|%s|%s\n' "$name" "$cipher" "$mount" >> "$CONFIG_FILE"
msg "$OFF added $name"
}
cmd_init() {
local cipher="${1:-}"
[[ -n "$cipher" ]] || read -rp "new cipherdir: " cipher
cipher="$(expand "$cipher")"
mkdir -p -- "$cipher"
gocryptfs -init -- "$cipher"
info "initialised — register it with 'gcfs add' (cipherdir: $cipher)"
}
cmd_edit() { "${EDITOR:-vi}" "$CONFIG_FILE"; }
cmd_help() { sed -n '3,29p' "$0" | sed 's/^# \{0,1\}//'; }
# ---- interactive menu ---------------------------------------------------
menu() {
while :; do
printf '\n'
cmd_status
printf '\n%s[number]%s toggle %s[a]%sdd %s[i]%snit %s[q]%suit ' \
"$B" "$R" "$B" "$R" "$B" "$R" "$B" "$R"
read -r choice || { printf '\n'; break; }
case "$choice" in
q|Q|"") break ;;
a|A) cmd_add ;;
i|I) cmd_init ;;
*)
load_vaults
idx=$(resolve "$choice") || continue
if is_mounted "${MOUNTS[$idx]}"; then
cmd_umount "$choice" || true
else
cmd_mount "$choice" || true
fi
;;
esac
done
}
# ---- dispatch -----------------------------------------------------------
ensure_config
case "${1:-menu}" in
menu) menu ;;
status|st|ls) cmd_status ;;
mount|m) shift; [[ $# -ge 1 ]] || die "usage: gcfs mount <name|#>"; cmd_mount "$1" ;;
umount|unmount|u) shift; [[ $# -ge 1 ]] || die "usage: gcfs umount <name|#>"; cmd_umount "$1" ;;
mount-all) cmd_mount_all ;;
umount-all) cmd_umount_all ;;
add) cmd_add ;;
init) shift; cmd_init "${1:-}" ;;
edit) cmd_edit ;;
help|-h|--help) cmd_help ;;
*) die "unknown command '$1' — try 'gcfs help'" ;;
esac