diff --git a/auth/auth_test.go b/auth/auth_test.go index a13ceced..6abb3e0c 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -4,11 +4,13 @@ import ( "context" "errors" "io" + "net" "strings" "testing" "time" "github.com/go-gost/core/auth" + xctx "github.com/go-gost/x/ctx" "github.com/go-gost/x/internal/loader" xlogger "github.com/go-gost/x/logger" ) @@ -1003,3 +1005,96 @@ type staticAuther struct { func (s *staticAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) { return s.id, s.ok } + +// --- whitelist tests --- + +func TestWhitelistedAuthenticator_IPMatch(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "" || !ok { + t.Fatalf("expected (\"\", true) for matched IP, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_IPNoMatch(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "test" || !ok { + t.Fatalf("expected (\"test\", true) for non-matched IP, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_CIDRMatch(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.1.2.3"), Port: 9999}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "" || !ok { + t.Fatalf("expected (\"\", true) for matched CIDR, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_CIDRNoMatch(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "test" || !ok { + t.Fatalf("expected (\"test\", true) for non-matched CIDR, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_EmptyPatterns(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "test" || !ok { + t.Fatalf("expected (\"test\", true) for empty patterns, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_NoSrcAddr(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"}) + id, ok := w.Authenticate(context.Background(), "user", "pass") + if id != "test" || !ok { + t.Fatalf("expected (\"test\", true) with no src addr in context, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_NilAuther(t *testing.T) { + // When IP matches, the whitelist returns OK regardless of nil auther. + w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "" || !ok { + t.Fatalf("expected (\"\", true) for matched whitelist even with nil auther, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_NilAutherNoMatch(t *testing.T) { + // When IP does not match and auther is nil, it falls through to ("", false). + w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 54321}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "" || ok { + t.Fatalf("expected (\"\", false) for nil auther with non-matching IP, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_UnderlyingDenies(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: false}, []string{"10.0.0.0/8"}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "test" || ok { + t.Fatalf("expected (\"test\", false) when underlying auther denies, got (%q, %v)", id, ok) + } +} + +func TestWhitelistedAuthenticator_InvalidPatterns(t *testing.T) { + w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"not-an-ip", "also-not-cidr/", ""}) + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321}) + id, ok := w.Authenticate(ctx, "user", "pass") + if id != "test" || !ok { + t.Fatalf("expected (\"test\", true) for all-invalid patterns, got (%q, %v)", id, ok) + } +} diff --git a/auth/whitelist.go b/auth/whitelist.go new file mode 100644 index 00000000..0815e410 --- /dev/null +++ b/auth/whitelist.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "net" + "strings" + + "github.com/go-gost/core/auth" + xctx "github.com/go-gost/x/ctx" + "github.com/go-gost/x/internal/matcher" +) + +// whitelistedAuthenticator wraps an Authenticator to skip authentication +// for client IPs matching the configured whitelist patterns. +type whitelistedAuthenticator struct { + auther auth.Authenticator + ipMatcher matcher.Matcher + cidrMatcher matcher.Matcher +} + +// WhitelistedAuthenticator returns an Authenticator that skips the underlying +// auther when the client's IP address matches one of the given patterns. +// Patterns may be IP addresses (e.g. "192.168.1.1") or CIDR ranges +// (e.g. "10.0.0.0/8"). Invalid patterns are silently ignored. +func WhitelistedAuthenticator(auther auth.Authenticator, patterns []string) auth.Authenticator { + var ips []net.IP + var inets []*net.IPNet + for _, pattern := range patterns { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + if ip := net.ParseIP(pattern); ip != nil { + ips = append(ips, ip) + continue + } + if _, inet, err := net.ParseCIDR(pattern); err == nil { + inets = append(inets, inet) + } + } + return &whitelistedAuthenticator{ + auther: auther, + ipMatcher: matcher.IPMatcher(ips), + cidrMatcher: matcher.CIDRMatcher(inets), + } +} + +func (w *whitelistedAuthenticator) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) { + if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil { + host, _, err := net.SplitHostPort(srcAddr.String()) + if err == nil && host != "" { + if w.ipMatcher.Match(host) || w.cidrMatcher.Match(host) { + return "", true + } + } + } + if w.auther != nil { + return w.auther.Authenticate(ctx, user, password, opts...) + } + return "", false +} diff --git a/config/parsing/service/parse.go b/config/parsing/service/parse.go index e9658338..315691d1 100644 --- a/config/parsing/service/parse.go +++ b/config/parsing/service/parse.go @@ -303,6 +303,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) { auther = xauth.AuthenticatorGroup(authers...) } + // Parse skipauth from handler metadata to whitelist client IPs that + // should bypass authentication. Accepts both YAML arrays + // (skipauth: ["10.0.0.0/8"]) and comma-separated URL query values + // (?skipauth=10.0.0.0/8,192.168.1.1). + if cfg.Handler.Metadata != nil { + hmd := metadata.NewMetadata(cfg.Handler.Metadata) + skipauth := mdutil.GetStrings(hmd, "skipauth") + if len(skipauth) == 0 { + if s := mdutil.GetString(hmd, "skipauth"); s != "" { + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + skipauth = append(skipauth, p) + } + } + } + } + if len(skipauth) > 0 { + if auther != nil { + auther = xauth.WhitelistedAuthenticator(auther, skipauth) + handlerLogger.Debugf("skipauth whitelist applied: %v", skipauth) + } else { + handlerLogger.Warnf("skipauth configured but no auther set — authentication is already disabled for all clients") + } + } + } + var recorders []recorder.RecorderObject for _, r := range cfg.Recorders { md := metadata.NewMetadata(r.Metadata)