# SFTPGo NixOS test
#
# This NixOS test sets up a basic test scenario for the SFTPGo module
# and covers the following scenarios:
# - uploading a file via sftp
# - downloading the file over sftp
# - assert that the ACLs are respected
# - share a file between alice and bob (using sftp)
# - assert that eve cannot acceess the shared folder between alice and bob.
# Additional test coverage for the remaining protocols (i.e. ftp, http and webdav)
# would be a nice to have for the future.
{ pkgs, lib, ... }:
let
inherit (import ./ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;
# Returns an attributeset of users who are not system users.
normalUsers = config: lib.filterAttrs (name: user: user.isNormalUser) config.users.users;
# Returns true if a user is a member of the given group
isMemberOf =
config:
# str
groupName:
# users.users attrset
user:
lib.any (x: x == user.name) config.users.groups.${groupName}.members;
# Generates a valid SFTPGo user configuration for a given user
# Will be converted to JSON and loaded on application startup.
generateUserAttrSet =
# attrset returned by config.users.users.<username>
user: {
# 0: user is disabled, login is not allowed
# 1: user is enabled
status = 1;
username = user.name;
password = ""; # disables password authentication
public_keys = user.openssh.authorizedKeys.keys;
email = "${user.name}@example.com";
# User home directory on the local filesystem
home_dir = "${config.services.sftpgo.dataDir}/users/${user.name}";
# Defines a mapping between virtual SFTPGo paths and filesystem paths outside the user home directory.
# Supported for local filesystem only. If one or more of the specified folders are not
# inside the dataprovider they will be automatically created.
# You have to create the folder on the filesystem yourself
virtual_folders = lib.optional (isMemberOf config sharedFolderName user) {
name = sharedFolderName;
mapped_path = "${config.services.sftpgo.dataDir}/${sharedFolderName}";
virtual_path = "/${sharedFolderName}";
};
# Defines the ACL on the virtual filesystem
permissions =
lib.recursiveUpdate
{
"/" = [ "list" ]; # read-only top level directory
"/private" = [ "*" ]; # private subdirectory, not shared with others
}
(
lib.optionalAttrs (isMemberOf config "shared" user) {
"/shared" = [ "*" ];
);
filters = {
allowed_ip = [ ];
denied_ip = [ ];
web_client = [
"password-change-disabled"
"password-reset-disabled"
"api-key-auth-change-disabled"
];
upload_bandwidth = 0; # unlimited
download_bandwidth = 0; # unlimited
expiration_date = 0; # means no expiration
max_sessions = 0;
quota_size = 0;
quota_files = 0;
# Generates a json file containing a static configuration
# of users and folders to import to SFTPGo.
loadDataJson =
pkgs.writeText "users-and-folders.json" (
builtins.toJSON {
users = lib.mapAttrsToList (name: user: generateUserAttrSet config user) (normalUsers config);
folders = [
description = "shared folder";
# 0: local filesystem
# 1: AWS S3 compatible
# 2: Google Cloud Storage
filesystem.provider = 0;
# Mapped path on the local filesystem
# All users in the matching group gain access
users = config.users.groups.${sharedFolderName}.members;
# Generated Host Key for connecting to SFTPGo's sftp subsystem.
snakeOilHostKey = pkgs.writeText "sftpgo_ed25519_host_key" ''
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBOtQu6U135yxtrvUqPoozUymkjoNNPVK6rqjS936RLtQAAAJAXOMoSFzjK
EgAAAAtzc2gtZWQyNTUxOQAAACBOtQu6U135yxtrvUqPoozUymkjoNNPVK6rqjS936RLtQ
AAAEAoRLEV1VD80mg314ObySpfrCcUqtWoOSS3EtMPPhx08U61C7pTXfnLG2u9So+ijNTK
aSOg009UrquqNL3fpEu1AAAADHNmdHBnb0BuaXhvcwE=
-----END OPENSSH PRIVATE KEY-----
'';
adminUsername = "admin";
adminPassword = "secretadminpassword";
aliceUsername = "alice";
alicePassword = "secretalicepassword";
bobUsername = "bob";
bobPassword = "secretbobpassword";
eveUsername = "eve";
evePassword = "secretevepassword";
sharedFolderName = "shared";
# A file for testing uploading via SFTP
testFile = pkgs.writeText "test.txt" "hello world";
sharedFile = pkgs.writeText "shared.txt" "shared content";
# Define the for exposing SFTP
sftpPort = 2022;
# Define the for exposing HTTP
httpPort = 8080;
in
name = "sftpgo";
meta.maintainers = with lib.maintainers; [ yayayayaka ];
nodes = {
server =
{ nodes, ... }:
networking.firewall.allowedTCPPorts = [
sftpPort
httpPort
# nodes.server.configure postgresql database
services.postgresql = {
enable = true;
ensureDatabases = [ "sftpgo" ];
ensureUsers = [
ensureDBOwnership = true;
services.sftpgo = {
loadDataFile = (loadDataJson nodes.server);
settings = {
data_provider = {
driver = "postgresql";
username = "sftpgo";
host = "/run/postgresql";
port = 5432;
# Enables the possibility to create an initial admin user on first startup.
create_default_admin = true;
httpd.bindings = [
address = ""; # listen on all interfaces
port = httpPort;
enable_https = false;
enable_web_client = true;
enable_web_admin = true;
# Enable sftpd
sftpd = {
bindings = [
port = sftpPort;
host_keys = [ snakeOilHostKey ];
password_authentication = false;
keyboard_interactive_authentication = false;
systemd.services.sftpgo = {
after = [ "postgresql.service" ];
environment = {
# Update existing users
SFTPGO_LOADDATA_MODE = "0";
SFTPGO_DEFAULT_ADMIN_USERNAME = adminUsername;
# This will end up in cleartext in the systemd service.
# Don't use this approach in production!
SFTPGO_DEFAULT_ADMIN_PASSWORD = adminPassword;
# Sets up the folder hierarchy on the local filesystem
systemd.tmpfiles.rules =
sftpgoUser = nodes.server.services.sftpgo.user;
sftpgoGroup = nodes.server.services.sftpgo.group;
statePath = nodes.server.services.sftpgo.dataDir;
[
# Create state directory
"d ${statePath} 0750 ${sftpgoUser} ${sftpgoGroup} -"
"d ${statePath}/users 0750 ${sftpgoUser} ${sftpgoGroup} -"
# Created shared folder directories
"d ${statePath}/${sharedFolderName} 2770 ${sftpgoUser} ${sharedFolderName} -"
]
++ lib.mapAttrsToList (
name: user:
# Create private user directories
''
d ${statePath}/users/${user.name} 0700 ${sftpgoUser} ${sftpgoGroup} -
d ${statePath}/users/${user.name}/private 0700 ${sftpgoUser} ${sftpgoGroup} -
'') (normalUsers nodes.server);
users.users =
commonAttrs = {
isNormalUser = true;
openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
# SFTPGo admin user
admin = commonAttrs // {
password = adminPassword;
# Alice and bob share folders with each other
alice = commonAttrs // {
password = alicePassword;
extraGroups = [ sharedFolderName ];
bob = commonAttrs // {
password = bobPassword;
# Eve has no shared folders
eve = commonAttrs // {
password = evePassword;
users.groups.${sharedFolderName} = { };
specialisation = {
# A specialisation for asserting that SFTPGo can bind to privileged ports.
privilegedPorts.configuration =
{ ... }:
22
80
sftpd.bindings = lib.mkForce [
address = "";
port = 22;
httpd.bindings = lib.mkForce [
port = 80;
client =
# Add the SFTPGo host key to the global known_hosts file
programs.ssh.knownHosts =
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE61C7pTXfnLG2u9So+ijNTKaSOg009UrquqNL3fpEu1";
"server" = commonAttrs;
"[server]:2022" = commonAttrs;
testScript =
# A function to generate test cases for wheter
# a specified username is expected to access the shared folder.
accessSharedFoldersSubtest =
# The username to run as
username,
# Whether the tests are expected to succeed or not
shouldSucceed ? true,
}:
with subtest("Test whether ${username} can access shared folders"):
client.${
if shouldSucceed then "succeed" else "fail"
}("sftp -P ${toString sftpPort} -b ${pkgs.writeText "${username}-ls-${sharedFolderName}" ''
ls ${sharedFolderName}
''} ${username}@server")
start_all()
client.wait_for_unit("default.target")
server.wait_for_unit("sftpgo.service")
with subtest("web client"):
client.wait_until_succeeds("curl -sSf http://server:${toString httpPort}/web/client/login")
# Ensure sftpgo found the static folder
client.wait_until_succeeds("curl -o /dev/null -sSf http://server:${toString httpPort}/static/favicon.ico")
with subtest("Setup SSH keys"):
client.succeed("mkdir -m 700 /root/.ssh")
client.succeed("cat ${snakeOilPrivateKey} > /root/.ssh/id_ecdsa")
client.succeed("chmod 600 /root/.ssh/id_ecdsa")
with subtest("Copy a file over sftp"):
client.wait_until_succeeds("scp -P ${toString sftpPort} ${toString testFile} alice@server:/private/${testFile.name}")
server.succeed("test -s ${statePath}/users/alice/private/${testFile.name}")
# The configured ACL should prevent uploading files to the root directory
client.fail("scp -P ${toString sftpPort} ${toString testFile} alice@server:/")
with subtest("Attempting an interactive SSH sessions must fail"):
client.fail("ssh -p ${toString sftpPort} alice@server")
${accessSharedFoldersSubtest {
username = "alice";
shouldSucceed = true;
}}
username = "bob";
username = "eve";
shouldSucceed = false;
with subtest("Test sharing files"):
# Alice uploads a file to shared folder
client.succeed("scp -P ${toString sftpPort} ${toString sharedFile} alice@server:/${sharedFolderName}/${sharedFile.name}")
server.succeed("test -s ${statePath}/${sharedFolderName}/${sharedFile.name}")
# Bob downloads the file from shared folder
client.succeed("scp -P ${toString sftpPort} bob@server:/shared/${sharedFile.name} ${sharedFile.name}")
client.succeed("test -s ${sharedFile.name}")
# Eve should not get the file from shared folder
client.fail("scp -P ${toString sftpPort} eve@server:/shared/${sharedFile.name}")
server.succeed("/run/current-system/specialisation/privilegedPorts/bin/switch-to-configuration test")
client.wait_until_succeeds("sftp -P 22 -b ${pkgs.writeText "get-hello-world.txt" ''
get /private/${testFile.name}
''} alice@server")