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:
2026-11-14 10:42:42 +01:00
co-authored by Claude Opus 5.5
parent 2b5bffe2be
commit 67c1086e2c
179 changed files with 1376 additions and 0 deletions
+9
View File
@@ -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
+369
View File
@@ -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