1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.nitter;
9 configFile = pkgs.writeText "nitter.conf" ''
10 ${lib.generators.toINI
11 {
12 # String values need to be quoted
13 mkKeyValue = lib.generators.mkKeyValueDefault {
14 mkValueString =
15 v:
16 if lib.isString v then
17 "\"" + (lib.escape [ "\"" ] (toString v)) + "\""
18 else
19 lib.generators.mkValueStringDefault { } v;
20 } " = ";
21 }
22 (
23 lib.recursiveUpdate {
24 Server = cfg.server;
25 Cache = cfg.cache;
26 Config = cfg.config // {
27 hmacKey = "@hmac@";
28 };
29 Preferences = cfg.preferences;
30 } cfg.settings
31 )
32 }
33 '';
34 # `hmac` is a secret used for cryptographic signing of video URLs.
35 # Generate it on first launch, then copy configuration and replace
36 # `@hmac@` with this value.
37 # We are not using sed as it would leak the value in the command line.
38 preStart = pkgs.writers.writePython3 "nitter-prestart" { } ''
39 import os
40 import secrets
41
42 state_dir = os.environ.get("STATE_DIRECTORY")
43 if not os.path.isfile(f"{state_dir}/hmac"):
44 # Generate hmac on first launch
45 hmac = secrets.token_hex(32)
46 with open(f"{state_dir}/hmac", "w") as f:
47 f.write(hmac)
48 else:
49 # Load previously generated hmac
50 with open(f"{state_dir}/hmac", "r") as f:
51 hmac = f.read()
52
53 configFile = "${configFile}"
54 with open(configFile, "r") as f_in:
55 with open(f"{state_dir}/nitter.conf", "w") as f_out:
56 f_out.write(f_in.read().replace("@hmac@", hmac))
57 '';
58in
59{
60 imports = [
61 # https://github.com/zedeus/nitter/pull/772
62 (lib.mkRemovedOptionModule [
63 "services"
64 "nitter"
65 "replaceInstagram"
66 ] "Nitter no longer supports this option as Bibliogram has been discontinued.")
67 (lib.mkRenamedOptionModule
68 [ "services" "nitter" "guestAccounts" ]
69 [ "services" "nitter" "sessionsFile" ]
70 )
71 ];
72
73 options = {
74 services.nitter = {
75 enable = lib.mkEnableOption "Nitter, an alternative Twitter front-end";
76
77 package = lib.mkPackageOption pkgs "nitter" { };
78
79 server = {
80 address = lib.mkOption {
81 type = lib.types.str;
82 default = "0.0.0.0";
83 example = "127.0.0.1";
84 description = "The address to listen on.";
85 };
86
87 port = lib.mkOption {
88 type = lib.types.port;
89 default = 8080;
90 example = 8000;
91 description = "The port to listen on.";
92 };
93
94 https = lib.mkOption {
95 type = lib.types.bool;
96 default = false;
97 description = "Set secure attribute on cookies. Keep it disabled to enable cookies when not using HTTPS.";
98 };
99
100 httpMaxConnections = lib.mkOption {
101 type = lib.types.int;
102 default = 100;
103 description = "Maximum number of HTTP connections.";
104 };
105
106 staticDir = lib.mkOption {
107 type = lib.types.path;
108 default = "${cfg.package}/share/nitter/public";
109 defaultText = lib.literalExpression ''"''${config.services.nitter.package}/share/nitter/public"'';
110 description = "Path to the static files directory.";
111 };
112
113 title = lib.mkOption {
114 type = lib.types.str;
115 default = "nitter";
116 description = "Title of the instance.";
117 };
118
119 hostname = lib.mkOption {
120 type = lib.types.str;
121 default = "localhost";
122 example = "nitter.net";
123 description = "Hostname of the instance.";
124 };
125 };
126
127 cache = {
128 listMinutes = lib.mkOption {
129 type = lib.types.int;
130 default = 240;
131 description = "How long to cache list info (not the tweets, so keep it high).";
132 };
133
134 rssMinutes = lib.mkOption {
135 type = lib.types.int;
136 default = 10;
137 description = "How long to cache RSS queries.";
138 };
139
140 redisHost = lib.mkOption {
141 type = lib.types.str;
142 default = "localhost";
143 description = "Redis host.";
144 };
145
146 redisPort = lib.mkOption {
147 type = lib.types.port;
148 default = 6379;
149 description = "Redis port.";
150 };
151
152 redisConnections = lib.mkOption {
153 type = lib.types.int;
154 default = 20;
155 description = "Redis connection pool size.";
156 };
157
158 redisMaxConnections = lib.mkOption {
159 type = lib.types.int;
160 default = 30;
161 description = ''
162 Maximum number of connections to Redis.
163
164 New connections are opened when none are available, but if the
165 pool size goes above this, they are closed when released, do not
166 worry about this unless you receive tons of requests per second.
167 '';
168 };
169 };
170
171 config = {
172 base64Media = lib.mkOption {
173 type = lib.types.bool;
174 default = false;
175 description = "Use base64 encoding for proxied media URLs.";
176 };
177
178 enableRSS = lib.mkEnableOption "RSS feeds" // {
179 default = true;
180 };
181
182 enableDebug = lib.mkEnableOption "request logs and debug endpoints";
183
184 proxy = lib.mkOption {
185 type = lib.types.str;
186 default = "";
187 description = "URL to a HTTP/HTTPS proxy.";
188 };
189
190 proxyAuth = lib.mkOption {
191 type = lib.types.str;
192 default = "";
193 description = "Credentials for proxy.";
194 };
195
196 tokenCount = lib.mkOption {
197 type = lib.types.int;
198 default = 10;
199 description = ''
200 Minimum amount of usable tokens.
201
202 Tokens are used to authorize API requests, but they expire after
203 ~1 hour, and have a limit of 187 requests. The limit gets reset
204 every 15 minutes, and the pool is filled up so there is always at
205 least tokenCount usable tokens. Only increase this if you receive
206 major bursts all the time.
207 '';
208 };
209 };
210
211 preferences = {
212 replaceTwitter = lib.mkOption {
213 type = lib.types.str;
214 default = "";
215 example = "nitter.net";
216 description = "Replace Twitter links with links to this instance (blank to disable).";
217 };
218
219 replaceYouTube = lib.mkOption {
220 type = lib.types.str;
221 default = "";
222 example = "piped.kavin.rocks";
223 description = "Replace YouTube links with links to this instance (blank to disable).";
224 };
225
226 replaceReddit = lib.mkOption {
227 type = lib.types.str;
228 default = "";
229 example = "teddit.net";
230 description = "Replace Reddit links with links to this instance (blank to disable).";
231 };
232
233 mp4Playback = lib.mkOption {
234 type = lib.types.bool;
235 default = true;
236 description = "Enable MP4 video playback.";
237 };
238
239 hlsPlayback = lib.mkOption {
240 type = lib.types.bool;
241 default = false;
242 description = "Enable HLS video streaming (requires JavaScript).";
243 };
244
245 proxyVideos = lib.mkOption {
246 type = lib.types.bool;
247 default = true;
248 description = "Proxy video streaming through the server (might be slow).";
249 };
250
251 muteVideos = lib.mkOption {
252 type = lib.types.bool;
253 default = false;
254 description = "Mute videos by default.";
255 };
256
257 autoplayGifs = lib.mkOption {
258 type = lib.types.bool;
259 default = true;
260 description = "Autoplay GIFs.";
261 };
262
263 theme = lib.mkOption {
264 type = lib.types.str;
265 default = "Nitter";
266 description = "Instance theme.";
267 };
268
269 infiniteScroll = lib.mkOption {
270 type = lib.types.bool;
271 default = false;
272 description = "Infinite scrolling (requires JavaScript, experimental!).";
273 };
274
275 stickyProfile = lib.mkOption {
276 type = lib.types.bool;
277 default = true;
278 description = "Make profile sidebar stick to top.";
279 };
280
281 bidiSupport = lib.mkOption {
282 type = lib.types.bool;
283 default = false;
284 description = "Support bidirectional text (makes clicking on tweets harder).";
285 };
286
287 hideTweetStats = lib.mkOption {
288 type = lib.types.bool;
289 default = false;
290 description = "Hide tweet stats (replies, retweets, likes).";
291 };
292
293 hideBanner = lib.mkOption {
294 type = lib.types.bool;
295 default = false;
296 description = "Hide profile banner.";
297 };
298
299 hidePins = lib.mkOption {
300 type = lib.types.bool;
301 default = false;
302 description = "Hide pinned tweets.";
303 };
304
305 hideReplies = lib.mkOption {
306 type = lib.types.bool;
307 default = false;
308 description = "Hide tweet replies.";
309 };
310
311 squareAvatars = lib.mkOption {
312 type = lib.types.bool;
313 default = false;
314 description = "Square profile pictures.";
315 };
316 };
317
318 settings = lib.mkOption {
319 type = lib.types.attrs;
320 default = { };
321 description = ''
322 Add settings here to override NixOS module generated settings.
323
324 Check the official repository for the available settings:
325 <https://github.com/zedeus/nitter/blob/master/nitter.example.conf>
326 '';
327 };
328
329 sessionsFile = lib.mkOption {
330 type = lib.types.path;
331 default = "/var/lib/nitter/sessions.jsonl";
332 description = ''
333 Path to the session tokens file.
334
335 This file contains a list of session tokens that can be used to
336 access the instance without logging in. The file is in JSONL format,
337 where each line is a JSON object with the following fields:
338
339 {"oauth_token":"some_token","oauth_token_secret":"some_secret_key"}
340
341 See <https://github.com/zedeus/nitter/wiki/Creating-session-tokens>
342 for more information on session tokens and how to generate them.
343 '';
344 };
345
346 redisCreateLocally = lib.mkOption {
347 type = lib.types.bool;
348 default = true;
349 description = "Configure local Redis server for Nitter.";
350 };
351
352 openFirewall = lib.mkOption {
353 type = lib.types.bool;
354 default = false;
355 description = "Open ports in the firewall for Nitter web interface.";
356 };
357 };
358 };
359
360 config = lib.mkIf cfg.enable {
361 assertions = [
362 {
363 assertion =
364 !cfg.redisCreateLocally || (cfg.cache.redisHost == "localhost" && cfg.cache.redisPort == 6379);
365 message = "When services.nitter.redisCreateLocally is enabled, you need to use localhost:6379 as a cache server.";
366 }
367 ];
368
369 systemd.services.nitter = {
370 description = "Nitter (An alternative Twitter front-end)";
371 wantedBy = [ "multi-user.target" ];
372 wants = [ "network-online.target" ];
373 after = [ "network-online.target" ];
374 serviceConfig = {
375 DynamicUser = true;
376 LoadCredential = "sessionsFile:${cfg.sessionsFile}";
377 StateDirectory = "nitter";
378 Environment = [
379 "NITTER_CONF_FILE=/var/lib/nitter/nitter.conf"
380 "NITTER_SESSIONS_FILE=%d/sessionsFile"
381 ];
382 # Some parts of Nitter expect `public` folder in working directory,
383 # see https://github.com/zedeus/nitter/issues/414
384 WorkingDirectory = "${cfg.package}/share/nitter";
385 ExecStart = "${cfg.package}/bin/nitter";
386 ExecStartPre = "${preStart}";
387 AmbientCapabilities = lib.mkIf (cfg.server.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
388 Restart = "on-failure";
389 RestartSec = "5s";
390 # Hardening
391 CapabilityBoundingSet = if (cfg.server.port < 1024) then [ "CAP_NET_BIND_SERVICE" ] else [ "" ];
392 DeviceAllow = [ "" ];
393 LockPersonality = true;
394 MemoryDenyWriteExecute = true;
395 PrivateDevices = true;
396 # A private user cannot have process capabilities on the host's user
397 # namespace and thus CAP_NET_BIND_SERVICE has no effect.
398 PrivateUsers = (cfg.server.port >= 1024);
399 ProcSubset = "pid";
400 ProtectClock = true;
401 ProtectControlGroups = true;
402 ProtectHome = true;
403 ProtectHostname = true;
404 ProtectKernelLogs = true;
405 ProtectKernelModules = true;
406 ProtectKernelTunables = true;
407 ProtectProc = "invisible";
408 RestrictAddressFamilies = [
409 "AF_INET"
410 "AF_INET6"
411 ];
412 RestrictNamespaces = true;
413 RestrictRealtime = true;
414 RestrictSUIDSGID = true;
415 SystemCallArchitectures = "native";
416 SystemCallFilter = [
417 "@system-service"
418 "~@privileged"
419 "~@resources"
420 ];
421 UMask = "0077";
422 };
423 };
424
425 services.redis.servers.nitter = lib.mkIf (cfg.redisCreateLocally) {
426 enable = true;
427 port = cfg.cache.redisPort;
428 };
429
430 networking.firewall = lib.mkIf cfg.openFirewall {
431 allowedTCPPorts = [ cfg.server.port ];
432 };
433 };
434}