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