Merge pull request #35 from corvus-migratorius/add-sftp-share

Add sftp share
This commit is contained in:
Fogucoco
2025-12-16 18:22:24 +03:00
committed by GitHub
14 changed files with 310 additions and 1 deletions

View File

@@ -17,6 +17,7 @@
- [nginx](roles/nginx/README.md)
- [prometheus](roles/prometheus/README.md)
- [rclone_yandex](roles/rclone_yandex/README.md)
- [sftp_share](roles/sftp_share/README.md)
- [smartctl_exporter](roles/smartctl_exporter/README.md)
- [ufw](roles/ufw/README.md)
- [wg_hub](roles/wg_hub/README.md)

View File

@@ -1,7 +1,7 @@
---
namespace: genlab
name: common
version: 0.16.0
version: 0.17.0
readme: README.md
authors:
- Alexander Gorelyshev (corvus-migratorius@proton.me)

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,2 @@
---
sftp_share_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.common.sftp_share
sftp_username: "testusr"
sftp_pubkey: "{{ lookup('file', 'ssh/id_ed25519.pub') }}"
sftp_root: "/primary/data"
sftp_transfers_groupname: "testgrp"
sftp_share_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_share_permissions
- name: Assert that user SFTP directory has correct owner
ansible.builtin.assert:
that:
- sftp_share_permissions.stat.pw_name == "root"
- sftp_share_permissions.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_share_permissions.stat.pw_name }}:{{ sftp_share_permissions.stat.gr_name }}"
- name: Gather transfers directory info
ansible.builtin.stat:
path: "{{ sftp_root }}/{{ sftp_username }}-uploads/transfers"
register: sftp_share_transfers_stat
- name: Assert that transfers directory has correct owner
ansible.builtin.assert:
that:
- sftp_share_transfers_stat.stat.pw_name == sftp_username
- sftp_share_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 {{ sftp_share_transfers_stat.stat.pw_name }}:{{ sftp_share_transfers_stat.stat.gr_name }}"
- name: Assert that transfers directory has correct permissions
ansible.builtin.assert:
that:
- sftp_share_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_share_permissions.stat.mode }}"
- name: Get user info
ansible.builtin.getent:
database: passwd
key: "{{ sftp_username }}"
register: sftp_share_user_info
- name: Check user exists
ansible.builtin.assert:
that:
- sftp_share_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: sftp_share_ssh.stdout != expected
register: sftp_share_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_share_sftp
failed_when: sftp_share_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_share_mkdir
failed_when: sftp_share_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,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_share_permissions }} -d /transfers
AllowTcpForwarding no
X11Forwarding no

View File

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