diff --git a/pkg/cmd/refresh/refresh.go b/pkg/cmd/refresh/refresh.go index 24df0986..d022a7aa 100644 --- a/pkg/cmd/refresh/refresh.go +++ b/pkg/cmd/refresh/refresh.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "io/fs" - "log" "sync" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" @@ -160,7 +159,7 @@ func GetConfigUpdater(store RefreshStore) (*ssh.ConfigUpdater, error) { return nil, breverrors.WrapAndTrace(err) } - cu := ssh.NewConfigUpdater(store, configs, keys.PrivateKey) + cu := ssh.NewConfigUpdater(workspaceSSHStore{RefreshStore: store}, configs, keys.PrivateKey) cu.ExternalNodes = getExternalNodeSSHEntries(store) return cu, nil @@ -171,13 +170,11 @@ func GetConfigUpdater(store RefreshStore) (*ssh.ConfigUpdater, error) { func getExternalNodeSSHEntries(store RefreshStore) []ssh.ExternalNodeSSHEntry { org, err := store.GetActiveOrganizationOrDefault() if err != nil { - log.Printf("external nodes: skipping (no org): %v", err) return nil } user, err := store.GetCurrentUser() if err != nil { - log.Printf("external nodes: skipping (no user): %v", err) return nil } @@ -186,7 +183,6 @@ func getExternalNodeSSHEntries(store RefreshStore) []ssh.ExternalNodeSSHEntry { OrganizationId: org.ID, })) if err != nil { - log.Printf("external nodes: skipping (list failed): %v", err) return nil } diff --git a/pkg/cmd/refresh/sshaccess.go b/pkg/cmd/refresh/sshaccess.go new file mode 100644 index 00000000..aad5c3e9 --- /dev/null +++ b/pkg/cmd/refresh/sshaccess.go @@ -0,0 +1,159 @@ +package refresh + +import ( + "context" + "fmt" + "log" + "time" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + + "github.com/brevdev/brev-cli/pkg/cmd/register" + "github.com/brevdev/brev-cli/pkg/config" + "github.com/brevdev/brev-cli/pkg/entity" + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +const sshAccessLookupTimeout = 10 * time.Second + +type environmentSSHClient interface { + GetEnvironment(context.Context, *connect.Request[devplanev1.GetEnvironmentRequest]) (*connect.Response[devplanev1.GetEnvironmentResponse], error) + GetNetworkInfo(context.Context, *connect.Request[devplanev1.EnvironmentServiceGetNetworkInfoRequest]) (*connect.Response[devplanev1.EnvironmentServiceGetNetworkInfoResponse], error) +} + +type workspaceSSHStore struct { + RefreshStore +} + +func (s workspaceSSHStore) GetContextWorkspaces() ([]entity.Workspace, error) { + workspaces, err := s.RefreshStore.GetContextWorkspaces() + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + + user, err := s.GetCurrentUser() + if err != nil { + log.Printf("workspace SSH access: using legacy configuration (current user lookup failed): %v", err) + return workspaces, nil + } + + client := register.NewEnvironmentServiceClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) + ctx, cancel := context.WithTimeout(context.Background(), sshAccessLookupTimeout) + defer cancel() + + return enrichWorkspacesWithSSHAccess(ctx, client, user.ID, workspaces), nil +} + +func enrichWorkspacesWithSSHAccess(ctx context.Context, client environmentSSHClient, userID string, workspaces []entity.Workspace) []entity.Workspace { + for i := range workspaces { + if workspaces[i].Status != entity.Running { + continue + } + + workspace, err := resolveWorkspaceSSH(ctx, client, userID, workspaces[i]) + if err != nil { + log.Printf("workspace SSH access: using legacy configuration for %s: %v", workspaces[i].ID, err) + continue + } + workspaces[i] = workspace + } + return workspaces +} + +func resolveWorkspaceSSH( + ctx context.Context, + client environmentSSHClient, + userID string, + workspace entity.Workspace, +) (entity.Workspace, error) { + environmentRes, err := client.GetEnvironment(ctx, connect.NewRequest(&devplanev1.GetEnvironmentRequest{ + EnvironmentId: workspace.ID, + AttachedDataOptions: &devplanev1.GetEnvironmentAttachedDataOptions{ + Instance: true, + SshAccess: true, + }, + })) + if err != nil { + return workspace, fmt.Errorf("get environment: %w", err) + } + if environmentRes == nil || environmentRes.Msg == nil || environmentRes.Msg.GetEnvironment() == nil { + return workspace, fmt.Errorf("get environment: empty response") + } + + // Use the ssh access information to determine the SSH target, rather than the public IP, DNS, or other information + // returned by the initial workspace query. + environment := environmentRes.Msg.GetEnvironment() + access := findUserSSHAccess(environment.GetSshAccess(), userID) + if access == nil { + return workspace, nil + } + if access.GetLinuxUser() == "" { + return workspace, fmt.Errorf("SSH access has no Linux user") + } + + // Using the port ID, we can lookup the network information to get the hostname and port number of the SSH target. + networkRes, err := client.GetNetworkInfo(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceGetNetworkInfoRequest{ + EnvironmentId: workspace.ID, + })) + if err != nil { + return workspace, fmt.Errorf("get network info: %w", err) + } + if networkRes == nil || networkRes.Msg == nil { + return workspace, fmt.Errorf("get network info: empty response") + } + + port := findNetworkPort(networkRes.Msg.GetNetworkInfo(), access.GetPortId()) + if port == nil { + return workspace, fmt.Errorf("SSH access port %q not found", access.GetPortId()) + } + if port.GetHostname() == "" || port.GetPortNumber() == 0 { + return workspace, fmt.Errorf("SSH access port %q has no endpoint", access.GetPortId()) + } + + // Honor the SSH access information as the source of truth for the SSH target. + workspace.SSHHostname = port.GetHostname() + workspace.SSHPort = int(port.GetPortNumber()) + workspace.SSHUser = access.GetLinuxUser() + workspace.SSHProxyHostname = "" + + // To support the "--host" fallback, preserve the legacy hostname information returned by the initial workspace query. + if providerHostname := providerSSHHostname(environment.GetInstance(), port.GetHostname()); providerHostname != "" { + workspace.HostSSHHostname = providerHostname + workspace.HostSSHProxyHostname = "" + } + return workspace, nil +} + +func findUserSSHAccess(accesses []*devplanev1.SSHAccess, userID string) *devplanev1.SSHAccess { + // Technically it is possible that multiple SSHAccess entries exist for a single user+environment. This is typically only + // for external nodes, which allow for multiple sshd processes to be targeted. For the normal environment flow, the below + // is a best-effort approach to find the *first* applicable entry. + for _, access := range accesses { + if access.GetUserId() == userID && access.GetPortId() != "" { + return access + } + } + return nil +} + +func findNetworkPort(networkInfo *devplanev1.EnvironmentNetworkInfo, portID string) *devplanev1.Port { + for _, port := range networkInfo.GetPorts() { + if port.GetPortId() == portID { + return port + } + } + return nil +} + +func providerSSHHostname(instance *devplanev1.Instance, workloadHostname string) string { + if instance == nil { + return "" + } + for _, hostname := range []string{instance.GetPublicIp(), instance.GetPublicDns(), instance.GetHostname()} { + if hostname != "" && hostname != workloadHostname { + return hostname + } + } + return "" +} diff --git a/pkg/cmd/refresh/sshaccess_test.go b/pkg/cmd/refresh/sshaccess_test.go new file mode 100644 index 00000000..0695be95 --- /dev/null +++ b/pkg/cmd/refresh/sshaccess_test.go @@ -0,0 +1,140 @@ +package refresh + +import ( + "context" + "errors" + "testing" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/google/go-cmp/cmp" + + "github.com/brevdev/brev-cli/pkg/entity" +) + +type stubEnvironmentSSHClient struct { + environment *devplanev1.Environment + networkInfo *devplanev1.EnvironmentNetworkInfo + err error + request *devplanev1.GetEnvironmentRequest + networkRequest *devplanev1.EnvironmentServiceGetNetworkInfoRequest +} + +func (s *stubEnvironmentSSHClient) GetEnvironment( + _ context.Context, + req *connect.Request[devplanev1.GetEnvironmentRequest], +) (*connect.Response[devplanev1.GetEnvironmentResponse], error) { + s.request = req.Msg + if s.err != nil { + return nil, s.err + } + return connect.NewResponse(&devplanev1.GetEnvironmentResponse{Environment: s.environment}), nil +} + +func (s *stubEnvironmentSSHClient) GetNetworkInfo( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceGetNetworkInfoRequest], +) (*connect.Response[devplanev1.EnvironmentServiceGetNetworkInfoResponse], error) { + s.networkRequest = req.Msg + return connect.NewResponse(&devplanev1.EnvironmentServiceGetNetworkInfoResponse{ + NetworkInfo: s.networkInfo, + }), nil +} + +func TestEnrichWorkspacesWithSSHAccess_UsesCurrentUsersPort(t *testing.T) { + workspace := entity.Workspace{ + ID: "env-1", + Name: "container-env", + DNS: "legacy.example.com", + Status: entity.Running, + SSHUser: "ubuntu", + SSHPort: 22, + HostSSHUser: "ubuntu", + HostSSHPort: 22, + SSHProxyHostname: "legacy-proxy.example.com", + HostSSHProxyHostname: "legacy-host-proxy.example.com", + } + client := &stubEnvironmentSSHClient{ + environment: &devplanev1.Environment{ + Instance: &devplanev1.Instance{ + SshHostname: "203.0.113.10", + SshPort: 22, + PublicIp: "203.0.113.10", + }, + SshAccess: []*devplanev1.SSHAccess{ + {UserId: "other-user", LinuxUser: "wrong-user", PortId: "other-port"}, + {UserId: "user-1", LinuxUser: "root", PortId: "ssh-port"}, + }, + }, + networkInfo: &devplanev1.EnvironmentNetworkInfo{ + Ports: []*devplanev1.Port{ + {PortId: "other-port", Hostname: strPtr("wrong.example.com"), PortNumber: 49999}, + {PortId: "ssh-port", Hostname: strPtr("skybridge.example.com"), PortNumber: 41234, ServerPort: 22}, + }, + }, + } + + got := enrichWorkspacesWithSSHAccess(context.Background(), client, "user-1", []entity.Workspace{workspace}) + want := workspace + want.SSHHostname = "skybridge.example.com" + want.SSHPort = 41234 + want.SSHUser = "root" + want.SSHProxyHostname = "" + want.HostSSHHostname = "203.0.113.10" + want.HostSSHProxyHostname = "" + + if diff := cmp.Diff([]entity.Workspace{want}, got); diff != "" { + t.Fatalf("unexpected workspace (-want +got): %s", diff) + } + if client.request.GetEnvironmentId() != workspace.ID { + t.Fatalf("requested environment %q, want %q", client.request.GetEnvironmentId(), workspace.ID) + } + options := client.request.GetAttachedDataOptions() + if !options.GetInstance() || !options.GetSshAccess() || options.GetSysUsers() { + t.Fatalf("missing SSH attachment options: %+v", options) + } + if client.networkRequest.GetEnvironmentId() != workspace.ID { + t.Fatalf("requested network environment %q, want %q", client.networkRequest.GetEnvironmentId(), workspace.ID) + } +} + +func TestEnrichWorkspacesWithSSHAccess_FallsBackOnError(t *testing.T) { + workspace := entity.Workspace{ + ID: "env-1", + Name: "legacy-env", + DNS: "legacy.example.com", + Status: entity.Running, + SSHUser: "ubuntu", + SSHPort: 22, + } + client := &stubEnvironmentSSHClient{err: errors.New("dev-plane unavailable")} + + got := enrichWorkspacesWithSSHAccess(context.Background(), client, "user-1", []entity.Workspace{workspace}) + if diff := cmp.Diff([]entity.Workspace{workspace}, got); diff != "" { + t.Fatalf("legacy workspace changed (-want +got): %s", diff) + } +} + +func TestEnrichWorkspacesWithSSHAccess_FallsBackWithoutPortBackedAccess(t *testing.T) { + workspace := entity.Workspace{ + ID: "env-1", + DNS: "legacy.example.com", + Status: entity.Running, + SSHUser: "ubuntu", + SSHPort: 22, + } + client := &stubEnvironmentSSHClient{environment: &devplanev1.Environment{ + Instance: &devplanev1.Instance{}, + SshAccess: []*devplanev1.SSHAccess{ + {UserId: "user-1", LinuxUser: "root"}, + }, + }} + + got := enrichWorkspacesWithSSHAccess(context.Background(), client, "user-1", []entity.Workspace{workspace}) + if diff := cmp.Diff([]entity.Workspace{workspace}, got); diff != "" { + t.Fatalf("legacy workspace changed (-want +got): %s", diff) + } + if client.networkRequest != nil { + t.Fatal("network info should not be fetched without port-backed access") + } +} diff --git a/pkg/cmd/register/rpcclient.go b/pkg/cmd/register/rpcclient.go index 273d9c21..20ff27bd 100644 --- a/pkg/cmd/register/rpcclient.go +++ b/pkg/cmd/register/rpcclient.go @@ -53,6 +53,14 @@ func NewNodeServiceClient(provider externalnode.TokenProvider, baseURL string) n ) } +// NewEnvironmentServiceClient creates an authenticated ConnectRPC EnvironmentServiceClient. +func NewEnvironmentServiceClient(provider externalnode.TokenProvider, baseURL string) nodev1connect.EnvironmentServiceClient { + return nodev1connect.NewEnvironmentServiceClient( + newAuthenticatedHTTPClient(provider), + baseURL, + ) +} + // toProtoNodeSpec converts the local HardwareProfile (used for collection, display, // persistence) to the generated proto NodeSpec for RPC calls. func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { diff --git a/pkg/cmd/shell/shell.go b/pkg/cmd/shell/shell.go index 85ba0a42..9b811c30 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -1,6 +1,7 @@ package shell import ( + "bufio" "bytes" "fmt" "io" @@ -111,6 +112,7 @@ func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID } sshName := string(localIdentifier) + printResolvedSSHTarget(sshName) err = runSSHWithOptions(sshName, host, false) if err == nil { trackShellAnalytics(sstore, workspace) @@ -132,6 +134,7 @@ func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID if err != nil { return breverrors.WrapAndTrace(err) } + printResolvedSSHTarget(sshName) err = util.WaitForSSHToBeAvailable(sshName, s) if err != nil { return breverrors.WrapAndTrace(err) @@ -247,3 +250,47 @@ func runSSHWithOptions(sshAlias string, host bool, printFailureAdvice bool) erro } return nil } + +func printResolvedSSHTarget(sshAlias string) { + output, err := exec.Command("ssh", "-G", sshAlias).Output() //nolint:gosec // alias is the same internal SSH target passed to ssh + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Resolved SSH target: unavailable:", err) + return + } + + var user, hostname, port, proxyCommand string + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + key, value, found := strings.Cut(scanner.Text(), " ") + if !found { + continue + } + switch key { + case "user": + user = value + case "hostname": + hostname = value + case "port": + port = value + case "proxycommand": + proxyCommand = value + } + } + + if hostname == "" { + _, _ = fmt.Fprintln(os.Stderr, "Resolved SSH target: unavailable") + return + } + + target := hostname + if user != "" { + target = user + "@" + target + } + if port != "" { + target += ":" + port + } + if proxyCommand != "" && proxyCommand != "none" { + target += " via " + proxyCommand + } + _, _ = fmt.Fprintln(os.Stderr, "Resolved SSH target:", target) +} diff --git a/pkg/entity/entity.go b/pkg/entity/entity.go index ee091129..1efe288c 100644 --- a/pkg/entity/entity.go +++ b/pkg/entity/entity.go @@ -286,9 +286,11 @@ type Workspace struct { IDEConfig IDEConfig `json:"ideConfig"` SSHPort int `json:"sshPort"` SSHUser string `json:"sshUser"` + SSHHostname string `json:"sshHostname,omitempty"` SSHProxyHostname string `json:"sshProxyHostname"` HostSSHPort int `json:"hostSshPort"` HostSSHUser string `json:"hostSshUser"` + HostSSHHostname string `json:"hostSshHostname,omitempty"` HostSSHProxyHostname string `json:"hostSshProxyHostname"` VerbBuildStatus VerbBuildStatus `json:"verbBuildStatus"` VerbYaml string `json:"verbYaml"` @@ -370,6 +372,20 @@ func (w Workspace) GetHostname() string { return hostname } +func (w Workspace) GetSSHHostname() string { + if w.SSHHostname != "" { + return w.SSHHostname + } + return w.GetHostname() +} + +func (w Workspace) GetHostSSHHostname() string { + if w.HostSSHHostname != "" { + return w.HostSSHHostname + } + return w.GetHostname() +} + func (w Workspace) GetSSHPort() int { port := 22 if w.SSHPort != 0 { diff --git a/pkg/ssh/sshconfigurer.go b/pkg/ssh/sshconfigurer.go index 9f334001..5d785625 100644 --- a/pkg/ssh/sshconfigurer.go +++ b/pkg/ssh/sshconfigurer.go @@ -358,7 +358,7 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, clo privateKeyPath = "\"" + privateKeyPath + "\"" var sshVal string user := workspace.GetSSHUser() - hostname := workspace.GetHostname() + hostname := workspace.GetSSHHostname() if workspace.SSHProxyHostname == "" { port := workspace.GetSSHPort() projPath, err := workspace.GetProjectFolderPath() @@ -408,6 +408,7 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, clo var hostSSHVal string hostport := workspace.GetHostSSHPort() hostuser := workspace.GetHostSSHUser() + hostSSHHostname := workspace.GetHostSSHHostname() if workspace.HostSSHProxyHostname == "" { projPath, err := workspace.GetProjectFolderPath() if err != nil { @@ -418,7 +419,7 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, clo IdentityFile: privateKeyPath, User: hostuser, Dir: projPath, - HostName: hostname, + HostName: hostSSHHostname, Port: hostport, } tmpl, err := template.New(alias).Parse(SSHConfigEntryTemplateV3) @@ -633,7 +634,7 @@ func (s SSHConfigurerJetBrains) CreateNewSSHConfig(workspaces []entity.Workspace // // func makeJetbrainsConfigEntry(workspace entity.Workspace, privateKeyPath string) JetbrainsGatewayConfigXMLSSHConfig { - hostname := workspace.GetHostname() + hostname := workspace.GetSSHHostname() port := workspace.GetSSHPort() // name := workspace.GetLocalIdentifier() return JetbrainsGatewayConfigXMLSSHConfig{ diff --git a/pkg/ssh/sshconfigurer_test.go b/pkg/ssh/sshconfigurer_test.go index b1f65524..4acb67f8 100644 --- a/pkg/ssh/sshconfigurer_test.go +++ b/pkg/ssh/sshconfigurer_test.go @@ -287,7 +287,7 @@ func Test_makeSSHConfigEntryV2(t *testing.T) { //nolint:funlen // test }{ // TODO: Add test cases. { - name: "test devplane uses ubuntu", + name: "test separate workload and host endpoints", args: args{ workspace: entity.Workspace{ ID: "test-id-2", @@ -301,14 +301,16 @@ func Test_makeSSHConfigEntryV2(t *testing.T) { //nolint:funlen // test GitRepo: "gitrepo", SSHPort: 20, SSHUser: "ubuntu-wk", + SSHHostname: "skybridge.example.com", HostSSHPort: 2022, HostSSHUser: "ubuntu-host", + HostSSHHostname: "203.0.113.10", }, privateKeyPath: "/my/priv/key.pem", runRemoteCMD: true, }, want: `Host testName2 - Hostname test2-dns-org.brev.sh + Hostname skybridge.example.com IdentityFile "/my/priv/key.pem" User ubuntu-wk ServerAliveInterval 30 @@ -325,7 +327,7 @@ func Test_makeSSHConfigEntryV2(t *testing.T) { //nolint:funlen // test Port 20 Host testName2-host - Hostname test2-dns-org.brev.sh + Hostname 203.0.113.10 IdentityFile "/my/priv/key.pem" User ubuntu-host ServerAliveInterval 30 @@ -523,6 +525,19 @@ Host testName2-host } } +func TestMakeJetbrainsConfigEntryUsesSSHHostname(t *testing.T) { + entry := makeJetbrainsConfigEntry(entity.Workspace{ + DNS: "legacy.example.com", + SSHHostname: "skybridge.example.com", + SSHUser: "root", + SSHPort: 41234, + }, "/tmp/brev.pem") + + if entry.Host != "skybridge.example.com" { + t.Fatalf("host = %q, want skybridge.example.com", entry.Host) + } +} + func TestSanitizeNodeName(t *testing.T) { tests := []struct { input string