diff options
Diffstat (limited to 'internal/cli')
| -rw-r--r-- | internal/cli/cli.go | 178 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 4 | ||||
| -rw-r--r-- | internal/cli/profile_test.go | 125 |
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") + } +} |
