Add command "nomad tls" (#14296)

This commit is contained in:
Lance Haig 2022-11-22 20:12:07 +01:00 committed by GitHub
parent c66ab53755
commit 0263e7af34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1510 additions and 12 deletions

4
.changelog/14296.txt Normal file
View File

@ -0,0 +1,4 @@
```release-note:improvement
cli: Added tls command to enable creating Certificate Authority and Self signed TLS certificates.
There are two sub commands `tls ca` and `tls cert` that are helpers when creating certificates.
```

View File

@ -926,6 +926,41 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"tls": func() (cli.Command, error) {
return &TLSCommand{
Meta: meta,
}, nil
},
"tls ca": func() (cli.Command, error) {
return &TLSCACommand{
Meta: meta,
}, nil
},
"tls ca create": func() (cli.Command, error) {
return &TLSCACreateCommand{
Meta: meta,
}, nil
},
"tls ca info": func() (cli.Command, error) {
return &TLSCAInfoCommand{
Meta: meta,
}, nil
},
"tls cert": func() (cli.Command, error) {
return &TLSCertCommand{
Meta: meta,
}, nil
},
"tls cert create": func() (cli.Command, error) {
return &TLSCertCreateCommand{
Meta: meta,
}, nil
},
"tls cert info": func() (cli.Command, error) {
return &TLSCertInfoCommand{
Meta: meta,
}, nil
},
"ui": func() (cli.Command, error) {
return &UiCommand{
Meta: meta,

56
command/tls.go Normal file
View File

@ -0,0 +1,56 @@
package command
import (
"os"
"strings"
"github.com/mitchellh/cli"
)
type TLSCommand struct {
Meta
}
func fileDoesNotExist(file string) bool {
if _, err := os.Stat(file); os.IsNotExist(err) {
return true
}
return false
}
func (c *TLSCommand) Help() string {
helpText := `
Usage: nomad tls <subcommand> <subcommand> [options]
This command groups subcommands for creating certificates for Nomad TLS configuration.
The TLS command allows operators to generate self signed certificates to use
when securing your Nomad cluster.
Some simple examples for creating certificates can be found here.
More detailed examples are available in the subcommands or the documentation.
Create a CA
$ nomad tls ca create
Create a server certificate
$ nomad tls cert create -server
Create a client certificate
$ nomad tls cert create -client
`
return strings.TrimSpace(helpText)
}
func (c *TLSCommand) Synopsis() string {
return "Generate Self Signed TLS Certificates for Nomad"
}
func (c *TLSCommand) Name() string { return "tls" }
func (c *TLSCommand) Run(_ []string) int {
return cli.RunResultHelp
}

44
command/tls_ca.go Normal file
View File

@ -0,0 +1,44 @@
package command
import (
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
type TLSCACommand struct {
Meta
}
func (c *TLSCACommand) Help() string {
helpText := `
Usage: nomad tls ca <subcommand> [options]
This command groups subcommands for interacting with certificate authorities.
For examples, see the documentation.
Create a certificate authority.
$ nomad tls ca create
Show information about a certificate authority.
$ nomad tls ca info
`
return strings.TrimSpace(helpText)
}
func (c *TLSCACommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *TLSCACommand) Synopsis() string {
return "Helpers for managing certificate authorities"
}
func (c *TLSCACommand) Name() string { return "tls ca" }
func (c *TLSCACommand) Run(_ []string) int {
return cli.RunResultHelp
}

162
command/tls_ca_create.go Normal file
View File

@ -0,0 +1,162 @@
package command
import (
"fmt"
"strings"
"github.com/posener/complete"
"github.com/hashicorp/nomad/helper/flags"
"github.com/hashicorp/nomad/helper/tlsutil"
"github.com/hashicorp/nomad/lib/file"
)
type TLSCACreateCommand struct {
Meta
// days is the number of days the CA will be valid for
days int
// constraint boolean enables the name constraint option in the CA which
// will then reject any domains other than the ones stiputalted in -domain
// and -addtitional-domain.
constraint bool
// domain is used to provide a custom domain for the CA
domain string
// commonName is used to set a common name for the CA
commonName string
// additionalDomain provides a list of restricted domains to the CA which
// will then reject any domains other than these.
additionalDomain flags.StringFlag
}
func (c *TLSCACreateCommand) Help() string {
helpText := `
Usage: nomad tls ca create [options]
Create a new certificate authority.
CA Create Options:
-additional-domain
Add additional DNS zones to the allowed list for the CA. The server will
reject certificates for DNS names other than those specified in -domain and
-additional-domain. This flag can be used multiple times. Only used in
combination with -domain and -name-constraint.
-common-name
Common Name of CA. Defaults to "Nomad Agent CA".
-days
Provide number of days the CA is valid for from now on.
Defaults to 5 years or 1825 days.
-domain
Domain of Nomad cluster. Only used in combination with -name-constraint.
Defaults to "nomad".
-name-constraint
Enables the DNS name restriction functionality to the CA. Results in the CA
rejecting certificates for any other DNS zone. If enabled, localhost and the
value of -domain will be added to the allowed DNS zones field. If the UI is
going to be served over HTTPS its hostname must be added with
-additional-domain. Defaults to false.
`
return strings.TrimSpace(helpText)
}
func (c *TLSCACreateCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-additional-domain": complete.PredictAnything,
"-common-name": complete.PredictAnything,
"-days": complete.PredictAnything,
"-domain": complete.PredictAnything,
"-name-constraint": complete.PredictAnything,
})
}
func (c *TLSCACreateCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *TLSCACreateCommand) Synopsis() string {
return "Create a certificate authority for Nomad"
}
func (c *TLSCACreateCommand) Name() string { return "tls ca create" }
func (c *TLSCACreateCommand) Run(args []string) int {
flagSet := c.Meta.FlagSet(c.Name(), FlagSetClient)
flagSet.Usage = func() { c.Ui.Output(c.Help()) }
flagSet.Var(&c.additionalDomain, "additional-domain", "")
flagSet.IntVar(&c.days, "days", 1825, "")
flagSet.BoolVar(&c.constraint, "name-constraint", false, "")
flagSet.StringVar(&c.domain, "domain", "nomad", "")
flagSet.StringVar(&c.commonName, "common-name", "", "")
if err := flagSet.Parse(args); err != nil {
return 1
}
// Check that we got no arguments
args = flagSet.Args()
if l := len(args); l < 0 || l > 1 {
c.Ui.Error("This command takes up to one argument")
c.Ui.Error(commandErrorText(c))
return 1
}
if c.domain != "" && c.domain != "nomad" && !c.constraint {
c.Ui.Error("Please provide the -name-constraint flag to use a custom domain constraint")
return 1
}
if c.domain == "nomad" && c.constraint {
c.Ui.Error("Please provide the -domain flag if you want to enable custom domain constraints")
return 1
}
if c.additionalDomain != nil && c.domain == "" && !c.constraint {
c.Ui.Error("Please provide the -name-constraint flag to use a custom domain constraints")
return 1
}
certFileName := fmt.Sprintf("%s-agent-ca.pem", c.domain)
pkFileName := fmt.Sprintf("%s-agent-ca-key.pem", c.domain)
if !(fileDoesNotExist(certFileName)) {
c.Ui.Error(fmt.Sprintf("CA certificate file '%s' already exists", certFileName))
return 1
}
if !(fileDoesNotExist(pkFileName)) {
c.Ui.Error(fmt.Sprintf("CA key file '%s' already exists", pkFileName))
return 1
}
constraints := []string{}
if c.constraint {
constraints = []string{c.domain, "localhost"}
constraints = append(constraints, c.additionalDomain...)
}
ca, pk, err := tlsutil.GenerateCA(tlsutil.CAOpts{Name: c.commonName, Days: c.days, Domain: c.domain, PermittedDNSDomains: constraints})
if err != nil {
c.Ui.Error(err.Error())
return 1
}
if err := file.WriteAtomicWithPerms(certFileName, []byte(ca), 0755, 0666); err != nil {
c.Ui.Error(err.Error())
return 1
}
c.Ui.Output("==> CA certificate saved to: " + certFileName)
if err := file.WriteAtomicWithPerms(pkFileName, []byte(pk), 0755, 0600); err != nil {
c.Ui.Error(err.Error())
return 1
}
c.Ui.Output("==> CA certificate key saved to: " + pkFileName)
return 0
}

View File

@ -0,0 +1,98 @@
package command
import (
"crypto/x509"
"os"
"strings"
"testing"
"time"
"github.com/hashicorp/nomad/testutil"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/require"
)
func TestCACreateCommand(t *testing.T) {
testDir := t.TempDir()
previousDirectory, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(testDir))
defer os.Chdir(previousDirectory)
type testcase struct {
name string
args []string
caPath string
keyPath string
extraCheck func(t *testing.T, cert *x509.Certificate)
}
// The following subtests must run serially.
cases := []testcase{
{"ca defaults",
nil,
"nomad-agent-ca.pem",
"nomad-agent-ca-key.pem",
func(t *testing.T, cert *x509.Certificate) {
require.Equal(t, 1825*24*time.Hour, time.Until(cert.NotAfter).Round(24*time.Hour))
require.False(t, cert.PermittedDNSDomainsCritical)
require.Len(t, cert.PermittedDNSDomains, 0)
},
},
{"ca options",
[]string{
"-days=365",
"-name-constraint=true",
"-domain=foo",
"-additional-domain=bar",
},
"foo-agent-ca.pem",
"foo-agent-ca-key.pem",
func(t *testing.T, cert *x509.Certificate) {
require.Equal(t, 365*24*time.Hour, time.Until(cert.NotAfter).Round(24*time.Hour))
require.True(t, cert.PermittedDNSDomainsCritical)
require.Len(t, cert.PermittedDNSDomains, 3)
require.ElementsMatch(t, cert.PermittedDNSDomains, []string{"foo", "localhost", "bar"})
},
},
{"with common-name",
[]string{
"-common-name=foo",
},
"nomad-agent-ca.pem",
"nomad-agent-ca-key.pem",
func(t *testing.T, cert *x509.Certificate) {
require.Equal(t, cert.Subject.CommonName, "foo")
},
},
{"without common-name",
[]string{},
"nomad-agent-ca.pem",
"nomad-agent-ca-key.pem",
func(t *testing.T, cert *x509.Certificate) {
require.True(t, strings.HasPrefix(cert.Subject.CommonName, "Nomad Agent CA"))
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
ui := cli.NewMockUi()
cmd := &TLSCACreateCommand{Meta: Meta{Ui: ui}}
require.Equal(t, 0, cmd.Run(tc.args), ui.ErrorWriter.String())
require.Equal(t, "", ui.ErrorWriter.String())
// is a valid key
key := testutil.IsValidSigner(t, tc.keyPath)
require.True(t, key)
// is a valid ca expects the ca
ca := testutil.IsValidCertificate(t, tc.caPath)
require.True(t, ca.BasicConstraintsValid)
require.Equal(t, x509.KeyUsageCertSign|x509.KeyUsageCRLSign|x509.KeyUsageDigitalSignature, ca.KeyUsage)
require.True(t, ca.IsCA)
require.Equal(t, ca.AuthorityKeyId, ca.SubjectKeyId)
tc.extraCheck(t, ca)
require.NoError(t, os.Remove(tc.caPath))
require.NoError(t, os.Remove(tc.keyPath))
})
}
}

91
command/tls_ca_info.go Normal file
View File

@ -0,0 +1,91 @@
package command
import (
"fmt"
"os"
"strings"
"github.com/hashicorp/nomad/helper/tlsutil"
"github.com/posener/complete"
"github.com/ryanuber/columnize"
)
type TLSCAInfoCommand struct {
Meta
}
func (c *TLSCAInfoCommand) Help() string {
helpText := `
Usage: nomad tls ca info <CA file>
Show information about a certificate authority.
`
return strings.TrimSpace(helpText)
}
func (c *TLSCAInfoCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{})
}
func (c *TLSCAInfoCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictOr(
complete.PredictFiles("*.pem"),
)
}
func (c *TLSCAInfoCommand) Synopsis() string {
return "Show certificate authority information"
}
func (c *TLSCAInfoCommand) Name() string { return "tls cert info" }
func (c *TLSCAInfoCommand) Run(args []string) int {
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we got no arguments
args = flags.Args()
if l := len(args); l < 0 || l > 1 {
c.Ui.Error("This command takes up to one argument")
c.Ui.Error(commandErrorText(c))
return 1
}
var certFile []byte
var err error
var file string
if len(args) == 0 {
c.Ui.Error(fmt.Sprintf("Error reading CA file: %v", err))
return 1
}
if len(args) == 1 {
file = args[0]
certFile, err = os.ReadFile(file)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading CA file: %v", err))
return 1
}
}
certInfo, err := tlsutil.ParseCert(string(certFile))
if err != nil {
c.Ui.Error(err.Error())
return 1
}
// Format the certificate info
basic := []string{
fmt.Sprintf("Serial Number|%s", certInfo.SerialNumber),
fmt.Sprintf("Issuer CN|%s", certInfo.Issuer.CommonName),
fmt.Sprintf("Common Name|%s", certInfo.Subject),
fmt.Sprintf("Expiry Date|%s", certInfo.NotAfter),
fmt.Sprintf("Permitted DNS Domains|%s", certInfo.PermittedDNSDomains),
}
// Print out the information
c.Ui.Output(columnize.SimpleFormat(basic))
return 0
}

44
command/tls_cert.go Normal file
View File

@ -0,0 +1,44 @@
package command
import (
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
type TLSCertCommand struct {
Meta
}
func (c *TLSCertCommand) Help() string {
helpText := `
Usage: nomad tls cert <subcommand> [options]
This command groups subcommands for interacting with certificates.
For examples, see the documentation.
Create a TLS certificate.
$ nomad tls cert create
Show information about a TLS certificate.
$ nomad tls cert info
`
return strings.TrimSpace(helpText)
}
func (c *TLSCertCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *TLSCertCommand) Synopsis() string {
return "Helpers for managing certificates"
}
func (c *TLSCertCommand) Name() string { return "tls cert" }
func (c *TLSCertCommand) Run(_ []string) int {
return cli.RunResultHelp
}

293
command/tls_cert_create.go Normal file
View File

@ -0,0 +1,293 @@
package command
import (
"crypto/x509"
"fmt"
"net"
"os"
"strings"
"github.com/posener/complete"
"github.com/hashicorp/nomad/helper/flags"
"github.com/hashicorp/nomad/helper/tlsutil"
"github.com/hashicorp/nomad/lib/file"
)
type TLSCertCreateCommand struct {
Meta
// dnsNames is a list of additional dns records to add to the SAN addresses
dnsNames flags.StringFlag
// ipAddresses is a list of additional IP address records to add to the SAN
// addresses
ipAddresses flags.StringFlag
// ca is used to set a custom CA certificate to create certificates from.
ca string
cli bool
client bool
// key is used to set the custom CA certificate key when creating
// certificates.
key string
// days is the number of days the certificate will be valid for.
days int
// cluster_region is used to add the region name to the certifacte SAN
// records
cluster_region string
// domain is used to provide a custom domain for the certificate.
domain string
server bool
}
func (c *TLSCertCreateCommand) Help() string {
helpText := `
Usage: nomad tls cert create [options]
Create a new TLS certificate to use within the Nomad cluster TLS
configuration. You should use the -client, -server or -cli options to create
certificates for these roles.
Certificate Create Options:
-additional-dnsname
Provide an additional dnsname for Subject Alternative Names.
"localhost" is always included. This flag may be provided multiple times.
-additional-ipaddress
Provide an additional ipaddress for Subject Alternative Names.
"127.0.0.1" is always included. This flag may be provided multiple times.
-ca
Provide path to the certificate authority certificate. Defaults to
#DOMAIN#-agent-ca.pem.
-cli
Generate a certificate for use with the Nomad CLI.
-client
Generate a client certificate.
-cluster-region
Provide the datacenter. Only used for -server certificates.
Defaults to "global".
-days
Provide number of days the certificate is valid for from now on.
Defaults to 1 year.
-domain
Provide the domain. Only used for -server certificates.
-key
Provide path to the certificate authority key. Defaults to
#DOMAIN#-agent-ca-key.pem.
-server
Generate a server certificate.
`
return strings.TrimSpace(helpText)
}
func (c *TLSCertCreateCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-additional-dnsname": complete.PredictAnything,
"-additional-ipaddress": complete.PredictAnything,
"-ca": complete.PredictAnything,
"-cli": complete.PredictNothing,
"-client": complete.PredictNothing,
"-days": complete.PredictAnything,
"-cluster-region": complete.PredictAnything,
"-domain": complete.PredictAnything,
"-key": complete.PredictAnything,
"-server": complete.PredictNothing,
})
}
func (c *TLSCertCreateCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *TLSCertCreateCommand) Synopsis() string {
return "Create a new TLS certificate"
}
func (c *TLSCertCreateCommand) Name() string { return "tls cert create" }
func (c *TLSCertCreateCommand) Run(args []string) int {
flagSet := c.Meta.FlagSet(c.Name(), FlagSetClient)
flagSet.Usage = func() { c.Ui.Output(c.Help()) }
flagSet.Var(&c.dnsNames, "additional-dnsname", "")
flagSet.Var(&c.ipAddresses, "additional-ipaddress", "")
flagSet.StringVar(&c.ca, "ca", "#DOMAIN#-agent-ca.pem", "")
flagSet.BoolVar(&c.cli, "cli", false, "")
flagSet.BoolVar(&c.client, "client", false, "")
flagSet.StringVar(&c.key, "key", "#DOMAIN#-agent-ca-key.pem", "")
flagSet.IntVar(&c.days, "days", 365, "")
flagSet.StringVar(&c.cluster_region, "cluster-region", "global", "")
flagSet.StringVar(&c.domain, "domain", "nomad", "")
flagSet.BoolVar(&c.server, "server", false, "")
if err := flagSet.Parse(args); err != nil {
return 1
}
// Check that we got no arguments
args = flagSet.Args()
if l := len(args); l < 0 || l > 1 {
c.Ui.Error("This command takes up to one argument")
c.Ui.Error(commandErrorText(c))
return 1
}
if c.ca == "" {
c.Ui.Error("Please provide the ca")
return 1
}
if c.key == "" {
c.Ui.Error("Please provide the key")
return 1
}
if !((c.server && !c.client && !c.cli) ||
(!c.server && c.client && !c.cli) ||
(!c.server && !c.client && c.cli)) {
c.Ui.Error("Please provide either -server, -client, or -cli")
return 1
}
var DNSNames []string
var IPAddresses []net.IP
var extKeyUsage []x509.ExtKeyUsage
var name, prefix string
for _, d := range c.dnsNames {
if len(d) > 0 {
DNSNames = append(DNSNames, strings.TrimSpace(d))
}
}
for _, i := range c.ipAddresses {
if len(i) > 0 {
IPAddresses = append(IPAddresses, net.ParseIP(strings.TrimSpace(i)))
}
}
if c.server {
name = fmt.Sprintf("server.%s.%s", c.cluster_region, c.domain)
DNSNames = append(DNSNames, name)
DNSNames = append(DNSNames, "localhost")
IPAddresses = append(IPAddresses, net.ParseIP("127.0.0.1"))
extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
prefix = fmt.Sprintf("%s-server-%s", c.cluster_region, c.domain)
} else if c.client {
name = fmt.Sprintf("client.%s.%s", c.cluster_region, c.domain)
DNSNames = append(DNSNames, []string{name, "localhost"}...)
IPAddresses = append(IPAddresses, net.ParseIP("127.0.0.1"))
extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}
prefix = fmt.Sprintf("%s-client-%s", c.cluster_region, c.domain)
} else if c.cli {
name = fmt.Sprintf("cli.%s.%s", c.cluster_region, c.domain)
DNSNames = []string{name, "localhost"}
prefix = fmt.Sprintf("%s-cli-%s", c.cluster_region, c.domain)
} else {
c.Ui.Error("Neither client, cli nor server - should not happen")
return 1
}
var pkFileName, certFileName string
tmpCert := fmt.Sprintf("%s.pem", prefix)
tmpPk := fmt.Sprintf("%s-key.pem", prefix)
// Check if the CA file already exists
if !(fileDoesNotExist(tmpCert)) {
c.Ui.Error(fmt.Sprintf("Certificate file '%s' already exists", tmpCert))
return 1
}
// Check if the Key file file already exists
if !(fileDoesNotExist(tmpPk)) {
c.Ui.Error(fmt.Sprintf("Key file '%s' already exists", tmpPk))
return 1
}
certFileName = tmpCert
pkFileName = tmpPk
caFile := strings.Replace(c.ca, "#DOMAIN#", c.domain, 1)
keyFile := strings.Replace(c.key, "#DOMAIN#", c.domain, 1)
cert, err := os.ReadFile(caFile)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading CA: %s", err))
return 1
}
caKey, err := os.ReadFile(keyFile)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading CA key: %s", err))
return 1
}
if c.server {
c.Ui.Warn(
`==> WARNING: Server Certificates grants authority to become a
server and access all state in the cluster including root keys
and all ACL tokens. Do not distribute them to production hosts
that are not server nodes. Store them as securely as CA keys.`)
}
c.Ui.Info("==> Using CA file " + caFile + " and CA key " + keyFile)
signer, err := tlsutil.ParseSigner(string(caKey))
if err != nil {
c.Ui.Error(err.Error())
return 1
}
pub, priv, err := tlsutil.GenerateCert(tlsutil.CertOpts{
Signer: signer, CA: string(cert), Name: name, Days: c.days,
DNSNames: DNSNames, IPAddresses: IPAddresses, ExtKeyUsage: extKeyUsage,
})
if err != nil {
c.Ui.Error(err.Error())
return 1
}
if err = tlsutil.Verify(string(cert), pub, name); err != nil {
c.Ui.Error(err.Error())
return 1
}
if err := file.WriteAtomicWithPerms(certFileName, []byte(pub), 0755, 0666); err != nil {
c.Ui.Error(err.Error())
return 1
}
if c.server {
c.Ui.Output("==> Server Certificate saved to " + certFileName)
} else if c.client {
c.Ui.Output("==> Client Certificate saved to " + certFileName)
} else if c.cli {
c.Ui.Output("==> Cli Certificate saved to " + certFileName)
}
if err := file.WriteAtomicWithPerms(pkFileName, []byte(priv), 0755, 0600); err != nil {
c.Ui.Error(err.Error())
return 1
}
if c.server {
c.Ui.Output("==> Server Certificate key saved to " + pkFileName)
} else if c.client {
c.Ui.Output("==> Client Certificate key saved to " + pkFileName)
} else if c.cli {
c.Ui.Output("==> CLI Certificate key saved to " + pkFileName)
}
return 0
}

View File

@ -0,0 +1,194 @@
package command
import (
"crypto/x509"
"net"
"os"
"testing"
"github.com/hashicorp/nomad/testutil"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/require"
)
func TestTlsCertCreateCommand_InvalidArgs(t *testing.T) {
t.Parallel()
type testcase struct {
args []string
expectErr string
}
cases := map[string]testcase{
"no args (ca/key inferred)": {[]string{},
"Please provide either -server, -client, or -cli"},
"no ca": {[]string{"-ca", "", "-key", ""},
"Please provide the ca"},
"no key": {[]string{"-ca", "foo.pem", "-key", ""},
"Please provide the key"},
"server+client+cli": {[]string{"-server", "-client", "-cli"},
"Please provide either -server, -client, or -cli"},
"server+client": {[]string{"-server", "-client"},
"Please provide either -server, -client, or -cli"},
"server+cli": {[]string{"-server", "-cli"},
"Please provide either -server, -client, or -cli"},
"client+cli": {[]string{"-client", "-cli"},
"Please provide either -server, -client, or -cli"},
}
for name, tc := range cases {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
ui := cli.NewMockUi()
cmd := &TLSCertCreateCommand{Meta: Meta{Ui: ui}}
require.NotEqual(t, 0, cmd.Run(tc.args))
got := ui.ErrorWriter.String()
if tc.expectErr == "" {
require.NotEmpty(t, got) // don't care
} else {
require.Contains(t, got, tc.expectErr)
}
})
}
}
func TestTlsCertCreateCommand_fileCreate(t *testing.T) {
testDir := t.TempDir()
previousDirectory, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(testDir))
defer os.Chdir(previousDirectory)
ui := cli.NewMockUi()
caCmd := &TLSCACreateCommand{Meta: Meta{Ui: ui}}
// Setup CA keys
caCmd.Run([]string{"nomad"})
type testcase struct {
name string
typ string
args []string
certPath string
keyPath string
expectCN string
expectDNS []string
expectIP []net.IP
errOut string
}
// The following subtests must run serially.
cases := []testcase{
{"server0",
"server",
[]string{"-server"},
"global-server-nomad.pem",
"global-server-nomad-key.pem",
"server.global.nomad",
[]string{
"server.global.nomad",
"localhost",
},
[]net.IP{{127, 0, 0, 1}},
"==> WARNING: Server Certificates grants authority to become a\n server and access all state in the cluster including root keys\n and all ACL tokens. Do not distribute them to production hosts\n that are not server nodes. Store them as securely as CA keys.\n",
},
{"server0-region2-altdomain",
"server",
[]string{"-server", "-cluster-region", "region2", "-domain", "nomad"},
"region2-server-nomad.pem",
"region2-server-nomad-key.pem",
"server.region2.nomad",
[]string{
"server.region2.nomad",
"localhost",
},
[]net.IP{{127, 0, 0, 1}},
"==> WARNING: Server Certificates grants authority to become a\n server and access all state in the cluster including root keys\n and all ACL tokens. Do not distribute them to production hosts\n that are not server nodes. Store them as securely as CA keys.\n",
},
{"client0",
"client",
[]string{"-client"},
"global-client-nomad.pem",
"global-client-nomad-key.pem",
"client.global.nomad",
[]string{
"client.global.nomad",
"localhost",
},
[]net.IP{{127, 0, 0, 1}},
"",
},
{"client0-region2-altdomain",
"client",
[]string{"-client", "-cluster-region", "region2", "-domain", "nomad"},
"region2-client-nomad.pem",
"region2-client-nomad-key.pem",
"client.region2.nomad",
[]string{
"client.region2.nomad",
"localhost",
},
[]net.IP{{127, 0, 0, 1}},
"",
},
{"cli0",
"cli",
[]string{"-cli"},
"global-cli-nomad.pem",
"global-cli-nomad-key.pem",
"cli.global.nomad",
[]string{
"cli.global.nomad",
"localhost",
},
nil,
"",
},
{"cli0-region2-altdomain",
"cli",
[]string{"-cli", "-cluster-region", "region2", "-domain", "nomad"},
"region2-cli-nomad.pem",
"region2-cli-nomad-key.pem",
"cli.region2.nomad",
[]string{
"cli.region2.nomad",
"localhost",
},
nil,
"",
},
}
for _, tc := range cases {
tc := tc
require.True(t, t.Run(tc.name, func(t *testing.T) {
ui := cli.NewMockUi()
cmd := &TLSCertCreateCommand{Meta: Meta{Ui: ui}}
require.Equal(t, 0, cmd.Run(tc.args))
require.Equal(t, tc.errOut, ui.ErrorWriter.String())
// is a valid cert expects the cert
cert := testutil.IsValidCertificate(t, tc.certPath)
require.Equal(t, tc.expectCN, cert.Subject.CommonName)
require.True(t, cert.BasicConstraintsValid)
require.Equal(t, x509.KeyUsageDigitalSignature|x509.KeyUsageKeyEncipherment, cert.KeyUsage)
switch tc.typ {
case "server":
require.Equal(t,
[]x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
cert.ExtKeyUsage)
case "client":
require.Equal(t,
[]x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
cert.ExtKeyUsage)
case "cli":
require.Len(t, cert.ExtKeyUsage, 0)
}
require.False(t, cert.IsCA)
require.Equal(t, tc.expectDNS, cert.DNSNames)
require.Equal(t, tc.expectIP, cert.IPAddresses)
}))
}
}

91
command/tls_cert_info.go Normal file
View File

@ -0,0 +1,91 @@
package command
import (
"fmt"
"os"
"strings"
"github.com/posener/complete"
"github.com/ryanuber/columnize"
"github.com/hashicorp/nomad/helper/tlsutil"
)
type TLSCertInfoCommand struct {
Meta
}
func (c *TLSCertInfoCommand) Help() string {
helpText := `
Usage: nomad tls cert info <certificate file>
Show information about a TLS certificate.
`
return strings.TrimSpace(helpText)
}
func (c *TLSCertInfoCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{})
}
func (c *TLSCertInfoCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictOr(
complete.PredictFiles("*.pem"),
)
}
func (c *TLSCertInfoCommand) Synopsis() string {
return "Show certificate information"
}
func (c *TLSCertInfoCommand) Name() string { return "tls cert info" }
func (c *TLSCertInfoCommand) Run(args []string) int {
flagSet := c.Meta.FlagSet(c.Name(), FlagSetClient)
flagSet.Usage = func() { c.Ui.Output(c.Help()) }
if err := flagSet.Parse(args); err != nil {
return 1
}
// Check that we got no arguments
args = flagSet.Args()
if l := len(args); l < 0 || l > 1 {
c.Ui.Error("This command takes up to one argument")
c.Ui.Error(commandErrorText(c))
return 1
}
var certFile []byte
var err error
var file string
if len(args) == 1 {
file = args[0]
certFile, err = os.ReadFile(file)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading certificate file: %v", err))
return 1
}
}
certInfo, err := tlsutil.ParseCert(string(certFile))
if err != nil {
c.Ui.Error(err.Error())
return 1
}
// Format the certificate info
basic := []string{
fmt.Sprintf("Serial Number|%s", certInfo.SerialNumber),
fmt.Sprintf("Issuer CN|%s", certInfo.Issuer.CommonName),
fmt.Sprintf("Common Name|%s", certInfo.Subject),
fmt.Sprintf("Expiry Date|%s", certInfo.NotAfter),
fmt.Sprintf("DNS Names|%s", certInfo.DNSNames),
fmt.Sprintf("IP Addresses|%s", certInfo.IPAddresses),
}
// Print out the information
c.Ui.Output(columnize.SimpleFormat(basic))
return 0
}

View File

@ -6,8 +6,8 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"net"
"os"
"strings"
"time"
@ -188,7 +188,7 @@ func (c *Config) AppendCA(pool *x509.CertPool) error {
}
// Read the file
data, err := ioutil.ReadFile(c.CAFile)
data, err := os.ReadFile(c.CAFile)
if err != nil {
return fmt.Errorf("Failed to read CA file: %v", err)
}

View File

@ -5,7 +5,6 @@ import (
"crypto/x509"
"fmt"
"io"
"io/ioutil"
"net"
"os"
"strings"
@ -89,7 +88,7 @@ TttDu+g2VdbcBwVDZ49X2Md6OY2N3G8Irdlj+n+mCQJaHwVt52DRzz0=
-----END CERTIFICATE-----
`
tmpCAFile, err := ioutil.TempFile("/tmp", "test_ca_file")
tmpCAFile, err := os.CreateTemp("/tmp", "test_ca_file")
require.NoError(err)
defer os.Remove(tmpCAFile.Name())
@ -167,7 +166,7 @@ TttDu+g2VdbcBwVDZ49X2Md6OY2N3G8Irdlj+n+mCQJaHwVt52DRzz0=
...outside of -----XXX----- blocks?
`
tmpCAFile, err := ioutil.TempFile("/tmp", "test_ca_file_extra")
tmpCAFile, err := os.CreateTemp("/tmp", "test_ca_file_extra")
require.NoError(err)
defer os.Remove(tmpCAFile.Name())
_, err = tmpCAFile.Write([]byte(certs))
@ -210,7 +209,7 @@ Ln2ZUe8CIDsQswBQS7URbqnKYDye2Y4befJkr4fmhhmMQb2ex9A4
Invalid
-----END CERTIFICATE-----`
tmpCAFile, err := ioutil.TempFile("/tmp", "test_ca_file")
tmpCAFile, err := os.CreateTemp("/tmp", "test_ca_file")
require.NoError(err)
defer os.Remove(tmpCAFile.Name())
_, err = tmpCAFile.Write([]byte(certs))
@ -242,7 +241,7 @@ func TestConfig_AppendCA_Invalid(t *testing.T) {
}
{
tmpFile, err := ioutil.TempFile("/tmp", "test_ca_file")
tmpFile, err := os.CreateTemp("/tmp", "test_ca_file")
require.Nil(err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte("Invalid CA Content!"))
@ -493,7 +492,7 @@ func startTLSServer(config *Config) (net.Conn, chan error) {
// server read any data from the client until error or
// EOF, which will allow the client to Close(), and
// *then* we Close() the server.
io.Copy(ioutil.Discard, tlsServer)
io.Copy(io.Discard, tlsServer)
tlsServer.Close()
}()
return clientConn, errc

View File

@ -105,7 +105,7 @@ func GenerateCA(opts CAOpts) (string, string, error) {
}
name := opts.Name
if name == "" {
name = fmt.Sprintf("Consul Agent CA %d", sn)
name = fmt.Sprintf("Nomad Agent CA %d", sn)
}
days := opts.Days
@ -229,6 +229,21 @@ func keyID(raw interface{}) ([]byte, error) {
return kID[:], nil
}
// ParseCert parses the x509 certificate from a PEM-encoded value.
func ParseCert(pemValue string) (*x509.Certificate, error) {
// The _ result below is not an error but the remaining PEM bytes.
block, _ := pem.Decode([]byte(pemValue))
if block == nil {
return nil, fmt.Errorf("no PEM-encoded data found")
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("first PEM-block should be CERTIFICATE type")
}
return x509.ParseCertificate(block.Bytes)
}
func parseCert(pemValue string) (*x509.Certificate, error) {
// The _ result below is not an error but the remaining PEM bytes.
block, _ := pem.Decode([]byte(pemValue))

View File

@ -86,7 +86,7 @@ func TestGenerateCA(t *testing.T) {
cert, err := parseCert(ca)
require.Nil(t, err)
require.True(t, strings.HasPrefix(cert.Subject.CommonName, "Consul Agent CA"))
require.True(t, strings.HasPrefix(cert.Subject.CommonName, "Nomad Agent CA"))
require.Equal(t, true, cert.IsCA)
require.Equal(t, true, cert.BasicConstraintsValid)
@ -104,7 +104,7 @@ func TestGenerateCA(t *testing.T) {
cert, err := parseCert(ca)
require.NoError(t, err)
require.True(t, strings.HasPrefix(cert.Subject.CommonName, "Consul Agent CA"))
require.True(t, strings.HasPrefix(cert.Subject.CommonName, "Nomad Agent CA"))
require.Equal(t, true, cert.IsCA)
require.Equal(t, true, cert.BasicConstraintsValid)
@ -147,7 +147,7 @@ func TestGenerateCert(t *testing.T) {
caID, err := keyID(signer.Public())
require.Nil(t, err)
require.Equal(t, caID, cert.AuthorityKeyId)
require.Contains(t, cert.Issuer.CommonName, "Consul Agent CA")
require.Contains(t, cert.Issuer.CommonName, "Nomad Agent CA")
require.Equal(t, false, cert.IsCA)
require.WithinDuration(t, cert.NotBefore, time.Now(), time.Minute)

51
lib/file/atomic.go Normal file
View File

@ -0,0 +1,51 @@
package file
import (
"fmt"
"os"
"path/filepath"
"github.com/hashicorp/go-uuid"
)
// WriteAtomicWithPerms creates a temp file with specific permissions and then renames and
// moves it to the path.
func WriteAtomicWithPerms(path string, contents []byte, dirPerms, filePerms os.FileMode) error {
uuid, err := uuid.GenerateUUID()
if err != nil {
return err
}
tempPath := fmt.Sprintf("%s-%s.tmp", path, uuid)
// Make a directory within the current one.
if err := os.MkdirAll(filepath.Dir(path), dirPerms); err != nil {
return err
}
// File opened with write only permissions. Will be created if it does not exist
// file is given specific permissions
fh, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, filePerms)
if err != nil {
return err
}
defer os.RemoveAll(tempPath) // clean up
if _, err := fh.Write(contents); err != nil {
fh.Close()
return err
}
// Commits the current state of the file to disk
if err := fh.Sync(); err != nil {
fh.Close()
return err
}
if err := fh.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, path); err != nil {
return err
}
return nil
}

49
testutil/tls.go Normal file
View File

@ -0,0 +1,49 @@
package testutil
import (
"crypto/x509"
"io/fs"
"os"
"testing"
"github.com/hashicorp/nomad/helper/tlsutil"
"github.com/stretchr/testify/require"
)
// Assert CA file exists and is a valid CA Returns the CA
func IsValidCertificate(t *testing.T, caPath string) *x509.Certificate {
t.Helper()
require.FileExists(t, caPath)
caData, err := os.ReadFile(caPath)
require.NoError(t, err)
ca, err := tlsutil.ParseCert(string(caData))
require.NoError(t, err)
require.NotNil(t, ca)
return ca
}
// Assert key file exists and is a valid signer returns a bool
func IsValidSigner(t *testing.T, keyPath string) bool {
t.Helper()
require.FileExists(t, keyPath)
fi, err := os.Stat(keyPath)
if err != nil {
t.Fatal("should not happen", err)
}
if want, have := fs.FileMode(0600), fi.Mode().Perm(); want != have {
t.Fatalf("private key file %s: permissions: want: %o; have: %o", keyPath, want, have)
}
keyData, err := os.ReadFile(keyPath)
require.NoError(t, err)
signer, err := tlsutil.ParseSigner(string(keyData))
require.NoError(t, err)
require.NotNil(t, signer)
return true
}

View File

@ -0,0 +1,59 @@
---
layout: docs
page_title: 'Commands: tls ca create'
description: |
This command creates a Certificate Authority that can be used to create
self signed certificates to be used for Nomad TLS setup.
---
# Command: nomad tls ca create
Create is used to create a self signed Certificate Authority to be used for
Nomad TLS setup.
## Usage
```plaintext
nomad tls ca create [options]
```
## CA Create Options
- `-additional-domain=<value>`: Add name constraints for the CA. The server will
reject certificates for DNS names other than those specified in `-domain` and
`-additional-domain`. Can be used multiple times. This option can only used in
combination with `-domain` and `-name-constraint`.
- `common-name`: Common Name of CA. Defaults to Nomad Agent CA.
- `-days=<int>`: Provide number of days the CA is valid for from now on,
defaults to 5 years.
- `-domain=<string>`: Domain of nomad cluster. Only used in combination with
`-name-constraint`. Defaults to `nomad`.
- `-name-constraint`: Add name constraints for the CA. Results in rejecting
certificates for other DNS than specified. If set to true, "localhost" and
`-domain` will be added to the allowed DNS. Defaults to false.
~> **Warning:** If `-name-constraint` is enabled and you intend to serve the
Nomad web UI over HTTPS its DNS must be added with `additional-domain`. It is
not possible to add that after the fact.
## Example
Create CA:
```shell-session
$ nomad tls ca create
==> CA Certificate saved to: nomad-agent-ca.pem
==> CA Certificate key saved to: nomad-agent-ca-key.pem
```
Create a CA with a specified domain:
```shell-session
$ nomad tls ca create -name-constraint="true" -domain="foo.com"
==> CA Certificate saved to: foo.com-agent-ca.pem
==> CA Certificate key saved to: foo.com-agent-ca-key.pem
```

View File

@ -0,0 +1,42 @@
---
layout: docs
page_title: 'Commands: tls ca info'
description: |
This command displays relevant information that is contained within a
Certificate Authority certificate.
---
# Command: nomad tls ca info
Info is used to display relevant information that is contained within the
provided certificate file.
## Usage
```plaintext
nomad tls ca info <CA file>
```
## Example
Display default CA:
```shell-session
$ nomad tls ca info nomad-agent-ca.pem
Serial Number 314623649437549144006237783956683542664
Issuer CN Nomad Agent CA 314623649437549144006237783956683542664
Common Name CN=Nomad Agent CA 314623649437549144006237783956683542664,O=HashiCorp Inc.,POSTALCODE=94105,STREET=101 Second Street,L=San Francisco,ST=CA,C=US
Expiry Date 2027-11-13 21:37:38 +0000 UTC
Permitted DNS Domains []
```
Display CA with a custom domain "foo.com":
```shell-session
$ nomad tls ca info foo.com-agent-ca.pem
Serial Number 189027561135335847320487296530900061259
Issuer CN Nomad Agent CA 189027561135335847320487296530900061259
Common Name CN=Nomad Agent CA 189027561135335847320487296530900061259,O=HashiCorp Inc.,POSTALCODE=94105,STREET=101 Second Street,L=San Francisco,ST=CA,C=US
Expiry Date 2027-11-13 21:38:15 +0000 UTC
Permitted DNS Domains [foo.com localhost]
```

View File

@ -0,0 +1,86 @@
---
layout: docs
page_title: 'Commands: TLS Cert Create'
description: |
This command creates a Certificate that can be used for Nomad TLS setup.
---
# Command: nomad tls cert create
The `tls cert create` command is used to create certificates to be used for
[TLS encryption][] for your Nomad cluster. You can then copy these to your
servers and clients. This command will not automatically update the
configuration of the agents.
## Usage
Usage: `nomad tls cert create [options]`
#### Command Options
- `-additional-dnsname=<string>`: Provide an additional dnsname for Subject
Alternative Names. `localhost` is always included. This flag may be provided
multiple times.
- `-additional-ipaddress=<string>`: Provide an additional ipaddress for Subject
Alternative Names. `127.0.0.1` is always included. This flag may be provided
multiple times.
- `-ca=<string>`: Provide path to the ca. Defaults to `#DOMAIN#-agent-ca.pem`.
- `-cli`: Generate cli certificate.
- `-client`: Generate client certificate.
- `-days=<int>`: Provide number of days the certificate is valid for from now
on. Defaults to 1 year.
- `-dc=<string>`: Provide the datacenter. Matters only for `-server`
certificates. Defaults to `dc1`.
- `-domain=<string>`: Provide the domain. Matters only for `-server`
certificates.
- `-key=<string>`: Provide path to the key. Defaults to
`#DOMAIN#-agent-ca-key.pem`.
- `-node=<string>`: When generating a server cert and this server is set an
additional DNS name is included of the form
`<node>.server.<datacenter>.<domain>`.
- `-server`: Generate server certificate.
## Examples
Create a certificate for servers:
```shell-session
$ nomad tls cert create -server
==> WARNING: Server Certificates grants authority to become a
server and access all state in the cluster including root keys
and all ACL tokens. Do not distribute them to production hosts
that are not server nodes. Store them as securely as CA keys.
==> Using CA file nomad-agent-ca.pem and CA key nomad-agent-ca-key.pem
==> Server Certificate saved to global-server-nomad.pem
==> Server Certificate key saved to global-server-nomad-key.pem
```
Create a certificate for clients:
```shell-session
$ nomad tls cert create -client
==> Using CA file nomad-agent-ca.pem and CA key nomad-agent-ca-key.pem
==> Client Certificate saved to global-client-nomad.pem
==> Client Certificate key saved to global-client-nomad-key.pem
```
Create a certificate for the CLI:
```shell-session
$ nomad tls cert create -cli
==> Using CA file nomad-agent-ca.pem and CA key nomad-agent-ca-key.pem
==> Cli Certificate saved to global-cli-nomad.pem
==> Cli Certificate key saved to global-cli-nomad-key.pem
```
[TLS encryption]: https://learn.hashicorp.com/tutorials/nomad/security-enable-tls?in=nomad/transport-security

View File

@ -0,0 +1,32 @@
---
layout: docs
page_title: 'Commands: TLS Cert Info'
description: |
This command displays relevant information that is contained within a
certificate.
---
# Command: nomad tls cert info
Info is used to display relevant information that is contained within a provided
certificate file.
## Usage
```plaintext
nomad tls cert info <certificate file>
```
## Examples
Display default certificate info:
```shell-session
$ nomad tls cert info global-cli-nomad.pem
Serial Number 307777061759235334129808343588809897525
Issuer CN Nomad Agent CA 314623649437549144006237783956683542664
Common Name CN=cli.global.nomad
Expiry Date 2023-11-14 21:40:45 +0000 UTC
DNS Names [cli.global.nomad localhost]
IP Addresses []
```

View File

@ -0,0 +1,28 @@
---
layout: docs
page_title: 'Commands: tls'
description: |
The tls command is used to help with creating a Certificate Authority
and up self signed certificates for Nomad TLS configuration.
---
# Command: tls
The `tls` command is used to help with setting up a self signed CA and certificates for Nomad TLS.
## Usage
Usage: `nomad tls <subcommand> <subcommand> [options]`
Run `nomad tls <subcommand> -h` for help on that subcommand. The following
subcommands are available:
- [`ca create`][cacreate] - Create Certificate Authority
- [`ca info`][cainfo] - Display information from a CA certificate
- [`cert create`][certcreate] - Create self signed certificates
- [`cert info`][certinfo] - Display information from a certificate
[cacreate]: /docs/commands/tls/ca-create 'Create Certificate Authority'
[cainfo]: /docs/commands/tls/ca-info 'Display information from a CA certificate'
[certcreate]: /docs/commands/tls/cert-create 'Create self signed certificates'
[certinfo]: /docs/commands/tls/cert-info 'Display information from a certificate'

View File

@ -904,6 +904,31 @@
}
]
},
{
"title": "tls",
"routes": [
{
"title": "Overview",
"path": "commands/tls"
},
{
"title": "ca create",
"path": "commands/tls/ca-create"
},
{
"title": "ca info",
"path": "commands/tls/ca-info"
},
{
"title": "cert create",
"path": "commands/tls/cert-create"
},
{
"title": "cert info",
"path": "commands/tls/cert-info"
}
]
},
{
"title": "ui",
"path": "commands/ui"