Ask Different Asked by Haravikk on January 24, 2021
I have several encrypted APFS volumes, some of which have their passphrases stored only in specific users’ keychains so they can unlock and mount automatically for those users.
However, when the user logs out, the volume remains mounted. While this isn’t strictly a problem (as I set mount points inside the user’s home folder, and have volume ownership enabled), I would prefer for the volume to unmount and lock again automatically.
This behaviour differs compared to mounting an encrypted disk image, which is mounted as a specific user (when viewing the output of mount
) and which then (normally*) unmounts when the user logs out.
Is it possible to replicate the same behaviour with APFS volumes, i.e- mount automatically for user with the passphrase in their keychain when they login, and then unmount (and lock) automatically when they logout?
*I say normally, as macOS Catalina appears to have bugs related to cleaning up user processes when a user logs out, resulting in many processes still running for logged out users. This currently includes disk-image-helper, so it no longer unmounts automatically as it did under Mojave. I’m happy to accept any answer that replicates the disk image mounting/unmounting behaviour with the caveat that this currently doesn’t always work for Catalina, on the basis that it should work if Apple ever fixes these bugs.
Update: I’ve tried doing the following in a script triggered by launchd, but launchd’s kill signal doesn’t appear to reach it:
#!/bin/bash
VOLUME=12345678-9012-3456-7890-123456789012
MOUNT_POINT=/Users/haravikk/Desktop/Foo
[ ! -e "${MOUNT_POINT}" ] && { mkdir "${MOUNT_POINT}" || exit 1; }
if echo -e "$(security find-generic-password -wa "${VOLUME}" | sed 's/../\x&/g')" | diskutil apfs unlockVolume "${VOLUME}" -stdinpassphrase; then
cleanup() {
echo 'Unmounting'
attempts=5
while [[ ${attempts} -gt 0 ]]; do
diskutil apfs lockVolume "${VOLUME}" && break
[[ -n "${MOUNT_POINT}" ]] && umount "${MOUNT_POINT}" && break
attempts=$((${attempts} - 1))
sleep 5
done
if [[ ${attempts} = 0 ]]; then
if ! diskutil unmount force "${VOLUME}"; then
if [[ -z "${MOUNT_POINT}" ]] || ! umount -f "${MOUNT_POINT}"; then
echo 'All attempts to unmount failed' >&2
fi
fi
fi
}
trap 'cleanup' SIGINT SIGHUP SIGTERM EXIT
while true; do
sleep 86400 &
wait $!
done
fi
The idea was that when the user logs out, launchd
should send their processes a kill signal (SIGINT
) which will trigger the script’s trap and allow it to unmount the volume. But this never seems to happen; the trap isn’t triggered at all.
If anyone is interested in using the basics of this script, note that you will need to have an entry in your keychain for the volume (you can do this by mounting with Disk Utility and choosing to save when prompted for the password), and must make sure that security
has permission to access it.
After much experimentation I've arrived at a scripted solution, with a script that can operate in two modes:
The first is as a daemon, usually run as root
(as launch daemon), which listens on a given socket for commands identifying the volume you want to mount (must be unmounted), followed by another to confirm that you mounted it (proving you can), and a third to then unmount it, force unmount it, or clear the request. The behaviour is a bit simplistic, but should reasonably establish a client had the ability to mount the volume, and therefore is allowed to request that it then be unmounted, using a simple random credential.
When not run in daemon mode, the script takes a volume identifier (anything supported by diskutil apfs unlockVolume
, UUIDs preferred) and attempts to unlock and mount the volume. You need to have the password for the volume in the keychain for the user running the script, and will be prompted to allow security
to access it. The script normally attempts to unmount a volume by itself, however I've established that most of the time this won't work, as disk arbitration is usually unloaded before the script attempts to do so (meaning diskutil unmount
and umount
both fail), as such if you want to use this script with a launch agent that will unmount on logout, you need to have a daemon running on the same system and set the --socket
argument to match.
Hopefully this is fairly clear in how it's supposed to be used, as it includes examples and options are documented. This is not intended for anyone that doesn't have some grasp of Terminal usage and shell scripting (ZSH specifically) as you may need to customise it to do exactly what you want.
#!/bin/zsh
{
# Examples:
# Standalone: ./MountAPFS 12345678-9012-3456-7890-12345678901234
# (mount): ./MountAPFS --create ~/Library/Volumes/Foo 12345678-9012-3456-7890-12345678901234
#
# Daemon: ./MountAPFS --daemon --socket 61616
# Client: ./MountAPFS --socket 61616 12345678-9012-3456-7890-12345678901234
while [ $# -gt 0 ]; do
case "$1" in
# Set a directory that needs to be created (usually the volume's mount point when a custom mount point is specified in /etc/fstab)
('--create'|'--create-dir'|'--create-directory')
CREATE_DIRECTORY="$2"; shift
case "${CREATE_DIRECTORY:0:1}" in
('/') ;;
('~') CREATE_DIRECTORY="${HOME}${CREATE_DIRECTORY:1}" ;;
(*) CREATE_DIRECTORY="${BASE_DIRECTORY}/${CREATE_DIRECTORY}" ;;
esac
;;
# Runs this script in daemon mount (do not mount any volumes, instead handle the unmount of registered volumes on behalf of other tasks).
('--daemon') DAEMON=1 ;;
# The socket to listen on/connect to when working in/with a daemon script
('--socket') SOCKET="$2"; WAIT=1; shift ;;
# The amount of time to wait for the volume to become available before giving up. This option can be used if there may be a race condition between this and another task before the volume becomes available
('--timeout') TIMEOUT="$2"; shift ;;
# Do not end once the volume is mounted, instead wait for a termination signal and attempt to unmount it
('--wait') WAIT=1 ;;
# Enable verbose output; this will output volume identifiers and tokens for tracing, but will only output the last four characters of tokens to prevent abuse (full tokens are 32 characters in length)
('-v'|'--verbose') VERBOSITY=$(($(echo "0${VERBOSITY}" | sed 's/[^0-9]*//g') + 1)) ;;
# Explicit end of arguments
('--') shift; break ;;
(--*) echo "Unknown option: $1" >&2; exit 2 ;;
# Implicit end of arguments (first volume)
(*) break ;;
esac
shift
done
VERBOSITY=$(echo "0${VERBOSITY}" | sed 's/[^0-9]*//g')
if [[ -n "${SOCKET}" ]]; then
[[ "${SOCKET}" = "$(echo "${SOCKET}" | sed 's/[^0-9]*//g')" ]] || { echo 'Invalid socket:' "${SOCKET}" >&2; exit 2; }
[[ "${SOCKET}" -gt 0 ]] || { echo 'Invalid socket:' "${SOCKET}" >&2; exit 2; }
fi
if [ "${DAEMON}" = 1 ]; then
[[ -n "${SOCKET}" ]] || { echo 'Daemon mode requires a socket' >&2; exit 2; }
# Open netcat on the specified socket
coproc nc -kl localhost "${SOCKET}" || { echo 'Unable to open socket' >&2; exit 2; }
trap 'coproc :' EXIT SIGHUP SIGINT SIGTERM
[[ ${VERBOSITY} -gt 0 ]] && echo 'APFS daemon listening on socket:' "${SOCKET}"
declare -A requested=()
declare -A mounted=()
while IFS='', read -rd '' line; do
cmd="${line:0:5}"
value="${line:5}"
case "${cmd}" in
# Indicates intention to mount a current unmounted volume (given in value).
# Returns a token that must be used in future commands
('mount')
if mount=$(diskutil info "${value}" 2>/dev/null | grep 'Mounted' | sed 's/[^:]*: *//') && [[ "${mount}" = 'No' ]]; then
token=$(echo "${value}$(head -c 512 </dev/urandom)" | md5)
requested[${token}]=${value}
printf '%s%s ' 'mount' "${token}" >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Accepted mount request for:' "${value} assigned token ending with:" "${token: -4}"
else
printf '%s%s ' 'error' 'Volume not found, or is already mounted' >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Volume not found or already mounted:' "${value}" >&2
fi
;;
# Indicates that the previously registered volume is now mounted. Volume is identified using the unique token returned by the mount command. Now that the volume has been mounted, it can be unmounted using the unmnt or funmt command.
# Returns the volume that was tested
('mnted')
volume=${requested[$value]}
if [ -n "${volume}" ]; then
if mount=$(diskutil info "${volume}" 2>/dev/null | grep 'Mounted' | sed 's/[^:]*: *//') && [[ "${mount}" != 'No' ]]; then
mounted[${value}]=${volume}
unset "requested[${token}]"
printf '%s%s ' 'mnted' "${volume}" >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Confirmed mounting of:' "${volume} using token ending with:" "${value: -4}"
else
printf '%s%s ' 'error' 'Volume not found, or is not mounted' >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Volume not found or not mounted:' "${volume}" >&2
fi
else
printf '%s%s ' 'error' 'Unknown token: use the mount command first' >&p
[[ ${VERBOSITY} -gt 0 ]] && echo "Received ${cmd} command out of sequence or invalid token ending with: ${token: -4}" >&2
fi
;;
# Requests that a previously mounted volume to be unmounted. Volume is identified using the unique token used in the mnted command.
# The funmt command will attempt to forcibly unmount the volume, and should only be used if the unmnt command previously failed.
# Returns the volume that was unmounted
('unmnt'|'funmt')
volume=${mounted[$value]}
if [ -n "${volume}" ]; then
if mount=$(diskutil info "${volume}" 2>/dev/null | grep 'Mounted' | sed 's/[^:]*: *//') && [[ "${mount}" != 'No' ]]; then
[ "${cmd}" = 'funmt' ] && force='force ' || force=''
if error=$(diskutil unmount ${force}"${volume}" 2>&1); then
unset "mounted[${token}]"
printf '%s%s ' "${cmd}" "${volume}" >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Unmounted volume:' "${volume} using token ending with:" "${token: -4}"
else
printf '%s%s ' 'error' "Unable to unmount ${volume}: ${error}" >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Unable to mount:' "${volume}: ${error}" >&2
fi
else
printf '%s%s ' 'error' 'Volume not found, or is not mounted' >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Volume not found:' "${volume}" >&2
fi
else
printf '%s%s ' 'error' 'Unknown token: use the mnted command first' >&p
[[ ${VERBOSITY} -gt 0 ]] && echo "Received ${cmd} command out of sequence: expected mnted" >&2
fi
;;
# Clear a token that is no longer needed
('clear')
unset "requested[${value}]"
unset "mounted[${value}]"
printf '%s%s ' 'clear' "${value}" >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Cleared token ending with:' "${value: -4}"
;;
# Unknown command
(*)
printf '%s%s ' 'error' "Unknown command: ${cmd}" >&p
[[ ${VERBOSITY} -gt 0 ]] && echo 'Received unknown command:' "${cmd}" >&2
;;
esac
done <&p
coproc :
[[ ${VERBOSITY} -gt 0 ]] && echo 'Terminating.'
else
[[ -z "${BASE_DIRECTORY}" ]] && BASE_DIRECTORY="${HOME}/Library/Vaults/"
[[ -d "${BASE_DIRECTORY}" && -w "${BASE_DIRECTORY}" ]] || { echo 'Missing or unwritable base directory:' "${BASE_DIRECTORY}" >&2; exit 1; }
[[ $# -lt 1 ]] && { echo 'Missing volume' >&2; exit 1; }
VOLUME="$1"
# If a timeout was given, wait until the volume is ready
TIMEOUT=$(echo "${TIMEOUT}" | sed 's/[^0-9]*//g')
if [[ -n "${TIMEOUT}" ]]; then
while [[ "${TIMEOUT}" -gt 0 ]]; do
diskutil info "${VOLUME}" 2>&1 >/dev/null && break
TIMEOUT=$((${TIMEOUT} - 5))
sleep 5
done
fi
# Make sure the volume is available to be unlocked
error=$(diskutil info "${VOLUME}" 2>&1) || { echo 'Volume not found:' "${VOLUME}:" "${error}" >&2; exit 3; }
# If a mount point was given, try to create a directory (otherwise volume won't mount over it)
if [[ -n "${CREATE_DIRECTORY}" ]] && [[ ! -d "${CREATE_DIRECTORY}" ]]; then
error=$(mkdir -m 700 "${CREATE_DIRECTORY}") || { echo 'Unable to create mount point:' "${CREATE_DIRECTORY}:" "${error}" >&2; exit 4; }
fi
# If a socket was given, register our intention to mount the volume
token=
if [[ "${WAIT}" = 1 && -n "${SOCKET}" ]]; then
socket_cmd() { local cmd="$1"; local value="$2"
coproc nc localhost "${SOCKET}" || { echo 'Unable to connect to socket' >&2; return 1; }
local response=
printf '%s%s ' "${cmd}" "${value}" >&p
read -rd '' response <&p
case "${response:0:5}" in
("${cmd}")
printf '%s' "${response:5}"
coproc :
return 0
;;
('error')
echo "socket_cmd() error: ${response:5}" >&2
coproc :
return 2
;;
(*)
echo 'Unknown/unsupported response:' "${response}" >&2
coproc :
return 3
;;
esac
}
token=$(socket_cmd 'mount' "${VOLUME}") || SOCKET=
fi
if error=$(echo -e "$(security find-generic-password -wa "${VOLUME}" | sed 's/../\x&/g')" | diskutil apfs unlockVolume "${VOLUME}" -stdinpassphrase) || error2=$(diskutil mount "${VOLUME}"); then
if [[ "${WAIT}" = 1 ]]; then
# Confirm mounting of volume to socket (if registered)
[[ -n "${token}" ]] && { volume_confirm=$(socket_cmd "mnted" "${token}") || token=; }
printf '%s' 'Awaiting signal... '
# Trap and wait until task is ended, then lock the volume
cleanup_run=0
cleanup() {
[[ ${cleanup_run} = 0 ]] || return 0
cleanup_run=1
echo 'received.'
printf '%s' 'Unmounting... '
attempts=5
while [[ ${attempts} -gt 0 ]]; do
diskutil apfs lockVolume "${VOLUME}" >/dev/null && echo 'done.' && break
[[ -n "${CREATE_DIRECTORY}" ]] && umount "${CREATE_DIRECTORY}" && echo 'done.' && break
[[ -n "${token}" ]] && volume_confirm=$(socket_cmd 'unmnt' "${token}") && token= && echo 'done.' && break
attempts=$((${attempts} - 1))
sleep 5
done
if [[ ${attempts} = 0 ]]; then
if diskutil unmount force "${VOLUME}" >/dev/null; then
echo 'forced.'
else
if [[ -z "${CREATE_DIRECTORY}" ]] || ! umount -f "${CREATE_DIRECTORY}"; then
if [[ -z "${token}" ]] || ! volume_confirm=$(socket_cmd 'funmt' "${token}"); then
echo 'failed.'
echo 'All attempts to unmount failed' >&2
else
token=
echo 'forced.'
fi
else
echo 'forced.'
fi
fi
fi
[[ -n "${token}" ]] && socket_cmd 'clear' "${token}"
# Clear all background tasks
coproc :
[[ -n "${${(v)jobstates##*:*:}%=*}" ]] && kill ${${(v)jobstates##*:*:}%=*}
}
trap 'cleanup' SIGINT SIGHUP SIGTERM EXIT
while true; do
sleep 86400 &
wait $!
done
fi
else
echo 'Unable to mount volume:' "${error}" "${error2}" >&2
[[ -n "${token}" ]] && socket_cmd 'clear' "${token}"
fi
fi
}
Answered by Haravikk on January 24, 2021
You might try using a logout hook. Login and logout hooks are deprecated, but I believe they still function.
Mounting the volume should not be a problem; a user LaunchAgent would handle that nicely. The problem lies in trying to unmount the volume at logout. Have you considered writing a system LaunchDaemon that periodically polls the open APFS volumes and unmounts those that don't have an associated user? The volumes should be automatically locked by system security when they are unmounted, so I don't think you need to make a special effort for that, and it sounds like you're thinking more about cleanup than anything else. If you put the daemon on (say) a 30 second timer, it shouldn't consume too much in the way of resources, and volumes will only persist for an average of 15 second after logout.
Answered by Ted Wrigley on January 24, 2021
Get help from others!
Recent Answers
Recent Questions
© 2024 TransWikia.com. All rights reserved. Sites we Love: PCI Database, UKBizDB, Menu Kuliner, Sharing RPP