summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:56:45 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:56:45 -0400
commita7c7fa9e76c9c7015c31378062aa5d0c17b0f38f (patch)
treef45c63ab1d8647c657175dd92ec15000dd64975e /internal/cli
parentc6a1240e469ff8170cf31b39a01c1cb08fdb86f4 (diff)
Fix DNS leaks, lifecycle race, and editor arg splitting
- DNS Leak / Isolation Bypass: Blocked glibc's systemd-resolved and D-Bus socket communication within the unprivileged mount namespace by introducing BlockHostServices(). This targeted mount-blocking forces glibc to fall back to the standard resolv.conf DNS routing path and prevents host leaks. - Lifecycle Race: Reordered and protected the reference-counting cleanup routine under the profile flock to ensure that check-and-unpin operations are atomic and do not teardown namespaces actively used by parallel processes. - Editor Arguments: Split the EDITOR environment variable into discrete field tokens before invocation to support editor configurations containing command-line flags. - Testing: Added E2E regression tests for DNS leak detection, namespace unpinning concurrency, and editor argument parsing. All E2E tests now compile and pass cleanly.
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/cli.go57
1 files changed, 33 insertions, 24 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 85b9ae3..9b3409e 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -159,25 +159,39 @@ func (a *App) ExecuteCommand(cfg *config.Config) error {
// Acquire execution lock during configuration and startup inside the namespace
lockFile, lockErr := namespace.AcquireProfileLock(pm, cfg.Profile)
+ if lockErr == nil {
+ defer namespace.ReleaseProfileLock(lockFile)
+ }
if err := namespace.PruneStalePids(pm, cfg.Profile); err != nil {
fmt.Printf("failed to prune stale pids: %v\n", err)
}
if err := namespace.RegisterProcess(pm, cfg.Profile); err != nil {
- if lockErr == nil {
- namespace.ReleaseProfileLock(lockFile)
- }
return fmt.Errorf("failed to register process: %w", err)
}
defer func() {
- if err := namespace.UnregisterProcess(pm, cfg.Profile); err != nil {
- fmt.Printf("failed to unregister process: %v\n", err)
- }
- if last, err := namespace.IsLastProcess(pm, cfg.Profile); err == nil && last {
- fmt.Printf("Last process exiting. Cleaning up profile %s...\n", cfg.Profile)
- if err := namespace.UnpinNamespace(pm, cfg.Profile); err != nil {
- fmt.Printf("failed to unpin namespace: %v\n", err)
+ // Re-acquire lock for the entire cleanup sequence to ensure atomic unregister and unpin
+ cleanupLock, cleanupErr := namespace.AcquireProfileLock(pm, cfg.Profile)
+ if cleanupErr == nil {
+ // Check if we are the last active process before unregistering
+ last, lastErr := namespace.IsLastProcess(pm, cfg.Profile)
+
+ if err := namespace.UnregisterProcess(pm, cfg.Profile); err != nil {
+ fmt.Printf("failed to unregister process: %v\n", err)
+ }
+
+ if lastErr == nil && last {
+ fmt.Printf("Last process exiting. Cleaning up profile %s...\n", cfg.Profile)
+ if err := namespace.UnpinNamespace(pm, cfg.Profile); err != nil {
+ fmt.Printf("failed to unpin namespace: %v\n", err)
+ }
+ }
+ namespace.ReleaseProfileLock(cleanupLock)
+ } else {
+ // Fallback if lock fails to ensure we still unregister
+ if err := namespace.UnregisterProcess(pm, cfg.Profile); err != nil {
+ fmt.Printf("failed to unregister process: %v\n", err)
}
}
}()
@@ -192,9 +206,6 @@ func (a *App) ExecuteCommand(cfg *config.Config) error {
if _, err := os.Stat(profilePath); err == nil {
wgCfg, err := wgconf.Parse(profilePath)
if err != nil {
- if lockErr == nil {
- namespace.ReleaseProfileLock(lockFile)
- }
return fmt.Errorf("failed to parse profile %s: %w", cfg.Profile, err)
}
@@ -225,9 +236,6 @@ func (a *App) ExecuteCommand(cfg *config.Config) error {
tunnel, err := wireguard.StartTunnel(wgCfg, dnsServer)
if err != nil {
- if lockErr == nil {
- namespace.ReleaseProfileLock(lockFile)
- }
return fmt.Errorf("failed to start WireGuard tunnel: %w", err)
}
defer tunnel.Close()
@@ -239,18 +247,13 @@ func (a *App) ExecuteCommand(cfg *config.Config) error {
} else {
// If profile is not default or it was explicitly requested but doesn't exist, we error
if cfg.Profile != "default" {
- if lockErr == nil {
- namespace.ReleaseProfileLock(lockFile)
- }
return fmt.Errorf("profile %s not found: %w", cfg.Profile, err)
}
fmt.Printf("warning: default profile configuration not found. Executing command in bare isolation.\n")
}
- // Setup and initialization are complete. We can now safely release the startup lock!
- if lockErr == nil {
- namespace.ReleaseProfileLock(lockFile)
- }
+ // We can now release the startup lock and execute the command
+ namespace.ReleaseProfileLock(lockFile)
cmd := exec.Command(cfg.Command[0], cfg.Command[1:]...)
cmd.Stdin = os.Stdin
@@ -327,9 +330,15 @@ func (a *App) handleProfileConfigure(name string) error {
editor = "vi" // Sensible fallback
}
+ // Split editor string into command and arguments (e.g., "vim -R" -> ["vim", "-R"])
+ editorArgs := strings.Fields(editor)
+ if len(editorArgs) == 0 {
+ editorArgs = []string{"vi"}
+ }
+
fmt.Printf("Opening profile %s in default editor (%s)...\n", name, editor)
- cmd := exec.Command(editor, profilePath)
+ cmd := exec.Command(editorArgs[0], append(editorArgs[1:], profilePath)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr