Add document command and its first subcommand

This adds the "airshipctl document" tree of commands, as well as one of
its leaf commands, "generate masterpassphrase".

Change-Id: I2365ebe67b38ebbbe4873c6e1beb6407f0e000eb
This commit is contained in:
Ian Howell 2019-08-15 16:28:55 -05:00
parent 1c999e2095
commit 9e2d6932f6
9 changed files with 330 additions and 8 deletions

20
cmd/document/document.go Normal file
View File

@ -0,0 +1,20 @@
package document
import (
"github.com/spf13/cobra"
"opendev.org/airship/airshipctl/cmd/document/secret"
"opendev.org/airship/airshipctl/pkg/environment"
)
// NewDocumentCommand creates a new command for managing airshipctl documents
func NewDocumentCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command {
documentRootCmd := &cobra.Command{
Use: "document",
Short: "manages deployment documents",
}
documentRootCmd.AddCommand(secret.NewSecretCommand(rootSettings))
return documentRootCmd
}

View File

@ -0,0 +1,20 @@
package generate
import (
"github.com/spf13/cobra"
"opendev.org/airship/airshipctl/pkg/environment"
)
// NewGenerateCommand creates a new command for generating secret information
func NewGenerateCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command {
generateRootCmd := &cobra.Command{
Use: "generate",
// TODO(howell): Make this more expressive
Short: "generates various secrets",
}
generateRootCmd.AddCommand(NewGenerateMasterPassphraseCommand(rootSettings))
return generateRootCmd
}

View File

@ -0,0 +1,26 @@
package generate
import (
"fmt"
"github.com/spf13/cobra"
"opendev.org/airship/airshipctl/pkg/environment"
"opendev.org/airship/airshipctl/pkg/secret"
)
// NewGenerateMasterPassphraseCommand creates a new command for generating secret information
func NewGenerateMasterPassphraseCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command {
masterPassphraseCmd := &cobra.Command{
Use: "masterpassphrase",
// TODO(howell): Make this more expressive
Short: "generates a secure master passphrase",
Run: func(cmd *cobra.Command, args []string) {
engine := secret.NewPassphraseEngine(nil)
masterPassphrase := engine.GeneratePassphrase()
fmt.Fprintln(cmd.OutOrStdout(), masterPassphrase)
},
}
return masterPassphraseCmd
}

View File

@ -0,0 +1,21 @@
package secret
import (
"github.com/spf13/cobra"
"opendev.org/airship/airshipctl/cmd/document/secret/generate"
"opendev.org/airship/airshipctl/pkg/environment"
)
// NewSecretCommand creates a new command for managing airshipctl secrets
func NewSecretCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command {
secretRootCmd := &cobra.Command{
Use: "secret",
// TODO(howell): Make this more expressive
Short: "manages secrets",
}
secretRootCmd.AddCommand(generate.NewGenerateCommand(rootSettings))
return secretRootCmd
}

View File

@ -14,6 +14,7 @@ import (
"opendev.org/airship/airshipctl/cmd/bootstrap"
"opendev.org/airship/airshipctl/cmd/completion"
"opendev.org/airship/airshipctl/cmd/document"
"opendev.org/airship/airshipctl/pkg/environment"
"opendev.org/airship/airshipctl/pkg/log"
)
@ -51,16 +52,10 @@ func AddDefaultAirshipCTLCommands(cmd *cobra.Command, settings *environment.Airs
cmd.AddCommand(argo.NewCommand())
cmd.AddCommand(bootstrap.NewBootstrapCommand(settings))
cmd.AddCommand(completion.NewCompletionCommand())
cmd.AddCommand(document.NewDocumentCommand(settings))
cmd.AddCommand(kubectl.NewDefaultKubectlCommand())
// Should we use cmd.OutOrStdout?
cmd.AddCommand(kubeadm.NewKubeadmCommand(os.Stdin, os.Stdout, os.Stderr))
kustomizeCmd, _, err := cmd.Find([]string{"kubectl", "kustomize"})
if err != nil {
log.Fatalf("Unable to find subcommand '%s'", err.Error())
}
cmd.AddCommand(kustomizeCmd)
return cmd
}

View File

@ -7,10 +7,10 @@ Available Commands:
argo argo is the command line interface to Argo
bootstrap bootstraps airshipctl
completion Generate autocompletions script for the specified shell (bash or zsh)
document manages deployment documents
help Help about any command
kubeadm kubeadm: easily bootstrap a secure Kubernetes cluster
kubectl kubectl controls the Kubernetes cluster manager
kustomize Build a kustomization target from a directory or a remote url.
version Show the version number of airshipctl
Flags:

98
pkg/secret/passphrases.go Normal file
View File

@ -0,0 +1,98 @@
package secret
import (
"math/rand"
"strings"
)
const (
defaultLength = 24
asciiLowers = "abcdefghijklmnopqrstuvwxyz"
asciiUppers = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
asciiNumbers = "0123456789"
asciiSymbols = "@#&-+=?"
)
var (
// pool is the complete collection of characters that can be used for a
// passphrase
pool []byte
)
func init() {
pool = append(pool, []byte(asciiLowers)...)
pool = append(pool, []byte(asciiUppers)...)
pool = append(pool, []byte(asciiNumbers)...)
pool = append(pool, []byte(asciiSymbols)...)
}
// PassphraseEngine is used to generate secure random passphrases
type PassphraseEngine struct {
rng *rand.Rand
pool []byte
}
// NewPassphraseEngine creates an PassphraseEngine using src. If src is nil,
// the returned PassphraseEngine will use the default Source
func NewPassphraseEngine(src rand.Source) *PassphraseEngine {
if src == nil {
src = &Source{}
}
return &PassphraseEngine{
rng: rand.New(src),
pool: pool,
}
}
// GeneratePassphrase returns a secure random string of length 24,
// containing at least one of each from the following sets:
// [0-9]
// [a-z]
// [A-Z]
// [@#&-+=?]
func (e *PassphraseEngine) GeneratePassphrase() string {
return e.GeneratePassphraseN(defaultLength)
}
// GeneratePassphraseN returns a secure random string containing at least
// one of each from the following sets. Its length will be max(length, 24)
// [0-9]
// [a-z]
// [A-Z]
// [@#&-+=?]
func (e *PassphraseEngine) GeneratePassphraseN(length int) string {
if length < defaultLength {
length = defaultLength
}
var passPhrase string
for !e.isValidPassphrase(passPhrase) {
var sb strings.Builder
for i := 0; i < length; i++ {
randIndex := e.rng.Intn(len(e.pool))
randChar := e.pool[randIndex]
sb.WriteString(string(randChar))
}
passPhrase = sb.String()
}
return passPhrase
}
func (e *PassphraseEngine) isValidPassphrase(passPhrase string) bool {
if len(passPhrase) < defaultLength {
return false
}
charSets := []string{
asciiLowers,
asciiUppers,
asciiNumbers,
asciiSymbols,
}
for _, charSet := range charSets {
if !strings.ContainsAny(passPhrase, charSet) {
return false
}
}
return true
}

View File

@ -0,0 +1,109 @@
package secret_test
import (
"math/rand"
"strings"
"testing"
"opendev.org/airship/airshipctl/pkg/secret"
)
const (
asciiLowers = "abcdefghijklmnopqrstuvwxyz"
asciiUppers = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
asciiNumbers = "0123456789"
asciiSymbols = "@#&-+=?"
defaultLength = 24
)
func TestDeterministicGenerateValidPassphrase(t *testing.T) {
testSource := rand.NewSource(42)
engine := secret.NewPassphraseEngine(testSource)
// pre-calculated for rand.NewSource(42)
expectedPassphrases := []string{
"erx&97vfqd7LN3HJ?t@oPhds",
"##Xeuvf5Njy@hNWSaRoleFkf",
"jB=kirg7acIt-=fx1Fb-tZ+7",
"eOS#W8yoAljSThPL2oT&aUZu",
"vlaQqKr-jXSCJfXYnvGik3b1",
"rBKtHZkOmFUM75?c2UWiZjdh",
"9g?QV?w6BCWN2EKAc+dZ-Jun",
"X@IIyqAg7Mz@Wm8eRE6KMEf3",
"7JpQkLd3ufhj4bLB8S=ipjNP",
"XC?bDaHTa3mrBYLMG@#B=Q0B",
}
for i, expected := range expectedPassphrases {
actual := engine.GeneratePassphrase()
if expected != actual {
t.Errorf("Call #%d to engine.GeneratePassphrase() should have returned %s, got %s",
i, expected, actual)
}
}
}
func TestNondeterministicGenerateValidPassphrase(t *testing.T) {
// Due to the nondeterminism of random number generators, this
// functionality is impossible to fully test. Let's just generate
// enough passphrases that we can be confident in the randomness.
// Fortunately, Go is pretty fast, so we can do upward of 100,000 of
// these without slowing down the test too much
engine := secret.NewPassphraseEngine(nil)
for i := 0; i < 100000; i++ {
passphrase := engine.GeneratePassphrase()
if !isValid(passphrase) {
t.Errorf("The engine generated an invalid password: %s", passphrase)
}
}
}
func TestGenerateValidPassphraseN(t *testing.T) {
testSource := rand.NewSource(42)
engine := secret.NewPassphraseEngine(testSource)
tests := []struct {
inputLegth int
expectedLength int
}{
{
inputLegth: 10,
expectedLength: defaultLength,
},
{
inputLegth: -5,
expectedLength: defaultLength,
},
{
inputLegth: 30,
expectedLength: 30,
},
}
for _, tt := range tests {
passphrase := engine.GeneratePassphraseN(tt.inputLegth)
if len(passphrase) != tt.expectedLength {
t.Errorf(`Passphrase "%s" should have length %d, got %d\n`,
passphrase, len(passphrase), tt.expectedLength)
}
}
}
func isValid(passphrase string) bool {
if len(passphrase) < defaultLength {
return false
}
charSets := []string{
asciiLowers,
asciiUppers,
asciiNumbers,
asciiSymbols,
}
for _, charSet := range charSets {
if !strings.ContainsAny(passphrase, charSet) {
return false
}
}
return true
}

33
pkg/secret/rngsource.go Normal file
View File

@ -0,0 +1,33 @@
package secret
import (
crypto "crypto/rand"
"encoding/binary"
"math/rand"
"opendev.org/airship/airshipctl/pkg/log"
)
// Source implements rand.Source
type Source struct{}
var _ rand.Source = &Source{}
// Uint64 returns a secure random uint64 in the range [0, 1<<64]. It will fail
// if an error is returned from the system's secure random numer generator
func (s *Source) Uint64() uint64 {
var value uint64
err := binary.Read(crypto.Reader, binary.BigEndian, &value)
if err != nil {
log.Fatalf("could not generate a random number: %s", err.Error())
}
return value
}
// Int63 returns a secure random int64 in the range [0, 1<<63]
func (s *Source) Int63() int64 {
return int64(s.Uint64() & ^(uint64(1 << 63)))
}
// Seed does nothing, since Source will use the crypto library
func (s *Source) Seed(_ int64) {}