various improvements to the nix files (#228)

* nix: allow passing extra packages in PATH

* nix: allow passing extra python packages

I wanted to use
https://github.com/9001/copyparty/blob/hovudstraum/bin/hooks/notify.py
but that wasn't really possible without this under the nix package.

* nix: format all nix files with nixfmt

* nix: reduce redundancy in the package

For readability

* nix: remove unused pyftpdlib import

* nix: put makeWrapper into the correct inputs

* nix: fill out all of meta

* nix: set formatter in flake for nix files

This allows contributors to format their nix changes with the `nix fmt`
command.

* nix: add u2c

* nix: add partyfuse

One downside of the way the nix ecosystem works is that MacFUSE needs to
be installed manually. Luckily the script tells you that already!

* nix: add missing cfssl import

* nix: add flake check that makes sure it builds with all flags

Because sometimes an import might be missing, and if it is an optional
then you'll only figure out that it's broken if you set the flag.

* nix: use correct overlay argument names

Or `nix flake check` will refuse to run the copyparty-full check
This commit is contained in:
Tom van Dijk 2025-07-29 00:16:30 +00:00 committed by GitHub
parent 735d9f9391
commit 4915b14be1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 373 additions and 237 deletions

View file

@ -4,28 +4,31 @@
lib, lib,
... ...
}: }:
with lib; let with lib;
mkKeyValue = key: value: let
if value == true mkKeyValue =
then key: value:
if value == true then
# sets with a true boolean value are coerced to just the key name # sets with a true boolean value are coerced to just the key name
key key
else if value == false else if value == false then
then
# or omitted completely when false # 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: mkValueString =
if isList value value:
then (concatStringsSep ", " (map mkValueString value)) if isList value then
else if isAttrs value (concatStringsSep ", " (map mkValueString value))
then "\n" + (mkAttrsString value) else if isAttrs value then
else (generators.mkValueStringDefault {} value); "\n" + (mkAttrsString value)
else
(generators.mkValueStringDefault { } value);
mkSectionName = value: "[" + (escape ["[" "]"] value) + "]"; mkSectionName = value: "[" + (escape [ "[" "]" ] value) + "]";
mkSection = name: attrs: '' mkSection = name: attrs: ''
${mkSectionName name} ${mkSectionName name}
@ -57,7 +60,8 @@ with lib; let
externalCacheDir = "/var/cache/copyparty"; externalCacheDir = "/var/cache/copyparty";
externalStateDir = "/var/lib/copyparty"; externalStateDir = "/var/lib/copyparty";
defaultShareDir = "${externalStateDir}/data"; defaultShareDir = "${externalStateDir}/data";
in { in
{
options.services.copyparty = { options.services.copyparty = {
enable = mkEnableOption "web-based file manager"; enable = mkEnableOption "web-based file manager";
@ -128,22 +132,27 @@ in {
}; };
accounts = mkOption { accounts = mkOption {
type = types.attrsOf (types.submodule ({...}: { type = types.attrsOf (
options = { types.submodule (
passwordFile = mkOption { { ... }:
type = types.str; {
description = '' options = {
Runtime file path to a file containing the user password. passwordFile = mkOption {
Must be readable by the copyparty user. type = types.str;
''; description = ''
example = "/run/keys/copyparty/ed"; Runtime file path to a file containing the user password.
}; Must be readable by the copyparty user.
}; '';
})); example = "/run/keys/copyparty/ed";
};
};
}
)
);
description = '' description = ''
A set of copyparty accounts to create. A set of copyparty accounts to create.
''; '';
default = {}; default = { };
example = literalExpression '' example = literalExpression ''
{ {
ed.passwordFile = "/run/keys/copyparty/ed"; ed.passwordFile = "/run/keys/copyparty/ed";
@ -152,74 +161,81 @@ in {
}; };
volumes = mkOption { volumes = mkOption {
type = types.attrsOf (types.submodule ({...}: { type = types.attrsOf (
options = { types.submodule (
path = mkOption { { ... }:
type = types.path; {
description = '' options = {
Path of a directory to share. path = mkOption {
''; type = types.path;
}; description = ''
access = mkOption { Path of a directory to share.
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"];
}; };
''; access = mkOption {
}; type = types.attrs;
flags = mkOption { description = ''
type = types.attrs; Attribute list of permissions and the users to apply them to.
description = ''
Attribute list of volume flags to apply. The key must be a string containing any combination of allowed permission:
See `${getExe cfg.package} --help-flags` for more details. "r" (read): list folder contents, download files
''; "w" (write): upload files; need "r" to see the uploads
example = literalExpression '' "m" (move): move files and folders; need "w" at destination
{ "d" (delete): permanently delete files and folders
# "fk" enables filekeys (necessary for upget permission) (4 chars long) "g" (get): download files, but cannot see folder contents
fk = 4; "G" (upget): "get", but can see filekeys of their own uploads
# scan for new files every 60sec "h" (html): "get", but folders return their index.html
scan = 60; "a" (admin): can see uploader IPs, config-reload
# volflag "e2d" enables the uploads database
e2d = true; For example: "rwmd"
# "d2t" disables multimedia parsers (in case the uploads are malicious)
d2t = true; The value must be one of:
# skips hashing file contents if path matches *.iso an account name, defined in `accounts`
nohash = "\.iso$"; 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 {
default = {}; 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"; description = "A set of copyparty volumes to create";
default = { default = {
"/" = { "/" = {
path = defaultShareDir; path = defaultShareDir;
access = {r = "*";}; access = {
r = "*";
};
}; };
}; };
example = literalExpression '' example = literalExpression ''
@ -238,93 +254,90 @@ in {
}; };
}; };
config = mkIf cfg.enable (let config = mkIf cfg.enable (
command = "${getExe cfg.package} -c ${runtimeConfigPath}"; let
in { command = "${getExe cfg.package} -c ${runtimeConfigPath}";
systemd.services.copyparty = { in
description = "http file sharing hub"; {
wantedBy = ["multi-user.target"]; systemd.services.copyparty = {
description = "http file sharing hub";
wantedBy = [ "multi-user.target" ];
environment = { environment = {
PYTHONUNBUFFERED = "true"; PYTHONUNBUFFERED = "true";
XDG_CONFIG_HOME = externalStateDir; XDG_CONFIG_HOME = externalStateDir;
}; };
preStart = let preStart =
replaceSecretCommand = name: attrs: "${getExe pkgs.replace-secret} '${ let
passwordPlaceholder name replaceSecretCommand =
}' '${attrs.passwordFile}' ${runtimeConfigPath}"; name: attrs:
in '' "${getExe pkgs.replace-secret} '${passwordPlaceholder name}' '${attrs.passwordFile}' ${runtimeConfigPath}";
set -euo pipefail in
install -m 600 ${configFile} ${runtimeConfigPath} ''
${concatStringsSep "\n" set -euo pipefail
(mapAttrsToList replaceSecretCommand cfg.accounts)} install -m 600 ${configFile} ${runtimeConfigPath}
''; ${concatStringsSep "\n" (mapAttrsToList replaceSecretCommand cfg.accounts)}
'';
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
ExecStart = command; ExecStart = command;
# Hardening options # Hardening options
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
RuntimeDirectory = ["copyparty"]; RuntimeDirectory = [ "copyparty" ];
RuntimeDirectoryMode = "0700"; RuntimeDirectoryMode = "0700";
StateDirectory = ["copyparty"]; StateDirectory = [ "copyparty" ];
StateDirectoryMode = "0700"; StateDirectoryMode = "0700";
CacheDirectory = lib.mkIf (cfg.settings ? hist) ["copyparty"]; CacheDirectory = lib.mkIf (cfg.settings ? hist) [ "copyparty" ];
CacheDirectoryMode = lib.mkIf (cfg.settings ? hist) "0700"; CacheDirectoryMode = lib.mkIf (cfg.settings ? hist) "0700";
WorkingDirectory = externalStateDir; WorkingDirectory = externalStateDir;
BindReadOnlyPaths = BindReadOnlyPaths = [
[
"/nix/store" "/nix/store"
"-/etc/resolv.conf" "-/etc/resolv.conf"
"-/etc/nsswitch.conf" "-/etc/nsswitch.conf"
"-/etc/group" "-/etc/group"
"-/etc/hosts" "-/etc/hosts"
"-/etc/localtime" "-/etc/localtime"
] ] ++ (mapAttrsToList (k: v: "-${v.passwordFile}") cfg.accounts);
++ (mapAttrsToList (k: v: "-${v.passwordFile}") cfg.accounts); BindPaths =
BindPaths = (if cfg.settings ? hist then [ cfg.settings.hist ] else [ ])
( ++ [ externalStateDir ]
if cfg.settings ? hist ++ (mapAttrsToList (k: v: v.path) cfg.volumes);
then [cfg.settings.hist] # ProtectSystem = "strict";
else [] # Note that unlike what 'ro' implies,
) # this actually makes it impossible to read anything in the root FS,
++ [externalStateDir] # except for things explicitly mounted via `RuntimeDirectory`, `StateDirectory`, `CacheDirectory`, and `BindReadOnlyPaths`.
++ (mapAttrsToList (k: v: v.path) cfg.volumes); # This is because TemporaryFileSystem creates a *new* *empty* filesystem for the process, so only bindmounts are visible.
# ProtectSystem = "strict"; TemporaryFileSystem = "/:ro";
# Note that unlike what 'ro' implies, PrivateTmp = true;
# this actually makes it impossible to read anything in the root FS, PrivateDevices = true;
# except for things explicitly mounted via `RuntimeDirectory`, `StateDirectory`, `CacheDirectory`, and `BindReadOnlyPaths`. ProtectKernelTunables = true;
# This is because TemporaryFileSystem creates a *new* *empty* filesystem for the process, so only bindmounts are visible. ProtectControlGroups = true;
TemporaryFileSystem = "/:ro"; RestrictSUIDSGID = true;
PrivateTmp = true; PrivateMounts = true;
PrivateDevices = true; ProtectKernelModules = true;
ProtectKernelTunables = true; ProtectKernelLogs = true;
ProtectControlGroups = true; ProtectHostname = true;
RestrictSUIDSGID = true; ProtectClock = true;
PrivateMounts = true; ProtectProc = "invisible";
ProtectKernelModules = true; ProcSubset = "pid";
ProtectKernelLogs = true; RestrictNamespaces = true;
ProtectHostname = true; RemoveIPC = true;
ProtectClock = true; UMask = "0077";
ProtectProc = "invisible"; LimitNOFILE = cfg.openFilesLimit;
ProcSubset = "pid"; NoNewPrivileges = true;
RestrictNamespaces = true; LockPersonality = true;
RemoveIPC = true; RestrictRealtime = true;
UMask = "0077"; MemoryDenyWriteExecute = true;
LimitNOFILE = cfg.openFilesLimit; };
NoNewPrivileges = true;
LockPersonality = true;
RestrictRealtime = true;
MemoryDenyWriteExecute = true;
}; };
};
# ensure volumes exist: # ensure volumes exist:
systemd.tmpfiles.settings."copyparty" = ( systemd.tmpfiles.settings."copyparty" = (
lib.attrsets.mapAttrs' ( lib.attrsets.mapAttrs' (
name: value: name: value:
lib.attrsets.nameValuePair (value.path) { lib.attrsets.nameValuePair (value.path) {
d = { d = {
#: in front of things means it wont change it if the directory already exists. #: in front of things means it wont change it if the directory already exists.
@ -333,32 +346,30 @@ in {
mode = ":755"; mode = ":755";
}; };
} }
) ) cfg.volumes
cfg.volumes );
);
users.groups.copyparty = lib.mkIf (cfg.user == "copyparty" && cfg.group == "copyparty") {}; users.groups.copyparty = lib.mkIf (cfg.user == "copyparty" && cfg.group == "copyparty") { };
users.users.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"; description = "Service user for copyparty";
group = "copyparty"; group = "copyparty";
home = externalStateDir; home = externalStateDir;
isSystemUser = true; isSystemUser = true;
}; };
environment.systemPackages = lib.mkIf cfg.mkHashWrapper [ environment.systemPackages = lib.mkIf cfg.mkHashWrapper [
(pkgs.writeShellScriptBin (pkgs.writeShellScriptBin "copyparty-hash" ''
"copyparty-hash" set -a # automatically export variables
'' # set same environment variables as the systemd service
set -a # automatically export variables ${lib.pipe config.systemd.services.copyparty.environment [
# set same environment variables as the systemd service (lib.filterAttrs (n: v: v != null && n != "PATH"))
${lib.pipe config.systemd.services.copyparty.environment [ (lib.mapAttrs (_: v: "${v}"))
(lib.filterAttrs (n: v: v != null && n != "PATH")) (lib.toShellVars)
(lib.mapAttrs (_: v: "${v}")) ]}
(lib.toShellVars) PATH=${config.systemd.services.copyparty.environment.PATH}:$PATH
]}
PATH=${config.systemd.services.copyparty.environment.PATH}:$PATH
exec ${command} --ah-cli exec ${command} --ah-cli
'') '')
]; ];
}); }
);
} }

View file

@ -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) # use argon2id-hashed passwords in config files (sha2 is always available)
withHashedPasswords ? true, withHashedPasswords ? true,
# generate TLS certificates on startup (pointless when reverse-proxied) # generate TLS certificates on startup (pointless when reverse-proxied)
withCertgen ? false, withCertgen ? false,
# create thumbnails with Pillow; faster than FFmpeg / MediaProcessing # create thumbnails with Pillow; faster than FFmpeg / MediaProcessing
withThumbnails ? true, withThumbnails ? true,
# create thumbnails with PyVIPS; even faster, uses more memory # create thumbnails with PyVIPS; even faster, uses more memory
# -- can be combined with Pillow to support more filetypes # -- can be combined with Pillow to support more filetypes
withFastThumbnails ? false, withFastThumbnails ? false,
# enable FFmpeg; thumbnails for most filetypes (also video and audio), extract audio metadata, transcode audio to opus # 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 # -- 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 # -- can be combined with Thumbnails and/or FastThumbnails, since FFmpeg is slower than both
withMediaProcessing ? true, withMediaProcessing ? true,
# if MediaProcessing is not enabled, you probably want this instead (less accurate, but much safer and faster) # if MediaProcessing is not enabled, you probably want this instead (less accurate, but much safer and faster)
withBasicAudioMetadata ? false, withBasicAudioMetadata ? false,
# send ZeroMQ messages from event-hooks # send ZeroMQ messages from event-hooks
withZeroMQ ? true, withZeroMQ ? true,
# enable FTPS support in the FTP server # enable FTPS support in the FTP server
withFTPS ? false, withFTPS ? false,
# samba/cifs server; dangerous and buggy, enable if you really need it # samba/cifs server; dangerous and buggy, enable if you really need it
withSMB ? false, 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 let
pinData = lib.importJSON ./pin.json; pinData = lib.importJSON ./pin.json;
pyEnv = python.withPackages (ps: pyEnv = python.withPackages (
with ps; [ ps:
with ps;
[
jinja2 jinja2
] ]
++ lib.optional withSMB impacket ++ lib.optional withSMB impacket
@ -47,22 +73,36 @@ let
++ lib.optional withBasicAudioMetadata mutagen ++ lib.optional withBasicAudioMetadata mutagen
++ lib.optional withHashedPasswords argon2-cffi ++ lib.optional withHashedPasswords argon2-cffi
++ lib.optional withZeroMQ pyzmq ++ lib.optional withZeroMQ pyzmq
); ++ (extraPythonPackages ps)
in stdenv.mkDerivation { );
runtimeDeps = ([ util-linux ] ++ extraPackages ++ lib.optional withMediaProcessing ffmpeg);
in
stdenv.mkDerivation {
pname = "copyparty"; pname = "copyparty";
version = pinData.version; inherit (pinData) version;
src = fetchurl { src = fetchurl {
url = pinData.url; inherit (pinData) url hash;
hash = pinData.hash;
}; };
buildInputs = [ makeWrapper ]; nativeBuildInputs = [ makeWrapper ];
dontUnpack = true; dontUnpack = true;
dontBuild = true;
installPhase = '' installPhase = ''
install -Dm755 $src $out/share/copyparty-sfx.py install -Dm755 $src $out/share/copyparty-sfx.py
makeWrapper ${pyEnv.interpreter} $out/bin/copyparty \ makeWrapper ${pyEnv.interpreter} $out/bin/copyparty \
--set PATH '${lib.makeBinPath ([ util-linux ] ++ lib.optional withMediaProcessing ffmpeg)}:$PATH' \ --prefix PATH : ${lib.makeBinPath runtimeDeps} \
--add-flags "$out/share/copyparty-sfx.py" --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 ];
};
} }

View file

@ -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
'';
}

View file

@ -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
'';
}

View file

@ -4,16 +4,30 @@
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = { self, nixpkgs, flake-utils }: outputs =
{
self,
nixpkgs,
flake-utils,
}:
{ {
nixosModules.default = ./contrib/nixos/modules/copyparty.nix; nixosModules.default = ./contrib/nixos/modules/copyparty.nix;
overlays.default = self: super: { overlays.default = final: prev: rec {
copyparty = copyparty = final.python3.pkgs.callPackage ./contrib/package/nix/copyparty {
self.python3.pkgs.callPackage ./contrib/package/nix/copyparty { ffmpeg = final.ffmpeg-full;
ffmpeg = self.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 let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
@ -22,10 +36,31 @@
}; };
overlays = [ self.overlays.default ]; 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 = { packages = {
inherit (pkgs) copyparty; inherit (pkgs)
copyparty
partyfuse
u2c
;
default = self.packages.${system}.copyparty; default = self.packages.${system}.copyparty;
}; };
});
formatter = pkgs.nixfmt-tree;
}
);
} }