at 24.11-pre 13 kB view raw
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}