Skip to content
Draft
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
11 changes: 11 additions & 0 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i

vmUpdateCmd := runCmds.InitVMUpdateCommand(vmCmd)
runCmds.InitVMUpdateTTL(vmUpdateCmd)
runCmds.InitVMUpdateRBACPolicy(vmUpdateCmd)

vmPortCmd := runCmds.InitVMPort(vmCmd)
runCmds.InitVMPortLs(vmPortCmd)
Expand Down Expand Up @@ -384,6 +385,16 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i
}
}

// If running inside a CMX VM, use the api_url from the MMDS so the CLI
// talks to the same vendor-api instance that issued the token. Only
// applies when REPLICATED_API_ORIGIN is not explicitly set by the user.
if creds.IsCMX && creds.APIOrigin != "" && os.Getenv("REPLICATED_API_ORIGIN") == "" {
platformOrigin = strings.TrimRight(creds.APIOrigin, "/")
if debugFlag {
fmt.Fprintf(os.Stderr, "[DEBUG] Using CMX MMDS vendor API origin: %s\n", platformOrigin)
}
}

if debugFlag {
fmt.Fprintf(os.Stderr, "[DEBUG] Platform API origin: %s\n", platformOrigin)
}
Expand Down
8 changes: 5 additions & 3 deletions cli/cmd/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,9 @@ type runnerArgs struct {
createVMWaitDuration time.Duration
createVMTags []string
createVMNetwork string
createVMDryRun bool
createVMPublicKeys []string
createVMDryRun bool
createVMPublicKeys []string
createVMRBACPolicyName string

lsVMShowTerminated bool
lsVMStartTime string
Expand All @@ -235,7 +236,8 @@ type runnerArgs struct {
removeVMNames []string
removeVMDryRun bool

updateVMTTL string
updateVMTTL string
updateVMRBACPolicyName string

updateVMName string
updateVMID string
Expand Down
12 changes: 12 additions & 0 deletions cli/cmd/vm_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ replicated vm create --distribution ubuntu --version 20.04 --ssh-public-key ~/.s

cmd.Flags().StringArrayVar(&r.args.createVMTags, "tag", []string{}, "Tag to apply to the VM (key=value format, can be specified multiple times)")
cmd.Flags().StringArrayVar(&r.args.createVMPublicKeys, "ssh-public-key", []string{}, "Path to SSH public key file to add to the VM (can be specified multiple times)")
cmd.Flags().StringVar(&r.args.createVMRBACPolicyName, "rbac-policy-name", "", "(alpha) Name of the RBAC policy to assign to the VM (enables automatic vendor-api authentication inside the VM)")
cmd.Flags().MarkHidden("rbac-policy-name")

cmd.Flags().BoolVar(&r.args.createVMDryRun, "dry-run", false, "Dry run")

Expand Down Expand Up @@ -103,6 +105,15 @@ func (r *runners) createVM(cmd *cobra.Command, args []string) error {
publicKeys = append(publicKeys, publicKey)
}

var rbacPolicyID string
if r.args.createVMRBACPolicyName != "" {
p, err := r.kotsAPI.GetPolicyByName(r.args.createVMRBACPolicyName)
if err != nil {
return errors.Wrap(err, "get rbac policy")
}
rbacPolicyID = p.ID
}

opts := kotsclient.CreateVMOpts{
Name: r.args.createVMName,
Distribution: r.args.createVMDistribution,
Expand All @@ -115,6 +126,7 @@ func (r *runners) createVM(cmd *cobra.Command, args []string) error {
Tags: tags,
PublicKeys: publicKeys,
DryRun: r.args.createVMDryRun,
RBACPolicyID: rbacPolicyID,
}

vms, err := r.createAndWaitForVM(opts)
Expand Down
72 changes: 72 additions & 0 deletions cli/cmd/vm_update_rbac_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cmd

import (
"fmt"

"github.com/pkg/errors"
"github.com/replicatedhq/replicated/pkg/platformclient"
"github.com/spf13/cobra"
)

func (r *runners) InitVMUpdateRBACPolicy(parent *cobra.Command) *cobra.Command {
cmd := &cobra.Command{
Use: "rbac-policy [ID_OR_NAME]",
Hidden: true,
Short: "(alpha) Update the RBAC policy assigned to a VM.",
Long: `(alpha) The 'rbac-policy' command assigns or removes the RBAC policy on a running VM.

When a policy is assigned, the VM's OIDC client credentials are used by the replicated CLI
inside the VM to authenticate with vendor-api automatically using that policy's permissions.
Pass an empty string to '--rbac-policy-name' to remove the policy from the VM.

Note: this feature is currently in alpha and requires the cmx_vm_rbac feature flag to be enabled.`,
Example: `# Assign an RBAC policy to a VM by VM ID
replicated vm update rbac-policy aaaaa11 --rbac-policy-name "Read Only"

# Assign an RBAC policy to a VM by VM name
replicated vm update rbac-policy my-test-vm --rbac-policy-name "Read Only"

# Remove the RBAC policy from a VM
replicated vm update rbac-policy my-test-vm --rbac-policy-name ""`,
RunE: r.updateVMRBACPolicy,
SilenceUsage: true,
ValidArgsFunction: r.completeVMIDsAndNames,
}
parent.AddCommand(cmd)

cmd.Flags().StringVar(&r.args.updateVMRBACPolicyName, "rbac-policy-name", "", "(alpha) Name of the RBAC policy to assign to the VM (pass empty string to remove)")
cmd.MarkFlagRequired("rbac-policy-name")

return cmd
}

func (r *runners) updateVMRBACPolicy(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
vmID, err := r.getVMIDFromArg(args[0])
if err != nil {
return errors.Wrap(err, "get vm id from arg")
}
r.args.updateVMID = vmID
} else if err := r.ensureUpdateVMIDArg(args); err != nil {
return errors.Wrap(err, "ensure vm id arg")
}

var policyID string
if r.args.updateVMRBACPolicyName != "" {
p, err := r.kotsAPI.GetPolicyByName(r.args.updateVMRBACPolicyName)
if err != nil {
return errors.Wrap(err, "get rbac policy")
}
policyID = p.ID
}

if err := r.kotsAPI.UpdateVMRBACPolicy(r.args.updateVMID, policyID); err != nil {
if errors.Cause(err) == platformclient.ErrForbidden {
return ErrCompatibilityMatrixTermsNotAccepted
}
return errors.Wrap(err, "update vm rbac policy")
}

fmt.Fprintln(r.w, "RBAC policy updated.")
return nil
}
168 changes: 168 additions & 0 deletions pkg/cmxmetadata/cmxmetadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package cmxmetadata

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)

const (
mmdsIPv4Addr = "169.254.170.254"
mmdsPath = "/latest/vendor-api"
mmdsTimeout = 500 * time.Millisecond // fail fast if not in CMX
tokenLeeway = 60 * time.Second // refresh token this early before expiry
)

// ErrNotAvailable is returned when the CMX metadata service is not reachable.
// This is the normal case when the CLI is not running inside a Firecracker VM.
var ErrNotAvailable = errors.New("CMX metadata service not available")

// VMMetadata holds the OIDC client credentials provisioned by vendor-api into
// the Firecracker MMDS.
type VMMetadata struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
APIURL string `json:"api_url"`
TokenEndpoint string `json:"token_endpoint"`
}

// GetVMMetadata attempts to read OIDC credentials from the Firecracker MMDS.
// It returns ErrNotAvailable if the metadata service is not reachable (i.e.
// the CLI is not running inside a CMX VM).
//
// Firecracker MMDS v1 returns a newline-separated key listing when querying a
// nested object path. Sending Accept: application/json causes it to return the
// full JSON subtree instead, which is what we need to parse in one request.
func GetVMMetadata() (*VMMetadata, error) {
client := &http.Client{
Timeout: mmdsTimeout,
}

mmdsURL := fmt.Sprintf("http://%s%s", mmdsIPv4Addr, mmdsPath)
req, err := http.NewRequest(http.MethodGet, mmdsURL, nil)
if err != nil {
return nil, ErrNotAvailable
}
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
return nil, ErrNotAvailable
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, ErrNotAvailable
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, ErrNotAvailable
}

var meta VMMetadata
if err := json.Unmarshal(body, &meta); err != nil {
return nil, ErrNotAvailable
}

if meta.ClientID == "" || meta.ClientSecret == "" {
return nil, ErrNotAvailable
}

return &meta, nil
}

// tokenCache holds a cached access token along with its expiry time.
type tokenCache struct {
mu sync.Mutex
token string
expiresAt time.Time
}

// package-level cache shared across all calls within a process lifetime.
var cache = &tokenCache{}

// GetAccessToken returns a valid access token for the given VMMetadata, using a
// cached token when possible and refreshing it when it is about to expire.
func GetAccessToken(meta *VMMetadata) (string, error) {
cache.mu.Lock()
defer cache.mu.Unlock()

if cache.token != "" && time.Until(cache.expiresAt) > tokenLeeway {
return cache.token, nil
}

token, expiresAt, err := exchangeCredentials(meta)
if err != nil {
return "", err
}

cache.token = token
if expiresAt != nil {
cache.expiresAt = *expiresAt
}

return token, nil
}

// tokenResponse is the JSON response from the token endpoint.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}

// exchangeCredentials performs the client_credentials grant against the token
// endpoint and returns the access token along with its absolute expiry time.
func exchangeCredentials(meta *VMMetadata) (string, *time.Time, error) {
formData := url.Values{}
formData.Set("grant_type", "client_credentials")
formData.Set("client_id", meta.ClientID)
formData.Set("client_secret", meta.ClientSecret)

client := &http.Client{
Timeout: 10 * time.Second,
}

resp, err := client.Post(
meta.TokenEndpoint,
"application/x-www-form-urlencoded",
strings.NewReader(formData.Encode()),
)
if err != nil {
return "", nil, fmt.Errorf("token exchange request failed: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, fmt.Errorf("reading token response body: %w", err)
}

if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(body))
}

var tokenResp tokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", nil, fmt.Errorf("parsing token response: %w", err)
}

if tokenResp.AccessToken == "" {
return "", nil, fmt.Errorf("token endpoint returned empty access_token")
}

var expiresAt *time.Time
if tokenResp.ExpiresIn > 0 {
t := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
expiresAt = &t
}

return tokenResp.AccessToken, expiresAt, nil
}
13 changes: 13 additions & 0 deletions pkg/credentials/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path"
"path/filepath"

"github.com/replicatedhq/replicated/pkg/cmxmetadata"
"github.com/replicatedhq/replicated/pkg/credentials/types"
)

Expand Down Expand Up @@ -54,6 +55,7 @@ func GetCurrentCredentials() (*types.Credentials, error) {
// 2. Named profile (if profileName is provided)
// 3. Default profile from config file (if profileName is empty)
// 4. Legacy single token from config file (backward compatibility)
// 5. CMX VM metadata service (automatic OIDC auth inside Firecracker VMs)
func GetCredentialsWithProfile(profileName string) (*types.Credentials, error) {
// Priority 1: Check environment variables first
envCredentials, err := getEnvCredentials()
Expand Down Expand Up @@ -82,6 +84,17 @@ func GetCredentialsWithProfile(profileName string) (*types.Credentials, error) {
return configFileCredentials, nil
}

// Priority 5: CMX VM metadata service (automatic OIDC auth inside Firecracker VMs).
// This is last so that any explicitly configured token always takes precedence,
// making it easy to override the VM identity during development or testing.
vmMeta, err := cmxmetadata.GetVMMetadata()
if err == nil {
token, err := cmxmetadata.GetAccessToken(vmMeta)
if err == nil {
return &types.Credentials{APIToken: token, IsCMX: true, APIOrigin: vmMeta.APIURL}, nil
}
}

return nil, ErrCredentialsNotFound
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/credentials/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ type Credentials struct {
IsEnv bool `json:"-"`
IsConfigFile bool `json:"-"`
IsProfile bool `json:"-"`
IsCMX bool `json:"-"`

// APIOrigin is populated when IsCMX is true. It holds the api_url from the
// Firecracker MMDS so the CLI can talk to the same vendor-api instance that
// issued the token, without requiring REPLICATED_API_ORIGIN to be set manually.
APIOrigin string `json:"-"`
}

// Profile represents a named authentication profile
Expand Down
Loading
Loading