├── .gitignore ├── assets └── stunner.gif ├── .github └── workflows │ └── release.yml ├── .goreleaser.yml ├── LICENSE ├── go.mod ├── README.md ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /assets/stunner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaxxstorm/stunner/HEAD/assets/stunner.gif -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | permissions: 2 | contents: write 3 | 4 | name: release 5 | on: 6 | push: 7 | tags: 8 | - v*.*.* 9 | - '!v*.*.*-**' 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Unshallow clone 18 | run: git fetch --prune --unshallow 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.23.x' 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v3 25 | with: 26 | args: release --clean 27 | version: latest 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: stunner 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - darwin 13 | - windows 14 | - linux 15 | goarch: 16 | - amd64 17 | - arm64 18 | ldflags: 19 | - "-X main.Version={{.Version}}" 20 | 21 | archives: 22 | - id: stunner 23 | 24 | format: tar.gz 25 | builds: 26 | - stunner 27 | name_template: "{{ .Binary }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}" 28 | format_overrides: 29 | - goos: windows 30 | format: zip 31 | 32 | changelog: 33 | sort: asc 34 | filters: 35 | exclude: 36 | - "^docs:" 37 | - "^test:" 38 | 39 | brews: 40 | - name: stunner 41 | repository: 42 | owner: jaxxstorm 43 | name: homebrew-tap 44 | commit_author: 45 | name: GitHub Actions 46 | email: bot@leebriggs.co.uk 47 | directory: Formula 48 | homepage: "https://leebriggs.co.uk" 49 | description: "A CLI tool to detect NAT type." -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 lbrlabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jaxxstorm/stunner 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/alecthomas/kong v1.8.1 7 | github.com/jackpal/gateway v1.0.16 8 | github.com/olekukonko/tablewriter v0.0.5 9 | go.uber.org/zap v1.27.0 10 | tailscale.com v1.80.3 11 | ) 12 | 13 | require ( 14 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 17 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 18 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 19 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 20 | github.com/charmbracelet/x/term v0.2.1 // indirect 21 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 23 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 24 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect 25 | github.com/google/go-cmp v0.6.0 // indirect 26 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 27 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 28 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/mattn/go-runewidth v0.0.16 // indirect 31 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 32 | github.com/mdlayher/socket v0.5.0 // indirect 33 | github.com/muesli/termenv v0.16.0 // indirect 34 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 35 | github.com/rivo/uniseg v0.4.7 // indirect 36 | github.com/stretchr/objx v0.5.2 // indirect 37 | github.com/stretchr/testify v1.10.0 // indirect 38 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 39 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 40 | github.com/vishvananda/netns v0.0.4 // indirect 41 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 42 | go.uber.org/multierr v1.11.0 // indirect 43 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 44 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 45 | golang.org/x/crypto v0.33.0 // indirect 46 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect 47 | golang.org/x/net v0.35.0 // indirect 48 | golang.org/x/sync v0.11.0 // indirect 49 | golang.org/x/sys v0.30.0 // indirect 50 | golang.org/x/text v0.22.0 // indirect 51 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 52 | gopkg.in/yaml.v3 v3.0.1 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stunner 2 | 3 | **Stunner** is a small Go CLI tool that sends STUN Binding Requests to multiple Tailscale DERP servers (or any STUN servers you specify) and reports the resulting NAT classification. This helps you determine whether you're behind a **Full Cone**, **Symmetric** NAT, **Restricted**, or otherwise, by analyzing how multiple STUN servers perceive your external IP/port mapping. 4 | 5 | ![Stunner](assets/stunner.gif) 6 | 7 | ## Features 8 | 9 | - **Multi-Server STUN**: Queries more than one server (ideally two or more) to detect NAT type accurately. 10 | - **Dynamic DERP Fetching**: If no STUN servers are specified, Stunner can fetch the Tailscale DERP map from `https://login.tailscale.com/derpmap/default` and pick two random servers automatically. 11 | - **NAT Classification**: Provides an overall NAT result, labeling it “Open Internet,” “Full Cone,” “Symmetric NAT,” etc., plus an “Easy” or “Hard” rating for hole punching. 12 | - **Verbose Debug Logging**: An optional `--debug` flag emits debug logs akin to `pystun3`, letting you trace each request/response. 13 | - **Tabular Output**: Results are displayed in tables for easy reading, e.g.: 14 | 15 | ```bash 16 | +----------------------------+-------+-----------+---------+ 17 | | STUN SERVER | PORT | IP | MAPPING | 18 | +----------------------------+-------+-----------+---------+ 19 | | derp3d.tailscale.com:3478 | 62236 | | UPnP | 20 | | derp13b.tailscale.com:3478 | 62236 | | UPnP | 21 | +----------------------------+-------+-----------+---------+ 22 | +--------+------------------------------+-----------+-------------------------------+ 23 | | RESULT | NAT TYPE | EASY/HARD | DETAIL | 24 | +--------+------------------------------+-----------+-------------------------------+ 25 | | Final | Endpoint-Independent Mapping | Easy | Endpoint-Independent Mapping. | 26 | +--------+------------------------------+-----------+-------------------------------+ 27 | ``` 28 | 29 | ## Installation 30 | 31 | ### OS X 32 | 33 | Install from homebrew: 34 | 35 | ```bash 36 | brew install jaxxstorm/tap/stunner 37 | ``` 38 | 39 | ### Linux 40 | 41 | Download the binary from releases 42 | 43 | ```bash 44 | VERSION=v0.0.10 45 | curl -L "https://github.com/jaxxstorm/stunner/releases/download/${VERSION}/stunner-${VERSION}-linux-amd64.tar.gz" | tar -xz 46 | ./stunner --version 47 | ``` 48 | 49 | ### Using Go 50 | 51 | If you have go installed you can build and install with 52 | 53 | ```bash 54 | go install github.com/jaxxstorm/stunner@latest 55 | ``` 56 | 57 | The resulting binary will appear inside `$GOPATH/bin` which you may want in your `PATH`. 58 | 59 | You can run `go env | grep "GOPATH"` to double check where go considers the `GOPATH` to be. 60 | 61 | ## Usage 62 | 63 | ```bash 64 | Usage: stunner [flags] 65 | 66 | Flags: 67 | -h, --help Show context-sensitive help. 68 | --stun-server=STUN-SERVER,... STUN servers to use for detection 69 | --stun-port=3478 STUN port to use for detection 70 | --source-ip="0.0.0.0" Local IP to bind 71 | --source-port=INT Local port to bind 72 | --debug Enable debug logging 73 | --software="tailnode" Software to send for STUN request 74 | --derp-map-url="https://login.tailscale.com/derpmap/default" URL to fetch DERP map from 75 | --version Show version 76 | ``` 77 | 78 | ### Common Flags 79 | 80 | - **`--stun-server `**: Provide one or more STUN servers manually. If you omit this, Stunner fetches two random DERP servers from Tailscale’s map by default. 81 | - **`--stun-port `**: Port used for each STUN server (default 3478). 82 | - **`--source-ip `** / **`--source-port `**: Local interface IP and port to bind the UDP socket (defaults to `0.0.0.0:54320`). 83 | - **`--debug`**: Enable debug logging, showing STUN transactions and responses. 84 | - **`--software `**: Customize the SOFTWARE attribute in your STUN Binding Request (defaults to `tailnode`). 85 | - **`--derpmapurl `**: Where to fetch the DERP map JSON if no STUN servers are specified (defaults to `https://login.tailscale.com/derpmap/default`). 86 | 87 | ### Example 88 | 89 | ```bash 90 | # Let Stunner fetch DERP servers automatically: 91 | ./stunner --debug 92 | 93 | # Supply your own STUN servers: 94 | ./stunner --stun-server=stun1.l.google.com --stun-server=stun2.l.google.com 95 | ``` 96 | 97 | Stunner will: 98 | 99 | 1. **Bind** a local UDP socket on the IP/port you specify. 100 | 2. **Send** STUN Binding Requests to each server. 101 | 3. **Parse** the responses, capturing external IP/port. 102 | 4. **Compare** the external ports from each server to classify your NAT as Full Cone, Symmetric, etc. 103 | 5. Print results in a **table**. 104 | 105 | ## Understanding the Output 106 | 107 | - **STUN Server**: The server used for the test. 108 | - **Port/IP**: The external port/IP you were mapped to when contacting that server. 109 | - **NAT Type**: The classification for a single test. The “Final” row merges the results from all servers and picks the best guess. 110 | - **Easy/Hard**: Whether the NAT is relatively easy (Full Cone / Restricted) or hard (Symmetric) to penetrate with hole punching. 111 | 112 | ## Notes & Limitations 113 | 114 | - **Experimental**: This is a proof-of-concept. It borrows STUN logic reminiscent of older STUN NAT detection flows and Tailscale’s idea of multi-server tests. 115 | - **Interpretation**: Real NAT environments can be complex. Stunner’s classification is a best effort. Some NAT/firewall behaviors can break these tests. 116 | - **Dependencies**: Go 1.18+ recommended. If building from source, ensure your environment supports modules. 117 | 118 | ## License 119 | 120 | MIT 121 | 122 | --- 123 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 2 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/kong v1.8.1 h1:6aamvWBE/REnR/BCq10EcozmcpUPc5aGI1lPAWdB0EE= 4 | github.com/alecthomas/kong v1.8.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 8 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 12 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 16 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 17 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 18 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 20 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 21 | github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= 22 | github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= 23 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 24 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 25 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= 28 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 29 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 30 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 31 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84= 32 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= 33 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 34 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 35 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 36 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 37 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 38 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 39 | github.com/jackpal/gateway v1.0.16 h1:mTBRuHSW8qviVqX7kXnxKevqlfS/OA01ys6k6fxSX7w= 40 | github.com/jackpal/gateway v1.0.16/go.mod h1:IOn1OUbso/cGYmnCBZbCEqhNCLSz0xxdtIpUpri5/nA= 41 | github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= 42 | github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 43 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 44 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 45 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 46 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 47 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 48 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 49 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 50 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 51 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 52 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 53 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 54 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 55 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 56 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 57 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= 58 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 59 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 60 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 61 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 62 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 63 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 64 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 65 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 67 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 68 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 69 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 70 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 71 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 72 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 73 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 74 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 75 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 76 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 77 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 78 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 79 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 80 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 81 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 82 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 83 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 84 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 85 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 86 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 87 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 88 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 89 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 90 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 91 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 92 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 93 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 94 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 95 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 96 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 97 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 98 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= 99 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= 100 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 101 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 102 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 103 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 104 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 105 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 106 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 107 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 111 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 112 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 113 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 114 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 115 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 117 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 118 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 119 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 120 | tailscale.com v1.80.3 h1:uGLWZdl61YbhvhoU6qdnHPF7zuuqGGRaTfbECur035Y= 121 | tailscale.com v1.80.3/go.mod h1:HTOFVeo5RY0qBl5Uy+LXHwgp0PLXgVSfgqWI34gSrPA= 122 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/binary" 7 | "encoding/hex" 8 | "encoding/json" 9 | "fmt" 10 | "hash/crc32" 11 | "io" 12 | math "math/rand" 13 | "net" 14 | "net/http" 15 | "strings" 16 | "time" 17 | 18 | "tailscale.com/net/portmapper" 19 | 20 | "github.com/jackpal/gateway" 21 | 22 | "github.com/alecthomas/kong" 23 | "github.com/charmbracelet/lipgloss" 24 | "go.uber.org/zap" 25 | "go.uber.org/zap/zapcore" 26 | "tailscale.com/net/netmon" 27 | ) 28 | 29 | // NAT types 30 | const ( 31 | Blocked = "UDP Blocked" 32 | OpenInternet = "No NAT" 33 | EndpointIndependentMapping = "Endpoint-Independent Mapping" 34 | AddressDependentFiltering = "Address-Dependent Filtering" 35 | AddressDependentMapping = "Address-Dependent Mapping" 36 | AddressAndPortDependentMapping = "Address and Port-Dependent Mapping" 37 | RestrictedConeNAT = "Restricted Cone NAT" 38 | PortRestrictedConeNAT = "Port-Restricted Cone NAT" 39 | ChangedAddressError = "ChangedAddressError" 40 | ) 41 | 42 | var Version = "dev" 43 | 44 | type RetVal struct { 45 | Resp bool // did we get a response? 46 | ExternalIP string // what IP did the STUN server see 47 | ExternalPort int // what port did the STUN server see 48 | SourceIP string // what IP did we bind to 49 | SourcePort int // what port did we bind to 50 | ChangedIP string // what IP did the STUN server see after we sent a change request 51 | ChangedPort int // what port did the STUN server see after we sent a change request 52 | } 53 | 54 | type CLIFlags struct { 55 | STUNServers []string `help:"STUN servers to use for detection" name:"stun-server" short:"s"` 56 | STUNPort int `help:"STUN port to use for detection" default:"3478" short:"p"` 57 | SourceIP string `help:"Local IP to bind" default:"0.0.0.0" short:"i"` 58 | SourcePort int `help:"Local port to bind" short:"P"` 59 | Debug bool `help:"Enable debug logging" default:"false" short:"d"` 60 | Software string `help:"Software to send for STUN request" default:"tailnode" short:"S"` 61 | DerpMapUrl string `help:"URL to fetch DERP map from" name:"derp-map-url" default:"https://login.tailscale.com/derpmap/default"` 62 | Version bool `help:"Show version"` 63 | NoIP bool `help:"Omit IP addresses in output" default:"false" short:"o"` 64 | } 65 | 66 | var CLI CLIFlags 67 | var logger *zap.SugaredLogger 68 | 69 | var ( 70 | bindingRequestType = []byte{0x00, 0x01} 71 | magicCookie = []byte{0x21, 0x12, 0xA4, 0x42} // defined by RFC 5389 72 | ) 73 | 74 | const ( 75 | attrSoftware = 0x8022 // STUN attribute for software 76 | attrFingerprint = 0x8028 // STUN attribute for fingerprint 77 | ) 78 | 79 | type TxID [12]byte 80 | 81 | func main() { 82 | math.New(math.NewSource(time.Now().UnixNano())) 83 | 84 | var CLI CLIFlags 85 | kctx := kong.Parse(&CLI, 86 | kong.Name("stunner"), 87 | kong.Description("A CLI tool to check your NAT Type"), 88 | kong.Vars{"version": Version}, 89 | ) 90 | 91 | if CLI.Version { 92 | fmt.Printf("stunner %s\n", Version) 93 | kctx.Exit(0) 94 | } 95 | initZapLogger(CLI.Debug) 96 | defer logger.Sync() 97 | 98 | var stunServers []string 99 | var err error 100 | 101 | if CLI.STUNServers == nil { 102 | logger.Debug("Selecting DERP servers from Derp URL: ", CLI.DerpMapUrl) 103 | stunServers, err = getStunServers(CLI.DerpMapUrl, CLI.STUNPort) 104 | if err != nil { 105 | logger.Fatal("error fetching DERP map: ", err) 106 | } 107 | logger.Debug("Note: DERP servers may not fully support STUN CHANGE-REQUEST attributes, which can affect NAT detection accuracy") 108 | } else { 109 | for _, s := range CLI.STUNServers { 110 | s = fmt.Sprintf("%s:%d", s, CLI.STUNPort) 111 | stunServers = append(stunServers, s) 112 | } 113 | } 114 | 115 | if len(stunServers) < 2 { 116 | logger.Fatal("At least two --stun-server arguments are required to reliably detect NAT types.") 117 | } 118 | 119 | var sourcePort int 120 | if CLI.SourcePort == 0 { 121 | sourcePort = randomPort() 122 | } else { 123 | sourcePort = CLI.SourcePort 124 | } 125 | 126 | results, finalNAT, _, _ := multiServerDetection(stunServers, CLI.SourceIP, sourcePort, CLI.Software) 127 | 128 | mappingProtocol := probePortmapAvailability() 129 | 130 | for i := range results { 131 | results[i].MappingProtocol = mappingProtocol 132 | } 133 | 134 | printTables(results, finalNAT, CLI.NoIP) 135 | kctx.Exit(0) 136 | } 137 | 138 | // generate a random port in the range 49152-65535 139 | func randomPort() int { 140 | return 49152 + math.Intn(16384) 141 | } 142 | 143 | // derpMap is the JSON structure returned by https://login.tailscale.com/derpmap/default. 144 | type derpMap struct { 145 | Regions map[string]struct { 146 | Nodes []struct { 147 | HostName string `json:"HostName"` 148 | } `json:"Nodes"` 149 | } `json:"Regions"` 150 | } 151 | 152 | func getStunServers(derpMapURL string, port int) ([]string, error) { 153 | resp, err := http.Get(derpMapURL) 154 | if err != nil { 155 | return nil, fmt.Errorf("fetching DERP map: %w", err) 156 | } 157 | defer resp.Body.Close() 158 | 159 | if resp.StatusCode != http.StatusOK { 160 | return nil, fmt.Errorf("unexpected HTTP status %d from DERP map", resp.StatusCode) 161 | } 162 | 163 | body, err := io.ReadAll(resp.Body) 164 | if err != nil { 165 | return nil, fmt.Errorf("reading DERP map response: %w", err) 166 | } 167 | 168 | var dm derpMap 169 | if err := json.Unmarshal(body, &dm); err != nil { 170 | return nil, fmt.Errorf("decoding DERP map JSON: %w", err) 171 | } 172 | 173 | var all []string 174 | for _, region := range dm.Regions { 175 | for _, node := range region.Nodes { 176 | if node.HostName != "" { 177 | all = append(all, node.HostName) 178 | } 179 | } 180 | } 181 | if len(all) < 2 { 182 | return nil, fmt.Errorf("found only %d DERP servers in map, need at least 2", len(all)) 183 | } 184 | math.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] }) 185 | 186 | for i := range all { 187 | all[i] = fmt.Sprintf("%s:%d", all[i], port) 188 | } 189 | logger.Debug("Using DERP servers: ", all[:2]) 190 | return all[:2], nil 191 | } 192 | 193 | func initZapLogger(debug bool) { 194 | cfg := zap.NewDevelopmentConfig() 195 | if debug { 196 | cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel) 197 | } else { 198 | cfg.Level = zap.NewAtomicLevelAt(zap.InfoLevel) 199 | } 200 | cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 201 | cfg.DisableCaller = true 202 | cfg.DisableStacktrace = true 203 | logr, err := cfg.Build() 204 | if err != nil { 205 | panic(err) 206 | } 207 | logger = logr.Sugar() 208 | } 209 | 210 | type PerServerResult struct { 211 | Server string 212 | NATType string 213 | ExternalIP string 214 | ExternalPort int 215 | MappingProtocol string 216 | } 217 | 218 | func multiServerDetection(servers []string, sourceIP string, sourcePort int, software string) ([]PerServerResult, string, string, int) { 219 | 220 | // bind to a local UDP socket on the specified source port 221 | sock, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP(sourceIP), Port: sourcePort}) 222 | if err != nil { 223 | logger.Debugf("Bind error: %v", err) 224 | return nil, Blocked, "", 0 // we can't bind, so we assume blocked 225 | } 226 | defer sock.Close() 227 | 228 | _ = sock.SetDeadline(time.Now().Add(5 * time.Minute)) // set a deadline for the socket 229 | var results []PerServerResult 230 | allPorts := make(map[int]bool) 231 | 232 | // loop through the servers and try discover the NAT type 233 | // NOTE: a single request doesn't give us everything we need, so see finalizeNAT for the final answer 234 | for _, srv := range servers { 235 | logger.Debugf("Do Test1 with server=%s", srv) 236 | natType, retVal := getNatType(sock, srv, software) 237 | 238 | logger.Debugf("Result after Test1 server=%s => NAT=%s, IP=%s, Port=%d", 239 | srv, natType, retVal.ExternalIP, retVal.ExternalPort) 240 | 241 | // We'll fill in MappingProtocol later, from probePortmapAvailability() 242 | results = append(results, PerServerResult{ 243 | Server: srv, 244 | NATType: natType, 245 | ExternalIP: retVal.ExternalIP, 246 | ExternalPort: retVal.ExternalPort, 247 | }) 248 | if retVal.ExternalPort != 0 { 249 | allPorts[retVal.ExternalPort] = true 250 | } 251 | } 252 | 253 | finalN, finalIP, finalPort := finalizeNAT(results, allPorts) 254 | return results, finalN, finalIP, finalPort 255 | } 256 | 257 | // look at the results we sent to the STUN servers and determine the NAT type 258 | func finalizeNAT(results []PerServerResult, ports map[int]bool) (string, string, int) { 259 | allBlocked := true 260 | for _, r := range results { 261 | if r.NATType != Blocked { 262 | allBlocked = false 263 | break 264 | } 265 | } 266 | if allBlocked { 267 | return Blocked, "", 0 268 | } 269 | 270 | // Key insight: If we get different external ports from different servers, 271 | // this is a strong indicator of Address-and-Port-Dependent mapping (Symmetric NAT) 272 | if len(ports) > 1 { 273 | logger.Debugf("Different external ports detected (%v) - indicating Address-and-Port-Dependent Mapping", ports) 274 | for _, r := range results { 275 | if r.ExternalIP != "" { 276 | return AddressAndPortDependentMapping, r.ExternalIP, r.ExternalPort 277 | } 278 | } 279 | return AddressAndPortDependentMapping, "", 0 280 | } 281 | 282 | // All servers report the same external port - this is good for P2P 283 | logger.Debugf("Same external port from all servers - indicating consistent mapping") 284 | 285 | // NAT RFC mappings 286 | priority := map[string]int{ 287 | OpenInternet: 8, 288 | EndpointIndependentMapping: 7, 289 | RestrictedConeNAT: 6, 290 | AddressDependentMapping: 5, 291 | PortRestrictedConeNAT: 4, 292 | AddressAndPortDependentMapping: 3, 293 | AddressDependentFiltering: 2, 294 | Blocked: 0, 295 | ChangedAddressError: 0, 296 | } 297 | bestType := Blocked 298 | bestScore := 0 299 | var bestIP string 300 | var bestPort int 301 | 302 | for _, r := range results { 303 | sc := priority[r.NATType] 304 | if sc > bestScore { 305 | bestScore = sc 306 | bestType = r.NATType 307 | bestIP = r.ExternalIP 308 | bestPort = r.ExternalPort 309 | } 310 | } 311 | 312 | logger.Debugf("Final NAT determination: %s (based on highest priority result)", bestType) 313 | return bestType, bestIP, bestPort 314 | } 315 | 316 | // getLocalIPForServer determines what local IP address would be used to reach a given server 317 | func getLocalIPForServer(server string) (string, error) { 318 | // Create a temporary connection to the server to see what local IP is used 319 | conn, err := net.Dial("udp", server) 320 | if err != nil { 321 | return "", err 322 | } 323 | defer conn.Close() 324 | 325 | localAddr := conn.LocalAddr().(*net.UDPAddr) 326 | return localAddr.IP.String(), nil 327 | } 328 | 329 | // isDerpServerLikely attempts to detect if we're talking to a DERP server 330 | // based on hostname patterns and response behavior 331 | func isDerpServerLikely(server string, originalResp, changeResp RetVal) bool { 332 | // Check for Tailscale DERP server hostname patterns 333 | if strings.Contains(server, "derp") && strings.Contains(server, "tailscale.com") { 334 | return true 335 | } 336 | 337 | // Check for other DERP-like patterns 338 | if strings.Contains(server, "derp") { 339 | return true 340 | } 341 | 342 | // Check for response patterns that suggest DERP server behavior: 343 | // - CHANGE-REQUEST returns identical response (suggests server ignoring flags) 344 | // - Missing or identical CHANGED-ADDRESS info 345 | if changeResp.Resp && originalResp.Resp { 346 | if changeResp.ExternalIP == originalResp.ExternalIP && 347 | changeResp.ExternalPort == originalResp.ExternalPort && 348 | changeResp.ChangedIP == originalResp.ChangedIP && 349 | changeResp.ChangedPort == originalResp.ChangedPort { 350 | return true 351 | } 352 | } 353 | 354 | return false 355 | } 356 | 357 | func getNatType(sock *net.UDPConn, server string, software string) (string, RetVal) { 358 | // Test I: Send a STUN Binding Request to the primary server 359 | ret := stunTest(sock, server, "", software) 360 | if !ret.Resp { 361 | return Blocked, ret 362 | } 363 | 364 | exIP, exPort := ret.ExternalIP, ret.ExternalPort 365 | chIP, chPort := ret.ChangedIP, ret.ChangedPort 366 | if exIP == "" { 367 | return Blocked, ret 368 | } 369 | 370 | // Get the actual local IP that would be used to reach this server 371 | actualLocalIP, err := getLocalIPForServer(server) 372 | if err != nil { 373 | logger.Debugf("Could not determine local IP for server %s: %v", server, err) 374 | // Fall back to socket's local address (which might be 0.0.0.0) 375 | localAddr := sock.LocalAddr().(*net.UDPAddr) 376 | actualLocalIP = localAddr.IP.String() 377 | } 378 | 379 | logger.Debugf("Comparing external IP %s with actual local IP %s", exIP, actualLocalIP) 380 | 381 | // Check if we're behind NAT by comparing external and local IPs 382 | if exIP == actualLocalIP { 383 | // We're not behind NAT - Test II: Send change request (change IP and port) 384 | isDerpServer := isDerpServerLikely(server, ret, RetVal{}) 385 | 386 | if isDerpServer { 387 | logger.Debugf("DERP server detected - skipping CHANGE-REQUEST tests as they're unreliable") 388 | return OpenInternet, ret 389 | } 390 | 391 | ret2 := stunTest(sock, server, "00000006", software) // Change IP and port 392 | if ret2.Resp { 393 | logger.Debugf("CHANGE-REQUEST test succeeded - confirmed No NAT") 394 | return OpenInternet, ret2 395 | } 396 | logger.Debugf("CHANGE-REQUEST test failed - likely Address-Dependent Filtering") 397 | return AddressDependentFiltering, ret2 398 | } 399 | 400 | // We're behind a NAT - now determine the NAT type using RFC 3489 algorithm 401 | isDerpServer := isDerpServerLikely(server, ret, RetVal{}) 402 | 403 | if isDerpServer { 404 | logger.Debugf("DERP server detected - using conservative classification without CHANGE-REQUEST tests") 405 | // For DERP servers, we can't reliably test CHANGE-REQUEST, so use conservative approach 406 | // Most home NATs are address-dependent or port-restricted 407 | return AddressDependentMapping, ret 408 | } 409 | 410 | // Test II: Send change request (change IP and port) for traditional STUN servers 411 | logger.Debugf("Testing with CHANGE-REQUEST: change IP and port") 412 | ret2 := stunTest(sock, server, "00000006", software) 413 | 414 | if ret2.Resp { 415 | // Got response to change request - check if mapping actually changed 416 | if ret2.ExternalIP != ret.ExternalIP || ret2.ExternalPort != ret.ExternalPort { 417 | logger.Debugf("CHANGE-REQUEST response shows different mapping (IP:%s->%s, Port:%d->%d) - Full Cone NAT", 418 | ret.ExternalIP, ret2.ExternalIP, ret.ExternalPort, ret2.ExternalPort) 419 | return EndpointIndependentMapping, ret2 420 | } else { 421 | logger.Debugf("CHANGE-REQUEST response identical to original - server may not support CHANGE-REQUEST properly") 422 | // Fallback to conservative classification 423 | return AddressDependentMapping, ret 424 | } 425 | } 426 | 427 | // Change request failed - Test with changed address if available 428 | if chIP == "" || chPort == 0 { 429 | logger.Debugf("No CHANGED-ADDRESS available - cannot perform full classification") 430 | return AddressDependentMapping, ret 431 | } 432 | 433 | // Test III: Send request to changed address (different IP, same port as original) 434 | logger.Debugf("Testing connection to changed address %s:%d", chIP, chPort) 435 | ret3 := stunTestToIP(sock, chIP, chPort, "", software) 436 | 437 | if !ret3.Resp { 438 | logger.Debugf("Could not reach changed address - likely Symmetric NAT") 439 | return AddressAndPortDependentMapping, ret 440 | } 441 | 442 | // Check if we get the same external mapping from the changed address 443 | if exIP == ret3.ExternalIP && exPort == ret3.ExternalPort { 444 | // Same mapping from different server - now test change port only 445 | logger.Debugf("Same mapping from changed address - testing port-only change request") 446 | ret4 := stunTestToIP(sock, chIP, chPort, "00000002", software) // Change port only 447 | 448 | if ret4.Resp { 449 | logger.Debugf("Port-only change request succeeded - Restricted Cone NAT") 450 | return RestrictedConeNAT, ret4 451 | } else { 452 | logger.Debugf("Port-only change request failed - Port-Restricted Cone NAT") 453 | return PortRestrictedConeNAT, ret3 454 | } 455 | } 456 | 457 | // Different mapping from different server - Symmetric NAT 458 | logger.Debugf("Different mapping from changed address - Symmetric NAT (Address-and-Port-Dependent)") 459 | return AddressAndPortDependentMapping, ret3 460 | } 461 | 462 | // Run a test1/test approach against a STUN server 463 | // we send a request, then send a change request to determine if we get the same port/IP tuple 464 | func stunTest(sock *net.UDPConn, hostPort, changeReq, software string) RetVal { 465 | var ret RetVal 466 | var tx TxID 467 | _, _ = rand.Read(tx[:]) 468 | 469 | var crBytes []byte 470 | if changeReq != "" { 471 | crBytes, _ = hex.DecodeString(changeReq) 472 | } 473 | 474 | req := buildRequest(tx, software, crBytes) 475 | 476 | logger.Debugf("TransactionID=%x, sending STUN request to %s with changeReq=%q", tx, hostPort, changeReq) 477 | 478 | count := 3 479 | for count > 0 { 480 | count-- 481 | raddr, err := net.ResolveUDPAddr("udp", hostPort) 482 | if err != nil { 483 | logger.Debugf("resolveUDPAddr error: %v", err) 484 | continue 485 | } 486 | 487 | logger.Debugf("sendto: %s", hostPort) 488 | 489 | _, err = sock.WriteToUDP(req, raddr) 490 | if err != nil { 491 | logger.Debugf("WriteToUDP error: %v", err) 492 | continue 493 | } 494 | 495 | buf := make([]byte, 2048) 496 | _ = sock.SetReadDeadline(time.Now().Add(2 * time.Second)) 497 | 498 | n, from, err := sock.ReadFromUDP(buf) 499 | if err != nil { 500 | logger.Debugf("readFromUDP error: %v, tries left=%d", err, count) 501 | continue 502 | } 503 | 504 | logger.Debugf("recvfrom: %v, %d bytes", from, n) 505 | 506 | if n < 20 { 507 | logger.Debug("received too few bytes, ignoring") 508 | continue 509 | } 510 | 511 | mt := binary.BigEndian.Uint16(buf[0:2]) 512 | if mt != 0x0101 { 513 | logger.Debugf("not a BindingSuccess => 0x%04x", mt) 514 | continue 515 | } 516 | 517 | cookie := buf[4:8] 518 | tid := buf[8:20] 519 | if !compareCookieAndTID(cookie, tid, tx) { 520 | logger.Debug("TransactionID mismatch") 521 | continue 522 | } 523 | 524 | msgLen := binary.BigEndian.Uint16(buf[2:4]) 525 | if int(msgLen) > (n - 20) { 526 | logger.Debugf("message length too large: %d vs actual %d", msgLen, n-20) 527 | continue 528 | } 529 | 530 | attrData := buf[20 : 20+msgLen] 531 | ret.Resp = true 532 | parseSTUNAttributes(attrData, &ret) 533 | 534 | logger.Debugf("Parsed STUN response => IP=%s Port=%d", ret.ExternalIP, ret.ExternalPort) 535 | return ret 536 | } 537 | return ret 538 | } 539 | 540 | func stunTestToIP(sock *net.UDPConn, ip string, port int, changeReq, software string) RetVal { 541 | if ip == "" || port == 0 { 542 | return RetVal{} 543 | } 544 | return stunTest(sock, fmt.Sprintf("%s:%d", ip, port), changeReq, software) 545 | } 546 | 547 | // parseSTUNAttributes parses the STUN attributes from the response 548 | // 0x0001 => MAPPED-ADDRESS 549 | // 0x0005 => CHANGED-ADDRESS 550 | // 0x0020 => XOR-MAPPED-ADDRESS 551 | func parseSTUNAttributes(attrs []byte, ret *RetVal) { 552 | var offset int 553 | for offset+4 <= len(attrs) { 554 | aType := binary.BigEndian.Uint16(attrs[offset : offset+2]) 555 | aLen := binary.BigEndian.Uint16(attrs[offset+2 : offset+4]) 556 | end := offset + 4 + int(aLen) 557 | if end > len(attrs) { 558 | break 559 | } 560 | val := attrs[offset+4 : end] 561 | switch aType { 562 | case 0x0001: 563 | if len(val) >= 8 { 564 | p := int(val[2])<<8 | int(val[3]) 565 | ip4 := fmt.Sprintf("%d.%d.%d.%d", val[4], val[5], val[6], val[7]) 566 | ret.ExternalIP = ip4 567 | ret.ExternalPort = p 568 | } 569 | case 0x0005: 570 | if len(val) >= 8 { 571 | p := int(val[2])<<8 | int(val[3]) 572 | ip4 := fmt.Sprintf("%d.%d.%d.%d", val[4], val[5], val[6], val[7]) 573 | ret.ChangedIP = ip4 574 | ret.ChangedPort = p 575 | } 576 | case 0x0020: 577 | if len(val) >= 8 { 578 | const mc = 0x2112A442 579 | p := binary.BigEndian.Uint16(val[2:4]) ^ uint16(mc>>16) 580 | raw := binary.BigEndian.Uint32(val[4:8]) ^ mc 581 | ip := make(net.IP, 4) 582 | binary.BigEndian.PutUint32(ip, raw) 583 | ret.ExternalIP = ip.String() 584 | ret.ExternalPort = int(p) 585 | } 586 | } 587 | offset = end 588 | } 589 | } 590 | 591 | // build the STUN request with all of the attributes 592 | // if we include the SOFTWARE attribute, it will be 0x8022 593 | // 0x0003 => CHANGE-REQUEST 594 | // 0x8028 => FINGERPRINT 595 | // 0x0001 => MAPPED-ADDRESS 596 | func buildRequest(tx TxID, software string, changeReq []byte) []byte { 597 | var attrs []byte 598 | if software != "" { 599 | sw := []byte(software) 600 | attrs = appendU16(attrs, attrSoftware) 601 | attrs = appendU16(attrs, uint16(len(sw))) 602 | attrs = append(attrs, sw...) 603 | attrs = stunPad(attrs) 604 | } 605 | if len(changeReq) == 4 { 606 | attrs = appendU16(attrs, 0x0003) 607 | attrs = appendU16(attrs, 4) 608 | attrs = append(attrs, changeReq...) 609 | attrs = stunPad(attrs) 610 | } 611 | hdr := make([]byte, 0, 20) 612 | hdr = append(hdr, bindingRequestType...) 613 | hdr = appendU16(hdr, 0) 614 | hdr = append(hdr, magicCookie...) 615 | hdr = append(hdr, tx[:]...) 616 | tmp := append(hdr, attrs...) 617 | fp := fingerPrint(tmp) 618 | fpA := make([]byte, 0, 8) 619 | fpA = appendU16(fpA, attrFingerprint) 620 | fpA = appendU16(fpA, 4) 621 | fpA = appendU32(fpA, fp) 622 | out := append(tmp, fpA...) 623 | attrLen := len(out) - 20 624 | binary.BigEndian.PutUint16(out[2:4], uint16(attrLen)) 625 | return out 626 | } 627 | 628 | func compareCookieAndTID(cookie, tid []byte, tx TxID) bool { 629 | if len(cookie) != 4 || len(tid) != 12 { 630 | return false 631 | } 632 | if cookie[0] != 0x21 || cookie[1] != 0x12 || cookie[2] != 0xa4 || cookie[3] != 0x42 { 633 | return false 634 | } 635 | return string(tid) == string(tx[:]) 636 | } 637 | 638 | // Checks whether the first 4 bytes are the correct STUN magic cookie, and whether the next 12 bytes match our transaction ID. 639 | func fingerPrint(b []byte) uint32 { 640 | c := crc32.ChecksumIEEE(b) 641 | return c ^ 0x5354554e 642 | } 643 | 644 | // Computes the STUN FINGERPRINT by taking the CRC32-IEEE of the packet data and XORing with 0x5354554e, per RFC5389. 645 | func stunPad(b []byte) []byte { 646 | p := (4 - (len(b) % 4)) % 4 647 | if p == 0 { 648 | return b 649 | } 650 | return append(b, make([]byte, p)...) 651 | } 652 | 653 | // helper function for appending a 16-bit unsigned integer to a byte slice 654 | func appendU16(b []byte, v uint16) []byte { 655 | var tmp [2]byte 656 | binary.BigEndian.PutUint16(tmp[:], v) 657 | return append(b, tmp[:]...) 658 | } 659 | 660 | // helper function for appending a 32-bit unsigned integer to a byte slice 661 | func appendU32(b []byte, v uint32) []byte { 662 | var tmp [4]byte 663 | binary.BigEndian.PutUint32(tmp[:], v) 664 | return append(b, tmp[:]...) 665 | } 666 | 667 | func probePortmapAvailability() string { 668 | // Attempt to discover default gateway 669 | gw, _ := gateway.DiscoverGateway() 670 | logger.Debugf("gateway discovery returned: %v", gw) 671 | 672 | nm, err := netmon.New(logger.Debugf) 673 | if err != nil { 674 | logger.Fatalf("netmon.New failed: %v", err) 675 | } 676 | nm.Start() 677 | 678 | defer nm.Close() 679 | 680 | pm := portmapper.NewClient( 681 | func(format string, args ...interface{}) { 682 | logger.Debugf(format, args...) 683 | }, 684 | nm, nil, nil, 685 | func() {}, 686 | ) 687 | 688 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 689 | defer cancel() 690 | 691 | probeResult, err := pm.Probe(ctx) 692 | if err != nil { 693 | logger.Debugf("pm.Probe => error: %v", err) 694 | return "None" 695 | } 696 | 697 | logger.Debugf("Port mapping probe results: UPnP=%v, PMP=%v, PCP=%v", probeResult.UPnP, probeResult.PMP, probeResult.PCP) 698 | 699 | // If PCP is found, we label it "PCP". 700 | // If PMP is found, we label it "NAT-PMP". 701 | // If UPnP is found, we label it "UPnP". 702 | if probeResult.PCP { 703 | return "PCP" 704 | } else if probeResult.PMP { 705 | return "NAT-PMP" 706 | } else if probeResult.UPnP { 707 | return "UPnP" 708 | } 709 | return "None" 710 | } 711 | 712 | type NatDetail struct { 713 | EasyVsHard string 714 | Notes string 715 | } 716 | 717 | func natDetailFor(n string) NatDetail { 718 | switch n { 719 | case Blocked: 720 | return NatDetail{"Hard", "The NAT or firewall is preventing inbound hole-punch attempts. Outbound connections do not facilitate inbound reachability."} 721 | case OpenInternet: 722 | return NatDetail{"None", "Your host is directly reachable from the internet."} 723 | case EndpointIndependentMapping: 724 | return NatDetail{"Easy", "Reuses the same public port for all remote connections, enabling inbound hole punching from any peer once an outbound packet is sent."} 725 | case AddressDependentFiltering: 726 | return NatDetail{"Hard", "Incoming packets are only accepted from the same remote IP that was used in the initial outbound connection, limiting who can punch in."} 727 | case AddressDependentMapping: 728 | return NatDetail{"Easy", "Uses one public port for each remote IP. Inbound connections must come from that IP."} 729 | case AddressAndPortDependentMapping: 730 | return NatDetail{"Hard", "Allocates different public ports for each remote IP:port combination, making inbound hole punching very difficult."} 731 | case RestrictedConeNAT: 732 | return NatDetail{"Easy", "All requests from the same internal IP:port use the same external IP:port. External hosts can send packets back only if the internal host has previously sent a packet to that external IP."} 733 | case PortRestrictedConeNAT: 734 | return NatDetail{"Hard", "Similar to Restricted Cone but also requires that the external port matches. External hosts can only send packets if the internal host has sent to that exact IP:port combination."} 735 | case ChangedAddressError: 736 | return NatDetail{"N/A", "An error occurred during NAT detection preventing a full classification."} 737 | default: 738 | return NatDetail{"N/A", "Unknown NAT type - no conclusive classification could be determined from the tests."} 739 | } 740 | } 741 | 742 | func printTables(results []PerServerResult, finalNAT string, omit bool) { 743 | // Define lipgloss styles with colors that work on both dark and light terminals 744 | titleStyle := lipgloss.NewStyle(). 745 | Bold(true). 746 | Foreground(lipgloss.Color("15")). // White text 747 | Background(lipgloss.Color("57")). // Purple background 748 | Padding(0, 1). 749 | MarginTop(1). 750 | MarginBottom(1) 751 | 752 | cardStyle := lipgloss.NewStyle(). 753 | Border(lipgloss.RoundedBorder()). 754 | BorderForeground(lipgloss.Color("57")). // Purple border 755 | Padding(1, 2). 756 | MarginBottom(1). 757 | Width(70) 758 | 759 | labelStyle := lipgloss.NewStyle(). 760 | Bold(true). 761 | Foreground(lipgloss.Color("13")) // Bright magenta 762 | 763 | valueStyle := lipgloss.NewStyle(). 764 | Foreground(lipgloss.Color("")) // Use terminal's default foreground color 765 | 766 | // Easy/Hard color coding with standard colors 767 | easyStyle := lipgloss.NewStyle(). 768 | Foreground(lipgloss.Color("2")). // Green 769 | Bold(true) 770 | 771 | hardStyle := lipgloss.NewStyle(). 772 | Foreground(lipgloss.Color("1")). // Red 773 | Bold(true) 774 | 775 | neutralStyle := lipgloss.NewStyle(). 776 | Foreground(lipgloss.Color("3")). // Yellow 777 | Bold(true) 778 | 779 | // Print STUN Results section 780 | fmt.Println(titleStyle.Render("🌐 STUN Results")) 781 | 782 | if len(results) == 0 { 783 | fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("No STUN results available")) 784 | return 785 | } 786 | 787 | // Create vertical cards for each STUN server result 788 | for _, r := range results { 789 | portStr := "None" 790 | ipStr := "None" 791 | if r.ExternalIP != "" { 792 | portStr = fmt.Sprintf("%d", r.ExternalPort) 793 | if omit { 794 | ipStr = "" 795 | } else { 796 | ipStr = r.ExternalIP 797 | } 798 | } 799 | 800 | // Style the mapping protocol 801 | var mappingStyled string 802 | switch r.MappingProtocol { 803 | case "UPnP", "NAT-PMP", "PCP": 804 | mappingStyled = easyStyle.Render(r.MappingProtocol) 805 | case "None": 806 | mappingStyled = hardStyle.Render(r.MappingProtocol) 807 | default: 808 | mappingStyled = neutralStyle.Render(r.MappingProtocol) 809 | } 810 | 811 | cardContent := fmt.Sprintf("%s %s\n%s %s\n%s %s\n%s %s", 812 | labelStyle.Render("Server:"), valueStyle.Render(r.Server), 813 | labelStyle.Render("Port:"), valueStyle.Render(portStr), 814 | labelStyle.Render("IP Address:"), valueStyle.Render(ipStr), 815 | labelStyle.Render("Port Mapping:"), mappingStyled, 816 | ) 817 | 818 | fmt.Println(cardStyle.Render(cardContent)) 819 | } 820 | 821 | // Print NAT Type Detection section 822 | fmt.Println(titleStyle.Render("🔍 NAT Type Detection")) 823 | 824 | details := natDetailFor(finalNAT) 825 | 826 | var directConns string 827 | switch finalNAT { 828 | case OpenInternet: 829 | directConns = "All devices" 830 | case EndpointIndependentMapping, RestrictedConeNAT, AddressDependentMapping: 831 | directConns = "Easy NAT + No NAT devices" 832 | case PortRestrictedConeNAT, AddressDependentFiltering, AddressAndPortDependentMapping: 833 | directConns = "No NAT devices only" 834 | case Blocked: 835 | directConns = "None (blocked)" 836 | default: 837 | directConns = "Unknown" 838 | } 839 | 840 | // Style the Easy/Hard indicator 841 | var difficultyStyled string 842 | switch details.EasyVsHard { 843 | case "None": 844 | difficultyStyled = easyStyle.Render("✨ None") 845 | case "Easy": 846 | difficultyStyled = easyStyle.Render("✅ Easy") 847 | case "Hard": 848 | difficultyStyled = hardStyle.Render("❌ Hard") 849 | default: 850 | difficultyStyled = neutralStyle.Render("❓ " + details.EasyVsHard) 851 | } 852 | 853 | // Create NAT result card 854 | natCardStyle := lipgloss.NewStyle(). 855 | Border(lipgloss.RoundedBorder()). 856 | BorderForeground(lipgloss.Color("13")). // Bright magenta border 857 | Padding(1, 2). 858 | MarginBottom(1). 859 | Width(70) 860 | 861 | natCardContent := fmt.Sprintf("%s %s\n\n%s %s\n\n%s %s\n\n%s\n%s\n\n%s %s", 862 | labelStyle.Render("NAT Type:"), valueStyle.Render(finalNAT), 863 | labelStyle.Render("Difficulty:"), difficultyStyled, 864 | labelStyle.Render("Direct Connections:"), valueStyle.Render(directConns), 865 | labelStyle.Render("Description:"), 866 | valueStyle.Render(details.Notes), 867 | labelStyle.Render("Status:"), getSummaryText(details.EasyVsHard), 868 | ) 869 | 870 | fmt.Println(natCardStyle.Render(natCardContent)) 871 | } 872 | 873 | func getSummaryText(difficulty string) string { 874 | easyStyle := lipgloss.NewStyle(). 875 | Foreground(lipgloss.Color("2")). // Green 876 | Bold(true) 877 | 878 | hardStyle := lipgloss.NewStyle(). 879 | Foreground(lipgloss.Color("1")). // Red 880 | Bold(true) 881 | 882 | neutralStyle := lipgloss.NewStyle(). 883 | Foreground(lipgloss.Color("3")). // Yellow 884 | Bold(true) 885 | 886 | switch difficulty { 887 | case "None": 888 | return easyStyle.Render("🚀 Perfect! You have no NAT - ALL connections will be direct.") 889 | case "Easy": 890 | return easyStyle.Render("🎉 Great! The NAT you're behind should allow direct connections for many connections.") 891 | case "Hard": 892 | return hardStyle.Render("⚠️ The NAT you're behind may require relay servers for connections.") 893 | default: 894 | return neutralStyle.Render("ℹ️ NAT detection was inconclusive.") 895 | } 896 | } 897 | --------------------------------------------------------------------------------