summary refs log tree commit diff
path: root/nixos/tests/sftpgo.nix
blob: db0098d2ac48c2b126601799732985279fb9ca99 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# 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")
  '';
}