metropolis: add cluster domain config and metroctl param

This adds a --cluster parameter to metroctl and a cluster domain field
to the bootstrap configuration. It is not yet used anywhere, but later
the cluster domain will be used to identify the cluster.

The length of the cluster domain is limited to 80, to allow for
constructing subdomains. This limit could be increased later if needed,
but it cannot easily be decreased, so I chose a conservative value that
should be enough in most cases.

Change-Id: I627cca8eb1d92c4b06e4dfd6b6926a013e8f33ae
Reviewed-on: https://review.monogon.dev/c/monogon/+/3508
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/metropolis/node/validation.go b/metropolis/node/validation.go
new file mode 100644
index 0000000..cf6d520
--- /dev/null
+++ b/metropolis/node/validation.go
@@ -0,0 +1,61 @@
+package node
+
+import (
+	"errors"
+	"fmt"
+	"regexp"
+)
+
+const (
+	// domainNameMaxLength is the maximum length of a domain name supported by DNS
+	// when represented without a trailing dot.
+	domainNameMaxLength = 253
+
+	// clusterDomainMaxLength is the maximum length of a cluster domain. Limiting
+	// this to 80 allows for constructing subdomains of the cluster domain, where
+	// the subdomain part can have length up to 172. With the joining dot, this
+	// adds up to 253.
+	clusterDomainMaxLength = 80
+)
+
+var (
+	fmtDomainNameLabel       = `[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?`
+	reDomainName             = regexp.MustCompile(`^` + fmtDomainNameLabel + `(\.` + fmtDomainNameLabel + `)*$`)
+	reDomainNameEndsInNumber = regexp.MustCompile(`(^|\.)([0-9]+|0x[0-9a-f]*)$`)
+
+	errDomainNameTooLong      = fmt.Errorf("too long, must have length at most %d", domainNameMaxLength)
+	errDomainNameInvalid      = errors.New("must consist of labels separated by '.', where each label has between 1 and 63 lowercase letters, digits or '-', and must not start or end with '-'")
+	errDomainNameEndsInNumber = errors.New("must not end in a number")
+
+	errClusterDomainTooLong = fmt.Errorf("too long, must have length at most %d", clusterDomainMaxLength)
+)
+
+// validateDomainName returns an error if the passed string is not a valid
+// domain name, according to these rules: The name must be a valid DNS name
+// without a trailing dot. Labels must only consist of lowercase letters, digits
+// or '-', and must not start or end with '-'. Additionally, the name must not
+// end in a number, so that it won't be parsed as an IPv4 address.
+func validateDomainName(d string) error {
+	if len(d) > domainNameMaxLength {
+		return errDomainNameTooLong
+	}
+	// This implements RFC 1123 domain validation. Additionally, it does not allow
+	// uppercase, so that we don't need to implement case-insensitive matching.
+	if !reDomainName.MatchString(d) {
+		return errDomainNameInvalid
+	}
+	// This implements https://url.spec.whatwg.org/#ends-in-a-number-checker
+	if reDomainNameEndsInNumber.MatchString(d) {
+		return errDomainNameEndsInNumber
+	}
+	return nil
+}
+
+// ValidateClusterDomain returns an error if the passed string is not a valid
+// cluster domain.
+func ValidateClusterDomain(d string) error {
+	if len(d) > clusterDomainMaxLength {
+		return errClusterDomainTooLong
+	}
+	return validateDomainName(d)
+}