From 58fce2f9b8550b5c7c2e7e7dc379113227824817 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Wed, 22 Jul 2026 08:34:23 -0700 Subject: [PATCH 1/3] use access over ssh --- pkg/cmd/refresh/refresh.go | 2 +- pkg/cmd/refresh/sshaccess.go | 159 +++++++++++++++++++++++++++ pkg/cmd/refresh/sshaccess_test.go | 177 ++++++++++++++++++++++++++++++ pkg/cmd/register/rpcclient.go | 8 ++ pkg/cmd/shell/shell.go | 42 +++++++ pkg/entity/entity.go | 16 +++ pkg/ssh/sshconfigurer.go | 7 +- pkg/ssh/sshconfigurer_test.go | 21 +++- 8 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 pkg/cmd/refresh/sshaccess.go create mode 100644 pkg/cmd/refresh/sshaccess_test.go diff --git a/pkg/cmd/refresh/refresh.go b/pkg/cmd/refresh/refresh.go index 24df09863..c33957046 100644 --- a/pkg/cmd/refresh/refresh.go +++ b/pkg/cmd/refresh/refresh.go @@ -160,7 +160,7 @@ func GetConfigUpdater(store RefreshStore) (*ssh.ConfigUpdater, error) { return nil, breverrors.WrapAndTrace(err) } - cu := ssh.NewConfigUpdater(store, configs, keys.PrivateKey) + cu := ssh.NewConfigUpdater(sshAccessWorkspaceStore{RefreshStore: store}, configs, keys.PrivateKey) cu.ExternalNodes = getExternalNodeSSHEntries(store) return cu, nil diff --git a/pkg/cmd/refresh/sshaccess.go b/pkg/cmd/refresh/sshaccess.go new file mode 100644 index 000000000..92f698b85 --- /dev/null +++ b/pkg/cmd/refresh/sshaccess.go @@ -0,0 +1,159 @@ +package refresh + +import ( + "context" + "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" +) + +const workspaceSSHAccessTimeout = 10 * time.Second + +type environmentGetter 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 sshAccessWorkspaceStore struct { + RefreshStore +} + +func (s sshAccessWorkspaceStore) GetContextWorkspaces() ([]entity.Workspace, error) { + workspaces, err := s.RefreshStore.GetContextWorkspaces() + if err != nil { + return nil, 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(), workspaceSSHAccessTimeout) + defer cancel() + + return enrichWorkspacesWithSSHAccess(ctx, client, user.ID, workspaces), nil +} + +func enrichWorkspacesWithSSHAccess(ctx context.Context, client environmentGetter, userID string, workspaces []entity.Workspace) []entity.Workspace { + for i := range workspaces { + if workspaces[i].Status != entity.Running { + continue + } + + res, err := client.GetEnvironment(ctx, connect.NewRequest(&devplanev1.GetEnvironmentRequest{ + EnvironmentId: workspaces[i].ID, + AttachedDataOptions: &devplanev1.GetEnvironmentAttachedDataOptions{ + Instance: true, + SshAccess: true, + }, + })) + if err != nil { + log.Printf("workspace SSH access: using legacy configuration for %s: %v", workspaces[i].ID, err) + continue + } + if res == nil || res.Msg == nil { + log.Printf("workspace SSH access: using legacy configuration for %s: empty response", workspaces[i].ID) + continue + } + + environment := res.Msg.GetEnvironment() + access := getPortBackedSSHAccess(environment, userID) + if access == nil { + log.Printf("workspace SSH access: using legacy configuration for %s (no port-backed access for current user)", workspaces[i].ID) + continue + } + + networkRes, err := client.GetNetworkInfo(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceGetNetworkInfoRequest{ + EnvironmentId: workspaces[i].ID, + })) + if err != nil { + log.Printf("workspace SSH access: using legacy configuration for %s (network lookup failed): %v", workspaces[i].ID, err) + continue + } + if networkRes == nil || networkRes.Msg == nil { + log.Printf("workspace SSH access: using legacy configuration for %s (empty network response)", workspaces[i].ID) + continue + } + + port := getSSHAccessPort(networkRes.Msg.GetNetworkInfo(), access.GetPortId()) + if port == nil { + log.Printf("workspace SSH access: using legacy configuration for %s (port %s not found)", workspaces[i].ID, access.GetPortId()) + continue + } + + workspaces[i] = applyEnvironmentSSHAccess(workspaces[i], environment.GetInstance(), access, port) + log.Printf( + "workspace SSH access: resolved %s as %s@%s:%d from port %s", + workspaces[i].ID, + workspaces[i].SSHUser, + workspaces[i].SSHHostname, + workspaces[i].SSHPort, + access.GetPortId(), + ) + } + return workspaces +} + +func applyEnvironmentSSHAccess( + workspace entity.Workspace, + instance *devplanev1.Instance, + access *devplanev1.SSHAccess, + port *devplanev1.Port, +) entity.Workspace { + if access == nil || port == nil || port.GetHostname() == "" || port.GetPortNumber() == 0 { + return workspace + } + + workspace.SSHHostname = port.GetHostname() + workspace.SSHPort = int(port.GetPortNumber()) + workspace.SSHUser = access.GetLinuxUser() + workspace.SSHProxyHostname = "" + + if providerHostname := getProviderSSHHostname(instance); providerHostname != "" { + workspace.HostSSHHostname = providerHostname + workspace.HostSSHProxyHostname = "" + } + return workspace +} + +func getPortBackedSSHAccess(environment *devplanev1.Environment, userID string) *devplanev1.SSHAccess { + for _, access := range environment.GetSshAccess() { + // A PortId identifies access through the resolved Skybridge endpoint. + // Legacy Cloudflare access has no PortId and keeps using the existing proxy config. + if access.GetUserId() == userID && access.GetPortId() != "" { + return access + } + } + return nil +} + +func getSSHAccessPort(networkInfo *devplanev1.EnvironmentNetworkInfo, portID string) *devplanev1.Port { + for _, port := range networkInfo.GetPorts() { + if port.GetPortId() == portID { + return port + } + } + return nil +} + +func getProviderSSHHostname(instance *devplanev1.Instance) string { + if instance.GetPublicIp() != "" { + return instance.GetPublicIp() + } + if instance.GetPublicDns() != "" && instance.GetPublicDns() != instance.GetSshHostname() { + return instance.GetPublicDns() + } + if instance.GetHostname() != "" && instance.GetHostname() != instance.GetSshHostname() { + return instance.GetHostname() + } + return "" +} diff --git a/pkg/cmd/refresh/sshaccess_test.go b/pkg/cmd/refresh/sshaccess_test.go new file mode 100644 index 000000000..4bc92b8dc --- /dev/null +++ b/pkg/cmd/refresh/sshaccess_test.go @@ -0,0 +1,177 @@ +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 stubEnvironmentGetter struct { + environment *devplanev1.Environment + networkInfo *devplanev1.EnvironmentNetworkInfo + err error + networkErr error + request *devplanev1.GetEnvironmentRequest + networkRequest *devplanev1.EnvironmentServiceGetNetworkInfoRequest +} + +func (s *stubEnvironmentGetter) 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 *stubEnvironmentGetter) GetNetworkInfo( + _ context.Context, + req *connect.Request[devplanev1.EnvironmentServiceGetNetworkInfoRequest], +) (*connect.Response[devplanev1.EnvironmentServiceGetNetworkInfoResponse], error) { + s.networkRequest = req.Msg + if s.networkErr != nil { + return nil, s.networkErr + } + return connect.NewResponse(&devplanev1.EnvironmentServiceGetNetworkInfoResponse{ + NetworkInfo: s.networkInfo, + }), nil +} + +func TestEnrichWorkspacesWithSSHAccess_ContainerEndpoint(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 := &stubEnvironmentGetter{ + environment: &devplanev1.Environment{ + Instance: &devplanev1.Instance{ + SshHostname: "203.0.113.10", + SshPort: 22, + PublicIp: "203.0.113.10", + }, + SshAccess: []*devplanev1.SSHAccess{ + {UserId: "user-1", LinuxUser: "root", PortId: "ssh-port"}, + }, + }, + networkInfo: &devplanev1.EnvironmentNetworkInfo{ + Ports: []*devplanev1.Port{ + {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_UsesCurrentUsersAccessWithoutSysUserType(t *testing.T) { + workspace := entity.Workspace{ + ID: "env-1", + Name: "vm-env", + DNS: "legacy.example.com", + Status: entity.Running, + SSHUser: "ubuntu", + SSHPort: 22, + } + client := &stubEnvironmentGetter{ + environment: &devplanev1.Environment{ + Instance: &devplanev1.Instance{}, + SshAccess: []*devplanev1.SSHAccess{ + {UserId: "other-user", LinuxUser: "wrong-user", PortId: "other-port"}, + {UserId: "user-1", LinuxUser: "ubuntu", 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}, + }, + }, + } + + got := enrichWorkspacesWithSSHAccess(context.Background(), client, "user-1", []entity.Workspace{workspace}) + want := workspace + want.SSHHostname = "skybridge.example.com" + want.SSHPort = 41234 + + if diff := cmp.Diff([]entity.Workspace{want}, got); diff != "" { + t.Fatalf("unexpected workspace (-want +got): %s", diff) + } +} + +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 := &stubEnvironmentGetter{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 := &stubEnvironmentGetter{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 273d9c213..20ff27bdf 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 85ba0a428..19f3ccba8 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -1,6 +1,7 @@ package shell import ( + "bufio" "bytes" "fmt" "io" @@ -132,6 +133,7 @@ func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID if err != nil { return breverrors.WrapAndTrace(err) } + printResolvedSSHConfig(sshName) err = util.WaitForSSHToBeAvailable(sshName, s) if err != nil { return breverrors.WrapAndTrace(err) @@ -220,6 +222,7 @@ func runSSHWithOptions(sshAlias string, host bool, printFailureAdvice bool) erro cmd = fmt.Sprintf("%s && ssh -t -o ConnectTimeout=5 %s 'DIR=$(readlink -f /proc/1/cwd 2>/dev/null || pwd); cd \"$DIR\" || echo \"Warning: Could not access container directory\" >&2; exec -l ${SHELL:-/bin/sh}'", sshAgentEval, sshAlias) } + printResolvedSSHConfig(sshAlias) var stderrBuf bytes.Buffer sshCmd := exec.Command("bash", "-c", cmd) //nolint:gosec //cmd is user input sshCmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) @@ -247,3 +250,42 @@ func runSSHWithOptions(sshAlias string, host bool, printFailureAdvice bool) erro } return nil } + +func printResolvedSSHConfig(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 + } + } + + 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 ee0911290..1efe288cc 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 9f3340015..5d785625e 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 b1f655245..4acb67f83 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 From 78569d56c5c58e70080439a9fc8e8a6c3b36e981 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Wed, 22 Jul 2026 09:00:28 -0700 Subject: [PATCH 2/3] simplify + comments --- pkg/cmd/refresh/refresh.go | 6 +- pkg/cmd/refresh/sshaccess.go | 147 +++++++++++++++--------------- pkg/cmd/refresh/sshaccess_test.go | 55 ++--------- pkg/cmd/shell/shell.go | 11 ++- 4 files changed, 91 insertions(+), 128 deletions(-) diff --git a/pkg/cmd/refresh/refresh.go b/pkg/cmd/refresh/refresh.go index c33957046..d022a7aaa 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(sshAccessWorkspaceStore{RefreshStore: 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 index 92f698b85..de2d515d4 100644 --- a/pkg/cmd/refresh/sshaccess.go +++ b/pkg/cmd/refresh/sshaccess.go @@ -2,6 +2,7 @@ package refresh import ( "context" + "fmt" "log" "time" @@ -13,18 +14,18 @@ import ( "github.com/brevdev/brev-cli/pkg/entity" ) -const workspaceSSHAccessTimeout = 10 * time.Second +const sshAccessLookupTimeout = 10 * time.Second -type environmentGetter interface { +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 sshAccessWorkspaceStore struct { +type workspaceSSHStore struct { RefreshStore } -func (s sshAccessWorkspaceStore) GetContextWorkspaces() ([]entity.Workspace, error) { +func (s workspaceSSHStore) GetContextWorkspaces() ([]entity.Workspace, error) { workspaces, err := s.RefreshStore.GetContextWorkspaces() if err != nil { return nil, err @@ -37,98 +38,97 @@ func (s sshAccessWorkspaceStore) GetContextWorkspaces() ([]entity.Workspace, err } client := register.NewEnvironmentServiceClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) - ctx, cancel := context.WithTimeout(context.Background(), workspaceSSHAccessTimeout) + ctx, cancel := context.WithTimeout(context.Background(), sshAccessLookupTimeout) defer cancel() return enrichWorkspacesWithSSHAccess(ctx, client, user.ID, workspaces), nil } -func enrichWorkspacesWithSSHAccess(ctx context.Context, client environmentGetter, userID string, workspaces []entity.Workspace) []entity.Workspace { +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 } - res, err := client.GetEnvironment(ctx, connect.NewRequest(&devplanev1.GetEnvironmentRequest{ - EnvironmentId: workspaces[i].ID, - AttachedDataOptions: &devplanev1.GetEnvironmentAttachedDataOptions{ - Instance: true, - SshAccess: true, - }, - })) + 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 } - if res == nil || res.Msg == nil { - log.Printf("workspace SSH access: using legacy configuration for %s: empty response", workspaces[i].ID) - continue - } - - environment := res.Msg.GetEnvironment() - access := getPortBackedSSHAccess(environment, userID) - if access == nil { - log.Printf("workspace SSH access: using legacy configuration for %s (no port-backed access for current user)", workspaces[i].ID) - continue - } - - networkRes, err := client.GetNetworkInfo(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceGetNetworkInfoRequest{ - EnvironmentId: workspaces[i].ID, - })) - if err != nil { - log.Printf("workspace SSH access: using legacy configuration for %s (network lookup failed): %v", workspaces[i].ID, err) - continue - } - if networkRes == nil || networkRes.Msg == nil { - log.Printf("workspace SSH access: using legacy configuration for %s (empty network response)", workspaces[i].ID) - continue - } - - port := getSSHAccessPort(networkRes.Msg.GetNetworkInfo(), access.GetPortId()) - if port == nil { - log.Printf("workspace SSH access: using legacy configuration for %s (port %s not found)", workspaces[i].ID, access.GetPortId()) - continue - } - - workspaces[i] = applyEnvironmentSSHAccess(workspaces[i], environment.GetInstance(), access, port) - log.Printf( - "workspace SSH access: resolved %s as %s@%s:%d from port %s", - workspaces[i].ID, - workspaces[i].SSHUser, - workspaces[i].SSHHostname, - workspaces[i].SSHPort, - access.GetPortId(), - ) + workspaces[i] = workspace } return workspaces } -func applyEnvironmentSSHAccess( +func resolveWorkspaceSSH( + ctx context.Context, + client environmentSSHClient, + userID string, workspace entity.Workspace, - instance *devplanev1.Instance, - access *devplanev1.SSHAccess, - port *devplanev1.Port, -) entity.Workspace { - if access == nil || port == nil || port.GetHostname() == "" || port.GetPortNumber() == 0 { - return 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 = "" - if providerHostname := getProviderSSHHostname(instance); providerHostname != "" { + // 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 + return workspace, nil } -func getPortBackedSSHAccess(environment *devplanev1.Environment, userID string) *devplanev1.SSHAccess { - for _, access := range environment.GetSshAccess() { - // A PortId identifies access through the resolved Skybridge endpoint. - // Legacy Cloudflare access has no PortId and keeps using the existing proxy config. +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 } @@ -136,7 +136,7 @@ func getPortBackedSSHAccess(environment *devplanev1.Environment, userID string) return nil } -func getSSHAccessPort(networkInfo *devplanev1.EnvironmentNetworkInfo, portID string) *devplanev1.Port { +func findNetworkPort(networkInfo *devplanev1.EnvironmentNetworkInfo, portID string) *devplanev1.Port { for _, port := range networkInfo.GetPorts() { if port.GetPortId() == portID { return port @@ -145,15 +145,14 @@ func getSSHAccessPort(networkInfo *devplanev1.EnvironmentNetworkInfo, portID str return nil } -func getProviderSSHHostname(instance *devplanev1.Instance) string { - if instance.GetPublicIp() != "" { - return instance.GetPublicIp() - } - if instance.GetPublicDns() != "" && instance.GetPublicDns() != instance.GetSshHostname() { - return instance.GetPublicDns() +func providerSSHHostname(instance *devplanev1.Instance, workloadHostname string) string { + if instance == nil { + return "" } - if instance.GetHostname() != "" && instance.GetHostname() != instance.GetSshHostname() { - return instance.GetHostname() + 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 index 4bc92b8dc..0695be95e 100644 --- a/pkg/cmd/refresh/sshaccess_test.go +++ b/pkg/cmd/refresh/sshaccess_test.go @@ -12,16 +12,15 @@ import ( "github.com/brevdev/brev-cli/pkg/entity" ) -type stubEnvironmentGetter struct { +type stubEnvironmentSSHClient struct { environment *devplanev1.Environment networkInfo *devplanev1.EnvironmentNetworkInfo err error - networkErr error request *devplanev1.GetEnvironmentRequest networkRequest *devplanev1.EnvironmentServiceGetNetworkInfoRequest } -func (s *stubEnvironmentGetter) GetEnvironment( +func (s *stubEnvironmentSSHClient) GetEnvironment( _ context.Context, req *connect.Request[devplanev1.GetEnvironmentRequest], ) (*connect.Response[devplanev1.GetEnvironmentResponse], error) { @@ -32,20 +31,17 @@ func (s *stubEnvironmentGetter) GetEnvironment( return connect.NewResponse(&devplanev1.GetEnvironmentResponse{Environment: s.environment}), nil } -func (s *stubEnvironmentGetter) GetNetworkInfo( +func (s *stubEnvironmentSSHClient) GetNetworkInfo( _ context.Context, req *connect.Request[devplanev1.EnvironmentServiceGetNetworkInfoRequest], ) (*connect.Response[devplanev1.EnvironmentServiceGetNetworkInfoResponse], error) { s.networkRequest = req.Msg - if s.networkErr != nil { - return nil, s.networkErr - } return connect.NewResponse(&devplanev1.EnvironmentServiceGetNetworkInfoResponse{ NetworkInfo: s.networkInfo, }), nil } -func TestEnrichWorkspacesWithSSHAccess_ContainerEndpoint(t *testing.T) { +func TestEnrichWorkspacesWithSSHAccess_UsesCurrentUsersPort(t *testing.T) { workspace := entity.Workspace{ ID: "env-1", Name: "container-env", @@ -58,7 +54,7 @@ func TestEnrichWorkspacesWithSSHAccess_ContainerEndpoint(t *testing.T) { SSHProxyHostname: "legacy-proxy.example.com", HostSSHProxyHostname: "legacy-host-proxy.example.com", } - client := &stubEnvironmentGetter{ + client := &stubEnvironmentSSHClient{ environment: &devplanev1.Environment{ Instance: &devplanev1.Instance{ SshHostname: "203.0.113.10", @@ -66,11 +62,13 @@ func TestEnrichWorkspacesWithSSHAccess_ContainerEndpoint(t *testing.T) { 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}, }, }, @@ -100,41 +98,6 @@ func TestEnrichWorkspacesWithSSHAccess_ContainerEndpoint(t *testing.T) { } } -func TestEnrichWorkspacesWithSSHAccess_UsesCurrentUsersAccessWithoutSysUserType(t *testing.T) { - workspace := entity.Workspace{ - ID: "env-1", - Name: "vm-env", - DNS: "legacy.example.com", - Status: entity.Running, - SSHUser: "ubuntu", - SSHPort: 22, - } - client := &stubEnvironmentGetter{ - environment: &devplanev1.Environment{ - Instance: &devplanev1.Instance{}, - SshAccess: []*devplanev1.SSHAccess{ - {UserId: "other-user", LinuxUser: "wrong-user", PortId: "other-port"}, - {UserId: "user-1", LinuxUser: "ubuntu", 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}, - }, - }, - } - - got := enrichWorkspacesWithSSHAccess(context.Background(), client, "user-1", []entity.Workspace{workspace}) - want := workspace - want.SSHHostname = "skybridge.example.com" - want.SSHPort = 41234 - - if diff := cmp.Diff([]entity.Workspace{want}, got); diff != "" { - t.Fatalf("unexpected workspace (-want +got): %s", diff) - } -} - func TestEnrichWorkspacesWithSSHAccess_FallsBackOnError(t *testing.T) { workspace := entity.Workspace{ ID: "env-1", @@ -144,7 +107,7 @@ func TestEnrichWorkspacesWithSSHAccess_FallsBackOnError(t *testing.T) { SSHUser: "ubuntu", SSHPort: 22, } - client := &stubEnvironmentGetter{err: errors.New("dev-plane unavailable")} + 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 != "" { @@ -160,7 +123,7 @@ func TestEnrichWorkspacesWithSSHAccess_FallsBackWithoutPortBackedAccess(t *testi SSHUser: "ubuntu", SSHPort: 22, } - client := &stubEnvironmentGetter{environment: &devplanev1.Environment{ + client := &stubEnvironmentSSHClient{environment: &devplanev1.Environment{ Instance: &devplanev1.Instance{}, SshAccess: []*devplanev1.SSHAccess{ {UserId: "user-1", LinuxUser: "root"}, diff --git a/pkg/cmd/shell/shell.go b/pkg/cmd/shell/shell.go index 19f3ccba8..9b811c30e 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -112,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) @@ -133,7 +134,7 @@ func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID if err != nil { return breverrors.WrapAndTrace(err) } - printResolvedSSHConfig(sshName) + printResolvedSSHTarget(sshName) err = util.WaitForSSHToBeAvailable(sshName, s) if err != nil { return breverrors.WrapAndTrace(err) @@ -222,7 +223,6 @@ func runSSHWithOptions(sshAlias string, host bool, printFailureAdvice bool) erro cmd = fmt.Sprintf("%s && ssh -t -o ConnectTimeout=5 %s 'DIR=$(readlink -f /proc/1/cwd 2>/dev/null || pwd); cd \"$DIR\" || echo \"Warning: Could not access container directory\" >&2; exec -l ${SHELL:-/bin/sh}'", sshAgentEval, sshAlias) } - printResolvedSSHConfig(sshAlias) var stderrBuf bytes.Buffer sshCmd := exec.Command("bash", "-c", cmd) //nolint:gosec //cmd is user input sshCmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) @@ -251,7 +251,7 @@ func runSSHWithOptions(sshAlias string, host bool, printFailureAdvice bool) erro return nil } -func printResolvedSSHConfig(sshAlias string) { +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) @@ -277,6 +277,11 @@ func printResolvedSSHConfig(sshAlias string) { } } + if hostname == "" { + _, _ = fmt.Fprintln(os.Stderr, "Resolved SSH target: unavailable") + return + } + target := hostname if user != "" { target = user + "@" + target From 3d7fc34d547835399a98974042b172e992377dcc Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Wed, 22 Jul 2026 12:50:24 -0700 Subject: [PATCH 3/3] lint --- pkg/cmd/refresh/sshaccess.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/refresh/sshaccess.go b/pkg/cmd/refresh/sshaccess.go index de2d515d4..aad5c3e9d 100644 --- a/pkg/cmd/refresh/sshaccess.go +++ b/pkg/cmd/refresh/sshaccess.go @@ -12,6 +12,7 @@ import ( "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 @@ -28,7 +29,7 @@ type workspaceSSHStore struct { func (s workspaceSSHStore) GetContextWorkspaces() ([]entity.Workspace, error) { workspaces, err := s.RefreshStore.GetContextWorkspaces() if err != nil { - return nil, err + return nil, breverrors.WrapAndTrace(err) } user, err := s.GetCurrentUser()