nixpkgs/nixos/tests/sftpgo.nix
2023-10-07 00:10:50 +03:00

383 lines
12 KiB
Nix

# 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 =
config:
# 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 = config: pkgs.writeText "users-and-folders.json" (builtins.toJSON {
users =
lib.mapAttrsToList (name: user: generateUserAttrSet config user) (normalUsers config);
folders = [
{
name = sharedFolderName;
description = "shared folder";
# 0: local filesystem
# 1: AWS S3 compatible
# 2: Google Cloud Storage
filesystem.provider = 0;
# Mapped path on the local filesystem
mapped_path = "${config.services.sftpgo.dataDir}/${sharedFolderName}";
# 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 = [{
name = "sftpgo";
ensurePermissions."DATABASE sftpgo" = "ALL PRIVILEGES";
}];
};
services.sftpgo = {
enable = true;
loadDataFile = (loadDataJson nodes.server);
settings = {
data_provider = {
driver = "postgresql";
name = "sftpgo";
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 = [{
address = ""; # listen on all interfaces
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 =
let
sftpgoUser = nodes.server.services.sftpgo.user;
sftpgoGroup = nodes.server.services.sftpgo.group;
statePath = nodes.server.services.sftpgo.dataDir;
in [
# 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 =
let
commonAttrs = {
isNormalUser = true;
openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
};
in {
# 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;
extraGroups = [ sharedFolderName ];
};
# 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 = { ... }: {
networking.firewall.allowedTCPPorts = [ 22 80 ];
services.sftpgo = {
settings = {
sftpd.bindings = lib.mkForce [{
address = "";
port = 22;
}];
httpd.bindings = lib.mkForce [{
address = "";
port = 80;
}];
};
};
};
};
};
client = { nodes, ... }: {
# Add the SFTPGo host key to the global known_hosts file
programs.ssh.knownHosts =
let
commonAttrs = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE61C7pTXfnLG2u9So+ijNTKaSOg009UrquqNL3fpEu1";
};
in {
"server" = commonAttrs;
"[server]:2022" = commonAttrs;
};
};
};
testScript = { nodes, ... }: let
# 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")
'';
statePath = nodes.server.services.sftpgo.dataDir;
in ''
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;
}}
${accessSharedFoldersSubtest {
username = "bob";
shouldSucceed = true;
}}
${accessSharedFoldersSubtest {
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")
'';
}