M1: make mount/unmount idempotent

mount now detects already-unlocked and already-mounted devices, returning
the existing mount point instead of failing. unmount handles already-locked
devices gracefully and skips unmount if not mounted before locking.

Adds IsMounted helper to udisks client. Updates PLAN.md with refined
v1.0.0 milestones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 07:58:25 -07:00
parent c835358829
commit ea7e09bdfb
4 changed files with 167 additions and 151 deletions

248
PLAN.md
View File

@@ -1,168 +1,158 @@
# arca — Implementation Plan
# arca — v1.0.0 Plan
## Overview
## Current State (v0.1.0)
A Go CLI that manages LUKS volumes. Uses udisks2 D-Bus for
passphrase/keyfile unlock (no root needed), and falls back to
`cryptsetup` for FIDO2/TPM2 tokens (requires root/doas). Three
subcommands: `mount`, `unmount`, `status`.
Working proof of concept. FIDO2 unlock via cryptsetup with passphrase
fallback, privileged mount/unmount, config aliases with method
sequencing, init and status commands.
## Dependencies
## M1: Idempotent mount/unmount
- `github.com/spf13/cobra` — CLI framework
- `github.com/godbus/dbus/v5` — D-Bus client
- `gopkg.in/yaml.v3` — config file parsing
`mount` and `unmount` should be safe to run at any point in a device's
lifecycle without producing confusing errors.
## Project Structure
### Files changed
```
arca/
├── main.go
├── cmd/
│ ├── root.go # root command, global flags
│ ├── mount.go # mount subcommand
│ ├── unmount.go # unmount subcommand
│ └── status.go # status subcommand
├── internal/
│ ├── udisks/ # udisks2 D-Bus client
│ │ ├── client.go # connection + device discovery
│ │ ├── encrypt.go # Unlock / Lock operations
│ │ ├── mount.go # Mount / Unmount operations
│ │ └── types.go # object path helpers, constants
│ ├── cryptsetup/ # cryptsetup CLI wrapper (FIDO2/TPM2)
│ │ └── cryptsetup.go
│ ├── unlock/ # method sequencing logic
│ │ └── unlock.go
│ └── config/
│ └── config.go # YAML config loading + device resolution
├── flake.nix
├── go.mod
├── go.sum
├── README.md
└── PLAN.md
```
- `cmd/mount.go`
- `cmd/unmount.go`
- `internal/udisks/client.go`
## Phase 1: Core Unlock/Lock Backend
### Work
Build `internal/udisks/` for D-Bus operations and `internal/cryptsetup/`
for FIDO2/TPM2 fallback.
1. In `runMount`, after `FindDevice`, check if the device is already
unlocked (`CleartextDevice` succeeds). If the cleartext device is
already mounted, print the existing mount point and return success.
If unlocked but not mounted, skip unlock and go straight to mount.
### udisks2 D-Bus (passphrase + keyfile)
2. In `runUnmount`, handle each failure case:
- `CleartextDevice` fails (already locked): print "already locked"
and return success.
- `Unmount` fails because not mounted: proceed to lock anyway.
- `Lock` fails because already locked: return success.
1. Connect to the system bus.
2. Enumerate block devices under `/org/freedesktop/UDisks2/block_devices/`.
3. Find a block device by device path or UUID.
4. `Unlock(passphrase)` — call `org.freedesktop.UDisks2.Encrypted.Unlock`
on the LUKS partition. Returns the cleartext device object path.
5. `Mount()` — call `org.freedesktop.UDisks2.Filesystem.Mount` on the
cleartext device. Returns the mount point path.
6. `Unmount()` + `Lock()` — reverse operations.
3. Add `client.IsMounted(dev)` helper that returns `(mountpoint, bool)`
to reduce duplicated mount-point checking logic.
Key D-Bus details:
- Bus name: `org.freedesktop.UDisks2`
- Object paths: `/org/freedesktop/UDisks2/block_devices/<name>`
- Interfaces: `org.freedesktop.UDisks2.Encrypted` (Unlock, Lock),
`org.freedesktop.UDisks2.Filesystem` (Mount, Unmount),
`org.freedesktop.UDisks2.Block` (device properties, UUID lookup)
---
### cryptsetup fallback (FIDO2 + TPM2)
## M2: Error messages
udisks2 does not support FIDO2/TPM2 token-based keyslots. For these,
fall back to invoking `cryptsetup` (via doas/sudo):
Replace generic D-Bus errors with actionable messages.
- FIDO2: `cryptsetup open --token-type systemd-fido2 <device> <name>`
- TPM2: `cryptsetup open --token-type systemd-tpm2 <device> <name>`
- `--token-only` can attempt all enrolled tokens automatically.
### Files changed
After `cryptsetup open`, mount via udisks2 D-Bus or plain `mount`.
- `internal/udisks/client.go`
- `cmd/mount.go`, `cmd/unmount.go`, `cmd/init.go`, `cmd/status.go`
### Unlock method sequencing
### Work
Each device has an ordered list of methods to try. The unlock logic walks
the list in order, stopping at the first success:
1. Wrap the `dbus.SystemBus()` error in `NewClient` to detect
"connection refused" or "no such file" and print:
`"cannot connect to udisks2 — is the udisks2 service running?"`
1. Try the first method (e.g., `fido2`).
2. If it fails (device not plugged in, timeout, etc.), try the next.
3. Continue until one succeeds or all are exhausted.
2. In `FindDevice`, when no device matches, include what was searched
and suggest `arca status` or `arca init`:
`"device /dev/sda1 not found (run 'arca status' to list devices)"`
Default when no config exists: `[passphrase]`.
3. In the unlock sequencer, prefix each method error with context:
`"fido2: cryptsetup open --token-only: exit status 5 (is the FIDO2 key plugged in?)"`
## Phase 2: Config File
---
Build `internal/config/`:
## M3: Unit tests
1. Load `~/.config/arca/config.yaml` (respect `$XDG_CONFIG_HOME`).
2. Resolve an alias to a UUID and unlock methods.
3. If no config file exists, that's fine — device path arguments still work
with the default method sequence `[passphrase]`.
Cover pure logic that doesn't need D-Bus or real devices.
Config schema:
### Files changed
```yaml
devices:
backup:
uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
mountpoint: "/mnt/backup" # optional
methods: # optional, default: [passphrase]
- fido2
- passphrase
media:
uuid: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
methods:
- keyfile
- passphrase
keyfile: "/path/to/media.key" # required if keyfile is in methods
```
- `internal/config/config_test.go` (new)
- `internal/cryptsetup/cryptsetup_test.go` (new)
Supported methods: `passphrase`, `keyfile`, `fido2`, `tpm2`.
### Work
## Phase 3: CLI Commands
1. **config_test.go** — test cases:
- `ResolveDevice` with exact alias match
- `ResolveDevice` with device path match (`/dev/sda1` -> `sda1`)
- `ResolveDevice` with unknown name returns default methods
- `ResolveDevice` with empty methods list defaults to `[passphrase]`
- `AliasFor` finds alias by UUID
- `AliasFor` returns "" for unknown UUID
- `Load` with nonexistent file returns empty config
- `Load` with valid YAML parses correctly
Wire up cobra subcommands:
2. **cryptsetup_test.go** — test cases:
- `MapperName("/dev/sda1")` == `"arca-sda1"`
- `MapperName("/dev/nvme0n1p2")` == `"arca-nvme0n1p2"`
- `hasTokenPlugins` with a temp dir containing a matching .so
- `hasTokenPlugins` with an empty temp dir
### `arca mount <device|alias>`
---
1. Resolve argument: if it matches a config alias, look up UUID and
methods list; otherwise treat as a device path with default methods.
2. Find the block device via udisks2.
3. Walk the methods list in order, attempting each unlock strategy:
- `passphrase` / `keyfile`: via udisks2 D-Bus (no root).
- `fido2` / `tpm2`: via `cryptsetup` CLI (requires doas/sudo).
- Stop at the first success; if all fail, report each error.
4. Mount the resulting cleartext device via udisks2.
5. Print the mount point.
## M4: CLI polish
### `arca unmount <device|alias>`
Small usability improvements.
1. Resolve argument to the LUKS block device.
2. Find the associated cleartext (mapped) device.
3. Call Unmount on the cleartext device.
4. Call Lock on the LUKS device.
### Files changed
### `arca status`
- `cmd/root.go`
- `cmd/mount.go`
- `cmd/init.go`
- `main.go`
- `.gitignore` (new)
1. Enumerate all block devices with the Encrypted interface.
2. For each, check if unlocked (has a cleartext device).
3. If mounted, show mount point.
4. Print a table: device, UUID, alias (if configured), state, mount point.
### Work
## Phase 4: Credential Input
1. Add `var version = "dev"` to `main.go`. Set `rootCmd.Version` in
`cmd/root.go`. Build with `-ldflags "-X main.version=..."` in
`flake.nix`.
- **Passphrase**: read from terminal with echo disabled (`golang.org/x/term`).
Alternatively, accept from stdin for scripting.
- **Keyfile**: read path from config, pass contents to udisks2.
- **FIDO2**: `cryptsetup` handles the token interaction (tap prompt comes
from libfido2).
- **TPM2**: `cryptsetup` handles TPM unsealing automatically.
2. Add `--mountpoint` / `-m` flag to `mount` subcommand. When set,
pass it to `cryptsetup.Mount` (privileged path) or log a warning
that udisks2 doesn't support custom mount points.
## Phase 5: Packaging
3. In `init`, use first 8 chars of UUID as alias instead of device
path basename. Example: `b8b2f8e3` instead of `sda1`. UUIDs are
stable across boots; device paths are not.
- `flake.nix` with `buildGoModule`.
- Add as a flake input to the NixOS config repo.
4. Create `.gitignore` containing `/arca`.
## Open Questions
---
- Should `mount` accept a `--mountpoint` flag to override the default?
- Should there be a `--dry-run` flag that shows what D-Bus calls would be
made?
- Is there value in a `--json` output flag for `status`?
## M5: Documentation and packaging
Make the project installable and the README trustworthy.
### Files changed
- `README.md`
- `flake.nix`
### Work
1. Update `README.md`:
- Replace placeholder UUIDs with realistic examples from actual
tested usage.
- Add "NixOS notes" section documenting the `LD_LIBRARY_PATH`
requirement for FIDO2/TPM2 and why `--external-tokens-path`
doesn't work.
- Add "Troubleshooting" section: no FIDO2 token enrolled, udisks2
not running, permission denied on mount.
- Document `init` subcommand.
2. Verify `flake.nix`:
- Run `nix build` and confirm it produces a working binary.
- Add `-ldflags` for version injection from `self.rev` or a
`version` variable.
- Test the flake output: `./result/bin/arca --version`.
3. Tag `v1.0.0`.
---
## Non-goals for v1.0.0
- `--dry-run` flag
- `--json` output for `status`
- udev auto-mount on plug
- Keyfile creation/management
- Multiple config files or config includes

View File

@@ -40,6 +40,18 @@ func runMount(cmd *cobra.Command, args []string) error {
return err
}
// Check if already unlocked.
if cleartext, err := client.CleartextDevice(dev); err == nil {
// Already unlocked — check if mounted too.
if mp, mounted := client.IsMounted(cleartext); mounted {
fmt.Println(mp)
return nil
}
// Unlocked but not mounted — just mount it.
return doMount(client, cleartext, devCfg)
}
// Need to unlock.
u := unlock.New(client, unlock.Options{
ReadPassphrase: readPassphrase,
KeyfilePath: devCfg.Keyfile,
@@ -50,16 +62,23 @@ func runMount(cmd *cobra.Command, args []string) error {
return err
}
var mountpoint string
if result.Privileged {
mountpoint, err = cryptsetup.Mount(result.Device.DevicePath, devCfg.Mountpoint)
} else {
mountpoint, err = client.Mount(result.Device)
mountpoint, err := cryptsetup.Mount(result.Device.DevicePath, devCfg.Mountpoint)
if err != nil {
return fmt.Errorf("mounting: %w", err)
}
fmt.Println(mountpoint)
return nil
}
return doMount(client, result.Device, devCfg)
}
func doMount(client *udisks.Client, cleartext *udisks.BlockDevice, devCfg config.ResolvedDevice) error {
mountpoint, err := client.Mount(cleartext)
if err != nil {
return fmt.Errorf("mounting: %w", err)
}
fmt.Println(mountpoint)
return nil
}

View File

@@ -2,7 +2,6 @@ package cmd
import (
"fmt"
"strings"
"git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/cryptsetup"
@@ -39,31 +38,27 @@ func runUnmount(cmd *cobra.Command, args []string) error {
return err
}
// Check if already locked.
cleartext, err := client.CleartextDevice(dev)
if err != nil {
return fmt.Errorf("finding cleartext device: %w", err)
fmt.Fprintf(cmd.ErrOrStderr(), "%s is already locked\n", target)
return nil
}
// Unmount: try udisks2 first, fall back to privileged umount.
mp, _ := client.MountPoint(cleartext)
if err := client.Unmount(cleartext); err != nil {
if mp == "" {
return fmt.Errorf("unmounting: %w", err)
}
if err := cryptsetup.Unmount(mp); err != nil {
return fmt.Errorf("unmounting: %w", err)
// Unmount if mounted.
if mp, mounted := client.IsMounted(cleartext); mounted {
if err := client.Unmount(cleartext); err != nil {
// udisks2 unmount failed — try privileged umount.
if err := cryptsetup.Unmount(mp); err != nil {
return fmt.Errorf("unmounting: %w", err)
}
}
}
// Lock: try udisks2 first, fall back to cryptsetup close if it's
// an arca-managed mapping.
// Lock: try udisks2 first, fall back to cryptsetup close.
if err := client.Lock(dev); err != nil {
mapperName := cryptsetup.MapperName(dev.DevicePath)
if strings.HasPrefix(mapperName, "arca-") {
if err := cryptsetup.Close(mapperName); err != nil {
return fmt.Errorf("locking: %w", err)
}
} else {
if err := cryptsetup.Close(mapperName); err != nil {
return fmt.Errorf("locking: %w", err)
}
}

View File

@@ -97,6 +97,18 @@ func (c *Client) MountPoint(dev *BlockDevice) (string, error) {
return string(bytes.TrimRight(mountPoints[0], "\x00")), nil
}
// IsMounted returns the mount point and true if the device is mounted.
func (c *Client) IsMounted(dev *BlockDevice) (string, bool) {
if !dev.HasFilesystem {
return "", false
}
mp, err := c.MountPoint(dev)
if err != nil || mp == "" {
return "", false
}
return mp, true
}
// DeviceAtPath returns the block device at the given D-Bus object path.
func (c *Client) DeviceAtPath(path dbus.ObjectPath) (*BlockDevice, error) {
devices, err := c.listBlockDevices()