diff --git a/cmd/agent/container/credentials_server.go b/cmd/agent/container/credentials_server.go index fd0c6228e..eed9aacd5 100644 --- a/cmd/agent/container/credentials_server.go +++ b/cmd/agent/container/credentials_server.go @@ -19,16 +19,22 @@ import ( "github.com/loft-sh/devpod/pkg/netstat" portpkg "github.com/loft-sh/devpod/pkg/port" "github.com/loft-sh/log" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) -const ExitCodeIO int = 64 +const ( + ExitCodeIO int = 64 + DefaultLogFile string = "/var/devpod/credentials-server.log" +) // CredentialsServerCmd holds the cmd flags type CredentialsServerCmd struct { *flags.GlobalFlags - User string + User string + Client string + Port int ConfigureGitHelper bool ConfigureDockerHelper bool @@ -61,16 +67,32 @@ 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().StringVar(&cmd.Client, "client", "", "client host") + credentialsServerCmd.Flags().IntVar(&cmd.Port, "port", 0, "port of credentials server running locally on client machine to connect to") return credentialsServerCmd } // Run runs the command logic func (cmd *CredentialsServerCmd) Run(ctx context.Context, port int) error { + var tunnelClient tunnel.TunnelClient + var err error + fileLogger := log.NewFileLogger(DefaultLogFile, logrus.DebugLevel) + // 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) + // if we have client address, lets use the http client + if cmd.Client != "" { + tunnelClient, err = tunnelserver.NewHTTPTunnelClient( + cmd.Client, fmt.Sprintf("%d", cmd.Port), fileLogger) + if err != nil { + return fmt.Errorf("error creating tunnel client: %w", err) + } + } else { + // otherwise we fallback to stdio 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 @@ -148,7 +170,7 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, port int) error { }(cmd.User) } - return credentials.RunCredentialsServer(ctx, port, tunnelClient, log) + return credentials.RunCredentialsServer(ctx, port, tunnelClient, cmd.Client, log) } func configureGitUserLocally(ctx context.Context, userName string, client tunnel.TunnelClient) error { diff --git a/cmd/agent/container/daemon.go b/cmd/agent/container/daemon.go index 61feaa6b9..69ff77e25 100644 --- a/cmd/agent/container/daemon.go +++ b/cmd/agent/container/daemon.go @@ -1,311 +1,19 @@ package container import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "os/exec" - "os/signal" - "strings" - "sync" - "syscall" - "time" - - "github.com/loft-sh/devpod/pkg/agent" - agentd "github.com/loft-sh/devpod/pkg/daemon/agent" - "github.com/loft-sh/devpod/pkg/devcontainer/config" - "github.com/loft-sh/devpod/pkg/platform/client" - "github.com/loft-sh/devpod/pkg/ts" - "github.com/loft-sh/log" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" + workspaced "github.com/loft-sh/devpod/pkg/daemon/workspace" "github.com/spf13/cobra" ) -const ( - RootDir = "/var/devpod" - DaemonConfigPath = "/var/run/secrets/devpod/daemon_config" -) - -type DaemonCmd struct { - Config *agentd.DaemonConfig - Log log.Logger -} - -// NewDaemonCmd creates the merged daemon command. +// NewDaemonCmd creates the daemon cobra command. func NewDaemonCmd() *cobra.Command { - cmd := &DaemonCmd{ - Config: &agentd.DaemonConfig{}, - Log: log.NewStreamLogger(os.Stdout, os.Stderr, logrus.InfoLevel), - } - daemonCmd := &cobra.Command{ + d := workspaced.NewDaemon() + cmd := &cobra.Command{ Use: "daemon", Short: "Starts the DevPod network daemon, SSH server and monitors container activity if timeout is set", Args: cobra.NoArgs, - RunE: cmd.Run, - } - daemonCmd.Flags().StringVar(&cmd.Config.Timeout, "timeout", "", "The timeout to stop the container after") - return daemonCmd -} - -func (cmd *DaemonCmd) Run(c *cobra.Command, args []string) error { - ctx := c.Context() - errChan := make(chan error, 4) - var wg sync.WaitGroup - - if err := cmd.loadConfig(); err != nil { - return err - } - - // Prepare timeout if specified. - var timeoutDuration time.Duration - if cmd.Config.Timeout != "" { - var err error - timeoutDuration, err = time.ParseDuration(cmd.Config.Timeout) - if err != nil { - return errors.Wrap(err, "failed to parse timeout duration") - } - if timeoutDuration > 0 { - if err := setupActivityFile(); err != nil { - return err - } - } - } - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - var tasksStarted bool - - // Start process reaper. - if os.Getpid() == 1 { - wg.Add(1) - go runReaper(ctx, errChan, &wg) - } - - // Start Tailscale networking server. - if cmd.shouldRunNetworkServer() { - tasksStarted = true - wg.Add(1) - go runNetworkServer(ctx, cmd, errChan, &wg) - } - - // Start timeout monitor. - if timeoutDuration > 0 { - tasksStarted = true - wg.Add(1) - go runTimeoutMonitor(ctx, timeoutDuration, errChan, &wg) - } - - // Start ssh server. - if cmd.shouldRunSsh() { - tasksStarted = true - wg.Add(1) - go runSshServer(ctx, cmd, errChan, &wg) - } - - // In case no task is configured, just wait indefinitely. - if !tasksStarted { - wg.Add(1) - go func() { - defer wg.Done() - <-ctx.Done() - }() - } - - // Listen for OS termination signals. - go handleSignals(ctx, errChan) - - // Wait until an error (or termination signal) occurs. - err := <-errChan - cancel() - wg.Wait() - - if err != nil { - cmd.Log.Errorf("Daemon error: %v", err) - os.Exit(1) - } - os.Exit(0) - return nil // Unreachable but needed. -} - -// loadConfig loads the daemon configuration from base64-encoded JSON. -// If a CLI-provided timeout exists, it will override the timeout in the config. -func (cmd *DaemonCmd) loadConfig() error { - // check local file - encodedCfg := "" - configBytes, err := os.ReadFile(DaemonConfigPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - // check environment variable - encodedCfg = os.Getenv(config.WorkspaceDaemonConfigExtraEnvVar) - } else { - return fmt.Errorf("get daemon config file %s: %w", DaemonConfigPath, err) - } - } else { - encodedCfg = string(configBytes) - } - - if strings.TrimSpace(encodedCfg) != "" { - decoded, err := base64.StdEncoding.DecodeString(encodedCfg) - if err != nil { - return fmt.Errorf("error decoding daemon config: %w", err) - } - var cfg agentd.DaemonConfig - if err = json.Unmarshal(decoded, &cfg); err != nil { - return fmt.Errorf("error unmarshalling daemon config: %w", err) - } - if cmd.Config.Timeout != "" { - cfg.Timeout = cmd.Config.Timeout - } - cmd.Config = &cfg - } - - return nil -} - -// shouldRunNetworkServer returns true if the required platform parameters are present. -func (cmd *DaemonCmd) shouldRunNetworkServer() bool { - return cmd.Config.Platform.AccessKey != "" && - cmd.Config.Platform.PlatformHost != "" && - cmd.Config.Platform.WorkspaceHost != "" -} - -// shouldRunSsh returns true if at least one SSH configuration value is provided. -func (cmd *DaemonCmd) shouldRunSsh() bool { - return cmd.Config.Ssh.Workdir != "" || cmd.Config.Ssh.User != "" -} - -// setupActivityFile creates and sets permissions on the container activity file. -func setupActivityFile() error { - if err := os.WriteFile(agent.ContainerActivityFile, nil, 0777); err != nil { - return err - } - return os.Chmod(agent.ContainerActivityFile, 0777) -} - -// runReaper starts the process reaper and waits for context cancellation. -func runReaper(ctx context.Context, errChan chan<- error, wg *sync.WaitGroup) { - defer wg.Done() - agentd.RunProcessReaper() - <-ctx.Done() -} - -// runTimeoutMonitor monitors the activity file and signals an error if the timeout is exceeded. -func runTimeoutMonitor(ctx context.Context, duration time.Duration, errChan chan<- error, wg *sync.WaitGroup) { - defer wg.Done() - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - stat, err := os.Stat(agent.ContainerActivityFile) - if err != nil { - continue - } - if !stat.ModTime().Add(duration).After(time.Now()) { - errChan <- errors.New("timeout reached, terminating daemon") - return - } - } - } -} - -// runNetworkServer starts the network server. -func runNetworkServer(ctx context.Context, cmd *DaemonCmd, errChan chan<- error, wg *sync.WaitGroup) { - defer wg.Done() - if err := os.MkdirAll(RootDir, os.ModePerm); err != nil { - errChan <- err - return - } - logger := initLogging() - config := client.NewConfig() - config.AccessKey = cmd.Config.Platform.AccessKey - config.Host = "https://" + cmd.Config.Platform.PlatformHost - config.Insecure = true - baseClient := client.NewClientFromConfig(config) - if err := baseClient.RefreshSelf(ctx); err != nil { - errChan <- fmt.Errorf("failed to refresh client: %w", err) - return - } - tsServer := ts.NewWorkspaceServer(&ts.WorkspaceServerConfig{ - AccessKey: cmd.Config.Platform.AccessKey, - PlatformHost: ts.RemoveProtocol(cmd.Config.Platform.PlatformHost), - WorkspaceHost: cmd.Config.Platform.WorkspaceHost, - Client: baseClient, - RootDir: RootDir, - LogF: func(format string, args ...interface{}) { - logger.Infof(format, args...) - }, - }, logger) - if err := tsServer.Start(ctx); err != nil { - errChan <- fmt.Errorf("network server: %w", err) + RunE: d.Run, } -} - -// runSshServer starts the SSH server. -func runSshServer(ctx context.Context, cmd *DaemonCmd, errChan chan<- error, wg *sync.WaitGroup) { - defer wg.Done() - binaryPath, err := os.Executable() - if err != nil { - errChan <- err - return - } - - args := []string{"agent", "container", "ssh-server"} - if cmd.Config.Ssh.Workdir != "" { - args = append(args, "--workdir", cmd.Config.Ssh.Workdir) - } - if cmd.Config.Ssh.User != "" { - args = append(args, "--remote-user", cmd.Config.Ssh.User) - } - - sshCmd := exec.Command(binaryPath, args...) - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - - if err := sshCmd.Start(); err != nil { - errChan <- fmt.Errorf("failed to start SSH server: %w", err) - return - } - - done := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - if sshCmd.Process != nil { - if err := sshCmd.Process.Signal(syscall.SIGTERM); err != nil { - errChan <- fmt.Errorf("failed to send SIGTERM to SSH server: %w", err) - } - } - case <-done: - } - }() - - if err := sshCmd.Wait(); err != nil { - errChan <- fmt.Errorf("SSH server exited abnormally: %w", err) - close(done) - return - } - close(done) -} - -// handleSignals listens for OS termination signals and sends an error through errChan. -func handleSignals(ctx context.Context, errChan chan<- error) { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - select { - case sig := <-sigChan: - errChan <- fmt.Errorf("received signal: %v", sig) - case <-ctx.Done(): - } -} - -// initLogging initializes logging and returns a combined logger. -func initLogging() log.Logger { - return log.NewStdoutLogger(nil, os.Stdout, os.Stderr, logrus.InfoLevel) + cmd.Flags().StringVar(&d.Config.Timeout, "timeout", "", "The timeout to stop the container after") + return cmd } diff --git a/cmd/agent/git_credentials.go b/cmd/agent/git_credentials.go index 19619f381..7f36d5304 100644 --- a/cmd/agent/git_credentials.go +++ b/cmd/agent/git_credentials.go @@ -12,11 +12,11 @@ import ( "path/filepath" "strconv" - "github.com/loft-sh/devpod/cmd/agent/container" "github.com/loft-sh/devpod/cmd/flags" + workspaced "github.com/loft-sh/devpod/pkg/daemon/workspace" + "github.com/loft-sh/devpod/pkg/daemon/workspace/network" "github.com/loft-sh/devpod/pkg/gitcredentials" devpodhttp "github.com/loft-sh/devpod/pkg/http" - "github.com/loft-sh/devpod/pkg/ts" "github.com/loft-sh/log" "github.com/spf13/cobra" ) @@ -79,7 +79,7 @@ func (cmd *GitCredentialsCmd) Run(ctx context.Context, args []string, log log.Lo } func getCredentialsFromWorkspaceServer(credentials *gitcredentials.GitCredentials) *gitcredentials.GitCredentials { - if _, err := os.Stat(filepath.Join(container.RootDir, ts.RunnerProxySocket)); err != nil { + if _, err := os.Stat(filepath.Join(workspaced.RootDir, network.RunnerProxySocket)); err != nil { // workspace server is not running return nil } @@ -87,7 +87,7 @@ func getCredentialsFromWorkspaceServer(credentials *gitcredentials.GitCredential httpClient := &http.Client{ Transport: &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", filepath.Join(container.RootDir, ts.RunnerProxySocket)) + return net.Dial("unix", filepath.Join(workspaced.RootDir, network.RunnerProxySocket)) }, }, } diff --git a/cmd/agent/workspace/delete.go b/cmd/agent/workspace/delete.go index 8cf6467e3..9824ebaae 100644 --- a/cmd/agent/workspace/delete.go +++ b/cmd/agent/workspace/delete.go @@ -7,7 +7,7 @@ import ( "github.com/loft-sh/devpod/cmd/flags" "github.com/loft-sh/devpod/pkg/agent" - agentdaemon "github.com/loft-sh/devpod/pkg/daemon/agent" + workspaced "github.com/loft-sh/devpod/pkg/daemon/workspace" provider2 "github.com/loft-sh/devpod/pkg/provider" "github.com/loft-sh/log" "github.com/pkg/errors" @@ -101,7 +101,7 @@ func removeDaemon(workspaceInfo *provider2.AgentWorkspaceInfo, log log.Logger) e } log.Debugf("Removing DevPod daemon from server...") - err := agentdaemon.RemoveDaemon() + err := workspaced.RemoveDaemon() if err != nil { return errors.Wrap(err, "remove daemon") } diff --git a/cmd/agent/workspace/up.go b/cmd/agent/workspace/up.go index 60c3a2e0f..2c6c55d23 100644 --- a/cmd/agent/workspace/up.go +++ b/cmd/agent/workspace/up.go @@ -17,7 +17,7 @@ import ( "github.com/loft-sh/devpod/pkg/client/clientimplementation" "github.com/loft-sh/devpod/pkg/command" "github.com/loft-sh/devpod/pkg/credentials" - agentdaemon "github.com/loft-sh/devpod/pkg/daemon/agent" + workspaced "github.com/loft-sh/devpod/pkg/daemon/workspace" "github.com/loft-sh/devpod/pkg/devcontainer" config2 "github.com/loft-sh/devpod/pkg/devcontainer/config" "github.com/loft-sh/devpod/pkg/devcontainer/crane" @@ -396,7 +396,7 @@ func installDaemon(workspaceInfo *provider2.AgentWorkspaceInfo, log log.Logger) } log.Debugf("Installing DevPod daemon into server...") - err := agentdaemon.InstallDaemon(workspaceInfo.Agent.DataPath, workspaceInfo.CLIOptions.DaemonInterval, log) + err := workspaced.InstallDaemon(workspaceInfo.Agent.DataPath, workspaceInfo.CLIOptions.DaemonInterval, log) if err != nil { return errors.Wrap(err, "install daemon") } diff --git a/cmd/build.go b/cmd/build.go index cba5e7924..21b5daf5f 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -14,6 +14,7 @@ import ( config2 "github.com/loft-sh/devpod/pkg/devcontainer/config" "github.com/loft-sh/devpod/pkg/image" "github.com/loft-sh/devpod/pkg/provider" + "github.com/loft-sh/devpod/pkg/stdio" workspace2 "github.com/loft-sh/devpod/pkg/workspace" "github.com/loft-sh/log" "github.com/pkg/errors" @@ -229,8 +230,7 @@ func buildAgentClient(ctx context.Context, workspaceClient client.WorkspaceClien // create container etc. result, err := tunnelserver.RunUpServer( cancelCtx, - stdoutReader, - stdinWriter, + stdio.NewStdioListener(stdoutReader, stdinWriter, false), workspaceClient.AgentInjectGitCredentials(cliOptions), workspaceClient.AgentInjectDockerCredentials(cliOptions), workspaceClient.WorkspaceConfig(), diff --git a/cmd/helper/helper.go b/cmd/helper/helper.go index 6461faa5f..608658976 100644 --- a/cmd/helper/helper.go +++ b/cmd/helper/helper.go @@ -35,5 +35,6 @@ func NewHelperCmd(globalFlags *flags.GlobalFlags) *cobra.Command { helperCmd.AddCommand(NewFleetServerCmd(globalFlags)) helperCmd.AddCommand(NewDockerCredentialsHelperCmd(globalFlags)) helperCmd.AddCommand(NewGetImageCmd(globalFlags)) + helperCmd.AddCommand(requestCmd) return helperCmd } diff --git a/cmd/helper/request.go b/cmd/helper/request.go new file mode 100644 index 000000000..dbafc3593 --- /dev/null +++ b/cmd/helper/request.go @@ -0,0 +1,36 @@ +package helper + +import ( + "fmt" + "io" + "log" + + "github.com/loft-sh/devpod/pkg/daemon/workspace/network" + "github.com/spf13/cobra" +) + +var requestCmd = &cobra.Command{ + Use: "request [path]", + Short: "Send an HTTP request to the specified path via the DevPod network", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := args[0] + + client := network.GetHTTPClient() + + url := fmt.Sprintf("http://%s", path) + log.Printf("Sending request to %s via DevPod network", url) + + resp, err := client.Get(url) + if err != nil { + log.Fatalf("HTTP request error: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Error reading response: %v", err) + } + fmt.Printf("Response:\n%s\n", body) + }, +} diff --git a/cmd/pro/daemon/netcheck.go b/cmd/pro/daemon/netcheck.go index f7f193b44..ddc6c2f28 100644 --- a/cmd/pro/daemon/netcheck.go +++ b/cmd/pro/daemon/netcheck.go @@ -9,7 +9,7 @@ import ( "github.com/loft-sh/devpod/cmd/pro/completion" proflags "github.com/loft-sh/devpod/cmd/pro/flags" "github.com/loft-sh/devpod/pkg/config" - daemon "github.com/loft-sh/devpod/pkg/daemon/platform" + daemon "github.com/loft-sh/devpod/pkg/daemon/local" providerpkg "github.com/loft-sh/devpod/pkg/provider" "github.com/loft-sh/log" "github.com/spf13/cobra" diff --git a/cmd/pro/daemon/start.go b/cmd/pro/daemon/start.go index 628b2aecb..69276cfdd 100644 --- a/cmd/pro/daemon/start.go +++ b/cmd/pro/daemon/start.go @@ -10,7 +10,7 @@ import ( "syscall" managementv1 "github.com/loft-sh/api/v4/pkg/apis/management/v1" - daemon "github.com/loft-sh/devpod/pkg/daemon/platform" + daemon "github.com/loft-sh/devpod/pkg/daemon/local" "github.com/loft-sh/devpod/pkg/platform/client" "github.com/loft-sh/devpod/cmd/pro/completion" diff --git a/cmd/pro/daemon/status.go b/cmd/pro/daemon/status.go index c93a67327..02f1637b9 100644 --- a/cmd/pro/daemon/status.go +++ b/cmd/pro/daemon/status.go @@ -5,12 +5,11 @@ import ( "encoding/json" "fmt" - platformdaemon "github.com/loft-sh/devpod/pkg/daemon/platform" - "github.com/loft-sh/devpod/cmd/agent" "github.com/loft-sh/devpod/cmd/pro/completion" proflags "github.com/loft-sh/devpod/cmd/pro/flags" "github.com/loft-sh/devpod/pkg/config" + locald "github.com/loft-sh/devpod/pkg/daemon/local" providerpkg "github.com/loft-sh/devpod/pkg/provider" "github.com/loft-sh/log" "github.com/spf13/cobra" @@ -64,7 +63,7 @@ func NewStatusCmd(flags *proflags.GlobalFlags) *cobra.Command { } func (cmd *StatusCmd) Run(ctx context.Context, devPodConfig *config.Config, provider *providerpkg.ProviderConfig) error { - status, err := platformdaemon.NewLocalClient(provider.Name).Status(ctx, cmd.Debug) + status, err := locald.NewLocalClient(provider.Name).Status(ctx, cmd.Debug) if err != nil { return err } diff --git a/cmd/pro/delete.go b/cmd/pro/delete.go index c5e9ef2b1..cd52a7dac 100644 --- a/cmd/pro/delete.go +++ b/cmd/pro/delete.go @@ -11,7 +11,7 @@ import ( providercmd "github.com/loft-sh/devpod/cmd/provider" "github.com/loft-sh/devpod/pkg/client/clientimplementation" "github.com/loft-sh/devpod/pkg/config" - daemon "github.com/loft-sh/devpod/pkg/daemon/platform" + daemon "github.com/loft-sh/devpod/pkg/daemon/local" "github.com/loft-sh/devpod/pkg/platform" "github.com/loft-sh/devpod/pkg/provider" "github.com/loft-sh/devpod/pkg/workspace" diff --git a/cmd/ssh.go b/cmd/ssh.go index 9083add28..1cb7baaab 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -17,7 +17,7 @@ import ( "github.com/loft-sh/devpod/pkg/agent" client2 "github.com/loft-sh/devpod/pkg/client" "github.com/loft-sh/devpod/pkg/config" - daemon "github.com/loft-sh/devpod/pkg/daemon/platform" + daemon "github.com/loft-sh/devpod/pkg/daemon/local" "github.com/loft-sh/devpod/pkg/gpg" "github.com/loft-sh/devpod/pkg/port" "github.com/loft-sh/devpod/pkg/provider" @@ -559,6 +559,7 @@ func (cmd *SSHCmd) startServices( configureDockerCredentials, configureGitCredentials, configureGitSSHSignatureHelper, + "", log, ) if err != nil { @@ -670,6 +671,10 @@ func startServicesDaemon(ctx context.Context, devPodConfig *config.Config, clien if err != nil { return err } + clientHost, err := client.GetClientAddress(ctx) + if err != nil { + return err + } configureDockerCredentials := devPodConfig.ContextOption(config.ContextOptionSSHInjectDockerCredentials) == "true" configureGitCredentials := devPodConfig.ContextOption(config.ContextOptionSSHInjectGitCredentials) == "true" @@ -698,6 +703,7 @@ func startServicesDaemon(ctx context.Context, devPodConfig *config.Config, clien configureDockerCredentials, configureGitCredentials, configureGitSSHSignatureHelper, + clientHost, log, ) } diff --git a/cmd/troubleshoot.go b/cmd/troubleshoot.go index 2e7d7b89f..949e94d45 100644 --- a/cmd/troubleshoot.go +++ b/cmd/troubleshoot.go @@ -12,7 +12,7 @@ import ( "github.com/loft-sh/devpod/cmd/provider" "github.com/loft-sh/devpod/pkg/client" "github.com/loft-sh/devpod/pkg/config" - daemon "github.com/loft-sh/devpod/pkg/daemon/platform" + daemon "github.com/loft-sh/devpod/pkg/daemon/local" "github.com/loft-sh/devpod/pkg/platform" pkgprovider "github.com/loft-sh/devpod/pkg/provider" "github.com/loft-sh/devpod/pkg/version" diff --git a/cmd/up.go b/cmd/up.go index 148a167c2..57760c44d 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -38,6 +38,7 @@ import ( "github.com/loft-sh/devpod/pkg/port" provider2 "github.com/loft-sh/devpod/pkg/provider" devssh "github.com/loft-sh/devpod/pkg/ssh" + "github.com/loft-sh/devpod/pkg/stdio" "github.com/loft-sh/devpod/pkg/telemetry" "github.com/loft-sh/devpod/pkg/tunnel" "github.com/loft-sh/devpod/pkg/util" @@ -451,8 +452,7 @@ func (cmd *UpCmd) devPodUpProxy( // create container etc. result, err := tunnelserver.RunUpServer( cancelCtx, - stdoutReader, - stdinWriter, + stdio.NewStdioListener(stdoutReader, stdinWriter, false), true, true, client.WorkspaceConfig(), @@ -571,8 +571,7 @@ func (cmd *UpCmd) devPodUpMachine( func(ctx context.Context, stdin io.WriteCloser, stdout io.Reader) (*config2.Result, error) { return tunnelserver.RunUpServer( ctx, - stdout, - stdin, + stdio.NewStdioListener(stdout, stdin, false), client.AgentInjectGitCredentials(cmd.CLIOptions), client.AgentInjectDockerCredentials(cmd.CLIOptions), client.WorkspaceConfig(), @@ -962,6 +961,7 @@ func startBrowserTunnel( configureDockerCredentials, configureGitCredentials, configureGitSSHSignatureHelper, + "", logger, ) if err != nil { diff --git a/go.mod b/go.mod index c64264b83..fee0b9747 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/moby/buildkit v0.20.1 github.com/moby/term v0.5.2 + github.com/mwitkow/grpc-proxy v0.0.0-20230212185441-f345521cb9c9 github.com/onsi/ginkgo/v2 v2.21.0 github.com/onsi/gomega v1.35.1 github.com/otiai10/copy v1.7.0 @@ -50,6 +51,7 @@ require ( github.com/rhysd/go-github-selfupdate v1.2.3 github.com/sirupsen/logrus v1.9.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/soheilhy/cmux v0.1.5 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/takama/daemon v1.0.0 diff --git a/go.sum b/go.sum index e7928dfe4..b15dfa47c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo= cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -39,6 +40,7 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -138,6 +140,7 @@ github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= @@ -158,6 +161,8 @@ github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb2 github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= @@ -247,6 +252,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch v5.8.1+incompatible h1:2toJaoe7/rNa1zpeQx0UnVEjqk6z2ecyA20V/zg8vTU= @@ -301,10 +310,22 @@ github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -313,6 +334,12 @@ github.com/google/cel-go v0.22.0 h1:b3FJZxpiv1vTMo2/5RDUqAHPxkT8mmMfJIrq1llbf7g= github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8= github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -334,6 +361,7 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= @@ -422,14 +450,10 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/loft-sh/admin-apis v0.0.0-20250221182517-7499d86167d2 h1:om1MqUdW84ZQc0GMGGgFfPI6xpTbrF+6DwKVq+76R44= github.com/loft-sh/admin-apis v0.0.0-20250221182517-7499d86167d2/go.mod h1:WHCqWfljfD1hkwk41hLeqBhW2yeLvWipB1sH6vfnR7U= -github.com/loft-sh/agentapi/v4 v4.3.0-devpod.alpha.29 h1:AAbhuBMm6ZV3OXSx8VXqCqQ9o/mOl6Hh425qZsGNUqw= -github.com/loft-sh/agentapi/v4 v4.3.0-devpod.alpha.29/go.mod h1:zkceRmB+KkkhDMHUQO1sQdcNfF2EPxl+RsSIEOAN/R8= github.com/loft-sh/agentapi/v4 v4.3.0-devpod.alpha.31 h1://tt9YAWfcCydJ5d991rsYin+KxzqNC3U6S8mEN882o= github.com/loft-sh/agentapi/v4 v4.3.0-devpod.alpha.31/go.mod h1:zkceRmB+KkkhDMHUQO1sQdcNfF2EPxl+RsSIEOAN/R8= github.com/loft-sh/analytics-client v0.0.0-20240219162240-2f4c64b2494e h1:JcPnMaoczikvpasi8OJ47dCkWZjfgFubWa4V2SZo7h0= github.com/loft-sh/analytics-client v0.0.0-20240219162240-2f4c64b2494e/go.mod h1:FFWcGASyM2QlWTDTCG/WBVM/XYr8btqYt335TFNRCFg= -github.com/loft-sh/api/v4 v4.3.0-devpod.alpha.29 h1:0xlQGAMwluhI6UbiDarKT21v5j/AULpxpDADdPzDxxI= -github.com/loft-sh/api/v4 v4.3.0-devpod.alpha.29/go.mod h1:X1ho9aS6qCBMSOtIvRfjVa5htDO5UDlYyDUAKpvtY+k= github.com/loft-sh/api/v4 v4.3.0-devpod.alpha.31 h1:H2k4Qi7zWloBQmD6fepUaZOpzmv6A7WxAWtDOdzrQC0= github.com/loft-sh/api/v4 v4.3.0-devpod.alpha.31/go.mod h1:qBNf6+sIg2XNYdBWe2c16C1gjDcrKZ88sGd76oYT3Jg= github.com/loft-sh/apiserver v0.0.0-20250206205835-422f1d472459 h1:6SrgBtT1S9ANsQMoO/O0Mq+hs9EbC5te5kPqOBfg5UI= @@ -520,6 +544,8 @@ github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54 github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/grpc-proxy v0.0.0-20230212185441-f345521cb9c9 h1:62uLwA3l2JMH84liO4ZhnjTH5PjFyCYxbHLgXPaJMtI= +github.com/mwitkow/grpc-proxy v0.0.0-20230212185441-f345521cb9c9/go.mod h1:MvMXoufZAtqExNexqi4cjrNYE9MefKddKylxjS+//n0= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= @@ -565,6 +591,7 @@ github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyf github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA= @@ -607,6 +634,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -749,25 +777,36 @@ golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= @@ -779,6 +818,7 @@ golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAG golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -786,6 +826,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -796,6 +837,11 @@ golang.org/x/sys v0.0.0-20200722175500-76b94024e4b6/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -834,9 +880,15 @@ golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= @@ -852,14 +904,35 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210401141331-865547bb08e2/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= google.golang.org/genproto/googleapis/api v0.0.0-20241021214115-324edc3d5d38 h1:2oV8dfuIkM1Ti7DwXc0BJfnwr9csz4TDXI9EmiI+Rbw= google.golang.org/genproto/googleapis/api v0.0.0-20241021214115-324edc3d5d38/go.mod h1:vuAjtvlwkDKF6L1GQ0SokiRLCGFfeBUXWr/aFFkHACc= google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI= google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -879,6 +952,7 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -891,6 +965,9 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8= gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= diff --git a/pkg/agent/tunnelserver/client.go b/pkg/agent/tunnelserver/client.go index fa3c9cc12..3b19a76f3 100644 --- a/pkg/agent/tunnelserver/client.go +++ b/pkg/agent/tunnelserver/client.go @@ -2,13 +2,18 @@ package tunnelserver import ( "context" + "fmt" "io" "net" "github.com/loft-sh/devpod/pkg/agent/tunnel" + locald "github.com/loft-sh/devpod/pkg/daemon/local" + "github.com/loft-sh/devpod/pkg/daemon/workspace/network" "github.com/loft-sh/devpod/pkg/stdio" + "github.com/loft-sh/log" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/resolver" ) @@ -34,3 +39,64 @@ func NewTunnelClient(reader io.Reader, writer io.WriteCloser, exitOnClose bool, return c, nil } + +// NewHTTPTunnelClient creates a new gRPC client that connects via the network proxy. +func NewHTTPTunnelClient(targetHost string, targetPort string, log log.Logger) (tunnel.TunnelClient, error) { + resolver.SetDefaultScheme("passthrough") + log.Infof("Starting tunnel client targeting %s:%s", targetHost, targetPort) + + unaryInterceptor := func( + ctx context.Context, + method string, + req, reply interface{}, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + md := metadata.New(map[string]string{ + network.HeaderTargetHost: targetHost, + network.HeaderTargetPort: targetPort, + network.HeaderProxyPort: fmt.Sprintf("%d", locald.DefaultGRPCProxyPort), + }) + + ctx = metadata.NewOutgoingContext(ctx, md) + log.Debugf("Unary interceptor adding metadata: host=%s, port=%s", targetHost, targetPort) + return invoker(ctx, method, req, reply, cc, opts...) + } + + streamInterceptor := func( + ctx context.Context, + desc *grpc.StreamDesc, + cc *grpc.ClientConn, + method string, + streamer grpc.Streamer, + opts ...grpc.CallOption, + ) (grpc.ClientStream, error) { + md := metadata.New(map[string]string{ + network.HeaderTargetHost: targetHost, + network.HeaderTargetPort: targetPort, + network.HeaderProxyPort: fmt.Sprintf("%d", locald.DefaultGRPCProxyPort), + }) + + ctx = metadata.NewOutgoingContext(ctx, md) + log.Debugf("Stream interceptor adding metadata: host=%s, port=%s", targetHost, targetPort) + return streamer(ctx, desc, cc, method, opts...) + } + + target := "passthrough:///proxy-socket-target" // dummy target, our dialer is responsible for using socket + + conn, err := grpc.NewClient(target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(network.GetContextDialer()), + grpc.WithUnaryInterceptor(unaryInterceptor), + grpc.WithStreamInterceptor(streamInterceptor), + ) + if err != nil { + log.Errorf("Failed to create gRPC client connection via proxy: %v", err) + return nil, fmt.Errorf("failed to create gRPC client via proxy: %w", err) + } + + log.Infof("Successfully connected tunnel client via proxy socket") + c := tunnel.NewTunnelClient(conn) + return c, nil +} diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index df040d590..9b1781a85 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net" "os" "path/filepath" "strings" @@ -31,7 +32,27 @@ import ( "google.golang.org/grpc/reflection" ) -func RunServicesServer(ctx context.Context, reader io.Reader, writer io.WriteCloser, allowGitCredentials, allowDockerCredentials bool, forwarder netstat.Forwarder, workspace *provider2.Workspace, log log.Logger, options ...Option) error { +// GetListener returns correct listener for services server - either stdio or tcp +func GetListener(client string, reader io.Reader, writer io.WriteCloser, exitOnClose bool, log log.Logger) (net.Listener, int, error) { + if client == "" { + log.Debug("GetListener - returning stdio listener") + return stdio.NewStdioListener(reader, writer, exitOnClose), 0, nil + } + log.Debug("GetListener - returning tcp listener") + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, 0, err + } + + // Extract the actual TCP port the OS has bound to. + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return nil, 0, fmt.Errorf("listener.Addr() is not a *net.TCPAddr") + } + return listener, tcpAddr.Port, nil +} + +func RunServicesServer(ctx context.Context, lis net.Listener, allowGitCredentials, allowDockerCredentials bool, forwarder netstat.Forwarder, workspace *provider2.Workspace, log log.Logger, options ...Option) error { opts := append(options, []Option{ WithForwarder(forwarder), WithAllowGitCredentials(allowGitCredentials), @@ -40,10 +61,10 @@ func RunServicesServer(ctx context.Context, reader io.Reader, writer io.WriteClo }...) tunnelServ := New(log, opts...) - return tunnelServ.Run(ctx, reader, writer) + return tunnelServ.Run(ctx, lis) } -func RunUpServer(ctx context.Context, reader io.Reader, writer io.WriteCloser, allowGitCredentials, allowDockerCredentials bool, workspace *provider2.Workspace, log log.Logger, options ...Option) (*config.Result, error) { +func RunUpServer(ctx context.Context, lis net.Listener, allowGitCredentials, allowDockerCredentials bool, workspace *provider2.Workspace, log log.Logger, options ...Option) (*config.Result, error) { opts := append(options, []Option{ WithWorkspace(workspace), WithAllowGitCredentials(allowGitCredentials), @@ -51,10 +72,10 @@ func RunUpServer(ctx context.Context, reader io.Reader, writer io.WriteCloser, a }...) tunnelServ := New(log, opts...) - return tunnelServ.RunWithResult(ctx, reader, writer) + return tunnelServ.RunWithResult(ctx, lis) } -func RunSetupServer(ctx context.Context, reader io.Reader, writer io.WriteCloser, allowGitCredentials, allowDockerCredentials bool, mounts []*config.Mount, log log.Logger, options ...Option) (*config.Result, error) { +func RunSetupServer(ctx context.Context, lis net.Listener, allowGitCredentials, allowDockerCredentials bool, mounts []*config.Mount, log log.Logger, options ...Option) (*config.Result, error) { opts := append(options, []Option{ WithMounts(mounts), WithAllowGitCredentials(allowGitCredentials), @@ -64,7 +85,7 @@ func RunSetupServer(ctx context.Context, reader io.Reader, writer io.WriteCloser tunnelServ := New(log, opts...) tunnelServ.allowPlatformOptions = true - return tunnelServ.RunWithResult(ctx, reader, writer) + return tunnelServ.RunWithResult(ctx, lis) } func New(log log.Logger, options ...Option) *tunnelServer { @@ -96,8 +117,7 @@ type tunnelServer struct { platformOptions *devpod.PlatformOptions } -func (t *tunnelServer) RunWithResult(ctx context.Context, reader io.Reader, writer io.WriteCloser) (*config.Result, error) { - lis := stdio.NewStdioListener(reader, writer, false) +func (t *tunnelServer) RunWithResult(ctx context.Context, lis net.Listener) (*config.Result, error) { s := grpc.NewServer() tunnel.RegisterTunnelServer(s, t) reflection.Register(s) @@ -114,8 +134,8 @@ func (t *tunnelServer) RunWithResult(ctx context.Context, reader io.Reader, writ } } -func (t *tunnelServer) Run(ctx context.Context, reader io.Reader, writer io.WriteCloser) error { - _, err := t.RunWithResult(ctx, reader, writer) +func (t *tunnelServer) Run(ctx context.Context, lis net.Listener) error { + _, err := t.RunWithResult(ctx, lis) return err } diff --git a/pkg/client/client.go b/pkg/client/client.go index 5f9b36665..e9e533474 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -89,6 +89,9 @@ type DaemonClient interface { // Ping tries to ping a workspace and prints results to stdout Ping(ctx context.Context, stdout io.Writer) error + + // GetClientAddress returns dns address of client. + GetClientAddress(ctx context.Context) (string, error) } type MachineClient interface { diff --git a/pkg/client/clientimplementation/daemonclient/client.go b/pkg/client/clientimplementation/daemonclient/client.go index 309828a1b..1957c799d 100644 --- a/pkg/client/clientimplementation/daemonclient/client.go +++ b/pkg/client/clientimplementation/daemonclient/client.go @@ -15,7 +15,7 @@ import ( storagev1 "github.com/loft-sh/api/v4/pkg/apis/storage/v1" clientpkg "github.com/loft-sh/devpod/pkg/client" "github.com/loft-sh/devpod/pkg/config" - daemon "github.com/loft-sh/devpod/pkg/daemon/platform" + daemon "github.com/loft-sh/devpod/pkg/daemon/local" "github.com/loft-sh/devpod/pkg/options" "github.com/loft-sh/devpod/pkg/platform" platformclient "github.com/loft-sh/devpod/pkg/platform/client" @@ -273,6 +273,17 @@ func (c *client) Ping(ctx context.Context, writer io.Writer) error { return nil } +func (c *client) GetClientAddress(ctx context.Context) (string, error) { + status, err := c.tsClient.Status(ctx) + if err != nil { + return "", err + } + if status.Self == nil { + return "", fmt.Errorf("no self peer found") + } + return status.Self.DNSName, nil +} + func (c *client) initPlatformClient(ctx context.Context) (platformclient.Client, error) { configPath, err := platform.LoftConfigPath(c.Context(), c.Provider()) if err != nil { diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index 7b28434f2..15239ebb9 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -15,42 +15,47 @@ import ( "github.com/pkg/errors" ) -const DefaultPort = "12049" -const CredentialsServerPortEnv = "DEVPOD_CREDENTIALS_SERVER_PORT" +const ( + DefaultPort = "12049" + CredentialsServerPortEnv = "DEVPOD_CREDENTIALS_SERVER_PORT" + CredentialsServerLogFile = "devpod-credentials-server.log" +) +// RunCredentialsServer starts a credentials server inside the DevPod workspace. func RunCredentialsServer( ctx context.Context, port int, client tunnel.TunnelClient, - log log.Logger, + clientHost string, + logger log.Logger, ) error { var handler http.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - log.Debugf("Incoming client connection at %s", request.URL.Path) + logger.Debugf("Incoming client connection at %s", request.URL.Path) if request.URL.Path == "/git-credentials" { - err := handleGitCredentialsRequest(ctx, writer, request, client, log) + err := handleGitCredentialsRequest(ctx, writer, request, client, clientHost, logger) 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) + err := handleDockerCredentialsRequest(ctx, writer, request, client, logger) 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) + err := handleGitSSHSignatureRequest(ctx, writer, request, client, logger) 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) + err := handleLoftPlatformCredentialsRequest(ctx, writer, request, client, logger) if err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) } } else if request.URL.Path == "/gpg-public-keys" { - err := handleGPGPublicKeysRequest(ctx, writer, request, client, log) + err := handleGPGPublicKeysRequest(ctx, writer, request, client, logger) if err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) } @@ -62,7 +67,7 @@ func RunCredentialsServer( errChan := make(chan error, 1) go func() { - log.Debugf("Credentials server started on port %d...", port) + logger.Debugf("Credentials server started on port %d...", port) // always returns error. ErrServerClosed on graceful close if err := srv.ListenAndServe(); err != http.ErrServerClosed { @@ -110,7 +115,7 @@ func handleDockerCredentialsRequest(ctx context.Context, writer http.ResponseWri return nil } -func handleGitCredentialsRequest(ctx context.Context, writer http.ResponseWriter, request *http.Request, client tunnel.TunnelClient, log log.Logger) error { +func handleGitCredentialsRequest(ctx context.Context, writer http.ResponseWriter, request *http.Request, client tunnel.TunnelClient, clientHost string, log log.Logger) error { out, err := io.ReadAll(request.Body) if err != nil { return errors.Wrap(err, "read request body") 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/daemon/platform/client.go b/pkg/daemon/local/client.go similarity index 99% rename from pkg/daemon/platform/client.go rename to pkg/daemon/local/client.go index 0becd3d6b..6d8049bb2 100644 --- a/pkg/daemon/platform/client.go +++ b/pkg/daemon/local/client.go @@ -1,4 +1,4 @@ -package daemon +package local import ( "bytes" diff --git a/pkg/daemon/platform/daemon.go b/pkg/daemon/local/daemon.go similarity index 89% rename from pkg/daemon/platform/daemon.go rename to pkg/daemon/local/daemon.go index 1ee52b1db..352f425dc 100644 --- a/pkg/daemon/platform/daemon.go +++ b/pkg/daemon/local/daemon.go @@ -1,4 +1,4 @@ -package daemon +package local import ( "bufio" @@ -23,11 +23,12 @@ import ( ) type Daemon struct { - socketListener net.Listener - tsServer *tsnet.Server - localServer *localServer - rootDir string - log log.Logger + socketListener net.Listener + tsServer *tsnet.Server + localServer *localServer + grpcServerProxy *LocalGRPCProxy + rootDir string + log log.Logger } type InitConfig struct { @@ -62,12 +63,18 @@ func Init(ctx context.Context, config InitConfig) (*Daemon, error) { return nil, fmt.Errorf("create local server: %w", err) } + grpcProxy, err := NewLocalGRPCProxy(tsServer, log) + if err != nil { + return nil, fmt.Errorf("create local credentials server: %w", err) + } + return &Daemon{ - socketListener: socketListener, - tsServer: tsServer, - localServer: localServer, - rootDir: config.RootDir, - log: log, + socketListener: socketListener, + tsServer: tsServer, + localServer: localServer, + grpcServerProxy: grpcProxy, + rootDir: config.RootDir, + log: log, }, nil } func (d *Daemon) Start(ctx context.Context) error { @@ -85,7 +92,10 @@ func (d *Daemon) Start(ctx context.Context) error { d.log.Info("Start netmap watcher") errChan <- d.watchNetmap(ctx) }() - + go func() { + d.log.Info("Start credentials server") + errChan <- d.grpcServerProxy.Listen(ctx) + }() defer func() { d.log.Info("Cleaning up daemon resources") _ = d.tsServer.Close() diff --git a/pkg/daemon/platform/error.go b/pkg/daemon/local/error.go similarity index 98% rename from pkg/daemon/platform/error.go rename to pkg/daemon/local/error.go index 4f994405a..bedeb5f10 100644 --- a/pkg/daemon/platform/error.go +++ b/pkg/daemon/local/error.go @@ -1,4 +1,4 @@ -package daemon +package local import ( "errors" diff --git a/pkg/daemon/platform/error_unix.go b/pkg/daemon/local/error_unix.go similarity index 92% rename from pkg/daemon/platform/error_unix.go rename to pkg/daemon/local/error_unix.go index debf8cc7f..d9b5c5ab3 100644 --- a/pkg/daemon/platform/error_unix.go +++ b/pkg/daemon/local/error_unix.go @@ -1,6 +1,6 @@ //go:build linux || darwin || unix -package daemon +package local import ( "errors" diff --git a/pkg/daemon/platform/error_windows.go b/pkg/daemon/local/error_windows.go similarity index 91% rename from pkg/daemon/platform/error_windows.go rename to pkg/daemon/local/error_windows.go index 15eb253cd..b2613b9f9 100644 --- a/pkg/daemon/platform/error_windows.go +++ b/pkg/daemon/local/error_windows.go @@ -1,6 +1,6 @@ //go:build windows -package daemon +package local import ( "errors" diff --git a/pkg/daemon/local/grpc_proxy.go b/pkg/daemon/local/grpc_proxy.go new file mode 100644 index 000000000..15fbd2b8f --- /dev/null +++ b/pkg/daemon/local/grpc_proxy.go @@ -0,0 +1,127 @@ +package local + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/loft-sh/devpod/pkg/daemon/workspace/network" + "github.com/loft-sh/log" + "github.com/mwitkow/grpc-proxy/proxy" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "tailscale.com/tsnet" +) + +const ( + DefaultGRPCProxyPort int = 14798 + DefaultTargetHost string = "localhost" +) + +type LocalGRPCProxy struct { + log log.Logger + tsServer *tsnet.Server + grpcServer *grpc.Server + ln net.Listener +} + +func NewLocalGRPCProxy(tsServer *tsnet.Server, logger log.Logger) (*LocalGRPCProxy, error) { + if tsServer == nil { + return nil, fmt.Errorf("tsnet.Server cannot be nil") + } + return &LocalGRPCProxy{ + log: logger, + tsServer: tsServer, + }, nil +} + +func (s *LocalGRPCProxy) Listen(ctx context.Context) error { + s.log.Infof("LocalGRPCProxy: Starting reverse proxy on tsnet port %d", DefaultGRPCProxyPort) + + listenAddr := fmt.Sprintf(":%d", DefaultGRPCProxyPort) + ln, err := s.tsServer.Listen("tcp", listenAddr) + if err != nil { + s.log.Errorf("LocalGRPCProxy: Failed to listen on tsnet %s: %v", listenAddr, err) + return fmt.Errorf("failed to listen on tsnet %s: %w", listenAddr, err) + } + s.ln = ln + + s.log.Infof("LocalGRPCProxy: tsnet listener started on %s", ln.Addr().String()) + + director := func(ctx context.Context, fullMethodName string) (context.Context, *grpc.ClientConn, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, nil, status.Errorf(codes.InvalidArgument, "missing metadata") + } + + // Get the target port from metadata. Host is always localhost. + targetPorts := md.Get(network.HeaderTargetPort) + if len(targetPorts) == 0 { + s.log.Error("LocalGRPCProxy: Director missing x-target-port metadata") + return nil, nil, status.Errorf(codes.InvalidArgument, "missing x-target-port metadata") + } + targetPort := targetPorts[0] + targetAddr := net.JoinHostPort(DefaultTargetHost, targetPort) + + s.log.Infof("LocalGRPCProxy: Proxying call %q to target %s", fullMethodName, targetAddr) + + conn, err := grpc.DialContext(ctx, targetAddr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithCodec(proxy.Codec()), + ) + if err != nil { + s.log.Errorf("LocalGRPCProxy: Failed to dial local target backend %s: %v", targetAddr, err) + return nil, nil, status.Errorf(codes.Internal, "failed to dial local target backend: %v", err) + } + + return ctx, conn, nil + } + + s.grpcServer = grpc.NewServer( + grpc.UnknownServiceHandler(proxy.TransparentHandler(director)), + ) + + s.log.Debugf("LocalGRPCProxy: gRPC reverse proxy configured, starting server on %s", ln.Addr().String()) + + if err := s.grpcServer.Serve(s.ln); err != nil { + if err.Error() != "grpc: the server has been stopped" { + s.log.Errorf("LocalGRPCProxy: failed to serve: %v", err) + return fmt.Errorf("gRPC server error: %w", err) + } else { + s.log.Infof("LocalGRPCProxy: server stopped.") + } + } + return nil +} + +func (s *LocalGRPCProxy) Stop() { + s.log.Info("LocalGRPCProxy: Stopping reverse proxy...") + if s.grpcServer != nil { + stopped := make(chan struct{}) + go func() { + s.grpcServer.GracefulStop() + close(stopped) + }() + + select { + case <-time.After(10 * time.Second): + s.log.Warnf("LocalGRPCProxy: Shutdown timed out after 10 seconds, forcing stop.") + s.grpcServer.Stop() + case <-stopped: + s.log.Infof("LocalGRPCProxy: server stopped.") + } + } + + if s.ln != nil { + if err := s.ln.Close(); err != nil { + s.log.Errorf("LocalGRPCProxy: Error closing listener: %v", err) + } else { + s.log.Infof("LocalGRPCProxy: Listener closed.") + } + } + s.log.Info("LocalGRPCProxy: Reverse proxy stopped.") +} diff --git a/pkg/daemon/platform/local_server.go b/pkg/daemon/local/local_server.go similarity index 99% rename from pkg/daemon/platform/local_server.go rename to pkg/daemon/local/local_server.go index 264fba9c6..6ab3a77b5 100644 --- a/pkg/daemon/platform/local_server.go +++ b/pkg/daemon/local/local_server.go @@ -1,4 +1,4 @@ -package daemon +package local import ( "context" diff --git a/pkg/daemon/platform/socket.go b/pkg/daemon/local/socket.go similarity index 98% rename from pkg/daemon/platform/socket.go rename to pkg/daemon/local/socket.go index 97c6c9163..cbe108b18 100644 --- a/pkg/daemon/platform/socket.go +++ b/pkg/daemon/local/socket.go @@ -1,6 +1,6 @@ //go:build linux || darwin || unix -package daemon +package local import ( "errors" diff --git a/pkg/daemon/platform/socket_windows.go b/pkg/daemon/local/socket_windows.go similarity index 96% rename from pkg/daemon/platform/socket_windows.go rename to pkg/daemon/local/socket_windows.go index eeafd1754..072473418 100644 --- a/pkg/daemon/platform/socket_windows.go +++ b/pkg/daemon/local/socket_windows.go @@ -1,6 +1,6 @@ //go:build windows -package daemon +package local import ( "fmt" diff --git a/pkg/daemon/platform/ts_server.go b/pkg/daemon/local/ts_server.go similarity index 99% rename from pkg/daemon/platform/ts_server.go rename to pkg/daemon/local/ts_server.go index 4525356fe..2041ace01 100644 --- a/pkg/daemon/platform/ts_server.go +++ b/pkg/daemon/local/ts_server.go @@ -1,4 +1,4 @@ -package daemon +package local import ( "context" diff --git a/pkg/daemon/platform/workspace_watcher.go b/pkg/daemon/local/workspace_watcher.go similarity index 99% rename from pkg/daemon/platform/workspace_watcher.go rename to pkg/daemon/local/workspace_watcher.go index 7d86f61b3..0efc600b0 100644 --- a/pkg/daemon/platform/workspace_watcher.go +++ b/pkg/daemon/local/workspace_watcher.go @@ -1,4 +1,4 @@ -package daemon +package local import ( "context" diff --git a/pkg/daemon/agent/daemon.go b/pkg/daemon/workspace/config.go similarity index 52% rename from pkg/daemon/agent/daemon.go rename to pkg/daemon/workspace/config.go index bdd816225..f49522acd 100644 --- a/pkg/daemon/agent/daemon.go +++ b/pkg/daemon/workspace/config.go @@ -1,22 +1,13 @@ -package agent +package workspace import ( "encoding/base64" "encoding/json" - "errors" - "fmt" - "os" - "os/exec" "path/filepath" - "runtime" "github.com/loft-sh/api/v4/pkg/devpod" "github.com/loft-sh/devpod/pkg/devcontainer/config" provider2 "github.com/loft-sh/devpod/pkg/provider" - "github.com/loft-sh/devpod/pkg/single" - "github.com/loft-sh/log" - perrors "github.com/pkg/errors" - "github.com/takama/daemon" ) type SshConfig struct { @@ -76,71 +67,3 @@ func GetEncodedWorkspaceDaemonConfig(platformOptions devpod.PlatformOptions, wor encoded := base64.StdEncoding.EncodeToString(data) return encoded, nil } - -func InstallDaemon(agentDir string, interval string, log log.Logger) error { - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - return fmt.Errorf("unsupported daemon os") - } - - // check if admin - service, err := daemon.New("devpod", "DevPod Agent Service", daemon.SystemDaemon) - if err != nil { - return err - } - - // install ourselves with devpod watch - args := []string{"agent", "daemon"} - if agentDir != "" { - args = append(args, "--agent-dir", agentDir) - } - if interval != "" { - args = append(args, "--interval", interval) - } - _, err = service.Install(args...) - if err != nil && !errors.Is(err, daemon.ErrAlreadyInstalled) { - return perrors.Wrap(err, "install service") - } - - // make sure daemon is started - _, err = service.Start() - if err != nil && !errors.Is(err, daemon.ErrAlreadyRunning) { - log.Warnf("Error starting service: %v", err) - - err = single.Single("daemon.pid", func() (*exec.Cmd, error) { - executable, err := os.Executable() - if err != nil { - return nil, err - } - - log.Infof("Successfully started DevPod daemon into server") - return exec.Command(executable, args...), nil - }) - if err != nil { - return fmt.Errorf("start daemon: %w", err) - } - } else if err == nil { - log.Infof("Successfully installed DevPod daemon into server") - } - - return nil -} - -func RemoveDaemon() error { - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - return fmt.Errorf("unsupported daemon os") - } - - // check if admin - service, err := daemon.New("devpod", "DevPod Agent Service", daemon.SystemDaemon) - if err != nil { - return err - } - - // remove daemon - _, err = service.Remove() - if err != nil && !errors.Is(err, daemon.ErrNotInstalled) { - return err - } - - return nil -} diff --git a/pkg/daemon/workspace/daemon.go b/pkg/daemon/workspace/daemon.go new file mode 100644 index 000000000..21ed944ac --- /dev/null +++ b/pkg/daemon/workspace/daemon.go @@ -0,0 +1,181 @@ +package workspace + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/loft-sh/devpod/pkg/devcontainer/config" + "github.com/loft-sh/log" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +const ( + // RootDir is the directory used by the daemon. + RootDir = "/var/devpod" + DaemonConfigPath = "/var/run/secrets/devpod/daemon_config" + WorkspaceDaemonConfigExtraEnvVar = "DEVPOD_WORKSPACE_DAEMON_CONFIG" +) + +// Daemon holds the config and logger for the daemon. +type Daemon struct { + Config *DaemonConfig + Log log.Logger +} + +// NewDaemon creates a new daemon instance. +func NewDaemon() *Daemon { + return &Daemon{ + Config: &DaemonConfig{}, + Log: log.NewStreamLogger(os.Stdout, os.Stderr, logrus.InfoLevel), + } +} + +// Run starts the daemon subsystems and waits for an error or termination signal. +func (d *Daemon) Run(c *cobra.Command, args []string) error { + ctx := c.Context() + errChan := make(chan error, 4) + var wg sync.WaitGroup + + if err := d.loadConfig(); err != nil { + return err + } + + // Prepare timeout if specified. + var timeoutDuration time.Duration + if d.Config.Timeout != "" { + var err error + timeoutDuration, err = time.ParseDuration(d.Config.Timeout) + if err != nil { + return errors.Wrap(err, "failed to parse timeout duration") + } + if timeoutDuration > 0 { + if err := SetupActivityFile(); err != nil { + return err + } + } + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var tasksStarted bool + + // Start process reaper if running as PID 1. + if os.Getpid() == 1 { + wg.Add(1) + go RunProcessReaper() + } + + // Start Tailscale networking server. + if d.shouldRunNetworkServer() { + tasksStarted = true + wg.Add(1) + go RunNetworkServer(ctx, d, errChan, &wg, RootDir) + } + + // Start timeout monitor. + if timeoutDuration > 0 { + tasksStarted = true + wg.Add(1) + go RunTimeoutMonitor(ctx, timeoutDuration, errChan, &wg) + } + + // Start SSH server. + if d.shouldRunSsh() { + tasksStarted = true + wg.Add(1) + go RunSshServer(ctx, d, errChan, &wg) + } + + // In case no task is configured, wait indefinitely. + if !tasksStarted { + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + }() + } + + // Listen for OS termination signals. + go HandleSignals(ctx, errChan) + + // Wait until an error (or termination signal) occurs. + err := <-errChan + cancel() + wg.Wait() + + if err != nil { + d.Log.Errorf("Daemon error: %v", err) + os.Exit(1) + } + os.Exit(0) + return nil // Unreachable but needed. +} + +// loadConfig loads the daemon configuration from base64-encoded JSON. +// If a CLI-provided timeout exists, it will override the timeout in the config. +func (cmd *Daemon) loadConfig() error { + // check local file + encodedCfg := "" + configBytes, err := os.ReadFile(DaemonConfigPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // check environment variable + encodedCfg = os.Getenv(config.WorkspaceDaemonConfigExtraEnvVar) + } else { + return fmt.Errorf("get daemon config file %s: %w", DaemonConfigPath, err) + } + } else { + encodedCfg = string(configBytes) + } + + if strings.TrimSpace(encodedCfg) != "" { + decoded, err := base64.StdEncoding.DecodeString(encodedCfg) + if err != nil { + return fmt.Errorf("error decoding daemon config: %w", err) + } + var cfg DaemonConfig + if err = json.Unmarshal(decoded, &cfg); err != nil { + return fmt.Errorf("error unmarshalling daemon config: %w", err) + } + if cmd.Config.Timeout != "" { + cfg.Timeout = cmd.Config.Timeout + } + cmd.Config = &cfg + } + + return nil +} + +// shouldRunNetworkServer returns true if the required platform parameters are present. +func (d *Daemon) shouldRunNetworkServer() bool { + return d.Config.Platform.AccessKey != "" && + d.Config.Platform.PlatformHost != "" && + d.Config.Platform.WorkspaceHost != "" +} + +// shouldRunSsh returns true if at least one SSH configuration value is provided. +func (d *Daemon) shouldRunSsh() bool { + return d.Config.Ssh.Workdir != "" || d.Config.Ssh.User != "" +} + +// HandleSignals listens for OS termination signals and sends an error through errChan. +func HandleSignals(ctx context.Context, errChan chan<- error) { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + select { + case sig := <-sigChan: + errChan <- fmt.Errorf("received signal: %v", sig) + case <-ctx.Done(): + } +} diff --git a/pkg/daemon/workspace/install.go b/pkg/daemon/workspace/install.go new file mode 100644 index 000000000..2190ed9ab --- /dev/null +++ b/pkg/daemon/workspace/install.go @@ -0,0 +1,82 @@ +package workspace + +import ( + "errors" + "fmt" + "os" + "os/exec" + "runtime" + + "github.com/loft-sh/devpod/pkg/single" + "github.com/loft-sh/log" + perrors "github.com/pkg/errors" + "github.com/takama/daemon" +) + +func InstallDaemon(agentDir string, interval string, log log.Logger) error { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + return fmt.Errorf("unsupported daemon os") + } + + // check if admin + service, err := daemon.New("devpod", "DevPod Agent Service", daemon.SystemDaemon) + if err != nil { + return err + } + + // install ourselves with devpod watch + args := []string{"agent", "daemon"} + if agentDir != "" { + args = append(args, "--agent-dir", agentDir) + } + if interval != "" { + args = append(args, "--interval", interval) + } + _, err = service.Install(args...) + if err != nil && !errors.Is(err, daemon.ErrAlreadyInstalled) { + return perrors.Wrap(err, "install service") + } + + // make sure daemon is started + _, err = service.Start() + if err != nil && !errors.Is(err, daemon.ErrAlreadyRunning) { + log.Warnf("Error starting service: %v", err) + + err = single.Single("daemon.pid", func() (*exec.Cmd, error) { + executable, err := os.Executable() + if err != nil { + return nil, err + } + + log.Infof("Successfully started DevPod daemon into server") + return exec.Command(executable, args...), nil + }) + if err != nil { + return fmt.Errorf("start daemon: %w", err) + } + } else if err == nil { + log.Infof("Successfully installed DevPod daemon into server") + } + + return nil +} + +func RemoveDaemon() error { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + return fmt.Errorf("unsupported daemon os") + } + + // check if admin + service, err := daemon.New("devpod", "DevPod Agent Service", daemon.SystemDaemon) + if err != nil { + return err + } + + // remove daemon + _, err = service.Remove() + if err != nil && !errors.Is(err, daemon.ErrNotInstalled) { + return err + } + + return nil +} diff --git a/pkg/daemon/workspace/network.go b/pkg/daemon/workspace/network.go new file mode 100644 index 000000000..67a08ca14 --- /dev/null +++ b/pkg/daemon/workspace/network.go @@ -0,0 +1,49 @@ +package workspace + +import ( + "context" + "fmt" + "os" + + "sync" + + "github.com/loft-sh/devpod/pkg/daemon/workspace/network" + "github.com/loft-sh/devpod/pkg/platform/client" + "github.com/loft-sh/devpod/pkg/ts" + "github.com/loft-sh/log" + "github.com/sirupsen/logrus" +) + +// RunNetworkServer starts the network server. +func RunNetworkServer(ctx context.Context, d *Daemon, errChan chan<- error, wg *sync.WaitGroup, rootDir string) { + defer wg.Done() + if err := os.MkdirAll(rootDir, os.ModePerm); err != nil { + errChan <- err + return + } + logger := log.NewStdoutLogger(nil, os.Stdout, os.Stderr, logrus.InfoLevel) + config := client.NewConfig() + config.AccessKey = d.Config.Platform.AccessKey + config.Host = "https://" + d.Config.Platform.PlatformHost + config.Insecure = true + baseClient := client.NewClientFromConfig(config) + if err := baseClient.RefreshSelf(ctx); err != nil { + errChan <- fmt.Errorf("failed to refresh client: %w", err) + return + } + networkServer := network.NewWorkspaceServer(&network.WorkspaceServerConfig{ + AccessKey: d.Config.Platform.AccessKey, + PlatformHost: ts.RemoveProtocol(d.Config.Platform.PlatformHost), + WorkspaceHost: d.Config.Platform.WorkspaceHost, + Client: baseClient, + RootDir: rootDir, + LogF: func(format string, args ...any) { + if logger.GetLevel() == logrus.DebugLevel { + logger.Debugf(format, args...) + } + }, + }, logger) + if err := networkServer.Start(ctx); err != nil { + errChan <- fmt.Errorf("network server: %w", err) + } +} diff --git a/pkg/daemon/workspace/network/client.go b/pkg/daemon/workspace/network/client.go new file mode 100644 index 000000000..80b61fe72 --- /dev/null +++ b/pkg/daemon/workspace/network/client.go @@ -0,0 +1,51 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/http" + "path/filepath" + "time" +) + +// Dial returns a net.Conn to the network proxy socket. +func Dial() (net.Conn, error) { + socketPath := filepath.Join(RootDir, NetworkProxySocket) + return net.Dial("unix", socketPath) +} + +// GetContextDialer returns ContextDialer interface function that uses our network socket. +func GetContextDialer() func(ctx context.Context, addr string) (net.Conn, error) { + // The 'addr' argument passed by grpc.DialContext is ignored here, + // as we always dial the fixed unix socket path. + return func(ctx context.Context, _ string) (net.Conn, error) { + conn, err := Dial() + if err != nil { + return nil, fmt.Errorf("failed to dial proxy socket: %w", err) + } + return conn, nil + } +} + +// GetHTTPTransport returns http.Transport that uses our network socket for HTTP requests. +func GetHTTPTransport() *http.Transport { + // Set up HTTP transport that uses our network socket. + return &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := Dial() + if err != nil { + return nil, fmt.Errorf("failed to dial proxy socket via http transport: %w", err) + } + return conn, nil + }, + } +} + +// GetHTTPClient returns a new HTTP client that uses our network socket for transport. +func GetHTTPClient() *http.Client { + return &http.Client{ + Transport: GetHTTPTransport(), + Timeout: 30 * time.Second, + } +} diff --git a/pkg/daemon/workspace/network/connection_tracker.go b/pkg/daemon/workspace/network/connection_tracker.go new file mode 100644 index 000000000..9b4112ad8 --- /dev/null +++ b/pkg/daemon/workspace/network/connection_tracker.go @@ -0,0 +1,36 @@ +package network + +import ( + "sync" + + "github.com/loft-sh/log" +) + +// ConnTracker is a simple connection counter used by several services. +type ConnTracker struct { + mu sync.Mutex + count int + + logger log.Logger +} + +func (c *ConnTracker) Add(serviceName string) { + c.mu.Lock() + defer c.mu.Unlock() + c.count++ + c.logger.Debugf("%s: Added new connection, connection count %d\n", serviceName, c.count) +} + +func (c *ConnTracker) Remove(serviceName string) { + c.mu.Lock() + defer c.mu.Unlock() + c.count-- + c.logger.Debugf("%s: Removed connection, connection count %d\n", serviceName, c.count) +} + +func (c *ConnTracker) Count(serviceName string) int { + c.mu.Lock() + defer c.mu.Unlock() + c.logger.Debugf("%s: Get connection count %d\n", serviceName, c.count) + return c.count +} diff --git a/pkg/daemon/workspace/network/grpc_proxy.go b/pkg/daemon/workspace/network/grpc_proxy.go new file mode 100644 index 000000000..3b7ac14d5 --- /dev/null +++ b/pkg/daemon/workspace/network/grpc_proxy.go @@ -0,0 +1,84 @@ +package network + +import ( + "context" + "fmt" + "net" + "strconv" + + "github.com/loft-sh/devpod/pkg/ts" + "github.com/loft-sh/log" + "github.com/mwitkow/grpc-proxy/proxy" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "tailscale.com/tsnet" +) + +// GrpcDirector handles the logic for directing gRPC requests. +type GrpcDirector struct { + log log.Logger + tsServer *tsnet.Server +} + +// NewGrpcDirector creates a new GrpcDirector. +func NewGrpcDirector(tsSrv *tsnet.Server, logger log.Logger) *GrpcDirector { + return &GrpcDirector{ + log: logger, + tsServer: tsSrv, + } +} + +func (d *GrpcDirector) tsDialer(ctx context.Context, addr string) (net.Conn, error) { + d.log.Debugf("GrpcDirector: Dialing target %s via tsnet", addr) + conn, err := d.tsServer.Dial(ctx, "tcp", addr) + if err != nil { + d.log.Errorf("GrpcDirector: Failed to dial target %s via tsnet: %v", addr, err) + return nil, fmt.Errorf("tsnet dial failed for %s: %w", addr, err) + } + d.log.Debugf("GrpcDirector: Successfully dialed %s via tsnet", addr) + return conn, nil +} + +func (d *GrpcDirector) DirectorFunc(ctx context.Context, fullMethodName string) (context.Context, *grpc.ClientConn, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + d.log.Warnf("NetworkProxyService: gRPC: Director missing incoming metadata for call %q", fullMethodName) + return nil, nil, status.Errorf(codes.InvalidArgument, "missing metadata") + } + mdCopy := md.Copy() + + targetHosts := mdCopy.Get(HeaderTargetHost) + targetPorts := mdCopy.Get(HeaderTargetPort) + proxyPorts := mdCopy.Get(HeaderProxyPort) + + if len(targetHosts) == 0 || len(targetPorts) == 0 || len(proxyPorts) == 0 { + d.log.Errorf("NetworkProxyService: gRPC: Director missing x-target-host, x-proxy-port or x-target-port metadata for call %q", fullMethodName) + return nil, nil, status.Errorf(codes.InvalidArgument, "missing x-target-host, x-proxy-port or x-target-port metadata") + } + + proxyPort, err := strconv.Atoi(proxyPorts[0]) + if err != nil { + d.log.Errorf("NetworkProxyService: gRPC: Invalid x-proxy-port %q: %v", proxyPorts[0], err) + return nil, nil, status.Errorf(codes.InvalidArgument, "invalid x-proxy-port: %v", err) + } + // The address we dial via tsnet is the intermediate proxy host and its port + targetAddr := ts.EnsureURL(targetHosts[0], proxyPort) + d.log.Debugf("NetworkProxyService: gRPC: Proxying call %q to target %s", fullMethodName, targetAddr) + + conn, err := grpc.DialContext(ctx, targetAddr, + grpc.WithContextDialer(d.tsDialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithCodec(proxy.Codec()), + ) + if err != nil { + d.log.Errorf("NetworkProxyService: gRPC: Failed to dial backend %s: %v", targetAddr, err) + return nil, nil, status.Errorf(codes.Internal, "failed to dial backend: %v", err) + } + + outCtx := metadata.NewOutgoingContext(ctx, mdCopy) + + return outCtx, conn, nil +} diff --git a/pkg/daemon/workspace/network/heartbeat.go b/pkg/daemon/workspace/network/heartbeat.go new file mode 100644 index 000000000..06dd0a1bb --- /dev/null +++ b/pkg/daemon/workspace/network/heartbeat.go @@ -0,0 +1,95 @@ +package network + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/loft-sh/log" + "tailscale.com/client/tailscale" + "tailscale.com/tsnet" +) + +// HeartbeatService sends periodic heartbeats when there are active connections. +type HeartbeatService struct { + tsServer *tsnet.Server + lc *tailscale.LocalClient + config *WorkspaceServerConfig + projectName string + workspaceName string + log log.Logger + tracker *ConnTracker +} + +// NewHeartbeatService creates a new HeartbeatService. +func NewHeartbeatService(config *WorkspaceServerConfig, tsServer *tsnet.Server, lc *tailscale.LocalClient, projectName, workspaceName string, tracker *ConnTracker, log log.Logger) *HeartbeatService { + return &HeartbeatService{ + tsServer: tsServer, + lc: lc, + config: config, + projectName: projectName, + workspaceName: workspaceName, + log: log, + tracker: tracker, + } +} + +// Start begins the heartbeat loop. +func (s *HeartbeatService) Start(ctx context.Context) { + s.log.Info("HeartbeatService: Start") + transport := &http.Transport{DialContext: s.tsServer.Dial} + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + s.log.Info("HeartbeatService: Exit") + return + case <-ticker.C: + s.log.Debugf("HeartbeatService: checking connection count") + if s.tracker.Count("HeartbeatService") > 0 { + if err := s.sendHeartbeat(ctx, client); err != nil { + s.log.Errorf("HeartbeatService: failed to send heartbeat: %v", err) + } + } else { + s.log.Debugf("HeartbeatService: No active connections, skipping heartbeat.") + } + } + } +} + +func (s *HeartbeatService) sendHeartbeat(ctx context.Context, client *http.Client) error { + hbCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + discoveredRunner, err := discoverRunner(hbCtx, s.lc, s.log) + if err != nil { + s.log.Errorf("HeartbeatService: failed to discover runner: %v", err) + return fmt.Errorf("failed to discover runner: %w", err) + } + + heartbeatURL := fmt.Sprintf("http://%s.ts.loft/devpod/%s/%s/heartbeat", discoveredRunner, s.projectName, s.workspaceName) + s.log.Infof("HeartbeatService: sending heartbeat to %s, active connections: %d", heartbeatURL, s.tracker.Count("HeartbeatService")) + req, err := http.NewRequestWithContext(hbCtx, "GET", heartbeatURL, nil) + if err != nil { + s.log.Errorf("HeartbeatService: failed to create request for %s: %v", heartbeatURL, err) + return fmt.Errorf("failed to create request for %s: %w", heartbeatURL, err) + } + req.Header.Set("Authorization", "Bearer "+s.config.AccessKey) + resp, err := client.Do(req) + if err != nil { + s.log.Errorf("HeartbeatService: request to %s failed: %v", heartbeatURL, err) + return fmt.Errorf("request to %s failed: %w", heartbeatURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + s.log.Errorf("HeartbeatService: received non-OK response from %s - Status: %d", heartbeatURL, resp.StatusCode) + return fmt.Errorf("received response from %s - Status: %d", heartbeatURL, resp.StatusCode) + } + + s.log.Debugf("HeartbeatService: received response from %s - Status: %d", heartbeatURL, resp.StatusCode) + return nil +} diff --git a/pkg/daemon/workspace/network/http_proxy.go b/pkg/daemon/workspace/network/http_proxy.go new file mode 100644 index 000000000..e2a533a14 --- /dev/null +++ b/pkg/daemon/workspace/network/http_proxy.go @@ -0,0 +1,109 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + + "github.com/loft-sh/devpod/pkg/ts" + "github.com/loft-sh/log" + "tailscale.com/tsnet" +) + +// HttpProxyHandler handles the logic for proxying HTTP requests. +type HttpProxyHandler struct { + log log.Logger + tsServer *tsnet.Server +} + +// NewHttpProxyHandler creates a new HttpProxyHandler. +func NewHttpProxyHandler(tsSrv *tsnet.Server, logger log.Logger) *HttpProxyHandler { + return &HttpProxyHandler{ + log: logger, + tsServer: tsSrv, + } +} + +func (h *HttpProxyHandler) tsDialer(ctx context.Context, addr string) (net.Conn, error) { + h.log.Debugf("HttpProxyHandler: Dialing target %s via tsnet", addr) + conn, err := h.tsServer.Dial(ctx, "tcp", addr) + if err != nil { + h.log.Errorf("HttpProxyHandler: Failed to dial target %s via tsnet: %v", addr, err) + return nil, fmt.Errorf("tsnet dial failed for %s: %w", addr, err) + } + h.log.Debugf("HttpProxyHandler: Successfully dialed %s via tsnet", addr) + return conn, nil +} + +// ServeHTTP implements the http.Handler interface. +func (h *HttpProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + targetHostHeader := r.Header.Get(HeaderTargetHost) + proxyPortHeader := r.Header.Get(HeaderProxyPort) + + var targetAddr string + + if targetHostHeader != "" && proxyPortHeader != "" { + proxyPort, err := strconv.Atoi(proxyPortHeader) + if err != nil { + h.log.Errorf("NetworkProxyService: HTTP: Invalid X-Proxy-Port %q: %v", proxyPortHeader, err) + http.Error(w, "Invalid X-Proxy-Port header", http.StatusBadRequest) + return + } + targetAddr = ts.EnsureURL(targetHostHeader, proxyPort) + h.log.Debugf("NetworkProxyService: HTTP: Proxying request for %s %s via custom headers to target %s", r.Method, r.URL.Path, targetAddr) + + } else { + host := r.Host + if host == "" { + h.log.Errorf("NetworkProxyService: HTTP: Request missing Host header") + http.Error(w, "Host header is required", http.StatusBadRequest) + return + } + + if !strings.Contains(host, ":") { + h.log.Debugf("NetworkProxyService: HTTP: Host header %q missing port, assuming port 80", host) + targetAddr = net.JoinHostPort(host, "80") + } else { + targetAddr = host + } + + h.log.Debugf("NetworkProxyService: HTTP: Proxying request for %s %s to target %s", r.Method, r.URL.Path, targetAddr) + } + + dialTargetAddr := targetAddr + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + targetURL := url.URL{ + Scheme: "http", + Host: dialTargetAddr, + } + req.URL.Scheme = targetURL.Scheme + req.URL.Host = targetURL.Host + req.URL.Path = r.URL.Path + req.URL.RawQuery = r.URL.RawQuery + req.Host = targetURL.Host + h.log.Debugf("NetworkProxyService: HTTP: Director setting outgoing URL to %s, Host header to %s", req.URL.String(), req.Host) + }, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return h.tsDialer(ctx, dialTargetAddr) + }, + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + h.log.Errorf("NetworkProxyService: HTTP: Proxy error to target %s: %v", dialTargetAddr, err) + http.Error(w, fmt.Sprintf("Proxy error: %v", err), http.StatusBadGateway) + }, + ModifyResponse: func(resp *http.Response) error { + h.log.Debugf("NetworkProxyService: HTTP: Received response %d from target %s", resp.StatusCode, dialTargetAddr) + return nil + }, + } + + proxy.ServeHTTP(w, r) +} diff --git a/pkg/daemon/workspace/network/netmap.go b/pkg/daemon/workspace/network/netmap.go new file mode 100644 index 000000000..0081ceb2e --- /dev/null +++ b/pkg/daemon/workspace/network/netmap.go @@ -0,0 +1,49 @@ +package network + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/loft-sh/devpod/pkg/ts" + "github.com/loft-sh/log" + "tailscale.com/client/tailscale" + "tailscale.com/types/netmap" +) + +// NetmapWatcherService watches the Tailscale netmap and writes it to a file. +type NetmapWatcherService struct { + rootDir string + lc *tailscale.LocalClient + log log.Logger +} + +// NewNetmapWatcherService creates a new NetmapWatcherService. +func NewNetmapWatcherService(rootDir string, lc *tailscale.LocalClient, log log.Logger) *NetmapWatcherService { + return &NetmapWatcherService{ + rootDir: rootDir, + lc: lc, + log: log, + } +} + +// Start begins watching the netmap. +func (s *NetmapWatcherService) Start(ctx context.Context) { + lastUpdate := time.Now() + if err := ts.WatchNetmap(ctx, s.lc, func(netMap *netmap.NetworkMap) { + if time.Since(lastUpdate) < netMapCooldown { + return + } + lastUpdate = time.Now() + nm, err := json.Marshal(netMap) + if err != nil { + s.log.Errorf("NetmapWatcherService: failed to marshal netmap: %v", err) + } else { + _ = os.WriteFile(filepath.Join(s.rootDir, "netmap.json"), nm, 0644) + } + }); err != nil { + s.log.Errorf("NetmapWatcherService: failed to watch netmap: %v", err) + } +} diff --git a/pkg/daemon/workspace/network/network_proxy.go b/pkg/daemon/workspace/network/network_proxy.go new file mode 100644 index 000000000..35719cf61 --- /dev/null +++ b/pkg/daemon/workspace/network/network_proxy.go @@ -0,0 +1,215 @@ +package network + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/loft-sh/log" + "github.com/mwitkow/grpc-proxy/proxy" + "github.com/soheilhy/cmux" + "google.golang.org/grpc" + "tailscale.com/tsnet" +) + +const ( + HeaderTargetHost string = "x-target-host" + HeaderTargetPort string = "x-target-port" + HeaderProxyPort string = "x-proxy-port" +) + +// NetworkProxyService proxies gRPC and HTTP requests over DevPod network. +// It coordinates the listener, cmux, and underlying servers. +type NetworkProxyService struct { + mainListener net.Listener + grpcServer *grpc.Server + httpServer *http.Server + tsServer *tsnet.Server + log log.Logger + socketPath string + mux cmux.CMux + grpcDirector *GrpcDirector + httpProxy *HttpProxyHandler +} + +// NewNetworkProxyService creates a new instance listening on the given unix socket. +func NewNetworkProxyService(socketPath string, tsServer *tsnet.Server, log log.Logger) (*NetworkProxyService, error) { + _ = os.Remove(socketPath) + l, err := net.Listen("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("failed to listen on socket %s: %w", socketPath, err) + } + + if err := os.Chmod(socketPath, 0777); err != nil { + l.Close() + return nil, fmt.Errorf("failed to set socket permissions on %s: %w", socketPath, err) + } + + log.Infof("NetworkProxyService: network proxy listening on socket %s", socketPath) + + grpcDirector := NewGrpcDirector(tsServer, log) + httpProxy := NewHttpProxyHandler(tsServer, log) + + return &NetworkProxyService{ + mainListener: l, + tsServer: tsServer, + log: log, + socketPath: socketPath, + grpcDirector: grpcDirector, + httpProxy: httpProxy, + }, nil +} + +// Start runs the gRPC reverse proxy server. +func (s *NetworkProxyService) Start(ctx context.Context) error { + // Create connection multiplexer + s.mux = cmux.New(s.mainListener) + + // Matchers + grpcL := s.mux.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc")) + httpL := s.mux.Match(cmux.Any()) + + // Servers + s.grpcServer = grpc.NewServer( + grpc.UnknownServiceHandler(proxy.TransparentHandler(s.grpcDirector.DirectorFunc)), + ) + s.httpServer = &http.Server{ + Handler: s.httpProxy, + } + + // Start servers + var runWg sync.WaitGroup + errChan := make(chan error, 3) + + runWg.Add(1) + go func() { + defer runWg.Done() + s.log.Debugf("NetworkProxyService: starting gRPC server...") + if err := s.grpcServer.Serve(grpcL); err != nil && !errors.Is(err, grpc.ErrServerStopped) && !errors.Is(err, cmux.ErrListenerClosed) { + s.log.Errorf("NetworkProxyService: gRPC server error: %v", err) + errChan <- fmt.Errorf("gRPC server error: %w", err) + } else { + s.log.Debugf("NetworkProxyService: gRPC server stopped.") + } + }() + + runWg.Add(1) + go func() { + defer runWg.Done() + s.log.Debugf("NetworkProxyService: starting HTTP server...") + if err := s.httpServer.Serve(httpL); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, cmux.ErrListenerClosed) { + s.log.Errorf("NetworkProxyService: HTTP server error: %v", err) + errChan <- fmt.Errorf("HTTP server error: %w", err) + } else { + s.log.Debugf("NetworkProxyService: HTTP server stopped.") + } + }() + + runWg.Add(1) + go func() { + defer runWg.Done() + s.log.Infof("NetworkProxyService: starting server...") + err := s.mux.Serve() + if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, cmux.ErrListenerClosed) { + s.log.Errorf("NetworkProxyService: server error: %v", err) + errChan <- fmt.Errorf("server error: %w", err) + } else { + s.log.Infof("NetworkProxyService: server stopped.") + } + }() + + s.log.Infof("NetworkProxyService: successfully started listeners on %s", s.socketPath) + + var finalErr error + select { + case <-ctx.Done(): + s.log.Infof("NetworkProxyService: context cancelled, shutting down proxy service") + finalErr = ctx.Err() + case err := <-errChan: + s.log.Errorf("NetworkProxyService: server error triggered shutdown: %v", err) + finalErr = err + } + + s.Stop() + + s.log.Debugf("NetworkProxyService: Waiting for servers to exit...") + runWg.Wait() + s.log.Debugf("NetworkProxyService: All servers exited.") + + return finalErr +} + +func (s *NetworkProxyService) Stop() { + s.log.Infof("NetworkProxyService: stopping proxy service...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var shutdownWg sync.WaitGroup + shutdownWg.Add(2) + + go func() { + defer shutdownWg.Done() + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + s.log.Debugf("NetworkProxyService: gRPC server stopped.") + } + }() + + go func() { + defer shutdownWg.Done() + if s.httpServer != nil { + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + s.log.Warnf("NetworkProxyService: HTTP server shutdown error: %v", err) + } else { + s.log.Debugf("NetworkProxyService: HTTP server stopped.") + } + } + }() + + s.log.Infof("NetworkProxyService: waiting for servers to stop...") + + waitDone := make(chan struct{}) + go func() { + defer close(waitDone) + shutdownWg.Wait() + }() + + select { + case <-waitDone: + s.log.Debugf("NetworkProxyService: All server shutdowns completed.") + case <-shutdownCtx.Done(): + s.log.Warnf("NetworkProxyService: Graceful shutdown timed out after waiting for servers.") + } + + s.log.Debugf("NetworkProxyService: Listener and socket cleanup.") + + if s.mainListener != nil { + s.log.Debugf("NetworkProxyService: Closing main listener...") + if err := s.mainListener.Close(); err != nil { + if !errors.Is(err, net.ErrClosed) && !errors.Is(err, cmux.ErrListenerClosed) { + s.log.Errorf("NetworkProxyService: Error closing main listener: %v", err) + } else { + s.log.Debugf("NetworkProxyService: Main listener closed.") + } + } else { + s.log.Debugf("NetworkProxyService: Main listener closed successfully.") + } + } else { + s.log.Warnf("NetworkProxyService: Main listener was nil during stop.") + } + + s.log.Debugf("NetworkProxyService: Removing socket file %s", s.socketPath) + if err := os.Remove(s.socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + s.log.Warnf("NetworkProxyService: Failed to remove socket file %s: %v", s.socketPath, err) + } else if err == nil { + s.log.Debugf("NetworkProxyService: Removed socket file %s", s.socketPath) + } + + s.log.Infof("NetworkProxyService: Proxy service stopped.") +} diff --git a/pkg/daemon/workspace/network/platform_credentials_server.go b/pkg/daemon/workspace/network/platform_credentials_server.go new file mode 100644 index 000000000..9f0f8d824 --- /dev/null +++ b/pkg/daemon/workspace/network/platform_credentials_server.go @@ -0,0 +1,87 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + + "github.com/loft-sh/log" + "tailscale.com/client/tailscale" + "tailscale.com/tsnet" +) + +// TODO: this is no longer needed since we have a generic network socket available. +// PlatformGitCredentialsService handles the /git-credentials endpoint. +type PlatformGitCredentialsService struct { + listener net.Listener + config *WorkspaceServerConfig + tsServer *tsnet.Server + lc *tailscale.LocalClient + projectName string + workspaceName string + log log.Logger +} + +// NewPlatformGitCredentialsService creates a new PlatformGitCredentialsService. +func NewPlatformGitCredentialsService(config *WorkspaceServerConfig, tsServer *tsnet.Server, lc *tailscale.LocalClient, projectName, workspaceName string, log log.Logger) (*PlatformGitCredentialsService, error) { + runnerProxySocket := filepath.Join(config.RootDir, RunnerProxySocket) + _ = os.Remove(runnerProxySocket) + l, err := net.Listen("unix", runnerProxySocket) + if err != nil { + return nil, fmt.Errorf("failed to listen on socket %s: %w", runnerProxySocket, err) + } + _ = os.Chmod(runnerProxySocket, 0777) + return &PlatformGitCredentialsService{ + listener: l, + config: config, + tsServer: tsServer, + lc: lc, + projectName: projectName, + workspaceName: workspaceName, + log: log, + }, nil +} + +// Start begins serving the /git-credentials endpoint. +func (s *PlatformGitCredentialsService) Start(ctx context.Context) { + s.log.Infof("Starting Git Credentials server on %s", RunnerProxySocket) + mux := http.NewServeMux() + mux.HandleFunc("/git-credentials", s.gitCredentialsHandler) + go func() { + if err := http.Serve(s.listener, mux); err != nil && err != http.ErrServerClosed { + s.log.Errorf("PlatformGitCredentialsService error: %v", err) + } + }() +} + +func (s *PlatformGitCredentialsService) gitCredentialsHandler(w http.ResponseWriter, r *http.Request) { + s.log.Infof("PlatformGitCredentialsService: received git credentials request from %s", r.RemoteAddr) + discoveredRunner, err := discoverRunner(r.Context(), s.lc, s.log) + if err != nil { + http.Error(w, "failed to discover runner", http.StatusInternalServerError) + return + } + runnerURL := fmt.Sprintf("http://%s.ts.loft/devpod/%s/%s/workspace-git-credentials", discoveredRunner, s.projectName, s.workspaceName) + parsedURL, err := url.Parse(runnerURL) + if err != nil { + http.Error(w, "failed to parse runner URL", http.StatusInternalServerError) + return + } + proxy := newReverseProxy(parsedURL, func(h http.Header) { + h.Set("Authorization", "Bearer "+s.config.AccessKey) + }) + transport := &http.Transport{DialContext: s.tsServer.Dial} + proxy.Transport = transport + proxy.ServeHTTP(w, r) +} + +// Stop stops the PlatformGitCredentialsService by closing its listener. +func (s *PlatformGitCredentialsService) Stop() { + if s.listener != nil { + s.listener.Close() + } +} diff --git a/pkg/daemon/workspace/network/port_forward.go b/pkg/daemon/workspace/network/port_forward.go new file mode 100644 index 000000000..d6b1071bf --- /dev/null +++ b/pkg/daemon/workspace/network/port_forward.go @@ -0,0 +1,96 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/loft-sh/log" + "tailscale.com/tsnet" +) + +const ( + // TSPortForwardPort is the fixed port on which the workspace HTTP reverse proxy listens. + TSPortForwardPort string = "12051" + + RunnerProxySocket string = "runner-proxy.sock" + NetworkProxySocket string = "devpod-net.sock" + RootDir string = "/var/devpod" + + netMapCooldown = 30 * time.Second +) + +// HTTPPortForwardService handles HTTP reverse proxy requests. +type HTTPPortForwardService struct { + listener net.Listener + tsServer *tsnet.Server + log log.Logger + tracker *ConnTracker +} + +// NewHTTPPortForwardService creates a new HTTPPortForwardService. +func NewHTTPPortForwardService(tsServer *tsnet.Server, tracker *ConnTracker, log log.Logger) (*HTTPPortForwardService, error) { + l, err := tsServer.Listen("tcp", fmt.Sprintf(":%s", TSPortForwardPort)) + if err != nil { + return nil, fmt.Errorf("failed to listen on TS port %s: %w", TSPortForwardPort, err) + } + return &HTTPPortForwardService{ + listener: l, + tsServer: tsServer, + log: log, + tracker: tracker, + }, nil +} + +// Start begins serving HTTP port forwarding requests. +func (s *HTTPPortForwardService) Start(ctx context.Context) { + s.log.Infof("Starting HTTP reverse proxy listener on TSNet port %s", TSPortForwardPort) + mux := http.NewServeMux() + mux.HandleFunc("/portforward", s.portForwardHandler) + go func() { + if err := http.Serve(s.listener, mux); err != nil && err != http.ErrServerClosed { + s.log.Errorf("HTTPPortForwardService error: %v", err) + } + }() +} + +func (s *HTTPPortForwardService) portForwardHandler(w http.ResponseWriter, r *http.Request) { + s.tracker.Add("PortForward") + defer s.tracker.Remove("PortForward") + s.log.Debugf("HTTPPortForwardService: received request") + + targetPort := r.Header.Get("X-Loft-Forward-Port") + baseForwardStr := r.Header.Get("X-Loft-Forward-Url") + if targetPort == "" || baseForwardStr == "" { + http.Error(w, "missing required X-Loft headers", http.StatusBadRequest) + return + } + s.log.Debugf("HTTPPortForwardService: headers: X-Loft-Forward-Port=%s, X-Loft-Forward-Url=%s", targetPort, baseForwardStr) + parsedURL, err := url.Parse(baseForwardStr) + if err != nil { + s.log.Errorf("HTTPPortForwardService: invalid base forward URL: %v", err) + http.Error(w, "invalid base forward URL", http.StatusBadRequest) + return + } + parsedURL.Scheme = "http" + parsedURL.Host = "127.0.0.1:" + targetPort + s.log.Debugf("HTTPPortForwardService: final target URL=%s", parsedURL.String()) + proxy := newReverseProxy(parsedURL, func(h http.Header) { + h.Del("X-Loft-Forward-Port") + h.Del("X-Loft-Forward-Url") + h.Del("X-Loft-Forward-Authorization") + }) + proxy.Transport = http.DefaultTransport + s.log.Infof("HTTPPortForwardService: proxying request: %s %s", r.Method, parsedURL.String()) + proxy.ServeHTTP(w, r) +} + +// Stop stops the HTTPPortForwardService by closing its listener. +func (s *HTTPPortForwardService) Stop() { + if s.listener != nil { + s.listener.Close() + } +} diff --git a/pkg/daemon/workspace/network/server.go b/pkg/daemon/workspace/network/server.go new file mode 100644 index 000000000..51cec91de --- /dev/null +++ b/pkg/daemon/workspace/network/server.go @@ -0,0 +1,199 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/url" + "path/filepath" + "strings" + + "github.com/loft-sh/devpod/pkg/platform/client" + "github.com/loft-sh/devpod/pkg/ts" + "github.com/loft-sh/log" + "tailscale.com/envknob" + "tailscale.com/ipn/store/mem" + "tailscale.com/tsnet" +) + +type WorkspaceServerConfig struct { + AccessKey string + PlatformHost string + WorkspaceHost string + LogF func(format string, args ...interface{}) + Client client.Client + RootDir string +} + +// WorkspaceServer is the main workspace network server. +// It creates and manages network server instance as well as +// all services that run on DevPod network inside the workspace. +type WorkspaceServer struct { + network *tsnet.Server // TODO: we probably want to hide network behind our own interface at some point + config *WorkspaceServerConfig + log log.Logger + connTracker *ConnTracker + + // Services + sshSvc *SSHService + httpProxySvc *HTTPPortForwardService + platformGitCredentialsSvc *PlatformGitCredentialsService + netProxySvc *NetworkProxyService + heartbeatSvc *HeartbeatService + netmapWatcher *NetmapWatcherService +} + +// NewWorkspaceServer creates a new WorkspaceServer. +func NewWorkspaceServer(config *WorkspaceServerConfig, logger log.Logger) *WorkspaceServer { + return &WorkspaceServer{ + config: config, + log: logger, + connTracker: &ConnTracker{ + logger: logger, + }, + } +} + +// Start initializes the network server server and all services, then blocks until the context is canceled. +func (s *WorkspaceServer) Start(ctx context.Context) error { + s.log.Infof("Starting workspace server") + workspaceName, projectName, err := s.joinNetwork(ctx) + if err != nil { + return err + } + + lc, err := s.network.LocalClient() + if err != nil { + return err + } + + // Create and start the SSH service. + s.sshSvc, err = NewSSHService(s.network, s.connTracker, s.log) + if err != nil { + return err + } + s.sshSvc.Start(ctx) + + s.httpProxySvc, err = NewHTTPPortForwardService(s.network, s.connTracker, s.log) + if err != nil { + return err + } + s.httpProxySvc.Start(ctx) + + // Create and start the platform git credentials service. + s.platformGitCredentialsSvc, err = NewPlatformGitCredentialsService(s.config, s.network, lc, projectName, workspaceName, s.log) + if err != nil { + return err + } + s.platformGitCredentialsSvc.Start(ctx) + + // Create and start the network proxy service. + networkSocket := filepath.Join(s.config.RootDir, NetworkProxySocket) + s.netProxySvc, err = NewNetworkProxyService(networkSocket, s.network, s.log) + if err != nil { + return err + } + go s.netProxySvc.Start(ctx) + + // Start the heartbeat service. + s.heartbeatSvc = NewHeartbeatService(s.config, s.network, lc, projectName, workspaceName, s.connTracker, s.log) + go s.heartbeatSvc.Start(ctx) + + // Start netmap watcher. + s.netmapWatcher = NewNetmapWatcherService(s.config.RootDir, lc, s.log) + go s.netmapWatcher.Start(ctx) + + // Wait until the context is canceled. + <-ctx.Done() + return nil +} + +// Stop shuts down all services and the network server. +func (s *WorkspaceServer) Stop() { + if s.sshSvc != nil { + s.sshSvc.Stop() + } + if s.httpProxySvc != nil { + s.httpProxySvc.Stop() + } + if s.platformGitCredentialsSvc != nil { + s.platformGitCredentialsSvc.Stop() + } + if s.netProxySvc != nil { + s.netProxySvc.Stop() + } + if s.network != nil { + s.network.Close() + s.network = nil + } + s.log.Info("Workspace server stopped") +} + +// Dial dials the given address using the network server. +func (s *WorkspaceServer) Dial(ctx context.Context, network, addr string) (net.Conn, error) { + if s.network == nil { + return nil, fmt.Errorf("network server is not running") + } + return s.network.Dial(ctx, network, addr) +} + +// joinNetwork validates configuration, sets up the control URL, starts the network server, +// and parses the hostname into workspace and project names. +func (s *WorkspaceServer) joinNetwork(ctx context.Context) (workspace, project string, err error) { + if err = s.validateConfig(); err != nil { + return "", "", err + } + baseURL, err := s.setupControlURL(ctx) + if err != nil { + return "", "", err + } + if err = s.initNetworkServer(ctx, baseURL); err != nil { + return "", "", err + } + return s.parseWorkspaceHostname() +} + +func (s *WorkspaceServer) validateConfig() error { + if s.config.AccessKey == "" || s.config.PlatformHost == "" || s.config.WorkspaceHost == "" { + return fmt.Errorf("access key, host, or hostname cannot be empty") + } + return nil +} + +func (s *WorkspaceServer) setupControlURL(ctx context.Context) (*url.URL, error) { + baseURL := &url.URL{ + Scheme: ts.GetEnvOrDefault("LOFT_TSNET_SCHEME", "https"), + Host: s.config.PlatformHost, + } + if err := ts.CheckDerpConnection(ctx, baseURL); err != nil { + return nil, fmt.Errorf("failed to verify DERP connection: %w", err) + } + return baseURL, nil +} + +func (s *WorkspaceServer) initNetworkServer(ctx context.Context, controlURL *url.URL) error { + store, _ := mem.New(s.config.LogF, "") + envknob.Setenv("TS_DEBUG_TLS_DIAL_INSECURE_SKIP_VERIFY", "true") + s.log.Infof("Connecting to control URL - %s/coordinator/", controlURL.String()) + s.network = &tsnet.Server{ // TODO: this probably could be extracted from here and local daemon into pkg/ts + Hostname: s.config.WorkspaceHost, + Logf: s.config.LogF, + ControlURL: controlURL.String() + "/coordinator/", + AuthKey: s.config.AccessKey, + Dir: s.config.RootDir, + Ephemeral: true, + Store: store, + } + if _, err := s.network.Up(ctx); err != nil { + return err + } + return nil +} + +func (s *WorkspaceServer) parseWorkspaceHostname() (workspace, project string, err error) { + parts := strings.Split(s.config.WorkspaceHost, ".") + if len(parts) < 4 { + return "", "", fmt.Errorf("invalid workspace hostname format: %s", s.config.WorkspaceHost) + } + return parts[1], parts[2], nil +} diff --git a/pkg/daemon/workspace/network/ssh.go b/pkg/daemon/workspace/network/ssh.go new file mode 100644 index 000000000..587b75f9a --- /dev/null +++ b/pkg/daemon/workspace/network/ssh.go @@ -0,0 +1,98 @@ +package network + +import ( + "context" + "fmt" + "io" + "net" + + sshServer "github.com/loft-sh/devpod/pkg/ssh/server" + "github.com/loft-sh/log" + "tailscale.com/tsnet" +) + +// SSHService handles SSH connections. +type SSHService struct { + listener net.Listener + tsServer *tsnet.Server + log log.Logger + tracker *ConnTracker +} + +// NewSSHService creates a new SSHService. +func NewSSHService(tsServer *tsnet.Server, tracker *ConnTracker, log log.Logger) (*SSHService, error) { + l, err := tsServer.Listen("tcp", fmt.Sprintf(":%d", sshServer.DefaultUserPort)) + if err != nil { + return nil, fmt.Errorf("failed to listen for SSH on port %d: %w", sshServer.DefaultUserPort, err) + } + return &SSHService{ + listener: l, + tsServer: tsServer, + log: log, + tracker: tracker, + }, nil +} + +// Start begins accepting SSH connections. +func (s *SSHService) Start(ctx context.Context) { + s.log.Infof("Starting SSH listener") + go s.acceptLoop(ctx) +} + +func (s *SSHService) acceptLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + conn, err := s.listener.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + s.log.Errorf("SSHService: failed to accept connection: %v", err) + continue + } + go s.handleConnection(conn) + } +} + +func (s *SSHService) handleConnection(conn net.Conn) { + s.tracker.Add("SSHService") + defer s.tracker.Remove("SSHService") + defer conn.Close() + + localAddr := fmt.Sprintf("127.0.0.1:%d", sshServer.DefaultUserPort) + backendConn, err := net.Dial("tcp", localAddr) + if err != nil { + s.log.Errorf("SSHService: failed to connect to local address %s: %v", localAddr, err) + return + } + defer backendConn.Close() + + // We need to wait for copying to finish before the function returns and Remove is called. + errChan := make(chan error, 2) + + go func() { + _, err := io.Copy(backendConn, conn) + errChan <- err + }() + + go func() { + _, err := io.Copy(conn, backendConn) + errChan <- err + }() + + // Wait for one side of the connection to close or error + <-errChan + // Optionally wait for the second one too, or just proceed to cleanup + // <-errChan +} + +// Stop stops the SSH server by closing its listener. +func (s *SSHService) Stop() { + if s.listener != nil { + s.listener.Close() + } +} diff --git a/pkg/daemon/workspace/network/util.go b/pkg/daemon/workspace/network/util.go new file mode 100644 index 000000000..c1ee72636 --- /dev/null +++ b/pkg/daemon/workspace/network/util.go @@ -0,0 +1,48 @@ +package network + +import ( + "context" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/loft-sh/log" + "tailscale.com/client/tailscale" +) + +// newReverseProxy creates a reverse proxy to the target and applies header modifications. +func newReverseProxy(target *url.URL, modifyHeaders func(http.Header)) *httputil.ReverseProxy { + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.Director = func(req *http.Request) { + dest := *target + req.URL = &dest + req.Host = dest.Host + modifyHeaders(req.Header) + } + return proxy +} + +// discoverRunner finds a peer whose hostname ends with "runner". +func discoverRunner(ctx context.Context, lc *tailscale.LocalClient, log log.Logger) (string, error) { + status, err := lc.Status(ctx) + if err != nil { + return "", fmt.Errorf("failed to get status: %w", err) + } + var runner string + for _, peer := range status.Peer { + if peer == nil || peer.HostName == "" { + continue + } + if strings.HasSuffix(peer.HostName, "runner") { + runner = peer.HostName + break + } + } + if runner == "" { + return "", fmt.Errorf("no active runner found") + } + log.Infof("discoverRunner: selected runner = %s", runner) + return runner, nil +} diff --git a/pkg/daemon/agent/reaper_linux.go b/pkg/daemon/workspace/reaper_linux.go similarity index 87% rename from pkg/daemon/agent/reaper_linux.go rename to pkg/daemon/workspace/reaper_linux.go index 7a6f08d6c..6c89c28ec 100644 --- a/pkg/daemon/agent/reaper_linux.go +++ b/pkg/daemon/workspace/reaper_linux.go @@ -1,7 +1,7 @@ //go:build linux // +build linux -package agent +package workspace import reaper "github.com/ramr/go-reaper" diff --git a/pkg/daemon/agent/reaper_stub.go b/pkg/daemon/workspace/reaper_stub.go similarity index 78% rename from pkg/daemon/agent/reaper_stub.go rename to pkg/daemon/workspace/reaper_stub.go index 235234b47..bf4ba762b 100644 --- a/pkg/daemon/agent/reaper_stub.go +++ b/pkg/daemon/workspace/reaper_stub.go @@ -1,6 +1,6 @@ //go:build !linux // +build !linux -package agent +package workspace func RunProcessReaper() {} diff --git a/pkg/daemon/workspace/ssh.go b/pkg/daemon/workspace/ssh.go new file mode 100644 index 000000000..dfda85969 --- /dev/null +++ b/pkg/daemon/workspace/ssh.go @@ -0,0 +1,57 @@ +package workspace + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "syscall" +) + +// RunSshServer starts the SSH server. +func RunSshServer(ctx context.Context, d *Daemon, errChan chan<- error, wg *sync.WaitGroup) { + defer wg.Done() + binaryPath, err := os.Executable() + if err != nil { + errChan <- err + return + } + + args := []string{"agent", "container", "ssh-server"} + if d.Config.Ssh.Workdir != "" { + args = append(args, "--workdir", d.Config.Ssh.Workdir) + } + if d.Config.Ssh.User != "" { + args = append(args, "--remote-user", d.Config.Ssh.User) + } + + sshCmd := exec.Command(binaryPath, args...) + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + if err := sshCmd.Start(); err != nil { + errChan <- fmt.Errorf("failed to start SSH server: %w", err) + return + } + + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + if sshCmd.Process != nil { + if err := sshCmd.Process.Signal(syscall.SIGTERM); err != nil { + errChan <- fmt.Errorf("failed to send SIGTERM to SSH server: %w", err) + } + } + case <-done: + } + }() + + if err := sshCmd.Wait(); err != nil { + errChan <- fmt.Errorf("SSH server exited abnormally: %w", err) + close(done) + return + } + close(done) +} diff --git a/pkg/daemon/workspace/timeout.go b/pkg/daemon/workspace/timeout.go new file mode 100644 index 000000000..cf7fb10a8 --- /dev/null +++ b/pkg/daemon/workspace/timeout.go @@ -0,0 +1,40 @@ +package workspace + +import ( + "context" + "os" + "sync" + "time" + + "github.com/loft-sh/devpod/pkg/agent" + "github.com/pkg/errors" +) + +func SetupActivityFile() error { + if err := os.WriteFile(agent.ContainerActivityFile, nil, 0777); err != nil { + return err + } + return os.Chmod(agent.ContainerActivityFile, 0777) +} + +// RunTimeoutMonitor monitors the activity file and sends an error if the timeout is exceeded. +func RunTimeoutMonitor(ctx context.Context, duration time.Duration, errChan chan<- error, wg *sync.WaitGroup) { + defer wg.Done() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + stat, err := os.Stat(agent.ContainerActivityFile) + if err != nil { + continue + } + if !stat.ModTime().Add(duration).After(time.Now()) { + errChan <- errors.New("timeout reached, terminating daemon") + return + } + } + } +} diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 8ed2ea79d..9c6552e95 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -20,6 +20,7 @@ import ( "github.com/loft-sh/devpod/pkg/driver" "github.com/loft-sh/devpod/pkg/ide" provider2 "github.com/loft-sh/devpod/pkg/provider" + "github.com/loft-sh/devpod/pkg/stdio" "github.com/loft-sh/log" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -127,8 +128,7 @@ func (r *runner) setupContainer( runSetupServer := func(ctx context.Context, stdin io.WriteCloser, stdout io.Reader) (*config.Result, error) { return tunnelserver.RunSetupServer( ctx, - stdout, - stdin, + stdio.NewStdioListener(stdout, stdin, false), r.WorkspaceConfig.Agent.InjectGitCredentials != "false", r.WorkspaceConfig.Agent.InjectDockerCredentials != "false", config.GetMounts(result), diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index cfb123bfe..94358d03e 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/loft-sh/devpod/pkg/daemon/agent" + workspaced "github.com/loft-sh/devpod/pkg/daemon/workspace" "github.com/loft-sh/devpod/pkg/devcontainer/config" "github.com/loft-sh/devpod/pkg/devcontainer/metadata" "github.com/loft-sh/devpod/pkg/driver" @@ -131,7 +131,7 @@ func (r *runner) runSingleContainer( if options.CLIOptions.Platform.AccessKey != "" { r.Log.Debugf("Platform config detected, injecting DevPod daemon entrypoint.") - data, err := agent.GetEncodedWorkspaceDaemonConfig(options.Platform, r.WorkspaceConfig.Workspace, substitutionContext, mergedConfig) + data, err := workspaced.GetEncodedWorkspaceDaemonConfig(options.Platform, r.WorkspaceConfig.Workspace, substitutionContext, mergedConfig) if err != nil { r.Log.Errorf("Failed to marshal daemon config: %v", err) } else { diff --git a/pkg/ts/util.go b/pkg/ts/util.go index d1515e147..225c922a4 100644 --- a/pkg/ts/util.go +++ b/pkg/ts/util.go @@ -41,6 +41,7 @@ func ParseWorkspaceHostname(hostname string) (name string, project string, err e return name, project, nil } +// GetURL builds network url from host and port func GetURL(host string, port int) string { if port == 0 { return fmt.Sprintf("%s.%s", host, LoftTSNetDomain) @@ -48,6 +49,19 @@ func GetURL(host string, port int) string { return fmt.Sprintf("%s.%s:%d", host, LoftTSNetDomain, port) } +// EnsureURL ensures that the given hostOrUrl is a valid URL. +func EnsureURL(hostOrUrl string, port int) string { + // ts peer name from netmap might end with a dot, so we need to trim it + hostOrUrl = strings.TrimSuffix(hostOrUrl, ".") + if strings.HasSuffix(hostOrUrl, LoftTSNetDomain) { + if port == 0 { + return hostOrUrl + } + return fmt.Sprintf("%s:%d", hostOrUrl, port) + } + return GetURL(hostOrUrl, port) +} + // WaitHostReachable polls until the given host is reachable via ts. func WaitHostReachable(ctx context.Context, lc *tailscale.LocalClient, addr Addr, maxRetries int, log log.Logger) error { for i := 0; i < maxRetries; i++ { diff --git a/pkg/ts/workspace_server.go b/pkg/ts/workspace_server.go deleted file mode 100644 index 52bd4f37b..000000000 --- a/pkg/ts/workspace_server.go +++ /dev/null @@ -1,488 +0,0 @@ -package ts - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/httputil" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/loft-sh/log" - - "github.com/loft-sh/devpod/pkg/platform/client" - sshServer "github.com/loft-sh/devpod/pkg/ssh/server" - "tailscale.com/client/tailscale" - "tailscale.com/envknob" - "tailscale.com/ipn/store/mem" - "tailscale.com/tsnet" - "tailscale.com/types/netmap" -) - -const ( - // TSPortForwardPort is the fixed port on which the workspace WebSocket reverse proxy listens. - TSPortForwardPort string = "12051" - - RunnerProxySocket string = "runner-proxy.sock" - - netMapCooldown = 30 * time.Second -) - -// WorkspaceServer holds the TSNet server and its listeners. -type WorkspaceServer struct { - tsServer *tsnet.Server - listeners []net.Listener - - connectionCounter int - connectionCounterMu sync.Mutex - - config *WorkspaceServerConfig - log log.Logger -} - -// WorkspaceServerConfig defines configuration for the TSNet instance. -type WorkspaceServerConfig struct { - AccessKey string - PlatformHost string - WorkspaceHost string - LogF func(format string, args ...interface{}) - Client client.Client - RootDir string -} - -// NewWorkspaceServer creates a new TSNet server instance. -func NewWorkspaceServer(config *WorkspaceServerConfig, logger log.Logger) *WorkspaceServer { - return &WorkspaceServer{ - config: config, - log: logger, - } -} - -// Start initializes the TSNet server, sets up listeners for SSH and HTTP -// reverse proxy traffic, and waits until the given context is canceled. -func (s *WorkspaceServer) Start(ctx context.Context) error { - s.log.Infof("Starting workspace server") - - // Perform TSNet initialization (validation, control URL, server startup, hostname parsing) - workspaceName, projectName, err := s.setupTSNet(ctx) - if err != nil { - return err - } - lc, err := s.tsServer.LocalClient() - if err != nil { - return err - } - - // send heartbeats - go s.sendHeartbeats(ctx, projectName, workspaceName, lc) - - // Start both SSH and HTTP reverse proxy listeners - if err := s.startListeners(ctx, projectName, workspaceName, lc); err != nil { - return err - } - - go func() { - lastUpdate := time.Now() - if err := WatchNetmap(ctx, lc, func(netMap *netmap.NetworkMap) { - if time.Since(lastUpdate) < netMapCooldown { - return - } - lastUpdate = time.Now() - - nm, err := json.Marshal(netMap) - if err != nil { - s.log.Errorf("Failed to marshal netmap: %v", err) - } else { - _ = os.WriteFile(filepath.Join(s.config.RootDir, "netmap.json"), nm, 0o644) - } - }); err != nil { - s.log.Errorf("Failed to watch netmap: %v", err) - } - }() - - // Wait until the context is canceled. - <-ctx.Done() - return nil -} - -// Stop shuts down all listeners and the TSNet server. -func (s *WorkspaceServer) Stop() { - for _, listener := range s.listeners { - if listener != nil { - listener.Close() - } - } - if s.tsServer != nil { - s.tsServer.Close() - s.tsServer = nil - } - s.log.Info("Tailscale server stopped") -} - -// Dial dials the given address using the TSNet server. -func (s *WorkspaceServer) Dial(ctx context.Context, network, addr string) (net.Conn, error) { - if s.tsServer == nil { - return nil, fmt.Errorf("tailscale server is not running") - } - return s.tsServer.Dial(ctx, network, addr) -} - -// setupTSNet validates configuration, sets up the control URL, starts the TSNet server, -// and parses the hostname into workspace and project names. -func (s *WorkspaceServer) setupTSNet(ctx context.Context) (workspace, project string, err error) { - if err = s.validateConfig(); err != nil { - return "", "", err - } - - baseURL, err := s.setupControlURL(ctx) - if err != nil { - return "", "", err - } - - if err = s.initTsServer(ctx, baseURL); err != nil { - return "", "", err - } - - return s.parseWorkspaceHostname() -} - -// validateConfig ensures required configuration values are set. -func (s *WorkspaceServer) validateConfig() error { - if s.config.AccessKey == "" || s.config.PlatformHost == "" || s.config.WorkspaceHost == "" { - return fmt.Errorf("access key, host, or hostname cannot be empty") - } - return nil -} - -// setupControlURL constructs the control URL and verifies DERP connection. -func (s *WorkspaceServer) setupControlURL(ctx context.Context) (*url.URL, error) { - baseURL := &url.URL{ - Scheme: GetEnvOrDefault("LOFT_TSNET_SCHEME", "https"), - Host: s.config.PlatformHost, - } - if err := CheckDerpConnection(ctx, baseURL); err != nil { - return nil, fmt.Errorf("failed to verify DERP connection: %w", err) - } - return baseURL, nil -} - -// initTsServer initializes the TSNet server. -func (s *WorkspaceServer) initTsServer(ctx context.Context, controlURL *url.URL) error { - store, _ := mem.New(s.config.LogF, "") - envknob.Setenv("TS_DEBUG_TLS_DIAL_INSECURE_SKIP_VERIFY", "true") - s.log.Infof("Connecting to control URL - %s/coordinator/", controlURL.String()) - s.tsServer = &tsnet.Server{ - Hostname: s.config.WorkspaceHost, - Logf: s.config.LogF, - ControlURL: controlURL.String() + "/coordinator/", - AuthKey: s.config.AccessKey, - Dir: s.config.RootDir, - Ephemeral: true, - Store: store, - } - if _, err := s.tsServer.Up(ctx); err != nil { - return err - } - return nil -} - -// parseHostname extracts workspace and project names from the hostname. -func (s *WorkspaceServer) parseWorkspaceHostname() (workspace, project string, err error) { - parts := strings.Split(s.config.WorkspaceHost, ".") - if len(parts) < 4 { - return "", "", fmt.Errorf("invalid workspace hostname format: %s", s.config.WorkspaceHost) - } - return parts[1], parts[2], nil -} - -// startListeners creates and starts the SSH and HTTP reverse proxy listeners. -func (s *WorkspaceServer) startListeners(ctx context.Context, projectName, workspaceName string, lc *tailscale.LocalClient) error { - // Create and start the SSH listener. - s.log.Infof("Starting SSH listener") - sshListener, err := s.createListener(fmt.Sprintf(":%d", sshServer.DefaultUserPort)) - if err != nil { - return err - } - - // Create and start the HTTP reverse proxy listener. - s.log.Infof("Starting HTTP reverse proxy listener on TSNet port %s", TSPortForwardPort) - wsListener, err := s.createListener(fmt.Sprintf(":%s", TSPortForwardPort)) - if err != nil { - return fmt.Errorf("failed to create listener on TS port %s: %w", TSPortForwardPort, err) - } - - // Create and start the platform HTTP git credentials listener - runnerProxySocket := filepath.Join(s.config.RootDir, RunnerProxySocket) - s.log.Infof("Starting runner proxy socket on %s", runnerProxySocket) - _ = os.Remove(runnerProxySocket) - runnerProxyListener, err := net.Listen("unix", runnerProxySocket) - if err != nil { - return fmt.Errorf("failed to create listener on TS port %s: %w", TSPortForwardPort, err) - } - - // make sure all users can access the socket - _ = os.Chmod(runnerProxySocket, 0o777) - - // add all listeners to the list - s.listeners = append(s.listeners, sshListener, wsListener, runnerProxyListener) - - // Setup HTTP handler for git credentials - go func() { - mux := http.NewServeMux() - transport := &http.Transport{DialContext: s.tsServer.Dial} - mux.HandleFunc("/git-credentials", func(w http.ResponseWriter, r *http.Request) { - s.gitCredentialsHandler(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() - mux.HandleFunc("/portforward", s.httpPortForwardHandler) - if err := http.Serve(wsListener, mux); err != nil && err != http.ErrServerClosed { - s.log.Errorf("HTTP server error on TS port %s: %v", TSPortForwardPort, err) - } - }() - - // Start handling SSH connections. - go s.handleSSHConnections(ctx, sshListener) - - return nil -} - -// createListener creates a raw listener and wraps it with connection tracking. -func (s *WorkspaceServer) createListener(addr string) (net.Listener, error) { - l, err := s.tsServer.Listen("tcp", addr) - if err != nil { - return nil, fmt.Errorf("failed to listen on %s: %w", addr, err) - } - - // create a new tracked listener to track the number of connections - return l, nil -} - -func (s *WorkspaceServer) addConnection() { - s.connectionCounterMu.Lock() - defer s.connectionCounterMu.Unlock() - s.connectionCounter++ -} - -func (s *WorkspaceServer) removeConnection() { - s.connectionCounterMu.Lock() - defer s.connectionCounterMu.Unlock() - s.connectionCounter-- -} - -// httpPortForwardHandler is the HTTP reverse proxy handler for workspace. -// It reconstructs the target URL using custom headers and forwards the request. -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) - - // 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-git-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) { - s.addConnection() - defer s.removeConnection() - s.log.Debugf("httpPortForwardHandler: starting") - - // Retrieve required custom headers. - targetPort := r.Header.Get("X-Loft-Forward-Port") - baseForwardStr := r.Header.Get("X-Loft-Forward-Url") - if targetPort == "" || baseForwardStr == "" { - http.Error(w, "missing required X-Loft headers", http.StatusBadRequest) - return - } - s.log.Debugf("httpPortForwardHandler: received headers: X-Loft-Forward-Port=%s, X-Loft-Forward-Url=%s", targetPort, baseForwardStr) - - // Parse and modify the URL to target the local endpoint. - parsedURL, err := url.Parse(baseForwardStr) - if err != nil { - s.log.Errorf("httpPortForwardHandler: failed to parse base URL: %v", err) - http.Error(w, "invalid base forward URL", http.StatusBadRequest) - return - } - parsedURL.Scheme = "http" - parsedURL.Host = "127.0.0.1:" + targetPort - s.log.Debugf("httpPortForwardHandler: final target URL=%s", parsedURL.String()) - - // 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 - // Remove custom headers so they are not forwarded. - req.Header.Del("X-Loft-Forward-Port") - req.Header.Del("X-Loft-Forward-Url") - req.Header.Del("X-Loft-Forward-Authorization") - } - proxy.Transport = http.DefaultTransport - - s.log.Infof("httpPortForwardHandler: final proxied request: %s %s", r.Method, parsedURL.String()) - proxy.ServeHTTP(w, r) -} - -// handleSSHConnections continuously accepts SSH connections and handles each one. -func (s *WorkspaceServer) handleSSHConnections(ctx context.Context, listener net.Listener) { - for { - select { - case <-ctx.Done(): - return - default: - } - clientConn, err := listener.Accept() - if err != nil { - if ctx.Err() != nil { - return - } - s.log.Errorf("Failed to accept connection: %v", err) - continue - } - go s.handleSSHConnection(clientConn) - } -} - -// handleSSHConnection proxies the SSH connection to the local backend. -func (s *WorkspaceServer) handleSSHConnection(clientConn net.Conn) { - s.addConnection() - defer s.removeConnection() - defer clientConn.Close() - - localAddr := fmt.Sprintf("127.0.0.1:%d", sshServer.DefaultUserPort) - backendConn, err := net.Dial("tcp", localAddr) - if err != nil { - s.log.Errorf("Failed to connect to local address %s: %v", localAddr, err) - return - } - defer backendConn.Close() - - // Start bidirectional copy between client and backend. - go func() { - defer clientConn.Close() - defer backendConn.Close() - _, err = io.Copy(backendConn, clientConn) - }() - _, err = io.Copy(clientConn, backendConn) -} - -func (s *WorkspaceServer) sendHeartbeats(ctx context.Context, projectName, workspaceName string, lc *tailscale.LocalClient) { - // create a new http client with a custom transport - transport := &http.Transport{DialContext: s.tsServer.Dial} - client := &http.Client{Transport: transport, Timeout: 10 * time.Second} - - // create a ticker to send heartbeats every 10 seconds - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // get the current number of connections - s.connectionCounterMu.Lock() - connections := s.connectionCounter - s.connectionCounterMu.Unlock() - - // send a heartbeat if there are connections - if connections > 0 { - err := s.sendHeartbeat(ctx, client, projectName, workspaceName, lc, connections) - if err != nil { - s.log.Errorf("Failed to send heartbeat: %v", err) - } - } - } - } -} - -func (s *WorkspaceServer) sendHeartbeat(ctx context.Context, client *http.Client, projectName, workspaceName string, lc *tailscale.LocalClient, connections int) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - discoveredRunner, err := s.discoverRunner(ctx, lc) - if err != nil { - return fmt.Errorf("failed to discover runner: %w", err) - } - - heartbeatURL := fmt.Sprintf("http://%s.ts.loft/devpod/%s/%s/heartbeat", discoveredRunner, projectName, workspaceName) - s.log.Infof("Sending heartbeat to %s, because there are %d active connections", heartbeatURL, connections) - req, err := http.NewRequestWithContext(ctx, "GET", heartbeatURL, nil) - if err != nil { - return fmt.Errorf("failed to create request for %s: %w", heartbeatURL, err) - } - - req.Header.Set("Authorization", "Bearer "+s.config.AccessKey) - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("request to %s failed: %w", heartbeatURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("received response from %s - Status: %d", heartbeatURL, resp.StatusCode) - } - s.log.Infof("received response from %s - Status: %d", heartbeatURL, resp.StatusCode) - return nil -} - -// discoverRunner attempts to find the runner peer from the TSNet status. -func (s *WorkspaceServer) discoverRunner(ctx context.Context, lc *tailscale.LocalClient) (string, error) { - status, err := lc.Status(ctx) - if err != nil { - return "", fmt.Errorf("failed to get status: %w", err) - } - - var runner string - for _, peer := range status.Peer { - if peer == nil || peer.HostName == "" { - continue - } - - if strings.HasSuffix(peer.HostName, "runner") { - runner = peer.HostName - break - } - } - if runner == "" { - return "", fmt.Errorf("no active runner found") - } - - s.log.Infof("discoverRunner: selected runner = %s", runner) - return runner, nil -} diff --git a/pkg/tunnel/services.go b/pkg/tunnel/services.go index 11b8c30c5..97af2f1ae 100644 --- a/pkg/tunnel/services.go +++ b/pkg/tunnel/services.go @@ -43,6 +43,7 @@ func RunServices( platformOptions *devpod.PlatformOptions, workspace *provider.Workspace, configureDockerCredentials, configureGitCredentials, configureGitSSHSignatureHelper bool, + client string, log log.Logger, ) error { // calculate exit after timeout @@ -87,15 +88,26 @@ func RunServices( forwarder = newForwarder(containerClient, append(forwardedPorts, fmt.Sprintf("%d", openvscode.DefaultVSCodePort)), log) } + // Create channels for the port and errors. + portChan := make(chan int, 1) errChan := make(chan error, 1) + go func() { defer cancel() defer stdinWriter.Close() - // forward credentials to container - err := tunnelserver.RunServicesServer( + listener, port, err := tunnelserver.GetListener(client, stdoutReader, stdinWriter, false, log) + if err != nil { + errChan <- errors.Wrap(err, "create tunnel server listener") + return + } + // Send the generated port back. + portChan <- port + defer listener.Close() + + // Start local credentials server on clients machine and forward credentials to container + err = tunnelserver.RunServicesServer( cancelCtx, - stdoutReader, - stdinWriter, + listener, configureGitCredentials, configureDockerCredentials, forwarder, @@ -109,14 +121,35 @@ func RunServices( close(errChan) }() - // run credentials server + log.Debugf("Waiting for credentials server port to be assigned...") + var port int + select { + case port = <-portChan: + if port != 0 { + log.Infof("Credentials server running on port %d\n", port) + } + case err = <-errChan: + return err + } + + // Run credentials server process. writer := log.ErrorStreamOnly().Writer(logrus.DebugLevel, false) defer writer.Close() command := fmt.Sprintf("'%s' agent container credentials-server --user '%s'", agent.ContainerDevPodHelperLocation, user) + + if client != "" { + command += fmt.Sprintf(" --client '%s'", client) + } + if configureGitCredentials { command += " --configure-git-helper" } + + if port != 0 { + command += fmt.Sprintf(" --port %d", port) + } + if configureGitSSHSignatureHelper { format, userSigningKey, err := gitsshsigning.ExtractGitConfiguration() if err == nil && format == gitsshsigning.GPGFormatSSH && userSigningKey != "" { diff --git a/pkg/workspace/list.go b/pkg/workspace/list.go index 6e1268d9f..d9c159357 100644 --- a/pkg/workspace/list.go +++ b/pkg/workspace/list.go @@ -15,7 +15,7 @@ import ( storagev1 "github.com/loft-sh/api/v4/pkg/apis/storage/v1" "github.com/loft-sh/devpod/pkg/client/clientimplementation" "github.com/loft-sh/devpod/pkg/config" - daemon "github.com/loft-sh/devpod/pkg/daemon/platform" + daemon "github.com/loft-sh/devpod/pkg/daemon/local" "github.com/loft-sh/devpod/pkg/platform" providerpkg "github.com/loft-sh/devpod/pkg/provider" "github.com/loft-sh/devpod/pkg/types" diff --git a/vendor/github.com/mwitkow/grpc-proxy/LICENSE.txt b/vendor/github.com/mwitkow/grpc-proxy/LICENSE.txt new file mode 100644 index 000000000..cbfdef8c5 --- /dev/null +++ b/vendor/github.com/mwitkow/grpc-proxy/LICENSE.txt @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. \ No newline at end of file diff --git a/vendor/github.com/mwitkow/grpc-proxy/proxy/codec.go b/vendor/github.com/mwitkow/grpc-proxy/proxy/codec.go new file mode 100644 index 000000000..cd1a4be85 --- /dev/null +++ b/vendor/github.com/mwitkow/grpc-proxy/proxy/codec.go @@ -0,0 +1,69 @@ +package proxy + +import ( + "fmt" + + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// Codec returns a proxying grpc.Codec with the default protobuf codec as parent. +// +// See CodecWithParent. +// +// Deprecated: No longer necessary. +func Codec() grpc.Codec { + return CodecWithParent(&protoCodec{}) +} + +// CodecWithParent returns a proxying grpc.Codec with a user provided codec as parent. +// +// Deprecated: No longer necessary. +func CodecWithParent(fallback grpc.Codec) grpc.Codec { + return &rawCodec{fallback} +} + +type rawCodec struct { + parentCodec grpc.Codec +} + +type frame struct { + payload []byte +} + +func (c *rawCodec) Marshal(v interface{}) ([]byte, error) { + out, ok := v.(*frame) + if !ok { + return c.parentCodec.Marshal(v) + } + return out.payload, nil + +} + +func (c *rawCodec) Unmarshal(data []byte, v interface{}) error { + dst, ok := v.(*frame) + if !ok { + return c.parentCodec.Unmarshal(data, v) + } + dst.payload = data + return nil +} + +func (c *rawCodec) String() string { + return fmt.Sprintf("proxy>%s", c.parentCodec.String()) +} + +// protoCodec is a Codec implementation with protobuf. It is the default rawCodec for gRPC. +type protoCodec struct{} + +func (protoCodec) Marshal(v interface{}) ([]byte, error) { + return proto.Marshal(v.(proto.Message)) +} + +func (protoCodec) Unmarshal(data []byte, v interface{}) error { + return proto.Unmarshal(data, v.(proto.Message)) +} + +func (protoCodec) String() string { + return "proto" +} diff --git a/vendor/github.com/mwitkow/grpc-proxy/proxy/director.go b/vendor/github.com/mwitkow/grpc-proxy/proxy/director.go new file mode 100644 index 000000000..e0fae50cc --- /dev/null +++ b/vendor/github.com/mwitkow/grpc-proxy/proxy/director.go @@ -0,0 +1,24 @@ +// Copyright 2017 Michal Witkowski. All Rights Reserved. +// See LICENSE for licensing terms. + +package proxy + +import ( + "context" + "google.golang.org/grpc" +) + +// StreamDirector returns a gRPC ClientConn to be used to forward the call to. +// +// The presence of the `Context` allows for rich filtering, e.g. based on Metadata (headers). +// If no handling is meant to be done, a `codes.NotImplemented` gRPC error should be returned. +// +// The context returned from this function should be the context for the *outgoing* (to backend) call. In case you want +// to forward any Metadata between the inbound request and outbound requests, you should do it manually. However, you +// *must* propagate the cancel function (`context.WithCancel`) of the inbound context to the one returned. +// +// It is worth noting that the StreamDirector will be fired *after* all server-side stream interceptors +// are invoked. So decisions around authorization, monitoring etc. are better to be handled there. +// +// See the rather rich example. +type StreamDirector func(ctx context.Context, fullMethodName string) (context.Context, *grpc.ClientConn, error) diff --git a/vendor/github.com/mwitkow/grpc-proxy/proxy/doc.go b/vendor/github.com/mwitkow/grpc-proxy/proxy/doc.go new file mode 100644 index 000000000..613b5c491 --- /dev/null +++ b/vendor/github.com/mwitkow/grpc-proxy/proxy/doc.go @@ -0,0 +1,15 @@ +// Copyright 2017 Michal Witkowski. All Rights Reserved. +// See LICENSE for licensing terms. + +/* +Package proxy provides a reverse proxy handler for gRPC. + +The implementation allows a grpc.Server to pass a received ServerStream to a ClientStream without understanding +the semantics of the messages exchanged. It basically provides a transparent reverse-proxy. + +This package is intentionally generic, exposing a StreamDirector function that allows users of this package +to implement whatever logic of backend-picking, dialing and service verification to perform. + +See examples on documented functions. +*/ +package proxy diff --git a/vendor/github.com/mwitkow/grpc-proxy/proxy/handler.go b/vendor/github.com/mwitkow/grpc-proxy/proxy/handler.go new file mode 100644 index 000000000..745a739aa --- /dev/null +++ b/vendor/github.com/mwitkow/grpc-proxy/proxy/handler.go @@ -0,0 +1,160 @@ +// Copyright 2017 Michal Witkowski. All Rights Reserved. +// See LICENSE for licensing terms. + +package proxy + +import ( + "context" + "io" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +var ( + clientStreamDescForProxying = &grpc.StreamDesc{ + ServerStreams: true, + ClientStreams: true, + } +) + +// RegisterService sets up a proxy handler for a particular gRPC service and method. +// The behaviour is the same as if you were registering a handler method, e.g. from a generated pb.go file. +func RegisterService(server *grpc.Server, director StreamDirector, serviceName string, methodNames ...string) { + streamer := &handler{director} + fakeDesc := &grpc.ServiceDesc{ + ServiceName: serviceName, + HandlerType: (*interface{})(nil), + } + for _, m := range methodNames { + streamDesc := grpc.StreamDesc{ + StreamName: m, + Handler: streamer.handler, + ServerStreams: true, + ClientStreams: true, + } + fakeDesc.Streams = append(fakeDesc.Streams, streamDesc) + } + server.RegisterService(fakeDesc, streamer) +} + +// TransparentHandler returns a handler that attempts to proxy all requests that are not registered in the server. +// The indented use here is as a transparent proxy, where the server doesn't know about the services implemented by the +// backends. It should be used as a `grpc.UnknownServiceHandler`. +func TransparentHandler(director StreamDirector) grpc.StreamHandler { + streamer := &handler{director: director} + return streamer.handler +} + +type handler struct { + director StreamDirector +} + +// handler is where the real magic of proxying happens. +// It is invoked like any gRPC server stream and uses the emptypb.Empty type server +// to proxy calls between the input and output streams. +func (s *handler) handler(srv interface{}, serverStream grpc.ServerStream) error { + // little bit of gRPC internals never hurt anyone + fullMethodName, ok := grpc.MethodFromServerStream(serverStream) + if !ok { + return status.Errorf(codes.Internal, "lowLevelServerStream not exists in context") + } + // We require that the director's returned context inherits from the serverStream.Context(). + outgoingCtx, backendConn, err := s.director(serverStream.Context(), fullMethodName) + if err != nil { + return err + } + + clientCtx, clientCancel := context.WithCancel(outgoingCtx) + defer clientCancel() + // TODO(mwitkow): Add a `forwarded` header to metadata, https://en.wikipedia.org/wiki/X-Forwarded-For. + clientStream, err := grpc.NewClientStream(clientCtx, clientStreamDescForProxying, backendConn, fullMethodName) + if err != nil { + return err + } + // Explicitly *do not close* s2cErrChan and c2sErrChan, otherwise the select below will not terminate. + // Channels do not have to be closed, it is just a control flow mechanism, see + // https://groups.google.com/forum/#!msg/golang-nuts/pZwdYRGxCIk/qpbHxRRPJdUJ + s2cErrChan := s.forwardServerToClient(serverStream, clientStream) + c2sErrChan := s.forwardClientToServer(clientStream, serverStream) + // We don't know which side is going to stop sending first, so we need a select between the two. + for i := 0; i < 2; i++ { + select { + case s2cErr := <-s2cErrChan: + if s2cErr == io.EOF { + // this is the happy case where the sender has encountered io.EOF, and won't be sending anymore./ + // the clientStream>serverStream may continue pumping though. + clientStream.CloseSend() + } else { + // however, we may have gotten a receive error (stream disconnected, a read error etc) in which case we need + // to cancel the clientStream to the backend, let all of its goroutines be freed up by the CancelFunc and + // exit with an error to the stack + clientCancel() + return status.Errorf(codes.Internal, "failed proxying s2c: %v", s2cErr) + } + case c2sErr := <-c2sErrChan: + // This happens when the clientStream has nothing else to offer (io.EOF), returned a gRPC error. In those two + // cases we may have received Trailers as part of the call. In case of other errors (stream closed) the trailers + // will be nil. + serverStream.SetTrailer(clientStream.Trailer()) + // c2sErr will contain RPC error from client code. If not io.EOF return the RPC error as server stream error. + if c2sErr != io.EOF { + return c2sErr + } + return nil + } + } + return status.Errorf(codes.Internal, "gRPC proxying should never reach this stage.") +} + +func (s *handler) forwardClientToServer(src grpc.ClientStream, dst grpc.ServerStream) chan error { + ret := make(chan error, 1) + go func() { + f := &emptypb.Empty{} + for i := 0; ; i++ { + if err := src.RecvMsg(f); err != nil { + ret <- err // this can be io.EOF which is happy case + break + } + if i == 0 { + // This is a bit of a hack, but client to server headers are only readable after first client msg is + // received but must be written to server stream before the first msg is flushed. + // This is the only place to do it nicely. + md, err := src.Header() + if err != nil { + ret <- err + break + } + if err := dst.SendHeader(md); err != nil { + ret <- err + break + } + } + if err := dst.SendMsg(f); err != nil { + ret <- err + break + } + } + }() + return ret +} + +func (s *handler) forwardServerToClient(src grpc.ServerStream, dst grpc.ClientStream) chan error { + ret := make(chan error, 1) + go func() { + f := &emptypb.Empty{} + for i := 0; ; i++ { + if err := src.RecvMsg(f); err != nil { + ret <- err // this can be io.EOF which is happy case + break + } + if err := dst.SendMsg(f); err != nil { + ret <- err + break + } + } + }() + return ret +} diff --git a/vendor/github.com/mwitkow/grpc-proxy/proxy/proxy.go b/vendor/github.com/mwitkow/grpc-proxy/proxy/proxy.go new file mode 100644 index 000000000..fc33121b9 --- /dev/null +++ b/vendor/github.com/mwitkow/grpc-proxy/proxy/proxy.go @@ -0,0 +1,33 @@ +// Copyright 2021 Michal Witkowski. All Rights Reserved. +// See LICENSE for licensing terms. + +package proxy + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// NewProxy sets up a simple proxy that forwards all requests to dst. +func NewProxy(dst *grpc.ClientConn, opts ...grpc.ServerOption) *grpc.Server { + opts = append(opts, DefaultProxyOpt(dst)) + // Set up the proxy server and then serve from it like in step one. + return grpc.NewServer(opts...) +} + +// DefaultProxyOpt returns an grpc.UnknownServiceHandler with a DefaultDirector. +func DefaultProxyOpt(cc *grpc.ClientConn) grpc.ServerOption { + return grpc.UnknownServiceHandler(TransparentHandler(DefaultDirector(cc))) +} + +// DefaultDirector returns a very simple forwarding StreamDirector that forwards all +// calls. +func DefaultDirector(cc *grpc.ClientConn) StreamDirector { + return func(ctx context.Context, fullMethodName string) (context.Context, *grpc.ClientConn, error) { + md, _ := metadata.FromIncomingContext(ctx) + ctx = metadata.NewOutgoingContext(ctx, md.Copy()) + return ctx, cc, nil + } +} diff --git a/vendor/github.com/soheilhy/cmux/.gitignore b/vendor/github.com/soheilhy/cmux/.gitignore new file mode 100644 index 000000000..daf913b1b --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/.gitignore @@ -0,0 +1,24 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof diff --git a/vendor/github.com/soheilhy/cmux/.travis.yml b/vendor/github.com/soheilhy/cmux/.travis.yml new file mode 100644 index 000000000..4d78a519f --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/.travis.yml @@ -0,0 +1,29 @@ +language: go + +go: + - 1.6 + - 1.7 + - 1.8 + - tip + +matrix: + allow_failures: + - go: tip + +gobuild_args: -race + +before_install: + - if [[ $TRAVIS_GO_VERSION == 1.6* ]]; then go get -u github.com/kisielk/errcheck; fi + - if [[ $TRAVIS_GO_VERSION == 1.6* ]]; then go get -u golang.org/x/lint/golint; fi + +before_script: + - '! gofmt -s -l . | read' + - echo $TRAVIS_GO_VERSION + - if [[ $TRAVIS_GO_VERSION == 1.6* ]]; then golint ./...; fi + - if [[ $TRAVIS_GO_VERSION == 1.6* ]]; then errcheck ./...; fi + - if [[ $TRAVIS_GO_VERSION == 1.6* ]]; then go tool vet .; fi + - if [[ $TRAVIS_GO_VERSION == 1.6* ]]; then go tool vet --shadow .; fi + +script: + - go test -bench . -v ./... + - go test -race -bench . -v ./... diff --git a/vendor/github.com/soheilhy/cmux/CONTRIBUTORS b/vendor/github.com/soheilhy/cmux/CONTRIBUTORS new file mode 100644 index 000000000..49878f228 --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/CONTRIBUTORS @@ -0,0 +1,12 @@ +# The list of people who have contributed code to the cmux repository. +# +# Auto-generated with: +# git log --oneline --pretty=format:'%an <%aE>' | sort -u +# +Andreas Jaekle +Dmitri Shuralyov +Ethan Mosbaugh +Soheil Hassas Yeganeh +Soheil Hassas Yeganeh +Tamir Duberstein +Tamir Duberstein diff --git a/vendor/github.com/soheilhy/cmux/LICENSE b/vendor/github.com/soheilhy/cmux/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/soheilhy/cmux/README.md b/vendor/github.com/soheilhy/cmux/README.md new file mode 100644 index 000000000..c4191b70b --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/README.md @@ -0,0 +1,83 @@ +# cmux: Connection Mux ![Travis Build Status](https://api.travis-ci.org/soheilhy/args.svg?branch=master "Travis Build Status") [![GoDoc](https://godoc.org/github.com/soheilhy/cmux?status.svg)](http://godoc.org/github.com/soheilhy/cmux) + +cmux is a generic Go library to multiplex connections based on +their payload. Using cmux, you can serve gRPC, SSH, HTTPS, HTTP, +Go RPC, and pretty much any other protocol on the same TCP listener. + +## How-To +Simply create your main listener, create a cmux for that listener, +and then match connections: +```go +// Create the main listener. +l, err := net.Listen("tcp", ":23456") +if err != nil { + log.Fatal(err) +} + +// Create a cmux. +m := cmux.New(l) + +// Match connections in order: +// First grpc, then HTTP, and otherwise Go RPC/TCP. +grpcL := m.Match(cmux.HTTP2HeaderField("content-type", "application/grpc")) +httpL := m.Match(cmux.HTTP1Fast()) +trpcL := m.Match(cmux.Any()) // Any means anything that is not yet matched. + +// Create your protocol servers. +grpcS := grpc.NewServer() +grpchello.RegisterGreeterServer(grpcS, &server{}) + +httpS := &http.Server{ + Handler: &helloHTTP1Handler{}, +} + +trpcS := rpc.NewServer() +trpcS.Register(&ExampleRPCRcvr{}) + +// Use the muxed listeners for your servers. +go grpcS.Serve(grpcL) +go httpS.Serve(httpL) +go trpcS.Accept(trpcL) + +// Start serving! +m.Serve() +``` + +Take a look at [other examples in the GoDoc](http://godoc.org/github.com/soheilhy/cmux/#pkg-examples). + +## Docs +* [GoDocs](https://godoc.org/github.com/soheilhy/cmux) + +## Performance +There is room for improvment but, since we are only matching +the very first bytes of a connection, the performance overheads on +long-lived connections (i.e., RPCs and pipelined HTTP streams) +is negligible. + +*TODO(soheil)*: Add benchmarks. + +## Limitations +* *TLS*: `net/http` uses a type assertion to identify TLS connections; since +cmux's lookahead-implementing connection wraps the underlying TLS connection, +this type assertion fails. +Because of that, you can serve HTTPS using cmux but `http.Request.TLS` +would not be set in your handlers. + +* *Different Protocols on The Same Connection*: `cmux` matches the connection +when it's accepted. For example, one connection can be either gRPC or REST, but +not both. That is, we assume that a client connection is either used for gRPC +or REST. + +* *Java gRPC Clients*: Java gRPC client blocks until it receives a SETTINGS +frame from the server. If you are using the Java client to connect to a cmux'ed +gRPC server please match with writers: +```go +grpcl := m.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc")) +``` + +# Copyright and License +Copyright 2016 The CMux Authors. All rights reserved. + +See [CONTRIBUTORS](https://github.com/soheilhy/cmux/blob/master/CONTRIBUTORS) +for the CMux Authors. Code is released under +[the Apache 2 license](https://github.com/soheilhy/cmux/blob/master/LICENSE). diff --git a/vendor/github.com/soheilhy/cmux/buffer.go b/vendor/github.com/soheilhy/cmux/buffer.go new file mode 100644 index 000000000..f8cf30a1e --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/buffer.go @@ -0,0 +1,67 @@ +// Copyright 2016 The CMux Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +package cmux + +import ( + "bytes" + "io" +) + +// bufferedReader is an optimized implementation of io.Reader that behaves like +// ``` +// io.MultiReader(bytes.NewReader(buffer.Bytes()), io.TeeReader(source, buffer)) +// ``` +// without allocating. +type bufferedReader struct { + source io.Reader + buffer bytes.Buffer + bufferRead int + bufferSize int + sniffing bool + lastErr error +} + +func (s *bufferedReader) Read(p []byte) (int, error) { + if s.bufferSize > s.bufferRead { + // If we have already read something from the buffer before, we return the + // same data and the last error if any. We need to immediately return, + // otherwise we may block for ever, if we try to be smart and call + // source.Read() seeking a little bit of more data. + bn := copy(p, s.buffer.Bytes()[s.bufferRead:s.bufferSize]) + s.bufferRead += bn + return bn, s.lastErr + } else if !s.sniffing && s.buffer.Cap() != 0 { + // We don't need the buffer anymore. + // Reset it to release the internal slice. + s.buffer = bytes.Buffer{} + } + + // If there is nothing more to return in the sniffed buffer, read from the + // source. + sn, sErr := s.source.Read(p) + if sn > 0 && s.sniffing { + s.lastErr = sErr + if wn, wErr := s.buffer.Write(p[:sn]); wErr != nil { + return wn, wErr + } + } + return sn, sErr +} + +func (s *bufferedReader) reset(snif bool) { + s.sniffing = snif + s.bufferRead = 0 + s.bufferSize = s.buffer.Len() +} diff --git a/vendor/github.com/soheilhy/cmux/cmux.go b/vendor/github.com/soheilhy/cmux/cmux.go new file mode 100644 index 000000000..5ba921e72 --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/cmux.go @@ -0,0 +1,307 @@ +// Copyright 2016 The CMux Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +package cmux + +import ( + "errors" + "fmt" + "io" + "net" + "sync" + "time" +) + +// Matcher matches a connection based on its content. +type Matcher func(io.Reader) bool + +// MatchWriter is a match that can also write response (say to do handshake). +type MatchWriter func(io.Writer, io.Reader) bool + +// ErrorHandler handles an error and returns whether +// the mux should continue serving the listener. +type ErrorHandler func(error) bool + +var _ net.Error = ErrNotMatched{} + +// ErrNotMatched is returned whenever a connection is not matched by any of +// the matchers registered in the multiplexer. +type ErrNotMatched struct { + c net.Conn +} + +func (e ErrNotMatched) Error() string { + return fmt.Sprintf("mux: connection %v not matched by an matcher", + e.c.RemoteAddr()) +} + +// Temporary implements the net.Error interface. +func (e ErrNotMatched) Temporary() bool { return true } + +// Timeout implements the net.Error interface. +func (e ErrNotMatched) Timeout() bool { return false } + +type errListenerClosed string + +func (e errListenerClosed) Error() string { return string(e) } +func (e errListenerClosed) Temporary() bool { return false } +func (e errListenerClosed) Timeout() bool { return false } + +// ErrListenerClosed is returned from muxListener.Accept when the underlying +// listener is closed. +var ErrListenerClosed = errListenerClosed("mux: listener closed") + +// ErrServerClosed is returned from muxListener.Accept when mux server is closed. +var ErrServerClosed = errors.New("mux: server closed") + +// for readability of readTimeout +var noTimeout time.Duration + +// New instantiates a new connection multiplexer. +func New(l net.Listener) CMux { + return &cMux{ + root: l, + bufLen: 1024, + errh: func(_ error) bool { return true }, + donec: make(chan struct{}), + readTimeout: noTimeout, + } +} + +// CMux is a multiplexer for network connections. +type CMux interface { + // Match returns a net.Listener that sees (i.e., accepts) only + // the connections matched by at least one of the matcher. + // + // The order used to call Match determines the priority of matchers. + Match(...Matcher) net.Listener + // MatchWithWriters returns a net.Listener that accepts only the + // connections that matched by at least of the matcher writers. + // + // Prefer Matchers over MatchWriters, since the latter can write on the + // connection before the actual handler. + // + // The order used to call Match determines the priority of matchers. + MatchWithWriters(...MatchWriter) net.Listener + // Serve starts multiplexing the listener. Serve blocks and perhaps + // should be invoked concurrently within a go routine. + Serve() error + // Closes cmux server and stops accepting any connections on listener + Close() + // HandleError registers an error handler that handles listener errors. + HandleError(ErrorHandler) + // sets a timeout for the read of matchers + SetReadTimeout(time.Duration) +} + +type matchersListener struct { + ss []MatchWriter + l muxListener +} + +type cMux struct { + root net.Listener + bufLen int + errh ErrorHandler + sls []matchersListener + readTimeout time.Duration + donec chan struct{} + mu sync.Mutex +} + +func matchersToMatchWriters(matchers []Matcher) []MatchWriter { + mws := make([]MatchWriter, 0, len(matchers)) + for _, m := range matchers { + cm := m + mws = append(mws, func(w io.Writer, r io.Reader) bool { + return cm(r) + }) + } + return mws +} + +func (m *cMux) Match(matchers ...Matcher) net.Listener { + mws := matchersToMatchWriters(matchers) + return m.MatchWithWriters(mws...) +} + +func (m *cMux) MatchWithWriters(matchers ...MatchWriter) net.Listener { + ml := muxListener{ + Listener: m.root, + connc: make(chan net.Conn, m.bufLen), + donec: make(chan struct{}), + } + m.sls = append(m.sls, matchersListener{ss: matchers, l: ml}) + return ml +} + +func (m *cMux) SetReadTimeout(t time.Duration) { + m.readTimeout = t +} + +func (m *cMux) Serve() error { + var wg sync.WaitGroup + + defer func() { + m.closeDoneChans() + wg.Wait() + + for _, sl := range m.sls { + close(sl.l.connc) + // Drain the connections enqueued for the listener. + for c := range sl.l.connc { + _ = c.Close() + } + } + }() + + for { + c, err := m.root.Accept() + if err != nil { + if !m.handleErr(err) { + return err + } + continue + } + + wg.Add(1) + go m.serve(c, m.donec, &wg) + } +} + +func (m *cMux) serve(c net.Conn, donec <-chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() + + muc := newMuxConn(c) + if m.readTimeout > noTimeout { + _ = c.SetReadDeadline(time.Now().Add(m.readTimeout)) + } + for _, sl := range m.sls { + for _, s := range sl.ss { + matched := s(muc.Conn, muc.startSniffing()) + if matched { + muc.doneSniffing() + if m.readTimeout > noTimeout { + _ = c.SetReadDeadline(time.Time{}) + } + select { + case sl.l.connc <- muc: + case <-donec: + _ = c.Close() + } + return + } + } + } + + _ = c.Close() + err := ErrNotMatched{c: c} + if !m.handleErr(err) { + _ = m.root.Close() + } +} + +func (m *cMux) Close() { + m.closeDoneChans() +} + +func (m *cMux) closeDoneChans() { + m.mu.Lock() + defer m.mu.Unlock() + + select { + case <-m.donec: + // Already closed. Don't close again + default: + close(m.donec) + } + for _, sl := range m.sls { + select { + case <-sl.l.donec: + // Already closed. Don't close again + default: + close(sl.l.donec) + } + } +} + +func (m *cMux) HandleError(h ErrorHandler) { + m.errh = h +} + +func (m *cMux) handleErr(err error) bool { + if !m.errh(err) { + return false + } + + if ne, ok := err.(net.Error); ok { + return ne.Temporary() + } + + return false +} + +type muxListener struct { + net.Listener + connc chan net.Conn + donec chan struct{} +} + +func (l muxListener) Accept() (net.Conn, error) { + select { + case c, ok := <-l.connc: + if !ok { + return nil, ErrListenerClosed + } + return c, nil + case <-l.donec: + return nil, ErrServerClosed + } +} + +// MuxConn wraps a net.Conn and provides transparent sniffing of connection data. +type MuxConn struct { + net.Conn + buf bufferedReader +} + +func newMuxConn(c net.Conn) *MuxConn { + return &MuxConn{ + Conn: c, + buf: bufferedReader{source: c}, + } +} + +// From the io.Reader documentation: +// +// When Read encounters an error or end-of-file condition after +// successfully reading n > 0 bytes, it returns the number of +// bytes read. It may return the (non-nil) error from the same call +// or return the error (and n == 0) from a subsequent call. +// An instance of this general case is that a Reader returning +// a non-zero number of bytes at the end of the input stream may +// return either err == EOF or err == nil. The next Read should +// return 0, EOF. +func (m *MuxConn) Read(p []byte) (int, error) { + return m.buf.Read(p) +} + +func (m *MuxConn) startSniffing() io.Reader { + m.buf.reset(true) + return &m.buf +} + +func (m *MuxConn) doneSniffing() { + m.buf.reset(false) +} diff --git a/vendor/github.com/soheilhy/cmux/doc.go b/vendor/github.com/soheilhy/cmux/doc.go new file mode 100644 index 000000000..aaa8f3158 --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/doc.go @@ -0,0 +1,18 @@ +// Copyright 2016 The CMux Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +// Package cmux is a library to multiplex network connections based on +// their payload. Using cmux, you can serve different protocols from the +// same listener. +package cmux diff --git a/vendor/github.com/soheilhy/cmux/matchers.go b/vendor/github.com/soheilhy/cmux/matchers.go new file mode 100644 index 000000000..878ae98cc --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/matchers.go @@ -0,0 +1,267 @@ +// Copyright 2016 The CMux Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +package cmux + +import ( + "bufio" + "crypto/tls" + "io" + "io/ioutil" + "net/http" + "strings" + + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +// Any is a Matcher that matches any connection. +func Any() Matcher { + return func(r io.Reader) bool { return true } +} + +// PrefixMatcher returns a matcher that matches a connection if it +// starts with any of the strings in strs. +func PrefixMatcher(strs ...string) Matcher { + pt := newPatriciaTreeString(strs...) + return pt.matchPrefix +} + +func prefixByteMatcher(list ...[]byte) Matcher { + pt := newPatriciaTree(list...) + return pt.matchPrefix +} + +var defaultHTTPMethods = []string{ + "OPTIONS", + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "TRACE", + "CONNECT", +} + +// HTTP1Fast only matches the methods in the HTTP request. +// +// This matcher is very optimistic: if it returns true, it does not mean that +// the request is a valid HTTP response. If you want a correct but slower HTTP1 +// matcher, use HTTP1 instead. +func HTTP1Fast(extMethods ...string) Matcher { + return PrefixMatcher(append(defaultHTTPMethods, extMethods...)...) +} + +// TLS matches HTTPS requests. +// +// By default, any TLS handshake packet is matched. An optional whitelist +// of versions can be passed in to restrict the matcher, for example: +// TLS(tls.VersionTLS11, tls.VersionTLS12) +func TLS(versions ...int) Matcher { + if len(versions) == 0 { + versions = []int{ + tls.VersionSSL30, + tls.VersionTLS10, + tls.VersionTLS11, + tls.VersionTLS12, + } + } + prefixes := [][]byte{} + for _, v := range versions { + prefixes = append(prefixes, []byte{22, byte(v >> 8 & 0xff), byte(v & 0xff)}) + } + return prefixByteMatcher(prefixes...) +} + +const maxHTTPRead = 4096 + +// HTTP1 parses the first line or upto 4096 bytes of the request to see if +// the conection contains an HTTP request. +func HTTP1() Matcher { + return func(r io.Reader) bool { + br := bufio.NewReader(&io.LimitedReader{R: r, N: maxHTTPRead}) + l, part, err := br.ReadLine() + if err != nil || part { + return false + } + + _, _, proto, ok := parseRequestLine(string(l)) + if !ok { + return false + } + + v, _, ok := http.ParseHTTPVersion(proto) + return ok && v == 1 + } +} + +// grabbed from net/http. +func parseRequestLine(line string) (method, uri, proto string, ok bool) { + s1 := strings.Index(line, " ") + s2 := strings.Index(line[s1+1:], " ") + if s1 < 0 || s2 < 0 { + return + } + s2 += s1 + 1 + return line[:s1], line[s1+1 : s2], line[s2+1:], true +} + +// HTTP2 parses the frame header of the first frame to detect whether the +// connection is an HTTP2 connection. +func HTTP2() Matcher { + return hasHTTP2Preface +} + +// HTTP1HeaderField returns a matcher matching the header fields of the first +// request of an HTTP 1 connection. +func HTTP1HeaderField(name, value string) Matcher { + return func(r io.Reader) bool { + return matchHTTP1Field(r, name, func(gotValue string) bool { + return gotValue == value + }) + } +} + +// HTTP1HeaderFieldPrefix returns a matcher matching the header fields of the +// first request of an HTTP 1 connection. If the header with key name has a +// value prefixed with valuePrefix, this will match. +func HTTP1HeaderFieldPrefix(name, valuePrefix string) Matcher { + return func(r io.Reader) bool { + return matchHTTP1Field(r, name, func(gotValue string) bool { + return strings.HasPrefix(gotValue, valuePrefix) + }) + } +} + +// HTTP2HeaderField returns a matcher matching the header fields of the first +// headers frame. +func HTTP2HeaderField(name, value string) Matcher { + return func(r io.Reader) bool { + return matchHTTP2Field(ioutil.Discard, r, name, func(gotValue string) bool { + return gotValue == value + }) + } +} + +// HTTP2HeaderFieldPrefix returns a matcher matching the header fields of the +// first headers frame. If the header with key name has a value prefixed with +// valuePrefix, this will match. +func HTTP2HeaderFieldPrefix(name, valuePrefix string) Matcher { + return func(r io.Reader) bool { + return matchHTTP2Field(ioutil.Discard, r, name, func(gotValue string) bool { + return strings.HasPrefix(gotValue, valuePrefix) + }) + } +} + +// HTTP2MatchHeaderFieldSendSettings matches the header field and writes the +// settings to the server. Prefer HTTP2HeaderField over this one, if the client +// does not block on receiving a SETTING frame. +func HTTP2MatchHeaderFieldSendSettings(name, value string) MatchWriter { + return func(w io.Writer, r io.Reader) bool { + return matchHTTP2Field(w, r, name, func(gotValue string) bool { + return gotValue == value + }) + } +} + +// HTTP2MatchHeaderFieldPrefixSendSettings matches the header field prefix +// and writes the settings to the server. Prefer HTTP2HeaderFieldPrefix over +// this one, if the client does not block on receiving a SETTING frame. +func HTTP2MatchHeaderFieldPrefixSendSettings(name, valuePrefix string) MatchWriter { + return func(w io.Writer, r io.Reader) bool { + return matchHTTP2Field(w, r, name, func(gotValue string) bool { + return strings.HasPrefix(gotValue, valuePrefix) + }) + } +} + +func hasHTTP2Preface(r io.Reader) bool { + var b [len(http2.ClientPreface)]byte + last := 0 + + for { + n, err := r.Read(b[last:]) + if err != nil { + return false + } + + last += n + eq := string(b[:last]) == http2.ClientPreface[:last] + if last == len(http2.ClientPreface) { + return eq + } + if !eq { + return false + } + } +} + +func matchHTTP1Field(r io.Reader, name string, matches func(string) bool) (matched bool) { + req, err := http.ReadRequest(bufio.NewReader(r)) + if err != nil { + return false + } + + return matches(req.Header.Get(name)) +} + +func matchHTTP2Field(w io.Writer, r io.Reader, name string, matches func(string) bool) (matched bool) { + if !hasHTTP2Preface(r) { + return false + } + + done := false + framer := http2.NewFramer(w, r) + hdec := hpack.NewDecoder(uint32(4<<10), func(hf hpack.HeaderField) { + if hf.Name == name { + done = true + if matches(hf.Value) { + matched = true + } + } + }) + for { + f, err := framer.ReadFrame() + if err != nil { + return false + } + + switch f := f.(type) { + case *http2.SettingsFrame: + // Sender acknoweldged the SETTINGS frame. No need to write + // SETTINGS again. + if f.IsAck() { + break + } + if err := framer.WriteSettings(); err != nil { + return false + } + case *http2.ContinuationFrame: + if _, err := hdec.Write(f.HeaderBlockFragment()); err != nil { + return false + } + done = done || f.FrameHeader.Flags&http2.FlagHeadersEndHeaders != 0 + case *http2.HeadersFrame: + if _, err := hdec.Write(f.HeaderBlockFragment()); err != nil { + return false + } + done = done || f.FrameHeader.Flags&http2.FlagHeadersEndHeaders != 0 + } + + if done { + return matched + } + } +} diff --git a/vendor/github.com/soheilhy/cmux/patricia.go b/vendor/github.com/soheilhy/cmux/patricia.go new file mode 100644 index 000000000..c3e3d85bd --- /dev/null +++ b/vendor/github.com/soheilhy/cmux/patricia.go @@ -0,0 +1,179 @@ +// Copyright 2016 The CMux Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +package cmux + +import ( + "bytes" + "io" +) + +// patriciaTree is a simple patricia tree that handles []byte instead of string +// and cannot be changed after instantiation. +type patriciaTree struct { + root *ptNode + maxDepth int // max depth of the tree. +} + +func newPatriciaTree(bs ...[]byte) *patriciaTree { + max := 0 + for _, b := range bs { + if max < len(b) { + max = len(b) + } + } + return &patriciaTree{ + root: newNode(bs), + maxDepth: max + 1, + } +} + +func newPatriciaTreeString(strs ...string) *patriciaTree { + b := make([][]byte, len(strs)) + for i, s := range strs { + b[i] = []byte(s) + } + return newPatriciaTree(b...) +} + +func (t *patriciaTree) matchPrefix(r io.Reader) bool { + buf := make([]byte, t.maxDepth) + n, _ := io.ReadFull(r, buf) + return t.root.match(buf[:n], true) +} + +func (t *patriciaTree) match(r io.Reader) bool { + buf := make([]byte, t.maxDepth) + n, _ := io.ReadFull(r, buf) + return t.root.match(buf[:n], false) +} + +type ptNode struct { + prefix []byte + next map[byte]*ptNode + terminal bool +} + +func newNode(strs [][]byte) *ptNode { + if len(strs) == 0 { + return &ptNode{ + prefix: []byte{}, + terminal: true, + } + } + + if len(strs) == 1 { + return &ptNode{ + prefix: strs[0], + terminal: true, + } + } + + p, strs := splitPrefix(strs) + n := &ptNode{ + prefix: p, + } + + nexts := make(map[byte][][]byte) + for _, s := range strs { + if len(s) == 0 { + n.terminal = true + continue + } + nexts[s[0]] = append(nexts[s[0]], s[1:]) + } + + n.next = make(map[byte]*ptNode) + for first, rests := range nexts { + n.next[first] = newNode(rests) + } + + return n +} + +func splitPrefix(bss [][]byte) (prefix []byte, rest [][]byte) { + if len(bss) == 0 || len(bss[0]) == 0 { + return prefix, bss + } + + if len(bss) == 1 { + return bss[0], [][]byte{{}} + } + + for i := 0; ; i++ { + var cur byte + eq := true + for j, b := range bss { + if len(b) <= i { + eq = false + break + } + + if j == 0 { + cur = b[i] + continue + } + + if cur != b[i] { + eq = false + break + } + } + + if !eq { + break + } + + prefix = append(prefix, cur) + } + + rest = make([][]byte, 0, len(bss)) + for _, b := range bss { + rest = append(rest, b[len(prefix):]) + } + + return prefix, rest +} + +func (n *ptNode) match(b []byte, prefix bool) bool { + l := len(n.prefix) + if l > 0 { + if l > len(b) { + l = len(b) + } + if !bytes.Equal(b[:l], n.prefix) { + return false + } + } + + if n.terminal && (prefix || len(n.prefix) == len(b)) { + return true + } + + if l >= len(b) { + return false + } + + nextN, ok := n.next[b[l]] + if !ok { + return false + } + + if l == len(b) { + b = b[l:l] + } else { + b = b[l+1:] + } + return nextN.match(b, prefix) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 13f1c4710..0bd125a53 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -983,6 +983,9 @@ github.com/muesli/termenv # github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 ## explicit github.com/munnerz/goautoneg +# github.com/mwitkow/grpc-proxy v0.0.0-20230212185441-f345521cb9c9 +## explicit; go 1.14 +github.com/mwitkow/grpc-proxy/proxy # github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f ## explicit github.com/mxk/go-flowrate/flowrate @@ -1108,6 +1111,9 @@ github.com/sirupsen/logrus # github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 ## explicit github.com/skratchdot/open-golang/open +# github.com/soheilhy/cmux v0.1.5 +## explicit; go 1.11 +github.com/soheilhy/cmux # github.com/spf13/cobra v1.8.1 ## explicit; go 1.15 github.com/spf13/cobra