summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go230
-rw-r--r--internal/config/config.go8
-rw-r--r--internal/manager/manager.go238
-rw-r--r--internal/namespace/namespace.go21
-rw-r--r--internal/paths/paths.go14
-rw-r--r--internal/wireguard/wireguard.go33
6 files changed, 338 insertions, 206 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)
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
}