// Package cli implements the command-line interface and bootstrap orchestration // for wg-wrap. package cli import ( "flag" "fmt" "os" "os/exec" "path/filepath" "strings" "git.theodohertyfamily.com/wg-wrap/internal/config" "git.theodohertyfamily.com/wg-wrap/internal/manager" "git.theodohertyfamily.com/wg-wrap/internal/namespace" "git.theodohertyfamily.com/wg-wrap/internal/paths" "git.theodohertyfamily.com/wg-wrap/pkg/wgconf" ) // App handles the CLI interaction and orchestration for wg-wrap. type App struct { Args []string ConfigDir string // Optional override for profile storage location RuntimeBaseDir string // Optional override for namespace/PID tracking mgr *manager.Manager } // NewApp creates a new App instance with the provided arguments. func NewApp(args []string) *App { return &App{Args: args} } func (a *App) getManager() *manager.Manager { if a.mgr == nil { a.mgr = manager.New(a.getPathManager(), namespace.NewLinuxOps()) } return a.mgr } func (a *App) getPathManager() *paths.PathManager { return paths.NewPathManager(a.ConfigDir, a.RuntimeBaseDir) } // Route parses the command line arguments and routes to the appropriate handler. func (a *App) Route() error { for i, arg := range a.Args { for j := 0; j < len(arg); j++ { if arg[j] == 0 { return fmt.Errorf("argument %d contains null byte at position %d", i, j) } } } if len(a.Args) > 1 { switch a.Args[1] { case "show-config": return a.showConfig() case "profile": return a.handleProfileCmd() } } return a.Run() } // Run executes the main logic of wg-wrap, including bootstrapping the namespace // and launching the wrapped command. func (a *App) Run() error { if len(a.Args) > 1 { switch a.Args[1] { case "test-ns": if !namespace.IsIsolated() { if err := namespace.Bootstrap(); err != nil { return fmt.Errorf("bootstrap failed: %w", err) } } ok, msg := namespace.VerifyIsolation() if !ok { return fmt.Errorf("isolation check failed: %s", msg) } fmt.Println("Isolation Verified: OK") return nil case "test-args": if !namespace.IsIsolated() { if err := namespace.Bootstrap(); err != nil { return fmt.Errorf("bootstrap failed: %w", err) } } return namespace.VerifyArguments(a.Args) case "test-lifecycle": profile := "default" for i := 0; i < len(a.Args)-1; i++ { if a.Args[i] == "--profile" && i+1 < len(a.Args) { profile = a.Args[i+1] break } } return a.getManager().VerifyLifecycle(profile) } } cfg := &config.Config{} fs := flag.NewFlagSet("wg-wrap", flag.ExitOnError) fs.Usage = a.printUsage fs.StringVar(&cfg.Profile, "profile", "", "WireGuard profile to use") fs.StringVar(&cfg.DNSServer, "dns-server", "", "Override DNS server to use") args := a.Args[1:] sepIdx := -1 for i, arg := range args { if arg == "--" { sepIdx = i break } } var flagsToParse []string if sepIdx != -1 { flagsToParse = args[:sepIdx] cfg.Command = args[sepIdx+1:] } else { flagsToParse = args } err := fs.Parse(flagsToParse) if err != nil { return fmt.Errorf("error parsing flags: %w", err) } if sepIdx == -1 { cfg.Command = fs.Args() } if len(cfg.Command) == 0 { return fmt.Errorf("no command provided. use --help for usage") } if cfg.Profile == "" { cfg.Profile = "default" } if !IsValidProfileName(cfg.Profile) { return fmt.Errorf("invalid profile name: %q (only alphanumeric characters, underscores, and hyphens are allowed)", cfg.Profile) } if namespace.IsIsolated() { return a.getManager().Execute(cfg, a.isVerbose()) } if err := a.getManager().Bootstrap(cfg); err != nil { return err } return nil } func (a *App) isVerbose() bool { return os.Getenv("WG_WRAP_VERBOSE") == "1" } func (a *App) printUsage() { fmt.Fprintf(os.Stderr, "Usage: wg-wrap [options] [-- command [args]]\n\n") fmt.Fprintf(os.Stderr, "Options:\n") fmt.Fprintf(os.Stderr, " -profile string\n\tWireGuard profile to use (default \"default\")\n") fmt.Fprintf(os.Stderr, " -dns-server string\n\tOverride DNS server to use\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, " show-config\n\tDisplay the current configuration and environment details\n") fmt.Fprintf(os.Stderr, " profile \n\tManage WireGuard profiles\n\t\t(list, import, configure, delete, stop)\n\n") fmt.Fprintf(os.Stderr, "Run the wrapped command:\n") fmt.Fprintf(os.Stderr, " wg-wrap [options] -- [args]\n") } func (a *App) printProfileUsage() { fmt.Fprintf(os.Stderr, "Usage: wg-wrap profile [args]\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, " list\n\tList all available profiles\n") fmt.Fprintf(os.Stderr, " import [name]\n\tImport a WireGuard .conf file\n") fmt.Fprintf(os.Stderr, " configure \n\tEdit a profile in the default editor\n") fmt.Fprintf(os.Stderr, " delete \n\tRemove a profile\n") fmt.Fprintf(os.Stderr, " stop \n\tStop a running profile and unpin its namespace\n") } func (a *App) handleProfileCmd() error { if len(a.Args) < 3 { a.printProfileUsage() return fmt.Errorf("missing profile subcommand") } subCmd := a.Args[2] switch subCmd { case "list": return a.handleProfileList() case "import": if len(a.Args) < 4 { a.printProfileUsage() return fmt.Errorf("missing import path") } var name string if len(a.Args) > 4 { name = a.Args[4] } return a.handleProfileImport(a.Args[3], name) case "configure": if len(a.Args) < 4 { a.printProfileUsage() return fmt.Errorf("missing profile name") } return a.handleProfileConfigure(a.Args[3]) case "delete": if len(a.Args) < 4 { a.printProfileUsage() return fmt.Errorf("missing profile name") } return a.handleProfileDelete(a.Args[3]) case "stop": if len(a.Args) < 4 { a.printProfileUsage() return fmt.Errorf("missing profile name") } if !IsValidProfileName(a.Args[3]) { return fmt.Errorf("invalid profile name: %q", a.Args[3]) } fmt.Printf("Stopping profile %s and unpinning namespace...\n", a.Args[3]) if err := a.getManager().StopProfile(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: a.printProfileUsage() return fmt.Errorf("unknown profile subcommand: %s", subCmd) } } func (a *App) handleProfileConfigure(name string) error { if !IsValidProfileName(name) { return fmt.Errorf("invalid profile name: %q", name) } 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) } editor := os.Getenv("EDITOR") if editor == "" { 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(editorArgs[0], append(editorArgs[1:], profilePath)...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("editor failed: %w", err) } return nil } 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, name 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) } if name == "" { baseName := filepath.Base(srcPath) name = strings.TrimSuffix(baseName, filepath.Ext(baseName)) if name == "" { return fmt.Errorf("invalid source filename") } } if !IsValidProfileName(name) { return fmt.Errorf("invalid profile name: %q", name) } destPath := filepath.Join(profilesDir, name+".conf") if _, err := os.Stat(destPath); err == nil { return fmt.Errorf("profile '%s' already exists", name) } data, err := os.ReadFile(srcPath) if err != nil { return fmt.Errorf("failed to read source file: %w", err) } if err := os.WriteFile(destPath, data, 0600); 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 { if !IsValidProfileName(name) { return fmt.Errorf("invalid profile name: %q", name) } 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") if len(a.Args) > 2 { _ = fs.Parse(a.Args[2:]) } if !IsValidProfileName(cfg.Profile) { return fmt.Errorf("invalid profile name: %q", 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(" Config Dir: %s\n", pm.ConfigDir()) 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 } // IsValidProfileName checks if a WireGuard profile name is safe and valid. // It allows only alphanumeric characters, underscores, and hyphens, and prevents // directory traversal attacks and hidden files. func IsValidProfileName(name string) bool { if name == "" { return false } for _, r := range name { if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' { return false } } if name == "." || name == ".." || strings.HasPrefix(name, "-") { return false } return true }