diff --git a/executor.go b/executor.go index 1e7f445..c567519 100644 --- a/executor.go +++ b/executor.go @@ -2,6 +2,7 @@ package ansible import ( "context" + "crypto/rand" "encoding/base64" "errors" "io" @@ -3625,11 +3626,138 @@ func (e *Executor) lookupValue(expr string, host string, task *Task) (any, bool) if value, ok := e.lookupConditionValue(arg, host, task, nil); ok { return value, true } + case "password": + if value, ok := e.lookupPassword(arg); ok { + return value, true + } } return nil, false } +func (e *Executor) lookupPassword(arg string) (string, bool) { + spec := parsePasswordLookupSpec(arg) + if spec.path == "" { + return "", false + } + + resolvedPath := e.resolveLocalPath(spec.path) + if data, err := coreio.Local.Read(resolvedPath); err == nil { + return strings.TrimRight(data, "\r\n"), true + } + + password, err := generatePassword(spec.length, spec.chars) + if err != nil { + return "", false + } + + if resolvedPath != "/dev/null" { + if err := coreio.Local.EnsureDir(pathDir(resolvedPath)); err != nil { + return "", false + } + if err := coreio.Local.Write(resolvedPath, password); err != nil { + return "", false + } + } + + return password, true +} + +type passwordLookupSpec struct { + path string + length int + chars string +} + +func parsePasswordLookupSpec(arg string) passwordLookupSpec { + spec := passwordLookupSpec{ + length: 20, + chars: passwordLookupCharset("ascii_letters,digits"), + } + + fields := strings.Fields(arg) + for _, field := range fields { + key, value, ok := strings.Cut(field, "=") + if ok { + switch lower(strings.TrimSpace(key)) { + case "length": + if n, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && n > 0 { + spec.length = n + } + case "chars": + if chars := passwordLookupCharset(value); chars != "" { + spec.chars = chars + } + } + continue + } + + if spec.path == "" { + spec.path = field + } + } + + return spec +} + +func passwordLookupCharset(value string) string { + if value == "" { + return "" + } + + var chars strings.Builder + seen := make(map[rune]bool) + appendChars := func(set string) { + for _, r := range set { + if seen[r] { + continue + } + seen[r] = true + chars.WriteRune(r) + } + } + + for _, token := range strings.Split(value, ",") { + switch lower(strings.TrimSpace(token)) { + case "ascii_letters": + appendChars("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + case "ascii_lowercase": + appendChars("abcdefghijklmnopqrstuvwxyz") + case "ascii_uppercase": + appendChars("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + case "digits": + appendChars("0123456789") + case "hexdigits": + appendChars("0123456789abcdefABCDEF") + default: + appendChars(token) + } + } + + return chars.String() +} + +func generatePassword(length int, chars string) (string, error) { + if length <= 0 { + length = 20 + } + if chars == "" { + chars = passwordLookupCharset("ascii_letters,digits") + } + + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return "", coreerr.E("Executor.lookupPassword", "generate password", err) + } + + output := make([]byte, length) + for i, b := range buf { + output[i] = chars[int(b)%len(chars)] + } + + return string(output), nil +} + // resolveLoop resolves loop items. func (e *Executor) resolveLoop(loop any, host string) []any { return e.resolveLoopWithTask(loop, host, nil) diff --git a/executor_extra_test.go b/executor_extra_test.go index a160997..18bd6aa 100644 --- a/executor_extra_test.go +++ b/executor_extra_test.go @@ -910,6 +910,31 @@ func TestExecutorExtra_HandleLookup_Good_FileGlobLookup(t *testing.T) { }), result) } +func TestExecutorExtra_HandleLookup_Good_PasswordLookupReadsExistingFile(t *testing.T) { + dir := t.TempDir() + passPath := joinPath(dir, "secrets", "app.pass") + require.NoError(t, writeTestFile(passPath, []byte("s3cret\n"), 0600)) + + e := NewExecutor(dir) + result := e.handleLookup("lookup('password', 'secrets/app.pass')", "", nil) + + assert.Equal(t, "s3cret", result) +} + +func TestExecutorExtra_HandleLookup_Good_PasswordLookupCreatesFile(t *testing.T) { + dir := t.TempDir() + passPath := joinPath(dir, "secrets", "generated.pass") + + e := NewExecutor(dir) + result := e.handleLookup("lookup('password', 'secrets/generated.pass length=12 chars=digits')", "", nil) + + require.Len(t, result, 12) + content, err := readTestFile(passPath) + require.NoError(t, err) + assert.Equal(t, result, string(content)) + assert.Len(t, content, 12) +} + func TestExecutorExtra_RunTaskOnHost_Good_LoopFromFileGlobLookup(t *testing.T) { dir := t.TempDir() require.NoError(t, writeTestFile(joinPath(dir, "files", "a.txt"), []byte("alpha"), 0644))