--- # --- # 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 /.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 '/.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