nixos/tests/sftpgo: init
This commit is contained in:
parent
f63f781063
commit
931a1b97f7
|
@ -665,6 +665,7 @@ in {
|
||||||
seafile = handleTest ./seafile.nix {};
|
seafile = handleTest ./seafile.nix {};
|
||||||
searx = handleTest ./searx.nix {};
|
searx = handleTest ./searx.nix {};
|
||||||
service-runner = handleTest ./service-runner.nix {};
|
service-runner = handleTest ./service-runner.nix {};
|
||||||
|
sftpgo = runTest ./sftpgo.nix;
|
||||||
sfxr-qt = handleTest ./sfxr-qt.nix {};
|
sfxr-qt = handleTest ./sfxr-qt.nix {};
|
||||||
sgtpuzzles = handleTest ./sgtpuzzles.nix {};
|
sgtpuzzles = handleTest ./sgtpuzzles.nix {};
|
||||||
shadow = handleTest ./shadow.nix {};
|
shadow = handleTest ./shadow.nix {};
|
||||||
|
|
384
nixos/tests/sftpgo.nix
Normal file
384
nixos/tests/sftpgo.nix
Normal file
|
@ -0,0 +1,384 @@
|
||||||
|
# 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, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
let
|
||||||
|
inherit (import ./ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;
|
||||||
|
|
||||||
|
# Returns an attributeset of users who are not system users.
|
||||||
|
normalUsers = config:
|
||||||
|
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:
|
||||||
|
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 =
|
||||||
|
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 =
|
||||||
|
recursiveUpdate {
|
||||||
|
"/" = [ "list" ]; # read-only top level directory
|
||||||
|
"/private" = [ "*" ]; # private subdirectory, not shared with others
|
||||||
|
} (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 =
|
||||||
|
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 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} -"
|
||||||
|
]
|
||||||
|
++ 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 = mkForce [{
|
||||||
|
address = "";
|
||||||
|
port = 22;
|
||||||
|
}];
|
||||||
|
|
||||||
|
httpd.bindings = 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")
|
||||||
|
'';
|
||||||
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
, buildGoModule
|
, buildGoModule
|
||||||
, fetchFromGitHub
|
, fetchFromGitHub
|
||||||
, installShellFiles
|
, installShellFiles
|
||||||
|
, nixosTests
|
||||||
}:
|
}:
|
||||||
|
|
||||||
buildGoModule rec {
|
buildGoModule rec {
|
||||||
|
@ -44,6 +45,8 @@ buildGoModule rec {
|
||||||
cp -r ./{openapi,static,templates} "$shareDirectory"
|
cp -r ./{openapi,static,templates} "$shareDirectory"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
passthru.tests = nixosTests.sftpgo;
|
||||||
|
|
||||||
meta = with lib; {
|
meta = with lib; {
|
||||||
homepage = "https://github.com/drakkan/sftpgo";
|
homepage = "https://github.com/drakkan/sftpgo";
|
||||||
changelog = "https://github.com/drakkan/sftpgo/releases/tag/v${version}";
|
changelog = "https://github.com/drakkan/sftpgo/releases/tag/v${version}";
|
||||||
|
|
Loading…
Reference in a new issue