mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7041074a8 | ||
|
|
d024c7786e | ||
|
|
d8317d876f | ||
|
|
69c397ded9 | ||
|
|
7f36d10605 | ||
|
|
5b1ff8330b | ||
|
|
3193eebd6c | ||
|
|
c71b16490b | ||
|
|
ae73d1ba61 | ||
|
|
4c086685b1 | ||
|
|
56c72823cb | ||
|
|
2a9e059bc2 | ||
|
|
f63f36805b | ||
|
|
1b07475bf3 | ||
|
|
3b25e0317f | ||
|
|
fb0cf72446 | ||
|
|
ee7b1e9462 | ||
|
|
143438a30e | ||
|
|
cd97e5e746 | ||
|
|
c1d954d689 | ||
|
|
d4e9450bad | ||
|
|
b777bcb81a | ||
|
|
39580f04ca | ||
|
|
b01f5d065f | ||
|
|
8fd5200bf1 | ||
|
|
ec4791485c | ||
|
|
c06ba17916 | ||
|
|
a32ffd5608 | ||
|
|
ffa7ceaed7 | ||
|
|
ea3ca94e53 | ||
|
|
5d6b00c3a6 | ||
|
|
0637486025 | ||
|
|
99a0f7323d | ||
|
|
4057ac54b8 | ||
|
|
6a0ce8914e | ||
|
|
03f4b1291e | ||
|
|
2e16457467 | ||
|
|
a1d7da4fec | ||
|
|
0522ae8155 | ||
|
|
55a246869e | ||
|
|
3ec3362ac4 | ||
|
|
3f01456480 | ||
|
|
9ea10763e4 | ||
|
|
e814852ca1 | ||
|
|
a102f9c6cf | ||
|
|
9ccf402ed1 | ||
|
|
61eb3e2a3b | ||
|
|
92002fa35e | ||
|
|
a424148018 | ||
|
|
d4d823df45 | ||
|
|
c45d27113e | ||
|
|
9fab332772 | ||
|
|
37bc81576c | ||
|
|
504c56f3c3 | ||
|
|
ae92f5aee6 | ||
|
|
48f31513c7 | ||
|
|
548471717a | ||
|
|
e583f5b538 | ||
|
|
9c585e2de8 | ||
|
|
34549e0e98 | ||
|
|
684a0ddf65 | ||
|
|
9fcd7d6890 | ||
|
|
66f502b153 | ||
|
|
1d34d4543a | ||
|
|
296d87a597 | ||
|
|
c29517be4d | ||
|
|
b23553d37a | ||
|
|
b7d807b464 | ||
|
|
dc31d5c15f |
@@ -163,10 +163,20 @@ type udpConn struct {
|
||||
// WrapUDPConn wraps a net.PacketConn as a udp.Conn with admission control.
|
||||
// This is used in UDP forwarding paths where the connection is treated
|
||||
// as a connected UDP socket.
|
||||
func WrapUDPConn(admission admission.Admission, pc net.PacketConn) udp.Conn {
|
||||
// If pc is nil, nil is returned. If admission is nil, the original connection
|
||||
// is returned unchanged (no-op).
|
||||
func WrapUDPConn(adm admission.Admission, pc net.PacketConn) udp.Conn {
|
||||
if pc == nil {
|
||||
return nil
|
||||
}
|
||||
if adm == nil {
|
||||
if uc, ok := pc.(udp.Conn); ok {
|
||||
return uc
|
||||
}
|
||||
}
|
||||
return &udpConn{
|
||||
PacketConn: pc,
|
||||
admission: admission,
|
||||
admission: adm,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,13 @@ func Register(r *gin.Engine, opts *Options) {
|
||||
config.PUT("/limiters/:limiter", updateLimiter)
|
||||
config.DELETE("/limiters/:limiter", deleteLimiter)
|
||||
|
||||
config.GET("/quotas", getQuotaList)
|
||||
config.GET("/quotas/:quota", getQuota)
|
||||
config.POST("/quotas", createQuota)
|
||||
config.PUT("/quotas/:quota", updateQuota)
|
||||
config.DELETE("/quotas/:quota", deleteQuota)
|
||||
config.POST("/quotas/:quota/reset", resetQuota)
|
||||
|
||||
config.GET("/climiters", getConnLimiterList)
|
||||
config.GET("/climiters/:limiter", getConnLimiter)
|
||||
config.POST("/climiters", createConnLimiter)
|
||||
|
||||
@@ -35,6 +35,29 @@ func fillServiceStatus(svc *config.ServiceConfig) {
|
||||
OutputBytes: st.Get(stats.KindOutputBytes),
|
||||
}
|
||||
}
|
||||
var stopped bool
|
||||
for _, qname := range svc.Quotas {
|
||||
lim := registry.QuotaLimiterRegistry().Get(qname)
|
||||
if lim == nil {
|
||||
continue
|
||||
}
|
||||
qs := lim.Snapshot()
|
||||
if qs.Blocked {
|
||||
stopped = true
|
||||
}
|
||||
svc.Status.Quotas = append(svc.Status.Quotas, config.ServiceQuota{
|
||||
Name: qname,
|
||||
Used: qs.Used,
|
||||
Limit: qs.Limit,
|
||||
StartsAt: qs.StartsAtUnix,
|
||||
ExpiresAt: qs.ExpiresAtUnix,
|
||||
Active: qs.Active,
|
||||
Expired: qs.Expired,
|
||||
Blocked: qs.Blocked,
|
||||
Direction: qs.Direction,
|
||||
})
|
||||
}
|
||||
svc.Status.StoppedByLimit = stopped
|
||||
for _, ev := range status.Events() {
|
||||
if !ev.Time.IsZero() {
|
||||
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/x/config"
|
||||
parser "github.com/go-gost/x/config/parsing/quota"
|
||||
"github.com/go-gost/x/limiter/quota"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
func fillQuotaStatus(q *config.QuotaConfig) {
|
||||
lim := registry.QuotaLimiterRegistry().Get(q.Name)
|
||||
if lim == nil {
|
||||
return
|
||||
}
|
||||
s := lim.Snapshot()
|
||||
q.Status = &config.QuotaStatus{
|
||||
Used: s.Used,
|
||||
Limit: s.Limit,
|
||||
StartsAt: s.StartsAtUnix,
|
||||
ExpiresAt: s.ExpiresAtUnix,
|
||||
Active: s.Active,
|
||||
Expired: s.Expired,
|
||||
Blocked: s.Blocked,
|
||||
Direction: s.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters getQuotaListRequest
|
||||
type getQuotaListRequest struct {
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getQuotaListResponse
|
||||
type getQuotaListResponse struct {
|
||||
// in: body
|
||||
Data quotaList
|
||||
}
|
||||
|
||||
type quotaList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.QuotaConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getQuotaList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/quotas Quota getQuotaListRequest
|
||||
//
|
||||
// Get quota list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getQuotaListResponse
|
||||
|
||||
var req getQuotaListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Quotas
|
||||
for _, q := range list {
|
||||
if q == nil {
|
||||
continue
|
||||
}
|
||||
fillQuotaStatus(q)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: quotaList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getQuotaRequest
|
||||
type getQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getQuotaResponse
|
||||
type getQuotaResponse struct {
|
||||
// in: body
|
||||
Data *config.QuotaConfig
|
||||
}
|
||||
|
||||
func getQuota(ctx *gin.Context) {
|
||||
// swagger:route GET /config/quotas/{quota} Quota getQuotaRequest
|
||||
//
|
||||
// Get quota.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getQuotaResponse
|
||||
|
||||
var req getQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getQuotaResponse
|
||||
|
||||
for _, q := range config.Global().Quotas {
|
||||
if q == nil {
|
||||
continue
|
||||
}
|
||||
if q.Name == req.Quota {
|
||||
fillQuotaStatus(q)
|
||||
resp.Data = q
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createQuotaRequest
|
||||
type createQuotaRequest struct {
|
||||
// in: body
|
||||
Data config.QuotaConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response createQuotaResponse
|
||||
type createQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func createQuota(ctx *gin.Context) {
|
||||
// swagger:route POST /config/quotas Quota createQuotaRequest
|
||||
//
|
||||
// Create a new quota, the name of the quota must be unique in quota list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: createQuotaResponse
|
||||
|
||||
var req createQuotaRequest
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Data.Name)
|
||||
if name == "" {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "quota name is required"))
|
||||
return
|
||||
}
|
||||
req.Data.Name = name
|
||||
req.Data.Status = nil
|
||||
|
||||
if registry.QuotaLimiterRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
v := parser.ParseQuotaLimiter(&req.Data)
|
||||
|
||||
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
c.Quotas = append(c.Quotas, &req.Data)
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters updateQuotaRequest
|
||||
type updateQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
// in: body
|
||||
Data config.QuotaConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response updateQuotaResponse
|
||||
type updateQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func updateQuota(ctx *gin.Context) {
|
||||
// swagger:route PUT /config/quotas/{quota} Quota updateQuotaRequest
|
||||
//
|
||||
// Update quota by name, the quota must already exist. The cumulative counter
|
||||
// is preserved across the update as long as the window is unchanged.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: updateQuotaResponse
|
||||
|
||||
var req updateQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Quota)
|
||||
|
||||
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
|
||||
return
|
||||
}
|
||||
|
||||
req.Data.Name = name
|
||||
req.Data.Status = nil
|
||||
|
||||
v := parser.ParseQuotaLimiter(&req.Data)
|
||||
|
||||
registry.QuotaLimiterRegistry().Unregister(name)
|
||||
|
||||
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
for i := range c.Quotas {
|
||||
if c.Quotas[i].Name == name {
|
||||
c.Quotas[i] = &req.Data
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters deleteQuotaRequest
|
||||
type deleteQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response deleteQuotaResponse
|
||||
type deleteQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func deleteQuota(ctx *gin.Context) {
|
||||
// swagger:route DELETE /config/quotas/{quota} Quota deleteQuotaRequest
|
||||
//
|
||||
// Delete quota by name. Services referencing it stop being limited (fail-open).
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: deleteQuotaResponse
|
||||
|
||||
var req deleteQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
name := strings.TrimSpace(req.Quota)
|
||||
|
||||
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
|
||||
return
|
||||
}
|
||||
registry.QuotaLimiterRegistry().Unregister(name)
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
quotas := c.Quotas
|
||||
c.Quotas = nil
|
||||
for _, q := range quotas {
|
||||
if q.Name == name {
|
||||
continue
|
||||
}
|
||||
c.Quotas = append(c.Quotas, q)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters resetQuotaRequest
|
||||
type resetQuotaRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Quota string `uri:"quota" json:"quota"`
|
||||
// in: body
|
||||
Data resetQuotaData `json:"data"`
|
||||
}
|
||||
|
||||
type resetQuotaData struct {
|
||||
Used *uint64 `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response resetQuotaResponse
|
||||
type resetQuotaResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func resetQuota(ctx *gin.Context) {
|
||||
// swagger:route POST /config/quotas/{quota}/reset Quota resetQuotaRequest
|
||||
//
|
||||
// Overwrite the cumulative counter of a quota (defaults to 0). The new value
|
||||
// is applied immediately and persisted; if the quota was blocking, services
|
||||
// resume once the value is below the limit.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: resetQuotaResponse
|
||||
|
||||
var req resetQuotaRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Quota)
|
||||
|
||||
lim := registry.QuotaLimiterRegistry().Get(name)
|
||||
if lim == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
|
||||
return
|
||||
}
|
||||
|
||||
used := uint64(0)
|
||||
if req.Data.Used != nil {
|
||||
used = *req.Data.Used
|
||||
}
|
||||
lim.Update(quota.Update{Used: &used})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+27
-1
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
@@ -141,9 +142,34 @@ func (r *Router) dial(ctx context.Context, network, address string, log logger.L
|
||||
if route == nil {
|
||||
route = DefaultRoute
|
||||
}
|
||||
ifceName := r.options.IfceName
|
||||
if strings.Contains(ifceName, "auto") {
|
||||
// "auto" triggers per-connection interface detection: the
|
||||
// ingress IP (conn.LocalAddr()) is used as the bind address,
|
||||
// ensuring egress traffic leaves via the same interface that
|
||||
// received the connection — the "source-in source-out"
|
||||
// pattern for multi-homed hosts.
|
||||
if dstAddr := xctx.DstAddrFromContext(ctx); dstAddr != nil {
|
||||
if host, _, _ := net.SplitHostPort(dstAddr.String()); host != "" &&
|
||||
host != "0.0.0.0" && host != "::" {
|
||||
// Replace only exact "auto" tokens in the
|
||||
// comma-separated interface list.
|
||||
parts := strings.Split(ifceName, ",")
|
||||
for i, p := range parts {
|
||||
if p == "auto" {
|
||||
parts[i] = host
|
||||
}
|
||||
}
|
||||
ifceName = strings.Join(parts, ",")
|
||||
}
|
||||
}
|
||||
if strings.Contains(ifceName, "auto") {
|
||||
log.Debugf("auto interface: no suitable ingress address, keeping literal %q", ifceName)
|
||||
}
|
||||
}
|
||||
conn, err = route.Dial(ctx, network, ipAddr,
|
||||
append([]chain.DialOption{
|
||||
chain.InterfaceDialOption(r.options.IfceName),
|
||||
chain.InterfaceDialOption(ifceName),
|
||||
chain.NetnsDialOption(r.options.Netns),
|
||||
chain.SockOptsDialOption(r.options.SockOpts),
|
||||
chain.LoggerDialOption(log),
|
||||
|
||||
@@ -466,6 +466,17 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// stdio listener uses url.Host as the forwarding target.
|
||||
if listener == "stdio" && len(nodes) == 0 && url.Host != "" {
|
||||
nodes = append(nodes, &config.ForwardNodeConfig{
|
||||
Name: fmt.Sprintf("%starget-0", namePrefix),
|
||||
Addr: url.Host,
|
||||
})
|
||||
if handler == "auto" {
|
||||
handler = "tcp"
|
||||
}
|
||||
}
|
||||
|
||||
if len(nodes) > 0 {
|
||||
if len(services) == 1 {
|
||||
services[0].Forwarder = &config.ForwarderConfig{
|
||||
|
||||
+83
-6
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -314,6 +316,11 @@ type RecorderObject struct {
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RewriterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
type LimiterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
|
||||
@@ -395,6 +402,9 @@ type HTTPBodyRewriteConfig struct {
|
||||
Type string
|
||||
Match string
|
||||
Replacement string
|
||||
// name of the rewriter plugin (via registry)
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
MaxChunkSize int `yaml:"maxChunkSize,omitempty" json:"maxChunkSize,omitempty"`
|
||||
}
|
||||
|
||||
type NodeFilterConfig struct {
|
||||
@@ -403,6 +413,15 @@ type NodeFilterConfig struct {
|
||||
Path string `yaml:",omitempty" json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMatcherConfig defines a routing-rule matcher for a hop node.
|
||||
//
|
||||
// Priority controls election among multiple matching nodes:
|
||||
// - 0 (default): auto-computed from the rule string length — longer rules
|
||||
// (more specific) get higher priority.
|
||||
// - negative: the node participates in matching but priority short-circuit
|
||||
// is disabled; the selector (round-robin, random, hash, etc.) always applies.
|
||||
// - positive: explicit priority; when a single node has strictly higher
|
||||
// priority than all others, it wins directly, bypassing the selector.
|
||||
type NodeMatcherConfig struct {
|
||||
Rule string `yaml:",omitempty" json:"rule,omitempty"`
|
||||
Priority int `yaml:",omitempty" json:"priority,omitempty"`
|
||||
@@ -470,13 +489,15 @@ type ServiceConfig struct {
|
||||
Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
|
||||
Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
|
||||
Limiter string `yaml:",omitempty" json:"limiter,omitempty"`
|
||||
Quotas []string `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
CLimiter string `yaml:"climiter,omitempty" json:"climiter,omitempty"`
|
||||
RLimiter string `yaml:"rlimiter,omitempty" json:"rlimiter,omitempty"`
|
||||
Logger string `yaml:",omitempty" json:"logger,omitempty"`
|
||||
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
|
||||
Observer string `yaml:",omitempty" json:"observer,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
|
||||
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
@@ -485,10 +506,12 @@ type ServiceConfig struct {
|
||||
}
|
||||
|
||||
type ServiceStatus struct {
|
||||
CreateTime int64 `yaml:"createTime" json:"createTime"`
|
||||
State string `yaml:"state" json:"state"`
|
||||
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
|
||||
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
|
||||
CreateTime int64 `yaml:"createTime" json:"createTime"`
|
||||
State string `yaml:"state" json:"state"`
|
||||
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
|
||||
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
|
||||
StoppedByLimit bool `yaml:"stoppedByLimit,omitempty" json:"stoppedByLimit,omitempty"`
|
||||
Quotas []ServiceQuota `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceEvent struct {
|
||||
@@ -504,6 +527,57 @@ type ServiceStats struct {
|
||||
OutputBytes uint64 `yaml:"outputBytes" json:"outputBytes"`
|
||||
}
|
||||
|
||||
// QuotaConfig is a named cumulative traffic-volume limiter; the same name
|
||||
// referenced by several services (ServiceConfig.Quotas) shares one counter.
|
||||
type QuotaConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limit string `yaml:",omitempty" json:"limit,omitempty"`
|
||||
StartsAt string `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
|
||||
ExpiresAt string `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||
Direction string `yaml:",omitempty" json:"direction,omitempty"`
|
||||
Flush string `yaml:",omitempty" json:"flush,omitempty"`
|
||||
Store *QuotaStoreConfig `yaml:",omitempty" json:"store,omitempty"`
|
||||
// read-only
|
||||
Status *QuotaStatus `yaml:",omitempty" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type QuotaStoreConfig struct {
|
||||
Type string `json:"type"` // "file" (default) | "redis" (stub, not implemented)
|
||||
File string `yaml:",omitempty" json:"file,omitempty"`
|
||||
Redis *QuotaRedisConfig `yaml:",omitempty" json:"redis,omitempty"`
|
||||
}
|
||||
|
||||
type QuotaRedisConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
DB int `yaml:",omitempty" json:"db,omitempty"`
|
||||
Username string `yaml:",omitempty" json:"username,omitempty"`
|
||||
Password string `yaml:",omitempty" json:"password,omitempty"`
|
||||
Key string `yaml:",omitempty" json:"key,omitempty"`
|
||||
}
|
||||
|
||||
type QuotaStatus struct {
|
||||
Used uint64 `yaml:"used" json:"used"`
|
||||
Limit uint64 `yaml:"limit" json:"limit"`
|
||||
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
|
||||
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||
Active bool `yaml:"active" json:"active"`
|
||||
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
|
||||
Blocked bool `yaml:"blocked" json:"blocked"`
|
||||
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceQuota struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Used uint64 `yaml:"used" json:"used"`
|
||||
Limit uint64 `yaml:"limit" json:"limit"`
|
||||
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
|
||||
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||
Active bool `yaml:"active" json:"active"`
|
||||
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
|
||||
Blocked bool `yaml:"blocked" json:"blocked"`
|
||||
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
|
||||
}
|
||||
|
||||
type ChainConfig struct {
|
||||
Name string `json:"name"`
|
||||
Hops []*HopConfig `json:"hops"`
|
||||
@@ -572,7 +646,9 @@ type Config struct {
|
||||
Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"`
|
||||
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
|
||||
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
|
||||
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
|
||||
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
|
||||
RLimiters []*LimiterConfig `yaml:"rlimiters,omitempty" json:"rlimiters,omitempty"`
|
||||
Observers []*ObserverConfig `yaml:",omitempty" json:"observers,omitempty"`
|
||||
@@ -603,6 +679,7 @@ func (c *Config) Read(r io.Reader, configType string) error {
|
||||
|
||||
func (c *Config) ReadFile(file string) error {
|
||||
v.SetConfigFile(file)
|
||||
v.SetConfigType(strings.TrimPrefix(filepath.Ext(file), ".")) // force format from extension
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
reg "github.com/go-gost/core/registry"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/core/router"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/sd"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
@@ -35,11 +36,14 @@ import (
|
||||
limiter_parser "github.com/go-gost/x/config/parsing/limiter"
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
observer_parser "github.com/go-gost/x/config/parsing/observer"
|
||||
quota_parser "github.com/go-gost/x/config/parsing/quota"
|
||||
recorder_parser "github.com/go-gost/x/config/parsing/recorder"
|
||||
resolver_parser "github.com/go-gost/x/config/parsing/resolver"
|
||||
rewriter_parser "github.com/go-gost/x/config/parsing/rewriter"
|
||||
router_parser "github.com/go-gost/x/config/parsing/router"
|
||||
sd_parser "github.com/go-gost/x/config/parsing/sd"
|
||||
service_parser "github.com/go-gost/x/config/parsing/service"
|
||||
quota "github.com/go-gost/x/limiter/quota"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -242,6 +246,16 @@ func register(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[rewriter.Rewriter]
|
||||
for _, c := range cfg.Rewriters {
|
||||
entries = append(entries, named[rewriter.Rewriter]{c.Name, rewriter_parser.ParseRewriter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.RewriterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[traffic.TrafficLimiter]
|
||||
for _, c := range cfg.Limiters {
|
||||
@@ -252,6 +266,16 @@ func register(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[*quota.Limiter]
|
||||
for _, c := range cfg.Quotas {
|
||||
entries = append(entries, named[*quota.Limiter]{c.Name, quota_parser.ParseQuotaLimiter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.QuotaLimiterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[conn.ConnLimiter]
|
||||
for _, c := range cfg.CLimiters {
|
||||
|
||||
@@ -29,6 +29,30 @@ import (
|
||||
// metadata-driven options (so_mark, interface, netns, proxy protocol), sets up
|
||||
// bypass rules, node filters, HTTP settings, and TLS node settings. The hop
|
||||
// parameter is used only for logging context.
|
||||
|
||||
func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []chain.HTTPBodyRewriteSettings {
|
||||
var out []chain.HTTPBodyRewriteSettings
|
||||
for _, v := range vs {
|
||||
pattern, _ := regexp.Compile(v.Match)
|
||||
rw := chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
MaxChunkSize: v.MaxChunkSize,
|
||||
}
|
||||
if v.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(v.Rewriter) {
|
||||
log.Warnf("rewriter %q not found in registry for rewrite rule", v.Rewriter)
|
||||
}
|
||||
rw.Rewriter = registry.RewriterRegistry().Get(v.Rewriter)
|
||||
}
|
||||
if pattern != nil || rw.Rewriter != nil {
|
||||
out = append(out, rw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
@@ -176,6 +200,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
|
||||
if matcher, err := routing.NewMatcher(rule); err == nil {
|
||||
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule)
|
||||
// Priority 0 means "use default": automatically set to the
|
||||
// rule length so longer (more specific) rules outrank shorter
|
||||
// ones. Use a negative priority to opt out of this behavior
|
||||
// and always go through the selector.
|
||||
if priority == 0 {
|
||||
priority = len(cfg.Matcher.Rule)
|
||||
}
|
||||
@@ -222,33 +250,9 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteBody {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteResponseBody {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteRequestBody {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteRequestBody = append(settings.RewriteRequestBody, chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
})
|
||||
}
|
||||
}
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
|
||||
settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
|
||||
opts = append(opts, chain.HTTPNodeOption(settings))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
@@ -47,6 +52,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
||||
SDs: []*config.SDConfig{{Name: "sd1"}},
|
||||
Recorders: []*config.RecorderConfig{{Name: "rec1"}},
|
||||
Limiters: []*config.LimiterConfig{{Name: "l1"}},
|
||||
Quotas: []*config.QuotaConfig{{Name: "q1"}},
|
||||
CLimiters: []*config.LimiterConfig{{Name: "cl1"}},
|
||||
RLimiters: []*config.LimiterConfig{{Name: "rl1"}},
|
||||
Loggers: []*config.LoggerConfig{{Name: "log1"}},
|
||||
@@ -66,6 +72,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
||||
SDs: []*config.SDConfig{{Name: "sd2"}},
|
||||
Recorders: []*config.RecorderConfig{{Name: "rec2"}},
|
||||
Limiters: []*config.LimiterConfig{{Name: "l2"}},
|
||||
Quotas: []*config.QuotaConfig{{Name: "q2"}},
|
||||
CLimiters: []*config.LimiterConfig{{Name: "cl2"}},
|
||||
RLimiters: []*config.LimiterConfig{{Name: "rl2"}},
|
||||
Loggers: []*config.LoggerConfig{{Name: "log2"}},
|
||||
@@ -111,6 +118,9 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
||||
if len(got.Limiters) != 2 {
|
||||
t.Fatal("limiters not properly appended")
|
||||
}
|
||||
if len(got.Quotas) != 2 {
|
||||
t.Fatal("quotas not properly appended")
|
||||
}
|
||||
if len(got.CLimiters) != 2 {
|
||||
t.Fatal("climiters not properly appended")
|
||||
}
|
||||
@@ -194,7 +204,7 @@ func TestMergeConfig_ScalarKeepsCfg1WhenCfg2Nil(t *testing.T) {
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
Init(Args{
|
||||
CfgFile: "test.yml",
|
||||
CfgFiles: []string{"test.yml"},
|
||||
Services: []string{"s1"},
|
||||
Nodes: []string{"n1"},
|
||||
Debug: true,
|
||||
@@ -203,8 +213,8 @@ func TestInit(t *testing.T) {
|
||||
MetricsAddr: ":9090",
|
||||
})
|
||||
|
||||
if defaultParser.args.CfgFile != "test.yml" {
|
||||
t.Fatalf("CfgFile not set: got %q", defaultParser.args.CfgFile)
|
||||
if len(defaultParser.args.CfgFiles) != 1 || defaultParser.args.CfgFiles[0] != "test.yml" {
|
||||
t.Fatalf("CfgFiles not set: got %v", defaultParser.args.CfgFiles)
|
||||
}
|
||||
if len(defaultParser.args.Services) != 1 || defaultParser.args.Services[0] != "s1" {
|
||||
t.Fatal("Services not set")
|
||||
@@ -225,3 +235,246 @@ func TestInit(t *testing.T) {
|
||||
t.Fatal("MetricsAddr not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_MultiFile(t *testing.T) {
|
||||
// Create two temporary config files.
|
||||
cfg1 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "s1", Addr: ":8080"}},
|
||||
}
|
||||
cfg2 := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "s2", Addr: ":8081"}},
|
||||
Log: &config.LogConfig{Level: "debug"},
|
||||
Quotas: []*config.QuotaConfig{{Name: "q1"}},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir, "cfg1.yml")
|
||||
file2 := filepath.Join(tmpDir, "cfg2.yml")
|
||||
|
||||
for _, entry := range []struct {
|
||||
path string
|
||||
cfg *config.Config
|
||||
}{
|
||||
{file1, cfg1},
|
||||
{file2, cfg2},
|
||||
} {
|
||||
f, err := os.Create(entry.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := entry.cfg.Write(f, "yaml"); err != nil {
|
||||
f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
Init(Args{
|
||||
CfgFiles: []string{file1, file2},
|
||||
})
|
||||
|
||||
got, err := Parse()
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(): %v", err)
|
||||
}
|
||||
|
||||
if len(got.Services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(got.Services))
|
||||
}
|
||||
if got.Log == nil || got.Log.Level != "debug" {
|
||||
t.Fatal("Log.Level not set from cfg2")
|
||||
}
|
||||
if len(got.Quotas) != 1 || got.Quotas[0].Name != "q1" {
|
||||
t.Fatal("Quotas not merged from cfg2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTPURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"http://example.com/config.yaml", true},
|
||||
{"https://example.com/config.json", true},
|
||||
{"ftp://example.com/config.yaml", false},
|
||||
{"config.yaml", false},
|
||||
{"/path/to/config.yml", false},
|
||||
{"http://[::1]:8080/cfg", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isHTTPURL(tt.input); got != tt.expected {
|
||||
t.Errorf("isHTTPURL(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
contentType string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"", "http://example.com/config.json", "json"},
|
||||
{"", "http://example.com/config.yml", "yaml"},
|
||||
{"", "http://example.com/config.yaml", "yaml"},
|
||||
{"", "http://example.com/config", "yaml"}, // default
|
||||
{"application/json", "http://example.com/config", "json"},
|
||||
{"application/yaml", "http://example.com/config", "yaml"},
|
||||
{"application/x-yaml", "http://example.com/config", "yaml"},
|
||||
{"text/yaml", "http://example.com/config", "yaml"},
|
||||
{"text/x-yaml", "http://example.com/config", "yaml"},
|
||||
{"text/plain; charset=utf-8", "http://example.com/config.json", "json"}, // content-type ignored, extension wins
|
||||
{"text/plain", "http://example.com/config", "yaml"}, // unknown -> default
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := detectFormat(tt.contentType, tt.url); got != tt.want {
|
||||
t.Errorf("detectFormat(%q, %q) = %q, want %q", tt.contentType, tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"http://user:pass@example.com/path", "http://example.com/path"},
|
||||
{"https://example.com/path", "https://example.com/path"},
|
||||
{"not a url", "not%20a%20url"},
|
||||
{"http://user@example.com/path", "http://example.com/path"}, // user with no password
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := sanitizeURL(tt.input); got != tt.expected {
|
||||
t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_YAML(t *testing.T) {
|
||||
// Write a YAML config and serve it via httptest.
|
||||
cfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "svc", Addr: ":8080"}},
|
||||
Log: &config.LogConfig{Level: "debug"},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := cfg.Write(&buf, "yaml"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := buf.Bytes()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
got := &config.Config{}
|
||||
if err := readConfigFromURL(ts.URL, got); err != nil {
|
||||
t.Fatalf("readConfigFromURL: %v", err)
|
||||
}
|
||||
if len(got.Services) != 1 || got.Services[0].Name != "svc" {
|
||||
t.Fatalf("unexpected services: %+v", got.Services)
|
||||
}
|
||||
if got.Log == nil || got.Log.Level != "debug" {
|
||||
t.Fatalf("unexpected log: %+v", got.Log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_JSON(t *testing.T) {
|
||||
// Remote serving JSON with explicit Content-Type.
|
||||
cfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "json-svc", Addr: ":9090"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := cfg.Write(&buf, "json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := buf.Bytes()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
got := &config.Config{}
|
||||
if err := readConfigFromURL(ts.URL, got); err != nil {
|
||||
t.Fatalf("readConfigFromURL: %v", err)
|
||||
}
|
||||
if got.Services[0].Name != "json-svc" {
|
||||
t.Fatalf("unexpected service name: %s", got.Services[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_Non200(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
err := readConfigFromURL(ts.URL, &config.Config{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigFromURL_OverSize(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(make([]byte, maxConfigSize+1))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
err := readConfigFromURL(ts.URL, &config.Config{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for oversized response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileAndURL(t *testing.T) {
|
||||
// Create a local file.
|
||||
fileCfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "file-svc", Addr: ":8080"}},
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
f, err := os.Create(filepath.Join(tmpDir, "local.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fileCfg.Write(f, "yaml"); err != nil {
|
||||
f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Serve a second config over HTTP.
|
||||
urlCfg := &config.Config{
|
||||
Services: []*config.ServiceConfig{{Name: "url-svc", Addr: ":8081"}},
|
||||
Log: &config.LogConfig{Level: "debug"},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := urlCfg.Write(&buf, "yaml"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := buf.Bytes()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
Init(Args{
|
||||
CfgFiles: []string{filepath.Join(tmpDir, "local.yml"), ts.URL},
|
||||
})
|
||||
|
||||
got, err := Parse()
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(): %v", err)
|
||||
}
|
||||
if len(got.Services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(got.Services))
|
||||
}
|
||||
if got.Log == nil || got.Log.Level != "debug" {
|
||||
t.Fatal("Log.Level not set from URL config")
|
||||
}
|
||||
}
|
||||
|
||||
+145
-28
@@ -2,9 +2,17 @@ package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
@@ -35,9 +43,10 @@ func Parse() (*config.Config, error) {
|
||||
// Args holds the raw CLI flags and positional arguments that Init will merge
|
||||
// into the final configuration.
|
||||
type Args struct {
|
||||
// CfgFile is the path to a YAML or JSON config file, "-" for stdin, or
|
||||
// an inline JSON string.
|
||||
CfgFile string
|
||||
// CfgFiles is the list of YAML or JSON config sources: file paths,
|
||||
// HTTP(S) URLs, "-" for stdin, or an inline JSON string.
|
||||
// Multiple sources are merged left-to-right.
|
||||
CfgFiles []string
|
||||
|
||||
// Services is the list of service definitions from -L flags.
|
||||
Services []string
|
||||
@@ -65,31 +74,16 @@ type parser struct {
|
||||
func (p *parser) Parse() (*config.Config, error) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
if cfgFile := strings.TrimSpace(p.args.CfgFile); cfgFile != "" {
|
||||
if cfgFile == "-" { // stdin
|
||||
br := bufio.NewReader(os.Stdin)
|
||||
b, err := br.Peek(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b[0] == '{' {
|
||||
if err := cfg.Read(br, "json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := cfg.Read(br, "yaml"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline
|
||||
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := cfg.ReadFile(cfgFile); err != nil { // file
|
||||
return nil, err
|
||||
}
|
||||
for _, cfgFile := range p.args.CfgFiles {
|
||||
cfgFile = strings.TrimSpace(cfgFile)
|
||||
if cfgFile == "" {
|
||||
continue
|
||||
}
|
||||
fcfg, err := readConfig(cfgFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = mergeConfig(cfg, fcfg)
|
||||
}
|
||||
|
||||
cmdCfg, err := cmd.BuildConfigFromCmd(p.args.Services, p.args.Nodes)
|
||||
@@ -98,7 +92,8 @@ func (p *parser) Parse() (*config.Config, error) {
|
||||
}
|
||||
cfg = mergeConfig(cfg, cmdCfg)
|
||||
|
||||
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil {
|
||||
if len(cfg.Services) == 0 && p.args.ApiAddr == "" && cfg.API == nil &&
|
||||
len(p.args.CfgFiles) == 0 {
|
||||
if err := cfg.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,6 +188,126 @@ func (p *parser) Parse() (*config.Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// readConfig reads a single configuration source, which may be "-" (stdin),
|
||||
// an HTTP(S) URL, an inline JSON string starting with "{", or a file path.
|
||||
func readConfig(cfgFile string) (*config.Config, error) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
if cfgFile == "-" { // stdin
|
||||
br := bufio.NewReader(os.Stdin)
|
||||
b, err := br.Peek(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b[0] == '{' {
|
||||
if err := cfg.Read(br, "json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := cfg.Read(br, "yaml"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(cfgFile, "{") && strings.HasSuffix(cfgFile, "}") { // inline
|
||||
if err := json.Unmarshal([]byte(cfgFile), cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if isHTTPURL(cfgFile) { // URL
|
||||
if err := readConfigFromURL(cfgFile, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := cfg.ReadFile(cfgFile); err != nil { // file
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// isHTTPURL reports whether s is an HTTP or HTTPS URL.
|
||||
func isHTTPURL(s string) bool {
|
||||
u, err := url.Parse(s)
|
||||
return err == nil && u.Scheme != "" && u.Host != "" &&
|
||||
(u.Scheme == "http" || u.Scheme == "https")
|
||||
}
|
||||
|
||||
// maxConfigSize is the maximum response body size for remote config files.
|
||||
const maxConfigSize = 10 << 20 // 10 MiB
|
||||
|
||||
// sharedHTTPClient is reused across config URL fetches to benefit from
|
||||
// connection pooling.
|
||||
var sharedHTTPClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// readConfigFromURL fetches a configuration from an HTTP(S) URL and reads it
|
||||
// into cfg. The format (yaml or json) is detected from the Content-Type header
|
||||
// or the URL path extension.
|
||||
func readConfigFromURL(urlStr string, cfg *config.Config) error {
|
||||
resp, err := sharedHTTPClient.Get(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch config from %s: %w", sanitizeURL(urlStr), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body) // drain body for connection reuse; ignore error since we are already failing
|
||||
return fmt.Errorf("fetch config from %s: %s", sanitizeURL(urlStr), resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxConfigSize+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config from %s: %w", sanitizeURL(urlStr), err)
|
||||
}
|
||||
if len(data) > maxConfigSize {
|
||||
return fmt.Errorf("config from %s exceeds maximum size %d", sanitizeURL(urlStr), maxConfigSize)
|
||||
}
|
||||
|
||||
format := detectFormat(resp.Header.Get("Content-Type"), urlStr)
|
||||
|
||||
if err := cfg.Read(bytes.NewReader(data), format); err != nil {
|
||||
return fmt.Errorf("parse config from %s: %w", sanitizeURL(urlStr), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeURL returns the URL with any embedded userinfo stripped for safe
|
||||
// use in error messages.
|
||||
func sanitizeURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// detectFormat determines the config format from the HTTP Content-Type header
|
||||
// or the URL path extension. Returns "yaml" or "json".
|
||||
func detectFormat(contentType, urlStr string) string {
|
||||
if mediatype, _, err := mime.ParseMediaType(contentType); err == nil {
|
||||
switch mediatype {
|
||||
case "application/json":
|
||||
return "json"
|
||||
case "application/yaml", "application/x-yaml",
|
||||
"text/yaml", "text/x-yaml":
|
||||
return "yaml"
|
||||
}
|
||||
}
|
||||
|
||||
if u, err := url.Parse(urlStr); err == nil {
|
||||
switch strings.ToLower(strings.TrimPrefix(path.Ext(u.Path), ".")) {
|
||||
case "json":
|
||||
return "json"
|
||||
case "yaml", "yml":
|
||||
return "yaml"
|
||||
}
|
||||
}
|
||||
|
||||
return "yaml"
|
||||
}
|
||||
|
||||
func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
if cfg1 == nil {
|
||||
return cfg2
|
||||
@@ -213,7 +328,9 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
|
||||
SDs: append(cfg1.SDs, cfg2.SDs...),
|
||||
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
|
||||
Rewriters: append(cfg1.Rewriters, cfg2.Rewriters...),
|
||||
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
|
||||
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
|
||||
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
|
||||
RLimiters: append(cfg1.RLimiters, cfg2.RLimiters...),
|
||||
Loggers: append(cfg1.Loggers, cfg2.Loggers...),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/units"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xquota "github.com/go-gost/x/limiter/quota"
|
||||
)
|
||||
|
||||
// ParseQuotaLimiter is best-effort: malformed fields are logged and treated as unset.
|
||||
func ParseQuotaLimiter(cfg *config.QuotaConfig) *xquota.Limiter {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log := logger.Default().WithFields(map[string]any{
|
||||
"kind": "quota",
|
||||
"quota": cfg.Name,
|
||||
})
|
||||
|
||||
var limit uint64
|
||||
if s := strings.TrimSpace(cfg.Limit); s != "" && s != "0" {
|
||||
if v, err := units.ParseBase2Bytes(s); err != nil {
|
||||
log.Warnf("quota: parse limit %q: %v", s, err)
|
||||
} else if v > 0 {
|
||||
limit = uint64(v)
|
||||
}
|
||||
}
|
||||
|
||||
var dir xquota.Direction
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.Direction)) {
|
||||
case "in":
|
||||
dir = xquota.DirectionIn
|
||||
case "out":
|
||||
dir = xquota.DirectionOut
|
||||
default:
|
||||
dir = xquota.DirectionTotal
|
||||
}
|
||||
|
||||
var flush time.Duration
|
||||
if s := strings.TrimSpace(cfg.Flush); s != "" {
|
||||
if d, err := time.ParseDuration(s); err != nil {
|
||||
log.Warnf("quota: parse flush %q: %v", s, err)
|
||||
} else {
|
||||
flush = d
|
||||
}
|
||||
}
|
||||
|
||||
return xquota.NewLimiter(cfg.Name, xquota.Options{
|
||||
Limit: limit,
|
||||
StartsAt: parseQuotaTime(cfg.StartsAt, log, "startsAt"),
|
||||
ExpiresAt: parseQuotaTime(cfg.ExpiresAt, log, "expiresAt"),
|
||||
Direction: dir,
|
||||
Flush: flush,
|
||||
Store: parseQuotaStore(cfg.Store, log),
|
||||
Logger: log,
|
||||
})
|
||||
}
|
||||
|
||||
func parseQuotaTime(s string, log logger.Logger, field string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
log.Warnf("quota: parse %s %q: %v", field, s, err)
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func parseQuotaStore(sc *config.QuotaStoreConfig, log logger.Logger) xquota.Store {
|
||||
const defaultFile = "gost-quota.json"
|
||||
|
||||
if sc == nil || sc.Type == "" || strings.EqualFold(sc.Type, "file") {
|
||||
path := defaultFile
|
||||
if sc != nil && strings.TrimSpace(sc.File) != "" {
|
||||
path = sc.File
|
||||
}
|
||||
return xquota.NewFileStore(path)
|
||||
}
|
||||
|
||||
if strings.EqualFold(sc.Type, "redis") {
|
||||
log.Warnf("quota: redis store not implemented; counter will not be persisted")
|
||||
var rc config.QuotaRedisConfig
|
||||
if sc.Redis != nil {
|
||||
rc = *sc.Redis
|
||||
}
|
||||
return xquota.NewRedisStore(xquota.RedisConfig{
|
||||
Addr: rc.Addr,
|
||||
Username: rc.Username,
|
||||
Password: rc.Password,
|
||||
DB: rc.DB,
|
||||
Key: rc.Key,
|
||||
})
|
||||
}
|
||||
|
||||
log.Warnf("quota: unknown store type %q; using file store %q", sc.Type, defaultFile)
|
||||
return xquota.NewFileStore(defaultFile)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package rewriter
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
rewriter_plugin "github.com/go-gost/x/rewriter/plugin"
|
||||
)
|
||||
|
||||
// ParseRewriter converts a RewriterConfig into a rewriter.Rewriter.
|
||||
// It currently supports plugin backends only (HTTP or gRPC).
|
||||
// Returns nil when cfg is nil or no backend is configured.
|
||||
func ParseRewriter(cfg *config.RewriterConfig) rewriter.Rewriter {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
ServerName: cfg.Plugin.TLS.ServerName,
|
||||
InsecureSkipVerify: !cfg.Plugin.TLS.Secure,
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(cfg.Plugin.Type) {
|
||||
case "http":
|
||||
return rewriter_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
default:
|
||||
return rewriter_plugin.NewGRPCPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
@@ -29,15 +30,32 @@ import (
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
|
||||
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xstats "github.com/go-gost/x/observer/stats"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
"github.com/go-gost/x/registry"
|
||||
xservice "github.com/go-gost/x/service"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
// plaintextListeners are listener types that never terminate TLS on their
|
||||
// accepted connections, so any certFile/keyFile/caFile configured for them is
|
||||
// silently ignored. This catches the common footgun where a user writes e.g.
|
||||
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
|
||||
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
|
||||
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
|
||||
// new plaintext listeners are added.
|
||||
var plaintextListeners = map[string]bool{
|
||||
"tcp": true, "udp": true,
|
||||
"ws": true, "mws": true,
|
||||
"redirect": true, "tproxy": true,
|
||||
"rtcp": true, "rudp": true,
|
||||
"unix": true, "runix": true, "serial": true, "stdio": true,
|
||||
}
|
||||
|
||||
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
|
||||
// It defaults the listener to "tcp" and the handler to "auto", resolves named
|
||||
// components from the registry (authers, admissions, bypasses, resolvers,
|
||||
@@ -87,6 +105,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
|
||||
}
|
||||
|
||||
if (tlsCfg.CertFile != "" || tlsCfg.KeyFile != "" || tlsCfg.CAFile != "") && plaintextListeners[cfg.Listener.Type] {
|
||||
serviceLogger.Warnf("TLS certificate options are configured but the %q listener does not use TLS and will ignore them; the connection will be plaintext. Use a TLS-capable listener (e.g. mwss, wss, tls, quic, grpc, http2, http3) to enable TLS.",
|
||||
cfg.Listener.Type)
|
||||
}
|
||||
|
||||
authers := auth_parser.List(cfg.Listener.Auther, cfg.Listener.Authers...)
|
||||
if len(authers) == 0 {
|
||||
if auther := auth_parser.ParseAutherFromAuth(cfg.Listener.Auth); auther != nil {
|
||||
@@ -246,6 +269,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, qname := range cfg.Quotas {
|
||||
ln = quota_wrapper.WrapListener(ln, strings.TrimSpace(qname))
|
||||
}
|
||||
|
||||
handlerLogger := serviceLogger.WithFields(map[string]any{
|
||||
"kind": "handler",
|
||||
})
|
||||
@@ -277,11 +304,44 @@ 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)
|
||||
rec := registry.RecorderRegistry().Get(r.Name)
|
||||
if r.Metadata != nil {
|
||||
rec = &xrecorder.MetadataRecorder{
|
||||
Recorder: rec,
|
||||
Metadata: r.Metadata,
|
||||
}
|
||||
}
|
||||
recorders = append(recorders, recorder.RecorderObject{
|
||||
Recorder: registry.RecorderRegistry().Get(r.Name),
|
||||
Recorder: rec,
|
||||
Record: r.Record,
|
||||
Options: &recorder.Options{
|
||||
Direction: mdutil.GetBool(md, parsing.MDKeyRecorderDirection),
|
||||
@@ -290,9 +350,18 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
HTTPBody: mdutil.GetBool(md, parsing.MDKeyRecorderHTTPBody),
|
||||
MaxBodySize: mdutil.GetInt(md, parsing.MDKeyRecorderHTTPMaxBodySize),
|
||||
},
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
var rew rewriter.Rewriter
|
||||
if cfg.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(cfg.Rewriter) {
|
||||
serviceLogger.Warnf("rewriter %q not found in registry", cfg.Rewriter)
|
||||
}
|
||||
rew = registry.RewriterRegistry().Get(cfg.Rewriter)
|
||||
}
|
||||
|
||||
routerOpts = []chain.RouterOption{
|
||||
chain.RetriesRouterOption(cfg.Handler.Retries),
|
||||
chain.TimeoutRouterOption(dialTimeout),
|
||||
@@ -322,6 +391,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
|
||||
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
|
||||
handler.RecordersOption(recorders...),
|
||||
handler.RewriterOption(rew),
|
||||
handler.LoggerOption(handlerLogger),
|
||||
handler.ServiceOption(cfg.Name),
|
||||
handler.NetnsOption(netnsIn),
|
||||
|
||||
+98
-22
@@ -12,6 +12,8 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +26,24 @@ var (
|
||||
defaultTLSConfig atomic.Value
|
||||
)
|
||||
|
||||
// testDefaultCertDir overrides the default certificate directory in tests.
|
||||
// It is set only from same-package test code and left empty in production.
|
||||
var testDefaultCertDir string
|
||||
|
||||
// defaultCertDir returns the directory used for persisting auto-generated
|
||||
// certificates. It uses testDefaultCertDir when set (for testing), otherwise
|
||||
// returns $HOME/.gost/.
|
||||
func defaultCertDir() string {
|
||||
if testDefaultCertDir != "" {
|
||||
return testDefaultCertDir
|
||||
}
|
||||
dir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".gost"
|
||||
}
|
||||
return filepath.Join(dir, ".gost")
|
||||
}
|
||||
|
||||
// DefaultTLSConfig returns the global default TLS configuration used as a
|
||||
// fallback when a listener or handler does not specify its own TLS settings.
|
||||
// The returned config is a shared instance; callers that need to mutate it
|
||||
@@ -40,44 +60,99 @@ func SetDefaultTLSConfig(cfg *tls.Config) {
|
||||
}
|
||||
|
||||
// BuildDefaultTLSConfig loads or generates the default TLS certificate and key
|
||||
// from the given config. If cfg is nil it attempts to load well-known files
|
||||
// ("cert.pem", "key.pem", "ca.pem"). On failure it generates a self-signed
|
||||
// ECDSA P-256 certificate valid for one year.
|
||||
// from the given config.
|
||||
//
|
||||
// When explicit certificate files are configured (CertFile, KeyFile, or CAFile
|
||||
// is non-empty) it loads them via tls_util.LoadDefaultConfig — the original
|
||||
// behaviour preserved for backward compatibility.
|
||||
//
|
||||
// When no certificate files are configured (all three empty), it tries sources
|
||||
// in this order:
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/ (persisted)
|
||||
// 3. If none found, generates a new ECDSA P-256 CA certificate, persists it
|
||||
// to $HOME/.gost/, and uses it.
|
||||
//
|
||||
// If cfg is nil an empty TLSConfig is used (equivalent to no explicit files),
|
||||
// so the CWD → persisted → generate path is taken.
|
||||
func BuildDefaultTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
if cfg == nil {
|
||||
cfg = &config.TLSConfig{
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
CAFile: "ca.pem",
|
||||
}
|
||||
cfg = &config.TLSConfig{}
|
||||
}
|
||||
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
// generate random self-signed certificate.
|
||||
cert, err := genCertificate(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if cfg.CertFile != "" || cfg.KeyFile != "" || cfg.CAFile != "" {
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
log.Debug("load global TLS certificate files failed, use random generated certificate")
|
||||
} else {
|
||||
log.Debug("load global TLS certificate files OK")
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
tlsConfig, err := loadOrGeneratePersistentTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func genCertificate(validity time.Duration, org string, cn string) (cert tls.Certificate, err error) {
|
||||
rawCert, rawKey, err := generateKeyPair(validity, org, cn)
|
||||
if err != nil {
|
||||
return
|
||||
// loadOrGeneratePersistentTLSConfig tries loading an auto-generated CA
|
||||
// certificate from these locations (in order):
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under defaultCertDir() (persisted)
|
||||
//
|
||||
// If none is found it generates a new one, persists it to defaultCertDir()
|
||||
// (best-effort), and returns it.
|
||||
func loadOrGeneratePersistentTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
// 1. Try CWD cert.pem / key.pem first (backward compatible).
|
||||
cwdCert, cwdErr := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if cwdErr == nil {
|
||||
log.Debug("loaded default certificate from current working directory (cert.pem / key.pem)")
|
||||
return &tls.Config{Certificates: []tls.Certificate{cwdCert}}, nil
|
||||
}
|
||||
return tls.X509KeyPair(rawCert, rawKey)
|
||||
|
||||
// 2. Try persisted certificate in defaultCertDir().
|
||||
dir := defaultCertDir()
|
||||
certFile := filepath.Join(dir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(dir, "auto-ca-key.pem")
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err == nil {
|
||||
log.Debugf("loaded persisted default certificate from %s", dir)
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
log.Debug("generating new default certificate (no persisted certificate found)")
|
||||
|
||||
// 3. Generate a new certificate.
|
||||
rawCert, rawKey, err := generateKeyPair(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Best-effort persist to disk. If it fails the in-memory certificate is
|
||||
// still usable.
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Warnf("failed to create certificate directory %s: %v", dir, err)
|
||||
} else {
|
||||
if err := os.WriteFile(certFile, rawCert, 0644); err != nil {
|
||||
log.Warnf("failed to persist certificate: %v", err)
|
||||
} else if err := os.WriteFile(keyFile, rawKey, 0600); err != nil {
|
||||
log.Warnf("failed to persist private key: %v", err)
|
||||
} else {
|
||||
log.Debugf("persisted default certificate to %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
cert, err = tls.X509KeyPair(rawCert, rawKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, rawKey []byte, err error) {
|
||||
@@ -124,6 +199,7 @@ func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, ra
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
if _, isRSA := priv.(*rsa.PrivateKey); isRSA {
|
||||
template.KeyUsage |= x509.KeyUsageKeyEncipherment
|
||||
|
||||
+181
-9
@@ -2,15 +2,58 @@ package parsing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// writeTestCertKey writes a self-signed cert/key pair to the given file paths.
|
||||
// The cert is NOT persisted via the defaultCertDir mechanism — it simulates
|
||||
// user-provided cert.pem / key.pem in a working directory.
|
||||
func writeTestCertKey(t *testing.T, certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "cwd-test.local"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(certFile, certPEM, 0644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
@@ -47,6 +90,10 @@ func TestDefaultTLSConfig_InitiallyNil(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
|
||||
// Use a temp dir so the test is isolated and doesn't touch $HOME/.gost
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
// BuildDefaultTLSConfig with nil should generate a self-signed cert
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
@@ -58,9 +105,22 @@ func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Verify that the certificate and key files were persisted.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg := &config.TLSConfig{
|
||||
Validity: 0, // uses default
|
||||
Organization: "TestOrg",
|
||||
@@ -78,26 +138,138 @@ func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_TwoCallsDifferentCerts(t *testing.T) {
|
||||
func TestBuildDefaultTLSConfig_TwoCallsSameCerts(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
if len(cfg1.Certificates) == 0 || len(cfg2.Certificates) == 0 {
|
||||
t.Fatal("expected certificates in both configs")
|
||||
}
|
||||
|
||||
// Each call generates a random cert; they should have different raw bytes.
|
||||
if len(cfg1.Certificates[0].Certificate) == 0 || len(cfg2.Certificates[0].Certificate) == 0 {
|
||||
t.Fatal("expected non-empty certificate raw bytes")
|
||||
}
|
||||
// Both calls should return the SAME persisted certificate.
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected different certificates from two calls")
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate from two calls (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_PersistsAndReloads(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// First call generates and persists.
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
|
||||
// Verify files exist on disk.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted after first call")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted after first call")
|
||||
}
|
||||
|
||||
// Second call loads the persisted cert.
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate on reload (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_FallsBackWhenWriteBlocked(t *testing.T) {
|
||||
// Point to a directory that can't be created (e.g. a file path).
|
||||
// We use a path under /proc on Linux which is a read-only filesystem.
|
||||
testDefaultCertDir = "/proc/doesnotexist/certdir"
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (should fall back to in-memory): %v", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("expected non-nil config even when write fails")
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate from fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDefaultTLSConfig_CWDTakesPriority verifies that when cert.pem and
|
||||
// key.pem exist in the current working directory, they take precedence over
|
||||
// both the persisted certificate and auto-generation.
|
||||
func TestBuildDefaultTLSConfig_CWDTakesPriority(t *testing.T) {
|
||||
// Create a temp dir with cert.pem / key.pem.
|
||||
cwd := t.TempDir()
|
||||
writeTestCertKey(t, filepath.Join(cwd, "cert.pem"), filepath.Join(cwd, "key.pem"))
|
||||
|
||||
// Also set up a persisted cert directory.
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// Pre-populate persisted cert so we can prove CWD wins.
|
||||
_, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pre-populate persisted cert: %v", err)
|
||||
}
|
||||
|
||||
// Change to the CWD that has cert.pem / key.pem.
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(cwd); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
// BuildDefaultTLSConfig should load CWD cert, not the persisted one.
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Load CWD cert directly and compare.
|
||||
cwdCert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if err != nil {
|
||||
t.Fatalf("load CWD cert: %v", err)
|
||||
}
|
||||
got := tlsCfg.Certificates[0].Certificate[0]
|
||||
want := cwdCert.Certificate[0]
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("CWD certificate was not used (persisted or auto-generated cert took priority)")
|
||||
}
|
||||
|
||||
// Load persisted cert to prove it's different from CWD cert.
|
||||
persistedCert, err := tls.LoadX509KeyPair(
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-cert.pem"),
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-key.pem"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted cert: %v", err)
|
||||
}
|
||||
if bytes.Equal(got, persistedCert.Certificate[0]) {
|
||||
t.Fatal("CWD cert should differ from persisted cert; test setup issue")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,11 @@ func (c *relayConnector) bind(conn net.Conn, cmd relay.CmdType, network, address
|
||||
})
|
||||
fa := &relay.AddrFeature{}
|
||||
if network != "unix" {
|
||||
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
}
|
||||
if address == "" {
|
||||
address = "0.0.0.0:0"
|
||||
} else if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
}
|
||||
}
|
||||
fa.ParseFrom(address)
|
||||
req.Features = append(req.Features, fa)
|
||||
|
||||
+13
-1
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -12,7 +13,9 @@ type quicSession struct {
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -23,6 +26,15 @@ func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (session *quicSession) IsClosed() bool {
|
||||
select {
|
||||
case <-session.session.Context().Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (session *quicSession) Close() error {
|
||||
return session.session.CloseWithError(quic.ApplicationErrorCode(0), "closed")
|
||||
}
|
||||
|
||||
+16
-3
@@ -75,9 +75,12 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
|
||||
d.sessionMutex.Lock()
|
||||
defer d.sessionMutex.Unlock()
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
delete(d.sessions, addr) // session is dead
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
options := &dialer.DialOptions{}
|
||||
for _, opt := range opts {
|
||||
@@ -91,6 +94,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
pc, err = icmp_pkg.ListenPacket("ip4:icmp", "")
|
||||
}
|
||||
if err != nil {
|
||||
d.sessionMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,16 +109,22 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
if err != nil {
|
||||
d.logger.Error(err)
|
||||
pc.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.sessions[addr] = session
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
delete(d.sessions, addr)
|
||||
}
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -133,7 +143,10 @@ func (d *icmpDialer) initSession(ctx context.Context, addr net.Addr, conn net.Pa
|
||||
}
|
||||
|
||||
tlsCfg := d.options.TLSConfig
|
||||
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
|
||||
tlsCfg = tlsCfg.Clone()
|
||||
if len(tlsCfg.NextProtos) == 0 {
|
||||
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
|
||||
}
|
||||
|
||||
session, err := quic.DialEarly(ctx, conn, addr, tlsCfg, quicConfig)
|
||||
if err != nil {
|
||||
|
||||
+11
-4
@@ -14,14 +14,21 @@ type metadata struct {
|
||||
}
|
||||
|
||||
func (d *icmpDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
if mdutil.GetBool(md, "keepalive") {
|
||||
d.md.keepAlivePeriod = mdutil.GetDuration(md, "ttl")
|
||||
const (
|
||||
keepAlive = "keepAlive"
|
||||
keepAlivePeriod = "ttl"
|
||||
handshakeTimeout = "handshakeTimeout"
|
||||
maxIdleTimeout = "maxIdleTimeout"
|
||||
)
|
||||
|
||||
if md == nil || !md.IsExists(keepAlive) || mdutil.GetBool(md, keepAlive) {
|
||||
d.md.keepAlivePeriod = mdutil.GetDuration(md, keepAlivePeriod)
|
||||
if d.md.keepAlivePeriod <= 0 {
|
||||
d.md.keepAlivePeriod = 10 * time.Second
|
||||
}
|
||||
}
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, "handshakeTimeout")
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, "maxIdleTimeout")
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+13
-1
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -12,7 +13,9 @@ type quicSession struct {
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -23,6 +26,15 @@ func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (session *quicSession) IsClosed() bool {
|
||||
select {
|
||||
case <-session.session.Context().Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (session *quicSession) Close() error {
|
||||
return session.session.CloseWithError(quic.ApplicationErrorCode(0), "closed")
|
||||
}
|
||||
|
||||
+17
-3
@@ -58,9 +58,12 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
|
||||
d.sessionMutex.Lock()
|
||||
defer d.sessionMutex.Unlock()
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
delete(d.sessions, addr) // session is dead
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
options := &dialer.DialOptions{}
|
||||
for _, opt := range opts {
|
||||
@@ -69,11 +72,13 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
|
||||
c, err := options.Dialer.Dial(ctx, "udp", "")
|
||||
if err != nil {
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
pc, ok := c.(net.PacketConn)
|
||||
if !ok {
|
||||
c.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, errors.New("quic: wrong connection type")
|
||||
}
|
||||
|
||||
@@ -85,16 +90,22 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
if err != nil {
|
||||
d.logger.Error(err)
|
||||
pc.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.sessions[addr] = session
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
delete(d.sessions, addr)
|
||||
}
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -115,7 +126,10 @@ func (d *quicDialer) initSession(ctx context.Context, addr net.Addr, conn net.Pa
|
||||
}
|
||||
|
||||
tlsCfg := d.options.TLSConfig
|
||||
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
|
||||
tlsCfg = tlsCfg.Clone()
|
||||
if len(tlsCfg.NextProtos) == 0 {
|
||||
tlsCfg.NextProtos = []string{"h3", "quic/v1"}
|
||||
}
|
||||
|
||||
session, err := quic.DialEarly(ctx, conn, addr, tlsCfg, quicConfig)
|
||||
if err != nil {
|
||||
|
||||
@@ -63,6 +63,18 @@ func (d *sshdDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
delete(d.sessions, addr) // session is dead
|
||||
ok = false
|
||||
}
|
||||
if ok {
|
||||
// Health check: verify the cached session is still alive before
|
||||
// returning it. A dead-but-not-yet-detected session (e.g. idle
|
||||
// connection silently dropped by NAT) would cause "ssh: unexpected
|
||||
// packet in response to channel open" later in the connector.
|
||||
if err := session.Ping(d.md.keepaliveTimeout); err != nil {
|
||||
d.options.Logger.Debugf("sshd session ping failed: %v, recreating", err)
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
var options dialer.DialOptions
|
||||
for _, opt := range opts {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package utls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/logger"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/net/proxyproto"
|
||||
"github.com/go-gost/x/registry"
|
||||
utls "github.com/refraction-networking/utls"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.DialerRegistry().Register("utls", NewDialer)
|
||||
}
|
||||
|
||||
type utlsDialer struct {
|
||||
md metadata
|
||||
log logger.Logger
|
||||
options dialer.Options
|
||||
}
|
||||
|
||||
func NewDialer(opts ...dialer.Option) dialer.Dialer {
|
||||
options := dialer.Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &utlsDialer{
|
||||
log: options.Logger,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *utlsDialer) Init(md md.Metadata) (err error) {
|
||||
return d.parseMetadata(md)
|
||||
}
|
||||
|
||||
func (d *utlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOption) (net.Conn, error) {
|
||||
var options dialer.DialOptions
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
conn, err := options.Dialer.Dial(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
d.log.Error(err)
|
||||
}
|
||||
|
||||
if d.md.keepalive {
|
||||
xnet.ApplyKeepalive(conn, net.KeepAliveConfig{
|
||||
Enable: true,
|
||||
Idle: d.md.keepaliveIdle,
|
||||
Interval: d.md.keepaliveInterval,
|
||||
Count: d.md.keepaliveCount,
|
||||
})
|
||||
}
|
||||
|
||||
conn = proxyproto.WrapClientConn(
|
||||
d.options.ProxyProtocol,
|
||||
xctx.SrcAddrFromContext(ctx),
|
||||
xctx.DstAddrFromContext(ctx),
|
||||
conn)
|
||||
|
||||
return conn, err
|
||||
}
|
||||
|
||||
// Handshake implements dialer.Handshaker.
|
||||
func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...dialer.HandshakeOption) (net.Conn, error) {
|
||||
if d.md.handshakeTimeout > 0 {
|
||||
conn.SetDeadline(time.Now().Add(d.md.handshakeTimeout))
|
||||
defer conn.SetDeadline(time.Time{})
|
||||
}
|
||||
|
||||
clientHelloID, ok := GetClientHelloID(d.md.fingerprint)
|
||||
if ok {
|
||||
d.log.Debugf("utls handshake with fingerprint: %s", d.md.fingerprint)
|
||||
utlsCfg := (*utls.Config)(unsafe.Pointer(d.options.TLSConfig))
|
||||
uconn := utls.UClient(conn, utlsCfg, clientHelloID)
|
||||
if err := uconn.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return uconn, nil
|
||||
}
|
||||
|
||||
// fingerprint not set, empty, "golang", or unknown: fall through to
|
||||
// standard crypto/tls.
|
||||
if d.md.fingerprint != "" {
|
||||
d.log.Warnf("unknown utls fingerprint: %s, falling back to standard TLS", d.md.fingerprint)
|
||||
}
|
||||
tlsConn := tls.Client(conn, d.options.TLSConfig)
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tlsConn, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package utls
|
||||
|
||||
import (
|
||||
utls "github.com/refraction-networking/utls"
|
||||
)
|
||||
|
||||
// fingerprintMap maps user-facing string names to uTLS ClientHelloID presets.
|
||||
// Each _Auto alias automatically tracks the latest known fingerprint for that
|
||||
// browser in the uTLS library.
|
||||
var fingerprintMap = map[string]utls.ClientHelloID{
|
||||
"chrome": utls.HelloChrome_Auto,
|
||||
"firefox": utls.HelloFirefox_Auto,
|
||||
"ios": utls.HelloIOS_Auto,
|
||||
"safari": utls.HelloSafari_Auto,
|
||||
"edge": utls.HelloEdge_Auto,
|
||||
"randomized": utls.HelloRandomized,
|
||||
"randomized-alpn": utls.HelloRandomizedALPN,
|
||||
"randomized-noalpn": utls.HelloRandomizedNoALPN,
|
||||
"golang": utls.HelloGolang,
|
||||
"custom": utls.HelloCustom,
|
||||
}
|
||||
|
||||
// GetClientHelloID returns the utls.ClientHelloID for the given fingerprint
|
||||
// name. The empty string and "golang" both return ok=false, signalling the
|
||||
// caller to fall through to standard crypto/tls. Unknown names also return
|
||||
// ok=false.
|
||||
func GetClientHelloID(name string) (utls.ClientHelloID, bool) {
|
||||
if name == "" || name == "golang" {
|
||||
return utls.ClientHelloID{}, false
|
||||
}
|
||||
id, ok := fingerprintMap[name]
|
||||
return id, ok
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package utls
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
handshakeTimeout time.Duration
|
||||
|
||||
keepalive bool
|
||||
keepaliveIdle time.Duration
|
||||
keepaliveInterval time.Duration
|
||||
keepaliveCount int
|
||||
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
func (d *utlsDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
const (
|
||||
handshakeTimeout = "handshakeTimeout"
|
||||
fingerprint = "fingerprint"
|
||||
)
|
||||
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
|
||||
|
||||
d.md.keepalive = mdutil.GetBool(md, "keepalive")
|
||||
d.md.keepaliveIdle = mdutil.GetDuration(md, "keepalive.idle")
|
||||
d.md.keepaliveInterval = mdutil.GetDuration(md, "keepalive.interval")
|
||||
d.md.keepaliveCount = mdutil.GetInt(md, "keepalive.count")
|
||||
|
||||
d.md.fingerprint = mdutil.GetString(md, fingerprint)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# Plan: Add uTLS Fingerprint Dialer (`utls`)
|
||||
|
||||
## Context
|
||||
|
||||
**Issue**: [go-gost/gost#31](https://github.com/go-gost/gost/issues/31) — Feature request to add TLS ClientHello fingerprint simulation using the [uTLS](https://github.com/refraction-networking/utls) library. GOST currently uses Go's `crypto/tls` which produces a distinctive Go TLS fingerprint that's easily blocked. Users want to mimic Chrome/Firefox/Safari/etc. fingerprints.
|
||||
|
||||
**Approach**: New `"utls"` dialer type (standalone, not modifying the existing `"tls"` dialer). The fingerprint is dialer-only metadata — not on the shared `TLSConfig` struct. This keeps the standard TLS dialer untouched and avoids pulling in the uTLS dependency for all users.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. `x/dialer/utls/fingerprint.go` — String→ClientHelloID map
|
||||
|
||||
Maps user-facing fingerprint names to `utls.ClientHelloID` presets. Supported names: `chrome`, `firefox`, `ios`, `safari`, `edge`, `randomized`, `randomized-alpn`, `randomized-noalpn`, `golang`, `custom`. Empty string and `"golang"` both mean "fall through to standard crypto/tls" (return `ok=false`). Unknown names get a warning log and also fall through.
|
||||
|
||||
### 2. `x/dialer/utls/metadata.go` — Metadata parsing
|
||||
|
||||
Mirrors `dialer/tls/metadata.go` exactly, adding a `fingerprint string` field. Parsed keys:
|
||||
- `handshakeTimeout` (duration)
|
||||
- `keepalive`, `keepalive.idle`, `keepalive.interval`, `keepalive.count` (TCP keepalive)
|
||||
- `fingerprint` (string) — **new**
|
||||
|
||||
### 3. `x/dialer/utls/dialer.go` — Core dialer
|
||||
|
||||
Mirrors `dialer/tls/dialer.go`. Registered as `"utls"`. The `Dial()` method is identical to the TLS dialer. The `Handshake()` method diverges: looks up the fingerprint; if `ok`, uses `utls.UClient(conn, tlsConfig, clientHelloID)`; otherwise falls through to `crypto/tls.Client()`.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### 4. `x/go.mod` — Add uTLS dependency
|
||||
|
||||
Add `github.com/refraction-networking/utls v1.8.2` to the require block, then run `go mod tidy` from `x/`.
|
||||
|
||||
### 5. `gost/cmd/gost/register.go` — Blank import
|
||||
|
||||
Add `_ "github.com/go-gost/x/dialer/utls"` in the "Register dialers" section, alphabetically between the `unix` and `ws` imports.
|
||||
|
||||
## No Changes Needed
|
||||
|
||||
- **Config parsing** — `node/parse.go` already passes `dialCfg.Metadata` to `d.Init()`; no changes required to any parsing code.
|
||||
- **`config/config.go`** — `DialerConfig.Metadata` already supports arbitrary keys; `fingerprint` flows through naturally.
|
||||
- **Metadata key constants** — No new constant in `config/parsing/parse.go`; local `const` in `parseMetadata()` follows existing dialer convention.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
- name: service-0
|
||||
addr: :8080
|
||||
handler:
|
||||
type: tcp
|
||||
listener:
|
||||
type: tcp
|
||||
forwarder:
|
||||
nodes:
|
||||
- name: target-0
|
||||
addr: example.com:443
|
||||
dialer:
|
||||
type: utls
|
||||
metadata:
|
||||
fingerprint: chrome
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# 1. Tidy dependencies
|
||||
cd x && go mod tidy
|
||||
|
||||
# 2. Build + vet the x module
|
||||
cd x && go build ./... && go vet ./...
|
||||
|
||||
# 3. Build + vet the gost binary (verifies blank import)
|
||||
cd gost && go build ./cmd/gost/... && go vet ./...
|
||||
```
|
||||
|
||||
No unit tests needed — the existing codebase has no tests in `x/dialer/`.
|
||||
@@ -4,15 +4,16 @@ go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
|
||||
github.com/andybalholm/brotli v1.0.6
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-gost/core v0.4.1
|
||||
github.com/go-gost/core v0.5.2
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.3
|
||||
github.com/go-gost/gosocks4 v0.1.0
|
||||
github.com/go-gost/gosocks5 v0.5.0
|
||||
github.com/go-gost/plugin v0.3.0
|
||||
github.com/go-gost/relay v0.6.0
|
||||
github.com/go-gost/plugin v0.4.0
|
||||
github.com/go-gost/relay v0.6.1
|
||||
github.com/go-gost/tls-dissector v0.2.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/gobwas/glob v0.2.3
|
||||
@@ -20,6 +21,7 @@ require (
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/klauspost/compress v1.17.4
|
||||
github.com/miekg/dns v1.1.61
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
@@ -28,8 +30,10 @@ require (
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.59.1
|
||||
github.com/quic-go/webtransport-go v0.10.0
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -56,7 +60,16 @@ require (
|
||||
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20
|
||||
)
|
||||
|
||||
require github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
require (
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAu
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -49,24 +51,28 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-gost/core v0.4.1 h1:/MZYv0tSkw9yoK2nl0GLl3GjT1/3qKl5u3E/DADyeZE=
|
||||
github.com/go-gost/core v0.4.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
|
||||
github.com/go-gost/core v0.5.1 h1:HbIn3naEOC661Z4SwzaSYUzffWSRbU0P6gkxiHmuG0I=
|
||||
github.com/go-gost/core v0.5.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
|
||||
github.com/go-gost/core v0.5.2 h1:TofTjnvOkga3IO625qTcUWukhR80cwBXI1RSkoOU0p4=
|
||||
github.com/go-gost/core v0.5.2/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.3 h1:6CUZLp+mTWXnKP2aK8/Z9ZP+ERMX9gSbywmPu4kGX/A=
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.3/go.mod h1:866zFNNI3He6Wef1M/IvAjTal74WhcfKfBgRpTlkKys=
|
||||
github.com/go-gost/gosocks4 v0.1.0 h1:eAzev6qw4fzkFQKC9uCHLVNnnPdHyqCggbnfNN80Pmk=
|
||||
github.com/go-gost/gosocks4 v0.1.0/go.mod h1:hzVjwijJuZR1pp3GqpTj+AKcSGrx68RlWTrQMFMYBP0=
|
||||
github.com/go-gost/gosocks5 v0.5.0 h1:YE37l1MJwde8diIQdynStqogMotG5enoTdborhA5yic=
|
||||
github.com/go-gost/gosocks5 v0.5.0/go.mod h1:1G6I7HP7VFVxveGkoK8mnprnJqSqJjdcASKsdUn4Pp4=
|
||||
github.com/go-gost/plugin v0.3.0 h1:pmll8nNd9PX92BWMB5+b2y2SEkBAWJLxD2ANfX+WHuw=
|
||||
github.com/go-gost/plugin v0.3.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
|
||||
github.com/go-gost/relay v0.6.0 h1:o8r0B3eTw8Rwwent+5x5Cw1g4AyNUl4hNqiyDJXkFvA=
|
||||
github.com/go-gost/relay v0.6.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
|
||||
github.com/go-gost/plugin v0.4.0 h1:M7MR5PL7QAFCrdWfcXVX+5iLNTVIZlhl8FfdV/K8dZY=
|
||||
github.com/go-gost/plugin v0.4.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
|
||||
github.com/go-gost/relay v0.6.1 h1:7SqnHFbY8x/DzvjpK03a5zcVH9+TbJAcnW/RT6s1ecc=
|
||||
github.com/go-gost/relay v0.6.1/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
|
||||
github.com/go-gost/tls-dissector v0.2.0 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc1SicVg=
|
||||
github.com/go-gost/tls-dissector v0.2.0/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -104,6 +110,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -123,6 +130,8 @@ github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9q
|
||||
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
@@ -135,6 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -173,6 +184,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@@ -188,6 +201,8 @@ github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBi
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
@@ -200,6 +215,12 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28=
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
@@ -240,6 +261,10 @@ github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbH
|
||||
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -263,6 +288,8 @@ github.com/xtaci/tcpraw v1.2.25 h1:VDlqo0op17JeXBM6e2G9ocCNLOJcw9mZbobMbJjo0vk=
|
||||
github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1GgJQbLk=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@@ -327,13 +354,17 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
|
||||
+62
-7
@@ -2,6 +2,7 @@ package auto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
@@ -12,7 +13,10 @@ import (
|
||||
"github.com/go-gost/gosocks4"
|
||||
"github.com/go-gost/gosocks5"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -94,6 +98,13 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
|
||||
// Peek 1 byte first to detect small protocols (SOCKS4/SOCKS5)
|
||||
// that send fewer bytes than the 5-byte TLS record header that
|
||||
// Sniff() needs. If we called Sniff() first, its Peek(5) would
|
||||
// block indefinitely on a SOCKS5 greeting (3 bytes: VER, NMETHODS,
|
||||
// METHODS), deadlocking with the client that is itself waiting for
|
||||
// the server's method-selection reply.
|
||||
b, err := br.Peek(1)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
@@ -101,20 +112,64 @@ func (h *autoHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
return err
|
||||
}
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
switch b[0] {
|
||||
case gosocks4.Ver4: // socks4
|
||||
case gosocks4.Ver4:
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
if h.socks4Handler != nil {
|
||||
return h.socks4Handler.Handle(ctx, conn)
|
||||
}
|
||||
case gosocks5.Ver5: // socks5
|
||||
return nil
|
||||
case gosocks5.Ver5:
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
if h.socks5Handler != nil {
|
||||
return h.socks5Handler.Handle(ctx, conn)
|
||||
}
|
||||
default: // http
|
||||
if h.httpHandler != nil {
|
||||
return h.httpHandler.Handle(ctx, conn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Not SOCKS — sniff for TLS, HTTP, or SSH.
|
||||
proto, _ := sniffing.Sniff(ctx, br)
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
|
||||
if proto == sniffing.ProtoTLS {
|
||||
return h.handleTLS(ctx, conn, log)
|
||||
}
|
||||
|
||||
// Default to HTTP.
|
||||
if h.httpHandler != nil {
|
||||
return h.httpHandler.Handle(ctx, conn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTLS forwards a TLS connection that arrived at the auto handler.
|
||||
// It parses the ClientHello for SNI, dials upstream through the router,
|
||||
// forwards the ClientHello, records ServerHello metadata, and pipes data
|
||||
// bidirectionally.
|
||||
func (h *autoHandler) handleTLS(ctx context.Context, conn net.Conn, log logger.Logger) error {
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
Network: "tcp",
|
||||
Service: h.options.Service,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
LocalAddr: conn.LocalAddr().String(),
|
||||
SID: ctxvalue.SidFromContext(ctx).String(),
|
||||
Time: time.Now(),
|
||||
}
|
||||
sniffer := &sniffing.Sniffer{}
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ro.Route = buf.String()
|
||||
return cc, nil
|
||||
}
|
||||
return sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,23 @@ func (h *dnsHandler) Init(md md.Metadata) (err error) {
|
||||
h.exchangers[node.Name] = ex
|
||||
}
|
||||
|
||||
if len(h.exchangers) == 0 {
|
||||
// Try system nameservers first (from /etc/resolv.conf on Unix).
|
||||
for i, addr := range systemNameservers() {
|
||||
ex, err := exchanger.NewExchanger(
|
||||
addr,
|
||||
exchanger.RouterOption(h.options.Router),
|
||||
exchanger.TimeoutOption(h.md.timeout),
|
||||
exchanger.LoggerOption(log),
|
||||
)
|
||||
if err != nil {
|
||||
log.Warnf("system nameserver %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
h.exchangers[fmt.Sprintf("system-%d", i)] = ex
|
||||
}
|
||||
}
|
||||
|
||||
if len(h.exchangers) == 0 {
|
||||
ex, err := exchanger.NewExchanger(
|
||||
defaultNameserver,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build !windows
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// systemNameservers reads the system DNS configuration from /etc/resolv.conf
|
||||
// and returns the nameserver addresses as URL-like strings suitable for
|
||||
// exchanger.NewExchanger (e.g., "udp://1.1.1.1:53").
|
||||
// Returns nil if the file cannot be read or contains no nameserver entries.
|
||||
func systemNameservers() []string {
|
||||
f, err := os.Open("/etc/resolv.conf")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var servers []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || line[0] == '#' || line[0] == ';' {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || fields[0] != "nameserver" {
|
||||
continue
|
||||
}
|
||||
addr := fields[1]
|
||||
// Verify it's a valid IP address.
|
||||
if ip := net.ParseIP(addr); ip == nil {
|
||||
continue
|
||||
}
|
||||
// If no port, default to 53.
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr = net.JoinHostPort(addr, "53")
|
||||
}
|
||||
servers = append(servers, fmt.Sprintf("udp://%s", addr))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dns
|
||||
|
||||
// systemNameservers returns nil on Windows because reading the system DNS
|
||||
// configuration requires the Windows IP Helper API. Callers should fall
|
||||
// back to the hardcoded default when this returns nil.
|
||||
func systemNameservers() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/limiter"
|
||||
"github.com/go-gost/core/recorder"
|
||||
stats "github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/logger"
|
||||
stats "github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
@@ -132,6 +132,7 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -140,6 +141,7 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -233,7 +235,7 @@ type SnifferBuilder struct {
|
||||
// ReadTimeout is the timeout for reading upstream HTTP response headers
|
||||
// and TLS ServerHello during sniffing. Passed through to sniffing.Sniffer.
|
||||
// See sniffing.Sniffer.ReadTimeout for details.
|
||||
ReadTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
}
|
||||
|
||||
// Build creates a new sniffing.Sniffer from the builder's configuration.
|
||||
|
||||
+17
-14
@@ -445,20 +445,9 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt
|
||||
ro.HTTP.Response.Header = resp.Header
|
||||
}()
|
||||
|
||||
// HTTP/2 connection preface "PRI * HTTP/2.0" is rejected, as are
|
||||
// non-CONNECT requests without an http:// scheme.
|
||||
if req.Method == "PRI" ||
|
||||
(req.Method != http.MethodConnect && req.URL.Scheme != "http") {
|
||||
resp.StatusCode = http.StatusBadRequest
|
||||
|
||||
if log.IsLevelEnabled(logger.TraceLevel) {
|
||||
dump, _ := httputil.DumpResponse(resp, false)
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
return resp.Write(conn)
|
||||
}
|
||||
|
||||
// Authenticate before request validation so that probe resistance
|
||||
// can intercept non-proxy-form requests (e.g., browser/scanner probes
|
||||
// that send "GET / HTTP/1.1" instead of proxy-form URLs).
|
||||
result := h.auth.Authenticate(ctx, req)
|
||||
if !result.OK {
|
||||
if result.PipeTo != "" {
|
||||
@@ -477,6 +466,20 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
|
||||
// HTTP/2 connection preface "PRI * HTTP/2.0" is rejected, as are
|
||||
// non-CONNECT requests without an http:// scheme.
|
||||
if req.Method == "PRI" ||
|
||||
(req.Method != http.MethodConnect && req.URL.Scheme != "http") {
|
||||
resp.StatusCode = http.StatusBadRequest
|
||||
|
||||
if log.IsLevelEnabled(logger.TraceLevel) {
|
||||
dump, _ := httputil.DumpResponse(resp, false)
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
return resp.Write(conn)
|
||||
}
|
||||
|
||||
log = log.WithFields(map[string]any{"clientID": result.ClientID})
|
||||
ro.ClientID = result.ClientID
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/logger"
|
||||
stats "github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/resolver"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
"github.com/go-gost/x/internal/net/udp"
|
||||
"github.com/go-gost/x/internal/util/socks"
|
||||
@@ -93,6 +97,16 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, clientID str
|
||||
return err
|
||||
}
|
||||
|
||||
// Wrap raw *net.UDPConn so domainAddr from socks.UDPTunServerConn.ReadFrom
|
||||
// are resolved through the configured resolver instead of failing WriteTo.
|
||||
if _, ok := pc.(*net.UDPConn); ok {
|
||||
pc = &resolvePacketConn{
|
||||
PacketConn: pc,
|
||||
resolver: h.options.Router.Options().Resolver,
|
||||
hostMapper: h.options.Router.Options().HostMapper,
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the HTTP connection as a SOCKS5 UDP tunnel server conn so
|
||||
// that the relay can read/write SOCKS5-encapsulated UDP datagrams.
|
||||
relay := udp.NewRelay(socks.UDPTunServerConn(conn), pc).
|
||||
@@ -110,3 +124,45 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, clientID str
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePacketConn wraps a net.PacketConn and resolves domain addresses
|
||||
// in WriteTo calls through hostMapper → configured resolver → system DNS.
|
||||
type resolvePacketConn struct {
|
||||
net.PacketConn
|
||||
resolver resolver.Resolver
|
||||
hostMapper hosts.HostMapper
|
||||
}
|
||||
|
||||
func (c *resolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
if c.hostMapper != nil {
|
||||
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 && c.resolver != nil {
|
||||
ips, _ = c.resolver.Resolve(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
ips, _ = net.LookupIP(host)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return 0, fmt.Errorf("http udp: cannot resolve %s", host)
|
||||
}
|
||||
|
||||
ip := ips[0]
|
||||
for _, candidate := range ips {
|
||||
if candidate.To4() != nil {
|
||||
ip = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
|
||||
}
|
||||
|
||||
+38
-37
@@ -355,6 +355,7 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
|
||||
// Resolve target address
|
||||
raddr, err := net.ResolveUDPAddr("udp", targetAddr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
log.Error("masque: failed to resolve target address: ", err)
|
||||
return err
|
||||
}
|
||||
@@ -370,43 +371,6 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
|
||||
// Get the underlying HTTP/3 stream
|
||||
stream := streamer.HTTPStream()
|
||||
|
||||
// Send success response with capsule-protocol header
|
||||
w.Header().Set("Capsule-Protocol", "?1")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Flush the response headers
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Create datagram connection wrapping the HTTP/3 stream (client side)
|
||||
datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr)
|
||||
defer datagramConn.Close()
|
||||
|
||||
// Wrap with recorder stats and traffic limiter
|
||||
var clientPC net.PacketConn = datagramConn
|
||||
clientPC = stats_wrapper.WrapPacketConn(clientPC, pStats)
|
||||
clientPC = traffic_wrapper.WrapPacketConn(
|
||||
clientPC,
|
||||
h.limiter,
|
||||
clientID,
|
||||
limiter.ServiceOption(h.options.Service),
|
||||
limiter.ScopeOption(limiter.ScopeClient),
|
||||
limiter.NetworkOption("udp"),
|
||||
limiter.AddrOption(targetAddr),
|
||||
limiter.ClientOption(clientID),
|
||||
limiter.SrcOption(ro.RemoteAddr),
|
||||
)
|
||||
|
||||
// Track per-client connection stats
|
||||
if h.options.Observer != nil {
|
||||
pstats := h.stats.Stats(clientID)
|
||||
pstats.Add(stats.KindTotalConns, 1)
|
||||
pstats.Add(stats.KindCurrentConns, 1)
|
||||
defer pstats.Add(stats.KindCurrentConns, -1)
|
||||
clientPC = stats_wrapper.WrapPacketConn(clientPC, pstats)
|
||||
}
|
||||
|
||||
// Get target connection - either through router/chain or direct
|
||||
var targetPC net.PacketConn
|
||||
|
||||
@@ -460,6 +424,43 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
|
||||
// Wrap target with metrics
|
||||
targetPC = metrics.WrapPacketConn(h.options.Service, targetPC)
|
||||
|
||||
// Send success response with capsule-protocol header
|
||||
w.Header().Set("Capsule-Protocol", "?1")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Flush the response headers
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Create datagram connection wrapping the HTTP/3 stream (client side)
|
||||
datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr)
|
||||
defer datagramConn.Close()
|
||||
|
||||
// Wrap with recorder stats and traffic limiter
|
||||
var clientPC net.PacketConn = datagramConn
|
||||
clientPC = stats_wrapper.WrapPacketConn(clientPC, pStats)
|
||||
clientPC = traffic_wrapper.WrapPacketConn(
|
||||
clientPC,
|
||||
h.limiter,
|
||||
clientID,
|
||||
limiter.ServiceOption(h.options.Service),
|
||||
limiter.ScopeOption(limiter.ScopeClient),
|
||||
limiter.NetworkOption("udp"),
|
||||
limiter.AddrOption(targetAddr),
|
||||
limiter.ClientOption(clientID),
|
||||
limiter.SrcOption(ro.RemoteAddr),
|
||||
)
|
||||
|
||||
// Track per-client connection stats
|
||||
if h.options.Observer != nil {
|
||||
pstats := h.stats.Stats(clientID)
|
||||
pstats.Add(stats.KindTotalConns, 1)
|
||||
pstats.Add(stats.KindCurrentConns, 1)
|
||||
defer pstats.Add(stats.KindCurrentConns, -1)
|
||||
clientPC = stats_wrapper.WrapPacketConn(clientPC, pstats)
|
||||
}
|
||||
|
||||
// Relay UDP packets between client and target
|
||||
relay := udp.NewRelay(clientPC, targetPC).
|
||||
WithService(h.options.Service).
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *metricsHandler) Init(md md.Metadata) (err error) {
|
||||
|
||||
xmetrics.Enable(true)
|
||||
|
||||
h.handler = promhttp.Handler()
|
||||
h.handler = promhttp.HandlerFor(xmetrics.Registry(), promhttp.HandlerOpts{})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(h.md.path, http.HandlerFunc(h.handleFunc))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"syscall"
|
||||
@@ -30,6 +29,10 @@ func (h *redirectHandler) getOriginalDstAddr(conn net.Conn) (addr net.Addr, err
|
||||
var cerr error
|
||||
err = rc.Control(func(fd uintptr) {
|
||||
if tcpAddr.IP.To4() != nil {
|
||||
// SO_ORIGINAL_DST returns a struct sockaddr_in (16 bytes).
|
||||
// GetsockoptIPv6Mreq reads into a 20-byte ipv6_mreq; only the
|
||||
// first 16 bytes (the Multiaddr field) are populated.
|
||||
// Layout: [0:2]=sin_family, [2:4]=sin_port (BE), [4:8]=sin_addr.
|
||||
mreq, err := unix.GetsockoptIPv6Mreq(int(fd), unix.IPPROTO_IP, unix.SO_ORIGINAL_DST)
|
||||
if err != nil {
|
||||
cerr = err
|
||||
@@ -41,17 +44,19 @@ func (h *redirectHandler) getOriginalDstAddr(conn net.Conn) (addr net.Addr, err
|
||||
Port: int(mreq.Multiaddr[2])<<8 + int(mreq.Multiaddr[3]),
|
||||
}
|
||||
} else {
|
||||
// SO_ORIGINAL_DST returns a struct sockaddr_in6 (28 bytes).
|
||||
// GetsockoptIPv6MTUInfo reads into a 32-byte ipv6_mtuinfo; only
|
||||
// the first 28 bytes (the Addr field) are populated.
|
||||
info, err := unix.GetsockoptIPv6MTUInfo(int(fd), unix.IPPROTO_IPV6, unix.SO_ORIGINAL_DST)
|
||||
if err != nil {
|
||||
cerr = err
|
||||
return
|
||||
}
|
||||
|
||||
var buf = make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(buf, info.Addr.Port)
|
||||
// info.Addr.Port is in network byte order (big-endian).
|
||||
addr = &net.TCPAddr{
|
||||
IP: net.IP(info.Addr.Addr[:]),
|
||||
Port: int(binary.NativeEndian.Uint16(buf)),
|
||||
Port: int(uint16(info.Addr.Port>>8 | info.Addr.Port<<8)),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+67
-1
@@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/limiter"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/relay"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
@@ -261,8 +264,25 @@ func (h *relayHandler) bindUDP(ctx context.Context, conn net.Conn, network, addr
|
||||
})
|
||||
ro.SrcAddr = pc.LocalAddr().String()
|
||||
|
||||
// lc.ListenPacket returns a raw *net.UDPConn which can't resolve
|
||||
// domainAddr returned by UDPTunServerConn.ReadFrom for domain targets.
|
||||
// Wrap it so domains resolve through hostMapper → configured resolver
|
||||
// → system DNS instead of failing WriteTo with an unknown addr type.
|
||||
if _, ok := pc.(*net.UDPConn); ok {
|
||||
var r resolver.Resolver
|
||||
var hm hosts.HostMapper
|
||||
if h.options.Router != nil {
|
||||
r = h.options.Router.Options().Resolver
|
||||
hm = h.options.Router.Options().HostMapper
|
||||
}
|
||||
pc = &resolvePacketConn{
|
||||
PacketConn: pc,
|
||||
resolver: r,
|
||||
hostMapper: hm,
|
||||
}
|
||||
}
|
||||
|
||||
pc = metrics.WrapPacketConn(serviceName, pc)
|
||||
// pc = admission.WrapPacketConn(l.options.Admission, pc)
|
||||
// pc = limiter.WrapPacketConn(l.options.TrafficLimiter, pc)
|
||||
|
||||
defer pc.Close()
|
||||
@@ -294,3 +314,49 @@ func (h *relayHandler) bindUDP(ctx context.Context, conn net.Conn, network, addr
|
||||
}).Debugf("%s >-< %s", conn.RemoteAddr(), pc.LocalAddr())
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePacketConn wraps a net.PacketConn and resolves domain addresses
|
||||
// in WriteTo calls through hostMapper → configured resolver → system DNS.
|
||||
//
|
||||
// After udpTunConn.ReadFrom returns a domainAddr for ATYP=DOMAINNAME
|
||||
// datagrams, a raw *net.UDPConn cannot consume it (WriteTo needs *net.UDPAddr).
|
||||
// This wrapper resolves the domain before forwarding.
|
||||
type resolvePacketConn struct {
|
||||
net.PacketConn
|
||||
resolver resolver.Resolver
|
||||
hostMapper hosts.HostMapper
|
||||
}
|
||||
|
||||
func (c *resolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
if c.hostMapper != nil {
|
||||
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 && c.resolver != nil {
|
||||
ips, _ = c.resolver.Resolve(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
ips, _ = net.LookupIP(host)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return 0, fmt.Errorf("relay udp: cannot resolve %s", host)
|
||||
}
|
||||
|
||||
ip := ips[0]
|
||||
for _, candidate := range ips {
|
||||
if candidate.To4() != nil {
|
||||
ip = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/relay"
|
||||
)
|
||||
|
||||
@@ -107,4 +111,86 @@ func TestHandleBind_UDP(t *testing.T) {
|
||||
if resp.Status != relay.StatusOK {
|
||||
t.Errorf("Status = %d, want %d", resp.Status, relay.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvePacketConn tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type testPacketConn struct {
|
||||
net.PacketConn
|
||||
lastAddr net.Addr
|
||||
lastData []byte
|
||||
}
|
||||
|
||||
func (c *testPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
c.lastAddr = addr
|
||||
c.lastData = make([]byte, len(b))
|
||||
copy(c.lastData, b)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
type stubResolver struct {
|
||||
ips []net.IP
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubResolver) Resolve(ctx context.Context, network, host string, opts ...resolver.Option) ([]net.IP, error) {
|
||||
return r.ips, r.err
|
||||
}
|
||||
|
||||
type stubHostMapper struct{}
|
||||
|
||||
func (m *stubHostMapper) Lookup(ctx context.Context, network, host string, opts ...hosts.Option) ([]net.IP, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type testDomainAddr struct{}
|
||||
|
||||
func (a *testDomainAddr) Network() string { return "udp" }
|
||||
func (a *testDomainAddr) String() string { return "dns.google:53" }
|
||||
|
||||
func Test_resolvePacketConn_domain(t *testing.T) {
|
||||
r := &resolvePacketConn{
|
||||
PacketConn: &testPacketConn{},
|
||||
resolver: &stubResolver{ips: []net.IP{net.ParseIP("1.2.3.4")}},
|
||||
hostMapper: &stubHostMapper{},
|
||||
}
|
||||
|
||||
_, err := r.WriteTo([]byte("data"), &testDomainAddr{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tpc := r.PacketConn.(*testPacketConn)
|
||||
udpAddr, ok := tpc.lastAddr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("expected *net.UDPAddr, got %T", tpc.lastAddr)
|
||||
}
|
||||
if !udpAddr.IP.Equal(net.ParseIP("1.2.3.4")) || udpAddr.Port != 53 {
|
||||
t.Fatalf("expected 1.2.3.4:53, got %v", udpAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_resolvePacketConn_ipPassthrough(t *testing.T) {
|
||||
r := &resolvePacketConn{
|
||||
PacketConn: &testPacketConn{},
|
||||
resolver: &stubResolver{err: errors.New("should not be called")},
|
||||
hostMapper: &stubHostMapper{},
|
||||
}
|
||||
|
||||
ipAddr := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 53}
|
||||
_, err := r.WriteTo([]byte("data"), ipAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tpc := r.PacketConn.(*testPacketConn)
|
||||
udpAddr, ok := tpc.lastAddr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("expected *net.UDPAddr, got %T", tpc.lastAddr)
|
||||
}
|
||||
if !udpAddr.IP.Equal(net.ParseIP("8.8.8.8")) || udpAddr.Port != 53 {
|
||||
t.Fatalf("expected 8.8.8.8:53, got %v", udpAddr)
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@ func (h *relayHandler) handleConnect(ctx context.Context, conn net.Conn, network
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -245,6 +246,7 @@ func (h *relayHandler) handleConnect(ctx context.Context, conn net.Conn, network
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -306,4 +308,4 @@ func (a *serialAddr) Network() string {
|
||||
|
||||
func (a *serialAddr) String() string {
|
||||
return a.port
|
||||
}
|
||||
}
|
||||
|
||||
+79
-89
@@ -1,11 +1,47 @@
|
||||
// Package sni implements a TLS SNI-based forwarding handler for protocol-aware
|
||||
// routing. It sniffs every inbound TCP connection, parses the TLS ClientHello
|
||||
// (or HTTP request header) to extract the server name, and routes traffic
|
||||
// through the proxy chain.
|
||||
//
|
||||
// # How it differs from the "tcp" / "forward" handler
|
||||
//
|
||||
// The SNI handler is purpose-built for TLS SNI routing — it always sniffs
|
||||
// and only handles HTTP and TLS traffic:
|
||||
//
|
||||
// - Non-HTTP/non-TLS connections are silently dropped (no raw forwarding
|
||||
// fallback). If you need raw TCP fallback, use the "tcp" or "forward"
|
||||
// handler instead.
|
||||
// - It does not implement [handler.Forwarder], so it has no hop-based node
|
||||
// selection, load balancing, or per-node authentication/rewrite settings.
|
||||
// All routing uses the SNI hostname as the destination address through
|
||||
// the configured chain.
|
||||
//
|
||||
// # Connection processing flow
|
||||
//
|
||||
// Each inbound net.Conn is processed by Handle:
|
||||
//
|
||||
// Handle()
|
||||
// ├─ newRecorderObject (session metadata: service, SID, addresses)
|
||||
// ├─ checkRateLimit (connection rate limiter, if configured)
|
||||
// ├─ Router nil check → errRouterNotAvailable
|
||||
// ├─ sniffing.Sniff (peek buffer, detect protocol)
|
||||
// │ └─ handleSniffedProtocol()
|
||||
// │ ├─ sniffing.ProtoHTTP → SnifferBuilder.Build().HandleHTTP()
|
||||
// │ ├─ sniffing.ProtoTLS → SnifferBuilder.Build().HandleTLS()
|
||||
// │ └─ default → silent drop
|
||||
// └─ unrecognised protocol → silently returns nil
|
||||
//
|
||||
// # SnifferBuilder pattern
|
||||
//
|
||||
// The sniffer configuration (MITM certs, WebSocket recording, bypass rules)
|
||||
// is immutable after Init. A [SnifferBuilder] is populated once during Init
|
||||
// and reused via [SnifferBuilder.Build] to create per-connection
|
||||
// [sniffing.Sniffer] instances.
|
||||
package sni
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
@@ -13,8 +49,6 @@ import (
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
@@ -29,26 +63,32 @@ func init() {
|
||||
registry.HandlerRegistry().Register("sni", NewHandler)
|
||||
}
|
||||
|
||||
// sniHandler is a protocol-aware forwarding handler that routes by TLS SNI
|
||||
// or HTTP Host header. It always sniffs the initial bytes to determine the
|
||||
// protocol and delegates to [sniffing.Sniffer] for HTTP/TLS handling.
|
||||
type sniHandler struct {
|
||||
md metadata
|
||||
options handler.Options
|
||||
recorder recorder.RecorderObject
|
||||
certPool tls_util.CertPool
|
||||
sniffer *SnifferBuilder
|
||||
}
|
||||
|
||||
// NewHandler creates an SNI handler with the given options.
|
||||
// The handler registers for the "sni" protocol.
|
||||
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||
options := handler.Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
h := &sniHandler{
|
||||
return &sniHandler{
|
||||
options: options,
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// Init parses metadata and initialises the sniffer builder and TLS certificate
|
||||
// pool. It implements [handler.Handler].
|
||||
func (h *sniHandler) Init(md md.Metadata) (err error) {
|
||||
if err = h.parseMetadata(md); err != nil {
|
||||
return
|
||||
@@ -65,26 +105,32 @@ func (h *sniHandler) Init(md md.Metadata) (err error) {
|
||||
h.certPool = tls_util.NewMemoryCertPool()
|
||||
}
|
||||
|
||||
h.sniffer = &SnifferBuilder{
|
||||
Websocket: h.md.sniffingWebsocket,
|
||||
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
|
||||
Recorder: h.recorder.Recorder,
|
||||
RecorderOptions: h.recorder.Options,
|
||||
Certificate: h.md.certificate,
|
||||
PrivateKey: h.md.privateKey,
|
||||
ALPN: h.md.alpn,
|
||||
CertPool: h.certPool,
|
||||
MitmBypass: h.md.mitmBypass,
|
||||
ReadTimeout: h.md.readTimeout,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle processes an inbound TCP connection. It always sniffs the initial
|
||||
// bytes to detect HTTP or TLS, then delegates to the protocol-specific
|
||||
// sniffer. Non-HTTP/non-TLS traffic is silently dropped.
|
||||
//
|
||||
// Handle implements [handler.Handler].
|
||||
func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
|
||||
defer conn.Close()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
Network: "tcp",
|
||||
Service: h.options.Service,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
LocalAddr: conn.LocalAddr().String(),
|
||||
SID: xctx.SidFromContext(ctx).String(),
|
||||
Time: start,
|
||||
}
|
||||
|
||||
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
||||
ro.ClientAddr = srcAddr.String()
|
||||
}
|
||||
ro := h.newRecorderObject(ctx, conn, start)
|
||||
|
||||
log := h.options.Logger.WithFields(map[string]any{
|
||||
"network": ro.Network,
|
||||
@@ -120,10 +166,17 @@ func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
|
||||
return rate_limiter.ErrRateLimit
|
||||
}
|
||||
|
||||
if h.options.Router == nil {
|
||||
err := errRouterNotAvailable
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if h.md.readTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Now().Add(h.md.readTimeout))
|
||||
defer conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
proto, sniffErr := sniffing.Sniff(ctx, br)
|
||||
if sniffErr != nil {
|
||||
@@ -131,75 +184,12 @@ func (h *sniHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
|
||||
}
|
||||
ro.Proto = proto
|
||||
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
if h.options.Router == nil {
|
||||
return nil, errors.New("sni: router not available")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
|
||||
ro.Route = buf.String()
|
||||
|
||||
if cc != nil {
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
}
|
||||
return cc, err
|
||||
}
|
||||
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
cc, err := dial(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cc = tls.Client(cc, cfg)
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
sniffer := &sniffing.Sniffer{
|
||||
Websocket: h.md.sniffingWebsocket,
|
||||
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
|
||||
Recorder: h.recorder.Recorder,
|
||||
RecorderOptions: h.recorder.Options,
|
||||
Certificate: h.md.certificate,
|
||||
PrivateKey: h.md.privateKey,
|
||||
NegotiatedProtocol: h.md.alpn,
|
||||
CertPool: h.certPool,
|
||||
MitmBypass: h.md.mitmBypass,
|
||||
ReadTimeout: h.md.readTimeout,
|
||||
}
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP:
|
||||
return sniffer.HandleHTTP(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
case sniffing.ProtoTLS:
|
||||
return sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
default:
|
||||
log.Debugf("unrecognized traffic from %s", conn.RemoteAddr())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *sniHandler) checkRateLimit(addr net.Addr) bool {
|
||||
if h.options.RateLimiter == nil {
|
||||
return true
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr.String())
|
||||
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
|
||||
return limiter.Allow(1)
|
||||
handled, sniffErr := h.handleSniffedProtocol(ctx, conn, ro, log, proto)
|
||||
if handled {
|
||||
return sniffErr
|
||||
}
|
||||
|
||||
return true
|
||||
log.Debugf("unrecognized traffic from %s", conn.RemoteAddr())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/limiter/rate"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewHandler(t *testing.T) {
|
||||
h := NewHandler(func(o *handler.Options) {
|
||||
o.Service = "test-svc"
|
||||
})
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil handler")
|
||||
}
|
||||
sh, ok := h.(*sniHandler)
|
||||
if !ok {
|
||||
t.Fatal("expected *sniHandler")
|
||||
}
|
||||
if sh.options.Service != "test-svc" {
|
||||
t.Errorf("expected Service 'test-svc', got %s", sh.options.Service)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestInit_Defaults(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
err := h.Init(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.readTimeout <= 0 {
|
||||
t.Error("expected positive default readTimeout")
|
||||
}
|
||||
if h.sniffer == nil {
|
||||
t.Error("expected sniffer to be initialised")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_SelectsRecorder(t *testing.T) {
|
||||
h := newTestHandler(withRecorder(recorder.RecorderObject{
|
||||
Record: xrecorder.RecorderServiceHandler,
|
||||
Recorder: &mockRecorder{},
|
||||
}))
|
||||
err := h.Init(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.recorder.Recorder == nil {
|
||||
t.Error("expected recorder to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_CertPool(t *testing.T) {
|
||||
cert, key := generateTestCertKey(t)
|
||||
h := newTestHandler()
|
||||
h.md.certificate = cert
|
||||
h.md.privateKey = key
|
||||
err := h.Init(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.certPool == nil {
|
||||
t.Error("expected certPool to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ParseMetadataError(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": "/nonexistent/cert.pem",
|
||||
"mitm.keyFile": "/nonexistent/key.pem",
|
||||
})
|
||||
err := h.Init(md)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Init with bad cert files")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handle — error paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandle_NoRouter(t *testing.T) {
|
||||
h := newTestHandler(withRouter(nil))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when router is nil")
|
||||
}
|
||||
if !errors.Is(err, errRouterNotAvailable) {
|
||||
t.Errorf("expected errRouterNotAvailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_RateLimited(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return false }}
|
||||
},
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handle — sniffing (protocol detection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandle_SniffError(t *testing.T) {
|
||||
// An empty connection causes sniffing.Sniff to return an error because
|
||||
// there are no bytes to peek. The handler should log the error and fall
|
||||
// through to the unrecognised path (silent drop).
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for unrecognised traffic, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_UnrecognizedProto(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
// Non-HTTP/non-TLS bytes won't match any sniffer protocol.
|
||||
conn := newStringConn([]byte("ssh-2.0-OpenSSH"))
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for unrecognised traffic, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_HTTP(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
n, err := client.Read(buf)
|
||||
if err == nil && n > 0 {
|
||||
client.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"))
|
||||
}
|
||||
}()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Logf("sniffed HTTP returned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_TLS(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte{
|
||||
0x16, 0x03, 0x01, 0x00, 0x06, 0x01, 0x00, 0x00, 0x02, 0x03, 0x01,
|
||||
})
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Log("sniffed TLS ClientHello without error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_RecorderError(t *testing.T) {
|
||||
// The deferred recorder error should not affect the return value.
|
||||
h := newTestHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}))
|
||||
h.recorder = recorder.RecorderObject{Recorder: &errorRecorder{}}
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil from unrecognised traffic, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_WithReadTimeout(t *testing.T) {
|
||||
h := newTestHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
}))
|
||||
h.md.readTimeout = 0 // zero disables the deadline
|
||||
h.recorder = recorder.RecorderObject{Recorder: &mockRecorder{}}
|
||||
conn := newStringConn([]byte("ssh"))
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_ConnClosed(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
err := h.Handle(context.Background(), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if !conn.closed {
|
||||
t.Error("expected connection to be closed after Handle returns")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/limiter/rate"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mockRouter implements chain.Router for unit tests.
|
||||
type mockRouter struct {
|
||||
opts *chain.RouterOptions
|
||||
dialFn func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
func (m *mockRouter) Options() *chain.RouterOptions { return m.opts }
|
||||
func (m *mockRouter) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (net.Conn, error) {
|
||||
if m.dialFn != nil {
|
||||
return m.dialFn(ctx, network, address)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRouter) Bind(ctx context.Context, network, address string, opts ...chain.BindOption) (net.Listener, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// stubRateLimiter implements rate.RateLimiter for tests.
|
||||
type stubRateLimiter struct {
|
||||
limiterFn func(key string) rate.Limiter
|
||||
}
|
||||
|
||||
func (m *stubRateLimiter) Limiter(key string) rate.Limiter {
|
||||
if m.limiterFn != nil {
|
||||
return m.limiterFn(key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubLimiter implements rate.Limiter for tests.
|
||||
type stubLimiter struct {
|
||||
allowFn func(n int) bool
|
||||
}
|
||||
|
||||
func (m *stubLimiter) Allow(n int) bool {
|
||||
if m.allowFn != nil {
|
||||
return m.allowFn(n)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *stubLimiter) Limit() float64 { return 0 }
|
||||
|
||||
// mockRecorder implements recorder.Recorder for tests.
|
||||
type mockRecorder struct{}
|
||||
|
||||
func (m *mockRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRecorder) Close() error { return nil }
|
||||
|
||||
// errorRecorder always returns an error on Record for testing error paths.
|
||||
type errorRecorder struct{}
|
||||
|
||||
func (e *errorRecorder) Record(ctx context.Context, b []byte, opts ...recorder.RecordOption) error {
|
||||
return errors.New("record error")
|
||||
}
|
||||
func (e *errorRecorder) Close() error { return nil }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stringConn — a net.Conn backed by bytes.Buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// stringConn is a net.Conn backed by bytes.Buffer, useful for testing
|
||||
// handlers without real network I/O.
|
||||
type stringConn struct {
|
||||
readBuf *bytes.Buffer
|
||||
writeBuf *bytes.Buffer
|
||||
local net.Addr
|
||||
remote net.Addr
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newStringConn(data []byte) *stringConn {
|
||||
return &stringConn{
|
||||
readBuf: bytes.NewBuffer(data),
|
||||
writeBuf: new(bytes.Buffer),
|
||||
local: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080},
|
||||
remote: &net.TCPAddr{IP: net.IPv4(10, 0, 0, 1), Port: 12345},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stringConn) Read(b []byte) (int, error) { return c.readBuf.Read(b) }
|
||||
func (c *stringConn) Write(b []byte) (int, error) { return c.writeBuf.Write(b) }
|
||||
func (c *stringConn) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
func (c *stringConn) LocalAddr() net.Addr { return c.local }
|
||||
func (c *stringConn) RemoteAddr() net.Addr { return c.remote }
|
||||
func (c *stringConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (c *stringConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (c *stringConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func nopLog() logger.Logger { return xlogger.Nop() }
|
||||
|
||||
// newTestHandler creates an uninitialised sniHandler with defaults suitable
|
||||
// for testing. The caller is responsible for calling Init if metadata parsing
|
||||
// is needed.
|
||||
func newTestHandler(opts ...handler.Option) *sniHandler {
|
||||
options := handler.Options{
|
||||
Logger: nopLog(),
|
||||
Router: &mockRouter{opts: &chain.RouterOptions{}},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
return &sniHandler{
|
||||
options: options,
|
||||
sniffer: &SnifferBuilder{},
|
||||
}
|
||||
}
|
||||
|
||||
// newInitdHandler creates a fully initialised sniHandler with default
|
||||
// metadata. Use when the test does not depend on specific metadata values.
|
||||
func newInitdHandler(opts ...handler.Option) *sniHandler {
|
||||
h := newTestHandler(opts...)
|
||||
_ = h.Init(xmd.NewMetadata(nil))
|
||||
return h
|
||||
}
|
||||
|
||||
// withRateLimiter is a helper to set the RateLimiter option on a handler.
|
||||
func withRateLimiter(rl rate.RateLimiter) handler.Option {
|
||||
return func(o *handler.Options) {
|
||||
o.RateLimiter = rl
|
||||
}
|
||||
}
|
||||
|
||||
// withRouter is a helper to set the Router option on a handler.
|
||||
func withRouter(r chain.Router) handler.Option {
|
||||
return func(o *handler.Options) {
|
||||
o.Router = r
|
||||
}
|
||||
}
|
||||
|
||||
// withRecorder is a helper to append a RecorderObject to the Recorders slice.
|
||||
func withRecorder(ro recorder.RecorderObject) handler.Option {
|
||||
return func(o *handler.Options) {
|
||||
o.Recorders = append(o.Recorders, ro)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Certificate helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// generateTestCertKey creates a self-signed test certificate and private key.
|
||||
func generateTestCertKey(t *testing.T) (*x509.Certificate, crypto.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
return cert, key
|
||||
}
|
||||
|
||||
// generateTestCertPEM creates PEM-encoded test certificate and private key.
|
||||
func generateTestCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
cert, key := generateTestCertKey(t)
|
||||
certPEM = pemEncode("CERTIFICATE", cert.Raw)
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key.(*rsa.PrivateKey))
|
||||
keyPEM = pemEncode("RSA PRIVATE KEY", keyBytes)
|
||||
return
|
||||
}
|
||||
|
||||
func pemEncode(blockType string, der []byte) []byte {
|
||||
block := &pem.Block{Type: blockType, Bytes: der}
|
||||
return pem.EncodeToMemory(block)
|
||||
}
|
||||
|
||||
func writeTempFile(t *testing.T, pattern string, data []byte) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
@@ -16,7 +16,7 @@ type metadata struct {
|
||||
// readTimeout controls two behaviors:
|
||||
// 1. A read deadline set on the client connection before sniffing the
|
||||
// TLS ClientHello / HTTP request (see handler.go Handle).
|
||||
// 2. Passed to SnifferBuilder as the timeout for reading upstream
|
||||
// 2. Passed to [sniffing.Sniffer] as the timeout for reading upstream
|
||||
// response headers during HTTP/TLS sniffing.
|
||||
// 0 or negative defaults to 15s.
|
||||
readTimeout time.Duration
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMetadata — defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseMetadata_Defaults(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
|
||||
err := h.parseMetadata(xmd.NewMetadata(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout <= 0 {
|
||||
t.Error("expected positive default readTimeout")
|
||||
}
|
||||
if h.md.sniffingWebsocket {
|
||||
t.Error("expected sniffingWebsocket disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMetadata — custom values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseMetadata_CustomValues(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"readTimeout": "60s",
|
||||
"hash": "host",
|
||||
"sniffing.websocket": true,
|
||||
"sniffing.websocket.sampleRate": 0.75,
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout != 60*time.Second {
|
||||
t.Errorf("expected 60s, got %v", h.md.readTimeout)
|
||||
}
|
||||
if h.md.hash != "host" {
|
||||
t.Errorf("expected hash 'host', got '%s'", h.md.hash)
|
||||
}
|
||||
if !h.md.sniffingWebsocket {
|
||||
t.Error("expected sniffingWebsocket true")
|
||||
}
|
||||
if h.md.sniffingWebsocketSampleRate != 0.75 {
|
||||
t.Errorf("expected 0.75, got %f", h.md.sniffingWebsocketSampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_ReadTimeoutDefault(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
// A non-positive value should fall back to the 15s default.
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"readTimeout": "0s",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout != 15*time.Second {
|
||||
t.Errorf("expected 15s default, got %v", h.md.readTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_ReadTimeoutNegative(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"readTimeout": "-5s",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if h.md.readTimeout != 15*time.Second {
|
||||
t.Errorf("expected 15s default for negative value, got %v", h.md.readTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMetadata — MITM cert/key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseMetadata_MITMCertError(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": "/nonexistent/cert.pem",
|
||||
"mitm.keyFile": "/nonexistent/key.pem",
|
||||
})
|
||||
err := h.parseMetadata(md)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent cert file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMCertKeyMismatch(t *testing.T) {
|
||||
// Only certFile set, not keyFile — keys should not be loaded.
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": "/some/cert.pem",
|
||||
})
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.certificate != nil {
|
||||
t.Error("expected certificate to remain nil when keyFile not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMCert(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCertPEM(t)
|
||||
certFile := writeTempFile(t, "cert-*.pem", certPEM)
|
||||
defer os.Remove(certFile)
|
||||
keyFile := writeTempFile(t, "key-*.pem", keyPEM)
|
||||
defer os.Remove(keyFile)
|
||||
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.certFile": certFile,
|
||||
"mitm.keyFile": keyFile,
|
||||
"mitm.alpn": "h2",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.certificate == nil {
|
||||
t.Error("expected certificate to be loaded")
|
||||
}
|
||||
if h.md.privateKey == nil {
|
||||
t.Error("expected privateKey to be loaded")
|
||||
}
|
||||
if h.md.alpn != "h2" {
|
||||
t.Errorf("expected alpn 'h2', got %s", h.md.alpn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMCertCACertFile(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCertPEM(t)
|
||||
certFile := writeTempFile(t, "cert-*.pem", certPEM)
|
||||
defer os.Remove(certFile)
|
||||
keyFile := writeTempFile(t, "key-*.pem", keyPEM)
|
||||
defer os.Remove(keyFile)
|
||||
|
||||
// Use the alternate "caCertFile"/"caKeyFile" key names.
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.caCertFile": certFile,
|
||||
"mitm.caKeyFile": keyFile,
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if h.md.certificate == nil {
|
||||
t.Error("expected certificate to be loaded via caCertFile")
|
||||
}
|
||||
if h.md.privateKey == nil {
|
||||
t.Error("expected privateKey to be loaded via caKeyFile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadata_MITMBypass(t *testing.T) {
|
||||
// Verify that mitm.bypass is parsed without error even when the named
|
||||
// bypass does not exist. The exact value depends on the global registry
|
||||
// state (other packages may have registered bypasses via init()).
|
||||
h := newTestHandler()
|
||||
md := xmd.NewMetadata(map[string]any{
|
||||
"mitm.bypass": "nonexistent-1769845",
|
||||
})
|
||||
|
||||
err := h.parseMetadata(md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// SnifferBuilder holds configuration for creating per-connection protocol
|
||||
// sniffers. It is populated once during Init and reused via [SnifferBuilder.Build]
|
||||
// for each connection to avoid repeated allocation.
|
||||
type SnifferBuilder struct {
|
||||
Websocket bool
|
||||
WebsocketSampleRate float64
|
||||
Recorder recorder.Recorder
|
||||
RecorderOptions *recorder.Options
|
||||
Certificate *x509.Certificate
|
||||
PrivateKey crypto.PrivateKey
|
||||
ALPN string
|
||||
CertPool tls_util.CertPool
|
||||
MitmBypass bypass.Bypass
|
||||
// ReadTimeout is the timeout for reading upstream HTTP response headers
|
||||
// and TLS ServerHello during sniffing. Passed through to [sniffing.Sniffer].
|
||||
// See sniffing.Sniffer.ReadTimeout for details.
|
||||
ReadTimeout time.Duration
|
||||
}
|
||||
|
||||
// Build creates a new [sniffing.Sniffer] from the builder's configuration.
|
||||
// Each call produces a fresh instance, safe for per-connection use.
|
||||
func (b *SnifferBuilder) Build() *sniffing.Sniffer {
|
||||
return &sniffing.Sniffer{
|
||||
Websocket: b.Websocket,
|
||||
WebsocketSampleRate: b.WebsocketSampleRate,
|
||||
Recorder: b.Recorder,
|
||||
RecorderOptions: b.RecorderOptions,
|
||||
Certificate: b.Certificate,
|
||||
PrivateKey: b.PrivateKey,
|
||||
NegotiatedProtocol: b.ALPN,
|
||||
CertPool: b.CertPool,
|
||||
MitmBypass: b.MitmBypass,
|
||||
ReadTimeout: b.ReadTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// sniffingDial creates a dial function for the sniffing branch that wraps
|
||||
// [chain.Router.Dial] with context-based hash selection, route recording,
|
||||
// and source/destination address population for the recorder.
|
||||
func (h *sniHandler) sniffingDial(ctx context.Context, network, address string, ro *xrecorder.HandlerRecorderObject) (net.Conn, error) {
|
||||
switch h.md.hash {
|
||||
case "host":
|
||||
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: address})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
|
||||
ro.Route = buf.String()
|
||||
|
||||
if cc != nil {
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
}
|
||||
return cc, err
|
||||
}
|
||||
|
||||
// sniffingDialTLS creates a TLS-aware dial function for the sniffing branch.
|
||||
// It dials via [sniffingDial] and wraps the connection with [tls.Client].
|
||||
func (h *sniHandler) sniffingDialTLS(ctx context.Context, network, address string, ro *xrecorder.HandlerRecorderObject, cfg *tls.Config) (net.Conn, error) {
|
||||
cc, err := h.sniffingDial(ctx, network, address, ro)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tls.Client(cc, cfg), nil
|
||||
}
|
||||
|
||||
// handleSniffedProtocol dispatches a sniffed connection to the
|
||||
// protocol-specific sniffer ([sniffing.Sniffer.HandleHTTP] or
|
||||
// [sniffing.Sniffer.HandleTLS]).
|
||||
//
|
||||
// It returns (true, err) when the protocol was handled and (false, nil)
|
||||
// when the protocol is unrecognised and the caller should handle the
|
||||
// connection directly (which for the SNI handler means silently dropping it).
|
||||
func (h *sniHandler) handleSniffedProtocol(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, proto string) (handled bool, err error) {
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS:
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return h.sniffingDial(ctx, network, address, ro)
|
||||
}
|
||||
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
return h.sniffingDialTLS(ctx, network, address, ro, cfg)
|
||||
}
|
||||
sniffer := h.sniffer.Build()
|
||||
if proto == sniffing.ProtoHTTP {
|
||||
return true, sniffer.HandleHTTP(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sniffingDial
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSniffingDial_Success(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
cc, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
if ro.Route == "" {
|
||||
t.Log("route is empty (mock does not populate context buffer)")
|
||||
}
|
||||
if ro.SrcAddr != "" {
|
||||
t.Log("src addr is set")
|
||||
}
|
||||
if ro.DstAddr != "" {
|
||||
t.Log("dst addr is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffingDial_RouterError(t *testing.T) {
|
||||
expectedErr := errors.New("dial failed")
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, expectedErr
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
_, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, expectedErr) {
|
||||
t.Errorf("expected %v, got %v", expectedErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffingDial_HashHost(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newTestHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
h.md.hash = "host"
|
||||
h.recorder = recorder.RecorderObject{Recorder: &mockRecorder{}}
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
cc, err := h.sniffingDial(context.Background(), "tcp", "example.com:80", ro)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sniffingDialTLS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSniffingDialTLS_Success(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
// sniffingDialTLS wraps the raw connection in tls.Client,
|
||||
// so the TLS handshake will likely fail against a raw net.Pipe,
|
||||
// but that's expected — we're testing the plumbing, not the TLS handshake.
|
||||
cc, err := h.sniffingDialTLS(context.Background(), "tcp", "example.com:443", ro, nil)
|
||||
if err == nil {
|
||||
defer cc.Close()
|
||||
}
|
||||
// Even with a nil tls.Config, tls.Client still attempts a handshake
|
||||
// which will fail against the pipe. We accept any outcome here
|
||||
// as long as sniffingDial was invoked correctly.
|
||||
}
|
||||
|
||||
func TestSniffingDialTLS_DialError(t *testing.T) {
|
||||
expectedErr := errors.New("dial failed")
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, expectedErr
|
||||
},
|
||||
}))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
_, err := h.sniffingDialTLS(context.Background(), "tcp", "example.com:443", ro, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failed dial")
|
||||
}
|
||||
if !errors.Is(err, expectedErr) {
|
||||
t.Errorf("expected %v, got %v", expectedErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SnifferBuilder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSnifferBuilder(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.sniffer.Websocket = true
|
||||
h.sniffer.WebsocketSampleRate = 0.5
|
||||
h.sniffer.ReadTimeout = 30 * time.Second
|
||||
|
||||
sniffer := h.sniffer.Build()
|
||||
|
||||
if !sniffer.Websocket {
|
||||
t.Error("expected Websocket to be true")
|
||||
}
|
||||
if sniffer.WebsocketSampleRate != 0.5 {
|
||||
t.Errorf("expected 0.5, got %f", sniffer.WebsocketSampleRate)
|
||||
}
|
||||
if sniffer.ReadTimeout != 30*time.Second {
|
||||
t.Errorf("expected 30s, got %v", sniffer.ReadTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnifferBuilder_ZeroValue(t *testing.T) {
|
||||
h := &sniHandler{sniffer: &SnifferBuilder{}}
|
||||
sniffer := h.sniffer.Build()
|
||||
|
||||
if sniffer == nil {
|
||||
t.Fatal("expected non-nil sniffer")
|
||||
}
|
||||
if sniffer.Websocket {
|
||||
t.Error("expected Websocket false by default")
|
||||
}
|
||||
if sniffer.WebsocketSampleRate != 0 {
|
||||
t.Errorf("expected 0, got %f", sniffer.WebsocketSampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleSniffedProtocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleSniffedProtocol_UnknownProto(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), "ssh")
|
||||
if handled {
|
||||
t.Error("expected handled=false for unknown proto")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSniffedProtocol_EmptyProto(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), "")
|
||||
if handled {
|
||||
t.Error("expected handled=false for empty proto")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSniffedProtocol_HTTP(t *testing.T) {
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("dial not configured in test")
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), sniffing.ProtoHTTP)
|
||||
|
||||
if !handled {
|
||||
t.Error("expected handled=true for HTTP proto")
|
||||
}
|
||||
if err == nil {
|
||||
t.Log("sniffer processed HTTP request without error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSniffedProtocol_TLS(t *testing.T) {
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("dial not configured in test")
|
||||
},
|
||||
}))
|
||||
conn := newStringConn([]byte{
|
||||
0x16, 0x03, 0x01, 0x00, 0x06, 0x01, 0x00, 0x00, 0x02, 0x03, 0x01,
|
||||
})
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
handled, err := h.handleSniffedProtocol(context.Background(), conn, ro, nopLog(), sniffing.ProtoTLS)
|
||||
|
||||
if !handled {
|
||||
t.Error("expected handled=true for TLS proto")
|
||||
}
|
||||
if err == nil {
|
||||
t.Log("sniffer processed TLS ClientHello without error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
var (
|
||||
errRouterNotAvailable = errors.New("sni: router not available")
|
||||
)
|
||||
|
||||
// newRecorderObject creates a HandlerRecorderObject populated with connection
|
||||
// metadata (service, addresses, network type, session ID, client address).
|
||||
func (h *sniHandler) newRecorderObject(ctx context.Context, conn net.Conn, start time.Time) *xrecorder.HandlerRecorderObject {
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
Service: h.options.Service,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
LocalAddr: conn.LocalAddr().String(),
|
||||
Network: "tcp",
|
||||
Time: start,
|
||||
SID: xctx.SidFromContext(ctx).String(),
|
||||
}
|
||||
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
||||
ro.ClientAddr = srcAddr.String()
|
||||
}
|
||||
return ro
|
||||
}
|
||||
|
||||
// checkRateLimit verifies that the remote address has not exceeded the
|
||||
// configured connection rate limit. Returns true if allowed, or if no rate
|
||||
// limiter is configured.
|
||||
func (h *sniHandler) checkRateLimit(addr net.Addr) bool {
|
||||
if h.options.RateLimiter == nil {
|
||||
return true
|
||||
}
|
||||
if addr == nil {
|
||||
return true
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr.String())
|
||||
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
|
||||
return limiter.Allow(1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package sni
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/limiter/rate"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// newRecorderObject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewRecorderObject_TCP(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
start := time.Now()
|
||||
|
||||
ro := h.newRecorderObject(context.Background(), conn, start)
|
||||
|
||||
if ro.Network != "tcp" {
|
||||
t.Errorf("expected network tcp, got %s", ro.Network)
|
||||
}
|
||||
if ro.RemoteAddr != "10.0.0.1:12345" {
|
||||
t.Errorf("expected remote 10.0.0.1:12345, got %s", ro.RemoteAddr)
|
||||
}
|
||||
if ro.LocalAddr != "127.0.0.1:8080" {
|
||||
t.Errorf("expected local 127.0.0.1:8080, got %s", ro.LocalAddr)
|
||||
}
|
||||
if !ro.Time.Equal(start) {
|
||||
t.Errorf("expected time %v, got %v", start, ro.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_SID(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
ctx := xctx.ContextWithSid(context.Background(), xctx.Sid("test-sid"))
|
||||
|
||||
ro := h.newRecorderObject(ctx, conn, time.Now())
|
||||
|
||||
if ro.SID != "test-sid" {
|
||||
t.Errorf("expected SID 'test-sid', got '%s'", ro.SID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_NilSrcAddr(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
ro := h.newRecorderObject(context.Background(), conn, time.Now())
|
||||
|
||||
if ro.ClientAddr != "" {
|
||||
t.Errorf("expected empty ClientAddr, got %s", ro.ClientAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_SrcAddr(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
srcAddr := &net.TCPAddr{IP: net.IPv4(192, 168, 1, 1), Port: 54321}
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), srcAddr)
|
||||
|
||||
ro := h.newRecorderObject(ctx, conn, time.Now())
|
||||
|
||||
if ro.ClientAddr != "192.168.1.1:54321" {
|
||||
t.Errorf("expected ClientAddr '192.168.1.1:54321', got '%s'", ro.ClientAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecorderObject_Service(t *testing.T) {
|
||||
h := newTestHandler(func(o *handler.Options) {
|
||||
o.Service = "my-service"
|
||||
})
|
||||
conn := newStringConn(nil)
|
||||
|
||||
ro := h.newRecorderObject(context.Background(), conn, time.Now())
|
||||
|
||||
if ro.Service != "my-service" {
|
||||
t.Errorf("expected Service 'my-service', got '%s'", ro.Service)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkRateLimit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckRateLimit_NilLimiter(t *testing.T) {
|
||||
h := newInitdHandler()
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if !h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected true when RateLimiter is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_NilAddr(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return false }}
|
||||
},
|
||||
}))
|
||||
|
||||
if !h.checkRateLimit(nil) {
|
||||
t.Error("expected true when addr is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_Allowed(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return true }}
|
||||
},
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if !h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected true when limiter allows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_Blocked(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter {
|
||||
return &stubLimiter{allowFn: func(n int) bool { return false }}
|
||||
},
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected false when limiter blocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRateLimit_NoLimiterForKey(t *testing.T) {
|
||||
h := newInitdHandler(withRateLimiter(&stubRateLimiter{
|
||||
limiterFn: func(key string) rate.Limiter { return nil },
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
|
||||
if !h.checkRateLimit(conn.RemoteAddr()) {
|
||||
t.Error("expected true when no limiter found for key")
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,7 @@ func (h *socks4Handler) handleConnect(ctx context.Context, conn net.Conn, req *g
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -318,6 +319,7 @@ func (h *socks4Handler) handleConnect(ctx context.Context, conn net.Conn, req *g
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -82,7 +82,10 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
|
||||
resp := gosocks5.NewReply(gosocks5.Succeeded, nil)
|
||||
// Report the actual bound address (BND.ADDR) per RFC 1928 §6.
|
||||
bndAddr := gosocks5.Addr{}
|
||||
bndAddr.ParseFrom(cc.LocalAddr().String())
|
||||
resp := gosocks5.NewReply(gosocks5.Succeeded, &bndAddr)
|
||||
log.Trace(resp)
|
||||
if err := resp.Write(conn); err != nil {
|
||||
log.Error(err)
|
||||
@@ -128,6 +131,7 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -136,6 +140,7 @@ func (h *socks5Handler) handleConnect(ctx context.Context, conn net.Conn, networ
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -100,6 +100,26 @@ func (h *socks5Handler) Init(md md.Metadata) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// networkAddr derives the network string from the SOCKS5 address type.
|
||||
// For IPv4 addresses it returns "tcp4"/"udp4" to prevent dual-stack
|
||||
// (IPv4-mapped-IPv6) socket creation on Linux, which would cause the
|
||||
// listener to bind on [::]:port instead of 0.0.0.0:port.
|
||||
// For IPv6 addresses it returns "tcp6"/"udp6".
|
||||
// For domain addresses it returns the unmodified network.
|
||||
func networkAddr(network string, addr *gosocks5.Addr) string {
|
||||
if addr == nil {
|
||||
return network
|
||||
}
|
||||
switch addr.Type {
|
||||
case gosocks5.AddrIPv4:
|
||||
return network + "4"
|
||||
case gosocks5.AddrIPv6:
|
||||
return network + "6"
|
||||
default:
|
||||
return network
|
||||
}
|
||||
}
|
||||
|
||||
func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -179,15 +199,15 @@ func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handl
|
||||
case gosocks5.CmdConnect:
|
||||
return h.handleConnect(ctx, conn, "tcp", address, ro, log)
|
||||
case gosocks5.CmdBind:
|
||||
return h.handleBind(ctx, conn, "tcp", address, ro, log)
|
||||
return h.handleBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
|
||||
case socks.CmdMuxBind:
|
||||
return h.handleMuxBind(ctx, conn, "tcp", address, ro, log)
|
||||
return h.handleMuxBind(ctx, conn, networkAddr("tcp", req.Addr), address, ro, log)
|
||||
case gosocks5.CmdUdp:
|
||||
ro.Network = "udp"
|
||||
return h.handleUDP(ctx, conn, "udp", ro, log)
|
||||
case socks.CmdUDPTun:
|
||||
ro.Network = "udp"
|
||||
return h.handleUDPTun(ctx, conn, "udp", address, ro, log)
|
||||
return h.handleUDPTun(ctx, conn, networkAddr("udp", req.Addr), address, ro, log)
|
||||
default:
|
||||
err = ErrUnknownCmd
|
||||
log.Error(err)
|
||||
|
||||
@@ -26,6 +26,9 @@ type metadata struct {
|
||||
enableBind bool
|
||||
enableUDP bool
|
||||
udpBufferSize int
|
||||
udpResolveDomain bool
|
||||
udpBindMin int
|
||||
udpBindMax int
|
||||
compatibilityMode bool
|
||||
hash string
|
||||
muxCfg *mux.Config
|
||||
@@ -58,6 +61,9 @@ func (h *socks5Handler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
h.md.enableBind = mdutil.GetBool(md, "bind")
|
||||
h.md.enableUDP = mdutil.GetBool(md, "udp")
|
||||
h.md.udpBufferSize = mdutil.GetInt(md, "udp.bufferSize", "udpBufferSize")
|
||||
h.md.udpResolveDomain = mdutil.GetBool(md, "udp.resolveDomain", "udpResolveDomain")
|
||||
h.md.udpBindMin = mdutil.GetInt(md, "udp.bindRange.min", "udp.minPort")
|
||||
h.md.udpBindMax = mdutil.GetInt(md, "udp.bindRange.max", "udp.maxPort")
|
||||
|
||||
h.md.compatibilityMode = mdutil.GetBool(md, "comp")
|
||||
h.md.hash = mdutil.GetString(md, "hash")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package v5
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/gosocks5"
|
||||
)
|
||||
|
||||
func TestNetworkAddr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
network string
|
||||
addr *gosocks5.Addr
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil addr returns unmodified",
|
||||
network: "tcp",
|
||||
addr: nil,
|
||||
want: "tcp",
|
||||
},
|
||||
{
|
||||
name: "IPv4 tcp returns tcp4",
|
||||
network: "tcp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
|
||||
want: "tcp4",
|
||||
},
|
||||
{
|
||||
name: "IPv6 tcp returns tcp6",
|
||||
network: "tcp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
|
||||
want: "tcp6",
|
||||
},
|
||||
{
|
||||
name: "IPv4 udp returns udp4",
|
||||
network: "udp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv4},
|
||||
want: "udp4",
|
||||
},
|
||||
{
|
||||
name: "IPv6 udp returns udp6",
|
||||
network: "udp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrIPv6},
|
||||
want: "udp6",
|
||||
},
|
||||
{
|
||||
name: "domain addr returns unmodified",
|
||||
network: "tcp",
|
||||
addr: &gosocks5.Addr{Type: gosocks5.AddrDomain},
|
||||
want: "tcp",
|
||||
},
|
||||
{
|
||||
name: "unknown addr type returns unmodified",
|
||||
network: "udp",
|
||||
addr: &gosocks5.Addr{Type: 99},
|
||||
want: "udp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := networkAddr(tt.network, tt.addr)
|
||||
if got != tt.want {
|
||||
t.Errorf("networkAddr(%q, %v) = %q, want %q", tt.network, tt.addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,25 +27,42 @@ func (selector *serverSelector) Methods() []uint8 {
|
||||
|
||||
func (s *serverSelector) Select(methods ...uint8) (method uint8) {
|
||||
s.logger.Debugf("%d %d %v", gosocks5.Ver5, len(methods), methods)
|
||||
method = gosocks5.MethodNoAuth
|
||||
|
||||
// Determine which methods the client offered.
|
||||
var hasNoAuth, hasUserPass, hasTLS, hasTLSAuth bool
|
||||
for _, m := range methods {
|
||||
if m == socks.MethodTLS && !s.noTLS && s.TLSConfig != nil {
|
||||
method = m
|
||||
break
|
||||
switch m {
|
||||
case gosocks5.MethodNoAuth:
|
||||
hasNoAuth = true
|
||||
case gosocks5.MethodUserPass:
|
||||
hasUserPass = true
|
||||
case socks.MethodTLS:
|
||||
hasTLS = true
|
||||
case socks.MethodTLSAuth:
|
||||
hasTLSAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
// when Authenticator is set, auth is mandatory
|
||||
// When Authenticator is set, authentication is mandatory.
|
||||
if s.Authenticator != nil {
|
||||
if method == gosocks5.MethodNoAuth {
|
||||
method = gosocks5.MethodUserPass
|
||||
if hasTLSAuth && !s.noTLS && s.TLSConfig != nil {
|
||||
return socks.MethodTLSAuth
|
||||
}
|
||||
if method == socks.MethodTLS && !s.noTLS {
|
||||
method = socks.MethodTLSAuth
|
||||
if hasUserPass {
|
||||
return gosocks5.MethodUserPass
|
||||
}
|
||||
return gosocks5.MethodNoAcceptable
|
||||
}
|
||||
|
||||
return
|
||||
// No authenticator — select the best unauthenticated method.
|
||||
if hasTLS && !s.noTLS && s.TLSConfig != nil {
|
||||
return socks.MethodTLS
|
||||
}
|
||||
if hasNoAuth {
|
||||
return gosocks5.MethodNoAuth
|
||||
}
|
||||
|
||||
return gosocks5.MethodNoAcceptable
|
||||
}
|
||||
|
||||
func (s *serverSelector) OnSelected(method uint8, conn net.Conn) (string, net.Conn, error) {
|
||||
|
||||
+213
-22
@@ -4,13 +4,19 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/hosts"
|
||||
"github.com/go-gost/core/limiter"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/gosocks5"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
@@ -49,7 +55,7 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
Netns: h.options.Netns,
|
||||
}
|
||||
|
||||
cc, err := lc.ListenPacket(ctx, network, net.JoinHostPort(host, "0"))
|
||||
cc, err := listenPacketInRange(ctx, &lc, network, host, h.md.udpBindMin, h.md.udpBindMax)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
reply := gosocks5.NewReply(gosocks5.Failure, nil)
|
||||
@@ -65,6 +71,45 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
})
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
|
||||
// Verify the upstream chain is reachable before replying Success,
|
||||
// per RFC 1928 §7.
|
||||
var buf bytes.Buffer
|
||||
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), network, "") // UDP association
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
reply := gosocks5.NewReply(gosocks5.Failure, nil)
|
||||
log.Trace(reply)
|
||||
reply.Write(conn)
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
pc, ok := c.(net.PacketConn)
|
||||
if !ok {
|
||||
err := errors.New("socks5: wrong connection type")
|
||||
log.Error(err)
|
||||
reply := gosocks5.NewReply(gosocks5.Failure, nil)
|
||||
log.Trace(reply)
|
||||
reply.Write(conn)
|
||||
return err
|
||||
}
|
||||
|
||||
// A direct (no-chain) UDP association yields a raw *net.UDPConn, which
|
||||
// cannot consume the domainAddr returned by udpConn.ReadFrom for
|
||||
// ATYP=DOMAINNAME datagrams. Wrap it so domains are resolved through the
|
||||
// configured resolver (hostMapper → resolver → system DNS) instead of the
|
||||
// previous hardcoded net.ResolveUDPAddr in the SOCKS5 decode path.
|
||||
// Chain-backed PacketConns (udpTunConn, udpRelayConn, ...) encode domains
|
||||
// as ATYP=Domain themselves and are left untouched.
|
||||
if _, isDirect := pc.(*net.UDPConn); isDirect {
|
||||
pc = &resolvePacketConn{
|
||||
PacketConn: pc,
|
||||
resolver: h.options.Router.Options().Resolver,
|
||||
hostMapper: h.options.Router.Options().HostMapper,
|
||||
}
|
||||
}
|
||||
|
||||
saddr := gosocks5.Addr{}
|
||||
saddr.ParseFrom(cc.LocalAddr().String())
|
||||
|
||||
@@ -81,23 +126,6 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
}
|
||||
|
||||
log.Debugf("bind on %s OK", cc.LocalAddr())
|
||||
|
||||
// obtain a udp connection
|
||||
var buf bytes.Buffer
|
||||
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), network, "") // UDP association
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
pc, ok := c.(net.PacketConn)
|
||||
if !ok {
|
||||
err := errors.New("socks5: wrong connection type")
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
pc = metrics.WrapPacketConn(ro.Service, pc)
|
||||
|
||||
{
|
||||
@@ -129,12 +157,29 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
}
|
||||
}
|
||||
|
||||
r := udp.NewRelay(socks.UDPConn(
|
||||
tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
err := fmt.Errorf("socks5 udp: unexpected remote address type %T", conn.RemoteAddr())
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
pc1 := socks.UDPConn(
|
||||
&filteredPacketConn{
|
||||
PacketConn: cc,
|
||||
filterIP: conn.RemoteAddr().(*net.TCPAddr).IP,
|
||||
filterIP: tcpAddr.IP,
|
||||
},
|
||||
h.md.udpBufferSize), pc).
|
||||
h.md.udpBufferSize)
|
||||
|
||||
if h.md.udpResolveDomain {
|
||||
pc1 = &domainResolvePacketConn{
|
||||
PacketConn: pc1,
|
||||
resolver: h.options.Router.Options().Resolver,
|
||||
hostMapper: h.options.Router.Options().HostMapper,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
r := udp.NewRelay(pc1, pc).
|
||||
WithService(h.options.Service).
|
||||
WithBypass(h.options.Bypass).
|
||||
WithBufferSize(h.md.udpBufferSize).
|
||||
@@ -151,6 +196,37 @@ func (h *socks5Handler) handleUDP(ctx context.Context, conn net.Conn, network st
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenPacketInRange binds the client-facing UDP relay socket. When lo..hi is
|
||||
// a valid range (lo > 0, hi >= lo) it scans ports from a random offset within
|
||||
// [lo, hi] and retries on EADDRINUSE, so a NAT router can forward a small,
|
||||
// fixed port set to the SOCKS server. The random offset avoids every
|
||||
// association hammering port lo under concurrency; the rotation visits distinct
|
||||
// ports, so a free port is always found when the range fits the attempt cap.
|
||||
// RFC 1928 leaves relay-port selection to the server; the caller still reports
|
||||
// the actually-bound port via BND.PORT. With no range, the OS chooses (port 0).
|
||||
// A non-EADDRINUSE error fails fast.
|
||||
func listenPacketInRange(ctx context.Context, lc *xnet.ListenConfig, network, host string, lo, hi int) (net.PacketConn, error) {
|
||||
if lo > 0 && hi >= lo {
|
||||
span := hi - lo + 1
|
||||
attempts := min(span, 64)
|
||||
offset := rand.IntN(span)
|
||||
var lastErr error
|
||||
for i := range attempts {
|
||||
port := lo + (offset+i)%span
|
||||
cc, err := lc.ListenPacket(ctx, network, net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err == nil {
|
||||
return cc, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !errors.Is(err, syscall.EADDRINUSE) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
return lc.ListenPacket(ctx, network, net.JoinHostPort(host, "0"))
|
||||
}
|
||||
|
||||
// filteredPacketConn implements SOCKS5 RFC 1928 UDP relay security by filtering
|
||||
// incoming packets to only accept those from the client IP that established the TCP control connection.
|
||||
type filteredPacketConn struct {
|
||||
@@ -165,8 +241,123 @@ func (f *filteredPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error
|
||||
return
|
||||
}
|
||||
|
||||
if udpAddr := addr.(*net.UDPAddr); udpAddr.IP.Equal(f.filterIP) {
|
||||
udpAddr, ok := addr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if udpAddr.IP.Equal(f.filterIP) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePacketConn wraps the upstream net.PacketConn for a direct (no-chain)
|
||||
// UDP association and resolves domain addresses to IPs in WriteTo calls.
|
||||
//
|
||||
// udpConn.ReadFrom now returns a domainAddr for ATYP=DOMAINNAME datagrams so
|
||||
// the domain survives to the chain; a raw *net.UDPConn cannot consume a
|
||||
// domainAddr (WriteTo needs *net.UDPAddr), so direct connections are wrapped
|
||||
// here. Resolution order is hostMapper → configured resolver → system DNS, so a
|
||||
// configured resolver (e.g. resolver=1.1.1.1) is honored instead of leaking
|
||||
// through the system resolver.
|
||||
type resolvePacketConn struct {
|
||||
net.PacketConn
|
||||
resolver resolver.Resolver
|
||||
hostMapper hosts.HostMapper
|
||||
}
|
||||
|
||||
func (c *resolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
if c.hostMapper != nil {
|
||||
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 && c.resolver != nil {
|
||||
ips, _ = c.resolver.Resolve(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
ips, _ = net.LookupIP(host)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return 0, fmt.Errorf("socks5 udp: cannot resolve %s", host)
|
||||
}
|
||||
|
||||
ip := ips[0]
|
||||
for _, candidate := range ips {
|
||||
if candidate.To4() != nil {
|
||||
ip = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
|
||||
}
|
||||
|
||||
// domainResolvePacketConn wraps a net.PacketConn and resolves domain
|
||||
// addresses to IPs in WriteTo calls. This guarantees that SOCKS5 UDP
|
||||
// response datagrams never carry ATYP=Domain (0x03), which some clients
|
||||
// (e.g., tun2proxy, Surge) cannot parse.
|
||||
type domainResolvePacketConn struct {
|
||||
net.PacketConn
|
||||
resolver resolver.Resolver
|
||||
hostMapper hosts.HostMapper
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func (c *domainResolvePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
// Already an IP address — pass through unchanged.
|
||||
return c.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
// Resolve the domain using the router's infrastructure
|
||||
// (host mapper → resolver → system fallback) so the SOCKS5 encoder
|
||||
// will use AddrIPv4 or AddrIPv6 instead of AddrDomain.
|
||||
var ips []net.IP
|
||||
if c.hostMapper != nil {
|
||||
ips, _ = c.hostMapper.Lookup(context.Background(), "ip", host)
|
||||
}
|
||||
if len(ips) == 0 && c.resolver != nil {
|
||||
ips, err = c.resolver.Resolve(context.Background(), "ip", host)
|
||||
if err != nil && c.log != nil {
|
||||
c.log.Warnf("socks5 udp: resolve %s: %v", host, err)
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
ips, err = net.LookupIP(host)
|
||||
}
|
||||
if err != nil || len(ips) == 0 {
|
||||
// Cannot resolve the domain — drop the datagram rather than
|
||||
// forwarding an ATYP=Domain response the client cannot parse.
|
||||
if c.log != nil {
|
||||
c.log.Warnf("socks5 udp: dropping datagram to %s (DNS resolution failed)", host)
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("socks5 udp: DNS resolution failed for %s", host)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Pick the first IPv4 address from the result set, falling back to
|
||||
// the first entry when no IPv4 is available.
|
||||
ip := ips[0]
|
||||
for _, candidate := range ips {
|
||||
if candidate.To4() != nil {
|
||||
ip = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return c.PacketConn.WriteTo(b, &net.UDPAddr{IP: ip, Port: port})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package v5
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
)
|
||||
|
||||
func TestListenPacketInRange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// No range configured: OS picks (port 0), always succeeds.
|
||||
lc := &xnet.ListenConfig{}
|
||||
cc, err := listenPacketInRange(ctx, lc, "udp", "127.0.0.1", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("port-0 bind: %v", err)
|
||||
}
|
||||
cc.Close()
|
||||
|
||||
// Range configured: bound port must fall within [lo, hi]. With 51
|
||||
// candidates and attempts = min(span, 64), only a fully-occupied range
|
||||
// fails — extremely unlikely on a dev/CI host.
|
||||
const lo, hi = 35000, 35050
|
||||
cc, err = listenPacketInRange(ctx, lc, "udp", "127.0.0.1", lo, hi)
|
||||
if err != nil {
|
||||
t.Fatalf("range bind [%d,%d]: %v", lo, hi, err)
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(cc.LocalAddr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
if port < lo || port > hi {
|
||||
t.Errorf("bound port %d outside [%d, %d]", port, lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
// A single-port range whose only port is already held must surface the
|
||||
// EADDRINUSE error (so the caller replies REP 0x01) — no hang, no silent miss.
|
||||
func TestListenPacketInRange_BusyPort(t *testing.T) {
|
||||
blocker, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("blocker bind: %v", err)
|
||||
}
|
||||
defer blocker.Close()
|
||||
_, portStr, _ := net.SplitHostPort(blocker.LocalAddr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
lc := &xnet.ListenConfig{}
|
||||
if _, err := listenPacketInRange(context.Background(), lc, "udp", "127.0.0.1", port, port); err == nil {
|
||||
t.Fatalf("expected bind on held port %d to fail", port)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,17 @@ func (h *socks5Handler) handleUDPTun(ctx context.Context, conn net.Conn, network
|
||||
}
|
||||
defer pc.Close()
|
||||
|
||||
// If the upstream is a raw *net.UDPConn (direct dial or BIND mode),
|
||||
// wrap it so domainAddr from socks.UDPTunServerConn.ReadFrom are
|
||||
// resolved through the configured resolver instead of failing WriteTo.
|
||||
if _, ok := pc.(*net.UDPConn); ok {
|
||||
pc = &resolvePacketConn{
|
||||
PacketConn: pc,
|
||||
resolver: h.options.Router.Options().Resolver,
|
||||
hostMapper: h.options.Router.Options().HostMapper,
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
pStats := xstats.Stats{}
|
||||
pc = stats_wrapper.WrapPacketConn(pc, &pStats)
|
||||
|
||||
@@ -237,6 +237,7 @@ func (h *ssHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.H
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -245,6 +246,7 @@ func (h *ssHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.H
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -209,6 +209,7 @@ func (h *forwardHandler) handleDirectForward(ctx context.Context, targetAddr str
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -217,6 +218,7 @@ func (h *forwardHandler) handleDirectForward(ctx context.Context, targetAddr str
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
+21
-13
@@ -45,10 +45,10 @@ func (h *tunHandler) handleClient(ctx context.Context, conn net.Conn, network st
|
||||
defer cc.Close()
|
||||
|
||||
if network == "udp" {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
iterCtx, iterCancel := context.WithCancel(ctx)
|
||||
defer iterCancel()
|
||||
|
||||
go h.keepalive(ctx, cc, ips)
|
||||
go h.keepalive(iterCtx, cc, ips)
|
||||
}
|
||||
|
||||
return h.transportClient(ctx, conn, cc, log)
|
||||
@@ -100,11 +100,21 @@ func (h *tunHandler) keepalive(ctx context.Context, conn net.Conn, ips []net.IP)
|
||||
}
|
||||
|
||||
func (h *tunHandler) transportClient(ctx context.Context, tun io.ReadWriter, conn net.Conn, log logger.Logger) error {
|
||||
c, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errc := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
var b [MaxMessageSize]byte
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
errc <- c.Err()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
err := func() error {
|
||||
n, err := tun.Read(b[:])
|
||||
if err != nil {
|
||||
@@ -155,6 +165,13 @@ func (h *tunHandler) transportClient(ctx context.Context, tun io.ReadWriter, con
|
||||
go func() {
|
||||
var b [MaxMessageSize]byte
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
errc <- c.Err()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
err := func() error {
|
||||
n, err := conn.Read(b[:])
|
||||
if err != nil {
|
||||
@@ -214,14 +231,5 @@ func (h *tunHandler) transportClient(ctx context.Context, tun io.ReadWriter, con
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
if err != nil && err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return collectFirstError(errc, cancel)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -111,6 +112,32 @@ func (h *tunHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.
|
||||
return h.handleServer(ctx, conn, config, log)
|
||||
}
|
||||
|
||||
// collectFirstError drains errc (whose capacity must equal the number of
|
||||
// goroutines writing to it) and returns the first error that is not io.EOF,
|
||||
// context.Canceled, or context.DeadlineExceeded. It cancels the derived
|
||||
// context after the first error to signal sibling goroutines to exit.
|
||||
// A timeout on the second read prevents deadlock when a goroutine is stuck
|
||||
// in a blocking read that is not context-aware.
|
||||
func collectFirstError(errc <-chan error, cancel context.CancelFunc) error {
|
||||
var firstErr error
|
||||
// Wait for the first goroutine to exit.
|
||||
err := <-errc
|
||||
cancel() // signal the sibling goroutine to exit
|
||||
if err != nil && err != io.EOF && err != context.Canceled && err != context.DeadlineExceeded {
|
||||
firstErr = err
|
||||
}
|
||||
// Wait for the sibling goroutine, but don't block forever — the
|
||||
// goroutine may be stuck in a blocking read that is not context-aware.
|
||||
select {
|
||||
case err := <-errc:
|
||||
if err != nil && firstErr == nil && err != io.EOF && err != context.Canceled && err != context.DeadlineExceeded {
|
||||
firstErr = err
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
type tunRouteKey [16]byte
|
||||
|
||||
func ipToTunRouteKey(ip net.IP) (key tunRouteKey) {
|
||||
|
||||
+21
-7
@@ -3,6 +3,7 @@ package tun
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -29,7 +30,7 @@ func (h *tunHandler) handleServer(ctx context.Context, conn net.Conn, config *tu
|
||||
|
||||
return h.transportServer(ctx, conn, pc, config, log)
|
||||
}()
|
||||
if err == ErrTun {
|
||||
if errors.Is(err, ErrTun) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -39,11 +40,21 @@ func (h *tunHandler) handleServer(ctx context.Context, conn net.Conn, config *tu
|
||||
}
|
||||
|
||||
func (h *tunHandler) transportServer(ctx context.Context, tun io.ReadWriter, conn net.PacketConn, config *tun_util.Config, log logger.Logger) error {
|
||||
errc := make(chan error, 1)
|
||||
c, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errc := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
var b [MaxMessageSize]byte
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
errc <- c.Err()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
err := func() error {
|
||||
n, err := tun.Read(b[:])
|
||||
if err != nil {
|
||||
@@ -110,6 +121,13 @@ func (h *tunHandler) transportServer(ctx context.Context, tun io.ReadWriter, con
|
||||
go func() {
|
||||
var b [MaxMessageSize]byte
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
errc <- c.Err()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
err := func() error {
|
||||
n, addr, err := conn.ReadFrom(b[:])
|
||||
if err != nil {
|
||||
@@ -231,11 +249,7 @@ func (h *tunHandler) transportServer(ctx context.Context, tun io.ReadWriter, con
|
||||
}
|
||||
}()
|
||||
|
||||
err := <-errc
|
||||
if err != nil && err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
return collectFirstError(errc, cancel)
|
||||
}
|
||||
|
||||
func (h *tunHandler) updateRoute(ip net.IP, addr net.Addr, log logger.Logger) {
|
||||
|
||||
@@ -180,6 +180,7 @@ func (h *unixHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
@@ -188,6 +189,7 @@ func (h *unixHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
+8
-3
@@ -176,6 +176,7 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
if matcher := node.Options().Matcher; matcher != nil {
|
||||
req := routing.Request{
|
||||
ClientIP: options.ClientIP,
|
||||
Network: options.Network,
|
||||
Host: options.Host,
|
||||
Protocol: options.Protocol,
|
||||
Method: options.Method,
|
||||
@@ -207,13 +208,17 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
})
|
||||
|
||||
if nodes[0].Options().Priority > 0 &&
|
||||
!anyBackupNode(nodes) {
|
||||
!anyBackupNode(nodes) &&
|
||||
nodes[0].Options().Priority > nodes[1].Options().Priority {
|
||||
// Priority short-circuit: highest-priority non-backup node wins.
|
||||
// Conditions: (1) top priority > 0 means a matcher indicated routing
|
||||
// specificity, so the top node is authoritative for this request;
|
||||
// (2) no backup node is present, otherwise BackupFilter would be
|
||||
// silently bypassed. When both hold the selector (FailFilter,
|
||||
// BackupFilter, strategy) is skipped and the best node wins directly.
|
||||
// silently bypassed; (3) the top priority is strictly higher than
|
||||
// the second-highest — when multiple nodes share the same matcher
|
||||
// rule they have equal priority and the selector (FailFilter,
|
||||
// BackupFilter, strategy) should still apply for load balancing.
|
||||
// When all three hold the selector is skipped and the best node wins directly.
|
||||
p.logger.Debugf("priority shortcut: node %s selected", nodes[0].Name)
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
@@ -422,6 +422,43 @@ func TestSelect_MatcherDoesNotMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelect_MatcherReceivesNetwork(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()
|
||||
|
||||
h.Select(context.Background(), hop.NetworkSelectOption("tcp"))
|
||||
|
||||
if capturedReq == nil {
|
||||
t.Fatal("request was not passed to matcher")
|
||||
}
|
||||
if capturedReq.Network != "tcp" {
|
||||
t.Errorf("matcher saw Network=%q, want %q", capturedReq.Network, "tcp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelect_MatcherMatchNetwork(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",
|
||||
chain.MatcherNodeOption(&testMatcher{match: true}),
|
||||
)
|
||||
h := newTestHop(NodeOption(n1, n2))
|
||||
defer h.Close()
|
||||
|
||||
node := h.Select(context.Background(), hop.NetworkSelectOption("udp"))
|
||||
if node == nil {
|
||||
t.Fatal("expected selected node, got nil")
|
||||
}
|
||||
if node.Name != "n2" {
|
||||
t.Errorf("expected 'n2' (match=true), 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))
|
||||
@@ -485,6 +522,31 @@ func TestSelect_WithSelector(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelect_EqualPriorityDoesNotShortcut(t *testing.T) {
|
||||
// Two nodes with identical matchers get the same default priority.
|
||||
// The priority shortcut must NOT trigger when multiple nodes share
|
||||
// the highest priority — the selector should still apply.
|
||||
n1 := chain.NewNode("n1", "127.0.0.1:8080",
|
||||
chain.MatcherNodeOption(&testMatcher{match: true}),
|
||||
chain.PriorityNodeOption(50),
|
||||
)
|
||||
n2 := chain.NewNode("n2", "127.0.0.1:9090",
|
||||
chain.MatcherNodeOption(&testMatcher{match: true}),
|
||||
chain.PriorityNodeOption(50),
|
||||
)
|
||||
sel := &testNodeSelector{selectedIdx: 1} // picks n2
|
||||
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, not shortcut), got %q", node.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelect_NilNodesSkipped(t *testing.T) {
|
||||
h := newTestHop()
|
||||
defer h.Close()
|
||||
|
||||
+4
-1
@@ -137,7 +137,10 @@ func (h *hostMapper) Lookup(ctx context.Context, network, host string, opts ...h
|
||||
}
|
||||
|
||||
if ips == nil {
|
||||
return
|
||||
ips = h.lookup(".")
|
||||
if ips == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch network {
|
||||
|
||||
+98
-4
@@ -14,12 +14,26 @@ import (
|
||||
|
||||
// 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.
|
||||
// It synchronously loads the initial mappings without starting the background reload goroutine
|
||||
// to prevent data races between NewHostMapper's goroutine and test lookups.
|
||||
func newTestMapper(opts ...Option) *hostMapper {
|
||||
opts = append(opts, LoggerOption(xlogger.Nop()))
|
||||
h := NewHostMapper(opts...).(*hostMapper)
|
||||
_ = h.reload(context.Background())
|
||||
var options options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
h := &hostMapper{
|
||||
mappings: make(map[string][]net.IP),
|
||||
cancelFunc: cancel,
|
||||
options: options,
|
||||
logger: options.logger,
|
||||
}
|
||||
if h.logger == nil {
|
||||
h.logger = xlogger.Nop()
|
||||
}
|
||||
_ = h.reload(ctx)
|
||||
cancel()
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -567,6 +581,86 @@ func TestLookupIPv4MappedIPv6(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCatchAll(t *testing.T) {
|
||||
h := newTestMapper(MappingsOption([]Mapping{
|
||||
{Hostname: ".", IP: net.ParseIP("10.0.0.1")},
|
||||
}))
|
||||
if err := h.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
host string
|
||||
ok bool
|
||||
}{
|
||||
{"example.com", true},
|
||||
{"anything.else.com", true},
|
||||
{"deep.sub.domain.org", true},
|
||||
{"singleword", 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 TestLookupCatchAllWithSpecific(t *testing.T) {
|
||||
h := newTestMapper(MappingsOption([]Mapping{
|
||||
{Hostname: ".example.com", IP: net.ParseIP("10.0.0.1")},
|
||||
{Hostname: ".", IP: net.ParseIP("10.0.0.2")},
|
||||
}))
|
||||
if err := h.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Specific .suffix should take priority over catch-all
|
||||
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] from .example.com, got %v (ok=%v)", ips, ok)
|
||||
}
|
||||
|
||||
ips, ok = h.Lookup(context.Background(), "ip", "sub.example.com")
|
||||
if !ok || !ips[0].Equal(net.ParseIP("10.0.0.1")) {
|
||||
t.Fatalf("expected [10.0.0.1] from .example.com, got %v (ok=%v)", ips, ok)
|
||||
}
|
||||
|
||||
// Unmatched hostname falls through to catch-all
|
||||
ips, ok = h.Lookup(context.Background(), "ip", "other.com")
|
||||
if !ok || !ips[0].Equal(net.ParseIP("10.0.0.2")) {
|
||||
t.Fatalf("expected [10.0.0.2] from catch-all, got %v (ok=%v)", ips, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCatchAllIP4IP6(t *testing.T) {
|
||||
h := newTestMapper(MappingsOption([]Mapping{
|
||||
{Hostname: ".", IP: net.ParseIP("10.0.0.1")},
|
||||
{Hostname: ".", IP: net.ParseIP("::1")},
|
||||
}))
|
||||
if err := h.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ips, ok := h.Lookup(context.Background(), "ip4", "anyhost.com")
|
||||
if !ok || len(ips) != 1 || !ips[0].Equal(net.ParseIP("10.0.0.1")) {
|
||||
t.Fatalf("expected [10.0.0.1] via ip4 catch-all, got %v (ok=%v)", ips, ok)
|
||||
}
|
||||
|
||||
ips, ok = h.Lookup(context.Background(), "ip6", "anyhost.com")
|
||||
if !ok || len(ips) != 1 || !ips[0].Equal(net.ParseIP("::1")) {
|
||||
t.Fatalf("expected [::1] via ip6 catch-all, got %v (ok=%v)", ips, ok)
|
||||
}
|
||||
|
||||
ips, ok = h.Lookup(context.Background(), "ip", "anyhost.com")
|
||||
if !ok || len(ips) != 2 {
|
||||
t.Fatalf("expected 2 IPs via catch-all, got %d (ok=%v)", len(ips), ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderWithListerPattern(t *testing.T) {
|
||||
var _ loader.Loader = (*testLister)(nil)
|
||||
var _ loader.Lister = (*testLister)(nil)
|
||||
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
// addresses assigned to that interface. The network parameter determines the address
|
||||
// type (TCPAddr for "tcp"/"tcp4"/"tcp6", UDPAddr for "udp"/"udp4"/"udp6", IPAddr
|
||||
// otherwise).
|
||||
func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr, err error) {
|
||||
//
|
||||
// isIP reports whether ifceName was parsed as an IP address (true) rather than an
|
||||
// interface name (false). When isIP is true, callers should skip SO_BINDTODEVICE —
|
||||
// the user's intent is source IP binding for policy routing, not device binding.
|
||||
func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr, isIP bool, err error) {
|
||||
if ifceName == "" {
|
||||
addr = append(addr, nil)
|
||||
return
|
||||
@@ -43,6 +47,8 @@ func ParseInterfaceAddr(ifceName, network string) (ifce string, addr []net.Addr,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ifceName is an IP address — skip SO_BINDTODEVICE, use LocalAddr only
|
||||
isIP = true
|
||||
ifce, err = findInterfaceByIP(ip)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -202,39 +202,48 @@ func Test_findInterfaceByIP(t *testing.T) {
|
||||
|
||||
func TestParseInterfaceAddr(t *testing.T) {
|
||||
// Empty interface name
|
||||
ifce, addrs, err := ParseInterfaceAddr("", "tcp")
|
||||
ifce, addrs, isIP, err := ParseInterfaceAddr("", "tcp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if ifce != "" {
|
||||
t.Errorf("expected empty ifce, got %s", ifce)
|
||||
}
|
||||
if isIP {
|
||||
t.Errorf("expected isIP=false for empty string, got true")
|
||||
}
|
||||
if len(addrs) != 1 || addrs[0] != nil {
|
||||
t.Errorf("expected [nil], got %v", addrs)
|
||||
}
|
||||
|
||||
// Non-existent interface
|
||||
_, _, err = ParseInterfaceAddr("nonexistent_interface_xyz", "tcp")
|
||||
_, _, _, err = ParseInterfaceAddr("nonexistent_interface_xyz", "tcp")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent interface")
|
||||
}
|
||||
|
||||
// IP string as interface name
|
||||
ifce, addrs, err = ParseInterfaceAddr("127.0.0.1", "tcp")
|
||||
// IP string as interface name — isIP should be true
|
||||
ifce, addrs, isIP, err = ParseInterfaceAddr("127.0.0.1", "tcp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if !isIP {
|
||||
t.Errorf("expected isIP=true for IP input, got false")
|
||||
}
|
||||
if ifce == "" {
|
||||
t.Log("127.0.0.1 not found on any interface (may happen in containers)")
|
||||
} else if len(addrs) != 1 {
|
||||
t.Errorf("expected 1 addr, got %d", len(addrs))
|
||||
}
|
||||
|
||||
// IP that doesn't exist on any interface
|
||||
ifce, addrs, err = ParseInterfaceAddr("203.0.113.1", "udp")
|
||||
// IP that doesn't exist on any interface — should still have isIP=true
|
||||
ifce, addrs, isIP, err = ParseInterfaceAddr("203.0.113.1", "udp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if !isIP {
|
||||
t.Errorf("expected isIP=true for IP input, got false")
|
||||
}
|
||||
if ifce != "" {
|
||||
t.Errorf("expected empty ifce for unassigned IP, got %s", ifce)
|
||||
}
|
||||
@@ -243,10 +252,13 @@ func TestParseInterfaceAddr(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test UDP network specific returns
|
||||
ifce, addrs, err = ParseInterfaceAddr("127.0.0.1", "udp")
|
||||
ifce, addrs, isIP, err = ParseInterfaceAddr("127.0.0.1", "udp")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if !isIP {
|
||||
t.Errorf("expected isIP=true for IP input, got false")
|
||||
}
|
||||
if ifce != "" {
|
||||
if len(addrs) != 1 {
|
||||
t.Errorf("expected 1 addr, got %d", len(addrs))
|
||||
|
||||
@@ -76,13 +76,14 @@ func (d *Dialer) Dial(ctx context.Context, network, addr string) (conn net.Conn,
|
||||
ifce = strings.TrimSuffix(ifce, "!")
|
||||
var ifceName string
|
||||
var ifAddrs []net.Addr
|
||||
ifceName, ifAddrs, err = xnet.ParseInterfaceAddr(ifce, network)
|
||||
var isIP bool
|
||||
ifceName, ifAddrs, isIP, err = xnet.ParseInterfaceAddr(ifce, network)
|
||||
if err != nil && strict {
|
||||
return
|
||||
}
|
||||
|
||||
for _, ifAddr := range ifAddrs {
|
||||
conn, err = d.dialOnce(ctx, network, addr, ifceName, ifAddr, log)
|
||||
conn, err = d.dialOnce(ctx, network, addr, ifceName, ifAddr, !isIP, log)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
@@ -100,7 +101,7 @@ func (d *Dialer) Dial(ctx context.Context, network, addr string) (conn net.Conn,
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, ifAddr net.Addr, log logger.Logger) (net.Conn, error) {
|
||||
func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, ifAddr net.Addr, bindToDevice bool, log logger.Logger) (net.Conn, error) {
|
||||
if ifceName != "" {
|
||||
log.Debugf("dial %s/%s via interface %s@%s", addr, network, ifceName, ifAddr)
|
||||
}
|
||||
@@ -123,11 +124,12 @@ func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, i
|
||||
return nil, err
|
||||
}
|
||||
err = sc.Control(func(fd uintptr) {
|
||||
if ifceName != "" {
|
||||
if err := bindDevice(network, addr, fd, ifceName); err != nil {
|
||||
log.Warnf("bind device: %v", err)
|
||||
}
|
||||
}
|
||||
// NOTE: bindDevice is intentionally skipped for empty-addr UDP
|
||||
// (relay/listener sockets). The ListenUDP laddr binding above
|
||||
// is sufficient to pin the source IP. SO_BINDTODEVICE would
|
||||
// force all outbound datagrams through the named interface,
|
||||
// which may conflict with the kernel routing table and cause
|
||||
// silent packet drops — breaking UDP associate (issue #287).
|
||||
if d.Mark != 0 {
|
||||
if err := setMark(fd, d.Mark); err != nil {
|
||||
log.Warnf("set mark: %v", err)
|
||||
@@ -144,10 +146,11 @@ func (d *Dialer) dialOnce(ctx context.Context, network, addr, ifceName string, i
|
||||
return nil, fmt.Errorf("dial: unsupported network %s", network)
|
||||
}
|
||||
netd := net.Dialer{
|
||||
Resolver: &net.Resolver{PreferGo: true},
|
||||
LocalAddr: ifAddr,
|
||||
Control: func(network, address string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) {
|
||||
if ifceName != "" {
|
||||
if ifceName != "" && bindToDevice {
|
||||
if err := bindDevice(network, address, fd, ifceName); err != nil {
|
||||
log.Warnf("%s/%s bind device: %v", address, network, err)
|
||||
}
|
||||
|
||||
@@ -35,3 +35,8 @@ func bindDevice(network, address string, fd uintptr, ifceName string) error {
|
||||
func setMark(fd uintptr, mark int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
|
||||
func switchNetns(name string) (restore func(), err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -14,3 +14,8 @@ func setMark(fd uintptr, mark int) error {
|
||||
}
|
||||
return unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_USER_COOKIE, mark)
|
||||
}
|
||||
|
||||
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
|
||||
func switchNetns(name string) (restore func(), err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -11,14 +11,6 @@ func bindDevice(network, address string, fd uintptr, ifceName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip SO_BINDTODEVICE for loopback interface — it prevents
|
||||
// outbound traffic from reaching non-local destinations.
|
||||
// Source IP binding via LocalAddr is sufficient for policy routing
|
||||
// when using IP aliases on loopback (e.g. ip addr add 10.1.1.1/32 dev lo).
|
||||
if ifce, err := net.InterfaceByName(ifceName); err == nil && ifce.Flags&net.FlagLoopback != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(address)
|
||||
if ip := net.ParseIP(host); ip != nil && !ip.IsGlobalUnicast() {
|
||||
return nil
|
||||
|
||||
@@ -14,3 +14,8 @@ func setMark(fd uintptr, mark int) error {
|
||||
}
|
||||
return unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RTABLE, mark)
|
||||
}
|
||||
|
||||
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
|
||||
func switchNetns(name string) (restore func(), err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -9,3 +9,8 @@ func bindDevice(network, address string, fd uintptr, ifceName string) error {
|
||||
func setMark(fd uintptr, mark int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
|
||||
func switchNetns(name string) (restore func(), err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ func setMark(fd uintptr, mark int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// switchNetns is a no-op stub — network namespace switching is a Linux-only feature.
|
||||
func switchNetns(name string) (restore func(), err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func bindSocketToInterface4(handle windows.Handle, index uint32) error {
|
||||
// For IPv4, this parameter must be an interface index in network byte order.
|
||||
// Ref: https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options
|
||||
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
|
||||
// Resolve resolves addr to a concrete IP address. If hosts is non-nil, it is
|
||||
// consulted first. If r is non-nil and no host mapping matched, the resolver
|
||||
// is used. If neither is available, addr is returned unchanged.
|
||||
// is used. If neither is available, the address is returned unchanged so that
|
||||
// the original hostname is preserved for downstream connectors (e.g., SOCKS5
|
||||
// ATYP=domain). DNS resolution is deferred to the final TCP dialer, which uses
|
||||
// a pure-Go resolver to avoid cgo thread exhaustion.
|
||||
func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hosts hosts.HostMapper, log logger.Logger) (string, error) {
|
||||
if addr == "" {
|
||||
return addr, nil
|
||||
@@ -51,5 +54,10 @@ func Resolve(ctx context.Context, network, addr string, r resolver.Resolver, hos
|
||||
}
|
||||
return net.JoinHostPort(ips[0].String(), port), nil
|
||||
}
|
||||
|
||||
// Return the address unchanged so that the original hostname is
|
||||
// preserved through the proxy chain. DNS resolution happens in the
|
||||
// final TCP dialer (dialer.go) which uses net.Resolver{PreferGo: true}
|
||||
// to avoid cgo-based lookups that block OS threads.
|
||||
return addr, nil
|
||||
}
|
||||
@@ -115,8 +115,19 @@ func TestResolve_ResolverEmptyResult(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolve_NoResolverNoHosts(t *testing.T) {
|
||||
// When no resolver and no host mapper are configured, the address is
|
||||
// returned unchanged so that the original hostname is preserved
|
||||
// through the proxy chain (e.g., SOCKS5 ATYP=domain).
|
||||
// DNS resolution is deferred to the final TCP dialer.
|
||||
ctx := context.Background()
|
||||
addr, err := Resolve(ctx, "tcp", "example.com:8080", nil, nil, nil)
|
||||
addr, err := Resolve(ctx, "tcp", "1.2.3.4:8080", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if addr != "1.2.3.4:8080" {
|
||||
t.Errorf("expected 1.2.3.4:8080, got %s", addr)
|
||||
}
|
||||
addr, err = Resolve(ctx, "tcp", "example.com:8080", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -81,7 +81,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
defer func() { cc.Close() }()
|
||||
|
||||
upstreamHost := normalizeHost(ro.HTTP.Host, "80")
|
||||
|
||||
ho.log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
|
||||
log = ho.log
|
||||
@@ -112,6 +114,41 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleO
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
// When DNS override directs multiple domains to the same proxy IP,
|
||||
// the browser may reuse a keep-alive connection for a different host.
|
||||
// Re-dial to ensure requests reach the correct upstream.
|
||||
if reqHost := normalizeHost(req.Host, "80"); reqHost != "" && reqHost != upstreamHost {
|
||||
cc.Close()
|
||||
newNode, res, resolveErr := resolveHTTPNode(ctx, reqHost, req, &ho)
|
||||
if resolveErr != nil {
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return resolveErr
|
||||
}
|
||||
dial := ho.dial
|
||||
if dial == nil {
|
||||
dial = (&net.Dialer{}).DialContext
|
||||
}
|
||||
newCC, dialErr := dial(ctx, "tcp", newNode.Addr)
|
||||
if dialErr != nil {
|
||||
return dialErr
|
||||
}
|
||||
newCC = tlsWrapConn(newCC, newNode.Options().TLS)
|
||||
upstreamHost = reqHost
|
||||
node = newNode
|
||||
cc = newCC
|
||||
ro.Host = reqHost
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
log = log.WithFields(map[string]any{
|
||||
"host": reqHost,
|
||||
"node": node.Name,
|
||||
"dst": node.Addr,
|
||||
"src": cc.LocalAddr().String(),
|
||||
})
|
||||
ho.log = log
|
||||
}
|
||||
|
||||
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, node, req, &pStats, &ho); err != nil || shouldClose {
|
||||
return err
|
||||
}
|
||||
@@ -319,6 +356,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
if re.Pattern.MatchString(req.URL.Path) {
|
||||
if s := re.Pattern.ReplaceAllString(req.URL.Path, re.Replacement); s != "" {
|
||||
req.URL.Path = s
|
||||
ro.HTTP.URI = req.URL.RequestURI()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -331,7 +369,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
|
||||
// Rewrite request body before wrapping for recording,
|
||||
// so the recorder sees the rewritten content.
|
||||
if err = rewriteReqBody(req, reqBodyRewrites...); err != nil {
|
||||
if err = rewriteReqBody(ctx, req, reqBodyRewrites...); err != nil {
|
||||
log.Errorf("rewrite request body: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -408,7 +446,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
resp.Header.Set("Connection", "close")
|
||||
}
|
||||
|
||||
if err = rewriteRespBody(resp, respBodyRewrites...); err != nil {
|
||||
if err = rewriteRespBody(ctx, resp, respBodyRewrites...); err != nil {
|
||||
log.Errorf("rewrite body: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -434,3 +472,4 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -41,38 +41,30 @@ func (h *Sniffer) handleUpgradeResponse(ctx context.Context, rw, cc io.ReadWrite
|
||||
return xnet.Pipe(ctx, rw, cc)
|
||||
}
|
||||
|
||||
func rewriteRespBody(resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if resp == nil || len(rewrites) == 0 || resp.ContentLength <= 0 {
|
||||
func rewriteRespBody(ctx context.Context, resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
|
||||
return nil
|
||||
uri := ""
|
||||
if resp.Request != nil {
|
||||
uri = resp.Request.RequestURI
|
||||
}
|
||||
|
||||
body, err := drainBody(resp.Body)
|
||||
if err != nil || body == nil {
|
||||
rb, err := newRewriteBody(ctx, resp.Body, rewrites,
|
||||
resp.Header.Get("Content-Type"),
|
||||
resp.Header.Get("Content-Encoding"),
|
||||
resp.ContentLength, "response", uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentType, _, _ := strings.Cut(resp.Header.Get("Content-Type"), ";")
|
||||
for _, rewrite := range rewrites {
|
||||
rewriteType := rewrite.Type
|
||||
if rewriteType == "" {
|
||||
rewriteType = "text/html"
|
||||
}
|
||||
if rewriteType != "*" && !strings.Contains(rewriteType, contentType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if rewrite.Pattern != nil {
|
||||
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
|
||||
}
|
||||
if rb == nil {
|
||||
return nil
|
||||
}
|
||||
resp.Body = rb
|
||||
if !rb.streaming && rb.contentLength >= 0 {
|
||||
resp.ContentLength = rb.contentLength
|
||||
resp.TransferEncoding = nil
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
}
|
||||
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,38 +82,26 @@ func drainBody(b io.ReadCloser) (body []byte, err error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func rewriteReqBody(req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if req == nil || len(rewrites) == 0 || req.Body == nil || req.ContentLength <= 0 {
|
||||
func rewriteReqBody(ctx context.Context, req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if encoding := req.Header.Get("Content-Encoding"); encoding != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := drainBody(req.Body)
|
||||
if err != nil || body == nil {
|
||||
rb, err := newRewriteBody(ctx, req.Body, rewrites,
|
||||
req.Header.Get("Content-Type"),
|
||||
req.Header.Get("Content-Encoding"),
|
||||
req.ContentLength, "request", req.RequestURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentType, _, _ := strings.Cut(req.Header.Get("Content-Type"), ";")
|
||||
for _, rewrite := range rewrites {
|
||||
rewriteType := rewrite.Type
|
||||
if rewriteType == "" {
|
||||
rewriteType = "text/html"
|
||||
}
|
||||
if rewriteType != "*" && !strings.Contains(rewriteType, contentType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if rewrite.Pattern != nil {
|
||||
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
|
||||
}
|
||||
if rb == nil {
|
||||
return nil
|
||||
}
|
||||
req.Body = rb
|
||||
if !rb.streaming && rb.contentLength >= 0 {
|
||||
req.ContentLength = rb.contentLength
|
||||
req.TransferEncoding = nil
|
||||
req.Header.Del("Transfer-Encoding")
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// scanSSEEvents is a bufio.SplitFunc that splits SSE events on \n\n (or \r\n\r\n).
|
||||
// The delimiter is consumed but not included in the token.
|
||||
func scanSSEEvents(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
for i := 0; i < len(data)-1; i++ {
|
||||
if data[i] == '\n' && data[i+1] == '\n' {
|
||||
return i + 2, data[:i], nil
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(data)-3; i++ {
|
||||
if data[i] == '\r' && data[i+1] == '\n' && data[i+2] == '\r' && data[i+3] == '\n' {
|
||||
return i + 4, data[:i], nil
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(data)-1; i++ {
|
||||
if data[i] == '\r' && data[i+1] == '\r' {
|
||||
return i + 2, data[:i], nil
|
||||
}
|
||||
}
|
||||
if atEOF && len(data) > 0 {
|
||||
return len(data), data, bufio.ErrFinalToken
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
// rewriteBody wraps an io.ReadCloser and applies the rewrite chain.
|
||||
//
|
||||
// Three processing modes:
|
||||
//
|
||||
// 1. Non-streaming (plain): eagerly buffer the entire body, rewrite once,
|
||||
// expose the final ContentLength.
|
||||
//
|
||||
// 2. Non-streaming (compressed): decompress, rewrite, recompress, expose
|
||||
// the final ContentLength.
|
||||
//
|
||||
// 3. SSE streaming: split on \n\n via bufio.Scanner, rewrite each event
|
||||
// independently. The first event carries sse_phase:"start", subsequent
|
||||
// events carry sse_phase:"event", and when the scanner hits EOF an
|
||||
// empty-body apply(nil, sse_phase:"end") is emitted so Rewriter plugins
|
||||
// can append a trailing event (e.g. message_stop for LLM API conversion).
|
||||
type rewriteBody struct {
|
||||
ctx context.Context
|
||||
src io.ReadCloser
|
||||
rewrites []chain.HTTPBodyRewriteSettings
|
||||
contentType string
|
||||
streaming bool
|
||||
|
||||
scanner *bufio.Scanner
|
||||
buf bytes.Buffer
|
||||
contentLength int64 // >= 0 for non-streaming bodies; -1 for streaming
|
||||
|
||||
// SSE stream lifecycle state.
|
||||
eventIndex int // incremented per SSE event for the "event" phase
|
||||
ended bool // true after the stream-end phase has been emitted
|
||||
scannerErr error // non-nil when scanner terminated with an error
|
||||
|
||||
sid string // session ID, cached from context at construction time
|
||||
|
||||
// Protocol detection metadata for Rewriter plugins.
|
||||
direction string // "request" or "response"
|
||||
uri string // HTTP request path (e.g. "/v1/messages")
|
||||
}
|
||||
|
||||
// multiReadCloser combines an io.Reader with an io.Closer, used when
|
||||
// replaying buffered prefix data followed by the original source.
|
||||
type multiReadCloser struct {
|
||||
r io.Reader
|
||||
c io.Closer
|
||||
}
|
||||
|
||||
func (m *multiReadCloser) Read(p []byte) (int, error) { return m.r.Read(p) }
|
||||
func (m *multiReadCloser) Close() error { return m.c.Close() }
|
||||
|
||||
// defaultMaxChunkSize is the default MaxChunkSize for chunked non-streaming
|
||||
// bodies when no rewrite rule explicitly sets one. 1MB covers typical LLM
|
||||
// responses without risking OOM on unbounded bodies.
|
||||
const defaultMaxChunkSize = 1 << 20 // 1MB
|
||||
|
||||
// newRewriteBody returns an io.ReadCloser that rewrites data read from src
|
||||
// through the given rewrite chain. Returns nil, nil when no rewrites are
|
||||
// configured, or for chunked/unknown-length non-streaming bodies.
|
||||
// For compressed bodies (Content-Encoding set), the body is decompressed,
|
||||
// rewritten, and recompressed transparently. Returns nil, err when the
|
||||
// upstream body cannot be read or the rewrite chain fails.
|
||||
func newRewriteBody(ctx context.Context, src io.ReadCloser, rewrites []chain.HTTPBodyRewriteSettings, contentType, contentEncoding string, contentLength int64, direction, uri string) (*rewriteBody, error) {
|
||||
if len(rewrites) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ct, _, _ := strings.Cut(contentType, ";")
|
||||
ct = strings.TrimSpace(ct)
|
||||
streaming := strings.HasPrefix(ct, "text/event-stream")
|
||||
|
||||
// Bounded buffering for chunked/unknown-length non-streaming bodies.
|
||||
if !streaming && contentLength < 0 {
|
||||
var maxChunkSize int
|
||||
for _, rw := range rewrites {
|
||||
if rw.MaxChunkSize > maxChunkSize {
|
||||
maxChunkSize = rw.MaxChunkSize
|
||||
}
|
||||
}
|
||||
if maxChunkSize == 0 {
|
||||
maxChunkSize = defaultMaxChunkSize
|
||||
}
|
||||
limited := io.LimitReader(src, int64(maxChunkSize+1))
|
||||
buf, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(buf) > maxChunkSize {
|
||||
// Overflow: passthrough without rewriting.
|
||||
// ContentLength stays -1 so Go re-chunk-encodes on Write.
|
||||
rb := &rewriteBody{
|
||||
ctx: ctx,
|
||||
src: &multiReadCloser{r: io.MultiReader(bytes.NewReader(buf), src), c: src},
|
||||
contentType: ct,
|
||||
streaming: false,
|
||||
contentLength: -1,
|
||||
}
|
||||
rb.sid = xctx.SidFromContext(ctx).String()
|
||||
rb.direction = direction
|
||||
rb.uri = uri
|
||||
return rb, nil
|
||||
}
|
||||
// Fits: rewrite and set ContentLength.
|
||||
rb := &rewriteBody{
|
||||
ctx: ctx,
|
||||
rewrites: rewrites,
|
||||
contentType: ct,
|
||||
streaming: false,
|
||||
contentLength: -1,
|
||||
}
|
||||
rb.sid = xctx.SidFromContext(ctx).String()
|
||||
rb.direction = direction
|
||||
rb.uri = uri
|
||||
if contentEncoding != "" {
|
||||
// Compressed: decompress → rewrite → recompress.
|
||||
var hasTypeMatch bool
|
||||
for _, rw := range rewrites {
|
||||
rt := rw.Type
|
||||
if rt == "" {
|
||||
if rw.Rewriter != nil {
|
||||
rt = "*"
|
||||
} else {
|
||||
rt = "text/html"
|
||||
}
|
||||
}
|
||||
if rt == "*" || strings.Contains(rt, ct) {
|
||||
hasTypeMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTypeMatch {
|
||||
rb.src = io.NopCloser(bytes.NewReader(buf))
|
||||
rb.contentLength = int64(len(buf))
|
||||
return rb, nil
|
||||
}
|
||||
decoded, err := decompressBody(buf, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewritten, err := rb.apply(decoded, rb.baseMetadataOpt())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recompressed, err := compressBody(rewritten, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(recompressed))
|
||||
rb.contentLength = int64(len(recompressed))
|
||||
} else {
|
||||
rewritten, err := rb.apply(buf, rb.baseMetadataOpt())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(rewritten))
|
||||
rb.contentLength = int64(len(rewritten))
|
||||
}
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
rb := &rewriteBody{
|
||||
ctx: ctx,
|
||||
src: src,
|
||||
rewrites: rewrites,
|
||||
contentType: ct,
|
||||
streaming: streaming,
|
||||
contentLength: -1,
|
||||
}
|
||||
rb.sid = xctx.SidFromContext(ctx).String()
|
||||
rb.direction = direction
|
||||
rb.uri = uri
|
||||
mdOpts := []rewriter.RewriteOption{rb.baseMetadataOpt()}
|
||||
|
||||
if contentEncoding != "" {
|
||||
if streaming {
|
||||
return nil, nil
|
||||
}
|
||||
// Early exit if no rewrite rule matches this content type
|
||||
// (avoids unnecessary decompress-recompress cycle).
|
||||
var hasTypeMatch bool
|
||||
for _, rw := range rewrites {
|
||||
rt := rw.Type
|
||||
if rt == "" {
|
||||
if rw.Rewriter != nil {
|
||||
rt = "*"
|
||||
} else {
|
||||
rt = "text/html"
|
||||
}
|
||||
}
|
||||
if rt == "*" || strings.Contains(rt, ct) {
|
||||
hasTypeMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTypeMatch {
|
||||
return nil, nil
|
||||
}
|
||||
// Compressed non-streaming: decompress, rewrite, recompress.
|
||||
body, err := drainBody(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := decompressBody(body, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewritten, err := rb.apply(decoded, mdOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recompressed, err := compressBody(rewritten, contentEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(recompressed))
|
||||
rb.contentLength = int64(len(recompressed))
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
if !streaming {
|
||||
// Non-streaming: eagerly read and rewrite so the caller knows the
|
||||
// final content length.
|
||||
body, err := drainBody(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewritten, err := rb.apply(body, mdOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb.src = io.NopCloser(bytes.NewReader(rewritten))
|
||||
rb.contentLength = int64(len(rewritten))
|
||||
} else {
|
||||
// SSE streaming: scan events split on \n\n and rewrite each one
|
||||
// independently on Read. Lifecycle phases (first event = start,
|
||||
// subsequent = event, EOF = end) are assigned in Read().
|
||||
scanner := bufio.NewScanner(src)
|
||||
scanner.Split(scanSSEEvents)
|
||||
scanner.Buffer(make([]byte, 64*1024), 64*1024*1024)
|
||||
rb.scanner = scanner
|
||||
}
|
||||
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
func (b *rewriteBody) Read(p []byte) (n int, err error) {
|
||||
for {
|
||||
if !b.streaming {
|
||||
return b.src.Read(p)
|
||||
}
|
||||
|
||||
// SSE streaming: flush buffered rewritten event first.
|
||||
if b.buf.Len() > 0 {
|
||||
return b.buf.Read(p)
|
||||
}
|
||||
|
||||
// Scan the next SSE event.
|
||||
if !b.scanner.Scan() {
|
||||
err = b.scanner.Err()
|
||||
if err == nil {
|
||||
if b.ended {
|
||||
if b.scannerErr != nil {
|
||||
return 0, b.scannerErr
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
// End of upstream SSE events — emit stream end phase.
|
||||
// This empty-body call lets Rewriter plugins append a
|
||||
// trailing event (e.g. message_stop for LLM conversion).
|
||||
b.ended = true
|
||||
md := map[string]any{
|
||||
"sid": b.sid,
|
||||
"direction": b.direction,
|
||||
"uri": b.uri,
|
||||
"sse_phase": "end",
|
||||
"event_index": b.eventIndex,
|
||||
}
|
||||
rewritten, endErr := b.apply(nil, rewriter.MetadataRewriteOption(md))
|
||||
if endErr != nil {
|
||||
return 0, endErr
|
||||
}
|
||||
if len(rewritten) > 0 {
|
||||
b.buf.Write(rewritten)
|
||||
writeSSEStreamDelim(&b.buf, rewritten)
|
||||
}
|
||||
return b.buf.Read(p)
|
||||
}
|
||||
// Scanner error (e.g. upstream connection reset): emit the end
|
||||
// phase so Rewriter plugins can send a terminal event
|
||||
// (response.failed / message_stop), then return the error
|
||||
// after the buffer drains.
|
||||
if !b.ended {
|
||||
b.ended = true
|
||||
b.scannerErr = err
|
||||
md := map[string]any{
|
||||
"sid": b.sid,
|
||||
"direction": b.direction,
|
||||
"uri": b.uri,
|
||||
"sse_phase": "end",
|
||||
"event_index": b.eventIndex,
|
||||
"stream_error": err.Error(),
|
||||
}
|
||||
rewritten, _ := b.apply(nil, rewriter.MetadataRewriteOption(md))
|
||||
if len(rewritten) > 0 {
|
||||
b.buf.Write(rewritten)
|
||||
writeSSEStreamDelim(&b.buf, rewritten)
|
||||
return b.buf.Read(p)
|
||||
}
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Copy token (scanner.Bytes() is only valid until next Scan()).
|
||||
event := make([]byte, len(b.scanner.Bytes()))
|
||||
copy(event, b.scanner.Bytes())
|
||||
|
||||
// SSE lifecycle phases — assigned to real events so the Rewriter
|
||||
// can distinguish first (start), middle (event), and last event
|
||||
// without synthetic signals. On EOF, apply(nil, end) produces any
|
||||
// trailing event (e.g. message_stop for LLM API conversion).
|
||||
phase := "event"
|
||||
if b.eventIndex == 0 {
|
||||
phase = "start"
|
||||
}
|
||||
md := map[string]any{
|
||||
"sid": b.sid,
|
||||
"direction": b.direction,
|
||||
"uri": b.uri,
|
||||
"sse_phase": phase,
|
||||
"event_index": b.eventIndex,
|
||||
}
|
||||
b.eventIndex++
|
||||
|
||||
rewritten, err := b.apply(event, rewriter.MetadataRewriteOption(md))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rewritten) > 0 {
|
||||
b.buf.Write(rewritten)
|
||||
writeSSEStreamDelim(&b.buf, rewritten)
|
||||
}
|
||||
// Loop to flush the buffer or scan the next event.
|
||||
}
|
||||
}
|
||||
|
||||
func (b *rewriteBody) Close() error {
|
||||
return b.src.Close()
|
||||
}
|
||||
|
||||
// baseMetadata returns rewriter metadata with all available fields.
|
||||
func (b *rewriteBody) baseMetadata() map[string]any {
|
||||
md := map[string]any{}
|
||||
if b.sid != "" {
|
||||
md["sid"] = b.sid
|
||||
}
|
||||
if b.direction != "" {
|
||||
md["direction"] = b.direction
|
||||
}
|
||||
if b.uri != "" {
|
||||
md["uri"] = b.uri
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
// baseMetadataOpt returns a RewriteOption wrapping baseMetadata.
|
||||
func (b *rewriteBody) baseMetadataOpt() rewriter.RewriteOption {
|
||||
return rewriter.MetadataRewriteOption(b.baseMetadata())
|
||||
}
|
||||
|
||||
// ponytail: guard against plugin already having added \n\n delimiter
|
||||
func writeSSEStreamDelim(buf *bytes.Buffer, data []byte) {
|
||||
if len(data) < 2 || data[len(data)-1] != '\n' || data[len(data)-2] != '\n' {
|
||||
buf.Write([]byte("\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func decompressBody(data []byte, encoding string) ([]byte, error) {
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
r, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
case "deflate":
|
||||
r, err := zlib.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
case "br":
|
||||
return io.ReadAll(brotli.NewReader(bytes.NewReader(data)))
|
||||
case "zstd":
|
||||
r, err := zstd.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
default:
|
||||
// identity or unknown — pass through.
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
func compressBody(data []byte, encoding string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
w := gzip.NewWriter(&buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "deflate":
|
||||
w := zlib.NewWriter(&buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "br":
|
||||
w := brotli.NewWriter(&buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "zstd":
|
||||
w, err := zstd.NewWriter(&buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return data, nil
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// apply runs the rewrite chain on body bytes.
|
||||
// Returns the (possibly unchanged) body and any error from a plugin Rewriter.
|
||||
func (b *rewriteBody) apply(body []byte, opts ...rewriter.RewriteOption) ([]byte, error) {
|
||||
if len(body) == 0 && len(opts) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
for _, rw := range b.rewrites {
|
||||
rewriteType := rw.Type
|
||||
if rewriteType == "" {
|
||||
if rw.Rewriter != nil {
|
||||
rewriteType = "*"
|
||||
} else {
|
||||
rewriteType = "text/html"
|
||||
}
|
||||
}
|
||||
if rewriteType != "*" && !strings.Contains(rewriteType, b.contentType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if rw.Rewriter != nil {
|
||||
if body == nil || rw.Pattern == nil || rw.Pattern.Match(body) {
|
||||
rewritten, err := rw.Rewriter.Rewrite(b.ctx, body, opts...)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
body = rewritten
|
||||
}
|
||||
} else if rw.Pattern != nil {
|
||||
body = rw.Pattern.ReplaceAll(body, rw.Replacement)
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package forwarder
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
@@ -13,10 +15,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
@@ -43,6 +48,32 @@ func (m *mockBypass) Contains(_ context.Context, _, _ string, _ ...bypass.Option
|
||||
|
||||
func (m *mockBypass) IsWhitelist() bool { return m.whitelist }
|
||||
|
||||
// compressTestData compresses data with the given encoding for test purposes.
|
||||
func compressTestData(data []byte, encoding string) []byte {
|
||||
var buf bytes.Buffer
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
w := gzip.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
case "deflate":
|
||||
w := zlib.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
case "br":
|
||||
w := brotli.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
case "zstd":
|
||||
w, _ := zstd.NewWriter(&buf)
|
||||
w.Write(data)
|
||||
w.Close()
|
||||
default:
|
||||
return data
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pure Function Tests
|
||||
// =============================================================================
|
||||
@@ -70,7 +101,7 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
wantCL: 11,
|
||||
},
|
||||
{
|
||||
name: "nil body skipped",
|
||||
name: "nil body",
|
||||
req: &http.Request{
|
||||
Body: nil,
|
||||
ContentLength: 11,
|
||||
@@ -79,10 +110,10 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
},
|
||||
wantBody: "",
|
||||
wantCL: 11,
|
||||
wantCL: 0,
|
||||
},
|
||||
{
|
||||
name: "zero content length skipped",
|
||||
name: "zero content length",
|
||||
req: &http.Request{
|
||||
Body: io.NopCloser(bytes.NewReader(hello)),
|
||||
ContentLength: 0,
|
||||
@@ -90,21 +121,8 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
rewrites: []chain.HTTPBodyRewriteSettings{
|
||||
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
},
|
||||
wantBody: "hello world",
|
||||
wantCL: 0,
|
||||
},
|
||||
{
|
||||
name: "content-encoding skips rewrite",
|
||||
req: &http.Request{
|
||||
Body: io.NopCloser(bytes.NewReader(hello)),
|
||||
ContentLength: int64(len(hello)),
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}},
|
||||
},
|
||||
rewrites: []chain.HTTPBodyRewriteSettings{
|
||||
{Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
},
|
||||
wantBody: "hello world",
|
||||
wantCL: 11,
|
||||
wantBody: "hi world",
|
||||
wantCL: 8,
|
||||
},
|
||||
{
|
||||
name: "content type does not match default text/html",
|
||||
@@ -164,7 +182,7 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req != nil {
|
||||
_ = rewriteReqBody(tt.req, tt.rewrites...)
|
||||
_ = rewriteReqBody(context.Background(), tt.req, tt.rewrites...)
|
||||
if tt.req.Body != nil {
|
||||
body, _ := io.ReadAll(tt.req.Body)
|
||||
tt.req.Body.Close()
|
||||
@@ -178,11 +196,40 @@ func TestRewriteReqBody(t *testing.T) {
|
||||
t.Errorf("ContentLength = %d, want %d", tt.req.ContentLength, tt.wantCL)
|
||||
}
|
||||
} else {
|
||||
_ = rewriteReqBody(nil) // should not panic
|
||||
_ = rewriteReqBody(context.Background(), nil) // should not panic
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestRewriteReqBody_Gzip(t *testing.T) {
|
||||
t.Run("gzip content-encoding rewrite", func(t *testing.T) {
|
||||
hello := []byte("hello world")
|
||||
gzipHello := compressTestData(hello, "gzip")
|
||||
req := &http.Request{
|
||||
Body: io.NopCloser(bytes.NewReader(gzipHello)),
|
||||
ContentLength: int64(len(gzipHello)),
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}, "Content-Type": {"text/html"}},
|
||||
}
|
||||
_ = rewriteReqBody(context.Background(), req, []chain.HTTPBodyRewriteSettings{
|
||||
{Type: "*", Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")},
|
||||
}...)
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
r, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid gzip output: %v", err)
|
||||
}
|
||||
decoded, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
if string(decoded) != "hi world" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
|
||||
}
|
||||
if req.ContentLength != int64(len(body)) {
|
||||
t.Errorf("ContentLength = %d, want %d", req.ContentLength, len(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func TestClampBodySize(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -344,8 +391,8 @@ func TestDrainBody(t *testing.T) {
|
||||
|
||||
func TestRewriteRespBody(t *testing.T) {
|
||||
t.Run("nil response", func(t *testing.T) {
|
||||
if err := rewriteRespBody(nil); err != nil {
|
||||
t.Errorf("rewriteRespBody(nil) = %v, want nil", err)
|
||||
if err := rewriteRespBody(context.Background(), nil); err != nil {
|
||||
t.Errorf("rewriteRespBody(context.Background(), nil) = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -354,7 +401,7 @@ func TestRewriteRespBody(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
_ = rewriteRespBody(resp)
|
||||
_ = rewriteRespBody(context.Background(), resp)
|
||||
// Body unchanged
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "original" {
|
||||
@@ -365,34 +412,43 @@ func TestRewriteRespBody(t *testing.T) {
|
||||
t.Run("zero content length", func(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
ContentLength: 0,
|
||||
Header: http.Header{"Content-Type": {"text/plain"}},
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("."),
|
||||
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "*",
|
||||
Replacement: []byte("replaced"),
|
||||
})
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
// Content-Length was 0 (unknown), so no rewrite applied
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
if string(got) != "replaced" {
|
||||
t.Errorf("body = %q, want %q", string(got), "replaced")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with content encoding", func(t *testing.T) {
|
||||
original := []byte("original")
|
||||
compressed := compressTestData(original, "gzip")
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}},
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
ContentLength: int64(len(compressed)),
|
||||
Body: io.NopCloser(bytes.NewReader(compressed)),
|
||||
}
|
||||
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Type: "*",
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Replacement: []byte("replaced"),
|
||||
})
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
// Content-Encoding present, rewrite skipped
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
// Output is gzip-compressed, decompress to verify.
|
||||
r, err := gzip.NewReader(bytes.NewReader(got))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid gzip output: %v", err)
|
||||
}
|
||||
decoded, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
if string(decoded) != "replaced" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "replaced")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -413,7 +469,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced"))
|
||||
_ = rewriteRespBody(context.Background(), resp, makeRewrite("text/html", "replaced"))
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "replaced" {
|
||||
t.Errorf("body = %q, want %q", string(got), "replaced")
|
||||
@@ -426,7 +482,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello")),
|
||||
}
|
||||
_ = rewriteRespBody(resp, makeRewrite("*", "world"))
|
||||
_ = rewriteRespBody(context.Background(), resp, makeRewrite("*", "world"))
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "world" {
|
||||
t.Errorf("body = %q, want %q", string(got), "world")
|
||||
@@ -439,7 +495,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced"))
|
||||
_ = rewriteRespBody(context.Background(), resp, makeRewrite("text/html", "replaced"))
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
@@ -454,7 +510,7 @@ func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
|
||||
}
|
||||
// Empty type defaults to "text/html", and response has no Content-Type -> "" containing "text/html"? No.
|
||||
// strings.Contains("text/html", "") is always true, so rewrite should happen.
|
||||
_ = rewriteRespBody(resp, makeRewrite("", "replaced"))
|
||||
_ = rewriteRespBody(context.Background(), resp, makeRewrite("", "replaced"))
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "replaced" {
|
||||
t.Errorf("body = %q, want %q (should be rewritten)", string(got), "replaced")
|
||||
@@ -1146,7 +1202,7 @@ func TestRewriteRespBody_PatternReplacement(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("<title>Old Title</title>")),
|
||||
}
|
||||
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("Old"),
|
||||
Type: "text/html",
|
||||
Replacement: []byte("New"),
|
||||
@@ -1169,7 +1225,7 @@ func TestRewriteRespBody_MultipleRewrites(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello")),
|
||||
}
|
||||
err := rewriteRespBody(resp,
|
||||
err := rewriteRespBody(context.Background(), resp,
|
||||
chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("h.*o"),
|
||||
Type: "text/html",
|
||||
@@ -1196,7 +1252,7 @@ func TestRewriteRespBody_NilPattern(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: nil,
|
||||
Type: "*",
|
||||
Replacement: []byte("replaced"),
|
||||
@@ -1215,7 +1271,7 @@ func TestRewriteRespBody_EmptyContentTypeUsesDefault(t *testing.T) {
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "",
|
||||
Replacement: []byte("rewritten"),
|
||||
@@ -1229,30 +1285,320 @@ func TestRewriteRespBody_EmptyContentTypeUsesDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_NegativeContentLength(t *testing.T) {
|
||||
func TestRewriteRespBody_DefaultMaxChunkSize(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("original"),
|
||||
Type: "*",
|
||||
Replacement: []byte("replaced"),
|
||||
Replacement: []byte("rewritten"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "rewritten" {
|
||||
t.Errorf("body = %q, want %q (default 1MB maxChunkSize should buffer and rewrite)", string(got), "rewritten")
|
||||
}
|
||||
if resp.ContentLength != int64(len("rewritten")) {
|
||||
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_MaxChunkSize_Fits(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "*",
|
||||
Replacement: []byte("rewritten"),
|
||||
MaxChunkSize: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "rewritten" {
|
||||
t.Errorf("body = %q, want %q", string(got), "rewritten")
|
||||
}
|
||||
if resp.ContentLength != int64(len("rewritten")) {
|
||||
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
|
||||
}
|
||||
if resp.TransferEncoding != nil {
|
||||
t.Errorf("TransferEncoding = %v, want nil", resp.TransferEncoding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_MaxChunkSize_Overflow(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("original")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(".*"),
|
||||
Type: "*",
|
||||
Replacement: []byte("rewritten"),
|
||||
MaxChunkSize: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "original" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
|
||||
t.Errorf("body = %q, want %q (unchanged, overflow)", string(got), "original")
|
||||
}
|
||||
if resp.ContentLength != -1 {
|
||||
t.Errorf("ContentLength = %d, want -1", resp.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_MaxChunkSize_Compressed_Fits(t *testing.T) {
|
||||
original := []byte("hello world")
|
||||
compressed := compressTestData(original, "gzip")
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": {"gzip"}, "Content-Type": {"text/plain"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(bytes.NewReader(compressed)),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("hello"),
|
||||
Type: "*",
|
||||
Replacement: []byte("hi"),
|
||||
MaxChunkSize: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Body should be decompressed → rewritten → recompressed.
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
gr, err := gzip.NewReader(bytes.NewReader(got))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid gzip output: %v", err)
|
||||
}
|
||||
decoded, _ := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if string(decoded) != "hi world" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
|
||||
}
|
||||
if resp.ContentLength != int64(len(got)) {
|
||||
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len(got))
|
||||
}
|
||||
if resp.TransferEncoding != nil {
|
||||
t.Errorf("TransferEncoding = %v, want nil", resp.TransferEncoding)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// copyWebsocketFrame Additional Tests
|
||||
// Rewriter Plugin Tests
|
||||
// =============================================================================
|
||||
|
||||
// mockRewriter is a simple rewriter.Rewriter for testing.
|
||||
type mockRewriter struct {
|
||||
cb func(b []byte) []byte
|
||||
called bool
|
||||
}
|
||||
|
||||
func (m *mockRewriter) Rewrite(_ context.Context, b []byte, _ ...rewriter.RewriteOption) ([]byte, error) {
|
||||
m.called = true
|
||||
if m.cb != nil {
|
||||
return m.cb(b), nil
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_RewriterNilPattern(t *testing.T) {
|
||||
rw := &mockRewriter{}
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Type: "*",
|
||||
Rewriter: rw,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rw.called {
|
||||
t.Error("rewriter was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_RewriterMatchPass(t *testing.T) {
|
||||
rw := &mockRewriter{
|
||||
cb: func(b []byte) []byte {
|
||||
return []byte("rewritten")
|
||||
},
|
||||
}
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello world")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Type: "*",
|
||||
Pattern: regexp.MustCompile("hello"),
|
||||
Rewriter: rw,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rw.called {
|
||||
t.Fatal("rewriter was not called")
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "rewritten" {
|
||||
t.Errorf("body = %q, want %q", string(body), "rewritten")
|
||||
}
|
||||
if resp.ContentLength != int64(len("rewritten")) {
|
||||
t.Errorf("ContentLength = %d, want %d", resp.ContentLength, len("rewritten"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_RewriterMatchSkip(t *testing.T) {
|
||||
rw := &mockRewriter{}
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello world")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Type: "*",
|
||||
Pattern: regexp.MustCompile("xyz"),
|
||||
Rewriter: rw,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rw.called {
|
||||
t.Fatal("rewriter should not have been called: body did not match pattern")
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "hello world" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_RewriterContentTypeFilter(t *testing.T) {
|
||||
rw := &mockRewriter{
|
||||
cb: func(b []byte) []byte { return []byte("rewritten") },
|
||||
}
|
||||
|
||||
t.Run("type matches calls rewriter", func(t *testing.T) {
|
||||
rw.called = false
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader(`{"key":"value"}`)),
|
||||
}
|
||||
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Type: "application/json",
|
||||
Rewriter: rw,
|
||||
})
|
||||
if !rw.called {
|
||||
t.Error("rewriter should have been called")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type mismatch skips rewriter", func(t *testing.T) {
|
||||
rw.called = false
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/plain"}},
|
||||
ContentLength: 100,
|
||||
Body: io.NopCloser(strings.NewReader("hello")),
|
||||
}
|
||||
_ = rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Type: "application/json",
|
||||
Rewriter: rw,
|
||||
})
|
||||
if rw.called {
|
||||
t.Error("rewriter should not have been called: content type mismatch")
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "hello" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRewriteReqBody_Rewriter(t *testing.T) {
|
||||
rw := &mockRewriter{
|
||||
cb: func(b []byte) []byte {
|
||||
return []byte("rewritten")
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("nil pattern calls rewriter", func(t *testing.T) {
|
||||
rw.called = false
|
||||
req := &http.Request{
|
||||
Body: io.NopCloser(strings.NewReader("hello")),
|
||||
ContentLength: 5,
|
||||
Header: http.Header{"Content-Type": {"text/plain"}},
|
||||
}
|
||||
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
|
||||
Type: "*",
|
||||
Rewriter: rw,
|
||||
})
|
||||
if !rw.called {
|
||||
t.Fatal("rewriter was not called")
|
||||
}
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
if string(body) != "rewritten" {
|
||||
t.Errorf("body = %q, want %q", string(body), "rewritten")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("match passes through to rewriter", func(t *testing.T) {
|
||||
rw.called = false
|
||||
req := &http.Request{
|
||||
Body: io.NopCloser(strings.NewReader("hello world")),
|
||||
ContentLength: 11,
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
}
|
||||
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
|
||||
Type: "text/html",
|
||||
Pattern: regexp.MustCompile("hello"),
|
||||
Rewriter: rw,
|
||||
})
|
||||
if !rw.called {
|
||||
t.Fatal("rewriter was not called")
|
||||
}
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
if string(body) != "rewritten" {
|
||||
t.Errorf("body = %q, want %q", string(body), "rewritten")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-match skips rewriter", func(t *testing.T) {
|
||||
rw.called = false
|
||||
req := &http.Request{
|
||||
Body: io.NopCloser(strings.NewReader("hello world")),
|
||||
ContentLength: 11,
|
||||
Header: http.Header{"Content-Type": {"text/html"}},
|
||||
}
|
||||
_ = rewriteReqBody(context.Background(), req, chain.HTTPBodyRewriteSettings{
|
||||
Type: "text/html",
|
||||
Pattern: regexp.MustCompile("xyz"),
|
||||
Rewriter: rw,
|
||||
})
|
||||
if rw.called {
|
||||
t.Fatal("rewriter should not have been called: body did not match pattern")
|
||||
}
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
if string(body) != "hello world" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(body), "hello world")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopyWebsocketFrame_EmptyPayload(t *testing.T) {
|
||||
h := &Sniffer{
|
||||
Recorder: &noopRecorder{},
|
||||
@@ -1393,3 +1739,268 @@ func TestServeH2_InvalidPreface(t *testing.T) {
|
||||
t.Error("expected error from invalid h2 preface, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSE (Server-Sent Events) Tests
|
||||
// =============================================================================
|
||||
|
||||
|
||||
func TestRewriteRespBody_SSE_ContentType(t *testing.T) {
|
||||
// rewriteRespBody now rewrites SSE per-event via the universal wrapper.
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Type": {"text/event-stream"}},
|
||||
ContentLength: -1,
|
||||
Body: io.NopCloser(strings.NewReader("data: hello\n\n")),
|
||||
}
|
||||
err := rewriteRespBody(context.Background(), resp, chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile("data: hello"),
|
||||
Type: "*",
|
||||
Replacement: []byte("data: world"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "data: world\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRewriteBody(t *testing.T) {
|
||||
makeRewrite := func(rewriteType, match, replace string) chain.HTTPBodyRewriteSettings {
|
||||
return chain.HTTPBodyRewriteSettings{
|
||||
Pattern: regexp.MustCompile(match),
|
||||
Type: rewriteType,
|
||||
Replacement: []byte(replace),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("regex replacement streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: world\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple events streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: a\n\ndata: b\n\ndata: c\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "data: [ab]$", "data: x"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
expected := "data: x\n\ndata: x\n\ndata: c\n\n"
|
||||
if string(got) != expected {
|
||||
t.Errorf("body = %q, want %q", string(got), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple rewrite rules streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
makeRewrite("*", "world", "there"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: there\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: there\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content type filter streaming", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
// text/html type rule — won't match text/event-stream.
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
{Type: "text/html", Pattern: regexp.MustCompile("hello"), Replacement: []byte("world")},
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: hello\n\n" {
|
||||
t.Errorf("body = %q, want %q (unchanged)", string(got), "data: hello\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no rewrites returns nil", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, nil, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
t.Error("expected nil for no rewrites")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content encoding returns nil", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
}, "text/event-stream", "gzip", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
t.Error("expected nil for Content-Encoding")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plugin rewriter streaming", func(t *testing.T) {
|
||||
rw := &mockRewriter{
|
||||
cb: func(b []byte) []byte { return []byte("data: rewritten") },
|
||||
}
|
||||
src := io.NopCloser(strings.NewReader("data: original\n\n"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
{Type: "*", Rewriter: rw},
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: rewritten\n\ndata: rewritten\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: rewritten\n\ndata: rewritten\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incomplete final event flushed", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("data: hello"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "world"),
|
||||
}, "text/event-stream", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "data: world\n\n" {
|
||||
t.Errorf("body = %q, want %q", string(got), "data: world\n\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-streaming body rewritten", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("hello world"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "hi"),
|
||||
}, "text/plain", "", 11, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "hi world" {
|
||||
t.Errorf("body = %q, want %q", string(got), "hi world")
|
||||
}
|
||||
if body.contentLength != int64(len("hi world")) {
|
||||
t.Errorf("contentLength = %d, want %d", body.contentLength, len("hi world"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chunked non-streaming uses default maxChunkSize", func(t *testing.T) {
|
||||
src := io.NopCloser(strings.NewReader("hello world"))
|
||||
body, err := newRewriteBody(context.Background(), src, []chain.HTTPBodyRewriteSettings{
|
||||
makeRewrite("*", "hello", "hi"),
|
||||
}, "text/plain", "", -1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body == nil {
|
||||
t.Fatal("expected non-nil body (default 1MB maxChunkSize)")
|
||||
}
|
||||
got, _ := io.ReadAll(body)
|
||||
body.Close()
|
||||
if string(got) != "hi world" {
|
||||
t.Errorf("body = %q, want %q", string(got), "hi world")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRewriteRespBody_CompressedEncodings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
encoding string
|
||||
}{
|
||||
{"gzip", "gzip"},
|
||||
{"deflate", "deflate"},
|
||||
{"br", "br"},
|
||||
{"zstd", "zstd"},
|
||||
}
|
||||
|
||||
input := []byte("hello world")
|
||||
rewrites := []chain.HTTPBodyRewriteSettings{
|
||||
{
|
||||
Type: "*",
|
||||
Pattern: regexp.MustCompile("hello"),
|
||||
Replacement: []byte("hi"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compressed := compressTestData(input, tt.encoding)
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": {tt.encoding}, "Content-Type": {"text/plain"}},
|
||||
ContentLength: int64(len(compressed)),
|
||||
Body: io.NopCloser(bytes.NewReader(compressed)),
|
||||
}
|
||||
if err := rewriteRespBody(context.Background(), resp, rewrites...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
// Output is recompressed; decompress to verify rewrite.
|
||||
decoded, err := decompressBody(got, tt.encoding)
|
||||
if err != nil {
|
||||
t.Fatalf("decompress: %v", err)
|
||||
}
|
||||
if string(decoded) != "hi world" {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), "hi world")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, conn net.Conn, opts ...HandleOp
|
||||
if ho.log != nil {
|
||||
ho.log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
|
||||
}
|
||||
return nil
|
||||
return errors.New("tls: sni is empty, closing connection")
|
||||
}
|
||||
ro.Host = host
|
||||
|
||||
@@ -321,6 +321,7 @@ func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHel
|
||||
}),
|
||||
WithHTTPKeepalive(true),
|
||||
WithNode(ho.node),
|
||||
WithBypass(ho.bypass),
|
||||
WithRecorderObject(ro),
|
||||
WithLog(log),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package relay
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-gost/gosocks5"
|
||||
"github.com/go-gost/relay"
|
||||
@@ -54,6 +55,19 @@ func UDPTunServerConn(c net.Conn) net.PacketConn {
|
||||
}
|
||||
}
|
||||
|
||||
// domainAddr is a net.Addr that preserves a domain name without resolving it.
|
||||
// Returned by udpTunConn.ReadFrom when a relay UDP datagram carries
|
||||
// ATYP=DOMAINNAME, so the relay handler can resolve it through the configured
|
||||
// resolver instead of leaking the query through the system resolver.
|
||||
type domainAddr struct {
|
||||
network string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func (a *domainAddr) Network() string { return a.network }
|
||||
func (a *domainAddr) String() string { return net.JoinHostPort(a.host, strconv.Itoa(a.port)) }
|
||||
|
||||
func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
socksAddr := gosocks5.Addr{}
|
||||
header := gosocks5.UDPHeader{
|
||||
@@ -72,7 +86,11 @@ func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
if n > len(b) {
|
||||
n = copy(b, dgram.Data)
|
||||
}
|
||||
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
|
||||
if net.ParseIP(socksAddr.Host) != nil {
|
||||
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
|
||||
} else {
|
||||
addr = &domainAddr{network: "udp", host: socksAddr.Host, port: int(socksAddr.Port)}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pipeConn is a net.Conn backed by bytes.Buffer — Write writes to buf, Read reads from buf.
|
||||
type pipeConn struct {
|
||||
net.Conn
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func newPipeConn() *pipeConn {
|
||||
return &pipeConn{}
|
||||
}
|
||||
func (c *pipeConn) Read(b []byte) (int, error) {
|
||||
n := copy(b, c.buf)
|
||||
c.buf = c.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
func (c *pipeConn) Write(b []byte) (int, error) {
|
||||
c.buf = append(c.buf, b...)
|
||||
return len(b), nil
|
||||
}
|
||||
func (c *pipeConn) Close() error { return nil }
|
||||
|
||||
func Test_udpTunConn_domainRoundTrip(t *testing.T) {
|
||||
pc1 := newPipeConn()
|
||||
server := UDPTunServerConn(pc1)
|
||||
|
||||
domain := &domainAddr{network: "udp", host: "dns.google", port: 53}
|
||||
_, err := server.WriteTo([]byte("hello"), domain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pc2 := newPipeConn()
|
||||
pc2.Write(pc1.buf)
|
||||
|
||||
_, addr, err := UDPTunServerConn(pc2).ReadFrom(make([]byte, 1500))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
da, ok := addr.(*domainAddr)
|
||||
if !ok {
|
||||
t.Fatalf("expected *domainAddr, got %T: %s", addr, addr)
|
||||
}
|
||||
if da.host != "dns.google" || da.port != 53 {
|
||||
t.Fatalf("expected dns.google:53, got %s:%d", da.host, da.port)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_udpTunConn_ipRoundTrip(t *testing.T) {
|
||||
pc1 := newPipeConn()
|
||||
server := UDPTunServerConn(pc1)
|
||||
|
||||
ipAddr := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}
|
||||
_, err := server.WriteTo([]byte("hello"), ipAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pc2 := newPipeConn()
|
||||
pc2.Write(pc1.buf)
|
||||
|
||||
_, addr, err := UDPTunServerConn(pc2).ReadFrom(make([]byte, 1500))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := addr.(*net.UDPAddr); !ok {
|
||||
t.Fatalf("expected *net.UDPAddr, got %T: %s", addr, addr)
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,9 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
defer func() { cc.Close() }()
|
||||
|
||||
upstreamHost := host
|
||||
|
||||
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
|
||||
|
||||
@@ -122,6 +124,26 @@ func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn,
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
// When DNS override directs multiple domains to the same proxy IP,
|
||||
// the browser may reuse a keep-alive connection for a different host.
|
||||
// Re-dial to ensure requests reach the correct upstream.
|
||||
if reqHost := normalizeHost(req.Host, "80"); reqHost != upstreamHost {
|
||||
cc.Close()
|
||||
cc, err = dial(ctx, network, reqHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upstreamHost = reqHost
|
||||
ro.Host = reqHost
|
||||
ro.SrcAddr = cc.LocalAddr().String()
|
||||
ro.DstAddr = cc.RemoteAddr().String()
|
||||
log = log.WithFields(map[string]any{
|
||||
"host": reqHost,
|
||||
"src": cc.LocalAddr().String(),
|
||||
"dst": cc.RemoteAddr().String(),
|
||||
})
|
||||
}
|
||||
|
||||
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, req, readTimeout, ro, &pStats, log); err != nil || shouldClose {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
@@ -52,7 +53,7 @@ func (h *Sniffer) HandleTLS(ctx context.Context, network string, conn net.Conn,
|
||||
if log != nil {
|
||||
log.Debugf("no sni in clienthello from %s", conn.RemoteAddr())
|
||||
}
|
||||
return nil
|
||||
return errors.New("tls: sni is empty, closing connection")
|
||||
}
|
||||
ro.Host = host
|
||||
|
||||
@@ -234,6 +235,7 @@ func (h *Sniffer) terminateTLS(ctx context.Context, network string, conn, cc net
|
||||
WithDialTLS(func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
}),
|
||||
WithBypass(ho.bypass),
|
||||
WithRecorderObject(ro),
|
||||
WithLog(log),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package socks
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-gost/core/common/bufpool"
|
||||
"github.com/go-gost/gosocks5"
|
||||
@@ -50,7 +51,11 @@ func (c *udpTunConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
if n > len(b) {
|
||||
n = copy(b, dgram.Data)
|
||||
}
|
||||
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
|
||||
if net.ParseIP(socksAddr.Host) != nil {
|
||||
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
|
||||
} else {
|
||||
addr = &domainAddr{network: "udp", host: socksAddr.Host, port: int(socksAddr.Port)}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -106,6 +111,20 @@ func UDPConn(c net.PacketConn, bufferSize int) net.PacketConn {
|
||||
}
|
||||
}
|
||||
|
||||
// domainAddr is a net.Addr that preserves a domain name without resolving it.
|
||||
// Returned by udpConn.ReadFrom when a SOCKS5 UDP datagram carries
|
||||
// ATYP=DOMAINNAME, so that downstream consumers (e.g. relay-chain WriteTo) can
|
||||
// forward the domain verbatim instead of forcing a local DNS lookup that would
|
||||
// leak the query through the system resolver and bypass any configured resolver.
|
||||
type domainAddr struct {
|
||||
network string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func (a *domainAddr) Network() string { return a.network }
|
||||
func (a *domainAddr) String() string { return net.JoinHostPort(a.host, strconv.Itoa(a.port)) }
|
||||
|
||||
// ReadFrom reads an UDP datagram.
|
||||
// NOTE: for server side,
|
||||
// the returned addr is the target address the client want to relay to.
|
||||
@@ -128,7 +147,11 @@ func (c *udpConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
}
|
||||
n = copy(b, buf[hlen:n])
|
||||
|
||||
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
|
||||
if net.ParseIP(socksAddr.Host) != nil {
|
||||
addr, err = net.ResolveUDPAddr("udp", socksAddr.String())
|
||||
} else {
|
||||
addr = &domainAddr{network: "udp", host: socksAddr.Host, port: int(socksAddr.Port)}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,25 @@ func (s *Session) Keepalive(interval, timeout time.Duration, retries int) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ping checks if the SSH session is alive by sending a keepalive request.
|
||||
// It blocks until a response is received or the timeout expires.
|
||||
// A nil error indicates the session is healthy.
|
||||
func (s *Session) Ping(timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultKeepaliveTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case err := <-s.ping():
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) ping() <-chan error {
|
||||
ch := make(chan error, 1)
|
||||
go func() {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// Package quota implements a persisted, long-term traffic-volume limiter that
|
||||
// can be shared across services by name. Unlike the rate limiters in
|
||||
// limiter/traffic and limiter/rate, it accumulates total bytes within a window
|
||||
// [startsAt, expiresAt) and stops the referencing service(s) once a byte limit
|
||||
// is reached. Enforcement is fail-open outside the window.
|
||||
package quota
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
var ErrQuotaExceeded = errors.New("quota: traffic limit reached")
|
||||
|
||||
const defaultFlushInterval = 10 * time.Second
|
||||
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
DirectionTotal Direction = iota
|
||||
DirectionIn
|
||||
DirectionOut
|
||||
)
|
||||
|
||||
func (d Direction) String() string {
|
||||
switch d {
|
||||
case DirectionIn:
|
||||
return "in"
|
||||
case DirectionOut:
|
||||
return "out"
|
||||
default:
|
||||
return "total"
|
||||
}
|
||||
}
|
||||
|
||||
// Options seeds a Limiter. Limit and the window are config-authoritative; a
|
||||
// persisted counter is restored only for a matching window (see NewLimiter).
|
||||
type Options struct {
|
||||
Limit uint64
|
||||
StartsAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Direction Direction
|
||||
Flush time.Duration
|
||||
Store Store
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
// Update overwrites runtime state; a nil field is left unchanged.
|
||||
type Update struct {
|
||||
Used *uint64
|
||||
Limit *uint64
|
||||
StartsAt *time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Used uint64
|
||||
Limit uint64
|
||||
StartsAtUnix int64
|
||||
ExpiresAtUnix int64
|
||||
Active bool
|
||||
Expired bool
|
||||
Blocked bool
|
||||
Direction string
|
||||
}
|
||||
|
||||
type Limiter struct {
|
||||
name string
|
||||
direction Direction
|
||||
flush time.Duration
|
||||
store Store
|
||||
log logger.Logger
|
||||
|
||||
used atomic.Uint64
|
||||
limit atomic.Uint64
|
||||
startsAt atomic.Int64 // unixnano; 0 = unset
|
||||
expiresAt atomic.Int64 // unixnano; 0 = unset
|
||||
blocked atomic.Bool
|
||||
dirty atomic.Bool
|
||||
closed atomic.Bool
|
||||
|
||||
mu sync.Mutex
|
||||
waitCh chan struct{} // closed+replaced to broadcast a state change
|
||||
|
||||
closeOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewLimiter(name string, opts Options) *Limiter {
|
||||
l := &Limiter{
|
||||
name: name,
|
||||
direction: opts.Direction,
|
||||
flush: opts.Flush,
|
||||
store: opts.Store,
|
||||
log: opts.Logger,
|
||||
waitCh: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if l.flush <= 0 {
|
||||
l.flush = defaultFlushInterval
|
||||
}
|
||||
if l.log == nil {
|
||||
l.log = xlogger.Nop()
|
||||
}
|
||||
|
||||
l.limit.Store(opts.Limit)
|
||||
sa := unixNanoOrZero(opts.StartsAt)
|
||||
ea := unixNanoOrZero(opts.ExpiresAt)
|
||||
l.startsAt.Store(sa)
|
||||
l.expiresAt.Store(ea)
|
||||
|
||||
// Restore the counter only within the same window: a changed window (a new
|
||||
// period pushed via config) starts fresh.
|
||||
if l.store != nil {
|
||||
if rec, ok, err := l.store.Load(name); err != nil {
|
||||
l.log.Warnf("quota: load %s: %v", name, err)
|
||||
} else if ok && rec.StartsAt == sa && rec.ExpiresAt == ea {
|
||||
l.used.Store(rec.Used)
|
||||
}
|
||||
}
|
||||
|
||||
l.blocked.Store(l.enforcing(time.Now()) && l.used.Load() >= l.limit.Load())
|
||||
go l.run()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Limiter) active(now time.Time) bool {
|
||||
n := now.UnixNano()
|
||||
if sa := l.startsAt.Load(); sa != 0 && n < sa {
|
||||
return false
|
||||
}
|
||||
if ea := l.expiresAt.Load(); ea != 0 && n >= ea {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Limiter) enforcing(now time.Time) bool {
|
||||
return l.limit.Load() > 0 && l.active(now)
|
||||
}
|
||||
|
||||
func (l *Limiter) Blocked() bool {
|
||||
if l.closed.Load() || !l.blocked.Load() {
|
||||
return false
|
||||
}
|
||||
return l.enforcing(time.Now())
|
||||
}
|
||||
|
||||
func (l *Limiter) AddIn(n int) { l.add(int64(n), DirectionIn) }
|
||||
func (l *Limiter) AddOut(n int) { l.add(int64(n), DirectionOut) }
|
||||
|
||||
func (l *Limiter) add(n int64, dir Direction) {
|
||||
if n <= 0 || l.closed.Load() {
|
||||
return
|
||||
}
|
||||
switch l.direction {
|
||||
case DirectionIn:
|
||||
if dir != DirectionIn {
|
||||
return
|
||||
}
|
||||
case DirectionOut:
|
||||
if dir != DirectionOut {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !l.active(time.Now()) {
|
||||
return
|
||||
}
|
||||
used := l.used.Add(uint64(n))
|
||||
l.dirty.Store(true)
|
||||
if lim := l.limit.Load(); lim > 0 && used >= lim {
|
||||
l.block()
|
||||
}
|
||||
}
|
||||
|
||||
// block is hot-path: no lock, no I/O, no wakeup (parking only happens on the
|
||||
// next Accept).
|
||||
func (l *Limiter) block() {
|
||||
if l.blocked.CompareAndSwap(false, true) {
|
||||
l.log.Warnf("quota: %s reached limit %d bytes, stopping", l.name, l.limit.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// WaitChan must be fetched before checking Blocked to avoid a missed wakeup.
|
||||
func (l *Limiter) WaitChan() <-chan struct{} {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.waitCh
|
||||
}
|
||||
|
||||
func (l *Limiter) notify() {
|
||||
l.mu.Lock()
|
||||
close(l.waitCh)
|
||||
l.waitCh = make(chan struct{})
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *Limiter) Update(u Update) {
|
||||
if u.Used != nil {
|
||||
l.used.Store(*u.Used)
|
||||
}
|
||||
if u.Limit != nil {
|
||||
l.limit.Store(*u.Limit)
|
||||
}
|
||||
if u.StartsAt != nil {
|
||||
l.startsAt.Store(unixNanoOrZero(*u.StartsAt))
|
||||
}
|
||||
if u.ExpiresAt != nil {
|
||||
l.expiresAt.Store(unixNanoOrZero(*u.ExpiresAt))
|
||||
}
|
||||
l.reevaluate()
|
||||
}
|
||||
|
||||
func (l *Limiter) reevaluate() {
|
||||
now := time.Now()
|
||||
shouldBlock := l.enforcing(now) && l.used.Load() >= l.limit.Load()
|
||||
l.blocked.Store(shouldBlock)
|
||||
if !shouldBlock {
|
||||
l.notify()
|
||||
}
|
||||
l.flushNow()
|
||||
}
|
||||
|
||||
func (l *Limiter) Snapshot() Snapshot {
|
||||
now := time.Now()
|
||||
ea := l.expiresAt.Load()
|
||||
return Snapshot{
|
||||
Used: l.used.Load(),
|
||||
Limit: l.limit.Load(),
|
||||
StartsAtUnix: nanoToSec(l.startsAt.Load()),
|
||||
ExpiresAtUnix: nanoToSec(ea),
|
||||
Active: l.active(now),
|
||||
Expired: ea != 0 && now.UnixNano() >= ea,
|
||||
Blocked: l.Blocked(),
|
||||
Direction: l.direction.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) run() {
|
||||
flushT := time.NewTicker(l.flush)
|
||||
defer flushT.Stop()
|
||||
boundT := time.NewTicker(time.Second)
|
||||
defer boundT.Stop()
|
||||
|
||||
wasActive := l.active(time.Now())
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.done:
|
||||
return
|
||||
|
||||
case <-flushT.C:
|
||||
if l.dirty.Swap(false) {
|
||||
l.persist()
|
||||
}
|
||||
|
||||
case <-boundT.C:
|
||||
now := time.Now()
|
||||
act := l.active(now)
|
||||
if act == wasActive {
|
||||
continue
|
||||
}
|
||||
wasActive = act
|
||||
if act {
|
||||
l.blocked.Store(l.limit.Load() > 0 && l.used.Load() >= l.limit.Load())
|
||||
} else {
|
||||
l.blocked.Store(false)
|
||||
}
|
||||
l.notify()
|
||||
l.persist()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) flushNow() {
|
||||
l.dirty.Store(false)
|
||||
l.persist()
|
||||
}
|
||||
|
||||
func (l *Limiter) persist() {
|
||||
if l.store == nil {
|
||||
return
|
||||
}
|
||||
rec := Record{
|
||||
Used: l.used.Load(),
|
||||
Limit: l.limit.Load(),
|
||||
StartsAt: l.startsAt.Load(),
|
||||
ExpiresAt: l.expiresAt.Load(),
|
||||
UpdatedAt: time.Now().UnixNano(),
|
||||
}
|
||||
if err := l.store.Save(l.name, rec); err != nil {
|
||||
l.log.Warnf("quota: save %s: %v", l.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close makes the limiter inert (Blocked false, counting a no-op) so deleting a
|
||||
// shared quota releases the referencing services instead of tearing them down.
|
||||
// Called by the registry on Unregister.
|
||||
func (l *Limiter) Close() error {
|
||||
l.closeOnce.Do(func() {
|
||||
l.closed.Store(true)
|
||||
close(l.done)
|
||||
l.flushNow()
|
||||
l.notify()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func unixNanoOrZero(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.UnixNano()
|
||||
}
|
||||
|
||||
func nanoToSec(n int64) int64 {
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return n / int64(time.Second)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestLimiter(t *testing.T, opts Options) *Limiter {
|
||||
t.Helper()
|
||||
l := NewLimiter("test", opts)
|
||||
t.Cleanup(func() { l.Close() })
|
||||
return l
|
||||
}
|
||||
|
||||
func TestLimiterBlocksAtLimit(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{Limit: 100})
|
||||
|
||||
l.AddIn(60)
|
||||
if l.Blocked() {
|
||||
t.Fatalf("should not block at 60/100")
|
||||
}
|
||||
l.AddOut(40)
|
||||
if !l.Blocked() {
|
||||
t.Fatalf("should block at 100/100")
|
||||
}
|
||||
if s := l.Snapshot(); !s.Blocked || s.Used != 100 {
|
||||
t.Fatalf("snapshot=%+v, want blocked used=100", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterUnblockOnOverwrite(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{Limit: 100})
|
||||
l.AddIn(150)
|
||||
if !l.Blocked() {
|
||||
t.Fatalf("expected blocked")
|
||||
}
|
||||
|
||||
zero := uint64(0)
|
||||
l.Update(Update{Used: &zero})
|
||||
|
||||
if l.Blocked() {
|
||||
t.Fatalf("expected unblocked after reset")
|
||||
}
|
||||
if got := l.Snapshot().Used; got != 0 {
|
||||
t.Fatalf("used=%d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterDirection(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{Limit: 100, Direction: DirectionIn})
|
||||
|
||||
l.AddOut(500)
|
||||
if u := l.Snapshot().Used; u != 0 {
|
||||
t.Fatalf("outbound counted under DirectionIn: used=%d", u)
|
||||
}
|
||||
l.AddIn(100)
|
||||
if !l.Blocked() {
|
||||
t.Fatalf("expected blocked after inbound reaches limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterWindowNotStarted(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{Limit: 100, StartsAt: time.Now().Add(time.Hour)})
|
||||
|
||||
l.AddIn(500)
|
||||
s := l.Snapshot()
|
||||
if s.Used != 0 {
|
||||
t.Fatalf("counted before startsAt: used=%d", s.Used)
|
||||
}
|
||||
if s.Active {
|
||||
t.Fatalf("should be inactive before startsAt")
|
||||
}
|
||||
if l.Blocked() {
|
||||
t.Fatalf("should not block before startsAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterFailOpenAfterExpiry(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{Limit: 100, ExpiresAt: time.Now().Add(-time.Hour)})
|
||||
|
||||
l.AddIn(500)
|
||||
s := l.Snapshot()
|
||||
if s.Used != 0 {
|
||||
t.Fatalf("counted after expiry: used=%d", s.Used)
|
||||
}
|
||||
if !s.Expired {
|
||||
t.Fatalf("snapshot should report expired")
|
||||
}
|
||||
if l.Blocked() {
|
||||
t.Fatalf("should be fail-open (unblocked) after expiry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterPersistsToDisk(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "quota.json")
|
||||
store := NewFileStore(path)
|
||||
|
||||
l := NewLimiter("svc", Options{Limit: 1000, Store: store})
|
||||
l.AddIn(300)
|
||||
l.AddOut(200)
|
||||
l.Close()
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read store file: %v", err)
|
||||
}
|
||||
var data map[string]Record
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if rec := data["svc"]; rec.Used != 500 {
|
||||
t.Fatalf("persisted used=%d, want 500", rec.Used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterRestoresFromDisk(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "quota.json")
|
||||
if err := os.WriteFile(path, []byte(`{"svc":{"used":777,"limit":1000}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewFileStore(path)
|
||||
l := NewLimiter("svc", Options{Limit: 1000, Store: store})
|
||||
t.Cleanup(func() { l.Close() })
|
||||
|
||||
if got := l.Snapshot().Used; got != 777 {
|
||||
t.Fatalf("restored used=%d, want 777", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitChanNotifiesOnReset(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{Limit: 100})
|
||||
l.AddIn(200)
|
||||
if !l.Blocked() {
|
||||
t.Fatal("expected blocked")
|
||||
}
|
||||
|
||||
ch := l.WaitChan()
|
||||
zero := uint64(0)
|
||||
l.Update(Update{Used: &zero})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WaitChan not notified after reset")
|
||||
}
|
||||
if l.Blocked() {
|
||||
t.Fatal("expected unblocked after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosedLimiterIsInert(t *testing.T) {
|
||||
l := NewLimiter("c", Options{Limit: 100})
|
||||
l.AddIn(200)
|
||||
if !l.Blocked() {
|
||||
t.Fatal("expected blocked")
|
||||
}
|
||||
|
||||
ch := l.WaitChan()
|
||||
l.Close()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WaitChan not notified on close")
|
||||
}
|
||||
if l.Blocked() {
|
||||
t.Fatal("closed limiter must be inert (not blocking)")
|
||||
}
|
||||
l.AddIn(1000)
|
||||
if u := l.Snapshot().Used; u != 200 {
|
||||
t.Fatalf("closed limiter counted: used=%d, want 200", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterResetsOnWindowChange(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "quota.json")
|
||||
store := NewFileStore(path)
|
||||
|
||||
t1 := time.Now().Add(time.Hour)
|
||||
l1 := NewLimiter("svc", Options{Limit: 1000, ExpiresAt: t1, Store: store})
|
||||
l1.AddIn(500)
|
||||
l1.Close()
|
||||
|
||||
t2 := time.Now().Add(2 * time.Hour)
|
||||
l2 := NewLimiter("svc", Options{Limit: 1000, ExpiresAt: t2, Store: store})
|
||||
t.Cleanup(func() { l2.Close() })
|
||||
|
||||
if u := l2.Snapshot().Used; u != 0 {
|
||||
t.Fatalf("counter not reset on window change: used=%d, want 0", u)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package quota
|
||||
|
||||
type Record struct {
|
||||
Used uint64 `json:"used"`
|
||||
Limit uint64 `json:"limit"`
|
||||
StartsAt int64 `json:"startsAt"` // unixnano; 0 = unset
|
||||
ExpiresAt int64 `json:"expiresAt"` // unixnano; 0 = unset
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Store persists per-name quota records. Implementations must be concurrency-safe.
|
||||
type Store interface {
|
||||
// Load returns ok=false when no record exists yet.
|
||||
Load(name string) (rec Record, ok bool, err error)
|
||||
Save(name string, rec Record) error
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// fileStore persists all records into one JSON file. A single instance is shared
|
||||
// per path so concurrent per-name saves merge instead of clobbering each other.
|
||||
type fileStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data map[string]Record
|
||||
}
|
||||
|
||||
var fileStores sync.Map // path -> *fileStore
|
||||
|
||||
func NewFileStore(path string) Store {
|
||||
if v, ok := fileStores.Load(path); ok {
|
||||
return v.(*fileStore)
|
||||
}
|
||||
fs := &fileStore{path: path, data: make(map[string]Record)}
|
||||
fs.load()
|
||||
actual, _ := fileStores.LoadOrStore(path, fs)
|
||||
return actual.(*fileStore)
|
||||
}
|
||||
|
||||
func (fs *fileStore) load() {
|
||||
b, err := os.ReadFile(fs.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data := make(map[string]Record)
|
||||
if json.Unmarshal(b, &data) == nil {
|
||||
fs.data = data
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *fileStore) Load(name string) (Record, bool, error) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
rec, ok := fs.data[name]
|
||||
return rec, ok, nil
|
||||
}
|
||||
|
||||
func (fs *fileStore) Save(name string, rec Record) error {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.data[name] = rec
|
||||
return fs.writeLocked()
|
||||
}
|
||||
|
||||
func (fs *fileStore) writeLocked() error {
|
||||
b, err := json.MarshalIndent(fs.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := fs.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, fs.path)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package quota
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string
|
||||
Username string
|
||||
Password string
|
||||
DB int
|
||||
Key string
|
||||
}
|
||||
|
||||
// redisStore is a no-op placeholder: a redis-configured quota behaves as
|
||||
// in-memory only until the backend is implemented.
|
||||
//
|
||||
// TODO: implement using github.com/go-redis/redis/v8 (already a dependency).
|
||||
// Suggested schema: HSET <Key> <name> <json(Record)> for Save, HGET for Load.
|
||||
// See internal/loader/redis.go and recorder/redis.go for the patterns.
|
||||
type redisStore struct {
|
||||
cfg RedisConfig
|
||||
}
|
||||
|
||||
func NewRedisStore(cfg RedisConfig) Store {
|
||||
return &redisStore{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *redisStore) Load(name string) (Record, bool, error) {
|
||||
return Record{}, false, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) Save(name string, rec Record) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"syscall"
|
||||
|
||||
"github.com/go-gost/x/ctx"
|
||||
xio "github.com/go-gost/x/internal/io"
|
||||
"github.com/go-gost/x/limiter/quota"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
type quotaConn struct {
|
||||
net.Conn
|
||||
name string
|
||||
}
|
||||
|
||||
func WrapConn(c net.Conn, name string) net.Conn {
|
||||
if c == nil || name == "" {
|
||||
return c
|
||||
}
|
||||
return "aConn{Conn: c, name: name}
|
||||
}
|
||||
|
||||
func (c *quotaConn) Read(b []byte) (n int, err error) {
|
||||
lim := registry.QuotaLimiterRegistry().Get(c.name)
|
||||
if lim != nil && lim.Blocked() {
|
||||
return 0, quota.ErrQuotaExceeded
|
||||
}
|
||||
n, err = c.Conn.Read(b)
|
||||
if lim != nil && n > 0 {
|
||||
lim.AddIn(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *quotaConn) Write(b []byte) (n int, err error) {
|
||||
lim := registry.QuotaLimiterRegistry().Get(c.name)
|
||||
if lim != nil && lim.Blocked() {
|
||||
return 0, quota.ErrQuotaExceeded
|
||||
}
|
||||
n, err = c.Conn.Write(b)
|
||||
if lim != nil && n > 0 {
|
||||
lim.AddOut(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Forward optional capabilities so wrapping does not hide them from handlers.
|
||||
|
||||
func (c *quotaConn) SyscallConn() (syscall.RawConn, error) {
|
||||
if sc, ok := c.Conn.(syscall.Conn); ok {
|
||||
return sc.SyscallConn()
|
||||
}
|
||||
return nil, xio.ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *quotaConn) CloseRead() error {
|
||||
if sc, ok := c.Conn.(xio.CloseRead); ok {
|
||||
return sc.CloseRead()
|
||||
}
|
||||
return xio.ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *quotaConn) CloseWrite() error {
|
||||
if sc, ok := c.Conn.(xio.CloseWrite); ok {
|
||||
return sc.CloseWrite()
|
||||
}
|
||||
return xio.ErrUnsupported
|
||||
}
|
||||
|
||||
func (c *quotaConn) Context() context.Context {
|
||||
if cc, ok := c.Conn.(ctx.Context); ok {
|
||||
return cc.Context()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnwrapConn returns the underlying connection, allowing type assertions
|
||||
// in handlers that iterate wrapper layers (e.g., SSH handler's unwrapConn).
|
||||
func (c *quotaConn) UnwrapConn() net.Conn {
|
||||
return c.Conn
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user