fix(matcher,plugin): case-insensitive matching, port accumulation, nil guards, doc comments

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.
This commit is contained in:
ginuerzh
2026-05-24 18:18:42 +08:00
parent 51455da96f
commit 0e96f602fa
17 changed files with 719 additions and 31 deletions
+59 -29
View File
@@ -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{}
}
+438
View File
@@ -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()
}
+11
View File
@@ -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))
}
+9
View File
@@ -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
+6
View File
@@ -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
}
+1
View File
@@ -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
+14 -1
View File
@@ -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,
+5 -1
View File
@@ -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 {
+3
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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
+4
View File
@@ -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)
+4
View File
@@ -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)
+4
View File
@@ -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{}
+7
View File
@@ -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)
+37
View File
@@ -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
}
+112
View File
@@ -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")
}
}