fix: handle cross-mount resolv.conf symlinks in sandbox (#32)

This commit is contained in:
JY Tan
2026-02-08 15:22:31 -08:00
committed by GitHub
parent b8b12ebe31
commit da5f61e390
2 changed files with 86 additions and 5 deletions

View File

@@ -12,6 +12,7 @@ import (
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"syscall"
"time" "time"
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
@@ -239,6 +240,37 @@ func canMountOver(path string) bool {
return fileExists(path) return fileExists(path)
} }
// sameDevice returns true if both paths reside on the same filesystem (device).
func sameDevice(path1, path2 string) bool {
var s1, s2 syscall.Stat_t
if syscall.Stat(path1, &s1) != nil || syscall.Stat(path2, &s2) != nil {
return true // err on the side of caution
}
return s1.Dev == s2.Dev
}
// intermediaryDirs returns the chain of directories between root and targetDir,
// from shallowest to deepest. Used to create --dir entries so bwrap can set up
// mount points inside otherwise-empty mount-point stubs.
//
// Example: intermediaryDirs("/", "/run/systemd/resolve") ->
//
// ["/run", "/run/systemd", "/run/systemd/resolve"]
func intermediaryDirs(root, targetDir string) []string {
rel, err := filepath.Rel(root, targetDir)
if err != nil {
return []string{targetDir}
}
parts := strings.Split(rel, string(filepath.Separator))
dirs := make([]string, 0, len(parts))
current := root
for _, part := range parts {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}
// getMandatoryDenyPaths returns concrete paths (not globs) that must be protected. // getMandatoryDenyPaths returns concrete paths (not globs) that must be protected.
// This expands the glob patterns from GetMandatoryDenyPatterns into real paths. // This expands the glob patterns from GetMandatoryDenyPatterns into real paths.
func getMandatoryDenyPaths(cwd string) []string { func getMandatoryDenyPaths(cwd string) []string {
@@ -414,13 +446,52 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
// Ensure /etc/resolv.conf is readable inside the sandbox. // Ensure /etc/resolv.conf is readable inside the sandbox.
// On some systems (e.g., WSL), /etc/resolv.conf is a symlink to a path // On some systems (e.g., WSL), /etc/resolv.conf is a symlink to a path
// on a separate mount point (e.g., /mnt/wsl/resolv.conf) that isn't // on a separate mount point (e.g., /mnt/wsl/resolv.conf) that isn't
// reachable after --ro-bind / / (non-recursive bind). We resolve the // reachable after --ro-bind / / (non-recursive bind). When the target
// symlink and bind the real file directly so DNS resolution works. // is on a different filesystem, we create intermediate directories and
// bind the real file at its original location so the symlink resolves.
if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" { if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" {
if fileExists(target) { // Skip targets under specially-mounted dirs — a --tmpfs there would
bwrapArgs = append(bwrapArgs, "--ro-bind", target, "/etc/resolv.conf") // overwrite the --dev-bind or --proc mounts established above.
targetUnderSpecialMount := strings.HasPrefix(target, "/dev/") ||
strings.HasPrefix(target, "/proc/") ||
strings.HasPrefix(target, "/tmp/")
// In defaultDenyRead mode, also skip if the target is under a path
// already individually bound (e.g., /run, /sys) — a --tmpfs would
// overwrite that explicit bind. Targets under unbound paths like
// /mnt/wsl still need the fix.
if defaultDenyRead {
for _, p := range GetDefaultReadablePaths() {
if strings.HasPrefix(target, p+"/") {
targetUnderSpecialMount = true
break
}
}
}
if fileExists(target) && !sameDevice("/", target) && !targetUnderSpecialMount {
// Make the symlink target reachable by creating its parent dirs.
// Walk down from / to the target's parent: skip dirs on the root
// device (they have real content like /mnt/c, /mnt/d on WSL),
// apply --tmpfs at the mount boundary (first dir on a different
// device — an empty mount-point stub safe to replace), then --dir
// for any deeper subdirectories inside the now-writable tmpfs.
targetDir := filepath.Dir(target)
mountBoundaryFound := false
for _, dir := range intermediaryDirs("/", targetDir) {
if !mountBoundaryFound {
if !sameDevice("/", dir) {
bwrapArgs = append(bwrapArgs, "--tmpfs", dir)
mountBoundaryFound = true
}
// skip dirs still on root device
} else {
bwrapArgs = append(bwrapArgs, "--dir", dir)
}
}
if mountBoundaryFound {
bwrapArgs = append(bwrapArgs, "--ro-bind", target, target)
}
if opts.Debug { if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Resolved /etc/resolv.conf symlink -> %s\n", target) fmt.Fprintf(os.Stderr, "[fence:linux] Resolved /etc/resolv.conf symlink -> %s (cross-mount)\n", target)
} }
} }
} }

View File

@@ -71,6 +71,16 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
} }
} }
// If /etc/resolv.conf is a cross-mount symlink (e.g., -> /mnt/wsl/resolv.conf
// on WSL), Landlock needs a read rule for the resolved target's parent dir,
// otherwise following the symlink hits EACCES.
if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" {
targetDir := filepath.Dir(target)
if err := ruleset.AllowRead(targetDir); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add resolv.conf target dir %s: %v\n", targetDir, err)
}
}
// Current working directory - read access (may be upgraded to write below) // Current working directory - read access (may be upgraded to write below)
if cwd != "" { if cwd != "" {
if err := ruleset.AllowRead(cwd); err != nil && debug { if err := ruleset.AllowRead(cwd); err != nil && debug {