summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
authorYaya <mak@nyantec.com>2023-04-19 13:13:54 +0000
committerYaya <mak@nyantec.com>2023-05-30 12:31:40 +0000
commit8cc61b1760c5da94b9c101434db589753ec9a822 (patch)
treea7447722049686005e0caa4635eee3e269de8226 /nixos
parentb092d789335cc042e5dd4e71c5ff566f30bb1ebb (diff)
nixos/tests/sftpgo: init
(cherry picked from commit e0444dd55fb00709ed6e4df7ce0814a3be4f0e23)
Diffstat (limited to 'nixos')
-rw-r--r--nixos/tests/all-tests.nix1
-rw-r--r--nixos/tests/sftpgo.nix384
2 files changed, 385 insertions, 0 deletions
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index af541737e12f4..1eae0038ce80d 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -664,6 +664,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 0000000000000..ca55b9c962a07
--- /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.<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")
+  '';
+}