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 (lib.mdDoc "Nitter");
56
57 package = mkOption {
58 default = pkgs.nitter;
59 type = types.package;
60 defaultText = literalExpression "pkgs.nitter";
61 description = lib.mdDoc "The nitter derivation to use.";
62 };
63
64 server = {
65 address = mkOption {
66 type = types.str;
67 default = "0.0.0.0";
68 example = "127.0.0.1";
69 description = lib.mdDoc "The address to listen on.";
70 };
71
72 port = mkOption {
73 type = types.port;
74 default = 8080;
75 example = 8000;
76 description = lib.mdDoc "The port to listen on.";
77 };
78
79 https = mkOption {
80 type = types.bool;
81 default = false;
82 description = lib.mdDoc "Set secure attribute on cookies. Keep it disabled to enable cookies when not using HTTPS.";
83 };
84
85 httpMaxConnections = mkOption {
86 type = types.int;
87 default = 100;
88 description = lib.mdDoc "Maximum number of HTTP connections.";
89 };
90
91 staticDir = mkOption {
92 type = types.path;
93 default = "${cfg.package}/share/nitter/public";
94 defaultText = literalExpression ''"''${config.services.nitter.package}/share/nitter/public"'';
95 description = lib.mdDoc "Path to the static files directory.";
96 };
97
98 title = mkOption {
99 type = types.str;
100 default = "nitter";
101 description = lib.mdDoc "Title of the instance.";
102 };
103
104 hostname = mkOption {
105 type = types.str;
106 default = "localhost";
107 example = "nitter.net";
108 description = lib.mdDoc "Hostname of the instance.";
109 };
110 };
111
112 cache = {
113 listMinutes = mkOption {
114 type = types.int;
115 default = 240;
116 description = lib.mdDoc "How long to cache list info (not the tweets, so keep it high).";
117 };
118
119 rssMinutes = mkOption {
120 type = types.int;
121 default = 10;
122 description = lib.mdDoc "How long to cache RSS queries.";
123 };
124
125 redisHost = mkOption {
126 type = types.str;
127 default = "localhost";
128 description = lib.mdDoc "Redis host.";
129 };
130
131 redisPort = mkOption {
132 type = types.port;
133 default = 6379;
134 description = lib.mdDoc "Redis port.";
135 };
136
137 redisConnections = mkOption {
138 type = types.int;
139 default = 20;
140 description = lib.mdDoc "Redis connection pool size.";
141 };
142
143 redisMaxConnections = mkOption {
144 type = types.int;
145 default = 30;
146 description = lib.mdDoc ''
147 Maximum number of connections to Redis.
148
149 New connections are opened when none are available, but if the
150 pool size goes above this, they are closed when released, do not
151 worry about this unless you receive tons of requests per second.
152 '';
153 };
154 };
155
156 config = {
157 base64Media = mkOption {
158 type = types.bool;
159 default = false;
160 description = lib.mdDoc "Use base64 encoding for proxied media URLs.";
161 };
162
163 enableRSS = mkEnableOption (lib.mdDoc "RSS feeds") // { default = true; };
164
165 enableDebug = mkEnableOption (lib.mdDoc "request logs and debug endpoints");
166
167 proxy = mkOption {
168 type = types.nullOr types.str;
169 default = null;
170 description = lib.mdDoc "URL to a HTTP/HTTPS proxy.";
171 };
172
173 proxyAuth = mkOption {
174 type = types.nullOr types.str;
175 default = null;
176 description = lib.mdDoc "Credentials for proxy.";
177 };
178
179 tokenCount = mkOption {
180 type = types.int;
181 default = 10;
182 description = lib.mdDoc ''
183 Minimum amount of usable tokens.
184
185 Tokens are used to authorize API requests, but they expire after
186 ~1 hour, and have a limit of 187 requests. The limit gets reset
187 every 15 minutes, and the pool is filled up so there is always at
188 least tokenCount usable tokens. Only increase this if you receive
189 major bursts all the time.
190 '';
191 };
192 };
193
194 preferences = {
195 replaceTwitter = mkOption {
196 type = types.str;
197 default = "";
198 example = "nitter.net";
199 description = lib.mdDoc "Replace Twitter links with links to this instance (blank to disable).";
200 };
201
202 replaceYouTube = mkOption {
203 type = types.str;
204 default = "";
205 example = "piped.kavin.rocks";
206 description = lib.mdDoc "Replace YouTube links with links to this instance (blank to disable).";
207 };
208
209 replaceReddit = mkOption {
210 type = types.str;
211 default = "";
212 example = "teddit.net";
213 description = lib.mdDoc "Replace Reddit links with links to this instance (blank to disable).";
214 };
215
216 mp4Playback = mkOption {
217 type = types.bool;
218 default = true;
219 description = lib.mdDoc "Enable MP4 video playback.";
220 };
221
222 hlsPlayback = mkOption {
223 type = types.bool;
224 default = false;
225 description = lib.mdDoc "Enable HLS video streaming (requires JavaScript).";
226 };
227
228 proxyVideos = mkOption {
229 type = types.bool;
230 default = true;
231 description = lib.mdDoc "Proxy video streaming through the server (might be slow).";
232 };
233
234 muteVideos = mkOption {
235 type = types.bool;
236 default = false;
237 description = lib.mdDoc "Mute videos by default.";
238 };
239
240 autoplayGifs = mkOption {
241 type = types.bool;
242 default = true;
243 description = lib.mdDoc "Autoplay GIFs.";
244 };
245
246 theme = mkOption {
247 type = types.str;
248 default = "Nitter";
249 description = lib.mdDoc "Instance theme.";
250 };
251
252 infiniteScroll = mkOption {
253 type = types.bool;
254 default = false;
255 description = lib.mdDoc "Infinite scrolling (requires JavaScript, experimental!).";
256 };
257
258 stickyProfile = mkOption {
259 type = types.bool;
260 default = true;
261 description = lib.mdDoc "Make profile sidebar stick to top.";
262 };
263
264 bidiSupport = mkOption {
265 type = types.bool;
266 default = false;
267 description = lib.mdDoc "Support bidirectional text (makes clicking on tweets harder).";
268 };
269
270 hideTweetStats = mkOption {
271 type = types.bool;
272 default = false;
273 description = lib.mdDoc "Hide tweet stats (replies, retweets, likes).";
274 };
275
276 hideBanner = mkOption {
277 type = types.bool;
278 default = false;
279 description = lib.mdDoc "Hide profile banner.";
280 };
281
282 hidePins = mkOption {
283 type = types.bool;
284 default = false;
285 description = lib.mdDoc "Hide pinned tweets.";
286 };
287
288 hideReplies = mkOption {
289 type = types.bool;
290 default = false;
291 description = lib.mdDoc "Hide tweet replies.";
292 };
293
294 squareAvatars = mkOption {
295 type = types.bool;
296 default = false;
297 description = lib.mdDoc "Square profile pictures.";
298 };
299 };
300
301 settings = mkOption {
302 type = types.attrs;
303 default = {};
304 description = lib.mdDoc ''
305 Add settings here to override NixOS module generated settings.
306
307 Check the official repository for the available settings:
308 https://github.com/zedeus/nitter/blob/master/nitter.example.conf
309 '';
310 };
311
312 redisCreateLocally = mkOption {
313 type = types.bool;
314 default = true;
315 description = lib.mdDoc "Configure local Redis server for Nitter.";
316 };
317
318 openFirewall = mkOption {
319 type = types.bool;
320 default = false;
321 description = lib.mdDoc "Open ports in the firewall for Nitter web interface.";
322 };
323 };
324 };
325
326 config = mkIf cfg.enable {
327 assertions = [
328 {
329 assertion = !cfg.redisCreateLocally || (cfg.cache.redisHost == "localhost" && cfg.cache.redisPort == 6379);
330 message = "When services.nitter.redisCreateLocally is enabled, you need to use localhost:6379 as a cache server.";
331 }
332 ];
333
334 systemd.services.nitter = {
335 description = "Nitter (An alternative Twitter front-end)";
336 wantedBy = [ "multi-user.target" ];
337 after = [ "network.target" ];
338 serviceConfig = {
339 DynamicUser = true;
340 StateDirectory = "nitter";
341 Environment = [ "NITTER_CONF_FILE=/var/lib/nitter/nitter.conf" ];
342 # Some parts of Nitter expect `public` folder in working directory,
343 # see https://github.com/zedeus/nitter/issues/414
344 WorkingDirectory = "${cfg.package}/share/nitter";
345 ExecStart = "${cfg.package}/bin/nitter";
346 ExecStartPre = "${preStart}";
347 AmbientCapabilities = lib.mkIf (cfg.server.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
348 Restart = "on-failure";
349 RestartSec = "5s";
350 # Hardening
351 CapabilityBoundingSet = if (cfg.server.port < 1024) then [ "CAP_NET_BIND_SERVICE" ] else [ "" ];
352 DeviceAllow = [ "" ];
353 LockPersonality = true;
354 MemoryDenyWriteExecute = true;
355 PrivateDevices = true;
356 # A private user cannot have process capabilities on the host's user
357 # namespace and thus CAP_NET_BIND_SERVICE has no effect.
358 PrivateUsers = (cfg.server.port >= 1024);
359 ProcSubset = "pid";
360 ProtectClock = true;
361 ProtectControlGroups = true;
362 ProtectHome = true;
363 ProtectHostname = true;
364 ProtectKernelLogs = true;
365 ProtectKernelModules = true;
366 ProtectKernelTunables = true;
367 ProtectProc = "invisible";
368 RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
369 RestrictNamespaces = true;
370 RestrictRealtime = true;
371 RestrictSUIDSGID = true;
372 SystemCallArchitectures = "native";
373 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
374 UMask = "0077";
375 };
376 };
377
378 services.redis.servers.nitter = lib.mkIf (cfg.redisCreateLocally) {
379 enable = true;
380 port = cfg.cache.redisPort;
381 };
382
383 networking.firewall = mkIf cfg.openFirewall {
384 allowedTCPPorts = [ cfg.server.port ];
385 };
386 };
387}