1{ lib, pkgs, config, ... }:
2
3let
4 settingsFormat = pkgs.formats.yaml {};
5 defaultUser = "slskd";
6in {
7 options.services.slskd = with lib; with types; {
8 enable = mkEnableOption "enable slskd";
9
10 package = mkPackageOptionMD pkgs "slskd" { };
11
12 user = mkOption {
13 type = types.str;
14 default = defaultUser;
15 description = "User account under which slskd runs.";
16 };
17
18 group = mkOption {
19 type = types.str;
20 default = defaultUser;
21 description = "Group under which slskd runs.";
22 };
23
24 domain = mkOption {
25 type = types.nullOr types.str;
26 description = ''
27 If non-null, enables an nginx reverse proxy virtual host at this FQDN,
28 at the path configurated with `services.slskd.web.url_base`.
29 '';
30 example = "slskd.example.com";
31 };
32
33 nginx = mkOption {
34 type = types.submodule (import ../web-servers/nginx/vhost-options.nix { inherit config lib; });
35 default = {};
36 example = lib.literalExpression ''
37 {
38 enableACME = true;
39 forceHttps = true;
40 }
41 '';
42 description = ''
43 This option customizes the nginx virtual host set up for slskd.
44 '';
45 };
46
47 environmentFile = mkOption {
48 type = path;
49 description = ''
50 Path to the environment file sourced on startup.
51 It must at least contain the variables `SLSKD_SLSK_USERNAME` and `SLSKD_SLSK_PASSWORD`.
52 Web interface credentials should also be set here in `SLSKD_USERNAME` and `SLSKD_PASSWORD`.
53 Other, optional credentials like SOCKS5 with `SLSKD_SLSK_PROXY_USERNAME` and `SLSKD_SLSK_PROXY_PASSWORD`
54 should all reside here instead of in the world-readable nix store.
55 Variables are documented at https://github.com/slskd/slskd/blob/master/docs/config.md
56 '';
57 };
58
59 openFirewall = mkOption {
60 type = bool;
61 description = "Whether to open the firewall for the soulseek network listen port (not the web interface port).";
62 default = false;
63 };
64
65 settings = mkOption {
66 description = ''
67 Application configuration for slskd. See
68 [documentation](https://github.com/slskd/slskd/blob/master/docs/config.md).
69 '';
70 default = {};
71 type = submodule {
72 freeformType = settingsFormat.type;
73 options = {
74 remote_file_management = mkEnableOption "modification of share contents through the web ui";
75
76 flags = {
77 force_share_scan = mkOption {
78 type = bool;
79 description = "Force a rescan of shares on every startup.";
80 };
81 no_version_check = mkOption {
82 type = bool;
83 default = true;
84 visible = false;
85 description = "Don't perform a version check on startup.";
86 };
87 };
88
89 directories = {
90 incomplete = mkOption {
91 type = nullOr path;
92 description = "Directory where incomplete downloading files are stored.";
93 defaultText = "/var/lib/slskd/incomplete";
94 default = null;
95 };
96 downloads = mkOption {
97 type = nullOr path;
98 description = "Directory where downloaded files are stored.";
99 defaultText = "/var/lib/slskd/downloads";
100 default = null;
101 };
102 };
103
104 shares = {
105 directories = mkOption {
106 type = listOf str;
107 description = ''
108 Paths to shared directories. See
109 [documentation](https://github.com/slskd/slskd/blob/master/docs/config.md#directories)
110 for advanced usage.
111 '';
112 example = lib.literalExpression ''[ "/home/John/Music" "!/home/John/Music/Recordings" "[Music Drive]/mnt" ]'';
113 };
114 filters = mkOption {
115 type = listOf str;
116 example = lib.literalExpression ''[ "\.ini$" "Thumbs.db$" "\.DS_Store$" ]'';
117 description = "Regular expressions of files to exclude from sharing.";
118 };
119 };
120
121 rooms = mkOption {
122 type = listOf str;
123 description = "Chat rooms to join on startup.";
124 };
125
126 soulseek = {
127 description = mkOption {
128 type = str;
129 description = "The user description for the Soulseek network.";
130 defaultText = "A slskd user. https://github.com/slskd/slskd";
131 };
132 listen_port = mkOption {
133 type = port;
134 description = "The port on which to listen for incoming connections.";
135 default = 50300;
136 };
137 };
138
139 global = {
140 # TODO speed units
141 upload = {
142 slots = mkOption {
143 type = ints.unsigned;
144 description = "Limit of the number of concurrent upload slots.";
145 };
146 speed_limit = mkOption {
147 type = ints.unsigned;
148 description = "Total upload speed limit.";
149 };
150 };
151 download = {
152 slots = mkOption {
153 type = ints.unsigned;
154 description = "Limit of the number of concurrent download slots.";
155 };
156 speed_limit = mkOption {
157 type = ints.unsigned;
158 description = "Total upload download limit";
159 };
160 };
161 };
162
163 filters.search.request = mkOption {
164 type = listOf str;
165 example = lib.literalExpression ''[ "^.{1,2}$" ]'';
166 description = "Incoming search requests which match this filter are ignored.";
167 };
168
169 web = {
170 port = mkOption {
171 type = port;
172 default = 5030;
173 description = "The HTTP listen port.";
174 };
175 url_base = mkOption {
176 type = path;
177 default = "/";
178 description = "The base path in the url for web requests.";
179 };
180 # Users should use a reverse proxy instead for https
181 https.disabled = mkOption {
182 type = bool;
183 default = true;
184 description = "Disable the built-in HTTPS server";
185 };
186 };
187
188 retention = {
189 transfers = {
190 upload = {
191 succeeded = mkOption {
192 type = ints.unsigned;
193 description = "Lifespan of succeeded upload tasks.";
194 defaultText = "(indefinite)";
195 };
196 errored = mkOption {
197 type = ints.unsigned;
198 description = "Lifespan of errored upload tasks.";
199 defaultText = "(indefinite)";
200 };
201 cancelled = mkOption {
202 type = ints.unsigned;
203 description = "Lifespan of cancelled upload tasks.";
204 defaultText = "(indefinite)";
205 };
206 };
207 download = {
208 succeeded = mkOption {
209 type = ints.unsigned;
210 description = "Lifespan of succeeded download tasks.";
211 defaultText = "(indefinite)";
212 };
213 errored = mkOption {
214 type = ints.unsigned;
215 description = "Lifespan of errored download tasks.";
216 defaultText = "(indefinite)";
217 };
218 cancelled = mkOption {
219 type = ints.unsigned;
220 description = "Lifespan of cancelled download tasks.";
221 defaultText = "(indefinite)";
222 };
223 };
224 };
225 files = {
226 complete = mkOption {
227 type = ints.unsigned;
228 description = "Lifespan of completely downloaded files in minutes.";
229 example = 20160;
230 defaultText = "(indefinite)";
231 };
232 incomplete = mkOption {
233 type = ints.unsigned;
234 description = "Lifespan of incomplete downloading files in minutes.";
235 defaultText = "(indefinite)";
236 };
237 };
238 };
239
240 logger = {
241 # Disable by default, journald already retains as needed
242 disk = mkOption {
243 type = bool;
244 description = "Whether to log to the application directory.";
245 default = false;
246 visible = false;
247 };
248 };
249 };
250 };
251 };
252 };
253
254 config = let
255 cfg = config.services.slskd;
256
257 confWithoutNullValues = (lib.filterAttrsRecursive (key: value: (builtins.tryEval value).success && value != null) cfg.settings);
258
259 configurationYaml = settingsFormat.generate "slskd.yml" confWithoutNullValues;
260
261 in lib.mkIf cfg.enable {
262
263 # Force off, configuration file is in nix store and is immutable
264 services.slskd.settings.remote_configuration = lib.mkForce false;
265
266 users.users = lib.optionalAttrs (cfg.user == defaultUser) {
267 "${defaultUser}" = {
268 group = cfg.group;
269 isSystemUser = true;
270 };
271 };
272
273 users.groups = lib.optionalAttrs (cfg.group == defaultUser) {
274 "${defaultUser}" = {};
275 };
276
277 systemd.services.slskd = {
278 description = "A modern client-server application for the Soulseek file sharing network";
279 after = [ "network.target" ];
280 wantedBy = [ "multi-user.target" ];
281 serviceConfig = {
282 Type = "simple";
283 User = cfg.user;
284 Group = cfg.group;
285 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
286 StateDirectory = "slskd"; # Creates /var/lib/slskd and manages permissions
287 ExecStart = "${cfg.package}/bin/slskd --app-dir /var/lib/slskd --config ${configurationYaml}";
288 Restart = "on-failure";
289 ReadOnlyPaths = map (d: builtins.elemAt (builtins.split "[^/]*(/.+)" d) 1) cfg.settings.shares.directories;
290 ReadWritePaths =
291 (lib.optional (cfg.settings.directories.incomplete != null) cfg.settings.directories.incomplete) ++
292 (lib.optional (cfg.settings.directories.downloads != null) cfg.settings.directories.downloads);
293 LockPersonality = true;
294 NoNewPrivileges = true;
295 PrivateDevices = true;
296 PrivateMounts = true;
297 PrivateTmp = true;
298 PrivateUsers = true;
299 ProtectClock = true;
300 ProtectControlGroups = true;
301 ProtectHome = true;
302 ProtectHostname = true;
303 ProtectKernelLogs = true;
304 ProtectKernelModules = true;
305 ProtectKernelTunables = true;
306 ProtectProc = "invisible";
307 ProtectSystem = "strict";
308 RemoveIPC = true;
309 RestrictNamespaces = true;
310 RestrictSUIDSGID = true;
311 };
312 };
313
314 networking.firewall.allowedTCPPorts = lib.optional cfg.openFirewall cfg.settings.soulseek.listen_port;
315
316 services.nginx = lib.mkIf (cfg.domain != null) {
317 enable = lib.mkDefault true;
318 virtualHosts."${cfg.domain}" = lib.mkMerge [
319 cfg.nginx
320 {
321 locations."${cfg.settings.web.url_base}" = {
322 proxyPass = "http://127.0.0.1:${toString cfg.settings.web.port}";
323 proxyWebsockets = true;
324 };
325 }
326 ];
327 };
328 };
329
330 meta = {
331 maintainers = with lib.maintainers; [ ppom melvyn2 ];
332 };
333}