Skip to content

Proxy credentials server over DevPod network if available #1836

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d2675b4
Rework workspace daemon structure
janekbaraniewski Apr 9, 2025
c7c889d
Add devpod-net socket to access available peers from different processes
janekbaraniewski Apr 9, 2025
a5d8f10
Expose client hostname
janekbaraniewski Apr 9, 2025
4684f14
Pass client hostname when running credentials server
janekbaraniewski Apr 9, 2025
e208c5e
Run local credentials server
janekbaraniewski Apr 10, 2025
f92fb33
Fetch git credentials from client if available
janekbaraniewski Apr 10, 2025
396c6f2
Use CombinedLogger in workspace credentials server, fix git ts handle…
janekbaraniewski Apr 10, 2025
3d7df02
Cleanup workspace daemon
janekbaraniewski Apr 10, 2025
8eb3021
Split workspace network server into separate services
janekbaraniewski Apr 10, 2025
cc1ff33
Fix package naming in local daemon package
janekbaraniewski Apr 10, 2025
2557f41
Cleanup naming in workspace network server
janekbaraniewski Apr 10, 2025
3bb63fe
Add GetClient helper to workspace network package
janekbaraniewski Apr 10, 2025
2890906
Inject listener into tunnelserver instead of hardcoding stdio
janekbaraniewski Apr 11, 2025
3447ae6
Change local credentials server into credentials proxy
janekbaraniewski Apr 11, 2025
dfcc669
Update workspace network client helpers
janekbaraniewski Apr 11, 2025
2f71d8c
Add http-based client to tunnel server
janekbaraniewski Apr 11, 2025
93bf862
grpc reverse proxy
janekbaraniewski Apr 14, 2025
715bc5d
Update logf in workspace daemon network
janekbaraniewski Apr 14, 2025
d3fb6e9
Fix workspace daemon package imports
janekbaraniewski Apr 14, 2025
c55f66a
Cleanup credentials server implementation
janekbaraniewski Apr 14, 2025
6eada99
Don't hardcode local credentials server prot
janekbaraniewski Apr 16, 2025
8f9b952
Multiplex socket to support both http and grpc requests
janekbaraniewski Apr 16, 2025
5bb7214
Add request helper command to send requests over DevPod network
janekbaraniewski Apr 16, 2025
05469d0
Use generic local grpc proxy in local daemon
janekbaraniewski Apr 16, 2025
89d6e99
Fix workspace connection tracker and heartbeats
janekbaraniewski Apr 16, 2025
a909739
Cleanup logging
janekbaraniewski Apr 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions cmd/agent/container/credentials_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
306 changes: 7 additions & 299 deletions cmd/agent/container/daemon.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading