//go:build linux package utils import ( "context" "fmt" "os" "os/exec" "strings" "syscall" "unicode/utf8" "go.keploy.io/server/v3/pkg/platform/engine" keployLog "go.keploy.io/server/v3/utils/log" "go.uber.org/zap" ) // execProcess is syscall.Exec, which ReexecWithSudo replaces this process // with sudo through; a test swaps in one that looks at what the process leaves // behind at that moment. var execProcess = syscall.Exec // sudoVersionOf is SudoVersion; a test swaps in what a given sudo prints. var sudoVersionOf = SudoVersion // ReexecWithSudo re-executes the current keploy command with sudo. // This is used when a Docker/Docker Compose command is detected and keploy is not running as root. // Uses syscall.Exec to replace the current process entirely - no parent process remains. // The re-executed keploy gets this process's environment through sudo's // --preserve-env list (sudoReexecArgs), and a sudo that would not keep it is // refused before anything runs (sudoReexecRefusal). // This function never returns on success. On a failure it says why and returns // the error, for the caller to end the run with exit code 1 through its own // return: an os.Exit here skipped every deferred call of the run, the removal // of its temp log among them. func ReexecWithSudo(logger *zap.Logger) error { // Find sudo binary sudoPath, err := exec.LookPath("sudo") if err != nil { logger.Error("sudo not found in PATH, cannot re-execute with elevated privileges", zap.Error(err)) fmt.Println("Error: sudo is required to run Docker commands. Please install sudo or run as root.") return fmt.Errorf("find sudo: %w", err) } // Find keploy binary - use the current executable keployPath, err := os.Executable() if err != nil { logger.Error("failed to get current executable path", zap.Error(err)) return fmt.Errorf("find this executable: %w", err) } if gap, ok := SudoPreserveEnvListGap(sudoVersionOf(context.Background())); ok { err := sudoReexecRefusal(gap) logger.Error("cannot re-execute with sudo", zap.Error(err)) return err } args, unkept := sudoReexecArgs(os.Environ(), os.Getenv("PATH"), keployPath, os.Args[1:]) if len(unkept) > 0 { logger.Warn("these environment variables do not reach the re-executed keploy: sudo's --preserve-env list cannot name a variable whose name has a comma or is not UTF-8", zap.Strings("variables", unkept)) } logger.Info("Re-executing with sudo for elevated privileges as Linux Docker/cloud replay path needs root-level eBPF privileges.") logger.Debug("Re-exec command", zap.Strings("args", args)) // The run goes on in the process the exec starts, which creates a log of // its own; this one's, a temp file, would be left with no run to remove // it. Its name goes now; the file itself, open close-on-exec as Go opens // every file, goes at the exec, or with this process if the exec fails. if err := keployLog.RemoveLogFile(LogFile); err != nil { logger.Debug("failed to remove this process's log before re-executing", zap.Error(err)) } // Use syscall.Exec to replace the current process // This means no parent process remains - clean handoff err = execProcess(sudoPath, args, os.Environ()) if err != nil { // syscall.Exec only returns on error logger.Error("failed to re-execute with sudo", zap.Error(err)) fmt.Printf("Error: failed to re-execute with sudo: %v\n", err) return fmt.Errorf("re-execute with sudo: %w", err) } return nil } // sudoReexecArgs is the command ReexecWithSudo execs: from this process's // environment, its PATH, its executable and its arguments, // // sudo --preserve-env=NAME,… env PATH= // // sudo resets the environment of its command. The re-executed keploy, and the // app and compose it runs, need the user's: a compose file's ${VAR}, the // app's settings, keploy's own. `sudo -E` asked for all of it, and sudo-rs // (Ubuntu 26.04's sudo) ignores -E ("preserving the entire environment // is not supported"). Both sudo and sudo-rs keep each variable a // --preserve-env list names wherever the sudoers rule lets the user set // variables (SETENV, which a rule for ALL implies), and refuse the whole // command loudly where it does not. So the list names every variable of the // environment, and their values stay in the environment, off the command line // that every user on the host can read. // // Left out of the list, so that both sudos give what `sudo -E` gave: // - PATH: `env` sets it after sudo, where a secure_path would replace it; // - USER, LOGNAME, USERNAME and every SUDO_* variable: sudo sets them, to // root and to the user who ran sudo; // - a name with a comma, which the list cannot carry, and a name that is not // UTF-8, for which sudo-rs refuses the whole command line: returned in // unkept, for the caller to say so; // - a name met before, and an entry with no name. func sudoReexecArgs(environ []string, path, keploy string, args []string) (argv, unkept []string) { var names []string seen := map[string]bool{} for _, kv := range environ { name, _, _ := strings.Cut(kv, "=") if name == "" || seen[name] { continue } seen[name] = true switch { case name == "PATH", name == "USER", name == "LOGNAME", name == "USERNAME", strings.HasPrefix(name, "SUDO_"): case strings.Contains(name, ","), !utf8.ValidString(name): unkept = append(unkept, name) default: names = append(names, name) } } argv = []string{"sudo"} if len(names) > 0 { argv = append(argv, "--preserve-env="+strings.Join(names, ",")) } argv = append(argv, "env", "PATH="+path, keploy) return append(argv, args...), unkept } // sudoReexecRefusal is why ReexecWithSudo does not re-execute keploy through // the sudo gap names, which would not keep the user's environment // (SudoPreserveEnvListGap), and what the user can do instead. func sudoReexecRefusal(gap SudoListGap) error { if gap.Ignored { return fmt.Errorf("keploy re-runs itself as root through sudo for this command, and names every variable of your environment for sudo to keep (--preserve-env=NAME,…). %s ignores that list (sudo-rs implements it from %s on), so the run would go on without your environment. Upgrade sudo-rs to %s or later, or run keploy as root yourself, with the variables the run needs: sudo env VAR=value … keploy …", gap, gap.SinceString(), gap.SinceString()) } return fmt.Errorf("keploy re-runs itself as root through sudo for this command, and names every variable of your environment for sudo to keep (--preserve-env=NAME,…). %s does not accept that list (sudo accepts it from %s on). Upgrade sudo to %s or later, or run keploy as root yourself: sudo -E keploy … keeps your environment on this sudo", gap, gap.SinceString(), gap.SinceString()) } // ShouldReexecWithSudo checks if keploy should re-execute itself with sudo. // Returns true if: // 1. A Docker/Docker Compose command is detected in the -c/--command flag, OR // 2. The "cloud replay" subcommand is being used (it generates a docker-compose internally) // AND Keploy is NOT currently running as root. func ShouldReexecWithSudo() bool { // Already running as root - no need to re-exec if os.Geteuid() == 0 { return false } // Check if this is a "cloud replay" command, which always needs root // because it generates and runs a docker-compose file internally. if isCloudReplayCmd(os.Args) { return true } // An explicit --cmd-type wins over sniffing the command string, and has // to be read straight out of os.Args: this runs before cobra parses // anything and long before any config file is read. Without it, // `--cmd-type docker-compose -c "make up"` would take the native path // here, start unprivileged, and then fail deep inside the docker branch // trying to write /proc/sys/kernel/perf_event_paranoid (#4399). // --from-container runs the app as a container, so it needs root for the // same reason every other docker kind does. It is checked before --cmd-type // because it does not require one: the flag alone settles the kind. if ExtractFromContainerFromArgs(os.Args) != "" { return true } // Extract the command from arguments cmd := ExtractCommandFromArgs(os.Args) // A container engine this build cannot drive is refused by ValidateFlags // with a message saying so. Asking for a sudo password first, only to be // refused after it, would be the worse experience. if cmd != "" && !engine.Supported(engine.Detect(cmd)) { return false } if explicit := ExtractCmdTypeFromArgs(os.Args); explicit != "" { switch kind := CmdType(explicit); kind { case Native, DockerRun, DockerStart, DockerCompose, FromContainer: return needsRoot(kind) } // Anything else is rejected later by ValidateFlags with a proper // message; fall through to sniffing rather than guessing here. } if cmd == "" { return false } // Check if it's a Docker command return needsRoot(FindDockerCmd(cmd)) } // needsRoot reports whether a command of this kind re-executes keploy under // sudo: the container kinds, except docker-start, which ValidateFlags refuses // (it cannot work) and which must not ask for a password first. func needsRoot(kind CmdType) bool { return IsDockerCmd(kind) && kind != DockerStart } // isCloudReplayCmd checks if the args represent the "keploy cloud replay" subcommand. func isCloudReplayCmd(args []string) bool { // It is tolerant of flags that may appear before or between subcommands, // e.g. `keploy --debug cloud replay` or `keploy cloud --debug replay`. if len(args) < 3 { return false } // Skip args[0] (program name) and search for "cloud" followed by "replay". for i := 1; i < len(args); i++ { if args[i] != "cloud" { continue } // Look ahead for "replay", allowing flags and their separate values between // "cloud" and "replay" (e.g. `cloud --config /tmp/cfg replay`). for j := i + 1; j < len(args); j++ { token := args[j] if token == "replay" { return true } // End-of-options marker; next token must be the subcommand. if token == "--" { if j+1 < len(args) && args[j+1] == "replay" { return true } break } // If this is not a flag, this isn't "cloud replay". if !strings.HasPrefix(token, "-") { break } // Flag with inline value (`--config=/tmp/cfg`) is fully consumed. if strings.Contains(token, "=") { continue } // Heuristically treat the next non-flag token as a flag value and skip it. // If the next token is "replay", keep it as the subcommand candidate. if j+1 < len(args) && args[j+1] != "replay" && !strings.HasPrefix(args[j+1], "-") { j++ } } } return false }