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