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 | |
| 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.
| -rw-r--r-- | README.md | 236 | ||||
| -rw-r--r-- | internal/cli/cli.go | 230 | ||||
| -rw-r--r-- | internal/config/config.go | 8 | ||||
| -rw-r--r-- | internal/manager/manager.go | 238 | ||||
| -rw-r--r-- | internal/namespace/namespace.go | 21 | ||||
| -rw-r--r-- | internal/paths/paths.go | 14 | ||||
| -rw-r--r-- | internal/wireguard/wireguard.go | 33 | ||||
| -rw-r--r-- | pkg/wgconf/wgconf.go | 25 |
8 files changed, 437 insertions, 368 deletions
@@ -1,169 +1,91 @@ # wg-wrap: Transparent Userspace VPN Wrapper -## Overview -wg-wrap is a tool that allows a native Linux process to communicate over a WireGuard VPN without requiring the host kernel to manage the VPN tunnel or requiring global root privileges. It achieves this by bridging a Linux network namespace's TUN device directly to a userspace WireGuard implementation. +`wg-wrap` allows you to run specific Linux applications over a WireGuard VPN without requiring root privileges or affecting your entire system's network configuration. -### Building from Source -Because `wg-wrap` uses an embedded C launcher to handle rootless namespace transitions, it cannot be built using `go build` alone. You must use the provided Makefile. - -**Requirements:** -- `gcc` -- `go` (1.23+) +## 🚀 Quick Start -**Build Instructions:** +### 1. Install +Build the binary using the provided Makefile: ```bash make ``` -This will compile the C launcher and embed it into the final `wg-wrap` binary. - -**Testing the Project:** -The project uses a `Makefile` to orchestrate building and testing. -- **Standard Tests**: Run the unit and integration tests: - ```bash - make test - ``` -- **Security Fuzzing**: Run the 8-bit clean argument integrity fuzzer: - ```bash - make fuzz - ``` - You can adjust the fuzzer's parallelism and duration: - ```bash - FUZZ_PARALLEL=4 FUZZ_TIME=1h make fuzz - ``` - -## Profile Management -To simplify usage, `wg-wrap` implements a profile system for managing WireGuard configurations. -- **Storage**: Profiles are stored as standard `.conf` files in `~/.config/wg-wrap/profiles/`. -- **Selection**: Users can reference a profile by its filename (without the extension). - - Example: `wg-wrap --profile home-vpn curl google.com` (looks for `~/.config/wg-wrap/profiles/home-vpn.conf`). -- **Command Separation**: Supports the `--` delimiter to explicitly separate `wg-wrap` flags from the target command. - - Example: `wg-wrap --profile home-vpn -- curl --option-that-looks-like-a-flag` -- **Default**: An optional `default.conf` in the same directory can be used if no profile is specified. - -### Profile Management Commands -Beyond wrapping commands, `wg-wrap` provides management sub-commands to handle profiles: -- `wg-wrap profile list`: Lists all available profiles in the config directory. -- `wg-wrap profile import <path> [name]`: Imports a `.conf` file into the profiles directory. If `[name]` is not provided, the profile name is derived from the `.conf` filename. Otherwise, the specified custom name is used. -- `wg-wrap profile configure <name>`: Opens the selected profile in the system's default editor. -- `wg-wrap profile delete <name>`: Removes the specified profile. -- `wg-wrap profile stop <name>`: Stops the tunnel/namespace associated with the specified profile and unpins it. + +### 2. Setup a Profile +Import your WireGuard `.conf` file as a profile: +```bash +./wg-wrap profile import ~/my-vpn.conf home-vpn +``` + +### 3. Run an Application +Run any command wrapped in the VPN: +```bash +./wg-wrap --profile home-vpn -- curl https://ifconfig.me +``` +*Only the `curl` command is routed through the VPN; your browser, SSH sessions, and other apps remain on your local network.* + +--- + +## 🛠️ Feature Overview + +### Process-Level Isolation +Unlike standard VPNs, `wg-wrap` provides complete network isolation per process. This means: +- **No Route Pollution**: Your host routing table remains untouched. +- **Rootless Operation**: Works without `sudo` using unprivileged user namespaces. +- **VPN Concurrency**: Run multiple different VPN profiles at the same time in different terminals. +- **Zero-Leak DNS**: Each process gets its own isolated DNS resolver, preventing leaks to your ISP. + +### Profile Management +Manage your VPN configurations easily from the CLI: + +| Command | Description | +| :--- | :--- | +| `profile list` | List all available VPN profiles. | +| `profile import <path> [name]` | Import a `.conf` file as a new profile. | +| `profile configure <name>` | Edit a profile's configuration in your default editor. | +| `profile delete <name>` | Remove a profile. | +| `profile stop <name>` | Force-stop an active tunnel session. | ### Diagnostics -For debugging and environment verification, `wg-wrap` provides diagnostic tools: -- `wg-wrap show-config [--profile <name>]`: Displays the current runtime configuration, including resolved paths for the runtime base directory and PID tracking, and verifies whether the process is currently isolated. -- `wg-wrap test-ns`: Verifies that the process is correctly isolated in a rootless network namespace. -- `wg-wrap test-args`: Outputs the current process arguments as hex-encoded strings to verify argument integrity through the bootstrap loop. - - -## The Core Architecture -The tool focuses on a direct, transparent data path: -`Linux Application` $\rightarrow$ `Linux Kernel Routing` $\rightarrow$ `TUN Device` $\rightarrow$ `Userspace WireGuard` $\rightarrow$ `UDP Socket` $\rightarrow$ `Internet`. - -### Rootless Bootstrap Loop & Host-Socket Preservation -To achieve rootless network isolation without interfering with the Go runtime's multi-threaded scheduler, and to maintain encrypted UDP socket connectivity over the host's network, `wg-wrap` employs an advanced bootstrap loop: - -1. **Host-Bound Socket Creation**: During the initial host-level start, `wg-wrap` opens a UDP socket bound to `0.0.0.0:0` on the host, clears its Close-on-Exec (`O_CLOEXEC`) flag using system `fcntl`, and stores the FD number in the environment (`WG_WRAP_HOST_SOCKET_FD`). -2. **Helper Deployment**: It writes an embedded single-threaded C launcher binary to a secure temporary location. -3. **Namespace Transition**: It uses `syscall.Exec` to replace itself with the C launcher, preserving the open socket file descriptor. -4. **Isolation**: The C launcher performs the `unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET)` sequence to isolate Mount, User, and Network environments, maps the current user to root (UID 0) inside the sandbox, and disables supplementary groups. -5. **Re-entry**: The launcher then `execvp`s the original `wg-wrap` binary. -6. **FDBind Tunnel Initialization**: The second instance of `wg-wrap` detects it is now isolated, extracts the `WG_WRAP_HOST_SOCKET_FD` descriptor, and wraps it inside a custom `FDBind` struct to initialize `wireguard-go`. Because sockets in Linux retain their creation-time network namespace, WireGuard's encrypted UDP transport communicates natively over the host interface, while decrypted process traffic is entirely locked inside the unprivileged sandbox's `tun0`. - -### Persistent Namespaces & Shared Sessions -To support multiple concurrent commands on the same WireGuard tunnel without re-establishing connections, `wg-wrap` employs session-based persistent, unprivileged namespaces: -- **Tracking**: Process usage is tracked using active PID files inside `/run/user/$UID/wg-wrap/profiles/<name>/pids/`. -- **Ref-Counting & Cleanup**: Active PIDs are regularly pruned. When the last active process exits, the namespace is unpinned via `UnpinNamespace` and resources are cleanly reclaimed. -- **Setns Join**: When a new process is executed on an active profile, it discovers an active PID and calls `syscall.Setns` (via `golang.org/x/sys/unix`) to attach itself to the existing User, Mount, and Network namespaces of the active tunnel in $\approx 10\text{ms}$. - -### 1. Components - -#### Network Namespace Manager -- **Isolation**: Uses `unshare -r -n` to create a new network namespace. This maps the current user to root within the namespace, allowing the creation of network interfaces without host-level root access. -- **Interface Setup**: Creates a virtual TUN device (e.g., `tun0`). -- **Kernel Routing**: Configures the Linux kernel's routing table inside the namespace to ensure the target traffic is directed into the TUN device. - - `ip addr add <internal-ip> dev tun0` - - `ip link set tun0 up` - - `ip route add <vpn-subnet> dev tun0` (or a default route `0.0.0.0/0`). - -#### Userspace WireGuard Controller -- **Engine**: Utilizes a userspace WireGuard implementation. - - Recommended: `golang.zx2c4.com/wireguard` (the official Go implementation). - - For advanced network stack isolation or userspace TCP/IP, consider `gvisor.dev/gvisor` (specifically `pkg/tcpip` for its `NetworkDispatcher` and `PacketBuffer` logic). -- **Binding**: The WireGuard device is bound directly to the TUN device. - - Use `golang.zx2c4.com/wireguard/tun` to interact with the Linux TUN device. - - Consider implementing a bridge similar to a userspace network stack to connect GVisor's network stack to the TUN device. -- **Configuration**: Parses standard WireGuard `.conf` files. - - **Implementation Note**: Use the `IpcSet` method of the `device.Device` to apply keys and endpoints. This avoids complex internal state management and is the standard way to interact with the userspace engine. - - Profiles must resolve to: Local Private Key, Remote Peer Public Keys, Remote Endpoints (UDP IP:Port), and Allowed IPs. - - - -#### Execution Wrapper -- **Bootstrap**: Orchestrates the namespace creation and WireGuard initialization. -- **Command Execution**: Uses `exec` to replace the bootstrap process with the user's requested command (e.g., `curl`, `ssh`), ensuring the command runs within the configured network environment. - -## Data Flow Detail -1. **Egress**: A process (e.g., `curl`) sends a packet to a remote IP. The Linux kernel sees the route via `tun0` and writes the raw IP packet to the TUN device. The userspace WireGuard device reads this packet, encrypts it according to the configured peer, and sends it as a UDP packet to the remote endpoint. -2. **Ingress**: A UDP packet arrives at the local port. The userspace WireGuard device decrypts it and writes the resulting raw IP packet back into the TUN device. The Linux kernel receives this packet and delivers it to the waiting application. - -## Comparison: Userspace vs. Kernel VPN -| Feature | Kernel WireGuard | WGWRAP (Userspace) | -| :--- | :--- | :--- | -| **Privileges** | Requires `CAP_NET_ADMIN` (Root) | No root required (`unshare`) | -| **Deployment** | Requires Kernel Module | Standalone Binary | -| **Isolation** | Global | Per-process/Namespace | -| **Routing** | Host Routing Table | Isolated Namespace Table | +Check your environment and verify isolation: +- `show-config`: View resolved paths and current isolation status. +- `test-ns`: Verify that you are correctly isolated in a network namespace. +- `test-args`: (For developers) Verify 8-bit clean argument passing. + +--- + +## 📖 Usage Examples + +**Run Firefox on a specific VPN:** +```bash +./wg-wrap --profile privacy-vpn -- firefox +``` + +**Run a series of tests against a private VPC:** +```bash +./wg-wrap --profile dev-vpc -- pytest tests/integration +``` + +**Connect to a home server and a work server simultaneously:** +```bash +# Terminal 1 +./wg-wrap --profile home-vpn -- ssh home-nas +# Terminal 2 +./wg-wrap --profile work-vpn -- ssh work-server +``` + +--- + +## 🏗️ Development + +### Building from Source +`wg-wrap` requires `gcc` and `go` (1.23+). It uses an embedded C launcher to handle the rootless namespace transition, so you must use the Makefile: +```bash +make +``` + +### Testing +- **Unit & Integration Tests**: `make test` +- **Security Fuzzing**: `make fuzz` (tests argument integrity through the bootstrap loop). ## License This project is free and unencumbered software released into the public domain. See the [LICENSE](LICENSE) file for details. - -## Technical Gotchas & Implementation Details - -### 1. MTU Management -WireGuard adds overhead. If the TUN device is set to 1500, encrypted packets may be dropped. -- **Action**: Set the TUN device MTU to `1420` bytes to prevent fragmentation/drops. - -### 2. DNS Handling -Routing traffic to the VPN doesn't guarantee DNS is routed. -- **Action**: The wrapper should update `/etc/resolv.conf` within the namespace or use the GVisor stack to intercept and redirect DNS queries to the VPN's designated DNS server. -- **Fallback Strategy**: **Never use the host's DNS servers**, as this creates DNS leaks and undermines the isolation of the network namespace. If no DNS server is specified in the WireGuard config: - - **Action**: Fall back to trusted, encrypted-capable public providers (e.g., Cloudflare `1.1.1.1` or Google `8.8.8.8`) via the VPN tunnel. - - **User Control**: Provide a flag (e.g., `--dns-server <IP>`) to allow the user to override the fallback and specify their own trusted resolver. - -### 3. Namespace Lifecycle -Network namespaces can leak if not managed. To prevent this, `wg-wrap` implements a "last-man-out" reference counting system: -- **Tracking**: Every process using a profile creates a PID file in `/run/user/$UID/wg-wrap/profiles/<name>/pids/`. -- **Automatic Cleanup**: When a process exits, it removes its PID file. If no PID files remain for a profile, `wg-wrap` automatically unpins the namespace and terminates the associated userspace WireGuard process. -- **Resilience**: Stale PID files (from crashed processes) are pruned during the initial join sequence of any new process. -- **Manual Override**: The controller also provides `wg-wrap profile stop <name>` to force the immediate teardown of a profile's namespace. - -### 4. User Namespace Sequence -To create a network namespace without root, you must create a user namespace first. -- **Sequence**: `CLONE_NEWUSER` $\rightarrow$ `CLONE_NEWNET` $\rightarrow$ `Setuid/Setgid` $\rightarrow$ Configure Interfaces. - -## Testing Strategy - -To ensure the correctness of the network isolation and the stability of the userspace tunnel, the following testing approach will be used: - -### 1. Component Testing (Unit/Integration) -- **Config Parser**: Test against a variety of WireGuard `.conf` files (edge cases: missing fields, invalid keys, extreme MTUs). -- **Profile Manager**: Verify that import/delete/edit operations correctly manipulate `~/.config/wg-wrap/profiles/`. -- **Namespace Logic**: Verify that `unshare` and `setns` calls correctly transition the process into the target namespace. - -### 2. Functional Testing (The "Data Plane" Test) -To avoid dependence on external infrastructure, we will implement a "Virtual Peer" test harness. This involves creating a standalone Go process that acts as the VPN server using a userspace network stack (e.g., GVisor). -- **Self-Contained Peer**: The test peer will instantiate its own userspace WireGuard device and an embedded TCP/IP stack to host virtual services (e.g., an HTTP server) on a private VPN IP. -- **Dynamic Config Generation**: The harness will generate a matching WireGuard `.conf` profile on the fly, allowing `wg-wrap` to connect to the virtual peer. -- **Connectivity Test**: `wg-wrap --profile test curl <virtual-peer-ip>` should successfully retrieve data from the embedded service. -- **Isolation Test**: `curl` executed *without* `wg-wrap` should not be able to reach the virtual peer's internal IP. -- **DNS Leak Test**: Use `dig` or `nslookup` to verify that DNS queries are routed through the VPN and not the host's resolver. -- **MTU Test**: Send large packets (e.g., using `ping -s 1400`) to ensure no fragmentation or packet loss occurs. - -### 3. Persistence & Concurrency Testing -- **Shared Namespace Test**: Start one command with a profile, then start another. Verify that both share the same internal IP address. -- **Lifecycle Test**: Terminate the first process and verify that the second process maintains connectivity (proving the namespace is pinned). -- **Cleanup Test**: Implement a `wg-wrap profile stop <name>` command to unpin the namespace and kill the associated userspace WG process. - -### 4. Environment Testing -- **Rootless Verification**: Run the entire suite as a non-privileged user to ensure no `CAP_NET_ADMIN` requirements are accidentally introduced. -- **Kernel Compatibility**: Test on multiple Linux kernels to ensure `CLONE_NEWUSER` and `CLONE_NEWNET` behave consistently. 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) diff --git a/internal/config/config.go b/internal/config/config.go index 5aa8462..d83c59d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,11 @@ package config +// Config holds the application-level configuration for a single execution run. type Config struct { - Profile string + // Profile is the name of the WireGuard profile to use. + Profile string + // DNSServer is an optional override for the DNS server. DNSServer string - Command []string + // Command is the actual command and arguments to be executed within the namespace. + Command []string } diff --git a/internal/manager/manager.go b/internal/manager/manager.go new file mode 100644 index 0000000..99a1a32 --- /dev/null +++ b/internal/manager/manager.go @@ -0,0 +1,238 @@ +// Package manager orchestrates the high-level lifecycle of WireGuard tunnels +// and their associated network namespaces. +// +// Architecture: +// wg-wrap provides a transparent data path: +// Linux Application -> Linux Kernel Routing -> TUN Device -> Userspace WireGuard -> UDP Socket -> Internet. +// +// Persistent Namespaces & Shared Sessions: +// To support multiple concurrent commands on the same WireGuard tunnel without re-establishing +// connections, wg-wrap employs session-based persistent, unprivileged namespaces. +// +// 1. Tracking: Process usage is tracked using active PID files inside the runtime base directory. +// 2. Ref-Counting & Cleanup: Active PIDs are regularly pruned. When the last active process exits, +// the namespace is unpinned and resources are reclaimed. +// 3. Setns Join: When a new process is executed on an active profile, it discovers an active PID +// and attaches itself to the existing User, Mount, and Network namespaces of the active tunnel. +package manager + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.theodohertyfamily.com/wg-wrap/internal/config" + "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" +) + +// Manager orchestrates the high-level lifecycle of WireGuard tunnels +// and their associated network namespaces. +type Manager struct { + // PM is the path manager used to resolve configuration and runtime directories. + PM *paths.PathManager +} + +// New creates a new Manager with the given path manager. +func New(pm *paths.PathManager) *Manager { + return &Manager{PM: pm} +} + +// Bootstrap ensures the process is running in an isolated user and network namespace. +// If an active session already exists for the profile, it joins it. +func (m *Manager) Bootstrap(cfg *config.Config) error { + if namespace.IsIsolated() { + return nil + } + + // Preserve the host runtime base dir in the environment before bootstrapping. + _ = os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", m.PM.RuntimeBaseDir()) + + // Acquire startup lock to prevent concurrent bootstrap/joining races. + lockFile, lockErr := namespace.AcquireProfileLock(m.PM, cfg.Profile) + if lockErr == nil { + defer namespace.ReleaseProfileLock(lockFile) + } + + // Before bootstrapping, see if an active namespace/process for the profile exists. + activePid, err := namespace.FindActiveProfilePid(m.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(m.PM, cfg.Profile) + + if err := namespace.BootstrapJoin(activePid); err != nil { + return fmt.Errorf("failed to join existing namespace: %w", err) + } + return nil + } + + if err := namespace.Bootstrap(); err != nil { + return fmt.Errorf("bootstrap failed: %w", err) + } + + return nil +} + +// Execute manages the full execution lifecycle inside an isolated namespace: +// lock acquisition, PID registration, tunnel initialization, command execution, and cleanup. +func (m *Manager) Execute(cfg *config.Config, verbose bool) error { + if !namespace.IsIsolated() { + return fmt.Errorf("Execute called without namespace isolation") + } + + // Acquire execution lock during configuration and startup inside the namespace. + lockFile, lockErr := namespace.AcquireProfileLock(m.PM, cfg.Profile) + var lockFileReleased bool + if lockErr == nil { + defer func() { + if !lockFileReleased { + namespace.ReleaseProfileLock(lockFile) + } + }() + } + + if err := namespace.PruneStalePids(m.PM, cfg.Profile); err != nil { + fmt.Fprintf(os.Stderr, "failed to prune stale pids: %v\n", err) + } + if err := namespace.RegisterProcess(m.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 { + cleanupLock = lockFile + } else { + cleanupLock, cleanupErr = namespace.AcquireProfileLock(m.PM, cfg.Profile) + } + + if cleanupErr == nil { + if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil { + fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err) + } + + if err := namespace.PruneStalePids(m.PM, cfg.Profile); err != nil { + fmt.Fprintf(os.Stderr, "failed to prune stale pids during cleanup: %v\n", err) + } + + last, lastErr := namespace.IsLastProcess(m.PM, cfg.Profile) + + if lastErr == nil && last { + if err := namespace.UnpinNamespace(m.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 { + if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil { + fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err) + } + } + }() + + if os.Getenv("WG_WRAP_JOINED") == "1" { + if verbose { + fmt.Printf("Joining active WireGuard tunnel session for profile %s...\n", cfg.Profile) + } + } else { + if verbose { + fmt.Printf("Initializing WireGuard tunnel for profile %s...\n", cfg.Profile) + } + + profilesDir := m.PM.ConfigDir() + profilePath := filepath.Join(profilesDir, cfg.Profile+".conf") + + 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) + } + + dnsServer := cfg.DNSServer + if dnsServer == "" { + dnsServer = wgCfg.DNS + } + if dnsServer == "" { + dnsServer = "1.1.1.1" + 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(m.PM, cfg.Profile, wgCfg, dnsServer) + if err != nil { + return fmt.Errorf("failed to start WireGuard tunnel: %w", err) + } + defer tunnel.Close() + + if err := namespace.PinNamespace(m.PM, cfg.Profile); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to pin namespace: %v\n", err) + } + } else { + if cfg.Profile != "default" { + return fmt.Errorf("profile %s not found", cfg.Profile) + } + fmt.Fprintf(os.Stderr, "warning: default profile configuration not found. Executing command in bare isolation.\n") + } + } + + 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 +} + +// StopProfile stops a profile session by unpinning its namespace. +func (m *Manager) StopProfile(profile string) error { + if err := namespace.UnpinNamespace(m.PM, profile); err != nil { + return fmt.Errorf("failed to stop profile: %w", err) + } + return nil +} + +// VerifyLifecycle checks for an active session for the given profile. +func (m *Manager) VerifyLifecycle(profile string) error { + activePid, err := namespace.FindActiveProfilePid(m.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 +} diff --git a/internal/namespace/namespace.go b/internal/namespace/namespace.go index 54414a9..b05dea2 100644 --- a/internal/namespace/namespace.go +++ b/internal/namespace/namespace.go @@ -1,3 +1,24 @@ +// Package namespace provides primitives for managing Linux user and network +// namespaces, including bootstrapping and pinning. +// +// Rootless Bootstrap Loop & Host-Socket Preservation: +// To achieve rootless network isolation without interfering with the Go runtime's multi-threaded +// scheduler, and to maintain encrypted UDP socket connectivity over the host's network, +// wg-wrap employs an advanced bootstrap loop: +// +// 1. Host-Bound Socket Creation: During the initial host-level start, a UDP socket is opened +// on 0.0.0.0:0 on the host, and its FD is stored in the environment (WG_WRAP_HOST_SOCKET_FD). +// 2. Helper Deployment: An embedded single-threaded C launcher is used to bridge the transition. +// 3. Namespace Transition: The process replaces itself with the C launcher via syscall.Exec. +// 4. Isolation: The launcher performs the unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET) +// sequence to isolate Mount, User, and Network environments. +// 5. Re-entry: The launcher then execvp's the original wg-wrap binary. +// 6. FDBind Tunnel Initialization: The second instance of wg-wrap wraps the host socket FD +// inside a custom FDBind struct to initialize wireguard-go. +// +// User Namespace Sequence: +// To create a network namespace without root, wg-wrap follows the sequence: +// CLONE_NEWUSER -> CLONE_NEWNET -> Setuid/Setgid -> Configure Interfaces. package namespace import ( diff --git a/internal/paths/paths.go b/internal/paths/paths.go index c7bdd94..67f6da6 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -1,3 +1,13 @@ +// Package paths handles the resolution of configuration and runtime directories, +// providing a consistent way to locate profile and PID tracking files. +// +// Profile Storage: +// Profiles are stored as standard .conf files in ~/.config/wg-wrap/profiles/. +// +// PID Tracking: +// To manage namespace lifecycles and shared sessions, wg-wrap tracks active processes +// using PID files located in the runtime base directory: +// /run/user/$UID/wg-wrap/profiles/<profile-name>/pids/ package paths import ( @@ -9,7 +19,9 @@ import ( // PathManager handles the resolution of configuration and runtime directories. // By using a struct, we can instantiate different managers for parallel tests. type PathManager struct { - ConfigDirOverride string + // ConfigDirOverride allows overriding the default config directory (usually XDG_CONFIG_HOME/wg-wrap/profiles). + ConfigDirOverride string + // RuntimeBaseOverride allows overriding the default runtime directory (usually XDG_RUNTIME_DIR). RuntimeBaseOverride string } diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go index 0c341f2..cea9590 100644 --- a/internal/wireguard/wireguard.go +++ b/internal/wireguard/wireguard.go @@ -1,5 +1,24 @@ //go:build linux +// Package wireguard provides the userspace WireGuard implementation and TUN device binding. +// +// Data Flow: +// 1. Egress: A process sends a packet. The Linux kernel routes it via tun0. The userspace +// WireGuard device reads this packet, encrypts it, and sends it as a UDP packet to the +// remote endpoint via the preserved host socket. +// 2. Ingress: A UDP packet arrives via the host socket. The userspace WireGuard device +// decrypts it and writes the raw IP packet back into the TUN device, delivering it to +// the process. +// +// MTU Management: +// WireGuard adds overhead. To prevent fragmentation and packet loss, the TUN device +// MTU is set to 1420 bytes. +// +// DNS Isolation: +// To prevent DNS leaks, wg-wrap isolates the namespace's DNS resolution by: +// 1. Creating a temporary resolv.conf within the profile's runtime directory. +// 2. Bind-mounting this file over /etc/resolv.conf inside the namespace. +// 3. Falling back to trusted public DNS (e.g., 1.1.1.1) if no DNS server is configured. package wireguard import ( @@ -25,7 +44,9 @@ import ( // Tunnel represents an active Userspace WireGuard tunnel inside a network namespace. type Tunnel struct { - Device *device.Device + // Device is the wireguard-go device instance. + Device *device.Device + // Tun is the underlying TUN device. Tun tun.Device dnsFile string } @@ -241,6 +262,7 @@ func GetTunnelLocalIP(cfg *wgconf.Config) (string, error) { return ip.String(), nil } +// ConfigureResolvConf creates a temporary resolv.conf file and bind-mounts it to /etc/resolv.conf. func ConfigureResolvConf(dns string, profileDir string) (string, error) { if dns == "" { return "", nil @@ -271,6 +293,7 @@ func ConfigureResolvConf(dns string, profileDir string) (string, error) { return launcherPath, nil } +// UnmountResolvConf unmounts the bind-mounted resolv.conf and removes the temporary file. func UnmountResolvConf(path string) error { if path == "" { return nil @@ -286,6 +309,8 @@ func UnmountResolvConf(path string) error { return nil } +// BlockHostServices bind-mounts empty files/directories over sensitive host services +// to prevent access from within the isolated namespace. func BlockHostServices(pm *paths.PathManager, profile string) error { blockDirBase := filepath.Join(pm.RuntimeBaseDir(), "profiles", profile, "block") if err := os.MkdirAll(blockDirBase, 0755); err != nil { @@ -321,8 +346,10 @@ func BlockHostServices(pm *paths.PathManager, profile string) error { return nil } +// HostBind is a placeholder bind implementation for WireGuard. type HostBind struct{} +// NewHostBind creates a new HostBind instance. func NewHostBind(inner conn.Bind, hostNetNSFd int) *HostBind { return &HostBind{} } @@ -337,11 +364,14 @@ func (h *HostBind) Send(bufs [][]byte, endpoint conn.Endpoint) error { return ni func (h *HostBind) ParseEndpoint(s string) (conn.Endpoint, error) { return nil, nil } func (h *HostBind) BatchSize() int { return 0 } +// FDBind implements wireguard-go's conn.Bind using an existing file descriptor. +// This allows the tunnel to use a UDP socket opened on the host. type FDBind struct { originalFd int conn *net.UDPConn } +// FDEndpoint implements wireguard-go's conn.Endpoint for file-descriptor based binds. type FDEndpoint struct { addr netip.AddrPort } @@ -354,6 +384,7 @@ func (e *FDEndpoint) SrcIP() netip.Addr { return netip.Addr{} } func (e *FDEndpoint) SrcToString() string { return "" } func (e *FDEndpoint) SrcIfidx() int32 { return 0 } +// NewFDBind creates a new FDBind instance from a raw file descriptor. func NewFDBind(fd int) (*FDBind, error) { return &FDBind{originalFd: fd}, nil } diff --git a/pkg/wgconf/wgconf.go b/pkg/wgconf/wgconf.go index 36434ba..5eac20d 100644 --- a/pkg/wgconf/wgconf.go +++ b/pkg/wgconf/wgconf.go @@ -1,3 +1,11 @@ +// Package wgconf provides functionality for parsing WireGuard .conf files. +// +// Parsing Logic: +// wgconf implements a robust parser for WireGuard configuration files, supporting: +// - Case-insensitive section ([Interface], [Peer]) and key names. +// - Inline and block comments starting with # or ;. +// - Multi-value fields (e.g., AllowedIPs) separated by commas. +// - Flexible whitespace handling around keys and values. package wgconf import ( @@ -9,16 +17,23 @@ import ( // Config represents a parsed WireGuard configuration file. type Config struct { + // PrivateKey is the local interface's private key. PrivateKey string - Address string - DNS string - Peers []Peer + // Address is the local interface's IP address. + Address string + // DNS is the DNS server to be used when the tunnel is active. + DNS string + // Peers is the list of remote peers defined in the configuration. + Peers []Peer } // Peer represents a WireGuard peer. type Peer struct { - PublicKey string - Endpoint string + // PublicKey is the remote peer's public key. + PublicKey string + // Endpoint is the public IP and port of the remote peer. + Endpoint string + // AllowedIPs is the list of IP addresses/networks allowed for this peer. AllowedIPs []string } |
