Repository: nxtrace/NTrace-core Branch: main Commit: 1cda0e53bb56 Files: 198 Total size: 1.1 MB Directory structure: gitextract_otbi_aku/ ├── .cross_compile.sh ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ ├── publishNewFormula.yml │ ├── test.yml │ └── triggerDebRepo.yml ├── .gitignore ├── AGENTS.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── README_zh_CN.md ├── _config.yml ├── assets/ │ └── windivert/ │ ├── divert.go │ ├── x64/ │ │ └── WinDivert64.sys │ └── x86/ │ └── WinDivert32.sys ├── cmd/ │ ├── cmd.go │ ├── cmd_test.go │ ├── deploy_disabled.go │ ├── deploy_full.go │ ├── flavor_full.go │ ├── flavor_ntr.go │ ├── flavor_tiny.go │ ├── globalping_disabled.go │ ├── globalping_full.go │ ├── listen_info_test.go │ ├── mtr_mode.go │ ├── mtr_mode_test.go │ ├── mtr_ui.go │ ├── mtu_mode.go │ └── mtu_mode_test.go ├── config/ │ ├── basic.go │ └── viper.go ├── dn42/ │ ├── dn42.go │ ├── geofeed.go │ ├── geofeed_test.go │ ├── ptr.go │ └── ptr_test.go ├── fast_trace/ │ ├── basic.go │ ├── fast_trace ipv6.go │ ├── fast_trace.go │ └── fast_trace_test.go ├── geofeed.example.csv ├── go.mod ├── go.sum ├── internal/ │ └── hoprender/ │ └── group.go ├── ipgeo/ │ ├── chunzhen.go │ ├── dn42.go │ ├── dn42_test.go │ ├── ipapicom.go │ ├── ipdbone.go │ ├── ipfilter.go │ ├── ipfilter_test.go │ ├── ipgeo.go │ ├── ipgeo_test.go │ ├── ipinfo.go │ ├── ipinfoLocal.go │ ├── ipinsight.go │ ├── ipsb.go │ ├── leo.go │ ├── tokens.go │ └── tokens_test.go ├── main.go ├── nt_config.yaml ├── nt_install.sh ├── pow/ │ ├── pow.go │ └── pow_test.go ├── printer/ │ ├── basic.go │ ├── classic_printer.go │ ├── easy.go │ ├── mtr_raw.go │ ├── mtr_table.go │ ├── mtr_table_test.go │ ├── mtr_tui.go │ ├── mtr_tui_color.go │ ├── printer.go │ ├── printer_test.go │ ├── realtime_common.go │ ├── realtime_printer.go │ ├── realtime_printer_router.go │ ├── tableprinter.go │ └── tableprinter_test.go ├── ptr.example.csv ├── reporter/ │ ├── reporter.go │ └── reporter_test.go ├── server/ │ ├── browser_access.go │ ├── browser_access_test.go │ ├── cache_handler.go │ ├── handlers.go │ ├── mtr.go │ ├── server.go │ ├── trace_handler.go │ ├── trace_handler_test.go │ ├── web/ │ │ ├── assets/ │ │ │ ├── app.js │ │ │ ├── mtr_agg.js │ │ │ ├── mtr_agg.test.cjs │ │ │ ├── mtr_truncation.test.cjs │ │ │ ├── style.css │ │ │ ├── trace_form.js │ │ │ └── trace_form.test.cjs │ │ └── index.html │ ├── ws_handler.go │ └── ws_handler_test.go ├── trace/ │ ├── cache.go │ ├── globalping.go │ ├── globalping_test.go │ ├── globalping_types.go │ ├── icmp_ipv4.go │ ├── icmp_ipv6.go │ ├── internal/ │ │ ├── icmp_common.go │ │ ├── icmp_darwin.go │ │ ├── icmp_decode.go │ │ ├── icmp_decode_test.go │ │ ├── icmp_unix.go │ │ ├── icmp_windows.go │ │ ├── icmp_windows_test.go │ │ ├── packet_listener.go │ │ ├── tcp_common.go │ │ ├── tcp_darwin.go │ │ ├── tcp_probe_decode.go │ │ ├── tcp_probe_decode_test.go │ │ ├── tcp_unix.go │ │ ├── tcp_windows.go │ │ ├── udp_common.go │ │ ├── udp_darwin.go │ │ ├── udp_unix.go │ │ ├── udp_windows.go │ │ └── windivert_sniff_windows.go │ ├── mtr_loop_runtime.go │ ├── mtr_raw.go │ ├── mtr_raw_test.go │ ├── mtr_runner.go │ ├── mtr_runner_test.go │ ├── mtr_scheduler.go │ ├── mtr_scheduler_runtime.go │ ├── mtr_scheduler_test.go │ ├── mtr_stats.go │ ├── mtr_stats_helpers.go │ ├── mtr_stats_test.go │ ├── mtu/ │ │ ├── decode.go │ │ ├── decode_test.go │ │ ├── metadata.go │ │ ├── metadata_test.go │ │ ├── runner.go │ │ ├── runner_test.go │ │ ├── socket_prober.go │ │ ├── socket_prober_read_default.go │ │ ├── socket_prober_read_windows.go │ │ ├── socket_prober_test.go │ │ ├── socketopts_darwin.go │ │ ├── socketopts_linux.go │ │ ├── socketopts_stub.go │ │ ├── socketopts_windows.go │ │ └── types.go │ ├── packet_size.go │ ├── packet_size_test.go │ ├── quic.go │ ├── tcp_ipv4.go │ ├── tcp_ipv6.go │ ├── tcp_match.go │ ├── tcp_match_test.go │ ├── temp_printer.go │ ├── trace.go │ ├── trace_runtime_test.go │ ├── udp_ipv4.go │ └── udp_ipv6.go ├── tracelog/ │ ├── log.go │ └── log_test.go ├── tracemap/ │ ├── tracemap.go │ └── tracemap_test.go ├── util/ │ ├── common.go │ ├── dns_resolver.go │ ├── dns_resolver_test.go │ ├── dot.go │ ├── env.go │ ├── env_test.go │ ├── frag.go │ ├── http_client_geo.go │ ├── http_client_geo_test.go │ ├── latency.go │ ├── latency_test.go │ ├── pcap.go │ ├── privilege_stub.go │ ├── privilege_windows.go │ ├── trace.go │ ├── trace_privilege.go │ ├── trace_privilege_darwin.go │ ├── trace_privilege_linux.go │ ├── trace_privilege_stub.go │ ├── trace_privilege_windows.go │ ├── trace_test.go │ ├── udp.go │ ├── util.go │ └── util_test.go └── wshandle/ ├── client.go └── client_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cross_compile.sh ================================================ #!/usr/bin/env bash set -Eeuo pipefail # -------- Config -------- # Usage: .cross_compile.sh [full|tiny|ntr|all] [debug] # full — build nexttrace (Full, includes WebUI + Globalping + MTR) # tiny — build nexttrace-tiny (no WebUI, no Globalping, no MTR) # ntr — build ntr (MTR-only, default MTR mode) # all — build all three flavors (default) # debug — enable debug symbols (can combine: .cross_compile.sh all debug) FLAVOR_ARG="${1:-all}" DEBUG_MODE="${2:-}" # Allow "debug" as first arg for backward compat if [[ "${FLAVOR_ARG}" == "debug" ]]; then FLAVOR_ARG="all" DEBUG_MODE="debug" fi # Define flavor specs: "bin_name:build_tags" declare -a FLAVOR_SPECS case "${FLAVOR_ARG}" in full) FLAVOR_SPECS=("nexttrace:") ;; tiny) FLAVOR_SPECS=("nexttrace-tiny:flavor_tiny") ;; ntr) FLAVOR_SPECS=("ntr:flavor_ntr") ;; all) FLAVOR_SPECS=("nexttrace:" "nexttrace-tiny:flavor_tiny" "ntr:flavor_ntr") ;; *) echo "Usage: $0 [full|tiny|ntr|all] [debug]" >&2 exit 1 ;; esac TARGET_DIR="dist" PLATFORMS="linux/386 linux/amd64 linux/arm64 linux/mips linux/mips64 linux/mipsle linux/mips64le linux/loong64 windows/amd64 windows/arm64 openbsd/amd64 openbsd/arm64 freebsd/amd64 freebsd/arm64" UPX_BIN="${UPX_BIN:-$(command -v upx 2>/dev/null || true)}" UPX_FLAGS="${UPX_FLAGS:--9}" # -------- Build metadata (robust) -------- BUILD_VERSION="$(git describe --tags --always 2>/dev/null || true)" BUILD_VERSION="${BUILD_VERSION:-dev}" BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" COMMIT_SHA1="$(git rev-parse --short HEAD 2>/dev/null || true)" COMMIT_SHA1="${COMMIT_SHA1:-unknown}" # 通用 ldflags(去掉了内部单引号) LD_BASE="-X github.com/nxtrace/NTrace-core/config.Version=${BUILD_VERSION} \ -X github.com/nxtrace/NTrace-core/config.BuildDate=${BUILD_DATE} \ -X github.com/nxtrace/NTrace-core/config.CommitID=${COMMIT_SHA1} \ -w -s" GO_BUILD_FLAGS=(-trimpath) if [[ "${DEBUG_MODE}" == "debug" ]]; then GO_BUILD_FLAGS=(-trimpath -gcflags "all=-N -l") fi # build_one BIN TAGS GOOS GOARCH [EXTRA_ENV...] build_one() { local bin="$1" tags="$2" goos="$3" goarch="$4" shift 4 local target="${TARGET_DIR}/${bin}_${goos}_${goarch}" local target_arm="" # Apply extra env vars (e.g. GOARM=7 suffix) for ev in "$@"; do local key="${ev%%=*}" val="${ev#*=}" if [[ "${key}" == "GOARM" ]]; then target_arm="${val}" target="${target}v${val}" elif [[ "${key}" == "GOMIPS" && "${val}" == "softfloat" ]]; then target="${target}_softfloat" fi done if [[ "${goos}" == "windows" ]]; then target="${target}.exe" fi local tags_flag=() if [[ -n "${tags}" ]]; then tags_flag=(-tags "${tags}") fi echo "build => ${target} (tags: ${tags:-none})" env "$@" go build "${GO_BUILD_FLAGS[@]}" "${tags_flag[@]}" -o "${target}" -ldflags "${LD_BASE}" compress_with_upx "${target}" "${goos}" "${goarch}" "${target_arm}" "quiet" } compress_with_upx() { local binary="${1:-}" local target_os="${2:-}" local target_arch="${3:-}" local target_arm="${4:-}" local note="${5:-}" if [[ "${target_os}" != "linux" ]]; then return fi case "${target_arch}" in 386|amd64|arm64) ;; arm) if [[ "${target_arm}" != "7" ]]; then return fi ;; *) return ;; esac if [[ -z "${UPX_BIN}" ]]; then return fi if [[ ! -f "${binary}" ]]; then return fi if [[ "${note}" != "quiet" ]]; then echo "upx => ${binary}" fi if ! "${UPX_BIN}" ${UPX_FLAGS} "${binary}" >/dev/null; then echo "warn: upx failed for ${binary}, keeping uncompressed" >&2 fi } if [[ -z "${UPX_BIN}" ]]; then echo "info: upx not found; set UPX_BIN or install upx to enable binary compression" >&2 else echo "info: using upx at ${UPX_BIN} with flags ${UPX_FLAGS}" >&2 fi echo "info: building flavor(s): ${FLAVOR_ARG}" >&2 # -------- Prepare out dir -------- rm -rf -- "${TARGET_DIR}" mkdir -p -- "${TARGET_DIR}" # -------- Pure Go targets (CGO off) -------- for pl in ${PLATFORMS}; do export CGO_ENABLED=0 GOOS="${pl%%/*}" GOARCH="${pl#*/}" export GOOS GOARCH for SPEC in "${FLAVOR_SPECS[@]}"; do BIN="${SPEC%%:*}" TAGS="${SPEC#*:}" build_one "${BIN}" "${TAGS}" "${GOOS}" "${GOARCH}" done # Extra soft-float variants for linux/mips and linux/mipsle if [[ "${GOOS}" == "linux" && ( "${GOARCH}" == "mips" || "${GOARCH}" == "mipsle" ) ]]; then for SPEC in "${FLAVOR_SPECS[@]}"; do BIN="${SPEC%%:*}" TAGS="${SPEC#*:}" build_one "${BIN}" "${TAGS}" "${GOOS}" "${GOARCH}" "GOMIPS=softfloat" done fi done # -------- linux/armv7(CGO off)-------- export CGO_ENABLED=0 export GOOS='linux' export GOARCH='arm' export GOARM='7' for SPEC in "${FLAVOR_SPECS[@]}"; do BIN="${SPEC%%:*}" TAGS="${SPEC#*:}" build_one "${BIN}" "${TAGS}" "${GOOS}" "${GOARCH}" "GOARM=7" done # -------- Darwin targets with CGO + SDK libpcap -------- if [[ "$(uname)" == "Darwin" ]]; then if ! command -v xcrun >/dev/null 2>&1; then echo "error: xcrun not found. Please install Xcode Command Line Tools: xcode-select --install" >&2 exit 1 fi SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" for GOARCH in amd64 arm64; do export CGO_ENABLED=1 export GOOS=darwin export CC=clang export CXX=clang++ if [[ "${GOARCH}" == "amd64" ]]; then ARCH_FLAG="-arch x86_64" else ARCH_FLAG="-arch arm64" fi # 仅提供 SDK/架构/最低系统版本;-lpcap 交由源码中的 #cgo LDFLAGS 处理,避免重复 export CGO_CFLAGS="-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0" export CGO_LDFLAGS="-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0" for SPEC in "${FLAVOR_SPECS[@]}"; do BIN="${SPEC%%:*}" TAGS="${SPEC#*:}" build_one "${BIN}" "${TAGS}" "${GOOS}" "${GOARCH}" done done # 合并 Universal 2(存在 lipo 才合并) if command -v lipo >/dev/null 2>&1; then for SPEC in "${FLAVOR_SPECS[@]}"; do BIN="${SPEC%%:*}" if [[ -f "${TARGET_DIR}/${BIN}_darwin_amd64" && -f "${TARGET_DIR}/${BIN}_darwin_arm64" ]]; then lipo -create -output "${TARGET_DIR}/${BIN}_darwin_universal" \ "${TARGET_DIR}/${BIN}_darwin_amd64" \ "${TARGET_DIR}/${BIN}_darwin_arm64" echo "build => ${TARGET_DIR}/${BIN}_darwin_universal" else echo "warn: missing one of darwin slices for ${BIN}; skip universal lipo." >&2 fi done else echo "warn: lipo not found; skip universal binary." >&2 fi fi ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- --- name: nexttrace 程序问题 about: "提交一个 nexttrace 的程序问题报告。" copyright: [v2fly](https://github.com/v2fly) --- ## 本项目是基于Linux/macOS的,请确认您遇到的问题是否在Linux或macOS上存在。 ## 你正在使用哪个版本的 nexttrace? ## 你看到的异常现象是什么? ## 你期待看到的正常表现是怎样的? ## 请附上你的命令 ## 请附上出错时软件输出的错误信息 ## 是否查询过本仓库wiki有没有类似错误 ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "gomod" directory: "/" # Location of package manifests schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/workflows/build.yml ================================================ name: Build & Release permissions: contents: read defaults: run: shell: bash concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: workflow_dispatch: push: branches: [ main ] tags: [ "v*" ] paths: - "**/*.go" - "go.mod" - "go.sum" - ".github/workflows/*.yml" pull_request: types: [opened, synchronize, reopened] paths: - "**/*.go" - "go.mod" - "go.sum" - ".github/workflows/*.yml" jobs: build: runs-on: ubuntu-latest continue-on-error: ${{ matrix.allow_fail || false }} strategy: fail-fast: false matrix: goos: [windows, freebsd, openbsd, linux, dragonfly] goarch: [amd64, 386] exclude: - goarch: 386 goos: dragonfly include: - { goos: linux, goarch: arm, goarm: 7 } - { goos: linux, goarch: arm, goarm: 6 } - { goos: linux, goarch: arm, goarm: 5 } - { goos: android, goarch: arm64 } - { goos: windows, goarch: arm64 } # windows/arm 已于 Go 1.26 移除 - { goos: linux, goarch: arm64 } - { goos: linux, goarch: riscv64 } - { goos: linux, goarch: loong64 } - { goos: linux, goarch: mips64 } - { goos: linux, goarch: mips64le } - { goos: linux, goarch: mipsle } - { goos: linux, goarch: mips } - { goos: linux, goarch: mipsle, gomips: softfloat } - { goos: linux, goarch: mips, gomips: softfloat } - { goos: linux, goarch: ppc64 } - { goos: linux, goarch: ppc64le } - { goos: freebsd, goarch: arm64 } - { goos: freebsd, goarch: arm, goarm: 7 } - { goos: linux, goarch: s390x } - { goos: openbsd, goarch: arm64 } - { goos: openbsd, goarch: arm, goarm: 7 } env: CGO_ENABLED: ${{ matrix.goos == 'android' && 1 || 0 }} GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} GOARM: ${{ matrix.goarm }} GOMIPS: ${{ matrix.gomips }} steps: - name: Checkout codebase uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true - name: Set up Go (built-in cache) uses: actions/setup-go@v6 with: go-version: '1.26.x' check-latest: true cache: true - name: Show Go toolchain info run: | set -Eeuo pipefail go version && go env && echo "Supported targets (dist list):" && go tool dist list | sort -u - name: Setup Android NDK if: matrix.goos == 'android' id: setup-ndk uses: nttld/setup-ndk@v1 with: ndk-version: r26d add-to-path: false - name: Resolve build metadata run: | set -Eeuo pipefail BUILD_VERSION="$(git describe --tags --always 2>/dev/null || echo dev)" BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" COMMIT_SHA1="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" ARM_SUFFIX="" if [ -n "${GOARM:-}" ]; then ARM_SUFFIX="v${GOARM}"; fi PLATFORM="${GOOS}_${GOARCH}${ARM_SUFFIX}" EXT="" if [ "${GOOS}" = "windows" ]; then EXT=".exe"; fi SOFT="" if [ "${GOMIPS:-}" = "softfloat" ]; then SOFT="_softfloat"; fi if [ "$GOOS" = "android" ]; then ANDROID_API=21 TOOLCHAIN_BIN="${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin" export CC="$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang" echo "CC=${CC}" >> "$GITHUB_ENV" fi echo "BUILD_VERSION=${BUILD_VERSION}" >> "$GITHUB_ENV" echo "BUILD_DATE=${BUILD_DATE}" >> "$GITHUB_ENV" echo "COMMIT_SHA1=${COMMIT_SHA1}" >> "$GITHUB_ENV" echo "ARM_SUFFIX=${ARM_SUFFIX}" >> "$GITHUB_ENV" echo "PLATFORM=${PLATFORM}" >> "$GITHUB_ENV" echo "EXT=${EXT}" >> "$GITHUB_ENV" echo "SOFT=${SOFT}" >> "$GITHUB_ENV" - name: Get project dependencies run: go mod download - name: Install UPX (selected Linux targets) if: > matrix.goos == 'linux' && ( matrix.goarch == 'amd64' || matrix.goarch == '386' || matrix.goarch == 'arm64' || (matrix.goarch == 'arm' && matrix.goarm == '7') ) run: | sudo apt-get update sudo apt-get install -y upx - name: Build all flavors run: | mkdir -p dist LD_BASE="-X github.com/nxtrace/NTrace-core/config.Version=${BUILD_VERSION} \ -X github.com/nxtrace/NTrace-core/config.BuildDate=${BUILD_DATE} \ -X github.com/nxtrace/NTrace-core/config.CommitID=${COMMIT_SHA1} \ -w -s" for SPEC in "nexttrace:" "nexttrace-tiny:flavor_tiny" "ntr:flavor_ntr"; do BIN="${SPEC%%:*}" TAGS="${SPEC#*:}" NAME="${BIN}_${PLATFORM}${EXT}${SOFT}" BUILD_ARGS=(-trimpath) if [ -n "$TAGS" ]; then BUILD_ARGS+=( -tags "$TAGS" ); fi BUILD_ARGS+=( -o "dist/${NAME}" -ldflags "$LD_BASE" ) echo "build => dist/${NAME} (tags: ${TAGS:-none})" go build "${BUILD_ARGS[@]}" if command -v upx >/dev/null 2>&1; then case "${GOOS}-${GOARCH}-${GOARM:-}" in linux-amd64-*|linux-386-*|linux-arm64-*|linux-arm-7) upx -9 "dist/${NAME}" ;; *) ;; esac fi done - name: Verify tiny/ntr decoupling run: | set -Eeuo pipefail for BIN in "nexttrace-tiny" "ntr"; do FNAME="${BIN}_${PLATFORM}${EXT}${SOFT}" if go version -m "dist/${FNAME}" 2>/dev/null | grep -qE 'github\.com/gin-gonic/gin|github\.com/jsdelivr/globalping-cli'; then echo "FAIL: ${FNAME} contains gin or globalping-cli dependency" exit 1 fi done echo "Decoupling check passed" - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: build-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('-v{0}', matrix.goarm) || '' }}${{ matrix.gomips && format('-{0}', matrix.gomips) || '' }} path: dist/ if-no-files-found: error build-darwin: runs-on: macos-latest strategy: fail-fast: false matrix: { goarch: [ amd64, arm64 ] } env: CGO_ENABLED: 1 GOOS: darwin GOARCH: ${{ matrix.goarch }} steps: - name: Checkout codebase uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true - name: Set up Go (built-in cache) uses: actions/setup-go@v6 with: go-version: '1.26.x' check-latest: true cache: true - name: Show Go toolchain info run: | set -Eeuo pipefail go version && go env && echo "Supported targets (dist list):" && go tool dist list | sort -u - name: Sanity check (Xcode/SDK/lipo/pcap) run: | set -Eeuo pipefail xcode-select -p SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" echo "$SDKROOT" clang --version || true xcrun -f lipo command -v lipo >/dev/null || (echo "lipo not found"; exit 1) test -f "$SDKROOT/usr/include/pcap/pcap.h" || (echo "pcap headers not found in SDK"; exit 1) - name: Resolve build metadata + Darwin CGO flags run: | set -Eeuo pipefail SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" if [ "${GOARCH}" = "amd64" ]; then ARCH_FLAG="-arch x86_64"; else ARCH_FLAG="-arch arm64"; fi CC="$(xcrun -f clang)"; CXX="$(xcrun -f clang++)" echo "CC=${CC}" >> "$GITHUB_ENV" echo "CXX=${CXX}" >> "$GITHUB_ENV" echo "CGO_CFLAGS=-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0" >> "$GITHUB_ENV" echo "CGO_LDFLAGS=-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0" >> "$GITHUB_ENV" BUILD_VERSION="$(git describe --tags --always 2>/dev/null || echo dev)" BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" COMMIT_SHA1="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" echo "BUILD_VERSION=${BUILD_VERSION}" >> "$GITHUB_ENV" echo "BUILD_DATE=${BUILD_DATE}" >> "$GITHUB_ENV" echo "COMMIT_SHA1=${COMMIT_SHA1}" >> "$GITHUB_ENV" - name: Get project dependencies run: go mod download - name: Build all flavors (Darwin, CGO+libpcap via SDK) run: | mkdir -p dist LD_BASE="-X github.com/nxtrace/NTrace-core/config.Version=${BUILD_VERSION} \ -X github.com/nxtrace/NTrace-core/config.BuildDate=${BUILD_DATE} \ -X github.com/nxtrace/NTrace-core/config.CommitID=${COMMIT_SHA1} \ -w -s" for SPEC in "nexttrace:" "nexttrace-tiny:flavor_tiny" "ntr:flavor_ntr"; do BIN="${SPEC%%:*}" TAGS="${SPEC#*:}" NAME="${BIN}_${GOOS}_${GOARCH}" BUILD_ARGS=(-trimpath) if [ -n "$TAGS" ]; then BUILD_ARGS+=( -tags "$TAGS" ); fi BUILD_ARGS+=( -o "dist/${NAME}" -ldflags "$LD_BASE" ) echo "build => dist/${NAME} (tags: ${TAGS:-none})" go build "${BUILD_ARGS[@]}" done - name: Verify tiny/ntr decoupling run: | set -Eeuo pipefail for BIN in "nexttrace-tiny" "ntr"; do FNAME="${BIN}_${GOOS}_${GOARCH}" if go version -m "dist/${FNAME}" 2>/dev/null | grep -qE 'github\.com/gin-gonic/gin|github\.com/jsdelivr/globalping-cli'; then echo "FAIL: ${FNAME} contains gin or globalping-cli dependency" exit 1 fi done echo "Decoupling check passed" - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: build-darwin-${{ matrix.goarch }} path: dist/ if-no-files-found: error # Build Universal binaries for all flavors darwin-universal: runs-on: macos-latest needs: [ build-darwin ] steps: - name: Download darwin slices (flatten) uses: actions/download-artifact@v8 with: pattern: build-darwin-* merge-multiple: true path: dist - name: Make macOS Universal (amd64+arm64) for all flavors run: | set -Eeuo pipefail for BIN in nexttrace nexttrace-tiny ntr; do AMD64="dist/${BIN}_darwin_amd64" ARM64="dist/${BIN}_darwin_arm64" if [ -f "$AMD64" ] && [ -f "$ARM64" ]; then lipo -create -output "dist/${BIN}_darwin_universal" "$AMD64" "$ARM64" file "dist/${BIN}_darwin_universal" || true echo "Built: dist/${BIN}_darwin_universal" else echo "Missing darwin slices for ${BIN}; cannot build universal." >&2 exit 1 fi done ls -l dist - name: Upload universal artifacts uses: actions/upload-artifact@v7 with: name: build-darwin-universal path: | dist/nexttrace_darwin_universal dist/nexttrace-tiny_darwin_universal dist/ntr_darwin_universal if-no-files-found: error # Download all build artifacts and publish to a single release release: runs-on: ubuntu-latest needs: [ build, build-darwin, darwin-universal ] if: startsWith(github.ref, 'refs/tags/v') steps: - name: Download all artifacts (flatten) uses: actions/download-artifact@v8 with: pattern: build-* merge-multiple: true path: dist_release - name: Show downloaded files run: | set -Eeuo pipefail ls -lah dist_release test -n "$(find dist_release -type f -print -quit)" || { echo "No artifacts found in dist_release"; exit 1; } - name: Create GitHub Release (draft) uses: softprops/action-gh-release@v2 with: draft: true name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }} files: dist_release/* token: ${{ secrets.GT_TOKEN }} fail_on_unmatched_files: true publish-new-formula: runs-on: ubuntu-latest needs: [ release ] if: startsWith(github.ref, 'refs/tags/v') steps: - name: config git run: | git config --global user.email "${{ secrets.GIT_MAIL }}" git config --global user.name "${{ secrets.GIT_NAME }}" - name: Clone repo run: git clone https://github.com/nxtrace/homebrew-nexttrace.git - name: Exec script run: | set -Eeuo pipefail cd homebrew-nexttrace bash genFormula.sh - name: Git Push run: | set -Eeuo pipefail cd homebrew-nexttrace git add -A git commit -m 'Publish a new version with Formula' || true git remote set-url origin https://${{ secrets.GT_TOKEN }}@github.com/nxtrace/homebrew-nexttrace.git git push - run: echo "🍏 This job's status is ${{ job.status }}." ================================================ FILE: .github/workflows/publishNewFormula.yml ================================================ name: Publish New Formula concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # Controls when the action will run. Workflow runs when manually triggered using the UI # or API. on: workflow_dispatch: # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "greet" publish-new-formula: # The type of runner that the job will run on runs-on: ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: # Runs a single command using the runners shell - name: config git run: | git config --global user.email "${{ secrets.GIT_MAIL }}" git config --global user.name "${{ secrets.GIT_NAME }}" - name: Clone repo run: | git clone https://github.com/nxtrace/homebrew-nexttrace.git - name: Exec script run: | set -Eeuo pipefail cd homebrew-nexttrace bash genFormula.sh # - name: setup SSH keys and known_hosts # run: | # mkdir -p ~/.ssh # ssh-keyscan github.com >> ~/.ssh/known_hosts # ssh-agent -a $SSH_AUTH_SOCK > /dev/null # ssh-add - <<< "${{ secrets.ID_RSA }}" # env: # SSH_AUTH_SOCK: /tmp/ssh_agent.sock - name: Git Push run: | set -Eeuo pipefail cd homebrew-nexttrace git add -A git commit -m 'Publish a new version with Formula' || true git remote set-url origin https://${{ secrets.GT_TOKEN }}@github.com/nxtrace/homebrew-nexttrace.git git push # env: # SSH_AUTH_SOCK: /tmp/ssh_agent.sock - run: echo "🍏 This job's status is ${{ job.status }}." ================================================ FILE: .github/workflows/test.yml ================================================ name: Test permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: push: branches: - main paths: - "**/*.go" - "go.mod" - "go.sum" - ".github/workflows/*.yml" pull_request: types: [opened, synchronize, reopened] paths: - "**/*.go" - "go.mod" - "go.sum" - ".github/workflows/*.yml" workflow_dispatch: jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [windows-latest, ubuntu-latest, macos-latest] steps: - name: Checkout codebase uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true - name: Set up Go (built-in cache) uses: actions/setup-go@v6 with: go-version: '1.26.x' check-latest: true cache: true - name: Test with unix if: ${{ matrix.os != 'windows-latest' }} shell: bash run: | sudo go env -w GOTOOLCHAIN=go1.26.0+auto sudo go test -v -covermode=count -coverprofile='coverage.out' ./... - name: Test with windows if: ${{ matrix.os == 'windows-latest' }} run: | go env -w GOTOOLCHAIN=go1.26.0+auto go test -v -covermode=count -coverprofile="coverage.out" ./... ================================================ FILE: .github/workflows/triggerDebRepo.yml ================================================ name: Trigger Deb Repo permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.release.id }} cancel-in-progress: true on: release: types: [published, prereleased] jobs: trigger-deb-repo: runs-on: ubuntu-latest steps: - env: GITHUB_TOKEN: ${{ secrets.GT_TOKEN }} # 操作 deb 仓库的 PAT run: | curl -X POST -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/nxtrace/nexttrace-debs/actions/workflows/build.yaml/dispatches \ -d '{"ref": "main", "inputs": {"tag": "${{ github.event.release.tag_name }}"}}' ================================================ FILE: .gitignore ================================================ ### VisualStudioCode template .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.code-workspace # Local History for Visual Studio Code .history/ ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf # Generated files .idea/**/contentModel.xml # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml # Gradle .idea/**/gradle.xml .idea/**/libraries # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. # .idea/artifacts # .idea/compiler.xml # .idea/jarRepositories.xml # .idea/modules.xml # .idea/*.iml # .idea/modules # *.iml # *.ipr # CMake cmake-build-*/ # Mongo Explorer plugin .idea/**/mongoSettings.xml # File-based project format *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # Editor-based Rest Client .idea/httpRequests # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser ### Example user template template ### Example user template # IntelliJ project files .idea *.iml out gen ### Go template # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Unignore DLLs under assets/windivert !assets/windivert/**/*.dll # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ ### Windows template # Windows thumbnail cache files Thumbs.db Thumbs.db:encryptable ehthumbs.db ehthumbs_vista.db # Dump file *.stackdump # Folder config file [Dd]esktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msix *.msm *.msp # Windows shortcuts *.lnk ### macOS template # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk # compile target directory dist/ NTrace-core .gocache .gomodcache .cache ================================================ FILE: AGENTS.md ================================================ # NTrace-core 项目记忆文件(2026-03 快照,rev-3) # 供 LLM 在后续会话中快速加载上下文,减少重复分析。 ## 项目概览 - 名称:NextTrace (NTrace-core) - 仓库:github.com/nxtrace/NTrace-core - 模块:`github.com/nxtrace/NTrace-core` - 语言:Go(`go 1.26.0`) - 入口:`main.go -> cmd.Execute()` - 核心能力:ICMP/TCP/UDP traceroute、GeoIP/RDNS、MTR 连续探测、Web/API、多平台构建 ## 构建与测试(必须遵守) - 常用命令: - 构建:`go build ./...` - 测试:`go test ./...` - 交叉编译脚本:`.cross_compile.sh` - Darwin 下 `trace/internal/icmp_darwin.go` 已不再使用 `//go:linkname`,改为 `syscall.Socket` + `os.NewFile` + 自定义 `icmpPacketConn`(实现 `net.PacketConn` / `net.Conn` / `syscall.Conn` + `ReadMsgIP` 以满足 `x/net/internal/socket.ipConn` 接口),并在 `ReadFrom` 中调用 `stripIPv4Header` 剥离 macOS DGRAM ICMP socket 返回的外层 IP 头。 ## 当前 CLI 语义(重点) ### 常规 traceroute 路径 - `--table`:现在是"最终汇总表"模式(一次探测完成后输出汇总表),不再是旧的异步 table 刷新模式。 - `--route-path`:仍由 `reporter.New(...).Print()` 负责(与 MTR report 无关)。 ### 独立 `--mtu` 路径 - `--mtu`:独立 UDP path-MTU / tracepath 风格模式,不复用普通 `trace.Traceroute` / MTR / Web 路径。 - flavor 可用性:仅 `nexttrace` / `nexttrace-tiny` 包含;`ntr` 不注册该 flag。 - 输出: - `TTY`:当前 TTL 占位后原地更新,边探测边刷行。 - `非 TTY`:TTL 定稿后逐行流式输出,不使用 renderer 自己的 ANSI 控制序列。 - `--json`:输出独立 mtu schema;`hop.geo` 已存在。 - 参数语义: - 复用 `--data-provider`、`--language`、`--no-rdns`、`--always-rdns`、`--dot-server`。 - `--mtu` 仍只支持 UDP;显式 `--tcp` 冲突报错。 - Geo/RDNS: - `trace/mtu` 自带独立 metadata helper,不依赖普通 `trace.Hop.fetchIPData`。 - 流式事件会先输出基础 hop,再在同一 TTL 内补一条带 Geo/RDNS 的 update,最后 `ttl_final` 定稿。 - macOS 上曾有 `Warning: macOS --mtu support is experimental.` 提示,现已删除;不要再假设 CLI 会打印这句。 ### `--psize` / `--tos` 语义与平台差异 - `--psize` 现在统一对齐 `mtr -s/--psize`: - 用户输入语义是“含 IP + 当前探测协议头的总字节数”。 - 内部 `trace.Config.PktSize` 仍保存 payload bytes。 - 未显式传入时,不再固定默认 `52`,而是按协议/IP 族自动取最小合法值: - ICMPv4 / UDPv4 = `28` - TCPv4 = `44` - ICMPv6 = `48` - UDPv6 = `50` - TCPv6 = `64` - 负数 `--psize` 表示“每个 probe 独立随机”,CLI 允许 `--psize -84` 这种写法并会在解析前归一化。 - `--tos` / `-Q`: - 范围固定 `0..255`。 - `--mtu` 与 Globalping 显式传 `--psize` / `--tos` 会直接报不支持。 - 平台发送路径差异(这是后续判断 bug 的关键记忆): - Linux / 其他 Unix: - `ICMP/TCP/UDP` 的 IPv4/IPv6 都走原生 socket/raw socket 路径。 - `--tos` 只是在现有路径上设置 `TOS/TrafficClass`,不会切换实现。 - macOS: - 与 Linux 类似,`ICMP/TCP/UDP` 的 IPv4/IPv6 都走原生发送路径。 - `--tos` 同样只是在现有路径上设置 `TOS/TrafficClass`。 - Windows: - `TCP/UDP` 的 IPv4/IPv6 一直走 WinDivert raw send。 - `ICMPv4` 一直走 socket path(`SetTOS` / `SetTTL`)。 - `ICMPv6`: - 默认或 `--tos 0`:继续走原生 socket path,只设置 `HopLimit`,保持与 `v1.5.2` 一致。 - 非零 `--tos`:切到 WinDivert raw send,直接发送完整 `IPv6 + ICMPv6` 报文,因为 Windows 的 `x/net/ipv6.PacketConn` 不能可靠设置 `TrafficClass`。 - 因此,Windows 上只有“`ICMPv6` 且 `--tos != 0`”这个组合会额外依赖 WinDivert 发送能力;README 中英两份都已写明。 ### 间隔默认值(分层体系) - `-z/--send-time`:每包间隔,默认 `defaultPacketIntervalMs = 50` ms。 - `-i/--ttl-time`: - **常规 traceroute**:TTL 分组间隔,默认 `defaultTracerouteTTLIntervalMs = 300` ms。 - **MTR 模式**:`normalizeMTRTraceConfig()` 始终覆盖为 `defaultMTRInternalTTLIntervalMs = 0` ms(各 TTL 间不间隔)。 - MTR 每跳探测间隔由 `-i` 显式传值 或 默认 1000ms 决定(见下文 `-q/-i` 语义)。`-z/--send-time` 在 MTR 模式下被忽略。 ### MTR 相关参数 - `-t/--mtr`:开启 MTR 交互模式(TTY 全屏 TUI)。 - `-r/--report`:MTR 报告模式(非交互),隐式开启 MTR。 - `-w/--wide`:宽报告模式,隐式等价 `--mtr --report --wide`。 - `--raw`:与 MTR 组合时进入 **MTR raw 流式模式**(`runMTRRaw`),不再与 MTR 冲突。 - 有效 MTR 开关:`effectiveMTR = mtr || report || wide`。 - MTR 三路分支(`chooseMTRRunMode`): 1. `effectiveMTRRaw` → `runMTRRaw`(流式行输出,适合管道/脚本) 2. `effectiveReport` → `runMTRReport`(非交互报告表) 3. 默认 → `runMTRTUI`(全屏 TUI) - MTR 冲突参数(会直接报错退出):`--table` `--classic` `--json` `--output` `--route-path` `--from` `--fast-trace` `--file` `--deploy`。 - **注意**:`--raw` 不再是冲突参数。 ### MTR 中 `-q/-i/-y` 的新语义 - `-q/--queries`: - 在 MTR report 下表示每跳探测次数,默认 10(仅当用户未显式传 `-q`)。 - 在 MTR TUI 下表示每跳最大探测次数,未显式传时默认无限运行。 - `-i/--ttl-time`: - 在 MTR 下表示每跳探测间隔毫秒,默认 1000ms(仅当用户未显式传 `-i`)。 - 各 TTL 间内部扫描间隔固定 0ms(`normalizeMTRTraceConfig` 覆盖为 `defaultMTRInternalTTLIntervalMs = 0`)。 - `-z/--send-time` 在 MTR 模式下被忽略。 - `-y/--ipinfo <0..4>`: - TUI 初始 Host 显示模式,默认 0(IP/PTR only)。 - 0=Base(IP/PTR) 1=ASN 2=City 3=Owner 4=Full - 仅 TUI 模式生效,report/raw 不受影响。 ### MTR report wide / non-wide 区别 - **wide 模式**(`-w` 或 `--mtr --report --wide`): - 查询 GeoIP,显示完整 host 信息(ASN + geo + MPLS)。 - **非 wide 模式**(`-r` 或 `--mtr --report`): - `normalizeMTRReportConfig` 设 `IPGeoSource=nil`(不查 geo)、`AlwaysWaitRDNS=true`。 - 显示 `formatCompactReportHost`:仅 IP/PTR + ASN,无 geo 列。 ## MTR 运行链路(重要文件) - 入口与调度:`cmd/mtr_mode.go`(~315 行) - `runMTRTUI(...)` / `runMTRReport(...)` / `runMTRRaw(...)` - `normalizeMTRTraceConfig(conf)` / `normalizeMTRReportConfig(conf, wide)` - `buildAPIInfo(...)` / `buildRawAPIInfoLine(...)` - MTR CLI 现在统一使用 `signal.NotifyContext(...)` 管理 Ctrl-C / SIGTERM;不再保留额外的 `sigCh` + goroutine 等待器。 - 交互控制:`cmd/mtr_ui.go` - alternate screen + raw mode - 输入状态机 `mtrInputParser`(字节流,吞掉 CSI/SS3/OSC/鼠标/焦点等序列) - Enter/Leave 显式关闭输入扩展模式:1000/1002/1003/1006/1015/1004/2004 - Quit 路径会先判空 `cancel`,因此 `newMTRUI(nil, ...)` / 测试注入 nil 不会 panic。 - 核心探测循环:`trace/mtr_runner.go` - `RunMTR` / `mtrLoop` / `RunMTRRaw` - 支持暂停、重置、流式预览(`ProgressThrottle` 默认 200ms) - ICMP 持久引擎 + TCP/UDP fallback - 统计聚合:`trace/mtr_stats.go` - `MTRAggregator` / `MTRHopStat` - unknown 合并策略:单路径时把 unknown 合并到唯一已知路径,避免同 TTL 分裂成 waiting + 真实 IP 两行 - 输出层: - TUI:`printer/mtr_tui.go` - table/report:`printer/mtr_table.go` - raw 行格式化:`printer.FormatMTRRawLine(rec)` - TUI 颜色:`printer/mtr_tui_color.go` ## MTR 交互行为(当前) - `q`/`Q`/`Ctrl-C`:退出 - `p`:暂停 - `SPACE`:恢复 - `r`:重置统计 - `y`:切换 Host 显示模式(IP/PTR → ASN → City → Owner → Full → 循环) - `n`:切换 Host 基名显示(PTR-or-IP / IP-only) - `e`:切换 MPLS 标签显示(toggle MPLS on/off) ## MTR 显示与统计规则(当前) - Host 显示支持 5 种模式(`-y/--ipinfo` 设初始值,`y` 键运行时循环): - `HostModeBase=0`:仅 IP/PTR,无 ASN 前缀 - `HostModeASN=1` / `HostModeCity=2` / `HostModeOwner=3` / `HostModeFull=4` - `HostNamePTRorIP` / `HostNameIPOnly` - 默认语言:`cn`(`--language en` 才优先英文字段) - waiting 判定:`loss >= 99.95 && IP=="" && Host==""` - 显示为 `(waiting for reply)` - 指标列(Loss/Snt/Last/Avg/Best/Wrst/StDev)留空 - TUI Host 对齐(重要,已从 tab 改为手动空格): - `buildTUIHostParts(stat, mode, nameMode, lang, showIPs)` 生成结构化 parts - `computeTUIASNWidth(stats, ...)` 扫描所有 hop 确定 ASN 列最大宽度 - `formatTUIHost(parts, asnW)` 用 `padRight(asn, asnW)` + 空格拼接(不用 `\t`) - ASN 为空但 IP 已知时填 `"AS???"` 占位符,保证列对齐(HostModeBase 除外,该模式不显示 ASN) - waiting hop 不填占位符 - compact report host(非 wide report): - `formatCompactReportHost(stat, nameMode, lang)` 仅输出 hostname/IP + ASN - TUI 其他特性: - 终端宽度自适应 + CJK 宽度计算(go-runewidth) - 窄屏右锚定指标区 - 动态 hop 前缀宽度(覆盖 3 位/4 位 TTL) - MPLS 独立续行显示 - 紧凑指标列宽度:Loss=5 Snt=3 RTT=7 RTTMin=5 ## MTR 目的地检测与高 TTL 丢弃 - 当 `knownFinalTTL` 已确定后,所有 `TTL > knownFinalTTL` 的调度槽位被标记为 `disabled`。 - disabled TTL 的探测回包(包括在途探测返回的 dst-ip 回复)**一律丢弃**,不折叠、不计入任何统计。 - **MaxPerHop 上限检查**(`states[originTTL].completed + inFlightCount >= MaxPerHop`): - 调度时使用 `completed + inFlightCount >= MaxPerHop` 防止超发。 - 完成时仍检查 `completed >= MaxPerHop` 丢弃溢出结果。 - `originTTL < curFinal` 时(更低 TTL 先到 dst-ip → 降低 `knownFinalTTL`): - 保存 `oldFinal`,更新 `knownFinalTTL = originTTL`,disable 所有 `originTTL+1..maxHops`。 - 调用 `agg.ClearHop(oldFinal)`:清除旧 finalTTL 的聚合数据(避免幽灵行),**不合并**到新 finalTTL。 - 新 finalTTL 由独立的 per-hop 调度器自行积累新鲜探测数据,不存在 Snt 膨胀问题。 - 调度状态(`inFlightCount`/`nextAt`/`consecutiveErrs`)更新在 `originTTL`。 - 统计聚合(`completed++`/`agg.Update`/`onProbe`)均使用 `originTTL`(不再有 `accountTTL` 分离)。 ## MTR Per-Hop 调度器关键设计(当前) - **多 in-flight 探测**:每 TTL 允许最多 `MaxInFlightPerHop`(默认 3)个并发探测。 - `mtrHopState.inFlightCount` 是计数器(非 bool)。 - 这解决了高丢包 hop 因超时阻塞导致 Snt 积累速率远低于低丢包 hop 的问题。 - **nextAt 基于发送时间**:`launchProbe` 时设 `nextAt = now + hopInterval`。 - 不再等探测完成才设 nextAt,调度器可在超时探测还在飞行中时为同一 TTL 发射新探测。 - 这保证了所有 TTL 的 Snt 积累速率大致相同,不受丢包率影响。 - **全局并发限制**:`inFlight`(全局计数器)< `parallelism` 仍然有效。 - **`MaxInFlightPerHop` 配置**:`mtrSchedulerConfig.MaxInFlightPerHop`,默认动态计算。 - 动态默认 = `ceil(Timeout / HopInterval) + 1`(至少 1)。 - 例:`Timeout=2s, HopInterval=1s` → 默认 3;`Timeout=2s, HopInterval=200ms` → 默认 11。 - 用户显式设置 > 0 时优先使用用户值。 ## MTR 引擎关键机制(易踩坑) - 目的地提前停止: - `knownFinalTTL`(持久缓存)用于缩短后续探测的 TTL 上界;高 TTL 标记 disabled 后不再调度。 - seq 16 位回卷处理: - `seqWillWrap(...)` 触发 `rotateEngine(...)` - 轮换 echoID 并重建 listener,协议层隔离新旧回包。 - 额外安全网: - onICMP 中有 RTT 合理性检查(`<=0` 或 `>timeout` 丢弃)。 - 流式预览: - 仅已发送 TTL 才会参与预览;未发送 TTL 保持 nil 槽位,避免提前计入 Snt/Loss。 ## Web Console / WebSocket(server/) ### WS 架构(`server/ws_handler.go`,~451 行) - **异步写模型**:`wsTraceSession` 使用 `sendCh`(buffered channel,1024)+ `writeLoop` goroutine。 - 调用方通过 `send(envelope)` 非阻塞投递;channel 满时返回 `errWSSlowConsumer`。 - `writeLoop` 从 `sendCh` 取消息,`SetWriteDeadline` + `WriteJSON`。 - **关闭路径**: - `closeWithCode(code, reason)`:异常关闭(slow consumer / write error),关 `stopCh` + 发 close frame。 - `finish()`:正常结束,`sendMu` 下关 `sendCh`,等 `writerDone`,再关 conn。 - 两者均幂等(`closeOnce` / `finishOnce`)。 - **可测试性**:`wsConn` 接口 + `fakeWSConn` mock(`server/ws_handler_test.go`)。 - **常量**:`wsSendQueueSize=1024`,`wsWriteTimeout=5s`。 ### Web MTR 调度模式(重要变更) - **已从 round-based 迁移到 per-hop 调度**。 - `runMTRTrace()`: - 优先读 `HopIntervalMs`,fallback `IntervalMs`,再缺省 1000ms。 - `MaxRounds` → `MaxPerHop`(0 = 无限运行直到客户端断开)。 - 不再使用 legacy round-based 的 `Interval` / `RunRound`。 - `executeMTRRaw()` 两路分支: - `HopInterval > 0`:per-hop 模式,仅在 LeoMoe/FastIP 初始化阶段短暂加锁;长期探测不再依赖 `SrcDev` / `DisableMPLS` 等进程级全局。 - fallback:legacy round-based 模式(保留兼容),`RunRound` 回调内 per-round 锁定。 - `trace/runMTRRawRoundBased()` 也会先做 `normalizeRuntimeConfig(&cfg)`,因此 legacy raw 路径同样能继承 `SourceDevice`;`DisableMPLS` 不再从全局反向覆盖会话配置。 - `traceRequest` 新增 `HopIntervalMs` 字段(`json:"hop_interval_ms"`),与 `IntervalMs` 解耦。 - 前端 MTR 请求现在发送 `hop_interval_ms=1000`,不再把旧的 `interval_ms=2000` 当默认值。 - 前端 raw 聚合键现在按 TTL 折叠,避免同一 hop 的 timeout / success 被拆成两行。 ### 前端渲染节流(`server/web/assets/app.js`) - MTR raw 消息通过 `scheduleMTRRender()` 节流,最小间隔 100ms,优先 `requestAnimationFrame`。 - `cancelScheduledMTRRender()` 在 `clearResult`、socket close/error 路径调用,避免孤儿回调。 - `flushMTRRender()` 立即执行挂起渲染。 ### 其他 server 文件 - `server/server.go`:Gin 路由注册 - `server/handlers.go`:REST 接口 - `server/mtr.go`:MTR 专用 handler 逻辑 - `server/trace_handler.go`:traceroute handler - `server/cache_handler.go`:缓存 ## DoT 与 Geo DNS - `--dot-server` 不仅影响目标域名解析,也影响 GeoIP API / LeoMoe FastIP 的域名解析链路。 - 关键文件:`util/dns_resolver.go` - `SetGeoDNSResolver(dotServer)` - `WithGeoDNSResolver(dotServer, fn)`:为 Web/API 请求提供作用域化的 resolver 切换;不同 resolver 串行切换,相同 resolver 允许安全嵌套,避免 `GetSourceWithGeoDNS` + 外层作用域组合时死锁。 - `geoResolverOverride` 的读写现在也走 `geoMu`,避免测试覆盖 resolver 时的数据竞争。 - `LookupHostForGeo(ctx, host)`:IP 字面量短路 -> DoT -> 失败时按配置 fallback 系统 DNS - `cmd/cmd.go` 在早期阶段(fast-trace / ws 初始化之前)注入 DoT 解析策略,避免早期分支绕过。 - `server/trace_handler.go` 通过 `ipgeo.GetSourceWithGeoDNS(...)` + `WithGeoDNSResolver(...)` 让 Web/API 请求也遵守 `dot_server`,包括 LeoMoe/FastIP 初始化阶段。 - Geo HTTP 请求统一走 `util.NewGeoHTTPClient(...)`(`util/http_client_geo.go`),其 Transport 现在从默认 Transport `Clone()` 而来,保留代理/HTTP2/连接池等标准行为。 ## LeoMoe FastIP 与 MTR 首行 - `util/latency.go`: - `FastIPMetaCache` 缓存节点元数据(IP/Latency/NodeName) - `SuppressFastIPOutput` 可抑制彩色横幅 - `GetFastIP(...)` 的 DNS 阶段现在显式受 `timeout` 限制;`FastIPMetaCache` 也改为在 fallback/default IP 决定后再写入,避免缓存空 IP。 - MTR 模式在进入 TUI 前会设 `SuppressFastIPOutput=true`,避免污染主终端历史。 - MTR TUI/report 首行 `APIInfo` 由 `cmd/mtr_mode.go` 的 `buildAPIInfo(...)` 生成(仅 LeoMoeAPI 且有元数据时显示)。 - MTR raw 首行由 `buildRawAPIInfoLine(...)` 生成(格式略不同,包含延迟信息)。 ## `--source` / `--dev` 现状 - `--dev` 在 `cmd/cmd.go` 先解析网卡并推导 `srcAddr`(已处理非 `*net.IPNet` 地址类型,避免 panic)。 - `trace.Config` 现在显式携带 `SourceDevice` / `DisableMPLS`,Darwin TCP/UDP 抓包与 MPLS 解析优先走会话级配置,不再依赖 Web 侧临时改写全局变量。 - `trace.Config` 也显式携带 `Context`;`TracerouteWithContext(...)` 通过把上游 ctx 传入各 tracer 的 `signal.NotifyContext(...)` 基底,让 TCP/UDP fallback MTR 可以响应取消。 - Windows TCP 目前仍无法把 `SourceDevice` 映射到 WinDivert 接口选择;当前策略是显式报错拒绝,而不是静默忽略该字段。 - MTR 标题显示源信息来自: - `--source`(最高优先) - `--dev` 推导 - UDP dial fallback - 相关函数:`cmd/mtr_mode.go -> resolveSrcIP(...)` ## CI 与工具链(当前) - `go.mod`: `go 1.26.0` - GitHub Actions: - `.github/workflows/build.yml` 使用 `setup-go@v6` + `go-version: 1.26.x` - `.github/workflows/test.yml` 使用 `setup-go@v6` + `go-version: 1.26.x` - test workflow 中 `GOTOOLCHAIN=go1.26.0+auto` - build matrix 已移除 `windows/arm` - `.cross_compile.sh` 与 workflow 里的 `go build` 现在都用数组构造 `-tags` 参数,避免 shell word-splitting;脚本也会把当前 `GOARM` 传给 `compress_with_upx`,使 linux/armv7 目标能命中对应压缩分支。 - `ipgeo/ipdbone.go` 不再原地修改全局 `defaultClient.httpClient.Timeout`;超时覆盖会通过克隆 client(复用 token cache / token init,同步替换整个 HTTP client)实现,避免 dial timeout 与 client timeout 脱节。 ## 关键文件导航 - CLI 主调度:`cmd/cmd.go`(~855 行) - MTR 参数/流程:`cmd/mtr_mode.go`(~315 行) - MTR 交互输入:`cmd/mtr_ui.go` - MTR 引擎:`trace/mtr_runner.go` - MTR 聚合:`trace/mtr_stats.go` - MTR TUI:`printer/mtr_tui.go`(~691 行) - MTR table/report:`printer/mtr_table.go`(~625 行) - MTR TUI 颜色:`printer/mtr_tui_color.go` - WS handler:`server/ws_handler.go`(~449 行) - 前端:`server/web/assets/app.js` - Geo DoT 解析:`util/dns_resolver.go` - Geo HTTP 客户端:`util/http_client_geo.go` - FastIP:`util/latency.go` ## 2026-03 Gocyclo 重构快照 - 第一波低风险重构已落地: - `ipgeo.Filter` 改为 CIDR 规则表驱动。 - `util.DomainLookUp` 拆成 resolver / lookup / family filter / interactive select 四段。 - `server.prepareTrace`、`normalizeTarget`、`trace.Traceroute`、`trace.Hop.fetchIPData` 已改成薄协调器。 - `GetMTUByIPForDevice`、`GetICMPResponsePayload`、`parseIPDBOneResponse`、`GlobalpingFormatLocation` 已拆 helper。 - 第二波主流程/输出层已部分落地: - `cmd.Execute` 已拆成 parser 注册 helper、启动模式 helper、运行时调度 helper。 - `fast_trace.FastTest` / `testFile` 已拆成交互选择、源地址推导、文件目标解析、单目标执行。 - `server.mtrAggregator.Update`、`wshandle.messageSendHandler` 已改成薄入口。 - `printer.RealtimePrinter`、`RealtimePrinterWithRouter`、`tracelog.RealtimePrinter` 现在共用 `internal/hoprender` 的 hop attempt 分组逻辑。 - 第三波协议层已开始落地: - `trace/internal/icmp_common.go`、`tcp_common.go`、`udp_common.go` 已改成“读包循环 + 共享解码 helper + 回调派发”结构。 - 新增 `trace/internal/icmp_decode.go`,集中处理 ICMPv4/v6 解析、echo reply 匹配、内嵌目标 IP 校验、内嵌 ICMP seq 提取。 - 新增 `trace/internal/icmp_decode_test.go`,覆盖 IPv4/IPv6 echo reply、error payload、目标 IP 校验、内嵌 seq 提取。 - `trace/internal/udp_unix.go` 的 `SendUDP` 已拆成 IPv4/IPv6 独立发送 helper;`trace/udp_ipv4.go` 的 `send()` 也已拆成配额检查、构包、超时守护、发送记账四段。 - Windows 协议层新增 `trace/internal/windivert_sniff_windows.go`,把 WinDivert sniff handle 打开、收包、ICMP/TCP 解码下沉为共享 helper;`icmp_windows.go` / `tcp_windows.go` / `udp_windows.go` 的 sniff 入口已变成薄协调器。 - Darwin `trace/internal/icmp_darwin.go:ListenPacket` 已拆成 socket spec、接口绑定、bind sockaddr、finalize packet conn 四段;`trace/internal/tcp_darwin.go:ListenTCP` 也改成设备选择 + BPF + 共享 TCP reply 解码 helper。 - 新增 `trace/internal/tcp_probe_decode.go` 与 `trace/internal/tcp_probe_decode_test.go`,集中处理 TCP probe reply 的 seq 还原、peer IP 提取与 IPv4/IPv6 解析,供 Darwin/Windows TCP sniff 共用。 - 当前已知剩余高复杂度主要集中在: - `printer/mtr_*` 渲染层 - `cmd/mtr_ui.go` 输入状态机 - `trace/globalping.go` 的主流程函数 - `trace/mtr_runner.go` 中仍未拆薄的 ICMP round handler(`probeRound` / `onICMP`) - 少量收尾函数:`fast_trace ipv6.go`、`reporter/reporter.go`、`trace/mtr_raw.go` ## 2026-03 Gocyclo 重构快照(追加) - MTR 核心热点已完成一轮收敛: - `trace/mtr_scheduler.go:runMTRScheduler` 已改成薄入口,核心状态与分支移动到 `trace/mtr_scheduler_runtime.go`。 - `trace/mtr_stats.go:Update` / `MigrateStats` 已拆成按 hop 分组、累加器合并、裁剪 helper;新增 `trace/mtr_stats_helpers.go`。 - `trace/mtr_runner.go:mtrLoop` 已改成薄入口,取消/重置/暂停/预览/backoff 分支移动到 `trace/mtr_loop_runtime.go`。 - MTR 输出层与输入层热点也已收敛: - `printer/mtr_tui.go:mtrTUIRenderWithWidth` 已拆成布局扫描、三行头部构建、host part 预构建、MPLS 续行渲染四段。 - `printer/mtr_table.go` 的 host 组装和 `MTRReportPrint` 已改成共享 host-part 拼接 helper + report header/row helper。 - `cmd/mtr_ui.go:(*mtrInputParser).Feed` 已拆成按状态分发的 parser helper。 - 最后一批业务流程热点也已拆薄: - `trace/globalping.go:GlobalpingTraceroute` 已拆成 client 构建、measurement 请求、结果解码、hop limit 推导、结果组装五段。 - `trace/mtr_runner.go:(*mtrICMPEngine).onICMP` / `probeRound` 已拆成 reply 校验、notify 清理、目的地 TTL 识别、round 准备、发包 sweep、等待回包、结果构建多个 helper。 - `fast_trace/fast_trace ipv6.go:FastTestv6`、`reporter/reporter.go:generateRouteReportNode`、`trace/mtr_raw.go:buildMTRRawRecordFromProbe` 也已分别拆成选择分发、route-node 属性构建、raw record metadata 填充 helper。 - 当前本地复杂度扫描结果: - `go run /tmp/checkcyclo.go .` 已无 `>15` 函数输出。 - `go test ./...` 通过。 ## 仍需记住的残余风险(非阻断) - `closeWithCode` 中 `closed.Store(true)` 在 `closeOnce.Do` 外部,理论上有微小竞态窗口(实际无害,因 `sendMu` 保护;且无法简单移入 Once 内部,否则第二个调用者无法设置 closed)。 ================================================ FILE: CLAUDE.md ================================================ 每次执行命令前均需阅读项目根目录下的 ./AGENTS.md 文件 ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================
NextTrace Logo


NextTrace

An open source visual routing tool that pursues light weight, developed using Golang.

---
HomePage: www.nxtrace.org

Github Actions

## IAAS Sponsor
         
We are extremely grateful to [DMIT](https://dmit.io), [Misaka](https://misaka.io) and [SnapStack](https://portal.saltyfish.io) for providing the network infrastructure that powers this project. ## How To Use Document Language: English | [简体中文](README_zh_CN.md) ⚠️ Please note: We welcome PR submissions from the community, but please submit your PRs to the [NTrace-dev](https://github.com/nxtrace/NTrace-dev) repository instead of [NTrace-core](https://github.com/nxtrace/NTrace-core) repository.
Regarding the NTrace-dev and NTrace-core repositories:
Both will largely remain consistent with each other. All development work is done within the NTrace-dev repository. The NTrace-dev repository releases new versions first. After running stably for an undetermined period, we will synchronize that version to NTrace-core. This means that the NTrace-dev repository serves as a "beta" or "testing" version.
Please note, there are exceptions to this synchronization. If a version of NTrace-dev encounters a serious bug, NTrace-core will skip that flawed version and synchronize directly to the next version that resolves the issue. ### Automated Install - Linux - One-click installation script ```shell curl -sL https://nxtrace.org/nt | bash ``` - Install nxtrace from the APT repository - Supports AMD64/ARM64 architectures ```shell curl -fsSL https://github.com/nxtrace/nexttrace-debs/releases/latest/download/nexttrace-archive-keyring.gpg | sudo tee /etc/apt/keyrings/nexttrace.gpg >/dev/null echo "Types: deb URIs: https://github.com/nxtrace/nexttrace-debs/releases/latest/download/ Suites: ./ Signed-By: /etc/apt/keyrings/nexttrace.gpg" | sudo tee /etc/apt/sources.list.d/nexttrace.sources >/dev/null sudo apt update sudo apt install nexttrace ``` - APT repository maintained by wcbing and nxtrace - Arch Linux AUR installation command - Directly download bin package (only supports amd64) ```shell yay -S nexttrace-bin ``` - Build from source (only supports amd64) ```shell yay -S nexttrace ``` - The AUR builds are maintained by ouuan, huyz - Linuxbrew's installation command Same as the macOS Homebrew's installation method (homebrew-core version only supports amd64) - deepin installation command ```shell apt install nexttrace ``` - [x-cmd](https://www.x-cmd.com/pkg/nexttrace) installation command ```shell x env use nexttrace ``` - Termux installation command ```shell pkg install root-repo pkg install nexttrace ``` - ImmortalWrt installation command ```shell opkg install nexttrace ``` - macOS - macOS Homebrew's installation command - Homebrew-core version ```shell brew install nexttrace ``` - This repository's ACTIONS automatically built version (updates faster) ```shell brew tap nxtrace/nexttrace && brew install nxtrace/nexttrace/nexttrace ``` - The homebrew-core build is maintained by chenrui333, please note that this version's updates may lag behind the repository Action automatically version - Windows - Windows WinGet installation command - WinGet version ```powershell winget install nexttrace ``` - WinGet build maintained by Dragon1573 - Windows Scoop installation command - Scoop-extras version ```powershell scoop bucket add extras && scoop install extras/nexttrace ``` - Scoop-extra is maintained by soenggam Please note, the repositories for all of the above installation methods are maintained by open source enthusiasts. Availability and timely updates are not guaranteed. If you encounter problems, please contact the repository maintainer to solve them, or use the binary packages provided by the official build of this project. ### Manual Install - Download the precompiled executable For users not covered by the above methods, please go directly to [Release](https://www.nxtrace.org/downloads) to download the compiled binary executable. - `Release` provides compiled binary executables for many systems and different architectures. If none are available, you can compile it yourself. - Some essential dependencies of this project are not fully implemented on `Windows` by `Golang`, so currently, `NextTrace` is in an experimental support phase on the `Windows` platform. ### Build Variants Starting from this release, NextTrace is published in **three flavors** under the same tag. Choose the one that best fits your use case: | Feature | `nexttrace` (Full) | `nexttrace-tiny` | `ntr` | | --------------------- | :----------------: | :--------------: | :----------: | | Normal traceroute | ✅ | ✅ | — | | Standalone MTU (`--mtu`) | ✅ | ✅ | — | | MTR TUI | ✅ | — | ✅ (default) | | MTR report (`-r`) | ✅ | — | ✅ | | MTR wide (`-w`) | ✅ | — | ✅ | | MTR raw (`--raw`) | ✅ | — | ✅ | | Globalping (`--from`) | ✅ | — | — | | WebUI (`--deploy`) | ✅ | — | — | | Fast Trace (`-F`) | ✅ | ✅ | — | | Default mode | traceroute | traceroute | MTR TUI | | Binary name | `nexttrace` | `nexttrace-tiny` | `ntr` | > **Note:** Package managers (Homebrew, AUR, Scoop, etc.) currently install the **Full** (`nexttrace`) version only. ### Feature Matrix - **`nexttrace`** — Full-featured build. Includes everything: traceroute, MTR, Globalping, and WebUI. - **`nexttrace-tiny`** — Lightweight build. Normal traceroute only, no MTR / Globalping / WebUI. Suitable for embedded or minimal environments. - **`ntr`** — MTR-focused build. Runs MTR TUI by default. No Globalping / WebUI; no normal traceroute mode and no standalone `--mtu` mode. ### Manual Build Build from source with Go 1.22+ installed: ```bash # Full (all features) go build -trimpath -o dist/nexttrace -ldflags "-w -s" . # Tiny (no MTR, no Globalping, no WebUI) go build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny -ldflags "-w -s" . # NTR (MTR-only) go build -tags flavor_ntr -trimpath -o dist/ntr -ldflags "-w -s" . ``` Cross-compile example: ```bash # Linux arm64, Tiny flavor GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \ go build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny_linux_arm64 -ldflags "-w -s" . ``` The `tiny` and `ntr` flavors use **compile-time build tags** to exclude modules — this is not a runtime switch. You can verify with `go version -m ` that `gin` and `globalping-cli` are absent from `nexttrace-tiny` and `ntr`. The `.cross_compile.sh` script supports building flavors: ```bash ./.cross_compile.sh all # Build all three flavors for all platforms ./.cross_compile.sh full # Build only nexttrace (Full) ./.cross_compile.sh tiny # Build only nexttrace-tiny ./.cross_compile.sh ntr # Build only ntr ``` ### Release Assets Naming Release binaries follow this naming convention: ``` {binary}_{os}_{arch}[v{arm}][.exe][_softfloat] ``` Examples: - `nexttrace_linux_amd64`, `nexttrace-tiny_linux_amd64`, `ntr_linux_amd64` - `nexttrace_darwin_universal`, `nexttrace-tiny_darwin_universal`, `ntr_darwin_universal` - `nexttrace_windows_amd64.exe`, `ntr_windows_amd64.exe` ### Get Started `NextTrace` uses the `ICMP` protocol to perform TraceRoute requests by default, which supports both `IPv4` and `IPv6` ```bash # IPv4 ICMP Trace nexttrace 1.0.0.1 # URL nexttrace http://example.com:8080/index.html?q=1 # Table output (report mode): runs trace once and prints a final summary table nexttrace --table 1.0.0.1 # Machine-readable output: stdout is a single JSON document nexttrace --raw 1.0.0.1 nexttrace --json 1.0.0.1 # Realtime trace output to a custom file nexttrace --output ./trace.log 1.0.0.1 # Realtime trace output to the default log file nexttrace --output-default 1.0.0.1 # IPv4/IPv6 Resolve Only, and automatically select the first IP when there are multiple IPs nexttrace --ipv4 g.co nexttrace --ipv6 g.co # IPv6 ICMP Trace nexttrace 2606:4700:4700::1111 # Developer mode: set the ENV variable NEXTTRACE_DEVMODE=1 to make fatal errors panic with a stack trace export NEXTTRACE_DEVMODE=1 # Set TTL-group interval in normal traceroute mode (default: 300ms) nexttrace -i 300 1.1.1.1 # Disable Path Visualization With the -M parameter nexttrace koreacentral.blob.core.windows.net # MapTrace URL: https://api.nxtrace.org/tracemap/html/c14e439e-3250-5310-8965-42a1e3545266.html # Disable MPLS display using the --disable-mpls / -e parameter or the NEXTTRACE_DISABLEMPLS environment variable nexttrace --disable-mpls example.com export NEXTTRACE_DISABLEMPLS=1 ``` PS: The route visualization module is an independent component, You can find its source code at [nxtrace/traceMap](https://github.com/nxtrace/traceMap). The routing visualization function requires the geographical coordinates of each Hop, but third-party APIs generally do not provide this information, so this function is currently only supported when used with LeoMoeAPI. #### Mandatory Configuration Steps for `Windows` Users - **For Normal User Mode:** Only **ICMP mode** can be used, and the firewall must allow `ICMP/ICMPv6` traffic. ```powershell netsh advfirewall firewall add rule name="All ICMP v4" dir=in action=allow protocol=icmpv4:any,any netsh advfirewall firewall add rule name="All ICMP v6" dir=in action=allow protocol=icmpv6:any,any ``` - **For Administrator Mode:** **TCP/UDP mode** requires `WinDivert`. **ICMP mode** supports `1=Socket` and `2=WinDivert` (`0=Auto`). If running in Socket mode, the firewall must allow `ICMP/ICMPv6`. On `Windows`, `ICMPv6` without `--tos` (or with `--tos 0`) keeps using the native Socket send path. A non-zero `ICMPv6 --tos` requires `WinDivert` send support in addition to administrator privilege. `WinDivert` can be automatically configured using the `--init` parameter. #### `NextTrace` now supports quick testing, and friends who have a one-time backhaul routing test requirement can use it ```bash # IPv4 ICMP Fast Test (Beijing + Shanghai + Guangzhou + Hangzhou) in China Telecom / Unicom / Mobile / Education Network nexttrace --fast-trace # You can also use TCP SYN for testing nexttrace --fast-trace --tcp # You can also quickly test through a customized IP/DOMAIN list file nexttrace --file /path/to/your/iplist.txt # CUSTOMIZED IP DOMAIN LIST FILE FORMAT ## One IP/DOMAIN per line + space + description information (optional) ## forExample: ## 106.37.67.1 BEIJING-TELECOM ## 240e:928:101:31a::1 BEIJING-TELECOM ## bj.10086.cn BEIJING-MOBILE ## 2409:8080:0:1::1 ## 223.5.5.5 ``` #### `NextTrace` already supports route tracing for specified Network Devices ```bash # Use eth0 network interface nexttrace --dev eth0 2606:4700:4700::1111 # Use eth0 network interface's IP # When using the network interface's IP for route tracing, note that the IP type to be traced should be the same as network interface's IP type (e.g. both IPv4) nexttrace --source 204.98.134.56 9.9.9.9 ``` #### `NextTrace` can also use `TCP` and `UDP` protocols to perform `Traceroute` requests ```bash # TCP SYN Trace nexttrace --tcp www.bing.com # You can specify the port by yourself [here is 443], the default port is 80 nexttrace --tcp --port 443 2001:4860:4860::8888 # UDP Trace nexttrace --udp 1.0.0.1 # You can specify the target port yourself [here it is 5353], the default is port 33494 nexttrace --udp --port 5353 1.0.0.1 # For TCP/UDP Trace, you can specify the source port; by default, a fixed random port is used # (If you need to use a different random source port for each packet, please set the ENV variable NEXTTRACE_RANDOMPORT, or set the source port to -1) nexttrace --tcp --source-port 14514 www.bing.com ``` #### `NextTrace` also supports standalone path-MTU discovery mode ```bash # Tracepath-style UDP PMTU discovery with live hop output nexttrace --mtu 1.1.1.1 # Reuse the normal GeoIP / RDNS knobs in mtu mode nexttrace --mtu --data-provider IPInfo --language en 1.1.1.1 # JSON output keeps the standalone mtu schema and now includes hop.geo nexttrace --mtu --json 1.1.1.1 ``` - `--mtu` is an independent UDP-only mode. It does not reuse the normal traceroute engine. - TTY output updates the current hop in place and adds color for hop state / PMTU highlights; redirected / piped output falls back to finalized line-by-line streaming without ANSI. - `--mtu --json` prints only the standalone MTU JSON document on stdout. - GeoIP, RDNS, `--data-provider`, `--language`, `--no-rdns`, `--always-rdns`, and `--dot-server` all apply to this mode. #### `NextTrace` also supports some advanced functions, such as ttl control, concurrent probe packet count control, mode switching, etc. ```bash # Display 2 latency samples per hop nexttrace --queries 2 www.hkix.net # Allow up to 10 probe packets per hop to collect those samples # (NextTrace stops earlier if it has already got the replies requested by --queries) nexttrace --max-attempts 10 www.hkix.net # or use the ENV variable NEXTTRACE_MAXATTEMPTS to persist across runs export NEXTTRACE_MAXATTEMPTS=10 # No concurrent probe packets, only one probe packet is sent at a time nexttrace --parallel-requests 1 www.hkix.net # Start Trace with TTL of 5, end at TTL of 10 nexttrace --first 5 --max-hops 10 www.decix.net # In addition, an ENV is provided to set whether to mask the destination IP and omit its hostname export NEXTTRACE_ENABLEHIDDENDSTIP=1 # Turn off the IP reverse parsing function nexttrace --no-rdns www.bbix.net # Set the probe packet size to 1024 bytes (inclusive IP + probe headers) nexttrace --psize 1024 example.com # Randomize each probe packet size up to 1500 bytes nexttrace --psize -1500 example.com # Set the TOS / traffic class field nexttrace -Q 46 example.com # Feature: print Route-Path diagram # Route-Path diagram example: # AS6453 Tata Communication「Singapore『Singapore』」 # ╭╯ # ╰AS9299 Philippine Long Distance Telephone Co.「Philippines『Metro Manila』」 # ╭╯ # ╰AS36776 Five9 Inc.「Philippines『Metro Manila』」 # ╭╯ # ╰AS37963 Aliyun「ALIDNS.COM『ALIDNS.COM』」 nexttrace --route-path www.time.com.my # Disable color output nexttrace --no-color 1.1.1.1 # or use ENV export NO_COLOR=1 ``` #### Advanced tuning quick guide | Flag | What it controls | Default / starting point | When to change it | | --- | --- | --- | --- | | `--queries` | Samples per hop in normal traceroute; explicit probe count per hop in MTR | traceroute: `3`; MTR report: `10` when omitted; MTR TUI/raw: unlimited when omitted | Raise to `5-10` on unstable paths | | `--max-attempts` | Hard cap on probe packets per hop | auto-sized from `--queries` | Raise on lossy links when replies arrive slowly | | `--parallel-requests` | Total in-flight probes across TTLs | `18` | Use `1` on multipath/load-balanced paths; keep `6-18` on stable links | | `--send-time` | Gap between packets inside one TTL group | `50ms` | Raise to `100-200ms` on rate-limited devices; ignored in MTR | | `--ttl-time` | Gap between TTL groups in traceroute; per-hop interval in MTR | traceroute: `300ms`; MTR: `1000ms` when omitted | Lower to speed up; raise on remote/rate-limited paths | | `--timeout` | Per-probe timeout | `1000ms` | Raise to `2000-3000ms` for intercontinental or high-loss paths | | `--psize` | Probe packet size | Protocol/IP-family minimum | Inclusive IP + probe headers; negative values randomize each probe up to `abs(value)`; sizes above the egress/path MTU may fragment on wire | | `-Q`, `--tos` | IP TOS / traffic class | `0` | Set DSCP/TOS style marking in the IP header; on Windows only `ICMPv6` with a non-zero value requires `WinDivert` | These probe knobs are CLI-only today; `nt_config.yaml` does not yet store them. If you want reusable profiles, keep them in shell aliases or small wrapper scripts. ```bash # Conservative profile for multipath or ECMP networks nexttrace --parallel-requests 1 --send-time 100 --ttl-time 500 --timeout 2000 example.com # Faster profile for stable single-path networks nexttrace --parallel-requests 18 --send-time 20 --ttl-time 150 example.com # Lossy long-haul profile nexttrace --queries 5 --max-attempts 10 --timeout 2500 example.com ``` #### `NextTrace` supports MTR (My Traceroute) continuous probing mode ```bash # MTR mode: continuous probing with ICMP (default), refreshes table in real-time nexttrace -t 1.1.1.1 # or equivalently: nexttrace --mtr 1.1.1.1 # MTR mode with TCP SYN probing nexttrace -t --tcp --port 443 www.bing.com # MTR mode with UDP probing nexttrace -t --udp 1.0.0.1 # Set per-hop probe interval (default: 1000ms in MTR; -z/--send-time is ignored in MTR mode) nexttrace -t -i 500 1.1.1.1 # Limit the max probes per hop (default: infinite in TUI, 10 in report mode) nexttrace -t -q 20 1.1.1.1 # Report mode: probe each hop N times then print a final summary (like mtr -r) nexttrace -r 1.1.1.1 # = --mtr --report, 10 probes per hop by default nexttrace -r -q 5 1.1.1.1 # 5 probes per hop # Wide report: no host column truncation (like mtr -rw) nexttrace -w 1.1.1.1 # = --mtr --report --wide # Show PTR and IP together (PTR first, IP in parentheses) in MTR output nexttrace --mtr --show-ips 1.1.1.1 nexttrace -r --show-ips 1.1.1.1 nexttrace -w --show-ips 1.1.1.1 # MTR raw stream mode (machine-friendly, one event per line) nexttrace --mtr --raw 1.1.1.1 nexttrace -r --raw 1.1.1.1 # Combine with other options nexttrace -t --tcp --max-hops 20 --first 3 --no-rdns 8.8.8.8 ``` When running in a terminal (TTY), MTR mode uses an **interactive full-screen TUI**: - **`q` / `Q`** — quit (restores terminal, no output left behind) - **`p`** — pause probing - **`SPACE`** — resume probing - **`r`** — reset statistics (counters are cleared, display mode is preserved) - **`y`** — cycle host display mode: ASN → City → Owner → Full - **`n`** — toggle host name display: - default: PTR (or IP fallback) ↔ IP only - with `--show-ips`: PTR (IP) ↔ IP only - **`e`** — toggle MPLS label display on/off - The TUI header displays **source → destination**, with `--source`/`--dev` information when specified. - When using LeoMoeAPI, the preferred API IP address is shown in the header. - Uses the **alternate screen buffer**, so your previous terminal history is preserved on exit. - When stdin is not a TTY (e.g. piped), it falls back to a simple table refresh. The **report mode** (`-r`/`--report`) produces a one-shot summary after all probes complete, suitable for scripting: ```text Start: 2025-07-14T09:12:00+08:00 HOST: myhost Loss% Snt Last Avg Best Wrst StDev 1. one.one.one.one 0.0% 10 1.23 1.45 0.98 2.10 0.32 2. 10.0.0.2 100.0% 10 0.00 0.00 0.00 0.00 0.00 ``` Rows shown as `(waiting for reply)` keep the same table layout; the metric cells on that row are left blank. In non-wide report mode, NextTrace intentionally keeps the host column compact: - only `PTR/IP` is shown - no Geo API lookup is performed - no ASN / owner / location fields are shown - MPLS labels are hidden Wide report mode (`-w` / `--wide`) keeps the current full-information behavior, including Geo-derived fields and MPLS output. When `--raw` is used together with MTR (`--mtr`, `-r`, or `-w`), NextTrace enters **MTR raw stream mode**. If the active data provider is `LeoMoeAPI`, NextTrace first prints one uncolored API info preamble line: ```text [NextTrace API] preferred API IP - [2403:18c0:1001:462:dd:38ff:fe48:e0c5] - 21.33ms - DMIT.NRT ``` After that, it prints one `|`-delimited event per line: ``` 4|84.17.33.106|po66-3518.cr01.nrt04.jp.misaka.io|0.27|60068|Japan|Tokyo|Tokyo||cdn77.com|35.6804|139.7690 ``` Field order: `ttl|ip|ptr|rtt|asn|country|prov|city|district|owner|lat|lng` Timeout rows keep the same 12-column layout: `ttl|*||||||||||` In MTR mode (`--mtr`, `-r`, `-w`, including `--raw`), `-i/--ttl-time` sets the **per-hop probe interval**: how long to wait between successive probes to the same hop (default: 1000ms when omitted). `-z/--send-time` is ignored in MTR mode. > Note: `--show-ips` only takes effect in MTR mode (`--mtr`, `-r`, `-w`); otherwise it is ignored. > > Note: `--mtr` cannot be used together with `--table`, `--classic`, `--json`, `--output`, `--output-default`, `--route-path`, `--from`, `--fast-trace`, `--file`, or `--deploy`. #### `NextTrace` supports users to select their own IP API (currently supports: `LeoMoeAPI`, `IP.SB`, `IPInfo`, `IPInsight`, `IPAPI.com`, `IPInfoLocal`, `CHUNZHEN`) ```bash # You can specify the IP database by yourself [IP-API.com here], if not specified, LeoMoeAPI will be used nexttrace --data-provider ip-api.com ## Note There are frequency limits for free queries of the ipinfo and IPInsight APIs. You can purchase services from these providers to remove the limits ## If necessary, you can clone this project, add the token provided by ipinfo or IPInsight and compile it yourself ## Fill the token to: ipgeo/tokens.go ## Note For the offline database IPInfoLocal, please download it manually and rename it to ipinfoLocal.mmdb. (You can download it from here: https://ipinfo.io/signup?ref=free-database-downloads) ## Current directory, nexttrace binary directory and FHS directories (Unix-like) will be searched. ## To customize it, please use environment variables, export NEXTTRACE_IPINFOLOCALPATH=/xxx/yyy.mmdb ## Please be aware: Due to the serious abuse of IP.SB, you will often be not able to query IP data from this source ## IP-API.com has a stricter restiction on API calls, if you can't query IP data from this source, please try again in a few minutes # The Pure-FTPd IP database defaults to using http://127.0.0.1:2060 as the query interface. To customize it, please use environment variables export NEXTTRACE_CHUNZHENURL=http://127.0.0.1:2060 ## You can use https://github.com/freshcn/qqwry to build your own Pure-FTPd IP database service # You can also specify the default IP database by setting an environment variable export NEXTTRACE_DATAPROVIDER=ipinfo ``` #### `NextTrace` supports mixed parameters and shortened parameters ```bash Example: nexttrace --data-provider IPAPI.com --max-hops 20 --tcp --port 443 --queries 5 --no-rdns 1.1.1.1 nexttrace -tcp --queries 2 --parallel-requests 1 --table --route-path 2001:4860:4860::8888 Equivalent to: nexttrace -d ip-api.com -m 20 -T -p 443 -q 5 -n 1.1.1.1 nexttrace -T -q 2 --parallel-requests 1 --table -P 2001:4860:4860::8888 ``` ### Globalping [Globalping](https://globalping.io/) provides access to thousands of community-hosted probes to run network tests and measurements. Run traceroute from a specified location by using the `--from` flag. The location field accepts continents, countries, regions, cities, ASNs, ISPs, or cloud regions. ```bash nexttrace google.com --from Germany nexttrace google.com --from comcast+california ``` A limit of 250 tests per hour is set for all anonymous users. To double the limit to 500 per hour please set the `GLOBALPING_TOKEN` environment variable with your token. ```bash export GLOBALPING_TOKEN=your_token_here ``` ### IP Database We use [bgp.tools](https://bgp.tools) as a data provider for routing tables. NextTrace BackEnd is now open-source. https://github.com/sjlleo/nexttrace-backend NextTrace `LeoMoeAPI` now utilizes the Proof of Work (POW) mechanism to prevent abuse, where NextTrace introduces the powclient library as a client-side component. Both the POW CLIENT and SERVER are open source, and everyone is welcome to use them. (Please direct any POW module-related questions to the corresponding repositories) - [GitHub - tsosunchia/powclient: Proof of Work CLIENT for NextTrace](https://github.com/tsosunchia/powclient) - [GitHub - tsosunchia/powserver: Proof of Work SERVER for NextTrace](https://github.com/tsosunchia/powserver) All NextTrace IP geolocation `API DEMO` can refer to [here](https://github.com/nxtrace/NTrace-core/blob/main/ipgeo/) ### Environment Variables NextTrace currently reads the following environment variables. For boolean switches, only `1` and `0` are recognized; other values fall back to the built-in default. For consistency, restart NextTrace after changing them. #### Core Runtime / Network | Variable | Default | Description | | --- | --- | --- | | `NEXTTRACE_DEVMODE` | `0` | Turn fatal errors into panics with stack traces for debugging. | | `NEXTTRACE_DEBUG` | unset | Print detected environment values while `GetEnv*` helpers parse them. | | `NEXTTRACE_DISABLEMPLS` | `0` | Disable MPLS display globally, similar to `--disable-mpls`. | | `NEXTTRACE_ENABLEHIDDENDSTIP` | `0` | Mask the destination IP and omit its hostname in output. | | `NEXTTRACE_RANDOMPORT` | `0` | Use a different random source port for each TCP/UDP probe. | | `NEXTTRACE_MAXATTEMPTS` | auto | Provide a default `--max-attempts` value when the CLI flag is not set. | | `NEXTTRACE_ICMPMODE` | `0` | Provide a default `--icmp-mode` value (`0=auto`, `1=socket`, `2=WinDivert` on Windows). | | `NEXTTRACE_UNINTERRUPTED` | `0` | When used together with `--raw`, rerun traceroute continuously instead of stopping after one round. | | `NEXTTRACE_PROXY` | unset | Outbound proxy URL for HTTP / WebSocket requests used by PoW, Geo APIs, tracemap, etc. | | `NEXTTRACE_DATAPROVIDER` | unset | Override the default IP geolocation provider (for example `ipinfo`). | #### Service / Web / Backend | Variable | Default | Description | | --- | --- | --- | | `NEXTTRACE_HOSTPORT` | `api.nxtrace.org` | Override the backend host or `host:port` used by LeoMoeAPI, tracemap, and FastIP flows. | | `NEXTTRACE_TOKEN` | unset | Pre-supplied LeoMoeAPI bearer token; when present, token fetching via PoW is skipped. | | `NEXTTRACE_POWPROVIDER` | `api.nxtrace.org` | Select the PoW provider. The built-in non-default alias is `sakura`. | | `NEXTTRACE_DEPLOY_ADDR` | unset | Default listen address for `--deploy` when `--listen` is not provided. | | `NEXTTRACE_ALLOW_CROSS_ORIGIN` | `0` | Only for `--deploy`: allow cross-origin browser access to the Web UI / API. Disabled by default for safety. | #### IP Database / Third-Party Providers | Variable | Default | Description | | --- | --- | --- | | `NEXTTRACE_IPINFOLOCALPATH` | auto search | Full path to `ipinfoLocal.mmdb` for the `IPInfoLocal` provider. | | `NEXTTRACE_CHUNZHENURL` | `http://127.0.0.1:2060` | Base URL of the Chunzhen lookup service. | | `NEXTTRACE_IPINFO_TOKEN` | unset | Token for the `IPInfo` provider. | | `NEXTTRACE_IPINSIGHT_TOKEN` | unset | Token for the `IPInsight` provider. | | `NEXTTRACE_IPAPI_BASE` | provider built-in URL | Override the base URL used by compatible IP API clients in the current implementation (`IPInfo`, `IPInsight`, `ip-api.com`). | | `IPDBONE_BASE_URL` | `https://api.ipdb.one` | Override the IPDB.One API base URL. | | `IPDBONE_API_ID` | unset | IPDB.One API ID. | | `IPDBONE_API_KEY` | unset | IPDB.One API key. | | `GLOBALPING_TOKEN` | unset | Authentication token for Globalping; raises the anonymous hourly limit when provided. | #### Config Discovery | Variable | Default | Description | | --- | --- | --- | | `XDG_CONFIG_HOME` | OS / shell default | If set, NextTrace also searches `$XDG_CONFIG_HOME/nexttrace` for `nt_config.yaml`. | ### For full usage list, please refer to the usage menu ```shell Usage: nexttrace [-h|--help] [--init] [-4|--ipv4] [-6|--ipv6] [-T|--tcp] [-U|--udp] [-F|--fast-trace] [-p|--port ] [--icmp-mode ] [-q|--queries ] [--max-attempts ] [--parallel-requests ] [-m|--max-hops ] [-d|--data-provider (IP.SB|ip.sb|IPInfo|ipinfo|IPInsight|ipinsight|IPAPI.com|ip-api.com|IPInfoLocal|ipinfolocal|chunzhen|LeoMoeAPI|leomoeapi|ipdb.one|disable-geoip)] [--pow-provider (api.nxtrace.org|sakura)] [-n|--no-rdns] [-a|--always-rdns] [-P|--route-path] [--dn42] [-o|--output ""] [-O|--output-default] [--table] [--raw] [-j|--json] [-c|--classic] [-f|--first ] [-M|--map] [-e|--disable-mpls] [-V|--version] [-s|--source ""] [--source-port ] [-D|--dev ""] [--listen ""] [--deploy] [-z|--send-time ] [-i|--ttl-time ] [--timeout ] [--psize ] [--dot-server (dnssb|aliyun|dnspod|google|cloudflare)] [-g|--language (en|cn)] [-C|--no-color] [--from ""] [-t|--mtr] [-r|--report] [-w|--wide] [--show-ips] [-y|--ipinfo ] [--file ""] [TARGET ""] Arguments: -h --help Print help information --init Windows ONLY: Extract WinDivert runtime to current directory -4 --ipv4 Use IPv4 only -6 --ipv6 Use IPv6 only -T --tcp Use TCP SYN for tracerouting (default dest-port is 80) -U --udp Use UDP SYN for tracerouting (default dest-port is 33494) -F --fast-trace One-Key Fast Trace to China ISPs -p --port Set the destination port to use. With default of 80 for "tcp", 33494 for "udp" --icmp-mode Windows ONLY: Choose the method to listen for ICMP packets (1=Socket, 2=WinDivert; 0=Auto) -q --queries Latency samples per hop. Increase to 5-10 on unstable paths for a steadier view. Default: 3 --max-attempts Advanced: hard cap on probe packets per hop. Leave unset for auto sizing; raise on lossy links if --queries is not enough --parallel-requests Advanced: total concurrent in-flight probes across TTLs. Use 1 on multipath/load-balanced paths; 6-18 is a good starting range on stable links. Default: 18 -m --max-hops Set the max number of hops (max TTL to be reached). Default: 30 -d --data-provider Choose IP Geograph Data Provider [IP.SB, IPInfo, IPInsight, IP-API.com, IPInfoLocal, CHUNZHEN, disable-geoip]. Default: LeoMoeAPI --pow-provider Choose PoW Provider [api.nxtrace.org, sakura] For China mainland users, please use sakura. Default: api.nxtrace.org -n --no-rdns Do not resolve IP addresses to their domain names -a --always-rdns Always resolve IP addresses to their domain names -P --route-path Print traceroute hop path by ASN and location --dn42 DN42 Mode -o --output Write trace result to FILE (RealtimePrinter only) -O --output-default Write trace result to the default log file (/tmp/trace.log) --table Output trace results as a final summary table (traceroute report mode) --raw Machine-friendly output. With MTR (--mtr/-r/-w), enables streaming raw event mode -j --json Output trace results as JSON -c --classic Classic Output trace results like BestTrace -f --first Start from the first_ttl hop (instead of 1). Default: 1 -M --map Disable Print Trace Map -e --disable-mpls Disable MPLS -V --version Print version info and exit -s --source Use source address src_addr for outgoing packets --source-port Use source port src_port for outgoing packets -D --dev Use the following Network Devices as the source address in outgoing packets --listen Set listen address for web console (e.g. 127.0.0.1:30080) --deploy Start the Gin powered web console -z --send-time Advanced: per-packet gap [ms] inside the same TTL group. Lower is faster; raise to 100-200ms on rate-limited links. Ignored in MTR mode. Default: 50 -i --ttl-time Advanced: TTL-group interval [ms] in normal traceroute. In MTR mode (--mtr/-r/-w, including --raw), this becomes per-hop probe interval. 500-1000ms is a good MTR starting range --timeout Per-probe timeout [ms]. Raise to 2000-3000 on slow intercontinental or high-loss paths. Default: 1000 --psize Probe packet size in bytes, inclusive IP and active probe headers. Default is the minimum legal size for the chosen protocol and IP family; raise for MTU or large-packet testing. Negative values randomize each probe up to abs(value). -Q --tos Set the IP type-of-service / traffic class value [0-255]. Default: 0 --dot-server Use DoT Server for DNS Parse [dnssb, aliyun, dnspod, google, cloudflare] -g --language Choose the language for displaying [en, cn]. Default: cn -C --no-color Disable Colorful Output --from Run traceroute via Globalping (https://globalping.io/network) from a specified location. The location field accepts continents, countries, regions, cities, ASNs, ISPs, or cloud regions. -t --mtr Enable MTR (My Traceroute) continuous probing mode -r --report MTR report mode (non-interactive, implies --mtr); can trigger MTR without --mtr -w --wide MTR wide report mode (implies --mtr --report); alone equals --mtr --report --wide --show-ips MTR only: display both PTR hostnames and numeric IPs (PTR first, IP in parentheses) -y --ipinfo Set initial MTR TUI host info mode (0-4). TUI only; ignored in --report/--raw. 0:IP/PTR 1:ASN 2:City 3:Owner 4:Full. Default: 0 --file Read IP Address or domain name from file TARGET Trace target: IPv4 address (e.g. 8.8.8.8), IPv6 address (e.g. 2001:db8::1), domain name (e.g. example.com), or URL (e.g. https://example.com) ``` ## Project screenshot ![image](https://user-images.githubusercontent.com/13616352/216064486-5e0a4ad5-01d6-4b3c-85e9-2e6d2519dc5d.png) ![image](https://user-images.githubusercontent.com/59512455/218501311-1ceb9b79-79e6-4eb6-988a-9d38f626cdb8.png) ## OpenTrace `OpenTrace` is the cross-platform `GUI` version of `NextTrace` developed by @Archeb, bringing a familiar but more powerful user experience. This software is still in the early stages of development and may have many flaws and errors. We value your feedback. [https://github.com/Archeb/opentrace](https://github.com/Archeb/opentrace) ## NEXTTRACE WEB API `NextTraceWebApi` is a web-based server implementation of `NextTrace` in the `MTR` style, offering various deployment options including `Docker`. For WebSocket continuous tracing, MTR now streams per-event payloads with `type: "mtr_raw"` (instead of periodic `mtr` snapshots). [https://github.com/nxtrace/nexttracewebapi](https://github.com/nxtrace/nexttracewebapi) ## NextTraceroute `NextTraceroute` is a root-free Android route tracing application that defaults to using the `NextTrace API`, developed by @surfaceocean. Thank you to all the test users for your enthusiastic support. This app has successfully passed the closed testing phase and is now officially available on the Google Play Store. [https://github.com/nxtrace/NextTraceroute](https://github.com/nxtrace/NextTraceroute) Get it on Google Play ## LeoMoeAPI Credits NextTrace focuses on Golang Traceroute implementations, and its LeoMoeAPI geolocation information is not supported by raw data, so a commercial version is not possible. The LeoMoeAPI data is subject to copyright restrictions from multiple data sources, and is only used for the purpose of displaying the geolocation of route tracing. 1. We would like to credit samleong123 for providing nodes in Malaysia, TOHUNET Looking Glass for global nodes, and Ping.sx from Misaka, where more than 80% of reliable calibration data comes from ping/mtr reports. 2. At the same time, we would like to credit isyekong for their contribution to rDNS-based calibration ideas and data. LeoMoeAPI is accelerating the development of rDNS resolution function, and has already achieved automated geolocation resolution for some backbone networks, but there are some misjudgments. We hope that NextTrace will become a One-Man ISP-friendly traceroute tool in the future, and we are working on improving the calibration of these ASN micro-backbones as much as possible. 3. In terms of development, I would like to credit missuo and zhshch for their help with Go cross-compilation, design concepts and TCP/UDP Traceroute refactoring, and tsosunchia for their support on TraceMap. 4. I would also like to credit FFEE_CO, TheresaQWQ, stydxm and others for their help. LeoMoeAPI has received a lot of support since its first release, so I would like to credit them all! We hope you can give us as much feedback as possible on IP geolocation errors (see issue) so that it can be calibrated in the first place and others can benefit from it. ## Cloudflare Support This project is sponsored by [Project Alexandria](http://www.cloudflare.com/oss-credits). Cloudflare Logo ## AIWEN TECH Support This project is sponsored by [AIWEN TECH](https://www.ipplus360.com). We’re pleased to enhance the accuracy and completeness of this project’s GEOIP lookups using `AIWEN TECH City-Level IP Database`, and to make it freely available to the public. AIWEN TECH IP Geolocation Data ## JetBrain Support This Project uses [JetBrain Open-Source Project License](https://jb.gg/OpenSourceSupport). We Proudly Develop By `Goland`. GoLand logo ## Credits [Gubo](https://www.gubo.org) Reliable Host Recommendation Website [IPInfo](https://ipinfo.io) Provided most of the data support for this project free of charge [BGP.TOOLS](https://bgp.tools) Provided some data support for this project free of charge [PeeringDB](https://www.peeringdb.com) Provided some data support for this project free of charge [Globalping](https://globalping.io) An open-source and free project that provides global access to run network tests like traceroute [sjlleo](https://github.com/sjlleo) The perpetual leader, founder, and core contributors [tsosunchia](https://github.com/tsosunchia) The project chair, infra maintainer, and core contributors [Yunlq](https://github.com/Yunlq) An active community contributor [Vincent Young](https://github.com/missuo) [zhshch2002](https://github.com/zhshch2002) [Sam Sam](https://github.com/samleong123) [waiting4new](https://github.com/waiting4new) [FFEE_CO](https://github.com/fkx4-p) [bobo liu](https://github.com/fakeboboliu) [YekongTAT](https://github.com/isyekong) ### Others - Although other third-party APIs are integrated in this project, please refer to the official website of the third-party APIs for specific TOS and AUP. If you encounter IP data errors, please contact them directly to correct them. - For feedback related to corrections about IP information, we currently have two channels available: > - [IP 错误报告汇总帖](https://github.com/orgs/nxtrace/discussions/222) in the GITHUB ISSUES section of this project (Recommended) > - This project's dedicated correction email: `correct#nxtrace.org` (Please note that this email is only for correcting IP-related information. For other feedback, please submit an ISSUE) - How to obtain the freshly baked binary executable of the latest commit? > Please go to the most recent [Build & Release](https://github.com/nxtrace/NTrace-dev/actions/workflows/build.yml) workflow in GitHub Actions. - Common questions - On Windows, ICMP mode requires manual firewall allowance for ICMP/ICMPv6 - On macOS, only ICMP mode does not require elevated privileges - In some cases, running multiple instances of NextTrace simultaneously may interfere with each other’s results (observed so far only in TCP mode) ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=nxtrace/NTrace-core&type=Date)](https://star-history.com/#nxtrace/NTrace-core&Date) ================================================ FILE: README_zh_CN.md ================================================
NextTrace Logo


NextTrace

一款追求轻量化的开源可视化路由跟踪工具。

---
主页:www.nxtrace.org

Github Actions

## IAAS Sponsor
         
我们非常感谢 [DMIT](https://dmit.io)、 [Misaka](https://misaka.io) 和 [SnapStack](https://portal.saltyfish.io) 提供了支持本项目所需的网络基础设施。 ## How To Use Document Language: [English](README.md) | 简体中文 ⚠️ 请注意:我们欢迎来自社区的PR提交,但是请将您的PR提交至 [NTrace-dev](https://github.com/nxtrace/NTrace-dev) 仓库,而不是 [NTrace-core](https://github.com/nxtrace/NTrace-core) 仓库。
关于NTrace-dev和NTrace-core两个仓库的说明:
二者将大体上保持一致。所有的开发工作均在NTrace-dev仓库中进行。NTrace-dev仓库首先发布新版本,在稳定运行一段时间后(时长不定),我们会把版本同步至NTrace-core。这意味着NTrace-dev仓库充当了一个“测试版”的角色。
请注意,版本同步也存在例外。如果NTrace-dev的某个版本出现了严重的bug,NTrace-core会跳过这一有缺陷的版本,直接同步到下一个修复了该问题的版本。 ### Before Using 使用 NextTrace 之前,我们建议您先阅读 [#IP 数据以及精准度说明](https://github.com/nxtrace/NTrace-core/blob/main/README_zh_CN.md#ip-%E6%95%B0%E6%8D%AE%E4%BB%A5%E5%8F%8A%E7%B2%BE%E5%87%86%E5%BA%A6%E8%AF%B4%E6%98%8E),在了解您自己的对数据精准度需求以后再进行抉择。 ### Automated Install - Linux - 一键安装脚本 ```shell curl -sL https://nxtrace.org/nt | bash ``` - 从 nxtrace的APT源安装 - 支持 AMD64/ARM64 架构 ```shell curl -fsSL https://github.com/nxtrace/nexttrace-debs/releases/latest/download/nexttrace-archive-keyring.gpg | sudo tee /etc/apt/keyrings/nexttrace.gpg >/dev/null echo "Types: deb URIs: https://github.com/nxtrace/nexttrace-debs/releases/latest/download/ Suites: ./ Signed-By: /etc/apt/keyrings/nexttrace.gpg" | sudo tee /etc/apt/sources.list.d/nexttrace.sources >/dev/null sudo apt update sudo apt install nexttrace ``` - APT源由 wcbing, nxtrace 维护 - Arch Linux AUR 安装命令 - 直接下载bin包(仅支持amd64) ```shell yay -S nexttrace-bin ``` - 从源码构建(仅支持amd64) ```shell yay -S nexttrace ``` - AUR 的构建分别由 ouuan, huyz 维护 - Linuxbrew 安装命令 同macOS Homebrew安装方法(homebrew-core版仅支持amd64) - deepin 安装命令 ```shell apt install nexttrace ``` - [x-cmd](https://cn.x-cmd.com/pkg/nexttrace) 安装命令 ```shell x env use nexttrace ``` - Termux 安装命令 ```shell pkg install root-repo pkg install nexttrace ``` - ImmortalWrt 安装命令 ```shell opkg install nexttrace ``` - macOS - macOS Homebrew 安装命令 - homebrew-core版 ```shell brew install nexttrace ``` - 本仓库ACTIONS自动构建版(更新更快) ```shell brew tap nxtrace/nexttrace && brew install nxtrace/nexttrace/nexttrace ``` - homebrew-core 构建由 chenrui333 维护,请注意该版本更新可能会落后仓库Action自动构建版本 - Windows - Windows WinGet 安装命令 - WinGet 版 ```powershell winget install nexttrace ``` - WinGet 构建由 Dragon1573 维护 - Windows Scoop 安装命令 - scoop-extras 版 ```powershell scoop bucket add extras && scoop install extras/nexttrace ``` - scoop-extra 由 soenggam 维护 请注意,以上多种安装方式的仓库均由开源爱好者自行维护,不保证可用性和及时更新,如遇到问题请联系仓库维护者解决,或使用本项目官方编译提供的二进制包。 ### Manual Install - 下载预编译的可执行程序 对于以上方法没有涵盖的用户,请直接前往 [Release](https://www.nxtrace.org/downloads) 下载编译好的二进制可执行文件。 - `Release`里面为很多系统以及不同架构提供了编译好的二进制可执行文件,如果没有可以自行编译。 - 一些本项目的必要依赖在`Windows`上`Golang`底层实现不完全,所以目前`NextTrace`在`Windows`平台出于实验性支持阶段。 ### 版本说明 从本版本开始,NextTrace 在同一 release tag 下发布 **三种构建版本**,按需选用: | 功能 | `nexttrace`(完整版) | `nexttrace-tiny` | `ntr` | | ----------------------- | :-------------------: | :--------------: | :--------: | | 常规 traceroute | ✅ | ✅ | — | | 独立 MTU(`--mtu`) | ✅ | ✅ | — | | MTR TUI | ✅ | — | ✅(默认) | | MTR 报告(`-r`) | ✅ | — | ✅ | | MTR 宽报告(`-w`) | ✅ | — | ✅ | | MTR 原始输出(`--raw`) | ✅ | — | ✅ | | Globalping(`--from`) | ✅ | — | — | | WebUI(`--deploy`) | ✅ | — | — | | 快速跟踪(`-F`) | ✅ | ✅ | — | | 默认运行模式 | traceroute | traceroute | MTR TUI | | 二进制名 | `nexttrace` | `nexttrace-tiny` | `ntr` | > **注意:** 包管理器(Homebrew、AUR、Scoop 等)目前仅安装 **完整版**(`nexttrace`)。 ### 功能对比 - **`nexttrace`** — 完整版。包含所有功能:traceroute、MTR、Globalping 与 WebUI。 - **`nexttrace-tiny`** — 精简版。仅保留常规 traceroute,不含 MTR / Globalping / WebUI。适合嵌入式或极简环境。 - **`ntr`** — MTR 专用版。默认启动 MTR TUI。无 Globalping / WebUI,无常规 traceroute 模式,也不带独立 `--mtu` 模式。 ### 手动编译 需要 Go 1.22+ 环境: ```bash # 完整版(所有功能) go build -trimpath -o dist/nexttrace -ldflags "-w -s" . # 精简版(无 MTR、无 Globalping、无 WebUI) go build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny -ldflags "-w -s" . # MTR 专用版 go build -tags flavor_ntr -trimpath -o dist/ntr -ldflags "-w -s" . ``` 交叉编译示例: ```bash # Linux arm64 精简版 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \ go build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny_linux_arm64 -ldflags "-w -s" . ``` `tiny` 和 `ntr` 版本通过 **编译期 build tags** 裁剪模块——不是运行时开关。可通过 `go version -m ` 验证 `nexttrace-tiny` 和 `ntr` 中不包含 `gin` 与 `globalping-cli`。 `.cross_compile.sh` 脚本支持按版本构建: ```bash ./.cross_compile.sh all # 构建全部三个版本(所有平台) ./.cross_compile.sh full # 仅构建 nexttrace(完整版) ./.cross_compile.sh tiny # 仅构建 nexttrace-tiny ./.cross_compile.sh ntr # 仅构建 ntr ``` ### 发行资产命名规则 Release 二进制文件命名格式: ```text {二进制名}_{操作系统}_{架构}[v{arm版本}][.exe][_softfloat] ``` 示例: - `nexttrace_linux_amd64`、`nexttrace-tiny_linux_amd64`、`ntr_linux_amd64` - `nexttrace_darwin_universal`、`nexttrace-tiny_darwin_universal`、`ntr_darwin_universal` - `nexttrace_windows_amd64.exe`、`ntr_windows_amd64.exe` ### Get Started `NextTrace` 默认使用`ICMP`协议发起`TraceRoute`请求,该协议同时支持`IPv4`和`IPv6` ```bash # IPv4 ICMP Trace nexttrace 1.0.0.1 # URL nexttrace http://example.com:8080/index.html?q=1 # 表格输出(报告模式):运行一次探测后打印最终汇总表格 nexttrace --table 1.0.0.1 # 机器可读输出:stdout 只包含一个 JSON 文档 nexttrace --raw 1.0.0.1 nexttrace --json 1.0.0.1 # 将实时 traceroute 输出写入自定义文件 nexttrace --output ./trace.log 1.0.0.1 # 将实时 traceroute 输出写入默认日志文件 nexttrace --output-default 1.0.0.1 # 只进行IPv4/IPv6解析,且当多个IP时自动选择第一个IP nexttrace --ipv4 g.co nexttrace --ipv6 g.co # IPv6 ICMP Trace nexttrace 2606:4700:4700::1111 # 普通 traceroute 模式下设置 TTL 分组间隔(默认 300ms) nexttrace -i 300 1.1.1.1 # 禁用路径可视化 使用 --map / -M 参数 nexttrace koreacentral.blob.core.windows.net # MapTrace URL: https://api.nxtrace.org/tracemap/html/c14e439e-3250-5310-8965-42a1e3545266.html # 禁用MPLS显示 使用 --disable-mpls / -e 参数 或 NEXTTRACE_DISABLEMPLS 环境变量 nexttrace --disable-mpls example.com export NEXTTRACE_DISABLEMPLS=1 ``` PS: 路由可视化的绘制模块为独立模块,具体代码可在 [nxtrace/traceMap](https://github.com/nxtrace/traceMap) 查看 路由可视化功能因为需要每个 Hop 的地理位置坐标,而第三方 API 通常不提供此类信息,所以此功能目前只支持搭配 LeoMoeAPI 使用。 #### `Windows` 用户必须完成的配置步骤 - 对于普通用户模式: 只能使用 **ICMP mode**,且需防火墙配置允许`ICMP/ICMPv6`。 ```powershell netsh advfirewall firewall add rule name="All ICMP v4" dir=in action=allow protocol=icmpv4:any,any netsh advfirewall firewall add rule name="All ICMP v6" dir=in action=allow protocol=icmpv6:any,any ``` - 对于管理员模式: **TCP/UDP mode** 依赖 `WinDivert`。 **ICMP mode** 支持 `1=Socket` 与 `2=WinDivert`(`0=Auto`)。使用 Socket 模式时,需防火墙配置允许`ICMP/ICMPv6`。 在 `Windows` 上,`ICMPv6` 未传 `--tos` 或显式 `--tos 0` 时继续走原生 Socket 发送路径;只有非零 `ICMPv6 --tos` 才额外依赖 `WinDivert` 发送能力,并要求管理员权限。 `WinDivert` 可使用 `--init` 参数自动配置环境。 #### `NextTrace` 现已经支持快速测试,有一次性测试回程路由需求的朋友可以使用 ```bash # 北上广(电信+联通+移动+教育网)IPv4 / IPv6 ICMP 快速测试 nexttrace --fast-trace # 也可以使用 TCP SYN 而非 ICMP 进行测试 nexttrace --fast-trace --tcp # 也可以通过自定义的IP/DOMAIN列表文件进行快速测试 nexttrace --file /path/to/your/iplist.txt # 自定义的IP/DOMAIN列表文件格式 ## 一行一个IP/DOMAIN + 空格 + 描述信息(可选) ## 例如: ## 106.37.67.1 北京电信 ## 240e:928:101:31a::1 北京电信 ## bj.10086.cn 北京移动 ## 2409:8080:0:1::1 ## 223.5.5.5 ``` #### `NextTrace` 已支持指定网卡进行路由跟踪 ```bash # 请注意 Lite 版本此参数不能和快速测试联用,如有需要请使用 enhanced 版本 # 使用 eth0 网卡 nexttrace --dev eth0 2606:4700:4700::1111 # 使用 eth0 网卡IP # 网卡 IP 可以使用 ip a 或者 ifconfig 获取 # 使用网卡IP进行路由跟踪时需要注意跟踪的IP类型应该和网卡IP类型一致(如都为 IPv4) nexttrace --source 204.98.134.56 9.9.9.9 ``` #### `NextTrace` 也可以使用`TCP`和`UDP`协议发起`Traceroute`请求 ```bash # TCP SYN Trace nexttrace --tcp www.bing.com # 可以自行指定目标端口[此处为443],默认80端口 nexttrace --tcp --port 443 2001:4860:4860::8888 # UDP Trace nexttrace --udp 1.0.0.1 # 可以自行指定目标端口[此处为5353],默认33494端口 nexttrace --udp --port 5353 1.0.0.1 # TCP/UDP Trace 可以自行指定源端口,默认使用随机一个固定的端口(如需每次发包随机使用不同的源端口,请设置`ENV` `NEXTTRACE_RANDOMPORT`) nexttrace --tcp --source-port 14514 www.bing.com ``` #### `NextTrace` 也支持独立的路径 MTU 探测模式 ```bash # 类 tracepath 的 UDP PMTU 探测,运行中实时刷行 nexttrace --mtu 1.1.1.1 # mtu 模式同样复用常规的 GeoIP / RDNS 参数 nexttrace --mtu --data-provider IPInfo --language en 1.1.1.1 # JSON 输出沿用独立 mtu schema,并包含 hop.geo nexttrace --mtu --json 1.1.1.1 ``` - `--mtu` 是独立的 UDP-only 模式,不复用普通 traceroute 引擎。 - TTY 下会原地更新当前 hop,并为 hop 状态 / PMTU 高亮加色;重定向/管道输出会退化成“定稿一跳输出一行”的无 ANSI 流式文本。 - `--mtu --json` 在 stdout 上只输出独立的 MTU JSON 文档。 - GeoIP、RDNS、`--data-provider`、`--language`、`--no-rdns`、`--always-rdns`、`--dot-server` 都对该模式生效。 #### `NextTrace`也同样支持一些进阶功能,如 TTL 控制、并发数控制、模式切换等 ```bash # 每一跳发送2个探测包 nexttrace --queries 2 www.hkix.net # 无并发,每次只发送一个探测包 nexttrace --parallel-requests 1 www.hkix.net # 从TTL为5开始发送探测包,直到TTL为10结束 nexttrace --first 5 --max-hops 10 www.decix.net # 此外还提供了一个ENV,可以设置是否隐匿目的IP export NEXTTRACE_ENABLEHIDDENDSTIP=1 # 关闭IP反向解析功能 nexttrace --no-rdns www.bbix.net # 设置探测包总大小为1024字节(含 IP + 探测协议头) nexttrace --psize 1024 example.com # 让每个 probe 在 1500 字节内随机大小 nexttrace --psize -1500 example.com # 设置 TOS / traffic class 字段 nexttrace -Q 46 example.com # 特色功能:打印Route-Path图 # Route-Path图示例: # AS6453 塔塔通信「Singapore『Singapore』」 # ╭╯ # ╰AS9299 Philippine Long Distance Telephone Co.「Philippines『Metro Manila』」 # ╭╯ # ╰AS36776 Five9 Inc.「Philippines『Metro Manila』」 # ╭╯ # ╰AS37963 阿里云「ALIDNS.COM『ALIDNS.COM』」 nexttrace --route-path www.time.com.my # 禁止色彩输出 nexttrace --no-color 1.1.1.1 # 或者使用环境变量 export NO_COLOR=1 ``` #### 高级参数调优速查 | 参数 | 控制内容 | 默认值 / 起步建议 | 什么时候调 | | --- | --- | --- | --- | | `--queries` | 常规 traceroute 的每跳采样数;MTR 中显式指定每跳探测次数 | traceroute: `3`;MTR report: 未指定时 `10`;MTR TUI/raw: 未指定时无限 | 链路抖动大时可升到 `5-10` | | `--max-attempts` | 每跳最大发包上限 | 默认按 `--queries` 自动推导 | 丢包严重、回包慢时增大 | | `--parallel-requests` | 跨 TTL 的总并发 in-flight 探测数 | `18` | 多路径/负载均衡链路用 `1`;稳定链路一般 `6-18` | | `--send-time` | 同一 TTL 组内相邻探测包间隔 | `50ms` | 设备限速时升到 `100-200ms`;MTR 下忽略 | | `--ttl-time` | 常规 traceroute 的 TTL 组间隔;MTR 的每跳探测间隔 | traceroute: `300ms`;MTR: 未指定时 `1000ms` | 想加速就调低;远程/限速链路调高 | | `--timeout` | 单个探测包超时 | `1000ms` | 跨洲或高丢包链路升到 `2000-3000ms` | | `--psize` | 探测包大小 | 按协议/IP 族自动取最小合法值 | 含 IP + 探测协议头;负值表示每个 probe 在 `abs(value)` 内随机;超过出接口/路径 MTU 时,链路上可能看到分片 | | `-Q`, `--tos` | IP TOS / traffic class | `0` | 设置 IP 头里的 TOS / traffic class;在 Windows 上仅 `ICMPv6` 且值非零时额外依赖 `WinDivert` | 这些探测参数目前仍是 CLI 级配置,`nt_config.yaml` 还不能直接保存它们。若要复用一组调优参数,建议写成 shell alias 或小脚本。 ```bash # 适合多路径 / ECMP 的保守配置 nexttrace --parallel-requests 1 --send-time 100 --ttl-time 500 --timeout 2000 example.com # 适合稳定单路径链路的快速配置 nexttrace --parallel-requests 18 --send-time 20 --ttl-time 150 example.com # 适合高丢包长途链路的配置 nexttrace --queries 5 --max-attempts 10 --timeout 2500 example.com ``` #### `NextTrace` 支持 MTR(My Traceroute)连续探测模式 ```bash # MTR 模式:使用 ICMP(默认)连续探测,实时刷新表格 nexttrace -t 1.1.1.1 # 等价写法: nexttrace --mtr 1.1.1.1 # MTR 模式使用 TCP SYN 探测 nexttrace -t --tcp --port 443 www.bing.com # MTR 模式使用 UDP 探测 nexttrace -t --udp 1.0.0.1 # 设置每个跳点的探测间隔(MTR 模式下默认 1000ms;-z/--send-time 在 MTR 模式下无效) nexttrace -t -i 500 1.1.1.1 # 限制每个跳点的最大探测次数(TUI 默认无限,报告模式默认 10) nexttrace -t -q 20 1.1.1.1 # 报告模式:对每个跳点探测 N 次后一次性输出统计摘要(类似 mtr -r) nexttrace -r 1.1.1.1 # = --mtr --report,默认每跳点 10 次 nexttrace -r -q 5 1.1.1.1 # 每跳点 5 次 # 宽报告模式:主机列不截断(类似 mtr -rw) nexttrace -w 1.1.1.1 # = --mtr --report --wide # 在 MTR 输出中同时显示 PTR 和 IP(PTR 在前,IP 括号) nexttrace --mtr --show-ips 1.1.1.1 nexttrace -r --show-ips 1.1.1.1 nexttrace -w --show-ips 1.1.1.1 # MTR 原始流式模式(面向程序解析,逐事件输出) nexttrace --mtr --raw 1.1.1.1 nexttrace -r --raw 1.1.1.1 # 与其他选项组合使用 nexttrace -t --tcp --max-hops 20 --first 3 --no-rdns 8.8.8.8 ``` 在终端(TTY)中运行时,MTR 模式使用**交互式全屏 TUI**: - **`q` / `Q`** — 退出(恢复终端,不留下输出) - **`p`** — 暂停探测 - **空格** — 恢复探测 - **`r`** — 重置统计(计数器清零,显示模式保持不变) - **`y`** — 循环切换主机显示模式:ASN → City → Owner → Full - **`n`** — 切换主机名显示方式: - 默认:PTR(无 PTR 时回退 IP)↔ 仅 IP - 启用 `--show-ips`:PTR (IP) ↔ 仅 IP - **`e`** — 切换 MPLS 标签显示开/关 - TUI 标题栏显示**源 → 目标**路由信息,指定 `--source`/`--dev` 时会展示对应信息。 - 使用 LeoMoeAPI 时,标题栏会显示首选 API IP 地址。 - 使用**备用屏幕缓冲区**,退出后恢复之前的终端历史记录。 - 当 stdin 非 TTY(如管道输入)时,降级为简单表格刷新模式。 **报告模式**(`-r`/`--report`)在所有探测完成后一次性输出统计,适合脚本使用: ```text Start: 2025-07-14T09:12:00+08:00 HOST: myhost Loss% Snt Last Avg Best Wrst StDev 1. one.one.one.one 0.0% 10 1.23 1.45 0.98 2.10 0.32 2. 10.0.0.2 100.0% 10 0.00 0.00 0.00 0.00 0.00 ``` 显示为 `(waiting for reply)` 的行仍然保留同样的表格列布局,只是该行的指标单元格会留空。 非 wide 报告模式会刻意保持 Host 列精简: - 只显示 `PTR/IP` - 不发起 Geo API 查询 - 不显示 ASN / 运营商 / 地理位置字段 - 不显示 MPLS 标签 wide 报告模式(`-w` / `--wide`)继续保留当前完整信息行为,包括 Geo 衍生字段和 MPLS 输出。 当 `--raw` 与 MTR(`--mtr`、`-r`、`-w`)一起使用时,会进入 **MTR 原始流式模式**。 如果当前数据源是 `LeoMoeAPI`,会先输出一行无色的 API 信息头: ```text [NextTrace API] preferred API IP - [2403:18c0:1001:462:dd:38ff:fe48:e0c5] - 21.33ms - DMIT.NRT ``` 之后再逐行输出 `|` 分隔的事件流: ``` 4|84.17.33.106|po66-3518.cr01.nrt04.jp.misaka.io|0.27|60068|日本|东京都|东京||cdn77.com|35.6804|139.7690 ``` 字段顺序: `ttl|ip|ptr|rtt|asn|一级行政区|二级行政区|三级行政区|四级行政区|owner|纬度|经度` 超时行保持固定 12 列: `ttl|*||||||||||` 在 MTR 模式(`--mtr`、`-r`、`-w`,包括 `--raw`)下,`-i/--ttl-time` 设置的是**每个跳点的探测间隔**:同一跳点两次连续探测之间的等待时间(未显式指定时默认 1000ms)。`-z/--send-time` 在 MTR 模式下被忽略。 > 注意:`--show-ips` 仅在 MTR 模式(`--mtr`、`-r`、`-w`)生效,其他模式会忽略。 > > 注意:`--mtr` 不可与 `--table`、`--classic`、`--json`、`--output`、`--output-default`、`--route-path`、`--from`、`--fast-trace`、`--file`、`--deploy` 同时使用。 #### `NextTrace`支持用户自主选择 IP 数据库(目前支持:`LeoMoeAPI`, `IP.SB`, `IPInfo`, `IPInsight`, `IPAPI.com`, `IPInfoLocal`, `CHUNZHEN`) ```bash # 可以自行指定IP数据库[此处为IP-API.com],不指定则默认为LeoMoeAPI nexttrace --data-provider ip-api.com ## 特别的: 其中 ipinfo 和 IPInsight API 对于免费版查询有频率限制,可从这些服务商自行购买服务以解除限制,如有需要可以 clone 本项目添加其提供的 token 自行编译 ## TOKEN填写路径:ipgeo/tokens.go ## 特别的: 对于离线库 IPInfoLocal,请自行下载并命名为 ipinfoLocal.mmdb ## (可以从这里下载:https://ipinfo.io/signup?ref=free-database-downloads), ## 默认搜索用户当前路径、程序所在路径、和 FHS 路径(Unix-like) ## 如果需要自定义路径,请设置环境变量 export NEXTTRACE_IPINFOLOCALPATH=/xxx/yyy.mmdb ## 另外:由于IP.SB被滥用比较严重,会经常出现无法查询的问题,请知悉。 ## IP-API.com限制调用较为严格,如有查询不到的情况,请几分钟后再试。 # 纯真IP数据库默认使用 http://127.0.0.1:2060 作为查询接口,如需自定义请使用环境变量 export NEXTTRACE_CHUNZHENURL=http://127.0.0.1:2060 ## 可使用 https://github.com/freshcn/qqwry 自行搭建纯真IP数据库服务 # 也可以通过设置环境变量来指定默认IP数据库 export NEXTTRACE_DATAPROVIDER=ipinfo ``` #### `NextTrace`支持使用混合参数和简略参数 ```bash Example: nexttrace --data-provider ip-api.com --max-hops 20 --tcp --port 443 --queries 5 --no-rdns 1.1.1.1 nexttrace -tcp --queries 2 --parallel-requests 1 --table --route-path 2001:4860:4860::8888 Equivalent to: nexttrace -d ip-api.com -m 20 -T -p 443 -q 5 -n 1.1.1.1 nexttrace -T -q 2 --parallel-requests 1 --table -P 2001:4860:4860::8888 ``` ### Globalping [Globalping](https://globalping.io/) 提供了对成千上万由社区托管的探针的访问能力,可用于运行网络测试和测量。 通过 `--from` 参数可以选择使用指定位置的探针来执行 traceroute。位置字段支持洲、国家、地区、城市、ASN、ISP 或云厂商区域等多种类型。 ```bash nexttrace google.com --from Germany nexttrace google.com --from comcast+california ``` 匿名用户默认每小时限额为 250 次测试。将 `GLOBALPING_TOKEN` 环境变量设置为你的令牌后,可将限额提升至每小时 500 次。 ```bash export GLOBALPING_TOKEN=your_token_here ``` ### 环境变量总览 NextTrace 当前会读取下列环境变量。对于布尔开关,只识别 `1` 和 `0`,其他值会回退到内置默认值。为了避免混淆,修改后建议重启 NextTrace。 #### 核心运行 / 网络 | 变量名 | 默认值 | 说明 | | --- | --- | --- | | `NEXTTRACE_DEVMODE` | `0` | 开发调试模式:致命错误改为 panic,并打印堆栈。 | | `NEXTTRACE_DEBUG` | 未设置 | 在 `GetEnv*` 解析环境变量时打印检测到的值。 | | `NEXTTRACE_DISABLEMPLS` | `0` | 全局禁用 MPLS 显示,效果类似 `--disable-mpls`。 | | `NEXTTRACE_ENABLEHIDDENDSTIP` | `0` | 隐匿目的 IP,并省略其主机名显示。 | | `NEXTTRACE_RANDOMPORT` | `0` | TCP/UDP 每个探测包使用不同的随机源端口。 | | `NEXTTRACE_MAXATTEMPTS` | 自动计算 | 当未显式传入 `--max-attempts` 时,提供默认最大重试次数。 | | `NEXTTRACE_ICMPMODE` | `0` | 当未显式传入 `--icmp-mode` 时提供默认值(`0=自动`、`1=Socket`、`2=WinDivert`)。 | | `NEXTTRACE_UNINTERRUPTED` | `0` | 与 `--raw` 一起使用时,会在一次探测结束后继续循环执行,而不是退出。 | | `NEXTTRACE_PROXY` | 未设置 | 为 PoW、Geo API、tracemap 等出站 HTTP / WebSocket 请求设置代理 URL。 | | `NEXTTRACE_DATAPROVIDER` | 未设置 | 覆盖默认 IP 地理信息源,例如 `ipinfo`。 | #### 服务 / Web / 后端 | 变量名 | 默认值 | 说明 | | --- | --- | --- | | `NEXTTRACE_HOSTPORT` | `api.nxtrace.org` | 覆盖 LeoMoeAPI、tracemap、FastIP 等使用的后端地址,支持 `host` 或 `host:port`。 | | `NEXTTRACE_TOKEN` | 未设置 | 预置 LeoMoeAPI Bearer Token;设置后将跳过 PoW 取 token 流程。 | | `NEXTTRACE_POWPROVIDER` | `api.nxtrace.org` | 指定 PoW 服务提供方;当前内置的非默认别名为 `sakura`。 | | `NEXTTRACE_DEPLOY_ADDR` | 未设置 | `--deploy` 模式下,当未传 `--listen` 时使用的默认监听地址。 | | `NEXTTRACE_ALLOW_CROSS_ORIGIN` | `0` | 仅对 `--deploy` 生效:是否允许跨站浏览器访问 Web UI / API。默认关闭以保证安全。 | #### IP 数据库 / 第三方服务 | 变量名 | 默认值 | 说明 | | --- | --- | --- | | `NEXTTRACE_IPINFOLOCALPATH` | 自动搜索 | `IPInfoLocal` 离线库 `ipinfoLocal.mmdb` 的完整路径。 | | `NEXTTRACE_CHUNZHENURL` | `http://127.0.0.1:2060` | 纯真 IP 查询服务的基础 URL。 | | `NEXTTRACE_IPINFO_TOKEN` | 未设置 | `IPInfo` 数据源使用的 token。 | | `NEXTTRACE_IPINSIGHT_TOKEN` | 未设置 | `IPInsight` 数据源使用的 token。 | | `NEXTTRACE_IPAPI_BASE` | 各 provider 内置地址 | 覆盖当前实现里兼容 HTTP 接口的数据源基础地址(`IPInfo`、`IPInsight`、`ip-api.com`)。 | | `IPDBONE_BASE_URL` | `https://api.ipdb.one` | 覆盖 IPDB.One API 基础地址。 | | `IPDBONE_API_ID` | 未设置 | IPDB.One API ID。 | | `IPDBONE_API_KEY` | 未设置 | IPDB.One API Key。 | | `GLOBALPING_TOKEN` | 未设置 | Globalping 鉴权 token;设置后可提升匿名用户的每小时测试额度。 | #### 配置文件搜索 | 变量名 | 默认值 | 说明 | | --- | --- | --- | | `XDG_CONFIG_HOME` | 取决于系统 / Shell | 如果设置了该变量,NextTrace 也会从 `$XDG_CONFIG_HOME/nexttrace` 搜索 `nt_config.yaml`。 | ### 全部用法详见 Usage 菜单 ```shell Usage: nexttrace [-h|--help] [--init] [-4|--ipv4] [-6|--ipv6] [-T|--tcp] [-U|--udp] [-F|--fast-trace] [-p|--port ] [--icmp-mode ] [-q|--queries ] [--max-attempts ] [--parallel-requests ] [-m|--max-hops ] [-d|--data-provider (IP.SB|ip.sb|IPInfo|ipinfo|IPInsight|ipinsight|IPAPI.com|ip-api.com|IPInfoLocal|ipinfolocal|chunzhen|LeoMoeAPI|leomoeapi|ipdb.one|disable-geoip)] [--pow-provider (api.nxtrace.org|sakura)] [-n|--no-rdns] [-a|--always-rdns] [-P|--route-path] [--dn42] [-o|--output ""] [-O|--output-default] [--table] [--raw] [-j|--json] [-c|--classic] [-f|--first ] [-M|--map] [-e|--disable-mpls] [-V|--version] [-s|--source ""] [--source-port ] [-D|--dev ""] [--listen ""] [--deploy] [-z|--send-time ] [-i|--ttl-time ] [--timeout ] [--psize ] [--dot-server (dnssb|aliyun|dnspod|google|cloudflare)] [-g|--language (en|cn)] [-C|--no-color] [--from ""] [-t|--mtr] [-r|--report] [-w|--wide] [--show-ips] [-y|--ipinfo ] [--file ""] [TARGET ""] Arguments: -h --help Print help information --init Windows ONLY: Extract WinDivert runtime to current directory -4 --ipv4 Use IPv4 only -6 --ipv6 Use IPv6 only -T --tcp Use TCP SYN for tracerouting (default dest-port is 80) -U --udp Use UDP SYN for tracerouting (default dest-port is 33494) -F --fast-trace One-Key Fast Trace to China ISPs -p --port Set the destination port to use. With default of 80 for "tcp", 33494 for "udp" --icmp-mode Windows ONLY: Choose the method to listen for ICMP packets (1=Socket, 2=WinDivert; 0=Auto) -q --queries Latency samples per hop. Increase to 5-10 on unstable paths for a steadier view. Default: 3 --max-attempts Advanced: hard cap on probe packets per hop. Leave unset for auto sizing; raise on lossy links if --queries is not enough --parallel-requests Advanced: total concurrent in-flight probes across TTLs. Use 1 on multipath/load-balanced paths; 6-18 is a good starting range on stable links. Default: 18 -m --max-hops Set the max number of hops (max TTL to be reached). Default: 30 -d --data-provider Choose IP Geograph Data Provider [IP.SB, IPInfo, IPInsight, IP-API.com, IPInfoLocal, CHUNZHEN, disable-geoip]. Default: LeoMoeAPI --pow-provider Choose PoW Provider [api.nxtrace.org, sakura] For China mainland users, please use sakura. Default: api.nxtrace.org -n --no-rdns Do not resolve IP addresses to their domain names -a --always-rdns Always resolve IP addresses to their domain names -P --route-path Print traceroute hop path by ASN and location --dn42 DN42 Mode -o --output Write trace result to FILE (RealtimePrinter only) -O --output-default Write trace result to the default log file (/tmp/trace.log) --table Output trace results as a final summary table (traceroute report mode) --raw Machine-friendly output. With MTR (--mtr/-r/-w), enables streaming raw event mode -j --json Output trace results as JSON -c --classic Classic Output trace results like BestTrace -f --first Start from the first_ttl hop (instead of 1). Default: 1 -M --map Disable Print Trace Map -e --disable-mpls Disable MPLS -V --version Print version info and exit -s --source Use source address src_addr for outgoing packets --source-port Use source port src_port for outgoing packets -D --dev Use the following Network Devices as the source address in outgoing packets --listen Set listen address for web console (e.g. 127.0.0.1:30080) --deploy Start the Gin powered web console -z --send-time Advanced: per-packet gap [ms] inside the same TTL group. Lower is faster; raise to 100-200ms on rate-limited links. Ignored in MTR mode. Default: 50 -i --ttl-time Advanced: TTL-group interval [ms] in normal traceroute. In MTR mode (--mtr/-r/-w, including --raw), this becomes per-hop probe interval. 500-1000ms is a good MTR starting range --timeout Per-probe timeout [ms]. Raise to 2000-3000 on slow intercontinental or high-loss paths. Default: 1000 --psize Probe packet size in bytes, inclusive IP and active probe headers. Default is the minimum legal size for the chosen protocol and IP family; raise for MTU or large-packet testing. Negative values randomize each probe up to abs(value). -Q --tos Set the IP type-of-service / traffic class value [0-255]. Default: 0 --dot-server Use DoT Server for DNS Parse [dnssb, aliyun, dnspod, google, cloudflare] -g --language Choose the language for displaying [en, cn]. Default: cn -C --no-color Disable Colorful Output --from Run traceroute via Globalping (https://globalping.io/network) from a specified location. The location field accepts continents, countries, regions, cities, ASNs, ISPs, or cloud regions. -t --mtr Enable MTR (My Traceroute) continuous probing mode -r --report MTR report mode (non-interactive, implies --mtr); can trigger MTR without --mtr -w --wide MTR wide report mode (implies --mtr --report); alone equals --mtr --report --wide --show-ips MTR only: display both PTR hostnames and numeric IPs (PTR first, IP in parentheses) -y --ipinfo Set initial MTR TUI host info mode (0-4). TUI only; ignored in --report/--raw. 0:IP/PTR 1:ASN 2:City 3:Owner 4:Full. Default: 0 --file Read IP Address or domain name from file TARGET Trace target: IPv4 address (e.g. 8.8.8.8), IPv6 address (e.g. 2001:db8::1), domain name (e.g. example.com), or URL (e.g. https://example.com) ``` ## 项目截图 ![image](https://user-images.githubusercontent.com/59512455/218505939-287727ce-7207-43c4-8e31-fcda7df0b872.png) ![image](https://user-images.githubusercontent.com/59512455/218504874-06b9fa4b-48e0-420a-a195-08a1200d65a7.png) ## 第三方 IP 数据库 API 开发接口 NextTrace 所有的的 IP 地理位置 `API DEMO` 可以参考[这里](https://github.com/nxtrace/NTrace-core/blob/main/ipgeo/) 你可以在这里添加你自己的 API 接口,为了 NextTrace 能够正确显示你接口中的内容,请参考 `leo.go` 中所需要的信息 ✨NextTrace `LeoMoeAPI` 的后端 Demo [GitHub - sjlleo/nexttrace-backend: NextTrace BackEnd](https://github.com/sjlleo/nexttrace-backend) NextTrace `LeoMoeAPI`现已使用Proof of Work(POW)机制来防止滥用,其中NextTrace作为客户端引入了powclient库,POW CLIENT/SERVER均已开源,欢迎大家使用。(POW模块相关问题请发到对应的仓库) - [GitHub - tsosunchia/powclient: Proof of Work CLIENT for NextTrace](https://github.com/tsosunchia/powclient) - [GitHub - tsosunchia/powserver: Proof of Work SERVER for NextTrace](https://github.com/tsosunchia/powserver) 对于中国大陆用户,可以使用 [Nya Labs](https://natfrp.com) 提供的位于大陆的POW服务器优化访问速度 ```shell #使用方法任选其一 #1. 在环境变量中设置 export NEXTTRACE_POWPROVIDER=sakura #2. 在命令行中设置 nexttrace --pow-provider sakura ``` ## OpenTrace `OpenTrace`是 @Archeb 开发的`NextTrace`的跨平台`GUI`版本,带来您熟悉但更强大的用户体验。 该软件仍然处于早期开发阶段,可能存在许多缺陷和错误,需要您宝贵的使用反馈。 [https://github.com/Archeb/opentrace](https://github.com/Archeb/opentrace) ## NEXTTRACE WEB API `NextTraceWebApi`是一个`MTR`风格的`NextTrace`网页版服务端实现,提供了包括`Docker`在内多种部署方式。 在 WebSocket 持续探测模式中,MTR 现改为逐事件推送 `type: "mtr_raw"`(不再使用周期性 `mtr` 快照消息)。 [https://github.com/nxtrace/nexttracewebapi](https://github.com/nxtrace/nexttracewebapi) ## NextTraceroute `NextTraceroute`,一款默认使用`NextTrace API`的免`root`安卓版路由跟踪应用,由 @surfaceocean 开发。 感谢所有测试用户的热情支持,本应用已经通过封闭测试,正式进入 Google Play 商店。 [https://github.com/nxtrace/NextTraceroute](https://github.com/nxtrace/NextTraceroute) Get it on Google Play ## Cloudflare Support 本项目受 [Alexandria 计划](http://www.cloudflare.com/oss-credits)赞助。 Cloudflare Logo ## AIWEN TECH Support 本项目受 [埃文科技](https://www.ipplus360.com) 赞助。 很高兴使用`埃文科技城市级IP库`增强本项目 GEOIP 查询的准确性与完整性,并免费提供给公众。 埃文科技 IP 定位数据 ## JetBrain Support 本项目受 [JetBrain Open-Source Project License](https://jb.gg/OpenSourceSupport) 支持。 很高兴使用`Goland`作为我们的开发工具。 GoLand logo ## Credits [Gubo](https://www.gubo.org) 靠谱主机推荐 [IPInfo](https://ipinfo.io) 无偿提供了本项目大部分数据支持 [BGP.TOOLS](https://bgp.tools) 无偿提供了本项目的一些数据支持 [PeeringDB](https://www.peeringdb.com) 无偿提供了本项目的一些数据支持 [Globalping](https://globalping.io) 一个开源且免费的项目,提供全球范围内运行 traceroute 等网络测试的访问服务 [sjlleo](https://github.com/sjlleo) 项目永远的领导者、创始人及核心贡献者 [tsosunchia](https://github.com/tsosunchia) 项目现任管理、基础设施运维及核心贡献者 [Yunlq](https://github.com/Yunlq) 活跃的社区贡献者 [Vincent Young](https://github.com/missuo) [zhshch2002](https://github.com/zhshch2002) [Sam Sam](https://github.com/samleong123) [waiting4new](https://github.com/waiting4new) [FFEE_CO](https://github.com/fkx4-p) [bobo liu](https://github.com/fakeboboliu) [YekongTAT](https://github.com/isyekong) ## Others - 其他第三方 API 尽管集成在本项目内,但是具体的 TOS 以及 AUP,请详见第三方 API 官网。如遇到 IP 数据错误,也请直接联系他们纠错。 - 如何获取最新commit的新鲜出炉的二进制可执行文件? > 请前往GitHub Actions中最新一次 [Build & Release](https://github.com/nxtrace/NTrace-dev/actions/workflows/build.yml) workflow. - 常见疑问 - Windows 平台下,ICMP 模式须手动放行ICMP/ICMPv6防火墙 - macOS 平台下,仅 ICMP 模式不需要提权运行 - 在一些情况下,同时运行多个 NextTrace 实例可能会导致互相干扰结果(目前仅在 TCP 模式下有观察到) ## IP 数据以及精准度说明 对于IP相关信息的纠错反馈,我们目前开放了两个渠道: > - 本项目的GITHUB ISSUES区中的[IP 错误报告汇总帖](https://github.com/orgs/nxtrace/discussions/222) > - 本项目的纠错专用邮箱: `correct#nxtrace.org` (请注意此邮箱仅供IP相关信息纠错专用,其他反馈请发送ISSUE) NextTrace 有多个数据源可以选择,目前默认使用的 LeoMoeAPI 为我们项目维护的数据源。 该项目由 OwO Network 的 [Missuo](https://github.com/missuo) && [Leo](https://github.com/sjlleo) 发起,由 [Zhshch](https://github.com/zhshch2002/) 完成最早期架构的编写和指导,后由 Leo 完成了大部分开发工作,现主要交由 [tsosunchia](https://github.com/tsosunchia) 完成后续的二开和维护工作。 LeoMoeAPI 是 [Leo](https://github.com/sjlleo) 的作品,归属于 Leo Network,由 [Leo](https://github.com/sjlleo) 完成整套后端 API 编写,该接口未经允许不可用于任何第三方用途。 LeoMoeAPI 早期数据主要来自 IPInsight、IPInfo,随着项目发展,越来越多的志愿者参与进了这个项目。目前 LeoMoeAPI 有近一半的数据是社区提供的,而另外一半主要来自于包含 IPInfo、IPData、BigDataCloud、IPGeoLocation 在内的多个第三方数据。 LeoMoeAPI 的骨干网数据有近 70% 是社区自发反馈又或者是项目组成员校准的,这给本项目的路由跟踪基础功能带来了一定的保证,但是全球骨干网的体量庞大,我们并无能力如 IPIP 等商业公司拥有海量监测节点,这使得 LeoMoeAPI 的数据精准度无法和形如 BestTrace(IPIP)相提并论。 LeoMoeAPI 已经尽力校准了比较常见的骨干网路由,这部分在测试的时候经常会命中,但是如果遇到封闭型 ISP 的路由,大概率可以遇到错误,此类数据不仅是我们,哪怕 IPInsight、IPInfo 也无法正确定位,目前只有 IPIP 能够标记正确,如对此类数据的精确性有着非常高的要求,请务必使用 BestTrace 作为首选。 我们不保证我们的数据一定会及时更新,也不保证数据的精确性,我们希望您在发现数据错误的时候可以前往 issue 页面提交错误报告,谢谢。 当您使用 LeoMoeAPI 即视为您已经完全了解 NextTrace LeoMoeAPI 的数据精确性,并且同意如果您引用 LeoMoeAPI 其中的数据从而引发的一切问题,均由您自己承担。 ## DN42 模式使用说明 使用这个模式需要您配置 2 个文件,分别是 geofeed.csv 以及 ptr.csv 当您初次运行 DN42 模式,NT 会为您生成 nt_config.yaml 文件,您可以自定义 2 个文件的存放位置,默认应该存放在 NT 的运行目录下 ### GeoFeed 对于 geofeed.csv 来说,格式如下: ``` IP_CDIR,LtdCode,ISO3166-2,CityName,ASN,IPWhois ``` 比如,您可以这么写: ``` 58.215.96.0/20,CN,CN-JS,Wuxi,23650,CHINANET-JS ``` 如果您有一个大段作为骨干网使用,您也可以不写地理位置信息,如下: ``` 202.97.0.0/16,,,4134,CHINANET-BACKBONE ``` ### PTR 对于 ptr.csv 来说,格式如下: ``` IATA_CODE,LtdCode,RegionName,CityName ``` 比如对于美国洛杉矶,您可以这么写 ``` LAX,US,California,Los Anegles ``` 需要注意的是,NextTrace 支持自动匹配 CSV 中的城市名,如果您的 PTR 记录中有 `losangeles`,您可以只添加上面一条记录就可以正常识别并读取。 ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=nxtrace/NTrace-core&type=Date)](https://star-history.com/#nxtrace/NTrace-core&Date) ================================================ FILE: _config.yml ================================================ theme: jekyll-theme-cayman ================================================ FILE: assets/windivert/divert.go ================================================ package windivert import ( "bytes" "crypto/sha256" _ "embed" "errors" "io" "os" "path/filepath" "runtime" ) //go:embed x64/WinDivert.dll var winDivertDLL64 []byte //go:embed x64/WinDivert64.sys var winDivertSYS64 []byte //go:embed x86/WinDivert.dll var winDivertDLL32 []byte //go:embed x86/WinDivert32.sys var winDivertSYS32 []byte // PrepareWinDivertRuntime 将内嵌的 WinDivert DLL/驱动解压到可执行文件同目录 func PrepareWinDivertRuntime() error { exe, err := os.Executable() if err != nil { return err } exeDir := filepath.Dir(exe) var dllBytes, sysBytes []byte var sysName string switch runtime.GOARCH { case "amd64", "arm64": dllBytes, sysBytes, sysName = winDivertDLL64, winDivertSYS64, "WinDivert64.sys" case "386", "arm": dllBytes, sysBytes, sysName = winDivertDLL32, winDivertSYS32, "WinDivert32.sys" default: return errors.New("unsupported GOARCH for WinDivert: " + runtime.GOARCH) } // DLL if err := writeIfChecksumDiff(filepath.Join(exeDir, "WinDivert.dll"), dllBytes); err != nil { return err } // SYS if err := writeIfChecksumDiff(filepath.Join(exeDir, sysName), sysBytes); err != nil { return err } return nil } // writeIfChecksumDiff 通过比较 SHA-256 来判断是否覆写目标文件 func writeIfChecksumDiff(dst string, data []byte) error { file, err := os.Open(dst) if err != nil { return os.WriteFile(dst, data, 0o644) // 读失败,则尝试覆盖 } hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { _ = file.Close() // 先关再写,避免 Windows 共享冲突 return os.WriteFile(dst, data, 0o644) // 读失败,则尝试覆盖 } sumFile := hash.Sum(nil) _ = file.Close() // 先关再写,避免 Windows 共享冲突 sumMem := sha256.Sum256(data) if bytes.Equal(sumFile, sumMem[:]) { return nil // 一致,跳过 } return os.WriteFile(dst, data, 0o644) // 不一致,则尝试覆盖 } ================================================ FILE: cmd/cmd.go ================================================ package cmd import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net" "os" "os/signal" "runtime" "strconv" "strings" "syscall" "time" "github.com/akamensky/argparse" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/assets/windivert" "github.com/nxtrace/NTrace-core/config" fastTrace "github.com/nxtrace/NTrace-core/fast_trace" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/printer" "github.com/nxtrace/NTrace-core/reporter" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/tracelog" "github.com/nxtrace/NTrace-core/tracemap" "github.com/nxtrace/NTrace-core/util" "github.com/nxtrace/NTrace-core/wshandle" ) func ptrBool(v bool) *bool { return &v } func ptrStr(v string) *string { return &v } func ptrInt(v int) *int { return &v } type listenInfo struct { Binding string Access string } const ( defaultPacketIntervalMs = 50 defaultTracerouteTTLIntervalMs = 300 ) var ( domainLookupFn = util.DomainLookUpWithContext ) func normalizeListenAddr(addr string) string { trimmed := strings.TrimSpace(addr) if trimmed == "" { return ":1080" } if isDigitsOnly(trimmed) { return ":" + trimmed } return trimmed } func splitListenAddr(effective string) (host, port string, ok bool) { host, port, err := net.SplitHostPort(effective) if err == nil { if port == "" { port = "1080" } return host, port, true } if strings.HasPrefix(effective, ":") { return "", strings.TrimPrefix(effective, ":"), true } return "", "", false } func formatHTTPListenURL(host, port string) string { if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { host = "[" + host + "]" } return fmt.Sprintf("http://%s:%s", host, port) } func resolveListenAccessHost(host string) string { if host == "" || host == "0.0.0.0" || host == "::" { return guessLocalIPv4() } return host } func buildListenInfo(addr string) listenInfo { effective := normalizeListenAddr(addr) host, port, ok := splitListenAddr(effective) if !ok { return listenInfo{Binding: effective} } rawHost := host if rawHost == "" { rawHost = "0.0.0.0" } info := listenInfo{ Binding: formatHTTPListenURL(rawHost, port), } accessHost := resolveListenAccessHost(host) if accessHost != "" { info.Access = formatHTTPListenURL(accessHost, port) } return info } func isDigitsOnly(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func normalizeNegativePacketSizeArgs(args []string) []string { if len(args) < 3 { return args } normalized := make([]string, 0, len(args)) for i := 0; i < len(args); i++ { cur := args[i] if cur == "--psize" && i+1 < len(args) && isNegativeInteger(args[i+1]) { normalized = append(normalized, "--psize="+args[i+1]) i++ continue } normalized = append(normalized, cur) } return normalized } func isNegativeInteger(s string) bool { if !strings.HasPrefix(s, "-") || len(s) < 2 { return false } v, err := strconv.Atoi(s) return err == nil && v < 0 } func guessLocalIPv4() string { addrs, err := net.InterfaceAddrs() if err == nil { for _, address := range addrs { if ipNet, ok := address.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { if ip4 := ipNet.IP.To4(); ip4 != nil { return ip4.String() } } } } return "127.0.0.1" } func defaultLocalListenAddr() string { if hasIPv4Loopback() { return "127.0.0.1:1080" } if hasIPv6Loopback() { return "[::1]:1080" } return "127.0.0.1:1080" } func hasIPv4Loopback() bool { addrs, err := net.InterfaceAddrs() if err != nil { return false } for _, address := range addrs { if ipNet, ok := address.(*net.IPNet); ok && ipNet.IP.IsLoopback() { if ip4 := ipNet.IP.To4(); ip4 != nil { return true } } } return false } func hasIPv6Loopback() bool { addrs, err := net.InterfaceAddrs() if err != nil { return false } for _, address := range addrs { if ipNet, ok := address.(*net.IPNet); ok && ipNet.IP.IsLoopback() { if ip := ipNet.IP; ip.To4() == nil && len(ip) == net.IPv6len { return true } } } return false } // sanitizeUsagePositionalArgs replaces the auto-generated positional argument // name (e.g. "_positionalArg_nexttrace_33") with a friendlier label in the // usage string produced by argparse. func sanitizeUsagePositionalArgs(usage string) string { // argparse generates names like "_positionalArg_nexttrace_" // We scan for the prefix and replace the whole token with "TARGET". const prefix = "_positionalArg_" for { idx := strings.Index(usage, prefix) if idx < 0 { break } // Find the end of the token (next space, newline, or end of string). end := idx + len(prefix) for end < len(usage) && usage[end] != ' ' && usage[end] != '\n' && usage[end] != '\r' && usage[end] != '\t' && usage[end] != ']' { end++ } usage = usage[:idx] + "TARGET" + usage[end:] } // argparse renders the positional as "--TARGET" in the description list; // strip the leading "--" so it reads as a plain positional placeholder. usage = strings.ReplaceAll(usage, "--TARGET", "TARGET") // Fix the description column alignment for the TARGET entry. // argparse gives positional args minimal spacing (" TARGET desc"), but named // flags are padded to a consistent description column (" --name desc"). // Detect that column from any named-flag line and re-pad the TARGET line to match. usage = fixPositionalAlignment(usage) return usage } // fixPositionalAlignment detects the description column used by named flags in the // argparse help output and re-pads the TARGET positional entry to match it. func fixPositionalAlignment(usage string) string { // Scan flag lines to find where descriptions start. // A flag line looks like " -X --nameDescription" or " --nameDescription". // We find the column of the first non-space character after the flag name (past position 8). descCol := 0 for _, line := range strings.Split(usage, "\n") { trimmed := strings.TrimLeft(line, " ") if !strings.HasPrefix(trimmed, "-") || strings.Contains(line, "TARGET") { continue } inGap := false for i := 8; i < len(line); i++ { if line[i] == ' ' { inGap = true } else if inGap { descCol = i break } } if descCol > 0 { break } } if descCol == 0 { return usage } // Find the TARGET description entry: "\n TARGET " const namePrefix = " TARGET" marker := "\n" + namePrefix idx := strings.Index(usage, marker) if idx < 0 { return usage } // afterName points to the character right after " TARGET" on that line. afterName := idx + 1 + len(namePrefix) // Skip the existing (minimal) spacing. end := afterName for end < len(usage) && usage[end] == ' ' { end++ } needed := descCol - len(namePrefix) if needed <= 0 { return usage } return usage[:afterName] + strings.Repeat(" ", needed) + usage[end:] } type effectiveMTRModes struct { mtr bool report bool wide bool raw bool } type tracerouteOutputFlags struct { routePath *bool outputPath *string outputDefault *bool tablePrint *bool jsonPrint *bool classicPrint *bool } type webUIFlags struct { deployListen *string deploy *bool } type mtrCLIFlags struct { mtrMode *bool reportMode *bool wideMode *bool showIPs *bool ipInfoMode *int } func registerInitFlag(parser *argparse.Parser) *bool { if runtime.GOOS == "windows" { return parser.Flag("", "init", &argparse.Options{Help: "Extract WinDivert runtime to current directory"}) } return ptrBool(false) } func registerFastTraceFlag(parser *argparse.Parser) *bool { if !defaultMTR { return parser.Flag("F", "fast-trace", &argparse.Options{Help: "One-Key Fast Trace to China ISPs"}) } return ptrBool(false) } func registerMTUFlag(parser *argparse.Parser) *bool { if enableMTU { return parser.Flag("", "mtu", &argparse.Options{Help: "Run standalone UDP path-MTU discovery mode with streaming output and GeoIP/RDNS"}) } return ptrBool(false) } func registerICMPModeFlag(parser *argparse.Parser) *int { if runtime.GOOS == "windows" { return parser.Int("", "icmp-mode", &argparse.Options{Help: "Choose the method to listen for ICMP packets (1=Socket, 2=WinDivert; 0=Auto)"}) } return ptrInt(0) } func buildQueriesHelp() string { if defaultMTR { return "MTR only: max probes per hop. 0 = unlimited in TUI/raw; --report defaults to 10 when omitted. Start with 10-20 on unstable paths" } return "Latency samples per hop. Increase to 5-10 on unstable paths for a steadier view" } func buildMaxAttemptsHelp() string { return "Advanced: hard cap on probe packets per hop. Leave unset for auto sizing; raise on lossy links if --queries is not enough" } func buildParallelRequestsHelp() string { return "Advanced: total concurrent in-flight probes across TTLs. Use 1 on multipath/load-balanced paths; 6-18 is a good starting range on stable links" } func buildPacketIntervalHelp() string { help := "Advanced: per-packet gap [ms] inside the same TTL group. Lower is faster; raise to 100-200ms on rate-limited links" if enableMTR { help += ". Ignored in MTR mode" } return help } func buildTimeoutHelp() string { return "Per-probe timeout [ms]. Raise to 2000-3000 on slow intercontinental or high-loss paths" } func buildPayloadSizeHelp() string { return "Probe packet size in bytes, inclusive IP and active probe headers. Default is the minimum legal size for the chosen protocol and IP family; raise for MTU or large-packet testing. Negative values randomize each probe up to abs(value)" } func buildTOSHelp() string { return "Set the IP type-of-service / traffic class value [0-255]" } func registerTracerouteOutputFlags(parser *argparse.Parser) tracerouteOutputFlags { if !defaultMTR { return tracerouteOutputFlags{ routePath: parser.Flag("P", "route-path", &argparse.Options{Help: "Print traceroute hop path by ASN and location"}), outputPath: parser.String("o", "output", &argparse.Options{Help: "Write trace result to FILE (RealtimePrinter only)"}), outputDefault: parser.Flag("O", "output-default", &argparse.Options{Help: "Write trace result to the default log file (/tmp/trace.log)"}), tablePrint: parser.Flag("", "table", &argparse.Options{Help: "Output trace results as a final summary table (traceroute report mode)"}), jsonPrint: parser.Flag("j", "json", &argparse.Options{Help: "Output trace results as JSON"}), classicPrint: parser.Flag("c", "classic", &argparse.Options{Help: "Classic Output trace results like BestTrace"}), } } return tracerouteOutputFlags{ routePath: ptrBool(false), outputPath: ptrStr(""), outputDefault: ptrBool(false), tablePrint: ptrBool(false), jsonPrint: ptrBool(false), classicPrint: ptrBool(false), } } func registerWebUIFlags(parser *argparse.Parser) webUIFlags { return registerWebUIFlagsWithAvailability(parser, enableWebUI) } func registerWebUIFlagsWithAvailability(parser *argparse.Parser, enabled bool) webUIFlags { if enabled { return webUIFlags{ deployListen: parser.String("", "listen", &argparse.Options{Help: "Set listen address for web console (e.g. 127.0.0.1:30080)"}), deploy: parser.Flag("", "deploy", &argparse.Options{Help: "Start the Gin powered web console"}), } } return webUIFlags{ deployListen: parser.String("", "listen", &argparse.Options{Help: "Set listen address for web console (full build only; unavailable in this binary)"}), deploy: parser.Flag("", "deploy", &argparse.Options{Help: "Start the Gin powered web console (full build only; unavailable in this binary)"}), } } func registerPacketIntervalFlag(parser *argparse.Parser) *int { if !defaultMTR { return parser.Int("z", "send-time", &argparse.Options{Default: defaultPacketIntervalMs, Help: buildPacketIntervalHelp()}) } return ptrInt(defaultPacketIntervalMs) } func buildRawHelp() string { rawHelp := "Machine-friendly output" if enableMTR { mtrFlags := "--mtr/-r/-w" if defaultMTR { mtrFlags = "-r/-w" } rawHelp += ". With MTR (" + mtrFlags + "), enables streaming raw event mode" } return rawHelp } func buildTTLIntervalHelp() string { if !enableMTR { return "Advanced: TTL-group interval [ms] in normal traceroute. 100-300ms is usually safe; lower is faster but may trigger rate limits" } if defaultMTR { return "Advanced: per-hop probe interval [ms] in MTR mode. 500-1000ms is a good starting point; omitted defaults to 1000ms" } return "Advanced: TTL-group interval [ms] in normal traceroute. In MTR mode (--mtr/-r/-w, including --raw), this becomes per-hop probe interval. 500-1000ms is a good MTR starting range" } func registerTTLIntervalFlag(parser *argparse.Parser) *int { return registerTTLIntervalFlagWithMTRSupport(parser, enableMTR) } func registerTTLIntervalFlagWithMTRSupport(parser *argparse.Parser, mtrEnabled bool) *int { options := &argparse.Options{Help: buildTTLIntervalHelp()} if !mtrEnabled { options.Default = defaultTracerouteTTLIntervalMs } return parser.Int("i", "ttl-time", options) } func applyTTLIntervalDefault(ttlInterval *int, ttlTimeExplicit, effectiveMTR bool) { if ttlInterval == nil || ttlTimeExplicit || effectiveMTR { return } *ttlInterval = defaultTracerouteTTLIntervalMs } func registerDisableMaptraceFlag(parser *argparse.Parser) *bool { if !defaultMTR { return parser.Flag("M", "map", &argparse.Options{Help: "Disable Print Trace Map"}) } return ptrBool(true) } func registerGlobalpingFlag(parser *argparse.Parser) *string { return registerGlobalpingFlagWithAvailability(parser, enableGlobalping) } func registerGlobalpingFlagWithAvailability(parser *argparse.Parser, enabled bool) *string { if enabled { return parser.String("", "from", &argparse.Options{Help: "Run traceroute via Globalping (https://globalping.io/network) from a specified location. The location field accepts continents, countries, regions, cities, ASNs, ISPs, or cloud regions."}) } return parser.String("", "from", &argparse.Options{Help: "Run traceroute via Globalping (full build only; unavailable in this binary)"}) } func registerMTRFlags(parser *argparse.Parser) mtrCLIFlags { if enableMTR { mtrMode := ptrBool(true) if !defaultMTR { mtrMode = parser.Flag("t", "mtr", &argparse.Options{Help: "Enable MTR (My Traceroute) continuous probing mode"}) } return mtrCLIFlags{ mtrMode: mtrMode, reportMode: parser.Flag("r", "report", &argparse.Options{Help: "MTR report mode (non-interactive, implies --mtr); can trigger MTR without --mtr"}), wideMode: parser.Flag("w", "wide", &argparse.Options{Help: "MTR wide report mode (implies --mtr --report); alone equals --mtr --report --wide"}), showIPs: parser.Flag("", "show-ips", &argparse.Options{Help: "MTR only: display both PTR hostnames and numeric IPs (PTR first, IP in parentheses)"}), ipInfoMode: parser.Int("y", "ipinfo", &argparse.Options{Default: 0, Help: "Set initial MTR TUI host info mode (0-4). TUI only; ignored in --report/--raw. 0:IP/PTR 1:ASN 2:City 3:Owner 4:Full"}), } } return mtrCLIFlags{ mtrMode: ptrBool(false), reportMode: ptrBool(false), wideMode: ptrBool(false), showIPs: ptrBool(false), ipInfoMode: ptrInt(0), } } func registerFileFlag(parser *argparse.Parser) *string { if !defaultMTR { return parser.String("", "file", &argparse.Options{Help: "Read IP Address or domain name from file"}) } return ptrStr("") } func deriveEffectiveMTRModes(mtrMode, reportMode, wideMode, rawPrint bool) effectiveMTRModes { mtr := mtrMode || reportMode || wideMode return effectiveMTRModes{ mtr: mtr, report: reportMode || wideMode, wide: wideMode, raw: mtr && rawPrint, } } func detectExplicitProbeFlags(parser *argparse.Parser) (queriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit bool) { for _, a := range parser.GetArgs() { if !a.GetParsed() { continue } switch a.GetLname() { case "queries": queriesExplicit = true case "ttl-time": ttlTimeExplicit = true case "psize": packetSizeExplicit = true case "tos": tosExplicit = true } } return queriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit } func resolvePacketSizeArg(packetSize int, explicit bool, method trace.Method, dstIP net.IP) int { if explicit { return packetSize } return trace.DefaultPacketSize(method, dstIP) } func applyColorMode(noColor bool) { color.NoColor = noColor } func shouldForceNoColorForMTUNonTTY(mtuMode, jsonPrint, stdoutIsTTY bool) bool { return mtuMode && !jsonPrint && !stdoutIsTTY } func printStartupBanner(jsonPrint bool, effectiveMTR bool) { if !jsonPrint && !effectiveMTR { printer.Version() } } func maybePrintVersion(ver bool) bool { if !ver { return false } printer.CopyRight() os.Exit(0) return true } func maybeRunDeployMode(deploy bool, deployListen string) bool { if !deploy { return false } if !enableWebUI { if err := runDeploy(""); err != nil { if util.EnvDevMode { panic(err) } log.Fatal(err) } return true } capabilitiesCheck() listenAddr := strings.TrimSpace(deployListen) envAddr := strings.TrimSpace(util.EnvDeployAddr) userProvided := listenAddr != "" || envAddr != "" if listenAddr == "" { listenAddr = envAddr } if listenAddr == "" { listenAddr = defaultLocalListenAddr() } info := buildListenInfo(listenAddr) fmt.Printf("启动 NextTrace Web 控制台,监听地址: %s\n", info.Binding) if !userProvided { fmt.Println("远程访问请显式设置 --listen(例如 --listen 0.0.0.0:1080)。") } if info.Access != "" && info.Access != info.Binding { fmt.Printf("如需远程访问,请尝试: %s\n", info.Access) } fmt.Println("注意:Web 控制台的安全性有限,请在确保安全的前提下使用,如有必要请使用ACL等方式加强安全性") if err := runDeploy(listenAddr); err != nil { if util.EnvDevMode { panic(err) } log.Fatal(err) } return true } func handleStartupModes(noColor, jsonPrint bool, modes effectiveMTRModes, ver, deploy bool, deployListen string, init bool, osType int) bool { applyColorMode(noColor) printStartupBanner(jsonPrint, modes.mtr) if maybePrintVersion(ver) { return true } if maybeRunDeployMode(deploy, deployListen) { return true } return maybePrepareWinDivert(init, osType) } func resolveOSType() int { switch runtime.GOOS { case "darwin": return 1 case "windows": return 2 default: return 3 } } func maybePrepareWinDivert(init bool, osType int) bool { if !init || osType != 2 { return false } if err := windivert.PrepareWinDivertRuntime(); err != nil { if util.EnvDevMode { panic(err) } log.Fatal(err) } fmt.Println("WinDivert runtime is ready.") return true } func applyDefaultPort(port *int, udp bool) { if *port != 0 { return } if udp { *port = 33494 return } *port = 80 } func clampProbeSettings(tcp bool, numMeasurements, maxAttempts *int) { if tcp { return } if *numMeasurements > 255 { fmt.Println("Query 最大值为 255,已自动调整为 255") *numMeasurements = 255 } if *maxAttempts > 255 { fmt.Println("MaxAttempt 最大值为 255,已自动调整为 255") *maxAttempts = 255 } } func resolveTraceMethod(tcp, udp bool) trace.Method { switch { case tcp: return trace.TCPTrace case udp: return trace.UDPTrace default: return trace.ICMPTrace } } func maybeRunFastTraceMode(from string, fastTraceFlag bool, file string, params fastTrace.ParamsFastTrace, method trace.Method) bool { if from != "" || (!fastTraceFlag && file == "") { return false } fastTrace.FastTest(method, params) if params.OutputPath != "" { fmt.Printf("您的追踪日志已经存放在 %s 中\n", params.OutputPath) } os.Exit(0) return true } func configureGeoDNS(dot string) { if dot != "" { util.SetGeoDNSResolver(dot) } } func normalizeCLITarget(raw string) string { domain := raw if strings.Contains(domain, "/") { domain = "n" + domain parts := strings.Split(domain, "/") if len(parts) < 3 { return "" } domain = parts[2] } if strings.Contains(domain, "]") && strings.Contains(domain, "[") { inner := strings.SplitN(domain, "]", 2)[0] parts := strings.SplitN(inner, "[", 2) if len(parts) >= 2 { return parts[1] } return domain } if strings.Contains(domain, ":") && strings.Count(domain, ":") == 1 { return strings.Split(domain, ":")[0] } return domain } func resolveCLITargetOrExit(raw string, usage string) string { if raw == "" { fmt.Print(usage) return "" } domain := normalizeCLITarget(raw) if domain == "" { if strings.Contains(raw, "/") { fmt.Println("Invalid input") } else { fmt.Print(usage) } } return domain } func applyDN42Mode(enabled bool, dataOrigin *string, disableMaptrace *bool) { if !enabled { return } config.InitConfig() *dataOrigin = "DN42" *disableMaptrace = true } func prepareRuntimeEnvironment(ctx context.Context, dn42 bool, dataOrigin *string, disableMaptrace *bool, powProvider *string) *wshandle.WsConn { capabilitiesCheck() applyDN42Mode(dn42, dataOrigin, disableMaptrace) return initLeoWebsocket(ctx, dataOrigin, powProvider) } func initLeoWebsocket(ctx context.Context, dataOrigin, powProvider *string) *wshandle.WsConn { if !strings.EqualFold(*dataOrigin, "LEOMOEAPI") { return nil } if !strings.EqualFold(*powProvider, "api.nxtrace.org") { util.PowProviderParam = *powProvider } if util.EnvDataProvider != "" { *dataOrigin = util.EnvDataProvider } if !strings.EqualFold(*dataOrigin, "LEOMOEAPI") { return nil } leoWs := wshandle.NewWithContext(ctx) if leoWs != nil { leoWs.Interrupt = make(chan os.Signal, 1) signal.Notify(leoWs.Interrupt, os.Interrupt) } return leoWs } func closeLeoWebsocket(leoWs *wshandle.WsConn) { if leoWs != nil { leoWs.Close() } } func maybeHandleGlobalping(from string, opts *trace.GlobalpingOptions, conf *trace.Config) bool { if from == "" { return false } handleGlobalpingTrace(opts, conf) return true } func lookupTargetIP(ctx context.Context, domain string, ipv4Only, ipv6Only bool, dot string, jsonPrint bool) (net.IP, error) { switch { case ipv6Only: return domainLookupFn(ctx, domain, "6", dot, jsonPrint) case ipv4Only: return domainLookupFn(ctx, domain, "4", dot, jsonPrint) default: return domainLookupFn(ctx, domain, "all", dot, jsonPrint) } } func lookupTargetIPOrExit(ctx context.Context, domain string, ipv4Only, ipv6Only bool, dot string, jsonPrint bool) net.IP { ip, err := lookupTargetIP(ctx, domain, ipv4Only, ipv6Only, dot, jsonPrint) if err != nil { if util.EnvDevMode { panic(err) } log.Fatal(err) } return ip } func resolveSourceDevice(srcDev string) (*net.Interface, error) { trimmed := strings.TrimSpace(srcDev) if trimmed == "" { return nil, nil } dev, err := net.InterfaceByName(trimmed) if err != nil || dev == nil { return nil, fmt.Errorf("无法找到网卡 %q: %v", trimmed, err) } return dev, nil } func resolveSourceDeviceAddr(dev *net.Interface, dstIP net.IP) string { if dev == nil || dstIP == nil { return "" } addrs, err := dev.Addrs() if err != nil { return "" } var candidate string for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) if !ok { continue } if (ipNet.IP.To4() == nil) != (dstIP.To4() == nil) { continue } candidate = ipNet.IP.String() parsed := net.ParseIP(candidate) if parsed != nil && !(parsed.IsPrivate() || parsed.IsLoopback() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast()) { return candidate } } return candidate } func resolveFallbackSrcAddr(dstIP net.IP) string { if dstIP == nil { return "" } if util.IsIPv6(dstIP) { resolved, _ := util.LocalIPPortv6(dstIP, nil, "udp6") if resolved != nil { return resolved.String() } return "" } resolved, _ := util.LocalIPPort(dstIP, nil, "udp") if resolved != nil { return resolved.String() } return "" } func resolveConfiguredSrcAddr(dstIP net.IP, srcAddr, srcDev string) (resolved string, explicit bool, err error) { if trimmed := strings.TrimSpace(srcAddr); trimmed != "" { return trimmed, true, nil } dev, err := resolveSourceDevice(srcDev) if err != nil { return "", false, err } if resolved := resolveSourceDeviceAddr(dev, dstIP); resolved != "" { return resolved, false, nil } return resolveFallbackSrcAddr(dstIP), false, nil } func applySourceDevice(srcDev string, dstIP net.IP, srcAddr *string) { dev, err := resolveSourceDevice(srcDev) if err != nil { fmt.Println(err) os.Exit(1) } if dev == nil { return } util.SrcDev = dev.Name if srcAddr == nil || strings.TrimSpace(*srcAddr) != "" { return } if resolved := resolveSourceDeviceAddr(dev, dstIP); resolved != "" { *srcAddr = resolved } } func printTraceNav(jsonPrint bool, effectiveMTR bool, ip net.IP, domain, dataOrigin string, maxHops, packetSize int, srcAddr string, method trace.Method) { if !jsonPrint && !effectiveMTR { printer.PrintTraceRouteNav(ip, domain, dataOrigin, maxHops, packetSize, srcAddr, string(method)) } } func buildTraceConfig( osType, icmpMode int, dn42 bool, srcAddr string, sourceDevice string, srcPort int, beginHop int, ip net.IP, port int, maxHops int, packetInterval int, ttlInterval int, numMeasurements int, maxAttempts int, parallelRequests int, lang string, noRDNS bool, alwaysRDNS bool, dataOrigin string, timeout int, packetSize int, randomPacketSize bool, tos int, disableMPLS bool, ) trace.Config { return trace.Config{ OSType: osType, ICMPMode: icmpMode, DN42: dn42, SrcAddr: srcAddr, SrcPort: srcPort, SourceDevice: strings.TrimSpace(sourceDevice), BeginHop: beginHop, DstIP: ip, DstPort: port, MaxHops: maxHops, PacketInterval: packetInterval, TTLInterval: ttlInterval, NumMeasurements: numMeasurements, MaxAttempts: maxAttempts, ParallelRequests: parallelRequests, Lang: lang, RDNS: !noRDNS, AlwaysWaitRDNS: alwaysRDNS, IPGeoSource: ipgeo.GetSource(dataOrigin), Timeout: time.Duration(timeout) * time.Millisecond, PktSize: packetSize, RandomPacketSize: randomPacketSize, TOS: tos, DisableMPLS: disableMPLS, } } func maybeRunMTRMode( modes effectiveMTRModes, method trace.Method, conf trace.Config, queriesExplicit bool, numMeasurements int, ttlTimeExplicit bool, ttlInterval int, domain string, dataOrigin string, showIPs bool, ipInfoMode int, ) bool { if !modes.mtr { return false } mtrMaxPerHop, mtrHopIntervalMs := deriveMTRProbeParams( modes.report, queriesExplicit, numMeasurements, ttlTimeExplicit, ttlInterval, ) switch chooseMTRRunMode(modes.raw, modes.report) { case mtrRunRaw: runMTRRaw(method, conf, mtrHopIntervalMs, mtrMaxPerHop, dataOrigin) case mtrRunReport: runMTRReport(method, conf, mtrHopIntervalMs, mtrMaxPerHop, domain, dataOrigin, modes.wide, showIPs) default: if ipInfoMode < 0 || ipInfoMode > 4 { fmt.Fprintf(os.Stderr, "--ipinfo/-y 必须在 0-4 范围内,当前值: %d\n", ipInfoMode) os.Exit(1) } runMTRTUI(method, conf, mtrHopIntervalMs, mtrMaxPerHop, domain, dataOrigin, showIPs, ipInfoMode) } return true } func resolveOutputPath(outputPath string, outputDefault bool) (string, error) { trimmed := strings.TrimSpace(outputPath) if trimmed != "" && outputDefault { return "", errors.New("--output 与 --output-default 不能同时使用") } if trimmed != "" { return trimmed, nil } if outputDefault { return tracelog.DefaultPath, nil } return "", nil } func validateJSONRealtimeOutput(jsonPrint bool, outputPath string) error { if jsonPrint && strings.TrimSpace(outputPath) != "" { return errors.New("--json 不能与 --output/--output-default 同时使用") } return nil } func setFastIPOutputSuppression(suppress bool) func() { prev := util.SuppressFastIPOutput util.SuppressFastIPOutput = suppress return func() { util.SuppressFastIPOutput = prev } } func configureTracePrinters(conf *trace.Config, tablePrint, classicPrint, rawPrint bool, outputPath string) (func() error, error) { if tablePrint { return nil, nil } router := false switch { case classicPrint: conf.RealtimePrinter = printer.ClassicPrinter case rawPrint: conf.RealtimePrinter = printer.EasyPrinter case outputPath != "": f, err := tracelog.OpenFile(outputPath) if err != nil { return nil, err } conf.RealtimePrinter = tracelog.NewRealtimePrinter(io.MultiWriter(os.Stdout, f)) return f.Close, nil case router: conf.RealtimePrinter = printer.RealtimePrinterWithRouter fmt.Println("路由表数据源由 BGP.Tools 提供,在此特表感谢") default: conf.RealtimePrinter = printer.RealtimePrinter } return nil, nil } func applyJSONOutputMode(conf *trace.Config, jsonPrint bool) { if jsonPrint { conf.RealtimePrinter = nil conf.AsyncPrinter = nil } } func maybeRunUninterruptedRaw(rawPrint bool, method trace.Method, conf trace.Config) { if !(util.Uninterrupted && rawPrint) { return } for { if _, err := trace.Traceroute(method, conf); err != nil { fmt.Println(err) } } } func runTraceOnce(method trace.Method, conf trace.Config) (*trace.Result, bool) { res, err := trace.Traceroute(method, conf) if err != nil { if !errors.Is(err, context.Canceled) { fmt.Println(err) } return nil, false } return res, true } func finalizeTraceResult(ctx context.Context, res *trace.Result, tablePrint, tableClearScreen, routePath bool, dstIP net.IP, disableMaptrace, jsonPrint bool, dataOrigin string) { if tablePrint { printer.TracerouteTablePrinter(res, tableClearScreen) } if routePath { reporter.New(res, dstIP.String()).Print() } r, err := json.Marshal(res) if err != nil { fmt.Println(err) return } if !disableMaptrace && (util.StringInSlice(strings.ToUpper(dataOrigin), []string{"LEOMOEAPI", "IPINFO", "IP-API.COM", "IPAPI.COM"})) { url, err := tracemap.GetMapUrlWithContext(ctx, string(r)) if err != nil { fmt.Println(err) return } res.TraceMapUrl = url if !jsonPrint { tracemap.PrintMapUrl(url) } } r, err = json.Marshal(res) if err != nil { fmt.Println(err) return } if jsonPrint { fmt.Println(string(r)) } } func Execute() { parser := argparse.NewParser(appBinName, "An open source visual route tracking CLI tool") // Override HelpFunc so positional arg names are sanitized in --help output parser.HelpFunc = func(c *argparse.Command, msg interface{}) string { return sanitizeUsagePositionalArgs(c.Usage(msg)) } init := registerInitFlag(parser) ipv4Only := parser.Flag("4", "ipv4", &argparse.Options{Help: "Use IPv4 only"}) ipv6Only := parser.Flag("6", "ipv6", &argparse.Options{Help: "Use IPv6 only"}) tcp := parser.Flag("T", "tcp", &argparse.Options{Help: "Use TCP SYN for tracerouting (default dest-port is 80)"}) udp := parser.Flag("U", "udp", &argparse.Options{Help: "Use UDP SYN for tracerouting (default dest-port is 33494)"}) mtuMode := registerMTUFlag(parser) fastTraceFlag := registerFastTraceFlag(parser) port := parser.Int("p", "port", &argparse.Options{Help: "Set the destination port to use. With default of 80 for \"tcp\", 33494 for \"udp\""}) icmpMode := registerICMPModeFlag(parser) numMeasurements := parser.Int("q", "queries", &argparse.Options{Default: 3, Help: buildQueriesHelp()}) maxAttempts := parser.Int("", "max-attempts", &argparse.Options{Help: buildMaxAttemptsHelp()}) parallelRequests := parser.Int("", "parallel-requests", &argparse.Options{Default: 18, Help: buildParallelRequestsHelp()}) maxHops := parser.Int("m", "max-hops", &argparse.Options{Default: 30, Help: "Set the max number of hops (max TTL to be reached)"}) dataOrigin := parser.Selector("d", "data-provider", []string{"IP.SB", "ip.sb", "IPInfo", "ipinfo", "IPInsight", "ipinsight", "IPAPI.com", "ip-api.com", "IPInfoLocal", "ipinfolocal", "chunzhen", "LeoMoeAPI", "leomoeapi", "ipdb.one", "disable-geoip"}, &argparse.Options{Default: "LeoMoeAPI", Help: "Choose IP Geograph Data Provider [IP.SB, IPInfo, IPInsight, IP-API.com, IPInfoLocal, CHUNZHEN, disable-geoip]"}) powProvider := parser.Selector("", "pow-provider", []string{"api.nxtrace.org", "sakura"}, &argparse.Options{Default: "api.nxtrace.org", Help: "Choose PoW Provider [api.nxtrace.org, sakura] For China mainland users, please use sakura"}) norDNS := parser.Flag("n", "no-rdns", &argparse.Options{Help: "Do not resolve IP addresses to their domain names"}) alwaysrDNS := parser.Flag("a", "always-rdns", &argparse.Options{Help: "Always resolve IP addresses to their domain names"}) outputFlags := registerTracerouteOutputFlags(parser) routePath := outputFlags.routePath outputPath := outputFlags.outputPath outputDefault := outputFlags.outputDefault tablePrint := outputFlags.tablePrint jsonPrint := outputFlags.jsonPrint classicPrint := outputFlags.classicPrint dn42 := parser.Flag("", "dn42", &argparse.Options{Help: "DN42 Mode"}) rawPrint := parser.Flag("", "raw", &argparse.Options{Help: buildRawHelp()}) beginHop := parser.Int("f", "first", &argparse.Options{Default: 1, Help: "Start from the first_ttl hop (instead of 1)"}) disableMaptrace := registerDisableMaptraceFlag(parser) disableMPLS := parser.Flag("e", "disable-mpls", &argparse.Options{Help: "Disable MPLS"}) ver := parser.Flag("V", "version", &argparse.Options{Help: "Print version info and exit"}) srcAddr := parser.String("s", "source", &argparse.Options{Help: "Use source address src_addr for outgoing packets"}) srcPort := parser.Int("", "source-port", &argparse.Options{Help: "Use source port src_port for outgoing packets"}) srcDev := parser.String("D", "dev", &argparse.Options{Help: "Use the following Network Devices as the source address in outgoing packets"}) webFlags := registerWebUIFlags(parser) deployListen := webFlags.deployListen deploy := webFlags.deploy //router := parser.Flag("R", "route", &argparse.Options{Help: "Show Routing Table [Provided By BGP.Tools]"}) // ── Send-time: hidden in ntr (always ignored in MTR mode) ── packetInterval := registerPacketIntervalFlag(parser) ttlInterval := registerTTLIntervalFlag(parser) timeout := parser.Int("", "timeout", &argparse.Options{Default: 1000, Help: buildTimeoutHelp()}) packetSize := parser.Int("", "psize", &argparse.Options{Help: buildPayloadSizeHelp()}) tos := parser.Int("Q", "tos", &argparse.Options{Default: 0, Help: buildTOSHelp()}) dot := parser.Selector("", "dot-server", []string{"dnssb", "aliyun", "dnspod", "google", "cloudflare"}, &argparse.Options{ Help: "Use DoT Server for DNS Parse [dnssb, aliyun, dnspod, google, cloudflare]"}) lang := parser.Selector("g", "language", []string{"en", "cn"}, &argparse.Options{Default: "cn", Help: "Choose the language for displaying [en, cn]"}) noColor := parser.Flag("C", "no-color", &argparse.Options{Help: "Disable Colorful Output"}) // ── Globalping flag (full only) ── from := registerGlobalpingFlag(parser) // ── MTR flags (full & ntr only) ── mtrFlags := registerMTRFlags(parser) mtrMode := mtrFlags.mtrMode reportMode := mtrFlags.reportMode wideMode := mtrFlags.wideMode showIPs := mtrFlags.showIPs ipInfoMode := mtrFlags.ipInfoMode // ── File: hidden in ntr (conflicts with default MTR mode) ── file := registerFileFlag(parser) str := parser.StringPositional(&argparse.Options{Help: "Trace target: IPv4 address (e.g. 8.8.8.8), IPv6 address (e.g. 2001:db8::1), domain name (e.g. example.com), or URL (e.g. https://example.com)"}) err := parser.Parse(normalizeNegativePacketSizeArgs(os.Args)) if err != nil { // In case of error print error and print usage // This can also be done by passing -h or --help flags fmt.Print(sanitizeUsagePositionalArgs(parser.Usage(err))) return } rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() util.SrcDev = "" mtrModes := deriveEffectiveMTRModes(*mtrMode, *reportMode, *wideMode, *rawPrint) resolvedOutputPath, outputErr := resolveOutputPath(*outputPath, *outputDefault) if outputErr != nil { fmt.Println(outputErr) os.Exit(1) } if err := validateJSONRealtimeOutput(*jsonPrint, resolvedOutputPath); err != nil { fmt.Println(err) os.Exit(1) } if *mtuMode { conflictFlags := buildMTUConflictFlags( *tcp, *rawPrint, mtrModes, *tablePrint, *classicPrint, *routePath, *outputPath != "", *outputDefault, *deploy, enableGlobalping, *from, *file, *fastTraceFlag, ) if conflict, ok := checkMTUConflicts(conflictFlags); !ok { fmt.Printf("--mtu 不能与 %s 同时使用\n", conflict) os.Exit(1) } if err := normalizeMTUProtocolFlags(tcp, udp); err != nil { fmt.Println(err) os.Exit(1) } } if mtrModes.mtr { conflictFlags := map[string]bool{ "table": *tablePrint, "classic": *classicPrint, "json": *jsonPrint, "output": *outputPath != "", "outputDefault": *outputDefault, "routePath": *routePath, "from": enableGlobalping && *from != "", "fastTrace": *fastTraceFlag, "file": *file != "", "deploy": enableWebUI && *deploy, } if conflict, ok := checkMTRConflicts(conflictFlags); !ok { fmt.Printf("--mtr 不能与 %s 同时使用\n", conflict) os.Exit(1) } } queriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit := detectExplicitProbeFlags(parser) applyTTLIntervalDefault(ttlInterval, ttlTimeExplicit, mtrModes.mtr) osType := resolveOSType() stdoutIsTTY := CheckTTY(int(os.Stdout.Fd())) if shouldForceNoColorForMTUNonTTY(*mtuMode, *jsonPrint, stdoutIsTTY) { *noColor = true } if handleStartupModes(*noColor, *jsonPrint, mtrModes, *ver, *deploy, *deployListen, *init, osType) { return } restoreFastIPOutput := setFastIPOutputSuppression(*jsonPrint || mtrModes.mtr) defer restoreFastIPOutput() if *tos < 0 || *tos > 255 { fmt.Println("--tos 必须在 0-255 之间") os.Exit(1) } applyDefaultPort(port, *udp) clampProbeSettings(*tcp, numMeasurements, maxAttempts) configureGeoDNS(*dot) if *mtuMode { if packetSizeExplicit { fmt.Println("--mtu 不支持 --psize") os.Exit(1) } if tosExplicit { fmt.Println("--mtu 不支持 --tos") os.Exit(1) } if !checkRuntimePrivileges(true) { os.Exit(1) } domain := resolveCLITargetOrExit(*str, sanitizeUsagePositionalArgs(parser.Usage(err))) if domain == "" { return } ip := lookupTargetIPOrExit(rootCtx, domain, *ipv4Only, *ipv6Only, *dot, *jsonPrint) resolvedSrcAddr, explicitSrc, srcResolveErr := resolveConfiguredSrcAddr(ip, *srcAddr, *srcDev) if srcResolveErr != nil { fmt.Println(srcResolveErr) os.Exit(1) } if !explicitSrc { applySourceDevice(*srcDev, ip, srcAddr) } if strings.TrimSpace(*srcAddr) == "" { *srcAddr = resolvedSrcAddr } srcIP, srcErr := resolveMTUSourceIP(ip, resolvedSrcAddr) if srcErr != nil { fmt.Println(srcErr) os.Exit(1) } leoWs := prepareRuntimeEnvironment(rootCtx, *dn42, dataOrigin, disableMaptrace, powProvider) defer closeLeoWebsocket(leoWs) conf := buildMTUTraceConfig( domain, ip, srcIP, *srcDev, *srcPort, *port, *beginHop, *maxHops, *numMeasurements, *timeout, *ttlInterval, !*norDNS, *alwaysrDNS, ipgeo.GetSource(*dataOrigin), *lang, ) if err := runStandaloneMTUMode(conf, *jsonPrint); err != nil { if !errors.Is(err, context.Canceled) { fmt.Println(err) } } return } method := resolveTraceMethod(*tcp, *udp) paramsFastTrace := fastTrace.ParamsFastTrace{ Context: rootCtx, OSType: osType, ICMPMode: *icmpMode, SrcDev: *srcDev, SrcAddr: *srcAddr, DstPort: *port, BeginHop: *beginHop, MaxHops: *maxHops, MaxAttempts: *maxAttempts, RDNS: !*norDNS, AlwaysWaitRDNS: *alwaysrDNS, Lang: *lang, PktSize: *packetSize, PacketSizeSet: packetSizeExplicit, TOS: *tos, Timeout: time.Duration(*timeout) * time.Millisecond, File: *file, Dot: *dot, OutputPath: resolvedOutputPath, } if maybeRunFastTraceMode(*from, *fastTraceFlag, *file, paramsFastTrace, method) { return } domain := resolveCLITargetOrExit(*str, sanitizeUsagePositionalArgs(parser.Usage(err))) if domain == "" { return } leoWs := prepareRuntimeEnvironment(rootCtx, *dn42, dataOrigin, disableMaptrace, powProvider) defer closeLeoWebsocket(leoWs) if *from != "" { if packetSizeExplicit { fmt.Println("Globalping 模式不支持 --psize") os.Exit(1) } if tosExplicit { fmt.Println("Globalping 模式不支持 --tos") os.Exit(1) } } if maybeHandleGlobalping( *from, &trace.GlobalpingOptions{ Target: *str, From: *from, IPv4: *ipv4Only, IPv6: *ipv6Only, TCP: *tcp, UDP: *udp, Port: *port, Packets: *numMeasurements, MaxHops: *maxHops, DisableMaptrace: *disableMaptrace, DataOrigin: *dataOrigin, TablePrint: *tablePrint, ClassicPrint: *classicPrint, RawPrint: *rawPrint, JSONPrint: *jsonPrint, ClearScreen: stdoutIsTTY, }, &trace.Config{ Context: rootCtx, OSType: osType, DN42: *dn42, NumMeasurements: *numMeasurements, Lang: *lang, RDNS: !*norDNS, AlwaysWaitRDNS: *alwaysrDNS, IPGeoSource: ipgeo.GetSource(*dataOrigin), Timeout: time.Duration(*timeout) * time.Millisecond, }, ) { return } ip := lookupTargetIPOrExit(rootCtx, domain, *ipv4Only, *ipv6Only, *dot, *jsonPrint) resolvedSrcAddr, explicitSrc, srcResolveErr := resolveConfiguredSrcAddr(ip, *srcAddr, *srcDev) if srcResolveErr != nil { fmt.Println(srcResolveErr) os.Exit(1) } if !explicitSrc { applySourceDevice(*srcDev, ip, srcAddr) } if strings.TrimSpace(*srcAddr) == "" { *srcAddr = resolvedSrcAddr } effectivePacketSize := resolvePacketSizeArg(*packetSize, packetSizeExplicit, method, ip) printTraceNav(*jsonPrint, mtrModes.mtr, ip, domain, *dataOrigin, *maxHops, effectivePacketSize, resolvedSrcAddr, method) packetSizeSpec, packetSizeErr := trace.NormalizePacketSize(method, ip, effectivePacketSize) if packetSizeErr != nil { fmt.Println(packetSizeErr) os.Exit(1) } util.SrcPort = *srcPort util.DstIP = ip.String() conf := buildTraceConfig( osType, *icmpMode, *dn42, resolvedSrcAddr, *srcDev, *srcPort, *beginHop, ip, *port, *maxHops, *packetInterval, *ttlInterval, *numMeasurements, *maxAttempts, *parallelRequests, *lang, *norDNS, *alwaysrDNS, *dataOrigin, *timeout, packetSizeSpec.PayloadSize, packetSizeSpec.Random, *tos, *disableMPLS, ) conf.Context = rootCtx if maybeRunMTRMode(mtrModes, method, conf, queriesExplicit, *numMeasurements, ttlTimeExplicit, *ttlInterval, domain, *dataOrigin, *showIPs, *ipInfoMode) { return } outputCleanup, err := configureTracePrinters(&conf, *tablePrint, *classicPrint, *rawPrint, resolvedOutputPath) if err != nil { fmt.Println(err) os.Exit(1) } if outputCleanup != nil { defer func() { if closeErr := outputCleanup(); closeErr != nil { fmt.Println(closeErr) } }() } applyJSONOutputMode(&conf, *jsonPrint) maybeRunUninterruptedRaw(*rawPrint, method, conf) res, ok := runTraceOnce(method, conf) if !ok { return } finalizeTraceResult(rootCtx, res, *tablePrint, stdoutIsTTY, *routePath, ip, *disableMaptrace, *jsonPrint, *dataOrigin) } type mtrRunMode int const ( mtrRunTUI mtrRunMode = iota mtrRunReport mtrRunRaw ) func chooseMTRRunMode(effectiveMTRRaw, effectiveReport bool) mtrRunMode { if effectiveMTRRaw { return mtrRunRaw } if effectiveReport { return mtrRunReport } return mtrRunTUI } // deriveMTRProbeParams computes per-hop scheduling parameters for MTR. // // maxPerHop priority: explicit -q > report default 10 > TUI/raw default 0 (unlimited). // hopIntervalMs priority: explicit -i > default 1000. func deriveMTRProbeParams( effectiveReport, queriesExplicit bool, numMeasurements int, ttlTimeExplicit bool, ttlInterval int, ) (maxPerHop int, hopIntervalMs int) { // maxPerHop if queriesExplicit { maxPerHop = numMeasurements } else if effectiveReport { maxPerHop = 10 // report 默认 10 } else { maxPerHop = 0 // TUI/raw → 无限 } // hopIntervalMs if ttlTimeExplicit { hopIntervalMs = ttlInterval } else { hopIntervalMs = 1000 } return } // deriveMTRRoundParams is the legacy round-based parameter derivation. // Kept for backward compatibility (Web MTR). func deriveMTRRoundParams(effectiveReport, queriesExplicit bool, numMeasurements int, ttlTimeExplicit bool, ttlInterval int) (maxRounds int, intervalMs int) { if effectiveReport { if queriesExplicit { maxRounds = numMeasurements } else { maxRounds = 10 // report 默认 10 轮 } } else if queriesExplicit { maxRounds = numMeasurements } else { maxRounds = 0 // 非 report → 无限 } if ttlTimeExplicit { intervalMs = ttlInterval } else { intervalMs = 1000 // MTR 默认 1000ms } return } func capabilitiesCheck() { status := util.TracePrivilegeStatus(appBinName, false) if status.Message != "" { fmt.Println(status.Message) } } func checkRuntimePrivileges(requireWindowsAdmin bool) bool { status := util.TracePrivilegeStatus(appBinName, requireWindowsAdmin) if status.Message != "" { fmt.Println(status.Message) } return !status.Fatal } ================================================ FILE: cmd/cmd_test.go ================================================ package cmd import ( "context" "errors" "net" "strings" "testing" "time" "github.com/akamensky/argparse" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/tracelog" "github.com/nxtrace/NTrace-core/util" ) func TestLookupTargetIPHonorsContextCancellation(t *testing.T) { oldLookup := domainLookupFn domainLookupFn = func(ctx context.Context, host, ipVersion, dotServer string, disableOutput bool) (net.IP, error) { <-ctx.Done() return nil, ctx.Err() } defer func() { domainLookupFn = oldLookup }() ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() _, err := lookupTargetIP(ctx, "example.com", false, false, "", true) if !errors.Is(err, context.Canceled) { t.Fatalf("lookupTargetIP error = %v, want context.Canceled", err) } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("lookupTargetIP returned too slowly after cancel: %v", elapsed) } } func TestRegisterGlobalpingFlagWithAvailability_DisabledStillParses(t *testing.T) { parser := argparse.NewParser("ntr", "") from := registerGlobalpingFlagWithAvailability(parser, false) if err := parser.Parse([]string{"ntr", "--from", "tokyo"}); err != nil { t.Fatalf("Parse returned error: %v", err) } if got := strings.TrimSpace(*from); got != "tokyo" { t.Fatalf("--from = %q, want tokyo", got) } } func TestRegisterWebUIFlagsWithAvailability_DisabledStillParses(t *testing.T) { parser := argparse.NewParser("ntr", "") flags := registerWebUIFlagsWithAvailability(parser, false) if err := parser.Parse([]string{"ntr", "--deploy", "--listen", "127.0.0.1:1080"}); err != nil { t.Fatalf("Parse returned error: %v", err) } if !*flags.deploy { t.Fatal("--deploy should parse as true") } if got := strings.TrimSpace(*flags.deployListen); got != "127.0.0.1:1080" { t.Fatalf("--listen = %q, want 127.0.0.1:1080", got) } } func TestRegisterTTLIntervalFlagWithMTRSupport_HelpOmitsTracerouteDefault(t *testing.T) { parser := argparse.NewParser("ntr", "") registerTTLIntervalFlagWithMTRSupport(parser, true) usage := parser.Usage(nil) if strings.Contains(usage, "Default: 300") { t.Fatalf("usage should not advertise traceroute default in MTR mode:\n%s", usage) } } func TestApplyTTLIntervalDefault(t *testing.T) { ttlInterval := 0 applyTTLIntervalDefault(&ttlInterval, false, false) if ttlInterval != defaultTracerouteTTLIntervalMs { t.Fatalf("ttlInterval = %d, want %d", ttlInterval, defaultTracerouteTTLIntervalMs) } ttlInterval = 0 applyTTLIntervalDefault(&ttlInterval, false, true) if ttlInterval != 0 { t.Fatalf("MTR ttlInterval = %d, want 0", ttlInterval) } ttlInterval = 0 applyTTLIntervalDefault(&ttlInterval, true, false) if ttlInterval != 0 { t.Fatalf("explicit ttlInterval = %d, want 0", ttlInterval) } } func TestAdvancedHelpTextMentionsTuningGuidance(t *testing.T) { parser := argparse.NewParser("ntr", "") registerPacketIntervalFlag(parser) parser.Int("", "max-attempts", &argparse.Options{Help: buildMaxAttemptsHelp()}) parser.Int("", "parallel-requests", &argparse.Options{Default: 18, Help: buildParallelRequestsHelp()}) parser.Int("", "timeout", &argparse.Options{Default: 1000, Help: buildTimeoutHelp()}) parser.Int("", "psize", &argparse.Options{Help: buildPayloadSizeHelp()}) usage := parser.Usage(nil) for _, want := range []string{ "load-balanced paths", "rate-limited links", "intercontinental", "raise for MTU or", } { if !strings.Contains(usage, want) { t.Fatalf("usage missing tuning guidance %q:\n%s", want, usage) } } } func TestProbeOptionHelpMentionsRandomPacketSizeAndTOS(t *testing.T) { parser := argparse.NewParser("ntr", "") parser.Int("", "psize", &argparse.Options{Help: buildPayloadSizeHelp()}) parser.Int("Q", "tos", &argparse.Options{Default: 0, Help: buildTOSHelp()}) usage := parser.Usage(nil) for _, want := range []string{ "Negative values randomize each probe", "type-of-service / traffic class", } { if !strings.Contains(usage, want) { t.Fatalf("usage missing %q:\n%s", want, usage) } } } func TestDetectExplicitProbeFlags(t *testing.T) { parser := argparse.NewParser("ntr", "") parser.Int("q", "queries", &argparse.Options{Default: 3}) parser.Int("i", "ttl-time", &argparse.Options{Default: 300}) parser.Int("", "psize", &argparse.Options{}) parser.Int("Q", "tos", &argparse.Options{Default: 0}) if err := parser.Parse([]string{"ntr", "--psize", "-123", "-Q", "46", "-q", "5"}); err != nil { t.Fatalf("Parse returned error: %v", err) } queriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit := detectExplicitProbeFlags(parser) if !queriesExplicit { t.Fatal("queriesExplicit = false, want true") } if ttlTimeExplicit { t.Fatal("ttlTimeExplicit = true, want false") } if !packetSizeExplicit { t.Fatal("packetSizeExplicit = false, want true") } if !tosExplicit { t.Fatal("tosExplicit = false, want true") } } func TestNormalizeNegativePacketSizeArgs(t *testing.T) { args := []string{"ntr", "--psize", "-84", "1.1.1.1"} got := normalizeNegativePacketSizeArgs(args) want := []string{"ntr", "--psize=-84", "1.1.1.1"} if len(got) != len(want) { t.Fatalf("len(got) = %d, want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i]) } } } func TestNegativePacketSizeParsesBeforeTarget(t *testing.T) { parser := argparse.NewParser("ntr", "") packetSize := parser.Int("", "psize", &argparse.Options{}) ipv6Only := parser.Flag("6", "ipv6", &argparse.Options{}) target := parser.StringPositional(&argparse.Options{}) args := normalizeNegativePacketSizeArgs([]string{"ntr", "-6", "--psize", "-96", "2606:4700:4700::1111"}) if err := parser.Parse(args); err != nil { t.Fatalf("Parse returned error: %v", err) } if !*ipv6Only { t.Fatal("-6 should parse as true") } if *packetSize != -96 { t.Fatalf("--psize = %d, want -96", *packetSize) } if *target != "2606:4700:4700::1111" { t.Fatalf("target = %q, want 2606:4700:4700::1111", *target) } } func TestResolvePacketSizeArg_DefaultsToProtocolMinimum(t *testing.T) { got := resolvePacketSizeArg(0, false, trace.TCPTrace, net.ParseIP("2a00:1450:4009:81a::200e")) if got != 64 { t.Fatalf("resolvePacketSizeArg() = %d, want 64", got) } } func TestRegisterTracerouteOutputFlagsParsesOutputPath(t *testing.T) { parser := argparse.NewParser("nexttrace", "") flags := registerTracerouteOutputFlags(parser) target := parser.StringPositional(&argparse.Options{}) if err := parser.Parse([]string{"nexttrace", "-o", "trace.log", "1.1.1.1"}); err != nil { t.Fatalf("Parse returned error: %v", err) } if got := strings.TrimSpace(*flags.outputPath); got != "trace.log" { t.Fatalf("--output = %q, want trace.log", got) } if *flags.outputDefault { t.Fatal("--output-default should be false") } if *target != "1.1.1.1" { t.Fatalf("target = %q, want 1.1.1.1", *target) } } func TestRegisterTracerouteOutputFlagsParsesOutputDefault(t *testing.T) { parser := argparse.NewParser("nexttrace", "") flags := registerTracerouteOutputFlags(parser) target := parser.StringPositional(&argparse.Options{}) if err := parser.Parse([]string{"nexttrace", "-O", "1.1.1.1"}); err != nil { t.Fatalf("Parse returned error: %v", err) } if !*flags.outputDefault { t.Fatal("--output-default should be true") } if got := strings.TrimSpace(*flags.outputPath); got != "" { t.Fatalf("--output = %q, want empty", got) } if *target != "1.1.1.1" { t.Fatalf("target = %q, want 1.1.1.1", *target) } } func TestResolveOutputPath(t *testing.T) { tests := []struct { name string outputPath string outputDefault bool want string wantErr string }{ {name: "custom", outputPath: "custom.log", want: "custom.log"}, {name: "default", outputDefault: true, want: tracelog.DefaultPath}, {name: "disabled"}, {name: "conflict", outputPath: "custom.log", outputDefault: true, wantErr: "--output 与 --output-default 不能同时使用"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := resolveOutputPath(tt.outputPath, tt.outputDefault) if tt.wantErr != "" { if err == nil || err.Error() != tt.wantErr { t.Fatalf("err = %v, want %q", err, tt.wantErr) } return } if err != nil { t.Fatalf("resolveOutputPath returned error: %v", err) } if got != tt.want { t.Fatalf("resolveOutputPath() = %q, want %q", got, tt.want) } }) } } func TestSetFastIPOutputSuppressionRestoresPreviousValue(t *testing.T) { orig := util.SuppressFastIPOutput util.SuppressFastIPOutput = false restore := setFastIPOutputSuppression(true) if !util.SuppressFastIPOutput { t.Fatal("SuppressFastIPOutput should be true after suppression") } restore() if util.SuppressFastIPOutput != false { t.Fatalf("SuppressFastIPOutput = %v, want false", util.SuppressFastIPOutput) } util.SuppressFastIPOutput = orig } func TestResolveConfiguredSrcAddrPrefersExplicitSource(t *testing.T) { dstIP := net.ParseIP("1.1.1.1") resolved, explicit, err := resolveConfiguredSrcAddr(dstIP, "192.0.2.10", "codex-nonexistent-dev0") if err != nil { t.Fatalf("resolveConfiguredSrcAddr returned error: %v", err) } if !explicit { t.Fatal("explicit source should be reported as explicit") } if resolved != "192.0.2.10" { t.Fatalf("resolved source = %q, want %q", resolved, "192.0.2.10") } } func TestValidateJSONRealtimeOutput(t *testing.T) { if err := validateJSONRealtimeOutput(true, "trace.log"); err == nil || err.Error() != "--json 不能与 --output/--output-default 同时使用" { t.Fatalf("err = %v, want json/output conflict", err) } if err := validateJSONRealtimeOutput(true, ""); err != nil { t.Fatalf("unexpected error without output path: %v", err) } } func TestShouldForceNoColorForMTUNonTTY(t *testing.T) { tests := []struct { name string mtuMode bool jsonPrint bool stdoutIsTTY bool want bool }{ {name: "mtu non-tty text", mtuMode: true, jsonPrint: false, stdoutIsTTY: false, want: true}, {name: "mtu tty text", mtuMode: true, jsonPrint: false, stdoutIsTTY: true, want: false}, {name: "mtu non-tty json", mtuMode: true, jsonPrint: true, stdoutIsTTY: false, want: false}, {name: "non-mtu non-tty text", mtuMode: false, jsonPrint: false, stdoutIsTTY: false, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := shouldForceNoColorForMTUNonTTY(tt.mtuMode, tt.jsonPrint, tt.stdoutIsTTY) if got != tt.want { t.Fatalf("shouldForceNoColorForMTUNonTTY() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: cmd/deploy_disabled.go ================================================ //go:build flavor_tiny || flavor_ntr package cmd import "fmt" func runDeploy(_ string) error { return fmt.Errorf("WebUI (--deploy) is not available in %s; please use the full nexttrace build", appBinName) } ================================================ FILE: cmd/deploy_full.go ================================================ //go:build !flavor_tiny && !flavor_ntr package cmd import ( "github.com/nxtrace/NTrace-core/server" ) func runDeploy(listenAddr string) error { return server.Run(listenAddr) } ================================================ FILE: cmd/flavor_full.go ================================================ //go:build !flavor_tiny && !flavor_ntr package cmd const ( appBinName = "nexttrace" enableWebUI = true enableGlobalping = true enableMTR = true enableMTU = true defaultMTR = false ) ================================================ FILE: cmd/flavor_ntr.go ================================================ //go:build flavor_ntr package cmd const ( appBinName = "ntr" enableWebUI = false enableGlobalping = false enableMTR = true enableMTU = false defaultMTR = true ) ================================================ FILE: cmd/flavor_tiny.go ================================================ //go:build flavor_tiny package cmd const ( appBinName = "nexttrace-tiny" enableWebUI = false enableGlobalping = false enableMTR = false enableMTU = true defaultMTR = false ) ================================================ FILE: cmd/globalping_disabled.go ================================================ //go:build flavor_tiny || flavor_ntr package cmd import ( "fmt" "os" "github.com/nxtrace/NTrace-core/trace" ) func handleGlobalpingTrace(_ *trace.GlobalpingOptions, _ *trace.Config) { fmt.Fprintf(os.Stderr, "--from (Globalping) is not available in %s; please use the full nexttrace build\n", appBinName) os.Exit(1) } ================================================ FILE: cmd/globalping_full.go ================================================ //go:build !flavor_tiny && !flavor_ntr package cmd import ( "context" "encoding/json" "fmt" "strings" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/printer" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/tracemap" "github.com/nxtrace/NTrace-core/util" ) func handleGlobalpingTrace(opts *trace.GlobalpingOptions, config *trace.Config) { ctx := context.Background() if config != nil && config.Context != nil { ctx = config.Context } res, measurement, err := trace.GlobalpingTraceroute(opts, config) if err != nil { fmt.Println(err) return } if !opts.DisableMaptrace && (util.StringInSlice(strings.ToUpper(opts.DataOrigin), []string{"LEOMOEAPI", "IPINFO", "IP-API.COM", "IPAPI.COM"})) { r, err := json.Marshal(res) if err != nil { fmt.Println(err) return } url, err := tracemap.GetMapUrlWithContext(ctx, string(r)) if err != nil { fmt.Println(err) return } res.TraceMapUrl = url } if opts.JSONPrint { r, err := json.Marshal(res) if err != nil { fmt.Println(err) return } fmt.Println(string(r)) return } if measurement == nil || len(measurement.Results) == 0 { fmt.Println(globalpingNoResultMessage(config.Lang)) return } fmt.Fprintln(color.Output, color.New(color.FgGreen, color.Bold).Sprintf("> %s", trace.GlobalpingFormatLocation(&measurement.Results[0]))) if opts.TablePrint { printer.TracerouteTablePrinter(res, opts.ClearScreen) } else { for i := range res.Hops { if opts.ClassicPrint { printer.ClassicPrinter(res, i) } else if opts.RawPrint { printer.EasyPrinter(res, i) } else { printer.RealtimePrinter(res, i) } } } if res.TraceMapUrl != "" { tracemap.PrintMapUrl(res.TraceMapUrl) } } func globalpingNoResultMessage(lang string) string { if strings.EqualFold(strings.TrimSpace(lang), "en") { return "Globalping returned no usable probe results; skipping output." } return "Globalping 未返回可用的探测结果,已跳过输出。" } ================================================ FILE: cmd/listen_info_test.go ================================================ package cmd import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestIsDigitsOnly(t *testing.T) { assert.True(t, isDigitsOnly("12345")) assert.False(t, isDigitsOnly("12a45")) assert.False(t, isDigitsOnly("")) } func TestBuildListenInfoPortOnly(t *testing.T) { info := buildListenInfo("8080") assert.Equal(t, "http://0.0.0.0:8080", info.Binding) assert.NotEmpty(t, info.Access) assert.True(t, strings.HasSuffix(info.Access, ":8080")) } func TestBuildListenInfoHostPort(t *testing.T) { info := buildListenInfo("192.0.2.1:9000") assert.Equal(t, "http://192.0.2.1:9000", info.Binding) assert.Equal(t, "http://192.0.2.1:9000", info.Access) } func TestBuildListenInfoKeepsInvalidInput(t *testing.T) { info := buildListenInfo("not a valid endpoint") assert.Equal(t, "not a valid endpoint", info.Binding) assert.Empty(t, info.Access) } ================================================ FILE: cmd/mtr_mode.go ================================================ package cmd import ( "context" "errors" "fmt" "io" "os" "os/signal" "strings" "syscall" "time" "github.com/nxtrace/NTrace-core/config" "github.com/nxtrace/NTrace-core/printer" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/util" ) const defaultMTRInternalTTLIntervalMs = 0 // MTR 模式下与其他输出/功能标志互斥的检查。 // 返回 true 表示存在冲突。 func checkMTRConflicts(flags map[string]bool) (conflict string, ok bool) { conflicts := []struct { name string set bool }{ {"--table", flags["table"]}, {"--classic", flags["classic"]}, {"--json", flags["json"]}, {"--output", flags["output"]}, {"--output-default", flags["outputDefault"]}, {"--route-path", flags["routePath"]}, {"--from", flags["from"]}, {"--fast-trace", flags["fastTrace"]}, {"--file", flags["file"]}, {"--deploy", flags["deploy"]}, } for _, c := range conflicts { if c.set { return c.name, false } } return "", true } // runMTRTUI 执行 MTR 交互式 TUI 模式。 // 当 stdin 为 TTY 时启用全屏 TUI(备用屏幕、按键控制); // 非 TTY 时降级为简单表格刷新。 func runMTRTUI(method trace.Method, conf trace.Config, hopIntervalMs int, maxPerHop int, domain string, dataOrigin string, showIPs bool, initialDisplayMode int) { if hopIntervalMs <= 0 { hopIntervalMs = 1000 } // Ctrl-C 优雅退出 sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() ctx, cancel := context.WithCancel(sigCtx) defer cancel() // 初始化 TUI 控制器 ui := newMTRUI(cancel, initialDisplayMode) ui.Enter() defer ui.Leave() // 按键读取协程(非 TTY 时内部 no-op) go ui.ReadKeysLoop(ctx) startTime := time.Now() target := conf.DstIP.String() // 解析源 IP:--source > --dev 推导 > udp dial fallback srcHost, _ := os.Hostname() if srcHost == "" { srcHost = "unknown-host" } srcIP := resolveSrcIP(conf) // 语言:默认为 "cn" lang := conf.Lang if lang == "" { lang = "cn" } // preferred API 信息(仅 LeoMoeAPI 且有结果时展示) apiInfo := buildAPIInfo(dataOrigin) roundConf := normalizeMTRTraceConfig(conf) opts := trace.MTROptions{ HopInterval: time.Duration(hopIntervalMs) * time.Millisecond, MaxPerHop: maxPerHop, IsResetRequested: ui.ConsumeRestartRequest, } // TTY 模式下使用 TUI 渲染器 + 暂停支持,非 TTY 使用简单表格 var onSnapshot trace.MTROnSnapshot if ui.IsTTY() { opts.IsPaused = ui.IsPaused onSnapshot = printer.MTRTUIPrinter(target, domain, target, config.Version, startTime, srcHost, srcIP, lang, apiInfo, showIPs, ui.IsPaused, ui.CurrentDisplayMode, ui.CurrentNameMode, ui.IsMPLSDisabled) } else { onSnapshot = func(iteration int, stats []trace.MTRHopStat) { printer.MTRTablePrinter(stats, iteration, ui.CurrentDisplayMode(), ui.CurrentNameMode(), lang, showIPs) } } err := trace.RunMTR(ctx, method, roundConf, opts, onSnapshot) if err != nil && !errors.Is(err, context.Canceled) { // 离开备用屏幕后再打印错误 fmt.Println(err) } } // runMTRReport 执行 MTR 非全屏报告模式(对齐 mtr -rzw 风格)。 // 探测完 maxPerHop 后一次性输出最终统计到 stdout,不进入 alternate screen。 func runMTRReport(method trace.Method, conf trace.Config, hopIntervalMs int, maxPerHop int, domain string, dataOrigin string, wide bool, showIPs bool) { if hopIntervalMs <= 0 { hopIntervalMs = 1000 } if maxPerHop <= 0 { maxPerHop = 10 } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() startTime := time.Now() srcHost, _ := os.Hostname() if srcHost == "" { srcHost = "unknown-host" } lang := conf.Lang if lang == "" { lang = "cn" } // 最终快照 var finalStats []trace.MTRHopStat onSnapshot := func(iteration int, stats []trace.MTRHopStat) { finalStats = stats } opts := trace.MTROptions{ HopInterval: time.Duration(hopIntervalMs) * time.Millisecond, MaxPerHop: maxPerHop, } roundConf := normalizeMTRReportConfig(conf, wide) err := trace.RunMTR(ctx, method, roundConf, opts, onSnapshot) if err != nil && !errors.Is(err, context.Canceled) { fmt.Println(err) return } if len(finalStats) == 0 { fmt.Println("No data collected.") return } printer.MTRReportPrint(finalStats, printer.MTRReportOptions{ StartTime: startTime, SrcHost: srcHost, Wide: wide, ShowIPs: showIPs, Lang: lang, }) } // runMTRRaw 执行 MTR 原始流式模式(逐事件输出,'|' 分隔)。 // 行格式固定为 12 列: // ttl|ip|ptr|rtt|asn|country|prov|city|district|owner|lat|lng func runMTRRaw(method trace.Method, conf trace.Config, hopIntervalMs int, maxPerHop int, dataOrigin string) { if hopIntervalMs <= 0 { hopIntervalMs = 1000 } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() opts := trace.MTRRawOptions{ HopInterval: time.Duration(hopIntervalMs) * time.Millisecond, MaxPerHop: maxPerHop, } roundConf := normalizeMTRTraceConfig(conf) if apiLine := buildRawAPIInfoLine(dataOrigin); apiLine != "" { fmt.Println(apiLine) } err := trace.RunMTRRaw(ctx, method, roundConf, opts, func(rec trace.MTRRawRecord) { fmt.Println(printer.FormatMTRRawLine(rec)) }) if err != nil && !errors.Is(err, context.Canceled) { writeMTRRawRuntimeError(os.Stderr, err) } } func normalizeMTRTraceConfig(conf trace.Config) trace.Config { normalized := conf normalized.TTLInterval = defaultMTRInternalTTLIntervalMs return normalized } func normalizeMTRReportConfig(conf trace.Config, wide bool) trace.Config { normalized := normalizeMTRTraceConfig(conf) if wide { return normalized } normalized.IPGeoSource = nil if normalized.RDNS { normalized.AlwaysWaitRDNS = true } return normalized } func writeMTRRawRuntimeError(w io.Writer, err error) { if err == nil || w == nil { return } _, _ = fmt.Fprintln(w, err) } // resolveSrcIP 按优先级解析源 IP:--source > --dev 推导 > udp dial fallback。 // 保证与目标 IP 族匹配,失败时返回 "unknown"。 func resolveSrcIP(conf trace.Config) string { sourceDevice := conf.SourceDevice if sourceDevice == "" { sourceDevice = util.SrcDev } resolved, _, err := resolveConfiguredSrcAddr(conf.DstIP, conf.SrcAddr, sourceDevice) if err == nil && strings.TrimSpace(resolved) != "" { return resolved } return "unknown" } // buildAPIInfo 生成首行 preferred API 扩展信息(纯文本,不含 ANSI;仅 LeoMoeAPI)。 func buildAPIInfo(dataOrigin string) string { if !strings.EqualFold(dataOrigin, "LeoMoeAPI") { return "" } meta := util.GetFastIPMetaCache() if meta.IP == "" { return "" } nodeName := meta.NodeName if nodeName == "" { nodeName = "Unknown" } return fmt.Sprintf("preferred API IP: %s[%s]", nodeName, meta.IP) } func buildRawAPIInfoLine(dataOrigin string) string { if !strings.EqualFold(dataOrigin, "LeoMoeAPI") { return "" } meta := util.GetFastIPMetaCache() if meta.IP == "" { return "" } nodeName := strings.TrimSpace(meta.NodeName) if nodeName == "" { nodeName = "Unknown" } latency := strings.TrimSpace(meta.Latency) if latency == "" { return fmt.Sprintf("[NextTrace API] preferred API IP - [%s] - %s", meta.IP, nodeName) } return fmt.Sprintf("[NextTrace API] preferred API IP - [%s] - %sms - %s", meta.IP, latency, nodeName) } ================================================ FILE: cmd/mtr_mode_test.go ================================================ package cmd import ( "bytes" "context" "errors" "os" "sync/atomic" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/util" ) func TestCheckMTRConflicts_NoConflict(t *testing.T) { flags := map[string]bool{ "table": false, "raw": false, "classic": false, "json": false, "output": false, "routePath": false, "from": false, "fastTrace": false, "file": false, "deploy": false, } conflict, ok := checkMTRConflicts(flags) if !ok { t.Errorf("expected no conflict, got %q", conflict) } } func TestCheckMTRConflicts_Table(t *testing.T) { flags := map[string]bool{ "table": true, "raw": false, "classic": false, "json": false, "output": false, "routePath": false, "from": false, "fastTrace": false, "file": false, "deploy": false, } conflict, ok := checkMTRConflicts(flags) if ok { t.Fatal("expected conflict with --table") } if conflict != "--table" { t.Errorf("conflict = %q, want --table", conflict) } } func TestCheckMTRConflicts_JSON(t *testing.T) { flags := map[string]bool{ "table": false, "raw": false, "classic": false, "json": true, "output": false, "outputDefault": false, "routePath": false, "from": false, "fastTrace": false, "file": false, "deploy": false, } conflict, ok := checkMTRConflicts(flags) if ok { t.Fatal("expected conflict with --json") } if conflict != "--json" { t.Errorf("conflict = %q, want --json", conflict) } } func TestCheckMTRConflicts_OutputDefault(t *testing.T) { flags := map[string]bool{ "table": false, "raw": false, "classic": false, "json": false, "output": false, "outputDefault": true, "routePath": false, "from": false, "fastTrace": false, "file": false, "deploy": false, } conflict, ok := checkMTRConflicts(flags) if ok { t.Fatal("expected conflict with --output-default") } if conflict != "--output-default" { t.Errorf("conflict = %q, want --output-default", conflict) } } func TestCheckMTRConflicts_FastTrace(t *testing.T) { flags := map[string]bool{ "table": false, "raw": false, "classic": false, "json": false, "output": false, "routePath": false, "from": false, "fastTrace": true, "file": false, "deploy": false, } conflict, ok := checkMTRConflicts(flags) if ok { t.Fatal("expected conflict with --fast-trace") } if conflict != "--fast-trace" { t.Errorf("conflict = %q, want --fast-trace", conflict) } } func TestCheckMTRConflicts_Deploy(t *testing.T) { flags := map[string]bool{ "table": false, "raw": false, "classic": false, "json": false, "output": false, "routePath": false, "from": false, "fastTrace": false, "file": false, "deploy": true, } conflict, ok := checkMTRConflicts(flags) if ok { t.Fatal("expected conflict with --deploy") } if conflict != "--deploy" { t.Errorf("conflict = %q, want --deploy", conflict) } } func TestCheckMTRConflicts_From(t *testing.T) { flags := map[string]bool{ "table": false, "raw": false, "classic": false, "json": false, "output": false, "routePath": false, "from": true, "fastTrace": false, "file": false, "deploy": false, } conflict, ok := checkMTRConflicts(flags) if ok { t.Fatal("expected conflict with --from") } if conflict != "--from" { t.Errorf("conflict = %q, want --from", conflict) } } func TestCheckMTRConflicts_AllConflicts(t *testing.T) { // 多个冲突标志同时设置时,应返回第一个匹配的 flags := map[string]bool{ "table": true, "raw": true, "classic": true, "json": true, "output": true, "routePath": true, "from": true, "fastTrace": true, "file": true, "deploy": true, } _, ok := checkMTRConflicts(flags) if ok { t.Fatal("expected conflict when all flags are set") } } func TestCheckMTRConflicts_RawAllowed(t *testing.T) { flags := map[string]bool{ "table": false, "raw": true, "classic": false, "json": false, "output": false, "routePath": false, "from": false, "fastTrace": false, "file": false, "deploy": false, } if conflict, ok := checkMTRConflicts(flags); !ok { t.Fatalf("raw should be allowed in MTR mode, got conflict=%q", conflict) } } func TestChooseMTRRunMode_RawPriority(t *testing.T) { if mode := chooseMTRRunMode(true, true); mode != mtrRunRaw { t.Fatalf("raw should take precedence over report, got mode=%v", mode) } if mode := chooseMTRRunMode(false, true); mode != mtrRunReport { t.Fatalf("report mode mismatch, got mode=%v", mode) } if mode := chooseMTRRunMode(false, false); mode != mtrRunTUI { t.Fatalf("tui mode mismatch, got mode=%v", mode) } } func TestDeriveMTRRoundParams_DefaultsAndOverrides(t *testing.T) { tests := []struct { name string effectiveReport bool queriesExplicit bool numMeasurements int ttlTimeExplicit bool ttlInterval int wantRounds int wantInterval int }{ { name: "report default rounds", effectiveReport: true, queriesExplicit: false, numMeasurements: 3, ttlTimeExplicit: false, ttlInterval: 50, wantRounds: 10, wantInterval: 1000, }, { name: "report explicit q", effectiveReport: true, queriesExplicit: true, numMeasurements: 7, ttlTimeExplicit: true, ttlInterval: 250, wantRounds: 7, wantInterval: 250, }, { name: "tui default infinite", effectiveReport: false, queriesExplicit: false, numMeasurements: 9, ttlTimeExplicit: false, ttlInterval: 10, wantRounds: 0, wantInterval: 1000, }, { name: "tui explicit q", effectiveReport: false, queriesExplicit: true, numMeasurements: 4, ttlTimeExplicit: true, ttlInterval: 1200, wantRounds: 4, wantInterval: 1200, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotRounds, gotInterval := deriveMTRRoundParams( tt.effectiveReport, tt.queriesExplicit, tt.numMeasurements, tt.ttlTimeExplicit, tt.ttlInterval, ) if gotRounds != tt.wantRounds || gotInterval != tt.wantInterval { t.Fatalf("got rounds=%d interval=%d, want rounds=%d interval=%d", gotRounds, gotInterval, tt.wantRounds, tt.wantInterval) } }) } } func TestDeriveMTRProbeParams_DefaultsAndOverrides(t *testing.T) { tests := []struct { name string effectiveReport bool queriesExplicit bool numMeasurements int ttlTimeExplicit bool ttlInterval int wantMaxPerHop int wantHopIntervalMs int }{ { name: "report default", effectiveReport: true, wantMaxPerHop: 10, wantHopIntervalMs: 1000, }, { name: "tui default (unlimited)", effectiveReport: false, wantMaxPerHop: 0, wantHopIntervalMs: 1000, }, { name: "report explicit q", effectiveReport: true, queriesExplicit: true, numMeasurements: 20, wantMaxPerHop: 20, wantHopIntervalMs: 1000, }, { name: "explicit -i", effectiveReport: true, ttlTimeExplicit: true, ttlInterval: 2000, wantMaxPerHop: 10, wantHopIntervalMs: 2000, }, { name: "tui explicit q", effectiveReport: false, queriesExplicit: true, numMeasurements: 5, wantMaxPerHop: 5, wantHopIntervalMs: 1000, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotMaxPerHop, gotHopIntervalMs := deriveMTRProbeParams( tt.effectiveReport, tt.queriesExplicit, tt.numMeasurements, tt.ttlTimeExplicit, tt.ttlInterval, ) if gotMaxPerHop != tt.wantMaxPerHop || gotHopIntervalMs != tt.wantHopIntervalMs { t.Fatalf("got maxPerHop=%d hopIntervalMs=%d, want maxPerHop=%d hopIntervalMs=%d", gotMaxPerHop, gotHopIntervalMs, tt.wantMaxPerHop, tt.wantHopIntervalMs) } }) } } func TestNormalizeMTRTraceConfig_UsesMTRInternalTTLInterval50(t *testing.T) { original := trace.Config{ TTLInterval: 1200, PacketInterval: 25, Timeout: 3, MaxHops: 18, BeginHop: 4, } normalized := normalizeMTRTraceConfig(original) if normalized.TTLInterval != defaultMTRInternalTTLIntervalMs { t.Fatalf("normalized TTLInterval = %d, want %d", normalized.TTLInterval, defaultMTRInternalTTLIntervalMs) } if normalized.PacketInterval != original.PacketInterval { t.Fatalf("normalized PacketInterval = %d, want %d", normalized.PacketInterval, original.PacketInterval) } if normalized.Timeout != original.Timeout || normalized.MaxHops != original.MaxHops || normalized.BeginHop != original.BeginHop { t.Fatalf("unexpected mutation of other fields: %+v", normalized) } if original.TTLInterval != 1200 { t.Fatalf("original config was modified in place: %+v", original) } } func TestDefaultConstants_NormalVsMTR(t *testing.T) { if defaultPacketIntervalMs != 50 { t.Fatalf("defaultPacketIntervalMs = %d, want 50", defaultPacketIntervalMs) } if defaultTracerouteTTLIntervalMs != 300 { t.Fatalf("defaultTracerouteTTLIntervalMs = %d, want 300", defaultTracerouteTTLIntervalMs) } if defaultMTRInternalTTLIntervalMs != 0 { t.Fatalf("defaultMTRInternalTTLIntervalMs = %d, want 0", defaultMTRInternalTTLIntervalMs) } } func TestNormalizeMTRReportConfig_NonWideDisablesGeoAndKeepsRDNS(t *testing.T) { geoSource := func(_ string, _ time.Duration, _ string, _ bool) (*ipgeo.IPGeoData, error) { return &ipgeo.IPGeoData{}, nil } original := trace.Config{ TTLInterval: 1200, IPGeoSource: geoSource, RDNS: true, AlwaysWaitRDNS: false, PacketInterval: 25, Timeout: 3, MaxHops: 18, } normalized := normalizeMTRReportConfig(original, false) if normalized.TTLInterval != defaultMTRInternalTTLIntervalMs { t.Fatalf("normalized TTLInterval = %d, want %d", normalized.TTLInterval, defaultMTRInternalTTLIntervalMs) } if normalized.IPGeoSource != nil { t.Fatal("non-wide report should disable IPGeoSource") } if !normalized.RDNS { t.Fatal("non-wide report should preserve RDNS=true") } if !normalized.AlwaysWaitRDNS { t.Fatal("non-wide report should force AlwaysWaitRDNS when RDNS is enabled") } if normalized.PacketInterval != original.PacketInterval || normalized.Timeout != original.Timeout || normalized.MaxHops != original.MaxHops { t.Fatalf("unexpected mutation of other fields: %+v", normalized) } if original.IPGeoSource == nil || original.TTLInterval != 1200 || original.AlwaysWaitRDNS { t.Fatalf("original config was modified in place: %+v", original) } } func TestNormalizeMTRReportConfig_NonWideRespectsNoRDNS(t *testing.T) { geoSource := func(_ string, _ time.Duration, _ string, _ bool) (*ipgeo.IPGeoData, error) { return &ipgeo.IPGeoData{}, nil } original := trace.Config{ TTLInterval: 1200, IPGeoSource: geoSource, RDNS: false, AlwaysWaitRDNS: false, } normalized := normalizeMTRReportConfig(original, false) if normalized.IPGeoSource != nil { t.Fatal("non-wide report should disable IPGeoSource") } if normalized.RDNS { t.Fatal("non-wide report should preserve RDNS=false") } if normalized.AlwaysWaitRDNS { t.Fatal("non-wide report should not force AlwaysWaitRDNS when RDNS is disabled") } } func TestNormalizeMTRReportConfig_WidePreservesGeoSettings(t *testing.T) { geoSource := func(_ string, _ time.Duration, _ string, _ bool) (*ipgeo.IPGeoData, error) { return &ipgeo.IPGeoData{}, nil } original := trace.Config{ TTLInterval: 1200, IPGeoSource: geoSource, RDNS: true, AlwaysWaitRDNS: false, PacketInterval: 25, } normalized := normalizeMTRReportConfig(original, true) if normalized.TTLInterval != defaultMTRInternalTTLIntervalMs { t.Fatalf("normalized TTLInterval = %d, want %d", normalized.TTLInterval, defaultMTRInternalTTLIntervalMs) } if normalized.IPGeoSource == nil { t.Fatal("wide report should preserve IPGeoSource") } if !normalized.RDNS { t.Fatal("wide report should preserve RDNS=true") } if normalized.AlwaysWaitRDNS != original.AlwaysWaitRDNS { t.Fatalf("wide report should preserve AlwaysWaitRDNS, got %v want %v", normalized.AlwaysWaitRDNS, original.AlwaysWaitRDNS) } if original.IPGeoSource == nil || original.TTLInterval != 1200 { t.Fatalf("original config was modified in place: %+v", original) } } func TestBuildRawAPIInfoLine_LeoMoeAPI(t *testing.T) { oldCache := util.GetFastIPCache() oldMeta := util.GetFastIPMetaCache() t.Cleanup(func() { util.SetFastIPCacheState(oldCache, oldMeta) }) util.SetFastIPCacheState("", util.FastIPMeta{ IP: "2403:18c0:1001:462:dd:38ff:fe48:e0c5", Latency: "21.33", NodeName: "DMIT.NRT", }) got := buildRawAPIInfoLine("LeoMoeAPI") want := "[NextTrace API] preferred API IP - [2403:18c0:1001:462:dd:38ff:fe48:e0c5] - 21.33ms - DMIT.NRT" if got != want { t.Fatalf("buildRawAPIInfoLine() = %q, want %q", got, want) } } func TestWriteMTRRawRuntimeError_WritesToProvidedWriter(t *testing.T) { var buf bytes.Buffer err := errors.New("hop timeout") writeMTRRawRuntimeError(&buf, err) if got := buf.String(); got != err.Error()+"\n" { t.Fatalf("writeMTRRawRuntimeError() wrote %q", got) } } // --------------------------------------------------------------------------- // ParseMTRKey 测试 // --------------------------------------------------------------------------- func TestParseMTRKey_Quit(t *testing.T) { for _, b := range []byte{'q', 'Q', 0x03} { if got := ParseMTRKey(b); got != "quit" { t.Errorf("ParseMTRKey(%q) = %q, want %q", b, got, "quit") } } } func TestParseMTRKey_Pause(t *testing.T) { for _, b := range []byte{'p', 'P'} { if got := ParseMTRKey(b); got != "pause" { t.Errorf("ParseMTRKey(%q) = %q, want %q", b, got, "pause") } } } func TestParseMTRKey_Resume(t *testing.T) { if got := ParseMTRKey(' '); got != "resume" { t.Errorf("ParseMTRKey(' ') = %q, want %q", got, "resume") } } func TestParseMTRKey_Unknown(t *testing.T) { for _, b := range []byte{'x', 'z', '1', '\n'} { if got := ParseMTRKey(b); got != "" { t.Errorf("ParseMTRKey(%q) = %q, want empty", b, got) } } } // --------------------------------------------------------------------------- // r 键重置测试 // --------------------------------------------------------------------------- func TestParseMTRKey_Restart(t *testing.T) { for _, b := range []byte{'r', 'R'} { if got := ParseMTRKey(b); got != "restart" { t.Errorf("ParseMTRKey(%q) = %q, want %q", b, got, "restart") } } } func TestParseMTRKey_DisplayMode(t *testing.T) { for _, b := range []byte{'y', 'Y'} { if got := ParseMTRKey(b); got != "display_mode" { t.Errorf("ParseMTRKey(%q) = %q, want %q", b, got, "display_mode") } } } func TestParseMTRKey_Unknown_IncludesY(t *testing.T) { // y/Y 现在已有映射,不再返回空 for _, b := range []byte{'x', 'z', '1', '\n'} { if got := ParseMTRKey(b); got != "" { t.Errorf("ParseMTRKey(%q) = %q, want empty", b, got) } } } func TestMTRUI_ConsumeRestartRequest(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() ui := newMTRUI(cancel, 0) // 初始状态:无重置请求 if ui.ConsumeRestartRequest() { t.Error("expected no restart request initially") } // 模拟按下 r 键 atomic.StoreInt32(&ui.restartReq, 1) // 第一次消费应返回 true if !ui.ConsumeRestartRequest() { t.Error("expected restart request after setting flag") } // 第二次消费应返回 false(已被消费) if ui.ConsumeRestartRequest() { t.Error("expected restart request to be consumed") } _ = ctx // suppress unused } // --------------------------------------------------------------------------- // 显示模式切换测试 // --------------------------------------------------------------------------- func TestMTRUI_DisplayModeCycle(t *testing.T) { _, cancel := context.WithCancel(context.Background()) defer cancel() ui := newMTRUI(cancel, 0) // 初始模式为 0 if got := ui.CurrentDisplayMode(); got != 0 { t.Errorf("initial display mode = %d, want 0", got) } // 循环切换 0 → 1 → 2 → 3 → 4 → 0 ui.CycleDisplayMode() if got := ui.CurrentDisplayMode(); got != 1 { t.Errorf("after 1st cycle: display mode = %d, want 1", got) } ui.CycleDisplayMode() if got := ui.CurrentDisplayMode(); got != 2 { t.Errorf("after 2nd cycle: display mode = %d, want 2", got) } ui.CycleDisplayMode() if got := ui.CurrentDisplayMode(); got != 3 { t.Errorf("after 3rd cycle: display mode = %d, want 3", got) } ui.CycleDisplayMode() if got := ui.CurrentDisplayMode(); got != 4 { t.Errorf("after 4th cycle: display mode = %d, want 4", got) } ui.CycleDisplayMode() if got := ui.CurrentDisplayMode(); got != 0 { t.Errorf("after 5th cycle: display mode = %d, want 0 (wrap)", got) } } func TestMTRUI_DisplayModeNotResetByRestart(t *testing.T) { _, cancel := context.WithCancel(context.Background()) defer cancel() ui := newMTRUI(cancel, 0) // 设置显示模式为 2 ui.CycleDisplayMode() // 0 → 1 ui.CycleDisplayMode() // 1 → 2 // 模拟重置请求 atomic.StoreInt32(&ui.restartReq, 1) ui.ConsumeRestartRequest() // 显示模式不应被重置 if got := ui.CurrentDisplayMode(); got != 2 { t.Errorf("display mode after restart = %d, want 2 (unchanged)", got) } } // --------------------------------------------------------------------------- // 初始显示模式测试 // --------------------------------------------------------------------------- func TestMTRUI_InitialDisplayMode_FromFlag(t *testing.T) { for _, mode := range []int{0, 1, 2, 3, 4} { _, cancel := context.WithCancel(context.Background()) ui := newMTRUI(cancel, mode) if got := ui.CurrentDisplayMode(); got != mode { t.Errorf("initialDisplayMode=%d: CurrentDisplayMode() = %d", mode, got) } cancel() } } func TestMTRUI_InitialDisplayMode_CycleFromNonZero(t *testing.T) { _, cancel := context.WithCancel(context.Background()) defer cancel() ui := newMTRUI(cancel, 3) // start at Owner // 3 → 4 ui.CycleDisplayMode() if got := ui.CurrentDisplayMode(); got != 4 { t.Errorf("after cycle from 3: got %d, want 4", got) } // 4 → 0 (wrap) ui.CycleDisplayMode() if got := ui.CurrentDisplayMode(); got != 0 { t.Errorf("after cycle from 4: got %d, want 0 (wrap)", got) } } // --------------------------------------------------------------------------- // CheckTTY / TTY 判定测试 // --------------------------------------------------------------------------- func TestCheckTTY_PipeFd(t *testing.T) { // 管道 fd 不是终端,CheckTTY 应返回 false r, w, err := os.Pipe() if err != nil { t.Fatal(err) } defer r.Close() defer w.Close() if CheckTTY(int(r.Fd())) { t.Error("pipe read-end should not be a TTY") } if CheckTTY(int(w.Fd())) { t.Error("pipe write-end should not be a TTY") } } func TestCheckTTY_StdoutRedirected(t *testing.T) { // 模拟 "stdin 是 TTY, stdout 被重定向" 场景: // 两个 fd 中至少一个非终端 → 应返回 false r, w, err := os.Pipe() if err != nil { t.Fatal(err) } defer r.Close() defer w.Close() // 即使 stdin fd 碰巧是终端(CI 中通常不是), // 只要 stdout fd 是管道就应为 false if CheckTTY(int(os.Stdin.Fd()), int(w.Fd())) { t.Error("CheckTTY(stdin, pipe) should be false when stdout is redirected") } } func TestCheckTTY_EmptyFds(t *testing.T) { // 空参数 → vacuously true if !CheckTTY() { t.Error("CheckTTY() with no args should be true") } } // --------------------------------------------------------------------------- // n 键 NameMode 切换测试 // --------------------------------------------------------------------------- func TestParseMTRKey_NameToggle(t *testing.T) { for _, b := range []byte{'n', 'N'} { if got := ParseMTRKey(b); got != "name_toggle" { t.Errorf("ParseMTRKey(%q) = %q, want %q", b, got, "name_toggle") } } } func TestMTRUI_NameModeToggle(t *testing.T) { _, cancel := context.WithCancel(context.Background()) defer cancel() ui := newMTRUI(cancel, 0) // 初始为 0 (PTRorIP) if got := ui.CurrentNameMode(); got != 0 { t.Errorf("initial name mode = %d, want 0", got) } // 切换 → 1 (IPOnly) ui.ToggleNameMode() if got := ui.CurrentNameMode(); got != 1 { t.Errorf("after toggle: name mode = %d, want 1", got) } // 再切换 → 0 (PTRorIP) ui.ToggleNameMode() if got := ui.CurrentNameMode(); got != 0 { t.Errorf("after 2nd toggle: name mode = %d, want 0", got) } } func TestMTRUI_NameModeNotResetByRestart(t *testing.T) { _, cancel := context.WithCancel(context.Background()) defer cancel() ui := newMTRUI(cancel, 0) // 设置 nameMode 为 1 ui.ToggleNameMode() if got := ui.CurrentNameMode(); got != 1 { t.Fatalf("name mode = %d, want 1", got) } // 模拟重置请求 atomic.StoreInt32(&ui.restartReq, 1) ui.ConsumeRestartRequest() // nameMode 不应被重置 if got := ui.CurrentNameMode(); got != 1 { t.Errorf("name mode after restart = %d, want 1 (unchanged)", got) } } // --------------------------------------------------------------------------- // mtrInputParser 测试 // --------------------------------------------------------------------------- // feedAll 向解析器喂入完整字节流,返回所有非 None 动作。 func feedAll(p *mtrInputParser, data []byte) []mtrInputAction { var actions []mtrInputAction for _, b := range data { a := p.Feed(b) if a != mtrActionNone { actions = append(actions, a) } } return actions } func TestMTRInputParser_IgnoresX10MouseSequence(t *testing.T) { // X10 mouse: ESC [ M Cb Cx Cy —— 6 字节 // 关键:Cb/Cx/Cy 可以是 0x20(空格),不应触发 resume var p mtrInputParser // 模拟点击事件:button=0(0x20), x=10(0x2A), y=5(0x25) seq := []byte{0x1B, '[', 'M', 0x20, 0x2A, 0x25} actions := feedAll(&p, seq) if len(actions) != 0 { t.Errorf("X10 mouse should produce no actions, got %v", actions) } // 确认解析器回到 ground:后续 'q' 应正常识别 a := p.Feed('q') if a != mtrActionQuit { t.Errorf("after X10 mouse, 'q' should produce quit, got %d", a) } } func TestMTRInputParser_IgnoresSGRMouseSequence(t *testing.T) { // SGR mouse: ESC [ < 0;10;5 M (按下) 或 ...m (释放) var p mtrInputParser press := []byte{0x1B, '[', '<', '0', ';', '1', '0', ';', '5', 'M'} release := []byte{0x1B, '[', '<', '0', ';', '1', '0', ';', '5', 'm'} actions := feedAll(&p, press) if len(actions) != 0 { t.Errorf("SGR mouse press should produce no actions, got %v", actions) } actions = feedAll(&p, release) if len(actions) != 0 { t.Errorf("SGR mouse release should produce no actions, got %v", actions) } } func TestMTRInputParser_IgnoresFocusSequence(t *testing.T) { var p mtrInputParser // Focus in: ESC [ I focusIn := []byte{0x1B, '[', 'I'} actions := feedAll(&p, focusIn) if len(actions) != 0 { t.Errorf("focus-in should produce no actions, got %v", actions) } // Focus out: ESC [ O focusOut := []byte{0x1B, '[', 'O'} actions = feedAll(&p, focusOut) if len(actions) != 0 { t.Errorf("focus-out should produce no actions, got %v", actions) } } func TestMTRInputParser_RecognizesNormalKeysAfterEscapeNoise(t *testing.T) { var p mtrInputParser // 先喂入一堆 escape 噪音(X10 mouse + focus + CSI arrow),然后喂正常键 noise := []byte{ 0x1B, '[', 'M', 0x20, 0x30, 0x30, // X10 mouse 0x1B, '[', 'I', // focus in 0x1B, '[', 'A', // CSI arrow up } noiseActions := feedAll(&p, noise) if len(noiseActions) != 0 { t.Errorf("noise should produce no actions, got %v", noiseActions) } // 现在喂入正常快捷键序列 keys := []byte{'p', ' ', 'r', 'y', 'n', 'q'} expected := []mtrInputAction{ mtrActionPause, mtrActionResume, mtrActionRestart, mtrActionDisplayMode, mtrActionNameToggle, mtrActionQuit, } actions := feedAll(&p, keys) if len(actions) != len(expected) { t.Fatalf("expected %d actions, got %d: %v", len(expected), len(actions), actions) } for i, want := range expected { if actions[i] != want { t.Errorf("action[%d] = %d, want %d", i, actions[i], want) } } } func TestMTRInputParser_SS3Ignored(t *testing.T) { // SS3 F (PF1 key): ESC O P var p mtrInputParser seq := []byte{0x1B, 'O', 'P'} actions := feedAll(&p, seq) if len(actions) != 0 { t.Errorf("SS3 sequence should produce no actions, got %v", actions) } } func TestMTRInputParser_OSCIgnored(t *testing.T) { // OSC title: ESC ] 0 ; t i t l e BEL var p mtrInputParser seq := []byte{0x1B, ']', '0', ';', 't', 'i', 't', 'l', 'e', 0x07} actions := feedAll(&p, seq) if len(actions) != 0 { t.Errorf("OSC sequence should produce no actions, got %v", actions) } } func TestMTRInputParser_BracketedPasteCSISwallowed(t *testing.T) { // 验证 bracketed paste 开始序列 ESC [ 2 0 0 ~ 被解析器吞掉(作为 CSI), // 且序列终止后后续字节正常回到 ground 状态处理。 // 真正的 bracketed paste 防护在 disableTerminalInputModes 已关闭 2004 模式, // 这里仅测试 CSI 终止符 '~' 之后 parser 恢复 ground 的行为。 var p mtrInputParser seq := []byte{ 0x1B, '[', '2', '0', '0', '~', // CSI 2 0 0 ~ → 被吞掉 'h', 'e', 'l', 'l', 'o', ' ', 'q', // 后续字节回到 ground 正常处理 } actions := feedAll(&p, seq) // CSI "200~" 终止在 '~',之后: // 'h','e','l','l','o' → 无映射(mtrActionNone) // ' ' → resume // 'q' → quit if len(actions) < 2 { t.Errorf("expected at least resume+quit from post-CSI bytes, got %d actions: %v", len(actions), actions) } // 验证最后两个 action 是 resume 和 quit if len(actions) >= 2 { got := actions[len(actions)-2:] if got[0] != mtrActionResume { t.Errorf("second-to-last action: want resume, got %d", got[0]) } if got[1] != mtrActionQuit { t.Errorf("last action: want quit, got %d", got[1]) } } } ================================================ FILE: cmd/mtr_ui.go ================================================ package cmd import ( "context" "fmt" "io" "os" "sync/atomic" "golang.org/x/term" ) // --------------------------------------------------------------------------- // MTR 交互式 TUI 控制器 // --------------------------------------------------------------------------- // mtrUI 管理终端交互状态:备份屏幕、raw mode、按键处理。 type mtrUI struct { isTTY bool oldState *term.State // raw mode 之前的终端状态 paused int32 // 0=running, 1=paused(atomic) restartReq int32 // 1=请求重置统计(atomic) displayMode int32 // 显示模式 0-4(atomic) nameMode int32 // Host 基础显示 0=PTR/IP, 1=IP only(atomic) disableMPLS int32 // 0=显示 MPLS, 1=隐藏 MPLS(atomic) cancel context.CancelFunc } // newMTRUI 创建 TUI 控制器。cancel 是用于退出 MTR 的 context cancel 函数。 // initialDisplayMode 设置 TUI 初始显示模式 (0-4)。 // stdin 和 stdout 都必须是终端才会启用交互式 TUI。 func newMTRUI(cancel context.CancelFunc, initialDisplayMode int) *mtrUI { return &mtrUI{ isTTY: term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())), cancel: cancel, displayMode: int32(initialDisplayMode), } } // IsTTY 返回 stdin 和 stdout 是否都是终端。 func (u *mtrUI) IsTTY() bool { return u.isTTY } // CheckTTY 检查给定的 fd 是否都是终端(可测试)。 func CheckTTY(fds ...int) bool { for _, fd := range fds { if !term.IsTerminal(fd) { return false } } return true } // IsPaused 返回当前是否处于暂停状态(供 MTROptions.IsPaused 使用)。 func (u *mtrUI) IsPaused() bool { return atomic.LoadInt32(&u.paused) == 1 } // CycleDisplayMode 循环切换显示模式 (0 → 1 → 2 → 3 → 4 → 0)。 func (u *mtrUI) CycleDisplayMode() { for { old := atomic.LoadInt32(&u.displayMode) next := (old + 1) % 5 if atomic.CompareAndSwapInt32(&u.displayMode, old, next) { return } } } // CurrentDisplayMode 返回当前显示模式 (0-4)。 func (u *mtrUI) CurrentDisplayMode() int { return int(atomic.LoadInt32(&u.displayMode)) } // ToggleNameMode 在 PTR/IP (0) 和 IP only (1) 之间切换。 func (u *mtrUI) ToggleNameMode() int32 { for { old := atomic.LoadInt32(&u.nameMode) next := int32(1) - old // 0→1, 1→0 if atomic.CompareAndSwapInt32(&u.nameMode, old, next) { return next } } } // CurrentNameMode 返回当前 Host 基础显示模式 (0=PTR/IP, 1=IP only)。 func (u *mtrUI) CurrentNameMode() int { return int(atomic.LoadInt32(&u.nameMode)) } // ToggleMPLS 在显示 MPLS (0) 和隐藏 MPLS (1) 之间切换。 func (u *mtrUI) ToggleMPLS() { for { old := atomic.LoadInt32(&u.disableMPLS) next := int32(1) - old // 0→1, 1→0 if atomic.CompareAndSwapInt32(&u.disableMPLS, old, next) { return } } } // IsMPLSDisabled 返回当前是否隐藏 MPLS 显示。 func (u *mtrUI) IsMPLSDisabled() bool { return atomic.LoadInt32(&u.disableMPLS) != 0 } // --------------------------------------------------------------------------- // 终端模式关闭序列(幂等) // --------------------------------------------------------------------------- // disableTerminalInputModes 向 stdout 写入显式关闭序列, // 确保鼠标事件、焦点事件、bracketed paste 等不会污染 MTR 输入。 // 在 Enter() 和 Leave() 中均调用,实现幂等防御。 func disableTerminalInputModes() { seqs := []string{ "\033[?1000l", // 关闭 X10 mouse "\033[?1002l", // 关闭 button-event mouse "\033[?1003l", // 关闭 any-event mouse "\033[?1006l", // 关闭 SGR extended mouse "\033[?1015l", // 关闭 urxvt mouse "\033[?1004l", // 关闭 focus in/out "\033[?2004l", // 关闭 bracketed paste } for _, s := range seqs { os.Stdout.WriteString(s) } } // Enter 进入交互模式:切换到备用屏幕缓冲区、隐藏光标、开启 raw mode。 // 非 TTY 时为 no-op。 func (u *mtrUI) Enter() { if !u.isTTY { return } // 备用屏幕缓冲区 os.Stdout.WriteString("\033[?1049h") // 隐藏光标 os.Stdout.WriteString("\033[?25l") // 防御:关闭可能被外部残留的鼠标/焦点/paste 模式 disableTerminalInputModes() // raw mode if oldState, err := term.MakeRaw(int(os.Stdin.Fd())); err == nil { u.oldState = oldState } else { // TUI 在 cooked mode 下无法正确处理按键,输出警告 io.WriteString(os.Stderr, fmt.Sprintf("warning: failed to enable raw mode: %v\n", err)) } } // Leave 离开交互模式:恢复终端状态、显示光标、离开备用屏幕。 // 非 TTY / 未 Enter 时为 no-op。必须在 defer 中调用。 func (u *mtrUI) Leave() { if !u.isTTY { return } // 防御:确保退出前关闭所有扩展输入模式 disableTerminalInputModes() // 恢复终端 if u.oldState != nil { _ = term.Restore(int(os.Stdin.Fd()), u.oldState) u.oldState = nil } // 显示光标 os.Stdout.WriteString("\033[?25h") // 离开备用屏幕缓冲区(恢复之前内容) os.Stdout.WriteString("\033[?1049l") } // --------------------------------------------------------------------------- // 输入解析器:字节流状态机 // --------------------------------------------------------------------------- // mtrInputAction 表示输入解析器解析出的动作。 type mtrInputAction int const ( mtrActionNone mtrInputAction = iota // 无动作(序列被吞掉或 buffer 不足) mtrActionQuit // q / Q / Ctrl-C mtrActionPause // p mtrActionResume // 空格 mtrActionRestart // r mtrActionDisplayMode // y mtrActionNameToggle // n mtrActionMPLSToggle // e ) // mtrInputParser 是一个字节级状态机,能区分普通按键与 // CSI/SS3/OSC/鼠标/焦点等转义序列,对后者整体吞掉。 type mtrInputParser struct { state mtrParserState csiN int // CSI 体内已读字节数(用于限制吞掉长度) } type mtrParserState int const ( mtrStateGround mtrParserState = iota // 等待新输入 mtrStateEsc // 刚收到 ESC (0x1B) mtrStateCSI // ESC [ —— CSI 序列体 mtrStateSS3 // ESC O —— SS3 序列体(1 字节负载) mtrStateOSC // ESC ] —— OSC 序列体(到 BEL/ST 结束) mtrStateX10Mouse // ESC [ M —— 3 字节负载 mtrStateSGRMouse // ESC [ < —— 到 M/m 结束 ) // mtrParserMaxCSI 限制 CSI 序列体最大长度,防止畸形输入卡死解析器。 const mtrParserMaxCSI = 64 // Feed 向解析器喂入一个字节,返回识别出的动作。 // 正常按键立即返回动作;转义序列在完整吞掉前返回 mtrActionNone。 func (p *mtrInputParser) Feed(b byte) mtrInputAction { switch p.state { case mtrStateGround: return p.feedGround(b) case mtrStateEsc: return p.feedEsc(b) case mtrStateCSI: return p.feedCSI(b) case mtrStateSS3: return p.feedSS3() case mtrStateOSC: return p.feedOSC(b) case mtrStateX10Mouse: return p.feedX10Mouse() case mtrStateSGRMouse: return p.feedSGRMouse(b) default: p.state = mtrStateGround return mtrActionNone } } func (p *mtrInputParser) feedGround(b byte) mtrInputAction { if b == 0x1B { p.state = mtrStateEsc return mtrActionNone } return mapKeyToAction(b) } func (p *mtrInputParser) feedEsc(b byte) mtrInputAction { switch b { case '[': p.state = mtrStateCSI p.csiN = 0 case 'O': p.state = mtrStateSS3 case ']': p.state = mtrStateOSC default: p.state = mtrStateGround } return mtrActionNone } func (p *mtrInputParser) feedCSI(b byte) mtrInputAction { p.csiN++ switch { case b == 'M': p.state = mtrStateX10Mouse p.csiN = 0 case b == '<': p.state = mtrStateSGRMouse case b == 'I' || b == 'O': p.state = mtrStateGround case b >= 0x40 && b <= 0x7E: p.state = mtrStateGround case p.csiN > mtrParserMaxCSI: p.state = mtrStateGround } return mtrActionNone } func (p *mtrInputParser) feedSS3() mtrInputAction { p.state = mtrStateGround return mtrActionNone } func (p *mtrInputParser) feedOSC(b byte) mtrInputAction { switch b { case 0x07: p.state = mtrStateGround case 0x1B: p.state = mtrStateEsc } return mtrActionNone } func (p *mtrInputParser) feedX10Mouse() mtrInputAction { p.csiN++ if p.csiN >= 3 { p.state = mtrStateGround } return mtrActionNone } func (p *mtrInputParser) feedSGRMouse(b byte) mtrInputAction { if b == 'M' || b == 'm' { p.state = mtrStateGround } return mtrActionNone } // mapKeyToAction 将普通单字节映射为动作。 func mapKeyToAction(b byte) mtrInputAction { switch b { case 'q', 'Q', 0x03: // q / Q / Ctrl-C return mtrActionQuit case 'p', 'P': return mtrActionPause case ' ': return mtrActionResume case 'r', 'R': return mtrActionRestart case 'y', 'Y': return mtrActionDisplayMode case 'n', 'N': return mtrActionNameToggle case 'e', 'E': return mtrActionMPLSToggle default: return mtrActionNone } } // ReadKeysLoop 在独立 goroutine 中读按键: // // q / Q → 退出(调用 cancel) // p → 暂停 // 空格 → 恢复 // r → 重置统计 // y → 切换显示模式 // n → 切换 Host 显示 // e → 切换 MPLS 显示 // // 使用 mtrInputParser 字节流状态机解析输入, // 自动吞掉 CSI/SS3/OSC/鼠标/焦点等转义序列。 // 当 ctx 结束或 stdin 关闭时自动退出。非 TTY 时立即返回。 func (u *mtrUI) ReadKeysLoop(ctx context.Context) { if !u.isTTY { return } var parser mtrInputParser buf := make([]byte, 64) // 批量读取,减少 syscall for { select { case <-ctx.Done(): return default: } n, err := os.Stdin.Read(buf) if err != nil || n == 0 { if err == io.EOF { return } return } for i := 0; i < n; i++ { action := parser.Feed(buf[i]) switch action { case mtrActionQuit: if u.cancel != nil { u.cancel() } return case mtrActionPause: atomic.StoreInt32(&u.paused, 1) case mtrActionResume: atomic.StoreInt32(&u.paused, 0) case mtrActionRestart: atomic.StoreInt32(&u.restartReq, 1) case mtrActionDisplayMode: u.CycleDisplayMode() case mtrActionNameToggle: u.ToggleNameMode() case mtrActionMPLSToggle: u.ToggleMPLS() } } } } // ConsumeRestartRequest 原子读取并清除重置请求标志。 // 返回 true 表示请求了重置统计。 func (u *mtrUI) ConsumeRestartRequest() bool { return atomic.SwapInt32(&u.restartReq, 0) == 1 } // ParseMTRKey 将单字节解析为操作名称(用于测试)。 // 返回值: "quit", "pause", "resume", "restart", "display_mode", "name_toggle", "" (未知)。 func ParseMTRKey(b byte) string { switch b { case 'q', 'Q', 0x03: return "quit" case 'p', 'P': return "pause" case ' ': return "resume" case 'r', 'R': return "restart" case 'y', 'Y': return "display_mode" case 'n', 'N': return "name_toggle" case 'e', 'E': return "mpls_toggle" default: return "" } } ================================================ FILE: cmd/mtu_mode.go ================================================ package cmd import ( "context" "encoding/json" "errors" "fmt" "io" "net" "os" "os/signal" "strings" "time" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/printer" mtutrace "github.com/nxtrace/NTrace-core/trace/mtu" "github.com/nxtrace/NTrace-core/util" ) type mtuConflictFlag struct { flag string enabled bool } func checkMTUConflicts(flags []mtuConflictFlag) (string, bool) { for _, flag := range flags { if flag.enabled { return flag.flag, false } } return "", true } func normalizeMTUProtocolFlags(tcp, udp *bool) error { if tcp != nil && *tcp { return errors.New("--mtu 仅支持 UDP,请移除 --tcp") } if udp != nil { *udp = true } return nil } func buildMTUConflictFlags( tcp, rawPrint bool, mtrModes effectiveMTRModes, tablePrint, classicPrint, routePath, outputPath, outputDefault, deploy bool, globalping bool, from, file string, fastTrace bool, ) []mtuConflictFlag { return []mtuConflictFlag{ {flag: "--tcp", enabled: tcp}, {flag: "--mtr", enabled: mtrModes.mtr}, {flag: "--raw", enabled: rawPrint}, {flag: "--table", enabled: tablePrint}, {flag: "--classic", enabled: classicPrint}, {flag: "--route-path", enabled: routePath}, {flag: "--output", enabled: outputPath}, {flag: "--output-default", enabled: outputDefault}, {flag: "--from", enabled: globalping && from != ""}, {flag: "--fast-trace", enabled: fastTrace}, {flag: "--file", enabled: file != ""}, {flag: "--deploy", enabled: deploy}, } } func resolveMTUSourceIP(dstIP net.IP, srcAddr string) (net.IP, error) { if trimmed := strings.TrimSpace(srcAddr); trimmed != "" { srcIP := net.ParseIP(trimmed) if srcIP == nil { return nil, fmt.Errorf("invalid source IP %q", srcAddr) } if util.IsIPv6(dstIP) { if !util.IsIPv6(srcIP) { return nil, fmt.Errorf("source IP %q does not match IPv6 destination %s", srcAddr, dstIP) } return srcIP, nil } if srcIP.To4() == nil { return nil, fmt.Errorf("source IP %q does not match IPv4 destination %s", srcAddr, dstIP) } return srcIP.To4(), nil } if util.IsIPv6(dstIP) { resolved, _ := util.LocalIPPortv6(dstIP, nil, "udp6") if resolved == nil { return nil, fmt.Errorf("unable to determine IPv6 source address for %s", dstIP) } return resolved, nil } resolved, _ := util.LocalIPPort(dstIP, nil, "udp") if resolved == nil { return nil, fmt.Errorf("unable to determine IPv4 source address for %s", dstIP) } return resolved, nil } func runStandaloneMTUMode(cfg mtutrace.Config, jsonPrint bool) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() if jsonPrint { result, err := mtutrace.Run(ctx, cfg) if err != nil { return err } encoded, err := json.Marshal(result) if err != nil { return err } fmt.Println(string(encoded)) return nil } streamCtx, cancel := context.WithCancel(ctx) defer cancel() renderer := newMTUStreamRenderer(os.Stdout, CheckTTY(int(os.Stdout.Fd()))) var renderErr error _, err := mtutrace.RunStream(streamCtx, cfg, func(event mtutrace.StreamEvent) { if renderErr != nil { return } if err := renderer.Render(event); err != nil { renderErr = err cancel() } }) if renderErr != nil { return renderErr } return err } func printMTUResult(w io.Writer, result *mtutrace.Result) error { return printMTUResultWithStyle(w, result, newMTUTextStyle(false)) } func printMTUResultWithStyle(w io.Writer, result *mtutrace.Result, style mtuTextStyle) error { if result == nil { return errors.New("nil mtu result") } if err := printMTUHeader(w, result.Target, result.ResolvedIP, result.StartMTU, result.ProbeSize, style); err != nil { return err } for _, hop := range result.Hops { if _, err := fmt.Fprintln(w, formatMTUHopLineWithStyle(hop, style)); err != nil { return err } } return printMTUSummary(w, result.PathMTU, style) } func formatMTUHopLine(hop mtutrace.Hop) string { return formatMTUHopLineWithStyle(hop, newMTUTextStyle(false)) } func formatMTUHopLineWithStyle(hop mtutrace.Hop, style mtuTextStyle) string { if hop.Event == mtutrace.EventTimeout { line := fmt.Sprintf("%s %s", style.ttl(hop.TTL), style.timeout()) if hop.PMTU > 0 { line += " " + style.pmtu(hop.PMTU) } return line } target := hop.IP if hop.Hostname != "" { target = fmt.Sprintf("%s (%s)", hop.Hostname, hop.IP) } line := fmt.Sprintf("%s %s", style.ttl(hop.TTL), style.hopTarget(hop.Event, target)) if hop.RTTMs > 0 { line += fmt.Sprintf(" %.2fms", hop.RTTMs) } if hop.PMTU > 0 { line += " " + style.pmtu(hop.PMTU) } if geo := formatMTUGeo(hop); geo != "" { line += " " + geo } return line } func formatMTUHopSnapshot(event mtutrace.StreamEvent) string { return formatMTUHopSnapshotWithStyle(event, newMTUTextStyle(false)) } func formatMTUHopSnapshotWithStyle(event mtutrace.StreamEvent, style mtuTextStyle) string { if event.Kind == mtutrace.StreamEventTTLStart { return fmt.Sprintf("%s %s", style.ttl(event.TTL), style.placeholder()) } return formatMTUHopLineWithStyle(event.Hop, style) } func printMTUHeader(w io.Writer, target, resolvedIP string, startMTU, probeSize int, style mtuTextStyle) error { _, err := fmt.Fprintln(w, style.header(fmt.Sprintf("tracepath to %s (%s), start MTU %d, %d byte packets", target, resolvedIP, startMTU, probeSize))) return err } func printMTUSummary(w io.Writer, pathMTU int, style mtuTextStyle) error { _, err := fmt.Fprintln(w, style.summary(pathMTU)) return err } type mtuStreamRenderer struct { w io.Writer isTTY bool style mtuTextStyle headerPrinted bool lineActive bool } func newMTUStreamRenderer(w io.Writer, isTTY bool) *mtuStreamRenderer { return &mtuStreamRenderer{ w: w, isTTY: isTTY, style: newMTUTextStyle(isTTY && !color.NoColor), } } func (r *mtuStreamRenderer) Render(event mtutrace.StreamEvent) error { if err := r.ensureHeader(event); err != nil { return err } switch event.Kind { case mtutrace.StreamEventTTLStart: if !r.isTTY { return nil } return r.renderTTYLine(formatMTUHopSnapshotWithStyle(event, r.style), false) case mtutrace.StreamEventTTLUpdate: if !r.isTTY { return nil } return r.renderTTYLine(formatMTUHopSnapshotWithStyle(event, r.style), false) case mtutrace.StreamEventTTLFinal: line := formatMTUHopSnapshotWithStyle(event, r.style) if r.isTTY { return r.renderTTYLine(line, true) } _, err := fmt.Fprintln(r.w, line) return err case mtutrace.StreamEventDone: if r.isTTY && r.lineActive { if _, err := io.WriteString(r.w, "\n"); err != nil { return err } r.lineActive = false } return printMTUSummary(r.w, event.PathMTU, r.style) default: return nil } } func (r *mtuStreamRenderer) ensureHeader(event mtutrace.StreamEvent) error { if r.headerPrinted { return nil } if event.Target == "" || event.ResolvedIP == "" { return nil } if err := printMTUHeader(r.w, event.Target, event.ResolvedIP, event.StartMTU, event.ProbeSize, r.style); err != nil { return err } r.headerPrinted = true return nil } func (r *mtuStreamRenderer) renderTTYLine(line string, final bool) error { if _, err := fmt.Fprintf(r.w, "\r\033[2K%s", line); err != nil { return err } r.lineActive = !final if !final { return nil } _, err := io.WriteString(r.w, "\n") return err } type mtuTextStyle struct { enabled bool } func newMTUTextStyle(enabled bool) mtuTextStyle { return mtuTextStyle{enabled: enabled} } func (s mtuTextStyle) apply(text string, attrs ...color.Attribute) string { if !s.enabled { return text } return color.New(attrs...).Sprint(text) } func (s mtuTextStyle) header(text string) string { return s.apply(text, color.FgCyan, color.Bold) } func (s mtuTextStyle) ttl(ttl int) string { return s.apply(fmt.Sprintf("%2d", ttl), color.Faint) } func (s mtuTextStyle) placeholder() string { return s.apply("...", color.FgHiBlack) } func (s mtuTextStyle) timeout() string { return s.apply("*", color.FgRed, color.Bold) } func (s mtuTextStyle) hopTarget(event mtutrace.Event, target string) string { switch event { case mtutrace.EventDestination: return s.apply(target, color.FgGreen, color.Bold) default: return s.apply(target, color.FgYellow) } } func (s mtuTextStyle) pmtu(pmtu int) string { return s.apply(fmt.Sprintf("pmtu %d", pmtu), color.FgCyan, color.Bold) } func (s mtuTextStyle) summary(pathMTU int) string { return s.apply(fmt.Sprintf("Path MTU: %d", pathMTU), color.FgGreen, color.Bold) } func buildMTUTraceConfig( target string, dstIP net.IP, srcIP net.IP, srcDev string, srcPort int, dstPort int, beginHop int, maxHops int, queries int, timeoutMs int, ttlIntervalMs int, rdns bool, alwaysWaitRDNS bool, geoSource ipgeo.Source, lang string, ) mtutrace.Config { return mtutrace.Config{ Target: target, DstIP: dstIP, SrcIP: srcIP, SourceDevice: srcDev, SrcPort: srcPort, DstPort: dstPort, BeginHop: beginHop, MaxHops: maxHops, Queries: queries, Timeout: time.Duration(timeoutMs) * time.Millisecond, TTLInterval: time.Duration(ttlIntervalMs) * time.Millisecond, RDNS: rdns, AlwaysWaitRDNS: alwaysWaitRDNS, IPGeoSource: geoSource, Lang: lang, } } func formatMTUGeo(hop mtutrace.Hop) string { if hop.Geo == nil || hop.IP == "" { return "" } if hop.Geo.Asnumber == "" && hop.Geo.Country == "" && hop.Geo.CountryEn == "" && hop.Geo.Prov == "" && hop.Geo.ProvEn == "" && hop.Geo.City == "" && hop.Geo.CityEn == "" && hop.Geo.District == "" && hop.Geo.Owner == "" && hop.Geo.Isp == "" && hop.Geo.Whois == "" { return "" } return printer.FormatIPGeoData(hop.IP, hop.Geo) } ================================================ FILE: cmd/mtu_mode_test.go ================================================ package cmd import ( "bytes" "encoding/json" "strings" "testing" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/ipgeo" mtutrace "github.com/nxtrace/NTrace-core/trace/mtu" ) func TestNormalizeMTUProtocolFlagsAutoEnablesUDP(t *testing.T) { tcp := false udp := false if err := normalizeMTUProtocolFlags(&tcp, &udp); err != nil { t.Fatalf("normalizeMTUProtocolFlags returned error: %v", err) } if !udp { t.Fatal("udp should be enabled in mtu mode") } } func TestNormalizeMTUProtocolFlagsRejectsTCP(t *testing.T) { tcp := true udp := false if err := normalizeMTUProtocolFlags(&tcp, &udp); err == nil { t.Fatal("expected tcp to be rejected in mtu mode") } } func TestCheckMTUConflicts(t *testing.T) { conflict, ok := checkMTUConflicts([]mtuConflictFlag{ {flag: "--table", enabled: true}, {flag: "--from", enabled: true}, }) if ok { t.Fatal("expected mtu conflict") } if conflict != "--table" { t.Fatalf("conflict = %q, want --table", conflict) } } func TestBuildMTUConflictFlagsIncludesOutputDefault(t *testing.T) { flags := buildMTUConflictFlags(false, false, effectiveMTRModes{}, false, false, false, false, true, false, false, "", "", false) conflict, ok := checkMTUConflicts(flags) if ok { t.Fatal("expected mtu conflict") } if conflict != "--output-default" { t.Fatalf("conflict = %q, want --output-default", conflict) } } func TestPrintMTUResultIncludesPMTUAndSummary(t *testing.T) { var buf bytes.Buffer res := &mtutrace.Result{ Target: "example.com", ResolvedIP: "203.0.113.9", StartMTU: 1500, ProbeSize: 65000, PathMTU: 1400, Hops: []mtutrace.Hop{ {TTL: 1, Event: mtutrace.EventTimeExceeded, IP: "192.0.2.1", RTTMs: 12.5, PMTU: 1400, Geo: &ipgeo.IPGeoData{Asnumber: "13335", CountryEn: "Hong Kong", Owner: "Cloudflare"}}, {TTL: 2, Event: mtutrace.EventTimeout}, }, } if err := printMTUResult(&buf, res); err != nil { t.Fatalf("printMTUResult returned error: %v", err) } output := buf.String() for _, want := range []string{ "tracepath to example.com (203.0.113.9), start MTU 1500, 65000 byte packets", "pmtu 1400", "AS13335", "Cloudflare", "Path MTU: 1400", " 2 *", } { if !strings.Contains(output, want) { t.Fatalf("output missing %q:\n%s", want, output) } } } func TestFormatMTUHopSnapshotStartPlaceholder(t *testing.T) { line := formatMTUHopSnapshot(mtutrace.StreamEvent{ Kind: mtutrace.StreamEventTTLStart, TTL: 3, }) if line != " 3 ..." { t.Fatalf("line = %q, want %q", line, " 3 ...") } } func TestMTUStreamRendererTTYRewritesCurrentLine(t *testing.T) { prevNoColor := color.NoColor color.NoColor = true defer func() { color.NoColor = prevNoColor }() var buf bytes.Buffer renderer := newMTUStreamRenderer(&buf, true) events := []mtutrace.StreamEvent{ { Kind: mtutrace.StreamEventTTLStart, TTL: 1, Target: "example.com", ResolvedIP: "203.0.113.9", StartMTU: 1500, ProbeSize: 65000, }, { Kind: mtutrace.StreamEventTTLUpdate, TTL: 1, Hop: mtutrace.Hop{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: "192.0.2.1", RTTMs: 12.5, PMTU: 1500}, }, { Kind: mtutrace.StreamEventTTLUpdate, TTL: 1, Hop: mtutrace.Hop{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: "192.0.2.1", RTTMs: 12.5, PMTU: 1500, Geo: &ipgeo.IPGeoData{Asnumber: "13335", CountryEn: "Hong Kong", Owner: "Cloudflare"}}, }, { Kind: mtutrace.StreamEventTTLFinal, TTL: 1, Hop: mtutrace.Hop{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: "192.0.2.1", RTTMs: 12.5, PMTU: 1500, Geo: &ipgeo.IPGeoData{Asnumber: "13335", CountryEn: "Hong Kong", Owner: "Cloudflare"}}, }, { Kind: mtutrace.StreamEventDone, PathMTU: 1500, }, } for _, event := range events { if err := renderer.Render(event); err != nil { t.Fatalf("Render returned error: %v", err) } } output := buf.String() for _, want := range []string{ "tracepath to example.com (203.0.113.9), start MTU 1500, 65000 byte packets\n", "\r\x1b[2K 1 ...", "\r\x1b[2K 1 192.0.2.1 12.50ms pmtu 1500 AS13335", "Cloudflare\n", "Path MTU: 1500\n", } { if !strings.Contains(output, want) { t.Fatalf("output missing %q:\n%q", want, output) } } } func TestMTUStreamRendererTTYAppliesColorsWhenEnabled(t *testing.T) { prevNoColor := color.NoColor color.NoColor = false defer func() { color.NoColor = prevNoColor }() var buf bytes.Buffer renderer := newMTUStreamRenderer(&buf, true) events := []mtutrace.StreamEvent{ { Kind: mtutrace.StreamEventTTLStart, TTL: 1, Target: "example.com", ResolvedIP: "203.0.113.9", StartMTU: 1500, ProbeSize: 65000, }, { Kind: mtutrace.StreamEventTTLFinal, TTL: 1, Hop: mtutrace.Hop{TTL: 1, Event: mtutrace.EventDestination, IP: "203.0.113.9", RTTMs: 10.5, PMTU: 1480}, }, { Kind: mtutrace.StreamEventDone, PathMTU: 1480, }, } for _, event := range events { if err := renderer.Render(event); err != nil { t.Fatalf("Render returned error: %v", err) } } output := buf.String() if !strings.Contains(output, "\x1b[") { t.Fatalf("output should contain ANSI color codes:\n%q", output) } for _, want := range []string{"tracepath to example.com", "pmtu 1480", "Path MTU: 1480"} { if !strings.Contains(output, want) { t.Fatalf("output missing %q:\n%q", want, output) } } } func TestMTUStreamRendererNonTTYPrintsOnlyFinalLines(t *testing.T) { var buf bytes.Buffer renderer := newMTUStreamRenderer(&buf, false) events := []mtutrace.StreamEvent{ { Kind: mtutrace.StreamEventTTLStart, TTL: 1, Target: "example.com", ResolvedIP: "203.0.113.9", StartMTU: 1500, ProbeSize: 65000, }, { Kind: mtutrace.StreamEventTTLUpdate, TTL: 1, Hop: mtutrace.Hop{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: "192.0.2.1", RTTMs: 12.5, PMTU: 1500}, }, { Kind: mtutrace.StreamEventTTLFinal, TTL: 1, Hop: mtutrace.Hop{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: "192.0.2.1", RTTMs: 12.5, PMTU: 1500, Geo: &ipgeo.IPGeoData{Asnumber: "13335", CountryEn: "Hong Kong", Owner: "Cloudflare"}}, }, { Kind: mtutrace.StreamEventDone, PathMTU: 1500, }, } for _, event := range events { if err := renderer.Render(event); err != nil { t.Fatalf("Render returned error: %v", err) } } output := buf.String() for _, unwanted := range []string{"\x1b[2K", " 1 ..."} { if strings.Contains(output, unwanted) { t.Fatalf("output should not contain %q:\n%q", unwanted, output) } } for _, want := range []string{ "tracepath to example.com (203.0.113.9), start MTU 1500, 65000 byte packets\n", " 1 192.0.2.1 12.50ms pmtu 1500 AS13335", "Cloudflare\n", "Path MTU: 1500\n", } { if !strings.Contains(output, want) { t.Fatalf("output missing %q:\n%q", want, output) } } } func TestMTUResultJSONIncludesGeo(t *testing.T) { res := &mtutrace.Result{ Target: "example.com", ResolvedIP: "203.0.113.9", StartMTU: 1500, ProbeSize: 65000, PathMTU: 1400, Hops: []mtutrace.Hop{ {TTL: 1, Event: mtutrace.EventTimeExceeded, IP: "192.0.2.1", Geo: &ipgeo.IPGeoData{Country: "中国香港", Owner: "Cloudflare"}}, }, } encoded, err := json.Marshal(res) if err != nil { t.Fatalf("Marshal returned error: %v", err) } output := string(encoded) for _, want := range []string{`"geo":`, `"country":"中国香港"`, `"owner":"Cloudflare"`} { if !strings.Contains(output, want) { t.Fatalf("json output missing %q:\n%s", want, output) } } } ================================================ FILE: config/basic.go ================================================ package config var Version = "v0.0.0.alpha" var BuildDate = "" var CommitID = "" ================================================ FILE: config/viper.go ================================================ package config import ( "errors" "fmt" "os" "path/filepath" "runtime" "github.com/spf13/viper" ) func InitConfig() { // 配置文件名, 不加扩展 viper.SetConfigName("nt_config") // name of config file (without extension) // 设置文件的扩展名 viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name // 查找配置文件所在路径 homeDir, err := os.UserHomeDir() if err != nil { homeDir = "" } xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") if xdgConfigHome == "" && homeDir != "" { xdgConfigHome = filepath.Join(homeDir, ".config") } configPaths := []string{ "/etc/nexttrace", "/usr/local/etc/nexttrace", } if runtime.GOOS == "darwin" { configPaths = append(configPaths, "/opt/homebrew/etc/nexttrace") } if xdgConfigHome != "" { configPaths = append(configPaths, filepath.Join(xdgConfigHome, "nexttrace")) } if homeDir != "" { configPaths = append(configPaths, filepath.Join(homeDir, ".local", "share", "nexttrace"), filepath.Join(homeDir, ".nexttrace"), filepath.Join(homeDir, "nexttrace"), homeDir, ) } configPaths = append(configPaths, "/usr/share/nexttrace", "/usr/local/share/nexttrace", ".", ) for _, path := range configPaths { viper.AddConfigPath(path) } // 配置默认值 viper.SetDefault("ptrPath", "./ptr.csv") viper.SetDefault("geoFeedPath", "./geofeed.csv") // 开始查找并读取配置文件 if err := viper.ReadInConfig(); err != nil { var notFound viper.ConfigFileNotFoundError if errors.As(err, ¬Found) { fmt.Println("未能找到配置文件,我们将在您的运行目录为您创建 nt_config.yaml 默认配置") if err := viper.SafeWriteConfigAs("./nt_config.yaml"); err != nil { fmt.Println("创建默认配置文件失败:", err) return } if err := viper.ReadInConfig(); err != nil { fmt.Println("加载默认配置失败:", err) } return } fmt.Println("加载配置文件失败:", err) return } } ================================================ FILE: dn42/dn42.go ================================================ package dn42 /*** [DN42 Package] 谨献给 DN42 所有的小伙伴们,祝你们终有一天能有自己的公网 ASN ~ By Leo ***/ ================================================ FILE: dn42/geofeed.go ================================================ package dn42 import ( "encoding/csv" "fmt" "net" "os" "sort" "github.com/spf13/viper" ) type GeoFeedRow struct { IPNet *net.IPNet CIDR string LtdCode string ISO3166 string City string ASN string IPWhois string } func GetGeoFeed(ip string) (GeoFeedRow, bool) { rows, err := ReadGeoFeed() if err != nil { // 无法加载 geofeed 数据,返回未找到 return GeoFeedRow{}, false } row, find := FindGeoFeedRow(ip, rows) return row, find } func ReadGeoFeed() ([]GeoFeedRow, error) { path := viper.GetString("geoFeedPath") if path == "" { return nil, fmt.Errorf("geoFeedPath not configured") } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() r := csv.NewReader(f) rows, err := r.ReadAll() if err != nil { return nil, err } // 将 CSV 中的每一行转换为 GeoFeedRow 类型,并保存到 rowsSlice 中 var rowsSlice []GeoFeedRow for _, row := range rows { cidr := row[0] // 假设第一列是 CIDR 字段 _, ipnet, err := net.ParseCIDR(cidr) if err != nil { // 如果解析 CIDR 失败,跳过这一行 continue } if len(row) == 4 { rowsSlice = append(rowsSlice, GeoFeedRow{ IPNet: ipnet, CIDR: cidr, LtdCode: row[1], ISO3166: row[2], City: row[3], }) } else if len(row) >= 6 { rowsSlice = append(rowsSlice, GeoFeedRow{ IPNet: ipnet, CIDR: cidr, LtdCode: row[1], ISO3166: row[2], City: row[3], ASN: row[4], IPWhois: row[5], }) } } // 根据 CIDR 范围从小到大排序,方便后面查找 sort.Slice(rowsSlice, func(i, j int) bool { return rowsSlice[i].IPNet.Mask.String() > rowsSlice[j].IPNet.Mask.String() }) return rowsSlice, nil } func FindGeoFeedRow(ipStr string, rows []GeoFeedRow) (GeoFeedRow, bool) { ip := net.ParseIP(ipStr) if ip == nil { // 如果传入的 IP 无效,直接返回 return GeoFeedRow{}, false } // 遍历每个 CIDR 范围,找到第一个包含传入的 IP 的 CIDR for _, row := range rows { if row.IPNet.Contains(ip) { return row, true } } return GeoFeedRow{}, false } ================================================ FILE: dn42/geofeed_test.go ================================================ package dn42 import ( "testing" "github.com/stretchr/testify/assert" ) func TestFindGeoFeedRowInvalidIP(t *testing.T) { row, found := FindGeoFeedRow("not-an-ip", nil) assert.False(t, found) assert.Equal(t, GeoFeedRow{}, row) } ================================================ FILE: dn42/ptr.go ================================================ package dn42 import ( "encoding/csv" "errors" "fmt" "os" "regexp" "strings" "github.com/spf13/viper" ) type PtrRow struct { IATACode string LtdCode string Region string City string } func matchesPattern(prefix string, s string) bool { pattern := fmt.Sprintf(`^(.*[-.\d]|^)%s[-.\d].*$`, prefix) r, err := regexp.Compile(pattern) if err != nil { fmt.Println("Invalid regular expression:", err) return false } return r.MatchString(s) } func FindPtrRecord(ptr string) (PtrRow, error) { path := viper.GetString("ptrPath") if path == "" { return PtrRow{}, errors.New("ptrPath not configured") } f, err := os.Open(path) if err != nil { return PtrRow{}, err } defer f.Close() r := csv.NewReader(f) rows, err := r.ReadAll() if err != nil { return PtrRow{}, err } // 转小写 ptr = strings.ToLower(ptr) // 先查城市名 for _, row := range rows { city := row[3] if city == "" { continue } city = strings.ReplaceAll(city, " ", "") city = strings.ToLower(city) if matchesPattern(city, ptr) { return PtrRow{ LtdCode: row[1], Region: row[2], City: row[3], }, nil } } // 查 IATA Code for _, row := range rows { iata := row[0] if iata == "" { continue } iata = strings.ToLower(iata) if matchesPattern(iata, ptr) { return PtrRow{ IATACode: iata, LtdCode: row[1], Region: row[2], City: row[3], }, nil } } return PtrRow{}, errors.New("ptr not found") } ================================================ FILE: dn42/ptr_test.go ================================================ package dn42 import ( "os" "path/filepath" "testing" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFindPtrRecordMatchesCity(t *testing.T) { dir := t.TempDir() ptrPath := filepath.Join(dir, "ptr.csv") content := "HKG,hk,Hong Kong,Hong Kong\nLAX,us,California,Los Angeles\n" require.NoError(t, os.WriteFile(ptrPath, []byte(content), 0o644)) viper.Set("ptrPath", ptrPath) t.Cleanup(viper.Reset) row, err := FindPtrRecord("core.hongkong-1.example") require.NoError(t, err) assert.Equal(t, "hk", row.LtdCode) assert.Equal(t, "Hong Kong", row.Region) assert.Equal(t, "Hong Kong", row.City) } func TestFindPtrRecordMatchesIATACode(t *testing.T) { dir := t.TempDir() ptrPath := filepath.Join(dir, "ptr.csv") require.NoError(t, os.WriteFile(ptrPath, []byte("LAX,us,California,Los Angeles\n"), 0o644)) viper.Set("ptrPath", ptrPath) t.Cleanup(viper.Reset) row, err := FindPtrRecord("edge.lax01.provider.test") require.NoError(t, err) assert.Equal(t, "lax", row.IATACode) assert.Equal(t, "us", row.LtdCode) assert.Equal(t, "California", row.Region) } func TestFindPtrRecordNotFound(t *testing.T) { dir := t.TempDir() ptrPath := filepath.Join(dir, "ptr.csv") require.NoError(t, os.WriteFile(ptrPath, []byte(""), 0o644)) viper.Set("ptrPath", ptrPath) t.Cleanup(viper.Reset) _, err := FindPtrRecord("unmatched.example") require.Error(t, err) } ================================================ FILE: fast_trace/basic.go ================================================ package fastTrace type AllLocationCollection struct { Beijing BackBoneCollection Shanghai BackBoneCollection Guangzhou BackBoneCollection Hangzhou BackBoneCollection Hefei BackBoneCollection Changsha BackBoneCollection } type BackBoneCollection struct { Location string CT163 ISPCollection CTCN2 ISPCollection CU169 ISPCollection CU9929 ISPCollection CM ISPCollection CMIN2 ISPCollection EDU ISPCollection CST ISPCollection } type ISPCollection struct { ISPName string IP string IPv6 string } const ( CT163 string = "电信 163 AS4134" CTCN2 string = "电信 CN2 AS4809" CU169 string = "联通 169 AS4837" CU9929 string = "联通 A网(CNC) AS9929" CM string = "移动 CMNET AS9808" CMIN2 string = "移动 CMIN2 AS58807" EDU string = "教育网 CERNET AS4538" CST string = "科技网 CSTNET AS7497" ) var TestIPsCollection = AllLocationCollection{ Beijing: Beijing, Shanghai: Shanghai, Guangzhou: Guangzhou, Hangzhou: Hangzhou, Hefei: Hefei, } var Beijing = BackBoneCollection{ Location: "北京", CT163: ISPCollection{ ISPName: CT163, IP: "ipv4.pek-4134.endpoint.nxtrace.org", IPv6: "ipv6.pek-4134.endpoint.nxtrace.org", }, CTCN2: ISPCollection{ ISPName: CTCN2, IP: "ipv4.pek-4809.endpoint.nxtrace.org", }, CU169: ISPCollection{ ISPName: CU169, IP: "ipv4.pek-4837.endpoint.nxtrace.org", IPv6: "ipv6.pek-4837.endpoint.nxtrace.org", }, CU9929: ISPCollection{ ISPName: CU9929, IP: "ipv4.pek-9929.endpoint.nxtrace.org", }, CM: ISPCollection{ ISPName: CM, IP: "ipv4.pek-9808.endpoint.nxtrace.org", IPv6: "ipv6.pek-9808.endpoint.nxtrace.org", }, CMIN2: ISPCollection{ ISPName: CMIN2, IP: "ipv4.pek-58807.endpoint.nxtrace.org", }, EDU: ISPCollection{ ISPName: EDU, IP: "ipv4.pek-4538.endpoint.nxtrace.org", IPv6: "ipv6.pek-4538.endpoint.nxtrace.org", }, // 中科院 CST: ISPCollection{ ISPName: CST, IP: "ipv4.pek-7497.endpoint.nxtrace.org", IPv6: "ipv6.pek-7497.endpoint.nxtrace.org", }, } var Shanghai = BackBoneCollection{ Location: "上海", CT163: ISPCollection{ ISPName: CT163, IP: "ipv4.sha-4134.endpoint.nxtrace.org", IPv6: "ipv6.sha-4134.endpoint.nxtrace.org", }, CTCN2: ISPCollection{ ISPName: CTCN2, IP: "ipv4.sha-4809.endpoint.nxtrace.org", }, CU169: ISPCollection{ ISPName: CU169, IP: "ipv4.sha-4837.endpoint.nxtrace.org", IPv6: "ipv6.sha-4837.endpoint.nxtrace.org", }, CU9929: ISPCollection{ ISPName: CU9929, IP: "ipv4.sha-9929.endpoint.nxtrace.org", IPv6: "ipv6.sha-9929.endpoint.nxtrace.org", }, CM: ISPCollection{ ISPName: CM, IP: "ipv4.sha-9808.endpoint.nxtrace.org", IPv6: "ipv6.sha-9808.endpoint.nxtrace.org", }, CMIN2: ISPCollection{ ISPName: CMIN2, IP: "ipv4.sha-58807.endpoint.nxtrace.org", }, EDU: ISPCollection{ ISPName: EDU, IP: "ipv4.sha-4538.endpoint.nxtrace.org", IPv6: "ipv6.sha-4538.endpoint.nxtrace.org", }, } var Guangzhou = BackBoneCollection{ Location: "广州", CT163: ISPCollection{ ISPName: CT163, IP: "ipv4.can-4134.endpoint.nxtrace.org", IPv6: "ipv6.can-4134.endpoint.nxtrace.org", }, CTCN2: ISPCollection{ ISPName: CTCN2, IP: "ipv4.can-4809.endpoint.nxtrace.org", }, CU169: ISPCollection{ ISPName: CU169, IP: "ipv4.can-4837.endpoint.nxtrace.org", IPv6: "ipv6.can-4837.endpoint.nxtrace.org", }, CU9929: ISPCollection{ ISPName: CU9929, IP: "ipv4.can-9929.endpoint.nxtrace.org", }, CM: ISPCollection{ ISPName: CM, IP: "ipv4.can-9808.endpoint.nxtrace.org", IPv6: "ipv6.can-9808.endpoint.nxtrace.org", }, CMIN2: ISPCollection{ ISPName: CMIN2, IP: "ipv4.can-58807.endpoint.nxtrace.org", }, // 中山大学 EDU: ISPCollection{ ISPName: EDU, IP: "ipv4.can-4538.endpoint.nxtrace.org", IPv6: "ipv6.can-4538.endpoint.nxtrace.org", }, } var Hangzhou = BackBoneCollection{ Location: "杭州", CT163: ISPCollection{ ISPName: CT163, IP: "ipv4.hgh-4134.endpoint.nxtrace.org", IPv6: "ipv6.hgh-4134.endpoint.nxtrace.org", }, CU169: ISPCollection{ ISPName: CU169, IP: "ipv4.hgh-4837.endpoint.nxtrace.org", IPv6: "ipv6.hgh-4837.endpoint.nxtrace.org", }, CM: ISPCollection{ ISPName: CM, IP: "ipv4.hgh-9808.endpoint.nxtrace.org", IPv6: "ipv6.hgh-9808.endpoint.nxtrace.org", }, // 浙江大学 教育网 EDU: ISPCollection{ ISPName: EDU, IP: "ipv4.hgh-4538.endpoint.nxtrace.org", IPv6: "ipv6.hgh-4538.endpoint.nxtrace.org", }, } var Hefei = BackBoneCollection{ Location: "合肥", // 中国科学技术大学 教育网 EDU: ISPCollection{ ISPName: EDU, IP: "ipv4.hfe-4538.endpoint.nxtrace.org", IPv6: "ipv6.hfe-4538.endpoint.nxtrace.org", }, // 中国科学技术大学 科技网 CST: ISPCollection{ ISPName: CST, IP: "ipv4.hfe-7497.endpoint.nxtrace.org", }, } ================================================ FILE: fast_trace/fast_trace ipv6.go ================================================ package fastTrace import ( "fmt" "log" "net" "os" "os/signal" "strings" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/util" "github.com/nxtrace/NTrace-core/wshandle" ) //var pFastTracer ParamsFastTrace func (f *FastTracer) tracert_v6(location string, ispCollection ISPCollection) { fmt.Fprintf(color.Output, "%s\n", color.New(color.FgYellow, color.Bold).Sprintf("『%s %s 』", location, ispCollection.ISPName)) displayPacketSize := f.ParamsFastTrace.PktSize if !f.ParamsFastTrace.PacketSizeSet { displayPacketSize = trace.DefaultPacketSize(f.TracerouteMethod, net.ParseIP(ispCollection.IPv6)) } fmt.Printf("traceroute to %s, %d hops max, %s, %s mode\n", ispCollection.IPv6, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod))) // ip, err := util.DomainLookUp(ispCollection.IPv6, "6", "", true) ip, err := util.DomainLookUpWithContext(f.ParamsFastTrace.Context, ispCollection.IPv6, "6", f.ParamsFastTrace.Dot, true) if err != nil { log.Fatal(err) } packetSize := f.ParamsFastTrace.PktSize if !f.ParamsFastTrace.PacketSizeSet { packetSize = trace.DefaultPacketSize(f.TracerouteMethod, ip) } packetSizeSpec, err := trace.NormalizePacketSize(f.TracerouteMethod, ip, packetSize) if err != nil { log.Fatal(err) } var conf = trace.Config{ Context: f.ParamsFastTrace.Context, OSType: f.ParamsFastTrace.OSType, ICMPMode: f.ParamsFastTrace.ICMPMode, BeginHop: f.ParamsFastTrace.BeginHop, DstIP: ip, DstPort: f.ParamsFastTrace.DstPort, MaxHops: f.ParamsFastTrace.MaxHops, NumMeasurements: 3, MaxAttempts: f.ParamsFastTrace.MaxAttempts, ParallelRequests: 18, RDNS: f.ParamsFastTrace.RDNS, AlwaysWaitRDNS: f.ParamsFastTrace.AlwaysWaitRDNS, PacketInterval: 100, TTLInterval: 500, IPGeoSource: ipgeo.GetSource("LeoMoeAPI"), Timeout: f.ParamsFastTrace.Timeout, SrcAddr: f.ParamsFastTrace.SrcAddr, PktSize: packetSizeSpec.PayloadSize, RandomPacketSize: packetSizeSpec.Random, TOS: f.ParamsFastTrace.TOS, Lang: f.ParamsFastTrace.Lang, } header := fmt.Sprintf("『%s %s 』\ntraceroute to %s, %d hops max, %s, %s mode\n", location, ispCollection.ISPName, ispCollection.IPv6, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod))) cleanup, err := configureFastTraceRealtimePrinter(&conf, f.ParamsFastTrace.OutputPath, header) if err != nil { return } if cleanup != nil { defer func() { if closeErr := cleanup(); closeErr != nil { log.Println(closeErr) } }() } _, err = trace.Traceroute(f.TracerouteMethod, conf) if err != nil { log.Fatal(err) } fmt.Println() } func (f *FastTracer) testAll_v6() { f.testCT_v6() println() f.testCU_v6() println() f.testCM_v6() println() f.testEDU_v6() } func (f *FastTracer) testCT_v6() { f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163) f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163) f.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CT163) f.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163) } func (f *FastTracer) testCU_v6() { f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169) f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169) f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU9929) f.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CU169) f.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169) } func (f *FastTracer) testCM_v6() { f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM) f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM) f.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CM) f.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM) } func (f *FastTracer) testEDU_v6() { f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.EDU) f.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.EDU) f.tracert_v6(TestIPsCollection.Hefei.Location, TestIPsCollection.Hefei.EDU) f.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.EDU) // 科技网暂时算在EDU里面,等拿到了足够多的数据再分离出去,单独用于测试 f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST) } func (f *FastTracer) testFastBJ_v6() { f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163) f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169) f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM) //f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) //f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST) } func (f *FastTracer) testFastSH_v6() { f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163) f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169) f.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM) } func (f *FastTracer) testFastGZ_v6() { f.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163) f.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169) f.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM) } func FastTestv6(traceMode trace.Method, paramsFastTrace ParamsFastTrace) { choice := readFastTestv6Choice() ft := FastTracer{ ParamsFastTrace: paramsFastTrace, TracerouteMethod: fastTestMethod(traceMode), } // 建立 WebSocket 连接 w := wshandle.NewWithContext(paramsFastTrace.Context) w.Interrupt = make(chan os.Signal, 1) signal.Notify(w.Interrupt, os.Interrupt) defer func() { w.Close() }() runFastTestv6Selection(&ft, choice) } func readFastTestv6Choice() string { var choice string fmt.Println("您想测试哪些ISP的路由?\n1. 北京三网快速测试\n2. 上海三网快速测试\n3. 广州三网快速测试\n4. 全国电信\n5. 全国联通\n6. 全国移动\n7. 全国教育网\n8. 全国五网") fmt.Print("请选择选项:") if _, err := fmt.Scanln(&choice); err != nil { return "1" } return choice } func fastTestMethod(traceMode trace.Method) trace.Method { switch traceMode { case trace.ICMPTrace, trace.TCPTrace, trace.UDPTrace: return traceMode default: return trace.ICMPTrace } } func runFastTestv6Selection(ft *FastTracer, choice string) { switch choice { case "1": ft.testFastBJ_v6() case "2": ft.testFastSH_v6() case "3": ft.testFastGZ_v6() case "4": ft.testCT_v6() case "5": ft.testCU_v6() case "6": ft.testCM_v6() case "7": ft.testEDU_v6() case "8": ft.testAll_v6() default: ft.testFastBJ_v6() } } ================================================ FILE: fast_trace/fast_trace.go ================================================ package fastTrace import ( "bufio" "context" "fmt" "io" "log" "net" "os" "os/signal" "strings" "time" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/printer" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/tracelog" "github.com/nxtrace/NTrace-core/util" "github.com/nxtrace/NTrace-core/wshandle" ) type FastTracer struct { TracerouteMethod trace.Method ParamsFastTrace ParamsFastTrace } type ParamsFastTrace struct { Context context.Context OSType int ICMPMode int SrcDev string SrcAddr string DstPort int BeginHop int MaxHops int MaxAttempts int RDNS bool AlwaysWaitRDNS bool Lang string PktSize int PacketSizeSet bool TOS int Timeout time.Duration File string Dot string OutputPath string } type IpListElement struct { Ip string Desc string Version4 bool // true for IPv4, false for IPv6 } func resolveTraceMethod(traceMode trace.Method) trace.Method { switch traceMode { case trace.TCPTrace: return trace.TCPTrace case trace.UDPTrace: return trace.UDPTrace default: return trace.ICMPTrace } } func resolveFastTraceSourceAddr(srcDev string, wantV4 bool) string { dev, devErr := net.InterfaceByName(srcDev) if devErr != nil || dev == nil { return "" } addrs, err := dev.Addrs() if err != nil { return "" } var candidate string for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) if !ok { continue } isV4 := ipNet.IP.To4() != nil if isV4 != wantV4 { continue } candidate = ipNet.IP.String() parsed := net.ParseIP(candidate) if parsed != nil && !(parsed.IsPrivate() || parsed.IsLoopback() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast()) { return candidate } } return candidate } func withFastTraceSourceAddr(params ParamsFastTrace, wantV4 bool) ParamsFastTrace { if params.SrcDev != "" { if srcAddr := resolveFastTraceSourceAddr(params.SrcDev, wantV4); srcAddr != "" { params.SrcAddr = srcAddr } } return params } func promptFastTraceChoice(prompt, defaultChoice string) string { fmt.Print(prompt) var choice string if _, err := fmt.Scanln(&choice); err != nil { return defaultChoice } return choice } func initFastTraceWS(ctx context.Context) *wshandle.WsConn { w := wshandle.NewWithContext(ctx) w.Interrupt = make(chan os.Signal, 1) signal.Notify(w.Interrupt, os.Interrupt) return w } func closeFastTraceWS(w *wshandle.WsConn) { if w != nil { w.Close() } } func newFastTracer(traceMode trace.Method, params ParamsFastTrace) FastTracer { return FastTracer{ TracerouteMethod: resolveTraceMethod(traceMode), ParamsFastTrace: params, } } func runFastTraceByChoice(ft FastTracer, choice string) { switch choice { case "2": ft.testFastSH() case "3": ft.testFastGZ() case "4": ft.testCT() case "5": ft.testCU() case "6": ft.testCM() case "7": ft.testEDU() case "8": ft.testAll() default: ft.testFastBJ() } } func parseIPListLine(ctx context.Context, line string) (IpListElement, bool) { parts := strings.SplitN(line, " ", 2) if len(parts) == 0 { return IpListElement{}, false } ip := parts[0] desc := ip if len(parts) == 2 { desc = parts[1] } parsedIP := net.ParseIP(ip) if parsedIP == nil { netIP, err := util.DomainLookUpWithContext(ctx, ip, "all", "", true) if err != nil { fmt.Printf("Ignoring invalid IP: %s\n", ip) return IpListElement{}, false } ip = netIP.String() } return IpListElement{ Ip: ip, Desc: desc, Version4: strings.Contains(ip, "."), }, true } func loadIPList(ctx context.Context, filePath string) []IpListElement { file, err := os.Open(filePath) if err != nil { fmt.Println("Error opening file:", err) return nil } defer func(file *os.File) { err := file.Close() if err != nil { log.Fatal(err) } }(file) ipList := make([]IpListElement, 0) scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if ipElem, ok := parseIPListLine(ctx, line); ok { ipList = append(ipList, ipElem) } else if strings.TrimSpace(line) != "" { fmt.Printf("Ignoring invalid line: %s\n", line) } } if err := scanner.Err(); err != nil { fmt.Println("Error reading file:", err) } return ipList } func printFileTraceHeader(ip IpListElement, params ParamsFastTrace, tracerouteMethod trace.Method) { fmt.Fprintf(color.Output, "%s\n", color.New(color.FgYellow, color.Bold).Sprint("『 "+ip.Desc+"』")) dst := ip.Ip if util.EnableHidDstIP { dst = util.HideIPPart(ip.Ip) } displayPacketSize := params.PktSize if !params.PacketSizeSet { displayPacketSize = trace.DefaultPacketSize(tracerouteMethod, net.ParseIP(ip.Ip)) } fmt.Printf("traceroute to %s, %d hops max, %s, %s mode\n", dst, params.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(tracerouteMethod))) } func buildFileTraceConfig(params ParamsFastTrace, tracerouteMethod trace.Method, ip IpListElement) trace.Config { dstIP := net.ParseIP(ip.Ip) packetSize := params.PktSize if !params.PacketSizeSet { packetSize = trace.DefaultPacketSize(tracerouteMethod, dstIP) } packetSizeSpec, err := trace.NormalizePacketSize(tracerouteMethod, dstIP, packetSize) if err != nil { log.Fatal(err) } return trace.Config{ Context: params.Context, OSType: params.OSType, ICMPMode: params.ICMPMode, BeginHop: params.BeginHop, DstIP: dstIP, DstPort: params.DstPort, MaxHops: params.MaxHops, NumMeasurements: 3, ParallelRequests: 18, RDNS: params.RDNS, AlwaysWaitRDNS: params.AlwaysWaitRDNS, PacketInterval: 100, TTLInterval: 500, IPGeoSource: ipgeo.GetSource("LeoMoeAPI"), Timeout: params.Timeout, SrcAddr: resolveFastTraceSourceAddr(params.SrcDev, ip.Version4), PktSize: packetSizeSpec.PayloadSize, RandomPacketSize: packetSizeSpec.Random, TOS: params.TOS, Lang: params.Lang, } } func configureFastTraceRealtimePrinter(conf *trace.Config, outputPath, header string) (func() error, error) { if strings.TrimSpace(outputPath) == "" { conf.RealtimePrinter = printer.RealtimePrinter return nil, nil } fp, err := tracelog.OpenFile(outputPath) if err != nil { log.Printf("fast trace output open failed for %q: %v; falling back to stdout", outputPath, err) conf.RealtimePrinter = printer.RealtimePrinter return nil, nil } if err := tracelog.WriteHeader(fp, header); err != nil { _ = fp.Close() log.Printf("fast trace output header write failed for %q: %v; falling back to stdout", outputPath, err) conf.RealtimePrinter = printer.RealtimePrinter return nil, nil } conf.RealtimePrinter = tracelog.NewRealtimePrinter(io.MultiWriter(os.Stdout, fp)) return fp.Close, nil } func runFileTraceTarget(params ParamsFastTrace, tracerouteMethod trace.Method, ip IpListElement) { printFileTraceHeader(ip, params, tracerouteMethod) conf := buildFileTraceConfig(params, tracerouteMethod, ip) displayPacketSize := params.PktSize if !params.PacketSizeSet { displayPacketSize = trace.DefaultPacketSize(tracerouteMethod, net.ParseIP(ip.Ip)) } header := fmt.Sprintf("『%s』\ntraceroute to %s, %d hops max, %s, %s mode\n", ip.Desc, ip.Ip, params.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(tracerouteMethod))) cleanup, err := configureFastTraceRealtimePrinter(&conf, params.OutputPath, header) if err != nil { log.Println(err) return } if cleanup != nil { defer func() { if closeErr := cleanup(); closeErr != nil { log.Println(closeErr) } }() } if _, err := trace.Traceroute(tracerouteMethod, conf); err != nil { log.Println(err) return } fmt.Println() } func (f *FastTracer) tracert(location string, ispCollection ISPCollection) { fmt.Fprintf(color.Output, "%s\n", color.New(color.FgYellow, color.Bold).Sprintf("『%s %s 』", location, ispCollection.ISPName)) displayPacketSize := f.ParamsFastTrace.PktSize if !f.ParamsFastTrace.PacketSizeSet { displayPacketSize = trace.DefaultPacketSize(f.TracerouteMethod, net.ParseIP(ispCollection.IP)) } fmt.Printf("traceroute to %s, %d hops max, %s, %s mode\n", ispCollection.IP, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod))) // ip, err := util.DomainLookUp(ispCollection.IP, "4", "", true) ip, err := util.DomainLookUpWithContext(f.ParamsFastTrace.Context, ispCollection.IP, "4", f.ParamsFastTrace.Dot, true) if err != nil { log.Fatal(err) } packetSize := f.ParamsFastTrace.PktSize if !f.ParamsFastTrace.PacketSizeSet { packetSize = trace.DefaultPacketSize(f.TracerouteMethod, ip) } packetSizeSpec, err := trace.NormalizePacketSize(f.TracerouteMethod, ip, packetSize) if err != nil { log.Fatal(err) } var conf = trace.Config{ Context: f.ParamsFastTrace.Context, OSType: f.ParamsFastTrace.OSType, ICMPMode: f.ParamsFastTrace.ICMPMode, BeginHop: f.ParamsFastTrace.BeginHop, DstIP: ip, DstPort: f.ParamsFastTrace.DstPort, MaxHops: f.ParamsFastTrace.MaxHops, NumMeasurements: 3, MaxAttempts: f.ParamsFastTrace.MaxAttempts, ParallelRequests: 18, RDNS: f.ParamsFastTrace.RDNS, AlwaysWaitRDNS: f.ParamsFastTrace.AlwaysWaitRDNS, PacketInterval: 100, TTLInterval: 500, IPGeoSource: ipgeo.GetSource("LeoMoeAPI"), Timeout: f.ParamsFastTrace.Timeout, SrcAddr: f.ParamsFastTrace.SrcAddr, PktSize: packetSizeSpec.PayloadSize, RandomPacketSize: packetSizeSpec.Random, TOS: f.ParamsFastTrace.TOS, Lang: f.ParamsFastTrace.Lang, } header := fmt.Sprintf("『%s %s 』\ntraceroute to %s, %d hops max, %s, %s mode\n", location, ispCollection.ISPName, ispCollection.IP, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod))) cleanup, err := configureFastTraceRealtimePrinter(&conf, f.ParamsFastTrace.OutputPath, header) if err != nil { log.Println(err) return } if cleanup != nil { defer func() { if closeErr := cleanup(); closeErr != nil { log.Println(closeErr) } }() } _, err = trace.Traceroute(f.TracerouteMethod, conf) if err != nil { log.Println(err) return } fmt.Println() } func FastTest(traceMode trace.Method, paramsFastTrace ParamsFastTrace) { if paramsFastTrace.File != "" { testFile(paramsFastTrace, traceMode) return } fmt.Println("Hi,欢迎使用 Fast Trace 功能,请注意 Fast Trace 功能只适合新手使用\n因为国内网络复杂,我们设置的测试目标有限,建议普通用户自测以获得更加精准的路由情况") fmt.Println("请您选择要测试的 IP 类型\n1. IPv4\n2. IPv6") if promptFastTraceChoice("请选择选项:", "1") == "2" { paramsFastTrace = withFastTraceSourceAddr(paramsFastTrace, false) FastTestv6(traceMode, paramsFastTrace) return } paramsFastTrace = withFastTraceSourceAddr(paramsFastTrace, true) fmt.Println("您想测试哪些ISP的路由?\n1. 北京三网快速测试\n2. 上海三网快速测试\n3. 广州三网快速测试\n4. 全国电信\n5. 全国联通\n6. 全国移动\n7. 全国教育网\n8. 全国五网") choice := promptFastTraceChoice("请选择选项:", "1") w := initFastTraceWS(paramsFastTrace.Context) defer closeFastTraceWS(w) runFastTraceByChoice(newFastTracer(traceMode, paramsFastTrace), choice) } func testFile(paramsFastTrace ParamsFastTrace, traceMode trace.Method) { w := initFastTraceWS(paramsFastTrace.Context) defer closeFastTraceWS(w) tracerouteMethod := resolveTraceMethod(traceMode) for _, ip := range loadIPList(paramsFastTrace.Context, paramsFastTrace.File) { runFileTraceTarget(paramsFastTrace, tracerouteMethod, ip) } } func (f *FastTracer) testAll() { f.testCT() println() f.testCU() println() f.testCM() println() f.testEDU() } func (f *FastTracer) testCT() { f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163) f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CTCN2) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CTCN2) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CTCN2) f.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CT163) } func (f *FastTracer) testCU() { f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169) f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU9929) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU9929) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU9929) f.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CU169) } func (f *FastTracer) testCM() { f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM) f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CMIN2) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CMIN2) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CMIN2) f.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CM) } func (f *FastTracer) testEDU() { f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.EDU) f.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.EDU) f.tracert(TestIPsCollection.Hefei.Location, TestIPsCollection.Hefei.EDU) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.EDU) // 科技网暂时算在EDU里面,等拿到了足够多的数据再分离出去,单独用于测试 f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST) f.tracert(TestIPsCollection.Hefei.Location, TestIPsCollection.Hefei.CST) } func (f *FastTracer) testFastBJ() { f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163) f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169) f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM) //f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) //f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST) } func (f *FastTracer) testFastSH() { f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169) f.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM) } func (f *FastTracer) testFastGZ() { f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169) f.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM) } ================================================ FILE: fast_trace/fast_trace_test.go ================================================ package fastTrace import ( "testing" ) func TestTrace(t *testing.T) { //pFastTrace := ParamsFastTrace{ // SrcDev: "", // SrcAddr: "", // BeginHop: 1, // MaxHops: 30, // RDNS: false, // AlwaysWaitRDNS: false, // Lang: "", // PktSize: 52, //} //ft := FastTracer{ParamsFastTrace: pFastTrace} //// 建立 WebSocket 连接 //w := wshandle.New() //w.Interrupt = make(chan os.Signal, 1) //signal.Notify(w.Interrupt, os.Interrupt) //defer func() { // w.Conn.Close() //}() //fmt.Println("TCP v4") //ft.TracerouteMethod = trace.TCPTrace //ft.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) //fmt.Println("TCP v6") //ft.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) //fmt.Println("ICMP v4") //ft.TracerouteMethod = trace.ICMPTrace //ft.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) //fmt.Println("ICMP v6") //ft.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU) } ================================================ FILE: geofeed.example.csv ================================================ 154.48.0.0/12,,,,174,COGENT-NET 200.15.12.0/22,BR,BR-SP,Sao Paulo,2914,NTT-BACKBONE 2001:0418:1403::/48,US,US-VA,Ashburn,2914,NTT-BACKBONE ================================================ FILE: go.mod ================================================ module github.com/nxtrace/NTrace-core go 1.26.1 require ( github.com/akamensky/argparse v1.4.0 github.com/fatih/color v1.18.0 github.com/gin-gonic/gin v1.12.0 github.com/google/gopacket v1.1.19 github.com/gorilla/websocket v1.5.3 github.com/jsdelivr/globalping-cli v1.5.1 github.com/mattn/go-runewidth v0.0.21 github.com/oschwald/maxminddb-golang v1.13.1 github.com/rodaine/table v1.3.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 github.com/tidwall/gjson v1.18.0 github.com/tsosunchia/powclient v0.2.0 github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 ) require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jsdelivr/globalping-cli v1.5.1 h1:7RZNmIljSBXe0xBeOoGQHXZNwHo6zDuQ0BI9hF12gLY= github.com/jsdelivr/globalping-cli v1.5.1/go.mod h1:Gw70OWvN6hIt0t4hftyUhcHuJQMTn4CvoobJiaTU0qg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rodaine/table v1.3.1 h1:jBVgg1bEu5EzEdYSrwUUlQpayDtkvtTmgFS0FPAxOq8= github.com/rodaine/table v1.3.1/go.mod h1:VYCJRCHa2DpD25uFALcB6hi5ECF3eEJQVhCXRjHgXc4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tsosunchia/powclient v0.2.0 h1:BDrI3O69CbzarbD+CnnY10Kuwn8xlmtQR0m5tBp+BG8= github.com/tsosunchia/powclient v0.2.0/go.mod h1:fkb7tTW+HMH3ZWZzQUgwvvFKMj/8Ys+C8Sm/uGQzDA0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f h1:glX3VZCYwW1/OmFxOjazfCtBLxXB3YNZk9LF2lYx+Lw= github.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f/go.mod h1:gh//RKyt2Gesx3eOj3ulzrSQ60ySj2UA4qnOdrtarvg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201008064518-c1f3e3309c71/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/hoprender/group.go ================================================ package hoprender import ( "fmt" "github.com/nxtrace/NTrace-core/trace" ) type Group struct { IP string Index int Timings []string } func GroupHopAttempts(hops []trace.Hop) []Group { latestIP := "" indexByIP := make(map[string]int) groups := make([]Group, 0, len(hops)) for i, hop := range hops { if hop.Address == nil { if latestIP != "" { groups[indexByIP[latestIP]].Timings = append(groups[indexByIP[latestIP]].Timings, "* ms") } continue } ip := hop.Address.String() groupIdx, ok := indexByIP[ip] if !ok { group := Group{ IP: ip, Index: i, Timings: make([]string, 0, len(hops)), } if latestIP == "" { for j := 0; j < i; j++ { group.Timings = append(group.Timings, "* ms") } } groups = append(groups, group) groupIdx = len(groups) - 1 indexByIP[ip] = groupIdx } groups[groupIdx].Timings = append(groups[groupIdx].Timings, fmt.Sprintf("%.2f ms", hop.RTT.Seconds()*1000)) latestIP = ip } if latestIP == "" { return nil } return groups } ================================================ FILE: ipgeo/chunzhen.go ================================================ package ipgeo import ( "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/nxtrace/NTrace-core/util" ) func Chunzhen(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) { url := util.GetEnvDefault("NEXTTRACE_CHUNZHENURL", "http://127.0.0.1:2060") + "?ip=" + ip client := util.NewGeoHTTPClient(timeout) req, err := http.NewRequest("GET", url, nil) if err != nil { return &IPGeoData{}, fmt.Errorf("chunzhen: failed to create request: %w", err) } content, err := client.Do(req) if err != nil { log.Println("纯真 请求超时(2s),请切换其他API使用") return &IPGeoData{}, err } defer content.Body.Close() body, err := io.ReadAll(content.Body) if err != nil { return &IPGeoData{}, fmt.Errorf("chunzhen: failed to read response: %w", err) } var data map[string]interface{} err = json.Unmarshal(body, &data) if err != nil { return &IPGeoData{}, err } ipData, ok := data[ip].(map[string]interface{}) if !ok { return &IPGeoData{}, fmt.Errorf("chunzhen: unexpected response format for ip %s", ip) } city, _ := ipData["area"].(string) region, _ := ipData["country"].(string) var asn string if ipData["asn"] != nil { asn, _ = ipData["asn"].(string) } // 判断是否前两个字为香港或台湾 var country string provinces := []string{ "北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江", "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", "湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州", "云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆", "台湾", "香港", "澳门", } for _, province := range provinces { if strings.Contains(region, province) { country = "中国" city = region + city break } } if country == "" { country = region } return &IPGeoData{ Asnumber: asn, Country: country, City: city, }, nil } ================================================ FILE: ipgeo/dn42.go ================================================ package ipgeo import ( "strings" "time" "github.com/nxtrace/NTrace-core/dn42" ) func LtdCodeToCountryOrAreaName(Code string) string { regionName := []string{"United States", "Afghanistan", "Åland Islands", "Albania", "Algeria", "American Samoa", "Andorra", "Angola", "Anguilla", "Antarctica", "Antigua and Barbuda", "Argentina", "Armenia", "Aruba", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", " Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bermuda", "Bhutan", "Bolivia", "Bosnia and Herzegovina", "Botswana", "Bouvet Island", "Brazil", "British Indian Ocean Territory", "Brunei", "Bulgaria", "Burkina Faso", "Burundi", "Cambodia", "Cameroon", "Canada ", "Cape Verde", "Cayman Islands", "Central Africa", "Chad", "Chile", "China", "Christmas Island", "Cocos (Keeling) Islands", "Colombia", "Comoros", "Congo (Brazzaville)", "DRC", "Cook Islands", "Costa Rica", "Cote d'Ivoire", "Croatia", "Cuba", "Cyprus", "Czech Republic", " Denmark", "Djibouti", "Dominica", "Dominica", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Ethiopia", "Falkland Islands (Malvinas)", "Faroe Islands", "Fiji", "Finland", "France", "French Guiana", "French Polynesia", " French Southern Territories", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Gibraltar", "Greece", "Greenland", "Grenada", "Guadeloupe", "Guam", "Guatemala", "Guernsey", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Heard and McDonald Islands", "Vatican", "Honduras", "Hong Kong", "Hungary", "Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "British Isles of Man", "Israel", "Italy", "Jamaica", "Japan", "Jersey", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "North Korea", "South Korea", " Kuwait", "Kyrgyzstan", "Laos", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg", "Macao", "FYROM", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", " Marshall Islands", "Martinique", "Mauritania", "Mauritius", "Mayotte", "Mexico", "Micronesia (Federated States of)", "Moldova", "Monaco", "Mongolia", "Montenegro", "Montserrat", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "Netherlands Antilles", "New Caledonia", "New Zealand", "Nicaragua", "Niger", "Nigeria", "Niue", "Norfolk Island", "Northern Mariana", "Norway", "Oman", "Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Pitcairn", "Poland", "Portugal", "Puerto Rico", "Qatar", "Reunion", "Romania", "Russian Federation", "Rwanda", "St. Helena", "St. Kitts and Nevis", "St. Lucia", "St. Pierre and Miquelon", "St. Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Georgia and South Sandwich Islands", "Spain", "Sri Lanka", "Sudan", "Suriname", "Svalbard and Jan Mayen Islands", "Swaziland", "Sweden ", "Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "Timor-Leste", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Turks and Caicos Islands", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", " United Kingdom", "U.S. Minor Outlying Islands", "Uruguay", "Uzbekistan", "Vanuatu", "Venezuela", "Vietnam", "British Virgin Islands", "U.S. Virgin Islands", "Wallis and Futuna", "Western Sahara", "Yemen", "Zambia", "Zimbabwe"} regionCode := []string{"us", "af", "ax", "al", "dz", "as", "ad", "ao", "ai", "aq", "ag", "ar", "am", "aw", "au", "at", "az", "bs", "bh", "bd", "bb", "by", "be", "bz", "bj", "bm", "bt", "bo", "ba", "bw", "bv", "br", "io", "bn", "bg", "bf", "bi", "kh", "cm", "ca", "cv", "ky", "cf", "td", "cl", "cn", "cx", "cc", "co", "km", "cg", "cd", "ck", "cr", "ci", "hr", "cu", "cy", "cz", "dk", "dj", "dm", "do", "ec", "eg", "sv", "gq", "er", "ee", "et", "fk", "fo", "fj", "fi", "fr", "gf", "pf", "tf", "ga", "gm", "ge", "de", "gh", "gi", "gr", "gl", "gd", "gp", "gu", "gt", "gg", "gn", "gw", "gy", "ht", "hm", "va", "hn", "hk", "hu", "is", "in", "id", "ir", "iq", "ie", "im", "il", "it", "jm", "jp", "je", "jo", "kz", "ke", "ki", "kp", "kr", "kw", "kg", "la", "lv", "lb", "ls", "lr", "ly", "li", "lt", "lu", "mo", "mk", "mg", "mw", "my", "mv", "ml", "mt", "mh", "mq", "mr", "mu", "yt", "mx", "fm", "md", "mc", "mn", "me", "ms", "ma", "mz", "mm", "na", "nr", "np", "nl", "an", "nc", "nz", "ni", "ne", "ng", "nu", "nf", "mp", "no", "om", "pk", "pw", "ps", "pa", "pg", "py", "pe", "ph", "pn", "pl", "pt", "pr", "qa", "re", "ro", "ru", "rw", "sh", "kn", "lc", "pm", "vc", "ws", "sm", "st", "sa", "sn", "rs", "sc", "sl", "sg", "sk", "si", "sb", "so", "za", "gs", "es", "lk", "sd", "sr", "sj", "sz", "se", "ch", "sy", "tw", "tj", "tz", "th", "tl", "tg", "tk", "to", "tt", "tn", "tr", "tm", "tc", "tv", "ug", "ua", "ae", "gb", "um", "uy", "uz", "vu", "ve", "vn", "vg", "vi", "wf", "eh", "ye", "zm", "zw"} Code = strings.ToLower(Code) for i, v := range regionCode { if strings.Contains(Code, v) { return strings.TrimSpace(regionName[i]) } } return Code } func DN42(ip string, _ time.Duration, _ string, _ bool) (*IPGeoData, error) { data := &IPGeoData{} // 先解析传入过来的数据 ipTmp := strings.Split(ip, ",") if len(ipTmp) > 1 { ip = ipTmp[0] } // 先查找 GeoFeed if geo, find := dn42.GetGeoFeed(ip); find { data.Country = geo.LtdCode data.City = geo.City data.Asnumber = geo.ASN data.Owner = geo.IPWhois } // 如果没找到,查找 PTR if len(ipTmp) > 1 { // 存在 PTR 记录 if res, err := dn42.FindPtrRecord(ipTmp[1]); err == nil && res.LtdCode != "" { data.Country = res.LtdCode data.Prov = res.Region data.City = res.City } } data.Country = LtdCodeToCountryOrAreaName(data.Country) switch data.Country { case "Hong Kong": data.Country = "China" data.Prov = "Hong Kong" case "Taiwan": data.Country = "China" data.Prov = "Taiwan" case "Macao": data.Country = "China" data.Prov = "Macao" case "": data.Country = "Unknown" } return data, nil } ================================================ FILE: ipgeo/dn42_test.go ================================================ package ipgeo import ( "os" "path/filepath" "testing" "time" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDN42GeoFeedAndPtrIntegration(t *testing.T) { dir := t.TempDir() geofeedPath := filepath.Join(dir, "geofeed.csv") ptrPath := filepath.Join(dir, "ptr.csv") geofeedContent := "192.0.2.0/24,hk,HK,Hong Kong,AS65000,Example Owner\n" ptrContent := "HKG,hk,Hong Kong,Hong Kong\n" require.NoError(t, os.WriteFile(geofeedPath, []byte(geofeedContent), 0o644)) require.NoError(t, os.WriteFile(ptrPath, []byte(ptrContent), 0o644)) viper.Set("geoFeedPath", geofeedPath) viper.Set("ptrPath", ptrPath) t.Cleanup(viper.Reset) res, err := DN42("192.0.2.8,core.hongkong-1.example", time.Second, "", false) require.NoError(t, err) assert.Equal(t, "China", res.Country) assert.Equal(t, "Hong Kong", res.Prov) assert.Equal(t, "Hong Kong", res.City) assert.Equal(t, "AS65000", res.Asnumber) assert.Equal(t, "Example Owner", res.Owner) } func TestDN42PtrFallback(t *testing.T) { dir := t.TempDir() geofeedPath := filepath.Join(dir, "geofeed.csv") ptrPath := filepath.Join(dir, "ptr.csv") // geofeed does not cover the IP, forcing the PTR fallback path require.NoError(t, os.WriteFile(geofeedPath, []byte("198.18.0.0/15,us,US,Test,AS65010,Owner\n"), 0o644)) require.NoError(t, os.WriteFile(ptrPath, []byte("AMS,nl,Noord-Holland,Amsterdam\n"), 0o644)) viper.Set("geoFeedPath", geofeedPath) viper.Set("ptrPath", ptrPath) t.Cleanup(viper.Reset) res, err := DN42("198.51.100.25,edge.ams01.provider", time.Second, "", false) require.NoError(t, err) assert.Equal(t, "Netherlands", res.Country) assert.Equal(t, "Noord-Holland", res.Prov) assert.Equal(t, "Amsterdam", res.City) } func TestDN42UnknownDefaults(t *testing.T) { dir := t.TempDir() geofeedPath := filepath.Join(dir, "geofeed.csv") ptrPath := filepath.Join(dir, "ptr.csv") require.NoError(t, os.WriteFile(geofeedPath, []byte(""), 0o644)) require.NoError(t, os.WriteFile(ptrPath, []byte(""), 0o644)) viper.Set("geoFeedPath", geofeedPath) viper.Set("ptrPath", ptrPath) t.Cleanup(viper.Reset) res, err := DN42("10.0.0.1", time.Second, "", false) require.NoError(t, err) assert.Equal(t, "Unknown", res.Country) assert.Empty(t, res.Prov) assert.Empty(t, res.City) } ================================================ FILE: ipgeo/ipapicom.go ================================================ package ipgeo import ( "errors" "fmt" "io" "log" "net/http" "regexp" "strconv" "time" "github.com/tidwall/gjson" "github.com/nxtrace/NTrace-core/util" ) func IPApiCom(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) { url := token.BaseOrDefault("http://ip-api.com/json/") + ip + "?fields=status,message,country,regionName,city,isp,district,as,lat,lon" client := util.NewGeoHTTPClient(timeout) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("ip-api.com: failed to create request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0") content, err := client.Do(req) if err != nil { log.Println("ip-api.com 请求超时(2s),请切换其他API使用") return nil, err } defer content.Body.Close() body, err := io.ReadAll(content.Body) if err != nil { return nil, fmt.Errorf("ip-api.com: failed to read response: %w", err) } res := gjson.ParseBytes(body) if res.Get("status").String() != "success" { return &IPGeoData{}, errors.New("超过API阈值") } re := regexp.MustCompile("[0-9]+") var country = res.Get("country").String() var prov = res.Get("region").String() var city = res.Get("city").String() var district = res.Get("district").String() if util.StringInSlice(country, []string{"Hong Kong", "Taiwan", "Macao"}) { district = prov + " " + city + " " + district city = country prov = "" country = "China" } lat, _ := strconv.ParseFloat(res.Get("lat").String(), 32) lng, _ := strconv.ParseFloat(res.Get("lon").String(), 32) return &IPGeoData{ Asnumber: re.FindString(res.Get("as").String()), Country: country, City: city, Prov: prov, District: district, Owner: res.Get("isp").String(), Lat: lat, Lng: lng, }, nil } ================================================ FILE: ipgeo/ipdbone.go ================================================ package ipgeo import ( "errors" "fmt" "io" "net/http" "strconv" "sync" "time" "github.com/tidwall/gjson" "github.com/nxtrace/NTrace-core/config" "github.com/nxtrace/NTrace-core/util" ) // LangMap shows language mapping for IPDB.One API var LangMap = map[string]string{ "en": "en", "cn": "zh", } // IPDBOneConfig holds the configuration for IPDB.One service type IPDBOneConfig struct { BaseURL string ApiID string ApiKey string } // GetDefaultConfig returns the default configuration with fallback values func GetDefaultConfig() *IPDBOneConfig { return &IPDBOneConfig{ BaseURL: util.GetEnvDefault("IPDBONE_BASE_URL", "https://api.ipdb.one"), ApiID: util.GetEnvDefault("IPDBONE_API_ID", ""), ApiKey: util.GetEnvDefault("IPDBONE_API_KEY", ""), } } // IPDBOneTokenCache manages the caching of auth tokens type IPDBOneTokenCache struct { token string expiresAt time.Time mutex sync.RWMutex } // GetToken retrieves cached token if valid, otherwise returns empty string func (c *IPDBOneTokenCache) GetToken() string { c.mutex.RLock() defer c.mutex.RUnlock() if c.token == "" || time.Now().After(c.expiresAt) { return "" } return c.token } // SetToken updates the token with its expiration time func (c *IPDBOneTokenCache) SetToken(token string, expiresIn time.Duration) { c.mutex.Lock() defer c.mutex.Unlock() c.token = token c.expiresAt = time.Now().Add(expiresIn) } // IPDBOneClient handles communication with IPDB.One API type IPDBOneClient struct { config *IPDBOneConfig tokenCache *IPDBOneTokenCache tokenInit *sync.Once httpClient *http.Client } // NewIPDBOneClient creates a new client for IPDB.One with default configuration func NewIPDBOneClient() *IPDBOneClient { return &IPDBOneClient{ config: GetDefaultConfig(), tokenCache: &IPDBOneTokenCache{}, tokenInit: &sync.Once{}, httpClient: util.NewGeoHTTPClient(3 * time.Second), } } func (c *IPDBOneClient) cloneWithTimeout(timeout time.Duration) *IPDBOneClient { if c == nil || timeout <= 0 { return c } return &IPDBOneClient{ config: c.config, tokenCache: c.tokenCache, tokenInit: c.tokenInit, httpClient: util.NewGeoHTTPClient(timeout), } } // fetchToken requests a new authentication token from the API func (c *IPDBOneClient) fetchToken() error { authURL := c.config.BaseURL + "/auth/requestToken/query" req, err := http.NewRequest("GET", authURL, nil) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "NextTrace/"+config.Version) req.Header.Set("x-api-id", c.config.ApiID) req.Header.Set("x-api-key", c.config.ApiKey) resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } statusCode := gjson.Get(string(body), "code").Int() statusMessage := gjson.Get(string(body), "message").String() if statusCode != 200 { return errors.New("failed to authenticate: " + statusMessage) } token := gjson.Get(string(body), "data.token").String() if token == "" { return errors.New("authentication failed: empty token received") } // Cache token with a 30-second expiration c.tokenCache.SetToken(token, 30*time.Second) return nil } // ensureToken makes sure a valid token is available, fetching a new one if needed func (c *IPDBOneClient) ensureToken() error { var initErr error // Ensure API credentials are set if c.config.ApiID == "" || c.config.ApiKey == "" { return errors.New("api id or api key is not set") } // Initialize token the first time this is called c.tokenInit.Do(func() { initErr = c.fetchToken() }) if initErr != nil { return initErr } // If token expired or not available, get a new one if c.tokenCache.GetToken() == "" { return c.fetchToken() } return nil } // LookupIP queries the IP information from IPDB.One func (c *IPDBOneClient) LookupIP(ip string, lang string) (*IPGeoData, error) { // Ensure we have a valid token if err := c.ensureToken(); err != nil { return &IPGeoData{}, fmt.Errorf("ipdbone auth: %w", err) } // Map language code if needed langCode, ok := LangMap[lang] if !ok { langCode = "en" // Default to English } // Query the IP information queryURL := c.config.BaseURL + "/query/" + ip + "?lang=" + langCode req, err := http.NewRequest("GET", queryURL, nil) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "NextTrace/"+config.Version) req.Header.Set("Authorization", "Bearer "+c.tokenCache.GetToken()) resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } statusCode := gjson.Get(string(body), "code").Int() if statusCode != 200 { return nil, errors.New("failed to get IP info: " + gjson.Get(string(body), "message").String()) } return parseIPDBOneResponse(ip, body) } // parseIPDBOneResponse converts the API response to an IPGeoData struct func parseIPDBOneResponse(ip string, responseBody []byte) (*IPGeoData, error) { data := gjson.Get(string(responseBody), "data") result := &IPGeoData{ IP: ip, } parseIPDBOneGeo(data.Get("geo"), result) parseIPDBOneRouting(data.Get("routing"), result) return result, nil } func hasJSONValue(result gjson.Result) bool { return result.Exists() && result.Type != gjson.Null } func parseIPDBOneGeo(geoData gjson.Result, result *IPGeoData) { if result == nil || !geoData.Exists() { return } coordinate := geoData.Get("coordinate") if hasJSONValue(coordinate) && coordinate.IsArray() && len(coordinate.Array()) >= 2 { result.Lat = coordinate.Array()[0].Float() result.Lng = coordinate.Array()[1].Float() } if country := geoData.Get("country"); hasJSONValue(country) { result.Country = country.String() } if region := geoData.Get("region"); hasJSONValue(region) { result.Prov = region.String() } if city := geoData.Get("city"); hasJSONValue(city) { result.City = city.String() } } func parseIPDBOneRouting(routingData gjson.Result, result *IPGeoData) { if result == nil || !routingData.Exists() { return } asnData := routingData.Get("asn") if number := asnData.Get("number"); hasJSONValue(number) { result.Asnumber = strconv.FormatInt(number.Int(), 10) } if asnName := routingData.Get("asn.name"); hasJSONValue(asnName) { result.Owner = asnName.String() } if domain := routingData.Get("asn.domain"); hasJSONValue(domain) { result.Owner = domain.String() } if asName := routingData.Get("asn.asname"); hasJSONValue(asName) { result.Whois = asName.String() } } // Global client instance for backward compatibility var defaultClient = NewIPDBOneClient() // IPDBOne looks up IP information from IPDB.One (maintains backward compatibility) func IPDBOne(ip string, timeout time.Duration, lang string, _ bool) (*IPGeoData, error) { client := defaultClient if timeout > 0 { client = defaultClient.cloneWithTimeout(timeout) } return client.LookupIP(ip, lang) } ================================================ FILE: ipgeo/ipfilter.go ================================================ package ipgeo import ( "net" ) type cidrFilterRule struct { cidr string whois string } var reservedCIDRRules = []cidrFilterRule{ {cidr: "0.0.0.0/8", whois: "RFC1122"}, {cidr: "100.64.0.0/10", whois: "RFC6598"}, {cidr: "127.0.0.0/8", whois: "RFC1122"}, {cidr: "169.254.0.0/16", whois: "RFC3927"}, {cidr: "192.0.0.0/24", whois: "RFC6890"}, {cidr: "192.0.2.0/24", whois: "RFC5737"}, {cidr: "192.88.99.0/24", whois: "RFC3068"}, {cidr: "198.18.0.0/15", whois: "RFC2544"}, {cidr: "198.51.100.0/24", whois: "RFC5737"}, {cidr: "203.0.113.0/24", whois: "RFC5737"}, {cidr: "224.0.0.0/4", whois: "RFC5771"}, {cidr: "255.255.255.255/32", whois: "RFC0919"}, {cidr: "240.0.0.0/4", whois: "RFC1112"}, {cidr: "fe80::/10", whois: "RFC4291"}, {cidr: "ff00::/8", whois: "RFC4291"}, {cidr: "fec0::/10", whois: "RFC3879"}, {cidr: "fe00::/9", whois: "RFC4291"}, {cidr: "64:ff9b::/96", whois: "RFC6052"}, {cidr: "0::/96", whois: "RFC4291"}, {cidr: "64:ff9b:1::/48", whois: "RFC6052"}, {cidr: "2001:db8::/32", whois: "RFC3849"}, {cidr: "2002::/16", whois: "RFC3056"}, } var dodCIDRRules = []cidrFilterRule{ {cidr: "6.0.0.0/8", whois: "DOD"}, {cidr: "7.0.0.0/8", whois: "DOD"}, {cidr: "11.0.0.0/8", whois: "DOD"}, {cidr: "21.0.0.0/8", whois: "DOD"}, {cidr: "22.0.0.0/8", whois: "DOD"}, {cidr: "26.0.0.0/8", whois: "DOD"}, {cidr: "28.0.0.0/8", whois: "DOD"}, {cidr: "29.0.0.0/8", whois: "DOD"}, {cidr: "30.0.0.0/8", whois: "DOD"}, {cidr: "33.0.0.0/8", whois: "DOD"}, {cidr: "55.0.0.0/8", whois: "DOD"}, {cidr: "214.0.0.0/8", whois: "DOD"}, {cidr: "215.0.0.0/8", whois: "DOD"}, } func cidrRangeContains(cidrRange string, checkIP string) bool { _, ipNet, err := net.ParseCIDR(cidrRange) if err != nil { return false } secondIP := net.ParseIP(checkIP) return ipNet.Contains(secondIP) } func matchCIDRFilterRule(ip string, rules []cidrFilterRule) (string, bool) { for _, rule := range rules { if cidrRangeContains(rule.cidr, ip) { return rule.whois, true } } return "", false } func classifyPrivateIP(parsedIP net.IP, rawIP string) (string, bool) { if parsedIP == nil || !parsedIP.IsPrivate() { return "", false } if cidrRangeContains("fc00::/7", rawIP) { return "RFC4193", true } return "RFC1918", true } func isInvalidScopedIPv6(parsedIP net.IP, rawIP string) bool { return parsedIP != nil && parsedIP.To4() == nil && !cidrRangeContains("2000::/3", rawIP) } // Filter 被选到的返回 geodata, true 否则返回 nil, false func Filter(ip string) (*IPGeoData, bool) { parsedIP := net.ParseIP(ip) if parsedIP == nil { return nil, false } if whois, ok := matchCIDRFilterRule(ip, reservedCIDRRules); ok { return &IPGeoData{Whois: whois}, true } if whois, ok := classifyPrivateIP(parsedIP, ip); ok { return &IPGeoData{Whois: whois}, true } if whois, ok := matchCIDRFilterRule(ip, dodCIDRRules); ok { return &IPGeoData{Whois: whois}, true } if isInvalidScopedIPv6(parsedIP, ip) { return &IPGeoData{Whois: "INVALID"}, true } return nil, false } ================================================ FILE: ipgeo/ipfilter_test.go ================================================ package ipgeo import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ──────── cidrRangeContains ──────── func TestCidrRangeContains_Match(t *testing.T) { assert.True(t, cidrRangeContains("10.0.0.0/8", "10.1.2.3")) } func TestCidrRangeContains_NoMatch(t *testing.T) { assert.False(t, cidrRangeContains("10.0.0.0/8", "11.0.0.1")) } func TestCidrRangeContains_InvalidCIDR(t *testing.T) { assert.False(t, cidrRangeContains("invalid", "10.0.0.1")) } func TestCidrRangeContains_InvalidIP(t *testing.T) { // net.ParseIP("notanip") returns nil; ipNet.Contains(nil) returns false assert.False(t, cidrRangeContains("10.0.0.0/8", "notanip")) } // ──────── Filter: IPv4 RFC ranges ──────── func TestFilter_RFC1918_Private(t *testing.T) { for _, ip := range []string{"10.0.0.1", "172.16.0.1", "192.168.1.1"} { geo, ok := Filter(ip) require.True(t, ok, "expected %s to be filtered", ip) assert.Equal(t, "RFC1918", geo.Whois, "ip=%s", ip) } } func TestFilter_Loopback(t *testing.T) { geo, ok := Filter("127.0.0.1") require.True(t, ok) assert.Equal(t, "RFC1122", geo.Whois) } func TestFilter_LinkLocal(t *testing.T) { geo, ok := Filter("169.254.1.1") require.True(t, ok) assert.Equal(t, "RFC3927", geo.Whois) } func TestFilter_CGNAT(t *testing.T) { geo, ok := Filter("100.64.0.1") require.True(t, ok) assert.Equal(t, "RFC6598", geo.Whois) } func TestFilter_Documentation_192_0_2(t *testing.T) { geo, ok := Filter("192.0.2.1") require.True(t, ok) assert.Equal(t, "RFC5737", geo.Whois) } func TestFilter_Documentation_198_51_100(t *testing.T) { geo, ok := Filter("198.51.100.1") require.True(t, ok) assert.Equal(t, "RFC5737", geo.Whois) } func TestFilter_Documentation_203_0_113(t *testing.T) { geo, ok := Filter("203.0.113.1") require.True(t, ok) assert.Equal(t, "RFC5737", geo.Whois) } func TestFilter_Benchmark(t *testing.T) { geo, ok := Filter("198.18.0.1") require.True(t, ok) assert.Equal(t, "RFC2544", geo.Whois) } func TestFilter_Multicast(t *testing.T) { geo, ok := Filter("224.0.0.1") require.True(t, ok) assert.Equal(t, "RFC5771", geo.Whois) } func TestFilter_DOD(t *testing.T) { for _, ip := range []string{"6.0.0.1", "7.0.0.1", "11.0.0.1", "21.0.0.1", "22.0.0.1", "26.0.0.1", "28.0.0.1", "29.0.0.1", "30.0.0.1", "33.0.0.1", "55.0.0.1", "214.0.0.1", "215.0.0.1"} { geo, ok := Filter(ip) require.True(t, ok, "expected %s to be filtered as DOD", ip) assert.Equal(t, "DOD", geo.Whois, "ip=%s", ip) } } func TestFilter_PublicIPv4_NotFiltered(t *testing.T) { _, ok := Filter("8.8.8.8") assert.False(t, ok) } func TestFilter_PublicIPv4_1_1_1_1_NotFiltered(t *testing.T) { _, ok := Filter("1.1.1.1") assert.False(t, ok) } // ──────── Filter: IPv6 ranges ──────── func TestFilter_IPv6_LinkLocal(t *testing.T) { geo, ok := Filter("fe80::1") require.True(t, ok) assert.Equal(t, "RFC4291", geo.Whois) } func TestFilter_IPv6_ULA(t *testing.T) { geo, ok := Filter("fd00::1") require.True(t, ok) assert.Equal(t, "RFC4193", geo.Whois) } func TestFilter_IPv6_Documentation(t *testing.T) { geo, ok := Filter("2001:db8::1") require.True(t, ok) assert.Equal(t, "RFC3849", geo.Whois) } func TestFilter_IPv6_Multicast(t *testing.T) { geo, ok := Filter("ff02::1") require.True(t, ok) assert.Equal(t, "RFC4291", geo.Whois) } func TestFilter_IPv6_GlobalUnicast_NotFiltered(t *testing.T) { _, ok := Filter("2606:4700::1") assert.False(t, ok) } func TestFilter_IPv6_InvalidScope(t *testing.T) { geo, ok := Filter("100::1") require.True(t, ok) assert.Equal(t, "INVALID", geo.Whois) } ================================================ FILE: ipgeo/ipgeo.go ================================================ package ipgeo import ( "strings" "time" "github.com/nxtrace/NTrace-core/util" ) type IPGeoData struct { IP string `json:"ip"` Asnumber string `json:"asnumber"` Country string `json:"country"` CountryEn string `json:"country_en"` Prov string `json:"prov"` ProvEn string `json:"prov_en"` City string `json:"city"` CityEn string `json:"city_en"` District string `json:"district"` Owner string `json:"owner"` Isp string `json:"isp"` Domain string `json:"domain"` Whois string `json:"whois"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Prefix string `json:"prefix"` Router map[string][]string `json:"router"` Source string `json:"source"` } type Source = func(ip string, timeout time.Duration, lang string, maptrace bool) (*IPGeoData, error) func GetSource(s string) Source { switch strings.ToUpper(s) { case "DN42": return DN42 case "LEOMOEAPI": return LeoIP case "IP.SB": return IPSB case "IPINSIGHT": return IPInSight case "IPAPI.COM": return IPApiCom case "IP-API.COM": return IPApiCom case "IPINFO": return IPInfo case "IPINFOLOCAL": return IPInfoLocal case "CHUNZHEN": return Chunzhen case "DISABLE-GEOIP": return disableGeoIP case "IPDB.ONE": return IPDBOne default: return LeoIP } } func GetSourceWithGeoDNS(s string, dotServer string) Source { base := GetSource(s) dotServer = strings.TrimSpace(strings.ToLower(dotServer)) if base == nil || dotServer == "" { return base } return func(ip string, timeout time.Duration, lang string, maptrace bool) (*IPGeoData, error) { return util.WithGeoDNSResolver(dotServer, func() (*IPGeoData, error) { return base(ip, timeout, lang, maptrace) }) } } func disableGeoIP(string, time.Duration, string, bool) (*IPGeoData, error) { return &IPGeoData{}, nil } ================================================ FILE: ipgeo/ipgeo_test.go ================================================ package ipgeo import ( "reflect" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetSourceMappings(t *testing.T) { t.Helper() tests := []struct { name string input string want Source }{ {name: "dn42", input: "DN42", want: DN42}, {name: "leo default", input: "LEOMOEAPI", want: LeoIP}, {name: "ipsb", input: "ip.sb", want: IPSB}, {name: "ipinsight", input: "ipinsight", want: IPInSight}, {name: "ipapi alias", input: "ip-api.com", want: IPApiCom}, {name: "ipapi uppercase", input: "IPAPI.COM", want: IPApiCom}, {name: "ipinfo", input: "IPINFO", want: IPInfo}, {name: "ipinfo local", input: "ipinfolocal", want: IPInfoLocal}, {name: "chunzhen", input: "ChunZhen", want: Chunzhen}, {name: "disable geoip", input: "disable-geoip", want: disableGeoIP}, {name: "ipdb", input: "IPDB.One", want: IPDBOne}, {name: "fallback", input: "unknown", want: LeoIP}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { got := GetSource(tc.input) require.NotNil(t, got) assert.Equal(t, reflect.ValueOf(tc.want).Pointer(), reflect.ValueOf(got).Pointer()) }) } } func TestDisableGeoIP(t *testing.T) { res, err := disableGeoIP("1.1.1.1", time.Second, "en", false) require.NoError(t, err) assert.Equal(t, &IPGeoData{}, res) } ================================================ FILE: ipgeo/ipinfo.go ================================================ package ipgeo import ( "io" "strconv" "strings" "time" "github.com/tidwall/gjson" "github.com/nxtrace/NTrace-core/util" ) func IPInfo(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) { url := token.BaseOrDefault("http://ipinfo.io/") + ip + "?token=" + token.ipinfo client := util.NewGeoHTTPClient(timeout) resp, err := client.Get(url) //resp, err := http.Get("https://ipinfo.io/" + ip + "?token=" + token.ipinfo) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } res := gjson.ParseBytes(body) // ISO-3166 转换 var countryMap = map[string]string{ "AF": "Afghanistan", "AX": "Åland Islands", "AL": "Albania", "DZ": "Algeria", "AS": "American Samoa", "AD": "Andorra", "AO": "Angola", "AI": "Anguilla", "AQ": "Antarctica", "AG": "Antigua and Barbuda", "AR": "Argentina", "AM": "Armenia", "AW": "Aruba", "AU": "Australia", "AT": "Austria", "AZ": "Azerbaijan", "BH": "Bahrain", "BS": "Bahamas", "BD": "Bangladesh", "BB": "Barbados", "BY": "Belarus", "BE": "Belgium", "BZ": "Belize", "BJ": "Benin", "BM": "Bermuda", "BT": "Bhutan", "BO": "Bolivia", "BQ": "Bonaire", "BA": "Bosnia and Herzegovina", "BW": "Botswana", "BV": "Bouvet Island", "BR": "Brazil", "IO": "British Indian Ocean Territory", "BN": "Brunei Darussalam", "BG": "Bulgaria", "BF": "Burkina Faso", "BI": "Burundi", "KH": "Cambodia", "CM": "Cameroon", "CA": "Canada", "CV": "Cape Verde", "KY": "Cayman Islands", "CF": "Central African Republic", "TD": "Chad", "CL": "Chile", "CN": "China", "CX": "Christmas Island", "CC": "Cocos (Keeling) Islands", "CO": "Colombia", "KM": "Comoros", "CG": "Congo", "CD": "Congo", "CK": "Cook Islands", "CR": "Costa Rica", "CI": "Côte d'Ivoire", "HR": "Croatia", "CU": "Cuba", "CW": "Curaçao", "CY": "Cyprus", "CZ": "Czech Republic", "DK": "Denmark", "DJ": "Djibouti", "DM": "Dominica", "DO": "Dominican Republic", "EC": "Ecuador", "EG": "Egypt", "SV": "El Salvador", "GQ": "Equatorial Guinea", "ER": "Eritrea", "EE": "Estonia", "ET": "Ethiopia", "FK": "Falkland Islands (Malvinas)", "FO": "Faroe Islands", "FJ": "Fiji", "FI": "Finland", "FR": "France", "GF": "French Guiana", "PF": "French Polynesia", "TF": "French Southern Territories", "GA": "Gabon", "GM": "Gambia", "GE": "Georgia", "DE": "Germany", "GH": "Ghana", "GI": "Gibraltar", "GR": "Greece", "GL": "Greenland", "GD": "Grenada", "GP": "Guadeloupe", "GU": "Guam", "GT": "Guatemala", "GG": "Guernsey", "GN": "Guinea", "GW": "Guinea-Bissau", "GY": "Guyana", "HT": "Haiti", "HM": "Heard Island and McDonald Islands", "VA": "Holy See (Vatican City State)", "HN": "Honduras", "HK": "Hong Kong", "HU": "Hungary", "IS": "Iceland", "IN": "India", "ID": "Indonesia", "IR": "Iran", "IQ": "Iraq", "IE": "Ireland", "IM": "Isle of Man", "IL": "Israel", "IT": "Italy", "JM": "Jamaica", "JP": "Japan", "JE": "Jersey", "JO": "Jordan", "KZ": "Kazakhstan", "KE": "Kenya", "KI": "Kiribati", "KP": "Korea", "KR": "Korea", "KW": "Kuwait", "KG": "Kyrgyzstan", "LA": "Lao People's Democratic Republic", "LV": "Latvia", "LB": "Lebanon", "LS": "Lesotho", "LR": "Liberia", "LY": "Libya", "LI": "Liechtenstein", "LT": "Lithuania", "LU": "Luxembourg", "MO": "Macao", "MK": "Macedonia", "MG": "Madagascar", "MW": "Malawi", "MY": "Malaysia", "MV": "Maldives", "ML": "Mali", "MT": "Malta", "MH": "Marshall Islands", "MQ": "Martinique", "MR": "Mauritania", "MU": "Mauritius", "YT": "Mayotte", "MX": "Mexico", "FM": "Micronesia", "MD": "Moldova", "MC": "Monaco", "MN": "Mongolia", "ME": "Montenegro", "MS": "Montserrat", "MA": "Morocco", "MZ": "Mozambique", "MM": "Myanmar", "NA": "Namibia", "NR": "Nauru", "NP": "Nepal", "NL": "Netherlands", "NC": "New Caledonia", "NZ": "New Zealand", "NI": "Nicaragua", "NE": "Niger", "NG": "Nigeria", "NU": "Niue", "NF": "Norfolk Island", "MP": "Northern Mariana Islands", "NO": "Norway", "OM": "Oman", "PK": "Pakistan", "PW": "Palau", "PS": "Palestine", "PA": "Panama", "PG": "Papua New Guinea", "PY": "Paraguay", "PE": "Peru", "PH": "Philippines", "PN": "Pitcairn", "PL": "Poland", "PT": "Portugal", "PR": "Puerto Rico", "QA": "Qatar", "RE": "Réunion", "RO": "Romania", "RU": "Russian Federation", "RW": "Rwanda", "BL": "Saint Barthélemy", "SH": "Saint Helena", "KN": "Saint Kitts and Nevis", "LC": "Saint Lucia", "MF": "Saint Martin (French part)", "PM": "Saint Pierre and Miquelon", "VC": "Saint Vincent and the Grenadines", "WS": "Samoa", "SM": "San Marino", "ST": "Sao Tome and Principe", "SA": "Saudi Arabia", "SN": "Senegal", "RS": "Serbia", "SC": "Seychelles", "SL": "Sierra Leone", "SG": "Singapore", "SX": "Sint Maarten (Dutch part)", "SK": "Slovakia", "SI": "Slovenia", "SB": "Solomon Islands", "SO": "Somalia", "ZA": "South Africa", "GS": "South Georgia and the South Sandwich Islands", "SS": "South Sudan", "ES": "Spain", "LK": "Sri Lanka", "SD": "Sudan", "SR": "Suriname", "SJ": "Svalbard and Jan Mayen", "SZ": "Swaziland", "SE": "Sweden", "CH": "Switzerland", "SY": "Syrian Arab Republic", "TW": "Taiwan", "TJ": "Tajikistan", "TZ": "Tanzania", "TH": "Thailand", "TL": "Timor-Leste", "TG": "Togo", "TK": "Tokelau", "TO": "Tonga", "TT": "Trinidad and Tobago", "TN": "Tunisia", "TR": "Turkey", "TM": "Turkmenistan", "TC": "Turks and Caicos Islands", "TV": "Tuvalu", "UG": "Uganda", "UA": "Ukraine", "AE": "United Arab Emirates", "GB": "United Kingdom", "US": "United States of America", "UM": "United States Minor Outlying Islands", "UY": "Uruguay", "UZ": "Uzbekistan", "VU": "Vanuatu", "VE": "Venezuela", "VN": "Viet Nam", "VG": "Virgin Islands", "VI": "Virgin Islands", "WF": "Wallis and Futuna", "EH": "Western Sahara", "YE": "Yemen", "ZM": "Zambia", "ZW": "Zimbabwe", } var country = res.Get("country").String() var prov = res.Get("region").String() var city = res.Get("city").String() var district = "" if util.StringInSlice(country, []string{"TW", "MO", "HK"}) { district = prov + " " + city city = countryMap[country] prov = "" country = "CN" } country = countryMap[country] var anycast = false if res.Get("anycast").String() == "true" { country = "ANYCAST" prov = "ANYCAST" city = "" anycast = true } i := strings.Index(res.Get("org").String(), " ") var owner string if i == -1 { owner = "" } else { owner = res.Get("org").String()[i:] } var asnumber = "" // 有时候不返回asn或其本身没有asn if strings.HasPrefix(res.Get("org").String(), "AS") { asnumber = strings.Fields(strings.TrimPrefix(res.Get("org").String(), "AS"))[0] } //"loc": "34.0522,-118.2437", var lat, lng float64 if res.Get("loc").String() != "" { lat, _ = strconv.ParseFloat(strings.Split(res.Get("loc").String(), ",")[0], 32) lng, _ = strconv.ParseFloat(strings.Split(res.Get("loc").String(), ",")[1], 32) } if anycast { lat, lng = 0, 0 } return &IPGeoData{ Asnumber: asnumber, Country: country, City: city, Prov: prov, District: district, Owner: owner, Lat: lat, Lng: lng, }, nil } ================================================ FILE: ipgeo/ipinfoLocal.go ================================================ package ipgeo import ( "errors" "fmt" "net" "os" "path/filepath" "runtime" "strings" "time" "github.com/oschwald/maxminddb-golang" "github.com/nxtrace/NTrace-core/util" ) const ( ipinfoDataBaseFilename = "ipinfoLocal.mmdb" ) // Cache the path of the ipinfoLocal.mmdb file var ipinfoDataBasePath = "" // We will try to get the path of the ipinfoLocal.mmdb file in the following order: // 1. Use the value of the environment variable NEXTTRACE_IPINFOLOCALPATH // 2. Search in the current folder and the executable folder // 3. Search in /usr/local/share/nexttrace/ and /usr/share/nexttrace/ (for Unix/Linux) // If the file is found, the path will be stored in the ipinfoDataBasePath variable func getIPInfoLocalPath() error { if ipinfoDataBasePath != "" { return nil } // NEXTTRACE_IPINFOLOCALPATH path := util.GetEnvDefault("NEXTTRACE_IPINFOLOCALPATH", "") if path != "" { if _, err := os.Stat(path); err == nil { ipinfoDataBasePath = path return nil } return errors.New("NEXTTRACE_IPINFOLOCALPATH is set but the file does not exist") } var folders []string // current folder if cur, err := os.Getwd(); err == nil { folders = append(folders, cur+string(filepath.Separator)) } // exeutable folder if exe, err := os.Executable(); err == nil { folders = append(folders, filepath.Dir(exe)+string(filepath.Separator)) } if runtime.GOOS != "windows" { folders = append(folders, "/usr/local/share/nexttrace/") folders = append(folders, "/usr/share/nexttrace/") } for _, folder := range folders { if _, err := os.Stat(folder + ipinfoDataBaseFilename); err == nil { ipinfoDataBasePath = folder + ipinfoDataBaseFilename return nil } } return errors.New("no ipinfoLocal.mmdb found") } func IPInfoLocal(ip string, _ time.Duration, _ string, _ bool) (*IPGeoData, error) { if err := getIPInfoLocalPath(); err != nil { return nil, fmt.Errorf("ipinfoLocal: cannot find ipinfoLocal.mmdb: %w", err) } region, err := maxminddb.Open(ipinfoDataBasePath) if err != nil { return nil, fmt.Errorf("ipinfoLocal: cannot open %s: %w", ipinfoDataBasePath, err) } defer func(region *maxminddb.Reader) { _ = region.Close() }(region) var record interface{} searchErr := region.Lookup(net.ParseIP(ip), &record) if searchErr != nil { return &IPGeoData{}, errors.New("no results") } recordMap, ok := record.(map[string]interface{}) if !ok { return &IPGeoData{}, errors.New("ipinfoLocal: unexpected record format") } countryName, _ := recordMap["country_name"].(string) countryCode, _ := recordMap["country"].(string) prov := "" if countryCode == "HK" { countryName = "China" prov = "Hong Kong" } if countryCode == "TW" { countryName = "China" prov = "Taiwan" } asnStr, _ := recordMap["asn"].(string) asName, _ := recordMap["as_name"].(string) return &IPGeoData{ Asnumber: strings.TrimPrefix(asnStr, "AS"), Country: countryName, City: "", Prov: prov, Owner: asName, }, nil } ================================================ FILE: ipgeo/ipinsight.go ================================================ package ipgeo import ( "io" "time" "github.com/tidwall/gjson" "github.com/nxtrace/NTrace-core/util" ) func IPInSight(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) { client := util.NewGeoHTTPClient(timeout) resp, err := client.Get(token.BaseOrDefault("https://api.ipinsight.io/ip/") + ip + "?token=" + token.ipinsight) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } res := gjson.ParseBytes(body) return &IPGeoData{ Country: res.Get("country_name").String(), City: res.Get("city_name").String(), Prov: res.Get("region_name").String(), }, nil } ================================================ FILE: ipgeo/ipsb.go ================================================ package ipgeo import ( "fmt" "io" "log" "net/http" "time" "github.com/tidwall/gjson" "github.com/nxtrace/NTrace-core/util" ) func IPSB(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) { url := token.BaseOrDefault("https://api.ip.sb/geoip/") + ip client := util.NewGeoHTTPClient(timeout) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("ip.sb: failed to create request: %w", err) } // 设置 UA,ip.sb 默认禁止 go-client User-Agent 的 api 请求 req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0") content, err := client.Do(req) if err != nil { log.Println("api.ip.sb 请求超时(2s),请切换其他API使用") return nil, err } defer content.Body.Close() body, err := io.ReadAll(content.Body) if err != nil { return nil, fmt.Errorf("ip.sb: failed to read response: %w", err) } res := gjson.ParseBytes(body) if res.Get("country").String() == "" { // 什么都拿不到,证明被Cloudflare风控了 return nil, fmt.Errorf("ip.sb: empty response, possibly blocked by Cloudflare") } return &IPGeoData{ Asnumber: res.Get("asn").String(), Country: res.Get("country").String(), City: res.Get("city").String(), Prov: res.Get("region").String(), Owner: res.Get("isp").String(), }, nil } ================================================ FILE: ipgeo/leo.go ================================================ package ipgeo import ( "encoding/json" "errors" "strconv" "sync" "time" "github.com/tidwall/gjson" "github.com/nxtrace/NTrace-core/wshandle" ) /*** * 原理介绍 By Leo * WebSocket 一共开启了一个发送和一个接收协程,在 New 了一个连接的实例对象后,不给予关闭,持续化连接 * 当有新的IP请求时,一直在等待IP数据的发送协程接收到从 leo.go 的 sendIPRequest 函数发来的IP数据,向服务端发送数据 * 由于实际使用时有大量并发,但是 ws 在同一时刻每次有且只能处理一次发送一条数据,所以必须给 ws 连接上互斥锁,保证每次只有一个协程访问 * 运作模型可以理解为一个 Node 一直在等待数据,当获得一个新的任务后,转交给下一个协程,不再关注这个 Node 的下一步处理过程,并且回到空闲状态继续等待新的任务 ***/ // IPPool IP 查询池 map - ip - ip channel type IPPool struct { pool map[string]chan IPGeoData poolMux sync.RWMutex } var IPPools = IPPool{ pool: make(map[string]chan IPGeoData), } func sendIPRequest(ip string) { wsConn := wshandle.GetWsConn() wsConn.MsgSendCh <- ip } func receiveParse() { // 获得连接实例 wsConn := wshandle.GetWsConn() // 防止多协程抢夺一个ws连接,导致死锁,当一个协程获得ws的控制权后上锁 wsConn.ConnMux.Lock() // 函数退出时解锁,给其他协程使用 defer wsConn.ConnMux.Unlock() for { // 接收到了一条IP信息 data, ok := <-wsConn.MsgReceiveCh if !ok { // channel 已关闭,退出循环 return } // json解析 -> data res := gjson.Parse(data) // 根据返回的IP信息,发送给对应等待回复的IP通道上 var domain = res.Get("domain").String() if res.Get("domain").String() == "" { domain = res.Get("owner").String() } m := make(map[string][]string) err := json.Unmarshal([]byte(res.Get("router").String()), &m) if err != nil { // 此处是正常的,因为有些IP没有路由信息 } lat, _ := strconv.ParseFloat(res.Get("lat").String(), 32) lng, _ := strconv.ParseFloat(res.Get("lng").String(), 32) ip := res.Get("ip").String() geo := IPGeoData{ Asnumber: res.Get("asnumber").String(), Country: res.Get("country").String(), CountryEn: res.Get("country_en").String(), Prov: res.Get("prov").String(), ProvEn: res.Get("prov_en").String(), City: res.Get("city").String(), CityEn: res.Get("city_en").String(), District: res.Get("district").String(), Owner: domain, Lat: lat, Lng: lng, Isp: res.Get("isp").String(), Whois: res.Get("whois").String(), Prefix: res.Get("prefix").String(), Router: m, } // Safely load (or lazily create) the channel for this IP before sending IPPools.poolMux.RLock() ch, ok := IPPools.pool[ip] IPPools.poolMux.RUnlock() if !ok || ch == nil { IPPools.poolMux.Lock() if IPPools.pool[ip] == nil { IPPools.pool[ip] = make(chan IPGeoData, 1) } ch = IPPools.pool[ip] IPPools.poolMux.Unlock() } ch <- geo } } // 当前的实现中,每次调用 receiveParse() 都会锁定 WebSocket 连接 // 当前为单例模式,只启动一个 receiveParse 协程 var receiveParseOnce sync.Once func LeoIP(ip string, timeout time.Duration, lang string, maptrace bool) (*IPGeoData, error) { // TODO: 根据lang的值请求中文/英文API // TODO: 根据maptrace的值决定是否请求经纬度信息 if timeout < 2*time.Second { timeout = 2 * time.Second } // 确保对应 IP 的通道已存在(读锁快速路径 + 写锁惰性创建) IPPools.poolMux.RLock() ch, ok := IPPools.pool[ip] IPPools.poolMux.RUnlock() if !ok || ch == nil { IPPools.poolMux.Lock() if IPPools.pool[ip] == nil { IPPools.pool[ip] = make(chan IPGeoData, 1) } ch = IPPools.pool[ip] IPPools.poolMux.Unlock() } // 发送请求 sendIPRequest(ip) // 确保 receiveParse 只启动一次 receiveParseOnce.Do(func() { go receiveParse() }) // 等待数据返回或超时 select { case res := <-ch: return &res, nil case <-time.After(timeout): return &IPGeoData{}, errors.New("TimeOut") } } ================================================ FILE: ipgeo/tokens.go ================================================ package ipgeo import "github.com/nxtrace/NTrace-core/util" type tokenData struct { ipinsight string ipinfo string ipleo string baseUrl string } func (t *tokenData) BaseOrDefault(def string) string { if t.baseUrl == "" { return def } return t.baseUrl } var token = tokenData{ ipinsight: util.GetEnvDefault("NEXTTRACE_IPINSIGHT_TOKEN", ""), ipinfo: util.GetEnvDefault("NEXTTRACE_IPINFO_TOKEN", ""), baseUrl: util.GetEnvDefault("NEXTTRACE_IPAPI_BASE", ""), ipleo: "NextTraceDemo", } ================================================ FILE: ipgeo/tokens_test.go ================================================ package ipgeo import ( "testing" "github.com/stretchr/testify/assert" ) func TestBaseOrDefault_Empty(t *testing.T) { td := &tokenData{baseUrl: ""} assert.Equal(t, "https://default.example.com", td.BaseOrDefault("https://default.example.com")) } func TestBaseOrDefault_Custom(t *testing.T) { td := &tokenData{baseUrl: "https://custom.example.com"} assert.Equal(t, "https://custom.example.com", td.BaseOrDefault("https://default.example.com")) } ================================================ FILE: main.go ================================================ package main import ( "github.com/nxtrace/NTrace-core/cmd" ) func main() { cmd.Execute() } ================================================ FILE: nt_config.yaml ================================================ geofeedpath: ./geofeed.csv ptrpath: ./ptr.csv ================================================ FILE: nt_install.sh ================================================ #!/bin/bash if [ "$1" = "http" ]; then protocol="http" else protocol="https" fi Green_font="\033[32m" Yellow_font="\033[33m" Red_font="\033[31m" Font_suffix="\033[0m" Info="${Green_font}[Info]${Font_suffix}" Error="${Red_font}[Error]${Font_suffix}" Tips="${Green_font}[Tips]${Font_suffix}" Temp_path="/var/tmp/nexttrace" checkRootPermit() { [[ $EUID -ne 0 ]] && echo -e "${Error} 请使用sudo/root权限运行本脚本" && exit 1 } checkSystemArch() { arch=$(uname -m) if [[ $arch == "x86_64" ]]; then archParam="amd64" elif [[ $arch == "i386" ]]; then archParam="386" elif [[ $arch == "i686" ]]; then archParam="386" elif [[ $arch == "aarch64" ]]; then archParam="arm64" elif [[ $arch == "armv7l" ]] || [[ $arch == "armv7ml" ]]; then archParam="armv7" elif [[ $arch == "mips" ]]; then archParam="mips" elif [[ $arch == "loongarch64" ]]; then archParam="loong64" fi } checkSystemDistribution() { case "$OSTYPE" in linux*) osDistribution="linux" if [ ! -d "/usr/local" ]; then downPath="/usr/bin/nexttrace" else downPath="/usr/local/bin/nexttrace" fi ;; *) echo "unknown: $OSTYPE" exit 1 ;; esac } downloadBinrayFile() { echo -e "${Info} 获取最新版的 NextTrace 发行版文件信息" for i in {1..3}; do downloadUrls=$(curl -sLf ${protocol}://www.nxtrace.org/api/dist/core/nexttrace_${osDistribution}_${archParam} --connect-timeout 2) if [ $? -eq 0 ]; then break fi done if [ $? -eq 0 ]; then primaryUrl=$(echo ${downloadUrls} | awk -F '|' '{print $1}') backupUrl=$(echo ${downloadUrls} | awk -F '|' '{print $2}') echo -e "${Info} 正在尝试从 Primary 节点下载 NextTrace" for i in {1..3}; do curl -sLf ${primaryUrl} -o ${Temp_path} --connect-timeout 2 if [ $? -eq 0 ]; then changeMode mv ${Temp_path} ${downPath} echo -e "${Info} NextTrace 现在已经在您的系统中可用" return fi done if [ -z ${backupUrl} ]; then echo -e "${Error} 从 Primary 节点下载失败,且 Backup 节点为空,无法下载 NextTrace" exit 1 fi echo -e "${Error} 从 Primary 节点下载失败,正在尝试从 Backup 节点下载 NextTrace" for i in {1..3}; do curl -sLf ${backupUrl} -o ${Temp_path} --connect-timeout 2 if [ $? -eq 0 ]; then changeMode mv ${Temp_path} ${downPath} echo -e "${Info} NextTrace 现在已经在您的系统中可用" return fi done echo -e "${Error} NextTrace 下载失败,请检查您的网络是否正常" exit 1 else echo -e "${Error} 获取下载地址失败,请检查您的网络是否正常" exit 1 fi } changeMode() { chmod +x ${Temp_path} &> /dev/null } runBinrayFileHelp() { if [ -e ${downPath} ]; then ${downPath} --version echo -e "${Tips} 一切准备就绪!使用命令 nexttrace 1.1.1.1 开始您的第一次路由测试吧~ 更多进阶命令玩法可以用 nexttrace -h 查看哦\n 关于软件卸载,因为nexttrace是绿色版单文件,卸载只需输入命令 rm ${downPath} 即可" fi } # Check Procedure checkRootPermit checkSystemDistribution checkSystemArch # Download Procedure downloadBinrayFile # Run Procedure runBinrayFileHelp ================================================ FILE: pow/pow.go ================================================ package pow import ( "context" "fmt" "net/url" "time" "github.com/tsosunchia/powclient" "github.com/nxtrace/NTrace-core/util" ) const ( baseURL = "/v3/challenge" ) var retTokenFn = powclient.RetToken func resolveTokenRequestTimeout(ctx context.Context, fallback time.Duration) time.Duration { if fallback <= 0 { fallback = 5 * time.Second } if ctx == nil { return fallback } deadline, ok := ctx.Deadline() if !ok { return fallback } remaining := time.Until(deadline) if remaining <= 0 { return time.Millisecond } if remaining < fallback { return remaining } return fallback } func GetToken(fastIp string, host string, port string) (string, error) { return GetTokenWithContext(context.Background(), fastIp, host, port) } func GetTokenWithContext(ctx context.Context, fastIp string, host string, port string) (string, error) { if ctx == nil { ctx = context.Background() } opCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() getTokenParams := powclient.NewGetTokenParams() u := url.URL{Scheme: "https", Host: fastIp + ":" + port, Path: baseURL} getTokenParams.BaseUrl = u.String() getTokenParams.SNI = host getTokenParams.Host = host getTokenParams.UserAgent = util.UserAgent getTokenParams.TimeoutSec = resolveTokenRequestTimeout(opCtx, getTokenParams.TimeoutSec) proxyUrl := util.GetProxy() if proxyUrl != nil { getTokenParams.Proxy = proxyUrl } var ( token string err error done = make(chan error, 1) ) go func() { var lastErr error for i := 0; i < 3; i++ { if opCtx.Err() != nil { done <- opCtx.Err() return } token, err = retTokenFn(getTokenParams) if err != nil { lastErr = err continue // 如果失败则重试 } done <- nil return } done <- fmt.Errorf("RetToken failed after 3 attempts (host=%s): %w", host, lastErr) }() select { case err := <-done: if err == nil { return token, nil } return "", err case <-opCtx.Done(): if ctx.Err() != nil { return "", ctx.Err() } return "", fmt.Errorf("RetToken timed out after 10s (host=%s)", host) } } ================================================ FILE: pow/pow_test.go ================================================ package pow import ( "context" "errors" "fmt" "net" "testing" "time" "github.com/stretchr/testify/assert" "github.com/tsosunchia/powclient" ) func TestGetToken(t *testing.T) { // 网络可达性前置检查:尝试 TCP 连接目标服务器 conn, err := net.DialTimeout("tcp", "origin-fallback.nxtrace.org:443", 3*time.Second) if err != nil { t.Skipf("skipping: network unreachable (origin-fallback.nxtrace.org:443): %v", err) } conn.Close() // 计时开始 start := time.Now() token, err := GetToken("origin-fallback.nxtrace.org", "origin-fallback.nxtrace.org", "443") // 计时结束 end := time.Now() fmt.Println("耗时:", end.Sub(start)) fmt.Println("token:", token) assert.NoError(t, err, "GetToken() returned an error") } func TestGetTokenWithContextReturnsCanceled(t *testing.T) { oldRetTokenFn := retTokenFn defer func() { retTokenFn = oldRetTokenFn }() started := make(chan struct{}) retTokenFn = func(*powclient.GetTokenParams) (string, error) { close(started) time.Sleep(200 * time.Millisecond) return "", errors.New("boom") } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := GetTokenWithContext(ctx, "example.com", "example.com", "443") done <- err }() <-started cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("GetTokenWithContext error = %v, want context.Canceled", err) } case <-time.After(100 * time.Millisecond): t.Fatal("GetTokenWithContext did not return promptly after cancel") } } func TestGetTokenWithContextClampsRequestTimeoutToContextDeadline(t *testing.T) { oldRetTokenFn := retTokenFn defer func() { retTokenFn = oldRetTokenFn }() gotTimeout := make(chan time.Duration, 1) retTokenFn = func(params *powclient.GetTokenParams) (string, error) { select { case gotTimeout <- params.TimeoutSec: default: } return "", errors.New("boom") } ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) defer cancel() _, _ = GetTokenWithContext(ctx, "example.com", "example.com", "443") select { case timeout := <-gotTimeout: if timeout <= 0 { t.Fatalf("retToken timeout = %v, want > 0", timeout) } if timeout > 150*time.Millisecond { t.Fatalf("retToken timeout = %v, want <= 150ms", timeout) } case <-time.After(100 * time.Millisecond): t.Fatal("retTokenFn was not called") } } ================================================ FILE: printer/basic.go ================================================ package printer import ( "fmt" "net" "strings" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/config" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/util" ) var version = config.Version var buildDate = config.BuildDate var commitID = config.CommitID func Version() { fmt.Fprintf(color.Output, "%s %s %s %s\n", color.New(color.FgWhite, color.Bold).Sprintf("%s", "NextTrace"), color.New(color.FgHiBlack, color.Bold).Sprintf("%s", version), color.New(color.FgHiBlack, color.Bold).Sprintf("%s", buildDate), color.New(color.FgHiBlack, color.Bold).Sprintf("%s", commitID), ) } func CopyRight() { sponsor() fmt.Fprintf(color.Output, "\n%s\n%s %s\n%s %s\n%s %s, %s, %s, %s, %s\n%s %s\n", color.New(color.FgCyan, color.Bold).Sprintf("%s", "NextTrace CopyRight"), color.New(color.FgWhite, color.Bold).Sprintf("%s", "Honorary Founder:"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "Leo"), color.New(color.FgWhite, color.Bold).Sprintf("%s", "Project Chair:"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "Tso"), color.New(color.FgWhite, color.Bold).Sprintf("%s", "Core-Developer:"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "Leo"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "Vincent"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "zhshch"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "Yunlq"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "Tso"), color.New(color.FgWhite, color.Bold).Sprintf("%s", "Infra Maintainer:"), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", "Tso"), ) } func sponsor() { italic := "\x1b[3m%s\x1b[0m" formatted := fmt.Sprintf(italic, "(Listed in no particular order)") fmt.Fprintf(color.Output, "%s\n%s\n%s\n%s\n%s\n", color.New(color.FgCyan, color.Bold).Sprintf("%s", "NextTrace Sponsored by"), color.New(color.FgHiYellow, color.Bold).Sprintf("%s", "· DMIT.io"), color.New(color.FgHiYellow, color.Bold).Sprintf("%s", "· Misaka.io"), color.New(color.FgHiYellow, color.Bold).Sprintf("%s", "· Saltyfish.io"), color.New(color.FgHiBlack, color.Bold).Sprintf("%s", formatted), ) } func PrintTraceRouteNav(ip net.IP, domain string, dataOrigin string, maxHops int, packetSize int, srcAddr string, mode string) { fmt.Println("IP Geo Data Provider: " + dataOrigin) if srcAddr == "" { srcAddr = "traceroute to" } else { srcAddr += " ->" } if !util.EnableHidDstIP { if ip.String() == domain { fmt.Printf("%s %s, %d hops max, %s, %s mode\n", srcAddr, ip.String(), maxHops, trace.FormatPacketSizeLabel(packetSize), strings.ToUpper(mode)) } else { fmt.Printf("%s %s (%s), %d hops max, %s, %s mode\n", srcAddr, ip.String(), domain, maxHops, trace.FormatPacketSizeLabel(packetSize), strings.ToUpper(mode)) } } else { fmt.Printf("%s %s, %d hops max, %s, %s mode\n", srcAddr, util.HideIPPart(ip.String()), maxHops, trace.FormatPacketSizeLabel(packetSize), strings.ToUpper(mode)) } } func applyLangSetting(h *trace.Hop) { if h.Geo == nil || h.Geo.Source == trace.PendingGeoSource { return } if len(h.Geo.Country) <= 1 { // 打印 h.Geo if h.Geo.Whois != "" { h.Geo.Country = h.Geo.Whois } else { if h.Geo.Source != "LeoMoeAPI" { h.Geo.Country = "网络故障" h.Geo.CountryEn = "Network Error" } else { h.Geo.Country = "未知" h.Geo.CountryEn = "Unknown" } } } if h.Lang == "en" { if h.Geo.Country == "Anycast" { } else if h.Geo.Prov == "骨干网" { h.Geo.Prov = "BackBone" } else if h.Geo.ProvEn == "" { if h.Geo.CountryEn != "" { h.Geo.Country = h.Geo.CountryEn } } else { if h.Geo.CityEn == "" { h.Geo.Country = h.Geo.ProvEn h.Geo.Prov = h.Geo.CountryEn h.Geo.City = "" } else { h.Geo.Country = h.Geo.CityEn h.Geo.Prov = h.Geo.ProvEn h.Geo.City = h.Geo.CountryEn } } } } ================================================ FILE: printer/classic_printer.go ================================================ package printer import ( "fmt" "strings" "github.com/nxtrace/NTrace-core/trace" ) type HopInfo int const ( General HopInfo = 0 IXP HopInfo = 1 Peer HopInfo = 2 PoP HopInfo = 3 Aboard HopInfo = 4 ) func findLatestAvailableHop(res *trace.Result, ttl int, probesIndex int) int { for ttl > 0 { // 查找上一个跃点是不是有效结果 ttl-- // 判断此TTL跃点是否有效并判断地理位置结构体是否已经初始化 if len(res.Hops[ttl]) != 0 && res.Hops[ttl][probesIndex].Success && res.Hops[ttl][probesIndex].Geo != nil { // TTL虽有效,但地理位置API没有能够正确返回数据,依旧不能视为有效数据 if res.Hops[ttl][probesIndex].Geo.Country == "" { // 跳过继续寻找上一个有效跃点 continue } return ttl } } // 没找到 return -1 } func unifyName(name string) string { if name == "China" || name == "CN" { return "中国" } else if name == "Hong kong" || name == "香港" || name == "Central and Western" { return "中国香港" } else if name == "Taiwan" || name == "台湾" { return "中国台湾" } else { return name } } func chinaISPPeer(hostname string) bool { var keyWords = []string{"china", "ct", "cu", "cm", "cnc", "4134", "4837", "4809", "9929"} for _, k := range keyWords { if strings.Contains(strings.ToLower(hostname), k) { return true } } return false } func chinaMainland(h trace.Hop) bool { if unifyName(h.Geo.Country) == "中国" && unifyName(h.Geo.Prov) != "中国香港" && unifyName(h.Geo.Prov) != "中国台湾" { return true } else { return false } } func makeHopsType(res *trace.Result, ttl int) map[int]HopInfo { // 创建一个字典,存放所有当前TTL的跃点类型集合 hopProbesMap := make(map[int]HopInfo) for i := range res.Hops[ttl] { // 判断是否res.Hops[ttl][i]是一个有效的跃点并且地理位置信息已经初始化 if res.Hops[ttl][i].Success && res.Hops[ttl][i].Geo != nil { if availableTTL := findLatestAvailableHop(res, ttl, i); availableTTL != -1 { switch { case strings.Contains(res.Hops[ttl][i].Geo.District, "IXP") || strings.Contains(strings.ToLower(res.Hops[ttl][i].Hostname), "ix"): hopProbesMap[i] = IXP case strings.Contains(res.Hops[ttl][i].Geo.District, "Peer") || chinaISPPeer(res.Hops[ttl][i].Hostname): hopProbesMap[i] = Peer case strings.Contains(res.Hops[ttl][i].Geo.District, "PoP"): hopProbesMap[i] = PoP // 2个有效跃点必须都为有效数据,如果当前跳没有地理位置信息或者为局域网,不能视为有效节点 case res.Hops[availableTTL][i].Geo.Country != "LAN Address" && res.Hops[ttl][i].Geo.Country != "LAN Address" && res.Hops[ttl][i].Geo.Country != "" && // 一个跃点在中国大陆,另外一个跃点在其他地区,则可以推断出数据包跨境 chinaMainland(res.Hops[availableTTL][i]) != chinaMainland(res.Hops[ttl][i]): // TODO: 将先后2跳跃点信息汇报给API,以完善相关数据 hopProbesMap[i] = Aboard } } else { hopProbesMap[i] = General } } } return hopProbesMap } func ClassicPrinter(res *trace.Result, ttl int) { fmt.Print(ttl + 1) hopsTypeMap := makeHopsType(res, ttl) for i := range res.Hops[ttl] { HopPrinter(res.Hops[ttl][i], hopsTypeMap[i]) } } ================================================ FILE: printer/easy.go ================================================ package printer import ( "fmt" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) func EasyPrinter(res *trace.Result, ttl int) { for i := range res.Hops[ttl] { if res.Hops[ttl][i].Address == nil { fmt.Printf("%d|*||||||\n", ttl+1) continue } if res.Hops[ttl][i].Geo == nil { res.Hops[ttl][i].Geo = &ipgeo.IPGeoData{} } applyLangSetting(&res.Hops[ttl][i]) // 应用语言设置 fmt.Printf( "%d|%s|%s|%.2f|%s|%s|%s|%s|%s|%s|%.4f|%.4f\n", ttl+1, res.Hops[ttl][i].Address.String(), res.Hops[ttl][i].Hostname, float64(res.Hops[ttl][i].RTT.Microseconds())/1000, res.Hops[ttl][i].Geo.Asnumber, res.Hops[ttl][i].Geo.Country, res.Hops[ttl][i].Geo.Prov, res.Hops[ttl][i].Geo.City, res.Hops[ttl][i].Geo.District, res.Hops[ttl][i].Geo.Owner, res.Hops[ttl][i].Geo.Lat, res.Hops[ttl][i].Geo.Lng, ) } } ================================================ FILE: printer/mtr_raw.go ================================================ package printer import ( "fmt" "strings" "github.com/nxtrace/NTrace-core/trace" ) // FormatMTRRawLine formats one MTR raw stream record with fixed 12 columns: // ttl|ip|ptr|rtt|asn|country|prov|city|district|owner|lat|lng func FormatMTRRawLine(rec trace.MTRRawRecord) string { if !rec.Success && rec.IP == "" && rec.Host == "" { // timeout row: keep a stable 12-column layout for machine parsers return fmt.Sprintf("%d|*||||||||||", rec.TTL) } rtt := "" if rec.RTTMs > 0 { rtt = fmt.Sprintf("%.2f", rec.RTTMs) } lat := "" lng := "" if rec.Lat != 0 || rec.Lng != 0 { lat = fmt.Sprintf("%.4f", rec.Lat) lng = fmt.Sprintf("%.4f", rec.Lng) } cols := []string{ fmt.Sprintf("%d", rec.TTL), sanitizeRawField(rec.IP), sanitizeRawField(rec.Host), rtt, sanitizeRawField(rec.ASN), sanitizeRawField(rec.Country), sanitizeRawField(rec.Prov), sanitizeRawField(rec.City), sanitizeRawField(rec.District), sanitizeRawField(rec.Owner), lat, lng, } return strings.Join(cols, "|") } func sanitizeRawField(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } // Preserve one-record-per-line and stable split by '|'. s = strings.ReplaceAll(s, "|", " ") s = strings.ReplaceAll(s, "\r", " ") s = strings.ReplaceAll(s, "\n", " ") return s } ================================================ FILE: printer/mtr_table.go ================================================ package printer import ( "fmt" "os" "strings" "time" "github.com/fatih/color" "github.com/mattn/go-runewidth" "github.com/rodaine/table" "golang.org/x/term" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) // --------------------------------------------------------------------------- // MTR 表格打印器 // --------------------------------------------------------------------------- // MTRTablePrinter 将 MTR 快照渲染为经典 MTR 风格表格。 // 每次调用都会先清屏再重绘。 func MTRTablePrinter(stats []trace.MTRHopStat, iteration int, mode int, nameMode int, lang string, showIPs bool) { // 清屏并移动到左上角 fmt.Print("\033[H\033[2J") headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() columnFmt := color.New(color.FgYellow).SprintfFunc() tbl := table.New("Hop", "Loss%", "Snt", "Last", "Avg", "Best", "Wrst", "StDev", "Host") tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) prevTTL := 0 for _, s := range stats { hopStr := fmt.Sprint(s.TTL) if s.TTL == prevTTL { hopStr = "" // 同一 TTL 的多路径不重复显示跳数 } prevTTL = s.TTL host := formatMTRHostWithMPLS(s, mode, nameMode, lang, showIPs) m := formatMTRMetricStrings(s) tbl.AddRow( hopStr, m.loss, m.snt, m.last, m.avg, m.best, m.wrst, m.stdev, host, ) } tbl.Print() } // MTRRenderTable 仅返回格式化后的行数据(用于测试/非终端场景)。 // mode / nameMode / lang 控制 Host 列内容;传 -1 / -1 / "" 等效于 HostModeFull + HostNamePTRorIP + "en"(向后兼容)。 func MTRRenderTable(stats []trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) []MTRRow { prevTTL := 0 rows := make([]MTRRow, 0, len(stats)) for _, s := range stats { hopStr := fmt.Sprint(s.TTL) if s.TTL == prevTTL { hopStr = "" } prevTTL = s.TTL m := formatMTRMetricStrings(s) rows = append(rows, MTRRow{ Hop: hopStr, Loss: m.loss, Snt: m.snt, Last: m.last, Avg: m.avg, Best: m.best, Wrst: m.wrst, StDev: m.stdev, Host: formatMTRHostWithMPLS(s, mode, nameMode, lang, showIPs), }) } return rows } // MTRRow 表示表格中一行经过格式化的数据。 type MTRRow struct { Hop string Loss string Snt string Last string Avg string Best string Wrst string StDev string Host string } // --------------------------------------------------------------------------- // 格式化辅助 // --------------------------------------------------------------------------- // formatLoss 返回 "0.0%"、"100.0%" 等。 func formatLoss(pct float64) string { return fmt.Sprintf("%.1f%%", pct) } // formatMs 返回 "12.34" —— 毫秒值保留两位小数。 func formatMs(ms float64) string { return fmt.Sprintf("%.2f", ms) } // isWaitingHopStat 判断该 hop 是否为 "(waiting for reply)" 状态。 // 条件:100% 丢包(≥ 99.95% 避免浮点边界抖动)且无 IP/Host。 func isWaitingHopStat(s trace.MTRHopStat) bool { return s.Loss >= 99.95 && s.IP == "" && s.Host == "" } // mtrMetrics 存储已格式化的指标字符串。 type mtrMetrics struct { loss, snt, last, avg, best, wrst, stdev string } // formatMTRMetricStrings 返回已格式化的指标字符串。 // waiting 行全部返回空字符串。 func formatMTRMetricStrings(s trace.MTRHopStat) mtrMetrics { if isWaitingHopStat(s) { return mtrMetrics{} } return mtrMetrics{ loss: formatLoss(s.Loss), snt: fmt.Sprint(s.Snt), last: formatMs(s.Last), avg: formatMs(s.Avg), best: formatMs(s.Best), wrst: formatMs(s.Wrst), stdev: formatMs(s.StDev), } } // --------------------------------------------------------------------------- // 显示模式常量 // --------------------------------------------------------------------------- const ( HostModeBase = 0 // 仅 IP/PTR HostModeASN = 1 // ASN + IP/PTR HostModeCity = 2 // ASN + IP/PTR + 城市 HostModeOwner = 3 // ASN + IP/PTR + owner HostModeFull = 4 // ASN + IP/PTR + full ) // --------------------------------------------------------------------------- // Host 基础显示模式(n 键切换) // --------------------------------------------------------------------------- const ( HostNamePTRorIP = 0 // 默认:有 PTR 显示 PTR,否则 IP HostNameIPOnly = 1 // 始终显示 IP ) // --------------------------------------------------------------------------- // Host 列格式化(多模式 + 语言感知) // --------------------------------------------------------------------------- // formatMTRHostBase 构建基础 host 标识。 // // nameMode == HostNameIPOnly → 始终显示 IP // nameMode == HostNamePTRorIP(默认): // - showIPs=false: 有 PTR 显示 PTR,否则 IP // - showIPs=true: 有 PTR 且有 IP 时显示 "PTR (IP)" // 都无 → "???" func formatMTRHostBase(s trace.MTRHopStat, nameMode int, showIPs bool) string { if nameMode == HostNameIPOnly { if s.IP != "" { return s.IP } return "???" } if showIPs { if s.Host != "" && s.IP != "" { if s.Host == s.IP { return s.Host } return fmt.Sprintf("%s (%s)", s.Host, s.IP) } if s.Host != "" { return s.Host } if s.IP != "" { return s.IP } return "???" } // HostNamePTRorIP(默认) if s.Host != "" { return s.Host } if s.IP != "" { return s.IP } return "???" } // geoField 根据语言选择中/英字段。 // lang == "en" 优先英文,否则优先中文。 func geoField(cn, en, lang string) string { if lang == "en" { if en != "" { return en } return cn } // 默认(含 "cn")优先中文 if cn != "" { return cn } return en } // formatMTRHostByMode 按显示模式构建 Host 列(不含 MPLS)。 // // HostModeBase (0): 仅 IP/PTR // HostModeASN (1): ASN + IP/PTR // HostModeCity (2): ASN + IP/PTR + 城市 // HostModeOwner (3): ASN + IP/PTR + owner // HostModeFull (4): ASN + IP/PTR + full // // ASN 始终作为前缀(对齐 mtr -rw 风格): // // "AS13335 one.one.one.one" (模式 1) // "AS13335 one.one.one.one US" (模式 2) func formatMTRHostByMode(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) string { return joinMTRHostParts(buildMTRHostParts(s, mode, nameMode, lang, showIPs), ", ") } // formatMTRHostWithMPLS 构建 Host 列完整内容(含内联 MPLS),供表格打印器使用。 func formatMTRHostWithMPLS(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) string { if mode < 0 { mode = HostModeFull } if nameMode < 0 { nameMode = HostNamePTRorIP } if lang == "" { lang = "en" } host := formatMTRHostByMode(s, mode, nameMode, lang, showIPs) if len(s.MPLS) > 0 { host += " " + strings.Join(s.MPLS, " ") } return host } // formatMTRHost 向后兼容别名(HostModeFull + HostNamePTRorIP + "en" + 内联 MPLS)。 func formatMTRHost(s trace.MTRHopStat) string { return formatMTRHostWithMPLS(s, HostModeFull, HostNamePTRorIP, "en", false) } // --------------------------------------------------------------------------- // 结构化 Host 组成(TUI / Report 共用) // --------------------------------------------------------------------------- // mtrHostParts 包含 Host 行的各组成部分,便于不同输出层(TUI/report)组装。 type mtrHostParts struct { waiting bool // loss ≥ 99.95% 且无地址 → 显示 (waiting for reply) asn string // "AS13335" 或 "" base string // IP 或 PTR extras []string // geo/owner 等附加字段(不含 ASN) } // buildMTRHostParts 从统计数据构建 host 各组成部分。 // // waiting 条件:loss ≥ 99.95%(避免浮点边界抖动)且无 Host 和 IP。 // 若 loss=100% 但仍有 IP/Host(极少见),优先显示真实地址。 func buildMTRHostParts(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) mtrHostParts { if isWaitingHopStat(s) { return mtrHostParts{waiting: true} } parts := mtrHostParts{base: formatMTRHostBase(s, nameMode, showIPs)} if mode == HostModeBase || s.Geo == nil { return parts } parts.asn = mtrASNLabel(s.Geo) parts.extras = mtrGeoExtras(s.Geo, mode, lang) return parts } // buildTUIHostParts 构建仅供 TUI 使用的 host 组成部分。 // // 与共享 buildMTRHostParts 不同,TUI 在 mode >= HostModeASN 时 // 对"有地址但缺失 ASN"的 hop 显示 AS???;HostModeBase 不显示 ASN。 func buildTUIHostParts(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) mtrHostParts { p := buildMTRHostParts(s, mode, nameMode, lang, showIPs) if p.waiting { return p } // HostModeBase 不显示 ASN,也不显示 AS??? if mode == HostModeBase { p.asn = "" return p } if p.asn == "" { p.asn = "AS???" } return p } // formatTUIHost 根据预先构建的 TUI host 组成和 ASN 列宽,生成手动空格对齐的 host 文本。 func formatTUIHost(parts mtrHostParts, asnW int) string { if parts.waiting { return "(waiting for reply)" } var b strings.Builder if asnW > 0 && parts.asn != "" { b.WriteString(padRight(parts.asn, asnW)) b.WriteByte(' ') } b.WriteString(parts.base) if len(parts.extras) > 0 { b.WriteByte(' ') b.WriteString(strings.Join(parts.extras, " ")) } return b.String() } // formatReportHost 构建 report 格式的 host 文本(空格分隔,waiting 感知,含 MPLS)。 func formatReportHost(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) string { host := joinMTRHostParts(buildMTRHostParts(s, mode, nameMode, lang, showIPs), " ") if len(s.MPLS) > 0 { host += " " + strings.Join(s.MPLS, " ") } return host } // formatCompactReportHost 构建非 wide report 的精简 Host 文本。 // // 规则: // - waiting → "(waiting for reply)" // - 仅显示 PTR/IP 基础信息 // - 不显示 ASN / GEO / Owner / MPLS func formatCompactReportHost(s trace.MTRHopStat, nameMode int, showIPs bool) string { if isWaitingHopStat(s) { return "(waiting for reply)" } return formatMTRHostBase(s, nameMode, showIPs) } // formatMTRGeoData 返回简短 geo 描述(向后兼容,等效于英文 HostModeFull geo 部分)。 func formatMTRGeoData(data *ipgeo.IPGeoData) string { if data == nil { return "" } var segs []string if data.Asnumber != "" { segs = append(segs, "AS"+data.Asnumber) } country := data.CountryEn if country == "" { country = data.Country } prov := data.ProvEn if prov == "" { prov = data.Prov } city := data.CityEn if city == "" { city = data.City } if country != "" { segs = append(segs, country) } if prov != "" && prov != country { segs = append(segs, prov) } if city != "" && city != prov { segs = append(segs, city) } owner := data.Owner if owner == "" { owner = data.Isp } if owner != "" { segs = append(segs, owner) } return strings.Join(segs, ", ") } // --------------------------------------------------------------------------- // MTR Report 模式打印器(对齐 mtr -rzw 风格) // --------------------------------------------------------------------------- // MTRReportOptions 控制报告输出细节。 type MTRReportOptions struct { StartTime time.Time SrcHost string Wide bool ShowIPs bool Lang string } // MTRReportPrint 以 mtr -rzw 风格将最终统计一次性输出到 stdout。 // // 输出格式(示例): // // Start: 2025-07-14T09:12:00+0800 // HOST: myhost Loss% Snt Last Avg Best Wrst StDev // 1. AS4134 one.one.one.one 0.0% 10 1.23 1.45 0.98 2.10 0.32 // 2. ??? 100.0% 10 0.00 0.00 0.00 0.00 0.00 // // Wide 模式下使用 HostModeFull(完整地址 + 运营商),host 列宽度取所有行最大值; // 非 wide 模式仅显示 PTR/IP,不查询/展示 GEO,也不显示 MPLS,按终端宽度截断: // // width < 100 → maxHost = 16 // 100 ≤ width < 140 → maxHost = 20 // width ≥ 140 → maxHost = 24 func MTRReportPrint(stats []trace.MTRHopStat, opts MTRReportOptions) { lang := normalizeMTRReportLang(opts.Lang) fmt.Printf("Start: %s\n", opts.StartTime.Format("2006-01-02T15:04:05-0700")) hosts, hostColW := prepareMTRReportHosts(stats, opts, lang) printMTRReportHeader(opts, hostColW) printMTRReportRows(stats, hosts, hostColW) } func joinMTRHostParts(parts mtrHostParts, extrasSep string) string { if parts.waiting { return "(waiting for reply)" } segments := make([]string, 0, 3) if parts.asn != "" { segments = append(segments, parts.asn) } segments = append(segments, parts.base) if len(parts.extras) > 0 { segments = append(segments, strings.Join(parts.extras, extrasSep)) } return strings.Join(segments, " ") } func mtrASNLabel(data *ipgeo.IPGeoData) string { if data == nil || data.Asnumber == "" { return "" } return "AS" + data.Asnumber } func mtrGeoExtras(data *ipgeo.IPGeoData, mode int, lang string) []string { switch mode { case HostModeBase, HostModeASN: return nil case HostModeCity: return singleMTRGeoExtra(mtrBestLocation(data, lang)) case HostModeOwner: return singleMTRGeoExtra(mtrGeoOwner(data)) default: return buildMTRFullGeoExtras(data, lang) } } func singleMTRGeoExtra(value string) []string { if value == "" { return nil } return []string{value} } func mtrBestLocation(data *ipgeo.IPGeoData, lang string) string { if city := geoField(data.City, data.CityEn, lang); city != "" { return city } if prov := geoField(data.Prov, data.ProvEn, lang); prov != "" { return prov } return geoField(data.Country, data.CountryEn, lang) } func mtrGeoOwner(data *ipgeo.IPGeoData) string { if data == nil { return "" } if data.Owner != "" { return data.Owner } return data.Isp } func buildMTRFullGeoExtras(data *ipgeo.IPGeoData, lang string) []string { country := geoField(data.Country, data.CountryEn, lang) prov := geoField(data.Prov, data.ProvEn, lang) city := geoField(data.City, data.CityEn, lang) extras := make([]string, 0, 4) if country != "" { extras = append(extras, country) } if prov != "" && prov != country { extras = append(extras, prov) } if city != "" && city != prov { extras = append(extras, city) } if owner := mtrGeoOwner(data); owner != "" { extras = append(extras, owner) } return extras } func normalizeMTRReportLang(lang string) string { if lang == "" { return "cn" } return lang } func prepareMTRReportHosts(stats []trace.MTRHopStat, opts MTRReportOptions, lang string) ([]string, int) { hosts := make([]string, len(stats)) for i, s := range stats { hosts[i] = buildMTRReportHost(s, opts, lang) } if opts.Wide { return hosts, computeWideMTRReportHostWidth(hosts, opts.SrcHost) } hostColW := narrowMTRReportHostWidth() truncateMTRReportHosts(hosts, hostColW) return hosts, hostColW } func buildMTRReportHost(s trace.MTRHopStat, opts MTRReportOptions, lang string) string { if opts.Wide { return formatReportHost(s, HostModeFull, HostNamePTRorIP, lang, opts.ShowIPs) } return formatCompactReportHost(s, HostNamePTRorIP, opts.ShowIPs) } func computeWideMTRReportHostWidth(hosts []string, srcHost string) int { hostColW := reportDisplayWidth(srcHost) for _, h := range hosts { if w := reportDisplayWidth(h); w > hostColW { hostColW = w } } return hostColW + 1 } func narrowMTRReportHostWidth() int { tw, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || tw <= 0 { tw = 80 } switch { case tw < 100: return 16 case tw < 140: return 20 default: return 24 } } func truncateMTRReportHosts(hosts []string, hostColW int) { for i, h := range hosts { if reportDisplayWidth(h) > hostColW { hosts[i] = reportTruncateToWidth(h, hostColW) } } } func printMTRReportHeader(opts MTRReportOptions, hostColW int) { hostHeader := opts.SrcHost if !opts.Wide && reportDisplayWidth(hostHeader) > hostColW { hostHeader = reportTruncateToWidth(hostHeader, hostColW) } fmt.Printf("HOST: %s%s\n", reportPadRight(hostHeader, hostColW), mtrReportHeaderMetrics()) } func mtrReportHeaderMetrics() string { const metricsFmt = " %6s %5s %6s %6s %6s %6s %6s" return fmt.Sprintf(metricsFmt, "Loss%", "Snt", "Last", "Avg", "Best", "Wrst", "StDev") } func printMTRReportRows(stats []trace.MTRHopStat, hosts []string, hostColW int) { prevTTL := 0 for i, s := range stats { fmt.Printf("%s%s%s\n", mtrReportPrefix(s.TTL, prevTTL), reportPadRight(hosts[i], hostColW), formatMTRReportMetrics(s)) prevTTL = s.TTL } } func mtrReportPrefix(ttl int, prevTTL int) string { if ttl == prevTTL { return " " } return fmt.Sprintf("%3d. ", ttl) } func formatMTRReportMetrics(s trace.MTRHopStat) string { const metricsFmt = " %6s %5s %6s %6s %6s %6s %6s" m := formatMTRMetricStrings(s) return fmt.Sprintf(metricsFmt, m.loss, m.snt, m.last, m.avg, m.best, m.wrst, m.stdev) } // reportDisplayWidth 返回字符串的终端显示宽度(CJK 字符占 2 列)。 func reportDisplayWidth(s string) int { return runewidth.StringWidth(s) } // reportTruncateToWidth 将字符串按终端显示宽度截断(CJK 安全)。 func reportTruncateToWidth(s string, maxW int) string { if runewidth.StringWidth(s) <= maxW { return s } return runewidth.Truncate(s, maxW, "") } // reportPadRight 将 s 用空格右填充到 width 显示列宽(CJK 安全)。 func reportPadRight(s string, width int) string { w := runewidth.StringWidth(s) if w >= width { return s } return s + strings.Repeat(" ", width-w) } ================================================ FILE: printer/mtr_table_test.go ================================================ package printer import ( "fmt" "io" "os" "strings" "testing" "time" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) func captureStdout(t *testing.T, fn func()) string { t.Helper() oldStdout := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatal(err) } os.Stdout = w defer func() { os.Stdout = oldStdout }() fn() _ = w.Close() out, err := io.ReadAll(r) if err != nil { t.Fatal(err) } _ = r.Close() return string(out) } func TestMTRRenderTable_HeaderOrder(t *testing.T) { // 验证 MTRRow 字段名(即列名)顺序:Hop, Loss%, Snt, Last, Avg, Best, Wrst, StDev, Host stats := []trace.MTRHopStat{ {TTL: 1, IP: "1.1.1.1", Loss: 0, Snt: 5, Last: 1.23, Avg: 1.50, Best: 1.00, Wrst: 2.00, StDev: 0.33}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } r := rows[0] if r.Hop != "1" { t.Errorf("Hop = %q, want %q", r.Hop, "1") } if r.Loss != "0.0%" { t.Errorf("Loss = %q, want %q", r.Loss, "0.0%") } if r.Snt != "5" { t.Errorf("Snt = %q, want %q", r.Snt, "5") } if r.Last != "1.23" { t.Errorf("Last = %q, want %q", r.Last, "1.23") } if r.Avg != "1.50" { t.Errorf("Avg = %q, want %q", r.Avg, "1.50") } if r.Best != "1.00" { t.Errorf("Best = %q, want %q", r.Best, "1.00") } if r.Wrst != "2.00" { t.Errorf("Wrst = %q, want %q", r.Wrst, "2.00") } if r.StDev != "0.33" { t.Errorf("StDev = %q, want %q", r.StDev, "0.33") } if r.Host != "1.1.1.1" { t.Errorf("Host = %q, want %q", r.Host, "1.1.1.1") } } func TestMTRRenderTable_NumericFormatting(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 33.3333, Snt: 3, Last: 0.456, Avg: 1.789, Best: 0.123, Wrst: 3.456, StDev: 1.234}, {TTL: 2, IP: "10.0.0.2", Loss: 100, Snt: 3, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) if len(rows) != 2 { t.Fatalf("expected 2 rows, got %d", len(rows)) } // 行 1: loss 保留一位小数加 % if rows[0].Loss != "33.3%" { t.Errorf("Loss = %q, want %q", rows[0].Loss, "33.3%") } // ms 保留两位小数 if rows[0].Last != "0.46" { t.Errorf("Last = %q, want %q", rows[0].Last, "0.46") } if rows[0].Avg != "1.79" { t.Errorf("Avg = %q, want %q", rows[0].Avg, "1.79") } // 行 2: 全超时 → 100% loss, RTT 全 0.00 if rows[1].Loss != "100.0%" { t.Errorf("Loss = %q, want %q", rows[1].Loss, "100.0%") } if rows[1].Last != "0.00" { t.Errorf("Last = %q, want %q", rows[1].Last, "0.00") } } func TestMTRRenderTable_NilGeo(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "192.168.1.1", Geo: nil}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } // Host 只显示 IP,无 panic if rows[0].Host != "192.168.1.1" { t.Errorf("Host = %q, want %q", rows[0].Host, "192.168.1.1") } } func TestMTRRenderTable_EmptyHostname(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "8.8.8.8", Host: "", Geo: &ipgeo.IPGeoData{ Asnumber: "15169", CountryEn: "United States", }}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } // 无 hostname 时只显示 IP + Geo want := "AS15169 8.8.8.8 United States" if rows[0].Host != want { t.Errorf("Host = %q, want %q", rows[0].Host, want) } } func TestMTRRenderTable_HostnameAndIP(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) want := "AS13335 one.one.one.one US, Cloudflare" if rows[0].Host != want { t.Errorf("Host = %q, want %q", rows[0].Host, want) } } func TestMTRRenderTable_MultiPath(t *testing.T) { // 同一 TTL 出现两个不同 IP(多路径) stats := []trace.MTRHopStat{ {TTL: 2, IP: "10.0.0.1"}, {TTL: 2, IP: "10.0.0.2"}, {TTL: 3, IP: "10.0.1.1"}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) if len(rows) != 3 { t.Fatalf("expected 3 rows, got %d", len(rows)) } // 第一行显示 TTL if rows[0].Hop != "2" { t.Errorf("rows[0].Hop = %q, want %q", rows[0].Hop, "2") } // 第二行同 TTL → 应为空 if rows[1].Hop != "" { t.Errorf("rows[1].Hop = %q, want empty", rows[1].Hop) } // 第三行是新 TTL if rows[2].Hop != "3" { t.Errorf("rows[2].Hop = %q, want %q", rows[2].Hop, "3") } } func TestMTRRenderTable_UnknownHost(t *testing.T) { // 无 IP 无 hostname → "???" stats := []trace.MTRHopStat{ {TTL: 1, IP: "", Host: ""}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) if rows[0].Host != "???" { t.Errorf("Host = %q, want %q", rows[0].Host, "???") } } func TestFormatLoss(t *testing.T) { cases := []struct { in float64 want string }{ {0, "0.0%"}, {100, "100.0%"}, {33.3333, "33.3%"}, {50, "50.0%"}, } for _, c := range cases { got := formatLoss(c.in) if got != c.want { t.Errorf("formatLoss(%v) = %q, want %q", c.in, got, c.want) } } } func TestFormatMs(t *testing.T) { cases := []struct { in float64 want string }{ {0, "0.00"}, {1.999, "2.00"}, {12.345, "12.35"}, {0.1, "0.10"}, } for _, c := range cases { got := formatMs(c.in) if got != c.want { t.Errorf("formatMs(%v) = %q, want %q", c.in, got, c.want) } } } // --------------------------------------------------------------------------- // MTR TUI 渲染测试 // --------------------------------------------------------------------------- func TestMTRTUIRenderString_Header(t *testing.T) { startTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) header := MTRTUIHeader{ Target: "1.1.1.1", StartTime: startTime, Status: MTRTUIRunning, Iteration: 5, Domain: "example.com", TargetIP: "1.1.1.1", Version: "v1.0.0", SrcHost: "myhost", SrcIP: "192.168.1.1", } result := MTRTUIRenderString(header, nil) if !strings.Contains(result, "NextTrace [v1.0.0]") { t.Error("missing 'NextTrace [v1.0.0]' in header") } if !strings.Contains(result, "myhost (192.168.1.1) -> example.com (1.1.1.1)") { t.Error("missing src->dst route line in header") } if !strings.Contains(result, "[Running]") { t.Error("missing Running status") } if strings.Contains(result, "Round:") { t.Error("TUI header should NOT contain 'Round:' text") } if !strings.Contains(result, "Quit") { t.Error("missing key hints") } if !strings.Contains(result, "Reset") { t.Error("missing reset key hint") } if !strings.Contains(result, "Y-display") { t.Error("missing display mode key hint") } } // TestMTRTUIRenderString_UsesCRLFOnly 确保 TUI 帧不包含裸 LF // (即每个 \n 前面必须是 \r),避免 raw mode 下光标不回行首导致斜排。 func TestMTRTUIRenderString_UsesCRLFOnly(t *testing.T) { header := MTRTUIHeader{ Target: "1.1.1.1", StartTime: time.Now(), Status: MTRTUIRunning, Iteration: 1, } stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 3, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0}, {TTL: 2, IP: "10.0.0.2", Loss: 50, Snt: 4, Last: 5.0, Avg: 6.0, Best: 4.0, Wrst: 8.0, StDev: 1.5}, } result := MTRTUIRenderString(header, stats) for i := 0; i < len(result); i++ { if result[i] == '\n' && (i == 0 || result[i-1] != '\r') { // 找到裸 LF 的位置供调试 start := i - 20 if start < 0 { start = 0 } end := i + 20 if end > len(result) { end = len(result) } t.Fatalf("bare LF at byte %d; context: %q", i, result[start:end]) } } } // TestMTRTablePrinter_NoRoundText verifies that the non-TTY fallback // (MTRTablePrinter) does NOT contain "Round:" in its output. func TestMTRTablePrinter_NoRoundText(t *testing.T) { origNoColor := color.NoColor color.NoColor = true defer func() { color.NoColor = origNoColor }() stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0}, } output := captureStdout(t, func() { MTRTablePrinter(stats, 5, HostModeFull, HostNamePTRorIP, "en", false) }) if strings.Contains(output, "Round:") { t.Errorf("MTRTablePrinter output should NOT contain 'Round:' text, got:\n%s", output) } } // TestMTRTUIRenderString_FramePrefix 确保帧以清屏序列开头, // 且 header 首行以 \r\n 结束。 func TestMTRTUIRenderString_FramePrefix(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), Status: MTRTUIRunning, Iteration: 1, Version: "v1.0.0", } result := MTRTUIRenderString(header, nil) if !strings.HasPrefix(result, "\033[H\033[2J") { t.Error("frame should start with cursor-home + erase-screen") } // header 首行应含有 NextTrace 并以 \r\n 结束 idx := strings.Index(result, "NextTrace [") if idx < 0 { t.Fatal("missing 'NextTrace [' in header") } nlIdx := strings.Index(result[idx:], "\r\n") if nlIdx < 0 { t.Error("header line should end with \\r\\n") } } func TestMTRTUIRenderString_PausedStatus(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Status: MTRTUIPaused, Iteration: 3, } result := MTRTUIRenderString(header, nil) if !strings.Contains(result, "[Paused]") { t.Error("expected Paused status") } } func TestMTRTUIRenderString_HopRows(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 5, Last: 1.23, Avg: 1.50, Best: 1.00, Wrst: 2.00, StDev: 0.33}, {TTL: 2, IP: "10.0.0.2", Loss: 50, Snt: 4, Last: 5.00, Avg: 6.00, Best: 4.00, Wrst: 8.00, StDev: 1.50}, } header := MTRTUIHeader{ Target: "1.1.1.1", StartTime: time.Now(), Status: MTRTUIRunning, Iteration: 1, } result := MTRTUIRenderString(header, stats) if !strings.Contains(result, "1.") { t.Error("missing 1. hop prefix") } if !strings.Contains(result, "2.") { t.Error("missing 2. hop prefix") } if !strings.Contains(result, "10.0.0.1") { t.Error("missing first hop IP") } if !strings.Contains(result, "10.0.0.2") { t.Error("missing second hop IP") } } func TestMTRTUIRenderString_MultiPath(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 2, IP: "10.0.0.1"}, {TTL: 2, IP: "10.0.0.2"}, {TTL: 3, IP: "10.0.1.1"}, } header := MTRTUIHeader{Target: "x", StartTime: time.Now(), Iteration: 1} result := MTRTUIRenderString(header, stats) // 第一行 TTL=2 → "2.", 第二行同 TTL → " " if !strings.Contains(result, "2.") { t.Error("missing first multipath hop prefix") } // 续行前缀 " " 不易在输出行中唯一匹配,跳过特定验证 if !strings.Contains(result, "3.") { t.Error("missing next TTL prefix") } } func TestFormatTUIHopPrefix(t *testing.T) { cases := []struct { ttl, prev, prefixW int want string }{ {1, 0, 4, " 1. "}, {5, 4, 4, " 5. "}, {3, 3, 4, " "}, {10, 9, 4, "10. "}, {100, 99, 5, "100. "}, {5, 5, 5, " "}, {1, 0, 5, " 1. "}, } for _, c := range cases { got := formatTUIHopPrefix(c.ttl, c.prev, c.prefixW) if got != c.want { t.Errorf("formatTUIHopPrefix(%d, %d, %d) = %q, want %q", c.ttl, c.prev, c.prefixW, got, c.want) } } } func TestTruncateStr(t *testing.T) { cases := []struct { s string maxLen int want string }{ {"short", 10, "short"}, {"exactly10!", 10, "exactly10!"}, {"this is too long", 10, "this is t."}, {"ab", 1, "."}, } for _, c := range cases { got := truncateStr(c.s, c.maxLen) if got != c.want { t.Errorf("truncateStr(%q, %d) = %q, want %q", c.s, c.maxLen, got, c.want) } } } // --------------------------------------------------------------------------- // 自适应布局新增测试 // --------------------------------------------------------------------------- // TestTUI_RightAlignedMetricsBlock 验证指标列数值右对齐并出现在行尾。 func TestTUI_RightAlignedMetricsBlock(t *testing.T) { header := MTRTUIHeader{Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1} stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 5, Last: 1.23, Avg: 1.50, Best: 1.00, Wrst: 2.00, StDev: 0.33}, } result := mtrTUIRenderStringWithWidth(header, stats, 120) lines := strings.Split(result, "\r\n") // 找到含 "1." 前缀且包含 hop IP 的数据行 var hopLine string for _, l := range lines { trimmed := strings.TrimLeft(l, " ") if strings.HasPrefix(trimmed, "1.") && strings.Contains(l, "10.0.0.1") { hopLine = l break } } if hopLine == "" { t.Fatal("missing hop line with 1. prefix") } // Loss 出现在 Host之后 hostIdx := strings.Index(hopLine, "10.0.0.1") lossIdx := strings.Index(hopLine, "0.0%") if lossIdx <= hostIdx { t.Errorf("Loss(%d) should appear after Host(%d)", lossIdx, hostIdx) } // 各指标值应存在 for _, m := range []string{"0.0%", "1.23", "1.50", "1.00", "2.00", "0.33"} { if !strings.Contains(hopLine, m) { t.Errorf("hop line missing metric %q", m) } } // 验证右对齐:指标前应有空格(padLeft 效果) // 取 "Snt" 列值 "5",应有前导空格 sntIdx := strings.Index(hopLine, "0.0%") if sntIdx < 0 { t.Fatal("metric not found in hop line") } // 指标块在行尾,末尾不应有大量多余空格 trimmed := strings.TrimRight(hopLine, " ") if len(trimmed) < len(hopLine)-2 { t.Errorf("too many trailing spaces; metrics should be near end of line") } } // TestTUI_HostExpandsOnWideTerminal 宽终端(200列)时 Host 列宽应大于默认 40。 func TestTUI_HostExpandsOnWideTerminal(t *testing.T) { lo := computeLayout(200, tuiPrefixW, 0) if lo.hostW <= tuiHostDefault { t.Errorf("wide terminal: hostW=%d, want > %d", lo.hostW, tuiHostDefault) } if lo.termWidth != 200 { t.Errorf("termWidth=%d, want 200", lo.termWidth) } } // TestTUI_ComputeLayout_NonZeroSntHint 验证 sntWidthForMax 返回非零值后 // computeLayout 使用更宽的 Snt 列(maxSnt>=1000 → sntWidth>=4)。 func TestTUI_ComputeLayout_NonZeroSntHint(t *testing.T) { maxSnt := 1500 sntHint := sntWidthForMax(maxSnt) if sntHint <= tuiSntDefault { t.Fatalf("sntWidthForMax(%d)=%d, want > %d", maxSnt, sntHint, tuiSntDefault) } lo := computeLayout(200, tuiPrefixW, sntHint) if lo.sntW != sntHint { t.Errorf("computeLayout sntW=%d, want %d (from sntWidthForMax(%d))", lo.sntW, sntHint, maxSnt) } // 同终端宽度下,更宽的 Snt 列应压缩 Host 列 lo0 := computeLayout(200, tuiPrefixW, 0) if lo.hostW >= lo0.hostW { t.Errorf("wider snt column should reduce host width: hostW(snt=%d)=%d, hostW(snt=0)=%d", sntHint, lo.hostW, lo0.hostW) } } // TestTUI_HostShrinksWhenWidthReduced 窄终端(80列)时 Host 列宽应被压缩。 func TestTUI_HostShrinksWhenWidthReduced(t *testing.T) { lo := computeLayout(80, tuiPrefixW, 0) if lo.hostW >= tuiHostDefault { t.Errorf("narrow terminal: hostW=%d, should be < %d", lo.hostW, tuiHostDefault) } if lo.hostW < tuiHostMin { t.Errorf("hostW=%d, should not be less than min %d", lo.hostW, tuiHostMin) } } // TestTUI_DualHeaderPacketsPings 验证双层分组表头: // 第一层含 "Packets" 和 "Pings",第二层含各列名。 func TestTUI_DualHeaderPacketsPings(t *testing.T) { header := MTRTUIHeader{Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1} result := mtrTUIRenderStringWithWidth(header, nil, 120) lines := strings.Split(result, "\r\n") foundPackets, foundPings := false, false foundLoss, foundSnt, foundLast, foundAvg, foundBest, foundWrst, foundStDev := false, false, false, false, false, false, false for _, l := range lines { if strings.Contains(l, "Packets") { foundPackets = true } if strings.Contains(l, "Pings") { foundPings = true } if strings.Contains(l, "Loss%") { foundLoss = true } if strings.Contains(l, "Snt") { foundSnt = true } if strings.Contains(l, "Last") { foundLast = true } if strings.Contains(l, "Avg") { foundAvg = true } if strings.Contains(l, "Best") { foundBest = true } if strings.Contains(l, "Wrst") { foundWrst = true } if strings.Contains(l, "StDev") { foundStDev = true } } if !foundPackets { t.Error("missing 'Packets' group label in header") } if !foundPings { t.Error("missing 'Pings' group label in header") } if !foundLoss || !foundSnt { t.Error("missing Loss%/Snt column names under Packets group") } if !foundLast || !foundAvg || !foundBest || !foundWrst || !foundStDev { t.Error("missing RTT column names under Pings group") } // "Packets" 和 "Pings" 应在同一行 for _, l := range lines { if strings.Contains(l, "Packets") && strings.Contains(l, "Pings") { return // 验证通过 } } t.Error("Packets and Pings should be on the same header line") } // TestTUI_VeryNarrowNoPanic 极窄终端(30列)不应 panic, // 且 hop 数据行与表头行的显示宽度不超过 termWidth。 func TestTUI_VeryNarrowNoPanic(t *testing.T) { header := MTRTUIHeader{Target: "x", StartTime: time.Now(), Iteration: 1} stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 1, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0}, } const width = 30 // 不应 panic result := mtrTUIRenderStringWithWidth(header, stats, width) if !strings.Contains(result, "\r\n") { t.Error("output should contain \\r\\n") } // 验证 hop 行与表头子行不超宽 lines := strings.Split(result, "\r\n") for _, l := range lines { // 跳过清屏序列、信息行和空行 if l == "" || strings.HasPrefix(l, "\033[") || strings.Contains(l, "NextTrace") || strings.Contains(l, "->") || strings.Contains(l, "Quit") { continue } w := displayWidthWithTabs(l, tuiTabStop) if w > width { t.Errorf("line exceeds termWidth=%d: displayWidth=%d, line=%q", width, w, l) } } } // TestTUI_DisplayWidthCJK 验证 CJK 宽字符截断和宽度计算。 func TestTUI_DisplayWidthCJK(t *testing.T) { // 每个中文字符占 2 列 if w := displayWidth("中文"); w != 4 { t.Errorf("displayWidth(\"中文\") = %d, want 4", w) } if w := displayWidth("abc"); w != 3 { t.Errorf("displayWidth(\"abc\") = %d, want 3", w) } // 截断:max=5 → "中文" (4列) 可以放下 got := truncateByDisplayWidth("中文", 5) if got != "中文" { t.Errorf("truncateByDisplayWidth(\"中文\", 5) = %q, want \"中文\"", got) } // 截断:max=3 → "中文" (4列) 超出 → 截断到 2列 + "." got = truncateByDisplayWidth("中文", 3) if displayWidth(got) > 3 { t.Errorf("truncateByDisplayWidth(\"中文\", 3) width=%d, want <= 3", displayWidth(got)) } // padRight CJK padded := padRight("中文", 8) // 4显示列 + 4空格 = 8列 if displayWidth(padded) != 8 { t.Errorf("padRight(\"中文\", 8) width=%d, want 8", displayWidth(padded)) } } // TestTUI_ComputeLayoutZeroWidth 验证 termWidth=0 回退到默认值。 func TestTUI_ComputeLayoutZeroWidth(t *testing.T) { lo := computeLayout(0, tuiPrefixW, 0) if lo.termWidth != tuiDefaultTerm { t.Errorf("termWidth=%d, want default %d", lo.termWidth, tuiDefaultTerm) } if lo.hostW < tuiHostMin { t.Errorf("hostW=%d, want >= %d", lo.hostW, tuiHostMin) } } // TestTUI_TotalWidthInvariant 验证 computeLayout 的核心不变式: // 对于 termWidth >= 20(绝对下限),totalWidth() == termWidth(右锚定)。 func TestTUI_TotalWidthInvariant(t *testing.T) { for _, tw := range []int{20, 23, 25, 30, 40, 50, 60, 61, 80, 120, 200} { lo := computeLayout(tw, tuiPrefixW, 0) if lo.totalWidth() != tw { t.Errorf("termWidth=%d: totalWidth()=%d, want exact match (hostW=%d, metricsWidth=%d)", tw, lo.totalWidth(), lo.hostW, lo.metricsWidth()) } if lo.hostW < 1 { t.Errorf("termWidth=%d: hostW=%d, must be >= 1", tw, lo.hostW) } } } // TestTUI_NarrowRightAnchor 验证窄屏(62列)时指标区贴右边界, // 即 metricsStart + metricsWidth == termWidth。 func TestTUI_NarrowRightAnchor(t *testing.T) { for _, tw := range []int{62, 65, 70, 80} { lo := computeLayout(tw, tuiPrefixW, 0) rightEdge := lo.metricsStart + lo.metricsWidth() if rightEdge != tw { t.Errorf("termWidth=%d: metricsStart(%d)+metricsWidth(%d)=%d, want %d", tw, lo.metricsStart, lo.metricsWidth(), rightEdge, tw) } } } // --------------------------------------------------------------------------- // MTR TUI Header 测试(版本、域名/IP、r 键提示) // --------------------------------------------------------------------------- func TestMTRTUI_HeaderContainsVersion(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Version: "v1.3.0", Iteration: 1, } out := mtrTUIRenderStringWithWidth(header, nil, 120) if !strings.Contains(out, "NextTrace [v1.3.0]") { t.Errorf("header should contain 'NextTrace [v1.3.0]', got:\n%s", out) } } func TestMTRTUI_HeaderContainsDomainAndIP(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", Domain: "dns.google", TargetIP: "8.8.8.8", StartTime: time.Now(), Iteration: 1, SrcHost: "myhost", SrcIP: "192.168.1.1", } out := mtrTUIRenderStringWithWidth(header, nil, 120) if !strings.Contains(out, "dns.google (8.8.8.8)") { t.Errorf("header should contain 'dns.google (8.8.8.8)', got:\n%s", out) } // 应包含 src -> dst 格式 if !strings.Contains(out, "myhost (192.168.1.1) -> dns.google (8.8.8.8)") { t.Errorf("header should contain src -> dst route line, got:\n%s", out) } } func TestMTRTUI_HeaderContainsResetKey(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, } out := mtrTUIRenderStringWithWidth(header, nil, 120) if !strings.Contains(out, "Reset") { t.Errorf("header should contain 'Reset', got:\n%s", out) } } func TestMTRTUI_HeaderIPOnlyWhenNoDomain(t *testing.T) { header := MTRTUIHeader{ Target: "1.2.3.4", TargetIP: "1.2.3.4", StartTime: time.Now(), Iteration: 1, } out := mtrTUIRenderStringWithWidth(header, nil, 120) // 无域名时只显示 IP(不含 "Host:" 前缀) if !strings.Contains(out, "1.2.3.4") { t.Errorf("header should contain '1.2.3.4' when domain is empty, got:\n%s", out) } // 不应出现 "Host:" 前缀(新格式使用 src -> dst) if strings.Contains(out, "Host:") { t.Errorf("new header should not contain 'Host:' prefix, got:\n%s", out) } } // --------------------------------------------------------------------------- // formatTUIHopPrefix 新风格测试 // --------------------------------------------------------------------------- func TestFormatTUIHopPrefix_MinimalStyle(t *testing.T) { // 新 TTL 应返回 "%*d. " 格式(prefixW=4: 2 位 TTL) got := formatTUIHopPrefix(1, 0, 4) if got != " 1. " { t.Errorf("formatTUIHopPrefix(1, 0, 4) = %q, want %q", got, " 1. ") } got = formatTUIHopPrefix(10, 9, 4) if got != "10. " { t.Errorf("formatTUIHopPrefix(10, 9, 4) = %q, want %q", got, "10. ") } // 续行应返回 prefixW 个空格 got = formatTUIHopPrefix(5, 5, 4) if got != " " { t.Errorf("formatTUIHopPrefix(5, 5, 4) = %q, want %q", got, " ") } // 3 位 TTL,prefixW=5 got = formatTUIHopPrefix(100, 99, 5) if got != "100. " { t.Errorf("formatTUIHopPrefix(100, 99, 5) = %q, want %q", got, "100. ") } got = formatTUIHopPrefix(5, 5, 5) if got != " " { t.Errorf("formatTUIHopPrefix(5, 5, 5) = %q, want %q", got, " ") } } // --------------------------------------------------------------------------- // formatMTRHost MPLS 测试 // --------------------------------------------------------------------------- func TestFormatMTRHost_IncludesMPLS(t *testing.T) { // extractMPLS 产出格式为 "[MPLS: Lbl N, TC N, S N, TTL N]",不应再包裹 stat := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", MPLS: []string{"[MPLS: Lbl 100, TC 0, S 1, TTL 1]", "[MPLS: Lbl 200, TC 0, S 0, TTL 1]"}, } got := formatMTRHost(stat) // 不应出现双层包裹 "[MPLS: [MPLS: ..." if strings.Contains(got, "[MPLS: [MPLS:") { t.Errorf("should not double-wrap MPLS, got: %q", got) } // 每个标签应直接出现 if !strings.Contains(got, "[MPLS: Lbl 100") { t.Errorf("should contain first MPLS label, got: %q", got) } if !strings.Contains(got, "[MPLS: Lbl 200") { t.Errorf("should contain second MPLS label, got: %q", got) } } func TestFormatMTRHost_NoMPLS(t *testing.T) { stat := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", } got := formatMTRHost(stat) if strings.Contains(got, "MPLS") { t.Errorf("formatMTRHost should not contain MPLS when empty, got: %q", got) } } // --------------------------------------------------------------------------- // IP 重复展示测试(P2) // --------------------------------------------------------------------------- func TestMTRTUI_HeaderIPNoDuplicate(t *testing.T) { // 当 Domain == TargetIP 时不应显示 "1.1.1.1 (1.1.1.1)" header := MTRTUIHeader{ Target: "1.1.1.1", Domain: "1.1.1.1", TargetIP: "1.1.1.1", StartTime: time.Now(), Iteration: 1, } out := mtrTUIRenderStringWithWidth(header, nil, 120) if strings.Contains(out, "1.1.1.1 (1.1.1.1)") { t.Errorf("should not show duplicate IP, got:\n%s", out) } // 新格式下目标 IP 应单独出现 if !strings.Contains(out, "1.1.1.1") { t.Errorf("should show '1.1.1.1', got:\n%s", out) } } // --------------------------------------------------------------------------- // 显示模式测试 // --------------------------------------------------------------------------- func TestFormatMTRHostByMode_Base(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, } // HostModeBase should produce only the base name (PTR or IP), no ASN, no geo. got := formatMTRHostByMode(s, HostModeBase, HostNamePTRorIP, "en", false) want := "one.one.one.one" if got != want { t.Errorf("HostModeBase PTRorIP: got %q, want %q", got, want) } // With HostNameIPOnly, should return only IP. got = formatMTRHostByMode(s, HostModeBase, HostNameIPOnly, "en", false) want = "1.1.1.1" if got != want { t.Errorf("HostModeBase IPOnly: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_Base_NilGeo(t *testing.T) { s := trace.MTRHopStat{TTL: 1, IP: "10.0.0.1"} got := formatMTRHostByMode(s, HostModeBase, HostNamePTRorIP, "en", false) if got != "10.0.0.1" { t.Errorf("HostModeBase nil geo: got %q, want %q", got, "10.0.0.1") } } func TestBuildTUIHostParts_BaseModeNoASNPlaceholder(t *testing.T) { // In HostModeBase, even when IP is known and geo has no ASN, the TUI // should NOT inject the "AS???" placeholder that other modes use. s := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", Geo: &ipgeo.IPGeoData{CountryEn: "US"}, } parts := buildTUIHostParts(s, HostModeBase, HostNamePTRorIP, "en", false) if parts.asn != "" { t.Errorf("HostModeBase: expected empty ASN, got %q", parts.asn) } if parts.base != "10.0.0.1" { t.Errorf("HostModeBase: expected Base='10.0.0.1', got %q", parts.base) } } func TestBuildTUIHostParts_ASNModeHasPlaceholder(t *testing.T) { // In HostModeASN, when IP is known but geo has no ASN, "AS???" placeholder is expected. s := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", Geo: &ipgeo.IPGeoData{CountryEn: "US"}, } parts := buildTUIHostParts(s, HostModeASN, HostNamePTRorIP, "en", false) if parts.asn != "AS???" { t.Errorf("HostModeASN: expected ASN='AS???', got %q", parts.asn) } } func TestFormatMTRHostByMode_ASN(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, } got := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, "en", false) want := "AS13335 one.one.one.one" if got != want { t.Errorf("HostModeASN: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_City(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", ProvEn: "California", CityEn: "Los Angeles", }, } got := formatMTRHostByMode(s, HostModeCity, HostNamePTRorIP, "en", false) want := "AS13335 1.1.1.1 Los Angeles" if got != want { t.Errorf("HostModeCity: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_Owner(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", Owner: "Cloudflare", }, } got := formatMTRHostByMode(s, HostModeOwner, HostNamePTRorIP, "en", false) want := "AS13335 1.1.1.1 Cloudflare" if got != want { t.Errorf("HostModeOwner: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_Full(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, } got := formatMTRHostByMode(s, HostModeFull, HostNamePTRorIP, "en", false) want := "AS13335 one.one.one.one US, Cloudflare" if got != want { t.Errorf("HostModeFull: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_NilGeo(t *testing.T) { s := trace.MTRHopStat{TTL: 1, IP: "10.0.0.1"} for _, mode := range []int{HostModeBase, HostModeASN, HostModeCity, HostModeOwner, HostModeFull} { got := formatMTRHostByMode(s, mode, HostNamePTRorIP, "en", false) if got != "10.0.0.1" { t.Errorf("mode %d with nil geo: got %q, want %q", mode, got, "10.0.0.1") } } } func TestFormatMTRHostByMode_NoASN(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", Geo: &ipgeo.IPGeoData{CountryEn: "US"}, } got := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, "en", false) // 无 ASN 时只显示 base if got != "10.0.0.1" { t.Errorf("HostModeASN no ASN: got %q, want %q", got, "10.0.0.1") } } // --------------------------------------------------------------------------- // 语言感知测试 // --------------------------------------------------------------------------- func TestGeoField_CN(t *testing.T) { got := geoField("中国", "China", "cn") if got != "中国" { t.Errorf("geoField cn: got %q, want %q", got, "中国") } } func TestGeoField_EN(t *testing.T) { got := geoField("中国", "China", "en") if got != "China" { t.Errorf("geoField en: got %q, want %q", got, "China") } } func TestGeoField_Fallback(t *testing.T) { // en 模式但无英文字段时回退到中文 got := geoField("中国", "", "en") if got != "中国" { t.Errorf("geoField en fallback: got %q, want %q", got, "中国") } // cn 模式但无中文字段时回退到英文 got = geoField("", "China", "cn") if got != "China" { t.Errorf("geoField cn fallback: got %q, want %q", got, "China") } } func TestFormatMTRHostByMode_LangCN(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", Country: "美国", CountryEn: "US", Prov: "加利福尼亚", ProvEn: "California", City: "洛杉矶", CityEn: "Los Angeles", }, } got := formatMTRHostByMode(s, HostModeCity, HostNamePTRorIP, "cn", false) want := "AS13335 1.1.1.1 洛杉矶" if got != want { t.Errorf("HostModeCity cn: got %q, want %q", got, want) } got = formatMTRHostByMode(s, HostModeCity, HostNamePTRorIP, "en", false) want = "AS13335 1.1.1.1 Los Angeles" if got != want { t.Errorf("HostModeCity en: got %q, want %q", got, want) } } // --------------------------------------------------------------------------- // MPLS 多行显示测试 // --------------------------------------------------------------------------- func TestTUI_MPLSMultiLine(t *testing.T) { header := MTRTUIHeader{Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1} stats := []trace.MTRHopStat{ { TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0, MPLS: []string{"[MPLS: Lbl 100, TC 0, S 1, TTL 1]", "[MPLS: Lbl 200, TC 0, S 0, TTL 1]"}, }, {TTL: 2, IP: "10.0.0.2", Loss: 0, Snt: 5, Last: 2.0, Avg: 2.0, Best: 2.0, Wrst: 2.0, StDev: 0}, } result := mtrTUIRenderStringWithWidth(header, stats, 120) // MPLS 标签应在独立的续行中出现 if !strings.Contains(result, "[MPLS: Lbl 100") { t.Error("missing first MPLS label in output") } if !strings.Contains(result, "[MPLS: Lbl 200") { t.Error("missing second MPLS label in output") } // MPLS 不应和 host IP 在同一行 lines := strings.Split(result, "\r\n") for _, l := range lines { if strings.Contains(l, "10.0.0.1") && strings.Contains(l, "MPLS") { t.Error("MPLS label should not be on the same line as host IP") } } } func TestTUI_MPLSMultiLine_NoMPLS(t *testing.T) { header := MTRTUIHeader{Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1} stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0}, } result := mtrTUIRenderStringWithWidth(header, stats, 120) if strings.Contains(result, "MPLS") { t.Error("output should not contain MPLS when hop has no labels") } } // --------------------------------------------------------------------------- // 新 header 格式综合测试 // --------------------------------------------------------------------------- func TestMTRTUI_HeaderSrcDstFormat(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", Domain: "dns.google", TargetIP: "8.8.8.8", StartTime: time.Now(), Iteration: 1, Version: "v1.0.0", SrcHost: "laptop.local", SrcIP: "10.0.0.5", } out := mtrTUIRenderStringWithWidth(header, nil, 150) // 第一行应仅含 NextTrace [版本] lines := strings.Split(out, "\r\n") found := false for _, l := range lines { if strings.Contains(l, "NextTrace [v1.0.0]") { found = true // 不应含 "My traceroute" if strings.Contains(l, "My traceroute") { t.Error("line 1 should not contain 'My traceroute'") } break } } if !found { t.Error("missing 'NextTrace [v1.0.0]' in output") } // 第二行应含 src -> dst + RFC3339 时间 if !strings.Contains(out, "laptop.local (10.0.0.5) -> dns.google (8.8.8.8)") { t.Errorf("missing route line, got:\n%s", out) } } func TestMTRTUI_HeaderNoSrcInfo(t *testing.T) { // 无源信息时应只显示目标 header := MTRTUIHeader{ Target: "8.8.8.8", Domain: "dns.google", TargetIP: "8.8.8.8", StartTime: time.Now(), Iteration: 1, Version: "v1.0.0", } out := mtrTUIRenderStringWithWidth(header, nil, 120) // 不应含 "->" (无源信息) if strings.Contains(out, "->") { t.Errorf("should not contain '->' when no src info, got:\n%s", out) } // 应含目标 if !strings.Contains(out, "dns.google (8.8.8.8)") { t.Errorf("should contain destination, got:\n%s", out) } } func TestMTRTUI_DisplayModeInKeys(t *testing.T) { for mode, label := range map[int]string{0: "IP/PTR", 1: "ASN", 2: "City", 3: "Owner", 4: "Full"} { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, DisplayMode: mode, } out := mtrTUIRenderStringWithWidth(header, nil, 120) expected := "Y-display(" + label + ")" if !strings.Contains(out, expected) { t.Errorf("mode %d: should contain %q, got:\n%s", mode, expected, out) } } } func TestMTRTUI_DisplayModeAffectsHopData(t *testing.T) { stats := []trace.MTRHopStat{ { TTL: 1, IP: "1.1.1.1", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0, }, } // Mode 0 (ASN): 有 ASN 无 country 无 owner header := MTRTUIHeader{Target: "x", StartTime: time.Now(), Iteration: 1, DisplayMode: HostModeASN, Lang: "en"} out := mtrTUIRenderStringWithWidth(header, stats, 120) if !strings.Contains(out, "AS13335") { t.Error("mode ASN should show ASN") } if strings.Contains(out, "Cloudflare") { t.Error("mode ASN should not show owner") } // Mode 3 (Full): 有 ASN + country + owner header.DisplayMode = HostModeFull out = mtrTUIRenderStringWithWidth(header, stats, 120) if !strings.Contains(out, "AS13335") { t.Error("mode Full should show ASN") } if !strings.Contains(out, "US") { t.Error("mode Full should show country") } if !strings.Contains(out, "Cloudflare") { t.Error("mode Full should show owner") } } // --------------------------------------------------------------------------- // NameMode (n 键) 测试 // --------------------------------------------------------------------------- func TestFormatMTRHostByMode_IPOnly_ShowsIP(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, } // HostNameIPOnly 时 base 始终是 IP,即使有 PTR got := formatMTRHostByMode(s, HostModeFull, HostNameIPOnly, "en", false) want := "AS13335 1.1.1.1 US, Cloudflare" if got != want { t.Errorf("HostModeFull+IPOnly: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_PTRorIP_ShowsPTR(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, } got := formatMTRHostByMode(s, HostModeFull, HostNamePTRorIP, "en", false) want := "AS13335 one.one.one.one US, Cloudflare" if got != want { t.Errorf("HostModeFull+PTRorIP: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_IPOnly_NoPTR(t *testing.T) { // 无 PTR 时 HostNameIPOnly 和 HostNamePTRorIP 结果相同 s := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", Geo: &ipgeo.IPGeoData{Asnumber: "64512"}, } gotIP := formatMTRHostByMode(s, HostModeASN, HostNameIPOnly, "en", false) gotPTR := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, "en", false) if gotIP != gotPTR { t.Errorf("no PTR: IPOnly=%q differs from PTRorIP=%q", gotIP, gotPTR) } } func TestMTRRenderTable_IPOnly(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", }}, } // HostNameIPOnly → Host 使用 IP 而非 PTR rows := MTRRenderTable(stats, HostModeFull, HostNameIPOnly, "en", false) if strings.Contains(rows[0].Host, "one.one.one.one") { t.Errorf("IPOnly should not show PTR, got: %q", rows[0].Host) } if !strings.Contains(rows[0].Host, "1.1.1.1") { t.Errorf("IPOnly should show IP, got: %q", rows[0].Host) } } func TestFormatMTRHostByMode_ShowIPs_PTRAndIP(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{Asnumber: "13335"}, } got := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, "en", true) want := "AS13335 one.one.one.one (1.1.1.1)" if got != want { t.Errorf("showIPs PTR+IP: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_ShowIPs_NoPTRFallsBackToIP(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", Geo: &ipgeo.IPGeoData{Asnumber: "64512"}, } got := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, "en", true) want := "AS64512 10.0.0.1" if got != want { t.Errorf("showIPs no PTR: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_ShowIPs_PTREqualsIP_NoDup(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "10.0.0.1", Host: "10.0.0.1", Geo: &ipgeo.IPGeoData{Asnumber: "64512"}, } got := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, "en", true) want := "AS64512 10.0.0.1" if got != want { t.Errorf("showIPs PTR==IP should not duplicate: got %q, want %q", got, want) } } func TestFormatMTRHostByMode_IPOnly_IgnoreShowIPs(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{Asnumber: "13335"}, } gotFalse := formatMTRHostByMode(s, HostModeASN, HostNameIPOnly, "en", false) gotTrue := formatMTRHostByMode(s, HostModeASN, HostNameIPOnly, "en", true) want := "AS13335 1.1.1.1" if gotFalse != want || gotTrue != want { t.Errorf("IPOnly should ignore showIPs: gotFalse=%q, gotTrue=%q, want %q", gotFalse, gotTrue, want) } } func TestFormatReportHost_ShowIPs_PTRAndIP(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{Asnumber: "13335"}, } got := formatReportHost(s, HostModeASN, HostNamePTRorIP, "en", true) want := "AS13335 one.one.one.one (1.1.1.1)" if got != want { t.Errorf("report showIPs PTR+IP: got %q, want %q", got, want) } } // --------------------------------------------------------------------------- // TUI Header APIInfo + NameMode 测试 // --------------------------------------------------------------------------- func TestMTRTUI_HeaderAPIInfo(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, Version: "v1.0.0", APIInfo: "preferred API IP: 1.2.3.4", } out := mtrTUIRenderStringWithWidth(header, nil, 120) if !strings.Contains(out, "preferred API IP: 1.2.3.4") { t.Errorf("header should contain API info, got:\n%s", out) } // API 信息应与 NextTrace 在同一行 lines := strings.Split(out, "\r\n") for _, l := range lines { if strings.Contains(l, "NextTrace [v1.0.0]") { if !strings.Contains(l, "preferred API IP: 1.2.3.4") { t.Errorf("API info should be on the same line as NextTrace, got:\n%s", l) } return } } t.Error("missing NextTrace header line") } func TestMTRTUI_HeaderNoAPIInfo(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, Version: "v1.0.0", } out := mtrTUIRenderStringWithWidth(header, nil, 120) if strings.Contains(out, "preferred API") { t.Errorf("header should not contain API info when empty, got:\n%s", out) } } func TestMTRTUI_NameModeInKeys(t *testing.T) { for nm, label := range map[int]string{0: "ptr", 1: "ip"} { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, NameMode: nm, } out := mtrTUIRenderStringWithWidth(header, nil, 120) expected := "N-host(" + label + ")" if !strings.Contains(out, expected) { t.Errorf("nameMode %d: should contain %q, got:\n%s", nm, expected, out) } } } func TestMTRTUI_NameModeInKeys_ShowIPs(t *testing.T) { for nm, label := range map[int]string{0: "ptr+ip", 1: "ip"} { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, NameMode: nm, ShowIPs: true, } out := mtrTUIRenderStringWithWidth(header, nil, 120) expected := "N-host(" + label + ")" if !strings.Contains(out, expected) { t.Errorf("showIPs nameMode %d: should contain %q, got:\n%s", nm, expected, out) } } } func TestMTRTUI_ShowIPsRendersPTRAndIP(t *testing.T) { header := MTRTUIHeader{ Target: "example.com", StartTime: time.Now(), Iteration: 1, DisplayMode: HostModeASN, NameMode: HostNamePTRorIP, ShowIPs: true, Lang: "en", } stats := []trace.MTRHopStat{ { TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Geo: &ipgeo.IPGeoData{Asnumber: "13335"}, Loss: 0, Snt: 5, Last: 1.1, Avg: 1.1, Best: 1.1, Wrst: 1.1, StDev: 0, }, } out := mtrTUIRenderStringWithWidth(header, stats, 120) if !strings.Contains(out, "one.one.one.one (1.1.1.1)") { t.Errorf("showIPs should render PTR (IP), got:\n%s", out) } header.NameMode = HostNameIPOnly out = mtrTUIRenderStringWithWidth(header, stats, 120) if strings.Contains(out, "one.one.one.one (1.1.1.1)") { t.Errorf("IPOnly should not render PTR (IP), got:\n%s", out) } if !strings.Contains(out, "AS13335 1.1.1.1") { t.Errorf("IPOnly should render IP in host column, got:\n%s", out) } } func TestMTRTUI_FirstLineCentered(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, Version: "v1.0.0", } out := mtrTUIRenderStringWithWidth(header, nil, 120) lines := strings.Split(out, "\r\n") for _, l := range lines { if strings.Contains(l, "NextTrace [v1.0.0]") { trimmed := strings.TrimRight(l, " ") // 居中意味着首字符不是 'N' if len(trimmed) > 0 && trimmed[0] == 'N' { t.Errorf("first line should be centered (left-padded), got: %q", l) } return } } t.Error("missing NextTrace header line") } // TestMTRTUI_FirstLineTruncatedOnNarrow 验证首行在极窄终端不会超宽。 func TestMTRTUI_FirstLineTruncatedOnNarrow(t *testing.T) { header := MTRTUIHeader{ Target: "8.8.8.8", StartTime: time.Now(), Iteration: 1, Version: "v1.0.0", APIInfo: "preferred API IP: 123.456.789.012 some very long extra text to overflow", } const width = 30 out := mtrTUIRenderStringWithWidth(header, nil, width) lines := strings.Split(out, "\r\n") for _, l := range lines { // 跳过清屏序列开头的行:去掉 ANSI CSI 序列后再检查 clean := l for strings.HasPrefix(clean, "\033[") { // 跳过 \033[ ... 直到终止字节 (0x40-0x7E) idx := 2 for idx < len(clean) && (clean[idx] < 0x40 || clean[idx] > 0x7E) { idx++ } if idx < len(clean) { idx++ // 跳过终止字节 } clean = clean[idx:] } if strings.Contains(clean, "NextTrace") { w := displayWidth(clean) if w > width { t.Errorf("first line exceeds termWidth=%d: displayWidth=%d, line=%q", width, w, clean) } return } } t.Error("missing NextTrace header line") } // --------------------------------------------------------------------------- // 新增:waiting for reply + 分隔符规则测试 // --------------------------------------------------------------------------- // TestTUI_WaitingForReplyOn100Loss 验证 100% loss 且无地址的 hop 在 TUI 中 // 显示 "(waiting for reply)" 而非 "???"。 func TestTUI_WaitingForReplyOn100Loss(t *testing.T) { header := MTRTUIHeader{Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1} stats := []trace.MTRHopStat{ {TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0}, {TTL: 2, IP: "", Host: "", Loss: 100, Snt: 5, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0}, {TTL: 3, IP: "10.0.0.3", Loss: 0, Snt: 5, Last: 2.0, Avg: 2.0, Best: 2.0, Wrst: 2.0, StDev: 0}, } result := mtrTUIRenderStringWithWidth(header, stats, 120) if !strings.Contains(result, "(waiting for reply)") { t.Errorf("100%% loss hop should show '(waiting for reply)', got:\n%s", result) } // 不应出现 "???"(TUI 中的 100% loss 无地址 hop) lines := strings.Split(result, "\r\n") for _, l := range lines { // 排除 header/表头行,只看数据行 trimmed := strings.TrimLeft(l, " ") if strings.HasPrefix(trimmed, "2.") && strings.Contains(l, "???") { t.Errorf("100%% loss hop should not show '???' in TUI, got line: %q", l) } } } // TestTUI_HostSeparators_ManualSpaceAlignment 验证 TUI 中 host 文本使用手动空格对齐: // - 序号后 1 空格 + ASN // - ASN 与 IP/PTR 之间为手动空格对齐 // - IP/PTR 与后续信息之间为空格 func TestTUI_HostSeparators_ManualSpaceAlignment(t *testing.T) { header := MTRTUIHeader{ Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1, DisplayMode: HostModeFull, Lang: "en", } stats := []trace.MTRHopStat{ { TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, }, } result := mtrTUIRenderStringWithWidth(header, stats, 120) lines := strings.Split(result, "\r\n") var hopLine string for _, l := range lines { if strings.Contains(l, "one.one.one.one") { hopLine = l break } } if hopLine == "" { t.Fatal("missing hop line with one.one.one.one") } // 序号后 1 空格:应含 " 1. AS" 模式 if !strings.Contains(hopLine, " 1. AS") { t.Errorf("prefix should be followed by 1 space then ASN, got: %q", hopLine) } // ASN 与 IP/PTR 之间应为空格对齐,不再使用 tab if !strings.Contains(hopLine, "AS13335 one.one.one.one") { t.Errorf("ASN and IP/PTR should be space-aligned, got: %q", hopLine) } if strings.Contains(hopLine, "\t") { t.Errorf("TUI host line should not contain tab, got: %q", hopLine) } // IP/PTR 与后续信息之间应为空格 if !strings.Contains(hopLine, "one.one.one.one US Cloudflare") { t.Errorf("IP/PTR and extras should be separated by space, got: %q", hopLine) } } func TestFormatTUIHost_ManualASNAlignment(t *testing.T) { parts := mtrHostParts{ asn: "AS13335", base: "one.one.one.one", extras: []string{"US", "Cloudflare"}, } got := formatTUIHost(parts, 7) want := "AS13335 one.one.one.one US Cloudflare" if got != want { t.Fatalf("formatTUIHost() = %q, want %q", got, want) } shortASN := mtrHostParts{ asn: "AS969", base: "one.one.one.one", } got = formatTUIHost(shortASN, 8) want = "AS969 one.one.one.one" if got != want { t.Fatalf("formatTUIHost() short ASN = %q, want %q", got, want) } } func TestBuildTUIHostParts_MissingASNUsesPlaceholder(t *testing.T) { parts := buildTUIHostParts(trace.MTRHopStat{ TTL: 1, IP: "210.78.30.166", Geo: &ipgeo.IPGeoData{}, }, HostModeASN, HostNamePTRorIP, "en", false) if parts.waiting { t.Fatal("missing ASN hop should not be waiting") } if parts.asn != "AS???" { t.Fatalf("parts.asn = %q, want %q", parts.asn, "AS???") } if parts.base != "210.78.30.166" { t.Fatalf("parts.base = %q, want %q", parts.base, "210.78.30.166") } } func TestBuildTUIHostParts_WaitingDoesNotUsePlaceholder(t *testing.T) { parts := buildTUIHostParts(trace.MTRHopStat{ TTL: 2, IP: "", Host: "", Loss: 100, Snt: 5, }, HostModeASN, HostNamePTRorIP, "en", false) if !parts.waiting { t.Fatal("waiting hop should keep waiting=true") } if parts.asn != "" { t.Fatalf("waiting hop should not use ASN placeholder, got %q", parts.asn) } } func TestTUI_MissingASNRenderedAsPlaceholder(t *testing.T) { header := MTRTUIHeader{ Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1, DisplayMode: HostModeASN, Lang: "en", } stats := []trace.MTRHopStat{ { TTL: 15, IP: "210.78.30.166", Loss: 0, Snt: 5, Last: 53.51, Avg: 53.81, Best: 53.36, Wrst: 54.58, StDev: 0.47, Geo: &ipgeo.IPGeoData{}, }, } out := mtrTUIRenderStringWithWidth(header, stats, 120) if !strings.Contains(out, "AS???") { t.Fatalf("missing ASN placeholder in output:\n%s", out) } if !strings.Contains(out, "AS??? 210.78.30.166") { t.Fatalf("placeholder row should include aligned base host, got:\n%s", out) } } // TestTUI_ManualASNAlignment_StillRightAnchored 验证使用手动空格对齐的 host 行 // 右侧指标区仍能对齐(metricsStart 稳定)。 func TestTUI_ManualASNAlignment_StillRightAnchored(t *testing.T) { header := MTRTUIHeader{ Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1, DisplayMode: HostModeASN, Lang: "en", } stats := []trace.MTRHopStat{ { TTL: 1, IP: "10.0.0.1", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0, Geo: &ipgeo.IPGeoData{Asnumber: "13335"}, }, { TTL: 2, IP: "10.0.0.2", Loss: 50, Snt: 4, Last: 5.0, Avg: 6.0, Best: 4.0, Wrst: 8.0, StDev: 1.5, Geo: &ipgeo.IPGeoData{Asnumber: "174"}, }, { TTL: 3, IP: "", Host: "", Loss: 100, Snt: 4, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0, }, } const width = 120 result := mtrTUIRenderStringWithWidth(header, stats, width) lo := computeLayout(width, tuiPrefixWidthForMaxTTL(3), 0) lines := strings.Split(result, "\r\n") // 在数据行中,指标区应出现在 metricsStart 附近 for _, l := range lines { trimmed := strings.TrimLeft(l, " ") if len(trimmed) == 0 { continue } // 检查是否是数据行(以 "N. " 格式开头) isData := false for _, prefix := range []string{"1.", "2.", "3."} { if strings.HasPrefix(trimmed, prefix) { isData = true break } } if !isData { continue } if strings.Contains(l, "\t") { t.Errorf("data row should not contain tab after manual alignment, got: %q", l) } // 行宽不应超过 termWidth w := displayWidthWithTabs(l, tuiTabStop) if w > width { t.Errorf("data row exceeds termWidth=%d: displayWidth=%d, line=%q", width, w, l) } // 指标区不应超出 if w > lo.metricsStart+lo.metricsWidth() { t.Errorf("data row overflows: displayWidth=%d > metricsStart(%d)+metricsWidth(%d), line=%q", w, lo.metricsStart, lo.metricsWidth(), l) } } } // TestReport_WaitingForReplyOn100Loss 验证 report 模式中 100% loss // 且无地址的 hop 显示 "(waiting for reply)"。 func TestReport_WaitingForReplyOn100Loss(t *testing.T) { p := buildMTRHostParts(trace.MTRHopStat{ TTL: 2, IP: "", Host: "", Loss: 100, Snt: 10, }, HostModeFull, HostNamePTRorIP, "en", false) if !p.waiting { t.Fatal("expected waiting=true for 100% loss with no IP/Host") } host := formatReportHost(trace.MTRHopStat{ TTL: 2, IP: "", Host: "", Loss: 100, Snt: 10, }, HostModeFull, HostNamePTRorIP, "en", false) if host != "(waiting for reply)" { t.Errorf("report host = %q, want %q", host, "(waiting for reply)") } // 有 IP 但 loss=100% → 不应显示 waiting hostWithIP := formatReportHost(trace.MTRHopStat{ TTL: 2, IP: "10.0.0.1", Host: "", Loss: 100, Snt: 10, }, HostModeFull, HostNamePTRorIP, "en", false) if hostWithIP == "(waiting for reply)" { t.Error("hop with IP should not show waiting even with 100% loss") } } // TestReport_FullExtrasUseSpaces_NoComma 验证 report HostModeFull 中 // 后续信息使用空格分隔,不含 ", "。 func TestReport_FullExtrasUseSpaces_NoComma(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Loss: 0, Snt: 10, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, } host := formatReportHost(s, HostModeFull, HostNamePTRorIP, "en", false) // 应为 "AS13335 one.one.one.one US Cloudflare"(空格分隔) if strings.Contains(host, ", ") { t.Errorf("report host should not contain ', ', got: %q", host) } want := "AS13335 one.one.one.one US Cloudflare" if host != want { t.Errorf("report host = %q, want %q", host, want) } } func TestFormatCompactReportHost_Waiting(t *testing.T) { host := formatCompactReportHost(trace.MTRHopStat{ TTL: 2, IP: "", Host: "", Loss: 100, Snt: 10, }, HostNamePTRorIP, false) if host != "(waiting for reply)" { t.Fatalf("formatCompactReportHost() = %q, want %q", host, "(waiting for reply)") } } func TestFormatCompactReportHost_BaseOnly_NoASNNoGeoNoMPLS(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Loss: 0, Snt: 10, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, MPLS: []string{"[MPLS: Lbl 100, TC 0, S 1, TTL 1]"}, } host := formatCompactReportHost(s, HostNamePTRorIP, false) if host != "one.one.one.one" { t.Fatalf("formatCompactReportHost() = %q, want %q", host, "one.one.one.one") } for _, disallowed := range []string{"AS13335", "US", "Cloudflare", "MPLS"} { if strings.Contains(host, disallowed) { t.Fatalf("formatCompactReportHost() should not contain %q, got %q", disallowed, host) } } } func TestFormatCompactReportHost_ShowIPs(t *testing.T) { s := trace.MTRHopStat{ TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", } host := formatCompactReportHost(s, HostNamePTRorIP, true) if host != "one.one.one.one (1.1.1.1)" { t.Fatalf("formatCompactReportHost() = %q, want %q", host, "one.one.one.one (1.1.1.1)") } } func TestMTRReportPrint_NonWideUsesBaseHostOnly(t *testing.T) { stats := []trace.MTRHopStat{ { TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Loss: 0, Snt: 10, Last: 1.23, Avg: 1.45, Best: 0.98, Wrst: 2.10, StDev: 0.32, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, MPLS: []string{"[MPLS: Lbl 100, TC 0, S 1, TTL 1]"}, }, } out := captureStdout(t, func() { MTRReportPrint(stats, MTRReportOptions{ StartTime: time.Date(2025, 7, 14, 9, 12, 0, 0, time.FixedZone("+0800", 8*3600)), SrcHost: "myhost", Wide: false, ShowIPs: false, Lang: "en", }) }) if !strings.Contains(out, "one.one.one.one") { t.Fatalf("non-wide report should contain base host, got:\n%s", out) } for _, disallowed := range []string{"AS13335", "Cloudflare", "MPLS", " US "} { if strings.Contains(out, disallowed) { t.Fatalf("non-wide report should not contain %q, got:\n%s", disallowed, out) } } } func TestMTRReportPrint_WideStillIncludesGeoOrMPLS(t *testing.T) { stats := []trace.MTRHopStat{ { TTL: 1, IP: "1.1.1.1", Host: "one.one.one.one", Loss: 0, Snt: 10, Last: 1.23, Avg: 1.45, Best: 0.98, Wrst: 2.10, StDev: 0.32, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "US", Owner: "Cloudflare", }, MPLS: []string{"[MPLS: Lbl 100, TC 0, S 1, TTL 1]"}, }, } out := captureStdout(t, func() { MTRReportPrint(stats, MTRReportOptions{ StartTime: time.Date(2025, 7, 14, 9, 12, 0, 0, time.FixedZone("+0800", 8*3600)), SrcHost: "myhost", Wide: true, ShowIPs: false, Lang: "en", }) }) for _, expected := range []string{"AS13335", "Cloudflare", "[MPLS: Lbl 100"} { if !strings.Contains(out, expected) { t.Fatalf("wide report should preserve %q, got:\n%s", expected, out) } } } // --------------------------------------------------------------------------- // 3 位 TTL 前缀宽度回归测试 // --------------------------------------------------------------------------- // TestTUI_ThreeDigitTTLAlignment 验证 TTL>=100 时前缀宽度自动扩展为 5, // 布局不变式仍成立,且数据行不超宽。 func TestTUI_ThreeDigitTTLAlignment(t *testing.T) { header := MTRTUIHeader{Target: "1.1.1.1", StartTime: time.Now(), Iteration: 1, Lang: "en"} var stats []trace.MTRHopStat for ttl := 99; ttl <= 101; ttl++ { stats = append(stats, trace.MTRHopStat{ TTL: ttl, IP: fmt.Sprintf("10.0.%d.1", ttl), Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0, }) } const width = 120 result := mtrTUIRenderStringWithWidth(header, stats, width) // 验证 prefixW 为 5(3 位 TTL → digits=3 → prefixW=5) prefixW := tuiPrefixWidthForMaxTTL(101) if prefixW != 5 { t.Errorf("tuiPrefixWidthForMaxTTL(101) = %d, want 5", prefixW) } // 布局不变式 lo := computeLayout(width, prefixW, 0) if lo.totalWidth() != width { t.Errorf("totalWidth()=%d, want %d (prefixW=%d, hostW=%d)", lo.totalWidth(), width, lo.prefixW, lo.hostW) } // 100. 前缀应出现在输出中 if !strings.Contains(result, "100.") { t.Error("missing '100.' prefix in output") } if !strings.Contains(result, "101.") { t.Error("missing '101.' prefix in output") } // 数据行不超宽 lines := strings.Split(result, "\r\n") for _, l := range lines { trimmed := strings.TrimLeft(l, " ") if len(trimmed) == 0 { continue } isData := false for _, pfx := range []string{"99.", "100.", "101."} { if strings.HasPrefix(trimmed, pfx) { isData = true break } } if !isData { continue } w := displayWidthWithTabs(l, tuiTabStop) if w > width { t.Errorf("data row exceeds termWidth=%d: displayWidth=%d, line=%q", width, w, l) } } } // TestTUI_PrefixWidthForMaxTTL 验证 tuiPrefixWidthForMaxTTL 各区间。 func TestTUI_PrefixWidthForMaxTTL(t *testing.T) { cases := []struct { maxTTL int want int }{ {0, 4}, // 空 stats → 默认 {1, 4}, // TTL<100 → 2 digits + 2 = 4 {99, 4}, {100, 5}, // 3 digits + 2 = 5 {255, 5}, {999, 5}, {1000, 6}, // 4 digits + 2 = 6(极端场景) } for _, c := range cases { got := tuiPrefixWidthForMaxTTL(c.maxTTL) if got != c.want { t.Errorf("tuiPrefixWidthForMaxTTL(%d) = %d, want %d", c.maxTTL, got, c.want) } } } // TestTUI_TotalWidthInvariant_ThreeDigitTTL 验证 3 位 TTL 下右锚定不变式。 func TestTUI_TotalWidthInvariant_ThreeDigitTTL(t *testing.T) { prefixW := tuiPrefixWidthForMaxTTL(100) // 5 for _, tw := range []int{21, 25, 30, 40, 60, 80, 120, 200} { lo := computeLayout(tw, prefixW, 0) if lo.totalWidth() != tw { t.Errorf("termWidth=%d, prefixW=%d: totalWidth()=%d, want exact match", tw, prefixW, lo.totalWidth()) } if lo.hostW < 1 { t.Errorf("termWidth=%d, prefixW=%d: hostW=%d, must be >= 1", tw, prefixW, lo.hostW) } } } // --------------------------------------------------------------------------- // Waiting-hop blank metrics // --------------------------------------------------------------------------- // TestMTRRenderTable_WaitingMetricsBlank 验证 100% 丢包且无 IP/Host 的行 // 的所有指标列均为空字符串。 func TestMTRRenderTable_WaitingMetricsBlank(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "1.1.1.1", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0}, {TTL: 2, IP: "", Host: "", Loss: 100, Snt: 5}, // waiting {TTL: 3, IP: "2.2.2.2", Loss: 10, Snt: 5, Last: 2.0, Avg: 2.0, Best: 2.0, Wrst: 2.0, StDev: 0}, } rows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, "en", false) if len(rows) != 3 { t.Fatalf("expected 3 rows, got %d", len(rows)) } // TTL 1 应有正常指标 if rows[0].Loss == "" { t.Error("TTL 1 Loss should not be empty") } // TTL 2 应全部空白 r := rows[1] for _, pair := range []struct { name, val string }{ {"Loss", r.Loss}, {"Snt", r.Snt}, {"Last", r.Last}, {"Avg", r.Avg}, {"Best", r.Best}, {"Wrst", r.Wrst}, {"StDev", r.StDev}, } { if pair.val != "" { t.Errorf("waiting row %s = %q, want empty", pair.name, pair.val) } } // TTL 3 应有正常指标 if rows[2].Loss == "" { t.Error("TTL 3 Loss should not be empty") } } // TestTUI_WaitingMetricsBlank 验证 TUI 帧中 waiting 行不出现 "100.0%" 或 "0.00"。 func TestTUI_WaitingMetricsBlank(t *testing.T) { stats := []trace.MTRHopStat{ {TTL: 1, IP: "1.1.1.1", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0}, {TTL: 2, IP: "", Host: "", Loss: 100, Snt: 5}, // waiting } header := MTRTUIHeader{ SrcHost: "localhost", Target: "example.com", } frame := mtrTUIRenderStringWithWidth(header, stats, 120) // Split lines and find the waiting row (contains "(waiting for reply)") lines := strings.Split(frame, "\n") var waitingLine string for _, l := range lines { if strings.Contains(l, "(waiting for reply)") { waitingLine = l break } } if waitingLine == "" { t.Fatal("no waiting-for-reply line found in TUI frame") } if strings.Contains(waitingLine, "100.0%") { t.Errorf("waiting line should not contain '100.0%%': %q", waitingLine) } if strings.Contains(waitingLine, "0.00") { t.Errorf("waiting line should not contain '0.00': %q", waitingLine) } } func TestFormatMTRRawLine_Success(t *testing.T) { line := FormatMTRRawLine(trace.MTRRawRecord{ TTL: 4, Success: true, IP: "84.17.33.106", Host: "po66-3518.cr01.nrt04.jp.misaka.io", RTTMs: 0.27, ASN: "60068", Country: "日本", Prov: "东京都", City: "东京", District: "", Owner: "cdn77.com", Lat: 35.6804, Lng: 139.7690, }) want := "4|84.17.33.106|po66-3518.cr01.nrt04.jp.misaka.io|0.27|60068|日本|东京都|东京||cdn77.com|35.6804|139.7690" if line != want { t.Fatalf("FormatMTRRawLine()=%q, want %q", line, want) } } func TestFormatMTRRawLine_TimeoutFixedColumns(t *testing.T) { line := FormatMTRRawLine(trace.MTRRawRecord{ TTL: 9, Success: false, }) if line != "9|*||||||||||" { t.Fatalf("timeout line = %q", line) } if got := strings.Count(line, "|"); got != 11 { t.Fatalf("timeout line should contain 11 separators (12 columns), got %d: %q", got, line) } } func TestMTRTUI_PacketsColorByLoss(t *testing.T) { cases := []struct { name string loss float64 waiting bool want color.Attribute }{ {name: "zero", loss: 0, waiting: false, want: color.FgHiGreen}, {name: "low", loss: 3, waiting: false, want: color.FgHiCyan}, {name: "mid", loss: 12, waiting: false, want: color.FgHiYellow}, {name: "high", loss: 35, waiting: false, want: color.FgYellow}, {name: "very_high", loss: 80, waiting: false, want: color.FgHiRed}, {name: "waiting", loss: 100, waiting: true, want: color.FgHiBlack}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := mtrColorLossBucket(tc.loss, tc.waiting) if got != tc.want { t.Fatalf("loss bucket mismatch: got=%v want=%v", got, tc.want) } }) } } func TestMTRTUI_ColorDisabled_NoANSI(t *testing.T) { orig := color.NoColor t.Cleanup(func() { color.NoColor = orig }) color.NoColor = true lossCell, sntCell := mtrColorPacketsByLoss(" 0.0%", " 1", 0, false) if strings.Contains(lossCell, "\x1b[") || strings.Contains(sntCell, "\x1b[") { t.Fatalf("NoColor=true should not emit ANSI, got loss=%q snt=%q", lossCell, sntCell) } } ================================================ FILE: printer/mtr_tui.go ================================================ package printer import ( "fmt" "io" "os" "strings" "time" "github.com/mattn/go-runewidth" "golang.org/x/term" "github.com/nxtrace/NTrace-core/trace" ) // --------------------------------------------------------------------------- // MTR TUI 全屏帧渲染器(mtr 风格自适应布局) // --------------------------------------------------------------------------- // MTRTUIStatus 表示 TUI 当前运行状态。 type MTRTUIStatus int const ( MTRTUIRunning MTRTUIStatus = iota MTRTUIPaused ) // MTRTUIHeader 包含帧顶部显示的元信息。 type MTRTUIHeader struct { Target string StartTime time.Time Status MTRTUIStatus Iteration int // 以下为 v2 新增字段 Domain string // 用户输入的域名(可为空) TargetIP string // 解析后的目标 IP Version string // 软件版本,如 "v1.3.0" // 以下为 v3 新增字段 SrcHost string // 源主机名 SrcIP string // 源 IP Lang string // 语言("en" / "cn") DisplayMode int // 显示模式 0-4 NameMode int // Host 基础显示 0=PTR/IP, 1=IP only ShowIPs bool // 是否显示 PTR+IP(nameMode=0 时生效) APIInfo string // preferred API 信息(纯文本,可为空) DisableMPLS bool // 是否隐藏 MPLS 行(运行时 toggle) } // --------------------------------------------------------------------------- // 布局计算器 // --------------------------------------------------------------------------- // mtrTUILayout 描述一帧布局参数,由终端宽度动态计算。 type mtrTUILayout struct { termWidth int prefixW int // hop prefix 列宽(如 "10.|--") hostW int // Host 列显示宽度 lossW int // Loss% 列宽 sntW int // Snt 列宽 lastW int // Last 列宽 avgW int // Avg 列宽 bestW int // Best 列宽 wrstW int // Wrst 列宽 stdevW int // StDev 列宽 metricsStart int // 指标区起始列(0-based) } // metricsWidth 返回右侧指标区总显示宽度(7 列 + 6 个间距)。 func (lo *mtrTUILayout) metricsWidth() int { return lo.lossW + lo.sntW + lo.lastW + lo.avgW + lo.bestW + lo.wrstW + lo.stdevW + 6*tuiMetricGap } // totalWidth 返回一行数据的总显示宽度。 func (lo *mtrTUILayout) totalWidth() int { return lo.prefixW + tuiPrefixGap + lo.hostW + tuiHostGap + lo.metricsWidth() } // 各列默认与最小宽度 const ( tuiPrefixW = 4 // 默认前缀宽度(TTL ≤ 99: "%2d. " = 4 列) tuiPrefixGap = 0 // 前缀尾部已含空格 tuiHostGap = 2 // Host 与指标区之间最小间距 tuiMetricGap = 1 // 指标列之间间距 tuiDefaultTerm = 120 tuiTabStop = 8 // tab 展开步长 tuiLossDefault = 5 tuiSntDefault = 3 tuiRTTDefault = 7 tuiHostDefault = 40 tuiHostMin = 8 tuiLossMin = 5 tuiSntMin = 3 tuiRTTMin = 5 ) // tuiPrefixWidthForMaxTTL 根据最大 TTL 值返回前缀列宽。 // 前缀格式 "%Nd. ",其中 N = max(2, digits(maxTTL)),列宽 = N + 2。 func tuiPrefixWidthForMaxTTL(maxTTL int) int { digits := 2 if maxTTL >= 1000 { digits = 4 } else if maxTTL >= 100 { digits = 3 } return digits + 2 // ". " 后缀 } // computeLayout 根据终端宽度和前缀宽度计算布局。 // // prefixW 为 hop 前缀列宽,由 tuiPrefixWidthForMaxTTL 动态计算。 // // 三阶段压缩策略: // 1. 默认指标宽度,Host 取剩余空间 // 2. Host 降至 tuiHostMin,按比例压缩指标列 // 3. 极窄场景:循环缩减 Host(最低 1 列)直到 totalWidth ≤ termWidth // // 绝对下限 totalWidth = prefixW+prefixGap(0)+host(1)+hostGap(2)+7×1+6×1。 // 当 termWidth 低于下限时接受溢出——该宽度下终端本身已不可用。 // sntWidthForMax returns the display width needed for the given max Snt value. // Minimum is tuiSntDefault (3). func sntWidthForMax(maxSnt int) int { w := tuiSntDefault for v := 1000; maxSnt >= v; v *= 10 { w++ } return w } func computeLayout(termWidth, prefixW, sntHint int) mtrTUILayout { if termWidth <= 0 { termWidth = tuiDefaultTerm } if prefixW <= 0 { prefixW = tuiPrefixW } sntW := tuiSntDefault if sntHint > sntW { sntW = sntHint } lo := mtrTUILayout{ termWidth: termWidth, prefixW: prefixW, lossW: tuiLossDefault, sntW: sntW, lastW: tuiRTTDefault, avgW: tuiRTTDefault, bestW: tuiRTTDefault, wrstW: tuiRTTDefault, stdevW: tuiRTTDefault, } // 左侧固定部分 = prefix + gap leftFixed := lo.prefixW + tuiPrefixGap // --- Phase 1: 默认指标,Host 取剩余 --- hostW := termWidth - leftFixed - tuiHostGap - lo.metricsWidth() if hostW >= tuiHostMin { lo.hostW = hostW lo.metricsStart = leftFixed + lo.hostW + tuiHostGap return lo } // --- Phase 2: Host 降至 tuiHostMin,压缩指标 --- lo.hostW = tuiHostMin metricsAvail := termWidth - leftFixed - lo.hostW - tuiHostGap lo.lossW, lo.sntW, lo.lastW, lo.avgW, lo.bestW, lo.wrstW, lo.stdevW = shrinkMetrics(metricsAvail, sntW) // --- Phase 3: 极窄——循环缩减 Host 直到不超宽(最低 1) --- for lo.totalWidth() > termWidth && lo.hostW > 1 { lo.hostW-- } // --- 右锚定:把剩余 slack 全部回填 hostW,保证指标区贴右边界 --- if slack := termWidth - lo.totalWidth(); slack > 0 { lo.hostW += slack } lo.metricsStart = leftFixed + lo.hostW + tuiHostGap return lo } // shrinkMetrics 在 available 宽度内缩小 7 列指标 + 6 间距。 // sntDefault 为当前 Snt 列目标宽度(可能因动态计算大于 tuiSntDefault)。 // // 当 available 极小时,列宽可降至绝对下限 1,确保 computeLayout // 的 phase-3 循环能把 totalWidth 压到 termWidth 以内。 func shrinkMetrics(available, sntDefault int) (lossW, sntW, lastW, avgW, bestW, wrstW, stdevW int) { if sntDefault < tuiSntDefault { sntDefault = tuiSntDefault } avail := available - 6*tuiMetricGap if avail < 7 { // 绝对下限:每列 1 return 1, 1, 1, 1, 1, 1, 1 } defaults := [7]int{tuiLossDefault, sntDefault, tuiRTTDefault, tuiRTTDefault, tuiRTTDefault, tuiRTTDefault, tuiRTTDefault} total := 0 for _, c := range defaults { total += c } if avail >= total { return defaults[0], defaults[1], defaults[2], defaults[3], defaults[4], defaults[5], defaults[6] } // 常规最小值 mins := [7]int{tuiLossMin, tuiSntMin, tuiRTTMin, tuiRTTMin, tuiRTTMin, tuiRTTMin, tuiRTTMin} minTotal := 0 for _, m := range mins { minTotal += m } var cols [7]int if avail >= minTotal { // 按比例缩小,兜底到常规最小值 for i := range cols { w := defaults[i] * avail / total if w < mins[i] { w = mins[i] } cols[i] = w } } else { // 极限缩小,兜底到 1 for i := range cols { w := defaults[i] * avail / total if w < 1 { w = 1 } cols[i] = w } } return cols[0], cols[1], cols[2], cols[3], cols[4], cols[5], cols[6] } // --------------------------------------------------------------------------- // 显示宽度辅助(CJK 宽字符感知) // --------------------------------------------------------------------------- // displayWidth 返回字符串的终端显示宽度。 func displayWidth(s string) int { return runewidth.StringWidth(s) } // truncateByDisplayWidth 将 s 截断到不超过 max 个显示列。 // 超长时追加 "."。 func truncateByDisplayWidth(s string, max int) string { if max <= 0 { return "" } w := runewidth.StringWidth(s) if w <= max { return s } if max <= 1 { return "." } return runewidth.Truncate(s, max-1, "") + "." } // padRight 将 s 用空格填充到 width 显示列宽(CJK 安全)。 func padRight(s string, width int) string { w := runewidth.StringWidth(s) if w >= width { return s } return s + strings.Repeat(" ", width-w) } // padLeft 将 s 左填充空格到 width 显示列宽。 func padLeft(s string, width int) string { w := runewidth.StringWidth(s) if w >= width { return s } return strings.Repeat(" ", width-w) + s } // --------------------------------------------------------------------------- // Tab 感知宽度辅助 // --------------------------------------------------------------------------- // displayWidthWithTabs 返回包含 tab 的字符串在终端上的显示宽度。 // tabStop 为 tab 停靠间隔(通常为 8)。 func displayWidthWithTabs(s string, tabStop int) int { col := 0 for _, r := range s { if r == '\t' { col = ((col / tabStop) + 1) * tabStop } else { col += runewidth.RuneWidth(r) } } return col } // truncateWithTabs 将包含 tab 的字符串截断到不超过 maxW 显示列。 func truncateWithTabs(s string, maxW int, tabStop int) string { if maxW <= 0 { return "" } col := 0 var result strings.Builder for _, r := range s { var nextCol int if r == '\t' { nextCol = ((col / tabStop) + 1) * tabStop } else { nextCol = col + runewidth.RuneWidth(r) } if nextCol > maxW { break } result.WriteRune(r) col = nextCol } return result.String() } // fitRight 先截断到 width,再右对齐填充。 // 当列宽小于内容宽度时严格截断,保证输出恰好 width 列。 func fitRight(s string, width int) string { if width <= 0 { return "" } s = truncateByDisplayWidth(s, width) return padLeft(s, width) } // fitLeft 先截断到 width,再左对齐填充。 func fitLeft(s string, width int) string { if width <= 0 { return "" } s = truncateByDisplayWidth(s, width) return padRight(s, width) } // --------------------------------------------------------------------------- // 帧渲染 // --------------------------------------------------------------------------- // tuiLine 在 raw mode 下输出一行并以 \r\n 结束, // 确保光标回到行首——裸 \n 在 raw mode 下只向下移动不回列。 func tuiLine(b *strings.Builder, format string, a ...any) { fmt.Fprintf(b, format, a...) b.WriteString("\r\n") } // getTermWidth 获取 stdout 终端宽度,失败时返回默认值。 var getTermWidth = func() int { w, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || w <= 0 { return tuiDefaultTerm } return w } // MTRTUIRender 将 MTR TUI 帧渲染到 w。 // 每帧重新获取终端宽度并计算自适应布局。 func MTRTUIRender(w io.Writer, header MTRTUIHeader, stats []trace.MTRHopStat) { mtrTUIRenderWithWidth(w, header, stats, getTermWidth()) } // mtrTUIRenderWithWidth 是带可控宽度的内部渲染入口(测试用)。 func mtrTUIRenderWithWidth(w io.Writer, header MTRTUIHeader, stats []trace.MTRHopStat, termWidth int) { lo := buildMTRTUILayout(stats, termWidth) var b strings.Builder writeMTRTUIFramePrefix(&b) renderMTRTUIHeader(&b, header, lo.termWidth) renderDualHeader(&b, lo) renderMTRTUIRows(&b, header, stats, lo) fmt.Fprint(w, b.String()) } func buildMTRTUILayout(stats []trace.MTRHopStat, termWidth int) mtrTUILayout { maxTTL, maxSnt := scanMTRTUIStats(stats) prefixW := tuiPrefixWidthForMaxTTL(maxTTL) return computeLayout(termWidth, prefixW, sntWidthForMax(maxSnt)) } func scanMTRTUIStats(stats []trace.MTRHopStat) (int, int) { maxTTL := 0 maxSnt := 0 for _, s := range stats { if s.TTL > maxTTL { maxTTL = s.TTL } if s.Snt > maxSnt { maxSnt = s.Snt } } return maxTTL, maxSnt } func writeMTRTUIFramePrefix(b *strings.Builder) { b.WriteString("\033[H\033[2J") } func renderMTRTUIHeader(b *strings.Builder, header MTRTUIHeader, termWidth int) { tuiLine(b, "%s", buildMTRTUITitleLine(header, termWidth)) tuiLine(b, "%s", buildMTRTUIRouteLine(header, termWidth, time.Now())) tuiLine(b, "%s", buildMTRTUIControlsLine(header, termWidth)) } func buildMTRTUITitleLine(header MTRTUIHeader, termWidth int) string { titlePart, apiPart := resolveMTRTUITitleParts(header) line := titlePart + apiPart lineW := displayWidth(line) if lineW > termWidth { line = truncateByDisplayWidth(line, termWidth) lineW = displayWidth(line) titleW := displayWidth(titlePart) if lineW <= titleW { titlePart = line apiPart = "" } else { apiPart = line[len(titlePart):] } } pad := 0 if lineW < termWidth { pad = (termWidth - lineW) / 2 } return strings.Repeat(" ", pad) + mtrTUITitleColor(titlePart) + apiPart } func resolveMTRTUITitleParts(header MTRTUIHeader) (string, string) { ver := header.Version if ver == "" { ver = "dev" } titlePart := fmt.Sprintf("NextTrace [%s]", ver) if header.APIInfo == "" { return titlePart, "" } return titlePart, " " + header.APIInfo } func buildMTRTUIRouteLine(header MTRTUIHeader, termWidth int, now time.Time) string { routeLine := buildMTRTUIRouteText(header) timeStr := now.Format("2006-01-02T15:04:05-0700") timeW := displayWidth(timeStr) gap := termWidth - displayWidth(routeLine) - timeW if gap < 2 { maxRoute := termWidth - timeW - 2 if maxRoute < 1 { maxRoute = 1 } routeLine = truncateByDisplayWidth(routeLine, maxRoute) gap = 2 } return mtrTUIRouteColor(routeLine) + strings.Repeat(" ", gap) + mtrTUITimeColor(timeStr) } func buildMTRTUIRouteText(header MTRTUIHeader) string { srcPart := resolveMTRTUISourceLabel(header) dstPart := resolveMTRTUIDestinationLabel(header) if srcPart == "" { return dstPart } return fmt.Sprintf("%s -> %s", srcPart, dstPart) } func resolveMTRTUISourceLabel(header MTRTUIHeader) string { switch { case header.SrcHost != "" && header.SrcIP != "" && header.SrcHost != header.SrcIP: return fmt.Sprintf("%s (%s)", header.SrcHost, header.SrcIP) case header.SrcIP != "": return header.SrcIP default: return header.SrcHost } } func resolveMTRTUIDestinationLabel(header MTRTUIHeader) string { switch { case header.Domain != "" && header.TargetIP != "" && header.Domain != header.TargetIP: return fmt.Sprintf("%s (%s)", header.Domain, header.TargetIP) case header.TargetIP != "": return header.TargetIP case header.Target != "": return header.Target default: return "" } } func buildMTRTUIControlsLine(header MTRTUIHeader, termWidth int) string { const keysPrefix = "Keys: " keyLine := strings.Join(buildMTRTUIKeyItems(header), " ") statusText := mtrTUIStatusText(header.Status) statusTag := mtrTUIStatusColor("[" + statusText + "]") pad := termWidth - displayWidth(keysPrefix) - displayWidth(keyLine) - len("["+statusText+"]") if pad < 2 { pad = 2 } return keysPrefix + keyLine + strings.Repeat(" ", pad) + statusTag } func buildMTRTUIKeyItems(header MTRTUIHeader) []string { return []string{ mtrTUIKeyHiColor("Q") + "uit", mtrTUIKeyHiColor("P") + "ause", mtrTUIKeyHiColor("Space") + "-resume", mtrTUIKeyHiColor("R") + "eset", mtrTUIKeyHiColor("Y") + "-display(" + mtrTUIDisplayModeLabel(header.DisplayMode) + ")", mtrTUIKeyHiColor("N") + "-host(" + mtrTUINameModeLabel(header.NameMode, header.ShowIPs) + ")", mtrTUIKeyHiColor("E") + "-mpls(" + mtrTUIMPLSLabel(header.DisableMPLS) + ")", } } func mtrTUIStatusText(status MTRTUIStatus) string { if status == MTRTUIPaused { return "Paused" } return "Running" } func mtrTUIDisplayModeLabel(mode int) string { modeNames := [5]string{"IP/PTR", "ASN", "City", "Owner", "Full"} if mode >= 0 && mode < len(modeNames) { return modeNames[mode] } return modeNames[0] } func mtrTUINameModeLabel(nameMode int, showIPs bool) string { if nameMode == 1 { return "ip" } if showIPs { return "ptr+ip" } return "ptr" } func mtrTUIMPLSLabel(disabled bool) string { if disabled { return "show" } return "hide" } func renderMTRTUIRows(b *strings.Builder, header MTRTUIHeader, stats []trace.MTRHopStat, lo mtrTUILayout) { allParts := buildTUIHostPartSet(stats, header) asnW := computeTUIASNWidthFromParts(allParts) prevTTL := 0 for i, s := range stats { hopPrefix := formatTUIHopPrefix(s.TTL, prevTTL, lo.prefixW) prevTTL = s.TTL renderDataRow(b, lo, hopPrefix, formatTUIHost(allParts[i], asnW), s) renderMTRTUIMPLSRows(b, lo, s.MPLS, header.DisableMPLS) } } func buildTUIHostPartSet(stats []trace.MTRHopStat, header MTRTUIHeader) []mtrHostParts { lang := header.Lang if lang == "" { lang = "en" } allParts := make([]mtrHostParts, len(stats)) for i, s := range stats { allParts[i] = buildTUIHostParts(s, header.DisplayMode, header.NameMode, lang, header.ShowIPs) } return allParts } func renderMTRTUIMPLSRows(b *strings.Builder, lo mtrTUILayout, labels []string, disabled bool) { if disabled || len(labels) == 0 { return } for _, label := range labels { var row strings.Builder row.WriteString(strings.Repeat(" ", lo.prefixW+tuiPrefixGap)) row.WriteString(mtrTUIMPLSColor(fitLeft(" "+label, lo.hostW))) tuiLine(b, "%s", row.String()) } } func computeTUIASNWidth(stats []trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) int { allParts := make([]mtrHostParts, len(stats)) for i, s := range stats { allParts[i] = buildTUIHostParts(s, mode, nameMode, lang, showIPs) } return computeTUIASNWidthFromParts(allParts) } func computeTUIASNWidthFromParts(allParts []mtrHostParts) int { maxW := 0 for _, parts := range allParts { if parts.waiting || parts.asn == "" { continue } if w := displayWidth(parts.asn); w > maxW { maxW = w } } if maxW == 0 { return 0 } minW := displayWidth("AS???") if maxW < minW { return minW } return maxW } // renderDualHeader 渲染 mtr 风格双层分组表头。 // // 第 1 行:左侧 "Host",右侧分组 "Packets" 和 "Pings" // 第 2 行:具体列名 Loss% Snt | Last Avg Best Wrst StDev func renderDualHeader(b *strings.Builder, lo mtrTUILayout) { // -- 第 1 行 -- prefix := strings.Repeat(" ", lo.prefixW+tuiPrefixGap) hostLabel := fitLeft("Host", lo.hostW) // "Packets" 覆盖 Loss+Snt,"Pings" 覆盖 5 个 RTT 列 packetsW := lo.lossW + tuiMetricGap + lo.sntW pingsW := lo.lastW + tuiMetricGap + lo.avgW + tuiMetricGap + lo.bestW + tuiMetricGap + lo.wrstW + tuiMetricGap + lo.stdevW gap := strings.Repeat(" ", tuiHostGap) packetsLabel := centerIn("Packets", packetsW) pingsLabel := centerIn("Pings", pingsW) tuiLine(b, "%s%s%s%s %s", prefix, mtrTUIHeaderColor(hostLabel), gap, mtrTUIHeaderColor(packetsLabel), mtrTUIHeaderColor(pingsLabel)) // -- 第 2 行 -- row := strings.Repeat(" ", lo.prefixW+tuiPrefixGap) row += padRight("", lo.hostW) row += strings.Repeat(" ", tuiHostGap) row += mtrTUIHeaderColor(fitRight("Loss%", lo.lossW)) row += strings.Repeat(" ", tuiMetricGap) row += mtrTUIHeaderColor(fitRight("Snt", lo.sntW)) row += strings.Repeat(" ", tuiMetricGap) row += mtrTUIHeaderColor(fitRight("Last", lo.lastW)) row += strings.Repeat(" ", tuiMetricGap) row += mtrTUIHeaderColor(fitRight("Avg", lo.avgW)) row += strings.Repeat(" ", tuiMetricGap) row += mtrTUIHeaderColor(fitRight("Best", lo.bestW)) row += strings.Repeat(" ", tuiMetricGap) row += mtrTUIHeaderColor(fitRight("Wrst", lo.wrstW)) row += strings.Repeat(" ", tuiMetricGap) row += mtrTUIHeaderColor(fitRight("StDev", lo.stdevW)) tuiLine(b, "%s", row) } // centerIn 将 s 在 width 宽度内居中,两侧空格填充。 func centerIn(s string, width int) string { w := runewidth.StringWidth(s) if w >= width { return runewidth.Truncate(s, width, "") } left := (width - w) / 2 right := width - w - left return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) } // renderDataRow 渲染一行 hop 数据。 // // 左侧为 prefix+hostText(含 tab),填充到 metricsStart 后拼接指标列, // 保证右侧指标列始终键齐。 func renderDataRow(b *strings.Builder, lo mtrTUILayout, hopPrefix, host string, s trace.MTRHopStat) { left := hopPrefix + host leftW := displayWidthWithTabs(left, tuiTabStop) // 截断:确保 left 不超过 metricsStart - 1(至少保留 1 列间距) maxLeft := lo.metricsStart - 1 if maxLeft < 1 { maxLeft = 1 } if leftW > maxLeft { left = truncateWithTabs(left, maxLeft, tuiTabStop) leftW = displayWidthWithTabs(left, tuiTabStop) } var row strings.Builder waiting := isWaitingHopStat(s) leftColored := mtrTUIHostColor(left) if strings.HasPrefix(left, hopPrefix) { hostPart := left[len(hopPrefix):] hostSty := mtrTUIHostColor if waiting { hostSty = mtrTUIWaitColor } leftColored = mtrTUIHopColor(hopPrefix) + hostSty(hostPart) } row.WriteString(leftColored) // 填充空格到 metricsStart if gap := lo.metricsStart - leftW; gap > 0 { row.WriteString(strings.Repeat(" ", gap)) } // 指标列,右对齐 m := formatMTRMetricStrings(s) lossCell := fitRight(m.loss, lo.lossW) sntCell := fitRight(m.snt, lo.sntW) lossCell, sntCell = mtrColorPacketsByLoss(lossCell, sntCell, s.Loss, waiting) row.WriteString(lossCell) row.WriteString(strings.Repeat(" ", tuiMetricGap)) row.WriteString(sntCell) row.WriteString(strings.Repeat(" ", tuiMetricGap)) row.WriteString(fitRight(m.last, lo.lastW)) row.WriteString(strings.Repeat(" ", tuiMetricGap)) row.WriteString(fitRight(m.avg, lo.avgW)) row.WriteString(strings.Repeat(" ", tuiMetricGap)) row.WriteString(fitRight(m.best, lo.bestW)) row.WriteString(strings.Repeat(" ", tuiMetricGap)) row.WriteString(fitRight(m.wrst, lo.wrstW)) row.WriteString(strings.Repeat(" ", tuiMetricGap)) row.WriteString(fitRight(m.stdev, lo.stdevW)) tuiLine(b, "%s", row.String()) } // MTRTUIRenderString 将 MTR TUI 帧渲染为字符串(方便测试)。 func MTRTUIRenderString(header MTRTUIHeader, stats []trace.MTRHopStat) string { var sb strings.Builder MTRTUIRender(&sb, header, stats) return sb.String() } // mtrTUIRenderStringWithWidth 宽度可控的渲染入口(测试用)。 func mtrTUIRenderStringWithWidth(header MTRTUIHeader, stats []trace.MTRHopStat, width int) string { var sb strings.Builder mtrTUIRenderWithWidth(&sb, header, stats, width) return sb.String() } // formatTUIHopPrefix 返回紧凑版跳数前缀,宽度由 prefixW 控制: // // prefixW=4: " 1. " 新 TTL / " " 续行 // prefixW=5: " 1. " 新 TTL / " " 续行 func formatTUIHopPrefix(ttl, prevTTL, prefixW int) string { if ttl == prevTTL { return strings.Repeat(" ", prefixW) } digitW := prefixW - 2 // ". " 后缀占 2 if digitW < 2 { digitW = 2 } return fmt.Sprintf("%*d. ", digitW, ttl) } // truncateStr 截断字符串到 maxLen 字节,超出时添加省略号。 // 对纯 ASCII 场景仍可使用;CJK 场景优先使用 truncateByDisplayWidth。 func truncateStr(s string, maxLen int) string { if len(s) <= maxLen { return s } if maxLen <= 1 { return "." } return s[:maxLen-1] + "." } // MTRTUIPrinter 返回一个可直接用作 MTROnSnapshot 的回调函数, // 将帧渲染到 os.Stdout。 func MTRTUIPrinter(target, domain, targetIP, version string, startTime time.Time, srcHost, srcIP, lang, apiInfo string, showIPs bool, isPaused func() bool, displayMode func() int, nameMode func() int, isMPLSDisabled func() bool) func(iteration int, stats []trace.MTRHopStat) { return func(iteration int, stats []trace.MTRHopStat) { status := MTRTUIRunning if isPaused != nil && isPaused() { status = MTRTUIPaused } mode := 0 if displayMode != nil { mode = displayMode() } nm := 0 if nameMode != nil { nm = nameMode() } noMPLS := false if isMPLSDisabled != nil { noMPLS = isMPLSDisabled() } MTRTUIRender(os.Stdout, MTRTUIHeader{ Target: target, StartTime: startTime, Status: status, Iteration: iteration, Domain: domain, TargetIP: targetIP, Version: version, SrcHost: srcHost, SrcIP: srcIP, Lang: lang, DisplayMode: mode, NameMode: nm, ShowIPs: showIPs, APIInfo: apiInfo, DisableMPLS: noMPLS, }, stats) } } ================================================ FILE: printer/mtr_tui_color.go ================================================ package printer import ( "strings" "github.com/fatih/color" ) var ( mtrTUITitleColor = color.New(color.FgHiWhite).SprintFunc() mtrTUIHeaderColor = color.New(color.FgHiWhite).SprintFunc() mtrTUIRouteColor = func(s string) string { return s } mtrTUITimeColor = func(s string) string { return s } mtrTUIKeyColor = func(s string) string { return s } mtrTUIKeyHiColor = color.New(color.FgHiWhite).SprintFunc() mtrTUIStatusColor = color.New(color.FgHiYellow, color.Bold).SprintFunc() mtrTUIHopColor = color.New(color.FgHiCyan, color.Bold).SprintFunc() mtrTUIHostColor = color.New(color.FgHiWhite).SprintFunc() mtrTUIMPLSColor = color.New(color.FgHiBlack).SprintFunc() mtrTUIWaitColor = color.New(color.FgHiBlack).SprintFunc() ) func mtrColorLossBucket(loss float64, waiting bool) color.Attribute { if waiting { return color.FgHiBlack } switch { case loss <= 0: return color.FgHiGreen case loss <= 5: return color.FgHiCyan case loss <= 20: return color.FgHiYellow case loss <= 50: return color.FgYellow default: return color.FgHiRed } } func mtrColorPacketsByLoss(lossCell, sntCell string, loss float64, waiting bool) (string, string) { attr := mtrColorLossBucket(loss, waiting) sty := color.New(attr, color.Bold).SprintFunc() if strings.TrimSpace(lossCell) != "" { lossCell = sty(lossCell) } if strings.TrimSpace(sntCell) != "" { sntCell = sty(sntCell) } return lossCell, sntCell } ================================================ FILE: printer/printer.go ================================================ package printer import ( "fmt" "strings" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) // var dataOrigin string // func TraceroutePrinter(res *trace.Result) { // for i, hop := range res.Hops { // fmt.Print(i + 1) // for _, h := range hop { // HopPrinter(h) // } // } // } //此文件目前仅供classic_printer使用 const ( RED_PREFIX = "\033[1;31m" GREEN_PREFIX = "\033[1;32m" YELLOW_PREFIX = "\033[1;33m" BLUE_PREFIX = "\033[1;34m" CYAN_PREFIX = "\033[1;36m" RESET_PREFIX = "\033[0m" ) func HopPrinter(h trace.Hop, info HopInfo) { if h.Address == nil { fmt.Println("\t*") } else { applyLangSetting(&h) // 应用语言设置 txt := "\t" if h.Hostname == "" { txt += fmt.Sprint(h.Address, " ", fmt.Sprintf("%.2f", h.RTT.Seconds()*1000), "ms") } else { txt += fmt.Sprint(h.Hostname, " (", h.Address, ") ", fmt.Sprintf("%.2f", h.RTT.Seconds()*1000), "ms") } if h.Geo != nil { txt += " " + FormatIPGeoData(h.Address.String(), h.Geo) } for _, v := range h.MPLS { txt += " " + v } switch info { case IXP: fmt.Print(CYAN_PREFIX) case PoP: fmt.Print(CYAN_PREFIX) case Peer: fmt.Print(YELLOW_PREFIX) case Aboard: fmt.Print(GREEN_PREFIX) } fmt.Println(txt) if info != General { fmt.Print(RESET_PREFIX) } } } func FormatIPGeoData(ip string, data *ipgeo.IPGeoData) string { var res = make([]string, 0, 10) if data.Source == "timeout" { if data.Country != "" { return data.Country } if data.CountryEn != "" { return data.CountryEn } } if data.Asnumber == "" { res = append(res, "*") } else { res = append(res, "AS"+data.Asnumber) } if data.Whois != "" && data.Country == "" && data.CountryEn == "" && data.Prov == "" && data.ProvEn == "" && data.City == "" && data.CityEn == "" && data.Owner == "" && data.Isp == "" { res = append(res, data.Whois) return strings.Join(res, ", ") } // TODO: 判断阿里云和腾讯云内网,数据不足,有待进一步完善 // TODO: 移动IDC判断到Hop.fetchIPData函数,减少API调用 //if strings.HasPrefix(ip, "9.") { // res = append(res, "LAN Address") //} else if strings.HasPrefix(ip, "11.") { // res = append(res, "LAN Address") //} else if data.Country == "" { // res = append(res, "LAN Address") if false { } else { // 有些IP的归属信息为空,这个时候将ISP的信息填入 if data.Owner == "" { data.Owner = data.Isp } if data.Country != "" { res = append(res, data.Country) } if data.Prov != "" { res = append(res, data.Prov) } if data.City != "" { res = append(res, data.City) } if data.Owner != "" { res = append(res, data.Owner) } } return strings.Join(res, ", ") } ================================================ FILE: printer/printer_test.go ================================================ package printer // func TestPrintTraceRouteNav(t *testing.T) { // PrintTraceRouteNav(util.DomainLookUp("1.1.1.1", false), "1.1.1.1", "dataOrigin") // } // var testGeo = &ipgeo.IPGeoData{ // Asnumber: "TestAsnumber", // Country: "TestCountry", // Prov: "TestProv", // City: "TestCity", // District: "TestDistrict", // Owner: "TestOwner", // Isp: "TestIsp", // } // var testResult = &trace.Result{ // Hops: [][]trace.Hop{ // { // { // Success: true, // Address: &net.IPAddr{IP: net.ParseIP("192.168.3.1")}, // Hostname: "test", // TTL: 0, // RTT: 10 * time.Millisecond, // Error: nil, // Geo: testGeo, // }, // { // Success: true, // Address: &net.IPAddr{IP: net.ParseIP("192.168.3.1")}, // Hostname: "test", // TTL: 0, // RTT: 10 * time.Millisecond, // Error: nil, // Geo: testGeo, // }, // }, // { // { // Success: false, // Address: nil, // Hostname: "", // TTL: 0, // RTT: 0, // Error: errors.New("test error"), // Geo: nil, // }, // { // Success: true, // Address: &net.IPAddr{IP: net.ParseIP("192.168.3.1")}, // Hostname: "test", // TTL: 0, // RTT: 10 * time.Millisecond, // Error: nil, // Geo: nil, // }, // }, // { // { // Success: true, // Address: &net.IPAddr{IP: net.ParseIP("192.168.3.1")}, // Hostname: "test", // TTL: 0, // RTT: 0, // Error: nil, // Geo: &ipgeo.IPGeoData{}, // }, // { // Success: true, // Address: &net.IPAddr{IP: net.ParseIP("192.168.3.1")}, // Hostname: "", // TTL: 0, // RTT: 10 * time.Millisecond, // Error: nil, // Geo: testGeo, // }, // }, // }, // } // // func TestTraceroutePrinter(t *testing.T) { // // TraceroutePrinter(testResult) // // } // func TestTracerouteTablePrinter(t *testing.T) { // TracerouteTablePrinter(testResult) // } // func TestRealtimePrinter(t *testing.T) { // RealtimePrinter(testResult, 0) // // RealtimePrinter(testResult, 1) // // RealtimePrinter(testResult, 2) // } ================================================ FILE: printer/realtime_common.go ================================================ package printer import ( "fmt" "net" "strings" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/internal/hoprender" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/util" ) func printRealtimeTTL(ttl int) { fmt.Printf("%s ", color.New(color.FgHiYellow, color.Bold).Sprintf("%-2d", ttl+1)) } func printRealtimeEmptyHop() { fmt.Fprintf(color.Output, "%s\n", color.New(color.FgWhite, color.Bold).Sprintf("*")) } func displayRealtimeIP(ip string) string { if util.EnableHidDstIP && ip == util.DstIP { return util.HideIPPart(ip) } return ip } func printRealtimeIPColumn(ip string) bool { isIPv6 := net.ParseIP(ip).To4() == nil width := "%-15s" if isIPv6 { width = "%-25s" } fmt.Fprintf(color.Output, "%s", color.New(color.FgWhite, color.Bold).Sprintf(width, displayRealtimeIP(ip))) return isIPv6 } func ensureHopGeo(hop *trace.Hop) { if hop.Geo == nil { hop.Geo = &ipgeo.IPGeoData{} } } func formatWhoisPrefix(whois string, suppressReserved bool) string { whoisFormat := strings.Split(whois, "-") if len(whoisFormat) > 1 { whoisFormat[0] = strings.Join(whoisFormat[:2], "-") } prefix := whoisFormat[0] if prefix == "" { return "" } if suppressReserved && (strings.HasPrefix(prefix, "RFC") || strings.HasPrefix(prefix, "DOD")) { return "" } return "[" + prefix + "]" } func highlightRealtimeBackbone(hop *trace.Hop, whoisPrefix string) bool { switch { case hop.Geo.Asnumber == "58807": return true case hop.Geo.Asnumber == "10099": return true case hop.Geo.Asnumber == "4809": return true case hop.Geo.Asnumber == "9929": return true case hop.Geo.Asnumber == "23764": return true case whoisPrefix == "[CTG-CN]": return true case whoisPrefix == "[CNC-BACKBONE]": return true case whoisPrefix == "[CUG-BACKBONE]": return true case whoisPrefix == "[CMIN2-NET]": return true case hop.Address != nil && strings.HasPrefix(hop.Address.String(), "59.43."): return true default: return false } } func printASNColumn(hop *trace.Hop, highlight bool) { if hop.Geo.Asnumber == "" { fmt.Printf(" %-8s", "*") return } style := color.New(color.FgHiGreen, color.Bold) if highlight { style = color.New(color.FgHiYellow, color.Bold) } fmt.Fprintf(color.Output, " %s", style.Sprintf("AS%-6s", hop.Geo.Asnumber)) } func printWhoisColumn(prefix string, highlight bool) { style := color.New(color.FgHiGreen, color.Bold) if highlight { style = color.New(color.FgHiYellow, color.Bold) } fmt.Fprintf(color.Output, " %s", style.Sprintf("%-16s", prefix)) } func displayRealtimeHostname(ip, hostname string) string { if util.EnableHidDstIP && ip == util.DstIP { return "" } return hostname } func printLocationLine(hop *trace.Hop, ip string, isIPv6 bool) { hostname := displayRealtimeHostname(ip, hop.Hostname) template := " %s %s %s %s %s\n %s " hostWidth := "%-39s" if isIPv6 { hostWidth = "%-32s" } fmt.Fprintf(color.Output, template, color.New(color.FgWhite, color.Bold).Sprintf("%s", hop.Geo.Country), color.New(color.FgWhite, color.Bold).Sprintf("%s", hop.Geo.Prov), color.New(color.FgWhite, color.Bold).Sprintf("%s", hop.Geo.City), color.New(color.FgWhite, color.Bold).Sprintf("%s", hop.Geo.District), fmt.Sprintf("%-6s", hop.Geo.Owner), color.New(color.FgHiBlack, color.Bold).Sprintf(hostWidth, hostname), ) } func printTimingSeries(values []string) { for i, value := range values { if i == 0 { fmt.Fprintf(color.Output, "%s", color.New(color.FgHiCyan, color.Bold).Sprintf("%s", value)) continue } fmt.Fprintf(color.Output, " / %s", color.New(color.FgHiCyan, color.Bold).Sprintf("%s", value)) } } func printHopMPLS(labels []string) { for _, label := range labels { fmt.Fprintf(color.Output, "%s", color.New(color.FgHiBlack, color.Bold).Sprintf("\n %s", label)) } } func renderRealtimeHopLine(res *trace.Result, ttl int, group hoprender.Group, blockDisplay bool) { if blockDisplay { fmt.Printf("%4s", "") } hop := &res.Hops[ttl][group.Index] ensureHopGeo(hop) applyLangSetting(hop) isIPv6 := printRealtimeIPColumn(group.IP) whoisPrefix := formatWhoisPrefix(hop.Geo.Whois, true) highlight := highlightRealtimeBackbone(hop, whoisPrefix) printASNColumn(hop, highlight) if !isIPv6 { printWhoisColumn(whoisPrefix, highlight) } printLocationLine(hop, group.IP, isIPv6) printTimingSeries(group.Timings) printHopMPLS(hop.MPLS) fmt.Println() } func prepareRouterGeo(hop *trace.Hop) { ensureHopGeo(hop) if hop.Geo.Country == "" && hop.Geo.Source != trace.PendingGeoSource { hop.Geo.Country = "LAN Address" } applyLangSetting(hop) } func renderRouterHopLine(res *trace.Result, ttl int, group hoprender.Group, blockDisplay bool) { if blockDisplay { fmt.Printf("%4s", "") } hop := &res.Hops[ttl][group.Index] prepareRouterGeo(hop) isIPv6 := printRealtimeIPColumn(group.IP) printASNColumn(hop, false) if !isIPv6 { printWhoisColumn(formatWhoisPrefix(hop.Geo.Whois, false), false) } printLocationLine(hop, group.IP, isIPv6) printTimingSeries(group.Timings) fmt.Println() } ================================================ FILE: printer/realtime_printer.go ================================================ package printer import ( "github.com/nxtrace/NTrace-core/internal/hoprender" "github.com/nxtrace/NTrace-core/trace" ) func RealtimePrinter(res *trace.Result, ttl int) { printRealtimeTTL(ttl) groups := hoprender.GroupHopAttempts(res.Hops[ttl]) if len(groups) == 0 { printRealtimeEmptyHop() return } blockDisplay := false for _, group := range groups { renderRealtimeHopLine(res, ttl, group, blockDisplay) blockDisplay = true } } ================================================ FILE: printer/realtime_printer_router.go ================================================ package printer import ( "fmt" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/internal/hoprender" "github.com/nxtrace/NTrace-core/trace" ) func RealtimePrinterWithRouter(res *trace.Result, ttl int) { printRealtimeTTL(ttl) groups := hoprender.GroupHopAttempts(res.Hops[ttl]) if len(groups) == 0 { printRealtimeEmptyHop() return } blockDisplay := false for _, group := range groups { renderRouterHopLine(res, ttl, group, blockDisplay) if !blockDisplay { renderRouterSummary(res, ttl) } blockDisplay = true } } func renderRouterSummary(res *trace.Result, ttl int) { hop := &res.Hops[ttl][0] if hop.Geo == nil { return } fmt.Fprintf(color.Output, "%s %s %s %s %s\n", color.New(color.FgWhite, color.Bold).Sprintf("-"), color.New(color.FgHiYellow, color.Bold).Sprintf("%s", hop.Geo.Prefix), color.New(color.FgWhite, color.Bold).Sprintf("路由表"), color.New(color.FgHiCyan, color.Bold).Sprintf("Beta"), color.New(color.FgWhite, color.Bold).Sprintf("-"), ) GetRouter(&hop.Geo.Router, "AS"+hop.Geo.Asnumber) } func GetRouter(r *map[string][]string, node string) { routeMap := *r for _, v := range routeMap[node] { if len(routeMap[v]) != 0 { fmt.Fprintf(color.Output, " %s %s %s\n", color.New(color.FgWhite, color.Bold).Sprintf("%s", routeMap[v][0]), color.New(color.FgWhite, color.Bold).Sprintf("%s", v), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", node), ) } else { fmt.Fprintf(color.Output, " %s %s\n", color.New(color.FgWhite, color.Bold).Sprintf("%s", v), color.New(color.FgHiBlue, color.Bold).Sprintf("%s", node), ) } } } ================================================ FILE: printer/tableprinter.go ================================================ package printer import ( "fmt" "io" "os" "strings" "github.com/fatih/color" "github.com/rodaine/table" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) type rowData struct { Hop string IP string Latency string Asnumber string Country string Prov string City string District string Owner string } func writeTracerouteTable(w io.Writer, res *trace.Result, clearScreen bool) { // 初始化表格 tbl := New() for _, hop := range res.Hops { for k, h := range hop { data := tableDataGenerator(h) if k > 0 { data.Hop = "" } if data.Country == "" && data.Prov == "" && data.City == "" { tbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, "", data.Owner) } else { if data.City != "" { tbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, data.City+", "+data.Prov+", "+data.Country, data.Owner) } else if data.Prov != "" { tbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, data.Prov+", "+data.Country, data.Owner) } else { tbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, data.Country, data.Owner) } } } } if clearScreen { _, _ = io.WriteString(w, "\033[H\033[2J") } // 打印表格 tbl.WithWriter(w).Print() } func TracerouteTablePrinter(res *trace.Result, clearScreen bool) { writeTracerouteTable(os.Stdout, res, clearScreen) } func New() table.Table { // 初始化表格 headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() columnFmt := color.New(color.FgYellow).SprintfFunc() tbl := table.New("Hop", "IP", "Latency", "ASN", "Location", "Owner") tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) return tbl } func tableDataGenerator(h trace.Hop) *rowData { if h.Address == nil { return &rowData{ Hop: fmt.Sprint(h.TTL), IP: "*", } } else { latency := fmt.Sprintf("%.2fms", h.RTT.Seconds()*1000) IP := h.Address.String() if strings.HasPrefix(IP, "9.") { return &rowData{ Hop: fmt.Sprint(h.TTL), IP: IP, Latency: latency, Country: "LAN Address", Prov: "", Owner: "", } } else if strings.HasPrefix(IP, "11.") { return &rowData{ Hop: fmt.Sprint(h.TTL), IP: IP, Latency: latency, Country: "LAN Address", Prov: "", Owner: "", } } if h.Hostname != "" { IP = fmt.Sprint(h.Hostname, " (", IP, ") ") } if h.Geo == nil { h.Geo = &ipgeo.IPGeoData{} } if h.Geo.Owner == "" { h.Geo.Owner = h.Geo.Isp } r := &rowData{ Hop: fmt.Sprint(h.TTL), IP: IP, Latency: latency, Asnumber: h.Geo.Asnumber, Country: h.Geo.CountryEn, Prov: h.Geo.ProvEn, City: h.Geo.CityEn, District: h.Geo.District, Owner: h.Geo.Owner, } return r } } ================================================ FILE: printer/tableprinter_test.go ================================================ package printer import ( "bytes" "net" "strings" "testing" "time" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) func testTracerouteTableResult() *trace.Result { return &trace.Result{ Hops: [][]trace.Hop{ { { TTL: 1, Address: &net.IPAddr{IP: net.ParseIP("192.0.2.1")}, Hostname: "router1", RTT: 12 * time.Millisecond, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", CountryEn: "Hong Kong", Owner: "Cloudflare", }, }, }, }, } } func TestWriteTracerouteTableNonTTYOmitsClearScreenANSI(t *testing.T) { prevNoColor := color.NoColor color.NoColor = true defer func() { color.NoColor = prevNoColor }() var buf bytes.Buffer writeTracerouteTable(&buf, testTracerouteTableResult(), false) output := buf.String() if strings.Contains(output, "\033[H\033[2J") { t.Fatalf("output should not contain clear-screen ANSI:\n%q", output) } for _, want := range []string{"Hop", "router1 (192.0.2.1)", "Cloudflare"} { if !strings.Contains(output, want) { t.Fatalf("output missing %q:\n%q", want, output) } } } func TestWriteTracerouteTableTTYIncludesClearScreenANSI(t *testing.T) { prevNoColor := color.NoColor color.NoColor = true defer func() { color.NoColor = prevNoColor }() var buf bytes.Buffer writeTracerouteTable(&buf, testTracerouteTableResult(), true) output := buf.String() if !strings.HasPrefix(output, "\033[H\033[2J") { t.Fatalf("output should start with clear-screen ANSI:\n%q", output) } } ================================================ FILE: ptr.example.csv ================================================ snge,SG,,Singapore CXS,CN,Hunan,Changsha LAX,US,California,Los Angeles SJC,US,California,San Jose ================================================ FILE: reporter/reporter.go ================================================ package reporter import ( "fmt" "net" "strings" "sync" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) type Reporter interface { Print() } func New(rs *trace.Result, ip string) Reporter { experimentTag() r := reporter{ routeResult: rs, targetIP: ip, } return &r } type reporter struct { targetTTL uint16 targetIP string routeReport map[uint16][]routeReportNode routeReportLock sync.Mutex routeResult *trace.Result wg sync.WaitGroup } type routeReportNode struct { asn string isp string geo []string ix bool } func experimentTag() { fmt.Println("Route-Path 功能实验室") } func (r *reporter) generateRouteReportNode(ip string, ipGeoData ipgeo.IPGeoData, ttl uint16) { defer r.wg.Done() node := routeReportNode{ ix: routeReportNodeIX(ip, ipGeoData), asn: routeReportNodeASN(ip, ipGeoData), isp: routeReportNodeISP(ipGeoData), } if ipGeoData.Asnumber == "" { node.asn = "*" } geo, ok := routeReportNodeGeo(ip, ipGeoData, r.targetIP) if !ok { return } node.geo = geo r.appendRouteReportNode(ttl, node) } func routeReportNodeIX(ip string, ipGeoData ipgeo.IPGeoData) bool { ptr, err := net.LookupAddr(ip) if err == nil && len(ptr) > 0 && strings.Contains(strings.ToLower(ptr[0]), "ix") { return true } return routeReportContainsIX(ipGeoData.Isp) || routeReportContainsIX(ipGeoData.Owner) } func routeReportContainsIX(value string) bool { value = strings.ToLower(value) return strings.Contains(value, "exchange") || strings.Contains(value, "ix") } func routeReportNodeASN(ip string, ipGeoData ipgeo.IPGeoData) string { if strings.HasPrefix(ip, "59.43") { return "4809" } return ipGeoData.Asnumber } func routeReportNodeGeo(ip string, ipGeoData ipgeo.IPGeoData, targetIP string) ([]string, bool) { if (ipGeoData.Country == "" || ipGeoData.Country == "LAN Address" || ipGeoData.Country == "-") && ip != targetIP { return nil, false } if ipGeoData.City == "" { return []string{ipGeoData.Country, ipGeoData.Prov}, true } return []string{ipGeoData.Country, ipGeoData.City}, true } func routeReportNodeISP(ipGeoData ipgeo.IPGeoData) string { if ipGeoData.Isp != "" { return ipGeoData.Isp } return ipGeoData.Owner } func (r *reporter) appendRouteReportNode(ttl uint16, node routeReportNode) { r.routeReportLock.Lock() r.routeReport[ttl] = append(r.routeReport[ttl], node) r.routeReportLock.Unlock() } func (r *reporter) InitialBaseData() Reporter { reportNodes := map[uint16][]routeReportNode{} r.routeReport = reportNodes r.targetTTL = uint16(len(r.routeResult.Hops)) for i := uint16(0); i < r.targetTTL; i++ { if i < uint16(len(r.routeResult.Hops)) && len(r.routeResult.Hops[i]) > 0 { traceHop := r.routeResult.Hops[i][0] if traceHop.Success && traceHop.Geo != nil { currentIP := traceHop.Address.String() r.wg.Add(1) go r.generateRouteReportNode(currentIP, *traceHop.Geo, i) } } } // 等待所有的子协程运行完毕 r.wg.Wait() return r } func (r *reporter) Print() { var beforeActiveTTL uint16 = 0 r.InitialBaseData() // 尝试首个有效 TTL for i := uint16(0); i < r.targetTTL; i++ { if len(r.routeReport[i]) != 0 { beforeActiveTTL = i // 找到以后便不再循环 break } } for i := beforeActiveTTL; i < r.targetTTL; i++ { // 计算该TTL内的数据长度,如果为0,则代表没有有效数据 if len(r.routeReport[i]) == 0 { // 跳过改跃点的数据整理 continue } nodeReport := r.routeReport[i][0] if i == beforeActiveTTL { fmt.Printf("AS%s %s「%s『%s", nodeReport.asn, nodeReport.isp, nodeReport.geo[0], nodeReport.geo[1]) } else { nodeReportBefore := r.routeReport[beforeActiveTTL][0] // ASN 相同,同个 ISP 内部的数据传递 if nodeReportBefore.asn == nodeReport.asn { // Same ASN but Coutry or City Changed if nodeReportBefore.geo[0] != nodeReport.geo[0] { fmt.Printf("』→ %s『%s", nodeReport.geo[0], nodeReport.geo[1]) } else { if nodeReportBefore.geo[1] != nodeReport.geo[1] { fmt.Printf(" → %s", nodeReport.geo[1]) } } } else { // ASN 不同,跨 ISP 的数据传递,这里可能会出现 POP、IP Transit、Peer、Exchange fmt.Printf("』」") if int(i) != len(r.routeReport)+1 { // 部分 Shell 客户端可能无法很好的展示这个特殊字符 // TODO: 寻找其他替代字符 fmt.Printf("\n ╭╯\n ╰") } if nodeReport.ix { fmt.Printf("AS%s \033[42;37mIXP\033[0m %s「%s『%s", nodeReport.asn, nodeReport.isp, nodeReport.geo[0], nodeReport.geo[1]) } else { fmt.Printf("AS%s %s「%s『%s", nodeReport.asn, nodeReport.isp, nodeReport.geo[0], nodeReport.geo[1]) } } } // 标记为最新的一个有效跃点 beforeActiveTTL = i } fmt.Println("』」") } ================================================ FILE: reporter/reporter_test.go ================================================ package reporter import ( "bytes" "io" "net" "os" "strings" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) var testResult = &trace.Result{ Hops: [][]trace.Hop{ { { Success: true, Address: &net.IPAddr{IP: net.ParseIP("192.168.3.1")}, Hostname: "test", TTL: 0, RTT: 10 * time.Millisecond, Error: nil, Geo: &ipgeo.IPGeoData{ Asnumber: "4808", Country: "中国", Prov: "北京市", City: "北京市", District: "北京市", Owner: "", Isp: "中国联通", }, }, }, { { Success: true, Address: &net.IPAddr{IP: net.ParseIP("114.249.16.1")}, Hostname: "test", TTL: 0, RTT: 10 * time.Millisecond, Error: nil, Geo: &ipgeo.IPGeoData{ Asnumber: "4808", Country: "中国", Prov: "北京市", City: "北京市", District: "北京市", Owner: "", Isp: "中国联通", }, }, }, { { Success: true, Address: &net.IPAddr{IP: net.ParseIP("219.158.5.150")}, Hostname: "test", TTL: 0, RTT: 10 * time.Millisecond, Error: nil, Geo: &ipgeo.IPGeoData{ Asnumber: "4837", Country: "中国", Prov: "", City: "", District: "", Owner: "", Isp: "中国联通", }, }, }, { { Success: true, Address: &net.IPAddr{IP: net.ParseIP("62.115.125.160")}, Hostname: "test", TTL: 0, RTT: 10 * time.Millisecond, Error: nil, Geo: &ipgeo.IPGeoData{ Asnumber: "1299", Country: "Sweden", Prov: "Stockholm County", City: "Stockholm", District: "", Owner: "", Isp: "Telia Company AB", }, }, }, { { Success: true, Address: &net.IPAddr{IP: net.ParseIP("213.226.68.73")}, Hostname: "test", TTL: 0, RTT: 10 * time.Millisecond, Error: nil, Geo: &ipgeo.IPGeoData{ Asnumber: "56630", Country: "Germany", Prov: "Hesse, Frankfurt", City: "", District: "", Owner: "", Isp: "Melbikomas UAB", }, }, }, }, } // captureStdout redirects os.Stdout to a pipe and returns the captured output. func captureStdout(t *testing.T, fn func()) string { t.Helper() oldStdout := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatalf("failed to create pipe: %v", err) } os.Stdout = w fn() w.Close() os.Stdout = oldStdout var buf bytes.Buffer io.Copy(&buf, r) r.Close() return buf.String() } func TestPrint(t *testing.T) { output := captureStdout(t, func() { r := New(testResult, "213.226.68.73") r.Print() }) // 验证实验标签 if !strings.Contains(output, "Route-Path 功能实验室") { t.Error("expected output to contain experiment tag 'Route-Path 功能实验室'") } // 验证包含各 ASN for _, asn := range []string{"AS4808", "AS4837", "AS1299", "AS56630"} { if !strings.Contains(output, asn) { t.Errorf("expected output to contain %s", asn) } } // 验证包含 ISP 名称 for _, isp := range []string{"中国联通", "Telia Company AB", "Melbikomas UAB"} { if !strings.Contains(output, isp) { t.Errorf("expected output to contain ISP %q", isp) } } // 验证包含跨 ASN 分隔符 if !strings.Contains(output, "╭╯") || !strings.Contains(output, "╰") { t.Error("expected output to contain ASN transition markers (╭╯/╰)") } // 验证包含地理信息括号 if !strings.Contains(output, "「") || !strings.Contains(output, "」") { t.Error("expected output to contain geographic brackets (「」)") } // 验证包含城市分隔 if !strings.Contains(output, "『") || !strings.Contains(output, "』") { t.Error("expected output to contain city brackets (『』)") } } ================================================ FILE: server/browser_access.go ================================================ package server import ( "net/http" "net/url" "strings" "github.com/gin-gonic/gin" "github.com/nxtrace/NTrace-core/util" ) const ( maxTraceRequestBodyBytes = 64 << 10 maxWSInitMessageBytes = 64 << 10 ) func browserOriginAllowed(r *http.Request) bool { if util.AllowCrossOriginBrowserAccess() { return true } origin := strings.TrimSpace(r.Header.Get("Origin")) if origin == "" { return true } u, err := url.Parse(origin) if err != nil || u.Host == "" { return false } return strings.EqualFold(u.Host, r.Host) } func browserAccessMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !browserOriginAllowed(c.Request) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "cross-origin browser access is disabled"}) return } if util.AllowCrossOriginBrowserAccess() { if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" { h := c.Writer.Header() h.Set("Access-Control-Allow-Origin", origin) h.Add("Vary", "Origin") h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") h.Set("Access-Control-Allow-Headers", "Content-Type") } } c.Next() } } ================================================ FILE: server/browser_access_test.go ================================================ package server import ( "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/nxtrace/NTrace-core/util" ) func TestTraceUpgraderCheckOrigin_DefaultsToSameOriginOnly(t *testing.T) { t.Setenv(util.EnvAllowCrossOriginKey, "0") if !traceUpgrader.CheckOrigin(&http.Request{Host: "127.0.0.1:1080"}) { t.Fatal("requests without Origin header should be allowed") } if !traceUpgrader.CheckOrigin(&http.Request{ Host: "127.0.0.1:1080", Header: http.Header{"Origin": []string{"http://127.0.0.1:1080"}}, }) { t.Fatal("same-origin websocket request should be allowed") } if traceUpgrader.CheckOrigin(&http.Request{ Host: "127.0.0.1:1080", Header: http.Header{"Origin": []string{"https://evil.example"}}, }) { t.Fatal("cross-origin websocket request should be rejected by default") } } func TestTraceUpgraderCheckOrigin_CanBeRelaxedViaEnv(t *testing.T) { t.Setenv(util.EnvAllowCrossOriginKey, "1") if !traceUpgrader.CheckOrigin(&http.Request{ Host: "127.0.0.1:1080", Header: http.Header{"Origin": []string{"https://evil.example"}}, }) { t.Fatal("cross-origin websocket request should be allowed when env is enabled") } } func TestBrowserAccessMiddleware_DefaultRejectsCrossOriginHTTP(t *testing.T) { t.Setenv(util.EnvAllowCrossOriginKey, "0") gin.SetMode(gin.TestMode) router := gin.New() router.Use(browserAccessMiddleware()) router.GET("/ok", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) req := httptest.NewRequest(http.MethodGet, "/ok", nil) req.Host = "127.0.0.1:1080" req.Header.Set("Origin", "https://evil.example") resp := httptest.NewRecorder() router.ServeHTTP(resp, req) if resp.Code != http.StatusForbidden { t.Fatalf("status = %d, want %d", resp.Code, http.StatusForbidden) } } func TestBrowserAccessMiddleware_CanEnableCORSViaEnv(t *testing.T) { t.Setenv(util.EnvAllowCrossOriginKey, "1") gin.SetMode(gin.TestMode) router := gin.New() router.Use(browserAccessMiddleware()) router.OPTIONS("/*path", func(c *gin.Context) { c.Status(http.StatusNoContent) }) req := httptest.NewRequest(http.MethodOptions, "/api/trace", nil) req.Host = "127.0.0.1:1080" req.Header.Set("Origin", "https://evil.example") resp := httptest.NewRecorder() router.ServeHTTP(resp, req) if resp.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d", resp.Code, http.StatusNoContent) } if got := resp.Header().Get("Access-Control-Allow-Origin"); got != "https://evil.example" { t.Fatalf("allow-origin = %q, want %q", got, "https://evil.example") } } ================================================ FILE: server/cache_handler.go ================================================ package server import ( "net/http" "github.com/gin-gonic/gin" "github.com/nxtrace/NTrace-core/trace" ) func cacheClearHandler(c *gin.Context) { trace.ClearCaches() c.JSON(http.StatusOK, gin.H{"ok": true}) } ================================================ FILE: server/handlers.go ================================================ package server import ( "net/http" "github.com/gin-gonic/gin" ) var ( supportedProtocols = []string{"icmp", "udp", "tcp"} dataProviders = []string{ "LeoMoeAPI", "IP.SB", "IPInsight", "IPInfo", "IPInfoLocal", "ip-api.com", "chunzhen", "DN42", "disable-geoip", "ipdb.one", } defaults = map[string]any{ "protocol": "icmp", "queries": 3, "max_hops": 30, "timeout_ms": 1000, "packet_size": nil, "tos": 0, "parallel_requests": 18, "begin_hop": 1, "language": "cn", "data_provider": "LeoMoeAPI", "disable_maptrace": false, } ) func optionsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "protocols": supportedProtocols, "dataProviders": dataProviders, "defaultOptions": defaults, }) } ================================================ FILE: server/mtr.go ================================================ package server import ( "math" "sort" "strings" "sync" "time" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) // Deprecated: websocket MTR now streams probe-level "mtr_raw" events via trace.RunMTRRaw. // This legacy snapshot aggregator is kept temporarily for compatibility/reference. type mtrAggregator struct { mu sync.Mutex stats map[int]map[string]*hopAccum nextOrder int } type hopAccum struct { TTL int Key string Host string IP string Sent int Received int Sum float64 Last float64 Best float64 Worst float64 Geo *ipgeo.IPGeoData Errors map[string]int order int mplsSet map[string]struct{} } type groupMetrics struct { host string ip string geo *ipgeo.IPGeoData sum float64 last float64 best float64 worst float64 received int count int errors map[string]int mpls map[string]struct{} } type mtrHopJSON struct { TTL int `json:"ttl"` Host string `json:"host,omitempty"` IP string `json:"ip,omitempty"` Sent int `json:"sent"` Received int `json:"received"` LossPercent float64 `json:"loss_percent"` LossCount int `json:"loss_count"` Last float64 `json:"last_ms"` Avg float64 `json:"avg_ms"` Best float64 `json:"best_ms"` Worst float64 `json:"worst_ms"` Geo *ipgeo.IPGeoData `json:"geo,omitempty"` FailureType string `json:"failure_type,omitempty"` Errors map[string]int `json:"errors,omitempty"` MPLS []string `json:"mpls,omitempty"` } type mtrSnapshot struct { Iteration int `json:"iteration"` Stats []mtrHopJSON `json:"stats"` } func newMTRAggregator() *mtrAggregator { return &mtrAggregator{ stats: make(map[int]map[string]*hopAccum), } } func buildAttemptGroups(attempts []trace.Hop) map[string]*groupMetrics { groups := make(map[string]*groupMetrics) for _, attempt := range attempts { host := strings.TrimSpace(attempt.Hostname) ip := "" if attempt.Address != nil { ip = strings.TrimSpace(attempt.Address.String()) } key := hopKey(ip, host) group := groups[key] if group == nil { group = &groupMetrics{ host: host, ip: ip, best: math.MaxFloat64, } groups[key] = group } mergeAttemptIntoGroup(group, attempt) } return groups } func mergeAttemptIntoGroup(group *groupMetrics, attempt trace.Hop) { group.count++ if group.geo == nil && attempt.Geo != nil { group.geo = attempt.Geo } addGroupMPLS(group, attempt.MPLS) if attempt.Success { updateGroupRTT(group, attempt) return } if group.errors == nil { group.errors = make(map[string]int) } group.errors[attemptErrorKey(attempt)]++ } func addGroupMPLS(group *groupMetrics, labels []string) { if len(labels) == 0 { return } if group.mpls == nil { group.mpls = make(map[string]struct{}) } for _, label := range labels { val := strings.TrimSpace(label) if val != "" { group.mpls[val] = struct{}{} } } } func updateGroupRTT(group *groupMetrics, attempt trace.Hop) { rttMs := float64(attempt.RTT) / float64(time.Millisecond) group.sum += rttMs group.received++ group.last = rttMs if rttMs > group.worst { group.worst = rttMs } if rttMs > 0 && rttMs < group.best { group.best = rttMs } } func attemptErrorKey(attempt trace.Hop) string { if attempt.Error == nil { return "timeout" } errKey := strings.TrimSpace(attempt.Error.Error()) if errKey == "" { return "timeout" } return errKey } func (agg *mtrAggregator) ensureHopAccum(ttl int, accMap map[string]*hopAccum, key string) *hopAccum { acc := accMap[key] if acc != nil { return acc } acc = &hopAccum{ TTL: ttl, Key: key, Best: math.MaxFloat64, order: agg.nextOrder, mplsSet: make(map[string]struct{}), } agg.nextOrder++ accMap[key] = acc return acc } func mergeGroup(acc *hopAccum, group *groupMetrics) { if group.ip != "" { acc.IP = group.ip } if group.host != "" { acc.Host = group.host } if group.geo != nil { acc.Geo = group.geo } acc.Sent += group.count if group.received > 0 { acc.Sum += group.sum acc.Received += group.received acc.Last = group.last if group.best > 0 && (acc.Best == math.MaxFloat64 || group.best < acc.Best) { acc.Best = group.best } if group.worst > acc.Worst { acc.Worst = group.worst } } mergeErrorCounts(acc, group.errors) mergeMPLSSet(acc, group.mpls) } func mergeErrorCounts(acc *hopAccum, errors map[string]int) { if len(errors) == 0 { return } if acc.Errors == nil { acc.Errors = make(map[string]int) } for errKey, count := range errors { acc.Errors[errKey] += count } } func mergeMPLSSet(acc *hopAccum, mpls map[string]struct{}) { if len(mpls) == 0 { return } if acc.mplsSet == nil { acc.mplsSet = make(map[string]struct{}) } for label := range mpls { acc.mplsSet[label] = struct{}{} } } func (agg *mtrAggregator) Update(res *trace.Result, queries int) []mtrHopJSON { agg.mu.Lock() defer agg.mu.Unlock() if queries <= 0 { queries = 1 } for idx, attempts := range res.Hops { if len(attempts) == 0 { continue } ttl := idx + 1 accMap := agg.stats[ttl] if accMap == nil { accMap = make(map[string]*hopAccum) agg.stats[ttl] = accMap } for key, group := range buildAttemptGroups(attempts) { acc := agg.ensureHopAccum(ttl, accMap, key) mergeGroup(acc, group) } } return agg.buildSnapshotLocked() } func (agg *mtrAggregator) Snapshot() []mtrHopJSON { agg.mu.Lock() defer agg.mu.Unlock() return agg.buildSnapshotLocked() } func (agg *mtrAggregator) buildSnapshotLocked() []mtrHopJSON { rows := make([]mtrHopJSON, 0, len(agg.stats)) keys := make([]int, 0, len(agg.stats)) for ttl := range agg.stats { keys = append(keys, ttl) } sort.Ints(keys) for _, ttl := range keys { accMap := agg.stats[ttl] if len(accMap) == 0 { continue } accs := make([]*hopAccum, 0, len(accMap)) for _, acc := range accMap { accs = append(accs, acc) } sort.SliceStable(accs, func(i, j int) bool { if accs[i].order == accs[j].order { return accs[i].IP < accs[j].IP } return accs[i].order < accs[j].order }) for _, acc := range accs { if acc == nil { continue } lossCount := acc.Sent - acc.Received lossPercent := 0.0 if acc.Sent > 0 { lossPercent = float64(lossCount) / float64(acc.Sent) * 100 } best := acc.Best if best == math.MaxFloat64 { best = 0 } avg := 0.0 if acc.Received > 0 { avg = acc.Sum / float64(acc.Received) } failureType := failureTypeFromErrors(acc.Errors, acc.Received, lossCount) mpls := sortedSet(acc.mplsSet) rows = append(rows, mtrHopJSON{ TTL: acc.TTL, Host: acc.Host, IP: acc.IP, Sent: acc.Sent, Received: acc.Received, LossPercent: lossPercent, LossCount: lossCount, Last: acc.Last, Avg: avg, Best: best, Worst: acc.Worst, Geo: acc.Geo, FailureType: failureType, Errors: copyErrors(acc.Errors), MPLS: mpls, }) } } return rows } func hopKey(ip, host string) string { ip = strings.TrimSpace(ip) host = strings.TrimSpace(host) if ip != "" { return "ip:" + ip } if host != "" { return "host:" + strings.ToLower(host) } return "unknown" } func copyErrors(src map[string]int) map[string]int { if len(src) == 0 { return nil } dst := make(map[string]int, len(src)) for k, v := range src { dst[k] = v } return dst } func sortedSet(set map[string]struct{}) []string { if len(set) == 0 { return nil } list := make([]string, 0, len(set)) for k := range set { list = append(list, k) } sort.Strings(list) return list } func failureTypeFromErrors(errors map[string]int, received, lossCount int) string { if lossCount <= 0 { return "" } if len(errors) == 0 { if received == 0 { return "all_timeout" } return "partial_timeout" } allTimeout := true for key := range errors { lower := strings.ToLower(strings.TrimSpace(key)) if lower == "timeout" || strings.Contains(lower, "timeout") { continue } allTimeout = false break } if allTimeout { if received == 0 { return "all_timeout" } return "partial_timeout" } return "mixed" } ================================================ FILE: server/server.go ================================================ package server import ( "context" "embed" "errors" "fmt" "io/fs" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/gin-gonic/gin" ) //go:embed web/* var webContent embed.FS const defaultListenAddr = ":1080" var indexPage []byte var assetsFS fs.FS func init() { var err error indexPage, err = webContent.ReadFile("web/index.html") if err != nil { panic(fmt.Errorf("web assets missing index.html: %w", err)) } assetsFS, err = fs.Sub(webContent, "web/assets") if err != nil { panic(fmt.Errorf("web assets missing asset directory: %w", err)) } } // Run starts the Gin HTTP server that exposes the traceroute UI and APIs. func Run(listenAddr string) error { if listenAddr == "" { listenAddr = defaultListenAddr } gin.SetMode(gin.ReleaseMode) router := gin.New() router.Use(gin.Logger(), gin.Recovery()) router.Use(browserAccessMiddleware()) router.OPTIONS("/*path", func(c *gin.Context) { c.Status(http.StatusNoContent) }) router.GET("/", func(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage) }) router.StaticFS("/assets", http.FS(assetsFS)) router.GET("/api/options", optionsHandler) router.POST("/api/trace", traceHandler) router.POST("/api/cache/clear", cacheClearHandler) router.GET("/ws/trace", traceWebsocketHandler) srv := &http.Server{Addr: listenAddr, Handler: router} ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) }() err := srv.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { if strings.Contains(err.Error(), "address already in use") { return fmt.Errorf("listen %s: %w", listenAddr, err) } return err } return nil } ================================================ FILE: server/trace_handler.go ================================================ package server import ( "context" "encoding/json" "errors" "fmt" "log" "net" "net/http" "net/url" "runtime" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/nxtrace/NTrace-core/config" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/tracemap" "github.com/nxtrace/NTrace-core/util" "github.com/nxtrace/NTrace-core/wshandle" ) var traceMu sync.Mutex var leoConnMu sync.Mutex var traceMapURLFn = tracemap.GetMapUrlWithContext var traceDomainLookupFn = util.DomainLookUpWithContext var withTraceMapScopeFn = func(setup *traceExecution, callback func() (string, error)) (string, error) { return withTraceGeoDNSScope(setup, callback) } type traceExecution struct { Req traceRequest Target string Protocol string DataProvider string Method trace.Method IP net.IP Config trace.Config NeedsLeoWS bool PowProvider string } type traceRequest struct { Target string `json:"target"` Protocol string `json:"protocol"` Port int `json:"port"` Queries int `json:"queries"` MaxHops int `json:"max_hops"` TimeoutMs int `json:"timeout_ms"` PacketSize *int `json:"packet_size"` TOS *int `json:"tos"` ParallelRequests int `json:"parallel_requests"` BeginHop int `json:"begin_hop"` IPv4Only bool `json:"ipv4_only"` IPv6Only bool `json:"ipv6_only"` DataProvider string `json:"data_provider"` PowProvider string `json:"pow_provider"` DotServer string `json:"dot_server"` DisableRDNS bool `json:"disable_rdns"` AlwaysRDNS bool `json:"always_rdns"` DisableMaptrace bool `json:"disable_maptrace"` DisableMPLS bool `json:"disable_mpls"` Language string `json:"language"` DN42 bool `json:"dn42"` SourceAddress string `json:"source_address"` SourcePort int `json:"source_port"` SourceDevice string `json:"source_device"` ICMPMode int `json:"icmp_mode"` PacketInterval int `json:"packet_interval"` TTLInterval int `json:"ttl_interval"` MaxAttempts int `json:"max_attempts"` AlwaysWaitRDNS bool `json:"always_wait_rdns"` Maptrace *bool `json:"maptrace"` // deprecated toggle compatibility LanguageOverride string `json:"language_override"` DataProviderAlias string `json:"data_provider_alias"` Mode string `json:"mode"` IntervalMs int `json:"interval_ms"` HopIntervalMs int `json:"hop_interval_ms"` MaxRounds int `json:"max_rounds"` } type hopAttempt struct { Success bool `json:"success"` IP string `json:"ip,omitempty"` Hostname string `json:"hostname,omitempty"` RTT float64 `json:"rtt_ms,omitempty"` Error string `json:"error,omitempty"` MPLS []string `json:"mpls,omitempty"` Geo *ipgeo.IPGeoData `json:"geo,omitempty"` } type hopResponse struct { TTL int `json:"ttl"` Attempts []hopAttempt `json:"attempts"` } type traceResponse struct { Target string `json:"target"` ResolvedIP string `json:"resolved_ip"` Protocol string `json:"protocol"` DataProvider string `json:"data_provider"` TraceMapURL string `json:"trace_map_url,omitempty"` Language string `json:"language"` Hops []hopResponse `json:"hops"` DurationMs int64 `json:"duration_ms"` } type traceProtocolSelection struct { protocol string method trace.Method dstPort int } func normalizeTraceRequest(req *traceRequest) (int, error) { if req == nil { return http.StatusBadRequest, errors.New("request is required") } req.Mode = strings.ToLower(strings.TrimSpace(req.Mode)) if req.Maptrace != nil { req.DisableMaptrace = !*req.Maptrace } if req.IPv4Only && req.IPv6Only { return http.StatusBadRequest, errors.New("ipv4_only and ipv6_only cannot be true at the same time") } if err := validateSourceDevice(req.SourceDevice); err != nil { return http.StatusBadRequest, err } if req.IntervalMs <= 0 { req.IntervalMs = 0 } if req.MaxRounds < 0 { req.MaxRounds = 0 } if req.TOS != nil && (*req.TOS < 0 || *req.TOS > 255) { return http.StatusBadRequest, errors.New("tos must be within range 0-255") } return 0, nil } func resolveTraceProtocol(req traceRequest) (traceProtocolSelection, int, error) { protocol := strings.ToLower(strings.TrimSpace(req.Protocol)) if protocol == "" { protocol = "icmp" } if !contains(supportedProtocols, protocol) { return traceProtocolSelection{}, http.StatusBadRequest, fmt.Errorf("unsupported protocol %q", protocol) } method := trace.ICMPTrace switch protocol { case "udp": method = trace.UDPTrace case "tcp": method = trace.TCPTrace } dstPort := req.Port if dstPort == 0 { switch method { case trace.UDPTrace: dstPort = 33494 case trace.TCPTrace: dstPort = 80 } } return traceProtocolSelection{ protocol: protocol, method: method, dstPort: dstPort, }, 0, nil } func resolveTraceDataProvider(req *traceRequest) (string, bool) { dataProvider := normalizeDataProvider(req.DataProvider, req.DataProviderAlias) if dataProvider == "" { dataProvider = defaults["data_provider"].(string) } if strings.EqualFold(dataProvider, "DN42") { req.DN42 = true } if req.DN42 { config.InitConfig() req.DisableMaptrace = true dataProvider = "DN42" } needsLeoWS := strings.EqualFold(dataProvider, "LEOMOEAPI") if needsLeoWS && util.EnvDataProvider != "" { dataProvider = util.EnvDataProvider needsLeoWS = strings.EqualFold(dataProvider, "LEOMOEAPI") } return dataProvider, needsLeoWS } func resolveTraceIPVersion(req traceRequest) string { switch { case req.IPv4Only: return "4" case req.IPv6Only: return "6" default: return "all" } } func prepareTrace(ctx context.Context, req traceRequest) (*traceExecution, int, error) { exec := &traceExecution{ Req: req, } if statusCode, err := normalizeTraceRequest(&exec.Req); err != nil { return nil, statusCode, err } target, err := normalizeTarget(exec.Req.Target) if err != nil { return nil, http.StatusBadRequest, err } exec.Target = target protocol, statusCode, err := resolveTraceProtocol(exec.Req) if err != nil { return nil, statusCode, err } exec.Protocol = protocol.protocol exec.Method = protocol.method dataProvider, needsLeoWS := resolveTraceDataProvider(&exec.Req) ip, err := traceDomainLookupFn(ctx, target, resolveTraceIPVersion(exec.Req), strings.ToLower(exec.Req.DotServer), true) if err != nil { return nil, http.StatusInternalServerError, err } exec.IP = ip exec.DataProvider = dataProvider exec.PowProvider = strings.TrimSpace(exec.Req.PowProvider) exec.NeedsLeoWS = needsLeoWS exec.Config, err = buildTraceConfig(exec.Req, exec.Method, ip, dataProvider, protocol.dstPort) if err != nil { return nil, http.StatusBadRequest, err } exec.Config.Context = ctx return exec, 0, nil } func traceHandler(c *gin.Context) { var req traceRequest c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxTraceRequestBodyBytes) if err := c.ShouldBindJSON(&req); err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "request payload too large"}) return } c.JSON(400, gin.H{"error": "invalid request payload", "details": err.Error()}) return } setup, statusCode, err := prepareTrace(c.Request.Context(), req) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } if statusCode == 0 { statusCode = 500 } log.Printf("[deploy] prepare trace failed target=%s error=%v", sanitizeLogParam(req.Target), err) c.JSON(statusCode, gin.H{"error": err.Error()}) return } log.Printf("[deploy] trace request target=%s proto=%s provider=%s lang=%s ipv4_only=%t ipv6_only=%t", sanitizeLogParam(setup.Target), sanitizeLogParam(setup.Protocol), sanitizeLogParam(setup.DataProvider), sanitizeLogParam(setup.Config.Lang), setup.Req.IPv4Only, setup.Req.IPv6Only) log.Printf("[deploy] target resolved target=%s ip=%s via dot=%s", sanitizeLogParam(setup.Target), setup.IP, sanitizeLogParam(strings.ToLower(setup.Req.DotServer))) traceMu.Lock() defer traceMu.Unlock() if setup.NeedsLeoWS { if _, err := withTraceSetupContext(setup, func() (struct{}, error) { ensureLeoMoeConnection() return struct{}{}, nil }); err != nil { log.Printf("[deploy] failed to initialize LeoMoeAPI connection target=%s error=%v", sanitizeLogParam(setup.Target), err) c.JSON(500, gin.H{"error": err.Error()}) return } } configured := setup.Config log.Printf("[deploy] starting trace target=%s resolved=%s method=%s lang=%s queries=%d maxHops=%d", sanitizeLogParam(setup.Target), setup.IP.String(), string(setup.Method), sanitizeLogParam(configured.Lang), configured.NumMeasurements, configured.MaxHops) start := time.Now() res, err := withTraceSetupContext(setup, func() (*trace.Result, error) { return traceTracerouteFn(setup.Method, configured) }) duration := time.Since(start) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } log.Printf("[deploy] trace failed target=%s error=%v", sanitizeLogParam(setup.Target), err) c.JSON(500, gin.H{"error": err.Error()}) return } traceMapURL := traceMapURLForResult(setup, res) if traceMapURL != "" { log.Printf("[deploy] trace map generated target=%s mapUrl=%s", sanitizeLogParam(setup.Target), traceMapURL) } response := traceResponse{ Target: setup.Target, ResolvedIP: setup.IP.String(), Protocol: setup.Protocol, DataProvider: setup.DataProvider, TraceMapURL: traceMapURL, Language: configured.Lang, Hops: convertHops(res, configured.Lang), DurationMs: duration.Milliseconds(), } log.Printf("[deploy] trace completed target=%s hops=%d duration=%s", sanitizeLogParam(setup.Target), len(response.Hops), duration) c.JSON(200, response) } func buildTraceConfig(req traceRequest, method trace.Method, ip net.IP, dataProvider string, port int) (trace.Config, error) { lang := strings.TrimSpace(req.Language) if lang == "" { lang = defaults["language"].(string) } timeout := req.TimeoutMs if timeout <= 0 { timeout = defaults["timeout_ms"].(int) } packetSize := trace.DefaultPacketSize(method, ip) if req.PacketSize != nil { packetSize = *req.PacketSize } packetSizeSpec, err := trace.NormalizePacketSize(method, ip, packetSize) if err != nil { return trace.Config{}, err } tos := defaults["tos"].(int) if req.TOS != nil { tos = *req.TOS } if req.PacketInterval <= 0 { req.PacketInterval = 50 } if req.TTLInterval <= 0 { req.TTLInterval = 50 } maxHops := req.MaxHops if maxHops <= 0 { maxHops = defaults["max_hops"].(int) } queries := req.Queries if queries <= 0 { queries = defaults["queries"].(int) } parallel := req.ParallelRequests if parallel <= 0 { parallel = defaults["parallel_requests"].(int) } beginHop := req.BeginHop if beginHop <= 0 { beginHop = defaults["begin_hop"].(int) } alwaysWait := req.AlwaysWaitRDNS || req.AlwaysRDNS ostype := 3 switch runtime.GOOS { case "darwin": ostype = 1 case "windows": ostype = 2 } return trace.Config{ OSType: ostype, ICMPMode: req.ICMPMode, SrcAddr: req.SourceAddress, SrcPort: req.SourcePort, SourceDevice: strings.TrimSpace(req.SourceDevice), BeginHop: beginHop, MaxHops: maxHops, NumMeasurements: queries, MaxAttempts: req.MaxAttempts, ParallelRequests: parallel, Timeout: time.Duration(timeout) * time.Millisecond, DstIP: ip, DstPort: port, IPGeoSource: ipgeo.GetSourceWithGeoDNS(dataProvider, req.DotServer), RDNS: !req.DisableRDNS, AlwaysWaitRDNS: alwaysWait, PacketInterval: req.PacketInterval, TTLInterval: req.TTLInterval, Lang: lang, DN42: req.DN42, PktSize: packetSizeSpec.PayloadSize, RandomPacketSize: packetSizeSpec.Random, TOS: tos, Maptrace: !req.DisableMaptrace, DisableMPLS: req.DisableMPLS, }, nil } func withTraceSetupContext[T any](setup *traceExecution, callback func() (T, error)) (T, error) { if callback == nil { var zero T return zero, nil } prevPowProvider := util.PowProviderParam util.PowProviderParam = "" if setup != nil { util.PowProviderParam = setup.PowProvider if setup.NeedsLeoWS { if setup.PowProvider != "" { log.Printf("[deploy] LeoMoeAPI using custom PoW provider=%s", sanitizeLogParam(setup.PowProvider)) } else { log.Printf("[deploy] LeoMoeAPI using default PoW provider") } } else if setup.PowProvider != "" { log.Printf("[deploy] overriding PoW provider=%s", sanitizeLogParam(setup.PowProvider)) } } defer func() { util.PowProviderParam = prevPowProvider }() return withTraceGeoDNSScope(setup, callback) } func withTraceGeoDNSScope[T any](setup *traceExecution, callback func() (T, error)) (T, error) { if callback == nil { var zero T return zero, nil } dotServer := "" if setup != nil { dotServer = strings.TrimSpace(strings.ToLower(setup.Req.DotServer)) } return util.WithGeoDNSResolver(dotServer, callback) } func traceMapURLForResult(setup *traceExecution, res *trace.Result) string { if setup == nil || res == nil || !setup.Config.Maptrace || !shouldGenerateMap(setup.DataProvider) { return "" } payload, err := json.Marshal(res) if err != nil { return "" } url, err := withTraceMapScopeFn(setup, func() (string, error) { ctx := setup.Config.Context if ctx == nil { ctx = context.Background() } return traceMapURLFn(ctx, string(payload)) }) if err != nil { return "" } return url } func convertHops(res *trace.Result, lang string) []hopResponse { if res == nil || len(res.Hops) == 0 { return nil } hops := make([]hopResponse, 0, len(res.Hops)) for idx, attempts := range res.Hops { resp := buildHopResponse(attempts, idx, lang) if len(resp.Attempts) == 0 { continue } hops = append(hops, resp) } return hops } func buildHopResponse(attempts []trace.Hop, idx int, lang string) hopResponse { resp := hopResponse{ TTL: idx + 1, Attempts: make([]hopAttempt, 0, len(attempts)), } for _, attempt := range attempts { ha := hopAttempt{ Success: attempt.Success, MPLS: attempt.MPLS, } if attempt.Address != nil { ha.IP = attempt.Address.String() } if attempt.Hostname != "" { ha.Hostname = attempt.Hostname } if attempt.RTT > 0 { ha.RTT = float64(attempt.RTT) / float64(time.Millisecond) } if attempt.Error != nil { ha.Error = attempt.Error.Error() } if attempt.Geo != nil { ha.Geo = localizeGeo(attempt.Geo, lang) } resp.Attempts = append(resp.Attempts, ha) } return resp } func parseTargetURLHost(target string) (string, string, error) { fallbackSource := target if !strings.Contains(target, "://") { return "", fallbackSource, nil } u, err := url.Parse(target) if err != nil { return "", "", fmt.Errorf("invalid target format: %w", err) } if u.Host != "" { return u.Host, fallbackSource, nil } if u.Path != "" { fallbackSource = strings.TrimPrefix(target, u.Scheme+"://") } return "", fallbackSource, nil } func extractTargetHost(target, fallbackSource string) (string, error) { parseTarget := target if strings.Contains(target, "/") { if !strings.HasPrefix(parseTarget, "//") { parseTarget = "//" + parseTarget } if u, err := url.Parse(parseTarget); err == nil && u.Host != "" { return u.Host, nil } } if !strings.Contains(fallbackSource, "/") { return "", nil } idx := strings.Index(fallbackSource, "/") if idx <= 0 { return "", errors.New("invalid target format") } candidate := strings.TrimSpace(fallbackSource[:idx]) if candidate == "" { return "", errors.New("invalid target format") } return candidate, nil } func stripTargetPort(target string) string { // Try standard SplitHostPort first — handles host:port and [IPv6]:port. if host, _, err := net.SplitHostPort(target); err == nil { return host } // Bare [IPv6] without port. if open := strings.Index(target, "["); open >= 0 { close := strings.Index(target[open:], "]") if close > 1 { return target[open+1 : open+close] } } // host:port with exactly one colon (plain IPv4 / hostname). if strings.Count(target, ":") == 1 { return target[:strings.Index(target, ":")] } return target } func normalizeTarget(input string) (string, error) { target := strings.TrimSpace(input) if target == "" { return "", errors.New("target is required") } host, fallbackSource, err := parseTargetURLHost(target) if err != nil { return "", err } if host == "" { host, err = extractTargetHost(target, fallbackSource) if err != nil { return "", err } } if host != "" { target = host } return strings.TrimSpace(stripTargetPort(target)), nil } func normalizeDataProvider(provider string, alias string) string { candidate := strings.TrimSpace(provider) if candidate == "" { candidate = strings.TrimSpace(alias) } if candidate == "" { return "" } upper := strings.ToUpper(candidate) switch upper { case "IP.SB": return "IP.SB" case "IP-API.COM", "IPAPI.COM": return "IPAPI.com" case "IPINFO", "IP INFO": return "IPInfo" case "IPINSIGHT", "IP INSIGHT": return "IPInsight" case "IPINFOLOCAL", "IP INFO LOCAL": return "IPInfoLocal" case "LEOMOEAPI", "LEOMOE": return "LeoMoeAPI" case "CHUNZHEN": return "chunzhen" case "DN42": return "DN42" case "DISABLE-GEOIP", "DISABLE_GEOIP": return "disable-geoip" case "IPDB.ONE": return "ipdb.one" default: return candidate } } func contains(list []string, v string) bool { for _, item := range list { if strings.EqualFold(item, v) { return true } } return false } func shouldGenerateMap(provider string) bool { allowed := []string{"LEOMOEAPI", "IPINFO", "IP-API.COM", "IPAPI.COM"} for _, item := range allowed { if strings.EqualFold(provider, item) { return true } } return false } func validateSourceDevice(device string) error { device = strings.TrimSpace(device) if device == "" { return nil } ifaces, err := net.Interfaces() if err != nil { return fmt.Errorf("list network interfaces: %w", err) } for _, iface := range ifaces { if iface.Name == device { return nil } } return fmt.Errorf("unknown source_device %q", device) } func ensureLeoMoeConnection() { leoConnMu.Lock() defer leoConnMu.Unlock() conn := wshandle.GetWsConn() if conn == nil || conn.MsgSendCh == nil || conn.MsgReceiveCh == nil { log.Println("[deploy] establishing initial LeoMoeAPI websocket") wshandle.New() return } if !conn.IsConnected() && !conn.IsConnecting() { log.Println("[deploy] reconnecting LeoMoeAPI websocket") wshandle.New() } } func localizeGeo(src *ipgeo.IPGeoData, lang string) *ipgeo.IPGeoData { if src == nil { return nil } dst := *src switch strings.ToLower(lang) { case "en": if dst.CountryEn != "" { dst.Country = dst.CountryEn } if dst.ProvEn != "" { dst.Prov = dst.ProvEn } if dst.CityEn != "" { dst.City = dst.CityEn } default: if dst.Country == "" && dst.CountryEn != "" { dst.Country = dst.CountryEn } if dst.Prov == "" && dst.ProvEn != "" { dst.Prov = dst.ProvEn } if dst.City == "" && dst.CityEn != "" { dst.City = dst.CityEn } } return &dst } ================================================ FILE: server/trace_handler_test.go ================================================ package server import ( "context" "errors" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/nxtrace/NTrace-core/trace" "github.com/nxtrace/NTrace-core/util" ) func TestPrepareTrace_DoesNotForceLegacyInterval(t *testing.T) { setup, statusCode, err := prepareTrace(context.Background(), traceRequest{ Target: "1.1.1.1", Mode: "mtr", DataProvider: "disable-geoip", }) if err != nil { t.Fatalf("prepareTrace returned error: %v (status=%d)", err, statusCode) } if setup.Req.IntervalMs != 0 { t.Fatalf("prepareTrace IntervalMs = %d, want 0", setup.Req.IntervalMs) } } func TestResolveWebMTRHopInterval_DefaultsToOneSecond(t *testing.T) { got := resolveWebMTRHopInterval(traceRequest{}) if got != time.Second { t.Fatalf("resolveWebMTRHopInterval() = %v, want %v", got, time.Second) } } func TestResolveWebMTRHopInterval_PrefersHopIntervalMs(t *testing.T) { got := resolveWebMTRHopInterval(traceRequest{IntervalMs: 2500, HopIntervalMs: 750}) if got != 750*time.Millisecond { t.Fatalf("resolveWebMTRHopInterval() = %v, want %v", got, 750*time.Millisecond) } } func TestBuildTraceConfig_PropagatesSessionScopedFields(t *testing.T) { packetSize := 52 tos := 0 cfg, err := buildTraceConfig(traceRequest{ SourceDevice: "en7", DisableMPLS: true, DotServer: "cloudflare", PacketSize: &packetSize, TOS: &tos, }, trace.ICMPTrace, net.ParseIP("1.1.1.1"), "IPInfo", 80) if err != nil { t.Fatalf("buildTraceConfig returned error: %v", err) } if cfg.SourceDevice != "en7" { t.Fatalf("buildTraceConfig SourceDevice = %q, want en7", cfg.SourceDevice) } if !cfg.DisableMPLS { t.Fatal("buildTraceConfig DisableMPLS = false, want true") } if cfg.IPGeoSource == nil { t.Fatal("buildTraceConfig IPGeoSource = nil, want wrapped source") } if cfg.TOS != 0 { t.Fatalf("buildTraceConfig TOS = %d, want 0", cfg.TOS) } } func TestBuildTraceConfig_PreservesNegativePacketSizeAndTOS(t *testing.T) { packetSize := -123 tos := 255 cfg, err := buildTraceConfig(traceRequest{ PacketSize: &packetSize, TOS: &tos, }, trace.ICMPTrace, net.ParseIP("1.1.1.1"), "disable-geoip", 80) if err != nil { t.Fatalf("buildTraceConfig returned error: %v", err) } if !cfg.RandomPacketSize { t.Fatal("buildTraceConfig RandomPacketSize = false, want true") } if cfg.TOS != 255 { t.Fatalf("buildTraceConfig TOS = %d, want 255", cfg.TOS) } } func TestBuildTraceConfig_DefaultsPacketSizeByProtocolAndFamily(t *testing.T) { cfg, err := buildTraceConfig(traceRequest{}, trace.TCPTrace, net.ParseIP("2a00:1450:4009:81a::200e"), "disable-geoip", 80) if err != nil { t.Fatalf("buildTraceConfig returned error: %v", err) } if cfg.PktSize != 0 { t.Fatalf("buildTraceConfig PktSize = %d, want 0 payload bytes for default TCP/IPv6 minimum", cfg.PktSize) } if cfg.RandomPacketSize { t.Fatal("buildTraceConfig RandomPacketSize = true, want false") } } func TestNormalizeTraceRequest_RejectsInvalidTOS(t *testing.T) { tos := 256 statusCode, err := normalizeTraceRequest(&traceRequest{TOS: &tos}) if err == nil { t.Fatal("normalizeTraceRequest should reject invalid tos") } if statusCode != http.StatusBadRequest { t.Fatalf("statusCode = %d, want %d", statusCode, http.StatusBadRequest) } } func TestPrepareTrace_RejectsUnknownSourceDevice(t *testing.T) { _, statusCode, err := prepareTrace(context.Background(), traceRequest{ Target: "1.1.1.1", DataProvider: "disable-geoip", SourceDevice: "codex-nonexistent-dev0", }) if err == nil { t.Fatal("prepareTrace should reject unknown source_device") } if statusCode != http.StatusBadRequest { t.Fatalf("statusCode = %d, want %d", statusCode, http.StatusBadRequest) } } func TestNormalizeTarget(t *testing.T) { tests := []struct { name string input string want string hasErr bool }{ {name: "empty", input: " ", hasErr: true}, {name: "url host", input: "https://example.com/path", want: "example.com"}, {name: "host with port", input: "example.com:8443", want: "example.com"}, {name: "ipv6 with brackets", input: "[2001:db8::1]:443", want: "2001:db8::1"}, {name: "bare ipv6 brackets", input: "[::1]", want: "::1"}, {name: "malformed reversed brackets", input: "foo]bar[", want: "foo]bar["}, {name: "malformed open only", input: "[abc", want: "[abc"}, {name: "malformed close only", input: "abc]", want: "abc]"}, {name: "slash target", input: "example.com/path", want: "example.com"}, {name: "invalid slash target", input: "/only-path", hasErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := normalizeTarget(tc.input) if tc.hasErr { if err == nil { t.Fatalf("normalizeTarget(%q) error = nil, want error", tc.input) } return } if err != nil { t.Fatalf("normalizeTarget(%q) returned error: %v", tc.input, err) } if got != tc.want { t.Fatalf("normalizeTarget(%q) = %q, want %q", tc.input, got, tc.want) } }) } } func TestTraceHandler_RejectsOversizedJSONBody(t *testing.T) { gin.SetMode(gin.TestMode) body := `{"target":"` + strings.Repeat("a", maxTraceRequestBodyBytes) + `"}` req := httptest.NewRequest(http.MethodPost, "/api/trace", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req traceHandler(c) if w.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge) } } func TestExecuteMTRRaw_PerHopDoesNotMutateSessionGlobals(t *testing.T) { oldRunMTRRaw := traceRunMTRRawFn defer func() { traceRunMTRRawFn = oldRunMTRRaw }() oldSrcDev := util.SrcDev oldDisableMPLS := util.DisableMPLS oldPowProvider := util.PowProviderParam defer func() { util.SrcDev = oldSrcDev util.DisableMPLS = oldDisableMPLS util.PowProviderParam = oldPowProvider }() util.SrcDev = "keep-dev" util.DisableMPLS = false util.PowProviderParam = "keep-pow" traceRunMTRRawFn = func(_ context.Context, _ trace.Method, cfg trace.Config, opts trace.MTRRawOptions, _ trace.MTRRawOnRecord) error { if cfg.SourceDevice != "en7" { t.Fatalf("cfg.SourceDevice = %q, want en7", cfg.SourceDevice) } if !cfg.DisableMPLS { t.Fatal("cfg.DisableMPLS = false, want true") } if opts.HopInterval != time.Second { t.Fatalf("opts.HopInterval = %v, want %v", opts.HopInterval, time.Second) } return nil } err := executeMTRRaw(context.Background(), &wsTraceSession{}, &traceExecution{ Req: traceRequest{ SourceDevice: "en7", DisableMPLS: true, HopIntervalMs: 1000, DotServer: "cloudflare", }, Target: "1.1.1.1", Method: trace.ICMPTrace, IP: net.ParseIP("1.1.1.1"), Config: trace.Config{ DstIP: net.ParseIP("1.1.1.1"), SourceDevice: "en7", DisableMPLS: true, IPGeoSource: nil, Timeout: time.Second, MaxHops: 30, ParallelRequests: 1, }, }, trace.MTRRawOptions{ HopInterval: time.Second, }, nil) if err != nil { t.Fatalf("executeMTRRaw returned error: %v", err) } if util.SrcDev != "keep-dev" { t.Fatalf("util.SrcDev = %q, want keep-dev", util.SrcDev) } if util.DisableMPLS { t.Fatal("util.DisableMPLS = true, want false") } if util.PowProviderParam != "keep-pow" { t.Fatalf("util.PowProviderParam = %q, want keep-pow", util.PowProviderParam) } } func TestTraceMapURLForResult_UsesRequestScopedMapHelper(t *testing.T) { oldMapFn := traceMapURLFn oldScopeFn := withTraceMapScopeFn defer func() { traceMapURLFn = oldMapFn withTraceMapScopeFn = oldScopeFn }() scopeCalled := false traceMapCalled := false withTraceMapScopeFn = func(setup *traceExecution, callback func() (string, error)) (string, error) { scopeCalled = true if setup == nil { t.Fatal("setup should not be nil") } if strings.TrimSpace(setup.Req.DotServer) != "cloudflare" { t.Fatalf("DotServer = %q, want cloudflare", setup.Req.DotServer) } return callback() } traceMapURLFn = func(ctx context.Context, payload string) (string, error) { traceMapCalled = true if ctx == nil { t.Fatal("context should not be nil") } if payload == "" { t.Fatal("payload should not be empty") } return "https://map.example.test", nil } got := traceMapURLForResult(&traceExecution{ Req: traceRequest{DotServer: "cloudflare"}, DataProvider: "IPInfo", Config: trace.Config{Maptrace: true}, }, &trace.Result{ Hops: [][]trace.Hop{{{TTL: 1}}}, }) if got != "https://map.example.test" { t.Fatalf("traceMapURLForResult() = %q, want https://map.example.test", got) } if !scopeCalled { t.Fatal("expected request-scoped map helper to be used") } if !traceMapCalled { t.Fatal("expected traceMapURLFn to be called") } } func TestPrepareTraceHonorsCanceledContext(t *testing.T) { oldLookup := traceDomainLookupFn traceDomainLookupFn = func(ctx context.Context, target, ipVersion, dotServer string, disableOutput bool) (net.IP, error) { <-ctx.Done() return nil, ctx.Err() } defer func() { traceDomainLookupFn = oldLookup }() ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() _, _, err := prepareTrace(ctx, traceRequest{ Target: "example.com", DataProvider: "disable-geoip", }) if !errors.Is(err, context.Canceled) { t.Fatalf("prepareTrace error = %v, want context.Canceled", err) } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("prepareTrace returned too slowly after cancel: %v", elapsed) } } ================================================ FILE: server/web/assets/app.js ================================================ const form = document.getElementById('trace-form'); const protocolSelect = document.getElementById('protocol'); const providerSelect = document.getElementById('data-provider'); const queriesInput = document.getElementById('queries'); const maxHopsInput = document.getElementById('max-hops'); const disableMaptraceInput = document.getElementById('disable-maptrace'); const dstPortHint = document.getElementById('dst-port-hint'); const dstPortInput = document.getElementById('dst-port'); const payloadSizeInput = document.getElementById('payload-size'); const tosInput = document.getElementById('tos'); const modeSelect = document.getElementById('mode'); const statusNode = document.getElementById('status'); const resultNode = document.getElementById('result'); const resultMetaNode = document.getElementById('result-meta'); const submitBtn = document.getElementById('submit-btn'); const stopBtn = document.getElementById('stop-btn'); const langToggleBtn = document.getElementById('lang-toggle'); const cacheBtn = document.getElementById('cache-btn'); const titleText = document.getElementById('title-text'); const subtitleText = document.getElementById('subtitle-text'); const footerText = document.getElementById('footer-text'); const labelTarget = document.getElementById('label-target'); const labelProtocol = document.getElementById('label-protocol'); const labelProvider = document.getElementById('label-provider'); const labelQueries = document.getElementById('label-queries'); const labelMaxHops = document.getElementById('label-maxhops'); const labelDisableMap = document.getElementById('label-disable-map'); const labelDstPort = document.getElementById('label-dst-port'); const labelPSize = document.getElementById('label-psize'); const labelTOS = document.getElementById('label-tos'); const labelMode = document.getElementById('label-mode'); const targetInput = document.getElementById('target'); const groupBasicParams = document.getElementById('group-basic-params'); const groupAdvancedParams = document.getElementById('group-advanced-params'); const groupDisableMap = document.getElementById('group-disable-map'); const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = `${wsScheme}://${window.location.host}/ws/trace`; let socket = null; let traceCompleted = false; const hopStore = new Map(); let latestSummary = {}; let currentLang = 'cn'; let currentMode = 'single'; let currentStatus = {state: 'idle', key: 'statusReady', custom: null}; let mtrStatsStore = []; let mtrRawAggStore = new Map(); let mtrRawOrderSeq = 0; let singleModeQueriesValue = ''; const MTR_RENDER_MIN_INTERVAL_MS = 100; let mtrRenderScheduled = false; let mtrRenderTimer = null; let mtrRenderRAF = null; let mtrRenderLastAt = 0; let mtrRawKnownFinalTTL = Infinity; const traceFormHelpers = globalThis.NextTraceForm || {}; const uiText = { cn: { title: 'NextTrace Web', subtitle: '在浏览器中运行 NextTrace,实时查看路由探测结果。', labelTarget: '目标地址', placeholderTarget: '例如:1.1.1.1 或 www.example.com', labelProtocol: '协议', labelProvider: '地理信息源', labelQueries: '每跳探测次数', labelMaxHops: '最大跳数', labelDisableMap: '禁用地图生成', labelDstPort: '目的端口', labelPSize: '探测包大小', labelTOS: 'TOS', labelMode: '探测模式', buttonStartSingle: '开始探测', buttonStartMtr: '开始持续探测', buttonStop: '停止', buttonClearCache: '清空缓存', langToggle: 'English', tableTTL: 'TTL', tableDetails: '探测详情', colLoss: '丢包率', colSent: '发送/接收', colLast: '最新', colAvg: '平均', colBest: '最佳', colWorst: '最差', colHost: '主机', colIP: '地址', colFailure: '失败原因', statusReady: '准备就绪', statusRunning: '正在探测,请稍候...', statusMtrRunning: '持续探测中…', statusSuccess: '探测完成', statusCacheClearing: '正在清理缓存…', statusCacheCleared: '缓存已清空', statusCacheFailed: '清理缓存失败', statusWsError: 'WebSocket 连接出错', statusDisconnected: '连接已断开', statusOptionsFailed: '无法加载选项:', statusTargetMissing: '请填写目标地址', statusTraceFailed: '探测失败', metaResolved: '解析结果', metaProvider: '数据源', metaDuration: '耗时', metaIterations: '持续轮次', metaMap: '地图', mapOpen: '打开地图', attemptLabelHost: '主机', attemptLabelAddress: '地址', attemptLabelLatency: '延迟', attemptLabelError: '错误', attemptLabelMPLS: 'MPLS', attemptLabelLoss: '丢包率', attemptLabelFailure: '失败', timeoutAll: '全部超时', timeoutPartial: '部分超时', unknownAddress: '未知地址', unknownError: '未知错误', hintDstPort: '仅 TCP/UDP 模式有效', attemptBadge: '探测', noResult: '未获取到有效路由信息。', footer: '当前会话仅提供基础功能,更多高级选项请使用 CLI。', modeSingle: '单次探测', modeMTR: '持续探测', }, en: { title: 'NextTrace Web', subtitle: 'Run NextTrace in your browser and watch the trace in real time.', labelTarget: 'Target', placeholderTarget: 'e.g. 1.1.1.1 or www.example.com', labelProtocol: 'Protocol', labelProvider: 'Geo provider', labelQueries: 'Probes per hop', labelMaxHops: 'Max hops', labelDisableMap: 'Disable map generation', labelDstPort: 'Destination Port', labelPSize: 'Probe Packet Size', labelTOS: 'TOS', labelMode: 'Mode', buttonStartSingle: 'Start Trace', buttonStartMtr: 'Start Continuous Trace', buttonStop: 'Stop', buttonClearCache: 'Clear Cache', langToggle: '中文', tableTTL: 'TTL', tableDetails: 'Details', colLoss: 'Loss', colSent: 'Sent/Recv', colLast: 'Last', colAvg: 'Avg', colBest: 'Best', colWorst: 'Worst', colHost: 'Host', colIP: 'IP', colFailure: 'Failure', statusReady: 'Ready', statusRunning: 'Tracing…', statusMtrRunning: 'Tracing continuously…', statusSuccess: 'Trace completed', statusCacheClearing: 'Clearing cache…', statusCacheCleared: 'Cache cleared', statusCacheFailed: 'Failed to clear cache', statusWsError: 'WebSocket error', statusDisconnected: 'Connection closed', statusOptionsFailed: 'Failed to load options:', statusTargetMissing: 'Please enter a target', statusTraceFailed: 'Trace failed', metaResolved: 'Resolved IP', metaProvider: 'Provider', metaDuration: 'Duration', metaIterations: 'Iterations', metaMap: 'Map', mapOpen: 'Open map', attemptLabelHost: 'Host', attemptLabelAddress: 'IP', attemptLabelLatency: 'Latency', attemptLabelError: 'Error', attemptLabelMPLS: 'MPLS', attemptLabelLoss: 'Loss', attemptLabelFailure: 'Failure', timeoutAll: 'All timeout', timeoutPartial: 'Partial timeout', unknownAddress: 'Unknown', unknownError: 'Unknown error', hintDstPort: 'Active for TCP/UDP only', attemptBadge: 'Probe', noResult: 'No valid hops collected yet.', footer: 'For advanced options, please use the CLI.', modeSingle: 'Single Trace', modeMTR: 'Continuous Trace', }, }; function t(key) { return uiText[currentLang][key] || key || ''; } function updateStatusDisplay(state, text) { statusNode.className = `status status--${state}`; statusNode.textContent = text; } function setStatus(state, message, translate = true) { if (translate) { currentStatus = {state, key: message, custom: null}; updateStatusDisplay(state, t(message)); } else { currentStatus = {state, key: null, custom: message}; updateStatusDisplay(state, message); } } function refreshStatus() { if (currentStatus.key) { updateStatusDisplay(currentStatus.state, t(currentStatus.key)); } else if (currentStatus.custom !== null) { updateStatusDisplay(currentStatus.state, currentStatus.custom); } } async function loadOptions() { try { const res = await fetch('/api/options'); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const data = await res.json(); fillSelect(protocolSelect, data.protocols, data.defaultOptions.protocol); fillSelect(providerSelect, data.dataProviders, data.defaultOptions.data_provider); queriesInput.value = Math.min(63, data.defaultOptions.queries || 3); singleModeQueriesValue = queriesInput.value; queriesInput.dataset.defaultValue = queriesInput.value; maxHopsInput.value = data.defaultOptions.max_hops; disableMaptraceInput.checked = data.defaultOptions.disable_maptrace; const defaultOptionValue = traceFormHelpers.defaultOptionValue || ((opts, key, fallback) => (opts && Object.prototype.hasOwnProperty.call(opts, key) ? opts[key] : fallback)); payloadSizeInput.value = defaultOptionValue(data.defaultOptions, 'packet_size', payloadSizeInput.value || '') ?? ''; tosInput.value = defaultOptionValue(data.defaultOptions, 'tos', tosInput.value || 0); dstPortInput.value = data.defaultOptions.port || dstPortInput.value || ''; updateDstPortState(); updateModeUI(); } catch (err) { setStatus('error', `${t('statusOptionsFailed')} ${err.message}`, false); submitBtn.disabled = true; } } function fillSelect(selectEl, values, defaultValue) { selectEl.innerHTML = ''; values.forEach((val) => { const option = document.createElement('option'); option.value = val; option.textContent = val; if (String(val).toLowerCase() === String(defaultValue).toLowerCase()) { option.selected = true; } selectEl.appendChild(option); }); } function readNumericValue(inputEl) { const raw = inputEl.value.trim(); if (raw === '') { return undefined; } const num = Number(raw); return Number.isFinite(num) ? num : undefined; } function clearResult(resetState = false) { cancelScheduledMTRRender(); resultNode.innerHTML = ''; resultNode.classList.add('hidden'); resultMetaNode.innerHTML = ''; resultMetaNode.classList.add('hidden'); if (resetState) { hopStore.clear(); latestSummary = {}; mtrStatsStore = []; mtrRawAggStore = new Map(); mtrRawOrderSeq = 0; mtrRenderLastAt = 0; mtrRawKnownFinalTTL = Infinity; stopBtn.classList.add('hidden'); stopBtn.disabled = true; } } function renderMeta(summary = {}) { const rows = []; if (summary.resolved_ip) { rows.push(`${t('metaResolved')}:${escapeHTML(summary.resolved_ip)}`); } if (summary.data_provider) { rows.push(`${t('metaProvider')}:${escapeHTML(summary.data_provider)}`); } if (summary.duration_ms !== undefined) { rows.push(`${t('metaDuration')}:${escapeHTML(summary.duration_ms)} ms`); } if (summary.iteration) { rows.push(`${t('metaIterations')}:${escapeHTML(summary.iteration)}`); } if (summary.trace_map_url) { // t('mapOpen') is assumed not user-supplied; escape only the URL rows.push(`${t('metaMap')}:${t('mapOpen')}`); } if (rows.length === 0) { resultMetaNode.classList.add('hidden'); resultMetaNode.innerHTML = ''; return; } resultMetaNode.innerHTML = rows.map((line) => `
${line}
`).join(''); resultMetaNode.classList.remove('hidden'); } function renderAttemptsGrouped(attempts) { const UNKNOWN_KEY = '__unknown__'; const groups = new Map(); const ipIndex = new Map(); const hostIndex = new Map(); let pendingUnknown = []; let lastGroup = null; const createGroup = (key) => { const group = { key, attempts: [], hosts: new Set(), ips: new Set(), firstHost: '', firstIP: '', }; groups.set(key, group); return group; }; attempts.forEach((attempt) => { const hostRaw = (attempt.hostname || '').trim(); const hostKey = hostRaw.toLowerCase(); const ip = (attempt.ip || '').trim(); if (!hostRaw && !ip) { if (lastGroup) { lastGroup.attempts.push(attempt); } else { pendingUnknown.push(attempt); } return; } let group = null; if (ip && ipIndex.has(ip)) { group = groups.get(ipIndex.get(ip)); } if (!group && hostRaw) { if (hostIndex.has(hostKey)) { group = groups.get(hostIndex.get(hostKey)); } } if (!group) { const key = ip ? `ip:${ip}` : `host:${hostKey}`; group = createGroup(key); } if (pendingUnknown.length > 0) { group.attempts.push(...pendingUnknown); pendingUnknown = []; } group.attempts.push(attempt); if (ip) { group.ips.add(ip); if (!group.firstIP) { group.firstIP = ip; } ipIndex.set(ip, group.key); } if (hostRaw) { group.hosts.add(hostRaw); if (!group.firstHost) { group.firstHost = hostRaw; } if (hostKey) { hostIndex.set(hostKey, group.key); } } lastGroup = group; }); if (pendingUnknown.length > 0) { const group = createGroup(UNKNOWN_KEY); group.attempts.push(...pendingUnknown); } const orderedGroups = Array.from(groups.values()).filter((group) => group.attempts.length > 0); const container = document.createElement('div'); container.className = 'attempts attempts--grouped'; let hasIdentifiedSummary = false; const summarySet = new Set(); const summaryLabels = []; orderedGroups.forEach((group) => { const displayIp = group.firstIP || ''; let displayHost = group.firstHost || ''; if (displayHost && displayIp && displayHost === displayIp) { displayHost = ''; } let label = ''; if (displayIp && displayHost && displayHost !== displayIp) { label = `${displayIp} (${displayHost})`; } else if (displayIp) { label = displayIp; } else if (displayHost) { label = displayHost; } else { label = '*'; } if (!summarySet.has(label)) { summarySet.add(label); summaryLabels.push(label); } if (displayIp || displayHost) { hasIdentifiedSummary = true; } }); if (summaryLabels.length > 1) { const summary = document.createElement('div'); summary.className = 'attempts__summary'; summary.textContent = summaryLabels.join(' | '); container.appendChild(summary); } orderedGroups.forEach((group) => { const box = document.createElement('div'); box.className = 'attempt attempt--group'; const header = document.createElement('div'); header.className = 'attempt__header'; const mainLine = []; const first = group.attempts[0] || {}; let displayHost = group.firstHost || ''; const displayIp = group.firstIP || ''; if (displayHost && displayIp && displayHost === displayIp) { displayHost = ''; } if (displayHost) { mainLine.push(createMetaItem(t('attemptLabelHost'), displayHost)); } if (displayIp) { mainLine.push(createMetaItem(t('attemptLabelAddress'), displayIp)); } if (mainLine.length === 0) { if (hasIdentifiedSummary) { const label = document.createElement('span'); label.className = 'attempt__star'; label.textContent = '*'; header.appendChild(label); } else { const star = document.createElement('span'); star.className = 'attempt__star'; star.textContent = '*'; header.appendChild(star); } } else { mainLine.forEach((el) => header.appendChild(el)); } box.appendChild(header); const metrics = document.createElement('div'); metrics.className = 'attempt__meta'; const rtts = group.attempts .filter((item) => item.rtt_ms !== undefined && item.rtt_ms !== null) .map((item) => Number(item.rtt_ms)); if (rtts.length > 0) { const min = Math.min(...rtts).toFixed(2); const max = Math.max(...rtts).toFixed(2); const avg = (rtts.reduce((sum, v) => sum + v, 0) / rtts.length).toFixed(2); metrics.appendChild(createMetaItem(t('attemptLabelLatency'), avg + ' ms (min ' + min + ', max ' + max + ')')); } const successes = group.attempts.filter((item) => item.success).length; const lossCount = group.attempts.length - successes; const lossRate = group.attempts.length > 0 ? (((lossCount) / group.attempts.length) * 100).toFixed(0) : '0'; metrics.appendChild(createMetaItem(t('attemptLabelLoss'), lossRate + '% (' + lossCount + '/' + group.attempts.length + ')')); const mplsAll = group.attempts.flatMap((item) => item.mpls || []); if (mplsAll.length > 0) { const unique = Array.from(new Set(mplsAll.map((entry) => String(entry || '').trim()).filter(Boolean))); if (unique.length > 0) { const mplsContainer = document.createElement('div'); mplsContainer.className = 'attempt__mpls'; unique.forEach((entry) => { const line = document.createElement('div'); line.textContent = entry; mplsContainer.appendChild(line); }); metrics.appendChild(mplsContainer); } } box.appendChild(metrics); const geoLine = document.createElement('div'); geoLine.className = 'attempt__geo'; const segments = []; if (first.geo) { if (first.geo.asnumber) { segments.push('AS' + first.geo.asnumber); } if (first.geo.country) { segments.push(first.geo.country); } if (first.geo.prov) { segments.push(first.geo.prov); } if (first.geo.city) { segments.push(first.geo.city); } if (first.geo.owner || first.geo.isp) { segments.push(first.geo.owner || first.geo.isp); } } if (segments.length > 0) { geoLine.textContent = segments.join(' · '); box.appendChild(geoLine); } const probes = document.createElement('div'); probes.className = 'attempt__probes'; group.attempts.forEach((item, index) => { const badge = document.createElement('span'); badge.className = 'attempt__badge'; badge.textContent = t('attemptBadge') + ' ' + (index + 1); if (!item.success) { badge.classList.add('attempt__badge--fail'); } probes.appendChild(badge); }); box.appendChild(probes); container.appendChild(box); }); return container; } function renderHops(hops) { if (!hops || hops.length === 0) { resultNode.innerHTML = `

${t('noResult')}

`; resultNode.classList.remove('hidden'); return; } const table = document.createElement('table'); const thead = document.createElement('thead'); thead.innerHTML = ` ${t('tableTTL')} ${t('tableDetails')} `; table.appendChild(thead); const tbody = document.createElement('tbody'); hops.forEach((hop) => { const tr = document.createElement('tr'); const ttlCell = document.createElement('td'); ttlCell.textContent = hop.ttl; tr.appendChild(ttlCell); const attemptsCell = document.createElement('td'); attemptsCell.appendChild(renderAttemptsGrouped(hop.attempts)); tr.appendChild(attemptsCell); tbody.appendChild(tr); }); table.appendChild(tbody); resultNode.innerHTML = ''; resultNode.appendChild(table); resultNode.classList.remove('hidden'); } function renderHopsFromStore() { const hops = Array.from(hopStore.values()).sort((a, b) => a.ttl - b.ttl); renderHops(hops); } function escapeHTML(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function createMetaItem(label, value, allowHTML = false) { const span = document.createElement('span'); const safeLabel = escapeHTML(label); const strValue = value === undefined || value === null ? '' : String(value); if (allowHTML) { span.innerHTML = `${safeLabel}: ${strValue}`; } else { span.innerHTML = `${safeLabel}: ${escapeHTML(strValue)}`; } return span; } function buildPayload() { const buildTracePayload = traceFormHelpers.buildTracePayload || ((values) => values); const payload = buildTracePayload({ target: form.target.value, protocol: protocolSelect.value, dataProvider: providerSelect.value, disableMaptrace: disableMaptraceInput.checked, language: currentLang, mode: modeSelect.value || 'single', queries: queriesInput.value, maxHops: maxHopsInput.value, dstPort: dstPortInput.value, packetSize: payloadSizeInput.value, tos: tosInput.value, }); if (payload.mode === 'mtr' && queriesInput.value !== '10') { queriesInput.value = '10'; } return payload; } function closeExistingSocket(hideStop = true) { cancelScheduledMTRRender(); if (socket) { socket.onclose = null; socket.onerror = null; try { socket.close(1000, 'client stop'); } catch (_) { // ignore } socket = null; } if (hideStop) { stopBtn.classList.add('hidden'); stopBtn.disabled = true; } } function flushMTRRender(force = false) { if (mtrRenderTimer !== null) { clearTimeout(mtrRenderTimer); mtrRenderTimer = null; } if (mtrRenderRAF !== null && typeof cancelAnimationFrame === 'function') { cancelAnimationFrame(mtrRenderRAF); mtrRenderRAF = null; } if (!force) { const now = Date.now(); const elapsed = now - mtrRenderLastAt; if (elapsed < MTR_RENDER_MIN_INTERVAL_MS) { const waitMs = MTR_RENDER_MIN_INTERVAL_MS - elapsed; mtrRenderScheduled = true; mtrRenderTimer = setTimeout(() => { mtrRenderTimer = null; flushMTRRender(); }, waitMs); return; } } mtrRenderScheduled = false; mtrRenderLastAt = Date.now(); renderMTRStats(buildMTRStatsFromRawAgg()); renderMeta(latestSummary); } function scheduleMTRRender() { if (mtrRenderScheduled) { return; } mtrRenderScheduled = true; const attemptRender = () => { mtrRenderRAF = null; const waitMs = Math.max(0, MTR_RENDER_MIN_INTERVAL_MS - (Date.now() - mtrRenderLastAt)); if (waitMs > 0) { mtrRenderTimer = setTimeout(() => { mtrRenderTimer = null; flushMTRRender(); }, waitMs); return; } flushMTRRender(); }; if (typeof requestAnimationFrame === 'function') { mtrRenderRAF = requestAnimationFrame(attemptRender); return; } mtrRenderTimer = setTimeout(() => { mtrRenderTimer = null; flushMTRRender(); }, 0); } function cancelScheduledMTRRender() { if (mtrRenderTimer !== null) { clearTimeout(mtrRenderTimer); mtrRenderTimer = null; } if (mtrRenderRAF !== null && typeof cancelAnimationFrame === 'function') { cancelAnimationFrame(mtrRenderRAF); mtrRenderRAF = null; } mtrRenderScheduled = false; } function handleSocketMessage(event) { let msg; try { msg = JSON.parse(event.data); } catch (err) { setStatus('error', err.message, false); return; } switch (msg.type) { case 'start': { latestSummary = {...latestSummary, ...msg.data}; renderMeta(latestSummary); break; } case 'hop': { if (currentMode !== 'mtr' && msg.data && typeof msg.data.ttl === 'number') { hopStore.set(msg.data.ttl, msg.data); renderHopsFromStore(); } break; } case 'mtr': { // Backward compatibility with old server snapshots. traceCompleted = false; cancelScheduledMTRRender(); if (msg.data && typeof msg.data.iteration === 'number') { latestSummary = {...latestSummary, iteration: msg.data.iteration}; } if (msg.data && Array.isArray(msg.data.stats)) { renderMTRStats(msg.data.stats); } else { renderMTRStats([]); } setStatus('running', 'statusMtrRunning'); stopBtn.disabled = false; renderMeta(latestSummary); break; } case 'mtr_raw': { traceCompleted = false; if (msg.data) { ingestMTRRawRecord(msg.data); const it = Number(msg.data.iteration); if (Number.isFinite(it) && it > 0) { latestSummary = {...latestSummary, iteration: it}; } } setStatus('running', 'statusMtrRunning'); stopBtn.disabled = false; scheduleMTRRender(); break; } case 'complete': { traceCompleted = true; submitBtn.disabled = false; if (currentMode === 'mtr') { if (msg.data && typeof msg.data.iteration === 'number') { latestSummary = {...latestSummary, iteration: msg.data.iteration}; } stopBtn.disabled = true; stopBtn.classList.add('hidden'); if (msg.data && Array.isArray(msg.data.stats)) { cancelScheduledMTRRender(); renderMTRStats(msg.data.stats); renderMeta(latestSummary); } else { flushMTRRender(true); } } else { if (msg.data && Array.isArray(msg.data.hops)) { hopStore.clear(); msg.data.hops.forEach((hop) => { if (hop && typeof hop.ttl === 'number') { hopStore.set(hop.ttl, hop); } }); } latestSummary = {...latestSummary, ...msg.data}; renderHopsFromStore(); renderMeta(latestSummary); } setStatus('success', 'statusSuccess'); closeExistingSocket(); break; } case 'error': { traceCompleted = true; submitBtn.disabled = false; stopBtn.disabled = true; const text = msg.error || t('statusTraceFailed'); setStatus('error', text, !msg.error); closeExistingSocket(); break; } default: break; } } function runTrace(evt) { evt.preventDefault(); cancelScheduledMTRRender(); clearResult(true); mtrRawAggStore = new Map(); mtrRawOrderSeq = 0; const payload = buildPayload(); if (!payload.target) { setStatus('error', 'statusTargetMissing'); return; } currentMode = payload.mode || 'single'; document.body.classList.toggle('mode-mtr', currentMode === 'mtr'); updateStartButtonText(); if (currentMode === 'mtr') { setStatus('running', 'statusMtrRunning'); stopBtn.classList.remove('hidden'); stopBtn.disabled = true; } else { setStatus('running', 'statusRunning'); stopBtn.classList.add('hidden'); stopBtn.disabled = true; } submitBtn.disabled = true; traceCompleted = false; closeExistingSocket(false); try { socket = new WebSocket(wsUrl); } catch (err) { setStatus('error', `${t('statusWsError')} ${err.message}`, false); submitBtn.disabled = false; updateModeUI(); return; } socket.onopen = () => { if (currentMode === 'mtr') { stopBtn.disabled = false; } socket.send(JSON.stringify(payload)); }; socket.onmessage = handleSocketMessage; socket.onerror = () => { cancelScheduledMTRRender(); if (!traceCompleted) { traceCompleted = true; setStatus('error', 'statusWsError'); submitBtn.disabled = false; stopBtn.disabled = true; } }; socket.onclose = () => { cancelScheduledMTRRender(); if (!traceCompleted) { setStatus('error', 'statusDisconnected'); submitBtn.disabled = false; } stopBtn.disabled = true; socket = null; }; } async function clearCache(silent = false) { if (!silent) { setStatus('running', 'statusCacheClearing'); } try { const res = await fetch('/api/cache/clear', {method: 'POST'}); if (!res.ok) { const errRes = await res.json().catch(() => ({})); const message = errRes.error || `${t('statusCacheFailed')} HTTP ${res.status}`; throw new Error(message); } if (!silent) { setStatus('success', 'statusCacheCleared'); } else { setStatus('idle', 'statusReady'); } } catch (err) { setStatus('error', err.message || t('statusCacheFailed'), false); } } function toggleLanguage() { currentLang = currentLang === 'cn' ? 'en' : 'cn'; applyTranslations(); clearCache(true); } function applyTranslations() { currentMode = modeSelect.value || 'single'; titleText.textContent = t('title'); subtitleText.textContent = t('subtitle'); footerText.textContent = t('footer'); labelTarget.textContent = t('labelTarget'); labelProtocol.textContent = t('labelProtocol'); labelProvider.textContent = t('labelProvider'); labelQueries.textContent = t('labelQueries'); labelMaxHops.textContent = t('labelMaxHops'); labelDisableMap.textContent = t('labelDisableMap'); labelDstPort.textContent = t('labelDstPort'); labelPSize.textContent = t('labelPSize'); labelTOS.textContent = t('labelTOS'); labelMode.textContent = t('labelMode'); dstPortHint.textContent = t('hintDstPort'); targetInput.placeholder = t('placeholderTarget'); updateStartButtonText(); cacheBtn.textContent = t('buttonClearCache'); langToggleBtn.textContent = t('langToggle'); stopBtn.textContent = t('buttonStop'); const options = modeSelect.options; if (options.length >= 2) { options[0].textContent = t('modeSingle'); options[1].textContent = t('modeMTR'); } const isMtr = currentMode === 'mtr'; document.body.classList.toggle('mode-mtr', isMtr); groupBasicParams.classList.toggle('hidden', isMtr); groupAdvancedParams.classList.toggle('hidden', isMtr); groupDisableMap.classList.toggle('hidden', isMtr); renderMeta(latestSummary); if (currentMode === 'mtr') { renderMTRStats(mtrStatsStore); } else { renderHopsFromStore(); } refreshStatus(); updateModeUI(); updateDstPortState(); } function updateDstPortState() { const proto = (protocolSelect.value || '').toLowerCase(); const enabled = proto === 'tcp' || proto === 'udp'; dstPortInput.disabled = !enabled; dstPortInput.parentElement.classList.toggle('disabled', !enabled); if (!enabled) { dstPortInput.value = ''; } else if (!dstPortInput.value) { dstPortInput.value = proto === 'tcp' ? '80' : '33494'; } } document.addEventListener('DOMContentLoaded', () => { applyTranslations(); updateModeUI(); setStatus('idle', 'statusReady'); loadOptions(); form.addEventListener('submit', runTrace); langToggleBtn.addEventListener('click', toggleLanguage); cacheBtn.addEventListener('click', () => clearCache(false)); providerSelect.addEventListener('change', () => clearCache(true)); protocolSelect.addEventListener('change', () => { updateDstPortState(); clearCache(true); }); payloadSizeInput.addEventListener('change', () => clearCache(true)); queriesInput.addEventListener('input', () => { if (!queriesInput.disabled) { singleModeQueriesValue = queriesInput.value; } }); modeSelect.addEventListener('change', updateModeUI); stopBtn.addEventListener('click', stopTrace); }); function updateStartButtonText() { if (currentMode === 'mtr') { submitBtn.textContent = t('buttonStartMtr'); } else { submitBtn.textContent = t('buttonStartSingle'); } } function updateModeUI() { currentMode = modeSelect.value || 'single'; const isMtr = currentMode === 'mtr'; document.body.classList.toggle('mode-mtr', isMtr); groupBasicParams.classList.toggle('hidden', isMtr); groupAdvancedParams.classList.toggle('hidden', isMtr); groupDisableMap.classList.toggle('hidden', isMtr); updateStartButtonText(); const queriesContainer = queriesInput.parentElement; if (isMtr) { if (!queriesInput.disabled) { const currentValue = queriesInput.value.trim(); if (currentValue) { singleModeQueriesValue = currentValue; } else if (!singleModeQueriesValue && queriesInput.dataset.defaultValue) { singleModeQueriesValue = queriesInput.dataset.defaultValue; } } queriesInput.value = '10'; queriesInput.disabled = true; if (queriesContainer) { queriesContainer.classList.add('disabled'); } stopBtn.classList.remove('hidden'); stopBtn.disabled = true; } else { queriesInput.disabled = false; if (queriesContainer) { queriesContainer.classList.remove('disabled'); } const restoreValue = singleModeQueriesValue || queriesInput.dataset.defaultValue || queriesInput.value || '3'; queriesInput.value = restoreValue; stopBtn.classList.add('hidden'); stopBtn.disabled = true; } } function stopTrace() { if (!socket) { stopBtn.disabled = true; stopBtn.classList.add('hidden'); return; } traceCompleted = true; stopBtn.disabled = true; closeExistingSocket(); submitBtn.disabled = false; setStatus('idle', 'statusReady'); } function mtrRawKey(rec) { const ttl = Number(rec && rec.ttl); return `${ttl}|ttl`; } function onlyTimeoutErrors(errors) { if (!errors) { return true; } const keys = Object.keys(errors); if (keys.length === 0) { return true; } return keys.every((k) => String(k).toLowerCase().includes('timeout')); } function recomputeMTRRawDerived(row) { row.loss_count = Math.max(0, row.sent - row.received); row.loss_percent = row.sent > 0 ? (row.loss_count / row.sent) * 100 : 0; row.avg_ms = row.received > 0 ? row._sum_ms / row.received : 0; if (row.loss_count <= 0) { row.failure_type = ''; } else if (onlyTimeoutErrors(row.errors)) { row.failure_type = row.received > 0 ? 'partial_timeout' : 'all_timeout'; } else { row.failure_type = 'mixed'; } } function ingestMTRRawRecord(rec) { if (!rec || !Number.isFinite(Number(rec.ttl))) { return; } const ttl = Number(rec.ttl); // --- knownFinalTTL truncation (mirrors server-side logic) --- // Drop probes beyond the known destination TTL. if (ttl > mtrRawKnownFinalTTL) { return; } // Detect destination: success + IP matches resolved target. const resolvedIP = latestSummary && latestSummary.resolved_ip ? String(latestSummary.resolved_ip).trim() : ''; const recIP = rec.ip ? String(rec.ip).trim() : ''; if (rec.success && recIP && resolvedIP && recIP === resolvedIP && ttl < mtrRawKnownFinalTTL) { mtrRawKnownFinalTTL = ttl; // Prune stale entries whose TTL exceeds the new boundary. for (const [k, v] of mtrRawAggStore) { if (v.ttl > mtrRawKnownFinalTTL) { mtrRawAggStore.delete(k); } } } // --- end truncation --- const key = mtrRawKey(rec); let row = mtrRawAggStore.get(key); if (!row) { row = { ttl: Number(rec.ttl), host: '', ip: '', sent: 0, received: 0, loss_percent: 0, loss_count: 0, last_ms: 0, avg_ms: 0, best_ms: 0, worst_ms: 0, geo: null, failure_type: '', errors: null, mpls: [], _sum_ms: 0, _order: mtrRawOrderSeq++, }; mtrRawAggStore.set(key, row); } row.sent += 1; const ip = rec.ip ? String(rec.ip).trim() : ''; const host = rec.host ? String(rec.host).trim() : ''; if (ip) { row.ip = ip; } if (host) { row.host = host; } const success = !!rec.success; const rtt = Number(rec.rtt_ms) || 0; if (success && (row.ip || row.host)) { row.received += 1; if (rtt > 0) { row.last_ms = rtt; row._sum_ms += rtt; if (row.best_ms <= 0 || rtt < row.best_ms) { row.best_ms = rtt; } if (rtt > row.worst_ms) { row.worst_ms = rtt; } } } else { if (!row.errors) { row.errors = Object.create(null); } row.errors.timeout = (Number(row.errors.timeout) || 0) + 1; } if (rec.asn || rec.country || rec.prov || rec.city || rec.district || rec.owner || rec.lat || rec.lng) { row.geo = row.geo || {}; if (rec.asn) { row.geo.asnumber = String(rec.asn).trim(); } if (rec.country) { row.geo.country = String(rec.country).trim(); } if (rec.prov) { row.geo.prov = String(rec.prov).trim(); } if (rec.city) { row.geo.city = String(rec.city).trim(); } if (rec.district) { row.geo.district = String(rec.district).trim(); } if (rec.owner) { row.geo.owner = String(rec.owner).trim(); } if (Number.isFinite(Number(rec.lat))) { row.geo.lat = Number(rec.lat); } if (Number.isFinite(Number(rec.lng))) { row.geo.lng = Number(rec.lng); } } if (Array.isArray(rec.mpls) && rec.mpls.length > 0) { const existing = new Set((row.mpls || []).map((v) => String(v))); rec.mpls.forEach((m) => { const val = String(m || '').trim(); if (val) { existing.add(val); } }); row.mpls = Array.from(existing); } recomputeMTRRawDerived(row); } function buildMTRStatsFromRawAgg() { const rows = Array.from(mtrRawAggStore.values()) .sort((a, b) => (a.ttl - b.ttl) || (a._order - b._order)) .map((row) => { const out = {...row}; delete out._sum_ms; delete out._order; return out; }); mtrStatsStore = rows; return rows; } function renderMTRStats(stats) { mtrStatsStore = Array.isArray(stats) ? stats : []; const normalizer = window.nextTraceMTRAgg && window.nextTraceMTRAgg.normalizeRenderableMTRStats; const data = typeof normalizer === 'function' ? normalizer(mtrStatsStore) : mtrStatsStore; if (!data || data.length === 0) { resultNode.innerHTML = `

${t('noResult')}

`; resultNode.classList.remove('hidden'); return; } const table = document.createElement('table'); const thead = document.createElement('thead'); thead.innerHTML = ` ${t('tableTTL')} ${t('colLoss')} ${t('colLast')} ${t('colAvg')} ${t('colBest')} ${t('colWorst')} ${t('colHost')} `; table.appendChild(thead); const tbody = document.createElement('tbody'); let lastTTL = null; data.forEach((stat) => { const row = document.createElement('tr'); const lossText = `${Math.round(stat.loss_percent || 0)}% (${stat.loss_count}/${stat.sent})`; const lastText = formatLatency(stat.last_ms, stat.received); const avgText = formatLatency(stat.avg_ms, stat.received); const bestText = formatLatency(stat.best_ms, stat.received); const worstText = formatLatency(stat.worst_ms, stat.received); const hostParts = getHostDisplayParts(stat); const mplsText = formatMPLSText(stat.mpls); const geoText = formatGeoDisplay(stat.geo); const appendCell = (value) => { const td = document.createElement('td'); td.textContent = value; row.appendChild(td); return td; }; const displayTTL = lastTTL === stat.ttl ? '' : stat.ttl; appendCell(displayTTL); lastTTL = stat.ttl; appendCell(lossText); appendCell(lastText); appendCell(avgText); appendCell(bestText); appendCell(worstText); const hostCell = appendCell(''); hostCell.classList.add('mtr-host-cell'); if (hostParts.ip) { hostCell.appendChild(document.createTextNode(hostParts.ip)); } if (hostParts.ip && hostParts.host) { hostCell.appendChild(document.createTextNode(' ')); } if (hostParts.host) { const hostSpan = document.createElement('span'); hostSpan.className = 'mtr-hostname'; hostSpan.textContent = hostParts.host; hostCell.appendChild(hostSpan); } if (!hostParts.ip && !hostParts.host) { hostCell.textContent = '--'; } if (geoText) { const geoDiv = document.createElement('div'); geoDiv.className = 'attempt__geo'; geoDiv.textContent = geoText; hostCell.appendChild(geoDiv); } if (mplsText) { const mplsDiv = document.createElement('div'); mplsDiv.className = 'mtr-mpls'; mplsDiv.textContent = mplsText; hostCell.appendChild(mplsDiv); } tbody.appendChild(row); }); table.appendChild(tbody); resultNode.innerHTML = ''; resultNode.appendChild(table); resultNode.classList.remove('hidden'); } function getHostDisplayParts(stat) { const ip = stat && stat.ip ? String(stat.ip).trim() : ''; let host = stat && stat.host ? String(stat.host).trim() : ''; if (ip && host && host === ip) { host = ''; } return { ip, host, }; } function formatMPLSText(mpls) { if (!Array.isArray(mpls) || mpls.length === 0) { return ''; } const unique = Array.from(new Set(mpls.map((item) => String(item || '').trim()).filter(Boolean))); return unique.join('\n'); } function formatLatency(value, received) { if (!received || value === undefined || value === null || Number(value) <= 0) { return '--'; } return Number(value).toFixed(2) + ' ms'; } function formatGeoDisplay(geo) { if (!geo) { return ''; } const parts = []; if (geo.asnumber) { parts.push('AS' + geo.asnumber); } const country = currentLang === 'en' ? (geo.country_en || geo.country) : (geo.country || geo.country_en); if (country) { parts.push(country.trim()); } const prov = currentLang === 'en' ? (geo.prov_en || geo.prov) : (geo.prov || geo.prov_en); if (prov) { parts.push(prov.trim()); } const city = currentLang === 'en' ? (geo.city_en || geo.city) : (geo.city || geo.city_en); if (city) { parts.push(city.trim()); } if (geo.owner) { parts.push(geo.owner.trim()); } else if (geo.isp) { parts.push(geo.isp.trim()); } return parts.filter(Boolean).join(' · '); } ================================================ FILE: server/web/assets/mtr_agg.js ================================================ (function(root, factory) { const api = factory(); if (typeof module !== 'undefined' && module.exports) { module.exports = api; } if (root) { root.nextTraceMTRAgg = api; } })(typeof globalThis !== 'undefined' ? globalThis : this, function() { const PROTOTYPE_POLLUTION_KEYS = new Set(['__proto__', 'prototype', 'constructor']); function normalizeErrorKey(key) { const trimmed = String(key || '').trim(); if (!trimmed || PROTOTYPE_POLLUTION_KEYS.has(trimmed)) { return null; } return trimmed; } function mergeErrorMaps(target, source) { const result = Object.create(null); if (target) { Object.keys(target).forEach((key) => { const normalizedKey = normalizeErrorKey(key); if (!normalizedKey) { return; } result[normalizedKey] = Number(target[key]) || 0; }); } if (!source) { return result; } Object.keys(source).forEach((key) => { const normalizedKey = normalizeErrorKey(key); if (!normalizedKey) { return; } const current = Number(result[normalizedKey]) || 0; const addition = Number(source[key]) || 0; result[normalizedKey] = current + addition; }); return result; } function cloneErrors(source) { if (!source) { return null; } const result = Object.create(null); Object.keys(source).forEach((key) => { const normalizedKey = normalizeErrorKey(key); if (!normalizedKey) { return; } result[normalizedKey] = Number(source[key]) || 0; }); return result; } function pickFailureType(current, candidate) { const priority = { all_timeout: 3, partial_timeout: 2, mixed: 1, }; const normalizedCurrent = current || ''; const normalizedCandidate = candidate || ''; const currentPriority = priority[normalizedCurrent] || 0; const candidatePriority = priority[normalizedCandidate] || 0; if (candidatePriority > currentPriority) { return normalizedCandidate; } return normalizedCurrent; } function cloneStat(stat) { const out = {...stat}; out.errors = cloneErrors(stat && stat.errors); if (Array.isArray(stat && stat.mpls)) { out.mpls = [...stat.mpls]; } return out; } function isKnownStat(stat) { const hasIp = stat && stat.ip && String(stat.ip).trim(); const hasHost = stat && stat.host && String(stat.host).trim(); return !!(hasIp || hasHost); } function aggregateUnknown(group) { const acc = { sent: 0, loss: 0, errors: null, failureType: '', }; group.unknown.forEach(({stat}) => { acc.sent += Number(stat.sent) || 0; acc.loss += Number(stat.loss_count) || 0; acc.errors = mergeErrorMaps(acc.errors, stat.errors || null); acc.failureType = pickFailureType(acc.failureType, stat.failure_type || ''); }); return acc; } function mergeUnknownIntoSingleKnown(rows) { const ttlGroups = new Map(); rows.forEach((stat, idx) => { if (!stat) { return; } const ttl = Number(stat.ttl) || 0; let group = ttlGroups.get(ttl); if (!group) { group = {known: [], unknown: []}; ttlGroups.set(ttl, group); } if (isKnownStat(stat)) { group.known.push({idx, stat}); } else { group.unknown.push({idx, stat}); } }); const mergedUnknownIdx = new Set(); ttlGroups.forEach((group) => { if (group.known.length !== 1 || group.unknown.length === 0) { return; } const primary = group.known[0].stat; const unknown = aggregateUnknown(group); const existingSent = Number(primary.sent) || 0; const existingLoss = Number(primary.loss_count) || 0; const totalSent = existingSent + unknown.sent; const totalLoss = existingLoss + unknown.loss; primary.sent = totalSent; primary.loss_count = totalLoss; primary.loss_percent = totalSent > 0 ? (totalLoss / totalSent) * 100 : 0; primary.received = Math.max(0, totalSent - totalLoss); primary.errors = mergeErrorMaps(primary.errors, unknown.errors); primary.failure_type = pickFailureType(primary.failure_type, unknown.failureType); group.unknown.forEach(({idx}) => mergedUnknownIdx.add(idx)); }); return rows.filter((_, idx) => !mergedUnknownIdx.has(idx)); } function normalizeRenderableMTRStats(stats) { const rows = Array.isArray(stats) ? stats.map(cloneStat) : []; return mergeUnknownIntoSingleKnown(rows); } return { normalizeRenderableMTRStats, }; }); ================================================ FILE: server/web/assets/mtr_agg.test.cjs ================================================ const test = require('node:test'); const assert = require('node:assert/strict'); const { normalizeRenderableMTRStats } = require('./mtr_agg.js'); test('merges unknown stats into the only known path for a ttl', () => { const rows = normalizeRenderableMTRStats([ { ttl: 1, sent: 3, received: 0, loss_count: 3, failure_type: 'all_timeout' }, { ttl: 1, ip: '1.1.1.1', host: 'one.one.one.one', sent: 1, received: 1, loss_count: 0, failure_type: '' }, ]); assert.equal(rows.length, 1); assert.equal(rows[0].ip, '1.1.1.1'); assert.equal(rows[0].sent, 4); assert.equal(rows[0].loss_count, 3); assert.equal(rows[0].received, 1); assert.equal(rows[0].failure_type, 'all_timeout'); }); test('preserves unknown row for multipath ttl instead of merging into the first known path', () => { const rows = normalizeRenderableMTRStats([ { ttl: 2, sent: 2, received: 0, loss_count: 2, failure_type: 'all_timeout' }, { ttl: 2, ip: '2.2.2.2', host: 'a.example', sent: 3, received: 3, loss_count: 0, failure_type: '' }, { ttl: 2, ip: '2.2.2.3', host: 'b.example', sent: 4, received: 4, loss_count: 0, failure_type: '' }, ]); assert.equal(rows.length, 3); const unknown = rows.find((row) => !row.ip && !row.host); assert.ok(unknown); assert.equal(unknown.sent, 2); assert.equal(unknown.loss_count, 2); const firstKnown = rows.find((row) => row.ip === '2.2.2.2'); assert.equal(firstKnown.sent, 3); assert.equal(firstKnown.loss_count, 0); }); ================================================ FILE: server/web/assets/mtr_truncation.test.cjs ================================================ /** * Tests for the mtrRawKnownFinalTTL truncation logic in app.js. * * Since ingestMTRRawRecord lives inside app.js (a browser script with DOM * dependencies), we replicate the minimal subset of state and logic here to * exercise the truncation behaviour in isolation under Node.js. */ const test = require('node:test'); const assert = require('node:assert/strict'); // --- Minimal replica of app.js globals & helpers needed for truncation --- function createCtx(resolvedIP) { const ctx = { mtrRawAggStore: new Map(), mtrRawOrderSeq: 0, mtrRawKnownFinalTTL: Infinity, latestSummary: { resolved_ip: resolvedIP }, }; function mtrRawKey(rec) { const ttl = Number(rec && rec.ttl); const ip = rec && rec.ip ? String(rec.ip).trim() : ''; const host = rec && rec.host ? String(rec.host).trim().toLowerCase() : ''; if (ip) return `${ttl}|ip:${ip}`; if (host) return `${ttl}|host:${host}`; return `${ttl}|unknown`; } /** Mirrors the truncation + ingestion logic from app.js ingestMTRRawRecord */ ctx.ingest = function ingest(rec) { if (!rec || !Number.isFinite(Number(rec.ttl))) return; const ttl = Number(rec.ttl); if (ttl > ctx.mtrRawKnownFinalTTL) return; const resolvedIP = ctx.latestSummary && ctx.latestSummary.resolved_ip ? String(ctx.latestSummary.resolved_ip).trim() : ''; const recIP = rec.ip ? String(rec.ip).trim() : ''; if (rec.success && recIP && resolvedIP && recIP === resolvedIP && ttl < ctx.mtrRawKnownFinalTTL) { ctx.mtrRawKnownFinalTTL = ttl; for (const [k, v] of ctx.mtrRawAggStore) { if (v.ttl > ctx.mtrRawKnownFinalTTL) { ctx.mtrRawAggStore.delete(k); } } } const key = mtrRawKey(rec); let row = ctx.mtrRawAggStore.get(key); if (!row) { row = { ttl, host: '', ip: '', sent: 0, received: 0, _order: ctx.mtrRawOrderSeq++, }; ctx.mtrRawAggStore.set(key, row); } row.sent += 1; if (rec.ip) row.ip = String(rec.ip).trim(); if (rec.host) row.host = String(rec.host).trim(); if (rec.success) row.received += 1; }; ctx.ttls = function () { return Array.from(ctx.mtrRawAggStore.values()) .sort((a, b) => a.ttl - b.ttl || a._order - b._order) .map((r) => r.ttl); }; ctx.rows = function () { return Array.from(ctx.mtrRawAggStore.values()) .sort((a, b) => a.ttl - b.ttl || a._order - b._order); }; return ctx; } // --- Tests --- test('drops records beyond knownFinalTTL', () => { const ctx = createCtx('10.0.0.1'); // Simulate: TTL 1-3 arrive, then TTL 2 hits destination. ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true }); ctx.ingest({ ttl: 3, ip: '10.0.0.5', success: true }); // not destination ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true }); // destination! assert.equal(ctx.mtrRawKnownFinalTTL, 2); // TTL 3 should have been pruned. assert.deepEqual(ctx.ttls(), [1, 2]); // Further TTL 3 records should be silently dropped. ctx.ingest({ ttl: 3, ip: '10.0.0.5', success: true }); assert.deepEqual(ctx.ttls(), [1, 2]); }); test('prunes stale high-TTL entries when finalTTL lowers', () => { const ctx = createCtx('10.0.0.1'); // Initial burst: TTL 1-5 all arrive before any destination is known. ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true }); ctx.ingest({ ttl: 2, ip: '10.0.0.2', success: true }); ctx.ingest({ ttl: 3, ip: '10.0.0.3', success: true }); ctx.ingest({ ttl: 4, ip: '10.0.0.4', success: true }); ctx.ingest({ ttl: 5, ip: '10.0.0.1', success: true }); // first destination at TTL 5 assert.equal(ctx.mtrRawKnownFinalTTL, 5); assert.deepEqual(ctx.ttls(), [1, 2, 3, 4, 5]); // Later: destination found at lower TTL 3. ctx.ingest({ ttl: 3, ip: '10.0.0.1', success: true }); assert.equal(ctx.mtrRawKnownFinalTTL, 3); // TTL 4 and 5 should be pruned; TTL 3 now has two paths (10.0.0.3 + 10.0.0.1). const ips = ctx.rows().map((r) => `${r.ttl}:${r.ip}`); assert.deepEqual(ips, ['1:192.168.0.1', '2:10.0.0.2', '3:10.0.0.3', '3:10.0.0.1']); // No TTL > 3 remains. assert.ok(ctx.ttls().every((t) => t <= 3)); }); test('timeout records at high TTL are also dropped once finalTTL is set', () => { const ctx = createCtx('10.0.0.1'); ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true }); ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true }); // destination assert.equal(ctx.mtrRawKnownFinalTTL, 2); // Late-arriving timeout for TTL 5 should be silently dropped. ctx.ingest({ ttl: 5, success: false }); assert.deepEqual(ctx.ttls(), [1, 2]); }); test('non-destination IP at same TTL does not trigger truncation', () => { const ctx = createCtx('10.0.0.1'); ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true }); ctx.ingest({ ttl: 2, ip: '10.0.0.2', success: true }); // not destination ctx.ingest({ ttl: 3, ip: '10.0.0.3', success: true }); // not destination assert.equal(ctx.mtrRawKnownFinalTTL, Infinity); assert.deepEqual(ctx.ttls(), [1, 2, 3]); }); test('does not crash with missing latestSummary.resolved_ip', () => { const ctx = createCtx(''); ctx.ingest({ ttl: 1, ip: '10.0.0.1', success: true }); ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true }); // Without resolved_ip, nothing should be truncated. assert.equal(ctx.mtrRawKnownFinalTTL, Infinity); assert.deepEqual(ctx.ttls(), [1, 2]); }); test('reset clears knownFinalTTL (simulated clearResult)', () => { const ctx = createCtx('10.0.0.1'); ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true }); ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true }); assert.equal(ctx.mtrRawKnownFinalTTL, 2); // Simulate clearResult(true). ctx.mtrRawAggStore = new Map(); ctx.mtrRawOrderSeq = 0; ctx.mtrRawKnownFinalTTL = Infinity; assert.equal(ctx.mtrRawKnownFinalTTL, Infinity); // New session should work fresh. ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true }); ctx.ingest({ ttl: 5, ip: '10.0.0.1', success: true }); assert.equal(ctx.mtrRawKnownFinalTTL, 5); assert.deepEqual(ctx.ttls(), [1, 5]); }); test('destination at TTL equal to current finalTTL does not re-prune', () => { const ctx = createCtx('10.0.0.1'); ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true }); ctx.ingest({ ttl: 3, ip: '10.0.0.1', success: true }); // set finalTTL=3 ctx.ingest({ ttl: 2, ip: '10.0.0.2', success: true }); assert.equal(ctx.mtrRawKnownFinalTTL, 3); // Same TTL destination (ttl === mtrRawKnownFinalTTL): should not lower. ctx.ingest({ ttl: 3, ip: '10.0.0.1', success: true }); assert.equal(ctx.mtrRawKnownFinalTTL, 3); assert.deepEqual(ctx.ttls(), [1, 2, 3]); }); ================================================ FILE: server/web/assets/style.css ================================================ *, *::before, *::after { box-sizing: border-box; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; } a { color: #60a5fa; text-decoration: none; } a:hover { text-decoration: underline; } .header { padding: 1.75rem 1.25rem 0.85rem; } .header__top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem; } .header__title h1 { margin: 0; font-size: 1.6rem; font-weight: 600; } .header__subtitle { margin: 0.65rem 0 0; color: #94a3b8; } .header__actions { display: flex; gap: 0.65rem; } .action-btn { padding: 0.5rem 1rem; border-radius: 0.55rem; border: 1px solid rgba(148, 163, 184, 0.35); background: rgba(15, 23, 42, 0.6); color: #e2e8f0; font-size: 0.88rem; font-weight: 500; cursor: pointer; transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } .action-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(56, 189, 248, 0.15); } .action-btn--primary { background: linear-gradient(135deg, #38bdf8, #6366f1); color: #0f172a; border-color: transparent; } .action-btn--ghost { border-color: rgba(148, 163, 184, 0.45); } @media (max-width: 640px) { .header__top { flex-direction: column; align-items: flex-start; } .header__actions { width: 100%; flex-wrap: wrap; justify-content: flex-start; } } .container { max-width: 1100px; margin: 0 auto; padding: 0 1.25rem 2.25rem; display: flex; flex-wrap: wrap; gap: 1.25rem; align-items: flex-start; } @media (max-width: 1024px) { .container { flex-direction: column; padding: 0 1rem 2.1rem; } } .panel { background: rgba(15, 23, 42, 0.85); border: 1px solid rgba(100, 116, 139, 0.2); border-radius: 0.9rem; padding: 1.25rem; box-shadow: 0 10px 32px rgba(15, 23, 42, 0.35); backdrop-filter: blur(10px); } .panel--form { flex: 0 0 320px; max-width: 360px; display: flex; flex-direction: column; gap: 0.8rem; } .panel--results { flex: 1 1 0; min-width: 320px; display: flex; flex-direction: column; gap: 0.75rem; min-height: calc(100vh - 260px); } @media (max-width: 1024px) { .panel--form, .panel--results { flex: 1 1 100%; max-width: 100%; min-height: auto; } } .form { display: flex; flex-direction: column; gap: 0.8rem; } .form__group { display: flex; flex-direction: column; gap: 0.4rem; } .panel--form .form__group { padding: 0.35rem 0.75rem; border-radius: 0.65rem; background: rgba(15, 23, 42, 0.42); border: 1px solid rgba(71, 85, 105, 0.35); } .panel--form .form__group.grid { padding: 0; border: none; background: transparent; gap: 0.8rem; } .panel--form .form__group.grid > div { padding: 0.35rem 0.75rem; border-radius: 0.65rem; background: rgba(15, 23, 42, 0.42); border: 1px solid rgba(71, 85, 105, 0.35); } .panel--form .form__group.checkbox-group { display: flex; align-items: center; padding: 0.35rem 0.75rem; border-radius: 0.65rem; background: rgba(15, 23, 42, 0.42); border: 1px solid rgba(71, 85, 105, 0.35); } .form__group small { font-size: 0.75rem; color: #94a3b8; } .form__group.disabled input { opacity: 0.6; cursor: not-allowed; } .form__group.disabled label, .form__group.disabled small { opacity: 0.65; } .form__group.grid { display: grid; gap: 0.75rem; } @media (min-width: 640px) { .form__group.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } label { font-size: 0.95rem; color: #cbd5f5; } input[type="text"], input[type="number"], select { width: 100%; padding: 0.65rem 0.75rem; border: 1px solid rgba(148, 163, 184, 0.35); border-radius: 0.6rem; background: rgba(15, 23, 42, 0.6); color: #e2e8f0; transition: border-color 0.2s ease, box-shadow 0.2s ease; } input:focus, select:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.3); } .checkbox { display: inline-flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none; } .checkbox input { width: 1.05rem; height: 1.05rem; } .form__actions { display: flex; justify-content: flex-end; gap: 0.65rem; } button[type="submit"] { padding: 0.65rem 1.4rem; border: none; border-radius: 0.55rem; background: linear-gradient(135deg, #38bdf8, #6366f1); color: #0f172a; font-weight: 600; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease; } button[type="submit"]:hover { transform: translateY(-1px); box-shadow: 0 10px 26px rgba(56, 189, 248, 0.35); } button[type="submit"]:disabled { opacity: 0.6; cursor: wait; transform: none; box-shadow: none; } .status { padding: 0.65rem 0.85rem; border-radius: 0.55rem; font-size: 0.92rem; } .status--idle { background: rgba(148, 163, 184, 0.12); color: #cbd5f5; } .status--running { background: rgba(56, 189, 248, 0.15); color: #38bdf8; } .status--error { background: rgba(248, 113, 113, 0.15); color: #f87171; } .status--success { background: rgba(74, 222, 128, 0.12); color: #4ade80; } .result-meta { font-size: 0.9rem; color: #cbd5f5; margin-bottom: 1rem; display: grid; gap: 0.35rem; } .results-header { display: flex; flex-direction: column; gap: 0.75rem; } @media (min-width: 600px) { .results-header { flex-direction: row; align-items: center; justify-content: space-between; } } .panel--results #result-meta { flex-shrink: 0; max-width: 420px; } #result { flex: 1 1 auto; overflow: auto; min-height: 0; max-height: 100%; } .result table { width: 100%; border-collapse: collapse; min-width: 520px; } body.mode-mtr .container { max-width: 1280px; padding: 0 1.5rem 2.5rem; gap: 1.5rem; } body.mode-mtr .panel--form { flex: 0 0 clamp(320px, 28vw, 440px); max-width: clamp(340px, 30vw, 460px); } body.mode-mtr .panel--results { flex: 1 1 clamp(520px, 60vw, 920px); min-width: clamp(360px, 55vw, 920px); gap: 0.85rem; min-height: calc(100vh - 240px); } body.mode-mtr #group-basic-params, body.mode-mtr #group-advanced-params, body.mode-mtr #group-disable-map { display: none; } .hidden { display: none !important; } body.mode-mtr .panel--results #result-meta { max-width: 560px; } body.mode-mtr .result table { min-width: 680px; } .result thead { background: rgba(15, 23, 42, 0.8); } .result th, .result td { padding: 0.6rem 0.75rem; border-bottom: 1px solid rgba(51, 65, 85, 0.5); text-align: left; font-size: 0.9rem; } .result tbody tr:hover { background: rgba(56, 189, 248, 0.08); } .mtr-host-cell { font-weight: 500; color: #e2e8f0; line-height: 1.4; } .mtr-host-cell .mtr-hostname { display: inline-block; font-size: 0.82em; color: rgba(148, 163, 184, 0.85); font-weight: 500; } .mtr-host-cell .attempt__geo { margin-top: 0.25rem; display: block; } .mtr-host-cell .mtr-mpls { margin-top: 0.2rem; display: block; font-size: 0.78em; color: rgba(148, 163, 184, 0.7); font-weight: 400; white-space: pre-line; } .attempts { display: flex; flex-direction: column; gap: 0.35rem; } .attempts--grouped { gap: 0.55rem; } .attempt { display: flex; flex-wrap: wrap; gap: 0.4rem 0.7rem; padding: 0.4rem 0.65rem; border-radius: 0.45rem; background: rgba(30, 41, 59, 0.6); border: 1px solid rgba(71, 85, 105, 0.35); } .attempt--group { flex-direction: column; gap: 0.4rem; padding: 0.55rem 0.75rem; } .attempt__header { display: flex; flex-wrap: wrap; gap: 0.6rem; font-weight: 600; color: #e2e8f0; } .attempt__star { font-size: 1rem; color: #e2e8f0; } .attempt__badge { padding: 0.1rem 0.45rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; background: rgba(94, 234, 212, 0.18); color: #5eead4; } .attempt__badge--fail { background: rgba(248, 113, 113, 0.18); color: #fca5a5; } .attempt__meta { display: flex; gap: 0.5rem; flex-wrap: wrap; font-size: 0.85rem; color: #e2e8f0; } .attempt__probes { display: flex; gap: 0.35rem; flex-wrap: wrap; } .attempt__geo { font-size: 0.8rem; color: #94a3b8; } body:not(.mode-mtr) .attempt__meta { font-size: 0.9rem; } body:not(.mode-mtr) .attempt__geo { font-size: 0.9rem; color: #e2e8f0; } body:not(.mode-mtr) .attempt__mpls { font-size: 0.85rem; color: #94a3b8; line-height: 1.35; margin-top: 0.2rem; } .footer { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: #64748b; border-top: 1px solid rgba(71, 85, 105, 0.3); } .footer span { display: block; } ================================================ FILE: server/web/assets/trace_form.js ================================================ (function (root, factory) { const api = factory(); if (typeof module !== 'undefined' && module.exports) { module.exports = api; } root.NextTraceForm = api; })(typeof globalThis !== 'undefined' ? globalThis : this, function () { function readNumericValueFromRaw(raw) { const text = String(raw ?? '').trim(); if (text === '') { return undefined; } const num = Number(text); return Number.isFinite(num) ? num : undefined; } function defaultOptionValue(defaultOptions, key, fallback) { if (defaultOptions && Object.prototype.hasOwnProperty.call(defaultOptions, key)) { return defaultOptions[key]; } return fallback; } function buildTracePayload(values) { const payload = { target: String(values.target || '').trim(), protocol: values.protocol, data_provider: values.dataProvider, disable_maptrace: Boolean(values.disableMaptrace), language: values.language, mode: values.mode || 'single', }; const isMtrMode = payload.mode === 'mtr'; if (isMtrMode) { payload.queries = 10; payload.hop_interval_ms = 1000; payload.max_rounds = 0; } else { const queries = readNumericValueFromRaw(values.queries); if (queries !== undefined) { payload.queries = Math.max(1, Math.min(63, queries)); } } const maxHops = readNumericValueFromRaw(values.maxHops); if (maxHops !== undefined) { payload.max_hops = maxHops; } const dstPort = readNumericValueFromRaw(values.dstPort); if (dstPort !== undefined) { payload.port = dstPort; } const packetSize = readNumericValueFromRaw(values.packetSize); if (packetSize !== undefined) { payload.packet_size = packetSize; } const tos = readNumericValueFromRaw(values.tos); if (tos !== undefined) { payload.tos = tos; } return payload; } return { buildTracePayload, defaultOptionValue, readNumericValueFromRaw, }; }); ================================================ FILE: server/web/assets/trace_form.test.cjs ================================================ const test = require('node:test'); const assert = require('node:assert/strict'); const { buildTracePayload, defaultOptionValue } = require('./trace_form.js'); test('buildTracePayload preserves negative packet_size and zero tos', () => { const payload = buildTracePayload({ target: '1.1.1.1', protocol: 'icmp', dataProvider: 'LeoMoeAPI', disableMaptrace: false, language: 'cn', mode: 'single', queries: '3', maxHops: '30', dstPort: '', packetSize: '-123', tos: '0', }); assert.equal(payload.packet_size, -123); assert.equal(payload.tos, 0); assert.equal(payload.queries, 3); }); test('buildTracePayload carries packet_size and tos in mtr mode', () => { const payload = buildTracePayload({ target: 'example.com', protocol: 'udp', dataProvider: 'disable-geoip', disableMaptrace: true, language: 'en', mode: 'mtr', queries: '5', maxHops: '20', dstPort: '33494', packetSize: '80', tos: '255', }); assert.equal(payload.mode, 'mtr'); assert.equal(payload.queries, 10); assert.equal(payload.hop_interval_ms, 1000); assert.equal(payload.max_rounds, 0); assert.equal(payload.packet_size, 80); assert.equal(payload.tos, 255); }); test('defaultOptionValue keeps explicit zero', () => { assert.equal(defaultOptionValue({ tos: 0 }, 'tos', 5), 0); assert.equal(defaultOptionValue({}, 'tos', 5), 5); }); test('defaultOptionValue preserves explicit null for auto packet size', () => { assert.equal(defaultOptionValue({ packet_size: null }, 'packet_size', 52), null); }); ================================================ FILE: server/web/index.html ================================================ NextTrace Web

NextTrace Web

在浏览器中运行 NextTrace,实时查看路由探测结果。

仅 TCP/UDP 模式有效
准备就绪
当前会话仅提供基础功能,更多高级选项请使用 CLI。
================================================ FILE: server/ws_handler.go ================================================ package server import ( "context" "encoding/json" "errors" "io" "log" "net/http" "strings" "sync" "sync/atomic" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/nxtrace/NTrace-core/trace" ) var traceUpgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return browserOriginAllowed(r) }, } const ( wsSendQueueSize = 1024 wsWriteTimeout = 5 * time.Second ) var ( errWSSlowConsumer = errors.New("websocket client too slow for mtr stream") errWSSessionClosed = errors.New("websocket session closed") traceTracerouteFn = trace.Traceroute traceRunMTRRawFn = trace.RunMTRRaw ) // sanitizeLogParam 清理用户输入中的换行和控制字符,防止日志注入。 func sanitizeLogParam(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { if r == '\n' || r == '\r' { b.WriteString("\\n") } else if r < 0x20 && r != '\t' { // 保留 tab,替换其他 C0 控制字符 b.WriteRune('\uFFFD') } else { b.WriteRune(r) } } return b.String() } func newWSSessionContext(parent context.Context) (context.Context, context.CancelFunc) { if parent == nil { parent = context.Background() } return context.WithCancel(parent) } type wsEnvelope struct { Type string `json:"type"` Data interface{} `json:"data,omitempty"` Error string `json:"error,omitempty"` Status int `json:"status,omitempty"` } type wsConn interface { WriteJSON(v interface{}) error SetWriteDeadline(t time.Time) error WriteControl(messageType int, data []byte, deadline time.Time) error Close() error NextReader() (messageType int, r io.Reader, err error) } type wsInitConn interface { SetReadDeadline(t time.Time) error SetReadLimit(limit int64) ReadMessage() (messageType int, p []byte, err error) } type wsTraceSession struct { conn wsConn sendMu sync.Mutex sendCh chan wsEnvelope stopCh chan struct{} writerDone chan struct{} closeOnce sync.Once finishOnce sync.Once closed atomic.Bool lang string seen map[int]int } func newWSTraceSession(conn wsConn, lang string, queueSize int) *wsTraceSession { if queueSize <= 0 { queueSize = wsSendQueueSize } s := &wsTraceSession{ conn: conn, sendCh: make(chan wsEnvelope, queueSize), stopCh: make(chan struct{}), writerDone: make(chan struct{}), lang: lang, seen: make(map[int]int), } go s.writeLoop() return s } func readWSInitMessage(conn wsInitConn) ([]byte, error) { if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { return nil, err } conn.SetReadLimit(maxWSInitMessageBytes) _, message, err := conn.ReadMessage() if err != nil { return nil, err } if err := conn.SetReadDeadline(time.Time{}); err != nil { return nil, err } return message, nil } func (s *wsTraceSession) writeLoop() { defer close(s.writerDone) defer func() { if r := recover(); r != nil { log.Printf("[deploy] writeLoop panic: %v", r) s.closeWithCode(websocket.CloseInternalServerErr, "internal error") } }() for { select { case <-s.stopCh: return case msg, ok := <-s.sendCh: if !ok { return } deadline := time.Now().Add(wsWriteTimeout) _ = s.conn.SetWriteDeadline(deadline) err := s.conn.WriteJSON(msg) if err != nil { s.closeWithCode(websocket.CloseInternalServerErr, "write failed") return } } } } func (s *wsTraceSession) send(msg wsEnvelope) error { s.sendMu.Lock() defer s.sendMu.Unlock() if s.closed.Load() { return errWSSessionClosed } select { case s.sendCh <- msg: return nil default: s.closeWithCode(websocket.CloseTryAgainLater, "client too slow for mtr stream") return errWSSlowConsumer } } func (s *wsTraceSession) closeWithCode(code int, reason string) { s.closed.Store(true) s.closeOnce.Do(func() { close(s.stopCh) deadline := time.Now().Add(wsWriteTimeout) _ = s.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), deadline) _ = s.conn.Close() }) } func (s *wsTraceSession) finish() { s.finishOnce.Do(func() { s.sendMu.Lock() wasClosed := s.closed.Swap(true) if !wasClosed { close(s.sendCh) } s.sendMu.Unlock() <-s.writerDone s.closeOnce.Do(func() { _ = s.conn.Close() }) }) } func traceWebsocketHandler(c *gin.Context) { conn, err := traceUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { log.Printf("[deploy] websocket upgrade failed: %v", err) return } defer func() { _ = conn.Close() }() message, err := readWSInitMessage(conn) if err != nil { log.Printf("[deploy] websocket read failed: %v", err) return } var req traceRequest if err := json.Unmarshal(message, &req); err != nil { _ = conn.WriteJSON(wsEnvelope{Type: "error", Error: "invalid request payload", Status: 400}) return } sessionCtx, cancel := newWSSessionContext(c.Request.Context()) defer cancel() var sessionRef atomic.Pointer[wsTraceSession] go func() { for { if _, _, err := conn.NextReader(); err != nil { cancel() if session := sessionRef.Load(); session != nil { session.closeWithCode(websocket.CloseNormalClosure, "client disconnected") } return } } }() setup, statusCode, err := prepareTrace(sessionCtx, req) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } if statusCode == 0 { statusCode = 500 } log.Printf("[deploy] websocket prepare trace failed target=%s error=%v", sanitizeLogParam(req.Target), err) _ = conn.WriteJSON(wsEnvelope{Type: "error", Error: err.Error(), Status: statusCode}) return } session := newWSTraceSession(conn, setup.Config.Lang, wsSendQueueSize) sessionRef.Store(session) defer session.finish() startPayload := gin.H{ "target": setup.Target, "resolved_ip": setup.IP.String(), "protocol": setup.Protocol, "data_provider": setup.DataProvider, "language": setup.Config.Lang, } if err := session.send(wsEnvelope{Type: "start", Data: startPayload}); err != nil { log.Printf("[deploy] websocket send start failed: %v", err) return } log.Printf("[deploy] (ws) trace request target=%s proto=%s provider=%s lang=%s ipv4_only=%t ipv6_only=%t", sanitizeLogParam(setup.Target), sanitizeLogParam(setup.Protocol), sanitizeLogParam(setup.DataProvider), sanitizeLogParam(setup.Config.Lang), setup.Req.IPv4Only, setup.Req.IPv6Only) log.Printf("[deploy] (ws) target resolved target=%s ip=%s via dot=%s", sanitizeLogParam(setup.Target), setup.IP, sanitizeLogParam(strings.ToLower(setup.Req.DotServer))) mode := setup.Req.Mode if mode == "" { mode = "single" } switch mode { case "mtr", "continuous": runMTRTrace(sessionCtx, session, setup) default: runSingleTrace(sessionCtx, session, setup) } } func runSingleTrace(ctx context.Context, session *wsTraceSession, setup *traceExecution) { session.seen = make(map[int]int) res, duration, err := executeTrace(ctx, session, setup, func(cfg *trace.Config) { cfg.RealtimePrinter = nil cfg.AsyncPrinter = func(result *trace.Result) { for idx, attempts := range result.Hops { if len(attempts) == 0 { continue } snapshot := append([]trace.Hop(nil), attempts...) newLen := len(snapshot) if newLen == 0 { continue } if prevLen, ok := session.seen[idx]; ok && newLen <= prevLen { continue } session.seen[idx] = newLen hop := buildHopResponse(snapshot, idx, session.lang) if len(hop.Attempts) == 0 { continue } if err := session.send(wsEnvelope{Type: "hop", Data: hop}); err != nil { log.Printf("[deploy] websocket hop send failed ttl=%d err=%v", hop.TTL, err) return } } } }) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } log.Printf("[deploy] websocket trace failed target=%s error=%v", sanitizeLogParam(setup.Target), err) _ = session.send(wsEnvelope{Type: "error", Error: err.Error(), Status: 500}) return } if session.closed.Load() { return } traceMapURL := traceMapURLForResult(setup, res) if traceMapURL != "" { log.Printf("[deploy] (ws) trace map generated target=%s url=%s", sanitizeLogParam(setup.Target), traceMapURL) } final := traceResponse{ Target: setup.Target, ResolvedIP: setup.IP.String(), Protocol: setup.Protocol, DataProvider: setup.DataProvider, TraceMapURL: traceMapURL, Language: setup.Config.Lang, Hops: convertHops(res, setup.Config.Lang), DurationMs: duration.Milliseconds(), } if err := session.send(wsEnvelope{Type: "complete", Data: final}); err != nil { log.Printf("[deploy] websocket send complete failed: %v", err) } log.Printf("[deploy] (ws) trace completed target=%s hops=%d duration=%s", sanitizeLogParam(setup.Target), len(final.Hops), duration) } func runMTRTrace(parentCtx context.Context, session *wsTraceSession, setup *traceExecution) { hopInterval := resolveWebMTRHopInterval(setup.Req) maxPerHop := setup.Req.MaxRounds // 0 = unlimited iteration := 0 ctx, cancel := context.WithCancel(parentCtx) defer cancel() err := executeMTRRaw(ctx, session, setup, trace.MTRRawOptions{ HopInterval: hopInterval, MaxPerHop: maxPerHop, }, func(rec trace.MTRRawRecord) { if rec.Iteration > iteration { iteration = rec.Iteration } if err := session.send(wsEnvelope{Type: "mtr_raw", Data: rec}); err != nil { cancel() } }) if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { log.Printf("[deploy] websocket MTR raw trace failed target=%s error=%v", sanitizeLogParam(setup.Target), err) _ = session.send(wsEnvelope{Type: "error", Error: err.Error(), Status: 500}) return } if !session.closed.Load() { _ = session.send(wsEnvelope{Type: "complete", Data: gin.H{"iteration": iteration}}) } } func executeMTRRaw(ctx context.Context, session *wsTraceSession, setup *traceExecution, opts trace.MTRRawOptions, onRecord trace.MTRRawOnRecord) error { config := setup.Config if session.closed.Load() { return nil } if opts.HopInterval > 0 { // Per-hop scheduling only needs LeoMoe/FastIP setup now; the trace runtime // itself no longer depends on per-session mutable globals. log.Printf("[deploy] (ws) starting MTR per-hop trace target=%s resolved=%s method=%s lang=%s maxHops=%d hopInterval=%s maxPerHop=%d", sanitizeLogParam(setup.Target), setup.IP.String(), string(setup.Method), sanitizeLogParam(config.Lang), config.MaxHops, opts.HopInterval, opts.MaxPerHop) traceMu.Lock() _, err := withTraceSetupContext(setup, func() (struct{}, error) { if setup.NeedsLeoWS { ensureLeoMoeConnection() } return struct{}{}, nil }) traceMu.Unlock() if err != nil { return err } return traceRunMTRRawFn(ctx, setup.Method, config, opts, onRecord) } // Legacy round-based path: inject RunRound with per-round locking. log.Printf("[deploy] (ws) starting MTR round-based trace target=%s resolved=%s method=%s lang=%s maxHops=%d interval=%s maxRounds=%d", sanitizeLogParam(setup.Target), setup.IP.String(), string(setup.Method), sanitizeLogParam(config.Lang), config.MaxHops, opts.Interval, opts.MaxRounds) opts.RunRound = func(method trace.Method, cfg trace.Config) (*trace.Result, error) { traceMu.Lock() defer traceMu.Unlock() return withTraceSetupContext(setup, func() (*trace.Result, error) { if setup.NeedsLeoWS { ensureLeoMoeConnection() } return traceTracerouteFn(method, cfg) }) } return traceRunMTRRawFn(ctx, setup.Method, config, opts, onRecord) } func executeTrace(ctx context.Context, session *wsTraceSession, setup *traceExecution, configure func(*trace.Config)) (*trace.Result, time.Duration, error) { traceMu.Lock() defer traceMu.Unlock() config := setup.Config config.Context = ctx if configure != nil { configure(&config) } if session.closed.Load() { return nil, 0, nil } log.Printf("[deploy] (ws) starting trace target=%s resolved=%s method=%s lang=%s queries=%d maxHops=%d", sanitizeLogParam(setup.Target), setup.IP.String(), string(setup.Method), sanitizeLogParam(config.Lang), config.NumMeasurements, config.MaxHops) start := time.Now() res, err := withTraceSetupContext(setup, func() (*trace.Result, error) { if setup.NeedsLeoWS { ensureLeoMoeConnection() } return traceTracerouteFn(setup.Method, config) }) duration := time.Since(start) return res, duration, err } func resolveWebMTRHopInterval(req traceRequest) time.Duration { if req.HopIntervalMs > 0 { return time.Duration(req.HopIntervalMs) * time.Millisecond } if req.IntervalMs > 0 { return time.Duration(req.IntervalMs) * time.Millisecond } return time.Second } ================================================ FILE: server/ws_handler_test.go ================================================ package server import ( "context" "encoding/json" "errors" "io" "sync" "testing" "time" "github.com/gorilla/websocket" ) type fakeWSConn struct { mu sync.Mutex writes []wsEnvelope writeStarted chan struct{} writeBlock chan struct{} closeOnce sync.Once closeCount int controlCount int deadlineCount int } func newFakeWSConn(blockWrites bool) *fakeWSConn { conn := &fakeWSConn{} if blockWrites { conn.writeStarted = make(chan struct{}) conn.writeBlock = make(chan struct{}) } return conn } func (f *fakeWSConn) WriteJSON(v interface{}) error { if f.writeStarted != nil { select { case <-f.writeStarted: default: close(f.writeStarted) } } if f.writeBlock != nil { <-f.writeBlock } data, err := json.Marshal(v) if err != nil { return err } var msg wsEnvelope if err := json.Unmarshal(data, &msg); err != nil { return err } f.mu.Lock() f.writes = append(f.writes, msg) f.mu.Unlock() return nil } func (f *fakeWSConn) SetWriteDeadline(time.Time) error { f.mu.Lock() f.deadlineCount++ f.mu.Unlock() return nil } func (f *fakeWSConn) WriteControl(messageType int, data []byte, deadline time.Time) error { f.mu.Lock() f.controlCount++ f.mu.Unlock() return nil } func (f *fakeWSConn) Close() error { f.closeOnce.Do(func() { f.mu.Lock() f.closeCount++ f.mu.Unlock() if f.writeBlock != nil { close(f.writeBlock) } }) return nil } func (f *fakeWSConn) NextReader() (messageType int, r io.Reader, err error) { return 0, nil, io.EOF } type fakeWSInitConn struct { deadlines []time.Time readLimit int64 message []byte err error deadlineErrs []error } func (f *fakeWSInitConn) SetReadDeadline(t time.Time) error { f.deadlines = append(f.deadlines, t) if len(f.deadlineErrs) > 0 { err := f.deadlineErrs[0] f.deadlineErrs = f.deadlineErrs[1:] return err } return nil } func (f *fakeWSInitConn) SetReadLimit(limit int64) { f.readLimit = limit } func (f *fakeWSInitConn) ReadMessage() (messageType int, p []byte, err error) { if f.err != nil { return 0, nil, f.err } return websocket.TextMessage, f.message, nil } func TestReadWSInitMessage_ClearsDeadlineAfterSuccessfulRead(t *testing.T) { conn := &fakeWSInitConn{message: []byte(`{"target":"example.com"}`)} msg, err := readWSInitMessage(conn) if err != nil { t.Fatalf("readWSInitMessage returned error: %v", err) } if string(msg) != `{"target":"example.com"}` { t.Fatalf("readWSInitMessage()=%q, want payload unchanged", string(msg)) } if conn.readLimit != maxWSInitMessageBytes { t.Fatalf("SetReadLimit=%d, want %d", conn.readLimit, maxWSInitMessageBytes) } if len(conn.deadlines) != 2 { t.Fatalf("SetReadDeadline called %d times, want 2", len(conn.deadlines)) } if conn.deadlines[0].IsZero() { t.Fatal("initial read deadline should be set") } if !conn.deadlines[1].IsZero() { t.Fatalf("final read deadline=%v, want zero time", conn.deadlines[1]) } } func TestReadWSInitMessage_ReturnsInitialDeadlineError(t *testing.T) { conn := &fakeWSInitConn{ message: []byte(`{"target":"example.com"}`), deadlineErrs: []error{errors.New("set deadline failed")}, } if _, err := readWSInitMessage(conn); err == nil || err.Error() != "set deadline failed" { t.Fatalf("readWSInitMessage error = %v, want initial deadline error", err) } } func TestReadWSInitMessage_ReturnsClearDeadlineError(t *testing.T) { conn := &fakeWSInitConn{ message: []byte(`{"target":"example.com"}`), deadlineErrs: []error{nil, errors.New("clear deadline failed")}, } if _, err := readWSInitMessage(conn); err == nil || err.Error() != "clear deadline failed" { t.Fatalf("readWSInitMessage error = %v, want clear deadline error", err) } } func TestNewWSSessionContextInheritsParentCancellation(t *testing.T) { parent, cancelParent := context.WithCancel(context.Background()) ctx, cancel := newWSSessionContext(parent) defer cancel() cancelParent() select { case <-ctx.Done(): if !errors.Is(ctx.Err(), context.Canceled) { t.Fatalf("ctx.Err() = %v, want context.Canceled", ctx.Err()) } case <-time.After(100 * time.Millisecond): t.Fatal("session context did not inherit parent cancellation") } } func TestWSTraceSessionSend_QueueOverflowReturnsErrSlowConsumer(t *testing.T) { conn := newFakeWSConn(true) session := newWSTraceSession(conn, "cn", 1) defer session.finish() if err := session.send(wsEnvelope{Type: "first"}); err != nil { t.Fatalf("first send returned error: %v", err) } <-conn.writeStarted if err := session.send(wsEnvelope{Type: "second"}); err != nil { t.Fatalf("second send returned error: %v", err) } err := session.send(wsEnvelope{Type: "third"}) if !errors.Is(err, errWSSlowConsumer) { t.Fatalf("expected errWSSlowConsumer, got %v", err) } if !session.closed.Load() { t.Fatal("session should be marked closed after queue overflow") } } func TestWSTraceSessionWriter_PreservesEnvelopeOrder(t *testing.T) { conn := newFakeWSConn(false) session := newWSTraceSession(conn, "cn", 4) if err := session.send(wsEnvelope{Type: "start"}); err != nil { t.Fatalf("first send returned error: %v", err) } if err := session.send(wsEnvelope{Type: "mtr_raw", Data: map[string]int{"ttl": 1}}); err != nil { t.Fatalf("second send returned error: %v", err) } session.finish() conn.mu.Lock() defer conn.mu.Unlock() if len(conn.writes) != 2 { t.Fatalf("writer sent %d envelopes, want 2", len(conn.writes)) } if conn.writes[0].Type != "start" || conn.writes[1].Type != "mtr_raw" { t.Fatalf("unexpected write order: %+v", conn.writes) } } func TestWSTraceSessionClose_IsIdempotent(t *testing.T) { conn := newFakeWSConn(false) session := newWSTraceSession(conn, "cn", 4) session.closeWithCode(websocket.CloseTryAgainLater, "slow consumer") session.closeWithCode(websocket.CloseTryAgainLater, "slow consumer") session.finish() session.finish() conn.mu.Lock() defer conn.mu.Unlock() if conn.closeCount != 1 { t.Fatalf("Close called %d times, want 1", conn.closeCount) } if conn.controlCount != 1 { t.Fatalf("WriteControl called %d times, want 1", conn.controlCount) } if conn.deadlineCount != 0 { t.Fatalf("SetWriteDeadline called %d times during close path, want 0", conn.deadlineCount) } } func TestSanitizeLogParam(t *testing.T) { tests := []struct { input string want string }{ {"normal text", "normal text"}, {"hello\nworld", "hello\\nworld"}, {"hello\r\nworld", "hello\\n\\nworld"}, {"line1\nline2\nline3", "line1\\nline2\\nline3"}, {"tab\there", "tab\there"}, {"null\x00byte", "null\uFFFDbyte"}, {"esc\x1b[31m", "esc\uFFFD[31m"}, {"", ""}, {"safe-host.example.com", "safe-host.example.com"}, {"evil\n[deploy] fake log entry", "evil\\n[deploy] fake log entry"}, } for _, tt := range tests { got := sanitizeLogParam(tt.input) if got != tt.want { t.Errorf("sanitizeLogParam(%q) = %q, want %q", tt.input, got, tt.want) } } } ================================================ FILE: trace/cache.go ================================================ package trace func ClearCaches() { geoCache.Range(func(key, value any) bool { geoCache.Delete(key) return true }) } ================================================ FILE: trace/globalping.go ================================================ //go:build !flavor_tiny && !flavor_ntr package trace import ( "context" "fmt" "math" "net" "net/http" "strings" "time" "github.com/jsdelivr/globalping-cli/globalping" _config "github.com/nxtrace/NTrace-core/config" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/util" ) func GlobalpingTraceroute(opts *GlobalpingOptions, config *Config) (*Result, *globalping.Measurement, error) { ctx := context.Background() if config != nil && config.Context != nil { ctx = config.Context } client := newGlobalpingClient(ctx) measurement, err := createGlobalpingMeasurement(ctx, client, buildGlobalpingMeasurement(opts)) if err != nil { return nil, nil, err } gpHops, err := decodeGlobalpingMeasurementHops(measurement) if err != nil { return nil, measurement, err } limit := resolveGlobalpingHopLimit(opts, config, len(gpHops)) return buildGlobalpingResult(gpHops, limit, config), measurement, nil } func newGlobalpingClient(ctx context.Context) globalping.Client { cfg := globalping.Config{ UserAgent: "NextTrace/" + _config.Version, HTTPClient: &http.Client{ Timeout: 30 * time.Second, Transport: newContextBoundTransport(ctx), }, } if util.GlobalpingToken != "" { cfg.AuthToken = &globalping.Token{ AccessToken: util.GlobalpingToken, Expiry: time.Now().Add(math.MaxInt64), } } return globalping.NewClient(cfg) } func newContextBoundTransport(ctx context.Context) http.RoundTripper { base := http.DefaultTransport if base == nil { base = http.DefaultTransport } return roundTripperFunc(func(req *http.Request) (*http.Response, error) { if ctx == nil { return base.RoundTrip(req) } return base.RoundTrip(req.Clone(ctx)) }) } type roundTripperFunc func(*http.Request) (*http.Response, error) func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func buildGlobalpingMeasurement(opts *GlobalpingOptions) *globalping.MeasurementCreate { req := &globalping.MeasurementCreate{ Type: "mtr", Target: opts.Target, Limit: 1, Locations: []globalping.Locations{{ Magic: opts.From, }}, Options: &globalping.MeasurementOptions{ Port: uint16(opts.Port), Packets: opts.Packets, Protocol: globalpingProtocol(opts), }, } assignGlobalpingIPVersion(req.Options, opts) return req } func globalpingProtocol(opts *GlobalpingOptions) string { switch { case opts.TCP: return "TCP" case opts.UDP: return "UDP" default: return "ICMP" } } func assignGlobalpingIPVersion(options *globalping.MeasurementOptions, opts *GlobalpingOptions) { switch { case opts.IPv4 && !opts.IPv6: options.IPVersion = globalping.IPVersion4 case opts.IPv6 && !opts.IPv4: options.IPVersion = globalping.IPVersion6 } } func createGlobalpingMeasurement(ctx context.Context, client globalping.Client, req *globalping.MeasurementCreate) (*globalping.Measurement, error) { res, err := client.CreateMeasurement(req) if err != nil { return nil, err } return awaitGlobalpingMeasurement(ctx, client, res.ID) } func awaitGlobalpingMeasurement(ctx context.Context, client globalping.Client, id string) (*globalping.Measurement, error) { if ctx == nil { ctx = context.Background() } for { if err := ctx.Err(); err != nil { return nil, err } measurement, err := client.GetMeasurement(id) if err != nil { return nil, err } if measurement.Status != globalping.StatusInProgress { return measurement, nil } timer := time.NewTimer(500 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } } } func decodeGlobalpingMeasurementHops(measurement *globalping.Measurement) ([]globalping.MTRHop, error) { if measurement.Status != globalping.StatusFinished { return nil, fmt.Errorf("measurement did not complete successfully: %s", measurement.Status) } if len(measurement.Results) == 0 { return nil, fmt.Errorf("globalping measurement returned no probe results") } firstResult := measurement.Results[0] if len(firstResult.Result.HopsRaw) == 0 { return nil, fmt.Errorf("globalping measurement results did not include hop data") } return globalping.DecodeMTRHops(firstResult.Result.HopsRaw) } func resolveGlobalpingHopLimit(opts *GlobalpingOptions, config *Config, total int) int { limit := opts.MaxHops if limit <= 0 && config != nil && config.MaxHops > 0 { limit = config.MaxHops } if limit <= 0 || limit > total { return total } return limit } func buildGlobalpingResult(gpHops []globalping.MTRHop, limit int, config *Config) *Result { result := &Result{} geoMap := map[string]*ipgeo.IPGeoData{} maxTimings := maxGlobalpingTimings(gpHops, limit) for i := 0; i < limit; i++ { result.Hops = append(result.Hops, buildGlobalpingTTLHops(i+1, &gpHops[i], maxTimings, geoMap, config)) } return result } func maxGlobalpingTimings(gpHops []globalping.MTRHop, limit int) int { maxTimings := 1 for i := 0; i < limit; i++ { if count := len(gpHops[i].Timings); count > maxTimings { maxTimings = count } } return maxTimings } func buildGlobalpingTTLHops(ttl int, gpHop *globalping.MTRHop, maxTimings int, geoMap map[string]*ipgeo.IPGeoData, config *Config) []Hop { hops := make([]Hop, 0, maxTimings) for j := 0; j < maxTimings; j++ { hops = append(hops, mapGlobalpingHop(ttl, gpHop, globalpingTimingAt(gpHop, j), geoMap, config)) } return hops } func globalpingTimingAt(gpHop *globalping.MTRHop, index int) *globalping.MTRTiming { if index >= len(gpHop.Timings) { return nil } return &gpHop.Timings[index] } func mapGlobalpingHop(ttl int, gpHop *globalping.MTRHop, timing *globalping.MTRTiming, geoMap map[string]*ipgeo.IPGeoData, config *Config) Hop { resolvedHostname := "" if config != nil && config.RDNS { if raw := strings.TrimSpace(gpHop.ResolvedHostname); raw != "" { trimmed := strings.TrimSuffix(raw, ".") if net.ParseIP(trimmed) == nil { resolvedHostname = CanonicalHostname(trimmed) } } } hop := Hop{ Hostname: resolvedHostname, TTL: ttl, } if config != nil { hop.Lang = config.Lang } if gpHop.ResolvedAddress != "" { hop.Address = &net.IPAddr{ IP: net.ParseIP(gpHop.ResolvedAddress), } if geo, ok := geoMap[gpHop.ResolvedAddress]; ok { hop.Geo = geo } else if config != nil { _ = hop.fetchIPData(*config) geoMap[gpHop.ResolvedAddress] = hop.Geo } } if timing == nil { return hop } hop.Success = true hop.RTT = time.Duration(timing.RTT * float64(time.Millisecond)) return hop } func hasGlobalpingProbeLocation(probe globalping.ProbeDetails) bool { return probe.City != "" || probe.State != "" || probe.Country != "" || probe.Continent != "" || probe.Network != "" || probe.ASN != 0 } func formatGlobalpingCity(probe globalping.ProbeDetails) string { if probe.City != "" && probe.State != "" { return probe.City + " (" + probe.State + ")" } if probe.City != "" { return probe.City } return probe.State } func formatGlobalpingNetwork(probe globalping.ProbeDetails) string { network := strings.TrimSpace(probe.Network) if network != "" && probe.ASN != 0 { return network + " (AS" + fmt.Sprint(probe.ASN) + ")" } if network != "" { return network } if probe.ASN != 0 { return "(AS" + fmt.Sprint(probe.ASN) + ")" } return "" } func appendGlobalpingPart(parts []string, value string) []string { if value == "" { return parts } return append(parts, value) } func GlobalpingFormatLocation(m *globalping.ProbeMeasurement) string { if m == nil { return "" } probe := m.Probe if !hasGlobalpingProbeLocation(probe) { return "" } var parts []string parts = appendGlobalpingPart(parts, formatGlobalpingCity(probe)) parts = appendGlobalpingPart(parts, probe.Country) parts = appendGlobalpingPart(parts, probe.Continent) parts = appendGlobalpingPart(parts, formatGlobalpingNetwork(probe)) return strings.Join(parts, ", ") } ================================================ FILE: trace/globalping_test.go ================================================ //go:build !flavor_tiny && !flavor_ntr package trace import ( "context" "errors" "testing" "time" "github.com/jsdelivr/globalping-cli/globalping" ) type fakeGlobalpingClient struct { getMeasurement func(id string) (*globalping.Measurement, error) create func(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, error) } func (f fakeGlobalpingClient) CreateMeasurement(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, error) { return f.create(measurement) } func (f fakeGlobalpingClient) GetMeasurement(id string) (*globalping.Measurement, error) { return f.getMeasurement(id) } func (f fakeGlobalpingClient) AwaitMeasurement(id string) (*globalping.Measurement, error) { return f.getMeasurement(id) } func (f fakeGlobalpingClient) GetMeasurementRaw(id string) ([]byte, error) { panic("not implemented") } func (f fakeGlobalpingClient) Authorize(func(error)) (*globalping.AuthorizeResponse, error) { panic("not implemented") } func (f fakeGlobalpingClient) TokenIntrospection(string) (*globalping.IntrospectionResponse, error) { panic("not implemented") } func (f fakeGlobalpingClient) Logout() error { panic("not implemented") } func (f fakeGlobalpingClient) RevokeToken(string) error { panic("not implemented") } func (f fakeGlobalpingClient) Limits() (*globalping.LimitsResponse, error) { panic("not implemented") } func TestAwaitGlobalpingMeasurementReturnsCanceled(t *testing.T) { client := fakeGlobalpingClient{ getMeasurement: func(id string) (*globalping.Measurement, error) { return &globalping.Measurement{Status: globalping.StatusInProgress}, nil }, } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := awaitGlobalpingMeasurement(ctx, client, "m-1") done <- err }() cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("awaitGlobalpingMeasurement error = %v, want context.Canceled", err) } case <-time.After(100 * time.Millisecond): t.Fatal("awaitGlobalpingMeasurement did not return promptly after cancel") } } func TestCreateGlobalpingMeasurementHonorsCanceledContext(t *testing.T) { client := fakeGlobalpingClient{ create: func(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, error) { return &globalping.MeasurementCreateResponse{ID: "m-1"}, nil }, getMeasurement: func(id string) (*globalping.Measurement, error) { return &globalping.Measurement{Status: globalping.StatusInProgress}, nil }, } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := createGlobalpingMeasurement(ctx, client, &globalping.MeasurementCreate{}) done <- err }() cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("createGlobalpingMeasurement error = %v, want context.Canceled", err) } case <-time.After(100 * time.Millisecond): t.Fatal("createGlobalpingMeasurement did not return promptly after cancel") } } ================================================ FILE: trace/globalping_types.go ================================================ package trace // GlobalpingOptions configures a Globalping-based traceroute request. // Defined in an untagged file so all build flavors can reference the type // without pulling in the globalping-cli dependency. type GlobalpingOptions struct { Target string From string IPv4 bool IPv6 bool TCP bool UDP bool Port int Packets int MaxHops int DisableMaptrace bool DataOrigin string TablePrint bool ClearScreen bool ClassicPrint bool RawPrint bool JSONPrint bool } ================================================ FILE: trace/icmp_ipv4.go ================================================ package trace import ( "context" "errors" "fmt" "math/rand" "net" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/google/gopacket/layers" "golang.org/x/sync/semaphore" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) type ICMPTracer struct { Config wg sync.WaitGroup res Result echoID int pending map[int]struct{} pendingMu sync.Mutex sentAt map[int]time.Time sentMu sync.RWMutex SrcIP net.IP final atomic.Int32 sem *semaphore.Weighted matchQ chan matchTask readyICMP chan struct{} } func (t *ICMPTracer) waitAllReady(ctx context.Context) { timeout := time.After(5 * time.Second) waiting := 1 for waiting > 0 { select { case <-ctx.Done(): return case <-t.readyICMP: waiting-- case <-timeout: return } } <-time.After(100 * time.Millisecond) } func (t *ICMPTracer) ttlComp(ttl int) bool { idx := ttl - 1 t.res.lock.RLock() defer t.res.lock.RUnlock() return idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements } func (t *ICMPTracer) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) { defer t.wg.Done() ttl := t.BeginHop - 1 ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { if t.AsyncPrinter != nil { t.AsyncPrinter(&t.res) } // 接收的时候检查一下是不是 3 跳都齐了 if t.ttlComp(ttl + 1) { if t.RealtimePrinter != nil { t.res.waitGeo(ctx, ttl) t.RealtimePrinter(&t.res, ttl) } ttl++ if ttl == int(t.final.Load()) || ttl >= t.MaxHops { cancel(errNaturalDone) // 标记为“自然完成” return } } select { case <-ctx.Done(): return case <-ticker.C: } } } func (t *ICMPTracer) launchTTL(ctx context.Context, s *internal.ICMPSpec, ttl int) { go func(ttl int) { for i := 0; i < t.MaxAttempts; i++ { // 若此 TTL 已完成或 ctx 已取消,则不再发起新的尝试 if t.ttlComp(ttl) || ctx.Err() != nil { return } t.wg.Add(1) go func(ttl, i int) { if err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) { if util.EnvDevMode { panic(err) } fmt.Fprintf(os.Stderr, "send error (ttl=%d, attempt=%d): %v\n", ttl, i, err) } }(ttl, i) if i+1 == t.MaxAttempts { return } if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) { return } } }(ttl) } func (t *ICMPTracer) initEchoID() { // 设置随机种子 r := rand.New(rand.NewSource(time.Now().UnixNano())) // 生成一个 8 位的随机 tag echoIDTag := r.Intn(256) // 获取当前进程的 pid pid := os.Getpid() // 将随机 tag 编码到高 8 位;将 pid 的低 8 位编码到低 8 位 t.echoID = (echoIDTag << 8) | (pid & 0xFF) } func (t *ICMPTracer) markPending(seq int) { t.pendingMu.Lock() defer t.pendingMu.Unlock() t.pending[seq] = struct{}{} } func (t *ICMPTracer) clearPending(seq int) bool { t.pendingMu.Lock() defer t.pendingMu.Unlock() _, ok := t.pending[seq] delete(t.pending, seq) return ok } func (t *ICMPTracer) storeSent(seq int, start time.Time) { t.sentMu.Lock() defer t.sentMu.Unlock() t.sentAt[seq] = start } func (t *ICMPTracer) lookupSent(seq int) (start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() start, ok = t.sentAt[seq] if !ok { return time.Time{}, false } return start, true } func (t *ICMPTracer) dropSent(seq int) { t.sentMu.Lock() defer t.sentMu.Unlock() delete(t.sentAt, seq) } func (t *ICMPTracer) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) { if f := t.final.Load(); f != -1 && ttl > int(f) { return } if ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) { for { old := t.final.Load() if old != -1 && ttl >= int(old) { break } if t.final.CompareAndSwap(old, int32(ttl)) { break } } } h := Hop{ Success: true, Address: peer, TTL: ttl, RTT: rtt, MPLS: mpls, } t.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config) } func (t *ICMPTracer) matchWorker(ctx context.Context) { defer t.wg.Done() for { select { case <-ctx.Done(): return case task, ok := <-t.matchQ: if !ok { return } // 固定等待 10ms,缓解登记竞态 timer := time.NewTimer(10 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } timer.Stop() // 尝试一次匹配 start, ok := t.lookupSent(task.seq) if !ok { continue } // 将 task.seq 转为 16 位无符号数 u := uint16(task.seq) // 高 8 位是 TTL ttl := int((u >> 8) & 0xFF) // 低 8 位是索引 i i := int(u & 0xFF) if t.clearPending(task.seq) { rtt := task.finish.Sub(start) t.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls) } t.dropSent(task.seq) } } } func (t *ICMPTracer) Execute() (res *Result, err error) { // 初始化 Echo.ID t.initEchoID() // 初始化 pending、sentAt 和 matchQ t.pending = make(map[int]struct{}) t.sentAt = make(map[int]time.Time) t.matchQ = make(chan matchTask, 60) // 创建就绪通道 t.readyICMP = make(chan struct{}) if len(t.res.Hops) > 0 { return &t.res, errTracerouteExecuted } // 初始化 res.Hops 和 res.tailDone,并预分配到 MaxHops t.res.Hops = make([][]Hop, t.MaxHops) t.res.tailDone = make([]bool, t.MaxHops) t.res.setGeoWait(t.NumMeasurements) // 解析并校验用户指定的 IPv4 源地址 SrcAddr := net.ParseIP(t.SrcAddr).To4() if t.SrcAddr != "" && SrcAddr == nil { return nil, errors.New("invalid IPv4 SrcAddr:" + t.SrcAddr) } t.SrcIP, _ = util.LocalIPPort(t.DstIP, SrcAddr, "icmp") if t.SrcIP == nil { return nil, errors.New("cannot determine local IPv4 address") } s := internal.NewICMPSpec( 4, t.ICMPMode, t.echoID, t.SrcIP, t.DstIP, ) s.InitICMP() defer s.Close() baseCtx := t.Context if baseCtx == nil { baseCtx = context.Background() } sigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM) ctx, cancel := context.WithCancelCause(sigCtx) t.final.Store(-1) workerN := 16 for i := 0; i < workerN; i++ { t.wg.Add(1) go t.matchWorker(ctx) } t.wg.Add(1) go func() { defer t.wg.Done() s.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, seq int) { t.handleICMPMessage(msg, finish, seq) }, ) }() t.waitAllReady(ctx) t.wg.Add(1) go t.PrintFunc(ctx, cancel) t.sem = semaphore.NewWeighted(int64(t.ParallelRequests)) t.wg.Add(1) go func() { defer t.wg.Done() // 立即启动 BeginHop 对应的 TTL 组 t.launchTTL(ctx, s, t.BeginHop) for ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ { // 之后按 TTLInterval 周期启动后续 TTL 组 if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) { return } // 如果到达最终跳,则退出 if f := t.final.Load(); f != -1 && ttl > int(f) { return } // 并发启动这个 TTL 的所有测量 t.launchTTL(ctx, s, ttl) } }() <-ctx.Done() stop() t.wg.Wait() final := int(t.final.Load()) if final == -1 { final = t.MaxHops } t.res.reduce(final) if cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) { return &t.res, cause } return &t.res, nil } func (t *ICMPTracer) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, seq int) { mpls := extractMPLS(msg, t.DisableMPLS) // 非阻塞投递;如果队列已满则直接丢弃该任务 select { case t.matchQ <- matchTask{ seq: seq, peer: msg.Peer, finish: finish, mpls: mpls, }: default: // 丢弃以避免阻塞抓包循环 } } func (t *ICMPTracer) send(ctx context.Context, s *internal.ICMPSpec, ttl, i int) error { defer t.wg.Done() if t.ttlComp(ttl) { // 快路径短路:若该 TTL 已完成,直接返回避免竞争信号量与无谓发包 return nil } if err := acquireTraceSemaphore(ctx, t.sem); err != nil { return err } defer t.sem.Release(1) if f := t.final.Load(); f != -1 && ttl > int(f) { return nil } if t.ttlComp(ttl) { // 竞态兜底:获取信号量期间可能已完成,再次检查以避免冗余发包 return nil } // 将 TTL 编码到高 8 位;将索引 i 编码到低 8 位 seq := (ttl << 8) | (i & 0xFF) ipHeader := &layers.IPv4{ Version: 4, SrcIP: t.SrcIP, DstIP: t.DstIP, Protocol: layers.IPProtocolICMPv4, TTL: uint8(ttl), TOS: uint8(t.TOS), } icmpHeader := &layers.ICMPv4{ TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0), Id: uint16(t.echoID), Seq: uint16(seq), } desiredPayloadSize := resolveProbePayloadSize(ICMPTrace, t.DstIP, t.PktSize, t.RandomPacketSize) payload := make([]byte, desiredPayloadSize) if desiredPayloadSize >= 3 { copy(payload[desiredPayloadSize-3:], []byte{'n', 't', 'r'}) // "ntr" 作为标识 } // 登记 pending,并启动超时守护 t.markPending(seq) go func(seq, ttl, i int) { if !waitForTraceDelay(ctx, t.Timeout) { _ = t.clearPending(seq) return } if !t.clearPending(seq) { return } if f := t.final.Load(); f != -1 && ttl > int(f) { return } if t.ttlComp(ttl) { return } h := Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, Error: errHopLimitTimeout, } _, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts) t.dropSent(seq) }(seq, ttl, i) start, err := s.SendICMP(ctx, ipHeader, icmpHeader, nil, payload) if err != nil { _ = t.clearPending(seq) return err } t.storeSent(seq, start) return nil } ================================================ FILE: trace/icmp_ipv6.go ================================================ package trace import ( "context" "errors" "fmt" "math/rand" "net" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/google/gopacket/layers" "golang.org/x/sync/semaphore" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) type ICMPTracerv6 struct { Config wg sync.WaitGroup res Result echoID int pending map[int]struct{} pendingMu sync.Mutex sentAt map[int]time.Time sentMu sync.RWMutex SrcIP net.IP final atomic.Int32 sem *semaphore.Weighted matchQ chan matchTask readyICMP chan struct{} } func (t *ICMPTracerv6) waitAllReady(ctx context.Context) { timeout := time.After(5 * time.Second) waiting := 1 for waiting > 0 { select { case <-ctx.Done(): return case <-t.readyICMP: waiting-- case <-timeout: return } } <-time.After(100 * time.Millisecond) } func (t *ICMPTracerv6) ttlComp(ttl int) bool { idx := ttl - 1 t.res.lock.RLock() defer t.res.lock.RUnlock() return idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements } func (t *ICMPTracerv6) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) { defer t.wg.Done() ttl := t.BeginHop - 1 ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { if t.AsyncPrinter != nil { t.AsyncPrinter(&t.res) } // 接收的时候检查一下是不是 3 跳都齐了 if t.ttlComp(ttl + 1) { if t.RealtimePrinter != nil { t.res.waitGeo(ctx, ttl) t.RealtimePrinter(&t.res, ttl) } ttl++ if ttl == int(t.final.Load()) || ttl >= t.MaxHops { cancel(errNaturalDone) // 标记为“自然完成” return } } select { case <-ctx.Done(): return case <-ticker.C: } } } func (t *ICMPTracerv6) launchTTL(ctx context.Context, s *internal.ICMPSpec, ttl int) { go func(ttl int) { for i := 0; i < t.MaxAttempts; i++ { // 若此 TTL 已完成或 ctx 已取消,则不再发起新的尝试 if t.ttlComp(ttl) || ctx.Err() != nil { return } t.wg.Add(1) go func(ttl, i int) { if err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) { if util.EnvDevMode { panic(err) } fmt.Fprintf(os.Stderr, "send error (ttl=%d, attempt=%d): %v\n", ttl, i, err) } }(ttl, i) if i+1 == t.MaxAttempts { return } if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) { return } } }(ttl) } func (t *ICMPTracerv6) initEchoID() { // 设置随机种子 r := rand.New(rand.NewSource(time.Now().UnixNano())) // 生成一个 8 位的随机 tag echoIDTag := r.Intn(256) // 获取当前进程的 pid pid := os.Getpid() // 将随机 tag 编码到高 8 位;将 pid 的低 8 位编码到低 8 位 t.echoID = (echoIDTag << 8) | (pid & 0xFF) } func (t *ICMPTracerv6) markPending(seq int) { t.pendingMu.Lock() defer t.pendingMu.Unlock() t.pending[seq] = struct{}{} } func (t *ICMPTracerv6) clearPending(seq int) bool { t.pendingMu.Lock() defer t.pendingMu.Unlock() _, ok := t.pending[seq] delete(t.pending, seq) return ok } func (t *ICMPTracerv6) storeSent(seq int, start time.Time) { t.sentMu.Lock() defer t.sentMu.Unlock() t.sentAt[seq] = start } func (t *ICMPTracerv6) lookupSent(seq int) (start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() start, ok = t.sentAt[seq] if !ok { return time.Time{}, false } return start, true } func (t *ICMPTracerv6) dropSent(seq int) { t.sentMu.Lock() defer t.sentMu.Unlock() delete(t.sentAt, seq) } func (t *ICMPTracerv6) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) { if f := t.final.Load(); f != -1 && ttl > int(f) { return } if ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) { for { old := t.final.Load() if old != -1 && ttl >= int(old) { break } if t.final.CompareAndSwap(old, int32(ttl)) { break } } } h := Hop{ Success: true, Address: peer, TTL: ttl, RTT: rtt, MPLS: mpls, } t.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config) } func (t *ICMPTracerv6) matchWorker(ctx context.Context) { defer t.wg.Done() for { select { case <-ctx.Done(): return case task, ok := <-t.matchQ: if !ok { return } // 固定等待 10ms,缓解登记竞态 timer := time.NewTimer(10 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } timer.Stop() // 尝试一次匹配 start, ok := t.lookupSent(task.seq) if !ok { continue } // 将 task.seq 转为 16 位无符号数 u := uint16(task.seq) // 高 8 位是 TTL ttl := int((u >> 8) & 0xFF) // 低 8 位是索引 i i := int(u & 0xFF) if t.clearPending(task.seq) { rtt := task.finish.Sub(start) t.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls) } t.dropSent(task.seq) } } } func (t *ICMPTracerv6) Execute() (res *Result, err error) { // 初始化 Echo.ID t.initEchoID() // 初始化 pending、sentAt 和 matchQ t.pending = make(map[int]struct{}) t.sentAt = make(map[int]time.Time) t.matchQ = make(chan matchTask, 60) // 创建就绪通道 t.readyICMP = make(chan struct{}) if len(t.res.Hops) > 0 { return &t.res, errTracerouteExecuted } // 初始化 res.Hops 和 res.tailDone,并预分配到 MaxHops t.res.Hops = make([][]Hop, t.MaxHops) t.res.tailDone = make([]bool, t.MaxHops) t.res.setGeoWait(t.NumMeasurements) // 解析并校验用户指定的 IPv6 源地址 SrcAddr := net.ParseIP(t.SrcAddr) if t.SrcAddr != "" && !util.IsIPv6(SrcAddr) { return nil, errors.New("invalid IPv6 SrcAddr: " + t.SrcAddr) } t.SrcIP, _ = util.LocalIPPortv6(t.DstIP, SrcAddr, "icmp6") if t.SrcIP == nil { return nil, errors.New("cannot determine local IPv6 address") } s := internal.NewICMPSpec( 6, t.ICMPMode, t.echoID, t.SrcIP, t.DstIP, ) s.InitICMP() defer s.Close() baseCtx := t.Context if baseCtx == nil { baseCtx = context.Background() } sigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM) ctx, cancel := context.WithCancelCause(sigCtx) t.final.Store(-1) workerN := 16 for i := 0; i < workerN; i++ { t.wg.Add(1) go t.matchWorker(ctx) } t.wg.Add(1) go func() { defer t.wg.Done() s.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, seq int) { t.handleICMPMessage(msg, finish, seq) }, ) }() t.waitAllReady(ctx) t.wg.Add(1) go t.PrintFunc(ctx, cancel) t.sem = semaphore.NewWeighted(int64(t.ParallelRequests)) t.wg.Add(1) go func() { defer t.wg.Done() // 立即启动 BeginHop 对应的 TTL 组 t.launchTTL(ctx, s, t.BeginHop) for ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ { // 之后按 TTLInterval 周期启动后续 TTL 组 if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) { return } // 如果到达最终跳,则退出 if f := t.final.Load(); f != -1 && ttl > int(f) { return } // 并发启动这个 TTL 的所有测量 t.launchTTL(ctx, s, ttl) } }() <-ctx.Done() stop() t.wg.Wait() final := int(t.final.Load()) if final == -1 { final = t.MaxHops } t.res.reduce(final) if cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) { return &t.res, cause } return &t.res, nil } func (t *ICMPTracerv6) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, seq int) { mpls := extractMPLS(msg, t.DisableMPLS) // 非阻塞投递;如果队列已满则直接丢弃该任务 select { case t.matchQ <- matchTask{ seq: seq, peer: msg.Peer, finish: finish, mpls: mpls, }: default: // 丢弃以避免阻塞抓包循环 } } func (t *ICMPTracerv6) send(ctx context.Context, s *internal.ICMPSpec, ttl, i int) error { defer t.wg.Done() if t.ttlComp(ttl) { // 快路径短路:若该 TTL 已完成,直接返回避免竞争信号量与无谓发包 return nil } if err := acquireTraceSemaphore(ctx, t.sem); err != nil { return err } defer t.sem.Release(1) if f := t.final.Load(); f != -1 && ttl > int(f) { return nil } if t.ttlComp(ttl) { // 竞态兜底:获取信号量期间可能已完成,再次检查以避免冗余发包 return nil } // 将 TTL 编码到高 8 位;将索引 i 编码到低 8 位 seq := (ttl << 8) | (i & 0xFF) ipHeader := &layers.IPv6{ Version: 6, SrcIP: t.SrcIP, DstIP: t.DstIP, NextHeader: layers.IPProtocolICMPv6, HopLimit: uint8(ttl), TrafficClass: uint8(t.TOS), } icmpHeader := &layers.ICMPv6{ TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0), } icmpEcho := &layers.ICMPv6Echo{ Identifier: uint16(t.echoID), SeqNumber: uint16(seq), } desiredPayloadSize := resolveProbePayloadSize(ICMPTrace, t.DstIP, t.PktSize, t.RandomPacketSize) payload := make([]byte, desiredPayloadSize) if desiredPayloadSize >= 3 { copy(payload[desiredPayloadSize-3:], []byte{'n', 't', 'r'}) // "ntr" 作为标识 } // 登记 pending,并启动超时守护 t.markPending(seq) go func(seq, ttl, i int) { if !waitForTraceDelay(ctx, t.Timeout) { _ = t.clearPending(seq) return } if !t.clearPending(seq) { return } if f := t.final.Load(); f != -1 && ttl > int(f) { return } if t.ttlComp(ttl) { return } h := Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, Error: errHopLimitTimeout, } _, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts) t.dropSent(seq) }(seq, ttl, i) start, err := s.SendICMP(ctx, ipHeader, icmpHeader, icmpEcho, payload) if err != nil { _ = t.clearPending(seq) return err } t.storeSent(seq, start) return nil } ================================================ FILE: trace/internal/icmp_common.go ================================================ package internal import ( "context" "fmt" "log" "net" "time" "github.com/google/gopacket" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "github.com/nxtrace/NTrace-core/util" ) type ipLayer interface { gopacket.NetworkLayer gopacket.SerializableLayer } func NewICMPSpec(IPVersion, ICMPMode, echoID int, srcIP, dstIP net.IP) *ICMPSpec { return &ICMPSpec{IPVersion: IPVersion, ICMPMode: ICMPMode, EchoID: echoID, SrcIP: srcIP, DstIP: dstIP} } func (s *ICMPSpec) InitICMP() { network := "ip4:icmp" if s.IPVersion == 6 { network = "ip6:ipv6-icmp" } icmpConn, err := ListenPacket(network, s.SrcIP.String()) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitICMP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err)) } log.Fatalf("(InitICMP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err) } s.icmp = icmpConn if s.IPVersion == 4 { s.icmp4 = ipv4.NewPacketConn(s.icmp) } else { s.icmp6 = ipv6.NewPacketConn(s.icmp) } } func (s *ICMPSpec) listenICMPSock(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) { lc := NewPacketListener(s.icmp) go lc.Start(ctx) close(ready) for { select { case <-ctx.Done(): return case msg, ok := <-lc.Messages: if !ok { return } finish, seq, ok := s.decodeICMPSocketMessage(msg) if ok { onICMP(msg, finish, seq) } } } } func (s *ICMPSpec) decodeICMPSocketMessage(msg ReceivedMessage) (time.Time, int, bool) { if msg.Err != nil { return time.Time{}, 0, false } finish := time.Now() rm, ok := parseSocketICMPMessage(s.IPVersion, msg.Msg) if !ok { return finish, 0, false } if seq, ok := matchSocketICMPEchoReply(s.IPVersion, rm, util.AddrIP(msg.Peer), s.DstIP, s.EchoID); ok { return finish, seq, true } data, ok := extractSocketICMPPayload(s.IPVersion, rm, s.DstIP) if !ok { return finish, 0, false } seq, ok := extractEmbeddedICMPSeq(data, s.EchoID) return finish, seq, ok } ================================================ FILE: trace/internal/icmp_darwin.go ================================================ //go:build darwin package internal import ( "context" "errors" "fmt" "log" "net" "os" "sync" "syscall" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/nxtrace/NTrace-core/util" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) type ICMPSpec struct { IPVersion int ICMPMode int EchoID int SrcIP net.IP DstIP net.IP icmp net.PacketConn icmp4 *ipv4.PacketConn icmp6 *ipv6.PacketConn hopLimitLock sync.Mutex } // --------------------------------------------------------------------------- // icmpPacketConn 将 ICMP DGRAM socket 包装为 net.PacketConn, // 通过 os.File + syscall.RawConn 正确集成到 Go 运行时 poller, // 同时保持 ICMP 语义(ReadFrom 返回 *net.IPAddr)。 // 这样可完全避免 //go:linkname 依赖。 // --------------------------------------------------------------------------- type icmpPacketConn struct { file *os.File rc syscall.RawConn af int // syscall.AF_INET or syscall.AF_INET6 } // 编译期断言:icmpPacketConn 实现 net.PacketConn + net.Conn + syscall.Conn var ( _ net.PacketConn = (*icmpPacketConn)(nil) _ net.Conn = (*icmpPacketConn)(nil) _ syscall.Conn = (*icmpPacketConn)(nil) ) func (c *icmpPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { var ( n int addr net.Addr readErr error ) err := c.rc.Read(func(fd uintptr) bool { var sa syscall.Sockaddr n, sa, readErr = syscall.Recvfrom(int(fd), b, 0) if readErr == syscall.EAGAIN || readErr == syscall.EWOULDBLOCK { return false // 未就绪,让 poller 继续等待 } if sa != nil { switch s := sa.(type) { case *syscall.SockaddrInet4: ip := make(net.IP, 4) copy(ip, s.Addr[:]) addr = &net.IPAddr{IP: ip} case *syscall.SockaddrInet6: ip := make(net.IP, 16) copy(ip, s.Addr[:]) addr = &net.IPAddr{IP: ip, Zone: zoneToName(s.ZoneId)} } } return true }) if err != nil { return 0, nil, err } if readErr != nil { return 0, nil, readErr } // macOS DGRAM ICMP socket 返回数据包含外层 IP 头; // 模拟 net.IPConn.ReadFrom 行为,将其剥离以保持与解析层兼容。 if c.af == syscall.AF_INET { n = stripIPv4Header(n, b) } return n, addr, nil } func (c *icmpPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) { sa, err := addrToSockaddr(addr, c.af) if err != nil { return 0, err } var writeErr error err = c.rc.Write(func(fd uintptr) bool { writeErr = syscall.Sendto(int(fd), b, 0, sa) if writeErr == syscall.EAGAIN || writeErr == syscall.EWOULDBLOCK { return false } return true }) if err != nil { return 0, err } if writeErr != nil { return 0, writeErr } return len(b), nil } func (c *icmpPacketConn) Close() error { return c.file.Close() } func (c *icmpPacketConn) LocalAddr() net.Addr { if c.af == syscall.AF_INET6 { return &net.IPAddr{IP: net.IPv6zero} } return &net.IPAddr{IP: net.IPv4zero} } func (c *icmpPacketConn) RemoteAddr() net.Addr { return nil } func (c *icmpPacketConn) SetDeadline(t time.Time) error { return c.file.SetDeadline(t) } func (c *icmpPacketConn) SetReadDeadline(t time.Time) error { return c.file.SetReadDeadline(t) } func (c *icmpPacketConn) SetWriteDeadline(t time.Time) error { return c.file.SetWriteDeadline(t) } // Read 实现 net.Conn 接口(ipv4.NewPacketConn 内部需要)。 // 对于无连接 ICMP socket,Read 等价于 ReadFrom 但丢弃源地址。 func (c *icmpPacketConn) Read(b []byte) (int, error) { n, _, err := c.ReadFrom(b) return n, err } // Write 实现 net.Conn 接口。对于无连接 socket 不可用。 func (c *icmpPacketConn) Write(b []byte) (int, error) { return 0, errors.New("Write not supported on unconnected ICMP socket; use WriteTo") } // ReadMsgIP 实现 x/net/internal/socket.ipConn 接口,使得 // socket.NewConn 能识别此连接为 "ip" 类型,从而正确初始化 socket.Conn。 // NTrace 不实际调用此方法(读取走 ReadFrom / PacketListener),仅为接口满足。 func (c *icmpPacketConn) ReadMsgIP(b, oob []byte) (n, oobn, flags int, addr *net.IPAddr, err error) { var rn int var rAddr *net.IPAddr var readErr error err = c.rc.Read(func(fd uintptr) bool { var sa syscall.Sockaddr rn, _, readErr, sa = recvmsgRaw(int(fd), b, oob) if readErr == syscall.EAGAIN || readErr == syscall.EWOULDBLOCK { return false } if sa != nil { switch s := sa.(type) { case *syscall.SockaddrInet4: ip := make(net.IP, 4) copy(ip, s.Addr[:]) rAddr = &net.IPAddr{IP: ip} case *syscall.SockaddrInet6: ip := make(net.IP, 16) copy(ip, s.Addr[:]) rAddr = &net.IPAddr{IP: ip, Zone: zoneToName(s.ZoneId)} } } return true }) if err != nil { return 0, 0, 0, nil, err } if readErr != nil { return 0, 0, 0, nil, readErr } return rn, 0, 0, rAddr, nil } // recvmsgRaw 使用 Recvfrom 实现简化版 recvmsg(不处理 OOB/control message)。 // 对于 ICMP DGRAM socket,内核不会为我们生成 IP 头 control message, // 因此 OOB 数据始终为空。 func recvmsgRaw(fd int, b, oob []byte) (n, oobn int, err error, sa syscall.Sockaddr) { n, sa, err = syscall.Recvfrom(fd, b, 0) return n, 0, err, sa } // SyscallConn 让 ipv4.NewPacketConn / ipv6.NewPacketConn 能通过 // setsockopt 设置 IP_TTL / IPV6_UNICAST_HOPS 等选项。 func (c *icmpPacketConn) SyscallConn() (syscall.RawConn, error) { return c.rc, nil } // addrToSockaddr 将 net.Addr 转换为 syscall.Sockaddr func addrToSockaddr(addr net.Addr, af int) (syscall.Sockaddr, error) { var ip net.IP switch a := addr.(type) { case *net.IPAddr: ip = a.IP case *net.UDPAddr: ip = a.IP default: return nil, fmt.Errorf("icmpPacketConn: unsupported addr type %T", addr) } if af == syscall.AF_INET { sa := &syscall.SockaddrInet4{} copy(sa.Addr[:], ip.To4()) return sa, nil } sa := &syscall.SockaddrInet6{} copy(sa.Addr[:], ip.To16()) return sa, nil } // stripIPv4Header 剥离 macOS DGRAM ICMP socket 返回数据中的 IPv4 头。 // 逻辑与 Go 标准库 net.stripIPv4Header 一致(iprawsock_posix.go)。 func stripIPv4Header(n int, b []byte) int { if len(b) < 20 { return n } l := int(b[0]&0x0f) << 2 if 20 > l || l > len(b) { return n } if b[0]>>4 != 4 { return n } copy(b, b[l:]) return n - l } // zoneToName 将 IPv6 zone ID 转换为接口名 func zoneToName(idx uint32) string { if idx == 0 { return "" } iface, err := net.InterfaceByIndex(int(idx)) if err != nil { return "" } return iface.Name } var ( errUnknownNetwork = errors.New("unknown network type") errUnknownIface = errors.New("unknown network interface") networkMap = map[string]string{ "ip4:icmp": "udp4", "ip4:1": "udp4", "ip6:ipv6-icmp": "udp6", "ip6:58": "udp6", } ) type darwinICMPSocketSpec struct { af int proto int } func darwinICMPSocketSpecForNetwork(network string) (darwinICMPSocketSpec, error) { nw, ok := networkMap[network] if !ok { return darwinICMPSocketSpec{}, errUnknownNetwork } if nw == "udp6" { return darwinICMPSocketSpec{af: syscall.AF_INET6, proto: syscall.IPPROTO_ICMPV6}, nil } return darwinICMPSocketSpec{af: syscall.AF_INET, proto: syscall.IPPROTO_ICMP}, nil } func mustOpenDarwinICMPSocket(spec darwinICMPSocketSpec) int { fd, err := syscall.Socket(spec.af, syscall.SOCK_DGRAM, spec.proto) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("ListenPacket: socket: %w", err)) } log.Fatalf("ListenPacket: socket: %v", err) } return fd } func interfaceHasIP(iface net.Interface, target net.IP) bool { addrs, err := iface.Addrs() if err != nil { return false } for _, addr := range addrs { ipnet, ok := addr.(*net.IPNet) if ok && ipnet.IP.Equal(target) { return true } } return false } func interfaceIndexByIP(ip net.IP) (int, error) { ifaces, err := net.Interfaces() if err != nil { return -1, err } for _, iface := range ifaces { if interfaceHasIP(iface, ip) { return iface.Index, nil } } return -1, errUnknownIface } func setDarwinBoundInterface(fd, proto, ifIndex int) error { if proto == syscall.IPPROTO_ICMP { if err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_BOUND_IF, ifIndex); err != nil { return fmt.Errorf("setsockopt IP_BOUND_IF: %w", err) } return nil } if err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IPV6, syscall.IPV6_BOUND_IF, ifIndex); err != nil { return fmt.Errorf("setsockopt IPV6_BOUND_IF: %w", err) } return nil } func bindDarwinICMPInterface(fd, proto int, laddr string) error { if laddr == "" { return nil } ifIndex, err := interfaceIndexByIP(net.ParseIP(laddr)) if err != nil { return err } return setDarwinBoundInterface(fd, proto, ifIndex) } func darwinICMPBindSockaddr(af int, laddr string) syscall.Sockaddr { if af == syscall.AF_INET { bindAddr := &syscall.SockaddrInet4{} if ip4 := net.ParseIP(laddr).To4(); ip4 != nil { copy(bindAddr.Addr[:], ip4) } return bindAddr } bindAddr := &syscall.SockaddrInet6{} if ip6 := net.ParseIP(laddr).To16(); ip6 != nil { copy(bindAddr.Addr[:], ip6) } return bindAddr } func bindDarwinICMPSocket(fd, af int, laddr string) error { if err := syscall.Bind(fd, darwinICMPBindSockaddr(af, laddr)); err != nil { return fmt.Errorf("bind: %w", err) } return nil } func finalizeDarwinICMPSocket(fd, af int) (net.PacketConn, error) { if err := syscall.SetNonblock(fd, true); err != nil { _ = syscall.Close(fd) return nil, fmt.Errorf("setnonblock: %w", err) } f := os.NewFile(uintptr(fd), "icmp") if f == nil { _ = syscall.Close(fd) return nil, fmt.Errorf("os.NewFile returned nil") } rc, err := f.SyscallConn() if err != nil { _ = f.Close() if util.EnvDevMode { panic(fmt.Errorf("ListenPacket: SyscallConn: %w", err)) } log.Fatalf("ListenPacket: SyscallConn: %v", err) } return &icmpPacketConn{file: f, rc: rc, af: af}, nil } func ListenPacket(network string, laddr string) (net.PacketConn, error) { spec, err := darwinICMPSocketSpecForNetwork(network) if err != nil { return nil, err } fd := mustOpenDarwinICMPSocket(spec) if err := bindDarwinICMPInterface(fd, spec.proto, laddr); err != nil { _ = syscall.Close(fd) return nil, err } if err := bindDarwinICMPSocket(fd, spec.af, laddr); err != nil { _ = syscall.Close(fd) return nil, err } return finalizeDarwinICMPSocket(fd, spec.af) } func (s *ICMPSpec) Close() { _ = s.icmp.Close() } func (s *ICMPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) { s.listenICMPSock(ctx, ready, onICMP) } func (s *ICMPSpec) SendICMP(ctx context.Context, ipHdr gopacket.NetworkLayer, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if s.IPVersion == 4 { ip4, ok := ipHdr.(*layers.IPv4) if !ok || ip4 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.IPv4 when s.IPVersion==4") } ttl := int(ip4.TTL) buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 ICMP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, icmpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 TTL + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.icmp4.SetTOS(int(ip4.TOS)); err != nil { return time.Time{}, err } if err := s.icmp4.SetTTL(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ip6, ok := ipHdr.(*layers.IPv6) if !ok || ip6 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.IPv6 when s.IPVersion==6") } ttl := int(ip6.HopLimit) ic6, ok := icmpHdr.(*layers.ICMPv6) if !ok || ic6 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.ICMPv6 when s.IPVersion==6") } if err := ic6.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 ICMP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 HopLimit + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.icmp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil { return time.Time{}, err } if err := s.icmp6.SetHopLimit(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ================================================ FILE: trace/internal/icmp_decode.go ================================================ package internal import ( "net" "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "github.com/nxtrace/NTrace-core/util" ) func parseSocketICMPMessage(ipVersion int, raw []byte) (*icmp.Message, bool) { protocol := 1 if ipVersion == 6 { protocol = 58 } rm, err := icmp.ParseMessage(protocol, raw) if err != nil { return nil, false } return rm, true } func matchSocketICMPEchoReply(ipVersion int, rm *icmp.Message, peerIP, dstIP net.IP, echoID int) (int, bool) { if peerIP == nil || !peerIP.Equal(dstIP) { return 0, false } if !isSocketICMPEchoReply(ipVersion, rm) { return 0, false } echo, ok := rm.Body.(*icmp.Echo) if !ok || echo == nil || echo.ID != echoID { return 0, false } return echo.Seq, true } func isSocketICMPEchoReply(ipVersion int, rm *icmp.Message) bool { switch ipVersion { case 4: return rm.Type == ipv4.ICMPTypeEchoReply case 6: return rm.Type == ipv6.ICMPTypeEchoReply default: return false } } func extractSocketICMPPayload(ipVersion int, rm *icmp.Message, dstIP net.IP) ([]byte, bool) { data, ok := extractSocketICMPErrorBody(ipVersion, rm) if !ok || !matchesEmbeddedDstIP(ipVersion, data, dstIP) { return nil, false } return data, true } func extractSocketICMPErrorBody(ipVersion int, rm *icmp.Message) ([]byte, bool) { switch ipVersion { case 4: return extractSocketICMPv4Body(rm) case 6: return extractSocketICMPv6Body(rm) default: return nil, false } } func extractSocketICMPv4Body(rm *icmp.Message) ([]byte, bool) { switch rm.Type { case ipv4.ICMPTypeTimeExceeded: body, ok := rm.Body.(*icmp.TimeExceeded) return icmpTimeExceededData(body, ok) case ipv4.ICMPTypeDestinationUnreachable: body, ok := rm.Body.(*icmp.DstUnreach) return icmpDstUnreachData(body, ok) default: return nil, false } } func extractSocketICMPv6Body(rm *icmp.Message) ([]byte, bool) { switch rm.Type { case ipv6.ICMPTypeTimeExceeded: body, ok := rm.Body.(*icmp.TimeExceeded) return icmpTimeExceededData(body, ok) case ipv6.ICMPTypePacketTooBig: body, ok := rm.Body.(*icmp.PacketTooBig) return icmpPacketTooBigData(body, ok) case ipv6.ICMPTypeDestinationUnreachable: body, ok := rm.Body.(*icmp.DstUnreach) return icmpDstUnreachData(body, ok) default: return nil, false } } func icmpTimeExceededData(body *icmp.TimeExceeded, ok bool) ([]byte, bool) { if !ok || body == nil { return nil, false } return body.Data, true } func icmpDstUnreachData(body *icmp.DstUnreach, ok bool) ([]byte, bool) { if !ok || body == nil { return nil, false } return body.Data, true } func icmpPacketTooBigData(body *icmp.PacketTooBig, ok bool) ([]byte, bool) { if !ok || body == nil { return nil, false } return body.Data, true } func matchesEmbeddedDstIP(ipVersion int, data []byte, dstIP net.IP) bool { embeddedDstIP, ok := extractEmbeddedDstIP(ipVersion, data) if !ok { return false } return embeddedDstIP.Equal(dstIP) } func extractEmbeddedDstIP(ipVersion int, data []byte) (net.IP, bool) { switch ipVersion { case 4: if len(data) < 20 || data[0]>>4 != 4 { return nil, false } return net.IP(data[16:20]), true case 6: if len(data) < 40 || data[0]>>4 != 6 { return nil, false } return net.IP(data[24:40]), true default: return nil, false } } func extractEmbeddedICMPSeq(data []byte, echoID int) (int, bool) { header, err := util.GetICMPResponsePayload(data) if err != nil { return 0, false } id, err := util.GetICMPID(header) if err != nil || id != echoID { return 0, false } seq, err := util.GetICMPSeq(header) if err != nil { return 0, false } return seq, true } ================================================ FILE: trace/internal/icmp_decode_test.go ================================================ package internal import ( "encoding/binary" "net" "testing" "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) func mustMarshalICMP(t *testing.T, message icmp.Message) []byte { t.Helper() raw, err := message.Marshal(nil) if err != nil { t.Fatalf("Marshal() error = %v", err) } return raw } func buildIPv4InnerPacket(dstIP net.IP, echoID, seq int) []byte { packet := make([]byte, 28) packet[0] = 0x45 copy(packet[16:20], dstIP.To4()) binary.BigEndian.PutUint16(packet[24:26], uint16(echoID)) binary.BigEndian.PutUint16(packet[26:28], uint16(seq)) return packet } func buildIPv6InnerPacket(dstIP net.IP, echoID, seq int) []byte { packet := make([]byte, 48) packet[0] = 0x60 packet[6] = 58 copy(packet[24:40], dstIP.To16()) binary.BigEndian.PutUint16(packet[44:46], uint16(echoID)) binary.BigEndian.PutUint16(packet[46:48], uint16(seq)) return packet } func TestMatchSocketICMPEchoReplyIPv4(t *testing.T) { dstIP := net.ParseIP("1.1.1.1") raw := mustMarshalICMP(t, icmp.Message{ Type: ipv4.ICMPTypeEchoReply, Code: 0, Body: &icmp.Echo{ID: 7, Seq: 11}, }) rm, ok := parseSocketICMPMessage(4, raw) if !ok { t.Fatalf("parseSocketICMPMessage() ok = false") } seq, ok := matchSocketICMPEchoReply(4, rm, dstIP, dstIP, 7) if !ok || seq != 11 { t.Fatalf("matchSocketICMPEchoReply() = (%d, %v), want (11, true)", seq, ok) } } func TestMatchSocketICMPEchoReplyIPv6(t *testing.T) { dstIP := net.ParseIP("2001:db8::1") raw := mustMarshalICMP(t, icmp.Message{ Type: ipv6.ICMPTypeEchoReply, Code: 0, Body: &icmp.Echo{ID: 9, Seq: 21}, }) rm, ok := parseSocketICMPMessage(6, raw) if !ok { t.Fatalf("parseSocketICMPMessage() ok = false") } seq, ok := matchSocketICMPEchoReply(6, rm, dstIP, dstIP, 9) if !ok || seq != 21 { t.Fatalf("matchSocketICMPEchoReply() = (%d, %v), want (21, true)", seq, ok) } } func TestExtractSocketICMPPayloadIPv4(t *testing.T) { dstIP := net.ParseIP("8.8.8.8") inner := buildIPv4InnerPacket(dstIP, 13, 99) raw := mustMarshalICMP(t, icmp.Message{ Type: ipv4.ICMPTypeTimeExceeded, Code: 0, Body: &icmp.TimeExceeded{Data: inner}, }) rm, ok := parseSocketICMPMessage(4, raw) if !ok { t.Fatalf("parseSocketICMPMessage() ok = false") } data, ok := extractSocketICMPPayload(4, rm, dstIP) if !ok { t.Fatalf("extractSocketICMPPayload() ok = false") } if seq, ok := extractEmbeddedICMPSeq(data, 13); !ok || seq != 99 { t.Fatalf("extractEmbeddedICMPSeq() = (%d, %v), want (99, true)", seq, ok) } } func TestExtractSocketICMPPayloadIPv6(t *testing.T) { dstIP := net.ParseIP("2001:db8::2") inner := buildIPv6InnerPacket(dstIP, 17, 123) raw := mustMarshalICMP(t, icmp.Message{ Type: ipv6.ICMPTypeDestinationUnreachable, Code: 0, Body: &icmp.DstUnreach{Data: inner}, }) rm, ok := parseSocketICMPMessage(6, raw) if !ok { t.Fatalf("parseSocketICMPMessage() ok = false") } data, ok := extractSocketICMPPayload(6, rm, dstIP) if !ok { t.Fatalf("extractSocketICMPPayload() ok = false") } if seq, ok := extractEmbeddedICMPSeq(data, 17); !ok || seq != 123 { t.Fatalf("extractEmbeddedICMPSeq() = (%d, %v), want (123, true)", seq, ok) } } func TestExtractSocketICMPPayloadRejectsWrongDestination(t *testing.T) { raw := mustMarshalICMP(t, icmp.Message{ Type: ipv4.ICMPTypeDestinationUnreachable, Code: 0, Body: &icmp.DstUnreach{Data: buildIPv4InnerPacket(net.ParseIP("9.9.9.9"), 3, 5)}, }) rm, ok := parseSocketICMPMessage(4, raw) if !ok { t.Fatalf("parseSocketICMPMessage() ok = false") } if _, ok := extractSocketICMPPayload(4, rm, net.ParseIP("8.8.8.8")); ok { t.Fatalf("extractSocketICMPPayload() ok = true, want false") } } ================================================ FILE: trace/internal/icmp_unix.go ================================================ //go:build !darwin && !(windows && amd64) package internal import ( "context" "errors" "fmt" "net" "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) type ICMPSpec struct { IPVersion int ICMPMode int EchoID int SrcIP net.IP DstIP net.IP icmp net.PacketConn icmp4 *ipv4.PacketConn icmp6 *ipv6.PacketConn hopLimitLock sync.Mutex } func ListenPacket(network string, laddr string) (net.PacketConn, error) { return net.ListenPacket(network, laddr) } func (s *ICMPSpec) Close() { _ = s.icmp.Close() } func (s *ICMPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) { s.listenICMPSock(ctx, ready, onICMP) } func (s *ICMPSpec) SendICMP(ctx context.Context, ipHdr gopacket.NetworkLayer, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if s.IPVersion == 4 { ip4, ok := ipHdr.(*layers.IPv4) if !ok || ip4 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.IPv4 when s.IPVersion==4") } ttl := int(ip4.TTL) buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 ICMP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, icmpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 TTL + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.icmp4.SetTOS(int(ip4.TOS)); err != nil { return time.Time{}, err } if err := s.icmp4.SetTTL(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ip6, ok := ipHdr.(*layers.IPv6) if !ok || ip6 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.IPv6 when s.IPVersion==6") } ttl := int(ip6.HopLimit) ic6, ok := icmpHdr.(*layers.ICMPv6) if !ok || ic6 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.ICMPv6 when s.IPVersion==6") } if err := ic6.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 ICMP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 HopLimit + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.icmp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil { return time.Time{}, err } if err := s.icmp6.SetHopLimit(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ================================================ FILE: trace/internal/icmp_windows.go ================================================ //go:build windows && amd64 package internal import ( "context" "errors" "fmt" "log" "net" "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/nxtrace/NTrace-core/util" wd "github.com/xjasonlyu/windivert-go" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) type ICMPSpec struct { IPVersion int ICMPMode int EchoID int SrcIP net.IP DstIP net.IP icmp net.PacketConn icmp4 *ipv4.PacketConn icmp6 *ipv6.PacketConn sendHandle wd.Handle sendAddr wd.Address hopLimitLock sync.Mutex } func ListenPacket(network string, laddr string) (net.PacketConn, error) { return net.ListenPacket(network, laddr) } func (s *ICMPSpec) Close() { _ = s.icmp.Close() if s.sendHandle != 0 { _ = s.sendHandle.Close() } } // winDivertAvailable 通过尝试打开一个 WinDivert 嗅探 handle 来判断 WinDivert 是否可用 func winDivertAvailable() (bool, error) { h, err := wd.Open("false", wd.LayerNetwork, 0, wd.FlagSniff|wd.FlagRecvOnly) if err != nil { return false, fmt.Errorf("WinDivert 不可用: %v", err) } _ = h.Close() return true, nil } // resolveICMPMode 进行最终模式判定 // 1=Socket, 2=WinDivert (嗅探模式,原 PCAP 模式的替代) func (s *ICMPSpec) resolveICMPMode() int { icmpMode := s.ICMPMode if icmpMode != 1 && icmpMode != 2 { icmpMode = 0 // 统一成 Auto } // 指定 1=Socket:直接返回 if icmpMode == 1 { return 1 } // Auto(0) 或强制 Sniff(2) → 尝试 WinDivert if !util.HasAdminPrivileges() { if icmpMode == 2 { log.Printf("请求使用 WinDivert 嗅探模式,但当前缺少管理员权限;已回退到 Socket 模式。") } return 1 } ok, err := winDivertAvailable() if !ok { if icmpMode == 2 { log.Printf("请求使用 WinDivert 嗅探模式,但 WinDivert 不可用: %v;已回退到 Socket 模式。", err) } return 1 } return 2 } func (s *ICMPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) { switch s.resolveICMPMode() { case 1: s.listenICMPSock(ctx, ready, onICMP) case 2: s.listenICMPWinDivert(ctx, ready, onICMP) } } func (s *ICMPSpec) listenICMPWinDivert(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) { handle, closeHandle := openWinDivertSniffHandle(ctx, winDivertICMPFilter(s.IPVersion, s.SrcIP), "ListenICMP") defer closeHandle() close(ready) buf := make([]byte, 65535) var addr wd.Address for { raw, finish, ok := receiveWinDivertPacket(ctx, handle, buf, &addr) if !ok { if ctx.Err() != nil { return } continue } packet, ok := decodeWinDivertICMPPacket(s.IPVersion, raw) if !ok { continue } msg := packet.message() if seq, ok := packet.echoReplyFor(s.DstIP, s.EchoID); ok { onICMP(msg, finish, seq) continue } data, ok := packet.errorPayloadFor(s.DstIP) if !ok { continue } if seq, ok := extractEmbeddedICMPSeq(data, s.EchoID); ok { onICMP(msg, finish, seq) } } } func (s *ICMPSpec) SendICMP(ctx context.Context, ipHdr gopacket.NetworkLayer, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if s.IPVersion == 4 { ip4, ok := ipHdr.(*layers.IPv4) if !ok || ip4 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.IPv4 when s.IPVersion==4") } ttl := int(ip4.TTL) buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 ICMP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, icmpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 TTL + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.icmp4.SetTOS(int(ip4.TOS)); err != nil { return time.Time{}, err } if err := s.icmp4.SetTTL(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ip6, ok := ipHdr.(*layers.IPv6) if !ok || ip6 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.IPv6 when s.IPVersion==6") } ttl := int(ip6.HopLimit) ic6, ok := icmpHdr.(*layers.ICMPv6) if !ok || ic6 == nil { return time.Time{}, errors.New("SendICMP: expect *layers.ICMPv6 when s.IPVersion==6") } if err := ic6.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } if shouldUseICMPv6RawSend(ip6) { return s.sendICMPv6WithWinDivert(ip6, icmpHdr, icmpEcho, payload) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // Socket path only needs the ICMPv6 payload; the kernel prepends the IPv6 header. if err := gopacket.SerializeLayers(buf, opts, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 HopLimit + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.icmp6.SetHopLimit(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } func shouldUseICMPv6RawSend(ip6 *layers.IPv6) bool { return ip6 != nil && ip6.TrafficClass != 0 } func (s *ICMPSpec) sendICMPv6WithWinDivert(ip6 *layers.IPv6, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) { s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.ensureICMPSendHandle(true); err != nil { return time.Time{}, err } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } if err := gopacket.SerializeLayers(buf, opts, ip6, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.sendHandle.Send(buf.Bytes(), &s.sendAddr); err != nil { return time.Time{}, err } return start, nil } func (s *ICMPSpec) ensureICMPSendHandle(ipv6 bool) error { if s.sendHandle != 0 { return nil } handle, err := wd.Open("false", wd.LayerNetwork, 0, 0) if err != nil { if ipv6 { return fmt.Errorf("ICMPv6 --tos on Windows requires WinDivert send support: %w", err) } return err } s.sendHandle = handle s.sendAddr.SetLayer(wd.LayerNetwork) s.sendAddr.SetEvent(wd.EventNetworkPacket) s.sendAddr.SetOutbound() if ipv6 { s.sendAddr.SetIPv6() } return nil } ================================================ FILE: trace/internal/icmp_windows_test.go ================================================ //go:build windows && amd64 package internal import ( "testing" "github.com/google/gopacket/layers" ) func TestShouldUseICMPv6RawSend(t *testing.T) { if shouldUseICMPv6RawSend(nil) { t.Fatal("nil header should not use raw send") } if shouldUseICMPv6RawSend(&layers.IPv6{}) { t.Fatal("zero traffic class should keep socket send") } if !shouldUseICMPv6RawSend(&layers.IPv6{TrafficClass: 46}) { t.Fatal("non-zero traffic class should use raw send") } } ================================================ FILE: trace/internal/packet_listener.go ================================================ package internal import ( "context" "errors" "net" "time" ) type ReceivedMessage struct { Peer net.Addr Msg []byte Err error } // PacketListener 负责监听网络数据包并通过通道传递接收到的消息 // 对外暴露只读的 Messages,避免外部代码误写 type PacketListener struct { Conn net.PacketConn Messages <-chan ReceivedMessage ch chan ReceivedMessage } // NewPacketListener 创建一个新的数据包监听器 // conn: 用于接收数据包的连接 // 返回初始化好的 PacketListener 实例 func NewPacketListener(conn net.PacketConn) *PacketListener { ch := make(chan ReceivedMessage, 64) return &PacketListener{Conn: conn, Messages: ch, ch: ch} } func (l *PacketListener) Start(ctx context.Context) { defer close(l.ch) go func() { <-ctx.Done() _ = l.Conn.Close() }() buf := make([]byte, 4096) for { n, peer, err := l.Conn.ReadFrom(buf) if err != nil { // 连接关闭或 ctx 取消:直接退出 if errors.Is(err, net.ErrClosed) || ctx.Err() != nil { return } // 限时等待投递错误;超时或取消就丢弃/退出 select { case l.ch <- ReceivedMessage{Err: err}: case <-ctx.Done(): return case <-time.After(5 * time.Second): } continue } if n == 0 { continue } // 拷贝出精确长度,避免 buf 复用带来的数据竞争 pkt := make([]byte, n) copy(pkt, buf[:n]) // 限时等待投递数据;超时或取消就丢弃/退出 select { case l.ch <- ReceivedMessage{Peer: peer, Msg: pkt}: case <-ctx.Done(): return case <-time.After(5 * time.Second): } } } ================================================ FILE: trace/internal/tcp_common.go ================================================ package internal import ( "context" "fmt" "log" "net" "time" "github.com/nxtrace/NTrace-core/util" ) func NewTCPSpec(IPVersion, ICMPMode int, srcIP, dstIP net.IP, dstPort int, pktSize int) *TCPSpec { return &TCPSpec{IPVersion: IPVersion, ICMPMode: ICMPMode, SrcIP: srcIP, DstIP: dstIP, DstPort: dstPort, PktSize: pktSize} } func (s *TCPSpec) InitICMP() { network := "ip4:icmp" if s.IPVersion == 6 { network = "ip6:ipv6-icmp" } icmpConn, err := net.ListenPacket(network, s.SrcIP.String()) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitICMP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err)) } log.Fatalf("(InitICMP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err) } s.icmp = icmpConn } func (s *TCPSpec) listenICMPSock(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { lc := NewPacketListener(s.icmp) go lc.Start(ctx) close(ready) for { select { case <-ctx.Done(): return case msg, ok := <-lc.Messages: if !ok { return } finish, data, ok := s.decodeICMPSocketMessage(msg) if ok { onICMP(msg, finish, data) } } } } func (s *TCPSpec) decodeICMPSocketMessage(msg ReceivedMessage) (time.Time, []byte, bool) { if msg.Err != nil { return time.Time{}, nil, false } finish := time.Now() rm, ok := parseSocketICMPMessage(s.IPVersion, msg.Msg) if !ok { return finish, nil, false } data, ok := extractSocketICMPPayload(s.IPVersion, rm, s.DstIP) return finish, data, ok } ================================================ FILE: trace/internal/tcp_darwin.go ================================================ //go:build darwin package internal import ( "context" "errors" "fmt" "log" "net" "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/google/gopacket/pcap" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "github.com/nxtrace/NTrace-core/util" ) type TCPSpec struct { IPVersion int ICMPMode int SrcIP net.IP DstIP net.IP DstPort int PktSize int SourceDevice string icmp net.PacketConn tcp net.PacketConn tcp4 *ipv4.PacketConn tcp6 *ipv6.PacketConn hopLimitLock sync.Mutex } func (s *TCPSpec) InitTCP() { network := "ip4:tcp" if s.IPVersion == 6 { network = "ip6:tcp" } tcp, err := net.ListenPacket(network, s.SrcIP.String()) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitTCP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err)) } log.Fatalf("(InitTCP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err) } s.tcp = tcp if s.IPVersion == 4 { s.tcp4 = ipv4.NewPacketConn(s.tcp) } else { s.tcp6 = ipv6.NewPacketConn(s.tcp) } } func (s *TCPSpec) Close() { _ = s.icmp.Close() _ = s.tcp.Close() } func (s *TCPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { s.listenICMPSock(ctx, ready, onICMP) } func (s *TCPSpec) captureDevice() string { if s.SourceDevice != "" { return s.SourceDevice } if dev, err := util.PcapDeviceByIP(s.SrcIP); err == nil { return dev } return "en0" } func (s *TCPSpec) tcpCaptureFilter() string { return fmt.Sprintf( "%s and tcp and src host %s and dst host %s and src port %d", tcpIPVersionPrefix(s.IPVersion), s.DstIP.String(), s.SrcIP.String(), s.DstPort, ) } func mustOpenDarwinTCPSniffHandle(dev string) *pcap.Handle { handle, err := util.OpenLiveImmediate(dev, 65535, true, 4<<20) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(ListenTCP) pcap open failed on %s: %v", dev, err)) } log.Fatalf("(ListenTCP) pcap open failed on %s: %v", dev, err) } return handle } func mustSetDarwinTCPFilter(handle *pcap.Handle, filter string) { if err := handle.SetBPFFilter(filter); err != nil { if util.EnvDevMode { panic(fmt.Errorf("(ListenTCP) set BPF failed: %v (filter=%q)", err, filter)) } log.Fatalf("(ListenTCP) set BPF failed: %v (filter=%q)", err, filter) } } func (s *TCPSpec) ListenTCP(ctx context.Context, ready chan struct{}, onTCP func(srcPort, seq, ack int, peer net.Addr, finish time.Time)) { handle := mustOpenDarwinTCPSniffHandle(s.captureDevice()) defer handle.Close() mustSetDarwinTCPFilter(handle, s.tcpCaptureFilter()) src := gopacket.NewPacketSource(handle, handle.LinkType()) pktCh := src.Packets() close(ready) for { select { case <-ctx.Done(): return case pkt, ok := <-pktCh: if !ok { return } finish := pkt.Metadata().Timestamp srcPort, seq, ack, peer, ok := decodeTCPProbePacket(s.IPVersion, s.DstPort, pkt) if !ok { continue } onTCP(srcPort, seq, ack, peer, finish) } } } func (s *TCPSpec) SendTCP(ctx context.Context, ipHdr gopacket.NetworkLayer, tcpHdr *layers.TCP, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if s.IPVersion == 4 { ip4, ok := ipHdr.(*layers.IPv4) if !ok || ip4 == nil { return time.Time{}, errors.New("SendTCP: expect *layers.IPv4 when s.IPVersion==4") } ttl := int(ip4.TTL) if err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 TCP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 TTL + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.tcp4.SetTOS(int(ip4.TOS)); err != nil { return time.Time{}, err } if err := s.tcp4.SetTTL(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ip6, ok := ipHdr.(*layers.IPv6) if !ok || ip6 == nil { return time.Time{}, errors.New("SendTCP: expect *layers.IPv6 when s.IPVersion==6") } ttl := int(ip6.HopLimit) if err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 TCP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 HopLimit + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.tcp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil { return time.Time{}, err } if err := s.tcp6.SetHopLimit(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ================================================ FILE: trace/internal/tcp_probe_decode.go ================================================ package internal import ( "net" "github.com/google/gopacket" "github.com/google/gopacket/layers" ) func tcpIPVersionPrefix(ipVersion int) string { if ipVersion == 6 { return "ip6" } return "ip" } func tcpProbeReply(tcp *layers.TCP) (seq int, ack int, ok bool) { if tcp == nil { return 0, 0, false } if tcp.ACK && tcp.RST { return 0, int(tcp.Ack), true } if tcp.ACK && tcp.SYN { return int(tcp.Ack) - 1, 0, true } return 0, 0, false } func tcpProbePeerIP(ipVersion int, pkt gopacket.Packet) (net.IP, bool) { if ipVersion == 4 { ip4, ok := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4) if !ok || ip4 == nil { return nil, false } return ip4.SrcIP, true } ip6, ok := pkt.Layer(layers.LayerTypeIPv6).(*layers.IPv6) if !ok || ip6 == nil { return nil, false } return ip6.SrcIP, true } func decodeTCPProbePacket(ipVersion, dstPort int, pkt gopacket.Packet) (srcPort, seq, ack int, peer net.Addr, ok bool) { tcp, ok := pkt.Layer(layers.LayerTypeTCP).(*layers.TCP) if !ok || tcp == nil || int(tcp.SrcPort) != dstPort { return 0, 0, 0, nil, false } seq, ack, ok = tcpProbeReply(tcp) if !ok { return 0, 0, 0, nil, false } peerIP, ok := tcpProbePeerIP(ipVersion, pkt) if !ok { return 0, 0, 0, nil, false } return int(tcp.DstPort), seq, ack, &net.IPAddr{IP: peerIP}, true } ================================================ FILE: trace/internal/tcp_probe_decode_test.go ================================================ package internal import ( "net" "testing" "github.com/google/gopacket" "github.com/google/gopacket/layers" ) func mustSerializeTCPProbePacket(t *testing.T, ipLayer gopacket.NetworkLayer, tcp *layers.TCP) gopacket.Packet { t.Helper() if err := tcp.SetNetworkLayerForChecksum(ipLayer); err != nil { t.Fatalf("SetNetworkLayerForChecksum() error = %v", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } switch ipLayer.(type) { case *layers.IPv4: if err := gopacket.SerializeLayers(buf, opts, ipLayer.(*layers.IPv4), tcp); err != nil { t.Fatalf("SerializeLayers() error = %v", err) } return gopacket.NewPacket(buf.Bytes(), layers.LayerTypeIPv4, gopacket.NoCopy) case *layers.IPv6: if err := gopacket.SerializeLayers(buf, opts, ipLayer.(*layers.IPv6), tcp); err != nil { t.Fatalf("SerializeLayers() error = %v", err) } return gopacket.NewPacket(buf.Bytes(), layers.LayerTypeIPv6, gopacket.NoCopy) default: t.Fatalf("unexpected IP layer type %T", ipLayer) return nil } } func TestDecodeTCPProbePacketIPv4RSTAck(t *testing.T) { srcIP := net.ParseIP("1.1.1.1") dstIP := net.ParseIP("2.2.2.2") ip4 := &layers.IPv4{ Version: 4, IHL: 5, Protocol: layers.IPProtocolTCP, SrcIP: srcIP, DstIP: dstIP, } tcp := &layers.TCP{ SrcPort: 443, DstPort: 32100, ACK: true, RST: true, Ack: 200, } pkt := mustSerializeTCPProbePacket(t, ip4, tcp) srcPort, seq, ack, peer, ok := decodeTCPProbePacket(4, 443, pkt) if !ok { t.Fatalf("decodeTCPProbePacket() ok = false") } if srcPort != 32100 || seq != 0 || ack != 200 { t.Fatalf("decodeTCPProbePacket() = (%d, %d, %d), want (32100, 0, 200)", srcPort, seq, ack) } if got := peer.(*net.IPAddr).IP; !got.Equal(srcIP) { t.Fatalf("peer IP = %v, want %v", got, srcIP) } } func TestDecodeTCPProbePacketIPv6SYNAck(t *testing.T) { srcIP := net.ParseIP("2001:db8::1") dstIP := net.ParseIP("2001:db8::2") ip6 := &layers.IPv6{ Version: 6, NextHeader: layers.IPProtocolTCP, SrcIP: srcIP, DstIP: dstIP, } tcp := &layers.TCP{ SrcPort: 8443, DstPort: 45678, ACK: true, SYN: true, Ack: 91, } pkt := mustSerializeTCPProbePacket(t, ip6, tcp) srcPort, seq, ack, peer, ok := decodeTCPProbePacket(6, 8443, pkt) if !ok { t.Fatalf("decodeTCPProbePacket() ok = false") } if srcPort != 45678 || seq != 90 || ack != 0 { t.Fatalf("decodeTCPProbePacket() = (%d, %d, %d), want (45678, 90, 0)", srcPort, seq, ack) } if got := peer.(*net.IPAddr).IP; !got.Equal(srcIP) { t.Fatalf("peer IP = %v, want %v", got, srcIP) } } func TestDecodeTCPProbePacketRejectsUnexpectedPort(t *testing.T) { ip4 := &layers.IPv4{ Version: 4, IHL: 5, Protocol: layers.IPProtocolTCP, SrcIP: net.ParseIP("3.3.3.3"), DstIP: net.ParseIP("4.4.4.4"), } tcp := &layers.TCP{ SrcPort: 80, DstPort: 50000, ACK: true, SYN: true, Ack: 50, } pkt := mustSerializeTCPProbePacket(t, ip4, tcp) if _, _, _, _, ok := decodeTCPProbePacket(4, 443, pkt); ok { t.Fatalf("decodeTCPProbePacket() ok = true, want false") } } ================================================ FILE: trace/internal/tcp_unix.go ================================================ //go:build !darwin && !(windows && amd64) package internal import ( "context" "errors" "fmt" "log" "net" "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "github.com/nxtrace/NTrace-core/util" ) type TCPSpec struct { IPVersion int ICMPMode int SrcIP net.IP DstIP net.IP DstPort int PktSize int SourceDevice string icmp net.PacketConn tcp net.PacketConn tcp4 *ipv4.PacketConn tcp6 *ipv6.PacketConn hopLimitLock sync.Mutex } func (s *TCPSpec) InitTCP() { network := "ip4:tcp" if s.IPVersion == 6 { network = "ip6:tcp" } tcp, err := net.ListenPacket(network, s.SrcIP.String()) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitTCP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err)) } log.Fatalf("(InitTCP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err) } s.tcp = tcp if s.IPVersion == 4 { s.tcp4 = ipv4.NewPacketConn(s.tcp) } else { s.tcp6 = ipv6.NewPacketConn(s.tcp) } } func (s *TCPSpec) Close() { _ = s.icmp.Close() _ = s.tcp.Close() } func (s *TCPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { s.listenICMPSock(ctx, ready, onICMP) } func (s *TCPSpec) ListenTCP(ctx context.Context, ready chan struct{}, onTCP func(srcPort, seq, ack int, peer net.Addr, finish time.Time)) { lc := NewPacketListener(s.tcp) go lc.Start(ctx) close(ready) for { select { case <-ctx.Done(): return case msg, ok := <-lc.Messages: if !ok { return } if msg.Err != nil { continue } finish := time.Now() if ip := util.AddrIP(msg.Peer); ip == nil || !ip.Equal(s.DstIP) { continue } // 解包 packet := gopacket.NewPacket(msg.Msg, layers.LayerTypeTCP, gopacket.Default) if packet.ErrorLayer() != nil { continue } // 从包中获取 TCP 层信息 tl, ok := packet.Layer(layers.LayerTypeTCP).(*layers.TCP) if !ok || tl == nil || int(tl.SrcPort) != s.DstPort { continue } seq, ack, ok := tcpProbeReply(tl) if !ok { continue } srcPort := int(tl.DstPort) onTCP(srcPort, seq, ack, msg.Peer, finish) } } } func (s *TCPSpec) SendTCP(ctx context.Context, ipHdr gopacket.NetworkLayer, tcpHdr *layers.TCP, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if s.IPVersion == 4 { ip4, ok := ipHdr.(*layers.IPv4) if !ok || ip4 == nil { return time.Time{}, errors.New("SendTCP: expect *layers.IPv4 when s.IPVersion==4") } ttl := int(ip4.TTL) if err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 TCP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 TTL + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.tcp4.SetTOS(int(ip4.TOS)); err != nil { return time.Time{}, err } if err := s.tcp4.SetTTL(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ip6, ok := ipHdr.(*layers.IPv6) if !ok || ip6 == nil { return time.Time{}, errors.New("SendTCP: expect *layers.IPv6 when s.IPVersion==6") } ttl := int(ip6.HopLimit) if err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 TCP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 HopLimit + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.tcp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil { return time.Time{}, err } if err := s.tcp6.SetHopLimit(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ================================================ FILE: trace/internal/tcp_windows.go ================================================ //go:build windows && amd64 package internal import ( "context" "fmt" "log" "net" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" wd "github.com/xjasonlyu/windivert-go" "github.com/nxtrace/NTrace-core/util" ) type TCPSpec struct { IPVersion int ICMPMode int SrcIP net.IP DstIP net.IP DstPort int icmp net.PacketConn PktSize int SourceDevice string addr wd.Address handle wd.Handle } func (s *TCPSpec) sourceDeviceUnsupportedErr() error { if s.SourceDevice == "" { return nil } return fmt.Errorf("source_device %q is not supported on Windows TCP traces", s.SourceDevice) } func (s *TCPSpec) InitTCP() { if err := s.sourceDeviceUnsupportedErr(); err != nil { if util.EnvDevMode { panic(err) } log.Fatal(err) } handle, err := wd.Open("false", wd.LayerNetwork, 0, 0) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitTCP) WinDivert open failed: %v", err)) } log.Fatalf("(InitTCP) WinDivert open failed: %v", err) } s.handle = handle // 设置出站 Address s.addr.SetLayer(wd.LayerNetwork) s.addr.SetEvent(wd.EventNetworkPacket) s.addr.SetOutbound() } func (s *TCPSpec) Close() { _ = s.icmp.Close() _ = s.handle.Close() } // resolveICMPMode 进行最终模式判定 func (s *TCPSpec) resolveICMPMode() int { icmpMode := s.ICMPMode if icmpMode != 1 && icmpMode != 2 { icmpMode = 0 // 统一成 Auto } // 指定 1=Socket:直接返回 if icmpMode == 1 { return 1 } // Auto(0) 或强制 Sniff(2) → 尝试 WinDivert ok, err := winDivertAvailable() if !ok { if icmpMode == 2 { log.Printf("请求使用 WinDivert 嗅探模式,但 WinDivert 不可用: %v;已回退到 Socket 模式。", err) } return 1 } return 2 } func (s *TCPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { switch s.resolveICMPMode() { case 1: s.listenICMPSock(ctx, ready, onICMP) case 2: s.listenICMPWinDivert(ctx, ready, onICMP) } } func (s *TCPSpec) listenICMPWinDivert(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { if err := s.sourceDeviceUnsupportedErr(); err != nil { if util.EnvDevMode { panic(err) } log.Fatal(err) } sniffHandle, closeHandleICMP := openWinDivertSniffHandle(ctx, winDivertICMPFilter(s.IPVersion, s.SrcIP), "ListenICMP") defer closeHandleICMP() close(ready) buf := make([]byte, 65535) var addr wd.Address for { raw, finish, ok := receiveWinDivertPacket(ctx, sniffHandle, buf, &addr) if !ok { if ctx.Err() != nil { return } continue } packet, ok := decodeWinDivertICMPPacket(s.IPVersion, raw) if !ok { continue } data, ok := packet.errorPayloadFor(s.DstIP) if !ok { continue } onICMP(packet.message(), finish, data) } } func (s *TCPSpec) ListenTCP(ctx context.Context, ready chan struct{}, onTCP func(srcPort, seq, ack int, peer net.Addr, finish time.Time)) { if err := s.sourceDeviceUnsupportedErr(); err != nil { if util.EnvDevMode { panic(err) } log.Fatal(err) } sniffHandle, closeHandleTCP := openWinDivertSniffHandle( ctx, winDivertTCPFilter(s.IPVersion, s.DstIP, s.SrcIP, s.DstPort), "ListenTCP", ) defer closeHandleTCP() close(ready) buf := make([]byte, 65535) var addr wd.Address for { raw, finish, ok := receiveWinDivertPacket(ctx, sniffHandle, buf, &addr) if !ok { if ctx.Err() != nil { return } continue } srcPort, seq, ack, peer, ok := decodeWinDivertTCPPacket(s.IPVersion, raw, s.DstPort) if !ok { continue } onTCP(srcPort, seq, ack, peer, finish) } } func (s *TCPSpec) SendTCP(ctx context.Context, ipHdr ipLayer, tcpHdr *layers.TCP, payload []byte) (time.Time, error) { if err := s.sourceDeviceUnsupportedErr(); err != nil { return time.Time{}, err } select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 IP 与 TCP 头以及 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, ipHdr, tcpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } start := time.Now() // 复用预置的出站 Address if _, err := s.handle.Send(buf.Bytes(), &s.addr); err != nil { return time.Time{}, err } return start, nil } ================================================ FILE: trace/internal/udp_common.go ================================================ package internal import ( "context" "fmt" "log" "net" "time" "github.com/nxtrace/NTrace-core/util" ) func NewUDPSpec(IPVersion, ICMPMode int, srcIP, dstIP net.IP, dstPort int) *UDPSpec { return &UDPSpec{IPVersion: IPVersion, ICMPMode: ICMPMode, SrcIP: srcIP, DstIP: dstIP, DstPort: dstPort} } func (s *UDPSpec) InitICMP() { network := "ip4:icmp" if s.IPVersion == 6 { network = "ip6:ipv6-icmp" } icmpConn, err := net.ListenPacket(network, s.SrcIP.String()) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitICMP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err)) } log.Fatalf("(InitICMP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err) } s.icmp = icmpConn } func (s *UDPSpec) listenICMPSock(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { lc := NewPacketListener(s.icmp) go lc.Start(ctx) close(ready) for { select { case <-ctx.Done(): return case msg, ok := <-lc.Messages: if !ok { return } finish, data, ok := s.decodeICMPSocketMessage(msg) if ok { onICMP(msg, finish, data) } } } } func (s *UDPSpec) decodeICMPSocketMessage(msg ReceivedMessage) (time.Time, []byte, bool) { if msg.Err != nil { return time.Time{}, nil, false } finish := time.Now() rm, ok := parseSocketICMPMessage(s.IPVersion, msg.Msg) if !ok { return finish, nil, false } data, ok := extractSocketICMPPayload(s.IPVersion, rm, s.DstIP) return finish, data, ok } ================================================ FILE: trace/internal/udp_darwin.go ================================================ //go:build darwin package internal import ( "context" "errors" "fmt" "log" "net" "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "github.com/nxtrace/NTrace-core/util" ) type UDPSpec struct { IPVersion int ICMPMode int SrcIP net.IP DstIP net.IP DstPort int SourceDevice string icmp net.PacketConn udp net.PacketConn udp4 *ipv4.PacketConn udp6 *ipv6.PacketConn hopLimitLock sync.Mutex } func (s *UDPSpec) InitUDP() { network := "ip4:udp" if s.IPVersion == 6 { network = "ip6:udp" } udp, err := net.ListenPacket(network, s.SrcIP.String()) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitUDP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err)) } log.Fatalf("(InitUDP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err) } s.udp = udp if s.IPVersion == 4 { s.udp4 = ipv4.NewPacketConn(s.udp) } else { s.udp6 = ipv6.NewPacketConn(s.udp) } } func (s *UDPSpec) Close() { _ = s.icmp.Close() _ = s.udp.Close() } func (s *UDPSpec) ListenOut(ctx context.Context, ready chan struct{}, onOut func(srcPort, seq, ttl int, start time.Time)) { // 选择捕获设备与本地接口 dev := "en0" if s.SourceDevice != "" { dev = s.SourceDevice } else if d, err := util.PcapDeviceByIP(s.SrcIP); err == nil { dev = d } // 以“立即模式”打开 pcap,降低首包丢失概率 handle, err := util.OpenLiveImmediate(dev, 65535, true, 4<<20) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(ListenOut) pcap open failed on %s: %v", dev, err)) } log.Fatalf("(ListenOut) pcap open failed on %s: %v", dev, err) } defer handle.Close() // 过滤:只抓对应协议族 + udp,来自本机 s.SrcIP → 目标 s.DstIP,且目标端口为 s.DstPort ipPrefix := "ip" if s.IPVersion == 6 { ipPrefix = "ip6" } filter := fmt.Sprintf( "%s and udp and src host %s and dst host %s and dst port %d", ipPrefix, s.SrcIP.String(), s.DstIP.String(), s.DstPort, ) if err := handle.SetBPFFilter(filter); err != nil { if util.EnvDevMode { panic(fmt.Errorf("(ListenOut) set BPF failed: %v (filter=%q)", err, filter)) } log.Fatalf("(ListenOut) set BPF failed: %v (filter=%q)", err, filter) } src := gopacket.NewPacketSource(handle, handle.LinkType()) pktCh := src.Packets() close(ready) for { select { case <-ctx.Done(): return case pkt, ok := <-pktCh: if !ok { return } // 解包 packet := pkt.NetworkLayer() if packet == nil { continue } start := pkt.Metadata().Timestamp // 从包中获取 IPv4 层信息 ip4, ok := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4) if !ok || ip4 == nil { continue } // 从包中获取 UDP 层信息 ul, ok := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP) if !ok || ul == nil { continue } ttl := int(ip4.TTL) srcPort := int(ul.SrcPort) seq := int(ip4.Id) onOut(srcPort, seq, ttl, start) } } } func (s *UDPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { s.listenICMPSock(ctx, ready, onICMP) } func (s *UDPSpec) SendUDP(ctx context.Context, ipHdr gopacket.NetworkLayer, udpHdr *layers.UDP, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if s.IPVersion == 4 { ip4, ok := ipHdr.(*layers.IPv4) if !ok || ip4 == nil { return time.Time{}, errors.New("SendUDP: expect *layers.IPv4 when s.IPVersion==4") } ttl := int(ip4.TTL) if err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 UDP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, udpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 TTL + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.udp4.SetTOS(int(ip4.TOS)); err != nil { return time.Time{}, err } if err := s.udp4.SetTTL(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.udp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ip6, ok := ipHdr.(*layers.IPv6) if !ok || ip6 == nil { return time.Time{}, errors.New("SendUDP: expect *layers.IPv6 when s.IPVersion==6") } ttl := int(ip6.HopLimit) if err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 UDP 头与 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, udpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } // 串行设置 HopLimit + 发送,放在同一把锁里保证并发安全 s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.udp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil { return time.Time{}, err } if err := s.udp6.SetHopLimit(ttl); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.udp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } ================================================ FILE: trace/internal/udp_unix.go ================================================ //go:build !darwin && !(windows && amd64) package internal import ( "context" "errors" "fmt" "log" "net" "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "github.com/nxtrace/NTrace-core/util" ) type UDPSpec struct { IPVersion int ICMPMode int SrcIP net.IP DstIP net.IP DstPort int SourceDevice string icmp net.PacketConn udp net.PacketConn udp4 *ipv4.RawConn udp6 *ipv6.PacketConn hopLimitLock sync.Mutex mtu int } func (s *UDPSpec) InitUDP() { network := "ip4:udp" if s.IPVersion == 6 { network = "ip6:udp" } udp, err := net.ListenPacket(network, s.SrcIP.String()) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitUDP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err)) } log.Fatalf("(InitUDP) ListenPacket(%s, %s) failed: %v", network, s.SrcIP, err) } s.udp = udp if s.IPVersion == 4 { s.udp4, err = ipv4.NewRawConn(s.udp) if err != nil { s.Close() if util.EnvDevMode { panic(fmt.Errorf("(InitUDP) create NewRawConn failed: %v", err)) } log.Fatalf("(InitUDP) create NewRawConn failed: %v", err) } // 获取本地接口的 MTU mtu := 1500 if m := util.GetMTUByIPForDevice(s.SrcIP, s.SourceDevice); m > 0 { mtu = m } s.mtu = mtu } else { s.udp6 = ipv6.NewPacketConn(s.udp) } } func (s *UDPSpec) Close() { _ = s.icmp.Close() _ = s.udp.Close() } func (s *UDPSpec) ListenOut(_ context.Context, _ chan struct{}, _ func(srcPort, seq, ttl int, start time.Time)) { } func (s *UDPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { s.listenICMPSock(ctx, ready, onICMP) } func serializeUDPPacket(payload []byte, layersToSerialize ...gopacket.SerializableLayer) ([]byte, error) { buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } serializeLayers := append(layersToSerialize, gopacket.Payload(payload)) if err := gopacket.SerializeLayers(buf, opts, serializeLayers...); err != nil { return nil, err } return buf.Bytes(), nil } func parseIPv4Packet(packet []byte) (*ipv4.Header, []byte, error) { ihl := int(packet[0]&0x0f) * 4 hdr, err := ipv4.ParseHeader(packet[:ihl]) if err != nil { return nil, nil, err } return hdr, packet[ihl:], nil } func (s *UDPSpec) sendUDPIPv4(ipHdr *layers.IPv4, udpHdr *layers.UDP, payload []byte) (time.Time, error) { if ipHdr == nil { return time.Time{}, errors.New("SendUDP: expect *layers.IPv4 when s.IPVersion==4") } if err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } packet, err := serializeUDPPacket(payload, ipHdr, udpHdr) if err != nil { return time.Time{}, err } hdr, body, err := parseIPv4Packet(packet) if err != nil { return time.Time{}, err } if len(packet) <= s.mtu { start := time.Now() if err := s.udp4.WriteTo(hdr, body, nil); err != nil { return time.Time{}, err } return start, nil } frags, err := util.IPv4Fragmentize(hdr, body, s.mtu) if err != nil { return time.Time{}, err } start := time.Now() for _, fr := range frags { if err := s.udp4.WriteTo(&fr.Hdr, fr.Body, nil); err != nil { return time.Time{}, err } } return start, nil } func (s *UDPSpec) sendUDPIPv6(ipHdr *layers.IPv6, udpHdr *layers.UDP, payload []byte) (time.Time, error) { if ipHdr == nil { return time.Time{}, errors.New("SendUDP: expect *layers.IPv6 when s.IPVersion==6") } if err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } packet, err := serializeUDPPacket(payload, udpHdr) if err != nil { return time.Time{}, err } s.hopLimitLock.Lock() defer s.hopLimitLock.Unlock() if err := s.udp6.SetTrafficClass(int(ipHdr.TrafficClass)); err != nil { return time.Time{}, err } if err := s.udp6.SetHopLimit(int(ipHdr.HopLimit)); err != nil { return time.Time{}, err } start := time.Now() if _, err := s.udp.WriteTo(packet, &net.IPAddr{IP: s.DstIP}); err != nil { return time.Time{}, err } return start, nil } func (s *UDPSpec) SendUDP(ctx context.Context, ipHdr ipLayer, udpHdr *layers.UDP, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if s.IPVersion == 4 { ip4, _ := ipHdr.(*layers.IPv4) return s.sendUDPIPv4(ip4, udpHdr, payload) } ip6, _ := ipHdr.(*layers.IPv6) return s.sendUDPIPv6(ip6, udpHdr, payload) } ================================================ FILE: trace/internal/udp_windows.go ================================================ //go:build windows && amd64 package internal import ( "context" "fmt" "log" "net" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" wd "github.com/xjasonlyu/windivert-go" "github.com/nxtrace/NTrace-core/util" ) type UDPSpec struct { IPVersion int ICMPMode int SrcIP net.IP DstIP net.IP DstPort int SourceDevice string icmp net.PacketConn addr wd.Address handle wd.Handle } func (s *UDPSpec) InitUDP() { handle, err := wd.Open("false", wd.LayerNetwork, 0, 0) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(InitUDP) WinDivert open failed: %v", err)) } log.Fatalf("(InitUDP) WinDivert open failed: %v", err) } s.handle = handle // 设置出站 Address s.addr.SetLayer(wd.LayerNetwork) s.addr.SetEvent(wd.EventNetworkPacket) s.addr.SetOutbound() } func (s *UDPSpec) Close() { _ = s.icmp.Close() _ = s.handle.Close() } func (s *UDPSpec) ListenOut(_ context.Context, _ chan struct{}, _ func(srcPort, seq, ttl int, start time.Time)) { } // resolveICMPMode 进行最终模式判定 func (s *UDPSpec) resolveICMPMode() int { icmpMode := s.ICMPMode if icmpMode != 1 && icmpMode != 2 { icmpMode = 0 // 统一成 Auto } // 指定 1=Socket:直接返回 if icmpMode == 1 { return 1 } // Auto(0) 或强制 Sniff(2) → 尝试 WinDivert ok, err := winDivertAvailable() if !ok { if icmpMode == 2 { log.Printf("请求使用 WinDivert 嗅探模式,但 WinDivert 不可用: %v;已回退到 Socket 模式。", err) } return 1 } return 2 } func (s *UDPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { switch s.resolveICMPMode() { case 1: s.listenICMPSock(ctx, ready, onICMP) case 2: s.listenICMPWinDivert(ctx, ready, onICMP) } } func (s *UDPSpec) listenICMPWinDivert(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) { sniffHandle, closeHandle := openWinDivertSniffHandle(ctx, winDivertICMPFilter(s.IPVersion, s.SrcIP), "ListenICMP") defer closeHandle() close(ready) buf := make([]byte, 65535) var addr wd.Address for { raw, finish, ok := receiveWinDivertPacket(ctx, sniffHandle, buf, &addr) if !ok { if ctx.Err() != nil { return } continue } packet, ok := decodeWinDivertICMPPacket(s.IPVersion, raw) if !ok { continue } data, ok := packet.errorPayloadFor(s.DstIP) if !ok { continue } onICMP(packet.message(), finish, data) } } func (s *UDPSpec) SendUDP(ctx context.Context, ipHdr ipLayer, udpHdr *layers.UDP, payload []byte) (time.Time, error) { select { case <-ctx.Done(): return time.Time{}, context.Canceled default: } if err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } // 序列化 IP 与 UDP 头以及 payload 到缓冲区 if err := gopacket.SerializeLayers(buf, opts, ipHdr, udpHdr, gopacket.Payload(payload)); err != nil { return time.Time{}, err } start := time.Now() // 复用预置的出站 Address if _, err := s.handle.Send(buf.Bytes(), &s.addr); err != nil { return time.Time{}, err } return start, nil } ================================================ FILE: trace/internal/windivert_sniff_windows.go ================================================ //go:build windows && amd64 package internal import ( "context" "fmt" "log" "net" "sync" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" wd "github.com/xjasonlyu/windivert-go" "github.com/nxtrace/NTrace-core/util" ) type winDivertICMPPacket struct { ipVersion int peerIP net.IP outer []byte errorData []byte echoID int echoSeq int echoReply bool } func winDivertICMPFilter(ipVersion int, srcIP net.IP) string { if ipVersion == 4 { return fmt.Sprintf("inbound and icmp and ip.DstAddr == %s", srcIP.String()) } return fmt.Sprintf("inbound and icmpv6 and ipv6.DstAddr == %s", srcIP.String()) } func winDivertTCPFilter(ipVersion int, dstIP, srcIP net.IP, dstPort int) string { if ipVersion == 4 { return fmt.Sprintf( "inbound and tcp and ip.SrcAddr == %s and ip.DstAddr == %s and tcp.SrcPort == %d", dstIP.String(), srcIP.String(), dstPort, ) } return fmt.Sprintf( "inbound and tcp and ipv6.SrcAddr == %s and ipv6.DstAddr == %s and tcp.SrcPort == %d", dstIP.String(), srcIP.String(), dstPort, ) } func openWinDivertSniffHandle(ctx context.Context, filter, action string) (wd.Handle, func()) { handle, err := wd.Open(filter, wd.LayerNetwork, 0, wd.FlagSniff|wd.FlagRecvOnly) if err != nil { if util.EnvDevMode { panic(fmt.Errorf("(%s) WinDivert open failed: %v (filter=%q)", action, err, filter)) } log.Fatalf("(%s) WinDivert open failed: %v (filter=%q)", action, err, filter) } var closeOnce sync.Once closeHandle := func() { closeOnce.Do(func() { _ = handle.Close() }) } go func() { <-ctx.Done() closeHandle() }() _ = handle.SetParam(wd.QueueLength, 8192) _ = handle.SetParam(wd.QueueTime, 4000) return handle, closeHandle } func packetDecoderForIPVersion(ipVersion int) gopacket.Decoder { if ipVersion == 4 { return layers.LayerTypeIPv4 } return layers.LayerTypeIPv6 } func receiveWinDivertPacket(ctx context.Context, handle wd.Handle, buf []byte, addr *wd.Address) ([]byte, time.Time, bool) { select { case <-ctx.Done(): return nil, time.Time{}, false default: } n, err := handle.Recv(buf, addr) if err != nil { select { case <-ctx.Done(): return nil, time.Time{}, false default: return nil, time.Time{}, false } } finish := time.Now() raw := make([]byte, n) copy(raw, buf[:n]) return raw, finish, true } func decodeWinDivertICMPPacket(ipVersion int, raw []byte) (*winDivertICMPPacket, bool) { pkt := gopacket.NewPacket(raw, packetDecoderForIPVersion(ipVersion), gopacket.NoCopy) if ipVersion == 4 { return decodeWinDivertICMPv4Packet(pkt, raw) } return decodeWinDivertICMPv6Packet(pkt, raw) } func decodeWinDivertTCPPacket(ipVersion int, raw []byte, dstPort int) (srcPort, seq, ack int, peer net.Addr, ok bool) { pkt := gopacket.NewPacket(raw, packetDecoderForIPVersion(ipVersion), gopacket.NoCopy) return decodeTCPProbePacket(ipVersion, dstPort, pkt) } func decodeWinDivertICMPv4Packet(pkt gopacket.Packet, raw []byte) (*winDivertICMPPacket, bool) { ip4, ok := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4) if !ok || ip4 == nil { return nil, false } ic4, ok := pkt.Layer(layers.LayerTypeICMPv4).(*layers.ICMPv4) if !ok || ic4 == nil { return nil, false } packet := &winDivertICMPPacket{ ipVersion: 4, peerIP: ip4.SrcIP, outer: raw, } switch ic4.TypeCode.Type() { case layers.ICMPv4TypeEchoReply: packet.echoReply = true packet.echoID = int(ic4.Id) packet.echoSeq = int(ic4.Seq) return packet, true case layers.ICMPv4TypeTimeExceeded, layers.ICMPv4TypeDestinationUnreachable: packet.errorData = ic4.Payload return packet, true default: return nil, false } } func decodeWinDivertICMPv6Packet(pkt gopacket.Packet, raw []byte) (*winDivertICMPPacket, bool) { ip6, ok := pkt.Layer(layers.LayerTypeIPv6).(*layers.IPv6) if !ok || ip6 == nil { return nil, false } ic6, ok := pkt.Layer(layers.LayerTypeICMPv6).(*layers.ICMPv6) if !ok || ic6 == nil || len(ic6.Payload) < 4 { return nil, false } packet := &winDivertICMPPacket{ ipVersion: 6, peerIP: ip6.SrcIP, outer: raw, } switch ic6.TypeCode.Type() { case layers.ICMPv6TypeEchoReply: echo, ok := pkt.Layer(layers.LayerTypeICMPv6Echo).(*layers.ICMPv6Echo) if !ok || echo == nil { return nil, false } packet.echoReply = true packet.echoID = int(echo.Identifier) packet.echoSeq = int(echo.SeqNumber) return packet, true case layers.ICMPv6TypeTimeExceeded, layers.ICMPv6TypePacketTooBig, layers.ICMPv6TypeDestinationUnreachable: packet.errorData = ic6.Payload[4:] return packet, true default: return nil, false } } func (p *winDivertICMPPacket) message() ReceivedMessage { return ReceivedMessage{ Peer: &net.IPAddr{IP: p.peerIP}, Msg: p.outer, } } func (p *winDivertICMPPacket) echoReplyFor(dstIP net.IP, echoID int) (int, bool) { if !p.echoReply || !p.peerIP.Equal(dstIP) || p.echoID != echoID { return 0, false } return p.echoSeq, true } func (p *winDivertICMPPacket) errorPayloadFor(dstIP net.IP) ([]byte, bool) { if p.echoReply || !matchesEmbeddedDstIP(p.ipVersion, p.errorData, dstIP) { return nil, false } return p.errorData, true } ================================================ FILE: trace/mtr_loop_runtime.go ================================================ package trace import ( "context" "fmt" "os" "time" ) type mtrLoopRuntime struct { ctx context.Context prober mtrProber config Config opts MTROptions agg *MTRAggregator onSnapshot MTROnSnapshot fillGeo bool bo *mtrBackoffCfg iteration int consecutiveErrors int backoff time.Duration } func newMTRLoopRuntime( ctx context.Context, prober mtrProber, config Config, opts MTROptions, agg *MTRAggregator, onSnapshot MTROnSnapshot, fillGeo bool, bo *mtrBackoffCfg, ) *mtrLoopRuntime { if bo == nil { bo = &defaultBackoff } if opts.ProgressThrottle <= 0 { opts.ProgressThrottle = 200 * time.Millisecond } return &mtrLoopRuntime{ ctx: ctx, prober: prober, config: config, opts: opts, agg: agg, onSnapshot: onSnapshot, fillGeo: fillGeo, bo: bo, backoff: bo.Initial, } } func (rt *mtrLoopRuntime) run() error { for { if err := rt.snapshotContextError(); err != nil { return err } rt.handleReset() if err := rt.waitWhilePaused(); err != nil { return err } res, err := rt.runProbeRound() if err != nil { shouldContinue, retErr := rt.handleProbeError(err) if retErr != nil { return retErr } if shouldContinue { continue } } rt.recordSuccess(res) if rt.opts.MaxRounds > 0 && rt.iteration >= rt.opts.MaxRounds { return nil } if err := rt.waitInterval(); err != nil { return err } } } func (rt *mtrLoopRuntime) snapshotContextError() error { if rt.ctx.Err() == nil { return nil } rt.emitSnapshot() return rt.ctx.Err() } func (rt *mtrLoopRuntime) emitSnapshot() { if rt.onSnapshot != nil { rt.onSnapshot(rt.iteration, rt.agg.Snapshot()) } } func (rt *mtrLoopRuntime) handleReset() { if rt.opts.IsResetRequested == nil || !rt.opts.IsResetRequested() { return } rt.agg.Reset() rt.iteration = 0 rt.consecutiveErrors = 0 rt.backoff = rt.bo.Initial if resetter, ok := rt.prober.(mtrResetter); ok { resetter.resetFinalTTL() } } func (rt *mtrLoopRuntime) waitWhilePaused() error { if rt.opts.IsPaused == nil { return nil } for rt.opts.IsPaused() { timer := time.NewTimer(200 * time.Millisecond) select { case <-rt.ctx.Done(): timer.Stop() return rt.snapshotContextError() case <-timer.C: } } return nil } func (rt *mtrLoopRuntime) runProbeRound() (*Result, error) { peeker, canPeek := rt.prober.(mtrPeeker) if !canPeek || rt.onSnapshot == nil { return rt.prober.probeRound(rt.ctx) } return rt.runProbeRoundWithPreview(peeker) } func (rt *mtrLoopRuntime) runProbeRoundWithPreview(peeker mtrPeeker) (*Result, error) { var ( res *Result err error ) done := make(chan struct{}) go func() { res, err = rt.prober.probeRound(rt.ctx) close(done) }() ticker := time.NewTicker(rt.opts.ProgressThrottle) defer ticker.Stop() for { select { case <-done: return res, err case <-ticker.C: rt.emitPreview(peeker) case <-rt.ctx.Done(): <-done if err == nil && rt.ctx.Err() != nil { err = rt.ctx.Err() } return res, err } } } func (rt *mtrLoopRuntime) emitPreview(peeker mtrPeeker) { partial := peeker.peekPartialResult() if partial == nil { return } preview := rt.agg.Clone() rt.onSnapshot(rt.iteration+1, preview.Update(partial, 1)) } func (rt *mtrLoopRuntime) handleProbeError(err error) (bool, error) { if rt.ctx.Err() != nil { return false, rt.snapshotContextError() } rt.consecutiveErrors++ fmt.Fprintf(os.Stderr, "mtr: probe error (%d/%d): %v\n", rt.consecutiveErrors, rt.bo.MaxConsec, err) if rt.consecutiveErrors >= rt.bo.MaxConsec { return false, fmt.Errorf("mtr: too many consecutive errors (%d), last: %w", rt.consecutiveErrors, err) } if err := rt.waitBackoff(); err != nil { return false, err } rt.backoff *= 2 if rt.backoff > rt.bo.Max { rt.backoff = rt.bo.Max } return true, nil } func (rt *mtrLoopRuntime) waitBackoff() error { timer := time.NewTimer(rt.backoff) defer timer.Stop() select { case <-rt.ctx.Done(): return rt.snapshotContextError() case <-timer.C: return nil } } func (rt *mtrLoopRuntime) recordSuccess(res *Result) { if rt.fillGeo { mtrFillGeoRDNS(res, rt.config) } rt.consecutiveErrors = 0 rt.backoff = rt.bo.Initial rt.iteration++ stats := rt.agg.Update(res, 1) if rt.onSnapshot != nil { rt.onSnapshot(rt.iteration, stats) } } func (rt *mtrLoopRuntime) waitInterval() error { if rt.opts.Interval <= 0 { return nil } timer := time.NewTimer(rt.opts.Interval) defer timer.Stop() select { case <-rt.ctx.Done(): return rt.snapshotContextError() case <-timer.C: return nil } } ================================================ FILE: trace/mtr_raw.go ================================================ package trace import ( "context" "fmt" "net" "strings" "sync" "time" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/util" ) // MTRRawOptions controls MTR raw streaming behavior. type MTRRawOptions struct { // Interval is the delay between rounds (default: 1s). // Legacy round-based mode only. Interval time.Duration // MaxRounds is the max number of rounds. 0 means run forever until canceled. // Legacy round-based mode only. MaxRounds int // HopInterval is the per-hop probe interval (per-hop scheduling mode). // > 0 activates per-hop scheduling; Interval/MaxRounds are ignored. HopInterval time.Duration // MaxPerHop is the max probes per TTL in per-hop mode. 0 = unlimited. MaxPerHop int // RunRound optionally overrides the traceroute call for each round. // It is mainly for callers that need per-round locking or global-state setup. // Legacy round-based mode only. RunRound func(method Method, cfg Config) (*Result, error) } // MTRRawRecord is one stream record emitted by MTR raw mode. // It keeps the same information family as classic --raw output. type MTRRawRecord struct { Iteration int `json:"iteration"` TTL int `json:"ttl"` Success bool `json:"success"` IP string `json:"ip,omitempty"` Host string `json:"host,omitempty"` RTTMs float64 `json:"rtt_ms"` ASN string `json:"asn,omitempty"` Country string `json:"country,omitempty"` Prov string `json:"prov,omitempty"` City string `json:"city,omitempty"` District string `json:"district,omitempty"` Owner string `json:"owner,omitempty"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` MPLS []string `json:"mpls,omitempty"` } // MTRRawOnRecord is called for each probe event. type MTRRawOnRecord func(rec MTRRawRecord) var mtrRawTracerouteFn = Traceroute // RunMTRRaw runs continuous traceroute and emits probe-level streaming records. // // When opts.HopInterval > 0, uses per-hop scheduling (each TTL independent); // otherwise uses legacy round-based scheduling for backward compatibility. func RunMTRRaw(ctx context.Context, method Method, cfg Config, opts MTRRawOptions, onRecord MTRRawOnRecord) error { if opts.HopInterval > 0 { return runMTRRawPerHop(ctx, method, cfg, opts, onRecord) } return runMTRRawRoundBased(ctx, method, cfg, opts, onRecord) } // runMTRRawPerHop uses per-hop scheduling for raw streaming. func runMTRRawPerHop(ctx context.Context, method Method, cfg Config, opts MTRRawOptions, onRecord MTRRawOnRecord) error { normalizeRuntimeConfig(&cfg) roundCfg := cfg roundCfg.NumMeasurements = 1 roundCfg.MaxAttempts = 1 roundCfg.AsyncPrinter = nil roundCfg.RealtimePrinter = nil if roundCfg.MaxHops == 0 { roundCfg.MaxHops = 30 } if roundCfg.ICMPMode <= 0 && util.EnvICMPMode > 0 { roundCfg.ICMPMode = util.EnvICMPMode } switch roundCfg.ICMPMode { case 0, 1, 2: default: roundCfg.ICMPMode = 0 } var prober mtrTTLProber if method == ICMPTrace { engine, err := newMTRICMPEngine(roundCfg) if err != nil { return fmt.Errorf("mtr raw: %w", err) } defer engine.close() if err := engine.start(ctx); err != nil { engine.close() return fmt.Errorf("mtr raw: %w", err) } prober = engine } else { prober = &mtrFallbackTTLProber{method: method, config: roundCfg} } agg := NewMTRAggregator() return runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{ BeginHop: roundCfg.BeginHop, MaxHops: roundCfg.MaxHops, HopInterval: opts.HopInterval, Timeout: roundCfg.Timeout, MaxPerHop: opts.MaxPerHop, ParallelRequests: roundCfg.ParallelRequests, FillGeo: true, BaseConfig: roundCfg, DstIP: roundCfg.DstIP, }, nil, func(result mtrProbeResult, iteration int) { if onRecord == nil { return } rec := buildMTRRawRecordFromProbe(iteration, result, roundCfg) onRecord(rec) }) } // runMTRRawRoundBased is the legacy round-based raw streaming path. func runMTRRawRoundBased(ctx context.Context, method Method, cfg Config, opts MTRRawOptions, onRecord MTRRawOnRecord) error { normalizeRuntimeConfig(&cfg) if opts.Interval <= 0 { opts.Interval = time.Second } roundCfg := cfg roundCfg.NumMeasurements = 1 roundCfg.MaxAttempts = 1 roundCfg.AsyncPrinter = nil roundCfg.RealtimePrinter = nil runRound := opts.RunRound if runRound == nil { runRound = mtrRawTracerouteFn } iteration := 0 for { select { case <-ctx.Done(): return ctx.Err() default: } iteration++ seen := make(map[int]int) var seenMu sync.Mutex cfgForRound := roundCfg cfgForRound.RealtimePrinter = func(res *Result, ttl int) { if onRecord == nil || ctx.Err() != nil { return } if ttl < 0 || ttl >= len(res.Hops) { return } seenMu.Lock() start := seen[ttl] end := len(res.Hops[ttl]) seen[ttl] = end seenMu.Unlock() if start >= end { return } for i := start; i < end; i++ { h := res.Hops[ttl][i] rec := buildMTRRawRecord(iteration, h, cfgForRound) onRecord(rec) } } done := make(chan struct{}) var traceErr error go func() { _, traceErr = runRound(method, cfgForRound) close(done) }() select { case <-ctx.Done(): // Wait for the in-flight round to finish before returning, so callers // can safely release any per-round/global state after RunMTRRaw exits. <-done return ctx.Err() case <-done: } if traceErr != nil { return traceErr } if opts.MaxRounds > 0 && iteration >= opts.MaxRounds { return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(opts.Interval): } } } func buildMTRRawRecord(iteration int, h Hop, cfg Config) MTRRawRecord { rec := MTRRawRecord{ Iteration: iteration, TTL: h.TTL, Success: h.Success && h.Address != nil, } if h.Address != nil { rec.IP = addrToIPString(h.Address) } rec.Host = strings.TrimSpace(h.Hostname) if h.RTT > 0 { rec.RTTMs = float64(h.RTT) / float64(time.Millisecond) } if len(h.MPLS) > 0 { rec.MPLS = append([]string(nil), h.MPLS...) } if h.Address != nil && (h.Geo == nil || isPendingGeo(h.Geo)) { if h.Lang == "" { h.Lang = cfg.Lang } _ = h.fetchIPData(cfg) } if h.Geo != nil { rec.ASN = strings.TrimSpace(h.Geo.Asnumber) rec.Country = geoTextByLang(cfg.Lang, h.Geo.Country, h.Geo.CountryEn) rec.Prov = geoTextByLang(cfg.Lang, h.Geo.Prov, h.Geo.ProvEn) rec.City = geoTextByLang(cfg.Lang, h.Geo.City, h.Geo.CityEn) rec.District = strings.TrimSpace(h.Geo.District) rec.Owner = strings.TrimSpace(h.Geo.Owner) if rec.Owner == "" { rec.Owner = strings.TrimSpace(h.Geo.Isp) } rec.Lat = h.Geo.Lat rec.Lng = h.Geo.Lng } return rec } func addrToIPString(addr net.Addr) string { switch v := addr.(type) { case *net.IPAddr: if v.IP != nil { return v.IP.String() } case *net.UDPAddr: if v.IP != nil { return v.IP.String() } case *net.TCPAddr: if v.IP != nil { return v.IP.String() } } s := strings.TrimSpace(addr.String()) if host, _, err := net.SplitHostPort(s); err == nil { return strings.Trim(host, "[]") } return strings.Trim(s, "[]") } func geoTextByLang(lang, cn, en string) string { cn = strings.TrimSpace(cn) en = strings.TrimSpace(en) if strings.EqualFold(lang, "en") { if en != "" { return en } return cn } if cn != "" { return cn } return en } // buildMTRRawRecordFromProbe constructs an MTRRawRecord from a per-hop scheduler probe result. func buildMTRRawRecordFromProbe(iteration int, pr mtrProbeResult, cfg Config) MTRRawRecord { rec := newMTRRawRecord(iteration, pr) if pr.Addr == nil { return rec } applyMTRRawProbeMetadata(&rec, pr, cfg) return rec } func newMTRRawRecord(iteration int, pr mtrProbeResult) MTRRawRecord { rec := MTRRawRecord{ Iteration: iteration, TTL: pr.TTL, Success: pr.Success && pr.Addr != nil, } if pr.Addr != nil { rec.IP = addrToIPString(pr.Addr) } if pr.RTT > 0 { rec.RTTMs = float64(pr.RTT) / float64(time.Millisecond) } if len(pr.MPLS) > 0 { rec.MPLS = append([]string(nil), pr.MPLS...) } return rec } func applyMTRRawProbeMetadata(rec *MTRRawRecord, pr mtrProbeResult, cfg Config) { if pr.Geo != nil || pr.Hostname != "" { applyMTRRawGeo(rec, pr.Geo, cfg.Lang) applyMTRRawHostname(rec, pr.Hostname) return } if cfg.IPGeoSource == nil && !cfg.RDNS { return } h := Hop{Address: pr.Addr, Lang: cfg.Lang} _ = h.fetchIPData(cfg) applyMTRRawGeo(rec, h.Geo, cfg.Lang) applyMTRRawHostname(rec, h.Hostname) } func applyMTRRawGeo(rec *MTRRawRecord, geo *ipgeo.IPGeoData, lang string) { if geo == nil { return } rec.ASN = strings.TrimSpace(geo.Asnumber) rec.Country = geoTextByLang(lang, geo.Country, geo.CountryEn) rec.Prov = geoTextByLang(lang, geo.Prov, geo.ProvEn) rec.City = geoTextByLang(lang, geo.City, geo.CityEn) rec.District = strings.TrimSpace(geo.District) rec.Owner = strings.TrimSpace(geo.Owner) if rec.Owner == "" { rec.Owner = strings.TrimSpace(geo.Isp) } rec.Lat = geo.Lat rec.Lng = geo.Lng } func applyMTRRawHostname(rec *MTRRawRecord, hostname string) { if hostname != "" { rec.Host = strings.TrimSpace(hostname) } } ================================================ FILE: trace/mtr_raw_test.go ================================================ package trace import ( "context" "net" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/util" ) func TestRunMTRRaw_EmitsPerAttemptRecords(t *testing.T) { old := mtrRawTracerouteFn t.Cleanup(func() { mtrRawTracerouteFn = old }) mtrRawTracerouteFn = func(_ Method, cfg Config) (*Result, error) { res := &Result{Hops: make([][]Hop, 2)} res.Hops[0] = []Hop{{ Success: true, Address: &net.IPAddr{IP: net.ParseIP("1.1.1.1")}, Hostname: "one.one.one.one", TTL: 1, RTT: 15 * time.Millisecond, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", Country: "美国", Prov: "加州", City: "洛杉矶", Owner: "Cloudflare", Lat: 35.1234, Lng: 139.5678, }, Lang: "cn", }} res.Hops[1] = []Hop{{ Success: false, TTL: 2, Error: errHopLimitTimeout, }} if cfg.RealtimePrinter != nil { cfg.RealtimePrinter(res, 0) cfg.RealtimePrinter(res, 1) } return res, nil } var records []MTRRawRecord err := RunMTRRaw(context.Background(), ICMPTrace, Config{Lang: "cn"}, MTRRawOptions{ MaxRounds: 1, Interval: time.Millisecond, }, func(rec MTRRawRecord) { records = append(records, rec) }) if err != nil { t.Fatalf("RunMTRRaw returned error: %v", err) } if len(records) != 2 { t.Fatalf("expected 2 records, got %d", len(records)) } if !records[0].Success || records[0].TTL != 1 || records[0].IP != "1.1.1.1" { t.Fatalf("unexpected first record: %+v", records[0]) } if records[0].ASN != "13335" || records[0].Country == "" || records[0].Owner != "Cloudflare" { t.Fatalf("first record geo fields missing: %+v", records[0]) } if records[1].Success || records[1].TTL != 2 { t.Fatalf("unexpected timeout record: %+v", records[1]) } } func TestRunMTRRaw_RespectsMaxRoundsAndInterval(t *testing.T) { old := mtrRawTracerouteFn t.Cleanup(func() { mtrRawTracerouteFn = old }) calls := 0 mtrRawTracerouteFn = func(_ Method, _ Config) (*Result, error) { calls++ return &Result{Hops: make([][]Hop, 0)}, nil } start := time.Now() err := RunMTRRaw(context.Background(), ICMPTrace, Config{}, MTRRawOptions{ MaxRounds: 3, Interval: 20 * time.Millisecond, }, nil) if err != nil { t.Fatalf("RunMTRRaw returned error: %v", err) } if calls != 3 { t.Fatalf("traceroute call count = %d, want 3", calls) } // Three rounds wait twice at 20ms each; allow a small scheduler tolerance below 40ms. if time.Since(start) < 38*time.Millisecond { t.Fatalf("interval appears not applied, elapsed=%v", time.Since(start)) } } func TestRunMTRRaw_ContextCancelStopsLoop(t *testing.T) { old := mtrRawTracerouteFn t.Cleanup(func() { mtrRawTracerouteFn = old }) mtrRawTracerouteFn = func(_ Method, _ Config) (*Result, error) { time.Sleep(120 * time.Millisecond) return &Result{Hops: make([][]Hop, 0)}, nil } ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(20 * time.Millisecond) cancel() }() start := time.Now() err := RunMTRRaw(ctx, ICMPTrace, Config{}, MTRRawOptions{ MaxRounds: 10, Interval: time.Second, }, nil) if err == nil { t.Fatal("expected context cancellation error") } elapsed := time.Since(start) if elapsed < 100*time.Millisecond { t.Fatalf("RunMTRRaw should wait for in-flight round to finish before returning, elapsed=%v", elapsed) } if elapsed > 250*time.Millisecond { t.Fatalf("cancel took unexpectedly long, elapsed=%v", elapsed) } } func TestRunMTRRaw_UsesRunRoundOverride(t *testing.T) { calls := 0 err := RunMTRRaw(context.Background(), ICMPTrace, Config{}, MTRRawOptions{ MaxRounds: 1, RunRound: func(_ Method, cfg Config) (*Result, error) { calls++ if cfg.RealtimePrinter == nil { t.Fatal("expected RealtimePrinter to be populated for raw streaming") } return &Result{Hops: make([][]Hop, 0)}, nil }, }, nil) if err != nil { t.Fatalf("RunMTRRaw returned error: %v", err) } if calls != 1 { t.Fatalf("RunRound override called %d times, want 1", calls) } } func TestRunMTRRaw_RoundBasedNormalizesRuntimeConfig(t *testing.T) { oldSrcDev := util.SrcDev oldDisableMPLS := util.DisableMPLS t.Cleanup(func() { util.SrcDev = oldSrcDev util.DisableMPLS = oldDisableMPLS }) util.SrcDev = "en0" util.DisableMPLS = true calls := 0 err := RunMTRRaw(context.Background(), ICMPTrace, Config{}, MTRRawOptions{ MaxRounds: 1, RunRound: func(_ Method, cfg Config) (*Result, error) { calls++ if cfg.SourceDevice != "en0" { t.Fatalf("cfg.SourceDevice = %q, want en0", cfg.SourceDevice) } if cfg.DisableMPLS { t.Fatal("cfg.DisableMPLS = true, want false") } return &Result{Hops: make([][]Hop, 0)}, nil }, }, nil) if err != nil { t.Fatalf("RunMTRRaw returned error: %v", err) } if calls != 1 { t.Fatalf("RunRound override called %d times, want 1", calls) } } ================================================ FILE: trace/mtr_runner.go ================================================ package trace import ( "context" "fmt" "math/rand" "net" "os" "sync" "sync/atomic" "time" "github.com/google/gopacket/layers" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) // --------------------------------------------------------------------------- // MTR 长驻运行器 // --------------------------------------------------------------------------- // MTROptions 控制 MTR 连续探测行为。 type MTROptions struct { // Interval 每轮之间的等待间隔(默认 1s)。 // 仅在 legacy round-based 模式(Web MTR)使用。 Interval time.Duration // MaxRounds 最大轮次,0 表示无限运行直到取消。 // 仅在 legacy round-based 模式使用。 MaxRounds int // HopInterval 同一 TTL 两次探测之间的间隔(per-hop 调度模式)。 // > 0 时启用 per-hop 调度,忽略 Interval / MaxRounds。 HopInterval time.Duration // MaxPerHop 每个 TTL 最大探测次数,0 表示无限。 MaxPerHop int // IsPaused 可选:返回 true 时暂停探测(轮询检查)。 IsPaused func() bool // IsResetRequested 可选:返回 true(原子消费)时重置统计。 IsResetRequested func() bool // ProgressThrottle 流式预览最小刷新间隔(默认 200ms)。 ProgressThrottle time.Duration } // MTROnSnapshot 每轮完成后的回调,用于刷新 CLI 表格。 // iteration 是当前轮次(从 1 开始),stats 是截至当前的聚合快照。 type MTROnSnapshot func(iteration int, stats []MTRHopStat) // mtrBackoffCfg 控制连续错误时的指数退避行为。 type mtrBackoffCfg struct { Initial time.Duration Max time.Duration MaxConsec int } var defaultBackoff = mtrBackoffCfg{ Initial: 500 * time.Millisecond, Max: 30 * time.Second, MaxConsec: 10, } // mtrProber 抽象一轮探测,允许测试注入 mock。 type mtrProber interface { probeRound(ctx context.Context) (*Result, error) close() } // mtrResetter 可选接口:重置统计时清除 prober 内部缓存。 type mtrResetter interface { resetFinalTTL() } // mtrPeeker 可选接口:支持流式预览探测进度。 // 引擎在 probeRound 执行期间,外部可调用 peekPartialResult 获取当前已收到的部分响应。 type mtrPeeker interface { peekPartialResult() *Result } // RunMTR 启动 MTR 连续探测模式。 // // 当 opts.HopInterval > 0 时使用 per-hop 独立调度(CLI MTR 模式): // // 每个 TTL 独立计时,完成后等 HopInterval 再发下一个。 // // 当 opts.HopInterval == 0 时使用 legacy round-based 调度(Web MTR 兼容): // // ICMP 使用持久 raw socket 跨轮复用;TCP/UDP 以 per-round Traceroute 回退。 func RunMTR(ctx context.Context, method Method, baseConfig Config, opts MTROptions, onSnapshot MTROnSnapshot) error { if opts.HopInterval > 0 { return runMTRPerHop(ctx, method, baseConfig, opts, onSnapshot) } return runMTRRoundBased(ctx, method, baseConfig, opts, onSnapshot) } // runMTRPerHop 使用 per-hop 独立调度模式。 func runMTRPerHop(ctx context.Context, method Method, baseConfig Config, opts MTROptions, onSnapshot MTROnSnapshot) error { normalizeRuntimeConfig(&baseConfig) baseConfig.NumMeasurements = 1 baseConfig.MaxAttempts = 1 baseConfig.RealtimePrinter = nil baseConfig.AsyncPrinter = nil if baseConfig.MaxHops == 0 { baseConfig.MaxHops = 30 } if baseConfig.ICMPMode <= 0 && util.EnvICMPMode > 0 { baseConfig.ICMPMode = util.EnvICMPMode } switch baseConfig.ICMPMode { case 0, 1, 2: default: baseConfig.ICMPMode = 0 } agg := NewMTRAggregator() var prober mtrTTLProber if method == ICMPTrace { engine, err := newMTRICMPEngine(baseConfig) if err != nil { return fmt.Errorf("mtr: %w", err) } defer engine.close() if err := engine.start(ctx); err != nil { engine.close() return fmt.Errorf("mtr: %w", err) } prober = engine } else { prober = &mtrFallbackTTLProber{method: method, config: baseConfig} } return runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{ BeginHop: baseConfig.BeginHop, MaxHops: baseConfig.MaxHops, HopInterval: opts.HopInterval, Timeout: baseConfig.Timeout, MaxPerHop: opts.MaxPerHop, ParallelRequests: baseConfig.ParallelRequests, ProgressThrottle: opts.ProgressThrottle, FillGeo: true, BaseConfig: baseConfig, DstIP: baseConfig.DstIP, IsPaused: opts.IsPaused, IsResetRequested: opts.IsResetRequested, }, onSnapshot, nil) } // runMTRRoundBased 使用 legacy round-based 调度模式(Web MTR 兼容)。 func runMTRRoundBased(ctx context.Context, method Method, baseConfig Config, opts MTROptions, onSnapshot MTROnSnapshot) error { normalizeRuntimeConfig(&baseConfig) if opts.Interval <= 0 { opts.Interval = time.Second } // MTR:每轮每 hop 仅一个探测包 baseConfig.NumMeasurements = 1 baseConfig.MaxAttempts = 1 // 注意:不覆盖 ParallelRequests——尊重用户 --parallel-requests 设定 baseConfig.RealtimePrinter = nil baseConfig.AsyncPrinter = nil // 与 Traceroute() 保持一致的默认值 if baseConfig.MaxHops == 0 { baseConfig.MaxHops = 30 } if baseConfig.ICMPMode <= 0 && util.EnvICMPMode > 0 { baseConfig.ICMPMode = util.EnvICMPMode } switch baseConfig.ICMPMode { case 0, 1, 2: default: baseConfig.ICMPMode = 0 } agg := NewMTRAggregator() if method == ICMPTrace { engine, err := newMTRICMPEngine(baseConfig) if err != nil { return fmt.Errorf("mtr: %w", err) } defer engine.close() if err := engine.start(ctx); err != nil { engine.close() return fmt.Errorf("mtr: %w", err) } return mtrLoop(ctx, engine, baseConfig, opts, agg, onSnapshot, true, nil) } prober := &mtrFallbackProber{method: method, config: baseConfig} // Traceroute 内部已做 geo/rdns 查询,无需再填充 return mtrLoop(ctx, prober, baseConfig, opts, agg, onSnapshot, false, nil) } // --------------------------------------------------------------------------- // 主循环(ICMP 持久引擎 / TCP·UDP 回退共用) // --------------------------------------------------------------------------- func mtrLoop( ctx context.Context, prober mtrProber, config Config, opts MTROptions, agg *MTRAggregator, onSnapshot MTROnSnapshot, fillGeo bool, bo *mtrBackoffCfg, ) error { defer prober.close() rt := newMTRLoopRuntime(ctx, prober, config, opts, agg, onSnapshot, fillGeo, bo) return rt.run() } // mtrFillGeoRDNS 并发查询 Result 中各 hop 的地理信息与反向 DNS。 // fetchIPData 内部有 singleflight + geoCache,重复 IP 不会重复查询。 func mtrFillGeoRDNS(res *Result, config Config) { var wg sync.WaitGroup for idx := range res.Hops { for j := range res.Hops[idx] { h := &res.Hops[idx][j] if !h.Success || h.Address == nil { continue } h.Lang = config.Lang wg.Add(1) go func(hop *Hop) { defer wg.Done() _ = hop.fetchIPData(config) }(h) } } wg.Wait() } // --------------------------------------------------------------------------- // 持久 ICMP 引擎(mtr 风格:raw socket 只创建一次,跨轮复用) // --------------------------------------------------------------------------- type mtrICMPEngine struct { config Config spec *internal.ICMPSpec echoID int srcIP net.IP ipVer int // 单调递增序列号,避免跨轮 seq 冲突 seqCounter uint32 // per-round 探针/响应匹配 mu sync.Mutex sentAt map[int]mtrProbeMeta // seq → probe metadata replied map[int]*mtrProbeReply notifyCh chan struct{} // 当前轮次 ID,用于丢弃过期响应 roundID uint32 // 已知目的地 TTL(-1 = 未知),跨轮保留以减少无效探测 knownFinalTTL int32 // 当前轮次内发现的目的地 TTL(-1 = 本轮未发现) roundFinalTTL int32 // 流式预览状态(peekPartialResult 使用,受 mu 保护) curTtlSeq map[int]int curBeginHop int curEffectiveMax int // Per-probe notification channels for ProbeTTL(受 mu 保护)。 probeNotify map[int]chan struct{} // seq → done chan // sendMu serializes seq allocation + rotation check in concurrent ProbeTTL calls. sendMu sync.Mutex } // mtrProbeMeta 记录已发送探针的元信息,用于响应匹配。 type mtrProbeMeta struct { ttl int start time.Time roundID uint32 } type mtrProbeReply struct { peer net.Addr rtt time.Duration mpls []string } func newMTRICMPEngine(config Config) (*mtrICMPEngine, error) { ipVer := 4 if config.DstIP.To4() == nil { ipVer = 6 } var srcAddr net.IP if config.SrcAddr != "" { srcAddr = net.ParseIP(config.SrcAddr) if ipVer == 4 && srcAddr != nil { srcAddr = srcAddr.To4() } } var srcIP net.IP if ipVer == 6 { srcIP, _ = util.LocalIPPortv6(config.DstIP, srcAddr, "icmp6") } else { srcIP, _ = util.LocalIPPort(config.DstIP, srcAddr, "icmp") } if srcIP == nil { return nil, fmt.Errorf("cannot determine local IP for MTR ICMP") } r := rand.New(rand.NewSource(time.Now().UnixNano())) echoID := (r.Intn(256) << 8) | (os.Getpid() & 0xFF) return &mtrICMPEngine{ config: config, ipVer: ipVer, echoID: echoID, srcIP: srcIP, knownFinalTTL: -1, }, nil } // start 创建持久 ICMP 套接字及监听协程。ctx 生命周期控制整个引擎。 func (e *mtrICMPEngine) start(ctx context.Context) error { e.spec = internal.NewICMPSpec(e.ipVer, e.config.ICMPMode, e.echoID, e.srcIP, e.config.DstIP) e.spec.InitICMP() e.notifyCh = make(chan struct{}, 1) e.sentAt = make(map[int]mtrProbeMeta) e.replied = make(map[int]*mtrProbeReply) e.probeNotify = make(map[int]chan struct{}) ready := make(chan struct{}) go e.spec.ListenICMP(ctx, ready, e.onICMP) select { case <-ready: case <-ctx.Done(): e.close() return ctx.Err() case <-time.After(5 * time.Second): e.close() return fmt.Errorf("ICMP listener startup timeout") } time.Sleep(100 * time.Millisecond) return nil } func (e *mtrICMPEngine) close() { if e.spec != nil { e.spec.Close() e.spec = nil } } // resetFinalTTL 清除已知目的地 TTL 缓存(r 键重置统计时调用)。 func (e *mtrICMPEngine) resetFinalTTL() { atomic.StoreInt32(&e.knownFinalTTL, -1) } // peekPartialResult 返回当前轮次已收到的部分探测结果(用于流式预览)。 // 在 probeRound 运行期间由外部 ticker 调用,与 probeRound 共享 mu 保护。 func (e *mtrICMPEngine) peekPartialResult() *Result { e.mu.Lock() defer e.mu.Unlock() beginHop := e.curBeginHop effectiveMax := e.curEffectiveMax if effectiveMax <= 0 || e.curTtlSeq == nil { return nil } // 若已检测到目的地,缩小预览范围 if rf := atomic.LoadInt32(&e.roundFinalTTL); rf > 0 && int(rf) < effectiveMax { effectiveMax = int(rf) } res := &Result{Hops: make([][]Hop, effectiveMax)} for ttl := beginHop; ttl <= effectiveMax; ttl++ { idx := ttl - 1 seq, sent := e.curTtlSeq[ttl] if !sent { // 尚未发送,保持 nil 槽位让聚合器跳过 continue } if reply, ok := e.replied[seq]; ok { res.Hops[idx] = []Hop{{ Success: true, Address: reply.peer, TTL: ttl, RTT: reply.rtt, MPLS: reply.mpls, }} } else { // 已发送但未收到响应,显示为超时 res.Hops[idx] = []Hop{{ Success: false, Address: nil, TTL: ttl, RTT: 0, }} } } return res } // seqWillWrap 判断再发 probeCount 个探针后 16 位 wire seq 是否会回卷。 // probeCount <= 0 时不发包,不可能回卷。 func seqWillWrap(seqCounter uint32, probeCount int) bool { if probeCount <= 0 { return false } return (seqCounter&0xFFFF)+uint32(probeCount) > 0xFFFF } // rotateEngine 关闭旧 ICMP 监听器,生成新 echoID 并重建引擎。 // 新 listener 过滤新 echoID,旧 echoID 的迟到回包在协议层即被丢弃, // 从而彻底消除 seq 16 位回卷导致的跨轮误匹配。 func (e *mtrICMPEngine) rotateEngine(ctx context.Context) error { e.spec.Close() r := rand.New(rand.NewSource(time.Now().UnixNano())) e.echoID = (r.Intn(256) << 8) | (os.Getpid() & 0xFF) atomic.StoreUint32(&e.seqCounter, 0) e.spec = internal.NewICMPSpec(e.ipVer, e.config.ICMPMode, e.echoID, e.srcIP, e.config.DstIP) e.spec.InitICMP() e.mu.Lock() // Notify any ProbeTTL waiters about rotation (they'll see no reply) for seq, ch := range e.probeNotify { close(ch) delete(e.probeNotify, seq) } e.notifyCh = make(chan struct{}, 1) e.sentAt = make(map[int]mtrProbeMeta) e.replied = make(map[int]*mtrProbeReply) e.probeNotify = make(map[int]chan struct{}) e.mu.Unlock() ready := make(chan struct{}) go e.spec.ListenICMP(ctx, ready, e.onICMP) select { case <-ready: case <-ctx.Done(): e.close() return ctx.Err() case <-time.After(5 * time.Second): e.close() return fmt.Errorf("ICMP listener restart timeout on echoID rotation") } return nil } // onICMP 是 ListenICMP 的回调:将响应匹配到已发送的探针。 func (e *mtrICMPEngine) onICMP(msg internal.ReceivedMessage, finish time.Time, seq int) { e.mu.Lock() start, ok := e.sentAt[seq] if !ok { e.mu.Unlock() return } if e.shouldDiscardProbeReplyLocked(seq, start, finish) { e.mu.Unlock() return } rtt := finish.Sub(start.start) e.storeProbeReplyLocked(seq, msg, rtt) finalTTL := e.detectRoundFinalTTLCandidate(msg.Peer, start.ttl) e.mu.Unlock() e.updateRoundFinalTTL(finalTTL) e.signalReplyReady() } func (e *mtrICMPEngine) shouldDiscardProbeReplyLocked(seq int, start mtrProbeMeta, finish time.Time) bool { if start.roundID != atomic.LoadUint32(&e.roundID) { e.discardProbeLocked(seq) return true } if !e.validProbeRTT(finish.Sub(start.start)) { e.discardProbeLocked(seq) return true } return false } func (e *mtrICMPEngine) validProbeRTT(rtt time.Duration) bool { maxRTT := e.config.Timeout if maxRTT <= 0 { maxRTT = 2 * time.Second } return rtt > 0 && rtt <= maxRTT } func (e *mtrICMPEngine) discardProbeLocked(seq int) { delete(e.sentAt, seq) e.closeProbeNotifyLocked(seq) } func (e *mtrICMPEngine) storeProbeReplyLocked(seq int, msg internal.ReceivedMessage, rtt time.Duration) { e.replied[seq] = &mtrProbeReply{ peer: msg.Peer, rtt: rtt, mpls: extractMPLS(msg, e.config.DisableMPLS), } delete(e.sentAt, seq) e.closeProbeNotifyLocked(seq) } func (e *mtrICMPEngine) closeProbeNotifyLocked(seq int) { if ch, ok := e.probeNotify[seq]; ok { close(ch) delete(e.probeNotify, seq) } } func (e *mtrICMPEngine) detectRoundFinalTTLCandidate(peer net.Addr, ttl int) int32 { peerIP := mtrPeerIP(peer) if peerIP == nil || !peerIP.Equal(e.config.DstIP) { return -1 } return int32(ttl) } func mtrPeerIP(addr net.Addr) net.IP { switch a := addr.(type) { case *net.IPAddr: return a.IP case *net.UDPAddr: return a.IP case *net.TCPAddr: return a.IP default: return nil } } func (e *mtrICMPEngine) updateRoundFinalTTL(ttl int32) { if ttl < 0 { return } curFinal := atomic.LoadInt32(&e.roundFinalTTL) if curFinal < 0 || ttl < curFinal { atomic.StoreInt32(&e.roundFinalTTL, ttl) } } func (e *mtrICMPEngine) signalReplyReady() { select { case e.notifyCh <- struct{}{}: default: } } // sendProbe 发送一个 ICMP echo(IPv4 或 IPv6),返回发送时间戳。 func (e *mtrICMPEngine) sendProbe(ctx context.Context, ttl, seq int) (time.Time, error) { payloadSize := resolveProbePayloadSize(ICMPTrace, e.config.DstIP, e.config.PktSize, e.config.RandomPacketSize) payload := make([]byte, payloadSize) if len(payload) >= 3 { copy(payload[len(payload)-3:], []byte{'n', 't', 'r'}) } if e.ipVer == 4 { ipHdr := &layers.IPv4{ Version: 4, SrcIP: e.srcIP, DstIP: e.config.DstIP, Protocol: layers.IPProtocolICMPv4, TTL: uint8(ttl), TOS: uint8(e.config.TOS), } icmpHdr := &layers.ICMPv4{ TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0), Id: uint16(e.echoID), Seq: uint16(seq), } return e.spec.SendICMP(ctx, ipHdr, icmpHdr, nil, payload) } // IPv6 ipHdr := &layers.IPv6{ Version: 6, SrcIP: e.srcIP, DstIP: e.config.DstIP, NextHeader: layers.IPProtocolICMPv6, HopLimit: uint8(ttl), TrafficClass: uint8(e.config.TOS), } icmpHdr := &layers.ICMPv6{ TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0), } if err := icmpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil { return time.Time{}, fmt.Errorf("SetNetworkLayerForChecksum: %w", err) } icmpEcho := &layers.ICMPv6Echo{ Identifier: uint16(e.echoID), SeqNumber: uint16(seq), } return e.spec.SendICMP(ctx, ipHdr, icmpHdr, icmpEcho, payload) } // probeRound 执行一轮持久探测:对每个 TTL 发送一个 ICMP echo, // 收集响应后构造与 Traceroute 兼容的 *Result。 // 若已知目的地 TTL 则提前停止发送。 func (e *mtrICMPEngine) probeRound(ctx context.Context) (*Result, error) { round := e.prepareProbeRound(ctx) if round.err != nil { return nil, round.err } if err := e.sendProbeSweep(ctx, round); err != nil { return nil, err } e.waitForProbeReplies(ctx) return e.buildProbeRoundResult(round.beginHop, e.finalizeProbeRound(round.effectiveMax)), nil } type mtrProbeRoundState struct { beginHop int effectiveMax int roundID uint32 probeDelay time.Duration err error } func (e *mtrICMPEngine) prepareProbeRound(ctx context.Context) mtrProbeRoundState { maxHops := e.config.MaxHops beginHop := e.config.BeginHop if beginHop <= 0 { beginHop = 1 } curRound := atomic.AddUint32(&e.roundID, 1) atomic.StoreInt32(&e.roundFinalTTL, -1) e.resetProbeRoundMaps() e.drainNotifySignal() probeCount := maxHops - beginHop + 1 if probeCount < 1 { probeCount = 1 } if err := e.rotateProbeEngineIfNeeded(ctx, probeCount); err != nil { return mtrProbeRoundState{err: fmt.Errorf("echoID rotation: %w", err)} } effectiveMax := e.effectiveProbeRoundMax(maxHops) e.initProbeRoundPreview(beginHop, effectiveMax) return mtrProbeRoundState{ beginHop: beginHop, effectiveMax: effectiveMax, roundID: curRound, probeDelay: e.probeRoundDelay(), } } func (e *mtrICMPEngine) resetProbeRoundMaps() { e.mu.Lock() e.sentAt = make(map[int]mtrProbeMeta) e.replied = make(map[int]*mtrProbeReply) e.mu.Unlock() } func (e *mtrICMPEngine) drainNotifySignal() { select { case <-e.notifyCh: default: } } func (e *mtrICMPEngine) rotateProbeEngineIfNeeded(ctx context.Context, probeCount int) error { if !seqWillWrap(atomic.LoadUint32(&e.seqCounter), probeCount) { return nil } return e.rotateEngine(ctx) } func (e *mtrICMPEngine) effectiveProbeRoundMax(maxHops int) int { if kf := atomic.LoadInt32(&e.knownFinalTTL); kf > 0 && int(kf) < maxHops { return int(kf) } return maxHops } func (e *mtrICMPEngine) initProbeRoundPreview(beginHop, effectiveMax int) { e.mu.Lock() e.curTtlSeq = make(map[int]int, effectiveMax-beginHop+1) e.curBeginHop = beginHop e.curEffectiveMax = effectiveMax e.mu.Unlock() } func (e *mtrICMPEngine) probeRoundDelay() time.Duration { probeDelay := time.Millisecond * time.Duration(e.config.PacketInterval) if probeDelay <= 0 { return 5 * time.Millisecond } return probeDelay } func (e *mtrICMPEngine) sendProbeSweep(ctx context.Context, round mtrProbeRoundState) error { for ttl := round.beginHop; ttl <= round.effectiveMax; ttl++ { if ctx.Err() != nil { return ctx.Err() } sent, err := e.sendProbeForTTL(ctx, ttl, round.roundID) if err != nil { return err } if !sent { continue } if err := e.waitProbeInterval(ctx, round.probeDelay); err != nil { return err } } return nil } func (e *mtrICMPEngine) sendProbeForTTL(ctx context.Context, ttl int, roundID uint32) (bool, error) { seq := int(atomic.AddUint32(&e.seqCounter, 1) & 0xFFFF) // Pre-register the seq so onICMP can match it even for very short RTT replies. preStart := time.Now() e.mu.Lock() e.sentAt[seq] = mtrProbeMeta{ttl: ttl, start: preStart, roundID: roundID} e.curTtlSeq[ttl] = seq e.mu.Unlock() start, err := e.sendProbe(ctx, ttl, seq) if err != nil { // Roll back the pre-registered state on send failure. e.mu.Lock() delete(e.sentAt, seq) if e.curTtlSeq[ttl] == seq { delete(e.curTtlSeq, ttl) } e.mu.Unlock() if ctx.Err() != nil { return false, ctx.Err() } return false, nil } // Update the start timestamp to the actual send time for accurate RTT. e.mu.Lock() if meta, ok := e.sentAt[seq]; ok { meta.start = start e.sentAt[seq] = meta } e.mu.Unlock() return true, nil } func (e *mtrICMPEngine) waitProbeInterval(ctx context.Context, delay time.Duration) error { select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): return nil } } func (e *mtrICMPEngine) waitForProbeReplies(ctx context.Context) { deadline := time.After(e.probeResponseTimeout()) for e.hasPendingProbeReplies() { select { case <-ctx.Done(): return case <-deadline: return case <-e.notifyCh: } } } func (e *mtrICMPEngine) probeResponseTimeout() time.Duration { timeout := e.config.Timeout if timeout <= 0 { return 2 * time.Second } return timeout } func (e *mtrICMPEngine) hasPendingProbeReplies() bool { e.mu.Lock() defer e.mu.Unlock() return len(e.sentAt) > 0 } func (e *mtrICMPEngine) finalizeProbeRound(effectiveMax int) int { e.updateKnownFinalTTLFromRound() return e.roundFinalMax(effectiveMax) } func (e *mtrICMPEngine) updateKnownFinalTTLFromRound() { if rf := atomic.LoadInt32(&e.roundFinalTTL); rf > 0 { kf := atomic.LoadInt32(&e.knownFinalTTL) if kf < 0 || rf < kf { atomic.StoreInt32(&e.knownFinalTTL, rf) } } } func (e *mtrICMPEngine) roundFinalMax(effectiveMax int) int { if rf := atomic.LoadInt32(&e.roundFinalTTL); rf > 0 && int(rf) < effectiveMax { return int(rf) } return effectiveMax } func (e *mtrICMPEngine) buildProbeRoundResult(beginHop, finalMax int) *Result { res := &Result{Hops: make([][]Hop, finalMax)} e.mu.Lock() defer e.mu.Unlock() for ttl := beginHop; ttl <= finalMax; ttl++ { res.Hops[ttl-1] = []Hop{e.probeRoundHop(ttl)} } return res } func (e *mtrICMPEngine) probeRoundHop(ttl int) Hop { if seq, sent := e.curTtlSeq[ttl]; sent { if reply, ok := e.replied[seq]; ok { return Hop{ Success: true, Address: reply.peer, TTL: ttl, RTT: reply.rtt, MPLS: reply.mpls, } } } return Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, Error: errHopLimitTimeout, } } // --------------------------------------------------------------------------- // TCP/UDP 回退 prober:每轮调用 Traceroute + 指数退避 // --------------------------------------------------------------------------- type mtrFallbackProber struct { method Method config Config } func (p *mtrFallbackProber) probeRound(ctx context.Context) (*Result, error) { return TracerouteWithContext(ctx, p.method, p.config) } func (p *mtrFallbackProber) close() {} // --------------------------------------------------------------------------- // ProbeTTL — single-TTL probing for per-hop scheduler // --------------------------------------------------------------------------- // ProbeTTL sends one ICMP echo at the given TTL and blocks until a response // arrives, the timeout elapses, or ctx is cancelled. func (e *mtrICMPEngine) ProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error) { // Serialize seq allocation + rotation check across concurrent ProbeTTL calls. e.sendMu.Lock() if seqWillWrap(atomic.LoadUint32(&e.seqCounter), 1) { if err := e.rotateEngine(ctx); err != nil { e.sendMu.Unlock() return mtrProbeResult{TTL: ttl}, fmt.Errorf("echoID rotation: %w", err) } } seq := int(atomic.AddUint32(&e.seqCounter, 1) & 0xFFFF) e.sendMu.Unlock() curRound := atomic.LoadUint32(&e.roundID) done := make(chan struct{}) // Pre-register before sending so onICMP can match even very short RTT replies. preStart := time.Now() e.mu.Lock() e.sentAt[seq] = mtrProbeMeta{ttl: ttl, start: preStart, roundID: curRound} e.probeNotify[seq] = done e.mu.Unlock() sendStart, err := e.sendProbe(ctx, ttl, seq) if err != nil { // Roll back pre-registered state. e.mu.Lock() delete(e.sentAt, seq) e.closeProbeNotifyLocked(seq) e.mu.Unlock() if ctx.Err() != nil { return mtrProbeResult{TTL: ttl}, ctx.Err() } // Send failed: treat as timeout (no response for this TTL) return mtrProbeResult{TTL: ttl}, nil } // Update to actual send timestamp for accurate RTT. e.mu.Lock() if meta, ok := e.sentAt[seq]; ok { meta.start = sendStart e.sentAt[seq] = meta } e.mu.Unlock() timeout := e.config.Timeout if timeout <= 0 { timeout = 2 * time.Second } timer := time.NewTimer(timeout) defer timer.Stop() select { case <-done: e.mu.Lock() reply, ok := e.replied[seq] if ok { delete(e.replied, seq) } e.mu.Unlock() if ok && reply != nil { return mtrProbeResult{ TTL: ttl, Success: true, Addr: reply.peer, RTT: reply.rtt, MPLS: reply.mpls, }, nil } // Notified but no reply → was discarded (stale/bad RTT) return mtrProbeResult{TTL: ttl}, nil case <-timer.C: e.mu.Lock() delete(e.sentAt, seq) delete(e.probeNotify, seq) e.mu.Unlock() return mtrProbeResult{TTL: ttl}, nil case <-ctx.Done(): e.mu.Lock() delete(e.sentAt, seq) delete(e.probeNotify, seq) e.mu.Unlock() return mtrProbeResult{TTL: ttl}, ctx.Err() } } // Reset invalidates all in-flight probes (bumps roundID) and clears // knownFinalTTL. In-flight ProbeTTL calls get notified and return immediately. func (e *mtrICMPEngine) Reset() error { e.resetFinalTTL() atomic.AddUint32(&e.roundID, 1) e.mu.Lock() for seq, ch := range e.probeNotify { close(ch) delete(e.probeNotify, seq) } e.sentAt = make(map[int]mtrProbeMeta) e.replied = make(map[int]*mtrProbeReply) e.mu.Unlock() return nil } // Close releases the underlying ICMP socket. func (e *mtrICMPEngine) Close() error { e.close() return nil } // --------------------------------------------------------------------------- // TCP/UDP 回退 TTL prober:单 TTL 探测 // --------------------------------------------------------------------------- // mtrFallbackTTLProber uses Traceroute for single-TTL probing (TCP/UDP fallback). type mtrFallbackTTLProber struct { method Method config Config } func (p *mtrFallbackTTLProber) ProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error) { cfg := p.config cfg.BeginHop = ttl cfg.MaxHops = ttl cfg.NumMeasurements = 1 cfg.MaxAttempts = 1 cfg.ParallelRequests = 1 cfg.RealtimePrinter = nil cfg.AsyncPrinter = nil res, err := TracerouteWithContext(ctx, p.method, cfg) if err != nil { return mtrProbeResult{TTL: ttl}, err } idx := ttl - 1 if idx < 0 || idx >= len(res.Hops) || len(res.Hops[idx]) == 0 { return mtrProbeResult{TTL: ttl}, nil } h := res.Hops[idx][0] return mtrProbeResult{ TTL: ttl, Success: h.Success && h.Address != nil, Addr: h.Address, RTT: h.RTT, MPLS: h.MPLS, Hostname: h.Hostname, Geo: h.Geo, }, nil } func (p *mtrFallbackTTLProber) Reset() error { return nil } func (p *mtrFallbackTTLProber) Close() error { return nil } ================================================ FILE: trace/mtr_runner_test.go ================================================ package trace import ( "context" "errors" "net" "sync/atomic" "testing" "time" "github.com/nxtrace/NTrace-core/trace/internal" ) // --------------------------------------------------------------------------- // Mock prober(实现 mtrProber 接口) // --------------------------------------------------------------------------- type mockProber struct { roundFn func(ctx context.Context) (*Result, error) closed int32 } func (m *mockProber) probeRound(ctx context.Context) (*Result, error) { return m.roundFn(ctx) } func (m *mockProber) close() { atomic.AddInt32(&m.closed, 1) } func constantResultProber(res *Result) *mockProber { return &mockProber{ roundFn: func(_ context.Context) (*Result, error) { return res, nil }, } } // 快速退避配置,避免测试阻塞 var fastBackoff = &mtrBackoffCfg{ Initial: time.Millisecond, Max: 5 * time.Millisecond, MaxConsec: 5, } // --------------------------------------------------------------------------- // 测试用例:通过 mtrLoop + mockProber 覆盖 RunMTR 主循环逻辑 // --------------------------------------------------------------------------- func TestMTRLoopMaxRounds(t *testing.T) { maxRounds := 5 res := mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, []Hop{mkHop(2, "2.2.2.2", 20*time.Millisecond)}, ) prober := constantResultProber(res) agg := NewMTRAggregator() var snapshots int err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: maxRounds, Interval: time.Millisecond, }, agg, func(iter int, stats []MTRHopStat) { snapshots++ }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } if snapshots != maxRounds { t.Errorf("expected %d snapshots, got %d", maxRounds, snapshots) } if atomic.LoadInt32(&prober.closed) != 1 { t.Error("prober.close() was not called") } } func TestMTRLoopCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var count int32 prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { n := atomic.AddInt32(&count, 1) if n >= 3 { cancel() } return mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, ), nil }, } agg := NewMTRAggregator() err := mtrLoop(ctx, prober, Config{}, MTROptions{ Interval: time.Millisecond, }, agg, func(_ int, _ []MTRHopStat) {}, false, fastBackoff) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } c := atomic.LoadInt32(&count) if c < 3 { t.Errorf("expected at least 3 probe rounds, got %d", c) } } func TestMTRLoopErrorBackoff(t *testing.T) { errProbe := errors.New("temporary error") var callTimes []time.Time prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { callTimes = append(callTimes, time.Now()) return nil, errProbe }, } agg := NewMTRAggregator() bo := &mtrBackoffCfg{ Initial: 10 * time.Millisecond, Max: 50 * time.Millisecond, MaxConsec: 5, } err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ Interval: time.Millisecond, }, agg, func(_ int, _ []MTRHopStat) {}, false, bo) if err == nil { t.Fatal("expected error from consecutive failures") } if len(callTimes) != bo.MaxConsec { t.Errorf("expected %d probe calls, got %d", bo.MaxConsec, len(callTimes)) } // 验证退避间隔递增(至少前几两次差值应递增) if len(callTimes) >= 3 { gap1 := callTimes[1].Sub(callTimes[0]) gap2 := callTimes[2].Sub(callTimes[1]) if gap2 <= gap1/2 { t.Errorf("expected increasing backoff gaps, gap1=%v gap2=%v", gap1, gap2) } } } func TestMTRLoopErrorRecovery(t *testing.T) { var count int32 errProbe := errors.New("temporary error") prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { n := atomic.AddInt32(&count, 1) if n <= 3 { return nil, errProbe } return mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, ), nil }, } agg := NewMTRAggregator() var snapshots int err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 2, Interval: time.Millisecond, }, agg, func(_ int, _ []MTRHopStat) { snapshots++ }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } if snapshots != 2 { t.Errorf("expected 2 successful snapshots after recovery, got %d", snapshots) } } func TestMTRLoopTimeoutHops(t *testing.T) { res := mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, []Hop{mkTimeoutHop(2)}, []Hop{mkHop(3, "3.3.3.3", 30*time.Millisecond)}, ) prober := constantResultProber(res) agg := NewMTRAggregator() var finalStats []MTRHopStat err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 1, Interval: time.Millisecond, }, agg, func(_ int, stats []MTRHopStat) { finalStats = stats }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(finalStats) != 3 { t.Fatalf("expected 3 rows, got %d", len(finalStats)) } if finalStats[1].Loss != 100 { t.Errorf("expected 100%% loss for timeout hop, got %f", finalStats[1].Loss) } } func TestMTRLoopSnapshotIterations(t *testing.T) { var round int32 prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { n := atomic.AddInt32(&round, 1) return mkResult( []Hop{mkHop(1, "1.1.1.1", time.Duration(n)*time.Millisecond)}, ), nil }, } agg := NewMTRAggregator() var iterations []int err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 3, Interval: time.Millisecond, }, agg, func(iter int, _ []MTRHopStat) { iterations = append(iterations, iter) }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } expected := []int{1, 2, 3} if len(iterations) != len(expected) { t.Fatalf("expected %d iterations, got %d", len(expected), len(iterations)) } for i, v := range expected { if iterations[i] != v { t.Errorf("iteration %d: expected %d, got %d", i, v, iterations[i]) } } } func TestMTRLoopCloseCalledOnError(t *testing.T) { prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { return nil, errors.New("always fail") }, } agg := NewMTRAggregator() _ = mtrLoop(context.Background(), prober, Config{}, MTROptions{ Interval: time.Millisecond, }, agg, nil, false, fastBackoff) if atomic.LoadInt32(&prober.closed) != 1 { t.Error("prober.close() was not called on error exit") } } func TestMTRLoopCloseCalledOnSuccess(t *testing.T) { prober := constantResultProber(mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, )) agg := NewMTRAggregator() _ = mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 1, Interval: time.Millisecond, }, agg, nil, false, fastBackoff) if atomic.LoadInt32(&prober.closed) != 1 { t.Error("prober.close() was not called on normal exit") } } func TestMTRLoopNilOnSnapshot(t *testing.T) { prober := constantResultProber(mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, )) agg := NewMTRAggregator() // 确保 onSnapshot=nil 不 panic err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 2, Interval: time.Millisecond, }, agg, nil, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } } // --------------------------------------------------------------------------- // Bug-fix 验证测试 // --------------------------------------------------------------------------- // TestMTRLoopCancelDuringIntervalCallsSnapshot 验证在 interval 等待期间 // ctx 取消时仍然会调用 onSnapshot(Bug fix #2)。 func TestMTRLoopCancelDuringIntervalCallsSnapshot(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var round int32 var lastSnapshotIter int prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { n := atomic.AddInt32(&round, 1) if n >= 2 { // 第二轮成功后,在间隔等待期间取消 go func() { time.Sleep(10 * time.Millisecond) cancel() }() } return mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, ), nil }, } agg := NewMTRAggregator() err := mtrLoop(ctx, prober, Config{}, MTROptions{ Interval: 5 * time.Second, // 足够长,确保在间隔中取消 }, agg, func(iter int, _ []MTRHopStat) { lastSnapshotIter = iter }, false, fastBackoff) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } // 关键:取消路径也应调用 onSnapshot if lastSnapshotIter < 2 { t.Errorf("expected at least 2 snapshot calls (last iter=%d)", lastSnapshotIter) } } // TestMTRLoopCancelDuringBackoffCallsSnapshot 验证在错误退避等待期间 // ctx 取消时也会调用 onSnapshot(Bug fix #2 扩展)。 func TestMTRLoopCancelDuringBackoffCallsSnapshot(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) // 第一轮成功,第二轮失败,然后在退避期间取消 var count int32 var snapshotCalled int32 prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { n := atomic.AddInt32(&count, 1) if n == 1 { return mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, ), nil } // 第二轮失败,在退避等待期间取消 go func() { time.Sleep(10 * time.Millisecond) cancel() }() return nil, errors.New("fail") }, } agg := NewMTRAggregator() bo := &mtrBackoffCfg{ Initial: 5 * time.Second, // 长退避,确保在退避中取消 Max: 10 * time.Second, MaxConsec: 5, } err := mtrLoop(ctx, prober, Config{}, MTROptions{ Interval: time.Millisecond, }, agg, func(iter int, _ []MTRHopStat) { atomic.AddInt32(&snapshotCalled, 1) }, false, bo) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } // 退避取消路径也应调用 onSnapshot if atomic.LoadInt32(&snapshotCalled) < 1 { t.Error("expected onSnapshot to be called during backoff cancel") } } // TestMTRLoopPause 验证 IsPaused 暂停探测行为。 func TestMTRLoopPause(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() var pauseFlag int32 // 1 = paused var round int32 prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { n := atomic.AddInt32(&round, 1) if n == 2 { // 第二轮后暂停 atomic.StoreInt32(&pauseFlag, 1) // 0.5s 后恢复 go func() { time.Sleep(100 * time.Millisecond) atomic.StoreInt32(&pauseFlag, 0) }() } return mkResult( []Hop{mkHop(1, "1.1.1.1", time.Duration(n)*time.Millisecond)}, ), nil }, } agg := NewMTRAggregator() var snapshots int err := mtrLoop(ctx, prober, Config{}, MTROptions{ MaxRounds: 4, Interval: time.Millisecond, IsPaused: func() bool { return atomic.LoadInt32(&pauseFlag) == 1 }, }, agg, func(iter int, _ []MTRHopStat) { snapshots++ }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } if snapshots != 4 { t.Errorf("expected 4 snapshots, got %d", snapshots) } } // --------------------------------------------------------------------------- // onICMP 直接单测:seq 回卷 + 迟到回包 + RTT 合理性检查 // --------------------------------------------------------------------------- // newTestICMPEngine 构造一个最小可测试的 mtrICMPEngine(不创建真实 socket)。 func newTestICMPEngine(timeout time.Duration) *mtrICMPEngine { if timeout <= 0 { timeout = 2 * time.Second } return &mtrICMPEngine{ config: Config{Timeout: timeout}, notifyCh: make(chan struct{}, 1), sentAt: make(map[int]mtrProbeMeta), replied: make(map[int]*mtrProbeReply), } } // TestOnICMP_NormalReply 正常回包应被接受。 func TestOnICMP_NormalReply(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreUint32(&e.roundID, 1) now := time.Now() seq := 42 e.sentAt[seq] = mtrProbeMeta{ttl: 3, start: now, roundID: 1} peer := &net.IPAddr{IP: net.ParseIP("8.8.8.8")} e.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(15*time.Millisecond), seq) if _, ok := e.replied[seq]; !ok { t.Fatal("normal reply should be accepted") } if e.replied[seq].rtt != 15*time.Millisecond { t.Errorf("expected RTT 15ms, got %v", e.replied[seq].rtt) } } // TestOnICMP_StaleRoundReply 旧轮次回包(roundID 不匹配)应被丢弃。 func TestOnICMP_StaleRoundReply(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreUint32(&e.roundID, 5) now := time.Now() seq := 100 // 旧轮次 roundID=3,当前轮次=5 e.sentAt[seq] = mtrProbeMeta{ttl: 2, start: now, roundID: 3} peer := &net.IPAddr{IP: net.ParseIP("1.2.3.4")} e.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(10*time.Millisecond), seq) if _, ok := e.replied[seq]; ok { t.Fatal("stale round reply should be discarded") } // sentAt 条目也应被删除 if _, ok := e.sentAt[seq]; ok { t.Fatal("stale sentAt entry should be cleaned up") } } // TestOnICMP_SeqWrapStaleReply 模拟 seq 16 位回卷后迟到回包场景。 // // 场景: // 1. 轮次 N 发送 seq=100,记录在 sentAt // 2. 经过 65536 次递增,seq 回卷到 100 // 3. 轮次 N+K 重新使用 seq=100,sentAt[100] 已更新为新轮次数据 // 4. 轮次 N 的迟到回包到达,finish 时间远晚于新轮次的发送时间 // // 预期:RTT > timeout,被 RTT 合理性检查丢弃。 func TestOnICMP_SeqWrapStaleReply(t *testing.T) { timeout := 2 * time.Second e := newTestICMPEngine(timeout) atomic.StoreUint32(&e.roundID, 2000) // 模拟新轮次刚刚发送 seq=100(1ms 前) newSendTime := time.Now() seq := 100 e.sentAt[seq] = mtrProbeMeta{ ttl: 5, start: newSendTime, roundID: 2000, } // 迟到回包:来自 ~36 分钟前的旧轮次,到达时间是 "现在" // RTT = now - newSendTime 中间插入一个巨大偏移来模拟跨轮错配 staleFinish := newSendTime.Add(5 * time.Second) // RTT 5s >> timeout 2s peer := &net.IPAddr{IP: net.ParseIP("10.0.0.1")} e.onICMP(internal.ReceivedMessage{Peer: peer}, staleFinish, seq) if _, ok := e.replied[seq]; ok { t.Fatal("stale reply with RTT > timeout should be discarded (seq wraparound)") } if _, ok := e.sentAt[seq]; ok { t.Fatal("sentAt entry should be cleaned up after stale discard") } } // TestOnICMP_NegativeRTT 时间倒退(finish < start)的回包应被丢弃。 func TestOnICMP_NegativeRTT(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreUint32(&e.roundID, 1) now := time.Now() seq := 200 e.sentAt[seq] = mtrProbeMeta{ttl: 1, start: now, roundID: 1} // finish 早于 start → RTT < 0 peer := &net.IPAddr{IP: net.ParseIP("10.0.0.2")} e.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(-100*time.Millisecond), seq) if _, ok := e.replied[seq]; ok { t.Fatal("negative RTT reply should be discarded") } } // TestOnICMP_ExactTimeoutBoundary RTT 恰好等于 timeout 的回包仍应被接受。 // 比较使用 > 而非 >=,刚好到达 timeout 的回包不算超时。 func TestOnICMP_ExactTimeoutBoundary(t *testing.T) { timeout := 2 * time.Second e := newTestICMPEngine(timeout) atomic.StoreUint32(&e.roundID, 1) now := time.Now() seq := 300 e.sentAt[seq] = mtrProbeMeta{ttl: 1, start: now, roundID: 1} // RTT == timeout(不是 >,而是恰好等于) peer := &net.IPAddr{IP: net.ParseIP("10.0.0.3")} e.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(timeout), seq) // RTT == timeout,不满足 rtt > maxRTT,应被接受 if _, ok := e.replied[seq]; !ok { t.Fatal("reply with RTT == timeout should still be accepted") } } // TestOnICMP_UnknownSeq 未知 seq 的回包应被静默忽略。 func TestOnICMP_UnknownSeq(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreUint32(&e.roundID, 1) peer := &net.IPAddr{IP: net.ParseIP("10.0.0.4")} e.onICMP(internal.ReceivedMessage{Peer: peer}, time.Now(), 999) if len(e.replied) != 0 { t.Fatal("unknown seq should not produce any reply") } } // --------------------------------------------------------------------------- // seqWillWrap 单测 // --------------------------------------------------------------------------- func TestSeqWillWrap_NoWrap(t *testing.T) { // 当前 seq=0,发 30 个探针,远不到 0xFFFF if seqWillWrap(0, 30) { t.Fatal("seq=0, probeCount=30 should not wrap") } } func TestSeqWillWrap_JustBelowBoundary(t *testing.T) { // 当前低 16 位 = 0xFFFF - 30 = 0xFFE1,发 30 个刚好不回卷 counter := uint32(0xFFFF - 30) if seqWillWrap(counter, 30) { t.Fatal("exactly fitting should not trigger wraparound") } } func TestSeqWillWrap_OneOver(t *testing.T) { // 当前低 16 位 = 0xFFFF - 29 = 0xFFE2,发 30 个会越界 counter := uint32(0xFFFF - 29) if !seqWillWrap(counter, 30) { t.Fatal("should detect imminent wraparound") } } func TestSeqWillWrap_AtMax(t *testing.T) { // 当前低 16 位 = 0xFFFF,发 1 个就越界 if !seqWillWrap(0xFFFF, 1) { t.Fatal("seq=0xFFFF + 1 probe must trigger wraparound") } } func TestSeqWillWrap_HighBitsIgnored(t *testing.T) { // seqCounter 高 16 位非零,低 16 位安全 counter := uint32(0x0003_0001) // 低 16 位 = 1 if seqWillWrap(counter, 30) { t.Fatal("high bits should be masked; low 16 bits = 1 with 30 probes is safe") } } func TestSeqWillWrap_HighBitsWrap(t *testing.T) { // seqCounter 高位非零,低 16 位接近边界 counter := uint32(0x0005_FFF0) // 低 16 位 = 0xFFF0 if !seqWillWrap(counter, 20) { t.Fatal("0xFFF0 + 20 > 0xFFFF, should detect wraparound") } } func TestSeqWillWrap_ZeroProbes(t *testing.T) { if seqWillWrap(0xFFFF, 0) { t.Fatal("probeCount=0 should never trigger wraparound") } } func TestSeqWillWrap_NegativeProbes(t *testing.T) { // beginHop > maxHops → probeCount 为负,不应回卷 if seqWillWrap(0xFFFF, -5) { t.Fatal("negative probeCount should never trigger wraparound") } } // TestProbeRound_BeginHopExceedsMaxHops 验证 beginHop > maxHops 时: // - seqWillWrap 不误判(不触发 rotateEngine) // - probeRound 返回 maxHops 长度的 Hops,但全部为 nil(两个循环均被跳过) // - seqCounter 不递增(未发送任何探针) func TestProbeRound_BeginHopExceedsMaxHops(t *testing.T) { e := newTestICMPEngine(2 * time.Second) e.config.BeginHop = 10 e.config.MaxHops = 5 // seqCounter 接近边界 — 若 probeCount 保护缺失会触发 rotateEngine(此处无 spec 会 panic) atomic.StoreUint32(&e.seqCounter, 0xFFF0) seqBefore := atomic.LoadUint32(&e.seqCounter) res, err := e.probeRound(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } // Hops 长度应为 maxHops if len(res.Hops) != 5 { t.Fatalf("expected 5 hop slots, got %d", len(res.Hops)) } // 两个 for ttl:=10; ttl<=5 循环都被跳过,Hops 全部为 nil for i, hops := range res.Hops { if hops != nil { t.Errorf("Hops[%d] should be nil (loop skipped), got %v", i, hops) } } // 未发送任何探针,seqCounter 应不变 if atomic.LoadUint32(&e.seqCounter) != seqBefore { t.Errorf("seqCounter should not change, was %d now %d", seqBefore, atomic.LoadUint32(&e.seqCounter)) } } // --------------------------------------------------------------------------- // 重置统计(r 键)测试 // --------------------------------------------------------------------------- // TestMTRLoop_RestartStatistics 验证 IsResetRequested 触发统计重置。 func TestMTRLoop_RestartStatistics(t *testing.T) { var round int32 var resetOnce int32 prober := &mockProber{ roundFn: func(_ context.Context) (*Result, error) { n := atomic.AddInt32(&round, 1) return mkResult( []Hop{mkHop(1, "1.1.1.1", time.Duration(n)*time.Millisecond)}, ), nil }, } agg := NewMTRAggregator() var iterations []int var sntValues []int err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 4, Interval: time.Millisecond, IsResetRequested: func() bool { // 第 2 轮后触发一次重置 r := atomic.LoadInt32(&round) if r == 2 && atomic.CompareAndSwapInt32(&resetOnce, 0, 1) { return true } return false }, }, agg, func(iter int, stats []MTRHopStat) { iterations = append(iterations, iter) if len(stats) > 0 { sntValues = append(sntValues, stats[0].Snt) } }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } // 重置后 iteration 从 0 重开,所以需要达到 4 轮才结束 // 轮次序列:round 1 → iter 1, round 2 → iter 2, [reset → iter=0], // round 3 → iter 1, round 4 → iter 2, round 5 → iter 3, round 6 → iter 4 // 最终必须 iteration 中出现 4 found := false for _, v := range iterations { if v == 4 { found = true } } if !found { t.Errorf("expected iteration to reach 4 after reset, got %v", iterations) } // 验证重置后 Snt 从 1 重新开始 sntOneCount := 0 for _, s := range sntValues { if s == 1 { sntOneCount++ } } // Snt=1 应至少出现 2 次(初始第一轮 + 重置后第一轮) if sntOneCount < 2 { t.Errorf("expected Snt=1 at least twice (initial + after reset), got %d occurrences in %v", sntOneCount, sntValues) } } // TestResetClearsKnownFinalTTL 验证 resetFinalTTL 清除已知目的地 TTL 缓存。 func TestResetClearsKnownFinalTTL(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreInt32(&e.knownFinalTTL, 5) e.resetFinalTTL() if got := atomic.LoadInt32(&e.knownFinalTTL); got != -1 { t.Errorf("expected knownFinalTTL=-1 after reset, got %d", got) } } // --------------------------------------------------------------------------- // 目的地停止测试 // --------------------------------------------------------------------------- // TestOnICMP_DetectsDestination 验证 onICMP 在 peer==DstIP 时设置 roundFinalTTL。 func TestOnICMP_DetectsDestination(t *testing.T) { e := newTestICMPEngine(2 * time.Second) e.config.DstIP = net.ParseIP("8.8.8.8") atomic.StoreUint32(&e.roundID, 1) atomic.StoreInt32(&e.roundFinalTTL, -1) atomic.StoreInt32(&e.knownFinalTTL, -1) now := time.Now() seq := 42 e.sentAt[seq] = mtrProbeMeta{ttl: 5, start: now, roundID: 1} peer := &net.IPAddr{IP: net.ParseIP("8.8.8.8")} e.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(15*time.Millisecond), seq) if got := atomic.LoadInt32(&e.roundFinalTTL); got != 5 { t.Errorf("expected roundFinalTTL=5, got %d", got) } } // TestOnICMP_NonDestinationDoesNotSetFinal 验证非目的地 hop 不设置 roundFinalTTL。 func TestOnICMP_NonDestinationDoesNotSetFinal(t *testing.T) { e := newTestICMPEngine(2 * time.Second) e.config.DstIP = net.ParseIP("8.8.8.8") atomic.StoreUint32(&e.roundID, 1) atomic.StoreInt32(&e.roundFinalTTL, -1) now := time.Now() seq := 42 e.sentAt[seq] = mtrProbeMeta{ttl: 3, start: now, roundID: 1} // 中间 hop,不是目的地 peer := &net.IPAddr{IP: net.ParseIP("10.0.0.1")} e.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(10*time.Millisecond), seq) if got := atomic.LoadInt32(&e.roundFinalTTL); got != -1 { t.Errorf("expected roundFinalTTL=-1 for non-destination, got %d", got) } } // --------------------------------------------------------------------------- // peekPartialResult 单测 // --------------------------------------------------------------------------- func TestPeekPartialResult_EmptyBeforeRound(t *testing.T) { e := newTestICMPEngine(2 * time.Second) // 未初始化 curTtlSeq → 返回 nil if got := e.peekPartialResult(); got != nil { t.Fatalf("expected nil before round, got %+v", got) } } func TestPeekPartialResult_PartialReplies(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreUint32(&e.roundID, 1) atomic.StoreInt32(&e.roundFinalTTL, -1) // 模拟 probeRound 已初始化 peek 状态 e.curBeginHop = 1 e.curEffectiveMax = 3 e.curTtlSeq = map[int]int{1: 10, 2: 11, 3: 12} // TTL 1 已收到响应,TTL 2/3 尚未 e.replied[10] = &mtrProbeReply{ peer: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, rtt: 5 * time.Millisecond, } res := e.peekPartialResult() if res == nil { t.Fatal("expected non-nil partial result") } if len(res.Hops) != 3 { t.Fatalf("expected 3 hop slots, got %d", len(res.Hops)) } // TTL 1: 成功 if len(res.Hops[0]) != 1 || !res.Hops[0][0].Success { t.Error("TTL 1 should be successful") } // TTL 2: 超时(尚未响应) if len(res.Hops[1]) != 1 || res.Hops[1][0].Success { t.Error("TTL 2 should be timeout (not replied)") } // TTL 3: 超时 if len(res.Hops[2]) != 1 || res.Hops[2][0].Success { t.Error("TTL 3 should be timeout") } } func TestPeekPartialResult_UnsentTTLsAreNil(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreUint32(&e.roundID, 1) atomic.StoreInt32(&e.roundFinalTTL, -1) // 模拟发送进行到一半:TTL 1-2 已发送,TTL 3-5 尚未 e.curBeginHop = 1 e.curEffectiveMax = 5 e.curTtlSeq = map[int]int{1: 10, 2: 11} // 3-5 不存在 // TTL 1 已回复 e.replied[10] = &mtrProbeReply{ peer: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, rtt: 5 * time.Millisecond, } res := e.peekPartialResult() if res == nil { t.Fatal("expected non-nil partial result") } if len(res.Hops) != 5 { t.Fatalf("expected 5 hop slots, got %d", len(res.Hops)) } // TTL 1: 已发送+已回复 → 成功 if res.Hops[0] == nil || !res.Hops[0][0].Success { t.Error("TTL 1 should be successful") } // TTL 2: 已发送+未回复 → 超时(非 nil) if res.Hops[1] == nil || res.Hops[1][0].Success { t.Error("TTL 2 should be timeout (sent but not replied)") } // TTL 3-5: 未发送 → nil(聚合器不计入 Snt/Loss) for i := 2; i < 5; i++ { if res.Hops[i] != nil { t.Errorf("TTL %d should be nil (unsent), got %+v", i+1, res.Hops[i]) } } } func TestPeekPartialResult_TrimsByRoundFinalTTL(t *testing.T) { e := newTestICMPEngine(2 * time.Second) atomic.StoreUint32(&e.roundID, 1) atomic.StoreInt32(&e.roundFinalTTL, 2) // 本轮已检测到目的地在 TTL 2 e.curBeginHop = 1 e.curEffectiveMax = 5 e.curTtlSeq = map[int]int{1: 10, 2: 11, 3: 12, 4: 13, 5: 14} res := e.peekPartialResult() if res == nil { t.Fatal("expected non-nil partial result") } // 应被裁剪到 TTL 2 if len(res.Hops) != 2 { t.Errorf("expected 2 hop slots (trimmed by roundFinalTTL), got %d", len(res.Hops)) } } // --------------------------------------------------------------------------- // mtrLoop 流式预览测试 // --------------------------------------------------------------------------- // mockPeekerProber 同时实现 mtrProber + mtrPeeker。 type mockPeekerProber struct { mockProber peekFn func() *Result } func (m *mockPeekerProber) peekPartialResult() *Result { if m.peekFn != nil { return m.peekFn() } return nil } func TestMTRLoop_StreamingProgress(t *testing.T) { // probeRound 耗时 300ms,ProgressThrottle 50ms // 在一轮中应产生多次预览 + 1 次最终快照 partialRes := mkResult( []Hop{mkHop(1, "1.1.1.1", 5*time.Millisecond)}, ) prober := &mockPeekerProber{ mockProber: mockProber{ roundFn: func(ctx context.Context) (*Result, error) { select { case <-time.After(300 * time.Millisecond): case <-ctx.Done(): return nil, ctx.Err() } return partialRes, nil }, }, peekFn: func() *Result { return partialRes }, } agg := NewMTRAggregator() var snapshotCount int32 err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 1, Interval: time.Millisecond, ProgressThrottle: 50 * time.Millisecond, }, agg, func(_ int, _ []MTRHopStat) { atomic.AddInt32(&snapshotCount, 1) }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } // 300ms / 50ms ≈ 6 次预览 + 1 次最终 ≈ 7,至少应有 2 次 count := atomic.LoadInt32(&snapshotCount) if count < 2 { t.Errorf("expected at least 2 snapshots (preview+final), got %d", count) } } func TestMTRLoop_NonPeekerNoStreaming(t *testing.T) { // 普通 mockProber 不实现 mtrPeeker,应正常工作(无预览) prober := constantResultProber(mkResult( []Hop{mkHop(1, "1.1.1.1", 5*time.Millisecond)}, )) agg := NewMTRAggregator() var snapshotCount int32 err := mtrLoop(context.Background(), prober, Config{}, MTROptions{ MaxRounds: 3, Interval: time.Millisecond, ProgressThrottle: time.Millisecond, }, agg, func(_ int, _ []MTRHopStat) { atomic.AddInt32(&snapshotCount, 1) }, false, fastBackoff) if err != nil { t.Fatalf("unexpected error: %v", err) } // 非 peeker 模式:每轮仅 1 次快照,共 3 次 if got := atomic.LoadInt32(&snapshotCount); got != 3 { t.Errorf("expected exactly 3 snapshots, got %d", got) } } ================================================ FILE: trace/mtr_scheduler.go ================================================ package trace import ( "context" "net" "time" "github.com/nxtrace/NTrace-core/ipgeo" ) // --------------------------------------------------------------------------- // Per-hop independent scheduler (CLI MTR mode) // --------------------------------------------------------------------------- // mtrProbeResult holds the outcome of a single TTL probe. type mtrProbeResult struct { TTL int Success bool Addr net.Addr RTT time.Duration MPLS []string Hostname string // pre-resolved PTR (fallback prober) Geo *ipgeo.IPGeoData // pre-resolved geo (fallback prober) } // mtrTTLProber abstracts single-TTL probing for the per-hop scheduler. type mtrTTLProber interface { // ProbeTTL sends a probe at the given TTL and blocks until response or timeout. ProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error) // Reset invalidates in-flight probes and clears internal caches (e.g. knownFinalTTL). Reset() error // Close releases underlying resources (sockets etc.). Close() error } // mtrSchedulerConfig configures the per-hop scheduler. type mtrSchedulerConfig struct { BeginHop int MaxHops int HopInterval time.Duration // delay between successive probes to the same TTL Timeout time.Duration // per-probe timeout; used to compute default MaxInFlightPerHop MaxPerHop int // 0 = unlimited (run until ctx cancelled) MaxConsecErrors int // per-TTL consecutive error limit; 0 → default 10 MaxInFlightPerHop int // max concurrent probes per TTL; 0 → ceil(Timeout/HopInterval)+1 ParallelRequests int ProgressThrottle time.Duration FillGeo bool BaseConfig Config // used for geo/RDNS lookup DstIP net.IP IsPaused func() bool IsResetRequested func() bool } // mtrHopState tracks per-TTL scheduling state. type mtrHopState struct { completed int inFlightCount int nextAt time.Time disabled bool consecutiveErrs int } // mtrCompletedProbe wraps a finished probe for the result channel. type mtrCompletedProbe struct { ttl int result mtrProbeResult gen uint64 doneAt time.Time err error } // runMTRScheduler runs the per-hop independent scheduling loop. // // Each TTL is probed independently: after a probe completes, the next probe for // that TTL is scheduled after HopInterval. Concurrency across TTLs is limited by // ParallelRequests. Iteration is defined as min(Snt) over active TTLs. // // onSnapshot is called periodically with aggregated stats (for TUI / report). // onProbe is called per completed probe (for raw streaming mode). func runMTRScheduler( ctx context.Context, prober mtrTTLProber, agg *MTRAggregator, cfg mtrSchedulerConfig, onSnapshot MTROnSnapshot, onProbe func(result mtrProbeResult, iteration int), ) error { defer prober.Close() rt, err := newMTRSchedulerRuntime(ctx, prober, agg, cfg, onSnapshot, onProbe) if err != nil { return err } return rt.run() } // mtrAddrToIP extracts net.IP from net.Addr. func mtrAddrToIP(addr net.Addr) net.IP { if addr == nil { return nil } switch a := addr.(type) { case *net.IPAddr: return a.IP case *net.UDPAddr: return a.IP case *net.TCPAddr: return a.IP } return nil } ================================================ FILE: trace/mtr_scheduler_runtime.go ================================================ package trace import ( "context" "fmt" "os" "sync/atomic" "time" ) type mtrSchedulerRuntime struct { ctx context.Context prober mtrTTLProber agg *MTRAggregator cfg mtrSchedulerConfig onSnapshot MTROnSnapshot onProbe func(result mtrProbeResult, iteration int) beginHop int maxHops int parallelism int hopInterval time.Duration progressDelay time.Duration maxConsecErrs int maxInFlightHop int states []mtrHopState generation uint64 knownFinalTTL int32 inFlight int resultCh chan mtrCompletedProbe lastSnapshot time.Time } func newMTRSchedulerRuntime( ctx context.Context, prober mtrTTLProber, agg *MTRAggregator, cfg mtrSchedulerConfig, onSnapshot MTROnSnapshot, onProbe func(result mtrProbeResult, iteration int), ) (*mtrSchedulerRuntime, error) { beginHop := cfg.BeginHop if beginHop <= 0 { beginHop = 1 } maxHops := cfg.MaxHops if maxHops <= 0 { maxHops = 30 } if maxHops > 255 { maxHops = 255 } if beginHop > maxHops { return nil, fmt.Errorf("mtr: beginHop (%d) > maxHops (%d)", beginHop, maxHops) } parallelism := cfg.ParallelRequests if parallelism < 1 { parallelism = 1 } hopInterval := cfg.HopInterval if hopInterval <= 0 { hopInterval = time.Second } progressDelay := cfg.ProgressThrottle if progressDelay <= 0 { progressDelay = 200 * time.Millisecond } maxConsecErrs := cfg.MaxConsecErrors if maxConsecErrs <= 0 { maxConsecErrs = 10 } maxInFlightHop := cfg.MaxInFlightPerHop if maxInFlightHop <= 0 { timeout := cfg.Timeout if timeout <= 0 { timeout = 2 * time.Second } maxInFlightHop = int((timeout+hopInterval-1)/hopInterval) + 1 if maxInFlightHop < 1 { maxInFlightHop = 1 } } return &mtrSchedulerRuntime{ ctx: ctx, prober: prober, agg: agg, cfg: cfg, onSnapshot: onSnapshot, onProbe: onProbe, beginHop: beginHop, maxHops: maxHops, parallelism: parallelism, hopInterval: hopInterval, progressDelay: progressDelay, maxConsecErrs: maxConsecErrs, maxInFlightHop: maxInFlightHop, states: make([]mtrHopState, maxHops+1), knownFinalTTL: -1, resultCh: make(chan mtrCompletedProbe, parallelism*2), }, nil } func (rt *mtrSchedulerRuntime) run() error { rt.scheduleReady() tick := time.NewTicker(5 * time.Millisecond) defer tick.Stop() for { select { case <-rt.ctx.Done(): return rt.handleCancel() case cp := <-rt.resultCh: rt.processResult(cp) if rt.isDone() { rt.maybeSnapshot(true) return nil } rt.scheduleReady() case <-tick.C: rt.handleReset() rt.scheduleReady() if rt.isDone() { rt.maybeSnapshot(true) return nil } } } } func (rt *mtrSchedulerRuntime) effectiveMax() int { kf := atomic.LoadInt32(&rt.knownFinalTTL) if kf > 0 && int(kf) < rt.maxHops { return int(kf) } return rt.maxHops } func (rt *mtrSchedulerRuntime) computeIteration() int { eMax := rt.effectiveMax() minSnt := -1 for ttl := rt.beginHop; ttl <= eMax; ttl++ { if rt.states[ttl].disabled { continue } snt := rt.states[ttl].completed if minSnt < 0 || snt < minSnt { minSnt = snt } } if minSnt < 0 { return 0 } return minSnt } func (rt *mtrSchedulerRuntime) maybeSnapshot(force bool) { if rt.onSnapshot == nil { return } now := time.Now() if !force && now.Sub(rt.lastSnapshot) < rt.progressDelay { return } rt.lastSnapshot = now rt.onSnapshot(rt.computeIteration(), rt.agg.Snapshot()) } func (rt *mtrSchedulerRuntime) launchProbe(ttl int) { rt.states[ttl].inFlightCount++ rt.states[ttl].nextAt = time.Now().Add(rt.hopInterval) rt.inFlight++ gen := rt.generation go func() { result, err := rt.prober.ProbeTTL(rt.ctx, ttl) rt.resultCh <- mtrCompletedProbe{ ttl: ttl, result: result, gen: gen, doneAt: time.Now(), err: err, } }() } func (rt *mtrSchedulerRuntime) processResult(cp mtrCompletedProbe) { rt.inFlight-- if cp.gen != rt.generation { return } if cp.ttl < rt.beginHop || cp.ttl > rt.maxHops { return } state := &rt.states[cp.ttl] state.inFlightCount-- if state.disabled { return } if cp.err != nil { rt.processProbeError(cp.ttl, cp.err) return } rt.processProbeSuccess(cp.ttl, cp.result) } func (rt *mtrSchedulerRuntime) processProbeError(ttl int, err error) { if rt.ctx.Err() != nil { return } state := &rt.states[ttl] state.consecutiveErrs++ fmt.Fprintf(os.Stderr, "mtr: probe error (%d/%d): %v\n", state.consecutiveErrs, rt.maxConsecErrs, err) if state.consecutiveErrs < rt.maxConsecErrs { return } state.consecutiveErrs = 0 state.completed++ rt.recordSyntheticTimeout(ttl) } func (rt *mtrSchedulerRuntime) recordSyntheticTimeout(ttl int) { rt.agg.Update(rt.timeoutProbeResult(ttl), 1) if rt.onProbe != nil { rt.onProbe(mtrProbeResult{TTL: ttl}, rt.computeIteration()) } rt.maybeSnapshot(false) } func (rt *mtrSchedulerRuntime) resultHopCount() int { if n := len(rt.states) - 1; n > 0 { return n } if rt.maxHops > 0 { return rt.maxHops } return 0 } func (rt *mtrSchedulerRuntime) timeoutProbeResult(ttl int) *Result { singleRes := &Result{Hops: make([][]Hop, rt.resultHopCount())} idx := ttl - 1 if idx >= 0 && idx < len(singleRes.Hops) { singleRes.Hops[idx] = []Hop{{TTL: ttl, Error: errHopLimitTimeout}} } return singleRes } func (rt *mtrSchedulerRuntime) processProbeSuccess(ttl int, result mtrProbeResult) { rt.detectDestination(ttl, result) if rt.probeBudgetReached(ttl) { rt.states[ttl].consecutiveErrs = 0 return } rt.markProbeCompleted(ttl) singleRes := rt.singleProbeResult(ttl, result) if rt.cfg.FillGeo && result.Geo == nil { mtrFillGeoRDNS(singleRes, rt.cfg.BaseConfig) } rt.agg.Update(singleRes, 1) if rt.onProbe != nil { rt.onProbe(result, rt.computeIteration()) } rt.maybeSnapshot(false) } func (rt *mtrSchedulerRuntime) detectDestination(ttl int, result mtrProbeResult) { if !result.Success || result.Addr == nil { return } peerIP := mtrAddrToIP(result.Addr) if peerIP == nil || !peerIP.Equal(rt.cfg.DstIP) { return } curFinal := atomic.LoadInt32(&rt.knownFinalTTL) if curFinal < 0 { atomic.StoreInt32(&rt.knownFinalTTL, int32(ttl)) rt.disableHigherTTLs(ttl + 1) return } if int32(ttl) < curFinal { oldFinal := int(curFinal) atomic.StoreInt32(&rt.knownFinalTTL, int32(ttl)) rt.disableHigherTTLs(ttl + 1) rt.agg.ClearHop(oldFinal) } } func (rt *mtrSchedulerRuntime) disableHigherTTLs(fromTTL int) { for ttl := fromTTL; ttl <= rt.maxHops; ttl++ { rt.states[ttl].disabled = true } } func (rt *mtrSchedulerRuntime) probeBudgetReached(ttl int) bool { return rt.cfg.MaxPerHop > 0 && rt.states[ttl].completed >= rt.cfg.MaxPerHop } func (rt *mtrSchedulerRuntime) markProbeCompleted(ttl int) { rt.states[ttl].consecutiveErrs = 0 rt.states[ttl].completed++ } func (rt *mtrSchedulerRuntime) singleProbeResult(ttl int, result mtrProbeResult) *Result { singleRes := &Result{Hops: make([][]Hop, rt.resultHopCount())} hop := Hop{ Success: result.Success, Address: result.Addr, Hostname: result.Hostname, TTL: ttl, RTT: result.RTT, MPLS: result.MPLS, Geo: result.Geo, Lang: rt.cfg.BaseConfig.Lang, } if !hop.Success && hop.Address == nil { hop.Error = errHopLimitTimeout } idx := ttl - 1 if idx >= 0 && idx < len(singleRes.Hops) { singleRes.Hops[idx] = []Hop{hop} } return singleRes } func (rt *mtrSchedulerRuntime) scheduleReady() { if rt.cfg.IsPaused != nil && rt.cfg.IsPaused() { return } now := time.Now() eMax := rt.effectiveMax() for ttl := rt.beginHop; ttl <= eMax; ttl++ { if rt.inFlight >= rt.parallelism { return } if rt.canLaunchProbe(ttl, now) { rt.launchProbe(ttl) } } } func (rt *mtrSchedulerRuntime) canLaunchProbe(ttl int, now time.Time) bool { state := &rt.states[ttl] if state.disabled || state.inFlightCount >= rt.maxInFlightHop { return false } if rt.cfg.MaxPerHop > 0 && state.completed+state.inFlightCount >= rt.cfg.MaxPerHop { return false } if !state.nextAt.IsZero() && now.Before(state.nextAt) { return false } return true } func (rt *mtrSchedulerRuntime) isDone() bool { if rt.cfg.MaxPerHop <= 0 { return false } eMax := rt.effectiveMax() for ttl := rt.beginHop; ttl <= eMax; ttl++ { state := &rt.states[ttl] if state.disabled { continue } if state.completed < rt.cfg.MaxPerHop || state.inFlightCount > 0 { return false } } return rt.inFlight == 0 } func (rt *mtrSchedulerRuntime) handleReset() { if rt.cfg.IsResetRequested == nil || !rt.cfg.IsResetRequested() { return } rt.generation++ for idx := range rt.states { rt.states[idx] = mtrHopState{} } atomic.StoreInt32(&rt.knownFinalTTL, -1) rt.agg.Reset() _ = rt.prober.Reset() } func (rt *mtrSchedulerRuntime) handleCancel() error { rt.drainInFlight() rt.maybeSnapshot(true) return rt.ctx.Err() } func (rt *mtrSchedulerRuntime) drainInFlight() { deadline := time.After(5 * time.Second) for rt.inFlight > 0 { select { case <-rt.resultCh: rt.inFlight-- case <-deadline: return } } } ================================================ FILE: trace/mtr_scheduler_test.go ================================================ package trace import ( "context" "errors" "fmt" "net" "sync" "sync/atomic" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" ) // --------------------------------------------------------------------------- // Mock TTL prober for scheduler tests // --------------------------------------------------------------------------- type mockTTLProber struct { mu sync.Mutex probeFn func(ctx context.Context, ttl int) (mtrProbeResult, error) resetCnt int32 closeCnt int32 probeCnt int32 probeLog []int // ttl of each probe call } func (m *mockTTLProber) ProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error) { atomic.AddInt32(&m.probeCnt, 1) m.mu.Lock() m.probeLog = append(m.probeLog, ttl) m.mu.Unlock() if m.probeFn != nil { return m.probeFn(ctx, ttl) } return mtrProbeResult{TTL: ttl}, nil } func (m *mockTTLProber) Reset() error { atomic.AddInt32(&m.resetCnt, 1) return nil } func (m *mockTTLProber) Close() error { atomic.AddInt32(&m.closeCnt, 1) return nil } func (m *mockTTLProber) getProbeCount() int { return int(atomic.LoadInt32(&m.probeCnt)) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- func TestScheduler_ResultBuildersUseBoundedHopCount(t *testing.T) { rt, err := newMTRSchedulerRuntime( context.Background(), &mockTTLProber{}, NewMTRAggregator(), mtrSchedulerConfig{ BeginHop: 1, MaxHops: 1 << 20, HopInterval: time.Millisecond, ParallelRequests: 1, }, nil, nil, ) if err != nil { t.Fatalf("newMTRSchedulerRuntime returned error: %v", err) } if rt.maxHops != 255 { t.Fatalf("rt.maxHops = %d, want 255", rt.maxHops) } if got := len(rt.timeoutProbeResult(1).Hops); got != 255 { t.Fatalf("timeoutProbeResult hop len = %d, want 255", got) } if got := len(rt.singleProbeResult(1, mtrProbeResult{TTL: 1}).Hops); got != 255 { t.Fatalf("singleProbeResult hop len = %d, want 255", got) } } func TestScheduler_MaxPerHopCompletion(t *testing.T) { dstIP := net.ParseIP("10.0.0.5") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { // Simulate: TTL 3 is the destination if ttl == 3 { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 10 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() var lastIter int var snapshotCount int32 err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: time.Millisecond, MaxPerHop: 3, ParallelRequests: 5, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, func(iter int, stats []MTRHopStat) { atomic.AddInt32(&snapshotCount, 1) lastIter = iter }, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should complete: each active TTL (1..3) should have 3 probes if lastIter != 3 { t.Errorf("expected final iteration=3, got %d", lastIter) } stats := agg.Snapshot() if len(stats) < 3 { t.Fatalf("expected at least 3 stats rows, got %d", len(stats)) } for _, s := range stats { if s.TTL >= 1 && s.TTL <= 3 { if s.Snt != 3 { t.Errorf("TTL %d: expected Snt=3, got %d", s.TTL, s.Snt) } } } if atomic.LoadInt32(&prober.closeCnt) != 1 { t.Error("prober.Close() not called") } } func TestScheduler_ContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var probes int32 prober := &mockTTLProber{ probeFn: func(ctx context.Context, ttl int) (mtrProbeResult, error) { n := atomic.AddInt32(&probes, 1) if n >= 5 { cancel() } return mtrProbeResult{TTL: ttl}, nil }, } agg := NewMTRAggregator() var snapshotCalled int32 err := runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 5, HopInterval: time.Millisecond, MaxPerHop: 0, // unlimited ParallelRequests: 5, ProgressThrottle: time.Millisecond, }, func(_ int, _ []MTRHopStat) { atomic.AddInt32(&snapshotCalled, 1) }, nil) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } if atomic.LoadInt32(&prober.closeCnt) != 1 { t.Error("prober.Close() not called on cancel") } } func TestScheduler_DestinationDetection(t *testing.T) { dstIP := net.ParseIP("8.8.8.8") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl >= 5 { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 50 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 10 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: time.Millisecond, MaxPerHop: 2, ParallelRequests: 1, // serialize to ensure dest detection before higher TTLs ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() // TTL 5 is the destination; higher TTLs should be disabled after detection. // With parallelism=1, at most TTL 6 could sneak in before the result is // processed (tick vs result race), so we allow a small margin. maxTTL := 0 for _, s := range stats { if s.TTL > maxTTL { maxTTL = s.TTL } } if maxTTL > 6 { t.Errorf("expected max TTL <= 6 (destination detected at 5), got %d", maxTTL) } } func TestScheduler_Reset(t *testing.T) { var probes int32 var resetOnce int32 prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { atomic.AddInt32(&probes, 1) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 5 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() var snapshotIters []int var iterMu sync.Mutex err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 2, HopInterval: time.Millisecond, MaxPerHop: 4, ParallelRequests: 2, ProgressThrottle: time.Millisecond, IsResetRequested: func() bool { // Trigger reset after some probes have been done p := atomic.LoadInt32(&probes) if p >= 4 && atomic.CompareAndSwapInt32(&resetOnce, 0, 1) { return true } return false }, }, func(iter int, _ []MTRHopStat) { iterMu.Lock() snapshotIters = append(snapshotIters, iter) iterMu.Unlock() }, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } // After reset, iteration should restart from 0→1 // Final iteration should be 4 (maxPerHop=4) iterMu.Lock() defer iterMu.Unlock() if len(snapshotIters) == 0 { t.Fatal("expected at least one snapshot") } lastIter := snapshotIters[len(snapshotIters)-1] if lastIter != 4 { t.Errorf("expected last iteration=4, got %d", lastIter) } // prober.Reset should have been called once if atomic.LoadInt32(&prober.resetCnt) != 1 { t.Errorf("expected 1 Reset call, got %d", atomic.LoadInt32(&prober.resetCnt)) } } func TestScheduler_Pause(t *testing.T) { var pauseFlag int32 var probes int32 ctx, cancel := context.WithCancel(context.Background()) defer cancel() prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { n := atomic.AddInt32(&probes, 1) if n == 2 { // Pause after 2 probes, resume after 50ms atomic.StoreInt32(&pauseFlag, 1) go func() { time.Sleep(50 * time.Millisecond) atomic.StoreInt32(&pauseFlag, 0) }() } if n >= 10 { cancel() } return mtrProbeResult{TTL: ttl}, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 2, HopInterval: time.Millisecond, MaxPerHop: 0, // unlimited, cancelled by ctx ParallelRequests: 2, ProgressThrottle: time.Millisecond, IsPaused: func() bool { return atomic.LoadInt32(&pauseFlag) == 1 }, }, nil, nil) if !errors.Is(err, context.Canceled) { t.Errorf("expected context.Canceled, got %v", err) } // Should have probed at least 2 (before pause) + some more after resume p := atomic.LoadInt32(&probes) if p < 4 { t.Errorf("expected at least 4 probes (across pause), got %d", p) } } func TestScheduler_IterationIsMinSnt(t *testing.T) { // TTL 1 responds quickly, TTL 2 responds slowly var ttl1Count, ttl2Count int32 prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 1 { atomic.AddInt32(&ttl1Count, 1) return mtrProbeResult{ TTL: 1, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 1 * time.Millisecond, }, nil } atomic.AddInt32(&ttl2Count, 1) time.Sleep(10 * time.Millisecond) // slower return mtrProbeResult{ TTL: 2, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.2")}, RTT: 10 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() var finalIter int err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 2, HopInterval: time.Millisecond, MaxPerHop: 3, ParallelRequests: 2, ProgressThrottle: time.Millisecond, }, func(iter int, _ []MTRHopStat) { finalIter = iter }, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } // Both TTLs should have 3 probes (MaxPerHop=3) if finalIter != 3 { t.Errorf("expected final iteration=3, got %d", finalIter) } } func TestScheduler_OnProbeCallback(t *testing.T) { dstIP := net.ParseIP("10.0.0.3") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 3 { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 1 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() var callbackResults []mtrProbeResult var mu sync.Mutex err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: time.Millisecond, MaxPerHop: 1, ParallelRequests: 5, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, func(result mtrProbeResult, _ int) { mu.Lock() callbackResults = append(callbackResults, result) mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } mu.Lock() defer mu.Unlock() // Should have callbacks for TTL 1, 2, 3 (dest stops further TTLs) if len(callbackResults) < 3 { t.Errorf("expected at least 3 onProbe callbacks, got %d", len(callbackResults)) } } func TestScheduler_BeginHopGreaterThanMaxHops(t *testing.T) { prober := &mockTTLProber{} agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 10, MaxHops: 5, HopInterval: time.Millisecond, MaxPerHop: 1, ParallelRequests: 1, }, nil, nil) if err == nil { t.Fatal("expected error for beginHop > maxHops") } } func TestScheduler_ConcurrencyLimit(t *testing.T) { var maxConcurrent int32 var current int32 prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { c := atomic.AddInt32(¤t, 1) // Track max concurrent for { old := atomic.LoadInt32(&maxConcurrent) if c <= old || atomic.CompareAndSwapInt32(&maxConcurrent, old, c) { break } } time.Sleep(20 * time.Millisecond) // hold slot atomic.AddInt32(¤t, -1) return mtrProbeResult{TTL: ttl}, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 10, HopInterval: time.Millisecond, MaxPerHop: 1, ParallelRequests: 3, // limit to 3 concurrent ProgressThrottle: time.Millisecond, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } mc := atomic.LoadInt32(&maxConcurrent) if mc > 3 { t.Errorf("expected max concurrent <= 3, got %d", mc) } if mc < 1 { t.Error("expected at least 1 concurrent probe") } } // TestMtrAddrToIP verifies the helper function. func TestMtrAddrToIP(t *testing.T) { ip := net.ParseIP("1.2.3.4") if got := mtrAddrToIP(&net.IPAddr{IP: ip}); !got.Equal(ip) { t.Errorf("IPAddr: got %v, want %v", got, ip) } if got := mtrAddrToIP(&net.UDPAddr{IP: ip}); !got.Equal(ip) { t.Errorf("UDPAddr: got %v, want %v", got, ip) } if got := mtrAddrToIP(&net.TCPAddr{IP: ip}); !got.Equal(ip) { t.Errorf("TCPAddr: got %v, want %v", got, ip) } if got := mtrAddrToIP(nil); got != nil { t.Errorf("nil: got %v, want nil", got) } } // --------------------------------------------------------------------------- // P1: Error budget tests // --------------------------------------------------------------------------- func TestScheduler_ErrorBudgetExhausted(t *testing.T) { // Every call to ProbeTTL returns an error. // With MaxConsecErrors=3, MaxPerHop=2, each TTL should eventually // complete because every 3 consecutive errors count as one completed timeout. errAlways := errors.New("always fail") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { return mtrProbeResult{TTL: ttl}, errAlways }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 2, HopInterval: time.Millisecond, MaxPerHop: 2, MaxConsecErrors: 3, ParallelRequests: 2, ProgressThrottle: time.Millisecond, }, nil, nil) if err != nil { t.Fatalf("expected nil (completed), got %v", err) } // Each TTL should have 2 completed (timeout) events, each requiring 3 errors. // So total probes = 2 TTLs * 2 completions * 3 errors = 12. totalProbes := prober.getProbeCount() if totalProbes < 12 { t.Errorf("expected at least 12 probes (2 TTLs * 2 * 3 errors), got %d", totalProbes) } } func TestScheduler_ErrorBudgetEmitsOnProbe(t *testing.T) { // When error budget is exhausted, the synthetic timeout must also fire // the onProbe callback so that raw MTR sees the same record count as // the aggregator Snt. errAlways := errors.New("always fail") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { return mtrProbeResult{TTL: ttl}, errAlways }, } agg := NewMTRAggregator() var mu sync.Mutex var rawRecords []mtrProbeResult err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 1, HopInterval: time.Millisecond, MaxPerHop: 2, MaxConsecErrors: 3, ParallelRequests: 1, ProgressThrottle: time.Millisecond, }, nil, func(result mtrProbeResult, _ int) { mu.Lock() rawRecords = append(rawRecords, result) mu.Unlock() }) if err != nil { t.Fatalf("expected nil (completed), got %v", err) } mu.Lock() defer mu.Unlock() // MaxPerHop=2, each requiring MaxConsecErrors=3 → 2 onProbe calls for TTL 1. if len(rawRecords) != 2 { t.Errorf("expected 2 onProbe callbacks (synthetic timeouts), got %d", len(rawRecords)) } for i, r := range rawRecords { if r.TTL != 1 { t.Errorf("record[%d]: expected TTL=1, got %d", i, r.TTL) } if r.Success { t.Errorf("record[%d]: expected Success=false for timeout", i) } } } func TestScheduler_ErrorResetsOnSuccess(t *testing.T) { // Pattern: fail, fail, succeed, fail, fail, succeed — should never hit budget. var calls int32 prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { n := atomic.AddInt32(&calls, 1) if n%3 != 0 { // every 3rd call succeeds return mtrProbeResult{TTL: ttl}, errors.New("fail") } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 1 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 1, HopInterval: time.Millisecond, MaxPerHop: 2, // need 2 successful MaxConsecErrors: 3, // budget = 3 consecutive ParallelRequests: 1, ProgressThrottle: time.Millisecond, }, nil, nil) if err != nil { t.Fatalf("expected nil (completed via successes), got %v", err) } stats := agg.Snapshot() found := false for _, s := range stats { if s.TTL == 1 && s.Snt >= 2 { found = true } } if !found { t.Error("TTL 1 should have at least 2 successful probes in aggregator") } } // --------------------------------------------------------------------------- // P2: Fallback geo/hostname propagation tests // --------------------------------------------------------------------------- func TestScheduler_FallbackGeoCarriedToAggregator(t *testing.T) { fakeGeo := &ipgeo.IPGeoData{ Asnumber: "AS13335", Country: "美国", Prov: "加利福尼亚", City: "旧金山", Owner: "Cloudflare", } prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("1.1.1.1")}, RTT: 5 * time.Millisecond, Hostname: "one.one.one.one", Geo: fakeGeo, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 1, HopInterval: time.Millisecond, MaxPerHop: 1, ParallelRequests: 1, ProgressThrottle: time.Millisecond, FillGeo: true, // should NOT re-fetch since probe carries Geo }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() if len(stats) == 0 { t.Fatal("expected at least 1 stat row") } s := stats[0] if s.Host != "one.one.one.one" { t.Errorf("expected Host='one.one.one.one', got %q", s.Host) } if s.Geo == nil { t.Fatal("expected Geo to be set, got nil") } if s.Geo.Asnumber != "AS13335" { t.Errorf("expected ASN='AS13335', got %q", s.Geo.Asnumber) } } func TestBuildMTRRawRecordFromProbe_PreResolvedGeo(t *testing.T) { fakeGeo := &ipgeo.IPGeoData{ Asnumber: "AS9808", Country: "中国", CountryEn: "China", City: "广州", CityEn: "Guangzhou", Owner: "ChinaMobile", } pr := mtrProbeResult{ TTL: 3, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("120.196.165.24")}, RTT: 8 * time.Millisecond, Hostname: "bras-vlan365.gd.gd", Geo: fakeGeo, } rec := buildMTRRawRecordFromProbe(5, pr, Config{Lang: "cn"}) if rec.ASN != "AS9808" { t.Errorf("expected ASN='AS9808', got %q", rec.ASN) } if rec.Host != "bras-vlan365.gd.gd" { t.Errorf("expected Host='bras-vlan365.gd.gd', got %q", rec.Host) } if rec.City != "广州" { t.Errorf("expected City='广州', got %q", rec.City) } if rec.Country != "中国" { t.Errorf("expected Country='中国', got %q", rec.Country) } if rec.Owner != "ChinaMobile" { t.Errorf("expected Owner='ChinaMobile', got %q", rec.Owner) } } func TestBuildMTRRawRecordFromProbe_NoGeoNoSource_NoHostname(t *testing.T) { // When probe has no pre-resolved geo and config has no IPGeoSource/RDNS, // record should still have IP/RTT but no geo/host fields. pr := mtrProbeResult{ TTL: 2, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.5")}, RTT: 3 * time.Millisecond, } rec := buildMTRRawRecordFromProbe(1, pr, Config{}) if rec.IP != "10.0.0.5" { t.Errorf("expected IP='10.0.0.5', got %q", rec.IP) } if rec.ASN != "" || rec.Host != "" || rec.Country != "" { t.Errorf("expected empty geo/host fields, got ASN=%q Host=%q Country=%q", rec.ASN, rec.Host, rec.Country) } } // --------------------------------------------------------------------------- // End-to-end: raw record count matches aggregator Snt under error budget // --------------------------------------------------------------------------- func TestScheduler_RawRecordCountMatchesAggSnt_ErrorBudget(t *testing.T) { // Simulate a mix of successes and persistent errors across 2 TTLs. // TTL 1: always succeeds. TTL 2: always errors. // With MaxPerHop=3 and MaxConsecErrors=2, both TTLs should complete // and the raw callback count per TTL must equal the aggregator Snt. prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 1 { return mtrProbeResult{ TTL: 1, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 5 * time.Millisecond, }, nil } return mtrProbeResult{TTL: 2}, errors.New("persistent failure") }, } agg := NewMTRAggregator() var mu sync.Mutex rawCountByTTL := map[int]int{} err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 2, HopInterval: time.Millisecond, MaxPerHop: 3, MaxConsecErrors: 2, ParallelRequests: 1, ProgressThrottle: time.Millisecond, }, nil, func(result mtrProbeResult, _ int) { mu.Lock() rawCountByTTL[result.TTL]++ mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } mu.Lock() defer mu.Unlock() for ttl := 1; ttl <= 2; ttl++ { rawCount := rawCountByTTL[ttl] snt := sntByTTL[ttl] if rawCount != snt { t.Errorf("TTL %d: raw callback count (%d) != aggregator Snt (%d)", ttl, rawCount, snt) } if snt != 3 { t.Errorf("TTL %d: expected Snt=3, got %d", ttl, snt) } } } // --------------------------------------------------------------------------- // RDNS-only: IPGeoSource=nil && RDNS=true enters fetchIPData path // --------------------------------------------------------------------------- func TestBuildMTRRawRecordFromProbe_RDNSOnlyPath(t *testing.T) { // With IPGeoSource=nil and RDNS=true, the function should enter the // fetchIPData path (not skip it). We use 127.0.0.1 which typically // resolves to "localhost" via PTR. Even if RDNS fails in CI, the test // verifies the code path doesn't panic and the record is well-formed. pr := mtrProbeResult{ TTL: 1, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("127.0.0.1")}, RTT: 1 * time.Millisecond, } rec := buildMTRRawRecordFromProbe(1, pr, Config{ RDNS: true, IPGeoSource: nil, // no geo source — only RDNS Lang: "en", }) // Basic sanity: record must have IP and RTT regardless. if rec.IP != "127.0.0.1" { t.Errorf("expected IP='127.0.0.1', got %q", rec.IP) } if rec.RTTMs <= 0 { t.Errorf("expected positive RTTMs, got %f", rec.RTTMs) } // Geo fields should be empty (no IPGeoSource). if rec.ASN != "" { t.Errorf("expected empty ASN with no geo source, got %q", rec.ASN) } // Host may or may not be set depending on system RDNS for 127.0.0.1. // The key assertion is that we reached here without panic/skip. t.Logf("RDNS-only path: Host=%q (may vary by system)", rec.Host) } // --------------------------------------------------------------------------- // Destination folding tests // --------------------------------------------------------------------------- func TestScheduler_HigherTTLDestinationRepliesDiscarded(t *testing.T) { // Destination is at TTL 3. Higher TTLs also return the destination IP // but with a small delay, ensuring TTL 3's result is processed first // (setting knownFinalTTL=3). The delayed higher-TTL probes are now // DISCARDED — not folded into TTL 3. dstIP := net.ParseIP("10.0.0.99") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl > 3 { // Higher TTLs return destination but after a delay, // so TTL 3's result is processed first. time.Sleep(30 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: time.Duration(ttl) * time.Millisecond, }, nil } if ttl == 3 { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: time.Duration(ttl) * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() var mu sync.Mutex probeByTTL := map[int]int{} err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 6, HopInterval: time.Millisecond, MaxPerHop: 3, ParallelRequests: 6, // enough to launch TTLs 1-6 simultaneously ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, func(result mtrProbeResult, _ int) { mu.Lock() probeByTTL[result.TTL]++ mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } // TTL 3 (finalTTL) should have ONLY its own probes — Snt == MaxPerHop (no fold) if sntByTTL[3] != 3 { t.Errorf("TTL 3 (final): expected Snt == 3 (own probes only, no fold), got %d", sntByTTL[3]) } // TTLs above final should NOT appear in aggregator (discarded) for ttl := 4; ttl <= 6; ttl++ { if sntByTTL[ttl] > 0 { t.Errorf("TTL %d: expected Snt=0 (discarded), got %d", ttl, sntByTTL[ttl]) } } // onProbe callbacks must NOT fire for discarded higher-TTL results mu.Lock() defer mu.Unlock() for ttl := 4; ttl <= 6; ttl++ { if probeByTTL[ttl] > 0 { t.Errorf("onProbe: TTL %d should have 0 callbacks (discarded), got %d", ttl, probeByTTL[ttl]) } } // Also verify that NO folded callbacks appeared at TTL 3 from higher-TTL probes: // TTL 3's callback count must equal its Snt. if probeByTTL[3] != sntByTTL[3] { t.Errorf("onProbe: TTL 3 callback count (%d) != Snt (%d); suggests folded callbacks leaked", probeByTTL[3], sntByTTL[3]) } } func TestScheduler_DiscardedDestinationRepliesCannotExceedMaxPerHop(t *testing.T) { // With discard semantics, higher TTL destination replies are discarded // entirely, so they can never push finalTTL's Snt above MaxPerHop. dstIP := net.ParseIP("10.0.0.99") var probeCount int32 prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { atomic.AddInt32(&probeCount, 1) if ttl >= 2 { // All TTLs >= 2 hit destination return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 1 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 10, HopInterval: time.Millisecond, MaxPerHop: 2, // strict cap ParallelRequests: 10, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() for _, s := range stats { if s.TTL == 2 { // TTL 2 is the finalTTL; higher TTL destination replies are // discarded entirely, so Snt must equal exactly MaxPerHop // (only own probes counted). if s.Snt > 2 { t.Errorf("TTL 2 (final): Snt=%d exceeds MaxPerHop=2 (discard violation)", s.Snt) } } } // Verify no higher TTLs have stats in the aggregator for _, s := range stats { if s.TTL > 2 && s.IP == dstIP.String() { t.Errorf("TTL %d: should not have dst-ip stats (discarded), got Snt=%d", s.TTL, s.Snt) } } } func TestScheduler_NonDestinationRepliesOnDisabledHigherTTLDiscarded(t *testing.T) { // If a higher TTL (after being disabled) returns a non-destination IP, // that reply should be silently discarded — not folded, not recorded. // Higher TTLs are delayed so TTL 3 (destination) is processed first. dstIP := net.ParseIP("10.0.0.99") var mu sync.Mutex var probeResults []mtrProbeResult prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 3 { // TTL 3 is destination — returns quickly return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } if ttl > 3 { // Higher TTLs return a non-destination intermediate IP // after a delay, so they arrive after TTL 3 sets disabled. time.Sleep(30 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.50")}, RTT: 3 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0." + fmt.Sprintf("%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 6, HopInterval: time.Millisecond, MaxPerHop: 2, ParallelRequests: 6, // all TTLs may launch before destination detected ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, func(result mtrProbeResult, _ int) { mu.Lock() probeResults = append(probeResults, result) mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } // Non-destination replies from disabled TTLs (4, 5, 6) with IP 10.0.0.50 // should have been discarded. Check that the aggregator has no entries // for TTLs > 3 with the intermediate IP. stats := agg.Snapshot() for _, s := range stats { if s.TTL > 3 && s.IP == "10.0.0.50" { t.Errorf("TTL %d: non-destination reply (10.0.0.50) should have been discarded, but appeared in aggregator", s.TTL) } } } func TestScheduler_FinalTTLLowered_MigratesStatsToNewFinal(t *testing.T) { // Scenario: higher TTL (12) returns destination first, establishing // knownFinalTTL=12. Then a lower TTL (7) returns destination, lowering // knownFinalTTL to 7. The stats already recorded at TTL 12 must be // migrated to TTL 7 — no ghost row at TTL 12 should remain. dstIP := net.ParseIP("10.0.0.99") var mu sync.Mutex callOrder := map[int]int{} // ttl → order of first return var callSeq int32 prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { mu.Lock() if callOrder[ttl] == 0 { callOrder[ttl] = int(atomic.AddInt32(&callSeq, 1)) } mu.Unlock() if ttl == 12 { // TTL 12 returns destination quickly return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 3 * time.Millisecond, }, nil } if ttl == 7 { // TTL 7 returns destination after a delay, // ensuring TTL 12 is processed first. time.Sleep(50 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } // Intermediate hops return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0." + fmt.Sprintf("%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 15, HopInterval: time.Millisecond, MaxPerHop: 3, ParallelRequests: 15, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} ipByTTL := map[int]string{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt ipByTTL[s.TTL] = s.IP } // TTL 12 should have NO stats — cleared when finalTTL lowered to 7 if sntByTTL[12] > 0 { t.Errorf("TTL 12 should have 0 stats after clearing, got Snt=%d (ghost row!)", sntByTTL[12]) } // TTL 7 (new final) should have stats (its own probes only) if sntByTTL[7] < 3 { t.Errorf("TTL 7 (final): expected Snt >= 3, got %d", sntByTTL[7]) } // Only one row should have the destination IP dstIPRows := 0 for _, s := range stats { if s.IP == "10.0.0.99" { dstIPRows++ if s.TTL != 7 { t.Errorf("destination IP found at TTL %d, expected only at TTL 7", s.TTL) } } } if dstIPRows == 0 { t.Error("expected at least one row with destination IP") } if dstIPRows > 1 { t.Errorf("expected exactly 1 dst-ip row (at TTL 7), got %d (duplicate!)", dstIPRows) } } func TestScheduler_FinalTTLLowered_ChainMigration(t *testing.T) { // Chain scenario: TTL 12 → final. Then TTL 9 → final (migrates 12→9). // Then TTL 7 → final (migrates 9→7). All stats end up at TTL 7. dstIP := net.ParseIP("10.0.0.99") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 12 { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 3 * time.Millisecond, }, nil } if ttl == 9 { time.Sleep(30 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 4 * time.Millisecond, }, nil } if ttl == 7 { time.Sleep(60 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0." + fmt.Sprintf("%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 15, HopInterval: time.Millisecond, MaxPerHop: 2, ParallelRequests: 15, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() for _, s := range stats { if s.IP == "10.0.0.99" && s.TTL != 7 { t.Errorf("destination IP at TTL %d, expected only at TTL 7 after chain lowering", s.TTL) } } // TTLs 9 and 12 should not have dst-ip stats (cleared during lowering) for _, s := range stats { if (s.TTL == 9 || s.TTL == 12) && s.IP == "10.0.0.99" { t.Errorf("TTL %d: ghost row with dst-ip after chain lowering", s.TTL) } } } // --------------------------------------------------------------------------- // New regression tests: discard over-final destination replies // --------------------------------------------------------------------------- func TestScheduler_LateHigherTTLDestinationReply_Discarded_NoSntBump(t *testing.T) { // Scheduler dispatches multiple TTLs concurrently. // TTL 3 hits destination first → sets knownFinalTTL=3 and disables >3. // Later: originTTL=5 returns destination reply (late) → MUST be discarded. dstIP := net.ParseIP("10.0.0.99") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 3 { // Destination — returns fast return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 3 * time.Millisecond, }, nil } if ttl == 5 { // Late destination reply — delayed so TTL 3 is processed first time.Sleep(40 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 8 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() var mu sync.Mutex var callbackCount int err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 6, HopInterval: time.Millisecond, MaxPerHop: 2, ParallelRequests: 6, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, func(result mtrProbeResult, _ int) { mu.Lock() callbackCount++ mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } // Final hop TTL 3 should have exactly MaxPerHop (2) Snt — no bump from TTL 5 if sntByTTL[3] != 2 { t.Errorf("TTL 3 (final): expected Snt=2 (MaxPerHop, own probes only), got %d", sntByTTL[3]) } // TTL 5 should have 0 Snt — discarded if sntByTTL[5] > 0 { t.Errorf("TTL 5: expected Snt=0 (discarded late dst reply), got %d", sntByTTL[5]) } // Callback count should equal sum of Snt across active TTLs only mu.Lock() totalSnt := 0 for _, s := range stats { totalSnt += s.Snt } if callbackCount != totalSnt { t.Errorf("callback count (%d) != total Snt (%d); discarded results may have leaked", callbackCount, totalSnt) } mu.Unlock() } func TestScheduler_DiscardedOverFinal_DoesNotEmitOnProbe(t *testing.T) { // Provide onProbe hook that appends all records. // Trigger late over-final destination reply. // Assert no record appended for discarded result. dstIP := net.ParseIP("10.0.0.99") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 2 { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } if ttl > 2 { time.Sleep(30 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 10 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 1 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() var mu sync.Mutex var records []mtrProbeResult err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 5, HopInterval: time.Millisecond, MaxPerHop: 2, ParallelRequests: 5, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, func(result mtrProbeResult, _ int) { mu.Lock() records = append(records, result) mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } mu.Lock() defer mu.Unlock() // No record should have TTL > 2 (all discarded) for i, r := range records { if r.TTL > 2 { t.Errorf("record[%d]: TTL %d should not have been emitted (discarded over-final)", i, r.TTL) } } // Record count must match aggregator Snt sum stats := agg.Snapshot() totalSnt := 0 for _, s := range stats { totalSnt += s.Snt } if len(records) != totalSnt { t.Errorf("record count (%d) != total Snt (%d); 1:1 onProbe/Snt invariant violated", len(records), totalSnt) } } func TestScheduler_FinalTTLLowering_Chain_WithMaxPerHop_NoGhostRow_StableStats(t *testing.T) { // Construct deterministic RTT samples with chain lowering: // Provisional final at TTL 12 → lowered to 9 → lowered to 7. // MaxPerHop=3 (small). Verify no ghost rows, stats stable. dstIP := net.ParseIP("10.0.0.99") // RTT values for deterministic stat validation rttMap := map[int][]time.Duration{ 7: {5 * time.Millisecond, 6 * time.Millisecond, 7 * time.Millisecond}, 9: {10 * time.Millisecond, 11 * time.Millisecond}, 12: {20 * time.Millisecond}, } var mu sync.Mutex callCountByTTL := map[int]int{} prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { mu.Lock() callCountByTTL[ttl]++ n := callCountByTTL[ttl] mu.Unlock() if ttl == 12 { // First to return destination rtt := 20 * time.Millisecond if n <= len(rttMap[12]) { rtt = rttMap[12][n-1] } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: rtt, }, nil } if ttl == 9 { time.Sleep(30 * time.Millisecond) rtt := 11 * time.Millisecond if n <= len(rttMap[9]) { rtt = rttMap[9][n-1] } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: rtt, }, nil } if ttl == 7 { time.Sleep(60 * time.Millisecond) rtt := 7 * time.Millisecond if n <= len(rttMap[7]) { rtt = rttMap[7][n-1] } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: rtt, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 15, HopInterval: time.Millisecond, MaxPerHop: 3, ParallelRequests: 15, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} ipByTTL := map[int]string{} var finalHopStat *MTRHopStat for i, s := range stats { sntByTTL[s.TTL] = s.Snt ipByTTL[s.TTL] = s.IP if s.TTL == 7 && s.IP == dstIP.String() { finalHopStat = &stats[i] } } // No ghost rows at TTL 12 or 9 with destination IP for _, ttl := range []int{12, 9} { if ipByTTL[ttl] == dstIP.String() { t.Errorf("TTL %d: ghost row with dst-ip after chain migration", ttl) } } // Final hop is TTL 7 if finalHopStat == nil { t.Fatal("expected final hop at TTL 7 with destination IP") } // Snt <= MaxPerHop if finalHopStat.Snt > 3 { t.Errorf("TTL 7 (final): Snt=%d exceeds MaxPerHop=3", finalHopStat.Snt) } // Snt must be > 0 (at least the migrated + own) if finalHopStat.Snt == 0 { t.Error("TTL 7 (final): Snt=0, expected > 0 after chain lowering") } // Avg should be reasonable (> 0 and not NaN) if finalHopStat.Avg <= 0 { t.Errorf("TTL 7 (final): Avg=%f, expected > 0 (stable stats)", finalHopStat.Avg) } // StDev should be non-negative if finalHopStat.StDev < 0 { t.Errorf("TTL 7 (final): StDev=%f, expected >= 0 (stable stats)", finalHopStat.StDev) } // Destination IP should appear exactly once across all stats rows dstIPCount := 0 for _, s := range stats { if s.IP == dstIP.String() { dstIPCount++ if s.TTL != 7 { t.Errorf("destination IP found at TTL %d, expected only at TTL 7", s.TTL) } } } if dstIPCount != 1 { t.Errorf("expected exactly 1 row with dst-ip (at TTL 7), got %d", dstIPCount) } } // --------------------------------------------------------------------------- // Regression: final hop Snt must NOT exceed other hops // --------------------------------------------------------------------------- func TestScheduler_FinalHopSntNotInflated_NoLowering(t *testing.T) { // Simple case: destination is at TTL 5, no lowering occurs. // All active TTLs should have equal Snt after completion. dstIP := net.ParseIP("10.0.0.99") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 5 { return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: time.Millisecond, MaxPerHop: 5, ParallelRequests: 30, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } // All active TTLs (1-5) should have exactly MaxPerHop Snt for ttl := 1; ttl <= 5; ttl++ { if sntByTTL[ttl] != 5 { t.Errorf("TTL %d: Snt=%d, expected 5 (MaxPerHop)", ttl, sntByTTL[ttl]) } } } func TestScheduler_FinalHopSntNotInflated_WithLowering(t *testing.T) { // Lowering scenario: TTL 8 hits destination first, then TTL 5 lowers it. // After completion, TTL 5 (final) should have Snt == MaxPerHop, same as // other hops — NOT inflated by migrated data from TTL 8. dstIP := net.ParseIP("10.0.0.99") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { if ttl == 8 { // Returns destination quickly (discovered first as provisional final) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 3 * time.Millisecond, }, nil } if ttl == 5 { // Real final — returns after delay so TTL 8 is processed first time.Sleep(30 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: dstIP}, RTT: 5 * time.Millisecond, }, nil } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl))}, RTT: time.Duration(ttl) * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 15, HopInterval: time.Millisecond, MaxPerHop: 4, ParallelRequests: 15, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } // All active TTLs (1-5) should have exactly MaxPerHop Snt for ttl := 1; ttl <= 5; ttl++ { if sntByTTL[ttl] != 4 { t.Errorf("TTL %d: Snt=%d, expected 4 (MaxPerHop, no inflation)", ttl, sntByTTL[ttl]) } } // Old provisional final (TTL 8) should have NO data (cleared, not migrated) if sntByTTL[8] > 0 { t.Errorf("TTL 8 (old provisional final): Snt=%d, expected 0 (cleared)", sntByTTL[8]) } } // --------------------------------------------------------------------------- // Multi in-flight per hop: high-loss hops should accumulate Snt equally // --------------------------------------------------------------------------- func TestScheduler_MultiInFlightPerHop_HighLossEqualSnt(t *testing.T) { // This test reproduces the original bug: when each TTL allows only 1 // in-flight probe and nextAt is based on completion time, a TTL with // high packet loss (simulated by long timeout) accumulates Snt much // slower than a low-loss TTL. // // With the multi-in-flight fix (inFlightCount counter + nextAt based on // launch time), all TTLs should complete with equal Snt = MaxPerHop. dstIP := net.ParseIP("10.0.0.5") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { // TTL 1: fast responder (no loss) // TTL 2: 80% loss (simulated as 80% of probes sleeping 200ms = "timeout") // TTL 3: destination, fast ip := net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl)) if ttl == 3 { ip = dstIP } if ttl == 2 { // Simulate high RTT / timeout — takes longer than other hops. // With multi-in-flight, the scheduler should still keep up. time.Sleep(50 * time.Millisecond) } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: ip}, RTT: 5 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: 10 * time.Millisecond, MaxPerHop: 10, MaxInFlightPerHop: 3, ParallelRequests: 30, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } // All active TTLs should have exactly MaxPerHop probes for ttl := 1; ttl <= 3; ttl++ { if sntByTTL[ttl] != 10 { t.Errorf("TTL %d: Snt=%d, expected 10 (MaxPerHop)", ttl, sntByTTL[ttl]) } } } func TestScheduler_MultiInFlightPerHop_TimeoutHopsKeepUp(t *testing.T) { // Simulate real packet loss: some probes return quickly (RTT), others // "time out" by sleeping the full timeout duration. With multi-in-flight, // the slow (timed-out) hop should still reach MaxPerHop because the // scheduler launches new probes while old ones are still in-flight. dstIP := net.ParseIP("10.0.0.10") var ttl2Calls int32 prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { ip := net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl)) if ttl == 5 { ip = dstIP } if ttl == 3 { // 50% of probes "time out" (take a long time) n := atomic.AddInt32(&ttl2Calls, 1) if n%2 == 0 { time.Sleep(100 * time.Millisecond) // "timeout" // Return as no-reply (timeout) return mtrProbeResult{TTL: ttl}, nil } } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: ip}, RTT: 2 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: 10 * time.Millisecond, MaxPerHop: 6, MaxInFlightPerHop: 3, ParallelRequests: 30, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } // All TTLs should complete with MaxPerHop probes for ttl := 1; ttl <= 5; ttl++ { if sntByTTL[ttl] != 6 { t.Errorf("TTL %d: Snt=%d, expected 6 (MaxPerHop)", ttl, sntByTTL[ttl]) } } } func TestScheduler_NextAtBasedOnLaunchTime(t *testing.T) { // Verify that the scheduler doesn't wait for probe completion to set nextAt. // If a probe takes 200ms and hopInterval is 10ms, a second probe for the same // TTL should launch ~10ms after the first (not 210ms after). var launches []time.Time var mu sync.Mutex prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { mu.Lock() launches = append(launches, time.Now()) mu.Unlock() // Simulate slow probe (timeout-like) time.Sleep(200 * time.Millisecond) return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: net.ParseIP("10.0.0.1")}, RTT: 200 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 1, HopInterval: 50 * time.Millisecond, MaxPerHop: 3, MaxInFlightPerHop: 3, ParallelRequests: 10, ProgressThrottle: time.Millisecond, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } mu.Lock() defer mu.Unlock() if len(launches) < 3 { t.Fatalf("expected 3 launches, got %d", len(launches)) } // With launch-based nextAt and hopInterval=50ms, launches 2 and 3 should // start ~50ms and ~100ms after launch 1 respectively, NOT 250ms and 500ms // (which would be the case with completion-based nextAt). for i := 1; i < len(launches); i++ { gap := launches[i].Sub(launches[i-1]) // Allow generous tolerance (50ms interval + scheduling jitter up to 50ms) if gap > 150*time.Millisecond { t.Errorf("gap between launch %d and %d: %v (expected < 150ms with launch-based nextAt)", i-1, i, gap) } } } func TestScheduler_MaxPerHopRespectedWithMultiInFlight(t *testing.T) { // Ensure that with multiple in-flight probes per hop, we never exceed // MaxPerHop in the final Snt count. The scheduler should stop launching // when completed + inFlightCount >= MaxPerHop. dstIP := net.ParseIP("10.0.0.3") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { // All probes take some time to complete time.Sleep(30 * time.Millisecond) ip := net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl)) if ttl == 3 { ip = dstIP } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: ip}, RTT: 5 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: 5 * time.Millisecond, MaxPerHop: 4, MaxInFlightPerHop: 5, // higher than MaxPerHop to test the guard ParallelRequests: 30, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() for _, s := range stats { if s.TTL >= 1 && s.TTL <= 3 { if s.Snt > 4 { t.Errorf("TTL %d: Snt=%d exceeds MaxPerHop=4", s.TTL, s.Snt) } if s.Snt != 4 { t.Errorf("TTL %d: Snt=%d, expected exactly 4 (MaxPerHop)", s.TTL, s.Snt) } } } } func TestScheduler_SingleInFlightPerHopConfig(t *testing.T) { // When MaxInFlightPerHop=1, behavior should match the old single-inflight // mode (for backward compatibility verification). The test just ensures // completion without error. dstIP := net.ParseIP("10.0.0.3") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { ip := net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl)) if ttl == 3 { ip = dstIP } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: ip}, RTT: 1 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: time.Millisecond, MaxPerHop: 3, MaxInFlightPerHop: 1, // explicit single in-flight ParallelRequests: 5, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() for _, s := range stats { if s.TTL >= 1 && s.TTL <= 3 && s.Snt != 3 { t.Errorf("TTL %d: Snt=%d, expected 3", s.TTL, s.Snt) } } } func TestScheduler_DynamicMaxInFlightPerHop(t *testing.T) { // Verify that when MaxInFlightPerHop is not explicitly set, the scheduler // computes it as ceil(timeout / hopInterval) + 1. With a large timeout // relative to hopInterval, the dynamic value should be high enough that // even fully-timing-out hops keep up with fast hops. // // Setup: timeout=500ms, hopInterval=50ms → dynamic = ceil(500/50)+1 = 11. // TTL 2 always "times out" (sleeps 500ms); TTL 1,3 are fast. // MaxPerHop=8: with dynamic=11, all 8 probes for TTL 2 can be in-flight // simultaneously, so TTL 2 completes at roughly the same wall-clock as // the fast hops. dstIP := net.ParseIP("10.0.0.3") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { ip := net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl)) if ttl == 3 { ip = dstIP } if ttl == 2 { time.Sleep(500 * time.Millisecond) // full "timeout" } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: ip}, RTT: 2 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: 50 * time.Millisecond, Timeout: 500 * time.Millisecond, // → dynamic maxInFlightPerHop = 11 MaxPerHop: 8, ParallelRequests: 30, ProgressThrottle: time.Millisecond, DstIP: dstIP, // MaxInFlightPerHop intentionally 0 → dynamic }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() sntByTTL := map[int]int{} for _, s := range stats { sntByTTL[s.TTL] = s.Snt } for ttl := 1; ttl <= 3; ttl++ { if sntByTTL[ttl] != 8 { t.Errorf("TTL %d: Snt=%d, expected 8 (MaxPerHop)", ttl, sntByTTL[ttl]) } } } func TestScheduler_DynamicMaxInFlightPerHop_SmallTimeout(t *testing.T) { // With timeout < hopInterval, dynamic = ceil(t/h)+1 = 1+1 = 2, not 1. // This ensures at least 2 slots so pipelining still works. dstIP := net.ParseIP("10.0.0.2") prober := &mockTTLProber{ probeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) { ip := net.ParseIP(fmt.Sprintf("10.0.0.%d", ttl)) if ttl == 2 { ip = dstIP } return mtrProbeResult{ TTL: ttl, Success: true, Addr: &net.IPAddr{IP: ip}, RTT: 1 * time.Millisecond, }, nil }, } agg := NewMTRAggregator() err := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{ BeginHop: 1, MaxHops: 30, HopInterval: 100 * time.Millisecond, Timeout: 50 * time.Millisecond, // timeout < hopInterval → dynamic = 2 MaxPerHop: 3, ParallelRequests: 10, ProgressThrottle: time.Millisecond, DstIP: dstIP, }, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } stats := agg.Snapshot() for _, s := range stats { if s.TTL >= 1 && s.TTL <= 2 && s.Snt != 3 { t.Errorf("TTL %d: Snt=%d, expected 3", s.TTL, s.Snt) } } } ================================================ FILE: trace/mtr_stats.go ================================================ package trace import ( "sort" "strings" "sync" "github.com/nxtrace/NTrace-core/ipgeo" ) // --------------------------------------------------------------------------- // MTR 聚合统计模型(公共层,CLI 和 Server 均可使用) // --------------------------------------------------------------------------- // MTRHopStat 表示 MTR 输出中一行统计数据。 type MTRHopStat struct { TTL int `json:"ttl"` Host string `json:"host,omitempty"` IP string `json:"ip,omitempty"` Loss float64 `json:"loss_percent"` Snt int `json:"snt"` Last float64 `json:"last_ms"` Avg float64 `json:"avg_ms"` Best float64 `json:"best_ms"` Wrst float64 `json:"wrst_ms"` StDev float64 `json:"stdev_ms"` Geo *ipgeo.IPGeoData `json:"geo,omitempty"` MPLS []string `json:"mpls,omitempty"` Received int `json:"received"` } // MTRSnapshot 是某一时刻的完整快照。 type MTRSnapshot struct { Iteration int `json:"iteration"` Stats []MTRHopStat `json:"stats"` } // --------------------------------------------------------------------------- // 内部累加器 // --------------------------------------------------------------------------- type mtrHopAccum struct { ttl int key string host string ip string sent int received int sum float64 sumSq float64 // Σ(rtt²),用于在线方差 last float64 best float64 worst float64 geo *ipgeo.IPGeoData order int mplsSet map[string]struct{} } // MTRAggregator 跨轮次聚合 hop 统计。线程安全。 type MTRAggregator struct { mu sync.Mutex stats map[int]map[string]*mtrHopAccum // [ttl][key] nextOrder int } // NewMTRAggregator 创建新的聚合器。 func NewMTRAggregator() *MTRAggregator { return &MTRAggregator{ stats: make(map[int]map[string]*mtrHopAccum), } } // Update 接收一轮 traceroute 的 Result 并更新统计,返回当前快照。 func (agg *MTRAggregator) Update(res *Result, queries int) []MTRHopStat { agg.mu.Lock() defer agg.mu.Unlock() if res == nil || len(res.Hops) == 0 { return agg.snapshotLocked() } _ = queries for idx, attempts := range res.Hops { if len(attempts) == 0 { continue } ttl := idx + 1 accMap := agg.accMapForTTLLocked(ttl) for key, group := range groupMTRHopAttempts(attempts) { agg.mergeGroupedHopLocked(ttl, accMap, key, group) } mergeUnknownIntoSingleKnown(accMap) } return agg.snapshotLocked() } // Reset 清空所有统计数据,用于 r 键重置。 func (agg *MTRAggregator) Reset() { agg.mu.Lock() defer agg.mu.Unlock() agg.stats = make(map[int]map[string]*mtrHopAccum) agg.nextOrder = 0 } // ClearHop 删除指定 TTL 上的所有聚合数据。 // 用于 per-hop 调度器中 knownFinalTTL 下调时,擦除旧 finalTTL 的过期统计, // 避免 ghost row,同时不会把旧 final 的 Snt 合并到新 final(防止 Snt 膨胀)。 func (agg *MTRAggregator) ClearHop(ttl int) { agg.mu.Lock() defer agg.mu.Unlock() delete(agg.stats, ttl) } // MigrateStats 将 fromTTL 上所有累加器迁移合并到 toTTL,然后删除 fromTTL。 // 用于 knownFinalTTL 下调时把旧 finalTTL 上已入账的 dst-ip 统计搬到新 finalTTL。 // maxPerHop > 0 时,合并后对每个累加器的 sent/received 做上限裁剪, // 保证 Snt 不超过预算。 func (agg *MTRAggregator) MigrateStats(fromTTL, toTTL, maxPerHop int) { agg.mu.Lock() defer agg.mu.Unlock() fromMap := agg.stats[fromTTL] if len(fromMap) == 0 { return } toMap := agg.accMapForTTLLocked(toTTL) for key, src := range fromMap { dst := toMap[key] if dst == nil { src.ttl = toTTL toMap[key] = src continue } mergeMTRHopAccum(dst, src) } for _, acc := range toMap { capMTRHopAccum(acc, maxPerHop) } delete(agg.stats, fromTTL) } // Clone 返回深拷贝的聚合器,用于流式预览(不影响原始数据)。 func (agg *MTRAggregator) Clone() *MTRAggregator { agg.mu.Lock() defer agg.mu.Unlock() c := &MTRAggregator{ stats: make(map[int]map[string]*mtrHopAccum, len(agg.stats)), nextOrder: agg.nextOrder, } for ttl, accMap := range agg.stats { cMap := make(map[string]*mtrHopAccum, len(accMap)) for key, acc := range accMap { dup := *acc // 浅拷贝 dup.mplsSet = make(map[string]struct{}, len(acc.mplsSet)) for k := range acc.mplsSet { dup.mplsSet[k] = struct{}{} } if acc.geo != nil { geoCopy := *acc.geo dup.geo = &geoCopy } cMap[key] = &dup } c.stats[ttl] = cMap } return c } // Snapshot 返回当前聚合结果快照。 func (agg *MTRAggregator) Snapshot() []MTRHopStat { agg.mu.Lock() defer agg.mu.Unlock() return agg.snapshotLocked() } func (agg *MTRAggregator) snapshotLocked() []MTRHopStat { // 收集 TTL 列表并排序 ttls := make([]int, 0, len(agg.stats)) for ttl := range agg.stats { ttls = append(ttls, ttl) } sort.Ints(ttls) var rows []MTRHopStat for _, ttl := range ttls { accMap := agg.stats[ttl] if len(accMap) == 0 { continue } // 按 order 稳定排序 accs := make([]*mtrHopAccum, 0, len(accMap)) for _, acc := range accMap { accs = append(accs, acc) } sort.SliceStable(accs, func(i, j int) bool { if accs[i].order == accs[j].order { return accs[i].ip < accs[j].ip } return accs[i].order < accs[j].order }) for _, acc := range accs { rows = append(rows, buildMTRHopStat(acc)) } } return rows } // mtrUnknownKey 是 timeout / 无地址 hop 的聚合键。 const mtrUnknownKey = "unknown" func mtrHopKey(ip, host string) string { ip = strings.TrimSpace(ip) host = strings.TrimSpace(host) if ip != "" { return "ip:" + ip } if host != "" { return "host:" + strings.ToLower(host) } return mtrUnknownKey } // mergeUnknownIntoSingleKnown 在同一 TTL 的 accMap 中, // 如果恰好只有 1 条非 unknown 路径,则将 unknown 累加器归并到该路径, // 避免同一跳同时出现 "(waiting for reply)" 和真实 IP 两行。 // // 多路径场景(非 unknown ≥ 2 或 == 0)不归并,防止误归因。 func mergeUnknownIntoSingleKnown(accMap map[string]*mtrHopAccum) { unk, ok := accMap[mtrUnknownKey] if !ok { return } // 收集非 unknown 累加器 var known *mtrHopAccum knownCount := 0 for k, acc := range accMap { if k == mtrUnknownKey { continue } known = acc knownCount++ if knownCount > 1 { break // 多路径,不归并 } } if knownCount != 1 || known == nil { return } mergeMTRHopAccum(known, unk) delete(accMap, mtrUnknownKey) } ================================================ FILE: trace/mtr_stats_helpers.go ================================================ package trace import ( "math" "sort" "strings" "time" "github.com/nxtrace/NTrace-core/ipgeo" ) type mtrHopGroup struct { host string ip string geo *ipgeo.IPGeoData sum float64 sumSq float64 last float64 best float64 worst float64 received int count int mpls map[string]struct{} } func newMTRHopGroup(host, ip string) *mtrHopGroup { return &mtrHopGroup{ host: host, ip: ip, best: math.MaxFloat64, } } func groupMTRHopAttempts(attempts []Hop) map[string]*mtrHopGroup { groups := make(map[string]*mtrHopGroup) for _, attempt := range attempts { host := strings.TrimSpace(attempt.Hostname) ip := "" if attempt.Address != nil { ip = strings.TrimSpace(attempt.Address.String()) } key := mtrHopKey(ip, host) group := groups[key] if group == nil { group = newMTRHopGroup(host, ip) groups[key] = group } group.includeAttempt(attempt) } return groups } func (g *mtrHopGroup) includeAttempt(attempt Hop) { g.count++ if g.geo == nil && attempt.Geo != nil { g.geo = attempt.Geo } mergeMTRLabels(&g.mpls, attempt.MPLS) if !attempt.Success { return } rttMs := float64(attempt.RTT) / float64(time.Millisecond) g.sum += rttMs g.sumSq += rttMs * rttMs g.received++ g.last = rttMs if rttMs > g.worst { g.worst = rttMs } if rttMs > 0 && rttMs < g.best { g.best = rttMs } } func (agg *MTRAggregator) accMapForTTLLocked(ttl int) map[string]*mtrHopAccum { accMap := agg.stats[ttl] if accMap == nil { accMap = make(map[string]*mtrHopAccum) agg.stats[ttl] = accMap } return accMap } func (agg *MTRAggregator) newHopAccum(ttl int, key string) *mtrHopAccum { acc := &mtrHopAccum{ ttl: ttl, key: key, best: math.MaxFloat64, order: agg.nextOrder, mplsSet: make(map[string]struct{}), } agg.nextOrder++ return acc } func (agg *MTRAggregator) mergeGroupedHopLocked(ttl int, accMap map[string]*mtrHopAccum, key string, group *mtrHopGroup) { acc := accMap[key] if acc == nil { acc = agg.newHopAccum(ttl, key) accMap[key] = acc } mergeMTRHopGroupIntoAccum(acc, group) } func mergeMTRHopGroupIntoAccum(acc *mtrHopAccum, group *mtrHopGroup) { if group.ip != "" { acc.ip = group.ip } if group.host != "" { acc.host = group.host } if group.geo != nil { acc.geo = group.geo } acc.sent += group.count if group.received > 0 { acc.sum += group.sum acc.sumSq += group.sumSq acc.received += group.received acc.last = group.last if group.best > 0 && (acc.best == math.MaxFloat64 || group.best < acc.best) { acc.best = group.best } if group.worst > acc.worst { acc.worst = group.worst } } mergeMTRLabelSet(acc.mplsSet, group.mpls) } func mergeMTRHopAccum(dst, src *mtrHopAccum) { dst.sent += src.sent dst.received += src.received if src.received > 0 { dst.sum += src.sum dst.sumSq += src.sumSq dst.last = src.last if src.best > 0 && src.best < dst.best { dst.best = src.best } if src.worst > dst.worst { dst.worst = src.worst } } if dst.geo == nil && src.geo != nil { dst.geo = src.geo } if dst.host == "" && src.host != "" { dst.host = src.host } if dst.ip == "" && src.ip != "" { dst.ip = src.ip } mergeMTRLabelSet(dst.mplsSet, src.mplsSet) } func mergeMTRLabels(dst *map[string]struct{}, labels []string) { if len(labels) == 0 { return } if *dst == nil { *dst = make(map[string]struct{}) } for _, label := range labels { val := strings.TrimSpace(label) if val != "" { (*dst)[val] = struct{}{} } } } func mergeMTRLabelSet(dst, src map[string]struct{}) { for label := range src { dst[label] = struct{}{} } } func capMTRHopAccum(acc *mtrHopAccum, maxPerHop int) { if maxPerHop <= 0 { return } if acc.sent > maxPerHop { acc.sent = maxPerHop } if acc.received <= acc.sent { return } nOrig := float64(acc.received) nNew := float64(acc.sent) ratio := nNew / nOrig sumNew := acc.sum * ratio ss := acc.sumSq - (acc.sum*acc.sum)/nOrig if ss < 0 { ss = 0 } var sumSqNew float64 if nOrig > 1 && nNew > 1 { sumSqNew = ss*(nNew-1)/(nOrig-1) + (sumNew*sumNew)/nNew } else { sumSqNew = (sumNew * sumNew) / nNew } acc.sum = sumNew acc.sumSq = sumSqNew acc.received = acc.sent } func buildMTRHopStat(acc *mtrHopAccum) MTRHopStat { lossCount := acc.sent - acc.received lossPct := 0.0 if acc.sent > 0 { lossPct = float64(lossCount) / float64(acc.sent) * 100 } best := acc.best if best == math.MaxFloat64 { best = 0 } avg := 0.0 if acc.received > 0 { avg = acc.sum / float64(acc.received) } stdev := 0.0 if acc.received > 1 { n := float64(acc.received) variance := (acc.sumSq - (acc.sum*acc.sum)/n) / (n - 1) if variance > 0 { stdev = math.Sqrt(variance) } } var mpls []string if len(acc.mplsSet) > 0 { mpls = make([]string, 0, len(acc.mplsSet)) for label := range acc.mplsSet { mpls = append(mpls, label) } sort.Strings(mpls) } return MTRHopStat{ TTL: acc.ttl, Host: acc.host, IP: acc.ip, Loss: lossPct, Snt: acc.sent, Last: acc.last, Avg: avg, Best: best, Wrst: acc.worst, StDev: stdev, Geo: acc.geo, MPLS: mpls, Received: acc.received, } } ================================================ FILE: trace/mtr_stats_test.go ================================================ package trace import ( "math" "net" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" ) func mkHop(ttl int, ip string, rtt time.Duration) Hop { return Hop{ Success: true, Address: &net.IPAddr{IP: net.ParseIP(ip)}, TTL: ttl, RTT: rtt, } } func mkTimeoutHop(ttl int) Hop { return Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, } } func mkResult(hopsByTTL ...[]Hop) *Result { res := &Result{ Hops: make([][]Hop, len(hopsByTTL)), } copy(res.Hops, hopsByTTL) return res } func roundN(v float64, n int) float64 { pow := math.Pow(10, float64(n)) return math.Round(v*pow) / pow } func TestSinglePath(t *testing.T) { agg := NewMTRAggregator() res1 := mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, []Hop{mkHop(2, "2.2.2.2", 20*time.Millisecond)}, ) agg.Update(res1, 1) res2 := mkResult( []Hop{mkHop(1, "1.1.1.1", 12*time.Millisecond)}, []Hop{mkHop(2, "2.2.2.2", 18*time.Millisecond)}, ) agg.Update(res2, 1) res3 := mkResult( []Hop{mkHop(1, "1.1.1.1", 14*time.Millisecond)}, []Hop{mkHop(2, "2.2.2.2", 22*time.Millisecond)}, ) stats := agg.Update(res3, 1) if len(stats) != 2 { t.Fatalf("expected 2 rows, got %d", len(stats)) } s := stats[0] if s.TTL != 1 { t.Errorf("TTL: want 1, got %d", s.TTL) } if s.Snt != 3 { t.Errorf("Snt: want 3, got %d", s.Snt) } if s.Received != 3 { t.Errorf("Received: want 3, got %d", s.Received) } if s.Loss != 0 { t.Errorf("Loss: want 0, got %f", s.Loss) } if s.Last != 14 { t.Errorf("Last: want 14, got %f", s.Last) } if s.Best != 10 { t.Errorf("Best: want 10, got %f", s.Best) } if s.Wrst != 14 { t.Errorf("Wrst: want 14, got %f", s.Wrst) } if roundN(s.Avg, 4) != 12 { t.Errorf("Avg: want 12, got %f", s.Avg) } if roundN(s.StDev, 4) != 2.0 { t.Errorf("StDev: want 2.0, got %f", s.StDev) } s2 := stats[1] if s2.TTL != 2 { t.Errorf("TTL: want 2, got %d", s2.TTL) } if s2.Snt != 3 { t.Errorf("Snt: want 3, got %d", s2.Snt) } if roundN(s2.Avg, 4) != 20 { t.Errorf("Avg: want 20, got %f", s2.Avg) } if roundN(s2.StDev, 4) != 2.0 { t.Errorf("StDev: want 2.0, got %f", s2.StDev) } } func TestMultiPath(t *testing.T) { agg := NewMTRAggregator() res1 := mkResult( []Hop{ mkHop(1, "10.0.0.1", 5*time.Millisecond), mkHop(1, "10.0.0.2", 8*time.Millisecond), }, ) stats := agg.Update(res1, 2) if len(stats) != 2 { t.Fatalf("expected 2 rows for multipath, got %d", len(stats)) } if stats[0].TTL != 1 || stats[1].TTL != 1 { t.Errorf("both rows should be TTL=1") } ips := map[string]bool{stats[0].IP: true, stats[1].IP: true} if !ips["10.0.0.1"] || !ips["10.0.0.2"] { t.Errorf("expected both IPs") } res2 := mkResult([]Hop{mkHop(1, "10.0.0.1", 6*time.Millisecond)}) stats = agg.Update(res2, 1) for _, s := range stats { if s.IP == "10.0.0.1" && s.Snt != 2 { t.Errorf("10.0.0.1 sent: want 2, got %d", s.Snt) } if s.IP == "10.0.0.2" && s.Snt != 1 { t.Errorf("10.0.0.2 sent: want 1, got %d", s.Snt) } } } func TestErrorMix(t *testing.T) { // 同 TTL: 2 次成功 (IP=1.1.1.1) + 1 次 timeout。 // 单路径归并后应只剩 1 行,Snt=3,Received=2,Loss≈33.3%。 agg := NewMTRAggregator() res := mkResult( []Hop{ mkHop(1, "1.1.1.1", 10*time.Millisecond), mkHop(1, "1.1.1.1", 20*time.Millisecond), mkTimeoutHop(1), }, ) stats := agg.Update(res, 3) if len(stats) != 1 { t.Fatalf("expected 1 row after merge, got %d", len(stats)) } s := stats[0] if s.IP != "1.1.1.1" { t.Errorf("IP: want 1.1.1.1, got %q", s.IP) } if s.Snt != 3 { t.Errorf("Snt: want 3, got %d", s.Snt) } if s.Received != 2 { t.Errorf("Received: want 2, got %d", s.Received) } wantLoss := roundN(100.0/3.0, 1) // 33.3 gotLoss := roundN(s.Loss, 1) if gotLoss != wantLoss { t.Errorf("Loss: want %.1f%%, got %.1f%%", wantLoss, gotLoss) } } func TestGeoPropagation(t *testing.T) { agg := NewMTRAggregator() geoData := &ipgeo.IPGeoData{Country: "US", City: "San Francisco"} hop := mkHop(1, "1.1.1.1", 10*time.Millisecond) hop.Geo = geoData res := mkResult([]Hop{hop}) stats := agg.Update(res, 1) if len(stats) != 1 { t.Fatalf("expected 1 row, got %d", len(stats)) } if stats[0].Geo == nil { t.Fatal("expected geo data, got nil") } if stats[0].Geo.Country != "US" { t.Errorf("Country: want US, got %s", stats[0].Geo.Country) } } func TestStDevSingleSample(t *testing.T) { agg := NewMTRAggregator() res := mkResult([]Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}) stats := agg.Update(res, 1) if len(stats) != 1 { t.Fatalf("expected 1 row, got %d", len(stats)) } if stats[0].StDev != 0 { t.Errorf("StDev with 1 sample: want 0, got %f", stats[0].StDev) } } func TestAllTimeout(t *testing.T) { agg := NewMTRAggregator() res := mkResult([]Hop{mkTimeoutHop(1), mkTimeoutHop(1), mkTimeoutHop(1)}) stats := agg.Update(res, 3) if len(stats) != 1 { t.Fatalf("expected 1 row, got %d", len(stats)) } s := stats[0] if s.Snt != 3 { t.Errorf("Snt: want 3, got %d", s.Snt) } if s.Received != 0 { t.Errorf("Received: want 0, got %d", s.Received) } if s.Loss != 100 { t.Errorf("Loss: want 100, got %f", s.Loss) } if s.Avg != 0 || s.Best != 0 || s.Wrst != 0 || s.StDev != 0 { t.Errorf("all RTT should be 0 for all-timeout") } } func TestUpdate_NilResult(t *testing.T) { agg := NewMTRAggregator() if got := agg.Update(nil, 1); got != nil { t.Fatalf("Update(nil, 1) = %v, want nil", got) } if got := agg.Update(&Result{}, 1); got != nil { t.Fatalf("Update(empty result, 1) = %v, want nil", got) } } func TestHostnamePropagation(t *testing.T) { agg := NewMTRAggregator() hop := mkHop(1, "1.1.1.1", 10*time.Millisecond) hop.Hostname = "one.one.one.one" res := mkResult([]Hop{hop}) stats := agg.Update(res, 1) if len(stats) != 1 { t.Fatalf("expected 1 row, got %d", len(stats)) } if stats[0].Host != "one.one.one.one" { t.Errorf("Host: want one.one.one.one, got %s", stats[0].Host) } } func TestMTRAggregator_Reset(t *testing.T) { agg := NewMTRAggregator() res := mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, []Hop{mkHop(2, "2.2.2.2", 20*time.Millisecond)}, ) agg.Update(res, 1) agg.Update(res, 1) // Reset 后 Snapshot 应为空 agg.Reset() snap := agg.Snapshot() if len(snap) != 0 { t.Fatalf("expected 0 rows after Reset, got %d", len(snap)) } // Reset 后继续 Update 应正常工作,Snt 从 1 重新开始 stats := agg.Update(res, 1) if len(stats) != 2 { t.Fatalf("expected 2 rows after re-update, got %d", len(stats)) } if stats[0].Snt != 1 { t.Errorf("Snt after reset: want 1, got %d", stats[0].Snt) } } func TestMTRAggregator_CloneIsolation(t *testing.T) { agg := NewMTRAggregator() res1 := mkResult( []Hop{mkHop(1, "1.1.1.1", 10*time.Millisecond)}, ) agg.Update(res1, 1) // Clone clone := agg.Clone() // 修改原始聚合器 res2 := mkResult( []Hop{mkHop(1, "1.1.1.1", 20*time.Millisecond)}, ) agg.Update(res2, 1) // Clone 快照应只有 1 次发送 cloneSnap := clone.Snapshot() if len(cloneSnap) != 1 { t.Fatalf("clone: expected 1 row, got %d", len(cloneSnap)) } if cloneSnap[0].Snt != 1 { t.Errorf("clone Snt: want 1, got %d", cloneSnap[0].Snt) } // 原始聚合器应有 2 次发送 origSnap := agg.Snapshot() if origSnap[0].Snt != 2 { t.Errorf("original Snt: want 2, got %d", origSnap[0].Snt) } // 修改 Clone 不影响原始 res3 := mkResult( []Hop{mkHop(1, "1.1.1.1", 30*time.Millisecond)}, ) clone.Update(res3, 1) cloneSnap = clone.Snapshot() if cloneSnap[0].Snt != 2 { t.Errorf("clone Snt after update: want 2, got %d", cloneSnap[0].Snt) } origSnap = agg.Snapshot() if origSnap[0].Snt != 2 { t.Errorf("original Snt should still be 2, got %d", origSnap[0].Snt) } } // TestUnknownMergedAfterLaterReply_SinglePath 验证跨轮次归并: // Round1: TTL1 timeout → Round2: TTL1 reply(IP=A)。 // 结果应只有 A 一行,Snt=2,Received=1,Loss=50%。 func TestUnknownMergedAfterLaterReply_SinglePath(t *testing.T) { agg := NewMTRAggregator() // Round 1: timeout r1 := mkResult([]Hop{mkTimeoutHop(1)}) stats := agg.Update(r1, 1) if len(stats) != 1 { t.Fatalf("round1: expected 1 row, got %d", len(stats)) } // Round 2: real reply r2 := mkResult([]Hop{mkHop(1, "10.0.0.1", 15*time.Millisecond)}) stats = agg.Update(r2, 1) if len(stats) != 1 { t.Fatalf("round2: expected 1 row after merge, got %d", len(stats)) } s := stats[0] if s.IP != "10.0.0.1" { t.Errorf("IP: want 10.0.0.1, got %q", s.IP) } if s.Snt != 2 { t.Errorf("Snt: want 2, got %d", s.Snt) } if s.Received != 1 { t.Errorf("Received: want 1, got %d", s.Received) } if s.Loss != 50 { t.Errorf("Loss: want 50, got %f", s.Loss) } } // TestUnknownPreserved_Multipath 验证多路径下 unknown 不被归并。 func TestUnknownPreserved_Multipath(t *testing.T) { agg := NewMTRAggregator() res := mkResult( []Hop{ mkHop(1, "10.0.0.1", 5*time.Millisecond), mkHop(1, "10.0.0.2", 8*time.Millisecond), mkTimeoutHop(1), }, ) stats := agg.Update(res, 3) // 应有 3 行:10.0.0.1、10.0.0.2、unknown if len(stats) != 3 { t.Fatalf("expected 3 rows for multipath+timeout, got %d", len(stats)) } ips := map[string]bool{} hasUnknown := false for _, s := range stats { if s.IP != "" { ips[s.IP] = true } if s.IP == "" && s.Host == "" { hasUnknown = true } } if !ips["10.0.0.1"] || !ips["10.0.0.2"] { t.Error("expected both known IPs") } if !hasUnknown { t.Error("expected unknown row preserved in multipath scenario") } } // --------------------------------------------------------------------------- // ClearHop 测试 // --------------------------------------------------------------------------- func TestMTRAggregator_ClearHop_RemovesData(t *testing.T) { agg := NewMTRAggregator() // Record stats at TTL 5 res := mkResult( nil, nil, nil, nil, []Hop{mkHop(5, "10.0.0.99", 5*time.Millisecond)}, ) agg.Update(res, 1) // Verify TTL 5 exists snap := agg.Snapshot() found := false for _, s := range snap { if s.TTL == 5 { found = true } } if !found { t.Fatal("expected TTL 5 in snapshot before ClearHop") } // Clear TTL 5 agg.ClearHop(5) // Verify TTL 5 is gone snap = agg.Snapshot() for _, s := range snap { if s.TTL == 5 { t.Error("TTL 5 should not exist after ClearHop") } } } func TestMTRAggregator_ClearHop_NoopIfMissing(t *testing.T) { agg := NewMTRAggregator() // Record stats at TTL 3 only res := mkResult( nil, nil, []Hop{mkHop(3, "10.0.0.3", 3*time.Millisecond)}, ) agg.Update(res, 1) // Clear non-existent TTL — should not panic agg.ClearHop(99) // TTL 3 should still exist snap := agg.Snapshot() found := false for _, s := range snap { if s.TTL == 3 { found = true } } if !found { t.Error("TTL 3 should still exist after clearing non-existent TTL") } } func TestMTRAggregator_ClearHop_DoesNotAffectOtherTTLs(t *testing.T) { agg := NewMTRAggregator() // Record stats at TTLs 3 and 5 res := mkResult( nil, nil, []Hop{mkHop(3, "10.0.0.3", 3*time.Millisecond)}, nil, []Hop{mkHop(5, "10.0.0.5", 5*time.Millisecond)}, ) agg.Update(res, 1) // Clear TTL 5 agg.ClearHop(5) // TTL 3 should still have its data snap := agg.Snapshot() for _, s := range snap { if s.TTL == 3 { if s.IP != "10.0.0.3" { t.Errorf("TTL 3 IP = %q, want 10.0.0.3", s.IP) } if s.Snt != 1 { t.Errorf("TTL 3 Snt = %d, want 1", s.Snt) } return } } t.Error("TTL 3 not found in snapshot after clearing TTL 5") } // --------------------------------------------------------------------------- // MigrateStats 测试 // --------------------------------------------------------------------------- func TestMTRAggregator_MigrateStats_MovesToNewTTL(t *testing.T) { agg := NewMTRAggregator() // Record stats at TTL 12 res := mkResult( nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, []Hop{mkHop(12, "8.8.8.8", 10*time.Millisecond)}, ) agg.Update(res, 1) // Verify TTL 12 has data snap := agg.Snapshot() hasTTL12 := false for _, s := range snap { if s.TTL == 12 && s.IP == "8.8.8.8" { hasTTL12 = true } } if !hasTTL12 { t.Fatal("expected stats at TTL 12 before migration") } // Migrate TTL 12 → TTL 7 agg.MigrateStats(12, 7, 0) // maxPerHop=0 → no cap // TTL 12 should be gone, TTL 7 should have the data snap = agg.Snapshot() for _, s := range snap { if s.TTL == 12 { t.Errorf("TTL 12 should have been removed after migration, found IP=%s", s.IP) } if s.TTL == 7 && s.IP == "8.8.8.8" { if s.Snt != 1 { t.Errorf("TTL 7: expected Snt=1, got %d", s.Snt) } return // success } } t.Error("TTL 7 should have migrated data from TTL 12") } func TestMTRAggregator_MigrateStats_MergesIntoExisting(t *testing.T) { agg := NewMTRAggregator() // Record 2 probes at TTL 7 (the new final) for i := 0; i < 2; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[6] = []Hop{mkHop(7, "8.8.8.8", 5*time.Millisecond)} agg.Update(res, 1) } // Record 3 probes at TTL 12 (the old final) for i := 0; i < 3; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[11] = []Hop{mkHop(12, "8.8.8.8", 10*time.Millisecond)} agg.Update(res, 1) } // Migrate 12 → 7 without cap agg.MigrateStats(12, 7, 0) snap := agg.Snapshot() for _, s := range snap { if s.TTL == 12 { t.Error("TTL 12 should be gone after migration") } if s.TTL == 7 && s.IP == "8.8.8.8" { // Merged: 2 (existing) + 3 (migrated) = 5 if s.Snt != 5 { t.Errorf("TTL 7: expected Snt=5 (2+3), got %d", s.Snt) } return } } t.Error("TTL 7 should have merged data") } func TestMTRAggregator_MigrateStats_NoopIfFromEmpty(t *testing.T) { agg := NewMTRAggregator() // Record at TTL 7 res := &Result{Hops: make([][]Hop, 8)} res.Hops[6] = []Hop{mkHop(7, "8.8.8.8", 5*time.Millisecond)} agg.Update(res, 1) // Migrate from non-existent TTL 12 — should be no-op agg.MigrateStats(12, 7, 0) snap := agg.Snapshot() for _, s := range snap { if s.TTL == 7 && s.IP == "8.8.8.8" && s.Snt == 1 { return // unchanged, correct } } t.Error("TTL 7 data should be unchanged after noop migration") } func TestMTRAggregator_MigrateStats_CapsAtMaxPerHop(t *testing.T) { agg := NewMTRAggregator() // Record 2 probes at TTL 7 for i := 0; i < 2; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[6] = []Hop{mkHop(7, "8.8.8.8", 5*time.Millisecond)} agg.Update(res, 1) } // Record 3 probes at TTL 12 for i := 0; i < 3; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[11] = []Hop{mkHop(12, "8.8.8.8", 10*time.Millisecond)} agg.Update(res, 1) } // Migrate with maxPerHop=3: merged total would be 5, capped to 3. agg.MigrateStats(12, 7, 3) snap := agg.Snapshot() for _, s := range snap { if s.TTL == 12 { t.Error("TTL 12 should be gone after migration") } if s.TTL == 7 && s.IP == "8.8.8.8" { if s.Snt != 3 { t.Errorf("TTL 7: expected Snt=3 (capped from 5), got %d", s.Snt) } return } } t.Error("TTL 7 should have capped merged data") } func TestMTRAggregator_MigrateStats_CapsReceived(t *testing.T) { agg := NewMTRAggregator() // 2 sent, 2 received at TTL 7 for i := 0; i < 2; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[6] = []Hop{mkHop(7, "8.8.8.8", 5*time.Millisecond)} agg.Update(res, 1) } // 3 sent, 3 received at TTL 12 for i := 0; i < 3; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[11] = []Hop{mkHop(12, "8.8.8.8", 10*time.Millisecond)} agg.Update(res, 1) } // maxPerHop=2: sent capped to 2, received capped to min(5,2)=2 agg.MigrateStats(12, 7, 2) snap := agg.Snapshot() for _, s := range snap { if s.TTL == 7 && s.IP == "8.8.8.8" { if s.Snt != 2 { t.Errorf("TTL 7: expected Snt=2 (capped), got %d", s.Snt) } // Loss should be 0% (received capped to sent=2) if s.Loss > 0.01 { t.Errorf("TTL 7: expected Loss ~0%%, got %.2f%%", s.Loss) } // Avg must stay consistent after proportional RTT scaling. // Pre-merge: 2×5ms + 3×10ms = 40ms total over 5 samples → avg = 8ms. // Proportional scaling preserves avg even after cap. expectedAvg := 8.0 // (2*5 + 3*10) / 5 = 8ms if s.Avg < expectedAvg-0.1 || s.Avg > expectedAvg+0.1 { t.Errorf("TTL 7: expected Avg ~%.1fms (proportional), got %.4fms", expectedAvg, s.Avg) } return } } t.Error("TTL 7 should have capped data") } func TestMTRAggregator_MigrateStats_CapsPreservesAvgStDev(t *testing.T) { agg := NewMTRAggregator() // 3 probes at TTL 7: all 4ms for i := 0; i < 3; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[6] = []Hop{mkHop(7, "8.8.8.8", 4*time.Millisecond)} agg.Update(res, 1) } // 3 probes at TTL 12: all 4ms (same RTT for zero-variance check) for i := 0; i < 3; i++ { res := &Result{Hops: make([][]Hop, 12)} res.Hops[11] = []Hop{mkHop(12, "8.8.8.8", 4*time.Millisecond)} agg.Update(res, 1) } // Pre-cap: 6 probes all 4ms → avg=4, stdev=0 // After cap to 3: avg must still be 4, stdev must still be 0. agg.MigrateStats(12, 7, 3) snap := agg.Snapshot() for _, s := range snap { if s.TTL == 7 && s.IP == "8.8.8.8" { if s.Snt != 3 { t.Errorf("expected Snt=3, got %d", s.Snt) } if s.Avg < 3.9 || s.Avg > 4.1 { t.Errorf("expected Avg ~4.0ms, got %.4fms", s.Avg) } if s.StDev > 0.1 { t.Errorf("expected StDev ~0, got %.4f", s.StDev) } return } } t.Error("expected TTL 7 data") } func TestMTRAggregator_MigrateStats_CapsPreservesNonZeroStDev(t *testing.T) { agg := NewMTRAggregator() // Build a merged accumulator with mixed RTTs and verify Avg + StDev // are preserved when received is capped. // // TTL 7: 2ms, 4ms (2 probes) // TTL 12: 6ms, 8ms, 10ms (3 probes) // Merged: [2, 4, 6, 8, 10] → 5 probes // avg = 30/5 = 6.0 // sample var = ((2-6)² + (4-6)² + (6-6)² + (8-6)² + (10-6)²) / 4 // = (16+4+0+4+16) / 4 = 10.0 // stdev = sqrt(10) ≈ 3.1623 rtts7 := []time.Duration{2 * time.Millisecond, 4 * time.Millisecond} for _, rtt := range rtts7 { res := &Result{Hops: make([][]Hop, 12)} res.Hops[6] = []Hop{mkHop(7, "8.8.8.8", rtt)} agg.Update(res, 1) } rtts12 := []time.Duration{6 * time.Millisecond, 8 * time.Millisecond, 10 * time.Millisecond} for _, rtt := range rtts12 { res := &Result{Hops: make([][]Hop, 12)} res.Hops[11] = []Hop{mkHop(12, "8.8.8.8", rtt)} agg.Update(res, 1) } // Snapshot before cap to get the "ground truth" Avg & StDev. agg.MigrateStats(12, 7, 0) // merge without cap first snapBefore := agg.Snapshot() var avgBefore, stdevBefore float64 for _, s := range snapBefore { if s.TTL == 7 && s.IP == "8.8.8.8" { avgBefore = s.Avg stdevBefore = s.StDev } } if avgBefore < 5.9 || avgBefore > 6.1 { t.Fatalf("pre-cap Avg wrong: got %.4f, expected ~6.0", avgBefore) } // Now build the same scenario fresh, but cap to 3. agg2 := NewMTRAggregator() for _, rtt := range rtts7 { res := &Result{Hops: make([][]Hop, 12)} res.Hops[6] = []Hop{mkHop(7, "8.8.8.8", rtt)} agg2.Update(res, 1) } for _, rtt := range rtts12 { res := &Result{Hops: make([][]Hop, 12)} res.Hops[11] = []Hop{mkHop(12, "8.8.8.8", rtt)} agg2.Update(res, 1) } agg2.MigrateStats(12, 7, 3) // cap received from 5 → 3 snapAfter := agg2.Snapshot() for _, s := range snapAfter { if s.TTL == 7 && s.IP == "8.8.8.8" { if s.Snt != 3 { t.Errorf("expected Snt=3, got %d", s.Snt) } // Avg must be preserved. if s.Avg < avgBefore-0.1 || s.Avg > avgBefore+0.1 { t.Errorf("Avg drifted: expected ~%.4f, got %.4f", avgBefore, s.Avg) } // StDev must be preserved (non-zero variance scenario). if stdevBefore > 0.1 { if s.StDev < stdevBefore-0.15 || s.StDev > stdevBefore+0.15 { t.Errorf("StDev drifted: expected ~%.4f, got %.4f", stdevBefore, s.StDev) } } return } } t.Error("expected TTL 7 data after capped migration") } ================================================ FILE: trace/mtu/decode.go ================================================ package mtu import ( "encoding/binary" "net" "time" "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) const probePayloadMinLen = 8 type probeResponse struct { Event Event IP net.IP RTT time.Duration PMTU int } func buildProbePayload(size int) []byte { if size < probePayloadMinLen { size = probePayloadMinLen } return make([]byte, size) } func parseICMPProbeResult(ipVersion int, raw []byte, peerIP, dstIP net.IP, dstPort, srcPort int) (probeResponse, bool) { protocol := 1 if ipVersion == 6 { protocol = 58 } rm, err := icmp.ParseMessage(protocol, raw) if err != nil { return probeResponse{}, false } var ( event Event data []byte pmtu int ) switch ipVersion { case 4: switch rm.Type { case ipv4.ICMPTypeTimeExceeded: body, ok := rm.Body.(*icmp.TimeExceeded) if !ok || body == nil { return probeResponse{}, false } event = EventTimeExceeded data = body.Data case ipv4.ICMPTypeDestinationUnreachable: body, ok := rm.Body.(*icmp.DstUnreach) if !ok || body == nil { return probeResponse{}, false } data = body.Data if len(raw) >= 8 && raw[1] == 4 { event = EventFragNeeded pmtu = int(binary.BigEndian.Uint16(raw[6:8])) } else if peerIP != nil && peerIP.Equal(dstIP) { event = EventDestination } else { return probeResponse{}, false } default: return probeResponse{}, false } case 6: switch rm.Type { case ipv6.ICMPTypeTimeExceeded: body, ok := rm.Body.(*icmp.TimeExceeded) if !ok || body == nil { return probeResponse{}, false } event = EventTimeExceeded data = body.Data case ipv6.ICMPTypePacketTooBig: body, ok := rm.Body.(*icmp.PacketTooBig) if !ok || body == nil { return probeResponse{}, false } event = EventPacketTooBig data = body.Data pmtu = body.MTU case ipv6.ICMPTypeDestinationUnreachable: body, ok := rm.Body.(*icmp.DstUnreach) if !ok || body == nil { return probeResponse{}, false } if peerIP == nil || !peerIP.Equal(dstIP) { return probeResponse{}, false } event = EventDestination data = body.Data default: return probeResponse{}, false } default: return probeResponse{}, false } if !matchesEmbeddedUDP(data, ipVersion, dstIP, dstPort, srcPort) { return probeResponse{}, false } return probeResponse{ Event: event, IP: peerIP, PMTU: pmtu, }, true } func matchesEmbeddedUDP(data []byte, ipVersion int, dstIP net.IP, dstPort, srcPort int) bool { packet, ok := parseEmbeddedUDPPacket(data, ipVersion) if !ok { return false } return packet.dstIP.Equal(dstIP) && packet.srcPort == srcPort && packet.dstPort == dstPort } type embeddedUDPPacket struct { dstIP net.IP srcPort int dstPort int } func parseEmbeddedUDPPacket(data []byte, ipVersion int) (embeddedUDPPacket, bool) { switch ipVersion { case 4: if len(data) < 28 || data[0]>>4 != 4 { return embeddedUDPPacket{}, false } ihl := int(data[0]&0x0f) * 4 if ihl < 20 || len(data) < ihl+8 { return embeddedUDPPacket{}, false } if data[9] != 17 { return embeddedUDPPacket{}, false } return parseEmbeddedUDPFromOffsets(data, ihl, net.IP(data[16:20])) case 6: if len(data) < 48 || data[0]>>4 != 6 { return embeddedUDPPacket{}, false } return parseEmbeddedIPv6UDP(data) default: return embeddedUDPPacket{}, false } } func parseEmbeddedIPv6UDP(data []byte) (embeddedUDPPacket, bool) { const ipv6HeaderLen = 40 nextHeader := data[6] offset := ipv6HeaderLen dstIP := net.IP(data[24:40]) for { switch nextHeader { case 17: return parseEmbeddedUDPFromOffsets(data, offset, dstIP) case 0, 43, 60: if len(data) < offset+2 { return embeddedUDPPacket{}, false } nextHeader = data[offset] hdrLen := (int(data[offset+1]) + 1) * 8 if hdrLen < 8 || len(data) < offset+hdrLen { return embeddedUDPPacket{}, false } offset += hdrLen case 44: if len(data) < offset+8 { return embeddedUDPPacket{}, false } nextHeader = data[offset] offset += 8 case 51: if len(data) < offset+2 { return embeddedUDPPacket{}, false } nextHeader = data[offset] hdrLen := (int(data[offset+1]) + 2) * 4 if hdrLen < 8 || len(data) < offset+hdrLen { return embeddedUDPPacket{}, false } offset += hdrLen case 50: return embeddedUDPPacket{}, false default: return embeddedUDPPacket{}, false } } } func parseEmbeddedUDPFromOffsets(data []byte, udpOffset int, dstIP net.IP) (embeddedUDPPacket, bool) { if len(data) < udpOffset+8 { return embeddedUDPPacket{}, false } return embeddedUDPPacket{ dstIP: append(net.IP(nil), dstIP...), srcPort: int(binary.BigEndian.Uint16(data[udpOffset : udpOffset+2])), dstPort: int(binary.BigEndian.Uint16(data[udpOffset+2 : udpOffset+4])), }, true } ================================================ FILE: trace/mtu/decode_test.go ================================================ package mtu import ( "encoding/binary" "net" "testing" "github.com/google/gopacket" "github.com/google/gopacket/layers" "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) func TestParseICMPProbeResultIPv4FragNeeded(t *testing.T) { dstIP := net.ParseIP("203.0.113.9") peerIP := net.ParseIP("198.51.100.1") inner := mustSerializeIPv4UDP(t, net.ParseIP("192.0.2.10"), dstIP, 40000, 33494, buildProbePayload(64)) msg := icmp.Message{ Type: ipv4.ICMPTypeDestinationUnreachable, Code: 4, Body: &icmp.DstUnreach{Data: inner}, } raw, err := msg.Marshal(nil) if err != nil { t.Fatalf("marshal icmp: %v", err) } binary.BigEndian.PutUint16(raw[6:8], 1400) resp, ok := parseICMPProbeResult(4, raw, peerIP, dstIP, 33494, 40000) if !ok { t.Fatal("expected frag-needed response to match") } if resp.Event != EventFragNeeded { t.Fatalf("event = %q, want %q", resp.Event, EventFragNeeded) } if resp.PMTU != 1400 { t.Fatalf("pmtu = %d, want 1400", resp.PMTU) } if !resp.IP.Equal(peerIP) { t.Fatalf("peer = %v, want %v", resp.IP, peerIP) } } func TestParseICMPProbeResultIPv6PacketTooBig(t *testing.T) { dstIP := net.ParseIP("2001:db8::9") peerIP := net.ParseIP("2001:db8::1") inner := mustSerializeIPv6UDP(t, net.ParseIP("2001:db8::10"), dstIP, 40001, 33494, buildProbePayload(80)) msg := icmp.Message{ Type: ipv6.ICMPTypePacketTooBig, Code: 0, Body: &icmp.PacketTooBig{MTU: 1280, Data: inner}, } raw, err := msg.Marshal(nil) if err != nil { t.Fatalf("marshal icmpv6: %v", err) } resp, ok := parseICMPProbeResult(6, raw, peerIP, dstIP, 33494, 40001) if !ok { t.Fatal("expected packet-too-big response to match") } if resp.Event != EventPacketTooBig { t.Fatalf("event = %q, want %q", resp.Event, EventPacketTooBig) } if resp.PMTU != 1280 { t.Fatalf("pmtu = %d, want 1280", resp.PMTU) } } func TestParseICMPProbeResultIPv4MatchesMinimumQuotedUDPHeader(t *testing.T) { dstIP := net.ParseIP("203.0.113.9") peerIP := net.ParseIP("198.51.100.1") inner := mustSerializeIPv4UDP(t, net.ParseIP("192.0.2.10"), dstIP, 40000, 33494, nil) inner = inner[:28] msg := icmp.Message{ Type: ipv4.ICMPTypeDestinationUnreachable, Code: 4, Body: &icmp.DstUnreach{Data: inner}, } raw, err := msg.Marshal(nil) if err != nil { t.Fatalf("marshal icmp: %v", err) } binary.BigEndian.PutUint16(raw[6:8], 1500) resp, ok := parseICMPProbeResult(4, raw, peerIP, dstIP, 33494, 40000) if !ok { t.Fatal("expected minimal quoted udp header to match") } if resp.PMTU != 1500 { t.Fatalf("pmtu = %d, want 1500", resp.PMTU) } } func TestParseEmbeddedUDPPacketIPv6WithExtensionHeaders(t *testing.T) { dstIP := net.ParseIP("2001:db8::9") data := make([]byte, 56) data[0] = 6 << 4 data[6] = 0 copy(data[24:40], dstIP.To16()) data[40] = 17 data[41] = 0 binary.BigEndian.PutUint16(data[48:50], 40001) binary.BigEndian.PutUint16(data[50:52], 33494) packet, ok := parseEmbeddedUDPPacket(data, 6) if !ok { t.Fatal("expected IPv6 UDP packet behind extension header to match") } if !packet.dstIP.Equal(dstIP) { t.Fatalf("dst ip = %v, want %v", packet.dstIP, dstIP) } if packet.srcPort != 40001 || packet.dstPort != 33494 { t.Fatalf("unexpected ports: %+v", packet) } } func mustSerializeIPv4UDP(t *testing.T, srcIP, dstIP net.IP, srcPort, dstPort int, payload []byte) []byte { t.Helper() ip := &layers.IPv4{ Version: 4, TTL: 1, SrcIP: srcIP.To4(), DstIP: dstIP.To4(), Protocol: layers.IPProtocolUDP, } udp := &layers.UDP{ SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort), } if err := udp.SetNetworkLayerForChecksum(ip); err != nil { t.Fatalf("set checksum: %v", err) } return mustSerializeLayers(t, ip, udp, gopacket.Payload(payload)) } func mustSerializeIPv6UDP(t *testing.T, srcIP, dstIP net.IP, srcPort, dstPort int, payload []byte) []byte { t.Helper() ip := &layers.IPv6{ Version: 6, HopLimit: 1, SrcIP: srcIP, DstIP: dstIP, NextHeader: layers.IPProtocolUDP, } udp := &layers.UDP{ SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort), } if err := udp.SetNetworkLayerForChecksum(ip); err != nil { t.Fatalf("set checksum: %v", err) } return mustSerializeLayers(t, ip, udp, gopacket.Payload(payload)) } func mustSerializeLayers(t *testing.T, layersToSerialize ...gopacket.SerializableLayer) []byte { t.Helper() buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} if err := gopacket.SerializeLayers(buf, opts, layersToSerialize...); err != nil { t.Fatalf("serialize layers: %v", err) } return buf.Bytes() } ================================================ FILE: trace/mtu/metadata.go ================================================ package mtu import ( "context" "reflect" "strings" "time" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/util" ) const mtuTimeoutGeoSource = "timeout" var mtuLookupAddr = util.LookupAddrWithContext type mtuGeoLookupResult struct { geo *ipgeo.IPGeoData err error } func enrichHopMetadata(ctx context.Context, cfg Config, hop Hop) (Hop, bool) { if !shouldFetchHopMetadata(cfg, hop) { return hop, false } updated := hop if ctx != nil && ctx.Err() != nil { return updated, false } ipStr := strings.TrimSpace(hop.IP) geoCh := startMTUGeoLookup(cfg, ipStr) rDNSStarted := cfg.RDNS && updated.Hostname == "" var rDNSCh <-chan []string if rDNSStarted { rDNSCh = startMTUPTRLookup(ctx, ipStr) } updated = waitForMTUGeoAndPTR(ctx, cfg, updated, geoCh, rDNSStarted, rDNSCh) return updated, !reflect.DeepEqual(updated, hop) } func shouldFetchHopMetadata(cfg Config, hop Hop) bool { if strings.TrimSpace(hop.IP) == "" || hop.Event == EventTimeout { return false } return cfg.IPGeoSource != nil || cfg.RDNS } func startMTUPTRLookup(ctx context.Context, ipStr string) <-chan []string { ch := make(chan []string, 1) go func() { ptrs, err := mtuLookupAddr(ctx, ipStr) if err != nil { ch <- nil return } ch <- ptrs }() return ch } func applyMTUPTRResult(h *Hop, ptrs []string) { if len(ptrs) == 0 { return } h.Hostname = strings.TrimSuffix(strings.TrimSpace(ptrs[0]), ".") } func startMTUGeoLookup(cfg Config, ipStr string) <-chan mtuGeoLookupResult { if cfg.IPGeoSource == nil { if cfg.RDNS { return nil } ch := make(chan mtuGeoLookupResult, 1) ch <- mtuGeoLookupResult{} return ch } ch := make(chan mtuGeoLookupResult, 1) go func() { if geo, ok := ipgeo.Filter(ipStr); ok { ch <- mtuGeoLookupResult{geo: normalizeMTUGeoData(geo)} return } geo, err := cfg.IPGeoSource(ipStr, cfg.Timeout, cfg.Lang, false) if err != nil { ch <- mtuGeoLookupResult{geo: mtuTimeoutGeo(), err: err} return } ch <- mtuGeoLookupResult{geo: normalizeMTUGeoData(geo)} }() return ch } func waitForMTUGeoAndPTR(ctx context.Context, cfg Config, hop Hop, geoCh <-chan mtuGeoLookupResult, rDNSStarted bool, rDNSCh <-chan []string) Hop { applyGeo := func(res mtuGeoLookupResult) { if res.geo != nil { hop.Geo = res.geo } } if cfg.AlwaysWaitRDNS { if rDNSStarted { select { case ptrs := <-rDNSCh: applyMTUPTRResult(&hop, ptrs) case <-time.After(time.Second): case <-ctxDoneChan(ctx): } } if geoCh != nil { select { case res := <-geoCh: applyGeo(res) case <-ctxDoneChan(ctx): } } return hop } if rDNSStarted { if geoCh == nil { select { case ptrs := <-rDNSCh: applyMTUPTRResult(&hop, ptrs) case <-ctxDoneChan(ctx): } return hop } select { case res := <-geoCh: applyGeo(res) return hop case ptrs := <-rDNSCh: applyMTUPTRResult(&hop, ptrs) select { case res := <-geoCh: applyGeo(res) case <-ctxDoneChan(ctx): } return hop case <-ctxDoneChan(ctx): return hop } } if geoCh != nil { select { case res := <-geoCh: applyGeo(res) case <-ctxDoneChan(ctx): } } return hop } func ctxDoneChan(ctx context.Context) <-chan struct{} { if ctx == nil { return nil } return ctx.Done() } func normalizeMTUGeoData(geo *ipgeo.IPGeoData) *ipgeo.IPGeoData { if geo == nil { return nil } if geo.Source == mtuTimeoutGeoSource { return geo } if geo.Asnumber == "" && geo.Country == "" && geo.CountryEn == "" && geo.Prov == "" && geo.ProvEn == "" && geo.City == "" && geo.CityEn == "" && geo.District == "" && geo.Owner == "" && geo.Isp == "" && geo.Domain == "" && geo.Whois == "" && geo.Lat == 0 && geo.Lng == 0 && geo.Prefix == "" && len(geo.Router) == 0 && geo.Source == "" { return nil } return geo } func mtuTimeoutGeo() *ipgeo.IPGeoData { return &ipgeo.IPGeoData{ Country: "网络故障", CountryEn: "Network Error", Source: mtuTimeoutGeoSource, } } ================================================ FILE: trace/mtu/metadata_test.go ================================================ package mtu import ( "context" "errors" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" ) func TestEnrichHopMetadataGeoSuccess(t *testing.T) { cfg := Config{ IPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) { return &ipgeo.IPGeoData{ Asnumber: "13335", Country: "中国香港", CountryEn: "Hong Kong", Owner: "Cloudflare", }, nil }, Lang: "cn", } hop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "1.1.1.1"}) if !changed { t.Fatal("expected hop metadata to change") } if hop.Geo == nil || hop.Geo.Asnumber != "13335" { t.Fatalf("unexpected geo: %+v", hop.Geo) } } func TestEnrichHopMetadataDisableGeoIPReturnsNoGeo(t *testing.T) { cfg := Config{ IPGeoSource: ipgeo.GetSource("disable-geoip"), } hop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "1.1.1.1"}) if changed { t.Fatalf("expected no metadata change, got %+v", hop) } if hop.Geo != nil { t.Fatalf("expected nil geo, got %+v", hop.Geo) } } func TestEnrichHopMetadataRDNSOnly(t *testing.T) { origLookup := mtuLookupAddr mtuLookupAddr = func(context.Context, string) ([]string, error) { return []string{"one.one.one.one."}, nil } defer func() { mtuLookupAddr = origLookup }() cfg := Config{ RDNS: true, AlwaysWaitRDNS: true, } hop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "1.1.1.1"}) if !changed { t.Fatal("expected hostname metadata change") } if hop.Hostname != "one.one.one.one" { t.Fatalf("hostname = %q, want %q", hop.Hostname, "one.one.one.one") } } func TestEnrichHopMetadataRDNSOnlyWithoutAlwaysWaitStillSetsHostname(t *testing.T) { origLookup := mtuLookupAddr mtuLookupAddr = func(context.Context, string) ([]string, error) { return []string{"resolver.example.com."}, nil } defer func() { mtuLookupAddr = origLookup }() cfg := Config{ RDNS: true, } hop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "1.1.1.1"}) if !changed { t.Fatal("expected hostname metadata change") } if hop.Hostname != "resolver.example.com" { t.Fatalf("hostname = %q, want %q", hop.Hostname, "resolver.example.com") } } func TestEnrichHopMetadataAlwaysWaitRDNSWaitsForPTR(t *testing.T) { origLookup := mtuLookupAddr mtuLookupAddr = func(context.Context, string) ([]string, error) { time.Sleep(20 * time.Millisecond) return []string{"resolver.example.com."}, nil } defer func() { mtuLookupAddr = origLookup }() baseCfg := Config{ RDNS: true, IPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) { return &ipgeo.IPGeoData{CountryEn: "US"}, nil }, } hopNoWait, _ := enrichHopMetadata(context.Background(), baseCfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "8.8.8.8"}) if hopNoWait.Hostname != "" { t.Fatalf("expected no hostname without AlwaysWaitRDNS, got %q", hopNoWait.Hostname) } baseCfg.AlwaysWaitRDNS = true hopWait, _ := enrichHopMetadata(context.Background(), baseCfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "8.8.8.8"}) if hopWait.Hostname != "resolver.example.com" { t.Fatalf("hostname = %q, want %q", hopWait.Hostname, "resolver.example.com") } if hopWait.Geo == nil || hopWait.Geo.CountryEn != "US" { t.Fatalf("unexpected geo with AlwaysWaitRDNS: %+v", hopWait.Geo) } } func TestEnrichHopMetadataGeoTimeout(t *testing.T) { cfg := Config{ IPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) { return nil, errors.New("boom") }, } hop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "1.1.1.1"}) if !changed { t.Fatal("expected timeout geo metadata change") } if hop.Geo == nil || hop.Geo.Source != mtuTimeoutGeoSource { t.Fatalf("unexpected timeout geo: %+v", hop.Geo) } } func TestEnrichHopMetadataCancelStopsWaitingForPTR(t *testing.T) { origLookup := mtuLookupAddr blocked := make(chan struct{}) mtuLookupAddr = func(ctx context.Context, ip string) ([]string, error) { close(blocked) <-ctx.Done() return nil, ctx.Err() } defer func() { mtuLookupAddr = origLookup }() ctx, cancel := context.WithCancel(context.Background()) cfg := Config{ RDNS: true, IPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) { time.Sleep(200 * time.Millisecond) return &ipgeo.IPGeoData{CountryEn: "US"}, nil }, } done := make(chan struct{}) var ( hop Hop changed bool ) go func() { hop, changed = enrichHopMetadata(ctx, cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: "1.1.1.1"}) close(done) }() <-blocked cancel() select { case <-done: case <-time.After(100 * time.Millisecond): t.Fatal("enrichHopMetadata did not stop promptly after cancel") } if changed { t.Fatalf("expected no metadata change after cancel, got %+v", hop) } } ================================================ FILE: trace/mtu/runner.go ================================================ package mtu import ( "context" "errors" "fmt" "time" "github.com/nxtrace/NTrace-core/util" ) type prober interface { Probe(ctx context.Context, plan probePlan) (probeResponse, error) Close() error } type probePlan struct { TTL int Token uint32 PayloadSize int Timeout time.Duration } type localMTUError struct { MTU int } func (e *localMTUError) Error() string { if e == nil { return "local pmtu update" } if e.MTU > 0 { return fmt.Sprintf("local pmtu update: %d", e.MTU) } return "local pmtu update" } func Run(ctx context.Context, cfg Config) (*Result, error) { return RunStream(ctx, cfg, nil) } func RunStream(ctx context.Context, cfg Config, sink StreamSink) (*Result, error) { cfg, err := normalizeConfig(cfg) if err != nil { return nil, err } p, err := newSocketProber(cfg) if err != nil { return nil, err } defer p.Close() return runStreamWithProber(ctx, cfg, p, sink) } func runWithProber(ctx context.Context, cfg Config, p prober) (*Result, error) { return runStreamWithProber(ctx, cfg, p, nil) } func runStreamWithProber(ctx context.Context, cfg Config, p prober, sink StreamSink) (*Result, error) { cfg, err := normalizeConfig(cfg) if err != nil { return nil, err } startMTU := initialPathMTU(cfg) probeMTU := initialProbeMTU(cfg.ipVersion()) res := &Result{ Target: cfg.Target, ResolvedIP: cfg.DstIP.String(), Protocol: "udp", IPVersion: cfg.ipVersion(), StartMTU: startMTU, ProbeSize: probeMTU, PathMTU: startMTU, Hops: make([]Hop, 0, cfg.MaxHops-cfg.BeginHop+1), } var token uint32 = 1 for ttl := cfg.BeginHop; ttl <= cfg.MaxHops; ttl++ { emitStreamEvent(sink, StreamEventTTLStart, res, Hop{TTL: ttl}) var hop Hop gotHop := false ttlPMTU := 0 ttlSawRemote := false for attempt := 0; attempt < cfg.Queries; { payloadSize := payloadSizeForMTU(probeMTU, res.IPVersion) resp, err := p.Probe(ctx, probePlan{ TTL: ttl, Token: token, PayloadSize: payloadSize, Timeout: cfg.Timeout, }) token++ if err != nil { var mtuErr *localMTUError if errors.As(err, &mtuErr) { reportedMTU := mtuErr.MTU if reportedMTU <= 0 { reportedMTU = res.PathMTU } nextMTU, ok := nextLocalProbeMTU(probeMTU, reportedMTU, res.IPVersion) if !ok { return nil, err } if ttl == cfg.BeginHop && probeMTU > res.StartMTU && nextMTU == res.StartMTU { ttlPMTU = candidatePathMTU(ttlPMTU, nextMTU) } probeMTU = nextMTU res.PathMTU = candidatePathMTU(res.PathMTU, nextMTU) continue } return nil, err } attempt++ if resp.Event == EventTimeout { continue } hop = buildHop(cfg, ttl, resp) if resp.Event == EventFragNeeded || resp.Event == EventPacketTooBig { ttlSawRemote = true ttlPMTU = candidatePathMTU(ttlPMTU, hop.PMTU) probeMTU = candidatePathMTU(probeMTU, hop.PMTU) res.PathMTU = candidatePathMTU(res.PathMTU, hop.PMTU) hop.PMTU = ttlPMTU emitStreamEvent(sink, StreamEventTTLUpdate, res, hop) gotHop = true continue } if ttlPMTU > 0 { hop.PMTU = ttlPMTU } else if ttl == 1 && res.ProbeSize > res.StartMTU && res.StartMTU > 0 && res.PathMTU == res.StartMTU { hop.PMTU = res.StartMTU } emitStreamEvent(sink, StreamEventTTLUpdate, res, hop) gotHop = true break } if !gotHop { hop = Hop{TTL: ttl, Event: EventTimeout} emitStreamEvent(sink, StreamEventTTLUpdate, res, hop) } else if ttlSawRemote && hop.PMTU == 0 { hop.PMTU = ttlPMTU } if updatedHop, changed := enrichHopMetadata(ctx, cfg, hop); changed { hop = updatedHop emitStreamEvent(sink, StreamEventTTLUpdate, res, hop) } res.Hops = append(res.Hops, hop) emitStreamEvent(sink, StreamEventTTLFinal, res, hop) if hop.Event == EventDestination { break } if ttl < cfg.MaxHops && cfg.TTLInterval > 0 { if err := sleepContext(ctx, cfg.TTLInterval); err != nil { return nil, err } } } res.PathMTU = candidatePathMTU(res.StartMTU, res.PathMTU) emitStreamEvent(sink, StreamEventDone, res, Hop{}) return res, nil } func normalizeConfig(cfg Config) (Config, error) { if cfg.DstIP == nil { return cfg, errors.New("destination IP is required") } if cfg.ipVersion() == 0 { return cfg, errors.New("destination IP is invalid") } if cfg.Target == "" { cfg.Target = cfg.DstIP.String() } if cfg.BeginHop < 1 { cfg.BeginHop = 1 } if cfg.MaxHops < cfg.BeginHop { return cfg, fmt.Errorf("max hops %d is smaller than first hop %d", cfg.MaxHops, cfg.BeginHop) } if cfg.Queries < 1 { cfg.Queries = 1 } if cfg.Timeout <= 0 { cfg.Timeout = time.Second } if cfg.DstPort == 0 { cfg.DstPort = 33494 } if cfg.SrcIP == nil { return cfg, errors.New("source IP is required") } if (cfg.SrcIP.To4() == nil) != (cfg.DstIP.To4() == nil) { return cfg, errors.New("source and destination IP address families do not match") } return cfg, nil } func (cfg Config) ipVersion() int { if util.IsIPv6(cfg.DstIP) { return 6 } if cfg.DstIP.To4() != nil { return 4 } return 0 } func initialPathMTU(cfg Config) int { if mtu := util.GetMTUByIPForDevice(cfg.SrcIP, cfg.SourceDevice); mtu > 0 { return mtu } if cfg.ipVersion() == 6 { return 1280 } return 1500 } func initialProbeMTU(ipVersion int) int { if ipVersion == 6 { return 65000 } return 65000 } func payloadSizeForMTU(pathMTU, ipVersion int) int { overhead := 28 if ipVersion == 6 { overhead = 48 } if payload := pathMTU - overhead; payload > probePayloadMinLen { return payload } return probePayloadMinLen } func minProbeMTU(ipVersion int) int { if ipVersion == 6 { return 48 + probePayloadMinLen } return 28 + probePayloadMinLen } func nextLocalProbeMTU(currentProbeMTU, reportedMTU, ipVersion int) (int, bool) { nextMTU := candidatePathMTU(currentProbeMTU, reportedMTU) if nextMTU < currentProbeMTU { return nextMTU, true } // Some platforms report EMSGSIZE before exposing a smaller socket MTU. if currentProbeMTU <= minProbeMTU(ipVersion) { return 0, false } return currentProbeMTU - 1, true } func candidatePathMTU(current, discovered int) int { if discovered <= 0 { return current } if current == 0 || discovered < current { return discovered } return current } func buildHop(cfg Config, ttl int, resp probeResponse) Hop { hop := Hop{ TTL: ttl, Event: resp.Event, PMTU: resp.PMTU, } if resp.IP != nil { hop.IP = resp.IP.String() } if resp.RTT > 0 { hop.RTTMs = float64(resp.RTT) / float64(time.Millisecond) } return hop } func sleepContext(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func emitStreamEvent(sink StreamSink, kind StreamEventKind, res *Result, hop Hop) { if sink == nil || res == nil { return } if hop.TTL == 0 && kind != StreamEventDone { hop.TTL = 0 } sink(StreamEvent{ Kind: kind, TTL: hop.TTL, Hop: hop, Target: res.Target, ResolvedIP: res.ResolvedIP, Protocol: res.Protocol, IPVersion: res.IPVersion, StartMTU: res.StartMTU, ProbeSize: res.ProbeSize, PathMTU: res.PathMTU, }) } ================================================ FILE: trace/mtu/runner_test.go ================================================ package mtu import ( "context" "errors" "net" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" ) type scriptedStep struct { response probeResponse err error } type scriptedProber struct { steps []scriptedStep plans []probePlan } func (p *scriptedProber) Probe(_ context.Context, plan probePlan) (probeResponse, error) { p.plans = append(p.plans, plan) if len(p.steps) == 0 { return probeResponse{}, errors.New("unexpected probe") } step := p.steps[0] p.steps = p.steps[1:] return step.response, step.err } func (p *scriptedProber) Close() error { return nil } func TestRunWithProberShrinksPMTUAndRetriesSameTTL(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 3, Queries: 2, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("192.0.2.1"), RTT: 12 * time.Millisecond}}, {response: probeResponse{Event: EventFragNeeded, IP: net.ParseIP("198.51.100.1"), RTT: 14 * time.Millisecond, PMTU: 1400}}, {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("198.51.100.1"), RTT: 15 * time.Millisecond}}, {response: probeResponse{Event: EventDestination, IP: cfg.DstIP, RTT: 18 * time.Millisecond}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if res.PathMTU != 1400 { t.Fatalf("path mtu = %d, want 1400", res.PathMTU) } if res.ProbeSize != 65000 { t.Fatalf("probe size = %d, want 65000", res.ProbeSize) } if len(res.Hops) != 3 { t.Fatalf("hop count = %d, want 3", len(res.Hops)) } if got := res.Hops[1].PMTU; got != 1400 { t.Fatalf("ttl 2 pmtu = %d, want 1400", got) } if got := prober.plans[0].PayloadSize; got != 64972 { t.Fatalf("initial payload size = %d, want 64972", got) } if got := prober.plans[2].PayloadSize; got != 1372 { t.Fatalf("payload size after local mtu shrink = %d, want 1372", got) } } func TestRunWithProberKeepsLocalPMTUOffHopOutput(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 1, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {err: &localMTUError{MTU: 1400}}, {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("192.0.2.1"), RTT: 11 * time.Millisecond}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if res.PathMTU != 1400 { t.Fatalf("path mtu = %d, want 1400", res.PathMTU) } if got := res.Hops[0].PMTU; got != 0 { t.Fatalf("local pmtu should not be attributed to hop, got %d", got) } if got := prober.plans[1].PayloadSize; got != 1372 { t.Fatalf("payload size after local mtu shrink = %d, want 1372", got) } } func TestRunWithProberAnnotatesFirstHopWithLocalStartMTU(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 1, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {err: &localMTUError{MTU: 1500}}, {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("192.0.2.1"), RTT: 11 * time.Millisecond}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if len(res.Hops) != 1 { t.Fatalf("hop count = %d, want 1", len(res.Hops)) } if got := res.Hops[0].PMTU; got != 1500 { t.Fatalf("first hop pmtu = %d, want 1500", got) } } func TestRunWithProberAnnotatesFirstHopWithStartMTUWithoutLocalEvent(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 1, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("192.0.2.1"), RTT: 11 * time.Millisecond}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if len(res.Hops) != 1 { t.Fatalf("hop count = %d, want 1", len(res.Hops)) } if got := res.Hops[0].PMTU; got != 1500 { t.Fatalf("first hop pmtu = %d, want 1500", got) } } func TestRunWithProberStopsTTLAfterFirstNonTimeout(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 3, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("1.1.1.1"), RTT: 10 * time.Millisecond}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if len(prober.plans) != 1 { t.Fatalf("probe count = %d, want 1", len(prober.plans)) } if len(res.Hops) != 1 || res.Hops[0].Event != EventTimeExceeded { t.Fatalf("unexpected hops: %+v", res.Hops) } } func TestRunWithProberWritesTimeoutAfterExhaustingQueries(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 2, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {response: probeResponse{Event: EventTimeout}}, {response: probeResponse{Event: EventTimeout}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if len(prober.plans) != 2 { t.Fatalf("probe count = %d, want 2", len(prober.plans)) } if len(res.Hops) != 1 || res.Hops[0].Event != EventTimeout { t.Fatalf("unexpected timeout hop: %+v", res.Hops) } } func TestRunWithProberTimeoutHopDoesNotExposeLocalPMTU(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 1, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {err: &localMTUError{MTU: 1400}}, {response: probeResponse{Event: EventTimeout}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if len(res.Hops) != 1 { t.Fatalf("hop count = %d, want 1", len(res.Hops)) } if got := res.Hops[0].PMTU; got != 0 { t.Fatalf("timeout hop pmtu = %d, want 0", got) } } func TestNormalizeConfigRejectsSourceDestinationFamilyMismatch(t *testing.T) { _, err := normalizeConfig(Config{ DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("2001:db8::1"), BeginHop: 1, MaxHops: 1, }) if err == nil || err.Error() != "source and destination IP address families do not match" { t.Fatalf("err = %v, want family mismatch error", err) } } func TestRunWithProberFallbackShrinksAfterRepeatedLocalMTUErrors(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 1, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {err: &localMTUError{}}, {err: &localMTUError{}}, {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("192.0.2.1"), RTT: 11 * time.Millisecond}}, }, } res, err := runWithProber(context.Background(), cfg, prober) if err != nil { t.Fatalf("runWithProber returned error: %v", err) } if got := len(prober.plans); got != 3 { t.Fatalf("probe count = %d, want 3", got) } if got := prober.plans[0].PayloadSize; got != 64972 { t.Fatalf("initial payload size = %d, want 64972", got) } if got := prober.plans[1].PayloadSize; got != 1472 { t.Fatalf("payload size after first local mtu shrink = %d, want 1472", got) } if got := prober.plans[2].PayloadSize; got != 1471 { t.Fatalf("payload size after fallback local mtu shrink = %d, want 1471", got) } if got := res.PathMTU; got != 1499 { t.Fatalf("path mtu = %d, want 1499", got) } if len(res.Hops) != 1 || res.Hops[0].Event != EventTimeExceeded { t.Fatalf("unexpected hops: %+v", res.Hops) } if got := res.Hops[0].PMTU; got != 1500 { t.Fatalf("first hop pmtu = %d, want 1500", got) } } func TestRunStreamWithProberEmitsOrderedEvents(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 2, Queries: 2, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("192.0.2.1"), RTT: 10 * time.Millisecond}}, {response: probeResponse{Event: EventFragNeeded, IP: net.ParseIP("198.51.100.1"), RTT: 12 * time.Millisecond, PMTU: 1400}}, {response: probeResponse{Event: EventDestination, IP: cfg.DstIP, RTT: 15 * time.Millisecond}}, }, } var events []StreamEvent res, err := runStreamWithProber(context.Background(), cfg, prober, func(event StreamEvent) { events = append(events, event) }) if err != nil { t.Fatalf("runStreamWithProber returned error: %v", err) } if res.PathMTU != 1400 { t.Fatalf("path mtu = %d, want 1400", res.PathMTU) } var gotKinds []StreamEventKind for _, event := range events { gotKinds = append(gotKinds, event.Kind) } wantKinds := []StreamEventKind{ StreamEventTTLStart, StreamEventTTLUpdate, StreamEventTTLFinal, StreamEventTTLStart, StreamEventTTLUpdate, StreamEventTTLUpdate, StreamEventTTLFinal, StreamEventDone, } if len(gotKinds) != len(wantKinds) { t.Fatalf("event count = %d, want %d (%v)", len(gotKinds), len(wantKinds), gotKinds) } for i, want := range wantKinds { if gotKinds[i] != want { t.Fatalf("event[%d] kind = %q, want %q", i, gotKinds[i], want) } } if got := events[6].Hop.PMTU; got != 1400 { t.Fatalf("final ttl2 pmtu = %d, want 1400", got) } if got := events[len(events)-1].PathMTU; got != 1400 { t.Fatalf("done path mtu = %d, want 1400", got) } } func TestRunStreamWithProberEmitsTimeoutFinalEvent(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 2, Timeout: time.Second, TTLInterval: 0, } prober := &scriptedProber{ steps: []scriptedStep{ {response: probeResponse{Event: EventTimeout}}, {response: probeResponse{Event: EventTimeout}}, }, } var events []StreamEvent _, err := runStreamWithProber(context.Background(), cfg, prober, func(event StreamEvent) { events = append(events, event) }) if err != nil { t.Fatalf("runStreamWithProber returned error: %v", err) } if len(events) != 4 { t.Fatalf("event count = %d, want 4", len(events)) } if events[0].Kind != StreamEventTTLStart || events[1].Kind != StreamEventTTLUpdate || events[2].Kind != StreamEventTTLFinal || events[3].Kind != StreamEventDone { t.Fatalf("unexpected event sequence: %+v", events) } if got := events[2].Hop.Event; got != EventTimeout { t.Fatalf("final timeout event = %q, want %q", got, EventTimeout) } } func TestRunStreamWithProberEmitsGeoUpdateBeforeFinal(t *testing.T) { cfg := Config{ Target: "example.com", DstIP: net.ParseIP("203.0.113.9"), SrcIP: net.ParseIP("192.0.2.10"), DstPort: 33494, BeginHop: 1, MaxHops: 1, Queries: 1, Timeout: time.Second, TTLInterval: 0, IPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) { return &ipgeo.IPGeoData{Asnumber: "13335", CountryEn: "Hong Kong", Owner: "Cloudflare"}, nil }, } var events []StreamEvent prober := &scriptedProber{ steps: []scriptedStep{ {response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP("1.1.1.1"), RTT: 10 * time.Millisecond}}, }, } res, err := runStreamWithProber(context.Background(), cfg, prober, func(event StreamEvent) { events = append(events, event) }) if err != nil { t.Fatalf("runStreamWithProber returned error: %v", err) } if len(res.Hops) != 1 || res.Hops[0].Geo == nil || res.Hops[0].Geo.Asnumber != "13335" { t.Fatalf("unexpected result geo: %+v", res.Hops) } if len(events) != 5 { t.Fatalf("event count = %d, want 5", len(events)) } wantKinds := []StreamEventKind{ StreamEventTTLStart, StreamEventTTLUpdate, StreamEventTTLUpdate, StreamEventTTLFinal, StreamEventDone, } for i, want := range wantKinds { if events[i].Kind != want { t.Fatalf("event[%d] kind = %q, want %q", i, events[i].Kind, want) } } if events[1].Hop.Geo != nil { t.Fatalf("expected first update without geo, got %+v", events[1].Hop.Geo) } if events[2].Hop.Geo == nil || events[2].Hop.Geo.Asnumber != "13335" { t.Fatalf("expected second update with geo, got %+v", events[2].Hop.Geo) } if events[3].Hop.Geo == nil || events[3].Hop.Geo.Asnumber != "13335" { t.Fatalf("expected final event with geo, got %+v", events[3].Hop.Geo) } } func TestCandidatePathMTUNeverIncreases(t *testing.T) { if got := candidatePathMTU(1400, 1500); got != 1400 { t.Fatalf("candidatePathMTU should not increase, got %d", got) } } ================================================ FILE: trace/mtu/socket_prober.go ================================================ package mtu import ( "context" "errors" "net" "sync" "time" traceinternal "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" ) type socketProber struct { ipVersion int dstIP net.IP dstPort int srcPort int udp *net.UDPConn icmp net.PacketConn udp4 *ipv4.PacketConn udp6 *ipv6.PacketConn sendMu sync.Mutex } var ErrWinDivertUnavailable = errors.New("windivert capture unavailable") func newSocketProber(cfg Config) (*socketProber, error) { network := "udp4" icmpNetwork := "ip4:icmp" if cfg.ipVersion() == 6 { network = "udp6" icmpNetwork = "ip6:ipv6-icmp" } localAddr := &net.UDPAddr{IP: cfg.SrcIP, Port: cfg.SrcPort} udpConn, err := net.ListenUDP(network, localAddr) if err != nil { return nil, err } if err := configurePMTUSocket(udpConn, cfg.ipVersion()); err != nil { udpConn.Close() return nil, err } icmpConn, err := traceinternal.ListenPacket(icmpNetwork, cfg.SrcIP.String()) if err != nil { udpConn.Close() return nil, err } prober := &socketProber{ ipVersion: cfg.ipVersion(), dstIP: append(net.IP(nil), cfg.DstIP...), dstPort: cfg.DstPort, udp: udpConn, icmp: icmpConn, } if addr, ok := udpConn.LocalAddr().(*net.UDPAddr); ok && addr != nil { prober.srcPort = addr.Port } if prober.ipVersion == 6 { prober.udp6 = ipv6.NewPacketConn(udpConn) } else { prober.udp4 = ipv4.NewPacketConn(udpConn) } return prober, nil } func (p *socketProber) Close() error { if p == nil { return nil } if p.icmp != nil { _ = p.icmp.Close() } if p.udp != nil { return p.udp.Close() } return nil } func (p *socketProber) Probe(ctx context.Context, plan probePlan) (probeResponse, error) { if err := ctx.Err(); err != nil { return probeResponse{}, err } dstPort := probeDstPort(p.dstPort, plan.Token) payload := buildProbePayload(plan.PayloadSize) captureDeadline := deadlineFromStart(ctx, time.Now(), plan.Timeout) capture, err := p.beginICMPResponseCapture(ctx, captureDeadline) if err != nil { if !errors.Is(err, ErrWinDivertUnavailable) { return probeResponse{}, err } } if capture != nil { defer capture.Close() } startSend := time.Now() if err := p.send(plan.TTL, payload, dstPort); err != nil { if isSendSizeErr(err) { return probeResponse{}, &localMTUError{MTU: socketPathMTU(p.udp, p.ipVersion)} } return probeResponse{}, err } buf := make([]byte, 4096) deadline := deadlineFromStart(ctx, startSend, plan.Timeout) resp, err := p.readICMPResponse(ctx, capture, deadline, dstPort, buf) if err != nil { return probeResponse{}, err } resp.RTT = time.Since(startSend) return resp, nil } func (p *socketProber) send(ttl int, payload []byte, dstPort int) error { p.sendMu.Lock() defer p.sendMu.Unlock() if p.ipVersion == 6 { if err := p.udp6.SetHopLimit(ttl); err != nil { return err } } else { if err := p.udp4.SetTTL(ttl); err != nil { return err } } _, err := p.udp.WriteToUDP(payload, &net.UDPAddr{IP: p.dstIP, Port: dstPort}) return err } func probeDstPort(base int, token uint32) int { if base <= 0 || base > 65535 { base = 33494 } if token == 0 { token = 1 } maxOffset := 65535 - base if maxOffset <= 0 { return base } offset := int((token - 1) % uint32(maxOffset+1)) return base + offset } func buildWinDivertMTUFilter(ipVersion int, srcIP net.IP) string { if srcIP == nil || srcIP.IsUnspecified() { if ipVersion == 6 { return "inbound and icmpv6" } return "inbound and icmp" } if ipVersion == 6 { return "inbound and icmpv6 and ipv6.DstAddr == " + srcIP.String() } return "inbound and icmp and ip.DstAddr == " + srcIP.String() } type icmpResponseCapture interface { Close() error } func deadlineFromStart(ctx context.Context, start time.Time, timeout time.Duration) time.Time { deadline := start.Add(timeout) if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { return ctxDeadline } return deadline } func (p *socketProber) readICMPResponseFromSocket(ctx context.Context, deadline time.Time, dstPort int, buf []byte) (probeResponse, error) { for { if err := ctx.Err(); err != nil { return probeResponse{}, err } if err := p.icmp.SetReadDeadline(deadline); err != nil { return probeResponse{}, err } n, peer, err := p.icmp.ReadFrom(buf) if err != nil { if ctx.Err() != nil { return probeResponse{}, ctx.Err() } var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return probeResponse{Event: EventTimeout}, nil } if isRecvSizeErr(err) { continue } return probeResponse{}, err } resp, ok := parseICMPProbeResult(p.ipVersion, buf[:n], util.AddrIP(peer), p.dstIP, dstPort, p.srcPort) if !ok { continue } return resp, nil } } ================================================ FILE: trace/mtu/socket_prober_read_default.go ================================================ //go:build !windows package mtu import ( "context" "time" ) func (p *socketProber) beginICMPResponseCapture(context.Context, time.Time) (icmpResponseCapture, error) { return nil, nil } func (p *socketProber) readICMPResponse(ctx context.Context, _ icmpResponseCapture, deadline time.Time, dstPort int, buf []byte) (probeResponse, error) { return p.readICMPResponseFromSocket(ctx, deadline, dstPort, buf) } ================================================ FILE: trace/mtu/socket_prober_read_windows.go ================================================ //go:build windows package mtu import ( "context" "fmt" "net" "sync" "time" "github.com/nxtrace/NTrace-core/util" wd "github.com/xjasonlyu/windivert-go" ) type winDivertCapture struct { ctx context.Context cancel context.CancelFunc handle wd.Handle buf []byte addr wd.Address closeOnce sync.Once } func (c *winDivertCapture) Close() error { if c == nil { return nil } if c.cancel != nil { c.cancel() } var err error c.closeOnce.Do(func() { err = c.handle.Close() }) return err } func (p *socketProber) beginICMPResponseCapture(ctx context.Context, _ time.Time) (icmpResponseCapture, error) { handle, err := wd.Open(winDivertMTUFilter(p.ipVersion, p.udp.LocalAddr()), wd.LayerNetwork, 0, wd.FlagSniff|wd.FlagRecvOnly) if err != nil { return nil, fmt.Errorf("%w: %v", ErrWinDivertUnavailable, err) } probeCtx, cancel := context.WithCancel(ctx) capture := &winDivertCapture{ ctx: probeCtx, cancel: cancel, handle: handle, buf: make([]byte, 65535), closeOnce: sync.Once{}, } go func() { <-probeCtx.Done() _ = capture.Close() }() _ = handle.SetParam(wd.QueueLength, 8192) _ = handle.SetParam(wd.QueueTime, 4000) return capture, nil } func (p *socketProber) readICMPResponse(ctx context.Context, capture icmpResponseCapture, deadline time.Time, dstPort int, buf []byte) (probeResponse, error) { if resp, err, ok := p.readICMPResponseViaWinDivert(ctx, capture, deadline, dstPort); ok { return resp, err } return p.readICMPResponseFromSocket(ctx, deadline, dstPort, buf) } func (p *socketProber) readICMPResponseViaWinDivert(ctx context.Context, capture icmpResponseCapture, deadline time.Time, dstPort int) (probeResponse, error, bool) { winCapture, ok := capture.(*winDivertCapture) if !ok || winCapture == nil { return probeResponse{}, nil, false } readCtx, cancel := context.WithDeadline(winCapture.ctx, deadline) defer cancel() go func() { <-readCtx.Done() _ = winCapture.Close() }() for { if err := readCtx.Err(); err != nil { if ctx.Err() != nil { return probeResponse{}, ctx.Err(), true } return probeResponse{Event: EventTimeout}, nil, true } n, err := winCapture.handle.Recv(winCapture.buf, &winCapture.addr) if err != nil { if ctx.Err() != nil { return probeResponse{}, ctx.Err(), true } if readCtx.Err() != nil { return probeResponse{Event: EventTimeout}, nil, true } return probeResponse{}, err, true } peerIP, icmpMsg, ok := extractWinDivertICMPMessage(p.ipVersion, winCapture.buf[:n]) if !ok { continue } resp, ok := parseICMPProbeResult(p.ipVersion, icmpMsg, peerIP, p.dstIP, dstPort, p.srcPort) if !ok { continue } return resp, nil, true } } func winDivertMTUFilter(ipVersion int, localAddr net.Addr) string { return buildWinDivertMTUFilter(ipVersion, util.AddrIP(localAddr)) } func extractWinDivertICMPMessage(ipVersion int, raw []byte) (net.IP, []byte, bool) { if len(raw) == 0 { return nil, nil, false } icmpMsg, err := util.GetICMPResponsePayload(raw) if err != nil || len(icmpMsg) == 0 { return nil, nil, false } switch ipVersion { case 4: if len(raw) < 20 || raw[0]>>4 != 4 { return nil, nil, false } return append(net.IP(nil), raw[12:16]...), append([]byte(nil), icmpMsg...), true case 6: if len(raw) < 40 || raw[0]>>4 != 6 { return nil, nil, false } return append(net.IP(nil), raw[8:24]...), append([]byte(nil), icmpMsg...), true default: return nil, nil, false } } ================================================ FILE: trace/mtu/socket_prober_test.go ================================================ package mtu import ( "net" "testing" ) func TestProbeDstPortHandlesZeroToken(t *testing.T) { if got := probeDstPort(33494, 0); got != 33494 { t.Fatalf("probeDstPort() = %d, want %d", got, 33494) } } func TestBuildWinDivertMTUFilter(t *testing.T) { tests := []struct { name string ipVersion int srcIP net.IP want string }{ { name: "ipv4 nil source", ipVersion: 4, srcIP: nil, want: "inbound and icmp", }, { name: "ipv4 unspecified source", ipVersion: 4, srcIP: net.IPv4zero, want: "inbound and icmp", }, { name: "ipv4 specified source", ipVersion: 4, srcIP: net.ParseIP("192.0.2.10"), want: "inbound and icmp and ip.DstAddr == 192.0.2.10", }, { name: "ipv6 nil source", ipVersion: 6, srcIP: nil, want: "inbound and icmpv6", }, { name: "ipv6 unspecified source", ipVersion: 6, srcIP: net.IPv6zero, want: "inbound and icmpv6", }, { name: "ipv6 specified source", ipVersion: 6, srcIP: net.ParseIP("2001:db8::10"), want: "inbound and icmpv6 and ipv6.DstAddr == 2001:db8::10", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := buildWinDivertMTUFilter(tc.ipVersion, tc.srcIP); got != tc.want { t.Fatalf("buildWinDivertMTUFilter() = %q, want %q", got, tc.want) } }) } } ================================================ FILE: trace/mtu/socketopts_darwin.go ================================================ //go:build darwin package mtu import ( "errors" "net" "golang.org/x/sys/unix" ) func configurePMTUSocket(conn *net.UDPConn, ipVersion int) error { rawConn, err := conn.SyscallConn() if err != nil { return err } var controlErr error if err := rawConn.Control(func(fd uintptr) { if ipVersion == 6 { controlErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_DONTFRAG, 1) return } controlErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_DONTFRAG, 1) }); err != nil { return err } return controlErr } func socketPathMTU(_ *net.UDPConn, _ int) int { return 0 } func isSendSizeErr(err error) bool { return errors.Is(err, unix.EMSGSIZE) } func isRecvSizeErr(err error) bool { return errors.Is(err, unix.EMSGSIZE) } ================================================ FILE: trace/mtu/socketopts_linux.go ================================================ //go:build linux package mtu import ( "errors" "net" "golang.org/x/sys/unix" ) func configurePMTUSocket(conn *net.UDPConn, ipVersion int) error { rawConn, err := conn.SyscallConn() if err != nil { return err } var controlErr error if err := rawConn.Control(func(fd uintptr) { if ipVersion == 6 { controlErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_MTU_DISCOVER, unix.IPV6_PMTUDISC_PROBE) return } controlErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_MTU_DISCOVER, unix.IP_PMTUDISC_PROBE) }); err != nil { return err } return controlErr } func socketPathMTU(conn *net.UDPConn, ipVersion int) int { rawConn, err := conn.SyscallConn() if err != nil { return 0 } mtu := 0 _ = rawConn.Control(func(fd uintptr) { if ipVersion == 6 { mtu, _ = unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_MTU) return } mtu, _ = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_MTU) }) return mtu } func isSendSizeErr(err error) bool { return errors.Is(err, unix.EMSGSIZE) } func isRecvSizeErr(err error) bool { return errors.Is(err, unix.EMSGSIZE) } ================================================ FILE: trace/mtu/socketopts_stub.go ================================================ //go:build !linux && !darwin && !windows package mtu import "net" func configurePMTUSocket(_ *net.UDPConn, _ int) error { return nil } func socketPathMTU(_ *net.UDPConn, _ int) int { return 0 } func isSendSizeErr(_ error) bool { return false } func isRecvSizeErr(_ error) bool { return false } ================================================ FILE: trace/mtu/socketopts_windows.go ================================================ //go:build windows package mtu import ( "errors" "net" "github.com/nxtrace/NTrace-core/util" "golang.org/x/sys/windows" ) const ( ipDontFragment = 14 ipv6DontFrag = 14 ) func configurePMTUSocket(conn *net.UDPConn, ipVersion int) error { rawConn, err := conn.SyscallConn() if err != nil { return err } var controlErr error if err := rawConn.Control(func(fd uintptr) { if ipVersion == 6 { controlErr = windows.SetsockoptInt( windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_MTU_DISCOVER, windows.IP_PMTUDISC_PROBE, ) if controlErr != nil { controlErr = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, ipv6DontFrag, 1) } return } controlErr = windows.SetsockoptInt( windows.Handle(fd), windows.IPPROTO_IP, windows.IP_MTU_DISCOVER, windows.IP_PMTUDISC_PROBE, ) if controlErr != nil { controlErr = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IP, ipDontFragment, 1) } }); err != nil { return err } return controlErr } func socketPathMTU(conn *net.UDPConn, _ int) int { if conn == nil { return 0 } addr, ok := conn.LocalAddr().(*net.UDPAddr) if !ok || addr == nil || addr.IP == nil { return 0 } return util.GetMTUByIPForDevice(addr.IP, "") } func isSendSizeErr(err error) bool { return errors.Is(err, windows.WSAEMSGSIZE) } func isRecvSizeErr(err error) bool { return errors.Is(err, windows.WSAEMSGSIZE) } ================================================ FILE: trace/mtu/types.go ================================================ package mtu import ( "net" "time" "github.com/nxtrace/NTrace-core/ipgeo" ) type Event string const ( EventTimeExceeded Event = "time_exceeded" EventPacketTooBig Event = "packet_too_big" EventFragNeeded Event = "frag_needed" EventDestination Event = "destination" EventTimeout Event = "timeout" ) type StreamEventKind string const ( StreamEventTTLStart StreamEventKind = "ttl_start" StreamEventTTLUpdate StreamEventKind = "ttl_update" StreamEventTTLFinal StreamEventKind = "ttl_final" StreamEventDone StreamEventKind = "done" ) type Config struct { Target string DstIP net.IP SrcIP net.IP SourceDevice string SrcPort int DstPort int BeginHop int MaxHops int Queries int Timeout time.Duration TTLInterval time.Duration RDNS bool AlwaysWaitRDNS bool IPGeoSource ipgeo.Source Lang string } type Hop struct { TTL int `json:"ttl"` Event Event `json:"event"` IP string `json:"ip,omitempty"` Hostname string `json:"hostname,omitempty"` RTTMs float64 `json:"rtt_ms,omitempty"` PMTU int `json:"pmtu,omitempty"` Geo *ipgeo.IPGeoData `json:"geo,omitempty"` } type Result struct { Target string `json:"target"` ResolvedIP string `json:"resolved_ip"` Protocol string `json:"protocol"` IPVersion int `json:"ip_version"` StartMTU int `json:"start_mtu"` ProbeSize int `json:"probe_size"` PathMTU int `json:"path_mtu"` Hops []Hop `json:"hops"` } type StreamEvent struct { Kind StreamEventKind `json:"kind"` TTL int `json:"ttl,omitempty"` Hop Hop `json:"hop,omitempty"` Target string `json:"target"` ResolvedIP string `json:"resolved_ip"` Protocol string `json:"protocol"` IPVersion int `json:"ip_version"` StartMTU int `json:"start_mtu"` ProbeSize int `json:"probe_size"` PathMTU int `json:"path_mtu"` } type StreamSink func(StreamEvent) ================================================ FILE: trace/packet_size.go ================================================ package trace import ( "fmt" "math/rand" "net" "github.com/nxtrace/NTrace-core/util" ) const ( ipv4HeaderBytes = 20 ipv6HeaderBytes = 40 icmpHeaderBytes = 8 udpHeaderBytes = 8 tcpProbeHeaderBytes = 24 udpV6MinPayload = 2 ) type PacketSizeSpec struct { PayloadSize int Random bool } func DefaultPacketSize(method Method, dstIP net.IP) int { return MinPacketSize(method, dstIP) } func packetSizeIPHeaderBytes(dstIP net.IP) int { if util.IsIPv6(dstIP) { return ipv6HeaderBytes } return ipv4HeaderBytes } func packetSizeProtocolHeaderBytes(method Method) int { switch method { case TCPTrace: return tcpProbeHeaderBytes case UDPTrace: return udpHeaderBytes default: return icmpHeaderBytes } } func packetSizeMinPayload(method Method, dstIP net.IP) int { if method == UDPTrace && util.IsIPv6(dstIP) { return udpV6MinPayload } return 0 } func MinPacketSize(method Method, dstIP net.IP) int { return packetSizeIPHeaderBytes(dstIP) + packetSizeProtocolHeaderBytes(method) + packetSizeMinPayload(method, dstIP) } func NormalizePacketSize(method Method, dstIP net.IP, packetSize int) (PacketSizeSpec, error) { random := packetSize < 0 packetSizeAbs := packetSize if random { packetSizeAbs = -packetSizeAbs } minSize := MinPacketSize(method, dstIP) if packetSizeAbs < minSize { return PacketSizeSpec{}, fmt.Errorf("packet size %d is too small for %s over %s; minimum is %d", packetSize, method, packetSizeFamilyLabel(dstIP), minSize) } payloadSize := packetSizeAbs - packetSizeIPHeaderBytes(dstIP) - packetSizeProtocolHeaderBytes(method) if payloadSize < packetSizeMinPayload(method, dstIP) { return PacketSizeSpec{}, fmt.Errorf("packet size %d is too small for %s over %s; minimum is %d", packetSize, method, packetSizeFamilyLabel(dstIP), minSize) } return PacketSizeSpec{ PayloadSize: payloadSize, Random: random, }, nil } func resolveProbePayloadSize(method Method, dstIP net.IP, maxPayloadSize int, randomPerProbe bool) int { minPayload := packetSizeMinPayload(method, dstIP) if !randomPerProbe || maxPayloadSize <= minPayload { return maxPayloadSize } return minPayload + rand.Intn(maxPayloadSize-minPayload+1) } func packetSizeFamilyLabel(dstIP net.IP) string { if util.IsIPv6(dstIP) { return "IPv6" } return "IPv4" } func FormatPacketSizeLabel(packetSize int) string { if packetSize < 0 { return fmt.Sprintf("random <= %d byte packets", -packetSize) } return fmt.Sprintf("%d byte packets", packetSize) } ================================================ FILE: trace/packet_size_test.go ================================================ package trace import ( "net" "testing" ) func TestNormalizePacketSize(t *testing.T) { tests := []struct { name string method Method ip net.IP packetSize int wantSize int wantRandom bool }{ {name: "icmp4", method: ICMPTrace, ip: net.ParseIP("1.1.1.1"), packetSize: 52, wantSize: 24}, {name: "udp4", method: UDPTrace, ip: net.ParseIP("1.1.1.1"), packetSize: 52, wantSize: 24}, {name: "icmp6", method: ICMPTrace, ip: net.ParseIP("2001:db8::1"), packetSize: 64, wantSize: 16}, {name: "udp6", method: UDPTrace, ip: net.ParseIP("2001:db8::1"), packetSize: 64, wantSize: 16}, {name: "tcp4", method: TCPTrace, ip: net.ParseIP("1.1.1.1"), packetSize: 64, wantSize: 20}, {name: "tcp6-random", method: TCPTrace, ip: net.ParseIP("2001:db8::1"), packetSize: -96, wantSize: 32, wantRandom: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spec, err := NormalizePacketSize(tt.method, tt.ip, tt.packetSize) if err != nil { t.Fatalf("NormalizePacketSize() error = %v", err) } if spec.PayloadSize != tt.wantSize { t.Fatalf("PayloadSize = %d, want %d", spec.PayloadSize, tt.wantSize) } if spec.Random != tt.wantRandom { t.Fatalf("Random = %v, want %v", spec.Random, tt.wantRandom) } }) } } func TestNormalizePacketSizeRejectsTooSmallValues(t *testing.T) { tests := []struct { name string method Method ip net.IP packetSize int }{ {name: "icmp4", method: ICMPTrace, ip: net.ParseIP("1.1.1.1"), packetSize: 27}, {name: "icmp4-zero", method: ICMPTrace, ip: net.ParseIP("1.1.1.1"), packetSize: 0}, {name: "icmp6", method: ICMPTrace, ip: net.ParseIP("2001:db8::1"), packetSize: 47}, {name: "udp6", method: UDPTrace, ip: net.ParseIP("2001:db8::1"), packetSize: 49}, {name: "tcp4", method: TCPTrace, ip: net.ParseIP("1.1.1.1"), packetSize: 43}, {name: "tcp6", method: TCPTrace, ip: net.ParseIP("2001:db8::1"), packetSize: 63}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, err := NormalizePacketSize(tt.method, tt.ip, tt.packetSize); err == nil { t.Fatal("NormalizePacketSize() error = nil, want error") } }) } } func TestMinPacketSize(t *testing.T) { tests := []struct { method Method ip net.IP want int }{ {method: ICMPTrace, ip: net.ParseIP("1.1.1.1"), want: 28}, {method: UDPTrace, ip: net.ParseIP("1.1.1.1"), want: 28}, {method: ICMPTrace, ip: net.ParseIP("2001:db8::1"), want: 48}, {method: UDPTrace, ip: net.ParseIP("2001:db8::1"), want: 50}, {method: TCPTrace, ip: net.ParseIP("1.1.1.1"), want: 44}, {method: TCPTrace, ip: net.ParseIP("2001:db8::1"), want: 64}, } for _, tt := range tests { if got := MinPacketSize(tt.method, tt.ip); got != tt.want { t.Fatalf("MinPacketSize(%s, %v) = %d, want %d", tt.method, tt.ip, got, tt.want) } } } func TestDefaultPacketSizeMatchesMinimum(t *testing.T) { ip := net.ParseIP("2a00:1450:4009:81a::200e") if got := DefaultPacketSize(TCPTrace, ip); got != 64 { t.Fatalf("DefaultPacketSize(TCPTrace, %v) = %d, want 64", ip, got) } } ================================================ FILE: trace/quic.go ================================================ package trace import ( "math/rand" "time" ) // var Packet = []byte{202, 255, 255, 255, 255, 10, 253, 187, 139, 161, 69, 45, 65, 177, 68, 23, 4, 83, 226, 136, 32, 0, 68, 204, 168, 172, 81, 54, 106, 24, 100, 29, 160, 51, 70, 95, 107, 100, 4, 127, 168, 161, 43, 243, 194, 192, 4, 192, 99, 149, 103, 193, 233, 86, 254, 220, 167, 6, 45, 209, 193, 11, 77, 123, 131, 80, 17, 201, 248, 246, 246, 45, 153, 229, 169, 191, 76, 131, 162, 109, 188, 151, 22, 36, 1, 229, 201, 194, 26, 8, 63, 197, 207, 43, 190, 55, 224, 59, 247, 19, 142, 34, 186, 122, 108, 162, 110, 221, 15, 36, 224, 90, 73, 182, 177, 119, 26, 226, 16, 13, 18, 201, 96, 249, 192, 162, 13, 8, 132, 41, 63, 221, 43, 62, 143, 236, 219, 207, 10, 4, 115, 117, 214, 53, 64, 25, 180, 81, 144, 173, 155, 11, 32, 239, 253, 131, 115, 136, 227, 77, 132, 144, 48, 135, 137, 138, 214, 14, 67, 63, 187, 54, 197, 18, 191, 243, 128, 157, 74, 27, 225, 33, 12, 163, 0, 249, 126, 252, 242, 2, 33, 70, 255, 204, 200, 7, 4, 65, 154, 157, 83, 125, 197, 130, 53, 187, 254, 96, 54, 114, 93, 108, 198, 218, 198, 86, 33, 50, 74, 131, 154, 26, 206, 122, 148, 212, 177, 163, 99, 0, 138, 68, 235, 222, 55, 252, 232, 32, 103, 68, 119, 33, 188, 227, 71, 13, 123, 229, 99, 206, 245, 115, 21, 216, 31, 61, 30, 174, 97, 36, 96, 28, 66, 173, 89, 139, 37, 102, 81, 232, 158, 212, 8, 33, 123, 216, 191, 133, 200, 13, 197, 94, 223, 246, 29, 246, 19, 44, 135, 113, 59, 231, 36, 186, 53, 228, 33, 206, 85, 59, 105, 6, 225, 227, 111, 146, 211, 195, 73, 108, 200, 70, 73, 220, 163, 120, 47, 167, 139, 24, 149, 53, 11, 224, 22, 241, 52, 214, 38, 17, 137, 96, 134, 144, 16, 44, 116, 151, 15, 57, 87, 167, 137, 194, 63, 101, 195, 31, 21, 51, 7, 95, 101, 114, 195, 75, 211, 137, 149, 2, 80, 183, 82, 120, 185, 39, 192, 82, 102, 141, 153, 156, 118, 137, 99, 59, 4, 93, 74, 96, 164, 80, 142, 16, 197, 111, 178, 230, 250, 189, 26, 184, 19, 236, 249, 170, 68, 11, 41, 37, 243, 176, 208, 209, 208, 108, 74, 205, 55, 165, 30, 182, 170, 62, 184, 89, 109, 34, 135, 174, 237, 207, 52, 18, 32, 229, 131, 237, 204, 33, 77, 67, 242, 248, 72, 137, 32, 173, 226, 234, 146, 203, 35, 208, 156, 107, 190, 255, 29, 163, 167, 25, 187, 27, 26, 31, 227, 202, 135, 50, 248, 122, 68, 80, 218, 58, 142, 193, 71, 154, 27, 255, 109, 253, 216, 250, 219, 112, 89, 56, 23, 47, 98, 104, 163, 17, 152, 102, 16, 139, 157, 172, 39, 191, 129, 132, 29, 189, 68, 216, 138, 3, 163, 101, 165, 200, 61, 56, 197, 142, 226, 156, 172, 62, 251, 193, 100, 94, 96, 166, 96, 19, 116, 5, 237, 177, 39, 188, 32, 177, 9, 171, 16, 224, 209, 53, 217, 144, 245, 215, 217, 196, 55, 232, 16, 121, 217, 58, 44, 34, 213, 5, 184, 171, 81, 167, 10, 34, 5, 116, 239, 179, 206, 83, 181, 235, 35, 121, 196, 234, 191, 119, 189, 238, 103, 235, 147, 155, 47, 151, 29, 56, 35, 72, 251, 107, 121, 21, 188, 184, 183, 72, 94, 191, 230, 100, 214, 161, 143, 235, 10, 236, 25, 172, 110, 95, 99, 99, 55, 25, 217, 131, 113, 57, 124, 195, 165, 204, 242, 109, 156, 106, 194, 233, 42, 175, 241, 213, 164, 207, 131, 207, 74, 52, 207, 155, 210, 227, 136, 41, 251, 47, 81, 192, 96, 26, 133, 85, 24, 57, 75, 188, 214, 146, 17, 209, 229, 217, 151, 71, 166, 174, 232, 162, 69, 147, 116, 247, 172, 154, 110, 7, 170, 78, 185, 18, 43, 70, 23, 215, 105, 118, 125, 64, 222, 16, 149, 205, 159, 138, 194, 46, 99, 228, 115, 205, 30, 92, 223, 29, 72, 86, 57, 14, 209, 176, 244, 200, 26, 208, 198, 153, 82, 154, 233, 32, 55, 219, 161, 243, 150, 233, 70, 142, 96, 52, 254, 200, 133, 56, 246, 80, 153, 59, 74, 245, 93, 30, 241, 70, 192, 93, 32, 253, 254, 181, 9, 179, 152, 218, 173, 216, 175, 3, 69, 249, 82, 145, 41, 96, 162, 241, 83, 245, 162, 71, 128, 58, 116, 200, 236, 93, 207, 174, 173, 68, 184, 61, 172, 100, 39, 118, 250, 136, 66, 154, 165, 178, 153, 156, 116, 230, 10, 33, 237, 91, 132, 57, 11, 111, 141, 162, 242, 159, 124, 255, 142, 101, 205, 127, 214, 97, 17, 146, 130, 151, 37, 222, 140, 227, 152, 92, 217, 210, 64, 198, 97, 72, 11, 2, 182, 114, 101, 68, 246, 183, 55, 46, 227, 62, 163, 65, 123, 208, 223, 132, 66, 202, 82, 126, 31, 166, 92, 126, 105, 239, 255, 25, 34, 249, 205, 67, 217, 139, 129, 147, 178, 81, 251, 226, 194, 8, 69, 208, 67, 243, 210, 181, 121, 59, 22, 186, 120, 31, 27, 186, 54, 81, 145, 89, 53, 152, 72, 107, 125, 202, 124, 112, 134, 124, 226, 95, 3, 148, 225, 191, 107, 209, 139, 58, 200, 72, 213, 100, 164, 187, 164, 86, 61, 65, 45, 167, 253, 128, 253, 133, 52, 21, 31, 67, 178, 173, 68, 186, 82, 135, 50, 123, 122, 56, 178, 37, 233, 82, 83, 61, 229, 47, 195, 2, 169, 129, 154, 226, 71, 163, 211, 232, 156, 97, 67, 125, 104, 61, 0, 81, 253, 234, 225, 1, 164, 113, 33, 229, 24, 101, 238, 128, 227, 219, 40, 218, 221, 78, 213, 172, 12, 69, 179, 142, 97, 156, 138, 54, 50, 145, 14, 191, 120, 37, 128, 171, 75, 201, 79, 78, 144, 21, 163, 233, 54, 107, 234, 134, 11, 204, 233, 156, 77, 200, 9, 26, 32, 156, 132, 116, 81, 161, 218, 131, 110, 30, 175, 118, 48, 150, 146, 234, 195, 109, 228, 74, 43, 247, 114, 31, 139, 235, 214, 147, 206, 145, 170, 54, 83, 160, 48, 59, 65, 250, 192, 13, 87, 240, 234, 215, 209, 158, 10, 91, 208, 151, 177, 71, 96, 209, 184, 125, 82, 156, 34, 74, 15, 97, 73, 249, 185, 79, 34, 42, 157, 175, 52, 234, 131, 51, 144, 203, 6, 81, 245, 215, 107, 71, 68, 113, 82, 210, 116, 18, 88, 92, 141, 68, 227, 185, 55, 114, 54, 243, 152, 47, 117, 250, 87, 180, 30, 57, 187, 98, 90, 127, 243, 94, 122, 48, 23, 200, 40, 89, 89, 53, 83, 221, 56, 231, 117, 200, 201, 101, 159, 147, 25, 194, 236, 249, 5, 0, 70, 209, 122, 162, 103, 178, 217, 242, 36, 226, 16, 215, 144, 98, 198, 173, 134, 89, 115, 171, 81, 0, 112, 6, 152, 154, 119, 28, 36, 209, 174, 21, 93, 62, 33, 39, 57, 67, 71, 239, 11, 158, 53, 79, 103, 157, 100, 234, 28, 222, 212, 196, 216, 162, 49, 104, 49, 18, 175, 140, 255, 152, 175, 25, 233, 200, 60, 203, 225, 232, 121, 46, 220, 243, 13, 125, 255, 158, 2, 153, 203, 203, 223, 159, 65, 2, 219, 105, 235, 109, 126, 124, 53, 122} //var endPacket = []byte{0, 68, 204, 168, 172, 81, 54, 106, 24, 100, 29, 160, 51, 70, 95, 107, 100, 4, 127, 168, 161, 43, 243, 194, 192, 4, 192, 99, 149, 103, 193, 233, 86, 254, 220, 167, 6, 45, 209, 193, 11, 77, 123, 131, 80, 17, 201, 248, 246, 246, 45, 153, 229, 169, 191, 76, 131, 162, 109, 188, 151, 22, 36, 1, 229, 201, 194, 26, 8, 63, 197, 207, 43, 190, 55, 224, 59, 247, 19, 142, 34, 186, 122, 108, 162, 110, 221, 15, 36, 224, 90, 73, 182, 177, 119, 26, 226, 16, 13, 18, 201, 96, 249, 192, 162, 13, 8, 132, 41, 63, 221, 43, 62, 143, 236, 219, 207, 10, 4, 115, 117, 214, 53, 64, 25, 180, 81, 144, 173, 155, 11, 32, 239, 253, 131, 115, 136, 227, 77, 132, 144, 48, 135, 137, 138, 214, 14, 67, 63, 187, 54, 197, 18, 191, 243, 128, 157, 74, 27, 225, 33, 12, 163, 0, 249, 126, 252, 242, 2, 33, 70, 255, 204, 200, 7, 4, 65, 154, 157, 83, 125, 197, 130, 53, 187, 254, 96, 54, 114, 93, 108, 198, 218, 198, 86, 33, 50, 74, 131, 154, 26, 206, 122, 148, 212, 177, 163, 99, 0, 138, 68, 235, 222, 55, 252, 232, 32, 103, 68, 119, 33, 188, 227, 71, 13, 123, 229, 99, 206, 245, 115, 21, 216, 31, 61, 30, 174, 97, 36, 96, 28, 66, 173, 89, 139, 37, 102, 81, 232, 158, 212, 8, 33, 123, 216, 191, 133, 200, 13, 197, 94, 223, 246, 29, 246, 19, 44, 135, 113, 59, 231, 36, 186, 53, 228, 33, 206, 85, 59, 105, 6, 225, 227, 111, 146, 211, 195, 73, 108, 200, 70, 73, 220, 163, 120, 47, 167, 139, 24, 149, 53, 11, 224, 22, 241, 52, 214, 38, 17, 137, 96, 134, 144, 16, 44, 116, 151, 15, 57, 87, 167, 137, 194, 63, 101, 195, 31, 21, 51, 7, 95, 101, 114, 195, 75, 211, 137, 149, 2, 80, 183, 82, 120, 185, 39, 192, 82, 102, 141, 153, 156, 118, 137, 99, 59, 4, 93, 74, 96, 164, 80, 142, 16, 197, 111, 178, 230, 250, 189, 26, 184, 19, 236, 249, 170, 68, 11, 41, 37, 243, 176, 208, 209, 208, 108, 74, 205, 55, 165, 30, 182, 170, 62, 184, 89, 109, 34, 135, 174, 237, 207, 52, 18, 32, 229, 131, 237, 204, 33, 77, 67, 242, 248, 72, 137, 32, 173, 226, 234, 146, 203, 35, 208, 156, 107, 190, 255, 29, 163, 167, 25, 187, 27, 26, 31, 227, 202, 135, 50, 248, 122, 68, 80, 218, 58, 142, 193, 71, 154, 27, 255, 109, 253, 216, 250, 219, 112, 89, 56, 23, 47, 98, 104, 163, 17, 152, 102, 16, 139, 157, 172, 39, 191, 129, 132, 29, 189, 68, 216, 138, 3, 163, 101, 165, 200, 61, 56, 197, 142, 226, 156, 172, 62, 251, 193, 100, 94, 96, 166, 96, 19, 116, 5, 237, 177, 39, 188, 32, 177, 9, 171, 16, 224, 209, 53, 217, 144, 245, 215, 217, 196, 55, 232, 16, 121, 217, 58, 44, 34, 213, 5, 184, 171, 81, 167, 10, 34, 5, 116, 239, 179, 206, 83, 181, 235, 35, 121, 196, 234, 191, 119, 189, 238, 103, 235, 147, 155, 47, 151, 29, 56, 35, 72, 251, 107, 121, 21, 188, 184, 183, 72, 94, 191, 230, 100, 214, 161, 143, 235, 10, 236, 25, 172, 110, 95, 99, 99, 55, 25, 217, 131, 113, 57, 124, 195, 165, 204, 242, 109, 156, 106, 194, 233, 42, 175, 241, 213, 164, 207, 131, 207, 74, 52, 207, 155, 210, 227, 136, 41, 251, 47, 81, 192, 96, 26, 133, 85, 24, 57, 75, 188, 214, 146, 17, 209, 229, 217, 151, 71, 166, 174, 232, 162, 69, 147, 116, 247, 172, 154, 110, 7, 170, 78, 185, 18, 43, 70, 23, 215, 105, 118, 125, 64, 222, 16, 149, 205, 159, 138, 194, 46, 99, 228, 115, 205, 30, 92, 223, 29, 72, 86, 57, 14, 209, 176, 244, 200, 26, 208, 198, 153, 82, 154, 233, 32, 55, 219, 161, 243, 150, 233, 70, 142, 96, 52, 254, 200, 133, 56, 246, 80, 153, 59, 74, 245, 93, 30, 241, 70, 192, 93, 32, 253, 254, 181, 9, 179, 152, 218, 173, 216, 175, 3, 69, 249, 82, 145, 41, 96, 162, 241, 83, 245, 162, 71, 128, 58, 116, 200, 236, 93, 207, 174, 173, 68, 184, 61, 172, 100, 39, 118, 250, 136, 66, 154, 165, 178, 153, 156, 116, 230, 10, 33, 237, 91, 132, 57, 11, 111, 141, 162, 242, 159, 124, 255, 142, 101, 205, 127, 214, 97, 17, 146, 130, 151, 37, 222, 140, 227, 152, 92, 217, 210, 64, 198, 97, 72, 11, 2, 182, 114, 101, 68, 246, 183, 55, 46, 227, 62, 163, 65, 123, 208, 223, 132, 66, 202, 82, 126, 31, 166, 92, 126, 105, 239, 255, 25, 34, 249, 205, 67, 217, 139, 129, 147, 178, 81, 251, 226, 194, 8, 69, 208, 67, 243, 210, 181, 121, 59, 22, 186, 120, 31, 27, 186, 54, 81, 145, 89, 53, 152, 72, 107, 125, 202, 124, 112, 134, 124, 226, 95, 3, 148, 225, 191, 107, 209, 139, 58, 200, 72, 213, 100, 164, 187, 164, 86, 61, 65, 45, 167, 253, 128, 253, 133, 52, 21, 31, 67, 178, 173, 68, 186, 82, 135, 50, 123, 122, 56, 178, 37, 233, 82, 83, 61, 229, 47, 195, 2, 169, 129, 154, 226, 71, 163, 211, 232, 156, 97, 67, 125, 104, 61, 0, 81, 253, 234, 225, 1, 164, 113, 33, 229, 24, 101, 238, 128, 227, 219, 40, 218, 221, 78, 213, 172, 12, 69, 179, 142, 97, 156, 138, 54, 50, 145, 14, 191, 120, 37, 128, 171, 75, 201, 79, 78, 144, 21, 163, 233, 54, 107, 234, 134, 11, 204, 233, 156, 77, 200, 9, 26, 32, 156, 132, 116, 81, 161, 218, 131, 110, 30, 175, 118, 48, 150, 146, 234, 195, 109, 228, 74, 43, 247, 114, 31, 139, 235, 214, 147, 206, 145, 170, 54, 83, 160, 48, 59, 65, 250, 192, 13, 87, 240, 234, 215, 209, 158, 10, 91, 208, 151, 177, 71, 96, 209, 184, 125, 82, 156, 34, 74, 15, 97, 73, 249, 185, 79, 34, 42, 157, 175, 52, 234, 131, 51, 144, 203, 6, 81, 245, 215, 107, 71, 68, 113, 82, 210, 116, 18, 88, 92, 141, 68, 227, 185, 55, 114, 54, 243, 152, 47, 117, 250, 87, 180, 30, 57, 187, 98, 90, 127, 243, 94, 122, 48, 23, 200, 40, 89, 89, 53, 83, 221, 56, 231, 117, 200, 201, 101, 159, 147, 25, 194, 236, 249, 5, 0, 70, 209, 122, 162, 103, 178, 217, 242, 36, 226, 16, 215, 144, 98, 198, 173, 134, 89, 115, 171, 81, 0, 112, 6, 152, 154, 119, 28, 36, 209, 174, 21, 93, 62, 33, 39, 57, 67, 71, 239, 11, 158, 53, 79, 103, 157, 100, 234, 28, 222, 212, 196, 216, 162, 49, 104, 49, 18, 175, 140, 255, 152, 175, 25, 233, 200, 60, 203, 225, 232, 121, 46, 220, 243, 13, 125, 255, 158, 2, 153, 203, 203, 223, 159, 65, 2, 219, 105, 235, 109, 126, 124, 53, 122} func GenerateQuicPayloadWithRandomIds() []byte { var beginHeader = []byte{202, 255, 255, 255, 255} var packet []byte var r = rand.New(rand.NewSource(time.Now().UnixNano())) packet = append(packet, beginHeader...) // append the length of destConnectionID packet = append(packet, 10) // generate random destConnectionId and append destConnectionId := make([]byte, 10) r.Read(destConnectionId) packet = append(packet, destConnectionId...) // append the length of destConnectionID packet = append(packet, 4) // generate random srcConnectionId and append srcConnectionId := make([]byte, 4) r.Read(srcConnectionId) packet = append(packet, srcConnectionId...) for i := len(packet); i < 1200; i++ { packet = append(packet, 0) } return packet } ================================================ FILE: trace/tcp_ipv4.go ================================================ package trace import ( "context" "errors" "fmt" "math/rand" "net" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/google/gopacket/layers" "golang.org/x/sync/semaphore" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) type TCPTracer struct { Config wg sync.WaitGroup res Result pending map[int]struct{} pendingMu sync.Mutex sentAt map[int]sentInfo sentMu sync.RWMutex SrcIP net.IP final atomic.Int32 sem *semaphore.Weighted matchQ chan matchTask readyICMP chan struct{} readyTCP chan struct{} } func (t *TCPTracer) waitAllReady(ctx context.Context) { timeout := time.After(5 * time.Second) waiting := 2 for waiting > 0 { select { case <-ctx.Done(): return case <-t.readyICMP: waiting-- case <-t.readyTCP: waiting-- case <-timeout: return } } <-time.After(100 * time.Millisecond) } func (t *TCPTracer) ttlComp(ttl int) bool { idx := ttl - 1 t.res.lock.RLock() defer t.res.lock.RUnlock() return idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements } func (t *TCPTracer) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) { defer t.wg.Done() ttl := t.BeginHop - 1 ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { if t.AsyncPrinter != nil { t.AsyncPrinter(&t.res) } // 接收的时候检查一下是不是 3 跳都齐了 if t.ttlComp(ttl + 1) { if t.RealtimePrinter != nil { t.res.waitGeo(ctx, ttl) t.RealtimePrinter(&t.res, ttl) } ttl++ if ttl == int(t.final.Load()) || ttl >= t.MaxHops { cancel(errNaturalDone) // 标记为“自然完成” return } } select { case <-ctx.Done(): return case <-ticker.C: } } } func (t *TCPTracer) launchTTL(ctx context.Context, s *internal.TCPSpec, ttl int) { go func(ttl int) { for i := 0; i < t.MaxAttempts; i++ { // 若此 TTL 已完成或 ctx 已取消,则不再发起新的尝试 if t.ttlComp(ttl) || ctx.Err() != nil { return } t.wg.Add(1) go func(ttl, i int) { if err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) { if util.EnvDevMode { panic(err) } fmt.Fprintf(os.Stderr, "send error (ttl=%d, attempt=%d): %v\n", ttl, i, err) } }(ttl, i) if i+1 == t.MaxAttempts { return } if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) { return } } }(ttl) } func (t *TCPTracer) markPending(seq int) { t.pendingMu.Lock() defer t.pendingMu.Unlock() t.pending[seq] = struct{}{} } func (t *TCPTracer) clearPending(seq int) bool { t.pendingMu.Lock() defer t.pendingMu.Unlock() _, ok := t.pending[seq] delete(t.pending, seq) return ok } func (t *TCPTracer) storeSent(seq, srcPort, payloadSize int, start time.Time) { t.sentMu.Lock() defer t.sentMu.Unlock() t.sentAt[seq] = sentInfo{srcPort: srcPort, payloadSize: payloadSize, start: start} } func (t *TCPTracer) lookupSent(seq int) (srcPort int, start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() si, ok := t.sentAt[seq] if !ok { return 0, time.Time{}, false } return si.srcPort, si.start, true } func (t *TCPTracer) lookupSentByAck(srcPort, ack int) (seq int, start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() return lookupTCPSentByAck(t.sentAt, srcPort, ack) } func (t *TCPTracer) dropSent(seq int) { t.sentMu.Lock() defer t.sentMu.Unlock() delete(t.sentAt, seq) } func (t *TCPTracer) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) { if f := t.final.Load(); f != -1 && ttl > int(f) { return } if ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) { for { old := t.final.Load() if old != -1 && ttl >= int(old) { break } if t.final.CompareAndSwap(old, int32(ttl)) { break } } } h := Hop{ Success: true, Address: peer, TTL: ttl, RTT: rtt, MPLS: mpls, } t.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config) } func (t *TCPTracer) matchWorker(ctx context.Context) { defer t.wg.Done() for { select { case <-ctx.Done(): return case task, ok := <-t.matchQ: if !ok { return } // 固定等待 10ms,缓解登记竞态 timer := time.NewTimer(10 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } timer.Stop() // 尝试一次匹配 var ( srcPort int start time.Time matched bool ) if task.ack != 0 { task.seq, start, matched = t.lookupSentByAck(task.srcPort, task.ack) srcPort = task.srcPort } else { srcPort, start, matched = t.lookupSent(task.seq) } if !matched || task.srcPort != srcPort { continue } // 将 task.seq 转为 32 位无符号数 u := uint32(task.seq) // 高 8 位是 TTL ttl := int((u >> 24) & 0xFF) // 低 24 位是索引 i i := int(u & 0xFFFFFF) if t.clearPending(task.seq) { rtt := task.finish.Sub(start) t.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls) } t.dropSent(task.seq) } } } func (t *TCPTracer) Execute() (res *Result, err error) { // 初始化 pending、sentAt 和 matchQ t.pending = make(map[int]struct{}) t.sentAt = make(map[int]sentInfo) t.matchQ = make(chan matchTask, 60) // 创建就绪通道 t.readyICMP = make(chan struct{}) t.readyTCP = make(chan struct{}) if len(t.res.Hops) > 0 { return &t.res, errTracerouteExecuted } // 初始化 res.Hops 和 res.tailDone,并预分配到 MaxHops t.res.Hops = make([][]Hop, t.MaxHops) t.res.tailDone = make([]bool, t.MaxHops) t.res.setGeoWait(t.NumMeasurements) // 解析并校验用户指定的 IPv4 源地址 SrcAddr := net.ParseIP(t.SrcAddr).To4() if t.SrcAddr != "" && SrcAddr == nil { return nil, errors.New("invalid IPv4 SrcAddr:" + t.SrcAddr) } t.SrcIP, _ = util.LocalIPPort(t.DstIP, SrcAddr, "tcp") if t.SrcIP == nil { return nil, errors.New("cannot determine local IPv4 address") } s := internal.NewTCPSpec( 4, t.ICMPMode, t.SrcIP, t.DstIP, t.DstPort, t.PktSize, ) s.SourceDevice = t.SourceDevice s.InitICMP() s.InitTCP() defer s.Close() baseCtx := t.Context if baseCtx == nil { baseCtx = context.Background() } sigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM) ctx, cancel := context.WithCancelCause(sigCtx) t.final.Store(-1) workerN := 16 for i := 0; i < workerN; i++ { t.wg.Add(1) go t.matchWorker(ctx) } t.wg.Add(1) go func() { defer t.wg.Done() s.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) { t.handleICMPMessage(msg, finish, data) }, ) }() t.wg.Add(1) go func() { defer t.wg.Done() s.ListenTCP(ctx, t.readyTCP, func(srcPort, seq, ack int, peer net.Addr, finish time.Time) { // 非阻塞投递,队列满则丢弃任务 select { case t.matchQ <- matchTask{ srcPort: srcPort, seq: seq, ack: ack, peer: peer, finish: finish, mpls: nil, }: default: // 丢弃以避免阻塞抓包循环 } }) }() t.waitAllReady(ctx) t.wg.Add(1) go t.PrintFunc(ctx, cancel) t.sem = semaphore.NewWeighted(int64(t.ParallelRequests)) t.wg.Add(1) go func() { defer t.wg.Done() // 立即启动 BeginHop 对应的 TTL 组 t.launchTTL(ctx, s, t.BeginHop) for ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ { // 之后按 TTLInterval 周期启动后续 TTL 组 if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) { return } // 如果到达最终跳,则退出 if f := t.final.Load(); f != -1 && ttl > int(f) { return } // 并发启动这个 TTL 的所有测量 t.launchTTL(ctx, s, ttl) } }() <-ctx.Done() stop() t.wg.Wait() final := int(t.final.Load()) if final == -1 { final = t.MaxHops } t.res.reduce(final) if cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) { return &t.res, cause } return &t.res, nil } func (t *TCPTracer) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) { mpls := extractMPLS(msg, t.DisableMPLS) header, err := util.GetICMPResponsePayload(data) if err != nil { return } srcPort, dstPort, err := util.GetTCPPorts(header) if err != nil { return } if dstPort != t.DstPort { return } seq, err := util.GetTCPSeq(header) if err != nil { return } // 非阻塞投递;如果队列已满则直接丢弃该任务 select { case t.matchQ <- matchTask{ srcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls, }: default: // 丢弃以避免阻塞抓包循环 } } func (t *TCPTracer) send(ctx context.Context, s *internal.TCPSpec, ttl, i int) error { defer t.wg.Done() if t.ttlComp(ttl) { // 快路径短路:若该 TTL 已完成,直接返回避免竞争信号量与无谓发包 return nil } if err := acquireTraceSemaphore(ctx, t.sem); err != nil { return err } defer t.sem.Release(1) if f := t.final.Load(); f != -1 && ttl > int(f) { return nil } if t.ttlComp(ttl) { // 竞态兜底:获取信号量期间可能已完成,再次检查以避免冗余发包 return nil } // 将 TTL 编码到高 8 位;将索引 i 编码到低 24 位 seq := (ttl << 24) | (i & 0xFFFFFF) _, SrcPort := func() (net.IP, int) { if !util.RandomPortEnabled() && t.SrcPort > 0 { return nil, t.SrcPort } return util.LocalIPPort(t.DstIP, t.SrcIP, "tcp") }() ipHeader := &layers.IPv4{ Version: 4, SrcIP: t.SrcIP, DstIP: t.DstIP, Protocol: layers.IPProtocolTCP, TTL: uint8(ttl), TOS: uint8(t.TOS), } tcpHeader := &layers.TCP{ SrcPort: layers.TCPPort(SrcPort), DstPort: layers.TCPPort(t.DstPort), Seq: uint32(seq), SYN: true, Window: 65535, Options: []layers.TCPOption{ {OptionType: layers.TCPOptionKindMSS, OptionLength: 4, OptionData: []byte{0x05, 0xb4}}, // MSS=1460 }, } desiredPayloadSize := resolveProbePayloadSize(TCPTrace, t.DstIP, t.PktSize, t.RandomPacketSize) payload := make([]byte, desiredPayloadSize) // 设置随机种子 r := rand.New(rand.NewSource(time.Now().UnixNano())) for k := range payload { payload[k] = byte(r.Intn(256)) } // 登记 pending,并启动超时守护 t.markPending(seq) go func(seq, ttl, i int) { if !waitForTraceDelay(ctx, t.Timeout) { _ = t.clearPending(seq) return } if !t.clearPending(seq) { return } if f := t.final.Load(); f != -1 && ttl > int(f) { return } if t.ttlComp(ttl) { return } h := Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, Error: errHopLimitTimeout, } _, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts) t.dropSent(seq) }(seq, ttl, i) start, err := s.SendTCP(ctx, ipHeader, tcpHeader, payload) if err != nil { _ = t.clearPending(seq) return err } t.storeSent(seq, SrcPort, desiredPayloadSize, start) return nil } ================================================ FILE: trace/tcp_ipv6.go ================================================ package trace import ( "context" "errors" "fmt" "math/rand" "net" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/google/gopacket/layers" "golang.org/x/sync/semaphore" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) type TCPTracerIPv6 struct { Config wg sync.WaitGroup res Result pending map[int]struct{} pendingMu sync.Mutex sentAt map[int]sentInfo sentMu sync.RWMutex SrcIP net.IP final atomic.Int32 sem *semaphore.Weighted matchQ chan matchTask readyICMP chan struct{} readyTCP chan struct{} } func (t *TCPTracerIPv6) waitAllReady(ctx context.Context) { timeout := time.After(5 * time.Second) waiting := 2 for waiting > 0 { select { case <-ctx.Done(): return case <-t.readyICMP: waiting-- case <-t.readyTCP: waiting-- case <-timeout: return } } <-time.After(100 * time.Millisecond) } func (t *TCPTracerIPv6) ttlComp(ttl int) bool { idx := ttl - 1 t.res.lock.RLock() defer t.res.lock.RUnlock() return idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements } func (t *TCPTracerIPv6) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) { defer t.wg.Done() ttl := t.BeginHop - 1 ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { if t.AsyncPrinter != nil { t.AsyncPrinter(&t.res) } // 接收的时候检查一下是不是 3 跳都齐了 if t.ttlComp(ttl + 1) { if t.RealtimePrinter != nil { t.res.waitGeo(ctx, ttl) t.RealtimePrinter(&t.res, ttl) } ttl++ if ttl == int(t.final.Load()) || ttl >= t.MaxHops { cancel(errNaturalDone) // 标记为“自然完成” return } } select { case <-ctx.Done(): return case <-ticker.C: } } } func (t *TCPTracerIPv6) launchTTL(ctx context.Context, s *internal.TCPSpec, ttl int) { go func(ttl int) { for i := 0; i < t.MaxAttempts; i++ { // 若此 TTL 已完成或 ctx 已取消,则不再发起新的尝试 if t.ttlComp(ttl) || ctx.Err() != nil { return } t.wg.Add(1) go func(ttl, i int) { if err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) { if util.EnvDevMode { panic(err) } fmt.Fprintf(os.Stderr, "send error (ttl=%d, attempt=%d): %v\n", ttl, i, err) } }(ttl, i) if i+1 == t.MaxAttempts { return } if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) { return } } }(ttl) } func (t *TCPTracerIPv6) markPending(seq int) { t.pendingMu.Lock() defer t.pendingMu.Unlock() t.pending[seq] = struct{}{} } func (t *TCPTracerIPv6) clearPending(seq int) bool { t.pendingMu.Lock() defer t.pendingMu.Unlock() _, ok := t.pending[seq] delete(t.pending, seq) return ok } func (t *TCPTracerIPv6) storeSent(seq, srcPort, payloadSize int, start time.Time) { t.sentMu.Lock() defer t.sentMu.Unlock() t.sentAt[seq] = sentInfo{srcPort: srcPort, payloadSize: payloadSize, start: start} } func (t *TCPTracerIPv6) lookupSent(seq int) (srcPort int, start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() si, ok := t.sentAt[seq] if !ok { return 0, time.Time{}, false } return si.srcPort, si.start, true } func (t *TCPTracerIPv6) lookupSentByAck(srcPort, ack int) (seq int, start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() return lookupTCPSentByAck(t.sentAt, srcPort, ack) } func (t *TCPTracerIPv6) dropSent(seq int) { t.sentMu.Lock() defer t.sentMu.Unlock() delete(t.sentAt, seq) } func (t *TCPTracerIPv6) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) { if f := t.final.Load(); f != -1 && ttl > int(f) { return } if ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) { for { old := t.final.Load() if old != -1 && ttl >= int(old) { break } if t.final.CompareAndSwap(old, int32(ttl)) { break } } } h := Hop{ Success: true, Address: peer, TTL: ttl, RTT: rtt, MPLS: mpls, } t.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config) } func (t *TCPTracerIPv6) matchWorker(ctx context.Context) { defer t.wg.Done() for { select { case <-ctx.Done(): return case task, ok := <-t.matchQ: if !ok { return } // 固定等待 10ms,缓解登记竞态 timer := time.NewTimer(10 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } timer.Stop() // 尝试一次匹配 var ( srcPort int start time.Time matched bool ) if task.ack != 0 { task.seq, start, matched = t.lookupSentByAck(task.srcPort, task.ack) srcPort = task.srcPort } else { srcPort, start, matched = t.lookupSent(task.seq) } if !matched || task.srcPort != srcPort { continue } // 将 task.seq 转为 32 位无符号数 u := uint32(task.seq) // 高 8 位是 TTL ttl := int((u >> 24) & 0xFF) // 低 24 位是索引 i i := int(u & 0xFFFFFF) if t.clearPending(task.seq) { rtt := task.finish.Sub(start) t.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls) } t.dropSent(task.seq) } } } func (t *TCPTracerIPv6) Execute() (res *Result, err error) { // 初始化 pending、sentAt 和 matchQ t.pending = make(map[int]struct{}) t.sentAt = make(map[int]sentInfo) t.matchQ = make(chan matchTask, 60) // 创建就绪通道 t.readyICMP = make(chan struct{}) t.readyTCP = make(chan struct{}) if len(t.res.Hops) > 0 { return &t.res, errTracerouteExecuted } // 初始化 res.Hops 和 res.tailDone,并预分配到 MaxHops t.res.Hops = make([][]Hop, t.MaxHops) t.res.tailDone = make([]bool, t.MaxHops) t.res.setGeoWait(t.NumMeasurements) // 解析并校验用户指定的 IPv6 源地址 SrcAddr := net.ParseIP(t.SrcAddr) if t.SrcAddr != "" && !util.IsIPv6(SrcAddr) { return nil, errors.New("invalid IPv6 SrcAddr: " + t.SrcAddr) } t.SrcIP, _ = util.LocalIPPortv6(t.DstIP, SrcAddr, "tcp6") if t.SrcIP == nil { return nil, errors.New("cannot determine local IPv6 address") } s := internal.NewTCPSpec( 6, t.ICMPMode, t.SrcIP, t.DstIP, t.DstPort, t.PktSize, ) s.SourceDevice = t.SourceDevice s.InitICMP() s.InitTCP() defer s.Close() baseCtx := t.Context if baseCtx == nil { baseCtx = context.Background() } sigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM) ctx, cancel := context.WithCancelCause(sigCtx) t.final.Store(-1) workerN := 16 for i := 0; i < workerN; i++ { t.wg.Add(1) go t.matchWorker(ctx) } t.wg.Add(1) go func() { defer t.wg.Done() s.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) { t.handleICMPMessage(msg, finish, data) }, ) }() t.wg.Add(1) go func() { defer t.wg.Done() s.ListenTCP(ctx, t.readyTCP, func(srcPort, seq, ack int, peer net.Addr, finish time.Time) { // 非阻塞投递,队列满则丢弃任务 select { case t.matchQ <- matchTask{ srcPort: srcPort, seq: seq, ack: ack, peer: peer, finish: finish, mpls: nil, }: default: // 丢弃以避免阻塞抓包循环 } }) }() t.waitAllReady(ctx) t.wg.Add(1) go t.PrintFunc(ctx, cancel) t.sem = semaphore.NewWeighted(int64(t.ParallelRequests)) t.wg.Add(1) go func() { defer t.wg.Done() // 立即启动 BeginHop 对应的 TTL 组 t.launchTTL(ctx, s, t.BeginHop) for ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ { // 之后按 TTLInterval 周期启动后续 TTL 组 if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) { return } // 如果到达最终跳,则退出 if f := t.final.Load(); f != -1 && ttl > int(f) { return } // 并发启动这个 TTL 的所有测量 t.launchTTL(ctx, s, ttl) } }() <-ctx.Done() stop() t.wg.Wait() final := int(t.final.Load()) if final == -1 { final = t.MaxHops } t.res.reduce(final) if cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) { return &t.res, cause } return &t.res, nil } func (t *TCPTracerIPv6) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) { mpls := extractMPLS(msg, t.DisableMPLS) header, err := util.GetICMPResponsePayload(data) if err != nil { return } srcPort, dstPort, err := util.GetTCPPorts(header) if err != nil { return } if dstPort != t.DstPort { return } seq, err := util.GetTCPSeq(header) if err != nil { return } // 非阻塞投递;如果队列已满则直接丢弃该任务 select { case t.matchQ <- matchTask{ srcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls, }: default: // 丢弃以避免阻塞抓包循环 } } func (t *TCPTracerIPv6) send(ctx context.Context, s *internal.TCPSpec, ttl, i int) error { defer t.wg.Done() if t.ttlComp(ttl) { // 快路径短路:若该 TTL 已完成,直接返回避免竞争信号量与无谓发包 return nil } if err := acquireTraceSemaphore(ctx, t.sem); err != nil { return err } defer t.sem.Release(1) if f := t.final.Load(); f != -1 && ttl > int(f) { return nil } if t.ttlComp(ttl) { // 竞态兜底:获取信号量期间可能已完成,再次检查以避免冗余发包 return nil } // 将 TTL 编码到高 8 位;将索引 i 编码到低 24 位 seq := (ttl << 24) | (i & 0xFFFFFF) _, SrcPort := func() (net.IP, int) { if !util.RandomPortEnabled() && t.SrcPort > 0 { return nil, t.SrcPort } return util.LocalIPPortv6(t.DstIP, t.SrcIP, "tcp6") }() ipHeader := &layers.IPv6{ Version: 6, SrcIP: t.SrcIP, DstIP: t.DstIP, NextHeader: layers.IPProtocolTCP, HopLimit: uint8(ttl), TrafficClass: uint8(t.TOS), } tcpHeader := &layers.TCP{ SrcPort: layers.TCPPort(SrcPort), DstPort: layers.TCPPort(t.DstPort), Seq: uint32(seq), SYN: true, Window: 65535, Options: []layers.TCPOption{ {OptionType: layers.TCPOptionKindMSS, OptionLength: 4, OptionData: []byte{0x05, 0xA0}}, // MSS=1440 }, } desiredPayloadSize := resolveProbePayloadSize(TCPTrace, t.DstIP, t.PktSize, t.RandomPacketSize) payload := make([]byte, desiredPayloadSize) // 设置随机种子 r := rand.New(rand.NewSource(time.Now().UnixNano())) for k := range payload { payload[k] = byte(r.Intn(256)) } // 登记 pending,并启动超时守护 t.markPending(seq) go func(seq, ttl, i int) { if !waitForTraceDelay(ctx, t.Timeout) { _ = t.clearPending(seq) return } if !t.clearPending(seq) { return } if f := t.final.Load(); f != -1 && ttl > int(f) { return } if t.ttlComp(ttl) { return } h := Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, Error: errHopLimitTimeout, } _, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts) t.dropSent(seq) }(seq, ttl, i) start, err := s.SendTCP(ctx, ipHeader, tcpHeader, payload) if err != nil { _ = t.clearPending(seq) return err } t.storeSent(seq, SrcPort, desiredPayloadSize, start) return nil } ================================================ FILE: trace/tcp_match.go ================================================ package trace import "time" func tcpReplyAckForProbe(seq int, payloadSize int) int { return int(uint32(seq) + 1 + uint32(payloadSize)) } func lookupTCPSentByAck(sentAt map[int]sentInfo, srcPort, ack int) (seq int, start time.Time, ok bool) { for candidateSeq, info := range sentAt { if info.srcPort != srcPort { continue } if tcpReplyAckForProbe(candidateSeq, info.payloadSize) != ack { continue } return candidateSeq, info.start, true } return 0, time.Time{}, false } ================================================ FILE: trace/tcp_match_test.go ================================================ package trace import ( "testing" "time" ) func TestLookupTCPSentByAck(t *testing.T) { startA := time.Unix(10, 0) startB := time.Unix(20, 0) sentAt := map[int]sentInfo{ 100: {srcPort: 40000, payloadSize: 20, start: startA}, 200: {srcPort: 40000, payloadSize: 48, start: startB}, } seq, start, ok := lookupTCPSentByAck(sentAt, 40000, tcpReplyAckForProbe(200, 48)) if !ok { t.Fatal("lookupTCPSentByAck() ok = false") } if seq != 200 { t.Fatalf("seq = %d, want 200", seq) } if !start.Equal(startB) { t.Fatalf("start = %v, want %v", start, startB) } } ================================================ FILE: trace/temp_printer.go ================================================ package trace import ( "fmt" "strings" "github.com/nxtrace/NTrace-core/ipgeo" ) func HopPrinter(h Hop) { if h.Address == nil { fmt.Println("\t*") } else { txt := "\t" if h.Hostname == "" { txt += fmt.Sprint(h.Address, " ", fmt.Sprintf("%.2f", h.RTT.Seconds()*1000), "ms") } else { txt += fmt.Sprint(h.Hostname, " (", h.Address, ") ", fmt.Sprintf("%.2f", h.RTT.Seconds()*1000), "ms") } if h.Geo != nil { txt += " " + formatIpGeoData(h.Address.String(), h.Geo) } fmt.Println(txt) } } func formatIpGeoData(ip string, data *ipgeo.IPGeoData) string { var res = make([]string, 0, 10) if data.Asnumber == "" { res = append(res, "*") } else { res = append(res, "AS"+data.Asnumber) } // TODO: 判断阿里云和腾讯云内网,数据不足,有待进一步完善 // TODO: 移动IDC判断到Hop.fetchIPData函数,减少API调用 if strings.HasPrefix(ip, "9.") { res = append(res, "LAN Address", "") } else if strings.HasPrefix(ip, "11.") { res = append(res, "LAN Address", "") } else if data.Country == "" { res = append(res, "LAN Address") } else { // 有些IP的归属信息为空,这个时候将ISP的信息填入 if data.Owner == "" { data.Owner = data.Isp } if data.District != "" { data.City = data.City + ", " + data.District } if data.Prov == "" && data.City == "" { // anyCast或是骨干网数据不应该有国家信息 data.Owner = data.Owner + ", " + data.Owner } else { // 非骨干网正常填入IP的国家信息数据 res = append(res, data.Country) } if data.Prov != "" { res = append(res, data.Prov) } if data.City != "" { res = append(res, data.City) } if data.Owner != "" { res = append(res, data.Owner) } } return strings.Join(res, ", ") } ================================================ FILE: trace/trace.go ================================================ package trace import ( "context" "errors" "fmt" "net" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "golang.org/x/net/idna" "golang.org/x/sync/semaphore" "golang.org/x/sync/singleflight" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) var ( errHopLimitTimeout = errors.New("hop timeout") errInvalidMethod = errors.New("invalid method") errNaturalDone = errors.New("trace natural done") errTracerouteExecuted = errors.New("traceroute already executed") geoCache = sync.Map{} ipGeoSF singleflight.Group ) type Config struct { Context context.Context OSType int ICMPMode int SrcAddr string SrcPort int SourceDevice string BeginHop int MaxHops int NumMeasurements int MaxAttempts int ParallelRequests int Timeout time.Duration DstIP net.IP DstPort int Quic bool IPGeoSource ipgeo.Source RDNS bool AlwaysWaitRDNS bool PacketInterval int TTLInterval int Lang string DN42 bool RealtimePrinter func(res *Result, ttl int) AsyncPrinter func(res *Result) PktSize int RandomPacketSize bool TOS int Maptrace bool DisableMPLS bool } type Method string const ( ICMPTrace Method = "icmp" UDPTrace Method = "udp" TCPTrace Method = "tcp" ) type attemptKey struct { ttl int i int } type attemptPort struct { srcPort int i int } type sentInfo struct { ttl int i int srcPort int payloadSize int start time.Time } type matchTask struct { srcPort int seq int ack int peer net.Addr finish time.Time mpls []string } type Tracer interface { Execute() (*Result, error) } func applyTracerouteDefaults(config *Config) { if config == nil { return } if config.MaxHops == 0 { config.MaxHops = 30 } if config.NumMeasurements == 0 { config.NumMeasurements = 3 } if config.ParallelRequests == 0 { config.ParallelRequests = config.NumMeasurements * 5 } } func waitForTraceDelay(ctx context.Context, d time.Duration) bool { if ctx == nil { ctx = context.Background() } if ctx != nil && ctx.Err() != nil { return false } if d <= 0 { return true } timer := time.NewTimer(d) defer stopAndDrainTimer(timer) select { case <-ctx.Done(): return false case <-timer.C: return ctx == nil || ctx.Err() == nil } } func stopAndDrainTimer(timer *time.Timer) { if timer == nil { return } if timer.Stop() { return } select { case <-timer.C: default: } } func acquireTraceSemaphore(ctx context.Context, sem *semaphore.Weighted) error { if sem == nil { return nil } if ctx == nil { ctx = context.Background() } if err := ctx.Err(); err != nil { return err } return sem.Acquire(ctx, 1) } func normalizeICMPMode(config *Config) { if config == nil { return } if config.ICMPMode <= 0 && util.EnvICMPMode > 0 { config.ICMPMode = util.EnvICMPMode } switch config.ICMPMode { case 0, 1, 2: default: config.ICMPMode = 0 } } func deriveMaxAttempts(config *Config) { if config == nil { return } if config.MaxAttempts <= 0 && util.EnvMaxAttempts > 0 { config.MaxAttempts = util.EnvMaxAttempts } if config.MaxAttempts > 0 && config.MaxAttempts >= config.NumMeasurements { return } n := config.NumMeasurements switch { case n <= 2 || n >= 10: config.MaxAttempts = n case n <= 6: config.MaxAttempts = n + 3 default: config.MaxAttempts = 10 } } func selectTracer(method Method, config Config) (Tracer, error) { isIPv4 := config.DstIP.To4() != nil switch method { case ICMPTrace: if isIPv4 { return &ICMPTracer{Config: config}, nil } return &ICMPTracerv6{Config: config}, nil case UDPTrace: if isIPv4 { return &UDPTracer{Config: config}, nil } return &UDPTracerIPv6{Config: config}, nil case TCPTrace: if isIPv4 { return &TCPTracer{Config: config}, nil } return &TCPTracerIPv6{Config: config}, nil default: return nil, errInvalidMethod } } func waitForPendingGeoData(ctx context.Context, result *Result) { if result == nil { return } done := make(chan struct{}) go func() { result.geoWG.Wait() close(done) }() select { case <-done: case <-ctxDoneChan(ctx): case <-time.After(30 * time.Second): } result.geoCanceled.Store(true) result.markAllPendingGeoTimeout() } func ctxDoneChan(ctx context.Context) <-chan struct{} { if ctx == nil { return nil } return ctx.Done() } func Traceroute(method Method, config Config) (*Result, error) { normalizeRuntimeConfig(&config) applyTracerouteDefaults(&config) normalizeICMPMode(&config) deriveMaxAttempts(&config) tracer, err := selectTracer(method, config) if err != nil { return &Result{}, err } result, err := tracer.Execute() if err != nil && errors.Is(err, syscall.EPERM) { err = fmt.Errorf("%w,请使用 root 权限运行", err) } waitForPendingGeoData(config.Context, result) return result, err } func TracerouteWithContext(ctx context.Context, method Method, config Config) (*Result, error) { if ctx == nil { ctx = context.Background() } config.Context = ctx return Traceroute(method, config) } func normalizeRuntimeConfig(config *Config) { if config == nil { return } if config.SourceDevice == "" && util.SrcDev != "" { config.SourceDevice = util.SrcDev } } type Result struct { Hops [][]Hop lock sync.RWMutex tailDone []bool TraceMapUrl string geoWait time.Duration geoWG sync.WaitGroup geoCanceled atomic.Bool } const PendingGeoSource = "pending" const timeoutGeoSource = "timeout" func isPendingGeo(geo *ipgeo.IPGeoData) bool { return geo != nil && geo.Source == PendingGeoSource } func pendingGeo() *ipgeo.IPGeoData { return &ipgeo.IPGeoData{Source: PendingGeoSource} } func timeoutGeo() *ipgeo.IPGeoData { return &ipgeo.IPGeoData{ Country: "网络故障", CountryEn: "Network Error", Source: timeoutGeoSource, } } func geoWaitForMeasurements(numMeasurements int) time.Duration { if numMeasurements <= 0 { numMeasurements = 1 } maxRetries := numMeasurements - 1 if maxRetries > 5 { maxRetries = 5 } total := 0 for attempt := 0; attempt <= maxRetries; attempt++ { timeout := 2 + attempt if timeout > 6 { timeout = 6 } total += timeout } return time.Duration(total) * time.Second } // 判定 Hop 是否“有效” func isValidHop(h Hop) bool { return h.Success && h.Address != nil } // add 带审计/限容 // - N = numMeasurements(每个 TTL 组的最小输出条数) // - M = maxAttempts(每个 TTL 组的最大尝试条数) // 规则:对同一 TTL,attemptIdx < N-1 无条件放行(索引 i 从 0 开始);第 N 条进行审计(已有有效 / 当次有效 / 达到最后一次尝试 任一成立即放行);超过 N 条一律忽略 func (s *Result) add(hop Hop, attemptIdx, numMeasurements, maxAttempts int) (bool, int) { s.lock.Lock() defer s.lock.Unlock() k := hop.TTL - 1 bucket := s.Hops[k] n := numMeasurements switch { case attemptIdx < n-1: // attemptIdx < N-1:无条件放行 s.Hops[k] = append(bucket, hop) return true, len(s.Hops[k]) - 1 case attemptIdx >= n-1: // 正在决定第 N 条:审计 // 放行条件(三选一): // (1) 前 N-1 中已存在有效值 // (2) 当前 hop 为有效值 // (3) 已到最后一次尝试 if len(bucket) >= n { return false, -1 } if s.tailDone[k] { return false, -1 } hasValid := false for _, h := range bucket { if isValidHop(h) { hasValid = true break } } if hasValid || isValidHop(hop) || attemptIdx >= maxAttempts-1 { s.Hops[k] = append(bucket, hop) // 填满第 N 个 s.tailDone[k] = true return true, len(s.Hops[k]) - 1 } // 否则丢弃,等待后续更优候选(长度仍保持 N-1) return false, -1 } return false, -1 } func (s *Result) setGeoWait(numMeasurements int) { s.geoWait = geoWaitForMeasurements(numMeasurements) } func (s *Result) reduce(final int) { s.lock.Lock() defer s.lock.Unlock() if final > 0 && final < len(s.Hops) { s.Hops = s.Hops[:final] } } type Hop struct { Success bool Address net.Addr Hostname string TTL int RTT time.Duration Error error Geo *ipgeo.IPGeoData Lang string MPLS []string } func isLDHASCII(label string) bool { for i := 0; i < len(label); i++ { b := label[i] if b > 0x7F { return false } if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' { continue } return false } return true } func CanonicalHostname(s string) string { if s == "" { return "" } // 去掉尾点 s = strings.TrimSuffix(s, ".") // 按标签逐个处理,确保仅对需要的标签做 IDNA 转换 parts := strings.Split(s, ".") for i, label := range parts { if label == "" { continue } if isLDHASCII(label) { // 纯 ASCII 且仅含 LDH:保留原大小写,不做大小写折叠 continue } // 含非 ASCII 或不满足 LDH:对该标签做 IDNA 转 ASCII if ascii, err := idna.Lookup.ToASCII(label); err == nil && ascii != "" { parts[i] = ascii } // 若转换失败,保留原标签 } return strings.Join(parts, ".") } func (s *Result) updateHop(ttl, idx int, updated Hop) { s.lock.Lock() defer s.lock.Unlock() k := ttl - 1 if k < 0 || k >= len(s.Hops) { return } if idx < 0 || idx >= len(s.Hops[k]) { return } h := &s.Hops[k][idx] if updated.Hostname != "" { h.Hostname = updated.Hostname } if updated.Geo != nil { h.Geo = updated.Geo } if updated.Lang != "" { h.Lang = updated.Lang } } func (s *Result) waitGeo(ctx context.Context, ttlIdx int) { if s.geoWait <= 0 { return } if ttlIdx < 0 { return } deadline := time.Now().Add(s.geoWait) for { if !s.hasPendingGeo(ttlIdx) { return } if time.Now().After(deadline) { s.markPendingGeoTimeout(ttlIdx) return } if ctx == nil { time.Sleep(20 * time.Millisecond) continue } select { case <-ctx.Done(): s.markPendingGeoTimeout(ttlIdx) return case <-time.After(20 * time.Millisecond): } } } func (s *Result) hasPendingGeo(ttlIdx int) bool { s.lock.RLock() defer s.lock.RUnlock() if ttlIdx < 0 || ttlIdx >= len(s.Hops) { return false } for _, hop := range s.Hops[ttlIdx] { if hop.Address == nil { continue } if isPendingGeo(hop.Geo) { return true } } return false } func (s *Result) markPendingGeoTimeout(ttlIdx int) { s.lock.Lock() defer s.lock.Unlock() if ttlIdx < 0 || ttlIdx >= len(s.Hops) { return } for i := range s.Hops[ttlIdx] { hop := &s.Hops[ttlIdx][i] if hop.Address == nil || !isPendingGeo(hop.Geo) { continue } hop.Geo = timeoutGeo() } } func (s *Result) markAllPendingGeoTimeout() { s.lock.Lock() defer s.lock.Unlock() for ttlIdx := range s.Hops { for i := range s.Hops[ttlIdx] { hop := &s.Hops[ttlIdx][i] if hop.Address == nil || !isPendingGeo(hop.Geo) { continue } hop.Geo = timeoutGeo() } } } func (s *Result) addWithGeoAsync(hop Hop, attemptIdx, numMeasurements, maxAttempts int, cfg Config) { if hop.Geo == nil { hop.Geo = pendingGeo() } else if hop.Geo.Source == "" { hop.Geo.Source = PendingGeoSource } if hop.Lang == "" { hop.Lang = cfg.Lang } added, idx := s.add(hop, attemptIdx, numMeasurements, maxAttempts) if !added { return } s.geoWG.Add(1) go func(ttl, idx int, h Hop) { defer s.geoWG.Done() _ = h.fetchIPData(cfg) if !s.geoCanceled.Load() { s.updateHop(ttl, idx, h) } }(hop.TTL, idx, hop) } func geoLookupMaxRetries(numMeasurements int) int { maxRetries := numMeasurements - 1 if maxRetries < 0 { return 0 } if maxRetries > 5 { return 5 } return maxRetries } func geoTimeoutForAttempt(attempt int) time.Duration { timeout := 2 + attempt if timeout > 6 { timeout = 6 } return time.Duration(timeout) * time.Second } func lookupGeoWithRetry(c Config, cacheKey, query string, dn42 bool) (*ipgeo.IPGeoData, error) { if cacheVal, ok := geoCache.Load(cacheKey); ok { if g, ok := cacheVal.(*ipgeo.IPGeoData); ok && g != nil { return g, nil } } typeErr := "ipgeo: nil or bad type from singleflight" lookupErr := "ipgeo: lookup failed without specific error" if dn42 { typeErr += " (DN42)" lookupErr += " (DN42)" } var lastErr error for attempt := 0; attempt <= geoLookupMaxRetries(c.NumMeasurements); attempt++ { timeout := geoTimeoutForAttempt(attempt) v, err, _ := ipGeoSF.Do(cacheKey, func() (any, error) { return c.IPGeoSource(query, timeout, c.Lang, c.Maptrace) }) if err != nil { lastErr = err continue } geo, ok := v.(*ipgeo.IPGeoData) if !ok || geo == nil { lastErr = errors.New(typeErr) continue } geoCache.Store(cacheKey, geo) return geo, nil } if lastErr == nil { lastErr = errors.New(lookupErr) } return nil, lastErr } func lookupPTR(ctx context.Context, ipStr string) []string { ptrs, err := util.LookupAddrWithContext(ctx, ipStr) if err != nil { return nil } if len(ptrs) > 0 { return ptrs } return nil } func applyPTRResult(h *Hop, ptrs []string) { if len(ptrs) > 0 { h.Hostname = CanonicalHostname(ptrs[0]) } } func startPTRLookup(ctx context.Context, ipStr string) <-chan []string { rDNSCh := make(chan []string, 1) go func() { select { case rDNSCh <- lookupPTR(ctx, ipStr): default: } }() return rDNSCh } func (h *Hop) resolveDN42Metadata(c Config, ipStr string) error { combined := ipStr if c.RDNS && h.Hostname == "" { applyPTRResult(h, lookupPTR(c.Context, ipStr)) if h.Hostname != "" { combined = ipStr + "," + h.Hostname } } if c.IPGeoSource == nil { return nil } geo, err := lookupGeoWithRetry(c, combined, combined, true) if err != nil { h.Geo = timeoutGeo() return err } h.Geo = geo return nil } func (h *Hop) startGeoLookup(c Config, ipStr string) <-chan error { ipGeoCh := make(chan error, 1) go func() { if c.IPGeoSource == nil || (h.Geo != nil && !isPendingGeo(h.Geo)) { ipGeoCh <- nil return } h.Lang = c.Lang if g, ok := ipgeo.Filter(ipStr); ok { h.Geo = g ipGeoCh <- nil return } geo, err := lookupGeoWithRetry(c, ipStr, ipStr, false) if err == nil { h.Geo = geo } ipGeoCh <- err }() return ipGeoCh } func (h *Hop) waitForGeoAndPTR(c Config, ipGeoCh <-chan error, rDNSStarted bool, rDNSCh <-chan []string) error { if c.AlwaysWaitRDNS { if rDNSStarted { select { case ptrs := <-rDNSCh: applyPTRResult(h, ptrs) case <-time.After(1 * time.Second): } } err := <-ipGeoCh if err != nil { h.Geo = timeoutGeo() } return err } if rDNSStarted { select { case err := <-ipGeoCh: if err != nil { h.Geo = timeoutGeo() } return err case ptrs := <-rDNSCh: applyPTRResult(h, ptrs) err := <-ipGeoCh if err != nil { h.Geo = timeoutGeo() } return err } } err := <-ipGeoCh if err != nil { h.Geo = timeoutGeo() } return err } func (h *Hop) fetchIPData(c Config) error { ipStr := h.Address.String() if c.DN42 { return h.resolveDN42Metadata(c, ipStr) } ipGeoCh := h.startGeoLookup(c, ipStr) rDNSStarted := c.RDNS && h.Hostname == "" var rDNSCh <-chan []string if rDNSStarted { rDNSCh = startPTRLookup(c.Context, ipStr) } return h.waitForGeoAndPTR(c, ipGeoCh, rDNSStarted, rDNSCh) } // parse 安全解析十六进制子串 s 为无符号整数 func parse(s string, bitSize int) (uint64, bool) { if len(s) == 0 { return 0, false } v, err := strconv.ParseUint(s, 16, bitSize) if err != nil { return 0, false } return v, true } // findValid 在十六进制字符串 hexStr 中截取从 ICMP 扩展头开始的部分 func findValid(hexStr string) string { n := len(hexStr) // 至少要能容纳 4B 扩展头,且 hexStr 的长度必须为偶数 if n < 8 || n%2 != 0 { return "" } // 从尾到头以 4B 为单位扫描(1B = 2 hex digits) for i := n - 8; i >= 0; i -= 8 { // 直接匹配 "2000" if hexStr[i:i+4] != "2000" { continue } // 处理扩展头 4B 后的剩余部分 remHex := n - (i + 8) // 剩余的 hex 个数 if remHex <= 0 { continue } remBytes := remHex / 2 // 剩余部分长度必须 ≥ 8B if remBytes >= 8 { return hexStr[i:] } // 否则继续向左寻找更早的 "2000" } return "" } func extractMPLS(msg internal.ReceivedMessage, disableMPLS bool) []string { if disableMPLS { return nil } // 将整包转换为十六进制字符串 hexStr := fmt.Sprintf("%x", msg.Msg) // 调用 findValid 截取从 ICMP 扩展头开始的字符串 extStr := findValid(hexStr) if extStr == "" { return nil } var mplsLSEList []string n := len(extStr) // 先逐对象检查 Class 是否为 MPLS Label Stack Class (1) for j := 8; j+8 <= n; { // 对象头:Length(2B) | Class(1B) | C-Type(1B) lengthU, ok := parse(extStr[j:j+4], 16) if !ok || lengthU < 4 { return nil } objLenBytes := int(lengthU) objLenHex := objLenBytes * 2 if j+objLenHex > n { return nil } // 读取 Class 的值 classU, ok := parse(extStr[j+4:j+6], 8) if !ok { return nil } class := int(classU) if class == 1 { // 去掉扩展头与 MPLS 对象头,只保留 MPLS 对象负载 payloadStart := j + 8 payloadEnd := j + objLenHex if payloadEnd <= payloadStart { return nil } mplsPayload := extStr[payloadStart:payloadEnd] // 仅 LSE 区域 if len(mplsPayload)%8 != 0 { // 每个 LSE = 4B = 8 hex digits return nil } // 逐个 LSE 解析并追加到 mplsLSEList for off := 0; off+8 <= len(mplsPayload); off += 8 { vU, ok := parse(mplsPayload[off:off+8], 32) if !ok { return nil } v := uint32(vU) lbl := (v >> 12) & 0xFFFFF // 20 bits tc := (v >> 9) & 0x7 // 3 bits s := (v >> 8) & 0x1 // 1 bit ttl := v & 0xFF // 8 bits mplsLSEList = append(mplsLSEList, fmt.Sprintf("[MPLS: Lbl %d, TC %d, S %d, TTL %d]", lbl, tc, s, ttl)) } } // 跳到下一个对象 j += objLenHex } return mplsLSEList } ================================================ FILE: trace/trace_runtime_test.go ================================================ package trace import ( "context" "errors" "net" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" "golang.org/x/sync/semaphore" ) func TestWaitForTraceDelayCanceledContextReturnsImmediately(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() if waitForTraceDelay(ctx, time.Second) { t.Fatal("waitForTraceDelay should return false for canceled context") } if elapsed := time.Since(start); elapsed > 50*time.Millisecond { t.Fatalf("waitForTraceDelay returned too slowly after cancel: %v", elapsed) } } func TestWaitForTraceDelayZeroDelaySucceeds(t *testing.T) { if !waitForTraceDelay(context.Background(), 0) { t.Fatal("waitForTraceDelay should succeed for zero delay") } } func TestAcquireTraceSemaphoreChecksCanceledContextFirst(t *testing.T) { sem := semaphore.NewWeighted(1) if err := sem.Acquire(context.Background(), 1); err != nil { t.Fatalf("initial acquire failed: %v", err) } ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() err := acquireTraceSemaphore(ctx, sem) if !errors.Is(err, context.Canceled) { t.Fatalf("acquireTraceSemaphore error = %v, want context.Canceled", err) } if elapsed := time.Since(start); elapsed > 50*time.Millisecond { t.Fatalf("acquireTraceSemaphore returned too slowly after cancel: %v", elapsed) } sem.Release(1) if err := acquireTraceSemaphore(context.Background(), sem); err != nil { t.Fatalf("acquireTraceSemaphore should still acquire after release: %v", err) } sem.Release(1) } func TestWaitForPendingGeoDataReturnsOnCanceledContext(t *testing.T) { res := &Result{ Hops: [][]Hop{{ { Address: &net.IPAddr{IP: net.ParseIP("1.1.1.1")}, Geo: pendingGeo(), }, }}, } res.geoWG.Add(1) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer res.geoWG.Done() <-done }() start := time.Now() cancel() waitForPendingGeoData(ctx, res) if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("waitForPendingGeoData returned too slowly after cancel: %v", elapsed) } if !res.geoCanceled.Load() { t.Fatal("geoCanceled = false, want true") } geo := res.Hops[0][0].Geo if geo == nil || geo.Source != timeoutGeoSource { t.Fatalf("hop geo = %+v, want timeout geo", geo) } close(done) } func TestWaitForPendingGeoDataReturnsImmediatelyForCompletedWorkers(t *testing.T) { res := &Result{ Hops: [][]Hop{{ { Address: &net.IPAddr{IP: net.ParseIP("1.1.1.1")}, Geo: &ipgeo.IPGeoData{CountryEn: "US"}, }, }}, } start := time.Now() waitForPendingGeoData(context.Background(), res) if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("waitForPendingGeoData returned too slowly for completed result: %v", elapsed) } } ================================================ FILE: trace/udp_ipv4.go ================================================ package trace import ( "context" "errors" "fmt" "math/rand" "net" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/google/gopacket/layers" "golang.org/x/sync/semaphore" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) type UDPTracer struct { Config wg sync.WaitGroup res Result ttlQueues map[int][]attemptPort ttlQMu sync.Mutex pending map[attemptKey]struct{} pendingMu sync.Mutex sentAt map[int]sentInfo sentMu sync.RWMutex SrcIP net.IP final atomic.Int32 sem *semaphore.Weighted matchQ chan matchTask readyOut chan struct{} readyICMP chan struct{} readyUDP chan struct{} } func (t *UDPTracer) waitAllReady(ctx context.Context) { timeout := time.After(5 * time.Second) waiting := 3 for waiting > 0 { select { case <-ctx.Done(): return case <-t.readyOut: waiting-- case <-t.readyICMP: waiting-- case <-t.readyUDP: waiting-- case <-timeout: return } } <-time.After(100 * time.Millisecond) } func (t *UDPTracer) ttlComp(ttl int) bool { idx := ttl - 1 t.res.lock.RLock() defer t.res.lock.RUnlock() return idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements } func (t *UDPTracer) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) { defer t.wg.Done() ttl := t.BeginHop - 1 ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { if t.AsyncPrinter != nil { t.AsyncPrinter(&t.res) } // 接收的时候检查一下是不是 3 跳都齐了 if t.ttlComp(ttl + 1) { if t.RealtimePrinter != nil { t.res.waitGeo(ctx, ttl) t.RealtimePrinter(&t.res, ttl) } ttl++ if ttl == int(t.final.Load()) || ttl >= t.MaxHops { cancel(errNaturalDone) // 标记为“自然完成” return } } select { case <-ctx.Done(): return case <-ticker.C: } } } func (t *UDPTracer) launchTTL(ctx context.Context, s *internal.UDPSpec, ttl int) { go func(ttl int) { for i := 0; i < t.MaxAttempts; i++ { // 若此 TTL 已完成或 ctx 已取消,则不再发起新的尝试 if t.ttlComp(ttl) || ctx.Err() != nil { return } t.wg.Add(1) go func(ttl, i int) { if err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) { if util.EnvDevMode { panic(err) } fmt.Fprintf(os.Stderr, "send error (ttl=%d, attempt=%d): %v\n", ttl, i, err) } }(ttl, i) if i+1 == t.MaxAttempts { return } if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) { return } } }(ttl) } func (t *UDPTracer) tryMatchTTLPort(ttl, srcPort int) (int, bool) { t.ttlQMu.Lock() defer t.ttlQMu.Unlock() q := t.ttlQueues[ttl] if len(q) == 0 { return 0, false } head := q[0] if head.srcPort != srcPort { return 0, false } t.ttlQueues[ttl] = q[1:] return head.i, true } func (t *UDPTracer) enqueueTTLPort(ttl, i, srcPort int) { ap := attemptPort{srcPort: srcPort, i: i} t.ttlQMu.Lock() defer t.ttlQMu.Unlock() t.ttlQueues[ttl] = append(t.ttlQueues[ttl], ap) } func (t *UDPTracer) markPending(ttl, i int) { key := attemptKey{ttl: ttl, i: i} t.pendingMu.Lock() defer t.pendingMu.Unlock() t.pending[key] = struct{}{} } func (t *UDPTracer) clearPending(ttl, i int) bool { key := attemptKey{ttl: ttl, i: i} t.pendingMu.Lock() defer t.pendingMu.Unlock() _, ok := t.pending[key] delete(t.pending, key) return ok } func (t *UDPTracer) storeSent(seq, ttl, i, srcPort int, start time.Time) { t.sentMu.Lock() defer t.sentMu.Unlock() if t.OSType != 1 { t.sentAt[seq] = sentInfo{srcPort: srcPort, start: start} } else { t.sentAt[seq] = sentInfo{ttl: ttl, i: i, srcPort: srcPort, start: start} } } func (t *UDPTracer) lookupSent(seq int) (ttl, i, srcPort int, start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() si, ok := t.sentAt[seq] if !ok { return 0, 0, 0, time.Time{}, false } return si.ttl, si.i, si.srcPort, si.start, true } func (t *UDPTracer) dropSent(seq int) { t.sentMu.Lock() defer t.sentMu.Unlock() delete(t.sentAt, seq) } func (t *UDPTracer) dropByAttempt(ttl, i int) { t.sentMu.Lock() defer t.sentMu.Unlock() for k, si := range t.sentAt { if si.ttl == ttl && si.i == i { delete(t.sentAt, k) return } } } func (t *UDPTracer) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) { if f := t.final.Load(); f != -1 && ttl > int(f) { return } if ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) { for { old := t.final.Load() if old != -1 && ttl >= int(old) { break } if t.final.CompareAndSwap(old, int32(ttl)) { break } } } h := Hop{ Success: true, Address: peer, TTL: ttl, RTT: rtt, MPLS: mpls, } t.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config) } func (t *UDPTracer) matchWorker(ctx context.Context) { defer t.wg.Done() for { select { case <-ctx.Done(): return case task, ok := <-t.matchQ: if !ok { return } // 固定等待 10ms,缓解登记竞态 timer := time.NewTimer(10 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } timer.Stop() // 尝试一次匹配 ttl, i, srcPort, start, ok := t.lookupSent(task.seq) if !ok { continue } if task.srcPort != srcPort { continue } if t.OSType != 1 { // 将 task.seq 转为 16 位无符号数 u := uint16(task.seq) // 高 8 位是 TTL ttl = int((u >> 8) & 0xFF) // 低 8 位是索引 i i = int(u & 0xFF) } if t.clearPending(ttl, i) { rtt := task.finish.Sub(start) t.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls) } t.dropSent(task.seq) } } } func (t *UDPTracer) Execute() (res *Result, err error) { // 初始化 ttlQueues、pending、sentAt 和 matchQ t.ttlQueues = make(map[int][]attemptPort) t.pending = make(map[attemptKey]struct{}) t.sentAt = make(map[int]sentInfo) t.matchQ = make(chan matchTask, 60) // 创建就绪通道 t.readyOut = make(chan struct{}) t.readyICMP = make(chan struct{}) t.readyUDP = make(chan struct{}) if len(t.res.Hops) > 0 { return &t.res, errTracerouteExecuted } // 初始化 res.Hops 和 res.tailDone,并预分配到 MaxHops t.res.Hops = make([][]Hop, t.MaxHops) t.res.tailDone = make([]bool, t.MaxHops) t.res.setGeoWait(t.NumMeasurements) // 解析并校验用户指定的 IPv4 源地址 SrcAddr := net.ParseIP(t.SrcAddr).To4() if t.SrcAddr != "" && SrcAddr == nil { return nil, errors.New("invalid IPv4 SrcAddr:" + t.SrcAddr) } t.SrcIP, _ = util.LocalIPPort(t.DstIP, SrcAddr, "udp") if t.SrcIP == nil { return nil, errors.New("cannot determine local IPv4 address") } s := internal.NewUDPSpec( 4, t.ICMPMode, t.SrcIP, t.DstIP, t.DstPort, ) s.SourceDevice = t.SourceDevice s.InitICMP() s.InitUDP() defer s.Close() baseCtx := t.Context if baseCtx == nil { baseCtx = context.Background() } sigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM) ctx, cancel := context.WithCancelCause(sigCtx) t.final.Store(-1) workerN := 16 for i := 0; i < workerN; i++ { t.wg.Add(1) go t.matchWorker(ctx) } if t.OSType == 1 { t.wg.Add(1) go func() { defer t.wg.Done() s.ListenOut(ctx, t.readyOut, func(srcPort, seq, ttl int, start time.Time) { // 严格按队列头端口匹配;不匹配就丢弃,避免混入其它进程/杂包 i, ok := t.tryMatchTTLPort(ttl, srcPort) if !ok { return } t.storeSent(seq, ttl, i, srcPort, start) }) }() } else { close(t.readyOut) } t.wg.Add(1) go func() { defer t.wg.Done() s.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) { t.handleICMPMessage(msg, finish, data) }, ) }() t.waitAllReady(ctx) t.wg.Add(1) go t.PrintFunc(ctx, cancel) t.sem = semaphore.NewWeighted(int64(t.ParallelRequests)) t.wg.Add(1) go func() { defer t.wg.Done() // 立即启动 BeginHop 对应的 TTL 组 t.launchTTL(ctx, s, t.BeginHop) for ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ { // 之后按 TTLInterval 周期启动后续 TTL 组 if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) { return } // 如果到达最终跳,则退出 if f := t.final.Load(); f != -1 && ttl > int(f) { return } // 并发启动这个 TTL 的所有测量 t.launchTTL(ctx, s, ttl) } }() <-ctx.Done() stop() t.wg.Wait() final := int(t.final.Load()) if final == -1 { final = t.MaxHops } t.res.reduce(final) if cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) { return &t.res, cause } return &t.res, nil } func (t *UDPTracer) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) { mpls := extractMPLS(msg, t.DisableMPLS) seq, err := util.GetUDPSeq(data) if err != nil { return } header, err := util.GetICMPResponsePayload(data) if err != nil { return } srcPort, dstPort, err := util.GetUDPPorts(header) if err != nil { return } if dstPort != t.DstPort { return } // 非阻塞投递;如果队列已满则直接丢弃该任务 select { case t.matchQ <- matchTask{ srcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls, }: default: // 丢弃以避免阻塞抓包循环 } } func randomPayload(size int, offset int) []byte { payload := make([]byte, size) r := rand.New(rand.NewSource(time.Now().UnixNano())) for i := offset; i < size; i++ { payload[i] = byte(r.Intn(256)) } return payload } func (t *UDPTracer) acquireSendPermit(ctx context.Context, ttl int) (func(), bool, error) { if t.ttlComp(ttl) { return nil, true, nil } if err := acquireTraceSemaphore(ctx, t.sem); err != nil { return nil, false, err } release := func() { t.sem.Release(1) } if f := t.final.Load(); f != -1 && ttl > int(f) { release() return nil, true, nil } if t.ttlComp(ttl) { release() return nil, true, nil } return release, false, nil } func (t *UDPTracer) resolveSourcePort() int { if !util.RandomPortEnabled() && t.SrcPort > 0 { return t.SrcPort } _, srcPort := util.LocalIPPort(t.DstIP, t.SrcIP, "udp") return srcPort } func (t *UDPTracer) buildUDPPacket(ttl, i, srcPort int) (int, *layers.IPv4, *layers.UDP, []byte) { seq := (ttl << 8) | (i & 0xFF) payloadSize := resolveProbePayloadSize(UDPTrace, t.DstIP, t.PktSize, t.RandomPacketSize) ipHeader := &layers.IPv4{ Version: 4, Id: uint16(seq), SrcIP: t.SrcIP, DstIP: t.DstIP, Protocol: layers.IPProtocolUDP, TTL: uint8(ttl), TOS: uint8(t.TOS), } udpHeader := &layers.UDP{ SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(t.DstPort), } return seq, ipHeader, udpHeader, randomPayload(payloadSize, 0) } func (t *UDPTracer) startSendTimeout(ctx context.Context, ttl, i, seq int) { t.markPending(ttl, i) go func(seq, ttl, i int) { if !waitForTraceDelay(ctx, t.Timeout) { _ = t.clearPending(ttl, i) return } if !t.clearPending(ttl, i) { return } if f := t.final.Load(); f != -1 && ttl > int(f) { return } if t.ttlComp(ttl) { return } h := Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, Error: errHopLimitTimeout, } _, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts) if t.OSType != 1 { t.dropSent(seq) return } t.dropByAttempt(ttl, i) }(seq, ttl, i) } func (t *UDPTracer) prepareDarwinSend(ttl, i, srcPort int) { if t.OSType == 1 { t.enqueueTTLPort(ttl, i, srcPort) } } func (t *UDPTracer) finalizeSent(seq, srcPort int, start time.Time) { if t.OSType != 1 { t.storeSent(seq, 0, 0, srcPort, start) } } func (t *UDPTracer) send(ctx context.Context, s *internal.UDPSpec, ttl, i int) error { defer t.wg.Done() release, skip, err := t.acquireSendPermit(ctx, ttl) if err != nil { return err } if skip { return nil } defer release() srcPort := t.resolveSourcePort() seq, ipHeader, udpHeader, payload := t.buildUDPPacket(ttl, i, srcPort) t.prepareDarwinSend(ttl, i, srcPort) t.startSendTimeout(ctx, ttl, i, seq) start, err := s.SendUDP(ctx, ipHeader, udpHeader, payload) if err != nil { _ = t.clearPending(ttl, i) return err } t.finalizeSent(seq, srcPort, start) return nil } ================================================ FILE: trace/udp_ipv6.go ================================================ package trace import ( "context" "errors" "fmt" "math/rand" "net" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" "github.com/google/gopacket/layers" "golang.org/x/sync/semaphore" "github.com/nxtrace/NTrace-core/trace/internal" "github.com/nxtrace/NTrace-core/util" ) type UDPTracerIPv6 struct { Config wg sync.WaitGroup res Result pending map[int]struct{} pendingMu sync.Mutex sentAt map[int]sentInfo sentMu sync.RWMutex SrcIP net.IP final atomic.Int32 sem *semaphore.Weighted matchQ chan matchTask readyICMP chan struct{} readyUDP chan struct{} } func (t *UDPTracerIPv6) waitAllReady(ctx context.Context) { timeout := time.After(5 * time.Second) waiting := 2 for waiting > 0 { select { case <-ctx.Done(): return case <-t.readyICMP: waiting-- case <-t.readyUDP: waiting-- case <-timeout: return } } <-time.After(100 * time.Millisecond) } func (t *UDPTracerIPv6) ttlComp(ttl int) bool { idx := ttl - 1 t.res.lock.RLock() defer t.res.lock.RUnlock() return idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements } func (t *UDPTracerIPv6) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) { defer t.wg.Done() ttl := t.BeginHop - 1 ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { if t.AsyncPrinter != nil { t.AsyncPrinter(&t.res) } // 接收的时候检查一下是不是 3 跳都齐了 if t.ttlComp(ttl + 1) { if t.RealtimePrinter != nil { t.res.waitGeo(ctx, ttl) t.RealtimePrinter(&t.res, ttl) } ttl++ if ttl == int(t.final.Load()) || ttl >= t.MaxHops { cancel(errNaturalDone) // 标记为“自然完成” return } } select { case <-ctx.Done(): return case <-ticker.C: } } } func (t *UDPTracerIPv6) launchTTL(ctx context.Context, s *internal.UDPSpec, ttl int) { go func(ttl int) { for i := 0; i < t.MaxAttempts; i++ { // 若此 TTL 已完成或 ctx 已取消,则不再发起新的尝试 if t.ttlComp(ttl) || ctx.Err() != nil { return } t.wg.Add(1) go func(ttl, i int) { if err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) { if util.EnvDevMode { panic(err) } fmt.Fprintf(os.Stderr, "send error (ttl=%d, attempt=%d): %v\n", ttl, i, err) } }(ttl, i) if i+1 == t.MaxAttempts { return } if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) { return } } }(ttl) } func (t *UDPTracerIPv6) markPending(seq int) { t.pendingMu.Lock() defer t.pendingMu.Unlock() t.pending[seq] = struct{}{} } func (t *UDPTracerIPv6) clearPending(seq int) bool { t.pendingMu.Lock() defer t.pendingMu.Unlock() _, ok := t.pending[seq] delete(t.pending, seq) return ok } func (t *UDPTracerIPv6) storeSent(seq, srcPort int, start time.Time) { t.sentMu.Lock() defer t.sentMu.Unlock() t.sentAt[seq] = sentInfo{srcPort: srcPort, start: start} } func (t *UDPTracerIPv6) lookupSent(seq int) (srcPort int, start time.Time, ok bool) { t.sentMu.RLock() defer t.sentMu.RUnlock() si, ok := t.sentAt[seq] if !ok { return 0, time.Time{}, false } return si.srcPort, si.start, true } func (t *UDPTracerIPv6) dropSent(seq int) { t.sentMu.Lock() defer t.sentMu.Unlock() delete(t.sentAt, seq) } func (t *UDPTracerIPv6) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) { if f := t.final.Load(); f != -1 && ttl > int(f) { return } if ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) { for { old := t.final.Load() if old != -1 && ttl >= int(old) { break } if t.final.CompareAndSwap(old, int32(ttl)) { break } } } h := Hop{ Success: true, Address: peer, TTL: ttl, RTT: rtt, MPLS: mpls, } t.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config) } func (t *UDPTracerIPv6) matchWorker(ctx context.Context) { defer t.wg.Done() for { select { case <-ctx.Done(): return case task, ok := <-t.matchQ: if !ok { return } // 固定等待 10ms,缓解登记竞态 timer := time.NewTimer(10 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } timer.Stop() // 尝试一次匹配 srcPort, start, ok := t.lookupSent(task.seq) if !ok { continue } if task.srcPort != srcPort { continue } // 将 task.seq 转为 16 位无符号数 u := uint16(task.seq) // 高 8 位是 TTL ttl := int((u >> 8) & 0xFF) // 低 8 位是索引 i i := int(u & 0xFF) if t.clearPending(task.seq) { rtt := task.finish.Sub(start) t.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls) } t.dropSent(task.seq) } } } func (t *UDPTracerIPv6) Execute() (res *Result, err error) { // 初始化 pending、sentAt 和 matchQ t.pending = make(map[int]struct{}) t.sentAt = make(map[int]sentInfo) t.matchQ = make(chan matchTask, 60) // 创建就绪通道 t.readyICMP = make(chan struct{}) t.readyUDP = make(chan struct{}) if len(t.res.Hops) > 0 { return &t.res, errTracerouteExecuted } // 初始化 res.Hops 和 res.tailDone,并预分配到 MaxHops t.res.Hops = make([][]Hop, t.MaxHops) t.res.tailDone = make([]bool, t.MaxHops) t.res.setGeoWait(t.NumMeasurements) // 解析并校验用户指定的 IPv6 源地址 SrcAddr := net.ParseIP(t.SrcAddr) if t.SrcAddr != "" && !util.IsIPv6(SrcAddr) { return nil, errors.New("invalid IPv6 SrcAddr: " + t.SrcAddr) } t.SrcIP, _ = util.LocalIPPortv6(t.DstIP, SrcAddr, "udp6") if t.SrcIP == nil { return nil, errors.New("cannot determine local IPv6 address") } s := internal.NewUDPSpec( 6, t.ICMPMode, t.SrcIP, t.DstIP, t.DstPort, ) s.SourceDevice = t.SourceDevice s.InitICMP() s.InitUDP() defer s.Close() baseCtx := t.Context if baseCtx == nil { baseCtx = context.Background() } sigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM) ctx, cancel := context.WithCancelCause(sigCtx) t.final.Store(-1) workerN := 16 for i := 0; i < workerN; i++ { t.wg.Add(1) go t.matchWorker(ctx) } t.wg.Add(1) go func() { defer t.wg.Done() s.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) { t.handleICMPMessage(msg, finish, data) }, ) }() t.waitAllReady(ctx) t.wg.Add(1) go t.PrintFunc(ctx, cancel) t.sem = semaphore.NewWeighted(int64(t.ParallelRequests)) t.wg.Add(1) go func() { defer t.wg.Done() // 立即启动 BeginHop 对应的 TTL 组 t.launchTTL(ctx, s, t.BeginHop) for ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ { // 之后按 TTLInterval 周期启动后续 TTL 组 if !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) { return } // 如果到达最终跳,则退出 if f := t.final.Load(); f != -1 && ttl > int(f) { return } // 并发启动这个 TTL 的所有测量 t.launchTTL(ctx, s, ttl) } }() <-ctx.Done() stop() t.wg.Wait() final := int(t.final.Load()) if final == -1 { final = t.MaxHops } t.res.reduce(final) if cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) { return &t.res, cause } return &t.res, nil } func (t *UDPTracerIPv6) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) { mpls := extractMPLS(msg, t.DisableMPLS) header, err := util.GetICMPResponsePayload(data) if err != nil { return } srcPort, dstPort, err := util.GetUDPPorts(header) if err != nil { return } if dstPort != t.DstPort { return } seq, err := util.GetUDPSeqv6(header) if err != nil { return } // 非阻塞投递;如果队列已满则直接丢弃该任务 select { case t.matchQ <- matchTask{ srcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls, }: default: // 丢弃以避免阻塞抓包循环 } } func (t *UDPTracerIPv6) send(ctx context.Context, s *internal.UDPSpec, ttl, i int) error { defer t.wg.Done() if t.ttlComp(ttl) { // 快路径短路:若该 TTL 已完成,直接返回避免竞争信号量与无谓发包 return nil } if err := acquireTraceSemaphore(ctx, t.sem); err != nil { return err } defer t.sem.Release(1) if f := t.final.Load(); f != -1 && ttl > int(f) { return nil } if t.ttlComp(ttl) { // 竞态兜底:获取信号量期间可能已完成,再次检查以避免冗余发包 return nil } // 将 TTL 编码到高 8 位;将索引 i 编码到低 8 位 seq := (ttl << 8) | (i & 0xFF) _, SrcPort := func() (net.IP, int) { if !util.RandomPortEnabled() && t.SrcPort > 0 { return nil, t.SrcPort } return util.LocalIPPortv6(t.DstIP, t.SrcIP, "udp6") }() ipHeader := &layers.IPv6{ Version: 6, SrcIP: t.SrcIP, DstIP: t.DstIP, NextHeader: layers.IPProtocolUDP, HopLimit: uint8(ttl), TrafficClass: uint8(t.TOS), } udpHeader := &layers.UDP{ SrcPort: layers.UDPPort(SrcPort), DstPort: layers.UDPPort(t.DstPort), } desiredPayloadSize := resolveProbePayloadSize(UDPTrace, t.DstIP, t.PktSize, t.RandomPacketSize) payload := make([]byte, desiredPayloadSize) // 设置随机种子 r := rand.New(rand.NewSource(time.Now().UnixNano())) for k := 2; k < desiredPayloadSize; k++ { payload[k] = byte(r.Intn(256)) } // 通过 payload[0:2] 补偿,使 UDP.Checksum 精确等于 seq if err := util.MakePayloadWithTargetChecksum(payload, t.SrcIP, t.DstIP, SrcPort, t.DstPort, uint16(seq)); err != nil { return err } // 登记 pending,并启动超时守护 t.markPending(seq) go func(seq, ttl, i int) { if !waitForTraceDelay(ctx, t.Timeout) { _ = t.clearPending(seq) return } if !t.clearPending(seq) { return } if f := t.final.Load(); f != -1 && ttl > int(f) { return } if t.ttlComp(ttl) { return } h := Hop{ Success: false, Address: nil, TTL: ttl, RTT: 0, Error: errHopLimitTimeout, } _, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts) t.dropSent(seq) }(seq, ttl, i) start, err := s.SendUDP(ctx, ipHeader, udpHeader, payload) if err != nil { _ = t.clearPending(seq) return err } t.storeSent(seq, SrcPort, start) return nil } ================================================ FILE: tracelog/log.go ================================================ package tracelog import ( "fmt" "io" "net" "os" "path/filepath" "strings" "github.com/nxtrace/NTrace-core/internal/hoprender" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) var DefaultPath = filepath.Join(os.TempDir(), "trace.log") func formatTraceLogWhois(whois string) string { whoisFormat := strings.Split(whois, "-") if len(whoisFormat) > 1 { whoisFormat[0] = strings.Join(whoisFormat[:2], "-") } if whoisFormat[0] == "" { return "" } return "[" + whoisFormat[0] + "]" } func traceLogLocationLine(hop *trace.Hop, ip string) string { if hop.Geo.Country == "" { hop.Geo.Country = "LAN Address" } format := " %s %s %s %s %-6s\n %-39s " if net.ParseIP(ip).To4() == nil { format = " %s %s %s %s %-6s\n %-35s " } return fmt.Sprintf(format, hop.Geo.Country, hop.Geo.Prov, hop.Geo.City, hop.Geo.District, hop.Geo.Owner, hop.Hostname) } func traceLogTimingLine(values []string) string { return strings.Join(values, "/ ") } func OpenFile(path string) (*os.File, error) { return os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) } func WriteHeader(w io.Writer, header string) error { if header == "" { return nil } _, err := io.WriteString(w, header) return err } func renderTraceLogLine(res *trace.Result, ttl int, group hoprender.Group, blockDisplay bool) string { var builder strings.Builder if blockDisplay { builder.WriteString(fmt.Sprintf("%4s", "")) } ip := group.IP if net.ParseIP(ip).To4() == nil { builder.WriteString(fmt.Sprintf("%-25s ", ip)) } else { builder.WriteString(fmt.Sprintf("%-15s ", ip)) } hop := &res.Hops[ttl][group.Index] if hop.Geo == nil { hop.Geo = &ipgeo.IPGeoData{} } if hop.Geo.Asnumber != "" { builder.WriteString(fmt.Sprintf("AS%-7s", hop.Geo.Asnumber)) } else { builder.WriteString(fmt.Sprintf(" %-8s", "*")) } if net.ParseIP(ip).To4() != nil { builder.WriteString(fmt.Sprintf("%-16s", formatTraceLogWhois(hop.Geo.Whois))) } builder.WriteString(traceLogLocationLine(hop, ip)) builder.WriteString(traceLogTimingLine(group.Timings)) return builder.String() } func WriteRealtime(w io.Writer, res *trace.Result, ttl int) error { prefix := fmt.Sprintf("%-2d ", ttl+1) groups := hoprender.GroupHopAttempts(res.Hops[ttl]) if len(groups) == 0 { _, err := fmt.Fprintln(w, prefix+"*") return err } for i, group := range groups { line := renderTraceLogLine(res, ttl, group, i > 0) if i == 0 { line = prefix + line } if _, err := fmt.Fprintln(w, line); err != nil { return err } } return nil } func NewRealtimePrinter(w io.Writer) func(res *trace.Result, ttl int) { return func(res *trace.Result, ttl int) { _ = WriteRealtime(w, res, ttl) } } func RealtimePrinter(res *trace.Result, ttl int) { f, err := OpenFile(DefaultPath) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "open trace log %q failed: %v\n", DefaultPath, err) _ = WriteRealtime(os.Stdout, res, ttl) return } defer func() { _ = f.Close() }() w := io.MultiWriter(os.Stdout, f) _ = WriteRealtime(w, res, ttl) } ================================================ FILE: tracelog/log_test.go ================================================ package tracelog import ( "bytes" "io" "net" "os" "path/filepath" "strings" "testing" "time" "github.com/nxtrace/NTrace-core/ipgeo" "github.com/nxtrace/NTrace-core/trace" ) func testTraceLogResult() *trace.Result { return &trace.Result{ Hops: [][]trace.Hop{ { { TTL: 1, Address: &net.IPAddr{IP: net.ParseIP("192.0.2.1")}, Hostname: "router1", RTT: 12 * time.Millisecond, Geo: &ipgeo.IPGeoData{ Asnumber: "13335", Country: "中国香港", Owner: "Cloudflare", }, }, }, }, } } func TestWriteHeader(t *testing.T) { var buf bytes.Buffer if err := WriteHeader(&buf, "header\n"); err != nil { t.Fatalf("WriteHeader returned error: %v", err) } if got := buf.String(); got != "header\n" { t.Fatalf("header = %q, want %q", got, "header\n") } } func TestWriteRealtimeUsesProvidedWriter(t *testing.T) { var buf bytes.Buffer if err := WriteRealtime(&buf, testTraceLogResult(), 0); err != nil { t.Fatalf("WriteRealtime returned error: %v", err) } output := buf.String() for _, want := range []string{"1", "192.0.2.1", "AS13335", "Cloudflare", "12.00 ms"} { if !strings.Contains(output, want) { t.Fatalf("output missing %q:\n%q", want, output) } } } func TestNewRealtimePrinterWrapsWriter(t *testing.T) { var buf bytes.Buffer printer := NewRealtimePrinter(&buf) printer(testTraceLogResult(), 0) if buf.Len() == 0 { t.Fatal("expected writer to receive trace output") } } func captureStdIO(t *testing.T, fn func()) (string, string) { t.Helper() oldStdout := os.Stdout oldStderr := os.Stderr stdoutR, stdoutW, err := os.Pipe() if err != nil { t.Fatalf("stdout pipe: %v", err) } stderrR, stderrW, err := os.Pipe() if err != nil { t.Fatalf("stderr pipe: %v", err) } os.Stdout = stdoutW os.Stderr = stderrW defer func() { os.Stdout = oldStdout os.Stderr = oldStderr }() fn() _ = stdoutW.Close() _ = stderrW.Close() stdoutBytes, err := io.ReadAll(stdoutR) if err != nil { t.Fatalf("read stdout: %v", err) } stderrBytes, err := io.ReadAll(stderrR) if err != nil { t.Fatalf("read stderr: %v", err) } return string(stdoutBytes), string(stderrBytes) } func TestDefaultPathUsesTempDir(t *testing.T) { want := filepath.Join(os.TempDir(), "trace.log") if DefaultPath != want { t.Fatalf("DefaultPath = %q, want %q", DefaultPath, want) } } func TestRealtimePrinterFallsBackToStdoutWhenOpenFails(t *testing.T) { oldDefaultPath := DefaultPath DefaultPath = t.TempDir() defer func() { DefaultPath = oldDefaultPath }() stdout, stderr := captureStdIO(t, func() { RealtimePrinter(testTraceLogResult(), 0) }) if !strings.Contains(stdout, "192.0.2.1") { t.Fatalf("stdout missing realtime output:\n%q", stdout) } if !strings.Contains(stderr, "open trace log") { t.Fatalf("stderr missing open failure:\n%q", stderr) } } ================================================ FILE: tracemap/tracemap.go ================================================ package tracemap import ( "context" "crypto/tls" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/util" ) var getFastIPWithContextFn = util.GetFastIPWithContext var traceMapHTTPClientFn = newTraceMapHTTPClient func GetMapUrl(r string) (string, error) { return GetMapUrlWithContext(context.Background(), r) } func GetMapUrlWithContext(ctx context.Context, r string) (string, error) { if ctx == nil { ctx = context.Background() } host, port := util.GetHostAndPort() var fastIp string // 如果 host 是一个 IP 使用默认域名 if valid := net.ParseIP(host); valid != nil { fastIp = host if len(strings.Split(fastIp, ":")) > 1 { fastIp = "[" + fastIp + "]" } host = "api.nxtrace.org" } else { // 默认配置完成,开始寻找最优 IP var err error fastIp, err = getFastIPWithContextFn(ctx, host, port, false) if err != nil { return "", err } } u := url.URL{Scheme: "https", Host: fastIp + ":" + port, Path: "/tracemap/api"} tracemapUrl := u.String() client := traceMapHTTPClientFn(host) proxyUrl := util.GetProxy() if proxyUrl != nil { if t, ok := client.Transport.(*http.Transport); ok { t.Proxy = http.ProxyURL(proxyUrl) } } req, err := http.NewRequestWithContext(ctx, "POST", tracemapUrl, strings.NewReader(r)) if err != nil { return "", errors.New("an issue occurred while connecting to the tracemap API") } req.Header.Add("User-Agent", util.UserAgent) req.Host = host req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return "", err } return "", errors.New("an issue occurred while connecting to the tracemap API") } defer func(Body io.ReadCloser) { err := Body.Close() if err != nil { return } }(resp.Body) body, err := io.ReadAll(resp.Body) if err != nil { return "", errors.New("an issue occurred while connecting to the tracemap API") } return string(body), nil } func newTraceMapHTTPClient(host string) *http.Client { return &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ ServerName: host, }, }, } } func PrintMapUrl(r string) { _, err := fmt.Fprintf(color.Output, "%s %s\n", color.New(color.FgWhite, color.Bold).Sprintf("%s", "MapTrace URL:"), color.New(color.FgBlue, color.Bold).Sprintf("%s", r), ) if err != nil { return } } ================================================ FILE: tracemap/tracemap_test.go ================================================ package tracemap import ( "context" "errors" "net/http" "testing" "time" "github.com/nxtrace/NTrace-core/util" ) type blockingRoundTripper struct{} func (blockingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { <-req.Context().Done() return nil, req.Context().Err() } func TestGetMapUrlWithContextReturnsCanceled(t *testing.T) { oldHostPort := util.EnvHostPort oldFastIPFn := getFastIPWithContextFn oldClientFn := traceMapHTTPClientFn defer func() { util.EnvHostPort = oldHostPort getFastIPWithContextFn = oldFastIPFn traceMapHTTPClientFn = oldClientFn }() util.EnvHostPort = "example.com:443" getFastIPWithContextFn = func(ctx context.Context, domain string, port string, enableOutput bool) (string, error) { return "127.0.0.1", nil } traceMapHTTPClientFn = func(host string) *http.Client { return &http.Client{Transport: blockingRoundTripper{}} } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := GetMapUrlWithContext(ctx, `{"hops":[]}`) done <- err }() cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("GetMapUrlWithContext error = %v, want context.Canceled", err) } case <-time.After(100 * time.Millisecond): t.Fatal("GetMapUrlWithContext did not return promptly after cancel") } } ================================================ FILE: util/common.go ================================================ package util import "net" func Dnspod() *net.Resolver { return newDoTResolver("dot.pub", "dot.pub:853") } func Aliyun() *net.Resolver { return newDoTResolver("dns.alidns.com", "dns.alidns.com:853") } func DNSSB() *net.Resolver { return newDoTResolver("45.11.45.11", "dot.sb:853") } func Cloudflare() *net.Resolver { return newDoTResolver("one.one.one.one", "one.one.one.one:853") } func Google() *net.Resolver { return newDoTResolver("dns.google", "dns.google:853") } ================================================ FILE: util/dns_resolver.go ================================================ package util import ( "context" "net" "sync" "time" ) // ────────────────────────────────────────────────── // Geo DNS Resolver —— 为 GeoIP API / LeoMoe FastIP // 提供统一的 DNS 解析策略层。 // // 策略:优先使用 DoT(--dot-server),DoT 失败时 // 自动回退系统 DNS(可用性优先)。 // ────────────────────────────────────────────────── var ( geoDotServer string // 当前 dot-server 选项(如 "dnssb") geoFallback bool = true // DoT 失败时是否回退系统 DNS geoMu sync.RWMutex geoApplyMu sync.Mutex geoScopeMu sync.Mutex geoScopeDot string geoScopePrev struct { dotServer string fallback bool } geoScopeDepth int // geoResolverOverride 允许测试注入自定义 resolver(仅测试用)。 // 非 nil 时 LookupHostForGeo 的 DoT 阶段使用该 resolver 替代 ResolverForDot 的结果。 geoResolverOverride *net.Resolver ) func setGeoResolverOverride(resolver *net.Resolver) { geoMu.Lock() defer geoMu.Unlock() geoResolverOverride = resolver } func getGeoResolverOverride() *net.Resolver { geoMu.RLock() defer geoMu.RUnlock() return geoResolverOverride } // SetGeoDNSResolver 设置 Geo 解析使用的 DoT 服务器名称。 // 空字符串表示仅使用系统 DNS。 func SetGeoDNSResolver(dotServer string) { geoMu.Lock() defer geoMu.Unlock() geoDotServer = dotServer } // SetGeoDNSFallback 设置 DoT 失败后是否回退系统 DNS,默认 true。 func SetGeoDNSFallback(enabled bool) { geoMu.Lock() defer geoMu.Unlock() geoFallback = enabled } // WithGeoDNSResolver 在 callback 生命周期内临时切换 Geo DNS resolver。 // 该辅助会串行化不同 resolver 的切换与恢复,并允许相同 resolver 作用域安全嵌套。 func WithGeoDNSResolver[T any](dotServer string, callback func() (T, error)) (T, error) { if callback == nil { var zero T return zero, nil } if dotServer == "" { return callback() } geoApplyMu.Lock() if geoScopeDepth > 0 && geoScopeDot == dotServer { geoScopeDepth++ geoApplyMu.Unlock() defer releaseGeoDNSResolverScope() return callback() } geoApplyMu.Unlock() geoScopeMu.Lock() prevDotServer, prevFallback := getGeoDNSConfig() SetGeoDNSResolver(dotServer) geoApplyMu.Lock() geoScopeDot = dotServer geoScopePrev.dotServer = prevDotServer geoScopePrev.fallback = prevFallback geoScopeDepth = 1 geoApplyMu.Unlock() defer releaseGeoDNSResolverScope() return callback() } func releaseGeoDNSResolverScope() { geoApplyMu.Lock() if geoScopeDepth <= 0 { geoApplyMu.Unlock() return } geoScopeDepth-- if geoScopeDepth > 0 { geoApplyMu.Unlock() return } prevDotServer := geoScopePrev.dotServer prevFallback := geoScopePrev.fallback geoScopeDot = "" geoScopePrev.dotServer = "" geoScopePrev.fallback = true geoApplyMu.Unlock() SetGeoDNSResolver(prevDotServer) SetGeoDNSFallback(prevFallback) geoScopeMu.Unlock() } // getGeoDNSConfig 返回当前快照;并发安全。 func getGeoDNSConfig() (dotServer string, fallback bool) { geoMu.RLock() defer geoMu.RUnlock() return geoDotServer, geoFallback } // ResolverForDot 根据 dotServer 名字返回对应的 *net.Resolver。 // 空 / 未知名字返回 nil(表示"使用系统默认")。 func ResolverForDot(dotServer string) *net.Resolver { switch dotServer { case "dnssb": return DNSSB() case "aliyun": return Aliyun() case "dnspod": return Dnspod() case "google": return Google() case "cloudflare": return Cloudflare() default: return nil } } // LookupHostForGeo 执行"Geo 专用"DNS 查询。 // // 1. 如果 host 是 IP 字面量,直接返回,不做 DNS 查询。 // 2. 若配置了 DoT,优先用 DoT 解析。 // 3. DoT 失败且 fallback=true 时,回退系统 DNS。 // 4. 全部失败才返回 error。 func LookupHostForGeo(ctx context.Context, host string) ([]net.IP, error) { // ── 1. IP 字面量短路 ── if ip := net.ParseIP(host); ip != nil { return []net.IP{ip}, nil } dotServer, fallback := getGeoDNSConfig() // ── 2. DoT 解析 ── r := ResolverForDot(dotServer) if override := getGeoResolverOverride(); override != nil { r = override } if r != nil { ips, err := resolveHost(ctx, r, host) if err == nil && len(ips) > 0 { return ips, nil } // DoT 失败,决定是否 fallback if !fallback { if err != nil { return nil, err } return nil, &net.DNSError{ Err: "no addresses found via DoT", Name: host, } } // 继续到 fallback } // ── 3. Fallback: 系统 DNS ── return resolveHost(ctx, net.DefaultResolver, host) } // resolveHost 用给定的 resolver 解析 host,返回 []net.IP。 func resolveHost(ctx context.Context, r *net.Resolver, host string) ([]net.IP, error) { // 使用较短的独立超时,避免阻塞调用方 child, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() addrs, err := r.LookupHost(child, host) if err != nil { return nil, err } var ips []net.IP for _, a := range addrs { if ip := net.ParseIP(a); ip != nil { ips = append(ips, ip) } } if len(ips) == 0 { return nil, &net.DNSError{ Err: "no addresses found", Name: host, } } return ips, nil } ================================================ FILE: util/dns_resolver_test.go ================================================ package util import ( "context" "net" "testing" "time" ) // ── ResolverForDot 映射 ───────────────────────────── func TestResolverMapping(t *testing.T) { known := []string{"dnssb", "aliyun", "dnspod", "google", "cloudflare"} for _, name := range known { r := ResolverForDot(name) if r == nil { t.Fatalf("ResolverForDot(%q) returned nil, want non-nil", name) } // 确认是自定义 dialer(PreferGo = true 且有 Dial) if !r.PreferGo { t.Errorf("ResolverForDot(%q).PreferGo = false, want true", name) } } // 空字符串 / 未知值返回 nil(表示系统默认) for _, name := range []string{"", "unknown", "xxx"} { if r := ResolverForDot(name); r != nil { t.Errorf("ResolverForDot(%q) = %v, want nil", name, r) } } } // ── IP 字面量短路 ──────────────────────────────────── func TestLookupHostForGeo_IPLiteral(t *testing.T) { // 无论 DoT 配置为何,IP 字面量应直接返回,不触发 DNS 查询。 SetGeoDNSResolver("dnssb") defer SetGeoDNSResolver("") cases := []string{"1.1.1.1", "::1", "2001:db8::1", "192.168.0.1"} for _, addr := range cases { ips, err := LookupHostForGeo(context.Background(), addr) if err != nil { t.Errorf("LookupHostForGeo(%q) err = %v, want nil", addr, err) continue } if len(ips) != 1 || ips[0].String() != net.ParseIP(addr).String() { t.Errorf("LookupHostForGeo(%q) = %v, want [%s]", addr, ips, addr) } } } // ── DoT 成功时不走 fallback ────────────────────────── func TestLookupHostForGeo_DoTSuccess(t *testing.T) { // 使用 Cloudflare DoT 解析一个可靠域名 SetGeoDNSResolver("cloudflare") SetGeoDNSFallback(true) defer func() { SetGeoDNSResolver("") SetGeoDNSFallback(true) }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() ips, err := LookupHostForGeo(ctx, "one.one.one.one") if err != nil { t.Skipf("DoT lookup failed (network issue?): %v", err) } if len(ips) == 0 { t.Error("expected at least 1 IP, got 0") } } // ── 未配置 DoT 时直接走系统 DNS ────────────────────── func TestLookupHostForGeo_NoDotFallsToSystem(t *testing.T) { // dotServer 为空 → ResolverForDot 返回 nil → 直接走系统 DNS。 SetGeoDNSResolver("") SetGeoDNSFallback(true) defer func() { SetGeoDNSResolver("") SetGeoDNSFallback(true) }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() ips, err := LookupHostForGeo(ctx, "one.one.one.one") if err != nil { t.Skipf("System DNS lookup failed (network issue?): %v", err) } if len(ips) == 0 { t.Error("expected at least 1 IP, got 0") } } // ── DoT 失败后回退系统 DNS ─────────────────────────── func TestLookupHostForGeo_DoTFailFallback(t *testing.T) { // 注入一个必定失败的 resolver(连接不可达地址), // 验证 fallback=true 时能回退到系统 DNS 成功解析。 badResolver := &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { // 拨向 RFC 5737 文档专用地址,必定失败 return net.DialTimeout("tcp", "192.0.2.1:853", 200*time.Millisecond) }, } SetGeoDNSResolver("cloudflare") // 需要非空,ResolverForDot 会被 override 覆盖 SetGeoDNSFallback(true) setGeoResolverOverride(badResolver) defer func() { setGeoResolverOverride(nil) SetGeoDNSResolver("") SetGeoDNSFallback(true) }() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() ips, err := LookupHostForGeo(ctx, "one.one.one.one") if err != nil { t.Skipf("System DNS fallback also failed (network issue?): %v", err) } if len(ips) == 0 { t.Error("expected at least 1 IP from system DNS fallback, got 0") } } // ── DoT 失败且 fallback=false 时返回错误 ───────────── func TestLookupHostForGeo_DoTFailNoFallback(t *testing.T) { badResolver := &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { return net.DialTimeout("tcp", "192.0.2.1:853", 200*time.Millisecond) }, } SetGeoDNSResolver("cloudflare") SetGeoDNSFallback(false) setGeoResolverOverride(badResolver) defer func() { setGeoResolverOverride(nil) SetGeoDNSResolver("") SetGeoDNSFallback(true) }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, err := LookupHostForGeo(ctx, "one.one.one.one") if err == nil { t.Error("expected error when DoT fails and fallback=false, got nil") } } // ── SetGeoDNSResolver / SetGeoDNSFallback 并发安全 ── func TestGeoDNSConfig_ConcurrentAccess(t *testing.T) { done := make(chan struct{}) go func() { for i := 0; i < 1000; i++ { SetGeoDNSResolver("google") SetGeoDNSFallback(false) } close(done) }() for i := 0; i < 1000; i++ { _, _ = getGeoDNSConfig() } <-done // 无 data race = 通过 } func TestWithGeoDNSResolver_RestoresPreviousConfig(t *testing.T) { SetGeoDNSResolver("google") SetGeoDNSFallback(false) defer func() { SetGeoDNSResolver("") SetGeoDNSFallback(true) }() seenDot := "" seenFallback := true got, err := WithGeoDNSResolver("cloudflare", func() (string, error) { seenDot, seenFallback = getGeoDNSConfig() return "ok", nil }) if err != nil { t.Fatalf("WithGeoDNSResolver returned error: %v", err) } if got != "ok" { t.Fatalf("WithGeoDNSResolver result = %q, want ok", got) } if seenDot != "cloudflare" { t.Fatalf("callback saw dot resolver %q, want cloudflare", seenDot) } if seenFallback { t.Fatalf("callback saw fallback=true, want inherited false") } dot, fallback := getGeoDNSConfig() if dot != "google" || fallback { t.Fatalf("resolver restored to (%q, %t), want (%q, %t)", dot, fallback, "google", false) } } func TestWithGeoDNSResolver_AllowsNestedSameResolver(t *testing.T) { SetGeoDNSResolver("google") SetGeoDNSFallback(false) defer func() { SetGeoDNSResolver("") SetGeoDNSFallback(true) }() done := make(chan struct{}) var ( got string err error outerDot string outerFallback bool nestedDot string nestedFallback bool ) go func() { defer close(done) got, err = WithGeoDNSResolver("cloudflare", func() (string, error) { outerDot, outerFallback = getGeoDNSConfig() return WithGeoDNSResolver("cloudflare", func() (string, error) { nestedDot, nestedFallback = getGeoDNSConfig() return "nested", nil }) }) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("nested WithGeoDNSResolver call deadlocked") } if err != nil { t.Fatalf("nested WithGeoDNSResolver returned error: %v", err) } if got != "nested" { t.Fatalf("nested WithGeoDNSResolver result = %q, want nested", got) } if outerDot != "cloudflare" || outerFallback { t.Fatalf("outer callback saw (%q, %t), want (%q, %t)", outerDot, outerFallback, "cloudflare", false) } if nestedDot != "cloudflare" || nestedFallback { t.Fatalf("nested callback saw (%q, %t), want (%q, %t)", nestedDot, nestedFallback, "cloudflare", false) } dot, fallback := getGeoDNSConfig() if dot != "google" || fallback { t.Fatalf("resolver restored to (%q, %t), want (%q, %t)", dot, fallback, "google", false) } } ================================================ FILE: util/dot.go ================================================ package util import ( "context" "crypto/tls" "net" "time" ) func newDoTResolver(serverName string, addrs string) *net.Resolver { d := &net.Dialer{ // 设置超时时间 Timeout: 1000 * time.Millisecond, } tlsConfig := &tls.Config{ // 设置 TLS Server Name 以确保证书能和域名对应 ServerName: serverName, } return &net.Resolver{ // 指定使用 Go Build-in 的 DNS Resolver 来解析 PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { conn, err := d.DialContext(ctx, "tcp", addrs) if err != nil { return nil, err } return tls.Client(conn, tlsConfig), nil }, } } ================================================ FILE: util/env.go ================================================ package util import ( "fmt" "os" "strconv" "strings" ) var ( DisableMPLS = GetEnvBool("NEXTTRACE_DISABLEMPLS", false) EnableHidDstIP = GetEnvBool("NEXTTRACE_ENABLEHIDDENDSTIP", false) EnvDevMode = GetEnvBool("NEXTTRACE_DEVMODE", false) EnvRandomPort = GetEnvBool("NEXTTRACE_RANDOMPORT", false) Uninterrupted = GetEnvBool("NEXTTRACE_UNINTERRUPTED", false) EnvProxyURL = GetEnvDefault("NEXTTRACE_PROXY", "") EnvToken = GetEnvDefault("NEXTTRACE_TOKEN", "") EnvDataProvider = GetEnvDefault("NEXTTRACE_DATAPROVIDER", "") EnvHostPort = GetEnvDefault("NEXTTRACE_HOSTPORT", "api.nxtrace.org") EnvPowProvider = GetEnvDefault("NEXTTRACE_POWPROVIDER", "api.nxtrace.org") EnvDeployAddr = GetEnvDefault("NEXTTRACE_DEPLOY_ADDR", "") EnvMaxAttempts = GetEnvInt("NEXTTRACE_MAXATTEMPTS", 0) EnvICMPMode = GetEnvInt("NEXTTRACE_ICMPMODE", 0) GlobalpingToken = GetEnvDefault("GLOBALPING_TOKEN", "") ) const EnvAllowCrossOriginKey = "NEXTTRACE_ALLOW_CROSS_ORIGIN" func GetEnvTrimmed(key string) (string, bool) { v, ok := os.LookupEnv(key) if !ok { return "", false } val := strings.TrimSpace(v) if os.Getenv("NEXTTRACE_DEBUG") != "" { fmt.Println("ENV", key, "detected as", val) } return val, true } func GetEnvBool(key string, def bool) bool { if val, ok := GetEnvTrimmed(key); ok { switch val { case "1": return true case "0": return false default: return def } } return def } func GetEnvDefault(key string, def string) string { if val, ok := GetEnvTrimmed(key); ok { return val } return def } func GetEnvInt(key string, def int) int { if val, ok := GetEnvTrimmed(key); ok { num, err := strconv.Atoi(val) if err != nil { return def } return num } return def } func AllowCrossOriginBrowserAccess() bool { return GetEnvBool(EnvAllowCrossOriginKey, false) } ================================================ FILE: util/env_test.go ================================================ package util import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetEnvTrimmed(t *testing.T) { t.Setenv("TEST_TRIMMED_KEY", " value ") val, ok := GetEnvTrimmed("TEST_TRIMMED_KEY") assert.True(t, ok) assert.Equal(t, "value", val) _, ok = GetEnvTrimmed("TEST_TRIMMED_MISSING") assert.False(t, ok) } func TestGetEnvBool(t *testing.T) { t.Setenv("TEST_BOOL_TRUE", "1") assert.True(t, GetEnvBool("TEST_BOOL_TRUE", false)) t.Setenv("TEST_BOOL_FALSE", "0") assert.False(t, GetEnvBool("TEST_BOOL_FALSE", true)) t.Setenv("TEST_BOOL_INVALID", "maybe") assert.True(t, GetEnvBool("TEST_BOOL_INVALID", true)) } func TestGetEnvDefault(t *testing.T) { t.Setenv("TEST_DEFAULT_KEY", " custom ") assert.Equal(t, "custom", GetEnvDefault("TEST_DEFAULT_KEY", "fallback")) assert.Equal(t, "fallback", GetEnvDefault("TEST_DEFAULT_MISSING", "fallback")) } func TestGetEnvInt(t *testing.T) { t.Setenv("TEST_INT_VALID", " 42 ") assert.Equal(t, 42, GetEnvInt("TEST_INT_VALID", 7)) t.Setenv("TEST_INT_INVALID", "NaN") assert.Equal(t, 5, GetEnvInt("TEST_INT_INVALID", 5)) assert.Equal(t, 9, GetEnvInt("TEST_INT_MISSING", 9)) } func TestAllowCrossOriginBrowserAccess(t *testing.T) { t.Setenv(EnvAllowCrossOriginKey, "1") assert.True(t, AllowCrossOriginBrowserAccess()) t.Setenv(EnvAllowCrossOriginKey, "0") assert.False(t, AllowCrossOriginBrowserAccess()) } ================================================ FILE: util/frag.go ================================================ package util import ( "errors" "net" "golang.org/x/net/ipv4" ) type IPv4Fragment struct { Hdr ipv4.Header Body []byte } func getNamedDeviceMTU(srcDev string) (int, bool) { if srcDev == "" { return 0, false } if ifi, err := net.InterfaceByName(srcDev); err == nil && ifi != nil { return ifi.MTU, true } return 0, false } func addressIP(addr net.Addr) net.IP { switch value := addr.(type) { case *net.IPNet: return value.IP case *net.IPAddr: return value.IP default: return nil } } func matchInterfaceIP(candidate, target net.IP, isIPv6 bool) bool { if candidate == nil { return false } if isIPv6 { normalized := candidate.To16() return normalized != nil && IsIPv6(candidate) && normalized.Equal(target) } normalized := candidate.To4() return normalized != nil && normalized.Equal(target) } // GetMTUByIPForDevice 根据给定 IPv4/IPv6 源地址返回所属网卡 MTU,优先使用指定网卡名。 func GetMTUByIPForDevice(srcIP net.IP, srcDev string) int { if mtu, ok := getNamedDeviceMTU(srcDev); ok { return mtu } is6 := IsIPv6(srcIP) var targetIP net.IP if is6 { targetIP = srcIP.To16() } else { targetIP = srcIP.To4() } ifaces, err := net.Interfaces() if err != nil { return 0 } for _, ifi := range ifaces { addrs, _ := ifi.Addrs() for _, a := range addrs { if matchInterfaceIP(addressIP(a), targetIP, is6) { return ifi.MTU } } } return 0 } // GetMTUByIP 保持旧调用点兼容,优先使用全局 SrcDev。 func GetMTUByIP(srcIP net.IP) int { return GetMTUByIPForDevice(srcIP, SrcDev) } // IPv4Fragmentize 将 base(IPv4 头)与 body(IPv4 负载:传输层头+数据)按 mtu 进行 IP 层分片 func IPv4Fragmentize(base *ipv4.Header, body []byte, mtu int) ([]IPv4Fragment, error) { // 低 13 位为分片偏移(单位 8 字节) const ipOffsetMask = 0x1FFF // 提取 IPv4 头长度 ihl ihl := base.Len // MTU 至少要容纳一个完整 IPv4 头 if mtu <= ihl { return nil, errors.New("IPv4Fragmentize: MTU too small (<= IHL)") } maxFragBody := mtu - ihl // 假如已经置位 DF=1,则直接报错并返回 nil if (base.Flags & ipv4.DontFragment) != 0 { return nil, errors.New("IPv4Fragmentize: DF set while fragmentation required") } // 非最后片的片内负载长度按 8 字节对齐 aligned := (maxFragBody / 8) * 8 // 预分配结果切片的容量 capacity := (len(body) + aligned - 1) / aligned frags := make([]IPv4Fragment, 0, capacity) // 按 aligned 切出所有的分片(最后片承载所有剩余字节) for off := 0; off < len(body); { more := off+aligned < len(body) fragLen := len(body) - off if more { fragLen = aligned } // 为每片拷贝出独立头部 h := *base h.Len = ihl h.TotalLen = ihl + fragLen // 先清除已有的 MF 标志 h.Flags &^= ipv4.MoreFragments // 写入分片偏移(仅低 13 位,单位 8 字节) h.FragOff &^= ipOffsetMask h.FragOff |= (off / 8) & ipOffsetMask // 非最后片,则置位 MF=1 到 Flags 字段中 if more { h.Flags |= ipv4.MoreFragments } // 置 0 使 Marshal 重算 IPv4 头校验和 h.Checksum = 0 frags = append(frags, IPv4Fragment{ Hdr: h, Body: body[off : off+fragLen], }) off += fragLen } return frags, nil } ================================================ FILE: util/http_client_geo.go ================================================ package util import ( "context" "fmt" "net" "net/http" "time" ) // NewGeoHTTPClient 返回一个使用 Geo DNS 解析策略的 *http.Client。 // // 内部 Transport.DialContext 会通过 LookupHostForGeo 解析目标 host, // 然后按 IP 拨号,保持请求 URL Host 不变(即 TLS SNI 不受影响)。 func NewGeoHTTPClient(timeout time.Duration) *http.Client { dialer := &net.Dialer{ Timeout: timeout, KeepAlive: 30 * time.Second, } transport := &http.Transport{} if base, ok := http.DefaultTransport.(*http.Transport); ok && base != nil { transport = base.Clone() } transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { // addr 可能不含端口号;原样拨号 return dialer.DialContext(ctx, network, addr) } // 用 Geo DNS 策略解析 host ips, err := LookupHostForGeo(ctx, host) if err != nil { return nil, err } // 依次尝试解析到的 IP,优先使用地址族匹配的 var lastErr error for _, ip := range ips { target := net.JoinHostPort(ip.String(), port) conn, dialErr := dialer.DialContext(ctx, network, target) if dialErr == nil { return conn, nil } lastErr = dialErr } if lastErr == nil { return nil, fmt.Errorf("geo DNS returned no IPs for host %q", host) } return nil, lastErr } return &http.Client{ Timeout: timeout, Transport: transport, } } ================================================ FILE: util/http_client_geo_test.go ================================================ package util import ( "net/http" "testing" "time" ) func TestNewGeoHTTPClient_ReturnsValidClient(t *testing.T) { c := NewGeoHTTPClient(3 * time.Second) if c == nil { t.Fatal("NewGeoHTTPClient returned nil") } if c.Timeout != 3*time.Second { t.Errorf("Timeout = %v, want 3s", c.Timeout) } } func TestNewGeoHTTPClient_HasCustomTransport(t *testing.T) { c := NewGeoHTTPClient(2 * time.Second) tr, ok := c.Transport.(*http.Transport) if !ok || tr == nil { t.Fatal("Transport is not *http.Transport or is nil") } if tr.DialContext == nil { t.Error("Transport.DialContext is nil, expected custom function") } } func TestNewGeoHTTPClient_DifferentTimeouts(t *testing.T) { for _, d := range []time.Duration{ 500 * time.Millisecond, 2 * time.Second, 10 * time.Second, } { c := NewGeoHTTPClient(d) if c.Timeout != d { t.Errorf("NewGeoHTTPClient(%v).Timeout = %v", d, c.Timeout) } } } ================================================ FILE: util/latency.go ================================================ package util import ( "context" "crypto/tls" "fmt" "io" "log" "net" "net/http" "strings" "sync" "time" "github.com/fatih/color" ) type ResponseInfo struct { IP string Latency string Content string } var ( timeout = 5 * time.Second fastIPCacheMu sync.RWMutex ) var FastIpCache = "" var ( fastIPLookupHostFn = LookupHostForGeo fastIPCheckLatency = checkLatencyWithContext ) // FastIPMeta 存储 FastIP 节点的结构化元数据。 type FastIPMeta struct { IP string // 节点 IP Latency string // 延迟(ms 字符串) NodeName string // 节点名称(API 返回的 Content 去除前后空白) } // FastIPMetaCache 缓存最近一次 FastIP 探测返回的节点元数据。 var FastIPMetaCache FastIPMeta // SuppressFastIPOutput 为 true 时,GetFastIP 即使 enableOutput=true 也不打印彩色输出。 // MTR 模式在进入备用屏前设置此标志,避免污染主终端历史。 var SuppressFastIPOutput bool func GetFastIP(domain string, port string, enableOutput bool) string { ip, err := GetFastIPWithContext(context.Background(), domain, port, enableOutput) if err != nil { log.Printf("FastIP probe failed: %v", err) return defaultFastIP() } return ip } func GetFastIPCache() string { fastIPCacheMu.RLock() defer fastIPCacheMu.RUnlock() return FastIpCache } func GetFastIPMetaCache() FastIPMeta { fastIPCacheMu.RLock() defer fastIPCacheMu.RUnlock() return FastIPMetaCache } func SetFastIPCacheState(ip string, meta FastIPMeta) { fastIPCacheMu.Lock() FastIpCache = ip FastIPMetaCache = meta fastIPCacheMu.Unlock() } func GetFastIPWithContext(ctx context.Context, domain string, port string, enableOutput bool) (string, error) { if ctx == nil { ctx = context.Background() } proxyUrl := GetProxy() if proxyUrl != nil { return "api.nxtrace.org", nil } if cachedIP := GetFastIPCache(); cachedIP != "" { return cachedIP, nil } var ips []net.IP var err error lookupCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() if domain == "api.nxtrace.org" { ips, err = fastIPLookupHostFn(lookupCtx, "api.nxtrace.org") } else { ips, err = fastIPLookupHostFn(lookupCtx, domain) } if err != nil { if lookupCtx.Err() != nil { return "", lookupCtx.Err() } log.Println("DNS resolution failed, please check your system DNS Settings") } if len(ips) == 0 { ips = defaultFastIPCandidates() } results := make(chan ResponseInfo, len(ips)) for _, ip := range ips { go fastIPCheckLatency(ctx, domain, ip.String(), port, results) } var result ResponseInfo select { case result = <-results: // 正常返回结果 case <-time.After(timeout): log.Println("IP connection has been timeout(5s), please check your network") case <-ctx.Done(): return "", ctx.Err() } //有些时候真的啥都不通,还是挑一个顶上吧 if result.IP == "" { result.IP = defaultFastIP() } meta := FastIPMeta{ IP: result.IP, Latency: result.Latency, NodeName: strings.TrimSpace(result.Content), } SetFastIPCacheState(result.IP, meta) if enableOutput && !SuppressFastIPOutput { _, _ = fmt.Fprintf(color.Output, "%s preferred API IP - %s - %s - %s", color.New(color.FgWhite, color.Bold).Sprintf("[NextTrace API]"), color.New(color.FgGreen, color.Bold).Sprintf("%s", result.IP), color.New(color.FgCyan, color.Bold).Sprintf("%sms", result.Latency), color.New(color.FgGreen, color.Bold).Sprintf("%s", result.Content), ) } return result.IP, nil } func defaultFastIPCandidates() []net.IP { return []net.IP{ net.ParseIP("45.88.195.154"), net.ParseIP("2605:52c0:2:954:114:514:1919:810"), } } func defaultFastIP() string { return "45.88.195.154" } func checkLatencyWithContext(ctx context.Context, domain string, ip string, port string, results chan<- ResponseInfo) { start := time.Now() if !strings.Contains(ip, ".") { ip = "[" + ip + "]" } // 自定义DialContext以使用指定的IP连接 transport := &http.Transport{ //DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { // return net.DialTimeout(network, addr, 1*time.Second) //}, TLSClientConfig: &tls.Config{ ServerName: domain, }, TLSHandshakeTimeout: timeout, } client := &http.Client{ Transport: transport, Timeout: timeout, } //此处虽然是 https://domain/ 但是实际上会使用指定的IP连接 req, err := http.NewRequestWithContext(ctx, "GET", "https://"+ip+":"+port+"/", nil) if err != nil { // !!! 此处不要给results返回任何值 //results <- ResponseInfo{IP: ip, Latency: "error", Content: ""} return } req.Host = domain req.Header.Add("User-Agent", UserAgent) resp, err := client.Do(req) if err != nil { //results <- ResponseInfo{IP: ip, Latency: "error", Content: ""} return } if resp == nil || resp.Body == nil { // 防止后续对 nil Body 的读写导致 panic return } defer func() { // 明确忽略关闭时的错误,HTTP 客户端此时已经读完正文 _ = resp.Body.Close() }() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { //results <- ResponseInfo{IP: ip, Latency: "error", Content: ""} return } bodyString := string(bodyBytes) latency := fmt.Sprintf("%.2f", float64(time.Since(start))/float64(time.Millisecond)) select { case results <- ResponseInfo{IP: ip, Latency: latency, Content: bodyString}: case <-ctx.Done(): default: } } ================================================ FILE: util/latency_test.go ================================================ package util import ( "context" "errors" "net" "testing" "time" ) func TestGetFastIPWithContextReturnsCanceled(t *testing.T) { oldLookup := fastIPLookupHostFn oldCheck := fastIPCheckLatency oldCache := GetFastIPCache() oldMeta := GetFastIPMetaCache() defer func() { fastIPLookupHostFn = oldLookup fastIPCheckLatency = oldCheck SetFastIPCacheState(oldCache, oldMeta) }() SetFastIPCacheState("", FastIPMeta{}) fastIPLookupHostFn = func(ctx context.Context, host string) ([]net.IP, error) { return []net.IP{net.ParseIP("1.1.1.1")}, nil } started := make(chan struct{}) fastIPCheckLatency = func(ctx context.Context, domain, ip, port string, results chan<- ResponseInfo) { close(started) <-ctx.Done() } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := GetFastIPWithContext(ctx, "example.com", "443", false) done <- err }() <-started cancel() select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("GetFastIPWithContext error = %v, want context.Canceled", err) } case <-time.After(100 * time.Millisecond): t.Fatal("GetFastIPWithContext did not return promptly after cancel") } } ================================================ FILE: util/pcap.go ================================================ //go:build darwin package util import ( "fmt" "net" "sync" "github.com/google/gopacket/pcap" ) var ( DevCache sync.Map // key: string(srcip) -> string(pcap device name) ) func ipKey(ip net.IP) string { if v4 := ip.To4(); v4 != nil { return v4.String() } return ip.String() } // PcapDeviceByIP 返回可用于 pcap.OpenLive 的设备名 func PcapDeviceByIP(srcip net.IP) (string, error) { key := ipKey(srcip) if v, ok := DevCache.Load(key); ok { return v.(string), nil } devs, err := pcap.FindAllDevs() if err != nil { return "", fmt.Errorf("pcap list devices: %w", err) } is6 := IsIPv6(srcip) var v4, v6 net.IP if is6 { v6 = srcip.To16() } else { v4 = srcip.To4() } // 按 IP 精确匹配 for _, d := range devs { for _, a := range d.Addresses { var got net.IP got = a.IP if got == nil { continue } if is6 { if g := got.To16(); g != nil && IsIPv6(got) && g.Equal(v6) { DevCache.Store(key, d.Name) return d.Name, nil } } else { if g := got.To4(); g != nil && g.Equal(v4) { DevCache.Store(key, d.Name) return d.Name, nil } } } } return "", fmt.Errorf("pcap device for IP %s not found", srcip) } // OpenLiveImmediate 打开一个启用“立即模式”的 pcap 句柄 func OpenLiveImmediate(dev string, snaplen int, promisc bool, bufferSize int) (*pcap.Handle, error) { // 创建一个未激活的句柄 ih, err := pcap.NewInactiveHandle(dev) if err != nil { return nil, err } defer func() { ih.CleanUp() }() if snaplen <= 0 { snaplen = 65535 } // 设置每个包的最大抓取长度 if err := ih.SetSnapLen(snaplen); err != nil { return nil, err } // 设置超时模式为 BlockForever,阻塞等待每包数据 if err := ih.SetTimeout(pcap.BlockForever); err != nil { return nil, err } // 开启“立即模式” if err := ih.SetImmediateMode(true); err != nil { return nil, err } // 开启“混杂模式”,以抓更多帧 if err := ih.SetPromisc(promisc); err != nil { return nil, err } // 设置内核缓冲区大小 if bufferSize > 0 { _ = ih.SetBufferSize(bufferSize) } // 激活:获得可读写的数据包句柄 h, err := ih.Activate() if err != nil { return nil, err } return h, nil } ================================================ FILE: util/privilege_stub.go ================================================ //go:build !windows package util func HasAdminPrivileges() bool { return true } ================================================ FILE: util/privilege_windows.go ================================================ //go:build windows package util import ( "unsafe" "golang.org/x/sys/windows" ) // HasAdminPrivileges reports whether the current Windows process is elevated. func HasAdminPrivileges() bool { var token windows.Token if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { return false } defer func() { _ = token.Close() }() type tokenElevation struct { TokenIsElevated uint32 } var elev tokenElevation var outLen uint32 if err := windows.GetTokenInformation( token, windows.TokenElevation, (*byte)(unsafe.Pointer(&elev)), uint32(unsafe.Sizeof(elev)), &outLen, ); err != nil { return false } return elev.TokenIsElevated != 0 } ================================================ FILE: util/trace.go ================================================ package util import ( "encoding/binary" "errors" ) func GetIPHeaderLength(data []byte) (int, error) { if len(data) < 1 { return 0, errors.New("received invalid IP header") } version := data[0] >> 4 switch version { case 4: ihl := int(data[0] & 0x0F) if ihl < 5 { return 0, errors.New("invalid IPv4 header length") } return ihl * 4, nil case 6: return 40, nil default: return 0, errors.New("unknown IP version") } } func extractIPv4Payload(data []byte, hdrLen int) ([]byte, error) { if len(data) < hdrLen { return nil, errors.New("inner IPv4 header too short") } return data[hdrLen:], nil } func nextIPv6PayloadOffset(data []byte, offset int, next byte) (int, byte, error) { switch next { case 0, 43, 60: if offset+2 > len(data) { return 0, 0, errors.New("IPv6 ext too short") } hdrExtLen := int(data[offset+1]) extLen := (hdrExtLen + 1) * 8 if offset+extLen > len(data) { return 0, 0, errors.New("IPv6 ext overflow") } return offset + extLen, data[offset], nil case 44: if offset+8 > len(data) { return 0, 0, errors.New("IPv6 frag too short") } return offset + 8, data[offset], nil case 51: if offset+2 > len(data) { return 0, 0, errors.New("IPv6 AH too short") } ahLen := int(data[offset+1]) extLen := (ahLen + 2) * 4 if offset+extLen > len(data) { return 0, 0, errors.New("IPv6 AH overflow") } return offset + extLen, data[offset], nil case 50: return 0, 0, errors.New("IPv6 ESP encountered; cannot locate upper-layer") default: return offset, next, nil } } func extractIPv6Payload(data []byte, hdrLen int) ([]byte, error) { if len(data) < hdrLen { return nil, errors.New("inner IPv6 header too short") } offset := hdrLen next := data[6] for { nextOffset, nextHeader, err := nextIPv6PayloadOffset(data, offset, next) if err != nil { return nil, err } if nextOffset == offset { if offset > len(data) { return nil, errors.New("IPv6 offset out of range") } return data[offset:], nil } offset = nextOffset next = nextHeader } } func GetICMPResponsePayload(data []byte) ([]byte, error) { if len(data) < 1 { return nil, errors.New("received invalid IP header") } version := data[0] >> 4 hdrLen, err := GetIPHeaderLength(data) if err != nil { return nil, err } switch version { case 4: return extractIPv4Payload(data, hdrLen) case 6: return extractIPv6Payload(data, hdrLen) default: return nil, errors.New("unknown IP version") } } func GetICMPID(data []byte) (int, error) { if len(data) < 6 { return 0, errors.New("length of icmp header too short for ID") } seqBytes := data[4:6] return int(binary.BigEndian.Uint16(seqBytes)), nil } func GetICMPSeq(data []byte) (int, error) { if len(data) < 8 { return 0, errors.New("length of icmp header too short for seq") } seqBytes := data[6:8] return int(binary.BigEndian.Uint16(seqBytes)), nil } func GetTCPPorts(data []byte) (int, int, error) { if len(data) < 4 { return 0, 0, errors.New("length of tcp header too short for ports") } srcPort := int(binary.BigEndian.Uint16(data[0:2])) dstPort := int(binary.BigEndian.Uint16(data[2:4])) return srcPort, dstPort, nil } func GetTCPSeq(data []byte) (int, error) { if len(data) < 8 { return 0, errors.New("length of tcp header too short for seq") } seqBytes := data[4:8] return int(binary.BigEndian.Uint32(seqBytes)), nil } func GetUDPPorts(data []byte) (int, int, error) { if len(data) < 4 { return 0, 0, errors.New("length of udp header too short for ports") } srcPort := int(binary.BigEndian.Uint16(data[0:2])) dstPort := int(binary.BigEndian.Uint16(data[2:4])) return srcPort, dstPort, nil } func GetUDPSeq(data []byte) (int, error) { if len(data) < 1 { return 0, errors.New("received invalid IPv4 header") } hdrLen, err := GetIPHeaderLength(data) if err != nil { return 0, err } if len(data) < hdrLen { return 0, errors.New("length of IPv4 header too short for seq") } seqBytes := data[4:6] return int(binary.BigEndian.Uint16(seqBytes)), nil } func GetUDPSeqv6(data []byte) (int, error) { if len(data) < 8 { return 0, errors.New("length of udp header too short for seq") } seqBytes := data[6:8] return int(binary.BigEndian.Uint16(seqBytes)), nil } ================================================ FILE: util/trace_privilege.go ================================================ package util type TracePrivilegeCheck struct { Message string Fatal bool } ================================================ FILE: util/trace_privilege_darwin.go ================================================ //go:build darwin package util // macOS 上旧逻辑不会因为权限检查阻断执行,这里保持同样语义。 func TracePrivilegeStatus(string, bool) TracePrivilegeCheck { return TracePrivilegeCheck{} } ================================================ FILE: util/trace_privilege_linux.go ================================================ //go:build linux package util import ( "fmt" "os" "github.com/syndtr/gocapability/capability" ) func TracePrivilegeStatus(appBinName string, _ bool) TracePrivilegeCheck { if os.Getuid() == 0 { return TracePrivilegeCheck{} } caps, err := capability.NewPid2(0) if err != nil { return TracePrivilegeCheck{Message: fmt.Sprintf("读取进程能力信息失败: %v", err)} } if err := caps.Load(); err != nil { return TracePrivilegeCheck{Message: fmt.Sprintf("加载进程能力信息失败: %v", err)} } if caps.Get(capability.EFFECTIVE, capability.CAP_NET_RAW) && caps.Get(capability.EFFECTIVE, capability.CAP_NET_ADMIN) { return TracePrivilegeCheck{} } return TracePrivilegeCheck{ Message: fmt.Sprintf( "您正在以普通用户权限运行 NextTrace,但 NextTrace 未被赋予监听网络套接字的ICMP消息包、修改IP头信息(TTL)等路由跟踪所需的权限\n"+ "请使用管理员用户执行 `sudo setcap cap_net_raw,cap_net_admin+eip ${your_nexttrace_path}/%s` 命令,赋予相关权限后再运行~\n"+ "什么?为什么 ping 普通用户执行不要 root 权限?因为这些工具在管理员安装时就已经被赋予了一些必要的权限,具体请使用 `getcap /usr/bin/ping` 查看", appBinName, ), } } ================================================ FILE: util/trace_privilege_stub.go ================================================ //go:build !linux && !darwin && !windows package util func TracePrivilegeStatus(string, bool) TracePrivilegeCheck { return TracePrivilegeCheck{} } ================================================ FILE: util/trace_privilege_windows.go ================================================ //go:build windows package util func TracePrivilegeStatus(_ string, requireWindowsAdmin bool) TracePrivilegeCheck { if !requireWindowsAdmin || HasAdminPrivileges() { return TracePrivilegeCheck{} } return TracePrivilegeCheck{ Message: "Windows 下 --mtu 需要管理员权限。当前实现依赖 WinDivert 或原始 ICMP 套接字;普通权限下无法可靠工作。请使用“以管理员身份运行”的终端重试。", Fatal: true, } } ================================================ FILE: util/trace_test.go ================================================ package util import ( "encoding/binary" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ──────── GetIPHeaderLength ──────── func TestGetIPHeaderLength_IPv4_MinIHL(t *testing.T) { // IHL=5 → 20 bytes data := []byte{0x45} // version=4, IHL=5 got, err := GetIPHeaderLength(data) require.NoError(t, err) assert.Equal(t, 20, got) } func TestGetIPHeaderLength_IPv4_WithOptions(t *testing.T) { // IHL=15 → 60 bytes (maximum) data := []byte{0x4F} got, err := GetIPHeaderLength(data) require.NoError(t, err) assert.Equal(t, 60, got) } func TestGetIPHeaderLength_IPv4_InvalidIHL(t *testing.T) { // IHL=3 < 5 → error data := []byte{0x43} _, err := GetIPHeaderLength(data) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid IPv4 header length") } func TestGetIPHeaderLength_IPv6(t *testing.T) { data := []byte{0x60} // version=6 got, err := GetIPHeaderLength(data) require.NoError(t, err) assert.Equal(t, 40, got) } func TestGetIPHeaderLength_UnknownVersion(t *testing.T) { data := []byte{0x30} // version=3 _, err := GetIPHeaderLength(data) assert.Error(t, err) assert.Contains(t, err.Error(), "unknown IP version") } func TestGetIPHeaderLength_Empty(t *testing.T) { _, err := GetIPHeaderLength(nil) assert.Error(t, err) } // ──────── GetICMPID / GetICMPSeq ──────── func TestGetICMPID_Valid(t *testing.T) { // ICMP header: type(1) code(1) checksum(2) ID(2) seq(2) data := make([]byte, 8) binary.BigEndian.PutUint16(data[4:6], 0x1234) // ID got, err := GetICMPID(data) require.NoError(t, err) assert.Equal(t, 0x1234, got) } func TestGetICMPID_TooShort(t *testing.T) { data := make([]byte, 5) _, err := GetICMPID(data) assert.Error(t, err) } func TestGetICMPSeq_Valid(t *testing.T) { data := make([]byte, 8) binary.BigEndian.PutUint16(data[6:8], 42) got, err := GetICMPSeq(data) require.NoError(t, err) assert.Equal(t, 42, got) } func TestGetICMPSeq_TooShort(t *testing.T) { data := make([]byte, 7) _, err := GetICMPSeq(data) assert.Error(t, err) } // ──────── GetTCPPorts / GetTCPSeq ──────── func TestGetTCPPorts_Valid(t *testing.T) { data := make([]byte, 8) binary.BigEndian.PutUint16(data[0:2], 12345) // src binary.BigEndian.PutUint16(data[2:4], 80) // dst src, dst, err := GetTCPPorts(data) require.NoError(t, err) assert.Equal(t, 12345, src) assert.Equal(t, 80, dst) } func TestGetTCPPorts_TooShort(t *testing.T) { data := make([]byte, 3) _, _, err := GetTCPPorts(data) assert.Error(t, err) } func TestGetTCPSeq_Valid(t *testing.T) { data := make([]byte, 8) binary.BigEndian.PutUint32(data[4:8], 0xDEADBEEF) got, err := GetTCPSeq(data) require.NoError(t, err) assert.Equal(t, int(uint32(0xDEADBEEF)), got) } func TestGetTCPSeq_TooShort(t *testing.T) { data := make([]byte, 7) _, err := GetTCPSeq(data) assert.Error(t, err) } // ──────── GetUDPPorts / GetUDPSeq / GetUDPSeqv6 ──────── func TestGetUDPPorts_Valid(t *testing.T) { data := make([]byte, 8) binary.BigEndian.PutUint16(data[0:2], 33494) binary.BigEndian.PutUint16(data[2:4], 443) src, dst, err := GetUDPPorts(data) require.NoError(t, err) assert.Equal(t, 33494, src) assert.Equal(t, 443, dst) } func TestGetUDPPorts_TooShort(t *testing.T) { data := make([]byte, 3) _, _, err := GetUDPPorts(data) assert.Error(t, err) } func TestGetUDPSeq_Valid(t *testing.T) { // Build a mini IPv4 packet with IHL=5 (20 bytes) + enough for IP ID field ipHdr := make([]byte, 20) ipHdr[0] = 0x45 // v4, IHL=5 binary.BigEndian.PutUint16(ipHdr[4:6], 9999) got, err := GetUDPSeq(ipHdr) require.NoError(t, err) assert.Equal(t, 9999, got) } func TestGetUDPSeq_TooShort(t *testing.T) { _, err := GetUDPSeq(nil) assert.Error(t, err) } func TestGetUDPSeqv6_Valid(t *testing.T) { data := make([]byte, 8) binary.BigEndian.PutUint16(data[6:8], 7777) got, err := GetUDPSeqv6(data) require.NoError(t, err) assert.Equal(t, 7777, got) } func TestGetUDPSeqv6_TooShort(t *testing.T) { data := make([]byte, 7) _, err := GetUDPSeqv6(data) assert.Error(t, err) } // ──────── GetICMPResponsePayload ──────── func TestGetICMPResponsePayload_IPv4_Simple(t *testing.T) { // IPv4 header (IHL=5, 20 bytes) + 4 bytes payload pkt := make([]byte, 24) pkt[0] = 0x45 pkt[20] = 0xAA pkt[21] = 0xBB pkt[22] = 0xCC pkt[23] = 0xDD payload, err := GetICMPResponsePayload(pkt) require.NoError(t, err) assert.Equal(t, []byte{0xAA, 0xBB, 0xCC, 0xDD}, payload) } func TestGetICMPResponsePayload_IPv6_NoExtHeaders(t *testing.T) { // IPv6 fixed header (40 bytes) with NextHeader=58 (ICMPv6) + 4 bytes payload pkt := make([]byte, 44) pkt[0] = 0x60 // version 6 pkt[6] = 58 // Next Header: ICMPv6 (upper-layer, not extension) pkt[40] = 0x11 pkt[41] = 0x22 pkt[42] = 0x33 pkt[43] = 0x44 payload, err := GetICMPResponsePayload(pkt) require.NoError(t, err) assert.Equal(t, []byte{0x11, 0x22, 0x33, 0x44}, payload) } func TestGetICMPResponsePayload_IPv6_WithHopByHopHeader(t *testing.T) { pkt := make([]byte, 52) pkt[0] = 0x60 pkt[6] = 0 // Hop-by-Hop pkt[40] = 58 // Next Header: ICMPv6 pkt[41] = 0 // 8-byte extension header pkt[48] = 0xAB pkt[49] = 0xCD pkt[50] = 0xEF pkt[51] = 0x01 payload, err := GetICMPResponsePayload(pkt) require.NoError(t, err) assert.Equal(t, []byte{0xAB, 0xCD, 0xEF, 0x01}, payload) } func TestGetICMPResponsePayload_Empty(t *testing.T) { _, err := GetICMPResponsePayload(nil) assert.Error(t, err) } ================================================ FILE: util/udp.go ================================================ package util import ( "net" ) func newUDPResolver() *net.Resolver { return &net.Resolver{ // 指定使用 Go Build-in 的 DNS Resolver 来解析 PreferGo: true, } } ================================================ FILE: util/util.go ================================================ package util import ( "context" "errors" "fmt" "log" "net" "net/url" "runtime" "strings" "sync" "time" "github.com/fatih/color" "github.com/nxtrace/NTrace-core/config" ) var SrcDev string var SrcPort int var DstIP string var PowProviderParam = "" var rDNSCache sync.Map var UserAgent = fmt.Sprintf("NextTrace %s/%s/%s", config.Version, runtime.GOOS, runtime.GOARCH) var cachedLocalIP net.IP var cachedLocalPort int var localIPOnce sync.Once var cachedLocalIPv6 net.IP var cachedLocalPort6 int var localIPv6Once sync.Once const dnsLookupTimeout = 5 * time.Second type addrLookupResolver interface { LookupAddr(ctx context.Context, addr string) ([]string, error) } var ( domainResolverFactory = resolverFactory rdnsResolver addrLookupResolver = net.DefaultResolver ) func IsIPv6(ip net.IP) bool { return ip != nil && ip.To4() == nil && ip.To16() != nil } // AddrIP 从常见的 net.Addr 中提取 IP:支持 *net.IPAddr / *net.TCPAddr / *net.UDPAddr // 若无法提取,返回 nil func AddrIP(a net.Addr) net.IP { switch addr := a.(type) { case *net.IPAddr: return addr.IP case *net.TCPAddr: return addr.IP case *net.UDPAddr: return addr.IP default: return nil } } func RandomPortEnabled() bool { return EnvRandomPort || SrcPort == -1 } func LookupAddr(addr string) ([]string, error) { return LookupAddrWithContext(context.Background(), addr) } func LookupAddrWithContext(ctx context.Context, addr string) ([]string, error) { // 如果在缓存中找到,直接返回 if hostname, ok := rDNSCache.Load(addr); ok { //fmt.Println("hit rDNSCache for", addr, hostname) if s, ok := hostname.(string); ok { return []string{s}, nil } } if ctx == nil { ctx = context.Background() } child, cancel := context.WithTimeout(ctx, dnsLookupTimeout) defer cancel() // 如果缓存中未找到,进行 DNS 查询 names, err := rdnsResolver.LookupAddr(child, addr) if err != nil { return nil, err } // 将查询结果存入缓存 if len(names) > 0 { rDNSCache.Store(addr, names[0]) } return names, nil } // getLocalIPPort(仅用于 IPv4): // (1) 若 srcIP 非空,则以其为绑定源 IP;否则先通过 DialUDP 到 dstIP 获取实际出站源 IP // (2) 根据 proto: // // "icmp" :直接返回 bindIP,bindPort=0(表示“无端口”); // "tcp"/"udp":使用 Listen* 以 Port=0 做一次本地绑定测试,让内核分配可用端口,并在记录后立即关闭 // // (3) 立即关闭监听并返回 (bindIP, bindPort),若出错则返回 (nil, -1) func getLocalIPPort(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) { if dstIP == nil || dstIP.To4() == nil { return nil, -1 } // (1) 选定 bindIP:优先使用显式 srcIP,否则通过 UDP 伪 connect 探测 var bindIP net.IP if srcIP != nil && srcIP.To4() != nil { bindIP = srcIP } else { serverAddr := &net.UDPAddr{IP: dstIP, Port: 12345} con, err := net.DialUDP("udp4", nil, serverAddr) if err != nil { return nil, -1 } la, _ := con.LocalAddr().(*net.UDPAddr) _ = con.Close() if la == nil || la.IP == nil || la.IP.To4() == nil { return nil, -1 } bindIP = la.IP } // (2) 按需求测试端口可用性(仅本地 bind,不做网络握手) switch proto { case "icmp": return bindIP, 0 case "tcp": ln, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: bindIP, Port: 0}) if err != nil { return nil, -1 } bindPort := ln.Addr().(*net.TCPAddr).Port _ = ln.Close() return bindIP, bindPort case "udp": pc, err := net.ListenUDP("udp4", &net.UDPAddr{IP: bindIP, Port: 0}) if err != nil { return nil, -1 } bindPort := pc.LocalAddr().(*net.UDPAddr).Port _ = pc.Close() return bindIP, bindPort } return nil, -1 } // getLocalIPPortv6(仅用于 IPv6): // (1) 若 srcIP 非空,则以其为绑定源 IP;否则先通过 DialUDP 到 dstIP 获取实际出站源 IP // (2) 根据 proto: // // "icmp6" :直接返回 bindIP,bindPort=0(表示“无端口”); // "tcp6"/"udp6":使用 Listen* 以 Port=0 做一次本地绑定测试,让内核分配可用端口,并在记录后立即关闭 // // (3) 立即关闭监听并返回 (bindIP, bindPort),若出错则返回 (nil, -1) func getLocalIPPortv6(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) { if !IsIPv6(dstIP) { return nil, -1 } // (1) 选定 bindIP:优先使用显式 srcIP,否则通过 UDP 伪 connect 探测 var bindIP net.IP if srcIP != nil && IsIPv6(srcIP) { bindIP = srcIP } else { serverAddr := &net.UDPAddr{IP: dstIP, Port: 12345} con, err := net.DialUDP("udp6", nil, serverAddr) if err != nil { return nil, -1 } la, _ := con.LocalAddr().(*net.UDPAddr) _ = con.Close() if la == nil || !IsIPv6(la.IP) { return nil, -1 } bindIP = la.IP } // (2) 按需求测试端口可用性(仅本地 bind,不做网络握手) switch proto { case "icmp6": return bindIP, 0 case "tcp6": ln, err := net.ListenTCP("tcp6", &net.TCPAddr{IP: bindIP, Port: 0}) if err != nil { return nil, -1 } bindPort := ln.Addr().(*net.TCPAddr).Port _ = ln.Close() return bindIP, bindPort case "udp6": pc, err := net.ListenUDP("udp6", &net.UDPAddr{IP: bindIP, Port: 0}) if err != nil { return nil, -1 } bindPort := pc.LocalAddr().(*net.UDPAddr).Port _ = pc.Close() return bindIP, bindPort } return nil, -1 } // LocalIPPort 根据目标 IPv4(以及可选的源 IPv4 与协议)返回本地 IP 与一个可用端口 func LocalIPPort(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) { // 若开启随机端口模式,每次直接计算并返回 if RandomPortEnabled() { return getLocalIPPort(dstIP, srcIP, proto) } // 否则仅计算一次并缓存 localIPOnce.Do(func() { cachedLocalIP, cachedLocalPort = getLocalIPPort(dstIP, srcIP, proto) }) if cachedLocalIP != nil { return cachedLocalIP, cachedLocalPort } return nil, -1 } // LocalIPPortv6 根据目标 IPv6(以及可选的源 IPv6 与协议)返回本地 IP 与一个可用端口 func LocalIPPortv6(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) { // 若开启随机端口模式,每次直接计算并返回 if RandomPortEnabled() { return getLocalIPPortv6(dstIP, srcIP, proto) } // 否则仅计算一次并缓存 localIPv6Once.Do(func() { cachedLocalIPv6, cachedLocalPort6 = getLocalIPPortv6(dstIP, srcIP, proto) }) if cachedLocalIPv6 != nil { return cachedLocalIPv6, cachedLocalPort6 } return nil, -1 } type hostLookupResolver interface { LookupHost(ctx context.Context, host string) ([]string, error) } type resolvedIPPrompt func([]net.IP) (int, error) func resolverFactory(dotServer string) hostLookupResolver { switch dotServer { case "dnssb": return DNSSB() case "aliyun": return Aliyun() case "dnspod": return Dnspod() case "google": return Google() case "cloudflare": return Cloudflare() default: return newUDPResolver() } } func lookupIPs(ctx context.Context, resolver hostLookupResolver, host string) ([]net.IP, error) { ipsStr, err := resolver.LookupHost(ctx, host) if err != nil { return nil, fmt.Errorf("DNS lookup failed: %w", err) } ips := make([]net.IP, 0, len(ipsStr)) for _, value := range ipsStr { if parsed := net.ParseIP(value); parsed != nil { ips = append(ips, parsed) } } return ips, nil } func filterByFamily(ips []net.IP, ipVersion string) []net.IP { if ipVersion == "all" { return ips } for _, ip := range ips { if ip == nil { continue } if ipVersion == "4" && ip.To4() != nil { return []net.IP{ip} } if ipVersion == "6" && ip.To4() == nil { return []net.IP{ip} } } return nil } func resolveFamilyLabel(ipVersion string) string { switch ipVersion { case "4": return "IPv4" case "6": return "IPv6" case "all", "": return "IPv4/IPv6" default: return ipVersion } } func promptResolvedIPChoice(ips []net.IP) (int, error) { fmt.Println("Please Choose the IP You Want To TraceRoute") for i, ip := range ips { _, _ = fmt.Fprintf(color.Output, "%s %s\n", color.New(color.FgHiYellow, color.Bold).Sprintf("%d.", i), color.New(color.FgWhite, color.Bold).Sprintf("%s", ip), ) } fmt.Printf("Your Option: ") var index int _, err := fmt.Scanln(&index) if err != nil { return 0, err } return index, nil } func selectResolvedIP(ips []net.IP, disableOutput bool, prompt resolvedIPPrompt) (net.IP, error) { if len(ips) == 0 { return nil, errors.New("no IPs available") } if len(ips) == 1 || disableOutput { return ips[0], nil } if prompt == nil { prompt = promptResolvedIPChoice } index, err := prompt(ips) if err != nil { index = 0 } if index < 0 || index >= len(ips) { return nil, fmt.Errorf("invalid selection: %d", index) } return ips[index], nil } func DomainLookUp(host string, ipVersion string, dotServer string, disableOutput bool) (net.IP, error) { return DomainLookUpWithContext(context.Background(), host, ipVersion, dotServer, disableOutput) } func DomainLookUpWithContext(ctx context.Context, host string, ipVersion string, dotServer string, disableOutput bool) (net.IP, error) { if ctx == nil { ctx = context.Background() } child, cancel := context.WithTimeout(ctx, dnsLookupTimeout) defer cancel() ips, err := lookupIPs(child, domainResolverFactory(dotServer), host) if err != nil { return nil, err } ips = filterByFamily(ips, ipVersion) if len(ips) == 0 { return nil, fmt.Errorf("no %s DNS records found for %s", resolveFamilyLabel(ipVersion), host) } selected, err := selectResolvedIP(ips, disableOutput, promptResolvedIPChoice) if err != nil { fmt.Println("Your Option is invalid") return nil, err } return selected, nil } func GetHostAndPort() (host string, port string) { // 解析域名 hostArr := strings.Split(EnvHostPort, ":") // 判断是否有指定端口 if len(hostArr) > 1 { // 判断是否为 IPv6 if strings.HasPrefix(EnvHostPort, "[") { tmp := strings.Split(EnvHostPort, "]") host = tmp[0] host = host[1:] if port = tmp[1]; port != "" { port = port[1:] } } else { host, port = hostArr[0], hostArr[1] } } else { host = EnvHostPort } if port == "" { // 默认端口 port = "443" } return } func GetProxy() *url.URL { if EnvProxyURL == "" { return nil } proxyURL, err := url.Parse(EnvProxyURL) if err != nil { log.Println("Failed to parse proxy URL:", err) return nil } return proxyURL } func GetPowProvider() string { var powProvider string if PowProviderParam == "" { powProvider = EnvPowProvider } else { powProvider = PowProviderParam } if powProvider == "sakura" { return "pow.nexttrace.owo.13a.com" } return "" } func StringInSlice(val string, list []string) bool { for _, v := range list { if v == val { return true } } return false } func HideIPPart(ip string) string { parsedIP := net.ParseIP(ip) if parsedIP == nil { return "" } if parsedIP.To4() != nil { // IPv4: 隐藏后16位 return strings.Join(strings.Split(ip, ".")[:2], ".") + ".0.0/16" } // IPv6: 隐藏后96位 return parsedIP.Mask(net.CIDRMask(32, 128)).String() + "/32" } // fold16 对 32 位累加和做 16 位一补折叠(包含环回进位) func fold16(sum uint32) uint16 { // 将高 16 位进位折叠回低 16 位,直至无进位 for (sum >> 16) != 0 { sum = (sum & 0xFFFF) + (sum >> 16) } return uint16(sum & 0xFFFF) } // addBytes 按大端序把字节流每 16 位累加到 sum;若长度为奇数,最后 1 字节作为高位、低位补 0 func addBytes(sum uint32, data []byte) uint32 { // 按大端序每次取两个字节,将合成后的 16 位无符号数累加到 sum for i := 0; i+1 < len(data); i += 2 { sum += uint32(data[i])<<8 | uint32(data[i+1]) } // 奇数字节则末尾补 0 if len(data)%2 == 1 { sum += uint32(data[len(data)-1]) << 8 } return sum } // UDPBaseSum 在“UDP.Checksum 视为 0、payload[0:2]=0x0000”的前提下,计算 16 位一补和 S0 func UDPBaseSum(srcIP, dstIP net.IP, srcPort, dstPort, udpLen int, payload []byte) uint16 { sum := uint32(0) if src4 := srcIP.To4(); src4 != nil { dst4 := dstIP.To4() sum = addBytes(sum, src4) sum = addBytes(sum, dst4) sum += uint32(0x0011) sum += uint32(udpLen & 0xFFFF) } else { src6 := srcIP.To16() dst6 := dstIP.To16() sum = addBytes(sum, src6) sum = addBytes(sum, dst6) uLen := uint32(udpLen) sum += (uLen >> 16) & 0xFFFF sum += uLen & 0xFFFF sum += uint32(0x0011) } sum += uint32(srcPort & 0xFFFF) sum += uint32(dstPort & 0xFFFF) sum += uint32(udpLen & 0xFFFF) sum = addBytes(sum, payload) return fold16(sum) } // FudgeWordForSeq 给定 S0 与目标 checksum(如 seq),返回应写入 payload[0:2] 的补偿值(16 位) // 原理:最终一补和 targetSum = ^targetChecksum;令补偿位 X,则 X = targetSum ⊕ (~S0) func FudgeWordForSeq(S0, targetChecksum uint16) uint16 { targetSum := ^targetChecksum // 目标一补和 x := uint32(targetSum) + uint32(^S0) // X = targetSum ⊕ (~S0) return fold16(x) } // MakePayloadWithTargetChecksum 修改 payload,使最终 UDP.Checksum == targetChecksum // 要求:payload 长度 >= 2(前 2 字节作为补偿位写入) func MakePayloadWithTargetChecksum(payload []byte, srcIP, dstIP net.IP, srcPort, dstPort int, targetChecksum uint16) error { if len(payload) < 2 { return errors.New("payload too short, need >= 2 bytes for fudge") } // v4/v6 一致性校验 if (srcIP.To4() == nil) != (dstIP.To4() == nil) { return errors.New("src/dst IP version mismatch (v4/v6)") } // 补偿位清零,再按“校验和字段=0”的前提计算 S0 payload[0], payload[1] = 0, 0 udpLen := 8 + len(payload) S0 := UDPBaseSum(srcIP, dstIP, srcPort, dstPort, udpLen, payload) fudge := FudgeWordForSeq(S0, targetChecksum) // 回写补偿位(网络序) payload[0] = byte(fudge >> 8) payload[1] = byte(fudge) return nil } ================================================ FILE: util/util_test.go ================================================ package util import ( "context" "errors" "net" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ──────── IsIPv6 ──────── func TestIsIPv6_True(t *testing.T) { assert.True(t, IsIPv6(net.ParseIP("2001:db8::1"))) assert.True(t, IsIPv6(net.ParseIP("::1"))) assert.True(t, IsIPv6(net.ParseIP("fe80::1"))) } func TestIsIPv6_False(t *testing.T) { assert.False(t, IsIPv6(net.ParseIP("1.2.3.4"))) assert.False(t, IsIPv6(net.ParseIP("127.0.0.1"))) assert.False(t, IsIPv6(nil)) } // ──────── AddrIP ──────── func TestAddrIP_IPAddr(t *testing.T) { ip := net.ParseIP("8.8.8.8") got := AddrIP(&net.IPAddr{IP: ip}) assert.Equal(t, ip, got) } func TestAddrIP_TCPAddr(t *testing.T) { ip := net.ParseIP("1.1.1.1") got := AddrIP(&net.TCPAddr{IP: ip, Port: 80}) assert.Equal(t, ip, got) } func TestAddrIP_UDPAddr(t *testing.T) { ip := net.ParseIP("2001:db8::1") got := AddrIP(&net.UDPAddr{IP: ip, Port: 53}) assert.Equal(t, ip, got) } func TestAddrIP_UnixAddr(t *testing.T) { got := AddrIP(&net.UnixAddr{Name: "/tmp/sock"}) assert.Nil(t, got) } func TestAddrIP_Nil(t *testing.T) { got := AddrIP(nil) assert.Nil(t, got) } // ──────── StringInSlice ──────── func TestStringInSlice_Found(t *testing.T) { assert.True(t, StringInSlice("b", []string{"a", "b", "c"})) } func TestStringInSlice_NotFound(t *testing.T) { assert.False(t, StringInSlice("z", []string{"a", "b"})) } func TestStringInSlice_EmptySlice(t *testing.T) { assert.False(t, StringInSlice("any", nil)) } // ──────── HideIPPart ──────── func TestHideIPPart_IPv4(t *testing.T) { assert.Equal(t, "192.168.0.0/16", HideIPPart("192.168.1.1")) } func TestHideIPPart_IPv6(t *testing.T) { got := HideIPPart("2001:db8::1") assert.Equal(t, "2001:db8::/32", got) } func TestHideIPPart_Invalid(t *testing.T) { assert.Equal(t, "", HideIPPart("notanip")) } // ──────── UDPBaseSum ──────── func TestUDPBaseSum_IPv4_KnownValue(t *testing.T) { src := net.ParseIP("192.168.1.1").To4() dst := net.ParseIP("10.0.0.1").To4() payload := make([]byte, 4) // 4-byte payload, all zero udpLen := 8 + len(payload) got := UDPBaseSum(src, dst, 12345, 80, udpLen, payload) // Should produce a non-zero checksum partial assert.NotEqual(t, uint16(0), got) } func TestUDPBaseSum_IPv6_NonZero(t *testing.T) { src := net.ParseIP("2001:db8::1") dst := net.ParseIP("2001:db8::2") payload := []byte{0, 0, 0, 0} udpLen := 8 + len(payload) got := UDPBaseSum(src, dst, 1000, 2000, udpLen, payload) assert.NotEqual(t, uint16(0), got) } // ──────── FudgeWordForSeq ──────── func TestFudgeWordForSeq_RoundTrip(t *testing.T) { // Given a base sum S0 and a target checksum, the fudge word should allow // reconstructing the target. We verify by recomputing. S0 := uint16(0x1234) target := uint16(42) fudge := FudgeWordForSeq(S0, target) // Reconstruct: fold16(S0 + fudge) should equal ~target sum := uint32(S0) + uint32(fudge) for (sum >> 16) != 0 { sum = (sum & 0xFFFF) + (sum >> 16) } reconstructed := ^uint16(sum) assert.Equal(t, target, reconstructed) } // ──────── MakePayloadWithTargetChecksum ──────── func TestMakePayloadWithTargetChecksum_RoundTrip(t *testing.T) { src := net.ParseIP("10.0.0.1").To4() dst := net.ParseIP("10.0.0.2").To4() payload := make([]byte, 8) targetCS := uint16(9999) err := MakePayloadWithTargetChecksum(payload, src, dst, 33494, 33434, targetCS) require.NoError(t, err) // Verify: compute the full checksum using the modified payload udpLen := 8 + len(payload) finalSum := UDPBaseSum(src, dst, 33494, 33434, udpLen, payload) finalChecksum := ^finalSum assert.Equal(t, targetCS, finalChecksum) } func TestMakePayloadWithTargetChecksum_TooShort(t *testing.T) { src := net.ParseIP("10.0.0.1").To4() dst := net.ParseIP("10.0.0.2").To4() payload := make([]byte, 1) // too short err := MakePayloadWithTargetChecksum(payload, src, dst, 100, 200, 42) require.Error(t, err) assert.Contains(t, err.Error(), "too short") } func TestMakePayloadWithTargetChecksum_VersionMismatch(t *testing.T) { src := net.ParseIP("10.0.0.1").To4() dst := net.ParseIP("2001:db8::1") // v6 payload := make([]byte, 4) err := MakePayloadWithTargetChecksum(payload, src, dst, 100, 200, 42) require.Error(t, err) assert.Contains(t, err.Error(), "mismatch") } type fakeHostLookupResolver struct { hosts []string err error } func (f fakeHostLookupResolver) LookupHost(context.Context, string) ([]string, error) { if f.err != nil { return nil, f.err } return f.hosts, nil } type fakeHostLookupResolverWithContext struct { lookup func(ctx context.Context, host string) ([]string, error) } func (f fakeHostLookupResolverWithContext) LookupHost(ctx context.Context, host string) ([]string, error) { return f.lookup(ctx, host) } type fakeAddrLookupResolver struct { lookup func(ctx context.Context, addr string) ([]string, error) } func (f fakeAddrLookupResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { return f.lookup(ctx, addr) } func TestLookupIPs_SkipsInvalidValues(t *testing.T) { ips, err := lookupIPs(context.Background(), fakeHostLookupResolver{ hosts: []string{"1.1.1.1", "not-an-ip", "2606:4700::1"}, }, "example.com") require.NoError(t, err) require.Len(t, ips, 2) assert.Equal(t, "1.1.1.1", ips[0].String()) assert.Equal(t, "2606:4700::1", ips[1].String()) } func TestLookupIPs_ReturnsWrappedError(t *testing.T) { _, err := lookupIPs(context.Background(), fakeHostLookupResolver{err: errors.New("boom")}, "example.com") require.Error(t, err) assert.Contains(t, err.Error(), "DNS lookup failed") } func TestDomainLookUpWithContextReturnsContextCanceled(t *testing.T) { oldFactory := domainResolverFactory domainResolverFactory = func(string) hostLookupResolver { return fakeHostLookupResolverWithContext{ lookup: func(ctx context.Context, host string) ([]string, error) { <-ctx.Done() return nil, ctx.Err() }, } } defer func() { domainResolverFactory = oldFactory }() ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() _, err := DomainLookUpWithContext(ctx, "example.com", "all", "", true) require.Error(t, err) if !errors.Is(err, context.Canceled) { t.Fatalf("DomainLookUpWithContext error = %v, want context.Canceled", err) } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("DomainLookUpWithContext returned too slowly after cancel: %v", elapsed) } } func TestLookupAddrWithContextUsesCache(t *testing.T) { oldResolver := rdnsResolver rdnsResolver = fakeAddrLookupResolver{ lookup: func(context.Context, string) ([]string, error) { t.Fatal("resolver should not be called when cache is warm") return nil, nil }, } defer func() { rdnsResolver = oldResolver }() rDNSCache = sync.Map{} rDNSCache.Store("1.1.1.1", "cached.example.") names, err := LookupAddrWithContext(context.Background(), "1.1.1.1") require.NoError(t, err) require.Equal(t, []string{"cached.example."}, names) } func TestLookupAddrWithContextStoresResultInCache(t *testing.T) { oldResolver := rdnsResolver rdnsResolver = fakeAddrLookupResolver{ lookup: func(context.Context, string) ([]string, error) { return []string{"resolver.example."}, nil }, } defer func() { rdnsResolver = oldResolver }() rDNSCache = sync.Map{} names, err := LookupAddrWithContext(context.Background(), "1.1.1.1") require.NoError(t, err) require.Equal(t, []string{"resolver.example."}, names) cached, ok := rDNSCache.Load("1.1.1.1") require.True(t, ok) require.Equal(t, "resolver.example.", cached) } func TestLookupAddrWithContextReturnsContextCanceled(t *testing.T) { oldResolver := rdnsResolver rdnsResolver = fakeAddrLookupResolver{ lookup: func(ctx context.Context, addr string) ([]string, error) { <-ctx.Done() return nil, ctx.Err() }, } defer func() { rdnsResolver = oldResolver }() rDNSCache = sync.Map{} ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() _, err := LookupAddrWithContext(ctx, "1.1.1.1") require.Error(t, err) if !errors.Is(err, context.Canceled) { t.Fatalf("LookupAddrWithContext error = %v, want context.Canceled", err) } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("LookupAddrWithContext returned too slowly after cancel: %v", elapsed) } } func TestFilterByFamily_PicksFirstMatchingAddress(t *testing.T) { ips := []net.IP{ net.ParseIP("2606:4700::1"), net.ParseIP("1.1.1.1"), net.ParseIP("8.8.8.8"), } filtered := filterByFamily(ips, "4") require.Len(t, filtered, 1) assert.Equal(t, "1.1.1.1", filtered[0].String()) } func TestSelectResolvedIP_PromptErrorFallsBackToFirst(t *testing.T) { ips := []net.IP{net.ParseIP("1.1.1.1"), net.ParseIP("8.8.8.8")} selected, err := selectResolvedIP(ips, false, func([]net.IP) (int, error) { return 0, errors.New("stdin closed") }) require.NoError(t, err) assert.Equal(t, "1.1.1.1", selected.String()) } func TestSelectResolvedIP_InvalidIndex(t *testing.T) { ips := []net.IP{net.ParseIP("1.1.1.1"), net.ParseIP("8.8.8.8")} _, err := selectResolvedIP(ips, false, func([]net.IP) (int, error) { return 10, nil }) require.Error(t, err) assert.Contains(t, err.Error(), "invalid selection") } func TestResolveFamilyLabel(t *testing.T) { assert.Equal(t, "IPv4", resolveFamilyLabel("4")) assert.Equal(t, "IPv6", resolveFamilyLabel("6")) assert.Equal(t, "IPv4/IPv6", resolveFamilyLabel("all")) } // ──────── GetPowProvider ──────── func TestGetPowProvider_Default(t *testing.T) { old := PowProviderParam oldEnv := EnvPowProvider defer func() { PowProviderParam = old; EnvPowProvider = oldEnv }() PowProviderParam = "" EnvPowProvider = "" assert.Equal(t, "", GetPowProvider()) } func TestGetPowProvider_Sakura(t *testing.T) { old := PowProviderParam defer func() { PowProviderParam = old }() PowProviderParam = "sakura" assert.Equal(t, "pow.nexttrace.owo.13a.com", GetPowProvider()) } ================================================ FILE: wshandle/client.go ================================================ package wshandle import ( "context" "crypto/tls" "errors" "log" "net" "net/http" "net/url" "os" "os/signal" "strings" "sync" "time" "github.com/gorilla/websocket" "github.com/nxtrace/NTrace-core/pow" "github.com/nxtrace/NTrace-core/util" ) func formatHostPort(addr, port string) string { clean := strings.TrimSpace(addr) clean = strings.Trim(clean, "[]") if strings.Contains(clean, ":") { return "[" + clean + "]:" + port } return clean + ":" + port } type wsWriteJob struct { msgType int data []byte } const ( wsClientWriteQueueSize = 1024 wsClientWriteTimeout = 5 * time.Second wsClientDialTimeout = 5 * time.Second ) type WsConn struct { Connecting bool Connected bool // 连接状态 MsgSendCh chan string // 消息发送通道 MsgReceiveCh chan string // 消息接收通道 Done chan struct{} // 发送结束通道 Exit chan bool // 程序退出信号 Interrupt chan os.Signal // 终端中止信号 Conn *websocket.Conn // 主连接 ConnMux sync.Mutex // 连接互斥锁 stateMu sync.RWMutex lifecycleMu sync.Mutex loopWG sync.WaitGroup closeOnce sync.Once writeCh chan wsWriteJob // serialized write queue writeStop chan struct{} // signals writeLoop to exit closeCh chan struct{} // signals background loops to exit closed bool baseCtx context.Context } func (c *WsConn) getConn() *websocket.Conn { c.stateMu.RLock() defer c.stateMu.RUnlock() return c.Conn } func (c *WsConn) setConn(conn *websocket.Conn) { c.stateMu.Lock() c.Conn = conn c.stateMu.Unlock() } func (c *WsConn) getDoneChan() chan struct{} { c.stateMu.RLock() defer c.stateMu.RUnlock() return c.Done } func (c *WsConn) setDoneChan(done chan struct{}) { c.stateMu.Lock() c.Done = done c.stateMu.Unlock() } // initWriteLoop creates the write channel and starts the single writer goroutine. // Must be called once when the WsConn is created. func (c *WsConn) initWriteLoop() { c.writeCh = make(chan wsWriteJob, wsClientWriteQueueSize) c.writeStop = make(chan struct{}) c.startLoop(c.writeLoop) } // writeLoop is the sole goroutine allowed to call conn.WriteMessage. func (c *WsConn) writeLoop() { for { select { case <-c.writeStop: return case job, ok := <-c.writeCh: if !ok { return } conn := c.getConn() if conn == nil { c.setConnected(false) continue } _ = conn.SetWriteDeadline(time.Now().Add(wsClientWriteTimeout)) if err := conn.WriteMessage(job.msgType, job.data); err != nil { log.Printf("wshandle writeLoop: %v", err) c.setConnected(false) } } } } // enqueueWrite sends a write job to the writeLoop. Returns an error if the // queue is full or writeLoop has stopped. func (c *WsConn) enqueueWrite(job wsWriteJob) error { c.lifecycleMu.Lock() defer c.lifecycleMu.Unlock() if c.closed { return errWriteLoopStopped } select { case c.writeCh <- job: return nil case <-c.writeStop: return errWriteLoopStopped default: return errWriteQueueFull } } var ( errWriteQueueFull = errors.New("wshandle: write queue full") errWriteLoopStopped = errors.New("wshandle: write loop stopped") ) var wsconn *WsConn var wsconnMu sync.RWMutex var wsconnNewMu sync.Mutex var host, port, fastIp string var envToken = util.EnvToken var cacheToken string var cacheTokenFailedTimes int var createWsConnFn = createWsConn var wsGetFastIPFn = util.GetFastIPWithContext var wsGetTokenFn = pow.GetTokenWithContext func newWsConn(conn *websocket.Conn, interrupt chan os.Signal) *WsConn { c := &WsConn{ Conn: conn, MsgSendCh: make(chan string, 10), MsgReceiveCh: make(chan string, 10), Interrupt: interrupt, closeCh: make(chan struct{}), baseCtx: context.Background(), } c.initWriteLoop() return c } func normalizeContext(ctx context.Context) context.Context { if ctx == nil { return context.Background() } return ctx } func deriveOperationContext(parent context.Context, stopCh <-chan struct{}, timeout time.Duration) (context.Context, context.CancelFunc) { base := normalizeContext(parent) linkedCtx, linkedCancel := context.WithCancel(base) if stopCh != nil { go func() { select { case <-stopCh: linkedCancel() case <-linkedCtx.Done(): } }() } if timeout <= 0 { return linkedCtx, linkedCancel } ctx, cancel := context.WithTimeout(linkedCtx, timeout) return ctx, func() { cancel() linkedCancel() } } func (c *WsConn) startLoop(fn func()) { c.loopWG.Add(1) go func() { defer c.loopWG.Done() fn() }() } func (c *WsConn) isClosed() bool { if c == nil { return true } select { case <-c.closeCh: return true default: return false } } func closeSignalChan(ch chan struct{}) { if ch == nil { return } defer func() { _ = recover() }() close(ch) } func (c *WsConn) closeConn() { conn := c.getConn() if conn == nil { return } _ = conn.Close() c.setConn(nil) } func (c *WsConn) replaceConn(conn *websocket.Conn) { c.stateMu.Lock() prev := c.Conn c.Conn = conn c.stateMu.Unlock() if prev != nil && prev != conn { _ = prev.Close() } } func (c *WsConn) Close() { if c == nil { return } c.closeOnce.Do(func() { c.lifecycleMu.Lock() c.closed = true c.lifecycleMu.Unlock() c.setConnectionState(false, false) if c.closeCh != nil { close(c.closeCh) } closeSignalChan(c.writeStop) closeSignalChan(c.getDoneChan()) if c.Interrupt != nil { signal.Stop(c.Interrupt) } c.closeConn() }) c.loopWG.Wait() } func (c *WsConn) setConnectionState(connected, connecting bool) { c.stateMu.Lock() c.Connected = connected c.Connecting = connecting c.stateMu.Unlock() } func (c *WsConn) setConnected(v bool) { c.stateMu.Lock() c.Connected = v c.stateMu.Unlock() } func (c *WsConn) setConnecting(v bool) { c.stateMu.Lock() c.Connecting = v c.stateMu.Unlock() } func (c *WsConn) IsConnected() bool { c.stateMu.RLock() defer c.stateMu.RUnlock() return c.Connected } func (c *WsConn) IsConnecting() bool { c.stateMu.RLock() defer c.stateMu.RUnlock() return c.Connecting } func (c *WsConn) startReconnecting() bool { if c.isClosed() { return false } c.stateMu.Lock() defer c.stateMu.Unlock() if c.Connected || c.Connecting { return false } c.Connecting = true return true } func (c *WsConn) keepAlive() { pingTicker := time.NewTicker(54 * time.Second) defer pingTicker.Stop() reconnectTicker := time.NewTicker(200 * time.Millisecond) defer reconnectTicker.Stop() for { select { case <-c.closeCh: return case <-pingTicker.C: if !c.IsConnected() { continue } if err := c.enqueueWrite(wsWriteJob{ msgType: websocket.TextMessage, data: []byte("ping"), }); err != nil { log.Println(err) c.setConnected(false) } case <-reconnectTicker.C: if c.startReconnecting() { c.recreateWsConn() } } } } func (c *WsConn) messageReceiveHandler() { done := c.getDoneChan() defer closeSignalChan(done) for { select { case <-c.closeCh: return default: } if c.IsConnected() { conn := c.getConn() if conn == nil { c.setConnected(false) continue } _, msg, err := conn.ReadMessage() if err != nil { // 读取信息出错,连接已经意外断开 // log.Println(err) c.setConnected(false) return } if string(msg) != "pong" { select { case c.MsgReceiveCh <- string(msg): case <-c.closeCh: return } } } else { // 降低断线时期的 CPU 占用 select { case <-c.closeCh: return case <-time.After(200 * time.Millisecond): } } } } func apiServerErrorMessage(ip string) string { return `{"ip":"` + ip + `", "asnumber":"API Server Error"}` } func (c *WsConn) trySendReceiveMessage(msg string) { select { case c.MsgReceiveCh <- msg: case <-c.closeCh: default: log.Println("wshandle: dropping queued receive message") } } func (c *WsConn) waitForNextDoneChan(doneCh chan struct{}) chan struct{} { for { newDone := c.getDoneChan() if newDone != nil && newDone != doneCh { return newDone } select { case <-c.closeCh: return nil case <-time.After(50 * time.Millisecond): } } } func (c *WsConn) sendQueuedMessage(msg string) { if !c.IsConnected() { c.trySendReceiveMessage(apiServerErrorMessage(msg)) return } if err := c.enqueueWrite(wsWriteJob{ msgType: websocket.TextMessage, data: []byte(msg), }); err != nil { log.Println("write:", err) c.setConnected(false) c.trySendReceiveMessage(apiServerErrorMessage(msg)) } } func (c *WsConn) handleInterrupt(doneCh chan struct{}) { _ = c.enqueueWrite(wsWriteJob{ msgType: websocket.CloseMessage, data: websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), }) select { case <-doneCh: case <-time.After(1 * time.Second): } } func (c *WsConn) messageSendHandler() { doneCh := c.getDoneChan() for { if current := c.getDoneChan(); current != nil && current != doneCh { doneCh = current } select { case <-c.closeCh: return case <-doneCh: doneCh = c.waitForNextDoneChan(doneCh) if doneCh == nil { return } case msg := <-c.MsgSendCh: c.sendQueuedMessage(msg) case <-c.Interrupt: c.handleInterrupt(doneCh) return } } } func (c *WsConn) recreateWsConn() { if c.isClosed() { return } c.setConnected(false) // 尝试重新连线 if host != "" && net.ParseIP(host) == nil { // 刷新一次最优 IP,防止旧 IP 已失效 fastIPCtx, cancelFastIP := deriveOperationContext(c.baseCtx, c.closeCh, 0) refreshedFastIP, err := wsGetFastIPFn(fastIPCtx, host, port, true) cancelFastIP() if err != nil { if !errors.Is(err, context.Canceled) { log.Printf("fast ip refresh failed: %v", err) } c.setConnectionState(false, false) return } fastIp = refreshedFastIP } u := url.URL{Scheme: "wss", Host: formatHostPort(fastIp, port), Path: "/v3/ipGeoWs"} // log.Printf("connecting to %s", u.String()) jwtToken, ua := envToken, []string{"Privileged Client"} err := error(nil) if envToken == "" { // 无环境变量 token if cacheToken == "" { // 无cacheToken, 重新获取 token tokenCtx, cancelToken := deriveOperationContext(c.baseCtx, c.closeCh, 0) if util.GetPowProvider() == "" { jwtToken, err = wsGetTokenFn(tokenCtx, fastIp, host, port) } else { jwtToken, err = wsGetTokenFn(tokenCtx, util.GetPowProvider(), util.GetPowProvider(), port) } cancelToken() if err != nil { if util.EnvDevMode { panic(err) } if !errors.Is(err, context.Canceled) { log.Printf("pow token fetch failed: %v", err) } cacheToken = "" cacheTokenFailedTimes++ c.setConnectionState(false, false) return } } else { // 使用 cacheToken jwtToken = cacheToken } ua = []string{util.UserAgent} } cacheToken = jwtToken requestHeader := http.Header{ "Host": []string{host}, "User-Agent": ua, "Authorization": []string{"Bearer " + jwtToken}, } dialer := *websocket.DefaultDialer // 按值拷贝,变成独立实例 // 现在 dialer 是一个新的 Dialer(值),内部字段与 DefaultDialer 相同 dialer.TLSClientConfig = &tls.Config{ ServerName: host, } proxyUrl := util.GetProxy() if proxyUrl != nil { dialer.Proxy = http.ProxyURL(proxyUrl) } ctx, cancel := deriveOperationContext(c.baseCtx, c.closeCh, wsClientDialTimeout) ws, _, err := dialer.DialContext(ctx, u.String(), requestHeader) cancel() if c.isClosed() { if ws != nil { _ = ws.Close() } return } if err != nil { log.Println("dial:", err) // <-time.After(time.Second * 1) c.setConnectionState(false, false) cacheTokenFailedTimes += 1 time.Sleep(1 * time.Second) //fmt.Println("重连失败", cacheTokenFailedTimes, "次") return } c.replaceConn(ws) c.setConnectionState(true, false) c.setDoneChan(make(chan struct{})) c.startLoop(c.messageReceiveHandler) } func createWsConn(ctx context.Context) *WsConn { proxyUrl := util.GetProxy() //fmt.Println("正在连接 WS") // 设置终端中断通道 interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt) ctx = normalizeContext(ctx) host, port = util.GetHostAndPort() // 如果 host 是一个 IP 使用默认域名 if valid := net.ParseIP(host); valid != nil { fastIp = host host = "api.nxtrace.org" } else { // 默认配置完成,开始寻找最优 IP refreshedFastIP, err := wsGetFastIPFn(ctx, host, port, true) if err != nil { if util.EnvDevMode { panic(err) } log.Printf("fast ip probe failed: %v", err) ws := newWsConn(nil, interrupt) ws.baseCtx = ctx ws.setDoneChan(make(chan struct{})) ws.setConnectionState(false, false) ws.startLoop(ws.keepAlive) ws.startLoop(ws.messageSendHandler) return ws } fastIp = refreshedFastIP } jwtToken, ua := envToken, []string{"Privileged Client"} err := error(nil) if envToken == "" { if util.GetPowProvider() == "" { jwtToken, err = wsGetTokenFn(ctx, fastIp, host, port) } else { jwtToken, err = wsGetTokenFn(ctx, util.GetPowProvider(), util.GetPowProvider(), port) } if err != nil { if util.EnvDevMode { panic(err) } log.Printf("pow token fetch failed: %v", err) ws := newWsConn(nil, interrupt) ws.setDoneChan(make(chan struct{})) ws.setConnectionState(false, false) ws.startLoop(ws.keepAlive) ws.startLoop(ws.messageSendHandler) return ws } ua = []string{util.UserAgent} } cacheToken = jwtToken cacheTokenFailedTimes = 0 requestHeader := http.Header{ "Host": []string{host}, "User-Agent": ua, "Authorization": []string{"Bearer " + jwtToken}, } dialer := *websocket.DefaultDialer // 按值拷贝,变成独立实例 // 现在 dialer 是一个新的 Dialer(值),内部字段与 DefaultDialer 相同 dialer.TLSClientConfig = &tls.Config{ ServerName: host, } if proxyUrl != nil { dialer.Proxy = http.ProxyURL(proxyUrl) } u := url.URL{Scheme: "wss", Host: formatHostPort(fastIp, port), Path: "/v3/ipGeoWs"} // log.Printf("connecting to %s", u.String()) dialCtx, cancel := deriveOperationContext(ctx, nil, wsClientDialTimeout) c, _, err := dialer.DialContext(dialCtx, u.String(), requestHeader) cancel() ws := newWsConn(c, interrupt) ws.baseCtx = ctx ws.setConnectionState(err == nil, false) if err != nil { log.Println("dial:", err) // <-time.After(time.Second * 1) cacheTokenFailedTimes++ ws.setDoneChan(make(chan struct{})) ws.startLoop(ws.keepAlive) ws.startLoop(ws.messageSendHandler) return ws } // defer c.Close() // 将连接写入WsConn,方便随时可取 ws.setDoneChan(make(chan struct{})) ws.startLoop(ws.keepAlive) ws.startLoop(ws.messageReceiveHandler) ws.startLoop(ws.messageSendHandler) return ws } func NewWithContext(ctx context.Context) *WsConn { wsconnNewMu.Lock() defer wsconnNewMu.Unlock() newConn := createWsConnFn(ctx) if newConn != nil { newConn.baseCtx = normalizeContext(ctx) } wsconnMu.Lock() oldConn := wsconn wsconn = newConn wsconnMu.Unlock() if oldConn != nil && oldConn != newConn { oldConn.Close() } return newConn } func New() *WsConn { return NewWithContext(context.Background()) } func GetWsConn() *WsConn { wsconnMu.RLock() defer wsconnMu.RUnlock() return wsconn } ================================================ FILE: wshandle/client_test.go ================================================ package wshandle import ( "context" "errors" "os" "testing" "time" "github.com/gorilla/websocket" ) func newStartedTestWsConn() *WsConn { c := newWsConn(nil, make(chan os.Signal, 1)) c.setDoneChan(make(chan struct{})) c.setConnectionState(false, false) c.startLoop(c.keepAlive) c.startLoop(c.messageSendHandler) return c } func saveAndRestoreGlobalWsConn(t *testing.T) { t.Helper() wsconnMu.RLock() oldWsconn := wsconn wsconnMu.RUnlock() t.Cleanup(func() { wsconnMu.Lock() current := wsconn wsconn = oldWsconn wsconnMu.Unlock() if current != nil && current != oldWsconn { current.Close() } }) } func TestWsConnCloseStopsBackgroundLoops(t *testing.T) { conn := newStartedTestWsConn() doneCh := conn.getDoneChan() conn.Close() conn.Close() if !conn.isClosed() { t.Fatal("connection should be marked closed") } if err := conn.enqueueWrite(wsWriteJob{msgType: websocket.TextMessage, data: []byte("ping")}); !errors.Is(err, errWriteLoopStopped) { t.Fatalf("enqueueWrite error=%v, want %v", err, errWriteLoopStopped) } select { case <-doneCh: default: t.Fatal("done channel should be closed by Close") } } func TestNewClosesPreviousGlobalWsConn(t *testing.T) { oldCreateFn := createWsConnFn defer func() { createWsConnFn = oldCreateFn }() saveAndRestoreGlobalWsConn(t) oldConn := newStartedTestWsConn() wsconnMu.Lock() wsconn = oldConn wsconnMu.Unlock() createWsConnFn = func(context.Context) *WsConn { return newStartedTestWsConn() } newConn := New() defer newConn.Close() if newConn == oldConn { t.Fatal("New should replace the previous global connection") } if GetWsConn() != newConn { t.Fatal("GetWsConn should return the replacement connection") } if !oldConn.isClosed() { t.Fatal("previous global connection should be closed before replacement") } if err := oldConn.enqueueWrite(wsWriteJob{msgType: websocket.TextMessage, data: []byte("ping")}); !errors.Is(err, errWriteLoopStopped) { t.Fatalf("old enqueueWrite error=%v, want %v", err, errWriteLoopStopped) } } func TestGetWsConnDoesNotBlockWhileNewClosesPreviousConn(t *testing.T) { oldCreateFn := createWsConnFn defer func() { createWsConnFn = oldCreateFn }() saveAndRestoreGlobalWsConn(t) release := make(chan struct{}) oldConn := newWsConn(nil, make(chan os.Signal, 1)) oldConn.setDoneChan(make(chan struct{})) oldConn.startLoop(func() { <-release }) wsconnMu.Lock() wsconn = oldConn wsconnMu.Unlock() newConn := newStartedTestWsConn() defer newConn.Close() createWsConnFn = func(context.Context) *WsConn { return newConn } newResult := make(chan *WsConn, 1) go func() { newResult <- New() }() select { case <-oldConn.closeCh: case <-time.After(time.Second): t.Fatal("New did not start closing the previous connection") } getResult := make(chan *WsConn, 1) go func() { getResult <- GetWsConn() }() select { case got := <-getResult: if got != newConn { t.Fatalf("GetWsConn returned %p, want %p", got, newConn) } case <-time.After(200 * time.Millisecond): t.Fatal("GetWsConn blocked while New was waiting for old Close") } close(release) select { case got := <-newResult: if got != newConn { t.Fatalf("New returned %p, want %p", got, newConn) } case <-time.After(time.Second): t.Fatal("New did not finish after releasing old Close") } } func TestSendQueuedMessageDoesNotBlockWhenDisconnectedAndReceiveQueueIsUnavailable(t *testing.T) { conn := newWsConn(nil, make(chan os.Signal, 1)) defer conn.Close() conn.MsgReceiveCh = make(chan string) done := make(chan struct{}) go func() { conn.sendQueuedMessage("1.1.1.1") close(done) }() select { case <-done: case <-time.After(200 * time.Millisecond): t.Fatal("sendQueuedMessage blocked while disconnected") } } func TestSendQueuedMessageDoesNotBlockWhenEnqueueWriteFails(t *testing.T) { conn := newWsConn(nil, make(chan os.Signal, 1)) defer conn.Close() conn.MsgReceiveCh = make(chan string) conn.setConnectionState(true, false) conn.lifecycleMu.Lock() conn.closed = true conn.lifecycleMu.Unlock() done := make(chan struct{}) go func() { conn.sendQueuedMessage("1.1.1.1") close(done) }() select { case <-done: case <-time.After(200 * time.Millisecond): t.Fatal("sendQueuedMessage blocked after enqueueWrite failure") } } func TestMessageReceiveHandlerCloseRaceDoesNotPanic(t *testing.T) { for i := 0; i < 50; i++ { conn := newWsConn(nil, make(chan os.Signal, 1)) conn.setDoneChan(make(chan struct{})) conn.setConnectionState(false, false) started := make(chan struct{}) conn.startLoop(func() { close(started) conn.messageReceiveHandler() }) <-started done := make(chan struct{}) go func() { conn.Close() close(done) }() select { case <-done: case <-time.After(time.Second): t.Fatal("Close hung while messageReceiveHandler was exiting") } } } func TestCreateWsConnHonorsCanceledContextDuringFastIP(t *testing.T) { oldFastIPFn := wsGetFastIPFn defer func() { wsGetFastIPFn = oldFastIPFn }() started := make(chan struct{}) wsGetFastIPFn = func(ctx context.Context, domain string, port string, enableOutput bool) (string, error) { close(started) <-ctx.Done() return "", ctx.Err() } ctx, cancel := context.WithCancel(context.Background()) done := make(chan *WsConn, 1) go func() { done <- createWsConn(ctx) }() <-started cancel() select { case conn := <-done: if conn == nil { t.Fatal("createWsConn returned nil") } defer conn.Close() if conn.IsConnected() { t.Fatal("connection should not be connected after canceled startup") } case <-time.After(100 * time.Millisecond): t.Fatal("createWsConn did not return promptly after cancel") } } func TestRecreateWsConnCloseCancelsFastIP(t *testing.T) { oldFastIPFn := wsGetFastIPFn oldHost, oldPort := host, port defer func() { wsGetFastIPFn = oldFastIPFn host, port = oldHost, oldPort }() started := make(chan struct{}) wsGetFastIPFn = func(ctx context.Context, domain string, port string, enableOutput bool) (string, error) { close(started) <-ctx.Done() return "", ctx.Err() } host = "example.com" port = "443" conn := newStartedTestWsConn() defer conn.Close() done := make(chan struct{}) go func() { conn.recreateWsConn() close(done) }() <-started conn.Close() select { case <-done: case <-time.After(100 * time.Millisecond): t.Fatal("recreateWsConn did not stop promptly after Close") } }