diff --git a/README.md b/README.md index 10843b9f..24b9f47d 100644 --- a/README.md +++ b/README.md @@ -1193,32 +1193,62 @@ for this setup, you will need a [flake-enabled](https://nixos.wiki/wiki/Flakes) } ``` -copyparty on NixOS is configured via `services.copyparty.config`, for example: +copyparty on NixOS is configured via `services.copyparty` options, for example: ```nix services.copyparty = { enable = true; - config = '' - [global] - i: 0.0.0.0 - no-reload + # directly maps to values in the [global] section of the copyparty config. + # see `copyparty --help` for available options + settings = { + i = "0.0.0.0"; + # use lists to set multiple values + p = [ 3210 3211 ]; + # use booleans to set binary flags + no-reload = true; + # using 'false' will do nothing and omit the value when generating a config + ignored-flag = false; + }; - # create users - [accounts] - # username: password - ed: 123 + # create users + accounts = { + # specify the account name as the key + ed = { + # provide the path to a file containing the password, keeping it out of /nix/store + # must be readable by the copyparty service user + passwordFile = "/run/keys/copyparty/ed_password"; + }; + # or do both in one go + k.passwordFile = "/run/keys/copyparty/k_password"; + }; - # create a volume - [/] # create a volume at "/" (the webroot), which will - /srv/copyparty # share the contents of "/srv/copyparty" - accs: - r: * # everyone gets read-access, but - rw: ed # the user "ed" gets read-write - ''; - # the service runs in an isolated environment by default - # any directory you reference in the volume configuration - # needs to be added here, in order to make it discoverable - # with the exception of /var/lib/copyparty, which is always available - readWritePaths = [ "/srv/copyparty" ]; + # create a volume + volumes = { + # create a volume at "/" (the webroot), which will + "/" = { + # share the contents of "/srv/copyparty" + path = "/srv/copyparty"; + # see `copyparty --help-accounts` for available options + access = { + # everyone gets read-access, but + r = "*"; + # users "ed" and "k" get read-write + rw = [ "ed" "k" ]; + }; + # see `copyparty --help-flags` for available options + flags = { + # "fk" enables filekeys (necessary for upget permission) (4 chars long) + fk = 4; + # scan for new files every 60sec + scan = 60; + # volflag "e2d" enables the uploads database + e2d = true; + # "d2t" disables multimedia parsers (in case the uploads are malicious) + d2t = true; + # skips hashing file contents if path matches *.iso + nohash = "\.iso$"; + }; + }; + }; # you may increase the open file limit for the process openFilesLimit = 8192; }; diff --git a/contrib/nixos/modules/copyparty.nix b/contrib/nixos/modules/copyparty.nix index ca708d70..5c7d65c2 100644 --- a/contrib/nixos/modules/copyparty.nix +++ b/contrib/nixos/modules/copyparty.nix @@ -3,11 +3,57 @@ with lib; let + mkKeyValue = key: value: + if value == true then + # sets with a true boolean value are coerced to just the key name + key + else if value == false then + # or omitted completely when false + "" + else + (generators.mkKeyValueDefault { inherit mkValueString; } ": " key value); + + mkAttrsString = value: (generators.toKeyValue { inherit mkKeyValue; } value); + + mkValueString = value: + if isList value then + (concatStringsSep ", " (map mkValueString value)) + else if isAttrs value then + "\n" + (mkAttrsString value) + else + (generators.mkValueStringDefault { } value); + + mkSectionName = value: "[" + (escape [ "[" "]" ] value) + "]"; + + mkSection = name: attrs: '' + ${mkSectionName name} + ${mkAttrsString attrs} + ''; + + mkVolume = name: attrs: '' + ${mkSectionName name} + ${attrs.path} + ${mkAttrsString { + accs = attrs.access; + flags = attrs.flags; + }} + ''; + + passwordPlaceholder = name: "{{password-${name}}}"; + + accountsWithPlaceholders = mapAttrs (name: attrs: passwordPlaceholder name); + + configStr = '' + ${mkSection "global" cfg.settings} + ${mkSection "accounts" (accountsWithPlaceholders cfg.accounts)} + ${concatStringsSep "\n" (mapAttrsToList mkVolume cfg.volumes)} + ''; + name = "copyparty"; cfg = config.services.copyparty; - configFile = pkgs.writeText "copyparty.conf" cfg.config; - bin = "${cfg.package}/bin/${name}"; - home = "/var/lib/copyparty"; + configFile = pkgs.writeText "${name}.conf" configStr; + runtimeConfigPath = "/run/${name}/${name}.conf"; + home = "/var/lib/${name}"; defaultShareDir = "${home}/data"; in { options.services.copyparty = { @@ -22,49 +68,136 @@ in { ''; }; - readWritePaths = mkOption { - default = [ ]; - type = types.listOf types.str; - description = "Paths permitted for read/write."; - }; - openFilesLimit = mkOption { default = 4096; type = types.either types.int types.str; description = "Number of files to allow copyparty to open."; }; - config = mkOption { - type = types.lines; - description = - "Configuration file. See https://github.com/9001/copyparty#server-config for reference"; - default = '' - [global] - i: 127.0.0.1 - no-reload - - # create a volume: - [/] # create a volume at "/" (the webroot), which will - ${defaultShareDir} # share the contents of "${defaultShareDir}" - accs: - r: * # everyone gets read-access, but + settings = mkOption { + type = types.attrs; + description = '' + Global settings to apply. + Directly maps to values in the [global] section of the copyparty config. + See `${getExe cfg.package} --help` for more details. ''; - example = '' - [global] - i: 0.0.0.0 - no-reload + default = { + i = "127.0.0.1"; + no-reload = true; + }; + example = literalExpression '' + { + i = "0.0.0.0"; + no-reload = true; + } + ''; + }; - # create users: - [accounts] - # username: password - ed: 123 + accounts = mkOption { + type = types.attrsOf (types.submodule ({ ... }: { + options = { + passwordFile = mkOption { + type = types.str; + description = '' + Runtime file path to a file containing the user password. + Must be readable by the copyparty user. + ''; + example = "/run/keys/copyparty/ed"; + }; + }; + })); + description = '' + A set of copyparty accounts to create. + ''; + default = { }; + example = literalExpression '' + { + ed.passwordFile = "/run/keys/copyparty/ed"; + }; + ''; + }; - # create a volume: - [/] # create a volume at "/" (the webroot), which will - ${defaultShareDir} # share the contents of "${defaultShareDir}" - accs: - r: * # everyone gets read-access, but - rw: ed # the user "ed" gets read-write + volumes = mkOption { + type = types.attrsOf (types.submodule ({ ... }: { + options = { + path = mkOption { + type = types.str; + description = '' + Path of a directory to share. + ''; + }; + access = mkOption { + type = types.attrs; + description = '' + Attribute list of permissions and the users to apply them to. + + The key must be a string containing any combination of allowed permission: + "r" (read): list folder contents, download files + "w" (write): upload files; need "r" to see the uploads + "m" (move): move files and folders; need "w" at destination + "d" (delete): permanently delete files and folders + "g" (get): download files, but cannot see folder contents + "G" (upget): "get", but can see filekeys of their own uploads + + For example: "rwmd" + + The value must be one of: + an account name, defined in `accounts` + a list of account names + "*", which means "any account" + ''; + example = literalExpression '' + { + # wG = write-upget = see your own uploads only + wG = "*"; + # read-write-modify-delete for users "ed" and "k" + rwmd = ["ed" "k"]; + }; + ''; + }; + flags = mkOption { + type = types.attrs; + description = '' + Attribute list of volume flags to apply. + See `${getExe cfg.package} --help-flags` for more details. + ''; + example = literalExpression '' + { + # "fk" enables filekeys (necessary for upget permission) (4 chars long) + fk = 4; + # scan for new files every 60sec + scan = 60; + # volflag "e2d" enables the uploads database + e2d = true; + # "d2t" disables multimedia parsers (in case the uploads are malicious) + d2t = true; + # skips hashing file contents if path matches *.iso + nohash = "\.iso$"; + }; + ''; + default = { }; + }; + }; + })); + description = "A set of copyparty volumes to create"; + default = { + "/" = { + path = defaultShareDir; + access = { r = "*"; }; + }; + }; + example = literalExpression '' + { + "/" = { + path = ${defaultShareDir}; + access = { + # wG = write-upget = see your own uploads only + wG = "*"; + # read-write-modify-delete for users "ed" and "k" + rwmd = ["ed" "k"]; + }; + }; + }; ''; }; }; @@ -79,20 +212,29 @@ in { XDG_CONFIG_HOME = "${home}/.config"; }; - preStart = '' - mkdir -p "$XDG_CONFIG_HOME" - mkdir -p "${defaultShareDir}" + preStart = let + replaceSecretCommand = name: attrs: + "${getExe pkgs.replace-secret} '${ + passwordPlaceholder name + }' '${attrs.passwordFile}' ${runtimeConfigPath}"; + in '' + set -euo pipefail + install -m 600 ${configFile} ${runtimeConfigPath} + ${concatStringsSep "\n" + (mapAttrsToList replaceSecretCommand cfg.accounts)} ''; serviceConfig = { Type = "simple"; - ExecStart = "${bin} -c ${configFile}"; + ExecStart = "${getExe cfg.package} -c ${runtimeConfigPath}"; # Hardening options User = "copyparty"; Group = "copyparty"; - StateDirectory = "copyparty"; - StateDirectoryMode = "0755"; + RuntimeDirectory = name; + RuntimeDirectoryMode = "0700"; + StateDirectory = [ name "${name}/data" "${name}/.config" ]; + StateDirectoryMode = "0700"; WorkingDirectory = home; TemporaryFileSystem = "/:ro"; BindReadOnlyPaths = [ @@ -101,8 +243,8 @@ in { "-/etc/nsswitch.conf" "-/etc/hosts" "-/etc/localtime" - ]; - BindPaths = [ home ] ++ cfg.readWritePaths; + ] ++ (mapAttrsToList (k: v: "-${v.passwordFile}") cfg.accounts); + BindPaths = [ home ] ++ (mapAttrsToList (k: v: v.path) cfg.volumes); # Would re-mount paths ignored by temporary root #ProtectSystem = "strict"; ProtectHome = true; @@ -125,7 +267,6 @@ in { NoNewPrivileges = true; LockPersonality = true; RestrictRealtime = true; - RestrictAddressFamilies = "AF_INET AF_INET6"; }; };