diff --git a/cmd/agent/docker_credentials.go b/cmd/agent/docker_credentials.go index bd9b00eb9..4362214cb 100644 --- a/cmd/agent/docker_credentials.go +++ b/cmd/agent/docker_credentials.go @@ -6,14 +6,19 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "os" + "path/filepath" "strconv" "strings" + "time" + "github.com/loft-sh/devpod/cmd/agent/container" "github.com/loft-sh/devpod/cmd/flags" "github.com/loft-sh/devpod/pkg/dockercredentials" devpodhttp "github.com/loft-sh/devpod/pkg/http" + "github.com/loft-sh/devpod/pkg/ts" "github.com/loft-sh/log" "github.com/spf13/cobra" ) @@ -111,6 +116,17 @@ func (cmd *DockerCredentialsCmd) handleGet(log log.Logger) error { return fmt.Errorf("no credentials server URL") } + credentials := getDockerCredentialsFromWorkspaceServer(&dockercredentials.Credentials{ServerURL: strings.TrimSpace(string(url))}) + if credentials != nil { + raw, err := json.Marshal(credentials) + if err != nil { + log.Errorf("Error encoding credentials: %v", err) + return nil + } + fmt.Print(string(raw)) + return nil + } + rawJSON, err := json.Marshal(&dockercredentials.Request{ServerURL: strings.TrimSpace(string(url))}) if err != nil { return err @@ -146,3 +162,65 @@ func (cmd *DockerCredentialsCmd) handleGet(log log.Logger) error { fmt.Print(string(raw)) return nil } + +func getDockerCredentialsFromWorkspaceServer(credentials *dockercredentials.Credentials) *dockercredentials.Credentials { + if _, err := os.Stat(filepath.Join(container.RootDir, ts.RunnerProxySocket)); err != nil { + // workspace server is not running + return nil + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", filepath.Join(container.RootDir, ts.RunnerProxySocket)) + }, + }, + Timeout: 15 * time.Second, + } + + credentials, credentialsErr := requestDockerCredentials(httpClient, credentials, "http://runner-proxy/docker-credentials") + if credentialsErr != nil { + // append error to /var/devpod/docker-credentials.log + file, err := os.OpenFile("/var/devpod/docker-credentials-error.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil + } + defer file.Close() + + _, _ = file.WriteString(fmt.Sprintf("get credentials from workspace server: %v\n", credentialsErr)) + return nil + } + + return credentials +} + +func requestDockerCredentials(httpClient *http.Client, credentials *dockercredentials.Credentials, url string) (*dockercredentials.Credentials, error) { + rawJSON, err := json.Marshal(credentials) + if err != nil { + return nil, fmt.Errorf("error marshalling credentials: %w", err) + } + + response, err := httpClient.Post(url, "application/json", bytes.NewReader(rawJSON)) + if err != nil { + return nil, fmt.Errorf("error retrieving credentials from credentials server: %w", err) + } + defer response.Body.Close() + + raw, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("error reading credentials: %w", err) + } + + // has the request succeeded? + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error reading credentials (%d): %s", response.StatusCode, string(raw)) + } + + credentials = &dockercredentials.Credentials{} + err = json.Unmarshal(raw, credentials) + if err != nil { + return nil, fmt.Errorf("error decoding credentials: %w", err) + } + + return credentials, nil +} diff --git a/pkg/daemon/platform/local_server.go b/pkg/daemon/platform/local_server.go index 264fba9c6..988f24126 100644 --- a/pkg/daemon/platform/local_server.go +++ b/pkg/daemon/platform/local_server.go @@ -13,6 +13,7 @@ import ( "github.com/gorilla/handlers" "github.com/julienschmidt/httprouter" managementv1 "github.com/loft-sh/api/v4/pkg/apis/management/v1" + "github.com/loft-sh/devpod/pkg/dockercredentials" "github.com/loft-sh/devpod/pkg/gitcredentials" "github.com/loft-sh/devpod/pkg/platform" platformclient "github.com/loft-sh/devpod/pkg/platform/client" @@ -21,6 +22,7 @@ import ( "github.com/loft-sh/log" "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" "tailscale.com/client/tailscale" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" @@ -82,6 +84,7 @@ var ( routeGetUserProfile = "/user-profile" routeUpdateUserProfile = "/update-user-profile" routeGitCredentials = "/git-credentials" + routeDockerCredentials = "/docker-credentials" ) func newLocalServer(lc *tailscale.LocalClient, pc platformclient.Client, devPodContext string, log log.Logger) (*localServer, error) { @@ -116,6 +119,7 @@ func newLocalServer(lc *tailscale.LocalClient, pc platformclient.Client, devPodC router.GET(routeGetUserProfile, l.userProfile) router.POST(routeUpdateUserProfile, l.updateUserProfile) router.GET(routeGitCredentials, l.getGitCredentials) + router.GET(routeDockerCredentials, l.getDockerCredentials) handler := handlers.LoggingHandler(log.Writer(logrus.DebugLevel, true), router) handler = handlers.RecoveryHandler(handlers.RecoveryLogger(panicLogger{log: l.log}), handlers.PrintRecoveryStack(true))(handler) @@ -612,6 +616,27 @@ func (l *localServer) getGitCredentials(w http.ResponseWriter, r *http.Request, tryJSON(w, credentials) } +func (l *localServer) getDockerCredentials(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + host := r.URL.Query().Get("server") + if host == "" { + http.Error(w, "missing required query parameter \"server\"", http.StatusBadRequest) + return + } + + all, err := dockercredentials.ListCredentials() + if err != nil { + klog.Errorf("failed to list docker credentials: %v", err) + http.Error(w, fmt.Errorf("list docker credentials: %w", err).Error(), http.StatusInternalServerError) + return + } + for registry, cred := range all.Registries { + if registry == host { + tryJSON(w, cred) + return + } + } +} + func tryJSON(w http.ResponseWriter, obj interface{}) { out, err := json.Marshal(obj) if err != nil { diff --git a/pkg/ts/workspace_server.go b/pkg/ts/workspace_server.go index 52bd4f37b..1391737b4 100644 --- a/pkg/ts/workspace_server.go +++ b/pkg/ts/workspace_server.go @@ -245,6 +245,18 @@ func (s *WorkspaceServer) startListeners(ctx context.Context, projectName, works } }() + // Setup HTTP handler for docker credentials. + go func() { + mux := http.NewServeMux() + transport := &http.Transport{DialContext: s.tsServer.Dial} + mux.HandleFunc("/docker-credentials", func(w http.ResponseWriter, r *http.Request) { + s.dockerCredentialsHandler(w, r, lc, transport, projectName, workspaceName) + }) + if err := http.Serve(runnerProxyListener, mux); err != nil && err != http.ErrServerClosed { + s.log.Errorf("HTTP runner proxy server error: %v", err) + } + }() + // Setup HTTP handler for port forwarding. go func() { mux := http.NewServeMux() @@ -283,8 +295,7 @@ func (s *WorkspaceServer) removeConnection() { s.connectionCounter-- } -// httpPortForwardHandler is the HTTP reverse proxy handler for workspace. -// It reconstructs the target URL using custom headers and forwards the request. +// gitCredentialsHandler is the handler for git credentials requests for workspace. func (s *WorkspaceServer) gitCredentialsHandler(w http.ResponseWriter, r *http.Request, lc *tailscale.LocalClient, transport *http.Transport, projectName, workspaceName string) { s.log.Infof("Received git credentials request from %s", r.RemoteAddr) @@ -315,6 +326,37 @@ func (s *WorkspaceServer) gitCredentialsHandler(w http.ResponseWriter, r *http.R proxy.ServeHTTP(w, r) } +// dockerCredentialsHandler is the handler for docker credentials requests for workspace. +func (s *WorkspaceServer) dockerCredentialsHandler(w http.ResponseWriter, r *http.Request, lc *tailscale.LocalClient, transport *http.Transport, projectName, workspaceName string) { + s.log.Infof("Received docker credentials request from %s", r.RemoteAddr) + + // create a new http client with a custom transport + discoveredRunner, err := s.discoverRunner(r.Context(), lc) + if err != nil { + http.Error(w, "failed to discover runner", http.StatusInternalServerError) + return + } + + // build the runner URL + runnerURL := fmt.Sprintf("http://%s.ts.loft/devpod/%s/%s/workspace-docker-credentials", discoveredRunner, projectName, workspaceName) + parsedURL, err := url.Parse(runnerURL) + if err != nil { + http.Error(w, "failed to parse runner URL", http.StatusInternalServerError) + return + } + + // Build the reverse proxy with a custom Director. + proxy := httputil.NewSingleHostReverseProxy(parsedURL) + proxy.Director = func(req *http.Request) { + dest := *parsedURL + req.URL = &dest + req.Host = dest.Host + req.Header.Set("Authorization", "Bearer "+s.config.AccessKey) + } + proxy.Transport = transport + proxy.ServeHTTP(w, r) +} + // httpPortForwardHandler is the HTTP reverse proxy handler for workspace. // It reconstructs the target URL using custom headers and forwards the request. func (s *WorkspaceServer) httpPortForwardHandler(w http.ResponseWriter, r *http.Request) {