1{
2 lib,
3 pkgs,
4 config,
5 utils,
6 ...
7}:
8with lib;
9let
10 cfg = config.services.lemmy;
11 settingsFormat = pkgs.formats.json { };
12in
13{
14 meta.maintainers = with maintainers; [ happysalada ];
15 meta.doc = ./lemmy.md;
16
17 imports = [
18 (mkRemovedOptionModule [
19 "services"
20 "lemmy"
21 "jwtSecretPath"
22 ] "As of v0.13.0, Lemmy auto-generates the JWT secret.")
23 ];
24
25 options.services.lemmy = {
26
27 enable = mkEnableOption "lemmy a federated alternative to reddit in rust";
28
29 server = {
30 package = mkPackageOption pkgs "lemmy-server" { };
31 };
32
33 ui = {
34 package = mkPackageOption pkgs "lemmy-ui" { };
35
36 port = mkOption {
37 type = types.port;
38 default = 1234;
39 description = "Port where lemmy-ui should listen for incoming requests.";
40 };
41 };
42
43 caddy.enable = mkEnableOption "exposing lemmy with the caddy reverse proxy";
44 nginx.enable = mkEnableOption "exposing lemmy with the nginx reverse proxy";
45
46 database = {
47 createLocally = mkEnableOption "creation of database on the instance";
48
49 uri = mkOption {
50 type = with types; nullOr str;
51 default = null;
52 description = "The connection URI to use. Takes priority over the configuration file if set.";
53 };
54
55 uriFile = mkOption {
56 type = with types; nullOr path;
57 default = null;
58 description = "File which contains the database uri.";
59 };
60 };
61
62 pictrsApiKeyFile = mkOption {
63 type = with types; nullOr path;
64 default = null;
65 description = "File which contains the value of `pictrs.api_key`.";
66 };
67
68 smtpPasswordFile = mkOption {
69 type = with types; nullOr path;
70 default = null;
71 description = "File which contains the value of `email.smtp_password`.";
72 };
73
74 adminPasswordFile = mkOption {
75 type = with types; nullOr path;
76 default = null;
77 description = "File which contains the value of `setup.admin_password`.";
78 };
79
80 settings = mkOption {
81 default = { };
82 description = "Lemmy configuration";
83
84 type = types.submodule {
85 freeformType = settingsFormat.type;
86
87 options.hostname = mkOption {
88 type = types.str;
89 default = null;
90 description = "The domain name of your instance (eg 'lemmy.ml').";
91 };
92
93 options.port = mkOption {
94 type = types.port;
95 default = 8536;
96 description = "Port where lemmy should listen for incoming requests.";
97 };
98
99 options.captcha = {
100 enabled = mkOption {
101 type = types.bool;
102 default = true;
103 description = "Enable Captcha.";
104 };
105 difficulty = mkOption {
106 type = types.enum [
107 "easy"
108 "medium"
109 "hard"
110 ];
111 default = "medium";
112 description = "The difficultly of the captcha to solve.";
113 };
114 };
115 };
116 };
117 };
118
119 config =
120 let
121 secretOptions = {
122 pictrsApiKeyFile = {
123 setting = [
124 "pictrs"
125 "api_key"
126 ];
127 path = cfg.pictrsApiKeyFile;
128 };
129 smtpPasswordFile = {
130 setting = [
131 "email"
132 "smtp_password"
133 ];
134 path = cfg.smtpPasswordFile;
135 };
136 adminPasswordFile = {
137 setting = [
138 "setup"
139 "admin_password"
140 ];
141 path = cfg.adminPasswordFile;
142 };
143 uriFile = {
144 setting = [
145 "database"
146 "uri"
147 ];
148 path = cfg.database.uriFile;
149 };
150 };
151 secrets = lib.filterAttrs (option: data: data.path != null) secretOptions;
152 in
153 lib.mkIf cfg.enable {
154 services.lemmy.settings =
155 lib.attrsets.recursiveUpdate
156 (
157 mapAttrs (name: mkDefault) {
158 bind = "127.0.0.1";
159 tls_enabled = true;
160 pictrs = {
161 url = with config.services.pict-rs; "http://${address}:${toString port}";
162 };
163 actor_name_max_length = 20;
164
165 rate_limit.message = 180;
166 rate_limit.message_per_second = 60;
167 rate_limit.post = 6;
168 rate_limit.post_per_second = 600;
169 rate_limit.register = 3;
170 rate_limit.register_per_second = 3600;
171 rate_limit.image = 6;
172 rate_limit.image_per_second = 3600;
173 }
174 // {
175 database = mapAttrs (name: mkDefault) {
176 user = "lemmy";
177 host = "/run/postgresql";
178 port = 5432;
179 database = "lemmy";
180 pool_size = 5;
181 };
182 }
183 )
184 (
185 lib.foldlAttrs (
186 acc: option: data:
187 acc // lib.setAttrByPath data.setting { _secret = option; }
188 ) { } secrets
189 );
190 # the option name is the id of the credential loaded by LoadCredential
191
192 services.postgresql = mkIf cfg.database.createLocally {
193 enable = true;
194 ensureDatabases = [ cfg.settings.database.database ];
195 ensureUsers = [
196 {
197 name = cfg.settings.database.user;
198 ensureDBOwnership = true;
199 }
200 ];
201 };
202
203 services.pict-rs.enable = true;
204
205 services.caddy = mkIf cfg.caddy.enable {
206 enable = mkDefault true;
207 virtualHosts."${cfg.settings.hostname}" = {
208 extraConfig = ''
209 handle_path /static/* {
210 root * ${cfg.ui.package}/dist
211 file_server
212 }
213 handle_path /static/${cfg.ui.package.passthru.commit_sha}/* {
214 root * ${cfg.ui.package}/dist
215 file_server
216 }
217 @for_backend {
218 path /api/* /pictrs/* /feeds/* /nodeinfo/*
219 }
220 handle @for_backend {
221 reverse_proxy 127.0.0.1:${toString cfg.settings.port}
222 }
223 @post {
224 method POST
225 }
226 handle @post {
227 reverse_proxy 127.0.0.1:${toString cfg.settings.port}
228 }
229 @jsonld {
230 header Accept "application/activity+json"
231 header Accept "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
232 }
233 handle @jsonld {
234 reverse_proxy 127.0.0.1:${toString cfg.settings.port}
235 }
236 handle {
237 reverse_proxy 127.0.0.1:${toString cfg.ui.port}
238 }
239 '';
240 };
241 };
242
243 services.nginx = mkIf cfg.nginx.enable {
244 enable = mkDefault true;
245 virtualHosts."${cfg.settings.hostname}".locations =
246 let
247 ui = "http://127.0.0.1:${toString cfg.ui.port}";
248 backend = "http://127.0.0.1:${toString cfg.settings.port}";
249 in
250 {
251 "~ ^/(api|pictrs|feeds|nodeinfo|.well-known)" = {
252 # backend requests
253 proxyPass = backend;
254 proxyWebsockets = true;
255 recommendedProxySettings = true;
256 };
257 "/" = {
258 # mixed frontend and backend requests, based on the request headers
259 extraConfig = ''
260 set $proxpass "${ui}";
261 if ($http_accept = "application/activity+json") {
262 set $proxpass "${backend}";
263 }
264 if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") {
265 set $proxpass "${backend}";
266 }
267 if ($request_method = POST) {
268 set $proxpass "${backend}";
269 }
270
271 # Cuts off the trailing slash on URLs to make them valid
272 rewrite ^(.+)/+$ $1 permanent;
273
274 proxy_pass $proxpass;
275 # Proxied `Host` header is required to validate ActivityPub HTTP signatures for incoming events.
276 # The other headers are optional, for the sake of better log data.
277 proxy_set_header X-Real-IP $remote_addr;
278 proxy_set_header Host $host;
279 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
280 '';
281 };
282 };
283 };
284
285 assertions = [
286 {
287 assertion =
288 cfg.database.createLocally
289 -> cfg.settings.database.host == "localhost" || cfg.settings.database.host == "/run/postgresql";
290 message = "if you want to create the database locally, you need to use a local database";
291 }
292 {
293 assertion =
294 (!(hasAttrByPath [ "federation" ] cfg.settings))
295 && (!(hasAttrByPath [ "federation" "enabled" ] cfg.settings));
296 message = "`services.lemmy.settings.federation` was removed in 0.17.0 and no longer has any effect";
297 }
298 {
299 assertion = cfg.database.uriFile != null -> cfg.database.uri == null && !cfg.database.createLocally;
300 message = "specifying a database uri while also specifying a database uri file is not allowed";
301 }
302 ];
303
304 systemd.services.lemmy =
305 let
306 substitutedConfig = "/run/lemmy/config.hjson";
307 in
308 {
309 description = "Lemmy server";
310
311 environment = {
312 LEMMY_CONFIG_LOCATION =
313 if secrets == { } then settingsFormat.generate "config.hjson" cfg.settings else substitutedConfig;
314 LEMMY_DATABASE_URL =
315 if cfg.database.uri != null then
316 cfg.database.uri
317 else
318 (mkIf (cfg.database.createLocally) "postgres:///lemmy?host=/run/postgresql&user=lemmy");
319 };
320
321 documentation = [
322 "https://join-lemmy.org/docs/en/admins/from_scratch.html"
323 "https://join-lemmy.org/docs/en/"
324 ];
325
326 wantedBy = [ "multi-user.target" ];
327
328 after = [ "pict-rs.service" ] ++ lib.optionals cfg.database.createLocally [ "postgresql.service" ];
329
330 requires = lib.optionals cfg.database.createLocally [ "postgresql.service" ];
331
332 # substitute secrets and prevent others from reading the result
333 # if somehow $CREDENTIALS_DIRECTORY is not set we fail
334 preStart = mkIf (secrets != { }) ''
335 set -u
336 umask u=rw,g=,o=
337 cd "$CREDENTIALS_DIRECTORY"
338 ${utils.genJqSecretsReplacementSnippet cfg.settings substitutedConfig}
339 '';
340
341 serviceConfig = {
342 DynamicUser = true;
343 RuntimeDirectory = "lemmy";
344 ExecStart = "${cfg.server.package}/bin/lemmy_server";
345 LoadCredential = lib.foldlAttrs (
346 acc: option: data:
347 acc ++ [ "${option}:${toString data.path}" ]
348 ) [ ] secrets;
349 PrivateTmp = true;
350 MemoryDenyWriteExecute = true;
351 NoNewPrivileges = true;
352 };
353 };
354
355 systemd.services.lemmy-ui = {
356 description = "Lemmy ui";
357
358 environment = {
359 LEMMY_UI_HOST = "127.0.0.1:${toString cfg.ui.port}";
360 LEMMY_UI_LEMMY_INTERNAL_HOST = "127.0.0.1:${toString cfg.settings.port}";
361 LEMMY_UI_LEMMY_EXTERNAL_HOST = cfg.settings.hostname;
362 LEMMY_UI_HTTPS = "false";
363 NODE_ENV = "production";
364 };
365
366 documentation = [
367 "https://join-lemmy.org/docs/en/admins/from_scratch.html"
368 "https://join-lemmy.org/docs/en/"
369 ];
370
371 wantedBy = [ "multi-user.target" ];
372
373 after = [ "lemmy.service" ];
374
375 requires = [ "lemmy.service" ];
376
377 serviceConfig = {
378 DynamicUser = true;
379 WorkingDirectory = "${cfg.ui.package}";
380 ExecStart = "${pkgs.nodejs}/bin/node ${cfg.ui.package}/dist/js/server.js";
381 };
382 };
383 };
384
385}