1# SFTPGo NixOS test
2#
3# This NixOS test sets up a basic test scenario for the SFTPGo module
4# and covers the following scenarios:
5# - uploading a file via sftp
6# - downloading the file over sftp
7# - assert that the ACLs are respected
8# - share a file between alice and bob (using sftp)
9# - assert that eve cannot acceess the shared folder between alice and bob.
10#
11# Additional test coverage for the remaining protocols (i.e. ftp, http and webdav)
12# would be a nice to have for the future.
13{ pkgs, lib, ... }:
14
15let
16 inherit (import ./ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;
17
18 # Returns an attributeset of users who are not system users.
19 normalUsers = config:
20 lib.filterAttrs (name: user: user.isNormalUser) config.users.users;
21
22 # Returns true if a user is a member of the given group
23 isMemberOf =
24 config:
25 # str
26 groupName:
27 # users.users attrset
28 user:
29 lib.any (x: x == user.name) config.users.groups.${groupName}.members;
30
31 # Generates a valid SFTPGo user configuration for a given user
32 # Will be converted to JSON and loaded on application startup.
33 generateUserAttrSet =
34 config:
35 # attrset returned by config.users.users.<username>
36 user: {
37 # 0: user is disabled, login is not allowed
38 # 1: user is enabled
39 status = 1;
40
41 username = user.name;
42 password = ""; # disables password authentication
43 public_keys = user.openssh.authorizedKeys.keys;
44 email = "${user.name}@example.com";
45
46 # User home directory on the local filesystem
47 home_dir = "${config.services.sftpgo.dataDir}/users/${user.name}";
48
49 # Defines a mapping between virtual SFTPGo paths and filesystem paths outside the user home directory.
50 #
51 # Supported for local filesystem only. If one or more of the specified folders are not
52 # inside the dataprovider they will be automatically created.
53 # You have to create the folder on the filesystem yourself
54 virtual_folders =
55 lib.optional (isMemberOf config sharedFolderName user) {
56 name = sharedFolderName;
57 mapped_path = "${config.services.sftpgo.dataDir}/${sharedFolderName}";
58 virtual_path = "/${sharedFolderName}";
59 };
60
61 # Defines the ACL on the virtual filesystem
62 permissions =
63 lib.recursiveUpdate {
64 "/" = [ "list" ]; # read-only top level directory
65 "/private" = [ "*" ]; # private subdirectory, not shared with others
66 } (lib.optionalAttrs (isMemberOf config "shared" user) {
67 "/shared" = [ "*" ];
68 });
69
70 filters = {
71 allowed_ip = [];
72 denied_ip = [];
73 web_client = [
74 "password-change-disabled"
75 "password-reset-disabled"
76 "api-key-auth-change-disabled"
77 ];
78 };
79
80 upload_bandwidth = 0; # unlimited
81 download_bandwidth = 0; # unlimited
82 expiration_date = 0; # means no expiration
83 max_sessions = 0;
84 quota_size = 0;
85 quota_files = 0;
86 };
87
88 # Generates a json file containing a static configuration
89 # of users and folders to import to SFTPGo.
90 loadDataJson = config: pkgs.writeText "users-and-folders.json" (builtins.toJSON {
91 users =
92 lib.mapAttrsToList (name: user: generateUserAttrSet config user) (normalUsers config);
93
94 folders = [
95 {
96 name = sharedFolderName;
97 description = "shared folder";
98
99 # 0: local filesystem
100 # 1: AWS S3 compatible
101 # 2: Google Cloud Storage
102 filesystem.provider = 0;
103
104 # Mapped path on the local filesystem
105 mapped_path = "${config.services.sftpgo.dataDir}/${sharedFolderName}";
106
107 # All users in the matching group gain access
108 users = config.users.groups.${sharedFolderName}.members;
109 }
110 ];
111 });
112
113 # Generated Host Key for connecting to SFTPGo's sftp subsystem.
114 snakeOilHostKey = pkgs.writeText "sftpgo_ed25519_host_key" ''
115 -----BEGIN OPENSSH PRIVATE KEY-----
116 b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
117 QyNTUxOQAAACBOtQu6U135yxtrvUqPoozUymkjoNNPVK6rqjS936RLtQAAAJAXOMoSFzjK
118 EgAAAAtzc2gtZWQyNTUxOQAAACBOtQu6U135yxtrvUqPoozUymkjoNNPVK6rqjS936RLtQ
119 AAAEAoRLEV1VD80mg314ObySpfrCcUqtWoOSS3EtMPPhx08U61C7pTXfnLG2u9So+ijNTK
120 aSOg009UrquqNL3fpEu1AAAADHNmdHBnb0BuaXhvcwE=
121 -----END OPENSSH PRIVATE KEY-----
122 '';
123
124 adminUsername = "admin";
125 adminPassword = "secretadminpassword";
126 aliceUsername = "alice";
127 alicePassword = "secretalicepassword";
128 bobUsername = "bob";
129 bobPassword = "secretbobpassword";
130 eveUsername = "eve";
131 evePassword = "secretevepassword";
132 sharedFolderName = "shared";
133
134 # A file for testing uploading via SFTP
135 testFile = pkgs.writeText "test.txt" "hello world";
136 sharedFile = pkgs.writeText "shared.txt" "shared content";
137
138 # Define the for exposing SFTP
139 sftpPort = 2022;
140
141 # Define the for exposing HTTP
142 httpPort = 8080;
143in
144{
145 name = "sftpgo";
146
147 meta.maintainers = with lib.maintainers; [ yayayayaka ];
148
149 nodes = {
150 server = { nodes, ... }: {
151 networking.firewall.allowedTCPPorts = [ sftpPort httpPort ];
152
153 # nodes.server.configure postgresql database
154 services.postgresql = {
155 enable = true;
156 ensureDatabases = [ "sftpgo" ];
157 ensureUsers = [{
158 name = "sftpgo";
159 ensureDBOwnership = true;
160 }];
161 };
162
163 services.sftpgo = {
164 enable = true;
165
166 loadDataFile = (loadDataJson nodes.server);
167
168 settings = {
169 data_provider = {
170 driver = "postgresql";
171 name = "sftpgo";
172 username = "sftpgo";
173 host = "/run/postgresql";
174 port = 5432;
175
176 # Enables the possibility to create an initial admin user on first startup.
177 create_default_admin = true;
178 };
179
180 httpd.bindings = [
181 {
182 address = ""; # listen on all interfaces
183 port = httpPort;
184 enable_https = false;
185
186 enable_web_client = true;
187 enable_web_admin = true;
188 }
189 ];
190
191 # Enable sftpd
192 sftpd = {
193 bindings = [{
194 address = ""; # listen on all interfaces
195 port = sftpPort;
196 }];
197 host_keys = [ snakeOilHostKey ];
198 password_authentication = false;
199 keyboard_interactive_authentication = false;
200 };
201 };
202 };
203
204 systemd.services.sftpgo = {
205 after = [ "postgresql.service"];
206 environment = {
207 # Update existing users
208 SFTPGO_LOADDATA_MODE = "0";
209 SFTPGO_DEFAULT_ADMIN_USERNAME = adminUsername;
210
211 # This will end up in cleartext in the systemd service.
212 # Don't use this approach in production!
213 SFTPGO_DEFAULT_ADMIN_PASSWORD = adminPassword;
214 };
215 };
216
217 # Sets up the folder hierarchy on the local filesystem
218 systemd.tmpfiles.rules =
219 let
220 sftpgoUser = nodes.server.services.sftpgo.user;
221 sftpgoGroup = nodes.server.services.sftpgo.group;
222 statePath = nodes.server.services.sftpgo.dataDir;
223 in [
224 # Create state directory
225 "d ${statePath} 0750 ${sftpgoUser} ${sftpgoGroup} -"
226 "d ${statePath}/users 0750 ${sftpgoUser} ${sftpgoGroup} -"
227
228 # Created shared folder directories
229 "d ${statePath}/${sharedFolderName} 2770 ${sftpgoUser} ${sharedFolderName} -"
230 ]
231 ++ lib.mapAttrsToList (name: user:
232 # Create private user directories
233 ''
234 d ${statePath}/users/${user.name} 0700 ${sftpgoUser} ${sftpgoGroup} -
235 d ${statePath}/users/${user.name}/private 0700 ${sftpgoUser} ${sftpgoGroup} -
236 ''
237 ) (normalUsers nodes.server);
238
239 users.users =
240 let
241 commonAttrs = {
242 isNormalUser = true;
243 openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
244 };
245 in {
246 # SFTPGo admin user
247 admin = commonAttrs // {
248 password = adminPassword;
249 };
250
251 # Alice and bob share folders with each other
252 alice = commonAttrs // {
253 password = alicePassword;
254 extraGroups = [ sharedFolderName ];
255 };
256
257 bob = commonAttrs // {
258 password = bobPassword;
259 extraGroups = [ sharedFolderName ];
260 };
261
262 # Eve has no shared folders
263 eve = commonAttrs // {
264 password = evePassword;
265 };
266 };
267
268 users.groups.${sharedFolderName} = {};
269
270 specialisation = {
271 # A specialisation for asserting that SFTPGo can bind to privileged ports.
272 privilegedPorts.configuration = { ... }: {
273 networking.firewall.allowedTCPPorts = [ 22 80 ];
274 services.sftpgo = {
275 settings = {
276 sftpd.bindings = lib.mkForce [{
277 address = "";
278 port = 22;
279 }];
280
281 httpd.bindings = lib.mkForce [{
282 address = "";
283 port = 80;
284 }];
285 };
286 };
287 };
288 };
289 };
290
291 client = { nodes, ... }: {
292 # Add the SFTPGo host key to the global known_hosts file
293 programs.ssh.knownHosts =
294 let
295 commonAttrs = {
296 publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE61C7pTXfnLG2u9So+ijNTKaSOg009UrquqNL3fpEu1";
297 };
298 in {
299 "server" = commonAttrs;
300 "[server]:2022" = commonAttrs;
301 };
302 };
303 };
304
305 testScript = { nodes, ... }: let
306 # A function to generate test cases for wheter
307 # a specified username is expected to access the shared folder.
308 accessSharedFoldersSubtest =
309 { # The username to run as
310 username
311 # Whether the tests are expected to succeed or not
312 , shouldSucceed ? true
313 }: ''
314 with subtest("Test whether ${username} can access shared folders"):
315 client.${if shouldSucceed then "succeed" else "fail"}("sftp -P ${toString sftpPort} -b ${
316 pkgs.writeText "${username}-ls-${sharedFolderName}" ''
317 ls ${sharedFolderName}
318 ''
319 } ${username}@server")
320 '';
321 statePath = nodes.server.services.sftpgo.dataDir;
322 in ''
323 start_all()
324
325 client.wait_for_unit("default.target")
326 server.wait_for_unit("sftpgo.service")
327
328 with subtest("web client"):
329 client.wait_until_succeeds("curl -sSf http://server:${toString httpPort}/web/client/login")
330
331 # Ensure sftpgo found the static folder
332 client.wait_until_succeeds("curl -o /dev/null -sSf http://server:${toString httpPort}/static/favicon.ico")
333
334 with subtest("Setup SSH keys"):
335 client.succeed("mkdir -m 700 /root/.ssh")
336 client.succeed("cat ${snakeOilPrivateKey} > /root/.ssh/id_ecdsa")
337 client.succeed("chmod 600 /root/.ssh/id_ecdsa")
338
339 with subtest("Copy a file over sftp"):
340 client.wait_until_succeeds("scp -P ${toString sftpPort} ${toString testFile} alice@server:/private/${testFile.name}")
341 server.succeed("test -s ${statePath}/users/alice/private/${testFile.name}")
342
343 # The configured ACL should prevent uploading files to the root directory
344 client.fail("scp -P ${toString sftpPort} ${toString testFile} alice@server:/")
345
346 with subtest("Attempting an interactive SSH sessions must fail"):
347 client.fail("ssh -p ${toString sftpPort} alice@server")
348
349 ${accessSharedFoldersSubtest {
350 username = "alice";
351 shouldSucceed = true;
352 }}
353
354 ${accessSharedFoldersSubtest {
355 username = "bob";
356 shouldSucceed = true;
357 }}
358
359 ${accessSharedFoldersSubtest {
360 username = "eve";
361 shouldSucceed = false;
362 }}
363
364 with subtest("Test sharing files"):
365 # Alice uploads a file to shared folder
366 client.succeed("scp -P ${toString sftpPort} ${toString sharedFile} alice@server:/${sharedFolderName}/${sharedFile.name}")
367 server.succeed("test -s ${statePath}/${sharedFolderName}/${sharedFile.name}")
368
369 # Bob downloads the file from shared folder
370 client.succeed("scp -P ${toString sftpPort} bob@server:/shared/${sharedFile.name} ${sharedFile.name}")
371 client.succeed("test -s ${sharedFile.name}")
372
373 # Eve should not get the file from shared folder
374 client.fail("scp -P ${toString sftpPort} eve@server:/shared/${sharedFile.name}")
375
376 server.succeed("/run/current-system/specialisation/privilegedPorts/bin/switch-to-configuration test")
377
378 client.wait_until_succeeds("sftp -P 22 -b ${pkgs.writeText "get-hello-world.txt" ''
379 get /private/${testFile.name}
380 ''} alice@server")
381 '';
382}