CertGrid CertGrid
Hands-on Lab·Ansible

Ansible SSH Keys and become

Two EX294 objectives in one sequence: create and distribute SSH keys to managed nodes, and configure privilege escalation on them. Both failures are produced first - the login that is refused and the sudo that is refused - so the fixes are answers to something rather than steps in a list. A new `deploy` user is used throughout, so nothing about the existing working connection is disturbed.

Control Node and Managed Nodes Guide 5 of 45 Intermediate

Written against the versions above. The `authorized_key` module manages one key at a time and is idempotent - running it twice reports `ok`, not a duplicated entry. `exclusive: true` makes it the only key for that user, which is a much bigger hammer than it looks and will lock out anything already there.

The key is generated on the control node and pushed to both managed nodes, so all three are involved.
Server NameIP AddressOSRolesCPURAMHDD
ANS-CTL01192.168.0.36Ubuntu 26.04 LTSAnsible Control Node2 Core3 GB50 GB
ANS-A01192.168.0.37Ubuntu 26.04 LTSManaged Node (group: web)2 Core3 GB50 GB
ANS-B01192.168.0.38Ubuntu 26.04 LTSManaged Node (group: db)2 Core3 GB50 GB

Before you start

  1. A user that cannot log in yet

    The user module creates the account on both nodes at once:

    ans-a01 | CHANGED
    ans-b01 | CHANGED

    and id confirms it exists. But an account is not access - there is no password set and no key authorised, so nothing can log in as deploy yet. That is the correct state for a service account and the starting point for the rest of the guide.

    The key pair is generated on the control node, because that is where the private half has to live:

    -rw------- 1 sysadmin sysadmin ... /home/sysadmin/.ssh/deploy_key
    -rw-r--r-- 1 sysadmin sysadmin ... /home/sysadmin/.ssh/deploy_key.pub

    600 and 644. If the private key is more permissive than 600, OpenSSH refuses to use it - a common self-inflicted failure when keys are copied around.

    bash Example session
    ansible all -m ansible.builtin.user -a "name=deploy shell=/bin/bash state=present" --become 2>&1 | grep -E "CHANGED|SUCCESS|name|home" | head -8ans-a01 | CHANGED => {    "create_home": true,    "home": "/home/deploy",    "name": "deploy",ans-b01 | CHANGED => {    "create_home": true,    "home": "/home/deploy",    "name": "deploy",ansible all -m command -a "id deploy" --becomeans-a01 | CHANGED | rc=0 >>uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)ans-b01 | CHANGED | rc=0 >>uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)ssh-keygen -t ed25519 -N "" -C "deploy@ans-ctl01" -f ~/.ssh/deploy_key <<< y 2>&1 | grep -E "public key|fingerprint" | head -2Your public key has been saved in /home/sysadmin/.ssh/deploy_key.pubThe key fingerprint is:ls -l ~/.ssh/deploy_key ~/.ssh/deploy_key.pub-rw------- 1 sysadmin sysadmin 411 Aug 23 07:12 /home/sysadmin/.ssh/deploy_key-rw-r--r-- 1 sysadmin sysadmin  98 Aug 23 07:12 /home/sysadmin/.ssh/deploy_key.pub

    Expected resultThe user created on both hosts and a key pair on the control node.

    Success conditionYou have an account and a key that have not met yet.

  2. It fails before the key is there

    ans-a01 | UNREACHABLE! => {
        "msg": "Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password)."

    UNREACHABLE!, not FAILED! - and the distinction matters. FAILED means Ansible got in and the task did not work. UNREACHABLE means it never got in at all, so nothing was attempted. When you see it, stop debugging the task and start debugging the connection.

    The message is OpenSSH's own: Permission denied (publickey,password) lists the methods the server was willing to accept. We have neither.

    bash Example session
    ansible all -m ping -u deploy --private-key ~/.ssh/deploy_key 2>&1 | grep -E "UNREACHABLE|Permission denied|msg" | head -4[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).ans-a01 | UNREACHABLE! => {    "msg": "Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).",[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-b01: Permission denied (publickey,password).

    Expected resultUNREACHABLE! with a permission denied from SSH itself.

    Success conditionYou can tell a connection failure from a task failure at a glance.

  3. Distribute the key with a module

    The authorized_key module, reading the public key with a lookup:

    ansible all -m ansible.builtin.authorized_key \
      -a "user=deploy state=present key={{ lookup('file', '/home/sysadmin/.ssh/deploy_key.pub') }}" --become
    ans-a01 | CHANGED
    ans-b01 | CHANGED

    and now:

    ans-a01 | SUCCESS => {
        "changed": false,
        "ping": "pong"
    }

    Two details worth taking away.

    lookup('file', ...) runs on the control node. It reads the local file and passes the contents; the managed node never sees a path. Lookups are always local, which surprises people the first time.

    --become was required. Writing into /home/deploy/.ssh/ needs privileges the sysadmin account does not have over another user's home directory.

    This is also the answer to "how do I bootstrap the first key" - you use an existing working connection to install the next one. ssh-copy-id is the manual equivalent and needs a password.

    bash Example session
    ansible all -m ansible.builtin.authorized_key -a "user=deploy state=present key={{ lookup('file', '/home/sysadmin/.ssh/deploy_key.pub') }}" --become 2>&1 | grep -E "CHANGED|SUCCESS|key_options|exclusive" | head -6ans-a01 | CHANGED => {    "exclusive": false,    "key_options": null,ans-b01 | CHANGED => {    "exclusive": false,    "key_options": null,ansible all -m ping -u deploy --private-key ~/.ssh/deploy_key 2>&1 | grep -E "UNREACHABLE|Permission denied|msg" | head -4[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).ans-a01 | UNREACHABLE! => {    "msg": "Task failed: Failed to connect to the host via ssh: deploy@ans-a01: Permission denied (publickey,password).",[ERROR]: Task failed: Failed to connect to the host via ssh: deploy@ans-b01: Permission denied (publickey,password).

    Expected resultThe key installed on both, and pong as the new user.

    Success conditionYou can distribute a key to a fleet with one command.

  4. But it still cannot become root

    ans-a01 | FAILED! => {
    ans-b01 | FAILED! => {

    FAILED! this time, not UNREACHABLE!. Ansible logged in perfectly well - the key works - and then sudo refused. That is the second half of managed-node setup, and it is a completely separate problem from the first.

    Authentication and authorisation are configured independently, which is why the exam lists them as two objectives. A key gets you in as the user; nothing about it grants root.

    bash Example session
    ansible all -m command -a "id -un" -u deploy --private-key ~/.ssh/deploy_key --become 2>&1 | grep -E "FAILED|password is required|rc=" | head -4ans-a01 | FAILED! => {ans-b01 | FAILED! => {

    Expected resultA task failure caused by sudo, not by the connection.

    Success conditionYou can separate 'cannot log in' from 'cannot escalate'.

  5. Configure privilege escalation

    A file in /etc/sudoers.d/, written with copy - and note the last argument:

    validate='visudo -cf %s'

    validate runs the checker against the temporary file before it is moved into place. If the syntax is wrong the file is never installed. Editing sudoers badly can lock everybody out of root on the machine, and this option is the difference between a typo and an outage. Use it every single time you touch sudoers.

    ans-a01 | CHANGED => {
        "dest": "/etc/sudoers.d/deploy",
        "mode": "0440",

    0440 is what sudo requires - it refuses to read a sudoers file that is group- or world-writable.

    And now:

    ans-a01 | CHANGED | rc=0 >>
    root
    deploy ALL=(ALL) NOPASSWD: ALL

    A drop-in file rather than editing /etc/sudoers directly, because it is removable, survives package upgrades of the sudo configuration, and can be managed per user.

    bash Example session
    ansible all -m ansible.builtin.copy -a "dest=/etc/sudoers.d/deploy content='deploy ALL=(ALL) NOPASSWD: ALL\n' owner=root group=root mode=0440 validate='visudo -cf %s'" --become 2>&1 | grep -E "CHANGED|SUCCESS|dest|mode" | head -6ans-a01 | CHANGED => {    "dest": "/etc/sudoers.d/deploy",    "mode": "0440",ans-b01 | CHANGED => {    "dest": "/etc/sudoers.d/deploy",    "mode": "0440",ansible all -m command -a "id -un" -u deploy --private-key ~/.ssh/deploy_key --becomeans-a01 | CHANGED | rc=0 >>rootans-b01 | CHANGED | rc=0 >>rootansible all -m command -a "cat /etc/sudoers.d/deploy" --becomeans-a01 | CHANGED | rc=0 >>deploy ALL=(ALL) NOPASSWD: ALLans-b01 | CHANGED | rc=0 >>deploy ALL=(ALL) NOPASSWD: ALL

    Expected resultA validated sudoers file, and root from the deploy user.

    Success conditionYou have privilege escalation configured, safely.

  6. Make it the default

    ans-a01 | SUCCESS => {
        "ping": "pong"
    }

    with --become --become-method sudo --become-user root spelled out in full. All three are already the defaults, so in practice --become (or -b) alone is what you type.

    Once this works, stop passing it on the command line. In ansible.cfg:

    [defaults]
    remote_user = deploy
    private_key_file = ~/.ssh/deploy_key
    
    [privilege_escalation]
    become = True
    become_method = sudo
    become_user = root
    become_ask_pass = False

    Or per play, with become: true, which is usually better - most plays need root for some tasks and not others, and being explicit in the playbook makes that visible to the next reader.

    What you have built is the standard shape of a managed fleet: one service account, one key, passwordless sudo, and nothing installed on the far side.

    bash Example session
    ansible all -m ansible.builtin.ping -u deploy --private-key ~/.ssh/deploy_key --become --become-method sudo --become-user rootans-a01 | SUCCESS => {    "changed": false,    "ping": "pong"}ans-b01 | SUCCESS => {    "changed": false,    "ping": "pong"}

    Expected resultA successful ping with escalation fully specified.

    Success conditionYou can move the flags into configuration and stop typing them.

Troubleshooting

Official sources