From 0e96f602fa623c12539c37c04bff8c2b7b61e4d2 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sun, 24 May 2026 18:18:42 +0800 Subject: [PATCH] fix(matcher,plugin): case-insensitive matching, port accumulation, nil guards, doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matcher: fix IPv6 normalization via netip.ParseAddr, port-range overwrite by switching to []*PortRange map, case-insensitive domain/host matching, glob.MustCompile→Compile to avoid panic on invalid patterns. plugin: guard nil *Options in NewGRPCConn and NewHTTPClient; add HTTPClientTransport helper for idle-connection cleanup. net/*: add doc comments for all exported symbols (no code changes). Add 30 tests (matcher_test.go 22, plugin_test.go 8) covering all matcher types, nil safety, case insensitivity, IPv6 normalization, port ranges, and plugin option composition. --- internal/matcher/matcher.go | 88 ++++-- internal/matcher/matcher_test.go | 438 ++++++++++++++++++++++++++++ internal/net/addr.go | 11 + internal/net/dialer/dialer.go | 9 + internal/net/http/http.go | 6 + internal/net/ip/ip.go | 1 + internal/net/net.go | 15 +- internal/net/pipe.go | 6 +- internal/net/proxyproto/conn.go | 3 + internal/net/proxyproto/listener.go | 2 + internal/net/resovle.go | 3 + internal/net/transport.go | 4 + internal/net/udp/conn.go | 4 + internal/net/udp/listener.go | 4 + internal/net/udp/relay.go | 7 + internal/plugin/plugin.go | 37 +++ internal/plugin/plugin_test.go | 112 +++++++ 17 files changed, 719 insertions(+), 31 deletions(-) create mode 100644 internal/matcher/matcher_test.go create mode 100644 internal/plugin/plugin_test.go diff --git a/internal/matcher/matcher.go b/internal/matcher/matcher.go index 3057b312..32ac409c 100644 --- a/internal/matcher/matcher.go +++ b/internal/matcher/matcher.go @@ -1,3 +1,5 @@ +// Package matcher provides pattern matching utilities for IP addresses, +// CIDR blocks, domains, wildcard patterns, and address:port combinations. package matcher import ( @@ -11,8 +13,8 @@ import ( "github.com/yl2chen/cidranger" ) -// Matcher is a generic pattern matcher, -// it gives the match result of the given pattern for specific v. +// Matcher is a generic pattern matcher that tests whether a given value +// matches any of the pre-configured patterns. type Matcher interface { Match(v string) bool } @@ -21,7 +23,7 @@ type ipMatcher struct { ips map[string]struct{} } -// IPMatcher creates a Matcher with a list of IP addresses. +// IPMatcher creates a Matcher for a list of IP addresses. func IPMatcher(ips []net.IP) Matcher { matcher := &ipMatcher{ ips: make(map[string]struct{}), @@ -36,34 +38,41 @@ func (m *ipMatcher) Match(ip string) bool { if m == nil || len(m.ips) == 0 { return false } - _, ok := m.ips[ip] + addr, err := netip.ParseAddr(ip) + if err != nil { + return false + } + _, ok := m.ips[addr.String()] return ok } type addrMatcher struct { - addrs map[string]*xnet.PortRange + addrs map[string][]*xnet.PortRange } -// AddrMatcher creates a Matcher with a list of HOST:PORT addresses. -// the host can be an IP (e.g. 192.168.1.1) address, a plain domain such as 'example.com', -// or a special pattern '.example.com' that matches 'example.com' -// and any subdomain 'abc.example.com', 'def.abc.example.com' etc. -// The PORT can be a single port number or port range MIN-MAX(e.g. 0-65535). +// AddrMatcher creates a Matcher for a list of HOST:PORT addresses. +// The host can be an IP address (e.g. 192.168.1.1), a plain domain such as +// "example.com", or a special pattern ".example.com" that matches +// "example.com" and any subdomain "abc.example.com", "def.abc.example.com" +// etc. The PORT can be a single port number or port range MIN-MAX +// (e.g. 0-65535). func AddrMatcher(addrs []string) Matcher { matcher := &addrMatcher{ - addrs: make(map[string]*xnet.PortRange), + addrs: make(map[string][]*xnet.PortRange), } for _, addr := range addrs { host, port, _ := net.SplitHostPort(addr) if host == "" { - matcher.addrs[addr] = nil + host = strings.ToLower(addr) + matcher.addrs[host] = append(matcher.addrs[host], nil) continue } + host = strings.ToLower(host) pr := &xnet.PortRange{} if err := pr.Parse(port); err != nil { pr = nil } - matcher.addrs[host] = pr + matcher.addrs[host] = append(matcher.addrs[host], pr) } return matcher } @@ -76,24 +85,25 @@ func (m *addrMatcher) Match(addr string) bool { if host == "" { host = addr } + host = strings.ToLower(host) port, _ := strconv.Atoi(sp) - if pr, ok := m.addrs[host]; ok { - if pr == nil || pr.Contains(port) { + if prs, ok := m.addrs[host]; ok { + if portRangeMatch(prs, port) { return true } } - if pr, ok := m.addrs["."+host]; ok { - if pr == nil || pr.Contains(port) { + if prs, ok := m.addrs["."+host]; ok { + if portRangeMatch(prs, port) { return true } } for { if index := strings.IndexByte(host, '.'); index > 0 { - if pr, ok := m.addrs[host[index:]]; ok { - if pr == nil || pr.Contains(port) { + if prs, ok := m.addrs[host[index:]]; ok { + if portRangeMatch(prs, port) { return true } } @@ -106,6 +116,17 @@ func (m *addrMatcher) Match(addr string) bool { return false } +// portRangeMatch returns true if any PortRange in prs contains port. +// A nil entry means "any port" and always matches. +func portRangeMatch(prs []*xnet.PortRange, port int) bool { + for _, pr := range prs { + if pr == nil || pr.Contains(port) { + return true + } + } + return false +} + type cidrMatcher struct { ranger cidranger.Ranger } @@ -134,16 +155,16 @@ type domainMatcher struct { domains map[string]struct{} } -// DomainMatcher creates a Matcher for a list of domains, -// the domain should be a plain domain such as 'example.com', -// or a special pattern '.example.com' that matches 'example.com' -// and any subdomain 'abc.example.com', 'def.abc.example.com' etc. +// DomainMatcher creates a Matcher for a list of domains. +// The domain should be a plain domain such as "example.com", +// or a special pattern ".example.com" that matches "example.com" +// and any subdomain "abc.example.com", "def.abc.example.com" etc. func DomainMatcher(domains []string) Matcher { matcher := &domainMatcher{ domains: make(map[string]struct{}), } for _, domain := range domains { - matcher.domains[domain] = struct{}{} + matcher.domains[strings.ToLower(domain)] = struct{}{} } return matcher } @@ -153,6 +174,8 @@ func (m *domainMatcher) Match(domain string) bool { return false } + domain = strings.ToLower(domain) + if _, ok := m.domains[domain]; ok { return true } @@ -183,8 +206,9 @@ type wildcardMatcher struct { patterns []wildcardMatcherPattern } -// WildcardMatcher creates a Matcher for a specific wildcard domain pattern, -// the pattern can be a wildcard such as '*.exmaple.com', '*.example.com:80', or '*.example.com:0-65535' +// WildcardMatcher creates a Matcher for wildcard domain patterns. +// The pattern can be a wildcard such as "*.example.com", +// "*.example.com:80", or "*.example.com:0-65535". func WildcardMatcher(patterns []string) Matcher { matcher := &wildcardMatcher{} for _, pattern := range patterns { @@ -192,13 +216,17 @@ func WildcardMatcher(patterns []string) Matcher { if host == "" { host = pattern } + g, err := glob.Compile(strings.ToLower(host)) + if err != nil { + continue + } pr := &xnet.PortRange{} if err := pr.Parse(port); err != nil { pr = nil } matcher.patterns = append(matcher.patterns, wildcardMatcherPattern{ - glob: glob.MustCompile(host), + glob: g, pr: pr, }) } @@ -215,6 +243,7 @@ func (m *wildcardMatcher) Match(addr string) bool { if host == "" { host = addr } + host = strings.ToLower(host) port, _ := strconv.Atoi(sp) for _, pattern := range m.patterns { if pattern.glob.Match(host) { @@ -231,6 +260,7 @@ type ipRangeMatcher struct { ranges []xnet.IPRange } +// IPRangeMatcher creates a Matcher for a list of IP ranges (e.g. "192.168.1.1-192.168.1.255"). func IPRangeMatcher(ranges []xnet.IPRange) Matcher { matcher := &ipRangeMatcher{ ranges: ranges, @@ -260,9 +290,9 @@ func (m *ipRangeMatcher) Match(addr string) bool { return false } -type nopMatcher struct { -} +type nopMatcher struct{} +// NopMatcher creates a Matcher that never matches anything. func NopMatcher() Matcher { return &nopMatcher{} } diff --git a/internal/matcher/matcher_test.go b/internal/matcher/matcher_test.go new file mode 100644 index 00000000..a257f8b9 --- /dev/null +++ b/internal/matcher/matcher_test.go @@ -0,0 +1,438 @@ +package matcher + +import ( + "net" + "testing" + + xnet "github.com/go-gost/x/internal/net" +) + +// ---- IPMatcher ---- + +func TestIPMatcher_Match(t *testing.T) { + m := IPMatcher([]net.IP{ + net.ParseIP("192.168.1.1"), + net.ParseIP("10.0.0.1"), + }) + if !m.Match("192.168.1.1") { + t.Error("expected match for 192.168.1.1") + } + if !m.Match("10.0.0.1") { + t.Error("expected match for 10.0.0.1") + } + if m.Match("172.16.0.1") { + t.Error("expected no match for 172.16.0.1") + } + if m.Match("") { + t.Error("expected no match for empty string") + } +} + +func TestIPMatcher_EmptyIPs(t *testing.T) { + m := IPMatcher(nil) + if m.Match("192.168.1.1") { + t.Error("expected no match when no IPs configured") + } +} + +func TestIPMatcher_NilMatcher(t *testing.T) { + var m *ipMatcher + if m.Match("192.168.1.1") { + t.Error("expected no match on nil matcher") + } +} + +func TestIPMatcher_NormalizedIPv6(t *testing.T) { + // Stored in compressed form, lookup with expanded form. + m := IPMatcher([]net.IP{net.ParseIP("2001:db8::1")}) + if !m.Match("2001:db8:0:0:0:0:0:1") { + t.Error("expected match for IPv6 expanded form") + } + if !m.Match("2001:db8::1") { + t.Error("expected match for IPv6 compressed form") + } +} + +func TestIPMatcher_InvalidInput(t *testing.T) { + m := IPMatcher([]net.IP{net.ParseIP("1.2.3.4")}) + if m.Match("not-an-ip") { + t.Error("expected no match for invalid IP string") + } +} + +// ---- AddrMatcher ---- + +func TestAddrMatcher_ExactMatch(t *testing.T) { + m := AddrMatcher([]string{"example.com:80", "192.168.1.1:443"}) + if !m.Match("example.com:80") { + t.Error("expected match for example.com:80") + } + if !m.Match("192.168.1.1:443") { + t.Error("expected match for 192.168.1.1:443") + } + if m.Match("example.com:443") { + t.Error("expected no match for example.com:443") + } + if m.Match("other.com:80") { + t.Error("expected no match for other.com:80") + } +} + +func TestAddrMatcher_PortRange(t *testing.T) { + m := AddrMatcher([]string{"example.com:8000-9000"}) + if !m.Match("example.com:8000") { + t.Error("expected match for port 8000 in range 8000-9000") + } + if !m.Match("example.com:9000") { + t.Error("expected match for port 9000 in range 8000-9000") + } + if !m.Match("example.com:8500") { + t.Error("expected match for port 8500 in range 8000-9000") + } + if m.Match("example.com:7999") { + t.Error("expected no match for port 7999 outside 8000-9000") + } +} + +func TestAddrMatcher_NoPort(t *testing.T) { + m := AddrMatcher([]string{"example.com"}) + if !m.Match("example.com:80") { + t.Error("expected match for any port when no port configured") + } + if !m.Match("example.com:443") { + t.Error("expected match for any port when no port configured") + } + if !m.Match("example.com") { + t.Error("expected match when no port in input") + } +} + +func TestAddrMatcher_Subdomain(t *testing.T) { + m := AddrMatcher([]string{".example.com:80"}) + if !m.Match("example.com:80") { + t.Error("expected match for example.com:80 with .example.com rule") + } + if !m.Match("sub.example.com:80") { + t.Error("expected match for sub.example.com:80 with .example.com rule") + } + if !m.Match("deep.sub.example.com:80") { + t.Error("expected match for deep.sub.example.com:80 with .example.com rule") + } + if m.Match("other.com:80") { + t.Error("expected no match for other.com:80") + } +} + +func TestAddrMatcher_MultiplePortsSameHost(t *testing.T) { + m := AddrMatcher([]string{"example.com:80", "example.com:443"}) + if !m.Match("example.com:80") { + t.Error("expected match for example.com:80 (first entry)") + } + if !m.Match("example.com:443") { + t.Error("expected match for example.com:443 (second entry, should not overwrite first)") + } + if m.Match("example.com:8080") { + t.Error("expected no match for example.com:8080") + } +} + +func TestAddrMatcher_CaseInsensitive(t *testing.T) { + m := AddrMatcher([]string{"Example.COM:80"}) + if !m.Match("EXAMPLE.COM:80") { + t.Error("expected case-insensitive match for EXAMPLE.COM:80") + } + if !m.Match("example.com:80") { + t.Error("expected case-insensitive match for example.com:80") + } +} + +func TestAddrMatcher_CaseInsensitiveSubdomain(t *testing.T) { + m := AddrMatcher([]string{".Example.COM:80"}) + if !m.Match("SUB.EXAMPLE.COM:80") { + t.Error("expected case-insensitive subdomain match") + } + if !m.Match("sub.example.com:80") { + t.Error("expected case-insensitive subdomain match (lowercased)") + } +} + +func TestAddrMatcher_EmptyAddrs(t *testing.T) { + m := AddrMatcher(nil) + if m.Match("example.com:80") { + t.Error("expected no match when no addrs configured") + } +} + +func TestAddrMatcher_NilMatcher(t *testing.T) { + var m *addrMatcher + if m.Match("example.com:80") { + t.Error("expected no match on nil matcher") + } +} + +func TestAddrMatcher_IPv6WithPort(t *testing.T) { + m := AddrMatcher([]string{"[::1]:80"}) + if !m.Match("[::1]:80") { + t.Error("expected match for [::1]:80") + } +} + +// ---- CIDRMatcher ---- + +func TestCIDRMatcher_Match(t *testing.T) { + _, cidr1, _ := net.ParseCIDR("192.168.0.0/16") + m := CIDRMatcher([]*net.IPNet{cidr1}) + if !m.Match("192.168.1.1") { + t.Error("expected match for 192.168.1.1 in 192.168.0.0/16") + } + if !m.Match("192.168.255.255") { + t.Error("expected match for 192.168.255.255 in 192.168.0.0/16") + } + if m.Match("10.0.0.1") { + t.Error("expected no match for 10.0.0.1") + } + if m.Match("not-an-ip") { + t.Error("expected no match for invalid IP") + } +} + +func TestCIDRMatcher_EmptyCIDRs(t *testing.T) { + m := CIDRMatcher(nil) + if m.Match("192.168.1.1") { + t.Error("expected no match when no CIDRs configured") + } +} + +func TestCIDRMatcher_NilMatcher(t *testing.T) { + var m *cidrMatcher + if m.Match("192.168.1.1") { + t.Error("expected no match on nil matcher") + } +} + +// ---- DomainMatcher ---- + +func TestDomainMatcher_ExactMatch(t *testing.T) { + m := DomainMatcher([]string{"example.com", "test.org"}) + if !m.Match("example.com") { + t.Error("expected match for example.com") + } + if !m.Match("test.org") { + t.Error("expected match for test.org") + } + if m.Match("other.com") { + t.Error("expected no match for other.com") + } +} + +func TestDomainMatcher_Subdomain(t *testing.T) { + m := DomainMatcher([]string{".example.com"}) + if !m.Match("example.com") { + t.Error("expected match for example.com with .example.com rule") + } + if !m.Match("sub.example.com") { + t.Error("expected match for sub.example.com with .example.com rule") + } + if !m.Match("deep.sub.example.com") { + t.Error("expected match for deep.sub.example.com") + } + if m.Match("notexample.com") { + t.Error("expected no match for notexample.com (different domain)") + } +} + +func TestDomainMatcher_NestedSubdomain(t *testing.T) { + m := DomainMatcher([]string{".example.com"}) + if !m.Match("a.b.c.example.com") { + t.Error("expected match for deeply nested subdomain") + } +} + +func TestDomainMatcher_CaseInsensitive(t *testing.T) { + m := DomainMatcher([]string{"Example.COM"}) + if !m.Match("EXAMPLE.COM") { + t.Error("expected case-insensitive match for EXAMPLE.COM") + } + if !m.Match("example.com") { + t.Error("expected case-insensitive match for example.com") + } +} + +func TestDomainMatcher_CaseInsensitiveSubdomain(t *testing.T) { + m := DomainMatcher([]string{".Example.COM"}) + if !m.Match("SUB.EXAMPLE.COM") { + t.Error("expected case-insensitive subdomain match") + } + if !m.Match("sub.example.com") { + t.Error("expected case-insensitive subdomain match (lowercased)") + } +} + +func TestDomainMatcher_EmptyDomains(t *testing.T) { + m := DomainMatcher(nil) + if m.Match("example.com") { + t.Error("expected no match when no domains configured") + } +} + +func TestDomainMatcher_NilMatcher(t *testing.T) { + var m *domainMatcher + if m.Match("example.com") { + t.Error("expected no match on nil matcher") + } +} + +// ---- WildcardMatcher ---- + +func TestWildcardMatcher_Match(t *testing.T) { + m := WildcardMatcher([]string{"*.example.com"}) + if !m.Match("foo.example.com") { + t.Error("expected match for foo.example.com") + } + if !m.Match("bar.example.com") { + t.Error("expected match for bar.example.com") + } + if m.Match("example.com") { + t.Error("expected no match for example.com (no wildcard prefix)") + } + if m.Match("foo.other.com") { + t.Error("expected no match for foo.other.com") + } +} + +func TestWildcardMatcher_WithPort(t *testing.T) { + m := WildcardMatcher([]string{"*.example.com:443"}) + if !m.Match("foo.example.com:443") { + t.Error("expected match for foo.example.com:443") + } + if m.Match("foo.example.com:80") { + t.Error("expected no match for foo.example.com:80") + } +} + +func TestWildcardMatcher_WithPortRange(t *testing.T) { + m := WildcardMatcher([]string{"*.example.com:8000-9000"}) + if !m.Match("foo.example.com:8000") { + t.Error("expected match for port 8000") + } + if !m.Match("foo.example.com:8500") { + t.Error("expected match for port 8500") + } + if m.Match("foo.example.com:7999") { + t.Error("expected no match for port 7999") + } +} + +func TestWildcardMatcher_CaseInsensitive(t *testing.T) { + m := WildcardMatcher([]string{"*.Example.COM"}) + if !m.Match("FOO.EXAMPLE.COM") { + t.Error("expected case-insensitive match for FOO.EXAMPLE.COM") + } + if !m.Match("foo.example.com") { + t.Error("expected case-insensitive match for foo.example.com") + } +} + +func TestWildcardMatcher_InvalidPattern(t *testing.T) { + // MustCompile would panic on this; Compile should just skip it. + m := WildcardMatcher([]string{"[invalid-glob"}) + if m.Match("anything") { + t.Error("expected no match for invalid pattern") + } +} + +func TestWildcardMatcher_EmptyPatterns(t *testing.T) { + m := WildcardMatcher(nil) + if m.Match("foo.example.com") { + t.Error("expected no match when no patterns configured") + } +} + +func TestWildcardMatcher_NilMatcher(t *testing.T) { + var m *wildcardMatcher + if m.Match("foo.example.com") { + t.Error("expected no match on nil matcher") + } +} + +// ---- IPRangeMatcher ---- + +func TestIPRangeMatcher_Match(t *testing.T) { + r := xnet.IPRange{} + if err := r.Parse("192.168.1.1-192.168.1.10"); err != nil { + t.Fatal(err) + } + m := IPRangeMatcher([]xnet.IPRange{r}) + if !m.Match("192.168.1.1") { + t.Error("expected match for 192.168.1.1 in range") + } + if !m.Match("192.168.1.5") { + t.Error("expected match for 192.168.1.5 in range") + } + if !m.Match("192.168.1.10") { + t.Error("expected match for 192.168.1.10 in range") + } + if m.Match("192.168.1.11") { + t.Error("expected no match for 192.168.1.11") + } + if m.Match("10.0.0.1") { + t.Error("expected no match for 10.0.0.1") + } +} + +func TestIPRangeMatcher_WithPort(t *testing.T) { + r := xnet.IPRange{} + if err := r.Parse("192.168.1.1-192.168.1.10"); err != nil { + t.Fatal(err) + } + m := IPRangeMatcher([]xnet.IPRange{r}) + if !m.Match("192.168.1.1:8080") { + t.Error("expected match for 192.168.1.1:8080 (port stripped)") + } +} + +func TestIPRangeMatcher_EmptyRanges(t *testing.T) { + m := IPRangeMatcher(nil) + if m.Match("192.168.1.1") { + t.Error("expected no match when no ranges configured") + } +} + +func TestIPRangeMatcher_NilMatcher(t *testing.T) { + var m *ipRangeMatcher + if m.Match("192.168.1.1") { + t.Error("expected no match on nil matcher") + } +} + +// ---- NopMatcher ---- + +func TestNopMatcher_Match(t *testing.T) { + m := NopMatcher() + if m.Match("anything") { + t.Error("nopMatcher should always return false") + } + if m.Match("") { + t.Error("nopMatcher should always return false for empty string") + } +} + +func TestNopMatcher_NilMatcher(t *testing.T) { + var m *nopMatcher + if m.Match("anything") { + t.Error("expected no match on nil matcher") + } +} + +// ---- Interface compliance ---- + +func TestMatcherInterface(t *testing.T) { + // All constructors must return Matcher interface values. + var _ Matcher = IPMatcher(nil) + var _ Matcher = AddrMatcher(nil) + var _ Matcher = CIDRMatcher(nil) + var _ Matcher = DomainMatcher(nil) + var _ Matcher = WildcardMatcher(nil) + var _ Matcher = IPRangeMatcher(nil) + var _ Matcher = NopMatcher() +} diff --git a/internal/net/addr.go b/internal/net/addr.go index a312c8d4..d55e15f6 100644 --- a/internal/net/addr.go +++ b/internal/net/addr.go @@ -8,6 +8,12 @@ import ( "strings" ) +// ParseInterfaceAddr resolves ifceName to a network interface name and its addresses. +// If ifceName is an IP address, it finds the corresponding interface and returns +// a single address with port 0. If ifceName is an interface name, it returns all +// addresses assigned to that interface. The network parameter determines the address +// type (TCPAddr for "tcp"/"tcp4"/"tcp6", UDPAddr for "udp"/"udp4"/"udp6", IPAddr +// otherwise). func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr, err error) { if ifceName == "" { addr = append(addr, nil) @@ -90,6 +96,7 @@ func findInterfaceByIP(ip net.IP) (string, error) { // e.g. 192.168.1.1:0-65535 type AddrPortRange string +// Addrs expands the port range into individual "host:port" strings. func (p AddrPortRange) Addrs() (addrs []string) { // ignore url scheme, e.g. http://, tls://, tcp://. if strings.Contains(string(p), "://") { @@ -151,15 +158,18 @@ func (pr *PortRange) Parse(s string) error { } } +// Contains reports whether port falls within the range. func (pr *PortRange) Contains(port int) bool { return port >= pr.Min && port <= pr.Max } +// IPRange represents a range of IP addresses. type IPRange struct { Min netip.Addr Max netip.Addr } +// Parse parses s as a single IP or IP range "min-max" into r. func (r *IPRange) Parse(s string) error { minmax := strings.Split(s, "-") switch len(minmax) { @@ -190,6 +200,7 @@ func (r *IPRange) Parse(s string) error { } } +// Contains reports whether addr falls within the IP range. func (r *IPRange) Contains(addr netip.Addr) bool { return !(addr.Less(r.Min) || r.Max.Less(addr)) } diff --git a/internal/net/dialer/dialer.go b/internal/net/dialer/dialer.go index b9a14ff6..5128667d 100644 --- a/internal/net/dialer/dialer.go +++ b/internal/net/dialer/dialer.go @@ -16,13 +16,18 @@ import ( ) const ( + // DefaultTimeout is the default dial timeout. DefaultTimeout = 10 * time.Second ) +// DefaultNetDialer is the default Dialer used when a nil Dialer is provided. var ( DefaultNetDialer = &Dialer{} ) +// Dialer is a network dialer with support for interface binding, network +// namespace switching, and socket marking. The zero value is ready to use +// via DefaultNetDialer. type Dialer struct { Interface string Netns string @@ -31,6 +36,10 @@ type Dialer struct { Log logger.Logger } +// Dial connects to addr on the named network. If d is nil, DefaultNetDialer +// is used. When Netns is set, the dial switches into that network namespace +// before creating the connection. When Interface is set, it iterates the +// specified interfaces, trying each address in turn. func (d *Dialer) Dial(ctx context.Context, network, addr string) (conn net.Conn, err error) { if d == nil { d = DefaultNetDialer diff --git a/internal/net/http/http.go b/internal/net/http/http.go index 39b96bfa..52b57086 100644 --- a/internal/net/http/http.go +++ b/internal/net/http/http.go @@ -8,6 +8,8 @@ import ( "strings" ) +// GetClientIP extracts the client IP from the request, checking CF-Connecting-IP, +// X-Forwarded-For, and X-Real-Ip headers in that order. func GetClientIP(req *http.Request) net.IP { if req == nil { return nil @@ -27,6 +29,7 @@ func GetClientIP(req *http.Request) net.IP { return net.ParseIP(sip) } +// Body wraps an io.ReadCloser with size-limited recording of the data read. type Body struct { r io.ReadCloser buf bytes.Buffer @@ -34,6 +37,7 @@ type Body struct { recordSize int } +// NewBody creates a Body that reads from r and records up to maxRecordSize bytes. func NewBody(r io.ReadCloser, maxRecordSize int) *Body { p := &Body{ r: r, @@ -63,10 +67,12 @@ func (p *Body) Close() error { return p.r.Close() } +// Content returns the recorded content. func (p *Body) Content() []byte { return p.buf.Bytes() } +// Length returns the total number of bytes read from the underlying reader. func (p *Body) Length() int64 { return p.length } diff --git a/internal/net/ip/ip.go b/internal/net/ip/ip.go index 1dcdfb6f..e6d6594d 100644 --- a/internal/net/ip/ip.go +++ b/internal/net/ip/ip.go @@ -18,6 +18,7 @@ var prots = map[waterutil.IPProtocol]string{ waterutil.IPv6_ICMP: "IPv6-ICMP", } +// Protocol returns the string name for the given IP protocol number. func Protocol(p waterutil.IPProtocol) string { if v, ok := prots[p]; ok { return v diff --git a/internal/net/net.go b/internal/net/net.go index 22483ce6..3aac05bd 100644 --- a/internal/net/net.go +++ b/internal/net/net.go @@ -13,33 +13,41 @@ import ( "github.com/vishvananda/netns" ) +// SetBuffer is a connection that supports setting send and receive buffer sizes. type SetBuffer interface { SetReadBuffer(bytes int) error SetWriteBuffer(bytes int) error } +// SyscallConn is a connection that exposes its raw system file descriptor. type SyscallConn interface { SyscallConn() (syscall.RawConn, error) } +// RemoteAddr is a connection that exposes its remote address. type RemoteAddr interface { RemoteAddr() net.Addr } -// tcpraw.TCPConn +// SetDSCP is a connection that supports setting the DSCP field. type SetDSCP interface { SetDSCP(int) error } +// IsIPv4 reports whether address is an IPv4 address by checking whether +// it starts with ':' or '[' (IPv6) vs a digit (IPv4). func IsIPv4(address string) bool { return address != "" && address[0] != ':' && address[0] != '[' } +// ListenConfig extends net.ListenConfig with network namespace support. type ListenConfig struct { Netns string net.ListenConfig } +// Listen announces on the local network address, switching into the configured +// network namespace first if one is set. func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (net.Listener, error) { if lc.Netns != "" { runtime.LockOSThread() @@ -70,6 +78,8 @@ func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (ne return lc.ListenConfig.Listen(ctx, network, address) } +// ListenPacket announces on the local network address for packet connections, +// switching into the configured network namespace first if one is set. func (lc *ListenConfig) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) { if lc.Netns != "" { runtime.LockOSThread() @@ -105,6 +115,9 @@ type readWriteConn struct { w io.Writer } +// NewReadWriteConn returns a net.Conn that reads from r and writes to w, +// delegating all other operations to c. If c supports CloseRead or CloseWrite, +// those are forwarded; otherwise they return ErrUnsupported. func NewReadWriteConn(r io.Reader, w io.Writer, c net.Conn) net.Conn { return &readWriteConn{ Conn: c, diff --git a/internal/net/pipe.go b/internal/net/pipe.go index 9a20bac7..d2303b80 100644 --- a/internal/net/pipe.go +++ b/internal/net/pipe.go @@ -31,7 +31,11 @@ func WithReadTimeout(d time.Duration) PipeOption { } } -// Pipe 在两个连接之间建立双向数据通道 +// Pipe establishes a bidirectional data channel between two connections. +// Data read from rw1 is written to rw2, and data read from rw2 is written to +// rw1. Each direction runs in its own goroutine. Pipe returns the first +// non-EOF error encountered, or nil if both directions complete cleanly. +// When ctx is cancelled, both connections are forcefully closed. func Pipe(ctx context.Context, rw1, rw2 io.ReadWriteCloser, opts ...PipeOption) error { var options pipeOptions for _, opt := range opts { diff --git a/internal/net/proxyproto/conn.go b/internal/net/proxyproto/conn.go index c7caa4e0..9c57919b 100644 --- a/internal/net/proxyproto/conn.go +++ b/internal/net/proxyproto/conn.go @@ -56,6 +56,9 @@ func (c *serverConn) CloseWrite() error { return xio.ErrUnsupported } +// WrapClientConn writes a PROXY protocol header to c using src and dst +// addresses, then returns c for continued use. If ppv is <= 0 or either +// address is nil, the connection is returned unchanged. func WrapClientConn(ppv int, src, dst net.Addr, c net.Conn) net.Conn { if ppv <= 0 || c == nil { return c diff --git a/internal/net/proxyproto/listener.go b/internal/net/proxyproto/listener.go index 497b3b80..bcebcde5 100644 --- a/internal/net/proxyproto/listener.go +++ b/internal/net/proxyproto/listener.go @@ -32,6 +32,8 @@ func (ln *listener) Accept() (net.Conn, error) { return &serverConn{Conn: conn, ctx: innerCtx}, nil } +// WrapListener wraps ln with a PROXY protocol listener that parses the PROXY +// header on each accepted connection. If ppv <= 0, ln is returned unchanged. func WrapListener(ppv int, ln net.Listener, readHeaderTimeout time.Duration) net.Listener { if ppv <= 0 { return ln diff --git a/internal/net/resovle.go b/internal/net/resovle.go index b7192241..96ee95ab 100644 --- a/internal/net/resovle.go +++ b/internal/net/resovle.go @@ -11,6 +11,9 @@ import ( ctxvalue "github.com/go-gost/x/ctx" ) +// Resolve resolves addr to a concrete IP address. If hosts is non-nil, it is +// consulted first. If r is non-nil and no host mapping matched, the resolver +// is used. If neither is available, addr is returned unchanged. func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hosts hosts.HostMapper, log logger.Logger) (string, error) { if addr == "" { return addr, nil diff --git a/internal/net/transport.go b/internal/net/transport.go index e69d7cf9..5e15d758 100644 --- a/internal/net/transport.go +++ b/internal/net/transport.go @@ -10,6 +10,8 @@ const ( bufferSize = 64 * 1024 ) +// Transport copies data bidirectionally between rw1 and rw2 using two +// goroutines. It returns the first non-EOF error encountered. func Transport(rw1, rw2 io.ReadWriter) error { errc := make(chan error, 2) go func() { @@ -27,6 +29,8 @@ func Transport(rw1, rw2 io.ReadWriter) error { return nil } +// CopyBuffer copies from src to dst using a buffer of bufSize obtained from +// the buffer pool, reducing allocation overhead for repeated copies. func CopyBuffer(dst io.Writer, src io.Reader, bufSize int) error { buf := bufpool.Get(bufSize) defer bufpool.Put(buf) diff --git a/internal/net/udp/conn.go b/internal/net/udp/conn.go index c670b788..6b2cc228 100644 --- a/internal/net/udp/conn.go +++ b/internal/net/udp/conn.go @@ -7,6 +7,8 @@ import ( xnet "github.com/go-gost/x/internal/net" ) +// Conn is a net.PacketConn that also supports io.Reader, io.Writer, buffer +// sizing, syscall access, and remote address exposure. type Conn interface { net.PacketConn io.Reader @@ -18,11 +20,13 @@ type Conn interface { xnet.RemoteAddr } +// ReadUDP supports reading UDP datagrams with metadata. type ReadUDP interface { ReadFromUDP(b []byte) (n int, addr *net.UDPAddr, err error) ReadMsgUDP(b, oob []byte) (n, oobn, flags int, addr *net.UDPAddr, err error) } +// WriteUDP supports writing UDP datagrams with metadata. type WriteUDP interface { WriteToUDP(b []byte, addr *net.UDPAddr) (int, error) WriteMsgUDP(b, oob []byte, addr *net.UDPAddr) (n, oobn int, err error) diff --git a/internal/net/udp/listener.go b/internal/net/udp/listener.go index 09c338f6..e2a2acea 100644 --- a/internal/net/udp/listener.go +++ b/internal/net/udp/listener.go @@ -11,6 +11,7 @@ import ( "github.com/go-gost/core/logger" ) +// ListenConfig configures a UDP-to-TCP-stream listener. type ListenConfig struct { Addr net.Addr Backlog int @@ -29,6 +30,9 @@ type listener struct { config *ListenConfig } +// NewListener creates a net.Listener from a net.PacketConn by demultiplexing +// UDP datagrams into per-client net.Conn streams. Idle connections are cleaned +// up according to cfg.TTL. func NewListener(conn net.PacketConn, cfg *ListenConfig) net.Listener { if cfg == nil { cfg = &ListenConfig{} diff --git a/internal/net/udp/relay.go b/internal/net/udp/relay.go index 5c9e510b..7b874acd 100644 --- a/internal/net/udp/relay.go +++ b/internal/net/udp/relay.go @@ -13,6 +13,7 @@ const ( defaultBufferSize = 4096 ) +// Relay copies UDP datagrams between two net.PacketConns bidirectionally. type Relay struct { service string pc1 net.PacketConn @@ -22,6 +23,7 @@ type Relay struct { logger logger.Logger } +// NewRelay creates a Relay that copies datagrams between pc1 and pc2. func NewRelay(pc1, pc2 net.PacketConn) *Relay { return &Relay{ pc1: pc1, @@ -29,26 +31,31 @@ func NewRelay(pc1, pc2 net.PacketConn) *Relay { } } +// WithService sets the service name for bypass matching. func (r *Relay) WithService(service string) *Relay { r.service = service return r } +// WithBypass sets the bypass matcher to skip certain addresses. func (r *Relay) WithBypass(bp bypass.Bypass) *Relay { r.bypass = bp return r } +// WithLogger sets the logger. func (r *Relay) WithLogger(logger logger.Logger) *Relay { r.logger = logger return r } +// WithBufferSize sets the buffer size for copy operations. func (r *Relay) WithBufferSize(n int) *Relay { r.bufferSize = n return r } +// Run starts the relay. It blocks until an error occurs or ctx is cancelled. func (r *Relay) Run(ctx context.Context) (err error) { errc := make(chan error, 2) diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index a4993636..7c66a5d1 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -1,3 +1,6 @@ +// Package plugin provides shared utilities for building gRPC and HTTP plugin +// clients. Both transport types support authentication tokens, TLS +// configuration, custom headers (HTTP), and connection timeouts. package plugin import ( @@ -13,10 +16,13 @@ import ( ) const ( + // GRPC is the transport type identifier for gRPC-based plugins. GRPC string = "grpc" + // HTTP is the transport type identifier for HTTP-based plugins. HTTP string = "http" ) +// Options holds the common configuration for gRPC and HTTP plugin clients. type Options struct { Token string TLSConfig *tls.Config @@ -24,32 +30,40 @@ type Options struct { Timeout time.Duration } +// Option configures Options. type Option func(opts *Options) +// TokenOption sets the authentication token sent with each request. func TokenOption(token string) Option { return func(opts *Options) { opts.Token = token } } +// TLSConfigOption sets the TLS configuration for transport security. func TLSConfigOption(cfg *tls.Config) Option { return func(opts *Options) { opts.TLSConfig = cfg } } +// HeaderOption sets custom HTTP headers sent with each request (HTTP transport only). func HeaderOption(header http.Header) Option { return func(opts *Options) { opts.Header = header } } +// TimeoutOption sets the request timeout (HTTP transport only). func TimeoutOption(timeout time.Duration) Option { return func(opts *Options) { opts.Timeout = timeout } } +// NewGRPCConn creates a gRPC client connection to addr. If opts is nil or +// opts.TLSConfig is nil, the connection uses insecure transport. When +// opts.Token is set, it is attached as per-RPC credentials. func NewGRPCConn(addr string, opts *Options) (*grpc.ClientConn, error) { grpcOpts := []grpc.DialOption{ // grpc.WithBlock(), @@ -57,6 +71,10 @@ func NewGRPCConn(addr string, opts *Options) (*grpc.ClientConn, error) { Backoff: backoff.DefaultConfig, }), } + if opts == nil { + grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + return grpc.NewClient(addr, grpcOpts...) + } if opts.TLSConfig != nil { grpcOpts = append(grpcOpts, grpc.WithAuthority(opts.TLSConfig.ServerName), @@ -85,7 +103,14 @@ func (c *rpcCredentials) RequireTransportSecurity() bool { return false } +// NewHTTPClient creates an http.Client from opts. If opts is nil, a default +// client with no timeout is returned. Use HTTPClientTransport to access the +// underlying transport and close idle connections when the client is no longer +// needed. func NewHTTPClient(opts *Options) *http.Client { + if opts == nil { + return &http.Client{} + } return &http.Client{ Timeout: opts.Timeout, Transport: &http.Transport{ @@ -93,3 +118,15 @@ func NewHTTPClient(opts *Options) *http.Client { }, } } + +// HTTPClientTransport returns the *http.Transport underlying c, or nil if c's +// transport is not an *http.Transport. Callers that hold the client for a +// long time should call tr.CloseIdleConnections() when the client is no longer +// needed. +func HTTPClientTransport(c *http.Client) *http.Transport { + if c == nil { + return nil + } + tr, _ := c.Transport.(*http.Transport) + return tr +} diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go new file mode 100644 index 00000000..b890d65b --- /dev/null +++ b/internal/plugin/plugin_test.go @@ -0,0 +1,112 @@ +package plugin + +import ( + "net/http" + "testing" + "time" +) + +func TestNewGRPCConn_NilOpts(t *testing.T) { + conn, err := NewGRPCConn("localhost:0", nil) + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("expected non-nil conn") + } + conn.Close() +} + +func TestNewGRPCConn_NoTLS(t *testing.T) { + conn, err := NewGRPCConn("localhost:0", &Options{}) + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("expected non-nil conn") + } + conn.Close() +} + +func TestNewGRPCConn_WithTLS(t *testing.T) { + conn, err := NewGRPCConn("localhost:0", &Options{ + TLSConfig: nil, // nil TLSConfig -> insecure + }) + if err != nil { + t.Fatal(err) + } + if conn == nil { + t.Fatal("expected non-nil conn") + } + conn.Close() +} + +func TestNewHTTPClient_NilOpts(t *testing.T) { + c := NewHTTPClient(nil) + if c == nil { + t.Fatal("expected non-nil client") + } +} + +func TestNewHTTPClient_Defaults(t *testing.T) { + c := NewHTTPClient(&Options{}) + if c == nil { + t.Fatal("expected non-nil client") + } + if c.Timeout != 0 { + t.Errorf("expected zero timeout, got %v", c.Timeout) + } +} + +func TestNewHTTPClient_WithTimeout(t *testing.T) { + c := NewHTTPClient(&Options{Timeout: 5 * time.Second}) + if c.Timeout != 5*time.Second { + t.Errorf("expected 5s timeout, got %v", c.Timeout) + } +} + +func TestHTTPClientTransport_Nil(t *testing.T) { + if tr := HTTPClientTransport(nil); tr != nil { + t.Error("expected nil transport for nil client") + } +} + +func TestHTTPClientTransport_Valid(t *testing.T) { + c := NewHTTPClient(&Options{}) + tr := HTTPClientTransport(c) + if tr == nil { + t.Fatal("expected non-nil transport") + } + // Verify we can call CloseIdleConnections without panic. + tr.CloseIdleConnections() +} + +func TestOption(t *testing.T) { + opts := &Options{} + TokenOption("tok")(opts) + TLSConfigOption(nil)(opts) + HeaderOption(http.Header{"X": []string{"y"}})(opts) + TimeoutOption(time.Second)(opts) + + if opts.Token != "tok" { + t.Errorf("expected Token 'tok', got %q", opts.Token) + } + if opts.Timeout != time.Second { + t.Errorf("expected 1s timeout, got %v", opts.Timeout) + } + if opts.Header.Get("X") != "y" { + t.Errorf("expected header X=y, got %v", opts.Header) + } +} + +func TestNewHTTPClient_TransportTLSConfig(t *testing.T) { + // nil TLSConfig — transport should still exist. + c := NewHTTPClient(&Options{}) + tr := HTTPClientTransport(c) + if tr == nil { + t.Fatal("expected non-nil transport") + } + if tr.TLSClientConfig != nil { + t.Error("expected nil TLSClientConfig") + } +}