summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go2
-rw-r--r--internal/manager/manager.go68
-rw-r--r--internal/namespace/namespace.go18
-rw-r--r--internal/namespace/ops.go80
-rw-r--r--internal/paths/paths_test.go156
-rw-r--r--internal/wireguard/wireguard.go12
6 files changed, 286 insertions, 50 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 1873e28..9041670 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -32,7 +32,7 @@ func NewApp(args []string) *App {
func (a *App) getManager() *manager.Manager {
if a.mgr == nil {
- a.mgr = manager.New(a.getPathManager())
+ a.mgr = manager.New(a.getPathManager(), namespace.NewLinuxOps())
}
return a.mgr
}
diff --git a/internal/manager/manager.go b/internal/manager/manager.go
index 99a1a32..ffb02c0 100644
--- a/internal/manager/manager.go
+++ b/internal/manager/manager.go
@@ -9,11 +9,11 @@
// 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.
+// 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 (
@@ -30,22 +30,22 @@ import (
"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
+ // NS handles the network namespace operations.
+ NS namespace.Ops
}
-// New creates a new Manager with the given path manager.
-func New(pm *paths.PathManager) *Manager {
- return &Manager{PM: pm}
+// New creates a new Manager with the given path manager and namespace operations.
+func New(pm *paths.PathManager, ns namespace.Ops) *Manager {
+ return &Manager{PM: pm, NS: ns}
}
// 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() {
+ if m.NS.IsIsolated() {
return nil
}
@@ -53,28 +53,28 @@ func (m *Manager) Bootstrap(cfg *config.Config) error {
_ = 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)
+ lockFile, lockErr := m.NS.AcquireProfileLock(m.PM, cfg.Profile)
if lockErr == nil {
- defer namespace.ReleaseProfileLock(lockFile)
+ defer m.NS.ReleaseProfileLock(lockFile)
}
// Before bootstrapping, see if an active namespace/process for the profile exists.
- activePid, err := namespace.FindActiveProfilePid(m.PM, cfg.Profile)
+ activePid, err := m.NS.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)
+ m.NS.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)
+ _ = m.NS.RegisterProcess(m.PM, cfg.Profile)
- if err := namespace.BootstrapJoin(activePid); err != nil {
+ if err := m.NS.BootstrapJoin(activePid); err != nil {
return fmt.Errorf("failed to join existing namespace: %w", err)
}
return nil
}
- if err := namespace.Bootstrap(); err != nil {
+ if err := m.NS.Bootstrap(); err != nil {
return fmt.Errorf("bootstrap failed: %w", err)
}
@@ -84,25 +84,25 @@ func (m *Manager) Bootstrap(cfg *config.Config) error {
// 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() {
+ if !m.NS.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)
+ lockFile, lockErr := m.NS.AcquireProfileLock(m.PM, cfg.Profile)
var lockFileReleased bool
if lockErr == nil {
defer func() {
if !lockFileReleased {
- namespace.ReleaseProfileLock(lockFile)
+ m.NS.ReleaseProfileLock(lockFile)
}
}()
}
- if err := namespace.PruneStalePids(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.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 {
+ if err := m.NS.RegisterProcess(m.PM, cfg.Profile); err != nil {
return fmt.Errorf("failed to register process: %w", err)
}
@@ -113,31 +113,31 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
if lockErr == nil && !lockFileReleased {
cleanupLock = lockFile
} else {
- cleanupLock, cleanupErr = namespace.AcquireProfileLock(m.PM, cfg.Profile)
+ cleanupLock, cleanupErr = m.NS.AcquireProfileLock(m.PM, cfg.Profile)
}
if cleanupErr == nil {
- if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.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 {
+ if err := m.NS.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)
+ last, lastErr := m.NS.IsLastProcess(m.PM, cfg.Profile)
if lastErr == nil && last {
- if err := namespace.UnpinNamespace(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.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)
+ m.NS.ReleaseProfileLock(cleanupLock)
} else {
- if err := namespace.UnregisterProcess(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.UnregisterProcess(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "failed to unregister process: %v\n", err)
}
}
@@ -191,7 +191,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
}
defer tunnel.Close()
- if err := namespace.PinNamespace(m.PM, cfg.Profile); err != nil {
+ if err := m.NS.PinNamespace(m.PM, cfg.Profile); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to pin namespace: %v\n", err)
}
} else {
@@ -203,7 +203,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
}
lockFileReleased = true
- namespace.ReleaseProfileLock(lockFile)
+ m.NS.ReleaseProfileLock(lockFile)
cmd := exec.Command(cfg.Command[0], cfg.Command[1:]...)
cmd.Stdin = os.Stdin
@@ -220,7 +220,7 @@ func (m *Manager) Execute(cfg *config.Config, verbose bool) error {
// 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 {
+ if err := m.NS.UnpinNamespace(m.PM, profile); err != nil {
return fmt.Errorf("failed to stop profile: %w", err)
}
return nil
@@ -228,7 +228,7 @@ func (m *Manager) StopProfile(profile string) error {
// VerifyLifecycle checks for an active session for the given profile.
func (m *Manager) VerifyLifecycle(profile string) error {
- activePid, err := namespace.FindActiveProfilePid(m.PM, profile)
+ activePid, err := m.NS.FindActiveProfilePid(m.PM, profile)
if err != nil || activePid <= 0 {
return fmt.Errorf("no active session found for profile %s", profile)
}
diff --git a/internal/namespace/namespace.go b/internal/namespace/namespace.go
index b05dea2..a50f70a 100644
--- a/internal/namespace/namespace.go
+++ b/internal/namespace/namespace.go
@@ -6,15 +6,15 @@
// 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.
+// 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:
diff --git a/internal/namespace/ops.go b/internal/namespace/ops.go
new file mode 100644
index 0000000..b2b5e10
--- /dev/null
+++ b/internal/namespace/ops.go
@@ -0,0 +1,80 @@
+package namespace
+
+import (
+ "os"
+
+ "git.theodohertyfamily.com/wg-wrap/internal/paths"
+)
+
+// Ops defines the set of operations required by the Manager to handle
+// namespace isolation, lifecycle, and synchronization.
+type Ops interface {
+ IsIsolated() bool
+ Bootstrap() error
+ BootstrapJoin(pid int) error
+ RegisterProcess(pm *paths.PathManager, profile string) error
+ UnregisterProcess(pm *paths.PathManager, profile string) error
+ PruneStalePids(pm *paths.PathManager, profile string) error
+ IsLastProcess(pm *paths.PathManager, profile string) (bool, error)
+ PinNamespace(pm *paths.PathManager, profile string) error
+ UnpinNamespace(pm *paths.PathManager, profile string) error
+ FindActiveProfilePid(pm *paths.PathManager, profile string) (int, error)
+ AcquireProfileLock(pm *paths.PathManager, profile string) (*os.File, error)
+ ReleaseProfileLock(file *os.File)
+}
+
+// linuxOps is the concrete implementation of Ops for Linux systems.
+type linuxOps struct{}
+
+// NewLinuxOps returns a new instance of the Linux-specific namespace operations.
+func NewLinuxOps() Ops {
+ return &linuxOps{}
+}
+
+func (l *linuxOps) IsIsolated() bool {
+ return IsIsolated()
+}
+
+func (l *linuxOps) Bootstrap() error {
+ return Bootstrap()
+}
+
+func (l *linuxOps) BootstrapJoin(pid int) error {
+ return BootstrapJoin(pid)
+}
+
+func (l *linuxOps) RegisterProcess(pm *paths.PathManager, profile string) error {
+ return RegisterProcess(pm, profile)
+}
+
+func (l *linuxOps) UnregisterProcess(pm *paths.PathManager, profile string) error {
+ return UnregisterProcess(pm, profile)
+}
+
+func (l *linuxOps) PruneStalePids(pm *paths.PathManager, profile string) error {
+ return PruneStalePids(pm, profile)
+}
+
+func (l *linuxOps) IsLastProcess(pm *paths.PathManager, profile string) (bool, error) {
+ return IsLastProcess(pm, profile)
+}
+
+func (l *linuxOps) PinNamespace(pm *paths.PathManager, profile string) error {
+ return PinNamespace(pm, profile)
+}
+
+func (l *linuxOps) UnpinNamespace(pm *paths.PathManager, profile string) error {
+ return UnpinNamespace(pm, profile)
+}
+
+func (l *linuxOps) FindActiveProfilePid(pm *paths.PathManager, profile string) (int, error) {
+ return FindActiveProfilePid(pm, profile)
+}
+
+func (l *linuxOps) AcquireProfileLock(pm *paths.PathManager, profile string) (*os.File, error) {
+ return AcquireProfileLock(pm, profile)
+}
+
+func (l *linuxOps) ReleaseProfileLock(file *os.File) {
+ ReleaseProfileLock(file)
+}
diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go
new file mode 100644
index 0000000..caf103d
--- /dev/null
+++ b/internal/paths/paths_test.go
@@ -0,0 +1,156 @@
+package paths
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPathManager_ConfigDir(t *testing.T) {
+ tests := []struct {
+ name string
+ configOverride string
+ envConfigHome string
+ userHome string
+ expected string
+ }{
+ {
+ name: "Use override",
+ configOverride: "/tmp/custom-config",
+ expected: "/tmp/custom-config",
+ },
+ {
+ name: "Use XDG_CONFIG_HOME",
+ envConfigHome: "/tmp/xdg-config",
+ expected: "/tmp/xdg-config/wg-wrap/profiles",
+ },
+ {
+ name: "Fallback to home",
+ userHome: "/home/testuser",
+ expected: "/home/testuser/.config/wg-wrap/profiles",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Backup and restore env
+ oldXdg := os.Getenv("XDG_CONFIG_HOME")
+ defer func() {
+ if err := os.Setenv("XDG_CONFIG_HOME", oldXdg); err != nil {
+ t.Errorf("failed to restore XDG_CONFIG_HOME: %v", err)
+ }
+ }()
+
+ if tt.envConfigHome != "" {
+ if err := os.Setenv("XDG_CONFIG_HOME", tt.envConfigHome); err != nil {
+ t.Fatalf("failed to set XDG_CONFIG_HOME: %v", err)
+ }
+ } else {
+ if err := os.Unsetenv("XDG_CONFIG_HOME"); err != nil {
+ t.Fatalf("failed to unset XDG_CONFIG_HOME: %v", err)
+ }
+ }
+
+ // We can't easily mock os.UserHomeDir() without monkey patching or interfaces
+ // but we can test the other paths.
+ pm := NewPathManager(tt.configOverride, "")
+ got := pm.ConfigDir()
+
+ if tt.userHome == "" && got != tt.expected {
+ t.Errorf("ConfigDir() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestPathManager_RuntimeBaseDir(t *testing.T) {
+ tests := []struct {
+ name string
+ runtimeOverride string
+ envHostRuntime string
+ envXdgRuntime string
+ expected string
+ }{
+ {
+ name: "Use override",
+ runtimeOverride: "/tmp/custom-runtime",
+ expected: "/tmp/custom-runtime",
+ },
+ {
+ name: "Use WG_WRAP_HOST_RUNTIME_BASE_DIR",
+ envHostRuntime: "/tmp/host-runtime",
+ expected: "/tmp/host-runtime",
+ },
+ {
+ name: "Use XDG_RUNTIME_DIR",
+ envXdgRuntime: "/tmp/xdg-runtime",
+ expected: "/tmp/xdg-runtime",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Backup and restore env
+ oldHost := os.Getenv("WG_WRAP_HOST_RUNTIME_BASE_DIR")
+ defer func() {
+ if err := os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", oldHost); err != nil {
+ t.Errorf("failed to restore WG_WRAP_HOST_RUNTIME_BASE_DIR: %v", err)
+ }
+ }()
+ oldXdg := os.Getenv("XDG_RUNTIME_DIR")
+ defer func() {
+ if err := os.Setenv("XDG_RUNTIME_DIR", oldXdg); err != nil {
+ t.Errorf("failed to restore XDG_RUNTIME_DIR: %v", err)
+ }
+ }()
+
+ if tt.envHostRuntime != "" {
+ if err := os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", tt.envHostRuntime); err != nil {
+ t.Fatalf("failed to set WG_WRAP_HOST_RUNTIME_BASE_DIR: %v", err)
+ }
+ } else {
+ if err := os.Unsetenv("WG_WRAP_HOST_RUNTIME_BASE_DIR"); err != nil {
+ t.Fatalf("failed to unset WG_WRAP_HOST_RUNTIME_BASE_DIR: %v", err)
+ }
+ }
+
+ if tt.envXdgRuntime != "" {
+ if err := os.Setenv("XDG_RUNTIME_DIR", tt.envXdgRuntime); err != nil {
+ t.Fatalf("failed to set XDG_RUNTIME_DIR: %v", err)
+ }
+ } else {
+ if err := os.Unsetenv("XDG_RUNTIME_DIR"); err != nil {
+ t.Fatalf("failed to unset XDG_RUNTIME_DIR: %v", err)
+ }
+ }
+
+ pm := NewPathManager("", tt.runtimeOverride)
+ got := pm.RuntimeBaseDir()
+
+ if got != tt.expected {
+ t.Errorf("RuntimeBaseDir() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestPathManager_ProfilePaths(t *testing.T) {
+ pm := NewPathManager("", "/tmp/runtime")
+ profile := "test-profile"
+
+ t.Run("ProfileNamespacePath", func(t *testing.T) {
+ expected := filepath.Join("/tmp/runtime", "profiles", profile+".ns")
+ got := pm.ProfileNamespacePath(profile)
+ if got != expected {
+ t.Errorf("ProfileNamespacePath() = %v, want %v", got, expected)
+ }
+ })
+
+ t.Run("ProfilePidsDir", func(t *testing.T) {
+ expected := filepath.Join("/tmp/runtime", "profiles", profile, "pids")
+ got := pm.ProfilePidsDir(profile)
+ if got != expected {
+ t.Errorf("ProfilePidsDir() = %v, want %v", got, expected)
+ }
+ })
+}
diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go
index cea9590..5db588e 100644
--- a/internal/wireguard/wireguard.go
+++ b/internal/wireguard/wireguard.go
@@ -3,12 +3,12 @@
// 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.
+// 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