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 Working proof of concept. FIDO2 unlock via cryptsetup with passphrase
passphrase/keyfile unlock (no root needed), and falls back to fallback, privileged mount/unmount, config aliases with method
`cryptsetup` for FIDO2/TPM2 tokens (requires root/doas). Three sequencing, init and status commands.
subcommands: `mount`, `unmount`, `status`.
## Dependencies ## M1: Idempotent mount/unmount
- `github.com/spf13/cobra` — CLI framework `mount` and `unmount` should be safe to run at any point in a device's
- `github.com/godbus/dbus/v5` — D-Bus client lifecycle without producing confusing errors.
- `gopkg.in/yaml.v3` — config file parsing
## Project Structure ### Files changed
``` - `cmd/mount.go`
arca/ - `cmd/unmount.go`
├── main.go - `internal/udisks/client.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
```
## Phase 1: Core Unlock/Lock Backend ### Work
Build `internal/udisks/` for D-Bus operations and `internal/cryptsetup/` 1. In `runMount`, after `FindDevice`, check if the device is already
for FIDO2/TPM2 fallback. 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. 3. Add `client.IsMounted(dev)` helper that returns `(mountpoint, bool)`
2. Enumerate block devices under `/org/freedesktop/UDisks2/block_devices/`. to reduce duplicated mount-point checking logic.
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.
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, Replace generic D-Bus errors with actionable messages.
fall back to invoking `cryptsetup` (via doas/sudo):
- FIDO2: `cryptsetup open --token-type systemd-fido2 <device> <name>` ### Files changed
- TPM2: `cryptsetup open --token-type systemd-tpm2 <device> <name>`
- `--token-only` can attempt all enrolled tokens automatically.
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 1. Wrap the `dbus.SystemBus()` error in `NewClient` to detect
the list in order, stopping at the first success: "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. In `FindDevice`, when no device matches, include what was searched
2. If it fails (device not plugged in, timeout, etc.), try the next. and suggest `arca status` or `arca init`:
3. Continue until one succeeds or all are exhausted. `"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`). Cover pure logic that doesn't need D-Bus or real devices.
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]`.
Config schema: ### Files changed
```yaml - `internal/config/config_test.go` (new)
devices: - `internal/cryptsetup/cryptsetup_test.go` (new)
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
```
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 ## M4: CLI polish
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.
### `arca unmount <device|alias>` Small usability improvements.
1. Resolve argument to the LUKS block device. ### Files changed
2. Find the associated cleartext (mapped) device.
3. Call Unmount on the cleartext device.
4. Call Lock on the LUKS device.
### `arca status` - `cmd/root.go`
- `cmd/mount.go`
- `cmd/init.go`
- `main.go`
- `.gitignore` (new)
1. Enumerate all block devices with the Encrypted interface. ### Work
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.
## 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`). 2. Add `--mountpoint` / `-m` flag to `mount` subcommand. When set,
Alternatively, accept from stdin for scripting. pass it to `cryptsetup.Mount` (privileged path) or log a warning
- **Keyfile**: read path from config, pass contents to udisks2. that udisks2 doesn't support custom mount points.
- **FIDO2**: `cryptsetup` handles the token interaction (tap prompt comes
from libfido2).
- **TPM2**: `cryptsetup` handles TPM unsealing automatically.
## 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`. 4. Create `.gitignore` containing `/arca`.
- Add as a flake input to the NixOS config repo.
## Open Questions ---
- Should `mount` accept a `--mountpoint` flag to override the default? ## M5: Documentation and packaging
- Should there be a `--dry-run` flag that shows what D-Bus calls would be
made? Make the project installable and the README trustworthy.
- Is there value in a `--json` output flag for `status`?
### 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 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{ u := unlock.New(client, unlock.Options{
ReadPassphrase: readPassphrase, ReadPassphrase: readPassphrase,
KeyfilePath: devCfg.Keyfile, KeyfilePath: devCfg.Keyfile,
@@ -50,16 +62,23 @@ func runMount(cmd *cobra.Command, args []string) error {
return err return err
} }
var mountpoint string
if result.Privileged { if result.Privileged {
mountpoint, err = cryptsetup.Mount(result.Device.DevicePath, devCfg.Mountpoint) mountpoint, err := cryptsetup.Mount(result.Device.DevicePath, devCfg.Mountpoint)
} else { if err != nil {
mountpoint, err = client.Mount(result.Device) 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 { if err != nil {
return fmt.Errorf("mounting: %w", err) return fmt.Errorf("mounting: %w", err)
} }
fmt.Println(mountpoint) fmt.Println(mountpoint)
return nil return nil
} }

View File

@@ -2,7 +2,6 @@ package cmd
import ( import (
"fmt" "fmt"
"strings"
"git.wntrmute.dev/kyle/arca/internal/config" "git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/cryptsetup" "git.wntrmute.dev/kyle/arca/internal/cryptsetup"
@@ -39,31 +38,27 @@ func runUnmount(cmd *cobra.Command, args []string) error {
return err return err
} }
// Check if already locked.
cleartext, err := client.CleartextDevice(dev) cleartext, err := client.CleartextDevice(dev)
if err != nil { 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. // Unmount if mounted.
mp, _ := client.MountPoint(cleartext) if mp, mounted := client.IsMounted(cleartext); mounted {
if err := client.Unmount(cleartext); err != nil { if err := client.Unmount(cleartext); err != nil {
if mp == "" { // udisks2 unmount failed — try privileged umount.
return fmt.Errorf("unmounting: %w", err) if err := cryptsetup.Unmount(mp); err != nil {
} return fmt.Errorf("unmounting: %w", err)
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 // Lock: try udisks2 first, fall back to cryptsetup close.
// an arca-managed mapping.
if err := client.Lock(dev); err != nil { if err := client.Lock(dev); err != nil {
mapperName := cryptsetup.MapperName(dev.DevicePath) mapperName := cryptsetup.MapperName(dev.DevicePath)
if strings.HasPrefix(mapperName, "arca-") { if err := cryptsetup.Close(mapperName); err != nil {
if err := cryptsetup.Close(mapperName); err != nil {
return fmt.Errorf("locking: %w", err)
}
} else {
return fmt.Errorf("locking: %w", err) 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 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. // DeviceAtPath returns the block device at the given D-Bus object path.
func (c *Client) DeviceAtPath(path dbus.ObjectPath) (*BlockDevice, error) { func (c *Client) DeviceAtPath(path dbus.ObjectPath) (*BlockDevice, error) {
devices, err := c.listBlockDevices() devices, err := c.listBlockDevices()