diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-06-04 22:38:44 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-06-04 22:38:44 -0400 |
| commit | 66b782e261f1cd928ad6a8482788a65fb484db45 (patch) | |
| tree | 38b6c46200d9c4464affc1c0c43494a5555acf33 /internal/cli/cli.go | |
| parent | c53503b52b6fc6de37b6053719521054003fa50b (diff) | |
refactor: simplify architecture and improve documentation
- Extract orchestration logic from `internal/cli` into a new `internal/manager` package for better composability.
- Migrate technical implementation details from README.md to package-level godoc strings.
- Rewrite README.md to be more user-centric, focusing on quick start and usage.
- Add comprehensive documentation for exported structs and fields across the project.
- Verify all changes with `go fmt`, `go vet`, `golangci-lint`, and full E2E test suite.
Diffstat (limited to 'internal/cli/cli.go')
| -rw-r--r-- | internal/cli/cli.go | 230 |
1 files changed, 28 insertions, 202 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index edf6048..1873e28 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,3 +1,5 @@ +// Package cli implements the command-line interface and bootstrap orchestration +// for wg-wrap. package cli import ( @@ -9,26 +11,37 @@ import ( "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/internal/wireguard" "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()) + } + 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++ { @@ -50,6 +63,8 @@ func (a *App) Route() error { 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] { @@ -73,8 +88,14 @@ func (a *App) Run() error { } return namespace.VerifyArguments(a.Args) case "test-lifecycle": - pm := a.getPathManager() - return a.verifyLifecycle(pm) + 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) } } @@ -123,39 +144,11 @@ func (a *App) Run() error { } if namespace.IsIsolated() { - return a.ExecuteCommand(cfg) - } - - pm := a.getPathManager() - - // Preserve the host runtime base dir in the environment before bootstrapping - _ = os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", pm.RuntimeBaseDir()) - - // Acquire startup lock to prevent concurrent bootstrap/joining races - lockFile, lockErr := namespace.AcquireProfileLock(pm, cfg.Profile) - if lockErr == nil { - defer namespace.ReleaseProfileLock(lockFile) - } - - // Before bootstrapping, see if an active namespace/process for the profile exists. - // If yes, we can join it! - activePid, err := namespace.FindActiveProfilePid(pm, cfg.Profile) - if err == nil && activePid > 0 { - // Release the lock before executing the command to allow others to join - namespace.ReleaseProfileLock(lockFile) - - // Register this PID before joining to prevent the race where the joining process - // hasn't registered itself yet, causing the existing process to think it's the last one. - _ = namespace.RegisterProcess(pm, cfg.Profile) - - if err := namespace.BootstrapJoin(activePid); err != nil { - return fmt.Errorf("failed to join existing namespace: %w", err) - } - return nil + return a.getManager().Execute(cfg, a.isVerbose()) } - if err := namespace.Bootstrap(); err != nil { - return fmt.Errorf("bootstrap failed: %w", err) + if err := a.getManager().Bootstrap(cfg); err != nil { + return err } return nil @@ -165,154 +158,6 @@ func (a *App) isVerbose() bool { return os.Getenv("WG_WRAP_VERBOSE") == "1" } -func (a *App) ExecuteCommand(cfg *config.Config) error { - if !namespace.IsIsolated() { - return fmt.Errorf("ExecuteCommand called without namespace isolation") - } - - pm := a.getPathManager() - - // Acquire execution lock during configuration and startup inside the namespace - lockFile, lockErr := namespace.AcquireProfileLock(pm, cfg.Profile) - var lockFileReleased bool - if lockErr == nil { - defer func() { - if !lockFileReleased { - namespace.ReleaseProfileLock(lockFile) - } - }() - } - - if err := namespace.PruneStalePids(pm, cfg.Profile); err != nil { - fmt.Fprintf(os.Stderr, "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) - } - - defer func() { - var cleanupLock *os.File - var cleanupErr error - - if lockErr == nil && !lockFileReleased { - // We already hold the lock, so we can just reuse lockFile for cleanup! - cleanupLock = lockFile - } else { - // Re-acquire lock for the entire cleanup sequence to ensure atomic unregister and unpin - cleanupLock, cleanupErr = namespace.AcquireProfileLock(pm, cfg.Profile) - } - - if cleanupErr == nil { - // 1. Unregister the process first. - if err := namespace.UnregisterProcess(pm, cfg.Profile); err != nil { - fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err) - } - - // 2. Prune and check if we are the last process. - if err := namespace.PruneStalePids(pm, cfg.Profile); err != nil { - fmt.Fprintf(os.Stderr, "failed to prune stale pids during cleanup: %v\n", err) - } - - last, lastErr := namespace.IsLastProcess(pm, cfg.Profile) - - if lastErr == nil && last { - if err := namespace.UnpinNamespace(pm, cfg.Profile); err != nil { - fmt.Fprintf(os.Stderr, "failed to unpin namespace: %v\n", err) - } - } - if lockErr == nil && !lockFileReleased { - lockFileReleased = true - } - namespace.ReleaseProfileLock(cleanupLock) - } else { - // Fallback if lock fails to ensure we still unregister - if err := namespace.UnregisterProcess(pm, cfg.Profile); err != nil { - fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err) - } - } - }() - - if os.Getenv("WG_WRAP_JOINED") == "1" { - if a.isVerbose() { - fmt.Printf("Joining active WireGuard tunnel session for profile %s...\n", cfg.Profile) - } - } else { - if a.isVerbose() { - fmt.Printf("Initializing WireGuard tunnel for profile %s...\n", cfg.Profile) - } - - // Parse the profile configuration - profilesDir := pm.ConfigDir() - profilePath := filepath.Join(profilesDir, cfg.Profile+".conf") - - // Create tunnel if the file exists - if _, err := os.Stat(profilePath); err == nil { - wgCfg, err := wgconf.Parse(profilePath) - if err != nil { - return fmt.Errorf("failed to parse profile %s: %w", cfg.Profile, err) - } - - // Start the WireGuard userspace device & routing table setup - dnsServer := cfg.DNSServer - if dnsServer == "" { - dnsServer = wgCfg.DNS - } - if dnsServer == "" { - dnsServer = "1.1.1.1" // Fallback to safe public DNS to prevent leaks - hasDefaultRoute := false - for _, peer := range wgCfg.Peers { - for _, ip := range peer.AllowedIPs { - trimmed := strings.TrimSpace(ip) - if trimmed == "0.0.0.0/0" || trimmed == "::/0" { - hasDefaultRoute = true - break - } - } - if hasDefaultRoute { - break - } - } - if !hasDefaultRoute { - fmt.Fprintf(os.Stderr, "warning: Falling back to 1.1.1.1, but your profile does not route all traffic (0.0.0.0/0). DNS resolution may fail.\n") - } - } - - tunnel, err := wireguard.StartTunnel(pm, cfg.Profile, wgCfg, dnsServer) - if err != nil { - return fmt.Errorf("failed to start WireGuard tunnel: %w", err) - } - defer tunnel.Close() - - // Pin the namespace so others can join it - if err := namespace.PinNamespace(pm, cfg.Profile); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to pin namespace: %v\n", err) - } - } else { - // If profile is not default or it was explicitly requested but doesn't exist, we error - if cfg.Profile != "default" { - return fmt.Errorf("profile %s not found: %w", cfg.Profile, err) - } - fmt.Fprintf(os.Stderr, "warning: default profile configuration not found. Executing command in bare isolation.\n") - } - } - - // We can now release the startup lock and execute the command - lockFileReleased = true - namespace.ReleaseProfileLock(lockFile) - - cmd := exec.Command(cfg.Command[0], cfg.Command[1:]...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = os.Environ() - - if err := cmd.Run(); err != nil { - return fmt.Errorf("command execution failed: %w", err) - } - - return nil -} - func (a *App) handleProfileCmd() error { if len(a.Args) < 3 { return fmt.Errorf("usage: wg-wrap profile <list|import|configure|delete|stop> [args]") @@ -349,8 +194,7 @@ func (a *App) handleProfileCmd() error { return fmt.Errorf("invalid profile name: %q", a.Args[3]) } fmt.Printf("Stopping profile %s and unpinning namespace...\n", a.Args[3]) - pm := a.getPathManager() - if err := namespace.UnpinNamespace(pm, a.Args[3]); err != nil { + 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]) @@ -478,24 +322,6 @@ func (a *App) handleProfileDelete(name string) error { return nil } -func (a *App) verifyLifecycle(pm *paths.PathManager) error { - 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 - } - } - - activePid, err := namespace.FindActiveProfilePid(pm, profile) - if err != nil || activePid <= 0 { - return fmt.Errorf("no active session found for profile %s", profile) - } - - fmt.Printf("Active session found for profile %s (PID: %d)\n", profile, activePid) - return nil -} - func (a *App) showConfig() error { cfg := &config.Config{} fs := flag.NewFlagSet("wg-wrap", flag.ExitOnError) |
