diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go index c9681b42c7d..4fa4c54dea0 100644 --- a/internal/devbox/packages.go +++ b/internal/devbox/packages.go @@ -469,7 +469,7 @@ func (d *Devbox) installNixPackagesToStore(ctx context.Context, mode installMode } // Other errors indicate we couldn't update nix.conf, so just warn and continue // by building from source if necessary. - ux.Fwarning(d.stderr, "Devbox was unable to configure Nix to use your organization's private cache. Some packages might be built from source.") + ux.Fwarning(d.stderr, "Devbox was unable to configure Nix to use your organization's private cache. Some packages might be built from source.\n") } } diff --git a/internal/devbox/providers/nixcache/nixcache.go b/internal/devbox/providers/nixcache/nixcache.go index 1e7d4b7025e..1e6ec9b717d 100644 --- a/internal/devbox/providers/nixcache/nixcache.go +++ b/internal/devbox/providers/nixcache/nixcache.go @@ -2,15 +2,12 @@ package nixcache import ( "context" - "errors" - "os" "time" "go.jetpack.io/devbox/internal/build" "go.jetpack.io/devbox/internal/cachehash" "go.jetpack.io/devbox/internal/devbox/providers/identity" "go.jetpack.io/devbox/internal/redact" - "go.jetpack.io/devbox/internal/setup" "go.jetpack.io/pkg/api" nixv1alpha1 "go.jetpack.io/pkg/api/gen/priv/nix/v1alpha1" "go.jetpack.io/pkg/auth/session" @@ -25,54 +22,6 @@ func Get() *Provider { return singleton } -func (p *Provider) Configure(ctx context.Context, username string) error { - return p.configure(ctx, username, false) -} - -func (p *Provider) ConfigureReprompt(ctx context.Context, username string) error { - return p.configure(ctx, username, true) -} - -func (p *Provider) configure(ctx context.Context, username string, reprompt bool) error { - setupTasks := []struct { - key string - task setup.Task - }{ - {"nixcache-setup-aws", &awsSetupTask{username}}, - {"nixcache-setup-nix", &nixSetupTask{username}}, - } - if reprompt { - for _, t := range setupTasks { - setup.Reset(t.key) - } - } - - // If we're already root, then do the setup without prompting the user - // for confirmation. - if os.Getuid() == 0 { - for _, t := range setupTasks { - err := setup.Run(ctx, t.key, t.task) - if err != nil { - return redact.Errorf("nixcache: run setup: %v", err) - } - } - return nil - } - - // Otherwise, ask the user to confirm if it's okay to sudo. - const sudoPrompt = "Devbox requires root to configure the Nix daemon to use your organization's Devbox cache. Allow sudo?" - for _, t := range setupTasks { - err := setup.ConfirmRun(ctx, t.key, t.task, sudoPrompt) - if errors.Is(err, setup.ErrUserRefused) { - return nil - } - if err != nil { - return redact.Errorf("nixcache: run setup: %v", err) - } - } - return nil -} - // Credentials fetches short-lived credentials that grant access to the user's // private cache. func (p *Provider) Credentials(ctx context.Context) (AWSCredentials, error) { diff --git a/internal/devbox/providers/nixcache/setup.go b/internal/devbox/providers/nixcache/setup.go index 19a396ae8e7..021c55360b4 100644 --- a/internal/devbox/providers/nixcache/setup.go +++ b/internal/devbox/providers/nixcache/setup.go @@ -9,83 +9,96 @@ import ( "os/exec" "os/user" "path/filepath" + "strings" "time" + "unicode" "go.jetpack.io/devbox/internal/debug" "go.jetpack.io/devbox/internal/envir" "go.jetpack.io/devbox/internal/nix" "go.jetpack.io/devbox/internal/redact" "go.jetpack.io/devbox/internal/setup" - "go.jetpack.io/devbox/internal/xdg" ) -// nixSetupTask adds the user to Nix's trusted-users list so that they can use -// their private Devbox cache with the Nix daemon. -type nixSetupTask struct { - // username is the OS username to trust. - username string +func (p *Provider) Configure(ctx context.Context, username string) error { + return p.configure(ctx, username, false) } -func (n *nixSetupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool { - cfg, err := nix.CurrentConfig(ctx) - if err != nil { - return true - } - trusted, _ := cfg.IsUserTrusted(ctx, n.username) - if trusted { - debug.Log("nixcache: skipping setup task nixcache-setup-nix: user %s is already trusted", n.username) - return false - } - - if _, err := nix.DaemonVersion(ctx); err != nil { - // This looks like a single-user install, so no need to - // configure the daemon. - debug.Log("nixcache: skipping setup task nixcache-setup-nix: error connecting to nix daemon, assuming single-user install: %v", err) - return false - } - return true +func (p *Provider) ConfigureReprompt(ctx context.Context, username string) error { + return p.configure(ctx, username, true) } -func (n *nixSetupTask) Run(ctx context.Context) error { - if os.Getuid() != 0 { - return sudo(ctx, n.username) +func (p *Provider) configure(ctx context.Context, username string, reprompt bool) error { + const key = "nixcache-setup" + if reprompt { + setup.Reset(key) } - err := nix.IncludeDevboxConfig(ctx, n.username) - if err != nil { - return redact.Errorf("modify nix config: %v", err) + + task := &setupTask{username} + const sudoPrompt = "You're logged into a Devbox account that now has access to a Nix cache. " + + "Allow Devbox to configure Nix to use the new cache (requires sudo)?" + err := setup.ConfirmRun(ctx, key, task, sudoPrompt) + if err != nil && !errors.Is(err, setup.ErrUserRefused) { + return redact.Errorf("nixcache: run setup: %v", err) } return nil } -// awsSetupTask configures the OS's root account to authenticate with AWS by -// obtaining a token from `devbox cache credentials`. -type awsSetupTask struct { - // username is the OS username that the Nix daemon should sudo as when - // running `devbox cache credentials`. +// setupTask adds the user to Nix's trusted-users list and updates +// ~root/.aws/config so that they can use their Devbox cache with the +// Nix daemon. +type setupTask struct { + // username is the OS username to trust. username string } -func (a *awsSetupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool { - // This task only needs to run once. - if !lastRun.Time.IsZero() { - debug.Log("nixcache: skipping setup task nixcache-setup-aws: setup was already run at %s", lastRun.Time) +func (s *setupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool { + if _, err := nix.DaemonVersion(ctx); err != nil { + // This looks like a single-user install, so no need to + // configure the daemon or root's AWS credentials. + debug.Log("nixcache: skipping setup: error connecting to nix daemon, assuming single-user install: %v", err) return false } - // No need to configure the daemon if this looks like a single-user - // install. - if _, err := nix.DaemonVersion(ctx); err != nil { - debug.Log("nixcache: skipping setup task nixcache-setup-aws: error connecting to nix daemon, assuming single-user install: %v", err) - return false + if lastRun.Time.IsZero() { + debug.Log("nixcache: running setup: first time setup") + return true + } + cfg, err := nix.CurrentConfig(ctx) + if err != nil { + debug.Log("nixcache: running setup: error getting current nix config, assuming user %s isn't trusted", s.username) + return true } - return true + trusted, err := cfg.IsUserTrusted(ctx, s.username) + if err != nil { + debug.Log("nixcache: running setup: error checking if user %s is trusted, assuming they aren't", s.username) + return true + } + if !trusted { + debug.Log("nixcache: running setup: user %s isn't trusted", s.username) + return true + } + return false } -func (a *awsSetupTask) Run(ctx context.Context) error { - if os.Getuid() != 0 { - return sudo(ctx, a.username) +func (s *setupTask) Run(ctx context.Context) error { + ran, err := setup.SudoDevbox(ctx, "cache", "configure", "--user", s.username) + if ran || err != nil { + return err + } + + err = nix.IncludeDevboxConfig(ctx, s.username) + if err != nil { + return redact.Errorf("update nix config: %v", err) } + err = s.updateAWSConfig() + if err != nil { + return redact.Errorf("update root aws config: %v", err) + } + return nil +} +func (s *setupTask) updateAWSConfig() error { exe, err := devboxExecutable() if err != nil { return err @@ -133,8 +146,8 @@ func (a *awsSetupTask) Run(ctx context.Context) error { [default] # sudo as the configured user so that their cached credential files have the # correct ownership. -credential_process = %s -u %s -i -- %s cache credentials -`, header, sudo, a.username, exe) +credential_process = %s -u %s -i %s-- %s cache credentials +`, header, sudo, s.username, propagatedEnv(), exe) if err != nil { return redact.Errorf("write to ~root/.aws/config: %v", err) } @@ -144,35 +157,51 @@ credential_process = %s -u %s -i -- %s cache credentials return nil } -func sudo(ctx context.Context, username string) error { - // Use the absolute path to Devbox instead of relying on PATH for two - // reasons: - // - // 1. sudo isn't guaranteed to preserve the current PATH and the root - // user might not have devbox in its PATH. - // 2. If we're running an alternative version of Devbox - // (such as a dev build) we want to use the same binary. - exe, err := devboxExecutable() - if err != nil { - return err - } - - // Ensure the XDG state directory exists before sudoing, otherwise it - // will be owned by root. It's used by the setup package to remember - // user responses to the confirmation prompt. - err = os.MkdirAll(xdg.StateSubpath("devbox"), 0o700) - if err != nil { - return err +// propagatedEnv returns a string of space-separated VAR=value pairs of +// environment variables that should be propagated to the credential_process +// command in ~root/.aws/config. This is especially important for CI because the +// Nix daemon won't otherwise see any environment variables set by the job. +func propagatedEnv() string { + envs := []string{ + "DEVBOX_API_TOKEN", + "DEVBOX_PROD", + "DEVBOX_USE_VERSION", + "XDG_CACHE_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", } + strb := strings.Builder{} + for _, name := range envs { + val := os.Getenv(name) + if val == "" { + continue + } + notPrintable := strings.ContainsFunc(val, func(r rune) bool { + return !unicode.IsPrint(r) + }) + if notPrintable { + debug.Log("nixcache: not including environment variable in ~root/.aws/config because it contains nonprintable runes: %q=%q", name, val) + continue + } - cmd := exec.CommandContext(ctx, "sudo", "--preserve-env=XDG_STATE_HOME", "--", exe, "cache", "configure", "--user", username) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("relaunch with sudo: %w", err) + strb.WriteString(name) + strb.WriteString(`="`) + for _, r := range val { + switch r { + // Special characters inside double quotes: + // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 + case '$', '`', '"', '\\': + strb.WriteByte('\\') + } + strb.WriteRune(r) + } + strb.WriteString(`" `) } - return nil + return strb.String() } // rootAWSConfigPath returns the default AWS config path for the root user. In a diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 5d82fcb7342..3553c2f8a33 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -8,14 +8,17 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/AlecAivazis/survey/v2" + "github.com/mattn/go-isatty" "go.jetpack.io/devbox/internal/build" "go.jetpack.io/devbox/internal/debug" + "go.jetpack.io/devbox/internal/envir" "go.jetpack.io/devbox/internal/redact" "go.jetpack.io/devbox/internal/xdg" ) @@ -29,6 +32,12 @@ var ErrUserRefused = errors.New("user refused run") // for confirmation. var ErrAlreadyRefused = errors.New("already refused by user") +type ctxKey string + +// ctxKeyTask tracks the current task key across processes when relaunching +// with sudo. +var ctxKeyTask ctxKey = "task" + // Task is a setup action that can conditionally run based on the state of a // previous run. type Task interface { @@ -64,6 +73,97 @@ func Run(ctx context.Context, key string, task Task) error { return run(ctx, key, task, "") } +// SudoDevbox relaunches Devbox as root using sudo, taking care to preserve +// Devbox environment variables that can affect the new process. If the current +// user is already root, then it returns (false, nil) to indicate that no sudo +// process ran. The caller can use this as a hint to know if it's running as the +// sudoed process. Typical usage is: +// +// func (*ConfigTask) Run(context.Context) error { +// ran, err := SudoDevbox(ctx, "cache", "configure") +// if ran || err != nil { +// // return early if we kicked off a sudo process or there +// // was an error +// return err +// } +// // do things as root +// } +// +// ConfirmRun(ctx, key, &ConfigTask{}, "Allow sudo to run Devbox as root?") +// +// A task that calls SudoDevbox should pass command arguments that cause the new +// Devbox process to rerun the task. The task executes unconditionally within +// the sudo process without re-prompting the user or a second call to its +// NeedsRun method. +func SudoDevbox(ctx context.Context, arg ...string) (ran bool, err error) { + if os.Getuid() == 0 { + return false, nil + } + + taskKey := "" + if v := ctx.Value(ctxKeyTask); v != nil { + taskKey = v.(string) + } + + // Ensure the state file and its directory exist before sudoing, + // otherwise they will be owned by root. This is easier than recursively + // chowning new directories/files after root creates them. + if taskKey != "" { + saveState(taskKey, state{}) + } + + // Use the absolute path to Devbox instead of relying on PATH for two + // reasons: + // + // 1. sudo isn't guaranteed to preserve the current PATH and the root + // user might not have devbox in its PATH. + // 2. If we're running an alternative version of Devbox + // (such as a dev build) we want to use the same binary. + exe, err := devboxExecutable() + if err != nil { + return false, err + } + + sudoArgs := make([]string, 0, len(arg)+4) + sudoArgs = append(sudoArgs, "--preserve-env="+strings.Join([]string{ + // Keep writing debug logs from the sudo process. + "DEVBOX_DEBUG", + + // Use the same Devbox API and auth token. + "DEVBOX_API_TOKEN", + "DEVBOX_PROD", + + // In case the Devbox version is overridden. + "DEVBOX_USE_VERSION", + + // Use the same XDG directories for state, caching, etc. + "XDG_CACHE_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", + }, ",")) + if taskKey != "" { + sudoArgs = append(sudoArgs, "DEVBOX_SUDO_TASK="+taskKey) + } + sudoArgs = append(sudoArgs, "--", exe) + sudoArgs = append(sudoArgs, arg...) + + cmd := exec.CommandContext(ctx, "sudo", sudoArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if taskKey == "" { + return false, redact.Errorf("setup: relaunch with sudo: %w", err) + } + return false, taskError(taskKey, redact.Errorf("relaunch with sudo: %w", err)) + } + return true, nil +} + // Run interactively prompts the user to confirm that it's ok to run a setup // task. It only prompts the user if the task's NeedsRun method returns true. If // the user refuses to run the task, then ConfirmPrompt will not ask them again. @@ -76,13 +176,27 @@ func ConfirmRun(ctx context.Context, key string, task Task, prompt string) error } var defaultPrompt = func(msg string) (response any, err error) { - err = survey.AskOne(&survey.Confirm{Message: msg}, &response) - return response, err + if isatty.IsTerminal(os.Stdin.Fd()) { + err = survey.AskOne(&survey.Confirm{Message: msg}, &response) + return response, err + } + debug.Log("setup: no tty detected, assuming yes to confirmation prompt: %q", msg) + return true, nil } func run(ctx context.Context, key string, task Task, prompt string) error { + ctx = context.WithValue(ctx, ctxKeyTask, key) + + // DEVBOX_SUDO_TASK is set when a task relaunched Devbox by calling + // SudoDevbox. If it matches the current task key, then the pre-sudo + // process is already running this task and we can skip checking + // task.NeedsRun and prompting the user. + isSudo := false + if envTask := os.Getenv("DEVBOX_SUDO_TASK"); envTask != "" { + isSudo = envTask == key + } state := loadState(key) - if !task.NeedsRun(ctx, state.LastRun) { + if !isSudo && !task.NeedsRun(ctx, state.LastRun) { return nil } @@ -93,7 +207,7 @@ func run(ctx context.Context, key string, task Task, prompt string) error { } }() - if prompt != "" { + if !isSudo && prompt != "" { state.ConfirmPrompt.Message = prompt if state.ConfirmPrompt.Asked && !state.ConfirmPrompt.Allowed { // We've asked before and the user said no. @@ -213,3 +327,19 @@ func taskError(key string, err error) error { } return redact.Errorf("setup: task %s: %w", key, err) } + +// devboxExecutable returns the path to the Devbox launcher script or the +// current binary if the launcher is unavailable. +func devboxExecutable() (string, error) { + if exe := os.Getenv(envir.LauncherPath); exe != "" { + if abs, err := filepath.Abs(exe); err == nil { + return abs, nil + } + } + + exe, err := os.Executable() + if err != nil { + return "", redact.Errorf("get path to devbox executable: %v", err) + } + return exe, nil +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 405b2e92568..f69c4f9cab2 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -3,6 +3,8 @@ package setup import ( "context" "errors" + "fmt" + "os" "testing" ) @@ -103,7 +105,7 @@ func TestTaskConfirmPromptAllow(t *testing.T) { NeedsRunFunc: func(context.Context, RunInfo) bool { return true }, } - defaultPrompt = func(string) (response any, err error) { return true, nil } + setPromptResponse(t, true) err := ConfirmRun(context.Background(), t.Name(), task, "continue?") if err != nil { t.Error("got non-nil error:", err) @@ -118,7 +120,7 @@ func TestTaskConfirmPromptDeny(t *testing.T) { NeedsRunFunc: func(context.Context, RunInfo) bool { return true }, } - defaultPrompt = func(string) (response any, err error) { return false, nil } + setPromptResponse(t, false) err := ConfirmRun(context.Background(), t.Name(), task, "continue?") if err == nil { t.Error("got nil error, want ErrUserRefused") @@ -127,7 +129,80 @@ func TestTaskConfirmPromptDeny(t *testing.T) { } } +// TestSudoDevbox uses sudo on the current test binary to recursively call +// itself as root. This test can only be run manually (because it needs sudo) +// but is still useful for testing after making any changes to the sudo code. +// +// - Within the test we check if os.Getuid() == 0 to act differently depending +// on if we're the sudo test process or the parent (non-sudo) test process. +// - The sudo version of the test creates a "test-sudo-devbox-result" file. +// - The non-sudo version of the test looks for the same file to know if the +// sudo worked. +func TestSudoDevbox(t *testing.T) { + t.Skip("this test must be run manually because it requires sudo") + + ctx := context.Background() + key := "test-sudo-devbox" + resultFile := key + "-result" + + // Non-sudo process cleans up the result file. + os.Remove(resultFile) + t.Cleanup(func() { + if os.Getuid() != 0 { + os.Remove(resultFile) + } + }) + + task := &testTask{} + task.RunFunc = func(ctx context.Context) error { + ran, err := SudoDevbox(ctx, "-test.run", "^"+t.Name()+"$") + if ran || err != nil { + return err + } + + // Create a result file to indicate to the non-sudo process that + // we ran as root successfully. + if os.Getuid() == 0 { + return os.WriteFile(resultFile, nil, 0o666) + } + err = fmt.Errorf("task.NeedsRun not running as root after calling SudoDevbox") + t.Error(err) + return err + } + task.NeedsRunFunc = func(ctx context.Context, lastRun RunInfo) bool { + if os.Getuid() == 0 { + t.Error("task.NeedsRun called in sudo process, but should only be called in user process") + } + return true + } + + old := defaultPrompt + t.Cleanup(func() { defaultPrompt = old }) + defaultPrompt = func(msg string) (response any, err error) { + if os.Getuid() == 0 { + err = fmt.Errorf("user prompted again while already running as sudo") + t.Error(err) + return false, err + } + return true, nil + } + + err := ConfirmRun(ctx, key, task, "Allow sudo to run Devbox as root?") + if err != nil { + t.Error("got ConfirmRun error:", err) + } + if _, err := os.Stat(resultFile); err != nil { + t.Error("got missing sudo result file:", err) + } +} + func tempXDGStateDir(t *testing.T) { t.Helper() t.Setenv("XDG_STATE_HOME", t.TempDir()) } + +func setPromptResponse(t *testing.T, a any) { + old := defaultPrompt + t.Cleanup(func() { defaultPrompt = old }) + defaultPrompt = func(string) (any, error) { return a, nil } +}