|
|
|
@@ -0,0 +1,369 @@
|
|
|
|
|
---
|
|
|
|
|
# ---
|
|
|
|
|
# Chrooted SFTP access
|
|
|
|
|
#
|
|
|
|
|
# Controlled by 'chrooted_sftpd_present' (default: false).
|
|
|
|
|
#
|
|
|
|
|
# - group '{{ sftp_group_name }}' (GID 200 or next free GID above)
|
|
|
|
|
# - sshd config '/etc/ssh/sshd_config.d/90-sshd-SFTP.conf'
|
|
|
|
|
# - sftp users from list 'sftp_user' (UID 201 or next free UID above)
|
|
|
|
|
#
|
|
|
|
|
# Chroot requirements (enforced by sshd):
|
|
|
|
|
# The home directory (= ChrootDirectory) and all its parents must be
|
|
|
|
|
# owned by root and not writable by group/others. Therefore the users
|
|
|
|
|
# home is owned by root and the user gets a writable subdirectory
|
|
|
|
|
# (default: 'upload').
|
|
|
|
|
#
|
|
|
|
|
# authorized_keys is placed at <home>/.ssh/authorized_keys and is
|
|
|
|
|
# owned by root, so sftp users can read but not change their keys.
|
|
|
|
|
# ---
|
|
|
|
|
|
|
|
|
|
# ---
|
|
|
|
|
# Group
|
|
|
|
|
# ---
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Get group database
|
|
|
|
|
ansible.builtin.getent:
|
|
|
|
|
database: group
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: "(sftpd.yml) Determine GID for sftp group: {{ sftp_group_name }}"
|
|
|
|
|
ansible.builtin.set_fact:
|
|
|
|
|
common_fact_sftp_gid: >-
|
|
|
|
|
{%- if sftp_group_name in ansible_facts.getent_group -%}
|
|
|
|
|
{{ ansible_facts.getent_group[sftp_group_name][1] }}
|
|
|
|
|
{%- else -%}
|
|
|
|
|
{%- set used = ansible_facts.getent_group.values() | map(attribute=1) | map('int') | list -%}
|
|
|
|
|
{%- set ns = namespace(gid=sftp_group_id_start | int) -%}
|
|
|
|
|
{%- for _ in range(1000) if ns.gid in used -%}
|
|
|
|
|
{%- set ns.gid = ns.gid + 1 -%}
|
|
|
|
|
{%- endfor -%}
|
|
|
|
|
{{ ns.gid }}
|
|
|
|
|
{%- endif -%}
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: "(sftpd.yml) Ensure sftp group exists: {{ sftp_group_name }}"
|
|
|
|
|
ansible.builtin.group:
|
|
|
|
|
name: "{{ sftp_group_name }}"
|
|
|
|
|
gid: "{{ common_fact_sftp_gid }}"
|
|
|
|
|
state: present
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# ---
|
|
|
|
|
# sshd configuration
|
|
|
|
|
# ---
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Ensure directory '/etc/ssh/sshd_config.d' exists
|
|
|
|
|
ansible.builtin.file:
|
|
|
|
|
path: /etc/ssh/sshd_config.d
|
|
|
|
|
state: directory
|
|
|
|
|
owner: root
|
|
|
|
|
group: root
|
|
|
|
|
mode: "0755"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Create/Update '/etc/ssh/sshd_config.d/90-sshd-SFTP.conf'
|
|
|
|
|
ansible.builtin.template:
|
|
|
|
|
src: etc/ssh/sshd_config.d/90-sshd-SFTP.conf.j2
|
|
|
|
|
dest: /etc/ssh/sshd_config.d/90-sshd-SFTP.conf
|
|
|
|
|
owner: root
|
|
|
|
|
group: root
|
|
|
|
|
mode: "0644"
|
|
|
|
|
register: common_sftpd_config
|
|
|
|
|
notify: "Restart ssh"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# The drop-in file can't be validated on its own (no HostKey etc.), so
|
|
|
|
|
# test the complete configuration. A failure stops the play for this host
|
|
|
|
|
# before the 'Restart ssh' handler runs.
|
|
|
|
|
- name: (sftpd.yml) Validate complete sshd configuration # noqa: no-handler
|
|
|
|
|
ansible.builtin.command:
|
|
|
|
|
cmd: /usr/sbin/sshd -t
|
|
|
|
|
changed_when: false
|
|
|
|
|
when: common_sftpd_config is changed
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# Restart sshd right now (if notified) - otherwise a failure in one of the
|
|
|
|
|
# following tasks would leave the changed config unapplied, and a later run
|
|
|
|
|
# wouldn't notify the handler again (file already up to date).
|
|
|
|
|
- name: (sftpd.yml) Apply changed sshd configuration now
|
|
|
|
|
ansible.builtin.meta: flush_handlers
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# ---
|
|
|
|
|
# SFTP users
|
|
|
|
|
#
|
|
|
|
|
# new users - entries without 'existing_user: true'. User, chroot
|
|
|
|
|
# (home) directory, upload directory and authorized_keys
|
|
|
|
|
# are created/managed.
|
|
|
|
|
# existing users - entries with 'existing_user: true' (e.g. www-data or
|
|
|
|
|
# suexec users of vhosts). Only added to the sftp group;
|
|
|
|
|
# UID, primary group, home and shell stay untouched,
|
|
|
|
|
# 'home' is ignored. Their home (from passwd) becomes the
|
|
|
|
|
# chroot directory, so it must meet sshd's chroot
|
|
|
|
|
# requirements - this is checked, but NOT changed.
|
|
|
|
|
# ---
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Get passwd database
|
|
|
|
|
ansible.builtin.getent:
|
|
|
|
|
database: passwd
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Split sftp_user into new and existing users
|
|
|
|
|
ansible.builtin.set_fact:
|
|
|
|
|
common_fact_sftp_new_users: "{{ sftp_user | rejectattr('existing_user', 'defined') | list
|
|
|
|
|
+ (sftp_user | selectattr('existing_user', 'defined') | rejectattr('existing_user') | list) }}"
|
|
|
|
|
common_fact_sftp_existing_users: "{{ sftp_user | selectattr('existing_user', 'defined') | selectattr('existing_user') | list }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# Users which already exist and have a login shell (anything but
|
|
|
|
|
# nologin/false). Their password is never changed.
|
|
|
|
|
- name: (sftpd.yml) Determine sftp users with shell access
|
|
|
|
|
ansible.builtin.set_fact:
|
|
|
|
|
common_fact_sftp_shell_users: >-
|
|
|
|
|
{%- set ns = namespace(result=[]) -%}
|
|
|
|
|
{%- for u in sftp_user if u.name in ansible_facts.getent_passwd
|
|
|
|
|
and (ansible_facts.getent_passwd[u.name][5] | basename) not in ['nologin', 'false'] -%}
|
|
|
|
|
{%- set ns.result = ns.result + [u.name] -%}
|
|
|
|
|
{%- endfor -%}
|
|
|
|
|
{{ ns.result }}
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# Existing users keep their UID. New users without explicit 'user_id'
|
|
|
|
|
# get the next free UID starting at 'sftp_user_id_start'.
|
|
|
|
|
- name: (sftpd.yml) Determine UIDs for new sftp users
|
|
|
|
|
ansible.builtin.set_fact:
|
|
|
|
|
common_fact_sftp_uids: >-
|
|
|
|
|
{%- set ns = namespace(uid=sftp_user_id_start | int, result={},
|
|
|
|
|
used=(ansible_facts.getent_passwd.values() | map(attribute=1) | map('int') | list)
|
|
|
|
|
+ (common_fact_sftp_new_users | selectattr('user_id', 'defined') | map(attribute='user_id') | map('int') | list)) -%}
|
|
|
|
|
{%- for u in common_fact_sftp_new_users -%}
|
|
|
|
|
{%- if u.user_id is defined -%}
|
|
|
|
|
{%- set ns.result = ns.result | combine({u.name: u.user_id | int}) -%}
|
|
|
|
|
{%- elif u.name in ansible_facts.getent_passwd -%}
|
|
|
|
|
{%- set ns.result = ns.result | combine({u.name: ansible_facts.getent_passwd[u.name][1] | int}) -%}
|
|
|
|
|
{%- else -%}
|
|
|
|
|
{%- for _ in range(1000) if ns.uid in ns.used -%}
|
|
|
|
|
{%- set ns.uid = ns.uid + 1 -%}
|
|
|
|
|
{%- endfor -%}
|
|
|
|
|
{%- set ns.result = ns.result | combine({u.name: ns.uid}) -%}
|
|
|
|
|
{%- set ns.used = ns.used + [ns.uid] -%}
|
|
|
|
|
{%- endif -%}
|
|
|
|
|
{%- endfor -%}
|
|
|
|
|
{{ ns.result }}
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# --- new users
|
|
|
|
|
|
|
|
|
|
# A 'new' user would get shell nologin, primary group sftp and a root
|
|
|
|
|
# owned home - don't do that to an account with shell access.
|
|
|
|
|
- name: (sftpd.yml) Fail if a new sftp user already exists with shell access
|
|
|
|
|
ansible.builtin.fail:
|
|
|
|
|
msg: >-
|
|
|
|
|
User '{{ item.name }}' already exists with shell access
|
|
|
|
|
({{ ansible_facts.getent_passwd[item.name][5] }}).
|
|
|
|
|
Use 'existing_user: true' for this user.
|
|
|
|
|
when: item.name in common_fact_sftp_shell_users
|
|
|
|
|
loop: "{{ common_fact_sftp_new_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Ensure new sftp users exist
|
|
|
|
|
ansible.builtin.user:
|
|
|
|
|
name: "{{ item.name }}"
|
|
|
|
|
uid: "{{ common_fact_sftp_uids[item.name] }}"
|
|
|
|
|
group: "{{ sftp_group_name }}"
|
|
|
|
|
home: "{{ item.home }}"
|
|
|
|
|
create_home: false
|
|
|
|
|
shell: /usr/sbin/nologin
|
|
|
|
|
# no password given: account locked ('!'), login only with ssh key
|
|
|
|
|
password: "{{ item.password | default('!') }}"
|
|
|
|
|
# never change the password of a user with shell access
|
|
|
|
|
update_password: "{{ 'on_create' if item.name in common_fact_sftp_shell_users
|
|
|
|
|
else item.update_password | default('always') }}"
|
|
|
|
|
state: present
|
|
|
|
|
loop: "{{ common_fact_sftp_new_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Ensure chroot (home) directories of new users exist - owned by root
|
|
|
|
|
ansible.builtin.file:
|
|
|
|
|
path: "{{ item.home }}"
|
|
|
|
|
state: directory
|
|
|
|
|
owner: root
|
|
|
|
|
group: root
|
|
|
|
|
mode: "0755"
|
|
|
|
|
loop: "{{ common_fact_sftp_new_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}: {{ item.home }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Ensure writable directory inside chroot of new users exists
|
|
|
|
|
ansible.builtin.file:
|
|
|
|
|
path: "{{ item.home }}/{{ item.upload_dir | default('upload') }}"
|
|
|
|
|
state: directory
|
|
|
|
|
owner: "{{ item.name }}"
|
|
|
|
|
group: "{{ sftp_group_name }}"
|
|
|
|
|
mode: "0750"
|
|
|
|
|
loop: "{{ common_fact_sftp_new_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}: {{ item.home }}/{{ item.upload_dir | default('upload') }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Ensure directory '<home>/.ssh' of new users exists - owned by root
|
|
|
|
|
ansible.builtin.file:
|
|
|
|
|
path: "{{ item.home }}/.ssh"
|
|
|
|
|
state: directory
|
|
|
|
|
owner: root
|
|
|
|
|
group: root
|
|
|
|
|
mode: "0755"
|
|
|
|
|
loop: "{{ common_fact_sftp_new_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# Always created - empty, if no ssh_keys are given.
|
|
|
|
|
- name: (sftpd.yml) Set authorized_keys of new users - owned by root
|
|
|
|
|
ansible.builtin.template:
|
|
|
|
|
src: sftp_authorized_keys.j2
|
|
|
|
|
dest: "{{ item.home }}/.ssh/authorized_keys"
|
|
|
|
|
owner: root
|
|
|
|
|
group: root
|
|
|
|
|
mode: "0644"
|
|
|
|
|
loop: "{{ common_fact_sftp_new_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# --- existing users
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Ensure existing users really exist
|
|
|
|
|
ansible.builtin.assert:
|
|
|
|
|
that:
|
|
|
|
|
- item.name in ansible_facts.getent_passwd
|
|
|
|
|
fail_msg: "User '{{ item.name }}' is marked as 'existing_user' but does not exist."
|
|
|
|
|
quiet: true
|
|
|
|
|
loop: "{{ common_fact_sftp_existing_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# sshd: the chroot directory and all its parents must be owned by root
|
|
|
|
|
# and must not be writable by group or others.
|
|
|
|
|
- name: (sftpd.yml) Check chroot requirements of existing users' home directories # noqa: risky-shell-pipe
|
|
|
|
|
ansible.builtin.shell:
|
|
|
|
|
cmd: |
|
|
|
|
|
d={{ ansible_facts.getent_passwd[item.name][4] | quote }}
|
|
|
|
|
rc=0
|
|
|
|
|
while : ; do
|
|
|
|
|
if ! out=$(stat -c '%u %U:%G %A %n' "$d" 2>&1) ; then
|
|
|
|
|
echo "MISSING $d" ; rc=1
|
|
|
|
|
else
|
|
|
|
|
set -- $out
|
|
|
|
|
case "$3" in
|
|
|
|
|
?????w*|????????w*) echo "BAD (writable by group/others) $out" ; rc=1 ;;
|
|
|
|
|
*) if [ "$1" != "0" ] ; then echo "BAD (not owned by root) $out" ; rc=1 ; else echo "OK $out" ; fi ;;
|
|
|
|
|
esac
|
|
|
|
|
fi
|
|
|
|
|
[ "$d" = "/" ] && break
|
|
|
|
|
d=$(dirname "$d")
|
|
|
|
|
done
|
|
|
|
|
exit $rc
|
|
|
|
|
loop: "{{ common_fact_sftp_existing_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}: {{ ansible_facts.getent_passwd[item.name][4] }}"
|
|
|
|
|
register: common_sftpd_existing_home_check
|
|
|
|
|
changed_when: false
|
|
|
|
|
failed_when: false
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Fail if home of an existing user is not usable as chroot directory
|
|
|
|
|
ansible.builtin.fail:
|
|
|
|
|
msg: >-
|
|
|
|
|
Home '{{ ansible_facts.getent_passwd[item.item.name][4] }}' of user '{{ item.item.name }}'
|
|
|
|
|
can't be used as chroot directory - it and all its parents must exist, be owned by root
|
|
|
|
|
and must not be writable by group/others: {{ item.stdout_lines | reject('match', '^OK ') | join(' | ') }}
|
|
|
|
|
when: item.rc != 0
|
|
|
|
|
loop: "{{ common_sftpd_existing_home_check.results }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: "(sftpd.yml) Add existing users to sftp group: {{ sftp_group_name }}"
|
|
|
|
|
ansible.builtin.user:
|
|
|
|
|
name: "{{ item.name }}"
|
|
|
|
|
groups: "{{ sftp_group_name }}"
|
|
|
|
|
append: true
|
|
|
|
|
# password only if given - and never for users with shell access
|
|
|
|
|
# (user exists, so 'on_create' means: don't touch)
|
|
|
|
|
password: "{{ item.password | default(omit) }}"
|
|
|
|
|
update_password: "{{ 'on_create' if item.name in common_fact_sftp_shell_users
|
|
|
|
|
else item.update_password | default('always') }}"
|
|
|
|
|
loop: "{{ common_fact_sftp_existing_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
# '~/.ssh' and '~/.ssh/authorized_keys' are always created (owned by the
|
|
|
|
|
# user). An existing authorized_keys file is never truncated.
|
|
|
|
|
- name: (sftpd.yml) Ensure directory '~/.ssh' of existing users exists
|
|
|
|
|
ansible.builtin.file:
|
|
|
|
|
path: "{{ ansible_facts.getent_passwd[item.name][4] }}/.ssh"
|
|
|
|
|
state: directory
|
|
|
|
|
owner: "{{ item.name }}"
|
|
|
|
|
group: "{{ ansible_facts.getent_passwd[item.name][2] }}"
|
|
|
|
|
mode: "0700"
|
|
|
|
|
loop: "{{ common_fact_sftp_existing_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}: {{ ansible_facts.getent_passwd[item.name][4] }}/.ssh"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Ensure file '~/.ssh/authorized_keys' of existing users exists
|
|
|
|
|
ansible.builtin.file:
|
|
|
|
|
path: "{{ ansible_facts.getent_passwd[item.name][4] }}/.ssh/authorized_keys"
|
|
|
|
|
state: touch
|
|
|
|
|
owner: "{{ item.name }}"
|
|
|
|
|
group: "{{ ansible_facts.getent_passwd[item.name][2] }}"
|
|
|
|
|
mode: "0600"
|
|
|
|
|
access_time: preserve
|
|
|
|
|
modification_time: preserve
|
|
|
|
|
loop: "{{ common_fact_sftp_existing_users }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|
|
|
|
|
|
|
|
|
|
- name: (sftpd.yml) Add ssh keys of existing users (if given)
|
|
|
|
|
ansible.posix.authorized_key:
|
|
|
|
|
user: "{{ item.0.name }}"
|
|
|
|
|
key: "{{ item.1 }}"
|
|
|
|
|
state: present
|
|
|
|
|
loop: "{{ common_fact_sftp_existing_users | subelements('ssh_keys', skip_missing=true) }}"
|
|
|
|
|
loop_control:
|
|
|
|
|
label: "{{ item.0.name }}"
|
|
|
|
|
tags:
|
|
|
|
|
- sftpd
|