Compare commits

..

No commits in common. "main" and "v0.0.5-20250411151652" have entirely different histories.

86 changed files with 2695 additions and 17162 deletions

View File

@ -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

View File

@ -1,12 +0,0 @@
npc:
roles:
- name: 使用说明
prompt: 依据当前仓库与已引入的公开文档知识库回答安装、使用和版本问题,附上文档链接。核对文档对应的版本与架构;没有依据时说明缺少的信息。仅提供说明和操作建议,不执行宿主机操作,也不请求或展示密钥及私有仓库内容。
defaultRepo: oneclickvirt/backtrace
defaultRole: 使用说明
button:
name: 使用说明
description: 查询公开的安装和使用文档
imports:
list:
- oneclickvirt/oneclickvirt.github.io

View File

@ -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

View File

@ -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 "$@"; }

View File

@ -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)

View File

@ -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

View File

@ -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 * * *

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
# backtrace
[![Hits](https://hits.spiritlhl.net/backtrace.svg?action=hit&title=Hits&title_bg=%23555555&count_bg=%230eecf8&edge_flat=false)](https://hits.spiritlhl.net) [![Downloads](https://ghdownload.spiritlhl.net/oneclickvirt/backtrace?color=01c674)](https://github.com/oneclickvirt/backtrace/releases)
[![Hits](https://hits.spiritlhl.net/backtrace.svg?action=hit&title=Hits&title_bg=%23555555&count_bg=%230eecf8&edge_flat=false)](https://hits.spiritlhl.net)
[![Build and Release](https://github.com/oneclickvirt/backtrace/actions/workflows/main.yaml/badge.svg)](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` 或其他系统配置。
## 概览图
![图片](https://github.com/oneclickvirt/backtrace/assets/103393591/4688f99f-0f02-486f-8ffc-78d30f2c2f95)

View File

@ -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.

View File

@ -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)
给出操作命令前核对这些源码文件及当前文档,保留现有版本固定、数据路径和宿主机标识。报告测试时区分真实程序执行、隔离测试及未验证的环境。

View File

@ -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"
}
}

View File

@ -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 表达式或网段。"
]
}

View File

@ -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()

View File

@ -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位整数

View File

@ -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])
}
}

View File

@ -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"

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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"
}
]
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
})
}

View File

@ -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}
}

View File

@ -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
}

View File

@ -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"}
}

View File

@ -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 ""
}

View File

@ -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)
}
}
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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 ""
}

View File

@ -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

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as10099.txt",
"count": 9,
"sha256": "d3fda28bdf4e8430164b60134fabc75c3f6d44c8beec28911b1325886abc5f07",
"generated_at": "2026-09-13T03:51:17Z"
}

View File

@ -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

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as23764.txt",
"count": 29,
"sha256": "956566eed70b6ba577734c998b6b614dbd68f3b139928eaef0008ca496b96e05",
"generated_at": "2026-08-28T10:13:57Z"
}

View File

@ -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

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as4134.txt",
"count": 633,
"sha256": "a70b9bab5e0fa5ec49d6a9236c6990a793626c035c8f9a23f27ba3bcf1c8a032",
"generated_at": "2026-10-22T03:48:24Z"
}

View File

@ -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

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as4809.txt",
"count": 198,
"sha256": "9ff6577616e42d2e8d8d1f3817a639c647b91d636b89d0c1d0145306c7cc4f87",
"generated_at": "2026-10-23T03:52:32Z"
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as4837.txt",
"count": 652,
"sha256": "ed2c895a4e65992ce1e5fb1b9454d4bd42e73c6a53b56aa36efdadc2e76f487b",
"generated_at": "2026-10-25T03:45:47Z"
}

View File

@ -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

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as58453.txt",
"count": 17,
"sha256": "e7e579da764e9dedf84145b5c4cdc51c4b2841517adbc6edb206896b40710119",
"generated_at": "2026-07-20T19:12:53Z"
}

View File

@ -1,2 +1,2 @@
2402:4f00:f000::/36
2402:4f00:f000::/48
2402:4f00
2402:4f00:f000

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as58807.txt",
"count": 2,
"sha256": "0765c7eefa122984fe69678b60ba99aa605d52601ef4ebf988c4e691e91c5361",
"generated_at": "2026-07-20T19:12:53Z"
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as9808.txt",
"count": 7192,
"sha256": "976af5656ac6d0fd87a71dc74518b80ee6e12aefb8c8779b9ea9b77af7e1c2dd",
"generated_at": "2026-10-30T04:53:49Z"
}

View File

@ -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

View File

@ -1,7 +0,0 @@
{
"schema": "backtrace.asn-prefixes/v1",
"file": "as9929.txt",
"count": 147,
"sha256": "1a609d504ade062827ebc1f365b390dcfe1129510f50791763eefd8081081401",
"generated_at": "2026-10-10T04:56:08Z"
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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,
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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]))
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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>")
}

View File

@ -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)
}
}
}

View File

@ -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, &current); 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)
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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",
}

View File

@ -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)

View File

@ -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.

View File

@ -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)
给出操作命令前核对这些源码文件及当前文档,保留现有版本固定、数据路径和宿主机标识。报告测试时区分真实程序执行、隔离测试及未验证的环境。

View File

@ -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)
}

View File

@ -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("❌ 本机未检测到公网连接")
}
}