diff --git a/cmd/agent/container/credentials_server.go b/cmd/agent/container/credentials_server.go index 7c36f558f..7c76a699f 100644 --- a/cmd/agent/container/credentials_server.go +++ b/cmd/agent/container/credentials_server.go @@ -1,10 +1,12 @@ package container import ( + "bytes" "context" "encoding/json" "fmt" "net" + "net/http" "os" "strconv" @@ -15,10 +17,10 @@ import ( "github.com/loft-sh/devpod/pkg/dockercredentials" "github.com/loft-sh/devpod/pkg/gitcredentials" "github.com/loft-sh/devpod/pkg/gitsshsigning" + devpodhttp "github.com/loft-sh/devpod/pkg/http" "github.com/loft-sh/devpod/pkg/netstat" portpkg "github.com/loft-sh/devpod/pkg/port" "github.com/loft-sh/log" - "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -35,6 +37,7 @@ type CredentialsServerCmd struct { ForwardPorts bool GitUserSigningKey string + Runner bool } // NewCredentialsServerCmd creates a new command @@ -46,8 +49,21 @@ func NewCredentialsServerCmd(flags *flags.GlobalFlags) *cobra.Command { Use: "credentials-server", Short: "Starts a credentials server", Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, args []string) error { - return cmd.Run(context.Background(), args) + RunE: func(c *cobra.Command, args []string) error { + runnerPort, err := credentials.GetRunnerPort() + if err != nil { + return err + } + if cmd.Runner { + return cmd.RunRunner(c.Context(), runnerPort) + } + + port, err := credentials.GetPort() + if err != nil { + return err + } + + return cmd.Run(c.Context(), port, runnerPort) }, } credentialsServerCmd.Flags().BoolVar(&cmd.ConfigureGitHelper, "configure-git-helper", false, "If true will configure git helper") @@ -56,11 +72,13 @@ func NewCredentialsServerCmd(flags *flags.GlobalFlags) *cobra.Command { credentialsServerCmd.Flags().StringVar(&cmd.GitUserSigningKey, "git-user-signing-key", "", "") credentialsServerCmd.Flags().StringVar(&cmd.User, "user", "", "The user to use") _ = credentialsServerCmd.MarkFlagRequired("user") + credentialsServerCmd.Flags().BoolVar(&cmd.Runner, "runner", false, "If true will create a credentials server connected to the runner") + return credentialsServerCmd } // Run runs the command logic -func (cmd *CredentialsServerCmd) Run(ctx context.Context, _ []string) error { +func (cmd *CredentialsServerCmd) Run(ctx context.Context, port int, runnerPort int) error { // create a grpc client tunnelClient, err := tunnelserver.NewTunnelClient(os.Stdin, os.Stdout, true, ExitCodeIO) if err != nil { @@ -70,17 +88,11 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, _ []string) error { // this message serves as a ping to the client _, err = tunnelClient.Ping(ctx, &tunnel.Empty{}) if err != nil { - return errors.Wrap(err, "ping client") + return fmt.Errorf("ping client: %w", err) } // create debug logger log := tunnelserver.NewTunnelLogger(ctx, tunnelClient, cmd.Debug) - log.Debugf("Start credentials server") - - port, err := credentials.GetPort() - if err != nil { - return err - } // forward ports if cmd.ForwardPorts { @@ -99,13 +111,90 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, _ []string) error { return nil } - binaryPath, err := os.Executable() + runnerAddr := checkRunnerCredentialServer(runnerPort) + // configure docker credential helper + if cmd.ConfigureDockerHelper && dockerCredentialsAllowed(runnerAddr) { + err = dockercredentials.ConfigureCredentialsContainer(cmd.User, port, log) + if err != nil { + return err + } + } + + // configure git user + err = configureGitUserLocally(ctx, cmd.User, tunnelClient) if err != nil { + log.Debugf("Error configuring git user: %v", err) return err } - // configure docker credential helper + // configure git credential helper + if cmd.ConfigureGitHelper && gitCredentialsAllowed(runnerAddr) { + binaryPath, err := os.Executable() + if err != nil { + return err + } + err = gitcredentials.ConfigureHelper(binaryPath, cmd.User, port) + if err != nil { + return fmt.Errorf("configure git helper: %w", err) + } + + // cleanup when we are done + defer func(userName string) { + _ = gitcredentials.RemoveHelper(userName) + }(cmd.User) + } + + // configure git ssh signature helper + if cmd.GitUserSigningKey != "" { + err = gitsshsigning.ConfigureHelper(cmd.User, cmd.GitUserSigningKey, log) + if err != nil { + return fmt.Errorf("configure git ssh signature helper: %w", err) + } + + // cleanup when we are done + defer func(userName string) { + _ = gitsshsigning.RemoveHelper(userName) + }(cmd.User) + } + + return credentials.RunCredentialsServer(ctx, port, tunnelClient, runnerAddr, log) +} + +// RunRunner starts the runners credentials server +// It's connected directly to a services server on the runner instead of on the origin developer machine +// +// The origin credentials server (default: port 12049) and the runner credentials server (default: port 12050) +// communicate through https. Since both are connected to their respective peers over stdio, the default mode is +// to always connect external tools (git, docker) to the origin instance. It is then responsible +// for pinging the runners server first. +// The runner will either send a valid response to use, an empty response meaning "no decision" or an error, indicating abortion. +func (cmd *CredentialsServerCmd) RunRunner(ctx context.Context, port int) error { + // create a grpc client + tunnelClient, err := tunnelserver.NewTunnelClient(os.Stdin, os.Stdout, true, ExitCodeIO) + if err != nil { + return fmt.Errorf("error creating tunnel client: %w", err) + } + + // this message serves as a ping to the client + _, err = tunnelClient.Ping(ctx, &tunnel.Empty{}) + if err != nil { + return fmt.Errorf("ping client: %w", err) + } + + // create debug logger + log := tunnelserver.NewTunnelLogger(ctx, tunnelClient, cmd.Debug) + + addr := net.JoinHostPort("localhost", strconv.Itoa(port)) + if ok, err := portpkg.IsAvailable(addr); !ok || err != nil { + log.Debugf("Port %d not available, exiting", port) + return nil + } + + // We go through the same startup procedure the origin credentials server goes through as well + // This ensures we set up everything according to platform settings if we are in scenarios where we + // don't have an origin server, for example in web mode. + if cmd.ConfigureDockerHelper { err = dockercredentials.ConfigureCredentialsContainer(cmd.User, port, log) if err != nil { @@ -113,7 +202,6 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, _ []string) error { } } - // configure git user err = configureGitUserLocally(ctx, cmd.User, tunnelClient) if err != nil { log.Debugf("Error configuring git user: %v", err) @@ -121,9 +209,13 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, _ []string) error { // configure git credential helper if cmd.ConfigureGitHelper { + binaryPath, err := os.Executable() + if err != nil { + return err + } err = gitcredentials.ConfigureHelper(binaryPath, cmd.User, port) if err != nil { - return errors.Wrap(err, "configure git helper") + return fmt.Errorf("configure git helper: %w", err) } // cleanup when we are done @@ -136,7 +228,7 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, _ []string) error { if cmd.GitUserSigningKey != "" { err = gitsshsigning.ConfigureHelper(cmd.User, cmd.GitUserSigningKey, log) if err != nil { - return errors.Wrap(err, "configure git ssh signature helper") + return fmt.Errorf("configure git ssh signature helper: %w", err) } // cleanup when we are done @@ -145,7 +237,7 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, _ []string) error { }(cmd.User) } - return credentials.RunCredentialsServer(ctx, port, tunnelClient, log) + return credentials.RunCredentialsServer(ctx, port, tunnelClient, "", log) } func configureGitUserLocally(ctx context.Context, userName string, client tunnel.TunnelClient) error { @@ -206,3 +298,46 @@ func (f *forwarder) StopForward(port string) error { _, err := f.client.StopForwardPort(f.ctx, &tunnel.StopForwardPortRequest{Port: port}) return err } + +// dockerCredentialsAllowed checks if the runner allows docker credential forwarding +// if we can connect to it +func dockerCredentialsAllowed(runnerAddr string) bool { + if runnerAddr == "" { + return true + } + + rawJSON, err := json.Marshal(&dockercredentials.Request{}) + if err != nil { + return false + } + res, err := devpodhttp.GetHTTPClient().Post(fmt.Sprintf("http://%s/%s", runnerAddr, "docker-credentials"), + "application/json", bytes.NewReader(rawJSON)) + + return res.StatusCode == http.StatusOK && err == nil +} + +// gitCredentialsAllowed checks if the runner allows git credential forwarding +// if we can connect to it +func gitCredentialsAllowed(runnerAddr string) bool { + if runnerAddr == "" { + return true + } + + res, err := devpodhttp.GetHTTPClient().Post(fmt.Sprintf("http://%s/%s", runnerAddr, "git-credentials"), + "application/json", bytes.NewReader([]byte(""))) + + return res.StatusCode == http.StatusOK && err == nil +} + +// checkRunnerCredentialServer tries to contact the runner credentials server +// and returns it's host:port address if available +func checkRunnerCredentialServer(runnerPort int) string { + runnerAddr := net.JoinHostPort("localhost", strconv.Itoa(runnerPort)) + runnerAvailable, _ := portpkg.IsAvailable(runnerAddr) + if runnerAvailable { + // If the port is free we don't have to check in with runner server + return "" + } + + return runnerAddr +} diff --git a/cmd/agent/workspace/install_dotfiles.go b/cmd/agent/workspace/install_dotfiles.go index 7723e3982..e776c6fab 100644 --- a/cmd/agent/workspace/install_dotfiles.go +++ b/cmd/agent/workspace/install_dotfiles.go @@ -68,7 +68,7 @@ func (cmd *InstallDotfilesCmd) Run(ctx context.Context) error { if cmd.InstallScript != "" { logger.Infof("Executing install script %s", cmd.InstallScript) - command := "./" + strings.TrimPrefix(cmd.InstallScript, "./") + cmd.InstallScript + command := "./" + strings.TrimPrefix(cmd.InstallScript, "./") err := ensureExecutable(command) if err != nil { diff --git a/cmd/ssh.go b/cmd/ssh.go index 97ede95e8..e5cb5a628 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -19,7 +19,6 @@ import ( client2 "github.com/loft-sh/devpod/pkg/client" "github.com/loft-sh/devpod/pkg/client/clientimplementation" "github.com/loft-sh/devpod/pkg/config" - "github.com/loft-sh/devpod/pkg/gitsshsigning" "github.com/loft-sh/devpod/pkg/gpg" "github.com/loft-sh/devpod/pkg/port" devssh "github.com/loft-sh/devpod/pkg/ssh" @@ -64,22 +63,24 @@ func NewSSHCmd(flags *flags.GlobalFlags) *cobra.Command { Use: "ssh", Short: "Starts a new ssh session to a workspace", RunE: func(_ *cobra.Command, args []string) error { - ctx := context.Background() - - err := mergeDevPodSshOptions(cmd) + devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err } - devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) - if err != nil { + if err := mergeDevPodSshOptions(cmd); err != nil { return err } + if cmd.Proxy { + // merge context options from env + config.MergeContextOptions(devPodConfig.Current(), os.Environ()) + } client, err := workspace2.GetWorkspace(devPodConfig, args, true, log.Default.ErrorStreamOnly()) if err != nil { return err } + ctx := context.Background() return cmd.Run(ctx, devPodConfig, client, log.Default.ErrorStreamOnly()) }, } @@ -396,7 +397,11 @@ func (cmd *SSHCmd) startTunnel(ctx context.Context, devPodConfig *config.Config, // Traffic is coming in from the outside, we need to forward it to the container if cmd.Proxy || cmd.Stdio { if cmd.Proxy { - go cmd.startProxyServices(ctx, devPodConfig, containerClient, log) + go func() { + if err := cmd.startRunnerServices(ctx, devPodConfig, containerClient, log); err != nil { + log.Error(err) + } + }() } return devssh.Run(ctx, containerClient, command, os.Stdin, os.Stdout, writer) @@ -441,75 +446,40 @@ func (cmd *SSHCmd) startServices( } } -func (cmd *SSHCmd) startProxyServices( +func (cmd *SSHCmd) startRunnerServices( ctx context.Context, devPodConfig *config.Config, containerClient *ssh.Client, log log.Logger, -) { - gitCredentials := devPodConfig.ContextOption(config.ContextOptionSSHInjectGitCredentials) == "true" - if !gitCredentials || cmd.User == "" || cmd.GitUsername == "" || cmd.GitToken == "" { - return - } +) error { + // check prerequisites + allowGitCredentials := devPodConfig.ContextOption(config.ContextOptionSSHInjectGitCredentials) == "true" + allowDockerCredentials := devPodConfig.ContextOption(config.ContextOptionSSHInjectDockerCredentials) == "true" - stdoutReader, stdoutWriter, err := os.Pipe() + // prepare pipes + stdoutReader, stdoutWriter, stdinReader, stdinWriter, err := preparePipes() if err != nil { - log.Debugf("Error creating stdout pipe: %v", err) - return + return fmt.Errorf("prepare pipes: %w", err) } defer stdoutWriter.Close() - - stdinReader, stdinWriter, err := os.Pipe() - if err != nil { - log.Debugf("Error creating stdin pipe: %v", err) - return - } defer stdinWriter.Close() + // prepare context cancelCtx, cancel := context.WithCancel(ctx) defer cancel() - errChan := make(chan error, 1) - go func() { - defer cancel() - writer := log.ErrorStreamOnly().Writer(logrus.DebugLevel, false) - defer writer.Close() - - command := fmt.Sprintf("'%s' agent container credentials-server --user '%s'", agent.ContainerDevPodHelperLocation, cmd.User) - if gitCredentials { - command += " --configure-git-helper" - } - - // check if we should enable git ssh commit signature support - if cmd.GitSSHSignatureForwarding || devPodConfig.ContextOption(config.ContextOptionGitSSHSignatureForwarding) == "true" { - format, userSigningKey, err := gitsshsigning.ExtractGitConfiguration() - if err != nil { - return - } + errChan := make(chan error, 2) - if userSigningKey != "" && format == gitsshsigning.GPGFormatSSH { - command += fmt.Sprintf(" --git-user-signing-key %s", userSigningKey) - } - } - - if log.GetLevel() == logrus.DebugLevel { - command += " --debug" - } + // start credentials server in workspace + go func() { + errChan <- startWorkspaceCredentialServer(cancelCtx, containerClient, cmd.User, allowGitCredentials, allowDockerCredentials, stdinReader, stdoutWriter, log) + }() - errChan <- devssh.Run(cancelCtx, containerClient, command, stdinReader, stdoutWriter, writer) + // start runner services server locally + go func() { + errChan <- startLocalServer(cancelCtx, allowGitCredentials, allowDockerCredentials, cmd.GitUsername, cmd.GitToken, stdoutReader, stdinWriter, log) }() - err = tunnelserver.RunServicesServer(ctx, stdoutReader, stdinWriter, true, true, nil, log, - []tunnelserver.Option{tunnelserver.WithGitCredentialsOverride(cmd.GitUsername, cmd.GitToken)}..., - ) - if err != nil { - log.Debugf("Error running proxy server: %v", err) - return - } - err = <-errChan - if err != nil { - log.Debugf("Error running credential server: %v", err) - return - } + return <-errChan } // setupGPGAgent will forward a local gpg-agent into the remote container @@ -621,3 +591,51 @@ func mergeDevPodSshOptions(cmd *SSHCmd) error { return nil } + +func startWorkspaceCredentialServer(ctx context.Context, client *ssh.Client, user string, allowGitCredentials, allowDockerCredentials bool, stdin io.Reader, stdout io.Writer, log log.Logger) error { + writer := log.ErrorStreamOnly().Writer(logrus.DebugLevel, false) + defer writer.Close() + + command := fmt.Sprintf("'%s' agent container credentials-server", agent.ContainerDevPodHelperLocation) + args := []string{ + fmt.Sprintf("--user '%s'", user), + } + if allowGitCredentials { + args = append(args, "--configure-git-helper") + } + if allowDockerCredentials { + args = append(args, "--configure-docker-helper") + } + if log.GetLevel() == logrus.DebugLevel { + args = append(args, "--debug") + } + args = append(args, "--runner") + command = fmt.Sprintf("%s %s", command, strings.Join(args, " ")) + + if err := devssh.Run(ctx, client, command, stdin, stdout, writer); err != nil { + return fmt.Errorf("run credentials server: %w", err) + } + + return nil +} + +func startLocalServer(ctx context.Context, allowGitCredentials, allowDockerCredentials bool, gitUsername, gitToken string, stdoutReader io.Reader, stdinWriter io.WriteCloser, log log.Logger) error { + if err := tunnelserver.RunRunnerServer(ctx, stdoutReader, stdinWriter, allowGitCredentials, allowDockerCredentials, gitUsername, gitToken, log); err != nil { + return fmt.Errorf("run runner services server: %w", err) + } + + return nil +} + +func preparePipes() (io.Reader, io.WriteCloser, io.Reader, io.WriteCloser, error) { + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("create stdout pipe: %w", err) + } + stdinReader, stdinWriter, err := os.Pipe() + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("create stdin pipe: %w", err) + } + + return stdoutReader, stdoutWriter, stdinReader, stdinWriter, nil +} diff --git a/cmd/up.go b/cmd/up.go index 94f55f0d7..e52b380cf 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -73,24 +73,27 @@ func NewUpCmd(flags *flags.GlobalFlags) *cobra.Command { Use: "up", Short: "Starts a new workspace", RunE: func(_ *cobra.Command, args []string) error { - // try to parse flags from env - err := mergeDevPodUpOptions(&cmd.CLIOptions) + devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err } - err = mergeEnvFromFiles(&cmd.CLIOptions) - if err != nil { + + // try to parse flags from env + if err := mergeDevPodUpOptions(&cmd.CLIOptions); err != nil { return err } - ctx := context.Background() var logger log.Logger = log.Default if cmd.Proxy { logger = logger.ErrorStreamOnly() - logger.Debugf("Using error stream as --proxy is enabled") + logger.Debug("Running in proxy mode") + logger.Debug("Using error output stream") + + // merge context options from env + config.MergeContextOptions(devPodConfig.Current(), os.Environ()) } - devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + err = mergeEnvFromFiles(&cmd.CLIOptions) if err != nil { return err } @@ -107,6 +110,7 @@ func NewUpCmd(flags *flags.GlobalFlags) *cobra.Command { cmd.SSHConfigPath = devPodConfig.ContextOption(config.ContextOptionSSHConfigPath) } + ctx := context.Background() client, err := workspace2.ResolveWorkspace( ctx, devPodConfig, @@ -538,8 +542,10 @@ func (cmd *UpCmd) devPodUpMachine( if err != nil { return nil, errors.Wrap(err, "create tunnel client") } + allowGitCredentials := devPodConfig.ContextOption(config.ContextOptionSSHInjectGitCredentials) == "true" + allowDockerCredentials := devPodConfig.ContextOption(config.ContextOptionSSHInjectDockerCredentials) == "true" - return tunnelserver.RunProxyServer(ctx, tunnelClient, stdout, stdin, log, cmd.GitUsername, cmd.GitToken) + return tunnelserver.RunProxyServer(ctx, tunnelClient, stdout, stdin, allowGitCredentials, allowDockerCredentials, cmd.GitUsername, cmd.GitToken, log) } return tunnelserver.RunUpServer( @@ -1018,14 +1024,13 @@ func setupLoftPlatformAccess(context, provider, user string, client client2.Base return fmt.Errorf("get port: %w", err) } - command := fmt.Sprintf("%v agent container setup-loft-platform-access --context %v --provider %v --port %v", agent.ContainerDevPodHelperLocation, context, provider, port) + command := fmt.Sprintf("\"%s\" agent container setup-loft-platform-access --context %s --provider %s --port %d", agent.ContainerDevPodHelperLocation, context, provider, port) - log.Debugf("Executing command -> %v", command) + log.Debugf("Executing command: %v", command) var errb bytes.Buffer cmd := exec.Command( execPath, "ssh", - "--agent-forwarding=true", "--start-services=true", "--user", user, diff --git a/pkg/agent/tunnelserver/proxyserver.go b/pkg/agent/tunnelserver/proxyserver.go index 10cdedd76..4e52d4f7e 100644 --- a/pkg/agent/tunnelserver/proxyserver.go +++ b/pkg/agent/tunnelserver/proxyserver.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "fmt" "io" "github.com/loft-sh/devpod/pkg/agent/tunnel" @@ -11,12 +12,11 @@ import ( "github.com/loft-sh/devpod/pkg/gitcredentials" "github.com/loft-sh/devpod/pkg/stdio" "github.com/loft-sh/log" - perrors "github.com/pkg/errors" "google.golang.org/grpc" "google.golang.org/grpc/reflection" ) -func RunProxyServer(ctx context.Context, client tunnel.TunnelClient, reader io.Reader, writer io.WriteCloser, log log.Logger, gitUsername, gitToken string) (*config.Result, error) { +func RunProxyServer(ctx context.Context, client tunnel.TunnelClient, reader io.Reader, writer io.WriteCloser, allowGitCredentials, allowDockerCredentials bool, gitUsername, gitToken string, log log.Logger) (*config.Result, error) { lis := stdio.NewStdioListener(reader, writer, false) s := grpc.NewServer() tunnelServ := &proxyServer{ @@ -25,6 +25,9 @@ func RunProxyServer(ctx context.Context, client tunnel.TunnelClient, reader io.R gitUsername: gitUsername, gitToken: gitToken, + + allowGitCredentials: allowGitCredentials, + allowDockerCredentials: allowDockerCredentials, } tunnel.RegisterTunnelServer(s, tunnelServ) reflection.Register(s) @@ -48,8 +51,10 @@ type proxyServer struct { result *config.Result log log.Logger - gitUsername string - gitToken string + gitUsername string + gitToken string + allowGitCredentials bool + allowDockerCredentials bool } func (t *proxyServer) ForwardPort(ctx context.Context, portRequest *tunnel.ForwardPortRequest) (*tunnel.ForwardPortResponse, error) { @@ -61,6 +66,9 @@ func (t *proxyServer) StopForwardPort(ctx context.Context, portRequest *tunnel.S } func (t *proxyServer) DockerCredentials(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) { + if !t.allowDockerCredentials { + return nil, fmt.Errorf("docker credentials forbidden") + } return t.client.DockerCredentials(ctx, message) } @@ -69,20 +77,22 @@ func (t *proxyServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunnel } func (t *proxyServer) GitCredentials(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) { + if !t.allowGitCredentials { + return nil, fmt.Errorf("git credentials forbidden") + } + // if we have a git token reuse that and don't ask the user if t.gitToken != "" { credentials := &gitcredentials.GitCredentials{} err := json.Unmarshal([]byte(message.Message), credentials) if err != nil { - return nil, perrors.Wrap(err, "decode git credentials request") + return nil, fmt.Errorf("decode git credentials request: %w", err) } - response, err := gitcredentials.GetCredentials(credentials, t.gitUsername, t.gitToken) - if err != nil { - return nil, perrors.Wrap(err, "get git response") - } + credentials.Password = t.gitToken + credentials.Username = t.gitUsername - out, err := json.Marshal(response) + out, err := json.Marshal(credentials) if err != nil { return nil, err } diff --git a/pkg/agent/tunnelserver/runnerserver.go b/pkg/agent/tunnelserver/runnerserver.go new file mode 100644 index 000000000..cb076d746 --- /dev/null +++ b/pkg/agent/tunnelserver/runnerserver.go @@ -0,0 +1,75 @@ +package tunnelserver + +import ( + "context" + "fmt" + "io" + + "github.com/loft-sh/devpod/pkg/agent/tunnel" + "github.com/loft-sh/devpod/pkg/stdio" + "github.com/loft-sh/log" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +func RunRunnerServer(ctx context.Context, reader io.Reader, writer io.WriteCloser, allowGitCredentials, allowDockerCredentials bool, gitUsername, gitToken string, log log.Logger) error { + runnerServ := &runnerServer{ + log: log, + allowGitCredentials: allowGitCredentials, + allowDockerCredentials: allowDockerCredentials, + gitCredentials: gitCredentialsOverride{username: gitUsername, token: gitToken}, + } + + return runnerServ.Run(ctx, reader, writer) +} + +type runnerServer struct { + tunnel.UnimplementedTunnelServer + + allowGitCredentials bool + allowDockerCredentials bool + log log.Logger + gitCredentials gitCredentialsOverride +} + +func (t *runnerServer) Run(ctx context.Context, reader io.Reader, writer io.WriteCloser) error { + lis := stdio.NewStdioListener(reader, writer, false) + s := grpc.NewServer() + tunnel.RegisterTunnelServer(s, t) + reflection.Register(s) + + return s.Serve(lis) +} + +func (t *runnerServer) DockerCredentials(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) { + if !t.allowDockerCredentials { + return nil, fmt.Errorf("docker credentials forbidden") + } + + return &tunnel.Message{}, nil +} + +func (t *runnerServer) GitCredentials(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) { + if !t.allowGitCredentials { + return nil, fmt.Errorf("git credentials forbidden") + } + + return &tunnel.Message{}, nil +} + +func (t *runnerServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunnel.Message, error) { + return &tunnel.Message{}, nil +} + +func (t *runnerServer) GitSSHSignature(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) { + return &tunnel.Message{}, nil +} + +func (t *runnerServer) LoftConfig(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) { + return &tunnel.Message{}, nil +} + +func (t *runnerServer) Ping(context.Context, *tunnel.Empty) (*tunnel.Empty, error) { + t.log.Debugf("Received ping from agent") + return &tunnel.Empty{}, nil +} diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index a6835ddb0..ff0b4750d 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -205,12 +205,18 @@ func (t *tunnelServer) GitCredentials(ctx context.Context, message *tunnel.Messa return nil, perrors.Wrap(err, "decode git credentials request") } - response, err := gitcredentials.GetCredentials(credentials, t.gitCredentialsOverride.username, t.gitCredentialsOverride.token) - if err != nil { - return nil, perrors.Wrap(err, "get git response") + if t.gitCredentialsOverride.token != "" { + credentials.Username = t.gitCredentialsOverride.username + credentials.Password = t.gitCredentialsOverride.token + } else { + response, err := gitcredentials.GetCredentials(credentials) + if err != nil { + return nil, perrors.Wrap(err, "get git response") + } + credentials = response } - out, err := json.Marshal(response) + out, err := json.Marshal(credentials) if err != nil { return nil, err } diff --git a/pkg/agent/workspace.go b/pkg/agent/workspace.go index ce70a947a..2f388bceb 100644 --- a/pkg/agent/workspace.go +++ b/pkg/agent/workspace.go @@ -285,10 +285,11 @@ func CloneRepositoryForWorkspace( // setup private ssh key if passed in extraEnv := []string{} if options.SSHKey != "" { - sshExtraEnv, err := setupSSHKey(options.SSHKey, agentConfig.Path) + sshExtraEnv, cleanUpSSHKey, err := setupSSHKey(options.SSHKey, agentConfig.Path) if err != nil { return err } + defer cleanUpSSHKey() extraEnv = append(extraEnv, sshExtraEnv...) } @@ -305,27 +306,30 @@ func CloneRepositoryForWorkspace( return nil } -func setupSSHKey(key string, agentPath string) ([]string, error) { +func setupSSHKey(key string, agentPath string) ([]string, func(), error) { keyFile, err := os.CreateTemp("", "") if err != nil { - return nil, err + return nil, nil, err } - defer os.Remove(keyFile.Name()) - defer keyFile.Close() if err := writeSSHKey(keyFile, key); err != nil { - return nil, err + return nil, nil, err } if err := os.Chmod(keyFile.Name(), 0o400); err != nil { - return nil, err + return nil, nil, err } env := []string{"GIT_TERMINAL_PROMPT=0"} gitSSHCmd := []string{agentPath, "helper", "ssh-git-clone", "--key-file=" + keyFile.Name()} env = append(env, "GIT_SSH_COMMAND="+command.Quote(gitSSHCmd)) - return env, nil + cleanup := func() { + os.Remove(keyFile.Name()) + keyFile.Close() + } + + return env, cleanup, nil } func writeSSHKey(key *os.File, sshKey string) error { diff --git a/pkg/config/context.go b/pkg/config/context.go index b4d676131..187918632 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -1,5 +1,11 @@ package config +import ( + "strings" + + "github.com/loft-sh/devpod/pkg/types" +) + const ( ContextOptionSSHAddPrivateKeys = "SSH_ADD_PRIVATE_KEYS" ContextOptionGPGAgentForwarding = "GPG_AGENT_FORWARDING" @@ -93,3 +99,31 @@ var ContextOptions = []ContextOption{ Default: "", }, } + +func MergeContextOptions(contextConfig *ContextConfig, environ []string) { + envVars := map[string]string{} + for _, v := range environ { + t := strings.SplitN(v, "=", 2) + if len(t) != 2 { + continue + } + envVars[t[0]] = t[1] + } + + for _, o := range ContextOptions { + // look up in env + envVal, ok := envVars[o.Name] + if !ok { + continue + } + // look up in current context options, skip if already exists + if _, ok := contextConfig.Options[o.Name]; ok { + continue + } + contextConfig.Options[o.Name] = OptionValue{ + Value: envVal, + UserProvided: true, + Filled: &[]types.Time{types.Now()}[0], + } + } +} diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go new file mode 100644 index 000000000..3e8578c26 --- /dev/null +++ b/pkg/config/context_test.go @@ -0,0 +1,71 @@ +package config + +import ( + "fmt" + "testing" + + gocmp "github.com/google/go-cmp/cmp" + "gotest.tools/assert" + "gotest.tools/assert/cmp" +) + +type testCaseMergeContextOption struct { + description string + in *ContextConfig + environ []string + expected *ContextConfig +} + +func TestCaseMergeContextOption(t *testing.T) { + testCases := []testCaseMergeContextOption{ + { + description: "empty config, nothing in env", + in: &ContextConfig{}, + environ: []string{}, + expected: &ContextConfig{}, + }, + { + description: "docker injection is false, nothing coming in from env", + in: &ContextConfig{ + Options: map[string]OptionValue{ + ContextOptionSSHInjectDockerCredentials: { + Value: "false", + }, + }, + }, + environ: []string{}, + expected: &ContextConfig{ + Options: map[string]OptionValue{ + ContextOptionSSHInjectDockerCredentials: { + Value: "false", + }, + }, + }, + }, + { + description: "docker injection set by env", + in: &ContextConfig{ + Options: map[string]OptionValue{}, + }, + environ: []string{fmt.Sprintf("%s=%s", ContextOptionSSHInjectDockerCredentials, "true")}, + expected: &ContextConfig{ + Options: map[string]OptionValue{ + ContextOptionSSHInjectDockerCredentials: { + Value: "true", + UserProvided: true, + }, + }, + }, + }, + } + + for _, tc := range testCases { + MergeContextOptions(tc.in, tc.environ) + ok := assert.Check(t, cmp.DeepEqual(tc.expected, tc.in, gocmp.FilterPath(func(p gocmp.Path) bool { + return p.String() != "Filled" + }, gocmp.Ignore()))) + if !ok { + fmt.Println(tc.description) + } + } +} diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index 05fad0d02..6d9914120 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -1,59 +1,70 @@ package credentials import ( + "bytes" "cmp" "context" "fmt" "io" + "net" "net/http" + "net/url" "os" "strconv" "github.com/loft-sh/devpod/pkg/agent/tunnel" + devpodhttp "github.com/loft-sh/devpod/pkg/http" "github.com/loft-sh/log" "github.com/pkg/errors" ) const DefaultPort = "12049" +const DefaultRunnerPort = "12050" const CredentialsServerPortEnv = "DEVPOD_CREDENTIALS_SERVER_PORT" +const CredentialsServerRunnerPortEnv = "DEVPOD_CREDENTIALS_SERVER_RUNNER_PORT" func RunCredentialsServer( ctx context.Context, port int, client tunnel.TunnelClient, + runnerAddr string, log log.Logger, ) error { - srv := &http.Server{ - Addr: "localhost:" + strconv.Itoa(port), - Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - log.Debugf("Incoming client connection at %s", request.URL.Path) - if request.URL.Path == "/git-credentials" { - err := handleGitCredentialsRequest(ctx, writer, request, client, log) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - } else if request.URL.Path == "/docker-credentials" { - err := handleDockerCredentialsRequest(ctx, writer, request, client, log) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - } else if request.URL.Path == "/git-ssh-signature" { - err := handleGitSSHSignatureRequest(ctx, writer, request, client, log) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - } else if request.URL.Path == "/loft-platform-credentials" { - err := handleLoftPlatformCredentialsRequest(ctx, writer, request, client, log) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - } + var handler http.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + log.Debugf("Incoming client connection at %s", request.URL.Path) + if request.URL.Path == "/git-credentials" { + err := handleGitCredentialsRequest(ctx, writer, request, client, log) + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return } - }), + } else if request.URL.Path == "/docker-credentials" { + err := handleDockerCredentialsRequest(ctx, writer, request, client, log) + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + } else if request.URL.Path == "/git-ssh-signature" { + err := handleGitSSHSignatureRequest(ctx, writer, request, client, log) + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + } else if request.URL.Path == "/loft-platform-credentials" { + err := handleLoftPlatformCredentialsRequest(ctx, writer, request, client, log) + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + } + } + }) + + if runnerAddr != "" { + handler = runnerProxy(handler, runnerAddr, log) } + addr := net.JoinHostPort("localhost", strconv.Itoa(port)) + srv := &http.Server{Addr: addr, Handler: handler} + errChan := make(chan error, 1) go func() { log.Debugf("Credentials server started on port %d...", port) @@ -85,6 +96,84 @@ func GetPort() (int, error) { return port, nil } +func GetRunnerPort() (int, error) { + strPort := cmp.Or(os.Getenv(CredentialsServerRunnerPortEnv), DefaultRunnerPort) + port, err := strconv.Atoi(strPort) + if err != nil { + return 0, fmt.Errorf("convert port %s: %w", strPort, err) + } + + return port, nil +} + +func runnerProxy(handler http.Handler, proxyAddr string, log log.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + proxyReq, err := prepareRequest(req, proxyAddr) + if err != nil { + log.Errorf("prepare proxy request", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // execute request against runner + res, err := devpodhttp.GetHTTPClient().Do(&proxyReq) + if err != nil { + log.Errorf("request from proxy: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer res.Body.Close() + out, err := io.ReadAll(res.Body) + if err != nil { + log.Errorf("read response body: %v", err) + return + } + if res.StatusCode != http.StatusOK { + log.Errorf("proxy request (%d): %d bytes", res.StatusCode, len(out)) + return + } + + // Send response from runner if it's not empty + if len(out) != 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) + log.Debugf("Successfully wrote back %d bytes", len(out)) + return + } + + // Otherwise forward to origin credentials server + handler.ServeHTTP(w, req) + }) +} + +func prepareRequest(req *http.Request, proxyAddr string) (http.Request, error) { + proxyReq := *req + var b bytes.Buffer + _, err := b.ReadFrom(req.Body) + if err != nil { + return proxyReq, fmt.Errorf("read body: %w", err) + } + req.Body = io.NopCloser(&b) + proxyReq.Body = io.NopCloser(bytes.NewReader(b.Bytes())) + + // rewrite target + p, err := url.JoinPath(fmt.Sprintf("http://%s", proxyAddr), req.URL.Path) + if err != nil { + return proxyReq, fmt.Errorf("join url path \"http://%s\", \"%s\": %w", proxyAddr, req.URL.Path, err) + } + + proxyURL, err := url.Parse(p) + if err != nil { + return proxyReq, fmt.Errorf("parse proxy url %s: %w", p, err) + } + + proxyReq.URL = proxyURL + proxyReq.RequestURI = "" + + return proxyReq, nil +} + func handleDockerCredentialsRequest(ctx context.Context, writer http.ResponseWriter, request *http.Request, client tunnel.TunnelClient, log log.Logger) error { out, err := io.ReadAll(request.Body) if err != nil { diff --git a/pkg/credentials/start.go b/pkg/credentials/start.go index b480a3686..424d7924a 100644 --- a/pkg/credentials/start.go +++ b/pkg/credentials/start.go @@ -23,7 +23,7 @@ func StartCredentialsServer(ctx context.Context, cancel context.CancelFunc, clie go func() { defer cancel() - err := RunCredentialsServer(ctx, port, client, log) + err := RunCredentialsServer(ctx, port, client, "", log) if err != nil { log.Errorf("Error running git credentials server: %v", err) } diff --git a/pkg/download/download.go b/pkg/download/download.go index e755185e3..a14fecd46 100644 --- a/pkg/download/download.go +++ b/pkg/download/download.go @@ -55,7 +55,7 @@ func File(rawURL string, log log.Logger) (io.ReadCloser, error) { Protocol: parsed.Scheme, Host: parsed.Host, Path: parsed.Path, - }, "", "") + }) if err == nil && credentials != nil && credentials.Password != "" { log.Debugf("Make request with credentials") return downloadGithubRelease(org, repo, release, file, credentials.Password) diff --git a/pkg/git/clone.go b/pkg/git/clone.go index 49628fa26..987eeef8c 100644 --- a/pkg/git/clone.go +++ b/pkg/git/clone.go @@ -133,7 +133,7 @@ func run(ctx context.Context, args []string, extraEnv []string, log log.Logger) gitCommand.Stderr = &buf gitCommand.Env = append(gitCommand.Env, extraEnv...) - // git always prints prograss output to stderr, + // git always prints progress output to stderr, // we need to check the exit code to decide where the logs should go if err := gitCommand.Run(); err != nil { // report as error diff --git a/pkg/gitcredentials/gitcredentials.go b/pkg/gitcredentials/gitcredentials.go index 36c059da4..79ecd46db 100644 --- a/pkg/gitcredentials/gitcredentials.go +++ b/pkg/gitcredentials/gitcredentials.go @@ -194,15 +194,7 @@ func GetUser(userName string) (*GitUser, error) { return gitUser, nil } -func GetCredentials(requestObj *GitCredentials, username string, token string) (*GitCredentials, error) { - if username != "" && token != "" { - // we have a token and username, use that - requestObj.Password = token - requestObj.Username = username - - return requestObj, nil - } - +func GetCredentials(requestObj *GitCredentials) (*GitCredentials, error) { // run in git helper mode if we have a port gitHelperPort := os.Getenv("DEVPOD_GIT_HELPER_PORT") if gitHelperPort != "" { diff --git a/pkg/loftconfig/client.go b/pkg/loftconfig/client.go index 32a8b81ee..6798a5aac 100644 --- a/pkg/loftconfig/client.go +++ b/pkg/loftconfig/client.go @@ -3,15 +3,27 @@ package loftconfig import ( "bytes" "encoding/json" + "errors" "io" "net/http" "strconv" + "syscall" + "time" devpodhttp "github.com/loft-sh/devpod/pkg/http" "github.com/loft-sh/devpod/pkg/loft/client" "github.com/loft-sh/log" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/util/retry" ) +var backoff = wait.Backoff{ + Steps: 4, + Duration: 300 * time.Millisecond, + Factor: 1, + Jitter: 0.1, +} + func GetLoftConfig(context, provider string, port int, logger log.Logger) (*client.Config, error) { request := &LoftConfigRequest{ Context: context, @@ -24,34 +36,44 @@ func GetLoftConfig(context, provider string, port int, logger log.Logger) (*clie return nil, err } - response, err := devpodhttp.GetHTTPClient().Post( - "http://localhost:"+strconv.Itoa(port)+"/loft-platform-credentials", - "application/json", - bytes.NewReader(rawJson), - ) - if err != nil { - logger.Errorf("Error retrieving credentials: %v", err) - return nil, err - } - defer response.Body.Close() + configResponse := &LoftConfigResponse{} + err = retry.OnError(backoff, func(err error) bool { + // connection refused is recoverable + return errors.Is(err, syscall.ECONNREFUSED) + }, func() error { + response, err := devpodhttp.GetHTTPClient().Post( + "http://localhost:"+strconv.Itoa(port)+"/loft-platform-credentials", + "application/json", + bytes.NewReader(rawJson), + ) + if err != nil { + logger.Errorf("Error retrieving credentials: %v", err) + return err + } + defer response.Body.Close() - raw, err := io.ReadAll(response.Body) - if err != nil { - logger.Errorf("Error reading loft config: %w", err) - return nil, err - } + raw, err := io.ReadAll(response.Body) + if err != nil { + logger.Errorf("Error reading loft config: %w", err) + return err + } - // has the request succeeded? - if response.StatusCode != http.StatusOK { - logger.Errorf("Error reading loft config (%d): %w", response.StatusCode, string(raw)) - return nil, err - } + // has the request succeeded? + if response.StatusCode != http.StatusOK { + logger.Errorf("Error reading loft config (%d): %w", response.StatusCode, string(raw)) + return err + } - configResponse := &LoftConfigResponse{} - err = json.Unmarshal(raw, configResponse) + err = json.Unmarshal(raw, configResponse) + if err != nil { + logger.Errorf("Error decoding loft config: %s %w", string(raw), err) + return nil + } + + return nil + }) if err != nil { - logger.Errorf("Error decoding loft config: %s %w", string(raw), err) - return nil, nil + return nil, err } return configResponse.LoftConfig, nil diff --git a/pkg/types/streams.go b/pkg/types/streams.go new file mode 100644 index 000000000..70c114468 --- /dev/null +++ b/pkg/types/streams.go @@ -0,0 +1,9 @@ +package types + +import "io" + +type Streams struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +}