diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index c2519b776b3a..a4e4679a2ccd 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -665,6 +665,7 @@ in { seafile = handleTest ./seafile.nix {}; searx = handleTest ./searx.nix {}; service-runner = handleTest ./service-runner.nix {}; + sftpgo = runTest ./sftpgo.nix; sfxr-qt = handleTest ./sfxr-qt.nix {}; sgtpuzzles = handleTest ./sgtpuzzles.nix {}; shadow = handleTest ./shadow.nix {}; diff --git a/nixos/tests/sftpgo.nix b/nixos/tests/sftpgo.nix new file mode 100644 index 000000000000..ca55b9c962a0 --- /dev/null +++ b/nixos/tests/sftpgo.nix @@ -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. + 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") + ''; +} diff --git a/pkgs/servers/sftpgo/default.nix b/pkgs/servers/sftpgo/default.nix index 4c4e7d22ad83..0a3a859ce62a 100644 --- a/pkgs/servers/sftpgo/default.nix +++ b/pkgs/servers/sftpgo/default.nix @@ -2,6 +2,7 @@ , buildGoModule , fetchFromGitHub , installShellFiles +, nixosTests }: buildGoModule rec { @@ -44,6 +45,8 @@ buildGoModule rec { cp -r ./{openapi,static,templates} "$shareDirectory" ''; + passthru.tests = nixosTests.sftpgo; + meta = with lib; { homepage = "https://github.com/drakkan/sftpgo"; changelog = "https://github.com/drakkan/sftpgo/releases/tag/v${version}";