Files
Yifan Gao b2b8edc0dd fix: stop operator attribution at the nearest known ASN
Shared upstream attribution could assign CERNET prefixes to China Telecom
or Unicom even when CERNET originated the route. Stop at the closest
configured operator in each prefix/origin's common AS-path suffix, while
preserving downstream coverage and multi-origin observations.

Correct overlapping CNGI/legacy ASN name matches, include operator
boundaries in the cache key, and wire the policy into list generation.
Accumulate common suffixes instead of retaining every observed path.

Add synthetic MRT and dual-stack regressions, fixed ASN matching checks,
and document the resulting list semantics.

Validation: 26 Rust tests and Ruby operator checks pass. Frozen-input full
generation preserves china and the seven-operator address union. All 41
sample classifications agree with the RIB paths; a fresh-cache rerun
after the internal simplification produces 28 byte-identical artifacts.

Refs #64
2026-09-14 23:26:01 +08:00

338 lines
12 KiB
Makefile

set unstable
default: prepare all stat
# Install or update bgp tooling dependencies
dependency:
#!/usr/bin/env bash
set -euo pipefail
cargo build --release --locked
if ! bgpkit-broker --version >/dev/null 2>&1; then
cargo binstall --secure --no-confirm bgpkit-broker@0.7.0
fi
target/release/china-operator-ip --version
bgpkit-broker --version
# Download and normalize latest autnums list
prepare_autnums:
#!/usr/bin/env bash
set -euo pipefail
aria2c -s 4 -x 4 -q -o autnums.html --allow-overwrite=true https://bgp.potaroo.net/cidr/autnums.html
awk -F'[<>]' '{print $3,$5}' autnums.html | grep '^AS' > asnames.txt
rm -f autnums.html
echo "INFO> asnames.txt updated ($(wc -l < asnames.txt) entries)" >&2
# Download an RIR extended delegated statistics file
prepare_registry registry url:
#!/usr/bin/env bash
set -euo pipefail
outfile="delegated-{{registry}}-extended-latest"
download="${outfile}.download"
rm -f "${download}" "${download}.aria2"
if aria2c -s 4 -x 4 -q -o "${download}" --allow-overwrite=true "{{url}}" && [[ -s "${download}" ]]; then
mv "${download}" "${outfile}"
echo "INFO> ${outfile} updated ($(wc -l < "${outfile}") records)" >&2
elif [[ -s "${outfile}" ]]; then
rm -f "${download}" "${download}.aria2"
echo "WARNING> failed to update ${outfile}; using cached copy ($(wc -l < "${outfile}") records)" >&2
else
rm -f "${download}" "${download}.aria2"
echo "ERROR> failed to update ${outfile} and no cached copy is available" >&2
exit 1
fi
# Download all RIR delegated statistics
[parallel]
prepare_registries: \
(prepare_registry "afrinic" "https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest") \
(prepare_registry "apnic" "https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest") \
(prepare_registry "arin" "https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest") \
(prepare_registry "lacnic" "https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest") \
(prepare_registry "ripencc" "https://ftp.ripe.net/ripe/stats/delegated-ripencc-extended-latest")
# Download the latest RIB snapshot for a collector
prepare_rib collector:
#!/usr/bin/env bash
set -euo pipefail
url="$(bgpkit-broker latest -c "{{collector}}" --json \
| jq -r '.[] | select(.data_type | contains("rib")) | .url' \
| head -n 1)"
if [[ -z "${url}" ]]; then
echo "Unable to determine {{collector}} RIB download url" >&2
exit 1
fi
if [[ "${url}" =~ (\.gz|\.bz2)$ ]]; then
suffix="${BASH_REMATCH[1]}"
else
echo "Unsupported archive format for {{collector}}: ${url}" >&2
exit 1
fi
outfile="rib-{{collector}}${suffix}"
rm -f "${outfile}"
aria2c -s 4 -x 4 -q -o "${outfile}" "${url}"
stat "${outfile}"
echo "INFO> ${outfile} ready for BGP classification" >&2
# Download the latest RIB snapshots (rrc21, rrc12, route-views6)
[parallel]
prepare_ribs: (prepare_rib "rrc00") (prepare_rib "rrc21") (prepare_rib "rrc12") (prepare_rib "route-views6")
# Prepare data for generation
[parallel]
prepare: prepare_autnums prepare_registries prepare_ribs
# Print allocated and assigned prefixes registered to COUNTRY by any RIR
registry_prefixes country:
#!/usr/bin/env ruby
require "ipaddr"
country = "{{country}}"
registries = %w[afrinic apnic arin lacnic ripencc]
files = registries.map { |registry| "delegated-#{registry}-extended-latest" }
missing = files.reject { |file| File.file?(file) && File.size?(file) }
abort("Missing delegated RIR files: #{missing.join(", ")}. Run 'just prepare_registries' first.") unless missing.empty?
prefixes = []
files.each do |file|
File.foreach(file) do |line|
_registry, record_country, type, start, value, _date, status = line.chomp.split("|", 8)
next unless record_country == country
next unless %w[allocated assigned].include?(status)
case type
when "ipv4"
current = IPAddr.new(start).to_i
remaining = Integer(value)
while remaining.positive?
alignment = current.zero? ? (1 << 32) : current & -current
block = [alignment, 1 << (remaining.bit_length - 1)].min
prefix_len = 32 - (block.bit_length - 1)
prefixes << [4, current, prefix_len, "#{IPAddr.new(current, Socket::AF_INET)}/#{prefix_len}"]
current += block
remaining -= block
end
when "ipv6"
prefix_len = Integer(value)
network = IPAddr.new("#{start}/#{prefix_len}")
prefixes << [6, network.to_i, prefix_len, "#{network}/#{prefix_len}"]
end
end
end
abort("No registered prefixes found for #{country}") if prefixes.empty?
prefixes.uniq.sort_by { |family, address, prefix_len, _| [family, address, prefix_len] }.each do |*_, prefix|
puts prefix
end
# Print raw ASN candidates for OPERATOR based on operators.yaml
get_asn_candidates_raw operator:
#!/usr/bin/env ruby
require "yaml"
cfg, asnames = "operators.yaml", "asnames.txt"
abort("Missing config: #{cfg}") unless File.file?(cfg)
abort("Missing asnames.txt. Run 'just prepare_autnums' first.") unless File.file?(asnames) && File.size?(asnames)
op = YAML.load_file(cfg).fetch("operators").fetch("{{operator}}")
country = op.fetch("country")
pattern_re = Regexp.new(op["pattern"].to_s, Regexp::IGNORECASE)
exclude_re = Regexp.new(op.fetch("exclude", "^$"), Regexp::IGNORECASE)
File.foreach(asnames) do |line|
line.chomp!
match = line.match(/^AS(\d+)\b.*,\s*([A-Z]{2})$/)
asn, line_country = match&.captures
next unless line_country == country
next unless pattern_re.match?(line)
next if exclude_re.match?(line)
puts asn
end
# Print static ASN candidates for OPERATOR based on operators.yaml
get_asn_candidates operator:
#!/usr/bin/env ruby
require "set"
require "yaml"
operator = "{{operator}}"
cfg = YAML.load_file("operators.yaml").fetch("operators").fetch(operator)
exclude_asn = cfg.fetch("exclude_asn", []).map(&:to_s).to_set
candidate_asns = IO.popen(["just", "get_asn_candidates_raw", operator], &:read).split
abort("Failed to get raw ASN candidates for #{operator}") unless $?.success?
candidate_asns.each do |asn|
puts asn unless exclude_asn.include?(asn)
end
# Print the trusted transit ASN set configured for OPERATOR
trusted_transit_asn operator:
#!/usr/bin/env ruby
require "set"
require "yaml"
operator = "{{operator}}"
operators = YAML.load_file("operators.yaml").fetch("operators")
cfg = operators.fetch(operator)
trusted_operators = cfg.fetch("trusted_transit_operators", [])
trusted_asns = Set.new
trusted_operators.each do |trusted_operator|
output = IO.popen(["just", "get_asn_candidates", trusted_operator], &:read)
abort("Failed to get trusted transit ASNs for #{trusted_operator}") unless $?.success?
trusted_asns.merge(output.split)
end
trusted_asns.sort_by(&:to_i).each { |asn| puts asn }
# Print all known operator ASNs used as shared-upstream boundaries
operator_asns:
#!/usr/bin/env ruby
require "set"
require "yaml"
asns = Set.new
YAML.load_file("operators.yaml").fetch("operators").each do |operator, cfg|
next if cfg.fetch("origin_only", false)
output = IO.popen(["just", "get_asn_candidates", operator], &:read)
abort("Failed to get operator ASNs for #{operator}") unless $?.success?
asns.merge(output.split)
end
asns.sort_by(&:to_i).each { |asn| puts asn }
# Generate IP lists for a single operator
gen operator:
#!/usr/bin/env ruby
require "fileutils"
require "yaml"
operator = "{{operator}}"
FileUtils.mkdir_p("result")
out, v4, v6 = %W[result/#{operator}46.txt result/#{operator}.txt result/#{operator}6.txt]
cfg = YAML.load_file("operators.yaml").fetch("operators").fetch(operator)
origin_only = cfg.fetch("origin_only", false)
ribs = Dir["rib-*.{gz,bz2}"].sort
abort("No rib-*.gz or rib-*.bz2 files found. Run 'just prepare_ribs' first.") if ribs.empty?
classifier = ["target/release/china-operator-ip", "--ignore-private-asn", "--cache"]
classifier << "--origin-only" if origin_only
unless origin_only
operator_asns = IO.popen(["just", "operator_asns"], &:read)
abort("Failed to get operator ASNs") unless $?.success?
operator_asn_path = "result/.operator-asns.txt"
File.write(operator_asn_path, operator_asns)
classifier += ["--operator-asn-file", operator_asn_path]
end
if country = cfg["registry_fallback_country"]
registered = IO.popen(["just", "registry_prefixes", country], &:read)
abort("Failed to get registered prefixes for #{country}") unless $?.success?
registered_path = "result/.#{operator}.registered.txt"
File.write(registered_path, registered)
classifier += ["--fallback-prefix-file", registered_path]
end
if cfg.fetch("trusted_transit_operators", []).any?
trusted_transit = IO.popen(["just", "trusted_transit_asn", operator], &:read)
abort("Failed to get trusted transit ASNs for #{operator}") unless $?.success?
trusted_transit_path = "result/.#{operator}.trusted-transit.txt"
File.write(trusted_transit_path, trusted_transit)
classifier += [
"--trusted-cn-transit-file",
trusted_transit_path,
"--asn-country-file",
"asnames.txt",
]
end
classifier += ribs.flat_map { |r| ["--mrt-file", r] }
warn "INFO> #{operator} start"
asns = IO.popen(["just", "get_asn_candidates", operator], &:read)
abort("Failed to get ASN list for #{operator}") unless $?.success?
abort("Failed to run BGP classifier for #{operator}") unless system(*classifier, *asns.split, out: out)
v6_lines, v4_lines = File.readlines(out).partition { |line| line.include?(":") }
File.write(v4, v4_lines.join)
File.write(v6, v6_lines.join)
warn "INFO> #{operator} done (v4=#{v4_lines.length} v6=#{v6_lines.length})"
# Generate IP lists for all operators sequentially
all:
#!/usr/bin/env ruby
require "yaml"
ops = YAML.load_file("operators.yaml").fetch("operators").keys.sort
ops.each do |op|
status = system("just", "gen", op)
exit($?.exitstatus || 1) unless status
end
guard:
#!/usr/bin/env ruby
{"china.txt" => 3000, "china6.txt" => 1000}.each do |f, min|
next if File.foreach("result/#{f}").count >= min
warn "#{f} too small"
exit 1
end
warn "INFO> guard checks passed"
# Summarize total IPv4/IPv6 address space per operator
stat:
#!/usr/bin/env ruby
require "yaml"
dir = "result"
files = Dir.exist?(dir) ? Dir.glob("#{dir}/*.txt").sort : []
files.reject! { |p| p.end_with?("46.txt") }
abort("result/*.txt files missing") if files.empty?
report = files.map do |p|
base = p.end_with?("6.txt") ? 48 : 32
total = File.foreach(p).sum do |line|
match = %r{/(\d+)}.match(line)
next 0 unless match
prefix_len = match[1].to_i
prefix_len <= base ? (1 << (base - prefix_len)) : 0
end
"#{File.basename(p, ".txt")}\n#{total}"
end.join("\n\n") + "\n"
print report
File.write("#{dir}/stat", report)
# Publish generated results into the ip-lists branch
upload: guard
#!/usr/bin/env bash
set -euo pipefail
rm -f ip-lists/{.,}*.txt
mv result/{*,.*.txt} ip-lists
cd ip-lists
tree -H . -P "*.txt|stat" -T "China Operator IP - prebuild results" > index.html
git config user.name "GitHub Actions"
git config user.email noreply@github.com
git add .
git commit -m "update $(date +%Y-%m-%d)"
git push -q
# Refresh CDN cache for all files in ip-lists directory
refresh_jsdelivr repository:
#!/usr/bin/env ruby
require "net/http"
dir = "ip-lists"
abort("#{dir} directory not found") unless Dir.exist?(dir)
Dir.children(dir).sort.each do |file|
warn "INFO> purging CDN cache for #{file}"
puts Net::HTTP.get_response(URI("https://purge.jsdelivr.net/gh/{{repository}}@#{dir}/#{file}")).inspect
end