Skip to content

Commit

Permalink
Add support for Client ID to Azure VM auto-discovery (#32800)
Browse files Browse the repository at this point in the history
This change adds the `client_id` optio nto the Discovery Service for
Azure VMs, which sets the client ID of the managed identity for discovered
nodes to use when joining the cluster. This allows the discovered nodes
to be discovered while having multiple managed identities assigned.
  • Loading branch information
atburke committed Oct 2, 2023
1 parent c8f6bd5 commit 736c2cc
Show file tree
Hide file tree
Showing 14 changed files with 1,543 additions and 1,221 deletions.
11 changes: 10 additions & 1 deletion api/proto/teleport/legacy/types/types.proto
Expand Up @@ -6198,8 +6198,10 @@ message InstallerParams {
// SSHDConfig provides the path to write sshd configuration changes
string SSHDConfig = 5 [(gogoproto.jsontag) = "sshd_config,omitempty"];
// PublicProxyAddr is the address of the proxy the discovered node should use
// to connect to the cluster. Used only in Azure.
// to connect to the cluster.
string PublicProxyAddr = 6 [(gogoproto.jsontag) = "proxy_addr,omitempty"];
// Azure is the set of Azure-specific installation parameters.
AzureInstallerParams Azure = 7 [(gogoproto.jsontag) = "azure,omitempty"];
}

// AWSSSM provides options to use when executing SSM documents
Expand All @@ -6209,6 +6211,13 @@ message AWSSSM {
string DocumentName = 1 [(gogoproto.jsontag) = "document_name,omitempty"];
}

// AzureInstallerParams is the set of Azure-specific installation parameters.
message AzureInstallerParams {
// ClientID is the client ID of the managed identity discovered nodes
// should use to join the cluster.
string ClientID = 1 [(gogoproto.jsontag) = "client_id,omitempty"];
}

// AzureMatcher matches Azure resources.
// It defines which resource types, filters and some configuration params.
message AzureMatcher {
Expand Down
1 change: 1 addition & 0 deletions api/types/installers/installer.sh.tmpl
Expand Up @@ -110,6 +110,7 @@ on_gcp() {
sudo /usr/local/bin/teleport node configure \
--proxy="{{ .PublicProxyAddr }}" \
--join-method=${JOIN_METHOD} \
--azure-client-id="{{ .AzureClientID }}" \
--token="$1" \
--output=file \
--labels="${LABELS}"
Expand Down
3 changes: 3 additions & 0 deletions api/types/installers/installers.go
Expand Up @@ -59,4 +59,7 @@ type Template struct {
// AutomaticUpgrades indicates whether Automatic Upgrades are enabled or disabled.
// Its value is either `true` or `false`.
AutomaticUpgrades string
// AzureClientID is the client ID of the managed identity to use when joining
// the cluster. Only applicable for the azure join method.
AzureClientID string
}
2,644 changes: 1,439 additions & 1,205 deletions api/types/types.pb.go

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions docs/pages/server-access/guides/azure-discovery.mdx
Expand Up @@ -203,9 +203,8 @@ with at least the `Microsoft.Compute/virtualMachines/read` permission.

(!docs/pages/includes/server-access/azure-join-managed-identity.mdx!)

VMs to be discovered must have only one managed identity assigned to them,
otherwise the installed Teleport agent will not know which identity to use
to authenticate with the cluster.
If the VMs to be discovered have more than one managed identity assigned to
them, save the client ID of the identity you just created for step 5.

## Step 4/6. Install the Teleport Discovery Service

Expand Down Expand Up @@ -259,6 +258,12 @@ discovery_service:
regions: ["<region>"]
tags:
"env": "prod" # Match virtual machines where tag:env=prod
install:
azure:
# Optional: If the VMs to discover have more than one managed
# identity assigned to them, set the client ID here to the client
# ID of the identity created in step 3.
client_id: "<client-id>"
```

- Edit the `teleport.auth_servers` key to match your Auth Service or Proxy Service's URI
Expand All @@ -271,6 +276,9 @@ discovery_service:

(!docs/pages/includes/server-access/custom-installer.mdx cloud="Azure" matcher="azure" matchTypes="[\"vm\"]"!)

If `client_id` is set in the Discovery Service config, custom installers will
also have the `{{ .AzureClientID }}` templating option.

## Troubleshooting

### No credential providers error
Expand Down
5 changes: 5 additions & 0 deletions lib/config/configuration.go
Expand Up @@ -1407,6 +1407,11 @@ func applyDiscoveryConfig(fc *FileConfig, cfg *servicecfg.Config) error {
ScriptName: matcher.InstallParams.ScriptName,
PublicProxyAddr: getInstallerProxyAddr(matcher.InstallParams, fc),
}
if matcher.InstallParams.Azure != nil {
m.Params.Azure = &types.AzureInstallerParams{
ClientID: matcher.InstallParams.Azure.ClientID,
}
}
}
cfg.Discovery.AzureMatchers = append(cfg.Discovery.AzureMatchers, m)
}
Expand Down
6 changes: 6 additions & 0 deletions lib/config/configuration_test.go
Expand Up @@ -3749,6 +3749,9 @@ func TestApplyDiscoveryConfig(t *testing.T) {
},
ScriptName: "default-installer",
PublicProxyAddr: "proxy.example.com",
Azure: &AzureInstallParams{
ClientID: "abcd1234",
},
},
},
},
Expand All @@ -3764,6 +3767,9 @@ func TestApplyDiscoveryConfig(t *testing.T) {
JoinToken: "azure-token",
ScriptName: "default-installer",
PublicProxyAddr: "proxy.example.com",
Azure: &types.AzureInstallerParams{
ClientID: "abcd1234",
},
},
},
},
Expand Down
39 changes: 31 additions & 8 deletions lib/config/fileconf.go
Expand Up @@ -186,6 +186,9 @@ type SampleFlags struct {
NodeName string
// Silent suppresses user hint printed after config has been generated.
Silent bool
// AzureClientID is the client ID of the managed identity to use when joining
// the cluster. Only applicable for the azure join method.
AzureClientID string
}

// MakeSampleFileConfig returns a sample config to start
Expand Down Expand Up @@ -222,13 +225,8 @@ func MakeSampleFileConfig(flags SampleFlags) (fc *FileConfig, err error) {
g.DataDir = defaults.DataDir
}

joinMethod := flags.JoinMethod
if joinMethod == "" && flags.AuthToken != "" {
joinMethod = string(types.JoinMethodToken)
}
g.JoinParams = JoinParams{
TokenName: flags.AuthToken,
Method: types.JoinMethod(joinMethod),
if err := setJoinParams(&g, flags); err != nil {
return nil, trace.Wrap(err)
}

if flags.Version == defaults.TeleportConfigVersionV3 {
Expand Down Expand Up @@ -302,6 +300,23 @@ func MakeSampleFileConfig(flags SampleFlags) (fc *FileConfig, err error) {
return fc, nil
}

func setJoinParams(g *Global, flags SampleFlags) error {
joinMethod := flags.JoinMethod
if joinMethod == "" && flags.AuthToken != "" {
joinMethod = string(types.JoinMethodToken)
}
g.JoinParams = JoinParams{
TokenName: flags.AuthToken,
Method: types.JoinMethod(joinMethod),
}
if flags.AzureClientID != "" {
g.JoinParams.Azure = AzureJoinParams{
ClientID: flags.AzureClientID,
}
}
return nil
}

func makeSampleSSHConfig(conf *servicecfg.Config, flags SampleFlags, enabled bool) (SSH, error) {
var s SSH
if enabled {
Expand Down Expand Up @@ -1868,8 +1883,10 @@ type InstallParams struct {
// SSHDConfig provides the path to write sshd configuration changes
SSHDConfig string `yaml:"sshd_config,omitempty"`
// PublicProxyAddr is the address of the proxy the discovered node should use
// to connect to the cluster. Used ony in Azure.
// to connect to the cluster.
PublicProxyAddr string `yaml:"public_proxy_addr,omitempty"`
// Azure is te set of installation parameters specific to Azure.
Azure *AzureInstallParams `yaml:"azure,omitempty"`
}

func (ip *InstallParams) Parse() (types.InstallerParams, error) {
Expand Down Expand Up @@ -1901,6 +1918,12 @@ type AWSSSM struct {
DocumentName string `yaml:"document_name,omitempty"`
}

// Azure is te set of installation parameters specific to Azure.
type AzureInstallParams struct {
// ClientID is the client ID of the managed identity to use for installation.
ClientID string `yaml:"client_id"`
}

// AzureMatcher matches Azure resources.
type AzureMatcher struct {
// Subscriptions are Azure subscriptions to query for resources.
Expand Down
8 changes: 8 additions & 0 deletions lib/config/fileconf_test.go
Expand Up @@ -1759,6 +1759,14 @@ func TestMakeSampleFileConfig(t *testing.T) {
require.Error(t, err)
})

t.Run("Azure client ID", func(t *testing.T) {
fc, err := MakeSampleFileConfig(SampleFlags{
AzureClientID: "abcd1234",
})
require.NoError(t, err)
require.Equal(t, "abcd1234", fc.JoinParams.Azure.ClientID)
})

t.Run("CAPin", func(t *testing.T) {
fc, err := MakeSampleFileConfig(SampleFlags{
CAPin: "sha256:7e12c17c20d9cb",
Expand Down
1 change: 1 addition & 0 deletions lib/srv/discovery/discovery.go
Expand Up @@ -740,6 +740,7 @@ func (s *Server) handleAzureInstances(instances *server.AzureInstances) error {
Params: instances.Parameters,
ScriptName: instances.ScriptName,
PublicProxyAddr: instances.PublicProxyAddr,
ClientID: instances.ClientID,
}
if err := s.azureInstaller.Run(s.ctx, req); err != nil {
return trace.Wrap(err)
Expand Down
24 changes: 20 additions & 4 deletions lib/srv/server/azure_installer.go
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"crypto/rand"
"fmt"
"net/url"

"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v3"
"github.com/aws/aws-sdk-go/aws"
Expand All @@ -46,11 +47,16 @@ type AzureRunRequest struct {
ResourceGroup string
ScriptName string
PublicProxyAddr string
ClientID string
}

// Run runs a command on a set of virtual machines and then blocks until the
// commands have completed.
func (ai *AzureInstaller) Run(ctx context.Context, req AzureRunRequest) error {
script, err := getInstallerScript(req.ScriptName, req.PublicProxyAddr, req.ClientID)
if err != nil {
return trace.Wrap(err)
}
g, ctx := errgroup.WithContext(ctx)
// Somewhat arbitrary limit to make sure Teleport doesn't have to install
// hundreds of nodes at once.
Expand All @@ -64,15 +70,25 @@ func (ai *AzureInstaller) Run(ctx context.Context, req AzureRunRequest) error {
ResourceGroup: req.ResourceGroup,
VMName: aws.StringValue(inst.Name),
Parameters: req.Params,
Script: getInstallerScript(req.ScriptName, req.PublicProxyAddr),
Script: script,
}
return trace.Wrap(req.Client.Run(ctx, runRequest))
})
}
return trace.Wrap(g.Wait())
}

func getInstallerScript(installerName, publicProxyAddr string) string {
func getInstallerScript(installerName, publicProxyAddr, clientID string) (string, error) {
installerURL, err := url.Parse(fmt.Sprintf("https://%s/v1/webapi/scripts/installer/%v", publicProxyAddr, installerName))
if err != nil {
return "", trace.Wrap(err)
}
if clientID != "" {
q := installerURL.Query()
q.Set("azure-client-id", clientID)
installerURL.RawQuery = q.Encode()
}

// Azure treats scripts with the same content as the same invocation and
// won't run them more than once. This is fine when the installer script
// succeeds, but it makes troubleshooting much harder when it fails. To
Expand All @@ -81,6 +97,6 @@ func getInstallerScript(installerName, publicProxyAddr string) string {
nonce := make([]byte, 8)
// No big deal if rand.Read fails, the script is still valid.
_, _ = rand.Read(nonce)
return fmt.Sprintf("curl -s -L https://%s/v1/webapi/scripts/installer/%v | bash -s $@ #%x",
publicProxyAddr, installerName, nonce)
script := fmt.Sprintf("curl -s -L %s| bash -s $@ #%x", installerURL, nonce)
return script, nil
}
5 changes: 5 additions & 0 deletions lib/srv/server/azure_watcher.go
Expand Up @@ -52,6 +52,8 @@ type AzureInstances struct {
Parameters []string
// Instances is a list of discovered Azure virtual machines.
Instances []*armcompute.VirtualMachine
// ClientID is the client ID of the managed identity to use for installation.
ClientID string
}

// MakeEvents generates MakeEvents for these instances.
Expand Down Expand Up @@ -118,6 +120,7 @@ type azureInstanceFetcher struct {
ResourceGroup string
Labels types.Labels
Parameters map[string]string
ClientID string
}

func newAzureInstanceFetcher(cfg azureFetcherConfig) *azureInstanceFetcher {
Expand All @@ -135,6 +138,7 @@ func newAzureInstanceFetcher(cfg azureFetcherConfig) *azureInstanceFetcher {
"scriptName": cfg.Matcher.Params.ScriptName,
"publicProxyAddr": cfg.Matcher.Params.PublicProxyAddr,
}
ret.ClientID = cfg.Matcher.Params.Azure.ClientID
}

return ret
Expand Down Expand Up @@ -186,6 +190,7 @@ func (f *azureInstanceFetcher) GetInstances(ctx context.Context, _ bool) ([]Inst
ScriptName: f.Parameters["scriptName"],
PublicProxyAddr: f.Parameters["publicProxyAddr"],
Parameters: []string{f.Parameters["token"]},
ClientID: f.ClientID,
}})
}
}
Expand Down
2 changes: 2 additions & 0 deletions lib/web/apiserver.go
Expand Up @@ -1763,13 +1763,15 @@ func (h *Handler) installer(w http.ResponseWriter, r *http.Request, p httprouter
if installUpdater {
repoChannel = stableCloudChannelRepo
}
azureClientID := r.URL.Query().Get("azure-client-id")

tmpl := installers.Template{
PublicProxyAddr: h.PublicProxyAddr(),
MajorVersion: version,
TeleportPackage: teleportPackage,
RepoChannel: repoChannel,
AutomaticUpgrades: strconv.FormatBool(installUpdater),
AzureClientID: azureClientID,
}
err = instTmpl.Execute(w, tmpl)
return nil, trace.Wrap(err)
Expand Down
1 change: 1 addition & 0 deletions tool/teleport/common/teleport.go
Expand Up @@ -415,6 +415,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
dumpNodeConfigure.Flag("join-method", "Method to use to join the cluster (token, iam, ec2, kubernetes, azure, gcp)").Default("token").EnumVar(&dumpFlags.JoinMethod, "token", "iam", "ec2", "kubernetes", "azure", "gcp")
dumpNodeConfigure.Flag("node-name", "Name for the Teleport node.").StringVar(&dumpFlags.NodeName)
dumpNodeConfigure.Flag("silent", "Suppress user hint message.").BoolVar(&dumpFlags.Silent)
dumpNodeConfigure.Flag("azure-client-id", "Sets the client ID of the managed identity to join with. Only applies to the 'azure' join method.").StringVar(&dumpFlags.AzureClientID)

waitCmd := app.Command(teleport.WaitSubCommand, "Used internally by Teleport to onWait until a specific condition is reached.").Hidden()
waitNoResolveCmd := waitCmd.Command("no-resolve", "Used internally to onWait until a domain stops resolving IP addresses.").Hidden()
Expand Down

0 comments on commit 736c2cc

Please sign in to comment.