diff --git a/contrib/nixos/modules/copyparty.nix b/contrib/nixos/modules/copyparty.nix index 2f13f07b..cd4fef40 100644 --- a/contrib/nixos/modules/copyparty.nix +++ b/contrib/nixos/modules/copyparty.nix @@ -4,28 +4,31 @@ lib, ... }: -with lib; let - mkKeyValue = key: value: - if value == true - then +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 + else if value == false then # or omitted completely when false "" - else (generators.mkKeyValueDefault {inherit mkValueString;} ": " key value); + else + (generators.mkKeyValueDefault { inherit mkValueString; } ": " key value); - mkAttrsString = value: (generators.toKeyValue {inherit mkKeyValue;} 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); + 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) + "]"; + mkSectionName = value: "[" + (escape [ "[" "]" ] value) + "]"; mkSection = name: attrs: '' ${mkSectionName name} @@ -57,7 +60,8 @@ with lib; let externalCacheDir = "/var/cache/copyparty"; externalStateDir = "/var/lib/copyparty"; defaultShareDir = "${externalStateDir}/data"; -in { +in +{ options.services.copyparty = { enable = mkEnableOption "web-based file manager"; @@ -128,22 +132,27 @@ in { }; 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"; - }; - }; - })); + 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 = {}; + default = { }; example = literalExpression '' { ed.passwordFile = "/run/keys/copyparty/ed"; @@ -152,74 +161,81 @@ in { }; volumes = mkOption { - type = types.attrsOf (types.submodule ({...}: { - options = { - path = mkOption { - type = types.path; - 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 - "h" (html): "get", but folders return their index.html - "a" (admin): can see uploader IPs, config-reload - - 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"]; + type = types.attrsOf ( + types.submodule ( + { ... }: + { + options = { + path = mkOption { + type = types.path; + description = '' + Path of a directory to share. + ''; }; - ''; - }; - 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$"; + 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 + "h" (html): "get", but folders return their index.html + "a" (admin): can see uploader IPs, config-reload + + 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"]; + }; + ''; }; - ''; - default = {}; - }; - }; - })); + 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 = "*";}; + access = { + r = "*"; + }; }; }; example = literalExpression '' @@ -238,93 +254,90 @@ in { }; }; - config = mkIf cfg.enable (let - command = "${getExe cfg.package} -c ${runtimeConfigPath}"; - in { - systemd.services.copyparty = { - description = "http file sharing hub"; - wantedBy = ["multi-user.target"]; + config = mkIf cfg.enable ( + let + command = "${getExe cfg.package} -c ${runtimeConfigPath}"; + in + { + systemd.services.copyparty = { + description = "http file sharing hub"; + wantedBy = [ "multi-user.target" ]; - environment = { - PYTHONUNBUFFERED = "true"; - XDG_CONFIG_HOME = externalStateDir; - }; + environment = { + PYTHONUNBUFFERED = "true"; + XDG_CONFIG_HOME = externalStateDir; + }; - 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)} - ''; + 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 = command; - # Hardening options - User = cfg.user; - Group = cfg.group; - RuntimeDirectory = ["copyparty"]; - RuntimeDirectoryMode = "0700"; - StateDirectory = ["copyparty"]; - StateDirectoryMode = "0700"; - CacheDirectory = lib.mkIf (cfg.settings ? hist) ["copyparty"]; - CacheDirectoryMode = lib.mkIf (cfg.settings ? hist) "0700"; - WorkingDirectory = externalStateDir; - BindReadOnlyPaths = - [ + serviceConfig = { + Type = "simple"; + ExecStart = command; + # Hardening options + User = cfg.user; + Group = cfg.group; + RuntimeDirectory = [ "copyparty" ]; + RuntimeDirectoryMode = "0700"; + StateDirectory = [ "copyparty" ]; + StateDirectoryMode = "0700"; + CacheDirectory = lib.mkIf (cfg.settings ? hist) [ "copyparty" ]; + CacheDirectoryMode = lib.mkIf (cfg.settings ? hist) "0700"; + WorkingDirectory = externalStateDir; + BindReadOnlyPaths = [ "/nix/store" "-/etc/resolv.conf" "-/etc/nsswitch.conf" "-/etc/group" "-/etc/hosts" "-/etc/localtime" - ] - ++ (mapAttrsToList (k: v: "-${v.passwordFile}") cfg.accounts); - BindPaths = - ( - if cfg.settings ? hist - then [cfg.settings.hist] - else [] - ) - ++ [externalStateDir] - ++ (mapAttrsToList (k: v: v.path) cfg.volumes); - # ProtectSystem = "strict"; - # Note that unlike what 'ro' implies, - # this actually makes it impossible to read anything in the root FS, - # except for things explicitly mounted via `RuntimeDirectory`, `StateDirectory`, `CacheDirectory`, and `BindReadOnlyPaths`. - # This is because TemporaryFileSystem creates a *new* *empty* filesystem for the process, so only bindmounts are visible. - TemporaryFileSystem = "/:ro"; - PrivateTmp = true; - PrivateDevices = true; - ProtectKernelTunables = true; - ProtectControlGroups = true; - RestrictSUIDSGID = true; - PrivateMounts = true; - ProtectKernelModules = true; - ProtectKernelLogs = true; - ProtectHostname = true; - ProtectClock = true; - ProtectProc = "invisible"; - ProcSubset = "pid"; - RestrictNamespaces = true; - RemoveIPC = true; - UMask = "0077"; - LimitNOFILE = cfg.openFilesLimit; - NoNewPrivileges = true; - LockPersonality = true; - RestrictRealtime = true; - MemoryDenyWriteExecute = true; + ] ++ (mapAttrsToList (k: v: "-${v.passwordFile}") cfg.accounts); + BindPaths = + (if cfg.settings ? hist then [ cfg.settings.hist ] else [ ]) + ++ [ externalStateDir ] + ++ (mapAttrsToList (k: v: v.path) cfg.volumes); + # ProtectSystem = "strict"; + # Note that unlike what 'ro' implies, + # this actually makes it impossible to read anything in the root FS, + # except for things explicitly mounted via `RuntimeDirectory`, `StateDirectory`, `CacheDirectory`, and `BindReadOnlyPaths`. + # This is because TemporaryFileSystem creates a *new* *empty* filesystem for the process, so only bindmounts are visible. + TemporaryFileSystem = "/:ro"; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + PrivateMounts = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectHostname = true; + ProtectClock = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictNamespaces = true; + RemoveIPC = true; + UMask = "0077"; + LimitNOFILE = cfg.openFilesLimit; + NoNewPrivileges = true; + LockPersonality = true; + RestrictRealtime = true; + MemoryDenyWriteExecute = true; + }; }; - }; - # ensure volumes exist: - systemd.tmpfiles.settings."copyparty" = ( - lib.attrsets.mapAttrs' ( - name: value: + # ensure volumes exist: + systemd.tmpfiles.settings."copyparty" = ( + lib.attrsets.mapAttrs' ( + name: value: lib.attrsets.nameValuePair (value.path) { d = { #: in front of things means it wont change it if the directory already exists. @@ -333,32 +346,30 @@ in { mode = ":755"; }; } - ) - cfg.volumes - ); + ) cfg.volumes + ); - users.groups.copyparty = lib.mkIf (cfg.user == "copyparty" && cfg.group == "copyparty") {}; - users.users.copyparty = lib.mkIf (cfg.user == "copyparty" && cfg.group == "copyparty") { - description = "Service user for copyparty"; - group = "copyparty"; - home = externalStateDir; - isSystemUser = true; - }; - environment.systemPackages = lib.mkIf cfg.mkHashWrapper [ - (pkgs.writeShellScriptBin - "copyparty-hash" - '' - set -a # automatically export variables - # set same environment variables as the systemd service - ${lib.pipe config.systemd.services.copyparty.environment [ - (lib.filterAttrs (n: v: v != null && n != "PATH")) - (lib.mapAttrs (_: v: "${v}")) - (lib.toShellVars) - ]} - PATH=${config.systemd.services.copyparty.environment.PATH}:$PATH + users.groups.copyparty = lib.mkIf (cfg.user == "copyparty" && cfg.group == "copyparty") { }; + users.users.copyparty = lib.mkIf (cfg.user == "copyparty" && cfg.group == "copyparty") { + description = "Service user for copyparty"; + group = "copyparty"; + home = externalStateDir; + isSystemUser = true; + }; + environment.systemPackages = lib.mkIf cfg.mkHashWrapper [ + (pkgs.writeShellScriptBin "copyparty-hash" '' + set -a # automatically export variables + # set same environment variables as the systemd service + ${lib.pipe config.systemd.services.copyparty.environment [ + (lib.filterAttrs (n: v: v != null && n != "PATH")) + (lib.mapAttrs (_: v: "${v}")) + (lib.toShellVars) + ]} + PATH=${config.systemd.services.copyparty.environment.PATH}:$PATH - exec ${command} --ah-cli - '') - ]; - }); + exec ${command} --ah-cli + '') + ]; + } + ); } diff --git a/contrib/package/nix/copyparty/default.nix b/contrib/package/nix/copyparty/default.nix index d34f9ae7..28d733ab 100644 --- a/contrib/package/nix/copyparty/default.nix +++ b/contrib/package/nix/copyparty/default.nix @@ -1,41 +1,67 @@ -{ lib, stdenv, makeWrapper, fetchurl, util-linux, python, jinja2, impacket, pyftpdlib, pyopenssl, argon2-cffi, pillow, pyvips, pyzmq, ffmpeg, mutagen, +{ + lib, + stdenv, + makeWrapper, + fetchurl, + util-linux, + python, + jinja2, + impacket, + pyopenssl, + cfssl, + argon2-cffi, + pillow, + pyvips, + pyzmq, + ffmpeg, + mutagen, -# use argon2id-hashed passwords in config files (sha2 is always available) -withHashedPasswords ? true, + # use argon2id-hashed passwords in config files (sha2 is always available) + withHashedPasswords ? true, -# generate TLS certificates on startup (pointless when reverse-proxied) -withCertgen ? false, + # generate TLS certificates on startup (pointless when reverse-proxied) + withCertgen ? false, -# create thumbnails with Pillow; faster than FFmpeg / MediaProcessing -withThumbnails ? true, + # create thumbnails with Pillow; faster than FFmpeg / MediaProcessing + withThumbnails ? true, -# create thumbnails with PyVIPS; even faster, uses more memory -# -- can be combined with Pillow to support more filetypes -withFastThumbnails ? false, + # create thumbnails with PyVIPS; even faster, uses more memory + # -- can be combined with Pillow to support more filetypes + withFastThumbnails ? false, -# enable FFmpeg; thumbnails for most filetypes (also video and audio), extract audio metadata, transcode audio to opus -# -- possibly dangerous if you allow anonymous uploads, since FFmpeg has a huge attack surface -# -- can be combined with Thumbnails and/or FastThumbnails, since FFmpeg is slower than both -withMediaProcessing ? true, + # enable FFmpeg; thumbnails for most filetypes (also video and audio), extract audio metadata, transcode audio to opus + # -- possibly dangerous if you allow anonymous uploads, since FFmpeg has a huge attack surface + # -- can be combined with Thumbnails and/or FastThumbnails, since FFmpeg is slower than both + withMediaProcessing ? true, -# if MediaProcessing is not enabled, you probably want this instead (less accurate, but much safer and faster) -withBasicAudioMetadata ? false, + # if MediaProcessing is not enabled, you probably want this instead (less accurate, but much safer and faster) + withBasicAudioMetadata ? false, -# send ZeroMQ messages from event-hooks -withZeroMQ ? true, + # send ZeroMQ messages from event-hooks + withZeroMQ ? true, -# enable FTPS support in the FTP server -withFTPS ? false, + # enable FTPS support in the FTP server + withFTPS ? false, -# samba/cifs server; dangerous and buggy, enable if you really need it -withSMB ? false, + # samba/cifs server; dangerous and buggy, enable if you really need it + withSMB ? false, + + # extra packages to add to the PATH + extraPackages ? [ ], + + # function that accepts a python packageset and returns a list of packages to + # be added to the python venv. useful for scripts and such that require + # additional dependencies + extraPythonPackages ? (_p: [ ]), }: let pinData = lib.importJSON ./pin.json; - pyEnv = python.withPackages (ps: - with ps; [ + pyEnv = python.withPackages ( + ps: + with ps; + [ jinja2 ] ++ lib.optional withSMB impacket @@ -47,22 +73,36 @@ let ++ lib.optional withBasicAudioMetadata mutagen ++ lib.optional withHashedPasswords argon2-cffi ++ lib.optional withZeroMQ pyzmq - ); -in stdenv.mkDerivation { + ++ (extraPythonPackages ps) + ); + + runtimeDeps = ([ util-linux ] ++ extraPackages ++ lib.optional withMediaProcessing ffmpeg); +in +stdenv.mkDerivation { pname = "copyparty"; - version = pinData.version; + inherit (pinData) version; src = fetchurl { - url = pinData.url; - hash = pinData.hash; + inherit (pinData) url hash; }; - buildInputs = [ makeWrapper ]; + nativeBuildInputs = [ makeWrapper ]; dontUnpack = true; - dontBuild = true; installPhase = '' install -Dm755 $src $out/share/copyparty-sfx.py makeWrapper ${pyEnv.interpreter} $out/bin/copyparty \ - --set PATH '${lib.makeBinPath ([ util-linux ] ++ lib.optional withMediaProcessing ffmpeg)}:$PATH' \ - --add-flags "$out/share/copyparty-sfx.py" + --prefix PATH : ${lib.makeBinPath runtimeDeps} \ + --add-flag $out/share/copyparty-sfx.py ''; - meta.mainProgram = "copyparty"; + meta = { + description = "Turn almost any device into a file server"; + longDescription = '' + Portable file server with accelerated resumable uploads, dedup, WebDAV, + FTP, TFTP, zeroconf, media indexer, thumbnails++ all in one file, no deps + ''; + homepage = "https://github.com/9001/copyparty"; + changelog = "https://github.com/9001/copyparty/releases/tag/v${pinData.version}"; + license = lib.licenses.mit; + inherit (python.meta) platforms; + mainProgram = "copyparty"; + sourceProvenance = [ lib.sourceTypes.binaryBytecode ]; + }; } diff --git a/contrib/package/nix/partyfuse/default.nix b/contrib/package/nix/partyfuse/default.nix new file mode 100644 index 00000000..59faa914 --- /dev/null +++ b/contrib/package/nix/partyfuse/default.nix @@ -0,0 +1,26 @@ +{ + stdenvNoCC, + copyparty, + python3, + makeBinaryWrapper, +}: +let + python = python3.withPackages (p: [ p.fusepy ]); +in +stdenvNoCC.mkDerivation { + pname = "partyfuse"; + inherit (copyparty) version meta; + src = ../../../..; + + nativeBuildInputs = [ makeBinaryWrapper ]; + + installPhase = '' + runHook preInstall + + install -Dm444 bin/partyfuse.py -t $out/share/copyparty + makeWrapper ${python.interpreter} $out/bin/partyfuse \ + --add-flag $out/share/copyparty/partyfuse.py + + runHook postInstall + ''; +} diff --git a/contrib/package/nix/u2c/default.nix b/contrib/package/nix/u2c/default.nix new file mode 100644 index 00000000..dc1e4c56 --- /dev/null +++ b/contrib/package/nix/u2c/default.nix @@ -0,0 +1,24 @@ +{ + stdenvNoCC, + copyparty, + python312, + makeBinaryWrapper, +}: +stdenvNoCC.mkDerivation { + pname = "u2c"; + inherit (copyparty) version meta; + src = ../../../..; + + nativeBuildInputs = [ makeBinaryWrapper ]; + + installPhase = '' + runHook preInstall + + install -Dm444 bin/u2c.py -t $out/share/copyparty + mkdir $out/bin + makeWrapper ${python312.interpreter} $out/bin/u2c \ + --add-flag $out/share/copyparty/u2c.py + + runHook postInstall + ''; +} diff --git a/flake.nix b/flake.nix index be9d0678..5f39d605 100644 --- a/flake.nix +++ b/flake.nix @@ -4,16 +4,30 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = + { + self, + nixpkgs, + flake-utils, + }: { nixosModules.default = ./contrib/nixos/modules/copyparty.nix; - overlays.default = self: super: { - copyparty = - self.python3.pkgs.callPackage ./contrib/package/nix/copyparty { - ffmpeg = self.ffmpeg-full; - }; + overlays.default = final: prev: rec { + copyparty = final.python3.pkgs.callPackage ./contrib/package/nix/copyparty { + ffmpeg = final.ffmpeg-full; + }; + + partyfuse = prev.callPackage ./contrib/package/nix/partyfuse { + inherit copyparty; + }; + + u2c = prev.callPackage ./contrib/package/nix/u2c { + inherit copyparty; + }; }; - } // flake-utils.lib.eachDefaultSystem (system: + } + // flake-utils.lib.eachDefaultSystem ( + system: let pkgs = import nixpkgs { inherit system; @@ -22,10 +36,31 @@ }; overlays = [ self.overlays.default ]; }; - in { + in + { + # check that copyparty builds with all optionals turned on + checks.copyparty-full = self.packages.${system}.copyparty.override { + withHashedPasswords = true; + withCertgen = true; + withThumbnails = true; + withFastThumbnails = true; + withMediaProcessing = true; + withBasicAudioMetadata = true; + withZeroMQ = true; + withFTPS = true; + withSMB = true; + }; + packages = { - inherit (pkgs) copyparty; + inherit (pkgs) + copyparty + partyfuse + u2c + ; default = self.packages.${system}.copyparty; }; - }); + + formatter = pkgs.nixfmt-tree; + } + ); }