Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ It is three parts (epic #287, #304):
the bundle does not hold falls through to `@fallback`, which answers the route
map in `deploy/nginx/conf.d/openipc-redirects.conf` (redirects, 410s for
retired addresses, a 302 home for anything unclaimed) and otherwise serves the
bundle's 404 page. That map is maintained by hand.
bundle's 404 page. That map is maintained by hand. Networks the TSPU cuts
off from the origin (every AS registered in Russia, generated by
`deploy/blocked-nets.py`) get a 301 to the same address on openipc.ru,
uploads included; `conf.d/openipc-blocked-nets.conf`.

## Commands

Expand Down
88 changes: 88 additions & 0 deletions deploy/blocked-nets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Regenerate deploy/nginx/conf.d/openipc-blocked-nets.list.

The list is every network announced by an autonomous system registered in a
country whose providers cannot reach openipc.org intact -- today Russia, where
the TSPU freezes a TLS connection to the origin's hosting network after the
first 20-odd kilobytes. nginx answers those networks with a 301 to the mirror
(conf.d/openipc-blocked-nets.conf).

deploy/blocked-nets.py # fetch iptoasn.com's table, rewrite the list
deploy/blocked-nets.py --from FILE # from a downloaded ip2asn-combined.tsv(.gz)

The table maps each announced range to the AS that announces it and the
country that AS is registered in. Ranges are turned into CIDRs and collapsed,
so the file nginx loads is as short as the address space allows. Run it by
hand when the list should catch up with routing, and commit the result: the
file is reviewed like any other configuration, and nothing on the host fetches
it.
"""

import argparse
import gzip
import io
import ipaddress
import os
import sys
import urllib.request
from datetime import datetime, timezone

SOURCE = "https://iptoasn.com/data/ip2asn-combined.tsv.gz"
COUNTRIES = ("RU",)
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "nginx", "conf.d", "openipc-blocked-nets.list")


def read_table(path):
if path is None:
with urllib.request.urlopen(SOURCE, timeout=120) as r:
raw = r.read()
else:
with open(path, "rb") as f:
raw = f.read()
if raw[:2] == b"\x1f\x8b":
raw = gzip.decompress(raw)
return io.StringIO(raw.decode("utf-8", "replace"))


def networks(table, countries):
v4, v6, asns = [], [], set()
for line in table:
fields = line.rstrip("\n").split("\t")
if len(fields) < 4:
continue
start, end, asn, country = fields[:4]
# AS 0 is "not routed": address space nobody announces.
if country not in countries or asn == "0":
continue
asns.add(asn)
first, last = ipaddress.ip_address(start), ipaddress.ip_address(end)
(v4 if first.version == 4 else v6).extend(ipaddress.summarize_address_range(first, last))
return list(ipaddress.collapse_addresses(v4)), list(ipaddress.collapse_addresses(v6)), asns


def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--from", dest="src", help="a downloaded ip2asn-combined.tsv or .tsv.gz")
ap.add_argument("--out", default=OUT)
args = ap.parse_args()

v4, v6, asns = networks(read_table(args.src), COUNTRIES)
if not v4 or len(asns) < 1000:
sys.exit(f"blocked-nets: {len(asns)} ASes and {len(v4)} IPv4 networks; the table looks wrong, nothing written")

day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
tmp = args.out + ".tmp"
with open(tmp, "w") as f:
f.write(f"# Generated by deploy/blocked-nets.py from {SOURCE} on {day}.\n")
f.write(f"# Every network announced by an AS registered in {', '.join(COUNTRIES)}: "
f"{len(asns)} ASes, {len(v4)} IPv4 and {len(v6)} IPv6 networks.\n")
f.write("# Do not edit; rerun the script. What it is for: openipc-blocked-nets.conf.\n")
for n in v4 + v6:
f.write(f"{n} 1;\n")
os.replace(tmp, args.out)
print(f"{args.out}: {len(asns)} ASes, {len(v4)} IPv4 + {len(v6)} IPv6 networks")


if __name__ == "__main__":
main()
22 changes: 21 additions & 1 deletion deploy/nginx/check-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ set -e
cp /repo/nginx.conf /etc/nginx/nginx.conf
mkdir -p /etc/nginx/conf.d /etc/nginx/sites-available /etc/nginx/sites-enabled
rm -f /etc/nginx/conf.d/default.conf
cp /repo/conf.d/*.conf /etc/nginx/conf.d/
cp /repo/conf.d/*.conf /repo/conf.d/*.list /etc/nginx/conf.d/
cp /repo/sites-available/* /etc/nginx/sites-available/
for f in /etc/nginx/sites-available/*; do ln -sf "$f" /etc/nginx/sites-enabled/; done
# The route state openipc-route owns on the host (deploy/route.sh): all go.
Expand Down Expand Up @@ -196,6 +196,9 @@ printf 'PNG\n' > /srv/www/shared/images/logo_openipc.png
# and CI's way through it are measured rather than read off the map.
sed -i 's|^geo \$openipc_datacentre_client {|&\n 127.0.0.2/32 1;|' \
/etc/nginx/conf.d/openipc-datacentre-block.conf
# And a third for a network the TSPU cuts off, which is sent to the mirror.
sed -i 's|^ include /etc/nginx/conf.d/openipc-blocked-nets.list;|&\n 127.0.0.3/32 1;|' \
/etc/nginx/conf.d/openipc-blocked-nets.conf

# Redirected explicitly. A daemonised nginx still inherits this exec's stdout
# and stderr, and `docker exec` does not return until those close -- so
Expand Down Expand Up @@ -642,6 +645,23 @@ for probe in "403 GET /" "403 GET /.git/config" "403 POST /snapshots" "200 POST
fail=1
fi
done
# From a network cut off from the origin: the same address on the mirror,
# query and all, whatever the method -- a camera's upload included
# (conf.d/openipc-blocked-nets.conf). Nothing else is redirected.
from_blocked() {
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' -k --max-time 5 --interface 127.0.0.3 \
--resolve "openipc.org:443:127.0.0.1" "$@" 2>/dev/null
}
for probe in "GET /ru/get-started?a=1" "POST /snapshots" "GET /api/v1/wizard/gk7205v300.json"; do
set -- $probe
got=$(from_blocked -X "$1" "https://openipc.org$2")
if [ "$got" = "301 https://openipc.ru$2" ]; then
printf ' %-32s %-5s (%s from a blocked network)\n' "$2" "${got%% *}" "$1"
else
printf ' %-32s %-5s (%s from a blocked network) MISMATCH: want 301 to https://openipc.ru%s\n' "$2" "$got" "$1" "$2"
fail=1
fi
done
expect $FW 200 go hsts
grep -q IMAGE /tmp/b || { echo " the firmware X-Accel-Redirect did not reach /firmware-cache/"; fail=1; }
redirects_to openipc.org /snapshots https://openipc.org/open-wall
Expand Down
41 changes: 41 additions & 0 deletions deploy/nginx/conf.d/openipc-blocked-nets.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Networks that cannot reach the origin intact, sent to the mirror.
#
# WHAT HAPPENS TO THEM. In Russia the TSPU freezes a TLS connection to this
# host's network after the first 20-odd kilobytes. Captured here on 2026-10-03,
# four camera uploads from Rostelecom, KES and SOVAM addresses: handshake
# complete, 22.8-24.9 KB of the picture received and acknowledged, then nothing
# in either direction -- the FIN nginx sent at its 60 s body timeout was
# retransmitted seven times and never acknowledged. 561 uploads ended that way
# on 2026-10-02. In the same period every upload that arrived through
# openipc.ru, which proxies this site from a host inside Russia, was stored:
# 552 of 552, plus eight 429s.
#
# WHAT THEY GET. A 301 to the same address on https://openipc.ru, before
# anything else is read. The answer is a few hundred bytes, so it arrives
# before the freeze does. Which networks, and from what, is decided here and
# nowhere else: a client carries openipc.org and follows a redirect, and never
# names a mirror itself.
#
# WHO THEY ARE. Every network announced by an AS registered in Russia, in
# openipc-blocked-nets.list -- generated by deploy/blocked-nets.py, committed,
# and refreshed by rerunning it.
#
# KEYED ON THE PEER, $realip_remote_addr, not on $remote_addr. A mirror's
# X-Forwarded-For is trusted (nginx.conf, set_real_ip_from), so behind
# openipc.ru $remote_addr IS a Russian reader -- and a rule keyed on it would
# send the mirror's own request back to the mirror, round and round. The peer
# is the host that connected. And openipc.ru's host is itself in a Russian AS
# (197695, REG.RU), so each mirror is listed below as not blocked, the more
# specific entry winning as it always does in a geo block. service/deploytest
# fails if an address in set_real_ip_from is missing here.
#
# Applied in sites-available/org.openipc's HTTPS server only. Port 80 is left
# alone: stock camera firmware fetches ipctool and sends its report there in
# plain HTTP, because it has no TLS to follow a redirect into.
geo $realip_remote_addr $openipc_blocked_net {
default 0;
include /etc/nginx/conf.d/openipc-blocked-nets.list;

194.58.109.202/32 0; # openipc.ru, опенипц.рф
194.238.42.216/32 0; # openipc.kz, openipc.cloud
}
Loading
Loading