summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-06-13 13:50:25 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-06-13 13:50:25 -0400
commit5646eca119f80f8f45ebec9fcbe666ca614ebf5d (patch)
treea785cb7f30b5a6444e208ae6717a73a758644998
parent29621ecbd1e77e6e1a70b6b3ea8fbe3a56e47df3 (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.go37
-rw-r--r--internal/manager/manager.go4
-rw-r--r--internal/namespace/namespace.go6
-rw-r--r--internal/namespace/ops.go5
-rw-r--r--internal/namespace/preflight.go156
-rw-r--r--internal/namespace/preflight_test.go199
-rw-r--r--internal/wireguard/wireguard_unit_test.go8
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.