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
452 changes: 376 additions & 76 deletions cmd/cloudstic/cmd_store.go

Large diffs are not rendered by default.

635 changes: 634 additions & 1 deletion cmd/cloudstic/cmd_store_test.go

Large diffs are not rendered by default.

61 changes: 37 additions & 24 deletions cmd/cloudstic/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,17 +170,19 @@ _cloudstic() {
*) store_sub="${words[j]}"; break ;;
esac
done
if [[ -z "$store_sub" ]]; then
COMPREPLY=($(compgen -W "list show new" -- "$cur"))
return
fi
case "$store_sub" in
list)
cmd_flags="-profiles-file" ;;
show)
cmd_flags="-profiles-file" ;;
new)
cmd_flags="-profiles-file -name -uri -s3-region -s3-profile -s3-endpoint -s3-access-key -s3-secret-key -s3-access-key-env -s3-secret-key-env -s3-profile-env -store-sftp-password -store-sftp-key -store-sftp-password-env -store-sftp-key-env -password-env -encryption-key-env -recovery-key-env -kms-key-arn -kms-region -kms-endpoint" ;;
if [[ -z "$store_sub" ]]; then
COMPREPLY=($(compgen -W "list show new verify" -- "$cur"))
return
fi
case "$store_sub" in
list)
cmd_flags="-profiles-file" ;;
show)
cmd_flags="-profiles-file" ;;
verify)
cmd_flags="-profiles-file" ;;
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" ;;
*)
cmd_flags="" ;;
esac
Expand Down Expand Up @@ -419,11 +421,12 @@ _cloudstic() {
;;
store)
local -a store_commands
store_commands=(
'list:List configured stores'
'show:Show one store and its configuration'
'new:Create or update a store entry'
)
store_commands=(
'list:List configured stores'
'show:Show one store and its configuration'
'new:Create or update a store entry'
'verify:Verify store credentials and connectivity'
)
local store_sub
local -i si=$((i+1))
while (( si < CURRENT )); do
Expand All @@ -437,14 +440,17 @@ _cloudstic() {
_describe -t store-commands 'store subcommand' store_commands
return
fi
case "$store_sub" in
list)
_arguments '-profiles-file[Path to profiles YAML file]:path:_files'
;;
show)
_arguments '-profiles-file[Path to profiles YAML file]:path:_files' ':store name:'
;;
new)
case "$store_sub" in
list)
_arguments '-profiles-file[Path to profiles YAML file]:path:_files'
;;
show)
_arguments '-profiles-file[Path to profiles YAML file]:path:_files' ':store name:'
;;
verify)
_arguments '-profiles-file[Path to profiles YAML file]:path:_files' ':store name:'
;;
new)
_arguments \
'-profiles-file[Path to profiles YAML file]:path:_files' \
'-name[Store reference name]:name:' \
Expand All @@ -454,13 +460,20 @@ _cloudstic() {
'-s3-endpoint[S3-compatible endpoint URL]:url:' \
'-s3-access-key[S3 static access key]:key:' \
'-s3-secret-key[S3 static secret key]:key:' \
'-s3-access-key-secret[Secret ref for S3 access key]:ref:' \
'-s3-secret-key-secret[Secret ref for S3 secret key]:ref:' \
'-s3-access-key-env[Env var for S3 access key]:var:' \
'-s3-secret-key-env[Env var for S3 secret key]:var:' \
'-s3-profile-env[Env var for AWS profile]:var:' \
'-store-sftp-password[SFTP password]:password:' \
'-store-sftp-key[SFTP private key path]:path:_files' \
'-store-sftp-password-secret[Secret ref for SFTP password]:ref:' \
'-store-sftp-key-secret[Secret ref for SFTP key path]:ref:' \
'-store-sftp-password-env[Env var for SFTP password]:var:' \
'-store-sftp-key-env[Env var for SFTP key path]:var:' \
'-password-secret[Secret ref for repository password]:ref:' \
'-encryption-key-secret[Secret ref for platform key]:ref:' \
'-recovery-key-secret[Secret ref for recovery key mnemonic]:ref:' \
'-password-env[Env var for repository password]:var:' \
'-encryption-key-env[Env var for platform key]:var:' \
'-recovery-key-env[Env var for recovery key mnemonic]:var:' \
Expand Down
25 changes: 21 additions & 4 deletions cmd/cloudstic/interactive.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package main

import (
"bufio"
"fmt"
"os"
"strconv"
"strings"

"github.com/moby/term"
xterm "golang.org/x/term"
)

func (r *runner) canPrompt() bool {
return !r.noPrompt && term.IsTerminal(os.Stdin.Fd()) && term.IsTerminal(os.Stdout.Fd())
stdin := r.stdin
if stdin == nil {
stdin = os.Stdin
}
return !r.noPrompt && term.IsTerminal(stdin.Fd()) && term.IsTerminal(os.Stdout.Fd())
}

func (r *runner) promptLine(label, defaultValue string) (string, error) {
Expand All @@ -20,8 +24,7 @@ func (r *runner) promptLine(label, defaultValue string) (string, error) {
} else {
_, _ = fmt.Fprintf(r.errOut, "%s: ", label)
}
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
line, err := r.lineReader().ReadString('\n')
if err != nil {
return "", err
}
Expand Down Expand Up @@ -68,3 +71,17 @@ func (r *runner) promptSelect(label string, options []string) (string, error) {
return options[n-1], nil
}
}

func (r *runner) promptSecret(label string) (string, error) {
_, _ = fmt.Fprintf(r.errOut, "%s: ", label)
stdin := r.stdin
if stdin == nil {
stdin = os.Stdin
}
b, err := xterm.ReadPassword(int(stdin.Fd()))
_, _ = fmt.Fprintln(r.errOut)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
Comment thread
rmanibus marked this conversation as resolved.
}
14 changes: 14 additions & 0 deletions cmd/cloudstic/runner.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bufio"
"fmt"
"io"
"os"
Expand All @@ -11,16 +12,29 @@ type runner struct {
errOut io.Writer
client cloudsticClient
noPrompt bool
stdin *os.File
lineIn *bufio.Reader
}

func newRunner() *runner {
return &runner{
out: os.Stdout,
errOut: os.Stderr,
noPrompt: hasGlobalFlag("no-prompt"),
stdin: os.Stdin,
}
}

func (r *runner) lineReader() *bufio.Reader {
if r.stdin == nil {
r.stdin = os.Stdin
}
if r.lineIn == nil {
r.lineIn = bufio.NewReader(r.stdin)
}
return r.lineIn
}

// hasGlobalFlag checks whether a boolean flag appears anywhere in os.Args.
// This is used for flags that must be parsed before subcommand flag sets.
func hasGlobalFlag(name string) bool {
Expand Down
35 changes: 35 additions & 0 deletions cmd/cloudstic/secret_store_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//go:build darwin

package main

import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)

var execCommandContext = exec.CommandContext

func saveSecretToNativeStore(ctx context.Context, service, account, value string) error {
cmd := execCommandContext(ctx, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", value)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("save secret in macOS keychain failed: %s", msg)
}
return nil
}

func nativeSecretExists(ctx context.Context, service, account string) (bool, error) {
cmd := execCommandContext(ctx, "security", "find-generic-password", "-s", service, "-a", account, "-w")
if err := cmd.Run(); err != nil {
return false, nil
}
Comment thread
rmanibus marked this conversation as resolved.
return true, nil
Comment thread
rmanibus marked this conversation as resolved.
}
101 changes: 101 additions & 0 deletions cmd/cloudstic/secret_store_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//go:build darwin

package main

import (
"context"
"os/exec"
"reflect"
"strings"
"testing"
)

func TestSaveSecretToNativeStore_Success(t *testing.T) {
orig := execCommandContext
defer func() { execCommandContext = orig }()

var gotName string
var gotArgs []string
execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
gotName = name
gotArgs = append([]string{}, args...)
return exec.CommandContext(ctx, "sh", "-c", "exit 0")
}

err := saveSecretToNativeStore(context.Background(), "cloudstic/store/prod", "password", "super-secret")
if err != nil {
t.Fatalf("saveSecretToNativeStore: %v", err)
}
if gotName != "security" {
t.Fatalf("command name=%q want security", gotName)
}
wantArgs := []string{"add-generic-password", "-U", "-s", "cloudstic/store/prod", "-a", "password", "-w", "super-secret"}
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("command args=%v want=%v", gotArgs, wantArgs)
}
}

func TestSaveSecretToNativeStore_Failure(t *testing.T) {
orig := execCommandContext
defer func() { execCommandContext = orig }()

execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "echo keychain failed 1>&2; exit 1")
}

err := saveSecretToNativeStore(context.Background(), "svc", "acct", "secret")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "save secret in macOS keychain failed") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(err.Error(), "keychain failed") {
t.Fatalf("expected stderr in error: %v", err)
}
}

func TestNativeSecretExists_Success(t *testing.T) {
orig := execCommandContext
defer func() { execCommandContext = orig }()

var gotName string
var gotArgs []string
execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
gotName = name
gotArgs = append([]string{}, args...)
return exec.CommandContext(ctx, "sh", "-c", "exit 0")
}

exists, err := nativeSecretExists(context.Background(), "cloudstic/store/prod", "password")
if err != nil {
t.Fatalf("nativeSecretExists: %v", err)
}
if !exists {
t.Fatal("expected exists=true")
}
if gotName != "security" {
t.Fatalf("command name=%q want security", gotName)
}
wantArgs := []string{"find-generic-password", "-s", "cloudstic/store/prod", "-a", "password", "-w"}
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("command args=%v want=%v", gotArgs, wantArgs)
}
}

func TestNativeSecretExists_NotFound(t *testing.T) {
orig := execCommandContext
defer func() { execCommandContext = orig }()

execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "exit 1")
}

exists, err := nativeSecretExists(context.Background(), "svc", "acct")
if err != nil {
t.Fatalf("nativeSecretExists: %v", err)
}
if exists {
t.Fatal("expected exists=false")
}
}
16 changes: 16 additions & 0 deletions cmd/cloudstic/secret_store_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !darwin

package main

import (
"context"
"errors"
)

func saveSecretToNativeStore(_ context.Context, _, _, _ string) error {
return errors.New("native secret write not supported on this platform")
}

func nativeSecretExists(_ context.Context, _, _ string) (bool, error) {
return false, nil
}
16 changes: 15 additions & 1 deletion cmd/cloudstic/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func printUsage() {
{"store new", "Create or update a store entry in profiles.yaml"},
{"store list", "List configured stores"},
{"store show", "Show one store and its configuration"},
{"store verify", "Verify one store's credentials and connectivity"},
{"profile new", "Create or update a backup profile in profiles.yaml"},
{"profile list", "List stores, auth entries, and backup profiles"},
{"profile show", "Show one profile and resolved store/auth references"},
Expand Down Expand Up @@ -158,6 +159,11 @@ func printUsage() {
t.Note(" Show one store and its configuration.")
t.Blank()

t.Command("store verify", "<name>")
t.Flags([][2]string{{"-profiles-file <path>", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}})
t.Note(" Resolve store credentials and verify connectivity.")
t.Blank()

t.Command("store new", "")
t.Flags([][2]string{
{"-name <name>", "Store reference name"},
Expand All @@ -167,13 +173,20 @@ func printUsage() {
{"-s3-endpoint <url>", "S3-compatible endpoint URL"},
{"-s3-access-key <key>", "S3 static access key"},
{"-s3-secret-key <key>", "S3 static secret key"},
{"-s3-access-key-secret <ref>", "Secret reference for S3 access key (env://, keychain://, wincred://, secret-service://)"},
{"-s3-secret-key-secret <ref>", "Secret reference for S3 secret key (env://, keychain://, wincred://, secret-service://)"},
{"-s3-access-key-env <var>", "Env var name for S3 access key"},
{"-s3-secret-key-env <var>", "Env var name for S3 secret key"},
{"-s3-profile-env <var>", "Env var name for AWS profile"},
{"-store-sftp-password <pass>", "SFTP password"},
{"-store-sftp-key <path>", "Path to SFTP private key"},
{"-store-sftp-password-secret <ref>", "Secret reference for SFTP password (env://, keychain://, wincred://, secret-service://)"},
{"-store-sftp-key-secret <ref>", "Secret reference for SFTP key path (env://, keychain://, wincred://, secret-service://)"},
{"-store-sftp-password-env <var>", "Env var name for SFTP password"},
{"-store-sftp-key-env <var>", "Env var name for SFTP key path"},
{"-password-secret <ref>", "Secret reference for repository password (env://, keychain://, wincred://, secret-service://)"},
{"-encryption-key-secret <ref>", "Secret reference for platform key (env://, keychain://, wincred://, secret-service://)"},
{"-recovery-key-secret <ref>", "Secret reference for recovery key mnemonic (env://, keychain://, wincred://, secret-service://)"},
Comment thread
rmanibus marked this conversation as resolved.
{"-password-env <var>", "Env var name for repository password"},
{"-encryption-key-env <var>", "Env var name for platform key (hex)"},
{"-recovery-key-env <var>", "Env var name for recovery key mnemonic"},
Expand All @@ -183,7 +196,8 @@ func printUsage() {
{"-profiles-file <path>", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")},
})
t.Note(" Create or update a store entry in profiles.yaml.",
" Encryption credentials use env var indirection: -password-env, -encryption-key-env.",
" Prefer secret refs: -password-secret / -encryption-key-secret / -recovery-key-secret.",
" Legacy -*-env flags are still supported and auto-converted to env:// refs on write.",
Comment thread
rmanibus marked this conversation as resolved.
" KMS settings are stored directly (ARN is not a secret).",
)
t.Blank()
Expand Down
Loading
Loading