Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use docker-credential-helper to manage secrets #869

Merged
merged 1 commit into from
May 14, 2020
Merged
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
224 changes: 224 additions & 0 deletions cfg/secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package cfg

import (
"errors"
"fmt"
"runtime"

"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/olebedev/config"
"github.com/wtfutil/wtf/logger"
)

type SecretLoadParams struct {
name string
globalConfig *config.Config
service string

secret *string
}

// Load module secrets.
//
// The credential helpers impose this structure:
//
// SERVICE is mapped to a SECRET and USERNAME
//
// Only SECRET is secret, SERVICE and USERNAME are not, so this
// API doesn't expose USERNAME.
//
// SERVICE was intended to be the URL of an API server, but
// for hosted services that do not have or need a configurable
// API server, its easier to just use the module name as the
// SERVICE:
//
// cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load()
//
// The user will use the module name as the service, and the API key as
// the secret, for example:
//
// % wtfutil save-secret circleci
// Secret: ...
//
// If a module (such as pihole, jenkins, or github) might have multiple
// instantiations each using a different API service (with its own unique
// API key), then the module should use the API URL to lookup the secret.
// For example, for github:
//
// cfg.ModuleSecret(name, globalConfig, &settings.apiKey).
// Service(settings.baseURL).
// Load()
//
// The user will use the API URL as the service, and the API key as the
// secret, for example, with github configured as:
//
// -- config.yml
// mods:
// github:
// baseURL: "https://github.mycompany.com/api/v3"
// ...
//
// the secret must be saved as:
//
// % wtfutil save-secret https://github.mycompany.com/api/v3
// Secret: ...
//
// If baseURL is not set in the configuration it will be the modules
// default, and the SERVICE will default to the module name, "github",
// and the user must save the secret as:
//
// % wtfutil save-secret github
// Secret: ...
//
// Ideally, the individual module documentation would describe the
// SERVICE name to use to save the secret.
func ModuleSecret(name string, globalConfig *config.Config, secret *string) *SecretLoadParams {
return &SecretLoadParams{
name: name,
globalConfig: globalConfig,
secret: secret,
service: name, // Default the service to the module name
}
}

func (slp *SecretLoadParams) Service(service string) *SecretLoadParams {
if service != "" {
slp.service = service
}
return slp
}

func (slp *SecretLoadParams) Load() {
configureSecret(
slp.globalConfig,
slp.service,
slp.secret,
)
}

type Secret struct {
Service string
Secret string
Username string
Store string
}

func configureSecret(
globalConfig *config.Config,
service string,
secret *string,
) {
if service == "" {
return
}

if secret == nil {
return
}

// Don't overwrite the secret if it was configured with yaml
if *secret != "" {
return
}

cred, err := FetchSecret(globalConfig, service)

if err != nil {
logger.Log(fmt.Sprintf("Loading secret failed: %s", err.Error()))
return
}

if cred == nil {
// No secret store configued.
return
}

if secret != nil && *secret == "" {
*secret = cred.Secret
}
}

// Fetch secret for `service`. Service is customarily a URL, but can be any
// identifier uniquely used by wtf to identify the service, such as the name
// of the module. nil is returned if the secretStore global property is not
// present or the secret is not found in that store.
func FetchSecret(globalConfig *config.Config, service string) (*Secret, error) {
prog := newProgram(globalConfig)

if prog == nil {
// No secret store configured.
return nil, nil
}

cred, err := client.Get(prog.runner, service)

if err != nil {
return nil, fmt.Errorf("get %v from %v: %w", service, prog.store, err)
}

return &Secret{
Service: cred.ServerURL,
Secret: cred.Secret,
Username: cred.Username,
Store: prog.store,
}, nil
}

func StoreSecret(globalConfig *config.Config, secret *Secret) error {
prog := newProgram(globalConfig)

if prog == nil {
return errors.New("cannot store secrets: wtf.secretStore is not configured")
}

cred := &credentials.Credentials{
ServerURL: secret.Service,
Username: secret.Username,
Secret: secret.Secret,
}

// docker-credential requires a username, but it isn't necessary for
// all services. Use a default if a username was not set.
if cred.Username == "" {
cred.Username = "default"
}

err := client.Store(prog.runner, cred)

if err != nil {
return fmt.Errorf("store %v: %w", prog.store, err)
}

return nil
}

type program struct {
store string
runner client.ProgramFunc
}

func newProgram(globalConfig *config.Config) *program {
secretStore := globalConfig.UString("wtf.secretStore", "(none)")

if secretStore == "(none)" {
return nil
}

if secretStore == "" {
switch runtime.GOOS {
case "windows":
secretStore = "winrt"
case "darwin":
secretStore = "osxkeychain"
default:
secretStore = "secretservice"
}

}

return &program{
secretStore,
client.NewShellProgramFunc("docker-credential-" + secretStore),
}
}
71 changes: 71 additions & 0 deletions flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/chzyer/readline"
goFlags "github.com/jessevdk/go-flags"
"github.com/olebedev/config"
"github.com/wtfutil/wtf/cfg"
Expand All @@ -17,10 +19,26 @@ type Flags struct {
Module string `short:"m" long:"module" optional:"yes" description:"Display info about a specific module, i.e.: 'wtfutil -m=todo'"`
Profile bool `short:"p" long:"profile" optional:"yes" description:"Profile application memory usage"`
Version bool `short:"v" long:"version" description:"Show version info"`
// Work-around go-flags misfeatures. If any sub-command is defined
// then `wtf` (no sub-commands, the common usage), is warned about.
Opt struct {
Cmd string `positional-arg-name:"command"`
Args []string `positional-arg-name:"args"`
} `positional-args:"yes"`

hasCustom bool
}

var EXTRA = `
Commands:
save-secret <service>
service Service URL or module name of secret.
Save a secret into the secret store. The secret will be prompted for.
Requires wtf.secretStore to be configured. See individual modules for
information on what service and secret means for their configuration,
not all modules use secrets.
`

// NewFlags creates an instance of Flags
func NewFlags() *Flags {
flags := Flags{}
Expand All @@ -46,6 +64,58 @@ func (flags *Flags) RenderIf(version, date string, config *config.Config) {
fmt.Printf("%s (%s)\n", version, date)
os.Exit(0)
}

if flags.Opt.Cmd == "" {
return
}

switch cmd := flags.Opt.Cmd; cmd {
case "save-secret":
var service, secret string
args := flags.Opt.Args

if len(args) < 1 || args[0] == "" {
fmt.Fprintf(os.Stderr, "save-secret: service required, see `%s --help`\n", os.Args[0])
os.Exit(1)
}

service = args[0]

if len(args) > 1 {
fmt.Fprintf(os.Stderr, "save-secret: too many arguments, see `%s --help`\n", os.Args[0])
os.Exit(1)
}

b, err := readline.Password("Secret: ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
secret = string(b)
secret = strings.TrimSpace(secret)

if secret == "" {
fmt.Fprintf(os.Stderr, "save-secret: secret required, see `%s --help`\n", os.Args[0])
os.Exit(1)
}

err = cfg.StoreSecret(config, &cfg.Secret{
Service: service,
Secret: secret,
Username: "default",
})

if err != nil {
fmt.Fprintf(os.Stderr, "Saving secret for service %q: %s\n", service, err.Error())
os.Exit(1)
}

fmt.Printf("Saved secret for service %q\n", service)
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "Command `%s` is not supported, try `%s --help`\n", cmd, os.Args[0])
os.Exit(1)
}
}

// HasCustomConfig returns TRUE if a config path was passed in, FALSE if one was not
Expand All @@ -68,6 +138,7 @@ func (flags *Flags) Parse() {
parser := goFlags.NewParser(flags, goFlags.Default)
if _, err := parser.Parse(); err != nil {
if flagsErr, ok := err.(*goFlags.Error); ok && flagsErr.Type == goFlags.ErrHelp {
fmt.Println(EXTRA)
os.Exit(0)
}
}
Expand Down
17 changes: 14 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ require (
github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8
github.com/briandowns/openweathermap v0.0.0-20180804155945-5f41b7c9d92d
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/digitalocean/godo v1.35.1
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/docker v1.13.1
github.com/docker/docker-credential-helpers v0.6.3
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.0
Expand All @@ -34,21 +36,29 @@ require (
github.com/hekmon/transmissionrpc v0.0.0-20190525133028-1d589625bacd
github.com/imdario/mergo v0.3.8 // indirect
github.com/jessevdk/go-flags v1.4.0
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.2.0 // indirect
github.com/logrusorgru/aurora v0.0.0-20190803045625-94edacc10f9b
github.com/mattn/go-colorable v0.1.4 // indirect
github.com/microsoft/azure-devops-go-api/azuredevops v0.0.0-20191014190507-26902c1d4325
github.com/mmcdole/gofeed v1.0.0-beta2
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect
github.com/nicklaw5/helix v0.5.8
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4
github.com/olekukonko/tablewriter v0.0.4
github.com/onsi/ginkgo v1.10.3 // indirect
github.com/onsi/gomega v1.7.1 // indirect
github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect
github.com/ovh/cds v0.0.0-20200131143542-5e69464c6598
github.com/pborman/uuid v1.2.0 // indirect
github.com/pkg/errors v0.9.1
github.com/pkg/profile v1.4.0
github.com/radovskyb/watcher v1.0.7
github.com/rivo/tview v0.0.0-20200108161608-1316ea7a4b35
github.com/shirou/gopsutil v2.20.4+incompatible
github.com/spf13/cobra v0.0.5 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.6.1 // indirect
github.com/stretchr/testify v1.5.1
github.com/wtfutil/spotigopher v0.0.0-20191127141047-7d8168fe103a
github.com/wtfutil/todoist v0.0.2-0.20191216004217-0ec29ceda61a
Expand All @@ -57,8 +67,9 @@ require (
github.com/zorkian/go-datadog-api v2.29.0+incompatible
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/text v0.3.2
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5 // indirect
google.golang.org/api v0.23.0
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/jarcoal/httpmock.v1 v1.0.0-20181110093347-3be5f16b70eb // indirect
gopkg.in/yaml.v2 v2.2.8
gotest.tools v2.2.0+incompatible
Expand Down
Loading