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