# Expects variable `ssh_users` as list of objects: # ssh_users: # - name: alice # keys: # - "ssh-ed25519 AAAA..." # - "ssh-rsa AAAA..." - name: Setting defaults for Windows OpenSSH Server installation ansible.builtin.set_fact: __ssh_default_ciphers__: - chacha20-poly1305@openssh.com - aes256-gcm@openssh.com - aes256-ctr __ssh_default_hostkey_algorithms__: - ssh-ed25519-cert-v01@openssh.com - ssh-rsa-cert-v01@openssh.com - ssh-ed25519 - ssh-rsa - ecdsa-sha2-nistp521-cert-v01@openssh.com - ecdsa-sha2-nistp384-cert-v01@openssh.com - ecdsa-sha2-nistp256-cert-v01@openssh.com - ecdsa-sha2-nistp521 - ecdsa-sha2-nistp384 - ecdsa-sha2-nistp256 - sk-ecdsa-sha2-nistp256@openssh.com __ssh_default_kex_algorithms__: - mlkem768x25519-sha256 - sntrup761x25519-sha512@openssh.com - curve25519-sha256 - curve25519-sha256@libssh.org - ecdh-sha2-nistp521 - ecdh-sha2-nistp384 - ecdh-sha2-nistp256 - diffie-hellman-group-exchange-sha256 __ssh_default_macs__: - hmac-sha2-512-etm@openssh.com - hmac-sha2-256-etm@openssh.com - hmac-sha2-512 - hmac-sha2-256 __ssh_default_auth_pubkey_accepted_algorithms__: - ecdsa-sha2-nistp521 - ecdsa-sha2-nistp384 - ecdsa-sha2-nistp256 - ssh-ed25519 - rsa-sha2-512 - rsa-sha2-256 - name: Override defaults with any provided variables ansible.builtin.set_fact: __ssh_users__: "{{ ssh_users | default([]) }}" __ssh_ciphers__: "{{ ssh_ciphers | default(__ssh_default_ciphers__) }}" __ssh_hostkey_algorithms__: "{{ ssh_hostkey_algorithms | default(__ssh_default_hostkey_algorithms__) }}" __ssh_kex_algorithms__: "{{ ssh_kex_algorithms | default(__ssh_default_kex_algorithms__) }}" __ssh_macs__: "{{ ssh_macs | default(__ssh_default_macs__) }}" __ssh_max_auth_tries__: "{{ ssh_max_auth_tries | default(10) }}" __ssh_max_sessions__: "{{ ssh_max_sessions | default(3) }}" __ssh_pubkey_accepted_algorithms__: "{{ ssh_pubkey_accepted_algorithms | default(__ssh_default_auth_pubkey_accepted_algorithms__) }}" - name: Get OpenSSH Server capability state ansible.windows.win_shell: | $cap = Get-WindowsCapability -Online | Where-Object { $_.Name -like 'OpenSSH.Server*' } | Select-Object -First 1 if ($cap) { $cap.State.ToString().Trim() } else { 'NotPresent' } args: executable: powershell.exe register: openssh_capability_state changed_when: false - name: Install OpenSSH Server capability when not installed ansible.windows.win_shell: | $cap = Get-WindowsCapability -Online | Where-Object { $_.Name -like 'OpenSSH.Server*' } | Select-Object -First 1 if (-not $cap) { throw 'OpenSSH.Server capability not found on this host.' } Add-WindowsCapability -Online -Name $cap.Name args: executable: powershell.exe register: openssh_install when: openssh_capability_state.stdout | trim != 'Installed' - name: Check whether OpenSSH host keys already exist ansible.windows.win_shell: | $keys = Get-ChildItem -Path 'C:\ProgramData\ssh' -Filter 'ssh_host_*_key' -File -ErrorAction SilentlyContinue if ($keys -and $keys.Count -gt 0) { Write-Output 'present' } else { Write-Output 'missing' } args: executable: powershell.exe register: ssh_hostkeys_state changed_when: false become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Generate OpenSSH host keys when missing (ssh-keygen -A) ansible.windows.win_shell: | $exe = 'C:\Windows\System32\OpenSSH\ssh-keygen.exe' if (Test-Path $exe) { & $exe -A 2>&1 Write-Output 'hostkeys-generated' } else { Write-Output 'ssh-keygen-not-found' exit(2) } register: ssh_keygen changed_when: "'hostkeys-generated' in ssh_keygen.stdout" when: ssh_hostkeys_state.stdout | trim == 'missing' become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Ensure OpenSSH private host keys are owned by SYSTEM ansible.windows.win_shell: | $changed = $false $keys = Get-ChildItem -Path 'C:\ProgramData\ssh' -Filter 'ssh_host_*_key' -File -ErrorAction SilentlyContinue if ($keys -and $keys.Count -gt 0) { foreach ($k in $keys) { $before = (Get-Acl -Path $k.FullName).Owner icacls.exe $k.FullName /setowner "*S-1-5-18" /C | Out-Null $after = (Get-Acl -Path $k.FullName).Owner if ($before -ne $after) { $changed = $true } } if ($changed) { Write-Output 'hostkey-owner-changed' } else { Write-Output 'hostkey-owner-unchanged' } } else { Write-Output 'no-keys' } register: ssh_hostkey_owner changed_when: "'hostkey-owner-changed' in ssh_hostkey_owner.stdout" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Restrict private host key file permissions (icacls) and remove provision user access ansible.windows.win_shell: | $changed = $false $removePrincipal = "{{ ansible_user | default('') }}" $keys = Get-ChildItem -Path 'C:\ProgramData\ssh' -Filter 'ssh_host_*_key' -File -ErrorAction SilentlyContinue if ($keys -and $keys.Count -gt 0) { foreach ($k in $keys) { $before = (Get-Acl -Path $k.FullName).Sddl icacls.exe $k.FullName /inheritance:r icacls.exe $k.FullName /grant:r "S-1-5-18:(F)" if ($removePrincipal -and $removePrincipal -notmatch '^(?i:NT AUTHORITY\\SYSTEM|SYSTEM)$') { icacls.exe $k.FullName /remove "$removePrincipal" 2>$null } $after = (Get-Acl -Path $k.FullName).Sddl if ($before -ne $after) { $changed = $true } } if ($changed) { Write-Output 'restricted-changed' } else { Write-Output 'restricted-unchanged' } } else { Write-Output 'no-keys' } args: executable: powershell.exe register: restrict_hostkey_perms changed_when: "'restricted-changed' in restrict_hostkey_perms.stdout" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Restrict C:\ProgramData\ssh folder write permissions ansible.windows.win_shell: | $p = 'C:\ProgramData\ssh' if (Test-Path $p) { $before = (Get-Acl -Path $p).Sddl icacls.exe $p /inheritance:r | Out-Null icacls.exe $p /grant:r "S-1-5-18:(OI)(CI)(F)" "S-1-5-32-544:(OI)(CI)(F)" | Out-Null icacls.exe $p /remove "S-1-5-32-545" 2>$null $after = (Get-Acl -Path $p).Sddl if ($before -ne $after) { Write-Output 'ssh-dir-acl-changed' } else { Write-Output 'ssh-dir-acl-unchanged' } } else { Write-Output 'no-dir' } args: executable: powershell.exe register: ssh_dir_acl changed_when: "'ssh-dir-acl-changed' in ssh_dir_acl.stdout" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Ensure sshd service is enabled and running ansible.windows.win_service: name: sshd start_mode: auto state: started - name: Ensure firewall allows SSH (TCP/22) community.windows.win_firewall_rule: name: "OpenSSH Server (sshd)" enable: true direction: in action: allow localport: 22 protocol: TCP profile: "Domain,Private" - name: Ensure SSH banner file is present ansible.windows.win_copy: src: "windows/issue.net" dest: 'C:\ProgramData\ssh\issue.net' force: true become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Detect available PowerShell for ssh subsystem ansible.windows.win_shell: | if (Test-Path 'C:\Program Files\PowerShell\7\pwsh.exe') { Write-Output 'C:\Program Files\PowerShell\7\pwsh.exe' } elseif (Test-Path 'C:\Program Files\PowerShell\6\pwsh.exe') { Write-Output 'C:\Program Files\PowerShell\6\pwsh.exe' } elseif (Test-Path 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe') { Write-Output 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' } else { Write-Output '' } register: ssh_subsystem_path changed_when: false - name: Set ssh subsystem executable path fact ansible.builtin.set_fact: __ssh_subsystem_path__: "{{ ssh_subsystem_path.stdout | trim }}" - name: Detect supported SSH key exchange algorithms ansible.windows.win_shell: | $ssh = 'C:\Windows\System32\OpenSSH\ssh.exe' if (Test-Path $ssh) { & $ssh -Q kex } else { Write-Output '' } args: executable: powershell.exe register: ssh_supported_kex changed_when: false - name: Render sshd_config from template ansible.windows.win_template: src: windows_sshd_config.j2 dest: 'C:\ProgramData\ssh\sshd_config' register: sshd_config_changed become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Restart sshd if config changed # noqa no-handler ansible.windows.win_service: name: sshd state: restarted when: sshd_config_changed.changed - name: Ensure users for SSH keys exist (no-op if already present) ansible.windows.win_user: name: "{{ item.name }}" state: present loop: "{{ __ssh_users__ | default([]) }}" loop_control: label: "{{ item.name }}" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Ensure user .ssh directory exists ansible.windows.win_file: path: "C:\\Users\\{{ item.name }}\\.ssh" state: directory loop: "{{ __ssh_users__ | default([]) }}" loop_control: label: "{{ item.name }}" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Deploy authorized_keys for each user ansible.windows.win_copy: dest: "C:\\Users\\{{ item.name }}\\.ssh\\authorized_keys" content: "{{ (item['keys'] | default([])) | join('\r\n') }}\r\n" force: true loop: "{{ __ssh_users__ | default([]) }}" loop_control: label: "{{ item.name }}" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Set ACL on .ssh directory to allow only the user and Administradores (directory) ansible.windows.win_acl: path: "C:\\Users\\{{ item.name }}\\.ssh" user: "{{ item.name }}" rights: FullControl type: allow inheritance: false propagate: false state: present loop: "{{ __ssh_users__ | default([]) }}" loop_control: label: "{{ item.name }}" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Set ACL on authorized_keys file to allow only the user and Administradores (file) ansible.windows.win_acl: path: "C:\\Users\\{{ item.name }}\\.ssh\\authorized_keys" user: "{{ item.name }}" rights: FullControl type: allow inheritance: false propagate: false state: present loop: "{{ __ssh_users__ | default([]) }}" loop_control: label: "{{ item.name }}" become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Create administrators_authorized_keys from admin users' keys (if any) ansible.windows.win_copy: dest: 'C:\ProgramData\ssh\administrators_authorized_keys' content: >- {{ (__ssh_users__ | default([]) | selectattr('admin', 'equalto', true) | map(attribute='keys') | list | flatten | default([])) | join('\r\n') }}\r\n force: true when: (__ssh_users__ | default([]) | selectattr('admin','equalto',true) | list) | length > 0 become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM" - name: Restrict administrators_authorized_keys permissions ansible.windows.win_shell: | $path = 'C:\ProgramData\ssh\administrators_authorized_keys' if (Test-Path $path) { $before = (Get-Acl -Path $path).Sddl icacls.exe $path /inheritance:r | Out-Null icacls.exe $path /grant:r "S-1-5-18:(F)" "S-1-5-32-544:(F)" | Out-Null $after = (Get-Acl -Path $path).Sddl if ($before -ne $after) { Write-Output 'admin-auth-keys-acl-changed' } else { Write-Output 'admin-auth-keys-unchanged' } } else { Write-Output 'no-admin-auth-keys' } args: executable: powershell.exe register: restrict_admin_auth_perms changed_when: "'admin-auth-keys-acl-changed' in restrict_admin_auth_perms.stdout" when: (__ssh_users__ | default([]) | selectattr('admin','equalto',true) | list) | length > 0 become: true become_method: ansible.builtin.runas become_user: "NT AUTHORITY\\SYSTEM"