mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-11-03 16:25:48 +08:00
Compare commits
No commits in common. "main" and "v0.0.5-20250411151652" have entirely different histories.
main
...
v0.0.5-202
16
.cnb.yml
16
.cnb.yml
@ -1,16 +0,0 @@
|
||||
main:
|
||||
push:
|
||||
- &id001
|
||||
name: index-public-documentation
|
||||
stages:
|
||||
- name: Index public documentation
|
||||
type: knowledge:update
|
||||
options:
|
||||
include:
|
||||
- README*.md
|
||||
- skills/oneclickvirt-backtrace/SKILL.md
|
||||
- skills/oneclickvirt-backtrace/SKILL_ZH.md
|
||||
issueSyncEnabled: false
|
||||
ignoreProcessFailures: false
|
||||
api_trigger_knowledge:
|
||||
- *id001
|
||||
@ -1,12 +0,0 @@
|
||||
npc:
|
||||
roles:
|
||||
- name: 使用说明
|
||||
prompt: 依据当前仓库与已引入的公开文档知识库回答安装、使用和版本问题,附上文档链接。核对文档对应的版本与架构;没有依据时说明缺少的信息。仅提供说明和操作建议,不执行宿主机操作,也不请求或展示密钥及私有仓库内容。
|
||||
defaultRepo: oneclickvirt/backtrace
|
||||
defaultRole: 使用说明
|
||||
button:
|
||||
name: 使用说明
|
||||
description: 查询公开的安装和使用文档
|
||||
imports:
|
||||
list:
|
||||
- oneclickvirt/oneclickvirt.github.io
|
||||
19
.github/dependabot.yml
vendored
19
.github/dependabot.yml
vendored
@ -1,19 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions-patch-minor:
|
||||
patterns:
|
||||
- '*'
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
actions-major:
|
||||
patterns:
|
||||
- '*'
|
||||
update-types:
|
||||
- major
|
||||
117
.github/scripts/ci_network.sh
vendored
117
.github/scripts/ci_network.sh
vendored
@ -1,117 +0,0 @@
|
||||
# shellcheck shell=bash
|
||||
# CI network operations have bounded retries; build and test commands are not retried.
|
||||
ci_retry() {
|
||||
local attempt status stdout_file stderr_file
|
||||
stdout_file=$(mktemp)
|
||||
stderr_file=$(mktemp)
|
||||
for attempt in 1 2 3; do
|
||||
if "$@" >"$stdout_file" 2>"$stderr_file"; then
|
||||
cat "$stdout_file"
|
||||
cat "$stderr_file" >&2
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
if [[ "$attempt" == 3 ]] || ! grep -Eiq 'HTTP[^0-9]*(408|429|500|502|503|504)|timed? out|timeout|connection (reset|refused|closed)|temporary failure|TLS handshake|unexpected EOF|could not resolve host|failed to connect|remote end hung up' "$stdout_file" "$stderr_file"; then
|
||||
cat "$stdout_file"
|
||||
cat "$stderr_file" >&2
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
return "$status"
|
||||
fi
|
||||
sleep "$((attempt * 2))"
|
||||
done
|
||||
}
|
||||
gh() { ci_retry command gh "$@"; }
|
||||
git() {
|
||||
if [[ "${1:-}" != clone ]]; then
|
||||
command git "$@"
|
||||
return
|
||||
fi
|
||||
local destination temporary status
|
||||
local clone_args=("$@")
|
||||
destination="${clone_args[${#clone_args[@]}-1]}"
|
||||
# Calls without an explicit destination keep git's native behavior.
|
||||
if [[ "$destination" == *://* || "$destination" == *.git ]]; then
|
||||
ci_retry command git "$@"
|
||||
return
|
||||
fi
|
||||
if [[ -e "$destination" || -L "$destination" ]]; then
|
||||
printf 'Clone destination already exists.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
temporary=$(mktemp -d "${destination}.ci-XXXXXX")
|
||||
clone_args[${#clone_args[@]}-1]="$temporary"
|
||||
# A failed clone may leave a partial checkout; clear only our owned temp directory.
|
||||
if ci_retry ci_clone_attempt "$temporary" "${clone_args[@]}"; then
|
||||
if [[ -e "$destination" || -L "$destination" ]]; then
|
||||
rm -rf "$temporary"
|
||||
return 1
|
||||
fi
|
||||
mv "$temporary" "$destination"
|
||||
else
|
||||
status=$?
|
||||
rm -rf "$temporary"
|
||||
return "$status"
|
||||
fi
|
||||
}
|
||||
ci_clone_attempt() {
|
||||
local temporary="$1"
|
||||
shift
|
||||
rm -rf "$temporary"
|
||||
command git "$@"
|
||||
}
|
||||
curl() {
|
||||
local argument mutating=false status stdout_file stderr_file source_index=-1 backup='' i
|
||||
local curl_args=("$@")
|
||||
for ((i=0; i<${#curl_args[@]}; i++)); do
|
||||
argument="${curl_args[i]}"
|
||||
case "$argument" in
|
||||
--fail-with-body) curl_args[i]=--fail ;;
|
||||
--data*|-d*|--form*|-F*|--upload-file*|-T*) mutating=true ;;
|
||||
POST|PATCH|PUT|DELETE|-XPOST|-XPATCH|-XPUT|-XDELETE|--request=POST|--request=PATCH|--request=PUT|--request=DELETE) mutating=true ;;
|
||||
https://*) source_index=$i ;;
|
||||
esac
|
||||
done
|
||||
if $mutating; then
|
||||
# A timed-out mutation may already have reached the server.
|
||||
command curl --fail --connect-timeout 15 --max-time 600 "${curl_args[@]}"
|
||||
return
|
||||
fi
|
||||
if [[ "$source_index" -ge 0 ]]; then
|
||||
case "${curl_args[source_index]}" in
|
||||
https://cdn.spiritlhl.net/https://*|https://cdn.spiritlhl.top/https://*)
|
||||
backup="${curl_args[source_index]#*/https://}"
|
||||
backup="https://$backup" ;;
|
||||
https://ftp.gnu.org/gnu/*)
|
||||
backup="https://ftpmirror.gnu.org/${curl_args[source_index]#https://ftp.gnu.org/gnu/}" ;;
|
||||
esac
|
||||
fi
|
||||
stdout_file=$(mktemp)
|
||||
stderr_file=$(mktemp)
|
||||
if command curl --fail --connect-timeout 15 --max-time 1200 --retry 3 --retry-max-time 180 --retry-connrefused "${curl_args[@]}" >"$stdout_file" 2>"$stderr_file"; then
|
||||
cat "$stdout_file"
|
||||
cat "$stderr_file" >&2
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
if [[ -n "$backup" ]]; then
|
||||
curl_args[source_index]="$backup"
|
||||
if command curl --fail --connect-timeout 15 --max-time 1200 --retry 3 --retry-max-time 180 --retry-connrefused "${curl_args[@]}" >"$stdout_file" 2>"$stderr_file"; then
|
||||
cat "$stdout_file"
|
||||
cat "$stderr_file" >&2
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
fi
|
||||
cat "$stdout_file"
|
||||
cat "$stderr_file" >&2
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
return "$status"
|
||||
}
|
||||
wget() { command wget --tries=3 --timeout=45 --waitretry=2 --retry-connrefused "$@"; }
|
||||
66
.github/scripts/merge_action_updates.py
vendored
66
.github/scripts/merge_action_updates.py
vendored
@ -1,66 +0,0 @@
|
||||
"""Merge Dependabot Action patch/minor updates after all head checks pass."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
repository = os.environ['GITHUB_REPOSITORY']
|
||||
event = json.load(open(os.environ['GITHUB_EVENT_PATH']))
|
||||
|
||||
def call(path, method='GET', body=None):
|
||||
req = urllib.request.Request('https://api.github.com/repos/' + repository + path, method=method,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=40) as response:
|
||||
value = response.read()
|
||||
return json.loads(value) if value else None
|
||||
|
||||
def versioned_changes(files):
|
||||
count = 0
|
||||
for file in files:
|
||||
path = file['filename']
|
||||
if not path.startswith('.github/') or not path.endswith(('.yml', '.yaml')) or 'patch' not in file:
|
||||
return False
|
||||
old, new = {}, {}
|
||||
for line in file['patch'].splitlines():
|
||||
if not line.startswith(('-', '+')) or line.startswith(('---', '+++')):
|
||||
continue
|
||||
match = re.match(r'[+-]\s*(?:-\s*)?uses:\s*([^\s@]+)@[0-9a-f]{40}\s+#\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$', line)
|
||||
if not match:
|
||||
# Updating any workflow behavior or an unversioned action
|
||||
# remains a reviewed change.
|
||||
return False
|
||||
(old if line[0] == '-' else new)[match[1]] = tuple(int(x or 0) for x in match.groups()[1:])
|
||||
if set(old) != set(new):
|
||||
return False
|
||||
for action in old:
|
||||
if old[action][0] != new[action][0] or new[action] < old[action]:
|
||||
return False
|
||||
count += 1
|
||||
return count > 0
|
||||
|
||||
run = event['workflow_run']
|
||||
if run['conclusion'] == 'success' and run['event'] == 'pull_request':
|
||||
for reference in run.get('pull_requests', []):
|
||||
number = reference['number']
|
||||
pr = call('/pulls/' + str(number))
|
||||
if pr['state'] != 'open' or pr['user']['login'] != 'dependabot[bot]' or pr['head']['repo']['full_name'] != repository:
|
||||
continue
|
||||
if pr['head']['sha'] != run['head_sha'] or pr.get('draft'):
|
||||
continue
|
||||
files = call('/pulls/' + str(number) + '/files?per_page=100')
|
||||
if pr['changed_files'] > 100 or not versioned_changes(files):
|
||||
continue
|
||||
sha = pr['head']['sha']
|
||||
for attempt in range(90):
|
||||
checks = call('/commits/' + sha + '/check-runs?per_page=100')['check_runs']
|
||||
statuses = call('/commits/' + sha + '/status')['statuses']
|
||||
if any(x['status'] == 'completed' and x['conclusion'] not in ('success', 'neutral', 'skipped') for x in checks) or any(x['state'] in ('failure', 'error') for x in statuses):
|
||||
break
|
||||
if checks and all(x['status'] == 'completed' for x in checks) and all(x['state'] == 'success' for x in statuses):
|
||||
latest = call('/pulls/' + str(number))
|
||||
if latest['head']['sha'] == sha and latest['state'] == 'open':
|
||||
call('/pulls/' + str(number) + '/merge', 'PUT', {'sha': sha, 'merge_method': 'squash'})
|
||||
break
|
||||
time.sleep(20)
|
||||
30
.github/workflows/action-policy.yml
vendored
30
.github/workflows/action-policy.yml
vendored
@ -1,30 +0,0 @@
|
||||
name: Action policy
|
||||
'on':
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/**
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Actions
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.26'
|
||||
cache: false
|
||||
- name: Validate workflows
|
||||
run: |
|
||||
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
"$(go env GOPATH)/bin/actionlint" -shellcheck= -pyflakes= -ignore 'label .* is unknown'
|
||||
- name: Verify private output isolation
|
||||
run: |
|
||||
if [[ -d .github/tests ]]; then python3 -m unittest discover -s .github/tests -p "test_*.py"; fi
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org|direct
|
||||
46
.github/workflows/build.yml
vendored
46
.github/workflows/build.yml
vendored
@ -1,38 +1,48 @@
|
||||
name: 创建IPv6检测的前缀
|
||||
concurrency:
|
||||
group: backtrace-ipv6-prefix-sync
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
jobs:
|
||||
fetch-ipv6-prefixes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: 设置 Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: 获取并处理多个ASN的IPv6前缀
|
||||
run: |
|
||||
go run ./cmd/update-prefixes -output-dir bk/prefix
|
||||
mkdir -p bk/prefix/
|
||||
for asn in AS4809 AS4134 AS9929 AS4837 AS58807 AS9808 AS58453 AS23764; do
|
||||
echo "处理 $asn..."
|
||||
curl -s -A "Mozilla/5.0" \
|
||||
"https://bgp.he.net/$asn" > "${asn}.html"
|
||||
grep -oE '[0-9a-f:]+::/[0-9]+' "${asn}.html" | sort -u > tmp_prefixes.txt
|
||||
{
|
||||
while read prefix; do
|
||||
ip_part=$(echo "$prefix" | cut -d/ -f1)
|
||||
prefix_len=$(echo "$prefix" | cut -d/ -f2)
|
||||
keep_segments=$((prefix_len / 16))
|
||||
segments=$(echo "$ip_part" | tr ':' '\n' | grep -v '^$')
|
||||
kept=$(echo "$segments" | head -n "$keep_segments" | tr '\n' ':' | sed 's/:$//')
|
||||
echo "$kept"
|
||||
done < tmp_prefixes.txt
|
||||
} | sort -u > "bk/prefix/${asn,,}.txt"
|
||||
rm -f "${asn}.html" tmp_prefixes.txt
|
||||
done
|
||||
|
||||
- name: 提交更新到仓库
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add bk/prefix/*.txt bk/prefix/*.txt.manifest.json
|
||||
git add bk/prefix/*.txt
|
||||
if git diff --cached --quiet; then
|
||||
echo "无变更,跳过提交。"
|
||||
else
|
||||
git commit -m "chore: 更新多个 ASN 的 IPv6 前缀"
|
||||
git push
|
||||
fi
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org|direct
|
||||
'on':
|
||||
workflow_dispatch: null
|
||||
schedule:
|
||||
- cron: 0 0 * * *
|
||||
|
||||
51
.github/workflows/ci.yaml
vendored
51
.github/workflows/ci.yaml
vendored
@ -1,34 +1,31 @@
|
||||
name: CI
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: backtrace-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go: [ '1.22.x' ]
|
||||
os: [ ubuntu-latest ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- name: Verify module and test source
|
||||
shell: bash
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Test
|
||||
run: go test ./... -coverprofile=coverage.txt
|
||||
- name: Create Tag
|
||||
if: success() # 仅在测试成功时运行
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod verify
|
||||
go test -race -count=1 ./...
|
||||
go vet ./...
|
||||
go build -o "$RUNNER_TEMP/backtrace" ./cmd
|
||||
git diff --check
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
TAG="v0.0.5-$(date +'%Y%m%d%H%M%S')"
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org|direct
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: null
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
247
.github/workflows/main.yaml
vendored
247
.github/workflows/main.yaml
vendored
@ -1,171 +1,132 @@
|
||||
name: Build and Release
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches: [ main ]
|
||||
# pull_request:
|
||||
# branches: [ main ]
|
||||
# release:
|
||||
# types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- id: release
|
||||
name: Verify release tag and source version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
tag="${{ inputs.release_tag }}"
|
||||
else
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
git fetch --force --tags origin
|
||||
[[ "$(git rev-list -n 1 "$tag")" == "$(git rev-parse HEAD)" ]]
|
||||
version="$(sed -n 's/^const BackTraceVersion = "\(v[0-9.]*\)"$/\1/p' model/model.go)"
|
||||
[[ "$version" == "$tag" ]]
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- name: Validate release source
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod verify
|
||||
go test -race -count=1 ./...
|
||||
go vet ./...
|
||||
go build -o "$RUNNER_TEMP/backtrace" ./cmd
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org|direct
|
||||
release:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Load trusted network recovery helpers
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --silent --show-error --location --connect-timeout 15 --max-time 90 --retry 3 --retry-max-time 180 --retry-connrefused "https://raw.githubusercontent.com/oneclickvirt/backtrace/main/.github/scripts/ci_network.sh" -o "$RUNNER_TEMP/ci-network.sh"
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.verify.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
source "$RUNNER_TEMP/ci-network.sh"
|
||||
set -euo pipefail
|
||||
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
|
||||
gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
|
||||
--title "$RELEASE_TAG" --generate-notes
|
||||
build:
|
||||
needs:
|
||||
- verify
|
||||
- release
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 'stable'
|
||||
|
||||
- name: Build and Test on Default Platform
|
||||
run: |
|
||||
go build -v ./...
|
||||
go test -v ./...
|
||||
|
||||
- name: Delete Existing Release Assets
|
||||
run: |
|
||||
release_id=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/oneclickvirt/backtrace/releases/tags/output" | jq -r '.id')
|
||||
echo "Deleting existing release assets..."
|
||||
assets=$(curl -s -H "Accept: application/vnd.github.v3+json" "https://api.github.com/repos/oneclickvirt/backtrace/releases/$release_id/assets" | jq -r '.[] | .id')
|
||||
for asset in $assets; do
|
||||
echo "Deleting asset with ID: $asset"
|
||||
curl -X DELETE -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/oneclickvirt/backtrace/releases/assets/$asset"
|
||||
done
|
||||
sleep 30
|
||||
|
||||
release-binary:
|
||||
name: Release Go Binary
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 'stable'
|
||||
|
||||
- name: Build and Release
|
||||
run: |
|
||||
mkdir -p bin
|
||||
cd cmd
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -o ../bin/backtrace-${{ matrix.goos }}-${{ matrix.goarch }} -v -ldflags="-extldflags=-static" .
|
||||
|
||||
- name: Upload New Assets
|
||||
run: |
|
||||
release_id=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/oneclickvirt/backtrace/releases/tags/output" | jq -r '.id')
|
||||
echo "Uploading new assets to release..."
|
||||
for file in ./bin/*; do
|
||||
echo "Uploading $file to release..."
|
||||
curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"$file" \
|
||||
"https://uploads.github.com/repos/oneclickvirt/backtrace/releases/$release_id/assets?name=$(basename "$file")"
|
||||
rm -rf $file
|
||||
done
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [windows, freebsd, openbsd, linux, darwin]
|
||||
goarch: [amd64, 386]
|
||||
exclude:
|
||||
- goarch: 386
|
||||
goos: darwin
|
||||
include:
|
||||
- goos: linux
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: '386'
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: '7'
|
||||
goarm: 7
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
- goos: linux
|
||||
goarch: mips
|
||||
- goos: linux
|
||||
goarch: mipsle
|
||||
- goos: linux
|
||||
goarch: mips64
|
||||
- goos: linux
|
||||
goarch: mips64le
|
||||
- goos: linux
|
||||
goarch: mipsle
|
||||
- goos: linux
|
||||
goarch: mips
|
||||
- goos: linux
|
||||
goarch: ppc64
|
||||
- goos: linux
|
||||
goarch: ppc64le
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
- goos: freebsd
|
||||
goarch: '386'
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
goarm: '7'
|
||||
- goos: freebsd
|
||||
goarm: 7
|
||||
- goos: openbsd
|
||||
goarch: arm64
|
||||
- goos: openbsd
|
||||
goarch: amd64
|
||||
- goos: openbsd
|
||||
goarch: '386'
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
goarm: '7'
|
||||
- goos: openbsd
|
||||
goarch: arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: '386'
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
steps:
|
||||
- name: Load trusted network recovery helpers
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --silent --show-error --location --connect-timeout 15 --max-time 90 --retry 3 --retry-max-time 180 --retry-connrefused "https://raw.githubusercontent.com/oneclickvirt/backtrace/main/.github/scripts/ci_network.sh" -o "$RUNNER_TEMP/ci-network.sh"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: '0'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
suffix=""
|
||||
[[ "$GOOS" == "windows" ]] && suffix=".exe"
|
||||
artifact="backtrace-${GOOS}-${GOARCH}${suffix}"
|
||||
go build -trimpath -buildvcs=false -ldflags="-s -w" -o "$artifact" ./cmd
|
||||
echo "ARTIFACT=$artifact" >> "$GITHUB_ENV"
|
||||
- name: Upload release asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.verify.outputs.tag }}
|
||||
run: |-
|
||||
source "$RUNNER_TEMP/ci-network.sh"
|
||||
gh release upload "$RELEASE_TAG" "$ARTIFACT" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
shell: bash
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org|direct
|
||||
'on':
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing semantic version tag to build and release
|
||||
required: true
|
||||
type: string
|
||||
goarm: 7
|
||||
# - goos: linux
|
||||
# goarch: mipsle
|
||||
# gomips: softfloat
|
||||
# - goos: linux
|
||||
# goarch: mips
|
||||
# gomips: softfloat
|
||||
# - goos: linux
|
||||
# goarch: arm
|
||||
# goarm: 6
|
||||
# - goos: linux
|
||||
# goarch: arm
|
||||
# goarm: 5
|
||||
|
||||
23
.github/workflows/merge-action-updates.yml
vendored
23
.github/workflows/merge-action-updates.yml
vendored
@ -1,23 +0,0 @@
|
||||
name: Merge safe Action updates
|
||||
'on':
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Action policy
|
||||
types:
|
||||
- completed
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
merge:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Merge validated patch and minor updates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/merge_action_updates.py
|
||||
76
.github/workflows/release.yaml
vendored
76
.github/workflows/release.yaml
vendored
@ -1,76 +0,0 @@
|
||||
name: Prepare and Tag Release
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: backtrace-release-data
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- id: release
|
||||
name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(sed -n 's/^const BackTraceVersion = "\(v[0-9.]*\)"$/\1/p' model/model.go)"
|
||||
tag="${{ inputs.release_tag }}"
|
||||
[[ -n "$tag" ]] || tag="$version"
|
||||
[[ "$tag" == "$version" ]]
|
||||
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
git fetch --force --tags origin
|
||||
! git rev-parse --verify --quiet "refs/tags/$tag"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
- name: Refresh embedded routing datasets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go run ./cmd/update-prefixes -output-dir bk/prefix
|
||||
go run ./cmd/update-asn-metadata
|
||||
- name: Validate release source
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod verify
|
||||
go test -race -count=1 ./...
|
||||
go vet ./...
|
||||
go build -o "$RUNNER_TEMP/backtrace" ./cmd
|
||||
git diff --check
|
||||
- name: Commit refreshed routing datasets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- bk/prefix bgptools/data)" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add bk/prefix bgptools/data
|
||||
git commit -m "chore(data): refresh routing datasets"
|
||||
git push origin HEAD:main
|
||||
- name: Create immutable release tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.release.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
|
||||
git push origin "refs/tags/$RELEASE_TAG"
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org|direct
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Semantic version tag; leave empty to use BackTraceVersion
|
||||
required: false
|
||||
type: string
|
||||
49
.github/workflows/sync-asn-metadata.yml
vendored
49
.github/workflows/sync-asn-metadata.yml
vendored
@ -1,49 +0,0 @@
|
||||
name: Sync ASN metadata
|
||||
concurrency:
|
||||
group: backtrace-asn-metadata-sync
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Update snapshot
|
||||
run: go run ./cmd/update-asn-metadata
|
||||
- name: Validate updater and runtime loader
|
||||
run: go test -race ./cmd/update-asn-metadata ./bgptools
|
||||
- name: Vet updater and runtime loader
|
||||
run: go vet ./cmd/update-asn-metadata ./bgptools
|
||||
- name: Commit semantic changes
|
||||
run: |
|
||||
if git diff --quiet -- bgptools/data/bgp-asn-map.json bgptools/data/bgp-asn-map.manifest.json; then
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add bgptools/data/bgp-asn-map.json bgptools/data/bgp-asn-map.manifest.json
|
||||
git commit -m "chore(data): update ASN metadata"
|
||||
git push
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org|direct
|
||||
'on':
|
||||
schedule:
|
||||
- cron: 29 3 * * *
|
||||
workflow_dispatch: null
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/sync-asn-metadata.yml
|
||||
- cmd/update-asn-metadata/**
|
||||
- bgptools/data/**
|
||||
- bgptools/asn_metadata.go
|
||||
- bgptools/asn_metadata_test.go
|
||||
- model/model.go
|
||||
- go.mod
|
||||
- go.sum
|
||||
22
README.md
22
README.md
@ -1,6 +1,6 @@
|
||||
# backtrace
|
||||
|
||||
[](https://hits.spiritlhl.net) [](https://github.com/oneclickvirt/backtrace/releases)
|
||||
[](https://hits.spiritlhl.net)
|
||||
|
||||
[](https://github.com/oneclickvirt/backtrace/actions/workflows/main.yaml)
|
||||
|
||||
@ -15,13 +15,13 @@
|
||||
- [x] 支持对```CTGNET```、```CN2GIA```和```CN2GT```线路的判断
|
||||
- [x] 支持对```CMIN2```和```CMI```线路的判断
|
||||
- [x] 支持对整个回程路由进行线路分析,一个目标IP可能会分析出多种线路
|
||||
- [x] 支持对主流接入点的线路检测,方便分析国际互联能力
|
||||
- [x] 多次并发路由检测以分析平均多次路由,避免单次路由因网络波动未能准确检测
|
||||
- [x] 增加对全平台的编译支持,原版[backtrace](https://github.com/zhanghanyun/backtrace)仅支持linux平台的amd64和arm64架构
|
||||
- [x] 兼容额外的ICMP地址获取,若当前目标IP无法查询路由尝试额外的IP地址
|
||||
- [x] 在线但本地 DNS 确认缺失时,在当前进程内使用经过固定地址 TLS 查询校验的最快 DoH/DoT 上游;瞬时超时、SERVFAIL 或丢包不会切换系统解析
|
||||
|
||||
相关输出和查询结果的说明:[跳转](https://github.com/oneclickvirt/ecs/blob/master/README_NEW_USER.md#%E4%B8%8A%E6%B8%B8%E5%8F%8A%E5%9B%9E%E7%A8%8B%E7%BA%BF%E8%B7%AF%E6%A3%80%E6%B5%8B)
|
||||
## TODO
|
||||
|
||||
- [ ] 自动检测汇聚层,裁剪结果不输出汇聚层后的线路(区分境内外段)
|
||||
- [ ] 添加对主流ISP的POP点检测,区分国际互联能力
|
||||
|
||||
## 使用
|
||||
|
||||
@ -51,17 +51,13 @@ backtrace
|
||||
|
||||
进行测试
|
||||
|
||||
无环境依赖,理论上适配所有系统和主流架构,更多架构请查看 https://github.com/oneclickvirt/backtrace/releases
|
||||
无环境依赖,理论上适配所有系统和主流架构,更多架构请查看 https://github.com/oneclickvirt/backtrace/releases/tag/output
|
||||
|
||||
```
|
||||
Usage: backtrace [options]
|
||||
-h Show help information
|
||||
-ip string
|
||||
Specify IP address for bgptools
|
||||
-ipv6
|
||||
Enable ipv6 testing
|
||||
-dns-mode string
|
||||
DNS mode (auto, system, doh, or dot) (default "auto")
|
||||
-log
|
||||
Enable logging
|
||||
-s Disabe show ip info (default true)
|
||||
@ -78,13 +74,9 @@ rm -rf /usr/bin/backtrace
|
||||
## 在Golang中使用
|
||||
|
||||
```
|
||||
go get github.com/oneclickvirt/backtrace@v0.0.21
|
||||
go get github.com/oneclickvirt/backtrace@latest
|
||||
```
|
||||
|
||||
## DNS 解析回退
|
||||
|
||||
`-dns-mode=auto` 是默认值。它会先保留系统 DNS,只有独立探测都确认本地解析器不可用时,才在本次进程内启用内置加密 DNS,并选择实测延迟最低的 DoH 或 DoT 上游。正常响应、NXDOMAIN、短暂超时、SERVFAIL 和丢包都不会被误判为 DNS 缺失。`system` 禁用回退,`doh` 和 `dot` 可显式强制相应协议;不会修改 `/etc/resolv.conf`、`/etc/hosts` 或其他系统配置。
|
||||
|
||||
## 概览图
|
||||
|
||||

|
||||
|
||||
56
SKILL.md
56
SKILL.md
@ -1,56 +0,0 @@
|
||||
---
|
||||
name: oneclickvirt-backtrace
|
||||
description: "Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute."
|
||||
metadata:
|
||||
repository: oneclickvirt/backtrace
|
||||
integration-version: "1.0.0"
|
||||
---
|
||||
|
||||
# backtrace
|
||||
|
||||
## Scope
|
||||
|
||||
Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute.
|
||||
|
||||
- Read probe_help from the installed binary before assuming flags are supported. MCP returns native JSON when the program provides JSON, otherwise native text.
|
||||
- Keep partial/network errors distinct from confirmed test outcomes. Current release assets and their actual architecture are available through release_assets.
|
||||
- The ip-info preset requires a literal target IP. Do not pass a host, proxy, shell expression or a network range.
|
||||
|
||||
## Install and update
|
||||
|
||||
```bash
|
||||
curl -fL --retry 3 https://raw.githubusercontent.com/oneclickvirt/backtrace/main/scripts/install_agent.py -o install_agent.py
|
||||
python3 install_agent.py --client codex
|
||||
```
|
||||
|
||||
The installer is also mirrored at `https://cnb.cool/oneclickvirt/backtrace/-/git/raw/main/scripts/install_agent.py`; add `--source cnb` to install from an immutable CNB snapshot (requires Git).
|
||||
|
||||
Use `--client claude` or `--client openclaw` for another client. Download and run again to update; `--ref` pins a source tag/commit. The installer verifies all SHA-256 checksums before replacing its files, preserves extra local files/client settings, and records the source commit in `install.json`.
|
||||
|
||||
## MCP
|
||||
|
||||
Requires Python 3.9+. The adapter uses existing local commands and their existing permissions; it does not install a virtualization runtime. Merge the generated `mcp.codex.toml`, `mcp.claude.json`, or `mcp.openclaw.json` into that client's existing configuration. For a remote host, run its adapter over noninteractive SSH.
|
||||
|
||||
```bash
|
||||
python3 install_agent.py --client codex --install-binary
|
||||
```
|
||||
|
||||
`--version` selects the probe release tag; `--ref` selects Skill/MCP source independently. The default resolves the latest stable GitHub release once, selects a real asset for this OS/architecture, and verifies SHA-256. Missing assets fail without guessing. Reuse an existing executable with `--binary /absolute/path/to/program`.
|
||||
|
||||
Call `integration_info` first for capabilities and prerequisites.
|
||||
|
||||
Available probe presets:
|
||||
|
||||
- `ip-info`: `backtrace -json -timeout 12s -ip <target>`
|
||||
|
||||
`release_assets` returns the actual version, filenames, sizes, digests and download URLs. Pass an exact tag for a pinned release. After a product update, verify help and an actual call rather than treating file presence as a successful test.
|
||||
|
||||
## Source and documentation
|
||||
|
||||
- [`backtrace_install.sh`](https://github.com/oneclickvirt/backtrace/blob/main/backtrace_install.sh)
|
||||
- [`cmd/main.go`](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go)
|
||||
- [`go.mod`](https://github.com/oneclickvirt/backtrace/blob/main/go.mod)
|
||||
|
||||
- [集成安装说明 / Integration guide](https://www.spiritlhl.net/guide/agent_integrations.html)
|
||||
|
||||
Check the relevant source and current documentation before giving commands. Preserve requested version pins, data paths and native host identifiers. Distinguish actual program execution, isolated tests and unverified environments.
|
||||
56
SKILL_ZH.md
56
SKILL_ZH.md
@ -1,56 +0,0 @@
|
||||
---
|
||||
name: oneclickvirt-backtrace
|
||||
description: "查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。"
|
||||
metadata:
|
||||
repository: oneclickvirt/backtrace
|
||||
integration-version: "1.0.0"
|
||||
---
|
||||
|
||||
# backtrace
|
||||
|
||||
## 使用范围
|
||||
|
||||
查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。
|
||||
|
||||
- 先用 probe_help 核对已安装程序的实际帮助。程序支持 JSON 时返回原生 JSON,否则返回原生文本。
|
||||
- 区分部分结果、网络失败和确定结论;通过 release_assets 查询当前真实产物及架构。
|
||||
- ip-info 预设只接受单个真实 IP,不接受域名、代理、Shell 表达式或网段。
|
||||
|
||||
## 安装和更新
|
||||
|
||||
```bash
|
||||
curl -fL --retry 3 https://raw.githubusercontent.com/oneclickvirt/backtrace/main/scripts/install_agent.py -o install_agent.py
|
||||
python3 install_agent.py --client codex
|
||||
```
|
||||
|
||||
可将下载地址改为 `https://cnb.cool/oneclickvirt/backtrace/-/git/raw/main/scripts/install_agent.py`,并使用 `--source cnb` 从 CNB 的固定提交安装;此模式另需 Git。
|
||||
|
||||
用 `--client claude` 或 `--client openclaw` 选择其他客户端。重新下载并执行可更新;`--ref` 固定源码标签或提交。安装前校验全部 SHA-256,保留额外本地文件及已有客户端设置,`install.json` 记录来源提交。
|
||||
|
||||
## MCP
|
||||
|
||||
运行需要 Python 3.9 以上版本;MCP 使用本机已有程序及其权限,不自动安装虚拟化环境。将安装目录内对应客户端的 `mcp.codex.toml`、`mcp.claude.json` 或 `mcp.openclaw.json` 合并到已有配置。远端宿主机应通过无交互 SSH 运行该主机上的适配器。
|
||||
|
||||
```bash
|
||||
python3 install_agent.py --client codex --install-binary
|
||||
```
|
||||
|
||||
`--version` 可指定检测程序的正式 Release 标签,`--ref` 则指定 Skill/MCP 源码版本,两者独立。默认从 GitHub 解析一次最新正式版,选择本机系统/架构的真实产物并校验 SHA-256;没有匹配产物时停止。已有程序可用 `--binary /完整路径/程序` 复用。
|
||||
|
||||
先调用 `integration_info` 查看能力和运行要求。
|
||||
|
||||
可用检测预设:
|
||||
|
||||
- `ip-info`: `backtrace -json -timeout 12s -ip <target>`
|
||||
|
||||
`release_assets` 返回实际版本、文件名、大小、摘要及下载地址;需要固定版本时传完整标签。更新产物后应重新安装并验证帮助和实际调用,不能只因文件存在便判定可用。
|
||||
|
||||
## 对应源码与说明
|
||||
|
||||
- [`backtrace_install.sh`](https://github.com/oneclickvirt/backtrace/blob/main/backtrace_install.sh)
|
||||
- [`cmd/main.go`](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go)
|
||||
- [`go.mod`](https://github.com/oneclickvirt/backtrace/blob/main/go.mod)
|
||||
|
||||
- [集成安装说明 / Integration guide](https://www.spiritlhl.net/guide/agent_integrations.html)
|
||||
|
||||
给出操作命令前核对这些源码文件及当前文档,保留现有版本固定、数据路径和宿主机标识。报告测试时区分真实程序执行、隔离测试及未验证的环境。
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"repository": "oneclickvirt/backtrace",
|
||||
"skill_name": "oneclickvirt-backtrace",
|
||||
"mcp": true,
|
||||
"files": {
|
||||
"agent/profile.json": "c0051a351c446d4135642cda3af37d302bc83b9c74ddfafc1dc68545e41249d6",
|
||||
"skills/oneclickvirt-backtrace/SKILL.md": "c077e85bc032b2acd2aaff9163aa6df541d9013ab5a41a7986aabc8875148b32",
|
||||
"skills/oneclickvirt-backtrace/SKILL_ZH.md": "4734522f932f22d713fa9d80b701d709b0df8bc00333862ec2866eb5103b7302",
|
||||
"agent/server.py": "f4fe673f355a9b8ab06b4f8c1f90251e0ad79e1d9324ee4d62eff5e12ac45b74"
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"repository": "oneclickvirt/backtrace",
|
||||
"branch": "main",
|
||||
"skill_name": "oneclickvirt-backtrace",
|
||||
"purpose": "Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute.",
|
||||
"notes": [
|
||||
"Read probe_help from the installed binary before assuming flags are supported. MCP returns native JSON when the program provides JSON, otherwise native text.",
|
||||
"Keep partial/network errors distinct from confirmed test outcomes. Current release assets and their actual architecture are available through release_assets.",
|
||||
"The ip-info preset requires a literal target IP. Do not pass a host, proxy, shell expression or a network range."
|
||||
],
|
||||
"guide": "https://www.spiritlhl.net/guide/agent_integrations.html",
|
||||
"releases": true,
|
||||
"mcp": true,
|
||||
"binary": "backtrace",
|
||||
"asset_prefix": "backtrace",
|
||||
"presets": {
|
||||
"ip-info": {
|
||||
"args": [
|
||||
"-json",
|
||||
"-timeout",
|
||||
"12s"
|
||||
],
|
||||
"target": "-ip",
|
||||
"timeout": 20
|
||||
}
|
||||
},
|
||||
"benchmark": false,
|
||||
"source_files": [
|
||||
"backtrace_install.sh",
|
||||
"cmd/main.go",
|
||||
"go.mod"
|
||||
],
|
||||
"purpose_zh": "查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。",
|
||||
"notes_zh": [
|
||||
"先用 probe_help 核对已安装程序的实际帮助。程序支持 JSON 时返回原生 JSON,否则返回原生文本。",
|
||||
"区分部分结果、网络失败和确定结论;通过 release_assets 查询当前真实产物及架构。",
|
||||
"ip-info 预设只接受单个真实 IP,不接受域名、代理、Shell 表达式或网段。"
|
||||
]
|
||||
}
|
||||
352
agent/server.py
352
agent/server.py
@ -1,352 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OneClickVirt repository MCP adapter. Python 3.9+, stdio, no extra packages."""
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
VERSION = "1.0.0"
|
||||
PROTOCOLS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25")
|
||||
LIMIT = 1024 * 1024
|
||||
|
||||
|
||||
def schema(properties=None, required=()):
|
||||
return {"type": "object", "properties": properties or {},
|
||||
"required": list(required), "additionalProperties": False}
|
||||
|
||||
|
||||
def string(description="", **extra):
|
||||
return dict(type="string", description=description, **extra)
|
||||
|
||||
|
||||
def validate(value, spec, name="arguments"):
|
||||
expected = spec.get("type")
|
||||
if expected == "object":
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(name + " must be an object")
|
||||
props = spec.get("properties", {})
|
||||
if set(spec.get("required", [])) - value.keys():
|
||||
raise ValueError(name + " has missing required fields")
|
||||
if not spec.get("additionalProperties", True) and value.keys() - props.keys():
|
||||
raise ValueError(name + " has unknown fields")
|
||||
for key, data in value.items():
|
||||
if key in props:
|
||||
validate(data, props[key], key)
|
||||
elif expected == "string":
|
||||
maximum = min(512, spec.get("maxLength", 512))
|
||||
if not isinstance(value, str) or len(value) > maximum:
|
||||
raise ValueError(name + " must be a string of at most " + str(maximum) + " characters")
|
||||
if "pattern" in spec and not re.fullmatch(spec["pattern"], value):
|
||||
raise ValueError(name + " has invalid characters")
|
||||
elif expected == "integer":
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(name + " must be an integer")
|
||||
if value < spec.get("minimum", value) or value > spec.get("maximum", value):
|
||||
raise ValueError(name + " is outside the supported range")
|
||||
if "enum" in spec and value not in spec["enum"]:
|
||||
raise ValueError(name + " is not a supported choice")
|
||||
|
||||
|
||||
def execute(argv, timeout=30, cwd=None, parse_json=True):
|
||||
"""Bound output, kill process groups on timeout, never invoke a shell."""
|
||||
if not argv or not shutil.which(argv[0]):
|
||||
raise ValueError("Required local command is missing: " + (argv[0] if argv else ""))
|
||||
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
|
||||
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out, stderr=err,
|
||||
start_new_session=os.name != "nt", cwd=cwd)
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
if os.name != "nt":
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
else:
|
||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
raise ValueError("Command exceeded its time limit; no result is claimed")
|
||||
out.seek(0)
|
||||
err.seek(0)
|
||||
stdout, stderr = out.read(LIMIT + 1), err.read(16385)
|
||||
if proc.returncode:
|
||||
raise ValueError("Command failed (exit %s): %s" % (
|
||||
proc.returncode, (stderr[:16384] or stdout[:16384]).decode("utf-8", "replace")))
|
||||
if len(stdout) > LIMIT:
|
||||
raise ValueError("Command result exceeds 1 MiB; narrow the request")
|
||||
text = stdout.decode("utf-8", "replace").strip()
|
||||
if not parse_json:
|
||||
return text
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
return text
|
||||
|
||||
|
||||
def github_json(path):
|
||||
headers = {"Accept": "application/vnd.github+json", "User-Agent": "oneclickvirt-mcp/" + VERSION}
|
||||
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
class SafeRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, response_headers, newurl):
|
||||
redirected = super().redirect_request(req, fp, code, msg, response_headers, newurl)
|
||||
if redirected is not None and urllib.parse.urlsplit(newurl).netloc != "api.github.com":
|
||||
redirected.remove_header("Authorization")
|
||||
return redirected
|
||||
req = urllib.request.Request("https://api.github.com" + path, headers=headers)
|
||||
try:
|
||||
with urllib.request.build_opener(SafeRedirect()).open(req, timeout=25) as response:
|
||||
data = response.read(8 * LIMIT + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ValueError("GitHub API returned %s; no release or asset is assumed" % exc.code) from None
|
||||
if len(data) > 8 * LIMIT:
|
||||
raise ValueError("GitHub response is too large")
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
class Adapter:
|
||||
def __init__(self, profile_path):
|
||||
self.path = Path(profile_path).resolve()
|
||||
self.profile = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
self.repo = self.profile["repository"]
|
||||
if not re.fullmatch(r"oneclickvirt/[A-Za-z0-9_.-]+", self.repo):
|
||||
raise ValueError("Invalid repository profile")
|
||||
|
||||
def binary(self):
|
||||
return os.environ.get("ONECLICKVIRT_MCP_BINARY") or self.profile.get("binary", "")
|
||||
|
||||
def tools(self):
|
||||
result = []
|
||||
def add(name, description, spec=None, write=False, external=False):
|
||||
result.append({"name": name, "description": description, "inputSchema": spec or schema(),
|
||||
"annotations": {"readOnlyHint": not write, "destructiveHint": write,
|
||||
"idempotentHint": not write, "openWorldHint": external}})
|
||||
add("integration_info", "Show repository, supported operations, prerequisites and documentation.")
|
||||
if self.profile.get("releases"):
|
||||
add("release_assets", "Resolve a current published release and list its actual assets. No assumed architectures or tags.",
|
||||
schema({"version": string("Exact release tag, or latest", pattern=r"[A-Za-z0-9_.+/-]{1,128}"),
|
||||
"filter": string("Optional case-insensitive substring in asset names", maxLength=128)}), external=True)
|
||||
backend = self.profile.get("runtime")
|
||||
if backend:
|
||||
add("list_instances", "List instances on this local runtime using its existing CLI.")
|
||||
add("instance_status", "Read one instance's state without returning environment variables or cloud-init secrets.",
|
||||
schema({"id": string(pattern=r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")}, ["id"]))
|
||||
if os.environ.get("ONECLICKVIRT_MCP_ALLOW_WRITE") == "1":
|
||||
add("instance_action", "Start, gracefully stop or restart an explicitly named instance. No delete or reinstall operation.",
|
||||
schema({"id": string(pattern=r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"),
|
||||
"action": string(enum=["start", "stop", "restart"])}, ["id", "action"]), write=True)
|
||||
if self.profile.get("presets"):
|
||||
add("probe_help", "Read the installed probe's actual help output.")
|
||||
add("run_probe", "Run one bounded preset with the existing local binary. Check integration_info for its resource and network effects.",
|
||||
schema({"preset": string(enum=list(self.profile["presets"])),
|
||||
"target": string("Only for a target-aware preset", pattern=r"[A-Za-z0-9][A-Za-z0-9.:_-]{0,252}")}, ["preset"]),
|
||||
write=self.profile.get("benchmark", False), external=True)
|
||||
if self.profile.get("network"):
|
||||
add("network_status", "Read Linux IPv6 addresses and routes. Does not change network configuration.")
|
||||
if self.profile.get("counter"):
|
||||
add("counter_monthly", "Read an existing counter's monthly statistics without incrementing it.",
|
||||
schema({"counter": string(pattern=r"[A-Za-z0-9_.-]{1,128}")}, ["counter"]), external=True)
|
||||
return result
|
||||
|
||||
def instances(self):
|
||||
backend = self.profile["runtime"]
|
||||
binary = self.binary()
|
||||
if backend in ("docker", "podman", "containerd"):
|
||||
raw = execute([binary, "ps", "-a", "--format", "{{json .}}"])
|
||||
if isinstance(raw, dict):
|
||||
rows = [raw]
|
||||
elif not raw:
|
||||
rows = []
|
||||
else:
|
||||
rows = [json.loads(line) for line in raw.splitlines()]
|
||||
result = []
|
||||
for row in rows:
|
||||
item = {key: row.get(key) for key in ("ID", "Names", "Image", "State", "Status")}
|
||||
if isinstance(item["Names"], list):
|
||||
item["Names"] = ",".join(item["Names"])
|
||||
result.append(item)
|
||||
return result
|
||||
if backend in ("incus", "lxd"):
|
||||
rows = execute([binary, "list", "--format=json"])
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("Runtime did not return an instance array")
|
||||
return [{k: row.get(k) for k in ("name", "status", "type", "location")} for row in rows]
|
||||
if backend == "pve":
|
||||
return execute([binary, "get", "/cluster/resources", "--type", "vm", "--output-format", "json"])
|
||||
if backend == "qemu":
|
||||
uri = os.environ.get("ONECLICKVIRT_LIBVIRT_URI", "qemu:///system")
|
||||
if not re.fullmatch(r"qemu:///(system|session)", uri):
|
||||
raise ValueError("Only local libvirt system/session URIs are supported")
|
||||
names = execute([binary, "-c", uri, "list", "--all", "--name"], parse_json=False)
|
||||
return [{"name": name} for name in str(names).splitlines() if name]
|
||||
if backend == "kubevirt":
|
||||
namespace = os.environ.get("ONECLICKVIRT_KUBE_NAMESPACE", "default")
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", namespace):
|
||||
raise ValueError("Invalid Kubernetes namespace")
|
||||
data = execute([binary, "get", "virtualmachines", "--namespace", namespace, "-o", "json"])
|
||||
return [{"name": row["metadata"]["name"], "namespace": namespace,
|
||||
"status": row.get("status", {})} for row in data["items"]]
|
||||
raise ValueError("Unsupported runtime")
|
||||
|
||||
def find_instance(self, identifier):
|
||||
rows = self.instances()
|
||||
for row in rows:
|
||||
names = str(row.get("Names", "")).split(",")
|
||||
if identifier in names + [str(row.get(k, "")) for k in ("name", "vmid", "ID")]:
|
||||
return row
|
||||
raise ValueError("Instance not found on the configured runtime")
|
||||
|
||||
def call(self, name, args):
|
||||
available = {t["name"]: t for t in self.tools()}
|
||||
if name not in available:
|
||||
raise ValueError("Unknown or disabled tool")
|
||||
validate(args, available[name]["inputSchema"])
|
||||
if name == "integration_info":
|
||||
return self.profile
|
||||
if name == "release_assets":
|
||||
repo = self.profile.get("release_repository", self.repo)
|
||||
version = args.get("version", "latest")
|
||||
suffix = "latest" if version == "latest" else "tags/" + urllib.parse.quote(version, safe="")
|
||||
value = github_json("/repos/" + repo + "/releases/" + suffix)
|
||||
if value.get("draft") or (version == "latest" and value.get("prerelease")):
|
||||
raise ValueError("Requested release is not a stable published release")
|
||||
match = args.get("filter", "").lower()
|
||||
return {"repository": repo, "version": value["tag_name"], "url": value["html_url"],
|
||||
"published_at": value["published_at"], "assets": [
|
||||
{k: a.get(k) for k in ("name", "size", "digest", "browser_download_url")}
|
||||
for a in value["assets"] if match in a["name"].lower()]}
|
||||
if name == "list_instances":
|
||||
return self.instances()
|
||||
if name == "instance_status":
|
||||
row = self.find_instance(args["id"])
|
||||
if self.profile["runtime"] == "qemu":
|
||||
uri = os.environ.get("ONECLICKVIRT_LIBVIRT_URI", "qemu:///system")
|
||||
row["state"] = execute([self.binary(), "-c", uri, "domstate", args["id"]])
|
||||
return row
|
||||
if name == "instance_action":
|
||||
row = self.find_instance(args["id"])
|
||||
backend, action, identifier = self.profile["runtime"], args["action"], args["id"]
|
||||
binary = self.binary()
|
||||
if backend in ("docker", "containerd", "podman", "incus", "lxd"):
|
||||
argv = [binary, action, identifier]
|
||||
elif backend == "qemu":
|
||||
argv = [binary, "-c", os.environ.get("ONECLICKVIRT_LIBVIRT_URI", "qemu:///system"),
|
||||
{"start": "start", "stop": "shutdown", "restart": "reboot"}[action], identifier]
|
||||
elif backend == "pve":
|
||||
node = row.get("node", "")
|
||||
kind = row.get("type", "")
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", node) or kind not in ("qemu", "lxc") or not identifier.isdecimal():
|
||||
raise ValueError("Invalid PVE resource identity")
|
||||
endpoint = "/nodes/%s/%s/%s/status/%s" % (node, kind, identifier, {"stop": "shutdown", "restart": "reboot"}.get(action, action))
|
||||
argv = [binary, "create", endpoint]
|
||||
else:
|
||||
namespace = row["namespace"]
|
||||
argv = ["virtctl", action, identifier, "--namespace", namespace]
|
||||
return {"id": identifier, "action": action, "output": execute(argv, timeout=90), "accepted": True}
|
||||
if name == "probe_help":
|
||||
return execute([self.binary()] + self.profile.get("help_args", ["-h"]), timeout=15)
|
||||
if name == "run_probe":
|
||||
preset = self.profile["presets"][args["preset"]]
|
||||
if self.profile.get("benchmark") and os.environ.get("ONECLICKVIRT_MCP_ALLOW_BENCHMARKS") != "1":
|
||||
raise ValueError("Benchmark execution is disabled; set ONECLICKVIRT_MCP_ALLOW_BENCHMARKS=1 on an intended test host")
|
||||
if bool(preset.get("target")) != bool(args.get("target")):
|
||||
raise ValueError("This preset %s a target" % ("requires" if preset.get("target") else "does not accept"))
|
||||
argv = [self.binary()] + preset["args"]
|
||||
if preset.get("target"):
|
||||
if self.repo == "oneclickvirt/backtrace":
|
||||
ipaddress.ip_address(args["target"])
|
||||
argv += [preset["target"], args["target"]]
|
||||
with tempfile.TemporaryDirectory(prefix="oneclickvirt-probe-") as directory:
|
||||
argv = [a.replace("{scratch}", directory) for a in argv]
|
||||
result = execute(argv, timeout=preset.get("timeout", 45), cwd=directory)
|
||||
return {"preset": args["preset"], "result": result}
|
||||
if name == "network_status":
|
||||
return {"addresses": execute(["ip", "-j", "-6", "addr", "show"]),
|
||||
"routes": execute(["ip", "-j", "-6", "route", "show"])}
|
||||
if name == "counter_monthly":
|
||||
base = os.environ.get("ONECLICKVIRT_COUNTER_URL", "https://hits.spiritlhl.net").rstrip("/")
|
||||
parsed = urllib.parse.urlsplit(base)
|
||||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.query or parsed.fragment:
|
||||
raise ValueError("Counter endpoint must be an HTTPS base URL")
|
||||
url = base + "/api/monthly?" + urllib.parse.urlencode({"counter": args["counter"]})
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "oneclickvirt-mcp/" + VERSION})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
content = response.read(LIMIT + 1)
|
||||
if len(content) > LIMIT:
|
||||
raise ValueError("Counter response exceeds 1 MiB")
|
||||
return json.loads(content)
|
||||
raise ValueError("Unsupported operation")
|
||||
|
||||
|
||||
def serve(adapter, input_stream=sys.stdin, output_stream=sys.stdout):
|
||||
for line in input_stream:
|
||||
if len(line) > LIMIT:
|
||||
error = {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Message exceeds 1 MiB"}}
|
||||
output_stream.write(json.dumps(error) + "\n")
|
||||
output_stream.flush()
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except ValueError:
|
||||
output_stream.write(json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}) + "\n")
|
||||
output_stream.flush()
|
||||
continue
|
||||
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
|
||||
output_stream.write(json.dumps({"jsonrpc": "2.0", "id": message.get("id") if isinstance(message, dict) else None,
|
||||
"error": {"code": -32600, "message": "Invalid request"}}) + "\n")
|
||||
output_stream.flush()
|
||||
continue
|
||||
if "id" not in message:
|
||||
continue
|
||||
reply = {"jsonrpc": "2.0", "id": message["id"]}
|
||||
method = message["method"]
|
||||
params = message.get("params", {})
|
||||
try:
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError("params must be an object")
|
||||
if method == "initialize":
|
||||
requested = params.get("protocolVersion")
|
||||
reply["result"] = {"protocolVersion": requested if requested in PROTOCOLS else PROTOCOLS[-1],
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": adapter.profile["skill_name"], "version": VERSION}}
|
||||
elif method == "ping":
|
||||
reply["result"] = {}
|
||||
elif method == "tools/list":
|
||||
reply["result"] = {"tools": adapter.tools()}
|
||||
elif method == "tools/call":
|
||||
try:
|
||||
value = adapter.call(params.get("name"), params.get("arguments", {}))
|
||||
reply["result"] = {"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False)}], "isError": False}
|
||||
except (ValueError, OSError, KeyError, TypeError, urllib.error.URLError) as exc:
|
||||
reply["result"] = {"content": [{"type": "text", "text": str(exc)}], "isError": True}
|
||||
else:
|
||||
reply["error"] = {"code": -32601, "message": "Method not found"}
|
||||
except (ValueError, TypeError) as exc:
|
||||
reply["error"] = {"code": -32602, "message": str(exc)}
|
||||
output_stream.write(json.dumps(reply, ensure_ascii=True) + "\n")
|
||||
output_stream.flush()
|
||||
|
||||
|
||||
def main():
|
||||
for stream in (sys.stdin, sys.stdout):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--profile", type=Path, default=Path(__file__).with_name("profile.json"))
|
||||
parser.add_argument("--version", action="version", version=VERSION)
|
||||
args = parser.parse_args()
|
||||
serve(Adapter(args.profile))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,72 +1,30 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
const defaultPrefixListLimit = 1 << 16
|
||||
|
||||
var (
|
||||
ErrInvalidPrefix = errors.New("invalid IPv4 prefix")
|
||||
ErrIPv6PrefixUnsupported = errors.New("IPv6 prefix generation is unsupported")
|
||||
ErrPrefixListTooLarge = errors.New("prefix list exceeds limit")
|
||||
)
|
||||
|
||||
// GeneratePrefixList generates the legacy list of /24 address prefixes. The
|
||||
// old API cannot return an error, so invalid, IPv6, and over-sized inputs
|
||||
// return nil. Callers that need to distinguish these cases should use
|
||||
// GeneratePrefixListWithLimit.
|
||||
// GeneratePrefixList 生成指定前缀的IP地址列表
|
||||
func GeneratePrefixList(prefix string) []string {
|
||||
result, err := GeneratePrefixListWithLimit(prefix, defaultPrefixListLimit)
|
||||
// 解析CIDR表示法的IP地址
|
||||
_, ipNet, err := net.ParseCIDR(prefix)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GeneratePrefixListWithLimit generates one entry for every /24-sized block
|
||||
// covered by an IPv4 CIDR. Prefixes narrower than /24 are rejected once the
|
||||
// number of blocks exceeds maxEntries; this keeps /0 and similarly broad
|
||||
// input from allocating or iterating unbounded data. A prefix longer than
|
||||
// /24 still produces the containing block, preserving the legacy behaviour.
|
||||
func GeneratePrefixListWithLimit(prefix string, maxEntries int) ([]string, error) {
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = defaultPrefixListLimit
|
||||
// 获取IP地址的32位整数表示
|
||||
ip := ipNet.IP.To4()
|
||||
start := binaryIPToInt(ip)
|
||||
maskSize, _ := ipNet.Mask.Size()
|
||||
end := start | (1<<(32-maskSize) - 1)
|
||||
// 生成IP地址列表
|
||||
var prefixList []string
|
||||
for i := start; i <= end; i++ {
|
||||
if (i-start)%256 == 0 {
|
||||
tempText := intToBinaryIP(i).String()
|
||||
prefixList = append(prefixList, tempText[:len(tempText)-2])
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q", ErrInvalidPrefix, prefix)
|
||||
}
|
||||
maskSize, bits := ipNet.Mask.Size()
|
||||
if bits == 128 {
|
||||
return nil, fmt.Errorf("%w: %q", ErrIPv6PrefixUnsupported, prefix)
|
||||
}
|
||||
if bits != 32 || maskSize < 0 || maskSize > 32 || ipNet.IP.To4() == nil {
|
||||
return nil, fmt.Errorf("%w: %q", ErrInvalidPrefix, prefix)
|
||||
}
|
||||
|
||||
// A /24 block is the output unit used by the original implementation. A
|
||||
// /25-/32 prefix therefore has one containing block, while a /0 would need
|
||||
// 2^24 entries and is rejected before allocation.
|
||||
blocks := uint64(1)
|
||||
if maskSize < 24 {
|
||||
blocks = uint64(1) << uint(24-maskSize)
|
||||
}
|
||||
if blocks > uint64(maxEntries) {
|
||||
return nil, fmt.Errorf("%w: %d entries (limit %d)", ErrPrefixListTooLarge, blocks, maxEntries)
|
||||
}
|
||||
|
||||
start := binaryIPToInt(ipNet.IP.To4()) &^ uint32(255)
|
||||
result := make([]string, 0, int(blocks))
|
||||
for index := uint64(0); index < blocks; index++ {
|
||||
// The arithmetic is bounded by 2^24 blocks, so this multiplication and
|
||||
// addition cannot overflow uint32 for a valid IPv4 network.
|
||||
value := start + uint32(index*256)
|
||||
result = append(result, fmt.Sprintf("%d.%d.%d", byte(value>>24), byte(value>>16), byte(value>>8)))
|
||||
}
|
||||
return result, nil
|
||||
return prefixList
|
||||
}
|
||||
|
||||
// 将IP地址转换为32位整数
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratePrefixListCompatibility(t *testing.T) {
|
||||
got := GeneratePrefixList("192.0.2.0/23")
|
||||
want := []string{"192.0.2", "192.0.3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("GeneratePrefixList() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
got = GeneratePrefixList("192.0.2.128/25")
|
||||
want = []string{"192.0.2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("GeneratePrefixList(/25) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePrefixListRejectsUnsafeInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
want error
|
||||
}{
|
||||
{name: "empty", prefix: "", want: ErrInvalidPrefix},
|
||||
{name: "invalid", prefix: "not-a-prefix", want: ErrInvalidPrefix},
|
||||
{name: "IPv6", prefix: "2001:db8::/32", want: ErrIPv6PrefixUnsupported},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := GeneratePrefixList(test.prefix); got != nil {
|
||||
t.Fatalf("legacy API returned %v, want nil", got)
|
||||
}
|
||||
if _, err := GeneratePrefixListWithLimit(test.prefix, 16); !errors.Is(err, test.want) {
|
||||
t.Fatalf("GeneratePrefixListWithLimit() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePrefixListLimitsBroadPrefixesBeforeAllocation(t *testing.T) {
|
||||
if _, err := GeneratePrefixListWithLimit("0.0.0.0/0", 1024); !errors.Is(err, ErrPrefixListTooLarge) {
|
||||
t.Fatalf("GeneratePrefixListWithLimit(/0) error = %v, want %v", err, ErrPrefixListTooLarge)
|
||||
}
|
||||
|
||||
got, err := GeneratePrefixListWithLimit("10.0.0.0/16", 256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 256 || got[0] != "10.0.0" || got[255] != "10.0.255" {
|
||||
t.Fatalf("unexpected bounded result: len=%d first=%q last=%q", len(got), got[0], got[len(got)-1])
|
||||
}
|
||||
}
|
||||
@ -34,16 +34,13 @@ case $os in
|
||||
Linux)
|
||||
case $arch in
|
||||
"x86_64" | "x86" | "amd64" | "x64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-linux-amd64"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-linux-amd64"
|
||||
;;
|
||||
"i386" | "i686")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-linux-386"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-linux-386"
|
||||
;;
|
||||
"armv7l" | "armv8" | "armv8l")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-linux-arm"
|
||||
;;
|
||||
"aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-linux-arm64"
|
||||
"armv7l" | "armv8" | "armv8l" | "aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-linux-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch"
|
||||
@ -54,13 +51,13 @@ case $os in
|
||||
Darwin)
|
||||
case $arch in
|
||||
"x86_64" | "x86" | "amd64" | "x64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-darwin-amd64"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-darwin-amd64"
|
||||
;;
|
||||
"i386" | "i686")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-darwin-386"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-darwin-386"
|
||||
;;
|
||||
"aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-darwin-arm64"
|
||||
"armv7l" | "armv8" | "armv8l" | "aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-darwin-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch"
|
||||
@ -71,16 +68,13 @@ case $os in
|
||||
FreeBSD)
|
||||
case $arch in
|
||||
amd64)
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-freebsd-amd64"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-freebsd-amd64"
|
||||
;;
|
||||
"i386" | "i686")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-freebsd-386"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-freebsd-386"
|
||||
;;
|
||||
"armv7l" | "armv8" | "armv8l")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-freebsd-arm"
|
||||
;;
|
||||
"aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-freebsd-arm64"
|
||||
"armv7l" | "armv8" | "armv8l" | "aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-freebsd-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch"
|
||||
@ -91,16 +85,13 @@ case $os in
|
||||
OpenBSD)
|
||||
case $arch in
|
||||
amd64)
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-openbsd-amd64"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-openbsd-amd64"
|
||||
;;
|
||||
"i386" | "i686")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-openbsd-386"
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-openbsd-386"
|
||||
;;
|
||||
"armv7l" | "armv8" | "armv8l")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-openbsd-arm"
|
||||
;;
|
||||
"aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/latest/download/backtrace-openbsd-arm64"
|
||||
"armv7l" | "armv8" | "armv8l" | "aarch64" | "arm64")
|
||||
wget -O backtrace "${cdn_success_url}https://github.com/oneclickvirt/backtrace/releases/download/output/backtrace-openbsd-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch"
|
||||
|
||||
@ -1,229 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ASNMetadataSchema = "backtrace.asn-metadata/v1"
|
||||
ASNMetadataManifestSchema = "backtrace.asn-metadata-manifest/v1"
|
||||
ASNMetadataMinimum = 50
|
||||
)
|
||||
|
||||
var asnMetadataSnapshotURLs = []string{
|
||||
"https://cdn.spiritlhl.net/https://raw.githubusercontent.com/oneclickvirt/backtrace/main/bgptools/data/bgp-asn-map.json",
|
||||
"https://raw.githubusercontent.com/oneclickvirt/backtrace/main/bgptools/data/bgp-asn-map.json",
|
||||
}
|
||||
|
||||
//go:embed data/bgp-asn-map.json
|
||||
var embeddedASNMetadata []byte
|
||||
|
||||
//go:embed data/bgp-asn-map.manifest.json
|
||||
var embeddedASNMetadataManifest []byte
|
||||
|
||||
type ASNMetadata struct {
|
||||
ASN uint32 `json:"asn"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ASNMetadataSource struct {
|
||||
Schema string `json:"schema"`
|
||||
Count int `json:"count"`
|
||||
GeneratedAt time.Time `json:"generated_at,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Fallback bool `json:"fallback"`
|
||||
}
|
||||
|
||||
type asnMetadataDocument struct {
|
||||
Schema string `json:"schema"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Entries []ASNMetadata `json:"entries"`
|
||||
}
|
||||
|
||||
type asnMetadataManifest struct {
|
||||
Schema string `json:"schema"`
|
||||
File string `json:"file"`
|
||||
Count int `json:"count"`
|
||||
SHA256 string `json:"sha256"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// LoadASNMetadata prefers the component's validated remote snapshot and
|
||||
// falls back to the compile-time snapshot. Upstream registry URLs and parsing
|
||||
// rules are intentionally not exposed to callers.
|
||||
func LoadASNMetadata(ctx context.Context, client *http.Client) ([]ASNMetadata, ASNMetadataSource, error) {
|
||||
return loadASNMetadata(ctx, client, asnMetadataSnapshotURLs, embeddedASNMetadata, embeddedASNMetadataManifest, ASNMetadataMinimum)
|
||||
}
|
||||
|
||||
// EmbeddedASNMetadata returns the validated compile-time snapshot without
|
||||
// performing network access.
|
||||
func EmbeddedASNMetadata() ([]ASNMetadata, ASNMetadataSource, error) {
|
||||
entries, generatedAt, err := parseASNMetadataDocument(embeddedASNMetadata, ASNMetadataMinimum)
|
||||
if err != nil {
|
||||
return nil, ASNMetadataSource{}, err
|
||||
}
|
||||
if err := validateASNMetadataManifest(embeddedASNMetadataManifest, embeddedASNMetadata, len(entries), generatedAt); err != nil {
|
||||
return nil, ASNMetadataSource{}, fmt.Errorf("validate embedded ASN metadata manifest: %w", err)
|
||||
}
|
||||
return entries, ASNMetadataSource{Schema: ASNMetadataSchema, Count: len(entries), GeneratedAt: generatedAt, Source: "embedded", Fallback: true}, nil
|
||||
}
|
||||
|
||||
// LookupASNMetadata resolves one ASN from the current component registry.
|
||||
func LookupASNMetadata(ctx context.Context, client *http.Client, asn string) (ASNMetadata, ASNMetadataSource, bool, error) {
|
||||
normalized, err := normalizeASN(asn)
|
||||
if err != nil {
|
||||
return ASNMetadata{}, ASNMetadataSource{}, false, err
|
||||
}
|
||||
number, _ := strconv.ParseUint(normalized, 10, 32)
|
||||
entries, source, err := LoadASNMetadata(ctx, client)
|
||||
if err != nil {
|
||||
return ASNMetadata{}, ASNMetadataSource{}, false, err
|
||||
}
|
||||
index := sort.Search(len(entries), func(index int) bool { return entries[index].ASN >= uint32(number) })
|
||||
if index >= len(entries) || entries[index].ASN != uint32(number) {
|
||||
return ASNMetadata{}, source, false, nil
|
||||
}
|
||||
return entries[index], source, true, nil
|
||||
}
|
||||
|
||||
func loadASNMetadata(ctx context.Context, client *http.Client, urls []string, embedded, embeddedManifest []byte, minimum int) ([]ASNMetadata, ASNMetadataSource, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
embeddedEntries, embeddedAt, err := parseASNMetadataDocument(embedded, minimum)
|
||||
if err != nil {
|
||||
return nil, ASNMetadataSource{}, fmt.Errorf("invalid embedded ASN metadata: %w", err)
|
||||
}
|
||||
if err := validateASNMetadataManifest(embeddedManifest, embedded, len(embeddedEntries), embeddedAt); err != nil {
|
||||
return nil, ASNMetadataSource{}, fmt.Errorf("invalid embedded ASN metadata manifest: %w", err)
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 6 * time.Second}
|
||||
}
|
||||
minimumRemote := max(minimum, len(embeddedEntries)*65/100)
|
||||
for index, snapshotURL := range urls {
|
||||
manifest, fetchErr := fetchASNMetadata(ctx, client, asnMetadataManifestURL(snapshotURL))
|
||||
if fetchErr != nil {
|
||||
continue
|
||||
}
|
||||
data, fetchErr := fetchASNMetadata(ctx, client, snapshotURL)
|
||||
if fetchErr != nil {
|
||||
continue
|
||||
}
|
||||
entries, generatedAt, parseErr := parseASNMetadataDocument(data, minimumRemote)
|
||||
if parseErr == nil && validateASNMetadataManifest(manifest, data, len(entries), generatedAt) == nil {
|
||||
return entries, ASNMetadataSource{Schema: ASNMetadataSchema, Count: len(entries), GeneratedAt: generatedAt, Source: metadataRemoteSource(index), Fallback: index > 0}, nil
|
||||
}
|
||||
}
|
||||
return embeddedEntries, ASNMetadataSource{Schema: ASNMetadataSchema, Count: len(embeddedEntries), GeneratedAt: embeddedAt, Source: "embedded", Fallback: true}, nil
|
||||
}
|
||||
|
||||
func metadataRemoteSource(index int) string {
|
||||
if index == 0 {
|
||||
return "cdn"
|
||||
}
|
||||
if index == 1 {
|
||||
return "raw"
|
||||
}
|
||||
return "remote"
|
||||
}
|
||||
|
||||
func asnMetadataManifestURL(snapshotURL string) string {
|
||||
return strings.TrimSuffix(snapshotURL, ".json") + ".manifest.json"
|
||||
}
|
||||
|
||||
func validateASNMetadataManifest(data, snapshot []byte, count int, generatedAt time.Time) error {
|
||||
var manifest asnMetadataManifest
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return errors.New("manifest contains trailing JSON")
|
||||
}
|
||||
if manifest.Schema != ASNMetadataManifestSchema || manifest.File != "bgp-asn-map.json" || manifest.Count != count || manifest.GeneratedAt.IsZero() || !manifest.GeneratedAt.Equal(generatedAt) {
|
||||
return errors.New("manifest schema, file, count, or generated_at is invalid")
|
||||
}
|
||||
hash := sha256.Sum256(snapshot)
|
||||
if !strings.EqualFold(manifest.SHA256, hex.EncodeToString(hash[:])) {
|
||||
return errors.New("manifest SHA-256 does not match snapshot")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchASNMetadata(ctx context.Context, client *http.Client, snapshotURL string) ([]byte, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, snapshotURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("create ASN metadata request failed")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-metadata/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
return nil, errors.New("ASN metadata request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
|
||||
}
|
||||
const maximumSize = 4 << 20
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, maximumSize+1))
|
||||
if err != nil {
|
||||
return nil, errors.New("ASN metadata response read failed")
|
||||
}
|
||||
if len(data) > maximumSize {
|
||||
return nil, fmt.Errorf("ASN metadata exceeds %d bytes", maximumSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func parseASNMetadataDocument(data []byte, minimum int) ([]ASNMetadata, time.Time, error) {
|
||||
var document asnMetadataDocument
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return nil, time.Time{}, errors.New("ASN metadata contains trailing JSON")
|
||||
}
|
||||
if document.Schema != ASNMetadataSchema || document.GeneratedAt.IsZero() {
|
||||
return nil, time.Time{}, errors.New("ASN metadata schema or generated_at is invalid")
|
||||
}
|
||||
seen := make(map[uint32]struct{}, len(document.Entries))
|
||||
entries := make([]ASNMetadata, 0, len(document.Entries))
|
||||
for _, entry := range document.Entries {
|
||||
entry.Name = strings.TrimSpace(entry.Name)
|
||||
if entry.ASN == 0 || entry.Name == "" {
|
||||
return nil, time.Time{}, errors.New("ASN metadata contains an invalid entry")
|
||||
}
|
||||
if _, exists := seen[entry.ASN]; exists {
|
||||
return nil, time.Time{}, fmt.Errorf("ASN metadata contains duplicate AS%d", entry.ASN)
|
||||
}
|
||||
seen[entry.ASN] = struct{}{}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if len(entries) < minimum {
|
||||
return nil, time.Time{}, fmt.Errorf("ASN metadata count %d is below minimum %d", len(entries), minimum)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].ASN < entries[j].ASN })
|
||||
return entries, document.GeneratedAt, nil
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadASNMetadataPrefersValidatedRemote(t *testing.T) {
|
||||
payload := `{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":2,"name":"Two"},{"asn":1,"name":"One"}]}`
|
||||
manifest := manifestFor([]byte(payload), "bgp-asn-map.json", 2)
|
||||
if err := validateASNMetadataManifest(manifest, []byte(payload), 2, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if strings.HasSuffix(request.URL.Path, ".manifest.json") {
|
||||
_, _ = w.Write(manifest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(payload))
|
||||
}))
|
||||
defer server.Close()
|
||||
entries, source, err := loadASNMetadata(context.Background(), server.Client(), []string{server.URL + "/bgp-asn-map.json"}, []byte(payload), manifest, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Source != "cdn" || source.Fallback || source.Count != 2 || entries[0].ASN != 1 {
|
||||
t.Fatalf("unexpected result: %#v %#v", entries, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadASNMetadataFallsBackOnSchemaFailure(t *testing.T) {
|
||||
embedded := `{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"}]}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"schema":"wrong"}`)) }))
|
||||
defer server.Close()
|
||||
manifest := manifestFor([]byte(embedded), "bgp-asn-map.json", 1)
|
||||
entries, source, err := loadASNMetadata(context.Background(), server.Client(), []string{server.URL + "/bgp-asn-map.json"}, []byte(embedded), manifest, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Source != "embedded" || !source.Fallback || len(entries) != 1 {
|
||||
t.Fatalf("unexpected fallback: %#v %#v", entries, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadASNMetadataFallsBackFromCDNToRaw(t *testing.T) {
|
||||
payload := []byte(`{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"}]}`)
|
||||
validManifest := manifestFor(payload, "bgp-asn-map.json", 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/cdn/bgp-asn-map.manifest.json" {
|
||||
_, _ = writer.Write([]byte(`{"schema":"backtrace.asn-metadata-manifest/v1","file":"bgp-asn-map.json","count":1,"sha256":"bad","generated_at":"2026-07-20T00:00:00Z"}`))
|
||||
return
|
||||
}
|
||||
if request.URL.Path == "/raw/bgp-asn-map.manifest.json" {
|
||||
_, _ = writer.Write(validManifest)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
entries, source, err := loadASNMetadata(context.Background(), server.Client(), []string{server.URL + "/cdn/bgp-asn-map.json", server.URL + "/raw/bgp-asn-map.json"}, payload, validManifest, 1)
|
||||
if err != nil || len(entries) != 1 || source.Source != "raw" || !source.Fallback {
|
||||
t.Fatalf("unexpected raw fallback: entries=%#v source=%#v err=%v", entries, source, err)
|
||||
}
|
||||
}
|
||||
|
||||
func manifestFor(snapshot []byte, file string, count int) []byte {
|
||||
hash := sha256.Sum256(snapshot)
|
||||
return []byte(fmt.Sprintf(`{"schema":"backtrace.asn-metadata-manifest/v1","file":"%s","count":%d,"sha256":"%s","generated_at":"2026-07-20T00:00:00Z"}`, file, count, hex.EncodeToString(hash[:])))
|
||||
}
|
||||
|
||||
func TestParseASNMetadataRejectsDuplicateAndDrop(t *testing.T) {
|
||||
payload := []byte(`{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"},{"asn":1,"name":"Again"}]}`)
|
||||
if _, _, err := parseASNMetadataDocument(payload, 1); err == nil {
|
||||
t.Fatal("duplicate ASN unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
@ -1,342 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-metadata/v1",
|
||||
"generated_at": "2026-07-21T15:33:49.459211Z",
|
||||
"entries": [
|
||||
{
|
||||
"asn": 174,
|
||||
"name": "Cogent"
|
||||
},
|
||||
{
|
||||
"asn": 701,
|
||||
"name": "Verizon"
|
||||
},
|
||||
{
|
||||
"asn": 702,
|
||||
"name": "Verizon"
|
||||
},
|
||||
{
|
||||
"asn": 1239,
|
||||
"name": "Sprint"
|
||||
},
|
||||
{
|
||||
"asn": 1273,
|
||||
"name": "Vodafone"
|
||||
},
|
||||
{
|
||||
"asn": 1299,
|
||||
"name": "Arelion"
|
||||
},
|
||||
{
|
||||
"asn": 1729,
|
||||
"name": "Telia"
|
||||
},
|
||||
{
|
||||
"asn": 2497,
|
||||
"name": "IIJ"
|
||||
},
|
||||
{
|
||||
"asn": 2516,
|
||||
"name": "KDDI"
|
||||
},
|
||||
{
|
||||
"asn": 2914,
|
||||
"name": "NTT"
|
||||
},
|
||||
{
|
||||
"asn": 3209,
|
||||
"name": "Vodafone"
|
||||
},
|
||||
{
|
||||
"asn": 3257,
|
||||
"name": "GTT"
|
||||
},
|
||||
{
|
||||
"asn": 3258,
|
||||
"name": "xTom"
|
||||
},
|
||||
{
|
||||
"asn": 3301,
|
||||
"name": "Telia"
|
||||
},
|
||||
{
|
||||
"asn": 3308,
|
||||
"name": "Telia"
|
||||
},
|
||||
{
|
||||
"asn": 3320,
|
||||
"name": "DTAG"
|
||||
},
|
||||
{
|
||||
"asn": 3356,
|
||||
"name": "Lumen"
|
||||
},
|
||||
{
|
||||
"asn": 3462,
|
||||
"name": "Hinet"
|
||||
},
|
||||
{
|
||||
"asn": 3491,
|
||||
"name": "PCCW"
|
||||
},
|
||||
{
|
||||
"asn": 3786,
|
||||
"name": "LG"
|
||||
},
|
||||
{
|
||||
"asn": 4445,
|
||||
"name": "Vodafone"
|
||||
},
|
||||
{
|
||||
"asn": 4609,
|
||||
"name": "CTM"
|
||||
},
|
||||
{
|
||||
"asn": 4637,
|
||||
"name": "Telstra"
|
||||
},
|
||||
{
|
||||
"asn": 4657,
|
||||
"name": "StarHub"
|
||||
},
|
||||
{
|
||||
"asn": 4766,
|
||||
"name": "KT"
|
||||
},
|
||||
{
|
||||
"asn": 4788,
|
||||
"name": "TMNet"
|
||||
},
|
||||
{
|
||||
"asn": 5378,
|
||||
"name": "Vodafone"
|
||||
},
|
||||
{
|
||||
"asn": 5511,
|
||||
"name": "Orange"
|
||||
},
|
||||
{
|
||||
"asn": 6233,
|
||||
"name": "xTom"
|
||||
},
|
||||
{
|
||||
"asn": 6453,
|
||||
"name": "TATA"
|
||||
},
|
||||
{
|
||||
"asn": 6461,
|
||||
"name": "Zayo"
|
||||
},
|
||||
{
|
||||
"asn": 6762,
|
||||
"name": "Sparkle"
|
||||
},
|
||||
{
|
||||
"asn": 6830,
|
||||
"name": "Liberty"
|
||||
},
|
||||
{
|
||||
"asn": 6939,
|
||||
"name": "HE"
|
||||
},
|
||||
{
|
||||
"asn": 7018,
|
||||
"name": "AT\u0026T"
|
||||
},
|
||||
{
|
||||
"asn": 7473,
|
||||
"name": "SingTel"
|
||||
},
|
||||
{
|
||||
"asn": 7922,
|
||||
"name": "Comcast"
|
||||
},
|
||||
{
|
||||
"asn": 7979,
|
||||
"name": "Servers"
|
||||
},
|
||||
{
|
||||
"asn": 8075,
|
||||
"name": "MS"
|
||||
},
|
||||
{
|
||||
"asn": 8888,
|
||||
"name": "xTom"
|
||||
},
|
||||
{
|
||||
"asn": 9002,
|
||||
"name": "RETN"
|
||||
},
|
||||
{
|
||||
"asn": 9269,
|
||||
"name": "HKBN"
|
||||
},
|
||||
{
|
||||
"asn": 9304,
|
||||
"name": "HGC"
|
||||
},
|
||||
{
|
||||
"asn": 9505,
|
||||
"name": "CHT"
|
||||
},
|
||||
{
|
||||
"asn": 9644,
|
||||
"name": "SK"
|
||||
},
|
||||
{
|
||||
"asn": 9886,
|
||||
"name": "CTCSCI"
|
||||
},
|
||||
{
|
||||
"asn": 9924,
|
||||
"name": "TFN"
|
||||
},
|
||||
{
|
||||
"asn": 12389,
|
||||
"name": "RT_RU"
|
||||
},
|
||||
{
|
||||
"asn": 12876,
|
||||
"name": "Scaleway"
|
||||
},
|
||||
{
|
||||
"asn": 12956,
|
||||
"name": "Telxius"
|
||||
},
|
||||
{
|
||||
"asn": 13335,
|
||||
"name": "CF"
|
||||
},
|
||||
{
|
||||
"asn": 15169,
|
||||
"name": "Google"
|
||||
},
|
||||
{
|
||||
"asn": 15802,
|
||||
"name": "DU"
|
||||
},
|
||||
{
|
||||
"asn": 16276,
|
||||
"name": "OVH"
|
||||
},
|
||||
{
|
||||
"asn": 16509,
|
||||
"name": "Amazon"
|
||||
},
|
||||
{
|
||||
"asn": 17676,
|
||||
"name": "Softbank"
|
||||
},
|
||||
{
|
||||
"asn": 18403,
|
||||
"name": "FPT"
|
||||
},
|
||||
{
|
||||
"asn": 20473,
|
||||
"name": "Vultr"
|
||||
},
|
||||
{
|
||||
"asn": 20485,
|
||||
"name": "TTK_RU"
|
||||
},
|
||||
{
|
||||
"asn": 23961,
|
||||
"name": "Misaka"
|
||||
},
|
||||
{
|
||||
"asn": 24940,
|
||||
"name": "Hetzner"
|
||||
},
|
||||
{
|
||||
"asn": 25820,
|
||||
"name": "IT7"
|
||||
},
|
||||
{
|
||||
"asn": 30844,
|
||||
"name": "Liquid"
|
||||
},
|
||||
{
|
||||
"asn": 31898,
|
||||
"name": "Oracle"
|
||||
},
|
||||
{
|
||||
"asn": 37700,
|
||||
"name": "JINX"
|
||||
},
|
||||
{
|
||||
"asn": 38731,
|
||||
"name": "CHT"
|
||||
},
|
||||
{
|
||||
"asn": 45102,
|
||||
"name": "Alibaba"
|
||||
},
|
||||
{
|
||||
"asn": 45899,
|
||||
"name": "VNPT"
|
||||
},
|
||||
{
|
||||
"asn": 49304,
|
||||
"name": "SAKURA"
|
||||
},
|
||||
{
|
||||
"asn": 51847,
|
||||
"name": "Nearoute"
|
||||
},
|
||||
{
|
||||
"asn": 58652,
|
||||
"name": "Level3"
|
||||
},
|
||||
{
|
||||
"asn": 60068,
|
||||
"name": "Datacamp"
|
||||
},
|
||||
{
|
||||
"asn": 63150,
|
||||
"name": "Bage"
|
||||
},
|
||||
{
|
||||
"asn": 132203,
|
||||
"name": "Tencent"
|
||||
},
|
||||
{
|
||||
"asn": 133613,
|
||||
"name": "MTel"
|
||||
},
|
||||
{
|
||||
"asn": 136907,
|
||||
"name": "Huawei"
|
||||
},
|
||||
{
|
||||
"asn": 137409,
|
||||
"name": "GSL"
|
||||
},
|
||||
{
|
||||
"asn": 140096,
|
||||
"name": "JINX"
|
||||
},
|
||||
{
|
||||
"asn": 152672,
|
||||
"name": "Aiyun"
|
||||
},
|
||||
{
|
||||
"asn": 202662,
|
||||
"name": "Hytron"
|
||||
},
|
||||
{
|
||||
"asn": 209242,
|
||||
"name": "CF"
|
||||
},
|
||||
{
|
||||
"asn": 211392,
|
||||
"name": "Dream"
|
||||
},
|
||||
{
|
||||
"asn": 214996,
|
||||
"name": "netcup"
|
||||
},
|
||||
{
|
||||
"asn": 216382,
|
||||
"name": "Layer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-metadata-manifest/v1",
|
||||
"file": "bgp-asn-map.json",
|
||||
"count": 84,
|
||||
"sha256": "f681dd78d55c27d4615d2e705de9bde24d111e8897d37a489b91e263f27f1a66",
|
||||
"generated_at": "2026-07-21T15:33:49.459211Z"
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultOriginASNURL = "https://stat.ripe.net/data/network-info/data.json"
|
||||
|
||||
var (
|
||||
ErrOriginASNMissing = errors.New("origin ASN response has no usable ASN")
|
||||
ErrOriginASNRateLimited = errors.New("origin ASN resolver rate limited")
|
||||
)
|
||||
|
||||
type OriginASNConfig struct {
|
||||
Client *http.Client
|
||||
BaseURL string
|
||||
Timeout time.Duration
|
||||
MaxResponseSize int64
|
||||
}
|
||||
|
||||
func ResolveOriginASN(ctx context.Context, ip string) (string, error) {
|
||||
return ResolveOriginASNWithConfig(ctx, ip, OriginASNConfig{})
|
||||
}
|
||||
|
||||
func ResolveOriginASNWithConfig(parent context.Context, ip string, config OriginASNConfig) (string, error) {
|
||||
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||
if parsed == nil {
|
||||
return "", ErrInvalidIPAddress
|
||||
}
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 8 * time.Second
|
||||
}
|
||||
if config.MaxResponseSize <= 0 {
|
||||
config.MaxResponseSize = 1 << 20
|
||||
}
|
||||
if config.Client == nil {
|
||||
config.Client = &http.Client{}
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
config.BaseURL = defaultOriginASNURL
|
||||
}
|
||||
requestURL, err := addQuery(config.BaseURL, "resource", parsed.String())
|
||||
if err != nil {
|
||||
return "", errors.New("invalid origin ASN source")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, config.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("create origin ASN request failed")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "oneclickvirt-backtrace-origin-asn/1")
|
||||
response, err := config.Client.Do(req)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return "", ctxErr
|
||||
}
|
||||
return "", errors.New("origin ASN request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusTooManyRequests {
|
||||
return "", ErrOriginASNRateLimited
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("origin ASN HTTP %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, config.MaxResponseSize+1))
|
||||
if err != nil {
|
||||
return "", errors.New("origin ASN response read failed")
|
||||
}
|
||||
if int64(len(body)) > config.MaxResponseSize {
|
||||
return "", fmt.Errorf("origin ASN response exceeds %d bytes", config.MaxResponseSize)
|
||||
}
|
||||
var payload struct {
|
||||
Status string `json:"status"`
|
||||
Data *struct {
|
||||
ASNs []json.RawMessage `json:"asns"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status := strings.TrimSpace(payload.Status); status != "" && !strings.EqualFold(status, "ok") {
|
||||
return "", fmt.Errorf("origin ASN response status %q", status)
|
||||
}
|
||||
if payload.Data == nil || len(payload.Data.ASNs) == 0 {
|
||||
return "", ErrOriginASNMissing
|
||||
}
|
||||
for _, raw := range payload.Data.ASNs {
|
||||
value, err := originASNValue(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if normalized, err := normalizeASN(value); err == nil {
|
||||
return normalized, nil
|
||||
}
|
||||
}
|
||||
return "", ErrOriginASNMissing
|
||||
}
|
||||
|
||||
func originASNValue(raw json.RawMessage) (string, error) {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text, nil
|
||||
}
|
||||
var number json.Number
|
||||
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&number); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := strconv.ParseUint(number.String(), 10, 32); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return number.String(), nil
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResolveOriginASNOfflineFixture(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("resource"); got != "192.0.2.1" {
|
||||
t.Errorf("resource = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":"ok","data":{"asns":["AS64500",64501],"prefix":"192.0.2.0/24"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
asn, err := ResolveOriginASNWithConfig(context.Background(), "192.0.2.1", OriginASNConfig{
|
||||
Client: server.Client(), BaseURL: server.URL, Timeout: time.Second,
|
||||
})
|
||||
if err != nil || asn != "64500" {
|
||||
t.Fatalf("ResolveOriginASNWithConfig() = %q, %v", asn, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOriginASNRejectsMissingAndDriftedData(t *testing.T) {
|
||||
for _, payload := range []string{
|
||||
`{"status":"ok","data":{"asns":[]}}`,
|
||||
`{"status":"ok","data":{"asns":"64500"}}`,
|
||||
`{"status":"ok","data":{"asns":["not-an-asn"]}}`,
|
||||
`{"status":"error","data":{"asns":[64500]}}`,
|
||||
} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(payload))
|
||||
}))
|
||||
_, err := ResolveOriginASNWithConfig(context.Background(), "2001:db8::1", OriginASNConfig{
|
||||
Client: server.Client(), BaseURL: server.URL, Timeout: time.Second,
|
||||
})
|
||||
server.Close()
|
||||
if err == nil {
|
||||
t.Fatalf("payload %s unexpectedly succeeded", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOriginASNReportsRateLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := ResolveOriginASNWithConfig(context.Background(), "192.0.2.1", OriginASNConfig{Client: server.Client(), BaseURL: server.URL})
|
||||
if !errors.Is(err, ErrOriginASNRateLimited) {
|
||||
t.Fatalf("rate limit error = %v", err)
|
||||
}
|
||||
}
|
||||
407
bgptools/pop.go
407
bgptools/pop.go
@ -1,407 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
"github.com/oneclickvirt/defaultset"
|
||||
)
|
||||
|
||||
type ASCard struct {
|
||||
ASN string
|
||||
Name string
|
||||
Fill string
|
||||
Stroke string
|
||||
ID string
|
||||
}
|
||||
|
||||
type Arrow struct {
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
type Upstream struct {
|
||||
ASN string
|
||||
Name string
|
||||
Direct bool
|
||||
Tier1 bool
|
||||
Type string
|
||||
}
|
||||
|
||||
type PoPResult struct {
|
||||
TargetASN string
|
||||
Upstreams []Upstream
|
||||
Result string
|
||||
}
|
||||
|
||||
type retryConfig struct {
|
||||
maxRetries int
|
||||
timeouts []time.Duration
|
||||
}
|
||||
|
||||
var defaultRetryConfig = retryConfig{
|
||||
maxRetries: 2,
|
||||
timeouts: []time.Duration{5 * time.Second, 6 * time.Second},
|
||||
}
|
||||
|
||||
func executeWithRetry(client *req.Client, url string, config retryConfig) (*req.Response, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < config.maxRetries; attempt++ {
|
||||
timeout := config.timeouts[attempt]
|
||||
resp, err := client.SetTimeout(timeout).R().
|
||||
Get(url)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
return resp, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("attempt %d failed with timeout %v: %w", attempt+1, timeout, err)
|
||||
} else {
|
||||
lastErr = fmt.Errorf("attempt %d failed with HTTP status %d (timeout %v)", attempt+1, resp.StatusCode, timeout)
|
||||
}
|
||||
if attempt < config.maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("all %d attempts failed, last error: %w", config.maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func getISPAbbr(asn, name string) string {
|
||||
if abbr, ok := model.Tier1Global[asn]; ok {
|
||||
return abbr
|
||||
}
|
||||
if idx := strings.Index(name, " "); idx != -1 && idx >= 18 {
|
||||
return name[:idx]
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func getISPType(asn string, tier1 bool, direct bool) string {
|
||||
switch {
|
||||
case tier1 && direct && model.Tier1Global[asn] != "":
|
||||
return "Tier1 Global"
|
||||
case tier1 && direct && model.Tier1Regional[asn] != "":
|
||||
return "Tier1 Regional"
|
||||
case tier1 && !direct:
|
||||
return "Tier1 Indirect"
|
||||
case model.Tier2[asn] != "":
|
||||
return "Tier2"
|
||||
case model.ContentProviders[asn] != "":
|
||||
return "CDN Provider"
|
||||
case model.IXPS[asn] != "":
|
||||
return "IXP"
|
||||
case direct:
|
||||
return "Direct"
|
||||
default:
|
||||
return "Indirect"
|
||||
}
|
||||
}
|
||||
|
||||
func isValidIP(ip string) bool {
|
||||
return net.ParseIP(ip) != nil
|
||||
}
|
||||
|
||||
func getSVGPath(ip string) (string, error) {
|
||||
if !isValidIP(ip) {
|
||||
return "", fmt.Errorf("invalid IP address: %s", ip)
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < defaultRetryConfig.maxRetries; attempt++ {
|
||||
client := req.C().ImpersonateChrome()
|
||||
url := fmt.Sprintf("https://bgp.tools/prefix/%s#connectivity", ip)
|
||||
resp, err := executeWithRetry(client, url, defaultRetryConfig)
|
||||
if err == nil && resp != nil {
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
body := resp.String()
|
||||
re := regexp.MustCompile(`<img[^>]+id="pathimg"[^>]+src="([^"]+)"`)
|
||||
matches := re.FindStringSubmatch(body)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1], nil
|
||||
}
|
||||
lastErr = fmt.Errorf("SVG path not found for IP %s", ip)
|
||||
} else {
|
||||
lastErr = fmt.Errorf("failed to fetch BGP info for IP %s: %w", ip, err)
|
||||
}
|
||||
if attempt < defaultRetryConfig.maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("failed to get SVG path after %d retries: %w", defaultRetryConfig.maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func downloadSVG(svgPath string) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < defaultRetryConfig.maxRetries; attempt++ {
|
||||
client := req.C().ImpersonateChrome()
|
||||
uuid := uuid.NewString()
|
||||
url := fmt.Sprintf("https://bgp.tools%s?%s&loggedin", svgPath, uuid)
|
||||
resp, err := executeWithRetry(client, url, defaultRetryConfig)
|
||||
if err == nil && resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
return string(bodyBytes), nil
|
||||
}
|
||||
lastErr = fmt.Errorf("failed to read SVG response body: %w", err)
|
||||
} else {
|
||||
lastErr = fmt.Errorf("failed to download SVG: %w", err)
|
||||
}
|
||||
if attempt < defaultRetryConfig.maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("failed to download SVG after %d retries: %w", defaultRetryConfig.maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func parseASAndEdges(svg string) ([]ASCard, []Arrow) {
|
||||
svg = html.UnescapeString(svg)
|
||||
var nodes []ASCard
|
||||
var edges []Arrow
|
||||
nodeRE := regexp.MustCompile(`(?s)<g id="node\d+" class="node">(.*?)</g>`)
|
||||
edgeRE := regexp.MustCompile(`(?s)<g id="edge\d+" class="edge">(.*?)</g>`)
|
||||
asnRE := regexp.MustCompile(`<title>AS(\d+)</title>`)
|
||||
nameRE := regexp.MustCompile(`xlink:title="([^"]+)"`)
|
||||
fillRE := regexp.MustCompile(`<polygon[^>]+fill="([^"]+)"`)
|
||||
strokeRE := regexp.MustCompile(`<polygon[^>]+stroke="([^"]+)"`)
|
||||
titleRE := regexp.MustCompile(`<title>AS(\d+)->AS(\d+)</title>`)
|
||||
for _, match := range nodeRE.FindAllStringSubmatch(svg, -1) {
|
||||
block := match[1]
|
||||
asn := ""
|
||||
if a := asnRE.FindStringSubmatch(block); len(a) > 1 {
|
||||
asn = a[1]
|
||||
}
|
||||
name := "unknown"
|
||||
if n := nameRE.FindStringSubmatch(block); len(n) > 1 {
|
||||
name = strings.TrimSpace(n[1])
|
||||
}
|
||||
fill := "none"
|
||||
if f := fillRE.FindStringSubmatch(block); len(f) > 1 {
|
||||
fill = f[1]
|
||||
}
|
||||
stroke := "none"
|
||||
if s := strokeRE.FindStringSubmatch(block); len(s) > 1 {
|
||||
stroke = s[1]
|
||||
}
|
||||
if asn != "" {
|
||||
nodes = append(nodes, ASCard{
|
||||
ASN: asn,
|
||||
Name: name,
|
||||
Fill: fill,
|
||||
Stroke: stroke,
|
||||
ID: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, match := range edgeRE.FindAllStringSubmatch(svg, -1) {
|
||||
block := match[1]
|
||||
if t := titleRE.FindStringSubmatch(block); len(t) == 3 {
|
||||
edges = append(edges, Arrow{
|
||||
From: t[1],
|
||||
To: t[2],
|
||||
})
|
||||
}
|
||||
}
|
||||
return nodes, edges
|
||||
}
|
||||
|
||||
func findTargetASN(nodes []ASCard) string {
|
||||
for _, n := range nodes {
|
||||
if n.Fill == "limegreen" || n.Stroke == "limegreen" || n.Fill == "green" {
|
||||
return n.ASN
|
||||
}
|
||||
}
|
||||
if len(nodes) > 0 {
|
||||
return nodes[0].ASN
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findUpstreams(targetASN string, nodes []ASCard, edges []Arrow) []Upstream {
|
||||
upstreamMap := map[string]bool{}
|
||||
for _, e := range edges {
|
||||
if e.From == targetASN {
|
||||
upstreamMap[e.To] = true
|
||||
}
|
||||
}
|
||||
var upstreams []Upstream
|
||||
addedASNs := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
if !upstreamMap[n.ASN] {
|
||||
continue
|
||||
}
|
||||
isTier1 := (n.Fill == "white" && n.Stroke == "#005ea5")
|
||||
upstreamType := getISPType(n.ASN, isTier1, true)
|
||||
upstreams = append(upstreams, Upstream{
|
||||
ASN: n.ASN,
|
||||
Name: n.Name,
|
||||
Direct: true,
|
||||
Tier1: isTier1,
|
||||
Type: upstreamType,
|
||||
})
|
||||
addedASNs[n.ASN] = true
|
||||
}
|
||||
if len(upstreams) == 1 {
|
||||
currentASN := upstreams[0].ASN
|
||||
for {
|
||||
nextUpstreams := map[string]bool{}
|
||||
for _, e := range edges {
|
||||
if e.From == currentASN {
|
||||
nextUpstreams[e.To] = true
|
||||
}
|
||||
}
|
||||
if len(nextUpstreams) != 1 {
|
||||
break
|
||||
}
|
||||
var nextASN string
|
||||
for asn := range nextUpstreams {
|
||||
nextASN = asn
|
||||
break
|
||||
}
|
||||
if addedASNs[nextASN] {
|
||||
break
|
||||
}
|
||||
var nextNode *ASCard
|
||||
for _, n := range nodes {
|
||||
if n.ASN == nextASN {
|
||||
nextNode = &n
|
||||
break
|
||||
}
|
||||
}
|
||||
if nextNode == nil {
|
||||
break
|
||||
}
|
||||
isTier1 := (nextNode.Fill == "white" && nextNode.Stroke == "#005ea5")
|
||||
upstreamType := getISPType(nextNode.ASN, isTier1, false)
|
||||
upstreams = append(upstreams, Upstream{
|
||||
ASN: nextNode.ASN,
|
||||
Name: nextNode.Name,
|
||||
Direct: false,
|
||||
Tier1: isTier1,
|
||||
Type: upstreamType,
|
||||
})
|
||||
addedASNs[nextNode.ASN] = true
|
||||
currentASN = nextASN
|
||||
}
|
||||
} else if len(upstreams) > 1 {
|
||||
for _, directUpstream := range upstreams {
|
||||
currentASN := directUpstream.ASN
|
||||
for {
|
||||
nextUpstreams := map[string]bool{}
|
||||
for _, e := range edges {
|
||||
if e.From == currentASN {
|
||||
nextUpstreams[e.To] = true
|
||||
}
|
||||
}
|
||||
if len(nextUpstreams) != 1 {
|
||||
break
|
||||
}
|
||||
var nextASN string
|
||||
for asn := range nextUpstreams {
|
||||
nextASN = asn
|
||||
break
|
||||
}
|
||||
if addedASNs[nextASN] {
|
||||
break
|
||||
}
|
||||
var nextNode *ASCard
|
||||
for _, n := range nodes {
|
||||
if n.ASN == nextASN {
|
||||
nextNode = &n
|
||||
break
|
||||
}
|
||||
}
|
||||
if nextNode == nil {
|
||||
break
|
||||
}
|
||||
isTier1 := (nextNode.Fill == "white" && nextNode.Stroke == "#005ea5")
|
||||
if isTier1 {
|
||||
upstreamType := getISPType(nextNode.ASN, isTier1, false)
|
||||
upstreams = append(upstreams, Upstream{
|
||||
ASN: nextNode.ASN,
|
||||
Name: nextNode.Name,
|
||||
Direct: false,
|
||||
Tier1: isTier1,
|
||||
Type: upstreamType,
|
||||
})
|
||||
addedASNs[nextNode.ASN] = true
|
||||
break
|
||||
}
|
||||
currentASN = nextASN
|
||||
}
|
||||
}
|
||||
}
|
||||
return upstreams
|
||||
}
|
||||
|
||||
func GetPoPInfo(ip string) (*PoPResult, error) {
|
||||
if ip == "" {
|
||||
return nil, fmt.Errorf("IP address cannot be empty")
|
||||
}
|
||||
svgPath, err := getSVGPath(ip)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取SVG路径失败: %w", err)
|
||||
}
|
||||
svg, err := downloadSVG(svgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载SVG失败: %w", err)
|
||||
}
|
||||
nodes, edges := parseASAndEdges(svg)
|
||||
if len(nodes) == 0 {
|
||||
return nil, fmt.Errorf("未找到任何AS节点")
|
||||
}
|
||||
targetASN := findTargetASN(nodes)
|
||||
if targetASN == "" {
|
||||
return nil, fmt.Errorf("无法识别目标 ASN")
|
||||
}
|
||||
upstreams := findUpstreams(targetASN, nodes, edges)
|
||||
colWidth := 18
|
||||
center := func(s string) string {
|
||||
runeLen := len([]rune(s))
|
||||
if runeLen >= colWidth {
|
||||
return string([]rune(s)[:colWidth])
|
||||
}
|
||||
padding := colWidth - runeLen
|
||||
left := padding / 2
|
||||
right := padding - left
|
||||
return strings.Repeat(" ", left) + s + strings.Repeat(" ", right)
|
||||
}
|
||||
var result strings.Builder
|
||||
perLine := 5
|
||||
for i := 0; i < len(upstreams); i += perLine {
|
||||
end := i + perLine
|
||||
if end > len(upstreams) {
|
||||
end = len(upstreams)
|
||||
}
|
||||
batch := upstreams[i:end]
|
||||
var line1, line2, line3 []string
|
||||
for _, u := range batch {
|
||||
abbr := getISPAbbr(u.ASN, u.Name)
|
||||
asStr := center("AS" + u.ASN)
|
||||
abbrStr := center(abbr)
|
||||
typeStr := center(u.Type)
|
||||
line1 = append(line1, defaultset.White(asStr))
|
||||
line2 = append(line2, abbrStr)
|
||||
line3 = append(line3, defaultset.Blue(typeStr))
|
||||
}
|
||||
result.WriteString(strings.Join(line1, ""))
|
||||
result.WriteString("\n")
|
||||
result.WriteString(strings.Join(line2, ""))
|
||||
result.WriteString("\n")
|
||||
result.WriteString(strings.Join(line3, ""))
|
||||
result.WriteString("\n")
|
||||
}
|
||||
return &PoPResult{
|
||||
TargetASN: targetASN,
|
||||
Upstreams: upstreams,
|
||||
Result: result.String(),
|
||||
}, nil
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetPoPInfo(t *testing.T) {
|
||||
result, err := GetPoPInfo("23.128.228.123")
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("目标 ASN: %s\n", result.TargetASN)
|
||||
fmt.Println(len(result.Upstreams))
|
||||
fmt.Println(result.Upstreams)
|
||||
fmt.Println("上游信息:")
|
||||
fmt.Print(result.Result)
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type privacyRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn privacyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
|
||||
func TestStructuredReportErrorsDoNotExposeRemoteURLs(t *testing.T) {
|
||||
var rdap *httptest.Server
|
||||
rdap = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"handle":"TEST","links":[{"rel":"geofeed","href":"https://private.example/geofeed?token=secret"}]}`)
|
||||
}))
|
||||
defer rdap.Close()
|
||||
failing := &http.Client{Transport: privacyRoundTripper(func(request *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial " + request.URL.String())
|
||||
})}
|
||||
report, err := QueryIPBGPReport(context.Background(), "192.0.2.1", IPBGPReportConfig{
|
||||
RDAPClient: rdap.Client(),
|
||||
RDAPBaseURL: rdap.URL,
|
||||
FetchGeofeed: true,
|
||||
GeofeedClient: failing,
|
||||
ResolveASN: func(context.Context, string) (string, error) {
|
||||
return "", errors.New("resolver https://private.example/asn?key=secret")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, source := range report.Sources {
|
||||
for _, field := range []string{source.Source, source.Error} {
|
||||
for _, forbidden := range []string{"private.example", "token=", "key=", "secret"} {
|
||||
if strings.Contains(field, forbidden) {
|
||||
t.Fatalf("structured source leaked %q: %+v", forbidden, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(report.Geofeeds) != 1 || report.Geofeeds[0].Error != "request_failed" {
|
||||
t.Fatalf("geofeed error was not stabilized: %+v", report.Geofeeds)
|
||||
}
|
||||
encoded, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"private.example", "token=", "key=", "secret", "userinfo@", `"geofeed_urls"`, `"port43"`, `"server"`, `"url"`} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("structured report leaked %q: %s", forbidden, encoded)
|
||||
}
|
||||
}
|
||||
if report.RDAP == nil || len(report.RDAP.GeofeedURLs) != 1 || report.Geofeeds[0].Status != ReportError {
|
||||
t.Fatalf("internal geofeed processing was not preserved: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteFetchErrorsDoNotExposeSourceURL(t *testing.T) {
|
||||
client := &http.Client{Transport: privacyRoundTripper(func(request *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial " + request.URL.String())
|
||||
})}
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{name: "ASN metadata", run: func() error {
|
||||
_, err := fetchASNMetadata(context.Background(), client, "https://private.example/asn?token=secret")
|
||||
return err
|
||||
}},
|
||||
{name: "RDAP", run: func() error {
|
||||
_, err := QueryRDAP(context.Background(), "192.0.2.1", client, "https://private.example/rdap?token=secret")
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.run()
|
||||
if err == nil {
|
||||
t.Fatal("expected request failure")
|
||||
}
|
||||
for _, forbidden := range []string{"private.example", "token=", "secret"} {
|
||||
if strings.Contains(err.Error(), forbidden) {
|
||||
t.Fatalf("error leaked %q: %v", forbidden, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredReportOmitsServiceLocationsButKeepsProbeEvidence(t *testing.T) {
|
||||
report := IPBGPReport{
|
||||
Status: ReportPartial,
|
||||
RDAP: &RDAPRecord{
|
||||
Port43: "whois.private.example:43",
|
||||
GeofeedURLs: []string{"https://private.example/rdap-geofeed.csv"},
|
||||
},
|
||||
WHOIS: &WHOISRecord{
|
||||
Server: "whois.private.example:43",
|
||||
Status: ReportAvailable,
|
||||
GeofeedURLs: []string{"https://private.example/whois-geofeed.csv"},
|
||||
},
|
||||
Geofeeds: []GeofeedResult{{
|
||||
URL: "https://private.example/geofeed.csv",
|
||||
Status: ReportAvailable,
|
||||
HTTPStatus: http.StatusOK,
|
||||
Bytes: 128,
|
||||
}},
|
||||
}
|
||||
encoded, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"private.example", "whois.private", `"geofeed_urls"`, `"port43"`, `"server"`, `"url"`} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("structured report leaked %q: %s", forbidden, encoded)
|
||||
}
|
||||
}
|
||||
for _, expected := range []string{`"status":"available"`, `"http_status":200`, `"bytes":128`} {
|
||||
if !strings.Contains(string(encoded), expected) {
|
||||
t.Fatalf("structured report lost probe evidence %q: %s", expected, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
211
bgptools/rdap.go
211
bgptools/rdap.go
@ -1,211 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidIPAddress = errors.New("invalid IP address")
|
||||
ErrRDAPRateLimited = errors.New("rdap rate limited")
|
||||
ErrRDAPResponseTooLarge = errors.New("rdap response too large")
|
||||
)
|
||||
|
||||
type RDAPEntity struct {
|
||||
Handle string `json:"handle,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
type RDAPRecord struct {
|
||||
IP string `json:"ip"`
|
||||
Handle string `json:"handle,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
StartAddress string `json:"start_address,omitempty"`
|
||||
EndAddress string `json:"end_address,omitempty"`
|
||||
ParentHandle string `json:"parent_handle,omitempty"`
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
RegistrationDate *time.Time `json:"registration_date,omitempty"`
|
||||
LastChangedDate *time.Time `json:"last_changed_date,omitempty"`
|
||||
GeofeedURLs []string `json:"-"`
|
||||
Entities []RDAPEntity `json:"entities,omitempty"`
|
||||
Port43 string `json:"-"`
|
||||
Status []string `json:"status,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL string) (RDAPRecord, error) {
|
||||
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||
if parsed == nil {
|
||||
return RDAPRecord{}, ErrInvalidIPAddress
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 8 * time.Second}
|
||||
}
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = "https://rdap.org/ip/"
|
||||
}
|
||||
requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return RDAPRecord{}, errors.New("create RDAP request failed")
|
||||
}
|
||||
req.Header.Set("Accept", "application/rdap+json, application/json")
|
||||
req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1")
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return RDAPRecord{}, ctxErr
|
||||
}
|
||||
return RDAPRecord{}, errors.New("RDAP request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusTooManyRequests {
|
||||
return RDAPRecord{}, ErrRDAPRateLimited
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return RDAPRecord{}, fmt.Errorf("rdap HTTP %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1))
|
||||
if err != nil {
|
||||
return RDAPRecord{}, errors.New("RDAP response read failed")
|
||||
}
|
||||
if len(data) > 4<<20 {
|
||||
return RDAPRecord{}, ErrRDAPResponseTooLarge
|
||||
}
|
||||
source := requestURL
|
||||
if response.Request != nil && response.Request.URL != nil {
|
||||
source = response.Request.URL.String()
|
||||
}
|
||||
return parseRDAPRecord(parsed.String(), source, data)
|
||||
}
|
||||
|
||||
func parseRDAPRecord(ip, source string, data []byte) (RDAPRecord, error) {
|
||||
var payload struct {
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Country string `json:"country"`
|
||||
StartAddress string `json:"startAddress"`
|
||||
EndAddress string `json:"endAddress"`
|
||||
ParentHandle string `json:"parentHandle"`
|
||||
Port43 string `json:"port43"`
|
||||
Status []string `json:"status"`
|
||||
Events []struct {
|
||||
Action string `json:"eventAction"`
|
||||
Date time.Time `json:"eventDate"`
|
||||
} `json:"events"`
|
||||
Links []struct {
|
||||
Rel string `json:"rel"`
|
||||
Href string `json:"href"`
|
||||
Type string `json:"type"`
|
||||
} `json:"links"`
|
||||
Entities []struct {
|
||||
Handle string `json:"handle"`
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"entities"`
|
||||
CIDRs []struct {
|
||||
V4Prefix string `json:"v4prefix"`
|
||||
V6Prefix string `json:"v6prefix"`
|
||||
Length *int `json:"length"`
|
||||
} `json:"cidr0_cidrs"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return RDAPRecord{}, err
|
||||
}
|
||||
record := RDAPRecord{
|
||||
IP: ip, Handle: payload.Handle, Name: payload.Name, Type: payload.Type,
|
||||
Country: payload.Country, StartAddress: payload.StartAddress, EndAddress: payload.EndAddress,
|
||||
ParentHandle: payload.ParentHandle, Port43: payload.Port43, Status: payload.Status, Source: "rdap",
|
||||
}
|
||||
for _, event := range payload.Events {
|
||||
switch strings.ToLower(event.Action) {
|
||||
case "registration":
|
||||
if !event.Date.IsZero() && (record.RegistrationDate == nil || event.Date.Before(*record.RegistrationDate)) {
|
||||
value := event.Date
|
||||
record.RegistrationDate = &value
|
||||
}
|
||||
case "last changed", "last update of rdap database":
|
||||
if !event.Date.IsZero() && (record.LastChangedDate == nil || event.Date.After(*record.LastChangedDate)) {
|
||||
value := event.Date
|
||||
record.LastChangedDate = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, link := range payload.Links {
|
||||
if strings.EqualFold(link.Rel, "geofeed") || strings.Contains(strings.ToLower(link.Type), "geofeed") {
|
||||
if href := strings.TrimSpace(link.Href); href != "" {
|
||||
record.GeofeedURLs = append(record.GeofeedURLs, href)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, entity := range payload.Entities {
|
||||
record.Entities = append(record.Entities, RDAPEntity{Handle: entity.Handle, Roles: entity.Roles})
|
||||
}
|
||||
for _, cidr := range payload.CIDRs {
|
||||
prefix := cidr.V4Prefix
|
||||
if prefix == "" {
|
||||
prefix = cidr.V6Prefix
|
||||
}
|
||||
if cidr.Length == nil {
|
||||
continue
|
||||
}
|
||||
if normalized, ok := normalizeRDAPPrefix(ip, prefix, *cidr.Length); ok {
|
||||
record.Prefixes = append(record.Prefixes, normalized)
|
||||
}
|
||||
}
|
||||
record.Prefixes = uniqueSortedStrings(record.Prefixes)
|
||||
record.GeofeedURLs = uniqueSortedStrings(record.GeofeedURLs)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func normalizeRDAPPrefix(targetIP, prefix string, length int) (string, bool) {
|
||||
target := net.ParseIP(strings.TrimSpace(targetIP))
|
||||
parsed := net.ParseIP(strings.TrimSpace(prefix))
|
||||
if target == nil || parsed == nil || (target.To4() == nil) != (parsed.To4() == nil) {
|
||||
return "", false
|
||||
}
|
||||
bits := 128
|
||||
if parsed.To4() != nil {
|
||||
bits = 32
|
||||
}
|
||||
if length < 0 || length > bits {
|
||||
return "", false
|
||||
}
|
||||
_, network, err := net.ParseCIDR(fmt.Sprintf("%s/%d", parsed.String(), length))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !network.Contains(target) {
|
||||
return "", false
|
||||
}
|
||||
return network.String(), true
|
||||
}
|
||||
|
||||
func uniqueSortedStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for value := range seen {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueryRDAPParsesRegistrationAndGeofeed(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rdap+json")
|
||||
_, _ = w.Write([]byte(`{"handle":"NET-TEST","name":"TEST-NET","country":"US","startAddress":"192.0.2.0","endAddress":"192.0.2.255","cidr0_cidrs":[{"v4prefix":"192.0.2.0","length":24}],"events":[{"eventAction":"registration","eventDate":"2020-01-02T03:04:05Z"}],"links":[{"rel":"geofeed","href":"https://example.test/geofeed.csv"}],"entities":[{"handle":"EXAMPLE","roles":["registrant"]}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
record, err := QueryRDAP(context.Background(), "192.0.2.1", server.Client(), server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Handle != "NET-TEST" || record.RegistrationDate == nil || len(record.GeofeedURLs) != 1 || len(record.Entities) != 1 || len(record.Prefixes) != 1 || record.Prefixes[0] != "192.0.2.0/24" {
|
||||
t.Fatalf("unexpected record: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRDAPRejectsInvalidIP(t *testing.T) {
|
||||
if _, err := QueryRDAP(context.Background(), "invalid", nil, ""); err == nil {
|
||||
t.Fatal("expected invalid IP error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRDAPCanonicalizesAndFiltersPrefixes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := strings.TrimPrefix(r.URL.Path, "/"); got != "2001:db8::1" {
|
||||
t.Errorf("RDAP path IP = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"port43":"whois.ripe.net","cidr0_cidrs":[{"v6prefix":"2001:db8::99","length":32},{"v6prefix":"2001:db8::"},{"v6prefix":"2001:db9::","length":32},{"v6prefix":"not-an-ip","length":32},{"v6prefix":"2001:db8::","length":129},{"v4prefix":"192.0.2.0","length":24}],"events":[{"eventAction":"registration"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
record, err := QueryRDAP(nil, "2001:db8::1", server.Client(), server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(record.Prefixes) != 1 || record.Prefixes[0] != "2001:db8::/32" {
|
||||
t.Fatalf("unexpected filtered prefixes: %+v", record.Prefixes)
|
||||
}
|
||||
}
|
||||
@ -1,489 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RelationshipKind is the relationship of a neighbouring network to the
|
||||
// target ASN. RIPEstat does not always publish a direction, in which case a
|
||||
// neighbour is reported as peer with Inferred=true instead of pretending that
|
||||
// the direction is known.
|
||||
type RelationshipKind string
|
||||
|
||||
const (
|
||||
RelationshipUpstream RelationshipKind = "upstream"
|
||||
RelationshipPeer RelationshipKind = "peer"
|
||||
RelationshipIXP RelationshipKind = "ixp"
|
||||
)
|
||||
|
||||
// RelationshipStatus is deliberately small and stable so callers can render
|
||||
// a partial report without parsing provider-specific error strings.
|
||||
type RelationshipStatus string
|
||||
|
||||
const (
|
||||
RelationshipAvailable RelationshipStatus = "available"
|
||||
RelationshipPartial RelationshipStatus = "partial"
|
||||
RelationshipRateLimited RelationshipStatus = "rate_limited"
|
||||
RelationshipTimeout RelationshipStatus = "timeout"
|
||||
RelationshipMissingFields RelationshipStatus = "missing_fields"
|
||||
RelationshipError RelationshipStatus = "error"
|
||||
RelationshipUnsupported RelationshipStatus = "unsupported"
|
||||
)
|
||||
|
||||
var errRelationshipMissingFields = errors.New("relationship response missing required fields")
|
||||
|
||||
// ASNRelationship is a normalized relationship entry. ASN is empty for an
|
||||
// IXP that has no ASN in PeeringDB; IXPID and Name remain usable identifiers.
|
||||
type ASNRelationship struct {
|
||||
ASN string `json:"asn,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Kind RelationshipKind `json:"kind"`
|
||||
Source string `json:"source"`
|
||||
Status RelationshipStatus `json:"status"`
|
||||
Power float64 `json:"power,omitempty"`
|
||||
IXPID string `json:"ixp_id,omitempty"`
|
||||
Inferred bool `json:"inferred,omitempty"`
|
||||
RateLimited bool `json:"rate_limited,omitempty"`
|
||||
Timeout bool `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// RelationshipSourceStatus records the result of each upstream API. A
|
||||
// report may be partial when one API is unavailable and the other succeeds.
|
||||
type RelationshipSourceStatus struct {
|
||||
Source string `json:"source"`
|
||||
Status RelationshipStatus `json:"status"`
|
||||
RateLimited bool `json:"rate_limited"`
|
||||
Timeout bool `json:"timeout"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ASNRelationshipReport contains normalized upstream, peer and IXP entries.
|
||||
// SourceStatuses allows callers to distinguish a clean empty result from a
|
||||
// provider timeout or rate limit.
|
||||
type ASNRelationshipReport struct {
|
||||
TargetASN string `json:"target_asn"`
|
||||
Status RelationshipStatus `json:"status"`
|
||||
RateLimited bool `json:"rate_limited"`
|
||||
Timeout bool `json:"timeout"`
|
||||
Upstreams []ASNRelationship `json:"upstreams,omitempty"`
|
||||
Peers []ASNRelationship `json:"peers,omitempty"`
|
||||
IXPs []ASNRelationship `json:"ixps,omitempty"`
|
||||
SourceStatuses []RelationshipSourceStatus `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
// RelationshipConfig controls the public API endpoints. URLs are explicit
|
||||
// Go configuration so callers can use local fixtures without environment
|
||||
// variables. Empty URLs select the documented public endpoints.
|
||||
type RelationshipConfig struct {
|
||||
Client *http.Client
|
||||
RIPEstatURL string
|
||||
PeeringDBURL string
|
||||
Timeout time.Duration
|
||||
MaxResponseSize int64
|
||||
}
|
||||
|
||||
const (
|
||||
defaultRIPEstatURL = "https://stat.ripe.net/data/asn-neighbours/data.json"
|
||||
defaultPeeringDBURL = "https://www.peeringdb.com/api/netixlan"
|
||||
)
|
||||
|
||||
// QueryASNRelationships fetches relationship data from RIPEstat and IXP
|
||||
// membership data from PeeringDB. It returns a report even when one source
|
||||
// fails; only an invalid ASN or caller cancellation is returned as an error.
|
||||
func QueryASNRelationships(ctx context.Context, asn string, cfg RelationshipConfig) (*ASNRelationshipReport, error) {
|
||||
normalized, err := normalizeASN(asn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 8 * time.Second
|
||||
}
|
||||
if cfg.MaxResponseSize <= 0 {
|
||||
cfg.MaxResponseSize = 4 << 20
|
||||
}
|
||||
if cfg.Client == nil {
|
||||
cfg.Client = &http.Client{}
|
||||
}
|
||||
if strings.TrimSpace(cfg.RIPEstatURL) == "" {
|
||||
cfg.RIPEstatURL = defaultRIPEstatURL
|
||||
}
|
||||
if strings.TrimSpace(cfg.PeeringDBURL) == "" {
|
||||
cfg.PeeringDBURL = defaultPeeringDBURL
|
||||
}
|
||||
|
||||
report := &ASNRelationshipReport{TargetASN: normalized}
|
||||
ripeURL, err := addQuery(cfg.RIPEstatURL, "resource", normalized)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid relationship source configuration")
|
||||
}
|
||||
ripeRaw, ripeStatus, ripeErr := fetchRelationshipJSON(ctx, cfg, "ripestat", ripeURL)
|
||||
report.SourceStatuses = append(report.SourceStatuses, sourceStatus("ripestat", ripeStatus, ripeErr))
|
||||
if ripeErr == nil {
|
||||
entries, parseErr := parseRIPEstatRelationships(ripeRaw, normalized)
|
||||
if parseErr != nil {
|
||||
ripeStatus = statusForParseError(parseErr)
|
||||
report.SourceStatuses[len(report.SourceStatuses)-1] = sourceStatus("ripestat", ripeStatus, parseErr)
|
||||
} else {
|
||||
for _, entry := range entries {
|
||||
if entry.Kind == RelationshipUpstream {
|
||||
report.Upstreams = append(report.Upstreams, entry)
|
||||
} else {
|
||||
report.Peers = append(report.Peers, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peeringURL, err := addQuery(cfg.PeeringDBURL, "asn", normalized)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid relationship source configuration")
|
||||
}
|
||||
peeringRaw, peeringStatus, peeringErr := fetchRelationshipJSON(ctx, cfg, "peeringdb", peeringURL)
|
||||
report.SourceStatuses = append(report.SourceStatuses, sourceStatus("peeringdb", peeringStatus, peeringErr))
|
||||
if peeringErr == nil {
|
||||
entries, parseErr := parsePeeringDBIXPs(peeringRaw)
|
||||
if parseErr != nil {
|
||||
peeringStatus = statusForParseError(parseErr)
|
||||
report.SourceStatuses[len(report.SourceStatuses)-1] = sourceStatus("peeringdb", peeringStatus, parseErr)
|
||||
} else {
|
||||
report.IXPs = append(report.IXPs, entries...)
|
||||
}
|
||||
}
|
||||
|
||||
sortRelationships(report)
|
||||
report.Status = aggregateRelationshipStatus(report.SourceStatuses)
|
||||
for _, source := range report.SourceStatuses {
|
||||
report.RateLimited = report.RateLimited || source.RateLimited
|
||||
report.Timeout = report.Timeout || source.Timeout
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// QueryASNRelationshipReport is a descriptive alias for QueryASNRelationships.
|
||||
func QueryASNRelationshipReport(ctx context.Context, asn string, cfg RelationshipConfig) (*ASNRelationshipReport, error) {
|
||||
return QueryASNRelationships(ctx, asn, cfg)
|
||||
}
|
||||
|
||||
func normalizeASN(value string) (string, error) {
|
||||
value = strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(value)), "AS")
|
||||
if value == "" {
|
||||
return "", errors.New("ASN cannot be empty")
|
||||
}
|
||||
n, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil || n == 0 {
|
||||
return "", fmt.Errorf("invalid ASN %q", value)
|
||||
}
|
||||
return strconv.FormatUint(n, 10), nil
|
||||
}
|
||||
|
||||
func addQuery(rawURL, key, value string) (string, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := u.Query()
|
||||
query.Set(key, value)
|
||||
u.RawQuery = query.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func fetchRelationshipJSON(parent context.Context, cfg RelationshipConfig, source, requestURL string) ([]byte, RelationshipStatus, error) {
|
||||
ctx, cancel := context.WithTimeout(parent, cfg.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, RelationshipError, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "oneclickvirt-backtrace-relationships/1")
|
||||
resp, err := cfg.Client.Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || (netErrTimeout(err)) {
|
||||
return nil, RelationshipTimeout, err
|
||||
}
|
||||
return nil, RelationshipError, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, RelationshipRateLimited, fmt.Errorf("%s HTTP %d", source, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
|
||||
return nil, RelationshipTimeout, fmt.Errorf("%s HTTP %d", source, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, RelationshipError, fmt.Errorf("%s HTTP %d", source, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, cfg.MaxResponseSize+1))
|
||||
if err != nil {
|
||||
return nil, RelationshipError, err
|
||||
}
|
||||
if int64(len(body)) > cfg.MaxResponseSize {
|
||||
return nil, RelationshipError, fmt.Errorf("%s response exceeds %d bytes", source, cfg.MaxResponseSize)
|
||||
}
|
||||
return body, RelationshipAvailable, nil
|
||||
}
|
||||
|
||||
func netErrTimeout(err error) bool {
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && netErr.Timeout()
|
||||
}
|
||||
|
||||
func sourceStatus(source string, status RelationshipStatus, err error) RelationshipSourceStatus {
|
||||
item := RelationshipSourceStatus{
|
||||
Source: source, Status: status,
|
||||
RateLimited: status == RelationshipRateLimited,
|
||||
Timeout: status == RelationshipTimeout,
|
||||
}
|
||||
if err != nil {
|
||||
switch status {
|
||||
case RelationshipRateLimited:
|
||||
item.Error = "rate_limited"
|
||||
case RelationshipTimeout:
|
||||
item.Error = "timeout"
|
||||
case RelationshipMissingFields:
|
||||
item.Error = "missing_fields"
|
||||
default:
|
||||
item.Error = "request_failed"
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func statusForParseError(err error) RelationshipStatus {
|
||||
if errors.Is(err, errRelationshipMissingFields) {
|
||||
return RelationshipMissingFields
|
||||
}
|
||||
return RelationshipError
|
||||
}
|
||||
|
||||
func aggregateRelationshipStatus(sources []RelationshipSourceStatus) RelationshipStatus {
|
||||
available := 0
|
||||
for _, source := range sources {
|
||||
if source.Status == RelationshipAvailable {
|
||||
available++
|
||||
}
|
||||
}
|
||||
if available == len(sources) {
|
||||
return RelationshipAvailable
|
||||
}
|
||||
if available > 0 {
|
||||
return RelationshipPartial
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.Status == RelationshipRateLimited {
|
||||
return RelationshipRateLimited
|
||||
}
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.Status == RelationshipTimeout {
|
||||
return RelationshipTimeout
|
||||
}
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.Status == RelationshipMissingFields {
|
||||
return RelationshipMissingFields
|
||||
}
|
||||
}
|
||||
return RelationshipError
|
||||
}
|
||||
|
||||
type ripeNeighbourResponse struct {
|
||||
Data *struct {
|
||||
Neighbours json.RawMessage `json:"neighbours"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ripeNeighbour struct {
|
||||
ASN json.RawMessage `json:"asn"`
|
||||
Name string `json:"name"`
|
||||
Power float64 `json:"power"`
|
||||
Type string `json:"type"`
|
||||
Relationship string `json:"relationship"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
func parseRIPEstatRelationships(payload []byte, targetASN string) ([]ASNRelationship, error) {
|
||||
var response ripeNeighbourResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Data == nil || len(response.Data.Neighbours) == 0 || string(response.Data.Neighbours) == "null" {
|
||||
return nil, errRelationshipMissingFields
|
||||
}
|
||||
var neighbours []ripeNeighbour
|
||||
if err := json.Unmarshal(response.Data.Neighbours, &neighbours); err != nil {
|
||||
return nil, errRelationshipMissingFields
|
||||
}
|
||||
var result []ASNRelationship
|
||||
for _, neighbour := range neighbours {
|
||||
asn, err := parseJSONASN(neighbour.ASN)
|
||||
if err != nil || asn == targetASN {
|
||||
continue
|
||||
}
|
||||
kind, inferred := relationshipKind(neighbour.Relationship, neighbour.Type, neighbour.Direction)
|
||||
result = append(result, ASNRelationship{
|
||||
ASN: asn, Name: strings.TrimSpace(neighbour.Name), Kind: kind,
|
||||
Source: "ripestat", Status: RelationshipAvailable,
|
||||
Power: neighbour.Power, Inferred: inferred,
|
||||
})
|
||||
}
|
||||
return dedupeRelationships(result), nil
|
||||
}
|
||||
|
||||
func relationshipKind(values ...string) (RelationshipKind, bool) {
|
||||
for _, value := range values {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "upstream", "provider", "transit":
|
||||
return RelationshipUpstream, false
|
||||
case "peer", "peering", "customer", "downstream":
|
||||
return RelationshipPeer, false
|
||||
case "ixp", "internet exchange", "exchange":
|
||||
return RelationshipIXP, false
|
||||
}
|
||||
}
|
||||
return RelationshipPeer, true
|
||||
}
|
||||
|
||||
func parseJSONASN(raw json.RawMessage) (string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return "", errors.New("missing ASN")
|
||||
}
|
||||
var value string
|
||||
if raw[0] == '"' {
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
var number json.Number
|
||||
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&number); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value = number.String()
|
||||
}
|
||||
return normalizeASN(value)
|
||||
}
|
||||
|
||||
func dedupeRelationships(values []ASNRelationship) []ASNRelationship {
|
||||
seen := make(map[string]ASNRelationship, len(values))
|
||||
for _, value := range values {
|
||||
key := string(value.Kind) + ":" + value.ASN + ":" + value.IXPID
|
||||
if previous, ok := seen[key]; !ok || previous.Name == "" && value.Name != "" {
|
||||
seen[key] = value
|
||||
}
|
||||
}
|
||||
result := make([]ASNRelationship, 0, len(seen))
|
||||
for _, value := range seen {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Kind != result[j].Kind {
|
||||
return result[i].Kind < result[j].Kind
|
||||
}
|
||||
if result[i].ASN != result[j].ASN {
|
||||
return result[i].ASN < result[j].ASN
|
||||
}
|
||||
return result[i].IXPID < result[j].IXPID
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
type peeringDBResponse struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func parsePeeringDBIXPs(payload []byte) ([]ASNRelationship, error) {
|
||||
var response peeringDBResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(response.Data) == 0 || string(response.Data) == "null" {
|
||||
return nil, errRelationshipMissingFields
|
||||
}
|
||||
var records []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(response.Data, &records); err != nil {
|
||||
return nil, errRelationshipMissingFields
|
||||
}
|
||||
result := make([]ASNRelationship, 0, len(records))
|
||||
for _, record := range records {
|
||||
id := firstString(record, "ix_id", "ixlan_id", "id")
|
||||
name := firstString(record, "name", "ix_name")
|
||||
if nested, ok := record["ixlan"]; ok {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal(nested, &object) == nil {
|
||||
if id == "" {
|
||||
id = firstString(object, "ix_id", "id")
|
||||
}
|
||||
if name == "" {
|
||||
name = firstString(object, "name", "name_long")
|
||||
}
|
||||
}
|
||||
}
|
||||
if nested, ok := record["ix"]; ok {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal(nested, &object) == nil {
|
||||
if id == "" {
|
||||
id = firstString(object, "id", "ix_id")
|
||||
}
|
||||
if name == "" {
|
||||
name = firstString(object, "name", "name_long")
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" && name == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, ASNRelationship{
|
||||
Name: name, Kind: RelationshipIXP, Source: "peeringdb",
|
||||
Status: RelationshipAvailable, IXPID: id,
|
||||
})
|
||||
}
|
||||
return dedupeRelationships(result), nil
|
||||
}
|
||||
|
||||
func firstString(values map[string]json.RawMessage, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
if number, err := parseJSONASN(raw); err == nil {
|
||||
return number
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortRelationships(report *ASNRelationshipReport) {
|
||||
report.Upstreams = dedupeRelationships(report.Upstreams)
|
||||
report.Peers = dedupeRelationships(report.Peers)
|
||||
report.IXPs = dedupeRelationships(report.IXPs)
|
||||
sort.Slice(report.SourceStatuses, func(i, j int) bool {
|
||||
return report.SourceStatuses[i].Source < report.SourceStatuses[j].Source
|
||||
})
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestQueryASNRelationshipsFixture(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/ripe":
|
||||
if got := r.URL.Query().Get("resource"); got != "64500" {
|
||||
t.Errorf("resource query = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"neighbours":[{"asn":64501,"name":"Transit Example","relationship":"upstream","power":12.5},{"asn":"64502","name":"Peer Example","type":"peer","power":3.25},{"asn":64500,"name":"target"}]}}`))
|
||||
case "/peering":
|
||||
if got := r.URL.Query().Get("asn"); got != "64500" {
|
||||
t.Errorf("asn query = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":101,"ix_id":7,"name":"Example IX"},{"ixlan":{"id":202,"name":"Nested IX"}}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
report, err := QueryASNRelationships(context.Background(), "AS64500", RelationshipConfig{
|
||||
Client: server.Client(),
|
||||
RIPEstatURL: server.URL + "/ripe",
|
||||
PeeringDBURL: server.URL + "/peering",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.TargetASN != "64500" || report.Status != RelationshipAvailable {
|
||||
t.Fatalf("unexpected report status: %+v", report)
|
||||
}
|
||||
if len(report.Upstreams) != 1 || report.Upstreams[0].ASN != "64501" || report.Upstreams[0].Source != "ripestat" {
|
||||
t.Fatalf("unexpected upstreams: %+v", report.Upstreams)
|
||||
}
|
||||
if len(report.Peers) != 1 || report.Peers[0].ASN != "64502" {
|
||||
t.Fatalf("unexpected peers: %+v", report.Peers)
|
||||
}
|
||||
if len(report.IXPs) != 2 || report.IXPs[0].Kind != RelationshipIXP {
|
||||
t.Fatalf("unexpected IXPs: %+v", report.IXPs)
|
||||
}
|
||||
for _, source := range report.SourceStatuses {
|
||||
if source.Status != RelationshipAvailable || source.RateLimited || source.Timeout {
|
||||
t.Fatalf("unexpected source status: %+v", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryASNRelationshipsRateLimitAndTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/limited":
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
case "/slow":
|
||||
<-r.Context().Done()
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
limited, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{
|
||||
Client: server.Client(),
|
||||
RIPEstatURL: server.URL + "/limited",
|
||||
PeeringDBURL: server.URL + "/peering",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if limited.Status != RelationshipPartial || !limited.RateLimited {
|
||||
t.Fatalf("expected partial rate-limited report: %+v", limited)
|
||||
}
|
||||
if status := sourceStatusByName(limited, "ripestat"); status.Status != RelationshipRateLimited || !status.RateLimited || status.Timeout {
|
||||
t.Fatalf("unexpected rate-limit source: %+v", status)
|
||||
}
|
||||
|
||||
timed, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{
|
||||
Client: server.Client(),
|
||||
RIPEstatURL: server.URL + "/slow",
|
||||
PeeringDBURL: server.URL + "/slow",
|
||||
Timeout: 20 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if timed.Status != RelationshipTimeout || !timed.Timeout {
|
||||
t.Fatalf("expected timeout report: %+v", timed)
|
||||
}
|
||||
for _, source := range timed.SourceStatuses {
|
||||
if source.Status != RelationshipTimeout || !source.Timeout || source.RateLimited {
|
||||
t.Fatalf("unexpected timeout source: %+v", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryASNRelationshipsFieldDrift(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/ripe" {
|
||||
_, _ = w.Write([]byte(`{"data":{"neighbors":[]}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
report, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{
|
||||
Client: server.Client(),
|
||||
RIPEstatURL: server.URL + "/ripe",
|
||||
PeeringDBURL: server.URL + "/peering",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != RelationshipMissingFields {
|
||||
t.Fatalf("expected missing_fields report: %+v", report)
|
||||
}
|
||||
for _, source := range report.SourceStatuses {
|
||||
if source.Status != RelationshipMissingFields {
|
||||
t.Fatalf("expected missing_fields source, got %+v", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryASNRelationshipsContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
started := time.Now()
|
||||
_, err := QueryASNRelationships(ctx, "64500", RelationshipConfig{
|
||||
Client: server.Client(),
|
||||
RIPEstatURL: server.URL,
|
||||
PeeringDBURL: server.URL,
|
||||
Timeout: time.Second,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context cancellation, got %v", err)
|
||||
}
|
||||
if time.Since(started) > 200*time.Millisecond {
|
||||
t.Fatalf("cancellation was not immediate: %v", time.Since(started))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeASN(t *testing.T) {
|
||||
for _, input := range []string{"AS64500", " as64500 ", "64500"} {
|
||||
if got, err := normalizeASN(input); err != nil || got != "64500" {
|
||||
t.Fatalf("normalizeASN(%q) = %q, %v", input, got, err)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"", "AS0", "AS4294967296", "AS-not-an-asn"} {
|
||||
if _, err := normalizeASN(input); err == nil {
|
||||
t.Fatalf("normalizeASN(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sourceStatusByName(report *ASNRelationshipReport, name string) RelationshipSourceStatus {
|
||||
for _, source := range report.SourceStatuses {
|
||||
if strings.EqualFold(source.Source, name) {
|
||||
return source
|
||||
}
|
||||
}
|
||||
return RelationshipSourceStatus{Source: name, Status: RelationshipError}
|
||||
}
|
||||
@ -1,630 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReportStatus is the provider-neutral status used by the unified IP/BGP
|
||||
// report. A report deliberately keeps source-level failures instead of
|
||||
// turning them into an empty successful result.
|
||||
type ReportStatus string
|
||||
|
||||
const (
|
||||
ReportAvailable ReportStatus = "available"
|
||||
ReportPartial ReportStatus = "partial"
|
||||
ReportRateLimited ReportStatus = "rate_limited"
|
||||
ReportTimeout ReportStatus = "timeout"
|
||||
ReportMissingFields ReportStatus = "missing_fields"
|
||||
ReportError ReportStatus = "error"
|
||||
ReportUnsupported ReportStatus = "unsupported"
|
||||
)
|
||||
|
||||
// IPBGPSourceStatus records the outcome of one logical source in a unified
|
||||
// report. Error is intentionally a short diagnostic, never a raw response.
|
||||
type IPBGPSourceStatus struct {
|
||||
Source string `json:"source"`
|
||||
Status ReportStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RIRInfo describes the registry inference and its evidence source. The
|
||||
// inference is conservative: country and address ownership are not used as a
|
||||
// substitute for an explicit RDAP/WHOIS registry signal.
|
||||
type RIRInfo struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Status ReportStatus `json:"status"`
|
||||
}
|
||||
|
||||
// GeofeedResult contains the bounded fetch result. URL remains internal so
|
||||
// reports expose the probe outcome without disclosing the service location.
|
||||
type GeofeedResult struct {
|
||||
URL string `json:"-"`
|
||||
Status ReportStatus `json:"status"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
Bytes int64 `json:"bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WHOISRecord is the small, structured subset used when RDAP is unavailable
|
||||
// or missing required fields. Raw port-43 text is never returned.
|
||||
type WHOISRecord struct {
|
||||
Server string `json:"-"`
|
||||
Status ReportStatus `json:"status"`
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
RegistrationDate *time.Time `json:"registration_date,omitempty"`
|
||||
GeofeedURLs []string `json:"-"`
|
||||
RIR RIRInfo `json:"rir"`
|
||||
}
|
||||
|
||||
// IPBGPReport combines RDAP, registry metadata, optional geofeed fetches and
|
||||
// ASN relationship data. Relationships are requested only when ASN or an
|
||||
// injected ASN resolver supplies a target ASN.
|
||||
type IPBGPReport struct {
|
||||
IP string `json:"ip"`
|
||||
ASN string `json:"asn,omitempty"`
|
||||
Status ReportStatus `json:"status"`
|
||||
RDAP *RDAPRecord `json:"rdap,omitempty"`
|
||||
WHOIS *WHOISRecord `json:"whois,omitempty"`
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
PrefixSource string `json:"prefix_source,omitempty"`
|
||||
RegistrationDate *time.Time `json:"registration_date,omitempty"`
|
||||
RIR RIRInfo `json:"rir"`
|
||||
Geofeeds []GeofeedResult `json:"geofeeds,omitempty"`
|
||||
Relationships *ASNRelationshipReport `json:"relationships,omitempty"`
|
||||
Sources []IPBGPSourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// ASNResolver allows a caller to supply an existing, structured IP-to-ASN
|
||||
// result without making this package invent a provider or read environment
|
||||
// variables.
|
||||
type ASNResolver func(context.Context, string) (string, error)
|
||||
|
||||
// WHOISDialContext is injectable so port-43 fallback can be tested entirely
|
||||
// with a local TCP fixture.
|
||||
type WHOISDialContext func(context.Context, string, string) (net.Conn, error)
|
||||
|
||||
// IPBGPReportConfig keeps every network dependency explicit and injectable.
|
||||
// WHOIS fallback is opt-in; when enabled it always has its own finite timeout.
|
||||
type IPBGPReportConfig struct {
|
||||
Timeout time.Duration
|
||||
|
||||
RDAPClient *http.Client
|
||||
RDAPBaseURL string
|
||||
|
||||
ASN string
|
||||
ResolveASN ASNResolver
|
||||
Relationships RelationshipConfig
|
||||
|
||||
FetchGeofeed bool
|
||||
GeofeedClient *http.Client
|
||||
GeofeedTimeout time.Duration
|
||||
MaxGeofeedBytes int64
|
||||
|
||||
EnableWHOISFallback bool
|
||||
WHOISServer string
|
||||
WHOISTimeout time.Duration
|
||||
MaxWHOISBytes int64
|
||||
WHOISDialContext WHOISDialContext
|
||||
|
||||
WHOISBootstrapClient *http.Client
|
||||
WHOISBootstrapURL string
|
||||
MaxWHOISBootstrapBytes int64
|
||||
}
|
||||
|
||||
const (
|
||||
defaultIPBGPReportTimeout = 15 * time.Second
|
||||
defaultGeofeedTimeout = 3 * time.Second
|
||||
defaultWHOISTimeout = 3 * time.Second
|
||||
defaultGeofeedBytes = 1 << 20
|
||||
defaultWHOISBytes = 1 << 20
|
||||
)
|
||||
|
||||
// QueryIPBGPReport executes the structured IP/BGP collection with a bounded
|
||||
// context. Provider failures produce a partial report; invalid input and
|
||||
// caller cancellation are returned as errors for compatibility with the
|
||||
// lower-level APIs.
|
||||
func QueryIPBGPReport(parent context.Context, ip string, cfg IPBGPReportConfig) (*IPBGPReport, error) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||
if parsed == nil {
|
||||
return nil, ErrInvalidIPAddress
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = defaultIPBGPReportTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, cfg.Timeout)
|
||||
defer cancel()
|
||||
|
||||
report := &IPBGPReport{IP: parsed.String(), Status: ReportError}
|
||||
|
||||
rdap, rdapErr := QueryRDAP(ctx, report.IP, cfg.RDAPClient, cfg.RDAPBaseURL)
|
||||
rdapStatus := reportStatusForError(rdapErr)
|
||||
if rdapErr == nil {
|
||||
report.RDAP = &rdap
|
||||
report.Prefixes = uniqueSortedStrings(rdap.Prefixes)
|
||||
if len(report.Prefixes) > 0 {
|
||||
report.PrefixSource = "rdap"
|
||||
}
|
||||
report.RegistrationDate = rdap.RegistrationDate
|
||||
rdapStatus = ReportAvailable
|
||||
if len(report.Prefixes) == 0 || report.RegistrationDate == nil {
|
||||
rdapStatus = ReportMissingFields
|
||||
}
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "rdap", Status: rdapStatus})
|
||||
} else {
|
||||
report.Sources = append(report.Sources, sourceStatusForError("rdap", rdapStatus, rdapErr))
|
||||
}
|
||||
|
||||
report.RIR = inferRIR(rdap, rdapErr == nil)
|
||||
if report.RIR.Status == "" {
|
||||
report.RIR.Status = ReportMissingFields
|
||||
}
|
||||
|
||||
if cfg.EnableWHOISFallback && (rdapErr != nil || len(report.Prefixes) == 0 || report.RegistrationDate == nil || report.RIR.Status != ReportAvailable) {
|
||||
server := strings.TrimSpace(cfg.WHOISServer)
|
||||
if server == "" {
|
||||
server = strings.TrimSpace(rdap.Port43)
|
||||
}
|
||||
if server == "" {
|
||||
resolved, err := resolveWHOISServer(ctx, report.IP, cfg)
|
||||
if err != nil {
|
||||
report.Sources = append(report.Sources, sourceStatusForError("whois_bootstrap", reportStatusForError(err), err))
|
||||
} else {
|
||||
server = resolved
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "whois_bootstrap", Status: ReportAvailable})
|
||||
}
|
||||
}
|
||||
if server == "" {
|
||||
status := IPBGPSourceStatus{Source: "whois", Status: ReportUnsupported, Error: "no port-43 server resolved"}
|
||||
report.Sources = append(report.Sources, status)
|
||||
} else {
|
||||
whois, err := queryWHOIS(ctx, report.IP, server, cfg)
|
||||
if err != nil {
|
||||
report.Sources = append(report.Sources, sourceStatusForError("whois", reportStatusForError(err), err))
|
||||
} else {
|
||||
report.WHOIS = &whois
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "whois", Status: whois.Status})
|
||||
if len(report.Prefixes) == 0 && len(whois.Prefixes) > 0 {
|
||||
report.Prefixes = append([]string(nil), whois.Prefixes...)
|
||||
report.PrefixSource = "whois"
|
||||
}
|
||||
if report.RegistrationDate == nil {
|
||||
report.RegistrationDate = whois.RegistrationDate
|
||||
}
|
||||
if report.RIR.Status != ReportAvailable && whois.RIR.Status == ReportAvailable {
|
||||
report.RIR = whois.RIR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "rir", Status: report.RIR.Status})
|
||||
|
||||
geofeedURLs := append([]string(nil), rdap.GeofeedURLs...)
|
||||
if report.WHOIS != nil {
|
||||
geofeedURLs = append(geofeedURLs, report.WHOIS.GeofeedURLs...)
|
||||
}
|
||||
geofeedURLs = uniqueSortedStrings(geofeedURLs)
|
||||
if report.RDAP != nil {
|
||||
report.RDAP.GeofeedURLs = sanitizeReportURLs(report.RDAP.GeofeedURLs)
|
||||
}
|
||||
if report.WHOIS != nil {
|
||||
report.WHOIS.GeofeedURLs = sanitizeReportURLs(report.WHOIS.GeofeedURLs)
|
||||
}
|
||||
for _, geofeedURL := range geofeedURLs {
|
||||
if !cfg.FetchGeofeed {
|
||||
report.Geofeeds = append(report.Geofeeds, GeofeedResult{URL: geofeedURL, Status: ReportUnsupported, Error: "fetch disabled"})
|
||||
continue
|
||||
}
|
||||
result := fetchGeofeed(ctx, geofeedURL, cfg)
|
||||
report.Geofeeds = append(report.Geofeeds, result)
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "geofeed", Status: result.Status, Error: result.Error})
|
||||
}
|
||||
|
||||
asn := strings.TrimSpace(cfg.ASN)
|
||||
if asn == "" && cfg.ResolveASN != nil {
|
||||
resolved, err := cfg.ResolveASN(ctx, report.IP)
|
||||
if err != nil {
|
||||
report.Sources = append(report.Sources, sourceStatusForError("asn_resolver", reportStatusForError(err), err))
|
||||
} else {
|
||||
asn = strings.TrimSpace(resolved)
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_resolver", Status: ReportAvailable})
|
||||
}
|
||||
}
|
||||
if asn != "" {
|
||||
normalizedASN, err := normalizeASN(asn)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.ASN = normalizedASN
|
||||
relationships, err := QueryASNRelationships(ctx, normalizedASN, cfg.Relationships)
|
||||
if relationships != nil {
|
||||
report.Relationships = relationships
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return report, err
|
||||
}
|
||||
report.Sources = append(report.Sources, sourceStatusForError("asn_relationships", reportStatusForError(err), err))
|
||||
} else {
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_relationships", Status: reportStatusFromRelationship(relationships.Status)})
|
||||
}
|
||||
} else {
|
||||
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_relationships", Status: ReportUnsupported, Error: "ASN not supplied"})
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Prefixes = uniqueSortedStrings(report.Prefixes)
|
||||
sort.SliceStable(report.Sources, func(i, j int) bool { return report.Sources[i].Source < report.Sources[j].Source })
|
||||
report.Status = aggregateReportStatus(report.Sources)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// QueryIPBGP is a short alias for callers that prefer the domain name over the
|
||||
// implementation-oriented Report suffix.
|
||||
func QueryIPBGP(ctx context.Context, ip string, cfg IPBGPReportConfig) (*IPBGPReport, error) {
|
||||
return QueryIPBGPReport(ctx, ip, cfg)
|
||||
}
|
||||
|
||||
func inferRIR(record RDAPRecord, available bool) RIRInfo {
|
||||
if !available {
|
||||
return RIRInfo{Status: ReportMissingFields}
|
||||
}
|
||||
text := strings.Join([]string{record.Source, record.Port43, record.Handle, record.ParentHandle}, " ")
|
||||
if name := rirNameFromText(text); name != "" {
|
||||
return RIRInfo{Name: name, Source: "rdap", Status: ReportAvailable}
|
||||
}
|
||||
return RIRInfo{Source: "rdap", Status: ReportMissingFields}
|
||||
}
|
||||
|
||||
func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) GeofeedResult {
|
||||
result := GeofeedResult{URL: sanitizeReportURL(rawURL)}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
result.Status = ReportUnsupported
|
||||
result.Error = "unsupported geofeed URL"
|
||||
return result
|
||||
}
|
||||
timeout := cfg.GeofeedTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultGeofeedTimeout
|
||||
}
|
||||
maxBytes := cfg.MaxGeofeedBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultGeofeedBytes
|
||||
}
|
||||
client := cfg.GeofeedClient
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
result.Status = ReportError
|
||||
result.Error = stableReportError(result.Status, err)
|
||||
return result
|
||||
}
|
||||
req.Header.Set("Accept", "text/csv, text/plain, */*")
|
||||
req.Header.Set("User-Agent", "oneclickvirt-backtrace-geofeed/1")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
result.Status = reportStatusForError(err)
|
||||
result.Error = stableReportError(result.Status, err)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result.HTTPStatus = resp.StatusCode
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
result.Status = ReportRateLimited
|
||||
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
|
||||
result.Status = ReportTimeout
|
||||
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
result.Status = ReportError
|
||||
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
result.Status = reportStatusForError(err)
|
||||
result.Error = stableReportError(result.Status, err)
|
||||
return result
|
||||
}
|
||||
if int64(len(body)) > maxBytes {
|
||||
result.Status = ReportError
|
||||
result.Error = fmt.Sprintf("response exceeds %d bytes", maxBytes)
|
||||
return result
|
||||
}
|
||||
result.Status = ReportAvailable
|
||||
result.Bytes = int64(len(body))
|
||||
return result
|
||||
}
|
||||
|
||||
func queryWHOIS(parent context.Context, ip, server string, cfg IPBGPReportConfig) (WHOISRecord, error) {
|
||||
address, err := normalizeWHOISAddress(server)
|
||||
if err != nil {
|
||||
return WHOISRecord{}, err
|
||||
}
|
||||
timeout := cfg.WHOISTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultWHOISTimeout
|
||||
}
|
||||
maxBytes := cfg.MaxWHOISBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultWHOISBytes
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
dial := cfg.WHOISDialContext
|
||||
if dial == nil {
|
||||
dialer := &net.Dialer{}
|
||||
dial = dialer.DialContext
|
||||
}
|
||||
conn, err := dial(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return WHOISRecord{}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
if _, err := fmt.Fprintf(conn, "%s\r\n", ip); err != nil {
|
||||
return WHOISRecord{}, err
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(conn, maxBytes+1))
|
||||
if err != nil {
|
||||
return WHOISRecord{}, err
|
||||
}
|
||||
if int64(len(body)) > maxBytes {
|
||||
return WHOISRecord{}, fmt.Errorf("whois response exceeds %d bytes", maxBytes)
|
||||
}
|
||||
result, err := parseWHOISRecord(address, body)
|
||||
if err != nil {
|
||||
return WHOISRecord{}, err
|
||||
}
|
||||
if len(result.Prefixes) == 0 && result.RegistrationDate == nil && result.RIR.Status != ReportAvailable && len(result.GeofeedURLs) == 0 {
|
||||
result.Status = ReportMissingFields
|
||||
} else {
|
||||
result.Status = ReportAvailable
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeWHOISAddress(server string) (string, error) {
|
||||
server = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(server, "whois://"), "tcp://"))
|
||||
if server == "" || strings.ContainsAny(server, "/ \t\r\n") {
|
||||
return "", errors.New("invalid WHOIS server")
|
||||
}
|
||||
if host, port, err := net.SplitHostPort(server); err == nil {
|
||||
if host == "" || port == "" {
|
||||
return "", errors.New("invalid WHOIS server")
|
||||
}
|
||||
return net.JoinHostPort(host, port), nil
|
||||
}
|
||||
if strings.Contains(server, ":") && net.ParseIP(server) == nil {
|
||||
return "", errors.New("invalid WHOIS server")
|
||||
}
|
||||
return net.JoinHostPort(server, "43"), nil
|
||||
}
|
||||
|
||||
func parseWHOISRecord(server string, body []byte) (WHOISRecord, error) {
|
||||
result := WHOISRecord{Server: server, RIR: RIRInfo{Status: ReportMissingFields}}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
||||
scanner.Buffer(make([]byte, 1024), 1<<20)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "cidr", "route", "route6", "inet6route", "prefix":
|
||||
for _, item := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\t' }) {
|
||||
if normalized, ok := normalizeWHOISPrefix(item); ok {
|
||||
result.Prefixes = append(result.Prefixes, normalized)
|
||||
}
|
||||
}
|
||||
case "registration date", "regdate", "created", "created date", "creation date":
|
||||
if parsed, ok := parseWHOISDate(value); ok {
|
||||
result.RegistrationDate = &parsed
|
||||
}
|
||||
case "geofeed", "geofeed url", "geofeed-url":
|
||||
if parsed, err := url.Parse(value); err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https") {
|
||||
result.GeofeedURLs = append(result.GeofeedURLs, parsed.String())
|
||||
}
|
||||
case "rir", "registry", "source", "whois server":
|
||||
if name := rirNameFromText(value); name != "" {
|
||||
result.RIR = RIRInfo{Name: name, Source: "whois", Status: ReportAvailable}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return WHOISRecord{}, err
|
||||
}
|
||||
result.Prefixes = uniqueSortedStrings(result.Prefixes)
|
||||
result.GeofeedURLs = uniqueSortedStrings(result.GeofeedURLs)
|
||||
if result.RIR.Status != ReportAvailable {
|
||||
if name := rirNameFromText(server); name != "" {
|
||||
result.RIR = RIRInfo{Name: name, Source: "whois", Status: ReportAvailable}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeWHOISPrefix(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
_, network, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return network.String(), true
|
||||
}
|
||||
|
||||
func parseWHOISDate(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
"02-Jan-2006",
|
||||
"20060102",
|
||||
} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func rirNameFromText(value string) string {
|
||||
tokens := strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
|
||||
})
|
||||
registries := map[string]string{
|
||||
"arin": "ARIN",
|
||||
"ripe": "RIPE NCC",
|
||||
"apnic": "APNIC",
|
||||
"lacnic": "LACNIC",
|
||||
"afrinic": "AFRINIC",
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if name := registries[token]; name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sourceStatusForError(source string, status ReportStatus, err error) IPBGPSourceStatus {
|
||||
item := IPBGPSourceStatus{Source: source, Status: status}
|
||||
if err != nil {
|
||||
item.Error = stableReportError(status, err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func stableReportError(status ReportStatus, err error) string {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "canceled"
|
||||
}
|
||||
switch status {
|
||||
case ReportRateLimited:
|
||||
return "rate_limited"
|
||||
case ReportTimeout:
|
||||
return "timeout"
|
||||
case ReportUnsupported:
|
||||
return "unsupported"
|
||||
case ReportMissingFields:
|
||||
return "missing_fields"
|
||||
default:
|
||||
return "request_failed"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeReportURL(raw string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.ForceQuery = false
|
||||
parsed.Fragment = ""
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func sanitizeReportURLs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if sanitized := sanitizeReportURL(value); sanitized != "" {
|
||||
result = append(result, sanitized)
|
||||
}
|
||||
}
|
||||
return uniqueSortedStrings(result)
|
||||
}
|
||||
|
||||
func reportStatusForError(err error) ReportStatus {
|
||||
if err == nil {
|
||||
return ReportAvailable
|
||||
}
|
||||
if errors.Is(err, ErrRDAPRateLimited) || errors.Is(err, ErrOriginASNRateLimited) {
|
||||
return ReportRateLimited
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || netErrTimeout(err) {
|
||||
return ReportTimeout
|
||||
}
|
||||
return ReportError
|
||||
}
|
||||
|
||||
func reportStatusFromRelationship(status RelationshipStatus) ReportStatus {
|
||||
switch status {
|
||||
case RelationshipAvailable:
|
||||
return ReportAvailable
|
||||
case RelationshipPartial:
|
||||
return ReportPartial
|
||||
case RelationshipRateLimited:
|
||||
return ReportRateLimited
|
||||
case RelationshipTimeout:
|
||||
return ReportTimeout
|
||||
case RelationshipMissingFields:
|
||||
return ReportMissingFields
|
||||
case RelationshipUnsupported:
|
||||
return ReportUnsupported
|
||||
default:
|
||||
return ReportError
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateReportStatus(sources []IPBGPSourceStatus) ReportStatus {
|
||||
if len(sources) == 0 {
|
||||
return ReportUnsupported
|
||||
}
|
||||
hasAvailable := false
|
||||
var first ReportStatus
|
||||
for _, source := range sources {
|
||||
if source.Status == ReportAvailable {
|
||||
hasAvailable = true
|
||||
continue
|
||||
}
|
||||
if first == "" {
|
||||
first = source.Status
|
||||
}
|
||||
}
|
||||
if first == "" {
|
||||
return ReportAvailable
|
||||
}
|
||||
if hasAvailable {
|
||||
return ReportPartial
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.Status != first {
|
||||
return ReportPartial
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
@ -1,252 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestQueryIPBGPReportOfflineFixtures(t *testing.T) {
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/rdap/"):
|
||||
if got := strings.TrimPrefix(r.URL.Path, "/rdap/"); got != "192.0.2.1" {
|
||||
t.Errorf("RDAP IP = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/rdap+json")
|
||||
_, _ = fmt.Fprintf(w, `{"handle":"NET-ARIN-TEST","port43":"whois.arin.net","cidr0_cidrs":[{"v4prefix":"192.0.2.42","length":24}],"events":[{"eventAction":"registration","eventDate":"2020-01-02T03:04:05Z"}],"links":[{"rel":"geofeed","href":%q}]}`, server.URL+"/geofeed.csv")
|
||||
case r.URL.Path == "/geofeed.csv":
|
||||
_, _ = io.WriteString(w, "192.0.2.0/24,US,US-CA,Example City,\n")
|
||||
case r.URL.Path == "/ripe":
|
||||
if got := r.URL.Query().Get("resource"); got != "64500" {
|
||||
t.Errorf("resource query = %q", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"data":{"neighbours":[{"asn":64501,"name":"Transit","relationship":"upstream"},{"asn":64502,"name":"Peer","relationship":"peer"}]}}`)
|
||||
case r.URL.Path == "/peering":
|
||||
_, _ = io.WriteString(w, `{"data":[{"ix_id":7,"name":"Example IX"}]}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
report, err := QueryIPBGPReport(context.Background(), "192.0.2.1", IPBGPReportConfig{
|
||||
Timeout: time.Second,
|
||||
RDAPClient: server.Client(),
|
||||
RDAPBaseURL: server.URL + "/rdap",
|
||||
FetchGeofeed: true,
|
||||
GeofeedClient: server.Client(),
|
||||
ResolveASN: func(_ context.Context, ip string) (string, error) {
|
||||
if ip != "192.0.2.1" {
|
||||
t.Fatalf("resolver IP = %q", ip)
|
||||
}
|
||||
return "AS64500", nil
|
||||
},
|
||||
Relationships: RelationshipConfig{
|
||||
Client: server.Client(),
|
||||
RIPEstatURL: server.URL + "/ripe",
|
||||
PeeringDBURL: server.URL + "/peering",
|
||||
Timeout: time.Second,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != ReportAvailable || report.IP != "192.0.2.1" || report.ASN != "64500" {
|
||||
t.Fatalf("unexpected report identity/status: %+v", report)
|
||||
}
|
||||
if len(report.Prefixes) != 1 || report.Prefixes[0] != "192.0.2.0/24" || report.PrefixSource != "rdap" {
|
||||
t.Fatalf("unexpected prefixes: %+v source=%q", report.Prefixes, report.PrefixSource)
|
||||
}
|
||||
if report.RIR.Name != "ARIN" || report.RIR.Status != ReportAvailable || report.RegistrationDate == nil {
|
||||
t.Fatalf("unexpected registry fields: RIR=%+v registration=%v", report.RIR, report.RegistrationDate)
|
||||
}
|
||||
if len(report.Geofeeds) != 1 || report.Geofeeds[0].Status != ReportAvailable || report.Geofeeds[0].Bytes == 0 {
|
||||
t.Fatalf("unexpected geofeed result: %+v", report.Geofeeds)
|
||||
}
|
||||
if report.Relationships == nil || len(report.Relationships.Upstreams) != 1 || len(report.Relationships.Peers) != 1 || len(report.Relationships.IXPs) != 1 {
|
||||
t.Fatalf("unexpected relationships: %+v", report.Relationships)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIPBGPReportWHOISFallbackTCPFixture(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
tcpErr := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
tcpErr <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
query, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
tcpErr <- err
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(query) != "203.0.113.9" {
|
||||
tcpErr <- fmt.Errorf("WHOIS query = %q", query)
|
||||
return
|
||||
}
|
||||
_, err = io.WriteString(conn, "CIDR: 203.0.113.99/24\r\nRegDate: 2021-02-03\r\nSource: ARIN\r\nGeofeed: https://example.test/geofeed.csv\r\n")
|
||||
tcpErr <- err
|
||||
}()
|
||||
|
||||
rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer rdapServer.Close()
|
||||
|
||||
report, err := QueryIPBGPReport(context.Background(), "203.0.113.9", IPBGPReportConfig{
|
||||
Timeout: time.Second,
|
||||
RDAPClient: rdapServer.Client(),
|
||||
RDAPBaseURL: rdapServer.URL,
|
||||
EnableWHOISFallback: true,
|
||||
WHOISServer: listener.Addr().String(),
|
||||
WHOISTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-tcpErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != ReportPartial || report.WHOIS == nil || report.WHOIS.Status != ReportAvailable {
|
||||
t.Fatalf("unexpected fallback status: %+v", report)
|
||||
}
|
||||
if report.PrefixSource != "whois" || len(report.Prefixes) != 1 || report.Prefixes[0] != "203.0.113.0/24" {
|
||||
t.Fatalf("unexpected WHOIS prefixes: %+v", report.Prefixes)
|
||||
}
|
||||
if report.RIR.Name != "ARIN" || report.RIR.Source != "whois" || report.RegistrationDate == nil {
|
||||
t.Fatalf("unexpected WHOIS metadata: RIR=%+v registration=%v", report.RIR, report.RegistrationDate)
|
||||
}
|
||||
if len(report.Geofeeds) != 1 || report.Geofeeds[0].Status != ReportUnsupported {
|
||||
t.Fatalf("disabled geofeed fetch was not explicit: %+v", report.Geofeeds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIPBGPReportWHOISTimeoutIsBounded(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = bufio.NewReader(conn).ReadString('\n')
|
||||
_, _ = io.Copy(io.Discard, conn)
|
||||
}()
|
||||
|
||||
rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer rdapServer.Close()
|
||||
|
||||
started := time.Now()
|
||||
report, err := QueryIPBGPReport(context.Background(), "198.51.100.1", IPBGPReportConfig{
|
||||
Timeout: time.Second,
|
||||
RDAPClient: rdapServer.Client(),
|
||||
RDAPBaseURL: rdapServer.URL,
|
||||
EnableWHOISFallback: true,
|
||||
WHOISServer: listener.Addr().String(),
|
||||
WHOISTimeout: 30 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("WHOIS timeout took %v", elapsed)
|
||||
}
|
||||
if source := unifiedSourceByName(report, "whois"); source.Status != ReportTimeout {
|
||||
t.Fatalf("WHOIS source = %+v, want timeout", source)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("TCP fixture did not observe connection cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIPBGPReportResolvesWHOISFromIANABootstrap(t *testing.T) {
|
||||
bootstrap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"services":[[["203.0.113.0/24"],["https://rdap.arin.net/registry/"]]]}`)
|
||||
}))
|
||||
defer bootstrap.Close()
|
||||
rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer rdapServer.Close()
|
||||
|
||||
dialed := make(chan string, 1)
|
||||
report, err := QueryIPBGPReport(context.Background(), "203.0.113.9", IPBGPReportConfig{
|
||||
Timeout: time.Second,
|
||||
RDAPClient: rdapServer.Client(),
|
||||
RDAPBaseURL: rdapServer.URL,
|
||||
EnableWHOISFallback: true,
|
||||
WHOISBootstrapClient: bootstrap.Client(),
|
||||
WHOISBootstrapURL: bootstrap.URL,
|
||||
WHOISTimeout: 250 * time.Millisecond,
|
||||
WHOISDialContext: func(_ context.Context, _, address string) (net.Conn, error) {
|
||||
dialed <- address
|
||||
client, server := net.Pipe()
|
||||
go func() {
|
||||
defer server.Close()
|
||||
_, _ = bufio.NewReader(server).ReadString('\n')
|
||||
_, _ = io.WriteString(server, "CIDR: 203.0.113.0/24\r\nSource: ARIN\r\n")
|
||||
}()
|
||||
return client, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-dialed; got != "whois.arin.net:43" {
|
||||
t.Fatalf("WHOIS address = %q", got)
|
||||
}
|
||||
if report.WHOIS == nil || report.WHOIS.Status != ReportAvailable || unifiedSourceByName(report, "whois_bootstrap").Status != ReportAvailable {
|
||||
t.Fatalf("bootstrap WHOIS evidence missing: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIPBGPReportRejectsInvalidInput(t *testing.T) {
|
||||
if _, err := QueryIPBGPReport(context.Background(), "", IPBGPReportConfig{}); !errors.Is(err, ErrInvalidIPAddress) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrInvalidIPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRIRInferenceRequiresTokenBoundary(t *testing.T) {
|
||||
if got := rirNameFromText("https://example.test/stripe-network"); got != "" {
|
||||
t.Fatalf("false RIR inference = %q", got)
|
||||
}
|
||||
if got := rirNameFromText("whois.ripe.net"); got != "RIPE NCC" {
|
||||
t.Fatalf("RIR inference = %q, want RIPE NCC", got)
|
||||
}
|
||||
}
|
||||
|
||||
func unifiedSourceByName(report *IPBGPReport, name string) IPBGPSourceStatus {
|
||||
for _, source := range report.Sources {
|
||||
if source.Source == name {
|
||||
return source
|
||||
}
|
||||
}
|
||||
return IPBGPSourceStatus{Source: name, Status: ReportError, Error: "not found"}
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
package bgptools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultIPv4BootstrapURL = "https://data.iana.org/rdap/ipv4.json"
|
||||
defaultIPv6BootstrapURL = "https://data.iana.org/rdap/ipv6.json"
|
||||
defaultWHOISBootstrapSize = 4 << 20
|
||||
)
|
||||
|
||||
var errWHOISBootstrapMissing = errors.New("IANA bootstrap has no WHOIS service for IP")
|
||||
|
||||
// resolveWHOISServer uses the IANA RDAP bootstrap only to identify the
|
||||
// responsible RIR. The actual fallback remains a bounded port-43 query.
|
||||
func resolveWHOISServer(ctx context.Context, ip string, cfg IPBGPReportConfig) (string, error) {
|
||||
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||
if parsed == nil {
|
||||
return "", ErrInvalidIPAddress
|
||||
}
|
||||
bootstrapURL := strings.TrimSpace(cfg.WHOISBootstrapURL)
|
||||
if bootstrapURL == "" {
|
||||
bootstrapURL = defaultIPv6BootstrapURL
|
||||
if parsed.To4() != nil {
|
||||
bootstrapURL = defaultIPv4BootstrapURL
|
||||
}
|
||||
}
|
||||
client := cfg.WHOISBootstrapClient
|
||||
if client == nil {
|
||||
client = cfg.RDAPClient
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
maximum := cfg.MaxWHOISBootstrapBytes
|
||||
if maximum <= 0 {
|
||||
maximum = defaultWHOISBootstrapSize
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bootstrapURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "oneclickvirt-backtrace-whois-bootstrap/1")
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("IANA bootstrap HTTP %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maximum+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(body)) > maximum {
|
||||
return "", fmt.Errorf("IANA bootstrap response exceeds %d bytes", maximum)
|
||||
}
|
||||
var payload struct {
|
||||
Services [][][]string `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
bestPrefix := -1
|
||||
bestServer := ""
|
||||
for _, service := range payload.Services {
|
||||
if len(service) < 2 {
|
||||
continue
|
||||
}
|
||||
server := whoisServerForRDAPURLs(service[1])
|
||||
if server == "" {
|
||||
continue
|
||||
}
|
||||
for _, prefix := range service[0] {
|
||||
_, network, err := net.ParseCIDR(strings.TrimSpace(prefix))
|
||||
if err != nil || !network.Contains(parsed) {
|
||||
continue
|
||||
}
|
||||
ones, _ := network.Mask.Size()
|
||||
if ones > bestPrefix {
|
||||
bestPrefix = ones
|
||||
bestServer = server
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestServer == "" {
|
||||
return "", errWHOISBootstrapMissing
|
||||
}
|
||||
return bestServer, nil
|
||||
}
|
||||
|
||||
func whoisServerForRDAPURLs(values []string) string {
|
||||
for _, value := range values {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
switch {
|
||||
case strings.Contains(host, "arin.net"):
|
||||
return "whois.arin.net"
|
||||
case strings.Contains(host, "apnic.net"):
|
||||
return "whois.apnic.net"
|
||||
case strings.Contains(host, "ripe.net"):
|
||||
return "whois.ripe.net"
|
||||
case strings.Contains(host, "afrinic.net"):
|
||||
return "whois.afrinic.net"
|
||||
case strings.Contains(host, "lacnic.net"):
|
||||
return "whois.lacnic.net"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -1,10 +1,77 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
)
|
||||
|
||||
func BackTrace(enableIpv6 bool) string {
|
||||
report := RunRouteReport(context.Background(), RouteReportConfig{EnableIPv6: enableIpv6})
|
||||
return RenderRouteReport(report)
|
||||
func BackTrace(enableIpv6 bool) {
|
||||
if model.CachedIcmpData == "" || model.ParsedIcmpTargets == nil || time.Since(model.CachedIcmpDataFetchTime) > time.Hour {
|
||||
model.CachedIcmpData = getData(model.IcmpTargets)
|
||||
model.CachedIcmpDataFetchTime = time.Now()
|
||||
if model.CachedIcmpData != "" {
|
||||
model.ParsedIcmpTargets = parseIcmpTargets(model.CachedIcmpData)
|
||||
}
|
||||
}
|
||||
if enableIpv6 {
|
||||
ipv4Count := len(model.Ipv4s)
|
||||
ipv6Count := len(model.Ipv6s)
|
||||
totalCount := ipv4Count + ipv6Count
|
||||
var (
|
||||
s = make([]string, totalCount)
|
||||
c = make(chan Result)
|
||||
t = time.After(time.Second * 10)
|
||||
)
|
||||
for i := range model.Ipv4s {
|
||||
go trace(c, i)
|
||||
}
|
||||
for i := range model.Ipv6s {
|
||||
go traceIPv6(c, i, ipv4Count)
|
||||
}
|
||||
loopIPv4v6:
|
||||
for range s {
|
||||
select {
|
||||
case o := <-c:
|
||||
s[o.i] = o.s
|
||||
case <-t:
|
||||
break loopIPv4v6
|
||||
}
|
||||
}
|
||||
for i := 0; i < ipv4Count; i++ {
|
||||
if s[i] != "" {
|
||||
fmt.Println(s[i])
|
||||
}
|
||||
}
|
||||
for i := ipv4Count; i < totalCount; i++ {
|
||||
if s[i] != "" {
|
||||
fmt.Println(s[i])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ipCount := len(model.Ipv4s)
|
||||
var (
|
||||
s = make([]string, ipCount)
|
||||
c = make(chan Result)
|
||||
t = time.After(time.Second * 10)
|
||||
)
|
||||
for i := range model.Ipv4s {
|
||||
go trace(c, i)
|
||||
}
|
||||
loopIPv4:
|
||||
for range s {
|
||||
select {
|
||||
case o := <-c:
|
||||
s[o.i] = o.s
|
||||
case <-t:
|
||||
break loopIPv4
|
||||
}
|
||||
}
|
||||
for _, r := range s {
|
||||
if r != "" {
|
||||
fmt.Println(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -9,11 +8,6 @@ func ipv4Asn(ip string) string {
|
||||
if strings.Contains(ip, ":") {
|
||||
return ipv6Asn(ip)
|
||||
}
|
||||
address, err := netip.ParseAddr(strings.TrimSpace(ip))
|
||||
if err != nil || !address.Is4() {
|
||||
return ""
|
||||
}
|
||||
octets := address.As4()
|
||||
switch {
|
||||
case strings.HasPrefix(ip, "59.43"):
|
||||
return "AS4809"
|
||||
@ -21,41 +15,17 @@ func ipv4Asn(ip string) string {
|
||||
return "AS4134"
|
||||
case strings.HasPrefix(ip, "218.105") || strings.HasPrefix(ip, "210.51"):
|
||||
return "AS9929"
|
||||
case strings.HasPrefix(ip, "202.77") || strings.HasPrefix(ip, "43.252") || strings.HasPrefix(ip, "61.14"):
|
||||
return "AS10099"
|
||||
case strings.HasPrefix(ip, "219.158"):
|
||||
return "AS4837"
|
||||
case isCMIN2IPv4(octets):
|
||||
case strings.HasPrefix(ip, "223.120.19") || strings.HasPrefix(ip, "223.120.17") || strings.HasPrefix(ip, "223.120.16") ||
|
||||
strings.HasPrefix(ip, "223.120.140") || strings.HasPrefix(ip, "223.120.130") || strings.HasPrefix(ip, "223.120.131") ||
|
||||
strings.HasPrefix(ip, "223.120.141"):
|
||||
return "AS58807"
|
||||
case strings.HasPrefix(ip, "223.118") || strings.HasPrefix(ip, "223.119") || strings.HasPrefix(ip, "223.120") || strings.HasPrefix(ip, "223.121"):
|
||||
return "AS58453"
|
||||
case strings.HasPrefix(ip, "221.183") || strings.HasPrefix(ip, "111.24"):
|
||||
return "AS9808"
|
||||
case strings.HasPrefix(ip, "69.194") || strings.HasPrefix(ip, "203.22"):
|
||||
return "AS23764"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isCMIN2IPv4(ip [4]byte) bool {
|
||||
if ip[0] != 223 {
|
||||
return false
|
||||
}
|
||||
if ip[1] == 118 && ip[2] == 32 {
|
||||
return true
|
||||
}
|
||||
if ip[1] == 120 && ip[2] >= 128 {
|
||||
return true
|
||||
}
|
||||
if ip[1] != 119 {
|
||||
return false
|
||||
}
|
||||
third := ip[2]
|
||||
return third == 8 || third == 9 ||
|
||||
(third >= 10 && third <= 15) ||
|
||||
(third >= 26 && third <= 29) ||
|
||||
(third >= 32 && third <= 37) ||
|
||||
third == 74 || third == 75 || third == 88 || third == 89 ||
|
||||
third == 100 || third == 252 || third == 253
|
||||
}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIPv4ASNBackboneSignatures(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"59.43.1.1": "AS4809",
|
||||
"202.97.1.1": "AS4134",
|
||||
"218.105.1.1": "AS9929",
|
||||
"202.77.1.1": "AS10099",
|
||||
"219.158.1.1": "AS4837",
|
||||
"223.118.32.1": "AS58807",
|
||||
"223.119.100.1": "AS58807",
|
||||
"223.120.128.1": "AS58807",
|
||||
"223.120.127.1": "AS58453",
|
||||
"221.183.1.1": "AS9808",
|
||||
"69.194.1.1": "AS23764",
|
||||
"192.0.2.1": "",
|
||||
}
|
||||
for address, want := range tests {
|
||||
if got := ipv4Asn(address); got != want {
|
||||
t.Fatalf("ipv4Asn(%q) = %q, want %q", address, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPv6ASNRecognizesCUGSnapshot(t *testing.T) {
|
||||
if got := ipv6Asn("2401:8a00:1:12::1"); got != "AS10099" {
|
||||
t.Fatalf("ipv6Asn(CUG) = %q, want AS10099", got)
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,8 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/netip"
|
||||
"strings"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed prefix/as4809.txt
|
||||
@ -15,9 +14,6 @@ var as4134Data string
|
||||
//go:embed prefix/as9929.txt
|
||||
var as9929Data string
|
||||
|
||||
//go:embed prefix/as10099.txt
|
||||
var as10099Data string
|
||||
|
||||
//go:embed prefix/as4837.txt
|
||||
var as4837Data string
|
||||
|
||||
@ -38,7 +34,6 @@ var asnPrefixes = map[string][]string{
|
||||
"AS4809": strings.Split(as4809Data, "\n"), // 电信 CN2 GT/GIA
|
||||
"AS4134": strings.Split(as4134Data, "\n"), // 电信 163 骨干网
|
||||
"AS9929": strings.Split(as9929Data, "\n"), // 联通 9929 优质国际线路
|
||||
"AS10099": strings.Split(as10099Data, "\n"), // 联通 CUG 国际网络
|
||||
"AS4837": strings.Split(as4837Data, "\n"), // 联通 AS4837 普通国际线路
|
||||
"AS58807": strings.Split(as58807Data, "\n"), // 移动 CMIN2 国际精品网
|
||||
"AS9808": strings.Split(as9808Data, "\n"), // 移动 CMI(中国移动国际公司)
|
||||
@ -49,48 +44,16 @@ var asnPrefixes = map[string][]string{
|
||||
// 判断 IPv6 地址是否匹配 ASN 中的某个前缀
|
||||
func ipv6Asn(ip string) string {
|
||||
ip = strings.ToLower(ip)
|
||||
address, addressErr := netip.ParseAddr(ip)
|
||||
if addressErr != nil || !address.Is6() {
|
||||
return ""
|
||||
}
|
||||
bestASN := ""
|
||||
bestSpecificity := -1
|
||||
for asn, prefixes := range currentASNPrefixes() {
|
||||
for asn, prefixes := range asnPrefixes {
|
||||
for _, prefix := range prefixes {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(prefix, "/") {
|
||||
network, networkErr := netip.ParsePrefix(prefix)
|
||||
if networkErr == nil && network.Contains(address) &&
|
||||
(network.Bits() > bestSpecificity || (network.Bits() == bestSpecificity && asn < bestASN)) {
|
||||
bestASN = asn
|
||||
bestSpecificity = network.Bits()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(ip, prefix) {
|
||||
specificity := prefixHexBits(prefix)
|
||||
if specificity > bestSpecificity || (specificity == bestSpecificity && asn < bestASN) {
|
||||
bestASN = asn
|
||||
bestSpecificity = specificity
|
||||
return asn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestASN
|
||||
}
|
||||
|
||||
func prefixHexBits(prefix string) int {
|
||||
bits := 0
|
||||
for _, value := range prefix {
|
||||
switch {
|
||||
case value >= '0' && value <= '9':
|
||||
bits += 4
|
||||
case value >= 'a' && value <= 'f':
|
||||
bits += 4
|
||||
}
|
||||
}
|
||||
return bits
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
2401:8a00:13::/48
|
||||
2401:8a00:1:12::/64
|
||||
2401:8a00:1:14::/64
|
||||
2401:8a00:1:17::/64
|
||||
2401:8a00:1:2::/64
|
||||
2401:8a00:1:3::/64
|
||||
2401:8a00:1:7::/64
|
||||
2401:8a00:1:c::/64
|
||||
2401:8a00::/32
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as10099.txt",
|
||||
"count": 9,
|
||||
"sha256": "d3fda28bdf4e8430164b60134fabc75c3f6d44c8beec28911b1325886abc5f07",
|
||||
"generated_at": "2026-09-13T03:51:17Z"
|
||||
}
|
||||
@ -1,29 +1,24 @@
|
||||
2400:9380:9002::/48
|
||||
2400:9380:9115::/48
|
||||
2400:9380:9116::/48
|
||||
2400:9380:9206::/48
|
||||
2400:9380:a003::/48
|
||||
2400:9380:a00a::/48
|
||||
2400:9380:a00c::/48
|
||||
2400:9380:a014::/48
|
||||
2400:9380:a016::/48
|
||||
2400:9380:a01f::/48
|
||||
2400:9380:a022::/48
|
||||
2400:9380:a028::/48
|
||||
2400:9380:a042::/48
|
||||
2400:9380:a110::/48
|
||||
240e:97d:8000::/48
|
||||
2804:1e48:9001::/48
|
||||
2804:1e48:9003::/48
|
||||
2a04:f580:9001::/48
|
||||
2a04:f580:9090::/48
|
||||
2a04:f581:110b::/48
|
||||
2a04:f581:a123::/48
|
||||
2a04:f581:a125::/48
|
||||
2c0f:f7a8:1::/48
|
||||
2c0f:f7a8:24::/48
|
||||
2c0f:f7a8:29::/48
|
||||
2c0f:f7a8:2::/48
|
||||
2c0f:f7a8:37::/48
|
||||
2c0f:f7a8:47::/48
|
||||
2c0f:f7a8:9011::/48
|
||||
2400:9380:9115
|
||||
2400:9380:9116
|
||||
2400:9380:9206
|
||||
2400:9380:9262
|
||||
2400:9380:a003
|
||||
2400:9380:a00a
|
||||
2400:9380:a00c
|
||||
2400:9380:a01f
|
||||
2400:9380:a022
|
||||
2400:9380:a026
|
||||
2400:9380:a028
|
||||
2400:9380:a042
|
||||
2400:9380:a110
|
||||
2804:1e48:9003
|
||||
2a04:f580:9001
|
||||
2a04:f581:110a
|
||||
2a04:f581:110b
|
||||
2a04:f581:a123
|
||||
2a04:f581:a125
|
||||
2a0f:7806:fffc
|
||||
2c0f:f7a8:1
|
||||
2c0f:f7a8:29
|
||||
2c0f:f7a8:37
|
||||
2c0f:f7a8:47
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as23764.txt",
|
||||
"count": 29,
|
||||
"sha256": "956566eed70b6ba577734c998b6b614dbd68f3b139928eaef0008ca496b96e05",
|
||||
"generated_at": "2026-08-28T10:13:57Z"
|
||||
}
|
||||
@ -1,633 +1,223 @@
|
||||
2400:9380:8001::/48
|
||||
2400:9380:8003::/48
|
||||
2400:9380:8021::/48
|
||||
2400:9380:8040::/48
|
||||
2400:9380:8140::/48
|
||||
2400:9380:8201::/48
|
||||
2400:9380:8301::/48
|
||||
240e:100:8000::/34
|
||||
240e:100:c000::/34
|
||||
240e:101::/34
|
||||
240e:103:c000::/34
|
||||
240e:104:4000::/36
|
||||
240e:104:7000::/36
|
||||
240e:104:c000::/34
|
||||
240e:106:4000::/34
|
||||
240e:108:1100::/44
|
||||
240e:108:1110::/44
|
||||
240e:108:1120::/44
|
||||
240e:108:1130::/44
|
||||
240e:108:1140::/44
|
||||
240e:108:1150::/44
|
||||
240e:108:1160::/44
|
||||
240e:108:1180::/44
|
||||
240e:108:1190::/44
|
||||
240e:108:11a0::/44
|
||||
240e:108:11b0::/44
|
||||
240e:108:11c0::/44
|
||||
240e:108:11d0::/44
|
||||
240e:108:11e0::/44
|
||||
240e:108:1200::/44
|
||||
240e:108:1210::/44
|
||||
240e:108:12a0::/44
|
||||
240e:108:12c0::/44
|
||||
240e:108:12d0::/44
|
||||
240e:108:1300::/44
|
||||
240e:108:1310::/44
|
||||
240e:108:1320::/44
|
||||
240e:108:1330::/44
|
||||
240e:108:1340::/44
|
||||
240e:108:1350::/44
|
||||
240e:108:1360::/44
|
||||
240e:108:1370::/44
|
||||
240e:108:1380::/44
|
||||
240e:108:1390::/44
|
||||
240e:108:13a0::/44
|
||||
240e:108:13b0::/44
|
||||
240e:108:13c0::/44
|
||||
240e:108:13d0::/44
|
||||
240e:108:13e0::/44
|
||||
240e:11:8001::/48
|
||||
240e:12::/32
|
||||
240e:144:c000::/34
|
||||
240e:14::/31
|
||||
240e:16:1001::/48
|
||||
240e:16:1008::/48
|
||||
240e:16:1009::/48
|
||||
240e:184:c000::/34
|
||||
240e:1:8000::/33
|
||||
240e:1::/33
|
||||
240e:1c7:c00::/40
|
||||
240e:1c:112::/48
|
||||
240e:1c::/31
|
||||
240e:204::/30
|
||||
240e:208::/30
|
||||
240e:20c::/30
|
||||
240e:210::/30
|
||||
240e:214::/30
|
||||
240e:218::/32
|
||||
240e:219::/32
|
||||
240e:21a::/32
|
||||
240e:21b::/32
|
||||
240e:21c::/30
|
||||
240e:220::/30
|
||||
240e:224::/32
|
||||
240e:225::/32
|
||||
240e:226::/32
|
||||
240e:227::/32
|
||||
240e:22c::/30
|
||||
240e:230::/30
|
||||
240e:234::/30
|
||||
240e:238::/30
|
||||
240e:23c::/30
|
||||
240e:240::/32
|
||||
240e:241::/32
|
||||
240e:242::/32
|
||||
240e:243::/32
|
||||
240e:244::/32
|
||||
240e:245::/32
|
||||
240e:246::/32
|
||||
240e:247::/32
|
||||
240e:248::/30
|
||||
240e:24:8000::/34
|
||||
240e:24c::/30
|
||||
240e:250::/30
|
||||
240e:254::/30
|
||||
240e:258::/30
|
||||
240e:25c::/30
|
||||
240e:260::/30
|
||||
240e:264::/30
|
||||
240e:268::/30
|
||||
240e:26c::/30
|
||||
240e:270::/30
|
||||
240e:274::/30
|
||||
240e:278::/30
|
||||
240e:27c::/30
|
||||
240e:2::/33
|
||||
240e:2c:8000::/34
|
||||
240e:304::/30
|
||||
240e:308::/30
|
||||
240e:30c::/30
|
||||
240e:310::/30
|
||||
240e:314::/30
|
||||
240e:318::/32
|
||||
240e:319::/32
|
||||
240e:31a::/32
|
||||
240e:31b::/32
|
||||
240e:31c::/30
|
||||
240e:320::/30
|
||||
240e:324::/32
|
||||
240e:325::/32
|
||||
240e:326::/32
|
||||
240e:327::/32
|
||||
240e:32c::/30
|
||||
240e:330::/30
|
||||
240e:334::/30
|
||||
240e:338::/30
|
||||
240e:33c::/30
|
||||
240e:340::/32
|
||||
240e:341::/32
|
||||
240e:342::/32
|
||||
240e:343::/32
|
||||
240e:344::/30
|
||||
240e:348::/30
|
||||
240e:34c::/30
|
||||
240e:350:205::/48
|
||||
240e:350::/29
|
||||
240e:358::/29
|
||||
240e:360::/29
|
||||
240e:368::/29
|
||||
240e:370::/29
|
||||
240e:378::/29
|
||||
240e:380::/29
|
||||
240e:388::/29
|
||||
240e:390::/29
|
||||
240e:398::/29
|
||||
240e:3a0::/28
|
||||
240e:3b0::/28
|
||||
240e:404::/30
|
||||
240e:408::/30
|
||||
240e:414::/30
|
||||
240e:418::/32
|
||||
240e:419::/32
|
||||
240e:41:8000::/33
|
||||
240e:41::/33
|
||||
240e:41a::/32
|
||||
240e:41b::/32
|
||||
240e:41c::/30
|
||||
240e:420::/30
|
||||
240e:424::/32
|
||||
240e:425::/32
|
||||
240e:426::/32
|
||||
240e:427::/32
|
||||
240e:42:4000::/48
|
||||
240e:42:8000::/33
|
||||
240e:42::/33
|
||||
240e:42c::/30
|
||||
240e:430::/30
|
||||
240e:434::/30
|
||||
240e:438::/30
|
||||
240e:43:8000::/33
|
||||
240e:43:8000::/48
|
||||
240e:43::/33
|
||||
240e:43c::/30
|
||||
240e:440::/32
|
||||
240e:441::/32
|
||||
240e:442::/32
|
||||
240e:443::/32
|
||||
240e:444::/32
|
||||
240e:445::/32
|
||||
240e:446::/32
|
||||
240e:447::/32
|
||||
240e:448::/30
|
||||
240e:44:8000::/33
|
||||
240e:44::/33
|
||||
240e:44c::/30
|
||||
240e:450::/30
|
||||
240e:454::/30
|
||||
240e:458::/30
|
||||
240e:45:8000::/33
|
||||
240e:45:8000::/48
|
||||
240e:45c::/30
|
||||
240e:460::/30
|
||||
240e:464::/30
|
||||
240e:468::/30
|
||||
240e:46:5008::/48
|
||||
240e:46:8000::/33
|
||||
240e:46::/33
|
||||
240e:46c::/30
|
||||
240e:470::/30
|
||||
240e:474::/30
|
||||
240e:478::/30
|
||||
240e:47:4::/48
|
||||
240e:47::/32
|
||||
240e:47c::/30
|
||||
240e:48:8000::/33
|
||||
240e:48::/33
|
||||
240e:49:8000::/33
|
||||
240e:49::/33
|
||||
240e:4a:4100::/48
|
||||
240e:4a:8000::/33
|
||||
240e:4a::/33
|
||||
240e:4b:1::/48
|
||||
240e:4b:2::/48
|
||||
240e:4b:8000::/33
|
||||
240e:4b::/33
|
||||
240e:4c:4006::/48
|
||||
240e:4c:5000::/48
|
||||
240e:4c:8000::/33
|
||||
240e:4c::/33
|
||||
240e:4d:8000::/33
|
||||
240e:4d::/33
|
||||
240e:4e:4000::/48
|
||||
240e:4e:8000::/33
|
||||
240e:4e::/33
|
||||
240e:4f:8000::/33
|
||||
240e:4f::/33
|
||||
240e:504::/30
|
||||
240e:508::/30
|
||||
240e:50:8000::/33
|
||||
240e:50::/33
|
||||
240e:514::/30
|
||||
240e:518::/32
|
||||
240e:519::/32
|
||||
240e:51::/32
|
||||
240e:51a::/32
|
||||
240e:51b::/32
|
||||
240e:51c::/30
|
||||
240e:520::/30
|
||||
240e:524::/32
|
||||
240e:525::/32
|
||||
240e:526::/32
|
||||
240e:527::/32
|
||||
240e:52:4802::/48
|
||||
240e:52:8000::/33
|
||||
240e:52::/33
|
||||
240e:52c::/30
|
||||
240e:530::/30
|
||||
240e:534::/30
|
||||
240e:538::/30
|
||||
240e:53:8000::/33
|
||||
240e:53::/33
|
||||
240e:53c::/30
|
||||
240e:540::/32
|
||||
240e:541::/32
|
||||
240e:542::/32
|
||||
240e:543::/32
|
||||
240e:544::/32
|
||||
240e:545::/32
|
||||
240e:546::/32
|
||||
240e:547::/32
|
||||
240e:548::/30
|
||||
240e:54:8000::/33
|
||||
240e:54::/33
|
||||
240e:54c::/30
|
||||
240e:550::/30
|
||||
240e:554::/30
|
||||
240e:558::/30
|
||||
240e:55:8000::/33
|
||||
240e:55::/33
|
||||
240e:55c::/30
|
||||
240e:560::/30
|
||||
240e:564::/30
|
||||
240e:568::/30
|
||||
240e:56:4807::/48
|
||||
240e:56:8000::/33
|
||||
240e:56::/33
|
||||
240e:56c::/30
|
||||
240e:570::/30
|
||||
240e:574::/30
|
||||
240e:578::/30
|
||||
240e:57:8000::/33
|
||||
240e:57::/33
|
||||
240e:57c::/30
|
||||
240e:5a:6800::/48
|
||||
240e:5a:8000::/33
|
||||
240e:5a::/33
|
||||
240e:5b:8000::/33
|
||||
240e:5b::/33
|
||||
240e:5c:8000::/33
|
||||
240e:5c::/33
|
||||
240e:5d:8000::/33
|
||||
240e:5d::/33
|
||||
240e:5e:8000::/33
|
||||
240e:5e::/33
|
||||
240e:5f:8000::/33
|
||||
240e:5f::/33
|
||||
240e:604::/30
|
||||
240e:608::/30
|
||||
240e:60::/30
|
||||
240e:60c::/30
|
||||
240e:610::/30
|
||||
240e:614::/30
|
||||
240e:618::/32
|
||||
240e:619::/32
|
||||
240e:61a::/32
|
||||
240e:61b::/32
|
||||
240e:61c::/30
|
||||
240e:620::/30
|
||||
240e:624::/32
|
||||
240e:625::/32
|
||||
240e:626::/32
|
||||
240e:627::/32
|
||||
240e:62c::/30
|
||||
240e:630::/30
|
||||
240e:634::/30
|
||||
240e:638::/30
|
||||
240e:638::/48
|
||||
240e:63c::/30
|
||||
240e:640::/32
|
||||
240e:641::/32
|
||||
240e:642::/32
|
||||
240e:643::/32
|
||||
240e:644::/30
|
||||
240e:645:2010::/48
|
||||
240e:648::/30
|
||||
240e:64::/32
|
||||
240e:64c::/30
|
||||
240e:650::/29
|
||||
240e:658::/29
|
||||
240e:659:1160::/48
|
||||
240e:660::/29
|
||||
240e:668::/29
|
||||
240e:670::/29
|
||||
240e:678::/29
|
||||
240e:680::/29
|
||||
240e:688::/29
|
||||
240e:690::/29
|
||||
240e:698::/29
|
||||
240e:6a0::/28
|
||||
240e:6b0::/28
|
||||
240e:704::/30
|
||||
240e:708::/30
|
||||
240e:70c::/30
|
||||
240e:710::/30
|
||||
240e:714::/30
|
||||
240e:718::/32
|
||||
240e:719::/32
|
||||
240e:71a::/32
|
||||
240e:71b::/32
|
||||
240e:71c::/30
|
||||
240e:720::/30
|
||||
240e:724::/32
|
||||
240e:725::/32
|
||||
240e:726::/32
|
||||
240e:727::/32
|
||||
240e:72c::/30
|
||||
240e:730::/30
|
||||
240e:734::/30
|
||||
240e:738::/30
|
||||
240e:73c::/30
|
||||
240e:740::/32
|
||||
240e:741::/32
|
||||
240e:742::/32
|
||||
240e:743::/32
|
||||
240e:744::/30
|
||||
240e:748::/30
|
||||
240e:74c::/30
|
||||
240e:750::/29
|
||||
240e:758::/29
|
||||
240e:760::/29
|
||||
240e:767:7180::/41
|
||||
240e:768::/29
|
||||
240e:770::/29
|
||||
240e:778::/29
|
||||
240e:780::/29
|
||||
240e:788::/29
|
||||
240e:790::/29
|
||||
240e:798::/29
|
||||
240e:7::/32
|
||||
240e:7a0::/28
|
||||
240e:7b0::/28
|
||||
240e:804::/30
|
||||
240e:814::/30
|
||||
240e:81c::/30
|
||||
240e:820::/30
|
||||
240e:82c::/30
|
||||
240e:830::/30
|
||||
240e:834::/30
|
||||
240e:838::/30
|
||||
240e:83c::/30
|
||||
240e:840::/32
|
||||
240e:841::/32
|
||||
240e:848::/30
|
||||
240e:84::/31
|
||||
240e:84c::/30
|
||||
240e:850::/30
|
||||
240e:854::/30
|
||||
240e:858::/30
|
||||
240e:85c::/30
|
||||
240e:860::/30
|
||||
240e:864::/30
|
||||
240e:868::/30
|
||||
240e:86::/31
|
||||
240e:86c::/30
|
||||
240e:870::/30
|
||||
240e:874::/30
|
||||
240e:878::/30
|
||||
240e:87c::/30
|
||||
240e:88::/31
|
||||
240e:8a::/31
|
||||
240e:8c::/31
|
||||
240e:8e::/31
|
||||
240e:904::/30
|
||||
240e:908::/30
|
||||
240e:90::/31
|
||||
240e:90c::/30
|
||||
240e:910::/30
|
||||
240e:914::/30
|
||||
240e:918::/32
|
||||
240e:919::/32
|
||||
240e:91a::/32
|
||||
240e:91b::/32
|
||||
240e:91c::/30
|
||||
240e:920::/30
|
||||
240e:924::/32
|
||||
240e:925::/32
|
||||
240e:926::/32
|
||||
240e:927::/32
|
||||
240e:92::/31
|
||||
240e:92c::/30
|
||||
240e:930::/30
|
||||
240e:934::/30
|
||||
240e:938::/30
|
||||
240e:93c::/30
|
||||
240e:940::/32
|
||||
240e:941::/32
|
||||
240e:942::/32
|
||||
240e:943::/32
|
||||
240e:944::/32
|
||||
240e:945::/32
|
||||
240e:946::/32
|
||||
240e:947::/32
|
||||
240e:948::/30
|
||||
240e:94c::/30
|
||||
240e:950::/30
|
||||
240e:954::/30
|
||||
240e:958::/30
|
||||
240e:95c::/30
|
||||
240e:960::/30
|
||||
240e:964::/30
|
||||
240e:968::/30
|
||||
240e:96::/31
|
||||
240e:96c::/30
|
||||
240e:970::/30
|
||||
240e:974::/30
|
||||
240e:978::/30
|
||||
240e:97c::/30
|
||||
240e:980:9400::/40
|
||||
240e:981:f000::/36
|
||||
240e:982:1000::/36
|
||||
240e:982:2000::/36
|
||||
240e:982:3000::/36
|
||||
240e:982:4000::/36
|
||||
240e:982:5000::/36
|
||||
240e:982:6000::/36
|
||||
240e:982:7000::/36
|
||||
240e:982:8000::/36
|
||||
240e:982:9000::/36
|
||||
240e:982::/36
|
||||
240e:982:a000::/36
|
||||
240e:982:b000::/36
|
||||
240e:982:c000::/36
|
||||
240e:982:d000::/36
|
||||
240e:982:e000::/36
|
||||
240e:983:1000::/40
|
||||
240e:983:100::/40
|
||||
240e:983:1100::/40
|
||||
240e:983:1200::/40
|
||||
240e:983:1300::/40
|
||||
240e:983:1400::/40
|
||||
240e:983:1500::/40
|
||||
240e:983:1600::/40
|
||||
240e:983:1700::/40
|
||||
240e:983:1800::/40
|
||||
240e:983:1900::/40
|
||||
240e:983:1a00::/40
|
||||
240e:983:1b00::/40
|
||||
240e:983:1c00::/40
|
||||
240e:983:1d00::/40
|
||||
240e:983:1e00::/40
|
||||
240e:983::/40
|
||||
240e:983:a00::/40
|
||||
240e:983:c00::/40
|
||||
240e:983:d00::/40
|
||||
240e:983:f00::/40
|
||||
240e:98::/31
|
||||
240e:9::/32
|
||||
240e:9a::/31
|
||||
240e:9c::/30
|
||||
240e::/20
|
||||
240e::/24
|
||||
240e:a04::/30
|
||||
240e:a08::/30
|
||||
240e:a0::/31
|
||||
240e:a0c::/32
|
||||
240e:a0d::/32
|
||||
240e:a0e::/32
|
||||
240e:a0f::/32
|
||||
240e:a14::/30
|
||||
240e:a18::/32
|
||||
240e:a19::/32
|
||||
240e:a1a::/32
|
||||
240e:a1b::/32
|
||||
240e:a1c::/30
|
||||
240e:a20::/30
|
||||
240e:a24::/32
|
||||
240e:a25::/32
|
||||
240e:a26::/32
|
||||
240e:a27::/32
|
||||
240e:a2::/31
|
||||
240e:a2c::/30
|
||||
240e:a30::/30
|
||||
240e:a34::/30
|
||||
240e:a38::/30
|
||||
240e:a40::/32
|
||||
240e:a41::/32
|
||||
240e:a42::/32
|
||||
240e:a43::/32
|
||||
240e:a44::/32
|
||||
240e:a45::/32
|
||||
240e:a46::/32
|
||||
240e:a47::/32
|
||||
240e:a48::/30
|
||||
240e:a4::/31
|
||||
240e:a4c::/30
|
||||
240e:a50::/30
|
||||
240e:a54::/30
|
||||
240e:a58::/30
|
||||
240e:a5:8000::/48
|
||||
240e:a5c::/30
|
||||
240e:a60::/30
|
||||
240e:a64::/30
|
||||
240e:a68::/30
|
||||
240e:a6::/31
|
||||
240e:a6c::/30
|
||||
240e:a70::/30
|
||||
240e:a74::/30
|
||||
240e:a78::/30
|
||||
240e:a7c::/30
|
||||
240e:a8::/31
|
||||
240e:aa::/31
|
||||
240e:ac::/31
|
||||
240e:ae::/31
|
||||
240e:b04::/30
|
||||
240e:b0::/31
|
||||
240e:b0c::/30
|
||||
240e:b14::/30
|
||||
240e:b1c::/30
|
||||
240e:b20::/30
|
||||
240e:b2::/31
|
||||
240e:b2c::/30
|
||||
240e:b30::/30
|
||||
240e:b34::/30
|
||||
240e:b38::/30
|
||||
240e:b44::/30
|
||||
240e:b48::/30
|
||||
240e:b4::/31
|
||||
240e:b4c::/30
|
||||
240e:b50::/29
|
||||
240e:b60::/29
|
||||
240e:b68::/29
|
||||
240e:b6::/31
|
||||
240e:b70::/29
|
||||
240e:b78::/29
|
||||
240e:b80::/29
|
||||
240e:b88::/29
|
||||
240e:b8::/31
|
||||
240e:b90::/29
|
||||
240e:b98::/29
|
||||
240e:b:8000::/33
|
||||
240e:b::/33
|
||||
240e:ba::/31
|
||||
240e:bb0::/28
|
||||
240e:bc::/31
|
||||
240e:bd:8000::/48
|
||||
240e:be::/31
|
||||
240e:c0::/31
|
||||
240e:c2::/31
|
||||
240e:c4::/30
|
||||
240e:c60::/30
|
||||
240e:c8::/31
|
||||
240e:ca::/31
|
||||
240e:cc::/31
|
||||
240e:cd:8000::/48
|
||||
240e:ce::/31
|
||||
240e:d0::/31
|
||||
240e:d2::/31
|
||||
240e:d4::/31
|
||||
240e:d6::/31
|
||||
240e:d8::/31
|
||||
240e:da::/31
|
||||
240e:dc::/31
|
||||
240e:de::/31
|
||||
240e:e8::/31
|
||||
240e:ea::/31
|
||||
240e:ec::/31
|
||||
240e:ee::/31
|
||||
240e:f0::/31
|
||||
240e:f2::/31
|
||||
240e:f4::/31
|
||||
240e:f6::/31
|
||||
240e:f8::/31
|
||||
240e:fa::/31
|
||||
240e:fc::/31
|
||||
240e:fe::/31
|
||||
2605:9d80:8001::/48
|
||||
2605:9d80:8011::/48
|
||||
2605:9d80:8021::/48
|
||||
2605:9d80:8031::/48
|
||||
2605:9d80:8041::/48
|
||||
2605:9d80:8081::/48
|
||||
2a04:f580:8010::/48
|
||||
2a04:f580:8011::/48
|
||||
2a04:f580:8090::/48
|
||||
2a04:f580:8210::/48
|
||||
2a04:f580:8211::/48
|
||||
2a04:f580:8290::/48
|
||||
2c0f:f7a8:8011::/48
|
||||
2c0f:f7a8:8050::/48
|
||||
2c0f:f7a8:805f::/48
|
||||
2c0f:f7a8:8150::/48
|
||||
2c0f:f7a8:815f::/48
|
||||
2c0f:f7a8:8211::/48
|
||||
2400:9380:8001
|
||||
2400:9380:8003
|
||||
2400:9380:8021
|
||||
2400:9380:8030
|
||||
2400:9380:8040
|
||||
2400:9380:8140
|
||||
2400:9380:8201
|
||||
2400:9380:8221
|
||||
2400:9380:8301
|
||||
240e
|
||||
240e:1
|
||||
240e:100
|
||||
240e:101
|
||||
240e:103
|
||||
240e:104
|
||||
240e:106
|
||||
240e:108
|
||||
240e:11:8001
|
||||
240e:12
|
||||
240e:144
|
||||
240e:16:1001
|
||||
240e:16:1008
|
||||
240e:16:1009
|
||||
240e:184
|
||||
240e:1c7
|
||||
240e:2
|
||||
240e:218
|
||||
240e:219
|
||||
240e:21a
|
||||
240e:21b
|
||||
240e:224
|
||||
240e:225
|
||||
240e:226
|
||||
240e:227
|
||||
240e:24
|
||||
240e:240
|
||||
240e:241
|
||||
240e:242
|
||||
240e:243
|
||||
240e:244
|
||||
240e:245
|
||||
240e:246
|
||||
240e:247
|
||||
240e:2c
|
||||
240e:318
|
||||
240e:319
|
||||
240e:31a
|
||||
240e:31b
|
||||
240e:324
|
||||
240e:325
|
||||
240e:326
|
||||
240e:327
|
||||
240e:340
|
||||
240e:341
|
||||
240e:342
|
||||
240e:343
|
||||
240e:350:205
|
||||
240e:41
|
||||
240e:418
|
||||
240e:419
|
||||
240e:41a
|
||||
240e:41b
|
||||
240e:42
|
||||
240e:424
|
||||
240e:425
|
||||
240e:426
|
||||
240e:427
|
||||
240e:42:4000
|
||||
240e:42:8010
|
||||
240e:43
|
||||
240e:43:8000
|
||||
240e:44
|
||||
240e:440
|
||||
240e:441
|
||||
240e:442
|
||||
240e:443
|
||||
240e:444
|
||||
240e:445
|
||||
240e:446
|
||||
240e:447
|
||||
240e:45
|
||||
240e:45:8000
|
||||
240e:46
|
||||
240e:46:5008
|
||||
240e:47
|
||||
240e:47:4
|
||||
240e:48
|
||||
240e:49
|
||||
240e:4a
|
||||
240e:4b
|
||||
240e:4b:2
|
||||
240e:4c
|
||||
240e:4c:4006
|
||||
240e:4d
|
||||
240e:4d:50ff
|
||||
240e:4e
|
||||
240e:4e:4000
|
||||
240e:4f
|
||||
240e:50
|
||||
240e:51
|
||||
240e:518
|
||||
240e:519
|
||||
240e:51a
|
||||
240e:51b
|
||||
240e:52
|
||||
240e:524
|
||||
240e:525
|
||||
240e:526
|
||||
240e:527
|
||||
240e:53
|
||||
240e:54
|
||||
240e:540
|
||||
240e:541
|
||||
240e:542
|
||||
240e:543
|
||||
240e:544
|
||||
240e:545
|
||||
240e:546
|
||||
240e:547
|
||||
240e:55
|
||||
240e:56
|
||||
240e:57
|
||||
240e:5a
|
||||
240e:5b
|
||||
240e:5c
|
||||
240e:5d
|
||||
240e:5e
|
||||
240e:5f
|
||||
240e:618
|
||||
240e:619
|
||||
240e:61a
|
||||
240e:61b
|
||||
240e:61d:1401
|
||||
240e:624
|
||||
240e:625
|
||||
240e:626
|
||||
240e:627
|
||||
240e:638
|
||||
240e:64
|
||||
240e:640
|
||||
240e:641
|
||||
240e:642
|
||||
240e:643
|
||||
240e:659:1160
|
||||
240e:7
|
||||
240e:718
|
||||
240e:719
|
||||
240e:71a
|
||||
240e:71b
|
||||
240e:724
|
||||
240e:725
|
||||
240e:726
|
||||
240e:727
|
||||
240e:740
|
||||
240e:741
|
||||
240e:742
|
||||
240e:743
|
||||
240e:767
|
||||
240e:840
|
||||
240e:841
|
||||
240e:9
|
||||
240e:918
|
||||
240e:919
|
||||
240e:91a
|
||||
240e:91b
|
||||
240e:924
|
||||
240e:925
|
||||
240e:926
|
||||
240e:927
|
||||
240e:940
|
||||
240e:941
|
||||
240e:942
|
||||
240e:943
|
||||
240e:944
|
||||
240e:945
|
||||
240e:946
|
||||
240e:947
|
||||
240e:980
|
||||
240e:981
|
||||
240e:982
|
||||
240e:983
|
||||
240e:a0c
|
||||
240e:a0d
|
||||
240e:a0e
|
||||
240e:a0f
|
||||
240e:a18
|
||||
240e:a19
|
||||
240e:a1a
|
||||
240e:a1b
|
||||
240e:a24
|
||||
240e:a25
|
||||
240e:a26
|
||||
240e:a27
|
||||
240e:a40
|
||||
240e:a41
|
||||
240e:a42
|
||||
240e:a43
|
||||
240e:a44
|
||||
240e:a45
|
||||
240e:a46
|
||||
240e:a47
|
||||
240e:a5:8000
|
||||
240e:b
|
||||
240e:cd:8000
|
||||
240e:d:100
|
||||
2605:9d80:8001
|
||||
2605:9d80:8011
|
||||
2605:9d80:8021
|
||||
2605:9d80:8031
|
||||
2605:9d80:8041
|
||||
2605:9d80:8081
|
||||
2a04:f580:8010
|
||||
2a04:f580:8011
|
||||
2a04:f580:8090
|
||||
2a04:f580:8210
|
||||
2a04:f580:8211
|
||||
2a04:f580:8290
|
||||
2c0f:f7a8:8011
|
||||
2c0f:f7a8:8050
|
||||
2c0f:f7a8:805f
|
||||
2c0f:f7a8:8150
|
||||
2c0f:f7a8:815f
|
||||
2c0f:f7a8:8211
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as4134.txt",
|
||||
"count": 633,
|
||||
"sha256": "a70b9bab5e0fa5ec49d6a9236c6990a793626c035c8f9a23f27ba3bcf1c8a032",
|
||||
"generated_at": "2026-10-22T03:48:24Z"
|
||||
}
|
||||
@ -1,198 +1,160 @@
|
||||
2400:9380:9001::/48
|
||||
2400:9380:9009::/48
|
||||
2400:9380:9020::/48
|
||||
2400:9380:9021::/48
|
||||
2400:9380:9050::/48
|
||||
2400:9380:9051::/48
|
||||
2400:9380:9060::/48
|
||||
2400:9380:9071::/48
|
||||
2400:9380:9080::/48
|
||||
2400:9380:9081::/48
|
||||
2400:9380:90b1::/48
|
||||
2400:9380:90b2::/48
|
||||
2400:9380:90b3::/48
|
||||
2400:9380:90b4::/48
|
||||
2400:9380:90b5::/48
|
||||
2400:9380:90b6::/48
|
||||
2400:9380:90b7::/48
|
||||
2400:9380:9109::/48
|
||||
2400:9380:9121::/48
|
||||
2400:9380:9220::/48
|
||||
2400:9380:9221::/48
|
||||
2400:9380:9250::/48
|
||||
2400:9380:9251::/48
|
||||
2400:9380:9260::/48
|
||||
2400:9380:9271::/48
|
||||
2400:9380:9280::/48
|
||||
2400:9380:9281::/48
|
||||
2400:9380:9282::/48
|
||||
2400:9380:92b1::/48
|
||||
2400:9380:92b2::/48
|
||||
2400:9380:92b3::/48
|
||||
2400:9380:92b4::/48
|
||||
2400:9380:92b5::/48
|
||||
2400:9380:92b6::/48
|
||||
2400:9380:92b7::/48
|
||||
240e:182:5401::/48
|
||||
240e:182:5501::/48
|
||||
240e:409:9000::/48
|
||||
240e:409:9001::/48
|
||||
240e:410:ff00::/48
|
||||
240e:411:ff00::/48
|
||||
240e:414:e710::/45
|
||||
240e:414:e718::/45
|
||||
240e:43d:fff1::/48
|
||||
240e:43d:fff3::/48
|
||||
240e:43d:fff4::/48
|
||||
240e:43d:fff7::/48
|
||||
240e:440:ac00::/48
|
||||
240e:440:ac01::/48
|
||||
240e:440:ac02::/48
|
||||
240e:441:ac00::/48
|
||||
240e:441:ac01::/48
|
||||
240e:441:ac02::/48
|
||||
240e:445:3f00::/48
|
||||
240e:446:3f00::/48
|
||||
240e:451:bfc0::/48
|
||||
240e:451:bfe0::/48
|
||||
240e:451:ffa0::/48
|
||||
240e:451:ffc0::/48
|
||||
240e:451:ffe0::/48
|
||||
240e:456:fe00::/48
|
||||
240e:457:fe00::/48
|
||||
240e:469:f400::/48
|
||||
240e:476:febf::/48
|
||||
240e:476:feff::/48
|
||||
240e:604:314::/48
|
||||
240e:604:319::/48
|
||||
240e:60e:8000::/48
|
||||
240e:60e:8001::/48
|
||||
240e:60e:e000::/35
|
||||
240e:615:7000::/40
|
||||
240e:61d:e600::/40
|
||||
240e:628:10::/45
|
||||
240e:62c:1f0::/44
|
||||
240e:62f::/48
|
||||
240e:638:c301::/48
|
||||
240e:638:c303::/48
|
||||
240e:638:f::/48
|
||||
240e:63c:3f00::/44
|
||||
240e:640:178::/48
|
||||
240e:640:179::/48
|
||||
240e:640:17a::/48
|
||||
240e:640:17b::/48
|
||||
240e:640:7201::/48
|
||||
240e:645:1000::/36
|
||||
240e:645:2000::/48
|
||||
240e:645:2010::/48
|
||||
240e:648:1e::/48
|
||||
240e:648:c00f::/48
|
||||
240e:648:c40f::/48
|
||||
240e:648:c80f::/48
|
||||
240e:648:cc0f::/48
|
||||
240e:648:d00f::/48
|
||||
240e:648:d40f::/48
|
||||
240e:648:d80f::/48
|
||||
240e:648:dc0f::/48
|
||||
240e:649:c00f::/48
|
||||
240e:649:c40f::/48
|
||||
240e:649:c80f::/48
|
||||
240e:649:cc0f::/48
|
||||
240e:649:d00f::/48
|
||||
240e:649:d40f::/48
|
||||
240e:649:d80f::/48
|
||||
240e:649:dc0f::/48
|
||||
240e:64e:d00::/44
|
||||
240e:64e:de0::/48
|
||||
240e:64e:de4::/48
|
||||
240e:64e:dec::/48
|
||||
240e:64e:ded::/48
|
||||
240e:650:8000::/38
|
||||
240e:659:f100::/48
|
||||
240e:65f:100::/48
|
||||
240e:65f:800::/48
|
||||
240e:65f:e000::/36
|
||||
240e:669:b000::/40
|
||||
240e:670:cf00::/48
|
||||
240e:670:d000::/36
|
||||
240e:679:1001::/48
|
||||
240e:679:800::/48
|
||||
240e:67c:2a80::/48
|
||||
240e:688:ed00::/48
|
||||
240e:699:7a00::/48
|
||||
240e:699:7a01::/48
|
||||
240e:699:7b00::/48
|
||||
240e:699:7b03::/48
|
||||
240e:699:7b06::/48
|
||||
240e:699:7b07::/48
|
||||
240e:699:7b08::/48
|
||||
240e:699:7b0a::/48
|
||||
240e:699:7b0c::/48
|
||||
240e:699:7b0d::/48
|
||||
240e:699:7b0f::/48
|
||||
240e:699:7b11::/48
|
||||
240e:699:7b12::/48
|
||||
240e:699:bc00::/38
|
||||
240e:6a0:d00::/48
|
||||
240e:6a0:d03::/48
|
||||
240e:6a0:d04::/48
|
||||
240e:6a0:d05::/48
|
||||
240e:6a0:d07::/48
|
||||
240e:6a0:d0a::/48
|
||||
240e:713:f020::/48
|
||||
240e:713:f021::/48
|
||||
240e:733:4c0::/48
|
||||
240e:733:4c1::/48
|
||||
240e:737:2ee0::/48
|
||||
240e:767:f000::/48
|
||||
240e:787:7000::/48
|
||||
240e:787:7001::/48
|
||||
240e:790:fe10::/48
|
||||
240e:790:fe20::/48
|
||||
240e:790:fe30::/48
|
||||
240e:790:fe70::/48
|
||||
240e:790:ff00::/40
|
||||
240e:7b6::/31
|
||||
240e:965:822::/48
|
||||
240e:eb:e800::/37
|
||||
240e:eb:f000::/37
|
||||
240e:f6:8002::/48
|
||||
240e:f6:8003::/48
|
||||
240e:ff:c020::/48
|
||||
240e:ff:c022::/48
|
||||
240e:ff:c024::/48
|
||||
240e:ff:c026::/48
|
||||
240e:ff:c027::/48
|
||||
240e:ff:c02e::/48
|
||||
240e:ff:c032::/48
|
||||
240e:ff:c034::/48
|
||||
240e:ff:c036::/48
|
||||
240e:ff:c038::/48
|
||||
2605:9d80:9003::/48
|
||||
2605:9d80:9013::/48
|
||||
2605:9d80:9023::/48
|
||||
2605:9d80:9033::/48
|
||||
2605:9d80:9042::/48
|
||||
2605:9d80:9071::/48
|
||||
2605:9d80:9092::/48
|
||||
2804:1e48:9002::/48
|
||||
2a04:f580:9010::/48
|
||||
2a04:f580:9012::/48
|
||||
2a04:f580:9013::/48
|
||||
2a04:f580:9020::/48
|
||||
2a04:f580:9040::/48
|
||||
2a04:f580:9050::/48
|
||||
2a04:f580:9060::/48
|
||||
2a04:f580:9070::/48
|
||||
2a04:f580:9210::/48
|
||||
2a04:f580:9212::/48
|
||||
2a04:f580:9213::/48
|
||||
2a04:f580:9220::/48
|
||||
2a04:f580:9230::/48
|
||||
2a04:f580:9240::/48
|
||||
2a04:f580:9250::/48
|
||||
2a04:f580:9260::/48
|
||||
2a04:f580:9270::/48
|
||||
2a04:f580:9280::/48
|
||||
2a04:f580:9290::/48
|
||||
2c0f:f7a8:9041::/48
|
||||
2400:9380:9000
|
||||
2400:9380:9001
|
||||
2400:9380:9002
|
||||
2400:9380:9005
|
||||
2400:9380:9009
|
||||
2400:9380:900a
|
||||
2400:9380:9020
|
||||
2400:9380:9021
|
||||
2400:9380:9030
|
||||
2400:9380:9031
|
||||
2400:9380:9040
|
||||
2400:9380:9041
|
||||
2400:9380:9050
|
||||
2400:9380:9051
|
||||
2400:9380:9060
|
||||
2400:9380:9061
|
||||
2400:9380:9070
|
||||
2400:9380:9071
|
||||
2400:9380:9080
|
||||
2400:9380:9081
|
||||
2400:9380:90a0
|
||||
2400:9380:90a1
|
||||
2400:9380:90b0
|
||||
2400:9380:90b1
|
||||
2400:9380:90b2
|
||||
2400:9380:90b3
|
||||
2400:9380:90b4
|
||||
2400:9380:90b5
|
||||
2400:9380:90b6
|
||||
2400:9380:90b7
|
||||
2400:9380:9100
|
||||
2400:9380:9101
|
||||
2400:9380:9121
|
||||
2400:9380:9201
|
||||
2400:9380:9202
|
||||
2400:9380:9205
|
||||
2400:9380:9220
|
||||
2400:9380:9221
|
||||
2400:9380:9230
|
||||
2400:9380:9231
|
||||
2400:9380:9240
|
||||
2400:9380:9241
|
||||
2400:9380:9250
|
||||
2400:9380:9251
|
||||
2400:9380:9252
|
||||
2400:9380:9260
|
||||
2400:9380:9261
|
||||
2400:9380:9270
|
||||
2400:9380:9271
|
||||
2400:9380:9272
|
||||
2400:9380:9280
|
||||
2400:9380:9281
|
||||
2400:9380:9282
|
||||
2400:9380:92a0
|
||||
2400:9380:92a1
|
||||
2400:9380:92b0
|
||||
2400:9380:92b1
|
||||
2400:9380:92b2
|
||||
2400:9380:92b3
|
||||
2400:9380:92b4
|
||||
2400:9380:92b5
|
||||
2400:9380:92b6
|
||||
2400:9380:92b7
|
||||
240e:182:5401
|
||||
240e:182:5501
|
||||
240e:409:9000
|
||||
240e:409:9001
|
||||
240e:410:ff00
|
||||
240e:411:ff00
|
||||
240e:414
|
||||
240e:43d:fff1
|
||||
240e:43d:fff3
|
||||
240e:43d:fff4
|
||||
240e:43d:fff7
|
||||
240e:440:ac00
|
||||
240e:440:ac01
|
||||
240e:440:ac02
|
||||
240e:441:ac00
|
||||
240e:441:ac01
|
||||
240e:441:ac02
|
||||
240e:445:3f00
|
||||
240e:446:3f00
|
||||
240e:451:bfc0
|
||||
240e:451:bfe0
|
||||
240e:451:ffa0
|
||||
240e:451:ffc0
|
||||
240e:451:ffe0
|
||||
240e:456:fe00
|
||||
240e:457:fe00
|
||||
240e:469:f400
|
||||
240e:476:febf
|
||||
240e:476:feff
|
||||
240e:604:314
|
||||
240e:60e:8000
|
||||
240e:60e:8001
|
||||
240e:615
|
||||
240e:62c
|
||||
240e:638:f
|
||||
240e:63c
|
||||
240e:640:178
|
||||
240e:640:179
|
||||
240e:640:17a
|
||||
240e:640:17b
|
||||
240e:648:1e
|
||||
240e:64e
|
||||
240e:659:f100
|
||||
240e:669
|
||||
240e:699:7a00
|
||||
240e:699:7a01
|
||||
240e:713:f020
|
||||
240e:713:f021
|
||||
240e:733:4c0
|
||||
240e:f6:8002
|
||||
2605:9d80:9003
|
||||
2605:9d80:9013
|
||||
2605:9d80:9023
|
||||
2605:9d80:9033
|
||||
2605:9d80:9042
|
||||
2605:9d80:9052
|
||||
2605:9d80:9061
|
||||
2605:9d80:9071
|
||||
2605:9d80:9092
|
||||
2804:1e48
|
||||
2804:1e48:9001
|
||||
2804:1e48:9002
|
||||
2a04:f580:9000
|
||||
2a04:f580:9001
|
||||
2a04:f580:9002
|
||||
2a04:f580:9010
|
||||
2a04:f580:9012
|
||||
2a04:f580:9013
|
||||
2a04:f580:9020
|
||||
2a04:f580:9030
|
||||
2a04:f580:9040
|
||||
2a04:f580:9050
|
||||
2a04:f580:9060
|
||||
2a04:f580:9070
|
||||
2a04:f580:9080
|
||||
2a04:f580:9090
|
||||
2a04:f580:9200
|
||||
2a04:f580:9201
|
||||
2a04:f580:9202
|
||||
2a04:f580:9210
|
||||
2a04:f580:9212
|
||||
2a04:f580:9213
|
||||
2a04:f580:9220
|
||||
2a04:f580:9230
|
||||
2a04:f580:9240
|
||||
2a04:f580:9250
|
||||
2a04:f580:9260
|
||||
2a04:f580:9270
|
||||
2a04:f580:9280
|
||||
2a04:f580:9290
|
||||
2c0f:f7a8:9010
|
||||
2c0f:f7a8:9011
|
||||
2c0f:f7a8:9020
|
||||
2c0f:f7a8:9041
|
||||
2c0f:f7a8:9210
|
||||
2c0f:f7a8:9211
|
||||
2c0f:f7a8:9220
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as4809.txt",
|
||||
"count": 198,
|
||||
"sha256": "9ff6577616e42d2e8d8d1f3817a639c647b91d636b89d0c1d0145306c7cc4f87",
|
||||
"generated_at": "2026-10-23T03:52:32Z"
|
||||
}
|
||||
1066
bk/prefix/as4837.txt
1066
bk/prefix/as4837.txt
File diff suppressed because it is too large
Load Diff
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as4837.txt",
|
||||
"count": 652,
|
||||
"sha256": "ed2c895a4e65992ce1e5fb1b9454d4bd42e73c6a53b56aa36efdadc2e76f487b",
|
||||
"generated_at": "2026-10-25T03:45:47Z"
|
||||
}
|
||||
@ -1,6 +1,3 @@
|
||||
2001:43f8:1f0
|
||||
2001:7f8:43
|
||||
2001:7fa:0:1
|
||||
2400:8800:1f0e:5f
|
||||
2400:8800:1f11:13
|
||||
2401:cf80:620f:1
|
||||
@ -8,10 +5,3 @@
|
||||
2402:4f00:3000
|
||||
2402:4f00:4000:4
|
||||
2402:4f00:4003
|
||||
2620:107:4008:1c2
|
||||
2620:107:4008:1d9
|
||||
2620:107:4008:bbef
|
||||
2620:107:4008:bd27
|
||||
2620:107:4008:d261
|
||||
2620:107:4008:d270
|
||||
2620:107:4008:d271
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as58453.txt",
|
||||
"count": 17,
|
||||
"sha256": "e7e579da764e9dedf84145b5c4cdc51c4b2841517adbc6edb206896b40710119",
|
||||
"generated_at": "2026-07-20T19:12:53Z"
|
||||
}
|
||||
@ -1,2 +1,2 @@
|
||||
2402:4f00:f000::/36
|
||||
2402:4f00:f000::/48
|
||||
2402:4f00
|
||||
2402:4f00:f000
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as58807.txt",
|
||||
"count": 2,
|
||||
"sha256": "0765c7eefa122984fe69678b60ba99aa605d52601ef4ebf988c4e691e91c5361",
|
||||
"generated_at": "2026-07-20T19:12:53Z"
|
||||
}
|
||||
8524
bk/prefix/as9808.txt
8524
bk/prefix/as9808.txt
File diff suppressed because it is too large
Load Diff
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as9808.txt",
|
||||
"count": 7192,
|
||||
"sha256": "976af5656ac6d0fd87a71dc74518b80ee6e12aefb8c8779b9ea9b77af7e1c2dd",
|
||||
"generated_at": "2026-10-30T04:53:49Z"
|
||||
}
|
||||
@ -1,147 +1,26 @@
|
||||
2408:8120:1::/48
|
||||
2408:8120:2::/48
|
||||
2408:8120:7000::/36
|
||||
2408:8256:226d::/48
|
||||
2408:8256:228b::/48
|
||||
2408:827a:8ff::/48
|
||||
2408:8411:c0c0::/48
|
||||
2408:8411:c0c1::/48
|
||||
2408:8411:c0c2::/48
|
||||
2408:8411:c0c3::/48
|
||||
2408:8411:c0c4::/48
|
||||
2408:8411:c0c5::/48
|
||||
2408:8417:f000::/44
|
||||
2408:8417:f010::/44
|
||||
2408:8417:f020::/44
|
||||
2408:8418:f000::/44
|
||||
2408:8418:f010::/44
|
||||
2408:8418:f020::/44
|
||||
2408:841d:ef20::/44
|
||||
2408:841d:ef30::/44
|
||||
2408:841e:ef10::/44
|
||||
2408:841e:ef20::/44
|
||||
2408:841e:ef30::/44
|
||||
2408:8435:b490::/47
|
||||
2408:8435:b492::/47
|
||||
2408:8435:b494::/47
|
||||
2408:8435:b496::/47
|
||||
2408:8435:b498::/47
|
||||
2408:8435:b49a::/47
|
||||
2408:843e:e010::/48
|
||||
2408:843e:e011::/48
|
||||
2408:843e:e012::/48
|
||||
2408:843e:e080::/48
|
||||
2408:843e:e081::/48
|
||||
2408:843e:e082::/48
|
||||
2408:8441:fe00::/44
|
||||
2408:8441:fe10::/44
|
||||
2408:8441:fe20::/44
|
||||
2408:8441:ff00::/44
|
||||
2408:8441:ff10::/44
|
||||
2408:8441:ff20::/44
|
||||
2408:8444:d500::/44
|
||||
2408:8444:d580::/44
|
||||
2408:8444:d700::/44
|
||||
2408:8444:d780::/44
|
||||
2408:8444:d900::/44
|
||||
2408:8444:d980::/44
|
||||
2408:844c:cb09::/48
|
||||
2408:844c:cb0a::/48
|
||||
2408:844c:cb0b::/48
|
||||
2408:844d:cb09::/48
|
||||
2408:844d:cb0a::/48
|
||||
2408:844d:cb0b::/48
|
||||
2408:844f:d408::/47
|
||||
2408:844f:d40a::/47
|
||||
2408:844f:d40c::/47
|
||||
2408:844f:d500::/47
|
||||
2408:844f:d502::/47
|
||||
2408:844f:d504::/47
|
||||
2408:8462:ff00::/44
|
||||
2408:8462:ff10::/44
|
||||
2408:8462:ff20::/44
|
||||
2408:8463:ff00::/44
|
||||
2408:8463:ff10::/44
|
||||
2408:8463:ff20::/44
|
||||
2408:8469:fc00::/48
|
||||
2408:846a:b600::/44
|
||||
2408:846a:b610::/44
|
||||
2408:846a:b620::/44
|
||||
2408:846b:b600::/44
|
||||
2408:846b:b610::/44
|
||||
2408:846b:b620::/44
|
||||
2408:8471:fa00::/48
|
||||
2408:8471:fa01::/48
|
||||
2408:8471:fa02::/48
|
||||
2408:8471:fb00::/48
|
||||
2408:8471:fb01::/48
|
||||
2408:8471:fb02::/48
|
||||
2408:8474:fb10::/44
|
||||
2408:8474:fb20::/44
|
||||
2408:8474:fb30::/44
|
||||
2408:8474:fc10::/44
|
||||
2408:8474:fc20::/44
|
||||
2408:8474:fc30::/44
|
||||
2408:8478:fb10::/44
|
||||
2408:8478:fb20::/44
|
||||
2408:8478:fb30::/44
|
||||
2408:8478:fc10::/44
|
||||
2408:8478:fc20::/44
|
||||
2408:8478:fc30::/44
|
||||
2408:847a:ff20::/48
|
||||
2408:847a:ff21::/48
|
||||
2408:847a:ff22::/48
|
||||
2408:847a:ff40::/48
|
||||
2408:847a:ff41::/48
|
||||
2408:847a:ff42::/48
|
||||
2408:8610:3bff::/48
|
||||
2408:8614:1f0::/48
|
||||
2408:8614:af0::/48
|
||||
2408:861c:1fff::/48
|
||||
2408:8625:10fb::/48
|
||||
2408:8625:fe::/48
|
||||
2408:8626:f200::/48
|
||||
2408:862b::/48
|
||||
2408:862e:2ff::/48
|
||||
2408:8634:1015::/48
|
||||
2408:8638:116::/48
|
||||
2408:863c:f010::/48
|
||||
2408:8640:f100::/40
|
||||
2408:8649:1a00::/48
|
||||
2408:8649:2a00::/48
|
||||
2408:864c:10ff::/48
|
||||
2408:8651:ff00::/48
|
||||
2408:8652:ff00::/48
|
||||
2408:8656:22cf::/48
|
||||
2408:8656:a52::/48
|
||||
2408:865c:f0::/44
|
||||
2408:8660:100::/48
|
||||
2408:8660:a100::/48
|
||||
2408:8660:ab00::/48
|
||||
2408:8660:b500::/48
|
||||
2408:8660:bf00::/48
|
||||
2408:8666:ff00::/48
|
||||
2408:8666:ff0d::/48
|
||||
2408:866a:a05::/48
|
||||
2408:866c:ff00::/48
|
||||
2408:8670:800::/48
|
||||
2408:8678:1400::/48
|
||||
2408:8756:3efd::/48
|
||||
2408:8a00:c000::/36
|
||||
2408:8a00:d000::/38
|
||||
2408:8a00:d400::/38
|
||||
2408:8a00:e000::/36
|
||||
2408:8a00:f000::/36
|
||||
2408:8a01::/36
|
||||
2408:8a02:b110::/44
|
||||
2408:8a02:b120::/44
|
||||
2408:8a04:8000::/36
|
||||
2408:8a04:e000::/40
|
||||
2408:8a06:100::/48
|
||||
2408:8a06:101::/48
|
||||
2408:8a06:1::/48
|
||||
2408:8a06::/48
|
||||
2408:8a07::/40
|
||||
2408:8a26:ee10::/48
|
||||
2408:8a26:f020::/48
|
||||
2408:8a26:f950::/48
|
||||
2408:8120
|
||||
2408:8120:1
|
||||
2408:8120:2
|
||||
2408:8256:226d
|
||||
2408:8256:228b
|
||||
2408:8610:3bff
|
||||
2408:8614:1f0
|
||||
2408:861c:1fff
|
||||
2408:8625:10fb
|
||||
2408:8626:f200
|
||||
2408:862b
|
||||
2408:862e:2ff
|
||||
2408:8638:116
|
||||
2408:8649:2a00
|
||||
2408:8656:a52
|
||||
2408:8660:100
|
||||
2408:8678:1400
|
||||
2408:8756:3efd
|
||||
2408:8a00
|
||||
2408:8a01
|
||||
2408:8a02
|
||||
2408:8a04
|
||||
2408:8a06
|
||||
2408:8a06:1
|
||||
2408:8a06:100
|
||||
2408:8a06:101
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema": "backtrace.asn-prefixes/v1",
|
||||
"file": "as9929.txt",
|
||||
"count": 147,
|
||||
"sha256": "1a609d504ade062827ebc1f365b390dcfe1129510f50791763eefd8081081401",
|
||||
"generated_at": "2026-10-10T04:56:08Z"
|
||||
}
|
||||
@ -1,296 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ASNPrefixRegistrySchema = "backtrace.asn-prefixes/v1"
|
||||
ASNPrefixRegistryRawBaseURL = "https://raw.githubusercontent.com/oneclickvirt/backtrace/main/bk/prefix"
|
||||
ASNPrefixRegistryCDNBaseURL = "https://cdn.spiritlhl.net/" + ASNPrefixRegistryRawBaseURL
|
||||
)
|
||||
|
||||
var knownPrefixASNs = []string{"AS10099", "AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"}
|
||||
var prefixFragmentPattern = regexp.MustCompile(`^[0-9a-fA-F:]+$`)
|
||||
|
||||
type ASNPrefixRegistrySource struct {
|
||||
Name string
|
||||
Base string
|
||||
ValidateManifest bool
|
||||
}
|
||||
|
||||
type ASNPrefixRegistryLoadResult struct {
|
||||
Prefixes map[string][]string
|
||||
Source string
|
||||
Fallback bool
|
||||
}
|
||||
|
||||
var activeASNPrefixes atomic.Value // stores map[string][]string; maps are immutable after publication
|
||||
var refreshASNPrefixesOnce sync.Once
|
||||
|
||||
//go:embed prefix/*.txt.manifest.json
|
||||
var embeddedPrefixManifestFS embed.FS
|
||||
|
||||
func init() {
|
||||
activeASNPrefixes.Store(cloneASNPrefixes(asnPrefixes))
|
||||
}
|
||||
|
||||
func DefaultASNPrefixRegistrySources() []ASNPrefixRegistrySource {
|
||||
return []ASNPrefixRegistrySource{
|
||||
{Name: "cdn", Base: ASNPrefixRegistryCDNBaseURL, ValidateManifest: true},
|
||||
{Name: "raw", Base: ASNPrefixRegistryRawBaseURL, ValidateManifest: true},
|
||||
}
|
||||
}
|
||||
|
||||
func LoadASNPrefixRegistry(ctx context.Context, client *http.Client, sources []ASNPrefixRegistrySource) (ASNPrefixRegistryLoadResult, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 6 * time.Second}
|
||||
}
|
||||
var lastErr error
|
||||
for index, source := range sources {
|
||||
prefixes, err := fetchASNPrefixSource(ctx, client, source)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("load %s ASN prefixes: %w", source.Name, err)
|
||||
continue
|
||||
}
|
||||
return ASNPrefixRegistryLoadResult{Prefixes: prefixes, Source: source.Name, Fallback: index > 0}, nil
|
||||
}
|
||||
embedded := cloneASNPrefixes(asnPrefixes)
|
||||
if len(embedded) > 0 {
|
||||
if err := validateEmbeddedPrefixManifests(); err != nil {
|
||||
return ASNPrefixRegistryLoadResult{}, fmt.Errorf("validate embedded ASN prefix manifests: %w", err)
|
||||
}
|
||||
return ASNPrefixRegistryLoadResult{Prefixes: embedded, Source: "embedded", Fallback: true}, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no ASN prefix registry sources configured")
|
||||
}
|
||||
return ASNPrefixRegistryLoadResult{}, lastErr
|
||||
}
|
||||
|
||||
func RefreshASNPrefixRegistry(ctx context.Context, client *http.Client, sources []ASNPrefixRegistrySource) (ASNPrefixRegistryLoadResult, error) {
|
||||
loaded, err := LoadASNPrefixRegistry(ctx, client, sources)
|
||||
if err != nil {
|
||||
return ASNPrefixRegistryLoadResult{}, err
|
||||
}
|
||||
activeASNPrefixes.Store(cloneASNPrefixes(loaded.Prefixes))
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
// StartASNPrefixRefresh performs one bounded background refresh. It never
|
||||
// delays legacy tracing and the embedded map remains active on any error.
|
||||
func StartASNPrefixRefresh() {
|
||||
refreshASNPrefixesOnce.Do(func() {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_, _ = RefreshASNPrefixRegistry(ctx, nil, DefaultASNPrefixRegistrySources())
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
type prefixManifest struct {
|
||||
Schema string `json:"schema"`
|
||||
File string `json:"file"`
|
||||
Count int `json:"count"`
|
||||
SHA256 string `json:"sha256"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
func fetchASNPrefixSource(ctx context.Context, client *http.Client, source ASNPrefixRegistrySource) (map[string][]string, error) {
|
||||
base := source.Base
|
||||
parsed, err := url.Parse(strings.TrimRight(base, "/"))
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
|
||||
return nil, errors.New("invalid ASN prefix source")
|
||||
}
|
||||
type prefixResult struct {
|
||||
asn string
|
||||
values []string
|
||||
err error
|
||||
}
|
||||
results := make(chan prefixResult, len(knownPrefixASNs))
|
||||
for _, asn := range knownPrefixASNs {
|
||||
go func(asn string) {
|
||||
endpoint := strings.TrimRight(base, "/") + "/" + strings.ToLower(asn) + ".txt"
|
||||
var manifestData []byte
|
||||
if source.ValidateManifest {
|
||||
fetchedManifest, fetchErr := fetchPrefixResource(ctx, client, endpoint+".manifest.json")
|
||||
if fetchErr != nil {
|
||||
results <- prefixResult{asn: asn, err: fmt.Errorf("manifest: %w", fetchErr)}
|
||||
return
|
||||
}
|
||||
manifestData = fetchedManifest
|
||||
}
|
||||
data, err := fetchPrefixResource(ctx, client, endpoint)
|
||||
if err != nil {
|
||||
results <- prefixResult{asn: asn, err: err}
|
||||
return
|
||||
}
|
||||
if source.ValidateManifest {
|
||||
if err := validatePrefixManifest(manifestData, data, strings.ToLower(asn)+".txt"); err != nil {
|
||||
results <- prefixResult{asn: asn, err: err}
|
||||
return
|
||||
}
|
||||
}
|
||||
values, parseErr := parseASNPrefixLines(data)
|
||||
results <- prefixResult{asn: asn, values: values, err: parseErr}
|
||||
}(asn)
|
||||
}
|
||||
result := make(map[string][]string, len(knownPrefixASNs))
|
||||
for range knownPrefixASNs {
|
||||
loaded := <-results
|
||||
if loaded.err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", loaded.asn, loaded.err)
|
||||
}
|
||||
result[loaded.asn] = loaded.values
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchPrefixResource(ctx context.Context, client *http.Client, endpoint string) ([]byte, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "text/plain, application/json")
|
||||
request.Header.Set("User-Agent", "oneclickvirt-backtrace/asn-prefix-registry-v1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(response.Body, 4<<20))
|
||||
}
|
||||
|
||||
func validatePrefixManifest(data, snapshot []byte, expectedFile string) error {
|
||||
var manifest prefixManifest
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return errors.New("manifest contains trailing JSON")
|
||||
}
|
||||
if manifest.Schema != ASNPrefixRegistrySchema || manifest.File != expectedFile || manifest.Count < 1 {
|
||||
return errors.New("manifest schema, file, or count is invalid")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, manifest.GeneratedAt); err != nil {
|
||||
return fmt.Errorf("manifest generated_at is invalid: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(snapshot)
|
||||
if !strings.EqualFold(manifest.SHA256, hex.EncodeToString(hash[:])) {
|
||||
return errors.New("manifest SHA-256 does not match snapshot")
|
||||
}
|
||||
values, err := parseASNPrefixLines(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != manifest.Count {
|
||||
return fmt.Errorf("manifest count %d does not match snapshot count %d", manifest.Count, len(values))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEmbeddedPrefixManifests() error {
|
||||
for _, asn := range knownPrefixASNs {
|
||||
name := strings.ToLower(asn) + ".txt"
|
||||
manifest, err := embeddedPrefixManifestFS.ReadFile("prefix/" + name + ".manifest.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var snapshot []byte
|
||||
switch asn {
|
||||
case "AS23764":
|
||||
snapshot = []byte(as23764Data)
|
||||
case "AS10099":
|
||||
snapshot = []byte(as10099Data)
|
||||
case "AS4134":
|
||||
snapshot = []byte(as4134Data)
|
||||
case "AS4809":
|
||||
snapshot = []byte(as4809Data)
|
||||
case "AS4837":
|
||||
snapshot = []byte(as4837Data)
|
||||
case "AS58453":
|
||||
snapshot = []byte(as58453Data)
|
||||
case "AS58807":
|
||||
snapshot = []byte(as58807Data)
|
||||
case "AS9808":
|
||||
snapshot = []byte(as9808Data)
|
||||
case "AS9929":
|
||||
snapshot = []byte(as9929Data)
|
||||
}
|
||||
if err := validatePrefixManifest(manifest, snapshot, name); err != nil {
|
||||
return fmt.Errorf("%s: %w", asn, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseASNPrefixLines(data []byte) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
values := make([]string, 0)
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil || !prefix.Addr().Is6() {
|
||||
return nil, fmt.Errorf("invalid IPv6 prefix %q", value)
|
||||
}
|
||||
} else if !prefixFragmentPattern.MatchString(value) {
|
||||
return nil, fmt.Errorf("invalid prefix fragment %q", value)
|
||||
}
|
||||
value = strings.ToLower(value)
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
values = append(values, value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New("prefix file is empty")
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func cloneASNPrefixes(input map[string][]string) map[string][]string {
|
||||
result := make(map[string][]string, len(input))
|
||||
for asn, values := range input {
|
||||
result[asn] = append([]string(nil), values...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func currentASNPrefixes() map[string][]string {
|
||||
if value := activeASNPrefixes.Load(); value != nil {
|
||||
return value.(map[string][]string)
|
||||
}
|
||||
return asnPrefixes
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseASNPrefixLinesNormalizesAndDeduplicates(t *testing.T) {
|
||||
values, err := parseASNPrefixLines([]byte("2409:8000\n2409:8000\n2401:db8::/32\n"))
|
||||
if err != nil || len(values) != 2 || values[0] != "2401:db8::/32" {
|
||||
t.Fatalf("unexpected prefixes: %#v, %v", values, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadASNPrefixRegistryUsesRawAfterCDNFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
path := strings.TrimPrefix(request.URL.Path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 2 {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
base, file := parts[0], parts[1]
|
||||
snapshot := []byte("2409:8000\n")
|
||||
if base == "bad" && file == "as23764.txt.manifest.json" {
|
||||
_, _ = writer.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(file, ".manifest.json") {
|
||||
hash := sha256.Sum256(snapshot)
|
||||
manifest, _ := json.Marshal(prefixManifest{Schema: ASNPrefixRegistrySchema, File: strings.TrimSuffix(file, ".manifest.json"), Count: 1, SHA256: hex.EncodeToString(hash[:]), GeneratedAt: "2026-01-01T00:00:00Z"})
|
||||
_, _ = writer.Write(manifest)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write(snapshot)
|
||||
}))
|
||||
defer server.Close()
|
||||
sources := []ASNPrefixRegistrySource{{Name: "cdn", Base: server.URL + "/bad", ValidateManifest: true}, {Name: "raw", Base: server.URL + "/good", ValidateManifest: true}}
|
||||
loaded, err := LoadASNPrefixRegistry(context.Background(), server.Client(), sources)
|
||||
if err != nil || loaded.Source != "raw" || !loaded.Fallback || len(loaded.Prefixes) != len(knownPrefixASNs) {
|
||||
t.Fatalf("unexpected load: %+v, %v", loaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePrefixManifestRejectsHashAndCountMismatch(t *testing.T) {
|
||||
snapshot := []byte("2409:8000\n")
|
||||
manifest := prefixManifest{Schema: ASNPrefixRegistrySchema, File: "as23764.txt", Count: 2, SHA256: strings.Repeat("0", 64), GeneratedAt: "2026-01-01T00:00:00Z"}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validatePrefixManifest(data, snapshot, "as23764.txt"); err == nil {
|
||||
t.Fatal("invalid manifest unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmbeddedPrefixManifests(t *testing.T) {
|
||||
if err := validateEmbeddedPrefixManifests(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedManifestFilesArePresent(t *testing.T) {
|
||||
for _, asn := range knownPrefixASNs {
|
||||
name := strings.ToLower(asn) + ".txt.manifest.json"
|
||||
if _, err := embeddedPrefixManifestFS.ReadFile(filepath.Join("prefix", name)); err != nil {
|
||||
t.Fatalf("missing embedded manifest %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadASNPrefixRegistryEmbeddedFallback(t *testing.T) {
|
||||
loaded, err := LoadASNPrefixRegistry(context.Background(), nil, nil)
|
||||
if err != nil || loaded.Source != "embedded" || !loaded.Fallback || len(loaded.Prefixes) == 0 {
|
||||
t.Fatalf("unexpected embedded load: %+v, %v", loaded, err)
|
||||
}
|
||||
}
|
||||
@ -1,238 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import "strings"
|
||||
|
||||
// RouteHopEvidence is the ordered ASN evidence observed at one responding hop.
|
||||
// A hop can contain more than one ASN when repeated traces take different paths.
|
||||
type RouteHopEvidence struct {
|
||||
Distance int `json:"distance"`
|
||||
ASNs []string `json:"asns,omitempty"`
|
||||
}
|
||||
|
||||
// RouteClassification is a conservative classification of a China-carrier
|
||||
// return route. Code and Confidence are stable machine-readable values; Label
|
||||
// preserves the compact legacy display used by backtrace and ecs.
|
||||
type RouteClassification struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Confidence string `json:"confidence"`
|
||||
Rank int `json:"rank"`
|
||||
Evidence string `json:"evidence"`
|
||||
}
|
||||
|
||||
const (
|
||||
routeConfidenceConfirmed = "confirmed"
|
||||
routeConfidenceMixed = "mixed"
|
||||
routeConfidenceInconclusive = "inconclusive"
|
||||
)
|
||||
|
||||
// ClassifyReturnRoute classifies ordered return-route evidence. Premium-route
|
||||
// claims require enough evidence to distinguish a backbone segment from a
|
||||
// single destination-network delivery hop.
|
||||
func ClassifyReturnRoute(carrier string, hops []RouteHopEvidence) RouteClassification {
|
||||
carrier = normalizeCarrier(carrier)
|
||||
switch carrier {
|
||||
case "CT":
|
||||
return classifyTelecom(hops)
|
||||
case "CU":
|
||||
return classifyUnicom(hops)
|
||||
case "CM":
|
||||
return classifyMobile(hops)
|
||||
default:
|
||||
return inconclusiveClassification("unknown_carrier", "线路证据不足", "unknown carrier")
|
||||
}
|
||||
}
|
||||
|
||||
func classifyTelecom(hops []RouteHopEvidence) RouteClassification {
|
||||
cn2Index, cn2Hops := routeASNPosition(hops, "AS4809")
|
||||
ct163Index, ct163Hops := routeASNPosition(hops, "AS4134")
|
||||
ctgIndex, _ := routeASNPosition(hops, "AS23764")
|
||||
if cn2Index >= 0 {
|
||||
if cn2Hops < 2 {
|
||||
return RouteClassification{
|
||||
Code: "ct_cn2_mixed", Label: "电信CN2混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3,
|
||||
Evidence: "only one AS4809 hop; CN2 GIA is not confirmed",
|
||||
}
|
||||
}
|
||||
if ct163Index < 0 || (cn2Index < ct163Index && ct163Hops <= 1) {
|
||||
return RouteClassification{
|
||||
Code: "ct_cn2_gia", Label: "电信CN2GIA [精品线路]", Confidence: routeConfidenceConfirmed, Rank: 5,
|
||||
Evidence: "at least two AS4809 hops precede at most one AS4134 delivery hop",
|
||||
}
|
||||
}
|
||||
if cn2Index < ct163Index {
|
||||
return RouteClassification{
|
||||
Code: "ct_cn2_mixed", Label: "电信CN2混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3,
|
||||
Evidence: "AS4809 is followed by multiple AS4134 backbone hops",
|
||||
}
|
||||
}
|
||||
return RouteClassification{
|
||||
Code: "ct_cn2_gt", Label: "电信CN2GT [优质线路]", Confidence: routeConfidenceMixed, Rank: 3,
|
||||
Evidence: "AS4134 appears before the AS4809 segment",
|
||||
}
|
||||
}
|
||||
if ctgIndex >= 0 {
|
||||
return RouteClassification{
|
||||
Code: "ct_ctgnet", Label: "电信CTGNET [精品线路]", Confidence: routeConfidenceConfirmed, Rank: 4,
|
||||
Evidence: "AS23764 is present",
|
||||
}
|
||||
}
|
||||
if ct163Index >= 0 {
|
||||
if ct163Hops <= 1 {
|
||||
return inconclusiveClassification("ct_destination_only", "仅见电信目的网", "only one AS4134 hop")
|
||||
}
|
||||
return RouteClassification{
|
||||
Code: "ct_163", Label: "电信163 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2,
|
||||
Evidence: "multiple AS4134 hops are present without premium backbone evidence",
|
||||
}
|
||||
}
|
||||
return inconclusiveClassification("ct_unknown", "未见电信骨干", "AS4809, AS23764, and AS4134 are absent")
|
||||
}
|
||||
|
||||
func classifyUnicom(hops []RouteHopEvidence) RouteClassification {
|
||||
cu9929Index, _ := routeASNPosition(hops, "AS9929")
|
||||
cugIndex, _ := routeASNPosition(hops, "AS10099")
|
||||
cu4837Index, cu4837Hops := routeASNPosition(hops, "AS4837")
|
||||
if cu9929Index >= 0 {
|
||||
if cu4837Index >= 0 && cu4837Index < cu9929Index {
|
||||
return RouteClassification{
|
||||
Code: "cu_9929_mixed", Label: "联通9929混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3,
|
||||
Evidence: "AS4837 appears before the AS9929 segment",
|
||||
}
|
||||
}
|
||||
return RouteClassification{
|
||||
Code: "cu_9929", Label: "联通9929 [优质线路]", Confidence: routeConfidenceConfirmed, Rank: 5,
|
||||
Evidence: "AS9929 is present without an earlier AS4837 segment",
|
||||
}
|
||||
}
|
||||
if cugIndex >= 0 {
|
||||
return RouteClassification{
|
||||
Code: "cu_cug", Label: "联通CUG [优质线路]", Confidence: routeConfidenceConfirmed, Rank: 3,
|
||||
Evidence: "AS10099 is present without AS9929",
|
||||
}
|
||||
}
|
||||
if cu4837Index >= 0 {
|
||||
if cu4837Hops <= 1 {
|
||||
return inconclusiveClassification("cu_destination_only", "仅见联通目的网", "only one AS4837 hop")
|
||||
}
|
||||
return RouteClassification{
|
||||
Code: "cu_4837", Label: "联通4837 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2,
|
||||
Evidence: "multiple AS4837 hops are present without premium backbone evidence",
|
||||
}
|
||||
}
|
||||
return inconclusiveClassification("cu_unknown", "未见联通骨干", "AS9929, AS10099, and AS4837 are absent")
|
||||
}
|
||||
|
||||
func classifyMobile(hops []RouteHopEvidence) RouteClassification {
|
||||
cmin2Index, _ := routeASNPosition(hops, "AS58807")
|
||||
cmiIndex, _ := routeASNPosition(hops, "AS58453")
|
||||
cmnetIndex, _ := routeASNPosition(hops, "AS9808")
|
||||
if cmin2Index >= 0 {
|
||||
if cmiIndex >= 0 && cmiIndex < cmin2Index {
|
||||
return RouteClassification{
|
||||
Code: "cm_cmin2_mixed", Label: "移动CMIN2混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3,
|
||||
Evidence: "AS58453 appears before the AS58807 segment",
|
||||
}
|
||||
}
|
||||
return RouteClassification{
|
||||
Code: "cm_cmin2", Label: "移动CMIN2 [精品线路]", Confidence: routeConfidenceConfirmed, Rank: 5,
|
||||
Evidence: "AS58807 is present without an earlier AS58453 segment",
|
||||
}
|
||||
}
|
||||
if cmiIndex >= 0 {
|
||||
return RouteClassification{
|
||||
Code: "cm_cmi", Label: "移动CMI [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2,
|
||||
Evidence: "AS58453 is present without CMIN2 evidence",
|
||||
}
|
||||
}
|
||||
if cmnetIndex >= 0 {
|
||||
return RouteClassification{
|
||||
Code: "cm_cmnet", Label: "移动CMNET [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2,
|
||||
Evidence: "AS9808 is present without international premium backbone evidence",
|
||||
}
|
||||
}
|
||||
return inconclusiveClassification("cm_unknown", "未见移动骨干", "AS58807, AS58453, and AS9808 are absent")
|
||||
}
|
||||
|
||||
// combineRouteClassifications keeps useful results when one attempt is
|
||||
// inconclusive, but downgrades conflicting confirmed paths to a mixed result.
|
||||
func combineRouteClassifications(carrier string, values []RouteClassification) RouteClassification {
|
||||
known := make([]RouteClassification, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value.Confidence != routeConfidenceInconclusive {
|
||||
known = append(known, value)
|
||||
}
|
||||
}
|
||||
if len(known) == 0 {
|
||||
if len(values) > 0 {
|
||||
return values[0]
|
||||
}
|
||||
return inconclusiveClassification(strings.ToLower(normalizeCarrier(carrier))+"_unknown", "线路证据不足", "no classified route evidence")
|
||||
}
|
||||
best := known[0]
|
||||
distinct := map[string]struct{}{best.Code: {}}
|
||||
for _, value := range known[1:] {
|
||||
distinct[value.Code] = struct{}{}
|
||||
if value.Rank > best.Rank {
|
||||
best = value
|
||||
}
|
||||
}
|
||||
if len(distinct) == 1 {
|
||||
return best
|
||||
}
|
||||
label := map[string]string{
|
||||
"CT": "电信动态混合 [优质线路]",
|
||||
"CU": "联通动态混合 [优质线路]",
|
||||
"CM": "移动动态混合 [优质线路]",
|
||||
}[normalizeCarrier(carrier)]
|
||||
if label == "" {
|
||||
label = "动态混合线路"
|
||||
}
|
||||
return RouteClassification{
|
||||
Code: strings.ToLower(normalizeCarrier(carrier)) + "_dynamic_mixed", Label: label,
|
||||
Confidence: routeConfidenceMixed, Rank: 3,
|
||||
Evidence: "successful trace attempts observed conflicting backbone classes",
|
||||
}
|
||||
}
|
||||
|
||||
func routeASNPosition(hops []RouteHopEvidence, target string) (int, int) {
|
||||
first := -1
|
||||
count := 0
|
||||
for index, hop := range hops {
|
||||
matched := false
|
||||
for _, asn := range hop.ASNs {
|
||||
if strings.EqualFold(strings.TrimSpace(asn), target) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
if first < 0 {
|
||||
first = index
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
return first, count
|
||||
}
|
||||
|
||||
func normalizeCarrier(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "CT", "TELECOM", "电信":
|
||||
return "CT"
|
||||
case "CU", "UNICOM", "联通":
|
||||
return "CU"
|
||||
case "CM", "CMCC", "MOBILE", "移动":
|
||||
return "CM"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func inconclusiveClassification(code, label, evidence string) RouteClassification {
|
||||
return RouteClassification{
|
||||
Code: code, Label: label, Confidence: routeConfidenceInconclusive,
|
||||
Rank: 0, Evidence: evidence,
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import "testing"
|
||||
|
||||
func routeFixture(asns ...string) []RouteHopEvidence {
|
||||
hops := make([]RouteHopEvidence, 0, len(asns))
|
||||
for index, asn := range asns {
|
||||
hops = append(hops, RouteHopEvidence{Distance: index + 1, ASNs: []string{asn}})
|
||||
}
|
||||
return hops
|
||||
}
|
||||
|
||||
func TestClassifyTelecomRequiresOrderedRepeatedEvidence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
asns []string
|
||||
code string
|
||||
rank int
|
||||
}{
|
||||
{name: "single CN2 hop", asns: []string{"AS4809", "AS4134"}, code: "ct_cn2_mixed", rank: 3},
|
||||
{name: "GIA with delivery hop", asns: []string{"AS4809", "AS4809", "AS4134"}, code: "ct_cn2_gia", rank: 5},
|
||||
{name: "CN2 then 163 backbone", asns: []string{"AS4809", "AS4809", "AS4134", "AS4134"}, code: "ct_cn2_mixed", rank: 3},
|
||||
{name: "163 before CN2", asns: []string{"AS4134", "AS4809", "AS4809"}, code: "ct_cn2_gt", rank: 3},
|
||||
{name: "single destination hop", asns: []string{"AS4134"}, code: "ct_destination_only", rank: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := ClassifyReturnRoute("CT", routeFixture(test.asns...))
|
||||
if result.Code != test.code || result.Rank != test.rank {
|
||||
t.Fatalf("ClassifyReturnRoute() = %+v, want code=%s rank=%d", result, test.code, test.rank)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyUnicomAddsCUGAndProtectsDestinationHop(t *testing.T) {
|
||||
tests := []struct {
|
||||
asns []string
|
||||
code string
|
||||
}{
|
||||
{asns: []string{"AS9929", "AS9929", "AS4837"}, code: "cu_9929"},
|
||||
{asns: []string{"AS4837", "AS9929"}, code: "cu_9929_mixed"},
|
||||
{asns: []string{"AS10099"}, code: "cu_cug"},
|
||||
{asns: []string{"AS4837"}, code: "cu_destination_only"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := ClassifyReturnRoute("CU", routeFixture(test.asns...))
|
||||
if result.Code != test.code {
|
||||
t.Fatalf("ClassifyReturnRoute(%v) = %+v, want %s", test.asns, result, test.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMobileUsesOrderedCMIN2Evidence(t *testing.T) {
|
||||
if result := ClassifyReturnRoute("CM", routeFixture("AS58807", "AS9808")); result.Code != "cm_cmin2" {
|
||||
t.Fatalf("CMIN2 route = %+v", result)
|
||||
}
|
||||
if result := ClassifyReturnRoute("CM", routeFixture("AS58453", "AS58807")); result.Code != "cm_cmin2_mixed" {
|
||||
t.Fatalf("mixed CMIN2 route = %+v", result)
|
||||
}
|
||||
if result := ClassifyReturnRoute("CM", routeFixture("AS9808")); result.Code != "cm_cmnet" {
|
||||
t.Fatalf("CMNET route = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRouteClassificationsDowngradesDynamicDisagreement(t *testing.T) {
|
||||
result := combineRouteClassifications("CU", []RouteClassification{
|
||||
ClassifyReturnRoute("CU", routeFixture("AS9929")),
|
||||
ClassifyReturnRoute("CU", routeFixture("AS4837", "AS4837")),
|
||||
})
|
||||
if result.Code != "cu_dynamic_mixed" || result.Confidence != routeConfidenceMixed || result.Rank != 3 {
|
||||
t.Fatalf("combined route = %+v", result)
|
||||
}
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
. "github.com/oneclickvirt/defaultset"
|
||||
)
|
||||
|
||||
// RenderRouteReport preserves the original compact one-target-per-line style.
|
||||
// Detailed hop statistics remain available in RouteReport JSON instead of
|
||||
// expanding the terminal section.
|
||||
func RenderRouteReport(report RouteReport) string {
|
||||
var builder strings.Builder
|
||||
for _, target := range report.Targets {
|
||||
var rendered string
|
||||
switch {
|
||||
case target.Status != RouteProbeAvailable:
|
||||
rendered = Red("检测不到回程路由节点的IP地址")
|
||||
default:
|
||||
rendered = renderLegacyRouteLabels(target)
|
||||
}
|
||||
addressWidth := 15
|
||||
if target.Target.IPVersion == "v6" {
|
||||
addressWidth = 24
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("%v %-*s %v\n", target.Target.Name, addressWidth, target.Target.Address, rendered))
|
||||
}
|
||||
return strings.TrimSuffix(builder.String(), "\n")
|
||||
}
|
||||
|
||||
// renderLegacyRouteLabels keeps the original terminal contract: every known
|
||||
// carrier ASN observed in the trace remains visible. The conservative
|
||||
// Classification is retained for JSON consumers, but its evidence-oriented
|
||||
// fallback labels must not replace the classic human-readable route result.
|
||||
func renderLegacyRouteLabels(target RouteTargetReport) string {
|
||||
asns := uniqueStrings(target.ObservedASNs)
|
||||
hasAS4134 := containsString(asns, "AS4134")
|
||||
hasAS4809 := containsString(asns, "AS4809")
|
||||
|
||||
ordered := make([]string, 0, len(asns)+1)
|
||||
if hasAS4809 {
|
||||
if hasAS4134 {
|
||||
ordered = append(ordered, "AS4809b")
|
||||
} else {
|
||||
ordered = append(ordered, "AS4809a")
|
||||
}
|
||||
}
|
||||
ordered = append(ordered, asns...)
|
||||
|
||||
seenLabels := make(map[string]struct{})
|
||||
labels := make([]string, 0, len(ordered))
|
||||
for _, asn := range ordered {
|
||||
if asn == "AS4809" {
|
||||
continue
|
||||
}
|
||||
label := model.M[asn]
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenLabels[label]; exists {
|
||||
continue
|
||||
}
|
||||
seenLabels[label] = struct{}{}
|
||||
switch asn {
|
||||
case "AS9929", "AS4809a", "AS23764":
|
||||
labels = append(labels, DarkGreen(label))
|
||||
case "AS4809b", "AS58807":
|
||||
labels = append(labels, Green(label))
|
||||
default:
|
||||
labels = append(labels, White(label))
|
||||
}
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
return strings.Join(labels, " ")
|
||||
}
|
||||
|
||||
// Reports produced by older API clients may contain only Classification.
|
||||
if target.Classification.Confidence != routeConfidenceInconclusive && target.Classification.Label != "" {
|
||||
switch {
|
||||
case target.Classification.Rank >= 4:
|
||||
return DarkGreen(target.Classification.Label)
|
||||
case target.Classification.Rank == 3:
|
||||
return Green(target.Classification.Label)
|
||||
default:
|
||||
return White(target.Classification.Label)
|
||||
}
|
||||
}
|
||||
return Red("检测不到已知线路的ASN")
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -1,441 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
)
|
||||
|
||||
const RouteReportSchema = "backtrace.routes/v1"
|
||||
|
||||
type RouteProbeStatus string
|
||||
|
||||
const (
|
||||
RouteProbeAvailable RouteProbeStatus = "available"
|
||||
RouteProbeUnavailable RouteProbeStatus = "unavailable"
|
||||
RouteProbeTimeout RouteProbeStatus = "timeout"
|
||||
RouteProbeCanceled RouteProbeStatus = "canceled"
|
||||
)
|
||||
|
||||
// RouteTarget identifies one carrier route without coupling the runner to the
|
||||
// built-in target registry. This also makes offline fixture tests possible.
|
||||
type RouteTarget struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
IPVersion string `json:"ip_version"`
|
||||
Carrier string `json:"carrier"`
|
||||
}
|
||||
|
||||
type RouteLatencyStats struct {
|
||||
Samples int `json:"samples"`
|
||||
MinMS float64 `json:"min_ms"`
|
||||
AvgMS float64 `json:"avg_ms"`
|
||||
P50MS float64 `json:"p50_ms"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
MaxMS float64 `json:"max_ms"`
|
||||
JitterMS float64 `json:"jitter_ms"`
|
||||
}
|
||||
|
||||
type RouteTargetReport struct {
|
||||
Target RouteTarget `json:"target"`
|
||||
Status RouteProbeStatus `json:"status"`
|
||||
Protocol string `json:"protocol"`
|
||||
Attempts int `json:"attempts"`
|
||||
SuccessfulAttempts int `json:"successful_attempts"`
|
||||
ValidHops int `json:"valid_hops"`
|
||||
TargetReached bool `json:"target_reached"`
|
||||
Fallback bool `json:"fallback"`
|
||||
ObservedASNs []string `json:"observed_asns,omitempty"`
|
||||
Classification RouteClassification `json:"classification"`
|
||||
Latency RouteLatencyStats `json:"hop_rtt"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type RouteReport struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Targets []RouteTargetReport `json:"targets"`
|
||||
}
|
||||
|
||||
type TraceFunc func(context.Context, net.IP) ([]*Hop, error)
|
||||
type AlternativeTargetFunc func(RouteTarget) []string
|
||||
|
||||
type RouteReportConfig struct {
|
||||
EnableIPv6 bool
|
||||
Attempts int
|
||||
Timeout time.Duration
|
||||
Targets []RouteTarget
|
||||
Trace TraceFunc
|
||||
AlternativeTarget AlternativeTargetFunc
|
||||
}
|
||||
|
||||
type routeAttempt struct {
|
||||
hops []*Hop
|
||||
targetIP net.IP
|
||||
fallback bool
|
||||
err error
|
||||
}
|
||||
|
||||
// RunRouteReport executes the built-in China carrier return-route matrix and
|
||||
// returns deterministic structured results. ICMP traceroute response rates are
|
||||
// route evidence only and must not be interpreted as application packet loss.
|
||||
func RunRouteReport(ctx context.Context, config RouteReportConfig) RouteReport {
|
||||
started := time.Now()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if config.Attempts <= 0 {
|
||||
config.Attempts = 3
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 10 * time.Second
|
||||
}
|
||||
if config.Trace == nil {
|
||||
config.Trace = TraceContext
|
||||
}
|
||||
usesDefaultAlternatives := config.AlternativeTarget == nil
|
||||
if len(config.Targets) == 0 {
|
||||
config.Targets = defaultRouteTargets(config.EnableIPv6)
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, config.Timeout)
|
||||
defer cancel()
|
||||
StartASNPrefixRefresh()
|
||||
if usesDefaultAlternatives {
|
||||
refreshDone := make(chan struct{})
|
||||
go func() {
|
||||
refreshAlternativeTargets(runCtx)
|
||||
close(refreshDone)
|
||||
}()
|
||||
config.AlternativeTarget = func(target RouteTarget) []string {
|
||||
select {
|
||||
case <-refreshDone:
|
||||
return defaultAlternativeTargets(target)
|
||||
case <-runCtx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
reports := make([]RouteTargetReport, len(config.Targets))
|
||||
type indexedReport struct {
|
||||
index int
|
||||
report RouteTargetReport
|
||||
}
|
||||
results := make(chan indexedReport, len(config.Targets))
|
||||
for index, target := range config.Targets {
|
||||
go func(index int, target RouteTarget) {
|
||||
results <- indexedReport{index: index, report: runRouteTarget(runCtx, target, config)}
|
||||
}(index, target)
|
||||
}
|
||||
received := make([]bool, len(config.Targets))
|
||||
receivedCount := 0
|
||||
for receivedCount < len(config.Targets) {
|
||||
select {
|
||||
case result := <-results:
|
||||
reports[result.index] = result.report
|
||||
if !received[result.index] {
|
||||
received[result.index] = true
|
||||
receivedCount++
|
||||
}
|
||||
case <-runCtx.Done():
|
||||
for index, target := range config.Targets {
|
||||
if received[index] {
|
||||
continue
|
||||
}
|
||||
reports[index] = canceledRouteTarget(target, config.Attempts, runCtx.Err())
|
||||
}
|
||||
receivedCount = len(config.Targets)
|
||||
}
|
||||
}
|
||||
return RouteReport{
|
||||
SchemaVersion: RouteReportSchema,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
DurationMS: time.Since(started).Milliseconds(),
|
||||
Targets: reports,
|
||||
}
|
||||
}
|
||||
|
||||
func runRouteTarget(ctx context.Context, target RouteTarget, config RouteReportConfig) RouteTargetReport {
|
||||
report := RouteTargetReport{
|
||||
Target: target, Status: RouteProbeUnavailable, Protocol: "icmp",
|
||||
Attempts: config.Attempts,
|
||||
Classification: inconclusiveClassification(strings.ToLower(normalizeCarrier(target.Carrier))+"_unknown", "线路证据不足", "no responding trace attempts"),
|
||||
}
|
||||
attempts := make(chan routeAttempt, config.Attempts)
|
||||
for attempt := 0; attempt < config.Attempts; attempt++ {
|
||||
go func() {
|
||||
attempts <- safeExecuteRouteAttempt(ctx, target, config.Trace, config.AlternativeTarget)
|
||||
}()
|
||||
}
|
||||
successful := make([]routeAttempt, 0, config.Attempts)
|
||||
for attempt := 0; attempt < config.Attempts; attempt++ {
|
||||
select {
|
||||
case result := <-attempts:
|
||||
if result.err == nil && len(result.hops) > 0 {
|
||||
successful = append(successful, result)
|
||||
report.Fallback = report.Fallback || result.fallback
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return canceledRouteTarget(target, config.Attempts, ctx.Err())
|
||||
}
|
||||
}
|
||||
if len(successful) == 0 {
|
||||
return report
|
||||
}
|
||||
report.Status = RouteProbeAvailable
|
||||
report.SuccessfulAttempts = len(successful)
|
||||
allHops := make([][]*Hop, 0, len(successful))
|
||||
classifications := make([]RouteClassification, 0, len(successful)+1)
|
||||
latencies := make([]float64, 0)
|
||||
for _, attempt := range successful {
|
||||
allHops = append(allHops, attempt.hops)
|
||||
evidence := routeEvidenceFromHops(attempt.hops)
|
||||
classifications = append(classifications, ClassifyReturnRoute(target.Carrier, evidence))
|
||||
latencies = append(latencies, routeRTTMilliseconds(attempt.hops)...)
|
||||
if routeReachedTarget(attempt.hops, attempt.targetIP) {
|
||||
report.TargetReached = true
|
||||
}
|
||||
}
|
||||
merged := mergeHops(allHops)
|
||||
mergedEvidence := routeEvidenceFromHops(merged)
|
||||
classifications = append(classifications, ClassifyReturnRoute(target.Carrier, mergedEvidence))
|
||||
report.ValidHops = len(mergedEvidence)
|
||||
report.ObservedASNs = uniqueRouteASNs(mergedEvidence)
|
||||
report.Classification = combineRouteClassifications(target.Carrier, classifications)
|
||||
report.Latency = calculateRouteLatency(latencies)
|
||||
return report
|
||||
}
|
||||
|
||||
func safeExecuteRouteAttempt(ctx context.Context, target RouteTarget, trace TraceFunc, alternatives AlternativeTargetFunc) (result routeAttempt) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
result = routeAttempt{err: errors.New("route probe failed")}
|
||||
}
|
||||
}()
|
||||
return executeRouteAttempt(ctx, target, trace, alternatives)
|
||||
}
|
||||
|
||||
func executeRouteAttempt(ctx context.Context, target RouteTarget, trace TraceFunc, alternatives AlternativeTargetFunc) routeAttempt {
|
||||
primary := net.ParseIP(strings.TrimSpace(target.Address))
|
||||
if primary == nil {
|
||||
return routeAttempt{err: errors.New("invalid route target")}
|
||||
}
|
||||
hops, err := trace(ctx, primary)
|
||||
if err == nil && len(hops) > 0 {
|
||||
return routeAttempt{hops: hops, targetIP: primary}
|
||||
}
|
||||
for _, value := range alternatives(target) {
|
||||
candidate := net.ParseIP(strings.TrimSpace(value))
|
||||
if candidate == nil || candidate.Equal(primary) {
|
||||
continue
|
||||
}
|
||||
hops, err = trace(ctx, candidate)
|
||||
if err == nil && len(hops) > 0 {
|
||||
return routeAttempt{hops: hops, targetIP: candidate, fallback: true}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return routeAttempt{err: ctx.Err()}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = errors.New("route returned no responding hops")
|
||||
}
|
||||
return routeAttempt{err: err}
|
||||
}
|
||||
|
||||
func canceledRouteTarget(target RouteTarget, attempts int, err error) RouteTargetReport {
|
||||
status := RouteProbeTimeout
|
||||
message := "route probe timed out"
|
||||
if errors.Is(err, context.Canceled) {
|
||||
status = RouteProbeCanceled
|
||||
message = "route probe canceled"
|
||||
}
|
||||
return RouteTargetReport{
|
||||
Target: target, Status: status, Protocol: "icmp", Attempts: attempts,
|
||||
Classification: inconclusiveClassification(strings.ToLower(normalizeCarrier(target.Carrier))+"_unknown", "线路证据不足", message),
|
||||
Error: message,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRouteTargets(enableIPv6 bool) []RouteTarget {
|
||||
targets := make([]RouteTarget, 0, len(model.Ipv4s)+len(model.Ipv6s))
|
||||
for index, address := range model.Ipv4s {
|
||||
targets = append(targets, RouteTarget{
|
||||
Name: model.Ipv4Names[index], Address: address, IPVersion: "v4", Carrier: carrierFromTargetName(model.Ipv4Names[index]),
|
||||
})
|
||||
}
|
||||
if enableIPv6 {
|
||||
for index, address := range model.Ipv6s {
|
||||
targets = append(targets, RouteTarget{
|
||||
Name: model.Ipv6Names[index], Address: address, IPVersion: "v6", Carrier: carrierFromTargetName(model.Ipv6Names[index]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func carrierFromTargetName(name string) string {
|
||||
switch {
|
||||
case strings.Contains(name, "电信"):
|
||||
return "CT"
|
||||
case strings.Contains(name, "联通"):
|
||||
return "CU"
|
||||
case strings.Contains(name, "移动"):
|
||||
return "CM"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func routeEvidenceFromHops(hops []*Hop) []RouteHopEvidence {
|
||||
result := make([]RouteHopEvidence, 0, len(hops))
|
||||
for _, hop := range hops {
|
||||
if hop == nil || len(hop.Nodes) == 0 {
|
||||
continue
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
asns := make([]string, 0, len(hop.Nodes))
|
||||
for _, node := range hop.Nodes {
|
||||
if node == nil || node.IP == nil {
|
||||
continue
|
||||
}
|
||||
asn := ipv4Asn(node.IP.String())
|
||||
if asn == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[asn]; exists {
|
||||
continue
|
||||
}
|
||||
seen[asn] = struct{}{}
|
||||
asns = append(asns, asn)
|
||||
}
|
||||
result = append(result, RouteHopEvidence{Distance: hop.Distance, ASNs: asns})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func uniqueRouteASNs(hops []RouteHopEvidence) []string {
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]string, 0)
|
||||
for _, hop := range hops {
|
||||
for _, asn := range hop.ASNs {
|
||||
if _, exists := seen[asn]; exists {
|
||||
continue
|
||||
}
|
||||
seen[asn] = struct{}{}
|
||||
result = append(result, asn)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func routeRTTMilliseconds(hops []*Hop) []float64 {
|
||||
result := make([]float64, 0)
|
||||
for _, hop := range hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
for _, node := range hop.Nodes {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
for _, value := range node.RTT {
|
||||
if value >= 0 {
|
||||
result = append(result, float64(value)/float64(time.Millisecond))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func calculateRouteLatency(values []float64) RouteLatencyStats {
|
||||
if len(values) == 0 {
|
||||
return RouteLatencyStats{}
|
||||
}
|
||||
ordered := append([]float64(nil), values...)
|
||||
sort.Float64s(ordered)
|
||||
total := 0.0
|
||||
for _, value := range values {
|
||||
total += value
|
||||
}
|
||||
jitter := 0.0
|
||||
if len(values) > 1 {
|
||||
for index := 1; index < len(values); index++ {
|
||||
delta := values[index] - values[index-1]
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
jitter += delta
|
||||
}
|
||||
jitter /= float64(len(values) - 1)
|
||||
}
|
||||
return RouteLatencyStats{
|
||||
Samples: len(values), MinMS: ordered[0], AvgMS: total / float64(len(values)),
|
||||
P50MS: ordered[percentileIndex(len(ordered), 0.50)], P95MS: ordered[percentileIndex(len(ordered), 0.95)],
|
||||
MaxMS: ordered[len(ordered)-1], JitterMS: jitter,
|
||||
}
|
||||
}
|
||||
|
||||
func percentileIndex(length int, percentile float64) int {
|
||||
if length <= 1 {
|
||||
return 0
|
||||
}
|
||||
index := int(math.Ceil(float64(length)*percentile)) - 1
|
||||
if index < 0 {
|
||||
return 0
|
||||
}
|
||||
if index >= length {
|
||||
return length - 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func routeReachedTarget(hops []*Hop, target net.IP) bool {
|
||||
if target == nil {
|
||||
return false
|
||||
}
|
||||
for _, hop := range hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
for _, node := range hop.Nodes {
|
||||
if node != nil && node.IP != nil && node.IP.Equal(target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var alternativeRefreshMu sync.Mutex
|
||||
|
||||
func refreshAlternativeTargets(ctx context.Context) {
|
||||
alternativeRefreshMu.Lock()
|
||||
defer alternativeRefreshMu.Unlock()
|
||||
if model.CachedIcmpData != "" && model.ParsedIcmpTargets != nil && time.Since(model.CachedIcmpDataFetchTime) <= time.Hour {
|
||||
return
|
||||
}
|
||||
data := getDataContext(ctx, model.IcmpTargets)
|
||||
if data == "" {
|
||||
return
|
||||
}
|
||||
parsed := parseIcmpTargets(data)
|
||||
if len(parsed) == 0 {
|
||||
return
|
||||
}
|
||||
model.CachedIcmpData = data
|
||||
model.ParsedIcmpTargets = parsed
|
||||
model.CachedIcmpDataFetchTime = time.Now()
|
||||
}
|
||||
|
||||
func defaultAlternativeTargets(target RouteTarget) []string {
|
||||
return tryAlternativeIPs(target.Name, target.IPVersion)
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
)
|
||||
|
||||
func testHop(distance int, ip string, rtt time.Duration) *Hop {
|
||||
return &Hop{Distance: distance, Nodes: []*Node{{IP: net.ParseIP(ip), RTT: []time.Duration{rtt}}}}
|
||||
}
|
||||
|
||||
func TestRunRouteReportOfflineFixture(t *testing.T) {
|
||||
report := RunRouteReport(context.Background(), RouteReportConfig{
|
||||
Attempts: 2,
|
||||
Timeout: time.Second,
|
||||
Targets: []RouteTarget{{Name: "测试电信v4", Address: "192.0.2.1", IPVersion: "v4", Carrier: "CT"}},
|
||||
Trace: func(_ context.Context, _ net.IP) ([]*Hop, error) {
|
||||
return []*Hop{
|
||||
testHop(1, "59.43.1.1", 10*time.Millisecond),
|
||||
testHop(2, "59.43.2.2", 20*time.Millisecond),
|
||||
testHop(3, "202.97.1.1", 30*time.Millisecond),
|
||||
}, nil
|
||||
},
|
||||
AlternativeTarget: func(RouteTarget) []string { return nil },
|
||||
})
|
||||
if report.SchemaVersion != RouteReportSchema || len(report.Targets) != 1 {
|
||||
t.Fatalf("unexpected report: %+v", report)
|
||||
}
|
||||
target := report.Targets[0]
|
||||
if target.Status != RouteProbeAvailable || target.SuccessfulAttempts != 2 || target.ValidHops != 3 {
|
||||
t.Fatalf("unexpected target status: %+v", target)
|
||||
}
|
||||
if target.Classification.Code != "ct_cn2_gia" || target.Latency.Samples != 6 || target.Latency.P95MS != 30 {
|
||||
t.Fatalf("unexpected route classification or latency: %+v", target)
|
||||
}
|
||||
rendered := RenderRouteReport(report)
|
||||
if strings.Contains(rendered, "P95") || !strings.Contains(rendered, "电信CN2GT") || !strings.Contains(rendered, "电信163") {
|
||||
t.Fatalf("legacy rendering is not compact: %q", RenderRouteReport(report))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRouteReportUsesAlternativeWithoutExposingItsAddress(t *testing.T) {
|
||||
report := RunRouteReport(context.Background(), RouteReportConfig{
|
||||
Attempts: 1,
|
||||
Timeout: time.Second,
|
||||
Targets: []RouteTarget{{Name: "测试联通v4", Address: "192.0.2.1", IPVersion: "v4", Carrier: "CU"}},
|
||||
Trace: func(_ context.Context, ip net.IP) ([]*Hop, error) {
|
||||
if ip.Equal(net.ParseIP("198.51.100.2")) {
|
||||
return []*Hop{testHop(1, "202.77.1.1", 12*time.Millisecond)}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
AlternativeTarget: func(RouteTarget) []string { return []string{"198.51.100.2"} },
|
||||
})
|
||||
target := report.Targets[0]
|
||||
if !target.Fallback || target.Classification.Code != "cu_cug" {
|
||||
t.Fatalf("alternative result = %+v", target)
|
||||
}
|
||||
if strings.Contains(RenderRouteReport(report), "198.51.100.2") {
|
||||
t.Fatalf("legacy output exposed fallback target: %q", RenderRouteReport(report))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteReportPreservesLegacyAddressWidths(t *testing.T) {
|
||||
report := RouteReport{Targets: []RouteTargetReport{
|
||||
{
|
||||
Target: RouteTarget{Name: "北京电信v4", Address: "219.141.140.10", IPVersion: "v4", Carrier: "CT"},
|
||||
Status: RouteProbeAvailable,
|
||||
Classification: RouteClassification{Label: "电信163 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2},
|
||||
},
|
||||
{
|
||||
Target: RouteTarget{Name: "北京电信v6", Address: "2400:89c0:1053:3::69", IPVersion: "v6", Carrier: "CT"},
|
||||
Status: RouteProbeAvailable,
|
||||
Classification: RouteClassification{Label: "电信163 [普通线路]", Confidence: routeConfidenceConfirmed, Rank: 2},
|
||||
},
|
||||
}}
|
||||
rendered := regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(RenderRouteReport(report), "")
|
||||
if !strings.Contains(rendered, "北京电信v4 219.141.140.10 电信163") {
|
||||
t.Fatalf("IPv4 legacy spacing changed: %q", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "北京电信v6 2400:89c0:1053:3::69 电信163") {
|
||||
t.Fatalf("IPv6 legacy spacing changed: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteReportUsesClassicASNLabels(t *testing.T) {
|
||||
report := RouteReport{Targets: []RouteTargetReport{
|
||||
{
|
||||
Target: RouteTarget{Name: "北京电信v4", Address: "219.141.140.10", IPVersion: "v4", Carrier: "CT"},
|
||||
Status: RouteProbeAvailable,
|
||||
ObservedASNs: []string{"AS6453", "AS4134"},
|
||||
Classification: inconclusiveClassification("ct_destination_only", "仅见电信目的网", "only one AS4134 hop"),
|
||||
},
|
||||
{
|
||||
Target: RouteTarget{Name: "上海电信v4", Address: "202.96.209.133", IPVersion: "v4", Carrier: "CT"},
|
||||
Status: RouteProbeAvailable,
|
||||
ObservedASNs: []string{"AS4809", "AS4134"},
|
||||
Classification: RouteClassification{Code: "ct_cn2_mixed", Label: "电信CN2混合 [优质线路]", Confidence: routeConfidenceMixed, Rank: 3},
|
||||
},
|
||||
{
|
||||
Target: RouteTarget{Name: "广州联通v4", Address: "210.21.196.6", IPVersion: "v4", Carrier: "CU"},
|
||||
Status: RouteProbeAvailable,
|
||||
ObservedASNs: []string{"AS4837"},
|
||||
Classification: inconclusiveClassification("cu_destination_only", "仅见联通目的网", "only one AS4837 hop"),
|
||||
},
|
||||
}}
|
||||
rendered := regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(RenderRouteReport(report), "")
|
||||
for _, want := range []string{
|
||||
"北京电信v4 219.141.140.10 电信163 [普通线路]",
|
||||
"上海电信v4 202.96.209.133 电信CN2GT [优质线路] 电信163 [普通线路]",
|
||||
"广州联通v4 210.21.196.6 联通4837 [普通线路]",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("classic route label missing %q from %q", want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, "仅见") || strings.Contains(rendered, "未见") {
|
||||
t.Fatalf("structured fallback label leaked into classic output: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRouteReportUsesClassicUnknownMessage(t *testing.T) {
|
||||
report := RouteReport{Targets: []RouteTargetReport{{
|
||||
Target: RouteTarget{Name: "北京电信v4", Address: "219.141.140.10", IPVersion: "v4", Carrier: "CT"},
|
||||
Status: RouteProbeAvailable,
|
||||
ObservedASNs: []string{"AS6453"},
|
||||
Classification: inconclusiveClassification("ct_unknown", "未见电信骨干", "known carrier ASNs are absent"),
|
||||
}}}
|
||||
rendered := regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(RenderRouteReport(report), "")
|
||||
if !strings.Contains(rendered, "检测不到已知线路的ASN") || strings.Contains(rendered, "未见电信骨干") {
|
||||
t.Fatalf("unexpected classic unknown route output: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRouteReportHonorsContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
report := RunRouteReport(ctx, RouteReportConfig{
|
||||
Attempts: 1,
|
||||
Targets: []RouteTarget{{Name: "测试移动v4", Address: "192.0.2.1", IPVersion: "v4", Carrier: "CM"}},
|
||||
Trace: func(ctx context.Context, _ net.IP) ([]*Hop, error) { return nil, ctx.Err() },
|
||||
AlternativeTarget: func(RouteTarget) []string { return nil },
|
||||
})
|
||||
if report.Targets[0].Status != RouteProbeCanceled {
|
||||
t.Fatalf("canceled report = %+v", report.Targets[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAlternativeTargetsHonorsCanceledContextAndKeepsValidCache(t *testing.T) {
|
||||
alternativeRefreshMu.Lock()
|
||||
oldData := model.CachedIcmpData
|
||||
oldTargets := model.ParsedIcmpTargets
|
||||
oldFetchedAt := model.CachedIcmpDataFetchTime
|
||||
model.CachedIcmpData = `[{"province":"北京","isp":"电信","ip_version":"v4","ips":"192.0.2.10"}]`
|
||||
model.ParsedIcmpTargets = []model.IcmpTarget{{Province: "北京", ISP: "电信", IPVersion: "v4", IPs: "192.0.2.10"}}
|
||||
model.CachedIcmpDataFetchTime = time.Now().Add(-2 * time.Hour)
|
||||
alternativeRefreshMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
alternativeRefreshMu.Lock()
|
||||
defer alternativeRefreshMu.Unlock()
|
||||
model.CachedIcmpData = oldData
|
||||
model.ParsedIcmpTargets = oldTargets
|
||||
model.CachedIcmpDataFetchTime = oldFetchedAt
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
started := time.Now()
|
||||
refreshAlternativeTargets(ctx)
|
||||
if elapsed := time.Since(started); elapsed > 250*time.Millisecond {
|
||||
t.Fatalf("canceled refresh took %s", elapsed)
|
||||
}
|
||||
if model.CachedIcmpData == "" || len(model.ParsedIcmpTargets) != 1 {
|
||||
t.Fatalf("failed refresh replaced valid cache: data=%q targets=%v", model.CachedIcmpData, model.ParsedIcmpTargets)
|
||||
}
|
||||
}
|
||||
@ -194,15 +194,14 @@ func (t *Tracer) serveData(from net.IP, b []byte) error {
|
||||
Logger.Info(fmt.Sprintf("收到IPv6 ICMP消息: 类型=%v, 代码=%v", msg.Type, msg.Code))
|
||||
}
|
||||
// 处理不同类型的ICMP消息
|
||||
switch msg.Type {
|
||||
case ipv6.ICMPTypeEchoReply:
|
||||
if msg.Type == ipv6.ICMPTypeEchoReply {
|
||||
if echo, ok := msg.Body.(*icmp.Echo); ok {
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("处理IPv6回显应答: ID=%d, Seq=%d", echo.ID, echo.Seq))
|
||||
}
|
||||
return t.serveReply(from, &packet{from, uint16(echo.ID), 1, time.Now()})
|
||||
}
|
||||
case ipv6.ICMPTypeTimeExceeded:
|
||||
} else if msg.Type == ipv6.ICMPTypeTimeExceeded {
|
||||
b = getReplyData(msg)
|
||||
if len(b) < ipv6.HeaderLen {
|
||||
if model.EnableLoger {
|
||||
@ -233,10 +232,7 @@ func (t *Tracer) serveData(from net.IP, b []byte) error {
|
||||
return err
|
||||
}
|
||||
if msg.Type == ipv4.ICMPTypeEchoReply {
|
||||
echo, ok := msg.Body.(*icmp.Echo)
|
||||
if !ok || echo == nil {
|
||||
return errUnsupportedProtocol
|
||||
}
|
||||
echo := msg.Body.(*icmp.Echo)
|
||||
return t.serveReply(from, &packet{from, uint16(echo.ID), 1, time.Now()})
|
||||
}
|
||||
b = getReplyData(msg)
|
||||
@ -544,15 +540,6 @@ func (h *Hop) Add(r *Reply) *Node {
|
||||
|
||||
// Trace is a simple traceroute tool using DefaultTracer.
|
||||
func Trace(ip net.IP) ([]*Hop, error) {
|
||||
return TraceContext(context.Background(), ip)
|
||||
}
|
||||
|
||||
// TraceContext runs an ICMP traceroute that stops promptly when the caller's
|
||||
// context is canceled.
|
||||
func TraceContext(ctx context.Context, ip net.IP) ([]*Hop, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
hops := make([]*Hop, 0, DefaultTracer.MaxHops)
|
||||
touch := func(dist int) *Hop {
|
||||
for _, h := range hops {
|
||||
@ -564,7 +551,7 @@ func TraceContext(ctx context.Context, ip net.IP) ([]*Hop, error) {
|
||||
hops = append(hops, h)
|
||||
return h
|
||||
}
|
||||
err := DefaultTracer.Trace(ctx, ip, func(r *Reply) {
|
||||
err := DefaultTracer.Trace(context.Background(), ip, func(r *Reply) {
|
||||
touch(r.Hops).Add(r)
|
||||
})
|
||||
if err != nil && err != context.DeadlineExceeded {
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
. "github.com/oneclickvirt/defaultset"
|
||||
@ -58,79 +57,50 @@ func extractIpv4ASNsFromHops(hops []*Hop, enableLogger bool) []string {
|
||||
|
||||
// trace IPv4追踪函数
|
||||
func trace(ch chan Result, i int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
if model.EnableLoger {
|
||||
InitLogger()
|
||||
defer Logger.Sync()
|
||||
Logger.Info(fmt.Sprintf("开始追踪 %s (%s)", model.Ipv4Names[i], model.Ipv4s[i]))
|
||||
}
|
||||
var allHops [][]*Hop
|
||||
var successfulTraces int
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
// 并发执行3次trace
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
wg.Add(1)
|
||||
go func(attemptNum int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("第%d次尝试追踪 %s (%s)", attemptNum, model.Ipv4Names[i], model.Ipv4s[i]))
|
||||
}
|
||||
// 先尝试原始IP地址
|
||||
hops, err := Trace(net.ParseIP(model.Ipv4s[i]))
|
||||
if err != nil {
|
||||
s := fmt.Sprintf("%v %-15s %v", model.Ipv4Names[i], model.Ipv4s[i], Red("检测不到回程路由节点的IP地址"))
|
||||
if model.EnableLoger {
|
||||
Logger.Warn(fmt.Sprintf("第%d次追踪 %s (%s) 失败: %v", attemptNum, model.Ipv4Names[i], model.Ipv4s[i], err))
|
||||
Logger.Error(fmt.Sprintf("追踪 %s (%s) 失败: %v", model.Ipv4Names[i], model.Ipv4s[i], err))
|
||||
}
|
||||
// 如果原始IP失败,尝试备选IP
|
||||
ch <- Result{i, s}
|
||||
}
|
||||
asns := extractIpv4ASNsFromHops(hops, model.EnableLoger)
|
||||
// 如果没有找到ASN,尝试备选IP
|
||||
if len(asns) == 0 {
|
||||
// 尝试从IcmpTargets获取备选IP
|
||||
if tryAltIPs := tryAlternativeIPs(model.Ipv4Names[i], "v4"); len(tryAltIPs) > 0 {
|
||||
for _, altIP := range tryAltIPs {
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("第%d次尝试备选IP %s 追踪 %s", attemptNum, altIP, model.Ipv4Names[i]))
|
||||
Logger.Info(fmt.Sprintf("尝试备选IP %s 追踪 %s", altIP, model.Ipv4Names[i]))
|
||||
}
|
||||
hops, err = Trace(net.ParseIP(altIP))
|
||||
if err == nil && len(hops) > 0 {
|
||||
newAsns := extractIpv4ASNsFromHops(hops, model.EnableLoger)
|
||||
asns = append(asns, newAsns...)
|
||||
if len(newAsns) > 0 {
|
||||
break // 成功找到可用IP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && len(hops) > 0 {
|
||||
mu.Lock()
|
||||
allHops = append(allHops, hops)
|
||||
successfulTraces++
|
||||
mu.Unlock()
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("第%d次追踪 %s (%s) 成功,获得%d个hop", attemptNum, model.Ipv4Names[i], model.Ipv4s[i], len(hops)))
|
||||
}
|
||||
}
|
||||
}(attempt)
|
||||
}
|
||||
// 等待所有goroutine完成
|
||||
wg.Wait()
|
||||
// 如果3次都失败
|
||||
if successfulTraces == 0 {
|
||||
s := fmt.Sprintf("%v %-15s %v", model.Ipv4Names[i], model.Ipv4s[i], Red("检测不到回程路由节点的IP地址"))
|
||||
if model.EnableLoger {
|
||||
Logger.Error(fmt.Sprintf("%s (%s) 3次尝试都失败,检测不到回程路由节点的IP地址", model.Ipv4Names[i], model.Ipv4s[i]))
|
||||
}
|
||||
ch <- Result{i, s}
|
||||
return
|
||||
}
|
||||
// 合并hops结果
|
||||
mergedHops := mergeHops(allHops)
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("%s (%s) 完成%d次成功追踪,合并后获得%d个hop", model.Ipv4Names[i], model.Ipv4s[i], successfulTraces, len(mergedHops)))
|
||||
}
|
||||
// 从合并后的hops提取ASN
|
||||
asns := extractIpv4ASNsFromHops(mergedHops, model.EnableLoger)
|
||||
asns = removeDuplicates(asns)
|
||||
// // 记录每个hop的信息
|
||||
// if model.EnableLoger {
|
||||
// for hopNum, hop := range hops {
|
||||
// for nodeNum, node := range hop.Nodes {
|
||||
// Logger.Info(fmt.Sprintf("追踪 %s (%s) - Hop %d, Node %d: %s (RTT: %v)",
|
||||
// model.Ipv4Names[i], model.Ipv4s[i], hopNum+1, nodeNum+1, node.IP.String(), node.RTT))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// 处理不同线路
|
||||
if len(asns) > 0 {
|
||||
var tempText string
|
||||
@ -196,16 +166,18 @@ func trace(ch chan Result, i int) {
|
||||
}
|
||||
if tempText == (fmt.Sprintf("%v ", model.Ipv4Names[i]) + fmt.Sprintf("%-15s ", model.Ipv4s[i])) {
|
||||
tempText += fmt.Sprintf("%v", Red("检测不到已知线路的ASN"))
|
||||
|
||||
if model.EnableLoger {
|
||||
Logger.Warn(fmt.Sprintf("%s (%s) 检测不到已知线路的ASN", model.Ipv4Names[i], model.Ipv4s[i]))
|
||||
}
|
||||
}
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("%s (%s) 追踪完成,最终结果: %s", model.Ipv4Names[i], model.Ipv4s[i], tempText))
|
||||
Logger.Info(fmt.Sprintf("%s (%s) 追踪完成,结果: %s", model.Ipv4Names[i], model.Ipv4s[i], tempText))
|
||||
}
|
||||
ch <- Result{i, tempText}
|
||||
} else {
|
||||
s := fmt.Sprintf("%v %-15s %v", model.Ipv4Names[i], model.Ipv4s[i], Red("检测不到回程路由节点的IPV4地址"))
|
||||
|
||||
if model.EnableLoger {
|
||||
Logger.Warn(fmt.Sprintf("%s (%s) 检测不到回程路由节点的IPV4地址", model.Ipv4Names[i], model.Ipv4s[i]))
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
. "github.com/oneclickvirt/defaultset"
|
||||
@ -12,7 +11,7 @@ import (
|
||||
"golang.org/x/net/ipv6"
|
||||
)
|
||||
|
||||
func newPacketV6(id uint16, _ net.IP, _ int) []byte {
|
||||
func newPacketV6(id uint16, dst net.IP, ttl int) []byte {
|
||||
// 使用ipv6包的Echo请求
|
||||
msg := icmp.Message{
|
||||
Type: ipv6.ICMPTypeEchoRequest,
|
||||
@ -43,21 +42,10 @@ func (t *Tracer) serveIPv6(conn *ipv6.PacketConn) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
srcAddr, ok := src.(*net.IPAddr)
|
||||
if !ok || srcAddr == nil {
|
||||
continue
|
||||
}
|
||||
hopLimit := 0
|
||||
if cm != nil {
|
||||
hopLimit = cm.HopLimit
|
||||
}
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("收到IPv6响应: 来源=%v, 跳数=%d", srcAddr, hopLimit))
|
||||
}
|
||||
fromIP := srcAddr.IP
|
||||
if fromIP == nil {
|
||||
continue
|
||||
Logger.Info(fmt.Sprintf("收到IPv6响应: 来源=%v, 跳数=%d", src, cm.HopLimit))
|
||||
}
|
||||
fromIP := src.(*net.IPAddr).IP
|
||||
err = t.serveData(fromIP, buf[:n])
|
||||
if err != nil && model.EnableLoger {
|
||||
Logger.Warn("处理IPv6数据失败: " + err.Error())
|
||||
@ -84,79 +72,50 @@ func extractIpv6ASNsFromHops(hops []*Hop, enableLogger bool) []string {
|
||||
|
||||
// traceIPv6 IPv6追踪函数
|
||||
func traceIPv6(ch chan Result, i int, offset int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
if model.EnableLoger {
|
||||
InitLogger()
|
||||
defer Logger.Sync()
|
||||
Logger.Info(fmt.Sprintf("开始追踪 %s (%s)", model.Ipv6Names[i], model.Ipv6s[i]))
|
||||
}
|
||||
var allHops [][]*Hop
|
||||
var successfulTraces int
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
// 并发执行3次trace
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
wg.Add(1)
|
||||
go func(attemptNum int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("第%d次尝试追踪 %s (%s)", attemptNum, model.Ipv6Names[i], model.Ipv6s[i]))
|
||||
}
|
||||
// 先尝试原始IP地址
|
||||
hops, err := Trace(net.ParseIP(model.Ipv6s[i]))
|
||||
if err != nil {
|
||||
s := fmt.Sprintf("%v %-24s %v", model.Ipv6Names[i], model.Ipv6s[i], Red("检测不到回程路由节点的IP地址"))
|
||||
if model.EnableLoger {
|
||||
Logger.Warn(fmt.Sprintf("第%d次追踪 %s (%s) 失败: %v", attemptNum, model.Ipv6Names[i], model.Ipv6s[i], err))
|
||||
Logger.Warn(fmt.Sprintf("%s (%s) 检测不到回程路由节点的IP地址", model.Ipv6Names[i], model.Ipv6s[i]))
|
||||
}
|
||||
// 如果原始IP失败,尝试备选IP
|
||||
if tryAltIPs := tryAlternativeIPs(model.Ipv6Names[i], "v6"); len(tryAltIPs) > 0 {
|
||||
ch <- Result{i + offset, s}
|
||||
}
|
||||
asns := extractIpv6ASNsFromHops(hops, model.EnableLoger)
|
||||
// 如果没有找到ASN,尝试备选IP
|
||||
if len(asns) == 0 {
|
||||
// 尝试从IcmpTargets获取备选IP
|
||||
if tryAltIPs := tryAlternativeIPs(model.Ipv4Names[i], "v4"); len(tryAltIPs) > 0 {
|
||||
for _, altIP := range tryAltIPs {
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("第%d次尝试备选IP %s 追踪 %s", attemptNum, altIP, model.Ipv6Names[i]))
|
||||
Logger.Info(fmt.Sprintf("尝试备选IP %s 追踪 %s", altIP, model.Ipv4Names[i]))
|
||||
}
|
||||
hops, err = Trace(net.ParseIP(altIP))
|
||||
if err == nil && len(hops) > 0 {
|
||||
newAsns := extractIpv6ASNsFromHops(hops, model.EnableLoger)
|
||||
asns = append(asns, newAsns...)
|
||||
if len(newAsns) > 0 {
|
||||
break // 成功找到可用IP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && len(hops) > 0 {
|
||||
mu.Lock()
|
||||
allHops = append(allHops, hops)
|
||||
successfulTraces++
|
||||
mu.Unlock()
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("第%d次追踪 %s (%s) 成功,获得%d个hop", attemptNum, model.Ipv6Names[i], model.Ipv6s[i], len(hops)))
|
||||
}
|
||||
}
|
||||
}(attempt)
|
||||
}
|
||||
// 等待所有goroutine完成
|
||||
wg.Wait()
|
||||
// 如果3次都失败
|
||||
if successfulTraces == 0 {
|
||||
s := fmt.Sprintf("%v %-24s %v", model.Ipv6Names[i], model.Ipv6s[i], Red("检测不到回程路由节点的IP地址"))
|
||||
if model.EnableLoger {
|
||||
Logger.Warn(fmt.Sprintf("%s (%s) 3次尝试都失败,检测不到回程路由节点的IP地址", model.Ipv6Names[i], model.Ipv6s[i]))
|
||||
}
|
||||
ch <- Result{i + offset, s}
|
||||
return
|
||||
}
|
||||
// 合并hops结果
|
||||
mergedHops := mergeHops(allHops)
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("%s (%s) 完成%d次成功追踪,合并后获得%d个hop", model.Ipv6Names[i], model.Ipv6s[i], successfulTraces, len(mergedHops)))
|
||||
}
|
||||
// 从合并后的hops提取ASN
|
||||
asns := extractIpv6ASNsFromHops(mergedHops, model.EnableLoger)
|
||||
asns = removeDuplicates(asns)
|
||||
// // 记录每个hop的信息
|
||||
// if model.EnableLoger {
|
||||
// for hopNum, hop := range hops {
|
||||
// for nodeNum, node := range hop.Nodes {
|
||||
// Logger.Info(fmt.Sprintf("追踪 %s (%s) - Hop %d, Node %d: %s (RTT: %v)",
|
||||
// model.Ipv6Names[i], model.Ipv6s[i], hopNum+1, nodeNum+1, node.IP.String(), node.RTT))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// 处理不同线路
|
||||
if len(asns) > 0 {
|
||||
var tempText string
|
||||
@ -227,7 +186,7 @@ func traceIPv6(ch chan Result, i int, offset int) {
|
||||
}
|
||||
}
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("%s (%s) 追踪完成,最终结果: %s", model.Ipv6Names[i], model.Ipv6s[i], tempText))
|
||||
Logger.Info(fmt.Sprintf("%s (%s) 追踪完成,结果: %s", model.Ipv6Names[i], model.Ipv6s[i], tempText))
|
||||
}
|
||||
ch <- Result{i + offset, tempText}
|
||||
} else {
|
||||
|
||||
188
bk/utils.go
188
bk/utils.go
@ -1,7 +1,6 @@
|
||||
package backtrace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -34,110 +33,27 @@ func removeDuplicates(elements []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// checkCdn 检查CDN可用性,参考shell脚本的测试逻辑
|
||||
func checkCdn(testUrl string) string {
|
||||
return checkCdnContext(context.Background(), testUrl)
|
||||
}
|
||||
|
||||
func checkCdnContext(ctx context.Context, testUrl string) string {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
client := req.C()
|
||||
client.SetTimeout(6 * time.Second)
|
||||
if model.EnableLoger {
|
||||
InitLogger()
|
||||
defer Logger.Sync()
|
||||
}
|
||||
for index, cdnUrl := range model.CdnList {
|
||||
url := cdnUrl + testUrl
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("Testing CDN source %d", index+1))
|
||||
}
|
||||
resp, err := client.R().SetContext(ctx).Get(url)
|
||||
if err == nil && resp != nil && resp.Body != nil {
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err == nil && strings.Contains(string(b), "success") {
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("CDN source %d available", index+1))
|
||||
}
|
||||
return cdnUrl
|
||||
}
|
||||
}
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("CDN source %d unavailable", index+1))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ""
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if model.EnableLoger {
|
||||
Logger.Info("No CDN available, using direct connection")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getData 获取目标地址的文本内容
|
||||
func getData(endpoint string) string {
|
||||
return getDataContext(context.Background(), endpoint)
|
||||
}
|
||||
|
||||
func getDataContext(ctx context.Context, endpoint string) string {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
client := req.C()
|
||||
client.SetTimeout(6 * time.Second)
|
||||
client.R().
|
||||
SetRetryCount(2).
|
||||
SetRetryBackoffInterval(1*time.Second, 5*time.Second).
|
||||
SetRetryFixedInterval(2 * time.Second)
|
||||
if model.EnableLoger {
|
||||
InitLogger()
|
||||
defer Logger.Sync()
|
||||
}
|
||||
|
||||
// 先测试CDN可用性
|
||||
testUrl := "https://raw.githubusercontent.com/spiritLHLS/ecs/main/back/test"
|
||||
cdnUrl := checkCdnContext(ctx, testUrl)
|
||||
|
||||
// 如果有可用的CDN,使用CDN获取数据
|
||||
if cdnUrl != "" {
|
||||
url := cdnUrl + endpoint
|
||||
if model.EnableLoger {
|
||||
Logger.Info("Using validated CDN source")
|
||||
}
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetRetryCount(2).
|
||||
SetRetryFixedInterval(2 * time.Second).
|
||||
Get(url)
|
||||
if err == nil && resp != nil && resp.Body != nil {
|
||||
for _, baseUrl := range model.CdnList {
|
||||
url := baseUrl + endpoint
|
||||
resp, err := client.R().Get(url)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err == nil && !strings.Contains(string(b), "error") {
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("Received data length: %d", len(b)))
|
||||
if strings.Contains(string(b), "error") {
|
||||
continue
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
if model.EnableLoger {
|
||||
Logger.Info("CDN data request failed")
|
||||
}
|
||||
}
|
||||
|
||||
// CDN不可用,尝试直连
|
||||
if model.EnableLoger {
|
||||
Logger.Info("Trying direct data source")
|
||||
}
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetRetryCount(2).
|
||||
SetRetryFixedInterval(2 * time.Second).
|
||||
Get(endpoint)
|
||||
if err == nil && resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("Received data length: %d", len(b)))
|
||||
@ -146,18 +62,25 @@ func getDataContext(ctx context.Context, endpoint string) string {
|
||||
}
|
||||
}
|
||||
if model.EnableLoger {
|
||||
Logger.Info("Direct data request failed")
|
||||
Logger.Info(fmt.Sprintf("HTTP request failed: %v", err))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseIcmpTargets 解析ICMP目标数据
|
||||
func parseIcmpTargets(jsonData string) []model.IcmpTarget {
|
||||
// 确保JSON数据格式正确,如果返回的是数组,需要添加[和]
|
||||
if !strings.HasPrefix(jsonData, "[") {
|
||||
jsonData = "[" + jsonData + "]"
|
||||
}
|
||||
// 如果JSON数据中的对象没有正确用逗号分隔,修复它
|
||||
jsonData = strings.ReplaceAll(jsonData, "}{", "},{")
|
||||
var targets []model.IcmpTarget
|
||||
err := json.Unmarshal([]byte(jsonData), &targets)
|
||||
if err != nil {
|
||||
if model.EnableLoger {
|
||||
Logger.Info(fmt.Sprintf("解析ICMP目标失败: %s", err.Error()))
|
||||
Logger.Error(fmt.Sprintf("Failed to parse ICMP targets: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -223,76 +146,3 @@ func tryAlternativeIPs(targetName string, ipVersion string) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeHops 合并多个hops结果
|
||||
func mergeHops(allHops [][]*Hop) []*Hop {
|
||||
if len(allHops) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(allHops) == 1 {
|
||||
return allHops[0]
|
||||
}
|
||||
// 找到最大长度
|
||||
maxLen := 0
|
||||
for _, hops := range allHops {
|
||||
if len(hops) > maxLen {
|
||||
maxLen = len(hops)
|
||||
}
|
||||
}
|
||||
var mergedHops []*Hop
|
||||
// 逐位置合并
|
||||
for pos := 0; pos < maxLen; pos++ {
|
||||
var availableHops []*Hop
|
||||
// 收集当前位置所有可用的hop
|
||||
for _, hops := range allHops {
|
||||
if pos < len(hops) {
|
||||
availableHops = append(availableHops, hops[pos])
|
||||
}
|
||||
}
|
||||
if len(availableHops) == 0 {
|
||||
continue
|
||||
}
|
||||
// 如果只有一个可用hop,直接使用
|
||||
if len(availableHops) == 1 {
|
||||
mergedHops = append(mergedHops, availableHops[0])
|
||||
continue
|
||||
}
|
||||
// 统计相同的hop(通过比较第一个node的IP)
|
||||
hopCount := make(map[string][]*Hop)
|
||||
for _, hop := range availableHops {
|
||||
var key string
|
||||
if len(hop.Nodes) > 0 && hop.Nodes[0].IP != nil {
|
||||
key = hop.Nodes[0].IP.String()
|
||||
} else {
|
||||
key = "*" // 超时或无响应
|
||||
}
|
||||
if _, exists := hopCount[key]; !exists {
|
||||
hopCount[key] = make([]*Hop, 0)
|
||||
}
|
||||
hopCount[key] = append(hopCount[key], hop)
|
||||
}
|
||||
// 按多数原则选择hop
|
||||
if len(hopCount) == 1 {
|
||||
// 所有hop都相同,选择第一个
|
||||
mergedHops = append(mergedHops, availableHops[0])
|
||||
} else {
|
||||
// 找出最多的hop类型
|
||||
maxCount := 0
|
||||
var majorityHops []*Hop
|
||||
for _, hops := range hopCount {
|
||||
if len(hops) > maxCount {
|
||||
maxCount = len(hops)
|
||||
majorityHops = hops
|
||||
}
|
||||
}
|
||||
// 如果有多数派,使用多数派的第一个
|
||||
if maxCount > 1 || len(hopCount) == 2 {
|
||||
mergedHops = append(mergedHops, majorityHops[0])
|
||||
} else {
|
||||
// 三个都不同,按请求早晚顺序选择第一个
|
||||
mergedHops = append(mergedHops, availableHops[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHops
|
||||
}
|
||||
|
||||
289
cmd/main.go
289
cmd/main.go
@ -1,23 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/bgptools"
|
||||
backtrace "github.com/oneclickvirt/backtrace/bk"
|
||||
"github.com/oneclickvirt/backtrace/model"
|
||||
"github.com/oneclickvirt/backtrace/utils"
|
||||
"github.com/oneclickvirt/basics/network/resolver"
|
||||
. "github.com/oneclickvirt/defaultset"
|
||||
)
|
||||
|
||||
@ -29,289 +21,48 @@ type IpInfo struct {
|
||||
Org string `json:"org"`
|
||||
}
|
||||
|
||||
type ConcurrentResults struct {
|
||||
bgpResult string
|
||||
backtraceResult string
|
||||
bgpError error
|
||||
// backtraceError error
|
||||
}
|
||||
|
||||
type cliOptions struct {
|
||||
showVersion bool
|
||||
showIPInfo bool
|
||||
help bool
|
||||
ipv6 bool
|
||||
jsonOutput bool
|
||||
routeJSON bool
|
||||
deep bool
|
||||
routeTries int
|
||||
specifiedIP string
|
||||
dnsMode string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
backtraceDNSConfigureFn = resolver.Configure
|
||||
backtraceDNSShutdownFn = resolver.Shutdown
|
||||
backtraceDNSBootstrapReachableFn = resolver.BootstrapReachable
|
||||
)
|
||||
|
||||
func newBacktraceFlagSet(options *cliOptions) *flag.FlagSet {
|
||||
set := flag.NewFlagSet("backtrace", flag.ContinueOnError)
|
||||
set.BoolVar(&options.help, "h", false, "Show help information")
|
||||
set.BoolVar(&options.showVersion, "v", false, "Show version")
|
||||
set.BoolVar(&options.showIPInfo, "s", true, "Disabe show ip info")
|
||||
set.BoolVar(&model.EnableLoger, "log", false, "Enable logging")
|
||||
set.BoolVar(&options.ipv6, "ipv6", false, "Enable ipv6 testing")
|
||||
set.StringVar(&options.specifiedIP, "ip", "", "Specify IP address for bgptools")
|
||||
set.BoolVar(&options.jsonOutput, "json", false, "Output structured RDAP/BGP report as JSON")
|
||||
set.BoolVar(&options.jsonOutput, "structured", false, "Alias for -json")
|
||||
set.BoolVar(&options.routeJSON, "route-json", false, "Output structured return-route report as JSON")
|
||||
set.BoolVar(&options.deep, "deep", false, "Fetch geofeed and enable WHOIS fallback")
|
||||
set.IntVar(&options.routeTries, "route-attempts", 3, "Traceroute attempts per target (1-5)")
|
||||
set.DurationVar(&options.timeout, "timeout", 15*time.Second, "Structured or route report timeout")
|
||||
set.StringVar(&options.dnsMode, "dns-mode", "auto", "DNS mode (auto, system, doh, or dot)")
|
||||
return set
|
||||
}
|
||||
|
||||
func safeGo(wg *sync.WaitGroup, fn func()) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
func configureBacktraceResolver(ctx context.Context, mode resolver.Mode, preCheck *utils.NetCheckResult) resolver.Status {
|
||||
requested := resolver.ParseMode(string(mode))
|
||||
config := resolver.Config{Mode: requested}
|
||||
status := resolver.Status{Requested: requested, Active: resolver.ModeUnavailable, Reason: "network unavailable"}
|
||||
if preCheck == nil || preCheck.Connected || requested == resolver.ModeDoH || requested == resolver.ModeDoT {
|
||||
return backtraceDNSConfigureFn(ctx, config)
|
||||
}
|
||||
if requested == resolver.ModeAuto {
|
||||
if _, reachable := backtraceDNSBootstrapReachableFn(ctx, config); reachable {
|
||||
return backtraceDNSConfigureFn(ctx, config)
|
||||
}
|
||||
status.Reason = "encrypted DNS endpoint unreachable"
|
||||
}
|
||||
backtraceDNSShutdownFn()
|
||||
return status
|
||||
}
|
||||
|
||||
func promoteEncryptedDNSConnectivity(preCheck *utils.NetCheckResult, status resolver.Status) {
|
||||
if preCheck == nil || preCheck.Connected || (status.Active != resolver.ModeDoH && status.Active != resolver.ModeDoT) {
|
||||
return
|
||||
}
|
||||
preCheck.Connected = true
|
||||
switch status.Stack {
|
||||
case "IPv4":
|
||||
preCheck.HasIPv4 = true
|
||||
case "IPv6":
|
||||
preCheck.HasIPv6 = true
|
||||
}
|
||||
if status.Stack != "" {
|
||||
preCheck.StackType = status.Stack
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var options cliOptions
|
||||
backtraceFlag := newBacktraceFlagSet(&options)
|
||||
if err := backtraceFlag.Parse(os.Args[1:]); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
if !options.jsonOutput && !options.routeJSON {
|
||||
fmt.Println(Green("Repo:"), Yellow("https://github.com/oneclickvirt/backtrace"))
|
||||
}
|
||||
if options.help {
|
||||
go func() {
|
||||
http.Get("https://hits.spiritlhl.net/backtrace.svg?action=hit&title=Hits&title_bg=%23555555&count_bg=%230eecf8&edge_flat=false")
|
||||
}()
|
||||
fmt.Println(Green("项目地址:"), Yellow("https://github.com/oneclickvirt/backtrace"))
|
||||
var showVersion, showIpInfo, help, ipv6 bool
|
||||
backtraceFlag := flag.NewFlagSet("backtrace", flag.ContinueOnError)
|
||||
backtraceFlag.BoolVar(&help, "h", false, "Show help information")
|
||||
backtraceFlag.BoolVar(&showVersion, "v", false, "Show version")
|
||||
backtraceFlag.BoolVar(&showIpInfo, "s", true, "Disabe show ip info")
|
||||
backtraceFlag.BoolVar(&model.EnableLoger, "log", false, "Enable logging")
|
||||
backtraceFlag.BoolVar(&ipv6, "ipv6", false, "Enable ipv6 testing")
|
||||
backtraceFlag.Parse(os.Args[1:])
|
||||
if help {
|
||||
fmt.Printf("Usage: %s [options]\n", os.Args[0])
|
||||
backtraceFlag.PrintDefaults()
|
||||
return
|
||||
}
|
||||
if options.showVersion {
|
||||
if showVersion {
|
||||
fmt.Println(model.BackTraceVersion)
|
||||
return
|
||||
}
|
||||
options.dnsMode = strings.ToLower(strings.TrimSpace(options.dnsMode))
|
||||
if options.dnsMode != "auto" && options.dnsMode != "system" && options.dnsMode != "doh" && options.dnsMode != "dot" {
|
||||
fmt.Fprintln(os.Stderr, "dns-mode must be auto, system, doh, or dot")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := validateStructuredOptions(options); err != nil {
|
||||
fmt.Fprintln(os.Stderr, sanitizeErrorText(err.Error()))
|
||||
os.Exit(2)
|
||||
}
|
||||
var preCheck *utils.NetCheckResult
|
||||
if !options.jsonOutput && !options.routeJSON {
|
||||
check := utils.CheckPublicAccess(3 * time.Second)
|
||||
preCheck = &check
|
||||
}
|
||||
status := configureBacktraceResolver(context.Background(), resolver.ParseMode(options.dnsMode), preCheck)
|
||||
promoteEncryptedDNSConnectivity(preCheck, status)
|
||||
if status.Active == resolver.ModeDoH || status.Active == resolver.ModeDoT {
|
||||
defer backtraceDNSShutdownFn()
|
||||
}
|
||||
go func() {
|
||||
resp, err := http.Get("https://hits.spiritlhl.net/backtrace.svg?action=hit&title=Hits&title_bg=%23555555&count_bg=%230eecf8&edge_flat=false")
|
||||
if err == nil && resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
if options.jsonOutput {
|
||||
config := bgptools.IPBGPReportConfig{
|
||||
Timeout: options.timeout,
|
||||
FetchGeofeed: options.deep,
|
||||
EnableWHOISFallback: options.deep,
|
||||
ResolveASN: func(ctx context.Context, ip string) (string, error) {
|
||||
return bgptools.ResolveOriginASNWithConfig(ctx, ip, bgptools.OriginASNConfig{Timeout: options.timeout})
|
||||
},
|
||||
}
|
||||
if err := writeStructuredReport(context.Background(), os.Stdout, options.specifiedIP, config, bgptools.QueryIPBGPReport); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "structured report failed: %s\n", sanitizeErrorText(err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if options.routeJSON {
|
||||
report := backtrace.RunRouteReport(context.Background(), backtrace.RouteReportConfig{
|
||||
EnableIPv6: options.ipv6,
|
||||
Attempts: options.routeTries,
|
||||
Timeout: options.timeout,
|
||||
})
|
||||
if err := writeStructuredRouteReport(os.Stdout, report); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "route report failed: %s\n", sanitizeErrorText(err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
info := IpInfo{}
|
||||
if options.showIPInfo {
|
||||
if showIpInfo {
|
||||
rsp, err := http.Get("http://ipinfo.io")
|
||||
if err != nil {
|
||||
fmt.Printf("get ip info err %s \n", sanitizeErrorText(err.Error()))
|
||||
fmt.Errorf("Get ip info err %v \n", err.Error())
|
||||
} else {
|
||||
defer rsp.Body.Close()
|
||||
info := IpInfo{}
|
||||
err = json.NewDecoder(rsp.Body).Decode(&info)
|
||||
if err != nil {
|
||||
fmt.Printf("json decode err %s \n", sanitizeErrorText(err.Error()))
|
||||
fmt.Errorf("json decode err %v \n", err.Error())
|
||||
} else {
|
||||
fmt.Println(Green("国家: ") + White(info.Country) + Green(" 城市: ") + White(info.City) +
|
||||
Green(" 服务商: ") + Blue(info.Org))
|
||||
}
|
||||
}
|
||||
}
|
||||
if preCheck == nil || !preCheck.Connected {
|
||||
fmt.Println(Red("PreCheck IP Type Failed"))
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
fmt.Println("Press Enter to exit...")
|
||||
fmt.Scanln()
|
||||
}
|
||||
return
|
||||
}
|
||||
var useIPv6 bool
|
||||
switch preCheck.StackType {
|
||||
case "DualStack":
|
||||
useIPv6 = options.ipv6
|
||||
case "IPv4":
|
||||
useIPv6 = false
|
||||
case "IPv6":
|
||||
useIPv6 = true
|
||||
default:
|
||||
fmt.Println(Red("PreCheck IP Type Failed"))
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
fmt.Println("Press Enter to exit...")
|
||||
fmt.Scanln()
|
||||
}
|
||||
return
|
||||
}
|
||||
results := ConcurrentResults{}
|
||||
var wg sync.WaitGroup
|
||||
var targetIP string
|
||||
if options.specifiedIP != "" {
|
||||
targetIP = options.specifiedIP
|
||||
} else if info.Ip != "" {
|
||||
targetIP = info.Ip
|
||||
}
|
||||
if targetIP != "" {
|
||||
wg.Add(1)
|
||||
safeGo(&wg, func() {
|
||||
for i := 0; i < 2; i++ {
|
||||
result, err := bgptools.GetPoPInfo(targetIP)
|
||||
results.bgpError = err
|
||||
if err == nil && result.Result != "" {
|
||||
results.bgpResult = result.Result
|
||||
return
|
||||
}
|
||||
if i == 0 {
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Add(1)
|
||||
safeGo(&wg, func() {
|
||||
result := backtrace.BackTrace(useIPv6)
|
||||
results.backtraceResult = result
|
||||
})
|
||||
wg.Wait()
|
||||
if results.bgpResult != "" {
|
||||
fmt.Print(indentLegacyOutput(results.bgpResult))
|
||||
}
|
||||
if results.backtraceResult != "" {
|
||||
fmt.Printf("%s\n", indentLegacyOutput(results.backtraceResult))
|
||||
}
|
||||
backtrace.BackTrace(ipv6)
|
||||
fmt.Println(Yellow("准确线路自行查看详细路由,本测试结果仅作参考"))
|
||||
fmt.Println(Yellow("同一目标地址多个线路时,检测可能已越过汇聚层,除第一个线路外,后续信息可能无效"))
|
||||
fmt.Println(Yellow("同一目标地址多个线路时,可能检测已越过汇聚层,除了第一个线路外,后续信息可能无效"))
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
fmt.Println("Press Enter to exit...")
|
||||
fmt.Scanln()
|
||||
}
|
||||
}
|
||||
|
||||
func validateStructuredOptions(options cliOptions) error {
|
||||
if options.jsonOutput && options.routeJSON {
|
||||
return fmt.Errorf("-json and -route-json are mutually exclusive")
|
||||
}
|
||||
if options.deep && !options.jsonOutput {
|
||||
return fmt.Errorf("-deep requires -json or -structured")
|
||||
}
|
||||
if options.routeJSON {
|
||||
if options.timeout <= 0 {
|
||||
return fmt.Errorf("route report timeout must be positive")
|
||||
}
|
||||
if options.routeTries < 1 || options.routeTries > 5 {
|
||||
return fmt.Errorf("route attempts must be between 1 and 5")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !options.jsonOutput {
|
||||
return nil
|
||||
}
|
||||
if options.specifiedIP == "" {
|
||||
return fmt.Errorf("structured report requires -ip")
|
||||
}
|
||||
if options.timeout <= 0 {
|
||||
return fmt.Errorf("structured report timeout must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStructuredRouteReport(output io.Writer, report backtrace.RouteReport) error {
|
||||
encoder := json.NewEncoder(output)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(report)
|
||||
}
|
||||
|
||||
func writeStructuredReport(ctx context.Context, output io.Writer, ip string, config bgptools.IPBGPReportConfig, query func(context.Context, string, bgptools.IPBGPReportConfig) (*bgptools.IPBGPReport, error)) error {
|
||||
report, err := query(ctx, ip, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := json.NewEncoder(output)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(report)
|
||||
}
|
||||
|
||||
214
cmd/main_test.go
214
cmd/main_test.go
@ -1,214 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/bgptools"
|
||||
backtrace "github.com/oneclickvirt/backtrace/bk"
|
||||
"github.com/oneclickvirt/backtrace/utils"
|
||||
"github.com/oneclickvirt/basics/network/resolver"
|
||||
)
|
||||
|
||||
func TestBacktraceStructuredFlagParsing(t *testing.T) {
|
||||
var options cliOptions
|
||||
set := newBacktraceFlagSet(&options)
|
||||
if err := set.Parse([]string{"-structured", "-deep", "-ip", "192.0.2.1", "-timeout", "900ms"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !options.jsonOutput || !options.deep || options.specifiedIP != "192.0.2.1" || options.timeout != 900*time.Millisecond {
|
||||
t.Fatalf("unexpected options: %+v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBacktraceDefaultsPreserveLegacyMode(t *testing.T) {
|
||||
var options cliOptions
|
||||
if err := newBacktraceFlagSet(&options).Parse(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !options.showIPInfo || options.jsonOutput || options.routeJSON || options.deep || options.ipv6 || options.specifiedIP != "" || options.dnsMode != "auto" || options.timeout != 15*time.Second || options.routeTries != 3 {
|
||||
t.Fatalf("legacy defaults changed: %+v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBacktraceDNSModeParsing(t *testing.T) {
|
||||
var options cliOptions
|
||||
if err := newBacktraceFlagSet(&options).Parse([]string{"-dns-mode", "DoT"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options.dnsMode = strings.ToLower(strings.TrimSpace(options.dnsMode))
|
||||
if options.dnsMode != "dot" {
|
||||
t.Fatalf("dns mode = %q, want dot", options.dnsMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureBacktraceResolverScopesBootstrapFallback(t *testing.T) {
|
||||
originalConfigure := backtraceDNSConfigureFn
|
||||
originalShutdown := backtraceDNSShutdownFn
|
||||
originalBootstrapReachable := backtraceDNSBootstrapReachableFn
|
||||
t.Cleanup(func() {
|
||||
backtraceDNSConfigureFn = originalConfigure
|
||||
backtraceDNSShutdownFn = originalShutdown
|
||||
backtraceDNSBootstrapReachableFn = originalBootstrapReachable
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
mode resolver.Mode
|
||||
connected bool
|
||||
bootstrapReachable bool
|
||||
configuredStatus resolver.Status
|
||||
wantConfigure int
|
||||
wantBootstrap int
|
||||
wantShutdown int
|
||||
}{
|
||||
{
|
||||
name: "connected auto uses normal resolver path",
|
||||
mode: resolver.ModeAuto,
|
||||
connected: true,
|
||||
configuredStatus: resolver.Status{Requested: resolver.ModeAuto, Active: resolver.ModeSystem, SystemAvailable: true},
|
||||
wantConfigure: 1,
|
||||
},
|
||||
{
|
||||
name: "offline auto stops when bootstrap is unreachable",
|
||||
mode: resolver.ModeAuto,
|
||||
wantBootstrap: 1,
|
||||
wantShutdown: 1,
|
||||
},
|
||||
{
|
||||
name: "offline auto retries after reachable bootstrap",
|
||||
mode: resolver.ModeAuto,
|
||||
bootstrapReachable: true,
|
||||
configuredStatus: resolver.Status{Requested: resolver.ModeAuto, Active: resolver.ModeDoH, DoHAvailable: true, Fallback: true, Stack: "IPv4"},
|
||||
wantConfigure: 1,
|
||||
wantBootstrap: 1,
|
||||
},
|
||||
{
|
||||
name: "forced DoH bypasses bootstrap gate",
|
||||
mode: resolver.ModeDoH,
|
||||
configuredStatus: resolver.Status{Requested: resolver.ModeDoH, Active: resolver.ModeDoH, DoHAvailable: true},
|
||||
wantConfigure: 1,
|
||||
},
|
||||
{
|
||||
name: "system mode never falls back",
|
||||
mode: resolver.ModeSystem,
|
||||
wantShutdown: 1,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configureCalls := 0
|
||||
bootstrapCalls := 0
|
||||
shutdownCalls := 0
|
||||
backtraceDNSConfigureFn = func(_ context.Context, config resolver.Config) resolver.Status {
|
||||
configureCalls++
|
||||
if config.Mode != test.mode {
|
||||
t.Fatalf("resolver mode = %q, want %q", config.Mode, test.mode)
|
||||
}
|
||||
return test.configuredStatus
|
||||
}
|
||||
backtraceDNSBootstrapReachableFn = func(_ context.Context, config resolver.Config) (string, bool) {
|
||||
bootstrapCalls++
|
||||
if config.Mode != resolver.ModeAuto {
|
||||
t.Fatalf("bootstrap mode = %q, want auto", config.Mode)
|
||||
}
|
||||
return "IPv4", test.bootstrapReachable
|
||||
}
|
||||
backtraceDNSShutdownFn = func() { shutdownCalls++ }
|
||||
|
||||
status := configureBacktraceResolver(context.Background(), test.mode, &utils.NetCheckResult{Connected: test.connected})
|
||||
if configureCalls != test.wantConfigure || bootstrapCalls != test.wantBootstrap || shutdownCalls != test.wantShutdown {
|
||||
t.Fatalf("DNS calls = configure:%d bootstrap:%d shutdown:%d, want configure:%d bootstrap:%d shutdown:%d", configureCalls, bootstrapCalls, shutdownCalls, test.wantConfigure, test.wantBootstrap, test.wantShutdown)
|
||||
}
|
||||
if test.wantConfigure == 0 && status.Active != resolver.ModeUnavailable {
|
||||
t.Fatalf("status = %#v, want unavailable", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteEncryptedDNSConnectivity(t *testing.T) {
|
||||
preCheck := &utils.NetCheckResult{StackType: "None"}
|
||||
promoteEncryptedDNSConnectivity(preCheck, resolver.Status{Active: resolver.ModeDoT, Stack: "IPv6"})
|
||||
if !preCheck.Connected || !preCheck.HasIPv6 || preCheck.HasIPv4 || preCheck.StackType != "IPv6" {
|
||||
t.Fatalf("successful encrypted DNS did not promote network state: %#v", preCheck)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBacktraceRouteStructuredFlagParsing(t *testing.T) {
|
||||
var options cliOptions
|
||||
set := newBacktraceFlagSet(&options)
|
||||
if err := set.Parse([]string{"-route-json", "-ipv6", "-route-attempts", "4", "-timeout", "9s"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !options.routeJSON || !options.ipv6 || options.routeTries != 4 || options.timeout != 9*time.Second {
|
||||
t.Fatalf("unexpected route options: %+v", options)
|
||||
}
|
||||
if err := validateStructuredOptions(options); err != nil {
|
||||
t.Fatalf("valid route options rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRouteStructuredOptions(t *testing.T) {
|
||||
for _, options := range []cliOptions{
|
||||
{jsonOutput: true, routeJSON: true, timeout: time.Second, routeTries: 3},
|
||||
{routeJSON: true, timeout: 0, routeTries: 3},
|
||||
{routeJSON: true, timeout: time.Second, routeTries: 0},
|
||||
{routeJSON: true, timeout: time.Second, routeTries: 6},
|
||||
} {
|
||||
if err := validateStructuredOptions(options); err == nil {
|
||||
t.Fatalf("expected invalid route options: %+v", options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStructuredRouteReportKeepsStdoutJSONOnly(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
report := backtrace.RouteReport{SchemaVersion: backtrace.RouteReportSchema, Targets: []backtrace.RouteTargetReport{}}
|
||||
if err := writeStructuredRouteReport(&output, report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded backtrace.RouteReport
|
||||
if err := json.Unmarshal(output.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("stdout is not route JSON: %v (%q)", err, output.String())
|
||||
}
|
||||
if decoded.SchemaVersion != backtrace.RouteReportSchema {
|
||||
t.Fatalf("unexpected route report: %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStructuredOptionsRequiresExplicitModeAndTarget(t *testing.T) {
|
||||
tests := []cliOptions{
|
||||
{deep: true, timeout: time.Second},
|
||||
{jsonOutput: true, timeout: time.Second},
|
||||
{jsonOutput: true, specifiedIP: "192.0.2.1"},
|
||||
}
|
||||
for _, options := range tests {
|
||||
if err := validateStructuredOptions(options); err == nil {
|
||||
t.Fatalf("expected invalid structured options: %+v", options)
|
||||
}
|
||||
}
|
||||
if err := validateStructuredOptions(cliOptions{jsonOutput: true, deep: true, specifiedIP: "192.0.2.1", timeout: time.Second}); err != nil {
|
||||
t.Fatalf("valid structured options rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStructuredReportKeepsStdoutJSONOnly(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
err := writeStructuredReport(context.Background(), &output, "192.0.2.1", bgptools.IPBGPReportConfig{Timeout: time.Second}, func(_ context.Context, ip string, _ bgptools.IPBGPReportConfig) (*bgptools.IPBGPReport, error) {
|
||||
return &bgptools.IPBGPReport{IP: ip, Status: bgptools.ReportAvailable, Sources: []bgptools.IPBGPSourceStatus{}}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var report bgptools.IPBGPReport
|
||||
if err := json.Unmarshal(output.Bytes(), &report); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v (%q)", err, output.String())
|
||||
}
|
||||
if report.IP != "192.0.2.1" {
|
||||
t.Fatalf("unexpected report: %+v", report)
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var privateErrorPattern = regexp.MustCompile("(?i)(https?://)[^\\s]+")
|
||||
var privateTokenPattern = regexp.MustCompile("(?i)(^|[?&\\s])(token|api[_-]?key|secret|authorization|password|key)=([^&\\s]+)")
|
||||
var privatePathPattern = regexp.MustCompile("(?:[A-Za-z]:\\\\|/)[^\\s]+")
|
||||
|
||||
func indentLegacyOutput(value string) string {
|
||||
lines := strings.Split(value, "\n")
|
||||
for index, line := range lines {
|
||||
if line != "" && line[0] != ' ' && line[0] != '\t' {
|
||||
lines[index] = " " + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// sanitizeErrorText keeps remote-source and credential details out of user-visible errors.
|
||||
func sanitizeErrorText(value string) string {
|
||||
value = privateErrorPattern.ReplaceAllString(value, "<remote>")
|
||||
value = privateTokenPattern.ReplaceAllString(value, "$1$2=<redacted>")
|
||||
return privatePathPattern.ReplaceAllString(value, "<path>")
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndentLegacyOutputLeavesOneLeadingCell(t *testing.T) {
|
||||
got := indentLegacyOutput("alpha\n beta\n\tgamma\n")
|
||||
want := " alpha\n beta\n\tgamma\n"
|
||||
if got != want {
|
||||
t.Fatalf("indentLegacyOutput() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeErrorTextRedactsRemoteSecretsAndPaths(t *testing.T) {
|
||||
input := "load https://private.example/repo/data?token=secret from /private/cache failed: api_key=hidden"
|
||||
got := sanitizeErrorText(input)
|
||||
for _, forbidden := range []string{"private.example", "secret", "hidden", "/private/cache"} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("sanitizeErrorText() leaked %q in %q", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,207 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/bgptools"
|
||||
)
|
||||
|
||||
const defaultSource = "https://raw.githubusercontent.com/xykt/NetQuality/main/ref/AS_Mapping.txt"
|
||||
|
||||
type document struct {
|
||||
Schema string `json:"schema"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Entries []bgptools.ASNMetadata `json:"entries"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Schema string `json:"schema"`
|
||||
File string `json:"file"`
|
||||
Count int `json:"count"`
|
||||
SHA256 string `json:"sha256"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
source := flag.String("source", defaultSource, "ASN name registry URL")
|
||||
output := flag.String("output", "bgptools/data/bgp-asn-map.json", "snapshot output path")
|
||||
minimum := flag.Int("min-count", bgptools.ASNMetadataMinimum, "minimum accepted ASN count")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "fetch timeout")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
entries, err := fetchEntries(ctx, http.DefaultClient, *source)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := updateSnapshot(*output, entries, *minimum); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchEntries(ctx context.Context, client *http.Client, source string) ([]bgptools.ASNMetadata, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-sync/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("ASN registry returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return parseEntries(io.LimitReader(response.Body, 4<<20))
|
||||
}
|
||||
|
||||
func parseEntries(reader io.Reader) ([]bgptools.ASNMetadata, error) {
|
||||
seen := make(map[uint32]string)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 || !strings.HasPrefix(strings.ToUpper(fields[0]), "AS") {
|
||||
continue
|
||||
}
|
||||
number, err := strconv.ParseUint(strings.TrimPrefix(strings.ToUpper(fields[0]), "AS"), 10, 32)
|
||||
if err != nil || number == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[uint32(number)]; !exists {
|
||||
seen[uint32(number)] = strings.Join(fields[1:], " ")
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]bgptools.ASNMetadata, 0, len(seen))
|
||||
for asn, name := range seen {
|
||||
entries = append(entries, bgptools.ASNMetadata{ASN: asn, Name: name})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].ASN < entries[j].ASN })
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("ASN registry contains no valid entries")
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func updateSnapshot(path string, entries []bgptools.ASNMetadata, minimum int) error {
|
||||
if len(entries) < minimum {
|
||||
return fmt.Errorf("ASN count %d is below minimum %d", len(entries), minimum)
|
||||
}
|
||||
currentData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var current document
|
||||
if err := json.Unmarshal(currentData, ¤t); err != nil || current.Schema != bgptools.ASNMetadataSchema {
|
||||
return fmt.Errorf("current ASN metadata schema is invalid")
|
||||
}
|
||||
if len(current.Entries) >= minimum && len(entries)*100 < len(current.Entries)*65 {
|
||||
return fmt.Errorf("ASN count dropped from %d to %d", len(current.Entries), len(entries))
|
||||
}
|
||||
if sameEntries(current.Entries, entries) && !current.GeneratedAt.IsZero() {
|
||||
return writeManifest(path, currentData, len(current.Entries))
|
||||
}
|
||||
next, err := json.MarshalIndent(document{Schema: bgptools.ASNMetadataSchema, GeneratedAt: time.Now().UTC(), Entries: entries}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next = append(next, '\n')
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".asn-metadata-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := temporary.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err := temporary.Write(next); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Chmod(0o644); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(name, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeManifest(path, next, len(entries))
|
||||
}
|
||||
|
||||
func writeManifest(snapshotPath string, snapshot []byte, count int) error {
|
||||
var snapshotDocument document
|
||||
if err := json.Unmarshal(snapshot, &snapshotDocument); err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sha256.Sum256(snapshot)
|
||||
value := manifest{
|
||||
Schema: bgptools.ASNMetadataManifestSchema, File: filepath.Base(snapshotPath), Count: count,
|
||||
SHA256: hex.EncodeToString(hash[:]), GeneratedAt: snapshotDocument.GeneratedAt,
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
manifestPath := strings.TrimSuffix(snapshotPath, ".json") + ".manifest.json"
|
||||
if current, readErr := os.ReadFile(manifestPath); readErr == nil && string(current) == string(data) {
|
||||
return nil
|
||||
}
|
||||
return writeAtomic(manifestPath, data)
|
||||
}
|
||||
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".asn-metadata-manifest-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := temporary.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Chmod(0o644); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, path)
|
||||
}
|
||||
|
||||
func sameEntries(left, right []bgptools.ASNMetadata) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oneclickvirt/backtrace/bgptools"
|
||||
)
|
||||
|
||||
func TestParseEntriesDeduplicatesAndSorts(t *testing.T) {
|
||||
entries, err := parseEntries(strings.NewReader("AS3356 Lumen\nAS174 Cogent\nAS3356 Level3\ninvalid\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0] != (bgptools.ASNMetadata{ASN: 174, Name: "Cogent"}) || entries[1].Name != "Lumen" {
|
||||
t.Fatalf("unexpected entries: %#v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSnapshotWritesMatchingManifestWithoutRewritingSnapshot(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "asn.json")
|
||||
current := []byte(`{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"},{"asn":2,"name":"Two"}]}`)
|
||||
if err := os.WriteFile(path, current, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := []bgptools.ASNMetadata{{ASN: 1, Name: "One"}, {ASN: 2, Name: "Two"}}
|
||||
if err := updateSnapshot(path, entries, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(after) != string(current) {
|
||||
t.Fatal("semantic no-op rewrote snapshot")
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(path), "asn.manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value manifest
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash := sha256.Sum256(current)
|
||||
if value.Schema != bgptools.ASNMetadataManifestSchema || value.File != "asn.json" || value.Count != 2 || value.SHA256 != hex.EncodeToString(hash[:]) {
|
||||
t.Fatalf("manifest mismatch: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSnapshotRejectsCountDrop(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "asn.json")
|
||||
current := `{"schema":"backtrace.asn-metadata/v1","generated_at":"2026-07-20T00:00:00Z","entries":[{"asn":1,"name":"One"},{"asn":2,"name":"Two"}]}`
|
||||
if err := os.WriteFile(path, []byte(current), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := updateSnapshot(path, []bgptools.ASNMetadata{{ASN: 1, Name: "One"}}, 1); err == nil {
|
||||
t.Fatal("count drop unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
@ -1,280 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var knownASNs = []string{"AS10099", "AS23764", "AS4134", "AS4809", "AS4837", "AS58453", "AS58807", "AS9808", "AS9929"}
|
||||
var errPrefixCountDrop = errors.New("prefix count dropped beyond protection threshold")
|
||||
|
||||
type updateConfig struct {
|
||||
SourceBase string
|
||||
OutputDir string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func main() {
|
||||
config := updateConfig{}
|
||||
flag.StringVar(&config.SourceBase, "source-base", "https://stat.ripe.net/data/announced-prefixes/data.json", "upstream announced-prefixes API")
|
||||
flag.StringVar(&config.OutputDir, "output-dir", "bk/prefix", "prefix snapshot directory")
|
||||
flag.DurationVar(&config.Timeout, "timeout", 30*time.Second, "upstream request timeout")
|
||||
flag.Parse()
|
||||
if err := updatePrefixes(context.Background(), http.DefaultClient, config); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func updatePrefixes(ctx context.Context, client *http.Client, config updateConfig) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if config.SourceBase == "" || config.OutputDir == "" || config.Timeout <= 0 {
|
||||
return errors.New("source-base, output-dir, and timeout must be valid")
|
||||
}
|
||||
valuesByASN := make(map[string][]string, len(knownASNs))
|
||||
for _, asn := range knownASNs {
|
||||
requestCtx, cancel := context.WithTimeout(ctx, config.Timeout)
|
||||
endpoint, err := url.Parse(config.SourceBase)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("parse source URL: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("resource", asn)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
request.Header.Set("User-Agent", "oneclickvirt-backtrace-prefix-sync/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("fetch %s: %w", asn, err)
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 8<<20))
|
||||
response.Body.Close()
|
||||
cancel()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("fetch %s: HTTP %d", asn, response.StatusCode)
|
||||
}
|
||||
values, err := extractIPv6Prefixes(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", asn, err)
|
||||
}
|
||||
valuesByASN[asn] = values
|
||||
}
|
||||
for _, asn := range knownASNs {
|
||||
path := filepath.Join(config.OutputDir, strings.ToLower(asn)+".txt")
|
||||
if err := writePrefixFile(path, valuesByASN[asn]); err != nil {
|
||||
if errors.Is(err, errPrefixCountDrop) {
|
||||
fmt.Fprintf(os.Stderr, "skip %s: %v\n", asn, err)
|
||||
current, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
valuesByASN[asn] = prefixValuesFromFile(current)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writePrefixManifest(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type prefixManifest struct {
|
||||
Schema string `json:"schema"`
|
||||
File string `json:"file"`
|
||||
Count int `json:"count"`
|
||||
SHA256 string `json:"sha256"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
func prefixValuesFromFile(data []byte) []string {
|
||||
values := make([]string, 0)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if value := strings.TrimSpace(line); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func writePrefixManifest(snapshotPath string) error {
|
||||
snapshot, err := os.ReadFile(snapshotPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values := prefixValuesFromFile(snapshot)
|
||||
if len(values) == 0 {
|
||||
return errors.New("prefix snapshot is empty")
|
||||
}
|
||||
hash := sha256.Sum256(snapshot)
|
||||
fileName := filepath.Base(snapshotPath)
|
||||
sha256Value := hex.EncodeToString(hash[:])
|
||||
manifestPath := snapshotPath + ".manifest.json"
|
||||
if current, readErr := os.ReadFile(manifestPath); readErr == nil {
|
||||
var existing prefixManifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(current))
|
||||
decoder.DisallowUnknownFields()
|
||||
var extra any
|
||||
if decoder.Decode(&existing) == nil && decoder.Decode(&extra) == io.EOF &&
|
||||
existing.Schema == "backtrace.asn-prefixes/v1" && existing.File == fileName &&
|
||||
existing.Count == len(values) && strings.EqualFold(existing.SHA256, sha256Value) {
|
||||
if _, timeErr := time.Parse(time.RFC3339, existing.GeneratedAt); timeErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return readErr
|
||||
}
|
||||
manifest := prefixManifest{Schema: "backtrace.asn-prefixes/v1", File: fileName, Count: len(values), SHA256: sha256Value, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return writeAtomicFile(manifestPath, data)
|
||||
}
|
||||
|
||||
func writeAtomicFile(output string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(output), ".prefix-manifest-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
defer os.Remove(temporaryName)
|
||||
if err := temporary.Chmod(0o644); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryName, output)
|
||||
}
|
||||
|
||||
func extractIPv6Prefixes(data []byte) ([]string, error) {
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Resource string `json:"resource"`
|
||||
Prefixes []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"prefixes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("decode announced-prefixes response: %w", err)
|
||||
}
|
||||
if response.Status != "ok" || response.Data.Resource == "" || response.Data.Prefixes == nil {
|
||||
return nil, errors.New("announced-prefixes response is missing required fields")
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, record := range response.Data.Prefixes {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(record.Prefix))
|
||||
if err != nil || !prefix.Addr().Is6() || prefix.Bits() < 16 || prefix.Bits() > 128 {
|
||||
continue
|
||||
}
|
||||
seen[prefix.String()] = struct{}{}
|
||||
}
|
||||
values := make([]string, 0, len(seen))
|
||||
for value := range seen {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Strings(values)
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New("no IPv6 prefixes found")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func writePrefixFile(output string, values []string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate := []byte(strings.Join(values, "\n") + "\n")
|
||||
current, readErr := os.ReadFile(output)
|
||||
if readErr == nil {
|
||||
currentCount := countNonemptyLines(current)
|
||||
if currentCount > 0 && len(values)*100 < currentCount*65 {
|
||||
return fmt.Errorf("%w: prefix count dropped from %d to %d for %s", errPrefixCountDrop, currentCount, len(values), filepath.Base(output))
|
||||
}
|
||||
if bytes.Equal(current, candidate) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return readErr
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(output), ".prefix-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
defer os.Remove(temporaryName)
|
||||
if err := temporary.Chmod(0o644); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(candidate); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryName, output)
|
||||
}
|
||||
|
||||
func countNonemptyLines(data []byte) int {
|
||||
count := 0
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractIPv6Prefixes(t *testing.T) {
|
||||
values, err := extractIPv6Prefixes([]byte(`{"status":"ok","data":{"resource":"64500","prefixes":[{"prefix":"2409:8000::/32"},{"prefix":"2409:8000::/32"},{"prefix":"192.0.2.0/24"}]}}`))
|
||||
if err != nil || len(values) != 1 || values[0] != "2409:8000::/32" {
|
||||
t.Fatalf("unexpected prefixes: %#v, %v", values, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIPv6PrefixesRejectsSchemaDrift(t *testing.T) {
|
||||
if _, err := extractIPv6Prefixes([]byte(`{"status":"ok","data":{"resource":"64500"}}`)); err == nil {
|
||||
t.Fatal("missing prefixes unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePrefixFileProtectsLargeCountDrop(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "as64500.txt")
|
||||
if err := os.WriteFile(path, []byte("2001:db8:1::/48\n2001:db8:2::/48\n2001:db8:3::/48\n2001:db8:4::/48\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := writePrefixFile(path, []string{"2001:db8:1::/48"})
|
||||
if !errors.Is(err, errPrefixCountDrop) {
|
||||
t.Fatalf("count drop error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePrefixManifestPreservesGeneratedAtWhenSnapshotIsUnchanged(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
snapshotPath := filepath.Join(directory, "as64500.txt")
|
||||
if err := os.WriteFile(snapshotPath, []byte("2001:db8::/48\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := snapshotPath + ".manifest.json"
|
||||
old := prefixManifest{Schema: "backtrace.asn-prefixes/v1", File: "as64500.txt", Count: 1, SHA256: "", GeneratedAt: "2020-01-02T03:04:05Z"}
|
||||
data, err := os.ReadFile(snapshotPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed a valid manifest with an old timestamp. The updater must retain it.
|
||||
hash := sha256.Sum256(data)
|
||||
old.SHA256 = hex.EncodeToString(hash[:])
|
||||
encoded, err := json.Marshal(old)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(manifestPath, append(encoded, '\n'), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writePrefixManifest(snapshotPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(actual, []byte(`"generated_at":"2020-01-02T03:04:05Z"`)) {
|
||||
t.Fatalf("generated_at was not preserved: %s", actual)
|
||||
}
|
||||
}
|
||||
40
go.mod
40
go.mod
@ -1,27 +1,33 @@
|
||||
module github.com/oneclickvirt/backtrace
|
||||
|
||||
go 1.25.3
|
||||
go 1.22.4
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/imroc/req/v3 v3.59.0
|
||||
github.com/oneclickvirt/basics v0.0.34
|
||||
github.com/oneclickvirt/defaultset v0.0.2-20240624082446
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sys v0.47.0
|
||||
github.com/imroc/req/v3 v3.50.0
|
||||
github.com/oneclickvirt/defaultset v0.0.0-20240624051018-30a50859e1b5
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/miekg/dns v1.1.73 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/cloudflare/circl v1.5.0 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||
github.com/refraction-networking/utls v1.6.7 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
)
|
||||
|
||||
103
go.sum
103
go.sum
@ -1,60 +1,71 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
|
||||
github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
|
||||
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo=
|
||||
github.com/imroc/req/v3 v3.59.0/go.mod h1:cwLwwaE90Pq6ZMmKuHA9FivJbg3avnCH8/RA5lUUs74=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
github.com/oneclickvirt/basics v0.0.34 h1:iT2lFMrWcxw0UwnFyw4Gasa2CDp4ERULsckBTFVzSUQ=
|
||||
github.com/oneclickvirt/basics v0.0.34/go.mod h1:AqnE9cDa4ThZRvTq7UlUmLYSZBY6CPBzbUhxGT3JWyU=
|
||||
github.com/oneclickvirt/defaultset v0.0.2-20240624082446 h1:5Pg3mK/u/vQvSz7anu0nxzrNdELi/AcDAU1mMsmPzyc=
|
||||
github.com/oneclickvirt/defaultset v0.0.2-20240624082446/go.mod h1:e9Jt4tf2sbemCtc84/XgKcHy9EZ2jkc5x2sW1NiJS+E=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/imroc/req/v3 v3.50.0 h1:n3BVnZiTRpvkN5T1IB79LC/THhFU9iXksNRMH4ZNVaY=
|
||||
github.com/imroc/req/v3 v3.50.0/go.mod h1:tsOk8K7zI6cU4xu/VWCZVtq9Djw9IWm4MslKzme5woU=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/oneclickvirt/defaultset v0.0.0-20240624051018-30a50859e1b5 h1:TUM6XzOB7Z7OxyXi3fwlZY9KfuVbvUBusYiNbSfX208=
|
||||
github.com/oneclickvirt/defaultset v0.0.0-20240624051018-30a50859e1b5/go.mod h1:e9Jt4tf2sbemCtc84/XgKcHy9EZ2jkc5x2sW1NiJS+E=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
|
||||
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
|
||||
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
|
||||
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
|
||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
|
||||
@ -2,7 +2,7 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
const BackTraceVersion = "v0.0.21"
|
||||
const BackTraceVersion = "v0.0.5"
|
||||
|
||||
var EnableLoger = false
|
||||
|
||||
@ -15,9 +15,8 @@ type IcmpTarget struct {
|
||||
}
|
||||
|
||||
var (
|
||||
IcmpTargets = "https://raw.githubusercontent.com/spiritLHLS/icmp_targets/main/nodes.json"
|
||||
IcmpTargets = "https://raw.githubusercontent.com/spiritLHLS/icmp_targets/refs/heads/main/nodes.json"
|
||||
CdnList = []string{
|
||||
"https://cdn.spiritlhl.net/",
|
||||
"http://cdn1.spiritlhl.net/",
|
||||
"http://cdn2.spiritlhl.net/",
|
||||
"http://cdn3.spiritlhl.net/",
|
||||
@ -68,7 +67,6 @@ var (
|
||||
"AS4809": "电信CN2 [优质线路]",
|
||||
"AS4134": "电信163 [普通线路]",
|
||||
"AS9929": "联通9929 [优质线路]",
|
||||
"AS10099": "联通CUG [优质线路]",
|
||||
"AS4837": "联通4837 [普通线路]",
|
||||
"AS58807": "移动CMIN2 [精品线路]",
|
||||
"AS9808": "移动CMI [普通线路]",
|
||||
@ -78,82 +76,3 @@ var (
|
||||
CachedIcmpDataFetchTime time.Time
|
||||
ParsedIcmpTargets []IcmpTarget
|
||||
)
|
||||
|
||||
var Tier1Global = map[string]string{
|
||||
"174": "Cogent",
|
||||
"1299": "Arelion",
|
||||
"3356": "Lumen",
|
||||
"3257": "GTT",
|
||||
"7018": "AT&T",
|
||||
"701": "Verizon",
|
||||
"2914": "NTT",
|
||||
"6453": "Tata",
|
||||
"3320": "DTAG",
|
||||
"5511": "Orange",
|
||||
"3491": "PCCW",
|
||||
"6461": "Zayo",
|
||||
"6830": "Liberty",
|
||||
"6762": "Sparkle",
|
||||
"12956": "Telxius",
|
||||
"702": "Verizon",
|
||||
}
|
||||
|
||||
var Tier1Regional = map[string]string{
|
||||
"4134": "ChinaNet",
|
||||
"4837": "China Unicom",
|
||||
"9808": "China Mobile",
|
||||
"4766": "Korea Telecom",
|
||||
"2516": "KDDI",
|
||||
"7713": "Telkomnet",
|
||||
"9121": "Etisalat",
|
||||
"7473": "SingTel",
|
||||
"4637": "Telstra",
|
||||
"5400": "British Telecom",
|
||||
"2497": "IIJ",
|
||||
"3462": "Chunghwa Telecom",
|
||||
"3463": "TWNIC",
|
||||
"12389": "SoftBank",
|
||||
"3303": "MTS",
|
||||
"45609": "Reliance Jio",
|
||||
}
|
||||
|
||||
var Tier2 = map[string]string{
|
||||
"6939": "HurricaneElectric",
|
||||
"20485": "Transtelecom",
|
||||
"1273": "Vodafone",
|
||||
"1239": "Sprint",
|
||||
"6453": "Tata",
|
||||
"6762": "Sparkle",
|
||||
"9002": "RETN",
|
||||
"7922": "Comcast",
|
||||
"23754": "Rostelecom",
|
||||
"3320": "DTAG",
|
||||
}
|
||||
|
||||
var ContentProviders = map[string]string{
|
||||
"15169": "Google",
|
||||
"32934": "Facebook",
|
||||
"54113": "Fastly",
|
||||
"20940": "Akamai",
|
||||
"13335": "Cloudflare",
|
||||
"14618": "Amazon AWS",
|
||||
"55102": "Netflix CDN",
|
||||
"4685": "CacheFly",
|
||||
"16509": "Amazon",
|
||||
"36040": "Amazon CloudFront",
|
||||
"36459": "EdgeCast",
|
||||
"24940": "CDNetworks",
|
||||
}
|
||||
|
||||
var IXPS = map[string]string{
|
||||
"5539": "IX.br",
|
||||
"25291": "HKIX",
|
||||
"1200": "AMS-IX",
|
||||
"6695": "DE-CIX",
|
||||
"58558": "LINX",
|
||||
"395848": "France-IX",
|
||||
"4713": "JPNAP",
|
||||
"4635": "SIX",
|
||||
"2906": "MSK-IX",
|
||||
"1273": "NIX.CZ",
|
||||
}
|
||||
|
||||
@ -1,262 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install/update this repository's Skill and optional Python stdio MCP adapter."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import io
|
||||
import zipfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REPOSITORY = "oneclickvirt/backtrace"
|
||||
DEFAULT_REF = "main"
|
||||
SKILL_NAME = "oneclickvirt-backtrace"
|
||||
MAX_FILE = 2 * 1024 * 1024
|
||||
|
||||
|
||||
class SafeRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if redirected is not None and urllib.parse.urlsplit(req.full_url).netloc != urllib.parse.urlsplit(newurl).netloc:
|
||||
redirected.remove_header("Authorization")
|
||||
return redirected
|
||||
|
||||
|
||||
def fetch(url, limit=MAX_FILE):
|
||||
headers = {"User-Agent": "oneclickvirt-agent-installer/1.0"}
|
||||
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
if token and urllib.parse.urlsplit(url).netloc == "api.github.com":
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.build_opener(SafeRedirect()).open(req, timeout=40) as response:
|
||||
data = response.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise ValueError("Integration file exceeds the size limit")
|
||||
return data
|
||||
|
||||
|
||||
def release_binary(profile, version):
|
||||
if not profile.get("asset_prefix"):
|
||||
raise ValueError("This integration does not distribute a standalone binary")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.+/-]{1,128}", version):
|
||||
raise ValueError("Invalid release tag")
|
||||
repo = profile.get("release_repository", REPOSITORY)
|
||||
suffix = "latest" if version == "latest" else "tags/" + urllib.parse.quote(version, safe="")
|
||||
release = json.loads(fetch("https://api.github.com/repos/" + repo + "/releases/" + suffix))
|
||||
if release.get("draft") or (version == "latest" and release.get("prerelease")):
|
||||
raise ValueError("Release is not a stable published release")
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64",
|
||||
"i386": "386", "i686": "386", "armv7l": "arm"}.get(machine, machine)
|
||||
separator = profile.get("asset_separator", "-")
|
||||
stem = separator.join([profile["asset_prefix"], system, arch])
|
||||
candidates = [a for a in release["assets"] if a["name"] in (stem, stem + ".exe", stem + ".zip")]
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("Release has no unique binary for " + system + "/" + arch + "; use --binary with an existing build")
|
||||
asset = candidates[0]
|
||||
url = asset["browser_download_url"]
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
expected_prefix = "https://github.com/" + repo + "/releases/download/"
|
||||
if not url.startswith(expected_prefix) or parsed.scheme != "https":
|
||||
raise ValueError("Unexpected binary download URL")
|
||||
content = fetch(url, limit=256 * 1024 * 1024)
|
||||
digest = asset.get("digest") or ""
|
||||
checksum = hashlib.sha256(content).hexdigest()
|
||||
if digest and digest != "sha256:" + checksum:
|
||||
raise ValueError("Published binary digest does not match the download")
|
||||
if not digest:
|
||||
checksum_assets = [a for a in release["assets"] if a["name"] in ("checksums.txt", "SHA256SUMS")]
|
||||
verified = False
|
||||
for entry in checksum_assets:
|
||||
checksum_url = entry["browser_download_url"]
|
||||
if not checksum_url.startswith(expected_prefix):
|
||||
raise ValueError("Unexpected checksum download URL")
|
||||
for line in fetch(checksum_url).decode().splitlines():
|
||||
match = re.fullmatch(r"([a-fA-F0-9]{64})\s+\*?(.+)", line)
|
||||
if match and match[2] == asset["name"] and match[1].lower() == checksum:
|
||||
verified = True
|
||||
if not verified:
|
||||
raise ValueError("Binary has no matching SHA-256 digest/checksum; existing installations were not changed")
|
||||
if asset["name"].endswith(".zip"):
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
entries = [f for f in archive.infolist() if Path(f.filename).name in (profile["binary"], profile["binary"] + ".exe") and not f.is_dir()]
|
||||
if len(entries) != 1 or entries[0].file_size > 256 * 1024 * 1024:
|
||||
raise ValueError("Archive does not contain one expected executable")
|
||||
content = archive.read(entries[0])
|
||||
return content, {"version": release["tag_name"], "asset": asset["name"], "archive_sha256": checksum,
|
||||
"binary_sha256": hashlib.sha256(content).hexdigest()}
|
||||
|
||||
|
||||
def resolve_source(source, ref):
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.+/-]{1,160}", ref) or ".." in ref:
|
||||
raise ValueError("Invalid source ref")
|
||||
if source == "github":
|
||||
info = json.loads(fetch("https://api.github.com/repos/" + REPOSITORY + "/commits/" + urllib.parse.quote(ref, safe="")))
|
||||
commit = info["sha"]
|
||||
base = "https://raw.githubusercontent.com/" + REPOSITORY + "/" + commit + "/"
|
||||
else:
|
||||
# Public Git refs require no credentials. Pin the CNB snapshot itself,
|
||||
# whose commit differs from the history-free GitHub source mirror.
|
||||
result = subprocess.check_output(["git", "-c", "credential.helper=", "ls-remote", "https://cnb.cool/" + REPOSITORY,
|
||||
ref if re.fullmatch(r"[a-f0-9]{40}", ref) else "refs/heads/" + ref,
|
||||
"refs/tags/" + ref, "refs/tags/" + ref + "^{}"], text=True, timeout=45)
|
||||
rows = [line.split() for line in result.splitlines()]
|
||||
if re.fullmatch(r"[a-f0-9]{40}", ref):
|
||||
commit = ref
|
||||
elif rows:
|
||||
peeled = [row[0] for row in rows if row[1].endswith("^{}")]
|
||||
commit = (peeled or [row[0] for row in rows])[0]
|
||||
else:
|
||||
raise ValueError("CNB source ref does not exist")
|
||||
base = "https://cnb.cool/" + REPOSITORY + "/-/git/raw/" + commit + "/"
|
||||
if not re.fullmatch(r"[a-f0-9]{40}", commit):
|
||||
raise ValueError("Source did not resolve to an immutable commit")
|
||||
return commit, lambda path: fetch(base + path)
|
||||
|
||||
|
||||
def load_payload(reader):
|
||||
manifest = json.loads(reader("agent/manifest.json"))
|
||||
if manifest.get("repository") != REPOSITORY or manifest.get("skill_name") != SKILL_NAME:
|
||||
raise ValueError("Manifest does not identify the requested integration")
|
||||
expected = {"agent/profile.json", "skills/" + SKILL_NAME + "/SKILL.md", "skills/" + SKILL_NAME + "/SKILL_ZH.md"}
|
||||
if manifest.get("mcp"):
|
||||
expected.add("agent/server.py")
|
||||
if set(manifest.get("files", {})) != expected:
|
||||
raise ValueError("Manifest contains missing or unexpected file paths")
|
||||
data = {}
|
||||
for path, digest in manifest["files"].items():
|
||||
content = reader(path)
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", digest) or hashlib.sha256(content).hexdigest() != digest:
|
||||
raise ValueError("SHA-256 verification failed for " + path)
|
||||
data[path] = content
|
||||
profile = json.loads(data["agent/profile.json"])
|
||||
if profile.get("repository") != REPOSITORY or profile.get("skill_name") != SKILL_NAME:
|
||||
raise ValueError("Profile does not match the requested repository")
|
||||
return manifest, data
|
||||
|
||||
|
||||
def atomic_write(target, content):
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.is_symlink():
|
||||
raise ValueError("Refusing to replace a symlink: " + str(target))
|
||||
handle, temporary = tempfile.mkstemp(prefix=".oneclickvirt-", dir=target.parent)
|
||||
try:
|
||||
with os.fdopen(handle, "wb") as stream:
|
||||
stream.write(content)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def install(manifest, payload, skill_dir, mcp_dir, record, skill_only=False, binary=None, binary_content=None):
|
||||
skill_dir = Path(skill_dir).expanduser().absolute()
|
||||
mcp_dir = Path(mcp_dir).expanduser().absolute()
|
||||
if skill_dir.is_symlink() or mcp_dir.is_symlink():
|
||||
raise ValueError("The integration destination itself must not be a symlink")
|
||||
has_mcp = manifest.get("mcp") and not skill_only
|
||||
writes = {}
|
||||
if binary_content is not None:
|
||||
writes[Path(binary)] = binary_content
|
||||
for filename in ("SKILL.md", "SKILL_ZH.md"):
|
||||
writes[skill_dir / filename] = payload["skills/" + SKILL_NAME + "/" + filename]
|
||||
if has_mcp:
|
||||
writes[mcp_dir / "server.py"] = payload["agent/server.py"]
|
||||
writes[mcp_dir / "profile.json"] = payload["agent/profile.json"]
|
||||
env = {}
|
||||
if binary:
|
||||
env["ONECLICKVIRT_MCP_BINARY"] = str(Path(binary).expanduser().resolve())
|
||||
config = {"mcpServers": {SKILL_NAME: {"command": sys.executable,
|
||||
"args": [str(mcp_dir / "server.py")], "env": env}}}
|
||||
writes[skill_dir / "mcp.example.json"] = (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode()
|
||||
writes[skill_dir / "mcp.claude.json"] = writes[skill_dir / "mcp.example.json"]
|
||||
server = dict(config["mcpServers"][SKILL_NAME], requestTimeoutMs=180000)
|
||||
writes[skill_dir / "mcp.openclaw.json"] = (json.dumps({"mcp": {"servers": {SKILL_NAME: server}}}, ensure_ascii=False, indent=2) + "\n").encode()
|
||||
toml = "[mcp_servers." + SKILL_NAME + "]\ncommand = " + json.dumps(sys.executable, ensure_ascii=False)
|
||||
toml += "\nargs = " + json.dumps([str(mcp_dir / "server.py")], ensure_ascii=False) + "\ntool_timeout_sec = 180\n"
|
||||
if env:
|
||||
toml += "\n[mcp_servers." + SKILL_NAME + ".env]\n"
|
||||
toml += "".join(key + " = " + json.dumps(value, ensure_ascii=False) + "\n" for key, value in env.items())
|
||||
writes[skill_dir / "mcp.codex.toml"] = toml.encode("utf-8")
|
||||
record = dict(record, repository=REPOSITORY, skill_name=SKILL_NAME, mcp_installed=bool(has_mcp),
|
||||
files=manifest["files"])
|
||||
writes[skill_dir / "install.json"] = (json.dumps(record, indent=2) + "\n").encode()
|
||||
# Verify the entire payload and every destination before replacing anything.
|
||||
for path in writes:
|
||||
if path.is_symlink():
|
||||
raise ValueError("Symlink installation file is not supported: " + str(path))
|
||||
if path.exists() and not path.is_file():
|
||||
raise ValueError("Destination is not a file: " + str(path))
|
||||
for path, content in writes.items():
|
||||
atomic_write(path, content)
|
||||
if binary_content is not None and path == Path(binary) and os.name != "nt":
|
||||
path.chmod(0o755)
|
||||
return {"skill_dir": str(skill_dir), "mcp_dir": str(mcp_dir) if has_mcp else None,
|
||||
"source_commit": record["commit"], "mcp_installed": bool(has_mcp)}
|
||||
|
||||
|
||||
def main():
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--client", choices=["claude", "codex", "openclaw"], default="claude")
|
||||
parser.add_argument("--source", choices=["github", "cnb"], default="github")
|
||||
parser.add_argument("--ref", default=DEFAULT_REF, help="Source branch, tag or immutable commit")
|
||||
parser.add_argument("--skill-dir", type=Path)
|
||||
parser.add_argument("--mcp-dir", type=Path, default=Path.home() / ".local/share/oneclickvirt-mcp" / SKILL_NAME)
|
||||
parser.add_argument("--skill-only", action="store_true")
|
||||
parser.add_argument("--binary", help="Absolute path to an already installed runtime/probe binary")
|
||||
parser.add_argument("--install-binary", action="store_true", help="Install the verified published probe alongside the adapter")
|
||||
parser.add_argument("--version", default="latest", help="Probe release tag; independent of the integration source --ref")
|
||||
parser.add_argument("--bin-dir", type=Path, default=Path.home() / ".local/bin")
|
||||
parser.add_argument("--source-dir", type=Path, help="Install from a verified local source checkout")
|
||||
args = parser.parse_args()
|
||||
if args.source_dir:
|
||||
root = args.source_dir.resolve()
|
||||
commit = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
|
||||
reader = lambda path: (root / path).read_bytes()
|
||||
else:
|
||||
commit, reader = resolve_source(args.source, args.ref)
|
||||
manifest, payload = load_payload(reader)
|
||||
if args.binary and args.install_binary:
|
||||
raise ValueError("Choose --binary or --install-binary")
|
||||
binary_content = None
|
||||
binary_record = {}
|
||||
binary = args.binary
|
||||
if binary:
|
||||
candidate = Path(binary).expanduser()
|
||||
if not candidate.is_absolute() or not candidate.is_file() or (os.name != "nt" and not os.access(candidate, os.X_OK)):
|
||||
raise ValueError("--binary must name an existing executable by its absolute path")
|
||||
if args.install_binary:
|
||||
if args.skill_only or not manifest.get("mcp"):
|
||||
raise ValueError("Binary installation requires an MCP-enabled integration")
|
||||
profile = json.loads(payload["agent/profile.json"])
|
||||
binary_content, binary_record = release_binary(profile, args.version)
|
||||
binary = str(args.bin_dir.expanduser().absolute() / (profile["binary"] + (".exe" if os.name == "nt" else "")))
|
||||
parents = {"claude": Path.home() / ".claude/skills", "codex": Path.home() / ".agents/skills",
|
||||
"openclaw": Path.home() / ".openclaw/skills"}
|
||||
result = install(manifest, payload, args.skill_dir or parents[args.client] / SKILL_NAME,
|
||||
args.mcp_dir, {"source": args.source, "ref": args.ref, "commit": commit, "binary": binary_record},
|
||||
args.skill_only, binary, binary_content)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
if result["mcp_installed"]:
|
||||
filename = {"codex": "mcp.codex.toml", "claude": "mcp.claude.json", "openclaw": "mcp.openclaw.json"}[args.client]
|
||||
print("Merge " + filename + " into your client's MCP settings. Existing settings and tokens were not modified.")
|
||||
else:
|
||||
print("Installed Skill files. This repository does not install an MCP server in this mode.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
print("Installation failed: " + str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@ -1,56 +0,0 @@
|
||||
---
|
||||
name: oneclickvirt-backtrace
|
||||
description: "Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute."
|
||||
metadata:
|
||||
repository: oneclickvirt/backtrace
|
||||
integration-version: "1.0.0"
|
||||
---
|
||||
|
||||
# backtrace
|
||||
|
||||
## Scope
|
||||
|
||||
Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute.
|
||||
|
||||
- Read probe_help from the installed binary before assuming flags are supported. MCP returns native JSON when the program provides JSON, otherwise native text.
|
||||
- Keep partial/network errors distinct from confirmed test outcomes. Current release assets and their actual architecture are available through release_assets.
|
||||
- The ip-info preset requires a literal target IP. Do not pass a host, proxy, shell expression or a network range.
|
||||
|
||||
## Install and update
|
||||
|
||||
```bash
|
||||
curl -fL --retry 3 https://raw.githubusercontent.com/oneclickvirt/backtrace/main/scripts/install_agent.py -o install_agent.py
|
||||
python3 install_agent.py --client codex
|
||||
```
|
||||
|
||||
The installer is also mirrored at `https://cnb.cool/oneclickvirt/backtrace/-/git/raw/main/scripts/install_agent.py`; add `--source cnb` to install from an immutable CNB snapshot (requires Git).
|
||||
|
||||
Use `--client claude` or `--client openclaw` for another client. Download and run again to update; `--ref` pins a source tag/commit. The installer verifies all SHA-256 checksums before replacing its files, preserves extra local files/client settings, and records the source commit in `install.json`.
|
||||
|
||||
## MCP
|
||||
|
||||
Requires Python 3.9+. The adapter uses existing local commands and their existing permissions; it does not install a virtualization runtime. Merge the generated `mcp.codex.toml`, `mcp.claude.json`, or `mcp.openclaw.json` into that client's existing configuration. For a remote host, run its adapter over noninteractive SSH.
|
||||
|
||||
```bash
|
||||
python3 install_agent.py --client codex --install-binary
|
||||
```
|
||||
|
||||
`--version` selects the probe release tag; `--ref` selects Skill/MCP source independently. The default resolves the latest stable GitHub release once, selects a real asset for this OS/architecture, and verifies SHA-256. Missing assets fail without guessing. Reuse an existing executable with `--binary /absolute/path/to/program`.
|
||||
|
||||
Call `integration_info` first for capabilities and prerequisites.
|
||||
|
||||
Available probe presets:
|
||||
|
||||
- `ip-info`: `backtrace -json -timeout 12s -ip <target>`
|
||||
|
||||
`release_assets` returns the actual version, filenames, sizes, digests and download URLs. Pass an exact tag for a pinned release. After a product update, verify help and an actual call rather than treating file presence as a successful test.
|
||||
|
||||
## Source and documentation
|
||||
|
||||
- [`backtrace_install.sh`](https://github.com/oneclickvirt/backtrace/blob/main/backtrace_install.sh)
|
||||
- [`cmd/main.go`](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go)
|
||||
- [`go.mod`](https://github.com/oneclickvirt/backtrace/blob/main/go.mod)
|
||||
|
||||
- [集成安装说明 / Integration guide](https://www.spiritlhl.net/guide/agent_integrations.html)
|
||||
|
||||
Check the relevant source and current documentation before giving commands. Preserve requested version pins, data paths and native host identifiers. Distinguish actual program execution, isolated tests and unverified environments.
|
||||
@ -1,56 +0,0 @@
|
||||
---
|
||||
name: oneclickvirt-backtrace
|
||||
description: "查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。"
|
||||
metadata:
|
||||
repository: oneclickvirt/backtrace
|
||||
integration-version: "1.0.0"
|
||||
---
|
||||
|
||||
# backtrace
|
||||
|
||||
## 使用范围
|
||||
|
||||
查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。
|
||||
|
||||
- 先用 probe_help 核对已安装程序的实际帮助。程序支持 JSON 时返回原生 JSON,否则返回原生文本。
|
||||
- 区分部分结果、网络失败和确定结论;通过 release_assets 查询当前真实产物及架构。
|
||||
- ip-info 预设只接受单个真实 IP,不接受域名、代理、Shell 表达式或网段。
|
||||
|
||||
## 安装和更新
|
||||
|
||||
```bash
|
||||
curl -fL --retry 3 https://raw.githubusercontent.com/oneclickvirt/backtrace/main/scripts/install_agent.py -o install_agent.py
|
||||
python3 install_agent.py --client codex
|
||||
```
|
||||
|
||||
可将下载地址改为 `https://cnb.cool/oneclickvirt/backtrace/-/git/raw/main/scripts/install_agent.py`,并使用 `--source cnb` 从 CNB 的固定提交安装;此模式另需 Git。
|
||||
|
||||
用 `--client claude` 或 `--client openclaw` 选择其他客户端。重新下载并执行可更新;`--ref` 固定源码标签或提交。安装前校验全部 SHA-256,保留额外本地文件及已有客户端设置,`install.json` 记录来源提交。
|
||||
|
||||
## MCP
|
||||
|
||||
运行需要 Python 3.9 以上版本;MCP 使用本机已有程序及其权限,不自动安装虚拟化环境。将安装目录内对应客户端的 `mcp.codex.toml`、`mcp.claude.json` 或 `mcp.openclaw.json` 合并到已有配置。远端宿主机应通过无交互 SSH 运行该主机上的适配器。
|
||||
|
||||
```bash
|
||||
python3 install_agent.py --client codex --install-binary
|
||||
```
|
||||
|
||||
`--version` 可指定检测程序的正式 Release 标签,`--ref` 则指定 Skill/MCP 源码版本,两者独立。默认从 GitHub 解析一次最新正式版,选择本机系统/架构的真实产物并校验 SHA-256;没有匹配产物时停止。已有程序可用 `--binary /完整路径/程序` 复用。
|
||||
|
||||
先调用 `integration_info` 查看能力和运行要求。
|
||||
|
||||
可用检测预设:
|
||||
|
||||
- `ip-info`: `backtrace -json -timeout 12s -ip <target>`
|
||||
|
||||
`release_assets` 返回实际版本、文件名、大小、摘要及下载地址;需要固定版本时传完整标签。更新产物后应重新安装并验证帮助和实际调用,不能只因文件存在便判定可用。
|
||||
|
||||
## 对应源码与说明
|
||||
|
||||
- [`backtrace_install.sh`](https://github.com/oneclickvirt/backtrace/blob/main/backtrace_install.sh)
|
||||
- [`cmd/main.go`](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go)
|
||||
- [`go.mod`](https://github.com/oneclickvirt/backtrace/blob/main/go.mod)
|
||||
|
||||
- [集成安装说明 / Integration guide](https://www.spiritlhl.net/guide/agent_integrations.html)
|
||||
|
||||
给出操作命令前核对这些源码文件及当前文档,保留现有版本固定、数据路径和宿主机标识。报告测试时区分真实程序执行、隔离测试及未验证的环境。
|
||||
@ -1,13 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
basicsutils "github.com/oneclickvirt/basics/utils"
|
||||
)
|
||||
|
||||
type NetCheckResult = basicsutils.NetCheckResult
|
||||
|
||||
func CheckPublicAccess(timeout time.Duration) NetCheckResult {
|
||||
return basicsutils.CheckPublicAccess(timeout)
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckPublicAccess(t *testing.T) {
|
||||
if os.Getenv("BACKTRACE_INTEGRATION") != "1" {
|
||||
t.Skip("set BACKTRACE_INTEGRATION=1 to run live public-access checks")
|
||||
}
|
||||
timeout := 3 * time.Second
|
||||
result := CheckPublicAccess(timeout)
|
||||
if result.Connected {
|
||||
fmt.Printf("✅ 本机有公网连接,类型: %s\n", result.StackType)
|
||||
} else {
|
||||
fmt.Println("❌ 本机未检测到公网连接")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user