Privileged unmount now does a best-effort rmdir on the mount point after umount succeeds. Only removes empty directories; non-empty dirs and errors are silently ignored. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
149 lines
4.4 KiB
Go
149 lines
4.4 KiB
Go
package cryptsetup
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.wntrmute.dev/kyle/arca/internal/verbose"
|
|
)
|
|
|
|
// Open opens a LUKS device using cryptsetup with token-based unlock.
|
|
func Open(devicePath, mapperName string) error {
|
|
args := withTokenPluginEnv([]string{"cryptsetup", "open", devicePath, mapperName, "--token-only"})
|
|
args = withPrivilege(args)
|
|
|
|
verbose.Printf("exec: %s", strings.Join(args, " "))
|
|
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("cryptsetup open --token-only: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close closes a LUKS mapping.
|
|
func Close(mapperName string) error {
|
|
args := []string{"cryptsetup", "close", mapperName}
|
|
args = withPrivilege(args)
|
|
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("cryptsetup close: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Mount mounts a device at the given mountpoint using privileged mount.
|
|
// If mountpoint is empty, mounts under /mnt/<basename>.
|
|
func Mount(devicePath, mountpoint string) (string, error) {
|
|
if mountpoint == "" {
|
|
mountpoint = "/mnt/" + filepath.Base(devicePath)
|
|
}
|
|
|
|
mkdirArgs := withPrivilege([]string{"mkdir", "-p", mountpoint})
|
|
if err := exec.Command(mkdirArgs[0], mkdirArgs[1:]...).Run(); err != nil {
|
|
return "", fmt.Errorf("creating mountpoint: %w", err)
|
|
}
|
|
|
|
args := withPrivilege([]string{"mount", devicePath, mountpoint})
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("mount %s: %w", devicePath, err)
|
|
}
|
|
return mountpoint, nil
|
|
}
|
|
|
|
// Unmount unmounts the given mountpoint using privileged umount, then
|
|
// removes the mount directory if it is empty.
|
|
func Unmount(mountpoint string) error {
|
|
args := withPrivilege([]string{"umount", mountpoint})
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("umount %s: %w", mountpoint, err)
|
|
}
|
|
|
|
// Clean up empty mount directory. Best-effort — ignore errors
|
|
// (directory may not be empty or may be a system path).
|
|
rmdirArgs := withPrivilege([]string{"rmdir", mountpoint})
|
|
exec.Command(rmdirArgs[0], rmdirArgs[1:]...).Run()
|
|
|
|
return nil
|
|
}
|
|
|
|
// MapperName returns "arca-<dev>" from a device path, e.g. "/dev/sda1" -> "arca-sda1".
|
|
func MapperName(devicePath string) string {
|
|
return "arca-" + filepath.Base(devicePath)
|
|
}
|
|
|
|
// withTokenPluginEnv wraps the command with `env LD_LIBRARY_PATH=...` if
|
|
// the systemd cryptsetup token plugins are found.
|
|
//
|
|
// On NixOS, cryptsetup uses dlopen to load token plugins, but glibc only
|
|
// searches the binary's baked-in RUNPATH — which doesn't include the
|
|
// systemd plugin directory. LD_LIBRARY_PATH is the only reliable way to
|
|
// make dlopen find them.
|
|
//
|
|
// We inject it via `env` rather than cmd.Env so it survives privilege
|
|
// escalation through doas/sudo.
|
|
func withTokenPluginEnv(args []string) []string {
|
|
pluginDir := findTokenPluginDir()
|
|
if pluginDir == "" {
|
|
return args
|
|
}
|
|
return append([]string{"env", "LD_LIBRARY_PATH=" + pluginDir}, args...)
|
|
}
|
|
|
|
// findTokenPluginDir locates the directory containing libcryptsetup-token-*.so.
|
|
// It checks the NixOS system profile first, then falls back to resolving
|
|
// from the systemd-cryptenroll binary.
|
|
func findTokenPluginDir() string {
|
|
// NixOS stable symlink — survives rebuilds.
|
|
const nixSystemPath = "/run/current-system/sw/lib/cryptsetup"
|
|
if hasTokenPlugins(nixSystemPath) {
|
|
verbose.Printf("token plugin dir: %s", nixSystemPath)
|
|
return nixSystemPath
|
|
}
|
|
|
|
// Fallback: derive from systemd-cryptenroll location.
|
|
if bin, err := exec.LookPath("systemd-cryptenroll"); err == nil {
|
|
if resolved, err := filepath.EvalSymlinks(bin); err == nil {
|
|
dir := filepath.Join(filepath.Dir(filepath.Dir(resolved)), "lib", "cryptsetup")
|
|
if hasTokenPlugins(dir) {
|
|
verbose.Printf("token plugin dir (from systemd-cryptenroll): %s", dir)
|
|
return dir
|
|
}
|
|
}
|
|
}
|
|
|
|
verbose.Printf("no token plugin directory found")
|
|
return ""
|
|
}
|
|
|
|
func hasTokenPlugins(dir string) bool {
|
|
matches, _ := filepath.Glob(filepath.Join(dir, "libcryptsetup-token-*.so"))
|
|
return len(matches) > 0
|
|
}
|
|
|
|
func withPrivilege(args []string) []string {
|
|
if _, err := exec.LookPath("doas"); err == nil {
|
|
return append([]string{"doas"}, args...)
|
|
}
|
|
if _, err := exec.LookPath("sudo"); err == nil {
|
|
return append([]string{"sudo"}, args...)
|
|
}
|
|
return args
|
|
}
|