add sftp_share role

This commit is contained in:
Sergey Malyuk
2025-12-16 18:03:23 +03:00
parent bbcd68cb98
commit fbd36ee3fc
21 changed files with 371 additions and 0 deletions

View File

@@ -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/

3
roles/sftp_share/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.vscode
.idea
.ansible

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,9 @@
---
name: ansible-sftp-share
channels:
- conda-forge
dependencies:
- python~=3.12.0
- pip>=24.2
- pip:
- -r requirements.txt

View File

@@ -0,0 +1,2 @@
---
sftp_permissions: "open,close,read,write,lstat,fstat,opendir,readdir,remove,mkdir,rmdir,realpath,rename"

View File

@@ -0,0 +1,7 @@
---
- name: Restart sshd
ansible.builtin.systemd_service:
name: ssh
state: restarted
enabled: true
daemon_reload: true

View File

@@ -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: []

View File

@@ -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

View File

@@ -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 .

View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBSSKh3Oc0GgnvSzCmvloaoBVpa3/ZcEUhimzih81XMUAAAAJCpucRAqbnE
QAAAAAtzc2gtZWQyNTUxOQAAACBSSKh3Oc0GgnvSzCmvloaoBVpa3/ZcEUhimzih81XMUA
AAAEDKy8xJ6QdOj+DG1oromiQ0TmwnTWFcMwjUMdqIZFd8blJIqHc5zQaCe9LMKa+WhqgF
Wlrf9lwRSGKbOKHzVcxQAAAAC3Jvb3RAdWJ1bnR1AQI=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJIqHc5zQaCe9LMKa+WhqgFWlrf9lwRSGKbOKHzVcxQ root@ubuntu

View File

@@ -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'

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -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

View File

@@ -0,0 +1 @@
ansible~=11.11.0

View File

@@ -0,0 +1,3 @@
# requirements file
---
collections: []

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
---