Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eb1840a
feat(sshcert): add per-environment SSH certificate store
patelspratik Aug 19, 2026
2095277
feat(refresh): retain port_id and cert-eligibility on workspace
patelspratik Aug 19, 2026
4702aa9
feat(shell): add brev shell --cert-only for Match exec cert minting
patelspratik Aug 20, 2026
bee7011
feat(ssh): emit Match exec cert block for cert-eligible workspaces
patelspratik Aug 20, 2026
5aaef0d
refactor: trim over-defensiveness and test duplication
patelspratik Aug 20, 2026
47a2a20
remove some unncessary comments
patelspratik Aug 20, 2026
2d334a4
feat(shell): wire IssueEnvironmentSSHCertificate gRPC, drop stub
patelspratik Aug 20, 2026
e719698
refactor: trim comments to non-obvious gotchas and motivation only
patelspratik Aug 20, 2026
fc9849d
remove more comments
patelspratik Aug 21, 2026
62753e1
fix(ssh): use absolute brev path in Match exec, not bare 'brev'
patelspratik Aug 21, 2026
96c18b8
docs: design SSH certificate Match exec compatibility
patelspratik Aug 21, 2026
91ff6b4
docs: plan SSH certificate Match exec compatibility
patelspratik Aug 21, 2026
a368eed
fix(ssh): include workspace in certificate hook
patelspratik Aug 21, 2026
c4184ee
fix(shell): infer SSH certificate requests from fields
patelspratik Aug 21, 2026
6320bc8
fix(ssh): escape certificate hook arguments
patelspratik Aug 21, 2026
1eefd43
remove docs
patelspratik Aug 21, 2026
172cbc4
clean
patelspratik Aug 21, 2026
a12991d
refactor: rename certOnly -> mintCert
patelspratik Aug 21, 2026
61f618c
clean
patelspratik Aug 21, 2026
f2445c1
cleanup
patelspratik Aug 21, 2026
28819b0
refactor: extract mint-cert into its own hidden command
patelspratik Aug 21, 2026
86cf348
clean
patelspratik Aug 21, 2026
10426dd
fix(mintcert): use noLoginCmdStore, treat empty token as auth failure
patelspratik Aug 21, 2026
b544ab0
docs: drop stale 'brev shell <instance> argument contract' comment
patelspratik Aug 21, 2026
e93eb6a
test: remove 4 low-value/duplicative cert tests
patelspratik Aug 21, 2026
71e9818
clean
patelspratik Aug 21, 2026
844f4cf
docs: cut redundant comments that restate the code
patelspratik Aug 21, 2026
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
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
156 changes: 156 additions & 0 deletions pkg/cmd/mintcert/mintcert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// 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) (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 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")
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 == "" {
// Match exec must stay non-interactive: an empty/expired token is a hard
// failure so ssh drops the cert IdentityFile and falls back to brev.pem,
// rather than blocking on a login prompt that would hang the ssh invocation.
_, _ = 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()
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
}

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