teleport/lib/web/join_tokens.go
Marco André Dinis 3102b82770
Use stable/cloud when Automatic Upgrades is on. (#23395)
* Use `cloud/stable` when Automatic Upgrades is on.

Teleport provides scripts to install teleport agents/services.
Those scripts use YUM/DEB repositories when possible.
Each repo has multiple channels:
- stable/v11
- stable/v12
- cloud/stable

We want to ensure that if the cluster is running in the cloud and
automatic upgrades is on (auth service was started with
TELEPORT_AUTOMATIC_UPGRADES=yes teleport ...), then the installation
script must offer the `cloud/stable` channel.

This PR changes the following scripts:
- Discover Install Node
- Discover Install Database Service
- Install App script
- EC2 default-installer and EC2 default-agentless-installer

* add helm chart knobs to enable auto updater

* use let instead of const and remove default export

* add HA to helm chart

* always return .automatic_upgrades in web ping response

* rename cloud/stable to stable/cloud

* fix ts test
2023-03-28 16:05:09 +00:00

513 lines
16 KiB
Go

/*
Copyright 2015-2022 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package web
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"hash/fnv"
"net/http"
"net/url"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"
"k8s.io/apimachinery/pkg/util/validation"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/types"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/httplib"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/web/scripts"
"github.com/gravitational/teleport/lib/web/ui"
)
const (
teleportOSSPackageName = "teleport"
teleportEntPackageName = "teleport-ent"
stableCloudChannelRepo = "stable/cloud"
)
// nodeJoinToken contains node token fields for the UI.
type nodeJoinToken struct {
// ID is token ID.
ID string `json:"id"`
// Expiry is token expiration time.
Expiry time.Time `json:"expiry,omitempty"`
// Method is the join method that the token supports
Method types.JoinMethod `json:"method"`
// SuggestedLabels contains the set of labels we expect the node to set when using this token
SuggestedLabels []ui.Label `json:"suggestedLabels,omitempty"`
}
// scriptSettings is used to hold values which are passed into the function that
// generates the join script.
type scriptSettings struct {
token string
appInstallMode bool
appName string
appURI string
joinMethod string
databaseInstallMode bool
stableCloudChannelRepo bool
}
func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
var req types.ProvisionTokenSpecV2
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
clt, err := ctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}
var expires time.Time
var tokenName string
switch req.JoinMethod {
case types.JoinMethodIAM:
// to prevent generation of redundant IAM tokens
// we generate a deterministic name for them
tokenName, err = generateIAMTokenName(req.Allow)
if err != nil {
return nil, trace.Wrap(err)
}
// if a token with this name is found and it has indeed the same rule set,
// return it. Otherwise, go ahead and create it
t, err := clt.GetToken(r.Context(), tokenName)
if err != nil && !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
}
if err == nil {
// check if the token found has the right rules
if t.GetJoinMethod() != types.JoinMethodIAM || !isSameRuleSet(req.Allow, t.GetAllowRules()) {
return nil, trace.BadParameter("failed to create token: token with name %q already exists and does not have the expected allow rules", tokenName)
}
return &nodeJoinToken{
ID: t.GetName(),
Expiry: t.Expiry(),
Method: t.GetJoinMethod(),
}, nil
}
// IAM tokens should 'never' expire
expires = time.Now().UTC().AddDate(1000, 0, 0)
case types.JoinMethodAzure:
tokenName, err := generateAzureTokenName(req.Azure.Allow)
if err != nil {
return nil, trace.Wrap(err)
}
t, err := clt.GetToken(r.Context(), tokenName)
if err != nil && !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
}
v2token, ok := t.(*types.ProvisionTokenV2)
if !ok {
return nil, trace.BadParameter("Azure join requires v2 token")
}
if err == nil {
if t.GetJoinMethod() != types.JoinMethodAzure || !isSameAzureRuleSet(req.Azure.Allow, v2token.Spec.Azure.Allow) {
return nil, trace.BadParameter("failed to create token: token with name %q already exists and does not have the expected allow rules", tokenName)
}
return &nodeJoinToken{
ID: t.GetName(),
Expiry: t.Expiry(),
Method: t.GetJoinMethod(),
}, nil
}
default:
tokenName, err = utils.CryptoRandomHex(auth.TokenLenBytes)
if err != nil {
return nil, trace.Wrap(err)
}
expires = time.Now().UTC().Add(defaults.NodeJoinTokenTTL)
}
// If using the automatic method to add a Node, the `install.sh` will add the token's suggested labels
// as part of the initial Labels configuration for that Node
// Script install-node.sh:
// ...
// $ teleport configure ... --labels <suggested_label=value>,<suggested_label=value> ...
// ...
//
// We create an ID and return it as part of the Token, so the UI can use this ID to query the Node that joined using this token
// WebUI can then query the resources by this id and answer the question:
// - Which Node joined the cluster from this token Y?
req.SuggestedLabels = types.Labels{
types.InternalResourceIDLabel: apiutils.Strings{uuid.NewString()},
}
provisionToken, err := types.NewProvisionTokenFromSpec(tokenName, expires, req)
if err != nil {
return nil, trace.Wrap(err)
}
err = clt.CreateToken(r.Context(), provisionToken)
if err != nil {
return nil, trace.Wrap(err)
}
suggestedLabels := make([]ui.Label, 0, len(req.SuggestedLabels))
for labelKey, labelValues := range req.SuggestedLabels {
suggestedLabels = append(suggestedLabels, ui.Label{
Name: labelKey,
Value: strings.Join(labelValues, " "),
})
}
return &nodeJoinToken{
ID: tokenName,
Expiry: expires,
Method: provisionToken.GetJoinMethod(),
SuggestedLabels: suggestedLabels,
}, nil
}
func (h *Handler) getNodeJoinScriptHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params) (interface{}, error) {
scripts.SetScriptHeaders(w.Header())
useStableCloudChannelRepo := h.ClusterFeatures.AutomaticUpgrades && h.ClusterFeatures.Cloud
settings := scriptSettings{
token: params.ByName("token"),
appInstallMode: false,
joinMethod: r.URL.Query().Get("method"),
stableCloudChannelRepo: useStableCloudChannelRepo,
}
script, err := getJoinScript(r.Context(), settings, h.GetProxyClient())
if err != nil {
log.WithError(err).Info("Failed to return the node install script.")
w.Write(scripts.ErrorBashScript)
return nil, nil
}
w.WriteHeader(http.StatusOK)
if _, err := fmt.Fprintln(w, script); err != nil {
log.WithError(err).Info("Failed to return the node install script.")
w.Write(scripts.ErrorBashScript)
}
return nil, nil
}
func (h *Handler) getAppJoinScriptHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params) (interface{}, error) {
scripts.SetScriptHeaders(w.Header())
queryValues := r.URL.Query()
name, err := url.QueryUnescape(queryValues.Get("name"))
if err != nil {
log.WithField("query-param", "name").WithError(err).Debug("Failed to return the app install script.")
w.Write(scripts.ErrorBashScript)
return nil, nil
}
uri, err := url.QueryUnescape(queryValues.Get("uri"))
if err != nil {
log.WithField("query-param", "uri").WithError(err).Debug("Failed to return the app install script.")
w.Write(scripts.ErrorBashScript)
return nil, nil
}
useStableCloudChannelRepo := h.ClusterFeatures.AutomaticUpgrades && h.ClusterFeatures.Cloud
settings := scriptSettings{
token: params.ByName("token"),
appInstallMode: true,
appName: name,
appURI: uri,
stableCloudChannelRepo: useStableCloudChannelRepo,
}
script, err := getJoinScript(r.Context(), settings, h.GetProxyClient())
if err != nil {
log.WithError(err).Info("Failed to return the app install script.")
w.Write(scripts.ErrorBashScript)
return nil, nil
}
w.WriteHeader(http.StatusOK)
if _, err := fmt.Fprintln(w, script); err != nil {
log.WithError(err).Debug("Failed to return the app install script.")
w.Write(scripts.ErrorBashScript)
}
return nil, nil
}
func (h *Handler) getDatabaseJoinScriptHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params) (interface{}, error) {
scripts.SetScriptHeaders(w.Header())
useStableCloudChannelRepo := h.ClusterFeatures.AutomaticUpgrades && h.ClusterFeatures.Cloud
settings := scriptSettings{
token: params.ByName("token"),
databaseInstallMode: true,
stableCloudChannelRepo: useStableCloudChannelRepo,
}
script, err := getJoinScript(r.Context(), settings, h.GetProxyClient())
if err != nil {
log.WithError(err).Info("Failed to return the database install script.")
w.Write(scripts.ErrorBashScript)
return nil, nil
}
w.WriteHeader(http.StatusOK)
if _, err := fmt.Fprintln(w, script); err != nil {
log.WithError(err).Debug("Failed to return the database install script.")
w.Write(scripts.ErrorBashScript)
}
return nil, nil
}
func getJoinScript(ctx context.Context, settings scriptSettings, m nodeAPIGetter) (string, error) {
switch types.JoinMethod(settings.joinMethod) {
case types.JoinMethodUnspecified, types.JoinMethodToken:
decodedToken, err := hex.DecodeString(settings.token)
if err != nil {
return "", trace.Wrap(err)
}
if len(decodedToken) != auth.TokenLenBytes {
return "", trace.BadParameter("invalid token %q", decodedToken)
}
case types.JoinMethodIAM:
default:
return "", trace.BadParameter("join method %q is not supported via script", settings.joinMethod)
}
// The provided token can be attacker controlled, so we must validate
// it with the backend before using it to generate the script.
token, err := m.GetToken(ctx, settings.token)
if err != nil {
return "", trace.BadParameter("invalid token")
}
// Get hostname and port from proxy server address.
proxyServers, err := m.GetProxies()
if err != nil {
return "", trace.Wrap(err)
}
if len(proxyServers) == 0 {
return "", trace.NotFound("no proxy servers found")
}
version := proxyServers[0].GetTeleportVersion()
publicAddr := proxyServers[0].GetPublicAddr()
if publicAddr == "" {
return "", trace.Errorf("proxy public_addr is not set, you must set proxy_service.public_addr to the publicly reachable address of the proxy before you can generate a node join script")
}
hostname, portStr, err := utils.SplitHostPort(publicAddr)
if err != nil {
return "", trace.Wrap(err)
}
// Get the CA pin hashes of the cluster to join.
localCAResponse, err := m.GetClusterCACert(ctx)
if err != nil {
return "", trace.Wrap(err)
}
caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA)
if err != nil {
return "", trace.Wrap(err)
}
labelsList := []string{}
for labelKey, labelValues := range token.GetSuggestedLabels() {
labels := strings.Join(labelValues, " ")
labelsList = append(labelsList, fmt.Sprintf("%s=%s", labelKey, labels))
}
var dbServiceResourceLabels []string
if settings.databaseInstallMode {
suggestedAgentMatcherLabels := token.GetSuggestedAgentMatcherLabels()
dbServiceResourceLabels, err = scripts.MarshalLabelsYAML(suggestedAgentMatcherLabels, 6)
if err != nil {
return "", trace.Wrap(err)
}
}
var buf bytes.Buffer
// If app install mode is requested but parameters are blank for some reason,
// we need to return an error.
if settings.appInstallMode {
if errs := validation.IsDNS1035Label(settings.appName); len(errs) > 0 {
return "", trace.BadParameter("appName %q must be a valid DNS subdomain: https://goteleport.com/docs/application-access/guides/connecting-apps/#application-name", settings.appName)
}
if !appURIPattern.MatchString(settings.appURI) {
return "", trace.BadParameter("appURI %q contains invalid characters", settings.appURI)
}
}
packageName := teleportOSSPackageName
if modules.GetModules().BuildType() == modules.BuildEnterprise {
packageName = teleportEntPackageName
}
// By default, it will use `stable/v<majorVersion>`, eg stable/v12
repoChannel := ""
if settings.stableCloudChannelRepo {
repoChannel = stableCloudChannelRepo
}
// This section relies on Go's default zero values to make sure that the settings
// are correct when not installing an app.
err = scripts.InstallNodeBashScript.Execute(&buf, map[string]interface{}{
"token": settings.token,
"hostname": hostname,
"port": portStr,
// The install.sh script has some manually generated configs and some
// generated by the `teleport <service> config` commands. The old bash
// version used space delimited values whereas the teleport command uses
// a comma delimeter. The Old version can be removed when the install.sh
// file has been completely converted over.
"caPinsOld": strings.Join(caPins, " "),
"caPins": strings.Join(caPins, ","),
"packageName": packageName,
"repoChannel": repoChannel,
"version": version,
"appInstallMode": strconv.FormatBool(settings.appInstallMode),
"appName": settings.appName,
"appURI": settings.appURI,
"joinMethod": settings.joinMethod,
"labels": strings.Join(labelsList, ","),
"databaseInstallMode": strconv.FormatBool(settings.databaseInstallMode),
"db_service_resource_labels": dbServiceResourceLabels,
})
if err != nil {
return "", trace.Wrap(err)
}
return buf.String(), nil
}
// generateIAMTokenName makes a deterministic name for a iam join token
// based on its rule set
func generateIAMTokenName(rules []*types.TokenRule) (string, error) {
// sort the rules by (account ID, arn)
// to make sure a set of rules will produce the same hash,
// no matter the order they are in the slice
orderedRules := make([]*types.TokenRule, len(rules))
copy(orderedRules, rules)
sortRules(orderedRules)
h := fnv.New32a()
for _, r := range orderedRules {
s := fmt.Sprintf("%s%s", r.AWSAccount, r.AWSARN)
_, err := h.Write([]byte(s))
if err != nil {
return "", trace.Wrap(err)
}
}
return fmt.Sprintf("teleport-ui-iam-%d", h.Sum32()), nil
}
// generateAzureTokenName makes a deterministic name for an azure join token
// based on its rule set.
func generateAzureTokenName(rules []*types.ProvisionTokenSpecV2Azure_Rule) (string, error) {
orderedRules := make([]*types.ProvisionTokenSpecV2Azure_Rule, len(rules))
copy(orderedRules, rules)
sortAzureRules(orderedRules)
h := fnv.New32a()
for _, r := range orderedRules {
_, err := h.Write([]byte(r.Subscription))
if err != nil {
return "", trace.Wrap(err)
}
}
return fmt.Sprintf("teleport-ui-azure-%d", h.Sum32()), nil
}
// sortRules sorts a slice of rules based on their AWS Account ID and ARN
func sortRules(rules []*types.TokenRule) {
sort.Slice(rules, func(i, j int) bool {
iAcct, jAcct := rules[i].AWSAccount, rules[j].AWSAccount
// if accountID is the same, sort based on arn
if iAcct == jAcct {
arn1, arn2 := rules[i].AWSARN, rules[j].AWSARN
return arn1 < arn2
}
return iAcct < jAcct
})
}
// sortAzureRules sorts a slice of Azure rules based on their subscription.
func sortAzureRules(rules []*types.ProvisionTokenSpecV2Azure_Rule) {
sort.Slice(rules, func(i, j int) bool {
return rules[i].Subscription < rules[j].Subscription
})
}
// isSameRuleSet check if r1 and r2 are the same rules, ignoring the order
func isSameRuleSet(r1 []*types.TokenRule, r2 []*types.TokenRule) bool {
sortRules(r1)
sortRules(r2)
return reflect.DeepEqual(r1, r2)
}
// isSameAzureRuleSet checks if r1 and r2 are the same rules, ignoring order.
func isSameAzureRuleSet(r1, r2 []*types.ProvisionTokenSpecV2Azure_Rule) bool {
sortAzureRules(r1)
sortAzureRules(r2)
return reflect.DeepEqual(r1, r2)
}
type nodeAPIGetter interface {
// GetToken looks up a provisioning token.
GetToken(ctx context.Context, token string) (types.ProvisionToken, error)
// GetClusterCACert returns the CAs for the local cluster without signing keys.
GetClusterCACert(ctx context.Context) (*proto.GetClusterCACertResponse, error)
// GetProxies returns a list of registered proxies.
GetProxies() ([]types.Server, error)
}
// appURIPattern is a regexp excluding invalid characters from application URIs.
var appURIPattern = regexp.MustCompile(`^[-\w/:. ]+$`)