diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-05-29 18:29:12 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-05-29 18:29:12 -0400 |
| commit | ee2f5d545825752af63da36e2b9ec7a92985a875 (patch) | |
| tree | 7328f73ac157dd19fa60e887fd243f0855935cce /internal/namespace | |
| parent | 135f6edbd9389bc4783f13c26aed0a74d3c8aca0 (diff) | |
feat: implement userspace wireguard data-path and unprivileged host fd-passing
- Implement complete rootless network namespace bootstrap via C launcher using unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET).
- Resolve unprivileged network isolation blackhole via host-socket preservation (FD passing): open client UDP sockets on the host pre-isolation, clear O_CLOEXEC, and ingest them via custom `FDBind` inside the sandbox.
- Implement isolated routing table automation over `tun0` (addresses, MTU, default routes).
- Implement persistent, multi-process namespace sharing and joining using reference-counted PID files and the setns system call.
- Write robust, self-contained E2E data plane test suites in `tests/e2e/e2e_test.go` using a mock UDP listener.
- Update project documentation (`README.md` and `AGENTS.md`) to reflect completed milestones.
- Ensure 100% test passing rate and zero lint/staticcheck warnings.
Diffstat (limited to 'internal/namespace')
| -rw-r--r-- | internal/namespace/launcher_src/launcher.c | 8 | ||||
| -rw-r--r-- | internal/namespace/namespace.go | 32 | ||||
| -rw-r--r-- | internal/namespace/namespace_stub.go | 20 | ||||
| -rw-r--r-- | internal/namespace/pinning.go | 97 |
4 files changed, 147 insertions, 10 deletions
diff --git a/internal/namespace/launcher_src/launcher.c b/internal/namespace/launcher_src/launcher.c index 4311430..e108da6 100644 --- a/internal/namespace/launcher_src/launcher.c +++ b/internal/namespace/launcher_src/launcher.c @@ -17,9 +17,11 @@ int main(int argc, char **argv) { uid_t current_uid = getuid(); gid_t current_gid = getgid(); - // 2. Combined Unshare for User and Network namespaces - if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == -1) { - perror("unshare(CLONE_NEWUSER | CLONE_NEWNET)"); + // 2. Combined Unshare for User, Mount, and Network namespaces + // We unshare Mount namespace (CLONE_NEWNS) to allow private /etc/resolv.conf setup + // without contaminating the host filesystem. + if (unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET) == -1) { + perror("unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET)"); return 1; } diff --git a/internal/namespace/namespace.go b/internal/namespace/namespace.go index b0794a4..a1e7ad9 100644 --- a/internal/namespace/namespace.go +++ b/internal/namespace/namespace.go @@ -3,9 +3,12 @@ package namespace import ( _ "embed" "fmt" + "net" "os" "os/exec" "syscall" + + "golang.org/x/sys/unix" ) //go:embed launcher.bin @@ -123,7 +126,34 @@ func Bootstrap() error { } } } - err = syscall.Exec(launcherPath, args, os.Environ()) + + // Open the host network namespace file descriptor before unsharing. + hostNetFd, err := syscall.Open("/proc/self/ns/net", syscall.O_RDONLY, 0) + if err != nil { + return fmt.Errorf("failed to open host netns: %w", err) + } + // Clear close-on-exec so it remains open across syscall.Exec + if flags, err := unix.FcntlInt(uintptr(hostNetFd), unix.F_GETFD, 0); err == nil { + _, _ = unix.FcntlInt(uintptr(hostNetFd), unix.F_SETFD, flags&^unix.FD_CLOEXEC) + } + + env := append(os.Environ(), fmt.Sprintf("WG_WRAP_HOST_NETNS_FD=%d", hostNetFd)) + + // Open a host UDP socket on 0.0.0.0:0 before unsharing network namespace. + laddr, err := net.ResolveUDPAddr("udp", "0.0.0.0:0") + if err == nil { + if conn, err := net.ListenUDP("udp", laddr); err == nil { + if file, err := conn.File(); err == nil { + hostSocketFd := file.Fd() + if flags, err := unix.FcntlInt(hostSocketFd, unix.F_GETFD, 0); err == nil { + _, _ = unix.FcntlInt(hostSocketFd, unix.F_SETFD, flags&^unix.FD_CLOEXEC) + } + env = append(env, fmt.Sprintf("WG_WRAP_HOST_SOCKET_FD=%d", hostSocketFd)) + } + } + } + + err = syscall.Exec(launcherPath, args, env) if err != nil { return fmt.Errorf("launcher exec failed: %w", err) } diff --git a/internal/namespace/namespace_stub.go b/internal/namespace/namespace_stub.go index 352ec13..84946bf 100644 --- a/internal/namespace/namespace_stub.go +++ b/internal/namespace/namespace_stub.go @@ -2,4 +2,22 @@ package namespace -// The namespace package provides stubs for non-Linux platforms. +import ( + "fmt" + "git.theodohertyfamily.com/tools/wg-wrap/internal/paths" +) + +// PinNamespace touches the namespace path to indicate it is pinned/active. +func PinNamespace(pm *paths.PathManager, profile string) error { + return fmt.Errorf("namespaces are not supported on this platform") +} + +// UnpinNamespace removes the pinned namespace file from the filesystem. +func UnpinNamespace(pm *paths.PathManager, profile string) error { + return fmt.Errorf("namespaces are not supported on this platform") +} + +// JoinExistingNamespace attempts to join the namespaces (user, mount, net) of an already active process. +func JoinExistingNamespace(pm *paths.PathManager, profile string) (bool, error) { + return false, fmt.Errorf("namespaces are not supported on this platform") +} diff --git a/internal/namespace/pinning.go b/internal/namespace/pinning.go index cd81a38..7976937 100644 --- a/internal/namespace/pinning.go +++ b/internal/namespace/pinning.go @@ -1,26 +1,42 @@ +//go:build linux + package namespace import ( "fmt" "os" + "path/filepath" + "strconv" + "syscall" "git.theodohertyfamily.com/tools/wg-wrap/internal/paths" + "golang.org/x/sys/unix" ) +// PinNamespace touches the namespace path to indicate it is pinned/active. +func PinNamespace(pm *paths.PathManager, profile string) error { + nsPath := GetProfileNamespacePath(pm, profile) + profilesDir := filepath.Dir(nsPath) + if err := os.MkdirAll(profilesDir, 0755); err != nil { + return fmt.Errorf("failed to create profiles directory: %w", err) + } + + // We write a placeholder file to indicate the profile namespace is pinned. + if err := os.WriteFile(nsPath, []byte("active"), 0644); err != nil { + return fmt.Errorf("failed to create namespace pin file: %w", err) + } + return nil +} + // UnpinNamespace removes the pinned namespace file from the filesystem. // This allows the namespace to be destroyed once the last process exits. func UnpinNamespace(pm *paths.PathManager, profile string) error { nsPath := GetProfileNamespacePath(pm, profile) - // We only want to unpin if there are no more active processes. - // The caller (cli.ExecuteCommand) is responsible for calling this - // when IsLastProcess returns true. - if _, err := os.Stat(nsPath); os.IsNotExist(err) { return nil } - // We also want to remove the pids directory if it's empty. pidsDir := GetPidsDirPath(pm, profile) // Unlink the namespace file @@ -33,3 +49,74 @@ func UnpinNamespace(pm *paths.PathManager, profile string) error { return nil } + +// JoinExistingNamespace attempts to join the namespaces (user, mount, net) +// of an already active process running under the same profile. +// Returns true if a namespace was successfully joined, false if no active namespace exists. +func JoinExistingNamespace(pm *paths.PathManager, profile string) (bool, error) { + if err := PruneStalePids(pm, profile); err != nil { + return false, fmt.Errorf("failed to prune stale pids: %w", err) + } + + pidsDir := GetPidsDirPath(pm, profile) + files, err := os.ReadDir(pidsDir) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to read pids dir: %w", err) + } + + var activePid int + for _, file := range files { + pid, err := strconv.Atoi(file.Name()) + if err != nil { + continue + } + // Since we already pruned stale pids, the first file we find is an active pid! + activePid = pid + break + } + + if activePid == 0 { + return false, nil + } + + // Join User Namespace first + userNsPath := fmt.Sprintf("/proc/%d/ns/user", activePid) + userFd, err := os.Open(userNsPath) + if err != nil { + return false, fmt.Errorf("failed to open user namespace: %w", err) + } + defer func() { _ = userFd.Close() }() + + if err := unix.Setns(int(userFd.Fd()), syscall.CLONE_NEWUSER); err != nil { + return false, fmt.Errorf("failed to join user namespace: %w", err) + } + + // Join Mount Namespace + mntNsPath := fmt.Sprintf("/proc/%d/ns/mnt", activePid) + mntFd, err := os.Open(mntNsPath) + if err != nil { + return false, fmt.Errorf("failed to open mount namespace: %w", err) + } + defer func() { _ = mntFd.Close() }() + + if err := unix.Setns(int(mntFd.Fd()), syscall.CLONE_NEWNS); err != nil { + return false, fmt.Errorf("failed to join mount namespace: %w", err) + } + + // Join Network Namespace + netNsPath := fmt.Sprintf("/proc/%d/ns/net", activePid) + netFd, err := os.Open(netNsPath) + if err != nil { + return false, fmt.Errorf("failed to open network namespace: %w", err) + } + defer func() { _ = netFd.Close() }() + + if err := unix.Setns(int(netFd.Fd()), syscall.CLONE_NEWNET); err != nil { + return false, fmt.Errorf("failed to join network namespace: %w", err) + } + + return true, nil +} |
