Skip to content
Closed
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
52 changes: 52 additions & 0 deletions lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@ func (a *Server) runPeriodicOperations() {
}()
case heartbeatCheckKey:
go func() {
var invalidNodeHostnames []string
req := &proto.ListUnifiedResourcesRequest{Kinds: []string{types.KindNode}, SortBy: types.SortBy{Field: types.ResourceKind}}

for {
Expand All @@ -1467,9 +1468,14 @@ func (a *Server) runPeriodicOperations() {
if !ok {
return false, nil
}

if services.NodeHasMissedKeepAlives(srv) {
missedKeepAliveCount++
}
if err := utils.ValidateNodeHostname(srv.GetHostname()); err != nil {
invalidNodeHostnames = append(invalidNodeHostnames, srv.GetHostname())
Comment on lines +1475 to +1476
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.

We should cap the number of invalid nodes added here to protect against consuming too much memory(what if all my nodes now have an invalid hostname 😨?) and to reduce overwhelming consumers of the notification. If the notification content is too large, we may be unable to persist the notification, and would also be hard for a user to digest. We probably want some happy medium since we also don't want one notification for each invalid hostname.

}

return false, nil
},
req,
Expand All @@ -1487,6 +1493,52 @@ func (a *Server) runPeriodicOperations() {

// Update prometheus gauge
heartbeatsMissedByAuth.Set(float64(missedKeepAliveCount))

// Send a notification that nodes with invalid hostames have been found
// to users that can update nodes
var msgBuilder strings.Builder
msgBuilder.WriteString(`Nodes have been found that are configured with an invalid hostname. Future versions of Teleport will change the hostname of nodes with invalid hostnames.
Please update these hostnames to hostnames only consisting of alphanumeric characters and the symbols '.' and '-'.
The nodes that contain invalid hostnames are as follows: `)
for i, hostname := range invalidNodeHostnames {
msgBuilder.WriteString(strconv.Quote(hostname))
if i != len(invalidNodeHostnames)-1 {
msgBuilder.WriteString(", ")
}
}

notif := &notificationsv1.GlobalNotification{
Spec: &notificationsv1.GlobalNotificationSpec{
Matcher: &notificationsv1.GlobalNotificationSpec_ByPermissions{
ByPermissions: &notificationsv1.ByPermissions{
RoleConditions: []*types.RoleConditions{
{
Rules: []types.Rule{
{
Resources: []string{types.KindNode},
Verbs: []string{types.VerbUpdate},
},
},
},
},
},
},
Notification: &notificationsv1.Notification{
SubKind: types.NotificationDefaultWarningSubKind,
Metadata: &headerv1.Metadata{
Labels: map[string]string{
types.NotificationTitleLabel: "Found nodes with invalid hostnames",
types.NotificationTextContentLabel: msgBuilder.String(),
},
},
Spec: &notificationsv1.NotificationSpec{},
},
},
}
_, err := a.CreateGlobalNotification(a.closeCtx, notif)
if err != nil {
log.Errorf("Failed to send a global notification about nodes with invalid hostnames: %v", err)
}
}()
case metricsKey:
go a.updateAgentMetrics()
Expand Down
6 changes: 6 additions & 0 deletions lib/auth/auth_with_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import (
"github.com/gravitational/teleport/lib/services/local"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
)

// ServerWithRoles is a wrapper around auth service
Expand Down Expand Up @@ -976,6 +977,11 @@ func (a *ServerWithRoles) UpsertNode(ctx context.Context, s types.Server) (*type
if err := a.action(s.GetNamespace(), types.KindNode, types.VerbCreate, types.VerbUpdate); err != nil {
return nil, trace.Wrap(err)
}
if hostname := s.GetHostname(); hostname != "" {
if err := utils.ValidateNodeHostname(hostname); err != nil {
return nil, trace.Wrap(err)
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.

I lean toward never hard rejecting any invalid hostnames, and always permitting access, but obfuscating the hostname. Imagine that Teleport is the only means by which an admin has access to the host to change the hostname and we've now prevented them from being able to modify the hostname by rejecting here.

}
}
return a.authServer.UpsertNode(ctx, s)
}

Expand Down
46 changes: 46 additions & 0 deletions lib/auth/auth_with_roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9108,3 +9108,49 @@ func TestCloudDefaultPasswordless(t *testing.T) {
})
}
}

// TestUpsertInvalidNodeHostname tests that creating a node with
// an invalid hostname results in an error.
func TestUpsertInvalidNodeHostname(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv, err := NewTestAuthServer(TestAuthServerConfig{Dir: t.TempDir()})
require.NoError(t, err)

rules := []types.Rule{
{
Resources: []string{types.KindNode},
Verbs: []string{types.VerbCreate, types.VerbUpdate},
},
}
user, _, err := CreateUserAndRole(srv.AuthServer, "test-user", nil, rules)
require.NoError(t, err)

authContext, err := srv.Authorizer.Authorize(authz.ContextWithUser(ctx, TestUser(user.GetName()).I))
require.NoError(t, err, trace.DebugReport(err))
s := &ServerWithRoles{
authServer: srv.AuthServer,
alog: srv.AuditLog,
context: *authContext,
}

name := uuid.NewString()
node, err := types.NewServerWithLabels(
name,
types.KindNode,
types.ServerSpecV2{
Hostname: "my amazing incredible spectacular node",
},
map[string]string{"name": name},
)
require.NoError(t, err)

_, err = s.UpsertNode(ctx, node)
require.ErrorContains(t, err, "Invalid hostname")

nodev2 := node.(*types.ServerV2)
nodev2.Spec.Hostname = "valid-hostname"

_, err = s.UpsertNode(ctx, node)
require.NoError(t, err)
}
8 changes: 8 additions & 0 deletions lib/services/local/presence.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,14 @@ func (s *PresenceService) UpsertNode(ctx context.Context, server types.Server) (
if n := server.GetNamespace(); n != apidefaults.Namespace {
return nil, trace.BadParameter("cannot place node in namespace %q, custom namespaces are deprecated", n)
}
if err := utils.ValidateNodeHostname(server.GetHostname()); err != nil {
s.log.Warnf(
Comment thread
capnspacehook marked this conversation as resolved.
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.

This will be misleading if this hostname violates the newly enforced length restriction. Perhaps the message here should be a bit more vague and include the error message instead?

`Node %q is configured with an invalid hostname. Future versions of Teleport will change the hostname of nodes with invalid hostnames.
Please update this hostname to a hostname only consisting of alphanumeric characters and the symbols '.' and '-'.`,
server.GetHostname(),
)
}

rev := server.GetRevision()
value, err := services.MarshalServer(server)
if err != nil {
Expand Down
10 changes: 10 additions & 0 deletions lib/srv/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import (
aws_sync "github.com/gravitational/teleport/lib/srv/discovery/fetchers/aws-sync"
"github.com/gravitational/teleport/lib/srv/discovery/fetchers/db"
"github.com/gravitational/teleport/lib/srv/server"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/spreadwork"
)

Expand Down Expand Up @@ -945,6 +946,15 @@ func (s *Server) heartbeatEICEInstance(instances *server.EC2Instances) {

continue
}
// Only validate the hostname of new nodes to ensure existing nodes that existed
// before hostname validation was checked are unaffected
if len(existingNodes) == 0 {
err := utils.ValidateNodeHostname(eiceNode.GetHostname())
if err != nil {
Comment on lines +952 to +953
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.

Suggestion: reduce the error scope

Suggested change
err := utils.ValidateNodeHostname(eiceNode.GetHostname())
if err != nil {
if err := utils.ValidateNodeHostname(eiceNode.GetHostname()); err != nil {

s.Log.Warnf("Error validating the hostname %q of node with name %q: %v", eiceNode.GetHostname(), eiceNode.GetName(), err)
continue
}
}

eiceNodeExpiration := s.clock.Now().Add(s.jitter(serverExpirationDuration))
eiceNode.SetExpiry(eiceNodeExpiration)
Expand Down
25 changes: 25 additions & 0 deletions lib/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
Expand Down Expand Up @@ -323,6 +324,30 @@ func IsValidHostname(hostname string) bool {
return true
}

const (
nodeHostnameMaxLen = 256
nodeHostnameRegexPattern = `^[a-zA-Z0-9]([\.-]?[a-zA-Z0-9]+)*$`
)

var nodeHostnameRegex = regexp.MustCompile(nodeHostnameRegexPattern)

// ValidateNodeHostname returns an error if the node hostname does not entirely
// consist of alphanumeric characters as well as '-' and '.'. A valid hostname also
// cannot begin with a symbol, and a symbol cannot be followed immediately by another symbol.
func ValidateNodeHostname(hostname string) error {
if len(hostname) > nodeHostnameMaxLen {
return trace.Errorf("Invalid hostname %q. Valid node hostnames must be under %d characters.", hostname, nodeHostnameMaxLen)
}
if !nodeHostnameRegex.MatchString(hostname) {
return trace.Errorf(
`Invalid hostname %q. Valid node hostnames consist of alphanumeric characters as well as the symbols '-' and '.'
The hostname cannot begin with a symbol, and a symbol cannot be followed immediately by another symbol.`,
hostname,
)
}
return nil
}

// IsValidUnixUser checks if a string represents a valid
// UNIX username.
func IsValidUnixUser(u string) bool {
Expand Down
55 changes: 55 additions & 0 deletions lib/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,61 @@ func TestIsValidHostname(t *testing.T) {
}
}

func TestValidateNodeHostname(t *testing.T) {
t.Parallel()
tests := []struct {
name string
hostname string
assert require.ErrorAssertionFunc
}{
{
name: "normal hostname",
hostname: "some-host-1.example.com",
assert: require.NoError,
},
{
name: "one component",
hostname: "example",
assert: require.NoError,
},
{
name: "empty",
hostname: "",
assert: require.Error,
},
{
name: "invalid characters",
hostname: "some spaces.example.com",
assert: require.Error,
},
{
name: "empty label",
hostname: "somewhere..example.com",
assert: require.Error,
},
{
name: "hostname too long",
hostname: strings.Repeat("x.", nodeHostnameMaxLen) + ".example.com",
assert: require.Error,
},
{
name: "uuid",
hostname: "9b61981f-d5c3-491d-8e58-be500db71d54",
assert: require.NoError,
},
{
name: "uuid with domain name",
hostname: "9b61981f-d5c3-491d-8e58-be500db71d54.example.com",
assert: require.NoError,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tc.assert(t, ValidateNodeHostname(tc.hostname))
})
}
}

// TestReplaceRegexp tests regexp-style replacement of values
func TestReplaceRegexp(t *testing.T) {
t.Parallel()
Expand Down
5 changes: 3 additions & 2 deletions lib/web/ui/perf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ func insertServers(ctx context.Context, b *testing.B, svc services.Presence, kin
Labels: labels,
},
Spec: types.ServerSpecV2{
Addr: addr,
Version: teleport.Version,
Addr: addr,
Hostname: name,
Version: teleport.Version,
},
}
var err error
Expand Down