at 23.11-beta 13 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: 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}