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:
248
PLAN.md
248
PLAN.md
@@ -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
|
||||
|
||||
29
cmd/mount.go
29
cmd/mount.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user