From cdd4fef61711376b72d3bc2fa0865682fc654c4e Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 18 Aug 2026 21:41:52 +0300 Subject: [PATCH] hosts/appview: more nginx rate-limit configs / ip blocks Signed-off-by: Anirudh Oppiliappan --- flake.lock | 26 ++++++++++ flake.nix | 23 ++++++++- hosts/appview/services/nginx.nix | 81 ++++++++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/flake.lock b/flake.lock index c4c6436..46a4bd7 100644 --- a/flake.lock +++ b/flake.lock @@ -48,6 +48,30 @@ "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" } }, + "asn-alibaba": { + "flake": false, + "locked": { + "narHash": "sha256-2Bf2W33SiKHFNwL6GbUmIWb6ycBS2/UdP7uhcYuqO+s=", + "type": "file", + "url": "https://raw.githubusercontent.com/ipverse/asn-ip/master/as/45102/aggregated.json" + }, + "original": { + "type": "file", + "url": "https://raw.githubusercontent.com/ipverse/asn-ip/master/as/45102/aggregated.json" + } + }, + "asn-tencent": { + "flake": false, + "locked": { + "narHash": "sha256-WRplWSD99/8HEH2h5sTwNfcsYYLACGS1qtBUFnREbzU=", + "type": "file", + "url": "https://raw.githubusercontent.com/ipverse/asn-ip/master/as/132203/aggregated.json" + }, + "original": { + "type": "file", + "url": "https://raw.githubusercontent.com/ipverse/asn-ip/master/as/132203/aggregated.json" + } + }, "atlogin": { "inputs": { "flake-utils": "flake-utils", @@ -998,6 +1022,8 @@ }, "root": { "inputs": { + "asn-alibaba": "asn-alibaba", + "asn-tencent": "asn-tencent", "atlogin": "atlogin", "cloudflare-ips-v4": "cloudflare-ips-v4", "cloudflare-ips-v6": "cloudflare-ips-v6", diff --git a/flake.nix b/flake.nix index 819ba96..ab66d52 100644 --- a/flake.nix +++ b/flake.nix @@ -15,6 +15,19 @@ url = "file+https://www.cloudflare.com/ips-v6"; flake = false; }; + # Aggregated prefix lists for the clouds hosting the scraper fleet. + # AS45102 = Alibaba (hosting, announces both 8.208/12 and 47.80/13); + # AS132203 = Tencent. Deliberately *not* AS37963 (Alibaba China): it + # announces 9.4M addresses and none of the observed traffic. + # Refresh with `nix flake update asn-alibaba asn-tencent`. + asn-alibaba = { + url = "file+https://raw.githubusercontent.com/ipverse/asn-ip/master/as/45102/aggregated.json"; + flake = false; + }; + asn-tencent = { + url = "file+https://raw.githubusercontent.com/ipverse/asn-ip/master/as/132203/aggregated.json"; + flake = false; + }; disko = { url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; @@ -34,6 +47,8 @@ colmena, cloudflare-ips-v4, cloudflare-ips-v6, + asn-alibaba, + asn-tencent, nixery-flake, tangled, tangled-mirror, @@ -47,6 +62,10 @@ cloudflareRanges = lib.filter (line: line != "") (lib.concatMap (input: lib.splitString "\n" (builtins.readFile input.outPath)) [cloudflare-ips-v4 cloudflare-ips-v6]); + scraperAsnRanges = + lib.concatMap + (input: (builtins.fromJSON (builtins.readFile input.outPath)).prefixes.ipv4) + [asn-alibaba asn-tencent]; baseModules = [ disko.nixosModules.disko @@ -59,7 +78,7 @@ lib.nixosSystem { inherit system; specialArgs = { - inherit commonArgs cloudflareRanges; + inherit commonArgs cloudflareRanges scraperAsnRanges; nixery-pkgs = import nixery-flake.outPath { pkgs = import nixpkgs {inherit system;}; }; @@ -193,7 +212,7 @@ meta = { nixpkgs = nixpkgs.legacyPackages.${system}; specialArgs = { - inherit commonArgs cloudflareRanges; + inherit commonArgs cloudflareRanges scraperAsnRanges; nixery-pkgs = import nixery-flake.outPath { pkgs = import nixpkgs {inherit system;}; }; diff --git a/hosts/appview/services/nginx.nix b/hosts/appview/services/nginx.nix index 721f7a2..f1e725c 100644 --- a/hosts/appview/services/nginx.nix +++ b/hosts/appview/services/nginx.nix @@ -2,6 +2,7 @@ config, pkgs, lib, + scraperAsnRanges, ... }: let # Denied from the ssh stream proxy; HTTP block_scraper does not apply here. @@ -10,6 +11,38 @@ "98.82.21.79" ]; sshDenyRules = lib.concatMapStringsSep "\n " (cidr: "deny ${cidr};") sshBlocklist; + + # Hand-maintained ranges, confirmed dropping traffic (261k packets on the /10 + # alone). Kept even though the ASN lists overlap them: these are *wider* than + # what AS132203 announces, so replacing them with ASN data would un-block + # space that is currently being dropped. + manualRanges = [ + "43.128.0.0/10" + "129.226.0.0/16" + "170.106.0.0/16" + "49.51.0.0/16" + ]; + + # Union with the per-ASN prefix lists from flake.lock. Overlap is free: both + # ipset hash:net and nginx's geo radix tree resolve by longest match. + blockedRanges = lib.unique (manualRanges ++ scraperAsnRanges); + blockedGeoRules = lib.concatMapStringsSep "\n " (cidr: "${cidr} 1;") blockedRanges; + + # ~360 prefixes is far too many for linearly-scanned iptables rules, so they + # go in one hash:net set matched by a single rule. + scraperSet = "scraper-nets"; + scraperSetFile = + pkgs.writeText "scraper-nets.ipset" + ("create ${scraperSet} hash:net family inet maxelem 65536\n" + + lib.concatMapStrings (cidr: "add ${scraperSet} ${cidr}\n") blockedRanges); + + # Aggregate ceiling for the heavy routes, shared by every client regardless of + # source IP -- the per-IP limit below cannot see a fleet that spreads itself + # thin across thousands of addresses. Tune against what appview can sustain + # against mirror-fsn: raise if legitimate users see 429s, lower if upstream + # still saturates. + heavyGlobalRate = "50r/s"; + heavyGlobalBurst = 100; in { services.nginx = { enable = true; @@ -20,8 +53,29 @@ in { eventsConfig = '' worker_connections 16384; ''; - # bot blocking + # rate limiting and bot blocking appendHttpConfig = '' + # Rate-limit only the expensive git-content routes. nginx does not account + # requests whose key is empty, so ordinary page loads, static assets and + # /notifications/count polling are untouched. + map $uri $heavy_route_key { + default ""; + "~*/(blob|raw|tree|archive|compare)/" $binary_remote_addr; + } + + # Same routes, one shared bucket, so the ceiling holds however the load + # is spread across source addresses. + map $heavy_route_key $heavy_global_key { + "" ""; + default "heavy"; + } + + limit_req_zone $heavy_route_key zone=git_content:10m rate=5r/s; + limit_req_zone $heavy_global_key zone=git_global:1m rate=${heavyGlobalRate}; + limit_conn_zone $binary_remote_addr zone=per_ip_conn:10m; + limit_req_status 429; + limit_conn_status 429; + map $http_user_agent $block_bot { default 0; ~*PerplexityBot 1; @@ -68,9 +122,8 @@ in { 207.145.252.0/24 1; 207.145.253.0/24 1; 207.145.255.0/24 1; - 43.172.0.0/15 1; - 43.132.0.0/16 1; 13.220.42.207/32 1; + ${blockedGeoRules} } ''; @@ -179,6 +232,9 @@ in { locations."/" = { proxyPass = "http://127.0.0.1:3000"; extraConfig = '' + limit_req zone=git_content burst=15 nodelay; + limit_req zone=git_global burst=${toString heavyGlobalBurst} nodelay; + limit_conn per_ip_conn 50; client_max_body_size 100M; ''; }; @@ -191,6 +247,25 @@ in { # Open firewall ports networking.firewall.allowedTCPPorts = [80 443 2222 22 3333]; + # Drop the scraper fleet before nginx accepts the connection. The nginx-level + # 403 above still costs an accept() and a TLS handshake per request, which is + # where the CPU was going. + # + # -I at position 1: extraCommands runs *after* the allowedTCPPorts ACCEPT + # rules, so an appended DROP would never be reached for 80/443. Every command + # is `|| true` on purpose -- if this script aborts early the firewall never + # installs its closing catch-all REJECT, which would fail open. + networking.firewall.extraPackages = [pkgs.ipset]; + networking.firewall.extraCommands = '' + ipset create -exist ${scraperSet} hash:net family inet maxelem 65536 || true + ipset flush ${scraperSet} || true + ipset restore -! -f ${scraperSetFile} || true + iptables -w -I nixos-fw 1 -m set --match-set ${scraperSet} src -j DROP || true + ''; + networking.firewall.extraStopCommands = '' + iptables -w -D nixos-fw -m set --match-set ${scraperSet} src -j DROP || true + ''; + # ACME configuration for Let's Encrypt security.acme = { acceptTerms = true; -- 2.51.2