Skip to content
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ module github.com/brevdev/brev-cli
go 1.25.0

require (
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1
connectrpc.com/connect v1.20.0
github.com/NVIDIA/go-nvml v0.13.0-1
github.com/alessio/shellescape v1.4.1
Expand Down Expand Up @@ -44,12 +44,13 @@ require (
github.com/tweekmonster/luser v0.0.0-20161003172636-3fa38070dbd7
github.com/wk8/go-ordered-map/v2 v2.0.0
github.com/writeas/go-strip-markdown v2.0.1+incompatible
golang.org/x/crypto v0.55.0
golang.org/x/text v0.41.0
k8s.io/cli-runtime v0.31.1
)

require (
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 // indirect
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1 // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
Expand Down Expand Up @@ -100,7 +101,6 @@ require (
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sync v0.22.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
Expand Down Expand Up @@ -152,7 +152,7 @@ require (
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.11
google.golang.org/protobuf v1.36.12
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1 h1:xanul5g4JQ0OPAQ3tjN8bTznw+aA6B/oq3pzOy8kC8Q=
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1/go.mod h1:ZxWENaPM6882Wtl2z6rZYVpXoagSyF6DiY/6m4BjGMU=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1 h1:KMs3AGf1zys1H8TnjBCorCd12zzWoUQae956KgsNfRM=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo=
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 h1:6amhprQmCKJ4wgJ6ngkh32d9V+dQcOLUZ/SfHdOnYgo=
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1/go.mod h1:O+pnSHMru/naTMrm4tmpBoH3wz6PHa+R75HR7Mv8X2g=
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1 h1:PKIsaGilewnQUSHNUn+Ir4sagWne713vJS3Ys7h9vAY=
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1/go.mod h1:r4xfuOy9bpAXm13ugDRO+JNmFVlXecGRuKtn1X7os/k=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1 h1:gmAgE9NC+BAovZIs9CNmjgExqM+Gox8AZ6ud3eVMxfA=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1/go.mod h1:N18pnR0HL6srurI7G19FpSEki71wA1u4e2c5zbfeTV8=
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1 h1:Qk/4GJyWVWvWsfEFeX4T+k7KouZdRUxxUnIUwJ3hmZg=
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1/go.mod h1:SacJAYqnICCQAsBA46cSA/hxhqhxYkiYzseucf6/fhQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
Expand Down Expand Up @@ -785,8 +785,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
6 changes: 6 additions & 0 deletions pkg/cmd/refresh/sshaccess.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/brevdev/brev-cli/pkg/config"
"github.com/brevdev/brev-cli/pkg/entity"
breverrors "github.com/brevdev/brev-cli/pkg/errors"
"github.com/brevdev/brev-cli/pkg/sshcert"
)

const sshAccessLookupTimeout = 10 * time.Second
Expand Down Expand Up @@ -117,6 +118,11 @@ func resolveWorkspaceSSH(
workspace.SSHUser = access.GetLinuxUser()
workspace.SSHProxyHostname = ""

// Retain port_id and cert-eligibility for the SSH config generator's Match
// exec block. Empty when SSH access wasn't resolved via the Environment API.
workspace.PortID = access.GetPortId()
workspace.SSHCertEligible = sshcert.EnvironmentCertEligible(environment.GetLabels())

// 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
Expand Down
35 changes: 35 additions & 0 deletions pkg/cmd/refresh/sshaccess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func TestEnrichWorkspacesWithSSHAccess_UsesCurrentUsersPort(t *testing.T) {
want.SSHProxyHostname = ""
want.HostSSHHostname = "203.0.113.10"
want.HostSSHProxyHostname = ""
want.PortID = "ssh-port"
want.SSHCertEligible = false // mock environment has no certauth label

if diff := cmp.Diff([]entity.Workspace{want}, got); diff != "" {
t.Fatalf("unexpected workspace (-want +got): %s", diff)
Expand Down Expand Up @@ -138,3 +140,36 @@ func TestEnrichWorkspacesWithSSHAccess_FallsBackWithoutPortBackedAccess(t *testi
t.Fatal("network info should not be fetched without port-backed access")
}
}

func TestEnrichWorkspacesWithSSHAccess_MarksCertEligibleFromLabels(t *testing.T) {
workspace := entity.Workspace{
ID: "env-1",
Name: "cert-env",
Status: entity.Running,
}
client := &stubEnvironmentSSHClient{
environment: &devplanev1.Environment{
Labels: map[string]string{"sshprovider": "certauth"},
Instance: &devplanev1.Instance{SshHostname: "203.0.113.10", SshPort: 22, PublicIp: "203.0.113.10"},
SshAccess: []*devplanev1.SSHAccess{
{UserId: "user-1", LinuxUser: "ubuntu", 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})
if len(got) != 1 {
t.Fatalf("expected 1 workspace, got %d", len(got))
}
if got[0].PortID != "ssh-port" {
t.Errorf("PortID = %q, want %q", got[0].PortID, "ssh-port")
}
if !got[0].SSHCertEligible {
t.Errorf("SSHCertEligible = false, want true (labels have sshprovider=certauth)")
}
}
165 changes: 165 additions & 0 deletions pkg/cmd/shell/certonly.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package shell

import (
"context"
"fmt"
"os"
"strings"
"time"

devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
"connectrpc.com/connect"
"github.com/spf13/afero"
"github.com/spf13/cobra"

"github.com/brevdev/brev-cli/pkg/cmd/register"
"github.com/brevdev/brev-cli/pkg/config"
breverrors "github.com/brevdev/brev-cli/pkg/errors"
"github.com/brevdev/brev-cli/pkg/externalnode"
"github.com/brevdev/brev-cli/pkg/sshcert"
)

// certOnlyTimeout bounds the wait for one issuance; bounds the worst-case
// delay ssh sees before a login.
const certOnlyTimeout = 15 * time.Second

type certOnlyRequest struct {
EnvironmentID string
PortID string
LinuxUser string
OutKey string // absolute path to write the private key (cert goes to <OutKey>-cert.pub)
}

type CertIssuer interface {
Issue(ctx context.Context, req certIssueRequest) (certIssueResult, error)
}

type certIssueRequest struct {
EnvironmentID string
PortID string
LinuxUser string
PublicKey string
}

type certIssueResult struct {
Certificate string
}

type environmentCertClient interface {
IssueEnvironmentSSHCertificate(ctx context.Context, req *connect.Request[devplanev1.IssueEnvironmentSSHCertificateRequest]) (*connect.Response[devplanev1.IssueEnvironmentSSHCertificateResponse], error)
}

type rpcCertIssuer struct {
client environmentCertClient
}

func (r rpcCertIssuer) Issue(ctx context.Context, req certIssueRequest) (certIssueResult, error) {
res, err := r.client.IssueEnvironmentSSHCertificate(ctx, connect.NewRequest(&devplanev1.IssueEnvironmentSSHCertificateRequest{
EnvironmentId: req.EnvironmentID,
LinuxUser: req.LinuxUser,
PortId: req.PortID,
PublicKey: req.PublicKey,
}))
if err != nil {
return certIssueResult{}, breverrors.WrapAndTrace(err)
}
return certIssueResult{Certificate: res.Msg.GetCertificate()}, nil
}

func newCertIssuer(provider externalnode.TokenProvider, baseURL string) CertIssuer {
return rpcCertIssuer{client: register.NewEnvironmentServiceClient(provider, baseURL)}
}

type certOnlyStore interface {
GetAccessToken() (string, error)
}

// runCertOnly is invoked by the ssh config's Match exec hook. On any failure it returns non-zero
// so ssh falls back to the static brev.pem. Must not prompt, as that would hang ssh.
func runCertOnly(store ShellStore, req certOnlyRequest) error {
return runCertOnlyWith(store, afero.NewOsFs(), newCertIssuer(store, config.GlobalConfig.GetBrevPublicAPIURL()), req)
}

func runCertOnlyWith(store certOnlyStore, fs afero.Fs, issuer CertIssuer, req certOnlyRequest) error {
if _, err := store.GetAccessToken(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "brev: no auth method found. Run `brev login` and retry.")
return breverrors.WrapAndTrace(err)
}
certPath := req.OutKey + "-cert.pub"
if ok, err := sshcert.HasValidCertAt(fs, certPath, time.Now(), sshcert.DefaultRenewalMargin); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "brev: failed to check cached cert: %v\n", err)
return breverrors.WrapAndTrace(err)
} else if ok {
return nil
}
privKeyPEM, pubKeyOpenSSH, err := sshcert.GenerateKeyPair()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "brev: failed to generate keypair: %v\n", err)
return breverrors.WrapAndTrace(err)
}
ctx, cancel := context.WithTimeout(context.Background(), certOnlyTimeout)
defer cancel()
res, err := issuer.Issue(ctx, certIssueRequest{
EnvironmentID: req.EnvironmentID,
PortID: req.PortID,
LinuxUser: req.LinuxUser,
PublicKey: pubKeyOpenSSH,
})
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "brev: could not issue ssh certificate: %v\n", err)
return breverrors.WrapAndTrace(err)
}
if err := sshcert.WriteFiles(fs, req.OutKey, certPath, privKeyPEM, res.Certificate); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "brev: failed to write cert files: %v\n", err)
return breverrors.WrapAndTrace(err)
}
return nil
}

type certOnlyFlags struct {
certOnly bool
env string
port string
user string
outKey string
}

// addCertOnlyFlags registers the hidden --cert-only flags. They are an
// implementation detail of the ssh config's Match exec hook, not user-facing.
func addCertOnlyFlags(cmd *cobra.Command, f *certOnlyFlags) {
cmd.Flags().BoolVar(&f.certOnly, "cert-only", false, "mint an SSH certificate and write it to disk, then exit (used by the ssh config Match exec hook)")
cmd.Flags().StringVar(&f.env, "env", "", "(--cert-only) environment ID to mint a certificate for")
cmd.Flags().StringVar(&f.port, "port", "", "(--cert-only) network-member port ID for the SSH access")
cmd.Flags().StringVar(&f.user, "user", "", "(--cert-only) linux user for the certificate principal")
cmd.Flags().StringVar(&f.outKey, "out-key", "", "(--cert-only) absolute path to write the private key (certificate goes to <path>-cert.pub)")

for _, name := range []string{"cert-only", "env", "port", "user", "out-key"} {
_ = cmd.Flags().MarkHidden(name)
}
}

// validateCertOnly errors if --cert-only is set without all four required
// params. The flags are hidden and only set by the generated ssh config, so the
// inverse (params without --cert-only) isn't a real scenario.
func validateCertOnly(f certOnlyFlags) error {
if !f.certOnly {
return nil
}
var missing []string
if f.env == "" {
missing = append(missing, "--env")
}
if f.port == "" {
missing = append(missing, "--port")
}
if f.user == "" {
missing = append(missing, "--user")
}
if f.outKey == "" {
missing = append(missing, "--out-key")
}
if len(missing) > 0 {
return fmt.Errorf("--cert-only requires %s", strings.Join(missing, ", "))
}
return nil
}
Loading
Loading