add sftp_share role
This commit is contained in:
16
roles/sftp_share/.ansible-lint
Normal file
16
roles/sftp_share/.ansible-lint
Normal 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
3
roles/sftp_share/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.vscode
|
||||
.idea
|
||||
.ansible
|
||||
8
roles/sftp_share/.yamllint
Normal file
8
roles/sftp_share/.yamllint
Normal 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
|
||||
73
roles/sftp_share/README.md
Normal file
73
roles/sftp_share/README.md
Normal 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
|
||||
11
roles/sftp_share/conda.dev.yml
Normal file
11
roles/sftp_share/conda.dev.yml
Normal 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
|
||||
9
roles/sftp_share/conda.prod.yml
Normal file
9
roles/sftp_share/conda.prod.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: ansible-sftp-share
|
||||
channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- python~=3.12.0
|
||||
- pip>=24.2
|
||||
- pip:
|
||||
- -r requirements.txt
|
||||
2
roles/sftp_share/defaults/main.yml
Normal file
2
roles/sftp_share/defaults/main.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
---
|
||||
sftp_permissions: "open,close,read,write,lstat,fstat,opendir,readdir,remove,mkdir,rmdir,realpath,rename"
|
||||
7
roles/sftp_share/handlers/main.yml
Normal file
7
roles/sftp_share/handlers/main.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
- name: Restart sshd
|
||||
ansible.builtin.systemd_service:
|
||||
name: ssh
|
||||
state: restarted
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
17
roles/sftp_share/meta/main.yml
Normal file
17
roles/sftp_share/meta/main.yml
Normal 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: []
|
||||
39
roles/sftp_share/molecule/default/converge.yml
Normal file
39
roles/sftp_share/molecule/default/converge.yml
Normal 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
|
||||
27
roles/sftp_share/molecule/default/molecule.yml
Normal file
27
roles/sftp_share/molecule/default/molecule.yml
Normal 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 .
|
||||
7
roles/sftp_share/molecule/default/ssh/id_ed25519
Normal file
7
roles/sftp_share/molecule/default/ssh/id_ed25519
Normal file
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBSSKh3Oc0GgnvSzCmvloaoBVpa3/ZcEUhimzih81XMUAAAAJCpucRAqbnE
|
||||
QAAAAAtzc2gtZWQyNTUxOQAAACBSSKh3Oc0GgnvSzCmvloaoBVpa3/ZcEUhimzih81XMUA
|
||||
AAAEDKy8xJ6QdOj+DG1oromiQ0TmwnTWFcMwjUMdqIZFd8blJIqHc5zQaCe9LMKa+WhqgF
|
||||
Wlrf9lwRSGKbOKHzVcxQAAAAC3Jvb3RAdWJ1bnR1AQI=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
1
roles/sftp_share/molecule/default/ssh/id_ed25519.pub
Normal file
1
roles/sftp_share/molecule/default/ssh/id_ed25519.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJIqHc5zQaCe9LMKa+WhqgFWlrf9lwRSGKbOKHzVcxQ root@ubuntu
|
||||
91
roles/sftp_share/molecule/default/verify.yml
Normal file
91
roles/sftp_share/molecule/default/verify.yml
Normal 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'
|
||||
6
roles/sftp_share/renovate.json
Normal file
6
roles/sftp_share/renovate.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
6
roles/sftp_share/requirements.ci.txt
Normal file
6
roles/sftp_share/requirements.ci.txt
Normal 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
|
||||
1
roles/sftp_share/requirements.txt
Normal file
1
roles/sftp_share/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
ansible~=11.11.0
|
||||
3
roles/sftp_share/requirements.yml
Normal file
3
roles/sftp_share/requirements.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
# requirements file
|
||||
---
|
||||
collections: []
|
||||
38
roles/sftp_share/tasks/main.yml
Normal file
38
roles/sftp_share/tasks/main.yml
Normal 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
|
||||
5
roles/sftp_share/templates/sshd_config.conf.j2
Normal file
5
roles/sftp_share/templates/sshd_config.conf.j2
Normal 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
|
||||
1
roles/sftp_share/vars/main.yml
Normal file
1
roles/sftp_share/vars/main.yml
Normal file
@@ -0,0 +1 @@
|
||||
---
|
||||
Reference in New Issue
Block a user