nixos: revamp (#159)

* formatting clean-up with alejandra.

* added ability to specify user and group.

* added option to have hist data live with volumes.

* improved my understanding of what paths copyparty needs to function.

* added environment script.

* Revert "added environment script."

Cant have 2 instances of copyparty running, even if one is just for
ah-cli...

This reverts commit c60c8d8e0b.

* fixup! added ability to specify user and group.

* Reapply "added environment script."

This reverts commit a54e950ecc.

* Moved back to TemporaryFileSystem for system hardening.

I misunderstood bind mounts...

* made systemd.tmpfiles rules to ensure the volume directories exist.

* changed copyparty-env script to copyparty-hash.

* removed seperatehist in favor of default settings attrset.

* new update of copyparty removed the need for some options.

* minor refactoring.

* fixed some descriptions that had not kept up with changes.

* fixup! removed seperatehist in favor of default settings attrset.
This commit is contained in:
Gabriel Venberg 2025-04-29 14:48:17 +02:00 committed by GitHub
parent 94352f278b
commit d1bca1f52f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,27 +1,29 @@
{ config, pkgs, lib, ... }: {
config,
with lib; pkgs,
lib,
let ...
}:
with lib; let
mkKeyValue = key: value: mkKeyValue = key: value:
if value == true then 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 then else if value == false
then
# or omitted completely when false # or omitted completely when false
"" ""
else else (generators.mkKeyValueDefault {inherit mkValueString;} ": " key value);
(generators.mkKeyValueDefault { inherit mkValueString; } ": " key value);
mkAttrsString = value: (generators.toKeyValue {inherit mkKeyValue;} value); mkAttrsString = value: (generators.toKeyValue {inherit mkKeyValue;} value);
mkValueString = value: mkValueString = value:
if isList value then if isList value
(concatStringsSep ", " (map mkValueString value)) then (concatStringsSep ", " (map mkValueString value))
else if isAttrs value then else if isAttrs value
"\n" + (mkAttrsString value) then "\n" + (mkAttrsString value)
else else (generators.mkValueStringDefault {} value);
(generators.mkValueStringDefault { } value);
mkSectionName = value: "[" + (escape ["[" "]"] value) + "]"; mkSectionName = value: "[" + (escape ["[" "]"] value) + "]";
@ -49,12 +51,12 @@ let
${concatStringsSep "\n" (mapAttrsToList mkVolume cfg.volumes)} ${concatStringsSep "\n" (mapAttrsToList mkVolume cfg.volumes)}
''; '';
name = "copyparty";
cfg = config.services.copyparty; cfg = config.services.copyparty;
configFile = pkgs.writeText "${name}.conf" configStr; configFile = pkgs.writeText "copyparty.conf" configStr;
runtimeConfigPath = "/run/${name}/${name}.conf"; runtimeConfigPath = "/run/copyparty/copyparty.conf";
home = "/var/lib/${name}"; externalCacheDir = "/var/cache/copyparty";
defaultShareDir = "${home}/data"; externalStateDir = "/var/lib/copyparty";
defaultShareDir = "${externalStateDir}/data";
in { in {
options.services.copyparty = { options.services.copyparty = {
enable = mkEnableOption "web-based file manager"; enable = mkEnableOption "web-based file manager";
@ -68,6 +70,35 @@ in {
''; '';
}; };
mkHashWrapper = mkOption {
type = types.bool;
default = true;
description = ''
Make a shell script wrapper called 'copyparty-hash' with all options set here,
that launches the hashing cli.
'';
};
user = mkOption {
type = types.str;
default = "copyparty";
description = ''
The user that copyparty will run under.
If changed from default, you are responsible for making sure the user exists.
'';
};
group = mkOption {
type = types.str;
default = "copyparty";
description = ''
The group that copyparty will run under.
If changed from default, you are responsible for making sure the user exists.
'';
};
openFilesLimit = mkOption { openFilesLimit = mkOption {
default = 4096; default = 4096;
type = types.either types.int types.str; type = types.either types.int types.str;
@ -79,16 +110,19 @@ in {
description = '' description = ''
Global settings to apply. Global settings to apply.
Directly maps to values in the [global] section of the copyparty config. Directly maps to values in the [global] section of the copyparty config.
Cannot set "c" or "hist", those are set by this module.
See `${getExe cfg.package} --help` for more details. See `${getExe cfg.package} --help` for more details.
''; '';
default = { default = {
i = "127.0.0.1"; i = "127.0.0.1";
no-reload = true; no-reload = true;
hist = externalCacheDir;
}; };
example = literalExpression '' example = literalExpression ''
{ {
i = "0.0.0.0"; i = "0.0.0.0";
no-reload = true; no-reload = true;
hist = ${externalCacheDir};
} }
''; '';
}; };
@ -121,7 +155,7 @@ in {
type = types.attrsOf (types.submodule ({...}: { type = types.attrsOf (types.submodule ({...}: {
options = { options = {
path = mkOption { path = mkOption {
type = types.str; type = types.path;
description = '' description = ''
Path of a directory to share. Path of a directory to share.
''; '';
@ -204,19 +238,20 @@ in {
}; };
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable (let
command = "${getExe cfg.package} -c ${runtimeConfigPath}";
in {
systemd.services.copyparty = { systemd.services.copyparty = {
description = "http file sharing hub"; description = "http file sharing hub";
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
environment = { environment = {
PYTHONUNBUFFERED = "true"; PYTHONUNBUFFERED = "true";
XDG_CONFIG_HOME = "${home}/.config"; XDG_CONFIG_HOME = externalStateDir;
}; };
preStart = let preStart = let
replaceSecretCommand = name: attrs: replaceSecretCommand = name: attrs: "${getExe pkgs.replace-secret} '${
"${getExe pkgs.replace-secret} '${
passwordPlaceholder name passwordPlaceholder name
}' '${attrs.passwordFile}' ${runtimeConfigPath}"; }' '${attrs.passwordFile}' ${runtimeConfigPath}";
in '' in ''
@ -228,28 +263,40 @@ in {
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
ExecStart = "${getExe cfg.package} -c ${runtimeConfigPath}"; ExecStart = command;
# Hardening options # Hardening options
User = "copyparty"; User = cfg.user;
Group = "copyparty"; Group = cfg.group;
RuntimeDirectory = name; RuntimeDirectory = ["copyparty"];
RuntimeDirectoryMode = "0700"; RuntimeDirectoryMode = "0700";
StateDirectory = [ name "${name}/data" "${name}/.config" ]; StateDirectory = ["copyparty"];
StateDirectoryMode = "0700"; StateDirectoryMode = "0700";
WorkingDirectory = home; CacheDirectory = lib.mkIf (cfg.settings ? hist) ["copyparty"];
TemporaryFileSystem = "/:ro"; CacheDirectoryMode = lib.mkIf (cfg.settings ? hist) "0700";
BindReadOnlyPaths = [ WorkingDirectory = externalStateDir;
BindReadOnlyPaths =
[
"/nix/store" "/nix/store"
"-/etc/resolv.conf" "-/etc/resolv.conf"
"-/etc/nsswitch.conf" "-/etc/nsswitch.conf"
"-/etc/hosts" "-/etc/hosts"
"-/etc/localtime" "-/etc/localtime"
] ++ (mapAttrsToList (k: v: "-${v.passwordFile}") cfg.accounts); ]
BindPaths = [ home ] ++ (mapAttrsToList (k: v: v.path) cfg.volumes); ++ (mapAttrsToList (k: v: "-${v.passwordFile}") cfg.accounts);
# Would re-mount paths ignored by temporary root BindPaths =
(
if cfg.settings ? hist
then [cfg.settings.hist]
else []
)
++ [externalStateDir]
++ (mapAttrsToList (k: v: v.path) cfg.volumes);
# ProtectSystem = "strict"; # ProtectSystem = "strict";
ProtectHome = true; # 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; PrivateTmp = true;
PrivateDevices = true; PrivateDevices = true;
ProtectKernelTunables = true; ProtectKernelTunables = true;
@ -269,15 +316,48 @@ in {
NoNewPrivileges = true; NoNewPrivileges = true;
LockPersonality = true; LockPersonality = true;
RestrictRealtime = true; RestrictRealtime = true;
MemoryDenyWriteExecute = true;
}; };
}; };
users.groups.copyparty = { }; # ensure volumes exist:
users.users.copyparty = { systemd.tmpfiles.settings."copyparty" = (
description = "Service user for copyparty"; lib.attrsets.mapAttrs' (
group = "copyparty"; name: value:
home = home; lib.attrsets.nameValuePair (value.path) {
isSystemUser = true; d = {
}; #: in front of things means it wont change it if the directory already exists.
group = ":${cfg.group}";
user = ":${cfg.user}";
mode = ":755";
}; };
} }
)
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
exec ${command} --ah-cli
'')
];
});
}