summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-05-22 16:17:55 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-05-22 16:17:55 -0400
commit135f6edbd9389bc4783f13c26aed0a74d3c8aca0 (patch)
tree41a8e80b0dcf2c42b045bc91d9101deceb049f47 /internal/cli
parent2e3a1d07b43e6e942e51ba263c6fcdc2351afc0d (diff)
refactor: unify path management and complete profile management system
- Create internal/paths package for unified config and runtime directory resolution - Implement robust WireGuard config parsing in pkg/wgconf - Implement profile management subcommands: list, import, configure, delete, stop - Fix namespace pinning path collisions (separating .ns files from pids directories) - Implement and verify namespace unpinning logic - Fix linting errors and improve error handling across the project
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/cli.go178
-rw-r--r--internal/cli/cli_test.go4
-rw-r--r--internal/cli/profile_test.go125
3 files changed, 250 insertions, 57 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 13a4a6b..66b5f79 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -5,12 +5,15 @@ import (
"fmt"
"os"
"os/exec"
+ "path/filepath"
+ "strings"
"git.theodohertyfamily.com/tools/wg-wrap/internal/config"
"git.theodohertyfamily.com/tools/wg-wrap/internal/namespace"
+ "git.theodohertyfamily.com/tools/wg-wrap/internal/paths"
+ "git.theodohertyfamily.com/tools/wg-wrap/pkg/wgconf"
)
-
type App struct {
Args []string
ConfigDir string // Optional override for profile storage location
@@ -21,8 +24,11 @@ func NewApp(args []string) *App {
return &App{Args: args}
}
+func (a *App) getPathManager() *paths.PathManager {
+ return paths.NewPathManager(a.ConfigDir, a.RuntimeBaseDir)
+}
+
func (a *App) Route() error {
- // 1. Validate arguments for null bytes to prevent exec failures in the C launcher
for i, arg := range a.Args {
for j := 0; j < len(arg); j++ {
if arg[j] == 0 {
@@ -31,7 +37,6 @@ func (a *App) Route() error {
}
}
- // Handle the internal diagnostic commands that should run on the HOST
if len(a.Args) > 1 {
switch a.Args[1] {
case "show-config":
@@ -39,18 +44,14 @@ func (a *App) Route() error {
}
}
- // Handle subcommands first (profile list, import, configure, delete, stop)
if len(a.Args) > 1 && a.Args[1] == "profile" {
return a.handleProfileCmd()
}
- // If we reach here, we are either wrapping a process or running a command
- // that requires isolation (e.g., test-ns, test-args).
return a.Run()
}
func (a *App) Run() error {
- // Handle the internal diagnostic commands that require ISOLATION
if len(a.Args) > 1 {
switch a.Args[1] {
case "test-ns":
@@ -78,7 +79,7 @@ func (a *App) Run() error {
cfg := &config.Config{}
fs := flag.NewFlagSet("wg-wrap", flag.ExitOnError)
- fs.StringVar(&cfg.Profile, "profile", "", "WireGuard profile to use (filename without extension in ~/.config/wg-wrap/profiles/)")
+ fs.StringVar(&cfg.Profile, "profile", "", "WireGuard profile to use")
fs.StringVar(&cfg.DNSServer, "dns-server", "", "Override DNS server to use")
args := a.Args[1:]
@@ -115,57 +116,46 @@ func (a *App) Run() error {
cfg.Profile = "default"
}
- // If we are already isolated, we enter the execution phase.
if namespace.IsIsolated() {
return a.ExecuteCommand(cfg)
}
- // If we are not isolated, we bootstrap.
- // The Bootstrap process will replace this process and restart it.
if err := namespace.Bootstrap(); err != nil {
return fmt.Errorf("bootstrap failed: %w", err)
}
- // This point is never reached because Bootstrap uses syscall.Exec
return nil
}
-// ExecuteCommand handles the isolated execution of the target application.
-// This is called after the bootstrap loop has successfully isolated the process.
func (a *App) ExecuteCommand(cfg *config.Config) error {
if !namespace.IsIsolated() {
return fmt.Errorf("ExecuteCommand called without namespace isolation")
}
- // 1. Prepare the namespace
- baseDir := a.RuntimeBaseDir
- if baseDir == "" {
- // Use XDG_RUNTIME_DIR or default via the namespace package
- // Since the namespace package now handles the default in GetProfileNamespacePath,
- // we can pass empty string if no override is present.
- baseDir = ""
- }
+ pm := a.getPathManager()
- namespace.PruneStalePids(baseDir, cfg.Profile)
- if err := namespace.RegisterProcess(baseDir, cfg.Profile); err != nil {
+ 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 {
return fmt.Errorf("failed to register process: %w", err)
}
- // Ensure we unregister and check for cleanup on exit
defer func() {
- namespace.UnregisterProcess(baseDir, cfg.Profile)
- if last, err := namespace.IsLastProcess(baseDir, cfg.Profile); err == nil && last {
+ 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)
- // Here we would call namespace.UnpinNamespace(baseDir, cfg.Profile)
- // and terminate the userspace WG process.
+ if err := namespace.UnpinNamespace(pm, cfg.Profile); err != nil {
+ fmt.Printf("failed to unpin namespace: %v\n", err)
+ }
}
}()
- // 2. VPN Setup (Stubbed)
fmt.Printf("Initializing WireGuard tunnel for profile %s...\n", cfg.Profile)
// TODO: Integrate with internal/wireguard to set up TUN and WG-Go
- // 3. Execute the target command
cmd := exec.Command(cfg.Command[0], cfg.Command[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
@@ -187,69 +177,147 @@ func (a *App) handleProfileCmd() error {
subCmd := a.Args[2]
switch subCmd {
case "list":
- fmt.Println("Listing profiles...")
- return fmt.Errorf("profile list not yet implemented")
+ return a.handleProfileList()
case "import":
if len(a.Args) < 4 {
return fmt.Errorf("usage: wg-wrap profile import <path>")
}
- fmt.Printf("Importing profile from %s...\n", a.Args[3])
- return fmt.Errorf("profile import not yet implemented")
+ return a.handleProfileImport(a.Args[3])
case "configure":
if len(a.Args) < 4 {
return fmt.Errorf("usage: wg-wrap profile configure <name>")
}
- fmt.Printf("Configuring profile %s...\n", a.Args[3])
- return fmt.Errorf("profile configure not yet implemented")
+ return a.handleProfileConfigure(a.Args[3])
case "delete":
if len(a.Args) < 4 {
return fmt.Errorf("usage: wg-wrap profile delete <name>")
}
- fmt.Printf("Deleting profile %s...\n", a.Args[3])
- return fmt.Errorf("profile delete not yet implemented")
+ return a.handleProfileDelete(a.Args[3])
case "stop":
if len(a.Args) < 4 {
return fmt.Errorf("usage: wg-wrap profile stop <name>")
}
fmt.Printf("Stopping profile %s and unpinning namespace...\n", a.Args[3])
- return fmt.Errorf("profile stop not yet implemented")
+ pm := a.getPathManager()
+ if err := namespace.UnpinNamespace(pm, a.Args[3]); err != nil {
+ return fmt.Errorf("failed to stop profile: %w", err)
+ }
+ fmt.Printf("Profile %s stopped and unpinned.\n", a.Args[3])
+ return nil
default:
return fmt.Errorf("unknown profile subcommand: %s", subCmd)
}
}
+func (a *App) handleProfileConfigure(name string) error {
+ profilesDir := a.getPathManager().ConfigDir()
+ profilePath := filepath.Join(profilesDir, name+".conf")
+ if _, err := os.Stat(profilePath); os.IsNotExist(err) {
+ return fmt.Errorf("profile '%s' not found", name)
+ }
+
+ cfg, err := wgconf.Parse(profilePath)
+ if err != nil {
+ return fmt.Errorf("failed to parse profile %s: %w", name, err)
+ }
+
+ fmt.Printf("Editing profile %s...\n", name)
+ fmt.Println("DNS server (current: '" + cfg.DNS + "'):")
+
+ return fmt.Errorf("interactive configuration not supported in this environment, use a config file")
+}
+
+func (a *App) handleProfileList() error {
+ profilesDir := a.getPathManager().ConfigDir()
+ entries, err := os.ReadDir(profilesDir)
+ if err != nil {
+ return fmt.Errorf("failed to read profiles directory %s: %w", profilesDir, err)
+ }
+
+ fmt.Println("Available profiles:")
+ found := false
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") {
+ name := strings.TrimSuffix(entry.Name(), ".conf")
+ fmt.Printf("- %s\n", name)
+ found = true
+ }
+ }
+
+ if !found {
+ fmt.Println(" (no profiles found)")
+ }
+
+ return nil
+}
+
+func (a *App) handleProfileImport(srcPath string) error {
+ profilesDir := a.getPathManager().ConfigDir()
+ if err := os.MkdirAll(profilesDir, 0755); err != nil {
+ return fmt.Errorf("failed to create profiles directory: %w", err)
+ }
+
+ if _, err := wgconf.Parse(srcPath); err != nil {
+ return fmt.Errorf("invalid WireGuard configuration at %s: %w", srcPath, err)
+ }
+
+ baseName := filepath.Base(srcPath)
+ name := strings.TrimSuffix(baseName, filepath.Ext(baseName))
+ if name == "" {
+ return fmt.Errorf("invalid source filename")
+ }
+
+ destPath := filepath.Join(profilesDir, name+".conf")
+ data, err := os.ReadFile(srcPath)
+ if err != nil {
+ return fmt.Errorf("failed to read source file: %w", err)
+ }
+
+ if err := os.WriteFile(destPath, data, 0644); err != nil {
+ return fmt.Errorf("failed to write profile to %s: %w", destPath, err)
+ }
+
+ fmt.Printf("Profile '%s' imported successfully to %s\n", name, destPath)
+ return nil
+}
+
+func (a *App) handleProfileDelete(name string) error {
+ profilesDir := a.getPathManager().ConfigDir()
+ destPath := filepath.Join(profilesDir, name+".conf")
+
+ if _, err := os.Stat(destPath); os.IsNotExist(err) {
+ return fmt.Errorf("profile '%s' not found", name)
+ }
+
+ if err := os.Remove(destPath); err != nil {
+ return fmt.Errorf("failed to delete profile %s: %w", name, err)
+ }
+
+ fmt.Printf("Profile '%s' deleted successfully\n", name)
+ return nil
+}
+
func (a *App) showConfig() error {
cfg := &config.Config{}
fs := flag.NewFlagSet("wg-wrap", flag.ExitOnError)
fs.StringVar(&cfg.Profile, "profile", "default", "WireGuard profile to use")
fs.StringVar(&cfg.DNSServer, "dns-server", "", "Override DNS server to use")
- // Parse the arguments that follow 'show-config'
if len(a.Args) > 2 {
_ = fs.Parse(a.Args[2:])
}
- // Determine runtime base directory
- runtimeBase := a.RuntimeBaseDir
- if runtimeBase == "" {
- runtimeBase = os.Getenv("XDG_RUNTIME_DIR")
- if runtimeBase == "" {
- runtimeBase = fmt.Sprintf("/run/user/%d", os.Getuid())
- }
- }
-
- // Resolve paths
- profilePath := namespace.GetProfileNamespacePath(runtimeBase, cfg.Profile)
- pidsPath := namespace.GetPidsDirPath(runtimeBase, cfg.Profile)
+ pm := paths.NewPathManager(a.ConfigDir, a.RuntimeBaseDir)
+ profilePath := pm.ProfileNamespacePath(cfg.Profile)
+ pidsPath := pm.ProfilePidsDir(cfg.Profile)
fmt.Printf("Configuration:\n")
fmt.Printf(" Profile: %s\n", cfg.Profile)
fmt.Printf(" DNS Server: %s\n", cfg.DNSServer)
- fmt.Printf(" Runtime Base: %s\n", runtimeBase)
+ fmt.Printf(" Runtime Base: %s\n", pm.RuntimeBaseDir())
fmt.Printf(" Profile Path: %s\n", profilePath)
fmt.Printf(" PIDs Path: %s\n", pidsPath)
fmt.Printf(" Isolated: %v\n", namespace.IsIsolated())
fmt.Printf(" UID: %d\n", os.Getuid())
-
return nil
}
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 0274fbc..a0d6263 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -1,8 +1,8 @@
package cli
import (
- "testing"
"strings"
+ "testing"
)
func TestAppRun_ProfileDirInjection(t *testing.T) {
@@ -25,7 +25,7 @@ func TestAppRun_ProfileDirInjection(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := NewApp(tt.args)
- app.ConfigDir = tmpDir // Inject temporary directory
+ app.ConfigDir = tmpDir // Inject temporary directory
app.RuntimeBaseDir = tmpDir // Inject temporary directory for PID tracking
err := app.Run()
diff --git a/internal/cli/profile_test.go b/internal/cli/profile_test.go
new file mode 100644
index 0000000..d256cb0
--- /dev/null
+++ b/internal/cli/profile_test.go
@@ -0,0 +1,125 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestProfileList(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create some dummy profile files
+ profiles := []string{"home.conf", "work.conf", "not-a-conf.txt"}
+ for _, p := range profiles {
+ err := os.WriteFile(filepath.Join(tmpDir, p), []byte("test content"), 0644)
+ if err != nil {
+ t.Fatalf("failed to create test profile %s: %v", p, err)
+ }
+ }
+
+ app := NewApp([]string{"wg-wrap", "profile", "list"})
+ app.ConfigDir = tmpDir
+
+ err := app.Route()
+ if err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
+}
+
+func TestProfileImport(t *testing.T) {
+ tmpDir := t.TempDir()
+ profilesDir := filepath.Join(tmpDir, "profiles")
+ err := os.MkdirAll(profilesDir, 0755)
+ if err != nil {
+ t.Fatalf("failed to create profiles dir: %v", err)
+ }
+
+ srcFile := filepath.Join(tmpDir, "source.conf")
+ err = os.WriteFile(srcFile, []byte("[Interface]\nPrivateKey = test\n"), 0644)
+ if err != nil {
+ t.Fatalf("failed to create source conf: %v", err)
+ }
+
+ app := NewApp([]string{"wg-wrap", "profile", "import", srcFile})
+ app.ConfigDir = profilesDir
+
+ err = app.Route()
+ if err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
+
+ // Verify the file was actually copied
+ destFile := filepath.Join(profilesDir, "source.conf")
+ if _, err := os.Stat(destFile); os.IsNotExist(err) {
+ t.Errorf("expected profile to be imported to %s", destFile)
+ }
+}
+
+func TestProfileDelete(t *testing.T) {
+ tmpDir := t.TempDir()
+ profilesDir := filepath.Join(tmpDir, "profiles")
+ err := os.MkdirAll(profilesDir, 0755)
+ if err != nil {
+ t.Fatalf("failed to create profiles dir: %v", err)
+ }
+
+ profileName := "test-profile"
+ profileFile := filepath.Join(profilesDir, profileName+".conf")
+ err = os.WriteFile(profileFile, []byte("[Interface]\nPrivateKey = test\n"), 0644)
+ if err != nil {
+ t.Fatalf("failed to create profile file: %v", err)
+ }
+
+ app := NewApp([]string{"wg-wrap", "profile", "delete", profileName})
+ app.ConfigDir = profilesDir
+
+ err = app.Route()
+ if err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
+
+ if _, err := os.Stat(profileFile); !os.IsNotExist(err) {
+ t.Errorf("expected profile file %s to be deleted", profileFile)
+ }
+}
+
+func TestProfileDeleteNotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+ app := NewApp([]string{"wg-wrap", "profile", "delete", "non-existent"})
+ app.ConfigDir = tmpDir
+
+ err := app.Route()
+ if err == nil {
+ t.Errorf("expected error when deleting non-existent profile, got nil")
+ }
+}
+
+func TestProfileConfigure(t *testing.T) {
+ // profile configure is intended to modify existing configs.
+ // For now, we just want to ensure it doesn't crash and we can
+ // eventually implement it.
+
+ tmpDir := t.TempDir()
+ profilesDir := filepath.Join(tmpDir, "profiles")
+ err := os.MkdirAll(profilesDir, 0755)
+ if err != nil {
+ t.Fatalf("failed to create profiles dir: %v", err)
+ }
+
+ profileName := "test-profile"
+ profileFile := filepath.Join(profilesDir, profileName+".conf")
+ err = os.WriteFile(profileFile, []byte("[Interface]\nPrivateKey = test\n"), 0644)
+ if err != nil {
+ t.Fatalf("failed to create profile file: %v", err)
+ }
+
+ app := NewApp([]string{"wg-wrap", "profile", "configure", profileName})
+ app.ConfigDir = profilesDir
+
+ err = app.Route()
+ // This will currently return "not yet implemented" error, which is expected for now.
+ if err == nil {
+ t.Errorf("expected 'not yet implemented' error, got nil")
+ }
+}