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