diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8bc328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +.vscode/ +.devcontainer/ +*.exe +*~ \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9..299ca74 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 肖其顿 (XIAO QI DUN) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..bfcb98e --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +probe +Copyright 2026 肖其顿 (XIAO QI DUN) + +This product includes software developed by +肖其顿 (XIAO QI DUN) (https://github.com/xiaoqidun/probe). \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df4c4e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/xiaoqidun/probe + +go 1.24.0 diff --git a/probe.go b/probe.go new file mode 100644 index 0000000..9884e32 --- /dev/null +++ b/probe.go @@ -0,0 +1,90 @@ +// Copyright 2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "flag" + "fmt" + "net" + "strings" + "time" +) + +func main() { + var ( + ip string + s1 string + s2 string + s5 string + to time.Duration + ) + flag.StringVar(&ip, "ip", "", "协议版本") + flag.StringVar(&s1, "s1", "stun.l.google.com:19302", "主服务器") + flag.StringVar(&s2, "s2", "", "次服务器") + flag.StringVar(&s5, "s5", "", "代理地址") + flag.DurationVar(&to, "to", 5*time.Second, "超时时间") + flag.Parse() + network := "udp" + switch ip { + case "": + network = "udp" + case "4": + network = "udp4" + case "6": + network = "udp6" + default: + flag.PrintDefaults() + return + } + var conn net.PacketConn + var err error + if s5 != "" { + fmt.Printf("通过代理探测: %s\n", s5) + conn, err = DialSocks5UDP(s5) + if err != nil { + fmt.Printf("连接代理失败: %v\n", err) + return + } + } else { + fmt.Printf("本地直接探测: %s\n", network) + conn, err = net.ListenPacket(network, ":0") + if err != nil { + fmt.Printf("本地监听失败: %v\n", err) + return + } + } + defer conn.Close() + if s5 == "" { + fmt.Printf("本地监听地址: %s\n", conn.LocalAddr()) + } + s1p := fmt.Sprintf("探测服务器一: %s (%s)", s1, network) + fmt.Println(s1p) + maxW := displayWidth(s1p) + if s2 != "" { + s2p := fmt.Sprintf("探测服务器二: %s (%s)", s2, network) + fmt.Println(s2p) + if w2 := displayWidth(s2p); w2 > maxW { + maxW = w2 + } + } + fmt.Println(strings.Repeat("-", maxW)) + result := DetectNAT(conn, s1, s2, network, to) + fmt.Printf("NAT 类型结果: %s\n", result.Type) + if result.MappedIP != "" { + fmt.Printf("本地映射地址: %s\n", result.MappedIP) + } + fmt.Printf("映射行为模式: %s\n", result.Mapping) + fmt.Printf("过滤行为模式: %s\n", result.Filtering) +} diff --git a/probe_nat.go b/probe_nat.go new file mode 100644 index 0000000..d615338 --- /dev/null +++ b/probe_nat.go @@ -0,0 +1,228 @@ +// Copyright 2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "crypto/rand" + "net" + "strconv" + "time" +) + +// MappingEndpointIndependent 独立映射模式常量 +const MappingEndpointIndependent = "Endpoint-Independent" + +// MappingAddressDependent 地址相关映射模式常量 +const MappingAddressDependent = "Address-Dependent" + +// MappingAddressPortDependent 地址端口相关映射模式常量 +const MappingAddressPortDependent = "Address and Port-Dependent" + +// MappingUnknown 未知映射模式常量 +const MappingUnknown = "Unknown" + +// FilteringEndpointIndependent 独立过滤模式常量 +const FilteringEndpointIndependent = "Endpoint-Independent" + +// FilteringAddressDependent 地址相关过滤模式常量 +const FilteringAddressDependent = "Address-Dependent" + +// FilteringAddressPortDependent 地址端口相关过滤模式常量 +const FilteringAddressPortDependent = "Address and Port-Dependent" + +// FilteringUnknown 未知过滤模式常量 +const FilteringUnknown = "Unknown" + +// NATOpen 公网类型常量 +const NATOpen = "Open Internet" + +// NATFullCone 全锥型NAT常量 +const NATFullCone = "Full Cone" + +// NATRestricted 限制锥型NAT常量 +const NATRestricted = "Restricted Cone" + +// NATPortRestricted 端口限制锥型NAT常量 +const NATPortRestricted = "Port Restricted Cone" + +// NATSymmetric 对称型NAT常量 +const NATSymmetric = "Symmetric" + +// NATUDPBlocked UDP阻塞常量 +const NATUDPBlocked = "UDP Blocked" + +// NATUnknown 未知类型常量 +const NATUnknown = "Unknown" + +// NATResult NAT探测结果结构 +type NATResult struct { + Type string + Mapping string + Filtering string + MappedIP string +} + +// resolveAddr 解析探测目标地址 +// 入参: conn 当前连接, addrStr 目标地址字符串, network 网络协议 +// 返回: addr 解析后的网络地址, err 解析错误 +func resolveAddr(conn net.PacketConn, addrStr, network string) (net.Addr, error) { + host, portStr, err := net.SplitHostPort(addrStr) + if err != nil { + return nil, err + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, err + } + if _, ok := conn.(*socks5PacketConn); ok { + if net.ParseIP(host) == nil && host != "localhost" && host != "127.0.0.1" { + return &SocksAddr{Host: host, Port: port}, nil + } + } + return net.ResolveUDPAddr(network, addrStr) +} + +// performTest 执行单次STUN探测 +// 入参: conn 连接对象, serverAddr STUN服务器地址, network 协议, timeout 超时设定, changeIP 变更IP标志, changePort 变更端口标志 +// 返回: msg 响应消息, addr 响应源地址, err 探测错误 +func performTest(conn net.PacketConn, serverAddr string, network string, timeout time.Duration, changeIP, changePort bool) (*stunMessage, *net.UDPAddr, error) { + dst, err := resolveAddr(conn, serverAddr, network) + if err != nil { + return nil, nil, err + } + txID := [12]byte{} + rand.Read(txID[:]) + req := encodeSTUNRequest(txID, changeIP, changePort) + if _, err := conn.WriteTo(req, dst); err != nil { + return nil, nil, err + } + conn.SetReadDeadline(time.Now().Add(timeout)) + defer conn.SetReadDeadline(time.Time{}) + buf := make([]byte, 2048) + for i := 0; i < 3; i++ { + n, addr, err := conn.ReadFrom(buf) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + break + } + return nil, nil, err + } + msg, err := decodeSTUNResponse(buf[:n], txID) + if err != nil { + continue + } + uAddr, ok := addr.(*net.UDPAddr) + if !ok { + continue + } + return msg, uAddr, nil + } + return nil, nil, nil +} + +// DetectNAT 执行NAT类型检测核心逻辑 +// 入参: conn 连接对象, primarySTUN 主服务器, secondarySTUN 辅服务器, network 协议, timeout 超时设定 +// 返回: result 检测结果 +func DetectNAT(conn net.PacketConn, primarySTUN, secondarySTUN, network string, timeout time.Duration) NATResult { + res := NATResult{Type: NATUnknown, Mapping: MappingUnknown, Filtering: FilteringUnknown} + resp1, _, err := performTest(conn, primarySTUN, network, timeout, false, false) + if err != nil || resp1 == nil { + res.Type = NATUDPBlocked + return res + } + mappedAddr1 := resp1.GetMappedAddress() + if mappedAddr1 == nil { + return res + } + res.MappedIP = mappedAddr1.String() + var targetSTUN2 string + if secondarySTUN != "" { + targetSTUN2 = secondarySTUN + } else { + changedAddr := resp1.GetChangedAddress() + if changedAddr != nil { + targetSTUN2 = net.JoinHostPort(changedAddr.IP.String(), strconv.Itoa(changedAddr.Port)) + } else { + host, port, _ := net.SplitHostPort(primarySTUN) + ips, err := net.LookupIP(host) + if err == nil && len(ips) > 1 { + primaryIP, _ := net.ResolveIPAddr("ip", host) + wantIPv4 := network == "udp4" + wantIPv6 := network == "udp6" + if network == "udp" { + wantIPv4 = primaryIP.IP.To4() != nil + wantIPv6 = !wantIPv4 + } + for _, ip := range ips { + if ip.Equal(primaryIP.IP) { + continue + } + isV4 := ip.To4() != nil + if wantIPv4 && !isV4 { + continue + } + if wantIPv6 && isV4 { + continue + } + targetSTUN2 = net.JoinHostPort(ip.String(), port) + break + } + } + } + } + var mappedAddr3 *net.UDPAddr + if targetSTUN2 != "" { + resp3, _, err := performTest(conn, targetSTUN2, network, timeout, false, false) + if err == nil && resp3 != nil { + mappedAddr3 = resp3.GetMappedAddress() + } + } + if mappedAddr3 != nil { + if mappedAddr1.String() == mappedAddr3.String() { + res.Mapping = MappingEndpointIndependent + } else { + res.Mapping = MappingAddressPortDependent + } + } else { + res.Mapping = MappingUnknown + } + resp2, _, _ := performTest(conn, primarySTUN, network, timeout, true, true) + if resp2 != nil { + res.Filtering = FilteringEndpointIndependent + } else { + resp4, _, _ := performTest(conn, primarySTUN, network, timeout, false, true) + if resp4 != nil { + res.Filtering = FilteringAddressDependent + } else { + res.Filtering = FilteringAddressPortDependent + } + } + if res.Filtering == FilteringEndpointIndependent { + res.Type = NATFullCone + if res.Mapping == MappingUnknown { + res.Mapping = MappingEndpointIndependent + } + } else if res.Mapping == MappingEndpointIndependent { + switch res.Filtering { + case FilteringAddressDependent: + res.Type = NATRestricted + case FilteringAddressPortDependent: + res.Type = NATPortRestricted + } + } else { + res.Type = NATSymmetric + } + return res +} diff --git a/probe_socks5.go b/probe_socks5.go new file mode 100644 index 0000000..132a3a7 --- /dev/null +++ b/probe_socks5.go @@ -0,0 +1,275 @@ +// Copyright 2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/url" + "strconv" + "strings" + "time" +) + +// SocksAddr SOCKS5域名地址类型 +type SocksAddr struct { + Host string + Port int +} + +// Network 返回网络类型 +// 返回: network 网络类型字符串 +func (a *SocksAddr) Network() string { return "udp" } + +// String 返回地址字符串表示 +// 返回: str 地址字符串 +func (a *SocksAddr) String() string { return net.JoinHostPort(a.Host, strconv.Itoa(a.Port)) } + +// socks5PacketConn SOCKS5数据包连接实现 +type socks5PacketConn struct { + tcpConn net.Conn + udpConn *net.UDPConn + relayAddr *net.UDPAddr + targetAddr net.Addr +} + +// ReadFrom 从UDP连接读取数据 +// 入参: p 读取缓冲区 +// 返回: n 读取字节数, addr 来源地址, err 读取错误 +func (c *socks5PacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + buf := make([]byte, 65535) + n, _, err = c.udpConn.ReadFromUDP(buf) + if err != nil { + return 0, nil, err + } + if n < 10 { + return 0, nil, nil + } + atyp := buf[3] + var rAddr *net.UDPAddr + var dataOffset int + switch atyp { + case 0x01: + if n < 10 { + return 0, nil, errors.New("short packet") + } + ip := net.IP(buf[4:8]) + port := binary.BigEndian.Uint16(buf[8:10]) + rAddr = &net.UDPAddr{IP: ip, Port: int(port)} + dataOffset = 10 + case 0x03: + dlen := int(buf[4]) + if n < 5+dlen+2 { + return 0, nil, errors.New("short packet") + } + domain := string(buf[5 : 5+dlen]) + port := binary.BigEndian.Uint16(buf[5+dlen : 5+dlen+2]) + ipAddr, err := net.ResolveIPAddr("ip", domain) + if err != nil { + return 0, nil, fmt.Errorf("failed to resolve payload domain: %v", err) + } + rAddr = &net.UDPAddr{IP: ipAddr.IP, Port: int(port)} + dataOffset = 5 + dlen + 2 + case 0x04: + if n < 22 { + return 0, nil, errors.New("short packet") + } + ip := net.IP(buf[4:20]) + port := binary.BigEndian.Uint16(buf[20:22]) + rAddr = &net.UDPAddr{IP: ip, Port: int(port)} + dataOffset = 22 + } + copy(p, buf[dataOffset:n]) + return n - dataOffset, rAddr, nil +} + +// WriteTo 写入数据到目标地址 +// 入参: p 数据内容, addr 目标地址 +// 返回: n 写入字节数, err 写入错误 +func (c *socks5PacketConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { + header := make([]byte, 0, 24) + header = append(header, 0, 0, 0) + switch a := addr.(type) { + case *net.UDPAddr: + ip4 := a.IP.To4() + if ip4 != nil { + header = append(header, 0x01) + header = append(header, ip4...) + } else { + if len(a.IP) == 16 { + header = append(header, 0x04) + header = append(header, a.IP...) + } else { + return 0, errors.New("unknown ip type") + } + } + portBuf := make([]byte, 2) + binary.BigEndian.PutUint16(portBuf, uint16(a.Port)) + header = append(header, portBuf...) + case *SocksAddr: + header = append(header, 0x03) + header = append(header, byte(len(a.Host))) + header = append(header, a.Host...) + portBuf := make([]byte, 2) + binary.BigEndian.PutUint16(portBuf, uint16(a.Port)) + header = append(header, portBuf...) + default: + return 0, errors.New("unsupported address type") + } + finalBuf := append(header, p...) + _, err = c.udpConn.WriteToUDP(finalBuf, c.relayAddr) + if err != nil { + return 0, err + } + return len(p), nil +} + +// Close 关闭连接 +// 返回: err 关闭错误 +func (c *socks5PacketConn) Close() error { + c.tcpConn.Close() + return c.udpConn.Close() +} + +// LocalAddr 获取本地地址 +// 返回: addr 本地地址 +func (c *socks5PacketConn) LocalAddr() net.Addr { + return c.udpConn.LocalAddr() +} + +// SetDeadline 设置读写截止时间 +// 入参: t 截止时间 +// 返回: err 设置错误 +func (c *socks5PacketConn) SetDeadline(t time.Time) error { + return c.udpConn.SetDeadline(t) +} + +// SetReadDeadline 设置读取截止时间 +// 入参: t 截止时间 +// 返回: err 设置错误 +func (c *socks5PacketConn) SetReadDeadline(t time.Time) error { + return c.udpConn.SetReadDeadline(t) +} + +// SetWriteDeadline 设置写入截止时间 +// 入参: t 截止时间 +// 返回: err 设置错误 +func (c *socks5PacketConn) SetWriteDeadline(t time.Time) error { + return c.udpConn.SetWriteDeadline(t) +} + +// DialSocks5UDP 建立SOCKS5 UDP关联 +// 入参: proxyAddr 代理服务器地址 +// 返回: conn 数据包连接, err 连接错误 +func DialSocks5UDP(proxyAddr string) (net.PacketConn, error) { + var host string + if strings.Contains(proxyAddr, "://") { + u, err := url.Parse(proxyAddr) + if err != nil { + return nil, err + } + host = u.Host + } else { + host = proxyAddr + } + conn, err := net.DialTimeout("tcp", host, 5*time.Second) + if err != nil { + return nil, err + } + _, err = conn.Write([]byte{0x05, 0x01, 0x00}) + if err != nil { + conn.Close() + return nil, err + } + buf := make([]byte, 2) + if _, err := io.ReadFull(conn, buf); err != nil { + conn.Close() + return nil, err + } + if buf[0] != 0x05 || buf[1] != 0x00 { + conn.Close() + return nil, errors.New("socks5 handshake failed") + } + req := []byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0} + if _, err := conn.Write(req); err != nil { + conn.Close() + return nil, err + } + header := make([]byte, 4) + if _, err := io.ReadFull(conn, header); err != nil { + conn.Close() + return nil, err + } + if header[1] != 0x00 { + conn.Close() + return nil, fmt.Errorf("socks5 udp associate failed: 0x%x", header[1]) + } + var relayIP net.IP + var relayPort int + switch header[3] { + case 0x01: + b := make([]byte, 4) + if _, err := io.ReadFull(conn, b); err != nil { + conn.Close() + return nil, err + } + relayIP = net.IP(b) + case 0x03: + lenBuf := make([]byte, 1) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + conn.Close() + return nil, err + } + domainBuf := make([]byte, int(lenBuf[0])) + if _, err := io.ReadFull(conn, domainBuf); err != nil { + conn.Close() + return nil, err + } + addr, err := net.ResolveIPAddr("ip", string(domainBuf)) + if err != nil { + conn.Close() + return nil, err + } + relayIP = addr.IP + case 0x04: + b := make([]byte, 16) + if _, err := io.ReadFull(conn, b); err != nil { + conn.Close() + return nil, err + } + relayIP = net.IP(b) + } + pb := make([]byte, 2) + if _, err := io.ReadFull(conn, pb); err != nil { + conn.Close() + return nil, err + } + relayPort = int(binary.BigEndian.Uint16(pb)) + if relayIP.IsUnspecified() { + if remoteAddr, ok := conn.RemoteAddr().(*net.TCPAddr); ok { + relayIP = remoteAddr.IP + } + } + relayAddr := &net.UDPAddr{IP: relayIP, Port: relayPort} + lConn, err := net.ListenUDP("udp", nil) + if err != nil { + conn.Close() + return nil, err + } + return &socks5PacketConn{tcpConn: conn, udpConn: lConn, relayAddr: relayAddr}, nil +} diff --git a/probe_stun.go b/probe_stun.go new file mode 100644 index 0000000..5e315aa --- /dev/null +++ b/probe_stun.go @@ -0,0 +1,209 @@ +// Copyright 2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/binary" + "errors" + "fmt" + "net" +) + +// stunMagicCookie be32用于STUN协议的魔法数 +const stunMagicCookie = 0x2112A442 + +// bindRequest STUN绑定请求消息类型 +const bindRequest = 0x0001 + +// bindResponse STUN绑定响应消息类型 +const bindResponse = 0x0101 + +// attrMappedAddress 映射地址属性类型 +const attrMappedAddress = 0x0001 + +// attrChangeRequest 修改请求属性类型 +const attrChangeRequest = 0x0003 + +// attrChangedAddress 修改后地址属性类型 +const attrChangedAddress = 0x0005 + +// attrXorMappedAddress 异或映射地址属性类型 +const attrXorMappedAddress = 0x0020 + +// attrOtherAddress 其他地址属性类型 +const attrOtherAddress = 0x802c + +// stunHeader STUN消息头部结构 +type stunHeader struct { + Type uint16 + Length uint16 + Cookie uint32 + ID [12]byte +} + +// stunMessage STUN消息完整结构 +type stunMessage struct { + Header stunHeader + Attributes []stunAttribute +} + +// stunAttribute STUN属性结构 +type stunAttribute struct { + Type uint16 + Length uint16 + Value []byte +} + +// encodeSTUNRequest 构造并编码STUN绑定请求消息 +// 入参: id 事务ID, changeIP 是否请求改变IP, changePort 是否请求改变端口 +// 返回: data 编码后的二进制数据 +func encodeSTUNRequest(id [12]byte, changeIP, changePort bool) []byte { + buf := make([]byte, 1024) + binary.BigEndian.PutUint16(buf[0:2], bindRequest) + binary.BigEndian.PutUint32(buf[4:8], stunMagicCookie) + copy(buf[8:20], id[:]) + offset := 20 + if changeIP || changePort { + binary.BigEndian.PutUint16(buf[offset:offset+2], attrChangeRequest) + binary.BigEndian.PutUint16(buf[offset+2:offset+4], 4) + offset += 4 + val := uint32(0) + if changeIP { + val |= 0x04 + } + if changePort { + val |= 0x02 + } + binary.BigEndian.PutUint32(buf[offset:offset+4], val) + offset += 4 + } + binary.BigEndian.PutUint16(buf[2:4], uint16(offset-20)) + return buf[:offset] +} + +// decodeSTUNResponse 解析STUN响应消息 +// 入参: data 接收到的二进制数据, txID 期望的事务ID +// 返回: msg 解析后的消息结构体指针, err 解析错误信息 +func decodeSTUNResponse(data []byte, txID [12]byte) (*stunMessage, error) { + if len(data) < 20 { + return nil, errors.New("response too short") + } + msgType := binary.BigEndian.Uint16(data[0:2]) + if msgType != bindResponse { + return nil, fmt.Errorf("unexpected message type: 0x%x", msgType) + } + length := binary.BigEndian.Uint16(data[2:4]) + if len(data) < 20+int(length) { + return nil, errors.New("incomplete message") + } + cookie := binary.BigEndian.Uint32(data[4:8]) + if string(data[8:20]) != string(txID[:]) { + return nil, errors.New("transaction id mismatch") + } + msg := &stunMessage{Header: stunHeader{Type: msgType, Length: length, Cookie: cookie, ID: txID}} + offset := 20 + end := 20 + int(length) + for offset < end { + if offset+4 > end { + break + } + attrType := binary.BigEndian.Uint16(data[offset : offset+2]) + attrLen := binary.BigEndian.Uint16(data[offset+2 : offset+4]) + offset += 4 + if offset+int(attrLen) > end { + break + } + val := make([]byte, attrLen) + copy(val, data[offset:offset+int(attrLen)]) + msg.Attributes = append(msg.Attributes, stunAttribute{Type: attrType, Length: attrLen, Value: val}) + offset += int(attrLen) + padding := (4 - (int(attrLen) % 4)) % 4 + offset += padding + } + return msg, nil +} + +// parseAddress 解析STUN属性中的地址信息 +// 入参: attrType 属性类型, data 属性值数据 +// 返回: addr 解析出的UDP地址, err 解析错误信息 +func parseAddress(attrType uint16, data []byte) (*net.UDPAddr, error) { + if len(data) < 4 { + return nil, errors.New("attribute too short") + } + family := data[1] + port := binary.BigEndian.Uint16(data[2:4]) + ipLen := 4 + if family == 0x02 { + ipLen = 16 + } else if family != 0x01 { + return nil, fmt.Errorf("unknown address family: %d", family) + } + if len(data) < 4+ipLen { + return nil, errors.New("invalid address length") + } + ip := make(net.IP, ipLen) + copy(ip, data[4:4+ipLen]) + if attrType == attrXorMappedAddress { + port ^= uint16(stunMagicCookie >> 16) + if ipLen == 4 { + mc := make([]byte, 4) + binary.BigEndian.PutUint32(mc, stunMagicCookie) + for i := 0; i < 4; i++ { + ip[i] ^= mc[i] + } + } + } + return &net.UDPAddr{IP: ip, Port: int(port)}, nil +} + +// GetMappedAddress 获取消息中的映射地址属性 +// 返回: addr 映射的UDP地址 +func (m *stunMessage) GetMappedAddress() *net.UDPAddr { + for _, attr := range m.Attributes { + if attr.Type == attrXorMappedAddress { + if addr, err := parseAddress(attr.Type, attr.Value); err == nil { + return addr + } + } + } + for _, attr := range m.Attributes { + if attr.Type == attrMappedAddress { + if addr, err := parseAddress(attr.Type, attr.Value); err == nil { + return addr + } + } + } + return nil +} + +// GetChangedAddress 获取消息中的变更地址属性 +// 返回: addr 变更的UDP地址 +func (m *stunMessage) GetChangedAddress() *net.UDPAddr { + for _, attr := range m.Attributes { + if attr.Type == attrChangedAddress { + if addr, err := parseAddress(attr.Type, attr.Value); err == nil { + return addr + } + } + } + for _, attr := range m.Attributes { + if attr.Type == attrOtherAddress { + if addr, err := parseAddress(attr.Type, attr.Value); err == nil { + return addr + } + } + } + return nil +} diff --git a/probe_util.go b/probe_util.go new file mode 100644 index 0000000..65e73ca --- /dev/null +++ b/probe_util.go @@ -0,0 +1,30 @@ +// Copyright 2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +// displayWidth 计算字符串显示宽度 +// 入参: s 字符串 +// 返回: w 视觉宽度 (中字2, 英字1) +func displayWidth(s string) int { + w := 0 + for _, r := range s { + if r > 127 { + w += 2 + } else { + w += 1 + } + } + return w +}