Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
23 changes: 23 additions & 0 deletions pkg/analytics/posthog.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ func IsAnalyticsFeatureEnabled() bool {
return ok && enabled
}

func IsSSHCertRequired() bool {
anonID := GetOrCreateAnalyticsID()
if anonID == "" {
return false
}

c, err := getClient()
if err != nil {
return false
}

result, err := c.IsFeatureEnabled(posthog.FeatureFlagPayload{
Key: "ssh-cert-required-cli",
DistinctId: anonID,
})
if err != nil {
return false
}

enabled, ok := result.(bool)
return ok && enabled
}

// RecordCommandStart should be called from PersistentPreRunE to record the start time
// and store the command context for potential error-path capture.
func RecordCommandStart(cmd *cobra.Command, args []string) {
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/brevdev/brev-cli/pkg/cmd/login"
"github.com/brevdev/brev-cli/pkg/cmd/logout"
"github.com/brevdev/brev-cli/pkg/cmd/ls"
"github.com/brevdev/brev-cli/pkg/cmd/mintcert"
"github.com/brevdev/brev-cli/pkg/cmd/notebook"
"github.com/brevdev/brev-cli/pkg/cmd/ollama"
"github.com/brevdev/brev-cli/pkg/cmd/open"
Expand Down Expand Up @@ -303,6 +304,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor
cmd.AddCommand(configureenvvars.NewCmdConfigureEnvVars(t, loginCmdStore))
cmd.AddCommand(importideconfig.NewCmdImportIDEConfig(t, noLoginCmdStore))
cmd.AddCommand(shell.NewCmdShell(t, loginCmdStore, noLoginCmdStore))
cmd.AddCommand(mintcert.NewCmdMintCert(noLoginCmdStore))
cmd.AddCommand(exec.NewCmdExec(t, loginCmdStore, noLoginCmdStore))
cmd.AddCommand(copy.NewCmdCopy(t, loginCmdStore, noLoginCmdStore))
cmd.AddCommand(open.NewCmdOpen(t, loginCmdStore, noLoginCmdStore))
Expand Down
149 changes: 149 additions & 0 deletions pkg/cmd/mintcert/mintcert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Package mintcert implements the `brev mint-cert` command, which mints a
// short-lived SSH certificate for an environment and writes it (with its
// backing ephemeral keypair) to disk. It is invoked by the ssh config's
// Match exec hook, generated by `brev refresh`.
package mintcert

import (
"context"
"fmt"
"os"
"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"
)

const timeout = 15 * time.Second

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

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

type certIssueRequest struct {
EnvironmentID string
PortID string
LinuxUser string
PublicKey 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) (string, 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 "", breverrors.WrapAndTrace(err)
}
return res.Msg.GetCertificate(), nil
}

func NewCmdMintCert(store Store) *cobra.Command {
var (
env string
port string
user string
outKey string
)
cmd := &cobra.Command{
Use: "mint-cert",
Short: "Mint a short-lived SSH certificate for an environment",
Args: cobra.NoArgs,
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runMintCert(store, mintCertRequest{
EnvironmentID: env,
PortID: port,
LinuxUser: user,
OutKey: outKey,
})
},
}
cmd.Flags().StringVar(&env, "env", "", "environment ID to mint a certificate for")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep this vague (similar to ssh, which is "the name or ID") so that we could possibly support external nodes with the same command?

cmd.Flags().StringVar(&port, "port", "", "network-member port ID for the SSH access")
cmd.Flags().StringVar(&user, "linux-user", "", "Linux user for the certificate principal")
cmd.Flags().StringVar(&outKey, "out-key", "", "private-key path (certificate goes to <path>-cert.pub)")
_ = cmd.MarkFlagRequired("env")
_ = cmd.MarkFlagRequired("port")
_ = cmd.MarkFlagRequired("linux-user")
_ = cmd.MarkFlagRequired("out-key")
return cmd
}

type mintCertRequest struct {
EnvironmentID string
PortID string
LinuxUser string
OutKey string
}

func runMintCert(store Store, req mintCertRequest) error {
return runMintCertWith(store, afero.NewOsFs(), newCertIssuer(store, config.GlobalConfig.GetBrevPublicAPIURL()), req)
}

func runMintCertWith(store Store, fs afero.Fs, issuer CertIssuer, req mintCertRequest) error {
token, err := store.GetAccessToken()
if err != nil || token == "" {
_, _ = fmt.Fprintln(os.Stderr, "brev: not logged in. Run `brev login` and retry.")
if err != nil {
return breverrors.WrapAndTrace(err)
}
return fmt.Errorf("not logged in")
}
_ = token
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(), timeout)
defer cancel()
cert, 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, cert); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "brev: failed to write cert files: %v\n", err)
return breverrors.WrapAndTrace(err)
}
return nil
}

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