1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9let
10 cfg = config.services.freshrss;
11 webserver = config.services.${cfg.webserver};
12
13 extension-env = pkgs.buildEnv {
14 name = "freshrss-extensions";
15 paths = cfg.extensions;
16 };
17 env-vars = {
18 DATA_PATH = cfg.dataDir;
19 }
20 // lib.optionalAttrs (cfg.extensions != [ ]) {
21 THIRDPARTY_EXTENSIONS_PATH = "${extension-env}/share/freshrss";
22 };
23in
24{
25 meta.maintainers = with maintainers; [
26 etu
27 stunkymonkey
28 mattchrist
29 ];
30
31 options.services.freshrss = {
32 enable = mkEnableOption "FreshRSS RSS aggregator and reader with php-fpm backend";
33
34 package = mkPackageOption pkgs "freshrss" { };
35
36 extensions = mkOption {
37 type = types.listOf types.package;
38 default = [ ];
39 defaultText = literalExpression "[]";
40 example = literalExpression ''
41 with freshrss-extensions; [
42 youtube
43 ] ++ [
44 (freshrss-extensions.buildFreshRssExtension {
45 FreshRssExtUniqueId = "ReadingTime";
46 pname = "reading-time";
47 version = "1.5";
48 src = pkgs.fetchFromGitLab {
49 domain = "framagit.org";
50 owner = "Lapineige";
51 repo = "FreshRSS_Extension-ReadingTime";
52 rev = "fb6e9e944ef6c5299fa56ffddbe04c41e5a34ebf";
53 hash = "sha256-C5cRfaphx4Qz2xg2z+v5qRji8WVSIpvzMbethTdSqsk=";
54 };
55 })
56 ]
57 '';
58 description = "Additional extensions to be used.";
59 };
60
61 defaultUser = mkOption {
62 type = types.str;
63 default = "admin";
64 description = "Default username for FreshRSS.";
65 example = "eva";
66 };
67
68 passwordFile = mkOption {
69 type = types.nullOr types.path;
70 default = null;
71 description = "Password for the defaultUser for FreshRSS.";
72 example = "/run/secrets/freshrss";
73 };
74
75 baseUrl = mkOption {
76 type = types.str;
77 description = "Default URL for FreshRSS.";
78 example = "https://freshrss.example.com";
79 };
80
81 language = mkOption {
82 type = types.str;
83 default = "en";
84 description = "Default language for FreshRSS.";
85 example = "de";
86 };
87
88 database = {
89 type = mkOption {
90 type = types.enum [
91 "sqlite"
92 "pgsql"
93 "mysql"
94 ];
95 default = "sqlite";
96 description = "Database type.";
97 example = "pgsql";
98 };
99
100 host = mkOption {
101 type = types.nullOr types.str;
102 default = "localhost";
103 description = "Database host for FreshRSS.";
104 };
105
106 port = mkOption {
107 type = types.nullOr types.port;
108 default = null;
109 description = "Database port for FreshRSS.";
110 example = 3306;
111 };
112
113 user = mkOption {
114 type = types.nullOr types.str;
115 default = "freshrss";
116 description = "Database user for FreshRSS.";
117 };
118
119 passFile = mkOption {
120 type = types.nullOr types.path;
121 default = null;
122 description = "Database password file for FreshRSS.";
123 example = "/run/secrets/freshrss";
124 };
125
126 name = mkOption {
127 type = types.nullOr types.str;
128 default = "freshrss";
129 description = "Database name for FreshRSS.";
130 };
131
132 tableprefix = mkOption {
133 type = types.nullOr types.str;
134 default = null;
135 description = "Database table prefix for FreshRSS.";
136 example = "freshrss";
137 };
138 };
139
140 dataDir = mkOption {
141 type = types.str;
142 default = "/var/lib/freshrss";
143 description = "Default data folder for FreshRSS.";
144 example = "/mnt/freshrss";
145 };
146
147 webserver = mkOption {
148 type = types.enum [
149 "nginx"
150 "caddy"
151 ];
152 default = "nginx";
153 description = ''
154 Whether to use nginx or caddy for virtual host management.
155
156 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
157 See [](#opt-services.nginx.virtualHosts) for further information.
158
159 Further caddy configuration can be done by adapting `services.caddy.virtualHosts.<name>`.
160 See [](#opt-services.caddy.virtualHosts) for further information.
161 '';
162 };
163
164 virtualHost = mkOption {
165 type = types.str;
166 default = "freshrss";
167 description = ''
168 Name of the caddy/nginx virtualhost to use and setup.
169 '';
170 };
171
172 pool = mkOption {
173 type = types.nullOr types.str;
174 default = "freshrss";
175 description = ''
176 Name of the php-fpm pool to use and setup. If not specified, a pool will be created
177 with default values.
178 '';
179 };
180
181 user = mkOption {
182 type = types.str;
183 default = "freshrss";
184 description = "User under which FreshRSS runs.";
185 };
186
187 authType = mkOption {
188 type = types.enum [
189 "form"
190 "http_auth"
191 "none"
192 ];
193 default = "form";
194 description = "Authentication type for FreshRSS.";
195 };
196 };
197
198 config =
199 let
200 defaultServiceConfig = {
201 ReadWritePaths = "${cfg.dataDir}";
202 DeviceAllow = "";
203 LockPersonality = true;
204 NoNewPrivileges = true;
205 PrivateDevices = true;
206 PrivateTmp = true;
207 PrivateUsers = true;
208 ProcSubset = "pid";
209 ProtectClock = true;
210 ProtectControlGroups = true;
211 ProtectHome = true;
212 ProtectHostname = true;
213 ProtectKernelLogs = true;
214 ProtectKernelModules = true;
215 ProtectKernelTunables = true;
216 ProtectProc = "invisible";
217 ProtectSystem = "strict";
218 RemoveIPC = true;
219 RestrictNamespaces = true;
220 RestrictRealtime = true;
221 RestrictSUIDSGID = true;
222 SystemCallArchitectures = "native";
223 SystemCallFilter = [
224 "@system-service"
225 "~@resources"
226 "~@privileged"
227 ];
228 UMask = "0007";
229 Type = "oneshot";
230 User = cfg.user;
231 Group = config.users.users.${cfg.user}.group;
232 StateDirectory = "freshrss";
233 WorkingDirectory = cfg.package;
234 };
235 in
236 mkIf cfg.enable {
237 assertions = mkIf (cfg.authType == "form") [
238 {
239 assertion = cfg.passwordFile != null;
240 message = ''
241 `passwordFile` must be supplied when using "form" authentication!
242 '';
243 }
244 ];
245
246 # Set up a Caddy virtual host.
247 services.caddy = mkIf (cfg.webserver == "caddy") {
248 enable = true;
249 virtualHosts.${cfg.virtualHost}.extraConfig = ''
250 root * ${config.services.freshrss.package}/p
251 php_fastcgi unix/${config.services.phpfpm.pools.freshrss.socket} {
252 env FRESHRSS_DATA_PATH ${config.services.freshrss.dataDir}
253 }
254 file_server
255 '';
256 };
257
258 # Set up a Nginx virtual host.
259 services.nginx = mkIf (cfg.webserver == "nginx") {
260 enable = true;
261 virtualHosts.${cfg.virtualHost} = {
262 root = "${cfg.package}/p";
263
264 # php files handling
265 # this regex is mandatory because of the API
266 locations."~ ^.+?\\.php(/.*)?$".extraConfig = ''
267 fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket};
268 fastcgi_split_path_info ^(.+\.php)(/.*)$;
269 # By default, the variable PATH_INFO is not set under PHP-FPM
270 # But FreshRSS API greader.php need it. If you have a “Bad Request” error, double check this var!
271 # NOTE: the separate $path_info variable is required. For more details, see:
272 # https://trac.nginx.org/nginx/ticket/321
273 set $path_info $fastcgi_path_info;
274 fastcgi_param PATH_INFO $path_info;
275 include ${pkgs.nginx}/conf/fastcgi_params;
276 include ${pkgs.nginx}/conf/fastcgi.conf;
277 '';
278
279 locations."/" = {
280 tryFiles = "$uri $uri/ index.php";
281 index = "index.php index.html index.htm";
282 };
283 };
284 };
285
286 # Set up phpfpm pool
287 services.phpfpm.pools = mkIf (cfg.pool != null) {
288 ${cfg.pool} = {
289 user = "freshrss";
290 settings = {
291 "listen.owner" = webserver.user;
292 "listen.group" = webserver.group;
293 "listen.mode" = "0600";
294 "pm" = "dynamic";
295 "pm.max_children" = 32;
296 "pm.max_requests" = 500;
297 "pm.start_servers" = 2;
298 "pm.min_spare_servers" = 2;
299 "pm.max_spare_servers" = 5;
300 "catch_workers_output" = true;
301 };
302 phpEnv = env-vars;
303 };
304 };
305
306 users.users."${cfg.user}" = {
307 description = "FreshRSS service user";
308 isSystemUser = true;
309 group = "${cfg.user}";
310 home = cfg.dataDir;
311 };
312 users.groups."${cfg.user}" = { };
313
314 systemd.tmpfiles.settings."10-freshrss".${cfg.dataDir}.d = {
315 inherit (cfg) user;
316 group = config.users.users.${cfg.user}.group;
317 };
318
319 systemd.services.freshrss-config =
320 let
321 settingsFlags = concatStringsSep " \\\n " (
322 mapAttrsToList (k: v: "${k} ${toString v}") {
323 "--default-user" = ''"${cfg.defaultUser}"'';
324 "--auth-type" = ''"${cfg.authType}"'';
325 "--base-url" = ''"${cfg.baseUrl}"'';
326 "--language" = ''"${cfg.language}"'';
327 "--db-type" = ''"${cfg.database.type}"'';
328 # The following attributes are optional depending on the type of
329 # database. Those that evaluate to null on the left hand side
330 # will be omitted.
331 ${if cfg.database.name != null then "--db-base" else null} = ''"${cfg.database.name}"'';
332 ${if cfg.database.passFile != null then "--db-password" else null} =
333 ''"$(cat ${cfg.database.passFile})"'';
334 ${if cfg.database.user != null then "--db-user" else null} = ''"${cfg.database.user}"'';
335 ${if cfg.database.tableprefix != null then "--db-prefix" else null} =
336 ''"${cfg.database.tableprefix}"'';
337 # hostname:port e.g. "localhost:5432"
338 ${if cfg.database.host != null && cfg.database.port != null then "--db-host" else null} =
339 ''"${cfg.database.host}:${toString cfg.database.port}"'';
340 # socket path e.g. "/run/postgresql"
341 ${if cfg.database.host != null && cfg.database.port == null then "--db-host" else null} =
342 ''"${cfg.database.host}"'';
343 }
344 );
345 in
346 {
347 description = "Set up the state directory for FreshRSS before use";
348 wantedBy = [ "multi-user.target" ];
349 serviceConfig = defaultServiceConfig // {
350 RemainAfterExit = true;
351 };
352 restartIfChanged = true;
353 environment = env-vars;
354
355 script =
356 let
357 userScriptArgs = ''--user ${cfg.defaultUser} ${
358 optionalString (cfg.authType == "form") ''--password "$(cat ${cfg.passwordFile})"''
359 }'';
360 updateUserScript = optionalString (cfg.authType == "form" || cfg.authType == "none") ''
361 ./cli/update-user.php ${userScriptArgs}
362 '';
363 createUserScript = optionalString (cfg.authType == "form" || cfg.authType == "none") ''
364 ./cli/create-user.php ${userScriptArgs}
365 '';
366 in
367 ''
368 # do installation or reconfigure
369 if test -f ${cfg.dataDir}/config.php; then
370 # reconfigure with settings
371 ./cli/reconfigure.php ${settingsFlags}
372 ${updateUserScript}
373 else
374 # check correct folders in data folder
375 ./cli/prepare.php
376 # install with settings
377 ./cli/do-install.php ${settingsFlags}
378 ${createUserScript}
379 fi
380 '';
381 };
382
383 systemd.services.freshrss-updater = {
384 description = "FreshRSS feed updater";
385 after = [ "freshrss-config.service" ];
386 startAt = "*:0/5";
387 environment = env-vars;
388 serviceConfig = defaultServiceConfig // {
389 ExecStart = "${cfg.package}/app/actualize_script.php";
390 };
391 };
392 };
393}