diff --git a/pkg/cmd/refresh/refresh.go b/pkg/cmd/refresh/refresh.go index 0612ffe7..59c078d1 100644 --- a/pkg/cmd/refresh/refresh.go +++ b/pkg/cmd/refresh/refresh.go @@ -3,12 +3,15 @@ package refresh import ( "fmt" + "io" + "io/fs" "sync" "github.com/brevdev/brev-cli/pkg/cmdcontext" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/ssh" + "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" "github.com/spf13/cobra" @@ -19,6 +22,10 @@ type RefreshStore interface { ssh.SSHConfigurerV2Store GetCurrentUser() (*entity.User, error) GetCurrentUserKeys() (*entity.UserKeys, error) + Chmod(string, fs.FileMode) error + MkdirAll(string, fs.FileMode) error + GetBrevCloudflaredBinaryPath() (string, error) + Create(string) (io.WriteCloser, error) } func NewCmdRefresh(t *terminal.Terminal, store RefreshStore) *cobra.Command { @@ -51,6 +58,12 @@ func NewCmdRefresh(t *terminal.Terminal, store RefreshStore) *cobra.Command { } func RunRefresh(store RefreshStore) error { + cl := GetCloudflare(store) + err := cl.DownloadCloudflaredBinaryIfItDNE() + if err != nil { + return breverrors.WrapAndTrace(err) + } + cu, err := GetConfigUpdater(store) if err != nil { return breverrors.WrapAndTrace(err) @@ -105,3 +118,8 @@ func GetConfigUpdater(store RefreshStore) (*ssh.ConfigUpdater, error) { return cu, nil } + +func GetCloudflare(refreshStore RefreshStore) store.Cloudflared { + cl := store.NewCloudflare(refreshStore) + return cl +} diff --git a/pkg/entity/entity.go b/pkg/entity/entity.go index 1e27d5ce..d4a5f302 100644 --- a/pkg/entity/entity.go +++ b/pkg/entity/entity.go @@ -284,32 +284,34 @@ type WorkspaceGroup struct { } // @Name WorkspaceGroup type Workspace struct { - ID string `json:"id"` - Name string `json:"name"` - WorkspaceGroupID string `json:"workspaceGroupId"` - OrganizationID string `json:"organizationId"` - WorkspaceClassID string `json:"workspaceClassId"` // WorkspaceClassID is resources, like "2x8" - InstanceType string `json:"instanceType,omitempty"` - CreatedByUserID string `json:"createdByUserId"` - DNS string `json:"dns"` - Status string `json:"status"` - Password string `json:"password"` - GitRepo string `json:"gitRepo"` - Version string `json:"version"` - WorkspaceTemplate WorkspaceTemplate `json:"workspaceTemplate"` - NetworkID string `json:"networkId"` - StartupScriptPath string `json:"startupScriptPath"` - ReposV0 ReposV0 `json:"repos"` - ExecsV0 ExecsV0 `json:"execs"` - ReposV1 *ReposV1 `json:"reposV1"` - ExecsV1 *ExecsV1 `json:"execsV1"` - IDEConfig IDEConfig `json:"ideConfig"` - SSHPort int `json:"sshPort"` - SSHUser string `json:"sshUser"` - HostSSHPort int `json:"hostSshPort"` - HostSSHUser string `json:"hostSshUser"` - VerbBuildStatus VerbBuildStatus `json:"verbBuildStatus"` - VerbYaml string `json:"verbYaml"` + ID string `json:"id"` + Name string `json:"name"` + WorkspaceGroupID string `json:"workspaceGroupId"` + OrganizationID string `json:"organizationId"` + WorkspaceClassID string `json:"workspaceClassId"` // WorkspaceClassID is resources, like "2x8" + InstanceType string `json:"instanceType,omitempty"` + CreatedByUserID string `json:"createdByUserId"` + DNS string `json:"dns"` + Status string `json:"status"` + Password string `json:"password"` + GitRepo string `json:"gitRepo"` + Version string `json:"version"` + WorkspaceTemplate WorkspaceTemplate `json:"workspaceTemplate"` + NetworkID string `json:"networkId"` + StartupScriptPath string `json:"startupScriptPath"` + ReposV0 ReposV0 `json:"repos"` + ExecsV0 ExecsV0 `json:"execs"` + ReposV1 *ReposV1 `json:"reposV1"` + ExecsV1 *ExecsV1 `json:"execsV1"` + IDEConfig IDEConfig `json:"ideConfig"` + SSHPort int `json:"sshPort"` + SSHUser string `json:"sshUser"` + SSHProxyHostname string `json:"sshProxyHostname"` + HostSSHPort int `json:"hostSshPort"` + HostSSHUser string `json:"hostSshUser"` + HostSSHProxyHostname string `json:"hostSshProxyHostname"` + VerbBuildStatus VerbBuildStatus `json:"verbBuildStatus"` + VerbYaml string `json:"verbYaml"` // PrimaryApplicationId string `json:"primaryApplicationId,omitempty"` // LastOnlineAt string `json:"lastOnlineAt,omitempty"` // CreatedAt string `json:"createdAt,omitempty"` diff --git a/pkg/files/files.go b/pkg/files/files.go index b3edcbd1..81ac7cc1 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -114,6 +114,12 @@ func GetBrevSSHConfigPath(home string) string { return brevSSHConfigPath } +func GetBrevCloudflaredBinaryPath(home string) string { + path := GetBrevHome(home) + brevCloudflaredBinaryPath := filepath.Join(path, "cloudflared") + return brevCloudflaredBinaryPath +} + func GetOnboardingStepPath(home string) string { path := GetBrevHome(home) brevOnboardingFilePath := filepath.Join(path, "onboarding_step.json") diff --git a/pkg/ssh/sshconfigurer.go b/pkg/ssh/sshconfigurer.go index d4763f64..73374d0a 100644 --- a/pkg/ssh/sshconfigurer.go +++ b/pkg/ssh/sshconfigurer.go @@ -110,6 +110,7 @@ type SSHConfigurerV2Store interface { GetWSLHostBrevSSHConfigPath() (string, error) GetWSLUserSSHConfig() (string, error) WriteWSLUserSSHConfig(config string) error + GetBrevCloudflaredBinaryPath() (string, error) } var _ Config = SSHConfigurerV2{} @@ -170,7 +171,12 @@ func (s SSHConfigurerV2) CreateWSLConfig(workspaces []entity.Workspace) (string, pkpath := files.GetSSHPrivateKeyPath(homedir) - sshConfig, err := makeNewSSHConfig(toWindowsPath(configPath), workspaces, toWindowsPath(pkpath)) + cloudflaredBinaryPath, err := s.store.GetBrevCloudflaredBinaryPath() + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + + sshConfig, err := makeNewSSHConfig(toWindowsPath(configPath), workspaces, toWindowsPath(pkpath), toWindowsPath(cloudflaredBinaryPath)) if err != nil { return "", breverrors.WrapAndTrace(err) } @@ -188,18 +194,23 @@ func (s SSHConfigurerV2) CreateNewSSHConfig(workspaces []entity.Workspace) (stri return "", breverrors.WrapAndTrace(err) } - sshConfig, err := makeNewSSHConfig(configPath, workspaces, pkPath) + cloudflaredBinaryPath, err := s.store.GetBrevCloudflaredBinaryPath() + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + + sshConfig, err := makeNewSSHConfig(configPath, workspaces, pkPath, cloudflaredBinaryPath) if err != nil { return "", breverrors.WrapAndTrace(err) } return sshConfig, nil } -func makeNewSSHConfig(configPath string, workspaces []entity.Workspace, pkpath string) (string, error) { +func makeNewSSHConfig(configPath string, workspaces []entity.Workspace, pkpath string, cloudflaredBinaryPath string) (string, error) { sshConfig := fmt.Sprintf("# included in %s\n", configPath) for _, w := range workspaces { - entry, err := makeSSHConfigEntryV2(w, pkpath) + entry, err := makeSSHConfigEntryV2(w, pkpath, cloudflaredBinaryPath) if err != nil { return "", breverrors.WrapAndTrace(err) } @@ -270,7 +281,7 @@ func tmplAndValToString(tmpl *template.Template, val interface{}) (string, error return buf.String(), nil } -func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string) (string, error) { +func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, cloudflaredBinaryPath string) (string, error) { //nolint:funlen // ok alias := string(workspace.GetLocalIdentifier()) privateKeyPath = "\"" + privateKeyPath + "\"" if workspace.IsLegacy() { @@ -291,10 +302,13 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string) (st return "", breverrors.WrapAndTrace(err) } return val, nil - } else { - hostname := workspace.GetHostname() + } + + var sshVal string + user := workspace.GetSSHUser() + hostname := workspace.GetHostname() + if workspace.SSHProxyHostname == "" { port := workspace.GetSSHPort() - user := workspace.GetSSHUser() entry := SSHConfigEntryV2{ Alias: alias, IdentityFile: privateKeyPath, @@ -307,15 +321,35 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string) (st if err != nil { return "", breverrors.WrapAndTrace(err) } - sshVal, err := tmplAndValToString(tmpl, entry) + sshVal, err = tmplAndValToString(tmpl, entry) + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + } else { + proxyCommand := makeCloudflareSSHProxyCommand(cloudflaredBinaryPath, workspace.SSHProxyHostname) + entry := SSHConfigEntryV2{ + Alias: alias, + IdentityFile: privateKeyPath, + User: user, + ProxyCommand: proxyCommand, + Dir: workspace.GetProjectFolderPath(), + } + tmpl, err := template.New(alias).Parse(SSHConfigEntryTemplateV2) + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + sshVal, err = tmplAndValToString(tmpl, entry) if err != nil { return "", breverrors.WrapAndTrace(err) } + } - port = workspace.GetHostSSHPort() + alias = fmt.Sprintf("%s-host", alias) + var hostSSHVal string + if workspace.HostSSHProxyHostname == "" { + port := workspace.GetHostSSHPort() user = workspace.GetHostSSHUser() - alias = fmt.Sprintf("%s-host", alias) - entry = SSHConfigEntryV2{ + entry := SSHConfigEntryV2{ Alias: alias, IdentityFile: privateKeyPath, User: user, @@ -323,18 +357,35 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string) (st HostName: hostname, Port: port, } - tmpl, err = template.New(alias).Parse(SSHConfigEntryTemplateV3) + tmpl, err := template.New(alias).Parse(SSHConfigEntryTemplateV3) if err != nil { return "", breverrors.WrapAndTrace(err) } - hostSSHVal, err := tmplAndValToString(tmpl, entry) + hostSSHVal, err = tmplAndValToString(tmpl, entry) + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + } else { + proxyCommand := makeCloudflareSSHProxyCommand(cloudflaredBinaryPath, workspace.HostSSHProxyHostname) + entry := SSHConfigEntryV2{ + Alias: alias, + IdentityFile: privateKeyPath, + User: user, + ProxyCommand: proxyCommand, + Dir: workspace.GetProjectFolderPath(), + } + tmpl, err := template.New(alias).Parse(SSHConfigEntryTemplateV2) + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + hostSSHVal, err = tmplAndValToString(tmpl, entry) if err != nil { return "", breverrors.WrapAndTrace(err) } - - val := fmt.Sprintf("%s%s", sshVal, hostSSHVal) - return val, nil } + + val := fmt.Sprintf("%s%s", sshVal, hostSSHVal) + return val, nil } // func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string) (string, error) { @@ -392,6 +443,10 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string) (st // } // } +func makeCloudflareSSHProxyCommand(cloudflaredBinaryPath string, hostname string) string { + return fmt.Sprintf("%s access ssh --hostname %s", cloudflaredBinaryPath, hostname) +} + func makeProxyCommand(workspaceID string) string { huproxyExec := "brev proxy" return fmt.Sprintf("%s %s", huproxyExec, workspaceID) diff --git a/pkg/ssh/sshconfigurer_test.go b/pkg/ssh/sshconfigurer_test.go index ce4c4208..67d11b06 100644 --- a/pkg/ssh/sshconfigurer_test.go +++ b/pkg/ssh/sshconfigurer_test.go @@ -125,6 +125,10 @@ func (d DummySSHConfigurerV2Store) WriteWSLUserSSHConfig(_ string) error { return nil } +func (d DummySSHConfigurerV2Store) GetBrevCloudflaredBinaryPath() (string, error) { + return "", nil +} + func TestCreateNewSSHConfig(t *testing.T) { c := NewSSHConfigurerV2(DummySSHConfigurerV2Store{}) cStr, err := c.CreateNewSSHConfig(somePlainWorkspaces) @@ -262,9 +266,10 @@ blaksdf;asdf; func Test_makeSSHConfigEntryV2(t *testing.T) { //nolint:funlen // test type args struct { - workspace entity.Workspace - privateKeyPath string - runRemoteCMD bool + workspace entity.Workspace + privateKeyPath string + cloudflaredBinaryPath string + runRemoteCMD bool } tests := []struct { name string @@ -486,12 +491,61 @@ Host testName2-host ForwardAgent yes RequestTTY yes +`, + }, + { + name: "test default ssh proxy", + args: args{ + workspace: entity.Workspace{ + ID: "test-id-2", + Name: "testName2", + WorkspaceGroupID: "test-id-2", + OrganizationID: "oi", + WorkspaceClassID: "wci", + CreatedByUserID: "cui", + DNS: "test2-dns-org.brev.sh", + Status: entity.Running, + Password: "sdfal", + GitRepo: "gitrepo", + SSHProxyHostname: "test-verb-proxy.com", + HostSSHProxyHostname: "test-host-proxy.com", + }, + privateKeyPath: "/my/priv/key.pem", + cloudflaredBinaryPath: "/Users/tmontfort/.brev/cloudflared", + runRemoteCMD: true, + }, + want: `Host testName2 + IdentityFile "/my/priv/key.pem" + User ubuntu + ProxyCommand /Users/tmontfort/.brev/cloudflared access ssh --hostname test-verb-proxy.com + ServerAliveInterval 30 + UserKnownHostsFile /dev/null + IdentitiesOnly yes + StrictHostKeyChecking no + PasswordAuthentication no + AddKeysToAgent yes + ForwardAgent yes + RequestTTY yes + +Host testName2-host + IdentityFile "/my/priv/key.pem" + User ubuntu + ProxyCommand /Users/tmontfort/.brev/cloudflared access ssh --hostname test-host-proxy.com + ServerAliveInterval 30 + UserKnownHostsFile /dev/null + IdentitiesOnly yes + StrictHostKeyChecking no + PasswordAuthentication no + AddKeysToAgent yes + ForwardAgent yes + RequestTTY yes + `, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := makeSSHConfigEntryV2(tt.args.workspace, tt.args.privateKeyPath) + got, err := makeSSHConfigEntryV2(tt.args.workspace, tt.args.privateKeyPath, tt.args.cloudflaredBinaryPath) if (err != nil) != tt.wantErr { t.Errorf("makeSSHConfigEntryV2() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/store/cloudflared.go b/pkg/store/cloudflared.go new file mode 100644 index 00000000..b1ddd33a --- /dev/null +++ b/pkg/store/cloudflared.go @@ -0,0 +1,118 @@ +package store + +import ( + "context" + "fmt" + "io" + "io/fs" + "net/http" + "path/filepath" + "runtime" + "strings" + + "github.com/brevdev/brev-cli/pkg/collections" + "github.com/brevdev/brev-cli/pkg/errors" +) + +type CloudflaredStore interface { + GetBrevCloudflaredBinaryPath() (string, error) + FileExists(string) (bool, error) + DownloadBinary(string, string) error + Chmod(string, fs.FileMode) error + MkdirAll(string, fs.FileMode) error + Create(string) (io.WriteCloser, error) +} + +type Cloudflared struct { + store CloudflaredStore +} + +func NewCloudflare(store CloudflaredStore) Cloudflared { + return Cloudflared{ + store: store, + } +} + +var CloudflaredVersion = "2024.10.0" + +func (c Cloudflared) DownloadCloudflaredBinaryIfItDNE() error { + binaryPath, err := c.store.GetBrevCloudflaredBinaryPath() + if err != nil { + return errors.WrapAndTrace(err) + } + binaryExists, err := c.store.FileExists(binaryPath) + if err != nil { + return errors.WrapAndTrace(err) + } + if binaryExists { + return nil + } + binaryURL, err := getCloudflaredBinaryDownloadURL() + if err != nil { + return errors.WrapAndTrace(err) + } + err = c.DownloadBinary(context.TODO(), binaryPath, binaryURL) + if err != nil { + return errors.WrapAndTrace(err) + } + err = c.store.Chmod(binaryPath, 0o755) + if err != nil { + return errors.WrapAndTrace(err) + } + return nil +} + +func (c Cloudflared) DownloadBinary(ctx context.Context, binaryPath, binaryURL string) error { + resp, err := collections.GetRequestWithContext(ctx, binaryURL) + if err != nil { + return errors.WrapAndTrace(err) + } + defer resp.Body.Close() //nolint:errcheck // defer + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + err = c.store.MkdirAll(filepath.Dir(binaryPath), 0o755) + if err != nil { + return errors.WrapAndTrace(err) + } + + out, err := c.store.Create(binaryPath) + if err != nil { + return errors.WrapAndTrace(err) + } + defer out.Close() //nolint:errcheck // defer + + var src io.Reader + if strings.HasSuffix(binaryURL, ".tgz") { + src = trytoUnTarGZ(resp.Body) + } else { + src = resp.Body + } + + _, err = io.Copy(out, src) + if err != nil { + return fmt.Errorf("error saving downloaded file: %v", err) + } + + err = c.store.Chmod(binaryPath, 0o755) + if err != nil { + return errors.WrapAndTrace(err) + } + return nil +} + +func getCloudflaredBinaryDownloadURL() (string, error) { + switch runtime.GOOS { + case "linux": + return fmt.Sprintf("https://github.com/cloudflare/cloudflared/releases/download/%s/cloudflared-linux-amd64", CloudflaredVersion), nil + case "darwin": + if runtime.GOARCH == "arm64" { + return fmt.Sprintf("https://github.com/cloudflare/cloudflared/releases/download/%s/cloudflared-darwin-arm64.tgz", CloudflaredVersion), nil + } + return fmt.Sprintf("https://github.com/cloudflare/cloudflared/releases/download/%s/cloudflared-darwin-amd64.tgz", CloudflaredVersion), nil + default: + return "", fmt.Errorf("unsupported OS %s for downloading Cloudflared binary", runtime.GOOS) + } +} diff --git a/pkg/store/cloudflared_test.go b/pkg/store/cloudflared_test.go new file mode 100644 index 00000000..ab50c13b --- /dev/null +++ b/pkg/store/cloudflared_test.go @@ -0,0 +1,43 @@ +package store + +import ( + "testing" + + "github.com/brevdev/brev-cli/pkg/auth" + "github.com/brevdev/brev-cli/pkg/config" + "github.com/brevdev/brev-cli/pkg/files" + "github.com/fatih/color" + "github.com/stretchr/testify/assert" +) + +func makeCloudflare() Cloudflared { + conf := config.NewConstants() + fs := files.AppFs + authenticator := auth.Authenticator{ + Audience: "https://brevdev.us.auth0.com/api/v2/", + ClientID: "JaqJRLEsdat5w7Tb0WqmTxzIeqwqepmk", + DeviceCodeEndpoint: "https://brevdev.us.auth0.com/oauth/device/code", + OauthTokenEndpoint: "https://brevdev.us.auth0.com/oauth/token", + } + // super annoying. this is needed to make the import stay + _ = color.New(color.FgYellow, color.Bold).SprintFunc() + + fsStore := NewBasicStore(). + WithFileSystem(fs) + loginAuth := auth.NewLoginAuth(fsStore, authenticator) + + loginCmdStore := fsStore.WithNoAuthHTTPClient( + NewNoAuthHTTPClient(conf.GetBrevAPIURl()), + ). + WithAuth(loginAuth, WithDebug(conf.GetDebugHTTP())) + return Cloudflared{ + store: loginCmdStore, + } +} + +func TestTask_DownloadCloudflaredBinary(t *testing.T) { + client := makeCloudflare() + + err := client.DownloadCloudflaredBinaryIfItDNE() + assert.NoError(t, err) +} diff --git a/pkg/store/file.go b/pkg/store/file.go index 79181170..55ea44ff 100644 --- a/pkg/store/file.go +++ b/pkg/store/file.go @@ -360,6 +360,22 @@ func (f FileStore) Chmod(path string, mode os.FileMode) error { return nil } +func (f FileStore) Create(target string) (io.WriteCloser, error) { + file, err := f.fs.Create(target) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return file, nil +} + +func (f FileStore) MkdirAll(path string, mode os.FileMode) error { + err := f.fs.MkdirAll(path, mode) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + func getUserHomeDir(f *FileStore) func() (string, error) { return func() (string, error) { if f.User != nil { diff --git a/pkg/store/ssh.go b/pkg/store/ssh.go index 7c32d4c6..61f195ed 100644 --- a/pkg/store/ssh.go +++ b/pkg/store/ssh.go @@ -107,6 +107,15 @@ func (f FileStore) GetWSLHostBrevSSHConfigPath() (string, error) { return path, nil } +func (f FileStore) GetBrevCloudflaredBinaryPath() (string, error) { + home, err := f.UserHomeDir() + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + path := files.GetBrevCloudflaredBinaryPath(home) + return path, nil +} + func (f FileStore) WriteUserSSHConfig(config string) error { home, err := f.UserHomeDir() if err != nil {