[
  {
    "path": ".cross_compile.sh",
    "content": "#!/usr/bin/env bash\n\nset -Eeuo pipefail\n\n# -------- Config --------\n# Usage: .cross_compile.sh [full|tiny|ntr|all] [debug]\n#   full  — build nexttrace (Full, includes WebUI + Globalping + MTR)\n#   tiny  — build nexttrace-tiny (no WebUI, no Globalping, no MTR)\n#   ntr   — build ntr (MTR-only, default MTR mode)\n#   all   — build all three flavors (default)\n#   debug — enable debug symbols (can combine: .cross_compile.sh all debug)\n\nFLAVOR_ARG=\"${1:-all}\"\nDEBUG_MODE=\"${2:-}\"\n\n# Allow \"debug\" as first arg for backward compat\nif [[ \"${FLAVOR_ARG}\" == \"debug\" ]]; then\n  FLAVOR_ARG=\"all\"\n  DEBUG_MODE=\"debug\"\nfi\n\n# Define flavor specs: \"bin_name:build_tags\"\ndeclare -a FLAVOR_SPECS\ncase \"${FLAVOR_ARG}\" in\n  full) FLAVOR_SPECS=(\"nexttrace:\") ;;\n  tiny) FLAVOR_SPECS=(\"nexttrace-tiny:flavor_tiny\") ;;\n  ntr)  FLAVOR_SPECS=(\"ntr:flavor_ntr\") ;;\n  all)  FLAVOR_SPECS=(\"nexttrace:\" \"nexttrace-tiny:flavor_tiny\" \"ntr:flavor_ntr\") ;;\n  *)\n    echo \"Usage: $0 [full|tiny|ntr|all] [debug]\" >&2\n    exit 1\n    ;;\nesac\n\nTARGET_DIR=\"dist\"\nPLATFORMS=\"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\"\nUPX_BIN=\"${UPX_BIN:-$(command -v upx 2>/dev/null || true)}\"\nUPX_FLAGS=\"${UPX_FLAGS:--9}\"\n\n# -------- Build metadata (robust) --------\nBUILD_VERSION=\"$(git describe --tags --always 2>/dev/null || true)\"\nBUILD_VERSION=\"${BUILD_VERSION:-dev}\"\nBUILD_DATE=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\"\nCOMMIT_SHA1=\"$(git rev-parse --short HEAD 2>/dev/null || true)\"\nCOMMIT_SHA1=\"${COMMIT_SHA1:-unknown}\"\n\n# 通用 ldflags（去掉了内部单引号）\nLD_BASE=\"-X github.com/nxtrace/NTrace-core/config.Version=${BUILD_VERSION} \\\n         -X github.com/nxtrace/NTrace-core/config.BuildDate=${BUILD_DATE} \\\n         -X github.com/nxtrace/NTrace-core/config.CommitID=${COMMIT_SHA1} \\\n         -w -s\"\n\nGO_BUILD_FLAGS=(-trimpath)\nif [[ \"${DEBUG_MODE}\" == \"debug\" ]]; then\n  GO_BUILD_FLAGS=(-trimpath -gcflags \"all=-N -l\")\nfi\n\n# build_one BIN TAGS GOOS GOARCH [EXTRA_ENV...]\nbuild_one() {\n  local bin=\"$1\" tags=\"$2\" goos=\"$3\" goarch=\"$4\"\n  shift 4\n  local target=\"${TARGET_DIR}/${bin}_${goos}_${goarch}\"\n  local target_arm=\"\"\n  # Apply extra env vars (e.g. GOARM=7 suffix)\n  for ev in \"$@\"; do\n    local key=\"${ev%%=*}\" val=\"${ev#*=}\"\n    if [[ \"${key}\" == \"GOARM\" ]]; then\n      target_arm=\"${val}\"\n      target=\"${target}v${val}\"\n    elif [[ \"${key}\" == \"GOMIPS\" && \"${val}\" == \"softfloat\" ]]; then\n      target=\"${target}_softfloat\"\n    fi\n  done\n  if [[ \"${goos}\" == \"windows\" ]]; then\n    target=\"${target}.exe\"\n  fi\n\n  local tags_flag=()\n  if [[ -n \"${tags}\" ]]; then\n    tags_flag=(-tags \"${tags}\")\n  fi\n\n  echo \"build => ${target}  (tags: ${tags:-none})\"\n  env \"$@\" go build \"${GO_BUILD_FLAGS[@]}\" \"${tags_flag[@]}\" -o \"${target}\" -ldflags \"${LD_BASE}\"\n  compress_with_upx \"${target}\" \"${goos}\" \"${goarch}\" \"${target_arm}\" \"quiet\"\n}\n\ncompress_with_upx() {\n  local binary=\"${1:-}\"\n  local target_os=\"${2:-}\"\n  local target_arch=\"${3:-}\"\n  local target_arm=\"${4:-}\"\n  local note=\"${5:-}\"\n  if [[ \"${target_os}\" != \"linux\" ]]; then\n    return\n  fi\n  case \"${target_arch}\" in\n    386|amd64|arm64)\n      ;;\n    arm)\n      if [[ \"${target_arm}\" != \"7\" ]]; then\n        return\n      fi\n      ;;\n    *)\n      return\n      ;;\n  esac\n  if [[ -z \"${UPX_BIN}\" ]]; then\n    return\n  fi\n  if [[ ! -f \"${binary}\" ]]; then\n    return\n  fi\n  if [[ \"${note}\" != \"quiet\" ]]; then\n    echo \"upx => ${binary}\"\n  fi\n  if ! \"${UPX_BIN}\" ${UPX_FLAGS} \"${binary}\" >/dev/null; then\n    echo \"warn: upx failed for ${binary}, keeping uncompressed\" >&2\n  fi\n}\n\nif [[ -z \"${UPX_BIN}\" ]]; then\n  echo \"info: upx not found; set UPX_BIN or install upx to enable binary compression\" >&2\nelse\n  echo \"info: using upx at ${UPX_BIN} with flags ${UPX_FLAGS}\" >&2\nfi\n\necho \"info: building flavor(s): ${FLAVOR_ARG}\" >&2\n\n# -------- Prepare out dir --------\nrm -rf -- \"${TARGET_DIR}\"\nmkdir -p -- \"${TARGET_DIR}\"\n\n# -------- Pure Go targets (CGO off) --------\nfor pl in ${PLATFORMS}; do\n  export CGO_ENABLED=0\n  GOOS=\"${pl%%/*}\"\n  GOARCH=\"${pl#*/}\"\n  export GOOS GOARCH\n\n  for SPEC in \"${FLAVOR_SPECS[@]}\"; do\n    BIN=\"${SPEC%%:*}\"\n    TAGS=\"${SPEC#*:}\"\n    build_one \"${BIN}\" \"${TAGS}\" \"${GOOS}\" \"${GOARCH}\"\n  done\n\n  # Extra soft-float variants for linux/mips and linux/mipsle\n  if [[ \"${GOOS}\" == \"linux\" && ( \"${GOARCH}\" == \"mips\" || \"${GOARCH}\" == \"mipsle\" ) ]]; then\n    for SPEC in \"${FLAVOR_SPECS[@]}\"; do\n      BIN=\"${SPEC%%:*}\"\n      TAGS=\"${SPEC#*:}\"\n      build_one \"${BIN}\" \"${TAGS}\" \"${GOOS}\" \"${GOARCH}\" \"GOMIPS=softfloat\"\n    done\n  fi\ndone\n\n# -------- linux/armv7（CGO off）--------\nexport CGO_ENABLED=0\nexport GOOS='linux'\nexport GOARCH='arm'\nexport GOARM='7'\nfor SPEC in \"${FLAVOR_SPECS[@]}\"; do\n  BIN=\"${SPEC%%:*}\"\n  TAGS=\"${SPEC#*:}\"\n  build_one \"${BIN}\" \"${TAGS}\" \"${GOOS}\" \"${GOARCH}\" \"GOARM=7\"\ndone\n\n# -------- Darwin targets with CGO + SDK libpcap --------\nif [[ \"$(uname)\" == \"Darwin\" ]]; then\n  if ! command -v xcrun >/dev/null 2>&1; then\n    echo \"error: xcrun not found. Please install Xcode Command Line Tools: xcode-select --install\" >&2\n    exit 1\n  fi\n  SDKROOT=\"$(xcrun --sdk macosx --show-sdk-path)\"\n\n  for GOARCH in amd64 arm64; do\n    export CGO_ENABLED=1\n    export GOOS=darwin\n    export CC=clang\n    export CXX=clang++\n\n    if [[ \"${GOARCH}\" == \"amd64\" ]]; then\n      ARCH_FLAG=\"-arch x86_64\"\n    else\n      ARCH_FLAG=\"-arch arm64\"\n    fi\n\n    # 仅提供 SDK/架构/最低系统版本；-lpcap 交由源码中的 #cgo LDFLAGS 处理，避免重复\n    export CGO_CFLAGS=\"-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0\"\n    export CGO_LDFLAGS=\"-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0\"\n\n    for SPEC in \"${FLAVOR_SPECS[@]}\"; do\n      BIN=\"${SPEC%%:*}\"\n      TAGS=\"${SPEC#*:}\"\n      build_one \"${BIN}\" \"${TAGS}\" \"${GOOS}\" \"${GOARCH}\"\n    done\n  done\n\n  # 合并 Universal 2（存在 lipo 才合并）\n  if command -v lipo >/dev/null 2>&1; then\n    for SPEC in \"${FLAVOR_SPECS[@]}\"; do\n      BIN=\"${SPEC%%:*}\"\n      if [[ -f \"${TARGET_DIR}/${BIN}_darwin_amd64\" && -f \"${TARGET_DIR}/${BIN}_darwin_arm64\" ]]; then\n        lipo -create -output \"${TARGET_DIR}/${BIN}_darwin_universal\" \\\n          \"${TARGET_DIR}/${BIN}_darwin_amd64\" \\\n          \"${TARGET_DIR}/${BIN}_darwin_arm64\"\n        echo \"build => ${TARGET_DIR}/${BIN}_darwin_universal\"\n      else\n        echo \"warn: missing one of darwin slices for ${BIN}; skip universal lipo.\" >&2\n      fi\n    done\n  else\n    echo \"warn: lipo not found; skip universal binary.\" >&2\n  fi\nfi\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n---\nname: nexttrace 程序问题\nabout: \"提交一个 nexttrace 的程序问题报告。\"\ncopyright: [v2fly](https://github.com/v2fly)\n---\n\n<!--\n除非特殊情况，请完整填写所有问题。不按模板发的 issue 将直接被关闭。\n如果你遇到的问题不是 nexttrace 的 bug，比如你不清楚如何配置，请在 https://github.com/nxtrace/NTrace-core/discussions 进行讨论。\n-->\n## 本项目是基于Linux/macOS的，请确认您遇到的问题是否在Linux或macOS上存在。\n\n<!-- 是/否 -->\n<!-- 对于只出现在Windows上的问题，本项目有时无法解决，请知悉 -->\n\n\n## 你正在使用哪个版本的 nexttrace？\n\n<!-- 比如linux_amd64 macOS_arm64 -->\n\n\n## 你看到的异常现象是什么？\n\n<!-- 请描述具体现象 -->\n\n\n## 你期待看到的正常表现是怎样的？\n\n\n\n## 请附上你的命令\n\n<!-- 提交 issue 前，请隐去您的隐私信息 -->\n\n\n## 请附上出错时软件输出的错误信息\n\n<!-- 是/否 -->\n\n## 是否查询过本仓库wiki有没有类似错误\n\n<!-- wiki: https://github.com/nxtrace/NTrace-core/wiki -->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build & Release\n\npermissions:\n  contents: read\n\ndefaults:\n  run:\n    shell: bash\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\non:\n  workflow_dispatch:\n  push:\n    branches: [ main ]\n    tags: [ \"v*\" ]\n    paths:\n      - \"**/*.go\"\n      - \"go.mod\"\n      - \"go.sum\"\n      - \".github/workflows/*.yml\"\n  pull_request:\n    types: [opened, synchronize, reopened]\n    paths:\n      - \"**/*.go\"\n      - \"go.mod\"\n      - \"go.sum\"\n      - \".github/workflows/*.yml\"\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    continue-on-error: ${{ matrix.allow_fail || false }}\n    strategy:\n      fail-fast: false\n      matrix:\n        goos: [windows, freebsd, openbsd, linux, dragonfly]\n        goarch: [amd64, 386]\n        exclude:\n          - goarch: 386\n            goos: dragonfly\n        include:\n          - { goos: linux,   goarch: arm,    goarm: 7 }\n          - { goos: linux,   goarch: arm,    goarm: 6 }\n          - { goos: linux,   goarch: arm,    goarm: 5 }\n          - { goos: android, goarch: arm64 }\n          - { goos: windows, goarch: arm64 }\n          # windows/arm 已于 Go 1.26 移除\n          - { goos: linux,   goarch: arm64 }\n          - { goos: linux,   goarch: riscv64 }\n          - { goos: linux,   goarch: loong64 }\n          - { goos: linux,   goarch: mips64 }\n          - { goos: linux,   goarch: mips64le }\n          - { goos: linux,   goarch: mipsle }\n          - { goos: linux,   goarch: mips }\n          - { goos: linux,   goarch: mipsle, gomips: softfloat }\n          - { goos: linux,   goarch: mips,   gomips: softfloat }\n          - { goos: linux,   goarch: ppc64 }\n          - { goos: linux,   goarch: ppc64le }\n          - { goos: freebsd, goarch: arm64 }\n          - { goos: freebsd, goarch: arm,    goarm: 7 }\n          - { goos: linux,   goarch: s390x }\n          - { goos: openbsd, goarch: arm64 }\n          - { goos: openbsd, goarch: arm,    goarm: 7 }\n    env:\n      CGO_ENABLED: ${{ matrix.goos == 'android' && 1 || 0 }}\n      GOOS:   ${{ matrix.goos }}\n      GOARCH: ${{ matrix.goarch }}\n      GOARM:  ${{ matrix.goarm }}\n      GOMIPS: ${{ matrix.gomips }}\n    steps:\n      - name: Checkout codebase\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          fetch-tags: true\n\n      - name: Set up Go (built-in cache)\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.26.x'\n          check-latest: true\n          cache: true\n\n      - name: Show Go toolchain info\n        run: |\n          set -Eeuo pipefail\n          go version && go env && echo \"Supported targets (dist list):\" && go tool dist list | sort -u\n\n      - name: Setup Android NDK\n        if: matrix.goos == 'android'\n        id: setup-ndk\n        uses: nttld/setup-ndk@v1\n        with:\n          ndk-version: r26d\n          add-to-path: false\n\n      - name: Resolve build metadata\n        run: |\n          set -Eeuo pipefail\n          BUILD_VERSION=\"$(git describe --tags --always 2>/dev/null || echo dev)\"\n          BUILD_DATE=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\"\n          COMMIT_SHA1=\"$(git rev-parse --short HEAD 2>/dev/null || echo unknown)\"\n\n          ARM_SUFFIX=\"\"\n          if [ -n \"${GOARM:-}\" ]; then ARM_SUFFIX=\"v${GOARM}\"; fi\n          PLATFORM=\"${GOOS}_${GOARCH}${ARM_SUFFIX}\"\n          EXT=\"\"\n          if [ \"${GOOS}\" = \"windows\" ]; then EXT=\".exe\"; fi\n          SOFT=\"\"\n          if [ \"${GOMIPS:-}\" = \"softfloat\" ]; then SOFT=\"_softfloat\"; fi\n          if [ \"$GOOS\" = \"android\" ]; then\n            ANDROID_API=21\n            TOOLCHAIN_BIN=\"${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin\"\n            export CC=\"$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang\"\n            echo \"CC=${CC}\" >> \"$GITHUB_ENV\"\n          fi\n\n          echo \"BUILD_VERSION=${BUILD_VERSION}\" >> \"$GITHUB_ENV\"\n          echo \"BUILD_DATE=${BUILD_DATE}\"       >> \"$GITHUB_ENV\"\n          echo \"COMMIT_SHA1=${COMMIT_SHA1}\"     >> \"$GITHUB_ENV\"\n          echo \"ARM_SUFFIX=${ARM_SUFFIX}\"       >> \"$GITHUB_ENV\"\n          echo \"PLATFORM=${PLATFORM}\"           >> \"$GITHUB_ENV\"\n          echo \"EXT=${EXT}\"                     >> \"$GITHUB_ENV\"\n          echo \"SOFT=${SOFT}\"                   >> \"$GITHUB_ENV\"\n\n      - name: Get project dependencies\n        run: go mod download\n\n      - name: Install UPX (selected Linux targets)\n        if: >\n          matrix.goos == 'linux' && (\n            matrix.goarch == 'amd64' ||\n            matrix.goarch == '386'   ||\n            matrix.goarch == 'arm64' ||\n            (matrix.goarch == 'arm' && matrix.goarm == '7')\n          )\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y upx\n\n      - name: Build all flavors\n        run: |\n          mkdir -p dist\n          LD_BASE=\"-X github.com/nxtrace/NTrace-core/config.Version=${BUILD_VERSION} \\\n                   -X github.com/nxtrace/NTrace-core/config.BuildDate=${BUILD_DATE} \\\n                   -X github.com/nxtrace/NTrace-core/config.CommitID=${COMMIT_SHA1} \\\n                   -w -s\"\n          for SPEC in \"nexttrace:\" \"nexttrace-tiny:flavor_tiny\" \"ntr:flavor_ntr\"; do\n            BIN=\"${SPEC%%:*}\"\n            TAGS=\"${SPEC#*:}\"\n            NAME=\"${BIN}_${PLATFORM}${EXT}${SOFT}\"\n            BUILD_ARGS=(-trimpath)\n            if [ -n \"$TAGS\" ]; then BUILD_ARGS+=( -tags \"$TAGS\" ); fi\n            BUILD_ARGS+=( -o \"dist/${NAME}\" -ldflags \"$LD_BASE\" )\n            echo \"build => dist/${NAME}  (tags: ${TAGS:-none})\"\n            go build \"${BUILD_ARGS[@]}\"\n            if command -v upx >/dev/null 2>&1; then\n              case \"${GOOS}-${GOARCH}-${GOARM:-}\" in\n                linux-amd64-*|linux-386-*|linux-arm64-*|linux-arm-7) upx -9 \"dist/${NAME}\" ;;\n                *) ;;\n              esac\n            fi\n          done\n\n      - name: Verify tiny/ntr decoupling\n        run: |\n          set -Eeuo pipefail\n          for BIN in \"nexttrace-tiny\" \"ntr\"; do\n            FNAME=\"${BIN}_${PLATFORM}${EXT}${SOFT}\"\n            if go version -m \"dist/${FNAME}\" 2>/dev/null | grep -qE 'github\\.com/gin-gonic/gin|github\\.com/jsdelivr/globalping-cli'; then\n              echo \"FAIL: ${FNAME} contains gin or globalping-cli dependency\"\n              exit 1\n            fi\n          done\n          echo \"Decoupling check passed\"\n\n      - name: Upload artifacts\n        uses: actions/upload-artifact@v7\n        with:\n          name: build-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('-v{0}', matrix.goarm) || '' }}${{ matrix.gomips && format('-{0}', matrix.gomips) || '' }}\n          path: dist/\n          if-no-files-found: error\n\n  build-darwin:\n    runs-on: macos-latest\n    strategy:\n      fail-fast: false\n      matrix: { goarch: [ amd64, arm64 ] }\n    env:\n      CGO_ENABLED: 1\n      GOOS: darwin\n      GOARCH: ${{ matrix.goarch }}\n    steps:\n      - name: Checkout codebase\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          fetch-tags: true\n\n      - name: Set up Go (built-in cache)\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.26.x'\n          check-latest: true\n          cache: true\n\n      - name: Show Go toolchain info\n        run: |\n          set -Eeuo pipefail\n          go version && go env && echo \"Supported targets (dist list):\" && go tool dist list | sort -u\n\n      - name: Sanity check (Xcode/SDK/lipo/pcap)\n        run: |\n          set -Eeuo pipefail\n          xcode-select -p\n          SDKROOT=\"$(xcrun --sdk macosx --show-sdk-path)\"\n          echo \"$SDKROOT\"\n          clang --version || true\n          xcrun -f lipo\n          command -v lipo >/dev/null || (echo \"lipo not found\"; exit 1)\n          test -f \"$SDKROOT/usr/include/pcap/pcap.h\" || (echo \"pcap headers not found in SDK\"; exit 1)\n\n      - name: Resolve build metadata + Darwin CGO flags\n        run: |\n          set -Eeuo pipefail\n          SDKROOT=\"$(xcrun --sdk macosx --show-sdk-path)\"\n          if [ \"${GOARCH}\" = \"amd64\" ]; then ARCH_FLAG=\"-arch x86_64\"; else ARCH_FLAG=\"-arch arm64\"; fi\n          CC=\"$(xcrun -f clang)\";  CXX=\"$(xcrun -f clang++)\"\n          echo \"CC=${CC}\"   >> \"$GITHUB_ENV\"\n          echo \"CXX=${CXX}\" >> \"$GITHUB_ENV\"\n          echo \"CGO_CFLAGS=-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0\"  >> \"$GITHUB_ENV\"\n          echo \"CGO_LDFLAGS=-isysroot ${SDKROOT} ${ARCH_FLAG} -mmacosx-version-min=11.0\" >> \"$GITHUB_ENV\"\n\n          BUILD_VERSION=\"$(git describe --tags --always 2>/dev/null || echo dev)\"\n          BUILD_DATE=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\"\n          COMMIT_SHA1=\"$(git rev-parse --short HEAD 2>/dev/null || echo unknown)\"\n          echo \"BUILD_VERSION=${BUILD_VERSION}\" >> \"$GITHUB_ENV\"\n          echo \"BUILD_DATE=${BUILD_DATE}\"       >> \"$GITHUB_ENV\"\n          echo \"COMMIT_SHA1=${COMMIT_SHA1}\"     >> \"$GITHUB_ENV\"\n\n      - name: Get project dependencies\n        run: go mod download\n\n      - name: Build all flavors (Darwin, CGO+libpcap via SDK)\n        run: |\n          mkdir -p dist\n          LD_BASE=\"-X github.com/nxtrace/NTrace-core/config.Version=${BUILD_VERSION} \\\n                   -X github.com/nxtrace/NTrace-core/config.BuildDate=${BUILD_DATE} \\\n                   -X github.com/nxtrace/NTrace-core/config.CommitID=${COMMIT_SHA1} \\\n                   -w -s\"\n          for SPEC in \"nexttrace:\" \"nexttrace-tiny:flavor_tiny\" \"ntr:flavor_ntr\"; do\n            BIN=\"${SPEC%%:*}\"\n            TAGS=\"${SPEC#*:}\"\n            NAME=\"${BIN}_${GOOS}_${GOARCH}\"\n            BUILD_ARGS=(-trimpath)\n            if [ -n \"$TAGS\" ]; then BUILD_ARGS+=( -tags \"$TAGS\" ); fi\n            BUILD_ARGS+=( -o \"dist/${NAME}\" -ldflags \"$LD_BASE\" )\n            echo \"build => dist/${NAME}  (tags: ${TAGS:-none})\"\n            go build \"${BUILD_ARGS[@]}\"\n          done\n\n      - name: Verify tiny/ntr decoupling\n        run: |\n          set -Eeuo pipefail\n          for BIN in \"nexttrace-tiny\" \"ntr\"; do\n            FNAME=\"${BIN}_${GOOS}_${GOARCH}\"\n            if go version -m \"dist/${FNAME}\" 2>/dev/null | grep -qE 'github\\.com/gin-gonic/gin|github\\.com/jsdelivr/globalping-cli'; then\n              echo \"FAIL: ${FNAME} contains gin or globalping-cli dependency\"\n              exit 1\n            fi\n          done\n          echo \"Decoupling check passed\"\n\n      - name: Upload artifacts\n        uses: actions/upload-artifact@v7\n        with:\n          name: build-darwin-${{ matrix.goarch }}\n          path: dist/\n          if-no-files-found: error\n\n  # Build Universal binaries for all flavors\n  darwin-universal:\n    runs-on: macos-latest\n    needs: [ build-darwin ]\n    steps:\n      - name: Download darwin slices (flatten)\n        uses: actions/download-artifact@v8\n        with:\n          pattern: build-darwin-*\n          merge-multiple: true\n          path: dist\n\n      - name: Make macOS Universal (amd64+arm64) for all flavors\n        run: |\n          set -Eeuo pipefail\n          for BIN in nexttrace nexttrace-tiny ntr; do\n            AMD64=\"dist/${BIN}_darwin_amd64\"\n            ARM64=\"dist/${BIN}_darwin_arm64\"\n            if [ -f \"$AMD64\" ] && [ -f \"$ARM64\" ]; then\n              lipo -create -output \"dist/${BIN}_darwin_universal\" \"$AMD64\" \"$ARM64\"\n              file \"dist/${BIN}_darwin_universal\" || true\n              echo \"Built: dist/${BIN}_darwin_universal\"\n            else\n              echo \"Missing darwin slices for ${BIN}; cannot build universal.\" >&2\n              exit 1\n            fi\n          done\n          ls -l dist\n\n      - name: Upload universal artifacts\n        uses: actions/upload-artifact@v7\n        with:\n          name: build-darwin-universal\n          path: |\n            dist/nexttrace_darwin_universal\n            dist/nexttrace-tiny_darwin_universal\n            dist/ntr_darwin_universal\n          if-no-files-found: error\n\n  # Download all build artifacts and publish to a single release\n  release:\n    runs-on: ubuntu-latest\n    needs: [ build, build-darwin, darwin-universal ]\n    if: startsWith(github.ref, 'refs/tags/v')\n    steps:\n      - name: Download all artifacts (flatten)\n        uses: actions/download-artifact@v8\n        with:\n          pattern: build-*\n          merge-multiple: true\n          path: dist_release\n\n      - name: Show downloaded files\n        run: |\n          set -Eeuo pipefail\n          ls -lah dist_release\n          test -n \"$(find dist_release -type f -print -quit)\" || { echo \"No artifacts found in dist_release\"; exit 1; }\n\n      - name: Create GitHub Release (draft)\n        uses: softprops/action-gh-release@v2\n        with:\n          draft: true\n          name: ${{ github.ref_name }}\n          tag_name: ${{ github.ref_name }}\n          files: dist_release/*\n          token: ${{ secrets.GT_TOKEN }}\n          fail_on_unmatched_files: true\n\n  publish-new-formula:\n    runs-on: ubuntu-latest\n    needs: [ release ]\n    if: startsWith(github.ref, 'refs/tags/v')\n    steps:\n      - name: config git\n        run: |\n          git config --global user.email \"${{ secrets.GIT_MAIL }}\"\n          git config --global user.name \"${{ secrets.GIT_NAME }}\"\n      - name: Clone repo\n        run: git clone https://github.com/nxtrace/homebrew-nexttrace.git\n      - name: Exec script\n        run: |\n          set -Eeuo pipefail\n          cd homebrew-nexttrace\n          bash genFormula.sh\n      - name: Git Push\n        run: |\n          set -Eeuo pipefail\n          cd homebrew-nexttrace\n          git add -A\n          git commit -m 'Publish a new version with Formula' || true\n          git remote set-url origin https://${{ secrets.GT_TOKEN }}@github.com/nxtrace/homebrew-nexttrace.git\n          git push\n      - run: echo \"🍏 This job's status is ${{ job.status }}.\"\n"
  },
  {
    "path": ".github/workflows/publishNewFormula.yml",
    "content": "name: Publish New Formula\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\n# Controls when the action will run. Workflow runs when manually triggered using the UI\n# or API.\non:\n  workflow_dispatch:\n\n# A workflow run is made up of one or more jobs that can run sequentially or in parallel\njobs:\n  # This workflow contains a single job called \"greet\"\n  publish-new-formula:\n    # The type of runner that the job will run on\n    runs-on: ubuntu-latest\n\n    # Steps represent a sequence of tasks that will be executed as part of the job\n    steps:\n      # Runs a single command using the runners shell\n      - name: config git\n        run: |\n          git config --global user.email \"${{ secrets.GIT_MAIL }}\"\n          git config --global user.name \"${{ secrets.GIT_NAME }}\"\n      - name: Clone repo\n        run: |\n          git clone https://github.com/nxtrace/homebrew-nexttrace.git\n      - name: Exec script\n        run: |\n          set -Eeuo pipefail\n          cd homebrew-nexttrace\n          bash genFormula.sh\n      # - name: setup SSH keys and known_hosts\n      #   run: |\n      #     mkdir -p ~/.ssh\n      #     ssh-keyscan github.com >> ~/.ssh/known_hosts\n      #     ssh-agent -a $SSH_AUTH_SOCK > /dev/null\n      #     ssh-add - <<< \"${{ secrets.ID_RSA }}\"\n      #   env:\n      #     SSH_AUTH_SOCK: /tmp/ssh_agent.sock\n      - name: Git Push\n        run: |\n          set -Eeuo pipefail\n          cd homebrew-nexttrace\n          git add -A\n          git commit -m 'Publish a new version with Formula' || true\n          git remote set-url origin https://${{ secrets.GT_TOKEN }}@github.com/nxtrace/homebrew-nexttrace.git\n          git push\n        # env:\n        #   SSH_AUTH_SOCK: /tmp/ssh_agent.sock\n      - run: echo \"🍏 This job's status is ${{ job.status }}.\"\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - \"**/*.go\"\n      - \"go.mod\"\n      - \"go.sum\"\n      - \".github/workflows/*.yml\"\n  pull_request:\n    types: [opened, synchronize, reopened]\n    paths:\n      - \"**/*.go\"\n      - \"go.mod\"\n      - \"go.sum\"\n      - \".github/workflows/*.yml\"\n  workflow_dispatch:\n\njobs:\n  test:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, ubuntu-latest, macos-latest]\n    steps:\n      - name: Checkout codebase\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          fetch-tags: true\n\n      - name: Set up Go (built-in cache)\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.26.x'\n          check-latest: true\n          cache: true\n\n      - name: Test with unix\n        if: ${{ matrix.os != 'windows-latest' }}\n        shell: bash\n        run: |\n          sudo go env -w GOTOOLCHAIN=go1.26.0+auto\n          sudo go test -v -covermode=count -coverprofile='coverage.out' ./...\n\n      - name: Test with windows\n        if: ${{ matrix.os == 'windows-latest' }}\n        run: |\n          go env -w GOTOOLCHAIN=go1.26.0+auto\n          go test -v -covermode=count -coverprofile=\"coverage.out\" ./...\n"
  },
  {
    "path": ".github/workflows/triggerDebRepo.yml",
    "content": "name: Trigger Deb Repo\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.release.id }}\n  cancel-in-progress: true\n\non:\n  release:\n    types: [published, prereleased]\n\njobs:\n  trigger-deb-repo:\n    runs-on: ubuntu-latest\n    steps:\n      - env:\n          GITHUB_TOKEN: ${{ secrets.GT_TOKEN }}  # 操作 deb 仓库的 PAT\n        run: |\n          curl -X POST -H \"Authorization: Bearer $GITHUB_TOKEN\" \\\n          -H \"Accept: application/vnd.github+json\" \\\n          https://api.github.com/repos/nxtrace/nexttrace-debs/actions/workflows/build.yaml/dispatches \\\n          -d '{\"ref\": \"main\", \"inputs\": {\"tag\": \"${{ github.event.release.tag_name }}\"}}'\n"
  },
  {
    "path": ".gitignore",
    "content": "### VisualStudioCode template\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n*.code-workspace\n\n# Local History for Visual Studio Code\n.history/\n\n### JetBrains template\n# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider\n# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839\n\n# User-specific stuff\n.idea/**/workspace.xml\n.idea/**/tasks.xml\n.idea/**/usage.statistics.xml\n.idea/**/dictionaries\n.idea/**/shelf\n\n# Generated files\n.idea/**/contentModel.xml\n\n# Sensitive or high-churn files\n.idea/**/dataSources/\n.idea/**/dataSources.ids\n.idea/**/dataSources.local.xml\n.idea/**/sqlDataSources.xml\n.idea/**/dynamic.xml\n.idea/**/uiDesigner.xml\n.idea/**/dbnavigator.xml\n\n# Gradle\n.idea/**/gradle.xml\n.idea/**/libraries\n\n# Gradle and Maven with auto-import\n# When using Gradle or Maven with auto-import, you should exclude module files,\n# since they will be recreated, and may cause churn.  Uncomment if using\n# auto-import.\n# .idea/artifacts\n# .idea/compiler.xml\n# .idea/jarRepositories.xml\n# .idea/modules.xml\n# .idea/*.iml\n# .idea/modules\n# *.iml\n# *.ipr\n\n# CMake\ncmake-build-*/\n\n# Mongo Explorer plugin\n.idea/**/mongoSettings.xml\n\n# File-based project format\n*.iws\n\n# IntelliJ\nout/\n\n# mpeltonen/sbt-idea plugin\n.idea_modules/\n\n# JIRA plugin\natlassian-ide-plugin.xml\n\n# Cursive Clojure plugin\n.idea/replstate.xml\n\n# Crashlytics plugin (for Android Studio and IntelliJ)\ncom_crashlytics_export_strings.xml\ncrashlytics.properties\ncrashlytics-build.properties\nfabric.properties\n\n# Editor-based Rest Client\n.idea/httpRequests\n\n# Android studio 3.1+ serialized cache file\n.idea/caches/build_file_checksums.ser\n\n### Example user template template\n### Example user template\n\n# IntelliJ project files\n.idea\n*.iml\nout\ngen\n\n### Go template\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Unignore DLLs under assets/windivert\n!assets/windivert/**/*.dll\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n### Windows template\n# Windows thumbnail cache files\nThumbs.db\nThumbs.db:encryptable\nehthumbs.db\nehthumbs_vista.db\n\n# Dump file\n*.stackdump\n\n# Folder config file\n[Dd]esktop.ini\n\n# Recycle Bin used on file shares\n$RECYCLE.BIN/\n\n# Windows Installer files\n*.cab\n*.msi\n*.msix\n*.msm\n*.msp\n\n# Windows shortcuts\n*.lnk\n\n### macOS template\n# General\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Icon must end with two \\r\nIcon\n\n# Thumbnails\n._*\n\n# Files that might appear in the root of a volume\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.com.apple.timemachine.donotpresent\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n# compile target directory\ndist/\n\nNTrace-core\n\n.gocache\n.gomodcache\n.cache\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# NTrace-core 项目记忆文件（2026-03 快照，rev-3）\n\n# 供 LLM 在后续会话中快速加载上下文，减少重复分析。\n\n## 项目概览\n\n- 名称：NextTrace (NTrace-core)\n- 仓库：github.com/nxtrace/NTrace-core\n- 模块：`github.com/nxtrace/NTrace-core`\n- 语言：Go（`go 1.26.0`）\n- 入口：`main.go -> cmd.Execute()`\n- 核心能力：ICMP/TCP/UDP traceroute、GeoIP/RDNS、MTR 连续探测、Web/API、多平台构建\n\n## 构建与测试（必须遵守）\n\n- 常用命令：\n  - 构建：`go build ./...`\n  - 测试：`go test ./...`\n- 交叉编译脚本：`.cross_compile.sh`\n- Darwin 下 `trace/internal/icmp_darwin.go` 已不再使用 `//go:linkname`，改为\n  `syscall.Socket` + `os.NewFile` + 自定义 `icmpPacketConn`（实现 `net.PacketConn` /\n  `net.Conn` / `syscall.Conn` + `ReadMsgIP` 以满足 `x/net/internal/socket.ipConn`\n  接口），并在 `ReadFrom` 中调用 `stripIPv4Header` 剥离 macOS DGRAM ICMP socket\n  返回的外层 IP 头。\n\n## 当前 CLI 语义（重点）\n\n### 常规 traceroute 路径\n\n- `--table`：现在是\"最终汇总表\"模式（一次探测完成后输出汇总表），不再是旧的异步 table 刷新模式。\n- `--route-path`：仍由 `reporter.New(...).Print()` 负责（与 MTR report 无关）。\n\n### 独立 `--mtu` 路径\n\n- `--mtu`：独立 UDP path-MTU / tracepath 风格模式，不复用普通 `trace.Traceroute` / MTR / Web 路径。\n- flavor 可用性：仅 `nexttrace` / `nexttrace-tiny` 包含；`ntr` 不注册该 flag。\n- 输出：\n  - `TTY`：当前 TTL 占位后原地更新，边探测边刷行。\n  - `非 TTY`：TTL 定稿后逐行流式输出，不使用 renderer 自己的 ANSI 控制序列。\n  - `--json`：输出独立 mtu schema；`hop.geo` 已存在。\n- 参数语义：\n  - 复用 `--data-provider`、`--language`、`--no-rdns`、`--always-rdns`、`--dot-server`。\n  - `--mtu` 仍只支持 UDP；显式 `--tcp` 冲突报错。\n- Geo/RDNS：\n  - `trace/mtu` 自带独立 metadata helper，不依赖普通 `trace.Hop.fetchIPData`。\n  - 流式事件会先输出基础 hop，再在同一 TTL 内补一条带 Geo/RDNS 的 update，最后 `ttl_final` 定稿。\n  - macOS 上曾有 `Warning: macOS --mtu support is experimental.` 提示，现已删除；不要再假设 CLI 会打印这句。\n\n### `--psize` / `--tos` 语义与平台差异\n\n- `--psize` 现在统一对齐 `mtr -s/--psize`：\n  - 用户输入语义是“含 IP + 当前探测协议头的总字节数”。\n  - 内部 `trace.Config.PktSize` 仍保存 payload bytes。\n  - 未显式传入时，不再固定默认 `52`，而是按协议/IP 族自动取最小合法值：\n    - ICMPv4 / UDPv4 = `28`\n    - TCPv4 = `44`\n    - ICMPv6 = `48`\n    - UDPv6 = `50`\n    - TCPv6 = `64`\n  - 负数 `--psize` 表示“每个 probe 独立随机”，CLI 允许 `--psize -84` 这种写法并会在解析前归一化。\n- `--tos` / `-Q`：\n  - 范围固定 `0..255`。\n  - `--mtu` 与 Globalping 显式传 `--psize` / `--tos` 会直接报不支持。\n- 平台发送路径差异（这是后续判断 bug 的关键记忆）：\n  - Linux / 其他 Unix：\n    - `ICMP/TCP/UDP` 的 IPv4/IPv6 都走原生 socket/raw socket 路径。\n    - `--tos` 只是在现有路径上设置 `TOS/TrafficClass`，不会切换实现。\n  - macOS：\n    - 与 Linux 类似，`ICMP/TCP/UDP` 的 IPv4/IPv6 都走原生发送路径。\n    - `--tos` 同样只是在现有路径上设置 `TOS/TrafficClass`。\n  - Windows：\n    - `TCP/UDP` 的 IPv4/IPv6 一直走 WinDivert raw send。\n    - `ICMPv4` 一直走 socket path（`SetTOS` / `SetTTL`）。\n    - `ICMPv6`：\n      - 默认或 `--tos 0`：继续走原生 socket path，只设置 `HopLimit`，保持与 `v1.5.2` 一致。\n      - 非零 `--tos`：切到 WinDivert raw send，直接发送完整 `IPv6 + ICMPv6` 报文，因为 Windows 的 `x/net/ipv6.PacketConn` 不能可靠设置 `TrafficClass`。\n    - 因此，Windows 上只有“`ICMPv6` 且 `--tos != 0`”这个组合会额外依赖 WinDivert 发送能力；README 中英两份都已写明。\n\n### 间隔默认值（分层体系）\n\n- `-z/--send-time`：每包间隔，默认 `defaultPacketIntervalMs = 50` ms。\n- `-i/--ttl-time`：\n  - **常规 traceroute**：TTL 分组间隔，默认 `defaultTracerouteTTLIntervalMs = 300` ms。\n  - **MTR 模式**：`normalizeMTRTraceConfig()` 始终覆盖为 `defaultMTRInternalTTLIntervalMs = 0` ms（各 TTL 间不间隔）。\n  - MTR 每跳探测间隔由 `-i` 显式传值 或 默认 1000ms 决定（见下文 `-q/-i` 语义）。`-z/--send-time` 在 MTR 模式下被忽略。\n\n### MTR 相关参数\n\n- `-t/--mtr`：开启 MTR 交互模式（TTY 全屏 TUI）。\n- `-r/--report`：MTR 报告模式（非交互），隐式开启 MTR。\n- `-w/--wide`：宽报告模式，隐式等价 `--mtr --report --wide`。\n- `--raw`：与 MTR 组合时进入 **MTR raw 流式模式**（`runMTRRaw`），不再与 MTR 冲突。\n- 有效 MTR 开关：`effectiveMTR = mtr || report || wide`。\n- MTR 三路分支（`chooseMTRRunMode`）：\n  1. `effectiveMTRRaw` → `runMTRRaw`（流式行输出，适合管道/脚本）\n  2. `effectiveReport` → `runMTRReport`（非交互报告表）\n  3. 默认 → `runMTRTUI`（全屏 TUI）\n- MTR 冲突参数（会直接报错退出）：`--table` `--classic` `--json` `--output` `--route-path` `--from` `--fast-trace` `--file` `--deploy`。\n  - **注意**：`--raw` 不再是冲突参数。\n\n### MTR 中 `-q/-i/-y` 的新语义\n\n- `-q/--queries`：\n  - 在 MTR report 下表示每跳探测次数，默认 10（仅当用户未显式传 `-q`）。\n  - 在 MTR TUI 下表示每跳最大探测次数，未显式传时默认无限运行。\n- `-i/--ttl-time`：\n  - 在 MTR 下表示每跳探测间隔毫秒，默认 1000ms（仅当用户未显式传 `-i`）。\n  - 各 TTL 间内部扫描间隔固定 0ms（`normalizeMTRTraceConfig` 覆盖为 `defaultMTRInternalTTLIntervalMs = 0`）。\n  - `-z/--send-time` 在 MTR 模式下被忽略。\n- `-y/--ipinfo <0..4>`：\n  - TUI 初始 Host 显示模式，默认 0（IP/PTR only）。\n  - 0=Base(IP/PTR) 1=ASN 2=City 3=Owner 4=Full\n  - 仅 TUI 模式生效，report/raw 不受影响。\n\n### MTR report wide / non-wide 区别\n\n- **wide 模式**（`-w` 或 `--mtr --report --wide`）：\n  - 查询 GeoIP，显示完整 host 信息（ASN + geo + MPLS）。\n- **非 wide 模式**（`-r` 或 `--mtr --report`）：\n  - `normalizeMTRReportConfig` 设 `IPGeoSource=nil`（不查 geo）、`AlwaysWaitRDNS=true`。\n  - 显示 `formatCompactReportHost`：仅 IP/PTR + ASN，无 geo 列。\n\n## MTR 运行链路（重要文件）\n\n- 入口与调度：`cmd/mtr_mode.go`（~315 行）\n  - `runMTRTUI(...)` / `runMTRReport(...)` / `runMTRRaw(...)`\n  - `normalizeMTRTraceConfig(conf)` / `normalizeMTRReportConfig(conf, wide)`\n  - `buildAPIInfo(...)` / `buildRawAPIInfoLine(...)`\n  - MTR CLI 现在统一使用 `signal.NotifyContext(...)` 管理 Ctrl-C / SIGTERM；不再保留额外的 `sigCh` + goroutine 等待器。\n- 交互控制：`cmd/mtr_ui.go`\n  - alternate screen + raw mode\n  - 输入状态机 `mtrInputParser`（字节流，吞掉 CSI/SS3/OSC/鼠标/焦点等序列）\n  - Enter/Leave 显式关闭输入扩展模式：1000/1002/1003/1006/1015/1004/2004\n  - Quit 路径会先判空 `cancel`，因此 `newMTRUI(nil, ...)` / 测试注入 nil 不会 panic。\n- 核心探测循环：`trace/mtr_runner.go`\n  - `RunMTR` / `mtrLoop` / `RunMTRRaw`\n  - 支持暂停、重置、流式预览（`ProgressThrottle` 默认 200ms）\n  - ICMP 持久引擎 + TCP/UDP fallback\n- 统计聚合：`trace/mtr_stats.go`\n  - `MTRAggregator` / `MTRHopStat`\n  - unknown 合并策略：单路径时把 unknown 合并到唯一已知路径，避免同 TTL 分裂成 waiting + 真实 IP 两行\n- 输出层：\n  - TUI：`printer/mtr_tui.go`\n  - table/report：`printer/mtr_table.go`\n  - raw 行格式化：`printer.FormatMTRRawLine(rec)`\n  - TUI 颜色：`printer/mtr_tui_color.go`\n\n## MTR 交互行为（当前）\n\n- `q`/`Q`/`Ctrl-C`：退出\n- `p`：暂停\n- `SPACE`：恢复\n- `r`：重置统计\n- `y`：切换 Host 显示模式（IP/PTR → ASN → City → Owner → Full → 循环）\n- `n`：切换 Host 基名显示（PTR-or-IP / IP-only）\n- `e`：切换 MPLS 标签显示（toggle MPLS on/off）\n\n## MTR 显示与统计规则（当前）\n\n- Host 显示支持 5 种模式（`-y/--ipinfo` 设初始值，`y` 键运行时循环）：\n  - `HostModeBase=0`：仅 IP/PTR，无 ASN 前缀\n  - `HostModeASN=1` / `HostModeCity=2` / `HostModeOwner=3` / `HostModeFull=4`\n  - `HostNamePTRorIP` / `HostNameIPOnly`\n- 默认语言：`cn`（`--language en` 才优先英文字段）\n- waiting 判定：`loss >= 99.95 && IP==\"\" && Host==\"\"`\n  - 显示为 `(waiting for reply)`\n  - 指标列（Loss/Snt/Last/Avg/Best/Wrst/StDev）留空\n- TUI Host 对齐（重要，已从 tab 改为手动空格）：\n  - `buildTUIHostParts(stat, mode, nameMode, lang, showIPs)` 生成结构化 parts\n  - `computeTUIASNWidth(stats, ...)` 扫描所有 hop 确定 ASN 列最大宽度\n  - `formatTUIHost(parts, asnW)` 用 `padRight(asn, asnW)` + 空格拼接（不用 `\\t`）\n  - ASN 为空但 IP 已知时填 `\"AS???\"` 占位符，保证列对齐（HostModeBase 除外，该模式不显示 ASN）\n  - waiting hop 不填占位符\n- compact report host（非 wide report）：\n  - `formatCompactReportHost(stat, nameMode, lang)` 仅输出 hostname/IP + ASN\n- TUI 其他特性：\n  - 终端宽度自适应 + CJK 宽度计算（go-runewidth）\n  - 窄屏右锚定指标区\n  - 动态 hop 前缀宽度（覆盖 3 位/4 位 TTL）\n  - MPLS 独立续行显示\n  - 紧凑指标列宽度：Loss=5 Snt=3 RTT=7 RTTMin=5\n\n## MTR 目的地检测与高 TTL 丢弃\n\n- 当 `knownFinalTTL` 已确定后，所有 `TTL > knownFinalTTL` 的调度槽位被标记为 `disabled`。\n- disabled TTL 的探测回包（包括在途探测返回的 dst-ip 回复）**一律丢弃**，不折叠、不计入任何统计。\n- **MaxPerHop 上限检查**（`states[originTTL].completed + inFlightCount >= MaxPerHop`）：\n  - 调度时使用 `completed + inFlightCount >= MaxPerHop` 防止超发。\n  - 完成时仍检查 `completed >= MaxPerHop` 丢弃溢出结果。\n- `originTTL < curFinal` 时（更低 TTL 先到 dst-ip → 降低 `knownFinalTTL`）：\n  - 保存 `oldFinal`，更新 `knownFinalTTL = originTTL`，disable 所有 `originTTL+1..maxHops`。\n  - 调用 `agg.ClearHop(oldFinal)`：清除旧 finalTTL 的聚合数据（避免幽灵行），**不合并**到新 finalTTL。\n  - 新 finalTTL 由独立的 per-hop 调度器自行积累新鲜探测数据，不存在 Snt 膨胀问题。\n- 调度状态（`inFlightCount`/`nextAt`/`consecutiveErrs`）更新在 `originTTL`。\n- 统计聚合（`completed++`/`agg.Update`/`onProbe`）均使用 `originTTL`（不再有 `accountTTL` 分离）。\n\n## MTR Per-Hop 调度器关键设计（当前）\n\n- **多 in-flight 探测**：每 TTL 允许最多 `MaxInFlightPerHop`（默认 3）个并发探测。\n  - `mtrHopState.inFlightCount` 是计数器（非 bool）。\n  - 这解决了高丢包 hop 因超时阻塞导致 Snt 积累速率远低于低丢包 hop 的问题。\n- **nextAt 基于发送时间**：`launchProbe` 时设 `nextAt = now + hopInterval`。\n  - 不再等探测完成才设 nextAt，调度器可在超时探测还在飞行中时为同一 TTL 发射新探测。\n  - 这保证了所有 TTL 的 Snt 积累速率大致相同，不受丢包率影响。\n- **全局并发限制**：`inFlight`（全局计数器）< `parallelism` 仍然有效。\n- **`MaxInFlightPerHop` 配置**：`mtrSchedulerConfig.MaxInFlightPerHop`，默认动态计算。\n  - 动态默认 = `ceil(Timeout / HopInterval) + 1`（至少 1）。\n  - 例：`Timeout=2s, HopInterval=1s` → 默认 3；`Timeout=2s, HopInterval=200ms` → 默认 11。\n  - 用户显式设置 > 0 时优先使用用户值。\n\n## MTR 引擎关键机制（易踩坑）\n\n- 目的地提前停止：\n  - `knownFinalTTL`（持久缓存）用于缩短后续探测的 TTL 上界；高 TTL 标记 disabled 后不再调度。\n- seq 16 位回卷处理：\n  - `seqWillWrap(...)` 触发 `rotateEngine(...)`\n  - 轮换 echoID 并重建 listener，协议层隔离新旧回包。\n- 额外安全网：\n  - onICMP 中有 RTT 合理性检查（`<=0` 或 `>timeout` 丢弃）。\n- 流式预览：\n  - 仅已发送 TTL 才会参与预览；未发送 TTL 保持 nil 槽位，避免提前计入 Snt/Loss。\n\n## Web Console / WebSocket（server/）\n\n### WS 架构（`server/ws_handler.go`，~451 行）\n\n- **异步写模型**：`wsTraceSession` 使用 `sendCh`（buffered channel，1024）+ `writeLoop` goroutine。\n  - 调用方通过 `send(envelope)` 非阻塞投递；channel 满时返回 `errWSSlowConsumer`。\n  - `writeLoop` 从 `sendCh` 取消息，`SetWriteDeadline` + `WriteJSON`。\n- **关闭路径**：\n  - `closeWithCode(code, reason)`：异常关闭（slow consumer / write error），关 `stopCh` + 发 close frame。\n  - `finish()`：正常结束，`sendMu` 下关 `sendCh`，等 `writerDone`，再关 conn。\n  - 两者均幂等（`closeOnce` / `finishOnce`）。\n- **可测试性**：`wsConn` 接口 + `fakeWSConn` mock（`server/ws_handler_test.go`）。\n- **常量**：`wsSendQueueSize=1024`，`wsWriteTimeout=5s`。\n\n### Web MTR 调度模式（重要变更）\n\n- **已从 round-based 迁移到 per-hop 调度**。\n- `runMTRTrace()`：\n  - 优先读 `HopIntervalMs`，fallback `IntervalMs`，再缺省 1000ms。\n  - `MaxRounds` → `MaxPerHop`（0 = 无限运行直到客户端断开）。\n  - 不再使用 legacy round-based 的 `Interval` / `RunRound`。\n- `executeMTRRaw()` 两路分支：\n  - `HopInterval > 0`：per-hop 模式，仅在 LeoMoe/FastIP 初始化阶段短暂加锁；长期探测不再依赖 `SrcDev` / `DisableMPLS` 等进程级全局。\n  - fallback：legacy round-based 模式（保留兼容），`RunRound` 回调内 per-round 锁定。\n  - `trace/runMTRRawRoundBased()` 也会先做 `normalizeRuntimeConfig(&cfg)`，因此 legacy raw 路径同样能继承 `SourceDevice`；`DisableMPLS` 不再从全局反向覆盖会话配置。\n- `traceRequest` 新增 `HopIntervalMs` 字段（`json:\"hop_interval_ms\"`），与 `IntervalMs` 解耦。\n- 前端 MTR 请求现在发送 `hop_interval_ms=1000`，不再把旧的 `interval_ms=2000` 当默认值。\n- 前端 raw 聚合键现在按 TTL 折叠，避免同一 hop 的 timeout / success 被拆成两行。\n\n### 前端渲染节流（`server/web/assets/app.js`）\n\n- MTR raw 消息通过 `scheduleMTRRender()` 节流，最小间隔 100ms，优先 `requestAnimationFrame`。\n- `cancelScheduledMTRRender()` 在 `clearResult`、socket close/error 路径调用，避免孤儿回调。\n- `flushMTRRender()` 立即执行挂起渲染。\n\n### 其他 server 文件\n\n- `server/server.go`：Gin 路由注册\n- `server/handlers.go`：REST 接口\n- `server/mtr.go`：MTR 专用 handler 逻辑\n- `server/trace_handler.go`：traceroute handler\n- `server/cache_handler.go`：缓存\n\n## DoT 与 Geo DNS\n\n- `--dot-server` 不仅影响目标域名解析，也影响 GeoIP API / LeoMoe FastIP 的域名解析链路。\n- 关键文件：`util/dns_resolver.go`\n  - `SetGeoDNSResolver(dotServer)`\n  - `WithGeoDNSResolver(dotServer, fn)`：为 Web/API 请求提供作用域化的 resolver 切换；不同 resolver 串行切换，相同 resolver 允许安全嵌套，避免 `GetSourceWithGeoDNS` + 外层作用域组合时死锁。\n  - `geoResolverOverride` 的读写现在也走 `geoMu`，避免测试覆盖 resolver 时的数据竞争。\n  - `LookupHostForGeo(ctx, host)`：IP 字面量短路 -> DoT -> 失败时按配置 fallback 系统 DNS\n- `cmd/cmd.go` 在早期阶段（fast-trace / ws 初始化之前）注入 DoT 解析策略，避免早期分支绕过。\n- `server/trace_handler.go` 通过 `ipgeo.GetSourceWithGeoDNS(...)` + `WithGeoDNSResolver(...)` 让 Web/API 请求也遵守 `dot_server`，包括 LeoMoe/FastIP 初始化阶段。\n- Geo HTTP 请求统一走 `util.NewGeoHTTPClient(...)`（`util/http_client_geo.go`），其 Transport 现在从默认 Transport `Clone()` 而来，保留代理/HTTP2/连接池等标准行为。\n\n## LeoMoe FastIP 与 MTR 首行\n\n- `util/latency.go`：\n  - `FastIPMetaCache` 缓存节点元数据（IP/Latency/NodeName）\n  - `SuppressFastIPOutput` 可抑制彩色横幅\n- `GetFastIP(...)` 的 DNS 阶段现在显式受 `timeout` 限制；`FastIPMetaCache` 也改为在 fallback/default IP 决定后再写入，避免缓存空 IP。\n- MTR 模式在进入 TUI 前会设 `SuppressFastIPOutput=true`，避免污染主终端历史。\n- MTR TUI/report 首行 `APIInfo` 由 `cmd/mtr_mode.go` 的 `buildAPIInfo(...)` 生成（仅 LeoMoeAPI 且有元数据时显示）。\n- MTR raw 首行由 `buildRawAPIInfoLine(...)` 生成（格式略不同，包含延迟信息）。\n\n## `--source` / `--dev` 现状\n\n- `--dev` 在 `cmd/cmd.go` 先解析网卡并推导 `srcAddr`（已处理非 `*net.IPNet` 地址类型，避免 panic）。\n- `trace.Config` 现在显式携带 `SourceDevice` / `DisableMPLS`，Darwin TCP/UDP 抓包与 MPLS 解析优先走会话级配置，不再依赖 Web 侧临时改写全局变量。\n- `trace.Config` 也显式携带 `Context`；`TracerouteWithContext(...)` 通过把上游 ctx 传入各 tracer 的 `signal.NotifyContext(...)` 基底，让 TCP/UDP fallback MTR 可以响应取消。\n- Windows TCP 目前仍无法把 `SourceDevice` 映射到 WinDivert 接口选择；当前策略是显式报错拒绝，而不是静默忽略该字段。\n- MTR 标题显示源信息来自：\n  - `--source`（最高优先）\n  - `--dev` 推导\n  - UDP dial fallback\n- 相关函数：`cmd/mtr_mode.go -> resolveSrcIP(...)`\n\n## CI 与工具链（当前）\n\n- `go.mod`: `go 1.26.0`\n- GitHub Actions：\n  - `.github/workflows/build.yml` 使用 `setup-go@v6` + `go-version: 1.26.x`\n  - `.github/workflows/test.yml` 使用 `setup-go@v6` + `go-version: 1.26.x`\n  - test workflow 中 `GOTOOLCHAIN=go1.26.0+auto`\n  - build matrix 已移除 `windows/arm`\n- `.cross_compile.sh` 与 workflow 里的 `go build` 现在都用数组构造 `-tags` 参数，避免 shell word-splitting；脚本也会把当前 `GOARM` 传给 `compress_with_upx`，使 linux/armv7 目标能命中对应压缩分支。\n- `ipgeo/ipdbone.go` 不再原地修改全局 `defaultClient.httpClient.Timeout`；超时覆盖会通过克隆 client（复用 token cache / token init，同步替换整个 HTTP client）实现，避免 dial timeout 与 client timeout 脱节。\n\n## 关键文件导航\n\n- CLI 主调度：`cmd/cmd.go`（~855 行）\n- MTR 参数/流程：`cmd/mtr_mode.go`（~315 行）\n- MTR 交互输入：`cmd/mtr_ui.go`\n- MTR 引擎：`trace/mtr_runner.go`\n- MTR 聚合：`trace/mtr_stats.go`\n- MTR TUI：`printer/mtr_tui.go`（~691 行）\n- MTR table/report：`printer/mtr_table.go`（~625 行）\n- MTR TUI 颜色：`printer/mtr_tui_color.go`\n- WS handler：`server/ws_handler.go`（~449 行）\n- 前端：`server/web/assets/app.js`\n- Geo DoT 解析：`util/dns_resolver.go`\n- Geo HTTP 客户端：`util/http_client_geo.go`\n- FastIP：`util/latency.go`\n\n## 2026-03 Gocyclo 重构快照\n\n- 第一波低风险重构已落地：\n  - `ipgeo.Filter` 改为 CIDR 规则表驱动。\n  - `util.DomainLookUp` 拆成 resolver / lookup / family filter / interactive select 四段。\n  - `server.prepareTrace`、`normalizeTarget`、`trace.Traceroute`、`trace.Hop.fetchIPData` 已改成薄协调器。\n  - `GetMTUByIPForDevice`、`GetICMPResponsePayload`、`parseIPDBOneResponse`、`GlobalpingFormatLocation` 已拆 helper。\n- 第二波主流程/输出层已部分落地：\n  - `cmd.Execute` 已拆成 parser 注册 helper、启动模式 helper、运行时调度 helper。\n  - `fast_trace.FastTest` / `testFile` 已拆成交互选择、源地址推导、文件目标解析、单目标执行。\n  - `server.mtrAggregator.Update`、`wshandle.messageSendHandler` 已改成薄入口。\n  - `printer.RealtimePrinter`、`RealtimePrinterWithRouter`、`tracelog.RealtimePrinter` 现在共用 `internal/hoprender` 的 hop attempt 分组逻辑。\n- 第三波协议层已开始落地：\n  - `trace/internal/icmp_common.go`、`tcp_common.go`、`udp_common.go` 已改成“读包循环 + 共享解码 helper + 回调派发”结构。\n  - 新增 `trace/internal/icmp_decode.go`，集中处理 ICMPv4/v6 解析、echo reply 匹配、内嵌目标 IP 校验、内嵌 ICMP seq 提取。\n  - 新增 `trace/internal/icmp_decode_test.go`，覆盖 IPv4/IPv6 echo reply、error payload、目标 IP 校验、内嵌 seq 提取。\n  - `trace/internal/udp_unix.go` 的 `SendUDP` 已拆成 IPv4/IPv6 独立发送 helper；`trace/udp_ipv4.go` 的 `send()` 也已拆成配额检查、构包、超时守护、发送记账四段。\n  - Windows 协议层新增 `trace/internal/windivert_sniff_windows.go`，把 WinDivert sniff handle 打开、收包、ICMP/TCP 解码下沉为共享 helper；`icmp_windows.go` / `tcp_windows.go` / `udp_windows.go` 的 sniff 入口已变成薄协调器。\n  - Darwin `trace/internal/icmp_darwin.go:ListenPacket` 已拆成 socket spec、接口绑定、bind sockaddr、finalize packet conn 四段；`trace/internal/tcp_darwin.go:ListenTCP` 也改成设备选择 + BPF + 共享 TCP reply 解码 helper。\n  - 新增 `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 共用。\n- 当前已知剩余高复杂度主要集中在：\n  - `printer/mtr_*` 渲染层\n  - `cmd/mtr_ui.go` 输入状态机\n  - `trace/globalping.go` 的主流程函数\n  - `trace/mtr_runner.go` 中仍未拆薄的 ICMP round handler（`probeRound` / `onICMP`）\n  - 少量收尾函数：`fast_trace ipv6.go`、`reporter/reporter.go`、`trace/mtr_raw.go`\n\n## 2026-03 Gocyclo 重构快照（追加）\n\n- MTR 核心热点已完成一轮收敛：\n  - `trace/mtr_scheduler.go:runMTRScheduler` 已改成薄入口，核心状态与分支移动到 `trace/mtr_scheduler_runtime.go`。\n  - `trace/mtr_stats.go:Update` / `MigrateStats` 已拆成按 hop 分组、累加器合并、裁剪 helper；新增 `trace/mtr_stats_helpers.go`。\n  - `trace/mtr_runner.go:mtrLoop` 已改成薄入口，取消/重置/暂停/预览/backoff 分支移动到 `trace/mtr_loop_runtime.go`。\n- MTR 输出层与输入层热点也已收敛：\n  - `printer/mtr_tui.go:mtrTUIRenderWithWidth` 已拆成布局扫描、三行头部构建、host part 预构建、MPLS 续行渲染四段。\n  - `printer/mtr_table.go` 的 host 组装和 `MTRReportPrint` 已改成共享 host-part 拼接 helper + report header/row helper。\n  - `cmd/mtr_ui.go:(*mtrInputParser).Feed` 已拆成按状态分发的 parser helper。\n- 最后一批业务流程热点也已拆薄：\n  - `trace/globalping.go:GlobalpingTraceroute` 已拆成 client 构建、measurement 请求、结果解码、hop limit 推导、结果组装五段。\n  - `trace/mtr_runner.go:(*mtrICMPEngine).onICMP` / `probeRound` 已拆成 reply 校验、notify 清理、目的地 TTL 识别、round 准备、发包 sweep、等待回包、结果构建多个 helper。\n  - `fast_trace/fast_trace ipv6.go:FastTestv6`、`reporter/reporter.go:generateRouteReportNode`、`trace/mtr_raw.go:buildMTRRawRecordFromProbe` 也已分别拆成选择分发、route-node 属性构建、raw record metadata 填充 helper。\n- 当前本地复杂度扫描结果：\n  - `go run /tmp/checkcyclo.go .` 已无 `>15` 函数输出。\n  - `go test ./...` 通过。\n\n## 仍需记住的残余风险（非阻断）\n\n- `closeWithCode` 中 `closed.Store(true)` 在 `closeOnce.Do` 外部，理论上有微小竞态窗口（实际无害，因 `sendMu` 保护；且无法简单移入 Once 内部，否则第二个调用者无法设置 closed）。\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "每次执行命令前均需阅读项目根目录下的 ./AGENTS.md 文件"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n<img src=\"assets/logo.png\" height=\"200px\" alt=\"NextTrace Logo\"/>\n\n</div>\n\n<h1 align=\"center\">\n  <br>NextTrace<br>\n</h1>\n\n<h4 align=\"center\">An open source visual routing tool that pursues light weight, developed using Golang.</h4>\n\n---\n\n<h6 align=\"center\">HomePage: www.nxtrace.org</h6>\n\n<p align=\"center\">\n  <a href=\"https://github.com/nxtrace/NTrace-dev/actions\">\n    <img src=\"https://img.shields.io/github/actions/workflow/status/nxtrace/NTrace-dev/build.yml?branch=main&style=flat-square\" alt=\"Github Actions\">\n  </a>\n  <a href=\"https://goreportcard.com/report/github.com/nxtrace/NTrace-dev\">\n    <img src=\"https://goreportcard.com/badge/github.com/nxtrace/NTrace-dev?style=flat-square\">\n  </a>\n  <a href=\"https://github.com/nxtrace/NTrace-dev/releases\">\n    <img src=\"https://img.shields.io/github/release/nxtrace/NTrace-dev/all.svg?style=flat-square\">\n  </a>\n</p>\n\n## IAAS Sponsor\n\n<div style=\"text-align: center;\">\n    <a href=\"https://dmit.io\">\n        <img src=\"https://assets.nxtrace.org/dmit.svg\" width=\"170.7\" height=\"62.9\">\n    </a>\n    &nbsp;&nbsp;&nbsp;&nbsp;\n    <a href=\"https://misaka.io\" >\n        <img src=\"https://assets.nxtrace.org/misaka.svg\" width=\"170.7\" height=\"62.9\">\n    </a>\n    &nbsp;&nbsp;&nbsp;&nbsp;\n    <a href=\"https://portal.saltyfish.io\" >\n        <img src=\"https://assets.nxtrace.org/snapstack.svg\" width=\"170.7\" height=\"62.9\">\n    </a>\n</div>\n\nWe 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.\n\n## How To Use\n\nDocument Language: English | [简体中文](README_zh_CN.md)\n\n⚠️ 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.<br>\nRegarding the NTrace-dev and NTrace-core repositories:<br>\nBoth 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.<br>\nPlease 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.\n\n### Automated Install\n\n- Linux\n  - One-click installation script\n\n    ```shell\n    curl -sL https://nxtrace.org/nt | bash\n    ```\n\n  - Install nxtrace from the APT repository\n    - Supports AMD64/ARM64 architectures\n      ```shell\n      curl -fsSL https://github.com/nxtrace/nexttrace-debs/releases/latest/download/nexttrace-archive-keyring.gpg | sudo tee /etc/apt/keyrings/nexttrace.gpg >/dev/null\n      echo \"Types: deb\n      URIs: https://github.com/nxtrace/nexttrace-debs/releases/latest/download/\n      Suites: ./\n      Signed-By: /etc/apt/keyrings/nexttrace.gpg\" | sudo tee /etc/apt/sources.list.d/nexttrace.sources >/dev/null\n      sudo apt update\n      sudo apt install nexttrace\n      ```\n    - APT repository maintained by wcbing and nxtrace\n\n  - Arch Linux AUR installation command\n    - Directly download bin package (only supports amd64)\n      ```shell\n      yay -S nexttrace-bin\n      ```\n    - Build from source (only supports amd64)\n      ```shell\n      yay -S nexttrace\n      ```\n    - The AUR builds are maintained by ouuan, huyz\n\n  - Linuxbrew's installation command\n\n    Same as the macOS Homebrew's installation method (homebrew-core version only supports amd64)\n\n  - deepin installation command\n    ```shell\n    apt install nexttrace\n    ```\n  - [x-cmd](https://www.x-cmd.com/pkg/nexttrace) installation command\n\n    ```shell\n    x env use nexttrace\n    ```\n\n  - Termux installation command\n    ```shell\n    pkg install root-repo\n    pkg install nexttrace\n    ```\n  - ImmortalWrt installation command\n    ```shell\n    opkg install nexttrace\n    ```\n\n- macOS\n  - macOS Homebrew's installation command\n    - Homebrew-core version\n      ```shell\n      brew install nexttrace\n      ```\n    - This repository's ACTIONS automatically built version (updates faster)\n      ```shell\n      brew tap nxtrace/nexttrace && brew install nxtrace/nexttrace/nexttrace\n      ```\n    - The homebrew-core build is maintained by chenrui333, please note that this version's updates may lag behind the repository Action automatically version\n\n- Windows\n  - Windows WinGet installation command\n    - WinGet version\n      ```powershell\n      winget install nexttrace\n      ```\n    - WinGet build maintained by Dragon1573\n\n  - Windows Scoop installation command\n    - Scoop-extras version\n      ```powershell\n      scoop bucket add extras && scoop install extras/nexttrace\n      ```\n    - Scoop-extra is maintained by soenggam\n\nPlease 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.\n\n### Manual Install\n\n- Download the precompiled executable\n\n  For users not covered by the above methods, please go directly to [Release](https://www.nxtrace.org/downloads) to download the compiled binary executable.\n  - `Release` provides compiled binary executables for many systems and different architectures. If none are available, you can compile it yourself.\n  - 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.\n\n### Build Variants\n\nStarting from this release, NextTrace is published in **three flavors** under the same tag. Choose the one that best fits your use case:\n\n| Feature               | `nexttrace` (Full) | `nexttrace-tiny` |    `ntr`     |\n| --------------------- | :----------------: | :--------------: | :----------: |\n| Normal traceroute     |         ✅         |        ✅        |      —       |\n| Standalone MTU (`--mtu`) |      ✅         |        ✅        |      —       |\n| MTR TUI               |         ✅         |        —         | ✅ (default) |\n| MTR report (`-r`)     |         ✅         |        —         |      ✅      |\n| MTR wide (`-w`)       |         ✅         |        —         |      ✅      |\n| MTR raw (`--raw`)     |         ✅         |        —         |      ✅      |\n| Globalping (`--from`) |         ✅         |        —         |      —       |\n| WebUI (`--deploy`)    |         ✅         |        —         |      —       |\n| Fast Trace (`-F`)     |         ✅         |        ✅        |      —       |\n| Default mode          |     traceroute     |    traceroute    |   MTR TUI    |\n| Binary name           |    `nexttrace`     | `nexttrace-tiny` |    `ntr`     |\n\n> **Note:** Package managers (Homebrew, AUR, Scoop, etc.) currently install the **Full** (`nexttrace`) version only.\n\n### Feature Matrix\n\n- **`nexttrace`** — Full-featured build. Includes everything: traceroute, MTR, Globalping, and WebUI.\n- **`nexttrace-tiny`** — Lightweight build. Normal traceroute only, no MTR / Globalping / WebUI. Suitable for embedded or minimal environments.\n- **`ntr`** — MTR-focused build. Runs MTR TUI by default. No Globalping / WebUI; no normal traceroute mode and no standalone `--mtu` mode.\n\n### Manual Build\n\nBuild from source with Go 1.22+ installed:\n\n```bash\n# Full (all features)\ngo build -trimpath -o dist/nexttrace -ldflags \"-w -s\" .\n\n# Tiny (no MTR, no Globalping, no WebUI)\ngo build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny -ldflags \"-w -s\" .\n\n# NTR (MTR-only)\ngo build -tags flavor_ntr -trimpath -o dist/ntr -ldflags \"-w -s\" .\n```\n\nCross-compile example:\n\n```bash\n# Linux arm64, Tiny flavor\nGOOS=linux GOARCH=arm64 CGO_ENABLED=0 \\\n  go build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny_linux_arm64 -ldflags \"-w -s\" .\n```\n\nThe `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 <binary>` that `gin` and `globalping-cli` are absent from `nexttrace-tiny` and `ntr`.\n\nThe `.cross_compile.sh` script supports building flavors:\n\n```bash\n./.cross_compile.sh all     # Build all three flavors for all platforms\n./.cross_compile.sh full    # Build only nexttrace (Full)\n./.cross_compile.sh tiny    # Build only nexttrace-tiny\n./.cross_compile.sh ntr     # Build only ntr\n```\n\n### Release Assets Naming\n\nRelease binaries follow this naming convention:\n\n```\n{binary}_{os}_{arch}[v{arm}][.exe][_softfloat]\n```\n\nExamples:\n\n- `nexttrace_linux_amd64`, `nexttrace-tiny_linux_amd64`, `ntr_linux_amd64`\n- `nexttrace_darwin_universal`, `nexttrace-tiny_darwin_universal`, `ntr_darwin_universal`\n- `nexttrace_windows_amd64.exe`, `ntr_windows_amd64.exe`\n\n### Get Started\n\n`NextTrace` uses the `ICMP` protocol to perform TraceRoute requests by default, which supports both `IPv4` and `IPv6`\n\n```bash\n# IPv4 ICMP Trace\nnexttrace 1.0.0.1\n# URL\nnexttrace http://example.com:8080/index.html?q=1\n\n# Table output (report mode): runs trace once and prints a final summary table\nnexttrace --table 1.0.0.1\n\n# Machine-readable output: stdout is a single JSON document\nnexttrace --raw 1.0.0.1\nnexttrace --json 1.0.0.1\n\n# Realtime trace output to a custom file\nnexttrace --output ./trace.log 1.0.0.1\n\n# Realtime trace output to the default log file\nnexttrace --output-default 1.0.0.1\n\n# IPv4/IPv6 Resolve Only, and automatically select the first IP when there are multiple IPs\nnexttrace --ipv4 g.co\nnexttrace --ipv6 g.co\n\n# IPv6 ICMP Trace\nnexttrace 2606:4700:4700::1111\n\n# Developer mode: set the ENV variable NEXTTRACE_DEVMODE=1 to make fatal errors panic with a stack trace\nexport NEXTTRACE_DEVMODE=1\n\n# Set TTL-group interval in normal traceroute mode (default: 300ms)\nnexttrace -i 300 1.1.1.1\n\n# Disable Path Visualization With the -M parameter\nnexttrace koreacentral.blob.core.windows.net\n# MapTrace URL: https://api.nxtrace.org/tracemap/html/c14e439e-3250-5310-8965-42a1e3545266.html\n\n# Disable MPLS display using the --disable-mpls / -e parameter or the NEXTTRACE_DISABLEMPLS environment variable\nnexttrace --disable-mpls example.com\nexport NEXTTRACE_DISABLEMPLS=1\n```\n\nPS: The route visualization module is an independent component, You can find its source code at [nxtrace/traceMap](https://github.com/nxtrace/traceMap).  \nThe 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.\n\n#### Mandatory Configuration Steps for `Windows` Users\n\n- **For Normal User Mode:**  \n  Only **ICMP mode** can be used, and the firewall must allow `ICMP/ICMPv6` traffic.\n  ```powershell\n  netsh advfirewall firewall add rule name=\"All ICMP v4\" dir=in action=allow protocol=icmpv4:any,any\n  netsh advfirewall firewall add rule name=\"All ICMP v6\" dir=in action=allow protocol=icmpv6:any,any\n  ```\n- **For Administrator Mode:**  \n  **TCP/UDP mode** requires `WinDivert`.  \n  **ICMP mode** supports `1=Socket` and `2=WinDivert` (`0=Auto`). If running in Socket mode, the firewall must allow `ICMP/ICMPv6`.  \n  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.  \n  `WinDivert` can be automatically configured using the `--init` parameter.\n\n#### `NextTrace` now supports quick testing, and friends who have a one-time backhaul routing test requirement can use it\n\n```bash\n# IPv4 ICMP Fast Test (Beijing + Shanghai + Guangzhou + Hangzhou) in China Telecom / Unicom / Mobile / Education Network\nnexttrace --fast-trace\n\n# You can also use TCP SYN for testing\nnexttrace --fast-trace --tcp\n\n# You can also quickly test through a customized IP/DOMAIN list file\nnexttrace --file /path/to/your/iplist.txt\n# CUSTOMIZED IP DOMAIN LIST FILE FORMAT\n## One IP/DOMAIN per line + space + description information (optional)\n## forExample:\n## 106.37.67.1 BEIJING-TELECOM\n## 240e:928:101:31a::1 BEIJING-TELECOM\n## bj.10086.cn BEIJING-MOBILE\n## 2409:8080:0:1::1\n## 223.5.5.5\n```\n\n#### `NextTrace` already supports route tracing for specified Network Devices\n\n```bash\n# Use eth0 network interface\nnexttrace --dev eth0 2606:4700:4700::1111\n\n# Use eth0 network interface's IP\n# 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)\nnexttrace --source 204.98.134.56 9.9.9.9\n```\n\n#### `NextTrace` can also use `TCP` and `UDP` protocols to perform `Traceroute` requests\n\n```bash\n# TCP SYN Trace\nnexttrace --tcp www.bing.com\n\n# You can specify the port by yourself [here is 443], the default port is 80\nnexttrace --tcp --port 443 2001:4860:4860::8888\n\n# UDP Trace\nnexttrace --udp 1.0.0.1\n\n# You can specify the target port yourself [here it is 5353], the default is port 33494\nnexttrace --udp --port 5353 1.0.0.1\n\n# For TCP/UDP Trace, you can specify the source port; by default, a fixed random port is used\n# (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)\nnexttrace --tcp --source-port 14514 www.bing.com\n```\n\n#### `NextTrace` also supports standalone path-MTU discovery mode\n\n```bash\n# Tracepath-style UDP PMTU discovery with live hop output\nnexttrace --mtu 1.1.1.1\n\n# Reuse the normal GeoIP / RDNS knobs in mtu mode\nnexttrace --mtu --data-provider IPInfo --language en 1.1.1.1\n\n# JSON output keeps the standalone mtu schema and now includes hop.geo\nnexttrace --mtu --json 1.1.1.1\n```\n\n- `--mtu` is an independent UDP-only mode. It does not reuse the normal traceroute engine.\n- 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.\n- `--mtu --json` prints only the standalone MTU JSON document on stdout.\n- GeoIP, RDNS, `--data-provider`, `--language`, `--no-rdns`, `--always-rdns`, and `--dot-server` all apply to this mode.\n\n#### `NextTrace` also supports some advanced functions, such as ttl control, concurrent probe packet count control, mode switching, etc.\n\n```bash\n# Display 2 latency samples per hop\nnexttrace --queries 2 www.hkix.net\n\n# Allow up to 10 probe packets per hop to collect those samples\n# (NextTrace stops earlier if it has already got the replies requested by --queries)\nnexttrace --max-attempts 10 www.hkix.net\n# or use the ENV variable NEXTTRACE_MAXATTEMPTS to persist across runs\nexport NEXTTRACE_MAXATTEMPTS=10\n\n# No concurrent probe packets, only one probe packet is sent at a time\nnexttrace --parallel-requests 1 www.hkix.net\n\n# Start Trace with TTL of 5, end at TTL of 10\nnexttrace --first 5 --max-hops 10 www.decix.net\n# In addition, an ENV is provided to set whether to mask the destination IP and omit its hostname\nexport NEXTTRACE_ENABLEHIDDENDSTIP=1\n\n# Turn off the IP reverse parsing function\nnexttrace --no-rdns www.bbix.net\n\n# Set the probe packet size to 1024 bytes (inclusive IP + probe headers)\nnexttrace --psize 1024 example.com\n\n# Randomize each probe packet size up to 1500 bytes\nnexttrace --psize -1500 example.com\n\n# Set the TOS / traffic class field\nnexttrace -Q 46 example.com\n\n# Feature: print Route-Path diagram\n# Route-Path diagram example:\n# AS6453 Tata Communication「Singapore『Singapore』」\n#  ╭╯\n#  ╰AS9299 Philippine Long Distance Telephone Co.「Philippines『Metro Manila』」\n#  ╭╯\n#  ╰AS36776 Five9 Inc.「Philippines『Metro Manila』」\n#  ╭╯\n#  ╰AS37963 Aliyun「ALIDNS.COM『ALIDNS.COM』」\nnexttrace --route-path www.time.com.my\n\n# Disable color output\nnexttrace --no-color 1.1.1.1\n# or use ENV\nexport NO_COLOR=1\n```\n\n#### Advanced tuning quick guide\n\n| Flag | What it controls | Default / starting point | When to change it |\n| --- | --- | --- | --- |\n| `--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 |\n| `--max-attempts` | Hard cap on probe packets per hop | auto-sized from `--queries` | Raise on lossy links when replies arrive slowly |\n| `--parallel-requests` | Total in-flight probes across TTLs | `18` | Use `1` on multipath/load-balanced paths; keep `6-18` on stable links |\n| `--send-time` | Gap between packets inside one TTL group | `50ms` | Raise to `100-200ms` on rate-limited devices; ignored in MTR |\n| `--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 |\n| `--timeout` | Per-probe timeout | `1000ms` | Raise to `2000-3000ms` for intercontinental or high-loss paths |\n| `--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 |\n| `-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` |\n\nThese 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.\n\n```bash\n# Conservative profile for multipath or ECMP networks\nnexttrace --parallel-requests 1 --send-time 100 --ttl-time 500 --timeout 2000 example.com\n\n# Faster profile for stable single-path networks\nnexttrace --parallel-requests 18 --send-time 20 --ttl-time 150 example.com\n\n# Lossy long-haul profile\nnexttrace --queries 5 --max-attempts 10 --timeout 2500 example.com\n```\n\n#### `NextTrace` supports MTR (My Traceroute) continuous probing mode\n\n```bash\n# MTR mode: continuous probing with ICMP (default), refreshes table in real-time\nnexttrace -t 1.1.1.1\n# or equivalently:\nnexttrace --mtr 1.1.1.1\n\n# MTR mode with TCP SYN probing\nnexttrace -t --tcp --port 443 www.bing.com\n\n# MTR mode with UDP probing\nnexttrace -t --udp 1.0.0.1\n\n# Set per-hop probe interval (default: 1000ms in MTR; -z/--send-time is ignored in MTR mode)\nnexttrace -t -i 500 1.1.1.1\n\n# Limit the max probes per hop (default: infinite in TUI, 10 in report mode)\nnexttrace -t -q 20 1.1.1.1\n\n# Report mode: probe each hop N times then print a final summary (like mtr -r)\nnexttrace -r 1.1.1.1       # = --mtr --report, 10 probes per hop by default\nnexttrace -r -q 5 1.1.1.1  # 5 probes per hop\n\n# Wide report: no host column truncation (like mtr -rw)\nnexttrace -w 1.1.1.1       # = --mtr --report --wide\n\n# Show PTR and IP together (PTR first, IP in parentheses) in MTR output\nnexttrace --mtr --show-ips 1.1.1.1\nnexttrace -r --show-ips 1.1.1.1\nnexttrace -w --show-ips 1.1.1.1\n\n# MTR raw stream mode (machine-friendly, one event per line)\nnexttrace --mtr --raw 1.1.1.1\nnexttrace -r --raw 1.1.1.1\n\n# Combine with other options\nnexttrace -t --tcp --max-hops 20 --first 3 --no-rdns 8.8.8.8\n```\n\nWhen running in a terminal (TTY), MTR mode uses an **interactive full-screen TUI**:\n\n- **`q` / `Q`** — quit (restores terminal, no output left behind)\n- **`p`** — pause probing\n- **`SPACE`** — resume probing\n- **`r`** — reset statistics (counters are cleared, display mode is preserved)\n- **`y`** — cycle host display mode: ASN → City → Owner → Full\n- **`n`** — toggle host name display:\n  - default: PTR (or IP fallback) ↔ IP only\n  - with `--show-ips`: PTR (IP) ↔ IP only\n- **`e`** — toggle MPLS label display on/off\n- The TUI header displays **source → destination**, with `--source`/`--dev` information when specified.\n- When using LeoMoeAPI, the preferred API IP address is shown in the header.\n- Uses the **alternate screen buffer**, so your previous terminal history is preserved on exit.\n- When stdin is not a TTY (e.g. piped), it falls back to a simple table refresh.\n\nThe **report mode** (`-r`/`--report`) produces a one-shot summary after all probes complete, suitable for scripting:\n\n```text\nStart: 2025-07-14T09:12:00+08:00\nHOST: myhost                    Loss%   Snt   Last    Avg   Best   Wrst  StDev\n  1. one.one.one.one            0.0%    10    1.23   1.45   0.98   2.10   0.32\n  2. 10.0.0.2                 100.0%    10    0.00   0.00   0.00   0.00   0.00\n```\n\nRows shown as `(waiting for reply)` keep the same table layout; the metric cells on that row are left blank.\n\nIn non-wide report mode, NextTrace intentionally keeps the host column compact:\n\n- only `PTR/IP` is shown\n- no Geo API lookup is performed\n- no ASN / owner / location fields are shown\n- MPLS labels are hidden\n\nWide report mode (`-w` / `--wide`) keeps the current full-information behavior, including Geo-derived fields and MPLS output.\n\nWhen `--raw` is used together with MTR (`--mtr`, `-r`, or `-w`), NextTrace enters **MTR raw stream mode**.\n\nIf the active data provider is `LeoMoeAPI`, NextTrace first prints one uncolored API info preamble line:\n\n```text\n[NextTrace API] preferred API IP - [2403:18c0:1001:462:dd:38ff:fe48:e0c5] - 21.33ms - DMIT.NRT\n```\n\nAfter that, it prints one `|`-delimited event per line:\n\n```\n4|84.17.33.106|po66-3518.cr01.nrt04.jp.misaka.io|0.27|60068|Japan|Tokyo|Tokyo||cdn77.com|35.6804|139.7690\n```\n\nField order:\n\n`ttl|ip|ptr|rtt|asn|country|prov|city|district|owner|lat|lng`\n\nTimeout rows keep the same 12-column layout:\n\n`ttl|*||||||||||`\n\nIn 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.\n\n> Note: `--show-ips` only takes effect in MTR mode (`--mtr`, `-r`, `-w`); otherwise it is ignored.\n>\n> Note: `--mtr` cannot be used together with `--table`, `--classic`, `--json`, `--output`, `--output-default`, `--route-path`, `--from`, `--fast-trace`, `--file`, or `--deploy`.\n\n#### `NextTrace` supports users to select their own IP API (currently supports: `LeoMoeAPI`, `IP.SB`, `IPInfo`, `IPInsight`, `IPAPI.com`, `IPInfoLocal`, `CHUNZHEN`)\n\n```bash\n# You can specify the IP database by yourself [IP-API.com here], if not specified, LeoMoeAPI will be used\nnexttrace --data-provider ip-api.com\n## 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\n##      If necessary, you can clone this project, add the token provided by ipinfo or IPInsight and compile it yourself\n##      Fill the token to: ipgeo/tokens.go\n\n## 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)\n##      Current directory, nexttrace binary directory and FHS directories (Unix-like) will be searched.\n##      To customize it, please use environment variables,\nexport NEXTTRACE_IPINFOLOCALPATH=/xxx/yyy.mmdb\n## Please be aware: Due to the serious abuse of IP.SB, you will often be not able to query IP data from this source\n## 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\n\n# 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\nexport NEXTTRACE_CHUNZHENURL=http://127.0.0.1:2060\n## You can use https://github.com/freshcn/qqwry to build your own Pure-FTPd IP database service\n\n# You can also specify the default IP database by setting an environment variable\nexport NEXTTRACE_DATAPROVIDER=ipinfo\n```\n\n#### `NextTrace` supports mixed parameters and shortened parameters\n\n```bash\nExample:\nnexttrace --data-provider IPAPI.com --max-hops 20 --tcp --port 443 --queries 5 --no-rdns 1.1.1.1\nnexttrace -tcp --queries 2 --parallel-requests 1 --table --route-path 2001:4860:4860::8888\n\nEquivalent to:\nnexttrace -d ip-api.com -m 20 -T -p 443 -q 5 -n 1.1.1.1\nnexttrace -T -q 2 --parallel-requests 1 --table -P 2001:4860:4860::8888\n```\n\n### Globalping\n\n[Globalping](https://globalping.io/) provides access to thousands of community-hosted probes to run network tests and measurements.\n\nRun traceroute from a specified location by using the `--from` flag. The location field accepts continents, countries, regions, cities, ASNs, ISPs, or cloud regions.\n\n```bash\nnexttrace google.com --from Germany\nnexttrace google.com --from comcast+california\n```\n\nA 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.\n\n```bash\nexport GLOBALPING_TOKEN=your_token_here\n```\n\n### IP Database\n\nWe use [bgp.tools](https://bgp.tools) as a data provider for routing tables.\n\nNextTrace BackEnd is now open-source.\n\nhttps://github.com/sjlleo/nexttrace-backend\n\nNextTrace `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)\n\n- [GitHub - tsosunchia/powclient: Proof of Work CLIENT for NextTrace](https://github.com/tsosunchia/powclient)\n- [GitHub - tsosunchia/powserver: Proof of Work SERVER for NextTrace](https://github.com/tsosunchia/powserver)\n\nAll NextTrace IP geolocation `API DEMO` can refer to [here](https://github.com/nxtrace/NTrace-core/blob/main/ipgeo/)\n\n### Environment Variables\n\nNextTrace 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.\n\n#### Core Runtime / Network\n\n| Variable | Default | Description |\n| --- | --- | --- |\n| `NEXTTRACE_DEVMODE` | `0` | Turn fatal errors into panics with stack traces for debugging. |\n| `NEXTTRACE_DEBUG` | unset | Print detected environment values while `GetEnv*` helpers parse them. |\n| `NEXTTRACE_DISABLEMPLS` | `0` | Disable MPLS display globally, similar to `--disable-mpls`. |\n| `NEXTTRACE_ENABLEHIDDENDSTIP` | `0` | Mask the destination IP and omit its hostname in output. |\n| `NEXTTRACE_RANDOMPORT` | `0` | Use a different random source port for each TCP/UDP probe. |\n| `NEXTTRACE_MAXATTEMPTS` | auto | Provide a default `--max-attempts` value when the CLI flag is not set. |\n| `NEXTTRACE_ICMPMODE` | `0` | Provide a default `--icmp-mode` value (`0=auto`, `1=socket`, `2=WinDivert` on Windows). |\n| `NEXTTRACE_UNINTERRUPTED` | `0` | When used together with `--raw`, rerun traceroute continuously instead of stopping after one round. |\n| `NEXTTRACE_PROXY` | unset | Outbound proxy URL for HTTP / WebSocket requests used by PoW, Geo APIs, tracemap, etc. |\n| `NEXTTRACE_DATAPROVIDER` | unset | Override the default IP geolocation provider (for example `ipinfo`). |\n\n#### Service / Web / Backend\n\n| Variable | Default | Description |\n| --- | --- | --- |\n| `NEXTTRACE_HOSTPORT` | `api.nxtrace.org` | Override the backend host or `host:port` used by LeoMoeAPI, tracemap, and FastIP flows. |\n| `NEXTTRACE_TOKEN` | unset | Pre-supplied LeoMoeAPI bearer token; when present, token fetching via PoW is skipped. |\n| `NEXTTRACE_POWPROVIDER` | `api.nxtrace.org` | Select the PoW provider. The built-in non-default alias is `sakura`. |\n| `NEXTTRACE_DEPLOY_ADDR` | unset | Default listen address for `--deploy` when `--listen` is not provided. |\n| `NEXTTRACE_ALLOW_CROSS_ORIGIN` | `0` | Only for `--deploy`: allow cross-origin browser access to the Web UI / API. Disabled by default for safety. |\n\n#### IP Database / Third-Party Providers\n\n| Variable | Default | Description |\n| --- | --- | --- |\n| `NEXTTRACE_IPINFOLOCALPATH` | auto search | Full path to `ipinfoLocal.mmdb` for the `IPInfoLocal` provider. |\n| `NEXTTRACE_CHUNZHENURL` | `http://127.0.0.1:2060` | Base URL of the Chunzhen lookup service. |\n| `NEXTTRACE_IPINFO_TOKEN` | unset | Token for the `IPInfo` provider. |\n| `NEXTTRACE_IPINSIGHT_TOKEN` | unset | Token for the `IPInsight` provider. |\n| `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`). |\n| `IPDBONE_BASE_URL` | `https://api.ipdb.one` | Override the IPDB.One API base URL. |\n| `IPDBONE_API_ID` | unset | IPDB.One API ID. |\n| `IPDBONE_API_KEY` | unset | IPDB.One API key. |\n| `GLOBALPING_TOKEN` | unset | Authentication token for Globalping; raises the anonymous hourly limit when provided. |\n\n#### Config Discovery\n\n| Variable | Default | Description |\n| --- | --- | --- |\n| `XDG_CONFIG_HOME` | OS / shell default | If set, NextTrace also searches `$XDG_CONFIG_HOME/nexttrace` for `nt_config.yaml`. |\n\n### For full usage list, please refer to the usage menu\n\n```shell\nUsage: nexttrace [-h|--help] [--init] [-4|--ipv4] [-6|--ipv6] [-T|--tcp]\n                 [-U|--udp] [-F|--fast-trace] [-p|--port <integer>]\n                 [--icmp-mode <integer>] [-q|--queries <integer>]\n                 [--max-attempts <integer>] [--parallel-requests <integer>]\n                 [-m|--max-hops <integer>] [-d|--data-provider\n                 (IP.SB|ip.sb|IPInfo|ipinfo|IPInsight|ipinsight|IPAPI.com|ip-api.com|IPInfoLocal|ipinfolocal|chunzhen|LeoMoeAPI|leomoeapi|ipdb.one|disable-geoip)]\n                 [--pow-provider (api.nxtrace.org|sakura)] [-n|--no-rdns]\n                 [-a|--always-rdns] [-P|--route-path] [--dn42] [-o|--output\n                 \"<value>\"] [-O|--output-default] [--table] [--raw]\n                 [-j|--json] [-c|--classic] [-f|--first <integer>] [-M|--map]\n                 [-e|--disable-mpls] [-V|--version]\n                 [-s|--source \"<value>\"] [--source-port <integer>] [-D|--dev\n                 \"<value>\"] [--listen \"<value>\"] [--deploy] [-z|--send-time\n                 <integer>] [-i|--ttl-time <integer>] [--timeout <integer>]\n                 [--psize <integer>] [--dot-server\n                 (dnssb|aliyun|dnspod|google|cloudflare)] [-g|--language\n                 (en|cn)] [-C|--no-color] [--from \"<value>\"] [-t|--mtr]\n                 [-r|--report] [-w|--wide] [--show-ips] [-y|--ipinfo <integer>]\n                 [--file \"<value>\"] [TARGET \"<value>\"]\n\nArguments:\n\n  -h  --help                         Print help information\n      --init                         Windows ONLY: Extract WinDivert runtime to\n                                     current directory\n  -4  --ipv4                         Use IPv4 only\n  -6  --ipv6                         Use IPv6 only\n  -T  --tcp                          Use TCP SYN for tracerouting (default\n                                     dest-port is 80)\n  -U  --udp                          Use UDP SYN for tracerouting (default\n                                     dest-port is 33494)\n  -F  --fast-trace                   One-Key Fast Trace to China ISPs\n  -p  --port                         Set the destination port to use. With\n                                     default of 80 for \"tcp\", 33494 for \"udp\"\n      --icmp-mode                    Windows ONLY: Choose the method to listen\n                                     for ICMP packets (1=Socket, 2=WinDivert;\n                                     0=Auto)\n  -q  --queries                      Latency samples per hop. Increase to 5-10\n                                     on unstable paths for a steadier view.\n                                     Default: 3\n      --max-attempts                 Advanced: hard cap on probe packets per\n                                     hop. Leave unset for auto sizing; raise on\n                                     lossy links if --queries is not enough\n      --parallel-requests            Advanced: total concurrent in-flight\n                                     probes across TTLs. Use 1 on\n                                     multipath/load-balanced paths; 6-18 is a\n                                     good starting range on stable links.\n                                     Default: 18\n  -m  --max-hops                     Set the max number of hops (max TTL to be\n                                     reached). Default: 30\n  -d  --data-provider                Choose IP Geograph Data Provider [IP.SB,\n                                     IPInfo, IPInsight, IP-API.com,\n                                     IPInfoLocal, CHUNZHEN, disable-geoip].\n                                     Default: LeoMoeAPI\n      --pow-provider                 Choose PoW Provider [api.nxtrace.org,\n                                     sakura] For China mainland users, please\n                                     use sakura. Default: api.nxtrace.org\n  -n  --no-rdns                      Do not resolve IP addresses to their\n                                     domain names\n  -a  --always-rdns                  Always resolve IP addresses to their\n                                     domain names\n  -P  --route-path                   Print traceroute hop path by ASN and\n                                     location\n      --dn42                         DN42 Mode\n  -o  --output                       Write trace result to FILE\n                                     (RealtimePrinter only)\n  -O  --output-default               Write trace result to the default log file\n                                     (/tmp/trace.log)\n      --table                        Output trace results as a final summary\n                                     table (traceroute report mode)\n      --raw                          Machine-friendly output. With MTR\n                                     (--mtr/-r/-w), enables streaming raw event\n                                     mode\n  -j  --json                         Output trace results as JSON\n  -c  --classic                      Classic Output trace results like\n                                     BestTrace\n  -f  --first                        Start from the first_ttl hop (instead of\n                                     1). Default: 1\n  -M  --map                          Disable Print Trace Map\n  -e  --disable-mpls                 Disable MPLS\n  -V  --version                      Print version info and exit\n  -s  --source                       Use source address src_addr for outgoing\n                                     packets\n      --source-port                  Use source port src_port for outgoing\n                                     packets\n  -D  --dev                          Use the following Network Devices as the\n                                     source address in outgoing packets\n      --listen                       Set listen address for web console (e.g.\n                                     127.0.0.1:30080)\n      --deploy                       Start the Gin powered web console\n  -z  --send-time                    Advanced: per-packet gap [ms] inside the\n                                     same TTL group. Lower is faster; raise to\n                                     100-200ms on rate-limited links. Ignored\n                                     in MTR mode. Default: 50\n  -i  --ttl-time                     Advanced: TTL-group interval [ms] in\n                                     normal traceroute. In MTR mode\n                                     (--mtr/-r/-w, including --raw), this\n                                     becomes per-hop probe interval. 500-1000ms\n                                     is a good MTR starting range\n      --timeout                      Per-probe timeout [ms]. Raise to 2000-3000\n                                     on slow intercontinental or high-loss\n                                     paths. Default: 1000\n      --psize                        Probe packet size in bytes, inclusive IP\n                                     and active probe headers. Default is the\n                                     minimum legal size for the chosen\n                                     protocol and IP family; raise for MTU or\n                                     large-packet testing. Negative values\n                                     randomize each probe up to abs(value).\n  -Q  --tos                          Set the IP type-of-service / traffic class\n                                     value [0-255]. Default: 0\n      --dot-server                   Use DoT Server for DNS Parse [dnssb,\n                                     aliyun, dnspod, google, cloudflare]\n  -g  --language                     Choose the language for displaying [en,\n                                     cn]. Default: cn\n  -C  --no-color                     Disable Colorful Output\n      --from                         Run traceroute via Globalping\n                                     (https://globalping.io/network) from a\n                                     specified location. The location field\n                                     accepts continents, countries, regions,\n                                     cities, ASNs, ISPs, or cloud regions.\n  -t  --mtr                          Enable MTR (My Traceroute) continuous\n                                     probing mode\n  -r  --report                       MTR report mode (non-interactive, implies\n                                     --mtr); can trigger MTR without --mtr\n  -w  --wide                         MTR wide report mode (implies --mtr\n                                     --report); alone equals --mtr --report\n                                     --wide\n      --show-ips                     MTR only: display both PTR hostnames and\n                                     numeric IPs (PTR first, IP in parentheses)\n  -y  --ipinfo                       Set initial MTR TUI host info mode (0-4).\n                                     TUI only; ignored in --report/--raw.\n                                     0:IP/PTR 1:ASN 2:City 3:Owner 4:Full.\n                                     Default: 0\n      --file                         Read IP Address or domain name from file\n      TARGET                         Trace target: IPv4 address (e.g. 8.8.8.8),\n                                     IPv6 address (e.g. 2001:db8::1), domain\n                                     name (e.g. example.com), or URL (e.g.\n                                     https://example.com)\n```\n\n## Project screenshot\n\n![image](https://user-images.githubusercontent.com/13616352/216064486-5e0a4ad5-01d6-4b3c-85e9-2e6d2519dc5d.png)\n\n![image](https://user-images.githubusercontent.com/59512455/218501311-1ceb9b79-79e6-4eb6-988a-9d38f626cdb8.png)\n\n## OpenTrace\n\n`OpenTrace` is the cross-platform `GUI` version of `NextTrace` developed by @Archeb, bringing a familiar but more powerful user experience.\n\nThis software is still in the early stages of development and may have many flaws and errors. We value your feedback.\n\n[https://github.com/Archeb/opentrace](https://github.com/Archeb/opentrace)\n\n## NEXTTRACE WEB API\n\n`NextTraceWebApi` is a web-based server implementation of `NextTrace` in the `MTR` style, offering various deployment options including `Docker`.\n\nFor WebSocket continuous tracing, MTR now streams per-event payloads with `type: \"mtr_raw\"` (instead of periodic `mtr` snapshots).\n\n[https://github.com/nxtrace/nexttracewebapi](https://github.com/nxtrace/nexttracewebapi)\n\n## NextTraceroute\n\n`NextTraceroute` is a root-free Android route tracing application that defaults to using the `NextTrace API`, developed by @surfaceocean.  \nThank 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.\n\n[https://github.com/nxtrace/NextTraceroute](https://github.com/nxtrace/NextTraceroute)  \n<a href='https://play.google.com/store/apps/details?id=com.surfaceocean.nexttraceroute&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' width=\"128\" height=\"48\" src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png'/></a>\n\n## LeoMoeAPI Credits\n\nNextTrace focuses on Golang Traceroute implementations, and its LeoMoeAPI geolocation information is not supported by raw data, so a commercial version is not possible.\n\nThe 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.\n\n1. 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.\n\n2. 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.\n\n3. 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.\n\n4. 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!\n\nWe 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.\n\n## Cloudflare Support\n\nThis project is sponsored by [Project Alexandria](http://www.cloudflare.com/oss-credits).\n\n<img src=\"https://cf-assets.www.cloudflare.com/slt3lc6tev37/2I3y49Uz9Y61lBS0kIPZu6/db6df1e6f99a8659267c442b75a0dff9/image.png\" alt=\"Cloudflare Logo\" width=\"331\">\n\n## AIWEN TECH Support\n\nThis 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.\n\n<img src=\"https://www.ipplus360.com/img/LOGO.c86cd0e1.svg\" title=\"\" alt=\"AIWEN TECH IP Geolocation Data\" width=\"331\">\n\n## JetBrain Support\n\nThis Project uses [JetBrain Open-Source Project License](https://jb.gg/OpenSourceSupport). We Proudly Develop By `Goland`.\n\n<img src=\"https://resources.jetbrains.com/storage/products/company/brand/logos/GoLand.png\" title=\"\" alt=\"GoLand logo\" width=\"331\">\n\n## Credits\n\n[Gubo](https://www.gubo.org) Reliable Host Recommendation Website\n\n[IPInfo](https://ipinfo.io) Provided most of the data support for this project free of charge\n\n[BGP.TOOLS](https://bgp.tools) Provided some data support for this project free of charge\n\n[PeeringDB](https://www.peeringdb.com) Provided some data support for this project free of charge\n\n[Globalping](https://globalping.io) An open-source and free project that provides global access to run network tests like traceroute\n\n[sjlleo](https://github.com/sjlleo) The perpetual leader, founder, and core contributors\n\n[tsosunchia](https://github.com/tsosunchia) The project chair, infra maintainer, and core contributors\n\n[Yunlq](https://github.com/Yunlq) An active community contributor\n\n[Vincent Young](https://github.com/missuo)\n\n[zhshch2002](https://github.com/zhshch2002)\n\n[Sam Sam](https://github.com/samleong123)\n\n[waiting4new](https://github.com/waiting4new)\n\n[FFEE_CO](https://github.com/fkx4-p)\n\n[bobo liu](https://github.com/fakeboboliu)\n\n[YekongTAT](https://github.com/isyekong)\n\n### Others\n\n- 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.\n\n- For feedback related to corrections about IP information, we currently have two channels available:\n\n  > - [IP 错误报告汇总帖](https://github.com/orgs/nxtrace/discussions/222) in the GITHUB ISSUES section of this project (Recommended)\n  > - 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)\n\n- How to obtain the freshly baked binary executable of the latest commit?\n\n  > Please go to the most recent [Build & Release](https://github.com/nxtrace/NTrace-dev/actions/workflows/build.yml) workflow in GitHub Actions.\n\n- Common questions\n  - On Windows, ICMP mode requires manual firewall allowance for ICMP/ICMPv6\n  - On macOS, only ICMP mode does not require elevated privileges\n  - In some cases, running multiple instances of NextTrace simultaneously may interfere with each other’s results (observed so far only in TCP mode)\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=nxtrace/NTrace-core&type=Date)](https://star-history.com/#nxtrace/NTrace-core&Date)\n"
  },
  {
    "path": "README_zh_CN.md",
    "content": "<div align=\"center\">\n\n<img src=\"assets/logo.png\" height=\"200px\" alt=\"NextTrace Logo\"/>\n\n</div>\n\n<h1 align=\"center\">\n  <br>NextTrace<br>\n</h1>\n\n<h4 align=\"center\">一款追求轻量化的开源可视化路由跟踪工具。</h4>\n\n---\n\n<h6 align=\"center\">主页：www.nxtrace.org</h6>\n\n<p align=\"center\">\n  <a href=\"https://github.com/nxtrace/NTrace-dev/actions\">\n    <img src=\"https://img.shields.io/github/actions/workflow/status/nxtrace/NTrace-dev/build.yml?branch=main&style=flat-square\" alt=\"Github Actions\">\n  </a>\n  <a href=\"https://goreportcard.com/report/github.com/nxtrace/NTrace-dev\">\n    <img src=\"https://goreportcard.com/badge/github.com/nxtrace/NTrace-dev?style=flat-square\">\n  </a>\n  <a href=\"https://github.com/nxtrace/NTrace-dev/releases\">\n    <img src=\"https://img.shields.io/github/release/nxtrace/NTrace-dev/all.svg?style=flat-square\">\n  </a>\n</p>\n\n## IAAS Sponsor\n\n<div style=\"text-align: center;\">\n    <a href=\"https://dmit.io\">\n        <img src=\"https://assets.nxtrace.org/dmit.svg\" width=\"170.7\" height=\"62.9\">\n    </a>\n    &nbsp;&nbsp;&nbsp;&nbsp;\n    <a href=\"https://misaka.io\" >\n        <img src=\"https://assets.nxtrace.org/misaka.svg\" width=\"170.7\" height=\"62.9\">\n    </a>\n    &nbsp;&nbsp;&nbsp;&nbsp;\n    <a href=\"https://portal.saltyfish.io\" >\n        <img src=\"https://assets.nxtrace.org/snapstack.svg\" width=\"170.7\" height=\"62.9\">\n    </a>\n</div>\n\n我们非常感谢 [DMIT](https://dmit.io)、 [Misaka](https://misaka.io) 和 [SnapStack](https://portal.saltyfish.io) 提供了支持本项目所需的网络基础设施。\n\n## How To Use\n\nDocument Language: [English](README.md) | 简体中文\n\n⚠️ 请注意：我们欢迎来自社区的PR提交，但是请将您的PR提交至 [NTrace-dev](https://github.com/nxtrace/NTrace-dev) 仓库，而不是 [NTrace-core](https://github.com/nxtrace/NTrace-core) 仓库。<br>\n关于NTrace-dev和NTrace-core两个仓库的说明：<br>\n二者将大体上保持一致。所有的开发工作均在NTrace-dev仓库中进行。NTrace-dev仓库首先发布新版本，在稳定运行一段时间后（时长不定），我们会把版本同步至NTrace-core。这意味着NTrace-dev仓库充当了一个“测试版”的角色。<br>\n请注意，版本同步也存在例外。如果NTrace-dev的某个版本出现了严重的bug，NTrace-core会跳过这一有缺陷的版本，直接同步到下一个修复了该问题的版本。\n\n### Before Using\n\n使用 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)，在了解您自己的对数据精准度需求以后再进行抉择。\n\n### Automated Install\n\n- Linux\n  - 一键安装脚本\n    ```shell\n    curl -sL https://nxtrace.org/nt | bash\n    ```\n  - 从 nxtrace的APT源安装\n    - 支持 AMD64/ARM64 架构\n      ```shell\n      curl -fsSL https://github.com/nxtrace/nexttrace-debs/releases/latest/download/nexttrace-archive-keyring.gpg | sudo tee /etc/apt/keyrings/nexttrace.gpg >/dev/null\n      echo \"Types: deb\n      URIs: https://github.com/nxtrace/nexttrace-debs/releases/latest/download/\n      Suites: ./\n      Signed-By: /etc/apt/keyrings/nexttrace.gpg\" | sudo tee /etc/apt/sources.list.d/nexttrace.sources >/dev/null\n      sudo apt update\n      sudo apt install nexttrace\n      ```\n    - APT源由 wcbing, nxtrace 维护\n\n  - Arch Linux AUR 安装命令\n    - 直接下载bin包(仅支持amd64)\n      ```shell\n      yay -S nexttrace-bin\n      ```\n    - 从源码构建(仅支持amd64)\n      ```shell\n      yay -S nexttrace\n      ```\n    - AUR 的构建分别由 ouuan, huyz 维护\n\n  - Linuxbrew 安装命令\n\n    同macOS Homebrew安装方法(homebrew-core版仅支持amd64)\n\n  - deepin 安装命令\n\n    ```shell\n    apt install nexttrace\n    ```\n\n  - [x-cmd](https://cn.x-cmd.com/pkg/nexttrace) 安装命令\n\n    ```shell\n    x env use nexttrace\n    ```\n\n  - Termux 安装命令\n    ```shell\n    pkg install root-repo\n    pkg install nexttrace\n    ```\n  - ImmortalWrt 安装命令\n    ```shell\n    opkg install nexttrace\n    ```\n\n- macOS\n  - macOS Homebrew 安装命令\n    - homebrew-core版\n      ```shell\n      brew install nexttrace\n      ```\n    - 本仓库ACTIONS自动构建版(更新更快)\n      ```shell\n      brew tap nxtrace/nexttrace && brew install nxtrace/nexttrace/nexttrace\n      ```\n    - homebrew-core 构建由 chenrui333 维护，请注意该版本更新可能会落后仓库Action自动构建版本\n\n- Windows\n  - Windows WinGet 安装命令\n    - WinGet 版\n      ```powershell\n      winget install nexttrace\n      ```\n    - WinGet 构建由 Dragon1573 维护\n\n  - Windows Scoop 安装命令\n    - scoop-extras 版\n    ```powershell\n    scoop bucket add extras && scoop install extras/nexttrace\n    ```\n\n    - scoop-extra 由 soenggam 维护\n\n请注意，以上多种安装方式的仓库均由开源爱好者自行维护，不保证可用性和及时更新，如遇到问题请联系仓库维护者解决，或使用本项目官方编译提供的二进制包。\n\n### Manual Install\n\n- 下载预编译的可执行程序\n\n  对于以上方法没有涵盖的用户，请直接前往 [Release](https://www.nxtrace.org/downloads) 下载编译好的二进制可执行文件。\n  - `Release`里面为很多系统以及不同架构提供了编译好的二进制可执行文件，如果没有可以自行编译。\n  - 一些本项目的必要依赖在`Windows`上`Golang`底层实现不完全，所以目前`NextTrace`在`Windows`平台出于实验性支持阶段。\n\n### 版本说明\n\n从本版本开始，NextTrace 在同一 release tag 下发布 **三种构建版本**，按需选用：\n\n| 功能                    | `nexttrace`（完整版） | `nexttrace-tiny` |   `ntr`    |\n| ----------------------- | :-------------------: | :--------------: | :--------: |\n| 常规 traceroute         |          ✅           |        ✅        |     —      |\n| 独立 MTU（`--mtu`）     |          ✅           |        ✅        |     —      |\n| MTR TUI                 |          ✅           |        —         | ✅（默认） |\n| MTR 报告（`-r`）        |          ✅           |        —         |     ✅     |\n| MTR 宽报告（`-w`）      |          ✅           |        —         |     ✅     |\n| MTR 原始输出（`--raw`） |          ✅           |        —         |     ✅     |\n| Globalping（`--from`）  |          ✅           |        —         |     —      |\n| WebUI（`--deploy`）     |          ✅           |        —         |     —      |\n| 快速跟踪（`-F`）        |          ✅           |        ✅        |     —      |\n| 默认运行模式            |      traceroute       |    traceroute    |  MTR TUI   |\n| 二进制名                |      `nexttrace`      | `nexttrace-tiny` |   `ntr`    |\n\n> **注意：** 包管理器（Homebrew、AUR、Scoop 等）目前仅安装 **完整版**（`nexttrace`）。\n\n### 功能对比\n\n- **`nexttrace`** — 完整版。包含所有功能：traceroute、MTR、Globalping 与 WebUI。\n- **`nexttrace-tiny`** — 精简版。仅保留常规 traceroute，不含 MTR / Globalping / WebUI。适合嵌入式或极简环境。\n- **`ntr`** — MTR 专用版。默认启动 MTR TUI。无 Globalping / WebUI，无常规 traceroute 模式，也不带独立 `--mtu` 模式。\n\n### 手动编译\n\n需要 Go 1.22+ 环境：\n\n```bash\n# 完整版（所有功能）\ngo build -trimpath -o dist/nexttrace -ldflags \"-w -s\" .\n\n# 精简版（无 MTR、无 Globalping、无 WebUI）\ngo build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny -ldflags \"-w -s\" .\n\n# MTR 专用版\ngo build -tags flavor_ntr -trimpath -o dist/ntr -ldflags \"-w -s\" .\n```\n\n交叉编译示例：\n\n```bash\n# Linux arm64 精简版\nGOOS=linux GOARCH=arm64 CGO_ENABLED=0 \\\n  go build -tags flavor_tiny -trimpath -o dist/nexttrace-tiny_linux_arm64 -ldflags \"-w -s\" .\n```\n\n`tiny` 和 `ntr` 版本通过 **编译期 build tags** 裁剪模块——不是运行时开关。可通过 `go version -m <binary>` 验证 `nexttrace-tiny` 和 `ntr` 中不包含 `gin` 与 `globalping-cli`。\n\n`.cross_compile.sh` 脚本支持按版本构建：\n\n```bash\n./.cross_compile.sh all     # 构建全部三个版本（所有平台）\n./.cross_compile.sh full    # 仅构建 nexttrace（完整版）\n./.cross_compile.sh tiny    # 仅构建 nexttrace-tiny\n./.cross_compile.sh ntr     # 仅构建 ntr\n```\n\n### 发行资产命名规则\n\nRelease 二进制文件命名格式：\n\n```text\n{二进制名}_{操作系统}_{架构}[v{arm版本}][.exe][_softfloat]\n```\n\n示例：\n\n- `nexttrace_linux_amd64`、`nexttrace-tiny_linux_amd64`、`ntr_linux_amd64`\n- `nexttrace_darwin_universal`、`nexttrace-tiny_darwin_universal`、`ntr_darwin_universal`\n- `nexttrace_windows_amd64.exe`、`ntr_windows_amd64.exe`\n\n### Get Started\n\n`NextTrace` 默认使用`ICMP`协议发起`TraceRoute`请求，该协议同时支持`IPv4`和`IPv6`\n\n```bash\n# IPv4 ICMP Trace\nnexttrace 1.0.0.1\n# URL\nnexttrace http://example.com:8080/index.html?q=1\n\n# 表格输出（报告模式）：运行一次探测后打印最终汇总表格\nnexttrace --table 1.0.0.1\n\n# 机器可读输出：stdout 只包含一个 JSON 文档\nnexttrace --raw 1.0.0.1\nnexttrace --json 1.0.0.1\n\n# 将实时 traceroute 输出写入自定义文件\nnexttrace --output ./trace.log 1.0.0.1\n\n# 将实时 traceroute 输出写入默认日志文件\nnexttrace --output-default 1.0.0.1\n\n# 只进行IPv4/IPv6解析，且当多个IP时自动选择第一个IP\nnexttrace --ipv4 g.co\nnexttrace --ipv6 g.co\n\n# IPv6 ICMP Trace\nnexttrace 2606:4700:4700::1111\n\n# 普通 traceroute 模式下设置 TTL 分组间隔（默认 300ms）\nnexttrace -i 300 1.1.1.1\n\n# 禁用路径可视化 使用 --map / -M 参数\nnexttrace koreacentral.blob.core.windows.net\n# MapTrace URL: https://api.nxtrace.org/tracemap/html/c14e439e-3250-5310-8965-42a1e3545266.html\n\n# 禁用MPLS显示 使用 --disable-mpls / -e 参数 或 NEXTTRACE_DISABLEMPLS 环境变量\nnexttrace --disable-mpls example.com\nexport NEXTTRACE_DISABLEMPLS=1\n```\n\nPS: 路由可视化的绘制模块为独立模块，具体代码可在 [nxtrace/traceMap](https://github.com/nxtrace/traceMap) 查看  \n路由可视化功能因为需要每个 Hop 的地理位置坐标，而第三方 API 通常不提供此类信息，所以此功能目前只支持搭配 LeoMoeAPI 使用。\n\n#### `Windows` 用户必须完成的配置步骤\n\n- 对于普通用户模式：  \n  只能使用 **ICMP mode**，且需防火墙配置允许`ICMP/ICMPv6`。\n  ```powershell\n  netsh advfirewall firewall add rule name=\"All ICMP v4\" dir=in action=allow protocol=icmpv4:any,any\n  netsh advfirewall firewall add rule name=\"All ICMP v6\" dir=in action=allow protocol=icmpv6:any,any\n  ```\n- 对于管理员模式：  \n  **TCP/UDP mode** 依赖 `WinDivert`。  \n  **ICMP mode** 支持 `1=Socket` 与 `2=WinDivert`（`0=Auto`）。使用 Socket 模式时，需防火墙配置允许`ICMP/ICMPv6`。  \n  在 `Windows` 上，`ICMPv6` 未传 `--tos` 或显式 `--tos 0` 时继续走原生 Socket 发送路径；只有非零 `ICMPv6 --tos` 才额外依赖 `WinDivert` 发送能力，并要求管理员权限。  \n  `WinDivert` 可使用 `--init` 参数自动配置环境。\n\n#### `NextTrace` 现已经支持快速测试，有一次性测试回程路由需求的朋友可以使用\n\n```bash\n# 北上广（电信+联通+移动+教育网）IPv4 / IPv6 ICMP 快速测试\nnexttrace --fast-trace\n\n# 也可以使用 TCP SYN 而非 ICMP 进行测试\nnexttrace --fast-trace --tcp\n\n# 也可以通过自定义的IP/DOMAIN列表文件进行快速测试\nnexttrace --file /path/to/your/iplist.txt\n# 自定义的IP/DOMAIN列表文件格式\n## 一行一个IP/DOMAIN + 空格 + 描述信息（可选）\n## 例如：\n## 106.37.67.1 北京电信\n## 240e:928:101:31a::1 北京电信\n## bj.10086.cn 北京移动\n## 2409:8080:0:1::1\n## 223.5.5.5\n```\n\n#### `NextTrace` 已支持指定网卡进行路由跟踪\n\n```bash\n# 请注意 Lite 版本此参数不能和快速测试联用，如有需要请使用 enhanced 版本\n# 使用 eth0 网卡\nnexttrace --dev eth0 2606:4700:4700::1111\n\n# 使用 eth0 网卡IP\n# 网卡 IP 可以使用 ip a 或者 ifconfig 获取\n# 使用网卡IP进行路由跟踪时需要注意跟踪的IP类型应该和网卡IP类型一致（如都为 IPv4）\nnexttrace --source 204.98.134.56 9.9.9.9\n```\n\n#### `NextTrace` 也可以使用`TCP`和`UDP`协议发起`Traceroute`请求\n\n```bash\n# TCP SYN Trace\nnexttrace --tcp www.bing.com\n\n# 可以自行指定目标端口[此处为443]，默认80端口\nnexttrace --tcp --port 443 2001:4860:4860::8888\n\n# UDP Trace\nnexttrace --udp 1.0.0.1\n\n# 可以自行指定目标端口[此处为5353]，默认33494端口\nnexttrace --udp --port 5353 1.0.0.1\n\n# TCP/UDP Trace 可以自行指定源端口，默认使用随机一个固定的端口(如需每次发包随机使用不同的源端口，请设置`ENV` `NEXTTRACE_RANDOMPORT`)\nnexttrace --tcp --source-port 14514 www.bing.com\n```\n\n#### `NextTrace` 也支持独立的路径 MTU 探测模式\n\n```bash\n# 类 tracepath 的 UDP PMTU 探测，运行中实时刷行\nnexttrace --mtu 1.1.1.1\n\n# mtu 模式同样复用常规的 GeoIP / RDNS 参数\nnexttrace --mtu --data-provider IPInfo --language en 1.1.1.1\n\n# JSON 输出沿用独立 mtu schema，并包含 hop.geo\nnexttrace --mtu --json 1.1.1.1\n```\n\n- `--mtu` 是独立的 UDP-only 模式，不复用普通 traceroute 引擎。\n- TTY 下会原地更新当前 hop，并为 hop 状态 / PMTU 高亮加色；重定向/管道输出会退化成“定稿一跳输出一行”的无 ANSI 流式文本。\n- `--mtu --json` 在 stdout 上只输出独立的 MTU JSON 文档。\n- GeoIP、RDNS、`--data-provider`、`--language`、`--no-rdns`、`--always-rdns`、`--dot-server` 都对该模式生效。\n\n#### `NextTrace`也同样支持一些进阶功能，如 TTL 控制、并发数控制、模式切换等\n\n```bash\n# 每一跳发送2个探测包\nnexttrace --queries 2 www.hkix.net\n\n# 无并发，每次只发送一个探测包\nnexttrace --parallel-requests 1 www.hkix.net\n\n# 从TTL为5开始发送探测包，直到TTL为10结束\nnexttrace --first 5 --max-hops 10 www.decix.net\n# 此外还提供了一个ENV，可以设置是否隐匿目的IP\nexport NEXTTRACE_ENABLEHIDDENDSTIP=1\n\n# 关闭IP反向解析功能\nnexttrace --no-rdns www.bbix.net\n\n# 设置探测包总大小为1024字节（含 IP + 探测协议头）\nnexttrace --psize 1024 example.com\n\n# 让每个 probe 在 1500 字节内随机大小\nnexttrace --psize -1500 example.com\n\n# 设置 TOS / traffic class 字段\nnexttrace -Q 46 example.com\n\n# 特色功能：打印Route-Path图\n# Route-Path图示例：\n# AS6453 塔塔通信「Singapore『Singapore』」\n#  ╭╯\n#  ╰AS9299 Philippine Long Distance Telephone Co.「Philippines『Metro Manila』」\n#  ╭╯\n#  ╰AS36776 Five9 Inc.「Philippines『Metro Manila』」\n#  ╭╯\n#  ╰AS37963 阿里云「ALIDNS.COM『ALIDNS.COM』」\nnexttrace --route-path www.time.com.my\n# 禁止色彩输出\nnexttrace --no-color 1.1.1.1\n# 或者使用环境变量\nexport NO_COLOR=1\n```\n\n#### 高级参数调优速查\n\n| 参数 | 控制内容 | 默认值 / 起步建议 | 什么时候调 |\n| --- | --- | --- | --- |\n| `--queries` | 常规 traceroute 的每跳采样数；MTR 中显式指定每跳探测次数 | traceroute: `3`；MTR report: 未指定时 `10`；MTR TUI/raw: 未指定时无限 | 链路抖动大时可升到 `5-10` |\n| `--max-attempts` | 每跳最大发包上限 | 默认按 `--queries` 自动推导 | 丢包严重、回包慢时增大 |\n| `--parallel-requests` | 跨 TTL 的总并发 in-flight 探测数 | `18` | 多路径/负载均衡链路用 `1`；稳定链路一般 `6-18` |\n| `--send-time` | 同一 TTL 组内相邻探测包间隔 | `50ms` | 设备限速时升到 `100-200ms`；MTR 下忽略 |\n| `--ttl-time` | 常规 traceroute 的 TTL 组间隔；MTR 的每跳探测间隔 | traceroute: `300ms`；MTR: 未指定时 `1000ms` | 想加速就调低；远程/限速链路调高 |\n| `--timeout` | 单个探测包超时 | `1000ms` | 跨洲或高丢包链路升到 `2000-3000ms` |\n| `--psize` | 探测包大小 | 按协议/IP 族自动取最小合法值 | 含 IP + 探测协议头；负值表示每个 probe 在 `abs(value)` 内随机；超过出接口/路径 MTU 时，链路上可能看到分片 |\n| `-Q`, `--tos` | IP TOS / traffic class | `0` | 设置 IP 头里的 TOS / traffic class；在 Windows 上仅 `ICMPv6` 且值非零时额外依赖 `WinDivert` |\n\n这些探测参数目前仍是 CLI 级配置，`nt_config.yaml` 还不能直接保存它们。若要复用一组调优参数，建议写成 shell alias 或小脚本。\n\n```bash\n# 适合多路径 / ECMP 的保守配置\nnexttrace --parallel-requests 1 --send-time 100 --ttl-time 500 --timeout 2000 example.com\n\n# 适合稳定单路径链路的快速配置\nnexttrace --parallel-requests 18 --send-time 20 --ttl-time 150 example.com\n\n# 适合高丢包长途链路的配置\nnexttrace --queries 5 --max-attempts 10 --timeout 2500 example.com\n```\n\n#### `NextTrace` 支持 MTR（My Traceroute）连续探测模式\n\n```bash\n# MTR 模式：使用 ICMP（默认）连续探测，实时刷新表格\nnexttrace -t 1.1.1.1\n# 等价写法：\nnexttrace --mtr 1.1.1.1\n\n# MTR 模式使用 TCP SYN 探测\nnexttrace -t --tcp --port 443 www.bing.com\n\n# MTR 模式使用 UDP 探测\nnexttrace -t --udp 1.0.0.1\n\n# 设置每个跳点的探测间隔（MTR 模式下默认 1000ms；-z/--send-time 在 MTR 模式下无效）\nnexttrace -t -i 500 1.1.1.1\n\n# 限制每个跳点的最大探测次数（TUI 默认无限，报告模式默认 10）\nnexttrace -t -q 20 1.1.1.1\n\n# 报告模式：对每个跳点探测 N 次后一次性输出统计摘要（类似 mtr -r）\nnexttrace -r 1.1.1.1       # = --mtr --report，默认每跳点 10 次\nnexttrace -r -q 5 1.1.1.1  # 每跳点 5 次\n\n# 宽报告模式：主机列不截断（类似 mtr -rw）\nnexttrace -w 1.1.1.1       # = --mtr --report --wide\n\n# 在 MTR 输出中同时显示 PTR 和 IP（PTR 在前，IP 括号）\nnexttrace --mtr --show-ips 1.1.1.1\nnexttrace -r --show-ips 1.1.1.1\nnexttrace -w --show-ips 1.1.1.1\n\n# MTR 原始流式模式（面向程序解析，逐事件输出）\nnexttrace --mtr --raw 1.1.1.1\nnexttrace -r --raw 1.1.1.1\n\n# 与其他选项组合使用\nnexttrace -t --tcp --max-hops 20 --first 3 --no-rdns 8.8.8.8\n```\n\n在终端（TTY）中运行时，MTR 模式使用**交互式全屏 TUI**：\n\n- **`q` / `Q`** — 退出（恢复终端，不留下输出）\n- **`p`** — 暂停探测\n- **空格** — 恢复探测\n- **`r`** — 重置统计（计数器清零，显示模式保持不变）\n- **`y`** — 循环切换主机显示模式：ASN → City → Owner → Full\n- **`n`** — 切换主机名显示方式：\n  - 默认：PTR（无 PTR 时回退 IP）↔ 仅 IP\n  - 启用 `--show-ips`：PTR (IP) ↔ 仅 IP\n- **`e`** — 切换 MPLS 标签显示开/关\n- TUI 标题栏显示**源 → 目标**路由信息，指定 `--source`/`--dev` 时会展示对应信息。\n- 使用 LeoMoeAPI 时，标题栏会显示首选 API IP 地址。\n- 使用**备用屏幕缓冲区**，退出后恢复之前的终端历史记录。\n- 当 stdin 非 TTY（如管道输入）时，降级为简单表格刷新模式。\n\n**报告模式**（`-r`/`--report`）在所有探测完成后一次性输出统计，适合脚本使用：\n\n```text\nStart: 2025-07-14T09:12:00+08:00\nHOST: myhost                    Loss%   Snt   Last    Avg   Best   Wrst  StDev\n  1. one.one.one.one            0.0%    10    1.23   1.45   0.98   2.10   0.32\n  2. 10.0.0.2                 100.0%    10    0.00   0.00   0.00   0.00   0.00\n```\n\n显示为 `(waiting for reply)` 的行仍然保留同样的表格列布局，只是该行的指标单元格会留空。\n\n非 wide 报告模式会刻意保持 Host 列精简：\n\n- 只显示 `PTR/IP`\n- 不发起 Geo API 查询\n- 不显示 ASN / 运营商 / 地理位置字段\n- 不显示 MPLS 标签\n\nwide 报告模式（`-w` / `--wide`）继续保留当前完整信息行为，包括 Geo 衍生字段和 MPLS 输出。\n\n当 `--raw` 与 MTR（`--mtr`、`-r`、`-w`）一起使用时，会进入 **MTR 原始流式模式**。\n\n如果当前数据源是 `LeoMoeAPI`，会先输出一行无色的 API 信息头：\n\n```text\n[NextTrace API] preferred API IP - [2403:18c0:1001:462:dd:38ff:fe48:e0c5] - 21.33ms - DMIT.NRT\n```\n\n之后再逐行输出 `|` 分隔的事件流：\n\n```\n4|84.17.33.106|po66-3518.cr01.nrt04.jp.misaka.io|0.27|60068|日本|东京都|东京||cdn77.com|35.6804|139.7690\n```\n\n字段顺序：\n\n`ttl|ip|ptr|rtt|asn|一级行政区|二级行政区|三级行政区|四级行政区|owner|纬度|经度`\n\n超时行保持固定 12 列：\n\n`ttl|*||||||||||`\n\n在 MTR 模式（`--mtr`、`-r`、`-w`，包括 `--raw`）下，`-i/--ttl-time` 设置的是**每个跳点的探测间隔**：同一跳点两次连续探测之间的等待时间（未显式指定时默认 1000ms）。`-z/--send-time` 在 MTR 模式下被忽略。\n\n> 注意：`--show-ips` 仅在 MTR 模式（`--mtr`、`-r`、`-w`）生效，其他模式会忽略。\n>\n> 注意：`--mtr` 不可与 `--table`、`--classic`、`--json`、`--output`、`--output-default`、`--route-path`、`--from`、`--fast-trace`、`--file`、`--deploy` 同时使用。\n\n#### `NextTrace`支持用户自主选择 IP 数据库（目前支持：`LeoMoeAPI`, `IP.SB`, `IPInfo`, `IPInsight`, `IPAPI.com`, `IPInfoLocal`, `CHUNZHEN`)\n\n```bash\n# 可以自行指定IP数据库[此处为IP-API.com]，不指定则默认为LeoMoeAPI\nnexttrace --data-provider ip-api.com\n## 特别的: 其中 ipinfo 和 IPInsight API 对于免费版查询有频率限制，可从这些服务商自行购买服务以解除限制，如有需要可以 clone 本项目添加其提供的 token 自行编译\n##        TOKEN填写路径：ipgeo/tokens.go\n\n## 特别的: 对于离线库 IPInfoLocal，请自行下载并命名为 ipinfoLocal.mmdb\n##        (可以从这里下载：https://ipinfo.io/signup?ref=free-database-downloads)，\n##        默认搜索用户当前路径、程序所在路径、和 FHS 路径（Unix-like）\n##        如果需要自定义路径，请设置环境变量\nexport NEXTTRACE_IPINFOLOCALPATH=/xxx/yyy.mmdb\n## 另外：由于IP.SB被滥用比较严重，会经常出现无法查询的问题，请知悉。\n##      IP-API.com限制调用较为严格，如有查询不到的情况，请几分钟后再试。\n# 纯真IP数据库默认使用 http://127.0.0.1:2060 作为查询接口，如需自定义请使用环境变量\nexport NEXTTRACE_CHUNZHENURL=http://127.0.0.1:2060\n## 可使用 https://github.com/freshcn/qqwry 自行搭建纯真IP数据库服务\n\n# 也可以通过设置环境变量来指定默认IP数据库\nexport NEXTTRACE_DATAPROVIDER=ipinfo\n```\n\n#### `NextTrace`支持使用混合参数和简略参数\n\n```bash\nExample:\nnexttrace --data-provider ip-api.com --max-hops 20 --tcp --port 443 --queries 5 --no-rdns 1.1.1.1\nnexttrace -tcp --queries 2 --parallel-requests 1 --table --route-path 2001:4860:4860::8888\n\nEquivalent to:\nnexttrace -d ip-api.com -m 20 -T -p 443 -q 5 -n 1.1.1.1\nnexttrace -T -q 2 --parallel-requests 1 --table -P 2001:4860:4860::8888\n```\n\n### Globalping\n\n[Globalping](https://globalping.io/) 提供了对成千上万由社区托管的探针的访问能力，可用于运行网络测试和测量。\n\n通过 `--from` 参数可以选择使用指定位置的探针来执行 traceroute。位置字段支持洲、国家、地区、城市、ASN、ISP 或云厂商区域等多种类型。\n\n```bash\nnexttrace google.com --from Germany\nnexttrace google.com --from comcast+california\n```\n\n匿名用户默认每小时限额为 250 次测试。将 `GLOBALPING_TOKEN` 环境变量设置为你的令牌后，可将限额提升至每小时 500 次。\n\n```bash\nexport GLOBALPING_TOKEN=your_token_here\n```\n\n### 环境变量总览\n\nNextTrace 当前会读取下列环境变量。对于布尔开关，只识别 `1` 和 `0`，其他值会回退到内置默认值。为了避免混淆，修改后建议重启 NextTrace。\n\n#### 核心运行 / 网络\n\n| 变量名 | 默认值 | 说明 |\n| --- | --- | --- |\n| `NEXTTRACE_DEVMODE` | `0` | 开发调试模式：致命错误改为 panic，并打印堆栈。 |\n| `NEXTTRACE_DEBUG` | 未设置 | 在 `GetEnv*` 解析环境变量时打印检测到的值。 |\n| `NEXTTRACE_DISABLEMPLS` | `0` | 全局禁用 MPLS 显示，效果类似 `--disable-mpls`。 |\n| `NEXTTRACE_ENABLEHIDDENDSTIP` | `0` | 隐匿目的 IP，并省略其主机名显示。 |\n| `NEXTTRACE_RANDOMPORT` | `0` | TCP/UDP 每个探测包使用不同的随机源端口。 |\n| `NEXTTRACE_MAXATTEMPTS` | 自动计算 | 当未显式传入 `--max-attempts` 时，提供默认最大重试次数。 |\n| `NEXTTRACE_ICMPMODE` | `0` | 当未显式传入 `--icmp-mode` 时提供默认值（`0=自动`、`1=Socket`、`2=WinDivert`）。 |\n| `NEXTTRACE_UNINTERRUPTED` | `0` | 与 `--raw` 一起使用时，会在一次探测结束后继续循环执行，而不是退出。 |\n| `NEXTTRACE_PROXY` | 未设置 | 为 PoW、Geo API、tracemap 等出站 HTTP / WebSocket 请求设置代理 URL。 |\n| `NEXTTRACE_DATAPROVIDER` | 未设置 | 覆盖默认 IP 地理信息源，例如 `ipinfo`。 |\n\n#### 服务 / Web / 后端\n\n| 变量名 | 默认值 | 说明 |\n| --- | --- | --- |\n| `NEXTTRACE_HOSTPORT` | `api.nxtrace.org` | 覆盖 LeoMoeAPI、tracemap、FastIP 等使用的后端地址，支持 `host` 或 `host:port`。 |\n| `NEXTTRACE_TOKEN` | 未设置 | 预置 LeoMoeAPI Bearer Token；设置后将跳过 PoW 取 token 流程。 |\n| `NEXTTRACE_POWPROVIDER` | `api.nxtrace.org` | 指定 PoW 服务提供方；当前内置的非默认别名为 `sakura`。 |\n| `NEXTTRACE_DEPLOY_ADDR` | 未设置 | `--deploy` 模式下，当未传 `--listen` 时使用的默认监听地址。 |\n| `NEXTTRACE_ALLOW_CROSS_ORIGIN` | `0` | 仅对 `--deploy` 生效：是否允许跨站浏览器访问 Web UI / API。默认关闭以保证安全。 |\n\n#### IP 数据库 / 第三方服务\n\n| 变量名 | 默认值 | 说明 |\n| --- | --- | --- |\n| `NEXTTRACE_IPINFOLOCALPATH` | 自动搜索 | `IPInfoLocal` 离线库 `ipinfoLocal.mmdb` 的完整路径。 |\n| `NEXTTRACE_CHUNZHENURL` | `http://127.0.0.1:2060` | 纯真 IP 查询服务的基础 URL。 |\n| `NEXTTRACE_IPINFO_TOKEN` | 未设置 | `IPInfo` 数据源使用的 token。 |\n| `NEXTTRACE_IPINSIGHT_TOKEN` | 未设置 | `IPInsight` 数据源使用的 token。 |\n| `NEXTTRACE_IPAPI_BASE` | 各 provider 内置地址 | 覆盖当前实现里兼容 HTTP 接口的数据源基础地址（`IPInfo`、`IPInsight`、`ip-api.com`）。 |\n| `IPDBONE_BASE_URL` | `https://api.ipdb.one` | 覆盖 IPDB.One API 基础地址。 |\n| `IPDBONE_API_ID` | 未设置 | IPDB.One API ID。 |\n| `IPDBONE_API_KEY` | 未设置 | IPDB.One API Key。 |\n| `GLOBALPING_TOKEN` | 未设置 | Globalping 鉴权 token；设置后可提升匿名用户的每小时测试额度。 |\n\n#### 配置文件搜索\n\n| 变量名 | 默认值 | 说明 |\n| --- | --- | --- |\n| `XDG_CONFIG_HOME` | 取决于系统 / Shell | 如果设置了该变量，NextTrace 也会从 `$XDG_CONFIG_HOME/nexttrace` 搜索 `nt_config.yaml`。 |\n\n### 全部用法详见 Usage 菜单\n\n```shell\nUsage: nexttrace [-h|--help] [--init] [-4|--ipv4] [-6|--ipv6] [-T|--tcp]\n                 [-U|--udp] [-F|--fast-trace] [-p|--port <integer>]\n                 [--icmp-mode <integer>] [-q|--queries <integer>]\n                 [--max-attempts <integer>] [--parallel-requests <integer>]\n                 [-m|--max-hops <integer>] [-d|--data-provider\n                 (IP.SB|ip.sb|IPInfo|ipinfo|IPInsight|ipinsight|IPAPI.com|ip-api.com|IPInfoLocal|ipinfolocal|chunzhen|LeoMoeAPI|leomoeapi|ipdb.one|disable-geoip)]\n                 [--pow-provider (api.nxtrace.org|sakura)] [-n|--no-rdns]\n                 [-a|--always-rdns] [-P|--route-path] [--dn42] [-o|--output\n                 \"<value>\"] [-O|--output-default] [--table] [--raw]\n                 [-j|--json] [-c|--classic] [-f|--first <integer>] [-M|--map]\n                 [-e|--disable-mpls] [-V|--version]\n                 [-s|--source \"<value>\"] [--source-port <integer>] [-D|--dev\n                 \"<value>\"] [--listen \"<value>\"] [--deploy] [-z|--send-time\n                 <integer>] [-i|--ttl-time <integer>] [--timeout <integer>]\n                 [--psize <integer>] [--dot-server\n                 (dnssb|aliyun|dnspod|google|cloudflare)] [-g|--language\n                 (en|cn)] [-C|--no-color] [--from \"<value>\"] [-t|--mtr]\n                 [-r|--report] [-w|--wide] [--show-ips] [-y|--ipinfo <integer>]\n                 [--file \"<value>\"] [TARGET \"<value>\"]\n\nArguments:\n\n  -h  --help                         Print help information\n      --init                         Windows ONLY: Extract WinDivert runtime to\n                                     current directory\n  -4  --ipv4                         Use IPv4 only\n  -6  --ipv6                         Use IPv6 only\n  -T  --tcp                          Use TCP SYN for tracerouting (default\n                                     dest-port is 80)\n  -U  --udp                          Use UDP SYN for tracerouting (default\n                                     dest-port is 33494)\n  -F  --fast-trace                   One-Key Fast Trace to China ISPs\n  -p  --port                         Set the destination port to use. With\n                                     default of 80 for \"tcp\", 33494 for \"udp\"\n      --icmp-mode                    Windows ONLY: Choose the method to listen\n                                     for ICMP packets (1=Socket, 2=WinDivert;\n                                     0=Auto)\n  -q  --queries                      Latency samples per hop. Increase to 5-10\n                                     on unstable paths for a steadier view.\n                                     Default: 3\n      --max-attempts                 Advanced: hard cap on probe packets per\n                                     hop. Leave unset for auto sizing; raise on\n                                     lossy links if --queries is not enough\n      --parallel-requests            Advanced: total concurrent in-flight\n                                     probes across TTLs. Use 1 on\n                                     multipath/load-balanced paths; 6-18 is a\n                                     good starting range on stable links.\n                                     Default: 18\n  -m  --max-hops                     Set the max number of hops (max TTL to be\n                                     reached). Default: 30\n  -d  --data-provider                Choose IP Geograph Data Provider [IP.SB,\n                                     IPInfo, IPInsight, IP-API.com,\n                                     IPInfoLocal, CHUNZHEN, disable-geoip].\n                                     Default: LeoMoeAPI\n      --pow-provider                 Choose PoW Provider [api.nxtrace.org,\n                                     sakura] For China mainland users, please\n                                     use sakura. Default: api.nxtrace.org\n  -n  --no-rdns                      Do not resolve IP addresses to their\n                                     domain names\n  -a  --always-rdns                  Always resolve IP addresses to their\n                                     domain names\n  -P  --route-path                   Print traceroute hop path by ASN and\n                                     location\n      --dn42                         DN42 Mode\n  -o  --output                       Write trace result to FILE\n                                     (RealtimePrinter only)\n  -O  --output-default               Write trace result to the default log file\n                                     (/tmp/trace.log)\n      --table                        Output trace results as a final summary\n                                     table (traceroute report mode)\n      --raw                          Machine-friendly output. With MTR\n                                     (--mtr/-r/-w), enables streaming raw event\n                                     mode\n  -j  --json                         Output trace results as JSON\n  -c  --classic                      Classic Output trace results like\n                                     BestTrace\n  -f  --first                        Start from the first_ttl hop (instead of\n                                     1). Default: 1\n  -M  --map                          Disable Print Trace Map\n  -e  --disable-mpls                 Disable MPLS\n  -V  --version                      Print version info and exit\n  -s  --source                       Use source address src_addr for outgoing\n                                     packets\n      --source-port                  Use source port src_port for outgoing\n                                     packets\n  -D  --dev                          Use the following Network Devices as the\n                                     source address in outgoing packets\n      --listen                       Set listen address for web console (e.g.\n                                     127.0.0.1:30080)\n      --deploy                       Start the Gin powered web console\n  -z  --send-time                    Advanced: per-packet gap [ms] inside the\n                                     same TTL group. Lower is faster; raise to\n                                     100-200ms on rate-limited links. Ignored\n                                     in MTR mode. Default: 50\n  -i  --ttl-time                     Advanced: TTL-group interval [ms] in\n                                     normal traceroute. In MTR mode\n                                     (--mtr/-r/-w, including --raw), this\n                                     becomes per-hop probe interval. 500-1000ms\n                                     is a good MTR starting range\n      --timeout                      Per-probe timeout [ms]. Raise to 2000-3000\n                                     on slow intercontinental or high-loss\n                                     paths. Default: 1000\n      --psize                        Probe packet size in bytes, inclusive IP\n                                     and active probe headers. Default is the\n                                     minimum legal size for the chosen\n                                     protocol and IP family; raise for MTU or\n                                     large-packet testing. Negative values\n                                     randomize each probe up to abs(value).\n  -Q  --tos                          Set the IP type-of-service / traffic class\n                                     value [0-255]. Default: 0\n      --dot-server                   Use DoT Server for DNS Parse [dnssb,\n                                     aliyun, dnspod, google, cloudflare]\n  -g  --language                     Choose the language for displaying [en,\n                                     cn]. Default: cn\n  -C  --no-color                     Disable Colorful Output\n      --from                         Run traceroute via Globalping\n                                     (https://globalping.io/network) from a\n                                     specified location. The location field\n                                     accepts continents, countries, regions,\n                                     cities, ASNs, ISPs, or cloud regions.\n  -t  --mtr                          Enable MTR (My Traceroute) continuous\n                                     probing mode\n  -r  --report                       MTR report mode (non-interactive, implies\n                                     --mtr); can trigger MTR without --mtr\n  -w  --wide                         MTR wide report mode (implies --mtr\n                                     --report); alone equals --mtr --report\n                                     --wide\n      --show-ips                     MTR only: display both PTR hostnames and\n                                     numeric IPs (PTR first, IP in parentheses)\n  -y  --ipinfo                       Set initial MTR TUI host info mode (0-4).\n                                     TUI only; ignored in --report/--raw.\n                                     0:IP/PTR 1:ASN 2:City 3:Owner 4:Full.\n                                     Default: 0\n      --file                         Read IP Address or domain name from file\n      TARGET                         Trace target: IPv4 address (e.g. 8.8.8.8),\n                                     IPv6 address (e.g. 2001:db8::1), domain\n                                     name (e.g. example.com), or URL (e.g.\n                                     https://example.com)\n```\n\n## 项目截图\n\n![image](https://user-images.githubusercontent.com/59512455/218505939-287727ce-7207-43c4-8e31-fcda7df0b872.png)\n\n![image](https://user-images.githubusercontent.com/59512455/218504874-06b9fa4b-48e0-420a-a195-08a1200d65a7.png)\n\n## 第三方 IP 数据库 API 开发接口\n\nNextTrace 所有的的 IP 地理位置 `API DEMO` 可以参考[这里](https://github.com/nxtrace/NTrace-core/blob/main/ipgeo/)\n\n你可以在这里添加你自己的 API 接口，为了 NextTrace 能够正确显示你接口中的内容，请参考 `leo.go` 中所需要的信息\n\n✨NextTrace `LeoMoeAPI` 的后端 Demo\n\n[GitHub - sjlleo/nexttrace-backend: NextTrace BackEnd](https://github.com/sjlleo/nexttrace-backend)\n\nNextTrace `LeoMoeAPI`现已使用Proof of Work(POW)机制来防止滥用，其中NextTrace作为客户端引入了powclient库，POW CLIENT/SERVER均已开源，欢迎大家使用。(POW模块相关问题请发到对应的仓库)\n\n- [GitHub - tsosunchia/powclient: Proof of Work CLIENT for NextTrace](https://github.com/tsosunchia/powclient)\n- [GitHub - tsosunchia/powserver: Proof of Work SERVER for NextTrace](https://github.com/tsosunchia/powserver)\n\n对于中国大陆用户，可以使用 [Nya Labs](https://natfrp.com) 提供的位于大陆的POW服务器优化访问速度\n\n```shell\n#使用方法任选其一\n#1. 在环境变量中设置\nexport NEXTTRACE_POWPROVIDER=sakura\n#2. 在命令行中设置\nnexttrace --pow-provider sakura\n```\n\n## OpenTrace\n\n`OpenTrace`是 @Archeb 开发的`NextTrace`的跨平台`GUI`版本，带来您熟悉但更强大的用户体验。  \n该软件仍然处于早期开发阶段，可能存在许多缺陷和错误，需要您宝贵的使用反馈。\n\n[https://github.com/Archeb/opentrace](https://github.com/Archeb/opentrace)\n\n## NEXTTRACE WEB API\n\n`NextTraceWebApi`是一个`MTR`风格的`NextTrace`网页版服务端实现，提供了包括`Docker`在内多种部署方式。\n\n在 WebSocket 持续探测模式中，MTR 现改为逐事件推送 `type: \"mtr_raw\"`（不再使用周期性 `mtr` 快照消息）。\n\n[https://github.com/nxtrace/nexttracewebapi](https://github.com/nxtrace/nexttracewebapi)\n\n## NextTraceroute\n\n`NextTraceroute`，一款默认使用`NextTrace API`的免`root`安卓版路由跟踪应用，由 @surfaceocean 开发。  \n感谢所有测试用户的热情支持，本应用已经通过封闭测试，正式进入 Google Play 商店。\n\n[https://github.com/nxtrace/NextTraceroute](https://github.com/nxtrace/NextTraceroute)  \n<a href='https://play.google.com/store/apps/details?id=com.surfaceocean.nexttraceroute&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' width=\"128\" height=\"48\" src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png'/></a>\n\n## Cloudflare Support\n\n本项目受 [Alexandria 计划](http://www.cloudflare.com/oss-credits)赞助。\n\n<img src=\"https://cf-assets.www.cloudflare.com/slt3lc6tev37/2I3y49Uz9Y61lBS0kIPZu6/db6df1e6f99a8659267c442b75a0dff9/image.png\" alt=\"Cloudflare Logo\" width=\"331\">\n\n## AIWEN TECH Support\n\n本项目受 [埃文科技](https://www.ipplus360.com) 赞助。 很高兴使用`埃文科技城市级IP库`增强本项目 GEOIP 查询的准确性与完整性，并免费提供给公众。\n\n<img src=\"https://www.ipplus360.com/img/LOGO.c86cd0e1.svg\" title=\"\" alt=\"埃文科技 IP 定位数据\" width=\"331\">\n\n## JetBrain Support\n\n本项目受 [JetBrain Open-Source Project License](https://jb.gg/OpenSourceSupport) 支持。 很高兴使用`Goland`作为我们的开发工具。\n\n<img src=\"https://resources.jetbrains.com/storage/products/company/brand/logos/GoLand.png\" title=\"\" alt=\"GoLand logo\" width=\"331\">\n\n## Credits\n\n[Gubo](https://www.gubo.org) 靠谱主机推荐\n\n[IPInfo](https://ipinfo.io) 无偿提供了本项目大部分数据支持\n\n[BGP.TOOLS](https://bgp.tools) 无偿提供了本项目的一些数据支持\n\n[PeeringDB](https://www.peeringdb.com) 无偿提供了本项目的一些数据支持\n\n[Globalping](https://globalping.io) 一个开源且免费的项目，提供全球范围内运行 traceroute 等网络测试的访问服务\n\n[sjlleo](https://github.com/sjlleo) 项目永远的领导者、创始人及核心贡献者\n\n[tsosunchia](https://github.com/tsosunchia) 项目现任管理、基础设施运维及核心贡献者\n\n[Yunlq](https://github.com/Yunlq) 活跃的社区贡献者\n\n[Vincent Young](https://github.com/missuo)\n\n[zhshch2002](https://github.com/zhshch2002)\n\n[Sam Sam](https://github.com/samleong123)\n\n[waiting4new](https://github.com/waiting4new)\n\n[FFEE_CO](https://github.com/fkx4-p)\n\n[bobo liu](https://github.com/fakeboboliu)\n\n[YekongTAT](https://github.com/isyekong)\n\n## Others\n\n- 其他第三方 API 尽管集成在本项目内，但是具体的 TOS 以及 AUP，请详见第三方 API 官网。如遇到 IP 数据错误，也请直接联系他们纠错。\n\n- 如何获取最新commit的新鲜出炉的二进制可执行文件？\n\n  > 请前往GitHub Actions中最新一次 [Build & Release](https://github.com/nxtrace/NTrace-dev/actions/workflows/build.yml) workflow.\n\n- 常见疑问\n  - Windows 平台下，ICMP 模式须手动放行ICMP/ICMPv6防火墙\n  - macOS 平台下，仅 ICMP 模式不需要提权运行\n  - 在一些情况下，同时运行多个 NextTrace 实例可能会导致互相干扰结果(目前仅在 TCP 模式下有观察到)\n\n## IP 数据以及精准度说明\n\n对于IP相关信息的纠错反馈，我们目前开放了两个渠道：\n\n> - 本项目的GITHUB ISSUES区中的[IP 错误报告汇总帖](https://github.com/orgs/nxtrace/discussions/222)\n> - 本项目的纠错专用邮箱: `correct#nxtrace.org` （请注意此邮箱仅供IP相关信息纠错专用，其他反馈请发送ISSUE）\n\nNextTrace 有多个数据源可以选择，目前默认使用的 LeoMoeAPI 为我们项目维护的数据源。\n\n该项目由 OwO Network 的 [Missuo](https://github.com/missuo) && [Leo](https://github.com/sjlleo) 发起，由 [Zhshch](https://github.com/zhshch2002/) 完成最早期架构的编写和指导，后由 Leo 完成了大部分开发工作，现主要交由 [tsosunchia](https://github.com/tsosunchia) 完成后续的二开和维护工作。\n\nLeoMoeAPI 是 [Leo](https://github.com/sjlleo) 的作品，归属于 Leo Network，由 [Leo](https://github.com/sjlleo) 完成整套后端 API 编写，该接口未经允许不可用于任何第三方用途。\n\nLeoMoeAPI 早期数据主要来自 IPInsight、IPInfo，随着项目发展，越来越多的志愿者参与进了这个项目。目前 LeoMoeAPI 有近一半的数据是社区提供的，而另外一半主要来自于包含 IPInfo、IPData、BigDataCloud、IPGeoLocation 在内的多个第三方数据。\n\nLeoMoeAPI 的骨干网数据有近 70% 是社区自发反馈又或者是项目组成员校准的，这给本项目的路由跟踪基础功能带来了一定的保证，但是全球骨干网的体量庞大，我们并无能力如 IPIP 等商业公司拥有海量监测节点，这使得 LeoMoeAPI 的数据精准度无法和形如 BestTrace（IPIP）相提并论。\n\nLeoMoeAPI 已经尽力校准了比较常见的骨干网路由，这部分在测试的时候经常会命中，但是如果遇到封闭型 ISP 的路由，大概率可以遇到错误，此类数据不仅是我们，哪怕 IPInsight、IPInfo 也无法正确定位，目前只有 IPIP 能够标记正确，如对此类数据的精确性有着非常高的要求，请务必使用 BestTrace 作为首选。\n\n我们不保证我们的数据一定会及时更新，也不保证数据的精确性，我们希望您在发现数据错误的时候可以前往 issue 页面提交错误报告，谢谢。\n\n当您使用 LeoMoeAPI 即视为您已经完全了解 NextTrace LeoMoeAPI 的数据精确性，并且同意如果您引用 LeoMoeAPI 其中的数据从而引发的一切问题，均由您自己承担。\n\n## DN42 模式使用说明\n\n使用这个模式需要您配置 2 个文件，分别是 geofeed.csv 以及 ptr.csv\n\n当您初次运行 DN42 模式，NT 会为您生成 nt_config.yaml 文件，您可以自定义 2 个文件的存放位置，默认应该存放在 NT 的运行目录下\n\n### GeoFeed\n\n对于 geofeed.csv 来说，格式如下：\n\n```\nIP_CDIR,LtdCode,ISO3166-2,CityName,ASN,IPWhois\n```\n\n比如，您可以这么写：\n\n```\n58.215.96.0/20,CN,CN-JS,Wuxi,23650,CHINANET-JS\n```\n\n如果您有一个大段作为骨干网使用，您也可以不写地理位置信息，如下：\n\n```\n202.97.0.0/16,,,4134,CHINANET-BACKBONE\n```\n\n### PTR\n\n对于 ptr.csv 来说，格式如下：\n\n```\nIATA_CODE,LtdCode,RegionName,CityName\n```\n\n比如对于美国洛杉矶，您可以这么写\n\n```\nLAX,US,California,Los Anegles\n```\n\n需要注意的是，NextTrace 支持自动匹配 CSV 中的城市名，如果您的 PTR 记录中有 `losangeles`，您可以只添加上面一条记录就可以正常识别并读取。\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=nxtrace/NTrace-core&type=Date)](https://star-history.com/#nxtrace/NTrace-core&Date)\n"
  },
  {
    "path": "_config.yml",
    "content": "theme: jekyll-theme-cayman"
  },
  {
    "path": "assets/windivert/divert.go",
    "content": "package windivert\n\nimport (\n\t\"bytes\"\n\t\"crypto/sha256\"\n\t_ \"embed\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n)\n\n//go:embed x64/WinDivert.dll\nvar winDivertDLL64 []byte\n\n//go:embed x64/WinDivert64.sys\nvar winDivertSYS64 []byte\n\n//go:embed x86/WinDivert.dll\nvar winDivertDLL32 []byte\n\n//go:embed x86/WinDivert32.sys\nvar winDivertSYS32 []byte\n\n// PrepareWinDivertRuntime 将内嵌的 WinDivert DLL/驱动解压到可执行文件同目录\nfunc PrepareWinDivertRuntime() error {\n\texe, err := os.Executable()\n\tif err != nil {\n\t\treturn err\n\t}\n\texeDir := filepath.Dir(exe)\n\n\tvar dllBytes, sysBytes []byte\n\tvar sysName string\n\n\tswitch runtime.GOARCH {\n\tcase \"amd64\", \"arm64\":\n\t\tdllBytes, sysBytes, sysName = winDivertDLL64, winDivertSYS64, \"WinDivert64.sys\"\n\tcase \"386\", \"arm\":\n\t\tdllBytes, sysBytes, sysName = winDivertDLL32, winDivertSYS32, \"WinDivert32.sys\"\n\tdefault:\n\t\treturn errors.New(\"unsupported GOARCH for WinDivert: \" + runtime.GOARCH)\n\t}\n\n\t// DLL\n\tif err := writeIfChecksumDiff(filepath.Join(exeDir, \"WinDivert.dll\"), dllBytes); err != nil {\n\t\treturn err\n\t}\n\n\t// SYS\n\tif err := writeIfChecksumDiff(filepath.Join(exeDir, sysName), sysBytes); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// writeIfChecksumDiff 通过比较 SHA-256 来判断是否覆写目标文件\nfunc writeIfChecksumDiff(dst string, data []byte) error {\n\tfile, err := os.Open(dst)\n\tif err != nil {\n\t\treturn os.WriteFile(dst, data, 0o644) // 读失败，则尝试覆盖\n\t}\n\n\thash := sha256.New()\n\tif _, err := io.Copy(hash, file); err != nil {\n\t\t_ = file.Close()                      // 先关再写，避免 Windows 共享冲突\n\t\treturn os.WriteFile(dst, data, 0o644) // 读失败，则尝试覆盖\n\t}\n\n\tsumFile := hash.Sum(nil)\n\t_ = file.Close() // 先关再写，避免 Windows 共享冲突\n\tsumMem := sha256.Sum256(data)\n\tif bytes.Equal(sumFile, sumMem[:]) {\n\t\treturn nil // 一致，跳过\n\t}\n\treturn os.WriteFile(dst, data, 0o644) // 不一致，则尝试覆盖\n}\n"
  },
  {
    "path": "cmd/cmd.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/akamensky/argparse\"\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/assets/windivert\"\n\t\"github.com/nxtrace/NTrace-core/config\"\n\tfastTrace \"github.com/nxtrace/NTrace-core/fast_trace\"\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/printer\"\n\t\"github.com/nxtrace/NTrace-core/reporter\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/tracelog\"\n\t\"github.com/nxtrace/NTrace-core/tracemap\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n\t\"github.com/nxtrace/NTrace-core/wshandle\"\n)\n\nfunc ptrBool(v bool) *bool    { return &v }\nfunc ptrStr(v string) *string { return &v }\nfunc ptrInt(v int) *int       { return &v }\n\ntype listenInfo struct {\n\tBinding string\n\tAccess  string\n}\n\nconst (\n\tdefaultPacketIntervalMs        = 50\n\tdefaultTracerouteTTLIntervalMs = 300\n)\n\nvar (\n\tdomainLookupFn = util.DomainLookUpWithContext\n)\n\nfunc normalizeListenAddr(addr string) string {\n\ttrimmed := strings.TrimSpace(addr)\n\tif trimmed == \"\" {\n\t\treturn \":1080\"\n\t}\n\tif isDigitsOnly(trimmed) {\n\t\treturn \":\" + trimmed\n\t}\n\treturn trimmed\n}\n\nfunc splitListenAddr(effective string) (host, port string, ok bool) {\n\thost, port, err := net.SplitHostPort(effective)\n\tif err == nil {\n\t\tif port == \"\" {\n\t\t\tport = \"1080\"\n\t\t}\n\t\treturn host, port, true\n\t}\n\tif strings.HasPrefix(effective, \":\") {\n\t\treturn \"\", strings.TrimPrefix(effective, \":\"), true\n\t}\n\treturn \"\", \"\", false\n}\n\nfunc formatHTTPListenURL(host, port string) string {\n\tif strings.Contains(host, \":\") && !strings.HasPrefix(host, \"[\") {\n\t\thost = \"[\" + host + \"]\"\n\t}\n\treturn fmt.Sprintf(\"http://%s:%s\", host, port)\n}\n\nfunc resolveListenAccessHost(host string) string {\n\tif host == \"\" || host == \"0.0.0.0\" || host == \"::\" {\n\t\treturn guessLocalIPv4()\n\t}\n\treturn host\n}\n\nfunc buildListenInfo(addr string) listenInfo {\n\teffective := normalizeListenAddr(addr)\n\thost, port, ok := splitListenAddr(effective)\n\tif !ok {\n\t\treturn listenInfo{Binding: effective}\n\t}\n\n\trawHost := host\n\tif rawHost == \"\" {\n\t\trawHost = \"0.0.0.0\"\n\t}\n\n\tinfo := listenInfo{\n\t\tBinding: formatHTTPListenURL(rawHost, port),\n\t}\n\n\taccessHost := resolveListenAccessHost(host)\n\tif accessHost != \"\" {\n\t\tinfo.Access = formatHTTPListenURL(accessHost, port)\n\t}\n\n\treturn info\n}\n\nfunc isDigitsOnly(s string) bool {\n\tif s == \"\" {\n\t\treturn false\n\t}\n\tfor _, r := range s {\n\t\tif r < '0' || r > '9' {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeNegativePacketSizeArgs(args []string) []string {\n\tif len(args) < 3 {\n\t\treturn args\n\t}\n\n\tnormalized := make([]string, 0, len(args))\n\tfor i := 0; i < len(args); i++ {\n\t\tcur := args[i]\n\t\tif cur == \"--psize\" && i+1 < len(args) && isNegativeInteger(args[i+1]) {\n\t\t\tnormalized = append(normalized, \"--psize=\"+args[i+1])\n\t\t\ti++\n\t\t\tcontinue\n\t\t}\n\t\tnormalized = append(normalized, cur)\n\t}\n\treturn normalized\n}\n\nfunc isNegativeInteger(s string) bool {\n\tif !strings.HasPrefix(s, \"-\") || len(s) < 2 {\n\t\treturn false\n\t}\n\tv, err := strconv.Atoi(s)\n\treturn err == nil && v < 0\n}\n\nfunc guessLocalIPv4() string {\n\taddrs, err := net.InterfaceAddrs()\n\tif err == nil {\n\t\tfor _, address := range addrs {\n\t\t\tif ipNet, ok := address.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {\n\t\t\t\tif ip4 := ipNet.IP.To4(); ip4 != nil {\n\t\t\t\t\treturn ip4.String()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn \"127.0.0.1\"\n}\n\nfunc defaultLocalListenAddr() string {\n\tif hasIPv4Loopback() {\n\t\treturn \"127.0.0.1:1080\"\n\t}\n\tif hasIPv6Loopback() {\n\t\treturn \"[::1]:1080\"\n\t}\n\treturn \"127.0.0.1:1080\"\n}\n\nfunc hasIPv4Loopback() bool {\n\taddrs, err := net.InterfaceAddrs()\n\tif err != nil {\n\t\treturn false\n\t}\n\tfor _, address := range addrs {\n\t\tif ipNet, ok := address.(*net.IPNet); ok && ipNet.IP.IsLoopback() {\n\t\t\tif ip4 := ipNet.IP.To4(); ip4 != nil {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasIPv6Loopback() bool {\n\taddrs, err := net.InterfaceAddrs()\n\tif err != nil {\n\t\treturn false\n\t}\n\tfor _, address := range addrs {\n\t\tif ipNet, ok := address.(*net.IPNet); ok && ipNet.IP.IsLoopback() {\n\t\t\tif ip := ipNet.IP; ip.To4() == nil && len(ip) == net.IPv6len {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// sanitizeUsagePositionalArgs replaces the auto-generated positional argument\n// name (e.g. \"_positionalArg_nexttrace_33\") with a friendlier label in the\n// usage string produced by argparse.\nfunc sanitizeUsagePositionalArgs(usage string) string {\n\t// argparse generates names like \"_positionalArg_nexttrace_<N>\"\n\t// We scan for the prefix and replace the whole token with \"TARGET\".\n\tconst prefix = \"_positionalArg_\"\n\tfor {\n\t\tidx := strings.Index(usage, prefix)\n\t\tif idx < 0 {\n\t\t\tbreak\n\t\t}\n\t\t// Find the end of the token (next space, newline, or end of string).\n\t\tend := idx + len(prefix)\n\t\tfor end < len(usage) && usage[end] != ' ' && usage[end] != '\\n' && usage[end] != '\\r' && usage[end] != '\\t' && usage[end] != ']' {\n\t\t\tend++\n\t\t}\n\t\tusage = usage[:idx] + \"TARGET\" + usage[end:]\n\t}\n\t// argparse renders the positional as \"--TARGET\" in the description list;\n\t// strip the leading \"--\" so it reads as a plain positional placeholder.\n\tusage = strings.ReplaceAll(usage, \"--TARGET\", \"TARGET\")\n\t// Fix the description column alignment for the TARGET entry.\n\t// argparse gives positional args minimal spacing (\"      TARGET  desc\"), but named\n\t// flags are padded to a consistent description column (\"      --name              desc\").\n\t// Detect that column from any named-flag line and re-pad the TARGET line to match.\n\tusage = fixPositionalAlignment(usage)\n\treturn usage\n}\n\n// fixPositionalAlignment detects the description column used by named flags in the\n// argparse help output and re-pads the TARGET positional entry to match it.\nfunc fixPositionalAlignment(usage string) string {\n\t// Scan flag lines to find where descriptions start.\n\t// A flag line looks like \"  -X  --name<spaces>Description\" or \"      --name<spaces>Description\".\n\t// We find the column of the first non-space character after the flag name (past position 8).\n\tdescCol := 0\n\tfor _, line := range strings.Split(usage, \"\\n\") {\n\t\ttrimmed := strings.TrimLeft(line, \" \")\n\t\tif !strings.HasPrefix(trimmed, \"-\") || strings.Contains(line, \"TARGET\") {\n\t\t\tcontinue\n\t\t}\n\t\tinGap := false\n\t\tfor i := 8; i < len(line); i++ {\n\t\t\tif line[i] == ' ' {\n\t\t\t\tinGap = true\n\t\t\t} else if inGap {\n\t\t\t\tdescCol = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif descCol > 0 {\n\t\t\tbreak\n\t\t}\n\t}\n\tif descCol == 0 {\n\t\treturn usage\n\t}\n\t// Find the TARGET description entry: \"\\n      TARGET  <description>\"\n\tconst namePrefix = \"      TARGET\"\n\tmarker := \"\\n\" + namePrefix\n\tidx := strings.Index(usage, marker)\n\tif idx < 0 {\n\t\treturn usage\n\t}\n\t// afterName points to the character right after \"      TARGET\" on that line.\n\tafterName := idx + 1 + len(namePrefix)\n\t// Skip the existing (minimal) spacing.\n\tend := afterName\n\tfor end < len(usage) && usage[end] == ' ' {\n\t\tend++\n\t}\n\tneeded := descCol - len(namePrefix)\n\tif needed <= 0 {\n\t\treturn usage\n\t}\n\treturn usage[:afterName] + strings.Repeat(\" \", needed) + usage[end:]\n}\n\ntype effectiveMTRModes struct {\n\tmtr    bool\n\treport bool\n\twide   bool\n\traw    bool\n}\n\ntype tracerouteOutputFlags struct {\n\troutePath     *bool\n\toutputPath    *string\n\toutputDefault *bool\n\ttablePrint    *bool\n\tjsonPrint     *bool\n\tclassicPrint  *bool\n}\n\ntype webUIFlags struct {\n\tdeployListen *string\n\tdeploy       *bool\n}\n\ntype mtrCLIFlags struct {\n\tmtrMode    *bool\n\treportMode *bool\n\twideMode   *bool\n\tshowIPs    *bool\n\tipInfoMode *int\n}\n\nfunc registerInitFlag(parser *argparse.Parser) *bool {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn parser.Flag(\"\", \"init\", &argparse.Options{Help: \"Extract WinDivert runtime to current directory\"})\n\t}\n\treturn ptrBool(false)\n}\n\nfunc registerFastTraceFlag(parser *argparse.Parser) *bool {\n\tif !defaultMTR {\n\t\treturn parser.Flag(\"F\", \"fast-trace\", &argparse.Options{Help: \"One-Key Fast Trace to China ISPs\"})\n\t}\n\treturn ptrBool(false)\n}\n\nfunc registerMTUFlag(parser *argparse.Parser) *bool {\n\tif enableMTU {\n\t\treturn parser.Flag(\"\", \"mtu\", &argparse.Options{Help: \"Run standalone UDP path-MTU discovery mode with streaming output and GeoIP/RDNS\"})\n\t}\n\treturn ptrBool(false)\n}\n\nfunc registerICMPModeFlag(parser *argparse.Parser) *int {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn parser.Int(\"\", \"icmp-mode\", &argparse.Options{Help: \"Choose the method to listen for ICMP packets (1=Socket, 2=WinDivert; 0=Auto)\"})\n\t}\n\treturn ptrInt(0)\n}\n\nfunc buildQueriesHelp() string {\n\tif defaultMTR {\n\t\treturn \"MTR only: max probes per hop. 0 = unlimited in TUI/raw; --report defaults to 10 when omitted. Start with 10-20 on unstable paths\"\n\t}\n\treturn \"Latency samples per hop. Increase to 5-10 on unstable paths for a steadier view\"\n}\n\nfunc buildMaxAttemptsHelp() string {\n\treturn \"Advanced: hard cap on probe packets per hop. Leave unset for auto sizing; raise on lossy links if --queries is not enough\"\n}\n\nfunc buildParallelRequestsHelp() string {\n\treturn \"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\"\n}\n\nfunc buildPacketIntervalHelp() string {\n\thelp := \"Advanced: per-packet gap [ms] inside the same TTL group. Lower is faster; raise to 100-200ms on rate-limited links\"\n\tif enableMTR {\n\t\thelp += \". Ignored in MTR mode\"\n\t}\n\treturn help\n}\n\nfunc buildTimeoutHelp() string {\n\treturn \"Per-probe timeout [ms]. Raise to 2000-3000 on slow intercontinental or high-loss paths\"\n}\n\nfunc buildPayloadSizeHelp() string {\n\treturn \"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)\"\n}\n\nfunc buildTOSHelp() string {\n\treturn \"Set the IP type-of-service / traffic class value [0-255]\"\n}\n\nfunc registerTracerouteOutputFlags(parser *argparse.Parser) tracerouteOutputFlags {\n\tif !defaultMTR {\n\t\treturn tracerouteOutputFlags{\n\t\t\troutePath:     parser.Flag(\"P\", \"route-path\", &argparse.Options{Help: \"Print traceroute hop path by ASN and location\"}),\n\t\t\toutputPath:    parser.String(\"o\", \"output\", &argparse.Options{Help: \"Write trace result to FILE (RealtimePrinter only)\"}),\n\t\t\toutputDefault: parser.Flag(\"O\", \"output-default\", &argparse.Options{Help: \"Write trace result to the default log file (/tmp/trace.log)\"}),\n\t\t\ttablePrint:    parser.Flag(\"\", \"table\", &argparse.Options{Help: \"Output trace results as a final summary table (traceroute report mode)\"}),\n\t\t\tjsonPrint:     parser.Flag(\"j\", \"json\", &argparse.Options{Help: \"Output trace results as JSON\"}),\n\t\t\tclassicPrint:  parser.Flag(\"c\", \"classic\", &argparse.Options{Help: \"Classic Output trace results like BestTrace\"}),\n\t\t}\n\t}\n\treturn tracerouteOutputFlags{\n\t\troutePath:     ptrBool(false),\n\t\toutputPath:    ptrStr(\"\"),\n\t\toutputDefault: ptrBool(false),\n\t\ttablePrint:    ptrBool(false),\n\t\tjsonPrint:     ptrBool(false),\n\t\tclassicPrint:  ptrBool(false),\n\t}\n}\n\nfunc registerWebUIFlags(parser *argparse.Parser) webUIFlags {\n\treturn registerWebUIFlagsWithAvailability(parser, enableWebUI)\n}\n\nfunc registerWebUIFlagsWithAvailability(parser *argparse.Parser, enabled bool) webUIFlags {\n\tif enabled {\n\t\treturn webUIFlags{\n\t\t\tdeployListen: parser.String(\"\", \"listen\", &argparse.Options{Help: \"Set listen address for web console (e.g. 127.0.0.1:30080)\"}),\n\t\t\tdeploy:       parser.Flag(\"\", \"deploy\", &argparse.Options{Help: \"Start the Gin powered web console\"}),\n\t\t}\n\t}\n\treturn webUIFlags{\n\t\tdeployListen: parser.String(\"\", \"listen\", &argparse.Options{Help: \"Set listen address for web console (full build only; unavailable in this binary)\"}),\n\t\tdeploy:       parser.Flag(\"\", \"deploy\", &argparse.Options{Help: \"Start the Gin powered web console (full build only; unavailable in this binary)\"}),\n\t}\n}\n\nfunc registerPacketIntervalFlag(parser *argparse.Parser) *int {\n\tif !defaultMTR {\n\t\treturn parser.Int(\"z\", \"send-time\", &argparse.Options{Default: defaultPacketIntervalMs, Help: buildPacketIntervalHelp()})\n\t}\n\treturn ptrInt(defaultPacketIntervalMs)\n}\n\nfunc buildRawHelp() string {\n\trawHelp := \"Machine-friendly output\"\n\tif enableMTR {\n\t\tmtrFlags := \"--mtr/-r/-w\"\n\t\tif defaultMTR {\n\t\t\tmtrFlags = \"-r/-w\"\n\t\t}\n\t\trawHelp += \". With MTR (\" + mtrFlags + \"), enables streaming raw event mode\"\n\t}\n\treturn rawHelp\n}\n\nfunc buildTTLIntervalHelp() string {\n\tif !enableMTR {\n\t\treturn \"Advanced: TTL-group interval [ms] in normal traceroute. 100-300ms is usually safe; lower is faster but may trigger rate limits\"\n\t}\n\tif defaultMTR {\n\t\treturn \"Advanced: per-hop probe interval [ms] in MTR mode. 500-1000ms is a good starting point; omitted defaults to 1000ms\"\n\t}\n\treturn \"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\"\n}\n\nfunc registerTTLIntervalFlag(parser *argparse.Parser) *int {\n\treturn registerTTLIntervalFlagWithMTRSupport(parser, enableMTR)\n}\n\nfunc registerTTLIntervalFlagWithMTRSupport(parser *argparse.Parser, mtrEnabled bool) *int {\n\toptions := &argparse.Options{Help: buildTTLIntervalHelp()}\n\tif !mtrEnabled {\n\t\toptions.Default = defaultTracerouteTTLIntervalMs\n\t}\n\treturn parser.Int(\"i\", \"ttl-time\", options)\n}\n\nfunc applyTTLIntervalDefault(ttlInterval *int, ttlTimeExplicit, effectiveMTR bool) {\n\tif ttlInterval == nil || ttlTimeExplicit || effectiveMTR {\n\t\treturn\n\t}\n\t*ttlInterval = defaultTracerouteTTLIntervalMs\n}\n\nfunc registerDisableMaptraceFlag(parser *argparse.Parser) *bool {\n\tif !defaultMTR {\n\t\treturn parser.Flag(\"M\", \"map\", &argparse.Options{Help: \"Disable Print Trace Map\"})\n\t}\n\treturn ptrBool(true)\n}\n\nfunc registerGlobalpingFlag(parser *argparse.Parser) *string {\n\treturn registerGlobalpingFlagWithAvailability(parser, enableGlobalping)\n}\n\nfunc registerGlobalpingFlagWithAvailability(parser *argparse.Parser, enabled bool) *string {\n\tif enabled {\n\t\treturn 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.\"})\n\t}\n\treturn parser.String(\"\", \"from\", &argparse.Options{Help: \"Run traceroute via Globalping (full build only; unavailable in this binary)\"})\n}\n\nfunc registerMTRFlags(parser *argparse.Parser) mtrCLIFlags {\n\tif enableMTR {\n\t\tmtrMode := ptrBool(true)\n\t\tif !defaultMTR {\n\t\t\tmtrMode = parser.Flag(\"t\", \"mtr\", &argparse.Options{Help: \"Enable MTR (My Traceroute) continuous probing mode\"})\n\t\t}\n\t\treturn mtrCLIFlags{\n\t\t\tmtrMode:    mtrMode,\n\t\t\treportMode: parser.Flag(\"r\", \"report\", &argparse.Options{Help: \"MTR report mode (non-interactive, implies --mtr); can trigger MTR without --mtr\"}),\n\t\t\twideMode:   parser.Flag(\"w\", \"wide\", &argparse.Options{Help: \"MTR wide report mode (implies --mtr --report); alone equals --mtr --report --wide\"}),\n\t\t\tshowIPs:    parser.Flag(\"\", \"show-ips\", &argparse.Options{Help: \"MTR only: display both PTR hostnames and numeric IPs (PTR first, IP in parentheses)\"}),\n\t\t\tipInfoMode: 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\"}),\n\t\t}\n\t}\n\treturn mtrCLIFlags{\n\t\tmtrMode:    ptrBool(false),\n\t\treportMode: ptrBool(false),\n\t\twideMode:   ptrBool(false),\n\t\tshowIPs:    ptrBool(false),\n\t\tipInfoMode: ptrInt(0),\n\t}\n}\n\nfunc registerFileFlag(parser *argparse.Parser) *string {\n\tif !defaultMTR {\n\t\treturn parser.String(\"\", \"file\", &argparse.Options{Help: \"Read IP Address or domain name from file\"})\n\t}\n\treturn ptrStr(\"\")\n}\n\nfunc deriveEffectiveMTRModes(mtrMode, reportMode, wideMode, rawPrint bool) effectiveMTRModes {\n\tmtr := mtrMode || reportMode || wideMode\n\treturn effectiveMTRModes{\n\t\tmtr:    mtr,\n\t\treport: reportMode || wideMode,\n\t\twide:   wideMode,\n\t\traw:    mtr && rawPrint,\n\t}\n}\n\nfunc detectExplicitProbeFlags(parser *argparse.Parser) (queriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit bool) {\n\tfor _, a := range parser.GetArgs() {\n\t\tif !a.GetParsed() {\n\t\t\tcontinue\n\t\t}\n\t\tswitch a.GetLname() {\n\t\tcase \"queries\":\n\t\t\tqueriesExplicit = true\n\t\tcase \"ttl-time\":\n\t\t\tttlTimeExplicit = true\n\t\tcase \"psize\":\n\t\t\tpacketSizeExplicit = true\n\t\tcase \"tos\":\n\t\t\ttosExplicit = true\n\t\t}\n\t}\n\treturn queriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit\n}\n\nfunc resolvePacketSizeArg(packetSize int, explicit bool, method trace.Method, dstIP net.IP) int {\n\tif explicit {\n\t\treturn packetSize\n\t}\n\treturn trace.DefaultPacketSize(method, dstIP)\n}\n\nfunc applyColorMode(noColor bool) {\n\tcolor.NoColor = noColor\n}\n\nfunc shouldForceNoColorForMTUNonTTY(mtuMode, jsonPrint, stdoutIsTTY bool) bool {\n\treturn mtuMode && !jsonPrint && !stdoutIsTTY\n}\n\nfunc printStartupBanner(jsonPrint bool, effectiveMTR bool) {\n\tif !jsonPrint && !effectiveMTR {\n\t\tprinter.Version()\n\t}\n}\n\nfunc maybePrintVersion(ver bool) bool {\n\tif !ver {\n\t\treturn false\n\t}\n\tprinter.CopyRight()\n\tos.Exit(0)\n\treturn true\n}\n\nfunc maybeRunDeployMode(deploy bool, deployListen string) bool {\n\tif !deploy {\n\t\treturn false\n\t}\n\tif !enableWebUI {\n\t\tif err := runDeploy(\"\"); err != nil {\n\t\t\tif util.EnvDevMode {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\treturn true\n\t}\n\n\tcapabilitiesCheck()\n\tlistenAddr := strings.TrimSpace(deployListen)\n\tenvAddr := strings.TrimSpace(util.EnvDeployAddr)\n\tuserProvided := listenAddr != \"\" || envAddr != \"\"\n\tif listenAddr == \"\" {\n\t\tlistenAddr = envAddr\n\t}\n\tif listenAddr == \"\" {\n\t\tlistenAddr = defaultLocalListenAddr()\n\t}\n\n\tinfo := buildListenInfo(listenAddr)\n\tfmt.Printf(\"启动 NextTrace Web 控制台，监听地址: %s\\n\", info.Binding)\n\tif !userProvided {\n\t\tfmt.Println(\"远程访问请显式设置 --listen（例如 --listen 0.0.0.0:1080）。\")\n\t}\n\tif info.Access != \"\" && info.Access != info.Binding {\n\t\tfmt.Printf(\"如需远程访问，请尝试: %s\\n\", info.Access)\n\t}\n\tfmt.Println(\"注意：Web 控制台的安全性有限，请在确保安全的前提下使用，如有必要请使用ACL等方式加强安全性\")\n\tif err := runDeploy(listenAddr); err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(err)\n\t\t}\n\t\tlog.Fatal(err)\n\t}\n\treturn true\n}\n\nfunc handleStartupModes(noColor, jsonPrint bool, modes effectiveMTRModes, ver, deploy bool, deployListen string, init bool, osType int) bool {\n\tapplyColorMode(noColor)\n\tprintStartupBanner(jsonPrint, modes.mtr)\n\tif maybePrintVersion(ver) {\n\t\treturn true\n\t}\n\tif maybeRunDeployMode(deploy, deployListen) {\n\t\treturn true\n\t}\n\treturn maybePrepareWinDivert(init, osType)\n}\n\nfunc resolveOSType() int {\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\treturn 1\n\tcase \"windows\":\n\t\treturn 2\n\tdefault:\n\t\treturn 3\n\t}\n}\n\nfunc maybePrepareWinDivert(init bool, osType int) bool {\n\tif !init || osType != 2 {\n\t\treturn false\n\t}\n\tif err := windivert.PrepareWinDivertRuntime(); err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(err)\n\t\t}\n\t\tlog.Fatal(err)\n\t}\n\tfmt.Println(\"WinDivert runtime is ready.\")\n\treturn true\n}\n\nfunc applyDefaultPort(port *int, udp bool) {\n\tif *port != 0 {\n\t\treturn\n\t}\n\tif udp {\n\t\t*port = 33494\n\t\treturn\n\t}\n\t*port = 80\n}\n\nfunc clampProbeSettings(tcp bool, numMeasurements, maxAttempts *int) {\n\tif tcp {\n\t\treturn\n\t}\n\tif *numMeasurements > 255 {\n\t\tfmt.Println(\"Query 最大值为 255，已自动调整为 255\")\n\t\t*numMeasurements = 255\n\t}\n\tif *maxAttempts > 255 {\n\t\tfmt.Println(\"MaxAttempt 最大值为 255，已自动调整为 255\")\n\t\t*maxAttempts = 255\n\t}\n}\n\nfunc resolveTraceMethod(tcp, udp bool) trace.Method {\n\tswitch {\n\tcase tcp:\n\t\treturn trace.TCPTrace\n\tcase udp:\n\t\treturn trace.UDPTrace\n\tdefault:\n\t\treturn trace.ICMPTrace\n\t}\n}\n\nfunc maybeRunFastTraceMode(from string, fastTraceFlag bool, file string, params fastTrace.ParamsFastTrace, method trace.Method) bool {\n\tif from != \"\" || (!fastTraceFlag && file == \"\") {\n\t\treturn false\n\t}\n\tfastTrace.FastTest(method, params)\n\tif params.OutputPath != \"\" {\n\t\tfmt.Printf(\"您的追踪日志已经存放在 %s 中\\n\", params.OutputPath)\n\t}\n\tos.Exit(0)\n\treturn true\n}\n\nfunc configureGeoDNS(dot string) {\n\tif dot != \"\" {\n\t\tutil.SetGeoDNSResolver(dot)\n\t}\n}\n\nfunc normalizeCLITarget(raw string) string {\n\tdomain := raw\n\tif strings.Contains(domain, \"/\") {\n\t\tdomain = \"n\" + domain\n\t\tparts := strings.Split(domain, \"/\")\n\t\tif len(parts) < 3 {\n\t\t\treturn \"\"\n\t\t}\n\t\tdomain = parts[2]\n\t}\n\tif strings.Contains(domain, \"]\") && strings.Contains(domain, \"[\") {\n\t\tinner := strings.SplitN(domain, \"]\", 2)[0]\n\t\tparts := strings.SplitN(inner, \"[\", 2)\n\t\tif len(parts) >= 2 {\n\t\t\treturn parts[1]\n\t\t}\n\t\treturn domain\n\t}\n\tif strings.Contains(domain, \":\") && strings.Count(domain, \":\") == 1 {\n\t\treturn strings.Split(domain, \":\")[0]\n\t}\n\treturn domain\n}\n\nfunc resolveCLITargetOrExit(raw string, usage string) string {\n\tif raw == \"\" {\n\t\tfmt.Print(usage)\n\t\treturn \"\"\n\t}\n\tdomain := normalizeCLITarget(raw)\n\tif domain == \"\" {\n\t\tif strings.Contains(raw, \"/\") {\n\t\t\tfmt.Println(\"Invalid input\")\n\t\t} else {\n\t\t\tfmt.Print(usage)\n\t\t}\n\t}\n\treturn domain\n}\n\nfunc applyDN42Mode(enabled bool, dataOrigin *string, disableMaptrace *bool) {\n\tif !enabled {\n\t\treturn\n\t}\n\tconfig.InitConfig()\n\t*dataOrigin = \"DN42\"\n\t*disableMaptrace = true\n}\n\nfunc prepareRuntimeEnvironment(ctx context.Context, dn42 bool, dataOrigin *string, disableMaptrace *bool, powProvider *string) *wshandle.WsConn {\n\tcapabilitiesCheck()\n\tapplyDN42Mode(dn42, dataOrigin, disableMaptrace)\n\treturn initLeoWebsocket(ctx, dataOrigin, powProvider)\n}\n\nfunc initLeoWebsocket(ctx context.Context, dataOrigin, powProvider *string) *wshandle.WsConn {\n\tif !strings.EqualFold(*dataOrigin, \"LEOMOEAPI\") {\n\t\treturn nil\n\t}\n\tif !strings.EqualFold(*powProvider, \"api.nxtrace.org\") {\n\t\tutil.PowProviderParam = *powProvider\n\t}\n\tif util.EnvDataProvider != \"\" {\n\t\t*dataOrigin = util.EnvDataProvider\n\t}\n\tif !strings.EqualFold(*dataOrigin, \"LEOMOEAPI\") {\n\t\treturn nil\n\t}\n\n\tleoWs := wshandle.NewWithContext(ctx)\n\tif leoWs != nil {\n\t\tleoWs.Interrupt = make(chan os.Signal, 1)\n\t\tsignal.Notify(leoWs.Interrupt, os.Interrupt)\n\t}\n\treturn leoWs\n}\n\nfunc closeLeoWebsocket(leoWs *wshandle.WsConn) {\n\tif leoWs != nil {\n\t\tleoWs.Close()\n\t}\n}\n\nfunc maybeHandleGlobalping(from string, opts *trace.GlobalpingOptions, conf *trace.Config) bool {\n\tif from == \"\" {\n\t\treturn false\n\t}\n\thandleGlobalpingTrace(opts, conf)\n\treturn true\n}\n\nfunc lookupTargetIP(ctx context.Context, domain string, ipv4Only, ipv6Only bool, dot string, jsonPrint bool) (net.IP, error) {\n\tswitch {\n\tcase ipv6Only:\n\t\treturn domainLookupFn(ctx, domain, \"6\", dot, jsonPrint)\n\tcase ipv4Only:\n\t\treturn domainLookupFn(ctx, domain, \"4\", dot, jsonPrint)\n\tdefault:\n\t\treturn domainLookupFn(ctx, domain, \"all\", dot, jsonPrint)\n\t}\n}\n\nfunc lookupTargetIPOrExit(ctx context.Context, domain string, ipv4Only, ipv6Only bool, dot string, jsonPrint bool) net.IP {\n\tip, err := lookupTargetIP(ctx, domain, ipv4Only, ipv6Only, dot, jsonPrint)\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(err)\n\t\t}\n\t\tlog.Fatal(err)\n\t}\n\treturn ip\n}\n\nfunc resolveSourceDevice(srcDev string) (*net.Interface, error) {\n\ttrimmed := strings.TrimSpace(srcDev)\n\tif trimmed == \"\" {\n\t\treturn nil, nil\n\t}\n\tdev, err := net.InterfaceByName(trimmed)\n\tif err != nil || dev == nil {\n\t\treturn nil, fmt.Errorf(\"无法找到网卡 %q: %v\", trimmed, err)\n\t}\n\treturn dev, nil\n}\n\nfunc resolveSourceDeviceAddr(dev *net.Interface, dstIP net.IP) string {\n\tif dev == nil || dstIP == nil {\n\t\treturn \"\"\n\t}\n\taddrs, err := dev.Addrs()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tvar candidate string\n\tfor _, addr := range addrs {\n\t\tipNet, ok := addr.(*net.IPNet)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif (ipNet.IP.To4() == nil) != (dstIP.To4() == nil) {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate = ipNet.IP.String()\n\t\tparsed := net.ParseIP(candidate)\n\t\tif parsed != nil && !(parsed.IsPrivate() ||\n\t\t\tparsed.IsLoopback() ||\n\t\t\tparsed.IsLinkLocalUnicast() ||\n\t\t\tparsed.IsLinkLocalMulticast()) {\n\t\t\treturn candidate\n\t\t}\n\t}\n\treturn candidate\n}\n\nfunc resolveFallbackSrcAddr(dstIP net.IP) string {\n\tif dstIP == nil {\n\t\treturn \"\"\n\t}\n\tif util.IsIPv6(dstIP) {\n\t\tresolved, _ := util.LocalIPPortv6(dstIP, nil, \"udp6\")\n\t\tif resolved != nil {\n\t\t\treturn resolved.String()\n\t\t}\n\t\treturn \"\"\n\t}\n\tresolved, _ := util.LocalIPPort(dstIP, nil, \"udp\")\n\tif resolved != nil {\n\t\treturn resolved.String()\n\t}\n\treturn \"\"\n}\n\nfunc resolveConfiguredSrcAddr(dstIP net.IP, srcAddr, srcDev string) (resolved string, explicit bool, err error) {\n\tif trimmed := strings.TrimSpace(srcAddr); trimmed != \"\" {\n\t\treturn trimmed, true, nil\n\t}\n\tdev, err := resolveSourceDevice(srcDev)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif resolved := resolveSourceDeviceAddr(dev, dstIP); resolved != \"\" {\n\t\treturn resolved, false, nil\n\t}\n\treturn resolveFallbackSrcAddr(dstIP), false, nil\n}\n\nfunc applySourceDevice(srcDev string, dstIP net.IP, srcAddr *string) {\n\tdev, err := resolveSourceDevice(srcDev)\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n\tif dev == nil {\n\t\treturn\n\t}\n\tutil.SrcDev = dev.Name\n\tif srcAddr == nil || strings.TrimSpace(*srcAddr) != \"\" {\n\t\treturn\n\t}\n\tif resolved := resolveSourceDeviceAddr(dev, dstIP); resolved != \"\" {\n\t\t*srcAddr = resolved\n\t}\n}\n\nfunc printTraceNav(jsonPrint bool, effectiveMTR bool, ip net.IP, domain, dataOrigin string, maxHops, packetSize int, srcAddr string, method trace.Method) {\n\tif !jsonPrint && !effectiveMTR {\n\t\tprinter.PrintTraceRouteNav(ip, domain, dataOrigin, maxHops, packetSize, srcAddr, string(method))\n\t}\n}\n\nfunc buildTraceConfig(\n\tosType, icmpMode int,\n\tdn42 bool,\n\tsrcAddr string,\n\tsourceDevice string,\n\tsrcPort int,\n\tbeginHop int,\n\tip net.IP,\n\tport int,\n\tmaxHops int,\n\tpacketInterval int,\n\tttlInterval int,\n\tnumMeasurements int,\n\tmaxAttempts int,\n\tparallelRequests int,\n\tlang string,\n\tnoRDNS bool,\n\talwaysRDNS bool,\n\tdataOrigin string,\n\ttimeout int,\n\tpacketSize int,\n\trandomPacketSize bool,\n\ttos int,\n\tdisableMPLS bool,\n) trace.Config {\n\treturn trace.Config{\n\t\tOSType:           osType,\n\t\tICMPMode:         icmpMode,\n\t\tDN42:             dn42,\n\t\tSrcAddr:          srcAddr,\n\t\tSrcPort:          srcPort,\n\t\tSourceDevice:     strings.TrimSpace(sourceDevice),\n\t\tBeginHop:         beginHop,\n\t\tDstIP:            ip,\n\t\tDstPort:          port,\n\t\tMaxHops:          maxHops,\n\t\tPacketInterval:   packetInterval,\n\t\tTTLInterval:      ttlInterval,\n\t\tNumMeasurements:  numMeasurements,\n\t\tMaxAttempts:      maxAttempts,\n\t\tParallelRequests: parallelRequests,\n\t\tLang:             lang,\n\t\tRDNS:             !noRDNS,\n\t\tAlwaysWaitRDNS:   alwaysRDNS,\n\t\tIPGeoSource:      ipgeo.GetSource(dataOrigin),\n\t\tTimeout:          time.Duration(timeout) * time.Millisecond,\n\t\tPktSize:          packetSize,\n\t\tRandomPacketSize: randomPacketSize,\n\t\tTOS:              tos,\n\t\tDisableMPLS:      disableMPLS,\n\t}\n}\n\nfunc maybeRunMTRMode(\n\tmodes effectiveMTRModes,\n\tmethod trace.Method,\n\tconf trace.Config,\n\tqueriesExplicit bool,\n\tnumMeasurements int,\n\tttlTimeExplicit bool,\n\tttlInterval int,\n\tdomain string,\n\tdataOrigin string,\n\tshowIPs bool,\n\tipInfoMode int,\n) bool {\n\tif !modes.mtr {\n\t\treturn false\n\t}\n\tmtrMaxPerHop, mtrHopIntervalMs := deriveMTRProbeParams(\n\t\tmodes.report,\n\t\tqueriesExplicit,\n\t\tnumMeasurements,\n\t\tttlTimeExplicit,\n\t\tttlInterval,\n\t)\n\n\tswitch chooseMTRRunMode(modes.raw, modes.report) {\n\tcase mtrRunRaw:\n\t\trunMTRRaw(method, conf, mtrHopIntervalMs, mtrMaxPerHop, dataOrigin)\n\tcase mtrRunReport:\n\t\trunMTRReport(method, conf, mtrHopIntervalMs, mtrMaxPerHop, domain, dataOrigin, modes.wide, showIPs)\n\tdefault:\n\t\tif ipInfoMode < 0 || ipInfoMode > 4 {\n\t\t\tfmt.Fprintf(os.Stderr, \"--ipinfo/-y 必须在 0-4 范围内，当前值: %d\\n\", ipInfoMode)\n\t\t\tos.Exit(1)\n\t\t}\n\t\trunMTRTUI(method, conf, mtrHopIntervalMs, mtrMaxPerHop, domain, dataOrigin, showIPs, ipInfoMode)\n\t}\n\treturn true\n}\n\nfunc resolveOutputPath(outputPath string, outputDefault bool) (string, error) {\n\ttrimmed := strings.TrimSpace(outputPath)\n\tif trimmed != \"\" && outputDefault {\n\t\treturn \"\", errors.New(\"--output 与 --output-default 不能同时使用\")\n\t}\n\tif trimmed != \"\" {\n\t\treturn trimmed, nil\n\t}\n\tif outputDefault {\n\t\treturn tracelog.DefaultPath, nil\n\t}\n\treturn \"\", nil\n}\n\nfunc validateJSONRealtimeOutput(jsonPrint bool, outputPath string) error {\n\tif jsonPrint && strings.TrimSpace(outputPath) != \"\" {\n\t\treturn errors.New(\"--json 不能与 --output/--output-default 同时使用\")\n\t}\n\treturn nil\n}\n\nfunc setFastIPOutputSuppression(suppress bool) func() {\n\tprev := util.SuppressFastIPOutput\n\tutil.SuppressFastIPOutput = suppress\n\treturn func() {\n\t\tutil.SuppressFastIPOutput = prev\n\t}\n}\n\nfunc configureTracePrinters(conf *trace.Config, tablePrint, classicPrint, rawPrint bool, outputPath string) (func() error, error) {\n\tif tablePrint {\n\t\treturn nil, nil\n\t}\n\trouter := false\n\tswitch {\n\tcase classicPrint:\n\t\tconf.RealtimePrinter = printer.ClassicPrinter\n\tcase rawPrint:\n\t\tconf.RealtimePrinter = printer.EasyPrinter\n\tcase outputPath != \"\":\n\t\tf, err := tracelog.OpenFile(outputPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tconf.RealtimePrinter = tracelog.NewRealtimePrinter(io.MultiWriter(os.Stdout, f))\n\t\treturn f.Close, nil\n\tcase router:\n\t\tconf.RealtimePrinter = printer.RealtimePrinterWithRouter\n\t\tfmt.Println(\"路由表数据源由 BGP.Tools 提供，在此特表感谢\")\n\tdefault:\n\t\tconf.RealtimePrinter = printer.RealtimePrinter\n\t}\n\treturn nil, nil\n}\n\nfunc applyJSONOutputMode(conf *trace.Config, jsonPrint bool) {\n\tif jsonPrint {\n\t\tconf.RealtimePrinter = nil\n\t\tconf.AsyncPrinter = nil\n\t}\n}\n\nfunc maybeRunUninterruptedRaw(rawPrint bool, method trace.Method, conf trace.Config) {\n\tif !(util.Uninterrupted && rawPrint) {\n\t\treturn\n\t}\n\tfor {\n\t\tif _, err := trace.Traceroute(method, conf); err != nil {\n\t\t\tfmt.Println(err)\n\t\t}\n\t}\n}\n\nfunc runTraceOnce(method trace.Method, conf trace.Config) (*trace.Result, bool) {\n\tres, err := trace.Traceroute(method, conf)\n\tif err != nil {\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tfmt.Println(err)\n\t\t}\n\t\treturn nil, false\n\t}\n\treturn res, true\n}\n\nfunc finalizeTraceResult(ctx context.Context, res *trace.Result, tablePrint, tableClearScreen, routePath bool, dstIP net.IP, disableMaptrace, jsonPrint bool, dataOrigin string) {\n\tif tablePrint {\n\t\tprinter.TracerouteTablePrinter(res, tableClearScreen)\n\t}\n\tif routePath {\n\t\treporter.New(res, dstIP.String()).Print()\n\t}\n\n\tr, err := json.Marshal(res)\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\tif !disableMaptrace &&\n\t\t(util.StringInSlice(strings.ToUpper(dataOrigin), []string{\"LEOMOEAPI\", \"IPINFO\", \"IP-API.COM\", \"IPAPI.COM\"})) {\n\t\turl, err := tracemap.GetMapUrlWithContext(ctx, string(r))\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\treturn\n\t\t}\n\t\tres.TraceMapUrl = url\n\t\tif !jsonPrint {\n\t\t\ttracemap.PrintMapUrl(url)\n\t\t}\n\t}\n\tr, err = json.Marshal(res)\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\tif jsonPrint {\n\t\tfmt.Println(string(r))\n\t}\n}\n\nfunc Execute() {\n\tparser := argparse.NewParser(appBinName, \"An open source visual route tracking CLI tool\")\n\t// Override HelpFunc so positional arg names are sanitized in --help output\n\tparser.HelpFunc = func(c *argparse.Command, msg interface{}) string {\n\t\treturn sanitizeUsagePositionalArgs(c.Usage(msg))\n\t}\n\tinit := registerInitFlag(parser)\n\tipv4Only := parser.Flag(\"4\", \"ipv4\", &argparse.Options{Help: \"Use IPv4 only\"})\n\tipv6Only := parser.Flag(\"6\", \"ipv6\", &argparse.Options{Help: \"Use IPv6 only\"})\n\ttcp := parser.Flag(\"T\", \"tcp\", &argparse.Options{Help: \"Use TCP SYN for tracerouting (default dest-port is 80)\"})\n\tudp := parser.Flag(\"U\", \"udp\", &argparse.Options{Help: \"Use UDP SYN for tracerouting (default dest-port is 33494)\"})\n\tmtuMode := registerMTUFlag(parser)\n\tfastTraceFlag := registerFastTraceFlag(parser)\n\tport := parser.Int(\"p\", \"port\", &argparse.Options{Help: \"Set the destination port to use. With default of 80 for \\\"tcp\\\", 33494 for \\\"udp\\\"\"})\n\ticmpMode := registerICMPModeFlag(parser)\n\tnumMeasurements := parser.Int(\"q\", \"queries\", &argparse.Options{Default: 3, Help: buildQueriesHelp()})\n\tmaxAttempts := parser.Int(\"\", \"max-attempts\", &argparse.Options{Help: buildMaxAttemptsHelp()})\n\tparallelRequests := parser.Int(\"\", \"parallel-requests\", &argparse.Options{Default: 18, Help: buildParallelRequestsHelp()})\n\tmaxHops := parser.Int(\"m\", \"max-hops\", &argparse.Options{Default: 30, Help: \"Set the max number of hops (max TTL to be reached)\"})\n\tdataOrigin := 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\",\n\t\tHelp: \"Choose IP Geograph Data Provider [IP.SB, IPInfo, IPInsight, IP-API.com, IPInfoLocal, CHUNZHEN, disable-geoip]\"})\n\tpowProvider := parser.Selector(\"\", \"pow-provider\", []string{\"api.nxtrace.org\", \"sakura\"}, &argparse.Options{Default: \"api.nxtrace.org\",\n\t\tHelp: \"Choose PoW Provider [api.nxtrace.org, sakura] For China mainland users, please use sakura\"})\n\tnorDNS := parser.Flag(\"n\", \"no-rdns\", &argparse.Options{Help: \"Do not resolve IP addresses to their domain names\"})\n\talwaysrDNS := parser.Flag(\"a\", \"always-rdns\", &argparse.Options{Help: \"Always resolve IP addresses to their domain names\"})\n\toutputFlags := registerTracerouteOutputFlags(parser)\n\troutePath := outputFlags.routePath\n\toutputPath := outputFlags.outputPath\n\toutputDefault := outputFlags.outputDefault\n\ttablePrint := outputFlags.tablePrint\n\tjsonPrint := outputFlags.jsonPrint\n\tclassicPrint := outputFlags.classicPrint\n\tdn42 := parser.Flag(\"\", \"dn42\", &argparse.Options{Help: \"DN42 Mode\"})\n\trawPrint := parser.Flag(\"\", \"raw\", &argparse.Options{Help: buildRawHelp()})\n\tbeginHop := parser.Int(\"f\", \"first\", &argparse.Options{Default: 1, Help: \"Start from the first_ttl hop (instead of 1)\"})\n\tdisableMaptrace := registerDisableMaptraceFlag(parser)\n\tdisableMPLS := parser.Flag(\"e\", \"disable-mpls\", &argparse.Options{Help: \"Disable MPLS\"})\n\tver := parser.Flag(\"V\", \"version\", &argparse.Options{Help: \"Print version info and exit\"})\n\tsrcAddr := parser.String(\"s\", \"source\", &argparse.Options{Help: \"Use source address src_addr for outgoing packets\"})\n\tsrcPort := parser.Int(\"\", \"source-port\", &argparse.Options{Help: \"Use source port src_port for outgoing packets\"})\n\tsrcDev := parser.String(\"D\", \"dev\", &argparse.Options{Help: \"Use the following Network Devices as the source address in outgoing packets\"})\n\n\twebFlags := registerWebUIFlags(parser)\n\tdeployListen := webFlags.deployListen\n\tdeploy := webFlags.deploy\n\n\t//router := parser.Flag(\"R\", \"route\", &argparse.Options{Help: \"Show Routing Table [Provided By BGP.Tools]\"})\n\t// ── Send-time: hidden in ntr (always ignored in MTR mode) ──\n\tpacketInterval := registerPacketIntervalFlag(parser)\n\tttlInterval := registerTTLIntervalFlag(parser)\n\ttimeout := parser.Int(\"\", \"timeout\", &argparse.Options{Default: 1000, Help: buildTimeoutHelp()})\n\tpacketSize := parser.Int(\"\", \"psize\", &argparse.Options{Help: buildPayloadSizeHelp()})\n\ttos := parser.Int(\"Q\", \"tos\", &argparse.Options{Default: 0, Help: buildTOSHelp()})\n\tdot := parser.Selector(\"\", \"dot-server\", []string{\"dnssb\", \"aliyun\", \"dnspod\", \"google\", \"cloudflare\"}, &argparse.Options{\n\t\tHelp: \"Use DoT Server for DNS Parse [dnssb, aliyun, dnspod, google, cloudflare]\"})\n\tlang := parser.Selector(\"g\", \"language\", []string{\"en\", \"cn\"}, &argparse.Options{Default: \"cn\",\n\t\tHelp: \"Choose the language for displaying [en, cn]\"})\n\tnoColor := parser.Flag(\"C\", \"no-color\", &argparse.Options{Help: \"Disable Colorful Output\"})\n\n\t// ── Globalping flag (full only) ──\n\tfrom := registerGlobalpingFlag(parser)\n\n\t// ── MTR flags (full & ntr only) ──\n\tmtrFlags := registerMTRFlags(parser)\n\tmtrMode := mtrFlags.mtrMode\n\treportMode := mtrFlags.reportMode\n\twideMode := mtrFlags.wideMode\n\tshowIPs := mtrFlags.showIPs\n\tipInfoMode := mtrFlags.ipInfoMode\n\n\t// ── File: hidden in ntr (conflicts with default MTR mode) ──\n\tfile := registerFileFlag(parser)\n\tstr := 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)\"})\n\n\terr := parser.Parse(normalizeNegativePacketSizeArgs(os.Args))\n\tif err != nil {\n\t\t// In case of error print error and print usage\n\t\t// This can also be done by passing -h or --help flags\n\t\tfmt.Print(sanitizeUsagePositionalArgs(parser.Usage(err)))\n\t\treturn\n\t}\n\trootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\tutil.SrcDev = \"\"\n\n\tmtrModes := deriveEffectiveMTRModes(*mtrMode, *reportMode, *wideMode, *rawPrint)\n\tresolvedOutputPath, outputErr := resolveOutputPath(*outputPath, *outputDefault)\n\tif outputErr != nil {\n\t\tfmt.Println(outputErr)\n\t\tos.Exit(1)\n\t}\n\tif err := validateJSONRealtimeOutput(*jsonPrint, resolvedOutputPath); err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n\tif *mtuMode {\n\t\tconflictFlags := buildMTUConflictFlags(\n\t\t\t*tcp,\n\t\t\t*rawPrint,\n\t\t\tmtrModes,\n\t\t\t*tablePrint,\n\t\t\t*classicPrint,\n\t\t\t*routePath,\n\t\t\t*outputPath != \"\",\n\t\t\t*outputDefault,\n\t\t\t*deploy,\n\t\t\tenableGlobalping,\n\t\t\t*from,\n\t\t\t*file,\n\t\t\t*fastTraceFlag,\n\t\t)\n\t\tif conflict, ok := checkMTUConflicts(conflictFlags); !ok {\n\t\t\tfmt.Printf(\"--mtu 不能与 %s 同时使用\\n\", conflict)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := normalizeMTUProtocolFlags(tcp, udp); err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\tif mtrModes.mtr {\n\t\tconflictFlags := map[string]bool{\n\t\t\t\"table\":         *tablePrint,\n\t\t\t\"classic\":       *classicPrint,\n\t\t\t\"json\":          *jsonPrint,\n\t\t\t\"output\":        *outputPath != \"\",\n\t\t\t\"outputDefault\": *outputDefault,\n\t\t\t\"routePath\":     *routePath,\n\t\t\t\"from\":          enableGlobalping && *from != \"\",\n\t\t\t\"fastTrace\":     *fastTraceFlag,\n\t\t\t\"file\":          *file != \"\",\n\t\t\t\"deploy\":        enableWebUI && *deploy,\n\t\t}\n\t\tif conflict, ok := checkMTRConflicts(conflictFlags); !ok {\n\t\t\tfmt.Printf(\"--mtr 不能与 %s 同时使用\\n\", conflict)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tqueriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit := detectExplicitProbeFlags(parser)\n\tapplyTTLIntervalDefault(ttlInterval, ttlTimeExplicit, mtrModes.mtr)\n\tosType := resolveOSType()\n\tstdoutIsTTY := CheckTTY(int(os.Stdout.Fd()))\n\tif shouldForceNoColorForMTUNonTTY(*mtuMode, *jsonPrint, stdoutIsTTY) {\n\t\t*noColor = true\n\t}\n\tif handleStartupModes(*noColor, *jsonPrint, mtrModes, *ver, *deploy, *deployListen, *init, osType) {\n\t\treturn\n\t}\n\trestoreFastIPOutput := setFastIPOutputSuppression(*jsonPrint || mtrModes.mtr)\n\tdefer restoreFastIPOutput()\n\n\tif *tos < 0 || *tos > 255 {\n\t\tfmt.Println(\"--tos 必须在 0-255 之间\")\n\t\tos.Exit(1)\n\t}\n\n\tapplyDefaultPort(port, *udp)\n\tclampProbeSettings(*tcp, numMeasurements, maxAttempts)\n\tconfigureGeoDNS(*dot)\n\n\tif *mtuMode {\n\t\tif packetSizeExplicit {\n\t\t\tfmt.Println(\"--mtu 不支持 --psize\")\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif tosExplicit {\n\t\t\tfmt.Println(\"--mtu 不支持 --tos\")\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif !checkRuntimePrivileges(true) {\n\t\t\tos.Exit(1)\n\t\t}\n\t\tdomain := resolveCLITargetOrExit(*str, sanitizeUsagePositionalArgs(parser.Usage(err)))\n\t\tif domain == \"\" {\n\t\t\treturn\n\t\t}\n\t\tip := lookupTargetIPOrExit(rootCtx, domain, *ipv4Only, *ipv6Only, *dot, *jsonPrint)\n\t\tresolvedSrcAddr, explicitSrc, srcResolveErr := resolveConfiguredSrcAddr(ip, *srcAddr, *srcDev)\n\t\tif srcResolveErr != nil {\n\t\t\tfmt.Println(srcResolveErr)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif !explicitSrc {\n\t\t\tapplySourceDevice(*srcDev, ip, srcAddr)\n\t\t}\n\t\tif strings.TrimSpace(*srcAddr) == \"\" {\n\t\t\t*srcAddr = resolvedSrcAddr\n\t\t}\n\t\tsrcIP, srcErr := resolveMTUSourceIP(ip, resolvedSrcAddr)\n\t\tif srcErr != nil {\n\t\t\tfmt.Println(srcErr)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tleoWs := prepareRuntimeEnvironment(rootCtx, *dn42, dataOrigin, disableMaptrace, powProvider)\n\t\tdefer closeLeoWebsocket(leoWs)\n\t\tconf := buildMTUTraceConfig(\n\t\t\tdomain,\n\t\t\tip,\n\t\t\tsrcIP,\n\t\t\t*srcDev,\n\t\t\t*srcPort,\n\t\t\t*port,\n\t\t\t*beginHop,\n\t\t\t*maxHops,\n\t\t\t*numMeasurements,\n\t\t\t*timeout,\n\t\t\t*ttlInterval,\n\t\t\t!*norDNS,\n\t\t\t*alwaysrDNS,\n\t\t\tipgeo.GetSource(*dataOrigin),\n\t\t\t*lang,\n\t\t)\n\t\tif err := runStandaloneMTUMode(conf, *jsonPrint); err != nil {\n\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\tfmt.Println(err)\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\n\tmethod := resolveTraceMethod(*tcp, *udp)\n\tparamsFastTrace := fastTrace.ParamsFastTrace{\n\t\tContext:        rootCtx,\n\t\tOSType:         osType,\n\t\tICMPMode:       *icmpMode,\n\t\tSrcDev:         *srcDev,\n\t\tSrcAddr:        *srcAddr,\n\t\tDstPort:        *port,\n\t\tBeginHop:       *beginHop,\n\t\tMaxHops:        *maxHops,\n\t\tMaxAttempts:    *maxAttempts,\n\t\tRDNS:           !*norDNS,\n\t\tAlwaysWaitRDNS: *alwaysrDNS,\n\t\tLang:           *lang,\n\t\tPktSize:        *packetSize,\n\t\tPacketSizeSet:  packetSizeExplicit,\n\t\tTOS:            *tos,\n\t\tTimeout:        time.Duration(*timeout) * time.Millisecond,\n\t\tFile:           *file,\n\t\tDot:            *dot,\n\t\tOutputPath:     resolvedOutputPath,\n\t}\n\tif maybeRunFastTraceMode(*from, *fastTraceFlag, *file, paramsFastTrace, method) {\n\t\treturn\n\t}\n\n\tdomain := resolveCLITargetOrExit(*str, sanitizeUsagePositionalArgs(parser.Usage(err)))\n\tif domain == \"\" {\n\t\treturn\n\t}\n\n\tleoWs := prepareRuntimeEnvironment(rootCtx, *dn42, dataOrigin, disableMaptrace, powProvider)\n\tdefer closeLeoWebsocket(leoWs)\n\n\tif *from != \"\" {\n\t\tif packetSizeExplicit {\n\t\t\tfmt.Println(\"Globalping 模式不支持 --psize\")\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif tosExplicit {\n\t\t\tfmt.Println(\"Globalping 模式不支持 --tos\")\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif maybeHandleGlobalping(\n\t\t*from,\n\t\t&trace.GlobalpingOptions{\n\t\t\tTarget:  *str,\n\t\t\tFrom:    *from,\n\t\t\tIPv4:    *ipv4Only,\n\t\t\tIPv6:    *ipv6Only,\n\t\t\tTCP:     *tcp,\n\t\t\tUDP:     *udp,\n\t\t\tPort:    *port,\n\t\t\tPackets: *numMeasurements,\n\t\t\tMaxHops: *maxHops,\n\n\t\t\tDisableMaptrace: *disableMaptrace,\n\t\t\tDataOrigin:      *dataOrigin,\n\n\t\t\tTablePrint:   *tablePrint,\n\t\t\tClassicPrint: *classicPrint,\n\t\t\tRawPrint:     *rawPrint,\n\t\t\tJSONPrint:    *jsonPrint,\n\t\t\tClearScreen:  stdoutIsTTY,\n\t\t},\n\t\t&trace.Config{\n\t\t\tContext:         rootCtx,\n\t\t\tOSType:          osType,\n\t\t\tDN42:            *dn42,\n\t\t\tNumMeasurements: *numMeasurements,\n\t\t\tLang:            *lang,\n\t\t\tRDNS:            !*norDNS,\n\t\t\tAlwaysWaitRDNS:  *alwaysrDNS,\n\t\t\tIPGeoSource:     ipgeo.GetSource(*dataOrigin),\n\t\t\tTimeout:         time.Duration(*timeout) * time.Millisecond,\n\t\t},\n\t) {\n\t\treturn\n\t}\n\n\tip := lookupTargetIPOrExit(rootCtx, domain, *ipv4Only, *ipv6Only, *dot, *jsonPrint)\n\n\tresolvedSrcAddr, explicitSrc, srcResolveErr := resolveConfiguredSrcAddr(ip, *srcAddr, *srcDev)\n\tif srcResolveErr != nil {\n\t\tfmt.Println(srcResolveErr)\n\t\tos.Exit(1)\n\t}\n\tif !explicitSrc {\n\t\tapplySourceDevice(*srcDev, ip, srcAddr)\n\t}\n\tif strings.TrimSpace(*srcAddr) == \"\" {\n\t\t*srcAddr = resolvedSrcAddr\n\t}\n\teffectivePacketSize := resolvePacketSizeArg(*packetSize, packetSizeExplicit, method, ip)\n\tprintTraceNav(*jsonPrint, mtrModes.mtr, ip, domain, *dataOrigin, *maxHops, effectivePacketSize, resolvedSrcAddr, method)\n\n\tpacketSizeSpec, packetSizeErr := trace.NormalizePacketSize(method, ip, effectivePacketSize)\n\tif packetSizeErr != nil {\n\t\tfmt.Println(packetSizeErr)\n\t\tos.Exit(1)\n\t}\n\n\tutil.SrcPort = *srcPort\n\tutil.DstIP = ip.String()\n\tconf := buildTraceConfig(\n\t\tosType,\n\t\t*icmpMode,\n\t\t*dn42,\n\t\tresolvedSrcAddr,\n\t\t*srcDev,\n\t\t*srcPort,\n\t\t*beginHop,\n\t\tip,\n\t\t*port,\n\t\t*maxHops,\n\t\t*packetInterval,\n\t\t*ttlInterval,\n\t\t*numMeasurements,\n\t\t*maxAttempts,\n\t\t*parallelRequests,\n\t\t*lang,\n\t\t*norDNS,\n\t\t*alwaysrDNS,\n\t\t*dataOrigin,\n\t\t*timeout,\n\t\tpacketSizeSpec.PayloadSize,\n\t\tpacketSizeSpec.Random,\n\t\t*tos,\n\t\t*disableMPLS,\n\t)\n\tconf.Context = rootCtx\n\n\tif maybeRunMTRMode(mtrModes, method, conf, queriesExplicit, *numMeasurements, ttlTimeExplicit, *ttlInterval, domain, *dataOrigin, *showIPs, *ipInfoMode) {\n\t\treturn\n\t}\n\n\toutputCleanup, err := configureTracePrinters(&conf, *tablePrint, *classicPrint, *rawPrint, resolvedOutputPath)\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n\tif outputCleanup != nil {\n\t\tdefer func() {\n\t\t\tif closeErr := outputCleanup(); closeErr != nil {\n\t\t\t\tfmt.Println(closeErr)\n\t\t\t}\n\t\t}()\n\t}\n\tapplyJSONOutputMode(&conf, *jsonPrint)\n\tmaybeRunUninterruptedRaw(*rawPrint, method, conf)\n\n\tres, ok := runTraceOnce(method, conf)\n\tif !ok {\n\t\treturn\n\t}\n\n\tfinalizeTraceResult(rootCtx, res, *tablePrint, stdoutIsTTY, *routePath, ip, *disableMaptrace, *jsonPrint, *dataOrigin)\n}\n\ntype mtrRunMode int\n\nconst (\n\tmtrRunTUI mtrRunMode = iota\n\tmtrRunReport\n\tmtrRunRaw\n)\n\nfunc chooseMTRRunMode(effectiveMTRRaw, effectiveReport bool) mtrRunMode {\n\tif effectiveMTRRaw {\n\t\treturn mtrRunRaw\n\t}\n\tif effectiveReport {\n\t\treturn mtrRunReport\n\t}\n\treturn mtrRunTUI\n}\n\n// deriveMTRProbeParams computes per-hop scheduling parameters for MTR.\n//\n// maxPerHop priority: explicit -q > report default 10 > TUI/raw default 0 (unlimited).\n// hopIntervalMs priority: explicit -i > default 1000.\nfunc deriveMTRProbeParams(\n\teffectiveReport, queriesExplicit bool, numMeasurements int,\n\tttlTimeExplicit bool, ttlInterval int,\n) (maxPerHop int, hopIntervalMs int) {\n\t// maxPerHop\n\tif queriesExplicit {\n\t\tmaxPerHop = numMeasurements\n\t} else if effectiveReport {\n\t\tmaxPerHop = 10 // report 默认 10\n\t} else {\n\t\tmaxPerHop = 0 // TUI/raw → 无限\n\t}\n\n\t// hopIntervalMs\n\tif ttlTimeExplicit {\n\t\thopIntervalMs = ttlInterval\n\t} else {\n\t\thopIntervalMs = 1000\n\t}\n\treturn\n}\n\n// deriveMTRRoundParams is the legacy round-based parameter derivation.\n// Kept for backward compatibility (Web MTR).\nfunc deriveMTRRoundParams(effectiveReport, queriesExplicit bool, numMeasurements int, ttlTimeExplicit bool, ttlInterval int) (maxRounds int, intervalMs int) {\n\tif effectiveReport {\n\t\tif queriesExplicit {\n\t\t\tmaxRounds = numMeasurements\n\t\t} else {\n\t\t\tmaxRounds = 10 // report 默认 10 轮\n\t\t}\n\t} else if queriesExplicit {\n\t\tmaxRounds = numMeasurements\n\t} else {\n\t\tmaxRounds = 0 // 非 report → 无限\n\t}\n\n\tif ttlTimeExplicit {\n\t\tintervalMs = ttlInterval\n\t} else {\n\t\tintervalMs = 1000 // MTR 默认 1000ms\n\t}\n\treturn\n}\n\nfunc capabilitiesCheck() {\n\tstatus := util.TracePrivilegeStatus(appBinName, false)\n\tif status.Message != \"\" {\n\t\tfmt.Println(status.Message)\n\t}\n}\n\nfunc checkRuntimePrivileges(requireWindowsAdmin bool) bool {\n\tstatus := util.TracePrivilegeStatus(appBinName, requireWindowsAdmin)\n\tif status.Message != \"\" {\n\t\tfmt.Println(status.Message)\n\t}\n\treturn !status.Fatal\n}\n"
  },
  {
    "path": "cmd/cmd_test.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/akamensky/argparse\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/tracelog\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc TestLookupTargetIPHonorsContextCancellation(t *testing.T) {\n\toldLookup := domainLookupFn\n\tdomainLookupFn = func(ctx context.Context, host, ipVersion, dotServer string, disableOutput bool) (net.IP, error) {\n\t\t<-ctx.Done()\n\t\treturn nil, ctx.Err()\n\t}\n\tdefer func() { domainLookupFn = oldLookup }()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\t_, err := lookupTargetIP(ctx, \"example.com\", false, false, \"\", true)\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Fatalf(\"lookupTargetIP error = %v, want context.Canceled\", err)\n\t}\n\tif elapsed := time.Since(start); elapsed > 100*time.Millisecond {\n\t\tt.Fatalf(\"lookupTargetIP returned too slowly after cancel: %v\", elapsed)\n\t}\n}\n\nfunc TestRegisterGlobalpingFlagWithAvailability_DisabledStillParses(t *testing.T) {\n\tparser := argparse.NewParser(\"ntr\", \"\")\n\tfrom := registerGlobalpingFlagWithAvailability(parser, false)\n\n\tif err := parser.Parse([]string{\"ntr\", \"--from\", \"tokyo\"}); err != nil {\n\t\tt.Fatalf(\"Parse returned error: %v\", err)\n\t}\n\tif got := strings.TrimSpace(*from); got != \"tokyo\" {\n\t\tt.Fatalf(\"--from = %q, want tokyo\", got)\n\t}\n}\n\nfunc TestRegisterWebUIFlagsWithAvailability_DisabledStillParses(t *testing.T) {\n\tparser := argparse.NewParser(\"ntr\", \"\")\n\tflags := registerWebUIFlagsWithAvailability(parser, false)\n\n\tif err := parser.Parse([]string{\"ntr\", \"--deploy\", \"--listen\", \"127.0.0.1:1080\"}); err != nil {\n\t\tt.Fatalf(\"Parse returned error: %v\", err)\n\t}\n\tif !*flags.deploy {\n\t\tt.Fatal(\"--deploy should parse as true\")\n\t}\n\tif got := strings.TrimSpace(*flags.deployListen); got != \"127.0.0.1:1080\" {\n\t\tt.Fatalf(\"--listen = %q, want 127.0.0.1:1080\", got)\n\t}\n}\n\nfunc TestRegisterTTLIntervalFlagWithMTRSupport_HelpOmitsTracerouteDefault(t *testing.T) {\n\tparser := argparse.NewParser(\"ntr\", \"\")\n\tregisterTTLIntervalFlagWithMTRSupport(parser, true)\n\n\tusage := parser.Usage(nil)\n\tif strings.Contains(usage, \"Default: 300\") {\n\t\tt.Fatalf(\"usage should not advertise traceroute default in MTR mode:\\n%s\", usage)\n\t}\n}\n\nfunc TestApplyTTLIntervalDefault(t *testing.T) {\n\tttlInterval := 0\n\tapplyTTLIntervalDefault(&ttlInterval, false, false)\n\tif ttlInterval != defaultTracerouteTTLIntervalMs {\n\t\tt.Fatalf(\"ttlInterval = %d, want %d\", ttlInterval, defaultTracerouteTTLIntervalMs)\n\t}\n\n\tttlInterval = 0\n\tapplyTTLIntervalDefault(&ttlInterval, false, true)\n\tif ttlInterval != 0 {\n\t\tt.Fatalf(\"MTR ttlInterval = %d, want 0\", ttlInterval)\n\t}\n\n\tttlInterval = 0\n\tapplyTTLIntervalDefault(&ttlInterval, true, false)\n\tif ttlInterval != 0 {\n\t\tt.Fatalf(\"explicit ttlInterval = %d, want 0\", ttlInterval)\n\t}\n}\n\nfunc TestAdvancedHelpTextMentionsTuningGuidance(t *testing.T) {\n\tparser := argparse.NewParser(\"ntr\", \"\")\n\tregisterPacketIntervalFlag(parser)\n\tparser.Int(\"\", \"max-attempts\", &argparse.Options{Help: buildMaxAttemptsHelp()})\n\tparser.Int(\"\", \"parallel-requests\", &argparse.Options{Default: 18, Help: buildParallelRequestsHelp()})\n\tparser.Int(\"\", \"timeout\", &argparse.Options{Default: 1000, Help: buildTimeoutHelp()})\n\tparser.Int(\"\", \"psize\", &argparse.Options{Help: buildPayloadSizeHelp()})\n\n\tusage := parser.Usage(nil)\n\tfor _, want := range []string{\n\t\t\"load-balanced paths\",\n\t\t\"rate-limited links\",\n\t\t\"intercontinental\",\n\t\t\"raise for MTU or\",\n\t} {\n\t\tif !strings.Contains(usage, want) {\n\t\t\tt.Fatalf(\"usage missing tuning guidance %q:\\n%s\", want, usage)\n\t\t}\n\t}\n}\n\nfunc TestProbeOptionHelpMentionsRandomPacketSizeAndTOS(t *testing.T) {\n\tparser := argparse.NewParser(\"ntr\", \"\")\n\tparser.Int(\"\", \"psize\", &argparse.Options{Help: buildPayloadSizeHelp()})\n\tparser.Int(\"Q\", \"tos\", &argparse.Options{Default: 0, Help: buildTOSHelp()})\n\n\tusage := parser.Usage(nil)\n\tfor _, want := range []string{\n\t\t\"Negative values randomize each probe\",\n\t\t\"type-of-service / traffic class\",\n\t} {\n\t\tif !strings.Contains(usage, want) {\n\t\t\tt.Fatalf(\"usage missing %q:\\n%s\", want, usage)\n\t\t}\n\t}\n}\n\nfunc TestDetectExplicitProbeFlags(t *testing.T) {\n\tparser := argparse.NewParser(\"ntr\", \"\")\n\tparser.Int(\"q\", \"queries\", &argparse.Options{Default: 3})\n\tparser.Int(\"i\", \"ttl-time\", &argparse.Options{Default: 300})\n\tparser.Int(\"\", \"psize\", &argparse.Options{})\n\tparser.Int(\"Q\", \"tos\", &argparse.Options{Default: 0})\n\n\tif err := parser.Parse([]string{\"ntr\", \"--psize\", \"-123\", \"-Q\", \"46\", \"-q\", \"5\"}); err != nil {\n\t\tt.Fatalf(\"Parse returned error: %v\", err)\n\t}\n\n\tqueriesExplicit, ttlTimeExplicit, packetSizeExplicit, tosExplicit := detectExplicitProbeFlags(parser)\n\tif !queriesExplicit {\n\t\tt.Fatal(\"queriesExplicit = false, want true\")\n\t}\n\tif ttlTimeExplicit {\n\t\tt.Fatal(\"ttlTimeExplicit = true, want false\")\n\t}\n\tif !packetSizeExplicit {\n\t\tt.Fatal(\"packetSizeExplicit = false, want true\")\n\t}\n\tif !tosExplicit {\n\t\tt.Fatal(\"tosExplicit = false, want true\")\n\t}\n}\n\nfunc TestNormalizeNegativePacketSizeArgs(t *testing.T) {\n\targs := []string{\"ntr\", \"--psize\", \"-84\", \"1.1.1.1\"}\n\tgot := normalizeNegativePacketSizeArgs(args)\n\twant := []string{\"ntr\", \"--psize=-84\", \"1.1.1.1\"}\n\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"len(got) = %d, want %d\", len(got), len(want))\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"got[%d] = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestNegativePacketSizeParsesBeforeTarget(t *testing.T) {\n\tparser := argparse.NewParser(\"ntr\", \"\")\n\tpacketSize := parser.Int(\"\", \"psize\", &argparse.Options{})\n\tipv6Only := parser.Flag(\"6\", \"ipv6\", &argparse.Options{})\n\ttarget := parser.StringPositional(&argparse.Options{})\n\n\targs := normalizeNegativePacketSizeArgs([]string{\"ntr\", \"-6\", \"--psize\", \"-96\", \"2606:4700:4700::1111\"})\n\tif err := parser.Parse(args); err != nil {\n\t\tt.Fatalf(\"Parse returned error: %v\", err)\n\t}\n\tif !*ipv6Only {\n\t\tt.Fatal(\"-6 should parse as true\")\n\t}\n\tif *packetSize != -96 {\n\t\tt.Fatalf(\"--psize = %d, want -96\", *packetSize)\n\t}\n\tif *target != \"2606:4700:4700::1111\" {\n\t\tt.Fatalf(\"target = %q, want 2606:4700:4700::1111\", *target)\n\t}\n}\n\nfunc TestResolvePacketSizeArg_DefaultsToProtocolMinimum(t *testing.T) {\n\tgot := resolvePacketSizeArg(0, false, trace.TCPTrace, net.ParseIP(\"2a00:1450:4009:81a::200e\"))\n\tif got != 64 {\n\t\tt.Fatalf(\"resolvePacketSizeArg() = %d, want 64\", got)\n\t}\n}\n\nfunc TestRegisterTracerouteOutputFlagsParsesOutputPath(t *testing.T) {\n\tparser := argparse.NewParser(\"nexttrace\", \"\")\n\tflags := registerTracerouteOutputFlags(parser)\n\ttarget := parser.StringPositional(&argparse.Options{})\n\n\tif err := parser.Parse([]string{\"nexttrace\", \"-o\", \"trace.log\", \"1.1.1.1\"}); err != nil {\n\t\tt.Fatalf(\"Parse returned error: %v\", err)\n\t}\n\tif got := strings.TrimSpace(*flags.outputPath); got != \"trace.log\" {\n\t\tt.Fatalf(\"--output = %q, want trace.log\", got)\n\t}\n\tif *flags.outputDefault {\n\t\tt.Fatal(\"--output-default should be false\")\n\t}\n\tif *target != \"1.1.1.1\" {\n\t\tt.Fatalf(\"target = %q, want 1.1.1.1\", *target)\n\t}\n}\n\nfunc TestRegisterTracerouteOutputFlagsParsesOutputDefault(t *testing.T) {\n\tparser := argparse.NewParser(\"nexttrace\", \"\")\n\tflags := registerTracerouteOutputFlags(parser)\n\ttarget := parser.StringPositional(&argparse.Options{})\n\n\tif err := parser.Parse([]string{\"nexttrace\", \"-O\", \"1.1.1.1\"}); err != nil {\n\t\tt.Fatalf(\"Parse returned error: %v\", err)\n\t}\n\tif !*flags.outputDefault {\n\t\tt.Fatal(\"--output-default should be true\")\n\t}\n\tif got := strings.TrimSpace(*flags.outputPath); got != \"\" {\n\t\tt.Fatalf(\"--output = %q, want empty\", got)\n\t}\n\tif *target != \"1.1.1.1\" {\n\t\tt.Fatalf(\"target = %q, want 1.1.1.1\", *target)\n\t}\n}\n\nfunc TestResolveOutputPath(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\toutputPath    string\n\t\toutputDefault bool\n\t\twant          string\n\t\twantErr       string\n\t}{\n\t\t{name: \"custom\", outputPath: \"custom.log\", want: \"custom.log\"},\n\t\t{name: \"default\", outputDefault: true, want: tracelog.DefaultPath},\n\t\t{name: \"disabled\"},\n\t\t{name: \"conflict\", outputPath: \"custom.log\", outputDefault: true, wantErr: \"--output 与 --output-default 不能同时使用\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := resolveOutputPath(tt.outputPath, tt.outputDefault)\n\t\t\tif tt.wantErr != \"\" {\n\t\t\t\tif err == nil || err.Error() != tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"err = %v, want %q\", err, tt.wantErr)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"resolveOutputPath returned error: %v\", err)\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Fatalf(\"resolveOutputPath() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSetFastIPOutputSuppressionRestoresPreviousValue(t *testing.T) {\n\torig := util.SuppressFastIPOutput\n\tutil.SuppressFastIPOutput = false\n\trestore := setFastIPOutputSuppression(true)\n\tif !util.SuppressFastIPOutput {\n\t\tt.Fatal(\"SuppressFastIPOutput should be true after suppression\")\n\t}\n\trestore()\n\tif util.SuppressFastIPOutput != false {\n\t\tt.Fatalf(\"SuppressFastIPOutput = %v, want false\", util.SuppressFastIPOutput)\n\t}\n\tutil.SuppressFastIPOutput = orig\n}\n\nfunc TestResolveConfiguredSrcAddrPrefersExplicitSource(t *testing.T) {\n\tdstIP := net.ParseIP(\"1.1.1.1\")\n\tresolved, explicit, err := resolveConfiguredSrcAddr(dstIP, \"192.0.2.10\", \"codex-nonexistent-dev0\")\n\tif err != nil {\n\t\tt.Fatalf(\"resolveConfiguredSrcAddr returned error: %v\", err)\n\t}\n\tif !explicit {\n\t\tt.Fatal(\"explicit source should be reported as explicit\")\n\t}\n\tif resolved != \"192.0.2.10\" {\n\t\tt.Fatalf(\"resolved source = %q, want %q\", resolved, \"192.0.2.10\")\n\t}\n}\n\nfunc TestValidateJSONRealtimeOutput(t *testing.T) {\n\tif err := validateJSONRealtimeOutput(true, \"trace.log\"); err == nil || err.Error() != \"--json 不能与 --output/--output-default 同时使用\" {\n\t\tt.Fatalf(\"err = %v, want json/output conflict\", err)\n\t}\n\tif err := validateJSONRealtimeOutput(true, \"\"); err != nil {\n\t\tt.Fatalf(\"unexpected error without output path: %v\", err)\n\t}\n}\n\nfunc TestShouldForceNoColorForMTUNonTTY(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tmtuMode     bool\n\t\tjsonPrint   bool\n\t\tstdoutIsTTY bool\n\t\twant        bool\n\t}{\n\t\t{name: \"mtu non-tty text\", mtuMode: true, jsonPrint: false, stdoutIsTTY: false, want: true},\n\t\t{name: \"mtu tty text\", mtuMode: true, jsonPrint: false, stdoutIsTTY: true, want: false},\n\t\t{name: \"mtu non-tty json\", mtuMode: true, jsonPrint: true, stdoutIsTTY: false, want: false},\n\t\t{name: \"non-mtu non-tty text\", mtuMode: false, jsonPrint: false, stdoutIsTTY: false, want: false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := shouldForceNoColorForMTUNonTTY(tt.mtuMode, tt.jsonPrint, tt.stdoutIsTTY)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Fatalf(\"shouldForceNoColorForMTUNonTTY() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cmd/deploy_disabled.go",
    "content": "//go:build flavor_tiny || flavor_ntr\n\npackage cmd\n\nimport \"fmt\"\n\nfunc runDeploy(_ string) error {\n\treturn fmt.Errorf(\"WebUI (--deploy) is not available in %s; please use the full nexttrace build\", appBinName)\n}\n"
  },
  {
    "path": "cmd/deploy_full.go",
    "content": "//go:build !flavor_tiny && !flavor_ntr\n\npackage cmd\n\nimport (\n\t\"github.com/nxtrace/NTrace-core/server\"\n)\n\nfunc runDeploy(listenAddr string) error {\n\treturn server.Run(listenAddr)\n}\n"
  },
  {
    "path": "cmd/flavor_full.go",
    "content": "//go:build !flavor_tiny && !flavor_ntr\n\npackage cmd\n\nconst (\n\tappBinName       = \"nexttrace\"\n\tenableWebUI      = true\n\tenableGlobalping = true\n\tenableMTR        = true\n\tenableMTU        = true\n\tdefaultMTR       = false\n)\n"
  },
  {
    "path": "cmd/flavor_ntr.go",
    "content": "//go:build flavor_ntr\n\npackage cmd\n\nconst (\n\tappBinName       = \"ntr\"\n\tenableWebUI      = false\n\tenableGlobalping = false\n\tenableMTR        = true\n\tenableMTU        = false\n\tdefaultMTR       = true\n)\n"
  },
  {
    "path": "cmd/flavor_tiny.go",
    "content": "//go:build flavor_tiny\n\npackage cmd\n\nconst (\n\tappBinName       = \"nexttrace-tiny\"\n\tenableWebUI      = false\n\tenableGlobalping = false\n\tenableMTR        = false\n\tenableMTU        = true\n\tdefaultMTR       = false\n)\n"
  },
  {
    "path": "cmd/globalping_disabled.go",
    "content": "//go:build flavor_tiny || flavor_ntr\n\npackage cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc handleGlobalpingTrace(_ *trace.GlobalpingOptions, _ *trace.Config) {\n\tfmt.Fprintf(os.Stderr, \"--from (Globalping) is not available in %s; please use the full nexttrace build\\n\", appBinName)\n\tos.Exit(1)\n}\n"
  },
  {
    "path": "cmd/globalping_full.go",
    "content": "//go:build !flavor_tiny && !flavor_ntr\n\npackage cmd\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/printer\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/tracemap\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc handleGlobalpingTrace(opts *trace.GlobalpingOptions, config *trace.Config) {\n\tctx := context.Background()\n\tif config != nil && config.Context != nil {\n\t\tctx = config.Context\n\t}\n\tres, measurement, err := trace.GlobalpingTraceroute(opts, config)\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\n\tif !opts.DisableMaptrace &&\n\t\t(util.StringInSlice(strings.ToUpper(opts.DataOrigin), []string{\"LEOMOEAPI\", \"IPINFO\", \"IP-API.COM\", \"IPAPI.COM\"})) {\n\t\tr, err := json.Marshal(res)\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\treturn\n\t\t}\n\t\turl, err := tracemap.GetMapUrlWithContext(ctx, string(r))\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\treturn\n\t\t}\n\t\tres.TraceMapUrl = url\n\t}\n\n\tif opts.JSONPrint {\n\t\tr, err := json.Marshal(res)\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\treturn\n\t\t}\n\t\tfmt.Println(string(r))\n\t\treturn\n\t}\n\n\tif measurement == nil || len(measurement.Results) == 0 {\n\t\tfmt.Println(globalpingNoResultMessage(config.Lang))\n\t\treturn\n\t}\n\n\tfmt.Fprintln(color.Output, color.New(color.FgGreen, color.Bold).Sprintf(\"> %s\", trace.GlobalpingFormatLocation(&measurement.Results[0])))\n\n\tif opts.TablePrint {\n\t\tprinter.TracerouteTablePrinter(res, opts.ClearScreen)\n\t} else {\n\t\tfor i := range res.Hops {\n\t\t\tif opts.ClassicPrint {\n\t\t\t\tprinter.ClassicPrinter(res, i)\n\t\t\t} else if opts.RawPrint {\n\t\t\t\tprinter.EasyPrinter(res, i)\n\t\t\t} else {\n\t\t\t\tprinter.RealtimePrinter(res, i)\n\t\t\t}\n\t\t}\n\t}\n\n\tif res.TraceMapUrl != \"\" {\n\t\ttracemap.PrintMapUrl(res.TraceMapUrl)\n\t}\n}\n\nfunc globalpingNoResultMessage(lang string) string {\n\tif strings.EqualFold(strings.TrimSpace(lang), \"en\") {\n\t\treturn \"Globalping returned no usable probe results; skipping output.\"\n\t}\n\treturn \"Globalping 未返回可用的探测结果，已跳过输出。\"\n}\n"
  },
  {
    "path": "cmd/listen_info_test.go",
    "content": "package cmd\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestIsDigitsOnly(t *testing.T) {\n\tassert.True(t, isDigitsOnly(\"12345\"))\n\tassert.False(t, isDigitsOnly(\"12a45\"))\n\tassert.False(t, isDigitsOnly(\"\"))\n}\n\nfunc TestBuildListenInfoPortOnly(t *testing.T) {\n\tinfo := buildListenInfo(\"8080\")\n\tassert.Equal(t, \"http://0.0.0.0:8080\", info.Binding)\n\tassert.NotEmpty(t, info.Access)\n\tassert.True(t, strings.HasSuffix(info.Access, \":8080\"))\n}\n\nfunc TestBuildListenInfoHostPort(t *testing.T) {\n\tinfo := buildListenInfo(\"192.0.2.1:9000\")\n\tassert.Equal(t, \"http://192.0.2.1:9000\", info.Binding)\n\tassert.Equal(t, \"http://192.0.2.1:9000\", info.Access)\n}\n\nfunc TestBuildListenInfoKeepsInvalidInput(t *testing.T) {\n\tinfo := buildListenInfo(\"not a valid endpoint\")\n\tassert.Equal(t, \"not a valid endpoint\", info.Binding)\n\tassert.Empty(t, info.Access)\n}\n"
  },
  {
    "path": "cmd/mtr_mode.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/config\"\n\t\"github.com/nxtrace/NTrace-core/printer\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nconst defaultMTRInternalTTLIntervalMs = 0\n\n// MTR 模式下与其他输出/功能标志互斥的检查。\n// 返回 true 表示存在冲突。\nfunc checkMTRConflicts(flags map[string]bool) (conflict string, ok bool) {\n\tconflicts := []struct {\n\t\tname string\n\t\tset  bool\n\t}{\n\t\t{\"--table\", flags[\"table\"]},\n\t\t{\"--classic\", flags[\"classic\"]},\n\t\t{\"--json\", flags[\"json\"]},\n\t\t{\"--output\", flags[\"output\"]},\n\t\t{\"--output-default\", flags[\"outputDefault\"]},\n\t\t{\"--route-path\", flags[\"routePath\"]},\n\t\t{\"--from\", flags[\"from\"]},\n\t\t{\"--fast-trace\", flags[\"fastTrace\"]},\n\t\t{\"--file\", flags[\"file\"]},\n\t\t{\"--deploy\", flags[\"deploy\"]},\n\t}\n\tfor _, c := range conflicts {\n\t\tif c.set {\n\t\t\treturn c.name, false\n\t\t}\n\t}\n\treturn \"\", true\n}\n\n// runMTRTUI 执行 MTR 交互式 TUI 模式。\n// 当 stdin 为 TTY 时启用全屏 TUI（备用屏幕、按键控制）；\n// 非 TTY 时降级为简单表格刷新。\nfunc runMTRTUI(method trace.Method, conf trace.Config, hopIntervalMs int, maxPerHop int, domain string, dataOrigin string, showIPs bool, initialDisplayMode int) {\n\tif hopIntervalMs <= 0 {\n\t\thopIntervalMs = 1000\n\t}\n\n\t// Ctrl-C 优雅退出\n\tsigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\tctx, cancel := context.WithCancel(sigCtx)\n\tdefer cancel()\n\n\t// 初始化 TUI 控制器\n\tui := newMTRUI(cancel, initialDisplayMode)\n\tui.Enter()\n\tdefer ui.Leave()\n\n\t// 按键读取协程（非 TTY 时内部 no-op）\n\tgo ui.ReadKeysLoop(ctx)\n\n\tstartTime := time.Now()\n\ttarget := conf.DstIP.String()\n\n\t// 解析源 IP：--source > --dev 推导 > udp dial fallback\n\tsrcHost, _ := os.Hostname()\n\tif srcHost == \"\" {\n\t\tsrcHost = \"unknown-host\"\n\t}\n\tsrcIP := resolveSrcIP(conf)\n\n\t// 语言：默认为 \"cn\"\n\tlang := conf.Lang\n\tif lang == \"\" {\n\t\tlang = \"cn\"\n\t}\n\n\t// preferred API 信息（仅 LeoMoeAPI 且有结果时展示）\n\tapiInfo := buildAPIInfo(dataOrigin)\n\troundConf := normalizeMTRTraceConfig(conf)\n\n\topts := trace.MTROptions{\n\t\tHopInterval:      time.Duration(hopIntervalMs) * time.Millisecond,\n\t\tMaxPerHop:        maxPerHop,\n\t\tIsResetRequested: ui.ConsumeRestartRequest,\n\t}\n\n\t// TTY 模式下使用 TUI 渲染器 + 暂停支持，非 TTY 使用简单表格\n\tvar onSnapshot trace.MTROnSnapshot\n\tif ui.IsTTY() {\n\t\topts.IsPaused = ui.IsPaused\n\t\tonSnapshot = printer.MTRTUIPrinter(target, domain, target, config.Version, startTime,\n\t\t\tsrcHost, srcIP, lang, apiInfo, showIPs, ui.IsPaused, ui.CurrentDisplayMode, ui.CurrentNameMode, ui.IsMPLSDisabled)\n\t} else {\n\t\tonSnapshot = func(iteration int, stats []trace.MTRHopStat) {\n\t\t\tprinter.MTRTablePrinter(stats, iteration, ui.CurrentDisplayMode(), ui.CurrentNameMode(), lang, showIPs)\n\t\t}\n\t}\n\n\terr := trace.RunMTR(ctx, method, roundConf, opts, onSnapshot)\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\t// 离开备用屏幕后再打印错误\n\t\tfmt.Println(err)\n\t}\n}\n\n// runMTRReport 执行 MTR 非全屏报告模式（对齐 mtr -rzw 风格）。\n// 探测完 maxPerHop 后一次性输出最终统计到 stdout，不进入 alternate screen。\nfunc runMTRReport(method trace.Method, conf trace.Config, hopIntervalMs int, maxPerHop int, domain string, dataOrigin string, wide bool, showIPs bool) {\n\tif hopIntervalMs <= 0 {\n\t\thopIntervalMs = 1000\n\t}\n\tif maxPerHop <= 0 {\n\t\tmaxPerHop = 10\n\t}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tstartTime := time.Now()\n\n\tsrcHost, _ := os.Hostname()\n\tif srcHost == \"\" {\n\t\tsrcHost = \"unknown-host\"\n\t}\n\n\tlang := conf.Lang\n\tif lang == \"\" {\n\t\tlang = \"cn\"\n\t}\n\n\t// 最终快照\n\tvar finalStats []trace.MTRHopStat\n\tonSnapshot := func(iteration int, stats []trace.MTRHopStat) {\n\t\tfinalStats = stats\n\t}\n\n\topts := trace.MTROptions{\n\t\tHopInterval: time.Duration(hopIntervalMs) * time.Millisecond,\n\t\tMaxPerHop:   maxPerHop,\n\t}\n\n\troundConf := normalizeMTRReportConfig(conf, wide)\n\terr := trace.RunMTR(ctx, method, roundConf, opts, onSnapshot)\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\n\tif len(finalStats) == 0 {\n\t\tfmt.Println(\"No data collected.\")\n\t\treturn\n\t}\n\n\tprinter.MTRReportPrint(finalStats, printer.MTRReportOptions{\n\t\tStartTime: startTime,\n\t\tSrcHost:   srcHost,\n\t\tWide:      wide,\n\t\tShowIPs:   showIPs,\n\t\tLang:      lang,\n\t})\n}\n\n// runMTRRaw 执行 MTR 原始流式模式（逐事件输出，'|' 分隔）。\n// 行格式固定为 12 列：\n// ttl|ip|ptr|rtt|asn|country|prov|city|district|owner|lat|lng\nfunc runMTRRaw(method trace.Method, conf trace.Config, hopIntervalMs int, maxPerHop int, dataOrigin string) {\n\tif hopIntervalMs <= 0 {\n\t\thopIntervalMs = 1000\n\t}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\topts := trace.MTRRawOptions{\n\t\tHopInterval: time.Duration(hopIntervalMs) * time.Millisecond,\n\t\tMaxPerHop:   maxPerHop,\n\t}\n\n\troundConf := normalizeMTRTraceConfig(conf)\n\tif apiLine := buildRawAPIInfoLine(dataOrigin); apiLine != \"\" {\n\t\tfmt.Println(apiLine)\n\t}\n\n\terr := trace.RunMTRRaw(ctx, method, roundConf, opts, func(rec trace.MTRRawRecord) {\n\t\tfmt.Println(printer.FormatMTRRawLine(rec))\n\t})\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\twriteMTRRawRuntimeError(os.Stderr, err)\n\t}\n}\n\nfunc normalizeMTRTraceConfig(conf trace.Config) trace.Config {\n\tnormalized := conf\n\tnormalized.TTLInterval = defaultMTRInternalTTLIntervalMs\n\treturn normalized\n}\n\nfunc normalizeMTRReportConfig(conf trace.Config, wide bool) trace.Config {\n\tnormalized := normalizeMTRTraceConfig(conf)\n\tif wide {\n\t\treturn normalized\n\t}\n\n\tnormalized.IPGeoSource = nil\n\tif normalized.RDNS {\n\t\tnormalized.AlwaysWaitRDNS = true\n\t}\n\treturn normalized\n}\n\nfunc writeMTRRawRuntimeError(w io.Writer, err error) {\n\tif err == nil || w == nil {\n\t\treturn\n\t}\n\t_, _ = fmt.Fprintln(w, err)\n}\n\n// resolveSrcIP 按优先级解析源 IP：--source > --dev 推导 > udp dial fallback。\n// 保证与目标 IP 族匹配，失败时返回 \"unknown\"。\nfunc resolveSrcIP(conf trace.Config) string {\n\tsourceDevice := conf.SourceDevice\n\tif sourceDevice == \"\" {\n\t\tsourceDevice = util.SrcDev\n\t}\n\tresolved, _, err := resolveConfiguredSrcAddr(conf.DstIP, conf.SrcAddr, sourceDevice)\n\tif err == nil && strings.TrimSpace(resolved) != \"\" {\n\t\treturn resolved\n\t}\n\treturn \"unknown\"\n}\n\n// buildAPIInfo 生成首行 preferred API 扩展信息（纯文本，不含 ANSI；仅 LeoMoeAPI）。\nfunc buildAPIInfo(dataOrigin string) string {\n\tif !strings.EqualFold(dataOrigin, \"LeoMoeAPI\") {\n\t\treturn \"\"\n\t}\n\tmeta := util.GetFastIPMetaCache()\n\tif meta.IP == \"\" {\n\t\treturn \"\"\n\t}\n\tnodeName := meta.NodeName\n\tif nodeName == \"\" {\n\t\tnodeName = \"Unknown\"\n\t}\n\treturn fmt.Sprintf(\"preferred API IP: %s[%s]\", nodeName, meta.IP)\n}\n\nfunc buildRawAPIInfoLine(dataOrigin string) string {\n\tif !strings.EqualFold(dataOrigin, \"LeoMoeAPI\") {\n\t\treturn \"\"\n\t}\n\tmeta := util.GetFastIPMetaCache()\n\tif meta.IP == \"\" {\n\t\treturn \"\"\n\t}\n\n\tnodeName := strings.TrimSpace(meta.NodeName)\n\tif nodeName == \"\" {\n\t\tnodeName = \"Unknown\"\n\t}\n\tlatency := strings.TrimSpace(meta.Latency)\n\tif latency == \"\" {\n\t\treturn fmt.Sprintf(\"[NextTrace API] preferred API IP - [%s] - %s\", meta.IP, nodeName)\n\t}\n\treturn fmt.Sprintf(\"[NextTrace API] preferred API IP - [%s] - %sms - %s\", meta.IP, latency, nodeName)\n}\n"
  },
  {
    "path": "cmd/mtr_mode_test.go",
    "content": "package cmd\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"os\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc TestCheckMTRConflicts_NoConflict(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": false, \"raw\": false, \"classic\": false,\n\t\t\"json\": false, \"output\": false,\n\t\t\"routePath\": false, \"from\": false, \"fastTrace\": false,\n\t\t\"file\": false, \"deploy\": false,\n\t}\n\tconflict, ok := checkMTRConflicts(flags)\n\tif !ok {\n\t\tt.Errorf(\"expected no conflict, got %q\", conflict)\n\t}\n}\n\nfunc TestCheckMTRConflicts_Table(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": true, \"raw\": false, \"classic\": false,\n\t\t\"json\": false, \"output\": false,\n\t\t\"routePath\": false, \"from\": false, \"fastTrace\": false,\n\t\t\"file\": false, \"deploy\": false,\n\t}\n\tconflict, ok := checkMTRConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected conflict with --table\")\n\t}\n\tif conflict != \"--table\" {\n\t\tt.Errorf(\"conflict = %q, want --table\", conflict)\n\t}\n}\n\nfunc TestCheckMTRConflicts_JSON(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": false, \"raw\": false, \"classic\": false,\n\t\t\"json\": true, \"output\": false, \"outputDefault\": false,\n\t\t\"routePath\": false, \"from\": false, \"fastTrace\": false,\n\t\t\"file\": false, \"deploy\": false,\n\t}\n\tconflict, ok := checkMTRConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected conflict with --json\")\n\t}\n\tif conflict != \"--json\" {\n\t\tt.Errorf(\"conflict = %q, want --json\", conflict)\n\t}\n}\n\nfunc TestCheckMTRConflicts_OutputDefault(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": false, \"raw\": false, \"classic\": false,\n\t\t\"json\": false, \"output\": false, \"outputDefault\": true,\n\t\t\"routePath\": false, \"from\": false, \"fastTrace\": false,\n\t\t\"file\": false, \"deploy\": false,\n\t}\n\tconflict, ok := checkMTRConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected conflict with --output-default\")\n\t}\n\tif conflict != \"--output-default\" {\n\t\tt.Errorf(\"conflict = %q, want --output-default\", conflict)\n\t}\n}\n\nfunc TestCheckMTRConflicts_FastTrace(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": false, \"raw\": false, \"classic\": false,\n\t\t\"json\": false, \"output\": false,\n\t\t\"routePath\": false, \"from\": false, \"fastTrace\": true,\n\t\t\"file\": false, \"deploy\": false,\n\t}\n\tconflict, ok := checkMTRConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected conflict with --fast-trace\")\n\t}\n\tif conflict != \"--fast-trace\" {\n\t\tt.Errorf(\"conflict = %q, want --fast-trace\", conflict)\n\t}\n}\n\nfunc TestCheckMTRConflicts_Deploy(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": false, \"raw\": false, \"classic\": false,\n\t\t\"json\": false, \"output\": false,\n\t\t\"routePath\": false, \"from\": false, \"fastTrace\": false,\n\t\t\"file\": false, \"deploy\": true,\n\t}\n\tconflict, ok := checkMTRConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected conflict with --deploy\")\n\t}\n\tif conflict != \"--deploy\" {\n\t\tt.Errorf(\"conflict = %q, want --deploy\", conflict)\n\t}\n}\n\nfunc TestCheckMTRConflicts_From(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": false, \"raw\": false, \"classic\": false,\n\t\t\"json\": false, \"output\": false,\n\t\t\"routePath\": false, \"from\": true, \"fastTrace\": false,\n\t\t\"file\": false, \"deploy\": false,\n\t}\n\tconflict, ok := checkMTRConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected conflict with --from\")\n\t}\n\tif conflict != \"--from\" {\n\t\tt.Errorf(\"conflict = %q, want --from\", conflict)\n\t}\n}\n\nfunc TestCheckMTRConflicts_AllConflicts(t *testing.T) {\n\t// 多个冲突标志同时设置时，应返回第一个匹配的\n\tflags := map[string]bool{\n\t\t\"table\": true, \"raw\": true, \"classic\": true,\n\t\t\"json\": true, \"output\": true,\n\t\t\"routePath\": true, \"from\": true, \"fastTrace\": true,\n\t\t\"file\": true, \"deploy\": true,\n\t}\n\t_, ok := checkMTRConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected conflict when all flags are set\")\n\t}\n}\n\nfunc TestCheckMTRConflicts_RawAllowed(t *testing.T) {\n\tflags := map[string]bool{\n\t\t\"table\": false, \"raw\": true, \"classic\": false,\n\t\t\"json\": false, \"output\": false,\n\t\t\"routePath\": false, \"from\": false, \"fastTrace\": false,\n\t\t\"file\": false, \"deploy\": false,\n\t}\n\tif conflict, ok := checkMTRConflicts(flags); !ok {\n\t\tt.Fatalf(\"raw should be allowed in MTR mode, got conflict=%q\", conflict)\n\t}\n}\n\nfunc TestChooseMTRRunMode_RawPriority(t *testing.T) {\n\tif mode := chooseMTRRunMode(true, true); mode != mtrRunRaw {\n\t\tt.Fatalf(\"raw should take precedence over report, got mode=%v\", mode)\n\t}\n\tif mode := chooseMTRRunMode(false, true); mode != mtrRunReport {\n\t\tt.Fatalf(\"report mode mismatch, got mode=%v\", mode)\n\t}\n\tif mode := chooseMTRRunMode(false, false); mode != mtrRunTUI {\n\t\tt.Fatalf(\"tui mode mismatch, got mode=%v\", mode)\n\t}\n}\n\nfunc TestDeriveMTRRoundParams_DefaultsAndOverrides(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\teffectiveReport bool\n\t\tqueriesExplicit bool\n\t\tnumMeasurements int\n\t\tttlTimeExplicit bool\n\t\tttlInterval     int\n\t\twantRounds      int\n\t\twantInterval    int\n\t}{\n\t\t{\n\t\t\tname:            \"report default rounds\",\n\t\t\teffectiveReport: true,\n\t\t\tqueriesExplicit: false,\n\t\t\tnumMeasurements: 3,\n\t\t\tttlTimeExplicit: false,\n\t\t\tttlInterval:     50,\n\t\t\twantRounds:      10,\n\t\t\twantInterval:    1000,\n\t\t},\n\t\t{\n\t\t\tname:            \"report explicit q\",\n\t\t\teffectiveReport: true,\n\t\t\tqueriesExplicit: true,\n\t\t\tnumMeasurements: 7,\n\t\t\tttlTimeExplicit: true,\n\t\t\tttlInterval:     250,\n\t\t\twantRounds:      7,\n\t\t\twantInterval:    250,\n\t\t},\n\t\t{\n\t\t\tname:            \"tui default infinite\",\n\t\t\teffectiveReport: false,\n\t\t\tqueriesExplicit: false,\n\t\t\tnumMeasurements: 9,\n\t\t\tttlTimeExplicit: false,\n\t\t\tttlInterval:     10,\n\t\t\twantRounds:      0,\n\t\t\twantInterval:    1000,\n\t\t},\n\t\t{\n\t\t\tname:            \"tui explicit q\",\n\t\t\teffectiveReport: false,\n\t\t\tqueriesExplicit: true,\n\t\t\tnumMeasurements: 4,\n\t\t\tttlTimeExplicit: true,\n\t\t\tttlInterval:     1200,\n\t\t\twantRounds:      4,\n\t\t\twantInterval:    1200,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotRounds, gotInterval := deriveMTRRoundParams(\n\t\t\t\ttt.effectiveReport,\n\t\t\t\ttt.queriesExplicit,\n\t\t\t\ttt.numMeasurements,\n\t\t\t\ttt.ttlTimeExplicit,\n\t\t\t\ttt.ttlInterval,\n\t\t\t)\n\t\t\tif gotRounds != tt.wantRounds || gotInterval != tt.wantInterval {\n\t\t\t\tt.Fatalf(\"got rounds=%d interval=%d, want rounds=%d interval=%d\",\n\t\t\t\t\tgotRounds, gotInterval, tt.wantRounds, tt.wantInterval)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDeriveMTRProbeParams_DefaultsAndOverrides(t *testing.T) {\n\ttests := []struct {\n\t\tname              string\n\t\teffectiveReport   bool\n\t\tqueriesExplicit   bool\n\t\tnumMeasurements   int\n\t\tttlTimeExplicit   bool\n\t\tttlInterval       int\n\t\twantMaxPerHop     int\n\t\twantHopIntervalMs int\n\t}{\n\t\t{\n\t\t\tname:              \"report default\",\n\t\t\teffectiveReport:   true,\n\t\t\twantMaxPerHop:     10,\n\t\t\twantHopIntervalMs: 1000,\n\t\t},\n\t\t{\n\t\t\tname:              \"tui default (unlimited)\",\n\t\t\teffectiveReport:   false,\n\t\t\twantMaxPerHop:     0,\n\t\t\twantHopIntervalMs: 1000,\n\t\t},\n\t\t{\n\t\t\tname:              \"report explicit q\",\n\t\t\teffectiveReport:   true,\n\t\t\tqueriesExplicit:   true,\n\t\t\tnumMeasurements:   20,\n\t\t\twantMaxPerHop:     20,\n\t\t\twantHopIntervalMs: 1000,\n\t\t},\n\t\t{\n\t\t\tname:              \"explicit -i\",\n\t\t\teffectiveReport:   true,\n\t\t\tttlTimeExplicit:   true,\n\t\t\tttlInterval:       2000,\n\t\t\twantMaxPerHop:     10,\n\t\t\twantHopIntervalMs: 2000,\n\t\t},\n\t\t{\n\t\t\tname:              \"tui explicit q\",\n\t\t\teffectiveReport:   false,\n\t\t\tqueriesExplicit:   true,\n\t\t\tnumMeasurements:   5,\n\t\t\twantMaxPerHop:     5,\n\t\t\twantHopIntervalMs: 1000,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotMaxPerHop, gotHopIntervalMs := deriveMTRProbeParams(\n\t\t\t\ttt.effectiveReport,\n\t\t\t\ttt.queriesExplicit,\n\t\t\t\ttt.numMeasurements,\n\t\t\t\ttt.ttlTimeExplicit,\n\t\t\t\ttt.ttlInterval,\n\t\t\t)\n\t\t\tif gotMaxPerHop != tt.wantMaxPerHop || gotHopIntervalMs != tt.wantHopIntervalMs {\n\t\t\t\tt.Fatalf(\"got maxPerHop=%d hopIntervalMs=%d, want maxPerHop=%d hopIntervalMs=%d\",\n\t\t\t\t\tgotMaxPerHop, gotHopIntervalMs, tt.wantMaxPerHop, tt.wantHopIntervalMs)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNormalizeMTRTraceConfig_UsesMTRInternalTTLInterval50(t *testing.T) {\n\toriginal := trace.Config{\n\t\tTTLInterval:    1200,\n\t\tPacketInterval: 25,\n\t\tTimeout:        3,\n\t\tMaxHops:        18,\n\t\tBeginHop:       4,\n\t}\n\n\tnormalized := normalizeMTRTraceConfig(original)\n\n\tif normalized.TTLInterval != defaultMTRInternalTTLIntervalMs {\n\t\tt.Fatalf(\"normalized TTLInterval = %d, want %d\", normalized.TTLInterval, defaultMTRInternalTTLIntervalMs)\n\t}\n\tif normalized.PacketInterval != original.PacketInterval {\n\t\tt.Fatalf(\"normalized PacketInterval = %d, want %d\", normalized.PacketInterval, original.PacketInterval)\n\t}\n\tif normalized.Timeout != original.Timeout || normalized.MaxHops != original.MaxHops || normalized.BeginHop != original.BeginHop {\n\t\tt.Fatalf(\"unexpected mutation of other fields: %+v\", normalized)\n\t}\n\tif original.TTLInterval != 1200 {\n\t\tt.Fatalf(\"original config was modified in place: %+v\", original)\n\t}\n}\n\nfunc TestDefaultConstants_NormalVsMTR(t *testing.T) {\n\tif defaultPacketIntervalMs != 50 {\n\t\tt.Fatalf(\"defaultPacketIntervalMs = %d, want 50\", defaultPacketIntervalMs)\n\t}\n\tif defaultTracerouteTTLIntervalMs != 300 {\n\t\tt.Fatalf(\"defaultTracerouteTTLIntervalMs = %d, want 300\", defaultTracerouteTTLIntervalMs)\n\t}\n\tif defaultMTRInternalTTLIntervalMs != 0 {\n\t\tt.Fatalf(\"defaultMTRInternalTTLIntervalMs = %d, want 0\", defaultMTRInternalTTLIntervalMs)\n\t}\n}\n\nfunc TestNormalizeMTRReportConfig_NonWideDisablesGeoAndKeepsRDNS(t *testing.T) {\n\tgeoSource := func(_ string, _ time.Duration, _ string, _ bool) (*ipgeo.IPGeoData, error) {\n\t\treturn &ipgeo.IPGeoData{}, nil\n\t}\n\toriginal := trace.Config{\n\t\tTTLInterval:    1200,\n\t\tIPGeoSource:    geoSource,\n\t\tRDNS:           true,\n\t\tAlwaysWaitRDNS: false,\n\t\tPacketInterval: 25,\n\t\tTimeout:        3,\n\t\tMaxHops:        18,\n\t}\n\n\tnormalized := normalizeMTRReportConfig(original, false)\n\n\tif normalized.TTLInterval != defaultMTRInternalTTLIntervalMs {\n\t\tt.Fatalf(\"normalized TTLInterval = %d, want %d\", normalized.TTLInterval, defaultMTRInternalTTLIntervalMs)\n\t}\n\tif normalized.IPGeoSource != nil {\n\t\tt.Fatal(\"non-wide report should disable IPGeoSource\")\n\t}\n\tif !normalized.RDNS {\n\t\tt.Fatal(\"non-wide report should preserve RDNS=true\")\n\t}\n\tif !normalized.AlwaysWaitRDNS {\n\t\tt.Fatal(\"non-wide report should force AlwaysWaitRDNS when RDNS is enabled\")\n\t}\n\tif normalized.PacketInterval != original.PacketInterval || normalized.Timeout != original.Timeout || normalized.MaxHops != original.MaxHops {\n\t\tt.Fatalf(\"unexpected mutation of other fields: %+v\", normalized)\n\t}\n\tif original.IPGeoSource == nil || original.TTLInterval != 1200 || original.AlwaysWaitRDNS {\n\t\tt.Fatalf(\"original config was modified in place: %+v\", original)\n\t}\n}\n\nfunc TestNormalizeMTRReportConfig_NonWideRespectsNoRDNS(t *testing.T) {\n\tgeoSource := func(_ string, _ time.Duration, _ string, _ bool) (*ipgeo.IPGeoData, error) {\n\t\treturn &ipgeo.IPGeoData{}, nil\n\t}\n\toriginal := trace.Config{\n\t\tTTLInterval:    1200,\n\t\tIPGeoSource:    geoSource,\n\t\tRDNS:           false,\n\t\tAlwaysWaitRDNS: false,\n\t}\n\n\tnormalized := normalizeMTRReportConfig(original, false)\n\n\tif normalized.IPGeoSource != nil {\n\t\tt.Fatal(\"non-wide report should disable IPGeoSource\")\n\t}\n\tif normalized.RDNS {\n\t\tt.Fatal(\"non-wide report should preserve RDNS=false\")\n\t}\n\tif normalized.AlwaysWaitRDNS {\n\t\tt.Fatal(\"non-wide report should not force AlwaysWaitRDNS when RDNS is disabled\")\n\t}\n}\n\nfunc TestNormalizeMTRReportConfig_WidePreservesGeoSettings(t *testing.T) {\n\tgeoSource := func(_ string, _ time.Duration, _ string, _ bool) (*ipgeo.IPGeoData, error) {\n\t\treturn &ipgeo.IPGeoData{}, nil\n\t}\n\toriginal := trace.Config{\n\t\tTTLInterval:    1200,\n\t\tIPGeoSource:    geoSource,\n\t\tRDNS:           true,\n\t\tAlwaysWaitRDNS: false,\n\t\tPacketInterval: 25,\n\t}\n\n\tnormalized := normalizeMTRReportConfig(original, true)\n\n\tif normalized.TTLInterval != defaultMTRInternalTTLIntervalMs {\n\t\tt.Fatalf(\"normalized TTLInterval = %d, want %d\", normalized.TTLInterval, defaultMTRInternalTTLIntervalMs)\n\t}\n\tif normalized.IPGeoSource == nil {\n\t\tt.Fatal(\"wide report should preserve IPGeoSource\")\n\t}\n\tif !normalized.RDNS {\n\t\tt.Fatal(\"wide report should preserve RDNS=true\")\n\t}\n\tif normalized.AlwaysWaitRDNS != original.AlwaysWaitRDNS {\n\t\tt.Fatalf(\"wide report should preserve AlwaysWaitRDNS, got %v want %v\", normalized.AlwaysWaitRDNS, original.AlwaysWaitRDNS)\n\t}\n\tif original.IPGeoSource == nil || original.TTLInterval != 1200 {\n\t\tt.Fatalf(\"original config was modified in place: %+v\", original)\n\t}\n}\n\nfunc TestBuildRawAPIInfoLine_LeoMoeAPI(t *testing.T) {\n\toldCache := util.GetFastIPCache()\n\toldMeta := util.GetFastIPMetaCache()\n\tt.Cleanup(func() {\n\t\tutil.SetFastIPCacheState(oldCache, oldMeta)\n\t})\n\n\tutil.SetFastIPCacheState(\"\", util.FastIPMeta{\n\t\tIP:       \"2403:18c0:1001:462:dd:38ff:fe48:e0c5\",\n\t\tLatency:  \"21.33\",\n\t\tNodeName: \"DMIT.NRT\",\n\t})\n\n\tgot := buildRawAPIInfoLine(\"LeoMoeAPI\")\n\twant := \"[NextTrace API] preferred API IP - [2403:18c0:1001:462:dd:38ff:fe48:e0c5] - 21.33ms - DMIT.NRT\"\n\tif got != want {\n\t\tt.Fatalf(\"buildRawAPIInfoLine() = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestWriteMTRRawRuntimeError_WritesToProvidedWriter(t *testing.T) {\n\tvar buf bytes.Buffer\n\terr := errors.New(\"hop timeout\")\n\twriteMTRRawRuntimeError(&buf, err)\n\tif got := buf.String(); got != err.Error()+\"\\n\" {\n\t\tt.Fatalf(\"writeMTRRawRuntimeError() wrote %q\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// ParseMTRKey 测试\n// ---------------------------------------------------------------------------\n\nfunc TestParseMTRKey_Quit(t *testing.T) {\n\tfor _, b := range []byte{'q', 'Q', 0x03} {\n\t\tif got := ParseMTRKey(b); got != \"quit\" {\n\t\t\tt.Errorf(\"ParseMTRKey(%q) = %q, want %q\", b, got, \"quit\")\n\t\t}\n\t}\n}\n\nfunc TestParseMTRKey_Pause(t *testing.T) {\n\tfor _, b := range []byte{'p', 'P'} {\n\t\tif got := ParseMTRKey(b); got != \"pause\" {\n\t\t\tt.Errorf(\"ParseMTRKey(%q) = %q, want %q\", b, got, \"pause\")\n\t\t}\n\t}\n}\n\nfunc TestParseMTRKey_Resume(t *testing.T) {\n\tif got := ParseMTRKey(' '); got != \"resume\" {\n\t\tt.Errorf(\"ParseMTRKey(' ') = %q, want %q\", got, \"resume\")\n\t}\n}\n\nfunc TestParseMTRKey_Unknown(t *testing.T) {\n\tfor _, b := range []byte{'x', 'z', '1', '\\n'} {\n\t\tif got := ParseMTRKey(b); got != \"\" {\n\t\t\tt.Errorf(\"ParseMTRKey(%q) = %q, want empty\", b, got)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// r 键重置测试\n// ---------------------------------------------------------------------------\n\nfunc TestParseMTRKey_Restart(t *testing.T) {\n\tfor _, b := range []byte{'r', 'R'} {\n\t\tif got := ParseMTRKey(b); got != \"restart\" {\n\t\t\tt.Errorf(\"ParseMTRKey(%q) = %q, want %q\", b, got, \"restart\")\n\t\t}\n\t}\n}\n\nfunc TestParseMTRKey_DisplayMode(t *testing.T) {\n\tfor _, b := range []byte{'y', 'Y'} {\n\t\tif got := ParseMTRKey(b); got != \"display_mode\" {\n\t\t\tt.Errorf(\"ParseMTRKey(%q) = %q, want %q\", b, got, \"display_mode\")\n\t\t}\n\t}\n}\n\nfunc TestParseMTRKey_Unknown_IncludesY(t *testing.T) {\n\t// y/Y 现在已有映射，不再返回空\n\tfor _, b := range []byte{'x', 'z', '1', '\\n'} {\n\t\tif got := ParseMTRKey(b); got != \"\" {\n\t\t\tt.Errorf(\"ParseMTRKey(%q) = %q, want empty\", b, got)\n\t\t}\n\t}\n}\n\nfunc TestMTRUI_ConsumeRestartRequest(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tui := newMTRUI(cancel, 0)\n\n\t// 初始状态：无重置请求\n\tif ui.ConsumeRestartRequest() {\n\t\tt.Error(\"expected no restart request initially\")\n\t}\n\n\t// 模拟按下 r 键\n\tatomic.StoreInt32(&ui.restartReq, 1)\n\n\t// 第一次消费应返回 true\n\tif !ui.ConsumeRestartRequest() {\n\t\tt.Error(\"expected restart request after setting flag\")\n\t}\n\n\t// 第二次消费应返回 false（已被消费）\n\tif ui.ConsumeRestartRequest() {\n\t\tt.Error(\"expected restart request to be consumed\")\n\t}\n\n\t_ = ctx // suppress unused\n}\n\n// ---------------------------------------------------------------------------\n// 显示模式切换测试\n// ---------------------------------------------------------------------------\n\nfunc TestMTRUI_DisplayModeCycle(t *testing.T) {\n\t_, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tui := newMTRUI(cancel, 0)\n\n\t// 初始模式为 0\n\tif got := ui.CurrentDisplayMode(); got != 0 {\n\t\tt.Errorf(\"initial display mode = %d, want 0\", got)\n\t}\n\n\t// 循环切换 0 → 1 → 2 → 3 → 4 → 0\n\tui.CycleDisplayMode()\n\tif got := ui.CurrentDisplayMode(); got != 1 {\n\t\tt.Errorf(\"after 1st cycle: display mode = %d, want 1\", got)\n\t}\n\n\tui.CycleDisplayMode()\n\tif got := ui.CurrentDisplayMode(); got != 2 {\n\t\tt.Errorf(\"after 2nd cycle: display mode = %d, want 2\", got)\n\t}\n\n\tui.CycleDisplayMode()\n\tif got := ui.CurrentDisplayMode(); got != 3 {\n\t\tt.Errorf(\"after 3rd cycle: display mode = %d, want 3\", got)\n\t}\n\n\tui.CycleDisplayMode()\n\tif got := ui.CurrentDisplayMode(); got != 4 {\n\t\tt.Errorf(\"after 4th cycle: display mode = %d, want 4\", got)\n\t}\n\n\tui.CycleDisplayMode()\n\tif got := ui.CurrentDisplayMode(); got != 0 {\n\t\tt.Errorf(\"after 5th cycle: display mode = %d, want 0 (wrap)\", got)\n\t}\n}\n\nfunc TestMTRUI_DisplayModeNotResetByRestart(t *testing.T) {\n\t_, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tui := newMTRUI(cancel, 0)\n\n\t// 设置显示模式为 2\n\tui.CycleDisplayMode() // 0 → 1\n\tui.CycleDisplayMode() // 1 → 2\n\n\t// 模拟重置请求\n\tatomic.StoreInt32(&ui.restartReq, 1)\n\tui.ConsumeRestartRequest()\n\n\t// 显示模式不应被重置\n\tif got := ui.CurrentDisplayMode(); got != 2 {\n\t\tt.Errorf(\"display mode after restart = %d, want 2 (unchanged)\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 初始显示模式测试\n// ---------------------------------------------------------------------------\n\nfunc TestMTRUI_InitialDisplayMode_FromFlag(t *testing.T) {\n\tfor _, mode := range []int{0, 1, 2, 3, 4} {\n\t\t_, cancel := context.WithCancel(context.Background())\n\t\tui := newMTRUI(cancel, mode)\n\t\tif got := ui.CurrentDisplayMode(); got != mode {\n\t\t\tt.Errorf(\"initialDisplayMode=%d: CurrentDisplayMode() = %d\", mode, got)\n\t\t}\n\t\tcancel()\n\t}\n}\n\nfunc TestMTRUI_InitialDisplayMode_CycleFromNonZero(t *testing.T) {\n\t_, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tui := newMTRUI(cancel, 3) // start at Owner\n\n\t// 3 → 4\n\tui.CycleDisplayMode()\n\tif got := ui.CurrentDisplayMode(); got != 4 {\n\t\tt.Errorf(\"after cycle from 3: got %d, want 4\", got)\n\t}\n\n\t// 4 → 0 (wrap)\n\tui.CycleDisplayMode()\n\tif got := ui.CurrentDisplayMode(); got != 0 {\n\t\tt.Errorf(\"after cycle from 4: got %d, want 0 (wrap)\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// CheckTTY / TTY 判定测试\n// ---------------------------------------------------------------------------\n\nfunc TestCheckTTY_PipeFd(t *testing.T) {\n\t// 管道 fd 不是终端，CheckTTY 应返回 false\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer r.Close()\n\tdefer w.Close()\n\n\tif CheckTTY(int(r.Fd())) {\n\t\tt.Error(\"pipe read-end should not be a TTY\")\n\t}\n\tif CheckTTY(int(w.Fd())) {\n\t\tt.Error(\"pipe write-end should not be a TTY\")\n\t}\n}\n\nfunc TestCheckTTY_StdoutRedirected(t *testing.T) {\n\t// 模拟 \"stdin 是 TTY, stdout 被重定向\" 场景：\n\t// 两个 fd 中至少一个非终端 → 应返回 false\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer r.Close()\n\tdefer w.Close()\n\n\t// 即使 stdin fd 碰巧是终端（CI 中通常不是），\n\t// 只要 stdout fd 是管道就应为 false\n\tif CheckTTY(int(os.Stdin.Fd()), int(w.Fd())) {\n\t\tt.Error(\"CheckTTY(stdin, pipe) should be false when stdout is redirected\")\n\t}\n}\n\nfunc TestCheckTTY_EmptyFds(t *testing.T) {\n\t// 空参数 → vacuously true\n\tif !CheckTTY() {\n\t\tt.Error(\"CheckTTY() with no args should be true\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// n 键 NameMode 切换测试\n// ---------------------------------------------------------------------------\n\nfunc TestParseMTRKey_NameToggle(t *testing.T) {\n\tfor _, b := range []byte{'n', 'N'} {\n\t\tif got := ParseMTRKey(b); got != \"name_toggle\" {\n\t\t\tt.Errorf(\"ParseMTRKey(%q) = %q, want %q\", b, got, \"name_toggle\")\n\t\t}\n\t}\n}\n\nfunc TestMTRUI_NameModeToggle(t *testing.T) {\n\t_, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tui := newMTRUI(cancel, 0)\n\n\t// 初始为 0 (PTRorIP)\n\tif got := ui.CurrentNameMode(); got != 0 {\n\t\tt.Errorf(\"initial name mode = %d, want 0\", got)\n\t}\n\n\t// 切换 → 1 (IPOnly)\n\tui.ToggleNameMode()\n\tif got := ui.CurrentNameMode(); got != 1 {\n\t\tt.Errorf(\"after toggle: name mode = %d, want 1\", got)\n\t}\n\n\t// 再切换 → 0 (PTRorIP)\n\tui.ToggleNameMode()\n\tif got := ui.CurrentNameMode(); got != 0 {\n\t\tt.Errorf(\"after 2nd toggle: name mode = %d, want 0\", got)\n\t}\n}\n\nfunc TestMTRUI_NameModeNotResetByRestart(t *testing.T) {\n\t_, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tui := newMTRUI(cancel, 0)\n\n\t// 设置 nameMode 为 1\n\tui.ToggleNameMode()\n\tif got := ui.CurrentNameMode(); got != 1 {\n\t\tt.Fatalf(\"name mode = %d, want 1\", got)\n\t}\n\n\t// 模拟重置请求\n\tatomic.StoreInt32(&ui.restartReq, 1)\n\tui.ConsumeRestartRequest()\n\n\t// nameMode 不应被重置\n\tif got := ui.CurrentNameMode(); got != 1 {\n\t\tt.Errorf(\"name mode after restart = %d, want 1 (unchanged)\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// mtrInputParser 测试\n// ---------------------------------------------------------------------------\n\n// feedAll 向解析器喂入完整字节流，返回所有非 None 动作。\nfunc feedAll(p *mtrInputParser, data []byte) []mtrInputAction {\n\tvar actions []mtrInputAction\n\tfor _, b := range data {\n\t\ta := p.Feed(b)\n\t\tif a != mtrActionNone {\n\t\t\tactions = append(actions, a)\n\t\t}\n\t}\n\treturn actions\n}\n\nfunc TestMTRInputParser_IgnoresX10MouseSequence(t *testing.T) {\n\t// X10 mouse: ESC [ M Cb Cx Cy  —— 6 字节\n\t// 关键：Cb/Cx/Cy 可以是 0x20（空格），不应触发 resume\n\tvar p mtrInputParser\n\t// 模拟点击事件：button=0(0x20), x=10(0x2A), y=5(0x25)\n\tseq := []byte{0x1B, '[', 'M', 0x20, 0x2A, 0x25}\n\tactions := feedAll(&p, seq)\n\tif len(actions) != 0 {\n\t\tt.Errorf(\"X10 mouse should produce no actions, got %v\", actions)\n\t}\n\n\t// 确认解析器回到 ground：后续 'q' 应正常识别\n\ta := p.Feed('q')\n\tif a != mtrActionQuit {\n\t\tt.Errorf(\"after X10 mouse, 'q' should produce quit, got %d\", a)\n\t}\n}\n\nfunc TestMTRInputParser_IgnoresSGRMouseSequence(t *testing.T) {\n\t// SGR mouse: ESC [ < 0;10;5 M  (按下) 或 ...m (释放)\n\tvar p mtrInputParser\n\tpress := []byte{0x1B, '[', '<', '0', ';', '1', '0', ';', '5', 'M'}\n\trelease := []byte{0x1B, '[', '<', '0', ';', '1', '0', ';', '5', 'm'}\n\n\tactions := feedAll(&p, press)\n\tif len(actions) != 0 {\n\t\tt.Errorf(\"SGR mouse press should produce no actions, got %v\", actions)\n\t}\n\tactions = feedAll(&p, release)\n\tif len(actions) != 0 {\n\t\tt.Errorf(\"SGR mouse release should produce no actions, got %v\", actions)\n\t}\n}\n\nfunc TestMTRInputParser_IgnoresFocusSequence(t *testing.T) {\n\tvar p mtrInputParser\n\t// Focus in: ESC [ I\n\tfocusIn := []byte{0x1B, '[', 'I'}\n\tactions := feedAll(&p, focusIn)\n\tif len(actions) != 0 {\n\t\tt.Errorf(\"focus-in should produce no actions, got %v\", actions)\n\t}\n\n\t// Focus out: ESC [ O\n\tfocusOut := []byte{0x1B, '[', 'O'}\n\tactions = feedAll(&p, focusOut)\n\tif len(actions) != 0 {\n\t\tt.Errorf(\"focus-out should produce no actions, got %v\", actions)\n\t}\n}\n\nfunc TestMTRInputParser_RecognizesNormalKeysAfterEscapeNoise(t *testing.T) {\n\tvar p mtrInputParser\n\n\t// 先喂入一堆 escape 噪音（X10 mouse + focus + CSI arrow），然后喂正常键\n\tnoise := []byte{\n\t\t0x1B, '[', 'M', 0x20, 0x30, 0x30, // X10 mouse\n\t\t0x1B, '[', 'I', // focus in\n\t\t0x1B, '[', 'A', // CSI arrow up\n\t}\n\tnoiseActions := feedAll(&p, noise)\n\tif len(noiseActions) != 0 {\n\t\tt.Errorf(\"noise should produce no actions, got %v\", noiseActions)\n\t}\n\n\t// 现在喂入正常快捷键序列\n\tkeys := []byte{'p', ' ', 'r', 'y', 'n', 'q'}\n\texpected := []mtrInputAction{\n\t\tmtrActionPause,\n\t\tmtrActionResume,\n\t\tmtrActionRestart,\n\t\tmtrActionDisplayMode,\n\t\tmtrActionNameToggle,\n\t\tmtrActionQuit,\n\t}\n\tactions := feedAll(&p, keys)\n\tif len(actions) != len(expected) {\n\t\tt.Fatalf(\"expected %d actions, got %d: %v\", len(expected), len(actions), actions)\n\t}\n\tfor i, want := range expected {\n\t\tif actions[i] != want {\n\t\t\tt.Errorf(\"action[%d] = %d, want %d\", i, actions[i], want)\n\t\t}\n\t}\n}\n\nfunc TestMTRInputParser_SS3Ignored(t *testing.T) {\n\t// SS3 F (PF1 key): ESC O P\n\tvar p mtrInputParser\n\tseq := []byte{0x1B, 'O', 'P'}\n\tactions := feedAll(&p, seq)\n\tif len(actions) != 0 {\n\t\tt.Errorf(\"SS3 sequence should produce no actions, got %v\", actions)\n\t}\n}\n\nfunc TestMTRInputParser_OSCIgnored(t *testing.T) {\n\t// OSC title: ESC ] 0 ; t i t l e BEL\n\tvar p mtrInputParser\n\tseq := []byte{0x1B, ']', '0', ';', 't', 'i', 't', 'l', 'e', 0x07}\n\tactions := feedAll(&p, seq)\n\tif len(actions) != 0 {\n\t\tt.Errorf(\"OSC sequence should produce no actions, got %v\", actions)\n\t}\n}\n\nfunc TestMTRInputParser_BracketedPasteCSISwallowed(t *testing.T) {\n\t// 验证 bracketed paste 开始序列 ESC [ 2 0 0 ~ 被解析器吞掉（作为 CSI），\n\t// 且序列终止后后续字节正常回到 ground 状态处理。\n\t// 真正的 bracketed paste 防护在 disableTerminalInputModes 已关闭 2004 模式，\n\t// 这里仅测试 CSI 终止符 '~' 之后 parser 恢复 ground 的行为。\n\tvar p mtrInputParser\n\tseq := []byte{\n\t\t0x1B, '[', '2', '0', '0', '~', // CSI 2 0 0 ~ → 被吞掉\n\t\t'h', 'e', 'l', 'l', 'o', ' ', 'q', // 后续字节回到 ground 正常处理\n\t}\n\tactions := feedAll(&p, seq)\n\t// CSI \"200~\" 终止在 '~'，之后：\n\t// 'h','e','l','l','o' → 无映射（mtrActionNone）\n\t// ' ' → resume\n\t// 'q' → quit\n\tif len(actions) < 2 {\n\t\tt.Errorf(\"expected at least resume+quit from post-CSI bytes, got %d actions: %v\", len(actions), actions)\n\t}\n\t// 验证最后两个 action 是 resume 和 quit\n\tif len(actions) >= 2 {\n\t\tgot := actions[len(actions)-2:]\n\t\tif got[0] != mtrActionResume {\n\t\t\tt.Errorf(\"second-to-last action: want resume, got %d\", got[0])\n\t\t}\n\t\tif got[1] != mtrActionQuit {\n\t\t\tt.Errorf(\"last action: want quit, got %d\", got[1])\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cmd/mtr_ui.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"sync/atomic\"\n\n\t\"golang.org/x/term\"\n)\n\n// ---------------------------------------------------------------------------\n// MTR 交互式 TUI 控制器\n// ---------------------------------------------------------------------------\n\n// mtrUI 管理终端交互状态：备份屏幕、raw mode、按键处理。\ntype mtrUI struct {\n\tisTTY       bool\n\toldState    *term.State // raw mode 之前的终端状态\n\tpaused      int32       // 0=running, 1=paused（atomic）\n\trestartReq  int32       // 1=请求重置统计（atomic）\n\tdisplayMode int32       // 显示模式 0-4（atomic）\n\tnameMode    int32       // Host 基础显示 0=PTR/IP, 1=IP only（atomic）\n\tdisableMPLS int32       // 0=显示 MPLS, 1=隐藏 MPLS（atomic）\n\tcancel      context.CancelFunc\n}\n\n// newMTRUI 创建 TUI 控制器。cancel 是用于退出 MTR 的 context cancel 函数。\n// initialDisplayMode 设置 TUI 初始显示模式 (0-4)。\n// stdin 和 stdout 都必须是终端才会启用交互式 TUI。\nfunc newMTRUI(cancel context.CancelFunc, initialDisplayMode int) *mtrUI {\n\treturn &mtrUI{\n\t\tisTTY:       term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())),\n\t\tcancel:      cancel,\n\t\tdisplayMode: int32(initialDisplayMode),\n\t}\n}\n\n// IsTTY 返回 stdin 和 stdout 是否都是终端。\nfunc (u *mtrUI) IsTTY() bool {\n\treturn u.isTTY\n}\n\n// CheckTTY 检查给定的 fd 是否都是终端（可测试）。\nfunc CheckTTY(fds ...int) bool {\n\tfor _, fd := range fds {\n\t\tif !term.IsTerminal(fd) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// IsPaused 返回当前是否处于暂停状态（供 MTROptions.IsPaused 使用）。\nfunc (u *mtrUI) IsPaused() bool {\n\treturn atomic.LoadInt32(&u.paused) == 1\n}\n\n// CycleDisplayMode 循环切换显示模式 (0 → 1 → 2 → 3 → 4 → 0)。\nfunc (u *mtrUI) CycleDisplayMode() {\n\tfor {\n\t\told := atomic.LoadInt32(&u.displayMode)\n\t\tnext := (old + 1) % 5\n\t\tif atomic.CompareAndSwapInt32(&u.displayMode, old, next) {\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// CurrentDisplayMode 返回当前显示模式 (0-4)。\nfunc (u *mtrUI) CurrentDisplayMode() int {\n\treturn int(atomic.LoadInt32(&u.displayMode))\n}\n\n// ToggleNameMode 在 PTR/IP (0) 和 IP only (1) 之间切换。\nfunc (u *mtrUI) ToggleNameMode() int32 {\n\tfor {\n\t\told := atomic.LoadInt32(&u.nameMode)\n\t\tnext := int32(1) - old // 0→1, 1→0\n\t\tif atomic.CompareAndSwapInt32(&u.nameMode, old, next) {\n\t\t\treturn next\n\t\t}\n\t}\n}\n\n// CurrentNameMode 返回当前 Host 基础显示模式 (0=PTR/IP, 1=IP only)。\nfunc (u *mtrUI) CurrentNameMode() int {\n\treturn int(atomic.LoadInt32(&u.nameMode))\n}\n\n// ToggleMPLS 在显示 MPLS (0) 和隐藏 MPLS (1) 之间切换。\nfunc (u *mtrUI) ToggleMPLS() {\n\tfor {\n\t\told := atomic.LoadInt32(&u.disableMPLS)\n\t\tnext := int32(1) - old // 0→1, 1→0\n\t\tif atomic.CompareAndSwapInt32(&u.disableMPLS, old, next) {\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// IsMPLSDisabled 返回当前是否隐藏 MPLS 显示。\nfunc (u *mtrUI) IsMPLSDisabled() bool {\n\treturn atomic.LoadInt32(&u.disableMPLS) != 0\n}\n\n// ---------------------------------------------------------------------------\n// 终端模式关闭序列（幂等）\n// ---------------------------------------------------------------------------\n\n// disableTerminalInputModes 向 stdout 写入显式关闭序列，\n// 确保鼠标事件、焦点事件、bracketed paste 等不会污染 MTR 输入。\n// 在 Enter() 和 Leave() 中均调用，实现幂等防御。\nfunc disableTerminalInputModes() {\n\tseqs := []string{\n\t\t\"\\033[?1000l\", // 关闭 X10 mouse\n\t\t\"\\033[?1002l\", // 关闭 button-event mouse\n\t\t\"\\033[?1003l\", // 关闭 any-event mouse\n\t\t\"\\033[?1006l\", // 关闭 SGR extended mouse\n\t\t\"\\033[?1015l\", // 关闭 urxvt mouse\n\t\t\"\\033[?1004l\", // 关闭 focus in/out\n\t\t\"\\033[?2004l\", // 关闭 bracketed paste\n\t}\n\tfor _, s := range seqs {\n\t\tos.Stdout.WriteString(s)\n\t}\n}\n\n// Enter 进入交互模式：切换到备用屏幕缓冲区、隐藏光标、开启 raw mode。\n// 非 TTY 时为 no-op。\nfunc (u *mtrUI) Enter() {\n\tif !u.isTTY {\n\t\treturn\n\t}\n\t// 备用屏幕缓冲区\n\tos.Stdout.WriteString(\"\\033[?1049h\")\n\t// 隐藏光标\n\tos.Stdout.WriteString(\"\\033[?25l\")\n\t// 防御：关闭可能被外部残留的鼠标/焦点/paste 模式\n\tdisableTerminalInputModes()\n\t// raw mode\n\tif oldState, err := term.MakeRaw(int(os.Stdin.Fd())); err == nil {\n\t\tu.oldState = oldState\n\t} else {\n\t\t// TUI 在 cooked mode 下无法正确处理按键，输出警告\n\t\tio.WriteString(os.Stderr, fmt.Sprintf(\"warning: failed to enable raw mode: %v\\n\", err))\n\t}\n}\n\n// Leave 离开交互模式：恢复终端状态、显示光标、离开备用屏幕。\n// 非 TTY / 未 Enter 时为 no-op。必须在 defer 中调用。\nfunc (u *mtrUI) Leave() {\n\tif !u.isTTY {\n\t\treturn\n\t}\n\t// 防御：确保退出前关闭所有扩展输入模式\n\tdisableTerminalInputModes()\n\t// 恢复终端\n\tif u.oldState != nil {\n\t\t_ = term.Restore(int(os.Stdin.Fd()), u.oldState)\n\t\tu.oldState = nil\n\t}\n\t// 显示光标\n\tos.Stdout.WriteString(\"\\033[?25h\")\n\t// 离开备用屏幕缓冲区（恢复之前内容）\n\tos.Stdout.WriteString(\"\\033[?1049l\")\n}\n\n// ---------------------------------------------------------------------------\n// 输入解析器：字节流状态机\n// ---------------------------------------------------------------------------\n\n// mtrInputAction 表示输入解析器解析出的动作。\ntype mtrInputAction int\n\nconst (\n\tmtrActionNone        mtrInputAction = iota // 无动作（序列被吞掉或 buffer 不足）\n\tmtrActionQuit                              // q / Q / Ctrl-C\n\tmtrActionPause                             // p\n\tmtrActionResume                            // 空格\n\tmtrActionRestart                           // r\n\tmtrActionDisplayMode                       // y\n\tmtrActionNameToggle                        // n\n\tmtrActionMPLSToggle                        // e\n)\n\n// mtrInputParser 是一个字节级状态机，能区分普通按键与\n// CSI/SS3/OSC/鼠标/焦点等转义序列，对后者整体吞掉。\ntype mtrInputParser struct {\n\tstate mtrParserState\n\tcsiN  int // CSI 体内已读字节数（用于限制吞掉长度）\n}\n\ntype mtrParserState int\n\nconst (\n\tmtrStateGround   mtrParserState = iota // 等待新输入\n\tmtrStateEsc                            // 刚收到 ESC (0x1B)\n\tmtrStateCSI                            // ESC [  ——  CSI 序列体\n\tmtrStateSS3                            // ESC O  ——  SS3 序列体（1 字节负载）\n\tmtrStateOSC                            // ESC ]  ——  OSC 序列体（到 BEL/ST 结束）\n\tmtrStateX10Mouse                       // ESC [ M  —— 3 字节负载\n\tmtrStateSGRMouse                       // ESC [ <  —— 到 M/m 结束\n)\n\n// mtrParserMaxCSI 限制 CSI 序列体最大长度，防止畸形输入卡死解析器。\nconst mtrParserMaxCSI = 64\n\n// Feed 向解析器喂入一个字节，返回识别出的动作。\n// 正常按键立即返回动作；转义序列在完整吞掉前返回 mtrActionNone。\nfunc (p *mtrInputParser) Feed(b byte) mtrInputAction {\n\tswitch p.state {\n\tcase mtrStateGround:\n\t\treturn p.feedGround(b)\n\tcase mtrStateEsc:\n\t\treturn p.feedEsc(b)\n\tcase mtrStateCSI:\n\t\treturn p.feedCSI(b)\n\tcase mtrStateSS3:\n\t\treturn p.feedSS3()\n\tcase mtrStateOSC:\n\t\treturn p.feedOSC(b)\n\tcase mtrStateX10Mouse:\n\t\treturn p.feedX10Mouse()\n\tcase mtrStateSGRMouse:\n\t\treturn p.feedSGRMouse(b)\n\tdefault:\n\t\tp.state = mtrStateGround\n\t\treturn mtrActionNone\n\t}\n}\n\nfunc (p *mtrInputParser) feedGround(b byte) mtrInputAction {\n\tif b == 0x1B {\n\t\tp.state = mtrStateEsc\n\t\treturn mtrActionNone\n\t}\n\treturn mapKeyToAction(b)\n}\n\nfunc (p *mtrInputParser) feedEsc(b byte) mtrInputAction {\n\tswitch b {\n\tcase '[':\n\t\tp.state = mtrStateCSI\n\t\tp.csiN = 0\n\tcase 'O':\n\t\tp.state = mtrStateSS3\n\tcase ']':\n\t\tp.state = mtrStateOSC\n\tdefault:\n\t\tp.state = mtrStateGround\n\t}\n\treturn mtrActionNone\n}\n\nfunc (p *mtrInputParser) feedCSI(b byte) mtrInputAction {\n\tp.csiN++\n\tswitch {\n\tcase b == 'M':\n\t\tp.state = mtrStateX10Mouse\n\t\tp.csiN = 0\n\tcase b == '<':\n\t\tp.state = mtrStateSGRMouse\n\tcase b == 'I' || b == 'O':\n\t\tp.state = mtrStateGround\n\tcase b >= 0x40 && b <= 0x7E:\n\t\tp.state = mtrStateGround\n\tcase p.csiN > mtrParserMaxCSI:\n\t\tp.state = mtrStateGround\n\t}\n\treturn mtrActionNone\n}\n\nfunc (p *mtrInputParser) feedSS3() mtrInputAction {\n\tp.state = mtrStateGround\n\treturn mtrActionNone\n}\n\nfunc (p *mtrInputParser) feedOSC(b byte) mtrInputAction {\n\tswitch b {\n\tcase 0x07:\n\t\tp.state = mtrStateGround\n\tcase 0x1B:\n\t\tp.state = mtrStateEsc\n\t}\n\treturn mtrActionNone\n}\n\nfunc (p *mtrInputParser) feedX10Mouse() mtrInputAction {\n\tp.csiN++\n\tif p.csiN >= 3 {\n\t\tp.state = mtrStateGround\n\t}\n\treturn mtrActionNone\n}\n\nfunc (p *mtrInputParser) feedSGRMouse(b byte) mtrInputAction {\n\tif b == 'M' || b == 'm' {\n\t\tp.state = mtrStateGround\n\t}\n\treturn mtrActionNone\n}\n\n// mapKeyToAction 将普通单字节映射为动作。\nfunc mapKeyToAction(b byte) mtrInputAction {\n\tswitch b {\n\tcase 'q', 'Q', 0x03: // q / Q / Ctrl-C\n\t\treturn mtrActionQuit\n\tcase 'p', 'P':\n\t\treturn mtrActionPause\n\tcase ' ':\n\t\treturn mtrActionResume\n\tcase 'r', 'R':\n\t\treturn mtrActionRestart\n\tcase 'y', 'Y':\n\t\treturn mtrActionDisplayMode\n\tcase 'n', 'N':\n\t\treturn mtrActionNameToggle\n\tcase 'e', 'E':\n\t\treturn mtrActionMPLSToggle\n\tdefault:\n\t\treturn mtrActionNone\n\t}\n}\n\n// ReadKeysLoop 在独立 goroutine 中读按键：\n//\n//\tq / Q → 退出（调用 cancel）\n//\tp     → 暂停\n//\t空格  → 恢复\n//\tr     → 重置统计\n//\ty     → 切换显示模式\n//\tn     → 切换 Host 显示\n//\te     → 切换 MPLS 显示\n//\n// 使用 mtrInputParser 字节流状态机解析输入，\n// 自动吞掉 CSI/SS3/OSC/鼠标/焦点等转义序列。\n// 当 ctx 结束或 stdin 关闭时自动退出。非 TTY 时立即返回。\nfunc (u *mtrUI) ReadKeysLoop(ctx context.Context) {\n\tif !u.isTTY {\n\t\treturn\n\t}\n\tvar parser mtrInputParser\n\tbuf := make([]byte, 64) // 批量读取，减少 syscall\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\t\tn, err := os.Stdin.Read(buf)\n\t\tif err != nil || n == 0 {\n\t\t\tif err == io.EOF {\n\t\t\t\treturn\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tfor i := 0; i < n; i++ {\n\t\t\taction := parser.Feed(buf[i])\n\t\t\tswitch action {\n\t\t\tcase mtrActionQuit:\n\t\t\t\tif u.cancel != nil {\n\t\t\t\t\tu.cancel()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\tcase mtrActionPause:\n\t\t\t\tatomic.StoreInt32(&u.paused, 1)\n\t\t\tcase mtrActionResume:\n\t\t\t\tatomic.StoreInt32(&u.paused, 0)\n\t\t\tcase mtrActionRestart:\n\t\t\t\tatomic.StoreInt32(&u.restartReq, 1)\n\t\t\tcase mtrActionDisplayMode:\n\t\t\t\tu.CycleDisplayMode()\n\t\t\tcase mtrActionNameToggle:\n\t\t\t\tu.ToggleNameMode()\n\t\t\tcase mtrActionMPLSToggle:\n\t\t\t\tu.ToggleMPLS()\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ConsumeRestartRequest 原子读取并清除重置请求标志。\n// 返回 true 表示请求了重置统计。\nfunc (u *mtrUI) ConsumeRestartRequest() bool {\n\treturn atomic.SwapInt32(&u.restartReq, 0) == 1\n}\n\n// ParseMTRKey 将单字节解析为操作名称（用于测试）。\n// 返回值: \"quit\", \"pause\", \"resume\", \"restart\", \"display_mode\", \"name_toggle\", \"\" (未知)。\nfunc ParseMTRKey(b byte) string {\n\tswitch b {\n\tcase 'q', 'Q', 0x03:\n\t\treturn \"quit\"\n\tcase 'p', 'P':\n\t\treturn \"pause\"\n\tcase ' ':\n\t\treturn \"resume\"\n\tcase 'r', 'R':\n\t\treturn \"restart\"\n\tcase 'y', 'Y':\n\t\treturn \"display_mode\"\n\tcase 'n', 'N':\n\t\treturn \"name_toggle\"\n\tcase 'e', 'E':\n\t\treturn \"mpls_toggle\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n"
  },
  {
    "path": "cmd/mtu_mode.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/printer\"\n\tmtutrace \"github.com/nxtrace/NTrace-core/trace/mtu\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype mtuConflictFlag struct {\n\tflag    string\n\tenabled bool\n}\n\nfunc checkMTUConflicts(flags []mtuConflictFlag) (string, bool) {\n\tfor _, flag := range flags {\n\t\tif flag.enabled {\n\t\t\treturn flag.flag, false\n\t\t}\n\t}\n\treturn \"\", true\n}\n\nfunc normalizeMTUProtocolFlags(tcp, udp *bool) error {\n\tif tcp != nil && *tcp {\n\t\treturn errors.New(\"--mtu 仅支持 UDP，请移除 --tcp\")\n\t}\n\tif udp != nil {\n\t\t*udp = true\n\t}\n\treturn nil\n}\n\nfunc buildMTUConflictFlags(\n\ttcp, rawPrint bool,\n\tmtrModes effectiveMTRModes,\n\ttablePrint, classicPrint, routePath, outputPath, outputDefault, deploy bool,\n\tglobalping bool,\n\tfrom, file string,\n\tfastTrace bool,\n) []mtuConflictFlag {\n\treturn []mtuConflictFlag{\n\t\t{flag: \"--tcp\", enabled: tcp},\n\t\t{flag: \"--mtr\", enabled: mtrModes.mtr},\n\t\t{flag: \"--raw\", enabled: rawPrint},\n\t\t{flag: \"--table\", enabled: tablePrint},\n\t\t{flag: \"--classic\", enabled: classicPrint},\n\t\t{flag: \"--route-path\", enabled: routePath},\n\t\t{flag: \"--output\", enabled: outputPath},\n\t\t{flag: \"--output-default\", enabled: outputDefault},\n\t\t{flag: \"--from\", enabled: globalping && from != \"\"},\n\t\t{flag: \"--fast-trace\", enabled: fastTrace},\n\t\t{flag: \"--file\", enabled: file != \"\"},\n\t\t{flag: \"--deploy\", enabled: deploy},\n\t}\n}\n\nfunc resolveMTUSourceIP(dstIP net.IP, srcAddr string) (net.IP, error) {\n\tif trimmed := strings.TrimSpace(srcAddr); trimmed != \"\" {\n\t\tsrcIP := net.ParseIP(trimmed)\n\t\tif srcIP == nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid source IP %q\", srcAddr)\n\t\t}\n\t\tif util.IsIPv6(dstIP) {\n\t\t\tif !util.IsIPv6(srcIP) {\n\t\t\t\treturn nil, fmt.Errorf(\"source IP %q does not match IPv6 destination %s\", srcAddr, dstIP)\n\t\t\t}\n\t\t\treturn srcIP, nil\n\t\t}\n\t\tif srcIP.To4() == nil {\n\t\t\treturn nil, fmt.Errorf(\"source IP %q does not match IPv4 destination %s\", srcAddr, dstIP)\n\t\t}\n\t\treturn srcIP.To4(), nil\n\t}\n\n\tif util.IsIPv6(dstIP) {\n\t\tresolved, _ := util.LocalIPPortv6(dstIP, nil, \"udp6\")\n\t\tif resolved == nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to determine IPv6 source address for %s\", dstIP)\n\t\t}\n\t\treturn resolved, nil\n\t}\n\n\tresolved, _ := util.LocalIPPort(dstIP, nil, \"udp\")\n\tif resolved == nil {\n\t\treturn nil, fmt.Errorf(\"unable to determine IPv4 source address for %s\", dstIP)\n\t}\n\treturn resolved, nil\n}\n\nfunc runStandaloneMTUMode(cfg mtutrace.Config, jsonPrint bool) error {\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)\n\tdefer stop()\n\n\tif jsonPrint {\n\t\tresult, err := mtutrace.Run(ctx, cfg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tencoded, err := json.Marshal(result)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfmt.Println(string(encoded))\n\t\treturn nil\n\t}\n\n\tstreamCtx, cancel := context.WithCancel(ctx)\n\tdefer cancel()\n\n\trenderer := newMTUStreamRenderer(os.Stdout, CheckTTY(int(os.Stdout.Fd())))\n\tvar renderErr error\n\t_, err := mtutrace.RunStream(streamCtx, cfg, func(event mtutrace.StreamEvent) {\n\t\tif renderErr != nil {\n\t\t\treturn\n\t\t}\n\t\tif err := renderer.Render(event); err != nil {\n\t\t\trenderErr = err\n\t\t\tcancel()\n\t\t}\n\t})\n\tif renderErr != nil {\n\t\treturn renderErr\n\t}\n\treturn err\n}\n\nfunc printMTUResult(w io.Writer, result *mtutrace.Result) error {\n\treturn printMTUResultWithStyle(w, result, newMTUTextStyle(false))\n}\n\nfunc printMTUResultWithStyle(w io.Writer, result *mtutrace.Result, style mtuTextStyle) error {\n\tif result == nil {\n\t\treturn errors.New(\"nil mtu result\")\n\t}\n\tif err := printMTUHeader(w, result.Target, result.ResolvedIP, result.StartMTU, result.ProbeSize, style); err != nil {\n\t\treturn err\n\t}\n\tfor _, hop := range result.Hops {\n\t\tif _, err := fmt.Fprintln(w, formatMTUHopLineWithStyle(hop, style)); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn printMTUSummary(w, result.PathMTU, style)\n}\n\nfunc formatMTUHopLine(hop mtutrace.Hop) string {\n\treturn formatMTUHopLineWithStyle(hop, newMTUTextStyle(false))\n}\n\nfunc formatMTUHopLineWithStyle(hop mtutrace.Hop, style mtuTextStyle) string {\n\tif hop.Event == mtutrace.EventTimeout {\n\t\tline := fmt.Sprintf(\"%s  %s\", style.ttl(hop.TTL), style.timeout())\n\t\tif hop.PMTU > 0 {\n\t\t\tline += \"  \" + style.pmtu(hop.PMTU)\n\t\t}\n\t\treturn line\n\t}\n\n\ttarget := hop.IP\n\tif hop.Hostname != \"\" {\n\t\ttarget = fmt.Sprintf(\"%s (%s)\", hop.Hostname, hop.IP)\n\t}\n\tline := fmt.Sprintf(\"%s  %s\", style.ttl(hop.TTL), style.hopTarget(hop.Event, target))\n\tif hop.RTTMs > 0 {\n\t\tline += fmt.Sprintf(\"  %.2fms\", hop.RTTMs)\n\t}\n\tif hop.PMTU > 0 {\n\t\tline += \"  \" + style.pmtu(hop.PMTU)\n\t}\n\tif geo := formatMTUGeo(hop); geo != \"\" {\n\t\tline += \"  \" + geo\n\t}\n\treturn line\n}\n\nfunc formatMTUHopSnapshot(event mtutrace.StreamEvent) string {\n\treturn formatMTUHopSnapshotWithStyle(event, newMTUTextStyle(false))\n}\n\nfunc formatMTUHopSnapshotWithStyle(event mtutrace.StreamEvent, style mtuTextStyle) string {\n\tif event.Kind == mtutrace.StreamEventTTLStart {\n\t\treturn fmt.Sprintf(\"%s  %s\", style.ttl(event.TTL), style.placeholder())\n\t}\n\treturn formatMTUHopLineWithStyle(event.Hop, style)\n}\n\nfunc printMTUHeader(w io.Writer, target, resolvedIP string, startMTU, probeSize int, style mtuTextStyle) error {\n\t_, err := fmt.Fprintln(w, style.header(fmt.Sprintf(\"tracepath to %s (%s), start MTU %d, %d byte packets\",\n\t\ttarget, resolvedIP, startMTU, probeSize)))\n\treturn err\n}\n\nfunc printMTUSummary(w io.Writer, pathMTU int, style mtuTextStyle) error {\n\t_, err := fmt.Fprintln(w, style.summary(pathMTU))\n\treturn err\n}\n\ntype mtuStreamRenderer struct {\n\tw             io.Writer\n\tisTTY         bool\n\tstyle         mtuTextStyle\n\theaderPrinted bool\n\tlineActive    bool\n}\n\nfunc newMTUStreamRenderer(w io.Writer, isTTY bool) *mtuStreamRenderer {\n\treturn &mtuStreamRenderer{\n\t\tw:     w,\n\t\tisTTY: isTTY,\n\t\tstyle: newMTUTextStyle(isTTY && !color.NoColor),\n\t}\n}\n\nfunc (r *mtuStreamRenderer) Render(event mtutrace.StreamEvent) error {\n\tif err := r.ensureHeader(event); err != nil {\n\t\treturn err\n\t}\n\n\tswitch event.Kind {\n\tcase mtutrace.StreamEventTTLStart:\n\t\tif !r.isTTY {\n\t\t\treturn nil\n\t\t}\n\t\treturn r.renderTTYLine(formatMTUHopSnapshotWithStyle(event, r.style), false)\n\tcase mtutrace.StreamEventTTLUpdate:\n\t\tif !r.isTTY {\n\t\t\treturn nil\n\t\t}\n\t\treturn r.renderTTYLine(formatMTUHopSnapshotWithStyle(event, r.style), false)\n\tcase mtutrace.StreamEventTTLFinal:\n\t\tline := formatMTUHopSnapshotWithStyle(event, r.style)\n\t\tif r.isTTY {\n\t\t\treturn r.renderTTYLine(line, true)\n\t\t}\n\t\t_, err := fmt.Fprintln(r.w, line)\n\t\treturn err\n\tcase mtutrace.StreamEventDone:\n\t\tif r.isTTY && r.lineActive {\n\t\t\tif _, err := io.WriteString(r.w, \"\\n\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tr.lineActive = false\n\t\t}\n\t\treturn printMTUSummary(r.w, event.PathMTU, r.style)\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc (r *mtuStreamRenderer) ensureHeader(event mtutrace.StreamEvent) error {\n\tif r.headerPrinted {\n\t\treturn nil\n\t}\n\tif event.Target == \"\" || event.ResolvedIP == \"\" {\n\t\treturn nil\n\t}\n\tif err := printMTUHeader(r.w, event.Target, event.ResolvedIP, event.StartMTU, event.ProbeSize, r.style); err != nil {\n\t\treturn err\n\t}\n\tr.headerPrinted = true\n\treturn nil\n}\n\nfunc (r *mtuStreamRenderer) renderTTYLine(line string, final bool) error {\n\tif _, err := fmt.Fprintf(r.w, \"\\r\\033[2K%s\", line); err != nil {\n\t\treturn err\n\t}\n\tr.lineActive = !final\n\tif !final {\n\t\treturn nil\n\t}\n\t_, err := io.WriteString(r.w, \"\\n\")\n\treturn err\n}\n\ntype mtuTextStyle struct {\n\tenabled bool\n}\n\nfunc newMTUTextStyle(enabled bool) mtuTextStyle {\n\treturn mtuTextStyle{enabled: enabled}\n}\n\nfunc (s mtuTextStyle) apply(text string, attrs ...color.Attribute) string {\n\tif !s.enabled {\n\t\treturn text\n\t}\n\treturn color.New(attrs...).Sprint(text)\n}\n\nfunc (s mtuTextStyle) header(text string) string {\n\treturn s.apply(text, color.FgCyan, color.Bold)\n}\n\nfunc (s mtuTextStyle) ttl(ttl int) string {\n\treturn s.apply(fmt.Sprintf(\"%2d\", ttl), color.Faint)\n}\n\nfunc (s mtuTextStyle) placeholder() string {\n\treturn s.apply(\"...\", color.FgHiBlack)\n}\n\nfunc (s mtuTextStyle) timeout() string {\n\treturn s.apply(\"*\", color.FgRed, color.Bold)\n}\n\nfunc (s mtuTextStyle) hopTarget(event mtutrace.Event, target string) string {\n\tswitch event {\n\tcase mtutrace.EventDestination:\n\t\treturn s.apply(target, color.FgGreen, color.Bold)\n\tdefault:\n\t\treturn s.apply(target, color.FgYellow)\n\t}\n}\n\nfunc (s mtuTextStyle) pmtu(pmtu int) string {\n\treturn s.apply(fmt.Sprintf(\"pmtu %d\", pmtu), color.FgCyan, color.Bold)\n}\n\nfunc (s mtuTextStyle) summary(pathMTU int) string {\n\treturn s.apply(fmt.Sprintf(\"Path MTU: %d\", pathMTU), color.FgGreen, color.Bold)\n}\n\nfunc buildMTUTraceConfig(\n\ttarget string,\n\tdstIP net.IP,\n\tsrcIP net.IP,\n\tsrcDev string,\n\tsrcPort int,\n\tdstPort int,\n\tbeginHop int,\n\tmaxHops int,\n\tqueries int,\n\ttimeoutMs int,\n\tttlIntervalMs int,\n\trdns bool,\n\talwaysWaitRDNS bool,\n\tgeoSource ipgeo.Source,\n\tlang string,\n) mtutrace.Config {\n\treturn mtutrace.Config{\n\t\tTarget:         target,\n\t\tDstIP:          dstIP,\n\t\tSrcIP:          srcIP,\n\t\tSourceDevice:   srcDev,\n\t\tSrcPort:        srcPort,\n\t\tDstPort:        dstPort,\n\t\tBeginHop:       beginHop,\n\t\tMaxHops:        maxHops,\n\t\tQueries:        queries,\n\t\tTimeout:        time.Duration(timeoutMs) * time.Millisecond,\n\t\tTTLInterval:    time.Duration(ttlIntervalMs) * time.Millisecond,\n\t\tRDNS:           rdns,\n\t\tAlwaysWaitRDNS: alwaysWaitRDNS,\n\t\tIPGeoSource:    geoSource,\n\t\tLang:           lang,\n\t}\n}\n\nfunc formatMTUGeo(hop mtutrace.Hop) string {\n\tif hop.Geo == nil || hop.IP == \"\" {\n\t\treturn \"\"\n\t}\n\tif hop.Geo.Asnumber == \"\" &&\n\t\thop.Geo.Country == \"\" &&\n\t\thop.Geo.CountryEn == \"\" &&\n\t\thop.Geo.Prov == \"\" &&\n\t\thop.Geo.ProvEn == \"\" &&\n\t\thop.Geo.City == \"\" &&\n\t\thop.Geo.CityEn == \"\" &&\n\t\thop.Geo.District == \"\" &&\n\t\thop.Geo.Owner == \"\" &&\n\t\thop.Geo.Isp == \"\" &&\n\t\thop.Geo.Whois == \"\" {\n\t\treturn \"\"\n\t}\n\treturn printer.FormatIPGeoData(hop.IP, hop.Geo)\n}\n"
  },
  {
    "path": "cmd/mtu_mode_test.go",
    "content": "package cmd\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\tmtutrace \"github.com/nxtrace/NTrace-core/trace/mtu\"\n)\n\nfunc TestNormalizeMTUProtocolFlagsAutoEnablesUDP(t *testing.T) {\n\ttcp := false\n\tudp := false\n\tif err := normalizeMTUProtocolFlags(&tcp, &udp); err != nil {\n\t\tt.Fatalf(\"normalizeMTUProtocolFlags returned error: %v\", err)\n\t}\n\tif !udp {\n\t\tt.Fatal(\"udp should be enabled in mtu mode\")\n\t}\n}\n\nfunc TestNormalizeMTUProtocolFlagsRejectsTCP(t *testing.T) {\n\ttcp := true\n\tudp := false\n\tif err := normalizeMTUProtocolFlags(&tcp, &udp); err == nil {\n\t\tt.Fatal(\"expected tcp to be rejected in mtu mode\")\n\t}\n}\n\nfunc TestCheckMTUConflicts(t *testing.T) {\n\tconflict, ok := checkMTUConflicts([]mtuConflictFlag{\n\t\t{flag: \"--table\", enabled: true},\n\t\t{flag: \"--from\", enabled: true},\n\t})\n\tif ok {\n\t\tt.Fatal(\"expected mtu conflict\")\n\t}\n\tif conflict != \"--table\" {\n\t\tt.Fatalf(\"conflict = %q, want --table\", conflict)\n\t}\n}\n\nfunc TestBuildMTUConflictFlagsIncludesOutputDefault(t *testing.T) {\n\tflags := buildMTUConflictFlags(false, false, effectiveMTRModes{}, false, false, false, false, true, false, false, \"\", \"\", false)\n\tconflict, ok := checkMTUConflicts(flags)\n\tif ok {\n\t\tt.Fatal(\"expected mtu conflict\")\n\t}\n\tif conflict != \"--output-default\" {\n\t\tt.Fatalf(\"conflict = %q, want --output-default\", conflict)\n\t}\n}\n\nfunc TestPrintMTUResultIncludesPMTUAndSummary(t *testing.T) {\n\tvar buf bytes.Buffer\n\tres := &mtutrace.Result{\n\t\tTarget:     \"example.com\",\n\t\tResolvedIP: \"203.0.113.9\",\n\t\tStartMTU:   1500,\n\t\tProbeSize:  65000,\n\t\tPathMTU:    1400,\n\t\tHops: []mtutrace.Hop{\n\t\t\t{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\"}},\n\t\t\t{TTL: 2, Event: mtutrace.EventTimeout},\n\t\t},\n\t}\n\tif err := printMTUResult(&buf, res); err != nil {\n\t\tt.Fatalf(\"printMTUResult returned error: %v\", err)\n\t}\n\toutput := buf.String()\n\tfor _, want := range []string{\n\t\t\"tracepath to example.com (203.0.113.9), start MTU 1500, 65000 byte packets\",\n\t\t\"pmtu 1400\",\n\t\t\"AS13335\",\n\t\t\"Cloudflare\",\n\t\t\"Path MTU: 1400\",\n\t\t\" 2  *\",\n\t} {\n\t\tif !strings.Contains(output, want) {\n\t\t\tt.Fatalf(\"output missing %q:\\n%s\", want, output)\n\t\t}\n\t}\n}\n\nfunc TestFormatMTUHopSnapshotStartPlaceholder(t *testing.T) {\n\tline := formatMTUHopSnapshot(mtutrace.StreamEvent{\n\t\tKind: mtutrace.StreamEventTTLStart,\n\t\tTTL:  3,\n\t})\n\tif line != \" 3  ...\" {\n\t\tt.Fatalf(\"line = %q, want %q\", line, \" 3  ...\")\n\t}\n}\n\nfunc TestMTUStreamRendererTTYRewritesCurrentLine(t *testing.T) {\n\tprevNoColor := color.NoColor\n\tcolor.NoColor = true\n\tdefer func() { color.NoColor = prevNoColor }()\n\n\tvar buf bytes.Buffer\n\trenderer := newMTUStreamRenderer(&buf, true)\n\tevents := []mtutrace.StreamEvent{\n\t\t{\n\t\t\tKind:       mtutrace.StreamEventTTLStart,\n\t\t\tTTL:        1,\n\t\t\tTarget:     \"example.com\",\n\t\t\tResolvedIP: \"203.0.113.9\",\n\t\t\tStartMTU:   1500,\n\t\t\tProbeSize:  65000,\n\t\t},\n\t\t{\n\t\t\tKind: mtutrace.StreamEventTTLUpdate,\n\t\t\tTTL:  1,\n\t\t\tHop:  mtutrace.Hop{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: \"192.0.2.1\", RTTMs: 12.5, PMTU: 1500},\n\t\t},\n\t\t{\n\t\t\tKind: mtutrace.StreamEventTTLUpdate,\n\t\t\tTTL:  1,\n\t\t\tHop:  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\"}},\n\t\t},\n\t\t{\n\t\t\tKind: mtutrace.StreamEventTTLFinal,\n\t\t\tTTL:  1,\n\t\t\tHop:  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\"}},\n\t\t},\n\t\t{\n\t\t\tKind:    mtutrace.StreamEventDone,\n\t\t\tPathMTU: 1500,\n\t\t},\n\t}\n\n\tfor _, event := range events {\n\t\tif err := renderer.Render(event); err != nil {\n\t\t\tt.Fatalf(\"Render returned error: %v\", err)\n\t\t}\n\t}\n\n\toutput := buf.String()\n\tfor _, want := range []string{\n\t\t\"tracepath to example.com (203.0.113.9), start MTU 1500, 65000 byte packets\\n\",\n\t\t\"\\r\\x1b[2K 1  ...\",\n\t\t\"\\r\\x1b[2K 1  192.0.2.1  12.50ms  pmtu 1500  AS13335\",\n\t\t\"Cloudflare\\n\",\n\t\t\"Path MTU: 1500\\n\",\n\t} {\n\t\tif !strings.Contains(output, want) {\n\t\t\tt.Fatalf(\"output missing %q:\\n%q\", want, output)\n\t\t}\n\t}\n}\n\nfunc TestMTUStreamRendererTTYAppliesColorsWhenEnabled(t *testing.T) {\n\tprevNoColor := color.NoColor\n\tcolor.NoColor = false\n\tdefer func() { color.NoColor = prevNoColor }()\n\n\tvar buf bytes.Buffer\n\trenderer := newMTUStreamRenderer(&buf, true)\n\tevents := []mtutrace.StreamEvent{\n\t\t{\n\t\t\tKind:       mtutrace.StreamEventTTLStart,\n\t\t\tTTL:        1,\n\t\t\tTarget:     \"example.com\",\n\t\t\tResolvedIP: \"203.0.113.9\",\n\t\t\tStartMTU:   1500,\n\t\t\tProbeSize:  65000,\n\t\t},\n\t\t{\n\t\t\tKind: mtutrace.StreamEventTTLFinal,\n\t\t\tTTL:  1,\n\t\t\tHop:  mtutrace.Hop{TTL: 1, Event: mtutrace.EventDestination, IP: \"203.0.113.9\", RTTMs: 10.5, PMTU: 1480},\n\t\t},\n\t\t{\n\t\t\tKind:    mtutrace.StreamEventDone,\n\t\t\tPathMTU: 1480,\n\t\t},\n\t}\n\n\tfor _, event := range events {\n\t\tif err := renderer.Render(event); err != nil {\n\t\t\tt.Fatalf(\"Render returned error: %v\", err)\n\t\t}\n\t}\n\n\toutput := buf.String()\n\tif !strings.Contains(output, \"\\x1b[\") {\n\t\tt.Fatalf(\"output should contain ANSI color codes:\\n%q\", output)\n\t}\n\tfor _, want := range []string{\"tracepath to example.com\", \"pmtu 1480\", \"Path MTU: 1480\"} {\n\t\tif !strings.Contains(output, want) {\n\t\t\tt.Fatalf(\"output missing %q:\\n%q\", want, output)\n\t\t}\n\t}\n}\n\nfunc TestMTUStreamRendererNonTTYPrintsOnlyFinalLines(t *testing.T) {\n\tvar buf bytes.Buffer\n\trenderer := newMTUStreamRenderer(&buf, false)\n\tevents := []mtutrace.StreamEvent{\n\t\t{\n\t\t\tKind:       mtutrace.StreamEventTTLStart,\n\t\t\tTTL:        1,\n\t\t\tTarget:     \"example.com\",\n\t\t\tResolvedIP: \"203.0.113.9\",\n\t\t\tStartMTU:   1500,\n\t\t\tProbeSize:  65000,\n\t\t},\n\t\t{\n\t\t\tKind: mtutrace.StreamEventTTLUpdate,\n\t\t\tTTL:  1,\n\t\t\tHop:  mtutrace.Hop{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: \"192.0.2.1\", RTTMs: 12.5, PMTU: 1500},\n\t\t},\n\t\t{\n\t\t\tKind: mtutrace.StreamEventTTLFinal,\n\t\t\tTTL:  1,\n\t\t\tHop:  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\"}},\n\t\t},\n\t\t{\n\t\t\tKind:    mtutrace.StreamEventDone,\n\t\t\tPathMTU: 1500,\n\t\t},\n\t}\n\n\tfor _, event := range events {\n\t\tif err := renderer.Render(event); err != nil {\n\t\t\tt.Fatalf(\"Render returned error: %v\", err)\n\t\t}\n\t}\n\n\toutput := buf.String()\n\tfor _, unwanted := range []string{\"\\x1b[2K\", \" 1  ...\"} {\n\t\tif strings.Contains(output, unwanted) {\n\t\t\tt.Fatalf(\"output should not contain %q:\\n%q\", unwanted, output)\n\t\t}\n\t}\n\tfor _, want := range []string{\n\t\t\"tracepath to example.com (203.0.113.9), start MTU 1500, 65000 byte packets\\n\",\n\t\t\" 1  192.0.2.1  12.50ms  pmtu 1500  AS13335\",\n\t\t\"Cloudflare\\n\",\n\t\t\"Path MTU: 1500\\n\",\n\t} {\n\t\tif !strings.Contains(output, want) {\n\t\t\tt.Fatalf(\"output missing %q:\\n%q\", want, output)\n\t\t}\n\t}\n}\n\nfunc TestMTUResultJSONIncludesGeo(t *testing.T) {\n\tres := &mtutrace.Result{\n\t\tTarget:     \"example.com\",\n\t\tResolvedIP: \"203.0.113.9\",\n\t\tStartMTU:   1500,\n\t\tProbeSize:  65000,\n\t\tPathMTU:    1400,\n\t\tHops: []mtutrace.Hop{\n\t\t\t{TTL: 1, Event: mtutrace.EventTimeExceeded, IP: \"192.0.2.1\", Geo: &ipgeo.IPGeoData{Country: \"中国香港\", Owner: \"Cloudflare\"}},\n\t\t},\n\t}\n\n\tencoded, err := json.Marshal(res)\n\tif err != nil {\n\t\tt.Fatalf(\"Marshal returned error: %v\", err)\n\t}\n\toutput := string(encoded)\n\tfor _, want := range []string{`\"geo\":`, `\"country\":\"中国香港\"`, `\"owner\":\"Cloudflare\"`} {\n\t\tif !strings.Contains(output, want) {\n\t\t\tt.Fatalf(\"json output missing %q:\\n%s\", want, output)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "config/basic.go",
    "content": "package config\n\nvar Version = \"v0.0.0.alpha\"\nvar BuildDate = \"\"\nvar CommitID = \"\"\n"
  },
  {
    "path": "config/viper.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\n\t\"github.com/spf13/viper\"\n)\n\nfunc InitConfig() {\n\t// 配置文件名， 不加扩展\n\tviper.SetConfigName(\"nt_config\") // name of config file (without extension)\n\t// 设置文件的扩展名\n\tviper.SetConfigType(\"yaml\") // REQUIRED if the config file does not have the extension in the name\n\t// 查找配置文件所在路径\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\thomeDir = \"\"\n\t}\n\n\txdgConfigHome := os.Getenv(\"XDG_CONFIG_HOME\")\n\tif xdgConfigHome == \"\" && homeDir != \"\" {\n\t\txdgConfigHome = filepath.Join(homeDir, \".config\")\n\t}\n\n\tconfigPaths := []string{\n\t\t\"/etc/nexttrace\",\n\t\t\"/usr/local/etc/nexttrace\",\n\t}\n\n\tif runtime.GOOS == \"darwin\" {\n\t\tconfigPaths = append(configPaths, \"/opt/homebrew/etc/nexttrace\")\n\t}\n\n\tif xdgConfigHome != \"\" {\n\t\tconfigPaths = append(configPaths, filepath.Join(xdgConfigHome, \"nexttrace\"))\n\t}\n\n\tif homeDir != \"\" {\n\t\tconfigPaths = append(configPaths,\n\t\t\tfilepath.Join(homeDir, \".local\", \"share\", \"nexttrace\"),\n\t\t\tfilepath.Join(homeDir, \".nexttrace\"),\n\t\t\tfilepath.Join(homeDir, \"nexttrace\"),\n\t\t\thomeDir,\n\t\t)\n\t}\n\n\tconfigPaths = append(configPaths,\n\t\t\"/usr/share/nexttrace\",\n\t\t\"/usr/local/share/nexttrace\",\n\t\t\".\",\n\t)\n\n\tfor _, path := range configPaths {\n\t\tviper.AddConfigPath(path)\n\t}\n\n\t// 配置默认值\n\tviper.SetDefault(\"ptrPath\", \"./ptr.csv\")\n\tviper.SetDefault(\"geoFeedPath\", \"./geofeed.csv\")\n\n\t// 开始查找并读取配置文件\n\tif err := viper.ReadInConfig(); err != nil {\n\t\tvar notFound viper.ConfigFileNotFoundError\n\t\tif errors.As(err, &notFound) {\n\t\t\tfmt.Println(\"未能找到配置文件，我们将在您的运行目录为您创建 nt_config.yaml 默认配置\")\n\t\t\tif err := viper.SafeWriteConfigAs(\"./nt_config.yaml\"); err != nil {\n\t\t\t\tfmt.Println(\"创建默认配置文件失败:\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := viper.ReadInConfig(); err != nil {\n\t\t\t\tfmt.Println(\"加载默认配置失败:\", err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tfmt.Println(\"加载配置文件失败:\", err)\n\t\treturn\n\t}\n}\n"
  },
  {
    "path": "dn42/dn42.go",
    "content": "package dn42\n\n/***\n\t[DN42 Package]\n\t\t谨献给 DN42 所有的小伙伴们，祝你们终有一天能有自己的公网 ASN ~\n\t\tBy Leo\n***/\n"
  },
  {
    "path": "dn42/geofeed.go",
    "content": "package dn42\n\nimport (\n\t\"encoding/csv\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"sort\"\n\n\t\"github.com/spf13/viper\"\n)\n\ntype GeoFeedRow struct {\n\tIPNet   *net.IPNet\n\tCIDR    string\n\tLtdCode string\n\tISO3166 string\n\tCity    string\n\tASN     string\n\tIPWhois string\n}\n\nfunc GetGeoFeed(ip string) (GeoFeedRow, bool) {\n\trows, err := ReadGeoFeed()\n\tif err != nil {\n\t\t// 无法加载 geofeed 数据，返回未找到\n\t\treturn GeoFeedRow{}, false\n\t}\n\n\trow, find := FindGeoFeedRow(ip, rows)\n\treturn row, find\n\n}\n\nfunc ReadGeoFeed() ([]GeoFeedRow, error) {\n\tpath := viper.GetString(\"geoFeedPath\")\n\tif path == \"\" {\n\t\treturn nil, fmt.Errorf(\"geoFeedPath not configured\")\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer f.Close()\n\n\tr := csv.NewReader(f)\n\trows, err := r.ReadAll()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 将 CSV 中的每一行转换为 GeoFeedRow 类型，并保存到 rowsSlice 中\n\tvar rowsSlice []GeoFeedRow\n\tfor _, row := range rows {\n\t\tcidr := row[0] // 假设第一列是 CIDR 字段\n\t\t_, ipnet, err := net.ParseCIDR(cidr)\n\t\tif err != nil {\n\t\t\t// 如果解析 CIDR 失败，跳过这一行\n\t\t\tcontinue\n\t\t}\n\t\tif len(row) == 4 {\n\t\t\trowsSlice = append(rowsSlice, GeoFeedRow{\n\t\t\t\tIPNet:   ipnet,\n\t\t\t\tCIDR:    cidr,\n\t\t\t\tLtdCode: row[1],\n\t\t\t\tISO3166: row[2],\n\t\t\t\tCity:    row[3],\n\t\t\t})\n\t\t} else if len(row) >= 6 {\n\t\t\trowsSlice = append(rowsSlice, GeoFeedRow{\n\t\t\t\tIPNet:   ipnet,\n\t\t\t\tCIDR:    cidr,\n\t\t\t\tLtdCode: row[1],\n\t\t\t\tISO3166: row[2],\n\t\t\t\tCity:    row[3],\n\t\t\t\tASN:     row[4],\n\t\t\t\tIPWhois: row[5],\n\t\t\t})\n\t\t}\n\n\t}\n\t// 根据 CIDR 范围从小到大排序，方便后面查找\n\tsort.Slice(rowsSlice, func(i, j int) bool {\n\t\treturn rowsSlice[i].IPNet.Mask.String() > rowsSlice[j].IPNet.Mask.String()\n\t})\n\n\treturn rowsSlice, nil\n}\n\nfunc FindGeoFeedRow(ipStr string, rows []GeoFeedRow) (GeoFeedRow, bool) {\n\tip := net.ParseIP(ipStr)\n\tif ip == nil {\n\t\t// 如果传入的 IP 无效，直接返回\n\t\treturn GeoFeedRow{}, false\n\t}\n\n\t// 遍历每个 CIDR 范围，找到第一个包含传入的 IP 的 CIDR\n\tfor _, row := range rows {\n\t\tif row.IPNet.Contains(ip) {\n\t\t\treturn row, true\n\t\t}\n\t}\n\n\treturn GeoFeedRow{}, false\n}\n"
  },
  {
    "path": "dn42/geofeed_test.go",
    "content": "package dn42\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFindGeoFeedRowInvalidIP(t *testing.T) {\n\trow, found := FindGeoFeedRow(\"not-an-ip\", nil)\n\tassert.False(t, found)\n\tassert.Equal(t, GeoFeedRow{}, row)\n}\n"
  },
  {
    "path": "dn42/ptr.go",
    "content": "package dn42\n\nimport (\n\t\"encoding/csv\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/spf13/viper\"\n)\n\ntype PtrRow struct {\n\tIATACode string\n\tLtdCode  string\n\tRegion   string\n\tCity     string\n}\n\nfunc matchesPattern(prefix string, s string) bool {\n\tpattern := fmt.Sprintf(`^(.*[-.\\d]|^)%s[-.\\d].*$`, prefix)\n\n\tr, err := regexp.Compile(pattern)\n\tif err != nil {\n\t\tfmt.Println(\"Invalid regular expression:\", err)\n\t\treturn false\n\t}\n\n\treturn r.MatchString(s)\n}\n\nfunc FindPtrRecord(ptr string) (PtrRow, error) {\n\tpath := viper.GetString(\"ptrPath\")\n\tif path == \"\" {\n\t\treturn PtrRow{}, errors.New(\"ptrPath not configured\")\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn PtrRow{}, err\n\t}\n\tdefer f.Close()\n\n\tr := csv.NewReader(f)\n\trows, err := r.ReadAll()\n\tif err != nil {\n\t\treturn PtrRow{}, err\n\t}\n\t// 转小写\n\tptr = strings.ToLower(ptr)\n\t// 先查城市名\n\tfor _, row := range rows {\n\t\tcity := row[3]\n\t\tif city == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcity = strings.ReplaceAll(city, \" \", \"\")\n\t\tcity = strings.ToLower(city)\n\n\t\tif matchesPattern(city, ptr) {\n\t\t\treturn PtrRow{\n\t\t\t\tLtdCode: row[1],\n\t\t\t\tRegion:  row[2],\n\t\t\t\tCity:    row[3],\n\t\t\t}, nil\n\t\t}\n\t}\n\t// 查 IATA Code\n\tfor _, row := range rows {\n\t\tiata := row[0]\n\t\tif iata == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tiata = strings.ToLower(iata)\n\t\tif matchesPattern(iata, ptr) {\n\t\t\treturn PtrRow{\n\t\t\t\tIATACode: iata,\n\t\t\t\tLtdCode:  row[1],\n\t\t\t\tRegion:   row[2],\n\t\t\t\tCity:     row[3],\n\t\t\t}, nil\n\t\t}\n\t}\n\n\treturn PtrRow{}, errors.New(\"ptr not found\")\n}\n"
  },
  {
    "path": "dn42/ptr_test.go",
    "content": "package dn42\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/spf13/viper\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestFindPtrRecordMatchesCity(t *testing.T) {\n\tdir := t.TempDir()\n\tptrPath := filepath.Join(dir, \"ptr.csv\")\n\tcontent := \"HKG,hk,Hong Kong,Hong Kong\\nLAX,us,California,Los Angeles\\n\"\n\trequire.NoError(t, os.WriteFile(ptrPath, []byte(content), 0o644))\n\n\tviper.Set(\"ptrPath\", ptrPath)\n\tt.Cleanup(viper.Reset)\n\n\trow, err := FindPtrRecord(\"core.hongkong-1.example\")\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"hk\", row.LtdCode)\n\tassert.Equal(t, \"Hong Kong\", row.Region)\n\tassert.Equal(t, \"Hong Kong\", row.City)\n}\n\nfunc TestFindPtrRecordMatchesIATACode(t *testing.T) {\n\tdir := t.TempDir()\n\tptrPath := filepath.Join(dir, \"ptr.csv\")\n\trequire.NoError(t, os.WriteFile(ptrPath, []byte(\"LAX,us,California,Los Angeles\\n\"), 0o644))\n\n\tviper.Set(\"ptrPath\", ptrPath)\n\tt.Cleanup(viper.Reset)\n\n\trow, err := FindPtrRecord(\"edge.lax01.provider.test\")\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"lax\", row.IATACode)\n\tassert.Equal(t, \"us\", row.LtdCode)\n\tassert.Equal(t, \"California\", row.Region)\n}\n\nfunc TestFindPtrRecordNotFound(t *testing.T) {\n\tdir := t.TempDir()\n\tptrPath := filepath.Join(dir, \"ptr.csv\")\n\trequire.NoError(t, os.WriteFile(ptrPath, []byte(\"\"), 0o644))\n\n\tviper.Set(\"ptrPath\", ptrPath)\n\tt.Cleanup(viper.Reset)\n\n\t_, err := FindPtrRecord(\"unmatched.example\")\n\trequire.Error(t, err)\n}\n"
  },
  {
    "path": "fast_trace/basic.go",
    "content": "package fastTrace\n\ntype AllLocationCollection struct {\n\tBeijing   BackBoneCollection\n\tShanghai  BackBoneCollection\n\tGuangzhou BackBoneCollection\n\tHangzhou  BackBoneCollection\n\tHefei     BackBoneCollection\n\tChangsha  BackBoneCollection\n}\n\ntype BackBoneCollection struct {\n\tLocation string\n\tCT163    ISPCollection\n\tCTCN2    ISPCollection\n\tCU169    ISPCollection\n\tCU9929   ISPCollection\n\tCM       ISPCollection\n\tCMIN2    ISPCollection\n\tEDU      ISPCollection\n\tCST      ISPCollection\n}\n\ntype ISPCollection struct {\n\tISPName string\n\tIP      string\n\tIPv6    string\n}\n\nconst (\n\tCT163  string = \"电信 163 AS4134\"\n\tCTCN2  string = \"电信 CN2 AS4809\"\n\tCU169  string = \"联通 169 AS4837\"\n\tCU9929 string = \"联通 A网(CNC) AS9929\"\n\tCM     string = \"移动 CMNET AS9808\"\n\tCMIN2  string = \"移动 CMIN2 AS58807\"\n\tEDU    string = \"教育网 CERNET AS4538\"\n\tCST    string = \"科技网 CSTNET AS7497\"\n)\n\nvar TestIPsCollection = AllLocationCollection{\n\tBeijing:   Beijing,\n\tShanghai:  Shanghai,\n\tGuangzhou: Guangzhou,\n\tHangzhou:  Hangzhou,\n\tHefei:     Hefei,\n}\n\nvar Beijing = BackBoneCollection{\n\tLocation: \"北京\",\n\tCT163: ISPCollection{\n\t\tISPName: CT163,\n\t\tIP:      \"ipv4.pek-4134.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.pek-4134.endpoint.nxtrace.org\",\n\t},\n\n\tCTCN2: ISPCollection{\n\t\tISPName: CTCN2,\n\t\tIP:      \"ipv4.pek-4809.endpoint.nxtrace.org\",\n\t},\n\n\tCU169: ISPCollection{\n\t\tISPName: CU169,\n\t\tIP:      \"ipv4.pek-4837.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.pek-4837.endpoint.nxtrace.org\",\n\t},\n\n\tCU9929: ISPCollection{\n\t\tISPName: CU9929,\n\t\tIP:      \"ipv4.pek-9929.endpoint.nxtrace.org\",\n\t},\n\n\tCM: ISPCollection{\n\t\tISPName: CM,\n\t\tIP:      \"ipv4.pek-9808.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.pek-9808.endpoint.nxtrace.org\",\n\t},\n\n\tCMIN2: ISPCollection{\n\t\tISPName: CMIN2,\n\t\tIP:      \"ipv4.pek-58807.endpoint.nxtrace.org\",\n\t},\n\n\tEDU: ISPCollection{\n\t\tISPName: EDU,\n\t\tIP:      \"ipv4.pek-4538.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.pek-4538.endpoint.nxtrace.org\",\n\t},\n\n\t// 中科院\n\tCST: ISPCollection{\n\t\tISPName: CST,\n\t\tIP:      \"ipv4.pek-7497.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.pek-7497.endpoint.nxtrace.org\",\n\t},\n}\n\nvar Shanghai = BackBoneCollection{\n\tLocation: \"上海\",\n\tCT163: ISPCollection{\n\t\tISPName: CT163,\n\t\tIP:      \"ipv4.sha-4134.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.sha-4134.endpoint.nxtrace.org\",\n\t},\n\n\tCTCN2: ISPCollection{\n\t\tISPName: CTCN2,\n\t\tIP:      \"ipv4.sha-4809.endpoint.nxtrace.org\",\n\t},\n\n\tCU169: ISPCollection{\n\t\tISPName: CU169,\n\t\tIP:      \"ipv4.sha-4837.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.sha-4837.endpoint.nxtrace.org\",\n\t},\n\n\tCU9929: ISPCollection{\n\t\tISPName: CU9929,\n\t\tIP:      \"ipv4.sha-9929.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.sha-9929.endpoint.nxtrace.org\",\n\t},\n\n\tCM: ISPCollection{\n\t\tISPName: CM,\n\t\tIP:      \"ipv4.sha-9808.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.sha-9808.endpoint.nxtrace.org\",\n\t},\n\n\tCMIN2: ISPCollection{\n\t\tISPName: CMIN2,\n\t\tIP:      \"ipv4.sha-58807.endpoint.nxtrace.org\",\n\t},\n\n\tEDU: ISPCollection{\n\t\tISPName: EDU,\n\t\tIP:      \"ipv4.sha-4538.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.sha-4538.endpoint.nxtrace.org\",\n\t},\n}\n\nvar Guangzhou = BackBoneCollection{\n\tLocation: \"广州\",\n\tCT163: ISPCollection{\n\t\tISPName: CT163,\n\t\tIP:      \"ipv4.can-4134.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.can-4134.endpoint.nxtrace.org\",\n\t},\n\n\tCTCN2: ISPCollection{\n\t\tISPName: CTCN2,\n\t\tIP:      \"ipv4.can-4809.endpoint.nxtrace.org\",\n\t},\n\n\tCU169: ISPCollection{\n\t\tISPName: CU169,\n\t\tIP:      \"ipv4.can-4837.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.can-4837.endpoint.nxtrace.org\",\n\t},\n\n\tCU9929: ISPCollection{\n\t\tISPName: CU9929,\n\t\tIP:      \"ipv4.can-9929.endpoint.nxtrace.org\",\n\t},\n\n\tCM: ISPCollection{\n\t\tISPName: CM,\n\t\tIP:      \"ipv4.can-9808.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.can-9808.endpoint.nxtrace.org\",\n\t},\n\n\tCMIN2: ISPCollection{\n\t\tISPName: CMIN2,\n\t\tIP:      \"ipv4.can-58807.endpoint.nxtrace.org\",\n\t},\n\n\t// 中山大学\n\tEDU: ISPCollection{\n\t\tISPName: EDU,\n\t\tIP:      \"ipv4.can-4538.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.can-4538.endpoint.nxtrace.org\",\n\t},\n}\n\nvar Hangzhou = BackBoneCollection{\n\tLocation: \"杭州\",\n\tCT163: ISPCollection{\n\t\tISPName: CT163,\n\t\tIP:      \"ipv4.hgh-4134.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.hgh-4134.endpoint.nxtrace.org\",\n\t},\n\tCU169: ISPCollection{\n\t\tISPName: CU169,\n\t\tIP:      \"ipv4.hgh-4837.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.hgh-4837.endpoint.nxtrace.org\",\n\t},\n\tCM: ISPCollection{\n\t\tISPName: CM,\n\t\tIP:      \"ipv4.hgh-9808.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.hgh-9808.endpoint.nxtrace.org\",\n\t},\n\t// 浙江大学 教育网\n\tEDU: ISPCollection{\n\t\tISPName: EDU,\n\t\tIP:      \"ipv4.hgh-4538.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.hgh-4538.endpoint.nxtrace.org\",\n\t},\n}\n\nvar Hefei = BackBoneCollection{\n\tLocation: \"合肥\",\n\t// 中国科学技术大学 教育网\n\tEDU: ISPCollection{\n\t\tISPName: EDU,\n\t\tIP:      \"ipv4.hfe-4538.endpoint.nxtrace.org\",\n\t\tIPv6:    \"ipv6.hfe-4538.endpoint.nxtrace.org\",\n\t},\n\t// 中国科学技术大学 科技网\n\tCST: ISPCollection{\n\t\tISPName: CST,\n\t\tIP:      \"ipv4.hfe-7497.endpoint.nxtrace.org\",\n\t},\n}\n"
  },
  {
    "path": "fast_trace/fast_trace ipv6.go",
    "content": "package fastTrace\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n\t\"github.com/nxtrace/NTrace-core/wshandle\"\n)\n\n//var pFastTracer ParamsFastTrace\n\nfunc (f *FastTracer) tracert_v6(location string, ispCollection ISPCollection) {\n\tfmt.Fprintf(color.Output, \"%s\\n\", color.New(color.FgYellow, color.Bold).Sprintf(\"『%s %s 』\", location, ispCollection.ISPName))\n\tdisplayPacketSize := f.ParamsFastTrace.PktSize\n\tif !f.ParamsFastTrace.PacketSizeSet {\n\t\tdisplayPacketSize = trace.DefaultPacketSize(f.TracerouteMethod, net.ParseIP(ispCollection.IPv6))\n\t}\n\tfmt.Printf(\"traceroute to %s, %d hops max, %s, %s mode\\n\", ispCollection.IPv6, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod)))\n\n\t// ip, err := util.DomainLookUp(ispCollection.IPv6, \"6\", \"\", true)\n\tip, err := util.DomainLookUpWithContext(f.ParamsFastTrace.Context, ispCollection.IPv6, \"6\", f.ParamsFastTrace.Dot, true)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tpacketSize := f.ParamsFastTrace.PktSize\n\tif !f.ParamsFastTrace.PacketSizeSet {\n\t\tpacketSize = trace.DefaultPacketSize(f.TracerouteMethod, ip)\n\t}\n\tpacketSizeSpec, err := trace.NormalizePacketSize(f.TracerouteMethod, ip, packetSize)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tvar conf = trace.Config{\n\t\tContext:          f.ParamsFastTrace.Context,\n\t\tOSType:           f.ParamsFastTrace.OSType,\n\t\tICMPMode:         f.ParamsFastTrace.ICMPMode,\n\t\tBeginHop:         f.ParamsFastTrace.BeginHop,\n\t\tDstIP:            ip,\n\t\tDstPort:          f.ParamsFastTrace.DstPort,\n\t\tMaxHops:          f.ParamsFastTrace.MaxHops,\n\t\tNumMeasurements:  3,\n\t\tMaxAttempts:      f.ParamsFastTrace.MaxAttempts,\n\t\tParallelRequests: 18,\n\t\tRDNS:             f.ParamsFastTrace.RDNS,\n\t\tAlwaysWaitRDNS:   f.ParamsFastTrace.AlwaysWaitRDNS,\n\t\tPacketInterval:   100,\n\t\tTTLInterval:      500,\n\t\tIPGeoSource:      ipgeo.GetSource(\"LeoMoeAPI\"),\n\t\tTimeout:          f.ParamsFastTrace.Timeout,\n\t\tSrcAddr:          f.ParamsFastTrace.SrcAddr,\n\t\tPktSize:          packetSizeSpec.PayloadSize,\n\t\tRandomPacketSize: packetSizeSpec.Random,\n\t\tTOS:              f.ParamsFastTrace.TOS,\n\t\tLang:             f.ParamsFastTrace.Lang,\n\t}\n\n\theader := fmt.Sprintf(\"『%s %s 』\\ntraceroute to %s, %d hops max, %s, %s mode\\n\",\n\t\tlocation, ispCollection.ISPName, ispCollection.IPv6, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod)))\n\tcleanup, err := configureFastTraceRealtimePrinter(&conf, f.ParamsFastTrace.OutputPath, header)\n\tif err != nil {\n\t\treturn\n\t}\n\tif cleanup != nil {\n\t\tdefer func() {\n\t\t\tif closeErr := cleanup(); closeErr != nil {\n\t\t\t\tlog.Println(closeErr)\n\t\t\t}\n\t\t}()\n\t}\n\n\t_, err = trace.Traceroute(f.TracerouteMethod, conf)\n\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tfmt.Println()\n}\n\nfunc (f *FastTracer) testAll_v6() {\n\tf.testCT_v6()\n\tprintln()\n\tf.testCU_v6()\n\tprintln()\n\tf.testCM_v6()\n\tprintln()\n\tf.testEDU_v6()\n}\n\nfunc (f *FastTracer) testCT_v6() {\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163)\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163)\n\tf.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CT163)\n\tf.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163)\n}\n\nfunc (f *FastTracer) testCU_v6() {\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169)\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169)\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU9929)\n\tf.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CU169)\n\tf.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169)\n}\n\nfunc (f *FastTracer) testCM_v6() {\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM)\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM)\n\tf.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CM)\n\tf.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM)\n}\n\nfunc (f *FastTracer) testEDU_v6() {\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.EDU)\n\tf.tracert_v6(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.EDU)\n\tf.tracert_v6(TestIPsCollection.Hefei.Location, TestIPsCollection.Hefei.EDU)\n\tf.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.EDU)\n\t// 科技网暂时算在EDU里面，等拿到了足够多的数据再分离出去，单独用于测试\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST)\n}\n\nfunc (f *FastTracer) testFastBJ_v6() {\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163)\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169)\n\tf.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM)\n\t//f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n\t//f.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST)\n}\n\nfunc (f *FastTracer) testFastSH_v6() {\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163)\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169)\n\tf.tracert_v6(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM)\n}\n\nfunc (f *FastTracer) testFastGZ_v6() {\n\tf.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163)\n\tf.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169)\n\tf.tracert_v6(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM)\n}\n\nfunc FastTestv6(traceMode trace.Method, paramsFastTrace ParamsFastTrace) {\n\tchoice := readFastTestv6Choice()\n\tft := FastTracer{\n\t\tParamsFastTrace:  paramsFastTrace,\n\t\tTracerouteMethod: fastTestMethod(traceMode),\n\t}\n\n\t// 建立 WebSocket 连接\n\tw := wshandle.NewWithContext(paramsFastTrace.Context)\n\tw.Interrupt = make(chan os.Signal, 1)\n\tsignal.Notify(w.Interrupt, os.Interrupt)\n\tdefer func() {\n\t\tw.Close()\n\t}()\n\n\trunFastTestv6Selection(&ft, choice)\n}\n\nfunc readFastTestv6Choice() string {\n\tvar choice string\n\tfmt.Println(\"您想测试哪些ISP的路由？\\n1. 北京三网快速测试\\n2. 上海三网快速测试\\n3. 广州三网快速测试\\n4. 全国电信\\n5. 全国联通\\n6. 全国移动\\n7. 全国教育网\\n8. 全国五网\")\n\tfmt.Print(\"请选择选项：\")\n\tif _, err := fmt.Scanln(&choice); err != nil {\n\t\treturn \"1\"\n\t}\n\treturn choice\n}\n\nfunc fastTestMethod(traceMode trace.Method) trace.Method {\n\tswitch traceMode {\n\tcase trace.ICMPTrace, trace.TCPTrace, trace.UDPTrace:\n\t\treturn traceMode\n\tdefault:\n\t\treturn trace.ICMPTrace\n\t}\n}\n\nfunc runFastTestv6Selection(ft *FastTracer, choice string) {\n\tswitch choice {\n\tcase \"1\":\n\t\tft.testFastBJ_v6()\n\tcase \"2\":\n\t\tft.testFastSH_v6()\n\tcase \"3\":\n\t\tft.testFastGZ_v6()\n\tcase \"4\":\n\t\tft.testCT_v6()\n\tcase \"5\":\n\t\tft.testCU_v6()\n\tcase \"6\":\n\t\tft.testCM_v6()\n\tcase \"7\":\n\t\tft.testEDU_v6()\n\tcase \"8\":\n\t\tft.testAll_v6()\n\tdefault:\n\t\tft.testFastBJ_v6()\n\t}\n}\n"
  },
  {
    "path": "fast_trace/fast_trace.go",
    "content": "package fastTrace\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/printer\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/tracelog\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n\t\"github.com/nxtrace/NTrace-core/wshandle\"\n)\n\ntype FastTracer struct {\n\tTracerouteMethod trace.Method\n\tParamsFastTrace  ParamsFastTrace\n}\n\ntype ParamsFastTrace struct {\n\tContext        context.Context\n\tOSType         int\n\tICMPMode       int\n\tSrcDev         string\n\tSrcAddr        string\n\tDstPort        int\n\tBeginHop       int\n\tMaxHops        int\n\tMaxAttempts    int\n\tRDNS           bool\n\tAlwaysWaitRDNS bool\n\tLang           string\n\tPktSize        int\n\tPacketSizeSet  bool\n\tTOS            int\n\tTimeout        time.Duration\n\tFile           string\n\tDot            string\n\tOutputPath     string\n}\n\ntype IpListElement struct {\n\tIp       string\n\tDesc     string\n\tVersion4 bool // true for IPv4, false for IPv6\n}\n\nfunc resolveTraceMethod(traceMode trace.Method) trace.Method {\n\tswitch traceMode {\n\tcase trace.TCPTrace:\n\t\treturn trace.TCPTrace\n\tcase trace.UDPTrace:\n\t\treturn trace.UDPTrace\n\tdefault:\n\t\treturn trace.ICMPTrace\n\t}\n}\n\nfunc resolveFastTraceSourceAddr(srcDev string, wantV4 bool) string {\n\tdev, devErr := net.InterfaceByName(srcDev)\n\tif devErr != nil || dev == nil {\n\t\treturn \"\"\n\t}\n\taddrs, err := dev.Addrs()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tvar candidate string\n\tfor _, addr := range addrs {\n\t\tipNet, ok := addr.(*net.IPNet)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tisV4 := ipNet.IP.To4() != nil\n\t\tif isV4 != wantV4 {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate = ipNet.IP.String()\n\t\tparsed := net.ParseIP(candidate)\n\t\tif parsed != nil && !(parsed.IsPrivate() || parsed.IsLoopback() ||\n\t\t\tparsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast()) {\n\t\t\treturn candidate\n\t\t}\n\t}\n\treturn candidate\n}\n\nfunc withFastTraceSourceAddr(params ParamsFastTrace, wantV4 bool) ParamsFastTrace {\n\tif params.SrcDev != \"\" {\n\t\tif srcAddr := resolveFastTraceSourceAddr(params.SrcDev, wantV4); srcAddr != \"\" {\n\t\t\tparams.SrcAddr = srcAddr\n\t\t}\n\t}\n\treturn params\n}\n\nfunc promptFastTraceChoice(prompt, defaultChoice string) string {\n\tfmt.Print(prompt)\n\tvar choice string\n\tif _, err := fmt.Scanln(&choice); err != nil {\n\t\treturn defaultChoice\n\t}\n\treturn choice\n}\n\nfunc initFastTraceWS(ctx context.Context) *wshandle.WsConn {\n\tw := wshandle.NewWithContext(ctx)\n\tw.Interrupt = make(chan os.Signal, 1)\n\tsignal.Notify(w.Interrupt, os.Interrupt)\n\treturn w\n}\n\nfunc closeFastTraceWS(w *wshandle.WsConn) {\n\tif w != nil {\n\t\tw.Close()\n\t}\n}\n\nfunc newFastTracer(traceMode trace.Method, params ParamsFastTrace) FastTracer {\n\treturn FastTracer{\n\t\tTracerouteMethod: resolveTraceMethod(traceMode),\n\t\tParamsFastTrace:  params,\n\t}\n}\n\nfunc runFastTraceByChoice(ft FastTracer, choice string) {\n\tswitch choice {\n\tcase \"2\":\n\t\tft.testFastSH()\n\tcase \"3\":\n\t\tft.testFastGZ()\n\tcase \"4\":\n\t\tft.testCT()\n\tcase \"5\":\n\t\tft.testCU()\n\tcase \"6\":\n\t\tft.testCM()\n\tcase \"7\":\n\t\tft.testEDU()\n\tcase \"8\":\n\t\tft.testAll()\n\tdefault:\n\t\tft.testFastBJ()\n\t}\n}\n\nfunc parseIPListLine(ctx context.Context, line string) (IpListElement, bool) {\n\tparts := strings.SplitN(line, \" \", 2)\n\tif len(parts) == 0 {\n\t\treturn IpListElement{}, false\n\t}\n\n\tip := parts[0]\n\tdesc := ip\n\tif len(parts) == 2 {\n\t\tdesc = parts[1]\n\t}\n\n\tparsedIP := net.ParseIP(ip)\n\tif parsedIP == nil {\n\t\tnetIP, err := util.DomainLookUpWithContext(ctx, ip, \"all\", \"\", true)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Ignoring invalid IP: %s\\n\", ip)\n\t\t\treturn IpListElement{}, false\n\t\t}\n\t\tip = netIP.String()\n\t}\n\n\treturn IpListElement{\n\t\tIp:       ip,\n\t\tDesc:     desc,\n\t\tVersion4: strings.Contains(ip, \".\"),\n\t}, true\n}\n\nfunc loadIPList(ctx context.Context, filePath string) []IpListElement {\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\tfmt.Println(\"Error opening file:\", err)\n\t\treturn nil\n\t}\n\tdefer func(file *os.File) {\n\t\terr := file.Close()\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t}(file)\n\n\tipList := make([]IpListElement, 0)\n\tscanner := bufio.NewScanner(file)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif ipElem, ok := parseIPListLine(ctx, line); ok {\n\t\t\tipList = append(ipList, ipElem)\n\t\t} else if strings.TrimSpace(line) != \"\" {\n\t\t\tfmt.Printf(\"Ignoring invalid line: %s\\n\", line)\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tfmt.Println(\"Error reading file:\", err)\n\t}\n\treturn ipList\n}\n\nfunc printFileTraceHeader(ip IpListElement, params ParamsFastTrace, tracerouteMethod trace.Method) {\n\tfmt.Fprintf(color.Output, \"%s\\n\", color.New(color.FgYellow, color.Bold).Sprint(\"『 \"+ip.Desc+\"』\"))\n\tdst := ip.Ip\n\tif util.EnableHidDstIP {\n\t\tdst = util.HideIPPart(ip.Ip)\n\t}\n\tdisplayPacketSize := params.PktSize\n\tif !params.PacketSizeSet {\n\t\tdisplayPacketSize = trace.DefaultPacketSize(tracerouteMethod, net.ParseIP(ip.Ip))\n\t}\n\tfmt.Printf(\"traceroute to %s, %d hops max, %s, %s mode\\n\", dst, params.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(tracerouteMethod)))\n}\n\nfunc buildFileTraceConfig(params ParamsFastTrace, tracerouteMethod trace.Method, ip IpListElement) trace.Config {\n\tdstIP := net.ParseIP(ip.Ip)\n\tpacketSize := params.PktSize\n\tif !params.PacketSizeSet {\n\t\tpacketSize = trace.DefaultPacketSize(tracerouteMethod, dstIP)\n\t}\n\tpacketSizeSpec, err := trace.NormalizePacketSize(tracerouteMethod, dstIP, packetSize)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\treturn trace.Config{\n\t\tContext:          params.Context,\n\t\tOSType:           params.OSType,\n\t\tICMPMode:         params.ICMPMode,\n\t\tBeginHop:         params.BeginHop,\n\t\tDstIP:            dstIP,\n\t\tDstPort:          params.DstPort,\n\t\tMaxHops:          params.MaxHops,\n\t\tNumMeasurements:  3,\n\t\tParallelRequests: 18,\n\t\tRDNS:             params.RDNS,\n\t\tAlwaysWaitRDNS:   params.AlwaysWaitRDNS,\n\t\tPacketInterval:   100,\n\t\tTTLInterval:      500,\n\t\tIPGeoSource:      ipgeo.GetSource(\"LeoMoeAPI\"),\n\t\tTimeout:          params.Timeout,\n\t\tSrcAddr:          resolveFastTraceSourceAddr(params.SrcDev, ip.Version4),\n\t\tPktSize:          packetSizeSpec.PayloadSize,\n\t\tRandomPacketSize: packetSizeSpec.Random,\n\t\tTOS:              params.TOS,\n\t\tLang:             params.Lang,\n\t}\n}\n\nfunc configureFastTraceRealtimePrinter(conf *trace.Config, outputPath, header string) (func() error, error) {\n\tif strings.TrimSpace(outputPath) == \"\" {\n\t\tconf.RealtimePrinter = printer.RealtimePrinter\n\t\treturn nil, nil\n\t}\n\n\tfp, err := tracelog.OpenFile(outputPath)\n\tif err != nil {\n\t\tlog.Printf(\"fast trace output open failed for %q: %v; falling back to stdout\", outputPath, err)\n\t\tconf.RealtimePrinter = printer.RealtimePrinter\n\t\treturn nil, nil\n\t}\n\tif err := tracelog.WriteHeader(fp, header); err != nil {\n\t\t_ = fp.Close()\n\t\tlog.Printf(\"fast trace output header write failed for %q: %v; falling back to stdout\", outputPath, err)\n\t\tconf.RealtimePrinter = printer.RealtimePrinter\n\t\treturn nil, nil\n\t}\n\tconf.RealtimePrinter = tracelog.NewRealtimePrinter(io.MultiWriter(os.Stdout, fp))\n\treturn fp.Close, nil\n}\n\nfunc runFileTraceTarget(params ParamsFastTrace, tracerouteMethod trace.Method, ip IpListElement) {\n\tprintFileTraceHeader(ip, params, tracerouteMethod)\n\n\tconf := buildFileTraceConfig(params, tracerouteMethod, ip)\n\tdisplayPacketSize := params.PktSize\n\tif !params.PacketSizeSet {\n\t\tdisplayPacketSize = trace.DefaultPacketSize(tracerouteMethod, net.ParseIP(ip.Ip))\n\t}\n\theader := 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)))\n\tcleanup, err := configureFastTraceRealtimePrinter(&conf, params.OutputPath, header)\n\tif err != nil {\n\t\tlog.Println(err)\n\t\treturn\n\t}\n\tif cleanup != nil {\n\t\tdefer func() {\n\t\t\tif closeErr := cleanup(); closeErr != nil {\n\t\t\t\tlog.Println(closeErr)\n\t\t\t}\n\t\t}()\n\t}\n\n\tif _, err := trace.Traceroute(tracerouteMethod, conf); err != nil {\n\t\tlog.Println(err)\n\t\treturn\n\t}\n\tfmt.Println()\n}\n\nfunc (f *FastTracer) tracert(location string, ispCollection ISPCollection) {\n\tfmt.Fprintf(color.Output, \"%s\\n\", color.New(color.FgYellow, color.Bold).Sprintf(\"『%s %s 』\", location, ispCollection.ISPName))\n\tdisplayPacketSize := f.ParamsFastTrace.PktSize\n\tif !f.ParamsFastTrace.PacketSizeSet {\n\t\tdisplayPacketSize = trace.DefaultPacketSize(f.TracerouteMethod, net.ParseIP(ispCollection.IP))\n\t}\n\tfmt.Printf(\"traceroute to %s, %d hops max, %s, %s mode\\n\", ispCollection.IP, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod)))\n\n\t// ip, err := util.DomainLookUp(ispCollection.IP, \"4\", \"\", true)\n\tip, err := util.DomainLookUpWithContext(f.ParamsFastTrace.Context, ispCollection.IP, \"4\", f.ParamsFastTrace.Dot, true)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tpacketSize := f.ParamsFastTrace.PktSize\n\tif !f.ParamsFastTrace.PacketSizeSet {\n\t\tpacketSize = trace.DefaultPacketSize(f.TracerouteMethod, ip)\n\t}\n\tpacketSizeSpec, err := trace.NormalizePacketSize(f.TracerouteMethod, ip, packetSize)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tvar conf = trace.Config{\n\t\tContext:          f.ParamsFastTrace.Context,\n\t\tOSType:           f.ParamsFastTrace.OSType,\n\t\tICMPMode:         f.ParamsFastTrace.ICMPMode,\n\t\tBeginHop:         f.ParamsFastTrace.BeginHop,\n\t\tDstIP:            ip,\n\t\tDstPort:          f.ParamsFastTrace.DstPort,\n\t\tMaxHops:          f.ParamsFastTrace.MaxHops,\n\t\tNumMeasurements:  3,\n\t\tMaxAttempts:      f.ParamsFastTrace.MaxAttempts,\n\t\tParallelRequests: 18,\n\t\tRDNS:             f.ParamsFastTrace.RDNS,\n\t\tAlwaysWaitRDNS:   f.ParamsFastTrace.AlwaysWaitRDNS,\n\t\tPacketInterval:   100,\n\t\tTTLInterval:      500,\n\t\tIPGeoSource:      ipgeo.GetSource(\"LeoMoeAPI\"),\n\t\tTimeout:          f.ParamsFastTrace.Timeout,\n\t\tSrcAddr:          f.ParamsFastTrace.SrcAddr,\n\t\tPktSize:          packetSizeSpec.PayloadSize,\n\t\tRandomPacketSize: packetSizeSpec.Random,\n\t\tTOS:              f.ParamsFastTrace.TOS,\n\t\tLang:             f.ParamsFastTrace.Lang,\n\t}\n\n\theader := fmt.Sprintf(\"『%s %s 』\\ntraceroute to %s, %d hops max, %s, %s mode\\n\",\n\t\tlocation, ispCollection.ISPName, ispCollection.IP, f.ParamsFastTrace.MaxHops, trace.FormatPacketSizeLabel(displayPacketSize), strings.ToUpper(string(f.TracerouteMethod)))\n\tcleanup, err := configureFastTraceRealtimePrinter(&conf, f.ParamsFastTrace.OutputPath, header)\n\tif err != nil {\n\t\tlog.Println(err)\n\t\treturn\n\t}\n\tif cleanup != nil {\n\t\tdefer func() {\n\t\t\tif closeErr := cleanup(); closeErr != nil {\n\t\t\t\tlog.Println(closeErr)\n\t\t\t}\n\t\t}()\n\t}\n\n\t_, err = trace.Traceroute(f.TracerouteMethod, conf)\n\n\tif err != nil {\n\t\tlog.Println(err)\n\t\treturn\n\t}\n\tfmt.Println()\n}\n\nfunc FastTest(traceMode trace.Method, paramsFastTrace ParamsFastTrace) {\n\tif paramsFastTrace.File != \"\" {\n\t\ttestFile(paramsFastTrace, traceMode)\n\t\treturn\n\t}\n\n\tfmt.Println(\"Hi，欢迎使用 Fast Trace 功能，请注意 Fast Trace 功能只适合新手使用\\n因为国内网络复杂，我们设置的测试目标有限，建议普通用户自测以获得更加精准的路由情况\")\n\tfmt.Println(\"请您选择要测试的 IP 类型\\n1. IPv4\\n2. IPv6\")\n\tif promptFastTraceChoice(\"请选择选项：\", \"1\") == \"2\" {\n\t\tparamsFastTrace = withFastTraceSourceAddr(paramsFastTrace, false)\n\t\tFastTestv6(traceMode, paramsFastTrace)\n\t\treturn\n\t}\n\tparamsFastTrace = withFastTraceSourceAddr(paramsFastTrace, true)\n\n\tfmt.Println(\"您想测试哪些ISP的路由？\\n1. 北京三网快速测试\\n2. 上海三网快速测试\\n3. 广州三网快速测试\\n4. 全国电信\\n5. 全国联通\\n6. 全国移动\\n7. 全国教育网\\n8. 全国五网\")\n\tchoice := promptFastTraceChoice(\"请选择选项：\", \"1\")\n\n\tw := initFastTraceWS(paramsFastTrace.Context)\n\tdefer closeFastTraceWS(w)\n\n\trunFastTraceByChoice(newFastTracer(traceMode, paramsFastTrace), choice)\n}\n\nfunc testFile(paramsFastTrace ParamsFastTrace, traceMode trace.Method) {\n\tw := initFastTraceWS(paramsFastTrace.Context)\n\tdefer closeFastTraceWS(w)\n\n\ttracerouteMethod := resolveTraceMethod(traceMode)\n\tfor _, ip := range loadIPList(paramsFastTrace.Context, paramsFastTrace.File) {\n\t\trunFileTraceTarget(paramsFastTrace, tracerouteMethod, ip)\n\t}\n}\n\nfunc (f *FastTracer) testAll() {\n\tf.testCT()\n\tprintln()\n\tf.testCU()\n\tprintln()\n\tf.testCM()\n\tprintln()\n\tf.testEDU()\n}\n\nfunc (f *FastTracer) testCT() {\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163)\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CTCN2)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CTCN2)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CTCN2)\n\tf.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CT163)\n}\n\nfunc (f *FastTracer) testCU() {\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169)\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU9929)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU9929)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU9929)\n\tf.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CU169)\n\n}\n\nfunc (f *FastTracer) testCM() {\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM)\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CMIN2)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CMIN2)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CMIN2)\n\tf.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.CM)\n}\n\nfunc (f *FastTracer) testEDU() {\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.EDU)\n\tf.tracert(TestIPsCollection.Hangzhou.Location, TestIPsCollection.Hangzhou.EDU)\n\tf.tracert(TestIPsCollection.Hefei.Location, TestIPsCollection.Hefei.EDU)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.EDU)\n\t// 科技网暂时算在EDU里面，等拿到了足够多的数据再分离出去，单独用于测试\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST)\n\tf.tracert(TestIPsCollection.Hefei.Location, TestIPsCollection.Hefei.CST)\n}\n\nfunc (f *FastTracer) testFastBJ() {\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CT163)\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CU169)\n\tf.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CM)\n\t//f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n\t//f.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.CST)\n}\n\nfunc (f *FastTracer) testFastSH() {\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CT163)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CU169)\n\tf.tracert(TestIPsCollection.Shanghai.Location, TestIPsCollection.Shanghai.CM)\n}\n\nfunc (f *FastTracer) testFastGZ() {\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CT163)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CU169)\n\tf.tracert(TestIPsCollection.Guangzhou.Location, TestIPsCollection.Guangzhou.CM)\n}\n"
  },
  {
    "path": "fast_trace/fast_trace_test.go",
    "content": "package fastTrace\n\nimport (\n\t\"testing\"\n)\n\nfunc TestTrace(t *testing.T) {\n\t//pFastTrace := ParamsFastTrace{\n\t//\tSrcDev:         \"\",\n\t//\tSrcAddr:        \"\",\n\t//\tBeginHop:       1,\n\t//\tMaxHops:        30,\n\t//\tRDNS:           false,\n\t//\tAlwaysWaitRDNS: false,\n\t//\tLang:           \"\",\n\t//\tPktSize:        52,\n\t//}\n\t//ft := FastTracer{ParamsFastTrace: pFastTrace}\n\t//// 建立 WebSocket 连接\n\t//w := wshandle.New()\n\t//w.Interrupt = make(chan os.Signal, 1)\n\t//signal.Notify(w.Interrupt, os.Interrupt)\n\t//defer func() {\n\t//\tw.Conn.Close()\n\t//}()\n\t//fmt.Println(\"TCP v4\")\n\t//ft.TracerouteMethod = trace.TCPTrace\n\t//ft.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n\t//fmt.Println(\"TCP v6\")\n\t//ft.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n\t//fmt.Println(\"ICMP v4\")\n\t//ft.TracerouteMethod = trace.ICMPTrace\n\t//ft.tracert(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n\t//fmt.Println(\"ICMP v6\")\n\t//ft.tracert_v6(TestIPsCollection.Beijing.Location, TestIPsCollection.Beijing.EDU)\n}\n"
  },
  {
    "path": "geofeed.example.csv",
    "content": "154.48.0.0/12,,,,174,COGENT-NET\n200.15.12.0/22,BR,BR-SP,Sao Paulo,2914,NTT-BACKBONE\n2001:0418:1403::/48,US,US-VA,Ashburn,2914,NTT-BACKBONE"
  },
  {
    "path": "go.mod",
    "content": "module github.com/nxtrace/NTrace-core\n\ngo 1.26.1\n\nrequire (\n\tgithub.com/akamensky/argparse v1.4.0\n\tgithub.com/fatih/color v1.18.0\n\tgithub.com/gin-gonic/gin v1.12.0\n\tgithub.com/google/gopacket v1.1.19\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/jsdelivr/globalping-cli v1.5.1\n\tgithub.com/mattn/go-runewidth v0.0.21\n\tgithub.com/oschwald/maxminddb-golang v1.13.1\n\tgithub.com/rodaine/table v1.3.1\n\tgithub.com/spf13/viper v1.21.0\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635\n\tgithub.com/tidwall/gjson v1.18.0\n\tgithub.com/tsosunchia/powclient v0.2.0\n\tgithub.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f\n\tgolang.org/x/net v0.52.0\n\tgolang.org/x/sync v0.20.0\n\tgolang.org/x/sys v0.42.0\n\tgolang.org/x/term v0.41.0\n)\n\nrequire (\n\tgithub.com/andybalholm/brotli v1.2.0 // indirect\n\tgithub.com/bytedance/gopkg v0.1.3 // indirect\n\tgithub.com/bytedance/sonic v1.15.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.5.0 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.2.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/fsnotify/fsnotify v1.9.0 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.12 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.30.1 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.19.2 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/quic-go/qpack v0.6.0 // indirect\n\tgithub.com/quic-go/quic-go v0.59.0 // indirect\n\tgithub.com/sagikazarmark/locafero v0.12.0 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/tidwall/match v1.2.0 // indirect\n\tgithub.com/tidwall/pretty v1.2.1 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.1 // indirect\n\tgo.mongodb.org/mongo-driver/v2 v2.5.0 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/arch v0.22.0 // indirect\n\tgolang.org/x/crypto v0.49.0 // indirect\n\tgolang.org/x/text v0.35.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.10 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc=\ngithub.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA=\ngithub.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=\ngithub.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=\ngithub.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=\ngithub.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=\ngithub.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=\ngithub.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=\ngithub.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=\ngithub.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=\ngithub.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=\ngithub.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=\ngithub.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=\ngithub.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=\ngithub.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=\ngithub.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=\ngithub.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=\ngithub.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=\ngithub.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=\ngithub.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/jsdelivr/globalping-cli v1.5.1 h1:7RZNmIljSBXe0xBeOoGQHXZNwHo6zDuQ0BI9hF12gLY=\ngithub.com/jsdelivr/globalping-cli v1.5.1/go.mod h1:Gw70OWvN6hIt0t4hftyUhcHuJQMTn4CvoobJiaTU0qg=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=\ngithub.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=\ngithub.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=\ngithub.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=\ngithub.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=\ngithub.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=\ngithub.com/rodaine/table v1.3.1 h1:jBVgg1bEu5EzEdYSrwUUlQpayDtkvtTmgFS0FPAxOq8=\ngithub.com/rodaine/table v1.3.1/go.mod h1:VYCJRCHa2DpD25uFALcB6hi5ECF3eEJQVhCXRjHgXc4=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=\ngithub.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=\ngithub.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=\ngithub.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=\ngithub.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=\ngithub.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=\ngithub.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=\ngithub.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=\ngithub.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tsosunchia/powclient v0.2.0 h1:BDrI3O69CbzarbD+CnnY10Kuwn8xlmtQR0m5tBp+BG8=\ngithub.com/tsosunchia/powclient v0.2.0/go.mod h1:fkb7tTW+HMH3ZWZzQUgwvvFKMj/8Ys+C8Sm/uGQzDA0=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=\ngithub.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=\ngithub.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f h1:glX3VZCYwW1/OmFxOjazfCtBLxXB3YNZk9LF2lYx+Lw=\ngithub.com/xjasonlyu/windivert-go v0.0.0-20201010013527-4239d0afa76f/go.mod h1:gh//RKyt2Gesx3eOj3ulzrSQ60ySj2UA4qnOdrtarvg=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngo.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=\ngo.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=\ngolang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=\ngolang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201008064518-c1f3e3309c71/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=\ngoogle.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "internal/hoprender/group.go",
    "content": "package hoprender\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\ntype Group struct {\n\tIP      string\n\tIndex   int\n\tTimings []string\n}\n\nfunc GroupHopAttempts(hops []trace.Hop) []Group {\n\tlatestIP := \"\"\n\tindexByIP := make(map[string]int)\n\tgroups := make([]Group, 0, len(hops))\n\n\tfor i, hop := range hops {\n\t\tif hop.Address == nil {\n\t\t\tif latestIP != \"\" {\n\t\t\t\tgroups[indexByIP[latestIP]].Timings = append(groups[indexByIP[latestIP]].Timings, \"* ms\")\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tip := hop.Address.String()\n\t\tgroupIdx, ok := indexByIP[ip]\n\t\tif !ok {\n\t\t\tgroup := Group{\n\t\t\t\tIP:      ip,\n\t\t\t\tIndex:   i,\n\t\t\t\tTimings: make([]string, 0, len(hops)),\n\t\t\t}\n\t\t\tif latestIP == \"\" {\n\t\t\t\tfor j := 0; j < i; j++ {\n\t\t\t\t\tgroup.Timings = append(group.Timings, \"* ms\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tgroups = append(groups, group)\n\t\t\tgroupIdx = len(groups) - 1\n\t\t\tindexByIP[ip] = groupIdx\n\t\t}\n\n\t\tgroups[groupIdx].Timings = append(groups[groupIdx].Timings, fmt.Sprintf(\"%.2f ms\", hop.RTT.Seconds()*1000))\n\t\tlatestIP = ip\n\t}\n\n\tif latestIP == \"\" {\n\t\treturn nil\n\t}\n\treturn groups\n}\n"
  },
  {
    "path": "ipgeo/chunzhen.go",
    "content": "package ipgeo\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc Chunzhen(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) {\n\turl := util.GetEnvDefault(\"NEXTTRACE_CHUNZHENURL\", \"http://127.0.0.1:2060\") + \"?ip=\" + ip\n\tclient := util.NewGeoHTTPClient(timeout)\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn &IPGeoData{}, fmt.Errorf(\"chunzhen: failed to create request: %w\", err)\n\t}\n\tcontent, err := client.Do(req)\n\tif err != nil {\n\t\tlog.Println(\"纯真 请求超时(2s)，请切换其他API使用\")\n\t\treturn &IPGeoData{}, err\n\t}\n\tdefer content.Body.Close()\n\tbody, err := io.ReadAll(content.Body)\n\tif err != nil {\n\t\treturn &IPGeoData{}, fmt.Errorf(\"chunzhen: failed to read response: %w\", err)\n\t}\n\n\tvar data map[string]interface{}\n\terr = json.Unmarshal(body, &data)\n\tif err != nil {\n\t\treturn &IPGeoData{}, err\n\t}\n\tipData, ok := data[ip].(map[string]interface{})\n\tif !ok {\n\t\treturn &IPGeoData{}, fmt.Errorf(\"chunzhen: unexpected response format for ip %s\", ip)\n\t}\n\tcity, _ := ipData[\"area\"].(string)\n\tregion, _ := ipData[\"country\"].(string)\n\tvar asn string\n\tif ipData[\"asn\"] != nil {\n\t\tasn, _ = ipData[\"asn\"].(string)\n\t}\n\t// 判断是否前两个字为香港或台湾\n\tvar country string\n\tprovinces := []string{\n\t\t\"北京\",\n\t\t\"天津\",\n\t\t\"河北\",\n\t\t\"山西\",\n\t\t\"内蒙古\",\n\t\t\"辽宁\",\n\t\t\"吉林\",\n\t\t\"黑龙江\",\n\t\t\"上海\",\n\t\t\"江苏\",\n\t\t\"浙江\",\n\t\t\"安徽\",\n\t\t\"福建\",\n\t\t\"江西\",\n\t\t\"山东\",\n\t\t\"河南\",\n\t\t\"湖北\",\n\t\t\"湖南\",\n\t\t\"广东\",\n\t\t\"广西\",\n\t\t\"海南\",\n\t\t\"重庆\",\n\t\t\"四川\",\n\t\t\"贵州\",\n\t\t\"云南\",\n\t\t\"西藏\",\n\t\t\"陕西\",\n\t\t\"甘肃\",\n\t\t\"青海\",\n\t\t\"宁夏\",\n\t\t\"新疆\",\n\t\t\"台湾\",\n\t\t\"香港\",\n\t\t\"澳门\",\n\t}\n\tfor _, province := range provinces {\n\t\tif strings.Contains(region, province) {\n\t\t\tcountry = \"中国\"\n\t\t\tcity = region + city\n\t\t\tbreak\n\t\t}\n\t}\n\tif country == \"\" {\n\t\tcountry = region\n\t}\n\treturn &IPGeoData{\n\t\tAsnumber: asn,\n\t\tCountry:  country,\n\t\tCity:     city,\n\t}, nil\n}\n"
  },
  {
    "path": "ipgeo/dn42.go",
    "content": "package ipgeo\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/dn42\"\n)\n\nfunc LtdCodeToCountryOrAreaName(Code string) string {\n\tregionName := []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\"}\n\tregionCode := []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\"}\n\tCode = strings.ToLower(Code)\n\tfor i, v := range regionCode {\n\t\tif strings.Contains(Code, v) {\n\t\t\treturn strings.TrimSpace(regionName[i])\n\t\t}\n\t}\n\treturn Code\n}\n\nfunc DN42(ip string, _ time.Duration, _ string, _ bool) (*IPGeoData, error) {\n\tdata := &IPGeoData{}\n\t// 先解析传入过来的数据\n\tipTmp := strings.Split(ip, \",\")\n\tif len(ipTmp) > 1 {\n\t\tip = ipTmp[0]\n\t}\n\t// 先查找 GeoFeed\n\tif geo, find := dn42.GetGeoFeed(ip); find {\n\t\tdata.Country = geo.LtdCode\n\t\tdata.City = geo.City\n\t\tdata.Asnumber = geo.ASN\n\t\tdata.Owner = geo.IPWhois\n\t}\n\t// 如果没找到，查找 PTR\n\tif len(ipTmp) > 1 {\n\t\t// 存在 PTR 记录\n\t\tif res, err := dn42.FindPtrRecord(ipTmp[1]); err == nil && res.LtdCode != \"\" {\n\t\t\tdata.Country = res.LtdCode\n\t\t\tdata.Prov = res.Region\n\t\t\tdata.City = res.City\n\t\t}\n\t}\n\n\tdata.Country = LtdCodeToCountryOrAreaName(data.Country)\n\n\tswitch data.Country {\n\tcase \"Hong Kong\":\n\t\tdata.Country = \"China\"\n\t\tdata.Prov = \"Hong Kong\"\n\tcase \"Taiwan\":\n\t\tdata.Country = \"China\"\n\t\tdata.Prov = \"Taiwan\"\n\tcase \"Macao\":\n\t\tdata.Country = \"China\"\n\t\tdata.Prov = \"Macao\"\n\tcase \"\":\n\t\tdata.Country = \"Unknown\"\n\t}\n\n\treturn data, nil\n}\n"
  },
  {
    "path": "ipgeo/dn42_test.go",
    "content": "package ipgeo\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/spf13/viper\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDN42GeoFeedAndPtrIntegration(t *testing.T) {\n\tdir := t.TempDir()\n\tgeofeedPath := filepath.Join(dir, \"geofeed.csv\")\n\tptrPath := filepath.Join(dir, \"ptr.csv\")\n\n\tgeofeedContent := \"192.0.2.0/24,hk,HK,Hong Kong,AS65000,Example Owner\\n\"\n\tptrContent := \"HKG,hk,Hong Kong,Hong Kong\\n\"\n\n\trequire.NoError(t, os.WriteFile(geofeedPath, []byte(geofeedContent), 0o644))\n\trequire.NoError(t, os.WriteFile(ptrPath, []byte(ptrContent), 0o644))\n\n\tviper.Set(\"geoFeedPath\", geofeedPath)\n\tviper.Set(\"ptrPath\", ptrPath)\n\tt.Cleanup(viper.Reset)\n\n\tres, err := DN42(\"192.0.2.8,core.hongkong-1.example\", time.Second, \"\", false)\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"China\", res.Country)\n\tassert.Equal(t, \"Hong Kong\", res.Prov)\n\tassert.Equal(t, \"Hong Kong\", res.City)\n\tassert.Equal(t, \"AS65000\", res.Asnumber)\n\tassert.Equal(t, \"Example Owner\", res.Owner)\n}\n\nfunc TestDN42PtrFallback(t *testing.T) {\n\tdir := t.TempDir()\n\tgeofeedPath := filepath.Join(dir, \"geofeed.csv\")\n\tptrPath := filepath.Join(dir, \"ptr.csv\")\n\n\t// geofeed does not cover the IP, forcing the PTR fallback path\n\trequire.NoError(t, os.WriteFile(geofeedPath, []byte(\"198.18.0.0/15,us,US,Test,AS65010,Owner\\n\"), 0o644))\n\trequire.NoError(t, os.WriteFile(ptrPath, []byte(\"AMS,nl,Noord-Holland,Amsterdam\\n\"), 0o644))\n\n\tviper.Set(\"geoFeedPath\", geofeedPath)\n\tviper.Set(\"ptrPath\", ptrPath)\n\tt.Cleanup(viper.Reset)\n\n\tres, err := DN42(\"198.51.100.25,edge.ams01.provider\", time.Second, \"\", false)\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"Netherlands\", res.Country)\n\tassert.Equal(t, \"Noord-Holland\", res.Prov)\n\tassert.Equal(t, \"Amsterdam\", res.City)\n}\n\nfunc TestDN42UnknownDefaults(t *testing.T) {\n\tdir := t.TempDir()\n\tgeofeedPath := filepath.Join(dir, \"geofeed.csv\")\n\tptrPath := filepath.Join(dir, \"ptr.csv\")\n\n\trequire.NoError(t, os.WriteFile(geofeedPath, []byte(\"\"), 0o644))\n\trequire.NoError(t, os.WriteFile(ptrPath, []byte(\"\"), 0o644))\n\n\tviper.Set(\"geoFeedPath\", geofeedPath)\n\tviper.Set(\"ptrPath\", ptrPath)\n\tt.Cleanup(viper.Reset)\n\n\tres, err := DN42(\"10.0.0.1\", time.Second, \"\", false)\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"Unknown\", res.Country)\n\tassert.Empty(t, res.Prov)\n\tassert.Empty(t, res.City)\n}\n"
  },
  {
    "path": "ipgeo/ipapicom.go",
    "content": "package ipgeo\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc IPApiCom(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) {\n\turl := token.BaseOrDefault(\"http://ip-api.com/json/\") + ip + \"?fields=status,message,country,regionName,city,isp,district,as,lat,lon\"\n\tclient := util.NewGeoHTTPClient(timeout)\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ip-api.com: failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0\")\n\tcontent, err := client.Do(req)\n\tif err != nil {\n\t\tlog.Println(\"ip-api.com 请求超时(2s)，请切换其他API使用\")\n\t\treturn nil, err\n\t}\n\tdefer content.Body.Close()\n\tbody, err := io.ReadAll(content.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ip-api.com: failed to read response: %w\", err)\n\t}\n\tres := gjson.ParseBytes(body)\n\n\tif res.Get(\"status\").String() != \"success\" {\n\t\treturn &IPGeoData{}, errors.New(\"超过API阈值\")\n\t}\n\n\tre := regexp.MustCompile(\"[0-9]+\")\n\tvar country = res.Get(\"country\").String()\n\tvar prov = res.Get(\"region\").String()\n\tvar city = res.Get(\"city\").String()\n\tvar district = res.Get(\"district\").String()\n\tif util.StringInSlice(country, []string{\"Hong Kong\", \"Taiwan\", \"Macao\"}) {\n\t\tdistrict = prov + \" \" + city + \" \" + district\n\t\tcity = country\n\t\tprov = \"\"\n\t\tcountry = \"China\"\n\t}\n\tlat, _ := strconv.ParseFloat(res.Get(\"lat\").String(), 32)\n\tlng, _ := strconv.ParseFloat(res.Get(\"lon\").String(), 32)\n\n\treturn &IPGeoData{\n\t\tAsnumber: re.FindString(res.Get(\"as\").String()),\n\t\tCountry:  country,\n\t\tCity:     city,\n\t\tProv:     prov,\n\t\tDistrict: district,\n\t\tOwner:    res.Get(\"isp\").String(),\n\t\tLat:      lat,\n\t\tLng:      lng,\n\t}, nil\n}\n"
  },
  {
    "path": "ipgeo/ipdbone.go",
    "content": "package ipgeo\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\n\t\"github.com/nxtrace/NTrace-core/config\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\n// LangMap shows language mapping for IPDB.One API\nvar LangMap = map[string]string{\n\t\"en\": \"en\",\n\t\"cn\": \"zh\",\n}\n\n// IPDBOneConfig holds the configuration for IPDB.One service\ntype IPDBOneConfig struct {\n\tBaseURL string\n\tApiID   string\n\tApiKey  string\n}\n\n// GetDefaultConfig returns the default configuration with fallback values\nfunc GetDefaultConfig() *IPDBOneConfig {\n\treturn &IPDBOneConfig{\n\t\tBaseURL: util.GetEnvDefault(\"IPDBONE_BASE_URL\", \"https://api.ipdb.one\"),\n\t\tApiID:   util.GetEnvDefault(\"IPDBONE_API_ID\", \"\"),\n\t\tApiKey:  util.GetEnvDefault(\"IPDBONE_API_KEY\", \"\"),\n\t}\n}\n\n// IPDBOneTokenCache manages the caching of auth tokens\ntype IPDBOneTokenCache struct {\n\ttoken     string\n\texpiresAt time.Time\n\tmutex     sync.RWMutex\n}\n\n// GetToken retrieves cached token if valid, otherwise returns empty string\nfunc (c *IPDBOneTokenCache) GetToken() string {\n\tc.mutex.RLock()\n\tdefer c.mutex.RUnlock()\n\n\tif c.token == \"\" || time.Now().After(c.expiresAt) {\n\t\treturn \"\"\n\t}\n\treturn c.token\n}\n\n// SetToken updates the token with its expiration time\nfunc (c *IPDBOneTokenCache) SetToken(token string, expiresIn time.Duration) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tc.token = token\n\tc.expiresAt = time.Now().Add(expiresIn)\n}\n\n// IPDBOneClient handles communication with IPDB.One API\ntype IPDBOneClient struct {\n\tconfig     *IPDBOneConfig\n\ttokenCache *IPDBOneTokenCache\n\ttokenInit  *sync.Once\n\thttpClient *http.Client\n}\n\n// NewIPDBOneClient creates a new client for IPDB.One with default configuration\nfunc NewIPDBOneClient() *IPDBOneClient {\n\treturn &IPDBOneClient{\n\t\tconfig:     GetDefaultConfig(),\n\t\ttokenCache: &IPDBOneTokenCache{},\n\t\ttokenInit:  &sync.Once{},\n\t\thttpClient: util.NewGeoHTTPClient(3 * time.Second),\n\t}\n}\n\nfunc (c *IPDBOneClient) cloneWithTimeout(timeout time.Duration) *IPDBOneClient {\n\tif c == nil || timeout <= 0 {\n\t\treturn c\n\t}\n\treturn &IPDBOneClient{\n\t\tconfig:     c.config,\n\t\ttokenCache: c.tokenCache,\n\t\ttokenInit:  c.tokenInit,\n\t\thttpClient: util.NewGeoHTTPClient(timeout),\n\t}\n}\n\n// fetchToken requests a new authentication token from the API\nfunc (c *IPDBOneClient) fetchToken() error {\n\tauthURL := c.config.BaseURL + \"/auth/requestToken/query\"\n\n\treq, err := http.NewRequest(\"GET\", authURL, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"NextTrace/\"+config.Version)\n\treq.Header.Set(\"x-api-id\", c.config.ApiID)\n\treq.Header.Set(\"x-api-key\", c.config.ApiKey)\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tstatusCode := gjson.Get(string(body), \"code\").Int()\n\tstatusMessage := gjson.Get(string(body), \"message\").String()\n\n\tif statusCode != 200 {\n\t\treturn errors.New(\"failed to authenticate: \" + statusMessage)\n\t}\n\n\ttoken := gjson.Get(string(body), \"data.token\").String()\n\tif token == \"\" {\n\t\treturn errors.New(\"authentication failed: empty token received\")\n\t}\n\n\t// Cache token with a 30-second expiration\n\tc.tokenCache.SetToken(token, 30*time.Second)\n\treturn nil\n}\n\n// ensureToken makes sure a valid token is available, fetching a new one if needed\nfunc (c *IPDBOneClient) ensureToken() error {\n\tvar initErr error\n\n\t// Ensure API credentials are set\n\tif c.config.ApiID == \"\" || c.config.ApiKey == \"\" {\n\t\treturn errors.New(\"api id or api key is not set\")\n\t}\n\n\t// Initialize token the first time this is called\n\tc.tokenInit.Do(func() {\n\t\tinitErr = c.fetchToken()\n\t})\n\n\tif initErr != nil {\n\t\treturn initErr\n\t}\n\n\t// If token expired or not available, get a new one\n\tif c.tokenCache.GetToken() == \"\" {\n\t\treturn c.fetchToken()\n\t}\n\n\treturn nil\n}\n\n// LookupIP queries the IP information from IPDB.One\nfunc (c *IPDBOneClient) LookupIP(ip string, lang string) (*IPGeoData, error) {\n\n\t// Ensure we have a valid token\n\tif err := c.ensureToken(); err != nil {\n\t\treturn &IPGeoData{}, fmt.Errorf(\"ipdbone auth: %w\", err)\n\t}\n\n\t// Map language code if needed\n\tlangCode, ok := LangMap[lang]\n\tif !ok {\n\t\tlangCode = \"en\" // Default to English\n\t}\n\n\t// Query the IP information\n\tqueryURL := c.config.BaseURL + \"/query/\" + ip + \"?lang=\" + langCode\n\n\treq, err := http.NewRequest(\"GET\", queryURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"NextTrace/\"+config.Version)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.tokenCache.GetToken())\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstatusCode := gjson.Get(string(body), \"code\").Int()\n\tif statusCode != 200 {\n\t\treturn nil, errors.New(\"failed to get IP info: \" + gjson.Get(string(body), \"message\").String())\n\t}\n\n\treturn parseIPDBOneResponse(ip, body)\n}\n\n// parseIPDBOneResponse converts the API response to an IPGeoData struct\nfunc parseIPDBOneResponse(ip string, responseBody []byte) (*IPGeoData, error) {\n\tdata := gjson.Get(string(responseBody), \"data\")\n\tresult := &IPGeoData{\n\t\tIP: ip,\n\t}\n\tparseIPDBOneGeo(data.Get(\"geo\"), result)\n\tparseIPDBOneRouting(data.Get(\"routing\"), result)\n\treturn result, nil\n}\n\nfunc hasJSONValue(result gjson.Result) bool {\n\treturn result.Exists() && result.Type != gjson.Null\n}\n\nfunc parseIPDBOneGeo(geoData gjson.Result, result *IPGeoData) {\n\tif result == nil || !geoData.Exists() {\n\t\treturn\n\t}\n\n\tcoordinate := geoData.Get(\"coordinate\")\n\tif hasJSONValue(coordinate) && coordinate.IsArray() && len(coordinate.Array()) >= 2 {\n\t\tresult.Lat = coordinate.Array()[0].Float()\n\t\tresult.Lng = coordinate.Array()[1].Float()\n\t}\n\n\tif country := geoData.Get(\"country\"); hasJSONValue(country) {\n\t\tresult.Country = country.String()\n\t}\n\tif region := geoData.Get(\"region\"); hasJSONValue(region) {\n\t\tresult.Prov = region.String()\n\t}\n\tif city := geoData.Get(\"city\"); hasJSONValue(city) {\n\t\tresult.City = city.String()\n\t}\n}\n\nfunc parseIPDBOneRouting(routingData gjson.Result, result *IPGeoData) {\n\tif result == nil || !routingData.Exists() {\n\t\treturn\n\t}\n\n\tasnData := routingData.Get(\"asn\")\n\tif number := asnData.Get(\"number\"); hasJSONValue(number) {\n\t\tresult.Asnumber = strconv.FormatInt(number.Int(), 10)\n\t}\n\tif asnName := routingData.Get(\"asn.name\"); hasJSONValue(asnName) {\n\t\tresult.Owner = asnName.String()\n\t}\n\tif domain := routingData.Get(\"asn.domain\"); hasJSONValue(domain) {\n\t\tresult.Owner = domain.String()\n\t}\n\tif asName := routingData.Get(\"asn.asname\"); hasJSONValue(asName) {\n\t\tresult.Whois = asName.String()\n\t}\n}\n\n// Global client instance for backward compatibility\nvar defaultClient = NewIPDBOneClient()\n\n// IPDBOne looks up IP information from IPDB.One (maintains backward compatibility)\nfunc IPDBOne(ip string, timeout time.Duration, lang string, _ bool) (*IPGeoData, error) {\n\tclient := defaultClient\n\tif timeout > 0 {\n\t\tclient = defaultClient.cloneWithTimeout(timeout)\n\t}\n\treturn client.LookupIP(ip, lang)\n}\n"
  },
  {
    "path": "ipgeo/ipfilter.go",
    "content": "package ipgeo\n\nimport (\n\t\"net\"\n)\n\ntype cidrFilterRule struct {\n\tcidr  string\n\twhois string\n}\n\nvar reservedCIDRRules = []cidrFilterRule{\n\t{cidr: \"0.0.0.0/8\", whois: \"RFC1122\"},\n\t{cidr: \"100.64.0.0/10\", whois: \"RFC6598\"},\n\t{cidr: \"127.0.0.0/8\", whois: \"RFC1122\"},\n\t{cidr: \"169.254.0.0/16\", whois: \"RFC3927\"},\n\t{cidr: \"192.0.0.0/24\", whois: \"RFC6890\"},\n\t{cidr: \"192.0.2.0/24\", whois: \"RFC5737\"},\n\t{cidr: \"192.88.99.0/24\", whois: \"RFC3068\"},\n\t{cidr: \"198.18.0.0/15\", whois: \"RFC2544\"},\n\t{cidr: \"198.51.100.0/24\", whois: \"RFC5737\"},\n\t{cidr: \"203.0.113.0/24\", whois: \"RFC5737\"},\n\t{cidr: \"224.0.0.0/4\", whois: \"RFC5771\"},\n\t{cidr: \"255.255.255.255/32\", whois: \"RFC0919\"},\n\t{cidr: \"240.0.0.0/4\", whois: \"RFC1112\"},\n\t{cidr: \"fe80::/10\", whois: \"RFC4291\"},\n\t{cidr: \"ff00::/8\", whois: \"RFC4291\"},\n\t{cidr: \"fec0::/10\", whois: \"RFC3879\"},\n\t{cidr: \"fe00::/9\", whois: \"RFC4291\"},\n\t{cidr: \"64:ff9b::/96\", whois: \"RFC6052\"},\n\t{cidr: \"0::/96\", whois: \"RFC4291\"},\n\t{cidr: \"64:ff9b:1::/48\", whois: \"RFC6052\"},\n\t{cidr: \"2001:db8::/32\", whois: \"RFC3849\"},\n\t{cidr: \"2002::/16\", whois: \"RFC3056\"},\n}\n\nvar dodCIDRRules = []cidrFilterRule{\n\t{cidr: \"6.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"7.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"11.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"21.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"22.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"26.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"28.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"29.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"30.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"33.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"55.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"214.0.0.0/8\", whois: \"DOD\"},\n\t{cidr: \"215.0.0.0/8\", whois: \"DOD\"},\n}\n\nfunc cidrRangeContains(cidrRange string, checkIP string) bool {\n\t_, ipNet, err := net.ParseCIDR(cidrRange)\n\tif err != nil {\n\t\treturn false\n\t}\n\tsecondIP := net.ParseIP(checkIP)\n\treturn ipNet.Contains(secondIP)\n}\n\nfunc matchCIDRFilterRule(ip string, rules []cidrFilterRule) (string, bool) {\n\tfor _, rule := range rules {\n\t\tif cidrRangeContains(rule.cidr, ip) {\n\t\t\treturn rule.whois, true\n\t\t}\n\t}\n\treturn \"\", false\n}\n\nfunc classifyPrivateIP(parsedIP net.IP, rawIP string) (string, bool) {\n\tif parsedIP == nil || !parsedIP.IsPrivate() {\n\t\treturn \"\", false\n\t}\n\tif cidrRangeContains(\"fc00::/7\", rawIP) {\n\t\treturn \"RFC4193\", true\n\t}\n\treturn \"RFC1918\", true\n}\n\nfunc isInvalidScopedIPv6(parsedIP net.IP, rawIP string) bool {\n\treturn parsedIP != nil && parsedIP.To4() == nil && !cidrRangeContains(\"2000::/3\", rawIP)\n}\n\n// Filter 被选到的返回 geodata, true  否则返回 nil, false\nfunc Filter(ip string) (*IPGeoData, bool) {\n\tparsedIP := net.ParseIP(ip)\n\tif parsedIP == nil {\n\t\treturn nil, false\n\t}\n\n\tif whois, ok := matchCIDRFilterRule(ip, reservedCIDRRules); ok {\n\t\treturn &IPGeoData{Whois: whois}, true\n\t}\n\tif whois, ok := classifyPrivateIP(parsedIP, ip); ok {\n\t\treturn &IPGeoData{Whois: whois}, true\n\t}\n\tif whois, ok := matchCIDRFilterRule(ip, dodCIDRRules); ok {\n\t\treturn &IPGeoData{Whois: whois}, true\n\t}\n\tif isInvalidScopedIPv6(parsedIP, ip) {\n\t\treturn &IPGeoData{Whois: \"INVALID\"}, true\n\t}\n\n\treturn nil, false\n}\n"
  },
  {
    "path": "ipgeo/ipfilter_test.go",
    "content": "package ipgeo\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// ──────── cidrRangeContains ────────\n\nfunc TestCidrRangeContains_Match(t *testing.T) {\n\tassert.True(t, cidrRangeContains(\"10.0.0.0/8\", \"10.1.2.3\"))\n}\n\nfunc TestCidrRangeContains_NoMatch(t *testing.T) {\n\tassert.False(t, cidrRangeContains(\"10.0.0.0/8\", \"11.0.0.1\"))\n}\n\nfunc TestCidrRangeContains_InvalidCIDR(t *testing.T) {\n\tassert.False(t, cidrRangeContains(\"invalid\", \"10.0.0.1\"))\n}\n\nfunc TestCidrRangeContains_InvalidIP(t *testing.T) {\n\t// net.ParseIP(\"notanip\") returns nil; ipNet.Contains(nil) returns false\n\tassert.False(t, cidrRangeContains(\"10.0.0.0/8\", \"notanip\"))\n}\n\n// ──────── Filter: IPv4 RFC ranges ────────\n\nfunc TestFilter_RFC1918_Private(t *testing.T) {\n\tfor _, ip := range []string{\"10.0.0.1\", \"172.16.0.1\", \"192.168.1.1\"} {\n\t\tgeo, ok := Filter(ip)\n\t\trequire.True(t, ok, \"expected %s to be filtered\", ip)\n\t\tassert.Equal(t, \"RFC1918\", geo.Whois, \"ip=%s\", ip)\n\t}\n}\n\nfunc TestFilter_Loopback(t *testing.T) {\n\tgeo, ok := Filter(\"127.0.0.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC1122\", geo.Whois)\n}\n\nfunc TestFilter_LinkLocal(t *testing.T) {\n\tgeo, ok := Filter(\"169.254.1.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC3927\", geo.Whois)\n}\n\nfunc TestFilter_CGNAT(t *testing.T) {\n\tgeo, ok := Filter(\"100.64.0.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC6598\", geo.Whois)\n}\n\nfunc TestFilter_Documentation_192_0_2(t *testing.T) {\n\tgeo, ok := Filter(\"192.0.2.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC5737\", geo.Whois)\n}\n\nfunc TestFilter_Documentation_198_51_100(t *testing.T) {\n\tgeo, ok := Filter(\"198.51.100.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC5737\", geo.Whois)\n}\n\nfunc TestFilter_Documentation_203_0_113(t *testing.T) {\n\tgeo, ok := Filter(\"203.0.113.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC5737\", geo.Whois)\n}\n\nfunc TestFilter_Benchmark(t *testing.T) {\n\tgeo, ok := Filter(\"198.18.0.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC2544\", geo.Whois)\n}\n\nfunc TestFilter_Multicast(t *testing.T) {\n\tgeo, ok := Filter(\"224.0.0.1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC5771\", geo.Whois)\n}\n\nfunc TestFilter_DOD(t *testing.T) {\n\tfor _, ip := range []string{\"6.0.0.1\", \"7.0.0.1\", \"11.0.0.1\", \"21.0.0.1\", \"22.0.0.1\",\n\t\t\"26.0.0.1\", \"28.0.0.1\", \"29.0.0.1\", \"30.0.0.1\", \"33.0.0.1\", \"55.0.0.1\",\n\t\t\"214.0.0.1\", \"215.0.0.1\"} {\n\t\tgeo, ok := Filter(ip)\n\t\trequire.True(t, ok, \"expected %s to be filtered as DOD\", ip)\n\t\tassert.Equal(t, \"DOD\", geo.Whois, \"ip=%s\", ip)\n\t}\n}\n\nfunc TestFilter_PublicIPv4_NotFiltered(t *testing.T) {\n\t_, ok := Filter(\"8.8.8.8\")\n\tassert.False(t, ok)\n}\n\nfunc TestFilter_PublicIPv4_1_1_1_1_NotFiltered(t *testing.T) {\n\t_, ok := Filter(\"1.1.1.1\")\n\tassert.False(t, ok)\n}\n\n// ──────── Filter: IPv6 ranges ────────\n\nfunc TestFilter_IPv6_LinkLocal(t *testing.T) {\n\tgeo, ok := Filter(\"fe80::1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC4291\", geo.Whois)\n}\n\nfunc TestFilter_IPv6_ULA(t *testing.T) {\n\tgeo, ok := Filter(\"fd00::1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC4193\", geo.Whois)\n}\n\nfunc TestFilter_IPv6_Documentation(t *testing.T) {\n\tgeo, ok := Filter(\"2001:db8::1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC3849\", geo.Whois)\n}\n\nfunc TestFilter_IPv6_Multicast(t *testing.T) {\n\tgeo, ok := Filter(\"ff02::1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"RFC4291\", geo.Whois)\n}\n\nfunc TestFilter_IPv6_GlobalUnicast_NotFiltered(t *testing.T) {\n\t_, ok := Filter(\"2606:4700::1\")\n\tassert.False(t, ok)\n}\n\nfunc TestFilter_IPv6_InvalidScope(t *testing.T) {\n\tgeo, ok := Filter(\"100::1\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"INVALID\", geo.Whois)\n}\n"
  },
  {
    "path": "ipgeo/ipgeo.go",
    "content": "package ipgeo\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype IPGeoData struct {\n\tIP        string              `json:\"ip\"`\n\tAsnumber  string              `json:\"asnumber\"`\n\tCountry   string              `json:\"country\"`\n\tCountryEn string              `json:\"country_en\"`\n\tProv      string              `json:\"prov\"`\n\tProvEn    string              `json:\"prov_en\"`\n\tCity      string              `json:\"city\"`\n\tCityEn    string              `json:\"city_en\"`\n\tDistrict  string              `json:\"district\"`\n\tOwner     string              `json:\"owner\"`\n\tIsp       string              `json:\"isp\"`\n\tDomain    string              `json:\"domain\"`\n\tWhois     string              `json:\"whois\"`\n\tLat       float64             `json:\"lat\"`\n\tLng       float64             `json:\"lng\"`\n\tPrefix    string              `json:\"prefix\"`\n\tRouter    map[string][]string `json:\"router\"`\n\tSource    string              `json:\"source\"`\n}\n\ntype Source = func(ip string, timeout time.Duration, lang string, maptrace bool) (*IPGeoData, error)\n\nfunc GetSource(s string) Source {\n\tswitch strings.ToUpper(s) {\n\tcase \"DN42\":\n\t\treturn DN42\n\tcase \"LEOMOEAPI\":\n\t\treturn LeoIP\n\tcase \"IP.SB\":\n\t\treturn IPSB\n\tcase \"IPINSIGHT\":\n\t\treturn IPInSight\n\tcase \"IPAPI.COM\":\n\t\treturn IPApiCom\n\tcase \"IP-API.COM\":\n\t\treturn IPApiCom\n\tcase \"IPINFO\":\n\t\treturn IPInfo\n\tcase \"IPINFOLOCAL\":\n\t\treturn IPInfoLocal\n\tcase \"CHUNZHEN\":\n\t\treturn Chunzhen\n\tcase \"DISABLE-GEOIP\":\n\t\treturn disableGeoIP\n\tcase \"IPDB.ONE\":\n\t\treturn IPDBOne\n\tdefault:\n\t\treturn LeoIP\n\t}\n}\n\nfunc GetSourceWithGeoDNS(s string, dotServer string) Source {\n\tbase := GetSource(s)\n\tdotServer = strings.TrimSpace(strings.ToLower(dotServer))\n\tif base == nil || dotServer == \"\" {\n\t\treturn base\n\t}\n\treturn func(ip string, timeout time.Duration, lang string, maptrace bool) (*IPGeoData, error) {\n\t\treturn util.WithGeoDNSResolver(dotServer, func() (*IPGeoData, error) {\n\t\t\treturn base(ip, timeout, lang, maptrace)\n\t\t})\n\t}\n}\n\nfunc disableGeoIP(string, time.Duration, string, bool) (*IPGeoData, error) {\n\treturn &IPGeoData{}, nil\n}\n"
  },
  {
    "path": "ipgeo/ipgeo_test.go",
    "content": "package ipgeo\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetSourceMappings(t *testing.T) {\n\tt.Helper()\n\ttests := []struct {\n\t\tname  string\n\t\tinput string\n\t\twant  Source\n\t}{\n\t\t{name: \"dn42\", input: \"DN42\", want: DN42},\n\t\t{name: \"leo default\", input: \"LEOMOEAPI\", want: LeoIP},\n\t\t{name: \"ipsb\", input: \"ip.sb\", want: IPSB},\n\t\t{name: \"ipinsight\", input: \"ipinsight\", want: IPInSight},\n\t\t{name: \"ipapi alias\", input: \"ip-api.com\", want: IPApiCom},\n\t\t{name: \"ipapi uppercase\", input: \"IPAPI.COM\", want: IPApiCom},\n\t\t{name: \"ipinfo\", input: \"IPINFO\", want: IPInfo},\n\t\t{name: \"ipinfo local\", input: \"ipinfolocal\", want: IPInfoLocal},\n\t\t{name: \"chunzhen\", input: \"ChunZhen\", want: Chunzhen},\n\t\t{name: \"disable geoip\", input: \"disable-geoip\", want: disableGeoIP},\n\t\t{name: \"ipdb\", input: \"IPDB.One\", want: IPDBOne},\n\t\t{name: \"fallback\", input: \"unknown\", want: LeoIP},\n\t}\n\n\tfor _, tc := range tests {\n\t\ttc := tc\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := GetSource(tc.input)\n\t\t\trequire.NotNil(t, got)\n\t\t\tassert.Equal(t, reflect.ValueOf(tc.want).Pointer(), reflect.ValueOf(got).Pointer())\n\t\t})\n\t}\n}\n\nfunc TestDisableGeoIP(t *testing.T) {\n\tres, err := disableGeoIP(\"1.1.1.1\", time.Second, \"en\", false)\n\trequire.NoError(t, err)\n\tassert.Equal(t, &IPGeoData{}, res)\n}\n"
  },
  {
    "path": "ipgeo/ipinfo.go",
    "content": "package ipgeo\n\nimport (\n\t\"io\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc IPInfo(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) {\n\turl := token.BaseOrDefault(\"http://ipinfo.io/\") + ip + \"?token=\" + token.ipinfo\n\tclient := util.NewGeoHTTPClient(timeout)\n\tresp, err := client.Get(url)\n\t//resp, err := http.Get(\"https://ipinfo.io/\" + ip + \"?token=\" + token.ipinfo)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tres := gjson.ParseBytes(body)\n\n\t// ISO-3166 转换\n\tvar countryMap = map[string]string{\n\t\t\"AF\": \"Afghanistan\",\n\t\t\"AX\": \"Åland Islands\",\n\t\t\"AL\": \"Albania\",\n\t\t\"DZ\": \"Algeria\",\n\t\t\"AS\": \"American Samoa\",\n\t\t\"AD\": \"Andorra\",\n\t\t\"AO\": \"Angola\",\n\t\t\"AI\": \"Anguilla\",\n\t\t\"AQ\": \"Antarctica\",\n\t\t\"AG\": \"Antigua and Barbuda\",\n\t\t\"AR\": \"Argentina\",\n\t\t\"AM\": \"Armenia\",\n\t\t\"AW\": \"Aruba\",\n\t\t\"AU\": \"Australia\",\n\t\t\"AT\": \"Austria\",\n\t\t\"AZ\": \"Azerbaijan\",\n\t\t\"BH\": \"Bahrain\",\n\t\t\"BS\": \"Bahamas\",\n\t\t\"BD\": \"Bangladesh\",\n\t\t\"BB\": \"Barbados\",\n\t\t\"BY\": \"Belarus\",\n\t\t\"BE\": \"Belgium\",\n\t\t\"BZ\": \"Belize\",\n\t\t\"BJ\": \"Benin\",\n\t\t\"BM\": \"Bermuda\",\n\t\t\"BT\": \"Bhutan\",\n\t\t\"BO\": \"Bolivia\",\n\t\t\"BQ\": \"Bonaire\",\n\t\t\"BA\": \"Bosnia and Herzegovina\",\n\t\t\"BW\": \"Botswana\",\n\t\t\"BV\": \"Bouvet Island\",\n\t\t\"BR\": \"Brazil\",\n\t\t\"IO\": \"British Indian Ocean Territory\",\n\t\t\"BN\": \"Brunei Darussalam\",\n\t\t\"BG\": \"Bulgaria\",\n\t\t\"BF\": \"Burkina Faso\",\n\t\t\"BI\": \"Burundi\",\n\t\t\"KH\": \"Cambodia\",\n\t\t\"CM\": \"Cameroon\",\n\t\t\"CA\": \"Canada\",\n\t\t\"CV\": \"Cape Verde\",\n\t\t\"KY\": \"Cayman Islands\",\n\t\t\"CF\": \"Central African Republic\",\n\t\t\"TD\": \"Chad\",\n\t\t\"CL\": \"Chile\",\n\t\t\"CN\": \"China\",\n\t\t\"CX\": \"Christmas Island\",\n\t\t\"CC\": \"Cocos (Keeling) Islands\",\n\t\t\"CO\": \"Colombia\",\n\t\t\"KM\": \"Comoros\",\n\t\t\"CG\": \"Congo\",\n\t\t\"CD\": \"Congo\",\n\t\t\"CK\": \"Cook Islands\",\n\t\t\"CR\": \"Costa Rica\",\n\t\t\"CI\": \"Côte d'Ivoire\",\n\t\t\"HR\": \"Croatia\",\n\t\t\"CU\": \"Cuba\",\n\t\t\"CW\": \"Curaçao\",\n\t\t\"CY\": \"Cyprus\",\n\t\t\"CZ\": \"Czech Republic\",\n\t\t\"DK\": \"Denmark\",\n\t\t\"DJ\": \"Djibouti\",\n\t\t\"DM\": \"Dominica\",\n\t\t\"DO\": \"Dominican Republic\",\n\t\t\"EC\": \"Ecuador\",\n\t\t\"EG\": \"Egypt\",\n\t\t\"SV\": \"El Salvador\",\n\t\t\"GQ\": \"Equatorial Guinea\",\n\t\t\"ER\": \"Eritrea\",\n\t\t\"EE\": \"Estonia\",\n\t\t\"ET\": \"Ethiopia\",\n\t\t\"FK\": \"Falkland Islands (Malvinas)\",\n\t\t\"FO\": \"Faroe Islands\",\n\t\t\"FJ\": \"Fiji\",\n\t\t\"FI\": \"Finland\",\n\t\t\"FR\": \"France\",\n\t\t\"GF\": \"French Guiana\",\n\t\t\"PF\": \"French Polynesia\",\n\t\t\"TF\": \"French Southern Territories\",\n\t\t\"GA\": \"Gabon\",\n\t\t\"GM\": \"Gambia\",\n\t\t\"GE\": \"Georgia\",\n\t\t\"DE\": \"Germany\",\n\t\t\"GH\": \"Ghana\",\n\t\t\"GI\": \"Gibraltar\",\n\t\t\"GR\": \"Greece\",\n\t\t\"GL\": \"Greenland\",\n\t\t\"GD\": \"Grenada\",\n\t\t\"GP\": \"Guadeloupe\",\n\t\t\"GU\": \"Guam\",\n\t\t\"GT\": \"Guatemala\",\n\t\t\"GG\": \"Guernsey\",\n\t\t\"GN\": \"Guinea\",\n\t\t\"GW\": \"Guinea-Bissau\",\n\t\t\"GY\": \"Guyana\",\n\t\t\"HT\": \"Haiti\",\n\t\t\"HM\": \"Heard Island and McDonald Islands\",\n\t\t\"VA\": \"Holy See (Vatican City State)\",\n\t\t\"HN\": \"Honduras\",\n\t\t\"HK\": \"Hong Kong\",\n\t\t\"HU\": \"Hungary\",\n\t\t\"IS\": \"Iceland\",\n\t\t\"IN\": \"India\",\n\t\t\"ID\": \"Indonesia\",\n\t\t\"IR\": \"Iran\",\n\t\t\"IQ\": \"Iraq\",\n\t\t\"IE\": \"Ireland\",\n\t\t\"IM\": \"Isle of Man\",\n\t\t\"IL\": \"Israel\",\n\t\t\"IT\": \"Italy\",\n\t\t\"JM\": \"Jamaica\",\n\t\t\"JP\": \"Japan\",\n\t\t\"JE\": \"Jersey\",\n\t\t\"JO\": \"Jordan\",\n\t\t\"KZ\": \"Kazakhstan\",\n\t\t\"KE\": \"Kenya\",\n\t\t\"KI\": \"Kiribati\",\n\t\t\"KP\": \"Korea\",\n\t\t\"KR\": \"Korea\",\n\t\t\"KW\": \"Kuwait\",\n\t\t\"KG\": \"Kyrgyzstan\",\n\t\t\"LA\": \"Lao People's Democratic Republic\",\n\t\t\"LV\": \"Latvia\",\n\t\t\"LB\": \"Lebanon\",\n\t\t\"LS\": \"Lesotho\",\n\t\t\"LR\": \"Liberia\",\n\t\t\"LY\": \"Libya\",\n\t\t\"LI\": \"Liechtenstein\",\n\t\t\"LT\": \"Lithuania\",\n\t\t\"LU\": \"Luxembourg\",\n\t\t\"MO\": \"Macao\",\n\t\t\"MK\": \"Macedonia\",\n\t\t\"MG\": \"Madagascar\",\n\t\t\"MW\": \"Malawi\",\n\t\t\"MY\": \"Malaysia\",\n\t\t\"MV\": \"Maldives\",\n\t\t\"ML\": \"Mali\",\n\t\t\"MT\": \"Malta\",\n\t\t\"MH\": \"Marshall Islands\",\n\t\t\"MQ\": \"Martinique\",\n\t\t\"MR\": \"Mauritania\",\n\t\t\"MU\": \"Mauritius\",\n\t\t\"YT\": \"Mayotte\",\n\t\t\"MX\": \"Mexico\",\n\t\t\"FM\": \"Micronesia\",\n\t\t\"MD\": \"Moldova\",\n\t\t\"MC\": \"Monaco\",\n\t\t\"MN\": \"Mongolia\",\n\t\t\"ME\": \"Montenegro\",\n\t\t\"MS\": \"Montserrat\",\n\t\t\"MA\": \"Morocco\",\n\t\t\"MZ\": \"Mozambique\",\n\t\t\"MM\": \"Myanmar\",\n\t\t\"NA\": \"Namibia\",\n\t\t\"NR\": \"Nauru\",\n\t\t\"NP\": \"Nepal\",\n\t\t\"NL\": \"Netherlands\",\n\t\t\"NC\": \"New Caledonia\",\n\t\t\"NZ\": \"New Zealand\",\n\t\t\"NI\": \"Nicaragua\",\n\t\t\"NE\": \"Niger\",\n\t\t\"NG\": \"Nigeria\",\n\t\t\"NU\": \"Niue\",\n\t\t\"NF\": \"Norfolk Island\",\n\t\t\"MP\": \"Northern Mariana Islands\",\n\t\t\"NO\": \"Norway\",\n\t\t\"OM\": \"Oman\",\n\t\t\"PK\": \"Pakistan\",\n\t\t\"PW\": \"Palau\",\n\t\t\"PS\": \"Palestine\",\n\t\t\"PA\": \"Panama\",\n\t\t\"PG\": \"Papua New Guinea\",\n\t\t\"PY\": \"Paraguay\",\n\t\t\"PE\": \"Peru\",\n\t\t\"PH\": \"Philippines\",\n\t\t\"PN\": \"Pitcairn\",\n\t\t\"PL\": \"Poland\",\n\t\t\"PT\": \"Portugal\",\n\t\t\"PR\": \"Puerto Rico\",\n\t\t\"QA\": \"Qatar\",\n\t\t\"RE\": \"Réunion\",\n\t\t\"RO\": \"Romania\",\n\t\t\"RU\": \"Russian Federation\",\n\t\t\"RW\": \"Rwanda\",\n\t\t\"BL\": \"Saint Barthélemy\",\n\t\t\"SH\": \"Saint Helena\",\n\t\t\"KN\": \"Saint Kitts and Nevis\",\n\t\t\"LC\": \"Saint Lucia\",\n\t\t\"MF\": \"Saint Martin (French part)\",\n\t\t\"PM\": \"Saint Pierre and Miquelon\",\n\t\t\"VC\": \"Saint Vincent and the Grenadines\",\n\t\t\"WS\": \"Samoa\",\n\t\t\"SM\": \"San Marino\",\n\t\t\"ST\": \"Sao Tome and Principe\",\n\t\t\"SA\": \"Saudi Arabia\",\n\t\t\"SN\": \"Senegal\",\n\t\t\"RS\": \"Serbia\",\n\t\t\"SC\": \"Seychelles\",\n\t\t\"SL\": \"Sierra Leone\",\n\t\t\"SG\": \"Singapore\",\n\t\t\"SX\": \"Sint Maarten (Dutch part)\",\n\t\t\"SK\": \"Slovakia\",\n\t\t\"SI\": \"Slovenia\",\n\t\t\"SB\": \"Solomon Islands\",\n\t\t\"SO\": \"Somalia\",\n\t\t\"ZA\": \"South Africa\",\n\t\t\"GS\": \"South Georgia and the South Sandwich Islands\",\n\t\t\"SS\": \"South Sudan\",\n\t\t\"ES\": \"Spain\",\n\t\t\"LK\": \"Sri Lanka\",\n\t\t\"SD\": \"Sudan\",\n\t\t\"SR\": \"Suriname\",\n\t\t\"SJ\": \"Svalbard and Jan Mayen\",\n\t\t\"SZ\": \"Swaziland\",\n\t\t\"SE\": \"Sweden\",\n\t\t\"CH\": \"Switzerland\",\n\t\t\"SY\": \"Syrian Arab Republic\",\n\t\t\"TW\": \"Taiwan\",\n\t\t\"TJ\": \"Tajikistan\",\n\t\t\"TZ\": \"Tanzania\",\n\t\t\"TH\": \"Thailand\",\n\t\t\"TL\": \"Timor-Leste\",\n\t\t\"TG\": \"Togo\",\n\t\t\"TK\": \"Tokelau\",\n\t\t\"TO\": \"Tonga\",\n\t\t\"TT\": \"Trinidad and Tobago\",\n\t\t\"TN\": \"Tunisia\",\n\t\t\"TR\": \"Turkey\",\n\t\t\"TM\": \"Turkmenistan\",\n\t\t\"TC\": \"Turks and Caicos Islands\",\n\t\t\"TV\": \"Tuvalu\",\n\t\t\"UG\": \"Uganda\",\n\t\t\"UA\": \"Ukraine\",\n\t\t\"AE\": \"United Arab Emirates\",\n\t\t\"GB\": \"United Kingdom\",\n\t\t\"US\": \"United States of America\",\n\t\t\"UM\": \"United States Minor Outlying Islands\",\n\t\t\"UY\": \"Uruguay\",\n\t\t\"UZ\": \"Uzbekistan\",\n\t\t\"VU\": \"Vanuatu\",\n\t\t\"VE\": \"Venezuela\",\n\t\t\"VN\": \"Viet Nam\",\n\t\t\"VG\": \"Virgin Islands\",\n\t\t\"VI\": \"Virgin Islands\",\n\t\t\"WF\": \"Wallis and Futuna\",\n\t\t\"EH\": \"Western Sahara\",\n\t\t\"YE\": \"Yemen\",\n\t\t\"ZM\": \"Zambia\",\n\t\t\"ZW\": \"Zimbabwe\",\n\t}\n\tvar country = res.Get(\"country\").String()\n\tvar prov = res.Get(\"region\").String()\n\tvar city = res.Get(\"city\").String()\n\tvar district = \"\"\n\tif util.StringInSlice(country, []string{\"TW\", \"MO\", \"HK\"}) {\n\t\tdistrict = prov + \" \" + city\n\t\tcity = countryMap[country]\n\t\tprov = \"\"\n\t\tcountry = \"CN\"\n\t}\n\tcountry = countryMap[country]\n\n\tvar anycast = false\n\tif res.Get(\"anycast\").String() == \"true\" {\n\t\tcountry = \"ANYCAST\"\n\t\tprov = \"ANYCAST\"\n\t\tcity = \"\"\n\t\tanycast = true\n\t}\n\n\ti := strings.Index(res.Get(\"org\").String(), \" \")\n\tvar owner string\n\tif i == -1 {\n\t\towner = \"\"\n\t} else {\n\t\towner = res.Get(\"org\").String()[i:]\n\t}\n\n\tvar asnumber = \"\"\n\t// 有时候不返回asn或其本身没有asn\n\tif strings.HasPrefix(res.Get(\"org\").String(), \"AS\") {\n\t\tasnumber = strings.Fields(strings.TrimPrefix(res.Get(\"org\").String(), \"AS\"))[0]\n\t}\n\n\t//\"loc\": \"34.0522,-118.2437\",\n\tvar lat, lng float64\n\tif res.Get(\"loc\").String() != \"\" {\n\t\tlat, _ = strconv.ParseFloat(strings.Split(res.Get(\"loc\").String(), \",\")[0], 32)\n\t\tlng, _ = strconv.ParseFloat(strings.Split(res.Get(\"loc\").String(), \",\")[1], 32)\n\t}\n\tif anycast {\n\t\tlat, lng = 0, 0\n\t}\n\n\treturn &IPGeoData{\n\t\tAsnumber: asnumber,\n\t\tCountry:  country,\n\t\tCity:     city,\n\t\tProv:     prov,\n\t\tDistrict: district,\n\t\tOwner:    owner,\n\t\tLat:      lat,\n\t\tLng:      lng,\n\t}, nil\n}\n"
  },
  {
    "path": "ipgeo/ipinfoLocal.go",
    "content": "package ipgeo\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/oschwald/maxminddb-golang\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nconst (\n\tipinfoDataBaseFilename = \"ipinfoLocal.mmdb\"\n)\n\n// Cache the path of the ipinfoLocal.mmdb file\nvar ipinfoDataBasePath = \"\"\n\n// We will try to get the path of the ipinfoLocal.mmdb file in the following order:\n// 1. Use the value of the environment variable NEXTTRACE_IPINFOLOCALPATH\n// 2. Search in the current folder and the executable folder\n// 3. Search in /usr/local/share/nexttrace/ and /usr/share/nexttrace/ (for Unix/Linux)\n// If the file is found, the path will be stored in the ipinfoDataBasePath variable\nfunc getIPInfoLocalPath() error {\n\tif ipinfoDataBasePath != \"\" {\n\t\treturn nil\n\t}\n\t// NEXTTRACE_IPINFOLOCALPATH\n\tpath := util.GetEnvDefault(\"NEXTTRACE_IPINFOLOCALPATH\", \"\")\n\tif path != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tipinfoDataBasePath = path\n\t\t\treturn nil\n\t\t}\n\t\treturn errors.New(\"NEXTTRACE_IPINFOLOCALPATH is set but the file does not exist\")\n\t}\n\tvar folders []string\n\t// current folder\n\tif cur, err := os.Getwd(); err == nil {\n\t\tfolders = append(folders, cur+string(filepath.Separator))\n\t}\n\t// exeutable folder\n\tif exe, err := os.Executable(); err == nil {\n\t\tfolders = append(folders, filepath.Dir(exe)+string(filepath.Separator))\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tfolders = append(folders, \"/usr/local/share/nexttrace/\")\n\t\tfolders = append(folders, \"/usr/share/nexttrace/\")\n\t}\n\tfor _, folder := range folders {\n\t\tif _, err := os.Stat(folder + ipinfoDataBaseFilename); err == nil {\n\t\t\tipinfoDataBasePath = folder + ipinfoDataBaseFilename\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn errors.New(\"no ipinfoLocal.mmdb found\")\n}\n\nfunc IPInfoLocal(ip string, _ time.Duration, _ string, _ bool) (*IPGeoData, error) {\n\tif err := getIPInfoLocalPath(); err != nil {\n\t\treturn nil, fmt.Errorf(\"ipinfoLocal: cannot find ipinfoLocal.mmdb: %w\", err)\n\t}\n\tregion, err := maxminddb.Open(ipinfoDataBasePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ipinfoLocal: cannot open %s: %w\", ipinfoDataBasePath, err)\n\t}\n\tdefer func(region *maxminddb.Reader) {\n\t\t_ = region.Close()\n\t}(region)\n\tvar record interface{}\n\tsearchErr := region.Lookup(net.ParseIP(ip), &record)\n\tif searchErr != nil {\n\t\treturn &IPGeoData{}, errors.New(\"no results\")\n\t}\n\trecordMap, ok := record.(map[string]interface{})\n\tif !ok {\n\t\treturn &IPGeoData{}, errors.New(\"ipinfoLocal: unexpected record format\")\n\t}\n\tcountryName, _ := recordMap[\"country_name\"].(string)\n\tcountryCode, _ := recordMap[\"country\"].(string)\n\tprov := \"\"\n\tif countryCode == \"HK\" {\n\t\tcountryName = \"China\"\n\t\tprov = \"Hong Kong\"\n\t}\n\tif countryCode == \"TW\" {\n\t\tcountryName = \"China\"\n\t\tprov = \"Taiwan\"\n\t}\n\tasnStr, _ := recordMap[\"asn\"].(string)\n\tasName, _ := recordMap[\"as_name\"].(string)\n\treturn &IPGeoData{\n\t\tAsnumber: strings.TrimPrefix(asnStr, \"AS\"),\n\t\tCountry:  countryName,\n\t\tCity:     \"\",\n\t\tProv:     prov,\n\t\tOwner:    asName,\n\t}, nil\n}\n"
  },
  {
    "path": "ipgeo/ipinsight.go",
    "content": "package ipgeo\n\nimport (\n\t\"io\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc IPInSight(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) {\n\tclient := util.NewGeoHTTPClient(timeout)\n\tresp, err := client.Get(token.BaseOrDefault(\"https://api.ipinsight.io/ip/\") + ip + \"?token=\" + token.ipinsight)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tres := gjson.ParseBytes(body)\n\n\treturn &IPGeoData{\n\t\tCountry: res.Get(\"country_name\").String(),\n\t\tCity:    res.Get(\"city_name\").String(),\n\t\tProv:    res.Get(\"region_name\").String(),\n\t}, nil\n}\n"
  },
  {
    "path": "ipgeo/ipsb.go",
    "content": "package ipgeo\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc IPSB(ip string, timeout time.Duration, _ string, _ bool) (*IPGeoData, error) {\n\turl := token.BaseOrDefault(\"https://api.ip.sb/geoip/\") + ip\n\tclient := util.NewGeoHTTPClient(timeout)\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ip.sb: failed to create request: %w\", err)\n\t}\n\t// 设置 UA，ip.sb 默认禁止 go-client User-Agent 的 api 请求\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0\")\n\tcontent, err := client.Do(req)\n\tif err != nil {\n\t\tlog.Println(\"api.ip.sb 请求超时(2s)，请切换其他API使用\")\n\t\treturn nil, err\n\t}\n\tdefer content.Body.Close()\n\tbody, err := io.ReadAll(content.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"ip.sb: failed to read response: %w\", err)\n\t}\n\tres := gjson.ParseBytes(body)\n\n\tif res.Get(\"country\").String() == \"\" {\n\t\t// 什么都拿不到，证明被Cloudflare风控了\n\t\treturn nil, fmt.Errorf(\"ip.sb: empty response, possibly blocked by Cloudflare\")\n\t}\n\n\treturn &IPGeoData{\n\t\tAsnumber: res.Get(\"asn\").String(),\n\t\tCountry:  res.Get(\"country\").String(),\n\t\tCity:     res.Get(\"city\").String(),\n\t\tProv:     res.Get(\"region\").String(),\n\t\tOwner:    res.Get(\"isp\").String(),\n\t}, nil\n}\n"
  },
  {
    "path": "ipgeo/leo.go",
    "content": "package ipgeo\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\n\t\"github.com/nxtrace/NTrace-core/wshandle\"\n)\n\n/***\n * 原理介绍 By Leo\n * WebSocket 一共开启了一个发送和一个接收协程，在 New 了一个连接的实例对象后，不给予关闭，持续化连接\n * 当有新的IP请求时，一直在等待IP数据的发送协程接收到从 leo.go 的 sendIPRequest 函数发来的IP数据，向服务端发送数据\n * 由于实际使用时有大量并发，但是 ws 在同一时刻每次有且只能处理一次发送一条数据，所以必须给 ws 连接上互斥锁，保证每次只有一个协程访问\n * 运作模型可以理解为一个 Node 一直在等待数据，当获得一个新的任务后，转交给下一个协程，不再关注这个 Node 的下一步处理过程，并且回到空闲状态继续等待新的任务\n***/\n\n// IPPool IP 查询池 map - ip - ip channel\ntype IPPool struct {\n\tpool    map[string]chan IPGeoData\n\tpoolMux sync.RWMutex\n}\n\nvar IPPools = IPPool{\n\tpool: make(map[string]chan IPGeoData),\n}\n\nfunc sendIPRequest(ip string) {\n\twsConn := wshandle.GetWsConn()\n\twsConn.MsgSendCh <- ip\n}\n\nfunc receiveParse() {\n\t// 获得连接实例\n\twsConn := wshandle.GetWsConn()\n\t// 防止多协程抢夺一个ws连接，导致死锁，当一个协程获得ws的控制权后上锁\n\twsConn.ConnMux.Lock()\n\t// 函数退出时解锁，给其他协程使用\n\tdefer wsConn.ConnMux.Unlock()\n\tfor {\n\t\t// 接收到了一条IP信息\n\t\tdata, ok := <-wsConn.MsgReceiveCh\n\t\tif !ok {\n\t\t\t// channel 已关闭，退出循环\n\t\t\treturn\n\t\t}\n\n\t\t// json解析 -> data\n\t\tres := gjson.Parse(data)\n\t\t// 根据返回的IP信息，发送给对应等待回复的IP通道上\n\t\tvar domain = res.Get(\"domain\").String()\n\n\t\tif res.Get(\"domain\").String() == \"\" {\n\t\t\tdomain = res.Get(\"owner\").String()\n\t\t}\n\n\t\tm := make(map[string][]string)\n\t\terr := json.Unmarshal([]byte(res.Get(\"router\").String()), &m)\n\t\tif err != nil {\n\t\t\t// 此处是正常的，因为有些IP没有路由信息\n\t\t}\n\n\t\tlat, _ := strconv.ParseFloat(res.Get(\"lat\").String(), 32)\n\t\tlng, _ := strconv.ParseFloat(res.Get(\"lng\").String(), 32)\n\n\t\tip := res.Get(\"ip\").String()\n\t\tgeo := IPGeoData{\n\t\t\tAsnumber:  res.Get(\"asnumber\").String(),\n\t\t\tCountry:   res.Get(\"country\").String(),\n\t\t\tCountryEn: res.Get(\"country_en\").String(),\n\t\t\tProv:      res.Get(\"prov\").String(),\n\t\t\tProvEn:    res.Get(\"prov_en\").String(),\n\t\t\tCity:      res.Get(\"city\").String(),\n\t\t\tCityEn:    res.Get(\"city_en\").String(),\n\t\t\tDistrict:  res.Get(\"district\").String(),\n\t\t\tOwner:     domain,\n\t\t\tLat:       lat,\n\t\t\tLng:       lng,\n\t\t\tIsp:       res.Get(\"isp\").String(),\n\t\t\tWhois:     res.Get(\"whois\").String(),\n\t\t\tPrefix:    res.Get(\"prefix\").String(),\n\t\t\tRouter:    m,\n\t\t}\n\n\t\t// Safely load (or lazily create) the channel for this IP before sending\n\t\tIPPools.poolMux.RLock()\n\t\tch, ok := IPPools.pool[ip]\n\t\tIPPools.poolMux.RUnlock()\n\t\tif !ok || ch == nil {\n\t\t\tIPPools.poolMux.Lock()\n\t\t\tif IPPools.pool[ip] == nil {\n\t\t\t\tIPPools.pool[ip] = make(chan IPGeoData, 1)\n\t\t\t}\n\t\t\tch = IPPools.pool[ip]\n\t\t\tIPPools.poolMux.Unlock()\n\t\t}\n\t\tch <- geo\n\t}\n}\n\n// 当前的实现中，每次调用 receiveParse() 都会锁定 WebSocket 连接\n// 当前为单例模式，只启动一个 receiveParse 协程\n\nvar receiveParseOnce sync.Once\n\nfunc LeoIP(ip string, timeout time.Duration, lang string, maptrace bool) (*IPGeoData, error) {\n\t// TODO: 根据lang的值请求中文/英文API\n\t// TODO: 根据maptrace的值决定是否请求经纬度信息\n\tif timeout < 2*time.Second {\n\t\ttimeout = 2 * time.Second\n\t}\n\n\t// 确保对应 IP 的通道已存在（读锁快速路径 + 写锁惰性创建）\n\tIPPools.poolMux.RLock()\n\tch, ok := IPPools.pool[ip]\n\tIPPools.poolMux.RUnlock()\n\tif !ok || ch == nil {\n\t\tIPPools.poolMux.Lock()\n\t\tif IPPools.pool[ip] == nil {\n\t\t\tIPPools.pool[ip] = make(chan IPGeoData, 1)\n\t\t}\n\t\tch = IPPools.pool[ip]\n\t\tIPPools.poolMux.Unlock()\n\t}\n\n\t// 发送请求\n\tsendIPRequest(ip)\n\n\t// 确保 receiveParse 只启动一次\n\treceiveParseOnce.Do(func() {\n\t\tgo receiveParse()\n\t})\n\n\t// 等待数据返回或超时\n\tselect {\n\tcase res := <-ch:\n\t\treturn &res, nil\n\tcase <-time.After(timeout):\n\t\treturn &IPGeoData{}, errors.New(\"TimeOut\")\n\t}\n}\n"
  },
  {
    "path": "ipgeo/tokens.go",
    "content": "package ipgeo\n\nimport \"github.com/nxtrace/NTrace-core/util\"\n\ntype tokenData struct {\n\tipinsight string\n\tipinfo    string\n\tipleo     string\n\tbaseUrl   string\n}\n\nfunc (t *tokenData) BaseOrDefault(def string) string {\n\tif t.baseUrl == \"\" {\n\t\treturn def\n\t}\n\treturn t.baseUrl\n}\n\nvar token = tokenData{\n\tipinsight: util.GetEnvDefault(\"NEXTTRACE_IPINSIGHT_TOKEN\", \"\"),\n\tipinfo:    util.GetEnvDefault(\"NEXTTRACE_IPINFO_TOKEN\", \"\"),\n\tbaseUrl:   util.GetEnvDefault(\"NEXTTRACE_IPAPI_BASE\", \"\"),\n\tipleo:     \"NextTraceDemo\",\n}\n"
  },
  {
    "path": "ipgeo/tokens_test.go",
    "content": "package ipgeo\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestBaseOrDefault_Empty(t *testing.T) {\n\ttd := &tokenData{baseUrl: \"\"}\n\tassert.Equal(t, \"https://default.example.com\", td.BaseOrDefault(\"https://default.example.com\"))\n}\n\nfunc TestBaseOrDefault_Custom(t *testing.T) {\n\ttd := &tokenData{baseUrl: \"https://custom.example.com\"}\n\tassert.Equal(t, \"https://custom.example.com\", td.BaseOrDefault(\"https://default.example.com\"))\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"github.com/nxtrace/NTrace-core/cmd\"\n)\n\nfunc main() {\n\tcmd.Execute()\n}\n"
  },
  {
    "path": "nt_config.yaml",
    "content": "geofeedpath: ./geofeed.csv\nptrpath: ./ptr.csv\n"
  },
  {
    "path": "nt_install.sh",
    "content": "#!/bin/bash\n\nif [ \"$1\" = \"http\" ]; then\n    protocol=\"http\"\nelse\n    protocol=\"https\"\nfi\n\n\nGreen_font=\"\\033[32m\"\nYellow_font=\"\\033[33m\"\nRed_font=\"\\033[31m\"\nFont_suffix=\"\\033[0m\"\nInfo=\"${Green_font}[Info]${Font_suffix}\"\nError=\"${Red_font}[Error]${Font_suffix}\"\nTips=\"${Green_font}[Tips]${Font_suffix}\"\nTemp_path=\"/var/tmp/nexttrace\"\n\ncheckRootPermit() {\n    [[ $EUID -ne 0 ]] && echo -e \"${Error} 请使用sudo/root权限运行本脚本\" && exit 1\n}\n\ncheckSystemArch() {\n    arch=$(uname -m)\n    if [[ $arch == \"x86_64\" ]]; then\n    archParam=\"amd64\"\n    elif [[ $arch == \"i386\" ]]; then\n    archParam=\"386\"\n    elif [[ $arch == \"i686\" ]]; then\n    archParam=\"386\"\n    elif [[ $arch == \"aarch64\" ]]; then\n    archParam=\"arm64\"\n    elif [[ $arch == \"armv7l\" ]] || [[ $arch == \"armv7ml\" ]]; then\n    archParam=\"armv7\"\n    elif [[ $arch == \"mips\" ]]; then\n    archParam=\"mips\"\n    elif [[ $arch == \"loongarch64\" ]]; then\n    archParam=\"loong64\"\n    fi\n}\n\ncheckSystemDistribution() {\n    case \"$OSTYPE\" in\n    linux*)\n    osDistribution=\"linux\"\n\n    if [ ! -d \"/usr/local\" ];\n    then\n    downPath=\"/usr/bin/nexttrace\"\n    else\n    downPath=\"/usr/local/bin/nexttrace\"\n    fi\n\n    ;;\n    *)\n    echo \"unknown: $OSTYPE\"\n    exit 1\n    ;;\n    esac\n}\n\ndownloadBinrayFile() {\n    echo -e \"${Info} 获取最新版的 NextTrace 发行版文件信息\"\n    for i in {1..3}; do\n        downloadUrls=$(curl -sLf ${protocol}://www.nxtrace.org/api/dist/core/nexttrace_${osDistribution}_${archParam} --connect-timeout 2)\n        if [ $? -eq 0 ]; then\n            break\n        fi\n    done\n    if [ $? -eq 0 ]; then\n        primaryUrl=$(echo ${downloadUrls} | awk -F '|' '{print $1}')\n        backupUrl=$(echo ${downloadUrls} | awk -F '|' '{print $2}')\n        echo -e \"${Info} 正在尝试从 Primary 节点下载 NextTrace\"\n        for i in {1..3}; do\n            curl -sLf ${primaryUrl} -o ${Temp_path} --connect-timeout 2\n            if [ $? -eq 0 ]; then\n                changeMode\n                mv ${Temp_path} ${downPath}\n                echo -e \"${Info} NextTrace 现在已经在您的系统中可用\"\n                return\n            fi\n        done\n        if [ -z ${backupUrl} ]; then\n            echo -e \"${Error} 从 Primary 节点下载失败，且 Backup 节点为空，无法下载 NextTrace\"\n            exit 1\n        fi\n        echo -e \"${Error} 从 Primary 节点下载失败，正在尝试从 Backup 节点下载 NextTrace\"\n        for i in {1..3}; do\n            curl -sLf ${backupUrl} -o ${Temp_path} --connect-timeout 2\n            if [ $? -eq 0 ]; then\n                changeMode\n                mv ${Temp_path} ${downPath}\n                echo -e \"${Info} NextTrace 现在已经在您的系统中可用\"\n                return\n            fi\n        done\n        echo -e \"${Error} NextTrace 下载失败，请检查您的网络是否正常\"\n        exit 1\n    else\n        echo -e \"${Error} 获取下载地址失败，请检查您的网络是否正常\"\n        exit 1\n    fi\n}\n\nchangeMode() {\n    chmod +x ${Temp_path} &> /dev/null\n}\n\nrunBinrayFileHelp() {\n    if [ -e ${downPath} ]; then\n    ${downPath} --version\n    echo -e \"${Tips} 一切准备就绪！使用命令 nexttrace 1.1.1.1 开始您的第一次路由测试吧~ 更多进阶命令玩法可以用 nexttrace -h 查看哦\\n       关于软件卸载，因为nexttrace是绿色版单文件，卸载只需输入命令 rm ${downPath} 即可\"\n    fi\n}\n\n# Check Procedure\ncheckRootPermit\ncheckSystemDistribution\ncheckSystemArch\n\n# Download Procedure\ndownloadBinrayFile\n\n# Run Procedure\nrunBinrayFileHelp\n\n"
  },
  {
    "path": "pow/pow.go",
    "content": "package pow\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"github.com/tsosunchia/powclient\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nconst (\n\tbaseURL = \"/v3/challenge\"\n)\n\nvar retTokenFn = powclient.RetToken\n\nfunc resolveTokenRequestTimeout(ctx context.Context, fallback time.Duration) time.Duration {\n\tif fallback <= 0 {\n\t\tfallback = 5 * time.Second\n\t}\n\tif ctx == nil {\n\t\treturn fallback\n\t}\n\tdeadline, ok := ctx.Deadline()\n\tif !ok {\n\t\treturn fallback\n\t}\n\tremaining := time.Until(deadline)\n\tif remaining <= 0 {\n\t\treturn time.Millisecond\n\t}\n\tif remaining < fallback {\n\t\treturn remaining\n\t}\n\treturn fallback\n}\n\nfunc GetToken(fastIp string, host string, port string) (string, error) {\n\treturn GetTokenWithContext(context.Background(), fastIp, host, port)\n}\n\nfunc GetTokenWithContext(ctx context.Context, fastIp string, host string, port string) (string, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\topCtx, cancel := context.WithTimeout(ctx, 10*time.Second)\n\tdefer cancel()\n\n\tgetTokenParams := powclient.NewGetTokenParams()\n\tu := url.URL{Scheme: \"https\", Host: fastIp + \":\" + port, Path: baseURL}\n\tgetTokenParams.BaseUrl = u.String()\n\tgetTokenParams.SNI = host\n\tgetTokenParams.Host = host\n\tgetTokenParams.UserAgent = util.UserAgent\n\tgetTokenParams.TimeoutSec = resolveTokenRequestTimeout(opCtx, getTokenParams.TimeoutSec)\n\tproxyUrl := util.GetProxy()\n\tif proxyUrl != nil {\n\t\tgetTokenParams.Proxy = proxyUrl\n\t}\n\tvar (\n\t\ttoken string\n\t\terr   error\n\t\tdone  = make(chan error, 1)\n\t)\n\tgo func() {\n\t\tvar lastErr error\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tif opCtx.Err() != nil {\n\t\t\t\tdone <- opCtx.Err()\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken, err = retTokenFn(getTokenParams)\n\t\t\tif err != nil {\n\t\t\t\tlastErr = err\n\t\t\t\tcontinue // 如果失败则重试\n\t\t\t}\n\t\t\tdone <- nil\n\t\t\treturn\n\t\t}\n\t\tdone <- fmt.Errorf(\"RetToken failed after 3 attempts (host=%s): %w\", host, lastErr)\n\t}()\n\n\tselect {\n\tcase err := <-done:\n\t\tif err == nil {\n\t\t\treturn token, nil\n\t\t}\n\t\treturn \"\", err\n\tcase <-opCtx.Done():\n\t\tif ctx.Err() != nil {\n\t\t\treturn \"\", ctx.Err()\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"RetToken timed out after 10s (host=%s)\", host)\n\t}\n}\n"
  },
  {
    "path": "pow/pow_test.go",
    "content": "package pow\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/tsosunchia/powclient\"\n)\n\nfunc TestGetToken(t *testing.T) {\n\t// 网络可达性前置检查：尝试 TCP 连接目标服务器\n\tconn, err := net.DialTimeout(\"tcp\", \"origin-fallback.nxtrace.org:443\", 3*time.Second)\n\tif err != nil {\n\t\tt.Skipf(\"skipping: network unreachable (origin-fallback.nxtrace.org:443): %v\", err)\n\t}\n\tconn.Close()\n\n\t// 计时开始\n\tstart := time.Now()\n\ttoken, err := GetToken(\"origin-fallback.nxtrace.org\", \"origin-fallback.nxtrace.org\", \"443\")\n\t// 计时结束\n\tend := time.Now()\n\tfmt.Println(\"耗时：\", end.Sub(start))\n\tfmt.Println(\"token:\", token)\n\tassert.NoError(t, err, \"GetToken() returned an error\")\n}\n\nfunc TestGetTokenWithContextReturnsCanceled(t *testing.T) {\n\toldRetTokenFn := retTokenFn\n\tdefer func() { retTokenFn = oldRetTokenFn }()\n\n\tstarted := make(chan struct{})\n\tretTokenFn = func(*powclient.GetTokenParams) (string, error) {\n\t\tclose(started)\n\t\ttime.Sleep(200 * time.Millisecond)\n\t\treturn \"\", errors.New(\"boom\")\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := GetTokenWithContext(ctx, \"example.com\", \"example.com\", \"443\")\n\t\tdone <- err\n\t}()\n\n\t<-started\n\tcancel()\n\n\tselect {\n\tcase err := <-done:\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tt.Fatalf(\"GetTokenWithContext error = %v, want context.Canceled\", err)\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"GetTokenWithContext did not return promptly after cancel\")\n\t}\n}\n\nfunc TestGetTokenWithContextClampsRequestTimeoutToContextDeadline(t *testing.T) {\n\toldRetTokenFn := retTokenFn\n\tdefer func() { retTokenFn = oldRetTokenFn }()\n\n\tgotTimeout := make(chan time.Duration, 1)\n\tretTokenFn = func(params *powclient.GetTokenParams) (string, error) {\n\t\tselect {\n\t\tcase gotTimeout <- params.TimeoutSec:\n\t\tdefault:\n\t\t}\n\t\treturn \"\", errors.New(\"boom\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)\n\tdefer cancel()\n\n\t_, _ = GetTokenWithContext(ctx, \"example.com\", \"example.com\", \"443\")\n\n\tselect {\n\tcase timeout := <-gotTimeout:\n\t\tif timeout <= 0 {\n\t\t\tt.Fatalf(\"retToken timeout = %v, want > 0\", timeout)\n\t\t}\n\t\tif timeout > 150*time.Millisecond {\n\t\t\tt.Fatalf(\"retToken timeout = %v, want <= 150ms\", timeout)\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"retTokenFn was not called\")\n\t}\n}\n"
  },
  {
    "path": "printer/basic.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/config\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nvar version = config.Version\nvar buildDate = config.BuildDate\nvar commitID = config.CommitID\n\nfunc Version() {\n\tfmt.Fprintf(color.Output, \"%s %s %s %s\\n\",\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", \"NextTrace\"),\n\t\tcolor.New(color.FgHiBlack, color.Bold).Sprintf(\"%s\", version),\n\t\tcolor.New(color.FgHiBlack, color.Bold).Sprintf(\"%s\", buildDate),\n\t\tcolor.New(color.FgHiBlack, color.Bold).Sprintf(\"%s\", commitID),\n\t)\n}\n\nfunc CopyRight() {\n\tsponsor()\n\tfmt.Fprintf(color.Output, \"\\n%s\\n%s %s\\n%s %s\\n%s %s, %s, %s, %s, %s\\n%s %s\\n\",\n\t\tcolor.New(color.FgCyan, color.Bold).Sprintf(\"%s\", \"NextTrace CopyRight\"),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", \"Honorary Founder:\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"Leo\"),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", \"Project Chair:\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"Tso\"),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", \"Core-Developer:\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"Leo\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"Vincent\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"zhshch\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"Yunlq\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"Tso\"),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", \"Infra Maintainer:\"),\n\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", \"Tso\"),\n\t)\n}\n\nfunc sponsor() {\n\titalic := \"\\x1b[3m%s\\x1b[0m\"\n\tformatted := fmt.Sprintf(italic, \"(Listed in no particular order)\")\n\n\tfmt.Fprintf(color.Output, \"%s\\n%s\\n%s\\n%s\\n%s\\n\",\n\t\tcolor.New(color.FgCyan, color.Bold).Sprintf(\"%s\", \"NextTrace Sponsored by\"),\n\t\tcolor.New(color.FgHiYellow, color.Bold).Sprintf(\"%s\", \"· DMIT.io\"),\n\t\tcolor.New(color.FgHiYellow, color.Bold).Sprintf(\"%s\", \"· Misaka.io\"),\n\t\tcolor.New(color.FgHiYellow, color.Bold).Sprintf(\"%s\", \"· Saltyfish.io\"),\n\t\tcolor.New(color.FgHiBlack, color.Bold).Sprintf(\"%s\", formatted),\n\t)\n}\n\nfunc PrintTraceRouteNav(ip net.IP, domain string, dataOrigin string, maxHops int, packetSize int, srcAddr string, mode string) {\n\tfmt.Println(\"IP Geo Data Provider: \" + dataOrigin)\n\tif srcAddr == \"\" {\n\t\tsrcAddr = \"traceroute to\"\n\t} else {\n\t\tsrcAddr += \" ->\"\n\t}\n\tif !util.EnableHidDstIP {\n\t\tif ip.String() == domain {\n\t\t\tfmt.Printf(\"%s %s, %d hops max, %s, %s mode\\n\", srcAddr, ip.String(), maxHops, trace.FormatPacketSizeLabel(packetSize), strings.ToUpper(mode))\n\t\t} else {\n\t\t\tfmt.Printf(\"%s %s (%s), %d hops max, %s, %s mode\\n\", srcAddr, ip.String(), domain, maxHops, trace.FormatPacketSizeLabel(packetSize), strings.ToUpper(mode))\n\t\t}\n\t} else {\n\t\tfmt.Printf(\"%s %s, %d hops max, %s, %s mode\\n\", srcAddr, util.HideIPPart(ip.String()), maxHops, trace.FormatPacketSizeLabel(packetSize), strings.ToUpper(mode))\n\t}\n}\n\nfunc applyLangSetting(h *trace.Hop) {\n\tif h.Geo == nil || h.Geo.Source == trace.PendingGeoSource {\n\t\treturn\n\t}\n\tif len(h.Geo.Country) <= 1 {\n\t\t// 打印 h.Geo\n\t\tif h.Geo.Whois != \"\" {\n\t\t\th.Geo.Country = h.Geo.Whois\n\t\t} else {\n\t\t\tif h.Geo.Source != \"LeoMoeAPI\" {\n\t\t\t\th.Geo.Country = \"网络故障\"\n\t\t\t\th.Geo.CountryEn = \"Network Error\"\n\t\t\t} else {\n\t\t\t\th.Geo.Country = \"未知\"\n\t\t\t\th.Geo.CountryEn = \"Unknown\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif h.Lang == \"en\" {\n\t\tif h.Geo.Country == \"Anycast\" {\n\t\t} else if h.Geo.Prov == \"骨干网\" {\n\t\t\th.Geo.Prov = \"BackBone\"\n\t\t} else if h.Geo.ProvEn == \"\" {\n\t\t\tif h.Geo.CountryEn != \"\" {\n\t\t\t\th.Geo.Country = h.Geo.CountryEn\n\t\t\t}\n\t\t} else {\n\t\t\tif h.Geo.CityEn == \"\" {\n\t\t\t\th.Geo.Country = h.Geo.ProvEn\n\t\t\t\th.Geo.Prov = h.Geo.CountryEn\n\t\t\t\th.Geo.City = \"\"\n\t\t\t} else {\n\t\t\t\th.Geo.Country = h.Geo.CityEn\n\t\t\t\th.Geo.Prov = h.Geo.ProvEn\n\t\t\t\th.Geo.City = h.Geo.CountryEn\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "printer/classic_printer.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\ntype HopInfo int\n\nconst (\n\tGeneral HopInfo = 0\n\tIXP     HopInfo = 1\n\tPeer    HopInfo = 2\n\tPoP     HopInfo = 3\n\tAboard  HopInfo = 4\n)\n\nfunc findLatestAvailableHop(res *trace.Result, ttl int, probesIndex int) int {\n\tfor ttl > 0 {\n\t\t// 查找上一个跃点是不是有效结果\n\t\tttl--\n\t\t// 判断此TTL跃点是否有效并判断地理位置结构体是否已经初始化\n\t\tif len(res.Hops[ttl]) != 0 && res.Hops[ttl][probesIndex].Success && res.Hops[ttl][probesIndex].Geo != nil {\n\t\t\t// TTL虽有效，但地理位置API没有能够正确返回数据，依旧不能视为有效数据\n\t\t\tif res.Hops[ttl][probesIndex].Geo.Country == \"\" {\n\t\t\t\t// 跳过继续寻找上一个有效跃点\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn ttl\n\t\t}\n\t}\n\t// 没找到\n\treturn -1\n}\n\nfunc unifyName(name string) string {\n\tif name == \"China\" || name == \"CN\" {\n\t\treturn \"中国\"\n\t} else if name == \"Hong kong\" || name == \"香港\" || name == \"Central and Western\" {\n\t\treturn \"中国香港\"\n\t} else if name == \"Taiwan\" || name == \"台湾\" {\n\t\treturn \"中国台湾\"\n\t} else {\n\t\treturn name\n\t}\n}\n\nfunc chinaISPPeer(hostname string) bool {\n\tvar keyWords = []string{\"china\", \"ct\", \"cu\", \"cm\", \"cnc\", \"4134\", \"4837\", \"4809\", \"9929\"}\n\tfor _, k := range keyWords {\n\t\tif strings.Contains(strings.ToLower(hostname), k) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc chinaMainland(h trace.Hop) bool {\n\tif unifyName(h.Geo.Country) == \"中国\" && unifyName(h.Geo.Prov) != \"中国香港\" && unifyName(h.Geo.Prov) != \"中国台湾\" {\n\t\treturn true\n\t} else {\n\t\treturn false\n\t}\n}\n\nfunc makeHopsType(res *trace.Result, ttl int) map[int]HopInfo {\n\t// 创建一个字典，存放所有当前TTL的跃点类型集合\n\thopProbesMap := make(map[int]HopInfo)\n\tfor i := range res.Hops[ttl] {\n\t\t// 判断是否res.Hops[ttl][i]是一个有效的跃点并且地理位置信息已经初始化\n\t\tif res.Hops[ttl][i].Success && res.Hops[ttl][i].Geo != nil {\n\t\t\tif availableTTL := findLatestAvailableHop(res, ttl, i); availableTTL != -1 {\n\t\t\t\tswitch {\n\t\t\t\tcase strings.Contains(res.Hops[ttl][i].Geo.District, \"IXP\") || strings.Contains(strings.ToLower(res.Hops[ttl][i].Hostname), \"ix\"):\n\t\t\t\t\thopProbesMap[i] = IXP\n\t\t\t\tcase strings.Contains(res.Hops[ttl][i].Geo.District, \"Peer\") || chinaISPPeer(res.Hops[ttl][i].Hostname):\n\t\t\t\t\thopProbesMap[i] = Peer\n\t\t\t\tcase strings.Contains(res.Hops[ttl][i].Geo.District, \"PoP\"):\n\t\t\t\t\thopProbesMap[i] = PoP\n\t\t\t\t// 2个有效跃点必须都为有效数据，如果当前跳没有地理位置信息或者为局域网，不能视为有效节点\n\t\t\t\tcase res.Hops[availableTTL][i].Geo.Country != \"LAN Address\" && res.Hops[ttl][i].Geo.Country != \"LAN Address\" && res.Hops[ttl][i].Geo.Country != \"\" &&\n\t\t\t\t\t// 一个跃点在中国大陆，另外一个跃点在其他地区，则可以推断出数据包跨境\n\t\t\t\t\tchinaMainland(res.Hops[availableTTL][i]) != chinaMainland(res.Hops[ttl][i]):\n\t\t\t\t\t// TODO: 将先后2跳跃点信息汇报给API，以完善相关数据\n\t\t\t\t\thopProbesMap[i] = Aboard\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\thopProbesMap[i] = General\n\t\t\t}\n\t\t}\n\t}\n\n\treturn hopProbesMap\n}\n\nfunc ClassicPrinter(res *trace.Result, ttl int) {\n\tfmt.Print(ttl + 1)\n\thopsTypeMap := makeHopsType(res, ttl)\n\tfor i := range res.Hops[ttl] {\n\t\tHopPrinter(res.Hops[ttl][i], hopsTypeMap[i])\n\t}\n}\n"
  },
  {
    "path": "printer/easy.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc EasyPrinter(res *trace.Result, ttl int) {\n\tfor i := range res.Hops[ttl] {\n\t\tif res.Hops[ttl][i].Address == nil {\n\t\t\tfmt.Printf(\"%d|*||||||\\n\", ttl+1)\n\t\t\tcontinue\n\t\t}\n\t\tif res.Hops[ttl][i].Geo == nil {\n\t\t\tres.Hops[ttl][i].Geo = &ipgeo.IPGeoData{}\n\t\t}\n\t\tapplyLangSetting(&res.Hops[ttl][i]) // 应用语言设置\n\t\tfmt.Printf(\n\t\t\t\"%d|%s|%s|%.2f|%s|%s|%s|%s|%s|%s|%.4f|%.4f\\n\",\n\t\t\tttl+1,\n\t\t\tres.Hops[ttl][i].Address.String(),\n\t\t\tres.Hops[ttl][i].Hostname,\n\t\t\tfloat64(res.Hops[ttl][i].RTT.Microseconds())/1000,\n\t\t\tres.Hops[ttl][i].Geo.Asnumber,\n\t\t\tres.Hops[ttl][i].Geo.Country,\n\t\t\tres.Hops[ttl][i].Geo.Prov,\n\t\t\tres.Hops[ttl][i].Geo.City,\n\t\t\tres.Hops[ttl][i].Geo.District,\n\t\t\tres.Hops[ttl][i].Geo.Owner,\n\t\t\tres.Hops[ttl][i].Geo.Lat,\n\t\t\tres.Hops[ttl][i].Geo.Lng,\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "printer/mtr_raw.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\n// FormatMTRRawLine formats one MTR raw stream record with fixed 12 columns:\n// ttl|ip|ptr|rtt|asn|country|prov|city|district|owner|lat|lng\nfunc FormatMTRRawLine(rec trace.MTRRawRecord) string {\n\tif !rec.Success && rec.IP == \"\" && rec.Host == \"\" {\n\t\t// timeout row: keep a stable 12-column layout for machine parsers\n\t\treturn fmt.Sprintf(\"%d|*||||||||||\", rec.TTL)\n\t}\n\n\trtt := \"\"\n\tif rec.RTTMs > 0 {\n\t\trtt = fmt.Sprintf(\"%.2f\", rec.RTTMs)\n\t}\n\n\tlat := \"\"\n\tlng := \"\"\n\tif rec.Lat != 0 || rec.Lng != 0 {\n\t\tlat = fmt.Sprintf(\"%.4f\", rec.Lat)\n\t\tlng = fmt.Sprintf(\"%.4f\", rec.Lng)\n\t}\n\n\tcols := []string{\n\t\tfmt.Sprintf(\"%d\", rec.TTL),\n\t\tsanitizeRawField(rec.IP),\n\t\tsanitizeRawField(rec.Host),\n\t\trtt,\n\t\tsanitizeRawField(rec.ASN),\n\t\tsanitizeRawField(rec.Country),\n\t\tsanitizeRawField(rec.Prov),\n\t\tsanitizeRawField(rec.City),\n\t\tsanitizeRawField(rec.District),\n\t\tsanitizeRawField(rec.Owner),\n\t\tlat,\n\t\tlng,\n\t}\n\treturn strings.Join(cols, \"|\")\n}\n\nfunc sanitizeRawField(s string) string {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// Preserve one-record-per-line and stable split by '|'.\n\ts = strings.ReplaceAll(s, \"|\", \" \")\n\ts = strings.ReplaceAll(s, \"\\r\", \" \")\n\ts = strings.ReplaceAll(s, \"\\n\", \" \")\n\treturn s\n}\n"
  },
  {
    "path": "printer/mtr_table.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n\t\"github.com/mattn/go-runewidth\"\n\t\"github.com/rodaine/table\"\n\t\"golang.org/x/term\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\n// ---------------------------------------------------------------------------\n// MTR 表格打印器\n// ---------------------------------------------------------------------------\n\n// MTRTablePrinter 将 MTR 快照渲染为经典 MTR 风格表格。\n// 每次调用都会先清屏再重绘。\nfunc MTRTablePrinter(stats []trace.MTRHopStat, iteration int, mode int, nameMode int, lang string, showIPs bool) {\n\t// 清屏并移动到左上角\n\tfmt.Print(\"\\033[H\\033[2J\")\n\n\theaderFmt := color.New(color.FgGreen, color.Underline).SprintfFunc()\n\tcolumnFmt := color.New(color.FgYellow).SprintfFunc()\n\n\ttbl := table.New(\"Hop\", \"Loss%\", \"Snt\", \"Last\", \"Avg\", \"Best\", \"Wrst\", \"StDev\", \"Host\")\n\ttbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt)\n\n\tprevTTL := 0\n\tfor _, s := range stats {\n\t\thopStr := fmt.Sprint(s.TTL)\n\t\tif s.TTL == prevTTL {\n\t\t\thopStr = \"\" // 同一 TTL 的多路径不重复显示跳数\n\t\t}\n\t\tprevTTL = s.TTL\n\n\t\thost := formatMTRHostWithMPLS(s, mode, nameMode, lang, showIPs)\n\t\tm := formatMTRMetricStrings(s)\n\t\ttbl.AddRow(\n\t\t\thopStr,\n\t\t\tm.loss,\n\t\t\tm.snt,\n\t\t\tm.last,\n\t\t\tm.avg,\n\t\t\tm.best,\n\t\t\tm.wrst,\n\t\t\tm.stdev,\n\t\t\thost,\n\t\t)\n\t}\n\n\ttbl.Print()\n}\n\n// MTRRenderTable 仅返回格式化后的行数据（用于测试/非终端场景）。\n// mode / nameMode / lang 控制 Host 列内容；传 -1 / -1 / \"\" 等效于 HostModeFull + HostNamePTRorIP + \"en\"（向后兼容）。\nfunc MTRRenderTable(stats []trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) []MTRRow {\n\tprevTTL := 0\n\trows := make([]MTRRow, 0, len(stats))\n\tfor _, s := range stats {\n\t\thopStr := fmt.Sprint(s.TTL)\n\t\tif s.TTL == prevTTL {\n\t\t\thopStr = \"\"\n\t\t}\n\t\tprevTTL = s.TTL\n\n\t\tm := formatMTRMetricStrings(s)\n\t\trows = append(rows, MTRRow{\n\t\t\tHop:   hopStr,\n\t\t\tLoss:  m.loss,\n\t\t\tSnt:   m.snt,\n\t\t\tLast:  m.last,\n\t\t\tAvg:   m.avg,\n\t\t\tBest:  m.best,\n\t\t\tWrst:  m.wrst,\n\t\t\tStDev: m.stdev,\n\t\t\tHost:  formatMTRHostWithMPLS(s, mode, nameMode, lang, showIPs),\n\t\t})\n\t}\n\treturn rows\n}\n\n// MTRRow 表示表格中一行经过格式化的数据。\ntype MTRRow struct {\n\tHop   string\n\tLoss  string\n\tSnt   string\n\tLast  string\n\tAvg   string\n\tBest  string\n\tWrst  string\n\tStDev string\n\tHost  string\n}\n\n// ---------------------------------------------------------------------------\n// 格式化辅助\n// ---------------------------------------------------------------------------\n\n// formatLoss 返回 \"0.0%\"、\"100.0%\" 等。\nfunc formatLoss(pct float64) string {\n\treturn fmt.Sprintf(\"%.1f%%\", pct)\n}\n\n// formatMs 返回 \"12.34\" —— 毫秒值保留两位小数。\nfunc formatMs(ms float64) string {\n\treturn fmt.Sprintf(\"%.2f\", ms)\n}\n\n// isWaitingHopStat 判断该 hop 是否为 \"(waiting for reply)\" 状态。\n// 条件：100% 丢包（≥ 99.95% 避免浮点边界抖动）且无 IP/Host。\nfunc isWaitingHopStat(s trace.MTRHopStat) bool {\n\treturn s.Loss >= 99.95 && s.IP == \"\" && s.Host == \"\"\n}\n\n// mtrMetrics 存储已格式化的指标字符串。\ntype mtrMetrics struct {\n\tloss, snt, last, avg, best, wrst, stdev string\n}\n\n// formatMTRMetricStrings 返回已格式化的指标字符串。\n// waiting 行全部返回空字符串。\nfunc formatMTRMetricStrings(s trace.MTRHopStat) mtrMetrics {\n\tif isWaitingHopStat(s) {\n\t\treturn mtrMetrics{}\n\t}\n\treturn mtrMetrics{\n\t\tloss:  formatLoss(s.Loss),\n\t\tsnt:   fmt.Sprint(s.Snt),\n\t\tlast:  formatMs(s.Last),\n\t\tavg:   formatMs(s.Avg),\n\t\tbest:  formatMs(s.Best),\n\t\twrst:  formatMs(s.Wrst),\n\t\tstdev: formatMs(s.StDev),\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 显示模式常量\n// ---------------------------------------------------------------------------\n\nconst (\n\tHostModeBase  = 0 // 仅 IP/PTR\n\tHostModeASN   = 1 // ASN + IP/PTR\n\tHostModeCity  = 2 // ASN + IP/PTR + 城市\n\tHostModeOwner = 3 // ASN + IP/PTR + owner\n\tHostModeFull  = 4 // ASN + IP/PTR + full\n)\n\n// ---------------------------------------------------------------------------\n// Host 基础显示模式（n 键切换）\n// ---------------------------------------------------------------------------\n\nconst (\n\tHostNamePTRorIP = 0 // 默认：有 PTR 显示 PTR，否则 IP\n\tHostNameIPOnly  = 1 // 始终显示 IP\n)\n\n// ---------------------------------------------------------------------------\n// Host 列格式化（多模式 + 语言感知）\n// ---------------------------------------------------------------------------\n\n// formatMTRHostBase 构建基础 host 标识。\n//\n//\tnameMode == HostNameIPOnly → 始终显示 IP\n//\tnameMode == HostNamePTRorIP（默认）:\n//\t  - showIPs=false: 有 PTR 显示 PTR，否则 IP\n//\t  - showIPs=true:  有 PTR 且有 IP 时显示 \"PTR (IP)\"\n//\t都无   → \"???\"\nfunc formatMTRHostBase(s trace.MTRHopStat, nameMode int, showIPs bool) string {\n\tif nameMode == HostNameIPOnly {\n\t\tif s.IP != \"\" {\n\t\t\treturn s.IP\n\t\t}\n\t\treturn \"???\"\n\t}\n\n\tif showIPs {\n\t\tif s.Host != \"\" && s.IP != \"\" {\n\t\t\tif s.Host == s.IP {\n\t\t\t\treturn s.Host\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"%s (%s)\", s.Host, s.IP)\n\t\t}\n\t\tif s.Host != \"\" {\n\t\t\treturn s.Host\n\t\t}\n\t\tif s.IP != \"\" {\n\t\t\treturn s.IP\n\t\t}\n\t\treturn \"???\"\n\t}\n\n\t// HostNamePTRorIP（默认）\n\tif s.Host != \"\" {\n\t\treturn s.Host\n\t}\n\tif s.IP != \"\" {\n\t\treturn s.IP\n\t}\n\treturn \"???\"\n}\n\n// geoField 根据语言选择中/英字段。\n// lang == \"en\" 优先英文，否则优先中文。\nfunc geoField(cn, en, lang string) string {\n\tif lang == \"en\" {\n\t\tif en != \"\" {\n\t\t\treturn en\n\t\t}\n\t\treturn cn\n\t}\n\t// 默认（含 \"cn\"）优先中文\n\tif cn != \"\" {\n\t\treturn cn\n\t}\n\treturn en\n}\n\n// formatMTRHostByMode 按显示模式构建 Host 列（不含 MPLS）。\n//\n//\tHostModeBase  (0): 仅 IP/PTR\n//\tHostModeASN   (1): ASN + IP/PTR\n//\tHostModeCity  (2): ASN + IP/PTR + 城市\n//\tHostModeOwner (3): ASN + IP/PTR + owner\n//\tHostModeFull  (4): ASN + IP/PTR + full\n//\n// ASN 始终作为前缀（对齐 mtr -rw 风格）：\n//\n//\t\"AS13335 one.one.one.one\"    （模式 1）\n//\t\"AS13335 one.one.one.one US\" （模式 2）\nfunc formatMTRHostByMode(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) string {\n\treturn joinMTRHostParts(buildMTRHostParts(s, mode, nameMode, lang, showIPs), \", \")\n}\n\n// formatMTRHostWithMPLS 构建 Host 列完整内容（含内联 MPLS），供表格打印器使用。\nfunc formatMTRHostWithMPLS(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) string {\n\tif mode < 0 {\n\t\tmode = HostModeFull\n\t}\n\tif nameMode < 0 {\n\t\tnameMode = HostNamePTRorIP\n\t}\n\tif lang == \"\" {\n\t\tlang = \"en\"\n\t}\n\thost := formatMTRHostByMode(s, mode, nameMode, lang, showIPs)\n\tif len(s.MPLS) > 0 {\n\t\thost += \" \" + strings.Join(s.MPLS, \" \")\n\t}\n\treturn host\n}\n\n// formatMTRHost 向后兼容别名（HostModeFull + HostNamePTRorIP + \"en\" + 内联 MPLS）。\nfunc formatMTRHost(s trace.MTRHopStat) string {\n\treturn formatMTRHostWithMPLS(s, HostModeFull, HostNamePTRorIP, \"en\", false)\n}\n\n// ---------------------------------------------------------------------------\n// 结构化 Host 组成（TUI / Report 共用）\n// ---------------------------------------------------------------------------\n\n// mtrHostParts 包含 Host 行的各组成部分，便于不同输出层（TUI/report）组装。\ntype mtrHostParts struct {\n\twaiting bool     // loss ≥ 99.95% 且无地址 → 显示 (waiting for reply)\n\tasn     string   // \"AS13335\" 或 \"\"\n\tbase    string   // IP 或 PTR\n\textras  []string // geo/owner 等附加字段（不含 ASN）\n}\n\n// buildMTRHostParts 从统计数据构建 host 各组成部分。\n//\n// waiting 条件：loss ≥ 99.95%（避免浮点边界抖动）且无 Host 和 IP。\n// 若 loss=100% 但仍有 IP/Host（极少见），优先显示真实地址。\nfunc buildMTRHostParts(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) mtrHostParts {\n\tif isWaitingHopStat(s) {\n\t\treturn mtrHostParts{waiting: true}\n\t}\n\n\tparts := mtrHostParts{base: formatMTRHostBase(s, nameMode, showIPs)}\n\tif mode == HostModeBase || s.Geo == nil {\n\t\treturn parts\n\t}\n\tparts.asn = mtrASNLabel(s.Geo)\n\tparts.extras = mtrGeoExtras(s.Geo, mode, lang)\n\treturn parts\n}\n\n// buildTUIHostParts 构建仅供 TUI 使用的 host 组成部分。\n//\n// 与共享 buildMTRHostParts 不同，TUI 在 mode >= HostModeASN 时\n// 对\"有地址但缺失 ASN\"的 hop 显示 AS???；HostModeBase 不显示 ASN。\nfunc buildTUIHostParts(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) mtrHostParts {\n\tp := buildMTRHostParts(s, mode, nameMode, lang, showIPs)\n\tif p.waiting {\n\t\treturn p\n\t}\n\t// HostModeBase 不显示 ASN，也不显示 AS???\n\tif mode == HostModeBase {\n\t\tp.asn = \"\"\n\t\treturn p\n\t}\n\tif p.asn == \"\" {\n\t\tp.asn = \"AS???\"\n\t}\n\treturn p\n}\n\n// formatTUIHost 根据预先构建的 TUI host 组成和 ASN 列宽，生成手动空格对齐的 host 文本。\nfunc formatTUIHost(parts mtrHostParts, asnW int) string {\n\tif parts.waiting {\n\t\treturn \"(waiting for reply)\"\n\t}\n\n\tvar b strings.Builder\n\tif asnW > 0 && parts.asn != \"\" {\n\t\tb.WriteString(padRight(parts.asn, asnW))\n\t\tb.WriteByte(' ')\n\t}\n\tb.WriteString(parts.base)\n\tif len(parts.extras) > 0 {\n\t\tb.WriteByte(' ')\n\t\tb.WriteString(strings.Join(parts.extras, \" \"))\n\t}\n\treturn b.String()\n}\n\n// formatReportHost 构建 report 格式的 host 文本（空格分隔，waiting 感知，含 MPLS）。\nfunc formatReportHost(s trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) string {\n\thost := joinMTRHostParts(buildMTRHostParts(s, mode, nameMode, lang, showIPs), \" \")\n\tif len(s.MPLS) > 0 {\n\t\thost += \" \" + strings.Join(s.MPLS, \" \")\n\t}\n\treturn host\n}\n\n// formatCompactReportHost 构建非 wide report 的精简 Host 文本。\n//\n// 规则：\n//   - waiting → \"(waiting for reply)\"\n//   - 仅显示 PTR/IP 基础信息\n//   - 不显示 ASN / GEO / Owner / MPLS\nfunc formatCompactReportHost(s trace.MTRHopStat, nameMode int, showIPs bool) string {\n\tif isWaitingHopStat(s) {\n\t\treturn \"(waiting for reply)\"\n\t}\n\treturn formatMTRHostBase(s, nameMode, showIPs)\n}\n\n// formatMTRGeoData 返回简短 geo 描述（向后兼容，等效于英文 HostModeFull geo 部分）。\nfunc formatMTRGeoData(data *ipgeo.IPGeoData) string {\n\tif data == nil {\n\t\treturn \"\"\n\t}\n\tvar segs []string\n\n\tif data.Asnumber != \"\" {\n\t\tsegs = append(segs, \"AS\"+data.Asnumber)\n\t}\n\n\tcountry := data.CountryEn\n\tif country == \"\" {\n\t\tcountry = data.Country\n\t}\n\tprov := data.ProvEn\n\tif prov == \"\" {\n\t\tprov = data.Prov\n\t}\n\tcity := data.CityEn\n\tif city == \"\" {\n\t\tcity = data.City\n\t}\n\n\tif country != \"\" {\n\t\tsegs = append(segs, country)\n\t}\n\tif prov != \"\" && prov != country {\n\t\tsegs = append(segs, prov)\n\t}\n\tif city != \"\" && city != prov {\n\t\tsegs = append(segs, city)\n\t}\n\n\towner := data.Owner\n\tif owner == \"\" {\n\t\towner = data.Isp\n\t}\n\tif owner != \"\" {\n\t\tsegs = append(segs, owner)\n\t}\n\n\treturn strings.Join(segs, \", \")\n}\n\n// ---------------------------------------------------------------------------\n// MTR Report 模式打印器（对齐 mtr -rzw 风格）\n// ---------------------------------------------------------------------------\n\n// MTRReportOptions 控制报告输出细节。\ntype MTRReportOptions struct {\n\tStartTime time.Time\n\tSrcHost   string\n\tWide      bool\n\tShowIPs   bool\n\tLang      string\n}\n\n// MTRReportPrint 以 mtr -rzw 风格将最终统计一次性输出到 stdout。\n//\n// 输出格式（示例）：\n//\n//\tStart: 2025-07-14T09:12:00+0800\n//\tHOST: myhost                       Loss%   Snt   Last    Avg   Best   Wrst  StDev\n//\t  1. AS4134 one.one.one.one         0.0%    10    1.23   1.45   0.98   2.10   0.32\n//\t  2. ???                           100.0%    10    0.00   0.00   0.00   0.00   0.00\n//\n// Wide 模式下使用 HostModeFull（完整地址 + 运营商），host 列宽度取所有行最大值；\n// 非 wide 模式仅显示 PTR/IP，不查询/展示 GEO，也不显示 MPLS，按终端宽度截断：\n//\n//\twidth < 100  → maxHost = 16\n//\t100 ≤ width < 140 → maxHost = 20\n//\twidth ≥ 140  → maxHost = 24\nfunc MTRReportPrint(stats []trace.MTRHopStat, opts MTRReportOptions) {\n\tlang := normalizeMTRReportLang(opts.Lang)\n\tfmt.Printf(\"Start: %s\\n\", opts.StartTime.Format(\"2006-01-02T15:04:05-0700\"))\n\n\thosts, hostColW := prepareMTRReportHosts(stats, opts, lang)\n\tprintMTRReportHeader(opts, hostColW)\n\tprintMTRReportRows(stats, hosts, hostColW)\n}\n\nfunc joinMTRHostParts(parts mtrHostParts, extrasSep string) string {\n\tif parts.waiting {\n\t\treturn \"(waiting for reply)\"\n\t}\n\tsegments := make([]string, 0, 3)\n\tif parts.asn != \"\" {\n\t\tsegments = append(segments, parts.asn)\n\t}\n\tsegments = append(segments, parts.base)\n\tif len(parts.extras) > 0 {\n\t\tsegments = append(segments, strings.Join(parts.extras, extrasSep))\n\t}\n\treturn strings.Join(segments, \" \")\n}\n\nfunc mtrASNLabel(data *ipgeo.IPGeoData) string {\n\tif data == nil || data.Asnumber == \"\" {\n\t\treturn \"\"\n\t}\n\treturn \"AS\" + data.Asnumber\n}\n\nfunc mtrGeoExtras(data *ipgeo.IPGeoData, mode int, lang string) []string {\n\tswitch mode {\n\tcase HostModeBase, HostModeASN:\n\t\treturn nil\n\tcase HostModeCity:\n\t\treturn singleMTRGeoExtra(mtrBestLocation(data, lang))\n\tcase HostModeOwner:\n\t\treturn singleMTRGeoExtra(mtrGeoOwner(data))\n\tdefault:\n\t\treturn buildMTRFullGeoExtras(data, lang)\n\t}\n}\n\nfunc singleMTRGeoExtra(value string) []string {\n\tif value == \"\" {\n\t\treturn nil\n\t}\n\treturn []string{value}\n}\n\nfunc mtrBestLocation(data *ipgeo.IPGeoData, lang string) string {\n\tif city := geoField(data.City, data.CityEn, lang); city != \"\" {\n\t\treturn city\n\t}\n\tif prov := geoField(data.Prov, data.ProvEn, lang); prov != \"\" {\n\t\treturn prov\n\t}\n\treturn geoField(data.Country, data.CountryEn, lang)\n}\n\nfunc mtrGeoOwner(data *ipgeo.IPGeoData) string {\n\tif data == nil {\n\t\treturn \"\"\n\t}\n\tif data.Owner != \"\" {\n\t\treturn data.Owner\n\t}\n\treturn data.Isp\n}\n\nfunc buildMTRFullGeoExtras(data *ipgeo.IPGeoData, lang string) []string {\n\tcountry := geoField(data.Country, data.CountryEn, lang)\n\tprov := geoField(data.Prov, data.ProvEn, lang)\n\tcity := geoField(data.City, data.CityEn, lang)\n\textras := make([]string, 0, 4)\n\tif country != \"\" {\n\t\textras = append(extras, country)\n\t}\n\tif prov != \"\" && prov != country {\n\t\textras = append(extras, prov)\n\t}\n\tif city != \"\" && city != prov {\n\t\textras = append(extras, city)\n\t}\n\tif owner := mtrGeoOwner(data); owner != \"\" {\n\t\textras = append(extras, owner)\n\t}\n\treturn extras\n}\n\nfunc normalizeMTRReportLang(lang string) string {\n\tif lang == \"\" {\n\t\treturn \"cn\"\n\t}\n\treturn lang\n}\n\nfunc prepareMTRReportHosts(stats []trace.MTRHopStat, opts MTRReportOptions, lang string) ([]string, int) {\n\thosts := make([]string, len(stats))\n\tfor i, s := range stats {\n\t\thosts[i] = buildMTRReportHost(s, opts, lang)\n\t}\n\tif opts.Wide {\n\t\treturn hosts, computeWideMTRReportHostWidth(hosts, opts.SrcHost)\n\t}\n\thostColW := narrowMTRReportHostWidth()\n\ttruncateMTRReportHosts(hosts, hostColW)\n\treturn hosts, hostColW\n}\n\nfunc buildMTRReportHost(s trace.MTRHopStat, opts MTRReportOptions, lang string) string {\n\tif opts.Wide {\n\t\treturn formatReportHost(s, HostModeFull, HostNamePTRorIP, lang, opts.ShowIPs)\n\t}\n\treturn formatCompactReportHost(s, HostNamePTRorIP, opts.ShowIPs)\n}\n\nfunc computeWideMTRReportHostWidth(hosts []string, srcHost string) int {\n\thostColW := reportDisplayWidth(srcHost)\n\tfor _, h := range hosts {\n\t\tif w := reportDisplayWidth(h); w > hostColW {\n\t\t\thostColW = w\n\t\t}\n\t}\n\treturn hostColW + 1\n}\n\nfunc narrowMTRReportHostWidth() int {\n\ttw, _, err := term.GetSize(int(os.Stdout.Fd()))\n\tif err != nil || tw <= 0 {\n\t\ttw = 80\n\t}\n\tswitch {\n\tcase tw < 100:\n\t\treturn 16\n\tcase tw < 140:\n\t\treturn 20\n\tdefault:\n\t\treturn 24\n\t}\n}\n\nfunc truncateMTRReportHosts(hosts []string, hostColW int) {\n\tfor i, h := range hosts {\n\t\tif reportDisplayWidth(h) > hostColW {\n\t\t\thosts[i] = reportTruncateToWidth(h, hostColW)\n\t\t}\n\t}\n}\n\nfunc printMTRReportHeader(opts MTRReportOptions, hostColW int) {\n\thostHeader := opts.SrcHost\n\tif !opts.Wide && reportDisplayWidth(hostHeader) > hostColW {\n\t\thostHeader = reportTruncateToWidth(hostHeader, hostColW)\n\t}\n\tfmt.Printf(\"HOST: %s%s\\n\", reportPadRight(hostHeader, hostColW), mtrReportHeaderMetrics())\n}\n\nfunc mtrReportHeaderMetrics() string {\n\tconst metricsFmt = \" %6s %5s %6s %6s %6s %6s %6s\"\n\treturn fmt.Sprintf(metricsFmt, \"Loss%\", \"Snt\", \"Last\", \"Avg\", \"Best\", \"Wrst\", \"StDev\")\n}\n\nfunc printMTRReportRows(stats []trace.MTRHopStat, hosts []string, hostColW int) {\n\tprevTTL := 0\n\tfor i, s := range stats {\n\t\tfmt.Printf(\"%s%s%s\\n\", mtrReportPrefix(s.TTL, prevTTL), reportPadRight(hosts[i], hostColW), formatMTRReportMetrics(s))\n\t\tprevTTL = s.TTL\n\t}\n}\n\nfunc mtrReportPrefix(ttl int, prevTTL int) string {\n\tif ttl == prevTTL {\n\t\treturn \"     \"\n\t}\n\treturn fmt.Sprintf(\"%3d. \", ttl)\n}\n\nfunc formatMTRReportMetrics(s trace.MTRHopStat) string {\n\tconst metricsFmt = \" %6s %5s %6s %6s %6s %6s %6s\"\n\tm := formatMTRMetricStrings(s)\n\treturn fmt.Sprintf(metricsFmt, m.loss, m.snt, m.last, m.avg, m.best, m.wrst, m.stdev)\n}\n\n// reportDisplayWidth 返回字符串的终端显示宽度（CJK 字符占 2 列）。\nfunc reportDisplayWidth(s string) int {\n\treturn runewidth.StringWidth(s)\n}\n\n// reportTruncateToWidth 将字符串按终端显示宽度截断（CJK 安全）。\nfunc reportTruncateToWidth(s string, maxW int) string {\n\tif runewidth.StringWidth(s) <= maxW {\n\t\treturn s\n\t}\n\treturn runewidth.Truncate(s, maxW, \"\")\n}\n\n// reportPadRight 将 s 用空格右填充到 width 显示列宽（CJK 安全）。\nfunc reportPadRight(s string, width int) string {\n\tw := runewidth.StringWidth(s)\n\tif w >= width {\n\t\treturn s\n\t}\n\treturn s + strings.Repeat(\" \", width-w)\n}\n"
  },
  {
    "path": "printer/mtr_table_test.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc captureStdout(t *testing.T, fn func()) string {\n\tt.Helper()\n\n\toldStdout := os.Stdout\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tos.Stdout = w\n\tdefer func() {\n\t\tos.Stdout = oldStdout\n\t}()\n\n\tfn()\n\n\t_ = w.Close()\n\tout, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t_ = r.Close()\n\treturn string(out)\n}\n\nfunc TestMTRRenderTable_HeaderOrder(t *testing.T) {\n\t// 验证 MTRRow 字段名（即列名）顺序：Hop, Loss%, Snt, Last, Avg, Best, Wrst, StDev, Host\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif len(rows) != 1 {\n\t\tt.Fatalf(\"expected 1 row, got %d\", len(rows))\n\t}\n\tr := rows[0]\n\tif r.Hop != \"1\" {\n\t\tt.Errorf(\"Hop = %q, want %q\", r.Hop, \"1\")\n\t}\n\tif r.Loss != \"0.0%\" {\n\t\tt.Errorf(\"Loss = %q, want %q\", r.Loss, \"0.0%\")\n\t}\n\tif r.Snt != \"5\" {\n\t\tt.Errorf(\"Snt = %q, want %q\", r.Snt, \"5\")\n\t}\n\tif r.Last != \"1.23\" {\n\t\tt.Errorf(\"Last = %q, want %q\", r.Last, \"1.23\")\n\t}\n\tif r.Avg != \"1.50\" {\n\t\tt.Errorf(\"Avg = %q, want %q\", r.Avg, \"1.50\")\n\t}\n\tif r.Best != \"1.00\" {\n\t\tt.Errorf(\"Best = %q, want %q\", r.Best, \"1.00\")\n\t}\n\tif r.Wrst != \"2.00\" {\n\t\tt.Errorf(\"Wrst = %q, want %q\", r.Wrst, \"2.00\")\n\t}\n\tif r.StDev != \"0.33\" {\n\t\tt.Errorf(\"StDev = %q, want %q\", r.StDev, \"0.33\")\n\t}\n\tif r.Host != \"1.1.1.1\" {\n\t\tt.Errorf(\"Host = %q, want %q\", r.Host, \"1.1.1.1\")\n\t}\n}\n\nfunc TestMTRRenderTable_NumericFormatting(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t\t{TTL: 2, IP: \"10.0.0.2\", Loss: 100, Snt: 3, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif len(rows) != 2 {\n\t\tt.Fatalf(\"expected 2 rows, got %d\", len(rows))\n\t}\n\n\t// 行 1: loss 保留一位小数加 %\n\tif rows[0].Loss != \"33.3%\" {\n\t\tt.Errorf(\"Loss = %q, want %q\", rows[0].Loss, \"33.3%\")\n\t}\n\t// ms 保留两位小数\n\tif rows[0].Last != \"0.46\" {\n\t\tt.Errorf(\"Last = %q, want %q\", rows[0].Last, \"0.46\")\n\t}\n\tif rows[0].Avg != \"1.79\" {\n\t\tt.Errorf(\"Avg = %q, want %q\", rows[0].Avg, \"1.79\")\n\t}\n\n\t// 行 2: 全超时 → 100% loss, RTT 全 0.00\n\tif rows[1].Loss != \"100.0%\" {\n\t\tt.Errorf(\"Loss = %q, want %q\", rows[1].Loss, \"100.0%\")\n\t}\n\tif rows[1].Last != \"0.00\" {\n\t\tt.Errorf(\"Last = %q, want %q\", rows[1].Last, \"0.00\")\n\t}\n}\n\nfunc TestMTRRenderTable_NilGeo(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{TTL: 1, IP: \"192.168.1.1\", Geo: nil},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif len(rows) != 1 {\n\t\tt.Fatalf(\"expected 1 row, got %d\", len(rows))\n\t}\n\t// Host 只显示 IP，无 panic\n\tif rows[0].Host != \"192.168.1.1\" {\n\t\tt.Errorf(\"Host = %q, want %q\", rows[0].Host, \"192.168.1.1\")\n\t}\n}\n\nfunc TestMTRRenderTable_EmptyHostname(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{TTL: 1, IP: \"8.8.8.8\", Host: \"\", Geo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"15169\",\n\t\t\tCountryEn: \"United States\",\n\t\t}},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif len(rows) != 1 {\n\t\tt.Fatalf(\"expected 1 row, got %d\", len(rows))\n\t}\n\t// 无 hostname 时只显示 IP + Geo\n\twant := \"AS15169 8.8.8.8 United States\"\n\tif rows[0].Host != want {\n\t\tt.Errorf(\"Host = %q, want %q\", rows[0].Host, want)\n\t}\n}\n\nfunc TestMTRRenderTable_HostnameAndIP(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{TTL: 1, IP: \"1.1.1.1\", Host: \"one.one.one.one\", Geo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t}},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\twant := \"AS13335 one.one.one.one US, Cloudflare\"\n\tif rows[0].Host != want {\n\t\tt.Errorf(\"Host = %q, want %q\", rows[0].Host, want)\n\t}\n}\n\nfunc TestMTRRenderTable_MultiPath(t *testing.T) {\n\t// 同一 TTL 出现两个不同 IP（多路径）\n\tstats := []trace.MTRHopStat{\n\t\t{TTL: 2, IP: \"10.0.0.1\"},\n\t\t{TTL: 2, IP: \"10.0.0.2\"},\n\t\t{TTL: 3, IP: \"10.0.1.1\"},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif len(rows) != 3 {\n\t\tt.Fatalf(\"expected 3 rows, got %d\", len(rows))\n\t}\n\t// 第一行显示 TTL\n\tif rows[0].Hop != \"2\" {\n\t\tt.Errorf(\"rows[0].Hop = %q, want %q\", rows[0].Hop, \"2\")\n\t}\n\t// 第二行同 TTL → 应为空\n\tif rows[1].Hop != \"\" {\n\t\tt.Errorf(\"rows[1].Hop = %q, want empty\", rows[1].Hop)\n\t}\n\t// 第三行是新 TTL\n\tif rows[2].Hop != \"3\" {\n\t\tt.Errorf(\"rows[2].Hop = %q, want %q\", rows[2].Hop, \"3\")\n\t}\n}\n\nfunc TestMTRRenderTable_UnknownHost(t *testing.T) {\n\t// 无 IP 无 hostname → \"???\"\n\tstats := []trace.MTRHopStat{\n\t\t{TTL: 1, IP: \"\", Host: \"\"},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif rows[0].Host != \"???\" {\n\t\tt.Errorf(\"Host = %q, want %q\", rows[0].Host, \"???\")\n\t}\n}\n\nfunc TestFormatLoss(t *testing.T) {\n\tcases := []struct {\n\t\tin   float64\n\t\twant string\n\t}{\n\t\t{0, \"0.0%\"},\n\t\t{100, \"100.0%\"},\n\t\t{33.3333, \"33.3%\"},\n\t\t{50, \"50.0%\"},\n\t}\n\tfor _, c := range cases {\n\t\tgot := formatLoss(c.in)\n\t\tif got != c.want {\n\t\t\tt.Errorf(\"formatLoss(%v) = %q, want %q\", c.in, got, c.want)\n\t\t}\n\t}\n}\n\nfunc TestFormatMs(t *testing.T) {\n\tcases := []struct {\n\t\tin   float64\n\t\twant string\n\t}{\n\t\t{0, \"0.00\"},\n\t\t{1.999, \"2.00\"},\n\t\t{12.345, \"12.35\"},\n\t\t{0.1, \"0.10\"},\n\t}\n\tfor _, c := range cases {\n\t\tgot := formatMs(c.in)\n\t\tif got != c.want {\n\t\t\tt.Errorf(\"formatMs(%v) = %q, want %q\", c.in, got, c.want)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// MTR TUI 渲染测试\n// ---------------------------------------------------------------------------\n\nfunc TestMTRTUIRenderString_Header(t *testing.T) {\n\tstartTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\theader := MTRTUIHeader{\n\t\tTarget:    \"1.1.1.1\",\n\t\tStartTime: startTime,\n\t\tStatus:    MTRTUIRunning,\n\t\tIteration: 5,\n\t\tDomain:    \"example.com\",\n\t\tTargetIP:  \"1.1.1.1\",\n\t\tVersion:   \"v1.0.0\",\n\t\tSrcHost:   \"myhost\",\n\t\tSrcIP:     \"192.168.1.1\",\n\t}\n\tresult := MTRTUIRenderString(header, nil)\n\n\tif !strings.Contains(result, \"NextTrace [v1.0.0]\") {\n\t\tt.Error(\"missing 'NextTrace [v1.0.0]' in header\")\n\t}\n\tif !strings.Contains(result, \"myhost (192.168.1.1) -> example.com (1.1.1.1)\") {\n\t\tt.Error(\"missing src->dst route line in header\")\n\t}\n\tif !strings.Contains(result, \"[Running]\") {\n\t\tt.Error(\"missing Running status\")\n\t}\n\tif strings.Contains(result, \"Round:\") {\n\t\tt.Error(\"TUI header should NOT contain 'Round:' text\")\n\t}\n\tif !strings.Contains(result, \"Quit\") {\n\t\tt.Error(\"missing key hints\")\n\t}\n\tif !strings.Contains(result, \"Reset\") {\n\t\tt.Error(\"missing reset key hint\")\n\t}\n\tif !strings.Contains(result, \"Y-display\") {\n\t\tt.Error(\"missing display mode key hint\")\n\t}\n}\n\n// TestMTRTUIRenderString_UsesCRLFOnly 确保 TUI 帧不包含裸 LF\n// （即每个 \\n 前面必须是 \\r），避免 raw mode 下光标不回行首导致斜排。\nfunc TestMTRTUIRenderString_UsesCRLFOnly(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"1.1.1.1\",\n\t\tStartTime: time.Now(),\n\t\tStatus:    MTRTUIRunning,\n\t\tIteration: 1,\n\t}\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t\t{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},\n\t}\n\tresult := MTRTUIRenderString(header, stats)\n\n\tfor i := 0; i < len(result); i++ {\n\t\tif result[i] == '\\n' && (i == 0 || result[i-1] != '\\r') {\n\t\t\t// 找到裸 LF 的位置供调试\n\t\t\tstart := i - 20\n\t\t\tif start < 0 {\n\t\t\t\tstart = 0\n\t\t\t}\n\t\t\tend := i + 20\n\t\t\tif end > len(result) {\n\t\t\t\tend = len(result)\n\t\t\t}\n\t\t\tt.Fatalf(\"bare LF at byte %d; context: %q\", i, result[start:end])\n\t\t}\n\t}\n}\n\n// TestMTRTablePrinter_NoRoundText verifies that the non-TTY fallback\n// (MTRTablePrinter) does NOT contain \"Round:\" in its output.\nfunc TestMTRTablePrinter_NoRoundText(t *testing.T) {\n\torigNoColor := color.NoColor\n\tcolor.NoColor = true\n\tdefer func() { color.NoColor = origNoColor }()\n\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t}\n\n\toutput := captureStdout(t, func() {\n\t\tMTRTablePrinter(stats, 5, HostModeFull, HostNamePTRorIP, \"en\", false)\n\t})\n\n\tif strings.Contains(output, \"Round:\") {\n\t\tt.Errorf(\"MTRTablePrinter output should NOT contain 'Round:' text, got:\\n%s\", output)\n\t}\n}\n\n// TestMTRTUIRenderString_FramePrefix 确保帧以清屏序列开头，\n// 且 header 首行以 \\r\\n 结束。\nfunc TestMTRTUIRenderString_FramePrefix(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),\n\t\tStatus:    MTRTUIRunning,\n\t\tIteration: 1,\n\t\tVersion:   \"v1.0.0\",\n\t}\n\tresult := MTRTUIRenderString(header, nil)\n\n\tif !strings.HasPrefix(result, \"\\033[H\\033[2J\") {\n\t\tt.Error(\"frame should start with cursor-home + erase-screen\")\n\t}\n\t// header 首行应含有 NextTrace 并以 \\r\\n 结束\n\tidx := strings.Index(result, \"NextTrace [\")\n\tif idx < 0 {\n\t\tt.Fatal(\"missing 'NextTrace [' in header\")\n\t}\n\tnlIdx := strings.Index(result[idx:], \"\\r\\n\")\n\tif nlIdx < 0 {\n\t\tt.Error(\"header line should end with \\\\r\\\\n\")\n\t}\n}\n\nfunc TestMTRTUIRenderString_PausedStatus(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tStatus:    MTRTUIPaused,\n\t\tIteration: 3,\n\t}\n\tresult := MTRTUIRenderString(header, nil)\n\n\tif !strings.Contains(result, \"[Paused]\") {\n\t\tt.Error(\"expected Paused status\")\n\t}\n}\n\nfunc TestMTRTUIRenderString_HopRows(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t\t{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},\n\t}\n\theader := MTRTUIHeader{\n\t\tTarget:    \"1.1.1.1\",\n\t\tStartTime: time.Now(),\n\t\tStatus:    MTRTUIRunning,\n\t\tIteration: 1,\n\t}\n\tresult := MTRTUIRenderString(header, stats)\n\n\tif !strings.Contains(result, \"1.\") {\n\t\tt.Error(\"missing 1. hop prefix\")\n\t}\n\tif !strings.Contains(result, \"2.\") {\n\t\tt.Error(\"missing 2. hop prefix\")\n\t}\n\tif !strings.Contains(result, \"10.0.0.1\") {\n\t\tt.Error(\"missing first hop IP\")\n\t}\n\tif !strings.Contains(result, \"10.0.0.2\") {\n\t\tt.Error(\"missing second hop IP\")\n\t}\n}\n\nfunc TestMTRTUIRenderString_MultiPath(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{TTL: 2, IP: \"10.0.0.1\"},\n\t\t{TTL: 2, IP: \"10.0.0.2\"},\n\t\t{TTL: 3, IP: \"10.0.1.1\"},\n\t}\n\theader := MTRTUIHeader{Target: \"x\", StartTime: time.Now(), Iteration: 1}\n\tresult := MTRTUIRenderString(header, stats)\n\n\t// 第一行 TTL=2 → \"2.\", 第二行同 TTL → \"  \"\n\tif !strings.Contains(result, \"2.\") {\n\t\tt.Error(\"missing first multipath hop prefix\")\n\t}\n\t// 续行前缀 \"  \" 不易在输出行中唯一匹配，跳过特定验证\n\tif !strings.Contains(result, \"3.\") {\n\t\tt.Error(\"missing next TTL prefix\")\n\t}\n}\n\nfunc TestFormatTUIHopPrefix(t *testing.T) {\n\tcases := []struct {\n\t\tttl, prev, prefixW int\n\t\twant               string\n\t}{\n\t\t{1, 0, 4, \" 1. \"},\n\t\t{5, 4, 4, \" 5. \"},\n\t\t{3, 3, 4, \"    \"},\n\t\t{10, 9, 4, \"10. \"},\n\t\t{100, 99, 5, \"100. \"},\n\t\t{5, 5, 5, \"     \"},\n\t\t{1, 0, 5, \"  1. \"},\n\t}\n\tfor _, c := range cases {\n\t\tgot := formatTUIHopPrefix(c.ttl, c.prev, c.prefixW)\n\t\tif got != c.want {\n\t\t\tt.Errorf(\"formatTUIHopPrefix(%d, %d, %d) = %q, want %q\", c.ttl, c.prev, c.prefixW, got, c.want)\n\t\t}\n\t}\n}\n\nfunc TestTruncateStr(t *testing.T) {\n\tcases := []struct {\n\t\ts      string\n\t\tmaxLen int\n\t\twant   string\n\t}{\n\t\t{\"short\", 10, \"short\"},\n\t\t{\"exactly10!\", 10, \"exactly10!\"},\n\t\t{\"this is too long\", 10, \"this is t.\"},\n\t\t{\"ab\", 1, \".\"},\n\t}\n\tfor _, c := range cases {\n\t\tgot := truncateStr(c.s, c.maxLen)\n\t\tif got != c.want {\n\t\t\tt.Errorf(\"truncateStr(%q, %d) = %q, want %q\", c.s, c.maxLen, got, c.want)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 自适应布局新增测试\n// ---------------------------------------------------------------------------\n\n// TestTUI_RightAlignedMetricsBlock 验证指标列数值右对齐并出现在行尾。\nfunc TestTUI_RightAlignedMetricsBlock(t *testing.T) {\n\theader := MTRTUIHeader{Target: \"1.1.1.1\", StartTime: time.Now(), Iteration: 1}\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t}\n\tresult := mtrTUIRenderStringWithWidth(header, stats, 120)\n\n\tlines := strings.Split(result, \"\\r\\n\")\n\t// 找到含 \"1.\" 前缀且包含 hop IP 的数据行\n\tvar hopLine string\n\tfor _, l := range lines {\n\t\ttrimmed := strings.TrimLeft(l, \" \")\n\t\tif strings.HasPrefix(trimmed, \"1.\") && strings.Contains(l, \"10.0.0.1\") {\n\t\t\thopLine = l\n\t\t\tbreak\n\t\t}\n\t}\n\tif hopLine == \"\" {\n\t\tt.Fatal(\"missing hop line with 1. prefix\")\n\t}\n\n\t// Loss 出现在 Host之后\n\thostIdx := strings.Index(hopLine, \"10.0.0.1\")\n\tlossIdx := strings.Index(hopLine, \"0.0%\")\n\tif lossIdx <= hostIdx {\n\t\tt.Errorf(\"Loss(%d) should appear after Host(%d)\", lossIdx, hostIdx)\n\t}\n\n\t// 各指标值应存在\n\tfor _, m := range []string{\"0.0%\", \"1.23\", \"1.50\", \"1.00\", \"2.00\", \"0.33\"} {\n\t\tif !strings.Contains(hopLine, m) {\n\t\t\tt.Errorf(\"hop line missing metric %q\", m)\n\t\t}\n\t}\n\n\t// 验证右对齐：指标前应有空格（padLeft 效果）\n\t// 取 \"Snt\" 列值 \"5\"，应有前导空格\n\tsntIdx := strings.Index(hopLine, \"0.0%\")\n\tif sntIdx < 0 {\n\t\tt.Fatal(\"metric not found in hop line\")\n\t}\n\t// 指标块在行尾，末尾不应有大量多余空格\n\ttrimmed := strings.TrimRight(hopLine, \" \")\n\tif len(trimmed) < len(hopLine)-2 {\n\t\tt.Errorf(\"too many trailing spaces; metrics should be near end of line\")\n\t}\n}\n\n// TestTUI_HostExpandsOnWideTerminal 宽终端(200列)时 Host 列宽应大于默认 40。\nfunc TestTUI_HostExpandsOnWideTerminal(t *testing.T) {\n\tlo := computeLayout(200, tuiPrefixW, 0)\n\tif lo.hostW <= tuiHostDefault {\n\t\tt.Errorf(\"wide terminal: hostW=%d, want > %d\", lo.hostW, tuiHostDefault)\n\t}\n\tif lo.termWidth != 200 {\n\t\tt.Errorf(\"termWidth=%d, want 200\", lo.termWidth)\n\t}\n}\n\n// TestTUI_ComputeLayout_NonZeroSntHint 验证 sntWidthForMax 返回非零值后\n// computeLayout 使用更宽的 Snt 列（maxSnt>=1000 → sntWidth>=4）。\nfunc TestTUI_ComputeLayout_NonZeroSntHint(t *testing.T) {\n\tmaxSnt := 1500\n\tsntHint := sntWidthForMax(maxSnt)\n\tif sntHint <= tuiSntDefault {\n\t\tt.Fatalf(\"sntWidthForMax(%d)=%d, want > %d\", maxSnt, sntHint, tuiSntDefault)\n\t}\n\n\tlo := computeLayout(200, tuiPrefixW, sntHint)\n\tif lo.sntW != sntHint {\n\t\tt.Errorf(\"computeLayout sntW=%d, want %d (from sntWidthForMax(%d))\", lo.sntW, sntHint, maxSnt)\n\t}\n\n\t// 同终端宽度下，更宽的 Snt 列应压缩 Host 列\n\tlo0 := computeLayout(200, tuiPrefixW, 0)\n\tif lo.hostW >= lo0.hostW {\n\t\tt.Errorf(\"wider snt column should reduce host width: hostW(snt=%d)=%d, hostW(snt=0)=%d\",\n\t\t\tsntHint, lo.hostW, lo0.hostW)\n\t}\n}\n\n// TestTUI_HostShrinksWhenWidthReduced 窄终端(80列)时 Host 列宽应被压缩。\nfunc TestTUI_HostShrinksWhenWidthReduced(t *testing.T) {\n\tlo := computeLayout(80, tuiPrefixW, 0)\n\tif lo.hostW >= tuiHostDefault {\n\t\tt.Errorf(\"narrow terminal: hostW=%d, should be < %d\", lo.hostW, tuiHostDefault)\n\t}\n\tif lo.hostW < tuiHostMin {\n\t\tt.Errorf(\"hostW=%d, should not be less than min %d\", lo.hostW, tuiHostMin)\n\t}\n}\n\n// TestTUI_DualHeaderPacketsPings 验证双层分组表头：\n// 第一层含 \"Packets\" 和 \"Pings\"，第二层含各列名。\nfunc TestTUI_DualHeaderPacketsPings(t *testing.T) {\n\theader := MTRTUIHeader{Target: \"1.1.1.1\", StartTime: time.Now(), Iteration: 1}\n\tresult := mtrTUIRenderStringWithWidth(header, nil, 120)\n\n\tlines := strings.Split(result, \"\\r\\n\")\n\n\tfoundPackets, foundPings := false, false\n\tfoundLoss, foundSnt, foundLast, foundAvg, foundBest, foundWrst, foundStDev := false, false, false, false, false, false, false\n\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"Packets\") {\n\t\t\tfoundPackets = true\n\t\t}\n\t\tif strings.Contains(l, \"Pings\") {\n\t\t\tfoundPings = true\n\t\t}\n\t\tif strings.Contains(l, \"Loss%\") {\n\t\t\tfoundLoss = true\n\t\t}\n\t\tif strings.Contains(l, \"Snt\") {\n\t\t\tfoundSnt = true\n\t\t}\n\t\tif strings.Contains(l, \"Last\") {\n\t\t\tfoundLast = true\n\t\t}\n\t\tif strings.Contains(l, \"Avg\") {\n\t\t\tfoundAvg = true\n\t\t}\n\t\tif strings.Contains(l, \"Best\") {\n\t\t\tfoundBest = true\n\t\t}\n\t\tif strings.Contains(l, \"Wrst\") {\n\t\t\tfoundWrst = true\n\t\t}\n\t\tif strings.Contains(l, \"StDev\") {\n\t\t\tfoundStDev = true\n\t\t}\n\t}\n\n\tif !foundPackets {\n\t\tt.Error(\"missing 'Packets' group label in header\")\n\t}\n\tif !foundPings {\n\t\tt.Error(\"missing 'Pings' group label in header\")\n\t}\n\tif !foundLoss || !foundSnt {\n\t\tt.Error(\"missing Loss%/Snt column names under Packets group\")\n\t}\n\tif !foundLast || !foundAvg || !foundBest || !foundWrst || !foundStDev {\n\t\tt.Error(\"missing RTT column names under Pings group\")\n\t}\n\n\t// \"Packets\" 和 \"Pings\" 应在同一行\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"Packets\") && strings.Contains(l, \"Pings\") {\n\t\t\treturn // 验证通过\n\t\t}\n\t}\n\tt.Error(\"Packets and Pings should be on the same header line\")\n}\n\n// TestTUI_VeryNarrowNoPanic 极窄终端(30列)不应 panic，\n// 且 hop 数据行与表头行的显示宽度不超过 termWidth。\nfunc TestTUI_VeryNarrowNoPanic(t *testing.T) {\n\theader := MTRTUIHeader{Target: \"x\", StartTime: time.Now(), Iteration: 1}\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t}\n\tconst width = 30\n\n\t// 不应 panic\n\tresult := mtrTUIRenderStringWithWidth(header, stats, width)\n\n\tif !strings.Contains(result, \"\\r\\n\") {\n\t\tt.Error(\"output should contain \\\\r\\\\n\")\n\t}\n\n\t// 验证 hop 行与表头子行不超宽\n\tlines := strings.Split(result, \"\\r\\n\")\n\tfor _, l := range lines {\n\t\t// 跳过清屏序列、信息行和空行\n\t\tif l == \"\" || strings.HasPrefix(l, \"\\033[\") ||\n\t\t\tstrings.Contains(l, \"NextTrace\") || strings.Contains(l, \"->\") || strings.Contains(l, \"Quit\") {\n\t\t\tcontinue\n\t\t}\n\t\tw := displayWidthWithTabs(l, tuiTabStop)\n\t\tif w > width {\n\t\t\tt.Errorf(\"line exceeds termWidth=%d: displayWidth=%d, line=%q\", width, w, l)\n\t\t}\n\t}\n}\n\n// TestTUI_DisplayWidthCJK 验证 CJK 宽字符截断和宽度计算。\nfunc TestTUI_DisplayWidthCJK(t *testing.T) {\n\t// 每个中文字符占 2 列\n\tif w := displayWidth(\"中文\"); w != 4 {\n\t\tt.Errorf(\"displayWidth(\\\"中文\\\") = %d, want 4\", w)\n\t}\n\tif w := displayWidth(\"abc\"); w != 3 {\n\t\tt.Errorf(\"displayWidth(\\\"abc\\\") = %d, want 3\", w)\n\t}\n\n\t// 截断：max=5 → \"中文\" (4列) 可以放下\n\tgot := truncateByDisplayWidth(\"中文\", 5)\n\tif got != \"中文\" {\n\t\tt.Errorf(\"truncateByDisplayWidth(\\\"中文\\\", 5) = %q, want \\\"中文\\\"\", got)\n\t}\n\n\t// 截断：max=3 → \"中文\" (4列) 超出 → 截断到 2列 + \".\"\n\tgot = truncateByDisplayWidth(\"中文\", 3)\n\tif displayWidth(got) > 3 {\n\t\tt.Errorf(\"truncateByDisplayWidth(\\\"中文\\\", 3) width=%d, want <= 3\", displayWidth(got))\n\t}\n\n\t// padRight CJK\n\tpadded := padRight(\"中文\", 8) // 4显示列 + 4空格 = 8列\n\tif displayWidth(padded) != 8 {\n\t\tt.Errorf(\"padRight(\\\"中文\\\", 8) width=%d, want 8\", displayWidth(padded))\n\t}\n}\n\n// TestTUI_ComputeLayoutZeroWidth 验证 termWidth=0 回退到默认值。\nfunc TestTUI_ComputeLayoutZeroWidth(t *testing.T) {\n\tlo := computeLayout(0, tuiPrefixW, 0)\n\tif lo.termWidth != tuiDefaultTerm {\n\t\tt.Errorf(\"termWidth=%d, want default %d\", lo.termWidth, tuiDefaultTerm)\n\t}\n\tif lo.hostW < tuiHostMin {\n\t\tt.Errorf(\"hostW=%d, want >= %d\", lo.hostW, tuiHostMin)\n\t}\n}\n\n// TestTUI_TotalWidthInvariant 验证 computeLayout 的核心不变式：\n// 对于 termWidth >= 20（绝对下限），totalWidth() == termWidth（右锚定）。\nfunc TestTUI_TotalWidthInvariant(t *testing.T) {\n\tfor _, tw := range []int{20, 23, 25, 30, 40, 50, 60, 61, 80, 120, 200} {\n\t\tlo := computeLayout(tw, tuiPrefixW, 0)\n\t\tif lo.totalWidth() != tw {\n\t\t\tt.Errorf(\"termWidth=%d: totalWidth()=%d, want exact match (hostW=%d, metricsWidth=%d)\",\n\t\t\t\ttw, lo.totalWidth(), lo.hostW, lo.metricsWidth())\n\t\t}\n\t\tif lo.hostW < 1 {\n\t\t\tt.Errorf(\"termWidth=%d: hostW=%d, must be >= 1\", tw, lo.hostW)\n\t\t}\n\t}\n}\n\n// TestTUI_NarrowRightAnchor 验证窄屏(62列)时指标区贴右边界，\n// 即 metricsStart + metricsWidth == termWidth。\nfunc TestTUI_NarrowRightAnchor(t *testing.T) {\n\tfor _, tw := range []int{62, 65, 70, 80} {\n\t\tlo := computeLayout(tw, tuiPrefixW, 0)\n\t\trightEdge := lo.metricsStart + lo.metricsWidth()\n\t\tif rightEdge != tw {\n\t\t\tt.Errorf(\"termWidth=%d: metricsStart(%d)+metricsWidth(%d)=%d, want %d\",\n\t\t\t\ttw, lo.metricsStart, lo.metricsWidth(), rightEdge, tw)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// MTR TUI Header 测试（版本、域名/IP、r 键提示）\n// ---------------------------------------------------------------------------\n\nfunc TestMTRTUI_HeaderContainsVersion(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tVersion:   \"v1.3.0\",\n\t\tIteration: 1,\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\tif !strings.Contains(out, \"NextTrace [v1.3.0]\") {\n\t\tt.Errorf(\"header should contain 'NextTrace [v1.3.0]', got:\\n%s\", out)\n\t}\n}\n\nfunc TestMTRTUI_HeaderContainsDomainAndIP(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tDomain:    \"dns.google\",\n\t\tTargetIP:  \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t\tSrcHost:   \"myhost\",\n\t\tSrcIP:     \"192.168.1.1\",\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\tif !strings.Contains(out, \"dns.google (8.8.8.8)\") {\n\t\tt.Errorf(\"header should contain 'dns.google (8.8.8.8)', got:\\n%s\", out)\n\t}\n\t// 应包含 src -> dst 格式\n\tif !strings.Contains(out, \"myhost (192.168.1.1) -> dns.google (8.8.8.8)\") {\n\t\tt.Errorf(\"header should contain src -> dst route line, got:\\n%s\", out)\n\t}\n}\n\nfunc TestMTRTUI_HeaderContainsResetKey(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\tif !strings.Contains(out, \"Reset\") {\n\t\tt.Errorf(\"header should contain 'Reset', got:\\n%s\", out)\n\t}\n}\n\nfunc TestMTRTUI_HeaderIPOnlyWhenNoDomain(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"1.2.3.4\",\n\t\tTargetIP:  \"1.2.3.4\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\t// 无域名时只显示 IP（不含 \"Host:\" 前缀）\n\tif !strings.Contains(out, \"1.2.3.4\") {\n\t\tt.Errorf(\"header should contain '1.2.3.4' when domain is empty, got:\\n%s\", out)\n\t}\n\t// 不应出现 \"Host:\" 前缀（新格式使用 src -> dst）\n\tif strings.Contains(out, \"Host:\") {\n\t\tt.Errorf(\"new header should not contain 'Host:' prefix, got:\\n%s\", out)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// formatTUIHopPrefix 新风格测试\n// ---------------------------------------------------------------------------\n\nfunc TestFormatTUIHopPrefix_MinimalStyle(t *testing.T) {\n\t// 新 TTL 应返回 \"%*d. \" 格式（prefixW=4: 2 位 TTL）\n\tgot := formatTUIHopPrefix(1, 0, 4)\n\tif got != \" 1. \" {\n\t\tt.Errorf(\"formatTUIHopPrefix(1, 0, 4) = %q, want %q\", got, \" 1. \")\n\t}\n\n\tgot = formatTUIHopPrefix(10, 9, 4)\n\tif got != \"10. \" {\n\t\tt.Errorf(\"formatTUIHopPrefix(10, 9, 4) = %q, want %q\", got, \"10. \")\n\t}\n\n\t// 续行应返回 prefixW 个空格\n\tgot = formatTUIHopPrefix(5, 5, 4)\n\tif got != \"    \" {\n\t\tt.Errorf(\"formatTUIHopPrefix(5, 5, 4) = %q, want %q\", got, \"    \")\n\t}\n\n\t// 3 位 TTL，prefixW=5\n\tgot = formatTUIHopPrefix(100, 99, 5)\n\tif got != \"100. \" {\n\t\tt.Errorf(\"formatTUIHopPrefix(100, 99, 5) = %q, want %q\", got, \"100. \")\n\t}\n\n\tgot = formatTUIHopPrefix(5, 5, 5)\n\tif got != \"     \" {\n\t\tt.Errorf(\"formatTUIHopPrefix(5, 5, 5) = %q, want %q\", got, \"     \")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// formatMTRHost MPLS 测试\n// ---------------------------------------------------------------------------\n\nfunc TestFormatMTRHost_IncludesMPLS(t *testing.T) {\n\t// extractMPLS 产出格式为 \"[MPLS: Lbl N, TC N, S N, TTL N]\"，不应再包裹\n\tstat := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"10.0.0.1\",\n\t\tMPLS: []string{\"[MPLS: Lbl 100, TC 0, S 1, TTL 1]\", \"[MPLS: Lbl 200, TC 0, S 0, TTL 1]\"},\n\t}\n\tgot := formatMTRHost(stat)\n\t// 不应出现双层包裹 \"[MPLS: [MPLS: ...\"\n\tif strings.Contains(got, \"[MPLS: [MPLS:\") {\n\t\tt.Errorf(\"should not double-wrap MPLS, got: %q\", got)\n\t}\n\t// 每个标签应直接出现\n\tif !strings.Contains(got, \"[MPLS: Lbl 100\") {\n\t\tt.Errorf(\"should contain first MPLS label, got: %q\", got)\n\t}\n\tif !strings.Contains(got, \"[MPLS: Lbl 200\") {\n\t\tt.Errorf(\"should contain second MPLS label, got: %q\", got)\n\t}\n}\n\nfunc TestFormatMTRHost_NoMPLS(t *testing.T) {\n\tstat := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"10.0.0.1\",\n\t}\n\tgot := formatMTRHost(stat)\n\tif strings.Contains(got, \"MPLS\") {\n\t\tt.Errorf(\"formatMTRHost should not contain MPLS when empty, got: %q\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// IP 重复展示测试（P2）\n// ---------------------------------------------------------------------------\n\nfunc TestMTRTUI_HeaderIPNoDuplicate(t *testing.T) {\n\t// 当 Domain == TargetIP 时不应显示 \"1.1.1.1 (1.1.1.1)\"\n\theader := MTRTUIHeader{\n\t\tTarget:    \"1.1.1.1\",\n\t\tDomain:    \"1.1.1.1\",\n\t\tTargetIP:  \"1.1.1.1\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\tif strings.Contains(out, \"1.1.1.1 (1.1.1.1)\") {\n\t\tt.Errorf(\"should not show duplicate IP, got:\\n%s\", out)\n\t}\n\t// 新格式下目标 IP 应单独出现\n\tif !strings.Contains(out, \"1.1.1.1\") {\n\t\tt.Errorf(\"should show '1.1.1.1', got:\\n%s\", out)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 显示模式测试\n// ---------------------------------------------------------------------------\n\nfunc TestFormatMTRHostByMode_Base(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t},\n\t}\n\t// HostModeBase should produce only the base name (PTR or IP), no ASN, no geo.\n\tgot := formatMTRHostByMode(s, HostModeBase, HostNamePTRorIP, \"en\", false)\n\twant := \"one.one.one.one\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeBase PTRorIP: got %q, want %q\", got, want)\n\t}\n\n\t// With HostNameIPOnly, should return only IP.\n\tgot = formatMTRHostByMode(s, HostModeBase, HostNameIPOnly, \"en\", false)\n\twant = \"1.1.1.1\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeBase IPOnly: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_Base_NilGeo(t *testing.T) {\n\ts := trace.MTRHopStat{TTL: 1, IP: \"10.0.0.1\"}\n\tgot := formatMTRHostByMode(s, HostModeBase, HostNamePTRorIP, \"en\", false)\n\tif got != \"10.0.0.1\" {\n\t\tt.Errorf(\"HostModeBase nil geo: got %q, want %q\", got, \"10.0.0.1\")\n\t}\n}\n\nfunc TestBuildTUIHostParts_BaseModeNoASNPlaceholder(t *testing.T) {\n\t// In HostModeBase, even when IP is known and geo has no ASN, the TUI\n\t// should NOT inject the \"AS???\" placeholder that other modes use.\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"10.0.0.1\",\n\t\tGeo: &ipgeo.IPGeoData{CountryEn: \"US\"},\n\t}\n\tparts := buildTUIHostParts(s, HostModeBase, HostNamePTRorIP, \"en\", false)\n\tif parts.asn != \"\" {\n\t\tt.Errorf(\"HostModeBase: expected empty ASN, got %q\", parts.asn)\n\t}\n\tif parts.base != \"10.0.0.1\" {\n\t\tt.Errorf(\"HostModeBase: expected Base='10.0.0.1', got %q\", parts.base)\n\t}\n}\n\nfunc TestBuildTUIHostParts_ASNModeHasPlaceholder(t *testing.T) {\n\t// In HostModeASN, when IP is known but geo has no ASN, \"AS???\" placeholder is expected.\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"10.0.0.1\",\n\t\tGeo: &ipgeo.IPGeoData{CountryEn: \"US\"},\n\t}\n\tparts := buildTUIHostParts(s, HostModeASN, HostNamePTRorIP, \"en\", false)\n\tif parts.asn != \"AS???\" {\n\t\tt.Errorf(\"HostModeASN: expected ASN='AS???', got %q\", parts.asn)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_ASN(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, \"en\", false)\n\twant := \"AS13335 one.one.one.one\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeASN: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_City(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"1.1.1.1\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tProvEn:    \"California\",\n\t\t\tCityEn:    \"Los Angeles\",\n\t\t},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeCity, HostNamePTRorIP, \"en\", false)\n\twant := \"AS13335 1.1.1.1 Los Angeles\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeCity: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_Owner(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"1.1.1.1\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber: \"13335\",\n\t\t\tOwner:    \"Cloudflare\",\n\t\t},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeOwner, HostNamePTRorIP, \"en\", false)\n\twant := \"AS13335 1.1.1.1 Cloudflare\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeOwner: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_Full(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeFull, HostNamePTRorIP, \"en\", false)\n\twant := \"AS13335 one.one.one.one US, Cloudflare\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeFull: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_NilGeo(t *testing.T) {\n\ts := trace.MTRHopStat{TTL: 1, IP: \"10.0.0.1\"}\n\tfor _, mode := range []int{HostModeBase, HostModeASN, HostModeCity, HostModeOwner, HostModeFull} {\n\t\tgot := formatMTRHostByMode(s, mode, HostNamePTRorIP, \"en\", false)\n\t\tif got != \"10.0.0.1\" {\n\t\t\tt.Errorf(\"mode %d with nil geo: got %q, want %q\", mode, got, \"10.0.0.1\")\n\t\t}\n\t}\n}\n\nfunc TestFormatMTRHostByMode_NoASN(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"10.0.0.1\",\n\t\tGeo: &ipgeo.IPGeoData{CountryEn: \"US\"},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, \"en\", false)\n\t// 无 ASN 时只显示 base\n\tif got != \"10.0.0.1\" {\n\t\tt.Errorf(\"HostModeASN no ASN: got %q, want %q\", got, \"10.0.0.1\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 语言感知测试\n// ---------------------------------------------------------------------------\n\nfunc TestGeoField_CN(t *testing.T) {\n\tgot := geoField(\"中国\", \"China\", \"cn\")\n\tif got != \"中国\" {\n\t\tt.Errorf(\"geoField cn: got %q, want %q\", got, \"中国\")\n\t}\n}\n\nfunc TestGeoField_EN(t *testing.T) {\n\tgot := geoField(\"中国\", \"China\", \"en\")\n\tif got != \"China\" {\n\t\tt.Errorf(\"geoField en: got %q, want %q\", got, \"China\")\n\t}\n}\n\nfunc TestGeoField_Fallback(t *testing.T) {\n\t// en 模式但无英文字段时回退到中文\n\tgot := geoField(\"中国\", \"\", \"en\")\n\tif got != \"中国\" {\n\t\tt.Errorf(\"geoField en fallback: got %q, want %q\", got, \"中国\")\n\t}\n\t// cn 模式但无中文字段时回退到英文\n\tgot = geoField(\"\", \"China\", \"cn\")\n\tif got != \"China\" {\n\t\tt.Errorf(\"geoField cn fallback: got %q, want %q\", got, \"China\")\n\t}\n}\n\nfunc TestFormatMTRHostByMode_LangCN(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"1.1.1.1\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountry:   \"美国\",\n\t\t\tCountryEn: \"US\",\n\t\t\tProv:      \"加利福尼亚\",\n\t\t\tProvEn:    \"California\",\n\t\t\tCity:      \"洛杉矶\",\n\t\t\tCityEn:    \"Los Angeles\",\n\t\t},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeCity, HostNamePTRorIP, \"cn\", false)\n\twant := \"AS13335 1.1.1.1 洛杉矶\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeCity cn: got %q, want %q\", got, want)\n\t}\n\n\tgot = formatMTRHostByMode(s, HostModeCity, HostNamePTRorIP, \"en\", false)\n\twant = \"AS13335 1.1.1.1 Los Angeles\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeCity en: got %q, want %q\", got, want)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// MPLS 多行显示测试\n// ---------------------------------------------------------------------------\n\nfunc TestTUI_MPLSMultiLine(t *testing.T) {\n\theader := MTRTUIHeader{Target: \"1.1.1.1\", StartTime: time.Now(), Iteration: 1}\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL:  1,\n\t\t\tIP:   \"10.0.0.1\",\n\t\t\tLoss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0,\n\t\t\tMPLS: []string{\"[MPLS: Lbl 100, TC 0, S 1, TTL 1]\", \"[MPLS: Lbl 200, TC 0, S 0, TTL 1]\"},\n\t\t},\n\t\t{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},\n\t}\n\tresult := mtrTUIRenderStringWithWidth(header, stats, 120)\n\n\t// MPLS 标签应在独立的续行中出现\n\tif !strings.Contains(result, \"[MPLS: Lbl 100\") {\n\t\tt.Error(\"missing first MPLS label in output\")\n\t}\n\tif !strings.Contains(result, \"[MPLS: Lbl 200\") {\n\t\tt.Error(\"missing second MPLS label in output\")\n\t}\n\n\t// MPLS 不应和 host IP 在同一行\n\tlines := strings.Split(result, \"\\r\\n\")\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"10.0.0.1\") && strings.Contains(l, \"MPLS\") {\n\t\t\tt.Error(\"MPLS label should not be on the same line as host IP\")\n\t\t}\n\t}\n}\n\nfunc TestTUI_MPLSMultiLine_NoMPLS(t *testing.T) {\n\theader := MTRTUIHeader{Target: \"1.1.1.1\", StartTime: time.Now(), Iteration: 1}\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t}\n\tresult := mtrTUIRenderStringWithWidth(header, stats, 120)\n\tif strings.Contains(result, \"MPLS\") {\n\t\tt.Error(\"output should not contain MPLS when hop has no labels\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 新 header 格式综合测试\n// ---------------------------------------------------------------------------\n\nfunc TestMTRTUI_HeaderSrcDstFormat(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tDomain:    \"dns.google\",\n\t\tTargetIP:  \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t\tVersion:   \"v1.0.0\",\n\t\tSrcHost:   \"laptop.local\",\n\t\tSrcIP:     \"10.0.0.5\",\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 150)\n\n\t// 第一行应仅含 NextTrace [版本]\n\tlines := strings.Split(out, \"\\r\\n\")\n\tfound := false\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"NextTrace [v1.0.0]\") {\n\t\t\tfound = true\n\t\t\t// 不应含 \"My traceroute\"\n\t\t\tif strings.Contains(l, \"My traceroute\") {\n\t\t\t\tt.Error(\"line 1 should not contain 'My traceroute'\")\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Error(\"missing 'NextTrace [v1.0.0]' in output\")\n\t}\n\n\t// 第二行应含 src -> dst + RFC3339 时间\n\tif !strings.Contains(out, \"laptop.local (10.0.0.5) -> dns.google (8.8.8.8)\") {\n\t\tt.Errorf(\"missing route line, got:\\n%s\", out)\n\t}\n}\n\nfunc TestMTRTUI_HeaderNoSrcInfo(t *testing.T) {\n\t// 无源信息时应只显示目标\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tDomain:    \"dns.google\",\n\t\tTargetIP:  \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t\tVersion:   \"v1.0.0\",\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\t// 不应含 \"->\" （无源信息）\n\tif strings.Contains(out, \"->\") {\n\t\tt.Errorf(\"should not contain '->' when no src info, got:\\n%s\", out)\n\t}\n\t// 应含目标\n\tif !strings.Contains(out, \"dns.google (8.8.8.8)\") {\n\t\tt.Errorf(\"should contain destination, got:\\n%s\", out)\n\t}\n}\n\nfunc TestMTRTUI_DisplayModeInKeys(t *testing.T) {\n\tfor mode, label := range map[int]string{0: \"IP/PTR\", 1: \"ASN\", 2: \"City\", 3: \"Owner\", 4: \"Full\"} {\n\t\theader := MTRTUIHeader{\n\t\t\tTarget:      \"8.8.8.8\",\n\t\t\tStartTime:   time.Now(),\n\t\t\tIteration:   1,\n\t\t\tDisplayMode: mode,\n\t\t}\n\t\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\t\texpected := \"Y-display(\" + label + \")\"\n\t\tif !strings.Contains(out, expected) {\n\t\t\tt.Errorf(\"mode %d: should contain %q, got:\\n%s\", mode, expected, out)\n\t\t}\n\t}\n}\n\nfunc TestMTRTUI_DisplayModeAffectsHopData(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL: 1, IP: \"1.1.1.1\",\n\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\tAsnumber:  \"13335\",\n\t\t\t\tCountryEn: \"US\",\n\t\t\t\tOwner:     \"Cloudflare\",\n\t\t\t},\n\t\t\tLoss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0,\n\t\t},\n\t}\n\n\t// Mode 0 (ASN): 有 ASN 无 country 无 owner\n\theader := MTRTUIHeader{Target: \"x\", StartTime: time.Now(), Iteration: 1, DisplayMode: HostModeASN, Lang: \"en\"}\n\tout := mtrTUIRenderStringWithWidth(header, stats, 120)\n\tif !strings.Contains(out, \"AS13335\") {\n\t\tt.Error(\"mode ASN should show ASN\")\n\t}\n\tif strings.Contains(out, \"Cloudflare\") {\n\t\tt.Error(\"mode ASN should not show owner\")\n\t}\n\n\t// Mode 3 (Full): 有 ASN + country + owner\n\theader.DisplayMode = HostModeFull\n\tout = mtrTUIRenderStringWithWidth(header, stats, 120)\n\tif !strings.Contains(out, \"AS13335\") {\n\t\tt.Error(\"mode Full should show ASN\")\n\t}\n\tif !strings.Contains(out, \"US\") {\n\t\tt.Error(\"mode Full should show country\")\n\t}\n\tif !strings.Contains(out, \"Cloudflare\") {\n\t\tt.Error(\"mode Full should show owner\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// NameMode (n 键) 测试\n// ---------------------------------------------------------------------------\n\nfunc TestFormatMTRHostByMode_IPOnly_ShowsIP(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t},\n\t}\n\t// HostNameIPOnly 时 base 始终是 IP，即使有 PTR\n\tgot := formatMTRHostByMode(s, HostModeFull, HostNameIPOnly, \"en\", false)\n\twant := \"AS13335 1.1.1.1 US, Cloudflare\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeFull+IPOnly: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_PTRorIP_ShowsPTR(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeFull, HostNamePTRorIP, \"en\", false)\n\twant := \"AS13335 one.one.one.one US, Cloudflare\"\n\tif got != want {\n\t\tt.Errorf(\"HostModeFull+PTRorIP: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_IPOnly_NoPTR(t *testing.T) {\n\t// 无 PTR 时 HostNameIPOnly 和 HostNamePTRorIP 结果相同\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"10.0.0.1\",\n\t\tGeo: &ipgeo.IPGeoData{Asnumber: \"64512\"},\n\t}\n\tgotIP := formatMTRHostByMode(s, HostModeASN, HostNameIPOnly, \"en\", false)\n\tgotPTR := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, \"en\", false)\n\tif gotIP != gotPTR {\n\t\tt.Errorf(\"no PTR: IPOnly=%q differs from PTRorIP=%q\", gotIP, gotPTR)\n\t}\n}\n\nfunc TestMTRRenderTable_IPOnly(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{TTL: 1, IP: \"1.1.1.1\", Host: \"one.one.one.one\", Geo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t}},\n\t}\n\t// HostNameIPOnly → Host 使用 IP 而非 PTR\n\trows := MTRRenderTable(stats, HostModeFull, HostNameIPOnly, \"en\", false)\n\tif strings.Contains(rows[0].Host, \"one.one.one.one\") {\n\t\tt.Errorf(\"IPOnly should not show PTR, got: %q\", rows[0].Host)\n\t}\n\tif !strings.Contains(rows[0].Host, \"1.1.1.1\") {\n\t\tt.Errorf(\"IPOnly should show IP, got: %q\", rows[0].Host)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_ShowIPs_PTRAndIP(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo:  &ipgeo.IPGeoData{Asnumber: \"13335\"},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, \"en\", true)\n\twant := \"AS13335 one.one.one.one (1.1.1.1)\"\n\tif got != want {\n\t\tt.Errorf(\"showIPs PTR+IP: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_ShowIPs_NoPTRFallsBackToIP(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"10.0.0.1\",\n\t\tGeo: &ipgeo.IPGeoData{Asnumber: \"64512\"},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, \"en\", true)\n\twant := \"AS64512 10.0.0.1\"\n\tif got != want {\n\t\tt.Errorf(\"showIPs no PTR: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_ShowIPs_PTREqualsIP_NoDup(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"10.0.0.1\",\n\t\tHost: \"10.0.0.1\",\n\t\tGeo:  &ipgeo.IPGeoData{Asnumber: \"64512\"},\n\t}\n\tgot := formatMTRHostByMode(s, HostModeASN, HostNamePTRorIP, \"en\", true)\n\twant := \"AS64512 10.0.0.1\"\n\tif got != want {\n\t\tt.Errorf(\"showIPs PTR==IP should not duplicate: got %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFormatMTRHostByMode_IPOnly_IgnoreShowIPs(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo:  &ipgeo.IPGeoData{Asnumber: \"13335\"},\n\t}\n\tgotFalse := formatMTRHostByMode(s, HostModeASN, HostNameIPOnly, \"en\", false)\n\tgotTrue := formatMTRHostByMode(s, HostModeASN, HostNameIPOnly, \"en\", true)\n\twant := \"AS13335 1.1.1.1\"\n\tif gotFalse != want || gotTrue != want {\n\t\tt.Errorf(\"IPOnly should ignore showIPs: gotFalse=%q, gotTrue=%q, want %q\", gotFalse, gotTrue, want)\n\t}\n}\n\nfunc TestFormatReportHost_ShowIPs_PTRAndIP(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tGeo:  &ipgeo.IPGeoData{Asnumber: \"13335\"},\n\t}\n\tgot := formatReportHost(s, HostModeASN, HostNamePTRorIP, \"en\", true)\n\twant := \"AS13335 one.one.one.one (1.1.1.1)\"\n\tif got != want {\n\t\tt.Errorf(\"report showIPs PTR+IP: got %q, want %q\", got, want)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// TUI Header APIInfo + NameMode 测试\n// ---------------------------------------------------------------------------\n\nfunc TestMTRTUI_HeaderAPIInfo(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t\tVersion:   \"v1.0.0\",\n\t\tAPIInfo:   \"preferred API IP: 1.2.3.4\",\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\tif !strings.Contains(out, \"preferred API IP: 1.2.3.4\") {\n\t\tt.Errorf(\"header should contain API info, got:\\n%s\", out)\n\t}\n\t// API 信息应与 NextTrace 在同一行\n\tlines := strings.Split(out, \"\\r\\n\")\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"NextTrace [v1.0.0]\") {\n\t\t\tif !strings.Contains(l, \"preferred API IP: 1.2.3.4\") {\n\t\t\t\tt.Errorf(\"API info should be on the same line as NextTrace, got:\\n%s\", l)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"missing NextTrace header line\")\n}\n\nfunc TestMTRTUI_HeaderNoAPIInfo(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t\tVersion:   \"v1.0.0\",\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\tif strings.Contains(out, \"preferred API\") {\n\t\tt.Errorf(\"header should not contain API info when empty, got:\\n%s\", out)\n\t}\n}\n\nfunc TestMTRTUI_NameModeInKeys(t *testing.T) {\n\tfor nm, label := range map[int]string{0: \"ptr\", 1: \"ip\"} {\n\t\theader := MTRTUIHeader{\n\t\t\tTarget:    \"8.8.8.8\",\n\t\t\tStartTime: time.Now(),\n\t\t\tIteration: 1,\n\t\t\tNameMode:  nm,\n\t\t}\n\t\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\t\texpected := \"N-host(\" + label + \")\"\n\t\tif !strings.Contains(out, expected) {\n\t\t\tt.Errorf(\"nameMode %d: should contain %q, got:\\n%s\", nm, expected, out)\n\t\t}\n\t}\n}\n\nfunc TestMTRTUI_NameModeInKeys_ShowIPs(t *testing.T) {\n\tfor nm, label := range map[int]string{0: \"ptr+ip\", 1: \"ip\"} {\n\t\theader := MTRTUIHeader{\n\t\t\tTarget:    \"8.8.8.8\",\n\t\t\tStartTime: time.Now(),\n\t\t\tIteration: 1,\n\t\t\tNameMode:  nm,\n\t\t\tShowIPs:   true,\n\t\t}\n\t\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\t\texpected := \"N-host(\" + label + \")\"\n\t\tif !strings.Contains(out, expected) {\n\t\t\tt.Errorf(\"showIPs nameMode %d: should contain %q, got:\\n%s\", nm, expected, out)\n\t\t}\n\t}\n}\n\nfunc TestMTRTUI_ShowIPsRendersPTRAndIP(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:      \"example.com\",\n\t\tStartTime:   time.Now(),\n\t\tIteration:   1,\n\t\tDisplayMode: HostModeASN,\n\t\tNameMode:    HostNamePTRorIP,\n\t\tShowIPs:     true,\n\t\tLang:        \"en\",\n\t}\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL: 1, IP: \"1.1.1.1\", Host: \"one.one.one.one\",\n\t\t\tGeo:  &ipgeo.IPGeoData{Asnumber: \"13335\"},\n\t\t\tLoss: 0, Snt: 5, Last: 1.1, Avg: 1.1, Best: 1.1, Wrst: 1.1, StDev: 0,\n\t\t},\n\t}\n\n\tout := mtrTUIRenderStringWithWidth(header, stats, 120)\n\tif !strings.Contains(out, \"one.one.one.one (1.1.1.1)\") {\n\t\tt.Errorf(\"showIPs should render PTR (IP), got:\\n%s\", out)\n\t}\n\n\theader.NameMode = HostNameIPOnly\n\tout = mtrTUIRenderStringWithWidth(header, stats, 120)\n\tif strings.Contains(out, \"one.one.one.one (1.1.1.1)\") {\n\t\tt.Errorf(\"IPOnly should not render PTR (IP), got:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"AS13335 1.1.1.1\") {\n\t\tt.Errorf(\"IPOnly should render IP in host column, got:\\n%s\", out)\n\t}\n}\n\nfunc TestMTRTUI_FirstLineCentered(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t\tVersion:   \"v1.0.0\",\n\t}\n\tout := mtrTUIRenderStringWithWidth(header, nil, 120)\n\tlines := strings.Split(out, \"\\r\\n\")\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"NextTrace [v1.0.0]\") {\n\t\t\ttrimmed := strings.TrimRight(l, \" \")\n\t\t\t// 居中意味着首字符不是 'N'\n\t\t\tif len(trimmed) > 0 && trimmed[0] == 'N' {\n\t\t\t\tt.Errorf(\"first line should be centered (left-padded), got: %q\", l)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"missing NextTrace header line\")\n}\n\n// TestMTRTUI_FirstLineTruncatedOnNarrow 验证首行在极窄终端不会超宽。\nfunc TestMTRTUI_FirstLineTruncatedOnNarrow(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:    \"8.8.8.8\",\n\t\tStartTime: time.Now(),\n\t\tIteration: 1,\n\t\tVersion:   \"v1.0.0\",\n\t\tAPIInfo:   \"preferred API IP: 123.456.789.012 some very long extra text to overflow\",\n\t}\n\tconst width = 30\n\tout := mtrTUIRenderStringWithWidth(header, nil, width)\n\tlines := strings.Split(out, \"\\r\\n\")\n\tfor _, l := range lines {\n\t\t// 跳过清屏序列开头的行：去掉 ANSI CSI 序列后再检查\n\t\tclean := l\n\t\tfor strings.HasPrefix(clean, \"\\033[\") {\n\t\t\t// 跳过 \\033[ ... 直到终止字节 (0x40-0x7E)\n\t\t\tidx := 2\n\t\t\tfor idx < len(clean) && (clean[idx] < 0x40 || clean[idx] > 0x7E) {\n\t\t\t\tidx++\n\t\t\t}\n\t\t\tif idx < len(clean) {\n\t\t\t\tidx++ // 跳过终止字节\n\t\t\t}\n\t\t\tclean = clean[idx:]\n\t\t}\n\t\tif strings.Contains(clean, \"NextTrace\") {\n\t\t\tw := displayWidth(clean)\n\t\t\tif w > width {\n\t\t\t\tt.Errorf(\"first line exceeds termWidth=%d: displayWidth=%d, line=%q\", width, w, clean)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"missing NextTrace header line\")\n}\n\n// ---------------------------------------------------------------------------\n// 新增：waiting for reply + 分隔符规则测试\n// ---------------------------------------------------------------------------\n\n// TestTUI_WaitingForReplyOn100Loss 验证 100% loss 且无地址的 hop 在 TUI 中\n// 显示 \"(waiting for reply)\" 而非 \"???\"。\nfunc TestTUI_WaitingForReplyOn100Loss(t *testing.T) {\n\theader := MTRTUIHeader{Target: \"1.1.1.1\", StartTime: time.Now(), Iteration: 1}\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t\t{TTL: 2, IP: \"\", Host: \"\", Loss: 100, Snt: 5, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0},\n\t\t{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},\n\t}\n\tresult := mtrTUIRenderStringWithWidth(header, stats, 120)\n\n\tif !strings.Contains(result, \"(waiting for reply)\") {\n\t\tt.Errorf(\"100%% loss hop should show '(waiting for reply)', got:\\n%s\", result)\n\t}\n\t// 不应出现 \"???\"（TUI 中的 100% loss 无地址 hop）\n\tlines := strings.Split(result, \"\\r\\n\")\n\tfor _, l := range lines {\n\t\t// 排除 header/表头行，只看数据行\n\t\ttrimmed := strings.TrimLeft(l, \" \")\n\t\tif strings.HasPrefix(trimmed, \"2.\") && strings.Contains(l, \"???\") {\n\t\t\tt.Errorf(\"100%% loss hop should not show '???' in TUI, got line: %q\", l)\n\t\t}\n\t}\n}\n\n// TestTUI_HostSeparators_ManualSpaceAlignment 验证 TUI 中 host 文本使用手动空格对齐：\n//   - 序号后 1 空格 + ASN\n//   - ASN 与 IP/PTR 之间为手动空格对齐\n//   - IP/PTR 与后续信息之间为空格\nfunc TestTUI_HostSeparators_ManualSpaceAlignment(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:      \"1.1.1.1\",\n\t\tStartTime:   time.Now(),\n\t\tIteration:   1,\n\t\tDisplayMode: HostModeFull,\n\t\tLang:        \"en\",\n\t}\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL: 1, IP: \"1.1.1.1\", Host: \"one.one.one.one\",\n\t\t\tLoss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0,\n\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\tAsnumber:  \"13335\",\n\t\t\t\tCountryEn: \"US\",\n\t\t\t\tOwner:     \"Cloudflare\",\n\t\t\t},\n\t\t},\n\t}\n\tresult := mtrTUIRenderStringWithWidth(header, stats, 120)\n\n\tlines := strings.Split(result, \"\\r\\n\")\n\tvar hopLine string\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"one.one.one.one\") {\n\t\t\thopLine = l\n\t\t\tbreak\n\t\t}\n\t}\n\tif hopLine == \"\" {\n\t\tt.Fatal(\"missing hop line with one.one.one.one\")\n\t}\n\n\t// 序号后 1 空格：应含 \" 1. AS\" 模式\n\tif !strings.Contains(hopLine, \" 1. AS\") {\n\t\tt.Errorf(\"prefix should be followed by 1 space then ASN, got: %q\", hopLine)\n\t}\n\n\t// ASN 与 IP/PTR 之间应为空格对齐，不再使用 tab\n\tif !strings.Contains(hopLine, \"AS13335 one.one.one.one\") {\n\t\tt.Errorf(\"ASN and IP/PTR should be space-aligned, got: %q\", hopLine)\n\t}\n\tif strings.Contains(hopLine, \"\\t\") {\n\t\tt.Errorf(\"TUI host line should not contain tab, got: %q\", hopLine)\n\t}\n\n\t// IP/PTR 与后续信息之间应为空格\n\tif !strings.Contains(hopLine, \"one.one.one.one US Cloudflare\") {\n\t\tt.Errorf(\"IP/PTR and extras should be separated by space, got: %q\", hopLine)\n\t}\n}\n\nfunc TestFormatTUIHost_ManualASNAlignment(t *testing.T) {\n\tparts := mtrHostParts{\n\t\tasn:    \"AS13335\",\n\t\tbase:   \"one.one.one.one\",\n\t\textras: []string{\"US\", \"Cloudflare\"},\n\t}\n\tgot := formatTUIHost(parts, 7)\n\twant := \"AS13335 one.one.one.one US Cloudflare\"\n\tif got != want {\n\t\tt.Fatalf(\"formatTUIHost() = %q, want %q\", got, want)\n\t}\n\n\tshortASN := mtrHostParts{\n\t\tasn:  \"AS969\",\n\t\tbase: \"one.one.one.one\",\n\t}\n\tgot = formatTUIHost(shortASN, 8)\n\twant = \"AS969    one.one.one.one\"\n\tif got != want {\n\t\tt.Fatalf(\"formatTUIHost() short ASN = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildTUIHostParts_MissingASNUsesPlaceholder(t *testing.T) {\n\tparts := buildTUIHostParts(trace.MTRHopStat{\n\t\tTTL: 1,\n\t\tIP:  \"210.78.30.166\",\n\t\tGeo: &ipgeo.IPGeoData{},\n\t}, HostModeASN, HostNamePTRorIP, \"en\", false)\n\n\tif parts.waiting {\n\t\tt.Fatal(\"missing ASN hop should not be waiting\")\n\t}\n\tif parts.asn != \"AS???\" {\n\t\tt.Fatalf(\"parts.asn = %q, want %q\", parts.asn, \"AS???\")\n\t}\n\tif parts.base != \"210.78.30.166\" {\n\t\tt.Fatalf(\"parts.base = %q, want %q\", parts.base, \"210.78.30.166\")\n\t}\n}\n\nfunc TestBuildTUIHostParts_WaitingDoesNotUsePlaceholder(t *testing.T) {\n\tparts := buildTUIHostParts(trace.MTRHopStat{\n\t\tTTL: 2, IP: \"\", Host: \"\", Loss: 100, Snt: 5,\n\t}, HostModeASN, HostNamePTRorIP, \"en\", false)\n\n\tif !parts.waiting {\n\t\tt.Fatal(\"waiting hop should keep waiting=true\")\n\t}\n\tif parts.asn != \"\" {\n\t\tt.Fatalf(\"waiting hop should not use ASN placeholder, got %q\", parts.asn)\n\t}\n}\n\nfunc TestTUI_MissingASNRenderedAsPlaceholder(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:      \"1.1.1.1\",\n\t\tStartTime:   time.Now(),\n\t\tIteration:   1,\n\t\tDisplayMode: HostModeASN,\n\t\tLang:        \"en\",\n\t}\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL:  15,\n\t\t\tIP:   \"210.78.30.166\",\n\t\t\tLoss: 0, Snt: 5, Last: 53.51, Avg: 53.81, Best: 53.36, Wrst: 54.58, StDev: 0.47,\n\t\t\tGeo: &ipgeo.IPGeoData{},\n\t\t},\n\t}\n\n\tout := mtrTUIRenderStringWithWidth(header, stats, 120)\n\tif !strings.Contains(out, \"AS???\") {\n\t\tt.Fatalf(\"missing ASN placeholder in output:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"AS??? 210.78.30.166\") {\n\t\tt.Fatalf(\"placeholder row should include aligned base host, got:\\n%s\", out)\n\t}\n}\n\n// TestTUI_ManualASNAlignment_StillRightAnchored 验证使用手动空格对齐的 host 行\n// 右侧指标区仍能对齐（metricsStart 稳定）。\nfunc TestTUI_ManualASNAlignment_StillRightAnchored(t *testing.T) {\n\theader := MTRTUIHeader{\n\t\tTarget:      \"1.1.1.1\",\n\t\tStartTime:   time.Now(),\n\t\tIteration:   1,\n\t\tDisplayMode: HostModeASN,\n\t\tLang:        \"en\",\n\t}\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL: 1, IP: \"10.0.0.1\", Loss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0,\n\t\t\tGeo: &ipgeo.IPGeoData{Asnumber: \"13335\"},\n\t\t},\n\t\t{\n\t\t\tTTL: 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,\n\t\t\tGeo: &ipgeo.IPGeoData{Asnumber: \"174\"},\n\t\t},\n\t\t{\n\t\t\tTTL: 3, IP: \"\", Host: \"\", Loss: 100, Snt: 4, Last: 0, Avg: 0, Best: 0, Wrst: 0, StDev: 0,\n\t\t},\n\t}\n\tconst width = 120\n\tresult := mtrTUIRenderStringWithWidth(header, stats, width)\n\n\tlo := computeLayout(width, tuiPrefixWidthForMaxTTL(3), 0)\n\tlines := strings.Split(result, \"\\r\\n\")\n\n\t// 在数据行中，指标区应出现在 metricsStart 附近\n\tfor _, l := range lines {\n\t\ttrimmed := strings.TrimLeft(l, \" \")\n\t\tif len(trimmed) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t// 检查是否是数据行（以 \"N. \" 格式开头）\n\t\tisData := false\n\t\tfor _, prefix := range []string{\"1.\", \"2.\", \"3.\"} {\n\t\t\tif strings.HasPrefix(trimmed, prefix) {\n\t\t\t\tisData = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !isData {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.Contains(l, \"\\t\") {\n\t\t\tt.Errorf(\"data row should not contain tab after manual alignment, got: %q\", l)\n\t\t}\n\t\t// 行宽不应超过 termWidth\n\t\tw := displayWidthWithTabs(l, tuiTabStop)\n\t\tif w > width {\n\t\t\tt.Errorf(\"data row exceeds termWidth=%d: displayWidth=%d, line=%q\", width, w, l)\n\t\t}\n\t\t// 指标区不应超出\n\t\tif w > lo.metricsStart+lo.metricsWidth() {\n\t\t\tt.Errorf(\"data row overflows: displayWidth=%d > metricsStart(%d)+metricsWidth(%d), line=%q\",\n\t\t\t\tw, lo.metricsStart, lo.metricsWidth(), l)\n\t\t}\n\t}\n}\n\n// TestReport_WaitingForReplyOn100Loss 验证 report 模式中 100% loss\n// 且无地址的 hop 显示 \"(waiting for reply)\"。\nfunc TestReport_WaitingForReplyOn100Loss(t *testing.T) {\n\tp := buildMTRHostParts(trace.MTRHopStat{\n\t\tTTL: 2, IP: \"\", Host: \"\", Loss: 100, Snt: 10,\n\t}, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif !p.waiting {\n\t\tt.Fatal(\"expected waiting=true for 100% loss with no IP/Host\")\n\t}\n\n\thost := formatReportHost(trace.MTRHopStat{\n\t\tTTL: 2, IP: \"\", Host: \"\", Loss: 100, Snt: 10,\n\t}, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif host != \"(waiting for reply)\" {\n\t\tt.Errorf(\"report host = %q, want %q\", host, \"(waiting for reply)\")\n\t}\n\n\t// 有 IP 但 loss=100% → 不应显示 waiting\n\thostWithIP := formatReportHost(trace.MTRHopStat{\n\t\tTTL: 2, IP: \"10.0.0.1\", Host: \"\", Loss: 100, Snt: 10,\n\t}, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif hostWithIP == \"(waiting for reply)\" {\n\t\tt.Error(\"hop with IP should not show waiting even with 100% loss\")\n\t}\n}\n\n// TestReport_FullExtrasUseSpaces_NoComma 验证 report HostModeFull 中\n// 后续信息使用空格分隔，不含 \", \"。\nfunc TestReport_FullExtrasUseSpaces_NoComma(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tLoss: 0, Snt: 10,\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t},\n\t}\n\thost := formatReportHost(s, HostModeFull, HostNamePTRorIP, \"en\", false)\n\t// 应为 \"AS13335 one.one.one.one US Cloudflare\"（空格分隔）\n\tif strings.Contains(host, \", \") {\n\t\tt.Errorf(\"report host should not contain ', ', got: %q\", host)\n\t}\n\twant := \"AS13335 one.one.one.one US Cloudflare\"\n\tif host != want {\n\t\tt.Errorf(\"report host = %q, want %q\", host, want)\n\t}\n}\n\nfunc TestFormatCompactReportHost_Waiting(t *testing.T) {\n\thost := formatCompactReportHost(trace.MTRHopStat{\n\t\tTTL: 2, IP: \"\", Host: \"\", Loss: 100, Snt: 10,\n\t}, HostNamePTRorIP, false)\n\tif host != \"(waiting for reply)\" {\n\t\tt.Fatalf(\"formatCompactReportHost() = %q, want %q\", host, \"(waiting for reply)\")\n\t}\n}\n\nfunc TestFormatCompactReportHost_BaseOnly_NoASNNoGeoNoMPLS(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t\tLoss: 0, Snt: 10,\n\t\tGeo: &ipgeo.IPGeoData{\n\t\t\tAsnumber:  \"13335\",\n\t\t\tCountryEn: \"US\",\n\t\t\tOwner:     \"Cloudflare\",\n\t\t},\n\t\tMPLS: []string{\"[MPLS: Lbl 100, TC 0, S 1, TTL 1]\"},\n\t}\n\thost := formatCompactReportHost(s, HostNamePTRorIP, false)\n\tif host != \"one.one.one.one\" {\n\t\tt.Fatalf(\"formatCompactReportHost() = %q, want %q\", host, \"one.one.one.one\")\n\t}\n\tfor _, disallowed := range []string{\"AS13335\", \"US\", \"Cloudflare\", \"MPLS\"} {\n\t\tif strings.Contains(host, disallowed) {\n\t\t\tt.Fatalf(\"formatCompactReportHost() should not contain %q, got %q\", disallowed, host)\n\t\t}\n\t}\n}\n\nfunc TestFormatCompactReportHost_ShowIPs(t *testing.T) {\n\ts := trace.MTRHopStat{\n\t\tTTL:  1,\n\t\tIP:   \"1.1.1.1\",\n\t\tHost: \"one.one.one.one\",\n\t}\n\thost := formatCompactReportHost(s, HostNamePTRorIP, true)\n\tif host != \"one.one.one.one (1.1.1.1)\" {\n\t\tt.Fatalf(\"formatCompactReportHost() = %q, want %q\", host, \"one.one.one.one (1.1.1.1)\")\n\t}\n}\n\nfunc TestMTRReportPrint_NonWideUsesBaseHostOnly(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL:  1,\n\t\t\tIP:   \"1.1.1.1\",\n\t\t\tHost: \"one.one.one.one\",\n\t\t\tLoss: 0, Snt: 10, Last: 1.23, Avg: 1.45, Best: 0.98, Wrst: 2.10, StDev: 0.32,\n\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\tAsnumber:  \"13335\",\n\t\t\t\tCountryEn: \"US\",\n\t\t\t\tOwner:     \"Cloudflare\",\n\t\t\t},\n\t\t\tMPLS: []string{\"[MPLS: Lbl 100, TC 0, S 1, TTL 1]\"},\n\t\t},\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tMTRReportPrint(stats, MTRReportOptions{\n\t\t\tStartTime: time.Date(2025, 7, 14, 9, 12, 0, 0, time.FixedZone(\"+0800\", 8*3600)),\n\t\t\tSrcHost:   \"myhost\",\n\t\t\tWide:      false,\n\t\t\tShowIPs:   false,\n\t\t\tLang:      \"en\",\n\t\t})\n\t})\n\n\tif !strings.Contains(out, \"one.one.one.one\") {\n\t\tt.Fatalf(\"non-wide report should contain base host, got:\\n%s\", out)\n\t}\n\tfor _, disallowed := range []string{\"AS13335\", \"Cloudflare\", \"MPLS\", \" US \"} {\n\t\tif strings.Contains(out, disallowed) {\n\t\t\tt.Fatalf(\"non-wide report should not contain %q, got:\\n%s\", disallowed, out)\n\t\t}\n\t}\n}\n\nfunc TestMTRReportPrint_WideStillIncludesGeoOrMPLS(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{\n\t\t\tTTL:  1,\n\t\t\tIP:   \"1.1.1.1\",\n\t\t\tHost: \"one.one.one.one\",\n\t\t\tLoss: 0, Snt: 10, Last: 1.23, Avg: 1.45, Best: 0.98, Wrst: 2.10, StDev: 0.32,\n\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\tAsnumber:  \"13335\",\n\t\t\t\tCountryEn: \"US\",\n\t\t\t\tOwner:     \"Cloudflare\",\n\t\t\t},\n\t\t\tMPLS: []string{\"[MPLS: Lbl 100, TC 0, S 1, TTL 1]\"},\n\t\t},\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tMTRReportPrint(stats, MTRReportOptions{\n\t\t\tStartTime: time.Date(2025, 7, 14, 9, 12, 0, 0, time.FixedZone(\"+0800\", 8*3600)),\n\t\t\tSrcHost:   \"myhost\",\n\t\t\tWide:      true,\n\t\t\tShowIPs:   false,\n\t\t\tLang:      \"en\",\n\t\t})\n\t})\n\n\tfor _, expected := range []string{\"AS13335\", \"Cloudflare\", \"[MPLS: Lbl 100\"} {\n\t\tif !strings.Contains(out, expected) {\n\t\t\tt.Fatalf(\"wide report should preserve %q, got:\\n%s\", expected, out)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 3 位 TTL 前缀宽度回归测试\n// ---------------------------------------------------------------------------\n\n// TestTUI_ThreeDigitTTLAlignment 验证 TTL>=100 时前缀宽度自动扩展为 5，\n// 布局不变式仍成立，且数据行不超宽。\nfunc TestTUI_ThreeDigitTTLAlignment(t *testing.T) {\n\theader := MTRTUIHeader{Target: \"1.1.1.1\", StartTime: time.Now(), Iteration: 1, Lang: \"en\"}\n\tvar stats []trace.MTRHopStat\n\tfor ttl := 99; ttl <= 101; ttl++ {\n\t\tstats = append(stats, trace.MTRHopStat{\n\t\t\tTTL: ttl, IP: fmt.Sprintf(\"10.0.%d.1\", ttl),\n\t\t\tLoss: 0, Snt: 5, Last: 1.0, Avg: 1.0, Best: 1.0, Wrst: 1.0, StDev: 0,\n\t\t})\n\t}\n\n\tconst width = 120\n\tresult := mtrTUIRenderStringWithWidth(header, stats, width)\n\n\t// 验证 prefixW 为 5（3 位 TTL → digits=3 → prefixW=5）\n\tprefixW := tuiPrefixWidthForMaxTTL(101)\n\tif prefixW != 5 {\n\t\tt.Errorf(\"tuiPrefixWidthForMaxTTL(101) = %d, want 5\", prefixW)\n\t}\n\n\t// 布局不变式\n\tlo := computeLayout(width, prefixW, 0)\n\tif lo.totalWidth() != width {\n\t\tt.Errorf(\"totalWidth()=%d, want %d (prefixW=%d, hostW=%d)\", lo.totalWidth(), width, lo.prefixW, lo.hostW)\n\t}\n\n\t// 100. 前缀应出现在输出中\n\tif !strings.Contains(result, \"100.\") {\n\t\tt.Error(\"missing '100.' prefix in output\")\n\t}\n\tif !strings.Contains(result, \"101.\") {\n\t\tt.Error(\"missing '101.' prefix in output\")\n\t}\n\n\t// 数据行不超宽\n\tlines := strings.Split(result, \"\\r\\n\")\n\tfor _, l := range lines {\n\t\ttrimmed := strings.TrimLeft(l, \" \")\n\t\tif len(trimmed) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tisData := false\n\t\tfor _, pfx := range []string{\"99.\", \"100.\", \"101.\"} {\n\t\t\tif strings.HasPrefix(trimmed, pfx) {\n\t\t\t\tisData = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !isData {\n\t\t\tcontinue\n\t\t}\n\t\tw := displayWidthWithTabs(l, tuiTabStop)\n\t\tif w > width {\n\t\t\tt.Errorf(\"data row exceeds termWidth=%d: displayWidth=%d, line=%q\", width, w, l)\n\t\t}\n\t}\n}\n\n// TestTUI_PrefixWidthForMaxTTL 验证 tuiPrefixWidthForMaxTTL 各区间。\nfunc TestTUI_PrefixWidthForMaxTTL(t *testing.T) {\n\tcases := []struct {\n\t\tmaxTTL int\n\t\twant   int\n\t}{\n\t\t{0, 4}, // 空 stats → 默认\n\t\t{1, 4}, // TTL<100 → 2 digits + 2 = 4\n\t\t{99, 4},\n\t\t{100, 5}, // 3 digits + 2 = 5\n\t\t{255, 5},\n\t\t{999, 5},\n\t\t{1000, 6}, // 4 digits + 2 = 6（极端场景）\n\t}\n\tfor _, c := range cases {\n\t\tgot := tuiPrefixWidthForMaxTTL(c.maxTTL)\n\t\tif got != c.want {\n\t\t\tt.Errorf(\"tuiPrefixWidthForMaxTTL(%d) = %d, want %d\", c.maxTTL, got, c.want)\n\t\t}\n\t}\n}\n\n// TestTUI_TotalWidthInvariant_ThreeDigitTTL 验证 3 位 TTL 下右锚定不变式。\nfunc TestTUI_TotalWidthInvariant_ThreeDigitTTL(t *testing.T) {\n\tprefixW := tuiPrefixWidthForMaxTTL(100) // 5\n\tfor _, tw := range []int{21, 25, 30, 40, 60, 80, 120, 200} {\n\t\tlo := computeLayout(tw, prefixW, 0)\n\t\tif lo.totalWidth() != tw {\n\t\t\tt.Errorf(\"termWidth=%d, prefixW=%d: totalWidth()=%d, want exact match\",\n\t\t\t\ttw, prefixW, lo.totalWidth())\n\t\t}\n\t\tif lo.hostW < 1 {\n\t\t\tt.Errorf(\"termWidth=%d, prefixW=%d: hostW=%d, must be >= 1\", tw, prefixW, lo.hostW)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Waiting-hop blank metrics\n// ---------------------------------------------------------------------------\n\n// TestMTRRenderTable_WaitingMetricsBlank 验证 100% 丢包且无 IP/Host 的行\n// 的所有指标列均为空字符串。\nfunc TestMTRRenderTable_WaitingMetricsBlank(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t\t{TTL: 2, IP: \"\", Host: \"\", Loss: 100, Snt: 5}, // waiting\n\t\t{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},\n\t}\n\trows := MTRRenderTable(stats, HostModeFull, HostNamePTRorIP, \"en\", false)\n\tif len(rows) != 3 {\n\t\tt.Fatalf(\"expected 3 rows, got %d\", len(rows))\n\t}\n\n\t// TTL 1 应有正常指标\n\tif rows[0].Loss == \"\" {\n\t\tt.Error(\"TTL 1 Loss should not be empty\")\n\t}\n\n\t// TTL 2 应全部空白\n\tr := rows[1]\n\tfor _, pair := range []struct {\n\t\tname, val string\n\t}{\n\t\t{\"Loss\", r.Loss}, {\"Snt\", r.Snt}, {\"Last\", r.Last},\n\t\t{\"Avg\", r.Avg}, {\"Best\", r.Best}, {\"Wrst\", r.Wrst}, {\"StDev\", r.StDev},\n\t} {\n\t\tif pair.val != \"\" {\n\t\t\tt.Errorf(\"waiting row %s = %q, want empty\", pair.name, pair.val)\n\t\t}\n\t}\n\n\t// TTL 3 应有正常指标\n\tif rows[2].Loss == \"\" {\n\t\tt.Error(\"TTL 3 Loss should not be empty\")\n\t}\n}\n\n// TestTUI_WaitingMetricsBlank 验证 TUI 帧中 waiting 行不出现 \"100.0%\" 或 \"0.00\"。\nfunc TestTUI_WaitingMetricsBlank(t *testing.T) {\n\tstats := []trace.MTRHopStat{\n\t\t{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},\n\t\t{TTL: 2, IP: \"\", Host: \"\", Loss: 100, Snt: 5}, // waiting\n\t}\n\theader := MTRTUIHeader{\n\t\tSrcHost: \"localhost\",\n\t\tTarget:  \"example.com\",\n\t}\n\tframe := mtrTUIRenderStringWithWidth(header, stats, 120)\n\n\t// Split lines and find the waiting row (contains \"(waiting for reply)\")\n\tlines := strings.Split(frame, \"\\n\")\n\tvar waitingLine string\n\tfor _, l := range lines {\n\t\tif strings.Contains(l, \"(waiting for reply)\") {\n\t\t\twaitingLine = l\n\t\t\tbreak\n\t\t}\n\t}\n\tif waitingLine == \"\" {\n\t\tt.Fatal(\"no waiting-for-reply line found in TUI frame\")\n\t}\n\tif strings.Contains(waitingLine, \"100.0%\") {\n\t\tt.Errorf(\"waiting line should not contain '100.0%%': %q\", waitingLine)\n\t}\n\tif strings.Contains(waitingLine, \"0.00\") {\n\t\tt.Errorf(\"waiting line should not contain '0.00': %q\", waitingLine)\n\t}\n}\n\nfunc TestFormatMTRRawLine_Success(t *testing.T) {\n\tline := FormatMTRRawLine(trace.MTRRawRecord{\n\t\tTTL:      4,\n\t\tSuccess:  true,\n\t\tIP:       \"84.17.33.106\",\n\t\tHost:     \"po66-3518.cr01.nrt04.jp.misaka.io\",\n\t\tRTTMs:    0.27,\n\t\tASN:      \"60068\",\n\t\tCountry:  \"日本\",\n\t\tProv:     \"东京都\",\n\t\tCity:     \"东京\",\n\t\tDistrict: \"\",\n\t\tOwner:    \"cdn77.com\",\n\t\tLat:      35.6804,\n\t\tLng:      139.7690,\n\t})\n\twant := \"4|84.17.33.106|po66-3518.cr01.nrt04.jp.misaka.io|0.27|60068|日本|东京都|东京||cdn77.com|35.6804|139.7690\"\n\tif line != want {\n\t\tt.Fatalf(\"FormatMTRRawLine()=%q, want %q\", line, want)\n\t}\n}\n\nfunc TestFormatMTRRawLine_TimeoutFixedColumns(t *testing.T) {\n\tline := FormatMTRRawLine(trace.MTRRawRecord{\n\t\tTTL:     9,\n\t\tSuccess: false,\n\t})\n\tif line != \"9|*||||||||||\" {\n\t\tt.Fatalf(\"timeout line = %q\", line)\n\t}\n\tif got := strings.Count(line, \"|\"); got != 11 {\n\t\tt.Fatalf(\"timeout line should contain 11 separators (12 columns), got %d: %q\", got, line)\n\t}\n}\n\nfunc TestMTRTUI_PacketsColorByLoss(t *testing.T) {\n\tcases := []struct {\n\t\tname    string\n\t\tloss    float64\n\t\twaiting bool\n\t\twant    color.Attribute\n\t}{\n\t\t{name: \"zero\", loss: 0, waiting: false, want: color.FgHiGreen},\n\t\t{name: \"low\", loss: 3, waiting: false, want: color.FgHiCyan},\n\t\t{name: \"mid\", loss: 12, waiting: false, want: color.FgHiYellow},\n\t\t{name: \"high\", loss: 35, waiting: false, want: color.FgYellow},\n\t\t{name: \"very_high\", loss: 80, waiting: false, want: color.FgHiRed},\n\t\t{name: \"waiting\", loss: 100, waiting: true, want: color.FgHiBlack},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := mtrColorLossBucket(tc.loss, tc.waiting)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Fatalf(\"loss bucket mismatch: got=%v want=%v\", got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMTRTUI_ColorDisabled_NoANSI(t *testing.T) {\n\torig := color.NoColor\n\tt.Cleanup(func() { color.NoColor = orig })\n\tcolor.NoColor = true\n\n\tlossCell, sntCell := mtrColorPacketsByLoss(\"  0.0%\", \"   1\", 0, false)\n\tif strings.Contains(lossCell, \"\\x1b[\") || strings.Contains(sntCell, \"\\x1b[\") {\n\t\tt.Fatalf(\"NoColor=true should not emit ANSI, got loss=%q snt=%q\", lossCell, sntCell)\n\t}\n}\n"
  },
  {
    "path": "printer/mtr_tui.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mattn/go-runewidth\"\n\t\"golang.org/x/term\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\n// ---------------------------------------------------------------------------\n// MTR TUI 全屏帧渲染器（mtr 风格自适应布局）\n// ---------------------------------------------------------------------------\n\n// MTRTUIStatus 表示 TUI 当前运行状态。\ntype MTRTUIStatus int\n\nconst (\n\tMTRTUIRunning MTRTUIStatus = iota\n\tMTRTUIPaused\n)\n\n// MTRTUIHeader 包含帧顶部显示的元信息。\ntype MTRTUIHeader struct {\n\tTarget    string\n\tStartTime time.Time\n\tStatus    MTRTUIStatus\n\tIteration int\n\t// 以下为 v2 新增字段\n\tDomain   string // 用户输入的域名（可为空）\n\tTargetIP string // 解析后的目标 IP\n\tVersion  string // 软件版本，如 \"v1.3.0\"\n\t// 以下为 v3 新增字段\n\tSrcHost     string // 源主机名\n\tSrcIP       string // 源 IP\n\tLang        string // 语言（\"en\" / \"cn\"）\n\tDisplayMode int    // 显示模式 0-4\n\tNameMode    int    // Host 基础显示 0=PTR/IP, 1=IP only\n\tShowIPs     bool   // 是否显示 PTR+IP（nameMode=0 时生效）\n\tAPIInfo     string // preferred API 信息（纯文本，可为空）\n\tDisableMPLS bool   // 是否隐藏 MPLS 行（运行时 toggle）\n}\n\n// ---------------------------------------------------------------------------\n// 布局计算器\n// ---------------------------------------------------------------------------\n\n// mtrTUILayout 描述一帧布局参数，由终端宽度动态计算。\ntype mtrTUILayout struct {\n\ttermWidth    int\n\tprefixW      int // hop prefix 列宽（如 \"10.|--\"）\n\thostW        int // Host 列显示宽度\n\tlossW        int // Loss% 列宽\n\tsntW         int // Snt 列宽\n\tlastW        int // Last 列宽\n\tavgW         int // Avg 列宽\n\tbestW        int // Best 列宽\n\twrstW        int // Wrst 列宽\n\tstdevW       int // StDev 列宽\n\tmetricsStart int // 指标区起始列（0-based）\n}\n\n// metricsWidth 返回右侧指标区总显示宽度（7 列 + 6 个间距）。\nfunc (lo *mtrTUILayout) metricsWidth() int {\n\treturn lo.lossW + lo.sntW + lo.lastW + lo.avgW + lo.bestW + lo.wrstW + lo.stdevW + 6*tuiMetricGap\n}\n\n// totalWidth 返回一行数据的总显示宽度。\nfunc (lo *mtrTUILayout) totalWidth() int {\n\treturn lo.prefixW + tuiPrefixGap + lo.hostW + tuiHostGap + lo.metricsWidth()\n}\n\n// 各列默认与最小宽度\nconst (\n\ttuiPrefixW     = 4 // 默认前缀宽度（TTL ≤ 99: \"%2d. \" = 4 列）\n\ttuiPrefixGap   = 0 // 前缀尾部已含空格\n\ttuiHostGap     = 2 // Host 与指标区之间最小间距\n\ttuiMetricGap   = 1 // 指标列之间间距\n\ttuiDefaultTerm = 120\n\ttuiTabStop     = 8 // tab 展开步长\n\n\ttuiLossDefault = 5\n\ttuiSntDefault  = 3\n\ttuiRTTDefault  = 7\n\ttuiHostDefault = 40\n\ttuiHostMin     = 8\n\ttuiLossMin     = 5\n\ttuiSntMin      = 3\n\ttuiRTTMin      = 5\n)\n\n// tuiPrefixWidthForMaxTTL 根据最大 TTL 值返回前缀列宽。\n// 前缀格式 \"%Nd. \"，其中 N = max(2, digits(maxTTL))，列宽 = N + 2。\nfunc tuiPrefixWidthForMaxTTL(maxTTL int) int {\n\tdigits := 2\n\tif maxTTL >= 1000 {\n\t\tdigits = 4\n\t} else if maxTTL >= 100 {\n\t\tdigits = 3\n\t}\n\treturn digits + 2 // \". \" 后缀\n}\n\n// computeLayout 根据终端宽度和前缀宽度计算布局。\n//\n// prefixW 为 hop 前缀列宽，由 tuiPrefixWidthForMaxTTL 动态计算。\n//\n// 三阶段压缩策略：\n//  1. 默认指标宽度，Host 取剩余空间\n//  2. Host 降至 tuiHostMin，按比例压缩指标列\n//  3. 极窄场景：循环缩减 Host（最低 1 列）直到 totalWidth ≤ termWidth\n//\n// 绝对下限 totalWidth = prefixW+prefixGap(0)+host(1)+hostGap(2)+7×1+6×1。\n// 当 termWidth 低于下限时接受溢出——该宽度下终端本身已不可用。\n// sntWidthForMax returns the display width needed for the given max Snt value.\n// Minimum is tuiSntDefault (3).\nfunc sntWidthForMax(maxSnt int) int {\n\tw := tuiSntDefault\n\tfor v := 1000; maxSnt >= v; v *= 10 {\n\t\tw++\n\t}\n\treturn w\n}\n\nfunc computeLayout(termWidth, prefixW, sntHint int) mtrTUILayout {\n\tif termWidth <= 0 {\n\t\ttermWidth = tuiDefaultTerm\n\t}\n\tif prefixW <= 0 {\n\t\tprefixW = tuiPrefixW\n\t}\n\tsntW := tuiSntDefault\n\tif sntHint > sntW {\n\t\tsntW = sntHint\n\t}\n\n\tlo := mtrTUILayout{\n\t\ttermWidth: termWidth,\n\t\tprefixW:   prefixW,\n\t\tlossW:     tuiLossDefault,\n\t\tsntW:      sntW,\n\t\tlastW:     tuiRTTDefault,\n\t\tavgW:      tuiRTTDefault,\n\t\tbestW:     tuiRTTDefault,\n\t\twrstW:     tuiRTTDefault,\n\t\tstdevW:    tuiRTTDefault,\n\t}\n\n\t// 左侧固定部分 = prefix + gap\n\tleftFixed := lo.prefixW + tuiPrefixGap\n\n\t// --- Phase 1: 默认指标，Host 取剩余 ---\n\thostW := termWidth - leftFixed - tuiHostGap - lo.metricsWidth()\n\tif hostW >= tuiHostMin {\n\t\tlo.hostW = hostW\n\t\tlo.metricsStart = leftFixed + lo.hostW + tuiHostGap\n\t\treturn lo\n\t}\n\n\t// --- Phase 2: Host 降至 tuiHostMin，压缩指标 ---\n\tlo.hostW = tuiHostMin\n\tmetricsAvail := termWidth - leftFixed - lo.hostW - tuiHostGap\n\tlo.lossW, lo.sntW, lo.lastW, lo.avgW, lo.bestW, lo.wrstW, lo.stdevW =\n\t\tshrinkMetrics(metricsAvail, sntW)\n\n\t// --- Phase 3: 极窄——循环缩减 Host 直到不超宽（最低 1） ---\n\tfor lo.totalWidth() > termWidth && lo.hostW > 1 {\n\t\tlo.hostW--\n\t}\n\n\t// --- 右锚定：把剩余 slack 全部回填 hostW，保证指标区贴右边界 ---\n\tif slack := termWidth - lo.totalWidth(); slack > 0 {\n\t\tlo.hostW += slack\n\t}\n\n\tlo.metricsStart = leftFixed + lo.hostW + tuiHostGap\n\treturn lo\n}\n\n// shrinkMetrics 在 available 宽度内缩小 7 列指标 + 6 间距。\n// sntDefault 为当前 Snt 列目标宽度（可能因动态计算大于 tuiSntDefault）。\n//\n// 当 available 极小时，列宽可降至绝对下限 1，确保 computeLayout\n// 的 phase-3 循环能把 totalWidth 压到 termWidth 以内。\nfunc shrinkMetrics(available, sntDefault int) (lossW, sntW, lastW, avgW, bestW, wrstW, stdevW int) {\n\tif sntDefault < tuiSntDefault {\n\t\tsntDefault = tuiSntDefault\n\t}\n\tavail := available - 6*tuiMetricGap\n\tif avail < 7 {\n\t\t// 绝对下限：每列 1\n\t\treturn 1, 1, 1, 1, 1, 1, 1\n\t}\n\n\tdefaults := [7]int{tuiLossDefault, sntDefault, tuiRTTDefault, tuiRTTDefault, tuiRTTDefault, tuiRTTDefault, tuiRTTDefault}\n\ttotal := 0\n\tfor _, c := range defaults {\n\t\ttotal += c\n\t}\n\tif avail >= total {\n\t\treturn defaults[0], defaults[1], defaults[2], defaults[3], defaults[4], defaults[5], defaults[6]\n\t}\n\n\t// 常规最小值\n\tmins := [7]int{tuiLossMin, tuiSntMin, tuiRTTMin, tuiRTTMin, tuiRTTMin, tuiRTTMin, tuiRTTMin}\n\tminTotal := 0\n\tfor _, m := range mins {\n\t\tminTotal += m\n\t}\n\n\tvar cols [7]int\n\tif avail >= minTotal {\n\t\t// 按比例缩小，兜底到常规最小值\n\t\tfor i := range cols {\n\t\t\tw := defaults[i] * avail / total\n\t\t\tif w < mins[i] {\n\t\t\t\tw = mins[i]\n\t\t\t}\n\t\t\tcols[i] = w\n\t\t}\n\t} else {\n\t\t// 极限缩小，兜底到 1\n\t\tfor i := range cols {\n\t\t\tw := defaults[i] * avail / total\n\t\t\tif w < 1 {\n\t\t\t\tw = 1\n\t\t\t}\n\t\t\tcols[i] = w\n\t\t}\n\t}\n\treturn cols[0], cols[1], cols[2], cols[3], cols[4], cols[5], cols[6]\n}\n\n// ---------------------------------------------------------------------------\n// 显示宽度辅助（CJK 宽字符感知）\n// ---------------------------------------------------------------------------\n\n// displayWidth 返回字符串的终端显示宽度。\nfunc displayWidth(s string) int {\n\treturn runewidth.StringWidth(s)\n}\n\n// truncateByDisplayWidth 将 s 截断到不超过 max 个显示列。\n// 超长时追加 \".\"。\nfunc truncateByDisplayWidth(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tw := runewidth.StringWidth(s)\n\tif w <= max {\n\t\treturn s\n\t}\n\tif max <= 1 {\n\t\treturn \".\"\n\t}\n\treturn runewidth.Truncate(s, max-1, \"\") + \".\"\n}\n\n// padRight 将 s 用空格填充到 width 显示列宽（CJK 安全）。\nfunc padRight(s string, width int) string {\n\tw := runewidth.StringWidth(s)\n\tif w >= width {\n\t\treturn s\n\t}\n\treturn s + strings.Repeat(\" \", width-w)\n}\n\n// padLeft 将 s 左填充空格到 width 显示列宽。\nfunc padLeft(s string, width int) string {\n\tw := runewidth.StringWidth(s)\n\tif w >= width {\n\t\treturn s\n\t}\n\treturn strings.Repeat(\" \", width-w) + s\n}\n\n// ---------------------------------------------------------------------------\n// Tab 感知宽度辅助\n// ---------------------------------------------------------------------------\n\n// displayWidthWithTabs 返回包含 tab 的字符串在终端上的显示宽度。\n// tabStop 为 tab 停靠间隔（通常为 8）。\nfunc displayWidthWithTabs(s string, tabStop int) int {\n\tcol := 0\n\tfor _, r := range s {\n\t\tif r == '\\t' {\n\t\t\tcol = ((col / tabStop) + 1) * tabStop\n\t\t} else {\n\t\t\tcol += runewidth.RuneWidth(r)\n\t\t}\n\t}\n\treturn col\n}\n\n// truncateWithTabs 将包含 tab 的字符串截断到不超过 maxW 显示列。\nfunc truncateWithTabs(s string, maxW int, tabStop int) string {\n\tif maxW <= 0 {\n\t\treturn \"\"\n\t}\n\tcol := 0\n\tvar result strings.Builder\n\tfor _, r := range s {\n\t\tvar nextCol int\n\t\tif r == '\\t' {\n\t\t\tnextCol = ((col / tabStop) + 1) * tabStop\n\t\t} else {\n\t\t\tnextCol = col + runewidth.RuneWidth(r)\n\t\t}\n\t\tif nextCol > maxW {\n\t\t\tbreak\n\t\t}\n\t\tresult.WriteRune(r)\n\t\tcol = nextCol\n\t}\n\treturn result.String()\n}\n\n// fitRight 先截断到 width，再右对齐填充。\n// 当列宽小于内容宽度时严格截断，保证输出恰好 width 列。\nfunc fitRight(s string, width int) string {\n\tif width <= 0 {\n\t\treturn \"\"\n\t}\n\ts = truncateByDisplayWidth(s, width)\n\treturn padLeft(s, width)\n}\n\n// fitLeft 先截断到 width，再左对齐填充。\nfunc fitLeft(s string, width int) string {\n\tif width <= 0 {\n\t\treturn \"\"\n\t}\n\ts = truncateByDisplayWidth(s, width)\n\treturn padRight(s, width)\n}\n\n// ---------------------------------------------------------------------------\n// 帧渲染\n// ---------------------------------------------------------------------------\n\n// tuiLine 在 raw mode 下输出一行并以 \\r\\n 结束，\n// 确保光标回到行首——裸 \\n 在 raw mode 下只向下移动不回列。\nfunc tuiLine(b *strings.Builder, format string, a ...any) {\n\tfmt.Fprintf(b, format, a...)\n\tb.WriteString(\"\\r\\n\")\n}\n\n// getTermWidth 获取 stdout 终端宽度，失败时返回默认值。\nvar getTermWidth = func() int {\n\tw, _, err := term.GetSize(int(os.Stdout.Fd()))\n\tif err != nil || w <= 0 {\n\t\treturn tuiDefaultTerm\n\t}\n\treturn w\n}\n\n// MTRTUIRender 将 MTR TUI 帧渲染到 w。\n// 每帧重新获取终端宽度并计算自适应布局。\nfunc MTRTUIRender(w io.Writer, header MTRTUIHeader, stats []trace.MTRHopStat) {\n\tmtrTUIRenderWithWidth(w, header, stats, getTermWidth())\n}\n\n// mtrTUIRenderWithWidth 是带可控宽度的内部渲染入口（测试用）。\nfunc mtrTUIRenderWithWidth(w io.Writer, header MTRTUIHeader, stats []trace.MTRHopStat, termWidth int) {\n\tlo := buildMTRTUILayout(stats, termWidth)\n\tvar b strings.Builder\n\n\twriteMTRTUIFramePrefix(&b)\n\trenderMTRTUIHeader(&b, header, lo.termWidth)\n\trenderDualHeader(&b, lo)\n\trenderMTRTUIRows(&b, header, stats, lo)\n\tfmt.Fprint(w, b.String())\n}\n\nfunc buildMTRTUILayout(stats []trace.MTRHopStat, termWidth int) mtrTUILayout {\n\tmaxTTL, maxSnt := scanMTRTUIStats(stats)\n\tprefixW := tuiPrefixWidthForMaxTTL(maxTTL)\n\treturn computeLayout(termWidth, prefixW, sntWidthForMax(maxSnt))\n}\n\nfunc scanMTRTUIStats(stats []trace.MTRHopStat) (int, int) {\n\tmaxTTL := 0\n\tmaxSnt := 0\n\tfor _, s := range stats {\n\t\tif s.TTL > maxTTL {\n\t\t\tmaxTTL = s.TTL\n\t\t}\n\t\tif s.Snt > maxSnt {\n\t\t\tmaxSnt = s.Snt\n\t\t}\n\t}\n\treturn maxTTL, maxSnt\n}\n\nfunc writeMTRTUIFramePrefix(b *strings.Builder) {\n\tb.WriteString(\"\\033[H\\033[2J\")\n}\n\nfunc renderMTRTUIHeader(b *strings.Builder, header MTRTUIHeader, termWidth int) {\n\ttuiLine(b, \"%s\", buildMTRTUITitleLine(header, termWidth))\n\ttuiLine(b, \"%s\", buildMTRTUIRouteLine(header, termWidth, time.Now()))\n\ttuiLine(b, \"%s\", buildMTRTUIControlsLine(header, termWidth))\n}\n\nfunc buildMTRTUITitleLine(header MTRTUIHeader, termWidth int) string {\n\ttitlePart, apiPart := resolveMTRTUITitleParts(header)\n\tline := titlePart + apiPart\n\tlineW := displayWidth(line)\n\tif lineW > termWidth {\n\t\tline = truncateByDisplayWidth(line, termWidth)\n\t\tlineW = displayWidth(line)\n\t\ttitleW := displayWidth(titlePart)\n\t\tif lineW <= titleW {\n\t\t\ttitlePart = line\n\t\t\tapiPart = \"\"\n\t\t} else {\n\t\t\tapiPart = line[len(titlePart):]\n\t\t}\n\t}\n\tpad := 0\n\tif lineW < termWidth {\n\t\tpad = (termWidth - lineW) / 2\n\t}\n\treturn strings.Repeat(\" \", pad) + mtrTUITitleColor(titlePart) + apiPart\n}\n\nfunc resolveMTRTUITitleParts(header MTRTUIHeader) (string, string) {\n\tver := header.Version\n\tif ver == \"\" {\n\t\tver = \"dev\"\n\t}\n\ttitlePart := fmt.Sprintf(\"NextTrace [%s]\", ver)\n\tif header.APIInfo == \"\" {\n\t\treturn titlePart, \"\"\n\t}\n\treturn titlePart, \"  \" + header.APIInfo\n}\n\nfunc buildMTRTUIRouteLine(header MTRTUIHeader, termWidth int, now time.Time) string {\n\trouteLine := buildMTRTUIRouteText(header)\n\ttimeStr := now.Format(\"2006-01-02T15:04:05-0700\")\n\ttimeW := displayWidth(timeStr)\n\tgap := termWidth - displayWidth(routeLine) - timeW\n\tif gap < 2 {\n\t\tmaxRoute := termWidth - timeW - 2\n\t\tif maxRoute < 1 {\n\t\t\tmaxRoute = 1\n\t\t}\n\t\trouteLine = truncateByDisplayWidth(routeLine, maxRoute)\n\t\tgap = 2\n\t}\n\treturn mtrTUIRouteColor(routeLine) + strings.Repeat(\" \", gap) + mtrTUITimeColor(timeStr)\n}\n\nfunc buildMTRTUIRouteText(header MTRTUIHeader) string {\n\tsrcPart := resolveMTRTUISourceLabel(header)\n\tdstPart := resolveMTRTUIDestinationLabel(header)\n\tif srcPart == \"\" {\n\t\treturn dstPart\n\t}\n\treturn fmt.Sprintf(\"%s -> %s\", srcPart, dstPart)\n}\n\nfunc resolveMTRTUISourceLabel(header MTRTUIHeader) string {\n\tswitch {\n\tcase header.SrcHost != \"\" && header.SrcIP != \"\" && header.SrcHost != header.SrcIP:\n\t\treturn fmt.Sprintf(\"%s (%s)\", header.SrcHost, header.SrcIP)\n\tcase header.SrcIP != \"\":\n\t\treturn header.SrcIP\n\tdefault:\n\t\treturn header.SrcHost\n\t}\n}\n\nfunc resolveMTRTUIDestinationLabel(header MTRTUIHeader) string {\n\tswitch {\n\tcase header.Domain != \"\" && header.TargetIP != \"\" && header.Domain != header.TargetIP:\n\t\treturn fmt.Sprintf(\"%s (%s)\", header.Domain, header.TargetIP)\n\tcase header.TargetIP != \"\":\n\t\treturn header.TargetIP\n\tcase header.Target != \"\":\n\t\treturn header.Target\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc buildMTRTUIControlsLine(header MTRTUIHeader, termWidth int) string {\n\tconst keysPrefix = \"Keys:  \"\n\tkeyLine := strings.Join(buildMTRTUIKeyItems(header), \"  \")\n\tstatusText := mtrTUIStatusText(header.Status)\n\tstatusTag := mtrTUIStatusColor(\"[\" + statusText + \"]\")\n\tpad := termWidth - displayWidth(keysPrefix) - displayWidth(keyLine) - len(\"[\"+statusText+\"]\")\n\tif pad < 2 {\n\t\tpad = 2\n\t}\n\treturn keysPrefix + keyLine + strings.Repeat(\" \", pad) + statusTag\n}\n\nfunc buildMTRTUIKeyItems(header MTRTUIHeader) []string {\n\treturn []string{\n\t\tmtrTUIKeyHiColor(\"Q\") + \"uit\",\n\t\tmtrTUIKeyHiColor(\"P\") + \"ause\",\n\t\tmtrTUIKeyHiColor(\"Space\") + \"-resume\",\n\t\tmtrTUIKeyHiColor(\"R\") + \"eset\",\n\t\tmtrTUIKeyHiColor(\"Y\") + \"-display(\" + mtrTUIDisplayModeLabel(header.DisplayMode) + \")\",\n\t\tmtrTUIKeyHiColor(\"N\") + \"-host(\" + mtrTUINameModeLabel(header.NameMode, header.ShowIPs) + \")\",\n\t\tmtrTUIKeyHiColor(\"E\") + \"-mpls(\" + mtrTUIMPLSLabel(header.DisableMPLS) + \")\",\n\t}\n}\n\nfunc mtrTUIStatusText(status MTRTUIStatus) string {\n\tif status == MTRTUIPaused {\n\t\treturn \"Paused\"\n\t}\n\treturn \"Running\"\n}\n\nfunc mtrTUIDisplayModeLabel(mode int) string {\n\tmodeNames := [5]string{\"IP/PTR\", \"ASN\", \"City\", \"Owner\", \"Full\"}\n\tif mode >= 0 && mode < len(modeNames) {\n\t\treturn modeNames[mode]\n\t}\n\treturn modeNames[0]\n}\n\nfunc mtrTUINameModeLabel(nameMode int, showIPs bool) string {\n\tif nameMode == 1 {\n\t\treturn \"ip\"\n\t}\n\tif showIPs {\n\t\treturn \"ptr+ip\"\n\t}\n\treturn \"ptr\"\n}\n\nfunc mtrTUIMPLSLabel(disabled bool) string {\n\tif disabled {\n\t\treturn \"show\"\n\t}\n\treturn \"hide\"\n}\n\nfunc renderMTRTUIRows(b *strings.Builder, header MTRTUIHeader, stats []trace.MTRHopStat, lo mtrTUILayout) {\n\tallParts := buildTUIHostPartSet(stats, header)\n\tasnW := computeTUIASNWidthFromParts(allParts)\n\tprevTTL := 0\n\tfor i, s := range stats {\n\t\thopPrefix := formatTUIHopPrefix(s.TTL, prevTTL, lo.prefixW)\n\t\tprevTTL = s.TTL\n\t\trenderDataRow(b, lo, hopPrefix, formatTUIHost(allParts[i], asnW), s)\n\t\trenderMTRTUIMPLSRows(b, lo, s.MPLS, header.DisableMPLS)\n\t}\n}\n\nfunc buildTUIHostPartSet(stats []trace.MTRHopStat, header MTRTUIHeader) []mtrHostParts {\n\tlang := header.Lang\n\tif lang == \"\" {\n\t\tlang = \"en\"\n\t}\n\tallParts := make([]mtrHostParts, len(stats))\n\tfor i, s := range stats {\n\t\tallParts[i] = buildTUIHostParts(s, header.DisplayMode, header.NameMode, lang, header.ShowIPs)\n\t}\n\treturn allParts\n}\n\nfunc renderMTRTUIMPLSRows(b *strings.Builder, lo mtrTUILayout, labels []string, disabled bool) {\n\tif disabled || len(labels) == 0 {\n\t\treturn\n\t}\n\tfor _, label := range labels {\n\t\tvar row strings.Builder\n\t\trow.WriteString(strings.Repeat(\" \", lo.prefixW+tuiPrefixGap))\n\t\trow.WriteString(mtrTUIMPLSColor(fitLeft(\"  \"+label, lo.hostW)))\n\t\ttuiLine(b, \"%s\", row.String())\n\t}\n}\n\nfunc computeTUIASNWidth(stats []trace.MTRHopStat, mode int, nameMode int, lang string, showIPs bool) int {\n\tallParts := make([]mtrHostParts, len(stats))\n\tfor i, s := range stats {\n\t\tallParts[i] = buildTUIHostParts(s, mode, nameMode, lang, showIPs)\n\t}\n\treturn computeTUIASNWidthFromParts(allParts)\n}\n\nfunc computeTUIASNWidthFromParts(allParts []mtrHostParts) int {\n\tmaxW := 0\n\tfor _, parts := range allParts {\n\t\tif parts.waiting || parts.asn == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif w := displayWidth(parts.asn); w > maxW {\n\t\t\tmaxW = w\n\t\t}\n\t}\n\tif maxW == 0 {\n\t\treturn 0\n\t}\n\tminW := displayWidth(\"AS???\")\n\tif maxW < minW {\n\t\treturn minW\n\t}\n\treturn maxW\n}\n\n// renderDualHeader 渲染 mtr 风格双层分组表头。\n//\n//\t第 1 行：左侧 \"Host\"，右侧分组 \"Packets\" 和 \"Pings\"\n//\t第 2 行：具体列名 Loss% Snt | Last Avg Best Wrst StDev\nfunc renderDualHeader(b *strings.Builder, lo mtrTUILayout) {\n\t// -- 第 1 行 --\n\tprefix := strings.Repeat(\" \", lo.prefixW+tuiPrefixGap)\n\thostLabel := fitLeft(\"Host\", lo.hostW)\n\n\t// \"Packets\" 覆盖 Loss+Snt，\"Pings\" 覆盖 5 个 RTT 列\n\tpacketsW := lo.lossW + tuiMetricGap + lo.sntW\n\tpingsW := lo.lastW + tuiMetricGap + lo.avgW + tuiMetricGap + lo.bestW + tuiMetricGap + lo.wrstW + tuiMetricGap + lo.stdevW\n\tgap := strings.Repeat(\" \", tuiHostGap)\n\n\tpacketsLabel := centerIn(\"Packets\", packetsW)\n\tpingsLabel := centerIn(\"Pings\", pingsW)\n\n\ttuiLine(b, \"%s%s%s%s %s\",\n\t\tprefix,\n\t\tmtrTUIHeaderColor(hostLabel),\n\t\tgap,\n\t\tmtrTUIHeaderColor(packetsLabel),\n\t\tmtrTUIHeaderColor(pingsLabel))\n\n\t// -- 第 2 行 --\n\trow := strings.Repeat(\" \", lo.prefixW+tuiPrefixGap)\n\trow += padRight(\"\", lo.hostW)\n\trow += strings.Repeat(\" \", tuiHostGap)\n\trow += mtrTUIHeaderColor(fitRight(\"Loss%\", lo.lossW))\n\trow += strings.Repeat(\" \", tuiMetricGap)\n\trow += mtrTUIHeaderColor(fitRight(\"Snt\", lo.sntW))\n\trow += strings.Repeat(\" \", tuiMetricGap)\n\trow += mtrTUIHeaderColor(fitRight(\"Last\", lo.lastW))\n\trow += strings.Repeat(\" \", tuiMetricGap)\n\trow += mtrTUIHeaderColor(fitRight(\"Avg\", lo.avgW))\n\trow += strings.Repeat(\" \", tuiMetricGap)\n\trow += mtrTUIHeaderColor(fitRight(\"Best\", lo.bestW))\n\trow += strings.Repeat(\" \", tuiMetricGap)\n\trow += mtrTUIHeaderColor(fitRight(\"Wrst\", lo.wrstW))\n\trow += strings.Repeat(\" \", tuiMetricGap)\n\trow += mtrTUIHeaderColor(fitRight(\"StDev\", lo.stdevW))\n\ttuiLine(b, \"%s\", row)\n}\n\n// centerIn 将 s 在 width 宽度内居中，两侧空格填充。\nfunc centerIn(s string, width int) string {\n\tw := runewidth.StringWidth(s)\n\tif w >= width {\n\t\treturn runewidth.Truncate(s, width, \"\")\n\t}\n\tleft := (width - w) / 2\n\tright := width - w - left\n\treturn strings.Repeat(\" \", left) + s + strings.Repeat(\" \", right)\n}\n\n// renderDataRow 渲染一行 hop 数据。\n//\n// 左侧为 prefix+hostText（含 tab），填充到 metricsStart 后拼接指标列，\n// 保证右侧指标列始终键齐。\nfunc renderDataRow(b *strings.Builder, lo mtrTUILayout, hopPrefix, host string, s trace.MTRHopStat) {\n\tleft := hopPrefix + host\n\tleftW := displayWidthWithTabs(left, tuiTabStop)\n\n\t// 截断：确保 left 不超过 metricsStart - 1（至少保留 1 列间距）\n\tmaxLeft := lo.metricsStart - 1\n\tif maxLeft < 1 {\n\t\tmaxLeft = 1\n\t}\n\tif leftW > maxLeft {\n\t\tleft = truncateWithTabs(left, maxLeft, tuiTabStop)\n\t\tleftW = displayWidthWithTabs(left, tuiTabStop)\n\t}\n\n\tvar row strings.Builder\n\twaiting := isWaitingHopStat(s)\n\tleftColored := mtrTUIHostColor(left)\n\tif strings.HasPrefix(left, hopPrefix) {\n\t\thostPart := left[len(hopPrefix):]\n\t\thostSty := mtrTUIHostColor\n\t\tif waiting {\n\t\t\thostSty = mtrTUIWaitColor\n\t\t}\n\t\tleftColored = mtrTUIHopColor(hopPrefix) + hostSty(hostPart)\n\t}\n\trow.WriteString(leftColored)\n\t// 填充空格到 metricsStart\n\tif gap := lo.metricsStart - leftW; gap > 0 {\n\t\trow.WriteString(strings.Repeat(\" \", gap))\n\t}\n\n\t// 指标列，右对齐\n\tm := formatMTRMetricStrings(s)\n\tlossCell := fitRight(m.loss, lo.lossW)\n\tsntCell := fitRight(m.snt, lo.sntW)\n\tlossCell, sntCell = mtrColorPacketsByLoss(lossCell, sntCell, s.Loss, waiting)\n\n\trow.WriteString(lossCell)\n\trow.WriteString(strings.Repeat(\" \", tuiMetricGap))\n\trow.WriteString(sntCell)\n\trow.WriteString(strings.Repeat(\" \", tuiMetricGap))\n\trow.WriteString(fitRight(m.last, lo.lastW))\n\trow.WriteString(strings.Repeat(\" \", tuiMetricGap))\n\trow.WriteString(fitRight(m.avg, lo.avgW))\n\trow.WriteString(strings.Repeat(\" \", tuiMetricGap))\n\trow.WriteString(fitRight(m.best, lo.bestW))\n\trow.WriteString(strings.Repeat(\" \", tuiMetricGap))\n\trow.WriteString(fitRight(m.wrst, lo.wrstW))\n\trow.WriteString(strings.Repeat(\" \", tuiMetricGap))\n\trow.WriteString(fitRight(m.stdev, lo.stdevW))\n\n\ttuiLine(b, \"%s\", row.String())\n}\n\n// MTRTUIRenderString 将 MTR TUI 帧渲染为字符串（方便测试）。\nfunc MTRTUIRenderString(header MTRTUIHeader, stats []trace.MTRHopStat) string {\n\tvar sb strings.Builder\n\tMTRTUIRender(&sb, header, stats)\n\treturn sb.String()\n}\n\n// mtrTUIRenderStringWithWidth 宽度可控的渲染入口（测试用）。\nfunc mtrTUIRenderStringWithWidth(header MTRTUIHeader, stats []trace.MTRHopStat, width int) string {\n\tvar sb strings.Builder\n\tmtrTUIRenderWithWidth(&sb, header, stats, width)\n\treturn sb.String()\n}\n\n// formatTUIHopPrefix 返回紧凑版跳数前缀，宽度由 prefixW 控制：\n//\n//\tprefixW=4: \" 1. \"  新 TTL / \"    \" 续行\n//\tprefixW=5: \"  1. \" 新 TTL / \"     \" 续行\nfunc formatTUIHopPrefix(ttl, prevTTL, prefixW int) string {\n\tif ttl == prevTTL {\n\t\treturn strings.Repeat(\" \", prefixW)\n\t}\n\tdigitW := prefixW - 2 // \". \" 后缀占 2\n\tif digitW < 2 {\n\t\tdigitW = 2\n\t}\n\treturn fmt.Sprintf(\"%*d. \", digitW, ttl)\n}\n\n// truncateStr 截断字符串到 maxLen 字节，超出时添加省略号。\n// 对纯 ASCII 场景仍可使用；CJK 场景优先使用 truncateByDisplayWidth。\nfunc truncateStr(s string, maxLen int) string {\n\tif len(s) <= maxLen {\n\t\treturn s\n\t}\n\tif maxLen <= 1 {\n\t\treturn \".\"\n\t}\n\treturn s[:maxLen-1] + \".\"\n}\n\n// MTRTUIPrinter 返回一个可直接用作 MTROnSnapshot 的回调函数，\n// 将帧渲染到 os.Stdout。\nfunc MTRTUIPrinter(target, domain, targetIP, version string, startTime time.Time,\n\tsrcHost, srcIP, lang, apiInfo string, showIPs bool,\n\tisPaused func() bool, displayMode func() int, nameMode func() int, isMPLSDisabled func() bool) func(iteration int, stats []trace.MTRHopStat) {\n\treturn func(iteration int, stats []trace.MTRHopStat) {\n\t\tstatus := MTRTUIRunning\n\t\tif isPaused != nil && isPaused() {\n\t\t\tstatus = MTRTUIPaused\n\t\t}\n\t\tmode := 0\n\t\tif displayMode != nil {\n\t\t\tmode = displayMode()\n\t\t}\n\t\tnm := 0\n\t\tif nameMode != nil {\n\t\t\tnm = nameMode()\n\t\t}\n\t\tnoMPLS := false\n\t\tif isMPLSDisabled != nil {\n\t\t\tnoMPLS = isMPLSDisabled()\n\t\t}\n\t\tMTRTUIRender(os.Stdout, MTRTUIHeader{\n\t\t\tTarget:      target,\n\t\t\tStartTime:   startTime,\n\t\t\tStatus:      status,\n\t\t\tIteration:   iteration,\n\t\t\tDomain:      domain,\n\t\t\tTargetIP:    targetIP,\n\t\t\tVersion:     version,\n\t\t\tSrcHost:     srcHost,\n\t\t\tSrcIP:       srcIP,\n\t\t\tLang:        lang,\n\t\t\tDisplayMode: mode,\n\t\t\tNameMode:    nm,\n\t\t\tShowIPs:     showIPs,\n\t\t\tAPIInfo:     apiInfo,\n\t\t\tDisableMPLS: noMPLS,\n\t\t}, stats)\n\t}\n}\n"
  },
  {
    "path": "printer/mtr_tui_color.go",
    "content": "package printer\n\nimport (\n\t\"strings\"\n\n\t\"github.com/fatih/color\"\n)\n\nvar (\n\tmtrTUITitleColor  = color.New(color.FgHiWhite).SprintFunc()\n\tmtrTUIHeaderColor = color.New(color.FgHiWhite).SprintFunc()\n\tmtrTUIRouteColor  = func(s string) string { return s }\n\tmtrTUITimeColor   = func(s string) string { return s }\n\tmtrTUIKeyColor    = func(s string) string { return s }\n\tmtrTUIKeyHiColor  = color.New(color.FgHiWhite).SprintFunc()\n\tmtrTUIStatusColor = color.New(color.FgHiYellow, color.Bold).SprintFunc()\n\n\tmtrTUIHopColor  = color.New(color.FgHiCyan, color.Bold).SprintFunc()\n\tmtrTUIHostColor = color.New(color.FgHiWhite).SprintFunc()\n\tmtrTUIMPLSColor = color.New(color.FgHiBlack).SprintFunc()\n\tmtrTUIWaitColor = color.New(color.FgHiBlack).SprintFunc()\n)\n\nfunc mtrColorLossBucket(loss float64, waiting bool) color.Attribute {\n\tif waiting {\n\t\treturn color.FgHiBlack\n\t}\n\tswitch {\n\tcase loss <= 0:\n\t\treturn color.FgHiGreen\n\tcase loss <= 5:\n\t\treturn color.FgHiCyan\n\tcase loss <= 20:\n\t\treturn color.FgHiYellow\n\tcase loss <= 50:\n\t\treturn color.FgYellow\n\tdefault:\n\t\treturn color.FgHiRed\n\t}\n}\n\nfunc mtrColorPacketsByLoss(lossCell, sntCell string, loss float64, waiting bool) (string, string) {\n\tattr := mtrColorLossBucket(loss, waiting)\n\tsty := color.New(attr, color.Bold).SprintFunc()\n\tif strings.TrimSpace(lossCell) != \"\" {\n\t\tlossCell = sty(lossCell)\n\t}\n\tif strings.TrimSpace(sntCell) != \"\" {\n\t\tsntCell = sty(sntCell)\n\t}\n\treturn lossCell, sntCell\n}\n"
  },
  {
    "path": "printer/printer.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\n// var dataOrigin string\n\n// func TraceroutePrinter(res *trace.Result) {\n// \tfor i, hop := range res.Hops {\n// \t\tfmt.Print(i + 1)\n// \t\tfor _, h := range hop {\n// \t\t\tHopPrinter(h)\n// \t\t}\n// \t}\n// }\n\n//此文件目前仅供classic_printer使用\n\nconst (\n\tRED_PREFIX    = \"\\033[1;31m\"\n\tGREEN_PREFIX  = \"\\033[1;32m\"\n\tYELLOW_PREFIX = \"\\033[1;33m\"\n\tBLUE_PREFIX   = \"\\033[1;34m\"\n\tCYAN_PREFIX   = \"\\033[1;36m\"\n\tRESET_PREFIX  = \"\\033[0m\"\n)\n\nfunc HopPrinter(h trace.Hop, info HopInfo) {\n\tif h.Address == nil {\n\t\tfmt.Println(\"\\t*\")\n\t} else {\n\t\tapplyLangSetting(&h) // 应用语言设置\n\t\ttxt := \"\\t\"\n\n\t\tif h.Hostname == \"\" {\n\t\t\ttxt += fmt.Sprint(h.Address, \" \", fmt.Sprintf(\"%.2f\", h.RTT.Seconds()*1000), \"ms\")\n\t\t} else {\n\t\t\ttxt += fmt.Sprint(h.Hostname, \" (\", h.Address, \") \", fmt.Sprintf(\"%.2f\", h.RTT.Seconds()*1000), \"ms\")\n\t\t}\n\n\t\tif h.Geo != nil {\n\t\t\ttxt += \" \" + FormatIPGeoData(h.Address.String(), h.Geo)\n\t\t}\n\t\tfor _, v := range h.MPLS {\n\t\t\ttxt += \" \" + v\n\t\t}\n\t\tswitch info {\n\t\tcase IXP:\n\t\t\tfmt.Print(CYAN_PREFIX)\n\t\tcase PoP:\n\t\t\tfmt.Print(CYAN_PREFIX)\n\t\tcase Peer:\n\t\t\tfmt.Print(YELLOW_PREFIX)\n\t\tcase Aboard:\n\t\t\tfmt.Print(GREEN_PREFIX)\n\t\t}\n\n\t\tfmt.Println(txt)\n\n\t\tif info != General {\n\t\t\tfmt.Print(RESET_PREFIX)\n\t\t}\n\t}\n}\n\nfunc FormatIPGeoData(ip string, data *ipgeo.IPGeoData) string {\n\tvar res = make([]string, 0, 10)\n\tif data.Source == \"timeout\" {\n\t\tif data.Country != \"\" {\n\t\t\treturn data.Country\n\t\t}\n\t\tif data.CountryEn != \"\" {\n\t\t\treturn data.CountryEn\n\t\t}\n\t}\n\n\tif data.Asnumber == \"\" {\n\t\tres = append(res, \"*\")\n\t} else {\n\t\tres = append(res, \"AS\"+data.Asnumber)\n\t}\n\tif data.Whois != \"\" &&\n\t\tdata.Country == \"\" &&\n\t\tdata.CountryEn == \"\" &&\n\t\tdata.Prov == \"\" &&\n\t\tdata.ProvEn == \"\" &&\n\t\tdata.City == \"\" &&\n\t\tdata.CityEn == \"\" &&\n\t\tdata.Owner == \"\" &&\n\t\tdata.Isp == \"\" {\n\t\tres = append(res, data.Whois)\n\t\treturn strings.Join(res, \", \")\n\t}\n\n\t// TODO: 判断阿里云和腾讯云内网，数据不足，有待进一步完善\n\t// TODO: 移动IDC判断到Hop.fetchIPData函数，减少API调用\n\t//if strings.HasPrefix(ip, \"9.\") {\n\t//\tres = append(res, \"LAN Address\")\n\t//} else if strings.HasPrefix(ip, \"11.\") {\n\t//\tres = append(res, \"LAN Address\")\n\t//} else if data.Country == \"\" {\n\t//\tres = append(res, \"LAN Address\")\n\tif false {\n\t} else {\n\t\t// 有些IP的归属信息为空，这个时候将ISP的信息填入\n\t\tif data.Owner == \"\" {\n\t\t\tdata.Owner = data.Isp\n\t\t}\n\t\tif data.Country != \"\" {\n\t\t\tres = append(res, data.Country)\n\t\t}\n\n\t\tif data.Prov != \"\" {\n\t\t\tres = append(res, data.Prov)\n\t\t}\n\t\tif data.City != \"\" {\n\t\t\tres = append(res, data.City)\n\t\t}\n\n\t\tif data.Owner != \"\" {\n\t\t\tres = append(res, data.Owner)\n\t\t}\n\t}\n\n\treturn strings.Join(res, \", \")\n}\n"
  },
  {
    "path": "printer/printer_test.go",
    "content": "package printer\n\n// func TestPrintTraceRouteNav(t *testing.T) {\n// \tPrintTraceRouteNav(util.DomainLookUp(\"1.1.1.1\", false), \"1.1.1.1\", \"dataOrigin\")\n// }\n\n// var testGeo = &ipgeo.IPGeoData{\n// \tAsnumber: \"TestAsnumber\",\n// \tCountry:  \"TestCountry\",\n// \tProv:     \"TestProv\",\n// \tCity:     \"TestCity\",\n// \tDistrict: \"TestDistrict\",\n// \tOwner:    \"TestOwner\",\n// \tIsp:      \"TestIsp\",\n// }\n\n// var testResult = &trace.Result{\n// \tHops: [][]trace.Hop{\n// \t\t{\n// \t\t\t{\n// \t\t\t\tSuccess:  true,\n// \t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.168.3.1\")},\n// \t\t\t\tHostname: \"test\",\n// \t\t\t\tTTL:      0,\n// \t\t\t\tRTT:      10 * time.Millisecond,\n// \t\t\t\tError:    nil,\n// \t\t\t\tGeo:      testGeo,\n// \t\t\t},\n// \t\t\t{\n// \t\t\t\tSuccess:  true,\n// \t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.168.3.1\")},\n// \t\t\t\tHostname: \"test\",\n// \t\t\t\tTTL:      0,\n// \t\t\t\tRTT:      10 * time.Millisecond,\n// \t\t\t\tError:    nil,\n// \t\t\t\tGeo:      testGeo,\n// \t\t\t},\n// \t\t},\n// \t\t{\n// \t\t\t{\n// \t\t\t\tSuccess:  false,\n// \t\t\t\tAddress:  nil,\n// \t\t\t\tHostname: \"\",\n// \t\t\t\tTTL:      0,\n// \t\t\t\tRTT:      0,\n// \t\t\t\tError:    errors.New(\"test error\"),\n// \t\t\t\tGeo:      nil,\n// \t\t\t},\n// \t\t\t{\n// \t\t\t\tSuccess:  true,\n// \t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.168.3.1\")},\n// \t\t\t\tHostname: \"test\",\n// \t\t\t\tTTL:      0,\n// \t\t\t\tRTT:      10 * time.Millisecond,\n// \t\t\t\tError:    nil,\n// \t\t\t\tGeo:      nil,\n// \t\t\t},\n// \t\t},\n// \t\t{\n// \t\t\t{\n// \t\t\t\tSuccess:  true,\n// \t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.168.3.1\")},\n// \t\t\t\tHostname: \"test\",\n// \t\t\t\tTTL:      0,\n// \t\t\t\tRTT:      0,\n// \t\t\t\tError:    nil,\n// \t\t\t\tGeo:      &ipgeo.IPGeoData{},\n// \t\t\t},\n// \t\t\t{\n// \t\t\t\tSuccess:  true,\n// \t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.168.3.1\")},\n// \t\t\t\tHostname: \"\",\n// \t\t\t\tTTL:      0,\n// \t\t\t\tRTT:      10 * time.Millisecond,\n// \t\t\t\tError:    nil,\n// \t\t\t\tGeo:      testGeo,\n// \t\t\t},\n// \t\t},\n// \t},\n// }\n\n// // func TestTraceroutePrinter(t *testing.T) {\n// // \tTraceroutePrinter(testResult)\n// // }\n\n// func TestTracerouteTablePrinter(t *testing.T) {\n// \tTracerouteTablePrinter(testResult)\n// }\n\n// func TestRealtimePrinter(t *testing.T) {\n// \tRealtimePrinter(testResult, 0)\n// \t// RealtimePrinter(testResult, 1)\n// \t// RealtimePrinter(testResult, 2)\n// }\n"
  },
  {
    "path": "printer/realtime_common.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/internal/hoprender\"\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc printRealtimeTTL(ttl int) {\n\tfmt.Printf(\"%s  \", color.New(color.FgHiYellow, color.Bold).Sprintf(\"%-2d\", ttl+1))\n}\n\nfunc printRealtimeEmptyHop() {\n\tfmt.Fprintf(color.Output, \"%s\\n\", color.New(color.FgWhite, color.Bold).Sprintf(\"*\"))\n}\n\nfunc displayRealtimeIP(ip string) string {\n\tif util.EnableHidDstIP && ip == util.DstIP {\n\t\treturn util.HideIPPart(ip)\n\t}\n\treturn ip\n}\n\nfunc printRealtimeIPColumn(ip string) bool {\n\tisIPv6 := net.ParseIP(ip).To4() == nil\n\twidth := \"%-15s\"\n\tif isIPv6 {\n\t\twidth = \"%-25s\"\n\t}\n\tfmt.Fprintf(color.Output, \"%s\", color.New(color.FgWhite, color.Bold).Sprintf(width, displayRealtimeIP(ip)))\n\treturn isIPv6\n}\n\nfunc ensureHopGeo(hop *trace.Hop) {\n\tif hop.Geo == nil {\n\t\thop.Geo = &ipgeo.IPGeoData{}\n\t}\n}\n\nfunc formatWhoisPrefix(whois string, suppressReserved bool) string {\n\twhoisFormat := strings.Split(whois, \"-\")\n\tif len(whoisFormat) > 1 {\n\t\twhoisFormat[0] = strings.Join(whoisFormat[:2], \"-\")\n\t}\n\tprefix := whoisFormat[0]\n\tif prefix == \"\" {\n\t\treturn \"\"\n\t}\n\tif suppressReserved && (strings.HasPrefix(prefix, \"RFC\") || strings.HasPrefix(prefix, \"DOD\")) {\n\t\treturn \"\"\n\t}\n\treturn \"[\" + prefix + \"]\"\n}\n\nfunc highlightRealtimeBackbone(hop *trace.Hop, whoisPrefix string) bool {\n\tswitch {\n\tcase hop.Geo.Asnumber == \"58807\":\n\t\treturn true\n\tcase hop.Geo.Asnumber == \"10099\":\n\t\treturn true\n\tcase hop.Geo.Asnumber == \"4809\":\n\t\treturn true\n\tcase hop.Geo.Asnumber == \"9929\":\n\t\treturn true\n\tcase hop.Geo.Asnumber == \"23764\":\n\t\treturn true\n\tcase whoisPrefix == \"[CTG-CN]\":\n\t\treturn true\n\tcase whoisPrefix == \"[CNC-BACKBONE]\":\n\t\treturn true\n\tcase whoisPrefix == \"[CUG-BACKBONE]\":\n\t\treturn true\n\tcase whoisPrefix == \"[CMIN2-NET]\":\n\t\treturn true\n\tcase hop.Address != nil && strings.HasPrefix(hop.Address.String(), \"59.43.\"):\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc printASNColumn(hop *trace.Hop, highlight bool) {\n\tif hop.Geo.Asnumber == \"\" {\n\t\tfmt.Printf(\" %-8s\", \"*\")\n\t\treturn\n\t}\n\tstyle := color.New(color.FgHiGreen, color.Bold)\n\tif highlight {\n\t\tstyle = color.New(color.FgHiYellow, color.Bold)\n\t}\n\tfmt.Fprintf(color.Output, \" %s\", style.Sprintf(\"AS%-6s\", hop.Geo.Asnumber))\n}\n\nfunc printWhoisColumn(prefix string, highlight bool) {\n\tstyle := color.New(color.FgHiGreen, color.Bold)\n\tif highlight {\n\t\tstyle = color.New(color.FgHiYellow, color.Bold)\n\t}\n\tfmt.Fprintf(color.Output, \" %s\", style.Sprintf(\"%-16s\", prefix))\n}\n\nfunc displayRealtimeHostname(ip, hostname string) string {\n\tif util.EnableHidDstIP && ip == util.DstIP {\n\t\treturn \"\"\n\t}\n\treturn hostname\n}\n\nfunc printLocationLine(hop *trace.Hop, ip string, isIPv6 bool) {\n\thostname := displayRealtimeHostname(ip, hop.Hostname)\n\ttemplate := \" %s %s %s %s %s\\n    %s   \"\n\thostWidth := \"%-39s\"\n\tif isIPv6 {\n\t\thostWidth = \"%-32s\"\n\t}\n\tfmt.Fprintf(color.Output, template,\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", hop.Geo.Country),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", hop.Geo.Prov),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", hop.Geo.City),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", hop.Geo.District),\n\t\tfmt.Sprintf(\"%-6s\", hop.Geo.Owner),\n\t\tcolor.New(color.FgHiBlack, color.Bold).Sprintf(hostWidth, hostname),\n\t)\n}\n\nfunc printTimingSeries(values []string) {\n\tfor i, value := range values {\n\t\tif i == 0 {\n\t\t\tfmt.Fprintf(color.Output, \"%s\", color.New(color.FgHiCyan, color.Bold).Sprintf(\"%s\", value))\n\t\t\tcontinue\n\t\t}\n\t\tfmt.Fprintf(color.Output, \" / %s\", color.New(color.FgHiCyan, color.Bold).Sprintf(\"%s\", value))\n\t}\n}\n\nfunc printHopMPLS(labels []string) {\n\tfor _, label := range labels {\n\t\tfmt.Fprintf(color.Output, \"%s\", color.New(color.FgHiBlack, color.Bold).Sprintf(\"\\n    %s\", label))\n\t}\n}\n\nfunc renderRealtimeHopLine(res *trace.Result, ttl int, group hoprender.Group, blockDisplay bool) {\n\tif blockDisplay {\n\t\tfmt.Printf(\"%4s\", \"\")\n\t}\n\n\thop := &res.Hops[ttl][group.Index]\n\tensureHopGeo(hop)\n\tapplyLangSetting(hop)\n\n\tisIPv6 := printRealtimeIPColumn(group.IP)\n\twhoisPrefix := formatWhoisPrefix(hop.Geo.Whois, true)\n\thighlight := highlightRealtimeBackbone(hop, whoisPrefix)\n\tprintASNColumn(hop, highlight)\n\tif !isIPv6 {\n\t\tprintWhoisColumn(whoisPrefix, highlight)\n\t}\n\tprintLocationLine(hop, group.IP, isIPv6)\n\tprintTimingSeries(group.Timings)\n\tprintHopMPLS(hop.MPLS)\n\tfmt.Println()\n}\n\nfunc prepareRouterGeo(hop *trace.Hop) {\n\tensureHopGeo(hop)\n\tif hop.Geo.Country == \"\" && hop.Geo.Source != trace.PendingGeoSource {\n\t\thop.Geo.Country = \"LAN Address\"\n\t}\n\tapplyLangSetting(hop)\n}\n\nfunc renderRouterHopLine(res *trace.Result, ttl int, group hoprender.Group, blockDisplay bool) {\n\tif blockDisplay {\n\t\tfmt.Printf(\"%4s\", \"\")\n\t}\n\n\thop := &res.Hops[ttl][group.Index]\n\tprepareRouterGeo(hop)\n\n\tisIPv6 := printRealtimeIPColumn(group.IP)\n\tprintASNColumn(hop, false)\n\tif !isIPv6 {\n\t\tprintWhoisColumn(formatWhoisPrefix(hop.Geo.Whois, false), false)\n\t}\n\tprintLocationLine(hop, group.IP, isIPv6)\n\tprintTimingSeries(group.Timings)\n\tfmt.Println()\n}\n"
  },
  {
    "path": "printer/realtime_printer.go",
    "content": "package printer\n\nimport (\n\t\"github.com/nxtrace/NTrace-core/internal/hoprender\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc RealtimePrinter(res *trace.Result, ttl int) {\n\tprintRealtimeTTL(ttl)\n\tgroups := hoprender.GroupHopAttempts(res.Hops[ttl])\n\tif len(groups) == 0 {\n\t\tprintRealtimeEmptyHop()\n\t\treturn\n\t}\n\n\tblockDisplay := false\n\tfor _, group := range groups {\n\t\trenderRealtimeHopLine(res, ttl, group, blockDisplay)\n\t\tblockDisplay = true\n\t}\n}\n"
  },
  {
    "path": "printer/realtime_printer_router.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/internal/hoprender\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc RealtimePrinterWithRouter(res *trace.Result, ttl int) {\n\tprintRealtimeTTL(ttl)\n\tgroups := hoprender.GroupHopAttempts(res.Hops[ttl])\n\tif len(groups) == 0 {\n\t\tprintRealtimeEmptyHop()\n\t\treturn\n\t}\n\n\tblockDisplay := false\n\tfor _, group := range groups {\n\t\trenderRouterHopLine(res, ttl, group, blockDisplay)\n\t\tif !blockDisplay {\n\t\t\trenderRouterSummary(res, ttl)\n\t\t}\n\t\tblockDisplay = true\n\t}\n}\n\nfunc renderRouterSummary(res *trace.Result, ttl int) {\n\thop := &res.Hops[ttl][0]\n\tif hop.Geo == nil {\n\t\treturn\n\t}\n\n\tfmt.Fprintf(color.Output, \"%s   %s %s %s   %s\\n\",\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"-\"),\n\t\tcolor.New(color.FgHiYellow, color.Bold).Sprintf(\"%s\", hop.Geo.Prefix),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"路由表\"),\n\t\tcolor.New(color.FgHiCyan, color.Bold).Sprintf(\"Beta\"),\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"-\"),\n\t)\n\tGetRouter(&hop.Geo.Router, \"AS\"+hop.Geo.Asnumber)\n}\n\nfunc GetRouter(r *map[string][]string, node string) {\n\trouteMap := *r\n\tfor _, v := range routeMap[node] {\n\t\tif len(routeMap[v]) != 0 {\n\t\t\tfmt.Fprintf(color.Output, \"    %s %s %s\\n\",\n\t\t\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", routeMap[v][0]),\n\t\t\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", v),\n\t\t\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", node),\n\t\t\t)\n\t\t} else {\n\t\t\tfmt.Fprintf(color.Output, \"    %s %s\\n\",\n\t\t\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", v),\n\t\t\t\tcolor.New(color.FgHiBlue, color.Bold).Sprintf(\"%s\", node),\n\t\t\t)\n\t\t}\n\n\t}\n}\n"
  },
  {
    "path": "printer/tableprinter.go",
    "content": "package printer\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/fatih/color\"\n\t\"github.com/rodaine/table\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\ntype rowData struct {\n\tHop      string\n\tIP       string\n\tLatency  string\n\tAsnumber string\n\tCountry  string\n\tProv     string\n\tCity     string\n\tDistrict string\n\tOwner    string\n}\n\nfunc writeTracerouteTable(w io.Writer, res *trace.Result, clearScreen bool) {\n\t// 初始化表格\n\ttbl := New()\n\tfor _, hop := range res.Hops {\n\t\tfor k, h := range hop {\n\t\t\tdata := tableDataGenerator(h)\n\t\t\tif k > 0 {\n\t\t\t\tdata.Hop = \"\"\n\t\t\t}\n\t\t\tif data.Country == \"\" && data.Prov == \"\" && data.City == \"\" {\n\t\t\t\ttbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, \"\", data.Owner)\n\t\t\t} else {\n\t\t\t\tif data.City != \"\" {\n\t\t\t\t\ttbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, data.City+\", \"+data.Prov+\", \"+data.Country, data.Owner)\n\t\t\t\t} else if data.Prov != \"\" {\n\t\t\t\t\ttbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, data.Prov+\", \"+data.Country, data.Owner)\n\t\t\t\t} else {\n\t\t\t\t\ttbl.AddRow(data.Hop, data.IP, data.Latency, data.Asnumber, data.Country, data.Owner)\n\t\t\t\t}\n\n\t\t\t}\n\t\t}\n\t}\n\tif clearScreen {\n\t\t_, _ = io.WriteString(w, \"\\033[H\\033[2J\")\n\t}\n\t// 打印表格\n\ttbl.WithWriter(w).Print()\n}\n\nfunc TracerouteTablePrinter(res *trace.Result, clearScreen bool) {\n\twriteTracerouteTable(os.Stdout, res, clearScreen)\n}\n\nfunc New() table.Table {\n\t// 初始化表格\n\theaderFmt := color.New(color.FgGreen, color.Underline).SprintfFunc()\n\tcolumnFmt := color.New(color.FgYellow).SprintfFunc()\n\n\ttbl := table.New(\"Hop\", \"IP\", \"Latency\", \"ASN\", \"Location\", \"Owner\")\n\ttbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt)\n\treturn tbl\n}\n\nfunc tableDataGenerator(h trace.Hop) *rowData {\n\tif h.Address == nil {\n\t\treturn &rowData{\n\t\t\tHop: fmt.Sprint(h.TTL),\n\t\t\tIP:  \"*\",\n\t\t}\n\t} else {\n\t\tlatency := fmt.Sprintf(\"%.2fms\", h.RTT.Seconds()*1000)\n\t\tIP := h.Address.String()\n\n\t\tif strings.HasPrefix(IP, \"9.\") {\n\t\t\treturn &rowData{\n\t\t\t\tHop:     fmt.Sprint(h.TTL),\n\t\t\t\tIP:      IP,\n\t\t\t\tLatency: latency,\n\t\t\t\tCountry: \"LAN Address\",\n\t\t\t\tProv:    \"\",\n\t\t\t\tOwner:   \"\",\n\t\t\t}\n\t\t} else if strings.HasPrefix(IP, \"11.\") {\n\t\t\treturn &rowData{\n\t\t\t\tHop:     fmt.Sprint(h.TTL),\n\t\t\t\tIP:      IP,\n\t\t\t\tLatency: latency,\n\t\t\t\tCountry: \"LAN Address\",\n\t\t\t\tProv:    \"\",\n\t\t\t\tOwner:   \"\",\n\t\t\t}\n\t\t}\n\n\t\tif h.Hostname != \"\" {\n\t\t\tIP = fmt.Sprint(h.Hostname, \" (\", IP, \") \")\n\t\t}\n\n\t\tif h.Geo == nil {\n\t\t\th.Geo = &ipgeo.IPGeoData{}\n\t\t}\n\n\t\tif h.Geo.Owner == \"\" {\n\t\t\th.Geo.Owner = h.Geo.Isp\n\t\t}\n\n\t\tr := &rowData{\n\t\t\tHop:      fmt.Sprint(h.TTL),\n\t\t\tIP:       IP,\n\t\t\tLatency:  latency,\n\t\t\tAsnumber: h.Geo.Asnumber,\n\t\t\tCountry:  h.Geo.CountryEn,\n\t\t\tProv:     h.Geo.ProvEn,\n\t\t\tCity:     h.Geo.CityEn,\n\t\t\tDistrict: h.Geo.District,\n\t\t\tOwner:    h.Geo.Owner,\n\t\t}\n\n\t\treturn r\n\t}\n}\n"
  },
  {
    "path": "printer/tableprinter_test.go",
    "content": "package printer\n\nimport (\n\t\"bytes\"\n\t\"net\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc testTracerouteTableResult() *trace.Result {\n\treturn &trace.Result{\n\t\tHops: [][]trace.Hop{\n\t\t\t{\n\t\t\t\t{\n\t\t\t\t\tTTL:      1,\n\t\t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.0.2.1\")},\n\t\t\t\t\tHostname: \"router1\",\n\t\t\t\t\tRTT:      12 * time.Millisecond,\n\t\t\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\t\t\tAsnumber:  \"13335\",\n\t\t\t\t\t\tCountryEn: \"Hong Kong\",\n\t\t\t\t\t\tOwner:     \"Cloudflare\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc TestWriteTracerouteTableNonTTYOmitsClearScreenANSI(t *testing.T) {\n\tprevNoColor := color.NoColor\n\tcolor.NoColor = true\n\tdefer func() { color.NoColor = prevNoColor }()\n\n\tvar buf bytes.Buffer\n\twriteTracerouteTable(&buf, testTracerouteTableResult(), false)\n\toutput := buf.String()\n\n\tif strings.Contains(output, \"\\033[H\\033[2J\") {\n\t\tt.Fatalf(\"output should not contain clear-screen ANSI:\\n%q\", output)\n\t}\n\tfor _, want := range []string{\"Hop\", \"router1 (192.0.2.1)\", \"Cloudflare\"} {\n\t\tif !strings.Contains(output, want) {\n\t\t\tt.Fatalf(\"output missing %q:\\n%q\", want, output)\n\t\t}\n\t}\n}\n\nfunc TestWriteTracerouteTableTTYIncludesClearScreenANSI(t *testing.T) {\n\tprevNoColor := color.NoColor\n\tcolor.NoColor = true\n\tdefer func() { color.NoColor = prevNoColor }()\n\n\tvar buf bytes.Buffer\n\twriteTracerouteTable(&buf, testTracerouteTableResult(), true)\n\toutput := buf.String()\n\n\tif !strings.HasPrefix(output, \"\\033[H\\033[2J\") {\n\t\tt.Fatalf(\"output should start with clear-screen ANSI:\\n%q\", output)\n\t}\n}\n"
  },
  {
    "path": "ptr.example.csv",
    "content": "snge,SG,,Singapore\nCXS,CN,Hunan,Changsha\nLAX,US,California,Los Angeles\nSJC,US,California,San Jose"
  },
  {
    "path": "reporter/reporter.go",
    "content": "package reporter\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\ntype Reporter interface {\n\tPrint()\n}\n\nfunc New(rs *trace.Result, ip string) Reporter {\n\texperimentTag()\n\tr := reporter{\n\t\trouteResult: rs,\n\t\ttargetIP:    ip,\n\t}\n\treturn &r\n}\n\ntype reporter struct {\n\ttargetTTL       uint16\n\ttargetIP        string\n\trouteReport     map[uint16][]routeReportNode\n\trouteReportLock sync.Mutex\n\trouteResult     *trace.Result\n\twg              sync.WaitGroup\n}\n\ntype routeReportNode struct {\n\tasn string\n\tisp string\n\tgeo []string\n\tix  bool\n}\n\nfunc experimentTag() {\n\tfmt.Println(\"Route-Path 功能实验室\")\n}\n\nfunc (r *reporter) generateRouteReportNode(ip string, ipGeoData ipgeo.IPGeoData, ttl uint16) {\n\tdefer r.wg.Done()\n\n\tnode := routeReportNode{\n\t\tix:  routeReportNodeIX(ip, ipGeoData),\n\t\tasn: routeReportNodeASN(ip, ipGeoData),\n\t\tisp: routeReportNodeISP(ipGeoData),\n\t}\n\tif ipGeoData.Asnumber == \"\" {\n\t\tnode.asn = \"*\"\n\t}\n\tgeo, ok := routeReportNodeGeo(ip, ipGeoData, r.targetIP)\n\tif !ok {\n\t\treturn\n\t}\n\tnode.geo = geo\n\tr.appendRouteReportNode(ttl, node)\n}\n\nfunc routeReportNodeIX(ip string, ipGeoData ipgeo.IPGeoData) bool {\n\tptr, err := net.LookupAddr(ip)\n\tif err == nil && len(ptr) > 0 && strings.Contains(strings.ToLower(ptr[0]), \"ix\") {\n\t\treturn true\n\t}\n\treturn routeReportContainsIX(ipGeoData.Isp) || routeReportContainsIX(ipGeoData.Owner)\n}\n\nfunc routeReportContainsIX(value string) bool {\n\tvalue = strings.ToLower(value)\n\treturn strings.Contains(value, \"exchange\") || strings.Contains(value, \"ix\")\n}\n\nfunc routeReportNodeASN(ip string, ipGeoData ipgeo.IPGeoData) string {\n\tif strings.HasPrefix(ip, \"59.43\") {\n\t\treturn \"4809\"\n\t}\n\treturn ipGeoData.Asnumber\n}\n\nfunc routeReportNodeGeo(ip string, ipGeoData ipgeo.IPGeoData, targetIP string) ([]string, bool) {\n\tif (ipGeoData.Country == \"\" || ipGeoData.Country == \"LAN Address\" || ipGeoData.Country == \"-\") && ip != targetIP {\n\t\treturn nil, false\n\t}\n\tif ipGeoData.City == \"\" {\n\t\treturn []string{ipGeoData.Country, ipGeoData.Prov}, true\n\t}\n\treturn []string{ipGeoData.Country, ipGeoData.City}, true\n}\n\nfunc routeReportNodeISP(ipGeoData ipgeo.IPGeoData) string {\n\tif ipGeoData.Isp != \"\" {\n\t\treturn ipGeoData.Isp\n\t}\n\treturn ipGeoData.Owner\n}\n\nfunc (r *reporter) appendRouteReportNode(ttl uint16, node routeReportNode) {\n\tr.routeReportLock.Lock()\n\tr.routeReport[ttl] = append(r.routeReport[ttl], node)\n\tr.routeReportLock.Unlock()\n}\n\nfunc (r *reporter) InitialBaseData() Reporter {\n\treportNodes := map[uint16][]routeReportNode{}\n\n\tr.routeReport = reportNodes\n\tr.targetTTL = uint16(len(r.routeResult.Hops))\n\n\tfor i := uint16(0); i < r.targetTTL; i++ {\n\t\tif i < uint16(len(r.routeResult.Hops)) && len(r.routeResult.Hops[i]) > 0 {\n\t\t\ttraceHop := r.routeResult.Hops[i][0]\n\t\t\tif traceHop.Success && traceHop.Geo != nil {\n\t\t\t\tcurrentIP := traceHop.Address.String()\n\t\t\t\tr.wg.Add(1)\n\t\t\t\tgo r.generateRouteReportNode(currentIP, *traceHop.Geo, i)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 等待所有的子协程运行完毕\n\tr.wg.Wait()\n\treturn r\n}\n\nfunc (r *reporter) Print() {\n\tvar beforeActiveTTL uint16 = 0\n\tr.InitialBaseData()\n\t// 尝试首个有效 TTL\n\tfor i := uint16(0); i < r.targetTTL; i++ {\n\t\tif len(r.routeReport[i]) != 0 {\n\t\t\tbeforeActiveTTL = i\n\t\t\t// 找到以后便不再循环\n\t\t\tbreak\n\t\t}\n\t}\n\n\tfor i := beforeActiveTTL; i < r.targetTTL; i++ {\n\t\t// 计算该TTL内的数据长度，如果为0，则代表没有有效数据\n\t\tif len(r.routeReport[i]) == 0 {\n\t\t\t// 跳过改跃点的数据整理\n\t\t\tcontinue\n\t\t}\n\t\tnodeReport := r.routeReport[i][0]\n\n\t\tif i == beforeActiveTTL {\n\t\t\tfmt.Printf(\"AS%s %s「%s『%s\", nodeReport.asn, nodeReport.isp, nodeReport.geo[0], nodeReport.geo[1])\n\t\t} else {\n\t\t\tnodeReportBefore := r.routeReport[beforeActiveTTL][0]\n\t\t\t// ASN 相同，同个 ISP 内部的数据传递\n\t\t\tif nodeReportBefore.asn == nodeReport.asn {\n\t\t\t\t// Same ASN but Coutry or City Changed\n\t\t\t\tif nodeReportBefore.geo[0] != nodeReport.geo[0] {\n\t\t\t\t\tfmt.Printf(\"』→ %s『%s\", nodeReport.geo[0], nodeReport.geo[1])\n\t\t\t\t} else {\n\t\t\t\t\tif nodeReportBefore.geo[1] != nodeReport.geo[1] {\n\t\t\t\t\t\tfmt.Printf(\" → %s\", nodeReport.geo[1])\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// ASN 不同，跨 ISP 的数据传递，这里可能会出现 POP、IP Transit、Peer、Exchange\n\t\t\t\tfmt.Printf(\"』」\")\n\t\t\t\tif int(i) != len(r.routeReport)+1 {\n\t\t\t\t\t// 部分 Shell 客户端可能无法很好的展示这个特殊字符\n\t\t\t\t\t// TODO: 寻找其他替代字符\n\t\t\t\t\tfmt.Printf(\"\\n ╭╯\\n ╰\")\n\t\t\t\t}\n\t\t\t\tif nodeReport.ix {\n\t\t\t\t\tfmt.Printf(\"AS%s \\033[42;37mIXP\\033[0m %s「%s『%s\", nodeReport.asn, nodeReport.isp, nodeReport.geo[0], nodeReport.geo[1])\n\t\t\t\t} else {\n\t\t\t\t\tfmt.Printf(\"AS%s %s「%s『%s\", nodeReport.asn, nodeReport.isp, nodeReport.geo[0], nodeReport.geo[1])\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// 标记为最新的一个有效跃点\n\t\tbeforeActiveTTL = i\n\t}\n\tfmt.Println(\"』」\")\n}\n"
  },
  {
    "path": "reporter/reporter_test.go",
    "content": "package reporter\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nvar testResult = &trace.Result{\n\tHops: [][]trace.Hop{\n\t\t{\n\t\t\t{\n\t\t\t\tSuccess:  true,\n\t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.168.3.1\")},\n\t\t\t\tHostname: \"test\",\n\t\t\t\tTTL:      0,\n\t\t\t\tRTT:      10 * time.Millisecond,\n\t\t\t\tError:    nil,\n\t\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\t\tAsnumber: \"4808\",\n\t\t\t\t\tCountry:  \"中国\",\n\t\t\t\t\tProv:     \"北京市\",\n\t\t\t\t\tCity:     \"北京市\",\n\t\t\t\t\tDistrict: \"北京市\",\n\t\t\t\t\tOwner:    \"\",\n\t\t\t\t\tIsp:      \"中国联通\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t{\n\t\t\t\tSuccess:  true,\n\t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"114.249.16.1\")},\n\t\t\t\tHostname: \"test\",\n\t\t\t\tTTL:      0,\n\t\t\t\tRTT:      10 * time.Millisecond,\n\t\t\t\tError:    nil,\n\t\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\t\tAsnumber: \"4808\",\n\t\t\t\t\tCountry:  \"中国\",\n\t\t\t\t\tProv:     \"北京市\",\n\t\t\t\t\tCity:     \"北京市\",\n\t\t\t\t\tDistrict: \"北京市\",\n\t\t\t\t\tOwner:    \"\",\n\t\t\t\t\tIsp:      \"中国联通\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t{\n\t\t\t\tSuccess:  true,\n\t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"219.158.5.150\")},\n\t\t\t\tHostname: \"test\",\n\t\t\t\tTTL:      0,\n\t\t\t\tRTT:      10 * time.Millisecond,\n\t\t\t\tError:    nil,\n\t\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\t\tAsnumber: \"4837\",\n\t\t\t\t\tCountry:  \"中国\",\n\t\t\t\t\tProv:     \"\",\n\t\t\t\t\tCity:     \"\",\n\t\t\t\t\tDistrict: \"\",\n\t\t\t\t\tOwner:    \"\",\n\t\t\t\t\tIsp:      \"中国联通\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t{\n\t\t\t\tSuccess:  true,\n\t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"62.115.125.160\")},\n\t\t\t\tHostname: \"test\",\n\t\t\t\tTTL:      0,\n\t\t\t\tRTT:      10 * time.Millisecond,\n\t\t\t\tError:    nil,\n\t\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\t\tAsnumber: \"1299\",\n\t\t\t\t\tCountry:  \"Sweden\",\n\t\t\t\t\tProv:     \"Stockholm County\",\n\t\t\t\t\tCity:     \"Stockholm\",\n\t\t\t\t\tDistrict: \"\",\n\t\t\t\t\tOwner:    \"\",\n\t\t\t\t\tIsp:      \"Telia Company AB\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t{\n\t\t\t\tSuccess:  true,\n\t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"213.226.68.73\")},\n\t\t\t\tHostname: \"test\",\n\t\t\t\tTTL:      0,\n\t\t\t\tRTT:      10 * time.Millisecond,\n\t\t\t\tError:    nil,\n\t\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\t\tAsnumber: \"56630\",\n\t\t\t\t\tCountry:  \"Germany\",\n\t\t\t\t\tProv:     \"Hesse, Frankfurt\",\n\t\t\t\t\tCity:     \"\",\n\t\t\t\t\tDistrict: \"\",\n\t\t\t\t\tOwner:    \"\",\n\t\t\t\t\tIsp:      \"Melbikomas UAB\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\n// captureStdout redirects os.Stdout to a pipe and returns the captured output.\nfunc captureStdout(t *testing.T, fn func()) string {\n\tt.Helper()\n\toldStdout := os.Stdout\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create pipe: %v\", err)\n\t}\n\tos.Stdout = w\n\n\tfn()\n\n\tw.Close()\n\tos.Stdout = oldStdout\n\n\tvar buf bytes.Buffer\n\tio.Copy(&buf, r)\n\tr.Close()\n\treturn buf.String()\n}\n\nfunc TestPrint(t *testing.T) {\n\toutput := captureStdout(t, func() {\n\t\tr := New(testResult, \"213.226.68.73\")\n\t\tr.Print()\n\t})\n\n\t// 验证实验标签\n\tif !strings.Contains(output, \"Route-Path 功能实验室\") {\n\t\tt.Error(\"expected output to contain experiment tag 'Route-Path 功能实验室'\")\n\t}\n\n\t// 验证包含各 ASN\n\tfor _, asn := range []string{\"AS4808\", \"AS4837\", \"AS1299\", \"AS56630\"} {\n\t\tif !strings.Contains(output, asn) {\n\t\t\tt.Errorf(\"expected output to contain %s\", asn)\n\t\t}\n\t}\n\n\t// 验证包含 ISP 名称\n\tfor _, isp := range []string{\"中国联通\", \"Telia Company AB\", \"Melbikomas UAB\"} {\n\t\tif !strings.Contains(output, isp) {\n\t\t\tt.Errorf(\"expected output to contain ISP %q\", isp)\n\t\t}\n\t}\n\n\t// 验证包含跨 ASN 分隔符\n\tif !strings.Contains(output, \"╭╯\") || !strings.Contains(output, \"╰\") {\n\t\tt.Error(\"expected output to contain ASN transition markers (╭╯/╰)\")\n\t}\n\n\t// 验证包含地理信息括号\n\tif !strings.Contains(output, \"「\") || !strings.Contains(output, \"」\") {\n\t\tt.Error(\"expected output to contain geographic brackets (「」)\")\n\t}\n\n\t// 验证包含城市分隔\n\tif !strings.Contains(output, \"『\") || !strings.Contains(output, \"』\") {\n\t\tt.Error(\"expected output to contain city brackets (『』)\")\n\t}\n}\n"
  },
  {
    "path": "server/browser_access.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nconst (\n\tmaxTraceRequestBodyBytes = 64 << 10\n\tmaxWSInitMessageBytes    = 64 << 10\n)\n\nfunc browserOriginAllowed(r *http.Request) bool {\n\tif util.AllowCrossOriginBrowserAccess() {\n\t\treturn true\n\t}\n\n\torigin := strings.TrimSpace(r.Header.Get(\"Origin\"))\n\tif origin == \"\" {\n\t\treturn true\n\t}\n\n\tu, err := url.Parse(origin)\n\tif err != nil || u.Host == \"\" {\n\t\treturn false\n\t}\n\n\treturn strings.EqualFold(u.Host, r.Host)\n}\n\nfunc browserAccessMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif !browserOriginAllowed(c.Request) {\n\t\t\tc.AbortWithStatusJSON(http.StatusForbidden, gin.H{\"error\": \"cross-origin browser access is disabled\"})\n\t\t\treturn\n\t\t}\n\n\t\tif util.AllowCrossOriginBrowserAccess() {\n\t\t\tif origin := strings.TrimSpace(c.Request.Header.Get(\"Origin\")); origin != \"\" {\n\t\t\t\th := c.Writer.Header()\n\t\t\t\th.Set(\"Access-Control-Allow-Origin\", origin)\n\t\t\t\th.Add(\"Vary\", \"Origin\")\n\t\t\t\th.Set(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\")\n\t\t\t\th.Set(\"Access-Control-Allow-Headers\", \"Content-Type\")\n\t\t\t}\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "server/browser_access_test.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc TestTraceUpgraderCheckOrigin_DefaultsToSameOriginOnly(t *testing.T) {\n\tt.Setenv(util.EnvAllowCrossOriginKey, \"0\")\n\n\tif !traceUpgrader.CheckOrigin(&http.Request{Host: \"127.0.0.1:1080\"}) {\n\t\tt.Fatal(\"requests without Origin header should be allowed\")\n\t}\n\n\tif !traceUpgrader.CheckOrigin(&http.Request{\n\t\tHost:   \"127.0.0.1:1080\",\n\t\tHeader: http.Header{\"Origin\": []string{\"http://127.0.0.1:1080\"}},\n\t}) {\n\t\tt.Fatal(\"same-origin websocket request should be allowed\")\n\t}\n\n\tif traceUpgrader.CheckOrigin(&http.Request{\n\t\tHost:   \"127.0.0.1:1080\",\n\t\tHeader: http.Header{\"Origin\": []string{\"https://evil.example\"}},\n\t}) {\n\t\tt.Fatal(\"cross-origin websocket request should be rejected by default\")\n\t}\n}\n\nfunc TestTraceUpgraderCheckOrigin_CanBeRelaxedViaEnv(t *testing.T) {\n\tt.Setenv(util.EnvAllowCrossOriginKey, \"1\")\n\n\tif !traceUpgrader.CheckOrigin(&http.Request{\n\t\tHost:   \"127.0.0.1:1080\",\n\t\tHeader: http.Header{\"Origin\": []string{\"https://evil.example\"}},\n\t}) {\n\t\tt.Fatal(\"cross-origin websocket request should be allowed when env is enabled\")\n\t}\n}\n\nfunc TestBrowserAccessMiddleware_DefaultRejectsCrossOriginHTTP(t *testing.T) {\n\tt.Setenv(util.EnvAllowCrossOriginKey, \"0\")\n\n\tgin.SetMode(gin.TestMode)\n\trouter := gin.New()\n\trouter.Use(browserAccessMiddleware())\n\trouter.GET(\"/ok\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\"ok\": true})\n\t})\n\n\treq := httptest.NewRequest(http.MethodGet, \"/ok\", nil)\n\treq.Host = \"127.0.0.1:1080\"\n\treq.Header.Set(\"Origin\", \"https://evil.example\")\n\tresp := httptest.NewRecorder()\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusForbidden {\n\t\tt.Fatalf(\"status = %d, want %d\", resp.Code, http.StatusForbidden)\n\t}\n}\n\nfunc TestBrowserAccessMiddleware_CanEnableCORSViaEnv(t *testing.T) {\n\tt.Setenv(util.EnvAllowCrossOriginKey, \"1\")\n\n\tgin.SetMode(gin.TestMode)\n\trouter := gin.New()\n\trouter.Use(browserAccessMiddleware())\n\trouter.OPTIONS(\"/*path\", func(c *gin.Context) {\n\t\tc.Status(http.StatusNoContent)\n\t})\n\n\treq := httptest.NewRequest(http.MethodOptions, \"/api/trace\", nil)\n\treq.Host = \"127.0.0.1:1080\"\n\treq.Header.Set(\"Origin\", \"https://evil.example\")\n\tresp := httptest.NewRecorder()\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"status = %d, want %d\", resp.Code, http.StatusNoContent)\n\t}\n\tif got := resp.Header().Get(\"Access-Control-Allow-Origin\"); got != \"https://evil.example\" {\n\t\tt.Fatalf(\"allow-origin = %q, want %q\", got, \"https://evil.example\")\n\t}\n}\n"
  },
  {
    "path": "server/cache_handler.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc cacheClearHandler(c *gin.Context) {\n\ttrace.ClearCaches()\n\tc.JSON(http.StatusOK, gin.H{\"ok\": true})\n}\n"
  },
  {
    "path": "server/handlers.go",
    "content": "package server\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nvar (\n\tsupportedProtocols = []string{\"icmp\", \"udp\", \"tcp\"}\n\tdataProviders      = []string{\n\t\t\"LeoMoeAPI\",\n\t\t\"IP.SB\",\n\t\t\"IPInsight\",\n\t\t\"IPInfo\",\n\t\t\"IPInfoLocal\",\n\t\t\"ip-api.com\",\n\t\t\"chunzhen\",\n\t\t\"DN42\",\n\t\t\"disable-geoip\",\n\t\t\"ipdb.one\",\n\t}\n\tdefaults = map[string]any{\n\t\t\"protocol\":          \"icmp\",\n\t\t\"queries\":           3,\n\t\t\"max_hops\":          30,\n\t\t\"timeout_ms\":        1000,\n\t\t\"packet_size\":       nil,\n\t\t\"tos\":               0,\n\t\t\"parallel_requests\": 18,\n\t\t\"begin_hop\":         1,\n\t\t\"language\":          \"cn\",\n\t\t\"data_provider\":     \"LeoMoeAPI\",\n\t\t\"disable_maptrace\":  false,\n\t}\n)\n\nfunc optionsHandler(c *gin.Context) {\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"protocols\":      supportedProtocols,\n\t\t\"dataProviders\":  dataProviders,\n\t\t\"defaultOptions\": defaults,\n\t})\n}\n"
  },
  {
    "path": "server/mtr.go",
    "content": "package server\n\nimport (\n\t\"math\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\n// Deprecated: websocket MTR now streams probe-level \"mtr_raw\" events via trace.RunMTRRaw.\n// This legacy snapshot aggregator is kept temporarily for compatibility/reference.\n\ntype mtrAggregator struct {\n\tmu        sync.Mutex\n\tstats     map[int]map[string]*hopAccum\n\tnextOrder int\n}\n\ntype hopAccum struct {\n\tTTL      int\n\tKey      string\n\tHost     string\n\tIP       string\n\tSent     int\n\tReceived int\n\tSum      float64\n\tLast     float64\n\tBest     float64\n\tWorst    float64\n\tGeo      *ipgeo.IPGeoData\n\tErrors   map[string]int\n\torder    int\n\tmplsSet  map[string]struct{}\n}\n\ntype groupMetrics struct {\n\thost     string\n\tip       string\n\tgeo      *ipgeo.IPGeoData\n\tsum      float64\n\tlast     float64\n\tbest     float64\n\tworst    float64\n\treceived int\n\tcount    int\n\terrors   map[string]int\n\tmpls     map[string]struct{}\n}\n\ntype mtrHopJSON struct {\n\tTTL         int              `json:\"ttl\"`\n\tHost        string           `json:\"host,omitempty\"`\n\tIP          string           `json:\"ip,omitempty\"`\n\tSent        int              `json:\"sent\"`\n\tReceived    int              `json:\"received\"`\n\tLossPercent float64          `json:\"loss_percent\"`\n\tLossCount   int              `json:\"loss_count\"`\n\tLast        float64          `json:\"last_ms\"`\n\tAvg         float64          `json:\"avg_ms\"`\n\tBest        float64          `json:\"best_ms\"`\n\tWorst       float64          `json:\"worst_ms\"`\n\tGeo         *ipgeo.IPGeoData `json:\"geo,omitempty\"`\n\tFailureType string           `json:\"failure_type,omitempty\"`\n\tErrors      map[string]int   `json:\"errors,omitempty\"`\n\tMPLS        []string         `json:\"mpls,omitempty\"`\n}\n\ntype mtrSnapshot struct {\n\tIteration int          `json:\"iteration\"`\n\tStats     []mtrHopJSON `json:\"stats\"`\n}\n\nfunc newMTRAggregator() *mtrAggregator {\n\treturn &mtrAggregator{\n\t\tstats: make(map[int]map[string]*hopAccum),\n\t}\n}\n\nfunc buildAttemptGroups(attempts []trace.Hop) map[string]*groupMetrics {\n\tgroups := make(map[string]*groupMetrics)\n\tfor _, attempt := range attempts {\n\t\thost := strings.TrimSpace(attempt.Hostname)\n\t\tip := \"\"\n\t\tif attempt.Address != nil {\n\t\t\tip = strings.TrimSpace(attempt.Address.String())\n\t\t}\n\n\t\tkey := hopKey(ip, host)\n\t\tgroup := groups[key]\n\t\tif group == nil {\n\t\t\tgroup = &groupMetrics{\n\t\t\t\thost: host,\n\t\t\t\tip:   ip,\n\t\t\t\tbest: math.MaxFloat64,\n\t\t\t}\n\t\t\tgroups[key] = group\n\t\t}\n\t\tmergeAttemptIntoGroup(group, attempt)\n\t}\n\treturn groups\n}\n\nfunc mergeAttemptIntoGroup(group *groupMetrics, attempt trace.Hop) {\n\tgroup.count++\n\tif group.geo == nil && attempt.Geo != nil {\n\t\tgroup.geo = attempt.Geo\n\t}\n\taddGroupMPLS(group, attempt.MPLS)\n\n\tif attempt.Success {\n\t\tupdateGroupRTT(group, attempt)\n\t\treturn\n\t}\n\n\tif group.errors == nil {\n\t\tgroup.errors = make(map[string]int)\n\t}\n\tgroup.errors[attemptErrorKey(attempt)]++\n}\n\nfunc addGroupMPLS(group *groupMetrics, labels []string) {\n\tif len(labels) == 0 {\n\t\treturn\n\t}\n\tif group.mpls == nil {\n\t\tgroup.mpls = make(map[string]struct{})\n\t}\n\tfor _, label := range labels {\n\t\tval := strings.TrimSpace(label)\n\t\tif val != \"\" {\n\t\t\tgroup.mpls[val] = struct{}{}\n\t\t}\n\t}\n}\n\nfunc updateGroupRTT(group *groupMetrics, attempt trace.Hop) {\n\trttMs := float64(attempt.RTT) / float64(time.Millisecond)\n\tgroup.sum += rttMs\n\tgroup.received++\n\tgroup.last = rttMs\n\tif rttMs > group.worst {\n\t\tgroup.worst = rttMs\n\t}\n\tif rttMs > 0 && rttMs < group.best {\n\t\tgroup.best = rttMs\n\t}\n}\n\nfunc attemptErrorKey(attempt trace.Hop) string {\n\tif attempt.Error == nil {\n\t\treturn \"timeout\"\n\t}\n\terrKey := strings.TrimSpace(attempt.Error.Error())\n\tif errKey == \"\" {\n\t\treturn \"timeout\"\n\t}\n\treturn errKey\n}\n\nfunc (agg *mtrAggregator) ensureHopAccum(ttl int, accMap map[string]*hopAccum, key string) *hopAccum {\n\tacc := accMap[key]\n\tif acc != nil {\n\t\treturn acc\n\t}\n\n\tacc = &hopAccum{\n\t\tTTL:     ttl,\n\t\tKey:     key,\n\t\tBest:    math.MaxFloat64,\n\t\torder:   agg.nextOrder,\n\t\tmplsSet: make(map[string]struct{}),\n\t}\n\tagg.nextOrder++\n\taccMap[key] = acc\n\treturn acc\n}\n\nfunc mergeGroup(acc *hopAccum, group *groupMetrics) {\n\tif group.ip != \"\" {\n\t\tacc.IP = group.ip\n\t}\n\tif group.host != \"\" {\n\t\tacc.Host = group.host\n\t}\n\tif group.geo != nil {\n\t\tacc.Geo = group.geo\n\t}\n\n\tacc.Sent += group.count\n\tif group.received > 0 {\n\t\tacc.Sum += group.sum\n\t\tacc.Received += group.received\n\t\tacc.Last = group.last\n\t\tif group.best > 0 && (acc.Best == math.MaxFloat64 || group.best < acc.Best) {\n\t\t\tacc.Best = group.best\n\t\t}\n\t\tif group.worst > acc.Worst {\n\t\t\tacc.Worst = group.worst\n\t\t}\n\t}\n\tmergeErrorCounts(acc, group.errors)\n\tmergeMPLSSet(acc, group.mpls)\n}\n\nfunc mergeErrorCounts(acc *hopAccum, errors map[string]int) {\n\tif len(errors) == 0 {\n\t\treturn\n\t}\n\tif acc.Errors == nil {\n\t\tacc.Errors = make(map[string]int)\n\t}\n\tfor errKey, count := range errors {\n\t\tacc.Errors[errKey] += count\n\t}\n}\n\nfunc mergeMPLSSet(acc *hopAccum, mpls map[string]struct{}) {\n\tif len(mpls) == 0 {\n\t\treturn\n\t}\n\tif acc.mplsSet == nil {\n\t\tacc.mplsSet = make(map[string]struct{})\n\t}\n\tfor label := range mpls {\n\t\tacc.mplsSet[label] = struct{}{}\n\t}\n}\n\nfunc (agg *mtrAggregator) Update(res *trace.Result, queries int) []mtrHopJSON {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\n\tif queries <= 0 {\n\t\tqueries = 1\n\t}\n\n\tfor idx, attempts := range res.Hops {\n\t\tif len(attempts) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tttl := idx + 1\n\t\taccMap := agg.stats[ttl]\n\t\tif accMap == nil {\n\t\t\taccMap = make(map[string]*hopAccum)\n\t\t\tagg.stats[ttl] = accMap\n\t\t}\n\n\t\tfor key, group := range buildAttemptGroups(attempts) {\n\t\t\tacc := agg.ensureHopAccum(ttl, accMap, key)\n\t\t\tmergeGroup(acc, group)\n\t\t}\n\t}\n\n\treturn agg.buildSnapshotLocked()\n}\n\nfunc (agg *mtrAggregator) Snapshot() []mtrHopJSON {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\treturn agg.buildSnapshotLocked()\n}\n\nfunc (agg *mtrAggregator) buildSnapshotLocked() []mtrHopJSON {\n\trows := make([]mtrHopJSON, 0, len(agg.stats))\n\tkeys := make([]int, 0, len(agg.stats))\n\tfor ttl := range agg.stats {\n\t\tkeys = append(keys, ttl)\n\t}\n\tsort.Ints(keys)\n\n\tfor _, ttl := range keys {\n\t\taccMap := agg.stats[ttl]\n\t\tif len(accMap) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\taccs := make([]*hopAccum, 0, len(accMap))\n\t\tfor _, acc := range accMap {\n\t\t\taccs = append(accs, acc)\n\t\t}\n\t\tsort.SliceStable(accs, func(i, j int) bool {\n\t\t\tif accs[i].order == accs[j].order {\n\t\t\t\treturn accs[i].IP < accs[j].IP\n\t\t\t}\n\t\t\treturn accs[i].order < accs[j].order\n\t\t})\n\n\t\tfor _, acc := range accs {\n\t\t\tif acc == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlossCount := acc.Sent - acc.Received\n\t\t\tlossPercent := 0.0\n\t\t\tif acc.Sent > 0 {\n\t\t\t\tlossPercent = float64(lossCount) / float64(acc.Sent) * 100\n\t\t\t}\n\t\t\tbest := acc.Best\n\t\t\tif best == math.MaxFloat64 {\n\t\t\t\tbest = 0\n\t\t\t}\n\t\t\tavg := 0.0\n\t\t\tif acc.Received > 0 {\n\t\t\t\tavg = acc.Sum / float64(acc.Received)\n\t\t\t}\n\n\t\t\tfailureType := failureTypeFromErrors(acc.Errors, acc.Received, lossCount)\n\t\t\tmpls := sortedSet(acc.mplsSet)\n\n\t\t\trows = append(rows, mtrHopJSON{\n\t\t\t\tTTL:         acc.TTL,\n\t\t\t\tHost:        acc.Host,\n\t\t\t\tIP:          acc.IP,\n\t\t\t\tSent:        acc.Sent,\n\t\t\t\tReceived:    acc.Received,\n\t\t\t\tLossPercent: lossPercent,\n\t\t\t\tLossCount:   lossCount,\n\t\t\t\tLast:        acc.Last,\n\t\t\t\tAvg:         avg,\n\t\t\t\tBest:        best,\n\t\t\t\tWorst:       acc.Worst,\n\t\t\t\tGeo:         acc.Geo,\n\t\t\t\tFailureType: failureType,\n\t\t\t\tErrors:      copyErrors(acc.Errors),\n\t\t\t\tMPLS:        mpls,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn rows\n}\n\nfunc hopKey(ip, host string) string {\n\tip = strings.TrimSpace(ip)\n\thost = strings.TrimSpace(host)\n\tif ip != \"\" {\n\t\treturn \"ip:\" + ip\n\t}\n\tif host != \"\" {\n\t\treturn \"host:\" + strings.ToLower(host)\n\t}\n\treturn \"unknown\"\n}\n\nfunc copyErrors(src map[string]int) map[string]int {\n\tif len(src) == 0 {\n\t\treturn nil\n\t}\n\tdst := make(map[string]int, len(src))\n\tfor k, v := range src {\n\t\tdst[k] = v\n\t}\n\treturn dst\n}\n\nfunc sortedSet(set map[string]struct{}) []string {\n\tif len(set) == 0 {\n\t\treturn nil\n\t}\n\tlist := make([]string, 0, len(set))\n\tfor k := range set {\n\t\tlist = append(list, k)\n\t}\n\tsort.Strings(list)\n\treturn list\n}\n\nfunc failureTypeFromErrors(errors map[string]int, received, lossCount int) string {\n\tif lossCount <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(errors) == 0 {\n\t\tif received == 0 {\n\t\t\treturn \"all_timeout\"\n\t\t}\n\t\treturn \"partial_timeout\"\n\t}\n\tallTimeout := true\n\tfor key := range errors {\n\t\tlower := strings.ToLower(strings.TrimSpace(key))\n\t\tif lower == \"timeout\" || strings.Contains(lower, \"timeout\") {\n\t\t\tcontinue\n\t\t}\n\t\tallTimeout = false\n\t\tbreak\n\t}\n\tif allTimeout {\n\t\tif received == 0 {\n\t\t\treturn \"all_timeout\"\n\t\t}\n\t\treturn \"partial_timeout\"\n\t}\n\treturn \"mixed\"\n}\n"
  },
  {
    "path": "server/server.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"embed\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n//go:embed web/*\nvar webContent embed.FS\n\nconst defaultListenAddr = \":1080\"\n\nvar indexPage []byte\nvar assetsFS fs.FS\n\nfunc init() {\n\tvar err error\n\tindexPage, err = webContent.ReadFile(\"web/index.html\")\n\tif err != nil {\n\t\tpanic(fmt.Errorf(\"web assets missing index.html: %w\", err))\n\t}\n\n\tassetsFS, err = fs.Sub(webContent, \"web/assets\")\n\tif err != nil {\n\t\tpanic(fmt.Errorf(\"web assets missing asset directory: %w\", err))\n\t}\n}\n\n// Run starts the Gin HTTP server that exposes the traceroute UI and APIs.\nfunc Run(listenAddr string) error {\n\tif listenAddr == \"\" {\n\t\tlistenAddr = defaultListenAddr\n\t}\n\n\tgin.SetMode(gin.ReleaseMode)\n\trouter := gin.New()\n\trouter.Use(gin.Logger(), gin.Recovery())\n\trouter.Use(browserAccessMiddleware())\n\n\trouter.OPTIONS(\"/*path\", func(c *gin.Context) {\n\t\tc.Status(http.StatusNoContent)\n\t})\n\n\trouter.GET(\"/\", func(c *gin.Context) {\n\t\tc.Data(http.StatusOK, \"text/html; charset=utf-8\", indexPage)\n\t})\n\n\trouter.StaticFS(\"/assets\", http.FS(assetsFS))\n\n\trouter.GET(\"/api/options\", optionsHandler)\n\trouter.POST(\"/api/trace\", traceHandler)\n\trouter.POST(\"/api/cache/clear\", cacheClearHandler)\n\trouter.GET(\"/ws/trace\", traceWebsocketHandler)\n\n\tsrv := &http.Server{Addr: listenAddr, Handler: router}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\t\t_ = srv.Shutdown(shutdownCtx)\n\t}()\n\n\terr := srv.ListenAndServe()\n\tif err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\tif strings.Contains(err.Error(), \"address already in use\") {\n\t\t\treturn fmt.Errorf(\"listen %s: %w\", listenAddr, err)\n\t\t}\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "server/trace_handler.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/nxtrace/NTrace-core/config\"\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/tracemap\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n\t\"github.com/nxtrace/NTrace-core/wshandle\"\n)\n\nvar traceMu sync.Mutex\nvar leoConnMu sync.Mutex\nvar traceMapURLFn = tracemap.GetMapUrlWithContext\nvar traceDomainLookupFn = util.DomainLookUpWithContext\nvar withTraceMapScopeFn = func(setup *traceExecution, callback func() (string, error)) (string, error) {\n\treturn withTraceGeoDNSScope(setup, callback)\n}\n\ntype traceExecution struct {\n\tReq          traceRequest\n\tTarget       string\n\tProtocol     string\n\tDataProvider string\n\tMethod       trace.Method\n\tIP           net.IP\n\tConfig       trace.Config\n\tNeedsLeoWS   bool\n\tPowProvider  string\n}\n\ntype traceRequest struct {\n\tTarget            string `json:\"target\"`\n\tProtocol          string `json:\"protocol\"`\n\tPort              int    `json:\"port\"`\n\tQueries           int    `json:\"queries\"`\n\tMaxHops           int    `json:\"max_hops\"`\n\tTimeoutMs         int    `json:\"timeout_ms\"`\n\tPacketSize        *int   `json:\"packet_size\"`\n\tTOS               *int   `json:\"tos\"`\n\tParallelRequests  int    `json:\"parallel_requests\"`\n\tBeginHop          int    `json:\"begin_hop\"`\n\tIPv4Only          bool   `json:\"ipv4_only\"`\n\tIPv6Only          bool   `json:\"ipv6_only\"`\n\tDataProvider      string `json:\"data_provider\"`\n\tPowProvider       string `json:\"pow_provider\"`\n\tDotServer         string `json:\"dot_server\"`\n\tDisableRDNS       bool   `json:\"disable_rdns\"`\n\tAlwaysRDNS        bool   `json:\"always_rdns\"`\n\tDisableMaptrace   bool   `json:\"disable_maptrace\"`\n\tDisableMPLS       bool   `json:\"disable_mpls\"`\n\tLanguage          string `json:\"language\"`\n\tDN42              bool   `json:\"dn42\"`\n\tSourceAddress     string `json:\"source_address\"`\n\tSourcePort        int    `json:\"source_port\"`\n\tSourceDevice      string `json:\"source_device\"`\n\tICMPMode          int    `json:\"icmp_mode\"`\n\tPacketInterval    int    `json:\"packet_interval\"`\n\tTTLInterval       int    `json:\"ttl_interval\"`\n\tMaxAttempts       int    `json:\"max_attempts\"`\n\tAlwaysWaitRDNS    bool   `json:\"always_wait_rdns\"`\n\tMaptrace          *bool  `json:\"maptrace\"` // deprecated toggle compatibility\n\tLanguageOverride  string `json:\"language_override\"`\n\tDataProviderAlias string `json:\"data_provider_alias\"`\n\tMode              string `json:\"mode\"`\n\tIntervalMs        int    `json:\"interval_ms\"`\n\tHopIntervalMs     int    `json:\"hop_interval_ms\"`\n\tMaxRounds         int    `json:\"max_rounds\"`\n}\n\ntype hopAttempt struct {\n\tSuccess  bool             `json:\"success\"`\n\tIP       string           `json:\"ip,omitempty\"`\n\tHostname string           `json:\"hostname,omitempty\"`\n\tRTT      float64          `json:\"rtt_ms,omitempty\"`\n\tError    string           `json:\"error,omitempty\"`\n\tMPLS     []string         `json:\"mpls,omitempty\"`\n\tGeo      *ipgeo.IPGeoData `json:\"geo,omitempty\"`\n}\n\ntype hopResponse struct {\n\tTTL      int          `json:\"ttl\"`\n\tAttempts []hopAttempt `json:\"attempts\"`\n}\n\ntype traceResponse struct {\n\tTarget       string        `json:\"target\"`\n\tResolvedIP   string        `json:\"resolved_ip\"`\n\tProtocol     string        `json:\"protocol\"`\n\tDataProvider string        `json:\"data_provider\"`\n\tTraceMapURL  string        `json:\"trace_map_url,omitempty\"`\n\tLanguage     string        `json:\"language\"`\n\tHops         []hopResponse `json:\"hops\"`\n\tDurationMs   int64         `json:\"duration_ms\"`\n}\n\ntype traceProtocolSelection struct {\n\tprotocol string\n\tmethod   trace.Method\n\tdstPort  int\n}\n\nfunc normalizeTraceRequest(req *traceRequest) (int, error) {\n\tif req == nil {\n\t\treturn http.StatusBadRequest, errors.New(\"request is required\")\n\t}\n\n\treq.Mode = strings.ToLower(strings.TrimSpace(req.Mode))\n\tif req.Maptrace != nil {\n\t\treq.DisableMaptrace = !*req.Maptrace\n\t}\n\tif req.IPv4Only && req.IPv6Only {\n\t\treturn http.StatusBadRequest, errors.New(\"ipv4_only and ipv6_only cannot be true at the same time\")\n\t}\n\tif err := validateSourceDevice(req.SourceDevice); err != nil {\n\t\treturn http.StatusBadRequest, err\n\t}\n\tif req.IntervalMs <= 0 {\n\t\treq.IntervalMs = 0\n\t}\n\tif req.MaxRounds < 0 {\n\t\treq.MaxRounds = 0\n\t}\n\tif req.TOS != nil && (*req.TOS < 0 || *req.TOS > 255) {\n\t\treturn http.StatusBadRequest, errors.New(\"tos must be within range 0-255\")\n\t}\n\treturn 0, nil\n}\n\nfunc resolveTraceProtocol(req traceRequest) (traceProtocolSelection, int, error) {\n\tprotocol := strings.ToLower(strings.TrimSpace(req.Protocol))\n\tif protocol == \"\" {\n\t\tprotocol = \"icmp\"\n\t}\n\tif !contains(supportedProtocols, protocol) {\n\t\treturn traceProtocolSelection{}, http.StatusBadRequest, fmt.Errorf(\"unsupported protocol %q\", protocol)\n\t}\n\n\tmethod := trace.ICMPTrace\n\tswitch protocol {\n\tcase \"udp\":\n\t\tmethod = trace.UDPTrace\n\tcase \"tcp\":\n\t\tmethod = trace.TCPTrace\n\t}\n\n\tdstPort := req.Port\n\tif dstPort == 0 {\n\t\tswitch method {\n\t\tcase trace.UDPTrace:\n\t\t\tdstPort = 33494\n\t\tcase trace.TCPTrace:\n\t\t\tdstPort = 80\n\t\t}\n\t}\n\n\treturn traceProtocolSelection{\n\t\tprotocol: protocol,\n\t\tmethod:   method,\n\t\tdstPort:  dstPort,\n\t}, 0, nil\n}\n\nfunc resolveTraceDataProvider(req *traceRequest) (string, bool) {\n\tdataProvider := normalizeDataProvider(req.DataProvider, req.DataProviderAlias)\n\tif dataProvider == \"\" {\n\t\tdataProvider = defaults[\"data_provider\"].(string)\n\t}\n\n\tif strings.EqualFold(dataProvider, \"DN42\") {\n\t\treq.DN42 = true\n\t}\n\tif req.DN42 {\n\t\tconfig.InitConfig()\n\t\treq.DisableMaptrace = true\n\t\tdataProvider = \"DN42\"\n\t}\n\n\tneedsLeoWS := strings.EqualFold(dataProvider, \"LEOMOEAPI\")\n\tif needsLeoWS && util.EnvDataProvider != \"\" {\n\t\tdataProvider = util.EnvDataProvider\n\t\tneedsLeoWS = strings.EqualFold(dataProvider, \"LEOMOEAPI\")\n\t}\n\n\treturn dataProvider, needsLeoWS\n}\n\nfunc resolveTraceIPVersion(req traceRequest) string {\n\tswitch {\n\tcase req.IPv4Only:\n\t\treturn \"4\"\n\tcase req.IPv6Only:\n\t\treturn \"6\"\n\tdefault:\n\t\treturn \"all\"\n\t}\n}\n\nfunc prepareTrace(ctx context.Context, req traceRequest) (*traceExecution, int, error) {\n\texec := &traceExecution{\n\t\tReq: req,\n\t}\n\n\tif statusCode, err := normalizeTraceRequest(&exec.Req); err != nil {\n\t\treturn nil, statusCode, err\n\t}\n\n\ttarget, err := normalizeTarget(exec.Req.Target)\n\tif err != nil {\n\t\treturn nil, http.StatusBadRequest, err\n\t}\n\texec.Target = target\n\n\tprotocol, statusCode, err := resolveTraceProtocol(exec.Req)\n\tif err != nil {\n\t\treturn nil, statusCode, err\n\t}\n\texec.Protocol = protocol.protocol\n\texec.Method = protocol.method\n\n\tdataProvider, needsLeoWS := resolveTraceDataProvider(&exec.Req)\n\tip, err := traceDomainLookupFn(ctx, target, resolveTraceIPVersion(exec.Req), strings.ToLower(exec.Req.DotServer), true)\n\tif err != nil {\n\t\treturn nil, http.StatusInternalServerError, err\n\t}\n\texec.IP = ip\n\n\texec.DataProvider = dataProvider\n\texec.PowProvider = strings.TrimSpace(exec.Req.PowProvider)\n\texec.NeedsLeoWS = needsLeoWS\n\texec.Config, err = buildTraceConfig(exec.Req, exec.Method, ip, dataProvider, protocol.dstPort)\n\tif err != nil {\n\t\treturn nil, http.StatusBadRequest, err\n\t}\n\texec.Config.Context = ctx\n\n\treturn exec, 0, nil\n}\n\nfunc traceHandler(c *gin.Context) {\n\tvar req traceRequest\n\tc.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxTraceRequestBodyBytes)\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tvar maxBytesErr *http.MaxBytesError\n\t\tif errors.As(err, &maxBytesErr) {\n\t\t\tc.JSON(http.StatusRequestEntityTooLarge, gin.H{\"error\": \"request payload too large\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(400, gin.H{\"error\": \"invalid request payload\", \"details\": err.Error()})\n\t\treturn\n\t}\n\n\tsetup, statusCode, err := prepareTrace(c.Request.Context(), req)\n\tif err != nil {\n\t\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\t\treturn\n\t\t}\n\t\tif statusCode == 0 {\n\t\t\tstatusCode = 500\n\t\t}\n\t\tlog.Printf(\"[deploy] prepare trace failed target=%s error=%v\", sanitizeLogParam(req.Target), err)\n\t\tc.JSON(statusCode, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tlog.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)\n\tlog.Printf(\"[deploy] target resolved target=%s ip=%s via dot=%s\", sanitizeLogParam(setup.Target), setup.IP, sanitizeLogParam(strings.ToLower(setup.Req.DotServer)))\n\n\ttraceMu.Lock()\n\tdefer traceMu.Unlock()\n\n\tif setup.NeedsLeoWS {\n\t\tif _, err := withTraceSetupContext(setup, func() (struct{}, error) {\n\t\t\tensureLeoMoeConnection()\n\t\t\treturn struct{}{}, nil\n\t\t}); err != nil {\n\t\t\tlog.Printf(\"[deploy] failed to initialize LeoMoeAPI connection target=%s error=%v\", sanitizeLogParam(setup.Target), err)\n\t\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t}\n\n\tconfigured := setup.Config\n\tlog.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)\n\n\tstart := time.Now()\n\tres, err := withTraceSetupContext(setup, func() (*trace.Result, error) {\n\t\treturn traceTracerouteFn(setup.Method, configured)\n\t})\n\tduration := time.Since(start)\n\tif err != nil {\n\t\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\t\treturn\n\t\t}\n\t\tlog.Printf(\"[deploy] trace failed target=%s error=%v\", sanitizeLogParam(setup.Target), err)\n\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\ttraceMapURL := traceMapURLForResult(setup, res)\n\tif traceMapURL != \"\" {\n\t\tlog.Printf(\"[deploy] trace map generated target=%s mapUrl=%s\", sanitizeLogParam(setup.Target), traceMapURL)\n\t}\n\n\tresponse := traceResponse{\n\t\tTarget:       setup.Target,\n\t\tResolvedIP:   setup.IP.String(),\n\t\tProtocol:     setup.Protocol,\n\t\tDataProvider: setup.DataProvider,\n\t\tTraceMapURL:  traceMapURL,\n\t\tLanguage:     configured.Lang,\n\t\tHops:         convertHops(res, configured.Lang),\n\t\tDurationMs:   duration.Milliseconds(),\n\t}\n\n\tlog.Printf(\"[deploy] trace completed target=%s hops=%d duration=%s\", sanitizeLogParam(setup.Target), len(response.Hops), duration)\n\tc.JSON(200, response)\n}\n\nfunc buildTraceConfig(req traceRequest, method trace.Method, ip net.IP, dataProvider string, port int) (trace.Config, error) {\n\tlang := strings.TrimSpace(req.Language)\n\tif lang == \"\" {\n\t\tlang = defaults[\"language\"].(string)\n\t}\n\n\ttimeout := req.TimeoutMs\n\tif timeout <= 0 {\n\t\ttimeout = defaults[\"timeout_ms\"].(int)\n\t}\n\n\tpacketSize := trace.DefaultPacketSize(method, ip)\n\tif req.PacketSize != nil {\n\t\tpacketSize = *req.PacketSize\n\t}\n\tpacketSizeSpec, err := trace.NormalizePacketSize(method, ip, packetSize)\n\tif err != nil {\n\t\treturn trace.Config{}, err\n\t}\n\n\ttos := defaults[\"tos\"].(int)\n\tif req.TOS != nil {\n\t\ttos = *req.TOS\n\t}\n\n\tif req.PacketInterval <= 0 {\n\t\treq.PacketInterval = 50\n\t}\n\tif req.TTLInterval <= 0 {\n\t\treq.TTLInterval = 50\n\t}\n\n\tmaxHops := req.MaxHops\n\tif maxHops <= 0 {\n\t\tmaxHops = defaults[\"max_hops\"].(int)\n\t}\n\n\tqueries := req.Queries\n\tif queries <= 0 {\n\t\tqueries = defaults[\"queries\"].(int)\n\t}\n\n\tparallel := req.ParallelRequests\n\tif parallel <= 0 {\n\t\tparallel = defaults[\"parallel_requests\"].(int)\n\t}\n\n\tbeginHop := req.BeginHop\n\tif beginHop <= 0 {\n\t\tbeginHop = defaults[\"begin_hop\"].(int)\n\t}\n\n\talwaysWait := req.AlwaysWaitRDNS || req.AlwaysRDNS\n\n\tostype := 3\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\tostype = 1\n\tcase \"windows\":\n\t\tostype = 2\n\t}\n\n\treturn trace.Config{\n\t\tOSType:           ostype,\n\t\tICMPMode:         req.ICMPMode,\n\t\tSrcAddr:          req.SourceAddress,\n\t\tSrcPort:          req.SourcePort,\n\t\tSourceDevice:     strings.TrimSpace(req.SourceDevice),\n\t\tBeginHop:         beginHop,\n\t\tMaxHops:          maxHops,\n\t\tNumMeasurements:  queries,\n\t\tMaxAttempts:      req.MaxAttempts,\n\t\tParallelRequests: parallel,\n\t\tTimeout:          time.Duration(timeout) * time.Millisecond,\n\t\tDstIP:            ip,\n\t\tDstPort:          port,\n\t\tIPGeoSource:      ipgeo.GetSourceWithGeoDNS(dataProvider, req.DotServer),\n\t\tRDNS:             !req.DisableRDNS,\n\t\tAlwaysWaitRDNS:   alwaysWait,\n\t\tPacketInterval:   req.PacketInterval,\n\t\tTTLInterval:      req.TTLInterval,\n\t\tLang:             lang,\n\t\tDN42:             req.DN42,\n\t\tPktSize:          packetSizeSpec.PayloadSize,\n\t\tRandomPacketSize: packetSizeSpec.Random,\n\t\tTOS:              tos,\n\t\tMaptrace:         !req.DisableMaptrace,\n\t\tDisableMPLS:      req.DisableMPLS,\n\t}, nil\n}\n\nfunc withTraceSetupContext[T any](setup *traceExecution, callback func() (T, error)) (T, error) {\n\tif callback == nil {\n\t\tvar zero T\n\t\treturn zero, nil\n\t}\n\n\tprevPowProvider := util.PowProviderParam\n\tutil.PowProviderParam = \"\"\n\tif setup != nil {\n\t\tutil.PowProviderParam = setup.PowProvider\n\t\tif setup.NeedsLeoWS {\n\t\t\tif setup.PowProvider != \"\" {\n\t\t\t\tlog.Printf(\"[deploy] LeoMoeAPI using custom PoW provider=%s\", sanitizeLogParam(setup.PowProvider))\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[deploy] LeoMoeAPI using default PoW provider\")\n\t\t\t}\n\t\t} else if setup.PowProvider != \"\" {\n\t\t\tlog.Printf(\"[deploy] overriding PoW provider=%s\", sanitizeLogParam(setup.PowProvider))\n\t\t}\n\t}\n\tdefer func() {\n\t\tutil.PowProviderParam = prevPowProvider\n\t}()\n\n\treturn withTraceGeoDNSScope(setup, callback)\n}\n\nfunc withTraceGeoDNSScope[T any](setup *traceExecution, callback func() (T, error)) (T, error) {\n\tif callback == nil {\n\t\tvar zero T\n\t\treturn zero, nil\n\t}\n\tdotServer := \"\"\n\tif setup != nil {\n\t\tdotServer = strings.TrimSpace(strings.ToLower(setup.Req.DotServer))\n\t}\n\treturn util.WithGeoDNSResolver(dotServer, callback)\n}\n\nfunc traceMapURLForResult(setup *traceExecution, res *trace.Result) string {\n\tif setup == nil || res == nil || !setup.Config.Maptrace || !shouldGenerateMap(setup.DataProvider) {\n\t\treturn \"\"\n\t}\n\tpayload, err := json.Marshal(res)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\turl, err := withTraceMapScopeFn(setup, func() (string, error) {\n\t\tctx := setup.Config.Context\n\t\tif ctx == nil {\n\t\t\tctx = context.Background()\n\t\t}\n\t\treturn traceMapURLFn(ctx, string(payload))\n\t})\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn url\n}\n\nfunc convertHops(res *trace.Result, lang string) []hopResponse {\n\tif res == nil || len(res.Hops) == 0 {\n\t\treturn nil\n\t}\n\n\thops := make([]hopResponse, 0, len(res.Hops))\n\tfor idx, attempts := range res.Hops {\n\t\tresp := buildHopResponse(attempts, idx, lang)\n\t\tif len(resp.Attempts) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\thops = append(hops, resp)\n\t}\n\treturn hops\n}\n\nfunc buildHopResponse(attempts []trace.Hop, idx int, lang string) hopResponse {\n\tresp := hopResponse{\n\t\tTTL:      idx + 1,\n\t\tAttempts: make([]hopAttempt, 0, len(attempts)),\n\t}\n\n\tfor _, attempt := range attempts {\n\t\tha := hopAttempt{\n\t\t\tSuccess: attempt.Success,\n\t\t\tMPLS:    attempt.MPLS,\n\t\t}\n\t\tif attempt.Address != nil {\n\t\t\tha.IP = attempt.Address.String()\n\t\t}\n\t\tif attempt.Hostname != \"\" {\n\t\t\tha.Hostname = attempt.Hostname\n\t\t}\n\t\tif attempt.RTT > 0 {\n\t\t\tha.RTT = float64(attempt.RTT) / float64(time.Millisecond)\n\t\t}\n\t\tif attempt.Error != nil {\n\t\t\tha.Error = attempt.Error.Error()\n\t\t}\n\t\tif attempt.Geo != nil {\n\t\t\tha.Geo = localizeGeo(attempt.Geo, lang)\n\t\t}\n\t\tresp.Attempts = append(resp.Attempts, ha)\n\t}\n\treturn resp\n}\n\nfunc parseTargetURLHost(target string) (string, string, error) {\n\tfallbackSource := target\n\tif !strings.Contains(target, \"://\") {\n\t\treturn \"\", fallbackSource, nil\n\t}\n\n\tu, err := url.Parse(target)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"invalid target format: %w\", err)\n\t}\n\tif u.Host != \"\" {\n\t\treturn u.Host, fallbackSource, nil\n\t}\n\tif u.Path != \"\" {\n\t\tfallbackSource = strings.TrimPrefix(target, u.Scheme+\"://\")\n\t}\n\treturn \"\", fallbackSource, nil\n}\n\nfunc extractTargetHost(target, fallbackSource string) (string, error) {\n\tparseTarget := target\n\tif strings.Contains(target, \"/\") {\n\t\tif !strings.HasPrefix(parseTarget, \"//\") {\n\t\t\tparseTarget = \"//\" + parseTarget\n\t\t}\n\t\tif u, err := url.Parse(parseTarget); err == nil && u.Host != \"\" {\n\t\t\treturn u.Host, nil\n\t\t}\n\t}\n\n\tif !strings.Contains(fallbackSource, \"/\") {\n\t\treturn \"\", nil\n\t}\n\tidx := strings.Index(fallbackSource, \"/\")\n\tif idx <= 0 {\n\t\treturn \"\", errors.New(\"invalid target format\")\n\t}\n\tcandidate := strings.TrimSpace(fallbackSource[:idx])\n\tif candidate == \"\" {\n\t\treturn \"\", errors.New(\"invalid target format\")\n\t}\n\treturn candidate, nil\n}\n\nfunc stripTargetPort(target string) string {\n\t// Try standard SplitHostPort first — handles host:port and [IPv6]:port.\n\tif host, _, err := net.SplitHostPort(target); err == nil {\n\t\treturn host\n\t}\n\t// Bare [IPv6] without port.\n\tif open := strings.Index(target, \"[\"); open >= 0 {\n\t\tclose := strings.Index(target[open:], \"]\")\n\t\tif close > 1 {\n\t\t\treturn target[open+1 : open+close]\n\t\t}\n\t}\n\t// host:port with exactly one colon (plain IPv4 / hostname).\n\tif strings.Count(target, \":\") == 1 {\n\t\treturn target[:strings.Index(target, \":\")]\n\t}\n\treturn target\n}\n\nfunc normalizeTarget(input string) (string, error) {\n\ttarget := strings.TrimSpace(input)\n\tif target == \"\" {\n\t\treturn \"\", errors.New(\"target is required\")\n\t}\n\n\thost, fallbackSource, err := parseTargetURLHost(target)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif host == \"\" {\n\t\thost, err = extractTargetHost(target, fallbackSource)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\tif host != \"\" {\n\t\ttarget = host\n\t}\n\n\treturn strings.TrimSpace(stripTargetPort(target)), nil\n}\nfunc normalizeDataProvider(provider string, alias string) string {\n\tcandidate := strings.TrimSpace(provider)\n\tif candidate == \"\" {\n\t\tcandidate = strings.TrimSpace(alias)\n\t}\n\tif candidate == \"\" {\n\t\treturn \"\"\n\t}\n\n\tupper := strings.ToUpper(candidate)\n\tswitch upper {\n\tcase \"IP.SB\":\n\t\treturn \"IP.SB\"\n\tcase \"IP-API.COM\", \"IPAPI.COM\":\n\t\treturn \"IPAPI.com\"\n\tcase \"IPINFO\", \"IP INFO\":\n\t\treturn \"IPInfo\"\n\tcase \"IPINSIGHT\", \"IP INSIGHT\":\n\t\treturn \"IPInsight\"\n\tcase \"IPINFOLOCAL\", \"IP INFO LOCAL\":\n\t\treturn \"IPInfoLocal\"\n\tcase \"LEOMOEAPI\", \"LEOMOE\":\n\t\treturn \"LeoMoeAPI\"\n\tcase \"CHUNZHEN\":\n\t\treturn \"chunzhen\"\n\tcase \"DN42\":\n\t\treturn \"DN42\"\n\tcase \"DISABLE-GEOIP\", \"DISABLE_GEOIP\":\n\t\treturn \"disable-geoip\"\n\tcase \"IPDB.ONE\":\n\t\treturn \"ipdb.one\"\n\tdefault:\n\t\treturn candidate\n\t}\n}\n\nfunc contains(list []string, v string) bool {\n\tfor _, item := range list {\n\t\tif strings.EqualFold(item, v) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc shouldGenerateMap(provider string) bool {\n\tallowed := []string{\"LEOMOEAPI\", \"IPINFO\", \"IP-API.COM\", \"IPAPI.COM\"}\n\tfor _, item := range allowed {\n\t\tif strings.EqualFold(provider, item) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc validateSourceDevice(device string) error {\n\tdevice = strings.TrimSpace(device)\n\tif device == \"\" {\n\t\treturn nil\n\t}\n\n\tifaces, err := net.Interfaces()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"list network interfaces: %w\", err)\n\t}\n\tfor _, iface := range ifaces {\n\t\tif iface.Name == device {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"unknown source_device %q\", device)\n}\n\nfunc ensureLeoMoeConnection() {\n\tleoConnMu.Lock()\n\tdefer leoConnMu.Unlock()\n\n\tconn := wshandle.GetWsConn()\n\tif conn == nil || conn.MsgSendCh == nil || conn.MsgReceiveCh == nil {\n\t\tlog.Println(\"[deploy] establishing initial LeoMoeAPI websocket\")\n\t\twshandle.New()\n\t\treturn\n\t}\n\n\tif !conn.IsConnected() && !conn.IsConnecting() {\n\t\tlog.Println(\"[deploy] reconnecting LeoMoeAPI websocket\")\n\t\twshandle.New()\n\t}\n}\n\nfunc localizeGeo(src *ipgeo.IPGeoData, lang string) *ipgeo.IPGeoData {\n\tif src == nil {\n\t\treturn nil\n\t}\n\n\tdst := *src\n\tswitch strings.ToLower(lang) {\n\tcase \"en\":\n\t\tif dst.CountryEn != \"\" {\n\t\t\tdst.Country = dst.CountryEn\n\t\t}\n\t\tif dst.ProvEn != \"\" {\n\t\t\tdst.Prov = dst.ProvEn\n\t\t}\n\t\tif dst.CityEn != \"\" {\n\t\t\tdst.City = dst.CityEn\n\t\t}\n\tdefault:\n\t\tif dst.Country == \"\" && dst.CountryEn != \"\" {\n\t\t\tdst.Country = dst.CountryEn\n\t\t}\n\t\tif dst.Prov == \"\" && dst.ProvEn != \"\" {\n\t\t\tdst.Prov = dst.ProvEn\n\t\t}\n\t\tif dst.City == \"\" && dst.CityEn != \"\" {\n\t\t\tdst.City = dst.CityEn\n\t\t}\n\t}\n\treturn &dst\n}\n"
  },
  {
    "path": "server/trace_handler_test.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc TestPrepareTrace_DoesNotForceLegacyInterval(t *testing.T) {\n\tsetup, statusCode, err := prepareTrace(context.Background(), traceRequest{\n\t\tTarget:       \"1.1.1.1\",\n\t\tMode:         \"mtr\",\n\t\tDataProvider: \"disable-geoip\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"prepareTrace returned error: %v (status=%d)\", err, statusCode)\n\t}\n\tif setup.Req.IntervalMs != 0 {\n\t\tt.Fatalf(\"prepareTrace IntervalMs = %d, want 0\", setup.Req.IntervalMs)\n\t}\n}\n\nfunc TestResolveWebMTRHopInterval_DefaultsToOneSecond(t *testing.T) {\n\tgot := resolveWebMTRHopInterval(traceRequest{})\n\tif got != time.Second {\n\t\tt.Fatalf(\"resolveWebMTRHopInterval() = %v, want %v\", got, time.Second)\n\t}\n}\n\nfunc TestResolveWebMTRHopInterval_PrefersHopIntervalMs(t *testing.T) {\n\tgot := resolveWebMTRHopInterval(traceRequest{IntervalMs: 2500, HopIntervalMs: 750})\n\tif got != 750*time.Millisecond {\n\t\tt.Fatalf(\"resolveWebMTRHopInterval() = %v, want %v\", got, 750*time.Millisecond)\n\t}\n}\n\nfunc TestBuildTraceConfig_PropagatesSessionScopedFields(t *testing.T) {\n\tpacketSize := 52\n\ttos := 0\n\tcfg, err := buildTraceConfig(traceRequest{\n\t\tSourceDevice: \"en7\",\n\t\tDisableMPLS:  true,\n\t\tDotServer:    \"cloudflare\",\n\t\tPacketSize:   &packetSize,\n\t\tTOS:          &tos,\n\t}, trace.ICMPTrace, net.ParseIP(\"1.1.1.1\"), \"IPInfo\", 80)\n\tif err != nil {\n\t\tt.Fatalf(\"buildTraceConfig returned error: %v\", err)\n\t}\n\n\tif cfg.SourceDevice != \"en7\" {\n\t\tt.Fatalf(\"buildTraceConfig SourceDevice = %q, want en7\", cfg.SourceDevice)\n\t}\n\tif !cfg.DisableMPLS {\n\t\tt.Fatal(\"buildTraceConfig DisableMPLS = false, want true\")\n\t}\n\tif cfg.IPGeoSource == nil {\n\t\tt.Fatal(\"buildTraceConfig IPGeoSource = nil, want wrapped source\")\n\t}\n\tif cfg.TOS != 0 {\n\t\tt.Fatalf(\"buildTraceConfig TOS = %d, want 0\", cfg.TOS)\n\t}\n}\n\nfunc TestBuildTraceConfig_PreservesNegativePacketSizeAndTOS(t *testing.T) {\n\tpacketSize := -123\n\ttos := 255\n\tcfg, err := buildTraceConfig(traceRequest{\n\t\tPacketSize: &packetSize,\n\t\tTOS:        &tos,\n\t}, trace.ICMPTrace, net.ParseIP(\"1.1.1.1\"), \"disable-geoip\", 80)\n\tif err != nil {\n\t\tt.Fatalf(\"buildTraceConfig returned error: %v\", err)\n\t}\n\tif !cfg.RandomPacketSize {\n\t\tt.Fatal(\"buildTraceConfig RandomPacketSize = false, want true\")\n\t}\n\tif cfg.TOS != 255 {\n\t\tt.Fatalf(\"buildTraceConfig TOS = %d, want 255\", cfg.TOS)\n\t}\n}\n\nfunc TestBuildTraceConfig_DefaultsPacketSizeByProtocolAndFamily(t *testing.T) {\n\tcfg, err := buildTraceConfig(traceRequest{}, trace.TCPTrace, net.ParseIP(\"2a00:1450:4009:81a::200e\"), \"disable-geoip\", 80)\n\tif err != nil {\n\t\tt.Fatalf(\"buildTraceConfig returned error: %v\", err)\n\t}\n\tif cfg.PktSize != 0 {\n\t\tt.Fatalf(\"buildTraceConfig PktSize = %d, want 0 payload bytes for default TCP/IPv6 minimum\", cfg.PktSize)\n\t}\n\tif cfg.RandomPacketSize {\n\t\tt.Fatal(\"buildTraceConfig RandomPacketSize = true, want false\")\n\t}\n}\n\nfunc TestNormalizeTraceRequest_RejectsInvalidTOS(t *testing.T) {\n\ttos := 256\n\tstatusCode, err := normalizeTraceRequest(&traceRequest{TOS: &tos})\n\tif err == nil {\n\t\tt.Fatal(\"normalizeTraceRequest should reject invalid tos\")\n\t}\n\tif statusCode != http.StatusBadRequest {\n\t\tt.Fatalf(\"statusCode = %d, want %d\", statusCode, http.StatusBadRequest)\n\t}\n}\n\nfunc TestPrepareTrace_RejectsUnknownSourceDevice(t *testing.T) {\n\t_, statusCode, err := prepareTrace(context.Background(), traceRequest{\n\t\tTarget:       \"1.1.1.1\",\n\t\tDataProvider: \"disable-geoip\",\n\t\tSourceDevice: \"codex-nonexistent-dev0\",\n\t})\n\tif err == nil {\n\t\tt.Fatal(\"prepareTrace should reject unknown source_device\")\n\t}\n\tif statusCode != http.StatusBadRequest {\n\t\tt.Fatalf(\"statusCode = %d, want %d\", statusCode, http.StatusBadRequest)\n\t}\n}\n\nfunc TestNormalizeTarget(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tinput  string\n\t\twant   string\n\t\thasErr bool\n\t}{\n\t\t{name: \"empty\", input: \" \", hasErr: true},\n\t\t{name: \"url host\", input: \"https://example.com/path\", want: \"example.com\"},\n\t\t{name: \"host with port\", input: \"example.com:8443\", want: \"example.com\"},\n\t\t{name: \"ipv6 with brackets\", input: \"[2001:db8::1]:443\", want: \"2001:db8::1\"},\n\t\t{name: \"bare ipv6 brackets\", input: \"[::1]\", want: \"::1\"},\n\t\t{name: \"malformed reversed brackets\", input: \"foo]bar[\", want: \"foo]bar[\"},\n\t\t{name: \"malformed open only\", input: \"[abc\", want: \"[abc\"},\n\t\t{name: \"malformed close only\", input: \"abc]\", want: \"abc]\"},\n\t\t{name: \"slash target\", input: \"example.com/path\", want: \"example.com\"},\n\t\t{name: \"invalid slash target\", input: \"/only-path\", hasErr: true},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot, err := normalizeTarget(tc.input)\n\t\t\tif tc.hasErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"normalizeTarget(%q) error = nil, want error\", tc.input)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"normalizeTarget(%q) returned error: %v\", tc.input, err)\n\t\t\t}\n\t\t\tif got != tc.want {\n\t\t\t\tt.Fatalf(\"normalizeTarget(%q) = %q, want %q\", tc.input, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTraceHandler_RejectsOversizedJSONBody(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tbody := `{\"target\":\"` + strings.Repeat(\"a\", maxTraceRequestBodyBytes) + `\"}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/trace\", strings.NewReader(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(w)\n\tc.Request = req\n\n\ttraceHandler(c)\n\n\tif w.Code != http.StatusRequestEntityTooLarge {\n\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusRequestEntityTooLarge)\n\t}\n}\n\nfunc TestExecuteMTRRaw_PerHopDoesNotMutateSessionGlobals(t *testing.T) {\n\toldRunMTRRaw := traceRunMTRRawFn\n\tdefer func() { traceRunMTRRawFn = oldRunMTRRaw }()\n\n\toldSrcDev := util.SrcDev\n\toldDisableMPLS := util.DisableMPLS\n\toldPowProvider := util.PowProviderParam\n\tdefer func() {\n\t\tutil.SrcDev = oldSrcDev\n\t\tutil.DisableMPLS = oldDisableMPLS\n\t\tutil.PowProviderParam = oldPowProvider\n\t}()\n\n\tutil.SrcDev = \"keep-dev\"\n\tutil.DisableMPLS = false\n\tutil.PowProviderParam = \"keep-pow\"\n\n\ttraceRunMTRRawFn = func(_ context.Context, _ trace.Method, cfg trace.Config, opts trace.MTRRawOptions, _ trace.MTRRawOnRecord) error {\n\t\tif cfg.SourceDevice != \"en7\" {\n\t\t\tt.Fatalf(\"cfg.SourceDevice = %q, want en7\", cfg.SourceDevice)\n\t\t}\n\t\tif !cfg.DisableMPLS {\n\t\t\tt.Fatal(\"cfg.DisableMPLS = false, want true\")\n\t\t}\n\t\tif opts.HopInterval != time.Second {\n\t\t\tt.Fatalf(\"opts.HopInterval = %v, want %v\", opts.HopInterval, time.Second)\n\t\t}\n\t\treturn nil\n\t}\n\n\terr := executeMTRRaw(context.Background(), &wsTraceSession{}, &traceExecution{\n\t\tReq: traceRequest{\n\t\t\tSourceDevice:  \"en7\",\n\t\t\tDisableMPLS:   true,\n\t\t\tHopIntervalMs: 1000,\n\t\t\tDotServer:     \"cloudflare\",\n\t\t},\n\t\tTarget: \"1.1.1.1\",\n\t\tMethod: trace.ICMPTrace,\n\t\tIP:     net.ParseIP(\"1.1.1.1\"),\n\t\tConfig: trace.Config{\n\t\t\tDstIP:            net.ParseIP(\"1.1.1.1\"),\n\t\t\tSourceDevice:     \"en7\",\n\t\t\tDisableMPLS:      true,\n\t\t\tIPGeoSource:      nil,\n\t\t\tTimeout:          time.Second,\n\t\t\tMaxHops:          30,\n\t\t\tParallelRequests: 1,\n\t\t},\n\t}, trace.MTRRawOptions{\n\t\tHopInterval: time.Second,\n\t}, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"executeMTRRaw returned error: %v\", err)\n\t}\n\n\tif util.SrcDev != \"keep-dev\" {\n\t\tt.Fatalf(\"util.SrcDev = %q, want keep-dev\", util.SrcDev)\n\t}\n\tif util.DisableMPLS {\n\t\tt.Fatal(\"util.DisableMPLS = true, want false\")\n\t}\n\tif util.PowProviderParam != \"keep-pow\" {\n\t\tt.Fatalf(\"util.PowProviderParam = %q, want keep-pow\", util.PowProviderParam)\n\t}\n}\n\nfunc TestTraceMapURLForResult_UsesRequestScopedMapHelper(t *testing.T) {\n\toldMapFn := traceMapURLFn\n\toldScopeFn := withTraceMapScopeFn\n\tdefer func() {\n\t\ttraceMapURLFn = oldMapFn\n\t\twithTraceMapScopeFn = oldScopeFn\n\t}()\n\n\tscopeCalled := false\n\ttraceMapCalled := false\n\n\twithTraceMapScopeFn = func(setup *traceExecution, callback func() (string, error)) (string, error) {\n\t\tscopeCalled = true\n\t\tif setup == nil {\n\t\t\tt.Fatal(\"setup should not be nil\")\n\t\t}\n\t\tif strings.TrimSpace(setup.Req.DotServer) != \"cloudflare\" {\n\t\t\tt.Fatalf(\"DotServer = %q, want cloudflare\", setup.Req.DotServer)\n\t\t}\n\t\treturn callback()\n\t}\n\ttraceMapURLFn = func(ctx context.Context, payload string) (string, error) {\n\t\ttraceMapCalled = true\n\t\tif ctx == nil {\n\t\t\tt.Fatal(\"context should not be nil\")\n\t\t}\n\t\tif payload == \"\" {\n\t\t\tt.Fatal(\"payload should not be empty\")\n\t\t}\n\t\treturn \"https://map.example.test\", nil\n\t}\n\n\tgot := traceMapURLForResult(&traceExecution{\n\t\tReq:          traceRequest{DotServer: \"cloudflare\"},\n\t\tDataProvider: \"IPInfo\",\n\t\tConfig:       trace.Config{Maptrace: true},\n\t}, &trace.Result{\n\t\tHops: [][]trace.Hop{{{TTL: 1}}},\n\t})\n\n\tif got != \"https://map.example.test\" {\n\t\tt.Fatalf(\"traceMapURLForResult() = %q, want https://map.example.test\", got)\n\t}\n\tif !scopeCalled {\n\t\tt.Fatal(\"expected request-scoped map helper to be used\")\n\t}\n\tif !traceMapCalled {\n\t\tt.Fatal(\"expected traceMapURLFn to be called\")\n\t}\n}\n\nfunc TestPrepareTraceHonorsCanceledContext(t *testing.T) {\n\toldLookup := traceDomainLookupFn\n\ttraceDomainLookupFn = func(ctx context.Context, target, ipVersion, dotServer string, disableOutput bool) (net.IP, error) {\n\t\t<-ctx.Done()\n\t\treturn nil, ctx.Err()\n\t}\n\tdefer func() { traceDomainLookupFn = oldLookup }()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\t_, _, err := prepareTrace(ctx, traceRequest{\n\t\tTarget:       \"example.com\",\n\t\tDataProvider: \"disable-geoip\",\n\t})\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Fatalf(\"prepareTrace error = %v, want context.Canceled\", err)\n\t}\n\tif elapsed := time.Since(start); elapsed > 100*time.Millisecond {\n\t\tt.Fatalf(\"prepareTrace returned too slowly after cancel: %v\", elapsed)\n\t}\n}\n"
  },
  {
    "path": "server/web/assets/app.js",
    "content": "const form = document.getElementById('trace-form');\nconst protocolSelect = document.getElementById('protocol');\nconst providerSelect = document.getElementById('data-provider');\nconst queriesInput = document.getElementById('queries');\nconst maxHopsInput = document.getElementById('max-hops');\nconst disableMaptraceInput = document.getElementById('disable-maptrace');\nconst dstPortHint = document.getElementById('dst-port-hint');\nconst dstPortInput = document.getElementById('dst-port');\nconst payloadSizeInput = document.getElementById('payload-size');\nconst tosInput = document.getElementById('tos');\nconst modeSelect = document.getElementById('mode');\nconst statusNode = document.getElementById('status');\nconst resultNode = document.getElementById('result');\nconst resultMetaNode = document.getElementById('result-meta');\nconst submitBtn = document.getElementById('submit-btn');\nconst stopBtn = document.getElementById('stop-btn');\nconst langToggleBtn = document.getElementById('lang-toggle');\nconst cacheBtn = document.getElementById('cache-btn');\nconst titleText = document.getElementById('title-text');\nconst subtitleText = document.getElementById('subtitle-text');\nconst footerText = document.getElementById('footer-text');\nconst labelTarget = document.getElementById('label-target');\nconst labelProtocol = document.getElementById('label-protocol');\nconst labelProvider = document.getElementById('label-provider');\nconst labelQueries = document.getElementById('label-queries');\nconst labelMaxHops = document.getElementById('label-maxhops');\nconst labelDisableMap = document.getElementById('label-disable-map');\nconst labelDstPort = document.getElementById('label-dst-port');\nconst labelPSize = document.getElementById('label-psize');\nconst labelTOS = document.getElementById('label-tos');\nconst labelMode = document.getElementById('label-mode');\nconst targetInput = document.getElementById('target');\nconst groupBasicParams = document.getElementById('group-basic-params');\nconst groupAdvancedParams = document.getElementById('group-advanced-params');\nconst groupDisableMap = document.getElementById('group-disable-map');\n\nconst wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';\nconst wsUrl = `${wsScheme}://${window.location.host}/ws/trace`;\n\nlet socket = null;\nlet traceCompleted = false;\nconst hopStore = new Map();\nlet latestSummary = {};\nlet currentLang = 'cn';\nlet currentMode = 'single';\nlet currentStatus = {state: 'idle', key: 'statusReady', custom: null};\nlet mtrStatsStore = [];\nlet mtrRawAggStore = new Map();\nlet mtrRawOrderSeq = 0;\nlet singleModeQueriesValue = '';\nconst MTR_RENDER_MIN_INTERVAL_MS = 100;\nlet mtrRenderScheduled = false;\nlet mtrRenderTimer = null;\nlet mtrRenderRAF = null;\nlet mtrRenderLastAt = 0;\nlet mtrRawKnownFinalTTL = Infinity;\nconst traceFormHelpers = globalThis.NextTraceForm || {};\n\nconst uiText = {\n  cn: {\n    title: 'NextTrace Web',\n    subtitle: '在浏览器中运行 NextTrace，实时查看路由探测结果。',\n    labelTarget: '目标地址',\n    placeholderTarget: '例如：1.1.1.1 或 www.example.com',\n    labelProtocol: '协议',\n    labelProvider: '地理信息源',\n    labelQueries: '每跳探测次数',\n    labelMaxHops: '最大跳数',\n    labelDisableMap: '禁用地图生成',\n    labelDstPort: '目的端口',\n    labelPSize: '探测包大小',\n    labelTOS: 'TOS',\n    labelMode: '探测模式',\n    buttonStartSingle: '开始探测',\n    buttonStartMtr: '开始持续探测',\n    buttonStop: '停止',\n    buttonClearCache: '清空缓存',\n    langToggle: 'English',\n    tableTTL: 'TTL',\n    tableDetails: '探测详情',\n    colLoss: '丢包率',\n    colSent: '发送/接收',\n    colLast: '最新',\n    colAvg: '平均',\n    colBest: '最佳',\n    colWorst: '最差',\n    colHost: '主机',\n    colIP: '地址',\n    colFailure: '失败原因',\n    statusReady: '准备就绪',\n    statusRunning: '正在探测，请稍候...',\n    statusMtrRunning: '持续探测中…',\n    statusSuccess: '探测完成',\n    statusCacheClearing: '正在清理缓存…',\n    statusCacheCleared: '缓存已清空',\n    statusCacheFailed: '清理缓存失败',\n    statusWsError: 'WebSocket 连接出错',\n    statusDisconnected: '连接已断开',\n    statusOptionsFailed: '无法加载选项:',\n    statusTargetMissing: '请填写目标地址',\n    statusTraceFailed: '探测失败',\n    metaResolved: '解析结果',\n    metaProvider: '数据源',\n    metaDuration: '耗时',\n    metaIterations: '持续轮次',\n    metaMap: '地图',\n    mapOpen: '打开地图',\n    attemptLabelHost: '主机',\n    attemptLabelAddress: '地址',\n    attemptLabelLatency: '延迟',\n    attemptLabelError: '错误',\n    attemptLabelMPLS: 'MPLS',\n    attemptLabelLoss: '丢包率',\n    attemptLabelFailure: '失败',\n    timeoutAll: '全部超时',\n    timeoutPartial: '部分超时',\n    unknownAddress: '未知地址',\n    unknownError: '未知错误',\n    hintDstPort: '仅 TCP/UDP 模式有效',\n    attemptBadge: '探测',\n    noResult: '未获取到有效路由信息。',\n    footer: '当前会话仅提供基础功能，更多高级选项请使用 CLI。',\n    modeSingle: '单次探测',\n    modeMTR: '持续探测',\n  },\n  en: {\n    title: 'NextTrace Web',\n    subtitle: 'Run NextTrace in your browser and watch the trace in real time.',\n    labelTarget: 'Target',\n    placeholderTarget: 'e.g. 1.1.1.1 or www.example.com',\n    labelProtocol: 'Protocol',\n    labelProvider: 'Geo provider',\n    labelQueries: 'Probes per hop',\n    labelMaxHops: 'Max hops',\n    labelDisableMap: 'Disable map generation',\n    labelDstPort: 'Destination Port',\n    labelPSize: 'Probe Packet Size',\n    labelTOS: 'TOS',\n    labelMode: 'Mode',\n    buttonStartSingle: 'Start Trace',\n    buttonStartMtr: 'Start Continuous Trace',\n    buttonStop: 'Stop',\n    buttonClearCache: 'Clear Cache',\n    langToggle: '中文',\n    tableTTL: 'TTL',\n    tableDetails: 'Details',\n    colLoss: 'Loss',\n    colSent: 'Sent/Recv',\n    colLast: 'Last',\n    colAvg: 'Avg',\n    colBest: 'Best',\n    colWorst: 'Worst',\n    colHost: 'Host',\n    colIP: 'IP',\n    colFailure: 'Failure',\n    statusReady: 'Ready',\n    statusRunning: 'Tracing…',\n    statusMtrRunning: 'Tracing continuously…',\n    statusSuccess: 'Trace completed',\n    statusCacheClearing: 'Clearing cache…',\n    statusCacheCleared: 'Cache cleared',\n    statusCacheFailed: 'Failed to clear cache',\n    statusWsError: 'WebSocket error',\n    statusDisconnected: 'Connection closed',\n    statusOptionsFailed: 'Failed to load options:',\n    statusTargetMissing: 'Please enter a target',\n    statusTraceFailed: 'Trace failed',\n    metaResolved: 'Resolved IP',\n    metaProvider: 'Provider',\n    metaDuration: 'Duration',\n    metaIterations: 'Iterations',\n    metaMap: 'Map',\n    mapOpen: 'Open map',\n    attemptLabelHost: 'Host',\n    attemptLabelAddress: 'IP',\n    attemptLabelLatency: 'Latency',\n    attemptLabelError: 'Error',\n    attemptLabelMPLS: 'MPLS',\n    attemptLabelLoss: 'Loss',\n    attemptLabelFailure: 'Failure',\n    timeoutAll: 'All timeout',\n    timeoutPartial: 'Partial timeout',\n    unknownAddress: 'Unknown',\n    unknownError: 'Unknown error',\n    hintDstPort: 'Active for TCP/UDP only',\n    attemptBadge: 'Probe',\n    noResult: 'No valid hops collected yet.',\n    footer: 'For advanced options, please use the CLI.',\n    modeSingle: 'Single Trace',\n    modeMTR: 'Continuous Trace',\n  },\n};\n\nfunction t(key) {\n  return uiText[currentLang][key] || key || '';\n}\n\nfunction updateStatusDisplay(state, text) {\n  statusNode.className = `status status--${state}`;\n  statusNode.textContent = text;\n}\n\nfunction setStatus(state, message, translate = true) {\n  if (translate) {\n    currentStatus = {state, key: message, custom: null};\n    updateStatusDisplay(state, t(message));\n  } else {\n    currentStatus = {state, key: null, custom: message};\n    updateStatusDisplay(state, message);\n  }\n}\n\nfunction refreshStatus() {\n  if (currentStatus.key) {\n    updateStatusDisplay(currentStatus.state, t(currentStatus.key));\n  } else if (currentStatus.custom !== null) {\n    updateStatusDisplay(currentStatus.state, currentStatus.custom);\n  }\n}\n\nasync function loadOptions() {\n  try {\n    const res = await fetch('/api/options');\n    if (!res.ok) {\n      throw new Error(`HTTP ${res.status}`);\n    }\n    const data = await res.json();\n    fillSelect(protocolSelect, data.protocols, data.defaultOptions.protocol);\n    fillSelect(providerSelect, data.dataProviders, data.defaultOptions.data_provider);\n    queriesInput.value = Math.min(63, data.defaultOptions.queries || 3);\n    singleModeQueriesValue = queriesInput.value;\n    queriesInput.dataset.defaultValue = queriesInput.value;\n    maxHopsInput.value = data.defaultOptions.max_hops;\n    disableMaptraceInput.checked = data.defaultOptions.disable_maptrace;\n    const defaultOptionValue = traceFormHelpers.defaultOptionValue || ((opts, key, fallback) => (opts && Object.prototype.hasOwnProperty.call(opts, key) ? opts[key] : fallback));\n    payloadSizeInput.value = defaultOptionValue(data.defaultOptions, 'packet_size', payloadSizeInput.value || '') ?? '';\n    tosInput.value = defaultOptionValue(data.defaultOptions, 'tos', tosInput.value || 0);\n    dstPortInput.value = data.defaultOptions.port || dstPortInput.value || '';\n    updateDstPortState();\n    updateModeUI();\n  } catch (err) {\n    setStatus('error', `${t('statusOptionsFailed')} ${err.message}`, false);\n    submitBtn.disabled = true;\n  }\n}\n\nfunction fillSelect(selectEl, values, defaultValue) {\n  selectEl.innerHTML = '';\n  values.forEach((val) => {\n    const option = document.createElement('option');\n    option.value = val;\n    option.textContent = val;\n    if (String(val).toLowerCase() === String(defaultValue).toLowerCase()) {\n      option.selected = true;\n    }\n    selectEl.appendChild(option);\n  });\n}\n\nfunction readNumericValue(inputEl) {\n  const raw = inputEl.value.trim();\n  if (raw === '') {\n    return undefined;\n  }\n  const num = Number(raw);\n  return Number.isFinite(num) ? num : undefined;\n}\n\nfunction clearResult(resetState = false) {\n  cancelScheduledMTRRender();\n  resultNode.innerHTML = '';\n  resultNode.classList.add('hidden');\n  resultMetaNode.innerHTML = '';\n  resultMetaNode.classList.add('hidden');\n  if (resetState) {\n    hopStore.clear();\n    latestSummary = {};\n    mtrStatsStore = [];\n    mtrRawAggStore = new Map();\n    mtrRawOrderSeq = 0;\n    mtrRenderLastAt = 0;\n    mtrRawKnownFinalTTL = Infinity;\n    stopBtn.classList.add('hidden');\n    stopBtn.disabled = true;\n  }\n}\n\nfunction renderMeta(summary = {}) {\n  const rows = [];\n  if (summary.resolved_ip) {\n    rows.push(`${t('metaResolved')}：<strong>${escapeHTML(summary.resolved_ip)}</strong>`);\n  }\n  if (summary.data_provider) {\n    rows.push(`${t('metaProvider')}：<strong>${escapeHTML(summary.data_provider)}</strong>`);\n  }\n  if (summary.duration_ms !== undefined) {\n    rows.push(`${t('metaDuration')}：<strong>${escapeHTML(summary.duration_ms)} ms</strong>`);\n  }\n  if (summary.iteration) {\n    rows.push(`${t('metaIterations')}：<strong>${escapeHTML(summary.iteration)}</strong>`);\n  }\n  if (summary.trace_map_url) {\n    // t('mapOpen') is assumed not user-supplied; escape only the URL\n    rows.push(`${t('metaMap')}：<a href=\"${escapeHTML(summary.trace_map_url)}\" target=\"_blank\" rel=\"noreferrer\">${t('mapOpen')}</a>`);\n  }\n  if (rows.length === 0) {\n    resultMetaNode.classList.add('hidden');\n    resultMetaNode.innerHTML = '';\n    return;\n  }\n  resultMetaNode.innerHTML = rows.map((line) => `<div>${line}</div>`).join('');\n  resultMetaNode.classList.remove('hidden');\n}\n\nfunction renderAttemptsGrouped(attempts) {\n  const UNKNOWN_KEY = '__unknown__';\n  const groups = new Map();\n  const ipIndex = new Map();\n  const hostIndex = new Map();\n  let pendingUnknown = [];\n  let lastGroup = null;\n\n  const createGroup = (key) => {\n    const group = {\n      key,\n      attempts: [],\n      hosts: new Set(),\n      ips: new Set(),\n      firstHost: '',\n      firstIP: '',\n    };\n    groups.set(key, group);\n    return group;\n  };\n\n  attempts.forEach((attempt) => {\n    const hostRaw = (attempt.hostname || '').trim();\n    const hostKey = hostRaw.toLowerCase();\n    const ip = (attempt.ip || '').trim();\n\n    if (!hostRaw && !ip) {\n      if (lastGroup) {\n        lastGroup.attempts.push(attempt);\n      } else {\n        pendingUnknown.push(attempt);\n      }\n      return;\n    }\n\n    let group = null;\n    if (ip && ipIndex.has(ip)) {\n      group = groups.get(ipIndex.get(ip));\n    }\n    if (!group && hostRaw) {\n      if (hostIndex.has(hostKey)) {\n        group = groups.get(hostIndex.get(hostKey));\n      }\n    }\n\n    if (!group) {\n      const key = ip ? `ip:${ip}` : `host:${hostKey}`;\n      group = createGroup(key);\n    }\n\n    if (pendingUnknown.length > 0) {\n      group.attempts.push(...pendingUnknown);\n      pendingUnknown = [];\n    }\n\n    group.attempts.push(attempt);\n\n    if (ip) {\n      group.ips.add(ip);\n      if (!group.firstIP) {\n        group.firstIP = ip;\n      }\n      ipIndex.set(ip, group.key);\n    }\n    if (hostRaw) {\n      group.hosts.add(hostRaw);\n      if (!group.firstHost) {\n        group.firstHost = hostRaw;\n      }\n      if (hostKey) {\n        hostIndex.set(hostKey, group.key);\n      }\n    }\n\n    lastGroup = group;\n  });\n\n  if (pendingUnknown.length > 0) {\n    const group = createGroup(UNKNOWN_KEY);\n    group.attempts.push(...pendingUnknown);\n  }\n\n  const orderedGroups = Array.from(groups.values()).filter((group) => group.attempts.length > 0);\n\n  const container = document.createElement('div');\n  container.className = 'attempts attempts--grouped';\n\n  let hasIdentifiedSummary = false;\n  const summarySet = new Set();\n  const summaryLabels = [];\n  orderedGroups.forEach((group) => {\n    const displayIp = group.firstIP || '';\n    let displayHost = group.firstHost || '';\n    if (displayHost && displayIp && displayHost === displayIp) {\n      displayHost = '';\n    }\n    let label = '';\n    if (displayIp && displayHost && displayHost !== displayIp) {\n      label = `${displayIp} (${displayHost})`;\n    } else if (displayIp) {\n      label = displayIp;\n    } else if (displayHost) {\n      label = displayHost;\n    } else {\n      label = '*';\n    }\n    if (!summarySet.has(label)) {\n      summarySet.add(label);\n      summaryLabels.push(label);\n    }\n    if (displayIp || displayHost) {\n      hasIdentifiedSummary = true;\n    }\n  });\n\n  if (summaryLabels.length > 1) {\n    const summary = document.createElement('div');\n    summary.className = 'attempts__summary';\n    summary.textContent = summaryLabels.join(' | ');\n    container.appendChild(summary);\n  }\n\n  orderedGroups.forEach((group) => {\n    const box = document.createElement('div');\n    box.className = 'attempt attempt--group';\n\n    const header = document.createElement('div');\n    header.className = 'attempt__header';\n    const mainLine = [];\n    const first = group.attempts[0] || {};\n    let displayHost = group.firstHost || '';\n    const displayIp = group.firstIP || '';\n    if (displayHost && displayIp && displayHost === displayIp) {\n      displayHost = '';\n    }\n    if (displayHost) {\n      mainLine.push(createMetaItem(t('attemptLabelHost'), displayHost));\n    }\n    if (displayIp) {\n      mainLine.push(createMetaItem(t('attemptLabelAddress'), displayIp));\n    }\n    if (mainLine.length === 0) {\n      if (hasIdentifiedSummary) {\n        const label = document.createElement('span');\n        label.className = 'attempt__star';\n        label.textContent = '*';\n        header.appendChild(label);\n      } else {\n        const star = document.createElement('span');\n        star.className = 'attempt__star';\n        star.textContent = '*';\n        header.appendChild(star);\n      }\n    } else {\n      mainLine.forEach((el) => header.appendChild(el));\n    }\n\n    box.appendChild(header);\n\n    const metrics = document.createElement('div');\n    metrics.className = 'attempt__meta';\n    const rtts = group.attempts\n      .filter((item) => item.rtt_ms !== undefined && item.rtt_ms !== null)\n      .map((item) => Number(item.rtt_ms));\n    if (rtts.length > 0) {\n      const min = Math.min(...rtts).toFixed(2);\n      const max = Math.max(...rtts).toFixed(2);\n      const avg = (rtts.reduce((sum, v) => sum + v, 0) / rtts.length).toFixed(2);\n      metrics.appendChild(createMetaItem(t('attemptLabelLatency'), avg + ' ms (min ' + min + ', max ' + max + ')'));\n    }\n    const successes = group.attempts.filter((item) => item.success).length;\n    const lossCount = group.attempts.length - successes;\n    const lossRate = group.attempts.length > 0 ? (((lossCount) / group.attempts.length) * 100).toFixed(0) : '0';\n    metrics.appendChild(createMetaItem(t('attemptLabelLoss'), lossRate + '% (' + lossCount + '/' + group.attempts.length + ')'));\n\n    const mplsAll = group.attempts.flatMap((item) => item.mpls || []);\n    if (mplsAll.length > 0) {\n      const unique = Array.from(new Set(mplsAll.map((entry) => String(entry || '').trim()).filter(Boolean)));\n      if (unique.length > 0) {\n        const mplsContainer = document.createElement('div');\n        mplsContainer.className = 'attempt__mpls';\n        unique.forEach((entry) => {\n          const line = document.createElement('div');\n          line.textContent = entry;\n          mplsContainer.appendChild(line);\n        });\n        metrics.appendChild(mplsContainer);\n      }\n    }\n    box.appendChild(metrics);\n\n    const geoLine = document.createElement('div');\n    geoLine.className = 'attempt__geo';\n    const segments = [];\n    if (first.geo) {\n      if (first.geo.asnumber) {\n        segments.push('AS' + first.geo.asnumber);\n      }\n      if (first.geo.country) {\n        segments.push(first.geo.country);\n      }\n      if (first.geo.prov) {\n        segments.push(first.geo.prov);\n      }\n      if (first.geo.city) {\n        segments.push(first.geo.city);\n      }\n      if (first.geo.owner || first.geo.isp) {\n        segments.push(first.geo.owner || first.geo.isp);\n      }\n    }\n    if (segments.length > 0) {\n      geoLine.textContent = segments.join(' · ');\n      box.appendChild(geoLine);\n    }\n\n    const probes = document.createElement('div');\n    probes.className = 'attempt__probes';\n    group.attempts.forEach((item, index) => {\n      const badge = document.createElement('span');\n      badge.className = 'attempt__badge';\n      badge.textContent = t('attemptBadge') + ' ' + (index + 1);\n      if (!item.success) {\n        badge.classList.add('attempt__badge--fail');\n      }\n      probes.appendChild(badge);\n    });\n    box.appendChild(probes);\n\n    container.appendChild(box);\n  });\n\n  return container;\n}\n\nfunction renderHops(hops) {\n  if (!hops || hops.length === 0) {\n    resultNode.innerHTML = `<p>${t('noResult')}</p>`;\n    resultNode.classList.remove('hidden');\n    return;\n  }\n\n  const table = document.createElement('table');\n  const thead = document.createElement('thead');\n  thead.innerHTML = `\n    <tr>\n      <th>${t('tableTTL')}</th>\n      <th>${t('tableDetails')}</th>\n    </tr>\n  `;\n  table.appendChild(thead);\n\n  const tbody = document.createElement('tbody');\n  hops.forEach((hop) => {\n    const tr = document.createElement('tr');\n    const ttlCell = document.createElement('td');\n    ttlCell.textContent = hop.ttl;\n    tr.appendChild(ttlCell);\n\n    const attemptsCell = document.createElement('td');\n    attemptsCell.appendChild(renderAttemptsGrouped(hop.attempts));\n    tr.appendChild(attemptsCell);\n\n    tbody.appendChild(tr);\n  });\n\n  table.appendChild(tbody);\n  resultNode.innerHTML = '';\n  resultNode.appendChild(table);\n  resultNode.classList.remove('hidden');\n}\n\nfunction renderHopsFromStore() {\n  const hops = Array.from(hopStore.values()).sort((a, b) => a.ttl - b.ttl);\n  renderHops(hops);\n}\n\nfunction escapeHTML(str) {\n  return String(str)\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;');\n}\n\nfunction createMetaItem(label, value, allowHTML = false) {\n  const span = document.createElement('span');\n  const safeLabel = escapeHTML(label);\n  const strValue = value === undefined || value === null ? '' : String(value);\n  if (allowHTML) {\n    span.innerHTML = `<strong>${safeLabel}:</strong> ${strValue}`;\n  } else {\n    span.innerHTML = `<strong>${safeLabel}:</strong> ${escapeHTML(strValue)}`;\n  }\n  return span;\n}\n\nfunction buildPayload() {\n  const buildTracePayload = traceFormHelpers.buildTracePayload || ((values) => values);\n  const payload = buildTracePayload({\n    target: form.target.value,\n    protocol: protocolSelect.value,\n    dataProvider: providerSelect.value,\n    disableMaptrace: disableMaptraceInput.checked,\n    language: currentLang,\n    mode: modeSelect.value || 'single',\n    queries: queriesInput.value,\n    maxHops: maxHopsInput.value,\n    dstPort: dstPortInput.value,\n    packetSize: payloadSizeInput.value,\n    tos: tosInput.value,\n  });\n  if (payload.mode === 'mtr' && queriesInput.value !== '10') {\n    queriesInput.value = '10';\n  }\n  return payload;\n}\n\nfunction closeExistingSocket(hideStop = true) {\n  cancelScheduledMTRRender();\n  if (socket) {\n    socket.onclose = null;\n    socket.onerror = null;\n    try {\n      socket.close(1000, 'client stop');\n    } catch (_) {\n      // ignore\n    }\n    socket = null;\n  }\n  if (hideStop) {\n    stopBtn.classList.add('hidden');\n    stopBtn.disabled = true;\n  }\n}\n\nfunction flushMTRRender(force = false) {\n  if (mtrRenderTimer !== null) {\n    clearTimeout(mtrRenderTimer);\n    mtrRenderTimer = null;\n  }\n  if (mtrRenderRAF !== null && typeof cancelAnimationFrame === 'function') {\n    cancelAnimationFrame(mtrRenderRAF);\n    mtrRenderRAF = null;\n  }\n  if (!force) {\n    const now = Date.now();\n    const elapsed = now - mtrRenderLastAt;\n    if (elapsed < MTR_RENDER_MIN_INTERVAL_MS) {\n      const waitMs = MTR_RENDER_MIN_INTERVAL_MS - elapsed;\n      mtrRenderScheduled = true;\n      mtrRenderTimer = setTimeout(() => {\n        mtrRenderTimer = null;\n        flushMTRRender();\n      }, waitMs);\n      return;\n    }\n  }\n  mtrRenderScheduled = false;\n  mtrRenderLastAt = Date.now();\n  renderMTRStats(buildMTRStatsFromRawAgg());\n  renderMeta(latestSummary);\n}\n\nfunction scheduleMTRRender() {\n  if (mtrRenderScheduled) {\n    return;\n  }\n  mtrRenderScheduled = true;\n\n  const attemptRender = () => {\n    mtrRenderRAF = null;\n    const waitMs = Math.max(0, MTR_RENDER_MIN_INTERVAL_MS - (Date.now() - mtrRenderLastAt));\n    if (waitMs > 0) {\n      mtrRenderTimer = setTimeout(() => {\n        mtrRenderTimer = null;\n        flushMTRRender();\n      }, waitMs);\n      return;\n    }\n    flushMTRRender();\n  };\n\n  if (typeof requestAnimationFrame === 'function') {\n    mtrRenderRAF = requestAnimationFrame(attemptRender);\n    return;\n  }\n  mtrRenderTimer = setTimeout(() => {\n    mtrRenderTimer = null;\n    flushMTRRender();\n  }, 0);\n}\n\nfunction cancelScheduledMTRRender() {\n  if (mtrRenderTimer !== null) {\n    clearTimeout(mtrRenderTimer);\n    mtrRenderTimer = null;\n  }\n  if (mtrRenderRAF !== null && typeof cancelAnimationFrame === 'function') {\n    cancelAnimationFrame(mtrRenderRAF);\n    mtrRenderRAF = null;\n  }\n  mtrRenderScheduled = false;\n}\n\nfunction handleSocketMessage(event) {\n  let msg;\n  try {\n    msg = JSON.parse(event.data);\n  } catch (err) {\n    setStatus('error', err.message, false);\n    return;\n  }\n\n  switch (msg.type) {\n    case 'start': {\n      latestSummary = {...latestSummary, ...msg.data};\n      renderMeta(latestSummary);\n      break;\n    }\n    case 'hop': {\n      if (currentMode !== 'mtr' && msg.data && typeof msg.data.ttl === 'number') {\n        hopStore.set(msg.data.ttl, msg.data);\n        renderHopsFromStore();\n      }\n      break;\n    }\n    case 'mtr': {\n      // Backward compatibility with old server snapshots.\n      traceCompleted = false;\n      cancelScheduledMTRRender();\n      if (msg.data && typeof msg.data.iteration === 'number') {\n        latestSummary = {...latestSummary, iteration: msg.data.iteration};\n      }\n      if (msg.data && Array.isArray(msg.data.stats)) {\n        renderMTRStats(msg.data.stats);\n      } else {\n        renderMTRStats([]);\n      }\n      setStatus('running', 'statusMtrRunning');\n      stopBtn.disabled = false;\n      renderMeta(latestSummary);\n      break;\n    }\n    case 'mtr_raw': {\n      traceCompleted = false;\n      if (msg.data) {\n        ingestMTRRawRecord(msg.data);\n        const it = Number(msg.data.iteration);\n        if (Number.isFinite(it) && it > 0) {\n          latestSummary = {...latestSummary, iteration: it};\n        }\n      }\n      setStatus('running', 'statusMtrRunning');\n      stopBtn.disabled = false;\n      scheduleMTRRender();\n      break;\n    }\n    case 'complete': {\n      traceCompleted = true;\n      submitBtn.disabled = false;\n      if (currentMode === 'mtr') {\n        if (msg.data && typeof msg.data.iteration === 'number') {\n          latestSummary = {...latestSummary, iteration: msg.data.iteration};\n        }\n        stopBtn.disabled = true;\n        stopBtn.classList.add('hidden');\n        if (msg.data && Array.isArray(msg.data.stats)) {\n          cancelScheduledMTRRender();\n          renderMTRStats(msg.data.stats);\n          renderMeta(latestSummary);\n        } else {\n          flushMTRRender(true);\n        }\n      } else {\n        if (msg.data && Array.isArray(msg.data.hops)) {\n          hopStore.clear();\n          msg.data.hops.forEach((hop) => {\n            if (hop && typeof hop.ttl === 'number') {\n              hopStore.set(hop.ttl, hop);\n            }\n          });\n        }\n        latestSummary = {...latestSummary, ...msg.data};\n        renderHopsFromStore();\n        renderMeta(latestSummary);\n      }\n      setStatus('success', 'statusSuccess');\n      closeExistingSocket();\n      break;\n    }\n    case 'error': {\n      traceCompleted = true;\n      submitBtn.disabled = false;\n      stopBtn.disabled = true;\n      const text = msg.error || t('statusTraceFailed');\n      setStatus('error', text, !msg.error);\n      closeExistingSocket();\n      break;\n    }\n    default:\n      break;\n  }\n}\n\nfunction runTrace(evt) {\n  evt.preventDefault();\n  cancelScheduledMTRRender();\n  clearResult(true);\n  mtrRawAggStore = new Map();\n  mtrRawOrderSeq = 0;\n\n  const payload = buildPayload();\n  if (!payload.target) {\n    setStatus('error', 'statusTargetMissing');\n    return;\n  }\n\n  currentMode = payload.mode || 'single';\n  document.body.classList.toggle('mode-mtr', currentMode === 'mtr');\n  updateStartButtonText();\n  if (currentMode === 'mtr') {\n    setStatus('running', 'statusMtrRunning');\n    stopBtn.classList.remove('hidden');\n    stopBtn.disabled = true;\n  } else {\n    setStatus('running', 'statusRunning');\n    stopBtn.classList.add('hidden');\n    stopBtn.disabled = true;\n  }\n\n  submitBtn.disabled = true;\n  traceCompleted = false;\n\n  closeExistingSocket(false);\n\n  try {\n    socket = new WebSocket(wsUrl);\n  } catch (err) {\n    setStatus('error', `${t('statusWsError')} ${err.message}`, false);\n    submitBtn.disabled = false;\n    updateModeUI();\n    return;\n  }\n\n  socket.onopen = () => {\n    if (currentMode === 'mtr') {\n      stopBtn.disabled = false;\n    }\n    socket.send(JSON.stringify(payload));\n  };\n\n  socket.onmessage = handleSocketMessage;\n\n  socket.onerror = () => {\n    cancelScheduledMTRRender();\n    if (!traceCompleted) {\n      traceCompleted = true;\n      setStatus('error', 'statusWsError');\n      submitBtn.disabled = false;\n      stopBtn.disabled = true;\n    }\n  };\n\n  socket.onclose = () => {\n    cancelScheduledMTRRender();\n    if (!traceCompleted) {\n      setStatus('error', 'statusDisconnected');\n      submitBtn.disabled = false;\n    }\n    stopBtn.disabled = true;\n    socket = null;\n  };\n}\n\nasync function clearCache(silent = false) {\n  if (!silent) {\n    setStatus('running', 'statusCacheClearing');\n  }\n  try {\n    const res = await fetch('/api/cache/clear', {method: 'POST'});\n    if (!res.ok) {\n      const errRes = await res.json().catch(() => ({}));\n      const message = errRes.error || `${t('statusCacheFailed')} HTTP ${res.status}`;\n      throw new Error(message);\n    }\n    if (!silent) {\n      setStatus('success', 'statusCacheCleared');\n    } else {\n      setStatus('idle', 'statusReady');\n    }\n  } catch (err) {\n    setStatus('error', err.message || t('statusCacheFailed'), false);\n  }\n}\n\nfunction toggleLanguage() {\n  currentLang = currentLang === 'cn' ? 'en' : 'cn';\n  applyTranslations();\n  clearCache(true);\n}\n\nfunction applyTranslations() {\n  currentMode = modeSelect.value || 'single';\n  titleText.textContent = t('title');\n  subtitleText.textContent = t('subtitle');\n  footerText.textContent = t('footer');\n  labelTarget.textContent = t('labelTarget');\n  labelProtocol.textContent = t('labelProtocol');\n  labelProvider.textContent = t('labelProvider');\n  labelQueries.textContent = t('labelQueries');\n  labelMaxHops.textContent = t('labelMaxHops');\n  labelDisableMap.textContent = t('labelDisableMap');\n  labelDstPort.textContent = t('labelDstPort');\n  labelPSize.textContent = t('labelPSize');\n  labelTOS.textContent = t('labelTOS');\n  labelMode.textContent = t('labelMode');\n  dstPortHint.textContent = t('hintDstPort');\n  targetInput.placeholder = t('placeholderTarget');\n  updateStartButtonText();\n  cacheBtn.textContent = t('buttonClearCache');\n  langToggleBtn.textContent = t('langToggle');\n  stopBtn.textContent = t('buttonStop');\n  const options = modeSelect.options;\n  if (options.length >= 2) {\n    options[0].textContent = t('modeSingle');\n    options[1].textContent = t('modeMTR');\n  }\n  const isMtr = currentMode === 'mtr';\n  document.body.classList.toggle('mode-mtr', isMtr);\n  groupBasicParams.classList.toggle('hidden', isMtr);\n  groupAdvancedParams.classList.toggle('hidden', isMtr);\n  groupDisableMap.classList.toggle('hidden', isMtr);\n  renderMeta(latestSummary);\n  if (currentMode === 'mtr') {\n    renderMTRStats(mtrStatsStore);\n  } else {\n    renderHopsFromStore();\n  }\n  refreshStatus();\n  updateModeUI();\n  updateDstPortState();\n}\n\n\nfunction updateDstPortState() {\n  const proto = (protocolSelect.value || '').toLowerCase();\n  const enabled = proto === 'tcp' || proto === 'udp';\n  dstPortInput.disabled = !enabled;\n  dstPortInput.parentElement.classList.toggle('disabled', !enabled);\n  if (!enabled) {\n    dstPortInput.value = '';\n  } else if (!dstPortInput.value) {\n    dstPortInput.value = proto === 'tcp' ? '80' : '33494';\n  }\n}\n\ndocument.addEventListener('DOMContentLoaded', () => {\n  applyTranslations();\n  updateModeUI();\n  setStatus('idle', 'statusReady');\n  loadOptions();\n  form.addEventListener('submit', runTrace);\n  langToggleBtn.addEventListener('click', toggleLanguage);\n  cacheBtn.addEventListener('click', () => clearCache(false));\n  providerSelect.addEventListener('change', () => clearCache(true));\n  protocolSelect.addEventListener('change', () => {\n    updateDstPortState();\n    clearCache(true);\n  });\n  payloadSizeInput.addEventListener('change', () => clearCache(true));\n  queriesInput.addEventListener('input', () => {\n    if (!queriesInput.disabled) {\n      singleModeQueriesValue = queriesInput.value;\n    }\n  });\n  modeSelect.addEventListener('change', updateModeUI);\n  stopBtn.addEventListener('click', stopTrace);\n});\n\nfunction updateStartButtonText() {\n  if (currentMode === 'mtr') {\n    submitBtn.textContent = t('buttonStartMtr');\n  } else {\n    submitBtn.textContent = t('buttonStartSingle');\n  }\n}\n\nfunction updateModeUI() {\n  currentMode = modeSelect.value || 'single';\n  const isMtr = currentMode === 'mtr';\n  document.body.classList.toggle('mode-mtr', isMtr);\n  groupBasicParams.classList.toggle('hidden', isMtr);\n  groupAdvancedParams.classList.toggle('hidden', isMtr);\n  groupDisableMap.classList.toggle('hidden', isMtr);\n  updateStartButtonText();\n\n  const queriesContainer = queriesInput.parentElement;\n\n  if (isMtr) {\n    if (!queriesInput.disabled) {\n      const currentValue = queriesInput.value.trim();\n      if (currentValue) {\n        singleModeQueriesValue = currentValue;\n      } else if (!singleModeQueriesValue && queriesInput.dataset.defaultValue) {\n        singleModeQueriesValue = queriesInput.dataset.defaultValue;\n      }\n    }\n    queriesInput.value = '10';\n    queriesInput.disabled = true;\n    if (queriesContainer) {\n      queriesContainer.classList.add('disabled');\n    }\n    stopBtn.classList.remove('hidden');\n    stopBtn.disabled = true;\n  } else {\n    queriesInput.disabled = false;\n    if (queriesContainer) {\n      queriesContainer.classList.remove('disabled');\n    }\n    const restoreValue = singleModeQueriesValue || queriesInput.dataset.defaultValue || queriesInput.value || '3';\n    queriesInput.value = restoreValue;\n    stopBtn.classList.add('hidden');\n    stopBtn.disabled = true;\n  }\n}\n\nfunction stopTrace() {\n  if (!socket) {\n    stopBtn.disabled = true;\n    stopBtn.classList.add('hidden');\n    return;\n  }\n  traceCompleted = true;\n  stopBtn.disabled = true;\n  closeExistingSocket();\n  submitBtn.disabled = false;\n  setStatus('idle', 'statusReady');\n}\n\nfunction mtrRawKey(rec) {\n  const ttl = Number(rec && rec.ttl);\n  return `${ttl}|ttl`;\n}\n\nfunction onlyTimeoutErrors(errors) {\n  if (!errors) {\n    return true;\n  }\n  const keys = Object.keys(errors);\n  if (keys.length === 0) {\n    return true;\n  }\n  return keys.every((k) => String(k).toLowerCase().includes('timeout'));\n}\n\nfunction recomputeMTRRawDerived(row) {\n  row.loss_count = Math.max(0, row.sent - row.received);\n  row.loss_percent = row.sent > 0 ? (row.loss_count / row.sent) * 100 : 0;\n  row.avg_ms = row.received > 0 ? row._sum_ms / row.received : 0;\n  if (row.loss_count <= 0) {\n    row.failure_type = '';\n  } else if (onlyTimeoutErrors(row.errors)) {\n    row.failure_type = row.received > 0 ? 'partial_timeout' : 'all_timeout';\n  } else {\n    row.failure_type = 'mixed';\n  }\n}\n\nfunction ingestMTRRawRecord(rec) {\n  if (!rec || !Number.isFinite(Number(rec.ttl))) {\n    return;\n  }\n  const ttl = Number(rec.ttl);\n\n  // --- knownFinalTTL truncation (mirrors server-side logic) ---\n  // Drop probes beyond the known destination TTL.\n  if (ttl > mtrRawKnownFinalTTL) {\n    return;\n  }\n  // Detect destination: success + IP matches resolved target.\n  const resolvedIP = latestSummary && latestSummary.resolved_ip\n    ? String(latestSummary.resolved_ip).trim() : '';\n  const recIP = rec.ip ? String(rec.ip).trim() : '';\n  if (rec.success && recIP && resolvedIP && recIP === resolvedIP && ttl < mtrRawKnownFinalTTL) {\n    mtrRawKnownFinalTTL = ttl;\n    // Prune stale entries whose TTL exceeds the new boundary.\n    for (const [k, v] of mtrRawAggStore) {\n      if (v.ttl > mtrRawKnownFinalTTL) {\n        mtrRawAggStore.delete(k);\n      }\n    }\n  }\n  // --- end truncation ---\n\n  const key = mtrRawKey(rec);\n  let row = mtrRawAggStore.get(key);\n  if (!row) {\n    row = {\n      ttl: Number(rec.ttl),\n      host: '',\n      ip: '',\n      sent: 0,\n      received: 0,\n      loss_percent: 0,\n      loss_count: 0,\n      last_ms: 0,\n      avg_ms: 0,\n      best_ms: 0,\n      worst_ms: 0,\n      geo: null,\n      failure_type: '',\n      errors: null,\n      mpls: [],\n      _sum_ms: 0,\n      _order: mtrRawOrderSeq++,\n    };\n    mtrRawAggStore.set(key, row);\n  }\n\n  row.sent += 1;\n  const ip = rec.ip ? String(rec.ip).trim() : '';\n  const host = rec.host ? String(rec.host).trim() : '';\n  if (ip) {\n    row.ip = ip;\n  }\n  if (host) {\n    row.host = host;\n  }\n\n  const success = !!rec.success;\n  const rtt = Number(rec.rtt_ms) || 0;\n  if (success && (row.ip || row.host)) {\n    row.received += 1;\n    if (rtt > 0) {\n      row.last_ms = rtt;\n      row._sum_ms += rtt;\n      if (row.best_ms <= 0 || rtt < row.best_ms) {\n        row.best_ms = rtt;\n      }\n      if (rtt > row.worst_ms) {\n        row.worst_ms = rtt;\n      }\n    }\n  } else {\n    if (!row.errors) {\n      row.errors = Object.create(null);\n    }\n    row.errors.timeout = (Number(row.errors.timeout) || 0) + 1;\n  }\n\n  if (rec.asn || rec.country || rec.prov || rec.city || rec.district || rec.owner || rec.lat || rec.lng) {\n    row.geo = row.geo || {};\n    if (rec.asn) {\n      row.geo.asnumber = String(rec.asn).trim();\n    }\n    if (rec.country) {\n      row.geo.country = String(rec.country).trim();\n    }\n    if (rec.prov) {\n      row.geo.prov = String(rec.prov).trim();\n    }\n    if (rec.city) {\n      row.geo.city = String(rec.city).trim();\n    }\n    if (rec.district) {\n      row.geo.district = String(rec.district).trim();\n    }\n    if (rec.owner) {\n      row.geo.owner = String(rec.owner).trim();\n    }\n    if (Number.isFinite(Number(rec.lat))) {\n      row.geo.lat = Number(rec.lat);\n    }\n    if (Number.isFinite(Number(rec.lng))) {\n      row.geo.lng = Number(rec.lng);\n    }\n  }\n\n  if (Array.isArray(rec.mpls) && rec.mpls.length > 0) {\n    const existing = new Set((row.mpls || []).map((v) => String(v)));\n    rec.mpls.forEach((m) => {\n      const val = String(m || '').trim();\n      if (val) {\n        existing.add(val);\n      }\n    });\n    row.mpls = Array.from(existing);\n  }\n\n  recomputeMTRRawDerived(row);\n}\n\nfunction buildMTRStatsFromRawAgg() {\n  const rows = Array.from(mtrRawAggStore.values())\n    .sort((a, b) => (a.ttl - b.ttl) || (a._order - b._order))\n    .map((row) => {\n      const out = {...row};\n      delete out._sum_ms;\n      delete out._order;\n      return out;\n    });\n  mtrStatsStore = rows;\n  return rows;\n}\n\nfunction renderMTRStats(stats) {\n  mtrStatsStore = Array.isArray(stats) ? stats : [];\n  const normalizer = window.nextTraceMTRAgg && window.nextTraceMTRAgg.normalizeRenderableMTRStats;\n  const data = typeof normalizer === 'function' ? normalizer(mtrStatsStore) : mtrStatsStore;\n  if (!data || data.length === 0) {\n    resultNode.innerHTML = `<p>${t('noResult')}</p>`;\n    resultNode.classList.remove('hidden');\n    return;\n  }\n\n  const table = document.createElement('table');\n  const thead = document.createElement('thead');\n  thead.innerHTML = `\n    <tr>\n      <th>${t('tableTTL')}</th>\n      <th>${t('colLoss')}</th>\n      <th>${t('colLast')}</th>\n      <th>${t('colAvg')}</th>\n      <th>${t('colBest')}</th>\n      <th>${t('colWorst')}</th>\n      <th>${t('colHost')}</th>\n    </tr>\n  `;\n  table.appendChild(thead);\n\n  const tbody = document.createElement('tbody');\n  let lastTTL = null;\n  data.forEach((stat) => {\n    const row = document.createElement('tr');\n\n    const lossText = `${Math.round(stat.loss_percent || 0)}% (${stat.loss_count}/${stat.sent})`;\n    const lastText = formatLatency(stat.last_ms, stat.received);\n    const avgText = formatLatency(stat.avg_ms, stat.received);\n    const bestText = formatLatency(stat.best_ms, stat.received);\n    const worstText = formatLatency(stat.worst_ms, stat.received);\n    const hostParts = getHostDisplayParts(stat);\n    const mplsText = formatMPLSText(stat.mpls);\n    const geoText = formatGeoDisplay(stat.geo);\n\n    const appendCell = (value) => {\n      const td = document.createElement('td');\n      td.textContent = value;\n      row.appendChild(td);\n      return td;\n    };\n\n    const displayTTL = lastTTL === stat.ttl ? '' : stat.ttl;\n    appendCell(displayTTL);\n    lastTTL = stat.ttl;\n    appendCell(lossText);\n    appendCell(lastText);\n    appendCell(avgText);\n    appendCell(bestText);\n    appendCell(worstText);\n\n    const hostCell = appendCell('');\n    hostCell.classList.add('mtr-host-cell');\n    if (hostParts.ip) {\n      hostCell.appendChild(document.createTextNode(hostParts.ip));\n    }\n    if (hostParts.ip && hostParts.host) {\n      hostCell.appendChild(document.createTextNode(' '));\n    }\n    if (hostParts.host) {\n      const hostSpan = document.createElement('span');\n      hostSpan.className = 'mtr-hostname';\n      hostSpan.textContent = hostParts.host;\n      hostCell.appendChild(hostSpan);\n    }\n    if (!hostParts.ip && !hostParts.host) {\n      hostCell.textContent = '--';\n    }\n    if (geoText) {\n      const geoDiv = document.createElement('div');\n      geoDiv.className = 'attempt__geo';\n      geoDiv.textContent = geoText;\n      hostCell.appendChild(geoDiv);\n    }\n    if (mplsText) {\n      const mplsDiv = document.createElement('div');\n      mplsDiv.className = 'mtr-mpls';\n      mplsDiv.textContent = mplsText;\n      hostCell.appendChild(mplsDiv);\n    }\n\n    tbody.appendChild(row);\n  });\n\n  table.appendChild(tbody);\n  resultNode.innerHTML = '';\n  resultNode.appendChild(table);\n  resultNode.classList.remove('hidden');\n}\n\nfunction getHostDisplayParts(stat) {\n  const ip = stat && stat.ip ? String(stat.ip).trim() : '';\n  let host = stat && stat.host ? String(stat.host).trim() : '';\n  if (ip && host && host === ip) {\n    host = '';\n  }\n  return {\n    ip,\n    host,\n  };\n}\n\nfunction formatMPLSText(mpls) {\n  if (!Array.isArray(mpls) || mpls.length === 0) {\n    return '';\n  }\n  const unique = Array.from(new Set(mpls.map((item) => String(item || '').trim()).filter(Boolean)));\n  return unique.join('\\n');\n}\n\nfunction formatLatency(value, received) {\n  if (!received || value === undefined || value === null || Number(value) <= 0) {\n    return '--';\n  }\n  return Number(value).toFixed(2) + ' ms';\n}\n\nfunction formatGeoDisplay(geo) {\n  if (!geo) {\n    return '';\n  }\n  const parts = [];\n  if (geo.asnumber) {\n    parts.push('AS' + geo.asnumber);\n  }\n  const country = currentLang === 'en' ? (geo.country_en || geo.country) : (geo.country || geo.country_en);\n  if (country) {\n    parts.push(country.trim());\n  }\n  const prov = currentLang === 'en' ? (geo.prov_en || geo.prov) : (geo.prov || geo.prov_en);\n  if (prov) {\n    parts.push(prov.trim());\n  }\n  const city = currentLang === 'en' ? (geo.city_en || geo.city) : (geo.city || geo.city_en);\n  if (city) {\n    parts.push(city.trim());\n  }\n  if (geo.owner) {\n    parts.push(geo.owner.trim());\n  } else if (geo.isp) {\n    parts.push(geo.isp.trim());\n  }\n  return parts.filter(Boolean).join(' · ');\n}\n"
  },
  {
    "path": "server/web/assets/mtr_agg.js",
    "content": "(function(root, factory) {\n  const api = factory();\n  if (typeof module !== 'undefined' && module.exports) {\n    module.exports = api;\n  }\n  if (root) {\n    root.nextTraceMTRAgg = api;\n  }\n})(typeof globalThis !== 'undefined' ? globalThis : this, function() {\n  const PROTOTYPE_POLLUTION_KEYS = new Set(['__proto__', 'prototype', 'constructor']);\n\n  function normalizeErrorKey(key) {\n    const trimmed = String(key || '').trim();\n    if (!trimmed || PROTOTYPE_POLLUTION_KEYS.has(trimmed)) {\n      return null;\n    }\n    return trimmed;\n  }\n\n  function mergeErrorMaps(target, source) {\n    const result = Object.create(null);\n    if (target) {\n      Object.keys(target).forEach((key) => {\n        const normalizedKey = normalizeErrorKey(key);\n        if (!normalizedKey) {\n          return;\n        }\n        result[normalizedKey] = Number(target[key]) || 0;\n      });\n    }\n    if (!source) {\n      return result;\n    }\n    Object.keys(source).forEach((key) => {\n      const normalizedKey = normalizeErrorKey(key);\n      if (!normalizedKey) {\n        return;\n      }\n      const current = Number(result[normalizedKey]) || 0;\n      const addition = Number(source[key]) || 0;\n      result[normalizedKey] = current + addition;\n    });\n    return result;\n  }\n\n  function cloneErrors(source) {\n    if (!source) {\n      return null;\n    }\n    const result = Object.create(null);\n    Object.keys(source).forEach((key) => {\n      const normalizedKey = normalizeErrorKey(key);\n      if (!normalizedKey) {\n        return;\n      }\n      result[normalizedKey] = Number(source[key]) || 0;\n    });\n    return result;\n  }\n\n  function pickFailureType(current, candidate) {\n    const priority = {\n      all_timeout: 3,\n      partial_timeout: 2,\n      mixed: 1,\n    };\n    const normalizedCurrent = current || '';\n    const normalizedCandidate = candidate || '';\n    const currentPriority = priority[normalizedCurrent] || 0;\n    const candidatePriority = priority[normalizedCandidate] || 0;\n    if (candidatePriority > currentPriority) {\n      return normalizedCandidate;\n    }\n    return normalizedCurrent;\n  }\n\n  function cloneStat(stat) {\n    const out = {...stat};\n    out.errors = cloneErrors(stat && stat.errors);\n    if (Array.isArray(stat && stat.mpls)) {\n      out.mpls = [...stat.mpls];\n    }\n    return out;\n  }\n\n  function isKnownStat(stat) {\n    const hasIp = stat && stat.ip && String(stat.ip).trim();\n    const hasHost = stat && stat.host && String(stat.host).trim();\n    return !!(hasIp || hasHost);\n  }\n\n  function aggregateUnknown(group) {\n    const acc = {\n      sent: 0,\n      loss: 0,\n      errors: null,\n      failureType: '',\n    };\n    group.unknown.forEach(({stat}) => {\n      acc.sent += Number(stat.sent) || 0;\n      acc.loss += Number(stat.loss_count) || 0;\n      acc.errors = mergeErrorMaps(acc.errors, stat.errors || null);\n      acc.failureType = pickFailureType(acc.failureType, stat.failure_type || '');\n    });\n    return acc;\n  }\n\n  function mergeUnknownIntoSingleKnown(rows) {\n    const ttlGroups = new Map();\n    rows.forEach((stat, idx) => {\n      if (!stat) {\n        return;\n      }\n      const ttl = Number(stat.ttl) || 0;\n      let group = ttlGroups.get(ttl);\n      if (!group) {\n        group = {known: [], unknown: []};\n        ttlGroups.set(ttl, group);\n      }\n      if (isKnownStat(stat)) {\n        group.known.push({idx, stat});\n      } else {\n        group.unknown.push({idx, stat});\n      }\n    });\n\n    const mergedUnknownIdx = new Set();\n    ttlGroups.forEach((group) => {\n      if (group.known.length !== 1 || group.unknown.length === 0) {\n        return;\n      }\n      const primary = group.known[0].stat;\n      const unknown = aggregateUnknown(group);\n      const existingSent = Number(primary.sent) || 0;\n      const existingLoss = Number(primary.loss_count) || 0;\n      const totalSent = existingSent + unknown.sent;\n      const totalLoss = existingLoss + unknown.loss;\n      primary.sent = totalSent;\n      primary.loss_count = totalLoss;\n      primary.loss_percent = totalSent > 0 ? (totalLoss / totalSent) * 100 : 0;\n      primary.received = Math.max(0, totalSent - totalLoss);\n      primary.errors = mergeErrorMaps(primary.errors, unknown.errors);\n      primary.failure_type = pickFailureType(primary.failure_type, unknown.failureType);\n\n      group.unknown.forEach(({idx}) => mergedUnknownIdx.add(idx));\n    });\n\n    return rows.filter((_, idx) => !mergedUnknownIdx.has(idx));\n  }\n\n  function normalizeRenderableMTRStats(stats) {\n    const rows = Array.isArray(stats) ? stats.map(cloneStat) : [];\n    return mergeUnknownIntoSingleKnown(rows);\n  }\n\n  return {\n    normalizeRenderableMTRStats,\n  };\n});\n"
  },
  {
    "path": "server/web/assets/mtr_agg.test.cjs",
    "content": "const test = require('node:test');\nconst assert = require('node:assert/strict');\n\nconst { normalizeRenderableMTRStats } = require('./mtr_agg.js');\n\ntest('merges unknown stats into the only known path for a ttl', () => {\n  const rows = normalizeRenderableMTRStats([\n    { ttl: 1, sent: 3, received: 0, loss_count: 3, failure_type: 'all_timeout' },\n    { ttl: 1, ip: '1.1.1.1', host: 'one.one.one.one', sent: 1, received: 1, loss_count: 0, failure_type: '' },\n  ]);\n\n  assert.equal(rows.length, 1);\n  assert.equal(rows[0].ip, '1.1.1.1');\n  assert.equal(rows[0].sent, 4);\n  assert.equal(rows[0].loss_count, 3);\n  assert.equal(rows[0].received, 1);\n  assert.equal(rows[0].failure_type, 'all_timeout');\n});\n\ntest('preserves unknown row for multipath ttl instead of merging into the first known path', () => {\n  const rows = normalizeRenderableMTRStats([\n    { ttl: 2, sent: 2, received: 0, loss_count: 2, failure_type: 'all_timeout' },\n    { ttl: 2, ip: '2.2.2.2', host: 'a.example', sent: 3, received: 3, loss_count: 0, failure_type: '' },\n    { ttl: 2, ip: '2.2.2.3', host: 'b.example', sent: 4, received: 4, loss_count: 0, failure_type: '' },\n  ]);\n\n  assert.equal(rows.length, 3);\n\n  const unknown = rows.find((row) => !row.ip && !row.host);\n  assert.ok(unknown);\n  assert.equal(unknown.sent, 2);\n  assert.equal(unknown.loss_count, 2);\n\n  const firstKnown = rows.find((row) => row.ip === '2.2.2.2');\n  assert.equal(firstKnown.sent, 3);\n  assert.equal(firstKnown.loss_count, 0);\n});\n"
  },
  {
    "path": "server/web/assets/mtr_truncation.test.cjs",
    "content": "/**\n * Tests for the mtrRawKnownFinalTTL truncation logic in app.js.\n *\n * Since ingestMTRRawRecord lives inside app.js (a browser script with DOM\n * dependencies), we replicate the minimal subset of state and logic here to\n * exercise the truncation behaviour in isolation under Node.js.\n */\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\n\n// --- Minimal replica of app.js globals & helpers needed for truncation ---\n\nfunction createCtx(resolvedIP) {\n  const ctx = {\n    mtrRawAggStore: new Map(),\n    mtrRawOrderSeq: 0,\n    mtrRawKnownFinalTTL: Infinity,\n    latestSummary: { resolved_ip: resolvedIP },\n  };\n\n  function mtrRawKey(rec) {\n    const ttl = Number(rec && rec.ttl);\n    const ip = rec && rec.ip ? String(rec.ip).trim() : '';\n    const host = rec && rec.host ? String(rec.host).trim().toLowerCase() : '';\n    if (ip) return `${ttl}|ip:${ip}`;\n    if (host) return `${ttl}|host:${host}`;\n    return `${ttl}|unknown`;\n  }\n\n  /** Mirrors the truncation + ingestion logic from app.js ingestMTRRawRecord */\n  ctx.ingest = function ingest(rec) {\n    if (!rec || !Number.isFinite(Number(rec.ttl))) return;\n    const ttl = Number(rec.ttl);\n\n    if (ttl > ctx.mtrRawKnownFinalTTL) return;\n\n    const resolvedIP = ctx.latestSummary && ctx.latestSummary.resolved_ip\n      ? String(ctx.latestSummary.resolved_ip).trim() : '';\n    const recIP = rec.ip ? String(rec.ip).trim() : '';\n    if (rec.success && recIP && resolvedIP && recIP === resolvedIP && ttl < ctx.mtrRawKnownFinalTTL) {\n      ctx.mtrRawKnownFinalTTL = ttl;\n      for (const [k, v] of ctx.mtrRawAggStore) {\n        if (v.ttl > ctx.mtrRawKnownFinalTTL) {\n          ctx.mtrRawAggStore.delete(k);\n        }\n      }\n    }\n\n    const key = mtrRawKey(rec);\n    let row = ctx.mtrRawAggStore.get(key);\n    if (!row) {\n      row = {\n        ttl, host: '', ip: '',\n        sent: 0, received: 0,\n        _order: ctx.mtrRawOrderSeq++,\n      };\n      ctx.mtrRawAggStore.set(key, row);\n    }\n    row.sent += 1;\n    if (rec.ip) row.ip = String(rec.ip).trim();\n    if (rec.host) row.host = String(rec.host).trim();\n    if (rec.success) row.received += 1;\n  };\n\n  ctx.ttls = function () {\n    return Array.from(ctx.mtrRawAggStore.values())\n      .sort((a, b) => a.ttl - b.ttl || a._order - b._order)\n      .map((r) => r.ttl);\n  };\n\n  ctx.rows = function () {\n    return Array.from(ctx.mtrRawAggStore.values())\n      .sort((a, b) => a.ttl - b.ttl || a._order - b._order);\n  };\n\n  return ctx;\n}\n\n// --- Tests ---\n\ntest('drops records beyond knownFinalTTL', () => {\n  const ctx = createCtx('10.0.0.1');\n  // Simulate: TTL 1-3 arrive, then TTL 2 hits destination.\n  ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true });\n  ctx.ingest({ ttl: 3, ip: '10.0.0.5', success: true }); // not destination\n  ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true }); // destination!\n\n  assert.equal(ctx.mtrRawKnownFinalTTL, 2);\n  // TTL 3 should have been pruned.\n  assert.deepEqual(ctx.ttls(), [1, 2]);\n\n  // Further TTL 3 records should be silently dropped.\n  ctx.ingest({ ttl: 3, ip: '10.0.0.5', success: true });\n  assert.deepEqual(ctx.ttls(), [1, 2]);\n});\n\ntest('prunes stale high-TTL entries when finalTTL lowers', () => {\n  const ctx = createCtx('10.0.0.1');\n  // Initial burst: TTL 1-5 all arrive before any destination is known.\n  ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true });\n  ctx.ingest({ ttl: 2, ip: '10.0.0.2', success: true });\n  ctx.ingest({ ttl: 3, ip: '10.0.0.3', success: true });\n  ctx.ingest({ ttl: 4, ip: '10.0.0.4', success: true });\n  ctx.ingest({ ttl: 5, ip: '10.0.0.1', success: true }); // first destination at TTL 5\n  assert.equal(ctx.mtrRawKnownFinalTTL, 5);\n  assert.deepEqual(ctx.ttls(), [1, 2, 3, 4, 5]);\n\n  // Later: destination found at lower TTL 3.\n  ctx.ingest({ ttl: 3, ip: '10.0.0.1', success: true });\n  assert.equal(ctx.mtrRawKnownFinalTTL, 3);\n  // TTL 4 and 5 should be pruned; TTL 3 now has two paths (10.0.0.3 + 10.0.0.1).\n  const ips = ctx.rows().map((r) => `${r.ttl}:${r.ip}`);\n  assert.deepEqual(ips, ['1:192.168.0.1', '2:10.0.0.2', '3:10.0.0.3', '3:10.0.0.1']);\n  // No TTL > 3 remains.\n  assert.ok(ctx.ttls().every((t) => t <= 3));\n});\n\ntest('timeout records at high TTL are also dropped once finalTTL is set', () => {\n  const ctx = createCtx('10.0.0.1');\n  ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true });\n  ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true }); // destination\n  assert.equal(ctx.mtrRawKnownFinalTTL, 2);\n\n  // Late-arriving timeout for TTL 5 should be silently dropped.\n  ctx.ingest({ ttl: 5, success: false });\n  assert.deepEqual(ctx.ttls(), [1, 2]);\n});\n\ntest('non-destination IP at same TTL does not trigger truncation', () => {\n  const ctx = createCtx('10.0.0.1');\n  ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true });\n  ctx.ingest({ ttl: 2, ip: '10.0.0.2', success: true }); // not destination\n  ctx.ingest({ ttl: 3, ip: '10.0.0.3', success: true }); // not destination\n\n  assert.equal(ctx.mtrRawKnownFinalTTL, Infinity);\n  assert.deepEqual(ctx.ttls(), [1, 2, 3]);\n});\n\ntest('does not crash with missing latestSummary.resolved_ip', () => {\n  const ctx = createCtx('');\n  ctx.ingest({ ttl: 1, ip: '10.0.0.1', success: true });\n  ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true });\n  // Without resolved_ip, nothing should be truncated.\n  assert.equal(ctx.mtrRawKnownFinalTTL, Infinity);\n  assert.deepEqual(ctx.ttls(), [1, 2]);\n});\n\ntest('reset clears knownFinalTTL (simulated clearResult)', () => {\n  const ctx = createCtx('10.0.0.1');\n  ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true });\n  ctx.ingest({ ttl: 2, ip: '10.0.0.1', success: true });\n  assert.equal(ctx.mtrRawKnownFinalTTL, 2);\n\n  // Simulate clearResult(true).\n  ctx.mtrRawAggStore = new Map();\n  ctx.mtrRawOrderSeq = 0;\n  ctx.mtrRawKnownFinalTTL = Infinity;\n\n  assert.equal(ctx.mtrRawKnownFinalTTL, Infinity);\n\n  // New session should work fresh.\n  ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true });\n  ctx.ingest({ ttl: 5, ip: '10.0.0.1', success: true });\n  assert.equal(ctx.mtrRawKnownFinalTTL, 5);\n  assert.deepEqual(ctx.ttls(), [1, 5]);\n});\n\ntest('destination at TTL equal to current finalTTL does not re-prune', () => {\n  const ctx = createCtx('10.0.0.1');\n  ctx.ingest({ ttl: 1, ip: '192.168.0.1', success: true });\n  ctx.ingest({ ttl: 3, ip: '10.0.0.1', success: true }); // set finalTTL=3\n  ctx.ingest({ ttl: 2, ip: '10.0.0.2', success: true });\n  assert.equal(ctx.mtrRawKnownFinalTTL, 3);\n\n  // Same TTL destination (ttl === mtrRawKnownFinalTTL): should not lower.\n  ctx.ingest({ ttl: 3, ip: '10.0.0.1', success: true });\n  assert.equal(ctx.mtrRawKnownFinalTTL, 3);\n  assert.deepEqual(ctx.ttls(), [1, 2, 3]);\n});\n"
  },
  {
    "path": "server/web/assets/style.css",
    "content": "*,\n*::before,\n*::after {\n  box-sizing: border-box;\n}\n\nbody {\n  margin: 0;\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Arial, sans-serif;\n  background: #0f172a;\n  color: #e2e8f0;\n  line-height: 1.6;\n}\n\na {\n  color: #60a5fa;\n  text-decoration: none;\n}\n\na:hover {\n  text-decoration: underline;\n}\n\n.header {\n  padding: 1.75rem 1.25rem 0.85rem;\n}\n\n.header__top {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: space-between;\n  gap: 0.75rem;\n}\n\n.header__title h1 {\n  margin: 0;\n  font-size: 1.6rem;\n  font-weight: 600;\n}\n\n.header__subtitle {\n  margin: 0.65rem 0 0;\n  color: #94a3b8;\n}\n\n.header__actions {\n  display: flex;\n  gap: 0.65rem;\n}\n\n.action-btn {\n  padding: 0.5rem 1rem;\n  border-radius: 0.55rem;\n  border: 1px solid rgba(148, 163, 184, 0.35);\n  background: rgba(15, 23, 42, 0.6);\n  color: #e2e8f0;\n  font-size: 0.88rem;\n  font-weight: 500;\n  cursor: pointer;\n  transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n.action-btn:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 8px 20px rgba(56, 189, 248, 0.15);\n}\n\n.action-btn--primary {\n  background: linear-gradient(135deg, #38bdf8, #6366f1);\n  color: #0f172a;\n  border-color: transparent;\n}\n\n.action-btn--ghost {\n  border-color: rgba(148, 163, 184, 0.45);\n}\n\n@media (max-width: 640px) {\n  .header__top {\n    flex-direction: column;\n    align-items: flex-start;\n  }\n\n  .header__actions {\n    width: 100%;\n    flex-wrap: wrap;\n    justify-content: flex-start;\n  }\n}\n\n.container {\n  max-width: 1100px;\n  margin: 0 auto;\n  padding: 0 1.25rem 2.25rem;\n  display: flex;\n  flex-wrap: wrap;\n  gap: 1.25rem;\n  align-items: flex-start;\n}\n\n@media (max-width: 1024px) {\n  .container {\n    flex-direction: column;\n    padding: 0 1rem 2.1rem;\n  }\n}\n\n.panel {\n  background: rgba(15, 23, 42, 0.85);\n  border: 1px solid rgba(100, 116, 139, 0.2);\n  border-radius: 0.9rem;\n  padding: 1.25rem;\n  box-shadow: 0 10px 32px rgba(15, 23, 42, 0.35);\n  backdrop-filter: blur(10px);\n}\n\n.panel--form {\n  flex: 0 0 320px;\n  max-width: 360px;\n  display: flex;\n  flex-direction: column;\n  gap: 0.8rem;\n}\n\n.panel--results {\n  flex: 1 1 0;\n  min-width: 320px;\n  display: flex;\n  flex-direction: column;\n  gap: 0.75rem;\n  min-height: calc(100vh - 260px);\n}\n\n@media (max-width: 1024px) {\n  .panel--form,\n  .panel--results {\n    flex: 1 1 100%;\n    max-width: 100%;\n    min-height: auto;\n  }\n}\n\n.form {\n  display: flex;\n  flex-direction: column;\n  gap: 0.8rem;\n}\n\n.form__group {\n  display: flex;\n  flex-direction: column;\n  gap: 0.4rem;\n}\n\n.panel--form .form__group {\n  padding: 0.35rem 0.75rem;\n  border-radius: 0.65rem;\n  background: rgba(15, 23, 42, 0.42);\n  border: 1px solid rgba(71, 85, 105, 0.35);\n}\n\n.panel--form .form__group.grid {\n  padding: 0;\n  border: none;\n  background: transparent;\n  gap: 0.8rem;\n}\n\n.panel--form .form__group.grid > div {\n  padding: 0.35rem 0.75rem;\n  border-radius: 0.65rem;\n  background: rgba(15, 23, 42, 0.42);\n  border: 1px solid rgba(71, 85, 105, 0.35);\n}\n\n.panel--form .form__group.checkbox-group {\n  display: flex;\n  align-items: center;\n  padding: 0.35rem 0.75rem;\n  border-radius: 0.65rem;\n  background: rgba(15, 23, 42, 0.42);\n  border: 1px solid rgba(71, 85, 105, 0.35);\n}\n\n.form__group small {\n  font-size: 0.75rem;\n  color: #94a3b8;\n}\n\n.form__group.disabled input {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n.form__group.disabled label,\n.form__group.disabled small {\n  opacity: 0.65;\n}\n\n.form__group.grid {\n  display: grid;\n  gap: 0.75rem;\n}\n\n@media (min-width: 640px) {\n  .form__group.grid {\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n  }\n}\n\nlabel {\n  font-size: 0.95rem;\n  color: #cbd5f5;\n}\n\ninput[type=\"text\"],\ninput[type=\"number\"],\nselect {\n  width: 100%;\n  padding: 0.65rem 0.75rem;\n  border: 1px solid rgba(148, 163, 184, 0.35);\n  border-radius: 0.6rem;\n  background: rgba(15, 23, 42, 0.6);\n  color: #e2e8f0;\n  transition: border-color 0.2s ease, box-shadow 0.2s ease;\n}\n\ninput:focus,\nselect:focus {\n  outline: none;\n  border-color: #38bdf8;\n  box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.3);\n}\n\n.checkbox {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.5rem;\n  cursor: pointer;\n  user-select: none;\n}\n\n.checkbox input {\n  width: 1.05rem;\n  height: 1.05rem;\n}\n\n.form__actions {\n  display: flex;\n  justify-content: flex-end;\n  gap: 0.65rem;\n}\n\nbutton[type=\"submit\"] {\n  padding: 0.65rem 1.4rem;\n  border: none;\n  border-radius: 0.55rem;\n  background: linear-gradient(135deg, #38bdf8, #6366f1);\n  color: #0f172a;\n  font-weight: 600;\n  cursor: pointer;\n  transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\nbutton[type=\"submit\"]:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 10px 26px rgba(56, 189, 248, 0.35);\n}\n\nbutton[type=\"submit\"]:disabled {\n  opacity: 0.6;\n  cursor: wait;\n  transform: none;\n  box-shadow: none;\n}\n\n.status {\n  padding: 0.65rem 0.85rem;\n  border-radius: 0.55rem;\n  font-size: 0.92rem;\n}\n\n.status--idle {\n  background: rgba(148, 163, 184, 0.12);\n  color: #cbd5f5;\n}\n\n.status--running {\n  background: rgba(56, 189, 248, 0.15);\n  color: #38bdf8;\n}\n\n.status--error {\n  background: rgba(248, 113, 113, 0.15);\n  color: #f87171;\n}\n\n.status--success {\n  background: rgba(74, 222, 128, 0.12);\n  color: #4ade80;\n}\n\n.result-meta {\n  font-size: 0.9rem;\n  color: #cbd5f5;\n  margin-bottom: 1rem;\n  display: grid;\n  gap: 0.35rem;\n}\n\n.results-header {\n  display: flex;\n  flex-direction: column;\n  gap: 0.75rem;\n}\n\n@media (min-width: 600px) {\n  .results-header {\n    flex-direction: row;\n    align-items: center;\n    justify-content: space-between;\n  }\n}\n\n.panel--results #result-meta {\n  flex-shrink: 0;\n  max-width: 420px;\n}\n\n#result {\n  flex: 1 1 auto;\n  overflow: auto;\n  min-height: 0;\n  max-height: 100%;\n}\n\n.result table {\n  width: 100%;\n  border-collapse: collapse;\n  min-width: 520px;\n}\n\nbody.mode-mtr .container {\n  max-width: 1280px;\n  padding: 0 1.5rem 2.5rem;\n  gap: 1.5rem;\n}\n\nbody.mode-mtr .panel--form {\n  flex: 0 0 clamp(320px, 28vw, 440px);\n  max-width: clamp(340px, 30vw, 460px);\n}\n\nbody.mode-mtr .panel--results {\n  flex: 1 1 clamp(520px, 60vw, 920px);\n  min-width: clamp(360px, 55vw, 920px);\n  gap: 0.85rem;\n  min-height: calc(100vh - 240px);\n}\n\nbody.mode-mtr #group-basic-params,\nbody.mode-mtr #group-advanced-params,\nbody.mode-mtr #group-disable-map {\n  display: none;\n}\n\n.hidden {\n  display: none !important;\n}\n\nbody.mode-mtr .panel--results #result-meta {\n  max-width: 560px;\n}\n\nbody.mode-mtr .result table {\n  min-width: 680px;\n}\n\n.result thead {\n  background: rgba(15, 23, 42, 0.8);\n}\n\n.result th,\n.result td {\n  padding: 0.6rem 0.75rem;\n  border-bottom: 1px solid rgba(51, 65, 85, 0.5);\n  text-align: left;\n  font-size: 0.9rem;\n}\n\n.result tbody tr:hover {\n  background: rgba(56, 189, 248, 0.08);\n}\n\n.mtr-host-cell {\n  font-weight: 500;\n  color: #e2e8f0;\n  line-height: 1.4;\n}\n\n.mtr-host-cell .mtr-hostname {\n  display: inline-block;\n  font-size: 0.82em;\n  color: rgba(148, 163, 184, 0.85);\n  font-weight: 500;\n}\n\n.mtr-host-cell .attempt__geo {\n  margin-top: 0.25rem;\n  display: block;\n}\n\n.mtr-host-cell .mtr-mpls {\n  margin-top: 0.2rem;\n  display: block;\n  font-size: 0.78em;\n  color: rgba(148, 163, 184, 0.7);\n  font-weight: 400;\n  white-space: pre-line;\n}\n\n.attempts {\n  display: flex;\n  flex-direction: column;\n  gap: 0.35rem;\n}\n\n.attempts--grouped {\n  gap: 0.55rem;\n}\n\n.attempt {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.4rem 0.7rem;\n  padding: 0.4rem 0.65rem;\n  border-radius: 0.45rem;\n  background: rgba(30, 41, 59, 0.6);\n  border: 1px solid rgba(71, 85, 105, 0.35);\n}\n\n.attempt--group {\n  flex-direction: column;\n  gap: 0.4rem;\n  padding: 0.55rem 0.75rem;\n}\n\n.attempt__header {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.6rem;\n  font-weight: 600;\n  color: #e2e8f0;\n}\n\n.attempt__star {\n  font-size: 1rem;\n  color: #e2e8f0;\n}\n\n.attempt__badge {\n  padding: 0.1rem 0.45rem;\n  border-radius: 999px;\n  font-size: 0.75rem;\n  font-weight: 600;\n  background: rgba(94, 234, 212, 0.18);\n  color: #5eead4;\n}\n\n.attempt__badge--fail {\n  background: rgba(248, 113, 113, 0.18);\n  color: #fca5a5;\n}\n\n.attempt__meta {\n  display: flex;\n  gap: 0.5rem;\n  flex-wrap: wrap;\n  font-size: 0.85rem;\n  color: #e2e8f0;\n}\n\n.attempt__probes {\n  display: flex;\n  gap: 0.35rem;\n  flex-wrap: wrap;\n}\n\n.attempt__geo {\n  font-size: 0.8rem;\n  color: #94a3b8;\n}\n\nbody:not(.mode-mtr) .attempt__meta {\n  font-size: 0.9rem;\n}\n\nbody:not(.mode-mtr) .attempt__geo {\n  font-size: 0.9rem;\n  color: #e2e8f0;\n}\n\nbody:not(.mode-mtr) .attempt__mpls {\n  font-size: 0.85rem;\n  color: #94a3b8;\n  line-height: 1.35;\n  margin-top: 0.2rem;\n}\n\n\n.footer {\n  padding: 1.5rem;\n  text-align: center;\n  font-size: 0.85rem;\n  color: #64748b;\n  border-top: 1px solid rgba(71, 85, 105, 0.3);\n}\n\n.footer span {\n  display: block;\n}\n"
  },
  {
    "path": "server/web/assets/trace_form.js",
    "content": "(function (root, factory) {\n  const api = factory();\n  if (typeof module !== 'undefined' && module.exports) {\n    module.exports = api;\n  }\n  root.NextTraceForm = api;\n})(typeof globalThis !== 'undefined' ? globalThis : this, function () {\n  function readNumericValueFromRaw(raw) {\n    const text = String(raw ?? '').trim();\n    if (text === '') {\n      return undefined;\n    }\n    const num = Number(text);\n    return Number.isFinite(num) ? num : undefined;\n  }\n\n  function defaultOptionValue(defaultOptions, key, fallback) {\n    if (defaultOptions && Object.prototype.hasOwnProperty.call(defaultOptions, key)) {\n      return defaultOptions[key];\n    }\n    return fallback;\n  }\n\n  function buildTracePayload(values) {\n    const payload = {\n      target: String(values.target || '').trim(),\n      protocol: values.protocol,\n      data_provider: values.dataProvider,\n      disable_maptrace: Boolean(values.disableMaptrace),\n      language: values.language,\n      mode: values.mode || 'single',\n    };\n\n    const isMtrMode = payload.mode === 'mtr';\n    if (isMtrMode) {\n      payload.queries = 10;\n      payload.hop_interval_ms = 1000;\n      payload.max_rounds = 0;\n    } else {\n      const queries = readNumericValueFromRaw(values.queries);\n      if (queries !== undefined) {\n        payload.queries = Math.max(1, Math.min(63, queries));\n      }\n    }\n\n    const maxHops = readNumericValueFromRaw(values.maxHops);\n    if (maxHops !== undefined) {\n      payload.max_hops = maxHops;\n    }\n\n    const dstPort = readNumericValueFromRaw(values.dstPort);\n    if (dstPort !== undefined) {\n      payload.port = dstPort;\n    }\n\n    const packetSize = readNumericValueFromRaw(values.packetSize);\n    if (packetSize !== undefined) {\n      payload.packet_size = packetSize;\n    }\n\n    const tos = readNumericValueFromRaw(values.tos);\n    if (tos !== undefined) {\n      payload.tos = tos;\n    }\n\n    return payload;\n  }\n\n  return {\n    buildTracePayload,\n    defaultOptionValue,\n    readNumericValueFromRaw,\n  };\n});\n"
  },
  {
    "path": "server/web/assets/trace_form.test.cjs",
    "content": "const test = require('node:test');\nconst assert = require('node:assert/strict');\n\nconst { buildTracePayload, defaultOptionValue } = require('./trace_form.js');\n\ntest('buildTracePayload preserves negative packet_size and zero tos', () => {\n  const payload = buildTracePayload({\n    target: '1.1.1.1',\n    protocol: 'icmp',\n    dataProvider: 'LeoMoeAPI',\n    disableMaptrace: false,\n    language: 'cn',\n    mode: 'single',\n    queries: '3',\n    maxHops: '30',\n    dstPort: '',\n    packetSize: '-123',\n    tos: '0',\n  });\n\n  assert.equal(payload.packet_size, -123);\n  assert.equal(payload.tos, 0);\n  assert.equal(payload.queries, 3);\n});\n\ntest('buildTracePayload carries packet_size and tos in mtr mode', () => {\n  const payload = buildTracePayload({\n    target: 'example.com',\n    protocol: 'udp',\n    dataProvider: 'disable-geoip',\n    disableMaptrace: true,\n    language: 'en',\n    mode: 'mtr',\n    queries: '5',\n    maxHops: '20',\n    dstPort: '33494',\n    packetSize: '80',\n    tos: '255',\n  });\n\n  assert.equal(payload.mode, 'mtr');\n  assert.equal(payload.queries, 10);\n  assert.equal(payload.hop_interval_ms, 1000);\n  assert.equal(payload.max_rounds, 0);\n  assert.equal(payload.packet_size, 80);\n  assert.equal(payload.tos, 255);\n});\n\ntest('defaultOptionValue keeps explicit zero', () => {\n  assert.equal(defaultOptionValue({ tos: 0 }, 'tos', 5), 0);\n  assert.equal(defaultOptionValue({}, 'tos', 5), 5);\n});\n\ntest('defaultOptionValue preserves explicit null for auto packet size', () => {\n  assert.equal(defaultOptionValue({ packet_size: null }, 'packet_size', 52), null);\n});\n"
  },
  {
    "path": "server/web/index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"utf-8\">\n  <title>NextTrace Web</title>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link rel=\"stylesheet\" href=\"/assets/style.css\">\n</head>\n<body>\n  <header class=\"header\">\n    <div class=\"header__top\">\n      <div class=\"header__title\">\n        <h1 id=\"title-text\">NextTrace Web</h1>\n      </div>\n      <div class=\"header__actions\">\n        <button type=\"button\" id=\"cache-btn\" class=\"action-btn action-btn--ghost\">清空缓存</button>\n        <button type=\"button\" id=\"lang-toggle\" class=\"action-btn action-btn--primary\">English</button>\n      </div>\n    </div>\n    <p class=\"header__subtitle\" id=\"subtitle-text\">在浏览器中运行 NextTrace，实时查看路由探测结果。</p>\n  </header>\n\n  <main class=\"container\">\n    <section class=\"panel panel--form\">\n      <form id=\"trace-form\" class=\"form\">\n        <div class=\"form__group\">\n          <label for=\"mode\" id=\"label-mode\">探测模式</label>\n          <select id=\"mode\" name=\"mode\">\n            <option value=\"single\">单次探测</option>\n            <option value=\"mtr\">持续探测</option>\n          </select>\n        </div>\n\n        <div class=\"form__group\">\n          <label for=\"target\" id=\"label-target\">目标地址</label>\n          <input id=\"target\" name=\"target\" type=\"text\" placeholder=\"例如：1.1.1.1 或 www.example.com\" required>\n        </div>\n\n        <div class=\"form__group grid\">\n          <div>\n            <label for=\"protocol\" id=\"label-protocol\">协议</label>\n            <select id=\"protocol\" name=\"protocol\"></select>\n          </div>\n          <div>\n            <label for=\"data-provider\" id=\"label-provider\">地理信息源</label>\n            <select id=\"data-provider\" name=\"dataProvider\"></select>\n          </div>\n        </div>\n\n        <div class=\"form__group grid\" id=\"group-basic-params\">\n          <div>\n            <label for=\"queries\" id=\"label-queries\">每跳探测次数</label>\n            <input id=\"queries\" name=\"queries\" type=\"number\" min=\"1\" max=\"63\">\n          </div>\n          <div>\n            <label for=\"max-hops\" id=\"label-maxhops\">最大跳数</label>\n            <input id=\"max-hops\" name=\"maxHops\" type=\"number\" min=\"1\" max=\"64\">\n          </div>\n        </div>\n\n        <div class=\"form__group grid\" id=\"group-advanced-params\">\n          <div>\n            <label for=\"dst-port\" id=\"label-dst-port\">目的端口</label>\n            <input id=\"dst-port\" name=\"dstPort\" type=\"number\" min=\"1\" max=\"65535\" disabled>\n            <small id=\"dst-port-hint\">仅 TCP/UDP 模式有效</small>\n          </div>\n          <div>\n            <label for=\"payload-size\" id=\"label-psize\">探测包大小</label>\n            <input id=\"payload-size\" name=\"payloadSize\" type=\"number\" step=\"1\">\n          </div>\n        </div>\n\n        <div class=\"form__group grid\">\n          <div>\n            <label for=\"tos\" id=\"label-tos\">TOS</label>\n            <input id=\"tos\" name=\"tos\" type=\"number\" min=\"0\" max=\"255\">\n          </div>\n        </div>\n\n        <div class=\"form__group checkbox-group\" id=\"group-disable-map\">\n          <label class=\"checkbox\">\n            <input type=\"checkbox\" id=\"disable-maptrace\" name=\"disableMaptrace\">\n            <span id=\"label-disable-map\">禁用地图生成</span>\n          </label>\n        </div>\n\n        <div class=\"form__actions\">\n          <button type=\"submit\" id=\"submit-btn\">开始探测</button>\n          <button type=\"button\" id=\"stop-btn\" class=\"action-btn action-btn--ghost hidden\">停止</button>\n        </div>\n      </form>\n    </section>\n\n    <section class=\"panel panel--results\">\n      <div class=\"results-header\">\n        <div id=\"status\" class=\"status status--idle\">准备就绪</div>\n        <div id=\"result-meta\" class=\"result-meta hidden\"></div>\n      </div>\n      <div id=\"result\" class=\"result hidden\"></div>\n    </section>\n  </main>\n\n  <footer class=\"footer\">\n    <span id=\"footer-text\">当前会话仅提供基础功能，更多高级选项请使用 CLI。</span>\n  </footer>\n\n  <script src=\"/assets/mtr_agg.js\" defer></script>\n  <script src=\"/assets/trace_form.js\" defer></script>\n  <script src=\"/assets/app.js\" defer></script>\n</body>\n</html>\n"
  },
  {
    "path": "server/ws_handler.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nvar traceUpgrader = websocket.Upgrader{\n\tReadBufferSize:  1024,\n\tWriteBufferSize: 1024,\n\tCheckOrigin: func(r *http.Request) bool {\n\t\treturn browserOriginAllowed(r)\n\t},\n}\n\nconst (\n\twsSendQueueSize = 1024\n\twsWriteTimeout  = 5 * time.Second\n)\n\nvar (\n\terrWSSlowConsumer  = errors.New(\"websocket client too slow for mtr stream\")\n\terrWSSessionClosed = errors.New(\"websocket session closed\")\n\ttraceTracerouteFn  = trace.Traceroute\n\ttraceRunMTRRawFn   = trace.RunMTRRaw\n)\n\n// sanitizeLogParam 清理用户输入中的换行和控制字符，防止日志注入。\nfunc sanitizeLogParam(s string) string {\n\tvar b strings.Builder\n\tb.Grow(len(s))\n\tfor _, r := range s {\n\t\tif r == '\\n' || r == '\\r' {\n\t\t\tb.WriteString(\"\\\\n\")\n\t\t} else if r < 0x20 && r != '\\t' {\n\t\t\t// 保留 tab，替换其他 C0 控制字符\n\t\t\tb.WriteRune('\\uFFFD')\n\t\t} else {\n\t\t\tb.WriteRune(r)\n\t\t}\n\t}\n\treturn b.String()\n}\n\nfunc newWSSessionContext(parent context.Context) (context.Context, context.CancelFunc) {\n\tif parent == nil {\n\t\tparent = context.Background()\n\t}\n\treturn context.WithCancel(parent)\n}\n\ntype wsEnvelope struct {\n\tType   string      `json:\"type\"`\n\tData   interface{} `json:\"data,omitempty\"`\n\tError  string      `json:\"error,omitempty\"`\n\tStatus int         `json:\"status,omitempty\"`\n}\n\ntype wsConn interface {\n\tWriteJSON(v interface{}) error\n\tSetWriteDeadline(t time.Time) error\n\tWriteControl(messageType int, data []byte, deadline time.Time) error\n\tClose() error\n\tNextReader() (messageType int, r io.Reader, err error)\n}\n\ntype wsInitConn interface {\n\tSetReadDeadline(t time.Time) error\n\tSetReadLimit(limit int64)\n\tReadMessage() (messageType int, p []byte, err error)\n}\n\ntype wsTraceSession struct {\n\tconn       wsConn\n\tsendMu     sync.Mutex\n\tsendCh     chan wsEnvelope\n\tstopCh     chan struct{}\n\twriterDone chan struct{}\n\tcloseOnce  sync.Once\n\tfinishOnce sync.Once\n\tclosed     atomic.Bool\n\tlang       string\n\tseen       map[int]int\n}\n\nfunc newWSTraceSession(conn wsConn, lang string, queueSize int) *wsTraceSession {\n\tif queueSize <= 0 {\n\t\tqueueSize = wsSendQueueSize\n\t}\n\ts := &wsTraceSession{\n\t\tconn:       conn,\n\t\tsendCh:     make(chan wsEnvelope, queueSize),\n\t\tstopCh:     make(chan struct{}),\n\t\twriterDone: make(chan struct{}),\n\t\tlang:       lang,\n\t\tseen:       make(map[int]int),\n\t}\n\tgo s.writeLoop()\n\treturn s\n}\n\nfunc readWSInitMessage(conn wsInitConn) ([]byte, error) {\n\tif err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {\n\t\treturn nil, err\n\t}\n\tconn.SetReadLimit(maxWSInitMessageBytes)\n\t_, message, err := conn.ReadMessage()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := conn.SetReadDeadline(time.Time{}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn message, nil\n}\n\nfunc (s *wsTraceSession) writeLoop() {\n\tdefer close(s.writerDone)\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tlog.Printf(\"[deploy] writeLoop panic: %v\", r)\n\t\t\ts.closeWithCode(websocket.CloseInternalServerErr, \"internal error\")\n\t\t}\n\t}()\n\tfor {\n\t\tselect {\n\t\tcase <-s.stopCh:\n\t\t\treturn\n\t\tcase msg, ok := <-s.sendCh:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdeadline := time.Now().Add(wsWriteTimeout)\n\t\t\t_ = s.conn.SetWriteDeadline(deadline)\n\t\t\terr := s.conn.WriteJSON(msg)\n\t\t\tif err != nil {\n\t\t\t\ts.closeWithCode(websocket.CloseInternalServerErr, \"write failed\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *wsTraceSession) send(msg wsEnvelope) error {\n\ts.sendMu.Lock()\n\tdefer s.sendMu.Unlock()\n\tif s.closed.Load() {\n\t\treturn errWSSessionClosed\n\t}\n\tselect {\n\tcase s.sendCh <- msg:\n\t\treturn nil\n\tdefault:\n\t\ts.closeWithCode(websocket.CloseTryAgainLater, \"client too slow for mtr stream\")\n\t\treturn errWSSlowConsumer\n\t}\n}\n\nfunc (s *wsTraceSession) closeWithCode(code int, reason string) {\n\ts.closed.Store(true)\n\ts.closeOnce.Do(func() {\n\t\tclose(s.stopCh)\n\t\tdeadline := time.Now().Add(wsWriteTimeout)\n\t\t_ = s.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), deadline)\n\t\t_ = s.conn.Close()\n\t})\n}\n\nfunc (s *wsTraceSession) finish() {\n\ts.finishOnce.Do(func() {\n\t\ts.sendMu.Lock()\n\t\twasClosed := s.closed.Swap(true)\n\t\tif !wasClosed {\n\t\t\tclose(s.sendCh)\n\t\t}\n\t\ts.sendMu.Unlock()\n\t\t<-s.writerDone\n\t\ts.closeOnce.Do(func() {\n\t\t\t_ = s.conn.Close()\n\t\t})\n\t})\n}\n\nfunc traceWebsocketHandler(c *gin.Context) {\n\tconn, err := traceUpgrader.Upgrade(c.Writer, c.Request, nil)\n\tif err != nil {\n\t\tlog.Printf(\"[deploy] websocket upgrade failed: %v\", err)\n\t\treturn\n\t}\n\tdefer func() {\n\t\t_ = conn.Close()\n\t}()\n\n\tmessage, err := readWSInitMessage(conn)\n\tif err != nil {\n\t\tlog.Printf(\"[deploy] websocket read failed: %v\", err)\n\t\treturn\n\t}\n\n\tvar req traceRequest\n\tif err := json.Unmarshal(message, &req); err != nil {\n\t\t_ = conn.WriteJSON(wsEnvelope{Type: \"error\", Error: \"invalid request payload\", Status: 400})\n\t\treturn\n\t}\n\n\tsessionCtx, cancel := newWSSessionContext(c.Request.Context())\n\tdefer cancel()\n\tvar sessionRef atomic.Pointer[wsTraceSession]\n\tgo func() {\n\t\tfor {\n\t\t\tif _, _, err := conn.NextReader(); err != nil {\n\t\t\t\tcancel()\n\t\t\t\tif session := sessionRef.Load(); session != nil {\n\t\t\t\t\tsession.closeWithCode(websocket.CloseNormalClosure, \"client disconnected\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tsetup, statusCode, err := prepareTrace(sessionCtx, req)\n\tif err != nil {\n\t\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\t\treturn\n\t\t}\n\t\tif statusCode == 0 {\n\t\t\tstatusCode = 500\n\t\t}\n\t\tlog.Printf(\"[deploy] websocket prepare trace failed target=%s error=%v\", sanitizeLogParam(req.Target), err)\n\t\t_ = conn.WriteJSON(wsEnvelope{Type: \"error\", Error: err.Error(), Status: statusCode})\n\t\treturn\n\t}\n\n\tsession := newWSTraceSession(conn, setup.Config.Lang, wsSendQueueSize)\n\tsessionRef.Store(session)\n\tdefer session.finish()\n\n\tstartPayload := gin.H{\n\t\t\"target\":        setup.Target,\n\t\t\"resolved_ip\":   setup.IP.String(),\n\t\t\"protocol\":      setup.Protocol,\n\t\t\"data_provider\": setup.DataProvider,\n\t\t\"language\":      setup.Config.Lang,\n\t}\n\tif err := session.send(wsEnvelope{Type: \"start\", Data: startPayload}); err != nil {\n\t\tlog.Printf(\"[deploy] websocket send start failed: %v\", err)\n\t\treturn\n\t}\n\n\tlog.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)\n\tlog.Printf(\"[deploy] (ws) target resolved target=%s ip=%s via dot=%s\", sanitizeLogParam(setup.Target), setup.IP, sanitizeLogParam(strings.ToLower(setup.Req.DotServer)))\n\n\tmode := setup.Req.Mode\n\tif mode == \"\" {\n\t\tmode = \"single\"\n\t}\n\n\tswitch mode {\n\tcase \"mtr\", \"continuous\":\n\t\trunMTRTrace(sessionCtx, session, setup)\n\tdefault:\n\t\trunSingleTrace(sessionCtx, session, setup)\n\t}\n}\n\nfunc runSingleTrace(ctx context.Context, session *wsTraceSession, setup *traceExecution) {\n\tsession.seen = make(map[int]int)\n\n\tres, duration, err := executeTrace(ctx, session, setup, func(cfg *trace.Config) {\n\t\tcfg.RealtimePrinter = nil\n\t\tcfg.AsyncPrinter = func(result *trace.Result) {\n\t\t\tfor idx, attempts := range result.Hops {\n\t\t\t\tif len(attempts) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tsnapshot := append([]trace.Hop(nil), attempts...)\n\t\t\t\tnewLen := len(snapshot)\n\t\t\t\tif newLen == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif prevLen, ok := session.seen[idx]; ok && newLen <= prevLen {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tsession.seen[idx] = newLen\n\n\t\t\t\thop := buildHopResponse(snapshot, idx, session.lang)\n\t\t\t\tif len(hop.Attempts) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif err := session.send(wsEnvelope{Type: \"hop\", Data: hop}); err != nil {\n\t\t\t\t\tlog.Printf(\"[deploy] websocket hop send failed ttl=%d err=%v\", hop.TTL, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\tif err != nil {\n\t\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\t\treturn\n\t\t}\n\t\tlog.Printf(\"[deploy] websocket trace failed target=%s error=%v\", sanitizeLogParam(setup.Target), err)\n\t\t_ = session.send(wsEnvelope{Type: \"error\", Error: err.Error(), Status: 500})\n\t\treturn\n\t}\n\n\tif session.closed.Load() {\n\t\treturn\n\t}\n\n\ttraceMapURL := traceMapURLForResult(setup, res)\n\tif traceMapURL != \"\" {\n\t\tlog.Printf(\"[deploy] (ws) trace map generated target=%s url=%s\", sanitizeLogParam(setup.Target), traceMapURL)\n\t}\n\n\tfinal := traceResponse{\n\t\tTarget:       setup.Target,\n\t\tResolvedIP:   setup.IP.String(),\n\t\tProtocol:     setup.Protocol,\n\t\tDataProvider: setup.DataProvider,\n\t\tTraceMapURL:  traceMapURL,\n\t\tLanguage:     setup.Config.Lang,\n\t\tHops:         convertHops(res, setup.Config.Lang),\n\t\tDurationMs:   duration.Milliseconds(),\n\t}\n\n\tif err := session.send(wsEnvelope{Type: \"complete\", Data: final}); err != nil {\n\t\tlog.Printf(\"[deploy] websocket send complete failed: %v\", err)\n\t}\n\tlog.Printf(\"[deploy] (ws) trace completed target=%s hops=%d duration=%s\", sanitizeLogParam(setup.Target), len(final.Hops), duration)\n}\n\nfunc runMTRTrace(parentCtx context.Context, session *wsTraceSession, setup *traceExecution) {\n\thopInterval := resolveWebMTRHopInterval(setup.Req)\n\tmaxPerHop := setup.Req.MaxRounds // 0 = unlimited\n\n\titeration := 0\n\tctx, cancel := context.WithCancel(parentCtx)\n\tdefer cancel()\n\n\terr := executeMTRRaw(ctx, session, setup, trace.MTRRawOptions{\n\t\tHopInterval: hopInterval,\n\t\tMaxPerHop:   maxPerHop,\n\t}, func(rec trace.MTRRawRecord) {\n\t\tif rec.Iteration > iteration {\n\t\t\titeration = rec.Iteration\n\t\t}\n\t\tif err := session.send(wsEnvelope{Type: \"mtr_raw\", Data: rec}); err != nil {\n\t\t\tcancel()\n\t\t}\n\t})\n\tif err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {\n\t\tlog.Printf(\"[deploy] websocket MTR raw trace failed target=%s error=%v\", sanitizeLogParam(setup.Target), err)\n\t\t_ = session.send(wsEnvelope{Type: \"error\", Error: err.Error(), Status: 500})\n\t\treturn\n\t}\n\n\tif !session.closed.Load() {\n\t\t_ = session.send(wsEnvelope{Type: \"complete\", Data: gin.H{\"iteration\": iteration}})\n\t}\n}\n\nfunc executeMTRRaw(ctx context.Context, session *wsTraceSession, setup *traceExecution, opts trace.MTRRawOptions, onRecord trace.MTRRawOnRecord) error {\n\tconfig := setup.Config\n\n\tif session.closed.Load() {\n\t\treturn nil\n\t}\n\n\tif opts.HopInterval > 0 {\n\t\t// Per-hop scheduling only needs LeoMoe/FastIP setup now; the trace runtime\n\t\t// itself no longer depends on per-session mutable globals.\n\t\tlog.Printf(\"[deploy] (ws) starting MTR per-hop trace target=%s resolved=%s method=%s lang=%s maxHops=%d hopInterval=%s maxPerHop=%d\",\n\t\t\tsanitizeLogParam(setup.Target), setup.IP.String(), string(setup.Method), sanitizeLogParam(config.Lang), config.MaxHops, opts.HopInterval, opts.MaxPerHop)\n\n\t\ttraceMu.Lock()\n\t\t_, err := withTraceSetupContext(setup, func() (struct{}, error) {\n\t\t\tif setup.NeedsLeoWS {\n\t\t\t\tensureLeoMoeConnection()\n\t\t\t}\n\t\t\treturn struct{}{}, nil\n\t\t})\n\t\ttraceMu.Unlock()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn traceRunMTRRawFn(ctx, setup.Method, config, opts, onRecord)\n\t}\n\n\t// Legacy round-based path: inject RunRound with per-round locking.\n\tlog.Printf(\"[deploy] (ws) starting MTR round-based trace target=%s resolved=%s method=%s lang=%s maxHops=%d interval=%s maxRounds=%d\",\n\t\tsanitizeLogParam(setup.Target), setup.IP.String(), string(setup.Method), sanitizeLogParam(config.Lang), config.MaxHops, opts.Interval, opts.MaxRounds)\n\n\topts.RunRound = func(method trace.Method, cfg trace.Config) (*trace.Result, error) {\n\t\ttraceMu.Lock()\n\t\tdefer traceMu.Unlock()\n\n\t\treturn withTraceSetupContext(setup, func() (*trace.Result, error) {\n\t\t\tif setup.NeedsLeoWS {\n\t\t\t\tensureLeoMoeConnection()\n\t\t\t}\n\t\t\treturn traceTracerouteFn(method, cfg)\n\t\t})\n\t}\n\n\treturn traceRunMTRRawFn(ctx, setup.Method, config, opts, onRecord)\n}\n\nfunc executeTrace(ctx context.Context, session *wsTraceSession, setup *traceExecution, configure func(*trace.Config)) (*trace.Result, time.Duration, error) {\n\ttraceMu.Lock()\n\tdefer traceMu.Unlock()\n\n\tconfig := setup.Config\n\tconfig.Context = ctx\n\tif configure != nil {\n\t\tconfigure(&config)\n\t}\n\n\tif session.closed.Load() {\n\t\treturn nil, 0, nil\n\t}\n\n\tlog.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)\n\tstart := time.Now()\n\tres, err := withTraceSetupContext(setup, func() (*trace.Result, error) {\n\t\tif setup.NeedsLeoWS {\n\t\t\tensureLeoMoeConnection()\n\t\t}\n\t\treturn traceTracerouteFn(setup.Method, config)\n\t})\n\tduration := time.Since(start)\n\treturn res, duration, err\n}\n\nfunc resolveWebMTRHopInterval(req traceRequest) time.Duration {\n\tif req.HopIntervalMs > 0 {\n\t\treturn time.Duration(req.HopIntervalMs) * time.Millisecond\n\t}\n\tif req.IntervalMs > 0 {\n\t\treturn time.Duration(req.IntervalMs) * time.Millisecond\n\t}\n\treturn time.Second\n}\n"
  },
  {
    "path": "server/ws_handler_test.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\ntype fakeWSConn struct {\n\tmu            sync.Mutex\n\twrites        []wsEnvelope\n\twriteStarted  chan struct{}\n\twriteBlock    chan struct{}\n\tcloseOnce     sync.Once\n\tcloseCount    int\n\tcontrolCount  int\n\tdeadlineCount int\n}\n\nfunc newFakeWSConn(blockWrites bool) *fakeWSConn {\n\tconn := &fakeWSConn{}\n\tif blockWrites {\n\t\tconn.writeStarted = make(chan struct{})\n\t\tconn.writeBlock = make(chan struct{})\n\t}\n\treturn conn\n}\n\nfunc (f *fakeWSConn) WriteJSON(v interface{}) error {\n\tif f.writeStarted != nil {\n\t\tselect {\n\t\tcase <-f.writeStarted:\n\t\tdefault:\n\t\t\tclose(f.writeStarted)\n\t\t}\n\t}\n\tif f.writeBlock != nil {\n\t\t<-f.writeBlock\n\t}\n\n\tdata, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar msg wsEnvelope\n\tif err := json.Unmarshal(data, &msg); err != nil {\n\t\treturn err\n\t}\n\n\tf.mu.Lock()\n\tf.writes = append(f.writes, msg)\n\tf.mu.Unlock()\n\treturn nil\n}\n\nfunc (f *fakeWSConn) SetWriteDeadline(time.Time) error {\n\tf.mu.Lock()\n\tf.deadlineCount++\n\tf.mu.Unlock()\n\treturn nil\n}\n\nfunc (f *fakeWSConn) WriteControl(messageType int, data []byte, deadline time.Time) error {\n\tf.mu.Lock()\n\tf.controlCount++\n\tf.mu.Unlock()\n\treturn nil\n}\n\nfunc (f *fakeWSConn) Close() error {\n\tf.closeOnce.Do(func() {\n\t\tf.mu.Lock()\n\t\tf.closeCount++\n\t\tf.mu.Unlock()\n\t\tif f.writeBlock != nil {\n\t\t\tclose(f.writeBlock)\n\t\t}\n\t})\n\treturn nil\n}\n\nfunc (f *fakeWSConn) NextReader() (messageType int, r io.Reader, err error) {\n\treturn 0, nil, io.EOF\n}\n\ntype fakeWSInitConn struct {\n\tdeadlines    []time.Time\n\treadLimit    int64\n\tmessage      []byte\n\terr          error\n\tdeadlineErrs []error\n}\n\nfunc (f *fakeWSInitConn) SetReadDeadline(t time.Time) error {\n\tf.deadlines = append(f.deadlines, t)\n\tif len(f.deadlineErrs) > 0 {\n\t\terr := f.deadlineErrs[0]\n\t\tf.deadlineErrs = f.deadlineErrs[1:]\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (f *fakeWSInitConn) SetReadLimit(limit int64) {\n\tf.readLimit = limit\n}\n\nfunc (f *fakeWSInitConn) ReadMessage() (messageType int, p []byte, err error) {\n\tif f.err != nil {\n\t\treturn 0, nil, f.err\n\t}\n\treturn websocket.TextMessage, f.message, nil\n}\n\nfunc TestReadWSInitMessage_ClearsDeadlineAfterSuccessfulRead(t *testing.T) {\n\tconn := &fakeWSInitConn{message: []byte(`{\"target\":\"example.com\"}`)}\n\n\tmsg, err := readWSInitMessage(conn)\n\tif err != nil {\n\t\tt.Fatalf(\"readWSInitMessage returned error: %v\", err)\n\t}\n\tif string(msg) != `{\"target\":\"example.com\"}` {\n\t\tt.Fatalf(\"readWSInitMessage()=%q, want payload unchanged\", string(msg))\n\t}\n\tif conn.readLimit != maxWSInitMessageBytes {\n\t\tt.Fatalf(\"SetReadLimit=%d, want %d\", conn.readLimit, maxWSInitMessageBytes)\n\t}\n\tif len(conn.deadlines) != 2 {\n\t\tt.Fatalf(\"SetReadDeadline called %d times, want 2\", len(conn.deadlines))\n\t}\n\tif conn.deadlines[0].IsZero() {\n\t\tt.Fatal(\"initial read deadline should be set\")\n\t}\n\tif !conn.deadlines[1].IsZero() {\n\t\tt.Fatalf(\"final read deadline=%v, want zero time\", conn.deadlines[1])\n\t}\n}\n\nfunc TestReadWSInitMessage_ReturnsInitialDeadlineError(t *testing.T) {\n\tconn := &fakeWSInitConn{\n\t\tmessage:      []byte(`{\"target\":\"example.com\"}`),\n\t\tdeadlineErrs: []error{errors.New(\"set deadline failed\")},\n\t}\n\n\tif _, err := readWSInitMessage(conn); err == nil || err.Error() != \"set deadline failed\" {\n\t\tt.Fatalf(\"readWSInitMessage error = %v, want initial deadline error\", err)\n\t}\n}\n\nfunc TestReadWSInitMessage_ReturnsClearDeadlineError(t *testing.T) {\n\tconn := &fakeWSInitConn{\n\t\tmessage:      []byte(`{\"target\":\"example.com\"}`),\n\t\tdeadlineErrs: []error{nil, errors.New(\"clear deadline failed\")},\n\t}\n\n\tif _, err := readWSInitMessage(conn); err == nil || err.Error() != \"clear deadline failed\" {\n\t\tt.Fatalf(\"readWSInitMessage error = %v, want clear deadline error\", err)\n\t}\n}\n\nfunc TestNewWSSessionContextInheritsParentCancellation(t *testing.T) {\n\tparent, cancelParent := context.WithCancel(context.Background())\n\tctx, cancel := newWSSessionContext(parent)\n\tdefer cancel()\n\n\tcancelParent()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tif !errors.Is(ctx.Err(), context.Canceled) {\n\t\t\tt.Fatalf(\"ctx.Err() = %v, want context.Canceled\", ctx.Err())\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"session context did not inherit parent cancellation\")\n\t}\n}\n\nfunc TestWSTraceSessionSend_QueueOverflowReturnsErrSlowConsumer(t *testing.T) {\n\tconn := newFakeWSConn(true)\n\tsession := newWSTraceSession(conn, \"cn\", 1)\n\tdefer session.finish()\n\n\tif err := session.send(wsEnvelope{Type: \"first\"}); err != nil {\n\t\tt.Fatalf(\"first send returned error: %v\", err)\n\t}\n\t<-conn.writeStarted\n\n\tif err := session.send(wsEnvelope{Type: \"second\"}); err != nil {\n\t\tt.Fatalf(\"second send returned error: %v\", err)\n\t}\n\n\terr := session.send(wsEnvelope{Type: \"third\"})\n\tif !errors.Is(err, errWSSlowConsumer) {\n\t\tt.Fatalf(\"expected errWSSlowConsumer, got %v\", err)\n\t}\n\tif !session.closed.Load() {\n\t\tt.Fatal(\"session should be marked closed after queue overflow\")\n\t}\n}\n\nfunc TestWSTraceSessionWriter_PreservesEnvelopeOrder(t *testing.T) {\n\tconn := newFakeWSConn(false)\n\tsession := newWSTraceSession(conn, \"cn\", 4)\n\n\tif err := session.send(wsEnvelope{Type: \"start\"}); err != nil {\n\t\tt.Fatalf(\"first send returned error: %v\", err)\n\t}\n\tif err := session.send(wsEnvelope{Type: \"mtr_raw\", Data: map[string]int{\"ttl\": 1}}); err != nil {\n\t\tt.Fatalf(\"second send returned error: %v\", err)\n\t}\n\n\tsession.finish()\n\n\tconn.mu.Lock()\n\tdefer conn.mu.Unlock()\n\tif len(conn.writes) != 2 {\n\t\tt.Fatalf(\"writer sent %d envelopes, want 2\", len(conn.writes))\n\t}\n\tif conn.writes[0].Type != \"start\" || conn.writes[1].Type != \"mtr_raw\" {\n\t\tt.Fatalf(\"unexpected write order: %+v\", conn.writes)\n\t}\n}\n\nfunc TestWSTraceSessionClose_IsIdempotent(t *testing.T) {\n\tconn := newFakeWSConn(false)\n\tsession := newWSTraceSession(conn, \"cn\", 4)\n\n\tsession.closeWithCode(websocket.CloseTryAgainLater, \"slow consumer\")\n\tsession.closeWithCode(websocket.CloseTryAgainLater, \"slow consumer\")\n\tsession.finish()\n\tsession.finish()\n\n\tconn.mu.Lock()\n\tdefer conn.mu.Unlock()\n\tif conn.closeCount != 1 {\n\t\tt.Fatalf(\"Close called %d times, want 1\", conn.closeCount)\n\t}\n\tif conn.controlCount != 1 {\n\t\tt.Fatalf(\"WriteControl called %d times, want 1\", conn.controlCount)\n\t}\n\tif conn.deadlineCount != 0 {\n\t\tt.Fatalf(\"SetWriteDeadline called %d times during close path, want 0\", conn.deadlineCount)\n\t}\n}\n\nfunc TestSanitizeLogParam(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant  string\n\t}{\n\t\t{\"normal text\", \"normal text\"},\n\t\t{\"hello\\nworld\", \"hello\\\\nworld\"},\n\t\t{\"hello\\r\\nworld\", \"hello\\\\n\\\\nworld\"},\n\t\t{\"line1\\nline2\\nline3\", \"line1\\\\nline2\\\\nline3\"},\n\t\t{\"tab\\there\", \"tab\\there\"},\n\t\t{\"null\\x00byte\", \"null\\uFFFDbyte\"},\n\t\t{\"esc\\x1b[31m\", \"esc\\uFFFD[31m\"},\n\t\t{\"\", \"\"},\n\t\t{\"safe-host.example.com\", \"safe-host.example.com\"},\n\t\t{\"evil\\n[deploy] fake log entry\", \"evil\\\\n[deploy] fake log entry\"},\n\t}\n\tfor _, tt := range tests {\n\t\tgot := sanitizeLogParam(tt.input)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"sanitizeLogParam(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "trace/cache.go",
    "content": "package trace\n\nfunc ClearCaches() {\n\tgeoCache.Range(func(key, value any) bool {\n\t\tgeoCache.Delete(key)\n\t\treturn true\n\t})\n}\n"
  },
  {
    "path": "trace/globalping.go",
    "content": "//go:build !flavor_tiny && !flavor_ntr\n\npackage trace\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jsdelivr/globalping-cli/globalping\"\n\t_config \"github.com/nxtrace/NTrace-core/config\"\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc GlobalpingTraceroute(opts *GlobalpingOptions, config *Config) (*Result, *globalping.Measurement, error) {\n\tctx := context.Background()\n\tif config != nil && config.Context != nil {\n\t\tctx = config.Context\n\t}\n\tclient := newGlobalpingClient(ctx)\n\tmeasurement, err := createGlobalpingMeasurement(ctx, client, buildGlobalpingMeasurement(opts))\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tgpHops, err := decodeGlobalpingMeasurementHops(measurement)\n\tif err != nil {\n\t\treturn nil, measurement, err\n\t}\n\tlimit := resolveGlobalpingHopLimit(opts, config, len(gpHops))\n\treturn buildGlobalpingResult(gpHops, limit, config), measurement, nil\n}\n\nfunc newGlobalpingClient(ctx context.Context) globalping.Client {\n\tcfg := globalping.Config{\n\t\tUserAgent: \"NextTrace/\" + _config.Version,\n\t\tHTTPClient: &http.Client{\n\t\t\tTimeout:   30 * time.Second,\n\t\t\tTransport: newContextBoundTransport(ctx),\n\t\t},\n\t}\n\tif util.GlobalpingToken != \"\" {\n\t\tcfg.AuthToken = &globalping.Token{\n\t\t\tAccessToken: util.GlobalpingToken,\n\t\t\tExpiry:      time.Now().Add(math.MaxInt64),\n\t\t}\n\t}\n\treturn globalping.NewClient(cfg)\n}\n\nfunc newContextBoundTransport(ctx context.Context) http.RoundTripper {\n\tbase := http.DefaultTransport\n\tif base == nil {\n\t\tbase = http.DefaultTransport\n\t}\n\treturn roundTripperFunc(func(req *http.Request) (*http.Response, error) {\n\t\tif ctx == nil {\n\t\t\treturn base.RoundTrip(req)\n\t\t}\n\t\treturn base.RoundTrip(req.Clone(ctx))\n\t})\n}\n\ntype roundTripperFunc func(*http.Request) (*http.Response, error)\n\nfunc (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {\n\treturn fn(req)\n}\n\nfunc buildGlobalpingMeasurement(opts *GlobalpingOptions) *globalping.MeasurementCreate {\n\treq := &globalping.MeasurementCreate{\n\t\tType:   \"mtr\",\n\t\tTarget: opts.Target,\n\t\tLimit:  1,\n\t\tLocations: []globalping.Locations{{\n\t\t\tMagic: opts.From,\n\t\t}},\n\t\tOptions: &globalping.MeasurementOptions{\n\t\t\tPort:     uint16(opts.Port),\n\t\t\tPackets:  opts.Packets,\n\t\t\tProtocol: globalpingProtocol(opts),\n\t\t},\n\t}\n\tassignGlobalpingIPVersion(req.Options, opts)\n\treturn req\n}\n\nfunc globalpingProtocol(opts *GlobalpingOptions) string {\n\tswitch {\n\tcase opts.TCP:\n\t\treturn \"TCP\"\n\tcase opts.UDP:\n\t\treturn \"UDP\"\n\tdefault:\n\t\treturn \"ICMP\"\n\t}\n}\n\nfunc assignGlobalpingIPVersion(options *globalping.MeasurementOptions, opts *GlobalpingOptions) {\n\tswitch {\n\tcase opts.IPv4 && !opts.IPv6:\n\t\toptions.IPVersion = globalping.IPVersion4\n\tcase opts.IPv6 && !opts.IPv4:\n\t\toptions.IPVersion = globalping.IPVersion6\n\t}\n}\n\nfunc createGlobalpingMeasurement(ctx context.Context, client globalping.Client, req *globalping.MeasurementCreate) (*globalping.Measurement, error) {\n\tres, err := client.CreateMeasurement(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn awaitGlobalpingMeasurement(ctx, client, res.ID)\n}\n\nfunc awaitGlobalpingMeasurement(ctx context.Context, client globalping.Client, id string) (*globalping.Measurement, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tfor {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tmeasurement, err := client.GetMeasurement(id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif measurement.Status != globalping.StatusInProgress {\n\t\t\treturn measurement, nil\n\t\t}\n\t\ttimer := time.NewTimer(500 * time.Millisecond)\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\ttimer.Stop()\n\t\t\treturn nil, ctx.Err()\n\t\tcase <-timer.C:\n\t\t}\n\t}\n}\n\nfunc decodeGlobalpingMeasurementHops(measurement *globalping.Measurement) ([]globalping.MTRHop, error) {\n\tif measurement.Status != globalping.StatusFinished {\n\t\treturn nil, fmt.Errorf(\"measurement did not complete successfully: %s\", measurement.Status)\n\t}\n\tif len(measurement.Results) == 0 {\n\t\treturn nil, fmt.Errorf(\"globalping measurement returned no probe results\")\n\t}\n\tfirstResult := measurement.Results[0]\n\tif len(firstResult.Result.HopsRaw) == 0 {\n\t\treturn nil, fmt.Errorf(\"globalping measurement results did not include hop data\")\n\t}\n\treturn globalping.DecodeMTRHops(firstResult.Result.HopsRaw)\n}\n\nfunc resolveGlobalpingHopLimit(opts *GlobalpingOptions, config *Config, total int) int {\n\tlimit := opts.MaxHops\n\tif limit <= 0 && config != nil && config.MaxHops > 0 {\n\t\tlimit = config.MaxHops\n\t}\n\tif limit <= 0 || limit > total {\n\t\treturn total\n\t}\n\treturn limit\n}\n\nfunc buildGlobalpingResult(gpHops []globalping.MTRHop, limit int, config *Config) *Result {\n\tresult := &Result{}\n\tgeoMap := map[string]*ipgeo.IPGeoData{}\n\tmaxTimings := maxGlobalpingTimings(gpHops, limit)\n\tfor i := 0; i < limit; i++ {\n\t\tresult.Hops = append(result.Hops, buildGlobalpingTTLHops(i+1, &gpHops[i], maxTimings, geoMap, config))\n\t}\n\treturn result\n}\n\nfunc maxGlobalpingTimings(gpHops []globalping.MTRHop, limit int) int {\n\tmaxTimings := 1\n\tfor i := 0; i < limit; i++ {\n\t\tif count := len(gpHops[i].Timings); count > maxTimings {\n\t\t\tmaxTimings = count\n\t\t}\n\t}\n\treturn maxTimings\n}\n\nfunc buildGlobalpingTTLHops(ttl int, gpHop *globalping.MTRHop, maxTimings int, geoMap map[string]*ipgeo.IPGeoData, config *Config) []Hop {\n\thops := make([]Hop, 0, maxTimings)\n\tfor j := 0; j < maxTimings; j++ {\n\t\thops = append(hops, mapGlobalpingHop(ttl, gpHop, globalpingTimingAt(gpHop, j), geoMap, config))\n\t}\n\treturn hops\n}\n\nfunc globalpingTimingAt(gpHop *globalping.MTRHop, index int) *globalping.MTRTiming {\n\tif index >= len(gpHop.Timings) {\n\t\treturn nil\n\t}\n\treturn &gpHop.Timings[index]\n}\n\nfunc mapGlobalpingHop(ttl int, gpHop *globalping.MTRHop, timing *globalping.MTRTiming, geoMap map[string]*ipgeo.IPGeoData, config *Config) Hop {\n\tresolvedHostname := \"\"\n\tif config != nil && config.RDNS {\n\t\tif raw := strings.TrimSpace(gpHop.ResolvedHostname); raw != \"\" {\n\t\t\ttrimmed := strings.TrimSuffix(raw, \".\")\n\t\t\tif net.ParseIP(trimmed) == nil {\n\t\t\t\tresolvedHostname = CanonicalHostname(trimmed)\n\t\t\t}\n\t\t}\n\t}\n\n\thop := Hop{\n\t\tHostname: resolvedHostname,\n\t\tTTL:      ttl,\n\t}\n\tif config != nil {\n\t\thop.Lang = config.Lang\n\t}\n\n\tif gpHop.ResolvedAddress != \"\" {\n\t\thop.Address = &net.IPAddr{\n\t\t\tIP: net.ParseIP(gpHop.ResolvedAddress),\n\t\t}\n\t\tif geo, ok := geoMap[gpHop.ResolvedAddress]; ok {\n\t\t\thop.Geo = geo\n\t\t} else if config != nil {\n\t\t\t_ = hop.fetchIPData(*config)\n\t\t\tgeoMap[gpHop.ResolvedAddress] = hop.Geo\n\t\t}\n\t}\n\n\tif timing == nil {\n\t\treturn hop\n\t}\n\n\thop.Success = true\n\thop.RTT = time.Duration(timing.RTT * float64(time.Millisecond))\n\n\treturn hop\n}\n\nfunc hasGlobalpingProbeLocation(probe globalping.ProbeDetails) bool {\n\treturn probe.City != \"\" ||\n\t\tprobe.State != \"\" ||\n\t\tprobe.Country != \"\" ||\n\t\tprobe.Continent != \"\" ||\n\t\tprobe.Network != \"\" ||\n\t\tprobe.ASN != 0\n}\n\nfunc formatGlobalpingCity(probe globalping.ProbeDetails) string {\n\tif probe.City != \"\" && probe.State != \"\" {\n\t\treturn probe.City + \" (\" + probe.State + \")\"\n\t}\n\tif probe.City != \"\" {\n\t\treturn probe.City\n\t}\n\treturn probe.State\n}\n\nfunc formatGlobalpingNetwork(probe globalping.ProbeDetails) string {\n\tnetwork := strings.TrimSpace(probe.Network)\n\tif network != \"\" && probe.ASN != 0 {\n\t\treturn network + \" (AS\" + fmt.Sprint(probe.ASN) + \")\"\n\t}\n\tif network != \"\" {\n\t\treturn network\n\t}\n\tif probe.ASN != 0 {\n\t\treturn \"(AS\" + fmt.Sprint(probe.ASN) + \")\"\n\t}\n\treturn \"\"\n}\n\nfunc appendGlobalpingPart(parts []string, value string) []string {\n\tif value == \"\" {\n\t\treturn parts\n\t}\n\treturn append(parts, value)\n}\n\nfunc GlobalpingFormatLocation(m *globalping.ProbeMeasurement) string {\n\tif m == nil {\n\t\treturn \"\"\n\t}\n\n\tprobe := m.Probe\n\tif !hasGlobalpingProbeLocation(probe) {\n\t\treturn \"\"\n\t}\n\n\tvar parts []string\n\tparts = appendGlobalpingPart(parts, formatGlobalpingCity(probe))\n\tparts = appendGlobalpingPart(parts, probe.Country)\n\tparts = appendGlobalpingPart(parts, probe.Continent)\n\tparts = appendGlobalpingPart(parts, formatGlobalpingNetwork(probe))\n\n\treturn strings.Join(parts, \", \")\n}\n"
  },
  {
    "path": "trace/globalping_test.go",
    "content": "//go:build !flavor_tiny && !flavor_ntr\n\npackage trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/jsdelivr/globalping-cli/globalping\"\n)\n\ntype fakeGlobalpingClient struct {\n\tgetMeasurement func(id string) (*globalping.Measurement, error)\n\tcreate         func(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, error)\n}\n\nfunc (f fakeGlobalpingClient) CreateMeasurement(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, error) {\n\treturn f.create(measurement)\n}\n\nfunc (f fakeGlobalpingClient) GetMeasurement(id string) (*globalping.Measurement, error) {\n\treturn f.getMeasurement(id)\n}\n\nfunc (f fakeGlobalpingClient) AwaitMeasurement(id string) (*globalping.Measurement, error) {\n\treturn f.getMeasurement(id)\n}\n\nfunc (f fakeGlobalpingClient) GetMeasurementRaw(id string) ([]byte, error) {\n\tpanic(\"not implemented\")\n}\n\nfunc (f fakeGlobalpingClient) Authorize(func(error)) (*globalping.AuthorizeResponse, error) {\n\tpanic(\"not implemented\")\n}\n\nfunc (f fakeGlobalpingClient) TokenIntrospection(string) (*globalping.IntrospectionResponse, error) {\n\tpanic(\"not implemented\")\n}\n\nfunc (f fakeGlobalpingClient) Logout() error {\n\tpanic(\"not implemented\")\n}\n\nfunc (f fakeGlobalpingClient) RevokeToken(string) error {\n\tpanic(\"not implemented\")\n}\n\nfunc (f fakeGlobalpingClient) Limits() (*globalping.LimitsResponse, error) {\n\tpanic(\"not implemented\")\n}\n\nfunc TestAwaitGlobalpingMeasurementReturnsCanceled(t *testing.T) {\n\tclient := fakeGlobalpingClient{\n\t\tgetMeasurement: func(id string) (*globalping.Measurement, error) {\n\t\t\treturn &globalping.Measurement{Status: globalping.StatusInProgress}, nil\n\t\t},\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := awaitGlobalpingMeasurement(ctx, client, \"m-1\")\n\t\tdone <- err\n\t}()\n\n\tcancel()\n\n\tselect {\n\tcase err := <-done:\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tt.Fatalf(\"awaitGlobalpingMeasurement error = %v, want context.Canceled\", err)\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"awaitGlobalpingMeasurement did not return promptly after cancel\")\n\t}\n}\n\nfunc TestCreateGlobalpingMeasurementHonorsCanceledContext(t *testing.T) {\n\tclient := fakeGlobalpingClient{\n\t\tcreate: func(measurement *globalping.MeasurementCreate) (*globalping.MeasurementCreateResponse, error) {\n\t\t\treturn &globalping.MeasurementCreateResponse{ID: \"m-1\"}, nil\n\t\t},\n\t\tgetMeasurement: func(id string) (*globalping.Measurement, error) {\n\t\t\treturn &globalping.Measurement{Status: globalping.StatusInProgress}, nil\n\t\t},\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := createGlobalpingMeasurement(ctx, client, &globalping.MeasurementCreate{})\n\t\tdone <- err\n\t}()\n\n\tcancel()\n\n\tselect {\n\tcase err := <-done:\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tt.Fatalf(\"createGlobalpingMeasurement error = %v, want context.Canceled\", err)\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"createGlobalpingMeasurement did not return promptly after cancel\")\n\t}\n}\n"
  },
  {
    "path": "trace/globalping_types.go",
    "content": "package trace\n\n// GlobalpingOptions configures a Globalping-based traceroute request.\n// Defined in an untagged file so all build flavors can reference the type\n// without pulling in the globalping-cli dependency.\ntype GlobalpingOptions struct {\n\tTarget  string\n\tFrom    string\n\tIPv4    bool\n\tIPv6    bool\n\tTCP     bool\n\tUDP     bool\n\tPort    int\n\tPackets int\n\tMaxHops int\n\n\tDisableMaptrace bool\n\tDataOrigin      string\n\n\tTablePrint   bool\n\tClearScreen  bool\n\tClassicPrint bool\n\tRawPrint     bool\n\tJSONPrint    bool\n}\n"
  },
  {
    "path": "trace/icmp_ipv4.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/sync/semaphore\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype ICMPTracer struct {\n\tConfig\n\twg        sync.WaitGroup\n\tres       Result\n\techoID    int\n\tpending   map[int]struct{}\n\tpendingMu sync.Mutex\n\tsentAt    map[int]time.Time\n\tsentMu    sync.RWMutex\n\tSrcIP     net.IP\n\tfinal     atomic.Int32\n\tsem       *semaphore.Weighted\n\tmatchQ    chan matchTask\n\treadyICMP chan struct{}\n}\n\nfunc (t *ICMPTracer) waitAllReady(ctx context.Context) {\n\ttimeout := time.After(5 * time.Second)\n\twaiting := 1\n\tfor waiting > 0 {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.readyICMP:\n\t\t\twaiting--\n\t\tcase <-timeout:\n\t\t\treturn\n\t\t}\n\t}\n\t<-time.After(100 * time.Millisecond)\n}\n\nfunc (t *ICMPTracer) ttlComp(ttl int) bool {\n\tidx := ttl - 1\n\tt.res.lock.RLock()\n\tdefer t.res.lock.RUnlock()\n\treturn idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements\n}\n\nfunc (t *ICMPTracer) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) {\n\tdefer t.wg.Done()\n\n\tttl := t.BeginHop - 1\n\tticker := time.NewTicker(200 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tif t.AsyncPrinter != nil {\n\t\t\tt.AsyncPrinter(&t.res)\n\t\t}\n\n\t\t// 接收的时候检查一下是不是 3 跳都齐了\n\t\tif t.ttlComp(ttl + 1) {\n\t\t\tif t.RealtimePrinter != nil {\n\t\t\t\tt.res.waitGeo(ctx, ttl)\n\t\t\t\tt.RealtimePrinter(&t.res, ttl)\n\t\t\t}\n\t\t\tttl++\n\t\t\tif ttl == int(t.final.Load()) || ttl >= t.MaxHops {\n\t\t\t\tcancel(errNaturalDone) // 标记为“自然完成”\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t}\n\t}\n}\n\nfunc (t *ICMPTracer) launchTTL(ctx context.Context, s *internal.ICMPSpec, ttl int) {\n\tgo func(ttl int) {\n\t\tfor i := 0; i < t.MaxAttempts; i++ {\n\t\t\t// 若此 TTL 已完成或 ctx 已取消，则不再发起新的尝试\n\t\t\tif t.ttlComp(ttl) || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tt.wg.Add(1)\n\t\t\tgo func(ttl, i int) {\n\t\t\t\tif err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) {\n\t\t\t\t\tif util.EnvDevMode {\n\t\t\t\t\t\tpanic(err)\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"send error (ttl=%d, attempt=%d): %v\\n\", ttl, i, err)\n\t\t\t\t}\n\t\t\t}(ttl, i)\n\n\t\t\tif i+1 == t.MaxAttempts {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}(ttl)\n}\n\nfunc (t *ICMPTracer) initEchoID() {\n\t// 设置随机种子\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\n\t// 生成一个 8 位的随机 tag\n\techoIDTag := r.Intn(256)\n\n\t// 获取当前进程的 pid\n\tpid := os.Getpid()\n\n\t// 将随机 tag 编码到高 8 位；将 pid 的低 8 位编码到低 8 位\n\tt.echoID = (echoIDTag << 8) | (pid & 0xFF)\n}\n\nfunc (t *ICMPTracer) markPending(seq int) {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\tt.pending[seq] = struct{}{}\n}\n\nfunc (t *ICMPTracer) clearPending(seq int) bool {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\t_, ok := t.pending[seq]\n\tdelete(t.pending, seq)\n\treturn ok\n}\n\nfunc (t *ICMPTracer) storeSent(seq int, start time.Time) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tt.sentAt[seq] = start\n}\n\nfunc (t *ICMPTracer) lookupSent(seq int) (start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\tstart, ok = t.sentAt[seq]\n\tif !ok {\n\t\treturn time.Time{}, false\n\t}\n\treturn start, true\n}\n\nfunc (t *ICMPTracer) dropSent(seq int) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tdelete(t.sentAt, seq)\n}\n\nfunc (t *ICMPTracer) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) {\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn\n\t}\n\n\tif ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) {\n\t\tfor {\n\t\t\told := t.final.Load()\n\t\t\tif old != -1 && ttl >= int(old) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif t.final.CompareAndSwap(old, int32(ttl)) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\th := Hop{\n\t\tSuccess: true,\n\t\tAddress: peer,\n\t\tTTL:     ttl,\n\t\tRTT:     rtt,\n\t\tMPLS:    mpls,\n\t}\n\tt.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config)\n}\n\nfunc (t *ICMPTracer) matchWorker(ctx context.Context) {\n\tdefer t.wg.Done()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase task, ok := <-t.matchQ:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 固定等待 10ms，缓解登记竞态\n\t\t\ttimer := time.NewTimer(10 * time.Millisecond)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\ttimer.Stop()\n\t\t\t\treturn\n\t\t\tcase <-timer.C:\n\t\t\t}\n\t\t\ttimer.Stop()\n\n\t\t\t// 尝试一次匹配\n\t\t\tstart, ok := t.lookupSent(task.seq)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 将 task.seq 转为 16 位无符号数\n\t\t\tu := uint16(task.seq)\n\n\t\t\t// 高 8 位是 TTL\n\t\t\tttl := int((u >> 8) & 0xFF)\n\n\t\t\t// 低 8 位是索引 i\n\t\t\ti := int(u & 0xFF)\n\n\t\t\tif t.clearPending(task.seq) {\n\t\t\t\trtt := task.finish.Sub(start)\n\t\t\t\tt.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls)\n\t\t\t}\n\t\t\tt.dropSent(task.seq)\n\t\t}\n\t}\n}\n\nfunc (t *ICMPTracer) Execute() (res *Result, err error) {\n\t// 初始化 Echo.ID\n\tt.initEchoID()\n\n\t// 初始化 pending、sentAt 和 matchQ\n\tt.pending = make(map[int]struct{})\n\tt.sentAt = make(map[int]time.Time)\n\tt.matchQ = make(chan matchTask, 60)\n\n\t// 创建就绪通道\n\tt.readyICMP = make(chan struct{})\n\n\tif len(t.res.Hops) > 0 {\n\t\treturn &t.res, errTracerouteExecuted\n\t}\n\n\t// 初始化 res.Hops 和 res.tailDone，并预分配到 MaxHops\n\tt.res.Hops = make([][]Hop, t.MaxHops)\n\tt.res.tailDone = make([]bool, t.MaxHops)\n\tt.res.setGeoWait(t.NumMeasurements)\n\n\t// 解析并校验用户指定的 IPv4 源地址\n\tSrcAddr := net.ParseIP(t.SrcAddr).To4()\n\tif t.SrcAddr != \"\" && SrcAddr == nil {\n\t\treturn nil, errors.New(\"invalid IPv4 SrcAddr:\" + t.SrcAddr)\n\t}\n\tt.SrcIP, _ = util.LocalIPPort(t.DstIP, SrcAddr, \"icmp\")\n\tif t.SrcIP == nil {\n\t\treturn nil, errors.New(\"cannot determine local IPv4 address\")\n\t}\n\n\ts := internal.NewICMPSpec(\n\t\t4,\n\t\tt.ICMPMode,\n\t\tt.echoID,\n\t\tt.SrcIP,\n\t\tt.DstIP,\n\t)\n\n\ts.InitICMP()\n\tdefer s.Close()\n\n\tbaseCtx := t.Context\n\tif baseCtx == nil {\n\t\tbaseCtx = context.Background()\n\t}\n\tsigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM)\n\tctx, cancel := context.WithCancelCause(sigCtx)\n\tt.final.Store(-1)\n\n\tworkerN := 16\n\tfor i := 0; i < workerN; i++ {\n\t\tt.wg.Add(1)\n\t\tgo t.matchWorker(ctx)\n\t}\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, seq int) {\n\t\t\tt.handleICMPMessage(msg, finish, seq)\n\t\t},\n\t\t)\n\t}()\n\tt.waitAllReady(ctx)\n\tt.wg.Add(1)\n\tgo t.PrintFunc(ctx, cancel)\n\n\tt.sem = semaphore.NewWeighted(int64(t.ParallelRequests))\n\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\t// 立即启动 BeginHop 对应的 TTL 组\n\t\tt.launchTTL(ctx, s, t.BeginHop)\n\n\t\tfor ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ {\n\t\t\t// 之后按 TTLInterval 周期启动后续 TTL 组\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 如果到达最终跳，则退出\n\t\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 并发启动这个 TTL 的所有测量\n\t\t\tt.launchTTL(ctx, s, ttl)\n\t\t}\n\t}()\n\n\t<-ctx.Done()\n\tstop()\n\tt.wg.Wait()\n\n\tfinal := int(t.final.Load())\n\tif final == -1 {\n\t\tfinal = t.MaxHops\n\t}\n\tt.res.reduce(final)\n\n\tif cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) {\n\t\treturn &t.res, cause\n\t}\n\treturn &t.res, nil\n}\n\nfunc (t *ICMPTracer) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, seq int) {\n\tmpls := extractMPLS(msg, t.DisableMPLS)\n\n\t// 非阻塞投递；如果队列已满则直接丢弃该任务\n\tselect {\n\tcase t.matchQ <- matchTask{\n\t\tseq: seq, peer: msg.Peer, finish: finish, mpls: mpls,\n\t}:\n\tdefault:\n\t\t// 丢弃以避免阻塞抓包循环\n\t}\n}\n\nfunc (t *ICMPTracer) send(ctx context.Context, s *internal.ICMPSpec, ttl, i int) error {\n\tdefer t.wg.Done()\n\n\tif t.ttlComp(ttl) {\n\t\t// 快路径短路：若该 TTL 已完成，直接返回避免竞争信号量与无谓发包\n\t\treturn nil\n\t}\n\n\tif err := acquireTraceSemaphore(ctx, t.sem); err != nil {\n\t\treturn err\n\t}\n\tdefer t.sem.Release(1)\n\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn nil\n\t}\n\n\tif t.ttlComp(ttl) {\n\t\t// 竞态兜底：获取信号量期间可能已完成，再次检查以避免冗余发包\n\t\treturn nil\n\t}\n\n\t// 将 TTL 编码到高 8 位；将索引 i 编码到低 8 位\n\tseq := (ttl << 8) | (i & 0xFF)\n\n\tipHeader := &layers.IPv4{\n\t\tVersion:  4,\n\t\tSrcIP:    t.SrcIP,\n\t\tDstIP:    t.DstIP,\n\t\tProtocol: layers.IPProtocolICMPv4,\n\t\tTTL:      uint8(ttl),\n\t\tTOS:      uint8(t.TOS),\n\t}\n\n\ticmpHeader := &layers.ICMPv4{\n\t\tTypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0),\n\t\tId:       uint16(t.echoID),\n\t\tSeq:      uint16(seq),\n\t}\n\n\tdesiredPayloadSize := resolveProbePayloadSize(ICMPTrace, t.DstIP, t.PktSize, t.RandomPacketSize)\n\tpayload := make([]byte, desiredPayloadSize)\n\n\tif desiredPayloadSize >= 3 {\n\t\tcopy(payload[desiredPayloadSize-3:], []byte{'n', 't', 'r'}) // \"ntr\" 作为标识\n\t}\n\n\t// 登记 pending，并启动超时守护\n\tt.markPending(seq)\n\tgo func(seq, ttl, i int) {\n\t\tif !waitForTraceDelay(ctx, t.Timeout) {\n\t\t\t_ = t.clearPending(seq)\n\t\t\treturn\n\t\t}\n\t\tif !t.clearPending(seq) {\n\t\t\treturn\n\t\t}\n\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\treturn\n\t\t}\n\t\tif t.ttlComp(ttl) {\n\t\t\treturn\n\t\t}\n\n\t\th := Hop{\n\t\t\tSuccess: false,\n\t\t\tAddress: nil,\n\t\t\tTTL:     ttl,\n\t\t\tRTT:     0,\n\t\t\tError:   errHopLimitTimeout,\n\t\t}\n\n\t\t_, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts)\n\t\tt.dropSent(seq)\n\t}(seq, ttl, i)\n\n\tstart, err := s.SendICMP(ctx, ipHeader, icmpHeader, nil, payload)\n\tif err != nil {\n\t\t_ = t.clearPending(seq)\n\t\treturn err\n\t}\n\tt.storeSent(seq, start)\n\treturn nil\n}\n"
  },
  {
    "path": "trace/icmp_ipv6.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/sync/semaphore\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype ICMPTracerv6 struct {\n\tConfig\n\twg        sync.WaitGroup\n\tres       Result\n\techoID    int\n\tpending   map[int]struct{}\n\tpendingMu sync.Mutex\n\tsentAt    map[int]time.Time\n\tsentMu    sync.RWMutex\n\tSrcIP     net.IP\n\tfinal     atomic.Int32\n\tsem       *semaphore.Weighted\n\tmatchQ    chan matchTask\n\treadyICMP chan struct{}\n}\n\nfunc (t *ICMPTracerv6) waitAllReady(ctx context.Context) {\n\ttimeout := time.After(5 * time.Second)\n\twaiting := 1\n\tfor waiting > 0 {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.readyICMP:\n\t\t\twaiting--\n\t\tcase <-timeout:\n\t\t\treturn\n\t\t}\n\t}\n\t<-time.After(100 * time.Millisecond)\n}\n\nfunc (t *ICMPTracerv6) ttlComp(ttl int) bool {\n\tidx := ttl - 1\n\tt.res.lock.RLock()\n\tdefer t.res.lock.RUnlock()\n\treturn idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements\n}\n\nfunc (t *ICMPTracerv6) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) {\n\tdefer t.wg.Done()\n\n\tttl := t.BeginHop - 1\n\tticker := time.NewTicker(200 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tif t.AsyncPrinter != nil {\n\t\t\tt.AsyncPrinter(&t.res)\n\t\t}\n\n\t\t// 接收的时候检查一下是不是 3 跳都齐了\n\t\tif t.ttlComp(ttl + 1) {\n\t\t\tif t.RealtimePrinter != nil {\n\t\t\t\tt.res.waitGeo(ctx, ttl)\n\t\t\t\tt.RealtimePrinter(&t.res, ttl)\n\t\t\t}\n\t\t\tttl++\n\t\t\tif ttl == int(t.final.Load()) || ttl >= t.MaxHops {\n\t\t\t\tcancel(errNaturalDone) // 标记为“自然完成”\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t}\n\t}\n}\n\nfunc (t *ICMPTracerv6) launchTTL(ctx context.Context, s *internal.ICMPSpec, ttl int) {\n\tgo func(ttl int) {\n\t\tfor i := 0; i < t.MaxAttempts; i++ {\n\t\t\t// 若此 TTL 已完成或 ctx 已取消，则不再发起新的尝试\n\t\t\tif t.ttlComp(ttl) || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tt.wg.Add(1)\n\t\t\tgo func(ttl, i int) {\n\t\t\t\tif err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) {\n\t\t\t\t\tif util.EnvDevMode {\n\t\t\t\t\t\tpanic(err)\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"send error (ttl=%d, attempt=%d): %v\\n\", ttl, i, err)\n\t\t\t\t}\n\t\t\t}(ttl, i)\n\n\t\t\tif i+1 == t.MaxAttempts {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}(ttl)\n}\n\nfunc (t *ICMPTracerv6) initEchoID() {\n\t// 设置随机种子\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\n\t// 生成一个 8 位的随机 tag\n\techoIDTag := r.Intn(256)\n\n\t// 获取当前进程的 pid\n\tpid := os.Getpid()\n\n\t// 将随机 tag 编码到高 8 位；将 pid 的低 8 位编码到低 8 位\n\tt.echoID = (echoIDTag << 8) | (pid & 0xFF)\n}\n\nfunc (t *ICMPTracerv6) markPending(seq int) {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\tt.pending[seq] = struct{}{}\n}\n\nfunc (t *ICMPTracerv6) clearPending(seq int) bool {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\t_, ok := t.pending[seq]\n\tdelete(t.pending, seq)\n\treturn ok\n}\n\nfunc (t *ICMPTracerv6) storeSent(seq int, start time.Time) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tt.sentAt[seq] = start\n}\n\nfunc (t *ICMPTracerv6) lookupSent(seq int) (start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\tstart, ok = t.sentAt[seq]\n\tif !ok {\n\t\treturn time.Time{}, false\n\t}\n\treturn start, true\n}\n\nfunc (t *ICMPTracerv6) dropSent(seq int) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tdelete(t.sentAt, seq)\n}\n\nfunc (t *ICMPTracerv6) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) {\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn\n\t}\n\n\tif ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) {\n\t\tfor {\n\t\t\told := t.final.Load()\n\t\t\tif old != -1 && ttl >= int(old) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif t.final.CompareAndSwap(old, int32(ttl)) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\th := Hop{\n\t\tSuccess: true,\n\t\tAddress: peer,\n\t\tTTL:     ttl,\n\t\tRTT:     rtt,\n\t\tMPLS:    mpls,\n\t}\n\tt.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config)\n}\n\nfunc (t *ICMPTracerv6) matchWorker(ctx context.Context) {\n\tdefer t.wg.Done()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase task, ok := <-t.matchQ:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 固定等待 10ms，缓解登记竞态\n\t\t\ttimer := time.NewTimer(10 * time.Millisecond)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\ttimer.Stop()\n\t\t\t\treturn\n\t\t\tcase <-timer.C:\n\t\t\t}\n\t\t\ttimer.Stop()\n\n\t\t\t// 尝试一次匹配\n\t\t\tstart, ok := t.lookupSent(task.seq)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 将 task.seq 转为 16 位无符号数\n\t\t\tu := uint16(task.seq)\n\n\t\t\t// 高 8 位是 TTL\n\t\t\tttl := int((u >> 8) & 0xFF)\n\n\t\t\t// 低 8 位是索引 i\n\t\t\ti := int(u & 0xFF)\n\n\t\t\tif t.clearPending(task.seq) {\n\t\t\t\trtt := task.finish.Sub(start)\n\t\t\t\tt.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls)\n\t\t\t}\n\t\t\tt.dropSent(task.seq)\n\t\t}\n\t}\n}\n\nfunc (t *ICMPTracerv6) Execute() (res *Result, err error) {\n\t// 初始化 Echo.ID\n\tt.initEchoID()\n\n\t// 初始化 pending、sentAt 和 matchQ\n\tt.pending = make(map[int]struct{})\n\tt.sentAt = make(map[int]time.Time)\n\tt.matchQ = make(chan matchTask, 60)\n\n\t// 创建就绪通道\n\tt.readyICMP = make(chan struct{})\n\n\tif len(t.res.Hops) > 0 {\n\t\treturn &t.res, errTracerouteExecuted\n\t}\n\n\t// 初始化 res.Hops 和 res.tailDone，并预分配到 MaxHops\n\tt.res.Hops = make([][]Hop, t.MaxHops)\n\tt.res.tailDone = make([]bool, t.MaxHops)\n\tt.res.setGeoWait(t.NumMeasurements)\n\n\t// 解析并校验用户指定的 IPv6 源地址\n\tSrcAddr := net.ParseIP(t.SrcAddr)\n\tif t.SrcAddr != \"\" && !util.IsIPv6(SrcAddr) {\n\t\treturn nil, errors.New(\"invalid IPv6 SrcAddr: \" + t.SrcAddr)\n\t}\n\tt.SrcIP, _ = util.LocalIPPortv6(t.DstIP, SrcAddr, \"icmp6\")\n\tif t.SrcIP == nil {\n\t\treturn nil, errors.New(\"cannot determine local IPv6 address\")\n\t}\n\n\ts := internal.NewICMPSpec(\n\t\t6,\n\t\tt.ICMPMode,\n\t\tt.echoID,\n\t\tt.SrcIP,\n\t\tt.DstIP,\n\t)\n\n\ts.InitICMP()\n\tdefer s.Close()\n\n\tbaseCtx := t.Context\n\tif baseCtx == nil {\n\t\tbaseCtx = context.Background()\n\t}\n\tsigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM)\n\tctx, cancel := context.WithCancelCause(sigCtx)\n\tt.final.Store(-1)\n\n\tworkerN := 16\n\tfor i := 0; i < workerN; i++ {\n\t\tt.wg.Add(1)\n\t\tgo t.matchWorker(ctx)\n\t}\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, seq int) {\n\t\t\tt.handleICMPMessage(msg, finish, seq)\n\t\t},\n\t\t)\n\t}()\n\tt.waitAllReady(ctx)\n\tt.wg.Add(1)\n\tgo t.PrintFunc(ctx, cancel)\n\n\tt.sem = semaphore.NewWeighted(int64(t.ParallelRequests))\n\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\t// 立即启动 BeginHop 对应的 TTL 组\n\t\tt.launchTTL(ctx, s, t.BeginHop)\n\n\t\tfor ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ {\n\t\t\t// 之后按 TTLInterval 周期启动后续 TTL 组\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 如果到达最终跳，则退出\n\t\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 并发启动这个 TTL 的所有测量\n\t\t\tt.launchTTL(ctx, s, ttl)\n\t\t}\n\t}()\n\n\t<-ctx.Done()\n\tstop()\n\tt.wg.Wait()\n\n\tfinal := int(t.final.Load())\n\tif final == -1 {\n\t\tfinal = t.MaxHops\n\t}\n\tt.res.reduce(final)\n\n\tif cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) {\n\t\treturn &t.res, cause\n\t}\n\treturn &t.res, nil\n}\n\nfunc (t *ICMPTracerv6) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, seq int) {\n\tmpls := extractMPLS(msg, t.DisableMPLS)\n\n\t// 非阻塞投递；如果队列已满则直接丢弃该任务\n\tselect {\n\tcase t.matchQ <- matchTask{\n\t\tseq: seq, peer: msg.Peer, finish: finish, mpls: mpls,\n\t}:\n\tdefault:\n\t\t// 丢弃以避免阻塞抓包循环\n\t}\n}\n\nfunc (t *ICMPTracerv6) send(ctx context.Context, s *internal.ICMPSpec, ttl, i int) error {\n\tdefer t.wg.Done()\n\n\tif t.ttlComp(ttl) {\n\t\t// 快路径短路：若该 TTL 已完成，直接返回避免竞争信号量与无谓发包\n\t\treturn nil\n\t}\n\n\tif err := acquireTraceSemaphore(ctx, t.sem); err != nil {\n\t\treturn err\n\t}\n\tdefer t.sem.Release(1)\n\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn nil\n\t}\n\n\tif t.ttlComp(ttl) {\n\t\t// 竞态兜底：获取信号量期间可能已完成，再次检查以避免冗余发包\n\t\treturn nil\n\t}\n\n\t// 将 TTL 编码到高 8 位；将索引 i 编码到低 8 位\n\tseq := (ttl << 8) | (i & 0xFF)\n\n\tipHeader := &layers.IPv6{\n\t\tVersion:      6,\n\t\tSrcIP:        t.SrcIP,\n\t\tDstIP:        t.DstIP,\n\t\tNextHeader:   layers.IPProtocolICMPv6,\n\t\tHopLimit:     uint8(ttl),\n\t\tTrafficClass: uint8(t.TOS),\n\t}\n\n\ticmpHeader := &layers.ICMPv6{\n\t\tTypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0),\n\t}\n\n\ticmpEcho := &layers.ICMPv6Echo{\n\t\tIdentifier: uint16(t.echoID),\n\t\tSeqNumber:  uint16(seq),\n\t}\n\n\tdesiredPayloadSize := resolveProbePayloadSize(ICMPTrace, t.DstIP, t.PktSize, t.RandomPacketSize)\n\tpayload := make([]byte, desiredPayloadSize)\n\n\tif desiredPayloadSize >= 3 {\n\t\tcopy(payload[desiredPayloadSize-3:], []byte{'n', 't', 'r'}) // \"ntr\" 作为标识\n\t}\n\n\t// 登记 pending，并启动超时守护\n\tt.markPending(seq)\n\tgo func(seq, ttl, i int) {\n\t\tif !waitForTraceDelay(ctx, t.Timeout) {\n\t\t\t_ = t.clearPending(seq)\n\t\t\treturn\n\t\t}\n\t\tif !t.clearPending(seq) {\n\t\t\treturn\n\t\t}\n\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\treturn\n\t\t}\n\t\tif t.ttlComp(ttl) {\n\t\t\treturn\n\t\t}\n\n\t\th := Hop{\n\t\t\tSuccess: false,\n\t\t\tAddress: nil,\n\t\t\tTTL:     ttl,\n\t\t\tRTT:     0,\n\t\t\tError:   errHopLimitTimeout,\n\t\t}\n\n\t\t_, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts)\n\t\tt.dropSent(seq)\n\t}(seq, ttl, i)\n\n\tstart, err := s.SendICMP(ctx, ipHeader, icmpHeader, icmpEcho, payload)\n\tif err != nil {\n\t\t_ = t.clearPending(seq)\n\t\treturn err\n\t}\n\tt.storeSent(seq, start)\n\treturn nil\n}\n"
  },
  {
    "path": "trace/internal/icmp_common.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype ipLayer interface {\n\tgopacket.NetworkLayer\n\tgopacket.SerializableLayer\n}\n\nfunc NewICMPSpec(IPVersion, ICMPMode, echoID int, srcIP, dstIP net.IP) *ICMPSpec {\n\treturn &ICMPSpec{IPVersion: IPVersion, ICMPMode: ICMPMode, EchoID: echoID, SrcIP: srcIP, DstIP: dstIP}\n}\n\nfunc (s *ICMPSpec) InitICMP() {\n\tnetwork := \"ip4:icmp\"\n\tif s.IPVersion == 6 {\n\t\tnetwork = \"ip6:ipv6-icmp\"\n\t}\n\n\ticmpConn, err := ListenPacket(network, s.SrcIP.String())\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitICMP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err))\n\t\t}\n\t\tlog.Fatalf(\"(InitICMP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err)\n\t}\n\ts.icmp = icmpConn\n\n\tif s.IPVersion == 4 {\n\t\ts.icmp4 = ipv4.NewPacketConn(s.icmp)\n\t} else {\n\t\ts.icmp6 = ipv6.NewPacketConn(s.icmp)\n\t}\n}\n\nfunc (s *ICMPSpec) listenICMPSock(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) {\n\tlc := NewPacketListener(s.icmp)\n\tgo lc.Start(ctx)\n\tclose(ready)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase msg, ok := <-lc.Messages:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfinish, seq, ok := s.decodeICMPSocketMessage(msg)\n\t\t\tif ok {\n\t\t\t\tonICMP(msg, finish, seq)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *ICMPSpec) decodeICMPSocketMessage(msg ReceivedMessage) (time.Time, int, bool) {\n\tif msg.Err != nil {\n\t\treturn time.Time{}, 0, false\n\t}\n\n\tfinish := time.Now()\n\trm, ok := parseSocketICMPMessage(s.IPVersion, msg.Msg)\n\tif !ok {\n\t\treturn finish, 0, false\n\t}\n\n\tif seq, ok := matchSocketICMPEchoReply(s.IPVersion, rm, util.AddrIP(msg.Peer), s.DstIP, s.EchoID); ok {\n\t\treturn finish, seq, true\n\t}\n\n\tdata, ok := extractSocketICMPPayload(s.IPVersion, rm, s.DstIP)\n\tif !ok {\n\t\treturn finish, 0, false\n\t}\n\n\tseq, ok := extractEmbeddedICMPSeq(data, s.EchoID)\n\treturn finish, seq, ok\n}\n"
  },
  {
    "path": "trace/internal/icmp_darwin.go",
    "content": "//go:build darwin\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n)\n\ntype ICMPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tEchoID       int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\ticmp         net.PacketConn\n\ticmp4        *ipv4.PacketConn\n\ticmp6        *ipv6.PacketConn\n\thopLimitLock sync.Mutex\n}\n\n// ---------------------------------------------------------------------------\n// icmpPacketConn 将 ICMP DGRAM socket 包装为 net.PacketConn，\n// 通过 os.File + syscall.RawConn 正确集成到 Go 运行时 poller，\n// 同时保持 ICMP 语义（ReadFrom 返回 *net.IPAddr）。\n// 这样可完全避免 //go:linkname 依赖。\n// ---------------------------------------------------------------------------\n\ntype icmpPacketConn struct {\n\tfile *os.File\n\trc   syscall.RawConn\n\taf   int // syscall.AF_INET or syscall.AF_INET6\n}\n\n// 编译期断言：icmpPacketConn 实现 net.PacketConn + net.Conn + syscall.Conn\nvar (\n\t_ net.PacketConn = (*icmpPacketConn)(nil)\n\t_ net.Conn       = (*icmpPacketConn)(nil)\n\t_ syscall.Conn   = (*icmpPacketConn)(nil)\n)\n\nfunc (c *icmpPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {\n\tvar (\n\t\tn       int\n\t\taddr    net.Addr\n\t\treadErr error\n\t)\n\terr := c.rc.Read(func(fd uintptr) bool {\n\t\tvar sa syscall.Sockaddr\n\t\tn, sa, readErr = syscall.Recvfrom(int(fd), b, 0)\n\t\tif readErr == syscall.EAGAIN || readErr == syscall.EWOULDBLOCK {\n\t\t\treturn false // 未就绪，让 poller 继续等待\n\t\t}\n\t\tif sa != nil {\n\t\t\tswitch s := sa.(type) {\n\t\t\tcase *syscall.SockaddrInet4:\n\t\t\t\tip := make(net.IP, 4)\n\t\t\t\tcopy(ip, s.Addr[:])\n\t\t\t\taddr = &net.IPAddr{IP: ip}\n\t\t\tcase *syscall.SockaddrInet6:\n\t\t\t\tip := make(net.IP, 16)\n\t\t\t\tcopy(ip, s.Addr[:])\n\t\t\t\taddr = &net.IPAddr{IP: ip, Zone: zoneToName(s.ZoneId)}\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\tif readErr != nil {\n\t\treturn 0, nil, readErr\n\t}\n\t// macOS DGRAM ICMP socket 返回数据包含外层 IP 头；\n\t// 模拟 net.IPConn.ReadFrom 行为，将其剥离以保持与解析层兼容。\n\tif c.af == syscall.AF_INET {\n\t\tn = stripIPv4Header(n, b)\n\t}\n\treturn n, addr, nil\n}\n\nfunc (c *icmpPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {\n\tsa, err := addrToSockaddr(addr, c.af)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tvar writeErr error\n\terr = c.rc.Write(func(fd uintptr) bool {\n\t\twriteErr = syscall.Sendto(int(fd), b, 0, sa)\n\t\tif writeErr == syscall.EAGAIN || writeErr == syscall.EWOULDBLOCK {\n\t\t\treturn false\n\t\t}\n\t\treturn true\n\t})\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif writeErr != nil {\n\t\treturn 0, writeErr\n\t}\n\treturn len(b), nil\n}\n\nfunc (c *icmpPacketConn) Close() error { return c.file.Close() }\n\nfunc (c *icmpPacketConn) LocalAddr() net.Addr {\n\tif c.af == syscall.AF_INET6 {\n\t\treturn &net.IPAddr{IP: net.IPv6zero}\n\t}\n\treturn &net.IPAddr{IP: net.IPv4zero}\n}\n\nfunc (c *icmpPacketConn) RemoteAddr() net.Addr               { return nil }\nfunc (c *icmpPacketConn) SetDeadline(t time.Time) error      { return c.file.SetDeadline(t) }\nfunc (c *icmpPacketConn) SetReadDeadline(t time.Time) error  { return c.file.SetReadDeadline(t) }\nfunc (c *icmpPacketConn) SetWriteDeadline(t time.Time) error { return c.file.SetWriteDeadline(t) }\n\n// Read 实现 net.Conn 接口（ipv4.NewPacketConn 内部需要）。\n// 对于无连接 ICMP socket，Read 等价于 ReadFrom 但丢弃源地址。\nfunc (c *icmpPacketConn) Read(b []byte) (int, error) {\n\tn, _, err := c.ReadFrom(b)\n\treturn n, err\n}\n\n// Write 实现 net.Conn 接口。对于无连接 socket 不可用。\nfunc (c *icmpPacketConn) Write(b []byte) (int, error) {\n\treturn 0, errors.New(\"Write not supported on unconnected ICMP socket; use WriteTo\")\n}\n\n// ReadMsgIP 实现 x/net/internal/socket.ipConn 接口，使得\n// socket.NewConn 能识别此连接为 \"ip\" 类型，从而正确初始化 socket.Conn。\n// NTrace 不实际调用此方法（读取走 ReadFrom / PacketListener），仅为接口满足。\nfunc (c *icmpPacketConn) ReadMsgIP(b, oob []byte) (n, oobn, flags int, addr *net.IPAddr, err error) {\n\tvar rn int\n\tvar rAddr *net.IPAddr\n\tvar readErr error\n\terr = c.rc.Read(func(fd uintptr) bool {\n\t\tvar sa syscall.Sockaddr\n\t\trn, _, readErr, sa = recvmsgRaw(int(fd), b, oob)\n\t\tif readErr == syscall.EAGAIN || readErr == syscall.EWOULDBLOCK {\n\t\t\treturn false\n\t\t}\n\t\tif sa != nil {\n\t\t\tswitch s := sa.(type) {\n\t\t\tcase *syscall.SockaddrInet4:\n\t\t\t\tip := make(net.IP, 4)\n\t\t\t\tcopy(ip, s.Addr[:])\n\t\t\t\trAddr = &net.IPAddr{IP: ip}\n\t\t\tcase *syscall.SockaddrInet6:\n\t\t\t\tip := make(net.IP, 16)\n\t\t\t\tcopy(ip, s.Addr[:])\n\t\t\t\trAddr = &net.IPAddr{IP: ip, Zone: zoneToName(s.ZoneId)}\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\tif err != nil {\n\t\treturn 0, 0, 0, nil, err\n\t}\n\tif readErr != nil {\n\t\treturn 0, 0, 0, nil, readErr\n\t}\n\treturn rn, 0, 0, rAddr, nil\n}\n\n// recvmsgRaw 使用 Recvfrom 实现简化版 recvmsg（不处理 OOB/control message）。\n// 对于 ICMP DGRAM socket，内核不会为我们生成 IP 头 control message，\n// 因此 OOB 数据始终为空。\nfunc recvmsgRaw(fd int, b, oob []byte) (n, oobn int, err error, sa syscall.Sockaddr) {\n\tn, sa, err = syscall.Recvfrom(fd, b, 0)\n\treturn n, 0, err, sa\n}\n\n// SyscallConn 让 ipv4.NewPacketConn / ipv6.NewPacketConn 能通过\n// setsockopt 设置 IP_TTL / IPV6_UNICAST_HOPS 等选项。\nfunc (c *icmpPacketConn) SyscallConn() (syscall.RawConn, error) { return c.rc, nil }\n\n// addrToSockaddr 将 net.Addr 转换为 syscall.Sockaddr\nfunc addrToSockaddr(addr net.Addr, af int) (syscall.Sockaddr, error) {\n\tvar ip net.IP\n\tswitch a := addr.(type) {\n\tcase *net.IPAddr:\n\t\tip = a.IP\n\tcase *net.UDPAddr:\n\t\tip = a.IP\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"icmpPacketConn: unsupported addr type %T\", addr)\n\t}\n\tif af == syscall.AF_INET {\n\t\tsa := &syscall.SockaddrInet4{}\n\t\tcopy(sa.Addr[:], ip.To4())\n\t\treturn sa, nil\n\t}\n\tsa := &syscall.SockaddrInet6{}\n\tcopy(sa.Addr[:], ip.To16())\n\treturn sa, nil\n}\n\n// stripIPv4Header 剥离 macOS DGRAM ICMP socket 返回数据中的 IPv4 头。\n// 逻辑与 Go 标准库 net.stripIPv4Header 一致（iprawsock_posix.go）。\nfunc stripIPv4Header(n int, b []byte) int {\n\tif len(b) < 20 {\n\t\treturn n\n\t}\n\tl := int(b[0]&0x0f) << 2\n\tif 20 > l || l > len(b) {\n\t\treturn n\n\t}\n\tif b[0]>>4 != 4 {\n\t\treturn n\n\t}\n\tcopy(b, b[l:])\n\treturn n - l\n}\n\n// zoneToName 将 IPv6 zone ID 转换为接口名\nfunc zoneToName(idx uint32) string {\n\tif idx == 0 {\n\t\treturn \"\"\n\t}\n\tiface, err := net.InterfaceByIndex(int(idx))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn iface.Name\n}\n\nvar (\n\terrUnknownNetwork = errors.New(\"unknown network type\")\n\terrUnknownIface   = errors.New(\"unknown network interface\")\n\tnetworkMap        = map[string]string{\n\t\t\"ip4:icmp\":      \"udp4\",\n\t\t\"ip4:1\":         \"udp4\",\n\t\t\"ip6:ipv6-icmp\": \"udp6\",\n\t\t\"ip6:58\":        \"udp6\",\n\t}\n)\n\ntype darwinICMPSocketSpec struct {\n\taf    int\n\tproto int\n}\n\nfunc darwinICMPSocketSpecForNetwork(network string) (darwinICMPSocketSpec, error) {\n\tnw, ok := networkMap[network]\n\tif !ok {\n\t\treturn darwinICMPSocketSpec{}, errUnknownNetwork\n\t}\n\tif nw == \"udp6\" {\n\t\treturn darwinICMPSocketSpec{af: syscall.AF_INET6, proto: syscall.IPPROTO_ICMPV6}, nil\n\t}\n\treturn darwinICMPSocketSpec{af: syscall.AF_INET, proto: syscall.IPPROTO_ICMP}, nil\n}\n\nfunc mustOpenDarwinICMPSocket(spec darwinICMPSocketSpec) int {\n\tfd, err := syscall.Socket(spec.af, syscall.SOCK_DGRAM, spec.proto)\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"ListenPacket: socket: %w\", err))\n\t\t}\n\t\tlog.Fatalf(\"ListenPacket: socket: %v\", err)\n\t}\n\treturn fd\n}\n\nfunc interfaceHasIP(iface net.Interface, target net.IP) bool {\n\taddrs, err := iface.Addrs()\n\tif err != nil {\n\t\treturn false\n\t}\n\tfor _, addr := range addrs {\n\t\tipnet, ok := addr.(*net.IPNet)\n\t\tif ok && ipnet.IP.Equal(target) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc interfaceIndexByIP(ip net.IP) (int, error) {\n\tifaces, err := net.Interfaces()\n\tif err != nil {\n\t\treturn -1, err\n\t}\n\tfor _, iface := range ifaces {\n\t\tif interfaceHasIP(iface, ip) {\n\t\t\treturn iface.Index, nil\n\t\t}\n\t}\n\treturn -1, errUnknownIface\n}\n\nfunc setDarwinBoundInterface(fd, proto, ifIndex int) error {\n\tif proto == syscall.IPPROTO_ICMP {\n\t\tif err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_BOUND_IF, ifIndex); err != nil {\n\t\t\treturn fmt.Errorf(\"setsockopt IP_BOUND_IF: %w\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tif err := syscall.SetsockoptInt(fd, syscall.IPPROTO_IPV6, syscall.IPV6_BOUND_IF, ifIndex); err != nil {\n\t\treturn fmt.Errorf(\"setsockopt IPV6_BOUND_IF: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc bindDarwinICMPInterface(fd, proto int, laddr string) error {\n\tif laddr == \"\" {\n\t\treturn nil\n\t}\n\tifIndex, err := interfaceIndexByIP(net.ParseIP(laddr))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn setDarwinBoundInterface(fd, proto, ifIndex)\n}\n\nfunc darwinICMPBindSockaddr(af int, laddr string) syscall.Sockaddr {\n\tif af == syscall.AF_INET {\n\t\tbindAddr := &syscall.SockaddrInet4{}\n\t\tif ip4 := net.ParseIP(laddr).To4(); ip4 != nil {\n\t\t\tcopy(bindAddr.Addr[:], ip4)\n\t\t}\n\t\treturn bindAddr\n\t}\n\n\tbindAddr := &syscall.SockaddrInet6{}\n\tif ip6 := net.ParseIP(laddr).To16(); ip6 != nil {\n\t\tcopy(bindAddr.Addr[:], ip6)\n\t}\n\treturn bindAddr\n}\n\nfunc bindDarwinICMPSocket(fd, af int, laddr string) error {\n\tif err := syscall.Bind(fd, darwinICMPBindSockaddr(af, laddr)); err != nil {\n\t\treturn fmt.Errorf(\"bind: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc finalizeDarwinICMPSocket(fd, af int) (net.PacketConn, error) {\n\tif err := syscall.SetNonblock(fd, true); err != nil {\n\t\t_ = syscall.Close(fd)\n\t\treturn nil, fmt.Errorf(\"setnonblock: %w\", err)\n\t}\n\n\tf := os.NewFile(uintptr(fd), \"icmp\")\n\tif f == nil {\n\t\t_ = syscall.Close(fd)\n\t\treturn nil, fmt.Errorf(\"os.NewFile returned nil\")\n\t}\n\n\trc, err := f.SyscallConn()\n\tif err != nil {\n\t\t_ = f.Close()\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"ListenPacket: SyscallConn: %w\", err))\n\t\t}\n\t\tlog.Fatalf(\"ListenPacket: SyscallConn: %v\", err)\n\t}\n\n\treturn &icmpPacketConn{file: f, rc: rc, af: af}, nil\n}\n\nfunc ListenPacket(network string, laddr string) (net.PacketConn, error) {\n\tspec, err := darwinICMPSocketSpecForNetwork(network)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfd := mustOpenDarwinICMPSocket(spec)\n\tif err := bindDarwinICMPInterface(fd, spec.proto, laddr); err != nil {\n\t\t_ = syscall.Close(fd)\n\t\treturn nil, err\n\t}\n\tif err := bindDarwinICMPSocket(fd, spec.af, laddr); err != nil {\n\t\t_ = syscall.Close(fd)\n\t\treturn nil, err\n\t}\n\n\treturn finalizeDarwinICMPSocket(fd, spec.af)\n}\n\nfunc (s *ICMPSpec) Close() {\n\t_ = s.icmp.Close()\n}\n\nfunc (s *ICMPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) {\n\ts.listenICMPSock(ctx, ready, onICMP)\n}\n\nfunc (s *ICMPSpec) SendICMP(ctx context.Context, ipHdr gopacket.NetworkLayer, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif s.IPVersion == 4 {\n\t\tip4, ok := ipHdr.(*layers.IPv4)\n\t\tif !ok || ip4 == nil {\n\t\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.IPv4 when s.IPVersion==4\")\n\t\t}\n\t\tttl := int(ip4.TTL)\n\n\t\tbuf := gopacket.NewSerializeBuffer()\n\t\topts := gopacket.SerializeOptions{\n\t\t\tComputeChecksums: true,\n\t\t\tFixLengths:       true,\n\t\t}\n\n\t\t// 序列化 ICMP 头与 payload 到缓冲区\n\t\tif err := gopacket.SerializeLayers(buf, opts, icmpHdr, gopacket.Payload(payload)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\t// 串行设置 TTL + 发送，放在同一把锁里保证并发安全\n\t\ts.hopLimitLock.Lock()\n\t\tdefer s.hopLimitLock.Unlock()\n\n\t\tif err := s.icmp4.SetTOS(int(ip4.TOS)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\tif err := s.icmp4.SetTTL(ttl); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\tstart := time.Now()\n\n\t\tif _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\treturn start, nil\n\t}\n\n\tip6, ok := ipHdr.(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.IPv6 when s.IPVersion==6\")\n\t}\n\tttl := int(ip6.HopLimit)\n\n\tic6, ok := icmpHdr.(*layers.ICMPv6)\n\tif !ok || ic6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.ICMPv6 when s.IPVersion==6\")\n\t}\n\n\tif err := ic6.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// 序列化 ICMP 头与 payload 到缓冲区\n\tif err := gopacket.SerializeLayers(buf, opts, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\t// 串行设置 HopLimit + 发送，放在同一把锁里保证并发安全\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.icmp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tif err := s.icmp6.SetHopLimit(ttl); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\tif _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n"
  },
  {
    "path": "trace/internal/icmp_decode.go",
    "content": "package internal\n\nimport (\n\t\"net\"\n\n\t\"golang.org/x/net/icmp\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc parseSocketICMPMessage(ipVersion int, raw []byte) (*icmp.Message, bool) {\n\tprotocol := 1\n\tif ipVersion == 6 {\n\t\tprotocol = 58\n\t}\n\trm, err := icmp.ParseMessage(protocol, raw)\n\tif err != nil {\n\t\treturn nil, false\n\t}\n\treturn rm, true\n}\n\nfunc matchSocketICMPEchoReply(ipVersion int, rm *icmp.Message, peerIP, dstIP net.IP, echoID int) (int, bool) {\n\tif peerIP == nil || !peerIP.Equal(dstIP) {\n\t\treturn 0, false\n\t}\n\tif !isSocketICMPEchoReply(ipVersion, rm) {\n\t\treturn 0, false\n\t}\n\techo, ok := rm.Body.(*icmp.Echo)\n\tif !ok || echo == nil || echo.ID != echoID {\n\t\treturn 0, false\n\t}\n\treturn echo.Seq, true\n}\n\nfunc isSocketICMPEchoReply(ipVersion int, rm *icmp.Message) bool {\n\tswitch ipVersion {\n\tcase 4:\n\t\treturn rm.Type == ipv4.ICMPTypeEchoReply\n\tcase 6:\n\t\treturn rm.Type == ipv6.ICMPTypeEchoReply\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc extractSocketICMPPayload(ipVersion int, rm *icmp.Message, dstIP net.IP) ([]byte, bool) {\n\tdata, ok := extractSocketICMPErrorBody(ipVersion, rm)\n\tif !ok || !matchesEmbeddedDstIP(ipVersion, data, dstIP) {\n\t\treturn nil, false\n\t}\n\treturn data, true\n}\n\nfunc extractSocketICMPErrorBody(ipVersion int, rm *icmp.Message) ([]byte, bool) {\n\tswitch ipVersion {\n\tcase 4:\n\t\treturn extractSocketICMPv4Body(rm)\n\tcase 6:\n\t\treturn extractSocketICMPv6Body(rm)\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc extractSocketICMPv4Body(rm *icmp.Message) ([]byte, bool) {\n\tswitch rm.Type {\n\tcase ipv4.ICMPTypeTimeExceeded:\n\t\tbody, ok := rm.Body.(*icmp.TimeExceeded)\n\t\treturn icmpTimeExceededData(body, ok)\n\tcase ipv4.ICMPTypeDestinationUnreachable:\n\t\tbody, ok := rm.Body.(*icmp.DstUnreach)\n\t\treturn icmpDstUnreachData(body, ok)\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc extractSocketICMPv6Body(rm *icmp.Message) ([]byte, bool) {\n\tswitch rm.Type {\n\tcase ipv6.ICMPTypeTimeExceeded:\n\t\tbody, ok := rm.Body.(*icmp.TimeExceeded)\n\t\treturn icmpTimeExceededData(body, ok)\n\tcase ipv6.ICMPTypePacketTooBig:\n\t\tbody, ok := rm.Body.(*icmp.PacketTooBig)\n\t\treturn icmpPacketTooBigData(body, ok)\n\tcase ipv6.ICMPTypeDestinationUnreachable:\n\t\tbody, ok := rm.Body.(*icmp.DstUnreach)\n\t\treturn icmpDstUnreachData(body, ok)\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc icmpTimeExceededData(body *icmp.TimeExceeded, ok bool) ([]byte, bool) {\n\tif !ok || body == nil {\n\t\treturn nil, false\n\t}\n\treturn body.Data, true\n}\n\nfunc icmpDstUnreachData(body *icmp.DstUnreach, ok bool) ([]byte, bool) {\n\tif !ok || body == nil {\n\t\treturn nil, false\n\t}\n\treturn body.Data, true\n}\n\nfunc icmpPacketTooBigData(body *icmp.PacketTooBig, ok bool) ([]byte, bool) {\n\tif !ok || body == nil {\n\t\treturn nil, false\n\t}\n\treturn body.Data, true\n}\n\nfunc matchesEmbeddedDstIP(ipVersion int, data []byte, dstIP net.IP) bool {\n\tembeddedDstIP, ok := extractEmbeddedDstIP(ipVersion, data)\n\tif !ok {\n\t\treturn false\n\t}\n\treturn embeddedDstIP.Equal(dstIP)\n}\n\nfunc extractEmbeddedDstIP(ipVersion int, data []byte) (net.IP, bool) {\n\tswitch ipVersion {\n\tcase 4:\n\t\tif len(data) < 20 || data[0]>>4 != 4 {\n\t\t\treturn nil, false\n\t\t}\n\t\treturn net.IP(data[16:20]), true\n\tcase 6:\n\t\tif len(data) < 40 || data[0]>>4 != 6 {\n\t\t\treturn nil, false\n\t\t}\n\t\treturn net.IP(data[24:40]), true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc extractEmbeddedICMPSeq(data []byte, echoID int) (int, bool) {\n\theader, err := util.GetICMPResponsePayload(data)\n\tif err != nil {\n\t\treturn 0, false\n\t}\n\tid, err := util.GetICMPID(header)\n\tif err != nil || id != echoID {\n\t\treturn 0, false\n\t}\n\tseq, err := util.GetICMPSeq(header)\n\tif err != nil {\n\t\treturn 0, false\n\t}\n\treturn seq, true\n}\n"
  },
  {
    "path": "trace/internal/icmp_decode_test.go",
    "content": "package internal\n\nimport (\n\t\"encoding/binary\"\n\t\"net\"\n\t\"testing\"\n\n\t\"golang.org/x/net/icmp\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n)\n\nfunc mustMarshalICMP(t *testing.T, message icmp.Message) []byte {\n\tt.Helper()\n\traw, err := message.Marshal(nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Marshal() error = %v\", err)\n\t}\n\treturn raw\n}\n\nfunc buildIPv4InnerPacket(dstIP net.IP, echoID, seq int) []byte {\n\tpacket := make([]byte, 28)\n\tpacket[0] = 0x45\n\tcopy(packet[16:20], dstIP.To4())\n\tbinary.BigEndian.PutUint16(packet[24:26], uint16(echoID))\n\tbinary.BigEndian.PutUint16(packet[26:28], uint16(seq))\n\treturn packet\n}\n\nfunc buildIPv6InnerPacket(dstIP net.IP, echoID, seq int) []byte {\n\tpacket := make([]byte, 48)\n\tpacket[0] = 0x60\n\tpacket[6] = 58\n\tcopy(packet[24:40], dstIP.To16())\n\tbinary.BigEndian.PutUint16(packet[44:46], uint16(echoID))\n\tbinary.BigEndian.PutUint16(packet[46:48], uint16(seq))\n\treturn packet\n}\n\nfunc TestMatchSocketICMPEchoReplyIPv4(t *testing.T) {\n\tdstIP := net.ParseIP(\"1.1.1.1\")\n\traw := mustMarshalICMP(t, icmp.Message{\n\t\tType: ipv4.ICMPTypeEchoReply,\n\t\tCode: 0,\n\t\tBody: &icmp.Echo{ID: 7, Seq: 11},\n\t})\n\n\trm, ok := parseSocketICMPMessage(4, raw)\n\tif !ok {\n\t\tt.Fatalf(\"parseSocketICMPMessage() ok = false\")\n\t}\n\tseq, ok := matchSocketICMPEchoReply(4, rm, dstIP, dstIP, 7)\n\tif !ok || seq != 11 {\n\t\tt.Fatalf(\"matchSocketICMPEchoReply() = (%d, %v), want (11, true)\", seq, ok)\n\t}\n}\n\nfunc TestMatchSocketICMPEchoReplyIPv6(t *testing.T) {\n\tdstIP := net.ParseIP(\"2001:db8::1\")\n\traw := mustMarshalICMP(t, icmp.Message{\n\t\tType: ipv6.ICMPTypeEchoReply,\n\t\tCode: 0,\n\t\tBody: &icmp.Echo{ID: 9, Seq: 21},\n\t})\n\n\trm, ok := parseSocketICMPMessage(6, raw)\n\tif !ok {\n\t\tt.Fatalf(\"parseSocketICMPMessage() ok = false\")\n\t}\n\tseq, ok := matchSocketICMPEchoReply(6, rm, dstIP, dstIP, 9)\n\tif !ok || seq != 21 {\n\t\tt.Fatalf(\"matchSocketICMPEchoReply() = (%d, %v), want (21, true)\", seq, ok)\n\t}\n}\n\nfunc TestExtractSocketICMPPayloadIPv4(t *testing.T) {\n\tdstIP := net.ParseIP(\"8.8.8.8\")\n\tinner := buildIPv4InnerPacket(dstIP, 13, 99)\n\traw := mustMarshalICMP(t, icmp.Message{\n\t\tType: ipv4.ICMPTypeTimeExceeded,\n\t\tCode: 0,\n\t\tBody: &icmp.TimeExceeded{Data: inner},\n\t})\n\n\trm, ok := parseSocketICMPMessage(4, raw)\n\tif !ok {\n\t\tt.Fatalf(\"parseSocketICMPMessage() ok = false\")\n\t}\n\tdata, ok := extractSocketICMPPayload(4, rm, dstIP)\n\tif !ok {\n\t\tt.Fatalf(\"extractSocketICMPPayload() ok = false\")\n\t}\n\tif seq, ok := extractEmbeddedICMPSeq(data, 13); !ok || seq != 99 {\n\t\tt.Fatalf(\"extractEmbeddedICMPSeq() = (%d, %v), want (99, true)\", seq, ok)\n\t}\n}\n\nfunc TestExtractSocketICMPPayloadIPv6(t *testing.T) {\n\tdstIP := net.ParseIP(\"2001:db8::2\")\n\tinner := buildIPv6InnerPacket(dstIP, 17, 123)\n\traw := mustMarshalICMP(t, icmp.Message{\n\t\tType: ipv6.ICMPTypeDestinationUnreachable,\n\t\tCode: 0,\n\t\tBody: &icmp.DstUnreach{Data: inner},\n\t})\n\n\trm, ok := parseSocketICMPMessage(6, raw)\n\tif !ok {\n\t\tt.Fatalf(\"parseSocketICMPMessage() ok = false\")\n\t}\n\tdata, ok := extractSocketICMPPayload(6, rm, dstIP)\n\tif !ok {\n\t\tt.Fatalf(\"extractSocketICMPPayload() ok = false\")\n\t}\n\tif seq, ok := extractEmbeddedICMPSeq(data, 17); !ok || seq != 123 {\n\t\tt.Fatalf(\"extractEmbeddedICMPSeq() = (%d, %v), want (123, true)\", seq, ok)\n\t}\n}\n\nfunc TestExtractSocketICMPPayloadRejectsWrongDestination(t *testing.T) {\n\traw := mustMarshalICMP(t, icmp.Message{\n\t\tType: ipv4.ICMPTypeDestinationUnreachable,\n\t\tCode: 0,\n\t\tBody: &icmp.DstUnreach{Data: buildIPv4InnerPacket(net.ParseIP(\"9.9.9.9\"), 3, 5)},\n\t})\n\n\trm, ok := parseSocketICMPMessage(4, raw)\n\tif !ok {\n\t\tt.Fatalf(\"parseSocketICMPMessage() ok = false\")\n\t}\n\tif _, ok := extractSocketICMPPayload(4, rm, net.ParseIP(\"8.8.8.8\")); ok {\n\t\tt.Fatalf(\"extractSocketICMPPayload() ok = true, want false\")\n\t}\n}\n"
  },
  {
    "path": "trace/internal/icmp_unix.go",
    "content": "//go:build !darwin && !(windows && amd64)\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n)\n\ntype ICMPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tEchoID       int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\ticmp         net.PacketConn\n\ticmp4        *ipv4.PacketConn\n\ticmp6        *ipv6.PacketConn\n\thopLimitLock sync.Mutex\n}\n\nfunc ListenPacket(network string, laddr string) (net.PacketConn, error) {\n\treturn net.ListenPacket(network, laddr)\n}\n\nfunc (s *ICMPSpec) Close() {\n\t_ = s.icmp.Close()\n}\n\nfunc (s *ICMPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) {\n\ts.listenICMPSock(ctx, ready, onICMP)\n}\n\nfunc (s *ICMPSpec) SendICMP(ctx context.Context, ipHdr gopacket.NetworkLayer, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif s.IPVersion == 4 {\n\t\tip4, ok := ipHdr.(*layers.IPv4)\n\t\tif !ok || ip4 == nil {\n\t\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.IPv4 when s.IPVersion==4\")\n\t\t}\n\t\tttl := int(ip4.TTL)\n\n\t\tbuf := gopacket.NewSerializeBuffer()\n\t\topts := gopacket.SerializeOptions{\n\t\t\tComputeChecksums: true,\n\t\t\tFixLengths:       true,\n\t\t}\n\n\t\t// 序列化 ICMP 头与 payload 到缓冲区\n\t\tif err := gopacket.SerializeLayers(buf, opts, icmpHdr, gopacket.Payload(payload)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\t// 串行设置 TTL + 发送，放在同一把锁里保证并发安全\n\t\ts.hopLimitLock.Lock()\n\t\tdefer s.hopLimitLock.Unlock()\n\n\t\tif err := s.icmp4.SetTOS(int(ip4.TOS)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\tif err := s.icmp4.SetTTL(ttl); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\tstart := time.Now()\n\n\t\tif _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\treturn start, nil\n\t}\n\n\tip6, ok := ipHdr.(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.IPv6 when s.IPVersion==6\")\n\t}\n\tttl := int(ip6.HopLimit)\n\n\tic6, ok := icmpHdr.(*layers.ICMPv6)\n\tif !ok || ic6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.ICMPv6 when s.IPVersion==6\")\n\t}\n\n\tif err := ic6.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// 序列化 ICMP 头与 payload 到缓冲区\n\tif err := gopacket.SerializeLayers(buf, opts, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\t// 串行设置 HopLimit + 发送，放在同一把锁里保证并发安全\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.icmp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tif err := s.icmp6.SetHopLimit(ttl); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\tif _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n"
  },
  {
    "path": "trace/internal/icmp_windows.go",
    "content": "//go:build windows && amd64\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n\twd \"github.com/xjasonlyu/windivert-go\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n)\n\ntype ICMPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tEchoID       int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\ticmp         net.PacketConn\n\ticmp4        *ipv4.PacketConn\n\ticmp6        *ipv6.PacketConn\n\tsendHandle   wd.Handle\n\tsendAddr     wd.Address\n\thopLimitLock sync.Mutex\n}\n\nfunc ListenPacket(network string, laddr string) (net.PacketConn, error) {\n\treturn net.ListenPacket(network, laddr)\n}\n\nfunc (s *ICMPSpec) Close() {\n\t_ = s.icmp.Close()\n\tif s.sendHandle != 0 {\n\t\t_ = s.sendHandle.Close()\n\t}\n}\n\n// winDivertAvailable 通过尝试打开一个 WinDivert 嗅探 handle 来判断 WinDivert 是否可用\nfunc winDivertAvailable() (bool, error) {\n\th, err := wd.Open(\"false\", wd.LayerNetwork, 0, wd.FlagSniff|wd.FlagRecvOnly)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"WinDivert 不可用: %v\", err)\n\t}\n\t_ = h.Close()\n\treturn true, nil\n}\n\n// resolveICMPMode 进行最终模式判定\n// 1=Socket, 2=WinDivert (嗅探模式，原 PCAP 模式的替代)\nfunc (s *ICMPSpec) resolveICMPMode() int {\n\ticmpMode := s.ICMPMode\n\tif icmpMode != 1 && icmpMode != 2 {\n\t\ticmpMode = 0 // 统一成 Auto\n\t}\n\n\t// 指定 1=Socket：直接返回\n\tif icmpMode == 1 {\n\t\treturn 1\n\t}\n\n\t// Auto(0) 或强制 Sniff(2) → 尝试 WinDivert\n\tif !util.HasAdminPrivileges() {\n\t\tif icmpMode == 2 {\n\t\t\tlog.Printf(\"请求使用 WinDivert 嗅探模式，但当前缺少管理员权限；已回退到 Socket 模式。\")\n\t\t}\n\t\treturn 1\n\t}\n\n\tok, err := winDivertAvailable()\n\tif !ok {\n\t\tif icmpMode == 2 {\n\t\t\tlog.Printf(\"请求使用 WinDivert 嗅探模式，但 WinDivert 不可用: %v；已回退到 Socket 模式。\", err)\n\t\t}\n\t\treturn 1\n\t}\n\treturn 2\n}\n\nfunc (s *ICMPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) {\n\tswitch s.resolveICMPMode() {\n\tcase 1:\n\t\ts.listenICMPSock(ctx, ready, onICMP)\n\tcase 2:\n\t\ts.listenICMPWinDivert(ctx, ready, onICMP)\n\t}\n}\n\nfunc (s *ICMPSpec) listenICMPWinDivert(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, seq int)) {\n\thandle, closeHandle := openWinDivertSniffHandle(ctx, winDivertICMPFilter(s.IPVersion, s.SrcIP), \"ListenICMP\")\n\tdefer closeHandle()\n\tclose(ready)\n\n\tbuf := make([]byte, 65535)\n\tvar addr wd.Address\n\n\tfor {\n\t\traw, finish, ok := receiveWinDivertPacket(ctx, handle, buf, &addr)\n\t\tif !ok {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tpacket, ok := decodeWinDivertICMPPacket(s.IPVersion, raw)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tmsg := packet.message()\n\t\tif seq, ok := packet.echoReplyFor(s.DstIP, s.EchoID); ok {\n\t\t\tonICMP(msg, finish, seq)\n\t\t\tcontinue\n\t\t}\n\n\t\tdata, ok := packet.errorPayloadFor(s.DstIP)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif seq, ok := extractEmbeddedICMPSeq(data, s.EchoID); ok {\n\t\t\tonICMP(msg, finish, seq)\n\t\t}\n\t}\n}\n\nfunc (s *ICMPSpec) SendICMP(ctx context.Context, ipHdr gopacket.NetworkLayer, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif s.IPVersion == 4 {\n\t\tip4, ok := ipHdr.(*layers.IPv4)\n\t\tif !ok || ip4 == nil {\n\t\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.IPv4 when s.IPVersion==4\")\n\t\t}\n\t\tttl := int(ip4.TTL)\n\n\t\tbuf := gopacket.NewSerializeBuffer()\n\t\topts := gopacket.SerializeOptions{\n\t\t\tComputeChecksums: true,\n\t\t\tFixLengths:       true,\n\t\t}\n\n\t\t// 序列化 ICMP 头与 payload 到缓冲区\n\t\tif err := gopacket.SerializeLayers(buf, opts, icmpHdr, gopacket.Payload(payload)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\t// 串行设置 TTL + 发送，放在同一把锁里保证并发安全\n\t\ts.hopLimitLock.Lock()\n\t\tdefer s.hopLimitLock.Unlock()\n\n\t\tif err := s.icmp4.SetTOS(int(ip4.TOS)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\tif err := s.icmp4.SetTTL(ttl); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\tstart := time.Now()\n\n\t\tif _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\treturn start, nil\n\t}\n\n\tip6, ok := ipHdr.(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.IPv6 when s.IPVersion==6\")\n\t}\n\tttl := int(ip6.HopLimit)\n\n\tic6, ok := icmpHdr.(*layers.ICMPv6)\n\tif !ok || ic6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendICMP: expect *layers.ICMPv6 when s.IPVersion==6\")\n\t}\n\n\tif err := ic6.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tif shouldUseICMPv6RawSend(ip6) {\n\t\treturn s.sendICMPv6WithWinDivert(ip6, icmpHdr, icmpEcho, payload)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// Socket path only needs the ICMPv6 payload; the kernel prepends the IPv6 header.\n\tif err := gopacket.SerializeLayers(buf, opts, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\t// 串行设置 HopLimit + 发送，放在同一把锁里保证并发安全\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.icmp6.SetHopLimit(ttl); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\tif _, err := s.icmp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n\nfunc shouldUseICMPv6RawSend(ip6 *layers.IPv6) bool {\n\treturn ip6 != nil && ip6.TrafficClass != 0\n}\n\nfunc (s *ICMPSpec) sendICMPv6WithWinDivert(ip6 *layers.IPv6, icmpHdr, icmpEcho gopacket.SerializableLayer, payload []byte) (time.Time, error) {\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.ensureICMPSendHandle(true); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\tif err := gopacket.SerializeLayers(buf, opts, ip6, icmpHdr, icmpEcho, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\tif _, err := s.sendHandle.Send(buf.Bytes(), &s.sendAddr); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n\nfunc (s *ICMPSpec) ensureICMPSendHandle(ipv6 bool) error {\n\tif s.sendHandle != 0 {\n\t\treturn nil\n\t}\n\n\thandle, err := wd.Open(\"false\", wd.LayerNetwork, 0, 0)\n\tif err != nil {\n\t\tif ipv6 {\n\t\t\treturn fmt.Errorf(\"ICMPv6 --tos on Windows requires WinDivert send support: %w\", err)\n\t\t}\n\t\treturn err\n\t}\n\n\ts.sendHandle = handle\n\ts.sendAddr.SetLayer(wd.LayerNetwork)\n\ts.sendAddr.SetEvent(wd.EventNetworkPacket)\n\ts.sendAddr.SetOutbound()\n\tif ipv6 {\n\t\ts.sendAddr.SetIPv6()\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "trace/internal/icmp_windows_test.go",
    "content": "//go:build windows && amd64\n\npackage internal\n\nimport (\n\t\"testing\"\n\n\t\"github.com/google/gopacket/layers\"\n)\n\nfunc TestShouldUseICMPv6RawSend(t *testing.T) {\n\tif shouldUseICMPv6RawSend(nil) {\n\t\tt.Fatal(\"nil header should not use raw send\")\n\t}\n\tif shouldUseICMPv6RawSend(&layers.IPv6{}) {\n\t\tt.Fatal(\"zero traffic class should keep socket send\")\n\t}\n\tif !shouldUseICMPv6RawSend(&layers.IPv6{TrafficClass: 46}) {\n\t\tt.Fatal(\"non-zero traffic class should use raw send\")\n\t}\n}\n"
  },
  {
    "path": "trace/internal/packet_listener.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"time\"\n)\n\ntype ReceivedMessage struct {\n\tPeer net.Addr\n\tMsg  []byte\n\tErr  error\n}\n\n// PacketListener 负责监听网络数据包并通过通道传递接收到的消息\n// 对外暴露只读的 Messages，避免外部代码误写\ntype PacketListener struct {\n\tConn     net.PacketConn\n\tMessages <-chan ReceivedMessage\n\tch       chan ReceivedMessage\n}\n\n// NewPacketListener 创建一个新的数据包监听器\n// conn: 用于接收数据包的连接\n// 返回初始化好的 PacketListener 实例\nfunc NewPacketListener(conn net.PacketConn) *PacketListener {\n\tch := make(chan ReceivedMessage, 64)\n\n\treturn &PacketListener{Conn: conn, Messages: ch, ch: ch}\n}\n\nfunc (l *PacketListener) Start(ctx context.Context) {\n\tdefer close(l.ch)\n\n\tgo func() {\n\t\t<-ctx.Done()\n\t\t_ = l.Conn.Close()\n\t}()\n\n\tbuf := make([]byte, 4096)\n\n\tfor {\n\t\tn, peer, err := l.Conn.ReadFrom(buf)\n\t\tif err != nil {\n\t\t\t// 连接关闭或 ctx 取消：直接退出\n\t\t\tif errors.Is(err, net.ErrClosed) || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 限时等待投递错误；超时或取消就丢弃/退出\n\t\t\tselect {\n\t\t\tcase l.ch <- ReceivedMessage{Err: err}:\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-time.After(5 * time.Second):\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif n == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 拷贝出精确长度，避免 buf 复用带来的数据竞争\n\t\tpkt := make([]byte, n)\n\t\tcopy(pkt, buf[:n])\n\n\t\t// 限时等待投递数据；超时或取消就丢弃/退出\n\t\tselect {\n\t\tcase l.ch <- ReceivedMessage{Peer: peer, Msg: pkt}:\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-time.After(5 * time.Second):\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "trace/internal/tcp_common.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc NewTCPSpec(IPVersion, ICMPMode int, srcIP, dstIP net.IP, dstPort int, pktSize int) *TCPSpec {\n\treturn &TCPSpec{IPVersion: IPVersion, ICMPMode: ICMPMode, SrcIP: srcIP, DstIP: dstIP, DstPort: dstPort, PktSize: pktSize}\n}\n\nfunc (s *TCPSpec) InitICMP() {\n\tnetwork := \"ip4:icmp\"\n\tif s.IPVersion == 6 {\n\t\tnetwork = \"ip6:ipv6-icmp\"\n\t}\n\n\ticmpConn, err := net.ListenPacket(network, s.SrcIP.String())\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitICMP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err))\n\t\t}\n\t\tlog.Fatalf(\"(InitICMP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err)\n\t}\n\ts.icmp = icmpConn\n}\n\nfunc (s *TCPSpec) listenICMPSock(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\tlc := NewPacketListener(s.icmp)\n\tgo lc.Start(ctx)\n\tclose(ready)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase msg, ok := <-lc.Messages:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfinish, data, ok := s.decodeICMPSocketMessage(msg)\n\t\t\tif ok {\n\t\t\t\tonICMP(msg, finish, data)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *TCPSpec) decodeICMPSocketMessage(msg ReceivedMessage) (time.Time, []byte, bool) {\n\tif msg.Err != nil {\n\t\treturn time.Time{}, nil, false\n\t}\n\n\tfinish := time.Now()\n\trm, ok := parseSocketICMPMessage(s.IPVersion, msg.Msg)\n\tif !ok {\n\t\treturn finish, nil, false\n\t}\n\n\tdata, ok := extractSocketICMPPayload(s.IPVersion, rm, s.DstIP)\n\treturn finish, data, ok\n}\n"
  },
  {
    "path": "trace/internal/tcp_darwin.go",
    "content": "//go:build darwin\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"github.com/google/gopacket/pcap\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype TCPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\tDstPort      int\n\tPktSize      int\n\tSourceDevice string\n\ticmp         net.PacketConn\n\ttcp          net.PacketConn\n\ttcp4         *ipv4.PacketConn\n\ttcp6         *ipv6.PacketConn\n\thopLimitLock sync.Mutex\n}\n\nfunc (s *TCPSpec) InitTCP() {\n\tnetwork := \"ip4:tcp\"\n\tif s.IPVersion == 6 {\n\t\tnetwork = \"ip6:tcp\"\n\t}\n\n\ttcp, err := net.ListenPacket(network, s.SrcIP.String())\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitTCP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err))\n\t\t}\n\t\tlog.Fatalf(\"(InitTCP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err)\n\t}\n\ts.tcp = tcp\n\n\tif s.IPVersion == 4 {\n\t\ts.tcp4 = ipv4.NewPacketConn(s.tcp)\n\t} else {\n\t\ts.tcp6 = ipv6.NewPacketConn(s.tcp)\n\t}\n}\n\nfunc (s *TCPSpec) Close() {\n\t_ = s.icmp.Close()\n\t_ = s.tcp.Close()\n}\n\nfunc (s *TCPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\ts.listenICMPSock(ctx, ready, onICMP)\n}\n\nfunc (s *TCPSpec) captureDevice() string {\n\tif s.SourceDevice != \"\" {\n\t\treturn s.SourceDevice\n\t}\n\tif dev, err := util.PcapDeviceByIP(s.SrcIP); err == nil {\n\t\treturn dev\n\t}\n\treturn \"en0\"\n}\n\nfunc (s *TCPSpec) tcpCaptureFilter() string {\n\treturn fmt.Sprintf(\n\t\t\"%s and tcp and src host %s and dst host %s and src port %d\",\n\t\ttcpIPVersionPrefix(s.IPVersion), s.DstIP.String(), s.SrcIP.String(), s.DstPort,\n\t)\n}\n\nfunc mustOpenDarwinTCPSniffHandle(dev string) *pcap.Handle {\n\thandle, err := util.OpenLiveImmediate(dev, 65535, true, 4<<20)\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(ListenTCP) pcap open failed on %s: %v\", dev, err))\n\t\t}\n\t\tlog.Fatalf(\"(ListenTCP) pcap open failed on %s: %v\", dev, err)\n\t}\n\treturn handle\n}\n\nfunc mustSetDarwinTCPFilter(handle *pcap.Handle, filter string) {\n\tif err := handle.SetBPFFilter(filter); err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(ListenTCP) set BPF failed: %v (filter=%q)\", err, filter))\n\t\t}\n\t\tlog.Fatalf(\"(ListenTCP) set BPF failed: %v (filter=%q)\", err, filter)\n\t}\n}\n\nfunc (s *TCPSpec) ListenTCP(ctx context.Context, ready chan struct{}, onTCP func(srcPort, seq, ack int, peer net.Addr, finish time.Time)) {\n\thandle := mustOpenDarwinTCPSniffHandle(s.captureDevice())\n\tdefer handle.Close()\n\n\tmustSetDarwinTCPFilter(handle, s.tcpCaptureFilter())\n\tsrc := gopacket.NewPacketSource(handle, handle.LinkType())\n\tpktCh := src.Packets()\n\tclose(ready)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase pkt, ok := <-pktCh:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfinish := pkt.Metadata().Timestamp\n\t\t\tsrcPort, seq, ack, peer, ok := decodeTCPProbePacket(s.IPVersion, s.DstPort, pkt)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tonTCP(srcPort, seq, ack, peer, finish)\n\t\t}\n\t}\n}\n\nfunc (s *TCPSpec) SendTCP(ctx context.Context, ipHdr gopacket.NetworkLayer, tcpHdr *layers.TCP, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif s.IPVersion == 4 {\n\t\tip4, ok := ipHdr.(*layers.IPv4)\n\t\tif !ok || ip4 == nil {\n\t\t\treturn time.Time{}, errors.New(\"SendTCP: expect *layers.IPv4 when s.IPVersion==4\")\n\t\t}\n\t\tttl := int(ip4.TTL)\n\n\t\tif err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t\t}\n\n\t\tbuf := gopacket.NewSerializeBuffer()\n\t\topts := gopacket.SerializeOptions{\n\t\t\tComputeChecksums: true,\n\t\t\tFixLengths:       true,\n\t\t}\n\n\t\t// 序列化 TCP 头与 payload 到缓冲区\n\t\tif err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\t// 串行设置 TTL + 发送，放在同一把锁里保证并发安全\n\t\ts.hopLimitLock.Lock()\n\t\tdefer s.hopLimitLock.Unlock()\n\n\t\tif err := s.tcp4.SetTOS(int(ip4.TOS)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\tif err := s.tcp4.SetTTL(ttl); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\tstart := time.Now()\n\n\t\tif _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\treturn start, nil\n\t}\n\n\tip6, ok := ipHdr.(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendTCP: expect *layers.IPv6 when s.IPVersion==6\")\n\t}\n\tttl := int(ip6.HopLimit)\n\n\tif err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// 序列化 TCP 头与 payload 到缓冲区\n\tif err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\t// 串行设置 HopLimit + 发送，放在同一把锁里保证并发安全\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.tcp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tif err := s.tcp6.SetHopLimit(ttl); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\tif _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n"
  },
  {
    "path": "trace/internal/tcp_probe_decode.go",
    "content": "package internal\n\nimport (\n\t\"net\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n)\n\nfunc tcpIPVersionPrefix(ipVersion int) string {\n\tif ipVersion == 6 {\n\t\treturn \"ip6\"\n\t}\n\treturn \"ip\"\n}\n\nfunc tcpProbeReply(tcp *layers.TCP) (seq int, ack int, ok bool) {\n\tif tcp == nil {\n\t\treturn 0, 0, false\n\t}\n\tif tcp.ACK && tcp.RST {\n\t\treturn 0, int(tcp.Ack), true\n\t}\n\tif tcp.ACK && tcp.SYN {\n\t\treturn int(tcp.Ack) - 1, 0, true\n\t}\n\treturn 0, 0, false\n}\n\nfunc tcpProbePeerIP(ipVersion int, pkt gopacket.Packet) (net.IP, bool) {\n\tif ipVersion == 4 {\n\t\tip4, ok := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4)\n\t\tif !ok || ip4 == nil {\n\t\t\treturn nil, false\n\t\t}\n\t\treturn ip4.SrcIP, true\n\t}\n\n\tip6, ok := pkt.Layer(layers.LayerTypeIPv6).(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn nil, false\n\t}\n\treturn ip6.SrcIP, true\n}\n\nfunc decodeTCPProbePacket(ipVersion, dstPort int, pkt gopacket.Packet) (srcPort, seq, ack int, peer net.Addr, ok bool) {\n\ttcp, ok := pkt.Layer(layers.LayerTypeTCP).(*layers.TCP)\n\tif !ok || tcp == nil || int(tcp.SrcPort) != dstPort {\n\t\treturn 0, 0, 0, nil, false\n\t}\n\n\tseq, ack, ok = tcpProbeReply(tcp)\n\tif !ok {\n\t\treturn 0, 0, 0, nil, false\n\t}\n\n\tpeerIP, ok := tcpProbePeerIP(ipVersion, pkt)\n\tif !ok {\n\t\treturn 0, 0, 0, nil, false\n\t}\n\n\treturn int(tcp.DstPort), seq, ack, &net.IPAddr{IP: peerIP}, true\n}\n"
  },
  {
    "path": "trace/internal/tcp_probe_decode_test.go",
    "content": "package internal\n\nimport (\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n)\n\nfunc mustSerializeTCPProbePacket(t *testing.T, ipLayer gopacket.NetworkLayer, tcp *layers.TCP) gopacket.Packet {\n\tt.Helper()\n\n\tif err := tcp.SetNetworkLayerForChecksum(ipLayer); err != nil {\n\t\tt.Fatalf(\"SetNetworkLayerForChecksum() error = %v\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\tswitch ipLayer.(type) {\n\tcase *layers.IPv4:\n\t\tif err := gopacket.SerializeLayers(buf, opts, ipLayer.(*layers.IPv4), tcp); err != nil {\n\t\t\tt.Fatalf(\"SerializeLayers() error = %v\", err)\n\t\t}\n\t\treturn gopacket.NewPacket(buf.Bytes(), layers.LayerTypeIPv4, gopacket.NoCopy)\n\tcase *layers.IPv6:\n\t\tif err := gopacket.SerializeLayers(buf, opts, ipLayer.(*layers.IPv6), tcp); err != nil {\n\t\t\tt.Fatalf(\"SerializeLayers() error = %v\", err)\n\t\t}\n\t\treturn gopacket.NewPacket(buf.Bytes(), layers.LayerTypeIPv6, gopacket.NoCopy)\n\tdefault:\n\t\tt.Fatalf(\"unexpected IP layer type %T\", ipLayer)\n\t\treturn nil\n\t}\n}\n\nfunc TestDecodeTCPProbePacketIPv4RSTAck(t *testing.T) {\n\tsrcIP := net.ParseIP(\"1.1.1.1\")\n\tdstIP := net.ParseIP(\"2.2.2.2\")\n\tip4 := &layers.IPv4{\n\t\tVersion:  4,\n\t\tIHL:      5,\n\t\tProtocol: layers.IPProtocolTCP,\n\t\tSrcIP:    srcIP,\n\t\tDstIP:    dstIP,\n\t}\n\ttcp := &layers.TCP{\n\t\tSrcPort: 443,\n\t\tDstPort: 32100,\n\t\tACK:     true,\n\t\tRST:     true,\n\t\tAck:     200,\n\t}\n\n\tpkt := mustSerializeTCPProbePacket(t, ip4, tcp)\n\tsrcPort, seq, ack, peer, ok := decodeTCPProbePacket(4, 443, pkt)\n\tif !ok {\n\t\tt.Fatalf(\"decodeTCPProbePacket() ok = false\")\n\t}\n\tif srcPort != 32100 || seq != 0 || ack != 200 {\n\t\tt.Fatalf(\"decodeTCPProbePacket() = (%d, %d, %d), want (32100, 0, 200)\", srcPort, seq, ack)\n\t}\n\tif got := peer.(*net.IPAddr).IP; !got.Equal(srcIP) {\n\t\tt.Fatalf(\"peer IP = %v, want %v\", got, srcIP)\n\t}\n}\n\nfunc TestDecodeTCPProbePacketIPv6SYNAck(t *testing.T) {\n\tsrcIP := net.ParseIP(\"2001:db8::1\")\n\tdstIP := net.ParseIP(\"2001:db8::2\")\n\tip6 := &layers.IPv6{\n\t\tVersion:    6,\n\t\tNextHeader: layers.IPProtocolTCP,\n\t\tSrcIP:      srcIP,\n\t\tDstIP:      dstIP,\n\t}\n\ttcp := &layers.TCP{\n\t\tSrcPort: 8443,\n\t\tDstPort: 45678,\n\t\tACK:     true,\n\t\tSYN:     true,\n\t\tAck:     91,\n\t}\n\n\tpkt := mustSerializeTCPProbePacket(t, ip6, tcp)\n\tsrcPort, seq, ack, peer, ok := decodeTCPProbePacket(6, 8443, pkt)\n\tif !ok {\n\t\tt.Fatalf(\"decodeTCPProbePacket() ok = false\")\n\t}\n\tif srcPort != 45678 || seq != 90 || ack != 0 {\n\t\tt.Fatalf(\"decodeTCPProbePacket() = (%d, %d, %d), want (45678, 90, 0)\", srcPort, seq, ack)\n\t}\n\tif got := peer.(*net.IPAddr).IP; !got.Equal(srcIP) {\n\t\tt.Fatalf(\"peer IP = %v, want %v\", got, srcIP)\n\t}\n}\n\nfunc TestDecodeTCPProbePacketRejectsUnexpectedPort(t *testing.T) {\n\tip4 := &layers.IPv4{\n\t\tVersion:  4,\n\t\tIHL:      5,\n\t\tProtocol: layers.IPProtocolTCP,\n\t\tSrcIP:    net.ParseIP(\"3.3.3.3\"),\n\t\tDstIP:    net.ParseIP(\"4.4.4.4\"),\n\t}\n\ttcp := &layers.TCP{\n\t\tSrcPort: 80,\n\t\tDstPort: 50000,\n\t\tACK:     true,\n\t\tSYN:     true,\n\t\tAck:     50,\n\t}\n\n\tpkt := mustSerializeTCPProbePacket(t, ip4, tcp)\n\tif _, _, _, _, ok := decodeTCPProbePacket(4, 443, pkt); ok {\n\t\tt.Fatalf(\"decodeTCPProbePacket() ok = true, want false\")\n\t}\n}\n"
  },
  {
    "path": "trace/internal/tcp_unix.go",
    "content": "//go:build !darwin && !(windows && amd64)\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype TCPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\tDstPort      int\n\tPktSize      int\n\tSourceDevice string\n\ticmp         net.PacketConn\n\ttcp          net.PacketConn\n\ttcp4         *ipv4.PacketConn\n\ttcp6         *ipv6.PacketConn\n\thopLimitLock sync.Mutex\n}\n\nfunc (s *TCPSpec) InitTCP() {\n\tnetwork := \"ip4:tcp\"\n\tif s.IPVersion == 6 {\n\t\tnetwork = \"ip6:tcp\"\n\t}\n\n\ttcp, err := net.ListenPacket(network, s.SrcIP.String())\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitTCP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err))\n\t\t}\n\t\tlog.Fatalf(\"(InitTCP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err)\n\t}\n\ts.tcp = tcp\n\n\tif s.IPVersion == 4 {\n\t\ts.tcp4 = ipv4.NewPacketConn(s.tcp)\n\t} else {\n\t\ts.tcp6 = ipv6.NewPacketConn(s.tcp)\n\t}\n}\n\nfunc (s *TCPSpec) Close() {\n\t_ = s.icmp.Close()\n\t_ = s.tcp.Close()\n}\n\nfunc (s *TCPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\ts.listenICMPSock(ctx, ready, onICMP)\n}\n\nfunc (s *TCPSpec) ListenTCP(ctx context.Context, ready chan struct{}, onTCP func(srcPort, seq, ack int, peer net.Addr, finish time.Time)) {\n\tlc := NewPacketListener(s.tcp)\n\tgo lc.Start(ctx)\n\tclose(ready)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase msg, ok := <-lc.Messages:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif msg.Err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfinish := time.Now()\n\n\t\t\tif ip := util.AddrIP(msg.Peer); ip == nil || !ip.Equal(s.DstIP) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 解包\n\t\t\tpacket := gopacket.NewPacket(msg.Msg, layers.LayerTypeTCP, gopacket.Default)\n\t\t\tif packet.ErrorLayer() != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 从包中获取 TCP 层信息\n\t\t\ttl, ok := packet.Layer(layers.LayerTypeTCP).(*layers.TCP)\n\t\t\tif !ok || tl == nil || int(tl.SrcPort) != s.DstPort {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tseq, ack, ok := tcpProbeReply(tl)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tsrcPort := int(tl.DstPort)\n\t\t\tonTCP(srcPort, seq, ack, msg.Peer, finish)\n\t\t}\n\t}\n}\n\nfunc (s *TCPSpec) SendTCP(ctx context.Context, ipHdr gopacket.NetworkLayer, tcpHdr *layers.TCP, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif s.IPVersion == 4 {\n\t\tip4, ok := ipHdr.(*layers.IPv4)\n\t\tif !ok || ip4 == nil {\n\t\t\treturn time.Time{}, errors.New(\"SendTCP: expect *layers.IPv4 when s.IPVersion==4\")\n\t\t}\n\t\tttl := int(ip4.TTL)\n\n\t\tif err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t\t}\n\n\t\tbuf := gopacket.NewSerializeBuffer()\n\t\topts := gopacket.SerializeOptions{\n\t\t\tComputeChecksums: true,\n\t\t\tFixLengths:       true,\n\t\t}\n\n\t\t// 序列化 TCP 头与 payload 到缓冲区\n\t\tif err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\t// 串行设置 TTL + 发送，放在同一把锁里保证并发安全\n\t\ts.hopLimitLock.Lock()\n\t\tdefer s.hopLimitLock.Unlock()\n\n\t\tif err := s.tcp4.SetTOS(int(ip4.TOS)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\tif err := s.tcp4.SetTTL(ttl); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\tstart := time.Now()\n\n\t\tif _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\treturn start, nil\n\t}\n\n\tip6, ok := ipHdr.(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendTCP: expect *layers.IPv6 when s.IPVersion==6\")\n\t}\n\tttl := int(ip6.HopLimit)\n\n\tif err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// 序列化 TCP 头与 payload 到缓冲区\n\tif err := gopacket.SerializeLayers(buf, opts, tcpHdr, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\t// 串行设置 HopLimit + 发送，放在同一把锁里保证并发安全\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.tcp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tif err := s.tcp6.SetHopLimit(ttl); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\tif _, err := s.tcp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n"
  },
  {
    "path": "trace/internal/tcp_windows.go",
    "content": "//go:build windows && amd64\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\twd \"github.com/xjasonlyu/windivert-go\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype TCPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\tDstPort      int\n\ticmp         net.PacketConn\n\tPktSize      int\n\tSourceDevice string\n\taddr         wd.Address\n\thandle       wd.Handle\n}\n\nfunc (s *TCPSpec) sourceDeviceUnsupportedErr() error {\n\tif s.SourceDevice == \"\" {\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"source_device %q is not supported on Windows TCP traces\", s.SourceDevice)\n}\n\nfunc (s *TCPSpec) InitTCP() {\n\tif err := s.sourceDeviceUnsupportedErr(); err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(err)\n\t\t}\n\t\tlog.Fatal(err)\n\t}\n\n\thandle, err := wd.Open(\"false\", wd.LayerNetwork, 0, 0)\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitTCP) WinDivert open failed: %v\", err))\n\t\t}\n\t\tlog.Fatalf(\"(InitTCP) WinDivert open failed: %v\", err)\n\t}\n\ts.handle = handle\n\n\t// 设置出站 Address\n\ts.addr.SetLayer(wd.LayerNetwork)\n\ts.addr.SetEvent(wd.EventNetworkPacket)\n\ts.addr.SetOutbound()\n}\n\nfunc (s *TCPSpec) Close() {\n\t_ = s.icmp.Close()\n\t_ = s.handle.Close()\n}\n\n// resolveICMPMode 进行最终模式判定\nfunc (s *TCPSpec) resolveICMPMode() int {\n\ticmpMode := s.ICMPMode\n\tif icmpMode != 1 && icmpMode != 2 {\n\t\ticmpMode = 0 // 统一成 Auto\n\t}\n\n\t// 指定 1=Socket：直接返回\n\tif icmpMode == 1 {\n\t\treturn 1\n\t}\n\n\t// Auto(0) 或强制 Sniff(2) → 尝试 WinDivert\n\tok, err := winDivertAvailable()\n\tif !ok {\n\t\tif icmpMode == 2 {\n\t\t\tlog.Printf(\"请求使用 WinDivert 嗅探模式，但 WinDivert 不可用: %v；已回退到 Socket 模式。\", err)\n\t\t}\n\t\treturn 1\n\t}\n\treturn 2\n}\n\nfunc (s *TCPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\tswitch s.resolveICMPMode() {\n\tcase 1:\n\t\ts.listenICMPSock(ctx, ready, onICMP)\n\tcase 2:\n\t\ts.listenICMPWinDivert(ctx, ready, onICMP)\n\t}\n}\n\nfunc (s *TCPSpec) listenICMPWinDivert(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\tif err := s.sourceDeviceUnsupportedErr(); err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(err)\n\t\t}\n\t\tlog.Fatal(err)\n\t}\n\n\tsniffHandle, closeHandleICMP := openWinDivertSniffHandle(ctx, winDivertICMPFilter(s.IPVersion, s.SrcIP), \"ListenICMP\")\n\tdefer closeHandleICMP()\n\tclose(ready)\n\n\tbuf := make([]byte, 65535)\n\tvar addr wd.Address\n\n\tfor {\n\t\traw, finish, ok := receiveWinDivertPacket(ctx, sniffHandle, buf, &addr)\n\t\tif !ok {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tpacket, ok := decodeWinDivertICMPPacket(s.IPVersion, raw)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tdata, ok := packet.errorPayloadFor(s.DstIP)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tonICMP(packet.message(), finish, data)\n\t}\n}\n\nfunc (s *TCPSpec) ListenTCP(ctx context.Context, ready chan struct{}, onTCP func(srcPort, seq, ack int, peer net.Addr, finish time.Time)) {\n\tif err := s.sourceDeviceUnsupportedErr(); err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(err)\n\t\t}\n\t\tlog.Fatal(err)\n\t}\n\n\tsniffHandle, closeHandleTCP := openWinDivertSniffHandle(\n\t\tctx,\n\t\twinDivertTCPFilter(s.IPVersion, s.DstIP, s.SrcIP, s.DstPort),\n\t\t\"ListenTCP\",\n\t)\n\tdefer closeHandleTCP()\n\n\tclose(ready)\n\n\tbuf := make([]byte, 65535)\n\tvar addr wd.Address\n\n\tfor {\n\t\traw, finish, ok := receiveWinDivertPacket(ctx, sniffHandle, buf, &addr)\n\t\tif !ok {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tsrcPort, seq, ack, peer, ok := decodeWinDivertTCPPacket(s.IPVersion, raw, s.DstPort)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tonTCP(srcPort, seq, ack, peer, finish)\n\t}\n}\n\nfunc (s *TCPSpec) SendTCP(ctx context.Context, ipHdr ipLayer, tcpHdr *layers.TCP, payload []byte) (time.Time, error) {\n\tif err := s.sourceDeviceUnsupportedErr(); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif err := tcpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// 序列化 IP 与 TCP 头以及 payload 到缓冲区\n\tif err := gopacket.SerializeLayers(buf, opts, ipHdr, tcpHdr, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\t// 复用预置的出站 Address\n\tif _, err := s.handle.Send(buf.Bytes(), &s.addr); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n"
  },
  {
    "path": "trace/internal/udp_common.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc NewUDPSpec(IPVersion, ICMPMode int, srcIP, dstIP net.IP, dstPort int) *UDPSpec {\n\treturn &UDPSpec{IPVersion: IPVersion, ICMPMode: ICMPMode, SrcIP: srcIP, DstIP: dstIP, DstPort: dstPort}\n}\n\nfunc (s *UDPSpec) InitICMP() {\n\tnetwork := \"ip4:icmp\"\n\tif s.IPVersion == 6 {\n\t\tnetwork = \"ip6:ipv6-icmp\"\n\t}\n\n\ticmpConn, err := net.ListenPacket(network, s.SrcIP.String())\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitICMP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err))\n\t\t}\n\t\tlog.Fatalf(\"(InitICMP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err)\n\t}\n\ts.icmp = icmpConn\n}\n\nfunc (s *UDPSpec) listenICMPSock(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\tlc := NewPacketListener(s.icmp)\n\tgo lc.Start(ctx)\n\tclose(ready)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase msg, ok := <-lc.Messages:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfinish, data, ok := s.decodeICMPSocketMessage(msg)\n\t\t\tif ok {\n\t\t\t\tonICMP(msg, finish, data)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *UDPSpec) decodeICMPSocketMessage(msg ReceivedMessage) (time.Time, []byte, bool) {\n\tif msg.Err != nil {\n\t\treturn time.Time{}, nil, false\n\t}\n\n\tfinish := time.Now()\n\trm, ok := parseSocketICMPMessage(s.IPVersion, msg.Msg)\n\tif !ok {\n\t\treturn finish, nil, false\n\t}\n\n\tdata, ok := extractSocketICMPPayload(s.IPVersion, rm, s.DstIP)\n\treturn finish, data, ok\n}\n"
  },
  {
    "path": "trace/internal/udp_darwin.go",
    "content": "//go:build darwin\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype UDPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\tDstPort      int\n\tSourceDevice string\n\ticmp         net.PacketConn\n\tudp          net.PacketConn\n\tudp4         *ipv4.PacketConn\n\tudp6         *ipv6.PacketConn\n\thopLimitLock sync.Mutex\n}\n\nfunc (s *UDPSpec) InitUDP() {\n\tnetwork := \"ip4:udp\"\n\tif s.IPVersion == 6 {\n\t\tnetwork = \"ip6:udp\"\n\t}\n\n\tudp, err := net.ListenPacket(network, s.SrcIP.String())\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitUDP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err))\n\t\t}\n\t\tlog.Fatalf(\"(InitUDP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err)\n\t}\n\ts.udp = udp\n\n\tif s.IPVersion == 4 {\n\t\ts.udp4 = ipv4.NewPacketConn(s.udp)\n\t} else {\n\t\ts.udp6 = ipv6.NewPacketConn(s.udp)\n\t}\n}\n\nfunc (s *UDPSpec) Close() {\n\t_ = s.icmp.Close()\n\t_ = s.udp.Close()\n}\n\nfunc (s *UDPSpec) ListenOut(ctx context.Context, ready chan struct{}, onOut func(srcPort, seq, ttl int, start time.Time)) {\n\t// 选择捕获设备与本地接口\n\tdev := \"en0\"\n\tif s.SourceDevice != \"\" {\n\t\tdev = s.SourceDevice\n\t} else if d, err := util.PcapDeviceByIP(s.SrcIP); err == nil {\n\t\tdev = d\n\t}\n\n\t// 以“立即模式”打开 pcap，降低首包丢失概率\n\thandle, err := util.OpenLiveImmediate(dev, 65535, true, 4<<20)\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(ListenOut) pcap open failed on %s: %v\", dev, err))\n\t\t}\n\t\tlog.Fatalf(\"(ListenOut) pcap open failed on %s: %v\", dev, err)\n\t}\n\tdefer handle.Close()\n\n\t// 过滤：只抓对应协议族 + udp，来自本机 s.SrcIP → 目标 s.DstIP，且目标端口为 s.DstPort\n\tipPrefix := \"ip\"\n\tif s.IPVersion == 6 {\n\t\tipPrefix = \"ip6\"\n\t}\n\tfilter := fmt.Sprintf(\n\t\t\"%s and udp and src host %s and dst host %s and dst port %d\",\n\t\tipPrefix, s.SrcIP.String(), s.DstIP.String(), s.DstPort,\n\t)\n\n\tif err := handle.SetBPFFilter(filter); err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(ListenOut) set BPF failed: %v (filter=%q)\", err, filter))\n\t\t}\n\t\tlog.Fatalf(\"(ListenOut) set BPF failed: %v (filter=%q)\", err, filter)\n\t}\n\n\tsrc := gopacket.NewPacketSource(handle, handle.LinkType())\n\tpktCh := src.Packets()\n\tclose(ready)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase pkt, ok := <-pktCh:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 解包\n\t\t\tpacket := pkt.NetworkLayer()\n\t\t\tif packet == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstart := pkt.Metadata().Timestamp\n\n\t\t\t// 从包中获取 IPv4 层信息\n\t\t\tip4, ok := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4)\n\t\t\tif !ok || ip4 == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 从包中获取 UDP 层信息\n\t\t\tul, ok := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP)\n\t\t\tif !ok || ul == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tttl := int(ip4.TTL)\n\t\t\tsrcPort := int(ul.SrcPort)\n\t\t\tseq := int(ip4.Id)\n\t\t\tonOut(srcPort, seq, ttl, start)\n\t\t}\n\t}\n}\n\nfunc (s *UDPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\ts.listenICMPSock(ctx, ready, onICMP)\n}\n\nfunc (s *UDPSpec) SendUDP(ctx context.Context, ipHdr gopacket.NetworkLayer, udpHdr *layers.UDP, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif s.IPVersion == 4 {\n\t\tip4, ok := ipHdr.(*layers.IPv4)\n\t\tif !ok || ip4 == nil {\n\t\t\treturn time.Time{}, errors.New(\"SendUDP: expect *layers.IPv4 when s.IPVersion==4\")\n\t\t}\n\t\tttl := int(ip4.TTL)\n\n\t\tif err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t\t}\n\n\t\tbuf := gopacket.NewSerializeBuffer()\n\t\topts := gopacket.SerializeOptions{\n\t\t\tComputeChecksums: true,\n\t\t\tFixLengths:       true,\n\t\t}\n\n\t\t// 序列化 UDP 头与 payload 到缓冲区\n\t\tif err := gopacket.SerializeLayers(buf, opts, udpHdr, gopacket.Payload(payload)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\t// 串行设置 TTL + 发送，放在同一把锁里保证并发安全\n\t\ts.hopLimitLock.Lock()\n\t\tdefer s.hopLimitLock.Unlock()\n\n\t\tif err := s.udp4.SetTOS(int(ip4.TOS)); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\tif err := s.udp4.SetTTL(ttl); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\n\t\tstart := time.Now()\n\n\t\tif _, err := s.udp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\treturn start, nil\n\t}\n\n\tip6, ok := ipHdr.(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn time.Time{}, errors.New(\"SendUDP: expect *layers.IPv6 when s.IPVersion==6\")\n\t}\n\tttl := int(ip6.HopLimit)\n\n\tif err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// 序列化 UDP 头与 payload 到缓冲区\n\tif err := gopacket.SerializeLayers(buf, opts, udpHdr, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\t// 串行设置 HopLimit + 发送，放在同一把锁里保证并发安全\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.udp6.SetTrafficClass(int(ip6.TrafficClass)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tif err := s.udp6.SetHopLimit(ttl); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\tif _, err := s.udp.WriteTo(buf.Bytes(), &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n"
  },
  {
    "path": "trace/internal/udp_unix.go",
    "content": "//go:build !darwin && !(windows && amd64)\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype UDPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\tDstPort      int\n\tSourceDevice string\n\ticmp         net.PacketConn\n\tudp          net.PacketConn\n\tudp4         *ipv4.RawConn\n\tudp6         *ipv6.PacketConn\n\thopLimitLock sync.Mutex\n\tmtu          int\n}\n\nfunc (s *UDPSpec) InitUDP() {\n\tnetwork := \"ip4:udp\"\n\tif s.IPVersion == 6 {\n\t\tnetwork = \"ip6:udp\"\n\t}\n\n\tudp, err := net.ListenPacket(network, s.SrcIP.String())\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitUDP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err))\n\t\t}\n\t\tlog.Fatalf(\"(InitUDP) ListenPacket(%s, %s) failed: %v\", network, s.SrcIP, err)\n\t}\n\ts.udp = udp\n\n\tif s.IPVersion == 4 {\n\t\ts.udp4, err = ipv4.NewRawConn(s.udp)\n\t\tif err != nil {\n\t\t\ts.Close()\n\t\t\tif util.EnvDevMode {\n\t\t\t\tpanic(fmt.Errorf(\"(InitUDP) create NewRawConn failed: %v\", err))\n\t\t\t}\n\t\t\tlog.Fatalf(\"(InitUDP) create NewRawConn failed: %v\", err)\n\t\t}\n\n\t\t// 获取本地接口的 MTU\n\t\tmtu := 1500\n\t\tif m := util.GetMTUByIPForDevice(s.SrcIP, s.SourceDevice); m > 0 {\n\t\t\tmtu = m\n\t\t}\n\t\ts.mtu = mtu\n\t} else {\n\t\ts.udp6 = ipv6.NewPacketConn(s.udp)\n\t}\n}\n\nfunc (s *UDPSpec) Close() {\n\t_ = s.icmp.Close()\n\t_ = s.udp.Close()\n}\n\nfunc (s *UDPSpec) ListenOut(_ context.Context, _ chan struct{}, _ func(srcPort, seq, ttl int, start time.Time)) {\n}\n\nfunc (s *UDPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\ts.listenICMPSock(ctx, ready, onICMP)\n}\n\nfunc serializeUDPPacket(payload []byte, layersToSerialize ...gopacket.SerializableLayer) ([]byte, error) {\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\tserializeLayers := append(layersToSerialize, gopacket.Payload(payload))\n\tif err := gopacket.SerializeLayers(buf, opts, serializeLayers...); err != nil {\n\t\treturn nil, err\n\t}\n\treturn buf.Bytes(), nil\n}\n\nfunc parseIPv4Packet(packet []byte) (*ipv4.Header, []byte, error) {\n\tihl := int(packet[0]&0x0f) * 4\n\thdr, err := ipv4.ParseHeader(packet[:ihl])\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\treturn hdr, packet[ihl:], nil\n}\n\nfunc (s *UDPSpec) sendUDPIPv4(ipHdr *layers.IPv4, udpHdr *layers.UDP, payload []byte) (time.Time, error) {\n\tif ipHdr == nil {\n\t\treturn time.Time{}, errors.New(\"SendUDP: expect *layers.IPv4 when s.IPVersion==4\")\n\t}\n\tif err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tpacket, err := serializeUDPPacket(payload, ipHdr, udpHdr)\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t}\n\thdr, body, err := parseIPv4Packet(packet)\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tif len(packet) <= s.mtu {\n\t\tstart := time.Now()\n\t\tif err := s.udp4.WriteTo(hdr, body, nil); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t\treturn start, nil\n\t}\n\n\tfrags, err := util.IPv4Fragmentize(hdr, body, s.mtu)\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tstart := time.Now()\n\tfor _, fr := range frags {\n\t\tif err := s.udp4.WriteTo(&fr.Hdr, fr.Body, nil); err != nil {\n\t\t\treturn time.Time{}, err\n\t\t}\n\t}\n\treturn start, nil\n}\n\nfunc (s *UDPSpec) sendUDPIPv6(ipHdr *layers.IPv6, udpHdr *layers.UDP, payload []byte) (time.Time, error) {\n\tif ipHdr == nil {\n\t\treturn time.Time{}, errors.New(\"SendUDP: expect *layers.IPv6 when s.IPVersion==6\")\n\t}\n\tif err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tpacket, err := serializeUDPPacket(payload, udpHdr)\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\ts.hopLimitLock.Lock()\n\tdefer s.hopLimitLock.Unlock()\n\n\tif err := s.udp6.SetTrafficClass(int(ipHdr.TrafficClass)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tif err := s.udp6.SetHopLimit(int(ipHdr.HopLimit)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\tstart := time.Now()\n\tif _, err := s.udp.WriteTo(packet, &net.IPAddr{IP: s.DstIP}); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n\nfunc (s *UDPSpec) SendUDP(ctx context.Context, ipHdr ipLayer, udpHdr *layers.UDP, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif s.IPVersion == 4 {\n\t\tip4, _ := ipHdr.(*layers.IPv4)\n\t\treturn s.sendUDPIPv4(ip4, udpHdr, payload)\n\t}\n\n\tip6, _ := ipHdr.(*layers.IPv6)\n\treturn s.sendUDPIPv6(ip6, udpHdr, payload)\n}\n"
  },
  {
    "path": "trace/internal/udp_windows.go",
    "content": "//go:build windows && amd64\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\twd \"github.com/xjasonlyu/windivert-go\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype UDPSpec struct {\n\tIPVersion    int\n\tICMPMode     int\n\tSrcIP        net.IP\n\tDstIP        net.IP\n\tDstPort      int\n\tSourceDevice string\n\ticmp         net.PacketConn\n\taddr         wd.Address\n\thandle       wd.Handle\n}\n\nfunc (s *UDPSpec) InitUDP() {\n\thandle, err := wd.Open(\"false\", wd.LayerNetwork, 0, 0)\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(InitUDP) WinDivert open failed: %v\", err))\n\t\t}\n\t\tlog.Fatalf(\"(InitUDP) WinDivert open failed: %v\", err)\n\t}\n\ts.handle = handle\n\n\t// 设置出站 Address\n\ts.addr.SetLayer(wd.LayerNetwork)\n\ts.addr.SetEvent(wd.EventNetworkPacket)\n\ts.addr.SetOutbound()\n}\n\nfunc (s *UDPSpec) Close() {\n\t_ = s.icmp.Close()\n\t_ = s.handle.Close()\n}\n\nfunc (s *UDPSpec) ListenOut(_ context.Context, _ chan struct{}, _ func(srcPort, seq, ttl int, start time.Time)) {\n}\n\n// resolveICMPMode 进行最终模式判定\nfunc (s *UDPSpec) resolveICMPMode() int {\n\ticmpMode := s.ICMPMode\n\tif icmpMode != 1 && icmpMode != 2 {\n\t\ticmpMode = 0 // 统一成 Auto\n\t}\n\n\t// 指定 1=Socket：直接返回\n\tif icmpMode == 1 {\n\t\treturn 1\n\t}\n\n\t// Auto(0) 或强制 Sniff(2) → 尝试 WinDivert\n\tok, err := winDivertAvailable()\n\tif !ok {\n\t\tif icmpMode == 2 {\n\t\t\tlog.Printf(\"请求使用 WinDivert 嗅探模式，但 WinDivert 不可用: %v；已回退到 Socket 模式。\", err)\n\t\t}\n\t\treturn 1\n\t}\n\treturn 2\n}\n\nfunc (s *UDPSpec) ListenICMP(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\tswitch s.resolveICMPMode() {\n\tcase 1:\n\t\ts.listenICMPSock(ctx, ready, onICMP)\n\tcase 2:\n\t\ts.listenICMPWinDivert(ctx, ready, onICMP)\n\t}\n}\n\nfunc (s *UDPSpec) listenICMPWinDivert(ctx context.Context, ready chan struct{}, onICMP func(msg ReceivedMessage, finish time.Time, data []byte)) {\n\tsniffHandle, closeHandle := openWinDivertSniffHandle(ctx, winDivertICMPFilter(s.IPVersion, s.SrcIP), \"ListenICMP\")\n\tdefer closeHandle()\n\tclose(ready)\n\n\tbuf := make([]byte, 65535)\n\tvar addr wd.Address\n\n\tfor {\n\t\traw, finish, ok := receiveWinDivertPacket(ctx, sniffHandle, buf, &addr)\n\t\tif !ok {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tpacket, ok := decodeWinDivertICMPPacket(s.IPVersion, raw)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tdata, ok := packet.errorPayloadFor(s.DstIP)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tonICMP(packet.message(), finish, data)\n\t}\n}\n\nfunc (s *UDPSpec) SendUDP(ctx context.Context, ipHdr ipLayer, udpHdr *layers.UDP, payload []byte) (time.Time, error) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn time.Time{}, context.Canceled\n\tdefault:\n\t}\n\n\tif err := udpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{\n\t\tComputeChecksums: true,\n\t\tFixLengths:       true,\n\t}\n\n\t// 序列化 IP 与 UDP 头以及 payload 到缓冲区\n\tif err := gopacket.SerializeLayers(buf, opts, ipHdr, udpHdr, gopacket.Payload(payload)); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tstart := time.Now()\n\n\t// 复用预置的出站 Address\n\tif _, err := s.handle.Send(buf.Bytes(), &s.addr); err != nil {\n\t\treturn time.Time{}, err\n\t}\n\treturn start, nil\n}\n"
  },
  {
    "path": "trace/internal/windivert_sniff_windows.go",
    "content": "//go:build windows && amd64\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\twd \"github.com/xjasonlyu/windivert-go\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype winDivertICMPPacket struct {\n\tipVersion int\n\tpeerIP    net.IP\n\touter     []byte\n\terrorData []byte\n\techoID    int\n\techoSeq   int\n\techoReply bool\n}\n\nfunc winDivertICMPFilter(ipVersion int, srcIP net.IP) string {\n\tif ipVersion == 4 {\n\t\treturn fmt.Sprintf(\"inbound and icmp and ip.DstAddr == %s\", srcIP.String())\n\t}\n\treturn fmt.Sprintf(\"inbound and icmpv6 and ipv6.DstAddr == %s\", srcIP.String())\n}\n\nfunc winDivertTCPFilter(ipVersion int, dstIP, srcIP net.IP, dstPort int) string {\n\tif ipVersion == 4 {\n\t\treturn fmt.Sprintf(\n\t\t\t\"inbound and tcp and ip.SrcAddr == %s and ip.DstAddr == %s and tcp.SrcPort == %d\",\n\t\t\tdstIP.String(), srcIP.String(), dstPort,\n\t\t)\n\t}\n\treturn fmt.Sprintf(\n\t\t\"inbound and tcp and ipv6.SrcAddr == %s and ipv6.DstAddr == %s and tcp.SrcPort == %d\",\n\t\tdstIP.String(), srcIP.String(), dstPort,\n\t)\n}\n\nfunc openWinDivertSniffHandle(ctx context.Context, filter, action string) (wd.Handle, func()) {\n\thandle, err := wd.Open(filter, wd.LayerNetwork, 0, wd.FlagSniff|wd.FlagRecvOnly)\n\tif err != nil {\n\t\tif util.EnvDevMode {\n\t\t\tpanic(fmt.Errorf(\"(%s) WinDivert open failed: %v (filter=%q)\", action, err, filter))\n\t\t}\n\t\tlog.Fatalf(\"(%s) WinDivert open failed: %v (filter=%q)\", action, err, filter)\n\t}\n\n\tvar closeOnce sync.Once\n\tcloseHandle := func() { closeOnce.Do(func() { _ = handle.Close() }) }\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tcloseHandle()\n\t}()\n\n\t_ = handle.SetParam(wd.QueueLength, 8192)\n\t_ = handle.SetParam(wd.QueueTime, 4000)\n\treturn handle, closeHandle\n}\n\nfunc packetDecoderForIPVersion(ipVersion int) gopacket.Decoder {\n\tif ipVersion == 4 {\n\t\treturn layers.LayerTypeIPv4\n\t}\n\treturn layers.LayerTypeIPv6\n}\n\nfunc receiveWinDivertPacket(ctx context.Context, handle wd.Handle, buf []byte, addr *wd.Address) ([]byte, time.Time, bool) {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn nil, time.Time{}, false\n\tdefault:\n\t}\n\n\tn, err := handle.Recv(buf, addr)\n\tif err != nil {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil, time.Time{}, false\n\t\tdefault:\n\t\t\treturn nil, time.Time{}, false\n\t\t}\n\t}\n\n\tfinish := time.Now()\n\traw := make([]byte, n)\n\tcopy(raw, buf[:n])\n\treturn raw, finish, true\n}\n\nfunc decodeWinDivertICMPPacket(ipVersion int, raw []byte) (*winDivertICMPPacket, bool) {\n\tpkt := gopacket.NewPacket(raw, packetDecoderForIPVersion(ipVersion), gopacket.NoCopy)\n\tif ipVersion == 4 {\n\t\treturn decodeWinDivertICMPv4Packet(pkt, raw)\n\t}\n\treturn decodeWinDivertICMPv6Packet(pkt, raw)\n}\n\nfunc decodeWinDivertTCPPacket(ipVersion int, raw []byte, dstPort int) (srcPort, seq, ack int, peer net.Addr, ok bool) {\n\tpkt := gopacket.NewPacket(raw, packetDecoderForIPVersion(ipVersion), gopacket.NoCopy)\n\treturn decodeTCPProbePacket(ipVersion, dstPort, pkt)\n}\n\nfunc decodeWinDivertICMPv4Packet(pkt gopacket.Packet, raw []byte) (*winDivertICMPPacket, bool) {\n\tip4, ok := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4)\n\tif !ok || ip4 == nil {\n\t\treturn nil, false\n\t}\n\tic4, ok := pkt.Layer(layers.LayerTypeICMPv4).(*layers.ICMPv4)\n\tif !ok || ic4 == nil {\n\t\treturn nil, false\n\t}\n\n\tpacket := &winDivertICMPPacket{\n\t\tipVersion: 4,\n\t\tpeerIP:    ip4.SrcIP,\n\t\touter:     raw,\n\t}\n\n\tswitch ic4.TypeCode.Type() {\n\tcase layers.ICMPv4TypeEchoReply:\n\t\tpacket.echoReply = true\n\t\tpacket.echoID = int(ic4.Id)\n\t\tpacket.echoSeq = int(ic4.Seq)\n\t\treturn packet, true\n\tcase layers.ICMPv4TypeTimeExceeded, layers.ICMPv4TypeDestinationUnreachable:\n\t\tpacket.errorData = ic4.Payload\n\t\treturn packet, true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc decodeWinDivertICMPv6Packet(pkt gopacket.Packet, raw []byte) (*winDivertICMPPacket, bool) {\n\tip6, ok := pkt.Layer(layers.LayerTypeIPv6).(*layers.IPv6)\n\tif !ok || ip6 == nil {\n\t\treturn nil, false\n\t}\n\tic6, ok := pkt.Layer(layers.LayerTypeICMPv6).(*layers.ICMPv6)\n\tif !ok || ic6 == nil || len(ic6.Payload) < 4 {\n\t\treturn nil, false\n\t}\n\n\tpacket := &winDivertICMPPacket{\n\t\tipVersion: 6,\n\t\tpeerIP:    ip6.SrcIP,\n\t\touter:     raw,\n\t}\n\n\tswitch ic6.TypeCode.Type() {\n\tcase layers.ICMPv6TypeEchoReply:\n\t\techo, ok := pkt.Layer(layers.LayerTypeICMPv6Echo).(*layers.ICMPv6Echo)\n\t\tif !ok || echo == nil {\n\t\t\treturn nil, false\n\t\t}\n\t\tpacket.echoReply = true\n\t\tpacket.echoID = int(echo.Identifier)\n\t\tpacket.echoSeq = int(echo.SeqNumber)\n\t\treturn packet, true\n\tcase layers.ICMPv6TypeTimeExceeded, layers.ICMPv6TypePacketTooBig, layers.ICMPv6TypeDestinationUnreachable:\n\t\tpacket.errorData = ic6.Payload[4:]\n\t\treturn packet, true\n\tdefault:\n\t\treturn nil, false\n\t}\n}\n\nfunc (p *winDivertICMPPacket) message() ReceivedMessage {\n\treturn ReceivedMessage{\n\t\tPeer: &net.IPAddr{IP: p.peerIP},\n\t\tMsg:  p.outer,\n\t}\n}\n\nfunc (p *winDivertICMPPacket) echoReplyFor(dstIP net.IP, echoID int) (int, bool) {\n\tif !p.echoReply || !p.peerIP.Equal(dstIP) || p.echoID != echoID {\n\t\treturn 0, false\n\t}\n\treturn p.echoSeq, true\n}\n\nfunc (p *winDivertICMPPacket) errorPayloadFor(dstIP net.IP) ([]byte, bool) {\n\tif p.echoReply || !matchesEmbeddedDstIP(p.ipVersion, p.errorData, dstIP) {\n\t\treturn nil, false\n\t}\n\treturn p.errorData, true\n}\n"
  },
  {
    "path": "trace/mtr_loop_runtime.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n)\n\ntype mtrLoopRuntime struct {\n\tctx               context.Context\n\tprober            mtrProber\n\tconfig            Config\n\topts              MTROptions\n\tagg               *MTRAggregator\n\tonSnapshot        MTROnSnapshot\n\tfillGeo           bool\n\tbo                *mtrBackoffCfg\n\titeration         int\n\tconsecutiveErrors int\n\tbackoff           time.Duration\n}\n\nfunc newMTRLoopRuntime(\n\tctx context.Context,\n\tprober mtrProber,\n\tconfig Config,\n\topts MTROptions,\n\tagg *MTRAggregator,\n\tonSnapshot MTROnSnapshot,\n\tfillGeo bool,\n\tbo *mtrBackoffCfg,\n) *mtrLoopRuntime {\n\tif bo == nil {\n\t\tbo = &defaultBackoff\n\t}\n\tif opts.ProgressThrottle <= 0 {\n\t\topts.ProgressThrottle = 200 * time.Millisecond\n\t}\n\treturn &mtrLoopRuntime{\n\t\tctx:        ctx,\n\t\tprober:     prober,\n\t\tconfig:     config,\n\t\topts:       opts,\n\t\tagg:        agg,\n\t\tonSnapshot: onSnapshot,\n\t\tfillGeo:    fillGeo,\n\t\tbo:         bo,\n\t\tbackoff:    bo.Initial,\n\t}\n}\n\nfunc (rt *mtrLoopRuntime) run() error {\n\tfor {\n\t\tif err := rt.snapshotContextError(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trt.handleReset()\n\t\tif err := rt.waitWhilePaused(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tres, err := rt.runProbeRound()\n\t\tif err != nil {\n\t\t\tshouldContinue, retErr := rt.handleProbeError(err)\n\t\t\tif retErr != nil {\n\t\t\t\treturn retErr\n\t\t\t}\n\t\t\tif shouldContinue {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\trt.recordSuccess(res)\n\t\tif rt.opts.MaxRounds > 0 && rt.iteration >= rt.opts.MaxRounds {\n\t\t\treturn nil\n\t\t}\n\t\tif err := rt.waitInterval(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n}\n\nfunc (rt *mtrLoopRuntime) snapshotContextError() error {\n\tif rt.ctx.Err() == nil {\n\t\treturn nil\n\t}\n\trt.emitSnapshot()\n\treturn rt.ctx.Err()\n}\n\nfunc (rt *mtrLoopRuntime) emitSnapshot() {\n\tif rt.onSnapshot != nil {\n\t\trt.onSnapshot(rt.iteration, rt.agg.Snapshot())\n\t}\n}\n\nfunc (rt *mtrLoopRuntime) handleReset() {\n\tif rt.opts.IsResetRequested == nil || !rt.opts.IsResetRequested() {\n\t\treturn\n\t}\n\n\trt.agg.Reset()\n\trt.iteration = 0\n\trt.consecutiveErrors = 0\n\trt.backoff = rt.bo.Initial\n\tif resetter, ok := rt.prober.(mtrResetter); ok {\n\t\tresetter.resetFinalTTL()\n\t}\n}\n\nfunc (rt *mtrLoopRuntime) waitWhilePaused() error {\n\tif rt.opts.IsPaused == nil {\n\t\treturn nil\n\t}\n\tfor rt.opts.IsPaused() {\n\t\ttimer := time.NewTimer(200 * time.Millisecond)\n\t\tselect {\n\t\tcase <-rt.ctx.Done():\n\t\t\ttimer.Stop()\n\t\t\treturn rt.snapshotContextError()\n\t\tcase <-timer.C:\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (rt *mtrLoopRuntime) runProbeRound() (*Result, error) {\n\tpeeker, canPeek := rt.prober.(mtrPeeker)\n\tif !canPeek || rt.onSnapshot == nil {\n\t\treturn rt.prober.probeRound(rt.ctx)\n\t}\n\treturn rt.runProbeRoundWithPreview(peeker)\n}\n\nfunc (rt *mtrLoopRuntime) runProbeRoundWithPreview(peeker mtrPeeker) (*Result, error) {\n\tvar (\n\t\tres *Result\n\t\terr error\n\t)\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tres, err = rt.prober.probeRound(rt.ctx)\n\t\tclose(done)\n\t}()\n\n\tticker := time.NewTicker(rt.opts.ProgressThrottle)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-done:\n\t\t\treturn res, err\n\t\tcase <-ticker.C:\n\t\t\trt.emitPreview(peeker)\n\t\tcase <-rt.ctx.Done():\n\t\t\t<-done\n\t\t\tif err == nil && rt.ctx.Err() != nil {\n\t\t\t\terr = rt.ctx.Err()\n\t\t\t}\n\t\t\treturn res, err\n\t\t}\n\t}\n}\n\nfunc (rt *mtrLoopRuntime) emitPreview(peeker mtrPeeker) {\n\tpartial := peeker.peekPartialResult()\n\tif partial == nil {\n\t\treturn\n\t}\n\tpreview := rt.agg.Clone()\n\trt.onSnapshot(rt.iteration+1, preview.Update(partial, 1))\n}\n\nfunc (rt *mtrLoopRuntime) handleProbeError(err error) (bool, error) {\n\tif rt.ctx.Err() != nil {\n\t\treturn false, rt.snapshotContextError()\n\t}\n\n\trt.consecutiveErrors++\n\tfmt.Fprintf(os.Stderr, \"mtr: probe error (%d/%d): %v\\n\", rt.consecutiveErrors, rt.bo.MaxConsec, err)\n\tif rt.consecutiveErrors >= rt.bo.MaxConsec {\n\t\treturn false, fmt.Errorf(\"mtr: too many consecutive errors (%d), last: %w\", rt.consecutiveErrors, err)\n\t}\n\n\tif err := rt.waitBackoff(); err != nil {\n\t\treturn false, err\n\t}\n\n\trt.backoff *= 2\n\tif rt.backoff > rt.bo.Max {\n\t\trt.backoff = rt.bo.Max\n\t}\n\treturn true, nil\n}\n\nfunc (rt *mtrLoopRuntime) waitBackoff() error {\n\ttimer := time.NewTimer(rt.backoff)\n\tdefer timer.Stop()\n\n\tselect {\n\tcase <-rt.ctx.Done():\n\t\treturn rt.snapshotContextError()\n\tcase <-timer.C:\n\t\treturn nil\n\t}\n}\n\nfunc (rt *mtrLoopRuntime) recordSuccess(res *Result) {\n\tif rt.fillGeo {\n\t\tmtrFillGeoRDNS(res, rt.config)\n\t}\n\n\trt.consecutiveErrors = 0\n\trt.backoff = rt.bo.Initial\n\trt.iteration++\n\n\tstats := rt.agg.Update(res, 1)\n\tif rt.onSnapshot != nil {\n\t\trt.onSnapshot(rt.iteration, stats)\n\t}\n}\n\nfunc (rt *mtrLoopRuntime) waitInterval() error {\n\tif rt.opts.Interval <= 0 {\n\t\treturn nil\n\t}\n\n\ttimer := time.NewTimer(rt.opts.Interval)\n\tdefer timer.Stop()\n\n\tselect {\n\tcase <-rt.ctx.Done():\n\t\treturn rt.snapshotContextError()\n\tcase <-timer.C:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "trace/mtr_raw.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\n// MTRRawOptions controls MTR raw streaming behavior.\ntype MTRRawOptions struct {\n\t// Interval is the delay between rounds (default: 1s).\n\t// Legacy round-based mode only.\n\tInterval time.Duration\n\t// MaxRounds is the max number of rounds. 0 means run forever until canceled.\n\t// Legacy round-based mode only.\n\tMaxRounds int\n\t// HopInterval is the per-hop probe interval (per-hop scheduling mode).\n\t// > 0 activates per-hop scheduling; Interval/MaxRounds are ignored.\n\tHopInterval time.Duration\n\t// MaxPerHop is the max probes per TTL in per-hop mode. 0 = unlimited.\n\tMaxPerHop int\n\t// RunRound optionally overrides the traceroute call for each round.\n\t// It is mainly for callers that need per-round locking or global-state setup.\n\t// Legacy round-based mode only.\n\tRunRound func(method Method, cfg Config) (*Result, error)\n}\n\n// MTRRawRecord is one stream record emitted by MTR raw mode.\n// It keeps the same information family as classic --raw output.\ntype MTRRawRecord struct {\n\tIteration int      `json:\"iteration\"`\n\tTTL       int      `json:\"ttl\"`\n\tSuccess   bool     `json:\"success\"`\n\tIP        string   `json:\"ip,omitempty\"`\n\tHost      string   `json:\"host,omitempty\"`\n\tRTTMs     float64  `json:\"rtt_ms\"`\n\tASN       string   `json:\"asn,omitempty\"`\n\tCountry   string   `json:\"country,omitempty\"`\n\tProv      string   `json:\"prov,omitempty\"`\n\tCity      string   `json:\"city,omitempty\"`\n\tDistrict  string   `json:\"district,omitempty\"`\n\tOwner     string   `json:\"owner,omitempty\"`\n\tLat       float64  `json:\"lat\"`\n\tLng       float64  `json:\"lng\"`\n\tMPLS      []string `json:\"mpls,omitempty\"`\n}\n\n// MTRRawOnRecord is called for each probe event.\ntype MTRRawOnRecord func(rec MTRRawRecord)\n\nvar mtrRawTracerouteFn = Traceroute\n\n// RunMTRRaw runs continuous traceroute and emits probe-level streaming records.\n//\n// When opts.HopInterval > 0, uses per-hop scheduling (each TTL independent);\n// otherwise uses legacy round-based scheduling for backward compatibility.\nfunc RunMTRRaw(ctx context.Context, method Method, cfg Config, opts MTRRawOptions, onRecord MTRRawOnRecord) error {\n\tif opts.HopInterval > 0 {\n\t\treturn runMTRRawPerHop(ctx, method, cfg, opts, onRecord)\n\t}\n\treturn runMTRRawRoundBased(ctx, method, cfg, opts, onRecord)\n}\n\n// runMTRRawPerHop uses per-hop scheduling for raw streaming.\nfunc runMTRRawPerHop(ctx context.Context, method Method, cfg Config, opts MTRRawOptions, onRecord MTRRawOnRecord) error {\n\tnormalizeRuntimeConfig(&cfg)\n\troundCfg := cfg\n\troundCfg.NumMeasurements = 1\n\troundCfg.MaxAttempts = 1\n\troundCfg.AsyncPrinter = nil\n\troundCfg.RealtimePrinter = nil\n\n\tif roundCfg.MaxHops == 0 {\n\t\troundCfg.MaxHops = 30\n\t}\n\tif roundCfg.ICMPMode <= 0 && util.EnvICMPMode > 0 {\n\t\troundCfg.ICMPMode = util.EnvICMPMode\n\t}\n\tswitch roundCfg.ICMPMode {\n\tcase 0, 1, 2:\n\tdefault:\n\t\troundCfg.ICMPMode = 0\n\t}\n\n\tvar prober mtrTTLProber\n\n\tif method == ICMPTrace {\n\t\tengine, err := newMTRICMPEngine(roundCfg)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"mtr raw: %w\", err)\n\t\t}\n\t\tdefer engine.close()\n\t\tif err := engine.start(ctx); err != nil {\n\t\t\tengine.close()\n\t\t\treturn fmt.Errorf(\"mtr raw: %w\", err)\n\t\t}\n\t\tprober = engine\n\t} else {\n\t\tprober = &mtrFallbackTTLProber{method: method, config: roundCfg}\n\t}\n\n\tagg := NewMTRAggregator()\n\n\treturn runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         roundCfg.BeginHop,\n\t\tMaxHops:          roundCfg.MaxHops,\n\t\tHopInterval:      opts.HopInterval,\n\t\tTimeout:          roundCfg.Timeout,\n\t\tMaxPerHop:        opts.MaxPerHop,\n\t\tParallelRequests: roundCfg.ParallelRequests,\n\t\tFillGeo:          true,\n\t\tBaseConfig:       roundCfg,\n\t\tDstIP:            roundCfg.DstIP,\n\t}, nil, func(result mtrProbeResult, iteration int) {\n\t\tif onRecord == nil {\n\t\t\treturn\n\t\t}\n\t\trec := buildMTRRawRecordFromProbe(iteration, result, roundCfg)\n\t\tonRecord(rec)\n\t})\n}\n\n// runMTRRawRoundBased is the legacy round-based raw streaming path.\nfunc runMTRRawRoundBased(ctx context.Context, method Method, cfg Config, opts MTRRawOptions, onRecord MTRRawOnRecord) error {\n\tnormalizeRuntimeConfig(&cfg)\n\tif opts.Interval <= 0 {\n\t\topts.Interval = time.Second\n\t}\n\n\troundCfg := cfg\n\troundCfg.NumMeasurements = 1\n\troundCfg.MaxAttempts = 1\n\troundCfg.AsyncPrinter = nil\n\troundCfg.RealtimePrinter = nil\n\n\trunRound := opts.RunRound\n\tif runRound == nil {\n\t\trunRound = mtrRawTracerouteFn\n\t}\n\n\titeration := 0\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tdefault:\n\t\t}\n\n\t\titeration++\n\t\tseen := make(map[int]int)\n\t\tvar seenMu sync.Mutex\n\n\t\tcfgForRound := roundCfg\n\t\tcfgForRound.RealtimePrinter = func(res *Result, ttl int) {\n\t\t\tif onRecord == nil || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif ttl < 0 || ttl >= len(res.Hops) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tseenMu.Lock()\n\t\t\tstart := seen[ttl]\n\t\t\tend := len(res.Hops[ttl])\n\t\t\tseen[ttl] = end\n\t\t\tseenMu.Unlock()\n\n\t\t\tif start >= end {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfor i := start; i < end; i++ {\n\t\t\t\th := res.Hops[ttl][i]\n\t\t\t\trec := buildMTRRawRecord(iteration, h, cfgForRound)\n\t\t\t\tonRecord(rec)\n\t\t\t}\n\t\t}\n\n\t\tdone := make(chan struct{})\n\t\tvar traceErr error\n\t\tgo func() {\n\t\t\t_, traceErr = runRound(method, cfgForRound)\n\t\t\tclose(done)\n\t\t}()\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\t// Wait for the in-flight round to finish before returning, so callers\n\t\t\t// can safely release any per-round/global state after RunMTRRaw exits.\n\t\t\t<-done\n\t\t\treturn ctx.Err()\n\t\tcase <-done:\n\t\t}\n\n\t\tif traceErr != nil {\n\t\t\treturn traceErr\n\t\t}\n\n\t\tif opts.MaxRounds > 0 && iteration >= opts.MaxRounds {\n\t\t\treturn nil\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tcase <-time.After(opts.Interval):\n\t\t}\n\t}\n}\n\nfunc buildMTRRawRecord(iteration int, h Hop, cfg Config) MTRRawRecord {\n\trec := MTRRawRecord{\n\t\tIteration: iteration,\n\t\tTTL:       h.TTL,\n\t\tSuccess:   h.Success && h.Address != nil,\n\t}\n\n\tif h.Address != nil {\n\t\trec.IP = addrToIPString(h.Address)\n\t}\n\trec.Host = strings.TrimSpace(h.Hostname)\n\tif h.RTT > 0 {\n\t\trec.RTTMs = float64(h.RTT) / float64(time.Millisecond)\n\t}\n\tif len(h.MPLS) > 0 {\n\t\trec.MPLS = append([]string(nil), h.MPLS...)\n\t}\n\n\tif h.Address != nil && (h.Geo == nil || isPendingGeo(h.Geo)) {\n\t\tif h.Lang == \"\" {\n\t\t\th.Lang = cfg.Lang\n\t\t}\n\t\t_ = h.fetchIPData(cfg)\n\t}\n\n\tif h.Geo != nil {\n\t\trec.ASN = strings.TrimSpace(h.Geo.Asnumber)\n\t\trec.Country = geoTextByLang(cfg.Lang, h.Geo.Country, h.Geo.CountryEn)\n\t\trec.Prov = geoTextByLang(cfg.Lang, h.Geo.Prov, h.Geo.ProvEn)\n\t\trec.City = geoTextByLang(cfg.Lang, h.Geo.City, h.Geo.CityEn)\n\t\trec.District = strings.TrimSpace(h.Geo.District)\n\t\trec.Owner = strings.TrimSpace(h.Geo.Owner)\n\t\tif rec.Owner == \"\" {\n\t\t\trec.Owner = strings.TrimSpace(h.Geo.Isp)\n\t\t}\n\t\trec.Lat = h.Geo.Lat\n\t\trec.Lng = h.Geo.Lng\n\t}\n\n\treturn rec\n}\n\nfunc addrToIPString(addr net.Addr) string {\n\tswitch v := addr.(type) {\n\tcase *net.IPAddr:\n\t\tif v.IP != nil {\n\t\t\treturn v.IP.String()\n\t\t}\n\tcase *net.UDPAddr:\n\t\tif v.IP != nil {\n\t\t\treturn v.IP.String()\n\t\t}\n\tcase *net.TCPAddr:\n\t\tif v.IP != nil {\n\t\t\treturn v.IP.String()\n\t\t}\n\t}\n\ts := strings.TrimSpace(addr.String())\n\tif host, _, err := net.SplitHostPort(s); err == nil {\n\t\treturn strings.Trim(host, \"[]\")\n\t}\n\treturn strings.Trim(s, \"[]\")\n}\n\nfunc geoTextByLang(lang, cn, en string) string {\n\tcn = strings.TrimSpace(cn)\n\ten = strings.TrimSpace(en)\n\tif strings.EqualFold(lang, \"en\") {\n\t\tif en != \"\" {\n\t\t\treturn en\n\t\t}\n\t\treturn cn\n\t}\n\tif cn != \"\" {\n\t\treturn cn\n\t}\n\treturn en\n}\n\n// buildMTRRawRecordFromProbe constructs an MTRRawRecord from a per-hop scheduler probe result.\nfunc buildMTRRawRecordFromProbe(iteration int, pr mtrProbeResult, cfg Config) MTRRawRecord {\n\trec := newMTRRawRecord(iteration, pr)\n\tif pr.Addr == nil {\n\t\treturn rec\n\t}\n\tapplyMTRRawProbeMetadata(&rec, pr, cfg)\n\treturn rec\n}\n\nfunc newMTRRawRecord(iteration int, pr mtrProbeResult) MTRRawRecord {\n\trec := MTRRawRecord{\n\t\tIteration: iteration,\n\t\tTTL:       pr.TTL,\n\t\tSuccess:   pr.Success && pr.Addr != nil,\n\t}\n\tif pr.Addr != nil {\n\t\trec.IP = addrToIPString(pr.Addr)\n\t}\n\tif pr.RTT > 0 {\n\t\trec.RTTMs = float64(pr.RTT) / float64(time.Millisecond)\n\t}\n\tif len(pr.MPLS) > 0 {\n\t\trec.MPLS = append([]string(nil), pr.MPLS...)\n\t}\n\treturn rec\n}\n\nfunc applyMTRRawProbeMetadata(rec *MTRRawRecord, pr mtrProbeResult, cfg Config) {\n\tif pr.Geo != nil || pr.Hostname != \"\" {\n\t\tapplyMTRRawGeo(rec, pr.Geo, cfg.Lang)\n\t\tapplyMTRRawHostname(rec, pr.Hostname)\n\t\treturn\n\t}\n\tif cfg.IPGeoSource == nil && !cfg.RDNS {\n\t\treturn\n\t}\n\th := Hop{Address: pr.Addr, Lang: cfg.Lang}\n\t_ = h.fetchIPData(cfg)\n\tapplyMTRRawGeo(rec, h.Geo, cfg.Lang)\n\tapplyMTRRawHostname(rec, h.Hostname)\n}\n\nfunc applyMTRRawGeo(rec *MTRRawRecord, geo *ipgeo.IPGeoData, lang string) {\n\tif geo == nil {\n\t\treturn\n\t}\n\trec.ASN = strings.TrimSpace(geo.Asnumber)\n\trec.Country = geoTextByLang(lang, geo.Country, geo.CountryEn)\n\trec.Prov = geoTextByLang(lang, geo.Prov, geo.ProvEn)\n\trec.City = geoTextByLang(lang, geo.City, geo.CityEn)\n\trec.District = strings.TrimSpace(geo.District)\n\trec.Owner = strings.TrimSpace(geo.Owner)\n\tif rec.Owner == \"\" {\n\t\trec.Owner = strings.TrimSpace(geo.Isp)\n\t}\n\trec.Lat = geo.Lat\n\trec.Lng = geo.Lng\n}\n\nfunc applyMTRRawHostname(rec *MTRRawRecord, hostname string) {\n\tif hostname != \"\" {\n\t\trec.Host = strings.TrimSpace(hostname)\n\t}\n}\n"
  },
  {
    "path": "trace/mtr_raw_test.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc TestRunMTRRaw_EmitsPerAttemptRecords(t *testing.T) {\n\told := mtrRawTracerouteFn\n\tt.Cleanup(func() { mtrRawTracerouteFn = old })\n\n\tmtrRawTracerouteFn = func(_ Method, cfg Config) (*Result, error) {\n\t\tres := &Result{Hops: make([][]Hop, 2)}\n\t\tres.Hops[0] = []Hop{{\n\t\t\tSuccess:  true,\n\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"1.1.1.1\")},\n\t\t\tHostname: \"one.one.one.one\",\n\t\t\tTTL:      1,\n\t\t\tRTT:      15 * time.Millisecond,\n\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\tAsnumber: \"13335\",\n\t\t\t\tCountry:  \"美国\",\n\t\t\t\tProv:     \"加州\",\n\t\t\t\tCity:     \"洛杉矶\",\n\t\t\t\tOwner:    \"Cloudflare\",\n\t\t\t\tLat:      35.1234,\n\t\t\t\tLng:      139.5678,\n\t\t\t},\n\t\t\tLang: \"cn\",\n\t\t}}\n\t\tres.Hops[1] = []Hop{{\n\t\t\tSuccess: false,\n\t\t\tTTL:     2,\n\t\t\tError:   errHopLimitTimeout,\n\t\t}}\n\n\t\tif cfg.RealtimePrinter != nil {\n\t\t\tcfg.RealtimePrinter(res, 0)\n\t\t\tcfg.RealtimePrinter(res, 1)\n\t\t}\n\t\treturn res, nil\n\t}\n\n\tvar records []MTRRawRecord\n\terr := RunMTRRaw(context.Background(), ICMPTrace, Config{Lang: \"cn\"}, MTRRawOptions{\n\t\tMaxRounds: 1,\n\t\tInterval:  time.Millisecond,\n\t}, func(rec MTRRawRecord) {\n\t\trecords = append(records, rec)\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"RunMTRRaw returned error: %v\", err)\n\t}\n\tif len(records) != 2 {\n\t\tt.Fatalf(\"expected 2 records, got %d\", len(records))\n\t}\n\n\tif !records[0].Success || records[0].TTL != 1 || records[0].IP != \"1.1.1.1\" {\n\t\tt.Fatalf(\"unexpected first record: %+v\", records[0])\n\t}\n\tif records[0].ASN != \"13335\" || records[0].Country == \"\" || records[0].Owner != \"Cloudflare\" {\n\t\tt.Fatalf(\"first record geo fields missing: %+v\", records[0])\n\t}\n\tif records[1].Success || records[1].TTL != 2 {\n\t\tt.Fatalf(\"unexpected timeout record: %+v\", records[1])\n\t}\n}\n\nfunc TestRunMTRRaw_RespectsMaxRoundsAndInterval(t *testing.T) {\n\told := mtrRawTracerouteFn\n\tt.Cleanup(func() { mtrRawTracerouteFn = old })\n\n\tcalls := 0\n\tmtrRawTracerouteFn = func(_ Method, _ Config) (*Result, error) {\n\t\tcalls++\n\t\treturn &Result{Hops: make([][]Hop, 0)}, nil\n\t}\n\n\tstart := time.Now()\n\terr := RunMTRRaw(context.Background(), ICMPTrace, Config{}, MTRRawOptions{\n\t\tMaxRounds: 3,\n\t\tInterval:  20 * time.Millisecond,\n\t}, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"RunMTRRaw returned error: %v\", err)\n\t}\n\tif calls != 3 {\n\t\tt.Fatalf(\"traceroute call count = %d, want 3\", calls)\n\t}\n\t// Three rounds wait twice at 20ms each; allow a small scheduler tolerance below 40ms.\n\tif time.Since(start) < 38*time.Millisecond {\n\t\tt.Fatalf(\"interval appears not applied, elapsed=%v\", time.Since(start))\n\t}\n}\n\nfunc TestRunMTRRaw_ContextCancelStopsLoop(t *testing.T) {\n\told := mtrRawTracerouteFn\n\tt.Cleanup(func() { mtrRawTracerouteFn = old })\n\n\tmtrRawTracerouteFn = func(_ Method, _ Config) (*Result, error) {\n\t\ttime.Sleep(120 * time.Millisecond)\n\t\treturn &Result{Hops: make([][]Hop, 0)}, nil\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tgo func() {\n\t\ttime.Sleep(20 * time.Millisecond)\n\t\tcancel()\n\t}()\n\n\tstart := time.Now()\n\terr := RunMTRRaw(ctx, ICMPTrace, Config{}, MTRRawOptions{\n\t\tMaxRounds: 10,\n\t\tInterval:  time.Second,\n\t}, nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected context cancellation error\")\n\t}\n\telapsed := time.Since(start)\n\tif elapsed < 100*time.Millisecond {\n\t\tt.Fatalf(\"RunMTRRaw should wait for in-flight round to finish before returning, elapsed=%v\", elapsed)\n\t}\n\tif elapsed > 250*time.Millisecond {\n\t\tt.Fatalf(\"cancel took unexpectedly long, elapsed=%v\", elapsed)\n\t}\n}\n\nfunc TestRunMTRRaw_UsesRunRoundOverride(t *testing.T) {\n\tcalls := 0\n\terr := RunMTRRaw(context.Background(), ICMPTrace, Config{}, MTRRawOptions{\n\t\tMaxRounds: 1,\n\t\tRunRound: func(_ Method, cfg Config) (*Result, error) {\n\t\t\tcalls++\n\t\t\tif cfg.RealtimePrinter == nil {\n\t\t\t\tt.Fatal(\"expected RealtimePrinter to be populated for raw streaming\")\n\t\t\t}\n\t\t\treturn &Result{Hops: make([][]Hop, 0)}, nil\n\t\t},\n\t}, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"RunMTRRaw returned error: %v\", err)\n\t}\n\tif calls != 1 {\n\t\tt.Fatalf(\"RunRound override called %d times, want 1\", calls)\n\t}\n}\n\nfunc TestRunMTRRaw_RoundBasedNormalizesRuntimeConfig(t *testing.T) {\n\toldSrcDev := util.SrcDev\n\toldDisableMPLS := util.DisableMPLS\n\tt.Cleanup(func() {\n\t\tutil.SrcDev = oldSrcDev\n\t\tutil.DisableMPLS = oldDisableMPLS\n\t})\n\n\tutil.SrcDev = \"en0\"\n\tutil.DisableMPLS = true\n\n\tcalls := 0\n\terr := RunMTRRaw(context.Background(), ICMPTrace, Config{}, MTRRawOptions{\n\t\tMaxRounds: 1,\n\t\tRunRound: func(_ Method, cfg Config) (*Result, error) {\n\t\t\tcalls++\n\t\t\tif cfg.SourceDevice != \"en0\" {\n\t\t\t\tt.Fatalf(\"cfg.SourceDevice = %q, want en0\", cfg.SourceDevice)\n\t\t\t}\n\t\t\tif cfg.DisableMPLS {\n\t\t\t\tt.Fatal(\"cfg.DisableMPLS = true, want false\")\n\t\t\t}\n\t\t\treturn &Result{Hops: make([][]Hop, 0)}, nil\n\t\t},\n\t}, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"RunMTRRaw returned error: %v\", err)\n\t}\n\tif calls != 1 {\n\t\tt.Fatalf(\"RunRound override called %d times, want 1\", calls)\n\t}\n}\n"
  },
  {
    "path": "trace/mtr_runner.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/google/gopacket/layers\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\n// ---------------------------------------------------------------------------\n// MTR 长驻运行器\n// ---------------------------------------------------------------------------\n\n// MTROptions 控制 MTR 连续探测行为。\ntype MTROptions struct {\n\t// Interval 每轮之间的等待间隔（默认 1s）。\n\t// 仅在 legacy round-based 模式（Web MTR）使用。\n\tInterval time.Duration\n\t// MaxRounds 最大轮次，0 表示无限运行直到取消。\n\t// 仅在 legacy round-based 模式使用。\n\tMaxRounds int\n\t// HopInterval 同一 TTL 两次探测之间的间隔（per-hop 调度模式）。\n\t// > 0 时启用 per-hop 调度，忽略 Interval / MaxRounds。\n\tHopInterval time.Duration\n\t// MaxPerHop 每个 TTL 最大探测次数，0 表示无限。\n\tMaxPerHop int\n\t// IsPaused 可选：返回 true 时暂停探测（轮询检查）。\n\tIsPaused func() bool\n\t// IsResetRequested 可选：返回 true（原子消费）时重置统计。\n\tIsResetRequested func() bool\n\t// ProgressThrottle 流式预览最小刷新间隔（默认 200ms）。\n\tProgressThrottle time.Duration\n}\n\n// MTROnSnapshot 每轮完成后的回调，用于刷新 CLI 表格。\n// iteration 是当前轮次（从 1 开始），stats 是截至当前的聚合快照。\ntype MTROnSnapshot func(iteration int, stats []MTRHopStat)\n\n// mtrBackoffCfg 控制连续错误时的指数退避行为。\ntype mtrBackoffCfg struct {\n\tInitial   time.Duration\n\tMax       time.Duration\n\tMaxConsec int\n}\n\nvar defaultBackoff = mtrBackoffCfg{\n\tInitial:   500 * time.Millisecond,\n\tMax:       30 * time.Second,\n\tMaxConsec: 10,\n}\n\n// mtrProber 抽象一轮探测，允许测试注入 mock。\ntype mtrProber interface {\n\tprobeRound(ctx context.Context) (*Result, error)\n\tclose()\n}\n\n// mtrResetter 可选接口：重置统计时清除 prober 内部缓存。\ntype mtrResetter interface {\n\tresetFinalTTL()\n}\n\n// mtrPeeker 可选接口：支持流式预览探测进度。\n// 引擎在 probeRound 执行期间，外部可调用 peekPartialResult 获取当前已收到的部分响应。\ntype mtrPeeker interface {\n\tpeekPartialResult() *Result\n}\n\n// RunMTR 启动 MTR 连续探测模式。\n//\n// 当 opts.HopInterval > 0 时使用 per-hop 独立调度（CLI MTR 模式）：\n//\n//\t每个 TTL 独立计时，完成后等 HopInterval 再发下一个。\n//\n// 当 opts.HopInterval == 0 时使用 legacy round-based 调度（Web MTR 兼容）：\n//\n//\tICMP 使用持久 raw socket 跨轮复用；TCP/UDP 以 per-round Traceroute 回退。\nfunc RunMTR(ctx context.Context, method Method, baseConfig Config, opts MTROptions, onSnapshot MTROnSnapshot) error {\n\tif opts.HopInterval > 0 {\n\t\treturn runMTRPerHop(ctx, method, baseConfig, opts, onSnapshot)\n\t}\n\treturn runMTRRoundBased(ctx, method, baseConfig, opts, onSnapshot)\n}\n\n// runMTRPerHop 使用 per-hop 独立调度模式。\nfunc runMTRPerHop(ctx context.Context, method Method, baseConfig Config, opts MTROptions, onSnapshot MTROnSnapshot) error {\n\tnormalizeRuntimeConfig(&baseConfig)\n\tbaseConfig.NumMeasurements = 1\n\tbaseConfig.MaxAttempts = 1\n\tbaseConfig.RealtimePrinter = nil\n\tbaseConfig.AsyncPrinter = nil\n\n\tif baseConfig.MaxHops == 0 {\n\t\tbaseConfig.MaxHops = 30\n\t}\n\tif baseConfig.ICMPMode <= 0 && util.EnvICMPMode > 0 {\n\t\tbaseConfig.ICMPMode = util.EnvICMPMode\n\t}\n\tswitch baseConfig.ICMPMode {\n\tcase 0, 1, 2:\n\tdefault:\n\t\tbaseConfig.ICMPMode = 0\n\t}\n\n\tagg := NewMTRAggregator()\n\tvar prober mtrTTLProber\n\n\tif method == ICMPTrace {\n\t\tengine, err := newMTRICMPEngine(baseConfig)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"mtr: %w\", err)\n\t\t}\n\t\tdefer engine.close()\n\t\tif err := engine.start(ctx); err != nil {\n\t\t\tengine.close()\n\t\t\treturn fmt.Errorf(\"mtr: %w\", err)\n\t\t}\n\t\tprober = engine\n\t} else {\n\t\tprober = &mtrFallbackTTLProber{method: method, config: baseConfig}\n\t}\n\n\treturn runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         baseConfig.BeginHop,\n\t\tMaxHops:          baseConfig.MaxHops,\n\t\tHopInterval:      opts.HopInterval,\n\t\tTimeout:          baseConfig.Timeout,\n\t\tMaxPerHop:        opts.MaxPerHop,\n\t\tParallelRequests: baseConfig.ParallelRequests,\n\t\tProgressThrottle: opts.ProgressThrottle,\n\t\tFillGeo:          true,\n\t\tBaseConfig:       baseConfig,\n\t\tDstIP:            baseConfig.DstIP,\n\t\tIsPaused:         opts.IsPaused,\n\t\tIsResetRequested: opts.IsResetRequested,\n\t}, onSnapshot, nil)\n}\n\n// runMTRRoundBased 使用 legacy round-based 调度模式（Web MTR 兼容）。\nfunc runMTRRoundBased(ctx context.Context, method Method, baseConfig Config, opts MTROptions, onSnapshot MTROnSnapshot) error {\n\tnormalizeRuntimeConfig(&baseConfig)\n\tif opts.Interval <= 0 {\n\t\topts.Interval = time.Second\n\t}\n\n\t// MTR：每轮每 hop 仅一个探测包\n\tbaseConfig.NumMeasurements = 1\n\tbaseConfig.MaxAttempts = 1\n\t// 注意：不覆盖 ParallelRequests——尊重用户 --parallel-requests 设定\n\tbaseConfig.RealtimePrinter = nil\n\tbaseConfig.AsyncPrinter = nil\n\n\t// 与 Traceroute() 保持一致的默认值\n\tif baseConfig.MaxHops == 0 {\n\t\tbaseConfig.MaxHops = 30\n\t}\n\tif baseConfig.ICMPMode <= 0 && util.EnvICMPMode > 0 {\n\t\tbaseConfig.ICMPMode = util.EnvICMPMode\n\t}\n\tswitch baseConfig.ICMPMode {\n\tcase 0, 1, 2:\n\tdefault:\n\t\tbaseConfig.ICMPMode = 0\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tif method == ICMPTrace {\n\t\tengine, err := newMTRICMPEngine(baseConfig)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"mtr: %w\", err)\n\t\t}\n\t\tdefer engine.close()\n\t\tif err := engine.start(ctx); err != nil {\n\t\t\tengine.close()\n\t\t\treturn fmt.Errorf(\"mtr: %w\", err)\n\t\t}\n\t\treturn mtrLoop(ctx, engine, baseConfig, opts, agg, onSnapshot, true, nil)\n\t}\n\n\tprober := &mtrFallbackProber{method: method, config: baseConfig}\n\t// Traceroute 内部已做 geo/rdns 查询，无需再填充\n\treturn mtrLoop(ctx, prober, baseConfig, opts, agg, onSnapshot, false, nil)\n}\n\n// ---------------------------------------------------------------------------\n// 主循环（ICMP 持久引擎 / TCP·UDP 回退共用）\n// ---------------------------------------------------------------------------\n\nfunc mtrLoop(\n\tctx context.Context,\n\tprober mtrProber,\n\tconfig Config,\n\topts MTROptions,\n\tagg *MTRAggregator,\n\tonSnapshot MTROnSnapshot,\n\tfillGeo bool,\n\tbo *mtrBackoffCfg,\n) error {\n\tdefer prober.close()\n\trt := newMTRLoopRuntime(ctx, prober, config, opts, agg, onSnapshot, fillGeo, bo)\n\treturn rt.run()\n}\n\n// mtrFillGeoRDNS 并发查询 Result 中各 hop 的地理信息与反向 DNS。\n// fetchIPData 内部有 singleflight + geoCache，重复 IP 不会重复查询。\nfunc mtrFillGeoRDNS(res *Result, config Config) {\n\tvar wg sync.WaitGroup\n\tfor idx := range res.Hops {\n\t\tfor j := range res.Hops[idx] {\n\t\t\th := &res.Hops[idx][j]\n\t\t\tif !h.Success || h.Address == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\th.Lang = config.Lang\n\t\t\twg.Add(1)\n\t\t\tgo func(hop *Hop) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t_ = hop.fetchIPData(config)\n\t\t\t}(h)\n\t\t}\n\t}\n\twg.Wait()\n}\n\n// ---------------------------------------------------------------------------\n// 持久 ICMP 引擎（mtr 风格：raw socket 只创建一次，跨轮复用）\n// ---------------------------------------------------------------------------\n\ntype mtrICMPEngine struct {\n\tconfig Config\n\tspec   *internal.ICMPSpec\n\techoID int\n\tsrcIP  net.IP\n\tipVer  int\n\n\t// 单调递增序列号，避免跨轮 seq 冲突\n\tseqCounter uint32\n\n\t// per-round 探针/响应匹配\n\tmu       sync.Mutex\n\tsentAt   map[int]mtrProbeMeta // seq → probe metadata\n\treplied  map[int]*mtrProbeReply\n\tnotifyCh chan struct{}\n\n\t// 当前轮次 ID，用于丢弃过期响应\n\troundID uint32\n\n\t// 已知目的地 TTL（-1 = 未知），跨轮保留以减少无效探测\n\tknownFinalTTL int32\n\t// 当前轮次内发现的目的地 TTL（-1 = 本轮未发现）\n\troundFinalTTL int32\n\n\t// 流式预览状态（peekPartialResult 使用，受 mu 保护）\n\tcurTtlSeq       map[int]int\n\tcurBeginHop     int\n\tcurEffectiveMax int\n\n\t// Per-probe notification channels for ProbeTTL（受 mu 保护）。\n\tprobeNotify map[int]chan struct{} // seq → done chan\n\n\t// sendMu serializes seq allocation + rotation check in concurrent ProbeTTL calls.\n\tsendMu sync.Mutex\n}\n\n// mtrProbeMeta 记录已发送探针的元信息，用于响应匹配。\ntype mtrProbeMeta struct {\n\tttl     int\n\tstart   time.Time\n\troundID uint32\n}\n\ntype mtrProbeReply struct {\n\tpeer net.Addr\n\trtt  time.Duration\n\tmpls []string\n}\n\nfunc newMTRICMPEngine(config Config) (*mtrICMPEngine, error) {\n\tipVer := 4\n\tif config.DstIP.To4() == nil {\n\t\tipVer = 6\n\t}\n\n\tvar srcAddr net.IP\n\tif config.SrcAddr != \"\" {\n\t\tsrcAddr = net.ParseIP(config.SrcAddr)\n\t\tif ipVer == 4 && srcAddr != nil {\n\t\t\tsrcAddr = srcAddr.To4()\n\t\t}\n\t}\n\n\tvar srcIP net.IP\n\tif ipVer == 6 {\n\t\tsrcIP, _ = util.LocalIPPortv6(config.DstIP, srcAddr, \"icmp6\")\n\t} else {\n\t\tsrcIP, _ = util.LocalIPPort(config.DstIP, srcAddr, \"icmp\")\n\t}\n\tif srcIP == nil {\n\t\treturn nil, fmt.Errorf(\"cannot determine local IP for MTR ICMP\")\n\t}\n\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\techoID := (r.Intn(256) << 8) | (os.Getpid() & 0xFF)\n\n\treturn &mtrICMPEngine{\n\t\tconfig:        config,\n\t\tipVer:         ipVer,\n\t\techoID:        echoID,\n\t\tsrcIP:         srcIP,\n\t\tknownFinalTTL: -1,\n\t}, nil\n}\n\n// start 创建持久 ICMP 套接字及监听协程。ctx 生命周期控制整个引擎。\nfunc (e *mtrICMPEngine) start(ctx context.Context) error {\n\te.spec = internal.NewICMPSpec(e.ipVer, e.config.ICMPMode, e.echoID, e.srcIP, e.config.DstIP)\n\te.spec.InitICMP()\n\n\te.notifyCh = make(chan struct{}, 1)\n\te.sentAt = make(map[int]mtrProbeMeta)\n\te.replied = make(map[int]*mtrProbeReply)\n\te.probeNotify = make(map[int]chan struct{})\n\n\tready := make(chan struct{})\n\tgo e.spec.ListenICMP(ctx, ready, e.onICMP)\n\n\tselect {\n\tcase <-ready:\n\tcase <-ctx.Done():\n\t\te.close()\n\t\treturn ctx.Err()\n\tcase <-time.After(5 * time.Second):\n\t\te.close()\n\t\treturn fmt.Errorf(\"ICMP listener startup timeout\")\n\t}\n\ttime.Sleep(100 * time.Millisecond)\n\treturn nil\n}\n\nfunc (e *mtrICMPEngine) close() {\n\tif e.spec != nil {\n\t\te.spec.Close()\n\t\te.spec = nil\n\t}\n}\n\n// resetFinalTTL 清除已知目的地 TTL 缓存（r 键重置统计时调用）。\nfunc (e *mtrICMPEngine) resetFinalTTL() {\n\tatomic.StoreInt32(&e.knownFinalTTL, -1)\n}\n\n// peekPartialResult 返回当前轮次已收到的部分探测结果（用于流式预览）。\n// 在 probeRound 运行期间由外部 ticker 调用，与 probeRound 共享 mu 保护。\nfunc (e *mtrICMPEngine) peekPartialResult() *Result {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\n\tbeginHop := e.curBeginHop\n\teffectiveMax := e.curEffectiveMax\n\tif effectiveMax <= 0 || e.curTtlSeq == nil {\n\t\treturn nil\n\t}\n\n\t// 若已检测到目的地，缩小预览范围\n\tif rf := atomic.LoadInt32(&e.roundFinalTTL); rf > 0 && int(rf) < effectiveMax {\n\t\teffectiveMax = int(rf)\n\t}\n\n\tres := &Result{Hops: make([][]Hop, effectiveMax)}\n\tfor ttl := beginHop; ttl <= effectiveMax; ttl++ {\n\t\tidx := ttl - 1\n\t\tseq, sent := e.curTtlSeq[ttl]\n\t\tif !sent {\n\t\t\t// 尚未发送，保持 nil 槽位让聚合器跳过\n\t\t\tcontinue\n\t\t}\n\t\tif reply, ok := e.replied[seq]; ok {\n\t\t\tres.Hops[idx] = []Hop{{\n\t\t\t\tSuccess: true,\n\t\t\t\tAddress: reply.peer,\n\t\t\t\tTTL:     ttl,\n\t\t\t\tRTT:     reply.rtt,\n\t\t\t\tMPLS:    reply.mpls,\n\t\t\t}}\n\t\t} else {\n\t\t\t// 已发送但未收到响应，显示为超时\n\t\t\tres.Hops[idx] = []Hop{{\n\t\t\t\tSuccess: false,\n\t\t\t\tAddress: nil,\n\t\t\t\tTTL:     ttl,\n\t\t\t\tRTT:     0,\n\t\t\t}}\n\t\t}\n\t}\n\treturn res\n}\n\n// seqWillWrap 判断再发 probeCount 个探针后 16 位 wire seq 是否会回卷。\n// probeCount <= 0 时不发包，不可能回卷。\nfunc seqWillWrap(seqCounter uint32, probeCount int) bool {\n\tif probeCount <= 0 {\n\t\treturn false\n\t}\n\treturn (seqCounter&0xFFFF)+uint32(probeCount) > 0xFFFF\n}\n\n// rotateEngine 关闭旧 ICMP 监听器，生成新 echoID 并重建引擎。\n// 新 listener 过滤新 echoID，旧 echoID 的迟到回包在协议层即被丢弃，\n// 从而彻底消除 seq 16 位回卷导致的跨轮误匹配。\nfunc (e *mtrICMPEngine) rotateEngine(ctx context.Context) error {\n\te.spec.Close()\n\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\te.echoID = (r.Intn(256) << 8) | (os.Getpid() & 0xFF)\n\tatomic.StoreUint32(&e.seqCounter, 0)\n\n\te.spec = internal.NewICMPSpec(e.ipVer, e.config.ICMPMode, e.echoID, e.srcIP, e.config.DstIP)\n\te.spec.InitICMP()\n\n\te.mu.Lock()\n\t// Notify any ProbeTTL waiters about rotation (they'll see no reply)\n\tfor seq, ch := range e.probeNotify {\n\t\tclose(ch)\n\t\tdelete(e.probeNotify, seq)\n\t}\n\te.notifyCh = make(chan struct{}, 1)\n\te.sentAt = make(map[int]mtrProbeMeta)\n\te.replied = make(map[int]*mtrProbeReply)\n\te.probeNotify = make(map[int]chan struct{})\n\te.mu.Unlock()\n\n\tready := make(chan struct{})\n\tgo e.spec.ListenICMP(ctx, ready, e.onICMP)\n\n\tselect {\n\tcase <-ready:\n\tcase <-ctx.Done():\n\t\te.close()\n\t\treturn ctx.Err()\n\tcase <-time.After(5 * time.Second):\n\t\te.close()\n\t\treturn fmt.Errorf(\"ICMP listener restart timeout on echoID rotation\")\n\t}\n\treturn nil\n}\n\n// onICMP 是 ListenICMP 的回调：将响应匹配到已发送的探针。\nfunc (e *mtrICMPEngine) onICMP(msg internal.ReceivedMessage, finish time.Time, seq int) {\n\te.mu.Lock()\n\tstart, ok := e.sentAt[seq]\n\tif !ok {\n\t\te.mu.Unlock()\n\t\treturn\n\t}\n\tif e.shouldDiscardProbeReplyLocked(seq, start, finish) {\n\t\te.mu.Unlock()\n\t\treturn\n\t}\n\n\trtt := finish.Sub(start.start)\n\te.storeProbeReplyLocked(seq, msg, rtt)\n\tfinalTTL := e.detectRoundFinalTTLCandidate(msg.Peer, start.ttl)\n\te.mu.Unlock()\n\n\te.updateRoundFinalTTL(finalTTL)\n\te.signalReplyReady()\n}\n\nfunc (e *mtrICMPEngine) shouldDiscardProbeReplyLocked(seq int, start mtrProbeMeta, finish time.Time) bool {\n\tif start.roundID != atomic.LoadUint32(&e.roundID) {\n\t\te.discardProbeLocked(seq)\n\t\treturn true\n\t}\n\tif !e.validProbeRTT(finish.Sub(start.start)) {\n\t\te.discardProbeLocked(seq)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e *mtrICMPEngine) validProbeRTT(rtt time.Duration) bool {\n\tmaxRTT := e.config.Timeout\n\tif maxRTT <= 0 {\n\t\tmaxRTT = 2 * time.Second\n\t}\n\treturn rtt > 0 && rtt <= maxRTT\n}\n\nfunc (e *mtrICMPEngine) discardProbeLocked(seq int) {\n\tdelete(e.sentAt, seq)\n\te.closeProbeNotifyLocked(seq)\n}\n\nfunc (e *mtrICMPEngine) storeProbeReplyLocked(seq int, msg internal.ReceivedMessage, rtt time.Duration) {\n\te.replied[seq] = &mtrProbeReply{\n\t\tpeer: msg.Peer,\n\t\trtt:  rtt,\n\t\tmpls: extractMPLS(msg, e.config.DisableMPLS),\n\t}\n\tdelete(e.sentAt, seq)\n\te.closeProbeNotifyLocked(seq)\n}\n\nfunc (e *mtrICMPEngine) closeProbeNotifyLocked(seq int) {\n\tif ch, ok := e.probeNotify[seq]; ok {\n\t\tclose(ch)\n\t\tdelete(e.probeNotify, seq)\n\t}\n}\n\nfunc (e *mtrICMPEngine) detectRoundFinalTTLCandidate(peer net.Addr, ttl int) int32 {\n\tpeerIP := mtrPeerIP(peer)\n\tif peerIP == nil || !peerIP.Equal(e.config.DstIP) {\n\t\treturn -1\n\t}\n\treturn int32(ttl)\n}\n\nfunc mtrPeerIP(addr net.Addr) net.IP {\n\tswitch a := addr.(type) {\n\tcase *net.IPAddr:\n\t\treturn a.IP\n\tcase *net.UDPAddr:\n\t\treturn a.IP\n\tcase *net.TCPAddr:\n\t\treturn a.IP\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc (e *mtrICMPEngine) updateRoundFinalTTL(ttl int32) {\n\tif ttl < 0 {\n\t\treturn\n\t}\n\tcurFinal := atomic.LoadInt32(&e.roundFinalTTL)\n\tif curFinal < 0 || ttl < curFinal {\n\t\tatomic.StoreInt32(&e.roundFinalTTL, ttl)\n\t}\n}\n\nfunc (e *mtrICMPEngine) signalReplyReady() {\n\tselect {\n\tcase e.notifyCh <- struct{}{}:\n\tdefault:\n\t}\n}\n\n// sendProbe 发送一个 ICMP echo（IPv4 或 IPv6），返回发送时间戳。\nfunc (e *mtrICMPEngine) sendProbe(ctx context.Context, ttl, seq int) (time.Time, error) {\n\tpayloadSize := resolveProbePayloadSize(ICMPTrace, e.config.DstIP, e.config.PktSize, e.config.RandomPacketSize)\n\tpayload := make([]byte, payloadSize)\n\tif len(payload) >= 3 {\n\t\tcopy(payload[len(payload)-3:], []byte{'n', 't', 'r'})\n\t}\n\n\tif e.ipVer == 4 {\n\t\tipHdr := &layers.IPv4{\n\t\t\tVersion:  4,\n\t\t\tSrcIP:    e.srcIP,\n\t\t\tDstIP:    e.config.DstIP,\n\t\t\tProtocol: layers.IPProtocolICMPv4,\n\t\t\tTTL:      uint8(ttl),\n\t\t\tTOS:      uint8(e.config.TOS),\n\t\t}\n\t\ticmpHdr := &layers.ICMPv4{\n\t\t\tTypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0),\n\t\t\tId:       uint16(e.echoID),\n\t\t\tSeq:      uint16(seq),\n\t\t}\n\t\treturn e.spec.SendICMP(ctx, ipHdr, icmpHdr, nil, payload)\n\t}\n\n\t// IPv6\n\tipHdr := &layers.IPv6{\n\t\tVersion:      6,\n\t\tSrcIP:        e.srcIP,\n\t\tDstIP:        e.config.DstIP,\n\t\tNextHeader:   layers.IPProtocolICMPv6,\n\t\tHopLimit:     uint8(ttl),\n\t\tTrafficClass: uint8(e.config.TOS),\n\t}\n\ticmpHdr := &layers.ICMPv6{\n\t\tTypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0),\n\t}\n\tif err := icmpHdr.SetNetworkLayerForChecksum(ipHdr); err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"SetNetworkLayerForChecksum: %w\", err)\n\t}\n\ticmpEcho := &layers.ICMPv6Echo{\n\t\tIdentifier: uint16(e.echoID),\n\t\tSeqNumber:  uint16(seq),\n\t}\n\treturn e.spec.SendICMP(ctx, ipHdr, icmpHdr, icmpEcho, payload)\n}\n\n// probeRound 执行一轮持久探测：对每个 TTL 发送一个 ICMP echo，\n// 收集响应后构造与 Traceroute 兼容的 *Result。\n// 若已知目的地 TTL 则提前停止发送。\nfunc (e *mtrICMPEngine) probeRound(ctx context.Context) (*Result, error) {\n\tround := e.prepareProbeRound(ctx)\n\tif round.err != nil {\n\t\treturn nil, round.err\n\t}\n\tif err := e.sendProbeSweep(ctx, round); err != nil {\n\t\treturn nil, err\n\t}\n\te.waitForProbeReplies(ctx)\n\treturn e.buildProbeRoundResult(round.beginHop, e.finalizeProbeRound(round.effectiveMax)), nil\n}\n\ntype mtrProbeRoundState struct {\n\tbeginHop     int\n\teffectiveMax int\n\troundID      uint32\n\tprobeDelay   time.Duration\n\terr          error\n}\n\nfunc (e *mtrICMPEngine) prepareProbeRound(ctx context.Context) mtrProbeRoundState {\n\tmaxHops := e.config.MaxHops\n\tbeginHop := e.config.BeginHop\n\tif beginHop <= 0 {\n\t\tbeginHop = 1\n\t}\n\tcurRound := atomic.AddUint32(&e.roundID, 1)\n\tatomic.StoreInt32(&e.roundFinalTTL, -1)\n\te.resetProbeRoundMaps()\n\te.drainNotifySignal()\n\n\tprobeCount := maxHops - beginHop + 1\n\tif probeCount < 1 {\n\t\tprobeCount = 1\n\t}\n\tif err := e.rotateProbeEngineIfNeeded(ctx, probeCount); err != nil {\n\t\treturn mtrProbeRoundState{err: fmt.Errorf(\"echoID rotation: %w\", err)}\n\t}\n\n\teffectiveMax := e.effectiveProbeRoundMax(maxHops)\n\te.initProbeRoundPreview(beginHop, effectiveMax)\n\n\treturn mtrProbeRoundState{\n\t\tbeginHop:     beginHop,\n\t\teffectiveMax: effectiveMax,\n\t\troundID:      curRound,\n\t\tprobeDelay:   e.probeRoundDelay(),\n\t}\n}\n\nfunc (e *mtrICMPEngine) resetProbeRoundMaps() {\n\te.mu.Lock()\n\te.sentAt = make(map[int]mtrProbeMeta)\n\te.replied = make(map[int]*mtrProbeReply)\n\te.mu.Unlock()\n}\n\nfunc (e *mtrICMPEngine) drainNotifySignal() {\n\tselect {\n\tcase <-e.notifyCh:\n\tdefault:\n\t}\n}\n\nfunc (e *mtrICMPEngine) rotateProbeEngineIfNeeded(ctx context.Context, probeCount int) error {\n\tif !seqWillWrap(atomic.LoadUint32(&e.seqCounter), probeCount) {\n\t\treturn nil\n\t}\n\treturn e.rotateEngine(ctx)\n}\n\nfunc (e *mtrICMPEngine) effectiveProbeRoundMax(maxHops int) int {\n\tif kf := atomic.LoadInt32(&e.knownFinalTTL); kf > 0 && int(kf) < maxHops {\n\t\treturn int(kf)\n\t}\n\treturn maxHops\n}\n\nfunc (e *mtrICMPEngine) initProbeRoundPreview(beginHop, effectiveMax int) {\n\te.mu.Lock()\n\te.curTtlSeq = make(map[int]int, effectiveMax-beginHop+1)\n\te.curBeginHop = beginHop\n\te.curEffectiveMax = effectiveMax\n\te.mu.Unlock()\n}\n\nfunc (e *mtrICMPEngine) probeRoundDelay() time.Duration {\n\tprobeDelay := time.Millisecond * time.Duration(e.config.PacketInterval)\n\tif probeDelay <= 0 {\n\t\treturn 5 * time.Millisecond\n\t}\n\treturn probeDelay\n}\n\nfunc (e *mtrICMPEngine) sendProbeSweep(ctx context.Context, round mtrProbeRoundState) error {\n\tfor ttl := round.beginHop; ttl <= round.effectiveMax; ttl++ {\n\t\tif ctx.Err() != nil {\n\t\t\treturn ctx.Err()\n\t\t}\n\t\tsent, err := e.sendProbeForTTL(ctx, ttl, round.roundID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !sent {\n\t\t\tcontinue\n\t\t}\n\t\tif err := e.waitProbeInterval(ctx, round.probeDelay); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (e *mtrICMPEngine) sendProbeForTTL(ctx context.Context, ttl int, roundID uint32) (bool, error) {\n\tseq := int(atomic.AddUint32(&e.seqCounter, 1) & 0xFFFF)\n\n\t// Pre-register the seq so onICMP can match it even for very short RTT replies.\n\tpreStart := time.Now()\n\te.mu.Lock()\n\te.sentAt[seq] = mtrProbeMeta{ttl: ttl, start: preStart, roundID: roundID}\n\te.curTtlSeq[ttl] = seq\n\te.mu.Unlock()\n\n\tstart, err := e.sendProbe(ctx, ttl, seq)\n\tif err != nil {\n\t\t// Roll back the pre-registered state on send failure.\n\t\te.mu.Lock()\n\t\tdelete(e.sentAt, seq)\n\t\tif e.curTtlSeq[ttl] == seq {\n\t\t\tdelete(e.curTtlSeq, ttl)\n\t\t}\n\t\te.mu.Unlock()\n\t\tif ctx.Err() != nil {\n\t\t\treturn false, ctx.Err()\n\t\t}\n\t\treturn false, nil\n\t}\n\n\t// Update the start timestamp to the actual send time for accurate RTT.\n\te.mu.Lock()\n\tif meta, ok := e.sentAt[seq]; ok {\n\t\tmeta.start = start\n\t\te.sentAt[seq] = meta\n\t}\n\te.mu.Unlock()\n\treturn true, nil\n}\n\nfunc (e *mtrICMPEngine) waitProbeInterval(ctx context.Context, delay time.Duration) error {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-time.After(delay):\n\t\treturn nil\n\t}\n}\n\nfunc (e *mtrICMPEngine) waitForProbeReplies(ctx context.Context) {\n\tdeadline := time.After(e.probeResponseTimeout())\n\tfor e.hasPendingProbeReplies() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-deadline:\n\t\t\treturn\n\t\tcase <-e.notifyCh:\n\t\t}\n\t}\n}\n\nfunc (e *mtrICMPEngine) probeResponseTimeout() time.Duration {\n\ttimeout := e.config.Timeout\n\tif timeout <= 0 {\n\t\treturn 2 * time.Second\n\t}\n\treturn timeout\n}\n\nfunc (e *mtrICMPEngine) hasPendingProbeReplies() bool {\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\treturn len(e.sentAt) > 0\n}\n\nfunc (e *mtrICMPEngine) finalizeProbeRound(effectiveMax int) int {\n\te.updateKnownFinalTTLFromRound()\n\treturn e.roundFinalMax(effectiveMax)\n}\n\nfunc (e *mtrICMPEngine) updateKnownFinalTTLFromRound() {\n\tif rf := atomic.LoadInt32(&e.roundFinalTTL); rf > 0 {\n\t\tkf := atomic.LoadInt32(&e.knownFinalTTL)\n\t\tif kf < 0 || rf < kf {\n\t\t\tatomic.StoreInt32(&e.knownFinalTTL, rf)\n\t\t}\n\t}\n}\n\nfunc (e *mtrICMPEngine) roundFinalMax(effectiveMax int) int {\n\tif rf := atomic.LoadInt32(&e.roundFinalTTL); rf > 0 && int(rf) < effectiveMax {\n\t\treturn int(rf)\n\t}\n\treturn effectiveMax\n}\n\nfunc (e *mtrICMPEngine) buildProbeRoundResult(beginHop, finalMax int) *Result {\n\tres := &Result{Hops: make([][]Hop, finalMax)}\n\te.mu.Lock()\n\tdefer e.mu.Unlock()\n\tfor ttl := beginHop; ttl <= finalMax; ttl++ {\n\t\tres.Hops[ttl-1] = []Hop{e.probeRoundHop(ttl)}\n\t}\n\treturn res\n}\n\nfunc (e *mtrICMPEngine) probeRoundHop(ttl int) Hop {\n\tif seq, sent := e.curTtlSeq[ttl]; sent {\n\t\tif reply, ok := e.replied[seq]; ok {\n\t\t\treturn Hop{\n\t\t\t\tSuccess: true,\n\t\t\t\tAddress: reply.peer,\n\t\t\t\tTTL:     ttl,\n\t\t\t\tRTT:     reply.rtt,\n\t\t\t\tMPLS:    reply.mpls,\n\t\t\t}\n\t\t}\n\t}\n\treturn Hop{\n\t\tSuccess: false,\n\t\tAddress: nil,\n\t\tTTL:     ttl,\n\t\tRTT:     0,\n\t\tError:   errHopLimitTimeout,\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// TCP/UDP 回退 prober：每轮调用 Traceroute + 指数退避\n// ---------------------------------------------------------------------------\n\ntype mtrFallbackProber struct {\n\tmethod Method\n\tconfig Config\n}\n\nfunc (p *mtrFallbackProber) probeRound(ctx context.Context) (*Result, error) {\n\treturn TracerouteWithContext(ctx, p.method, p.config)\n}\n\nfunc (p *mtrFallbackProber) close() {}\n\n// ---------------------------------------------------------------------------\n// ProbeTTL — single-TTL probing for per-hop scheduler\n// ---------------------------------------------------------------------------\n\n// ProbeTTL sends one ICMP echo at the given TTL and blocks until a response\n// arrives, the timeout elapses, or ctx is cancelled.\nfunc (e *mtrICMPEngine) ProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error) {\n\t// Serialize seq allocation + rotation check across concurrent ProbeTTL calls.\n\te.sendMu.Lock()\n\tif seqWillWrap(atomic.LoadUint32(&e.seqCounter), 1) {\n\t\tif err := e.rotateEngine(ctx); err != nil {\n\t\t\te.sendMu.Unlock()\n\t\t\treturn mtrProbeResult{TTL: ttl}, fmt.Errorf(\"echoID rotation: %w\", err)\n\t\t}\n\t}\n\tseq := int(atomic.AddUint32(&e.seqCounter, 1) & 0xFFFF)\n\te.sendMu.Unlock()\n\n\tcurRound := atomic.LoadUint32(&e.roundID)\n\tdone := make(chan struct{})\n\n\t// Pre-register before sending so onICMP can match even very short RTT replies.\n\tpreStart := time.Now()\n\te.mu.Lock()\n\te.sentAt[seq] = mtrProbeMeta{ttl: ttl, start: preStart, roundID: curRound}\n\te.probeNotify[seq] = done\n\te.mu.Unlock()\n\n\tsendStart, err := e.sendProbe(ctx, ttl, seq)\n\tif err != nil {\n\t\t// Roll back pre-registered state.\n\t\te.mu.Lock()\n\t\tdelete(e.sentAt, seq)\n\t\te.closeProbeNotifyLocked(seq)\n\t\te.mu.Unlock()\n\t\tif ctx.Err() != nil {\n\t\t\treturn mtrProbeResult{TTL: ttl}, ctx.Err()\n\t\t}\n\t\t// Send failed: treat as timeout (no response for this TTL)\n\t\treturn mtrProbeResult{TTL: ttl}, nil\n\t}\n\n\t// Update to actual send timestamp for accurate RTT.\n\te.mu.Lock()\n\tif meta, ok := e.sentAt[seq]; ok {\n\t\tmeta.start = sendStart\n\t\te.sentAt[seq] = meta\n\t}\n\te.mu.Unlock()\n\n\ttimeout := e.config.Timeout\n\tif timeout <= 0 {\n\t\ttimeout = 2 * time.Second\n\t}\n\ttimer := time.NewTimer(timeout)\n\tdefer timer.Stop()\n\n\tselect {\n\tcase <-done:\n\t\te.mu.Lock()\n\t\treply, ok := e.replied[seq]\n\t\tif ok {\n\t\t\tdelete(e.replied, seq)\n\t\t}\n\t\te.mu.Unlock()\n\n\t\tif ok && reply != nil {\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    reply.peer,\n\t\t\t\tRTT:     reply.rtt,\n\t\t\t\tMPLS:    reply.mpls,\n\t\t\t}, nil\n\t\t}\n\t\t// Notified but no reply → was discarded (stale/bad RTT)\n\t\treturn mtrProbeResult{TTL: ttl}, nil\n\n\tcase <-timer.C:\n\t\te.mu.Lock()\n\t\tdelete(e.sentAt, seq)\n\t\tdelete(e.probeNotify, seq)\n\t\te.mu.Unlock()\n\t\treturn mtrProbeResult{TTL: ttl}, nil\n\n\tcase <-ctx.Done():\n\t\te.mu.Lock()\n\t\tdelete(e.sentAt, seq)\n\t\tdelete(e.probeNotify, seq)\n\t\te.mu.Unlock()\n\t\treturn mtrProbeResult{TTL: ttl}, ctx.Err()\n\t}\n}\n\n// Reset invalidates all in-flight probes (bumps roundID) and clears\n// knownFinalTTL. In-flight ProbeTTL calls get notified and return immediately.\nfunc (e *mtrICMPEngine) Reset() error {\n\te.resetFinalTTL()\n\tatomic.AddUint32(&e.roundID, 1)\n\n\te.mu.Lock()\n\tfor seq, ch := range e.probeNotify {\n\t\tclose(ch)\n\t\tdelete(e.probeNotify, seq)\n\t}\n\te.sentAt = make(map[int]mtrProbeMeta)\n\te.replied = make(map[int]*mtrProbeReply)\n\te.mu.Unlock()\n\n\treturn nil\n}\n\n// Close releases the underlying ICMP socket.\nfunc (e *mtrICMPEngine) Close() error {\n\te.close()\n\treturn nil\n}\n\n// ---------------------------------------------------------------------------\n// TCP/UDP 回退 TTL prober：单 TTL 探测\n// ---------------------------------------------------------------------------\n\n// mtrFallbackTTLProber uses Traceroute for single-TTL probing (TCP/UDP fallback).\ntype mtrFallbackTTLProber struct {\n\tmethod Method\n\tconfig Config\n}\n\nfunc (p *mtrFallbackTTLProber) ProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error) {\n\tcfg := p.config\n\tcfg.BeginHop = ttl\n\tcfg.MaxHops = ttl\n\tcfg.NumMeasurements = 1\n\tcfg.MaxAttempts = 1\n\tcfg.ParallelRequests = 1\n\tcfg.RealtimePrinter = nil\n\tcfg.AsyncPrinter = nil\n\n\tres, err := TracerouteWithContext(ctx, p.method, cfg)\n\tif err != nil {\n\t\treturn mtrProbeResult{TTL: ttl}, err\n\t}\n\n\tidx := ttl - 1\n\tif idx < 0 || idx >= len(res.Hops) || len(res.Hops[idx]) == 0 {\n\t\treturn mtrProbeResult{TTL: ttl}, nil\n\t}\n\n\th := res.Hops[idx][0]\n\treturn mtrProbeResult{\n\t\tTTL:      ttl,\n\t\tSuccess:  h.Success && h.Address != nil,\n\t\tAddr:     h.Address,\n\t\tRTT:      h.RTT,\n\t\tMPLS:     h.MPLS,\n\t\tHostname: h.Hostname,\n\t\tGeo:      h.Geo,\n\t}, nil\n}\n\nfunc (p *mtrFallbackTTLProber) Reset() error { return nil }\nfunc (p *mtrFallbackTTLProber) Close() error { return nil }\n"
  },
  {
    "path": "trace/mtr_runner_test.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n)\n\n// ---------------------------------------------------------------------------\n// Mock prober（实现 mtrProber 接口）\n// ---------------------------------------------------------------------------\n\ntype mockProber struct {\n\troundFn func(ctx context.Context) (*Result, error)\n\tclosed  int32\n}\n\nfunc (m *mockProber) probeRound(ctx context.Context) (*Result, error) {\n\treturn m.roundFn(ctx)\n}\n\nfunc (m *mockProber) close() {\n\tatomic.AddInt32(&m.closed, 1)\n}\n\nfunc constantResultProber(res *Result) *mockProber {\n\treturn &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\treturn res, nil\n\t\t},\n\t}\n}\n\n// 快速退避配置，避免测试阻塞\nvar fastBackoff = &mtrBackoffCfg{\n\tInitial:   time.Millisecond,\n\tMax:       5 * time.Millisecond,\n\tMaxConsec: 5,\n}\n\n// ---------------------------------------------------------------------------\n// 测试用例：通过 mtrLoop + mockProber 覆盖 RunMTR 主循环逻辑\n// ---------------------------------------------------------------------------\n\nfunc TestMTRLoopMaxRounds(t *testing.T) {\n\tmaxRounds := 5\n\tres := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t[]Hop{mkHop(2, \"2.2.2.2\", 20*time.Millisecond)},\n\t)\n\tprober := constantResultProber(res)\n\tagg := NewMTRAggregator()\n\n\tvar snapshots int\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds: maxRounds,\n\t\tInterval:  time.Millisecond,\n\t}, agg, func(iter int, stats []MTRHopStat) {\n\t\tsnapshots++\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif snapshots != maxRounds {\n\t\tt.Errorf(\"expected %d snapshots, got %d\", maxRounds, snapshots)\n\t}\n\tif atomic.LoadInt32(&prober.closed) != 1 {\n\t\tt.Error(\"prober.close() was not called\")\n\t}\n}\n\nfunc TestMTRLoopCancel(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tvar count int32\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tn := atomic.AddInt32(&count, 1)\n\t\t\tif n >= 3 {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn mkResult(\n\t\t\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t\t), nil\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\terr := mtrLoop(ctx, prober, Config{}, MTROptions{\n\t\tInterval: time.Millisecond,\n\t}, agg, func(_ int, _ []MTRHopStat) {}, false, fastBackoff)\n\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Errorf(\"expected context.Canceled, got %v\", err)\n\t}\n\tc := atomic.LoadInt32(&count)\n\tif c < 3 {\n\t\tt.Errorf(\"expected at least 3 probe rounds, got %d\", c)\n\t}\n}\n\nfunc TestMTRLoopErrorBackoff(t *testing.T) {\n\terrProbe := errors.New(\"temporary error\")\n\tvar callTimes []time.Time\n\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tcallTimes = append(callTimes, time.Now())\n\t\t\treturn nil, errProbe\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\tbo := &mtrBackoffCfg{\n\t\tInitial:   10 * time.Millisecond,\n\t\tMax:       50 * time.Millisecond,\n\t\tMaxConsec: 5,\n\t}\n\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tInterval: time.Millisecond,\n\t}, agg, func(_ int, _ []MTRHopStat) {}, false, bo)\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error from consecutive failures\")\n\t}\n\tif len(callTimes) != bo.MaxConsec {\n\t\tt.Errorf(\"expected %d probe calls, got %d\", bo.MaxConsec, len(callTimes))\n\t}\n\n\t// 验证退避间隔递增（至少前几两次差值应递增）\n\tif len(callTimes) >= 3 {\n\t\tgap1 := callTimes[1].Sub(callTimes[0])\n\t\tgap2 := callTimes[2].Sub(callTimes[1])\n\t\tif gap2 <= gap1/2 {\n\t\t\tt.Errorf(\"expected increasing backoff gaps, gap1=%v gap2=%v\", gap1, gap2)\n\t\t}\n\t}\n}\n\nfunc TestMTRLoopErrorRecovery(t *testing.T) {\n\tvar count int32\n\terrProbe := errors.New(\"temporary error\")\n\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tn := atomic.AddInt32(&count, 1)\n\t\t\tif n <= 3 {\n\t\t\t\treturn nil, errProbe\n\t\t\t}\n\t\t\treturn mkResult(\n\t\t\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t\t), nil\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\tvar snapshots int\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds: 2,\n\t\tInterval:  time.Millisecond,\n\t}, agg, func(_ int, _ []MTRHopStat) {\n\t\tsnapshots++\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif snapshots != 2 {\n\t\tt.Errorf(\"expected 2 successful snapshots after recovery, got %d\", snapshots)\n\t}\n}\n\nfunc TestMTRLoopTimeoutHops(t *testing.T) {\n\tres := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t[]Hop{mkTimeoutHop(2)},\n\t\t[]Hop{mkHop(3, \"3.3.3.3\", 30*time.Millisecond)},\n\t)\n\tprober := constantResultProber(res)\n\tagg := NewMTRAggregator()\n\n\tvar finalStats []MTRHopStat\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds: 1,\n\t\tInterval:  time.Millisecond,\n\t}, agg, func(_ int, stats []MTRHopStat) {\n\t\tfinalStats = stats\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif len(finalStats) != 3 {\n\t\tt.Fatalf(\"expected 3 rows, got %d\", len(finalStats))\n\t}\n\tif finalStats[1].Loss != 100 {\n\t\tt.Errorf(\"expected 100%% loss for timeout hop, got %f\", finalStats[1].Loss)\n\t}\n}\n\nfunc TestMTRLoopSnapshotIterations(t *testing.T) {\n\tvar round int32\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tn := atomic.AddInt32(&round, 1)\n\t\t\treturn mkResult(\n\t\t\t\t[]Hop{mkHop(1, \"1.1.1.1\", time.Duration(n)*time.Millisecond)},\n\t\t\t), nil\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\tvar iterations []int\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds: 3,\n\t\tInterval:  time.Millisecond,\n\t}, agg, func(iter int, _ []MTRHopStat) {\n\t\titerations = append(iterations, iter)\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\texpected := []int{1, 2, 3}\n\tif len(iterations) != len(expected) {\n\t\tt.Fatalf(\"expected %d iterations, got %d\", len(expected), len(iterations))\n\t}\n\tfor i, v := range expected {\n\t\tif iterations[i] != v {\n\t\t\tt.Errorf(\"iteration %d: expected %d, got %d\", i, v, iterations[i])\n\t\t}\n\t}\n}\n\nfunc TestMTRLoopCloseCalledOnError(t *testing.T) {\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\treturn nil, errors.New(\"always fail\")\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\t_ = mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tInterval: time.Millisecond,\n\t}, agg, nil, false, fastBackoff)\n\n\tif atomic.LoadInt32(&prober.closed) != 1 {\n\t\tt.Error(\"prober.close() was not called on error exit\")\n\t}\n}\n\nfunc TestMTRLoopCloseCalledOnSuccess(t *testing.T) {\n\tprober := constantResultProber(mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t))\n\tagg := NewMTRAggregator()\n\n\t_ = mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds: 1,\n\t\tInterval:  time.Millisecond,\n\t}, agg, nil, false, fastBackoff)\n\n\tif atomic.LoadInt32(&prober.closed) != 1 {\n\t\tt.Error(\"prober.close() was not called on normal exit\")\n\t}\n}\n\nfunc TestMTRLoopNilOnSnapshot(t *testing.T) {\n\tprober := constantResultProber(mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t))\n\tagg := NewMTRAggregator()\n\n\t// 确保 onSnapshot=nil 不 panic\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds: 2,\n\t\tInterval:  time.Millisecond,\n\t}, agg, nil, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Bug-fix 验证测试\n// ---------------------------------------------------------------------------\n\n// TestMTRLoopCancelDuringIntervalCallsSnapshot 验证在 interval 等待期间\n// ctx 取消时仍然会调用 onSnapshot（Bug fix #2）。\nfunc TestMTRLoopCancelDuringIntervalCallsSnapshot(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tvar round int32\n\tvar lastSnapshotIter int\n\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tn := atomic.AddInt32(&round, 1)\n\t\t\tif n >= 2 {\n\t\t\t\t// 第二轮成功后，在间隔等待期间取消\n\t\t\t\tgo func() {\n\t\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t\t\tcancel()\n\t\t\t\t}()\n\t\t\t}\n\t\t\treturn mkResult(\n\t\t\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t\t), nil\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\terr := mtrLoop(ctx, prober, Config{}, MTROptions{\n\t\tInterval: 5 * time.Second, // 足够长，确保在间隔中取消\n\t}, agg, func(iter int, _ []MTRHopStat) {\n\t\tlastSnapshotIter = iter\n\t}, false, fastBackoff)\n\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Errorf(\"expected context.Canceled, got %v\", err)\n\t}\n\t// 关键：取消路径也应调用 onSnapshot\n\tif lastSnapshotIter < 2 {\n\t\tt.Errorf(\"expected at least 2 snapshot calls (last iter=%d)\", lastSnapshotIter)\n\t}\n}\n\n// TestMTRLoopCancelDuringBackoffCallsSnapshot 验证在错误退避等待期间\n// ctx 取消时也会调用 onSnapshot（Bug fix #2 扩展）。\nfunc TestMTRLoopCancelDuringBackoffCallsSnapshot(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\t// 第一轮成功，第二轮失败，然后在退避期间取消\n\tvar count int32\n\tvar snapshotCalled int32\n\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tn := atomic.AddInt32(&count, 1)\n\t\t\tif n == 1 {\n\t\t\t\treturn mkResult(\n\t\t\t\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t\t\t), nil\n\t\t\t}\n\t\t\t// 第二轮失败，在退避等待期间取消\n\t\t\tgo func() {\n\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t\tcancel()\n\t\t\t}()\n\t\t\treturn nil, errors.New(\"fail\")\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\tbo := &mtrBackoffCfg{\n\t\tInitial:   5 * time.Second, // 长退避，确保在退避中取消\n\t\tMax:       10 * time.Second,\n\t\tMaxConsec: 5,\n\t}\n\n\terr := mtrLoop(ctx, prober, Config{}, MTROptions{\n\t\tInterval: time.Millisecond,\n\t}, agg, func(iter int, _ []MTRHopStat) {\n\t\tatomic.AddInt32(&snapshotCalled, 1)\n\t}, false, bo)\n\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Errorf(\"expected context.Canceled, got %v\", err)\n\t}\n\t// 退避取消路径也应调用 onSnapshot\n\tif atomic.LoadInt32(&snapshotCalled) < 1 {\n\t\tt.Error(\"expected onSnapshot to be called during backoff cancel\")\n\t}\n}\n\n// TestMTRLoopPause 验证 IsPaused 暂停探测行为。\nfunc TestMTRLoopPause(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tvar pauseFlag int32 // 1 = paused\n\tvar round int32\n\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tn := atomic.AddInt32(&round, 1)\n\t\t\tif n == 2 {\n\t\t\t\t// 第二轮后暂停\n\t\t\t\tatomic.StoreInt32(&pauseFlag, 1)\n\t\t\t\t// 0.5s 后恢复\n\t\t\t\tgo func() {\n\t\t\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\t\t\tatomic.StoreInt32(&pauseFlag, 0)\n\t\t\t\t}()\n\t\t\t}\n\t\t\treturn mkResult(\n\t\t\t\t[]Hop{mkHop(1, \"1.1.1.1\", time.Duration(n)*time.Millisecond)},\n\t\t\t), nil\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\tvar snapshots int\n\terr := mtrLoop(ctx, prober, Config{}, MTROptions{\n\t\tMaxRounds: 4,\n\t\tInterval:  time.Millisecond,\n\t\tIsPaused:  func() bool { return atomic.LoadInt32(&pauseFlag) == 1 },\n\t}, agg, func(iter int, _ []MTRHopStat) {\n\t\tsnapshots++\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif snapshots != 4 {\n\t\tt.Errorf(\"expected 4 snapshots, got %d\", snapshots)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// onICMP 直接单测：seq 回卷 + 迟到回包 + RTT 合理性检查\n// ---------------------------------------------------------------------------\n\n// newTestICMPEngine 构造一个最小可测试的 mtrICMPEngine（不创建真实 socket）。\nfunc newTestICMPEngine(timeout time.Duration) *mtrICMPEngine {\n\tif timeout <= 0 {\n\t\ttimeout = 2 * time.Second\n\t}\n\treturn &mtrICMPEngine{\n\t\tconfig:   Config{Timeout: timeout},\n\t\tnotifyCh: make(chan struct{}, 1),\n\t\tsentAt:   make(map[int]mtrProbeMeta),\n\t\treplied:  make(map[int]*mtrProbeReply),\n\t}\n}\n\n// TestOnICMP_NormalReply 正常回包应被接受。\nfunc TestOnICMP_NormalReply(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreUint32(&e.roundID, 1)\n\n\tnow := time.Now()\n\tseq := 42\n\te.sentAt[seq] = mtrProbeMeta{ttl: 3, start: now, roundID: 1}\n\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"8.8.8.8\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(15*time.Millisecond), seq)\n\n\tif _, ok := e.replied[seq]; !ok {\n\t\tt.Fatal(\"normal reply should be accepted\")\n\t}\n\tif e.replied[seq].rtt != 15*time.Millisecond {\n\t\tt.Errorf(\"expected RTT 15ms, got %v\", e.replied[seq].rtt)\n\t}\n}\n\n// TestOnICMP_StaleRoundReply 旧轮次回包（roundID 不匹配）应被丢弃。\nfunc TestOnICMP_StaleRoundReply(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreUint32(&e.roundID, 5)\n\n\tnow := time.Now()\n\tseq := 100\n\t// 旧轮次 roundID=3，当前轮次=5\n\te.sentAt[seq] = mtrProbeMeta{ttl: 2, start: now, roundID: 3}\n\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"1.2.3.4\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(10*time.Millisecond), seq)\n\n\tif _, ok := e.replied[seq]; ok {\n\t\tt.Fatal(\"stale round reply should be discarded\")\n\t}\n\t// sentAt 条目也应被删除\n\tif _, ok := e.sentAt[seq]; ok {\n\t\tt.Fatal(\"stale sentAt entry should be cleaned up\")\n\t}\n}\n\n// TestOnICMP_SeqWrapStaleReply 模拟 seq 16 位回卷后迟到回包场景。\n//\n// 场景：\n//  1. 轮次 N 发送 seq=100，记录在 sentAt\n//  2. 经过 65536 次递增，seq 回卷到 100\n//  3. 轮次 N+K 重新使用 seq=100，sentAt[100] 已更新为新轮次数据\n//  4. 轮次 N 的迟到回包到达，finish 时间远晚于新轮次的发送时间\n//\n// 预期：RTT > timeout，被 RTT 合理性检查丢弃。\nfunc TestOnICMP_SeqWrapStaleReply(t *testing.T) {\n\ttimeout := 2 * time.Second\n\te := newTestICMPEngine(timeout)\n\tatomic.StoreUint32(&e.roundID, 2000)\n\n\t// 模拟新轮次刚刚发送 seq=100（1ms 前）\n\tnewSendTime := time.Now()\n\tseq := 100\n\te.sentAt[seq] = mtrProbeMeta{\n\t\tttl:     5,\n\t\tstart:   newSendTime,\n\t\troundID: 2000,\n\t}\n\n\t// 迟到回包：来自 ~36 分钟前的旧轮次，到达时间是 \"现在\"\n\t// RTT = now - newSendTime 中间插入一个巨大偏移来模拟跨轮错配\n\tstaleFinish := newSendTime.Add(5 * time.Second) // RTT 5s >> timeout 2s\n\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, staleFinish, seq)\n\n\tif _, ok := e.replied[seq]; ok {\n\t\tt.Fatal(\"stale reply with RTT > timeout should be discarded (seq wraparound)\")\n\t}\n\tif _, ok := e.sentAt[seq]; ok {\n\t\tt.Fatal(\"sentAt entry should be cleaned up after stale discard\")\n\t}\n}\n\n// TestOnICMP_NegativeRTT 时间倒退（finish < start）的回包应被丢弃。\nfunc TestOnICMP_NegativeRTT(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreUint32(&e.roundID, 1)\n\n\tnow := time.Now()\n\tseq := 200\n\te.sentAt[seq] = mtrProbeMeta{ttl: 1, start: now, roundID: 1}\n\n\t// finish 早于 start → RTT < 0\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"10.0.0.2\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(-100*time.Millisecond), seq)\n\n\tif _, ok := e.replied[seq]; ok {\n\t\tt.Fatal(\"negative RTT reply should be discarded\")\n\t}\n}\n\n// TestOnICMP_ExactTimeoutBoundary RTT 恰好等于 timeout 的回包仍应被接受。\n// 比较使用 > 而非 >=，刚好到达 timeout 的回包不算超时。\nfunc TestOnICMP_ExactTimeoutBoundary(t *testing.T) {\n\ttimeout := 2 * time.Second\n\te := newTestICMPEngine(timeout)\n\tatomic.StoreUint32(&e.roundID, 1)\n\n\tnow := time.Now()\n\tseq := 300\n\te.sentAt[seq] = mtrProbeMeta{ttl: 1, start: now, roundID: 1}\n\n\t// RTT == timeout（不是 >，而是恰好等于）\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"10.0.0.3\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(timeout), seq)\n\n\t// RTT == timeout，不满足 rtt > maxRTT，应被接受\n\tif _, ok := e.replied[seq]; !ok {\n\t\tt.Fatal(\"reply with RTT == timeout should still be accepted\")\n\t}\n}\n\n// TestOnICMP_UnknownSeq 未知 seq 的回包应被静默忽略。\nfunc TestOnICMP_UnknownSeq(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreUint32(&e.roundID, 1)\n\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"10.0.0.4\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, time.Now(), 999)\n\n\tif len(e.replied) != 0 {\n\t\tt.Fatal(\"unknown seq should not produce any reply\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// seqWillWrap 单测\n// ---------------------------------------------------------------------------\n\nfunc TestSeqWillWrap_NoWrap(t *testing.T) {\n\t// 当前 seq=0，发 30 个探针，远不到 0xFFFF\n\tif seqWillWrap(0, 30) {\n\t\tt.Fatal(\"seq=0, probeCount=30 should not wrap\")\n\t}\n}\n\nfunc TestSeqWillWrap_JustBelowBoundary(t *testing.T) {\n\t// 当前低 16 位 = 0xFFFF - 30 = 0xFFE1，发 30 个刚好不回卷\n\tcounter := uint32(0xFFFF - 30)\n\tif seqWillWrap(counter, 30) {\n\t\tt.Fatal(\"exactly fitting should not trigger wraparound\")\n\t}\n}\n\nfunc TestSeqWillWrap_OneOver(t *testing.T) {\n\t// 当前低 16 位 = 0xFFFF - 29 = 0xFFE2，发 30 个会越界\n\tcounter := uint32(0xFFFF - 29)\n\tif !seqWillWrap(counter, 30) {\n\t\tt.Fatal(\"should detect imminent wraparound\")\n\t}\n}\n\nfunc TestSeqWillWrap_AtMax(t *testing.T) {\n\t// 当前低 16 位 = 0xFFFF，发 1 个就越界\n\tif !seqWillWrap(0xFFFF, 1) {\n\t\tt.Fatal(\"seq=0xFFFF + 1 probe must trigger wraparound\")\n\t}\n}\n\nfunc TestSeqWillWrap_HighBitsIgnored(t *testing.T) {\n\t// seqCounter 高 16 位非零，低 16 位安全\n\tcounter := uint32(0x0003_0001) // 低 16 位 = 1\n\tif seqWillWrap(counter, 30) {\n\t\tt.Fatal(\"high bits should be masked; low 16 bits = 1 with 30 probes is safe\")\n\t}\n}\n\nfunc TestSeqWillWrap_HighBitsWrap(t *testing.T) {\n\t// seqCounter 高位非零，低 16 位接近边界\n\tcounter := uint32(0x0005_FFF0) // 低 16 位 = 0xFFF0\n\tif !seqWillWrap(counter, 20) {\n\t\tt.Fatal(\"0xFFF0 + 20 > 0xFFFF, should detect wraparound\")\n\t}\n}\n\nfunc TestSeqWillWrap_ZeroProbes(t *testing.T) {\n\tif seqWillWrap(0xFFFF, 0) {\n\t\tt.Fatal(\"probeCount=0 should never trigger wraparound\")\n\t}\n}\n\nfunc TestSeqWillWrap_NegativeProbes(t *testing.T) {\n\t// beginHop > maxHops → probeCount 为负，不应回卷\n\tif seqWillWrap(0xFFFF, -5) {\n\t\tt.Fatal(\"negative probeCount should never trigger wraparound\")\n\t}\n}\n\n// TestProbeRound_BeginHopExceedsMaxHops 验证 beginHop > maxHops 时：\n//   - seqWillWrap 不误判（不触发 rotateEngine）\n//   - probeRound 返回 maxHops 长度的 Hops，但全部为 nil（两个循环均被跳过）\n//   - seqCounter 不递增（未发送任何探针）\nfunc TestProbeRound_BeginHopExceedsMaxHops(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\te.config.BeginHop = 10\n\te.config.MaxHops = 5\n\t// seqCounter 接近边界 — 若 probeCount 保护缺失会触发 rotateEngine（此处无 spec 会 panic）\n\tatomic.StoreUint32(&e.seqCounter, 0xFFF0)\n\n\tseqBefore := atomic.LoadUint32(&e.seqCounter)\n\n\tres, err := e.probeRound(context.Background())\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// Hops 长度应为 maxHops\n\tif len(res.Hops) != 5 {\n\t\tt.Fatalf(\"expected 5 hop slots, got %d\", len(res.Hops))\n\t}\n\n\t// 两个 for ttl:=10; ttl<=5 循环都被跳过，Hops 全部为 nil\n\tfor i, hops := range res.Hops {\n\t\tif hops != nil {\n\t\t\tt.Errorf(\"Hops[%d] should be nil (loop skipped), got %v\", i, hops)\n\t\t}\n\t}\n\n\t// 未发送任何探针，seqCounter 应不变\n\tif atomic.LoadUint32(&e.seqCounter) != seqBefore {\n\t\tt.Errorf(\"seqCounter should not change, was %d now %d\", seqBefore, atomic.LoadUint32(&e.seqCounter))\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 重置统计（r 键）测试\n// ---------------------------------------------------------------------------\n\n// TestMTRLoop_RestartStatistics 验证 IsResetRequested 触发统计重置。\nfunc TestMTRLoop_RestartStatistics(t *testing.T) {\n\tvar round int32\n\tvar resetOnce int32\n\n\tprober := &mockProber{\n\t\troundFn: func(_ context.Context) (*Result, error) {\n\t\t\tn := atomic.AddInt32(&round, 1)\n\t\t\treturn mkResult(\n\t\t\t\t[]Hop{mkHop(1, \"1.1.1.1\", time.Duration(n)*time.Millisecond)},\n\t\t\t), nil\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\tvar iterations []int\n\tvar sntValues []int\n\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds: 4,\n\t\tInterval:  time.Millisecond,\n\t\tIsResetRequested: func() bool {\n\t\t\t// 第 2 轮后触发一次重置\n\t\t\tr := atomic.LoadInt32(&round)\n\t\t\tif r == 2 && atomic.CompareAndSwapInt32(&resetOnce, 0, 1) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t}, agg, func(iter int, stats []MTRHopStat) {\n\t\titerations = append(iterations, iter)\n\t\tif len(stats) > 0 {\n\t\t\tsntValues = append(sntValues, stats[0].Snt)\n\t\t}\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// 重置后 iteration 从 0 重开，所以需要达到 4 轮才结束\n\t// 轮次序列：round 1 → iter 1, round 2 → iter 2, [reset → iter=0],\n\t//           round 3 → iter 1, round 4 → iter 2, round 5 → iter 3, round 6 → iter 4\n\t// 最终必须 iteration 中出现 4\n\tfound := false\n\tfor _, v := range iterations {\n\t\tif v == 4 {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Errorf(\"expected iteration to reach 4 after reset, got %v\", iterations)\n\t}\n\n\t// 验证重置后 Snt 从 1 重新开始\n\tsntOneCount := 0\n\tfor _, s := range sntValues {\n\t\tif s == 1 {\n\t\t\tsntOneCount++\n\t\t}\n\t}\n\t// Snt=1 应至少出现 2 次（初始第一轮 + 重置后第一轮）\n\tif sntOneCount < 2 {\n\t\tt.Errorf(\"expected Snt=1 at least twice (initial + after reset), got %d occurrences in %v\", sntOneCount, sntValues)\n\t}\n}\n\n// TestResetClearsKnownFinalTTL 验证 resetFinalTTL 清除已知目的地 TTL 缓存。\nfunc TestResetClearsKnownFinalTTL(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreInt32(&e.knownFinalTTL, 5)\n\n\te.resetFinalTTL()\n\n\tif got := atomic.LoadInt32(&e.knownFinalTTL); got != -1 {\n\t\tt.Errorf(\"expected knownFinalTTL=-1 after reset, got %d\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 目的地停止测试\n// ---------------------------------------------------------------------------\n\n// TestOnICMP_DetectsDestination 验证 onICMP 在 peer==DstIP 时设置 roundFinalTTL。\nfunc TestOnICMP_DetectsDestination(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\te.config.DstIP = net.ParseIP(\"8.8.8.8\")\n\tatomic.StoreUint32(&e.roundID, 1)\n\tatomic.StoreInt32(&e.roundFinalTTL, -1)\n\tatomic.StoreInt32(&e.knownFinalTTL, -1)\n\n\tnow := time.Now()\n\tseq := 42\n\te.sentAt[seq] = mtrProbeMeta{ttl: 5, start: now, roundID: 1}\n\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"8.8.8.8\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(15*time.Millisecond), seq)\n\n\tif got := atomic.LoadInt32(&e.roundFinalTTL); got != 5 {\n\t\tt.Errorf(\"expected roundFinalTTL=5, got %d\", got)\n\t}\n}\n\n// TestOnICMP_NonDestinationDoesNotSetFinal 验证非目的地 hop 不设置 roundFinalTTL。\nfunc TestOnICMP_NonDestinationDoesNotSetFinal(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\te.config.DstIP = net.ParseIP(\"8.8.8.8\")\n\tatomic.StoreUint32(&e.roundID, 1)\n\tatomic.StoreInt32(&e.roundFinalTTL, -1)\n\n\tnow := time.Now()\n\tseq := 42\n\te.sentAt[seq] = mtrProbeMeta{ttl: 3, start: now, roundID: 1}\n\n\t// 中间 hop，不是目的地\n\tpeer := &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")}\n\te.onICMP(internal.ReceivedMessage{Peer: peer}, now.Add(10*time.Millisecond), seq)\n\n\tif got := atomic.LoadInt32(&e.roundFinalTTL); got != -1 {\n\t\tt.Errorf(\"expected roundFinalTTL=-1 for non-destination, got %d\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// peekPartialResult 单测\n// ---------------------------------------------------------------------------\n\nfunc TestPeekPartialResult_EmptyBeforeRound(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\t// 未初始化 curTtlSeq → 返回 nil\n\tif got := e.peekPartialResult(); got != nil {\n\t\tt.Fatalf(\"expected nil before round, got %+v\", got)\n\t}\n}\n\nfunc TestPeekPartialResult_PartialReplies(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreUint32(&e.roundID, 1)\n\tatomic.StoreInt32(&e.roundFinalTTL, -1)\n\n\t// 模拟 probeRound 已初始化 peek 状态\n\te.curBeginHop = 1\n\te.curEffectiveMax = 3\n\te.curTtlSeq = map[int]int{1: 10, 2: 11, 3: 12}\n\n\t// TTL 1 已收到响应，TTL 2/3 尚未\n\te.replied[10] = &mtrProbeReply{\n\t\tpeer: &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\trtt:  5 * time.Millisecond,\n\t}\n\n\tres := e.peekPartialResult()\n\tif res == nil {\n\t\tt.Fatal(\"expected non-nil partial result\")\n\t}\n\tif len(res.Hops) != 3 {\n\t\tt.Fatalf(\"expected 3 hop slots, got %d\", len(res.Hops))\n\t}\n\t// TTL 1: 成功\n\tif len(res.Hops[0]) != 1 || !res.Hops[0][0].Success {\n\t\tt.Error(\"TTL 1 should be successful\")\n\t}\n\t// TTL 2: 超时（尚未响应）\n\tif len(res.Hops[1]) != 1 || res.Hops[1][0].Success {\n\t\tt.Error(\"TTL 2 should be timeout (not replied)\")\n\t}\n\t// TTL 3: 超时\n\tif len(res.Hops[2]) != 1 || res.Hops[2][0].Success {\n\t\tt.Error(\"TTL 3 should be timeout\")\n\t}\n}\n\nfunc TestPeekPartialResult_UnsentTTLsAreNil(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreUint32(&e.roundID, 1)\n\tatomic.StoreInt32(&e.roundFinalTTL, -1)\n\n\t// 模拟发送进行到一半：TTL 1-2 已发送，TTL 3-5 尚未\n\te.curBeginHop = 1\n\te.curEffectiveMax = 5\n\te.curTtlSeq = map[int]int{1: 10, 2: 11} // 3-5 不存在\n\n\t// TTL 1 已回复\n\te.replied[10] = &mtrProbeReply{\n\t\tpeer: &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\trtt:  5 * time.Millisecond,\n\t}\n\n\tres := e.peekPartialResult()\n\tif res == nil {\n\t\tt.Fatal(\"expected non-nil partial result\")\n\t}\n\tif len(res.Hops) != 5 {\n\t\tt.Fatalf(\"expected 5 hop slots, got %d\", len(res.Hops))\n\t}\n\n\t// TTL 1: 已发送+已回复 → 成功\n\tif res.Hops[0] == nil || !res.Hops[0][0].Success {\n\t\tt.Error(\"TTL 1 should be successful\")\n\t}\n\t// TTL 2: 已发送+未回复 → 超时（非 nil）\n\tif res.Hops[1] == nil || res.Hops[1][0].Success {\n\t\tt.Error(\"TTL 2 should be timeout (sent but not replied)\")\n\t}\n\t// TTL 3-5: 未发送 → nil（聚合器不计入 Snt/Loss）\n\tfor i := 2; i < 5; i++ {\n\t\tif res.Hops[i] != nil {\n\t\t\tt.Errorf(\"TTL %d should be nil (unsent), got %+v\", i+1, res.Hops[i])\n\t\t}\n\t}\n}\n\nfunc TestPeekPartialResult_TrimsByRoundFinalTTL(t *testing.T) {\n\te := newTestICMPEngine(2 * time.Second)\n\tatomic.StoreUint32(&e.roundID, 1)\n\tatomic.StoreInt32(&e.roundFinalTTL, 2) // 本轮已检测到目的地在 TTL 2\n\n\te.curBeginHop = 1\n\te.curEffectiveMax = 5\n\te.curTtlSeq = map[int]int{1: 10, 2: 11, 3: 12, 4: 13, 5: 14}\n\n\tres := e.peekPartialResult()\n\tif res == nil {\n\t\tt.Fatal(\"expected non-nil partial result\")\n\t}\n\t// 应被裁剪到 TTL 2\n\tif len(res.Hops) != 2 {\n\t\tt.Errorf(\"expected 2 hop slots (trimmed by roundFinalTTL), got %d\", len(res.Hops))\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// mtrLoop 流式预览测试\n// ---------------------------------------------------------------------------\n\n// mockPeekerProber 同时实现 mtrProber + mtrPeeker。\ntype mockPeekerProber struct {\n\tmockProber\n\tpeekFn func() *Result\n}\n\nfunc (m *mockPeekerProber) peekPartialResult() *Result {\n\tif m.peekFn != nil {\n\t\treturn m.peekFn()\n\t}\n\treturn nil\n}\n\nfunc TestMTRLoop_StreamingProgress(t *testing.T) {\n\t// probeRound 耗时 300ms，ProgressThrottle 50ms\n\t// 在一轮中应产生多次预览 + 1 次最终快照\n\tpartialRes := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 5*time.Millisecond)},\n\t)\n\n\tprober := &mockPeekerProber{\n\t\tmockProber: mockProber{\n\t\t\troundFn: func(ctx context.Context) (*Result, error) {\n\t\t\t\tselect {\n\t\t\t\tcase <-time.After(300 * time.Millisecond):\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn nil, ctx.Err()\n\t\t\t\t}\n\t\t\t\treturn partialRes, nil\n\t\t\t},\n\t\t},\n\t\tpeekFn: func() *Result {\n\t\t\treturn partialRes\n\t\t},\n\t}\n\tagg := NewMTRAggregator()\n\n\tvar snapshotCount int32\n\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds:        1,\n\t\tInterval:         time.Millisecond,\n\t\tProgressThrottle: 50 * time.Millisecond,\n\t}, agg, func(_ int, _ []MTRHopStat) {\n\t\tatomic.AddInt32(&snapshotCount, 1)\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// 300ms / 50ms ≈ 6 次预览 + 1 次最终 ≈ 7，至少应有 2 次\n\tcount := atomic.LoadInt32(&snapshotCount)\n\tif count < 2 {\n\t\tt.Errorf(\"expected at least 2 snapshots (preview+final), got %d\", count)\n\t}\n}\n\nfunc TestMTRLoop_NonPeekerNoStreaming(t *testing.T) {\n\t// 普通 mockProber 不实现 mtrPeeker，应正常工作（无预览）\n\tprober := constantResultProber(mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 5*time.Millisecond)},\n\t))\n\tagg := NewMTRAggregator()\n\n\tvar snapshotCount int32\n\terr := mtrLoop(context.Background(), prober, Config{}, MTROptions{\n\t\tMaxRounds:        3,\n\t\tInterval:         time.Millisecond,\n\t\tProgressThrottle: time.Millisecond,\n\t}, agg, func(_ int, _ []MTRHopStat) {\n\t\tatomic.AddInt32(&snapshotCount, 1)\n\t}, false, fastBackoff)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// 非 peeker 模式：每轮仅 1 次快照，共 3 次\n\tif got := atomic.LoadInt32(&snapshotCount); got != 3 {\n\t\tt.Errorf(\"expected exactly 3 snapshots, got %d\", got)\n\t}\n}\n"
  },
  {
    "path": "trace/mtr_scheduler.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\n// ---------------------------------------------------------------------------\n// Per-hop independent scheduler (CLI MTR mode)\n// ---------------------------------------------------------------------------\n\n// mtrProbeResult holds the outcome of a single TTL probe.\ntype mtrProbeResult struct {\n\tTTL      int\n\tSuccess  bool\n\tAddr     net.Addr\n\tRTT      time.Duration\n\tMPLS     []string\n\tHostname string           // pre-resolved PTR (fallback prober)\n\tGeo      *ipgeo.IPGeoData // pre-resolved geo  (fallback prober)\n}\n\n// mtrTTLProber abstracts single-TTL probing for the per-hop scheduler.\ntype mtrTTLProber interface {\n\t// ProbeTTL sends a probe at the given TTL and blocks until response or timeout.\n\tProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error)\n\t// Reset invalidates in-flight probes and clears internal caches (e.g. knownFinalTTL).\n\tReset() error\n\t// Close releases underlying resources (sockets etc.).\n\tClose() error\n}\n\n// mtrSchedulerConfig configures the per-hop scheduler.\ntype mtrSchedulerConfig struct {\n\tBeginHop          int\n\tMaxHops           int\n\tHopInterval       time.Duration // delay between successive probes to the same TTL\n\tTimeout           time.Duration // per-probe timeout; used to compute default MaxInFlightPerHop\n\tMaxPerHop         int           // 0 = unlimited (run until ctx cancelled)\n\tMaxConsecErrors   int           // per-TTL consecutive error limit; 0 → default 10\n\tMaxInFlightPerHop int           // max concurrent probes per TTL; 0 → ceil(Timeout/HopInterval)+1\n\tParallelRequests  int\n\tProgressThrottle  time.Duration\n\tFillGeo           bool\n\tBaseConfig        Config // used for geo/RDNS lookup\n\tDstIP             net.IP\n\n\tIsPaused         func() bool\n\tIsResetRequested func() bool\n}\n\n// mtrHopState tracks per-TTL scheduling state.\ntype mtrHopState struct {\n\tcompleted       int\n\tinFlightCount   int\n\tnextAt          time.Time\n\tdisabled        bool\n\tconsecutiveErrs int\n}\n\n// mtrCompletedProbe wraps a finished probe for the result channel.\ntype mtrCompletedProbe struct {\n\tttl    int\n\tresult mtrProbeResult\n\tgen    uint64\n\tdoneAt time.Time\n\terr    error\n}\n\n// runMTRScheduler runs the per-hop independent scheduling loop.\n//\n// Each TTL is probed independently: after a probe completes, the next probe for\n// that TTL is scheduled after HopInterval. Concurrency across TTLs is limited by\n// ParallelRequests. Iteration is defined as min(Snt) over active TTLs.\n//\n// onSnapshot is called periodically with aggregated stats (for TUI / report).\n// onProbe is called per completed probe (for raw streaming mode).\nfunc runMTRScheduler(\n\tctx context.Context,\n\tprober mtrTTLProber,\n\tagg *MTRAggregator,\n\tcfg mtrSchedulerConfig,\n\tonSnapshot MTROnSnapshot,\n\tonProbe func(result mtrProbeResult, iteration int),\n) error {\n\tdefer prober.Close()\n\trt, err := newMTRSchedulerRuntime(ctx, prober, agg, cfg, onSnapshot, onProbe)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn rt.run()\n}\n\n// mtrAddrToIP extracts net.IP from net.Addr.\nfunc mtrAddrToIP(addr net.Addr) net.IP {\n\tif addr == nil {\n\t\treturn nil\n\t}\n\tswitch a := addr.(type) {\n\tcase *net.IPAddr:\n\t\treturn a.IP\n\tcase *net.UDPAddr:\n\t\treturn a.IP\n\tcase *net.TCPAddr:\n\t\treturn a.IP\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "trace/mtr_scheduler_runtime.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"sync/atomic\"\n\t\"time\"\n)\n\ntype mtrSchedulerRuntime struct {\n\tctx            context.Context\n\tprober         mtrTTLProber\n\tagg            *MTRAggregator\n\tcfg            mtrSchedulerConfig\n\tonSnapshot     MTROnSnapshot\n\tonProbe        func(result mtrProbeResult, iteration int)\n\tbeginHop       int\n\tmaxHops        int\n\tparallelism    int\n\thopInterval    time.Duration\n\tprogressDelay  time.Duration\n\tmaxConsecErrs  int\n\tmaxInFlightHop int\n\tstates         []mtrHopState\n\tgeneration     uint64\n\tknownFinalTTL  int32\n\tinFlight       int\n\tresultCh       chan mtrCompletedProbe\n\tlastSnapshot   time.Time\n}\n\nfunc newMTRSchedulerRuntime(\n\tctx context.Context,\n\tprober mtrTTLProber,\n\tagg *MTRAggregator,\n\tcfg mtrSchedulerConfig,\n\tonSnapshot MTROnSnapshot,\n\tonProbe func(result mtrProbeResult, iteration int),\n) (*mtrSchedulerRuntime, error) {\n\tbeginHop := cfg.BeginHop\n\tif beginHop <= 0 {\n\t\tbeginHop = 1\n\t}\n\n\tmaxHops := cfg.MaxHops\n\tif maxHops <= 0 {\n\t\tmaxHops = 30\n\t}\n\tif maxHops > 255 {\n\t\tmaxHops = 255\n\t}\n\tif beginHop > maxHops {\n\t\treturn nil, fmt.Errorf(\"mtr: beginHop (%d) > maxHops (%d)\", beginHop, maxHops)\n\t}\n\n\tparallelism := cfg.ParallelRequests\n\tif parallelism < 1 {\n\t\tparallelism = 1\n\t}\n\n\thopInterval := cfg.HopInterval\n\tif hopInterval <= 0 {\n\t\thopInterval = time.Second\n\t}\n\n\tprogressDelay := cfg.ProgressThrottle\n\tif progressDelay <= 0 {\n\t\tprogressDelay = 200 * time.Millisecond\n\t}\n\n\tmaxConsecErrs := cfg.MaxConsecErrors\n\tif maxConsecErrs <= 0 {\n\t\tmaxConsecErrs = 10\n\t}\n\n\tmaxInFlightHop := cfg.MaxInFlightPerHop\n\tif maxInFlightHop <= 0 {\n\t\ttimeout := cfg.Timeout\n\t\tif timeout <= 0 {\n\t\t\ttimeout = 2 * time.Second\n\t\t}\n\t\tmaxInFlightHop = int((timeout+hopInterval-1)/hopInterval) + 1\n\t\tif maxInFlightHop < 1 {\n\t\t\tmaxInFlightHop = 1\n\t\t}\n\t}\n\n\treturn &mtrSchedulerRuntime{\n\t\tctx:            ctx,\n\t\tprober:         prober,\n\t\tagg:            agg,\n\t\tcfg:            cfg,\n\t\tonSnapshot:     onSnapshot,\n\t\tonProbe:        onProbe,\n\t\tbeginHop:       beginHop,\n\t\tmaxHops:        maxHops,\n\t\tparallelism:    parallelism,\n\t\thopInterval:    hopInterval,\n\t\tprogressDelay:  progressDelay,\n\t\tmaxConsecErrs:  maxConsecErrs,\n\t\tmaxInFlightHop: maxInFlightHop,\n\t\tstates:         make([]mtrHopState, maxHops+1),\n\t\tknownFinalTTL:  -1,\n\t\tresultCh:       make(chan mtrCompletedProbe, parallelism*2),\n\t}, nil\n}\n\nfunc (rt *mtrSchedulerRuntime) run() error {\n\trt.scheduleReady()\n\n\ttick := time.NewTicker(5 * time.Millisecond)\n\tdefer tick.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-rt.ctx.Done():\n\t\t\treturn rt.handleCancel()\n\t\tcase cp := <-rt.resultCh:\n\t\t\trt.processResult(cp)\n\t\t\tif rt.isDone() {\n\t\t\t\trt.maybeSnapshot(true)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\trt.scheduleReady()\n\t\tcase <-tick.C:\n\t\t\trt.handleReset()\n\t\t\trt.scheduleReady()\n\t\t\tif rt.isDone() {\n\t\t\t\trt.maybeSnapshot(true)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (rt *mtrSchedulerRuntime) effectiveMax() int {\n\tkf := atomic.LoadInt32(&rt.knownFinalTTL)\n\tif kf > 0 && int(kf) < rt.maxHops {\n\t\treturn int(kf)\n\t}\n\treturn rt.maxHops\n}\n\nfunc (rt *mtrSchedulerRuntime) computeIteration() int {\n\teMax := rt.effectiveMax()\n\tminSnt := -1\n\tfor ttl := rt.beginHop; ttl <= eMax; ttl++ {\n\t\tif rt.states[ttl].disabled {\n\t\t\tcontinue\n\t\t}\n\t\tsnt := rt.states[ttl].completed\n\t\tif minSnt < 0 || snt < minSnt {\n\t\t\tminSnt = snt\n\t\t}\n\t}\n\tif minSnt < 0 {\n\t\treturn 0\n\t}\n\treturn minSnt\n}\n\nfunc (rt *mtrSchedulerRuntime) maybeSnapshot(force bool) {\n\tif rt.onSnapshot == nil {\n\t\treturn\n\t}\n\tnow := time.Now()\n\tif !force && now.Sub(rt.lastSnapshot) < rt.progressDelay {\n\t\treturn\n\t}\n\trt.lastSnapshot = now\n\trt.onSnapshot(rt.computeIteration(), rt.agg.Snapshot())\n}\n\nfunc (rt *mtrSchedulerRuntime) launchProbe(ttl int) {\n\trt.states[ttl].inFlightCount++\n\trt.states[ttl].nextAt = time.Now().Add(rt.hopInterval)\n\trt.inFlight++\n\n\tgen := rt.generation\n\tgo func() {\n\t\tresult, err := rt.prober.ProbeTTL(rt.ctx, ttl)\n\t\trt.resultCh <- mtrCompletedProbe{\n\t\t\tttl:    ttl,\n\t\t\tresult: result,\n\t\t\tgen:    gen,\n\t\t\tdoneAt: time.Now(),\n\t\t\terr:    err,\n\t\t}\n\t}()\n}\n\nfunc (rt *mtrSchedulerRuntime) processResult(cp mtrCompletedProbe) {\n\trt.inFlight--\n\tif cp.gen != rt.generation {\n\t\treturn\n\t}\n\tif cp.ttl < rt.beginHop || cp.ttl > rt.maxHops {\n\t\treturn\n\t}\n\n\tstate := &rt.states[cp.ttl]\n\tstate.inFlightCount--\n\tif state.disabled {\n\t\treturn\n\t}\n\tif cp.err != nil {\n\t\trt.processProbeError(cp.ttl, cp.err)\n\t\treturn\n\t}\n\trt.processProbeSuccess(cp.ttl, cp.result)\n}\n\nfunc (rt *mtrSchedulerRuntime) processProbeError(ttl int, err error) {\n\tif rt.ctx.Err() != nil {\n\t\treturn\n\t}\n\n\tstate := &rt.states[ttl]\n\tstate.consecutiveErrs++\n\tfmt.Fprintf(os.Stderr, \"mtr: probe error (%d/%d): %v\\n\", state.consecutiveErrs, rt.maxConsecErrs, err)\n\tif state.consecutiveErrs < rt.maxConsecErrs {\n\t\treturn\n\t}\n\n\tstate.consecutiveErrs = 0\n\tstate.completed++\n\trt.recordSyntheticTimeout(ttl)\n}\n\nfunc (rt *mtrSchedulerRuntime) recordSyntheticTimeout(ttl int) {\n\trt.agg.Update(rt.timeoutProbeResult(ttl), 1)\n\tif rt.onProbe != nil {\n\t\trt.onProbe(mtrProbeResult{TTL: ttl}, rt.computeIteration())\n\t}\n\trt.maybeSnapshot(false)\n}\n\nfunc (rt *mtrSchedulerRuntime) resultHopCount() int {\n\tif n := len(rt.states) - 1; n > 0 {\n\t\treturn n\n\t}\n\tif rt.maxHops > 0 {\n\t\treturn rt.maxHops\n\t}\n\treturn 0\n}\n\nfunc (rt *mtrSchedulerRuntime) timeoutProbeResult(ttl int) *Result {\n\tsingleRes := &Result{Hops: make([][]Hop, rt.resultHopCount())}\n\tidx := ttl - 1\n\tif idx >= 0 && idx < len(singleRes.Hops) {\n\t\tsingleRes.Hops[idx] = []Hop{{TTL: ttl, Error: errHopLimitTimeout}}\n\t}\n\treturn singleRes\n}\n\nfunc (rt *mtrSchedulerRuntime) processProbeSuccess(ttl int, result mtrProbeResult) {\n\trt.detectDestination(ttl, result)\n\tif rt.probeBudgetReached(ttl) {\n\t\trt.states[ttl].consecutiveErrs = 0\n\t\treturn\n\t}\n\n\trt.markProbeCompleted(ttl)\n\tsingleRes := rt.singleProbeResult(ttl, result)\n\tif rt.cfg.FillGeo && result.Geo == nil {\n\t\tmtrFillGeoRDNS(singleRes, rt.cfg.BaseConfig)\n\t}\n\n\trt.agg.Update(singleRes, 1)\n\tif rt.onProbe != nil {\n\t\trt.onProbe(result, rt.computeIteration())\n\t}\n\trt.maybeSnapshot(false)\n}\n\nfunc (rt *mtrSchedulerRuntime) detectDestination(ttl int, result mtrProbeResult) {\n\tif !result.Success || result.Addr == nil {\n\t\treturn\n\t}\n\n\tpeerIP := mtrAddrToIP(result.Addr)\n\tif peerIP == nil || !peerIP.Equal(rt.cfg.DstIP) {\n\t\treturn\n\t}\n\n\tcurFinal := atomic.LoadInt32(&rt.knownFinalTTL)\n\tif curFinal < 0 {\n\t\tatomic.StoreInt32(&rt.knownFinalTTL, int32(ttl))\n\t\trt.disableHigherTTLs(ttl + 1)\n\t\treturn\n\t}\n\n\tif int32(ttl) < curFinal {\n\t\toldFinal := int(curFinal)\n\t\tatomic.StoreInt32(&rt.knownFinalTTL, int32(ttl))\n\t\trt.disableHigherTTLs(ttl + 1)\n\t\trt.agg.ClearHop(oldFinal)\n\t}\n}\n\nfunc (rt *mtrSchedulerRuntime) disableHigherTTLs(fromTTL int) {\n\tfor ttl := fromTTL; ttl <= rt.maxHops; ttl++ {\n\t\trt.states[ttl].disabled = true\n\t}\n}\n\nfunc (rt *mtrSchedulerRuntime) probeBudgetReached(ttl int) bool {\n\treturn rt.cfg.MaxPerHop > 0 && rt.states[ttl].completed >= rt.cfg.MaxPerHop\n}\n\nfunc (rt *mtrSchedulerRuntime) markProbeCompleted(ttl int) {\n\trt.states[ttl].consecutiveErrs = 0\n\trt.states[ttl].completed++\n}\n\nfunc (rt *mtrSchedulerRuntime) singleProbeResult(ttl int, result mtrProbeResult) *Result {\n\tsingleRes := &Result{Hops: make([][]Hop, rt.resultHopCount())}\n\thop := Hop{\n\t\tSuccess:  result.Success,\n\t\tAddress:  result.Addr,\n\t\tHostname: result.Hostname,\n\t\tTTL:      ttl,\n\t\tRTT:      result.RTT,\n\t\tMPLS:     result.MPLS,\n\t\tGeo:      result.Geo,\n\t\tLang:     rt.cfg.BaseConfig.Lang,\n\t}\n\tif !hop.Success && hop.Address == nil {\n\t\thop.Error = errHopLimitTimeout\n\t}\n\n\tidx := ttl - 1\n\tif idx >= 0 && idx < len(singleRes.Hops) {\n\t\tsingleRes.Hops[idx] = []Hop{hop}\n\t}\n\treturn singleRes\n}\n\nfunc (rt *mtrSchedulerRuntime) scheduleReady() {\n\tif rt.cfg.IsPaused != nil && rt.cfg.IsPaused() {\n\t\treturn\n\t}\n\n\tnow := time.Now()\n\teMax := rt.effectiveMax()\n\tfor ttl := rt.beginHop; ttl <= eMax; ttl++ {\n\t\tif rt.inFlight >= rt.parallelism {\n\t\t\treturn\n\t\t}\n\t\tif rt.canLaunchProbe(ttl, now) {\n\t\t\trt.launchProbe(ttl)\n\t\t}\n\t}\n}\n\nfunc (rt *mtrSchedulerRuntime) canLaunchProbe(ttl int, now time.Time) bool {\n\tstate := &rt.states[ttl]\n\tif state.disabled || state.inFlightCount >= rt.maxInFlightHop {\n\t\treturn false\n\t}\n\tif rt.cfg.MaxPerHop > 0 && state.completed+state.inFlightCount >= rt.cfg.MaxPerHop {\n\t\treturn false\n\t}\n\tif !state.nextAt.IsZero() && now.Before(state.nextAt) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (rt *mtrSchedulerRuntime) isDone() bool {\n\tif rt.cfg.MaxPerHop <= 0 {\n\t\treturn false\n\t}\n\n\teMax := rt.effectiveMax()\n\tfor ttl := rt.beginHop; ttl <= eMax; ttl++ {\n\t\tstate := &rt.states[ttl]\n\t\tif state.disabled {\n\t\t\tcontinue\n\t\t}\n\t\tif state.completed < rt.cfg.MaxPerHop || state.inFlightCount > 0 {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn rt.inFlight == 0\n}\n\nfunc (rt *mtrSchedulerRuntime) handleReset() {\n\tif rt.cfg.IsResetRequested == nil || !rt.cfg.IsResetRequested() {\n\t\treturn\n\t}\n\n\trt.generation++\n\tfor idx := range rt.states {\n\t\trt.states[idx] = mtrHopState{}\n\t}\n\tatomic.StoreInt32(&rt.knownFinalTTL, -1)\n\trt.agg.Reset()\n\t_ = rt.prober.Reset()\n}\n\nfunc (rt *mtrSchedulerRuntime) handleCancel() error {\n\trt.drainInFlight()\n\trt.maybeSnapshot(true)\n\treturn rt.ctx.Err()\n}\n\nfunc (rt *mtrSchedulerRuntime) drainInFlight() {\n\tdeadline := time.After(5 * time.Second)\n\tfor rt.inFlight > 0 {\n\t\tselect {\n\t\tcase <-rt.resultCh:\n\t\t\trt.inFlight--\n\t\tcase <-deadline:\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "trace/mtr_scheduler_test.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\n// ---------------------------------------------------------------------------\n// Mock TTL prober for scheduler tests\n// ---------------------------------------------------------------------------\n\ntype mockTTLProber struct {\n\tmu       sync.Mutex\n\tprobeFn  func(ctx context.Context, ttl int) (mtrProbeResult, error)\n\tresetCnt int32\n\tcloseCnt int32\n\tprobeCnt int32\n\tprobeLog []int // ttl of each probe call\n}\n\nfunc (m *mockTTLProber) ProbeTTL(ctx context.Context, ttl int) (mtrProbeResult, error) {\n\tatomic.AddInt32(&m.probeCnt, 1)\n\tm.mu.Lock()\n\tm.probeLog = append(m.probeLog, ttl)\n\tm.mu.Unlock()\n\tif m.probeFn != nil {\n\t\treturn m.probeFn(ctx, ttl)\n\t}\n\treturn mtrProbeResult{TTL: ttl}, nil\n}\n\nfunc (m *mockTTLProber) Reset() error {\n\tatomic.AddInt32(&m.resetCnt, 1)\n\treturn nil\n}\n\nfunc (m *mockTTLProber) Close() error {\n\tatomic.AddInt32(&m.closeCnt, 1)\n\treturn nil\n}\n\nfunc (m *mockTTLProber) getProbeCount() int {\n\treturn int(atomic.LoadInt32(&m.probeCnt))\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_ResultBuildersUseBoundedHopCount(t *testing.T) {\n\trt, err := newMTRSchedulerRuntime(\n\t\tcontext.Background(),\n\t\t&mockTTLProber{},\n\t\tNewMTRAggregator(),\n\t\tmtrSchedulerConfig{\n\t\t\tBeginHop:         1,\n\t\t\tMaxHops:          1 << 20,\n\t\t\tHopInterval:      time.Millisecond,\n\t\t\tParallelRequests: 1,\n\t\t},\n\t\tnil,\n\t\tnil,\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"newMTRSchedulerRuntime returned error: %v\", err)\n\t}\n\tif rt.maxHops != 255 {\n\t\tt.Fatalf(\"rt.maxHops = %d, want 255\", rt.maxHops)\n\t}\n\n\tif got := len(rt.timeoutProbeResult(1).Hops); got != 255 {\n\t\tt.Fatalf(\"timeoutProbeResult hop len = %d, want 255\", got)\n\t}\n\tif got := len(rt.singleProbeResult(1, mtrProbeResult{TTL: 1}).Hops); got != 255 {\n\t\tt.Fatalf(\"singleProbeResult hop len = %d, want 255\", got)\n\t}\n}\n\nfunc TestScheduler_MaxPerHopCompletion(t *testing.T) {\n\tdstIP := net.ParseIP(\"10.0.0.5\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\t// Simulate: TTL 3 is the destination\n\t\t\tif ttl == 3 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     ttl,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:     10 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))},\n\t\t\t\tRTT:     time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\tvar lastIter int\n\tvar snapshotCount int32\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          30,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        3,\n\t\tParallelRequests: 5,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, func(iter int, stats []MTRHopStat) {\n\t\tatomic.AddInt32(&snapshotCount, 1)\n\t\tlastIter = iter\n\t}, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// Should complete: each active TTL (1..3) should have 3 probes\n\tif lastIter != 3 {\n\t\tt.Errorf(\"expected final iteration=3, got %d\", lastIter)\n\t}\n\n\tstats := agg.Snapshot()\n\tif len(stats) < 3 {\n\t\tt.Fatalf(\"expected at least 3 stats rows, got %d\", len(stats))\n\t}\n\n\tfor _, s := range stats {\n\t\tif s.TTL >= 1 && s.TTL <= 3 {\n\t\t\tif s.Snt != 3 {\n\t\t\t\tt.Errorf(\"TTL %d: expected Snt=3, got %d\", s.TTL, s.Snt)\n\t\t\t}\n\t\t}\n\t}\n\n\tif atomic.LoadInt32(&prober.closeCnt) != 1 {\n\t\tt.Error(\"prober.Close() not called\")\n\t}\n}\n\nfunc TestScheduler_ContextCancel(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tvar probes int32\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(ctx context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tn := atomic.AddInt32(&probes, 1)\n\t\t\tif n >= 5 {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn mtrProbeResult{TTL: ttl}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\tvar snapshotCalled int32\n\n\terr := runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          5,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        0, // unlimited\n\t\tParallelRequests: 5,\n\t\tProgressThrottle: time.Millisecond,\n\t}, func(_ int, _ []MTRHopStat) {\n\t\tatomic.AddInt32(&snapshotCalled, 1)\n\t}, nil)\n\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Errorf(\"expected context.Canceled, got %v\", err)\n\t}\n\tif atomic.LoadInt32(&prober.closeCnt) != 1 {\n\t\tt.Error(\"prober.Close() not called on cancel\")\n\t}\n}\n\nfunc TestScheduler_DestinationDetection(t *testing.T) {\n\tdstIP := net.ParseIP(\"8.8.8.8\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl >= 5 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     ttl,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:     50 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:     10 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          30,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2,\n\t\tParallelRequests: 1, // serialize to ensure dest detection before higher TTLs\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\t// TTL 5 is the destination; higher TTLs should be disabled after detection.\n\t// With parallelism=1, at most TTL 6 could sneak in before the result is\n\t// processed (tick vs result race), so we allow a small margin.\n\tmaxTTL := 0\n\tfor _, s := range stats {\n\t\tif s.TTL > maxTTL {\n\t\t\tmaxTTL = s.TTL\n\t\t}\n\t}\n\tif maxTTL > 6 {\n\t\tt.Errorf(\"expected max TTL <= 6 (destination detected at 5), got %d\", maxTTL)\n\t}\n}\n\nfunc TestScheduler_Reset(t *testing.T) {\n\tvar probes int32\n\tvar resetOnce int32\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tatomic.AddInt32(&probes, 1)\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:     5 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tvar snapshotIters []int\n\tvar iterMu sync.Mutex\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          2,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        4,\n\t\tParallelRequests: 2,\n\t\tProgressThrottle: time.Millisecond,\n\t\tIsResetRequested: func() bool {\n\t\t\t// Trigger reset after some probes have been done\n\t\t\tp := atomic.LoadInt32(&probes)\n\t\t\tif p >= 4 && atomic.CompareAndSwapInt32(&resetOnce, 0, 1) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t}, func(iter int, _ []MTRHopStat) {\n\t\titerMu.Lock()\n\t\tsnapshotIters = append(snapshotIters, iter)\n\t\titerMu.Unlock()\n\t}, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// After reset, iteration should restart from 0→1\n\t// Final iteration should be 4 (maxPerHop=4)\n\titerMu.Lock()\n\tdefer iterMu.Unlock()\n\n\tif len(snapshotIters) == 0 {\n\t\tt.Fatal(\"expected at least one snapshot\")\n\t}\n\tlastIter := snapshotIters[len(snapshotIters)-1]\n\tif lastIter != 4 {\n\t\tt.Errorf(\"expected last iteration=4, got %d\", lastIter)\n\t}\n\n\t// prober.Reset should have been called once\n\tif atomic.LoadInt32(&prober.resetCnt) != 1 {\n\t\tt.Errorf(\"expected 1 Reset call, got %d\", atomic.LoadInt32(&prober.resetCnt))\n\t}\n}\n\nfunc TestScheduler_Pause(t *testing.T) {\n\tvar pauseFlag int32\n\tvar probes int32\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tn := atomic.AddInt32(&probes, 1)\n\t\t\tif n == 2 {\n\t\t\t\t// Pause after 2 probes, resume after 50ms\n\t\t\t\tatomic.StoreInt32(&pauseFlag, 1)\n\t\t\t\tgo func() {\n\t\t\t\t\ttime.Sleep(50 * time.Millisecond)\n\t\t\t\t\tatomic.StoreInt32(&pauseFlag, 0)\n\t\t\t\t}()\n\t\t\t}\n\t\t\tif n >= 10 {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn mtrProbeResult{TTL: ttl}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(ctx, prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          2,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        0, // unlimited, cancelled by ctx\n\t\tParallelRequests: 2,\n\t\tProgressThrottle: time.Millisecond,\n\t\tIsPaused:         func() bool { return atomic.LoadInt32(&pauseFlag) == 1 },\n\t}, nil, nil)\n\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Errorf(\"expected context.Canceled, got %v\", err)\n\t}\n\t// Should have probed at least 2 (before pause) + some more after resume\n\tp := atomic.LoadInt32(&probes)\n\tif p < 4 {\n\t\tt.Errorf(\"expected at least 4 probes (across pause), got %d\", p)\n\t}\n}\n\nfunc TestScheduler_IterationIsMinSnt(t *testing.T) {\n\t// TTL 1 responds quickly, TTL 2 responds slowly\n\tvar ttl1Count, ttl2Count int32\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 1 {\n\t\t\t\tatomic.AddInt32(&ttl1Count, 1)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     1,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\t\tRTT:     1 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tatomic.AddInt32(&ttl2Count, 1)\n\t\t\ttime.Sleep(10 * time.Millisecond) // slower\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     2,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.2\")},\n\t\t\t\tRTT:     10 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\tvar finalIter int\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          2,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        3,\n\t\tParallelRequests: 2,\n\t\tProgressThrottle: time.Millisecond,\n\t}, func(iter int, _ []MTRHopStat) {\n\t\tfinalIter = iter\n\t}, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// Both TTLs should have 3 probes (MaxPerHop=3)\n\tif finalIter != 3 {\n\t\tt.Errorf(\"expected final iteration=3, got %d\", finalIter)\n\t}\n}\n\nfunc TestScheduler_OnProbeCallback(t *testing.T) {\n\tdstIP := net.ParseIP(\"10.0.0.3\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 3 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:  1 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tvar callbackResults []mtrProbeResult\n\tvar mu sync.Mutex\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          30,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        1,\n\t\tParallelRequests: 5,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, func(result mtrProbeResult, _ int) {\n\t\tmu.Lock()\n\t\tcallbackResults = append(callbackResults, result)\n\t\tmu.Unlock()\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\t// Should have callbacks for TTL 1, 2, 3 (dest stops further TTLs)\n\tif len(callbackResults) < 3 {\n\t\tt.Errorf(\"expected at least 3 onProbe callbacks, got %d\", len(callbackResults))\n\t}\n}\n\nfunc TestScheduler_BeginHopGreaterThanMaxHops(t *testing.T) {\n\tprober := &mockTTLProber{}\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         10,\n\t\tMaxHops:          5,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        1,\n\t\tParallelRequests: 1,\n\t}, nil, nil)\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error for beginHop > maxHops\")\n\t}\n}\n\nfunc TestScheduler_ConcurrencyLimit(t *testing.T) {\n\tvar maxConcurrent int32\n\tvar current int32\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tc := atomic.AddInt32(&current, 1)\n\t\t\t// Track max concurrent\n\t\t\tfor {\n\t\t\t\told := atomic.LoadInt32(&maxConcurrent)\n\t\t\t\tif c <= old || atomic.CompareAndSwapInt32(&maxConcurrent, old, c) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\ttime.Sleep(20 * time.Millisecond) // hold slot\n\t\t\tatomic.AddInt32(&current, -1)\n\t\t\treturn mtrProbeResult{TTL: ttl}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          10,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        1,\n\t\tParallelRequests: 3, // limit to 3 concurrent\n\t\tProgressThrottle: time.Millisecond,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tmc := atomic.LoadInt32(&maxConcurrent)\n\tif mc > 3 {\n\t\tt.Errorf(\"expected max concurrent <= 3, got %d\", mc)\n\t}\n\tif mc < 1 {\n\t\tt.Error(\"expected at least 1 concurrent probe\")\n\t}\n}\n\n// TestMtrAddrToIP verifies the helper function.\nfunc TestMtrAddrToIP(t *testing.T) {\n\tip := net.ParseIP(\"1.2.3.4\")\n\n\tif got := mtrAddrToIP(&net.IPAddr{IP: ip}); !got.Equal(ip) {\n\t\tt.Errorf(\"IPAddr: got %v, want %v\", got, ip)\n\t}\n\tif got := mtrAddrToIP(&net.UDPAddr{IP: ip}); !got.Equal(ip) {\n\t\tt.Errorf(\"UDPAddr: got %v, want %v\", got, ip)\n\t}\n\tif got := mtrAddrToIP(&net.TCPAddr{IP: ip}); !got.Equal(ip) {\n\t\tt.Errorf(\"TCPAddr: got %v, want %v\", got, ip)\n\t}\n\tif got := mtrAddrToIP(nil); got != nil {\n\t\tt.Errorf(\"nil: got %v, want nil\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// P1: Error budget tests\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_ErrorBudgetExhausted(t *testing.T) {\n\t// Every call to ProbeTTL returns an error.\n\t// With MaxConsecErrors=3, MaxPerHop=2, each TTL should eventually\n\t// complete because every 3 consecutive errors count as one completed timeout.\n\terrAlways := errors.New(\"always fail\")\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\treturn mtrProbeResult{TTL: ttl}, errAlways\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          2,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2,\n\t\tMaxConsecErrors:  3,\n\t\tParallelRequests: 2,\n\t\tProgressThrottle: time.Millisecond,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"expected nil (completed), got %v\", err)\n\t}\n\n\t// Each TTL should have 2 completed (timeout) events, each requiring 3 errors.\n\t// So total probes = 2 TTLs * 2 completions * 3 errors = 12.\n\ttotalProbes := prober.getProbeCount()\n\tif totalProbes < 12 {\n\t\tt.Errorf(\"expected at least 12 probes (2 TTLs * 2 * 3 errors), got %d\", totalProbes)\n\t}\n}\n\nfunc TestScheduler_ErrorBudgetEmitsOnProbe(t *testing.T) {\n\t// When error budget is exhausted, the synthetic timeout must also fire\n\t// the onProbe callback so that raw MTR sees the same record count as\n\t// the aggregator Snt.\n\terrAlways := errors.New(\"always fail\")\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\treturn mtrProbeResult{TTL: ttl}, errAlways\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tvar mu sync.Mutex\n\tvar rawRecords []mtrProbeResult\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          1,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2,\n\t\tMaxConsecErrors:  3,\n\t\tParallelRequests: 1,\n\t\tProgressThrottle: time.Millisecond,\n\t}, nil, func(result mtrProbeResult, _ int) {\n\t\tmu.Lock()\n\t\trawRecords = append(rawRecords, result)\n\t\tmu.Unlock()\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"expected nil (completed), got %v\", err)\n\t}\n\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\t// MaxPerHop=2, each requiring MaxConsecErrors=3 → 2 onProbe calls for TTL 1.\n\tif len(rawRecords) != 2 {\n\t\tt.Errorf(\"expected 2 onProbe callbacks (synthetic timeouts), got %d\", len(rawRecords))\n\t}\n\tfor i, r := range rawRecords {\n\t\tif r.TTL != 1 {\n\t\t\tt.Errorf(\"record[%d]: expected TTL=1, got %d\", i, r.TTL)\n\t\t}\n\t\tif r.Success {\n\t\t\tt.Errorf(\"record[%d]: expected Success=false for timeout\", i)\n\t\t}\n\t}\n}\n\nfunc TestScheduler_ErrorResetsOnSuccess(t *testing.T) {\n\t// Pattern: fail, fail, succeed, fail, fail, succeed — should never hit budget.\n\tvar calls int32\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tn := atomic.AddInt32(&calls, 1)\n\t\t\tif n%3 != 0 { // every 3rd call succeeds\n\t\t\t\treturn mtrProbeResult{TTL: ttl}, errors.New(\"fail\")\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:     1 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          1,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2, // need 2 successful\n\t\tMaxConsecErrors:  3, // budget = 3 consecutive\n\t\tParallelRequests: 1,\n\t\tProgressThrottle: time.Millisecond,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"expected nil (completed via successes), got %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tfound := false\n\tfor _, s := range stats {\n\t\tif s.TTL == 1 && s.Snt >= 2 {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Error(\"TTL 1 should have at least 2 successful probes in aggregator\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// P2: Fallback geo/hostname propagation tests\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_FallbackGeoCarriedToAggregator(t *testing.T) {\n\tfakeGeo := &ipgeo.IPGeoData{\n\t\tAsnumber: \"AS13335\",\n\t\tCountry:  \"美国\",\n\t\tProv:     \"加利福尼亚\",\n\t\tCity:     \"旧金山\",\n\t\tOwner:    \"Cloudflare\",\n\t}\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:      ttl,\n\t\t\t\tSuccess:  true,\n\t\t\t\tAddr:     &net.IPAddr{IP: net.ParseIP(\"1.1.1.1\")},\n\t\t\t\tRTT:      5 * time.Millisecond,\n\t\t\t\tHostname: \"one.one.one.one\",\n\t\t\t\tGeo:      fakeGeo,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          1,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        1,\n\t\tParallelRequests: 1,\n\t\tProgressThrottle: time.Millisecond,\n\t\tFillGeo:          true, // should NOT re-fetch since probe carries Geo\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tif len(stats) == 0 {\n\t\tt.Fatal(\"expected at least 1 stat row\")\n\t}\n\n\ts := stats[0]\n\tif s.Host != \"one.one.one.one\" {\n\t\tt.Errorf(\"expected Host='one.one.one.one', got %q\", s.Host)\n\t}\n\tif s.Geo == nil {\n\t\tt.Fatal(\"expected Geo to be set, got nil\")\n\t}\n\tif s.Geo.Asnumber != \"AS13335\" {\n\t\tt.Errorf(\"expected ASN='AS13335', got %q\", s.Geo.Asnumber)\n\t}\n}\n\nfunc TestBuildMTRRawRecordFromProbe_PreResolvedGeo(t *testing.T) {\n\tfakeGeo := &ipgeo.IPGeoData{\n\t\tAsnumber:  \"AS9808\",\n\t\tCountry:   \"中国\",\n\t\tCountryEn: \"China\",\n\t\tCity:      \"广州\",\n\t\tCityEn:    \"Guangzhou\",\n\t\tOwner:     \"ChinaMobile\",\n\t}\n\n\tpr := mtrProbeResult{\n\t\tTTL:      3,\n\t\tSuccess:  true,\n\t\tAddr:     &net.IPAddr{IP: net.ParseIP(\"120.196.165.24\")},\n\t\tRTT:      8 * time.Millisecond,\n\t\tHostname: \"bras-vlan365.gd.gd\",\n\t\tGeo:      fakeGeo,\n\t}\n\n\trec := buildMTRRawRecordFromProbe(5, pr, Config{Lang: \"cn\"})\n\n\tif rec.ASN != \"AS9808\" {\n\t\tt.Errorf(\"expected ASN='AS9808', got %q\", rec.ASN)\n\t}\n\tif rec.Host != \"bras-vlan365.gd.gd\" {\n\t\tt.Errorf(\"expected Host='bras-vlan365.gd.gd', got %q\", rec.Host)\n\t}\n\tif rec.City != \"广州\" {\n\t\tt.Errorf(\"expected City='广州', got %q\", rec.City)\n\t}\n\tif rec.Country != \"中国\" {\n\t\tt.Errorf(\"expected Country='中国', got %q\", rec.Country)\n\t}\n\tif rec.Owner != \"ChinaMobile\" {\n\t\tt.Errorf(\"expected Owner='ChinaMobile', got %q\", rec.Owner)\n\t}\n}\n\nfunc TestBuildMTRRawRecordFromProbe_NoGeoNoSource_NoHostname(t *testing.T) {\n\t// When probe has no pre-resolved geo and config has no IPGeoSource/RDNS,\n\t// record should still have IP/RTT but no geo/host fields.\n\tpr := mtrProbeResult{\n\t\tTTL:     2,\n\t\tSuccess: true,\n\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.5\")},\n\t\tRTT:     3 * time.Millisecond,\n\t}\n\n\trec := buildMTRRawRecordFromProbe(1, pr, Config{})\n\n\tif rec.IP != \"10.0.0.5\" {\n\t\tt.Errorf(\"expected IP='10.0.0.5', got %q\", rec.IP)\n\t}\n\tif rec.ASN != \"\" || rec.Host != \"\" || rec.Country != \"\" {\n\t\tt.Errorf(\"expected empty geo/host fields, got ASN=%q Host=%q Country=%q\",\n\t\t\trec.ASN, rec.Host, rec.Country)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// End-to-end: raw record count matches aggregator Snt under error budget\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_RawRecordCountMatchesAggSnt_ErrorBudget(t *testing.T) {\n\t// Simulate a mix of successes and persistent errors across 2 TTLs.\n\t// TTL 1: always succeeds. TTL 2: always errors.\n\t// With MaxPerHop=3 and MaxConsecErrors=2, both TTLs should complete\n\t// and the raw callback count per TTL must equal the aggregator Snt.\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 1 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     1,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\t\tRTT:     5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{TTL: 2}, errors.New(\"persistent failure\")\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tvar mu sync.Mutex\n\trawCountByTTL := map[int]int{}\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          2,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        3,\n\t\tMaxConsecErrors:  2,\n\t\tParallelRequests: 1,\n\t\tProgressThrottle: time.Millisecond,\n\t}, nil, func(result mtrProbeResult, _ int) {\n\t\tmu.Lock()\n\t\trawCountByTTL[result.TTL]++\n\t\tmu.Unlock()\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\tfor ttl := 1; ttl <= 2; ttl++ {\n\t\trawCount := rawCountByTTL[ttl]\n\t\tsnt := sntByTTL[ttl]\n\t\tif rawCount != snt {\n\t\t\tt.Errorf(\"TTL %d: raw callback count (%d) != aggregator Snt (%d)\",\n\t\t\t\tttl, rawCount, snt)\n\t\t}\n\t\tif snt != 3 {\n\t\t\tt.Errorf(\"TTL %d: expected Snt=3, got %d\", ttl, snt)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// RDNS-only: IPGeoSource=nil && RDNS=true enters fetchIPData path\n// ---------------------------------------------------------------------------\n\nfunc TestBuildMTRRawRecordFromProbe_RDNSOnlyPath(t *testing.T) {\n\t// With IPGeoSource=nil and RDNS=true, the function should enter the\n\t// fetchIPData path (not skip it). We use 127.0.0.1 which typically\n\t// resolves to \"localhost\" via PTR. Even if RDNS fails in CI, the test\n\t// verifies the code path doesn't panic and the record is well-formed.\n\tpr := mtrProbeResult{\n\t\tTTL:     1,\n\t\tSuccess: true,\n\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"127.0.0.1\")},\n\t\tRTT:     1 * time.Millisecond,\n\t}\n\n\trec := buildMTRRawRecordFromProbe(1, pr, Config{\n\t\tRDNS:        true,\n\t\tIPGeoSource: nil, // no geo source — only RDNS\n\t\tLang:        \"en\",\n\t})\n\n\t// Basic sanity: record must have IP and RTT regardless.\n\tif rec.IP != \"127.0.0.1\" {\n\t\tt.Errorf(\"expected IP='127.0.0.1', got %q\", rec.IP)\n\t}\n\tif rec.RTTMs <= 0 {\n\t\tt.Errorf(\"expected positive RTTMs, got %f\", rec.RTTMs)\n\t}\n\t// Geo fields should be empty (no IPGeoSource).\n\tif rec.ASN != \"\" {\n\t\tt.Errorf(\"expected empty ASN with no geo source, got %q\", rec.ASN)\n\t}\n\t// Host may or may not be set depending on system RDNS for 127.0.0.1.\n\t// The key assertion is that we reached here without panic/skip.\n\tt.Logf(\"RDNS-only path: Host=%q (may vary by system)\", rec.Host)\n}\n\n// ---------------------------------------------------------------------------\n// Destination folding tests\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_HigherTTLDestinationRepliesDiscarded(t *testing.T) {\n\t// Destination is at TTL 3. Higher TTLs also return the destination IP\n\t// but with a small delay, ensuring TTL 3's result is processed first\n\t// (setting knownFinalTTL=3). The delayed higher-TTL probes are now\n\t// DISCARDED — not folded into TTL 3.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl > 3 {\n\t\t\t\t// Higher TTLs return destination but after a delay,\n\t\t\t\t// so TTL 3's result is processed first.\n\t\t\t\ttime.Sleep(30 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     ttl,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:     time.Duration(ttl) * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 3 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     ttl,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:     time.Duration(ttl) * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:     time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tvar mu sync.Mutex\n\tprobeByTTL := map[int]int{}\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          6,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        3,\n\t\tParallelRequests: 6, // enough to launch TTLs 1-6 simultaneously\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, func(result mtrProbeResult, _ int) {\n\t\tmu.Lock()\n\t\tprobeByTTL[result.TTL]++\n\t\tmu.Unlock()\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\t// TTL 3 (finalTTL) should have ONLY its own probes — Snt == MaxPerHop (no fold)\n\tif sntByTTL[3] != 3 {\n\t\tt.Errorf(\"TTL 3 (final): expected Snt == 3 (own probes only, no fold), got %d\", sntByTTL[3])\n\t}\n\n\t// TTLs above final should NOT appear in aggregator (discarded)\n\tfor ttl := 4; ttl <= 6; ttl++ {\n\t\tif sntByTTL[ttl] > 0 {\n\t\t\tt.Errorf(\"TTL %d: expected Snt=0 (discarded), got %d\", ttl, sntByTTL[ttl])\n\t\t}\n\t}\n\n\t// onProbe callbacks must NOT fire for discarded higher-TTL results\n\tmu.Lock()\n\tdefer mu.Unlock()\n\tfor ttl := 4; ttl <= 6; ttl++ {\n\t\tif probeByTTL[ttl] > 0 {\n\t\t\tt.Errorf(\"onProbe: TTL %d should have 0 callbacks (discarded), got %d\", ttl, probeByTTL[ttl])\n\t\t}\n\t}\n\t// Also verify that NO folded callbacks appeared at TTL 3 from higher-TTL probes:\n\t// TTL 3's callback count must equal its Snt.\n\tif probeByTTL[3] != sntByTTL[3] {\n\t\tt.Errorf(\"onProbe: TTL 3 callback count (%d) != Snt (%d); suggests folded callbacks leaked\", probeByTTL[3], sntByTTL[3])\n\t}\n}\n\nfunc TestScheduler_DiscardedDestinationRepliesCannotExceedMaxPerHop(t *testing.T) {\n\t// With discard semantics, higher TTL destination replies are discarded\n\t// entirely, so they can never push finalTTL's Snt above MaxPerHop.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tvar probeCount int32\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tatomic.AddInt32(&probeCount, 1)\n\t\t\tif ttl >= 2 {\n\t\t\t\t// All TTLs >= 2 hit destination\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     ttl,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:     5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:     1 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          10,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2, // strict cap\n\t\tParallelRequests: 10,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tfor _, s := range stats {\n\t\tif s.TTL == 2 {\n\t\t\t// TTL 2 is the finalTTL; higher TTL destination replies are\n\t\t\t// discarded entirely, so Snt must equal exactly MaxPerHop\n\t\t\t// (only own probes counted).\n\t\t\tif s.Snt > 2 {\n\t\t\t\tt.Errorf(\"TTL 2 (final): Snt=%d exceeds MaxPerHop=2 (discard violation)\", s.Snt)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Verify no higher TTLs have stats in the aggregator\n\tfor _, s := range stats {\n\t\tif s.TTL > 2 && s.IP == dstIP.String() {\n\t\t\tt.Errorf(\"TTL %d: should not have dst-ip stats (discarded), got Snt=%d\", s.TTL, s.Snt)\n\t\t}\n\t}\n}\n\nfunc TestScheduler_NonDestinationRepliesOnDisabledHigherTTLDiscarded(t *testing.T) {\n\t// If a higher TTL (after being disabled) returns a non-destination IP,\n\t// that reply should be silently discarded — not folded, not recorded.\n\t// Higher TTLs are delayed so TTL 3 (destination) is processed first.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tvar mu sync.Mutex\n\tvar probeResults []mtrProbeResult\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 3 {\n\t\t\t\t// TTL 3 is destination — returns quickly\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     ttl,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:     5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl > 3 {\n\t\t\t\t// Higher TTLs return a non-destination intermediate IP\n\t\t\t\t// after a delay, so they arrive after TTL 3 sets disabled.\n\t\t\t\ttime.Sleep(30 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL:     ttl,\n\t\t\t\t\tSuccess: true,\n\t\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.50\")},\n\t\t\t\t\tRTT:     3 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.\" + fmt.Sprintf(\"%d\", ttl))},\n\t\t\t\tRTT:     time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          6,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2,\n\t\tParallelRequests: 6, // all TTLs may launch before destination detected\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, func(result mtrProbeResult, _ int) {\n\t\tmu.Lock()\n\t\tprobeResults = append(probeResults, result)\n\t\tmu.Unlock()\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// Non-destination replies from disabled TTLs (4, 5, 6) with IP 10.0.0.50\n\t// should have been discarded. Check that the aggregator has no entries\n\t// for TTLs > 3 with the intermediate IP.\n\tstats := agg.Snapshot()\n\tfor _, s := range stats {\n\t\tif s.TTL > 3 && s.IP == \"10.0.0.50\" {\n\t\t\tt.Errorf(\"TTL %d: non-destination reply (10.0.0.50) should have been discarded, but appeared in aggregator\", s.TTL)\n\t\t}\n\t}\n}\n\nfunc TestScheduler_FinalTTLLowered_MigratesStatsToNewFinal(t *testing.T) {\n\t// Scenario: higher TTL (12) returns destination first, establishing\n\t// knownFinalTTL=12. Then a lower TTL (7) returns destination, lowering\n\t// knownFinalTTL to 7. The stats already recorded at TTL 12 must be\n\t// migrated to TTL 7 — no ghost row at TTL 12 should remain.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tvar mu sync.Mutex\n\tcallOrder := map[int]int{} // ttl → order of first return\n\tvar callSeq int32\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tmu.Lock()\n\t\t\tif callOrder[ttl] == 0 {\n\t\t\t\tcallOrder[ttl] = int(atomic.AddInt32(&callSeq, 1))\n\t\t\t}\n\t\t\tmu.Unlock()\n\n\t\t\tif ttl == 12 {\n\t\t\t\t// TTL 12 returns destination quickly\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  3 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 7 {\n\t\t\t\t// TTL 7 returns destination after a delay,\n\t\t\t\t// ensuring TTL 12 is processed first.\n\t\t\t\ttime.Sleep(50 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\t// Intermediate hops\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(\"10.0.0.\" + fmt.Sprintf(\"%d\", ttl))},\n\t\t\t\tRTT:  time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          15,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        3,\n\t\tParallelRequests: 15,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tipByTTL := map[int]string{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t\tipByTTL[s.TTL] = s.IP\n\t}\n\n\t// TTL 12 should have NO stats — cleared when finalTTL lowered to 7\n\tif sntByTTL[12] > 0 {\n\t\tt.Errorf(\"TTL 12 should have 0 stats after clearing, got Snt=%d (ghost row!)\", sntByTTL[12])\n\t}\n\n\t// TTL 7 (new final) should have stats (its own probes only)\n\tif sntByTTL[7] < 3 {\n\t\tt.Errorf(\"TTL 7 (final): expected Snt >= 3, got %d\", sntByTTL[7])\n\t}\n\n\t// Only one row should have the destination IP\n\tdstIPRows := 0\n\tfor _, s := range stats {\n\t\tif s.IP == \"10.0.0.99\" {\n\t\t\tdstIPRows++\n\t\t\tif s.TTL != 7 {\n\t\t\t\tt.Errorf(\"destination IP found at TTL %d, expected only at TTL 7\", s.TTL)\n\t\t\t}\n\t\t}\n\t}\n\tif dstIPRows == 0 {\n\t\tt.Error(\"expected at least one row with destination IP\")\n\t}\n\tif dstIPRows > 1 {\n\t\tt.Errorf(\"expected exactly 1 dst-ip row (at TTL 7), got %d (duplicate!)\", dstIPRows)\n\t}\n}\n\nfunc TestScheduler_FinalTTLLowered_ChainMigration(t *testing.T) {\n\t// Chain scenario: TTL 12 → final. Then TTL 9 → final (migrates 12→9).\n\t// Then TTL 7 → final (migrates 9→7). All stats end up at TTL 7.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 12 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  3 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 9 {\n\t\t\t\ttime.Sleep(30 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  4 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 7 {\n\t\t\t\ttime.Sleep(60 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(\"10.0.0.\" + fmt.Sprintf(\"%d\", ttl))},\n\t\t\t\tRTT:  time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          15,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2,\n\t\tParallelRequests: 15,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tfor _, s := range stats {\n\t\tif s.IP == \"10.0.0.99\" && s.TTL != 7 {\n\t\t\tt.Errorf(\"destination IP at TTL %d, expected only at TTL 7 after chain lowering\", s.TTL)\n\t\t}\n\t}\n\n\t// TTLs 9 and 12 should not have dst-ip stats (cleared during lowering)\n\tfor _, s := range stats {\n\t\tif (s.TTL == 9 || s.TTL == 12) && s.IP == \"10.0.0.99\" {\n\t\t\tt.Errorf(\"TTL %d: ghost row with dst-ip after chain lowering\", s.TTL)\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// New regression tests: discard over-final destination replies\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_LateHigherTTLDestinationReply_Discarded_NoSntBump(t *testing.T) {\n\t// Scheduler dispatches multiple TTLs concurrently.\n\t// TTL 3 hits destination first → sets knownFinalTTL=3 and disables >3.\n\t// Later: originTTL=5 returns destination reply (late) → MUST be discarded.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 3 {\n\t\t\t\t// Destination — returns fast\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  3 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 5 {\n\t\t\t\t// Late destination reply — delayed so TTL 3 is processed first\n\t\t\t\ttime.Sleep(40 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  8 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))},\n\t\t\t\tRTT:  time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tvar mu sync.Mutex\n\tvar callbackCount int\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          6,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2,\n\t\tParallelRequests: 6,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, func(result mtrProbeResult, _ int) {\n\t\tmu.Lock()\n\t\tcallbackCount++\n\t\tmu.Unlock()\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\t// Final hop TTL 3 should have exactly MaxPerHop (2) Snt — no bump from TTL 5\n\tif sntByTTL[3] != 2 {\n\t\tt.Errorf(\"TTL 3 (final): expected Snt=2 (MaxPerHop, own probes only), got %d\", sntByTTL[3])\n\t}\n\n\t// TTL 5 should have 0 Snt — discarded\n\tif sntByTTL[5] > 0 {\n\t\tt.Errorf(\"TTL 5: expected Snt=0 (discarded late dst reply), got %d\", sntByTTL[5])\n\t}\n\n\t// Callback count should equal sum of Snt across active TTLs only\n\tmu.Lock()\n\ttotalSnt := 0\n\tfor _, s := range stats {\n\t\ttotalSnt += s.Snt\n\t}\n\tif callbackCount != totalSnt {\n\t\tt.Errorf(\"callback count (%d) != total Snt (%d); discarded results may have leaked\", callbackCount, totalSnt)\n\t}\n\tmu.Unlock()\n}\n\nfunc TestScheduler_DiscardedOverFinal_DoesNotEmitOnProbe(t *testing.T) {\n\t// Provide onProbe hook that appends all records.\n\t// Trigger late over-final destination reply.\n\t// Assert no record appended for discarded result.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 2 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl > 2 {\n\t\t\t\ttime.Sleep(30 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  10 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:  1 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\tvar mu sync.Mutex\n\tvar records []mtrProbeResult\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          5,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        2,\n\t\tParallelRequests: 5,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, func(result mtrProbeResult, _ int) {\n\t\tmu.Lock()\n\t\trecords = append(records, result)\n\t\tmu.Unlock()\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\t// No record should have TTL > 2 (all discarded)\n\tfor i, r := range records {\n\t\tif r.TTL > 2 {\n\t\t\tt.Errorf(\"record[%d]: TTL %d should not have been emitted (discarded over-final)\", i, r.TTL)\n\t\t}\n\t}\n\n\t// Record count must match aggregator Snt sum\n\tstats := agg.Snapshot()\n\ttotalSnt := 0\n\tfor _, s := range stats {\n\t\ttotalSnt += s.Snt\n\t}\n\tif len(records) != totalSnt {\n\t\tt.Errorf(\"record count (%d) != total Snt (%d); 1:1 onProbe/Snt invariant violated\", len(records), totalSnt)\n\t}\n}\n\nfunc TestScheduler_FinalTTLLowering_Chain_WithMaxPerHop_NoGhostRow_StableStats(t *testing.T) {\n\t// Construct deterministic RTT samples with chain lowering:\n\t// Provisional final at TTL 12 → lowered to 9 → lowered to 7.\n\t// MaxPerHop=3 (small). Verify no ghost rows, stats stable.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\t// RTT values for deterministic stat validation\n\trttMap := map[int][]time.Duration{\n\t\t7:  {5 * time.Millisecond, 6 * time.Millisecond, 7 * time.Millisecond},\n\t\t9:  {10 * time.Millisecond, 11 * time.Millisecond},\n\t\t12: {20 * time.Millisecond},\n\t}\n\tvar mu sync.Mutex\n\tcallCountByTTL := map[int]int{}\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tmu.Lock()\n\t\t\tcallCountByTTL[ttl]++\n\t\t\tn := callCountByTTL[ttl]\n\t\t\tmu.Unlock()\n\n\t\t\tif ttl == 12 {\n\t\t\t\t// First to return destination\n\t\t\t\trtt := 20 * time.Millisecond\n\t\t\t\tif n <= len(rttMap[12]) {\n\t\t\t\t\trtt = rttMap[12][n-1]\n\t\t\t\t}\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  rtt,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 9 {\n\t\t\t\ttime.Sleep(30 * time.Millisecond)\n\t\t\t\trtt := 11 * time.Millisecond\n\t\t\t\tif n <= len(rttMap[9]) {\n\t\t\t\t\trtt = rttMap[9][n-1]\n\t\t\t\t}\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  rtt,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 7 {\n\t\t\t\ttime.Sleep(60 * time.Millisecond)\n\t\t\t\trtt := 7 * time.Millisecond\n\t\t\t\tif n <= len(rttMap[7]) {\n\t\t\t\t\trtt = rttMap[7][n-1]\n\t\t\t\t}\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  rtt,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))},\n\t\t\t\tRTT:  time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          15,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        3,\n\t\tParallelRequests: 15,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tipByTTL := map[int]string{}\n\tvar finalHopStat *MTRHopStat\n\tfor i, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t\tipByTTL[s.TTL] = s.IP\n\t\tif s.TTL == 7 && s.IP == dstIP.String() {\n\t\t\tfinalHopStat = &stats[i]\n\t\t}\n\t}\n\n\t// No ghost rows at TTL 12 or 9 with destination IP\n\tfor _, ttl := range []int{12, 9} {\n\t\tif ipByTTL[ttl] == dstIP.String() {\n\t\t\tt.Errorf(\"TTL %d: ghost row with dst-ip after chain migration\", ttl)\n\t\t}\n\t}\n\n\t// Final hop is TTL 7\n\tif finalHopStat == nil {\n\t\tt.Fatal(\"expected final hop at TTL 7 with destination IP\")\n\t}\n\n\t// Snt <= MaxPerHop\n\tif finalHopStat.Snt > 3 {\n\t\tt.Errorf(\"TTL 7 (final): Snt=%d exceeds MaxPerHop=3\", finalHopStat.Snt)\n\t}\n\n\t// Snt must be > 0 (at least the migrated + own)\n\tif finalHopStat.Snt == 0 {\n\t\tt.Error(\"TTL 7 (final): Snt=0, expected > 0 after chain lowering\")\n\t}\n\n\t// Avg should be reasonable (> 0 and not NaN)\n\tif finalHopStat.Avg <= 0 {\n\t\tt.Errorf(\"TTL 7 (final): Avg=%f, expected > 0 (stable stats)\", finalHopStat.Avg)\n\t}\n\n\t// StDev should be non-negative\n\tif finalHopStat.StDev < 0 {\n\t\tt.Errorf(\"TTL 7 (final): StDev=%f, expected >= 0 (stable stats)\", finalHopStat.StDev)\n\t}\n\n\t// Destination IP should appear exactly once across all stats rows\n\tdstIPCount := 0\n\tfor _, s := range stats {\n\t\tif s.IP == dstIP.String() {\n\t\t\tdstIPCount++\n\t\t\tif s.TTL != 7 {\n\t\t\t\tt.Errorf(\"destination IP found at TTL %d, expected only at TTL 7\", s.TTL)\n\t\t\t}\n\t\t}\n\t}\n\tif dstIPCount != 1 {\n\t\tt.Errorf(\"expected exactly 1 row with dst-ip (at TTL 7), got %d\", dstIPCount)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Regression: final hop Snt must NOT exceed other hops\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_FinalHopSntNotInflated_NoLowering(t *testing.T) {\n\t// Simple case: destination is at TTL 5, no lowering occurs.\n\t// All active TTLs should have equal Snt after completion.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 5 {\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))},\n\t\t\t\tRTT:  time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          30,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        5,\n\t\tParallelRequests: 30,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\t// All active TTLs (1-5) should have exactly MaxPerHop Snt\n\tfor ttl := 1; ttl <= 5; ttl++ {\n\t\tif sntByTTL[ttl] != 5 {\n\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected 5 (MaxPerHop)\", ttl, sntByTTL[ttl])\n\t\t}\n\t}\n}\n\nfunc TestScheduler_FinalHopSntNotInflated_WithLowering(t *testing.T) {\n\t// Lowering scenario: TTL 8 hits destination first, then TTL 5 lowers it.\n\t// After completion, TTL 5 (final) should have Snt == MaxPerHop, same as\n\t// other hops — NOT inflated by migrated data from TTL 8.\n\tdstIP := net.ParseIP(\"10.0.0.99\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tif ttl == 8 {\n\t\t\t\t// Returns destination quickly (discovered first as provisional final)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  3 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tif ttl == 5 {\n\t\t\t\t// Real final — returns after delay so TTL 8 is processed first\n\t\t\t\ttime.Sleep(30 * time.Millisecond)\n\t\t\t\treturn mtrProbeResult{\n\t\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\t\tAddr: &net.IPAddr{IP: dstIP},\n\t\t\t\t\tRTT:  5 * time.Millisecond,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))},\n\t\t\t\tRTT:  time.Duration(ttl) * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          15,\n\t\tHopInterval:      time.Millisecond,\n\t\tMaxPerHop:        4,\n\t\tParallelRequests: 15,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\t// All active TTLs (1-5) should have exactly MaxPerHop Snt\n\tfor ttl := 1; ttl <= 5; ttl++ {\n\t\tif sntByTTL[ttl] != 4 {\n\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected 4 (MaxPerHop, no inflation)\", ttl, sntByTTL[ttl])\n\t\t}\n\t}\n\n\t// Old provisional final (TTL 8) should have NO data (cleared, not migrated)\n\tif sntByTTL[8] > 0 {\n\t\tt.Errorf(\"TTL 8 (old provisional final): Snt=%d, expected 0 (cleared)\", sntByTTL[8])\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Multi in-flight per hop: high-loss hops should accumulate Snt equally\n// ---------------------------------------------------------------------------\n\nfunc TestScheduler_MultiInFlightPerHop_HighLossEqualSnt(t *testing.T) {\n\t// This test reproduces the original bug: when each TTL allows only 1\n\t// in-flight probe and nextAt is based on completion time, a TTL with\n\t// high packet loss (simulated by long timeout) accumulates Snt much\n\t// slower than a low-loss TTL.\n\t//\n\t// With the multi-in-flight fix (inFlightCount counter + nextAt based on\n\t// launch time), all TTLs should complete with equal Snt = MaxPerHop.\n\tdstIP := net.ParseIP(\"10.0.0.5\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\t// TTL 1: fast responder (no loss)\n\t\t\t// TTL 2: 80% loss (simulated as 80% of probes sleeping 200ms = \"timeout\")\n\t\t\t// TTL 3: destination, fast\n\t\t\tip := net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))\n\t\t\tif ttl == 3 {\n\t\t\t\tip = dstIP\n\t\t\t}\n\n\t\t\tif ttl == 2 {\n\t\t\t\t// Simulate high RTT / timeout — takes longer than other hops.\n\t\t\t\t// With multi-in-flight, the scheduler should still keep up.\n\t\t\t\ttime.Sleep(50 * time.Millisecond)\n\t\t\t}\n\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: ip},\n\t\t\t\tRTT:     5 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:          1,\n\t\tMaxHops:           30,\n\t\tHopInterval:       10 * time.Millisecond,\n\t\tMaxPerHop:         10,\n\t\tMaxInFlightPerHop: 3,\n\t\tParallelRequests:  30,\n\t\tProgressThrottle:  time.Millisecond,\n\t\tDstIP:             dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\t// All active TTLs should have exactly MaxPerHop probes\n\tfor ttl := 1; ttl <= 3; ttl++ {\n\t\tif sntByTTL[ttl] != 10 {\n\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected 10 (MaxPerHop)\", ttl, sntByTTL[ttl])\n\t\t}\n\t}\n}\n\nfunc TestScheduler_MultiInFlightPerHop_TimeoutHopsKeepUp(t *testing.T) {\n\t// Simulate real packet loss: some probes return quickly (RTT), others\n\t// \"time out\" by sleeping the full timeout duration. With multi-in-flight,\n\t// the slow (timed-out) hop should still reach MaxPerHop because the\n\t// scheduler launches new probes while old ones are still in-flight.\n\tdstIP := net.ParseIP(\"10.0.0.10\")\n\n\tvar ttl2Calls int32\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tip := net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))\n\t\t\tif ttl == 5 {\n\t\t\t\tip = dstIP\n\t\t\t}\n\n\t\t\tif ttl == 3 {\n\t\t\t\t// 50% of probes \"time out\" (take a long time)\n\t\t\t\tn := atomic.AddInt32(&ttl2Calls, 1)\n\t\t\t\tif n%2 == 0 {\n\t\t\t\t\ttime.Sleep(100 * time.Millisecond) // \"timeout\"\n\t\t\t\t\t// Return as no-reply (timeout)\n\t\t\t\t\treturn mtrProbeResult{TTL: ttl}, nil\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: ip},\n\t\t\t\tRTT:     2 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:          1,\n\t\tMaxHops:           30,\n\t\tHopInterval:       10 * time.Millisecond,\n\t\tMaxPerHop:         6,\n\t\tMaxInFlightPerHop: 3,\n\t\tParallelRequests:  30,\n\t\tProgressThrottle:  time.Millisecond,\n\t\tDstIP:             dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\t// All TTLs should complete with MaxPerHop probes\n\tfor ttl := 1; ttl <= 5; ttl++ {\n\t\tif sntByTTL[ttl] != 6 {\n\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected 6 (MaxPerHop)\", ttl, sntByTTL[ttl])\n\t\t}\n\t}\n}\n\nfunc TestScheduler_NextAtBasedOnLaunchTime(t *testing.T) {\n\t// Verify that the scheduler doesn't wait for probe completion to set nextAt.\n\t// If a probe takes 200ms and hopInterval is 10ms, a second probe for the same\n\t// TTL should launch ~10ms after the first (not 210ms after).\n\tvar launches []time.Time\n\tvar mu sync.Mutex\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tmu.Lock()\n\t\t\tlaunches = append(launches, time.Now())\n\t\t\tmu.Unlock()\n\n\t\t\t// Simulate slow probe (timeout-like)\n\t\t\ttime.Sleep(200 * time.Millisecond)\n\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: net.ParseIP(\"10.0.0.1\")},\n\t\t\t\tRTT:     200 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:          1,\n\t\tMaxHops:           1,\n\t\tHopInterval:       50 * time.Millisecond,\n\t\tMaxPerHop:         3,\n\t\tMaxInFlightPerHop: 3,\n\t\tParallelRequests:  10,\n\t\tProgressThrottle:  time.Millisecond,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\tif len(launches) < 3 {\n\t\tt.Fatalf(\"expected 3 launches, got %d\", len(launches))\n\t}\n\n\t// With launch-based nextAt and hopInterval=50ms, launches 2 and 3 should\n\t// start ~50ms and ~100ms after launch 1 respectively, NOT 250ms and 500ms\n\t// (which would be the case with completion-based nextAt).\n\tfor i := 1; i < len(launches); i++ {\n\t\tgap := launches[i].Sub(launches[i-1])\n\t\t// Allow generous tolerance (50ms interval + scheduling jitter up to 50ms)\n\t\tif gap > 150*time.Millisecond {\n\t\t\tt.Errorf(\"gap between launch %d and %d: %v (expected < 150ms with launch-based nextAt)\",\n\t\t\t\ti-1, i, gap)\n\t\t}\n\t}\n}\n\nfunc TestScheduler_MaxPerHopRespectedWithMultiInFlight(t *testing.T) {\n\t// Ensure that with multiple in-flight probes per hop, we never exceed\n\t// MaxPerHop in the final Snt count. The scheduler should stop launching\n\t// when completed + inFlightCount >= MaxPerHop.\n\tdstIP := net.ParseIP(\"10.0.0.3\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\t// All probes take some time to complete\n\t\t\ttime.Sleep(30 * time.Millisecond)\n\n\t\t\tip := net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))\n\t\t\tif ttl == 3 {\n\t\t\t\tip = dstIP\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: ip},\n\t\t\t\tRTT:     5 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:          1,\n\t\tMaxHops:           30,\n\t\tHopInterval:       5 * time.Millisecond,\n\t\tMaxPerHop:         4,\n\t\tMaxInFlightPerHop: 5, // higher than MaxPerHop to test the guard\n\t\tParallelRequests:  30,\n\t\tProgressThrottle:  time.Millisecond,\n\t\tDstIP:             dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tfor _, s := range stats {\n\t\tif s.TTL >= 1 && s.TTL <= 3 {\n\t\t\tif s.Snt > 4 {\n\t\t\t\tt.Errorf(\"TTL %d: Snt=%d exceeds MaxPerHop=4\", s.TTL, s.Snt)\n\t\t\t}\n\t\t\tif s.Snt != 4 {\n\t\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected exactly 4 (MaxPerHop)\", s.TTL, s.Snt)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestScheduler_SingleInFlightPerHopConfig(t *testing.T) {\n\t// When MaxInFlightPerHop=1, behavior should match the old single-inflight\n\t// mode (for backward compatibility verification). The test just ensures\n\t// completion without error.\n\tdstIP := net.ParseIP(\"10.0.0.3\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tip := net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))\n\t\t\tif ttl == 3 {\n\t\t\t\tip = dstIP\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: ip},\n\t\t\t\tRTT:     1 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:          1,\n\t\tMaxHops:           30,\n\t\tHopInterval:       time.Millisecond,\n\t\tMaxPerHop:         3,\n\t\tMaxInFlightPerHop: 1, // explicit single in-flight\n\t\tParallelRequests:  5,\n\t\tProgressThrottle:  time.Millisecond,\n\t\tDstIP:             dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tfor _, s := range stats {\n\t\tif s.TTL >= 1 && s.TTL <= 3 && s.Snt != 3 {\n\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected 3\", s.TTL, s.Snt)\n\t\t}\n\t}\n}\n\nfunc TestScheduler_DynamicMaxInFlightPerHop(t *testing.T) {\n\t// Verify that when MaxInFlightPerHop is not explicitly set, the scheduler\n\t// computes it as ceil(timeout / hopInterval) + 1. With a large timeout\n\t// relative to hopInterval, the dynamic value should be high enough that\n\t// even fully-timing-out hops keep up with fast hops.\n\t//\n\t// Setup: timeout=500ms, hopInterval=50ms → dynamic = ceil(500/50)+1 = 11.\n\t// TTL 2 always \"times out\" (sleeps 500ms); TTL 1,3 are fast.\n\t// MaxPerHop=8: with dynamic=11, all 8 probes for TTL 2 can be in-flight\n\t// simultaneously, so TTL 2 completes at roughly the same wall-clock as\n\t// the fast hops.\n\tdstIP := net.ParseIP(\"10.0.0.3\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tip := net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))\n\t\t\tif ttl == 3 {\n\t\t\t\tip = dstIP\n\t\t\t}\n\t\t\tif ttl == 2 {\n\t\t\t\ttime.Sleep(500 * time.Millisecond) // full \"timeout\"\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL:     ttl,\n\t\t\t\tSuccess: true,\n\t\t\t\tAddr:    &net.IPAddr{IP: ip},\n\t\t\t\tRTT:     2 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          30,\n\t\tHopInterval:      50 * time.Millisecond,\n\t\tTimeout:          500 * time.Millisecond, // → dynamic maxInFlightPerHop = 11\n\t\tMaxPerHop:        8,\n\t\tParallelRequests: 30,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t\t// MaxInFlightPerHop intentionally 0 → dynamic\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tsntByTTL := map[int]int{}\n\tfor _, s := range stats {\n\t\tsntByTTL[s.TTL] = s.Snt\n\t}\n\n\tfor ttl := 1; ttl <= 3; ttl++ {\n\t\tif sntByTTL[ttl] != 8 {\n\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected 8 (MaxPerHop)\", ttl, sntByTTL[ttl])\n\t\t}\n\t}\n}\n\nfunc TestScheduler_DynamicMaxInFlightPerHop_SmallTimeout(t *testing.T) {\n\t// With timeout < hopInterval, dynamic = ceil(t/h)+1 = 1+1 = 2, not 1.\n\t// This ensures at least 2 slots so pipelining still works.\n\tdstIP := net.ParseIP(\"10.0.0.2\")\n\n\tprober := &mockTTLProber{\n\t\tprobeFn: func(_ context.Context, ttl int) (mtrProbeResult, error) {\n\t\t\tip := net.ParseIP(fmt.Sprintf(\"10.0.0.%d\", ttl))\n\t\t\tif ttl == 2 {\n\t\t\t\tip = dstIP\n\t\t\t}\n\t\t\treturn mtrProbeResult{\n\t\t\t\tTTL: ttl, Success: true,\n\t\t\t\tAddr: &net.IPAddr{IP: ip},\n\t\t\t\tRTT:  1 * time.Millisecond,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tagg := NewMTRAggregator()\n\n\terr := runMTRScheduler(context.Background(), prober, agg, mtrSchedulerConfig{\n\t\tBeginHop:         1,\n\t\tMaxHops:          30,\n\t\tHopInterval:      100 * time.Millisecond,\n\t\tTimeout:          50 * time.Millisecond, // timeout < hopInterval → dynamic = 2\n\t\tMaxPerHop:        3,\n\t\tParallelRequests: 10,\n\t\tProgressThrottle: time.Millisecond,\n\t\tDstIP:            dstIP,\n\t}, nil, nil)\n\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tstats := agg.Snapshot()\n\tfor _, s := range stats {\n\t\tif s.TTL >= 1 && s.TTL <= 2 && s.Snt != 3 {\n\t\t\tt.Errorf(\"TTL %d: Snt=%d, expected 3\", s.TTL, s.Snt)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "trace/mtr_stats.go",
    "content": "package trace\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\n// ---------------------------------------------------------------------------\n// MTR 聚合统计模型（公共层，CLI 和 Server 均可使用）\n// ---------------------------------------------------------------------------\n\n// MTRHopStat 表示 MTR 输出中一行统计数据。\ntype MTRHopStat struct {\n\tTTL      int              `json:\"ttl\"`\n\tHost     string           `json:\"host,omitempty\"`\n\tIP       string           `json:\"ip,omitempty\"`\n\tLoss     float64          `json:\"loss_percent\"`\n\tSnt      int              `json:\"snt\"`\n\tLast     float64          `json:\"last_ms\"`\n\tAvg      float64          `json:\"avg_ms\"`\n\tBest     float64          `json:\"best_ms\"`\n\tWrst     float64          `json:\"wrst_ms\"`\n\tStDev    float64          `json:\"stdev_ms\"`\n\tGeo      *ipgeo.IPGeoData `json:\"geo,omitempty\"`\n\tMPLS     []string         `json:\"mpls,omitempty\"`\n\tReceived int              `json:\"received\"`\n}\n\n// MTRSnapshot 是某一时刻的完整快照。\ntype MTRSnapshot struct {\n\tIteration int          `json:\"iteration\"`\n\tStats     []MTRHopStat `json:\"stats\"`\n}\n\n// ---------------------------------------------------------------------------\n// 内部累加器\n// ---------------------------------------------------------------------------\n\ntype mtrHopAccum struct {\n\tttl      int\n\tkey      string\n\thost     string\n\tip       string\n\tsent     int\n\treceived int\n\tsum      float64\n\tsumSq    float64 // Σ(rtt²)，用于在线方差\n\tlast     float64\n\tbest     float64\n\tworst    float64\n\tgeo      *ipgeo.IPGeoData\n\torder    int\n\tmplsSet  map[string]struct{}\n}\n\n// MTRAggregator 跨轮次聚合 hop 统计。线程安全。\ntype MTRAggregator struct {\n\tmu        sync.Mutex\n\tstats     map[int]map[string]*mtrHopAccum // [ttl][key]\n\tnextOrder int\n}\n\n// NewMTRAggregator 创建新的聚合器。\nfunc NewMTRAggregator() *MTRAggregator {\n\treturn &MTRAggregator{\n\t\tstats: make(map[int]map[string]*mtrHopAccum),\n\t}\n}\n\n// Update 接收一轮 traceroute 的 Result 并更新统计，返回当前快照。\nfunc (agg *MTRAggregator) Update(res *Result, queries int) []MTRHopStat {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\n\tif res == nil || len(res.Hops) == 0 {\n\t\treturn agg.snapshotLocked()\n\t}\n\n\t_ = queries\n\n\tfor idx, attempts := range res.Hops {\n\t\tif len(attempts) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tttl := idx + 1\n\t\taccMap := agg.accMapForTTLLocked(ttl)\n\t\tfor key, group := range groupMTRHopAttempts(attempts) {\n\t\t\tagg.mergeGroupedHopLocked(ttl, accMap, key, group)\n\t\t}\n\t\tmergeUnknownIntoSingleKnown(accMap)\n\t}\n\n\treturn agg.snapshotLocked()\n}\n\n// Reset 清空所有统计数据，用于 r 键重置。\nfunc (agg *MTRAggregator) Reset() {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\tagg.stats = make(map[int]map[string]*mtrHopAccum)\n\tagg.nextOrder = 0\n}\n\n// ClearHop 删除指定 TTL 上的所有聚合数据。\n// 用于 per-hop 调度器中 knownFinalTTL 下调时，擦除旧 finalTTL 的过期统计，\n// 避免 ghost row，同时不会把旧 final 的 Snt 合并到新 final（防止 Snt 膨胀）。\nfunc (agg *MTRAggregator) ClearHop(ttl int) {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\tdelete(agg.stats, ttl)\n}\n\n// MigrateStats 将 fromTTL 上所有累加器迁移合并到 toTTL，然后删除 fromTTL。\n// 用于 knownFinalTTL 下调时把旧 finalTTL 上已入账的 dst-ip 统计搬到新 finalTTL。\n// maxPerHop > 0 时，合并后对每个累加器的 sent/received 做上限裁剪，\n// 保证 Snt 不超过预算。\nfunc (agg *MTRAggregator) MigrateStats(fromTTL, toTTL, maxPerHop int) {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\n\tfromMap := agg.stats[fromTTL]\n\tif len(fromMap) == 0 {\n\t\treturn\n\t}\n\n\ttoMap := agg.accMapForTTLLocked(toTTL)\n\n\tfor key, src := range fromMap {\n\t\tdst := toMap[key]\n\t\tif dst == nil {\n\t\t\tsrc.ttl = toTTL\n\t\t\ttoMap[key] = src\n\t\t\tcontinue\n\t\t}\n\t\tmergeMTRHopAccum(dst, src)\n\t}\n\n\tfor _, acc := range toMap {\n\t\tcapMTRHopAccum(acc, maxPerHop)\n\t}\n\n\tdelete(agg.stats, fromTTL)\n}\n\n// Clone 返回深拷贝的聚合器，用于流式预览（不影响原始数据）。\nfunc (agg *MTRAggregator) Clone() *MTRAggregator {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\n\tc := &MTRAggregator{\n\t\tstats:     make(map[int]map[string]*mtrHopAccum, len(agg.stats)),\n\t\tnextOrder: agg.nextOrder,\n\t}\n\tfor ttl, accMap := range agg.stats {\n\t\tcMap := make(map[string]*mtrHopAccum, len(accMap))\n\t\tfor key, acc := range accMap {\n\t\t\tdup := *acc // 浅拷贝\n\t\t\tdup.mplsSet = make(map[string]struct{}, len(acc.mplsSet))\n\t\t\tfor k := range acc.mplsSet {\n\t\t\t\tdup.mplsSet[k] = struct{}{}\n\t\t\t}\n\t\t\tif acc.geo != nil {\n\t\t\t\tgeoCopy := *acc.geo\n\t\t\t\tdup.geo = &geoCopy\n\t\t\t}\n\t\t\tcMap[key] = &dup\n\t\t}\n\t\tc.stats[ttl] = cMap\n\t}\n\treturn c\n}\n\n// Snapshot 返回当前聚合结果快照。\nfunc (agg *MTRAggregator) Snapshot() []MTRHopStat {\n\tagg.mu.Lock()\n\tdefer agg.mu.Unlock()\n\treturn agg.snapshotLocked()\n}\n\nfunc (agg *MTRAggregator) snapshotLocked() []MTRHopStat {\n\t// 收集 TTL 列表并排序\n\tttls := make([]int, 0, len(agg.stats))\n\tfor ttl := range agg.stats {\n\t\tttls = append(ttls, ttl)\n\t}\n\tsort.Ints(ttls)\n\n\tvar rows []MTRHopStat\n\tfor _, ttl := range ttls {\n\t\taccMap := agg.stats[ttl]\n\t\tif len(accMap) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 按 order 稳定排序\n\t\taccs := make([]*mtrHopAccum, 0, len(accMap))\n\t\tfor _, acc := range accMap {\n\t\t\taccs = append(accs, acc)\n\t\t}\n\t\tsort.SliceStable(accs, func(i, j int) bool {\n\t\t\tif accs[i].order == accs[j].order {\n\t\t\t\treturn accs[i].ip < accs[j].ip\n\t\t\t}\n\t\t\treturn accs[i].order < accs[j].order\n\t\t})\n\n\t\tfor _, acc := range accs {\n\t\t\trows = append(rows, buildMTRHopStat(acc))\n\t\t}\n\t}\n\treturn rows\n}\n\n// mtrUnknownKey 是 timeout / 无地址 hop 的聚合键。\nconst mtrUnknownKey = \"unknown\"\n\nfunc mtrHopKey(ip, host string) string {\n\tip = strings.TrimSpace(ip)\n\thost = strings.TrimSpace(host)\n\tif ip != \"\" {\n\t\treturn \"ip:\" + ip\n\t}\n\tif host != \"\" {\n\t\treturn \"host:\" + strings.ToLower(host)\n\t}\n\treturn mtrUnknownKey\n}\n\n// mergeUnknownIntoSingleKnown 在同一 TTL 的 accMap 中，\n// 如果恰好只有 1 条非 unknown 路径，则将 unknown 累加器归并到该路径，\n// 避免同一跳同时出现 \"(waiting for reply)\" 和真实 IP 两行。\n//\n// 多路径场景（非 unknown ≥ 2 或 == 0）不归并，防止误归因。\nfunc mergeUnknownIntoSingleKnown(accMap map[string]*mtrHopAccum) {\n\tunk, ok := accMap[mtrUnknownKey]\n\tif !ok {\n\t\treturn\n\t}\n\n\t// 收集非 unknown 累加器\n\tvar known *mtrHopAccum\n\tknownCount := 0\n\tfor k, acc := range accMap {\n\t\tif k == mtrUnknownKey {\n\t\t\tcontinue\n\t\t}\n\t\tknown = acc\n\t\tknownCount++\n\t\tif knownCount > 1 {\n\t\t\tbreak // 多路径，不归并\n\t\t}\n\t}\n\tif knownCount != 1 || known == nil {\n\t\treturn\n\t}\n\n\tmergeMTRHopAccum(known, unk)\n\tdelete(accMap, mtrUnknownKey)\n}\n"
  },
  {
    "path": "trace/mtr_stats_helpers.go",
    "content": "package trace\n\nimport (\n\t\"math\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\ntype mtrHopGroup struct {\n\thost     string\n\tip       string\n\tgeo      *ipgeo.IPGeoData\n\tsum      float64\n\tsumSq    float64\n\tlast     float64\n\tbest     float64\n\tworst    float64\n\treceived int\n\tcount    int\n\tmpls     map[string]struct{}\n}\n\nfunc newMTRHopGroup(host, ip string) *mtrHopGroup {\n\treturn &mtrHopGroup{\n\t\thost: host,\n\t\tip:   ip,\n\t\tbest: math.MaxFloat64,\n\t}\n}\n\nfunc groupMTRHopAttempts(attempts []Hop) map[string]*mtrHopGroup {\n\tgroups := make(map[string]*mtrHopGroup)\n\tfor _, attempt := range attempts {\n\t\thost := strings.TrimSpace(attempt.Hostname)\n\t\tip := \"\"\n\t\tif attempt.Address != nil {\n\t\t\tip = strings.TrimSpace(attempt.Address.String())\n\t\t}\n\t\tkey := mtrHopKey(ip, host)\n\t\tgroup := groups[key]\n\t\tif group == nil {\n\t\t\tgroup = newMTRHopGroup(host, ip)\n\t\t\tgroups[key] = group\n\t\t}\n\t\tgroup.includeAttempt(attempt)\n\t}\n\treturn groups\n}\n\nfunc (g *mtrHopGroup) includeAttempt(attempt Hop) {\n\tg.count++\n\tif g.geo == nil && attempt.Geo != nil {\n\t\tg.geo = attempt.Geo\n\t}\n\tmergeMTRLabels(&g.mpls, attempt.MPLS)\n\tif !attempt.Success {\n\t\treturn\n\t}\n\n\trttMs := float64(attempt.RTT) / float64(time.Millisecond)\n\tg.sum += rttMs\n\tg.sumSq += rttMs * rttMs\n\tg.received++\n\tg.last = rttMs\n\tif rttMs > g.worst {\n\t\tg.worst = rttMs\n\t}\n\tif rttMs > 0 && rttMs < g.best {\n\t\tg.best = rttMs\n\t}\n}\n\nfunc (agg *MTRAggregator) accMapForTTLLocked(ttl int) map[string]*mtrHopAccum {\n\taccMap := agg.stats[ttl]\n\tif accMap == nil {\n\t\taccMap = make(map[string]*mtrHopAccum)\n\t\tagg.stats[ttl] = accMap\n\t}\n\treturn accMap\n}\n\nfunc (agg *MTRAggregator) newHopAccum(ttl int, key string) *mtrHopAccum {\n\tacc := &mtrHopAccum{\n\t\tttl:     ttl,\n\t\tkey:     key,\n\t\tbest:    math.MaxFloat64,\n\t\torder:   agg.nextOrder,\n\t\tmplsSet: make(map[string]struct{}),\n\t}\n\tagg.nextOrder++\n\treturn acc\n}\n\nfunc (agg *MTRAggregator) mergeGroupedHopLocked(ttl int, accMap map[string]*mtrHopAccum, key string, group *mtrHopGroup) {\n\tacc := accMap[key]\n\tif acc == nil {\n\t\tacc = agg.newHopAccum(ttl, key)\n\t\taccMap[key] = acc\n\t}\n\tmergeMTRHopGroupIntoAccum(acc, group)\n}\n\nfunc mergeMTRHopGroupIntoAccum(acc *mtrHopAccum, group *mtrHopGroup) {\n\tif group.ip != \"\" {\n\t\tacc.ip = group.ip\n\t}\n\tif group.host != \"\" {\n\t\tacc.host = group.host\n\t}\n\tif group.geo != nil {\n\t\tacc.geo = group.geo\n\t}\n\tacc.sent += group.count\n\tif group.received > 0 {\n\t\tacc.sum += group.sum\n\t\tacc.sumSq += group.sumSq\n\t\tacc.received += group.received\n\t\tacc.last = group.last\n\t\tif group.best > 0 && (acc.best == math.MaxFloat64 || group.best < acc.best) {\n\t\t\tacc.best = group.best\n\t\t}\n\t\tif group.worst > acc.worst {\n\t\t\tacc.worst = group.worst\n\t\t}\n\t}\n\tmergeMTRLabelSet(acc.mplsSet, group.mpls)\n}\n\nfunc mergeMTRHopAccum(dst, src *mtrHopAccum) {\n\tdst.sent += src.sent\n\tdst.received += src.received\n\tif src.received > 0 {\n\t\tdst.sum += src.sum\n\t\tdst.sumSq += src.sumSq\n\t\tdst.last = src.last\n\t\tif src.best > 0 && src.best < dst.best {\n\t\t\tdst.best = src.best\n\t\t}\n\t\tif src.worst > dst.worst {\n\t\t\tdst.worst = src.worst\n\t\t}\n\t}\n\tif dst.geo == nil && src.geo != nil {\n\t\tdst.geo = src.geo\n\t}\n\tif dst.host == \"\" && src.host != \"\" {\n\t\tdst.host = src.host\n\t}\n\tif dst.ip == \"\" && src.ip != \"\" {\n\t\tdst.ip = src.ip\n\t}\n\tmergeMTRLabelSet(dst.mplsSet, src.mplsSet)\n}\n\nfunc mergeMTRLabels(dst *map[string]struct{}, labels []string) {\n\tif len(labels) == 0 {\n\t\treturn\n\t}\n\tif *dst == nil {\n\t\t*dst = make(map[string]struct{})\n\t}\n\tfor _, label := range labels {\n\t\tval := strings.TrimSpace(label)\n\t\tif val != \"\" {\n\t\t\t(*dst)[val] = struct{}{}\n\t\t}\n\t}\n}\n\nfunc mergeMTRLabelSet(dst, src map[string]struct{}) {\n\tfor label := range src {\n\t\tdst[label] = struct{}{}\n\t}\n}\n\nfunc capMTRHopAccum(acc *mtrHopAccum, maxPerHop int) {\n\tif maxPerHop <= 0 {\n\t\treturn\n\t}\n\tif acc.sent > maxPerHop {\n\t\tacc.sent = maxPerHop\n\t}\n\tif acc.received <= acc.sent {\n\t\treturn\n\t}\n\n\tnOrig := float64(acc.received)\n\tnNew := float64(acc.sent)\n\tratio := nNew / nOrig\n\tsumNew := acc.sum * ratio\n\tss := acc.sumSq - (acc.sum*acc.sum)/nOrig\n\tif ss < 0 {\n\t\tss = 0\n\t}\n\n\tvar sumSqNew float64\n\tif nOrig > 1 && nNew > 1 {\n\t\tsumSqNew = ss*(nNew-1)/(nOrig-1) + (sumNew*sumNew)/nNew\n\t} else {\n\t\tsumSqNew = (sumNew * sumNew) / nNew\n\t}\n\n\tacc.sum = sumNew\n\tacc.sumSq = sumSqNew\n\tacc.received = acc.sent\n}\n\nfunc buildMTRHopStat(acc *mtrHopAccum) MTRHopStat {\n\tlossCount := acc.sent - acc.received\n\tlossPct := 0.0\n\tif acc.sent > 0 {\n\t\tlossPct = float64(lossCount) / float64(acc.sent) * 100\n\t}\n\n\tbest := acc.best\n\tif best == math.MaxFloat64 {\n\t\tbest = 0\n\t}\n\n\tavg := 0.0\n\tif acc.received > 0 {\n\t\tavg = acc.sum / float64(acc.received)\n\t}\n\n\tstdev := 0.0\n\tif acc.received > 1 {\n\t\tn := float64(acc.received)\n\t\tvariance := (acc.sumSq - (acc.sum*acc.sum)/n) / (n - 1)\n\t\tif variance > 0 {\n\t\t\tstdev = math.Sqrt(variance)\n\t\t}\n\t}\n\n\tvar mpls []string\n\tif len(acc.mplsSet) > 0 {\n\t\tmpls = make([]string, 0, len(acc.mplsSet))\n\t\tfor label := range acc.mplsSet {\n\t\t\tmpls = append(mpls, label)\n\t\t}\n\t\tsort.Strings(mpls)\n\t}\n\n\treturn MTRHopStat{\n\t\tTTL:      acc.ttl,\n\t\tHost:     acc.host,\n\t\tIP:       acc.ip,\n\t\tLoss:     lossPct,\n\t\tSnt:      acc.sent,\n\t\tLast:     acc.last,\n\t\tAvg:      avg,\n\t\tBest:     best,\n\t\tWrst:     acc.worst,\n\t\tStDev:    stdev,\n\t\tGeo:      acc.geo,\n\t\tMPLS:     mpls,\n\t\tReceived: acc.received,\n\t}\n}\n"
  },
  {
    "path": "trace/mtr_stats_test.go",
    "content": "package trace\n\nimport (\n\t\"math\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\nfunc mkHop(ttl int, ip string, rtt time.Duration) Hop {\n\treturn Hop{\n\t\tSuccess: true,\n\t\tAddress: &net.IPAddr{IP: net.ParseIP(ip)},\n\t\tTTL:     ttl,\n\t\tRTT:     rtt,\n\t}\n}\n\nfunc mkTimeoutHop(ttl int) Hop {\n\treturn Hop{\n\t\tSuccess: false,\n\t\tAddress: nil,\n\t\tTTL:     ttl,\n\t\tRTT:     0,\n\t}\n}\n\nfunc mkResult(hopsByTTL ...[]Hop) *Result {\n\tres := &Result{\n\t\tHops: make([][]Hop, len(hopsByTTL)),\n\t}\n\tcopy(res.Hops, hopsByTTL)\n\treturn res\n}\n\nfunc roundN(v float64, n int) float64 {\n\tpow := math.Pow(10, float64(n))\n\treturn math.Round(v*pow) / pow\n}\n\nfunc TestSinglePath(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\tres1 := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t[]Hop{mkHop(2, \"2.2.2.2\", 20*time.Millisecond)},\n\t)\n\tagg.Update(res1, 1)\n\n\tres2 := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 12*time.Millisecond)},\n\t\t[]Hop{mkHop(2, \"2.2.2.2\", 18*time.Millisecond)},\n\t)\n\tagg.Update(res2, 1)\n\n\tres3 := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 14*time.Millisecond)},\n\t\t[]Hop{mkHop(2, \"2.2.2.2\", 22*time.Millisecond)},\n\t)\n\tstats := agg.Update(res3, 1)\n\n\tif len(stats) != 2 {\n\t\tt.Fatalf(\"expected 2 rows, got %d\", len(stats))\n\t}\n\n\ts := stats[0]\n\tif s.TTL != 1 {\n\t\tt.Errorf(\"TTL: want 1, got %d\", s.TTL)\n\t}\n\tif s.Snt != 3 {\n\t\tt.Errorf(\"Snt: want 3, got %d\", s.Snt)\n\t}\n\tif s.Received != 3 {\n\t\tt.Errorf(\"Received: want 3, got %d\", s.Received)\n\t}\n\tif s.Loss != 0 {\n\t\tt.Errorf(\"Loss: want 0, got %f\", s.Loss)\n\t}\n\tif s.Last != 14 {\n\t\tt.Errorf(\"Last: want 14, got %f\", s.Last)\n\t}\n\tif s.Best != 10 {\n\t\tt.Errorf(\"Best: want 10, got %f\", s.Best)\n\t}\n\tif s.Wrst != 14 {\n\t\tt.Errorf(\"Wrst: want 14, got %f\", s.Wrst)\n\t}\n\tif roundN(s.Avg, 4) != 12 {\n\t\tt.Errorf(\"Avg: want 12, got %f\", s.Avg)\n\t}\n\tif roundN(s.StDev, 4) != 2.0 {\n\t\tt.Errorf(\"StDev: want 2.0, got %f\", s.StDev)\n\t}\n\n\ts2 := stats[1]\n\tif s2.TTL != 2 {\n\t\tt.Errorf(\"TTL: want 2, got %d\", s2.TTL)\n\t}\n\tif s2.Snt != 3 {\n\t\tt.Errorf(\"Snt: want 3, got %d\", s2.Snt)\n\t}\n\tif roundN(s2.Avg, 4) != 20 {\n\t\tt.Errorf(\"Avg: want 20, got %f\", s2.Avg)\n\t}\n\tif roundN(s2.StDev, 4) != 2.0 {\n\t\tt.Errorf(\"StDev: want 2.0, got %f\", s2.StDev)\n\t}\n}\n\nfunc TestMultiPath(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tres1 := mkResult(\n\t\t[]Hop{\n\t\t\tmkHop(1, \"10.0.0.1\", 5*time.Millisecond),\n\t\t\tmkHop(1, \"10.0.0.2\", 8*time.Millisecond),\n\t\t},\n\t)\n\tstats := agg.Update(res1, 2)\n\tif len(stats) != 2 {\n\t\tt.Fatalf(\"expected 2 rows for multipath, got %d\", len(stats))\n\t}\n\tif stats[0].TTL != 1 || stats[1].TTL != 1 {\n\t\tt.Errorf(\"both rows should be TTL=1\")\n\t}\n\tips := map[string]bool{stats[0].IP: true, stats[1].IP: true}\n\tif !ips[\"10.0.0.1\"] || !ips[\"10.0.0.2\"] {\n\t\tt.Errorf(\"expected both IPs\")\n\t}\n\n\tres2 := mkResult([]Hop{mkHop(1, \"10.0.0.1\", 6*time.Millisecond)})\n\tstats = agg.Update(res2, 1)\n\tfor _, s := range stats {\n\t\tif s.IP == \"10.0.0.1\" && s.Snt != 2 {\n\t\t\tt.Errorf(\"10.0.0.1 sent: want 2, got %d\", s.Snt)\n\t\t}\n\t\tif s.IP == \"10.0.0.2\" && s.Snt != 1 {\n\t\t\tt.Errorf(\"10.0.0.2 sent: want 1, got %d\", s.Snt)\n\t\t}\n\t}\n}\n\nfunc TestErrorMix(t *testing.T) {\n\t// 同 TTL: 2 次成功 (IP=1.1.1.1) + 1 次 timeout。\n\t// 单路径归并后应只剩 1 行，Snt=3，Received=2，Loss≈33.3%。\n\tagg := NewMTRAggregator()\n\tres := mkResult(\n\t\t[]Hop{\n\t\t\tmkHop(1, \"1.1.1.1\", 10*time.Millisecond),\n\t\t\tmkHop(1, \"1.1.1.1\", 20*time.Millisecond),\n\t\t\tmkTimeoutHop(1),\n\t\t},\n\t)\n\tstats := agg.Update(res, 3)\n\tif len(stats) != 1 {\n\t\tt.Fatalf(\"expected 1 row after merge, got %d\", len(stats))\n\t}\n\ts := stats[0]\n\tif s.IP != \"1.1.1.1\" {\n\t\tt.Errorf(\"IP: want 1.1.1.1, got %q\", s.IP)\n\t}\n\tif s.Snt != 3 {\n\t\tt.Errorf(\"Snt: want 3, got %d\", s.Snt)\n\t}\n\tif s.Received != 2 {\n\t\tt.Errorf(\"Received: want 2, got %d\", s.Received)\n\t}\n\twantLoss := roundN(100.0/3.0, 1) // 33.3\n\tgotLoss := roundN(s.Loss, 1)\n\tif gotLoss != wantLoss {\n\t\tt.Errorf(\"Loss: want %.1f%%, got %.1f%%\", wantLoss, gotLoss)\n\t}\n}\n\nfunc TestGeoPropagation(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tgeoData := &ipgeo.IPGeoData{Country: \"US\", City: \"San Francisco\"}\n\thop := mkHop(1, \"1.1.1.1\", 10*time.Millisecond)\n\thop.Geo = geoData\n\tres := mkResult([]Hop{hop})\n\tstats := agg.Update(res, 1)\n\tif len(stats) != 1 {\n\t\tt.Fatalf(\"expected 1 row, got %d\", len(stats))\n\t}\n\tif stats[0].Geo == nil {\n\t\tt.Fatal(\"expected geo data, got nil\")\n\t}\n\tif stats[0].Geo.Country != \"US\" {\n\t\tt.Errorf(\"Country: want US, got %s\", stats[0].Geo.Country)\n\t}\n}\n\nfunc TestStDevSingleSample(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tres := mkResult([]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)})\n\tstats := agg.Update(res, 1)\n\tif len(stats) != 1 {\n\t\tt.Fatalf(\"expected 1 row, got %d\", len(stats))\n\t}\n\tif stats[0].StDev != 0 {\n\t\tt.Errorf(\"StDev with 1 sample: want 0, got %f\", stats[0].StDev)\n\t}\n}\n\nfunc TestAllTimeout(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tres := mkResult([]Hop{mkTimeoutHop(1), mkTimeoutHop(1), mkTimeoutHop(1)})\n\tstats := agg.Update(res, 3)\n\tif len(stats) != 1 {\n\t\tt.Fatalf(\"expected 1 row, got %d\", len(stats))\n\t}\n\ts := stats[0]\n\tif s.Snt != 3 {\n\t\tt.Errorf(\"Snt: want 3, got %d\", s.Snt)\n\t}\n\tif s.Received != 0 {\n\t\tt.Errorf(\"Received: want 0, got %d\", s.Received)\n\t}\n\tif s.Loss != 100 {\n\t\tt.Errorf(\"Loss: want 100, got %f\", s.Loss)\n\t}\n\tif s.Avg != 0 || s.Best != 0 || s.Wrst != 0 || s.StDev != 0 {\n\t\tt.Errorf(\"all RTT should be 0 for all-timeout\")\n\t}\n}\n\nfunc TestUpdate_NilResult(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tif got := agg.Update(nil, 1); got != nil {\n\t\tt.Fatalf(\"Update(nil, 1) = %v, want nil\", got)\n\t}\n\tif got := agg.Update(&Result{}, 1); got != nil {\n\t\tt.Fatalf(\"Update(empty result, 1) = %v, want nil\", got)\n\t}\n}\n\nfunc TestHostnamePropagation(t *testing.T) {\n\tagg := NewMTRAggregator()\n\thop := mkHop(1, \"1.1.1.1\", 10*time.Millisecond)\n\thop.Hostname = \"one.one.one.one\"\n\tres := mkResult([]Hop{hop})\n\tstats := agg.Update(res, 1)\n\tif len(stats) != 1 {\n\t\tt.Fatalf(\"expected 1 row, got %d\", len(stats))\n\t}\n\tif stats[0].Host != \"one.one.one.one\" {\n\t\tt.Errorf(\"Host: want one.one.one.one, got %s\", stats[0].Host)\n\t}\n}\n\nfunc TestMTRAggregator_Reset(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tres := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t\t[]Hop{mkHop(2, \"2.2.2.2\", 20*time.Millisecond)},\n\t)\n\tagg.Update(res, 1)\n\tagg.Update(res, 1)\n\n\t// Reset 后 Snapshot 应为空\n\tagg.Reset()\n\tsnap := agg.Snapshot()\n\tif len(snap) != 0 {\n\t\tt.Fatalf(\"expected 0 rows after Reset, got %d\", len(snap))\n\t}\n\n\t// Reset 后继续 Update 应正常工作，Snt 从 1 重新开始\n\tstats := agg.Update(res, 1)\n\tif len(stats) != 2 {\n\t\tt.Fatalf(\"expected 2 rows after re-update, got %d\", len(stats))\n\t}\n\tif stats[0].Snt != 1 {\n\t\tt.Errorf(\"Snt after reset: want 1, got %d\", stats[0].Snt)\n\t}\n}\n\nfunc TestMTRAggregator_CloneIsolation(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tres1 := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 10*time.Millisecond)},\n\t)\n\tagg.Update(res1, 1)\n\n\t// Clone\n\tclone := agg.Clone()\n\n\t// 修改原始聚合器\n\tres2 := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 20*time.Millisecond)},\n\t)\n\tagg.Update(res2, 1)\n\n\t// Clone 快照应只有 1 次发送\n\tcloneSnap := clone.Snapshot()\n\tif len(cloneSnap) != 1 {\n\t\tt.Fatalf(\"clone: expected 1 row, got %d\", len(cloneSnap))\n\t}\n\tif cloneSnap[0].Snt != 1 {\n\t\tt.Errorf(\"clone Snt: want 1, got %d\", cloneSnap[0].Snt)\n\t}\n\n\t// 原始聚合器应有 2 次发送\n\torigSnap := agg.Snapshot()\n\tif origSnap[0].Snt != 2 {\n\t\tt.Errorf(\"original Snt: want 2, got %d\", origSnap[0].Snt)\n\t}\n\n\t// 修改 Clone 不影响原始\n\tres3 := mkResult(\n\t\t[]Hop{mkHop(1, \"1.1.1.1\", 30*time.Millisecond)},\n\t)\n\tclone.Update(res3, 1)\n\tcloneSnap = clone.Snapshot()\n\tif cloneSnap[0].Snt != 2 {\n\t\tt.Errorf(\"clone Snt after update: want 2, got %d\", cloneSnap[0].Snt)\n\t}\n\torigSnap = agg.Snapshot()\n\tif origSnap[0].Snt != 2 {\n\t\tt.Errorf(\"original Snt should still be 2, got %d\", origSnap[0].Snt)\n\t}\n}\n\n// TestUnknownMergedAfterLaterReply_SinglePath 验证跨轮次归并：\n// Round1: TTL1 timeout → Round2: TTL1 reply(IP=A)。\n// 结果应只有 A 一行，Snt=2，Received=1，Loss=50%。\nfunc TestUnknownMergedAfterLaterReply_SinglePath(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Round 1: timeout\n\tr1 := mkResult([]Hop{mkTimeoutHop(1)})\n\tstats := agg.Update(r1, 1)\n\tif len(stats) != 1 {\n\t\tt.Fatalf(\"round1: expected 1 row, got %d\", len(stats))\n\t}\n\n\t// Round 2: real reply\n\tr2 := mkResult([]Hop{mkHop(1, \"10.0.0.1\", 15*time.Millisecond)})\n\tstats = agg.Update(r2, 1)\n\tif len(stats) != 1 {\n\t\tt.Fatalf(\"round2: expected 1 row after merge, got %d\", len(stats))\n\t}\n\ts := stats[0]\n\tif s.IP != \"10.0.0.1\" {\n\t\tt.Errorf(\"IP: want 10.0.0.1, got %q\", s.IP)\n\t}\n\tif s.Snt != 2 {\n\t\tt.Errorf(\"Snt: want 2, got %d\", s.Snt)\n\t}\n\tif s.Received != 1 {\n\t\tt.Errorf(\"Received: want 1, got %d\", s.Received)\n\t}\n\tif s.Loss != 50 {\n\t\tt.Errorf(\"Loss: want 50, got %f\", s.Loss)\n\t}\n}\n\n// TestUnknownPreserved_Multipath 验证多路径下 unknown 不被归并。\nfunc TestUnknownPreserved_Multipath(t *testing.T) {\n\tagg := NewMTRAggregator()\n\tres := mkResult(\n\t\t[]Hop{\n\t\t\tmkHop(1, \"10.0.0.1\", 5*time.Millisecond),\n\t\t\tmkHop(1, \"10.0.0.2\", 8*time.Millisecond),\n\t\t\tmkTimeoutHop(1),\n\t\t},\n\t)\n\tstats := agg.Update(res, 3)\n\n\t// 应有 3 行：10.0.0.1、10.0.0.2、unknown\n\tif len(stats) != 3 {\n\t\tt.Fatalf(\"expected 3 rows for multipath+timeout, got %d\", len(stats))\n\t}\n\tips := map[string]bool{}\n\thasUnknown := false\n\tfor _, s := range stats {\n\t\tif s.IP != \"\" {\n\t\t\tips[s.IP] = true\n\t\t}\n\t\tif s.IP == \"\" && s.Host == \"\" {\n\t\t\thasUnknown = true\n\t\t}\n\t}\n\tif !ips[\"10.0.0.1\"] || !ips[\"10.0.0.2\"] {\n\t\tt.Error(\"expected both known IPs\")\n\t}\n\tif !hasUnknown {\n\t\tt.Error(\"expected unknown row preserved in multipath scenario\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// ClearHop 测试\n// ---------------------------------------------------------------------------\n\nfunc TestMTRAggregator_ClearHop_RemovesData(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Record stats at TTL 5\n\tres := mkResult(\n\t\tnil, nil, nil, nil,\n\t\t[]Hop{mkHop(5, \"10.0.0.99\", 5*time.Millisecond)},\n\t)\n\tagg.Update(res, 1)\n\n\t// Verify TTL 5 exists\n\tsnap := agg.Snapshot()\n\tfound := false\n\tfor _, s := range snap {\n\t\tif s.TTL == 5 {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatal(\"expected TTL 5 in snapshot before ClearHop\")\n\t}\n\n\t// Clear TTL 5\n\tagg.ClearHop(5)\n\n\t// Verify TTL 5 is gone\n\tsnap = agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 5 {\n\t\t\tt.Error(\"TTL 5 should not exist after ClearHop\")\n\t\t}\n\t}\n}\n\nfunc TestMTRAggregator_ClearHop_NoopIfMissing(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Record stats at TTL 3 only\n\tres := mkResult(\n\t\tnil, nil,\n\t\t[]Hop{mkHop(3, \"10.0.0.3\", 3*time.Millisecond)},\n\t)\n\tagg.Update(res, 1)\n\n\t// Clear non-existent TTL — should not panic\n\tagg.ClearHop(99)\n\n\t// TTL 3 should still exist\n\tsnap := agg.Snapshot()\n\tfound := false\n\tfor _, s := range snap {\n\t\tif s.TTL == 3 {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Error(\"TTL 3 should still exist after clearing non-existent TTL\")\n\t}\n}\n\nfunc TestMTRAggregator_ClearHop_DoesNotAffectOtherTTLs(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Record stats at TTLs 3 and 5\n\tres := mkResult(\n\t\tnil, nil,\n\t\t[]Hop{mkHop(3, \"10.0.0.3\", 3*time.Millisecond)},\n\t\tnil,\n\t\t[]Hop{mkHop(5, \"10.0.0.5\", 5*time.Millisecond)},\n\t)\n\tagg.Update(res, 1)\n\n\t// Clear TTL 5\n\tagg.ClearHop(5)\n\n\t// TTL 3 should still have its data\n\tsnap := agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 3 {\n\t\t\tif s.IP != \"10.0.0.3\" {\n\t\t\t\tt.Errorf(\"TTL 3 IP = %q, want 10.0.0.3\", s.IP)\n\t\t\t}\n\t\t\tif s.Snt != 1 {\n\t\t\t\tt.Errorf(\"TTL 3 Snt = %d, want 1\", s.Snt)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"TTL 3 not found in snapshot after clearing TTL 5\")\n}\n\n// ---------------------------------------------------------------------------\n// MigrateStats 测试\n// ---------------------------------------------------------------------------\n\nfunc TestMTRAggregator_MigrateStats_MovesToNewTTL(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Record stats at TTL 12\n\tres := mkResult(\n\t\tnil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,\n\t\t[]Hop{mkHop(12, \"8.8.8.8\", 10*time.Millisecond)},\n\t)\n\tagg.Update(res, 1)\n\n\t// Verify TTL 12 has data\n\tsnap := agg.Snapshot()\n\thasTTL12 := false\n\tfor _, s := range snap {\n\t\tif s.TTL == 12 && s.IP == \"8.8.8.8\" {\n\t\t\thasTTL12 = true\n\t\t}\n\t}\n\tif !hasTTL12 {\n\t\tt.Fatal(\"expected stats at TTL 12 before migration\")\n\t}\n\n\t// Migrate TTL 12 → TTL 7\n\tagg.MigrateStats(12, 7, 0) // maxPerHop=0 → no cap\n\n\t// TTL 12 should be gone, TTL 7 should have the data\n\tsnap = agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 12 {\n\t\t\tt.Errorf(\"TTL 12 should have been removed after migration, found IP=%s\", s.IP)\n\t\t}\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" {\n\t\t\tif s.Snt != 1 {\n\t\t\t\tt.Errorf(\"TTL 7: expected Snt=1, got %d\", s.Snt)\n\t\t\t}\n\t\t\treturn // success\n\t\t}\n\t}\n\tt.Error(\"TTL 7 should have migrated data from TTL 12\")\n}\n\nfunc TestMTRAggregator_MigrateStats_MergesIntoExisting(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Record 2 probes at TTL 7 (the new final)\n\tfor i := 0; i < 2; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[6] = []Hop{mkHop(7, \"8.8.8.8\", 5*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// Record 3 probes at TTL 12 (the old final)\n\tfor i := 0; i < 3; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[11] = []Hop{mkHop(12, \"8.8.8.8\", 10*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// Migrate 12 → 7 without cap\n\tagg.MigrateStats(12, 7, 0)\n\n\tsnap := agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 12 {\n\t\t\tt.Error(\"TTL 12 should be gone after migration\")\n\t\t}\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" {\n\t\t\t// Merged: 2 (existing) + 3 (migrated) = 5\n\t\t\tif s.Snt != 5 {\n\t\t\t\tt.Errorf(\"TTL 7: expected Snt=5 (2+3), got %d\", s.Snt)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"TTL 7 should have merged data\")\n}\n\nfunc TestMTRAggregator_MigrateStats_NoopIfFromEmpty(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Record at TTL 7\n\tres := &Result{Hops: make([][]Hop, 8)}\n\tres.Hops[6] = []Hop{mkHop(7, \"8.8.8.8\", 5*time.Millisecond)}\n\tagg.Update(res, 1)\n\n\t// Migrate from non-existent TTL 12 — should be no-op\n\tagg.MigrateStats(12, 7, 0)\n\n\tsnap := agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" && s.Snt == 1 {\n\t\t\treturn // unchanged, correct\n\t\t}\n\t}\n\tt.Error(\"TTL 7 data should be unchanged after noop migration\")\n}\n\nfunc TestMTRAggregator_MigrateStats_CapsAtMaxPerHop(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Record 2 probes at TTL 7\n\tfor i := 0; i < 2; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[6] = []Hop{mkHop(7, \"8.8.8.8\", 5*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// Record 3 probes at TTL 12\n\tfor i := 0; i < 3; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[11] = []Hop{mkHop(12, \"8.8.8.8\", 10*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// Migrate with maxPerHop=3: merged total would be 5, capped to 3.\n\tagg.MigrateStats(12, 7, 3)\n\n\tsnap := agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 12 {\n\t\t\tt.Error(\"TTL 12 should be gone after migration\")\n\t\t}\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" {\n\t\t\tif s.Snt != 3 {\n\t\t\t\tt.Errorf(\"TTL 7: expected Snt=3 (capped from 5), got %d\", s.Snt)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"TTL 7 should have capped merged data\")\n}\n\nfunc TestMTRAggregator_MigrateStats_CapsReceived(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// 2 sent, 2 received at TTL 7\n\tfor i := 0; i < 2; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[6] = []Hop{mkHop(7, \"8.8.8.8\", 5*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// 3 sent, 3 received at TTL 12\n\tfor i := 0; i < 3; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[11] = []Hop{mkHop(12, \"8.8.8.8\", 10*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// maxPerHop=2: sent capped to 2, received capped to min(5,2)=2\n\tagg.MigrateStats(12, 7, 2)\n\n\tsnap := agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" {\n\t\t\tif s.Snt != 2 {\n\t\t\t\tt.Errorf(\"TTL 7: expected Snt=2 (capped), got %d\", s.Snt)\n\t\t\t}\n\t\t\t// Loss should be 0% (received capped to sent=2)\n\t\t\tif s.Loss > 0.01 {\n\t\t\t\tt.Errorf(\"TTL 7: expected Loss ~0%%, got %.2f%%\", s.Loss)\n\t\t\t}\n\t\t\t// Avg must stay consistent after proportional RTT scaling.\n\t\t\t// Pre-merge: 2×5ms + 3×10ms = 40ms total over 5 samples → avg = 8ms.\n\t\t\t// Proportional scaling preserves avg even after cap.\n\t\t\texpectedAvg := 8.0 // (2*5 + 3*10) / 5 = 8ms\n\t\t\tif s.Avg < expectedAvg-0.1 || s.Avg > expectedAvg+0.1 {\n\t\t\t\tt.Errorf(\"TTL 7: expected Avg ~%.1fms (proportional), got %.4fms\", expectedAvg, s.Avg)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"TTL 7 should have capped data\")\n}\n\nfunc TestMTRAggregator_MigrateStats_CapsPreservesAvgStDev(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// 3 probes at TTL 7: all 4ms\n\tfor i := 0; i < 3; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[6] = []Hop{mkHop(7, \"8.8.8.8\", 4*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// 3 probes at TTL 12: all 4ms (same RTT for zero-variance check)\n\tfor i := 0; i < 3; i++ {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[11] = []Hop{mkHop(12, \"8.8.8.8\", 4*time.Millisecond)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// Pre-cap: 6 probes all 4ms → avg=4, stdev=0\n\t// After cap to 3: avg must still be 4, stdev must still be 0.\n\tagg.MigrateStats(12, 7, 3)\n\n\tsnap := agg.Snapshot()\n\tfor _, s := range snap {\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" {\n\t\t\tif s.Snt != 3 {\n\t\t\t\tt.Errorf(\"expected Snt=3, got %d\", s.Snt)\n\t\t\t}\n\t\t\tif s.Avg < 3.9 || s.Avg > 4.1 {\n\t\t\t\tt.Errorf(\"expected Avg ~4.0ms, got %.4fms\", s.Avg)\n\t\t\t}\n\t\t\tif s.StDev > 0.1 {\n\t\t\t\tt.Errorf(\"expected StDev ~0, got %.4f\", s.StDev)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"expected TTL 7 data\")\n}\nfunc TestMTRAggregator_MigrateStats_CapsPreservesNonZeroStDev(t *testing.T) {\n\tagg := NewMTRAggregator()\n\n\t// Build a merged accumulator with mixed RTTs and verify Avg + StDev\n\t// are preserved when received is capped.\n\t//\n\t// TTL 7: 2ms, 4ms (2 probes)\n\t// TTL 12: 6ms, 8ms, 10ms (3 probes)\n\t// Merged: [2, 4, 6, 8, 10] → 5 probes\n\t//   avg = 30/5 = 6.0\n\t//   sample var = ((2-6)² + (4-6)² + (6-6)² + (8-6)² + (10-6)²) / 4\n\t//             = (16+4+0+4+16) / 4 = 10.0\n\t//   stdev = sqrt(10) ≈ 3.1623\n\trtts7 := []time.Duration{2 * time.Millisecond, 4 * time.Millisecond}\n\tfor _, rtt := range rtts7 {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[6] = []Hop{mkHop(7, \"8.8.8.8\", rtt)}\n\t\tagg.Update(res, 1)\n\t}\n\trtts12 := []time.Duration{6 * time.Millisecond, 8 * time.Millisecond, 10 * time.Millisecond}\n\tfor _, rtt := range rtts12 {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[11] = []Hop{mkHop(12, \"8.8.8.8\", rtt)}\n\t\tagg.Update(res, 1)\n\t}\n\n\t// Snapshot before cap to get the \"ground truth\" Avg & StDev.\n\tagg.MigrateStats(12, 7, 0) // merge without cap first\n\n\tsnapBefore := agg.Snapshot()\n\tvar avgBefore, stdevBefore float64\n\tfor _, s := range snapBefore {\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" {\n\t\t\tavgBefore = s.Avg\n\t\t\tstdevBefore = s.StDev\n\t\t}\n\t}\n\tif avgBefore < 5.9 || avgBefore > 6.1 {\n\t\tt.Fatalf(\"pre-cap Avg wrong: got %.4f, expected ~6.0\", avgBefore)\n\t}\n\n\t// Now build the same scenario fresh, but cap to 3.\n\tagg2 := NewMTRAggregator()\n\tfor _, rtt := range rtts7 {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[6] = []Hop{mkHop(7, \"8.8.8.8\", rtt)}\n\t\tagg2.Update(res, 1)\n\t}\n\tfor _, rtt := range rtts12 {\n\t\tres := &Result{Hops: make([][]Hop, 12)}\n\t\tres.Hops[11] = []Hop{mkHop(12, \"8.8.8.8\", rtt)}\n\t\tagg2.Update(res, 1)\n\t}\n\n\tagg2.MigrateStats(12, 7, 3) // cap received from 5 → 3\n\n\tsnapAfter := agg2.Snapshot()\n\tfor _, s := range snapAfter {\n\t\tif s.TTL == 7 && s.IP == \"8.8.8.8\" {\n\t\t\tif s.Snt != 3 {\n\t\t\t\tt.Errorf(\"expected Snt=3, got %d\", s.Snt)\n\t\t\t}\n\t\t\t// Avg must be preserved.\n\t\t\tif s.Avg < avgBefore-0.1 || s.Avg > avgBefore+0.1 {\n\t\t\t\tt.Errorf(\"Avg drifted: expected ~%.4f, got %.4f\", avgBefore, s.Avg)\n\t\t\t}\n\t\t\t// StDev must be preserved (non-zero variance scenario).\n\t\t\tif stdevBefore > 0.1 {\n\t\t\t\tif s.StDev < stdevBefore-0.15 || s.StDev > stdevBefore+0.15 {\n\t\t\t\t\tt.Errorf(\"StDev drifted: expected ~%.4f, got %.4f\", stdevBefore, s.StDev)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Error(\"expected TTL 7 data after capped migration\")\n}\n"
  },
  {
    "path": "trace/mtu/decode.go",
    "content": "package mtu\n\nimport (\n\t\"encoding/binary\"\n\t\"net\"\n\t\"time\"\n\n\t\"golang.org/x/net/icmp\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n)\n\nconst probePayloadMinLen = 8\n\ntype probeResponse struct {\n\tEvent Event\n\tIP    net.IP\n\tRTT   time.Duration\n\tPMTU  int\n}\n\nfunc buildProbePayload(size int) []byte {\n\tif size < probePayloadMinLen {\n\t\tsize = probePayloadMinLen\n\t}\n\treturn make([]byte, size)\n}\n\nfunc parseICMPProbeResult(ipVersion int, raw []byte, peerIP, dstIP net.IP, dstPort, srcPort int) (probeResponse, bool) {\n\tprotocol := 1\n\tif ipVersion == 6 {\n\t\tprotocol = 58\n\t}\n\trm, err := icmp.ParseMessage(protocol, raw)\n\tif err != nil {\n\t\treturn probeResponse{}, false\n\t}\n\n\tvar (\n\t\tevent Event\n\t\tdata  []byte\n\t\tpmtu  int\n\t)\n\n\tswitch ipVersion {\n\tcase 4:\n\t\tswitch rm.Type {\n\t\tcase ipv4.ICMPTypeTimeExceeded:\n\t\t\tbody, ok := rm.Body.(*icmp.TimeExceeded)\n\t\t\tif !ok || body == nil {\n\t\t\t\treturn probeResponse{}, false\n\t\t\t}\n\t\t\tevent = EventTimeExceeded\n\t\t\tdata = body.Data\n\t\tcase ipv4.ICMPTypeDestinationUnreachable:\n\t\t\tbody, ok := rm.Body.(*icmp.DstUnreach)\n\t\t\tif !ok || body == nil {\n\t\t\t\treturn probeResponse{}, false\n\t\t\t}\n\t\t\tdata = body.Data\n\t\t\tif len(raw) >= 8 && raw[1] == 4 {\n\t\t\t\tevent = EventFragNeeded\n\t\t\t\tpmtu = int(binary.BigEndian.Uint16(raw[6:8]))\n\t\t\t} else if peerIP != nil && peerIP.Equal(dstIP) {\n\t\t\t\tevent = EventDestination\n\t\t\t} else {\n\t\t\t\treturn probeResponse{}, false\n\t\t\t}\n\t\tdefault:\n\t\t\treturn probeResponse{}, false\n\t\t}\n\tcase 6:\n\t\tswitch rm.Type {\n\t\tcase ipv6.ICMPTypeTimeExceeded:\n\t\t\tbody, ok := rm.Body.(*icmp.TimeExceeded)\n\t\t\tif !ok || body == nil {\n\t\t\t\treturn probeResponse{}, false\n\t\t\t}\n\t\t\tevent = EventTimeExceeded\n\t\t\tdata = body.Data\n\t\tcase ipv6.ICMPTypePacketTooBig:\n\t\t\tbody, ok := rm.Body.(*icmp.PacketTooBig)\n\t\t\tif !ok || body == nil {\n\t\t\t\treturn probeResponse{}, false\n\t\t\t}\n\t\t\tevent = EventPacketTooBig\n\t\t\tdata = body.Data\n\t\t\tpmtu = body.MTU\n\t\tcase ipv6.ICMPTypeDestinationUnreachable:\n\t\t\tbody, ok := rm.Body.(*icmp.DstUnreach)\n\t\t\tif !ok || body == nil {\n\t\t\t\treturn probeResponse{}, false\n\t\t\t}\n\t\t\tif peerIP == nil || !peerIP.Equal(dstIP) {\n\t\t\t\treturn probeResponse{}, false\n\t\t\t}\n\t\t\tevent = EventDestination\n\t\t\tdata = body.Data\n\t\tdefault:\n\t\t\treturn probeResponse{}, false\n\t\t}\n\tdefault:\n\t\treturn probeResponse{}, false\n\t}\n\n\tif !matchesEmbeddedUDP(data, ipVersion, dstIP, dstPort, srcPort) {\n\t\treturn probeResponse{}, false\n\t}\n\n\treturn probeResponse{\n\t\tEvent: event,\n\t\tIP:    peerIP,\n\t\tPMTU:  pmtu,\n\t}, true\n}\n\nfunc matchesEmbeddedUDP(data []byte, ipVersion int, dstIP net.IP, dstPort, srcPort int) bool {\n\tpacket, ok := parseEmbeddedUDPPacket(data, ipVersion)\n\tif !ok {\n\t\treturn false\n\t}\n\treturn packet.dstIP.Equal(dstIP) &&\n\t\tpacket.srcPort == srcPort &&\n\t\tpacket.dstPort == dstPort\n}\n\ntype embeddedUDPPacket struct {\n\tdstIP   net.IP\n\tsrcPort int\n\tdstPort int\n}\n\nfunc parseEmbeddedUDPPacket(data []byte, ipVersion int) (embeddedUDPPacket, bool) {\n\tswitch ipVersion {\n\tcase 4:\n\t\tif len(data) < 28 || data[0]>>4 != 4 {\n\t\t\treturn embeddedUDPPacket{}, false\n\t\t}\n\t\tihl := int(data[0]&0x0f) * 4\n\t\tif ihl < 20 || len(data) < ihl+8 {\n\t\t\treturn embeddedUDPPacket{}, false\n\t\t}\n\t\tif data[9] != 17 {\n\t\t\treturn embeddedUDPPacket{}, false\n\t\t}\n\t\treturn parseEmbeddedUDPFromOffsets(data, ihl, net.IP(data[16:20]))\n\tcase 6:\n\t\tif len(data) < 48 || data[0]>>4 != 6 {\n\t\t\treturn embeddedUDPPacket{}, false\n\t\t}\n\t\treturn parseEmbeddedIPv6UDP(data)\n\tdefault:\n\t\treturn embeddedUDPPacket{}, false\n\t}\n}\n\nfunc parseEmbeddedIPv6UDP(data []byte) (embeddedUDPPacket, bool) {\n\tconst ipv6HeaderLen = 40\n\n\tnextHeader := data[6]\n\toffset := ipv6HeaderLen\n\tdstIP := net.IP(data[24:40])\n\n\tfor {\n\t\tswitch nextHeader {\n\t\tcase 17:\n\t\t\treturn parseEmbeddedUDPFromOffsets(data, offset, dstIP)\n\t\tcase 0, 43, 60:\n\t\t\tif len(data) < offset+2 {\n\t\t\t\treturn embeddedUDPPacket{}, false\n\t\t\t}\n\t\t\tnextHeader = data[offset]\n\t\t\thdrLen := (int(data[offset+1]) + 1) * 8\n\t\t\tif hdrLen < 8 || len(data) < offset+hdrLen {\n\t\t\t\treturn embeddedUDPPacket{}, false\n\t\t\t}\n\t\t\toffset += hdrLen\n\t\tcase 44:\n\t\t\tif len(data) < offset+8 {\n\t\t\t\treturn embeddedUDPPacket{}, false\n\t\t\t}\n\t\t\tnextHeader = data[offset]\n\t\t\toffset += 8\n\t\tcase 51:\n\t\t\tif len(data) < offset+2 {\n\t\t\t\treturn embeddedUDPPacket{}, false\n\t\t\t}\n\t\t\tnextHeader = data[offset]\n\t\t\thdrLen := (int(data[offset+1]) + 2) * 4\n\t\t\tif hdrLen < 8 || len(data) < offset+hdrLen {\n\t\t\t\treturn embeddedUDPPacket{}, false\n\t\t\t}\n\t\t\toffset += hdrLen\n\t\tcase 50:\n\t\t\treturn embeddedUDPPacket{}, false\n\t\tdefault:\n\t\t\treturn embeddedUDPPacket{}, false\n\t\t}\n\t}\n}\n\nfunc parseEmbeddedUDPFromOffsets(data []byte, udpOffset int, dstIP net.IP) (embeddedUDPPacket, bool) {\n\tif len(data) < udpOffset+8 {\n\t\treturn embeddedUDPPacket{}, false\n\t}\n\treturn embeddedUDPPacket{\n\t\tdstIP:   append(net.IP(nil), dstIP...),\n\t\tsrcPort: int(binary.BigEndian.Uint16(data[udpOffset : udpOffset+2])),\n\t\tdstPort: int(binary.BigEndian.Uint16(data[udpOffset+2 : udpOffset+4])),\n\t}, true\n}\n"
  },
  {
    "path": "trace/mtu/decode_test.go",
    "content": "package mtu\n\nimport (\n\t\"encoding/binary\"\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/google/gopacket\"\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/net/icmp\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n)\n\nfunc TestParseICMPProbeResultIPv4FragNeeded(t *testing.T) {\n\tdstIP := net.ParseIP(\"203.0.113.9\")\n\tpeerIP := net.ParseIP(\"198.51.100.1\")\n\tinner := mustSerializeIPv4UDP(t, net.ParseIP(\"192.0.2.10\"), dstIP, 40000, 33494, buildProbePayload(64))\n\n\tmsg := icmp.Message{\n\t\tType: ipv4.ICMPTypeDestinationUnreachable,\n\t\tCode: 4,\n\t\tBody: &icmp.DstUnreach{Data: inner},\n\t}\n\traw, err := msg.Marshal(nil)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal icmp: %v\", err)\n\t}\n\tbinary.BigEndian.PutUint16(raw[6:8], 1400)\n\n\tresp, ok := parseICMPProbeResult(4, raw, peerIP, dstIP, 33494, 40000)\n\tif !ok {\n\t\tt.Fatal(\"expected frag-needed response to match\")\n\t}\n\tif resp.Event != EventFragNeeded {\n\t\tt.Fatalf(\"event = %q, want %q\", resp.Event, EventFragNeeded)\n\t}\n\tif resp.PMTU != 1400 {\n\t\tt.Fatalf(\"pmtu = %d, want 1400\", resp.PMTU)\n\t}\n\tif !resp.IP.Equal(peerIP) {\n\t\tt.Fatalf(\"peer = %v, want %v\", resp.IP, peerIP)\n\t}\n}\n\nfunc TestParseICMPProbeResultIPv6PacketTooBig(t *testing.T) {\n\tdstIP := net.ParseIP(\"2001:db8::9\")\n\tpeerIP := net.ParseIP(\"2001:db8::1\")\n\tinner := mustSerializeIPv6UDP(t, net.ParseIP(\"2001:db8::10\"), dstIP, 40001, 33494, buildProbePayload(80))\n\n\tmsg := icmp.Message{\n\t\tType: ipv6.ICMPTypePacketTooBig,\n\t\tCode: 0,\n\t\tBody: &icmp.PacketTooBig{MTU: 1280, Data: inner},\n\t}\n\traw, err := msg.Marshal(nil)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal icmpv6: %v\", err)\n\t}\n\n\tresp, ok := parseICMPProbeResult(6, raw, peerIP, dstIP, 33494, 40001)\n\tif !ok {\n\t\tt.Fatal(\"expected packet-too-big response to match\")\n\t}\n\tif resp.Event != EventPacketTooBig {\n\t\tt.Fatalf(\"event = %q, want %q\", resp.Event, EventPacketTooBig)\n\t}\n\tif resp.PMTU != 1280 {\n\t\tt.Fatalf(\"pmtu = %d, want 1280\", resp.PMTU)\n\t}\n}\n\nfunc TestParseICMPProbeResultIPv4MatchesMinimumQuotedUDPHeader(t *testing.T) {\n\tdstIP := net.ParseIP(\"203.0.113.9\")\n\tpeerIP := net.ParseIP(\"198.51.100.1\")\n\tinner := mustSerializeIPv4UDP(t, net.ParseIP(\"192.0.2.10\"), dstIP, 40000, 33494, nil)\n\tinner = inner[:28]\n\n\tmsg := icmp.Message{\n\t\tType: ipv4.ICMPTypeDestinationUnreachable,\n\t\tCode: 4,\n\t\tBody: &icmp.DstUnreach{Data: inner},\n\t}\n\traw, err := msg.Marshal(nil)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal icmp: %v\", err)\n\t}\n\tbinary.BigEndian.PutUint16(raw[6:8], 1500)\n\n\tresp, ok := parseICMPProbeResult(4, raw, peerIP, dstIP, 33494, 40000)\n\tif !ok {\n\t\tt.Fatal(\"expected minimal quoted udp header to match\")\n\t}\n\tif resp.PMTU != 1500 {\n\t\tt.Fatalf(\"pmtu = %d, want 1500\", resp.PMTU)\n\t}\n}\n\nfunc TestParseEmbeddedUDPPacketIPv6WithExtensionHeaders(t *testing.T) {\n\tdstIP := net.ParseIP(\"2001:db8::9\")\n\tdata := make([]byte, 56)\n\tdata[0] = 6 << 4\n\tdata[6] = 0\n\tcopy(data[24:40], dstIP.To16())\n\n\tdata[40] = 17\n\tdata[41] = 0\n\tbinary.BigEndian.PutUint16(data[48:50], 40001)\n\tbinary.BigEndian.PutUint16(data[50:52], 33494)\n\n\tpacket, ok := parseEmbeddedUDPPacket(data, 6)\n\tif !ok {\n\t\tt.Fatal(\"expected IPv6 UDP packet behind extension header to match\")\n\t}\n\tif !packet.dstIP.Equal(dstIP) {\n\t\tt.Fatalf(\"dst ip = %v, want %v\", packet.dstIP, dstIP)\n\t}\n\tif packet.srcPort != 40001 || packet.dstPort != 33494 {\n\t\tt.Fatalf(\"unexpected ports: %+v\", packet)\n\t}\n}\n\nfunc mustSerializeIPv4UDP(t *testing.T, srcIP, dstIP net.IP, srcPort, dstPort int, payload []byte) []byte {\n\tt.Helper()\n\tip := &layers.IPv4{\n\t\tVersion:  4,\n\t\tTTL:      1,\n\t\tSrcIP:    srcIP.To4(),\n\t\tDstIP:    dstIP.To4(),\n\t\tProtocol: layers.IPProtocolUDP,\n\t}\n\tudp := &layers.UDP{\n\t\tSrcPort: layers.UDPPort(srcPort),\n\t\tDstPort: layers.UDPPort(dstPort),\n\t}\n\tif err := udp.SetNetworkLayerForChecksum(ip); err != nil {\n\t\tt.Fatalf(\"set checksum: %v\", err)\n\t}\n\treturn mustSerializeLayers(t, ip, udp, gopacket.Payload(payload))\n}\n\nfunc mustSerializeIPv6UDP(t *testing.T, srcIP, dstIP net.IP, srcPort, dstPort int, payload []byte) []byte {\n\tt.Helper()\n\tip := &layers.IPv6{\n\t\tVersion:    6,\n\t\tHopLimit:   1,\n\t\tSrcIP:      srcIP,\n\t\tDstIP:      dstIP,\n\t\tNextHeader: layers.IPProtocolUDP,\n\t}\n\tudp := &layers.UDP{\n\t\tSrcPort: layers.UDPPort(srcPort),\n\t\tDstPort: layers.UDPPort(dstPort),\n\t}\n\tif err := udp.SetNetworkLayerForChecksum(ip); err != nil {\n\t\tt.Fatalf(\"set checksum: %v\", err)\n\t}\n\treturn mustSerializeLayers(t, ip, udp, gopacket.Payload(payload))\n}\n\nfunc mustSerializeLayers(t *testing.T, layersToSerialize ...gopacket.SerializableLayer) []byte {\n\tt.Helper()\n\tbuf := gopacket.NewSerializeBuffer()\n\topts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}\n\tif err := gopacket.SerializeLayers(buf, opts, layersToSerialize...); err != nil {\n\t\tt.Fatalf(\"serialize layers: %v\", err)\n\t}\n\treturn buf.Bytes()\n}\n"
  },
  {
    "path": "trace/mtu/metadata.go",
    "content": "package mtu\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nconst mtuTimeoutGeoSource = \"timeout\"\n\nvar mtuLookupAddr = util.LookupAddrWithContext\n\ntype mtuGeoLookupResult struct {\n\tgeo *ipgeo.IPGeoData\n\terr error\n}\n\nfunc enrichHopMetadata(ctx context.Context, cfg Config, hop Hop) (Hop, bool) {\n\tif !shouldFetchHopMetadata(cfg, hop) {\n\t\treturn hop, false\n\t}\n\n\tupdated := hop\n\tif ctx != nil && ctx.Err() != nil {\n\t\treturn updated, false\n\t}\n\tipStr := strings.TrimSpace(hop.IP)\n\tgeoCh := startMTUGeoLookup(cfg, ipStr)\n\trDNSStarted := cfg.RDNS && updated.Hostname == \"\"\n\tvar rDNSCh <-chan []string\n\tif rDNSStarted {\n\t\trDNSCh = startMTUPTRLookup(ctx, ipStr)\n\t}\n\n\tupdated = waitForMTUGeoAndPTR(ctx, cfg, updated, geoCh, rDNSStarted, rDNSCh)\n\treturn updated, !reflect.DeepEqual(updated, hop)\n}\n\nfunc shouldFetchHopMetadata(cfg Config, hop Hop) bool {\n\tif strings.TrimSpace(hop.IP) == \"\" || hop.Event == EventTimeout {\n\t\treturn false\n\t}\n\treturn cfg.IPGeoSource != nil || cfg.RDNS\n}\n\nfunc startMTUPTRLookup(ctx context.Context, ipStr string) <-chan []string {\n\tch := make(chan []string, 1)\n\tgo func() {\n\t\tptrs, err := mtuLookupAddr(ctx, ipStr)\n\t\tif err != nil {\n\t\t\tch <- nil\n\t\t\treturn\n\t\t}\n\t\tch <- ptrs\n\t}()\n\treturn ch\n}\n\nfunc applyMTUPTRResult(h *Hop, ptrs []string) {\n\tif len(ptrs) == 0 {\n\t\treturn\n\t}\n\th.Hostname = strings.TrimSuffix(strings.TrimSpace(ptrs[0]), \".\")\n}\n\nfunc startMTUGeoLookup(cfg Config, ipStr string) <-chan mtuGeoLookupResult {\n\tif cfg.IPGeoSource == nil {\n\t\tif cfg.RDNS {\n\t\t\treturn nil\n\t\t}\n\t\tch := make(chan mtuGeoLookupResult, 1)\n\t\tch <- mtuGeoLookupResult{}\n\t\treturn ch\n\t}\n\tch := make(chan mtuGeoLookupResult, 1)\n\tgo func() {\n\t\tif geo, ok := ipgeo.Filter(ipStr); ok {\n\t\t\tch <- mtuGeoLookupResult{geo: normalizeMTUGeoData(geo)}\n\t\t\treturn\n\t\t}\n\n\t\tgeo, err := cfg.IPGeoSource(ipStr, cfg.Timeout, cfg.Lang, false)\n\t\tif err != nil {\n\t\t\tch <- mtuGeoLookupResult{geo: mtuTimeoutGeo(), err: err}\n\t\t\treturn\n\t\t}\n\t\tch <- mtuGeoLookupResult{geo: normalizeMTUGeoData(geo)}\n\t}()\n\treturn ch\n}\n\nfunc waitForMTUGeoAndPTR(ctx context.Context, cfg Config, hop Hop, geoCh <-chan mtuGeoLookupResult, rDNSStarted bool, rDNSCh <-chan []string) Hop {\n\tapplyGeo := func(res mtuGeoLookupResult) {\n\t\tif res.geo != nil {\n\t\t\thop.Geo = res.geo\n\t\t}\n\t}\n\n\tif cfg.AlwaysWaitRDNS {\n\t\tif rDNSStarted {\n\t\t\tselect {\n\t\t\tcase ptrs := <-rDNSCh:\n\t\t\t\tapplyMTUPTRResult(&hop, ptrs)\n\t\t\tcase <-time.After(time.Second):\n\t\t\tcase <-ctxDoneChan(ctx):\n\t\t\t}\n\t\t}\n\t\tif geoCh != nil {\n\t\t\tselect {\n\t\t\tcase res := <-geoCh:\n\t\t\t\tapplyGeo(res)\n\t\t\tcase <-ctxDoneChan(ctx):\n\t\t\t}\n\t\t}\n\t\treturn hop\n\t}\n\n\tif rDNSStarted {\n\t\tif geoCh == nil {\n\t\t\tselect {\n\t\t\tcase ptrs := <-rDNSCh:\n\t\t\t\tapplyMTUPTRResult(&hop, ptrs)\n\t\t\tcase <-ctxDoneChan(ctx):\n\t\t\t}\n\t\t\treturn hop\n\t\t}\n\t\tselect {\n\t\tcase res := <-geoCh:\n\t\t\tapplyGeo(res)\n\t\t\treturn hop\n\t\tcase ptrs := <-rDNSCh:\n\t\t\tapplyMTUPTRResult(&hop, ptrs)\n\t\t\tselect {\n\t\t\tcase res := <-geoCh:\n\t\t\t\tapplyGeo(res)\n\t\t\tcase <-ctxDoneChan(ctx):\n\t\t\t}\n\t\t\treturn hop\n\t\tcase <-ctxDoneChan(ctx):\n\t\t\treturn hop\n\t\t}\n\t}\n\n\tif geoCh != nil {\n\t\tselect {\n\t\tcase res := <-geoCh:\n\t\t\tapplyGeo(res)\n\t\tcase <-ctxDoneChan(ctx):\n\t\t}\n\t}\n\treturn hop\n}\n\nfunc ctxDoneChan(ctx context.Context) <-chan struct{} {\n\tif ctx == nil {\n\t\treturn nil\n\t}\n\treturn ctx.Done()\n}\n\nfunc normalizeMTUGeoData(geo *ipgeo.IPGeoData) *ipgeo.IPGeoData {\n\tif geo == nil {\n\t\treturn nil\n\t}\n\tif geo.Source == mtuTimeoutGeoSource {\n\t\treturn geo\n\t}\n\tif geo.Asnumber == \"\" &&\n\t\tgeo.Country == \"\" &&\n\t\tgeo.CountryEn == \"\" &&\n\t\tgeo.Prov == \"\" &&\n\t\tgeo.ProvEn == \"\" &&\n\t\tgeo.City == \"\" &&\n\t\tgeo.CityEn == \"\" &&\n\t\tgeo.District == \"\" &&\n\t\tgeo.Owner == \"\" &&\n\t\tgeo.Isp == \"\" &&\n\t\tgeo.Domain == \"\" &&\n\t\tgeo.Whois == \"\" &&\n\t\tgeo.Lat == 0 &&\n\t\tgeo.Lng == 0 &&\n\t\tgeo.Prefix == \"\" &&\n\t\tlen(geo.Router) == 0 &&\n\t\tgeo.Source == \"\" {\n\t\treturn nil\n\t}\n\treturn geo\n}\n\nfunc mtuTimeoutGeo() *ipgeo.IPGeoData {\n\treturn &ipgeo.IPGeoData{\n\t\tCountry:   \"网络故障\",\n\t\tCountryEn: \"Network Error\",\n\t\tSource:    mtuTimeoutGeoSource,\n\t}\n}\n"
  },
  {
    "path": "trace/mtu/metadata_test.go",
    "content": "package mtu\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\nfunc TestEnrichHopMetadataGeoSuccess(t *testing.T) {\n\tcfg := Config{\n\t\tIPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) {\n\t\t\treturn &ipgeo.IPGeoData{\n\t\t\t\tAsnumber:  \"13335\",\n\t\t\t\tCountry:   \"中国香港\",\n\t\t\t\tCountryEn: \"Hong Kong\",\n\t\t\t\tOwner:     \"Cloudflare\",\n\t\t\t}, nil\n\t\t},\n\t\tLang: \"cn\",\n\t}\n\n\thop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"1.1.1.1\"})\n\tif !changed {\n\t\tt.Fatal(\"expected hop metadata to change\")\n\t}\n\tif hop.Geo == nil || hop.Geo.Asnumber != \"13335\" {\n\t\tt.Fatalf(\"unexpected geo: %+v\", hop.Geo)\n\t}\n}\n\nfunc TestEnrichHopMetadataDisableGeoIPReturnsNoGeo(t *testing.T) {\n\tcfg := Config{\n\t\tIPGeoSource: ipgeo.GetSource(\"disable-geoip\"),\n\t}\n\n\thop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"1.1.1.1\"})\n\tif changed {\n\t\tt.Fatalf(\"expected no metadata change, got %+v\", hop)\n\t}\n\tif hop.Geo != nil {\n\t\tt.Fatalf(\"expected nil geo, got %+v\", hop.Geo)\n\t}\n}\n\nfunc TestEnrichHopMetadataRDNSOnly(t *testing.T) {\n\torigLookup := mtuLookupAddr\n\tmtuLookupAddr = func(context.Context, string) ([]string, error) {\n\t\treturn []string{\"one.one.one.one.\"}, nil\n\t}\n\tdefer func() { mtuLookupAddr = origLookup }()\n\n\tcfg := Config{\n\t\tRDNS:           true,\n\t\tAlwaysWaitRDNS: true,\n\t}\n\n\thop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"1.1.1.1\"})\n\tif !changed {\n\t\tt.Fatal(\"expected hostname metadata change\")\n\t}\n\tif hop.Hostname != \"one.one.one.one\" {\n\t\tt.Fatalf(\"hostname = %q, want %q\", hop.Hostname, \"one.one.one.one\")\n\t}\n}\n\nfunc TestEnrichHopMetadataRDNSOnlyWithoutAlwaysWaitStillSetsHostname(t *testing.T) {\n\torigLookup := mtuLookupAddr\n\tmtuLookupAddr = func(context.Context, string) ([]string, error) {\n\t\treturn []string{\"resolver.example.com.\"}, nil\n\t}\n\tdefer func() { mtuLookupAddr = origLookup }()\n\n\tcfg := Config{\n\t\tRDNS: true,\n\t}\n\n\thop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"1.1.1.1\"})\n\tif !changed {\n\t\tt.Fatal(\"expected hostname metadata change\")\n\t}\n\tif hop.Hostname != \"resolver.example.com\" {\n\t\tt.Fatalf(\"hostname = %q, want %q\", hop.Hostname, \"resolver.example.com\")\n\t}\n}\n\nfunc TestEnrichHopMetadataAlwaysWaitRDNSWaitsForPTR(t *testing.T) {\n\torigLookup := mtuLookupAddr\n\tmtuLookupAddr = func(context.Context, string) ([]string, error) {\n\t\ttime.Sleep(20 * time.Millisecond)\n\t\treturn []string{\"resolver.example.com.\"}, nil\n\t}\n\tdefer func() { mtuLookupAddr = origLookup }()\n\n\tbaseCfg := Config{\n\t\tRDNS: true,\n\t\tIPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) {\n\t\t\treturn &ipgeo.IPGeoData{CountryEn: \"US\"}, nil\n\t\t},\n\t}\n\n\thopNoWait, _ := enrichHopMetadata(context.Background(), baseCfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"8.8.8.8\"})\n\tif hopNoWait.Hostname != \"\" {\n\t\tt.Fatalf(\"expected no hostname without AlwaysWaitRDNS, got %q\", hopNoWait.Hostname)\n\t}\n\n\tbaseCfg.AlwaysWaitRDNS = true\n\thopWait, _ := enrichHopMetadata(context.Background(), baseCfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"8.8.8.8\"})\n\tif hopWait.Hostname != \"resolver.example.com\" {\n\t\tt.Fatalf(\"hostname = %q, want %q\", hopWait.Hostname, \"resolver.example.com\")\n\t}\n\tif hopWait.Geo == nil || hopWait.Geo.CountryEn != \"US\" {\n\t\tt.Fatalf(\"unexpected geo with AlwaysWaitRDNS: %+v\", hopWait.Geo)\n\t}\n}\n\nfunc TestEnrichHopMetadataGeoTimeout(t *testing.T) {\n\tcfg := Config{\n\t\tIPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) {\n\t\t\treturn nil, errors.New(\"boom\")\n\t\t},\n\t}\n\n\thop, changed := enrichHopMetadata(context.Background(), cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"1.1.1.1\"})\n\tif !changed {\n\t\tt.Fatal(\"expected timeout geo metadata change\")\n\t}\n\tif hop.Geo == nil || hop.Geo.Source != mtuTimeoutGeoSource {\n\t\tt.Fatalf(\"unexpected timeout geo: %+v\", hop.Geo)\n\t}\n}\n\nfunc TestEnrichHopMetadataCancelStopsWaitingForPTR(t *testing.T) {\n\torigLookup := mtuLookupAddr\n\tblocked := make(chan struct{})\n\tmtuLookupAddr = func(ctx context.Context, ip string) ([]string, error) {\n\t\tclose(blocked)\n\t\t<-ctx.Done()\n\t\treturn nil, ctx.Err()\n\t}\n\tdefer func() { mtuLookupAddr = origLookup }()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcfg := Config{\n\t\tRDNS: true,\n\t\tIPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) {\n\t\t\ttime.Sleep(200 * time.Millisecond)\n\t\t\treturn &ipgeo.IPGeoData{CountryEn: \"US\"}, nil\n\t\t},\n\t}\n\n\tdone := make(chan struct{})\n\tvar (\n\t\thop     Hop\n\t\tchanged bool\n\t)\n\tgo func() {\n\t\thop, changed = enrichHopMetadata(ctx, cfg, Hop{TTL: 1, Event: EventTimeExceeded, IP: \"1.1.1.1\"})\n\t\tclose(done)\n\t}()\n\n\t<-blocked\n\tcancel()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"enrichHopMetadata did not stop promptly after cancel\")\n\t}\n\tif changed {\n\t\tt.Fatalf(\"expected no metadata change after cancel, got %+v\", hop)\n\t}\n}\n"
  },
  {
    "path": "trace/mtu/runner.go",
    "content": "package mtu\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype prober interface {\n\tProbe(ctx context.Context, plan probePlan) (probeResponse, error)\n\tClose() error\n}\n\ntype probePlan struct {\n\tTTL         int\n\tToken       uint32\n\tPayloadSize int\n\tTimeout     time.Duration\n}\n\ntype localMTUError struct {\n\tMTU int\n}\n\nfunc (e *localMTUError) Error() string {\n\tif e == nil {\n\t\treturn \"local pmtu update\"\n\t}\n\tif e.MTU > 0 {\n\t\treturn fmt.Sprintf(\"local pmtu update: %d\", e.MTU)\n\t}\n\treturn \"local pmtu update\"\n}\n\nfunc Run(ctx context.Context, cfg Config) (*Result, error) {\n\treturn RunStream(ctx, cfg, nil)\n}\n\nfunc RunStream(ctx context.Context, cfg Config, sink StreamSink) (*Result, error) {\n\tcfg, err := normalizeConfig(cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tp, err := newSocketProber(cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer p.Close()\n\treturn runStreamWithProber(ctx, cfg, p, sink)\n}\n\nfunc runWithProber(ctx context.Context, cfg Config, p prober) (*Result, error) {\n\treturn runStreamWithProber(ctx, cfg, p, nil)\n}\n\nfunc runStreamWithProber(ctx context.Context, cfg Config, p prober, sink StreamSink) (*Result, error) {\n\tcfg, err := normalizeConfig(cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstartMTU := initialPathMTU(cfg)\n\tprobeMTU := initialProbeMTU(cfg.ipVersion())\n\tres := &Result{\n\t\tTarget:     cfg.Target,\n\t\tResolvedIP: cfg.DstIP.String(),\n\t\tProtocol:   \"udp\",\n\t\tIPVersion:  cfg.ipVersion(),\n\t\tStartMTU:   startMTU,\n\t\tProbeSize:  probeMTU,\n\t\tPathMTU:    startMTU,\n\t\tHops:       make([]Hop, 0, cfg.MaxHops-cfg.BeginHop+1),\n\t}\n\n\tvar token uint32 = 1\n\tfor ttl := cfg.BeginHop; ttl <= cfg.MaxHops; ttl++ {\n\t\temitStreamEvent(sink, StreamEventTTLStart, res, Hop{TTL: ttl})\n\n\t\tvar hop Hop\n\t\tgotHop := false\n\t\tttlPMTU := 0\n\t\tttlSawRemote := false\n\n\t\tfor attempt := 0; attempt < cfg.Queries; {\n\t\t\tpayloadSize := payloadSizeForMTU(probeMTU, res.IPVersion)\n\t\t\tresp, err := p.Probe(ctx, probePlan{\n\t\t\t\tTTL:         ttl,\n\t\t\t\tToken:       token,\n\t\t\t\tPayloadSize: payloadSize,\n\t\t\t\tTimeout:     cfg.Timeout,\n\t\t\t})\n\t\t\ttoken++\n\t\t\tif err != nil {\n\t\t\t\tvar mtuErr *localMTUError\n\t\t\t\tif errors.As(err, &mtuErr) {\n\t\t\t\t\treportedMTU := mtuErr.MTU\n\t\t\t\t\tif reportedMTU <= 0 {\n\t\t\t\t\t\treportedMTU = res.PathMTU\n\t\t\t\t\t}\n\t\t\t\t\tnextMTU, ok := nextLocalProbeMTU(probeMTU, reportedMTU, res.IPVersion)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t\tif ttl == cfg.BeginHop && probeMTU > res.StartMTU && nextMTU == res.StartMTU {\n\t\t\t\t\t\tttlPMTU = candidatePathMTU(ttlPMTU, nextMTU)\n\t\t\t\t\t}\n\t\t\t\t\tprobeMTU = nextMTU\n\t\t\t\t\tres.PathMTU = candidatePathMTU(res.PathMTU, nextMTU)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tattempt++\n\t\t\tif resp.Event == EventTimeout {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\thop = buildHop(cfg, ttl, resp)\n\t\t\tif resp.Event == EventFragNeeded || resp.Event == EventPacketTooBig {\n\t\t\t\tttlSawRemote = true\n\t\t\t\tttlPMTU = candidatePathMTU(ttlPMTU, hop.PMTU)\n\t\t\t\tprobeMTU = candidatePathMTU(probeMTU, hop.PMTU)\n\t\t\t\tres.PathMTU = candidatePathMTU(res.PathMTU, hop.PMTU)\n\t\t\t\thop.PMTU = ttlPMTU\n\t\t\t\temitStreamEvent(sink, StreamEventTTLUpdate, res, hop)\n\t\t\t\tgotHop = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif ttlPMTU > 0 {\n\t\t\t\thop.PMTU = ttlPMTU\n\t\t\t} else if ttl == 1 && res.ProbeSize > res.StartMTU && res.StartMTU > 0 && res.PathMTU == res.StartMTU {\n\t\t\t\thop.PMTU = res.StartMTU\n\t\t\t}\n\t\t\temitStreamEvent(sink, StreamEventTTLUpdate, res, hop)\n\t\t\tgotHop = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !gotHop {\n\t\t\thop = Hop{TTL: ttl, Event: EventTimeout}\n\t\t\temitStreamEvent(sink, StreamEventTTLUpdate, res, hop)\n\t\t} else if ttlSawRemote && hop.PMTU == 0 {\n\t\t\thop.PMTU = ttlPMTU\n\t\t}\n\t\tif updatedHop, changed := enrichHopMetadata(ctx, cfg, hop); changed {\n\t\t\thop = updatedHop\n\t\t\temitStreamEvent(sink, StreamEventTTLUpdate, res, hop)\n\t\t}\n\t\tres.Hops = append(res.Hops, hop)\n\t\temitStreamEvent(sink, StreamEventTTLFinal, res, hop)\n\n\t\tif hop.Event == EventDestination {\n\t\t\tbreak\n\t\t}\n\t\tif ttl < cfg.MaxHops && cfg.TTLInterval > 0 {\n\t\t\tif err := sleepContext(ctx, cfg.TTLInterval); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\tres.PathMTU = candidatePathMTU(res.StartMTU, res.PathMTU)\n\temitStreamEvent(sink, StreamEventDone, res, Hop{})\n\treturn res, nil\n}\n\nfunc normalizeConfig(cfg Config) (Config, error) {\n\tif cfg.DstIP == nil {\n\t\treturn cfg, errors.New(\"destination IP is required\")\n\t}\n\tif cfg.ipVersion() == 0 {\n\t\treturn cfg, errors.New(\"destination IP is invalid\")\n\t}\n\tif cfg.Target == \"\" {\n\t\tcfg.Target = cfg.DstIP.String()\n\t}\n\tif cfg.BeginHop < 1 {\n\t\tcfg.BeginHop = 1\n\t}\n\tif cfg.MaxHops < cfg.BeginHop {\n\t\treturn cfg, fmt.Errorf(\"max hops %d is smaller than first hop %d\", cfg.MaxHops, cfg.BeginHop)\n\t}\n\tif cfg.Queries < 1 {\n\t\tcfg.Queries = 1\n\t}\n\tif cfg.Timeout <= 0 {\n\t\tcfg.Timeout = time.Second\n\t}\n\tif cfg.DstPort == 0 {\n\t\tcfg.DstPort = 33494\n\t}\n\tif cfg.SrcIP == nil {\n\t\treturn cfg, errors.New(\"source IP is required\")\n\t}\n\tif (cfg.SrcIP.To4() == nil) != (cfg.DstIP.To4() == nil) {\n\t\treturn cfg, errors.New(\"source and destination IP address families do not match\")\n\t}\n\treturn cfg, nil\n}\n\nfunc (cfg Config) ipVersion() int {\n\tif util.IsIPv6(cfg.DstIP) {\n\t\treturn 6\n\t}\n\tif cfg.DstIP.To4() != nil {\n\t\treturn 4\n\t}\n\treturn 0\n}\n\nfunc initialPathMTU(cfg Config) int {\n\tif mtu := util.GetMTUByIPForDevice(cfg.SrcIP, cfg.SourceDevice); mtu > 0 {\n\t\treturn mtu\n\t}\n\tif cfg.ipVersion() == 6 {\n\t\treturn 1280\n\t}\n\treturn 1500\n}\n\nfunc initialProbeMTU(ipVersion int) int {\n\tif ipVersion == 6 {\n\t\treturn 65000\n\t}\n\treturn 65000\n}\n\nfunc payloadSizeForMTU(pathMTU, ipVersion int) int {\n\toverhead := 28\n\tif ipVersion == 6 {\n\t\toverhead = 48\n\t}\n\tif payload := pathMTU - overhead; payload > probePayloadMinLen {\n\t\treturn payload\n\t}\n\treturn probePayloadMinLen\n}\n\nfunc minProbeMTU(ipVersion int) int {\n\tif ipVersion == 6 {\n\t\treturn 48 + probePayloadMinLen\n\t}\n\treturn 28 + probePayloadMinLen\n}\n\nfunc nextLocalProbeMTU(currentProbeMTU, reportedMTU, ipVersion int) (int, bool) {\n\tnextMTU := candidatePathMTU(currentProbeMTU, reportedMTU)\n\tif nextMTU < currentProbeMTU {\n\t\treturn nextMTU, true\n\t}\n\t// Some platforms report EMSGSIZE before exposing a smaller socket MTU.\n\tif currentProbeMTU <= minProbeMTU(ipVersion) {\n\t\treturn 0, false\n\t}\n\treturn currentProbeMTU - 1, true\n}\n\nfunc candidatePathMTU(current, discovered int) int {\n\tif discovered <= 0 {\n\t\treturn current\n\t}\n\tif current == 0 || discovered < current {\n\t\treturn discovered\n\t}\n\treturn current\n}\n\nfunc buildHop(cfg Config, ttl int, resp probeResponse) Hop {\n\thop := Hop{\n\t\tTTL:   ttl,\n\t\tEvent: resp.Event,\n\t\tPMTU:  resp.PMTU,\n\t}\n\tif resp.IP != nil {\n\t\thop.IP = resp.IP.String()\n\t}\n\tif resp.RTT > 0 {\n\t\thop.RTTMs = float64(resp.RTT) / float64(time.Millisecond)\n\t}\n\treturn hop\n}\n\nfunc sleepContext(ctx context.Context, d time.Duration) error {\n\ttimer := time.NewTimer(d)\n\tdefer timer.Stop()\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-timer.C:\n\t\treturn nil\n\t}\n}\n\nfunc emitStreamEvent(sink StreamSink, kind StreamEventKind, res *Result, hop Hop) {\n\tif sink == nil || res == nil {\n\t\treturn\n\t}\n\tif hop.TTL == 0 && kind != StreamEventDone {\n\t\thop.TTL = 0\n\t}\n\tsink(StreamEvent{\n\t\tKind:       kind,\n\t\tTTL:        hop.TTL,\n\t\tHop:        hop,\n\t\tTarget:     res.Target,\n\t\tResolvedIP: res.ResolvedIP,\n\t\tProtocol:   res.Protocol,\n\t\tIPVersion:  res.IPVersion,\n\t\tStartMTU:   res.StartMTU,\n\t\tProbeSize:  res.ProbeSize,\n\t\tPathMTU:    res.PathMTU,\n\t})\n}\n"
  },
  {
    "path": "trace/mtu/runner_test.go",
    "content": "package mtu\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\ntype scriptedStep struct {\n\tresponse probeResponse\n\terr      error\n}\n\ntype scriptedProber struct {\n\tsteps []scriptedStep\n\tplans []probePlan\n}\n\nfunc (p *scriptedProber) Probe(_ context.Context, plan probePlan) (probeResponse, error) {\n\tp.plans = append(p.plans, plan)\n\tif len(p.steps) == 0 {\n\t\treturn probeResponse{}, errors.New(\"unexpected probe\")\n\t}\n\tstep := p.steps[0]\n\tp.steps = p.steps[1:]\n\treturn step.response, step.err\n}\n\nfunc (p *scriptedProber) Close() error { return nil }\n\nfunc TestRunWithProberShrinksPMTUAndRetriesSameTTL(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     3,\n\t\tQueries:     2,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"192.0.2.1\"), RTT: 12 * time.Millisecond}},\n\t\t\t{response: probeResponse{Event: EventFragNeeded, IP: net.ParseIP(\"198.51.100.1\"), RTT: 14 * time.Millisecond, PMTU: 1400}},\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"198.51.100.1\"), RTT: 15 * time.Millisecond}},\n\t\t\t{response: probeResponse{Event: EventDestination, IP: cfg.DstIP, RTT: 18 * time.Millisecond}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif res.PathMTU != 1400 {\n\t\tt.Fatalf(\"path mtu = %d, want 1400\", res.PathMTU)\n\t}\n\tif res.ProbeSize != 65000 {\n\t\tt.Fatalf(\"probe size = %d, want 65000\", res.ProbeSize)\n\t}\n\tif len(res.Hops) != 3 {\n\t\tt.Fatalf(\"hop count = %d, want 3\", len(res.Hops))\n\t}\n\tif got := res.Hops[1].PMTU; got != 1400 {\n\t\tt.Fatalf(\"ttl 2 pmtu = %d, want 1400\", got)\n\t}\n\tif got := prober.plans[0].PayloadSize; got != 64972 {\n\t\tt.Fatalf(\"initial payload size = %d, want 64972\", got)\n\t}\n\tif got := prober.plans[2].PayloadSize; got != 1372 {\n\t\tt.Fatalf(\"payload size after local mtu shrink = %d, want 1372\", got)\n\t}\n}\n\nfunc TestRunWithProberKeepsLocalPMTUOffHopOutput(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     1,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{err: &localMTUError{MTU: 1400}},\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"192.0.2.1\"), RTT: 11 * time.Millisecond}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif res.PathMTU != 1400 {\n\t\tt.Fatalf(\"path mtu = %d, want 1400\", res.PathMTU)\n\t}\n\tif got := res.Hops[0].PMTU; got != 0 {\n\t\tt.Fatalf(\"local pmtu should not be attributed to hop, got %d\", got)\n\t}\n\tif got := prober.plans[1].PayloadSize; got != 1372 {\n\t\tt.Fatalf(\"payload size after local mtu shrink = %d, want 1372\", got)\n\t}\n}\n\nfunc TestRunWithProberAnnotatesFirstHopWithLocalStartMTU(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     1,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{err: &localMTUError{MTU: 1500}},\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"192.0.2.1\"), RTT: 11 * time.Millisecond}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif len(res.Hops) != 1 {\n\t\tt.Fatalf(\"hop count = %d, want 1\", len(res.Hops))\n\t}\n\tif got := res.Hops[0].PMTU; got != 1500 {\n\t\tt.Fatalf(\"first hop pmtu = %d, want 1500\", got)\n\t}\n}\n\nfunc TestRunWithProberAnnotatesFirstHopWithStartMTUWithoutLocalEvent(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     1,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"192.0.2.1\"), RTT: 11 * time.Millisecond}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif len(res.Hops) != 1 {\n\t\tt.Fatalf(\"hop count = %d, want 1\", len(res.Hops))\n\t}\n\tif got := res.Hops[0].PMTU; got != 1500 {\n\t\tt.Fatalf(\"first hop pmtu = %d, want 1500\", got)\n\t}\n}\n\nfunc TestRunWithProberStopsTTLAfterFirstNonTimeout(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     3,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"1.1.1.1\"), RTT: 10 * time.Millisecond}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif len(prober.plans) != 1 {\n\t\tt.Fatalf(\"probe count = %d, want 1\", len(prober.plans))\n\t}\n\tif len(res.Hops) != 1 || res.Hops[0].Event != EventTimeExceeded {\n\t\tt.Fatalf(\"unexpected hops: %+v\", res.Hops)\n\t}\n}\n\nfunc TestRunWithProberWritesTimeoutAfterExhaustingQueries(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     2,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{response: probeResponse{Event: EventTimeout}},\n\t\t\t{response: probeResponse{Event: EventTimeout}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif len(prober.plans) != 2 {\n\t\tt.Fatalf(\"probe count = %d, want 2\", len(prober.plans))\n\t}\n\tif len(res.Hops) != 1 || res.Hops[0].Event != EventTimeout {\n\t\tt.Fatalf(\"unexpected timeout hop: %+v\", res.Hops)\n\t}\n}\n\nfunc TestRunWithProberTimeoutHopDoesNotExposeLocalPMTU(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     1,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{err: &localMTUError{MTU: 1400}},\n\t\t\t{response: probeResponse{Event: EventTimeout}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif len(res.Hops) != 1 {\n\t\tt.Fatalf(\"hop count = %d, want 1\", len(res.Hops))\n\t}\n\tif got := res.Hops[0].PMTU; got != 0 {\n\t\tt.Fatalf(\"timeout hop pmtu = %d, want 0\", got)\n\t}\n}\n\nfunc TestNormalizeConfigRejectsSourceDestinationFamilyMismatch(t *testing.T) {\n\t_, err := normalizeConfig(Config{\n\t\tDstIP:    net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:    net.ParseIP(\"2001:db8::1\"),\n\t\tBeginHop: 1,\n\t\tMaxHops:  1,\n\t})\n\tif err == nil || err.Error() != \"source and destination IP address families do not match\" {\n\t\tt.Fatalf(\"err = %v, want family mismatch error\", err)\n\t}\n}\n\nfunc TestRunWithProberFallbackShrinksAfterRepeatedLocalMTUErrors(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     1,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{err: &localMTUError{}},\n\t\t\t{err: &localMTUError{}},\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"192.0.2.1\"), RTT: 11 * time.Millisecond}},\n\t\t},\n\t}\n\n\tres, err := runWithProber(context.Background(), cfg, prober)\n\tif err != nil {\n\t\tt.Fatalf(\"runWithProber returned error: %v\", err)\n\t}\n\tif got := len(prober.plans); got != 3 {\n\t\tt.Fatalf(\"probe count = %d, want 3\", got)\n\t}\n\tif got := prober.plans[0].PayloadSize; got != 64972 {\n\t\tt.Fatalf(\"initial payload size = %d, want 64972\", got)\n\t}\n\tif got := prober.plans[1].PayloadSize; got != 1472 {\n\t\tt.Fatalf(\"payload size after first local mtu shrink = %d, want 1472\", got)\n\t}\n\tif got := prober.plans[2].PayloadSize; got != 1471 {\n\t\tt.Fatalf(\"payload size after fallback local mtu shrink = %d, want 1471\", got)\n\t}\n\tif got := res.PathMTU; got != 1499 {\n\t\tt.Fatalf(\"path mtu = %d, want 1499\", got)\n\t}\n\tif len(res.Hops) != 1 || res.Hops[0].Event != EventTimeExceeded {\n\t\tt.Fatalf(\"unexpected hops: %+v\", res.Hops)\n\t}\n\tif got := res.Hops[0].PMTU; got != 1500 {\n\t\tt.Fatalf(\"first hop pmtu = %d, want 1500\", got)\n\t}\n}\n\nfunc TestRunStreamWithProberEmitsOrderedEvents(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     2,\n\t\tQueries:     2,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"192.0.2.1\"), RTT: 10 * time.Millisecond}},\n\t\t\t{response: probeResponse{Event: EventFragNeeded, IP: net.ParseIP(\"198.51.100.1\"), RTT: 12 * time.Millisecond, PMTU: 1400}},\n\t\t\t{response: probeResponse{Event: EventDestination, IP: cfg.DstIP, RTT: 15 * time.Millisecond}},\n\t\t},\n\t}\n\n\tvar events []StreamEvent\n\tres, err := runStreamWithProber(context.Background(), cfg, prober, func(event StreamEvent) {\n\t\tevents = append(events, event)\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"runStreamWithProber returned error: %v\", err)\n\t}\n\tif res.PathMTU != 1400 {\n\t\tt.Fatalf(\"path mtu = %d, want 1400\", res.PathMTU)\n\t}\n\n\tvar gotKinds []StreamEventKind\n\tfor _, event := range events {\n\t\tgotKinds = append(gotKinds, event.Kind)\n\t}\n\twantKinds := []StreamEventKind{\n\t\tStreamEventTTLStart,\n\t\tStreamEventTTLUpdate,\n\t\tStreamEventTTLFinal,\n\t\tStreamEventTTLStart,\n\t\tStreamEventTTLUpdate,\n\t\tStreamEventTTLUpdate,\n\t\tStreamEventTTLFinal,\n\t\tStreamEventDone,\n\t}\n\tif len(gotKinds) != len(wantKinds) {\n\t\tt.Fatalf(\"event count = %d, want %d (%v)\", len(gotKinds), len(wantKinds), gotKinds)\n\t}\n\tfor i, want := range wantKinds {\n\t\tif gotKinds[i] != want {\n\t\t\tt.Fatalf(\"event[%d] kind = %q, want %q\", i, gotKinds[i], want)\n\t\t}\n\t}\n\tif got := events[6].Hop.PMTU; got != 1400 {\n\t\tt.Fatalf(\"final ttl2 pmtu = %d, want 1400\", got)\n\t}\n\tif got := events[len(events)-1].PathMTU; got != 1400 {\n\t\tt.Fatalf(\"done path mtu = %d, want 1400\", got)\n\t}\n}\n\nfunc TestRunStreamWithProberEmitsTimeoutFinalEvent(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     2,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t}\n\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{response: probeResponse{Event: EventTimeout}},\n\t\t\t{response: probeResponse{Event: EventTimeout}},\n\t\t},\n\t}\n\n\tvar events []StreamEvent\n\t_, err := runStreamWithProber(context.Background(), cfg, prober, func(event StreamEvent) {\n\t\tevents = append(events, event)\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"runStreamWithProber returned error: %v\", err)\n\t}\n\tif len(events) != 4 {\n\t\tt.Fatalf(\"event count = %d, want 4\", len(events))\n\t}\n\tif events[0].Kind != StreamEventTTLStart || events[1].Kind != StreamEventTTLUpdate || events[2].Kind != StreamEventTTLFinal || events[3].Kind != StreamEventDone {\n\t\tt.Fatalf(\"unexpected event sequence: %+v\", events)\n\t}\n\tif got := events[2].Hop.Event; got != EventTimeout {\n\t\tt.Fatalf(\"final timeout event = %q, want %q\", got, EventTimeout)\n\t}\n}\n\nfunc TestRunStreamWithProberEmitsGeoUpdateBeforeFinal(t *testing.T) {\n\tcfg := Config{\n\t\tTarget:      \"example.com\",\n\t\tDstIP:       net.ParseIP(\"203.0.113.9\"),\n\t\tSrcIP:       net.ParseIP(\"192.0.2.10\"),\n\t\tDstPort:     33494,\n\t\tBeginHop:    1,\n\t\tMaxHops:     1,\n\t\tQueries:     1,\n\t\tTimeout:     time.Second,\n\t\tTTLInterval: 0,\n\t\tIPGeoSource: func(ip string, timeout time.Duration, lang string, maptrace bool) (*ipgeo.IPGeoData, error) {\n\t\t\treturn &ipgeo.IPGeoData{Asnumber: \"13335\", CountryEn: \"Hong Kong\", Owner: \"Cloudflare\"}, nil\n\t\t},\n\t}\n\n\tvar events []StreamEvent\n\tprober := &scriptedProber{\n\t\tsteps: []scriptedStep{\n\t\t\t{response: probeResponse{Event: EventTimeExceeded, IP: net.ParseIP(\"1.1.1.1\"), RTT: 10 * time.Millisecond}},\n\t\t},\n\t}\n\n\tres, err := runStreamWithProber(context.Background(), cfg, prober, func(event StreamEvent) {\n\t\tevents = append(events, event)\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"runStreamWithProber returned error: %v\", err)\n\t}\n\tif len(res.Hops) != 1 || res.Hops[0].Geo == nil || res.Hops[0].Geo.Asnumber != \"13335\" {\n\t\tt.Fatalf(\"unexpected result geo: %+v\", res.Hops)\n\t}\n\tif len(events) != 5 {\n\t\tt.Fatalf(\"event count = %d, want 5\", len(events))\n\t}\n\twantKinds := []StreamEventKind{\n\t\tStreamEventTTLStart,\n\t\tStreamEventTTLUpdate,\n\t\tStreamEventTTLUpdate,\n\t\tStreamEventTTLFinal,\n\t\tStreamEventDone,\n\t}\n\tfor i, want := range wantKinds {\n\t\tif events[i].Kind != want {\n\t\t\tt.Fatalf(\"event[%d] kind = %q, want %q\", i, events[i].Kind, want)\n\t\t}\n\t}\n\tif events[1].Hop.Geo != nil {\n\t\tt.Fatalf(\"expected first update without geo, got %+v\", events[1].Hop.Geo)\n\t}\n\tif events[2].Hop.Geo == nil || events[2].Hop.Geo.Asnumber != \"13335\" {\n\t\tt.Fatalf(\"expected second update with geo, got %+v\", events[2].Hop.Geo)\n\t}\n\tif events[3].Hop.Geo == nil || events[3].Hop.Geo.Asnumber != \"13335\" {\n\t\tt.Fatalf(\"expected final event with geo, got %+v\", events[3].Hop.Geo)\n\t}\n}\n\nfunc TestCandidatePathMTUNeverIncreases(t *testing.T) {\n\tif got := candidatePathMTU(1400, 1500); got != 1400 {\n\t\tt.Fatalf(\"candidatePathMTU should not increase, got %d\", got)\n\t}\n}\n"
  },
  {
    "path": "trace/mtu/socket_prober.go",
    "content": "package mtu\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\ttraceinternal \"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n)\n\ntype socketProber struct {\n\tipVersion int\n\tdstIP     net.IP\n\tdstPort   int\n\tsrcPort   int\n\tudp       *net.UDPConn\n\ticmp      net.PacketConn\n\tudp4      *ipv4.PacketConn\n\tudp6      *ipv6.PacketConn\n\tsendMu    sync.Mutex\n}\n\nvar ErrWinDivertUnavailable = errors.New(\"windivert capture unavailable\")\n\nfunc newSocketProber(cfg Config) (*socketProber, error) {\n\tnetwork := \"udp4\"\n\ticmpNetwork := \"ip4:icmp\"\n\tif cfg.ipVersion() == 6 {\n\t\tnetwork = \"udp6\"\n\t\ticmpNetwork = \"ip6:ipv6-icmp\"\n\t}\n\n\tlocalAddr := &net.UDPAddr{IP: cfg.SrcIP, Port: cfg.SrcPort}\n\tudpConn, err := net.ListenUDP(network, localAddr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := configurePMTUSocket(udpConn, cfg.ipVersion()); err != nil {\n\t\tudpConn.Close()\n\t\treturn nil, err\n\t}\n\n\ticmpConn, err := traceinternal.ListenPacket(icmpNetwork, cfg.SrcIP.String())\n\tif err != nil {\n\t\tudpConn.Close()\n\t\treturn nil, err\n\t}\n\n\tprober := &socketProber{\n\t\tipVersion: cfg.ipVersion(),\n\t\tdstIP:     append(net.IP(nil), cfg.DstIP...),\n\t\tdstPort:   cfg.DstPort,\n\t\tudp:       udpConn,\n\t\ticmp:      icmpConn,\n\t}\n\tif addr, ok := udpConn.LocalAddr().(*net.UDPAddr); ok && addr != nil {\n\t\tprober.srcPort = addr.Port\n\t}\n\tif prober.ipVersion == 6 {\n\t\tprober.udp6 = ipv6.NewPacketConn(udpConn)\n\t} else {\n\t\tprober.udp4 = ipv4.NewPacketConn(udpConn)\n\t}\n\treturn prober, nil\n}\n\nfunc (p *socketProber) Close() error {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tif p.icmp != nil {\n\t\t_ = p.icmp.Close()\n\t}\n\tif p.udp != nil {\n\t\treturn p.udp.Close()\n\t}\n\treturn nil\n}\n\nfunc (p *socketProber) Probe(ctx context.Context, plan probePlan) (probeResponse, error) {\n\tif err := ctx.Err(); err != nil {\n\t\treturn probeResponse{}, err\n\t}\n\n\tdstPort := probeDstPort(p.dstPort, plan.Token)\n\tpayload := buildProbePayload(plan.PayloadSize)\n\tcaptureDeadline := deadlineFromStart(ctx, time.Now(), plan.Timeout)\n\tcapture, err := p.beginICMPResponseCapture(ctx, captureDeadline)\n\tif err != nil {\n\t\tif !errors.Is(err, ErrWinDivertUnavailable) {\n\t\t\treturn probeResponse{}, err\n\t\t}\n\t}\n\tif capture != nil {\n\t\tdefer capture.Close()\n\t}\n\tstartSend := time.Now()\n\tif err := p.send(plan.TTL, payload, dstPort); err != nil {\n\t\tif isSendSizeErr(err) {\n\t\t\treturn probeResponse{}, &localMTUError{MTU: socketPathMTU(p.udp, p.ipVersion)}\n\t\t}\n\t\treturn probeResponse{}, err\n\t}\n\n\tbuf := make([]byte, 4096)\n\tdeadline := deadlineFromStart(ctx, startSend, plan.Timeout)\n\tresp, err := p.readICMPResponse(ctx, capture, deadline, dstPort, buf)\n\tif err != nil {\n\t\treturn probeResponse{}, err\n\t}\n\tresp.RTT = time.Since(startSend)\n\treturn resp, nil\n}\n\nfunc (p *socketProber) send(ttl int, payload []byte, dstPort int) error {\n\tp.sendMu.Lock()\n\tdefer p.sendMu.Unlock()\n\n\tif p.ipVersion == 6 {\n\t\tif err := p.udp6.SetHopLimit(ttl); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tif err := p.udp4.SetTTL(ttl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t_, err := p.udp.WriteToUDP(payload, &net.UDPAddr{IP: p.dstIP, Port: dstPort})\n\treturn err\n}\n\nfunc probeDstPort(base int, token uint32) int {\n\tif base <= 0 || base > 65535 {\n\t\tbase = 33494\n\t}\n\tif token == 0 {\n\t\ttoken = 1\n\t}\n\tmaxOffset := 65535 - base\n\tif maxOffset <= 0 {\n\t\treturn base\n\t}\n\toffset := int((token - 1) % uint32(maxOffset+1))\n\treturn base + offset\n}\n\nfunc buildWinDivertMTUFilter(ipVersion int, srcIP net.IP) string {\n\tif srcIP == nil || srcIP.IsUnspecified() {\n\t\tif ipVersion == 6 {\n\t\t\treturn \"inbound and icmpv6\"\n\t\t}\n\t\treturn \"inbound and icmp\"\n\t}\n\tif ipVersion == 6 {\n\t\treturn \"inbound and icmpv6 and ipv6.DstAddr == \" + srcIP.String()\n\t}\n\treturn \"inbound and icmp and ip.DstAddr == \" + srcIP.String()\n}\n\ntype icmpResponseCapture interface {\n\tClose() error\n}\n\nfunc deadlineFromStart(ctx context.Context, start time.Time, timeout time.Duration) time.Time {\n\tdeadline := start.Add(timeout)\n\tif ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {\n\t\treturn ctxDeadline\n\t}\n\treturn deadline\n}\n\nfunc (p *socketProber) readICMPResponseFromSocket(ctx context.Context, deadline time.Time, dstPort int, buf []byte) (probeResponse, error) {\n\tfor {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\treturn probeResponse{}, err\n\t\t}\n\t\tif err := p.icmp.SetReadDeadline(deadline); err != nil {\n\t\t\treturn probeResponse{}, err\n\t\t}\n\t\tn, peer, err := p.icmp.ReadFrom(buf)\n\t\tif err != nil {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn probeResponse{}, ctx.Err()\n\t\t\t}\n\t\t\tvar netErr net.Error\n\t\t\tif errors.As(err, &netErr) && netErr.Timeout() {\n\t\t\t\treturn probeResponse{Event: EventTimeout}, nil\n\t\t\t}\n\t\t\tif isRecvSizeErr(err) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn probeResponse{}, err\n\t\t}\n\t\tresp, ok := parseICMPProbeResult(p.ipVersion, buf[:n], util.AddrIP(peer), p.dstIP, dstPort, p.srcPort)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n"
  },
  {
    "path": "trace/mtu/socket_prober_read_default.go",
    "content": "//go:build !windows\n\npackage mtu\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\nfunc (p *socketProber) beginICMPResponseCapture(context.Context, time.Time) (icmpResponseCapture, error) {\n\treturn nil, nil\n}\n\nfunc (p *socketProber) readICMPResponse(ctx context.Context, _ icmpResponseCapture, deadline time.Time, dstPort int, buf []byte) (probeResponse, error) {\n\treturn p.readICMPResponseFromSocket(ctx, deadline, dstPort, buf)\n}\n"
  },
  {
    "path": "trace/mtu/socket_prober_read_windows.go",
    "content": "//go:build windows\n\npackage mtu\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n\twd \"github.com/xjasonlyu/windivert-go\"\n)\n\ntype winDivertCapture struct {\n\tctx       context.Context\n\tcancel    context.CancelFunc\n\thandle    wd.Handle\n\tbuf       []byte\n\taddr      wd.Address\n\tcloseOnce sync.Once\n}\n\nfunc (c *winDivertCapture) Close() error {\n\tif c == nil {\n\t\treturn nil\n\t}\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\tvar err error\n\tc.closeOnce.Do(func() {\n\t\terr = c.handle.Close()\n\t})\n\treturn err\n}\n\nfunc (p *socketProber) beginICMPResponseCapture(ctx context.Context, _ time.Time) (icmpResponseCapture, error) {\n\thandle, err := wd.Open(winDivertMTUFilter(p.ipVersion, p.udp.LocalAddr()), wd.LayerNetwork, 0, wd.FlagSniff|wd.FlagRecvOnly)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: %v\", ErrWinDivertUnavailable, err)\n\t}\n\tprobeCtx, cancel := context.WithCancel(ctx)\n\tcapture := &winDivertCapture{\n\t\tctx:       probeCtx,\n\t\tcancel:    cancel,\n\t\thandle:    handle,\n\t\tbuf:       make([]byte, 65535),\n\t\tcloseOnce: sync.Once{},\n\t}\n\tgo func() {\n\t\t<-probeCtx.Done()\n\t\t_ = capture.Close()\n\t}()\n\n\t_ = handle.SetParam(wd.QueueLength, 8192)\n\t_ = handle.SetParam(wd.QueueTime, 4000)\n\n\treturn capture, nil\n}\n\nfunc (p *socketProber) readICMPResponse(ctx context.Context, capture icmpResponseCapture, deadline time.Time, dstPort int, buf []byte) (probeResponse, error) {\n\tif resp, err, ok := p.readICMPResponseViaWinDivert(ctx, capture, deadline, dstPort); ok {\n\t\treturn resp, err\n\t}\n\treturn p.readICMPResponseFromSocket(ctx, deadline, dstPort, buf)\n}\n\nfunc (p *socketProber) readICMPResponseViaWinDivert(ctx context.Context, capture icmpResponseCapture, deadline time.Time, dstPort int) (probeResponse, error, bool) {\n\twinCapture, ok := capture.(*winDivertCapture)\n\tif !ok || winCapture == nil {\n\t\treturn probeResponse{}, nil, false\n\t}\n\treadCtx, cancel := context.WithDeadline(winCapture.ctx, deadline)\n\tdefer cancel()\n\tgo func() {\n\t\t<-readCtx.Done()\n\t\t_ = winCapture.Close()\n\t}()\n\tfor {\n\t\tif err := readCtx.Err(); err != nil {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn probeResponse{}, ctx.Err(), true\n\t\t\t}\n\t\t\treturn probeResponse{Event: EventTimeout}, nil, true\n\t\t}\n\n\t\tn, err := winCapture.handle.Recv(winCapture.buf, &winCapture.addr)\n\t\tif err != nil {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn probeResponse{}, ctx.Err(), true\n\t\t\t}\n\t\t\tif readCtx.Err() != nil {\n\t\t\t\treturn probeResponse{Event: EventTimeout}, nil, true\n\t\t\t}\n\t\t\treturn probeResponse{}, err, true\n\t\t}\n\t\tpeerIP, icmpMsg, ok := extractWinDivertICMPMessage(p.ipVersion, winCapture.buf[:n])\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tresp, ok := parseICMPProbeResult(p.ipVersion, icmpMsg, peerIP, p.dstIP, dstPort, p.srcPort)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\treturn resp, nil, true\n\t}\n}\n\nfunc winDivertMTUFilter(ipVersion int, localAddr net.Addr) string {\n\treturn buildWinDivertMTUFilter(ipVersion, util.AddrIP(localAddr))\n}\n\nfunc extractWinDivertICMPMessage(ipVersion int, raw []byte) (net.IP, []byte, bool) {\n\tif len(raw) == 0 {\n\t\treturn nil, nil, false\n\t}\n\ticmpMsg, err := util.GetICMPResponsePayload(raw)\n\tif err != nil || len(icmpMsg) == 0 {\n\t\treturn nil, nil, false\n\t}\n\n\tswitch ipVersion {\n\tcase 4:\n\t\tif len(raw) < 20 || raw[0]>>4 != 4 {\n\t\t\treturn nil, nil, false\n\t\t}\n\t\treturn append(net.IP(nil), raw[12:16]...), append([]byte(nil), icmpMsg...), true\n\tcase 6:\n\t\tif len(raw) < 40 || raw[0]>>4 != 6 {\n\t\t\treturn nil, nil, false\n\t\t}\n\t\treturn append(net.IP(nil), raw[8:24]...), append([]byte(nil), icmpMsg...), true\n\tdefault:\n\t\treturn nil, nil, false\n\t}\n}\n"
  },
  {
    "path": "trace/mtu/socket_prober_test.go",
    "content": "package mtu\n\nimport (\n\t\"net\"\n\t\"testing\"\n)\n\nfunc TestProbeDstPortHandlesZeroToken(t *testing.T) {\n\tif got := probeDstPort(33494, 0); got != 33494 {\n\t\tt.Fatalf(\"probeDstPort() = %d, want %d\", got, 33494)\n\t}\n}\n\nfunc TestBuildWinDivertMTUFilter(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tipVersion int\n\t\tsrcIP     net.IP\n\t\twant      string\n\t}{\n\t\t{\n\t\t\tname:      \"ipv4 nil source\",\n\t\t\tipVersion: 4,\n\t\t\tsrcIP:     nil,\n\t\t\twant:      \"inbound and icmp\",\n\t\t},\n\t\t{\n\t\t\tname:      \"ipv4 unspecified source\",\n\t\t\tipVersion: 4,\n\t\t\tsrcIP:     net.IPv4zero,\n\t\t\twant:      \"inbound and icmp\",\n\t\t},\n\t\t{\n\t\t\tname:      \"ipv4 specified source\",\n\t\t\tipVersion: 4,\n\t\t\tsrcIP:     net.ParseIP(\"192.0.2.10\"),\n\t\t\twant:      \"inbound and icmp and ip.DstAddr == 192.0.2.10\",\n\t\t},\n\t\t{\n\t\t\tname:      \"ipv6 nil source\",\n\t\t\tipVersion: 6,\n\t\t\tsrcIP:     nil,\n\t\t\twant:      \"inbound and icmpv6\",\n\t\t},\n\t\t{\n\t\t\tname:      \"ipv6 unspecified source\",\n\t\t\tipVersion: 6,\n\t\t\tsrcIP:     net.IPv6zero,\n\t\t\twant:      \"inbound and icmpv6\",\n\t\t},\n\t\t{\n\t\t\tname:      \"ipv6 specified source\",\n\t\t\tipVersion: 6,\n\t\t\tsrcIP:     net.ParseIP(\"2001:db8::10\"),\n\t\t\twant:      \"inbound and icmpv6 and ipv6.DstAddr == 2001:db8::10\",\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := buildWinDivertMTUFilter(tc.ipVersion, tc.srcIP); got != tc.want {\n\t\t\t\tt.Fatalf(\"buildWinDivertMTUFilter() = %q, want %q\", got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "trace/mtu/socketopts_darwin.go",
    "content": "//go:build darwin\n\npackage mtu\n\nimport (\n\t\"errors\"\n\t\"net\"\n\n\t\"golang.org/x/sys/unix\"\n)\n\nfunc configurePMTUSocket(conn *net.UDPConn, ipVersion int) error {\n\trawConn, err := conn.SyscallConn()\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar controlErr error\n\tif err := rawConn.Control(func(fd uintptr) {\n\t\tif ipVersion == 6 {\n\t\t\tcontrolErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_DONTFRAG, 1)\n\t\t\treturn\n\t\t}\n\t\tcontrolErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_DONTFRAG, 1)\n\t}); err != nil {\n\t\treturn err\n\t}\n\treturn controlErr\n}\n\nfunc socketPathMTU(_ *net.UDPConn, _ int) int {\n\treturn 0\n}\n\nfunc isSendSizeErr(err error) bool {\n\treturn errors.Is(err, unix.EMSGSIZE)\n}\n\nfunc isRecvSizeErr(err error) bool {\n\treturn errors.Is(err, unix.EMSGSIZE)\n}\n"
  },
  {
    "path": "trace/mtu/socketopts_linux.go",
    "content": "//go:build linux\n\npackage mtu\n\nimport (\n\t\"errors\"\n\t\"net\"\n\n\t\"golang.org/x/sys/unix\"\n)\n\nfunc configurePMTUSocket(conn *net.UDPConn, ipVersion int) error {\n\trawConn, err := conn.SyscallConn()\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar controlErr error\n\tif err := rawConn.Control(func(fd uintptr) {\n\t\tif ipVersion == 6 {\n\t\t\tcontrolErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_MTU_DISCOVER, unix.IPV6_PMTUDISC_PROBE)\n\t\t\treturn\n\t\t}\n\t\tcontrolErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_MTU_DISCOVER, unix.IP_PMTUDISC_PROBE)\n\t}); err != nil {\n\t\treturn err\n\t}\n\treturn controlErr\n}\n\nfunc socketPathMTU(conn *net.UDPConn, ipVersion int) int {\n\trawConn, err := conn.SyscallConn()\n\tif err != nil {\n\t\treturn 0\n\t}\n\tmtu := 0\n\t_ = rawConn.Control(func(fd uintptr) {\n\t\tif ipVersion == 6 {\n\t\t\tmtu, _ = unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_MTU)\n\t\t\treturn\n\t\t}\n\t\tmtu, _ = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_MTU)\n\t})\n\treturn mtu\n}\n\nfunc isSendSizeErr(err error) bool {\n\treturn errors.Is(err, unix.EMSGSIZE)\n}\n\nfunc isRecvSizeErr(err error) bool {\n\treturn errors.Is(err, unix.EMSGSIZE)\n}\n"
  },
  {
    "path": "trace/mtu/socketopts_stub.go",
    "content": "//go:build !linux && !darwin && !windows\n\npackage mtu\n\nimport \"net\"\n\nfunc configurePMTUSocket(_ *net.UDPConn, _ int) error {\n\treturn nil\n}\n\nfunc socketPathMTU(_ *net.UDPConn, _ int) int {\n\treturn 0\n}\n\nfunc isSendSizeErr(_ error) bool {\n\treturn false\n}\n\nfunc isRecvSizeErr(_ error) bool {\n\treturn false\n}\n"
  },
  {
    "path": "trace/mtu/socketopts_windows.go",
    "content": "//go:build windows\n\npackage mtu\n\nimport (\n\t\"errors\"\n\t\"net\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n\t\"golang.org/x/sys/windows\"\n)\n\nconst (\n\tipDontFragment = 14\n\tipv6DontFrag   = 14\n)\n\nfunc configurePMTUSocket(conn *net.UDPConn, ipVersion int) error {\n\trawConn, err := conn.SyscallConn()\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar controlErr error\n\tif err := rawConn.Control(func(fd uintptr) {\n\t\tif ipVersion == 6 {\n\t\t\tcontrolErr = windows.SetsockoptInt(\n\t\t\t\twindows.Handle(fd),\n\t\t\t\twindows.IPPROTO_IPV6,\n\t\t\t\twindows.IPV6_MTU_DISCOVER,\n\t\t\t\twindows.IP_PMTUDISC_PROBE,\n\t\t\t)\n\t\t\tif controlErr != nil {\n\t\t\t\tcontrolErr = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, ipv6DontFrag, 1)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tcontrolErr = windows.SetsockoptInt(\n\t\t\twindows.Handle(fd),\n\t\t\twindows.IPPROTO_IP,\n\t\t\twindows.IP_MTU_DISCOVER,\n\t\t\twindows.IP_PMTUDISC_PROBE,\n\t\t)\n\t\tif controlErr != nil {\n\t\t\tcontrolErr = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IP, ipDontFragment, 1)\n\t\t}\n\t}); err != nil {\n\t\treturn err\n\t}\n\treturn controlErr\n}\n\nfunc socketPathMTU(conn *net.UDPConn, _ int) int {\n\tif conn == nil {\n\t\treturn 0\n\t}\n\taddr, ok := conn.LocalAddr().(*net.UDPAddr)\n\tif !ok || addr == nil || addr.IP == nil {\n\t\treturn 0\n\t}\n\treturn util.GetMTUByIPForDevice(addr.IP, \"\")\n}\n\nfunc isSendSizeErr(err error) bool {\n\treturn errors.Is(err, windows.WSAEMSGSIZE)\n}\n\nfunc isRecvSizeErr(err error) bool {\n\treturn errors.Is(err, windows.WSAEMSGSIZE)\n}\n"
  },
  {
    "path": "trace/mtu/types.go",
    "content": "package mtu\n\nimport (\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\ntype Event string\n\nconst (\n\tEventTimeExceeded Event = \"time_exceeded\"\n\tEventPacketTooBig Event = \"packet_too_big\"\n\tEventFragNeeded   Event = \"frag_needed\"\n\tEventDestination  Event = \"destination\"\n\tEventTimeout      Event = \"timeout\"\n)\n\ntype StreamEventKind string\n\nconst (\n\tStreamEventTTLStart  StreamEventKind = \"ttl_start\"\n\tStreamEventTTLUpdate StreamEventKind = \"ttl_update\"\n\tStreamEventTTLFinal  StreamEventKind = \"ttl_final\"\n\tStreamEventDone      StreamEventKind = \"done\"\n)\n\ntype Config struct {\n\tTarget         string\n\tDstIP          net.IP\n\tSrcIP          net.IP\n\tSourceDevice   string\n\tSrcPort        int\n\tDstPort        int\n\tBeginHop       int\n\tMaxHops        int\n\tQueries        int\n\tTimeout        time.Duration\n\tTTLInterval    time.Duration\n\tRDNS           bool\n\tAlwaysWaitRDNS bool\n\tIPGeoSource    ipgeo.Source\n\tLang           string\n}\n\ntype Hop struct {\n\tTTL      int              `json:\"ttl\"`\n\tEvent    Event            `json:\"event\"`\n\tIP       string           `json:\"ip,omitempty\"`\n\tHostname string           `json:\"hostname,omitempty\"`\n\tRTTMs    float64          `json:\"rtt_ms,omitempty\"`\n\tPMTU     int              `json:\"pmtu,omitempty\"`\n\tGeo      *ipgeo.IPGeoData `json:\"geo,omitempty\"`\n}\n\ntype Result struct {\n\tTarget     string `json:\"target\"`\n\tResolvedIP string `json:\"resolved_ip\"`\n\tProtocol   string `json:\"protocol\"`\n\tIPVersion  int    `json:\"ip_version\"`\n\tStartMTU   int    `json:\"start_mtu\"`\n\tProbeSize  int    `json:\"probe_size\"`\n\tPathMTU    int    `json:\"path_mtu\"`\n\tHops       []Hop  `json:\"hops\"`\n}\n\ntype StreamEvent struct {\n\tKind       StreamEventKind `json:\"kind\"`\n\tTTL        int             `json:\"ttl,omitempty\"`\n\tHop        Hop             `json:\"hop,omitempty\"`\n\tTarget     string          `json:\"target\"`\n\tResolvedIP string          `json:\"resolved_ip\"`\n\tProtocol   string          `json:\"protocol\"`\n\tIPVersion  int             `json:\"ip_version\"`\n\tStartMTU   int             `json:\"start_mtu\"`\n\tProbeSize  int             `json:\"probe_size\"`\n\tPathMTU    int             `json:\"path_mtu\"`\n}\n\ntype StreamSink func(StreamEvent)\n"
  },
  {
    "path": "trace/packet_size.go",
    "content": "package trace\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nconst (\n\tipv4HeaderBytes     = 20\n\tipv6HeaderBytes     = 40\n\ticmpHeaderBytes     = 8\n\tudpHeaderBytes      = 8\n\ttcpProbeHeaderBytes = 24\n\tudpV6MinPayload     = 2\n)\n\ntype PacketSizeSpec struct {\n\tPayloadSize int\n\tRandom      bool\n}\n\nfunc DefaultPacketSize(method Method, dstIP net.IP) int {\n\treturn MinPacketSize(method, dstIP)\n}\n\nfunc packetSizeIPHeaderBytes(dstIP net.IP) int {\n\tif util.IsIPv6(dstIP) {\n\t\treturn ipv6HeaderBytes\n\t}\n\treturn ipv4HeaderBytes\n}\n\nfunc packetSizeProtocolHeaderBytes(method Method) int {\n\tswitch method {\n\tcase TCPTrace:\n\t\treturn tcpProbeHeaderBytes\n\tcase UDPTrace:\n\t\treturn udpHeaderBytes\n\tdefault:\n\t\treturn icmpHeaderBytes\n\t}\n}\n\nfunc packetSizeMinPayload(method Method, dstIP net.IP) int {\n\tif method == UDPTrace && util.IsIPv6(dstIP) {\n\t\treturn udpV6MinPayload\n\t}\n\treturn 0\n}\n\nfunc MinPacketSize(method Method, dstIP net.IP) int {\n\treturn packetSizeIPHeaderBytes(dstIP) + packetSizeProtocolHeaderBytes(method) + packetSizeMinPayload(method, dstIP)\n}\n\nfunc NormalizePacketSize(method Method, dstIP net.IP, packetSize int) (PacketSizeSpec, error) {\n\trandom := packetSize < 0\n\tpacketSizeAbs := packetSize\n\tif random {\n\t\tpacketSizeAbs = -packetSizeAbs\n\t}\n\n\tminSize := MinPacketSize(method, dstIP)\n\tif packetSizeAbs < minSize {\n\t\treturn PacketSizeSpec{}, fmt.Errorf(\"packet size %d is too small for %s over %s; minimum is %d\", packetSize, method, packetSizeFamilyLabel(dstIP), minSize)\n\t}\n\n\tpayloadSize := packetSizeAbs - packetSizeIPHeaderBytes(dstIP) - packetSizeProtocolHeaderBytes(method)\n\tif payloadSize < packetSizeMinPayload(method, dstIP) {\n\t\treturn PacketSizeSpec{}, fmt.Errorf(\"packet size %d is too small for %s over %s; minimum is %d\", packetSize, method, packetSizeFamilyLabel(dstIP), minSize)\n\t}\n\n\treturn PacketSizeSpec{\n\t\tPayloadSize: payloadSize,\n\t\tRandom:      random,\n\t}, nil\n}\n\nfunc resolveProbePayloadSize(method Method, dstIP net.IP, maxPayloadSize int, randomPerProbe bool) int {\n\tminPayload := packetSizeMinPayload(method, dstIP)\n\tif !randomPerProbe || maxPayloadSize <= minPayload {\n\t\treturn maxPayloadSize\n\t}\n\treturn minPayload + rand.Intn(maxPayloadSize-minPayload+1)\n}\n\nfunc packetSizeFamilyLabel(dstIP net.IP) string {\n\tif util.IsIPv6(dstIP) {\n\t\treturn \"IPv6\"\n\t}\n\treturn \"IPv4\"\n}\n\nfunc FormatPacketSizeLabel(packetSize int) string {\n\tif packetSize < 0 {\n\t\treturn fmt.Sprintf(\"random <= %d byte packets\", -packetSize)\n\t}\n\treturn fmt.Sprintf(\"%d byte packets\", packetSize)\n}\n"
  },
  {
    "path": "trace/packet_size_test.go",
    "content": "package trace\n\nimport (\n\t\"net\"\n\t\"testing\"\n)\n\nfunc TestNormalizePacketSize(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tmethod     Method\n\t\tip         net.IP\n\t\tpacketSize int\n\t\twantSize   int\n\t\twantRandom bool\n\t}{\n\t\t{name: \"icmp4\", method: ICMPTrace, ip: net.ParseIP(\"1.1.1.1\"), packetSize: 52, wantSize: 24},\n\t\t{name: \"udp4\", method: UDPTrace, ip: net.ParseIP(\"1.1.1.1\"), packetSize: 52, wantSize: 24},\n\t\t{name: \"icmp6\", method: ICMPTrace, ip: net.ParseIP(\"2001:db8::1\"), packetSize: 64, wantSize: 16},\n\t\t{name: \"udp6\", method: UDPTrace, ip: net.ParseIP(\"2001:db8::1\"), packetSize: 64, wantSize: 16},\n\t\t{name: \"tcp4\", method: TCPTrace, ip: net.ParseIP(\"1.1.1.1\"), packetSize: 64, wantSize: 20},\n\t\t{name: \"tcp6-random\", method: TCPTrace, ip: net.ParseIP(\"2001:db8::1\"), packetSize: -96, wantSize: 32, wantRandom: true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tspec, err := NormalizePacketSize(tt.method, tt.ip, tt.packetSize)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"NormalizePacketSize() error = %v\", err)\n\t\t\t}\n\t\t\tif spec.PayloadSize != tt.wantSize {\n\t\t\t\tt.Fatalf(\"PayloadSize = %d, want %d\", spec.PayloadSize, tt.wantSize)\n\t\t\t}\n\t\t\tif spec.Random != tt.wantRandom {\n\t\t\t\tt.Fatalf(\"Random = %v, want %v\", spec.Random, tt.wantRandom)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNormalizePacketSizeRejectsTooSmallValues(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tmethod     Method\n\t\tip         net.IP\n\t\tpacketSize int\n\t}{\n\t\t{name: \"icmp4\", method: ICMPTrace, ip: net.ParseIP(\"1.1.1.1\"), packetSize: 27},\n\t\t{name: \"icmp4-zero\", method: ICMPTrace, ip: net.ParseIP(\"1.1.1.1\"), packetSize: 0},\n\t\t{name: \"icmp6\", method: ICMPTrace, ip: net.ParseIP(\"2001:db8::1\"), packetSize: 47},\n\t\t{name: \"udp6\", method: UDPTrace, ip: net.ParseIP(\"2001:db8::1\"), packetSize: 49},\n\t\t{name: \"tcp4\", method: TCPTrace, ip: net.ParseIP(\"1.1.1.1\"), packetSize: 43},\n\t\t{name: \"tcp6\", method: TCPTrace, ip: net.ParseIP(\"2001:db8::1\"), packetSize: 63},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif _, err := NormalizePacketSize(tt.method, tt.ip, tt.packetSize); err == nil {\n\t\t\t\tt.Fatal(\"NormalizePacketSize() error = nil, want error\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMinPacketSize(t *testing.T) {\n\ttests := []struct {\n\t\tmethod Method\n\t\tip     net.IP\n\t\twant   int\n\t}{\n\t\t{method: ICMPTrace, ip: net.ParseIP(\"1.1.1.1\"), want: 28},\n\t\t{method: UDPTrace, ip: net.ParseIP(\"1.1.1.1\"), want: 28},\n\t\t{method: ICMPTrace, ip: net.ParseIP(\"2001:db8::1\"), want: 48},\n\t\t{method: UDPTrace, ip: net.ParseIP(\"2001:db8::1\"), want: 50},\n\t\t{method: TCPTrace, ip: net.ParseIP(\"1.1.1.1\"), want: 44},\n\t\t{method: TCPTrace, ip: net.ParseIP(\"2001:db8::1\"), want: 64},\n\t}\n\n\tfor _, tt := range tests {\n\t\tif got := MinPacketSize(tt.method, tt.ip); got != tt.want {\n\t\t\tt.Fatalf(\"MinPacketSize(%s, %v) = %d, want %d\", tt.method, tt.ip, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestDefaultPacketSizeMatchesMinimum(t *testing.T) {\n\tip := net.ParseIP(\"2a00:1450:4009:81a::200e\")\n\tif got := DefaultPacketSize(TCPTrace, ip); got != 64 {\n\t\tt.Fatalf(\"DefaultPacketSize(TCPTrace, %v) = %d, want 64\", ip, got)\n\t}\n}\n"
  },
  {
    "path": "trace/quic.go",
    "content": "package trace\n\nimport (\n\t\"math/rand\"\n\t\"time\"\n)\n\n// 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}\n\n//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}\n\nfunc GenerateQuicPayloadWithRandomIds() []byte {\n\tvar beginHeader = []byte{202, 255, 255, 255, 255}\n\tvar packet []byte\n\tvar r = rand.New(rand.NewSource(time.Now().UnixNano()))\n\tpacket = append(packet, beginHeader...)\n\t// append the length of destConnectionID\n\tpacket = append(packet, 10)\n\n\t// generate random destConnectionId and append\n\tdestConnectionId := make([]byte, 10)\n\tr.Read(destConnectionId)\n\tpacket = append(packet, destConnectionId...)\n\n\t// append the length of destConnectionID\n\tpacket = append(packet, 4)\n\n\t// generate random srcConnectionId and append\n\tsrcConnectionId := make([]byte, 4)\n\tr.Read(srcConnectionId)\n\tpacket = append(packet, srcConnectionId...)\n\n\tfor i := len(packet); i < 1200; i++ {\n\t\tpacket = append(packet, 0)\n\t}\n\n\treturn packet\n}\n"
  },
  {
    "path": "trace/tcp_ipv4.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/sync/semaphore\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype TCPTracer struct {\n\tConfig\n\twg        sync.WaitGroup\n\tres       Result\n\tpending   map[int]struct{}\n\tpendingMu sync.Mutex\n\tsentAt    map[int]sentInfo\n\tsentMu    sync.RWMutex\n\tSrcIP     net.IP\n\tfinal     atomic.Int32\n\tsem       *semaphore.Weighted\n\tmatchQ    chan matchTask\n\treadyICMP chan struct{}\n\treadyTCP  chan struct{}\n}\n\nfunc (t *TCPTracer) waitAllReady(ctx context.Context) {\n\ttimeout := time.After(5 * time.Second)\n\twaiting := 2\n\tfor waiting > 0 {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.readyICMP:\n\t\t\twaiting--\n\t\tcase <-t.readyTCP:\n\t\t\twaiting--\n\t\tcase <-timeout:\n\t\t\treturn\n\t\t}\n\t}\n\t<-time.After(100 * time.Millisecond)\n}\n\nfunc (t *TCPTracer) ttlComp(ttl int) bool {\n\tidx := ttl - 1\n\tt.res.lock.RLock()\n\tdefer t.res.lock.RUnlock()\n\treturn idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements\n}\n\nfunc (t *TCPTracer) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) {\n\tdefer t.wg.Done()\n\n\tttl := t.BeginHop - 1\n\tticker := time.NewTicker(200 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tif t.AsyncPrinter != nil {\n\t\t\tt.AsyncPrinter(&t.res)\n\t\t}\n\n\t\t// 接收的时候检查一下是不是 3 跳都齐了\n\t\tif t.ttlComp(ttl + 1) {\n\t\t\tif t.RealtimePrinter != nil {\n\t\t\t\tt.res.waitGeo(ctx, ttl)\n\t\t\t\tt.RealtimePrinter(&t.res, ttl)\n\t\t\t}\n\t\t\tttl++\n\t\t\tif ttl == int(t.final.Load()) || ttl >= t.MaxHops {\n\t\t\t\tcancel(errNaturalDone) // 标记为“自然完成”\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t}\n\t}\n}\n\nfunc (t *TCPTracer) launchTTL(ctx context.Context, s *internal.TCPSpec, ttl int) {\n\tgo func(ttl int) {\n\t\tfor i := 0; i < t.MaxAttempts; i++ {\n\t\t\t// 若此 TTL 已完成或 ctx 已取消，则不再发起新的尝试\n\t\t\tif t.ttlComp(ttl) || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tt.wg.Add(1)\n\t\t\tgo func(ttl, i int) {\n\t\t\t\tif err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) {\n\t\t\t\t\tif util.EnvDevMode {\n\t\t\t\t\t\tpanic(err)\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"send error (ttl=%d, attempt=%d): %v\\n\", ttl, i, err)\n\t\t\t\t}\n\t\t\t}(ttl, i)\n\n\t\t\tif i+1 == t.MaxAttempts {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}(ttl)\n}\n\nfunc (t *TCPTracer) markPending(seq int) {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\tt.pending[seq] = struct{}{}\n}\n\nfunc (t *TCPTracer) clearPending(seq int) bool {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\t_, ok := t.pending[seq]\n\tdelete(t.pending, seq)\n\treturn ok\n}\n\nfunc (t *TCPTracer) storeSent(seq, srcPort, payloadSize int, start time.Time) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tt.sentAt[seq] = sentInfo{srcPort: srcPort, payloadSize: payloadSize, start: start}\n}\n\nfunc (t *TCPTracer) lookupSent(seq int) (srcPort int, start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\tsi, ok := t.sentAt[seq]\n\tif !ok {\n\t\treturn 0, time.Time{}, false\n\t}\n\treturn si.srcPort, si.start, true\n}\n\nfunc (t *TCPTracer) lookupSentByAck(srcPort, ack int) (seq int, start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\treturn lookupTCPSentByAck(t.sentAt, srcPort, ack)\n}\n\nfunc (t *TCPTracer) dropSent(seq int) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tdelete(t.sentAt, seq)\n}\n\nfunc (t *TCPTracer) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) {\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn\n\t}\n\n\tif ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) {\n\t\tfor {\n\t\t\told := t.final.Load()\n\t\t\tif old != -1 && ttl >= int(old) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif t.final.CompareAndSwap(old, int32(ttl)) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\th := Hop{\n\t\tSuccess: true,\n\t\tAddress: peer,\n\t\tTTL:     ttl,\n\t\tRTT:     rtt,\n\t\tMPLS:    mpls,\n\t}\n\tt.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config)\n}\n\nfunc (t *TCPTracer) matchWorker(ctx context.Context) {\n\tdefer t.wg.Done()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase task, ok := <-t.matchQ:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 固定等待 10ms，缓解登记竞态\n\t\t\ttimer := time.NewTimer(10 * time.Millisecond)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\ttimer.Stop()\n\t\t\t\treturn\n\t\t\tcase <-timer.C:\n\t\t\t}\n\t\t\ttimer.Stop()\n\n\t\t\t// 尝试一次匹配\n\t\t\tvar (\n\t\t\t\tsrcPort int\n\t\t\t\tstart   time.Time\n\t\t\t\tmatched bool\n\t\t\t)\n\t\t\tif task.ack != 0 {\n\t\t\t\ttask.seq, start, matched = t.lookupSentByAck(task.srcPort, task.ack)\n\t\t\t\tsrcPort = task.srcPort\n\t\t\t} else {\n\t\t\t\tsrcPort, start, matched = t.lookupSent(task.seq)\n\t\t\t}\n\t\t\tif !matched || task.srcPort != srcPort {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 将 task.seq 转为 32 位无符号数\n\t\t\tu := uint32(task.seq)\n\n\t\t\t// 高 8 位是 TTL\n\t\t\tttl := int((u >> 24) & 0xFF)\n\n\t\t\t// 低 24 位是索引 i\n\t\t\ti := int(u & 0xFFFFFF)\n\n\t\t\tif t.clearPending(task.seq) {\n\t\t\t\trtt := task.finish.Sub(start)\n\t\t\t\tt.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls)\n\t\t\t}\n\t\t\tt.dropSent(task.seq)\n\t\t}\n\t}\n}\n\nfunc (t *TCPTracer) Execute() (res *Result, err error) {\n\t// 初始化 pending、sentAt 和 matchQ\n\tt.pending = make(map[int]struct{})\n\tt.sentAt = make(map[int]sentInfo)\n\tt.matchQ = make(chan matchTask, 60)\n\n\t// 创建就绪通道\n\tt.readyICMP = make(chan struct{})\n\tt.readyTCP = make(chan struct{})\n\n\tif len(t.res.Hops) > 0 {\n\t\treturn &t.res, errTracerouteExecuted\n\t}\n\n\t// 初始化 res.Hops 和 res.tailDone，并预分配到 MaxHops\n\tt.res.Hops = make([][]Hop, t.MaxHops)\n\tt.res.tailDone = make([]bool, t.MaxHops)\n\tt.res.setGeoWait(t.NumMeasurements)\n\n\t// 解析并校验用户指定的 IPv4 源地址\n\tSrcAddr := net.ParseIP(t.SrcAddr).To4()\n\tif t.SrcAddr != \"\" && SrcAddr == nil {\n\t\treturn nil, errors.New(\"invalid IPv4 SrcAddr:\" + t.SrcAddr)\n\t}\n\tt.SrcIP, _ = util.LocalIPPort(t.DstIP, SrcAddr, \"tcp\")\n\tif t.SrcIP == nil {\n\t\treturn nil, errors.New(\"cannot determine local IPv4 address\")\n\t}\n\n\ts := internal.NewTCPSpec(\n\t\t4,\n\t\tt.ICMPMode,\n\t\tt.SrcIP,\n\t\tt.DstIP,\n\t\tt.DstPort,\n\t\tt.PktSize,\n\t)\n\ts.SourceDevice = t.SourceDevice\n\n\ts.InitICMP()\n\ts.InitTCP()\n\tdefer s.Close()\n\n\tbaseCtx := t.Context\n\tif baseCtx == nil {\n\t\tbaseCtx = context.Background()\n\t}\n\tsigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM)\n\tctx, cancel := context.WithCancelCause(sigCtx)\n\tt.final.Store(-1)\n\n\tworkerN := 16\n\tfor i := 0; i < workerN; i++ {\n\t\tt.wg.Add(1)\n\t\tgo t.matchWorker(ctx)\n\t}\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\t\t\tt.handleICMPMessage(msg, finish, data)\n\t\t},\n\t\t)\n\t}()\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenTCP(ctx, t.readyTCP, func(srcPort, seq, ack int, peer net.Addr, finish time.Time) {\n\t\t\t// 非阻塞投递，队列满则丢弃任务\n\t\t\tselect {\n\t\t\tcase t.matchQ <- matchTask{\n\t\t\t\tsrcPort: srcPort, seq: seq, ack: ack, peer: peer, finish: finish, mpls: nil,\n\t\t\t}:\n\t\t\tdefault:\n\t\t\t\t// 丢弃以避免阻塞抓包循环\n\t\t\t}\n\t\t})\n\t}()\n\tt.waitAllReady(ctx)\n\tt.wg.Add(1)\n\tgo t.PrintFunc(ctx, cancel)\n\n\tt.sem = semaphore.NewWeighted(int64(t.ParallelRequests))\n\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\t// 立即启动 BeginHop 对应的 TTL 组\n\t\tt.launchTTL(ctx, s, t.BeginHop)\n\n\t\tfor ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ {\n\t\t\t// 之后按 TTLInterval 周期启动后续 TTL 组\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 如果到达最终跳，则退出\n\t\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 并发启动这个 TTL 的所有测量\n\t\t\tt.launchTTL(ctx, s, ttl)\n\t\t}\n\t}()\n\n\t<-ctx.Done()\n\tstop()\n\tt.wg.Wait()\n\n\tfinal := int(t.final.Load())\n\tif final == -1 {\n\t\tfinal = t.MaxHops\n\t}\n\tt.res.reduce(final)\n\n\tif cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) {\n\t\treturn &t.res, cause\n\t}\n\treturn &t.res, nil\n}\n\nfunc (t *TCPTracer) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\tmpls := extractMPLS(msg, t.DisableMPLS)\n\n\theader, err := util.GetICMPResponsePayload(data)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tsrcPort, dstPort, err := util.GetTCPPorts(header)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif dstPort != t.DstPort {\n\t\treturn\n\t}\n\n\tseq, err := util.GetTCPSeq(header)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// 非阻塞投递；如果队列已满则直接丢弃该任务\n\tselect {\n\tcase t.matchQ <- matchTask{\n\t\tsrcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls,\n\t}:\n\tdefault:\n\t\t// 丢弃以避免阻塞抓包循环\n\t}\n}\n\nfunc (t *TCPTracer) send(ctx context.Context, s *internal.TCPSpec, ttl, i int) error {\n\tdefer t.wg.Done()\n\n\tif t.ttlComp(ttl) {\n\t\t// 快路径短路：若该 TTL 已完成，直接返回避免竞争信号量与无谓发包\n\t\treturn nil\n\t}\n\n\tif err := acquireTraceSemaphore(ctx, t.sem); err != nil {\n\t\treturn err\n\t}\n\tdefer t.sem.Release(1)\n\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn nil\n\t}\n\n\tif t.ttlComp(ttl) {\n\t\t// 竞态兜底：获取信号量期间可能已完成，再次检查以避免冗余发包\n\t\treturn nil\n\t}\n\n\t// 将 TTL 编码到高 8 位；将索引 i 编码到低 24 位\n\tseq := (ttl << 24) | (i & 0xFFFFFF)\n\n\t_, SrcPort := func() (net.IP, int) {\n\t\tif !util.RandomPortEnabled() && t.SrcPort > 0 {\n\t\t\treturn nil, t.SrcPort\n\t\t}\n\t\treturn util.LocalIPPort(t.DstIP, t.SrcIP, \"tcp\")\n\t}()\n\n\tipHeader := &layers.IPv4{\n\t\tVersion:  4,\n\t\tSrcIP:    t.SrcIP,\n\t\tDstIP:    t.DstIP,\n\t\tProtocol: layers.IPProtocolTCP,\n\t\tTTL:      uint8(ttl),\n\t\tTOS:      uint8(t.TOS),\n\t}\n\n\ttcpHeader := &layers.TCP{\n\t\tSrcPort: layers.TCPPort(SrcPort),\n\t\tDstPort: layers.TCPPort(t.DstPort),\n\t\tSeq:     uint32(seq),\n\t\tSYN:     true,\n\t\tWindow:  65535,\n\t\tOptions: []layers.TCPOption{\n\t\t\t{OptionType: layers.TCPOptionKindMSS, OptionLength: 4, OptionData: []byte{0x05, 0xb4}}, // MSS=1460\n\t\t},\n\t}\n\n\tdesiredPayloadSize := resolveProbePayloadSize(TCPTrace, t.DstIP, t.PktSize, t.RandomPacketSize)\n\tpayload := make([]byte, desiredPayloadSize)\n\n\t// 设置随机种子\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\tfor k := range payload {\n\t\tpayload[k] = byte(r.Intn(256))\n\t}\n\n\t// 登记 pending，并启动超时守护\n\tt.markPending(seq)\n\tgo func(seq, ttl, i int) {\n\t\tif !waitForTraceDelay(ctx, t.Timeout) {\n\t\t\t_ = t.clearPending(seq)\n\t\t\treturn\n\t\t}\n\t\tif !t.clearPending(seq) {\n\t\t\treturn\n\t\t}\n\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\treturn\n\t\t}\n\t\tif t.ttlComp(ttl) {\n\t\t\treturn\n\t\t}\n\n\t\th := Hop{\n\t\t\tSuccess: false,\n\t\t\tAddress: nil,\n\t\t\tTTL:     ttl,\n\t\t\tRTT:     0,\n\t\t\tError:   errHopLimitTimeout,\n\t\t}\n\n\t\t_, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts)\n\t\tt.dropSent(seq)\n\t}(seq, ttl, i)\n\n\tstart, err := s.SendTCP(ctx, ipHeader, tcpHeader, payload)\n\tif err != nil {\n\t\t_ = t.clearPending(seq)\n\t\treturn err\n\t}\n\tt.storeSent(seq, SrcPort, desiredPayloadSize, start)\n\treturn nil\n}\n"
  },
  {
    "path": "trace/tcp_ipv6.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/sync/semaphore\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype TCPTracerIPv6 struct {\n\tConfig\n\twg        sync.WaitGroup\n\tres       Result\n\tpending   map[int]struct{}\n\tpendingMu sync.Mutex\n\tsentAt    map[int]sentInfo\n\tsentMu    sync.RWMutex\n\tSrcIP     net.IP\n\tfinal     atomic.Int32\n\tsem       *semaphore.Weighted\n\tmatchQ    chan matchTask\n\treadyICMP chan struct{}\n\treadyTCP  chan struct{}\n}\n\nfunc (t *TCPTracerIPv6) waitAllReady(ctx context.Context) {\n\ttimeout := time.After(5 * time.Second)\n\twaiting := 2\n\tfor waiting > 0 {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.readyICMP:\n\t\t\twaiting--\n\t\tcase <-t.readyTCP:\n\t\t\twaiting--\n\t\tcase <-timeout:\n\t\t\treturn\n\t\t}\n\t}\n\t<-time.After(100 * time.Millisecond)\n}\n\nfunc (t *TCPTracerIPv6) ttlComp(ttl int) bool {\n\tidx := ttl - 1\n\tt.res.lock.RLock()\n\tdefer t.res.lock.RUnlock()\n\treturn idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements\n}\n\nfunc (t *TCPTracerIPv6) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) {\n\tdefer t.wg.Done()\n\n\tttl := t.BeginHop - 1\n\tticker := time.NewTicker(200 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tif t.AsyncPrinter != nil {\n\t\t\tt.AsyncPrinter(&t.res)\n\t\t}\n\n\t\t// 接收的时候检查一下是不是 3 跳都齐了\n\t\tif t.ttlComp(ttl + 1) {\n\t\t\tif t.RealtimePrinter != nil {\n\t\t\t\tt.res.waitGeo(ctx, ttl)\n\t\t\t\tt.RealtimePrinter(&t.res, ttl)\n\t\t\t}\n\t\t\tttl++\n\t\t\tif ttl == int(t.final.Load()) || ttl >= t.MaxHops {\n\t\t\t\tcancel(errNaturalDone) // 标记为“自然完成”\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t}\n\t}\n}\n\nfunc (t *TCPTracerIPv6) launchTTL(ctx context.Context, s *internal.TCPSpec, ttl int) {\n\tgo func(ttl int) {\n\t\tfor i := 0; i < t.MaxAttempts; i++ {\n\t\t\t// 若此 TTL 已完成或 ctx 已取消，则不再发起新的尝试\n\t\t\tif t.ttlComp(ttl) || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tt.wg.Add(1)\n\t\t\tgo func(ttl, i int) {\n\t\t\t\tif err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) {\n\t\t\t\t\tif util.EnvDevMode {\n\t\t\t\t\t\tpanic(err)\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"send error (ttl=%d, attempt=%d): %v\\n\", ttl, i, err)\n\t\t\t\t}\n\t\t\t}(ttl, i)\n\n\t\t\tif i+1 == t.MaxAttempts {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}(ttl)\n}\n\nfunc (t *TCPTracerIPv6) markPending(seq int) {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\tt.pending[seq] = struct{}{}\n}\n\nfunc (t *TCPTracerIPv6) clearPending(seq int) bool {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\t_, ok := t.pending[seq]\n\tdelete(t.pending, seq)\n\treturn ok\n}\n\nfunc (t *TCPTracerIPv6) storeSent(seq, srcPort, payloadSize int, start time.Time) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tt.sentAt[seq] = sentInfo{srcPort: srcPort, payloadSize: payloadSize, start: start}\n}\n\nfunc (t *TCPTracerIPv6) lookupSent(seq int) (srcPort int, start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\tsi, ok := t.sentAt[seq]\n\tif !ok {\n\t\treturn 0, time.Time{}, false\n\t}\n\treturn si.srcPort, si.start, true\n}\n\nfunc (t *TCPTracerIPv6) lookupSentByAck(srcPort, ack int) (seq int, start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\treturn lookupTCPSentByAck(t.sentAt, srcPort, ack)\n}\n\nfunc (t *TCPTracerIPv6) dropSent(seq int) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tdelete(t.sentAt, seq)\n}\n\nfunc (t *TCPTracerIPv6) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) {\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn\n\t}\n\n\tif ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) {\n\t\tfor {\n\t\t\told := t.final.Load()\n\t\t\tif old != -1 && ttl >= int(old) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif t.final.CompareAndSwap(old, int32(ttl)) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\th := Hop{\n\t\tSuccess: true,\n\t\tAddress: peer,\n\t\tTTL:     ttl,\n\t\tRTT:     rtt,\n\t\tMPLS:    mpls,\n\t}\n\tt.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config)\n}\n\nfunc (t *TCPTracerIPv6) matchWorker(ctx context.Context) {\n\tdefer t.wg.Done()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase task, ok := <-t.matchQ:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 固定等待 10ms，缓解登记竞态\n\t\t\ttimer := time.NewTimer(10 * time.Millisecond)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\ttimer.Stop()\n\t\t\t\treturn\n\t\t\tcase <-timer.C:\n\t\t\t}\n\t\t\ttimer.Stop()\n\n\t\t\t// 尝试一次匹配\n\t\t\tvar (\n\t\t\t\tsrcPort int\n\t\t\t\tstart   time.Time\n\t\t\t\tmatched bool\n\t\t\t)\n\t\t\tif task.ack != 0 {\n\t\t\t\ttask.seq, start, matched = t.lookupSentByAck(task.srcPort, task.ack)\n\t\t\t\tsrcPort = task.srcPort\n\t\t\t} else {\n\t\t\t\tsrcPort, start, matched = t.lookupSent(task.seq)\n\t\t\t}\n\t\t\tif !matched || task.srcPort != srcPort {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 将 task.seq 转为 32 位无符号数\n\t\t\tu := uint32(task.seq)\n\n\t\t\t// 高 8 位是 TTL\n\t\t\tttl := int((u >> 24) & 0xFF)\n\n\t\t\t// 低 24 位是索引 i\n\t\t\ti := int(u & 0xFFFFFF)\n\n\t\t\tif t.clearPending(task.seq) {\n\t\t\t\trtt := task.finish.Sub(start)\n\t\t\t\tt.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls)\n\t\t\t}\n\t\t\tt.dropSent(task.seq)\n\t\t}\n\t}\n}\n\nfunc (t *TCPTracerIPv6) Execute() (res *Result, err error) {\n\t// 初始化 pending、sentAt 和 matchQ\n\tt.pending = make(map[int]struct{})\n\tt.sentAt = make(map[int]sentInfo)\n\tt.matchQ = make(chan matchTask, 60)\n\n\t// 创建就绪通道\n\tt.readyICMP = make(chan struct{})\n\tt.readyTCP = make(chan struct{})\n\n\tif len(t.res.Hops) > 0 {\n\t\treturn &t.res, errTracerouteExecuted\n\t}\n\n\t// 初始化 res.Hops 和 res.tailDone，并预分配到 MaxHops\n\tt.res.Hops = make([][]Hop, t.MaxHops)\n\tt.res.tailDone = make([]bool, t.MaxHops)\n\tt.res.setGeoWait(t.NumMeasurements)\n\n\t// 解析并校验用户指定的 IPv6 源地址\n\tSrcAddr := net.ParseIP(t.SrcAddr)\n\tif t.SrcAddr != \"\" && !util.IsIPv6(SrcAddr) {\n\t\treturn nil, errors.New(\"invalid IPv6 SrcAddr: \" + t.SrcAddr)\n\t}\n\tt.SrcIP, _ = util.LocalIPPortv6(t.DstIP, SrcAddr, \"tcp6\")\n\tif t.SrcIP == nil {\n\t\treturn nil, errors.New(\"cannot determine local IPv6 address\")\n\t}\n\n\ts := internal.NewTCPSpec(\n\t\t6,\n\t\tt.ICMPMode,\n\t\tt.SrcIP,\n\t\tt.DstIP,\n\t\tt.DstPort,\n\t\tt.PktSize,\n\t)\n\ts.SourceDevice = t.SourceDevice\n\n\ts.InitICMP()\n\ts.InitTCP()\n\tdefer s.Close()\n\n\tbaseCtx := t.Context\n\tif baseCtx == nil {\n\t\tbaseCtx = context.Background()\n\t}\n\tsigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM)\n\tctx, cancel := context.WithCancelCause(sigCtx)\n\tt.final.Store(-1)\n\n\tworkerN := 16\n\tfor i := 0; i < workerN; i++ {\n\t\tt.wg.Add(1)\n\t\tgo t.matchWorker(ctx)\n\t}\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\t\t\tt.handleICMPMessage(msg, finish, data)\n\t\t},\n\t\t)\n\t}()\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenTCP(ctx, t.readyTCP, func(srcPort, seq, ack int, peer net.Addr, finish time.Time) {\n\t\t\t// 非阻塞投递，队列满则丢弃任务\n\t\t\tselect {\n\t\t\tcase t.matchQ <- matchTask{\n\t\t\t\tsrcPort: srcPort, seq: seq, ack: ack, peer: peer, finish: finish, mpls: nil,\n\t\t\t}:\n\t\t\tdefault:\n\t\t\t\t// 丢弃以避免阻塞抓包循环\n\t\t\t}\n\t\t})\n\t}()\n\tt.waitAllReady(ctx)\n\tt.wg.Add(1)\n\tgo t.PrintFunc(ctx, cancel)\n\n\tt.sem = semaphore.NewWeighted(int64(t.ParallelRequests))\n\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\t// 立即启动 BeginHop 对应的 TTL 组\n\t\tt.launchTTL(ctx, s, t.BeginHop)\n\n\t\tfor ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ {\n\t\t\t// 之后按 TTLInterval 周期启动后续 TTL 组\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 如果到达最终跳，则退出\n\t\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 并发启动这个 TTL 的所有测量\n\t\t\tt.launchTTL(ctx, s, ttl)\n\t\t}\n\t}()\n\n\t<-ctx.Done()\n\tstop()\n\tt.wg.Wait()\n\n\tfinal := int(t.final.Load())\n\tif final == -1 {\n\t\tfinal = t.MaxHops\n\t}\n\tt.res.reduce(final)\n\n\tif cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) {\n\t\treturn &t.res, cause\n\t}\n\treturn &t.res, nil\n}\n\nfunc (t *TCPTracerIPv6) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\tmpls := extractMPLS(msg, t.DisableMPLS)\n\n\theader, err := util.GetICMPResponsePayload(data)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tsrcPort, dstPort, err := util.GetTCPPorts(header)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif dstPort != t.DstPort {\n\t\treturn\n\t}\n\n\tseq, err := util.GetTCPSeq(header)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// 非阻塞投递；如果队列已满则直接丢弃该任务\n\tselect {\n\tcase t.matchQ <- matchTask{\n\t\tsrcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls,\n\t}:\n\tdefault:\n\t\t// 丢弃以避免阻塞抓包循环\n\t}\n}\n\nfunc (t *TCPTracerIPv6) send(ctx context.Context, s *internal.TCPSpec, ttl, i int) error {\n\tdefer t.wg.Done()\n\n\tif t.ttlComp(ttl) {\n\t\t// 快路径短路：若该 TTL 已完成，直接返回避免竞争信号量与无谓发包\n\t\treturn nil\n\t}\n\n\tif err := acquireTraceSemaphore(ctx, t.sem); err != nil {\n\t\treturn err\n\t}\n\tdefer t.sem.Release(1)\n\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn nil\n\t}\n\n\tif t.ttlComp(ttl) {\n\t\t// 竞态兜底：获取信号量期间可能已完成，再次检查以避免冗余发包\n\t\treturn nil\n\t}\n\n\t// 将 TTL 编码到高 8 位；将索引 i 编码到低 24 位\n\tseq := (ttl << 24) | (i & 0xFFFFFF)\n\n\t_, SrcPort := func() (net.IP, int) {\n\t\tif !util.RandomPortEnabled() && t.SrcPort > 0 {\n\t\t\treturn nil, t.SrcPort\n\t\t}\n\t\treturn util.LocalIPPortv6(t.DstIP, t.SrcIP, \"tcp6\")\n\t}()\n\n\tipHeader := &layers.IPv6{\n\t\tVersion:      6,\n\t\tSrcIP:        t.SrcIP,\n\t\tDstIP:        t.DstIP,\n\t\tNextHeader:   layers.IPProtocolTCP,\n\t\tHopLimit:     uint8(ttl),\n\t\tTrafficClass: uint8(t.TOS),\n\t}\n\n\ttcpHeader := &layers.TCP{\n\t\tSrcPort: layers.TCPPort(SrcPort),\n\t\tDstPort: layers.TCPPort(t.DstPort),\n\t\tSeq:     uint32(seq),\n\t\tSYN:     true,\n\t\tWindow:  65535,\n\t\tOptions: []layers.TCPOption{\n\t\t\t{OptionType: layers.TCPOptionKindMSS, OptionLength: 4, OptionData: []byte{0x05, 0xA0}}, // MSS=1440\n\t\t},\n\t}\n\n\tdesiredPayloadSize := resolveProbePayloadSize(TCPTrace, t.DstIP, t.PktSize, t.RandomPacketSize)\n\tpayload := make([]byte, desiredPayloadSize)\n\n\t// 设置随机种子\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\tfor k := range payload {\n\t\tpayload[k] = byte(r.Intn(256))\n\t}\n\n\t// 登记 pending，并启动超时守护\n\tt.markPending(seq)\n\tgo func(seq, ttl, i int) {\n\t\tif !waitForTraceDelay(ctx, t.Timeout) {\n\t\t\t_ = t.clearPending(seq)\n\t\t\treturn\n\t\t}\n\t\tif !t.clearPending(seq) {\n\t\t\treturn\n\t\t}\n\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\treturn\n\t\t}\n\t\tif t.ttlComp(ttl) {\n\t\t\treturn\n\t\t}\n\n\t\th := Hop{\n\t\t\tSuccess: false,\n\t\t\tAddress: nil,\n\t\t\tTTL:     ttl,\n\t\t\tRTT:     0,\n\t\t\tError:   errHopLimitTimeout,\n\t\t}\n\n\t\t_, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts)\n\t\tt.dropSent(seq)\n\t}(seq, ttl, i)\n\n\tstart, err := s.SendTCP(ctx, ipHeader, tcpHeader, payload)\n\tif err != nil {\n\t\t_ = t.clearPending(seq)\n\t\treturn err\n\t}\n\tt.storeSent(seq, SrcPort, desiredPayloadSize, start)\n\treturn nil\n}\n"
  },
  {
    "path": "trace/tcp_match.go",
    "content": "package trace\n\nimport \"time\"\n\nfunc tcpReplyAckForProbe(seq int, payloadSize int) int {\n\treturn int(uint32(seq) + 1 + uint32(payloadSize))\n}\n\nfunc lookupTCPSentByAck(sentAt map[int]sentInfo, srcPort, ack int) (seq int, start time.Time, ok bool) {\n\tfor candidateSeq, info := range sentAt {\n\t\tif info.srcPort != srcPort {\n\t\t\tcontinue\n\t\t}\n\t\tif tcpReplyAckForProbe(candidateSeq, info.payloadSize) != ack {\n\t\t\tcontinue\n\t\t}\n\t\treturn candidateSeq, info.start, true\n\t}\n\treturn 0, time.Time{}, false\n}\n"
  },
  {
    "path": "trace/tcp_match_test.go",
    "content": "package trace\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestLookupTCPSentByAck(t *testing.T) {\n\tstartA := time.Unix(10, 0)\n\tstartB := time.Unix(20, 0)\n\tsentAt := map[int]sentInfo{\n\t\t100: {srcPort: 40000, payloadSize: 20, start: startA},\n\t\t200: {srcPort: 40000, payloadSize: 48, start: startB},\n\t}\n\n\tseq, start, ok := lookupTCPSentByAck(sentAt, 40000, tcpReplyAckForProbe(200, 48))\n\tif !ok {\n\t\tt.Fatal(\"lookupTCPSentByAck() ok = false\")\n\t}\n\tif seq != 200 {\n\t\tt.Fatalf(\"seq = %d, want 200\", seq)\n\t}\n\tif !start.Equal(startB) {\n\t\tt.Fatalf(\"start = %v, want %v\", start, startB)\n\t}\n}\n"
  },
  {
    "path": "trace/temp_printer.go",
    "content": "package trace\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n)\n\nfunc HopPrinter(h Hop) {\n\tif h.Address == nil {\n\t\tfmt.Println(\"\\t*\")\n\t} else {\n\t\ttxt := \"\\t\"\n\n\t\tif h.Hostname == \"\" {\n\t\t\ttxt += fmt.Sprint(h.Address, \" \", fmt.Sprintf(\"%.2f\", h.RTT.Seconds()*1000), \"ms\")\n\t\t} else {\n\t\t\ttxt += fmt.Sprint(h.Hostname, \" (\", h.Address, \") \", fmt.Sprintf(\"%.2f\", h.RTT.Seconds()*1000), \"ms\")\n\t\t}\n\n\t\tif h.Geo != nil {\n\t\t\ttxt += \" \" + formatIpGeoData(h.Address.String(), h.Geo)\n\t\t}\n\n\t\tfmt.Println(txt)\n\t}\n}\n\nfunc formatIpGeoData(ip string, data *ipgeo.IPGeoData) string {\n\tvar res = make([]string, 0, 10)\n\n\tif data.Asnumber == \"\" {\n\t\tres = append(res, \"*\")\n\t} else {\n\t\tres = append(res, \"AS\"+data.Asnumber)\n\t}\n\n\t// TODO: 判断阿里云和腾讯云内网，数据不足，有待进一步完善\n\t// TODO: 移动IDC判断到Hop.fetchIPData函数，减少API调用\n\tif strings.HasPrefix(ip, \"9.\") {\n\t\tres = append(res, \"LAN Address\", \"\")\n\t} else if strings.HasPrefix(ip, \"11.\") {\n\t\tres = append(res, \"LAN Address\", \"\")\n\t} else if data.Country == \"\" {\n\t\tres = append(res, \"LAN Address\")\n\t} else {\n\t\t// 有些IP的归属信息为空，这个时候将ISP的信息填入\n\t\tif data.Owner == \"\" {\n\t\t\tdata.Owner = data.Isp\n\t\t}\n\t\tif data.District != \"\" {\n\t\t\tdata.City = data.City + \", \" + data.District\n\t\t}\n\t\tif data.Prov == \"\" && data.City == \"\" {\n\t\t\t// anyCast或是骨干网数据不应该有国家信息\n\t\t\tdata.Owner = data.Owner + \", \" + data.Owner\n\t\t} else {\n\t\t\t// 非骨干网正常填入IP的国家信息数据\n\t\t\tres = append(res, data.Country)\n\t\t}\n\n\t\tif data.Prov != \"\" {\n\t\t\tres = append(res, data.Prov)\n\t\t}\n\t\tif data.City != \"\" {\n\t\t\tres = append(res, data.City)\n\t\t}\n\n\t\tif data.Owner != \"\" {\n\t\t\tres = append(res, data.Owner)\n\t\t}\n\t}\n\n\treturn strings.Join(res, \", \")\n}\n"
  },
  {
    "path": "trace/trace.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"golang.org/x/net/idna\"\n\t\"golang.org/x/sync/semaphore\"\n\t\"golang.org/x/sync/singleflight\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nvar (\n\terrHopLimitTimeout    = errors.New(\"hop timeout\")\n\terrInvalidMethod      = errors.New(\"invalid method\")\n\terrNaturalDone        = errors.New(\"trace natural done\")\n\terrTracerouteExecuted = errors.New(\"traceroute already executed\")\n\tgeoCache              = sync.Map{}\n\tipGeoSF               singleflight.Group\n)\n\ntype Config struct {\n\tContext          context.Context\n\tOSType           int\n\tICMPMode         int\n\tSrcAddr          string\n\tSrcPort          int\n\tSourceDevice     string\n\tBeginHop         int\n\tMaxHops          int\n\tNumMeasurements  int\n\tMaxAttempts      int\n\tParallelRequests int\n\tTimeout          time.Duration\n\tDstIP            net.IP\n\tDstPort          int\n\tQuic             bool\n\tIPGeoSource      ipgeo.Source\n\tRDNS             bool\n\tAlwaysWaitRDNS   bool\n\tPacketInterval   int\n\tTTLInterval      int\n\tLang             string\n\tDN42             bool\n\tRealtimePrinter  func(res *Result, ttl int)\n\tAsyncPrinter     func(res *Result)\n\tPktSize          int\n\tRandomPacketSize bool\n\tTOS              int\n\tMaptrace         bool\n\tDisableMPLS      bool\n}\n\ntype Method string\n\nconst (\n\tICMPTrace Method = \"icmp\"\n\tUDPTrace  Method = \"udp\"\n\tTCPTrace  Method = \"tcp\"\n)\n\ntype attemptKey struct {\n\tttl int\n\ti   int\n}\n\ntype attemptPort struct {\n\tsrcPort int\n\ti       int\n}\n\ntype sentInfo struct {\n\tttl         int\n\ti           int\n\tsrcPort     int\n\tpayloadSize int\n\tstart       time.Time\n}\n\ntype matchTask struct {\n\tsrcPort int\n\tseq     int\n\tack     int\n\tpeer    net.Addr\n\tfinish  time.Time\n\tmpls    []string\n}\n\ntype Tracer interface {\n\tExecute() (*Result, error)\n}\n\nfunc applyTracerouteDefaults(config *Config) {\n\tif config == nil {\n\t\treturn\n\t}\n\tif config.MaxHops == 0 {\n\t\tconfig.MaxHops = 30\n\t}\n\tif config.NumMeasurements == 0 {\n\t\tconfig.NumMeasurements = 3\n\t}\n\tif config.ParallelRequests == 0 {\n\t\tconfig.ParallelRequests = config.NumMeasurements * 5\n\t}\n}\n\nfunc waitForTraceDelay(ctx context.Context, d time.Duration) bool {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif ctx != nil && ctx.Err() != nil {\n\t\treturn false\n\t}\n\tif d <= 0 {\n\t\treturn true\n\t}\n\ttimer := time.NewTimer(d)\n\tdefer stopAndDrainTimer(timer)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn false\n\tcase <-timer.C:\n\t\treturn ctx == nil || ctx.Err() == nil\n\t}\n}\n\nfunc stopAndDrainTimer(timer *time.Timer) {\n\tif timer == nil {\n\t\treturn\n\t}\n\tif timer.Stop() {\n\t\treturn\n\t}\n\tselect {\n\tcase <-timer.C:\n\tdefault:\n\t}\n}\n\nfunc acquireTraceSemaphore(ctx context.Context, sem *semaphore.Weighted) error {\n\tif sem == nil {\n\t\treturn nil\n\t}\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif err := ctx.Err(); err != nil {\n\t\treturn err\n\t}\n\treturn sem.Acquire(ctx, 1)\n}\n\nfunc normalizeICMPMode(config *Config) {\n\tif config == nil {\n\t\treturn\n\t}\n\tif config.ICMPMode <= 0 && util.EnvICMPMode > 0 {\n\t\tconfig.ICMPMode = util.EnvICMPMode\n\t}\n\tswitch config.ICMPMode {\n\tcase 0, 1, 2:\n\tdefault:\n\t\tconfig.ICMPMode = 0\n\t}\n}\n\nfunc deriveMaxAttempts(config *Config) {\n\tif config == nil {\n\t\treturn\n\t}\n\tif config.MaxAttempts <= 0 && util.EnvMaxAttempts > 0 {\n\t\tconfig.MaxAttempts = util.EnvMaxAttempts\n\t}\n\tif config.MaxAttempts > 0 && config.MaxAttempts >= config.NumMeasurements {\n\t\treturn\n\t}\n\n\tn := config.NumMeasurements\n\tswitch {\n\tcase n <= 2 || n >= 10:\n\t\tconfig.MaxAttempts = n\n\tcase n <= 6:\n\t\tconfig.MaxAttempts = n + 3\n\tdefault:\n\t\tconfig.MaxAttempts = 10\n\t}\n}\n\nfunc selectTracer(method Method, config Config) (Tracer, error) {\n\tisIPv4 := config.DstIP.To4() != nil\n\tswitch method {\n\tcase ICMPTrace:\n\t\tif isIPv4 {\n\t\t\treturn &ICMPTracer{Config: config}, nil\n\t\t}\n\t\treturn &ICMPTracerv6{Config: config}, nil\n\tcase UDPTrace:\n\t\tif isIPv4 {\n\t\t\treturn &UDPTracer{Config: config}, nil\n\t\t}\n\t\treturn &UDPTracerIPv6{Config: config}, nil\n\tcase TCPTrace:\n\t\tif isIPv4 {\n\t\t\treturn &TCPTracer{Config: config}, nil\n\t\t}\n\t\treturn &TCPTracerIPv6{Config: config}, nil\n\tdefault:\n\t\treturn nil, errInvalidMethod\n\t}\n}\n\nfunc waitForPendingGeoData(ctx context.Context, result *Result) {\n\tif result == nil {\n\t\treturn\n\t}\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tresult.geoWG.Wait()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\tcase <-ctxDoneChan(ctx):\n\tcase <-time.After(30 * time.Second):\n\t}\n\tresult.geoCanceled.Store(true)\n\tresult.markAllPendingGeoTimeout()\n}\n\nfunc ctxDoneChan(ctx context.Context) <-chan struct{} {\n\tif ctx == nil {\n\t\treturn nil\n\t}\n\treturn ctx.Done()\n}\n\nfunc Traceroute(method Method, config Config) (*Result, error) {\n\tnormalizeRuntimeConfig(&config)\n\tapplyTracerouteDefaults(&config)\n\tnormalizeICMPMode(&config)\n\tderiveMaxAttempts(&config)\n\n\ttracer, err := selectTracer(method, config)\n\tif err != nil {\n\t\treturn &Result{}, err\n\t}\n\n\tresult, err := tracer.Execute()\n\tif err != nil && errors.Is(err, syscall.EPERM) {\n\t\terr = fmt.Errorf(\"%w，请使用 root 权限运行\", err)\n\t}\n\twaitForPendingGeoData(config.Context, result)\n\treturn result, err\n}\n\nfunc TracerouteWithContext(ctx context.Context, method Method, config Config) (*Result, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tconfig.Context = ctx\n\treturn Traceroute(method, config)\n}\n\nfunc normalizeRuntimeConfig(config *Config) {\n\tif config == nil {\n\t\treturn\n\t}\n\tif config.SourceDevice == \"\" && util.SrcDev != \"\" {\n\t\tconfig.SourceDevice = util.SrcDev\n\t}\n}\n\ntype Result struct {\n\tHops        [][]Hop\n\tlock        sync.RWMutex\n\ttailDone    []bool\n\tTraceMapUrl string\n\tgeoWait     time.Duration\n\tgeoWG       sync.WaitGroup\n\tgeoCanceled atomic.Bool\n}\n\nconst PendingGeoSource = \"pending\"\nconst timeoutGeoSource = \"timeout\"\n\nfunc isPendingGeo(geo *ipgeo.IPGeoData) bool {\n\treturn geo != nil && geo.Source == PendingGeoSource\n}\n\nfunc pendingGeo() *ipgeo.IPGeoData {\n\treturn &ipgeo.IPGeoData{Source: PendingGeoSource}\n}\n\nfunc timeoutGeo() *ipgeo.IPGeoData {\n\treturn &ipgeo.IPGeoData{\n\t\tCountry:   \"网络故障\",\n\t\tCountryEn: \"Network Error\",\n\t\tSource:    timeoutGeoSource,\n\t}\n}\n\nfunc geoWaitForMeasurements(numMeasurements int) time.Duration {\n\tif numMeasurements <= 0 {\n\t\tnumMeasurements = 1\n\t}\n\tmaxRetries := numMeasurements - 1\n\tif maxRetries > 5 {\n\t\tmaxRetries = 5\n\t}\n\n\ttotal := 0\n\tfor attempt := 0; attempt <= maxRetries; attempt++ {\n\t\ttimeout := 2 + attempt\n\t\tif timeout > 6 {\n\t\t\ttimeout = 6\n\t\t}\n\t\ttotal += timeout\n\t}\n\treturn time.Duration(total) * time.Second\n}\n\n// 判定 Hop 是否“有效”\nfunc isValidHop(h Hop) bool {\n\treturn h.Success && h.Address != nil\n}\n\n// add 带审计/限容\n// - N = numMeasurements（每个 TTL 组的最小输出条数）\n// - M = maxAttempts（每个 TTL 组的最大尝试条数）\n// 规则：对同一 TTL，attemptIdx < N-1 无条件放行（索引 i 从 0 开始）；第 N 条进行审计（已有有效 / 当次有效 / 达到最后一次尝试 任一成立即放行）；超过 N 条一律忽略\nfunc (s *Result) add(hop Hop, attemptIdx, numMeasurements, maxAttempts int) (bool, int) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\n\tk := hop.TTL - 1\n\tbucket := s.Hops[k]\n\tn := numMeasurements\n\n\tswitch {\n\tcase attemptIdx < n-1:\n\t\t// attemptIdx < N-1：无条件放行\n\t\ts.Hops[k] = append(bucket, hop)\n\t\treturn true, len(s.Hops[k]) - 1\n\tcase attemptIdx >= n-1:\n\t\t// 正在决定第 N 条：审计\n\t\t// 放行条件（三选一）：\n\t\t// (1) 前 N-1 中已存在有效值\n\t\t// (2) 当前 hop 为有效值\n\t\t// (3) 已到最后一次尝试\n\t\tif len(bucket) >= n {\n\t\t\treturn false, -1\n\t\t}\n\n\t\tif s.tailDone[k] {\n\t\t\treturn false, -1\n\t\t}\n\n\t\thasValid := false\n\t\tfor _, h := range bucket {\n\t\t\tif isValidHop(h) {\n\t\t\t\thasValid = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif hasValid || isValidHop(hop) || attemptIdx >= maxAttempts-1 {\n\t\t\ts.Hops[k] = append(bucket, hop) // 填满第 N 个\n\t\t\ts.tailDone[k] = true\n\t\t\treturn true, len(s.Hops[k]) - 1\n\t\t}\n\t\t// 否则丢弃，等待后续更优候选（长度仍保持 N-1）\n\t\treturn false, -1\n\t}\n\treturn false, -1\n}\n\nfunc (s *Result) setGeoWait(numMeasurements int) {\n\ts.geoWait = geoWaitForMeasurements(numMeasurements)\n}\n\nfunc (s *Result) reduce(final int) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\n\tif final > 0 && final < len(s.Hops) {\n\t\ts.Hops = s.Hops[:final]\n\t}\n}\n\ntype Hop struct {\n\tSuccess  bool\n\tAddress  net.Addr\n\tHostname string\n\tTTL      int\n\tRTT      time.Duration\n\tError    error\n\tGeo      *ipgeo.IPGeoData\n\tLang     string\n\tMPLS     []string\n}\n\nfunc isLDHASCII(label string) bool {\n\tfor i := 0; i < len(label); i++ {\n\t\tb := label[i]\n\t\tif b > 0x7F {\n\t\t\treturn false\n\t\t}\n\t\tif (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' {\n\t\t\tcontinue\n\t\t}\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc CanonicalHostname(s string) string {\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉尾点\n\ts = strings.TrimSuffix(s, \".\")\n\t// 按标签逐个处理，确保仅对需要的标签做 IDNA 转换\n\tparts := strings.Split(s, \".\")\n\tfor i, label := range parts {\n\t\tif label == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isLDHASCII(label) {\n\t\t\t// 纯 ASCII 且仅含 LDH：保留原大小写，不做大小写折叠\n\t\t\tcontinue\n\t\t}\n\t\t// 含非 ASCII 或不满足 LDH：对该标签做 IDNA 转 ASCII\n\t\tif ascii, err := idna.Lookup.ToASCII(label); err == nil && ascii != \"\" {\n\t\t\tparts[i] = ascii\n\t\t}\n\t\t// 若转换失败，保留原标签\n\t}\n\treturn strings.Join(parts, \".\")\n}\n\nfunc (s *Result) updateHop(ttl, idx int, updated Hop) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\n\tk := ttl - 1\n\tif k < 0 || k >= len(s.Hops) {\n\t\treturn\n\t}\n\tif idx < 0 || idx >= len(s.Hops[k]) {\n\t\treturn\n\t}\n\n\th := &s.Hops[k][idx]\n\tif updated.Hostname != \"\" {\n\t\th.Hostname = updated.Hostname\n\t}\n\tif updated.Geo != nil {\n\t\th.Geo = updated.Geo\n\t}\n\tif updated.Lang != \"\" {\n\t\th.Lang = updated.Lang\n\t}\n}\n\nfunc (s *Result) waitGeo(ctx context.Context, ttlIdx int) {\n\tif s.geoWait <= 0 {\n\t\treturn\n\t}\n\tif ttlIdx < 0 {\n\t\treturn\n\t}\n\n\tdeadline := time.Now().Add(s.geoWait)\n\tfor {\n\t\tif !s.hasPendingGeo(ttlIdx) {\n\t\t\treturn\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\ts.markPendingGeoTimeout(ttlIdx)\n\t\t\treturn\n\t\t}\n\t\tif ctx == nil {\n\t\t\ttime.Sleep(20 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\ts.markPendingGeoTimeout(ttlIdx)\n\t\t\treturn\n\t\tcase <-time.After(20 * time.Millisecond):\n\t\t}\n\t}\n}\n\nfunc (s *Result) hasPendingGeo(ttlIdx int) bool {\n\ts.lock.RLock()\n\tdefer s.lock.RUnlock()\n\n\tif ttlIdx < 0 || ttlIdx >= len(s.Hops) {\n\t\treturn false\n\t}\n\n\tfor _, hop := range s.Hops[ttlIdx] {\n\t\tif hop.Address == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif isPendingGeo(hop.Geo) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (s *Result) markPendingGeoTimeout(ttlIdx int) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\n\tif ttlIdx < 0 || ttlIdx >= len(s.Hops) {\n\t\treturn\n\t}\n\n\tfor i := range s.Hops[ttlIdx] {\n\t\thop := &s.Hops[ttlIdx][i]\n\t\tif hop.Address == nil || !isPendingGeo(hop.Geo) {\n\t\t\tcontinue\n\t\t}\n\t\thop.Geo = timeoutGeo()\n\t}\n}\n\nfunc (s *Result) markAllPendingGeoTimeout() {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\n\tfor ttlIdx := range s.Hops {\n\t\tfor i := range s.Hops[ttlIdx] {\n\t\t\thop := &s.Hops[ttlIdx][i]\n\t\t\tif hop.Address == nil || !isPendingGeo(hop.Geo) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\thop.Geo = timeoutGeo()\n\t\t}\n\t}\n}\n\nfunc (s *Result) addWithGeoAsync(hop Hop, attemptIdx, numMeasurements, maxAttempts int, cfg Config) {\n\tif hop.Geo == nil {\n\t\thop.Geo = pendingGeo()\n\t} else if hop.Geo.Source == \"\" {\n\t\thop.Geo.Source = PendingGeoSource\n\t}\n\tif hop.Lang == \"\" {\n\t\thop.Lang = cfg.Lang\n\t}\n\n\tadded, idx := s.add(hop, attemptIdx, numMeasurements, maxAttempts)\n\tif !added {\n\t\treturn\n\t}\n\n\ts.geoWG.Add(1)\n\tgo func(ttl, idx int, h Hop) {\n\t\tdefer s.geoWG.Done()\n\t\t_ = h.fetchIPData(cfg)\n\t\tif !s.geoCanceled.Load() {\n\t\t\ts.updateHop(ttl, idx, h)\n\t\t}\n\t}(hop.TTL, idx, hop)\n}\n\nfunc geoLookupMaxRetries(numMeasurements int) int {\n\tmaxRetries := numMeasurements - 1\n\tif maxRetries < 0 {\n\t\treturn 0\n\t}\n\tif maxRetries > 5 {\n\t\treturn 5\n\t}\n\treturn maxRetries\n}\n\nfunc geoTimeoutForAttempt(attempt int) time.Duration {\n\ttimeout := 2 + attempt\n\tif timeout > 6 {\n\t\ttimeout = 6\n\t}\n\treturn time.Duration(timeout) * time.Second\n}\n\nfunc lookupGeoWithRetry(c Config, cacheKey, query string, dn42 bool) (*ipgeo.IPGeoData, error) {\n\tif cacheVal, ok := geoCache.Load(cacheKey); ok {\n\t\tif g, ok := cacheVal.(*ipgeo.IPGeoData); ok && g != nil {\n\t\t\treturn g, nil\n\t\t}\n\t}\n\n\ttypeErr := \"ipgeo: nil or bad type from singleflight\"\n\tlookupErr := \"ipgeo: lookup failed without specific error\"\n\tif dn42 {\n\t\ttypeErr += \" (DN42)\"\n\t\tlookupErr += \" (DN42)\"\n\t}\n\n\tvar lastErr error\n\tfor attempt := 0; attempt <= geoLookupMaxRetries(c.NumMeasurements); attempt++ {\n\t\ttimeout := geoTimeoutForAttempt(attempt)\n\t\tv, err, _ := ipGeoSF.Do(cacheKey, func() (any, error) {\n\t\t\treturn c.IPGeoSource(query, timeout, c.Lang, c.Maptrace)\n\t\t})\n\t\tif err != nil {\n\t\t\tlastErr = err\n\t\t\tcontinue\n\t\t}\n\n\t\tgeo, ok := v.(*ipgeo.IPGeoData)\n\t\tif !ok || geo == nil {\n\t\t\tlastErr = errors.New(typeErr)\n\t\t\tcontinue\n\t\t}\n\n\t\tgeoCache.Store(cacheKey, geo)\n\t\treturn geo, nil\n\t}\n\n\tif lastErr == nil {\n\t\tlastErr = errors.New(lookupErr)\n\t}\n\treturn nil, lastErr\n}\n\nfunc lookupPTR(ctx context.Context, ipStr string) []string {\n\tptrs, err := util.LookupAddrWithContext(ctx, ipStr)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif len(ptrs) > 0 {\n\t\treturn ptrs\n\t}\n\treturn nil\n}\n\nfunc applyPTRResult(h *Hop, ptrs []string) {\n\tif len(ptrs) > 0 {\n\t\th.Hostname = CanonicalHostname(ptrs[0])\n\t}\n}\n\nfunc startPTRLookup(ctx context.Context, ipStr string) <-chan []string {\n\trDNSCh := make(chan []string, 1)\n\tgo func() {\n\t\tselect {\n\t\tcase rDNSCh <- lookupPTR(ctx, ipStr):\n\t\tdefault:\n\t\t}\n\t}()\n\treturn rDNSCh\n}\n\nfunc (h *Hop) resolveDN42Metadata(c Config, ipStr string) error {\n\tcombined := ipStr\n\tif c.RDNS && h.Hostname == \"\" {\n\t\tapplyPTRResult(h, lookupPTR(c.Context, ipStr))\n\t\tif h.Hostname != \"\" {\n\t\t\tcombined = ipStr + \",\" + h.Hostname\n\t\t}\n\t}\n\tif c.IPGeoSource == nil {\n\t\treturn nil\n\t}\n\n\tgeo, err := lookupGeoWithRetry(c, combined, combined, true)\n\tif err != nil {\n\t\th.Geo = timeoutGeo()\n\t\treturn err\n\t}\n\th.Geo = geo\n\treturn nil\n}\n\nfunc (h *Hop) startGeoLookup(c Config, ipStr string) <-chan error {\n\tipGeoCh := make(chan error, 1)\n\tgo func() {\n\t\tif c.IPGeoSource == nil || (h.Geo != nil && !isPendingGeo(h.Geo)) {\n\t\t\tipGeoCh <- nil\n\t\t\treturn\n\t\t}\n\n\t\th.Lang = c.Lang\n\t\tif g, ok := ipgeo.Filter(ipStr); ok {\n\t\t\th.Geo = g\n\t\t\tipGeoCh <- nil\n\t\t\treturn\n\t\t}\n\n\t\tgeo, err := lookupGeoWithRetry(c, ipStr, ipStr, false)\n\t\tif err == nil {\n\t\t\th.Geo = geo\n\t\t}\n\t\tipGeoCh <- err\n\t}()\n\treturn ipGeoCh\n}\n\nfunc (h *Hop) waitForGeoAndPTR(c Config, ipGeoCh <-chan error, rDNSStarted bool, rDNSCh <-chan []string) error {\n\tif c.AlwaysWaitRDNS {\n\t\tif rDNSStarted {\n\t\t\tselect {\n\t\t\tcase ptrs := <-rDNSCh:\n\t\t\t\tapplyPTRResult(h, ptrs)\n\t\t\tcase <-time.After(1 * time.Second):\n\t\t\t}\n\t\t}\n\t\terr := <-ipGeoCh\n\t\tif err != nil {\n\t\t\th.Geo = timeoutGeo()\n\t\t}\n\t\treturn err\n\t}\n\n\tif rDNSStarted {\n\t\tselect {\n\t\tcase err := <-ipGeoCh:\n\t\t\tif err != nil {\n\t\t\t\th.Geo = timeoutGeo()\n\t\t\t}\n\t\t\treturn err\n\t\tcase ptrs := <-rDNSCh:\n\t\t\tapplyPTRResult(h, ptrs)\n\t\t\terr := <-ipGeoCh\n\t\t\tif err != nil {\n\t\t\t\th.Geo = timeoutGeo()\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n\n\terr := <-ipGeoCh\n\tif err != nil {\n\t\th.Geo = timeoutGeo()\n\t}\n\treturn err\n}\n\nfunc (h *Hop) fetchIPData(c Config) error {\n\tipStr := h.Address.String()\n\tif c.DN42 {\n\t\treturn h.resolveDN42Metadata(c, ipStr)\n\t}\n\n\tipGeoCh := h.startGeoLookup(c, ipStr)\n\trDNSStarted := c.RDNS && h.Hostname == \"\"\n\tvar rDNSCh <-chan []string\n\tif rDNSStarted {\n\t\trDNSCh = startPTRLookup(c.Context, ipStr)\n\t}\n\treturn h.waitForGeoAndPTR(c, ipGeoCh, rDNSStarted, rDNSCh)\n}\n\n// parse 安全解析十六进制子串 s 为无符号整数\nfunc parse(s string, bitSize int) (uint64, bool) {\n\tif len(s) == 0 {\n\t\treturn 0, false\n\t}\n\tv, err := strconv.ParseUint(s, 16, bitSize)\n\tif err != nil {\n\t\treturn 0, false\n\t}\n\treturn v, true\n}\n\n// findValid 在十六进制字符串 hexStr 中截取从 ICMP 扩展头开始的部分\nfunc findValid(hexStr string) string {\n\tn := len(hexStr)\n\t// 至少要能容纳 4B 扩展头，且 hexStr 的长度必须为偶数\n\tif n < 8 || n%2 != 0 {\n\t\treturn \"\"\n\t}\n\n\t// 从尾到头以 4B 为单位扫描（1B = 2 hex digits）\n\tfor i := n - 8; i >= 0; i -= 8 {\n\t\t// 直接匹配 \"2000\"\n\t\tif hexStr[i:i+4] != \"2000\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 处理扩展头 4B 后的剩余部分\n\t\tremHex := n - (i + 8) // 剩余的 hex 个数\n\t\tif remHex <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tremBytes := remHex / 2\n\t\t// 剩余部分长度必须 ≥ 8B\n\t\tif remBytes >= 8 {\n\t\t\treturn hexStr[i:]\n\t\t}\n\n\t\t// 否则继续向左寻找更早的 \"2000\"\n\t}\n\treturn \"\"\n}\n\nfunc extractMPLS(msg internal.ReceivedMessage, disableMPLS bool) []string {\n\tif disableMPLS {\n\t\treturn nil\n\t}\n\n\t// 将整包转换为十六进制字符串\n\thexStr := fmt.Sprintf(\"%x\", msg.Msg)\n\n\t// 调用 findValid 截取从 ICMP 扩展头开始的字符串\n\textStr := findValid(hexStr)\n\tif extStr == \"\" {\n\t\treturn nil\n\t}\n\n\tvar mplsLSEList []string\n\tn := len(extStr)\n\n\t// 先逐对象检查 Class 是否为 MPLS Label Stack Class (1)\n\tfor j := 8; j+8 <= n; {\n\t\t// 对象头：Length(2B) | Class(1B) | C-Type(1B)\n\t\tlengthU, ok := parse(extStr[j:j+4], 16)\n\t\tif !ok || lengthU < 4 {\n\t\t\treturn nil\n\t\t}\n\t\tobjLenBytes := int(lengthU)\n\t\tobjLenHex := objLenBytes * 2\n\t\tif j+objLenHex > n {\n\t\t\treturn nil\n\t\t}\n\n\t\t// 读取 Class 的值\n\t\tclassU, ok := parse(extStr[j+4:j+6], 8)\n\t\tif !ok {\n\t\t\treturn nil\n\t\t}\n\t\tclass := int(classU)\n\n\t\tif class == 1 {\n\t\t\t// 去掉扩展头与 MPLS 对象头，只保留 MPLS 对象负载\n\t\t\tpayloadStart := j + 8\n\t\t\tpayloadEnd := j + objLenHex\n\t\t\tif payloadEnd <= payloadStart {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tmplsPayload := extStr[payloadStart:payloadEnd] // 仅 LSE 区域\n\t\t\tif len(mplsPayload)%8 != 0 {                   // 每个 LSE = 4B = 8 hex digits\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// 逐个 LSE 解析并追加到 mplsLSEList\n\t\t\tfor off := 0; off+8 <= len(mplsPayload); off += 8 {\n\t\t\t\tvU, ok := parse(mplsPayload[off:off+8], 32)\n\t\t\t\tif !ok {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tv := uint32(vU)\n\n\t\t\t\tlbl := (v >> 12) & 0xFFFFF // 20 bits\n\t\t\t\ttc := (v >> 9) & 0x7       // 3 bits\n\t\t\t\ts := (v >> 8) & 0x1        // 1 bit\n\t\t\t\tttl := v & 0xFF            // 8 bits\n\n\t\t\t\tmplsLSEList = append(mplsLSEList, fmt.Sprintf(\"[MPLS: Lbl %d, TC %d, S %d, TTL %d]\", lbl, tc, s, ttl))\n\t\t\t}\n\t\t}\n\n\t\t// 跳到下一个对象\n\t\tj += objLenHex\n\t}\n\treturn mplsLSEList\n}\n"
  },
  {
    "path": "trace/trace_runtime_test.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"golang.org/x/sync/semaphore\"\n)\n\nfunc TestWaitForTraceDelayCanceledContextReturnsImmediately(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\tif waitForTraceDelay(ctx, time.Second) {\n\t\tt.Fatal(\"waitForTraceDelay should return false for canceled context\")\n\t}\n\tif elapsed := time.Since(start); elapsed > 50*time.Millisecond {\n\t\tt.Fatalf(\"waitForTraceDelay returned too slowly after cancel: %v\", elapsed)\n\t}\n}\n\nfunc TestWaitForTraceDelayZeroDelaySucceeds(t *testing.T) {\n\tif !waitForTraceDelay(context.Background(), 0) {\n\t\tt.Fatal(\"waitForTraceDelay should succeed for zero delay\")\n\t}\n}\n\nfunc TestAcquireTraceSemaphoreChecksCanceledContextFirst(t *testing.T) {\n\tsem := semaphore.NewWeighted(1)\n\tif err := sem.Acquire(context.Background(), 1); err != nil {\n\t\tt.Fatalf(\"initial acquire failed: %v\", err)\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\terr := acquireTraceSemaphore(ctx, sem)\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Fatalf(\"acquireTraceSemaphore error = %v, want context.Canceled\", err)\n\t}\n\tif elapsed := time.Since(start); elapsed > 50*time.Millisecond {\n\t\tt.Fatalf(\"acquireTraceSemaphore returned too slowly after cancel: %v\", elapsed)\n\t}\n\n\tsem.Release(1)\n\tif err := acquireTraceSemaphore(context.Background(), sem); err != nil {\n\t\tt.Fatalf(\"acquireTraceSemaphore should still acquire after release: %v\", err)\n\t}\n\tsem.Release(1)\n}\n\nfunc TestWaitForPendingGeoDataReturnsOnCanceledContext(t *testing.T) {\n\tres := &Result{\n\t\tHops: [][]Hop{{\n\t\t\t{\n\t\t\t\tAddress: &net.IPAddr{IP: net.ParseIP(\"1.1.1.1\")},\n\t\t\t\tGeo:     pendingGeo(),\n\t\t\t},\n\t\t}},\n\t}\n\tres.geoWG.Add(1)\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tdefer res.geoWG.Done()\n\t\t<-done\n\t}()\n\n\tstart := time.Now()\n\tcancel()\n\twaitForPendingGeoData(ctx, res)\n\tif elapsed := time.Since(start); elapsed > 100*time.Millisecond {\n\t\tt.Fatalf(\"waitForPendingGeoData returned too slowly after cancel: %v\", elapsed)\n\t}\n\tif !res.geoCanceled.Load() {\n\t\tt.Fatal(\"geoCanceled = false, want true\")\n\t}\n\tgeo := res.Hops[0][0].Geo\n\tif geo == nil || geo.Source != timeoutGeoSource {\n\t\tt.Fatalf(\"hop geo = %+v, want timeout geo\", geo)\n\t}\n\tclose(done)\n}\n\nfunc TestWaitForPendingGeoDataReturnsImmediatelyForCompletedWorkers(t *testing.T) {\n\tres := &Result{\n\t\tHops: [][]Hop{{\n\t\t\t{\n\t\t\t\tAddress: &net.IPAddr{IP: net.ParseIP(\"1.1.1.1\")},\n\t\t\t\tGeo:     &ipgeo.IPGeoData{CountryEn: \"US\"},\n\t\t\t},\n\t\t}},\n\t}\n\n\tstart := time.Now()\n\twaitForPendingGeoData(context.Background(), res)\n\tif elapsed := time.Since(start); elapsed > 100*time.Millisecond {\n\t\tt.Fatalf(\"waitForPendingGeoData returned too slowly for completed result: %v\", elapsed)\n\t}\n}\n"
  },
  {
    "path": "trace/udp_ipv4.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/sync/semaphore\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype UDPTracer struct {\n\tConfig\n\twg        sync.WaitGroup\n\tres       Result\n\tttlQueues map[int][]attemptPort\n\tttlQMu    sync.Mutex\n\tpending   map[attemptKey]struct{}\n\tpendingMu sync.Mutex\n\tsentAt    map[int]sentInfo\n\tsentMu    sync.RWMutex\n\tSrcIP     net.IP\n\tfinal     atomic.Int32\n\tsem       *semaphore.Weighted\n\tmatchQ    chan matchTask\n\treadyOut  chan struct{}\n\treadyICMP chan struct{}\n\treadyUDP  chan struct{}\n}\n\nfunc (t *UDPTracer) waitAllReady(ctx context.Context) {\n\ttimeout := time.After(5 * time.Second)\n\twaiting := 3\n\tfor waiting > 0 {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.readyOut:\n\t\t\twaiting--\n\t\tcase <-t.readyICMP:\n\t\t\twaiting--\n\t\tcase <-t.readyUDP:\n\t\t\twaiting--\n\t\tcase <-timeout:\n\t\t\treturn\n\t\t}\n\t}\n\t<-time.After(100 * time.Millisecond)\n}\n\nfunc (t *UDPTracer) ttlComp(ttl int) bool {\n\tidx := ttl - 1\n\tt.res.lock.RLock()\n\tdefer t.res.lock.RUnlock()\n\treturn idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements\n}\n\nfunc (t *UDPTracer) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) {\n\tdefer t.wg.Done()\n\n\tttl := t.BeginHop - 1\n\tticker := time.NewTicker(200 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tif t.AsyncPrinter != nil {\n\t\t\tt.AsyncPrinter(&t.res)\n\t\t}\n\n\t\t// 接收的时候检查一下是不是 3 跳都齐了\n\t\tif t.ttlComp(ttl + 1) {\n\t\t\tif t.RealtimePrinter != nil {\n\t\t\t\tt.res.waitGeo(ctx, ttl)\n\t\t\t\tt.RealtimePrinter(&t.res, ttl)\n\t\t\t}\n\t\t\tttl++\n\t\t\tif ttl == int(t.final.Load()) || ttl >= t.MaxHops {\n\t\t\t\tcancel(errNaturalDone) // 标记为“自然完成”\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t}\n\t}\n}\n\nfunc (t *UDPTracer) launchTTL(ctx context.Context, s *internal.UDPSpec, ttl int) {\n\tgo func(ttl int) {\n\t\tfor i := 0; i < t.MaxAttempts; i++ {\n\t\t\t// 若此 TTL 已完成或 ctx 已取消，则不再发起新的尝试\n\t\t\tif t.ttlComp(ttl) || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tt.wg.Add(1)\n\t\t\tgo func(ttl, i int) {\n\t\t\t\tif err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) {\n\t\t\t\t\tif util.EnvDevMode {\n\t\t\t\t\t\tpanic(err)\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"send error (ttl=%d, attempt=%d): %v\\n\", ttl, i, err)\n\t\t\t\t}\n\t\t\t}(ttl, i)\n\n\t\t\tif i+1 == t.MaxAttempts {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}(ttl)\n}\n\nfunc (t *UDPTracer) tryMatchTTLPort(ttl, srcPort int) (int, bool) {\n\tt.ttlQMu.Lock()\n\tdefer t.ttlQMu.Unlock()\n\tq := t.ttlQueues[ttl]\n\tif len(q) == 0 {\n\t\treturn 0, false\n\t}\n\thead := q[0]\n\tif head.srcPort != srcPort {\n\t\treturn 0, false\n\t}\n\tt.ttlQueues[ttl] = q[1:]\n\treturn head.i, true\n}\n\nfunc (t *UDPTracer) enqueueTTLPort(ttl, i, srcPort int) {\n\tap := attemptPort{srcPort: srcPort, i: i}\n\tt.ttlQMu.Lock()\n\tdefer t.ttlQMu.Unlock()\n\tt.ttlQueues[ttl] = append(t.ttlQueues[ttl], ap)\n}\n\nfunc (t *UDPTracer) markPending(ttl, i int) {\n\tkey := attemptKey{ttl: ttl, i: i}\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\tt.pending[key] = struct{}{}\n}\n\nfunc (t *UDPTracer) clearPending(ttl, i int) bool {\n\tkey := attemptKey{ttl: ttl, i: i}\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\t_, ok := t.pending[key]\n\tdelete(t.pending, key)\n\treturn ok\n}\n\nfunc (t *UDPTracer) storeSent(seq, ttl, i, srcPort int, start time.Time) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tif t.OSType != 1 {\n\t\tt.sentAt[seq] = sentInfo{srcPort: srcPort, start: start}\n\t} else {\n\t\tt.sentAt[seq] = sentInfo{ttl: ttl, i: i, srcPort: srcPort, start: start}\n\t}\n}\n\nfunc (t *UDPTracer) lookupSent(seq int) (ttl, i, srcPort int, start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\tsi, ok := t.sentAt[seq]\n\tif !ok {\n\t\treturn 0, 0, 0, time.Time{}, false\n\t}\n\treturn si.ttl, si.i, si.srcPort, si.start, true\n}\n\nfunc (t *UDPTracer) dropSent(seq int) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tdelete(t.sentAt, seq)\n}\n\nfunc (t *UDPTracer) dropByAttempt(ttl, i int) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tfor k, si := range t.sentAt {\n\t\tif si.ttl == ttl && si.i == i {\n\t\t\tdelete(t.sentAt, k)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (t *UDPTracer) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) {\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn\n\t}\n\n\tif ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) {\n\t\tfor {\n\t\t\told := t.final.Load()\n\t\t\tif old != -1 && ttl >= int(old) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif t.final.CompareAndSwap(old, int32(ttl)) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\th := Hop{\n\t\tSuccess: true,\n\t\tAddress: peer,\n\t\tTTL:     ttl,\n\t\tRTT:     rtt,\n\t\tMPLS:    mpls,\n\t}\n\tt.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config)\n}\n\nfunc (t *UDPTracer) matchWorker(ctx context.Context) {\n\tdefer t.wg.Done()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase task, ok := <-t.matchQ:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 固定等待 10ms，缓解登记竞态\n\t\t\ttimer := time.NewTimer(10 * time.Millisecond)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\ttimer.Stop()\n\t\t\t\treturn\n\t\t\tcase <-timer.C:\n\t\t\t}\n\t\t\ttimer.Stop()\n\n\t\t\t// 尝试一次匹配\n\t\t\tttl, i, srcPort, start, ok := t.lookupSent(task.seq)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif task.srcPort != srcPort {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif t.OSType != 1 {\n\t\t\t\t// 将 task.seq 转为 16 位无符号数\n\t\t\t\tu := uint16(task.seq)\n\n\t\t\t\t// 高 8 位是 TTL\n\t\t\t\tttl = int((u >> 8) & 0xFF)\n\n\t\t\t\t// 低 8 位是索引 i\n\t\t\t\ti = int(u & 0xFF)\n\t\t\t}\n\n\t\t\tif t.clearPending(ttl, i) {\n\t\t\t\trtt := task.finish.Sub(start)\n\t\t\t\tt.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls)\n\t\t\t}\n\t\t\tt.dropSent(task.seq)\n\t\t}\n\t}\n}\n\nfunc (t *UDPTracer) Execute() (res *Result, err error) {\n\t// 初始化 ttlQueues、pending、sentAt 和 matchQ\n\tt.ttlQueues = make(map[int][]attemptPort)\n\tt.pending = make(map[attemptKey]struct{})\n\tt.sentAt = make(map[int]sentInfo)\n\tt.matchQ = make(chan matchTask, 60)\n\n\t// 创建就绪通道\n\tt.readyOut = make(chan struct{})\n\tt.readyICMP = make(chan struct{})\n\tt.readyUDP = make(chan struct{})\n\n\tif len(t.res.Hops) > 0 {\n\t\treturn &t.res, errTracerouteExecuted\n\t}\n\n\t// 初始化 res.Hops 和 res.tailDone，并预分配到 MaxHops\n\tt.res.Hops = make([][]Hop, t.MaxHops)\n\tt.res.tailDone = make([]bool, t.MaxHops)\n\tt.res.setGeoWait(t.NumMeasurements)\n\n\t// 解析并校验用户指定的 IPv4 源地址\n\tSrcAddr := net.ParseIP(t.SrcAddr).To4()\n\tif t.SrcAddr != \"\" && SrcAddr == nil {\n\t\treturn nil, errors.New(\"invalid IPv4 SrcAddr:\" + t.SrcAddr)\n\t}\n\tt.SrcIP, _ = util.LocalIPPort(t.DstIP, SrcAddr, \"udp\")\n\tif t.SrcIP == nil {\n\t\treturn nil, errors.New(\"cannot determine local IPv4 address\")\n\t}\n\n\ts := internal.NewUDPSpec(\n\t\t4,\n\t\tt.ICMPMode,\n\t\tt.SrcIP,\n\t\tt.DstIP,\n\t\tt.DstPort,\n\t)\n\ts.SourceDevice = t.SourceDevice\n\n\ts.InitICMP()\n\ts.InitUDP()\n\tdefer s.Close()\n\n\tbaseCtx := t.Context\n\tif baseCtx == nil {\n\t\tbaseCtx = context.Background()\n\t}\n\tsigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM)\n\tctx, cancel := context.WithCancelCause(sigCtx)\n\tt.final.Store(-1)\n\n\tworkerN := 16\n\tfor i := 0; i < workerN; i++ {\n\t\tt.wg.Add(1)\n\t\tgo t.matchWorker(ctx)\n\t}\n\tif t.OSType == 1 {\n\t\tt.wg.Add(1)\n\t\tgo func() {\n\t\t\tdefer t.wg.Done()\n\t\t\ts.ListenOut(ctx, t.readyOut, func(srcPort, seq, ttl int, start time.Time) {\n\t\t\t\t// 严格按队列头端口匹配；不匹配就丢弃，避免混入其它进程/杂包\n\t\t\t\ti, ok := t.tryMatchTTLPort(ttl, srcPort)\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tt.storeSent(seq, ttl, i, srcPort, start)\n\t\t\t})\n\t\t}()\n\t} else {\n\t\tclose(t.readyOut)\n\t}\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\t\t\tt.handleICMPMessage(msg, finish, data)\n\t\t},\n\t\t)\n\t}()\n\tt.waitAllReady(ctx)\n\tt.wg.Add(1)\n\tgo t.PrintFunc(ctx, cancel)\n\n\tt.sem = semaphore.NewWeighted(int64(t.ParallelRequests))\n\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\t// 立即启动 BeginHop 对应的 TTL 组\n\t\tt.launchTTL(ctx, s, t.BeginHop)\n\n\t\tfor ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ {\n\t\t\t// 之后按 TTLInterval 周期启动后续 TTL 组\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 如果到达最终跳，则退出\n\t\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 并发启动这个 TTL 的所有测量\n\t\t\tt.launchTTL(ctx, s, ttl)\n\t\t}\n\t}()\n\n\t<-ctx.Done()\n\tstop()\n\tt.wg.Wait()\n\n\tfinal := int(t.final.Load())\n\tif final == -1 {\n\t\tfinal = t.MaxHops\n\t}\n\tt.res.reduce(final)\n\n\tif cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) {\n\t\treturn &t.res, cause\n\t}\n\treturn &t.res, nil\n}\n\nfunc (t *UDPTracer) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\tmpls := extractMPLS(msg, t.DisableMPLS)\n\n\tseq, err := util.GetUDPSeq(data)\n\tif err != nil {\n\t\treturn\n\t}\n\n\theader, err := util.GetICMPResponsePayload(data)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tsrcPort, dstPort, err := util.GetUDPPorts(header)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif dstPort != t.DstPort {\n\t\treturn\n\t}\n\n\t// 非阻塞投递；如果队列已满则直接丢弃该任务\n\tselect {\n\tcase t.matchQ <- matchTask{\n\t\tsrcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls,\n\t}:\n\tdefault:\n\t\t// 丢弃以避免阻塞抓包循环\n\t}\n}\n\nfunc randomPayload(size int, offset int) []byte {\n\tpayload := make([]byte, size)\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\tfor i := offset; i < size; i++ {\n\t\tpayload[i] = byte(r.Intn(256))\n\t}\n\treturn payload\n}\n\nfunc (t *UDPTracer) acquireSendPermit(ctx context.Context, ttl int) (func(), bool, error) {\n\tif t.ttlComp(ttl) {\n\t\treturn nil, true, nil\n\t}\n\tif err := acquireTraceSemaphore(ctx, t.sem); err != nil {\n\t\treturn nil, false, err\n\t}\n\trelease := func() { t.sem.Release(1) }\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\trelease()\n\t\treturn nil, true, nil\n\t}\n\tif t.ttlComp(ttl) {\n\t\trelease()\n\t\treturn nil, true, nil\n\t}\n\treturn release, false, nil\n}\n\nfunc (t *UDPTracer) resolveSourcePort() int {\n\tif !util.RandomPortEnabled() && t.SrcPort > 0 {\n\t\treturn t.SrcPort\n\t}\n\t_, srcPort := util.LocalIPPort(t.DstIP, t.SrcIP, \"udp\")\n\treturn srcPort\n}\n\nfunc (t *UDPTracer) buildUDPPacket(ttl, i, srcPort int) (int, *layers.IPv4, *layers.UDP, []byte) {\n\tseq := (ttl << 8) | (i & 0xFF)\n\tpayloadSize := resolveProbePayloadSize(UDPTrace, t.DstIP, t.PktSize, t.RandomPacketSize)\n\tipHeader := &layers.IPv4{\n\t\tVersion:  4,\n\t\tId:       uint16(seq),\n\t\tSrcIP:    t.SrcIP,\n\t\tDstIP:    t.DstIP,\n\t\tProtocol: layers.IPProtocolUDP,\n\t\tTTL:      uint8(ttl),\n\t\tTOS:      uint8(t.TOS),\n\t}\n\tudpHeader := &layers.UDP{\n\t\tSrcPort: layers.UDPPort(srcPort),\n\t\tDstPort: layers.UDPPort(t.DstPort),\n\t}\n\treturn seq, ipHeader, udpHeader, randomPayload(payloadSize, 0)\n}\n\nfunc (t *UDPTracer) startSendTimeout(ctx context.Context, ttl, i, seq int) {\n\tt.markPending(ttl, i)\n\tgo func(seq, ttl, i int) {\n\t\tif !waitForTraceDelay(ctx, t.Timeout) {\n\t\t\t_ = t.clearPending(ttl, i)\n\t\t\treturn\n\t\t}\n\t\tif !t.clearPending(ttl, i) {\n\t\t\treturn\n\t\t}\n\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\treturn\n\t\t}\n\t\tif t.ttlComp(ttl) {\n\t\t\treturn\n\t\t}\n\n\t\th := Hop{\n\t\t\tSuccess: false,\n\t\t\tAddress: nil,\n\t\t\tTTL:     ttl,\n\t\t\tRTT:     0,\n\t\t\tError:   errHopLimitTimeout,\n\t\t}\n\t\t_, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts)\n\t\tif t.OSType != 1 {\n\t\t\tt.dropSent(seq)\n\t\t\treturn\n\t\t}\n\t\tt.dropByAttempt(ttl, i)\n\t}(seq, ttl, i)\n}\n\nfunc (t *UDPTracer) prepareDarwinSend(ttl, i, srcPort int) {\n\tif t.OSType == 1 {\n\t\tt.enqueueTTLPort(ttl, i, srcPort)\n\t}\n}\n\nfunc (t *UDPTracer) finalizeSent(seq, srcPort int, start time.Time) {\n\tif t.OSType != 1 {\n\t\tt.storeSent(seq, 0, 0, srcPort, start)\n\t}\n}\n\nfunc (t *UDPTracer) send(ctx context.Context, s *internal.UDPSpec, ttl, i int) error {\n\tdefer t.wg.Done()\n\n\trelease, skip, err := t.acquireSendPermit(ctx, ttl)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif skip {\n\t\treturn nil\n\t}\n\tdefer release()\n\n\tsrcPort := t.resolveSourcePort()\n\tseq, ipHeader, udpHeader, payload := t.buildUDPPacket(ttl, i, srcPort)\n\tt.prepareDarwinSend(ttl, i, srcPort)\n\tt.startSendTimeout(ctx, ttl, i, seq)\n\tstart, err := s.SendUDP(ctx, ipHeader, udpHeader, payload)\n\tif err != nil {\n\t\t_ = t.clearPending(ttl, i)\n\t\treturn err\n\t}\n\tt.finalizeSent(seq, srcPort, start)\n\treturn nil\n}\n"
  },
  {
    "path": "trace/udp_ipv6.go",
    "content": "package trace\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/gopacket/layers\"\n\t\"golang.org/x/sync/semaphore\"\n\n\t\"github.com/nxtrace/NTrace-core/trace/internal\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype UDPTracerIPv6 struct {\n\tConfig\n\twg        sync.WaitGroup\n\tres       Result\n\tpending   map[int]struct{}\n\tpendingMu sync.Mutex\n\tsentAt    map[int]sentInfo\n\tsentMu    sync.RWMutex\n\tSrcIP     net.IP\n\tfinal     atomic.Int32\n\tsem       *semaphore.Weighted\n\tmatchQ    chan matchTask\n\treadyICMP chan struct{}\n\treadyUDP  chan struct{}\n}\n\nfunc (t *UDPTracerIPv6) waitAllReady(ctx context.Context) {\n\ttimeout := time.After(5 * time.Second)\n\twaiting := 2\n\tfor waiting > 0 {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-t.readyICMP:\n\t\t\twaiting--\n\t\tcase <-t.readyUDP:\n\t\t\twaiting--\n\t\tcase <-timeout:\n\t\t\treturn\n\t\t}\n\t}\n\t<-time.After(100 * time.Millisecond)\n}\n\nfunc (t *UDPTracerIPv6) ttlComp(ttl int) bool {\n\tidx := ttl - 1\n\tt.res.lock.RLock()\n\tdefer t.res.lock.RUnlock()\n\treturn idx < len(t.res.Hops) && len(t.res.Hops[idx]) >= t.NumMeasurements\n}\n\nfunc (t *UDPTracerIPv6) PrintFunc(ctx context.Context, cancel context.CancelCauseFunc) {\n\tdefer t.wg.Done()\n\n\tttl := t.BeginHop - 1\n\tticker := time.NewTicker(200 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tif t.AsyncPrinter != nil {\n\t\t\tt.AsyncPrinter(&t.res)\n\t\t}\n\n\t\t// 接收的时候检查一下是不是 3 跳都齐了\n\t\tif t.ttlComp(ttl + 1) {\n\t\t\tif t.RealtimePrinter != nil {\n\t\t\t\tt.res.waitGeo(ctx, ttl)\n\t\t\t\tt.RealtimePrinter(&t.res, ttl)\n\t\t\t}\n\t\t\tttl++\n\t\t\tif ttl == int(t.final.Load()) || ttl >= t.MaxHops {\n\t\t\t\tcancel(errNaturalDone) // 标记为“自然完成”\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t}\n\t}\n}\n\nfunc (t *UDPTracerIPv6) launchTTL(ctx context.Context, s *internal.UDPSpec, ttl int) {\n\tgo func(ttl int) {\n\t\tfor i := 0; i < t.MaxAttempts; i++ {\n\t\t\t// 若此 TTL 已完成或 ctx 已取消，则不再发起新的尝试\n\t\t\tif t.ttlComp(ttl) || ctx.Err() != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tt.wg.Add(1)\n\t\t\tgo func(ttl, i int) {\n\t\t\t\tif err := t.send(ctx, s, ttl, i); err != nil && !errors.Is(err, context.Canceled) {\n\t\t\t\t\tif util.EnvDevMode {\n\t\t\t\t\t\tpanic(err)\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Fprintf(os.Stderr, \"send error (ttl=%d, attempt=%d): %v\\n\", ttl, i, err)\n\t\t\t\t}\n\t\t\t}(ttl, i)\n\n\t\t\tif i+1 == t.MaxAttempts {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.PacketInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}(ttl)\n}\n\nfunc (t *UDPTracerIPv6) markPending(seq int) {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\tt.pending[seq] = struct{}{}\n}\n\nfunc (t *UDPTracerIPv6) clearPending(seq int) bool {\n\tt.pendingMu.Lock()\n\tdefer t.pendingMu.Unlock()\n\t_, ok := t.pending[seq]\n\tdelete(t.pending, seq)\n\treturn ok\n}\n\nfunc (t *UDPTracerIPv6) storeSent(seq, srcPort int, start time.Time) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tt.sentAt[seq] = sentInfo{srcPort: srcPort, start: start}\n}\n\nfunc (t *UDPTracerIPv6) lookupSent(seq int) (srcPort int, start time.Time, ok bool) {\n\tt.sentMu.RLock()\n\tdefer t.sentMu.RUnlock()\n\tsi, ok := t.sentAt[seq]\n\tif !ok {\n\t\treturn 0, time.Time{}, false\n\t}\n\treturn si.srcPort, si.start, true\n}\n\nfunc (t *UDPTracerIPv6) dropSent(seq int) {\n\tt.sentMu.Lock()\n\tdefer t.sentMu.Unlock()\n\tdelete(t.sentAt, seq)\n}\n\nfunc (t *UDPTracerIPv6) addHopWithIndex(peer net.Addr, ttl, i int, rtt time.Duration, mpls []string) {\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn\n\t}\n\n\tif ip := util.AddrIP(peer); ip != nil && ip.Equal(t.DstIP) {\n\t\tfor {\n\t\t\told := t.final.Load()\n\t\t\tif old != -1 && ttl >= int(old) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif t.final.CompareAndSwap(old, int32(ttl)) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\th := Hop{\n\t\tSuccess: true,\n\t\tAddress: peer,\n\t\tTTL:     ttl,\n\t\tRTT:     rtt,\n\t\tMPLS:    mpls,\n\t}\n\tt.res.addWithGeoAsync(h, i, t.NumMeasurements, t.MaxAttempts, t.Config)\n}\n\nfunc (t *UDPTracerIPv6) matchWorker(ctx context.Context) {\n\tdefer t.wg.Done()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase task, ok := <-t.matchQ:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 固定等待 10ms，缓解登记竞态\n\t\t\ttimer := time.NewTimer(10 * time.Millisecond)\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\ttimer.Stop()\n\t\t\t\treturn\n\t\t\tcase <-timer.C:\n\t\t\t}\n\t\t\ttimer.Stop()\n\n\t\t\t// 尝试一次匹配\n\t\t\tsrcPort, start, ok := t.lookupSent(task.seq)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif task.srcPort != srcPort {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 将 task.seq 转为 16 位无符号数\n\t\t\tu := uint16(task.seq)\n\n\t\t\t// 高 8 位是 TTL\n\t\t\tttl := int((u >> 8) & 0xFF)\n\n\t\t\t// 低 8 位是索引 i\n\t\t\ti := int(u & 0xFF)\n\n\t\t\tif t.clearPending(task.seq) {\n\t\t\t\trtt := task.finish.Sub(start)\n\t\t\t\tt.addHopWithIndex(task.peer, ttl, i, rtt, task.mpls)\n\t\t\t}\n\t\t\tt.dropSent(task.seq)\n\t\t}\n\t}\n}\n\nfunc (t *UDPTracerIPv6) Execute() (res *Result, err error) {\n\t// 初始化 pending、sentAt 和 matchQ\n\tt.pending = make(map[int]struct{})\n\tt.sentAt = make(map[int]sentInfo)\n\tt.matchQ = make(chan matchTask, 60)\n\n\t// 创建就绪通道\n\tt.readyICMP = make(chan struct{})\n\tt.readyUDP = make(chan struct{})\n\n\tif len(t.res.Hops) > 0 {\n\t\treturn &t.res, errTracerouteExecuted\n\t}\n\n\t// 初始化 res.Hops 和 res.tailDone，并预分配到 MaxHops\n\tt.res.Hops = make([][]Hop, t.MaxHops)\n\tt.res.tailDone = make([]bool, t.MaxHops)\n\tt.res.setGeoWait(t.NumMeasurements)\n\n\t// 解析并校验用户指定的 IPv6 源地址\n\tSrcAddr := net.ParseIP(t.SrcAddr)\n\tif t.SrcAddr != \"\" && !util.IsIPv6(SrcAddr) {\n\t\treturn nil, errors.New(\"invalid IPv6 SrcAddr: \" + t.SrcAddr)\n\t}\n\tt.SrcIP, _ = util.LocalIPPortv6(t.DstIP, SrcAddr, \"udp6\")\n\tif t.SrcIP == nil {\n\t\treturn nil, errors.New(\"cannot determine local IPv6 address\")\n\t}\n\n\ts := internal.NewUDPSpec(\n\t\t6,\n\t\tt.ICMPMode,\n\t\tt.SrcIP,\n\t\tt.DstIP,\n\t\tt.DstPort,\n\t)\n\ts.SourceDevice = t.SourceDevice\n\n\ts.InitICMP()\n\ts.InitUDP()\n\tdefer s.Close()\n\n\tbaseCtx := t.Context\n\tif baseCtx == nil {\n\t\tbaseCtx = context.Background()\n\t}\n\tsigCtx, stop := signal.NotifyContext(baseCtx, os.Interrupt, syscall.SIGTERM)\n\tctx, cancel := context.WithCancelCause(sigCtx)\n\tt.final.Store(-1)\n\n\tworkerN := 16\n\tfor i := 0; i < workerN; i++ {\n\t\tt.wg.Add(1)\n\t\tgo t.matchWorker(ctx)\n\t}\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\ts.ListenICMP(ctx, t.readyICMP, func(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\t\t\tt.handleICMPMessage(msg, finish, data)\n\t\t},\n\t\t)\n\t}()\n\tt.waitAllReady(ctx)\n\tt.wg.Add(1)\n\tgo t.PrintFunc(ctx, cancel)\n\n\tt.sem = semaphore.NewWeighted(int64(t.ParallelRequests))\n\n\tt.wg.Add(1)\n\tgo func() {\n\t\tdefer t.wg.Done()\n\t\t// 立即启动 BeginHop 对应的 TTL 组\n\t\tt.launchTTL(ctx, s, t.BeginHop)\n\n\t\tfor ttl := t.BeginHop + 1; ttl <= t.MaxHops; ttl++ {\n\t\t\t// 之后按 TTLInterval 周期启动后续 TTL 组\n\t\t\tif !waitForTraceDelay(ctx, time.Millisecond*time.Duration(t.TTLInterval)) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 如果到达最终跳，则退出\n\t\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 并发启动这个 TTL 的所有测量\n\t\t\tt.launchTTL(ctx, s, ttl)\n\t\t}\n\t}()\n\n\t<-ctx.Done()\n\tstop()\n\tt.wg.Wait()\n\n\tfinal := int(t.final.Load())\n\tif final == -1 {\n\t\tfinal = t.MaxHops\n\t}\n\tt.res.reduce(final)\n\n\tif cause := context.Cause(ctx); !errors.Is(cause, errNaturalDone) {\n\t\treturn &t.res, cause\n\t}\n\treturn &t.res, nil\n}\n\nfunc (t *UDPTracerIPv6) handleICMPMessage(msg internal.ReceivedMessage, finish time.Time, data []byte) {\n\tmpls := extractMPLS(msg, t.DisableMPLS)\n\n\theader, err := util.GetICMPResponsePayload(data)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tsrcPort, dstPort, err := util.GetUDPPorts(header)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif dstPort != t.DstPort {\n\t\treturn\n\t}\n\n\tseq, err := util.GetUDPSeqv6(header)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// 非阻塞投递；如果队列已满则直接丢弃该任务\n\tselect {\n\tcase t.matchQ <- matchTask{\n\t\tsrcPort: srcPort, seq: seq, peer: msg.Peer, finish: finish, mpls: mpls,\n\t}:\n\tdefault:\n\t\t// 丢弃以避免阻塞抓包循环\n\t}\n}\n\nfunc (t *UDPTracerIPv6) send(ctx context.Context, s *internal.UDPSpec, ttl, i int) error {\n\tdefer t.wg.Done()\n\n\tif t.ttlComp(ttl) {\n\t\t// 快路径短路：若该 TTL 已完成，直接返回避免竞争信号量与无谓发包\n\t\treturn nil\n\t}\n\n\tif err := acquireTraceSemaphore(ctx, t.sem); err != nil {\n\t\treturn err\n\t}\n\tdefer t.sem.Release(1)\n\n\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\treturn nil\n\t}\n\n\tif t.ttlComp(ttl) {\n\t\t// 竞态兜底：获取信号量期间可能已完成，再次检查以避免冗余发包\n\t\treturn nil\n\t}\n\n\t// 将 TTL 编码到高 8 位；将索引 i 编码到低 8 位\n\tseq := (ttl << 8) | (i & 0xFF)\n\n\t_, SrcPort := func() (net.IP, int) {\n\t\tif !util.RandomPortEnabled() && t.SrcPort > 0 {\n\t\t\treturn nil, t.SrcPort\n\t\t}\n\t\treturn util.LocalIPPortv6(t.DstIP, t.SrcIP, \"udp6\")\n\t}()\n\n\tipHeader := &layers.IPv6{\n\t\tVersion:      6,\n\t\tSrcIP:        t.SrcIP,\n\t\tDstIP:        t.DstIP,\n\t\tNextHeader:   layers.IPProtocolUDP,\n\t\tHopLimit:     uint8(ttl),\n\t\tTrafficClass: uint8(t.TOS),\n\t}\n\n\tudpHeader := &layers.UDP{\n\t\tSrcPort: layers.UDPPort(SrcPort),\n\t\tDstPort: layers.UDPPort(t.DstPort),\n\t}\n\n\tdesiredPayloadSize := resolveProbePayloadSize(UDPTrace, t.DstIP, t.PktSize, t.RandomPacketSize)\n\tpayload := make([]byte, desiredPayloadSize)\n\n\t// 设置随机种子\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\tfor k := 2; k < desiredPayloadSize; k++ {\n\t\tpayload[k] = byte(r.Intn(256))\n\t}\n\n\t// 通过 payload[0:2] 补偿，使 UDP.Checksum 精确等于 seq\n\tif err := util.MakePayloadWithTargetChecksum(payload, t.SrcIP, t.DstIP, SrcPort, t.DstPort, uint16(seq)); err != nil {\n\t\treturn err\n\t}\n\n\t// 登记 pending，并启动超时守护\n\tt.markPending(seq)\n\tgo func(seq, ttl, i int) {\n\t\tif !waitForTraceDelay(ctx, t.Timeout) {\n\t\t\t_ = t.clearPending(seq)\n\t\t\treturn\n\t\t}\n\t\tif !t.clearPending(seq) {\n\t\t\treturn\n\t\t}\n\t\tif f := t.final.Load(); f != -1 && ttl > int(f) {\n\t\t\treturn\n\t\t}\n\t\tif t.ttlComp(ttl) {\n\t\t\treturn\n\t\t}\n\n\t\th := Hop{\n\t\t\tSuccess: false,\n\t\t\tAddress: nil,\n\t\t\tTTL:     ttl,\n\t\t\tRTT:     0,\n\t\t\tError:   errHopLimitTimeout,\n\t\t}\n\n\t\t_, _ = t.res.add(h, i, t.NumMeasurements, t.MaxAttempts)\n\t\tt.dropSent(seq)\n\t}(seq, ttl, i)\n\n\tstart, err := s.SendUDP(ctx, ipHeader, udpHeader, payload)\n\tif err != nil {\n\t\t_ = t.clearPending(seq)\n\t\treturn err\n\t}\n\tt.storeSent(seq, SrcPort, start)\n\treturn nil\n}\n"
  },
  {
    "path": "tracelog/log.go",
    "content": "package tracelog\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/nxtrace/NTrace-core/internal/hoprender\"\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nvar DefaultPath = filepath.Join(os.TempDir(), \"trace.log\")\n\nfunc formatTraceLogWhois(whois string) string {\n\twhoisFormat := strings.Split(whois, \"-\")\n\tif len(whoisFormat) > 1 {\n\t\twhoisFormat[0] = strings.Join(whoisFormat[:2], \"-\")\n\t}\n\tif whoisFormat[0] == \"\" {\n\t\treturn \"\"\n\t}\n\treturn \"[\" + whoisFormat[0] + \"]\"\n}\n\nfunc traceLogLocationLine(hop *trace.Hop, ip string) string {\n\tif hop.Geo.Country == \"\" {\n\t\thop.Geo.Country = \"LAN Address\"\n\t}\n\tformat := \" %s %s %s %s %-6s\\n    %-39s   \"\n\tif net.ParseIP(ip).To4() == nil {\n\t\tformat = \" %s %s %s %s %-6s\\n    %-35s \"\n\t}\n\treturn fmt.Sprintf(format, hop.Geo.Country, hop.Geo.Prov, hop.Geo.City, hop.Geo.District, hop.Geo.Owner, hop.Hostname)\n}\n\nfunc traceLogTimingLine(values []string) string {\n\treturn strings.Join(values, \"/ \")\n}\n\nfunc OpenFile(path string) (*os.File, error) {\n\treturn os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)\n}\n\nfunc WriteHeader(w io.Writer, header string) error {\n\tif header == \"\" {\n\t\treturn nil\n\t}\n\t_, err := io.WriteString(w, header)\n\treturn err\n}\n\nfunc renderTraceLogLine(res *trace.Result, ttl int, group hoprender.Group, blockDisplay bool) string {\n\tvar builder strings.Builder\n\tif blockDisplay {\n\t\tbuilder.WriteString(fmt.Sprintf(\"%4s\", \"\"))\n\t}\n\n\tip := group.IP\n\tif net.ParseIP(ip).To4() == nil {\n\t\tbuilder.WriteString(fmt.Sprintf(\"%-25s \", ip))\n\t} else {\n\t\tbuilder.WriteString(fmt.Sprintf(\"%-15s \", ip))\n\t}\n\n\thop := &res.Hops[ttl][group.Index]\n\tif hop.Geo == nil {\n\t\thop.Geo = &ipgeo.IPGeoData{}\n\t}\n\n\tif hop.Geo.Asnumber != \"\" {\n\t\tbuilder.WriteString(fmt.Sprintf(\"AS%-7s\", hop.Geo.Asnumber))\n\t} else {\n\t\tbuilder.WriteString(fmt.Sprintf(\" %-8s\", \"*\"))\n\t}\n\tif net.ParseIP(ip).To4() != nil {\n\t\tbuilder.WriteString(fmt.Sprintf(\"%-16s\", formatTraceLogWhois(hop.Geo.Whois)))\n\t}\n\n\tbuilder.WriteString(traceLogLocationLine(hop, ip))\n\tbuilder.WriteString(traceLogTimingLine(group.Timings))\n\treturn builder.String()\n}\n\nfunc WriteRealtime(w io.Writer, res *trace.Result, ttl int) error {\n\tprefix := fmt.Sprintf(\"%-2d  \", ttl+1)\n\tgroups := hoprender.GroupHopAttempts(res.Hops[ttl])\n\tif len(groups) == 0 {\n\t\t_, err := fmt.Fprintln(w, prefix+\"*\")\n\t\treturn err\n\t}\n\n\tfor i, group := range groups {\n\t\tline := renderTraceLogLine(res, ttl, group, i > 0)\n\t\tif i == 0 {\n\t\t\tline = prefix + line\n\t\t}\n\t\tif _, err := fmt.Fprintln(w, line); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc NewRealtimePrinter(w io.Writer) func(res *trace.Result, ttl int) {\n\treturn func(res *trace.Result, ttl int) {\n\t\t_ = WriteRealtime(w, res, ttl)\n\t}\n}\n\nfunc RealtimePrinter(res *trace.Result, ttl int) {\n\tf, err := OpenFile(DefaultPath)\n\tif err != nil {\n\t\t_, _ = fmt.Fprintf(os.Stderr, \"open trace log %q failed: %v\\n\", DefaultPath, err)\n\t\t_ = WriteRealtime(os.Stdout, res, ttl)\n\t\treturn\n\t}\n\tdefer func() { _ = f.Close() }()\n\n\tw := io.MultiWriter(os.Stdout, f)\n\t_ = WriteRealtime(w, res, ttl)\n}\n"
  },
  {
    "path": "tracelog/log_test.go",
    "content": "package tracelog\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/ipgeo\"\n\t\"github.com/nxtrace/NTrace-core/trace\"\n)\n\nfunc testTraceLogResult() *trace.Result {\n\treturn &trace.Result{\n\t\tHops: [][]trace.Hop{\n\t\t\t{\n\t\t\t\t{\n\t\t\t\t\tTTL:      1,\n\t\t\t\t\tAddress:  &net.IPAddr{IP: net.ParseIP(\"192.0.2.1\")},\n\t\t\t\t\tHostname: \"router1\",\n\t\t\t\t\tRTT:      12 * time.Millisecond,\n\t\t\t\t\tGeo: &ipgeo.IPGeoData{\n\t\t\t\t\t\tAsnumber: \"13335\",\n\t\t\t\t\t\tCountry:  \"中国香港\",\n\t\t\t\t\t\tOwner:    \"Cloudflare\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc TestWriteHeader(t *testing.T) {\n\tvar buf bytes.Buffer\n\tif err := WriteHeader(&buf, \"header\\n\"); err != nil {\n\t\tt.Fatalf(\"WriteHeader returned error: %v\", err)\n\t}\n\tif got := buf.String(); got != \"header\\n\" {\n\t\tt.Fatalf(\"header = %q, want %q\", got, \"header\\n\")\n\t}\n}\n\nfunc TestWriteRealtimeUsesProvidedWriter(t *testing.T) {\n\tvar buf bytes.Buffer\n\tif err := WriteRealtime(&buf, testTraceLogResult(), 0); err != nil {\n\t\tt.Fatalf(\"WriteRealtime returned error: %v\", err)\n\t}\n\toutput := buf.String()\n\tfor _, want := range []string{\"1\", \"192.0.2.1\", \"AS13335\", \"Cloudflare\", \"12.00 ms\"} {\n\t\tif !strings.Contains(output, want) {\n\t\t\tt.Fatalf(\"output missing %q:\\n%q\", want, output)\n\t\t}\n\t}\n}\n\nfunc TestNewRealtimePrinterWrapsWriter(t *testing.T) {\n\tvar buf bytes.Buffer\n\tprinter := NewRealtimePrinter(&buf)\n\tprinter(testTraceLogResult(), 0)\n\tif buf.Len() == 0 {\n\t\tt.Fatal(\"expected writer to receive trace output\")\n\t}\n}\n\nfunc captureStdIO(t *testing.T, fn func()) (string, string) {\n\tt.Helper()\n\n\toldStdout := os.Stdout\n\toldStderr := os.Stderr\n\tstdoutR, stdoutW, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatalf(\"stdout pipe: %v\", err)\n\t}\n\tstderrR, stderrW, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatalf(\"stderr pipe: %v\", err)\n\t}\n\n\tos.Stdout = stdoutW\n\tos.Stderr = stderrW\n\tdefer func() {\n\t\tos.Stdout = oldStdout\n\t\tos.Stderr = oldStderr\n\t}()\n\n\tfn()\n\n\t_ = stdoutW.Close()\n\t_ = stderrW.Close()\n\n\tstdoutBytes, err := io.ReadAll(stdoutR)\n\tif err != nil {\n\t\tt.Fatalf(\"read stdout: %v\", err)\n\t}\n\tstderrBytes, err := io.ReadAll(stderrR)\n\tif err != nil {\n\t\tt.Fatalf(\"read stderr: %v\", err)\n\t}\n\treturn string(stdoutBytes), string(stderrBytes)\n}\n\nfunc TestDefaultPathUsesTempDir(t *testing.T) {\n\twant := filepath.Join(os.TempDir(), \"trace.log\")\n\tif DefaultPath != want {\n\t\tt.Fatalf(\"DefaultPath = %q, want %q\", DefaultPath, want)\n\t}\n}\n\nfunc TestRealtimePrinterFallsBackToStdoutWhenOpenFails(t *testing.T) {\n\toldDefaultPath := DefaultPath\n\tDefaultPath = t.TempDir()\n\tdefer func() { DefaultPath = oldDefaultPath }()\n\n\tstdout, stderr := captureStdIO(t, func() {\n\t\tRealtimePrinter(testTraceLogResult(), 0)\n\t})\n\n\tif !strings.Contains(stdout, \"192.0.2.1\") {\n\t\tt.Fatalf(\"stdout missing realtime output:\\n%q\", stdout)\n\t}\n\tif !strings.Contains(stderr, \"open trace log\") {\n\t\tt.Fatalf(\"stderr missing open failure:\\n%q\", stderr)\n\t}\n}\n"
  },
  {
    "path": "tracemap/tracemap.go",
    "content": "package tracemap\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nvar getFastIPWithContextFn = util.GetFastIPWithContext\nvar traceMapHTTPClientFn = newTraceMapHTTPClient\n\nfunc GetMapUrl(r string) (string, error) {\n\treturn GetMapUrlWithContext(context.Background(), r)\n}\n\nfunc GetMapUrlWithContext(ctx context.Context, r string) (string, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\thost, port := util.GetHostAndPort()\n\tvar fastIp string\n\t// 如果 host 是一个 IP 使用默认域名\n\tif valid := net.ParseIP(host); valid != nil {\n\t\tfastIp = host\n\t\tif len(strings.Split(fastIp, \":\")) > 1 {\n\t\t\tfastIp = \"[\" + fastIp + \"]\"\n\t\t}\n\t\thost = \"api.nxtrace.org\"\n\t} else {\n\t\t// 默认配置完成，开始寻找最优 IP\n\t\tvar err error\n\t\tfastIp, err = getFastIPWithContextFn(ctx, host, port, false)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\tu := url.URL{Scheme: \"https\", Host: fastIp + \":\" + port, Path: \"/tracemap/api\"}\n\ttracemapUrl := u.String()\n\n\tclient := traceMapHTTPClientFn(host)\n\tproxyUrl := util.GetProxy()\n\tif proxyUrl != nil {\n\t\tif t, ok := client.Transport.(*http.Transport); ok {\n\t\t\tt.Proxy = http.ProxyURL(proxyUrl)\n\t\t}\n\t}\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", tracemapUrl, strings.NewReader(r))\n\tif err != nil {\n\t\treturn \"\", errors.New(\"an issue occurred while connecting to the tracemap API\")\n\t}\n\treq.Header.Add(\"User-Agent\", util.UserAgent)\n\treq.Host = host\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn \"\", errors.New(\"an issue occurred while connecting to the tracemap API\")\n\t}\n\tdefer func(Body io.ReadCloser) {\n\t\terr := Body.Close()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}(resp.Body)\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", errors.New(\"an issue occurred while connecting to the tracemap API\")\n\t}\n\treturn string(body), nil\n}\n\nfunc newTraceMapHTTPClient(host string) *http.Client {\n\treturn &http.Client{\n\t\tTimeout: 5 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{\n\t\t\t\tServerName: host,\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc PrintMapUrl(r string) {\n\t_, err := fmt.Fprintf(color.Output, \"%s %s\\n\",\n\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", \"MapTrace URL:\"),\n\t\tcolor.New(color.FgBlue, color.Bold).Sprintf(\"%s\", r),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n}\n"
  },
  {
    "path": "tracemap/tracemap_test.go",
    "content": "package tracemap\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\ntype blockingRoundTripper struct{}\n\nfunc (blockingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\t<-req.Context().Done()\n\treturn nil, req.Context().Err()\n}\n\nfunc TestGetMapUrlWithContextReturnsCanceled(t *testing.T) {\n\toldHostPort := util.EnvHostPort\n\toldFastIPFn := getFastIPWithContextFn\n\toldClientFn := traceMapHTTPClientFn\n\tdefer func() {\n\t\tutil.EnvHostPort = oldHostPort\n\t\tgetFastIPWithContextFn = oldFastIPFn\n\t\ttraceMapHTTPClientFn = oldClientFn\n\t}()\n\n\tutil.EnvHostPort = \"example.com:443\"\n\tgetFastIPWithContextFn = func(ctx context.Context, domain string, port string, enableOutput bool) (string, error) {\n\t\treturn \"127.0.0.1\", nil\n\t}\n\ttraceMapHTTPClientFn = func(host string) *http.Client {\n\t\treturn &http.Client{Transport: blockingRoundTripper{}}\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := GetMapUrlWithContext(ctx, `{\"hops\":[]}`)\n\t\tdone <- err\n\t}()\n\n\tcancel()\n\n\tselect {\n\tcase err := <-done:\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tt.Fatalf(\"GetMapUrlWithContext error = %v, want context.Canceled\", err)\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"GetMapUrlWithContext did not return promptly after cancel\")\n\t}\n}\n"
  },
  {
    "path": "util/common.go",
    "content": "package util\n\nimport \"net\"\n\nfunc Dnspod() *net.Resolver {\n\treturn newDoTResolver(\"dot.pub\", \"dot.pub:853\")\n}\n\nfunc Aliyun() *net.Resolver {\n\treturn newDoTResolver(\"dns.alidns.com\", \"dns.alidns.com:853\")\n}\n\nfunc DNSSB() *net.Resolver {\n\treturn newDoTResolver(\"45.11.45.11\", \"dot.sb:853\")\n}\n\nfunc Cloudflare() *net.Resolver {\n\treturn newDoTResolver(\"one.one.one.one\", \"one.one.one.one:853\")\n}\n\nfunc Google() *net.Resolver {\n\treturn newDoTResolver(\"dns.google\", \"dns.google:853\")\n}\n"
  },
  {
    "path": "util/dns_resolver.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n)\n\n// ──────────────────────────────────────────────────\n// Geo DNS Resolver —— 为 GeoIP API / LeoMoe FastIP\n// 提供统一的 DNS 解析策略层。\n//\n// 策略：优先使用 DoT（--dot-server），DoT 失败时\n// 自动回退系统 DNS（可用性优先）。\n// ──────────────────────────────────────────────────\n\nvar (\n\tgeoDotServer string        // 当前 dot-server 选项（如 \"dnssb\"）\n\tgeoFallback  bool   = true // DoT 失败时是否回退系统 DNS\n\tgeoMu        sync.RWMutex\n\tgeoApplyMu   sync.Mutex\n\tgeoScopeMu   sync.Mutex\n\tgeoScopeDot  string\n\tgeoScopePrev struct {\n\t\tdotServer string\n\t\tfallback  bool\n\t}\n\tgeoScopeDepth int\n\n\t// geoResolverOverride 允许测试注入自定义 resolver（仅测试用）。\n\t// 非 nil 时 LookupHostForGeo 的 DoT 阶段使用该 resolver 替代 ResolverForDot 的结果。\n\tgeoResolverOverride *net.Resolver\n)\n\nfunc setGeoResolverOverride(resolver *net.Resolver) {\n\tgeoMu.Lock()\n\tdefer geoMu.Unlock()\n\tgeoResolverOverride = resolver\n}\n\nfunc getGeoResolverOverride() *net.Resolver {\n\tgeoMu.RLock()\n\tdefer geoMu.RUnlock()\n\treturn geoResolverOverride\n}\n\n// SetGeoDNSResolver 设置 Geo 解析使用的 DoT 服务器名称。\n// 空字符串表示仅使用系统 DNS。\nfunc SetGeoDNSResolver(dotServer string) {\n\tgeoMu.Lock()\n\tdefer geoMu.Unlock()\n\tgeoDotServer = dotServer\n}\n\n// SetGeoDNSFallback 设置 DoT 失败后是否回退系统 DNS，默认 true。\nfunc SetGeoDNSFallback(enabled bool) {\n\tgeoMu.Lock()\n\tdefer geoMu.Unlock()\n\tgeoFallback = enabled\n}\n\n// WithGeoDNSResolver 在 callback 生命周期内临时切换 Geo DNS resolver。\n// 该辅助会串行化不同 resolver 的切换与恢复，并允许相同 resolver 作用域安全嵌套。\nfunc WithGeoDNSResolver[T any](dotServer string, callback func() (T, error)) (T, error) {\n\tif callback == nil {\n\t\tvar zero T\n\t\treturn zero, nil\n\t}\n\tif dotServer == \"\" {\n\t\treturn callback()\n\t}\n\n\tgeoApplyMu.Lock()\n\tif geoScopeDepth > 0 && geoScopeDot == dotServer {\n\t\tgeoScopeDepth++\n\t\tgeoApplyMu.Unlock()\n\t\tdefer releaseGeoDNSResolverScope()\n\t\treturn callback()\n\t}\n\tgeoApplyMu.Unlock()\n\n\tgeoScopeMu.Lock()\n\tprevDotServer, prevFallback := getGeoDNSConfig()\n\tSetGeoDNSResolver(dotServer)\n\tgeoApplyMu.Lock()\n\tgeoScopeDot = dotServer\n\tgeoScopePrev.dotServer = prevDotServer\n\tgeoScopePrev.fallback = prevFallback\n\tgeoScopeDepth = 1\n\tgeoApplyMu.Unlock()\n\tdefer releaseGeoDNSResolverScope()\n\n\treturn callback()\n}\n\nfunc releaseGeoDNSResolverScope() {\n\tgeoApplyMu.Lock()\n\tif geoScopeDepth <= 0 {\n\t\tgeoApplyMu.Unlock()\n\t\treturn\n\t}\n\tgeoScopeDepth--\n\tif geoScopeDepth > 0 {\n\t\tgeoApplyMu.Unlock()\n\t\treturn\n\t}\n\n\tprevDotServer := geoScopePrev.dotServer\n\tprevFallback := geoScopePrev.fallback\n\tgeoScopeDot = \"\"\n\tgeoScopePrev.dotServer = \"\"\n\tgeoScopePrev.fallback = true\n\tgeoApplyMu.Unlock()\n\n\tSetGeoDNSResolver(prevDotServer)\n\tSetGeoDNSFallback(prevFallback)\n\tgeoScopeMu.Unlock()\n}\n\n// getGeoDNSConfig 返回当前快照；并发安全。\nfunc getGeoDNSConfig() (dotServer string, fallback bool) {\n\tgeoMu.RLock()\n\tdefer geoMu.RUnlock()\n\treturn geoDotServer, geoFallback\n}\n\n// ResolverForDot 根据 dotServer 名字返回对应的 *net.Resolver。\n// 空 / 未知名字返回 nil（表示\"使用系统默认\"）。\nfunc ResolverForDot(dotServer string) *net.Resolver {\n\tswitch dotServer {\n\tcase \"dnssb\":\n\t\treturn DNSSB()\n\tcase \"aliyun\":\n\t\treturn Aliyun()\n\tcase \"dnspod\":\n\t\treturn Dnspod()\n\tcase \"google\":\n\t\treturn Google()\n\tcase \"cloudflare\":\n\t\treturn Cloudflare()\n\tdefault:\n\t\treturn nil\n\t}\n}\n\n// LookupHostForGeo 执行\"Geo 专用\"DNS 查询。\n//\n//  1. 如果 host 是 IP 字面量，直接返回，不做 DNS 查询。\n//  2. 若配置了 DoT，优先用 DoT 解析。\n//  3. DoT 失败且 fallback=true 时，回退系统 DNS。\n//  4. 全部失败才返回 error。\nfunc LookupHostForGeo(ctx context.Context, host string) ([]net.IP, error) {\n\t// ── 1. IP 字面量短路 ──\n\tif ip := net.ParseIP(host); ip != nil {\n\t\treturn []net.IP{ip}, nil\n\t}\n\n\tdotServer, fallback := getGeoDNSConfig()\n\n\t// ── 2. DoT 解析 ──\n\tr := ResolverForDot(dotServer)\n\tif override := getGeoResolverOverride(); override != nil {\n\t\tr = override\n\t}\n\tif r != nil {\n\t\tips, err := resolveHost(ctx, r, host)\n\t\tif err == nil && len(ips) > 0 {\n\t\t\treturn ips, nil\n\t\t}\n\t\t// DoT 失败，决定是否 fallback\n\t\tif !fallback {\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn nil, &net.DNSError{\n\t\t\t\tErr:  \"no addresses found via DoT\",\n\t\t\t\tName: host,\n\t\t\t}\n\t\t}\n\t\t// 继续到 fallback\n\t}\n\n\t// ── 3. Fallback: 系统 DNS ──\n\treturn resolveHost(ctx, net.DefaultResolver, host)\n}\n\n// resolveHost 用给定的 resolver 解析 host，返回 []net.IP。\nfunc resolveHost(ctx context.Context, r *net.Resolver, host string) ([]net.IP, error) {\n\t// 使用较短的独立超时，避免阻塞调用方\n\tchild, cancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer cancel()\n\n\taddrs, err := r.LookupHost(child, host)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ips []net.IP\n\tfor _, a := range addrs {\n\t\tif ip := net.ParseIP(a); ip != nil {\n\t\t\tips = append(ips, ip)\n\t\t}\n\t}\n\tif len(ips) == 0 {\n\t\treturn nil, &net.DNSError{\n\t\t\tErr:  \"no addresses found\",\n\t\t\tName: host,\n\t\t}\n\t}\n\treturn ips, nil\n}\n"
  },
  {
    "path": "util/dns_resolver_test.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n)\n\n// ── ResolverForDot 映射 ─────────────────────────────\n\nfunc TestResolverMapping(t *testing.T) {\n\tknown := []string{\"dnssb\", \"aliyun\", \"dnspod\", \"google\", \"cloudflare\"}\n\tfor _, name := range known {\n\t\tr := ResolverForDot(name)\n\t\tif r == nil {\n\t\t\tt.Fatalf(\"ResolverForDot(%q) returned nil, want non-nil\", name)\n\t\t}\n\t\t// 确认是自定义 dialer（PreferGo = true 且有 Dial）\n\t\tif !r.PreferGo {\n\t\t\tt.Errorf(\"ResolverForDot(%q).PreferGo = false, want true\", name)\n\t\t}\n\t}\n\t// 空字符串 / 未知值返回 nil（表示系统默认）\n\tfor _, name := range []string{\"\", \"unknown\", \"xxx\"} {\n\t\tif r := ResolverForDot(name); r != nil {\n\t\t\tt.Errorf(\"ResolverForDot(%q) = %v, want nil\", name, r)\n\t\t}\n\t}\n}\n\n// ── IP 字面量短路 ────────────────────────────────────\n\nfunc TestLookupHostForGeo_IPLiteral(t *testing.T) {\n\t// 无论 DoT 配置为何，IP 字面量应直接返回，不触发 DNS 查询。\n\tSetGeoDNSResolver(\"dnssb\")\n\tdefer SetGeoDNSResolver(\"\")\n\n\tcases := []string{\"1.1.1.1\", \"::1\", \"2001:db8::1\", \"192.168.0.1\"}\n\tfor _, addr := range cases {\n\t\tips, err := LookupHostForGeo(context.Background(), addr)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"LookupHostForGeo(%q) err = %v, want nil\", addr, err)\n\t\t\tcontinue\n\t\t}\n\t\tif len(ips) != 1 || ips[0].String() != net.ParseIP(addr).String() {\n\t\t\tt.Errorf(\"LookupHostForGeo(%q) = %v, want [%s]\", addr, ips, addr)\n\t\t}\n\t}\n}\n\n// ── DoT 成功时不走 fallback ──────────────────────────\n\nfunc TestLookupHostForGeo_DoTSuccess(t *testing.T) {\n\t// 使用 Cloudflare DoT 解析一个可靠域名\n\tSetGeoDNSResolver(\"cloudflare\")\n\tSetGeoDNSFallback(true)\n\tdefer func() {\n\t\tSetGeoDNSResolver(\"\")\n\t\tSetGeoDNSFallback(true)\n\t}()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\n\tips, err := LookupHostForGeo(ctx, \"one.one.one.one\")\n\tif err != nil {\n\t\tt.Skipf(\"DoT lookup failed (network issue?): %v\", err)\n\t}\n\tif len(ips) == 0 {\n\t\tt.Error(\"expected at least 1 IP, got 0\")\n\t}\n}\n\n// ── 未配置 DoT 时直接走系统 DNS ──────────────────────\n\nfunc TestLookupHostForGeo_NoDotFallsToSystem(t *testing.T) {\n\t// dotServer 为空 → ResolverForDot 返回 nil → 直接走系统 DNS。\n\tSetGeoDNSResolver(\"\")\n\tSetGeoDNSFallback(true)\n\tdefer func() {\n\t\tSetGeoDNSResolver(\"\")\n\t\tSetGeoDNSFallback(true)\n\t}()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\n\tips, err := LookupHostForGeo(ctx, \"one.one.one.one\")\n\tif err != nil {\n\t\tt.Skipf(\"System DNS lookup failed (network issue?): %v\", err)\n\t}\n\tif len(ips) == 0 {\n\t\tt.Error(\"expected at least 1 IP, got 0\")\n\t}\n}\n\n// ── DoT 失败后回退系统 DNS ───────────────────────────\n\nfunc TestLookupHostForGeo_DoTFailFallback(t *testing.T) {\n\t// 注入一个必定失败的 resolver（连接不可达地址），\n\t// 验证 fallback=true 时能回退到系统 DNS 成功解析。\n\tbadResolver := &net.Resolver{\n\t\tPreferGo: true,\n\t\tDial: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\t// 拨向 RFC 5737 文档专用地址，必定失败\n\t\t\treturn net.DialTimeout(\"tcp\", \"192.0.2.1:853\", 200*time.Millisecond)\n\t\t},\n\t}\n\tSetGeoDNSResolver(\"cloudflare\") // 需要非空，ResolverForDot 会被 override 覆盖\n\tSetGeoDNSFallback(true)\n\tsetGeoResolverOverride(badResolver)\n\tdefer func() {\n\t\tsetGeoResolverOverride(nil)\n\t\tSetGeoDNSResolver(\"\")\n\t\tSetGeoDNSFallback(true)\n\t}()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\n\tips, err := LookupHostForGeo(ctx, \"one.one.one.one\")\n\tif err != nil {\n\t\tt.Skipf(\"System DNS fallback also failed (network issue?): %v\", err)\n\t}\n\tif len(ips) == 0 {\n\t\tt.Error(\"expected at least 1 IP from system DNS fallback, got 0\")\n\t}\n}\n\n// ── DoT 失败且 fallback=false 时返回错误 ─────────────\n\nfunc TestLookupHostForGeo_DoTFailNoFallback(t *testing.T) {\n\tbadResolver := &net.Resolver{\n\t\tPreferGo: true,\n\t\tDial: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\treturn net.DialTimeout(\"tcp\", \"192.0.2.1:853\", 200*time.Millisecond)\n\t\t},\n\t}\n\tSetGeoDNSResolver(\"cloudflare\")\n\tSetGeoDNSFallback(false)\n\tsetGeoResolverOverride(badResolver)\n\tdefer func() {\n\t\tsetGeoResolverOverride(nil)\n\t\tSetGeoDNSResolver(\"\")\n\t\tSetGeoDNSFallback(true)\n\t}()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\n\t_, err := LookupHostForGeo(ctx, \"one.one.one.one\")\n\tif err == nil {\n\t\tt.Error(\"expected error when DoT fails and fallback=false, got nil\")\n\t}\n}\n\n// ── SetGeoDNSResolver / SetGeoDNSFallback 并发安全 ──\n\nfunc TestGeoDNSConfig_ConcurrentAccess(t *testing.T) {\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tfor i := 0; i < 1000; i++ {\n\t\t\tSetGeoDNSResolver(\"google\")\n\t\t\tSetGeoDNSFallback(false)\n\t\t}\n\t\tclose(done)\n\t}()\n\tfor i := 0; i < 1000; i++ {\n\t\t_, _ = getGeoDNSConfig()\n\t}\n\t<-done\n\t// 无 data race = 通过\n}\n\nfunc TestWithGeoDNSResolver_RestoresPreviousConfig(t *testing.T) {\n\tSetGeoDNSResolver(\"google\")\n\tSetGeoDNSFallback(false)\n\tdefer func() {\n\t\tSetGeoDNSResolver(\"\")\n\t\tSetGeoDNSFallback(true)\n\t}()\n\n\tseenDot := \"\"\n\tseenFallback := true\n\tgot, err := WithGeoDNSResolver(\"cloudflare\", func() (string, error) {\n\t\tseenDot, seenFallback = getGeoDNSConfig()\n\t\treturn \"ok\", nil\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"WithGeoDNSResolver returned error: %v\", err)\n\t}\n\tif got != \"ok\" {\n\t\tt.Fatalf(\"WithGeoDNSResolver result = %q, want ok\", got)\n\t}\n\tif seenDot != \"cloudflare\" {\n\t\tt.Fatalf(\"callback saw dot resolver %q, want cloudflare\", seenDot)\n\t}\n\tif seenFallback {\n\t\tt.Fatalf(\"callback saw fallback=true, want inherited false\")\n\t}\n\n\tdot, fallback := getGeoDNSConfig()\n\tif dot != \"google\" || fallback {\n\t\tt.Fatalf(\"resolver restored to (%q, %t), want (%q, %t)\", dot, fallback, \"google\", false)\n\t}\n}\n\nfunc TestWithGeoDNSResolver_AllowsNestedSameResolver(t *testing.T) {\n\tSetGeoDNSResolver(\"google\")\n\tSetGeoDNSFallback(false)\n\tdefer func() {\n\t\tSetGeoDNSResolver(\"\")\n\t\tSetGeoDNSFallback(true)\n\t}()\n\n\tdone := make(chan struct{})\n\tvar (\n\t\tgot            string\n\t\terr            error\n\t\touterDot       string\n\t\touterFallback  bool\n\t\tnestedDot      string\n\t\tnestedFallback bool\n\t)\n\n\tgo func() {\n\t\tdefer close(done)\n\t\tgot, err = WithGeoDNSResolver(\"cloudflare\", func() (string, error) {\n\t\t\touterDot, outerFallback = getGeoDNSConfig()\n\t\t\treturn WithGeoDNSResolver(\"cloudflare\", func() (string, error) {\n\t\t\t\tnestedDot, nestedFallback = getGeoDNSConfig()\n\t\t\t\treturn \"nested\", nil\n\t\t\t})\n\t\t})\n\t}()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"nested WithGeoDNSResolver call deadlocked\")\n\t}\n\n\tif err != nil {\n\t\tt.Fatalf(\"nested WithGeoDNSResolver returned error: %v\", err)\n\t}\n\tif got != \"nested\" {\n\t\tt.Fatalf(\"nested WithGeoDNSResolver result = %q, want nested\", got)\n\t}\n\tif outerDot != \"cloudflare\" || outerFallback {\n\t\tt.Fatalf(\"outer callback saw (%q, %t), want (%q, %t)\", outerDot, outerFallback, \"cloudflare\", false)\n\t}\n\tif nestedDot != \"cloudflare\" || nestedFallback {\n\t\tt.Fatalf(\"nested callback saw (%q, %t), want (%q, %t)\", nestedDot, nestedFallback, \"cloudflare\", false)\n\t}\n\n\tdot, fallback := getGeoDNSConfig()\n\tif dot != \"google\" || fallback {\n\t\tt.Fatalf(\"resolver restored to (%q, %t), want (%q, %t)\", dot, fallback, \"google\", false)\n\t}\n}\n"
  },
  {
    "path": "util/dot.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"net\"\n\t\"time\"\n)\n\nfunc newDoTResolver(serverName string, addrs string) *net.Resolver {\n\n\td := &net.Dialer{\n\t\t// 设置超时时间\n\t\tTimeout: 1000 * time.Millisecond,\n\t}\n\n\ttlsConfig := &tls.Config{\n\t\t// 设置 TLS Server Name 以确保证书能和域名对应\n\t\tServerName: serverName,\n\t}\n\treturn &net.Resolver{\n\t\t// 指定使用 Go Build-in 的 DNS Resolver 来解析\n\t\tPreferGo: true,\n\t\tDial: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\tconn, err := d.DialContext(ctx, \"tcp\", addrs)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn tls.Client(conn, tlsConfig), nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "util/env.go",
    "content": "package util\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nvar (\n\tDisableMPLS     = GetEnvBool(\"NEXTTRACE_DISABLEMPLS\", false)\n\tEnableHidDstIP  = GetEnvBool(\"NEXTTRACE_ENABLEHIDDENDSTIP\", false)\n\tEnvDevMode      = GetEnvBool(\"NEXTTRACE_DEVMODE\", false)\n\tEnvRandomPort   = GetEnvBool(\"NEXTTRACE_RANDOMPORT\", false)\n\tUninterrupted   = GetEnvBool(\"NEXTTRACE_UNINTERRUPTED\", false)\n\tEnvProxyURL     = GetEnvDefault(\"NEXTTRACE_PROXY\", \"\")\n\tEnvToken        = GetEnvDefault(\"NEXTTRACE_TOKEN\", \"\")\n\tEnvDataProvider = GetEnvDefault(\"NEXTTRACE_DATAPROVIDER\", \"\")\n\tEnvHostPort     = GetEnvDefault(\"NEXTTRACE_HOSTPORT\", \"api.nxtrace.org\")\n\tEnvPowProvider  = GetEnvDefault(\"NEXTTRACE_POWPROVIDER\", \"api.nxtrace.org\")\n\tEnvDeployAddr   = GetEnvDefault(\"NEXTTRACE_DEPLOY_ADDR\", \"\")\n\tEnvMaxAttempts  = GetEnvInt(\"NEXTTRACE_MAXATTEMPTS\", 0)\n\tEnvICMPMode     = GetEnvInt(\"NEXTTRACE_ICMPMODE\", 0)\n\tGlobalpingToken = GetEnvDefault(\"GLOBALPING_TOKEN\", \"\")\n)\n\nconst EnvAllowCrossOriginKey = \"NEXTTRACE_ALLOW_CROSS_ORIGIN\"\n\nfunc GetEnvTrimmed(key string) (string, bool) {\n\tv, ok := os.LookupEnv(key)\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\tval := strings.TrimSpace(v)\n\tif os.Getenv(\"NEXTTRACE_DEBUG\") != \"\" {\n\t\tfmt.Println(\"ENV\", key, \"detected as\", val)\n\t}\n\treturn val, true\n}\n\nfunc GetEnvBool(key string, def bool) bool {\n\tif val, ok := GetEnvTrimmed(key); ok {\n\t\tswitch val {\n\t\tcase \"1\":\n\t\t\treturn true\n\t\tcase \"0\":\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn def\n\t\t}\n\t}\n\treturn def\n}\n\nfunc GetEnvDefault(key string, def string) string {\n\tif val, ok := GetEnvTrimmed(key); ok {\n\t\treturn val\n\t}\n\treturn def\n}\n\nfunc GetEnvInt(key string, def int) int {\n\tif val, ok := GetEnvTrimmed(key); ok {\n\t\tnum, err := strconv.Atoi(val)\n\t\tif err != nil {\n\t\t\treturn def\n\t\t}\n\t\treturn num\n\t}\n\treturn def\n}\n\nfunc AllowCrossOriginBrowserAccess() bool {\n\treturn GetEnvBool(EnvAllowCrossOriginKey, false)\n}\n"
  },
  {
    "path": "util/env_test.go",
    "content": "package util\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestGetEnvTrimmed(t *testing.T) {\n\tt.Setenv(\"TEST_TRIMMED_KEY\", \"  value  \")\n\n\tval, ok := GetEnvTrimmed(\"TEST_TRIMMED_KEY\")\n\tassert.True(t, ok)\n\tassert.Equal(t, \"value\", val)\n\n\t_, ok = GetEnvTrimmed(\"TEST_TRIMMED_MISSING\")\n\tassert.False(t, ok)\n}\n\nfunc TestGetEnvBool(t *testing.T) {\n\tt.Setenv(\"TEST_BOOL_TRUE\", \"1\")\n\tassert.True(t, GetEnvBool(\"TEST_BOOL_TRUE\", false))\n\n\tt.Setenv(\"TEST_BOOL_FALSE\", \"0\")\n\tassert.False(t, GetEnvBool(\"TEST_BOOL_FALSE\", true))\n\n\tt.Setenv(\"TEST_BOOL_INVALID\", \"maybe\")\n\tassert.True(t, GetEnvBool(\"TEST_BOOL_INVALID\", true))\n}\n\nfunc TestGetEnvDefault(t *testing.T) {\n\tt.Setenv(\"TEST_DEFAULT_KEY\", \" custom \")\n\tassert.Equal(t, \"custom\", GetEnvDefault(\"TEST_DEFAULT_KEY\", \"fallback\"))\n\n\tassert.Equal(t, \"fallback\", GetEnvDefault(\"TEST_DEFAULT_MISSING\", \"fallback\"))\n}\n\nfunc TestGetEnvInt(t *testing.T) {\n\tt.Setenv(\"TEST_INT_VALID\", \" 42 \")\n\tassert.Equal(t, 42, GetEnvInt(\"TEST_INT_VALID\", 7))\n\n\tt.Setenv(\"TEST_INT_INVALID\", \"NaN\")\n\tassert.Equal(t, 5, GetEnvInt(\"TEST_INT_INVALID\", 5))\n\n\tassert.Equal(t, 9, GetEnvInt(\"TEST_INT_MISSING\", 9))\n}\n\nfunc TestAllowCrossOriginBrowserAccess(t *testing.T) {\n\tt.Setenv(EnvAllowCrossOriginKey, \"1\")\n\tassert.True(t, AllowCrossOriginBrowserAccess())\n\n\tt.Setenv(EnvAllowCrossOriginKey, \"0\")\n\tassert.False(t, AllowCrossOriginBrowserAccess())\n}\n"
  },
  {
    "path": "util/frag.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"net\"\n\n\t\"golang.org/x/net/ipv4\"\n)\n\ntype IPv4Fragment struct {\n\tHdr  ipv4.Header\n\tBody []byte\n}\n\nfunc getNamedDeviceMTU(srcDev string) (int, bool) {\n\tif srcDev == \"\" {\n\t\treturn 0, false\n\t}\n\tif ifi, err := net.InterfaceByName(srcDev); err == nil && ifi != nil {\n\t\treturn ifi.MTU, true\n\t}\n\treturn 0, false\n}\n\nfunc addressIP(addr net.Addr) net.IP {\n\tswitch value := addr.(type) {\n\tcase *net.IPNet:\n\t\treturn value.IP\n\tcase *net.IPAddr:\n\t\treturn value.IP\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc matchInterfaceIP(candidate, target net.IP, isIPv6 bool) bool {\n\tif candidate == nil {\n\t\treturn false\n\t}\n\tif isIPv6 {\n\t\tnormalized := candidate.To16()\n\t\treturn normalized != nil && IsIPv6(candidate) && normalized.Equal(target)\n\t}\n\tnormalized := candidate.To4()\n\treturn normalized != nil && normalized.Equal(target)\n}\n\n// GetMTUByIPForDevice 根据给定 IPv4/IPv6 源地址返回所属网卡 MTU，优先使用指定网卡名。\nfunc GetMTUByIPForDevice(srcIP net.IP, srcDev string) int {\n\tif mtu, ok := getNamedDeviceMTU(srcDev); ok {\n\t\treturn mtu\n\t}\n\n\tis6 := IsIPv6(srcIP)\n\tvar targetIP net.IP\n\tif is6 {\n\t\ttargetIP = srcIP.To16()\n\t} else {\n\t\ttargetIP = srcIP.To4()\n\t}\n\n\tifaces, err := net.Interfaces()\n\tif err != nil {\n\t\treturn 0\n\t}\n\tfor _, ifi := range ifaces {\n\t\taddrs, _ := ifi.Addrs()\n\t\tfor _, a := range addrs {\n\t\t\tif matchInterfaceIP(addressIP(a), targetIP, is6) {\n\t\t\t\treturn ifi.MTU\n\t\t\t}\n\t\t}\n\t}\n\treturn 0\n}\n\n// GetMTUByIP 保持旧调用点兼容，优先使用全局 SrcDev。\nfunc GetMTUByIP(srcIP net.IP) int {\n\treturn GetMTUByIPForDevice(srcIP, SrcDev)\n}\n\n// IPv4Fragmentize 将 base（IPv4 头）与 body（IPv4 负载：传输层头+数据）按 mtu 进行 IP 层分片\nfunc IPv4Fragmentize(base *ipv4.Header, body []byte, mtu int) ([]IPv4Fragment, error) {\n\t// 低 13 位为分片偏移（单位 8 字节）\n\tconst ipOffsetMask = 0x1FFF\n\n\t// 提取 IPv4 头长度 ihl\n\tihl := base.Len\n\n\t// MTU 至少要容纳一个完整 IPv4 头\n\tif mtu <= ihl {\n\t\treturn nil, errors.New(\"IPv4Fragmentize: MTU too small (<= IHL)\")\n\t}\n\tmaxFragBody := mtu - ihl\n\n\t// 假如已经置位 DF=1，则直接报错并返回 nil\n\tif (base.Flags & ipv4.DontFragment) != 0 {\n\t\treturn nil, errors.New(\"IPv4Fragmentize: DF set while fragmentation required\")\n\t}\n\n\t// 非最后片的片内负载长度按 8 字节对齐\n\taligned := (maxFragBody / 8) * 8\n\n\t// 预分配结果切片的容量\n\tcapacity := (len(body) + aligned - 1) / aligned\n\tfrags := make([]IPv4Fragment, 0, capacity)\n\n\t// 按 aligned 切出所有的分片（最后片承载所有剩余字节）\n\tfor off := 0; off < len(body); {\n\t\tmore := off+aligned < len(body)\n\t\tfragLen := len(body) - off\n\t\tif more {\n\t\t\tfragLen = aligned\n\t\t}\n\n\t\t// 为每片拷贝出独立头部\n\t\th := *base\n\t\th.Len = ihl\n\t\th.TotalLen = ihl + fragLen\n\n\t\t// 先清除已有的 MF 标志\n\t\th.Flags &^= ipv4.MoreFragments\n\n\t\t// 写入分片偏移（仅低 13 位，单位 8 字节）\n\t\th.FragOff &^= ipOffsetMask\n\t\th.FragOff |= (off / 8) & ipOffsetMask\n\n\t\t// 非最后片，则置位 MF=1 到 Flags 字段中\n\t\tif more {\n\t\t\th.Flags |= ipv4.MoreFragments\n\t\t}\n\n\t\t// 置 0 使 Marshal 重算 IPv4 头校验和\n\t\th.Checksum = 0\n\n\t\tfrags = append(frags, IPv4Fragment{\n\t\t\tHdr:  h,\n\t\t\tBody: body[off : off+fragLen],\n\t\t})\n\t\toff += fragLen\n\t}\n\treturn frags, nil\n}\n"
  },
  {
    "path": "util/http_client_geo.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// NewGeoHTTPClient 返回一个使用 Geo DNS 解析策略的 *http.Client。\n//\n// 内部 Transport.DialContext 会通过 LookupHostForGeo 解析目标 host，\n// 然后按 IP 拨号，保持请求 URL Host 不变（即 TLS SNI 不受影响）。\nfunc NewGeoHTTPClient(timeout time.Duration) *http.Client {\n\tdialer := &net.Dialer{\n\t\tTimeout:   timeout,\n\t\tKeepAlive: 30 * time.Second,\n\t}\n\n\ttransport := &http.Transport{}\n\tif base, ok := http.DefaultTransport.(*http.Transport); ok && base != nil {\n\t\ttransport = base.Clone()\n\t}\n\ttransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\thost, port, err := net.SplitHostPort(addr)\n\t\tif err != nil {\n\t\t\t// addr 可能不含端口号；原样拨号\n\t\t\treturn dialer.DialContext(ctx, network, addr)\n\t\t}\n\n\t\t// 用 Geo DNS 策略解析 host\n\t\tips, err := LookupHostForGeo(ctx, host)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// 依次尝试解析到的 IP，优先使用地址族匹配的\n\t\tvar lastErr error\n\t\tfor _, ip := range ips {\n\t\t\ttarget := net.JoinHostPort(ip.String(), port)\n\t\t\tconn, dialErr := dialer.DialContext(ctx, network, target)\n\t\t\tif dialErr == nil {\n\t\t\t\treturn conn, nil\n\t\t\t}\n\t\t\tlastErr = dialErr\n\t\t}\n\t\tif lastErr == nil {\n\t\t\treturn nil, fmt.Errorf(\"geo DNS returned no IPs for host %q\", host)\n\t\t}\n\t\treturn nil, lastErr\n\t}\n\n\treturn &http.Client{\n\t\tTimeout:   timeout,\n\t\tTransport: transport,\n\t}\n}\n"
  },
  {
    "path": "util/http_client_geo_test.go",
    "content": "package util\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestNewGeoHTTPClient_ReturnsValidClient(t *testing.T) {\n\tc := NewGeoHTTPClient(3 * time.Second)\n\tif c == nil {\n\t\tt.Fatal(\"NewGeoHTTPClient returned nil\")\n\t}\n\tif c.Timeout != 3*time.Second {\n\t\tt.Errorf(\"Timeout = %v, want 3s\", c.Timeout)\n\t}\n}\n\nfunc TestNewGeoHTTPClient_HasCustomTransport(t *testing.T) {\n\tc := NewGeoHTTPClient(2 * time.Second)\n\ttr, ok := c.Transport.(*http.Transport)\n\tif !ok || tr == nil {\n\t\tt.Fatal(\"Transport is not *http.Transport or is nil\")\n\t}\n\tif tr.DialContext == nil {\n\t\tt.Error(\"Transport.DialContext is nil, expected custom function\")\n\t}\n}\n\nfunc TestNewGeoHTTPClient_DifferentTimeouts(t *testing.T) {\n\tfor _, d := range []time.Duration{\n\t\t500 * time.Millisecond,\n\t\t2 * time.Second,\n\t\t10 * time.Second,\n\t} {\n\t\tc := NewGeoHTTPClient(d)\n\t\tif c.Timeout != d {\n\t\t\tt.Errorf(\"NewGeoHTTPClient(%v).Timeout = %v\", d, c.Timeout)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/latency.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n)\n\ntype ResponseInfo struct {\n\tIP      string\n\tLatency string\n\tContent string\n}\n\nvar (\n\ttimeout       = 5 * time.Second\n\tfastIPCacheMu sync.RWMutex\n)\nvar FastIpCache = \"\"\n\nvar (\n\tfastIPLookupHostFn = LookupHostForGeo\n\tfastIPCheckLatency = checkLatencyWithContext\n)\n\n// FastIPMeta 存储 FastIP 节点的结构化元数据。\ntype FastIPMeta struct {\n\tIP       string // 节点 IP\n\tLatency  string // 延迟（ms 字符串）\n\tNodeName string // 节点名称（API 返回的 Content 去除前后空白）\n}\n\n// FastIPMetaCache 缓存最近一次 FastIP 探测返回的节点元数据。\nvar FastIPMetaCache FastIPMeta\n\n// SuppressFastIPOutput 为 true 时，GetFastIP 即使 enableOutput=true 也不打印彩色输出。\n// MTR 模式在进入备用屏前设置此标志，避免污染主终端历史。\nvar SuppressFastIPOutput bool\n\nfunc GetFastIP(domain string, port string, enableOutput bool) string {\n\tip, err := GetFastIPWithContext(context.Background(), domain, port, enableOutput)\n\tif err != nil {\n\t\tlog.Printf(\"FastIP probe failed: %v\", err)\n\t\treturn defaultFastIP()\n\t}\n\treturn ip\n}\n\nfunc GetFastIPCache() string {\n\tfastIPCacheMu.RLock()\n\tdefer fastIPCacheMu.RUnlock()\n\treturn FastIpCache\n}\n\nfunc GetFastIPMetaCache() FastIPMeta {\n\tfastIPCacheMu.RLock()\n\tdefer fastIPCacheMu.RUnlock()\n\treturn FastIPMetaCache\n}\n\nfunc SetFastIPCacheState(ip string, meta FastIPMeta) {\n\tfastIPCacheMu.Lock()\n\tFastIpCache = ip\n\tFastIPMetaCache = meta\n\tfastIPCacheMu.Unlock()\n}\n\nfunc GetFastIPWithContext(ctx context.Context, domain string, port string, enableOutput bool) (string, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tproxyUrl := GetProxy()\n\tif proxyUrl != nil {\n\t\treturn \"api.nxtrace.org\", nil\n\t}\n\tif cachedIP := GetFastIPCache(); cachedIP != \"\" {\n\t\treturn cachedIP, nil\n\t}\n\n\tvar ips []net.IP\n\tvar err error\n\tlookupCtx, cancel := context.WithTimeout(ctx, timeout)\n\tdefer cancel()\n\tif domain == \"api.nxtrace.org\" {\n\t\tips, err = fastIPLookupHostFn(lookupCtx, \"api.nxtrace.org\")\n\t} else {\n\t\tips, err = fastIPLookupHostFn(lookupCtx, domain)\n\t}\n\n\tif err != nil {\n\t\tif lookupCtx.Err() != nil {\n\t\t\treturn \"\", lookupCtx.Err()\n\t\t}\n\t\tlog.Println(\"DNS resolution failed, please check your system DNS Settings\")\n\t}\n\n\tif len(ips) == 0 {\n\t\tips = defaultFastIPCandidates()\n\t}\n\n\tresults := make(chan ResponseInfo, len(ips))\n\tfor _, ip := range ips {\n\t\tgo fastIPCheckLatency(ctx, domain, ip.String(), port, results)\n\t}\n\n\tvar result ResponseInfo\n\n\tselect {\n\tcase result = <-results:\n\t\t// 正常返回结果\n\tcase <-time.After(timeout):\n\t\tlog.Println(\"IP connection has been timeout(5s), please check your network\")\n\tcase <-ctx.Done():\n\t\treturn \"\", ctx.Err()\n\t}\n\n\t//有些时候真的啥都不通，还是挑一个顶上吧\n\tif result.IP == \"\" {\n\t\tresult.IP = defaultFastIP()\n\t}\n\n\tmeta := FastIPMeta{\n\t\tIP:       result.IP,\n\t\tLatency:  result.Latency,\n\t\tNodeName: strings.TrimSpace(result.Content),\n\t}\n\tSetFastIPCacheState(result.IP, meta)\n\n\tif enableOutput && !SuppressFastIPOutput {\n\t\t_, _ = fmt.Fprintf(color.Output, \"%s preferred API IP - %s - %s - %s\",\n\t\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"[NextTrace API]\"),\n\t\t\tcolor.New(color.FgGreen, color.Bold).Sprintf(\"%s\", result.IP),\n\t\t\tcolor.New(color.FgCyan, color.Bold).Sprintf(\"%sms\", result.Latency),\n\t\t\tcolor.New(color.FgGreen, color.Bold).Sprintf(\"%s\", result.Content),\n\t\t)\n\t}\n\n\treturn result.IP, nil\n}\n\nfunc defaultFastIPCandidates() []net.IP {\n\treturn []net.IP{\n\t\tnet.ParseIP(\"45.88.195.154\"),\n\t\tnet.ParseIP(\"2605:52c0:2:954:114:514:1919:810\"),\n\t}\n}\n\nfunc defaultFastIP() string {\n\treturn \"45.88.195.154\"\n}\n\nfunc checkLatencyWithContext(ctx context.Context, domain string, ip string, port string, results chan<- ResponseInfo) {\n\tstart := time.Now()\n\tif !strings.Contains(ip, \".\") {\n\t\tip = \"[\" + ip + \"]\"\n\t}\n\n\t// 自定义DialContext以使用指定的IP连接\n\ttransport := &http.Transport{\n\t\t//DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t//\treturn net.DialTimeout(network, addr, 1*time.Second)\n\t\t//},\n\t\tTLSClientConfig: &tls.Config{\n\t\t\tServerName: domain,\n\t\t},\n\t\tTLSHandshakeTimeout: timeout,\n\t}\n\n\tclient := &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   timeout,\n\t}\n\n\t//此处虽然是 https://domain/ 但是实际上会使用指定的IP连接\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"https://\"+ip+\":\"+port+\"/\", nil)\n\tif err != nil {\n\t\t// !!! 此处不要给results返回任何值\n\t\t//results <- ResponseInfo{IP: ip, Latency: \"error\", Content: \"\"}\n\t\treturn\n\t}\n\treq.Host = domain\n\treq.Header.Add(\"User-Agent\", UserAgent)\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\t//results <- ResponseInfo{IP: ip, Latency: \"error\", Content: \"\"}\n\t\treturn\n\t}\n\tif resp == nil || resp.Body == nil {\n\t\t// 防止后续对 nil Body 的读写导致 panic\n\t\treturn\n\t}\n\tdefer func() {\n\t\t// 明确忽略关闭时的错误，HTTP 客户端此时已经读完正文\n\t\t_ = resp.Body.Close()\n\t}()\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\t//results <- ResponseInfo{IP: ip, Latency: \"error\", Content: \"\"}\n\t\treturn\n\t}\n\tbodyString := string(bodyBytes)\n\n\tlatency := fmt.Sprintf(\"%.2f\", float64(time.Since(start))/float64(time.Millisecond))\n\tselect {\n\tcase results <- ResponseInfo{IP: ip, Latency: latency, Content: bodyString}:\n\tcase <-ctx.Done():\n\tdefault:\n\t}\n}\n"
  },
  {
    "path": "util/latency_test.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestGetFastIPWithContextReturnsCanceled(t *testing.T) {\n\toldLookup := fastIPLookupHostFn\n\toldCheck := fastIPCheckLatency\n\toldCache := GetFastIPCache()\n\toldMeta := GetFastIPMetaCache()\n\tdefer func() {\n\t\tfastIPLookupHostFn = oldLookup\n\t\tfastIPCheckLatency = oldCheck\n\t\tSetFastIPCacheState(oldCache, oldMeta)\n\t}()\n\n\tSetFastIPCacheState(\"\", FastIPMeta{})\n\tfastIPLookupHostFn = func(ctx context.Context, host string) ([]net.IP, error) {\n\t\treturn []net.IP{net.ParseIP(\"1.1.1.1\")}, nil\n\t}\n\tstarted := make(chan struct{})\n\tfastIPCheckLatency = func(ctx context.Context, domain, ip, port string, results chan<- ResponseInfo) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := GetFastIPWithContext(ctx, \"example.com\", \"443\", false)\n\t\tdone <- err\n\t}()\n\n\t<-started\n\tcancel()\n\n\tselect {\n\tcase err := <-done:\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tt.Fatalf(\"GetFastIPWithContext error = %v, want context.Canceled\", err)\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"GetFastIPWithContext did not return promptly after cancel\")\n\t}\n}\n"
  },
  {
    "path": "util/pcap.go",
    "content": "//go:build darwin\n\npackage util\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\n\t\"github.com/google/gopacket/pcap\"\n)\n\nvar (\n\tDevCache sync.Map // key: string(srcip) -> string(pcap device name)\n)\n\nfunc ipKey(ip net.IP) string {\n\tif v4 := ip.To4(); v4 != nil {\n\t\treturn v4.String()\n\t}\n\treturn ip.String()\n}\n\n// PcapDeviceByIP 返回可用于 pcap.OpenLive 的设备名\nfunc PcapDeviceByIP(srcip net.IP) (string, error) {\n\tkey := ipKey(srcip)\n\tif v, ok := DevCache.Load(key); ok {\n\t\treturn v.(string), nil\n\t}\n\n\tdevs, err := pcap.FindAllDevs()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"pcap list devices: %w\", err)\n\t}\n\n\tis6 := IsIPv6(srcip)\n\tvar v4, v6 net.IP\n\tif is6 {\n\t\tv6 = srcip.To16()\n\t} else {\n\t\tv4 = srcip.To4()\n\t}\n\n\t// 按 IP 精确匹配\n\tfor _, d := range devs {\n\t\tfor _, a := range d.Addresses {\n\t\t\tvar got net.IP\n\t\t\tgot = a.IP\n\t\t\tif got == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif is6 {\n\t\t\t\tif g := got.To16(); g != nil && IsIPv6(got) && g.Equal(v6) {\n\t\t\t\t\tDevCache.Store(key, d.Name)\n\t\t\t\t\treturn d.Name, nil\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif g := got.To4(); g != nil && g.Equal(v4) {\n\t\t\t\t\tDevCache.Store(key, d.Name)\n\t\t\t\t\treturn d.Name, nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"pcap device for IP %s not found\", srcip)\n}\n\n// OpenLiveImmediate 打开一个启用“立即模式”的 pcap 句柄\nfunc OpenLiveImmediate(dev string, snaplen int, promisc bool, bufferSize int) (*pcap.Handle, error) {\n\t// 创建一个未激活的句柄\n\tih, err := pcap.NewInactiveHandle(dev)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() {\n\t\tih.CleanUp()\n\t}()\n\n\tif snaplen <= 0 {\n\t\tsnaplen = 65535\n\t}\n\n\t// 设置每个包的最大抓取长度\n\tif err := ih.SetSnapLen(snaplen); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 设置超时模式为 BlockForever，阻塞等待每包数据\n\tif err := ih.SetTimeout(pcap.BlockForever); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 开启“立即模式”\n\tif err := ih.SetImmediateMode(true); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 开启“混杂模式”，以抓更多帧\n\tif err := ih.SetPromisc(promisc); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 设置内核缓冲区大小\n\tif bufferSize > 0 {\n\t\t_ = ih.SetBufferSize(bufferSize)\n\t}\n\n\t// 激活：获得可读写的数据包句柄\n\th, err := ih.Activate()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn h, nil\n}\n"
  },
  {
    "path": "util/privilege_stub.go",
    "content": "//go:build !windows\n\npackage util\n\nfunc HasAdminPrivileges() bool {\n\treturn true\n}\n"
  },
  {
    "path": "util/privilege_windows.go",
    "content": "//go:build windows\n\npackage util\n\nimport (\n\t\"unsafe\"\n\n\t\"golang.org/x/sys/windows\"\n)\n\n// HasAdminPrivileges reports whether the current Windows process is elevated.\nfunc HasAdminPrivileges() bool {\n\tvar token windows.Token\n\tif err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {\n\t\treturn false\n\t}\n\tdefer func() {\n\t\t_ = token.Close()\n\t}()\n\n\ttype tokenElevation struct {\n\t\tTokenIsElevated uint32\n\t}\n\tvar elev tokenElevation\n\tvar outLen uint32\n\tif err := windows.GetTokenInformation(\n\t\ttoken,\n\t\twindows.TokenElevation,\n\t\t(*byte)(unsafe.Pointer(&elev)),\n\t\tuint32(unsafe.Sizeof(elev)),\n\t\t&outLen,\n\t); err != nil {\n\t\treturn false\n\t}\n\treturn elev.TokenIsElevated != 0\n}\n"
  },
  {
    "path": "util/trace.go",
    "content": "package util\n\nimport (\n\t\"encoding/binary\"\n\t\"errors\"\n)\n\nfunc GetIPHeaderLength(data []byte) (int, error) {\n\tif len(data) < 1 {\n\t\treturn 0, errors.New(\"received invalid IP header\")\n\t}\n\tversion := data[0] >> 4\n\tswitch version {\n\tcase 4:\n\t\tihl := int(data[0] & 0x0F)\n\t\tif ihl < 5 {\n\t\t\treturn 0, errors.New(\"invalid IPv4 header length\")\n\t\t}\n\t\treturn ihl * 4, nil\n\tcase 6:\n\t\treturn 40, nil\n\tdefault:\n\t\treturn 0, errors.New(\"unknown IP version\")\n\t}\n}\n\nfunc extractIPv4Payload(data []byte, hdrLen int) ([]byte, error) {\n\tif len(data) < hdrLen {\n\t\treturn nil, errors.New(\"inner IPv4 header too short\")\n\t}\n\treturn data[hdrLen:], nil\n}\n\nfunc nextIPv6PayloadOffset(data []byte, offset int, next byte) (int, byte, error) {\n\tswitch next {\n\tcase 0, 43, 60:\n\t\tif offset+2 > len(data) {\n\t\t\treturn 0, 0, errors.New(\"IPv6 ext too short\")\n\t\t}\n\t\thdrExtLen := int(data[offset+1])\n\t\textLen := (hdrExtLen + 1) * 8\n\t\tif offset+extLen > len(data) {\n\t\t\treturn 0, 0, errors.New(\"IPv6 ext overflow\")\n\t\t}\n\t\treturn offset + extLen, data[offset], nil\n\tcase 44:\n\t\tif offset+8 > len(data) {\n\t\t\treturn 0, 0, errors.New(\"IPv6 frag too short\")\n\t\t}\n\t\treturn offset + 8, data[offset], nil\n\tcase 51:\n\t\tif offset+2 > len(data) {\n\t\t\treturn 0, 0, errors.New(\"IPv6 AH too short\")\n\t\t}\n\t\tahLen := int(data[offset+1])\n\t\textLen := (ahLen + 2) * 4\n\t\tif offset+extLen > len(data) {\n\t\t\treturn 0, 0, errors.New(\"IPv6 AH overflow\")\n\t\t}\n\t\treturn offset + extLen, data[offset], nil\n\tcase 50:\n\t\treturn 0, 0, errors.New(\"IPv6 ESP encountered; cannot locate upper-layer\")\n\tdefault:\n\t\treturn offset, next, nil\n\t}\n}\n\nfunc extractIPv6Payload(data []byte, hdrLen int) ([]byte, error) {\n\tif len(data) < hdrLen {\n\t\treturn nil, errors.New(\"inner IPv6 header too short\")\n\t}\n\n\toffset := hdrLen\n\tnext := data[6]\n\tfor {\n\t\tnextOffset, nextHeader, err := nextIPv6PayloadOffset(data, offset, next)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif nextOffset == offset {\n\t\t\tif offset > len(data) {\n\t\t\t\treturn nil, errors.New(\"IPv6 offset out of range\")\n\t\t\t}\n\t\t\treturn data[offset:], nil\n\t\t}\n\t\toffset = nextOffset\n\t\tnext = nextHeader\n\t}\n}\n\nfunc GetICMPResponsePayload(data []byte) ([]byte, error) {\n\tif len(data) < 1 {\n\t\treturn nil, errors.New(\"received invalid IP header\")\n\t}\n\tversion := data[0] >> 4\n\thdrLen, err := GetIPHeaderLength(data)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tswitch version {\n\tcase 4:\n\t\treturn extractIPv4Payload(data, hdrLen)\n\tcase 6:\n\t\treturn extractIPv6Payload(data, hdrLen)\n\tdefault:\n\t\treturn nil, errors.New(\"unknown IP version\")\n\t}\n}\n\nfunc GetICMPID(data []byte) (int, error) {\n\tif len(data) < 6 {\n\t\treturn 0, errors.New(\"length of icmp header too short for ID\")\n\t}\n\tseqBytes := data[4:6]\n\treturn int(binary.BigEndian.Uint16(seqBytes)), nil\n}\n\nfunc GetICMPSeq(data []byte) (int, error) {\n\tif len(data) < 8 {\n\t\treturn 0, errors.New(\"length of icmp header too short for seq\")\n\t}\n\tseqBytes := data[6:8]\n\treturn int(binary.BigEndian.Uint16(seqBytes)), nil\n}\n\nfunc GetTCPPorts(data []byte) (int, int, error) {\n\tif len(data) < 4 {\n\t\treturn 0, 0, errors.New(\"length of tcp header too short for ports\")\n\t}\n\tsrcPort := int(binary.BigEndian.Uint16(data[0:2]))\n\tdstPort := int(binary.BigEndian.Uint16(data[2:4]))\n\treturn srcPort, dstPort, nil\n}\n\nfunc GetTCPSeq(data []byte) (int, error) {\n\tif len(data) < 8 {\n\t\treturn 0, errors.New(\"length of tcp header too short for seq\")\n\t}\n\tseqBytes := data[4:8]\n\treturn int(binary.BigEndian.Uint32(seqBytes)), nil\n}\n\nfunc GetUDPPorts(data []byte) (int, int, error) {\n\tif len(data) < 4 {\n\t\treturn 0, 0, errors.New(\"length of udp header too short for ports\")\n\t}\n\tsrcPort := int(binary.BigEndian.Uint16(data[0:2]))\n\tdstPort := int(binary.BigEndian.Uint16(data[2:4]))\n\treturn srcPort, dstPort, nil\n}\n\nfunc GetUDPSeq(data []byte) (int, error) {\n\tif len(data) < 1 {\n\t\treturn 0, errors.New(\"received invalid IPv4 header\")\n\t}\n\thdrLen, err := GetIPHeaderLength(data)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif len(data) < hdrLen {\n\t\treturn 0, errors.New(\"length of IPv4 header too short for seq\")\n\t}\n\tseqBytes := data[4:6]\n\treturn int(binary.BigEndian.Uint16(seqBytes)), nil\n}\n\nfunc GetUDPSeqv6(data []byte) (int, error) {\n\tif len(data) < 8 {\n\t\treturn 0, errors.New(\"length of udp header too short for seq\")\n\t}\n\tseqBytes := data[6:8]\n\treturn int(binary.BigEndian.Uint16(seqBytes)), nil\n}\n"
  },
  {
    "path": "util/trace_privilege.go",
    "content": "package util\n\ntype TracePrivilegeCheck struct {\n\tMessage string\n\tFatal   bool\n}\n"
  },
  {
    "path": "util/trace_privilege_darwin.go",
    "content": "//go:build darwin\n\npackage util\n\n// macOS 上旧逻辑不会因为权限检查阻断执行，这里保持同样语义。\nfunc TracePrivilegeStatus(string, bool) TracePrivilegeCheck {\n\treturn TracePrivilegeCheck{}\n}\n"
  },
  {
    "path": "util/trace_privilege_linux.go",
    "content": "//go:build linux\n\npackage util\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/syndtr/gocapability/capability\"\n)\n\nfunc TracePrivilegeStatus(appBinName string, _ bool) TracePrivilegeCheck {\n\tif os.Getuid() == 0 {\n\t\treturn TracePrivilegeCheck{}\n\t}\n\n\tcaps, err := capability.NewPid2(0)\n\tif err != nil {\n\t\treturn TracePrivilegeCheck{Message: fmt.Sprintf(\"读取进程能力信息失败: %v\", err)}\n\t}\n\tif err := caps.Load(); err != nil {\n\t\treturn TracePrivilegeCheck{Message: fmt.Sprintf(\"加载进程能力信息失败: %v\", err)}\n\t}\n\tif caps.Get(capability.EFFECTIVE, capability.CAP_NET_RAW) && caps.Get(capability.EFFECTIVE, capability.CAP_NET_ADMIN) {\n\t\treturn TracePrivilegeCheck{}\n\t}\n\n\treturn TracePrivilegeCheck{\n\t\tMessage: fmt.Sprintf(\n\t\t\t\"您正在以普通用户权限运行 NextTrace，但 NextTrace 未被赋予监听网络套接字的ICMP消息包、修改IP头信息（TTL）等路由跟踪所需的权限\\n\"+\n\t\t\t\t\"请使用管理员用户执行 `sudo setcap cap_net_raw,cap_net_admin+eip ${your_nexttrace_path}/%s` 命令，赋予相关权限后再运行~\\n\"+\n\t\t\t\t\"什么？为什么 ping 普通用户执行不要 root 权限？因为这些工具在管理员安装时就已经被赋予了一些必要的权限，具体请使用 `getcap /usr/bin/ping` 查看\",\n\t\t\tappBinName,\n\t\t),\n\t}\n}\n"
  },
  {
    "path": "util/trace_privilege_stub.go",
    "content": "//go:build !linux && !darwin && !windows\n\npackage util\n\nfunc TracePrivilegeStatus(string, bool) TracePrivilegeCheck {\n\treturn TracePrivilegeCheck{}\n}\n"
  },
  {
    "path": "util/trace_privilege_windows.go",
    "content": "//go:build windows\n\npackage util\n\nfunc TracePrivilegeStatus(_ string, requireWindowsAdmin bool) TracePrivilegeCheck {\n\tif !requireWindowsAdmin || HasAdminPrivileges() {\n\t\treturn TracePrivilegeCheck{}\n\t}\n\treturn TracePrivilegeCheck{\n\t\tMessage: \"Windows 下 --mtu 需要管理员权限。当前实现依赖 WinDivert 或原始 ICMP 套接字；普通权限下无法可靠工作。请使用“以管理员身份运行”的终端重试。\",\n\t\tFatal:   true,\n\t}\n}\n"
  },
  {
    "path": "util/trace_test.go",
    "content": "package util\n\nimport (\n\t\"encoding/binary\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// ──────── GetIPHeaderLength ────────\n\nfunc TestGetIPHeaderLength_IPv4_MinIHL(t *testing.T) {\n\t// IHL=5 → 20 bytes\n\tdata := []byte{0x45} // version=4, IHL=5\n\tgot, err := GetIPHeaderLength(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 20, got)\n}\n\nfunc TestGetIPHeaderLength_IPv4_WithOptions(t *testing.T) {\n\t// IHL=15 → 60 bytes (maximum)\n\tdata := []byte{0x4F}\n\tgot, err := GetIPHeaderLength(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 60, got)\n}\n\nfunc TestGetIPHeaderLength_IPv4_InvalidIHL(t *testing.T) {\n\t// IHL=3 < 5 → error\n\tdata := []byte{0x43}\n\t_, err := GetIPHeaderLength(data)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"invalid IPv4 header length\")\n}\n\nfunc TestGetIPHeaderLength_IPv6(t *testing.T) {\n\tdata := []byte{0x60} // version=6\n\tgot, err := GetIPHeaderLength(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 40, got)\n}\n\nfunc TestGetIPHeaderLength_UnknownVersion(t *testing.T) {\n\tdata := []byte{0x30} // version=3\n\t_, err := GetIPHeaderLength(data)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"unknown IP version\")\n}\n\nfunc TestGetIPHeaderLength_Empty(t *testing.T) {\n\t_, err := GetIPHeaderLength(nil)\n\tassert.Error(t, err)\n}\n\n// ──────── GetICMPID / GetICMPSeq ────────\n\nfunc TestGetICMPID_Valid(t *testing.T) {\n\t// ICMP header: type(1) code(1) checksum(2) ID(2) seq(2)\n\tdata := make([]byte, 8)\n\tbinary.BigEndian.PutUint16(data[4:6], 0x1234) // ID\n\tgot, err := GetICMPID(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 0x1234, got)\n}\n\nfunc TestGetICMPID_TooShort(t *testing.T) {\n\tdata := make([]byte, 5)\n\t_, err := GetICMPID(data)\n\tassert.Error(t, err)\n}\n\nfunc TestGetICMPSeq_Valid(t *testing.T) {\n\tdata := make([]byte, 8)\n\tbinary.BigEndian.PutUint16(data[6:8], 42)\n\tgot, err := GetICMPSeq(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 42, got)\n}\n\nfunc TestGetICMPSeq_TooShort(t *testing.T) {\n\tdata := make([]byte, 7)\n\t_, err := GetICMPSeq(data)\n\tassert.Error(t, err)\n}\n\n// ──────── GetTCPPorts / GetTCPSeq ────────\n\nfunc TestGetTCPPorts_Valid(t *testing.T) {\n\tdata := make([]byte, 8)\n\tbinary.BigEndian.PutUint16(data[0:2], 12345) // src\n\tbinary.BigEndian.PutUint16(data[2:4], 80)    // dst\n\tsrc, dst, err := GetTCPPorts(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 12345, src)\n\tassert.Equal(t, 80, dst)\n}\n\nfunc TestGetTCPPorts_TooShort(t *testing.T) {\n\tdata := make([]byte, 3)\n\t_, _, err := GetTCPPorts(data)\n\tassert.Error(t, err)\n}\n\nfunc TestGetTCPSeq_Valid(t *testing.T) {\n\tdata := make([]byte, 8)\n\tbinary.BigEndian.PutUint32(data[4:8], 0xDEADBEEF)\n\tgot, err := GetTCPSeq(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, int(uint32(0xDEADBEEF)), got)\n}\n\nfunc TestGetTCPSeq_TooShort(t *testing.T) {\n\tdata := make([]byte, 7)\n\t_, err := GetTCPSeq(data)\n\tassert.Error(t, err)\n}\n\n// ──────── GetUDPPorts / GetUDPSeq / GetUDPSeqv6 ────────\n\nfunc TestGetUDPPorts_Valid(t *testing.T) {\n\tdata := make([]byte, 8)\n\tbinary.BigEndian.PutUint16(data[0:2], 33494)\n\tbinary.BigEndian.PutUint16(data[2:4], 443)\n\tsrc, dst, err := GetUDPPorts(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 33494, src)\n\tassert.Equal(t, 443, dst)\n}\n\nfunc TestGetUDPPorts_TooShort(t *testing.T) {\n\tdata := make([]byte, 3)\n\t_, _, err := GetUDPPorts(data)\n\tassert.Error(t, err)\n}\n\nfunc TestGetUDPSeq_Valid(t *testing.T) {\n\t// Build a mini IPv4 packet with IHL=5 (20 bytes) + enough for IP ID field\n\tipHdr := make([]byte, 20)\n\tipHdr[0] = 0x45 // v4, IHL=5\n\tbinary.BigEndian.PutUint16(ipHdr[4:6], 9999)\n\tgot, err := GetUDPSeq(ipHdr)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 9999, got)\n}\n\nfunc TestGetUDPSeq_TooShort(t *testing.T) {\n\t_, err := GetUDPSeq(nil)\n\tassert.Error(t, err)\n}\n\nfunc TestGetUDPSeqv6_Valid(t *testing.T) {\n\tdata := make([]byte, 8)\n\tbinary.BigEndian.PutUint16(data[6:8], 7777)\n\tgot, err := GetUDPSeqv6(data)\n\trequire.NoError(t, err)\n\tassert.Equal(t, 7777, got)\n}\n\nfunc TestGetUDPSeqv6_TooShort(t *testing.T) {\n\tdata := make([]byte, 7)\n\t_, err := GetUDPSeqv6(data)\n\tassert.Error(t, err)\n}\n\n// ──────── GetICMPResponsePayload ────────\n\nfunc TestGetICMPResponsePayload_IPv4_Simple(t *testing.T) {\n\t// IPv4 header (IHL=5, 20 bytes) + 4 bytes payload\n\tpkt := make([]byte, 24)\n\tpkt[0] = 0x45\n\tpkt[20] = 0xAA\n\tpkt[21] = 0xBB\n\tpkt[22] = 0xCC\n\tpkt[23] = 0xDD\n\tpayload, err := GetICMPResponsePayload(pkt)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []byte{0xAA, 0xBB, 0xCC, 0xDD}, payload)\n}\n\nfunc TestGetICMPResponsePayload_IPv6_NoExtHeaders(t *testing.T) {\n\t// IPv6 fixed header (40 bytes) with NextHeader=58 (ICMPv6) + 4 bytes payload\n\tpkt := make([]byte, 44)\n\tpkt[0] = 0x60 // version 6\n\tpkt[6] = 58   // Next Header: ICMPv6 (upper-layer, not extension)\n\tpkt[40] = 0x11\n\tpkt[41] = 0x22\n\tpkt[42] = 0x33\n\tpkt[43] = 0x44\n\tpayload, err := GetICMPResponsePayload(pkt)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []byte{0x11, 0x22, 0x33, 0x44}, payload)\n}\n\nfunc TestGetICMPResponsePayload_IPv6_WithHopByHopHeader(t *testing.T) {\n\tpkt := make([]byte, 52)\n\tpkt[0] = 0x60\n\tpkt[6] = 0   // Hop-by-Hop\n\tpkt[40] = 58 // Next Header: ICMPv6\n\tpkt[41] = 0  // 8-byte extension header\n\tpkt[48] = 0xAB\n\tpkt[49] = 0xCD\n\tpkt[50] = 0xEF\n\tpkt[51] = 0x01\n\n\tpayload, err := GetICMPResponsePayload(pkt)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []byte{0xAB, 0xCD, 0xEF, 0x01}, payload)\n}\n\nfunc TestGetICMPResponsePayload_Empty(t *testing.T) {\n\t_, err := GetICMPResponsePayload(nil)\n\tassert.Error(t, err)\n}\n"
  },
  {
    "path": "util/udp.go",
    "content": "package util\n\nimport (\n\t\"net\"\n)\n\nfunc newUDPResolver() *net.Resolver {\n\treturn &net.Resolver{\n\t\t// 指定使用 Go Build-in 的 DNS Resolver 来解析\n\t\tPreferGo: true,\n\t}\n}\n"
  },
  {
    "path": "util/util.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/url\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatih/color\"\n\n\t\"github.com/nxtrace/NTrace-core/config\"\n)\n\nvar SrcDev string\nvar SrcPort int\nvar DstIP string\nvar PowProviderParam = \"\"\nvar rDNSCache sync.Map\nvar UserAgent = fmt.Sprintf(\"NextTrace %s/%s/%s\", config.Version, runtime.GOOS, runtime.GOARCH)\nvar cachedLocalIP net.IP\nvar cachedLocalPort int\nvar localIPOnce sync.Once\nvar cachedLocalIPv6 net.IP\nvar cachedLocalPort6 int\nvar localIPv6Once sync.Once\n\nconst dnsLookupTimeout = 5 * time.Second\n\ntype addrLookupResolver interface {\n\tLookupAddr(ctx context.Context, addr string) ([]string, error)\n}\n\nvar (\n\tdomainResolverFactory                    = resolverFactory\n\trdnsResolver          addrLookupResolver = net.DefaultResolver\n)\n\nfunc IsIPv6(ip net.IP) bool {\n\treturn ip != nil && ip.To4() == nil && ip.To16() != nil\n}\n\n// AddrIP 从常见的 net.Addr 中提取 IP：支持 *net.IPAddr / *net.TCPAddr / *net.UDPAddr\n// 若无法提取，返回 nil\nfunc AddrIP(a net.Addr) net.IP {\n\tswitch addr := a.(type) {\n\tcase *net.IPAddr:\n\t\treturn addr.IP\n\tcase *net.TCPAddr:\n\t\treturn addr.IP\n\tcase *net.UDPAddr:\n\t\treturn addr.IP\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc RandomPortEnabled() bool {\n\treturn EnvRandomPort || SrcPort == -1\n}\n\nfunc LookupAddr(addr string) ([]string, error) {\n\treturn LookupAddrWithContext(context.Background(), addr)\n}\n\nfunc LookupAddrWithContext(ctx context.Context, addr string) ([]string, error) {\n\t// 如果在缓存中找到，直接返回\n\tif hostname, ok := rDNSCache.Load(addr); ok {\n\t\t//fmt.Println(\"hit rDNSCache for\", addr, hostname)\n\t\tif s, ok := hostname.(string); ok {\n\t\t\treturn []string{s}, nil\n\t\t}\n\t}\n\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tchild, cancel := context.WithTimeout(ctx, dnsLookupTimeout)\n\tdefer cancel()\n\n\t// 如果缓存中未找到，进行 DNS 查询\n\tnames, err := rdnsResolver.LookupAddr(child, addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 将查询结果存入缓存\n\tif len(names) > 0 {\n\t\trDNSCache.Store(addr, names[0])\n\t}\n\treturn names, nil\n}\n\n// getLocalIPPort（仅用于 IPv4）：\n// (1) 若 srcIP 非空，则以其为绑定源 IP；否则先通过 DialUDP 到 dstIP 获取实际出站源 IP\n// (2) 根据 proto：\n//\n//\t\"icmp\"     ：直接返回 bindIP，bindPort=0（表示“无端口”）；\n//\t\"tcp\"/\"udp\"：使用 Listen* 以 Port=0 做一次本地绑定测试，让内核分配可用端口，并在记录后立即关闭\n//\n// (3) 立即关闭监听并返回 (bindIP, bindPort)，若出错则返回 (nil, -1)\nfunc getLocalIPPort(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) {\n\tif dstIP == nil || dstIP.To4() == nil {\n\t\treturn nil, -1\n\t}\n\n\t// (1) 选定 bindIP：优先使用显式 srcIP，否则通过 UDP 伪 connect 探测\n\tvar bindIP net.IP\n\tif srcIP != nil && srcIP.To4() != nil {\n\t\tbindIP = srcIP\n\t} else {\n\t\tserverAddr := &net.UDPAddr{IP: dstIP, Port: 12345}\n\t\tcon, err := net.DialUDP(\"udp4\", nil, serverAddr)\n\t\tif err != nil {\n\t\t\treturn nil, -1\n\t\t}\n\t\tla, _ := con.LocalAddr().(*net.UDPAddr)\n\t\t_ = con.Close()\n\t\tif la == nil || la.IP == nil || la.IP.To4() == nil {\n\t\t\treturn nil, -1\n\t\t}\n\t\tbindIP = la.IP\n\t}\n\n\t// (2) 按需求测试端口可用性（仅本地 bind，不做网络握手）\n\tswitch proto {\n\tcase \"icmp\":\n\t\treturn bindIP, 0\n\tcase \"tcp\":\n\t\tln, err := net.ListenTCP(\"tcp4\", &net.TCPAddr{IP: bindIP, Port: 0})\n\t\tif err != nil {\n\t\t\treturn nil, -1\n\t\t}\n\t\tbindPort := ln.Addr().(*net.TCPAddr).Port\n\t\t_ = ln.Close()\n\t\treturn bindIP, bindPort\n\tcase \"udp\":\n\t\tpc, err := net.ListenUDP(\"udp4\", &net.UDPAddr{IP: bindIP, Port: 0})\n\t\tif err != nil {\n\t\t\treturn nil, -1\n\t\t}\n\t\tbindPort := pc.LocalAddr().(*net.UDPAddr).Port\n\t\t_ = pc.Close()\n\t\treturn bindIP, bindPort\n\t}\n\treturn nil, -1\n}\n\n// getLocalIPPortv6（仅用于 IPv6）：\n// (1) 若 srcIP 非空，则以其为绑定源 IP；否则先通过 DialUDP 到 dstIP 获取实际出站源 IP\n// (2) 根据 proto：\n//\n//\t\"icmp6\"      ：直接返回 bindIP，bindPort=0（表示“无端口”）；\n//\t\"tcp6\"/\"udp6\"：使用 Listen* 以 Port=0 做一次本地绑定测试，让内核分配可用端口，并在记录后立即关闭\n//\n// (3) 立即关闭监听并返回 (bindIP, bindPort)，若出错则返回 (nil, -1)\nfunc getLocalIPPortv6(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) {\n\tif !IsIPv6(dstIP) {\n\t\treturn nil, -1\n\t}\n\n\t// (1) 选定 bindIP：优先使用显式 srcIP，否则通过 UDP 伪 connect 探测\n\tvar bindIP net.IP\n\tif srcIP != nil && IsIPv6(srcIP) {\n\t\tbindIP = srcIP\n\t} else {\n\t\tserverAddr := &net.UDPAddr{IP: dstIP, Port: 12345}\n\t\tcon, err := net.DialUDP(\"udp6\", nil, serverAddr)\n\t\tif err != nil {\n\t\t\treturn nil, -1\n\t\t}\n\t\tla, _ := con.LocalAddr().(*net.UDPAddr)\n\t\t_ = con.Close()\n\t\tif la == nil || !IsIPv6(la.IP) {\n\t\t\treturn nil, -1\n\t\t}\n\t\tbindIP = la.IP\n\t}\n\n\t// (2) 按需求测试端口可用性（仅本地 bind，不做网络握手）\n\tswitch proto {\n\tcase \"icmp6\":\n\t\treturn bindIP, 0\n\tcase \"tcp6\":\n\t\tln, err := net.ListenTCP(\"tcp6\", &net.TCPAddr{IP: bindIP, Port: 0})\n\t\tif err != nil {\n\t\t\treturn nil, -1\n\t\t}\n\t\tbindPort := ln.Addr().(*net.TCPAddr).Port\n\t\t_ = ln.Close()\n\t\treturn bindIP, bindPort\n\tcase \"udp6\":\n\t\tpc, err := net.ListenUDP(\"udp6\", &net.UDPAddr{IP: bindIP, Port: 0})\n\t\tif err != nil {\n\t\t\treturn nil, -1\n\t\t}\n\t\tbindPort := pc.LocalAddr().(*net.UDPAddr).Port\n\t\t_ = pc.Close()\n\t\treturn bindIP, bindPort\n\t}\n\treturn nil, -1\n}\n\n// LocalIPPort 根据目标 IPv4（以及可选的源 IPv4 与协议）返回本地 IP 与一个可用端口\nfunc LocalIPPort(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) {\n\t// 若开启随机端口模式，每次直接计算并返回\n\tif RandomPortEnabled() {\n\t\treturn getLocalIPPort(dstIP, srcIP, proto)\n\t}\n\n\t// 否则仅计算一次并缓存\n\tlocalIPOnce.Do(func() {\n\t\tcachedLocalIP, cachedLocalPort = getLocalIPPort(dstIP, srcIP, proto)\n\t})\n\n\tif cachedLocalIP != nil {\n\t\treturn cachedLocalIP, cachedLocalPort\n\t}\n\treturn nil, -1\n}\n\n// LocalIPPortv6 根据目标 IPv6（以及可选的源 IPv6 与协议）返回本地 IP 与一个可用端口\nfunc LocalIPPortv6(dstIP net.IP, srcIP net.IP, proto string) (net.IP, int) {\n\t// 若开启随机端口模式，每次直接计算并返回\n\tif RandomPortEnabled() {\n\t\treturn getLocalIPPortv6(dstIP, srcIP, proto)\n\t}\n\n\t// 否则仅计算一次并缓存\n\tlocalIPv6Once.Do(func() {\n\t\tcachedLocalIPv6, cachedLocalPort6 = getLocalIPPortv6(dstIP, srcIP, proto)\n\t})\n\n\tif cachedLocalIPv6 != nil {\n\t\treturn cachedLocalIPv6, cachedLocalPort6\n\t}\n\treturn nil, -1\n}\n\ntype hostLookupResolver interface {\n\tLookupHost(ctx context.Context, host string) ([]string, error)\n}\n\ntype resolvedIPPrompt func([]net.IP) (int, error)\n\nfunc resolverFactory(dotServer string) hostLookupResolver {\n\tswitch dotServer {\n\tcase \"dnssb\":\n\t\treturn DNSSB()\n\tcase \"aliyun\":\n\t\treturn Aliyun()\n\tcase \"dnspod\":\n\t\treturn Dnspod()\n\tcase \"google\":\n\t\treturn Google()\n\tcase \"cloudflare\":\n\t\treturn Cloudflare()\n\tdefault:\n\t\treturn newUDPResolver()\n\t}\n}\n\nfunc lookupIPs(ctx context.Context, resolver hostLookupResolver, host string) ([]net.IP, error) {\n\tipsStr, err := resolver.LookupHost(ctx, host)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"DNS lookup failed: %w\", err)\n\t}\n\n\tips := make([]net.IP, 0, len(ipsStr))\n\tfor _, value := range ipsStr {\n\t\tif parsed := net.ParseIP(value); parsed != nil {\n\t\t\tips = append(ips, parsed)\n\t\t}\n\t}\n\treturn ips, nil\n}\n\nfunc filterByFamily(ips []net.IP, ipVersion string) []net.IP {\n\tif ipVersion == \"all\" {\n\t\treturn ips\n\t}\n\tfor _, ip := range ips {\n\t\tif ip == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif ipVersion == \"4\" && ip.To4() != nil {\n\t\t\treturn []net.IP{ip}\n\t\t}\n\t\tif ipVersion == \"6\" && ip.To4() == nil {\n\t\t\treturn []net.IP{ip}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc resolveFamilyLabel(ipVersion string) string {\n\tswitch ipVersion {\n\tcase \"4\":\n\t\treturn \"IPv4\"\n\tcase \"6\":\n\t\treturn \"IPv6\"\n\tcase \"all\", \"\":\n\t\treturn \"IPv4/IPv6\"\n\tdefault:\n\t\treturn ipVersion\n\t}\n}\n\nfunc promptResolvedIPChoice(ips []net.IP) (int, error) {\n\tfmt.Println(\"Please Choose the IP You Want To TraceRoute\")\n\tfor i, ip := range ips {\n\t\t_, _ = fmt.Fprintf(color.Output, \"%s %s\\n\",\n\t\t\tcolor.New(color.FgHiYellow, color.Bold).Sprintf(\"%d.\", i),\n\t\t\tcolor.New(color.FgWhite, color.Bold).Sprintf(\"%s\", ip),\n\t\t)\n\t}\n\tfmt.Printf(\"Your Option: \")\n\n\tvar index int\n\t_, err := fmt.Scanln(&index)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn index, nil\n}\n\nfunc selectResolvedIP(ips []net.IP, disableOutput bool, prompt resolvedIPPrompt) (net.IP, error) {\n\tif len(ips) == 0 {\n\t\treturn nil, errors.New(\"no IPs available\")\n\t}\n\tif len(ips) == 1 || disableOutput {\n\t\treturn ips[0], nil\n\t}\n\tif prompt == nil {\n\t\tprompt = promptResolvedIPChoice\n\t}\n\n\tindex, err := prompt(ips)\n\tif err != nil {\n\t\tindex = 0\n\t}\n\tif index < 0 || index >= len(ips) {\n\t\treturn nil, fmt.Errorf(\"invalid selection: %d\", index)\n\t}\n\treturn ips[index], nil\n}\n\nfunc DomainLookUp(host string, ipVersion string, dotServer string, disableOutput bool) (net.IP, error) {\n\treturn DomainLookUpWithContext(context.Background(), host, ipVersion, dotServer, disableOutput)\n}\n\nfunc DomainLookUpWithContext(ctx context.Context, host string, ipVersion string, dotServer string, disableOutput bool) (net.IP, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tchild, cancel := context.WithTimeout(ctx, dnsLookupTimeout)\n\tdefer cancel()\n\n\tips, err := lookupIPs(child, domainResolverFactory(dotServer), host)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tips = filterByFamily(ips, ipVersion)\n\n\tif len(ips) == 0 {\n\t\treturn nil, fmt.Errorf(\"no %s DNS records found for %s\", resolveFamilyLabel(ipVersion), host)\n\t}\n\n\tselected, err := selectResolvedIP(ips, disableOutput, promptResolvedIPChoice)\n\tif err != nil {\n\t\tfmt.Println(\"Your Option is invalid\")\n\t\treturn nil, err\n\t}\n\treturn selected, nil\n}\n\nfunc GetHostAndPort() (host string, port string) {\n\t// 解析域名\n\thostArr := strings.Split(EnvHostPort, \":\")\n\t// 判断是否有指定端口\n\tif len(hostArr) > 1 {\n\t\t// 判断是否为 IPv6\n\t\tif strings.HasPrefix(EnvHostPort, \"[\") {\n\t\t\ttmp := strings.Split(EnvHostPort, \"]\")\n\t\t\thost = tmp[0]\n\t\t\thost = host[1:]\n\t\t\tif port = tmp[1]; port != \"\" {\n\t\t\t\tport = port[1:]\n\t\t\t}\n\t\t} else {\n\t\t\thost, port = hostArr[0], hostArr[1]\n\t\t}\n\t} else {\n\t\thost = EnvHostPort\n\t}\n\tif port == \"\" {\n\t\t// 默认端口\n\t\tport = \"443\"\n\t}\n\treturn\n}\n\nfunc GetProxy() *url.URL {\n\tif EnvProxyURL == \"\" {\n\t\treturn nil\n\t}\n\tproxyURL, err := url.Parse(EnvProxyURL)\n\tif err != nil {\n\t\tlog.Println(\"Failed to parse proxy URL:\", err)\n\t\treturn nil\n\t}\n\treturn proxyURL\n}\n\nfunc GetPowProvider() string {\n\tvar powProvider string\n\tif PowProviderParam == \"\" {\n\t\tpowProvider = EnvPowProvider\n\t} else {\n\t\tpowProvider = PowProviderParam\n\t}\n\tif powProvider == \"sakura\" {\n\t\treturn \"pow.nexttrace.owo.13a.com\"\n\t}\n\treturn \"\"\n}\n\nfunc StringInSlice(val string, list []string) bool {\n\tfor _, v := range list {\n\t\tif v == val {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc HideIPPart(ip string) string {\n\tparsedIP := net.ParseIP(ip)\n\tif parsedIP == nil {\n\t\treturn \"\"\n\t}\n\n\tif parsedIP.To4() != nil {\n\t\t// IPv4: 隐藏后16位\n\t\treturn strings.Join(strings.Split(ip, \".\")[:2], \".\") + \".0.0/16\"\n\t}\n\t// IPv6: 隐藏后96位\n\treturn parsedIP.Mask(net.CIDRMask(32, 128)).String() + \"/32\"\n}\n\n// fold16 对 32 位累加和做 16 位一补折叠（包含环回进位）\nfunc fold16(sum uint32) uint16 {\n\t// 将高 16 位进位折叠回低 16 位，直至无进位\n\tfor (sum >> 16) != 0 {\n\t\tsum = (sum & 0xFFFF) + (sum >> 16)\n\t}\n\n\treturn uint16(sum & 0xFFFF)\n}\n\n// addBytes 按大端序把字节流每 16 位累加到 sum；若长度为奇数，最后 1 字节作为高位、低位补 0\nfunc addBytes(sum uint32, data []byte) uint32 {\n\t// 按大端序每次取两个字节，将合成后的 16 位无符号数累加到 sum\n\tfor i := 0; i+1 < len(data); i += 2 {\n\t\tsum += uint32(data[i])<<8 | uint32(data[i+1])\n\t}\n\n\t// 奇数字节则末尾补 0\n\tif len(data)%2 == 1 {\n\t\tsum += uint32(data[len(data)-1]) << 8\n\t}\n\n\treturn sum\n}\n\n// UDPBaseSum 在“UDP.Checksum 视为 0、payload[0:2]=0x0000”的前提下，计算 16 位一补和 S0\nfunc UDPBaseSum(srcIP, dstIP net.IP, srcPort, dstPort, udpLen int, payload []byte) uint16 {\n\tsum := uint32(0)\n\n\tif src4 := srcIP.To4(); src4 != nil {\n\t\tdst4 := dstIP.To4()\n\t\tsum = addBytes(sum, src4)\n\t\tsum = addBytes(sum, dst4)\n\t\tsum += uint32(0x0011)\n\t\tsum += uint32(udpLen & 0xFFFF)\n\t} else {\n\t\tsrc6 := srcIP.To16()\n\t\tdst6 := dstIP.To16()\n\t\tsum = addBytes(sum, src6)\n\t\tsum = addBytes(sum, dst6)\n\n\t\tuLen := uint32(udpLen)\n\t\tsum += (uLen >> 16) & 0xFFFF\n\t\tsum += uLen & 0xFFFF\n\n\t\tsum += uint32(0x0011)\n\t}\n\tsum += uint32(srcPort & 0xFFFF)\n\tsum += uint32(dstPort & 0xFFFF)\n\tsum += uint32(udpLen & 0xFFFF)\n\n\tsum = addBytes(sum, payload)\n\n\treturn fold16(sum)\n}\n\n// FudgeWordForSeq 给定 S0 与目标 checksum（如 seq），返回应写入 payload[0:2] 的补偿值（16 位）\n// 原理：最终一补和 targetSum = ^targetChecksum；令补偿位 X，则 X = targetSum ⊕ (~S0)\nfunc FudgeWordForSeq(S0, targetChecksum uint16) uint16 {\n\ttargetSum := ^targetChecksum         // 目标一补和\n\tx := uint32(targetSum) + uint32(^S0) // X = targetSum ⊕ (~S0)\n\treturn fold16(x)\n}\n\n// MakePayloadWithTargetChecksum 修改 payload，使最终 UDP.Checksum == targetChecksum\n// 要求：payload 长度 >= 2（前 2 字节作为补偿位写入）\nfunc MakePayloadWithTargetChecksum(payload []byte, srcIP, dstIP net.IP, srcPort, dstPort int, targetChecksum uint16) error {\n\tif len(payload) < 2 {\n\t\treturn errors.New(\"payload too short, need >= 2 bytes for fudge\")\n\t}\n\n\t// v4/v6 一致性校验\n\tif (srcIP.To4() == nil) != (dstIP.To4() == nil) {\n\t\treturn errors.New(\"src/dst IP version mismatch (v4/v6)\")\n\t}\n\n\t// 补偿位清零，再按“校验和字段=0”的前提计算 S0\n\tpayload[0], payload[1] = 0, 0\n\tudpLen := 8 + len(payload)\n\tS0 := UDPBaseSum(srcIP, dstIP, srcPort, dstPort, udpLen, payload)\n\tfudge := FudgeWordForSeq(S0, targetChecksum)\n\n\t// 回写补偿位（网络序）\n\tpayload[0] = byte(fudge >> 8)\n\tpayload[1] = byte(fudge)\n\treturn nil\n}\n"
  },
  {
    "path": "util/util_test.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// ──────── IsIPv6 ────────\n\nfunc TestIsIPv6_True(t *testing.T) {\n\tassert.True(t, IsIPv6(net.ParseIP(\"2001:db8::1\")))\n\tassert.True(t, IsIPv6(net.ParseIP(\"::1\")))\n\tassert.True(t, IsIPv6(net.ParseIP(\"fe80::1\")))\n}\n\nfunc TestIsIPv6_False(t *testing.T) {\n\tassert.False(t, IsIPv6(net.ParseIP(\"1.2.3.4\")))\n\tassert.False(t, IsIPv6(net.ParseIP(\"127.0.0.1\")))\n\tassert.False(t, IsIPv6(nil))\n}\n\n// ──────── AddrIP ────────\n\nfunc TestAddrIP_IPAddr(t *testing.T) {\n\tip := net.ParseIP(\"8.8.8.8\")\n\tgot := AddrIP(&net.IPAddr{IP: ip})\n\tassert.Equal(t, ip, got)\n}\n\nfunc TestAddrIP_TCPAddr(t *testing.T) {\n\tip := net.ParseIP(\"1.1.1.1\")\n\tgot := AddrIP(&net.TCPAddr{IP: ip, Port: 80})\n\tassert.Equal(t, ip, got)\n}\n\nfunc TestAddrIP_UDPAddr(t *testing.T) {\n\tip := net.ParseIP(\"2001:db8::1\")\n\tgot := AddrIP(&net.UDPAddr{IP: ip, Port: 53})\n\tassert.Equal(t, ip, got)\n}\n\nfunc TestAddrIP_UnixAddr(t *testing.T) {\n\tgot := AddrIP(&net.UnixAddr{Name: \"/tmp/sock\"})\n\tassert.Nil(t, got)\n}\n\nfunc TestAddrIP_Nil(t *testing.T) {\n\tgot := AddrIP(nil)\n\tassert.Nil(t, got)\n}\n\n// ──────── StringInSlice ────────\n\nfunc TestStringInSlice_Found(t *testing.T) {\n\tassert.True(t, StringInSlice(\"b\", []string{\"a\", \"b\", \"c\"}))\n}\n\nfunc TestStringInSlice_NotFound(t *testing.T) {\n\tassert.False(t, StringInSlice(\"z\", []string{\"a\", \"b\"}))\n}\n\nfunc TestStringInSlice_EmptySlice(t *testing.T) {\n\tassert.False(t, StringInSlice(\"any\", nil))\n}\n\n// ──────── HideIPPart ────────\n\nfunc TestHideIPPart_IPv4(t *testing.T) {\n\tassert.Equal(t, \"192.168.0.0/16\", HideIPPart(\"192.168.1.1\"))\n}\n\nfunc TestHideIPPart_IPv6(t *testing.T) {\n\tgot := HideIPPart(\"2001:db8::1\")\n\tassert.Equal(t, \"2001:db8::/32\", got)\n}\n\nfunc TestHideIPPart_Invalid(t *testing.T) {\n\tassert.Equal(t, \"\", HideIPPart(\"notanip\"))\n}\n\n// ──────── UDPBaseSum ────────\n\nfunc TestUDPBaseSum_IPv4_KnownValue(t *testing.T) {\n\tsrc := net.ParseIP(\"192.168.1.1\").To4()\n\tdst := net.ParseIP(\"10.0.0.1\").To4()\n\tpayload := make([]byte, 4) // 4-byte payload, all zero\n\tudpLen := 8 + len(payload)\n\tgot := UDPBaseSum(src, dst, 12345, 80, udpLen, payload)\n\t// Should produce a non-zero checksum partial\n\tassert.NotEqual(t, uint16(0), got)\n}\n\nfunc TestUDPBaseSum_IPv6_NonZero(t *testing.T) {\n\tsrc := net.ParseIP(\"2001:db8::1\")\n\tdst := net.ParseIP(\"2001:db8::2\")\n\tpayload := []byte{0, 0, 0, 0}\n\tudpLen := 8 + len(payload)\n\tgot := UDPBaseSum(src, dst, 1000, 2000, udpLen, payload)\n\tassert.NotEqual(t, uint16(0), got)\n}\n\n// ──────── FudgeWordForSeq ────────\n\nfunc TestFudgeWordForSeq_RoundTrip(t *testing.T) {\n\t// Given a base sum S0 and a target checksum, the fudge word should allow\n\t// reconstructing the target. We verify by recomputing.\n\tS0 := uint16(0x1234)\n\ttarget := uint16(42)\n\tfudge := FudgeWordForSeq(S0, target)\n\t// Reconstruct: fold16(S0 + fudge) should equal ~target\n\tsum := uint32(S0) + uint32(fudge)\n\tfor (sum >> 16) != 0 {\n\t\tsum = (sum & 0xFFFF) + (sum >> 16)\n\t}\n\treconstructed := ^uint16(sum)\n\tassert.Equal(t, target, reconstructed)\n}\n\n// ──────── MakePayloadWithTargetChecksum ────────\n\nfunc TestMakePayloadWithTargetChecksum_RoundTrip(t *testing.T) {\n\tsrc := net.ParseIP(\"10.0.0.1\").To4()\n\tdst := net.ParseIP(\"10.0.0.2\").To4()\n\tpayload := make([]byte, 8)\n\ttargetCS := uint16(9999)\n\n\terr := MakePayloadWithTargetChecksum(payload, src, dst, 33494, 33434, targetCS)\n\trequire.NoError(t, err)\n\n\t// Verify: compute the full checksum using the modified payload\n\tudpLen := 8 + len(payload)\n\tfinalSum := UDPBaseSum(src, dst, 33494, 33434, udpLen, payload)\n\tfinalChecksum := ^finalSum\n\tassert.Equal(t, targetCS, finalChecksum)\n}\n\nfunc TestMakePayloadWithTargetChecksum_TooShort(t *testing.T) {\n\tsrc := net.ParseIP(\"10.0.0.1\").To4()\n\tdst := net.ParseIP(\"10.0.0.2\").To4()\n\tpayload := make([]byte, 1) // too short\n\terr := MakePayloadWithTargetChecksum(payload, src, dst, 100, 200, 42)\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"too short\")\n}\n\nfunc TestMakePayloadWithTargetChecksum_VersionMismatch(t *testing.T) {\n\tsrc := net.ParseIP(\"10.0.0.1\").To4()\n\tdst := net.ParseIP(\"2001:db8::1\") // v6\n\tpayload := make([]byte, 4)\n\terr := MakePayloadWithTargetChecksum(payload, src, dst, 100, 200, 42)\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"mismatch\")\n}\n\ntype fakeHostLookupResolver struct {\n\thosts []string\n\terr   error\n}\n\nfunc (f fakeHostLookupResolver) LookupHost(context.Context, string) ([]string, error) {\n\tif f.err != nil {\n\t\treturn nil, f.err\n\t}\n\treturn f.hosts, nil\n}\n\ntype fakeHostLookupResolverWithContext struct {\n\tlookup func(ctx context.Context, host string) ([]string, error)\n}\n\nfunc (f fakeHostLookupResolverWithContext) LookupHost(ctx context.Context, host string) ([]string, error) {\n\treturn f.lookup(ctx, host)\n}\n\ntype fakeAddrLookupResolver struct {\n\tlookup func(ctx context.Context, addr string) ([]string, error)\n}\n\nfunc (f fakeAddrLookupResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {\n\treturn f.lookup(ctx, addr)\n}\n\nfunc TestLookupIPs_SkipsInvalidValues(t *testing.T) {\n\tips, err := lookupIPs(context.Background(), fakeHostLookupResolver{\n\t\thosts: []string{\"1.1.1.1\", \"not-an-ip\", \"2606:4700::1\"},\n\t}, \"example.com\")\n\trequire.NoError(t, err)\n\trequire.Len(t, ips, 2)\n\tassert.Equal(t, \"1.1.1.1\", ips[0].String())\n\tassert.Equal(t, \"2606:4700::1\", ips[1].String())\n}\n\nfunc TestLookupIPs_ReturnsWrappedError(t *testing.T) {\n\t_, err := lookupIPs(context.Background(), fakeHostLookupResolver{err: errors.New(\"boom\")}, \"example.com\")\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"DNS lookup failed\")\n}\n\nfunc TestDomainLookUpWithContextReturnsContextCanceled(t *testing.T) {\n\toldFactory := domainResolverFactory\n\tdomainResolverFactory = func(string) hostLookupResolver {\n\t\treturn fakeHostLookupResolverWithContext{\n\t\t\tlookup: func(ctx context.Context, host string) ([]string, error) {\n\t\t\t\t<-ctx.Done()\n\t\t\t\treturn nil, ctx.Err()\n\t\t\t},\n\t\t}\n\t}\n\tdefer func() { domainResolverFactory = oldFactory }()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\t_, err := DomainLookUpWithContext(ctx, \"example.com\", \"all\", \"\", true)\n\trequire.Error(t, err)\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Fatalf(\"DomainLookUpWithContext error = %v, want context.Canceled\", err)\n\t}\n\tif elapsed := time.Since(start); elapsed > 100*time.Millisecond {\n\t\tt.Fatalf(\"DomainLookUpWithContext returned too slowly after cancel: %v\", elapsed)\n\t}\n}\n\nfunc TestLookupAddrWithContextUsesCache(t *testing.T) {\n\toldResolver := rdnsResolver\n\trdnsResolver = fakeAddrLookupResolver{\n\t\tlookup: func(context.Context, string) ([]string, error) {\n\t\t\tt.Fatal(\"resolver should not be called when cache is warm\")\n\t\t\treturn nil, nil\n\t\t},\n\t}\n\tdefer func() { rdnsResolver = oldResolver }()\n\n\trDNSCache = sync.Map{}\n\trDNSCache.Store(\"1.1.1.1\", \"cached.example.\")\n\n\tnames, err := LookupAddrWithContext(context.Background(), \"1.1.1.1\")\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"cached.example.\"}, names)\n}\n\nfunc TestLookupAddrWithContextStoresResultInCache(t *testing.T) {\n\toldResolver := rdnsResolver\n\trdnsResolver = fakeAddrLookupResolver{\n\t\tlookup: func(context.Context, string) ([]string, error) {\n\t\t\treturn []string{\"resolver.example.\"}, nil\n\t\t},\n\t}\n\tdefer func() { rdnsResolver = oldResolver }()\n\n\trDNSCache = sync.Map{}\n\n\tnames, err := LookupAddrWithContext(context.Background(), \"1.1.1.1\")\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"resolver.example.\"}, names)\n\n\tcached, ok := rDNSCache.Load(\"1.1.1.1\")\n\trequire.True(t, ok)\n\trequire.Equal(t, \"resolver.example.\", cached)\n}\n\nfunc TestLookupAddrWithContextReturnsContextCanceled(t *testing.T) {\n\toldResolver := rdnsResolver\n\trdnsResolver = fakeAddrLookupResolver{\n\t\tlookup: func(ctx context.Context, addr string) ([]string, error) {\n\t\t\t<-ctx.Done()\n\t\t\treturn nil, ctx.Err()\n\t\t},\n\t}\n\tdefer func() { rdnsResolver = oldResolver }()\n\n\trDNSCache = sync.Map{}\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\t_, err := LookupAddrWithContext(ctx, \"1.1.1.1\")\n\trequire.Error(t, err)\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Fatalf(\"LookupAddrWithContext error = %v, want context.Canceled\", err)\n\t}\n\tif elapsed := time.Since(start); elapsed > 100*time.Millisecond {\n\t\tt.Fatalf(\"LookupAddrWithContext returned too slowly after cancel: %v\", elapsed)\n\t}\n}\n\nfunc TestFilterByFamily_PicksFirstMatchingAddress(t *testing.T) {\n\tips := []net.IP{\n\t\tnet.ParseIP(\"2606:4700::1\"),\n\t\tnet.ParseIP(\"1.1.1.1\"),\n\t\tnet.ParseIP(\"8.8.8.8\"),\n\t}\n\tfiltered := filterByFamily(ips, \"4\")\n\trequire.Len(t, filtered, 1)\n\tassert.Equal(t, \"1.1.1.1\", filtered[0].String())\n}\n\nfunc TestSelectResolvedIP_PromptErrorFallsBackToFirst(t *testing.T) {\n\tips := []net.IP{net.ParseIP(\"1.1.1.1\"), net.ParseIP(\"8.8.8.8\")}\n\tselected, err := selectResolvedIP(ips, false, func([]net.IP) (int, error) {\n\t\treturn 0, errors.New(\"stdin closed\")\n\t})\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"1.1.1.1\", selected.String())\n}\n\nfunc TestSelectResolvedIP_InvalidIndex(t *testing.T) {\n\tips := []net.IP{net.ParseIP(\"1.1.1.1\"), net.ParseIP(\"8.8.8.8\")}\n\t_, err := selectResolvedIP(ips, false, func([]net.IP) (int, error) {\n\t\treturn 10, nil\n\t})\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"invalid selection\")\n}\n\nfunc TestResolveFamilyLabel(t *testing.T) {\n\tassert.Equal(t, \"IPv4\", resolveFamilyLabel(\"4\"))\n\tassert.Equal(t, \"IPv6\", resolveFamilyLabel(\"6\"))\n\tassert.Equal(t, \"IPv4/IPv6\", resolveFamilyLabel(\"all\"))\n}\n\n// ──────── GetPowProvider ────────\n\nfunc TestGetPowProvider_Default(t *testing.T) {\n\told := PowProviderParam\n\toldEnv := EnvPowProvider\n\tdefer func() { PowProviderParam = old; EnvPowProvider = oldEnv }()\n\n\tPowProviderParam = \"\"\n\tEnvPowProvider = \"\"\n\tassert.Equal(t, \"\", GetPowProvider())\n}\n\nfunc TestGetPowProvider_Sakura(t *testing.T) {\n\told := PowProviderParam\n\tdefer func() { PowProviderParam = old }()\n\n\tPowProviderParam = \"sakura\"\n\tassert.Equal(t, \"pow.nexttrace.owo.13a.com\", GetPowProvider())\n}\n"
  },
  {
    "path": "wshandle/client.go",
    "content": "package wshandle\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/nxtrace/NTrace-core/pow\"\n\t\"github.com/nxtrace/NTrace-core/util\"\n)\n\nfunc formatHostPort(addr, port string) string {\n\tclean := strings.TrimSpace(addr)\n\tclean = strings.Trim(clean, \"[]\")\n\tif strings.Contains(clean, \":\") {\n\t\treturn \"[\" + clean + \"]:\" + port\n\t}\n\treturn clean + \":\" + port\n}\n\ntype wsWriteJob struct {\n\tmsgType int\n\tdata    []byte\n}\n\nconst (\n\twsClientWriteQueueSize = 1024\n\twsClientWriteTimeout   = 5 * time.Second\n\twsClientDialTimeout    = 5 * time.Second\n)\n\ntype WsConn struct {\n\tConnecting   bool\n\tConnected    bool            // 连接状态\n\tMsgSendCh    chan string     // 消息发送通道\n\tMsgReceiveCh chan string     // 消息接收通道\n\tDone         chan struct{}   // 发送结束通道\n\tExit         chan bool       // 程序退出信号\n\tInterrupt    chan os.Signal  // 终端中止信号\n\tConn         *websocket.Conn // 主连接\n\tConnMux      sync.Mutex      // 连接互斥锁\n\tstateMu      sync.RWMutex\n\tlifecycleMu  sync.Mutex\n\tloopWG       sync.WaitGroup\n\tcloseOnce    sync.Once\n\twriteCh      chan wsWriteJob // serialized write queue\n\twriteStop    chan struct{}   // signals writeLoop to exit\n\tcloseCh      chan struct{}   // signals background loops to exit\n\tclosed       bool\n\tbaseCtx      context.Context\n}\n\nfunc (c *WsConn) getConn() *websocket.Conn {\n\tc.stateMu.RLock()\n\tdefer c.stateMu.RUnlock()\n\treturn c.Conn\n}\n\nfunc (c *WsConn) setConn(conn *websocket.Conn) {\n\tc.stateMu.Lock()\n\tc.Conn = conn\n\tc.stateMu.Unlock()\n}\n\nfunc (c *WsConn) getDoneChan() chan struct{} {\n\tc.stateMu.RLock()\n\tdefer c.stateMu.RUnlock()\n\treturn c.Done\n}\n\nfunc (c *WsConn) setDoneChan(done chan struct{}) {\n\tc.stateMu.Lock()\n\tc.Done = done\n\tc.stateMu.Unlock()\n}\n\n// initWriteLoop creates the write channel and starts the single writer goroutine.\n// Must be called once when the WsConn is created.\nfunc (c *WsConn) initWriteLoop() {\n\tc.writeCh = make(chan wsWriteJob, wsClientWriteQueueSize)\n\tc.writeStop = make(chan struct{})\n\tc.startLoop(c.writeLoop)\n}\n\n// writeLoop is the sole goroutine allowed to call conn.WriteMessage.\nfunc (c *WsConn) writeLoop() {\n\tfor {\n\t\tselect {\n\t\tcase <-c.writeStop:\n\t\t\treturn\n\t\tcase job, ok := <-c.writeCh:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconn := c.getConn()\n\t\t\tif conn == nil {\n\t\t\t\tc.setConnected(false)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t_ = conn.SetWriteDeadline(time.Now().Add(wsClientWriteTimeout))\n\t\t\tif err := conn.WriteMessage(job.msgType, job.data); err != nil {\n\t\t\t\tlog.Printf(\"wshandle writeLoop: %v\", err)\n\t\t\t\tc.setConnected(false)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// enqueueWrite sends a write job to the writeLoop. Returns an error if the\n// queue is full or writeLoop has stopped.\nfunc (c *WsConn) enqueueWrite(job wsWriteJob) error {\n\tc.lifecycleMu.Lock()\n\tdefer c.lifecycleMu.Unlock()\n\tif c.closed {\n\t\treturn errWriteLoopStopped\n\t}\n\tselect {\n\tcase c.writeCh <- job:\n\t\treturn nil\n\tcase <-c.writeStop:\n\t\treturn errWriteLoopStopped\n\tdefault:\n\t\treturn errWriteQueueFull\n\t}\n}\n\nvar (\n\terrWriteQueueFull   = errors.New(\"wshandle: write queue full\")\n\terrWriteLoopStopped = errors.New(\"wshandle: write loop stopped\")\n)\n\nvar wsconn *WsConn\nvar wsconnMu sync.RWMutex\nvar wsconnNewMu sync.Mutex\nvar host, port, fastIp string\nvar envToken = util.EnvToken\nvar cacheToken string\nvar cacheTokenFailedTimes int\nvar createWsConnFn = createWsConn\nvar wsGetFastIPFn = util.GetFastIPWithContext\nvar wsGetTokenFn = pow.GetTokenWithContext\n\nfunc newWsConn(conn *websocket.Conn, interrupt chan os.Signal) *WsConn {\n\tc := &WsConn{\n\t\tConn:         conn,\n\t\tMsgSendCh:    make(chan string, 10),\n\t\tMsgReceiveCh: make(chan string, 10),\n\t\tInterrupt:    interrupt,\n\t\tcloseCh:      make(chan struct{}),\n\t\tbaseCtx:      context.Background(),\n\t}\n\tc.initWriteLoop()\n\treturn c\n}\n\nfunc normalizeContext(ctx context.Context) context.Context {\n\tif ctx == nil {\n\t\treturn context.Background()\n\t}\n\treturn ctx\n}\n\nfunc deriveOperationContext(parent context.Context, stopCh <-chan struct{}, timeout time.Duration) (context.Context, context.CancelFunc) {\n\tbase := normalizeContext(parent)\n\tlinkedCtx, linkedCancel := context.WithCancel(base)\n\tif stopCh != nil {\n\t\tgo func() {\n\t\t\tselect {\n\t\t\tcase <-stopCh:\n\t\t\t\tlinkedCancel()\n\t\t\tcase <-linkedCtx.Done():\n\t\t\t}\n\t\t}()\n\t}\n\tif timeout <= 0 {\n\t\treturn linkedCtx, linkedCancel\n\t}\n\tctx, cancel := context.WithTimeout(linkedCtx, timeout)\n\treturn ctx, func() {\n\t\tcancel()\n\t\tlinkedCancel()\n\t}\n}\n\nfunc (c *WsConn) startLoop(fn func()) {\n\tc.loopWG.Add(1)\n\tgo func() {\n\t\tdefer c.loopWG.Done()\n\t\tfn()\n\t}()\n}\n\nfunc (c *WsConn) isClosed() bool {\n\tif c == nil {\n\t\treturn true\n\t}\n\tselect {\n\tcase <-c.closeCh:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc closeSignalChan(ch chan struct{}) {\n\tif ch == nil {\n\t\treturn\n\t}\n\tdefer func() {\n\t\t_ = recover()\n\t}()\n\tclose(ch)\n}\n\nfunc (c *WsConn) closeConn() {\n\tconn := c.getConn()\n\tif conn == nil {\n\t\treturn\n\t}\n\t_ = conn.Close()\n\tc.setConn(nil)\n}\n\nfunc (c *WsConn) replaceConn(conn *websocket.Conn) {\n\tc.stateMu.Lock()\n\tprev := c.Conn\n\tc.Conn = conn\n\tc.stateMu.Unlock()\n\tif prev != nil && prev != conn {\n\t\t_ = prev.Close()\n\t}\n}\n\nfunc (c *WsConn) Close() {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.closeOnce.Do(func() {\n\t\tc.lifecycleMu.Lock()\n\t\tc.closed = true\n\t\tc.lifecycleMu.Unlock()\n\n\t\tc.setConnectionState(false, false)\n\t\tif c.closeCh != nil {\n\t\t\tclose(c.closeCh)\n\t\t}\n\t\tcloseSignalChan(c.writeStop)\n\t\tcloseSignalChan(c.getDoneChan())\n\t\tif c.Interrupt != nil {\n\t\t\tsignal.Stop(c.Interrupt)\n\t\t}\n\t\tc.closeConn()\n\t})\n\tc.loopWG.Wait()\n}\n\nfunc (c *WsConn) setConnectionState(connected, connecting bool) {\n\tc.stateMu.Lock()\n\tc.Connected = connected\n\tc.Connecting = connecting\n\tc.stateMu.Unlock()\n}\n\nfunc (c *WsConn) setConnected(v bool) {\n\tc.stateMu.Lock()\n\tc.Connected = v\n\tc.stateMu.Unlock()\n}\n\nfunc (c *WsConn) setConnecting(v bool) {\n\tc.stateMu.Lock()\n\tc.Connecting = v\n\tc.stateMu.Unlock()\n}\n\nfunc (c *WsConn) IsConnected() bool {\n\tc.stateMu.RLock()\n\tdefer c.stateMu.RUnlock()\n\treturn c.Connected\n}\n\nfunc (c *WsConn) IsConnecting() bool {\n\tc.stateMu.RLock()\n\tdefer c.stateMu.RUnlock()\n\treturn c.Connecting\n}\n\nfunc (c *WsConn) startReconnecting() bool {\n\tif c.isClosed() {\n\t\treturn false\n\t}\n\tc.stateMu.Lock()\n\tdefer c.stateMu.Unlock()\n\tif c.Connected || c.Connecting {\n\t\treturn false\n\t}\n\tc.Connecting = true\n\treturn true\n}\n\nfunc (c *WsConn) keepAlive() {\n\tpingTicker := time.NewTicker(54 * time.Second)\n\tdefer pingTicker.Stop()\n\treconnectTicker := time.NewTicker(200 * time.Millisecond)\n\tdefer reconnectTicker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.closeCh:\n\t\t\treturn\n\t\tcase <-pingTicker.C:\n\t\t\tif !c.IsConnected() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := c.enqueueWrite(wsWriteJob{\n\t\t\t\tmsgType: websocket.TextMessage,\n\t\t\t\tdata:    []byte(\"ping\"),\n\t\t\t}); err != nil {\n\t\t\t\tlog.Println(err)\n\t\t\t\tc.setConnected(false)\n\t\t\t}\n\t\tcase <-reconnectTicker.C:\n\t\t\tif c.startReconnecting() {\n\t\t\t\tc.recreateWsConn()\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (c *WsConn) messageReceiveHandler() {\n\tdone := c.getDoneChan()\n\tdefer closeSignalChan(done)\n\tfor {\n\t\tselect {\n\t\tcase <-c.closeCh:\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\t\tif c.IsConnected() {\n\t\t\tconn := c.getConn()\n\t\t\tif conn == nil {\n\t\t\t\tc.setConnected(false)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t_, msg, err := conn.ReadMessage()\n\t\t\tif err != nil {\n\t\t\t\t// 读取信息出错，连接已经意外断开\n\t\t\t\t// log.Println(err)\n\t\t\t\tc.setConnected(false)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif string(msg) != \"pong\" {\n\t\t\t\tselect {\n\t\t\t\tcase c.MsgReceiveCh <- string(msg):\n\t\t\t\tcase <-c.closeCh:\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 降低断线时期的 CPU 占用\n\t\t\tselect {\n\t\t\tcase <-c.closeCh:\n\t\t\t\treturn\n\t\t\tcase <-time.After(200 * time.Millisecond):\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc apiServerErrorMessage(ip string) string {\n\treturn `{\"ip\":\"` + ip + `\", \"asnumber\":\"API Server Error\"}`\n}\n\nfunc (c *WsConn) trySendReceiveMessage(msg string) {\n\tselect {\n\tcase c.MsgReceiveCh <- msg:\n\tcase <-c.closeCh:\n\tdefault:\n\t\tlog.Println(\"wshandle: dropping queued receive message\")\n\t}\n}\n\nfunc (c *WsConn) waitForNextDoneChan(doneCh chan struct{}) chan struct{} {\n\tfor {\n\t\tnewDone := c.getDoneChan()\n\t\tif newDone != nil && newDone != doneCh {\n\t\t\treturn newDone\n\t\t}\n\t\tselect {\n\t\tcase <-c.closeCh:\n\t\t\treturn nil\n\t\tcase <-time.After(50 * time.Millisecond):\n\t\t}\n\t}\n}\n\nfunc (c *WsConn) sendQueuedMessage(msg string) {\n\tif !c.IsConnected() {\n\t\tc.trySendReceiveMessage(apiServerErrorMessage(msg))\n\t\treturn\n\t}\n\n\tif err := c.enqueueWrite(wsWriteJob{\n\t\tmsgType: websocket.TextMessage,\n\t\tdata:    []byte(msg),\n\t}); err != nil {\n\t\tlog.Println(\"write:\", err)\n\t\tc.setConnected(false)\n\t\tc.trySendReceiveMessage(apiServerErrorMessage(msg))\n\t}\n}\n\nfunc (c *WsConn) handleInterrupt(doneCh chan struct{}) {\n\t_ = c.enqueueWrite(wsWriteJob{\n\t\tmsgType: websocket.CloseMessage,\n\t\tdata:    websocket.FormatCloseMessage(websocket.CloseNormalClosure, \"\"),\n\t})\n\n\tselect {\n\tcase <-doneCh:\n\tcase <-time.After(1 * time.Second):\n\t}\n}\n\nfunc (c *WsConn) messageSendHandler() {\n\tdoneCh := c.getDoneChan()\n\tfor {\n\t\tif current := c.getDoneChan(); current != nil && current != doneCh {\n\t\t\tdoneCh = current\n\t\t}\n\n\t\tselect {\n\t\tcase <-c.closeCh:\n\t\t\treturn\n\t\tcase <-doneCh:\n\t\t\tdoneCh = c.waitForNextDoneChan(doneCh)\n\t\t\tif doneCh == nil {\n\t\t\t\treturn\n\t\t\t}\n\t\tcase msg := <-c.MsgSendCh:\n\t\t\tc.sendQueuedMessage(msg)\n\t\tcase <-c.Interrupt:\n\t\t\tc.handleInterrupt(doneCh)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (c *WsConn) recreateWsConn() {\n\tif c.isClosed() {\n\t\treturn\n\t}\n\tc.setConnected(false)\n\t// 尝试重新连线\n\tif host != \"\" && net.ParseIP(host) == nil {\n\t\t// 刷新一次最优 IP，防止旧 IP 已失效\n\t\tfastIPCtx, cancelFastIP := deriveOperationContext(c.baseCtx, c.closeCh, 0)\n\t\trefreshedFastIP, err := wsGetFastIPFn(fastIPCtx, host, port, true)\n\t\tcancelFastIP()\n\t\tif err != nil {\n\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\tlog.Printf(\"fast ip refresh failed: %v\", err)\n\t\t\t}\n\t\t\tc.setConnectionState(false, false)\n\t\t\treturn\n\t\t}\n\t\tfastIp = refreshedFastIP\n\t}\n\tu := url.URL{Scheme: \"wss\", Host: formatHostPort(fastIp, port), Path: \"/v3/ipGeoWs\"}\n\t// log.Printf(\"connecting to %s\", u.String())\n\tjwtToken, ua := envToken, []string{\"Privileged Client\"}\n\terr := error(nil)\n\tif envToken == \"\" {\n\t\t// 无环境变量 token\n\t\tif cacheToken == \"\" {\n\t\t\t// 无cacheToken, 重新获取 token\n\t\t\ttokenCtx, cancelToken := deriveOperationContext(c.baseCtx, c.closeCh, 0)\n\t\t\tif util.GetPowProvider() == \"\" {\n\t\t\t\tjwtToken, err = wsGetTokenFn(tokenCtx, fastIp, host, port)\n\t\t\t} else {\n\t\t\t\tjwtToken, err = wsGetTokenFn(tokenCtx, util.GetPowProvider(), util.GetPowProvider(), port)\n\t\t\t}\n\t\t\tcancelToken()\n\t\t\tif err != nil {\n\t\t\t\tif util.EnvDevMode {\n\t\t\t\t\tpanic(err)\n\t\t\t\t}\n\t\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\t\tlog.Printf(\"pow token fetch failed: %v\", err)\n\t\t\t\t}\n\t\t\t\tcacheToken = \"\"\n\t\t\t\tcacheTokenFailedTimes++\n\t\t\t\tc.setConnectionState(false, false)\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\t// 使用 cacheToken\n\t\t\tjwtToken = cacheToken\n\t\t}\n\t\tua = []string{util.UserAgent}\n\t}\n\tcacheToken = jwtToken\n\trequestHeader := http.Header{\n\t\t\"Host\":          []string{host},\n\t\t\"User-Agent\":    ua,\n\t\t\"Authorization\": []string{\"Bearer \" + jwtToken},\n\t}\n\tdialer := *websocket.DefaultDialer // 按值拷贝，变成独立实例\n\t// 现在 dialer 是一个新的 Dialer（值），内部字段与 DefaultDialer 相同\n\tdialer.TLSClientConfig = &tls.Config{\n\t\tServerName: host,\n\t}\n\tproxyUrl := util.GetProxy()\n\tif proxyUrl != nil {\n\t\tdialer.Proxy = http.ProxyURL(proxyUrl)\n\t}\n\tctx, cancel := deriveOperationContext(c.baseCtx, c.closeCh, wsClientDialTimeout)\n\tws, _, err := dialer.DialContext(ctx, u.String(), requestHeader)\n\tcancel()\n\tif c.isClosed() {\n\t\tif ws != nil {\n\t\t\t_ = ws.Close()\n\t\t}\n\t\treturn\n\t}\n\tif err != nil {\n\t\tlog.Println(\"dial:\", err)\n\t\t// <-time.After(time.Second * 1)\n\t\tc.setConnectionState(false, false)\n\t\tcacheTokenFailedTimes += 1\n\t\ttime.Sleep(1 * time.Second)\n\t\t//fmt.Println(\"重连失败\", cacheTokenFailedTimes, \"次\")\n\t\treturn\n\t}\n\tc.replaceConn(ws)\n\tc.setConnectionState(true, false)\n\n\tc.setDoneChan(make(chan struct{}))\n\tc.startLoop(c.messageReceiveHandler)\n}\n\nfunc createWsConn(ctx context.Context) *WsConn {\n\tproxyUrl := util.GetProxy()\n\t//fmt.Println(\"正在连接 WS\")\n\t// 设置终端中断通道\n\tinterrupt := make(chan os.Signal, 1)\n\tsignal.Notify(interrupt, os.Interrupt)\n\tctx = normalizeContext(ctx)\n\thost, port = util.GetHostAndPort()\n\t// 如果 host 是一个 IP 使用默认域名\n\tif valid := net.ParseIP(host); valid != nil {\n\t\tfastIp = host\n\t\thost = \"api.nxtrace.org\"\n\t} else {\n\t\t// 默认配置完成，开始寻找最优 IP\n\t\trefreshedFastIP, err := wsGetFastIPFn(ctx, host, port, true)\n\t\tif err != nil {\n\t\t\tif util.EnvDevMode {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tlog.Printf(\"fast ip probe failed: %v\", err)\n\t\t\tws := newWsConn(nil, interrupt)\n\t\t\tws.baseCtx = ctx\n\t\t\tws.setDoneChan(make(chan struct{}))\n\t\t\tws.setConnectionState(false, false)\n\t\t\tws.startLoop(ws.keepAlive)\n\t\t\tws.startLoop(ws.messageSendHandler)\n\t\t\treturn ws\n\t\t}\n\t\tfastIp = refreshedFastIP\n\t}\n\tjwtToken, ua := envToken, []string{\"Privileged Client\"}\n\terr := error(nil)\n\tif envToken == \"\" {\n\t\tif util.GetPowProvider() == \"\" {\n\t\t\tjwtToken, err = wsGetTokenFn(ctx, fastIp, host, port)\n\t\t} else {\n\t\t\tjwtToken, err = wsGetTokenFn(ctx, util.GetPowProvider(), util.GetPowProvider(), port)\n\t\t}\n\t\tif err != nil {\n\t\t\tif util.EnvDevMode {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tlog.Printf(\"pow token fetch failed: %v\", err)\n\t\t\tws := newWsConn(nil, interrupt)\n\t\t\tws.setDoneChan(make(chan struct{}))\n\t\t\tws.setConnectionState(false, false)\n\t\t\tws.startLoop(ws.keepAlive)\n\t\t\tws.startLoop(ws.messageSendHandler)\n\t\t\treturn ws\n\t\t}\n\t\tua = []string{util.UserAgent}\n\t}\n\tcacheToken = jwtToken\n\tcacheTokenFailedTimes = 0\n\trequestHeader := http.Header{\n\t\t\"Host\":          []string{host},\n\t\t\"User-Agent\":    ua,\n\t\t\"Authorization\": []string{\"Bearer \" + jwtToken},\n\t}\n\tdialer := *websocket.DefaultDialer // 按值拷贝，变成独立实例\n\t// 现在 dialer 是一个新的 Dialer（值），内部字段与 DefaultDialer 相同\n\tdialer.TLSClientConfig = &tls.Config{\n\t\tServerName: host,\n\t}\n\tif proxyUrl != nil {\n\t\tdialer.Proxy = http.ProxyURL(proxyUrl)\n\t}\n\tu := url.URL{Scheme: \"wss\", Host: formatHostPort(fastIp, port), Path: \"/v3/ipGeoWs\"}\n\t// log.Printf(\"connecting to %s\", u.String())\n\n\tdialCtx, cancel := deriveOperationContext(ctx, nil, wsClientDialTimeout)\n\tc, _, err := dialer.DialContext(dialCtx, u.String(), requestHeader)\n\tcancel()\n\n\tws := newWsConn(c, interrupt)\n\tws.baseCtx = ctx\n\tws.setConnectionState(err == nil, false)\n\n\tif err != nil {\n\t\tlog.Println(\"dial:\", err)\n\t\t// <-time.After(time.Second * 1)\n\t\tcacheTokenFailedTimes++\n\t\tws.setDoneChan(make(chan struct{}))\n\t\tws.startLoop(ws.keepAlive)\n\t\tws.startLoop(ws.messageSendHandler)\n\t\treturn ws\n\t}\n\t// defer c.Close()\n\t// 将连接写入WsConn，方便随时可取\n\tws.setDoneChan(make(chan struct{}))\n\tws.startLoop(ws.keepAlive)\n\tws.startLoop(ws.messageReceiveHandler)\n\tws.startLoop(ws.messageSendHandler)\n\treturn ws\n}\n\nfunc NewWithContext(ctx context.Context) *WsConn {\n\twsconnNewMu.Lock()\n\tdefer wsconnNewMu.Unlock()\n\n\tnewConn := createWsConnFn(ctx)\n\tif newConn != nil {\n\t\tnewConn.baseCtx = normalizeContext(ctx)\n\t}\n\n\twsconnMu.Lock()\n\toldConn := wsconn\n\twsconn = newConn\n\twsconnMu.Unlock()\n\n\tif oldConn != nil && oldConn != newConn {\n\t\toldConn.Close()\n\t}\n\treturn newConn\n}\n\nfunc New() *WsConn {\n\treturn NewWithContext(context.Background())\n}\n\nfunc GetWsConn() *WsConn {\n\twsconnMu.RLock()\n\tdefer wsconnMu.RUnlock()\n\treturn wsconn\n}\n"
  },
  {
    "path": "wshandle/client_test.go",
    "content": "package wshandle\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\nfunc newStartedTestWsConn() *WsConn {\n\tc := newWsConn(nil, make(chan os.Signal, 1))\n\tc.setDoneChan(make(chan struct{}))\n\tc.setConnectionState(false, false)\n\tc.startLoop(c.keepAlive)\n\tc.startLoop(c.messageSendHandler)\n\treturn c\n}\n\nfunc saveAndRestoreGlobalWsConn(t *testing.T) {\n\tt.Helper()\n\n\twsconnMu.RLock()\n\toldWsconn := wsconn\n\twsconnMu.RUnlock()\n\n\tt.Cleanup(func() {\n\t\twsconnMu.Lock()\n\t\tcurrent := wsconn\n\t\twsconn = oldWsconn\n\t\twsconnMu.Unlock()\n\t\tif current != nil && current != oldWsconn {\n\t\t\tcurrent.Close()\n\t\t}\n\t})\n}\n\nfunc TestWsConnCloseStopsBackgroundLoops(t *testing.T) {\n\tconn := newStartedTestWsConn()\n\tdoneCh := conn.getDoneChan()\n\n\tconn.Close()\n\tconn.Close()\n\n\tif !conn.isClosed() {\n\t\tt.Fatal(\"connection should be marked closed\")\n\t}\n\tif err := conn.enqueueWrite(wsWriteJob{msgType: websocket.TextMessage, data: []byte(\"ping\")}); !errors.Is(err, errWriteLoopStopped) {\n\t\tt.Fatalf(\"enqueueWrite error=%v, want %v\", err, errWriteLoopStopped)\n\t}\n\tselect {\n\tcase <-doneCh:\n\tdefault:\n\t\tt.Fatal(\"done channel should be closed by Close\")\n\t}\n}\n\nfunc TestNewClosesPreviousGlobalWsConn(t *testing.T) {\n\toldCreateFn := createWsConnFn\n\tdefer func() {\n\t\tcreateWsConnFn = oldCreateFn\n\t}()\n\tsaveAndRestoreGlobalWsConn(t)\n\n\toldConn := newStartedTestWsConn()\n\twsconnMu.Lock()\n\twsconn = oldConn\n\twsconnMu.Unlock()\n\n\tcreateWsConnFn = func(context.Context) *WsConn {\n\t\treturn newStartedTestWsConn()\n\t}\n\n\tnewConn := New()\n\tdefer newConn.Close()\n\n\tif newConn == oldConn {\n\t\tt.Fatal(\"New should replace the previous global connection\")\n\t}\n\tif GetWsConn() != newConn {\n\t\tt.Fatal(\"GetWsConn should return the replacement connection\")\n\t}\n\tif !oldConn.isClosed() {\n\t\tt.Fatal(\"previous global connection should be closed before replacement\")\n\t}\n\tif err := oldConn.enqueueWrite(wsWriteJob{msgType: websocket.TextMessage, data: []byte(\"ping\")}); !errors.Is(err, errWriteLoopStopped) {\n\t\tt.Fatalf(\"old enqueueWrite error=%v, want %v\", err, errWriteLoopStopped)\n\t}\n}\n\nfunc TestGetWsConnDoesNotBlockWhileNewClosesPreviousConn(t *testing.T) {\n\toldCreateFn := createWsConnFn\n\tdefer func() {\n\t\tcreateWsConnFn = oldCreateFn\n\t}()\n\tsaveAndRestoreGlobalWsConn(t)\n\n\trelease := make(chan struct{})\n\toldConn := newWsConn(nil, make(chan os.Signal, 1))\n\toldConn.setDoneChan(make(chan struct{}))\n\toldConn.startLoop(func() {\n\t\t<-release\n\t})\n\n\twsconnMu.Lock()\n\twsconn = oldConn\n\twsconnMu.Unlock()\n\n\tnewConn := newStartedTestWsConn()\n\tdefer newConn.Close()\n\tcreateWsConnFn = func(context.Context) *WsConn {\n\t\treturn newConn\n\t}\n\n\tnewResult := make(chan *WsConn, 1)\n\tgo func() {\n\t\tnewResult <- New()\n\t}()\n\n\tselect {\n\tcase <-oldConn.closeCh:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"New did not start closing the previous connection\")\n\t}\n\n\tgetResult := make(chan *WsConn, 1)\n\tgo func() {\n\t\tgetResult <- GetWsConn()\n\t}()\n\n\tselect {\n\tcase got := <-getResult:\n\t\tif got != newConn {\n\t\t\tt.Fatalf(\"GetWsConn returned %p, want %p\", got, newConn)\n\t\t}\n\tcase <-time.After(200 * time.Millisecond):\n\t\tt.Fatal(\"GetWsConn blocked while New was waiting for old Close\")\n\t}\n\n\tclose(release)\n\n\tselect {\n\tcase got := <-newResult:\n\t\tif got != newConn {\n\t\t\tt.Fatalf(\"New returned %p, want %p\", got, newConn)\n\t\t}\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"New did not finish after releasing old Close\")\n\t}\n}\n\nfunc TestSendQueuedMessageDoesNotBlockWhenDisconnectedAndReceiveQueueIsUnavailable(t *testing.T) {\n\tconn := newWsConn(nil, make(chan os.Signal, 1))\n\tdefer conn.Close()\n\tconn.MsgReceiveCh = make(chan string)\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tconn.sendQueuedMessage(\"1.1.1.1\")\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(200 * time.Millisecond):\n\t\tt.Fatal(\"sendQueuedMessage blocked while disconnected\")\n\t}\n}\n\nfunc TestSendQueuedMessageDoesNotBlockWhenEnqueueWriteFails(t *testing.T) {\n\tconn := newWsConn(nil, make(chan os.Signal, 1))\n\tdefer conn.Close()\n\tconn.MsgReceiveCh = make(chan string)\n\tconn.setConnectionState(true, false)\n\n\tconn.lifecycleMu.Lock()\n\tconn.closed = true\n\tconn.lifecycleMu.Unlock()\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tconn.sendQueuedMessage(\"1.1.1.1\")\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(200 * time.Millisecond):\n\t\tt.Fatal(\"sendQueuedMessage blocked after enqueueWrite failure\")\n\t}\n}\n\nfunc TestMessageReceiveHandlerCloseRaceDoesNotPanic(t *testing.T) {\n\tfor i := 0; i < 50; i++ {\n\t\tconn := newWsConn(nil, make(chan os.Signal, 1))\n\t\tconn.setDoneChan(make(chan struct{}))\n\t\tconn.setConnectionState(false, false)\n\n\t\tstarted := make(chan struct{})\n\t\tconn.startLoop(func() {\n\t\t\tclose(started)\n\t\t\tconn.messageReceiveHandler()\n\t\t})\n\n\t\t<-started\n\n\t\tdone := make(chan struct{})\n\t\tgo func() {\n\t\t\tconn.Close()\n\t\t\tclose(done)\n\t\t}()\n\n\t\tselect {\n\t\tcase <-done:\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"Close hung while messageReceiveHandler was exiting\")\n\t\t}\n\t}\n}\n\nfunc TestCreateWsConnHonorsCanceledContextDuringFastIP(t *testing.T) {\n\toldFastIPFn := wsGetFastIPFn\n\tdefer func() { wsGetFastIPFn = oldFastIPFn }()\n\n\tstarted := make(chan struct{})\n\twsGetFastIPFn = func(ctx context.Context, domain string, port string, enableOutput bool) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan *WsConn, 1)\n\tgo func() {\n\t\tdone <- createWsConn(ctx)\n\t}()\n\n\t<-started\n\tcancel()\n\n\tselect {\n\tcase conn := <-done:\n\t\tif conn == nil {\n\t\t\tt.Fatal(\"createWsConn returned nil\")\n\t\t}\n\t\tdefer conn.Close()\n\t\tif conn.IsConnected() {\n\t\t\tt.Fatal(\"connection should not be connected after canceled startup\")\n\t\t}\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"createWsConn did not return promptly after cancel\")\n\t}\n}\n\nfunc TestRecreateWsConnCloseCancelsFastIP(t *testing.T) {\n\toldFastIPFn := wsGetFastIPFn\n\toldHost, oldPort := host, port\n\tdefer func() {\n\t\twsGetFastIPFn = oldFastIPFn\n\t\thost, port = oldHost, oldPort\n\t}()\n\n\tstarted := make(chan struct{})\n\twsGetFastIPFn = func(ctx context.Context, domain string, port string, enableOutput bool) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t}\n\n\thost = \"example.com\"\n\tport = \"443\"\n\n\tconn := newStartedTestWsConn()\n\tdefer conn.Close()\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tconn.recreateWsConn()\n\t\tclose(done)\n\t}()\n\n\t<-started\n\tconn.Close()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"recreateWsConn did not stop promptly after Close\")\n\t}\n}\n"
  }
]