at 25.11-pre 14 kB view raw
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}