summaryrefslogtreecommitdiff
path: root/internal/cli/cli.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli/cli.go')
-rw-r--r--internal/cli/cli.go230
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)