diff --git a/roles/sftp_share/.ansible-lint b/roles/sftp_share/.ansible-lint new file mode 100644 index 0000000..f865478 --- /dev/null +++ b/roles/sftp_share/.ansible-lint @@ -0,0 +1,16 @@ +--- +profile: production +strict: true + +# Enable checking of loop variable prefixes in roles +loop_var_prefix: "^(__|{role}_)" + +skip_list: + - var-naming[no-role-prefix] + +warn_list: + - role-name[path] + - var-naming[no-role-prefix] + +exclude_paths: + - .github/ diff --git a/roles/sftp_share/.gitignore b/roles/sftp_share/.gitignore new file mode 100644 index 0000000..292f339 --- /dev/null +++ b/roles/sftp_share/.gitignore @@ -0,0 +1,3 @@ +.vscode +.idea +.ansible \ No newline at end of file diff --git a/roles/sftp_share/.yamllint b/roles/sftp_share/.yamllint new file mode 100644 index 0000000..611db54 --- /dev/null +++ b/roles/sftp_share/.yamllint @@ -0,0 +1,8 @@ +--- +rules: + brackets: + forbid: false + min-spaces-inside: 0 + max-spaces-inside: 2 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: 2 diff --git a/roles/sftp_share/README.md b/roles/sftp_share/README.md new file mode 100644 index 0000000..2a9c476 --- /dev/null +++ b/roles/sftp_share/README.md @@ -0,0 +1,73 @@ +SFTP Share Ansible Role +========= + +Creates a user account and directories for secured SFTP data exchange (one user = one share). + +The role creates a dedicated SSH config under `/etc/ssh/sshd_config.d/`, configuring: + +- chroot into the directory of the share +- limits commands to `internal-sftp` +- further controls SFTP permissions for the share + +Disk quota management is not included. + +Requirements +------------ + +None + +Role Variables +-------------- + +`sftp_username` — user account name on the sFTP server +`sftp_pubkey` — the public part of the SSH key the user will be using to connect to the server +`sftp_root` — directory to put the SFTP shares under +`sftp_transfers_groupname` — group owning `{{sftp_root}}/{{sftp_username}}-uploads/transfers` +`sftp_permissions` — list of permissions, see below for supported (default: `open,close,read,write,lstat,fstat,opendir,readdir,remove,mkdir,rmdir,realpath,rename`) + +List Of SFTP Operations +------------------------ +`open` — open a file for reading or writing +`close` — close an opened file +`read` — read data from a file +`write` — write data to a file +`lstat` — get file attributes without following symlinks +`fstat` — get attributes of an opened file +`setstat` — change file attributes (chmod, chown, etc.) +`fsetstat` — change attributes of an opened file +`opendir` — open a directory for reading +`readdir` — read the contents of a directory +`remove` — delete a file +`mkdir` — create a new directory +`rmdir` — remove an empty directory +`realpath` — resolve absolute path of a file or directory (inside chroot) +`stat` — get file attributes (follows symlinks) +`rename` — rename a file or directory +`readlink` — read the target of a symbolic link +`symlink` — create a symbolic link + +For example: + +- Default: `open,close,read,write,lstat,fstat,opendir,readdir,remove,mkdir,rmdir,realpath,rename` +- Read only: `open,close,read,lstat,fstat,opendir,readdir,realpath,stat` +- Required to establish an SFTP connection: `realpath` + +Dependencies +------------ + +None + +Example Playbook +---------------- + +See `molecule/default/converge.yml` for an example role invocation. + +License +------- + +BSD + +Author Information +------------------ + +malyuk.ss@genlab.llc diff --git a/roles/sftp_share/conda.dev.yml b/roles/sftp_share/conda.dev.yml new file mode 100644 index 0000000..421257c --- /dev/null +++ b/roles/sftp_share/conda.dev.yml @@ -0,0 +1,11 @@ +--- +name: ansible-sftp-share +channels: + - conda-forge +dependencies: + - python~=3.12.0 + - pip>=24.2 + - actionlint + - pip: + - -r requirements.txt + - -r requirements.ci.txt diff --git a/roles/sftp_share/conda.prod.yml b/roles/sftp_share/conda.prod.yml new file mode 100644 index 0000000..2f50d61 --- /dev/null +++ b/roles/sftp_share/conda.prod.yml @@ -0,0 +1,9 @@ +--- +name: ansible-sftp-share +channels: + - conda-forge +dependencies: + - python~=3.12.0 + - pip>=24.2 + - pip: + - -r requirements.txt diff --git a/roles/sftp_share/defaults/main.yml b/roles/sftp_share/defaults/main.yml new file mode 100644 index 0000000..d742061 --- /dev/null +++ b/roles/sftp_share/defaults/main.yml @@ -0,0 +1,2 @@ +--- +sftp_permissions: "open,close,read,write,lstat,fstat,opendir,readdir,remove,mkdir,rmdir,realpath,rename" diff --git a/roles/sftp_share/handlers/main.yml b/roles/sftp_share/handlers/main.yml new file mode 100644 index 0000000..46760ca --- /dev/null +++ b/roles/sftp_share/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart sshd + ansible.builtin.systemd_service: + name: ssh + state: restarted + enabled: true + daemon_reload: true diff --git a/roles/sftp_share/meta/main.yml b/roles/sftp_share/meta/main.yml new file mode 100644 index 0000000..06a754f --- /dev/null +++ b/roles/sftp_share/meta/main.yml @@ -0,0 +1,17 @@ +--- +galaxy_info: + role_name: "sftp_share" + namespace: genlab + author: "Sergey Malyuk" + company: "Genlab, LLC" + description: "" + license: "MIT" + min_ansible_version: "2.1" + + platforms: + - name: "Ubuntu" + versions: [ "focal", "jammy", "noble" ] + + galaxy_tags: [ ] + +dependencies: [] diff --git a/roles/sftp_share/molecule/default/converge.yml b/roles/sftp_share/molecule/default/converge.yml new file mode 100644 index 0000000..a6cba4e --- /dev/null +++ b/roles/sftp_share/molecule/default/converge.yml @@ -0,0 +1,39 @@ +--- +- name: Converge + hosts: all + + pre_tasks: + - name: Create test group + ansible.builtin.group: + name: testgrp + state: present + + - name: Ensure OpenSSH server is installed + ansible.builtin.apt: + name: openssh-server + state: present + update_cache: true + + - name: Ensure the `/root/.ssh` directory exists + ansible.builtin.file: + state: directory + path: "/root/.ssh" + mode: "0700" + owner: root + group: root + + - name: Provide an SSH private key for testing purposes + ansible.builtin.copy: + src: "ssh/id_ed25519" + dest: "/root/.ssh/id_ed25519" + mode: "0600" + owner: root + group: root + + roles: + - role: genlab.sftp_share + sftp_username: "testusr" + sftp_pubkey: "{{ lookup('file', 'ssh/id_ed25519.pub') }}" + sftp_root: "/primary/data" + sftp_transfers_groupname: "testgrp" + sftp_permissions: "open,close,read,lstat,fstat,opendir,readdir,realpath,stat" # read-only diff --git a/roles/sftp_share/molecule/default/molecule.yml b/roles/sftp_share/molecule/default/molecule.yml new file mode 100644 index 0000000..d82158e --- /dev/null +++ b/roles/sftp_share/molecule/default/molecule.yml @@ -0,0 +1,27 @@ +--- +dependency: + name: galaxy + +driver: + name: docker + +platforms: + - name: ubuntu + image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2404}-ansible:latest + pre_build_image: true + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + +provisioner: + name: ansible + +verifier: + name: ansible + +lint: | + set -e + yamllint . + ansible-lint . diff --git a/roles/sftp_share/molecule/default/ssh/id_ed25519 b/roles/sftp_share/molecule/default/ssh/id_ed25519 new file mode 100644 index 0000000..f18502b --- /dev/null +++ b/roles/sftp_share/molecule/default/ssh/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBSSKh3Oc0GgnvSzCmvloaoBVpa3/ZcEUhimzih81XMUAAAAJCpucRAqbnE +QAAAAAtzc2gtZWQyNTUxOQAAACBSSKh3Oc0GgnvSzCmvloaoBVpa3/ZcEUhimzih81XMUA +AAAEDKy8xJ6QdOj+DG1oromiQ0TmwnTWFcMwjUMdqIZFd8blJIqHc5zQaCe9LMKa+WhqgF +Wlrf9lwRSGKbOKHzVcxQAAAAC3Jvb3RAdWJ1bnR1AQI= +-----END OPENSSH PRIVATE KEY----- diff --git a/roles/sftp_share/molecule/default/ssh/id_ed25519.pub b/roles/sftp_share/molecule/default/ssh/id_ed25519.pub new file mode 100644 index 0000000..6c1eb0d --- /dev/null +++ b/roles/sftp_share/molecule/default/ssh/id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJIqHc5zQaCe9LMKa+WhqgFWlrf9lwRSGKbOKHzVcxQ root@ubuntu diff --git a/roles/sftp_share/molecule/default/verify.yml b/roles/sftp_share/molecule/default/verify.yml new file mode 100644 index 0000000..43a853e --- /dev/null +++ b/roles/sftp_share/molecule/default/verify.yml @@ -0,0 +1,91 @@ +--- +- name: Verify + hosts: all + gather_facts: false + any_errors_fatal: true + vars: + sftp_username: "testusr" + sftp_pubkey: "{{ lookup('file', 'ssh/id_ed25519.pub') }}" + sftp_root: "/primary/data" + sftp_transfers_groupname: "testgrp" + + tasks: + - name: Gather user SFTP directory info + ansible.builtin.stat: + path: "{{ sftp_root }}/{{ sftp_username }}-uploads" + register: sftp_root_stat + + - name: Assert that user SFTP directory has correct owner + ansible.builtin.assert: + that: + - sftp_root_stat.stat.pw_name == "root" + - sftp_root_stat.stat.gr_name == "root" + success_msg: "SFTP directory {{ sftp_root }}/{{ sftp_username }}-uploads has correct owner root:root" + fail_msg: | + "SFTP directory {{ sftp_root }}/{{ sftp_username }}-uploads does not have correct owner root:root, + actual owner is {{ sftp_root_stat.stat.pw_name }}:{{ sftp_root_stat.stat.gr_name }}" + + - name: Gather transfers directory info + ansible.builtin.stat: + path: "{{ sftp_root }}/{{ sftp_username }}-uploads/transfers" + register: transfers_stat + + - name: Assert that transfers directory has correct owner + ansible.builtin.assert: + that: + - transfers_stat.stat.pw_name == sftp_username + - transfers_stat.stat.gr_name == sftp_transfers_groupname + success_msg: "Directory {{ sftp_root }}/{{ sftp_username }}-uploads/transfers has correct owner {{ sftp_username }}:{{ sftp_transfers_groupname }}" + fail_msg: | + "Directory {{ sftp_root }}/{{ sftp_username }}-uploads/transfers does not have correct owner {{ sftp_username }}:{{ sftp_transfers_groupname }}, + actual owner is {{ transfers_stat.stat.pw_name }}:{{ transfers_stat.stat.gr_name }}" + + - name: Assert that transfers directory has correct permissions + ansible.builtin.assert: + that: + - transfers_stat.stat.mode == "2770" + success_msg: "Directory {{ sftp_root }}/{{ sftp_username }}-uploads/transfers has correct permissions 2770" + fail_msg: | + "Directory {{ sftp_root }}/{{ sftp_username }}-uploads/transfers does not have correct permissions 2770, + actual permissions are {{ sftp_root_stat.stat.mode }}" + + - name: Get user info + ansible.builtin.getent: + database: passwd + key: "{{ sftp_username }}" + register: user_info + + - name: Check user exists + ansible.builtin.assert: + that: + - user_info is defined + success_msg: "User {{ sftp_username }} exists" + fail_msg: "User {{ sftp_username }} does not exist" + + - name: Check that sshd refuses non-SFTP connection attempts + changed_when: false + failed_when: ssh.stdout != expected + register: ssh + vars: + expected: "This service allows sftp connections only." + ansible.builtin.command: "ssh -l {{ sftp_username }} 127.0.0.1 -o StrictHostKeyChecking=no exit" + + - name: Check that sFTP chroots into /transfers + changed_when: false + register: sftp + failed_when: sftp.stdout != expected + vars: + expected: "sftp> pwd\nRemote working directory: /transfers" + ansible.builtin.shell: + executable: /bin/bash + cmd: 'set -o pipefail; echo "pwd" | sftp -b - -o StrictHostKeyChecking=no {{ sftp_username | quote }}@127.0.0.1' + + - name: Check that non-whitelisted sFTP permissions are denied + changed_when: false + register: sftp_mkdir + failed_when: sftp_mkdir.stderr != expected + vars: + expected: 'remote mkdir "/transfers/foo": Permission denied' + ansible.builtin.shell: + executable: /bin/bash + cmd: 'set -o pipefail; echo "mkdir foo" | sftp -b - -o StrictHostKeyChecking=no {{ sftp_username | quote }}@127.0.0.1' diff --git a/roles/sftp_share/renovate.json b/roles/sftp_share/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/roles/sftp_share/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/roles/sftp_share/requirements.ci.txt b/roles/sftp_share/requirements.ci.txt new file mode 100644 index 0000000..f99c76a --- /dev/null +++ b/roles/sftp_share/requirements.ci.txt @@ -0,0 +1,6 @@ +ansible-lint +molecule==24.12.0 +molecule-plugins[docker] +docker~=7.1.0 +requests==2.31.0 # pinned to the latest version not breaking Docker SDK +yamllint diff --git a/roles/sftp_share/requirements.txt b/roles/sftp_share/requirements.txt new file mode 100644 index 0000000..b7e1d8c --- /dev/null +++ b/roles/sftp_share/requirements.txt @@ -0,0 +1 @@ +ansible~=11.11.0 diff --git a/roles/sftp_share/requirements.yml b/roles/sftp_share/requirements.yml new file mode 100644 index 0000000..3d5f1cd --- /dev/null +++ b/roles/sftp_share/requirements.yml @@ -0,0 +1,3 @@ +# requirements file +--- +collections: [] diff --git a/roles/sftp_share/tasks/main.yml b/roles/sftp_share/tasks/main.yml new file mode 100644 index 0000000..f88a5a8 --- /dev/null +++ b/roles/sftp_share/tasks/main.yml @@ -0,0 +1,38 @@ +--- +- name: Create SFTP user - {{ sftp_username }} + ansible.builtin.user: + name: "{{ sftp_username }}" + state: present + create_home: true + shell: /sbin/nologin + +- name: Create SFTP user upload directory - {{ sftp_username }} + ansible.builtin.file: + state: directory + path: "{{ sftp_root }}/{{ sftp_username }}-uploads/" + mode: '0755' + owner: root + group: root + +- name: Create transfers directory - {{ sftp_username }} + ansible.builtin.file: + state: directory + path: "{{ sftp_root }}/{{ sftp_username }}-uploads/transfers/" + mode: '2770' + owner: "{{ sftp_username }}" + group: "{{ sftp_transfers_groupname }}" + +- name: Push sshd config - {{ sftp_username }} + ansible.builtin.template: + src: sshd_config.conf.j2 + dest: "/etc/ssh/sshd_config.d/60-sftp-jail-{{ sftp_username }}.conf" + mode: '0600' + owner: "root" + group: "root" + notify: Restart sshd + +- name: Push public ssh key - {{ sftp_username }} + ansible.posix.authorized_key: + user: "{{ sftp_username }}" + key: "{{ sftp_pubkey }}" + state: present diff --git a/roles/sftp_share/templates/sshd_config.conf.j2 b/roles/sftp_share/templates/sshd_config.conf.j2 new file mode 100644 index 0000000..0496ef2 --- /dev/null +++ b/roles/sftp_share/templates/sshd_config.conf.j2 @@ -0,0 +1,5 @@ +Match User {{ sftp_username }} + ChrootDirectory /primary/data/{{ sftp_username }}-uploads + ForceCommand internal-sftp -u 007 -p {{ sftp_permissions }} -d /transfers + AllowTcpForwarding no + X11Forwarding no diff --git a/roles/sftp_share/vars/main.yml b/roles/sftp_share/vars/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/sftp_share/vars/main.yml @@ -0,0 +1 @@ +---