summaryrefslogtreecommitdiff
path: root/internal/namespace
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-05-29 18:29:12 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-05-29 18:29:12 -0400
commitee2f5d545825752af63da36e2b9ec7a92985a875 (patch)
tree7328f73ac157dd19fa60e887fd243f0855935cce /internal/namespace
parent135f6edbd9389bc4783f13c26aed0a74d3c8aca0 (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.c8
-rw-r--r--internal/namespace/namespace.go32
-rw-r--r--internal/namespace/namespace_stub.go20
-rw-r--r--internal/namespace/pinning.go97
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
+}