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