From 96d75d9f1fab87365d7e6b5070eed3a5757c3484 Mon Sep 17 00:00:00 2001 From: James O'Doherty Date: Fri, 22 May 2026 09:18:55 -0400 Subject: Refactor CLI for testability and implement hermetic config path injection --- internal/cli/cli.go | 114 +++++++++++++++++++++++++++++++++++ internal/cli/cli_test.go | 34 +++++++++++ internal/config/config.go | 18 ++++++ internal/namespace/namespace_test.go | 14 ++--- internal/wireguard/wireguard_test.go | 8 +-- 5 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go (limited to 'internal') diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..cb95202 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,114 @@ +package cli + +import ( + "flag" + "fmt" + + "git.theodohertyfamily.com/tools/wg-wrap/internal/config" +) + +type App struct { + Args []string + ConfigDir string // Optional override for profile storage location +} + +func NewApp(args []string) *App { + return &App{Args: args} +} + +func (a *App) Run() error { + // Handle subcommands first (profile list, import, configure, delete, stop) + if len(a.Args) > 1 && a.Args[1] == "profile" { + return a.handleProfileCmd() + } + + 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.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" + } + + profilesDir := a.ConfigDir + if profilesDir == "" { + profilesDir = config.GetDefaultProfilesDir() + } + + fmt.Printf("Profile: %s\n", cfg.Profile) + fmt.Printf("Profiles Directory: %s\n", profilesDir) + fmt.Printf("DNS Server: %s\n", cfg.DNSServer) + fmt.Printf("Command: %v\n", cfg.Command) + return nil +} + +func (a *App) handleProfileCmd() error { + if len(a.Args) < 3 { + return fmt.Errorf("usage: wg-wrap profile [args]") + } + + subCmd := a.Args[2] + switch subCmd { + case "list": + fmt.Println("Listing profiles...") + return fmt.Errorf("profile list not yet implemented") + case "import": + if len(a.Args) < 4 { + return fmt.Errorf("usage: wg-wrap profile import ") + } + fmt.Printf("Importing profile from %s...\n", a.Args[3]) + return fmt.Errorf("profile import not yet implemented") + case "configure": + if len(a.Args) < 4 { + return fmt.Errorf("usage: wg-wrap profile configure ") + } + fmt.Printf("Configuring profile %s...\n", a.Args[3]) + return fmt.Errorf("profile configure not yet implemented") + case "delete": + if len(a.Args) < 4 { + return fmt.Errorf("usage: wg-wrap profile delete ") + } + fmt.Printf("Deleting profile %s...\n", a.Args[3]) + return fmt.Errorf("profile delete not yet implemented") + case "stop": + if len(a.Args) < 4 { + return fmt.Errorf("usage: wg-wrap profile stop ") + } + fmt.Printf("Stopping profile %s and unpinning namespace...\n", a.Args[3]) + return fmt.Errorf("profile stop not yet implemented") + default: + return fmt.Errorf("unknown profile subcommand: %s", subCmd) + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..71ff6cb --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "testing" +) + +func TestAppRun_ProfileDirInjection(t *testing.T) { + // Set up a temporary directory to simulate XDG_CONFIG_HOME/wg-wrap/profiles + tmpDir := t.TempDir() + + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "valid profile with injected dir", + args: []string{"wg-wrap", "--profile", "test-vpn", "curl", "google.com"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := NewApp(tt.args) + app.ConfigDir = tmpDir // Inject temporary directory + + err := app.Run() + if (err != nil) != tt.wantErr { + t.Errorf("App.Run() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5aa8462..d81a1f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,25 @@ package config +import ( + "os" + "path/filepath" +) + type Config struct { Profile string DNSServer string Command []string } + +// GetDefaultProfilesDir returns the standard XDG path for wg-wrap profiles. +func GetDefaultProfilesDir() string { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "etc/wg-wrap/profiles" // Fallback + } + configHome = filepath.Join(home, ".config") + } + return filepath.Join(configHome, "wg-wrap", "profiles") +} diff --git a/internal/namespace/namespace_test.go b/internal/namespace/namespace_test.go index cfa0e9b..e39710d 100644 --- a/internal/namespace/namespace_test.go +++ b/internal/namespace/namespace_test.go @@ -7,18 +7,16 @@ import ( ) func TestNamespaceCreation(t *testing.T) { - t.Log("Integration Test: Verifying CLONE_NEWUSER and CLONE_NEWNET syscalls") - // TODO: Verify that unshare creates a new network namespace - // TODO: Verify that the process has root privileges inside the namespace + // Test that CLONE_NEWUSER and CLONE_NEWNET are called in the correct sequence and a netns is created. + t.Skip("not implemented") } func TestNamespacePinning(t *testing.T) { - t.Log("Integration Test: Verifying bind-mount of namespace to /run/user/$UID/wg-wrap/") - // TODO: Verify that the namespace survives after the process exits - // TODO: Verify that we can re-join the namespace via setns + // Test that the network namespace is bind-mounted to /run/user/$UID/wg-wrap/ and persists after process exit. + t.Skip("not implemented") } func TestRoutingSetup(t *testing.T) { - t.Log("Integration Test: Verifying TUN device creation and IP routing table setup") - // TODO: Mock 'ip' command or use netlink to verify route exists + // Test that the TUN device is created and the routing table is configured with the correct VPN subnet. + t.Skip("not implemented") } diff --git a/internal/wireguard/wireguard_test.go b/internal/wireguard/wireguard_test.go index 05e0fb7..9bbd24c 100644 --- a/internal/wireguard/wireguard_test.go +++ b/internal/wireguard/wireguard_test.go @@ -7,11 +7,11 @@ import ( ) func TestWireGuardDeviceBinding(t *testing.T) { - t.Log("Integration Test: Verifying binding of userspace WG device to TUN device") - // TODO: Initialize a wg-go device and link it to a mock TUN + // Test that the userspace WireGuard device is correctly bound to the Linux TUN device. + t.Skip("not implemented") } func TestIpcSetConfiguration(t *testing.T) { - t.Log("Integration Test: Verifying IpcSet applies keys and endpoints correctly") - // TODO: Verify that configuration updates are reflected in the device state + // Test that IpcSet correctly updates the WireGuard device keys and endpoints. + t.Skip("not implemented") } -- cgit v1.2.3