4 Commits

Author SHA1 Message Date
9c59e78e38 Bump version to 1.2.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:39:54 -07:00
53cd2d35f1 M10: clean up empty mount directories after unmount
Privileged unmount now does a best-effort rmdir on the mount point after
umount succeeds. Only removes empty directories; non-empty dirs and
errors are silently ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:39:42 -07:00
71e20925f6 M9: init --merge to add new devices without overwriting
Add --merge flag to init that loads existing config, skips devices
whose UUID is already configured, and appends only new discoveries.
--force and --merge are mutually exclusive. Uses Config.Save() from M8.
Error message now suggests both --force and --merge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:39:08 -07:00
feb22db039 M8: add command to append a single device to config
New 'arca add <device>' subcommand detects a LUKS device via udisks2 and
appends it to the config with passphrase as default method. Supports
--alias/-a to override the generated name. Skips if UUID already
configured. Adds Config.Save() and Config.HasUUID() to config package.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:38:32 -07:00
5 changed files with 152 additions and 35 deletions

87
cmd/add.go Normal file
View File

@@ -0,0 +1,87 @@
package cmd
import (
"fmt"
"strings"
"git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/udisks"
"github.com/spf13/cobra"
)
var addAlias string
var addCmd = &cobra.Command{
Use: "add <device>",
Short: "Add a device to the config",
Long: "Detects a LUKS device via udisks2 and adds it to the config file with a default passphrase method.",
Args: cobra.ExactArgs(1),
RunE: runAdd,
}
func init() {
addCmd.Flags().StringVarP(&addAlias, "alias", "a", "", "alias name (default: first 8 chars of UUID)")
rootCmd.AddCommand(addCmd)
}
func runAdd(cmd *cobra.Command, args []string) error {
target := args[0]
client, err := udisks.NewClient()
if err != nil {
return fmt.Errorf("connecting to udisks2: %w", err)
}
defer client.Close()
// Find the device to get its UUID.
dev, err := client.FindDevice("", target)
if err != nil {
return err
}
if !dev.HasEncrypted {
return fmt.Errorf("%s is not a LUKS-encrypted device", target)
}
if dev.UUID == "" {
return fmt.Errorf("%s has no UUID", target)
}
cfg := config.Load()
// Check if already configured.
if existing := cfg.AliasFor(dev.UUID); existing != "" {
fmt.Printf("Device %s (UUID %s) already configured as %q\n", dev.DevicePath, dev.UUID, existing)
return nil
}
alias := addAlias
if alias == "" {
alias = aliasFromUUID(dev.UUID)
}
// Check for alias collision.
if _, exists := cfg.Devices[alias]; exists {
return fmt.Errorf("alias %q already in use — choose a different name with --alias", alias)
}
cfg.Devices[alias] = config.DeviceConfig{
UUID: dev.UUID,
Methods: []string{"passphrase"},
}
if err := cfg.Save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
fmt.Printf("Added %s (UUID %s) as %q\n", dev.DevicePath, dev.UUID, alias)
return nil
}
func aliasFromUUID(uuid string) string {
clean := strings.ReplaceAll(uuid, "-", "")
if len(clean) > 8 {
clean = clean[:8]
}
return clean
}

View File

@@ -3,18 +3,17 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"git.wntrmute.dev/kyle/arca/internal/config" "git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/udisks" "git.wntrmute.dev/kyle/arca/internal/udisks"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v3"
) )
var forceInit bool var (
forceInit bool
mergeInit bool
)
var initCmd = &cobra.Command{ var initCmd = &cobra.Command{
Use: "init", Use: "init",
@@ -25,17 +24,26 @@ var initCmd = &cobra.Command{
func init() { func init() {
initCmd.Flags().BoolVarP(&forceInit, "force", "f", false, "overwrite existing config file") initCmd.Flags().BoolVarP(&forceInit, "force", "f", false, "overwrite existing config file")
initCmd.Flags().BoolVar(&mergeInit, "merge", false, "add new devices to existing config without overwriting")
initCmd.MarkFlagsMutuallyExclusive("force", "merge")
rootCmd.AddCommand(initCmd) rootCmd.AddCommand(initCmd)
} }
func runInit(cmd *cobra.Command, args []string) error { func runInit(cmd *cobra.Command, args []string) error {
cfgPath := config.Path() cfgPath := config.Path()
// Load existing config for merge, or start fresh.
var cfg *config.Config
if mergeInit {
cfg = config.Load()
} else {
if !forceInit { if !forceInit {
if _, err := os.Stat(cfgPath); err == nil { if _, err := os.Stat(cfgPath); err == nil {
return fmt.Errorf("config already exists at %s (use --force to overwrite)", cfgPath) return fmt.Errorf("config already exists at %s (use --force to overwrite or --merge to add new devices)", cfgPath)
} }
} }
cfg = &config.Config{Devices: make(map[string]config.DeviceConfig)}
}
client, err := udisks.NewClient() client, err := udisks.NewClient()
if err != nil { if err != nil {
@@ -53,43 +61,42 @@ func runInit(cmd *cobra.Command, args []string) error {
return fmt.Errorf("detecting root device: %w", err) return fmt.Errorf("detecting root device: %w", err)
} }
cfg := config.Config{ added := 0
Devices: make(map[string]config.DeviceConfig),
}
for _, dev := range encrypted { for _, dev := range encrypted {
if isRootBacking(dev.ObjectPath, rootBacking) { if isRootBacking(dev.ObjectPath, rootBacking) {
fmt.Fprintf(os.Stderr, "Skipping %s (root filesystem)\n", dev.DevicePath) fmt.Fprintf(os.Stderr, "Skipping %s (root filesystem)\n", dev.DevicePath)
continue continue
} }
if cfg.HasUUID(dev.UUID) {
fmt.Fprintf(os.Stderr, "Skipping %s (already configured)\n", dev.DevicePath)
continue
}
alias := aliasFromUUID(dev.UUID) alias := aliasFromUUID(dev.UUID)
cfg.Devices[alias] = config.DeviceConfig{ cfg.Devices[alias] = config.DeviceConfig{
UUID: dev.UUID, UUID: dev.UUID,
Methods: []string{"passphrase"}, Methods: []string{"passphrase"},
} }
fmt.Fprintf(os.Stderr, "Found %s (UUID %s) -> alias %q\n", dev.DevicePath, dev.UUID, alias) fmt.Fprintf(os.Stderr, "Found %s (UUID %s) -> alias %q\n", dev.DevicePath, dev.UUID, alias)
added++
} }
if len(cfg.Devices) == 0 { if added == 0 && !mergeInit {
fmt.Println("No non-root LUKS devices found.") fmt.Println("No non-root LUKS devices found.")
return nil return nil
} }
data, err := yaml.Marshal(&cfg) if added == 0 && mergeInit {
if err != nil { fmt.Println("No new devices to add.")
return fmt.Errorf("marshaling config: %w", err) return nil
} }
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { if err := cfg.Save(); err != nil {
return fmt.Errorf("creating config directory: %w", err) return fmt.Errorf("saving config: %w", err)
} }
if err := os.WriteFile(cfgPath, data, 0o644); err != nil { fmt.Printf("Config written to %s (%d device(s) added)\n", cfgPath, added)
return fmt.Errorf("writing config: %w", err)
}
fmt.Printf("Config written to %s\n", cfgPath)
return nil return nil
} }
@@ -101,13 +108,3 @@ func isRootBacking(path dbus.ObjectPath, rootDevices []dbus.ObjectPath) bool {
} }
return false return false
} }
func aliasFromUUID(uuid string) string {
// Use first 8 chars of UUID as a stable alias.
// "b8b2f8e3-4cde-4aca-a96e-df9274019f9f" -> "b8b2f8e3"
clean := strings.ReplaceAll(uuid, "-", "")
if len(clean) > 8 {
clean = clean[:8]
}
return clean
}

View File

@@ -10,7 +10,7 @@
let let
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
version = "1.1.0"; version = "1.2.0";
in in
{ {
packages.${system}.default = pkgs.buildGoModule { packages.${system}.default = pkgs.buildGoModule {

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -76,6 +77,31 @@ func resolvedFrom(dev DeviceConfig) ResolvedDevice {
} }
} }
// HasUUID returns true if a device with the given UUID is already configured.
func (c *Config) HasUUID(uuid string) bool {
for _, dev := range c.Devices {
if dev.UUID == uuid {
return true
}
}
return false
}
// Save writes the config to the config file.
func (c *Config) Save() error {
path := configPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := yaml.Marshal(c)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(path, data, 0o644)
}
// AliasFor returns the config alias for a given UUID, or "" if none. // AliasFor returns the config alias for a given UUID, or "" if none.
func (c *Config) AliasFor(uuid string) string { func (c *Config) AliasFor(uuid string) string {
for name, dev := range c.Devices { for name, dev := range c.Devices {

View File

@@ -64,7 +64,8 @@ func Mount(devicePath, mountpoint string) (string, error) {
return mountpoint, nil return mountpoint, nil
} }
// Unmount unmounts the given mountpoint using privileged umount. // Unmount unmounts the given mountpoint using privileged umount, then
// removes the mount directory if it is empty.
func Unmount(mountpoint string) error { func Unmount(mountpoint string) error {
args := withPrivilege([]string{"umount", mountpoint}) args := withPrivilege([]string{"umount", mountpoint})
cmd := exec.Command(args[0], args[1:]...) cmd := exec.Command(args[0], args[1:]...)
@@ -73,6 +74,12 @@ func Unmount(mountpoint string) error {
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("umount %s: %w", mountpoint, err) return fmt.Errorf("umount %s: %w", mountpoint, err)
} }
// Clean up empty mount directory. Best-effort — ignore errors
// (directory may not be empty or may be a system path).
rmdirArgs := withPrivilege([]string{"rmdir", mountpoint})
exec.Command(rmdirArgs[0], rmdirArgs[1:]...).Run()
return nil return nil
} }