diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-05-22 16:17:55 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-05-22 16:17:55 -0400 |
| commit | 135f6edbd9389bc4783f13c26aed0a74d3c8aca0 (patch) | |
| tree | 41a8e80b0dcf2c42b045bc91d9101deceb049f47 /internal/cli/cli.go | |
| parent | 2e3a1d07b43e6e942e51ba263c6fcdc2351afc0d (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/cli.go')
| -rw-r--r-- | internal/cli/cli.go | 178 |
1 files changed, 123 insertions, 55 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 } |
