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