From 988c0e53caaacc6279bfdd1c95a2d36584090956 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:52:53 +0000 Subject: [PATCH] feat(ansible): support authorized_key path options Co-Authored-By: Virgil --- mock_ssh_test.go | 22 ++++++++++++++++++---- modules.go | 22 ++++++++++++++++++---- modules_adv_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/mock_ssh_test.go b/mock_ssh_test.go index 5a27014..f827296 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -1359,6 +1359,8 @@ func moduleAuthorizedKeyWithClient(_ *Executor, client sshRunner, args map[strin key := getStringArg(args, "key", "") state := getStringArg(args, "state", "present") exclusive := getBoolArg(args, "exclusive", false) + manageDir := getBoolArg(args, "manage_dir", true) + pathArg := getStringArg(args, "path", "") if user == "" || key == "" { return nil, mockError("moduleAuthorizedKeyWithClient", "authorized_key: user and key required") @@ -1377,7 +1379,17 @@ func moduleAuthorizedKeyWithClient(_ *Executor, client sshRunner, args map[strin } } - authKeysPath := joinPath(home, ".ssh", "authorized_keys") + authKeysPath := pathArg + if authKeysPath == "" { + authKeysPath = joinPath(home, ".ssh", "authorized_keys") + } else if corexHasPrefix(authKeysPath, "~/") { + authKeysPath = joinPath(home, corexTrimPrefix(authKeysPath, "~/")) + } else if authKeysPath == "~" { + authKeysPath = home + } + if authKeysPath == "" { + authKeysPath = joinPath(home, ".ssh", "authorized_keys") + } if state == "absent" { // Remove the exact key line when present. @@ -1387,9 +1399,11 @@ func moduleAuthorizedKeyWithClient(_ *Executor, client sshRunner, args map[strin return &TaskResult{Changed: true}, nil } - // Ensure .ssh directory exists (best-effort) - _, _, _, _ = client.Run(context.Background(), sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", - pathDir(authKeysPath), pathDir(authKeysPath), user, user, pathDir(authKeysPath))) + if manageDir { + // Ensure the parent directory exists (best-effort). + _, _, _, _ = client.Run(context.Background(), sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", + pathDir(authKeysPath), pathDir(authKeysPath), user, user, pathDir(authKeysPath))) + } if exclusive { cmd := sprintf("printf '%%s\\n' %q > %q", key, authKeysPath) diff --git a/modules.go b/modules.go index 20fb5ac..e8220ce 100644 --- a/modules.go +++ b/modules.go @@ -2877,6 +2877,8 @@ func (e *Executor) moduleAuthorizedKey(ctx context.Context, client sshExecutorCl key := getStringArg(args, "key", "") state := getStringArg(args, "state", "present") exclusive := getBoolArg(args, "exclusive", false) + manageDir := getBoolArg(args, "manage_dir", true) + pathArg := getStringArg(args, "path", "") if user == "" || key == "" { return nil, coreerr.E("Executor.moduleAuthorizedKey", "user and key required", nil) @@ -2895,7 +2897,17 @@ func (e *Executor) moduleAuthorizedKey(ctx context.Context, client sshExecutorCl } } - authKeysPath := joinPath(home, ".ssh", "authorized_keys") + authKeysPath := pathArg + if authKeysPath == "" { + authKeysPath = joinPath(home, ".ssh", "authorized_keys") + } else if corexHasPrefix(authKeysPath, "~/") { + authKeysPath = joinPath(home, corexTrimPrefix(authKeysPath, "~/")) + } else if authKeysPath == "~" { + authKeysPath = home + } + if authKeysPath == "" { + authKeysPath = joinPath(home, ".ssh", "authorized_keys") + } if state == "absent" { // Remove the exact key line when present. @@ -2905,9 +2917,11 @@ func (e *Executor) moduleAuthorizedKey(ctx context.Context, client sshExecutorCl return &TaskResult{Changed: true}, nil } - // Ensure .ssh directory exists (best-effort) - _, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", - pathDir(authKeysPath), pathDir(authKeysPath), user, user, pathDir(authKeysPath))) + if manageDir { + // Ensure the parent directory exists (best-effort). + _, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", + pathDir(authKeysPath), pathDir(authKeysPath), user, user, pathDir(authKeysPath))) + } if exclusive { cmd := sprintf("printf '%%s\\n' %q > %q", key, authKeysPath) diff --git a/modules_adv_test.go b/modules_adv_test.go index 34aa190..0968697 100644 --- a/modules_adv_test.go +++ b/modules_adv_test.go @@ -433,6 +433,48 @@ func TestModulesAdv_ModuleAuthorizedKey_Good_ExclusiveRewritesFile(t *testing.T) assert.False(t, mock.hasExecuted(`grep -qF`)) } +func TestModulesAdv_ModuleAuthorizedKey_Good_CustomPath(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT... user@host" + mock.expectCommand(`getent passwd deploy`, "/home/deploy", "", 0) + mock.expectCommand(`mkdir -p "/srv/keys"`, "", "", 0) + mock.expectCommand(`grep -qF`, "", "", 1) + mock.expectCommand(`echo`, "", "", 0) + mock.expectCommand(`chmod 600`, "", "", 0) + + result, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "user": "deploy", + "key": testKey, + "path": "/srv/keys/deploy_keys", + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("/srv/keys/deploy_keys")) + assert.True(t, mock.hasExecuted(`mkdir -p "/srv/keys"`)) +} + +func TestModulesAdv_ModuleAuthorizedKey_Good_ManageDirDisabled(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT... user@host" + mock.expectCommand(`getent passwd deploy`, "/home/deploy", "", 0) + mock.expectCommand(`grep -qF`, "", "", 1) + mock.expectCommand(`echo`, "", "", 0) + mock.expectCommand(`chmod 600`, "", "", 0) + + result, err := moduleAuthorizedKeyWithClient(e, mock, map[string]any{ + "user": "deploy", + "key": testKey, + "manage_dir": false, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.False(t, mock.hasExecuted(`mkdir -p`)) +} + func TestModulesAdv_ModuleAuthorizedKey_Good_RootUserFallback(t *testing.T) { e, mock := newTestExecutorWithMock("host1") testKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcT... admin@host"