Skip to content
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
7 changes: 7 additions & 0 deletions cmd/cloudstic/cmd_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,13 @@ func (r *runner) runProfileNew() int {
if !storeHasExplicitEncryption(s) {
r.promptEncryptionConfig(cfg, a.storeRef, a.profilesFile)
}
if err := r.checkOrInitStore(cfg, a.storeRef, a.profilesFile, checkOrInitOptions{
allowMissingSecrets: true,
warnOnMissingSecrets: true,
offerInit: true,
}); err != nil {
_, _ = fmt.Fprintf(r.errOut, "%v\n", err)
}
}

requiredProvider := profileProviderFromSource(a.source)
Expand Down
84 changes: 74 additions & 10 deletions cmd/cloudstic/cmd_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (r *runner) runStore() int {
if len(os.Args) < 3 {
_, _ = fmt.Fprintln(r.errOut, "Usage: cloudstic store <subcommand> [options]")
_, _ = fmt.Fprintln(r.errOut, "")
_, _ = fmt.Fprintln(r.errOut, "Available subcommands: list, show, new, verify")
_, _ = fmt.Fprintln(r.errOut, "Available subcommands: list, show, new, verify, init")
return 1
}

Expand All @@ -34,6 +34,8 @@ func (r *runner) runStore() int {
return r.runStoreNew()
case "verify":
return r.runStoreVerify()
case "init":
return r.runStoreInit()
default:
return r.fail("Unknown store subcommand: %s", os.Args[2])
}
Expand Down Expand Up @@ -315,7 +317,11 @@ func (r *runner) runStoreNew() int {
if forcePromptEncryption || !storeHasExplicitEncryption(s) {
r.promptEncryptionConfig(cfg, *name, *profilesFile)
}
if err := r.checkOrInitStore(cfg, *name, *profilesFile, true, !existedBefore, true); err != nil {
if err := r.checkOrInitStore(cfg, *name, *profilesFile, checkOrInitOptions{
allowMissingSecrets: true,
warnOnMissingSecrets: !existedBefore,
offerInit: true,
}); err != nil {
_, _ = fmt.Fprintf(r.errOut, "%v\n", err)
}
}
Expand Down Expand Up @@ -364,18 +370,74 @@ func (r *runner) runStoreVerify() int {
if _, ok := cfg.Stores[name]; !ok {
return r.fail("Unknown store %q", name)
}
if err := r.checkOrInitStore(cfg, name, *profilesFile, false, true, false); err != nil {
if err := r.checkOrInitStore(cfg, name, *profilesFile, checkOrInitOptions{
warnOnMissingSecrets: true,
}); err != nil {
return r.fail("%v", err)
}
return 0
}

func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, profilesFile string, allowMissingSecrets, warnOnMissingSecrets, offerInit bool) error {
func (r *runner) runStoreInit() int {
fs := flag.NewFlagSet("store init", flag.ExitOnError)
profilesFile := fs.String("profiles-file", envDefault("CLOUDSTIC_PROFILES_FILE", defaultProfilesPathFallback()), "Path to profiles YAML file")
yes := fs.Bool("yes", false, "Initialize without confirmation prompt")
_ = fs.Parse(reorderArgs(fs, os.Args[3:]))
if fs.NArg() > 1 {
return r.fail("usage: cloudstic store init [-profiles-file <path>] [-yes] <name>")
}

name := ""
if fs.NArg() == 1 {
name = fs.Arg(0)
}

cfg, err := cloudstic.LoadProfilesFile(*profilesFile)
if err != nil {
return r.fail("Failed to load profiles: %v", err)
}
if len(cfg.Stores) == 0 {
return r.fail("No stores configured")
}

if name == "" {
if !r.canPrompt() {
return r.fail("usage: cloudstic store init [-profiles-file <path>] [-yes] <name>")
}
names := sortedKeys(cfg.Stores)
picked, pickErr := r.promptSelect("Select store", names)
if pickErr != nil {
return r.fail("Failed to select store: %v", pickErr)
}
name = picked
}

if _, ok := cfg.Stores[name]; !ok {
return r.fail("Unknown store %q", name)
}
if err := r.checkOrInitStore(cfg, name, *profilesFile, checkOrInitOptions{
warnOnMissingSecrets: true,
offerInit: true,
assumeYes: *yes,
}); err != nil {
return r.fail("%v", err)
}
return 0
}

type checkOrInitOptions struct {
allowMissingSecrets bool
warnOnMissingSecrets bool
offerInit bool
assumeYes bool
}

func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, profilesFile string, opts checkOrInitOptions) error {
s := cfg.Stores[storeName]
g, err := globalFlagsFromProfileStore(s)
if err != nil {
if allowMissingSecrets && isSecretNotFoundError(err) {
if warnOnMissingSecrets {
if opts.allowMissingSecrets && isSecretNotFoundError(err) {
if opts.warnOnMissingSecrets {
_, _ = fmt.Fprintf(r.errOut, "Store credentials are configured but not currently available: %v\n", err)
_, _ = fmt.Fprintf(r.errOut, "Set required secrets and run: cloudstic store verify %s\n", storeName)
}
Expand Down Expand Up @@ -409,12 +471,14 @@ func (r *runner) checkOrInitStore(cfg *cloudstic.ProfilesConfig, storeName, prof
}

_, _ = fmt.Fprintln(r.out, "Store is accessible but not yet initialized.")
if !offerInit {
if !opts.offerInit {
return nil
}
yes, promptErr := r.promptConfirm("Initialize it now?", true)
if promptErr != nil || !yes {
return nil
if !opts.assumeYes {
yes, promptErr := r.promptConfirm("Initialize it now?", true)
if promptErr != nil || !yes {
return nil
}
}

// Check if the store has encryption config.
Expand Down
10 changes: 5 additions & 5 deletions cmd/cloudstic/cmd_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ func TestCheckOrInitStore_AlreadyInitialized(t *testing.T) {
var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut}
if err := r.checkOrInitStore(cfg, "test", profilesPath, false, true, true); err != nil {
if err := r.checkOrInitStore(cfg, "test", profilesPath, checkOrInitOptions{warnOnMissingSecrets: true, offerInit: true}); err != nil {
t.Fatalf("checkOrInitStore: %v", err)
}

Expand Down Expand Up @@ -343,7 +343,7 @@ func TestCheckOrInitStore_InitializedEncrypted_ValidCredentials(t *testing.T) {
var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut}
if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", false, true, true); err != nil {
if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", checkOrInitOptions{warnOnMissingSecrets: true, offerInit: true}); err != nil {
t.Fatalf("checkOrInitStore: %v", err)
}
if !strings.Contains(out.String(), "Repository is encrypted; verifying configured credentials") {
Expand Down Expand Up @@ -381,7 +381,7 @@ func TestCheckOrInitStore_InitializedEncrypted_InvalidCredentials(t *testing.T)
}}

r := &runner{out: &strings.Builder{}, errOut: &strings.Builder{}}
err = r.checkOrInitStore(cfg, "test", "profiles.yaml", false, true, true)
err = r.checkOrInitStore(cfg, "test", "profiles.yaml", checkOrInitOptions{warnOnMissingSecrets: true, offerInit: true})
if err == nil {
t.Fatal("expected error")
}
Expand Down Expand Up @@ -930,7 +930,7 @@ func TestCheckOrInitStore_MissingSecretAllowed(t *testing.T) {
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut}

if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", true, true, true); err != nil {
if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", checkOrInitOptions{allowMissingSecrets: true, warnOnMissingSecrets: true, offerInit: true}); err != nil {
t.Fatalf("checkOrInitStore: %v", err)
}
if !strings.Contains(errOut.String(), "cloudstic store verify test") {
Expand All @@ -949,7 +949,7 @@ func TestCheckOrInitStore_MissingSecretAllowedSilent(t *testing.T) {
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut}

if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", true, false, true); err != nil {
if err := r.checkOrInitStore(cfg, "test", "profiles.yaml", checkOrInitOptions{allowMissingSecrets: true, offerInit: true}); err != nil {
t.Fatalf("checkOrInitStore: %v", err)
}
if errOut.String() != "" {
Expand Down
21 changes: 20 additions & 1 deletion cmd/cloudstic/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ _cloudstic() {
esac
done
if [[ -z "$store_sub" ]]; then
COMPREPLY=($(compgen -W "list show new verify" -- "$cur"))
COMPREPLY=($(compgen -W "list show new verify init" -- "$cur"))
return
fi
case "$store_sub" in
Expand All @@ -181,6 +181,8 @@ _cloudstic() {
cmd_flags="-profiles-file" ;;
verify)
cmd_flags="-profiles-file" ;;
init)
cmd_flags="-profiles-file -yes" ;;
new)
cmd_flags="-profiles-file -name -uri -s3-region -s3-profile -s3-endpoint -s3-access-key -s3-secret-key -s3-access-key-secret -s3-secret-key-secret -s3-access-key-env -s3-secret-key-env -s3-profile-env -store-sftp-password -store-sftp-key -store-sftp-password-secret -store-sftp-key-secret -store-sftp-password-env -store-sftp-key-env -password-secret -encryption-key-secret -recovery-key-secret -password-env -encryption-key-env -recovery-key-env -kms-key-arn -kms-region -kms-endpoint" ;;
*)
Expand Down Expand Up @@ -426,6 +428,7 @@ _cloudstic() {
'show:Show one store and its configuration'
'new:Create or update a store entry'
'verify:Verify store credentials and connectivity'
'init:Initialize a store by reference'
)
local store_sub
local -i si=$((i+1))
Expand All @@ -450,6 +453,9 @@ _cloudstic() {
verify)
_arguments '-profiles-file[Path to profiles YAML file]:path:_files' ':store name:'
;;
init)
_arguments '-profiles-file[Path to profiles YAML file]:path:_files' '-yes[Initialize without confirmation prompt]' ':store name:'
;;
new)
_arguments \
'-profiles-file[Path to profiles YAML file]:path:_files' \
Expand Down Expand Up @@ -667,6 +673,19 @@ complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_s
complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l onedrive-client-id -x -d 'OneDrive OAuth client ID'
complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l onedrive-token-file -r -F -d 'OneDrive OAuth token file'

# store subcommands
complete -c cloudstic -n '__fish_seen_subcommand_from store; and not __fish_seen_subcommand_from list show new verify init' -a list -d 'List configured stores'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and not __fish_seen_subcommand_from list show new verify init' -a show -d 'Show one store and its configuration'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and not __fish_seen_subcommand_from list show new verify init' -a new -d 'Create or update a store entry'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and not __fish_seen_subcommand_from list show new verify init' -a verify -d 'Verify store credentials and connectivity'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and not __fish_seen_subcommand_from list show new verify init' -a init -d 'Initialize a store by reference'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from list' -l profiles-file -r -F -d 'Path to profiles YAML file'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from show' -l profiles-file -r -F -d 'Path to profiles YAML file'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from new' -l profiles-file -r -F -d 'Path to profiles YAML file'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from verify' -l profiles-file -r -F -d 'Path to profiles YAML file'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from init' -l profiles-file -r -F -d 'Path to profiles YAML file'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from init' -l yes -d 'Initialize without confirmation prompt'

# auth subcommands
complete -c cloudstic -n '__fish_seen_subcommand_from auth; and not __fish_seen_subcommand_from list show new login' -a list -d 'List auth entries from profiles.yaml'
complete -c cloudstic -n '__fish_seen_subcommand_from auth; and not __fish_seen_subcommand_from list show new login' -a show -d 'Show one auth entry'
Expand Down
68 changes: 41 additions & 27 deletions cmd/cloudstic/secret_store_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,61 @@
package main

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"strings"

"github.com/keybase/go-keychain"
)

var execCommandContext = exec.CommandContext
var (
keychainAddItem = keychain.AddItem
keychainUpdateItem = keychain.UpdateItem
keychainGetGenericPassword = keychain.GetGenericPassword
keychainGenericPasswordKind = keychain.SecClassGenericPassword
)

func saveSecretToNativeStore(ctx context.Context, service, account, value string) error {
cmd := execCommandContext(ctx, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w")
cmd.Stdin = strings.NewReader(value + "\n" + value + "\n")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
_ = ctx

item := keychain.NewItem()
item.SetSecClass(keychainGenericPasswordKind)
item.SetService(service)
item.SetAccount(account)
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
item.SetData([]byte(value))

if err := keychainAddItem(item); err != nil {
if err == keychain.ErrorDuplicateItem {
query := keychain.NewItem()
query.SetSecClass(keychainGenericPasswordKind)
query.SetService(service)
query.SetAccount(account)

update := keychain.NewItem()
update.SetData([]byte(value))

if updateErr := keychainUpdateItem(query, update); updateErr != nil {
return fmt.Errorf("save secret in macOS keychain failed: %v", updateErr)
}
return nil
}
return fmt.Errorf("save secret in macOS keychain failed: %s", msg)
return fmt.Errorf("save secret in macOS keychain failed: %v", err)
}
return nil
}

func nativeSecretExists(ctx context.Context, service, account string) (bool, error) {
cmd := execCommandContext(ctx, "security", "find-generic-password", "-s", service, "-a", account)
cmd.Stdout = io.Discard
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 44 {
_ = ctx

data, err := keychainGetGenericPassword(service, account, "", "")
if err != nil {
if err == keychain.ErrorItemNotFound {
return false, nil
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return false, fmt.Errorf("check secret in macOS keychain failed: %s", msg)
return false, fmt.Errorf("check secret in macOS keychain failed: %v", err)
}
if data == nil {
return false, nil
}
return true, nil
}
Loading
Loading