Add config validation, remove command, status filtering, and unlock method display

config check: validates UUID format, recognized methods, keyfile
consistency and existence. Reports all issues with alias context.

remove: deletes a device from config by alias. Inverse of add.

status: --mounted, --unlocked, --locked flags filter the device table.
Flags combine as OR.

mount/unlock: display which method succeeded and key slot used, e.g.
"(fido2, key slot 1)". cryptsetup Open now runs with -v and parses
"Key slot N unlocked" from stderr via io.MultiWriter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 10:22:52 -07:00
parent ce10c41466
commit e9247c720a
9 changed files with 245 additions and 27 deletions

45
cmd/config.go Normal file
View File

@@ -0,0 +1,45 @@
package cmd
import (
"fmt"
"os"
"git.wntrmute.dev/kyle/arca/internal/config"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage arca configuration",
}
var configCheckCmd = &cobra.Command{
Use: "check",
Short: "Validate the config file",
RunE: runConfigCheck,
}
func init() {
configCmd.AddCommand(configCheckCmd)
rootCmd.AddCommand(configCmd)
}
func runConfigCheck(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if len(cfg.Devices) == 0 {
fmt.Println("No devices configured.")
return nil
}
errs := config.Validate(cfg)
if len(errs) == 0 {
fmt.Printf("Config OK (%d device(s))\n", len(cfg.Devices))
return nil
}
for _, e := range errs {
fmt.Fprintln(os.Stderr, e)
}
return fmt.Errorf("config has %d issue(s)", len(errs))
}

19
cmd/format.go Normal file
View File

@@ -0,0 +1,19 @@
package cmd
import (
"fmt"
"git.wntrmute.dev/kyle/arca/internal/unlock"
)
// formatMethod returns a parenthesized string describing how a device
// was unlocked, e.g. "(fido2, key slot 1)" or "(passphrase)".
func formatMethod(r *unlock.Result) string {
if r.Method == "" {
return ""
}
if r.KeySlot != "" {
return fmt.Sprintf("(%s, key slot %s)", r.Method, r.KeySlot)
}
return fmt.Sprintf("(%s)", r.Method)
}

View File

@@ -85,36 +85,43 @@ func runMount(cmd *cobra.Command, args []string) error {
return err
}
methodInfo := formatMethod(result)
if result.Privileged {
mnt, err := cryptsetup.Mount(result.Device.DevicePath, mp)
if err != nil {
return fmt.Errorf("mounting: %w", err)
}
fmt.Println(mnt)
fmt.Printf("%s %s\n", mnt, methodInfo)
return nil
}
if mp != "" {
fmt.Fprintf(os.Stderr, "warning: --mountpoint is ignored for udisks2 mounts (passphrase/keyfile path)\n")
}
return doMount(client, result.Device, "")
return doMountWithInfo(client, result.Device, "", methodInfo)
}
func doMount(client *udisks.Client, cleartext *udisks.BlockDevice, mp string) error {
return doMountWithInfo(client, cleartext, mp, "")
}
func doMountWithInfo(client *udisks.Client, cleartext *udisks.BlockDevice, mp, methodInfo string) error {
var mnt string
var err error
if mp != "" {
// udisks2 doesn't support custom mount points; use privileged mount.
mnt, err := cryptsetup.Mount(cleartext.DevicePath, mp)
if err != nil {
return fmt.Errorf("mounting: %w", err)
}
fmt.Println(mnt)
return nil
mnt, err = cryptsetup.Mount(cleartext.DevicePath, mp)
} else {
mnt, err = client.Mount(cleartext)
}
mnt, err := client.Mount(cleartext)
if err != nil {
return fmt.Errorf("mounting: %w", err)
}
fmt.Println(mnt)
if methodInfo != "" {
fmt.Printf("%s %s\n", mnt, methodInfo)
} else {
fmt.Println(mnt)
}
return nil
}

38
cmd/remove.go Normal file
View File

@@ -0,0 +1,38 @@
package cmd
import (
"fmt"
"git.wntrmute.dev/kyle/arca/internal/config"
"github.com/spf13/cobra"
)
var removeCmd = &cobra.Command{
Use: "remove <alias>",
Short: "Remove a device from the config",
Args: cobra.ExactArgs(1),
RunE: runRemove,
ValidArgsFunction: completeDeviceOrAlias,
}
func init() {
rootCmd.AddCommand(removeCmd)
}
func runRemove(cmd *cobra.Command, args []string) error {
alias := args[0]
cfg := config.Load()
if _, ok := cfg.Devices[alias]; !ok {
return fmt.Errorf("no device with alias %q in config", alias)
}
delete(cfg.Devices, alias)
if err := cfg.Save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
fmt.Printf("Removed %q from config\n", alias)
return nil
}

View File

@@ -10,6 +10,12 @@ import (
"github.com/spf13/cobra"
)
var (
filterMounted bool
filterUnlocked bool
filterLocked bool
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show LUKS volume status",
@@ -17,6 +23,9 @@ var statusCmd = &cobra.Command{
}
func init() {
statusCmd.Flags().BoolVar(&filterMounted, "mounted", false, "show only mounted devices")
statusCmd.Flags().BoolVar(&filterUnlocked, "unlocked", false, "show only unlocked (but not mounted) devices")
statusCmd.Flags().BoolVar(&filterLocked, "locked", false, "show only locked devices")
rootCmd.AddCommand(statusCmd)
}
@@ -34,6 +43,8 @@ func runStatus(cmd *cobra.Command, args []string) error {
return err
}
filtering := filterMounted || filterUnlocked || filterLocked
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "DEVICE\tUUID\tALIAS\tSTATE\tMOUNTPOINT")
@@ -50,6 +61,23 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
}
if filtering {
switch state {
case "mounted":
if !filterMounted {
continue
}
case "unlocked":
if !filterUnlocked {
continue
}
case "locked":
if !filterLocked {
continue
}
}
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
dev.DevicePath, dev.UUID, alias, state, mountpoint)
}

View File

@@ -54,6 +54,6 @@ func runUnlock(cmd *cobra.Command, args []string) error {
return err
}
fmt.Printf("Unlocked %s -> %s\n", target, result.Device.DevicePath)
fmt.Printf("Unlocked %s -> %s %s\n", target, result.Device.DevicePath, formatMethod(result))
return nil
}