diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-06-13 13:50:25 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-06-13 13:50:25 -0400 |
| commit | 5646eca119f80f8f45ebec9fcbe666ca614ebf5d (patch) | |
| tree | a785cb7f30b5a6444e208ae6717a73a758644998 | |
| parent | 29621ecbd1e77e6e1a70b6b3ea8fbe3a56e47df3 (diff) | |
feat: implement system preflight checks and health diagnostics
Introduced a tiered system verification mechanism to improve reliability
and provide actionable feedback to users, avoiding false positives in
the critical execution path.
Key changes:
- Implement `CheckSystemRequirements` for critical, non-ambiguous
requirements (e.g., TUN device availability) to ensure fatal
environment issues are caught immediately during bootstrap.
- Implement a user-facing `healthcheck` command that provides
comprehensive diagnostics and actionable configuration hints for
common misconfigurations (e.g., unprivileged user namespaces,
subuid/subgid mappings, and kernel sysctls).
- Refactor the `FileSystem` interface to support full mockability,
allowing for exhaustive unit testing of diagnostic logic.
- Add comprehensive unit tests in `internal/namespace/preflight_test.go`
covering various Linux distributions, privilege levels, and
hardware availability scenarios.
- Ensure code quality through formatting, static analysis (golangci-lint),
and validation of all existing unit, integration, and E2E tests.
| -rw-r--r-- | internal/cli/cli.go | 37 | ||||
| -rw-r--r-- | internal/manager/manager.go | 4 | ||||
| -rw-r--r-- | internal/namespace/namespace.go | 6 | ||||
| -rw-r--r-- | internal/namespace/ops.go | 5 | ||||
| -rw-r--r-- | internal/namespace/preflight.go | 156 | ||||
| -rw-r--r-- | internal/namespace/preflight_test.go | 199 | ||||
| -rw-r--r-- | internal/wireguard/wireguard_unit_test.go | 8 |
7 files changed, 413 insertions, 2 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b38d0d9..ac8c206 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -71,6 +71,8 @@ func (a *App) Route() error { return a.executeWrapped(a.Args[2:]) case "test-ns": return a.testNS() + case "healthcheck": + return a.handleHealthCheck() case "test-args": return a.testArgs() case "test-lifecycle": @@ -139,6 +141,38 @@ func (a *App) executeWrapped(args []string) error { return nil } +func (a *App) handleHealthCheck() error { + fmt.Println("Performing system health check for rootless network namespaces...") + fmt.Println() + + results := namespace.RunHealthCheck() + allPassed := true + + for _, res := range results { + status := "✅ PASSED" + if !res.Passed { + status = "❌ FAILED" + allPassed = false + } + + fmt.Printf("%-40s %s\n", res.Name, status) + if !res.Passed { + fmt.Printf(" Reason: %s\n", res.Message) + fmt.Printf(" Hint: %s\n", res.Hint) + } else if res.Message != "" { + fmt.Printf(" Info: %s\n", res.Message) + } + fmt.Println() + } + + if allPassed { + fmt.Println("Overall Status: System is healthy and ready for wg-wrap.") + return nil + } + + return fmt.Errorf("some system requirements were not met; please follow the hints above") +} + func (a *App) testNS() error { if !namespace.IsIsolated() { if err := namespace.Bootstrap(); err != nil { @@ -183,7 +217,8 @@ func (a *App) printUsage() { fmt.Fprintf(os.Stderr, " run [options] [-- command] \tRun a command in the wrapped environment\n") fmt.Fprintf(os.Stderr, " exec [options] [-- command] \tAlias for 'run'\n") fmt.Fprintf(os.Stderr, " profile <command> \t\tManage WireGuard profiles (list, import, configure, delete, stop)\n") - fmt.Fprintf(os.Stderr, " show-config \t\t\tDisplay the current configuration and environment details\n\n") + fmt.Fprintf(os.Stderr, " show-config \t\t\tDisplay the current configuration and environment details\n") + fmt.Fprintf(os.Stderr, " healthcheck \t\t\tCheck system compatibility and configuration hints\n\n") fmt.Fprintf(os.Stderr, "Run Options:\n") fmt.Fprintf(os.Stderr, " -profile string \t\tWireGuard profile to use (default \"default\")\n") fmt.Fprintf(os.Stderr, " -dns-server string \tOverride DNS server to use\n\n") diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 270b99e..29bd229 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -49,6 +49,10 @@ func (m *Manager) Bootstrap(cfg *config.Config) error { return nil } + if err := m.NS.CheckSystemRequirements(); err != nil { + return fmt.Errorf("system check failed: %w", err) + } + // Preserve the host runtime base dir in the environment before bootstrapping. _ = os.Setenv("WG_WRAP_HOST_RUNTIME_BASE_DIR", m.PM.RuntimeBaseDir()) diff --git a/internal/namespace/namespace.go b/internal/namespace/namespace.go index 368775f..3f27faf 100644 --- a/internal/namespace/namespace.go +++ b/internal/namespace/namespace.go @@ -58,6 +58,8 @@ type FileSystem interface { CreateTemp(dir, pattern string) (*os.File, error) MkdirTemp(dir, pattern string) (string, error) Remove(name string) error + ReadFile(name string) ([]byte, error) + Open(name string) (*os.File, error) } // realFS is the production implementation using the os package. @@ -73,7 +75,9 @@ func (r *realFS) CreateTemp(dir, pattern string) (*os.File, error) { func (r *realFS) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) } -func (r *realFS) Remove(name string) error { return os.Remove(name) } +func (r *realFS) Remove(name string) error { return os.Remove(name) } +func (r *realFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) } +func (r *realFS) Open(name string) (*os.File, error) { return os.Open(name) } // DefaultFS is the global instance used by the package functions. var DefaultFS FileSystem = &realFS{} diff --git a/internal/namespace/ops.go b/internal/namespace/ops.go index b2b5e10..76f696c 100644 --- a/internal/namespace/ops.go +++ b/internal/namespace/ops.go @@ -9,6 +9,7 @@ import ( // Ops defines the set of operations required by the Manager to handle // namespace isolation, lifecycle, and synchronization. type Ops interface { + CheckSystemRequirements() error IsIsolated() bool Bootstrap() error BootstrapJoin(pid int) error @@ -31,6 +32,10 @@ func NewLinuxOps() Ops { return &linuxOps{} } +func (l *linuxOps) CheckSystemRequirements() error { + return CheckSystemRequirements() +} + func (l *linuxOps) IsIsolated() bool { return IsIsolated() } diff --git a/internal/namespace/preflight.go b/internal/namespace/preflight.go new file mode 100644 index 0000000..2db44ab --- /dev/null +++ b/internal/namespace/preflight.go @@ -0,0 +1,156 @@ +package namespace + +import ( + "bufio" + "fmt" + "os" + "os/user" + "strings" +) + +// Requirement defines a system check and the hint to provide if it fails. +type Requirement struct { + Name string + Check func() (bool, string) + Hint string +} + +// Preflight handles system requirement checks and health diagnostics. +type Preflight struct { + FS FileSystem +} + +// NewPreflight creates a new preflight checker with the provided filesystem. +func NewPreflight(fs FileSystem) *Preflight { + return &Preflight{FS: fs} +} + +// CheckSystemRequirements verifies the absolute minimum requirements +// that must be met for wg-wrap to function. +func (p *Preflight) CheckSystemRequirements() error { + // The only absolute hard requirement that isn't better caught by a syscall + // is the existence and accessibility of the TUN device. + if _, err := p.FS.Stat("/dev/net/tun"); os.IsNotExist(err) { + return fmt.Errorf("critical requirement missing: /dev/net/tun not found. Please ensure the tun module is loaded (sudo modprobe tun)") + } + + f, err := p.FS.Open("/dev/net/tun") + if err != nil { + return fmt.Errorf("critical requirement missing: /dev/net/tun is not accessible: %v", err) + } + _ = f.Close() + + return nil +} + +// CheckSystemRequirements is the global wrapper for the preflight check. +func CheckSystemRequirements() error { + return (&Preflight{FS: DefaultFS}).CheckSystemRequirements() +} + +// RunHealthCheck performs a comprehensive set of diagnostics to help users +// configure their system for rootless network namespaces. +func (p *Preflight) RunHealthCheck(uid int, username string) []HealthResult { + isRoot := uid == 0 + + checks := []Requirement{ + { + Name: "TUN Device Accessibility", + Check: func() (bool, string) { + if _, err := p.FS.Stat("/dev/net/tun"); os.IsNotExist(err) { + return false, "/dev/net/tun does not exist" + } + f, err := p.FS.Open("/dev/net/tun") + if err != nil { + return false, fmt.Sprintf("cannot open /dev/net/tun: %v", err) + } + _ = f.Close() + return true, "" + }, + Hint: "Ensure the TUN module is loaded (sudo modprobe tun) and that your user has permissions to access /dev/net/tun", + }, + { + Name: "Unprivileged User Namespaces (Debian/Ubuntu)", + Check: func() (bool, string) { + data, err := p.FS.ReadFile("/proc/sys/kernel/unprivileged_userns_clone") + if err != nil { + return true, "Not applicable (file not found)" + } + if strings.TrimSpace(string(data)) == "0" { + return false, "unprivileged_userns_clone is disabled" + } + return true, "" + }, + Hint: "Enable unprivileged user namespaces by running: sudo sysctl -w kernel.unprivileged_userns_clone=1", + }, + { + Name: "User Namespace Limit", + Check: func() (bool, string) { + data, err := p.FS.ReadFile("/proc/sys/user/max_user_namespaces") + if err != nil { + return true, "Not applicable (file not found)" + } + if strings.TrimSpace(string(data)) == "0" { + return false, "max_user_namespaces is set to 0" + } + return true, "" + }, + Hint: "Increase the user namespace limit: sudo sysctl -w user.max_user_namespaces=1024", + }, + { + Name: "SubUID/SubGID Mappings", + Check: func() (bool, string) { + if isRoot { + return true, "Bypassed (Running as root)" + } + if username == "" { + return false, "Could not determine current username" + } + data, err := p.FS.ReadFile("/etc/subuid") + if err != nil { + return false, fmt.Sprintf("failed to read /etc/subuid: %v", err) + } + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, username+":") { + return true, "" + } + } + return false, fmt.Sprintf("no subuid mapping found for user %s", username) + }, + Hint: "Configure subuid and subgid for your user. Example: sudo usermod --add-subuids 100000-60000 --add-subgids 100000-60000 $USER", + }, + } + + var results []HealthResult + for _, req := range checks { + ok, msg := req.Check() + results = append(results, HealthResult{ + Name: req.Name, + Passed: ok, + Message: msg, + Hint: req.Hint, + }) + } + return results +} + +// RunHealthCheck is the global wrapper for the health check. +func RunHealthCheck() []HealthResult { + u, _ := user.Current() + username := "" + if u != nil { + username = u.Username + } + return (&Preflight{FS: DefaultFS}).RunHealthCheck(os.Getuid(), username) +} + +// HealthResult describes the outcome of a single health check. +type HealthResult struct { + Name string + Passed bool + Message string + Hint string +} diff --git a/internal/namespace/preflight_test.go b/internal/namespace/preflight_test.go new file mode 100644 index 0000000..49790f2 --- /dev/null +++ b/internal/namespace/preflight_test.go @@ -0,0 +1,199 @@ +package namespace + +import ( + "os" + "testing" + "time" +) + +type mockFileInfo struct { + name string +} + +func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Size() int64 { return 0 } +func (m *mockFileInfo) Mode() os.FileMode { return 0 } +func (m *mockFileInfo) ModTime() time.Time { return time.Now() } +func (m *mockFileInfo) IsDir() bool { return false } +func (m *mockFileInfo) Sys() any { return nil } + +type mockFS struct { + files map[string][]byte + canOpen map[string]bool +} + +func (m *mockFS) Stat(name string) (os.FileInfo, error) { + if _, ok := m.files[name]; ok { + return &mockFileInfo{name: name}, nil + } + return nil, os.ErrNotExist +} + +func (m *mockFS) MkdirAll(path string, perm os.FileMode) error { return nil } +func (m *mockFS) CreateTemp(dir, pattern string) (*os.File, error) { return nil, nil } +func (m *mockFS) MkdirTemp(dir, pattern string) (string, error) { return "", nil } +func (m *mockFS) Remove(name string) error { return nil } + +func (m *mockFS) ReadFile(name string) ([]byte, error) { + if data, ok := m.files[name]; ok { + return data, nil + } + return nil, os.ErrNotExist +} + +func (m *mockFS) Open(name string) (*os.File, error) { + if m.canOpen[name] { + f, _ := os.CreateTemp("", "mock-file") + return f, nil + } + return nil, os.ErrPermission +} + +func TestCheckSystemRequirements(t *testing.T) { + tests := []struct { + name string + files map[string][]byte + canOpen map[string]bool + wantErr bool + }{ + { + name: "all requirements met", + files: map[string][]byte{ + "/dev/net/tun": {}, + }, + canOpen: map[string]bool{ + "/dev/net/tun": true, + }, + wantErr: false, + }, + { + name: "tun device missing", + files: map[string][]byte{}, + canOpen: map[string]bool{}, + wantErr: true, + }, + { + name: "tun device not accessible", + files: map[string][]byte{ + "/dev/net/tun": {}, + }, + canOpen: map[string]bool{ + "/dev/net/tun": false, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := &mockFS{files: tt.files, canOpen: tt.canOpen} + p := NewPreflight(fs) + err := p.CheckSystemRequirements() + if (err != nil) != tt.wantErr { + t.Errorf("CheckSystemRequirements() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRunHealthCheck(t *testing.T) { + tests := []struct { + name string + files map[string][]byte + canOpen map[string]bool + uid int + username string + wantPass map[string]bool + }{ + { + name: "healthy non-root user on Debian", + files: map[string][]byte{ + "/dev/net/tun": {}, + "/proc/sys/kernel/unprivileged_userns_clone": []byte("1"), + "/proc/sys/user/max_user_namespaces": []byte("1024"), + "/etc/subuid": []byte("james:100000:60000"), + }, + canOpen: map[string]bool{ + "/dev/net/tun": true, + }, + uid: 1000, + username: "james", + wantPass: map[string]bool{ + "TUN Device Accessibility": true, + "Unprivileged User Namespaces (Debian/Ubuntu)": true, + "User Namespace Limit": true, + "SubUID/SubGID Mappings": true, + }, + }, + { + name: "healthy root user", + files: map[string][]byte{ + "/dev/net/tun": {}, + }, + canOpen: map[string]bool{ + "/dev/net/tun": true, + }, + uid: 0, + username: "root", + wantPass: map[string]bool{ + "TUN Device Accessibility": true, + "SubUID/SubGID Mappings": true, // Should be bypassed + }, + }, + { + name: "broken config", + files: map[string][]byte{ + "/dev/net/tun": {}, + "/proc/sys/kernel/unprivileged_userns_clone": []byte("0"), + "/proc/sys/user/max_user_namespaces": []byte("0"), + "/etc/subuid": []byte("someone-else:100000:60000"), + }, + canOpen: map[string]bool{ + "/dev/net/tun": true, + }, + uid: 1000, + username: "james", + wantPass: map[string]bool{ + "TUN Device Accessibility": true, + "Unprivileged User Namespaces (Debian/Ubuntu)": false, + "User Namespace Limit": false, + "SubUID/SubGID Mappings": false, + }, + }, + { + name: "non-debian healthy", + files: map[string][]byte{ + "/dev/net/tun": {}, + "/proc/sys/user/max_user_namespaces": []byte("1024"), + "/etc/subuid": []byte("james:100000:60000"), + }, + canOpen: map[string]bool{ + "/dev/net/tun": true, + }, + uid: 1000, + username: "james", + wantPass: map[string]bool{ + "TUN Device Accessibility": true, + "Unprivileged User Namespaces (Debian/Ubuntu)": true, // Not applicable is considered passed + "User Namespace Limit": true, + "SubUID/SubGID Mappings": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := &mockFS{files: tt.files, canOpen: tt.canOpen} + p := NewPreflight(fs) + results := p.RunHealthCheck(tt.uid, tt.username) + + for _, res := range results { + if pass, ok := tt.wantPass[res.Name]; ok { + if res.Passed != pass { + t.Errorf("Requirement %s: passed = %v, want %v (Msg: %s)", res.Name, res.Passed, pass, res.Message) + } + } + } + }) + } +} diff --git a/internal/wireguard/wireguard_unit_test.go b/internal/wireguard/wireguard_unit_test.go index 1ad7f65..9fbe6b0 100644 --- a/internal/wireguard/wireguard_unit_test.go +++ b/internal/wireguard/wireguard_unit_test.go @@ -71,6 +71,14 @@ func (m *mockFS) MkdirTemp(dir, pattern string) (string, error) { return res, nil } +func (m *mockFS) ReadFile(name string) ([]byte, error) { + return os.ReadFile(m.fullPath(name)) +} + +func (m *mockFS) Open(name string) (*os.File, error) { + return os.Open(m.fullPath(name)) +} + func (m *mockFS) Remove(name string) error { // If the path is absolute and starts with our root, we can remove it directly. // Otherwise, we use fullPath to ensure it's within root. |
