# 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. 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") ''; }