From ee2f5d545825752af63da36e2b9ec7a92985a875 Mon Sep 17 00:00:00 2001 From: James O'Doherty Date: Fri, 29 May 2026 18:29:12 -0400 Subject: feat: implement userspace wireguard data-path and unprivileged host fd-passing - Implement complete rootless network namespace bootstrap via C launcher using unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET). - Resolve unprivileged network isolation blackhole via host-socket preservation (FD passing): open client UDP sockets on the host pre-isolation, clear O_CLOEXEC, and ingest them via custom `FDBind` inside the sandbox. - Implement isolated routing table automation over `tun0` (addresses, MTU, default routes). - Implement persistent, multi-process namespace sharing and joining using reference-counted PID files and the setns system call. - Write robust, self-contained E2E data plane test suites in `tests/e2e/e2e_test.go` using a mock UDP listener. - Update project documentation (`README.md` and `AGENTS.md`) to reflect completed milestones. - Ensure 100% test passing rate and zero lint/staticcheck warnings. --- internal/cli/cli.go | 42 +++++++++++++++++++++++++++++++++++++++++- internal/cli/cli_test.go | 19 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) (limited to 'internal/cli') diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 66b5f79..0876d08 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -11,6 +11,7 @@ import ( "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/internal/wireguard" "git.theodohertyfamily.com/tools/wg-wrap/pkg/wgconf" ) @@ -120,6 +121,16 @@ func (a *App) Run() error { return a.ExecuteCommand(cfg) } + // Before bootstrapping, see if an active namespace/process for the profile exists. + // If yes, we can join it! + pm := a.getPathManager() + joined, err := namespace.JoinExistingNamespace(pm, cfg.Profile) + if err == nil && joined { + // We have joined the active namespace (user, mnt, net). + // We can now execute the command immediately in this context! + return a.ExecuteCommand(cfg) + } + if err := namespace.Bootstrap(); err != nil { return fmt.Errorf("bootstrap failed: %w", err) } @@ -154,7 +165,36 @@ func (a *App) ExecuteCommand(cfg *config.Config) error { }() fmt.Printf("Initializing WireGuard tunnel for profile %s...\n", cfg.Profile) - // TODO: Integrate with internal/wireguard to set up TUN and WG-Go + + // 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 + tunnel, err := wireguard.StartTunnel(wgCfg) + 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.Printf("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.Printf("warning: default profile configuration not found. Executing command in bare isolation.\n") + } cmd := exec.Command(cfg.Command[0], cfg.Command[1:]...) cmd.Stdin = os.Stdin diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a0d6263..fcf489a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,6 +1,8 @@ package cli import ( + "os" + "path/filepath" "strings" "testing" ) @@ -10,6 +12,21 @@ func TestAppRun_ProfileDirInjection(t *testing.T) { // Set up a temporary directory to simulate XDG_CONFIG_HOME/wg-wrap/profiles tmpDir := t.TempDir() + // Write a valid test-vpn.conf profile file to the temporary directory + confContent := `[Interface] +PrivateKey = YXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGY= +Address = 10.0.0.2/24 + +[Peer] +PublicKey = YXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGY= +Endpoint = 127.0.0.1:51820 +AllowedIPs = 10.0.0.0/24 +` + importPath := filepath.Join(tmpDir, "test-vpn.conf") + if err := os.WriteFile(importPath, []byte(confContent), 0644); err != nil { + t.Fatalf("failed to write test profile: %v", err) + } + tests := []struct { name string args []string @@ -17,7 +34,7 @@ func TestAppRun_ProfileDirInjection(t *testing.T) { }{ { name: "valid profile with injected dir", - args: []string{"wg-wrap", "--profile", "test-vpn", "curl", "google.com"}, + args: []string{"wg-wrap", "--profile", "test-vpn", "true"}, wantErr: false, }, } -- cgit v1.2.3