docs(hosts,hop): add doc comments, fix missing httpLoader.Close, accumulate reload errors
- hosts/hosts.go: add package and symbol doc comments, fix missing httpLoader.Close() in Close() - hosts/plugin: add doc comments for HTTP and gRPC plugin types - hop/hop.go: add doc comments, accumulate reload/parse errors via errors.Join instead of silently discarding them - hop/plugin/http.go: add doc comments and missing Close() method - Add unit tests for hosts (580 lines) and hop (991 lines)
This commit is contained in:
+48
-8
@@ -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
|
||||
}
|
||||
|
||||
+1323
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
+23
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user