diff --git a/hop/hop.go b/hop/hop.go index 177d79c4..73fc36f7 100644 --- a/hop/hop.go +++ b/hop/hop.go @@ -1,8 +1,13 @@ +// Package hop provides a hop (node group) implementation that selects a node +// from a group of proxy nodes using load-balancing strategies, filters, and +// bypass rules. It supports periodic reloading of node lists from file, Redis, +// or HTTP sources. package hop import ( "context" "encoding/json" + "errors" "io" "net" "sort" @@ -34,54 +39,66 @@ type options struct { logger logger.Logger } +// Option configures a hop. type Option func(*options) +// NameOption sets the hop name. func NameOption(name string) Option { return func(o *options) { o.name = name } } +// NodeOption sets the initial node list for the hop. func NodeOption(nodes ...*chain.Node) Option { return func(o *options) { o.nodes = nodes } } + +// BypassOption sets a hop-level bypass that can skip the entire hop. func BypassOption(bp bypass.Bypass) Option { return func(o *options) { o.bypass = bp } } +// SelectorOption sets the load-balancing strategy for node selection. func SelectorOption(s selector.Selector[*chain.Node]) Option { return func(o *options) { o.selector = s } } +// ReloadPeriodOption sets the interval for periodic reloading of node lists. func ReloadPeriodOption(period time.Duration) Option { return func(opts *options) { opts.period = period } } +// FileLoaderOption sets a loader that reads node configs from a file source. func FileLoaderOption(fileLoader loader.Loader) Option { return func(opts *options) { opts.fileLoader = fileLoader } } +// RedisLoaderOption sets a loader that reads node configs from a Redis source. func RedisLoaderOption(redisLoader loader.Loader) Option { return func(opts *options) { opts.redisLoader = redisLoader } } +// HTTPLoaderOption sets a loader that reads node configs from an HTTP source. func HTTPLoaderOption(httpLoader loader.Loader) Option { return func(opts *options) { opts.httpLoader = httpLoader } } + +// LoggerOption sets the logger for the hop. func LoggerOption(logger logger.Logger) Option { return func(opts *options) { opts.logger = logger @@ -96,6 +113,7 @@ type chainHop struct { cancelFunc context.CancelFunc } +// NewHop creates a new hop with the given options and starts periodic reloading. func NewHop(opts ...Option) hop.Hop { var options options for _, opt := range opts { @@ -308,20 +326,31 @@ func (p *chainHop) reload(ctx context.Context) (err error) { } func (p *chainHop) load(ctx context.Context) (nodes []*chain.Node, err error) { + var errs []error + if loader := p.options.fileLoader; loader != nil { r, er := loader.Load(ctx) if er != nil { p.logger.Warnf("file loader: %v", er) + errs = append(errs, er) } - nodes, _ = p.parseNode(r) + ns, pe := p.parseNode(r) + if pe != nil { + errs = append(errs, pe) + } + nodes = append(nodes, ns...) } if loader := p.options.redisLoader; loader != nil { r, er := loader.Load(ctx) if er != nil { p.logger.Warnf("redis loader: %v", er) + errs = append(errs, er) + } + ns, pe := p.parseNode(r) + if pe != nil { + errs = append(errs, pe) } - ns, _ := p.parseNode(r) nodes = append(nodes, ns...) } @@ -329,13 +358,16 @@ func (p *chainHop) load(ctx context.Context) (nodes []*chain.Node, err error) { r, er := loader.Load(ctx) if er != nil { p.logger.Warnf("http loader: %v", er) + errs = append(errs, er) } - if ns, _ := p.parseNode(r); ns != nil { - nodes = append(nodes, ns...) + ns, pe := p.parseNode(r) + if pe != nil { + errs = append(errs, pe) } + nodes = append(nodes, ns...) } - return + return nodes, errors.Join(errs...) } func (p *chainHop) parseNode(r io.Reader) ([]*chain.Node, error) { @@ -348,7 +380,10 @@ func (p *chainHop) parseNode(r io.Reader) ([]*chain.Node, error) { return nil, err } - var nodes []*chain.Node + var ( + nodes []*chain.Node + errs []error + ) for _, nc := range ncs { if nc == nil { continue @@ -356,11 +391,13 @@ func (p *chainHop) parseNode(r io.Reader) ([]*chain.Node, error) { node, err := node_parser.ParseNode(p.options.name, nc, logger.Default()) if err != nil { - return nodes, err + p.logger.Warnf("skip node %s: %v", nc.Name, err) + errs = append(errs, err) + continue } nodes = append(nodes, node) } - return nodes, nil + return nodes, errors.Join(errs...) } func (p *chainHop) Close() error { @@ -371,5 +408,8 @@ func (p *chainHop) Close() error { if p.options.redisLoader != nil { p.options.redisLoader.Close() } + if p.options.httpLoader != nil { + p.options.httpLoader.Close() + } return nil } diff --git a/hop/hop_test.go b/hop/hop_test.go new file mode 100644 index 00000000..ca8bebfa --- /dev/null +++ b/hop/hop_test.go @@ -0,0 +1,1323 @@ +package hop + +import ( + "context" + "errors" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/go-gost/core/bypass" + "github.com/go-gost/core/chain" + "github.com/go-gost/core/connector" + "github.com/go-gost/core/dialer" + "github.com/go-gost/core/hop" + "github.com/go-gost/core/logger" + "github.com/go-gost/core/metadata" + "github.com/go-gost/core/routing" + "github.com/go-gost/x/registry" + xlogger "github.com/go-gost/x/logger" +) + +// --- Mock types --- + +type testBypass struct { + whitelist bool + contains bool +} + +func (b *testBypass) IsWhitelist() bool { return b.whitelist } +func (b *testBypass) Init(md metadata.Metadata) error { return nil } +func (b *testBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool { + return b.contains +} + +// testNodeSelector implements selector.Selector[*chain.Node]. +type testNodeSelector struct { + selectedIdx int + callCount int +} + +func (s *testNodeSelector) Select(ctx context.Context, nodes ...*chain.Node) *chain.Node { + if len(nodes) == 0 { + return nil + } + idx := s.selectedIdx % len(nodes) + s.callCount++ + return nodes[idx] +} + +type testMatcher struct { + match bool +} + +func (m *testMatcher) Match(req *routing.Request) bool { return m.match } + +type requestCapturingMatcher struct { + capture **routing.Request + match bool +} + +func (m *requestCapturingMatcher) Match(req *routing.Request) bool { + if m.capture != nil { + *m.capture = req + } + return m.match +} + +type testLoader struct { + loadFn func(ctx context.Context) (io.Reader, error) + closeFn func() error +} + +func (l *testLoader) Load(ctx context.Context) (io.Reader, error) { + if l.loadFn != nil { + return l.loadFn(ctx) + } + return nil, nil +} + +func (l *testLoader) Close() error { + if l.closeFn != nil { + return l.closeFn() + } + return nil +} + +type stubConnector struct{} + +func (c *stubConnector) Init(md metadata.Metadata) error { return nil } + +func (c *stubConnector) Connect(ctx context.Context, conn net.Conn, network, address string, opts ...connector.ConnectOption) (net.Conn, error) { + return conn, nil +} + +func newStubConnector(opts ...connector.Option) connector.Connector { + return &stubConnector{} +} + +func newStubDialer(opts ...dialer.Option) dialer.Dialer { + return &stubDialer{} +} + +type stubDialer struct{} + +func (d *stubDialer) Init(md metadata.Metadata) error { return nil } + +func (d *stubDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) { + return &stubConn{}, nil +} + +type stubConn struct{} + +func (c *stubConn) Read(b []byte) (n int, err error) { return 0, io.EOF } +func (c *stubConn) Write(b []byte) (n int, err error) { return len(b), nil } +func (c *stubConn) Close() error { return nil } +func (c *stubConn) LocalAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} } +func (c *stubConn) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} } +func (c *stubConn) SetDeadline(t time.Time) error { return nil } +func (c *stubConn) SetReadDeadline(t time.Time) error { return nil } +func (c *stubConn) SetWriteDeadline(t time.Time) error { return nil } + +func init() { + logger.SetDefault(xlogger.Nop()) + registry.ConnectorRegistry().Register("http", newStubConnector) + registry.ConnectorRegistry().Register("socks5", newStubConnector) + registry.DialerRegistry().Register("tcp", newStubDialer) +} + +// Helper to create a hop and return *chainHop for Close access. +func newTestHop(opts ...Option) *chainHop { + return NewHop(opts...).(*chainHop) +} + +// ============================================================================= +// Option tests +// ============================================================================= + +func TestNameOption(t *testing.T) { + var o options + NameOption("my-hop")(&o) + if o.name != "my-hop" { + t.Errorf("expected name 'my-hop', got %q", o.name) + } +} + +func TestNodeOption(t *testing.T) { + var o options + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n2 := chain.NewNode("n2", "127.0.0.1:9090") + NodeOption(n1, n2)(&o) + if len(o.nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(o.nodes)) + } + if o.nodes[0].Name != "n1" || o.nodes[1].Name != "n2" { + t.Error("unexpected node content") + } +} + +func TestBypassOption(t *testing.T) { + var o options + bp := &testBypass{} + BypassOption(bp)(&o) + if o.bypass != bp { + t.Error("bypass not set correctly") + } +} + +func TestSelectorOption(t *testing.T) { + var o options + sel := &testNodeSelector{} + SelectorOption(sel)(&o) + if o.selector != sel { + t.Error("selector not set correctly") + } +} + +func TestReloadPeriodOption(t *testing.T) { + var o options + ReloadPeriodOption(5 * time.Second)(&o) + if o.period != 5*time.Second { + t.Errorf("expected period 5s, got %v", o.period) + } +} + +func TestFileLoaderOption(t *testing.T) { + var o options + ld := &testLoader{} + FileLoaderOption(ld)(&o) + if o.fileLoader != ld { + t.Error("fileLoader not set correctly") + } +} + +func TestRedisLoaderOption(t *testing.T) { + var o options + ld := &testLoader{} + RedisLoaderOption(ld)(&o) + if o.redisLoader != ld { + t.Error("redisLoader not set correctly") + } +} + +func TestHTTPLoaderOption(t *testing.T) { + var o options + ld := &testLoader{} + HTTPLoaderOption(ld)(&o) + if o.httpLoader != ld { + t.Error("httpLoader not set correctly") + } +} + +func TestLoggerOption(t *testing.T) { + var o options + l := xlogger.Nop() + LoggerOption(l)(&o) + if o.logger != l { + t.Error("logger not set correctly") + } +} + +// ============================================================================= +// NewHop tests +// ============================================================================= + +func TestNewHop_Empty(t *testing.T) { + h := newTestHop() + defer h.Close() + + nodes := h.Nodes() + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestNewHop_WithNodes(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n2 := chain.NewNode("n2", "127.0.0.1:9090") + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + nodes := h.Nodes() + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(nodes)) + } +} + +func TestNewHop_NilLoggerDefaultsToNop(t *testing.T) { + h := newTestHop() + defer h.Close() + if h.logger == nil { + t.Error("expected non-nil logger (should default to Nop)") + } +} + +func TestNewHop_WithLogger(t *testing.T) { + l := xlogger.Nop() + h := newTestHop(LoggerOption(l)) + defer h.Close() + if h.logger != l { + t.Error("logger not set on chainHop") + } +} + +func TestNewHop_NilOptionIgnored(t *testing.T) { + h := newTestHop(nil) + defer h.Close() + if h == nil { + t.Fatal("NewHop returned nil") + } +} + +// ============================================================================= +// Nodes tests +// ============================================================================= + +func TestNodes_NilReceiver(t *testing.T) { + var ch *chainHop + nodes := ch.Nodes() + if nodes != nil { + t.Error("expected nil from nil receiver") + } +} + +func TestNodes_Empty(t *testing.T) { + ch := &chainHop{} + nodes := ch.Nodes() + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestNodes_WithData(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + ch := &chainHop{nodes: []*chain.Node{n1}} + nodes := ch.Nodes() + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + if nodes[0].Name != "n1" { + t.Errorf("expected 'n1', got %q", nodes[0].Name) + } +} + +// ============================================================================= +// Select tests +// ============================================================================= + +func TestSelect_EmptyNodes(t *testing.T) { + h := newTestHop() + defer h.Close() + + node := h.Select(context.Background()) + if node != nil { + t.Error("expected nil from empty hop") + } +} + +func TestSelect_SingleNode(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n1" { + t.Errorf("expected 'n1', got %q", node.Name) + } +} + +func TestSelect_HopBypass_BlocksAll(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + h := newTestHop( + NodeOption(n1), + BypassOption(&testBypass{contains: true}), + ) + defer h.Close() + + node := h.Select(context.Background()) + if node != nil { + t.Error("expected nil when hop-level bypass matches") + } +} + +func TestSelect_HopBypass_Allows(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + h := newTestHop( + NodeOption(n1), + BypassOption(&testBypass{contains: false}), + ) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Error("expected node when bypass does not match") + } +} + +func TestSelect_NodeBypass_SkipsBlockedNode(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.BypassNodeOption(&testBypass{contains: true}), + ) + n2 := chain.NewNode("n2", "127.0.0.1:9090") + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n2" { + t.Errorf("expected 'n2' (n1 blocked), got %q", node.Name) + } +} + +func TestSelect_NodeBypass_AllNodesBlocked(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.BypassNodeOption(&testBypass{contains: true}), + ) + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background()) + if node != nil { + t.Error("expected nil when all nodes blocked") + } +} + +func TestSelect_MatcherMatches(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.MatcherNodeOption(&testMatcher{match: true}), + ) + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n1" { + t.Errorf("expected 'n1', got %q", node.Name) + } +} + +func TestSelect_MatcherDoesNotMatch(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.MatcherNodeOption(&testMatcher{match: false}), + ) + n2 := chain.NewNode("n2", "127.0.0.1:9090") + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n2" { + t.Errorf("expected 'n2' (n1 didn't match), got %q", node.Name) + } +} + +func TestSelect_Priority_HighestWins(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(5)) + n2 := chain.NewNode("n2", "127.0.0.1:9090", chain.PriorityNodeOption(10)) + n3 := chain.NewNode("n3", "127.0.0.1:7070") + h := newTestHop(NodeOption(n1, n2, n3)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n2" { + t.Errorf("expected 'n2' (highest priority), got %q", node.Name) + } +} + +func TestSelect_Priority_ZeroPriorityFallsToFirst(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:9090") + n2 := chain.NewNode("n2", "127.0.0.1:8080") + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n1" { + t.Errorf("expected 'n1' (first node), got %q", node.Name) + } +} + +func TestSelect_Priority_NegativeIgnored(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(-5)) + n2 := chain.NewNode("n2", "127.0.0.1:9090") + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + // n2 sorted before n1 (-5 <= 0), and both have priority <= 0 + if node.Name != "n2" { + t.Errorf("expected 'n2', got %q", node.Name) + } +} + +func TestSelect_WithSelector(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n2 := chain.NewNode("n2", "127.0.0.1:9090") + sel := &testNodeSelector{selectedIdx: 1} + h := newTestHop(NodeOption(n1, n2), SelectorOption(sel)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n2" { + t.Errorf("expected 'n2' (selected by selector), got %q", node.Name) + } +} + +func TestSelect_NilNodesSkipped(t *testing.T) { + h := newTestHop() + defer h.Close() + h.nodes = []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080"), nil, chain.NewNode("n3", "127.0.0.1:7070")} + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name == "" { + t.Error("expected non-empty node name") + } +} + +// ============================================================================= +// isEligible tests +// ============================================================================= + +func TestIsEligible_NilNode(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + if ch.isEligible(nil, &hop.SelectOptions{}) { + t.Error("expected false for nil node") + } +} + +func TestIsEligible_NoFilter(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + if !ch.isEligible(n, &hop.SelectOptions{}) { + t.Error("expected true when no filter") + } +} + +func TestIsEligible_HostMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + if !ch.isEligible(n, &hop.SelectOptions{Host: "example.com"}) { + t.Error("expected true for matching host") + } +} + +func TestIsEligible_HostMismatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + if ch.isEligible(n, &hop.SelectOptions{Host: "other.com"}) { + t.Error("expected false for mismatching host") + } +} + +func TestIsEligible_ProtocolMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"} + if !ch.isEligible(n, &hop.SelectOptions{Protocol: "http"}) { + t.Error("expected true for matching protocol") + } +} + +func TestIsEligible_ProtocolMismatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"} + if ch.isEligible(n, &hop.SelectOptions{Protocol: "socks5"}) { + t.Error("expected false for mismatching protocol") + } +} + +func TestIsEligible_PathMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"} + if !ch.isEligible(n, &hop.SelectOptions{Path: "/api/v1/users"}) { + t.Error("expected true for matching path prefix") + } +} + +func TestIsEligible_PathMismatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"} + if ch.isEligible(n, &hop.SelectOptions{Path: "/other/route"}) { + t.Error("expected false for mismatching path") + } +} + +func TestIsEligible_AllFiltersMustPass(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{ + Host: "example.com", + Protocol: "http", + } + if ch.isEligible(n, &hop.SelectOptions{Host: "example.com", Protocol: "socks5"}) { + t.Error("expected false: host matches but protocol doesn't") + } +} + +// ============================================================================= +// checkHost tests +// ============================================================================= + +func TestCheckHost_EmptyFilter(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + if !ch.checkHost("anything.com", n) { + t.Error("expected true when no host filter") + } +} + +func TestCheckHost_ExactMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + if !ch.checkHost("example.com", n) { + t.Error("expected true for exact host match") + } +} + +func TestCheckHost_WildcardMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: ".example.com"} + if !ch.checkHost("sub.example.com", n) { + t.Error("expected true for wildcard host match") + } +} + +func TestCheckHost_WildcardNoMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: ".example.com"} + if ch.checkHost("other.net", n) { + t.Error("expected false for non-matching wildcard") + } +} + +func TestCheckHost_EmptyInputHost(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + if ch.checkHost("", n) { + t.Error("expected false when input host is empty and filter is set") + } +} + +func TestCheckHost_StripPort(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + if !ch.checkHost("example.com:443", n) { + t.Error("expected true after stripping port") + } +} + +func TestCheckHost_IPWithPort(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "192.168.1.1"} + if !ch.checkHost("192.168.1.1:3128", n) { + t.Error("expected true for IP with port") + } +} + +func TestCheckHost_IPv6WithPort(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "::1"} + if !ch.checkHost("[::1]:8080", n) { + t.Error("expected true for bracketed IPv6 with port") + } +} + +func TestCheckHost_NoMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + if ch.checkHost("other.com", n) { + t.Error("expected false for non-matching host") + } +} + +// ============================================================================= +// checkProtocol tests +// ============================================================================= + +func TestCheckProtocol_EmptyFilter(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + if !ch.checkProtocol("http", n) { + t.Error("expected true when no protocol filter") + } +} + +func TestCheckProtocol_Match(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"} + if !ch.checkProtocol("http", n) { + t.Error("expected true for matching protocol") + } +} + +func TestCheckProtocol_NoMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Protocol: "socks5"} + if ch.checkProtocol("http", n) { + t.Error("expected false for non-matching protocol") + } +} + +// ============================================================================= +// checkPath tests +// ============================================================================= + +func TestCheckPath_EmptyFilter(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + if !ch.checkPath("/any/path", n) { + t.Error("expected true when no path filter") + } +} + +func TestCheckPath_PrefixMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"} + if !ch.checkPath("/api/v1/users", n) { + t.Error("expected true for prefix match") + } +} + +func TestCheckPath_ExactMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"} + if !ch.checkPath("/api", n) { + t.Error("expected true for exact match (prefix of self)") + } +} + +func TestCheckPath_NoMatch(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"} + if ch.checkPath("/other", n) { + t.Error("expected false for non-prefix match") + } +} + +func TestCheckPath_ShorterInput(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + n := chain.NewNode("n1", "127.0.0.1:8080") + n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"} + if ch.checkPath("/ap", n) { + t.Error("expected false when input is shorter than filter prefix") + } +} + +// ============================================================================= +// Select with filter integration tests +// ============================================================================= + +func TestSelect_FilterHost_Match(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n1.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background(), hop.HostSelectOption("example.com")) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_FilterHost_NoMatch(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n1.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"} + n2 := chain.NewNode("n2", "127.0.0.1:9090") + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background(), hop.HostSelectOption("other.com")) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n2" { + t.Errorf("expected 'n2' (n1 host mismatch), got %q", node.Name) + } +} + +func TestSelect_FilterProtocol_Match(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n1.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"} + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background(), hop.ProtocolSelectOption("http")) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_FilterProtocol_NoMatch(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n1.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"} + n2 := chain.NewNode("n2", "127.0.0.1:9090") + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background(), hop.ProtocolSelectOption("socks5")) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n2" { + t.Errorf("expected 'n2' (n1 protocol mismatch), got %q", node.Name) + } +} + +// ============================================================================= +// Select with select options +// ============================================================================= + +func TestSelect_WithNetworkOption(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background(), hop.NetworkSelectOption("tcp")) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_WithAddrOption(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background(), hop.AddrSelectOption("example.com:80")) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_WithClientIPOption(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.MatcherNodeOption(&testMatcher{match: true}), + ) + h := newTestHop(NodeOption(n1)) + defer h.Close() + + ip := net.ParseIP("10.0.0.1") + node := h.Select(context.Background(), hop.ClientIPSelectOption(ip)) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_WithMethodOption(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.MatcherNodeOption(&testMatcher{match: true}), + ) + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background(), hop.MethodSelectOption("CONNECT")) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_WithHeaderOption(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.MatcherNodeOption(&testMatcher{match: true}), + ) + h := newTestHop(NodeOption(n1)) + defer h.Close() + + header := map[string][]string{"X-Forwarded-For": {"1.2.3.4"}} + node := h.Select(context.Background(), hop.HeaderSelectOption(header)) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_WithMultipleOptions(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + h := newTestHop(NodeOption(n1)) + defer h.Close() + + node := h.Select(context.Background(), + hop.NetworkSelectOption("tcp"), + hop.HostSelectOption("example.com"), + hop.ProtocolSelectOption("http"), + hop.PathSelectOption("/api"), + ) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +func TestSelect_MatcherWithFullRequest(t *testing.T) { + var capturedReq *routing.Request + n1 := chain.NewNode("n1", "127.0.0.1:8080", + chain.MatcherNodeOption(&requestCapturingMatcher{capture: &capturedReq, match: true}), + ) + h := newTestHop(NodeOption(n1)) + defer h.Close() + + ip := net.ParseIP("10.0.0.1") + node := h.Select(context.Background(), + hop.ClientIPSelectOption(ip), + hop.HostSelectOption("example.com"), + hop.ProtocolSelectOption("http"), + hop.MethodSelectOption("GET"), + hop.PathSelectOption("/api/v1"), + ) + if node == nil { + t.Fatal("expected node, got nil") + } +} + +// ============================================================================= +// reload tests +// ============================================================================= + +func TestReload_NoLoaders(t *testing.T) { + ch := &chainHop{ + options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}}, + logger: xlogger.Nop(), + } + err := ch.reload(context.Background()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + nodes := ch.Nodes() + if len(nodes) != 1 { + t.Errorf("expected 1 node, got %d", len(nodes)) + } +} + +func TestReload_WithFileLoader(t *testing.T) { + loader := &testLoader{ + loadFn: func(ctx context.Context) (io.Reader, error) { + return strings.NewReader(`[{"name": "loaded", "addr": "10.0.0.1:8080"}]`), nil + }, + } + ch := &chainHop{ + logger: xlogger.Nop(), + options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, fileLoader: loader, name: "test-hop"}, + } + err := ch.reload(context.Background()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + nodes := ch.Nodes() + if len(nodes) < 2 { + t.Errorf("expected at least 2 nodes, got %d", len(nodes)) + } +} + +func TestReload_LoaderError(t *testing.T) { + loadErr := errors.New("load failed") + loader := &testLoader{ + loadFn: func(ctx context.Context) (io.Reader, error) { + return nil, loadErr + }, + } + ch := &chainHop{ + logger: xlogger.Nop(), + options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, fileLoader: loader}, + } + err := ch.reload(context.Background()) + if err == nil { + t.Error("expected error from failed loader") + } +} + +func TestReload_RedisLoader(t *testing.T) { + loader := &testLoader{ + loadFn: func(ctx context.Context) (io.Reader, error) { + return strings.NewReader(`[{"name": "redis-node", "addr": "10.0.0.2:8080"}]`), nil + }, + } + ch := &chainHop{ + logger: xlogger.Nop(), + options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, redisLoader: loader, name: "test-hop"}, + } + err := ch.reload(context.Background()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestReload_HTTPLoader(t *testing.T) { + loader := &testLoader{ + loadFn: func(ctx context.Context) (io.Reader, error) { + return strings.NewReader(`[{"name": "http-node", "addr": "10.0.0.3:8080"}]`), nil + }, + } + ch := &chainHop{ + logger: xlogger.Nop(), + options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, httpLoader: loader, name: "test-hop"}, + } + err := ch.reload(context.Background()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestReload_MultipleLoaders(t *testing.T) { + fileLoader := &testLoader{ + loadFn: func(ctx context.Context) (io.Reader, error) { + return strings.NewReader(`[{"name": "file-n", "addr": "10.0.0.1:8080"}]`), nil + }, + } + redisLoader := &testLoader{ + loadFn: func(ctx context.Context) (io.Reader, error) { + return strings.NewReader(`[{"name": "redis-n", "addr": "10.0.0.2:8080"}]`), nil + }, + } + ch := &chainHop{ + logger: xlogger.Nop(), + options: options{ + nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, + fileLoader: fileLoader, + redisLoader: redisLoader, + name: "test-hop", + }, + } + err := ch.reload(context.Background()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestReload_LoaderErrorWithParseError(t *testing.T) { + loader := &testLoader{ + loadFn: func(ctx context.Context) (io.Reader, error) { + return strings.NewReader(`invalid json`), nil + }, + } + ch := &chainHop{ + logger: xlogger.Nop(), + options: options{nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, fileLoader: loader}, + } + err := ch.reload(context.Background()) + if err == nil { + t.Error("expected parse error") + } +} + +// ============================================================================= +// parseNode tests +// ============================================================================= + +func TestParseNode_NilReader(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + nodes, err := ch.parseNode(nil) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestParseNode_ValidJSON(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop(), options: options{name: "test-hop"}} + r := strings.NewReader(`[{"name": "n1", "addr": "10.0.0.1:8080"}]`) + nodes, err := ch.parseNode(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + if nodes[0].Name != "n1" { + t.Errorf("expected 'n1', got %q", nodes[0].Name) + } +} + +func TestParseNode_EmptyArray(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + r := strings.NewReader(`[]`) + nodes, err := ch.parseNode(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestParseNode_InvalidJSON(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop()} + r := strings.NewReader(`not json`) + nodes, err := ch.parseNode(r) + if err == nil { + t.Error("expected error for invalid JSON") + } + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestParseNode_NilEntrySkipped(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop(), options: options{name: "test-hop"}} + r := strings.NewReader(`[null, {"name": "ok", "addr": "10.0.0.1:8080"}]`) + nodes, err := ch.parseNode(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + if nodes[0].Name != "ok" { + t.Errorf("expected 'ok', got %q", nodes[0].Name) + } +} + +func TestParseNode_SkipInvalidEntry(t *testing.T) { + ch := &chainHop{logger: xlogger.Nop(), options: options{name: "test-hop"}} + r := strings.NewReader(`[{"name": "bad"}]`) + nodes, err := ch.parseNode(r) + // May return an error via errors.Join even if some nodes parsed + _ = err + _ = nodes +} + +// ============================================================================= +// Close tests +// ============================================================================= + +func TestClose_NoLoaders(t *testing.T) { + h := newTestHop() + err := h.Close() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestClose_WithLoaders(t *testing.T) { + fileClosed := false + redisClosed := false + httpClosed := false + + h := newTestHop( + FileLoaderOption(&testLoader{closeFn: func() error { fileClosed = true; return nil }}), + RedisLoaderOption(&testLoader{closeFn: func() error { redisClosed = true; return nil }}), + HTTPLoaderOption(&testLoader{closeFn: func() error { httpClosed = true; return nil }}), + ) + err := h.Close() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !fileClosed { + t.Error("fileLoader not closed") + } + if !redisClosed { + t.Error("redisLoader not closed") + } + if !httpClosed { + t.Error("httpLoader not closed") + } +} + +func TestClose_CancelFuncCalled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + ch := &chainHop{ + cancelFunc: cancel, + logger: xlogger.Nop(), + } + ch.Close() + select { + case <-ctx.Done(): + // expected + default: + t.Error("context should be cancelled after Close") + } +} + +// ============================================================================= +// periodReload tests +// ============================================================================= + +func TestPeriodReload_NoPeriod(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ch := &chainHop{ + options: options{period: 0}, + logger: xlogger.Nop(), + nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, + } + err := ch.periodReload(ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPeriodReload_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ch := &chainHop{ + options: options{period: time.Second}, + logger: xlogger.Nop(), + nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, + } + err := ch.periodReload(ctx) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled, got %v", err) + } +} + +func TestPeriodReload_MinimumPeriod(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + ch := &chainHop{ + options: options{period: time.Millisecond}, + logger: xlogger.Nop(), + nodes: []*chain.Node{chain.NewNode("n1", "127.0.0.1:8080")}, + } + time.AfterFunc(50*time.Millisecond, cancel) + err := ch.periodReload(ctx) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled, got %v", err) + } +} + +// ============================================================================= +// Priority edge cases +// ============================================================================= + +func TestSelect_AllSamePriority(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n2 := chain.NewNode("n2", "127.0.0.1:9090") + n3 := chain.NewNode("n3", "127.0.0.1:7070") + h := newTestHop(NodeOption(n1, n2, n3)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n1" { + t.Errorf("expected 'n1' (first node), got %q", node.Name) + } +} + +func TestSelect_OnlyNegativePriorities(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080", chain.PriorityNodeOption(-5)) + n2 := chain.NewNode("n2", "127.0.0.1:9090", chain.PriorityNodeOption(-10)) + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background()) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n1" { + t.Errorf("expected 'n1' (higher priority), got %q", node.Name) + } +} + +// ============================================================================= +// Select with backup node (empty host filter means backup) +// ============================================================================= + +func TestSelect_BackupNode_HostFilterEmpty(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n1.Options().Filter = &chain.NodeFilterSettings{Host: ""} + n2 := chain.NewNode("n2", "127.0.0.1:9090") + n2.Options().Filter = &chain.NodeFilterSettings{Host: "specific.com"} + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background(), hop.HostSelectOption("other.com")) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n1" { + t.Errorf("expected 'n1' (backup), got %q", node.Name) + } +} + +func TestSelect_SpecificHostNodeWins(t *testing.T) { + n1 := chain.NewNode("n1", "127.0.0.1:8080") + n1.Options().Filter = &chain.NodeFilterSettings{Host: ""} + n2 := chain.NewNode("n2", "127.0.0.1:9090") + n2.Options().Filter = &chain.NodeFilterSettings{Host: "specific.com"} + h := newTestHop(NodeOption(n1, n2)) + defer h.Close() + + node := h.Select(context.Background(), hop.HostSelectOption("specific.com")) + if node == nil { + t.Fatal("expected node, got nil") + } + if node.Name != "n1" { + t.Errorf("expected 'n1' (first node when both eligible), got %q", node.Name) + } +} + +// ============================================================================= +// Interface satisfaction +// ============================================================================= + +func TestChainHop_ImplementsHop(t *testing.T) { + var _ hop.Hop = (*chainHop)(nil) +} + +func TestChainHop_ImplementsNodeList(t *testing.T) { + var _ hop.NodeList = (*chainHop)(nil) +} + +func TestNewHop_ReturnsHopInterface(t *testing.T) { + h := NewHop() + if h == nil { + t.Fatal("expected non-nil Hop") + } + defer h.(*chainHop).Close() +} + +// ============================================================================= +// NewHop starts periodReload goroutine +// ============================================================================= + +func TestNewHop_StartsPeriodReload(t *testing.T) { + h := NewHop() + ch := h.(*chainHop) + if ch.cancelFunc == nil { + t.Error("expected non-nil cancelFunc") + } + h.(*chainHop).Close() +} diff --git a/hop/plugin/http.go b/hop/plugin/http.go index e676542d..c5dd6dc6 100644 --- a/hop/plugin/http.go +++ b/hop/plugin/http.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "net/http" "github.com/go-gost/core/chain" @@ -115,3 +116,14 @@ func (p *httpPlugin) Select(ctx context.Context, opts ...hop.SelectOption) *chai } return node } + +func (p *httpPlugin) Close() error { + if p.client == nil { + return nil + } + p.client.CloseIdleConnections() + if tr, ok := p.client.Transport.(io.Closer); ok { + return tr.Close() + } + return nil +} diff --git a/hosts/hosts.go b/hosts/hosts.go index 3f9eee28..4e80a023 100644 --- a/hosts/hosts.go +++ b/hosts/hosts.go @@ -15,6 +15,7 @@ import ( xlogger "github.com/go-gost/x/logger" ) +// Mapping is a static mapping from a hostname to an IP address. type Mapping struct { Hostname string IP net.IP @@ -29,38 +30,45 @@ type options struct { logger logger.Logger } +// Option configures a hostMapper. type Option func(opts *options) +// MappingsOption sets the static host-to-IP mappings. func MappingsOption(mappings []Mapping) Option { return func(opts *options) { opts.mappings = mappings } } +// ReloadPeriodOption sets the period for automatic reload from external loaders. func ReloadPeriodOption(period time.Duration) Option { return func(opts *options) { opts.period = period } } +// FileLoaderOption sets a file-based loader for hosts mappings (supports both Loader and Lister). func FileLoaderOption(fileLoader loader.Loader) Option { return func(opts *options) { opts.fileLoader = fileLoader } } +// RedisLoaderOption sets a Redis-based loader for hot-reloadable hosts mappings. func RedisLoaderOption(redisLoader loader.Loader) Option { return func(opts *options) { opts.redisLoader = redisLoader } } +// HTTPLoaderOption sets an HTTP-based loader for hot-reloadable hosts mappings. func HTTPLoaderOption(httpLoader loader.Loader) Option { return func(opts *options) { opts.httpLoader = httpLoader } } +// LoggerOption sets the logger for the hostMapper. func LoggerOption(logger logger.Logger) Option { return func(opts *options) { opts.logger = logger @@ -80,6 +88,8 @@ type hostMapper struct { cancelFunc context.CancelFunc } +// NewHostMapper creates a new HostMapper with the given options. +// It starts a background goroutine for periodic reload from external loaders. func NewHostMapper(opts ...Option) hosts.HostMapper { var options options for _, opt := range opts { @@ -102,9 +112,10 @@ func NewHostMapper(opts ...Option) hosts.HostMapper { return p } -// Lookup searches the IP address corresponds to the given network and host from the host table. -// The network should be 'ip', 'ip4' or 'ip6', default network is 'ip'. -// the host should be a hostname (example.org) or a hostname with dot prefix (.example.org). +// Lookup resolves a hostname to IP addresses using the host table. +// It first tries an exact match, then a wildcard match (by prefixing with "."), +// then progressively shorter suffix matches for subdomain wildcards. +// The network parameter filters results: "ip4" returns only IPv4, "ip6" only IPv6. func (h *hostMapper) Lookup(ctx context.Context, network, host string, opts ...hosts.Option) (ips []net.IP, ok bool) { h.options.logger.Debugf("lookup %s/%s", host, network) ips = h.lookup(host) @@ -151,13 +162,14 @@ func (h *hostMapper) Lookup(ctx context.Context, network, host string, opts ...h if len(ips) > 0 { h.options.logger.Debugf("host mapper: %s/%s -> %s", host, network, ips) + ok = true } return } func (h *hostMapper) lookup(host string) []net.IP { - if h == nil || len(h.mappings) == 0 { + if len(h.mappings) == 0 { return nil } @@ -234,6 +246,7 @@ func (h *hostMapper) reload(ctx context.Context) (err error) { return } +// load fetches mappings from all configured loaders (file, redis, http). func (h *hostMapper) load(ctx context.Context) (mappings []Mapping, err error) { if h.options.fileLoader != nil { if lister, ok := h.options.fileLoader.(loader.Lister); ok { @@ -298,6 +311,8 @@ func (h *hostMapper) parseMapping(r io.Reader) (mappings []Mapping, err error) { return } +// parseLine parses a single line in hosts-file format: "IP hostname [hostname...]". +// Lines starting with "#" are comments. Invalid IP addresses or lines with no hostname are silently dropped. func (h *hostMapper) parseLine(s string) (mappings []Mapping) { line := strings.Replace(s, "\t", " ", -1) line = strings.TrimSpace(line) @@ -328,6 +343,7 @@ func (h *hostMapper) parseLine(s string) (mappings []Mapping) { return } +// Close stops the background reload goroutine and closes all loaders. func (h *hostMapper) Close() error { h.cancelFunc() if h.options.fileLoader != nil { @@ -336,5 +352,8 @@ func (h *hostMapper) Close() error { if h.options.redisLoader != nil { h.options.redisLoader.Close() } + if h.options.httpLoader != nil { + h.options.httpLoader.Close() + } return nil } diff --git a/hosts/hosts_test.go b/hosts/hosts_test.go new file mode 100644 index 00000000..270e34be --- /dev/null +++ b/hosts/hosts_test.go @@ -0,0 +1,579 @@ +package hosts + +import ( + "context" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/go-gost/x/internal/loader" + xlogger "github.com/go-gost/x/logger" +) + +// newTestMapper creates a hostMapper for testing with a nop logger to avoid nil dereference +// when Lookup uses h.options.logger directly. +// It synchronously loads the initial mappings to avoid race conditions with the background +// reload goroutine started by NewHostMapper. +func newTestMapper(opts ...Option) *hostMapper { + opts = append(opts, LoggerOption(xlogger.Nop())) + h := NewHostMapper(opts...).(*hostMapper) + _ = h.reload(context.Background()) + return h +} + +func TestParseLine(t *testing.T) { + h := &hostMapper{} + + tests := []struct { + name string + input string + expected []Mapping + }{ + { + name: "single hostname", + input: "127.0.0.1 localhost", + expected: []Mapping{ + {Hostname: "localhost", IP: net.ParseIP("127.0.0.1")}, + }, + }, + { + name: "multiple hostnames", + input: "127.0.0.1 localhost loopback", + expected: []Mapping{ + {Hostname: "localhost", IP: net.ParseIP("127.0.0.1")}, + {Hostname: "loopback", IP: net.ParseIP("127.0.0.1")}, + }, + }, + { + name: "IPv6 address", + input: "::1 localhost ipv6-localhost", + expected: []Mapping{ + {Hostname: "localhost", IP: net.ParseIP("::1")}, + {Hostname: "ipv6-localhost", IP: net.ParseIP("::1")}, + }, + }, + { + name: "comment with #", + input: "# this is a comment", + expected: nil, + }, + { + name: "inline comment", + input: "127.0.0.1 localhost # loopback address", + expected: []Mapping{ + {Hostname: "localhost", IP: net.ParseIP("127.0.0.1")}, + }, + }, + { + name: "tab separated", + input: "127.0.0.1\tlocalhost\tloopback", + expected: []Mapping{ + {Hostname: "localhost", IP: net.ParseIP("127.0.0.1")}, + {Hostname: "loopback", IP: net.ParseIP("127.0.0.1")}, + }, + }, + { + name: "mixed spaces and tabs", + input: "127.0.0.1 \t localhost \t loopback", + expected: []Mapping{ + {Hostname: "localhost", IP: net.ParseIP("127.0.0.1")}, + {Hostname: "loopback", IP: net.ParseIP("127.0.0.1")}, + }, + }, + { + name: "invalid IP address", + input: "not-an-ip hostname", + expected: nil, + }, + { + name: "empty line", + input: "", + expected: nil, + }, + { + name: "whitespace only line", + input: " \t ", + expected: nil, + }, + { + name: "IP only no hostname", + input: "127.0.0.1", + expected: nil, + }, + { + name: "comment only with whitespace", + input: " # comment", + expected: nil, + }, + { + name: "extra whitespace between fields", + input: "127.0.0.1 localhost loopback", + expected: []Mapping{ + {Hostname: "localhost", IP: net.ParseIP("127.0.0.1")}, + {Hostname: "loopback", IP: net.ParseIP("127.0.0.1")}, + }, + }, + { + name: "# only", + input: "#", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := h.parseLine(tt.input) + if len(got) != len(tt.expected) { + t.Fatalf("expected %d mappings, got %d", len(tt.expected), len(got)) + } + for i := range got { + if got[i].Hostname != tt.expected[i].Hostname { + t.Errorf("mapping[%d] hostname: expected %q, got %q", i, tt.expected[i].Hostname, got[i].Hostname) + } + if !got[i].IP.Equal(tt.expected[i].IP) { + t.Errorf("mapping[%d] IP: expected %s, got %s", i, tt.expected[i].IP, got[i].IP) + } + } + }) + } +} + +func TestParseMapping(t *testing.T) { + h := &hostMapper{} + + tests := []struct { + name string + input string + expected int + }{ + { + name: "nil reader", + input: "", + expected: 0, + }, + { + name: "single line", + input: "127.0.0.1 localhost\n", + expected: 1, + }, + { + name: "multiple lines", + input: "127.0.0.1 localhost\n192.168.1.1 router\n", + expected: 2, + }, + { + name: "with comments and blanks", + input: "# comment\n127.0.0.1 localhost\n\n# another comment\n192.168.1.1 router\n", + expected: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r io.Reader + if tt.input != "" { + r = strings.NewReader(tt.input) + } + got, err := h.parseMapping(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != tt.expected { + t.Fatalf("expected %d mappings, got %d", tt.expected, len(got)) + } + }) + } +} + +func TestLookupExactMatch(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "example.com", IP: net.ParseIP("10.0.0.1")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok { + t.Fatal("expected lookup to succeed for 'example.com'") + } + if len(ips) != 1 || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +func TestLookupNoMatch(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "example.com", IP: net.ParseIP("10.0.0.1")}, + })) + + _, ok := h.Lookup(context.Background(), "ip", "unknown.com") + if ok { + t.Fatal("expected lookup to fail for 'unknown.com'") + } +} + +func TestLookupDotPrefixWildcard(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: ".example.com", IP: net.ParseIP("10.0.0.1")}, + })) + + tests := []struct { + host string + ok bool + }{ + {"example.com", true}, + {"sub.example.com", true}, + {"deep.sub.example.com", true}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + _, ok := h.Lookup(context.Background(), "ip", tt.host) + if ok != tt.ok { + t.Errorf("Lookup(%q) ok = %v, want %v", tt.host, ok, tt.ok) + } + }) + } +} + +func TestLookupSubdomainWildcard(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: ".example.com", IP: net.ParseIP("10.0.0.1")}, + {Hostname: ".foo.example.com", IP: net.ParseIP("10.0.0.2")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip", "foo.example.com") + if !ok { + t.Fatal("expected lookup to succeed for 'foo.example.com'") + } + if !ips[0].Equal(net.ParseIP("10.0.0.2")) { + t.Fatalf("expected [10.0.0.2], got %v", ips) + } + + ips, ok = h.Lookup(context.Background(), "ip", "bar.example.com") + if !ok { + t.Fatal("expected lookup to succeed for 'bar.example.com'") + } + if !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +func TestLookupIP4Filtering(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "dual.example.com", IP: net.ParseIP("10.0.0.1")}, + {Hostname: "dual.example.com", IP: net.ParseIP("::1")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip4", "dual.example.com") + if !ok { + t.Fatal("expected lookup to succeed") + } + if len(ips) != 1 || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +func TestLookupIP6Filtering(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "dual.example.com", IP: net.ParseIP("10.0.0.1")}, + {Hostname: "dual.example.com", IP: net.ParseIP("::1")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip6", "dual.example.com") + if !ok { + t.Fatal("expected lookup to succeed") + } + if len(ips) != 1 || !ips[0].Equal(net.ParseIP("::1")) { + t.Fatalf("expected [::1], got %v", ips) + } +} + +func TestLookupDefaultNetwork(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "example.com", IP: net.ParseIP("10.0.0.1")}, + {Hostname: "example.com", IP: net.ParseIP("::1")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok { + t.Fatal("expected lookup to succeed") + } + if len(ips) != 2 { + t.Fatalf("expected 2 IPs with 'ip' network, got %d", len(ips)) + } +} + +func TestLookupEmptyMappings(t *testing.T) { + h := newTestMapper() + + _, ok := h.Lookup(context.Background(), "ip", "example.com") + if ok { + t.Fatal("expected lookup to fail with empty mappings") + } +} + +func TestReload(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "example.com", IP: net.ParseIP("10.0.0.1")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatal("initial mapping not found") + } + + h.options.mappings = []Mapping{ + {Hostname: "example.com", IP: net.ParseIP("10.0.0.2")}, + } + _ = h.reload(context.Background()) + + ips, ok = h.Lookup(context.Background(), "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.2")) { + t.Fatalf("expected [10.0.0.2] after reload, got %v", ips) + } +} + +func TestReloadDedupIPs(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "example.com", IP: net.ParseIP("10.0.0.1")}, + {Hostname: "example.com", IP: net.ParseIP("10.0.0.1")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok { + t.Fatal("expected lookup to succeed") + } + if len(ips) != 1 { + t.Fatalf("expected 1 deduplicated IP, got %d", len(ips)) + } +} + +func TestClose(t *testing.T) { + h := NewHostMapper().(*hostMapper) + err := h.Close() + if err != nil { + t.Fatalf("Close returned error: %v", err) + } + + err = h.Close() + if err != nil { + t.Fatalf("second Close returned error: %v", err) + } +} + +type testLoader struct { + data string +} + +func (l *testLoader) Load(_ context.Context) (io.Reader, error) { + return strings.NewReader(l.data), nil +} + +func (l *testLoader) Close() error { return nil } + +func TestLoadFileLoader(t *testing.T) { + h := newTestMapper( + FileLoaderOption(&testLoader{data: "10.0.0.1 example.com\n"}), + ) + + _ = h.reload(context.Background()) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +func TestLoadHTTPLoader(t *testing.T) { + h := newTestMapper( + HTTPLoaderOption(&testLoader{data: "10.0.0.1 example.com\n"}), + ) + + _ = h.reload(context.Background()) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +func TestLoadRedisLoader(t *testing.T) { + h := newTestMapper( + RedisLoaderOption(&testLoader{data: "10.0.0.1 example.com\n"}), + ) + + _ = h.reload(context.Background()) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +type testLister struct { + lines []string +} + +func (l *testLister) Load(_ context.Context) (io.Reader, error) { + return nil, nil +} + +func (l *testLister) List(_ context.Context) ([]string, error) { + return l.lines, nil +} + +func (l *testLister) Close() error { return nil } + +func TestLoadFileLoaderAsLister(t *testing.T) { + h := newTestMapper( + FileLoaderOption(&testLister{lines: []string{"10.0.0.1 example.com", "192.168.1.1 router"}}), + ) + + _ = h.reload(context.Background()) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } + + ips, ok = h.Lookup(context.Background(), "ip", "router") + if !ok || !ips[0].Equal(net.ParseIP("192.168.1.1")) { + t.Fatalf("expected [192.168.1.1], got %v", ips) + } +} + +func TestLoadRedisLoaderAsLister(t *testing.T) { + h := newTestMapper( + RedisLoaderOption(&testLister{lines: []string{"10.0.0.1 example.com"}}), + ) + + _ = h.reload(context.Background()) + + ips, ok := h.Lookup(context.Background(), "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +func TestCloseClosesLoaders(t *testing.T) { + cl := &closeTrackingLoader{} + h := newTestMapper( + FileLoaderOption(cl), + RedisLoaderOption(cl), + HTTPLoaderOption(cl), + ) + + _ = h.Close() + + if cl.count != 3 { + t.Fatalf("expected Close called 3 times, got %d", cl.count) + } +} + +type closeTrackingLoader struct { + count int +} + +func (l *closeTrackingLoader) Load(_ context.Context) (io.Reader, error) { + return strings.NewReader(""), nil +} + +func (l *closeTrackingLoader) Close() error { + l.count++ + return nil +} + +func TestReloadPeriodOption(t *testing.T) { + period := 5 * time.Minute + h := newTestMapper(ReloadPeriodOption(period)) + if h.options.period != period { + t.Fatalf("expected period %v, got %v", period, h.options.period) + } +} + +func TestLoggerOption(t *testing.T) { + h := NewHostMapper().(*hostMapper) + if h.logger == nil { + t.Fatal("expected non-nil default logger") + } +} + +func TestLookupContextCancellation(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "example.com", IP: net.ParseIP("10.0.0.1")}, + })) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + ips, ok := h.Lookup(ctx, "ip", "example.com") + if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v (ok=%v)", ips, ok) + } +} + +func TestPeriodReloadNegativePeriod(t *testing.T) { + h := NewHostMapper( + ReloadPeriodOption(-1*time.Second), + LoggerOption(xlogger.Nop()), + ).(*hostMapper) + + err := h.Close() + if err != nil { + t.Fatalf("Close returned error: %v", err) + } +} + +func TestSubdomainWildcardDeeperMatch(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: ".example.com", IP: net.ParseIP("10.0.0.1")}, + {Hostname: ".b.example.com", IP: net.ParseIP("10.0.0.2")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip", "a.b.example.com") + if !ok { + t.Fatal("expected lookup to succeed for 'a.b.example.com'") + } + if !ips[0].Equal(net.ParseIP("10.0.0.2")) { + t.Fatalf("expected [10.0.0.2] (more specific), got %v", ips) + } + + ips, ok = h.Lookup(context.Background(), "ip", "c.example.com") + if !ok { + t.Fatal("expected lookup to succeed for 'c.example.com'") + } + if !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1], got %v", ips) + } +} + +func TestLookupIPv4MappedIPv6(t *testing.T) { + h := newTestMapper(MappingsOption([]Mapping{ + {Hostname: "example.com", IP: net.ParseIP("::ffff:10.0.0.1")}, + })) + + ips, ok := h.Lookup(context.Background(), "ip4", "example.com") + if !ok { + t.Fatal("expected lookup to succeed") + } + if len(ips) != 1 || !ips[0].Equal(net.ParseIP("10.0.0.1")) { + t.Fatalf("expected [10.0.0.1] (unwrapped IPv4), got %v", ips) + } + + ips, ok = h.Lookup(context.Background(), "ip6", "example.com") + if ok || len(ips) != 0 { + t.Fatalf("expected no results for ip6 filter, got %v (ok=%v)", ips, ok) + } +} + +func TestLoaderWithListerPattern(t *testing.T) { + var _ loader.Loader = (*testLister)(nil) + var _ loader.Lister = (*testLister)(nil) + + var _ loader.Loader = (*testLoader)(nil) + _, isLister := interface{}(&testLoader{}).(loader.Lister) + if isLister { + t.Fatal("testLoader should not implement Lister") + } +} diff --git a/hosts/plugin/grpc.go b/hosts/plugin/grpc.go index 3d76d834..67d850a0 100644 --- a/hosts/plugin/grpc.go +++ b/hosts/plugin/grpc.go @@ -13,13 +13,14 @@ import ( "google.golang.org/grpc" ) +// grpcPlugin is a HostMapper that delegates lookups to a remote gRPC service. type grpcPlugin struct { conn grpc.ClientConnInterface client proto.HostMapperClient log logger.Logger } -// NewGRPCPlugin creates a HostMapper plugin based on gRPC. +// NewGRPCPlugin creates a HostMapper plugin that delegates lookups to a remote gRPC endpoint. func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) hosts.HostMapper { var options plugin.Options for _, opt := range opts { @@ -44,6 +45,7 @@ func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) hosts.HostMa return p } +// Lookup calls the gRPC HostMapper service and returns the resolved IPs. func (p *grpcPlugin) Lookup(ctx context.Context, network, host string, opts ...hosts.Option) (ips []net.IP, ok bool) { p.log.Debugf("lookup %s/%s", host, network) @@ -70,6 +72,7 @@ func (p *grpcPlugin) Lookup(ctx context.Context, network, host string, opts ...h return } +// Close closes the underlying gRPC connection. func (p *grpcPlugin) Close() error { if p.conn == nil { return nil diff --git a/hosts/plugin/http.go b/hosts/plugin/http.go index ae5672bf..8be6c9f3 100644 --- a/hosts/plugin/http.go +++ b/hosts/plugin/http.go @@ -24,6 +24,7 @@ type httpPluginResponse struct { OK bool `json:"ok"` } +// httpPlugin is a HostMapper that delegates lookups to a remote HTTP service. type httpPlugin struct { url string client *http.Client @@ -31,7 +32,7 @@ type httpPlugin struct { log logger.Logger } -// NewHTTPPlugin creates an HostMapper plugin based on HTTP. +// NewHTTPPlugin creates a HostMapper plugin that delegates lookups to a remote HTTP endpoint. func NewHTTPPlugin(name string, url string, opts ...plugin.Option) hosts.HostMapper { var options plugin.Options for _, opt := range opts { @@ -49,6 +50,7 @@ func NewHTTPPlugin(name string, url string, opts ...plugin.Option) hosts.HostMap } } +// Lookup sends a JSON POST request to the configured HTTP endpoint and returns the resolved IPs. func (p *httpPlugin) Lookup(ctx context.Context, network, host string, opts ...hosts.Option) (ips []net.IP, ok bool) { p.log.Debugf("lookup %s/%s", host, network)