Role common: add task sftpd.yml for chrooted SFTP access.
- group 'sftpusers' (GID 200 or next free), sshd drop-in /etc/ssh/sshd_config.d/90-sshd-SFTP.conf (Match Group, ChrootDirectory %h, ForceCommand internal-sftp), PasswordAuthentication via 'sftp_password_authentication' - new sftp users (UID 201 or next free) with root owned chroot home, upload dir and root owned authorized_keys (always created) - existing users (e.g. www-data, suexec users) can be added to the group; chroot requirements of their home are checked, not changed - passwords: update_password defaults to 'always', password of users with shell access is never changed - controlled by 'chrooted_sftpd_present' (default: false), tag 'sftpd' - add empty sftpd section to all host_vars *-common.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -180,6 +180,15 @@
|
||||
ansible.builtin.import_tasks: sshd.yml
|
||||
tags: sshd
|
||||
|
||||
# tags supported inside sftpd.yml
|
||||
#
|
||||
# sftpd
|
||||
- name: (main.yml) Import sftpd.yml tasks
|
||||
ansible.builtin.import_tasks: sftpd.yml
|
||||
when:
|
||||
- chrooted_sftpd_present is defined and chrooted_sftpd_present | bool
|
||||
tags: sftpd
|
||||
|
||||
# tags supported inside sudoers.yml:
|
||||
#
|
||||
# sudoers-remove
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user