mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93bd2ea760 | ||
|
|
9292e0af3e | ||
|
|
6ad7269644 | ||
|
|
4ea4efaba9 | ||
|
|
faeede7881 | ||
|
|
a38a701dc4 | ||
|
|
0132cefaec | ||
|
|
25cd60d42d | ||
|
|
f8ca4908f3 | ||
|
|
cee264e184 | ||
|
|
64ea5184f8 | ||
|
|
fc5fc052e4 | ||
|
|
e6aeba8701 | ||
|
|
cc02cff00c | ||
|
|
18df26e9bc | ||
|
|
a9be7e6dce | ||
|
|
0e0eab0c52 | ||
|
|
98e9651361 | ||
|
|
e4e36ca095 | ||
|
|
746811eea5 | ||
|
|
7f72a81864 | ||
|
|
2112532e0a | ||
|
|
b2d901e70f | ||
|
|
b106f764a6 | ||
|
|
edb0b5d651 | ||
|
|
e3e41abd66 | ||
|
|
221144fab3 | ||
|
|
6b1c6757f6 | ||
|
|
3157436140 | ||
|
|
0ab5363f60 | ||
|
|
88670baeae | ||
|
|
226420a622 | ||
|
|
3cd4fad4bf | ||
|
|
291fa48fae | ||
|
|
6e9ff675b5 | ||
|
|
506edffc93 | ||
|
|
6248d4c0f8 | ||
|
|
50f4a6b096 | ||
|
|
30ba6d64a5 | ||
|
|
72db96d4cd | ||
|
|
8edf82af9b | ||
|
|
839f2a280b | ||
|
|
c23a2f1e53 | ||
|
|
0ffc000d0d | ||
|
|
486a77665d | ||
|
|
db3c7a375c | ||
|
|
9aab47bcd2 | ||
|
|
3474501784 | ||
|
|
b92c1b130a | ||
|
|
431fe759dc | ||
|
|
4d15ca54af | ||
|
|
6096be44af | ||
|
|
bf6f7713d8 | ||
|
|
6a44b6f018 | ||
|
|
641536de3d | ||
|
|
edd6e5f3f2 | ||
|
|
88b03e0721 | ||
|
|
7a190938aa | ||
|
|
bc06c7f5da | ||
|
|
d0287998cf | ||
|
|
8602043e8d | ||
|
|
7952b4f55d | ||
|
|
7c44ba8125 | ||
|
|
58ab312950 | ||
|
|
f5127f010c | ||
|
|
2edc6d045a | ||
|
|
7b088f8f42 | ||
|
|
f59eac64d9 | ||
|
|
46fc054ad5 | ||
|
|
c91a414d68 | ||
|
|
84f83f6776 | ||
|
|
c16a2804e0 | ||
|
|
255f9ff6ec | ||
|
|
c5c8f0903c | ||
|
|
b8f7c71910 | ||
|
|
fe394a8420 | ||
|
|
d12663dfe3 | ||
|
|
108fd6a1f5 | ||
|
|
f9fb5f443d | ||
|
|
32ebe6550c | ||
|
|
5810404829 | ||
|
|
a2e2810669 | ||
|
|
ca767cc102 | ||
|
|
2baf376c87 | ||
|
|
19be0882db | ||
|
|
1e6c0851e6 | ||
|
|
0ca1e7621a | ||
|
|
4bc3886825 | ||
|
|
52b80b257f | ||
|
|
7b5a92817f | ||
|
|
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 |
+22
@@ -0,0 +1,22 @@
|
||||
# Build & test artifacts
|
||||
*.test
|
||||
coverage.out
|
||||
coverage*.out
|
||||
coverage*.tmp
|
||||
|
||||
# Generated local TLS cert
|
||||
cert.pem
|
||||
|
||||
# Stray Node tooling in a Go module
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
# Index / IDE / agent state
|
||||
.codegraph/
|
||||
.claude/
|
||||
.remember/
|
||||
.build/
|
||||
__pycache__/
|
||||
|
||||
# Local test configs
|
||||
play/
|
||||
@@ -163,4 +163,10 @@ func Register(r *gin.Engine, opts *Options) {
|
||||
config.POST("/rlimiters", createRateLimiter)
|
||||
config.PUT("/rlimiters/:limiter", updateRateLimiter)
|
||||
config.DELETE("/rlimiters/:limiter", deleteRateLimiter)
|
||||
|
||||
config.GET("/caches", getCacheList)
|
||||
config.GET("/caches/:cache", getCache)
|
||||
config.POST("/caches", createCache)
|
||||
config.PUT("/caches/:cache", updateCache)
|
||||
config.DELETE("/caches/:cache", deleteCache)
|
||||
}
|
||||
|
||||
+29
-11
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
@@ -127,10 +129,11 @@ func getConfig(ctx *gin.Context) {
|
||||
|
||||
// swagger:parameters saveConfigRequest
|
||||
type saveConfigRequest struct {
|
||||
// output format, one of yaml|json, default is yaml.
|
||||
// output format, one of yaml|json, default is derived from the file extension.
|
||||
// in: query
|
||||
Format string `form:"format" json:"format"`
|
||||
// file path, default is gost.yaml|gost.json in current working directory.
|
||||
// file path, default is the loaded config file, or gost.yaml|gost.json in
|
||||
// the current working directory when no config file was loaded.
|
||||
// in: query
|
||||
Path string `form:"path" json:"path"`
|
||||
}
|
||||
@@ -155,16 +158,31 @@ func saveConfig(ctx *gin.Context) {
|
||||
var req saveConfigRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
file := "gost.yaml"
|
||||
switch req.Format {
|
||||
case "json":
|
||||
file = "gost.json"
|
||||
default:
|
||||
req.Format = "yaml"
|
||||
file := req.Path
|
||||
if file == "" {
|
||||
// Default to the config file that was loaded (via -C), falling back to
|
||||
// the current working directory when the config came from elsewhere.
|
||||
file = config.ConfigFile()
|
||||
}
|
||||
|
||||
if req.Path != "" {
|
||||
file = req.Path
|
||||
if file == "" {
|
||||
file = "gost.yaml"
|
||||
if req.Format == "json" {
|
||||
file = "gost.json"
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(req.Format) {
|
||||
case "json":
|
||||
req.Format = "json"
|
||||
case "yaml":
|
||||
req.Format = "yaml"
|
||||
default:
|
||||
// Derive the format from the file extension so a loaded JSON config
|
||||
// is saved back as JSON (ReadFile forces format from extension).
|
||||
if strings.ToLower(filepath.Ext(file)) == ".json" {
|
||||
req.Format = "json"
|
||||
} else {
|
||||
req.Format = "yaml"
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(file)
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
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/cache"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getCacheListRequest
|
||||
type getCacheListRequest struct{}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getCacheListResponse
|
||||
type getCacheListResponse struct {
|
||||
// in: body
|
||||
Data cacheList
|
||||
}
|
||||
|
||||
type cacheList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.CacheConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getCacheList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/caches Cache getCacheListRequest
|
||||
//
|
||||
// Get cache list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getCacheListResponse
|
||||
|
||||
var req getCacheListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Caches
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: cacheList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getCacheRequest
|
||||
type getCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getCacheResponse
|
||||
type getCacheResponse struct {
|
||||
// in: body
|
||||
Data *config.CacheConfig
|
||||
}
|
||||
|
||||
func getCache(ctx *gin.Context) {
|
||||
// swagger:route GET /config/caches/{cache} Cache getCacheRequest
|
||||
//
|
||||
// Get cache.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getCacheResponse
|
||||
|
||||
var req getCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getCacheResponse
|
||||
for _, c := range config.Global().Caches {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
if c.Name == req.Cache {
|
||||
resp.Data = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createCacheRequest
|
||||
type createCacheRequest struct {
|
||||
// in: body
|
||||
Data config.CacheConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response createCacheResponse
|
||||
type createCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func createCache(ctx *gin.Context) {
|
||||
// swagger:route POST /config/caches Cache createCacheRequest
|
||||
//
|
||||
// Create a new cache, the name of the cache must be unique in cache list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: createCacheResponse
|
||||
|
||||
var req createCacheRequest
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Data.Name)
|
||||
if name == "" {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache name is required"))
|
||||
return
|
||||
}
|
||||
req.Data.Name = name
|
||||
|
||||
if registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
v := parser.ParseCache(&req.Data)
|
||||
if v == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := registry.CacheRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
c.Caches = append(c.Caches, &req.Data)
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters updateCacheRequest
|
||||
type updateCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
// in: body
|
||||
Data config.CacheConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response updateCacheResponse
|
||||
type updateCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func updateCache(ctx *gin.Context) {
|
||||
// swagger:route PUT /config/caches/{cache} Cache updateCacheRequest
|
||||
//
|
||||
// Update cache by name, the cache must already exist.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: updateCacheResponse
|
||||
|
||||
var req updateCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Cache)
|
||||
|
||||
if !registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
|
||||
return
|
||||
}
|
||||
|
||||
req.Data.Name = name
|
||||
|
||||
v := parser.ParseCache(&req.Data)
|
||||
if v == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
|
||||
return
|
||||
}
|
||||
|
||||
registry.CacheRegistry().Unregister(name)
|
||||
|
||||
if err := registry.CacheRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
for i := range c.Caches {
|
||||
if c.Caches[i].Name == name {
|
||||
c.Caches[i] = &req.Data
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters deleteCacheRequest
|
||||
type deleteCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response deleteCacheResponse
|
||||
type deleteCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func deleteCache(ctx *gin.Context) {
|
||||
// swagger:route DELETE /config/caches/{cache} Cache deleteCacheRequest
|
||||
//
|
||||
// Delete cache by name.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: deleteCacheResponse
|
||||
|
||||
var req deleteCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
name := strings.TrimSpace(req.Cache)
|
||||
|
||||
if !registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
|
||||
return
|
||||
}
|
||||
registry.CacheRegistry().Unregister(name)
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
caches := c.Caches
|
||||
c.Caches = nil
|
||||
for _, s := range caches {
|
||||
if s.Name == name {
|
||||
continue
|
||||
}
|
||||
c.Caches = append(c.Caches, s)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
+633
-437
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -60,13 +60,18 @@ func (p *grpcPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
if v := xctx.SrcAddrFromContext(ctx); v != nil {
|
||||
clientAddr = v.String()
|
||||
}
|
||||
r, err := p.client.Authenticate(ctx,
|
||||
&proto.AuthenticateRequest{
|
||||
Service: options.Service,
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: clientAddr,
|
||||
})
|
||||
req := &proto.AuthenticateRequest{
|
||||
Service: options.Service,
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: clientAddr,
|
||||
}
|
||||
if v := xctx.PeerCertFromContext(ctx); v != nil {
|
||||
req.ClientCn = v.CN
|
||||
req.ClientSan = v.SANs
|
||||
req.ClientCertFingerprint = v.Fingerprint
|
||||
}
|
||||
r, err := p.client.Authenticate(ctx, req)
|
||||
if err != nil {
|
||||
p.log.Error(err)
|
||||
return "", false
|
||||
|
||||
+12
-4
@@ -14,10 +14,13 @@ import (
|
||||
)
|
||||
|
||||
type httpPluginRequest struct {
|
||||
Service string `json:"service"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Client string `json:"client"`
|
||||
Service string `json:"service"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Client string `json:"client"`
|
||||
ClientCn string `json:"clientCn,omitempty"`
|
||||
ClientSan []string `json:"clientSan,omitempty"`
|
||||
ClientCertFingerprint string `json:"clientCertFingerprint,omitempty"`
|
||||
}
|
||||
|
||||
type httpPluginResponse struct {
|
||||
@@ -71,6 +74,11 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
Password: password,
|
||||
Client: clientAddr,
|
||||
}
|
||||
if v := xctx.PeerCertFromContext(ctx); v != nil {
|
||||
rb.ClientCn = v.CN
|
||||
rb.ClientSan = v.SANs
|
||||
rb.ClientCertFingerprint = v.Fingerprint
|
||||
}
|
||||
v, err := json.Marshal(&rb)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+6
-6
@@ -12,8 +12,8 @@
|
||||
// Matching modes:
|
||||
// - Blacklist (whitelist=false): matching addresses bypass the proxy.
|
||||
// This is the default.
|
||||
// - Whitelist (whitelist=true): only matching addresses bypass the proxy;
|
||||
// all other addresses go through the proxy chain.
|
||||
// - Whitelist (whitelist=true): matching addresses go through the proxy
|
||||
// chain; all other addresses bypass the proxy.
|
||||
package bypass
|
||||
|
||||
import (
|
||||
@@ -80,8 +80,8 @@ type options struct {
|
||||
type Option func(opts *options)
|
||||
|
||||
// WhitelistOption sets whether the bypass operates in whitelist mode.
|
||||
// In whitelist mode, only matching addresses bypass the proxy;
|
||||
// all others go through the proxy chain.
|
||||
// In whitelist mode, matching addresses go through the proxy chain;
|
||||
// all others bypass the proxy.
|
||||
func WhitelistOption(whitelist bool) Option {
|
||||
return func(opts *options) {
|
||||
opts.whitelist = whitelist
|
||||
@@ -214,8 +214,8 @@ type localBypass struct {
|
||||
// NewBypass creates and initializes a local Bypass instance.
|
||||
//
|
||||
// In blacklist mode (the default), addresses matching any pattern bypass the
|
||||
// proxy. In whitelist mode (set via WhitelistOption(true)), only matching
|
||||
// addresses bypass the proxy and all others go through the chain.
|
||||
// proxy. In whitelist mode (set via WhitelistOption(true)), matching addresses
|
||||
// go through the proxy chain and all others bypass the proxy.
|
||||
//
|
||||
// If a reload period is configured, patterns from external loaders are
|
||||
// refreshed automatically in the background.
|
||||
|
||||
Vendored
+241
@@ -0,0 +1,241 @@
|
||||
// Package cache provides in-memory implementations of the core cache.Cache
|
||||
// interface.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/logger"
|
||||
)
|
||||
|
||||
// EvictionPolicy selects which entry to remove when the cache is full.
|
||||
type EvictionPolicy string
|
||||
|
||||
const (
|
||||
// EvictOldest removes the entry inserted least recently (by insertion time).
|
||||
EvictOldest EvictionPolicy = "oldest"
|
||||
// EvictLRU removes the entry accessed least recently.
|
||||
EvictLRU EvictionPolicy = "lru"
|
||||
)
|
||||
|
||||
// Options configures a memory cache.
|
||||
type Options struct {
|
||||
// DefaultTTL is applied to entries stored without an explicit TTL.
|
||||
// A non-positive value means entries never expire by default.
|
||||
DefaultTTL time.Duration
|
||||
// MaxSize bounds the number of entries. 0 means unbounded.
|
||||
MaxSize int
|
||||
// MaxBytes bounds the total size of stored data in bytes. 0 means unbounded.
|
||||
MaxBytes int64
|
||||
// CleanupInterval sets the period for the background expiry sweep.
|
||||
// A non-positive value disables the background goroutine.
|
||||
CleanupInterval time.Duration
|
||||
// Eviction is the policy used when MaxSize or MaxBytes is exceeded.
|
||||
// Defaults to EvictOldest.
|
||||
Eviction EvictionPolicy
|
||||
// Logger is used for debug logging. Optional.
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
data []byte
|
||||
expiration time.Time // zero = never expires
|
||||
created time.Time
|
||||
lastAccess time.Time
|
||||
}
|
||||
|
||||
// memoryCache is a concurrency-safe in-memory cache.Cache implementation.
|
||||
type memoryCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*entry
|
||||
currentBytes int64
|
||||
|
||||
defaultTTL time.Duration
|
||||
maxSize int
|
||||
maxBytes int64
|
||||
eviction EvictionPolicy
|
||||
logger logger.Logger
|
||||
|
||||
stopCleanup chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewMemoryCache creates an in-memory cache. If opts.CleanupInterval is
|
||||
// positive, a background goroutine periodically removes expired entries; it is
|
||||
// stopped by Close.
|
||||
func NewMemoryCache(opts Options) cache.Cache {
|
||||
eviction := opts.Eviction
|
||||
if eviction != EvictLRU {
|
||||
eviction = EvictOldest
|
||||
}
|
||||
|
||||
c := &memoryCache{
|
||||
entries: make(map[string]*entry),
|
||||
defaultTTL: opts.DefaultTTL,
|
||||
maxSize: opts.MaxSize,
|
||||
maxBytes: opts.MaxBytes,
|
||||
eviction: eviction,
|
||||
logger: opts.Logger,
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
|
||||
if opts.CleanupInterval > 0 {
|
||||
go c.cleanupLoop(opts.CleanupInterval)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns the entry for key. An expired-but-present entry is returned with
|
||||
// cache.ErrNotFound reserved for absent keys, so callers can serve stale.
|
||||
func (c *memoryCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
|
||||
c.mu.Lock()
|
||||
e, ok := c.entries[key]
|
||||
if ok {
|
||||
e.lastAccess = time.Now()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return nil, cache.ErrNotFound
|
||||
}
|
||||
|
||||
// Return a copy so callers can't mutate the stored buffer.
|
||||
data := make([]byte, len(e.data))
|
||||
copy(data, e.data)
|
||||
return &cache.Entry{Data: data, Expiration: e.expiration}, nil
|
||||
}
|
||||
|
||||
// Set stores data under key. A non-positive TTL uses the cache default; a
|
||||
// non-positive default means the entry never expires.
|
||||
func (c *memoryCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
|
||||
var so cache.SetOptions
|
||||
for _, opt := range opts {
|
||||
opt(&so)
|
||||
}
|
||||
|
||||
ttl := so.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = c.defaultTTL
|
||||
}
|
||||
var expiration time.Time
|
||||
if ttl > 0 {
|
||||
expiration = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
cp := make([]byte, len(data))
|
||||
copy(cp, data)
|
||||
|
||||
now := time.Now()
|
||||
e := &entry{
|
||||
data: cp,
|
||||
expiration: expiration,
|
||||
created: now,
|
||||
lastAccess: now,
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Replace: account for the size delta of an existing key.
|
||||
if old, ok := c.entries[key]; ok {
|
||||
c.currentBytes -= int64(len(old.data))
|
||||
}
|
||||
c.entries[key] = e
|
||||
c.currentBytes += int64(len(cp))
|
||||
|
||||
c.evictLocked()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes key from the cache.
|
||||
func (c *memoryCache) Delete(ctx context.Context, key string) error {
|
||||
c.mu.Lock()
|
||||
if e, ok := c.entries[key]; ok {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the background cleanup goroutine. Safe to call more than once.
|
||||
// Implements io.Closer so the registry stops the goroutine on hot reload.
|
||||
func (c *memoryCache) Close() error {
|
||||
c.stopOnce.Do(func() { close(c.stopCleanup) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memoryCache) cleanupLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.stopCleanup:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, e := range c.entries {
|
||||
if !e.expiration.IsZero() && now.After(e.expiration) {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictLocked enforces the size/byte bounds. It first drops expired entries,
|
||||
// then evicts by policy until within bounds. Caller must hold c.mu.
|
||||
func (c *memoryCache) evictLocked() {
|
||||
if !c.overCapacityLocked() {
|
||||
return
|
||||
}
|
||||
|
||||
// First pass: drop expired entries — they're free wins.
|
||||
now := time.Now()
|
||||
for k, e := range c.entries {
|
||||
if !e.expiration.IsZero() && now.After(e.expiration) {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: evict by policy until within bounds.
|
||||
for c.overCapacityLocked() && len(c.entries) > 0 {
|
||||
var victim string
|
||||
var victimTS time.Time
|
||||
first := true
|
||||
for k, e := range c.entries {
|
||||
ts := e.created
|
||||
if c.eviction == EvictLRU {
|
||||
ts = e.lastAccess
|
||||
}
|
||||
if first || ts.Before(victimTS) {
|
||||
victim = k
|
||||
victimTS = ts
|
||||
first = false
|
||||
}
|
||||
}
|
||||
if e, ok := c.entries[victim]; ok {
|
||||
c.currentBytes -= int64(len(e.data))
|
||||
delete(c.entries, victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *memoryCache) overCapacityLocked() bool {
|
||||
if c.maxSize > 0 && len(c.entries) > c.maxSize {
|
||||
return true
|
||||
}
|
||||
if c.maxBytes > 0 && c.currentBytes > c.maxBytes {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Vendored
+205
@@ -0,0 +1,205 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
)
|
||||
|
||||
func TestSetGet(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
|
||||
ctx := context.Background()
|
||||
|
||||
err := c.Set(ctx, "k", []byte("hello"))
|
||||
if err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
e, err := c.Get(ctx, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if string(e.Data) != "hello" {
|
||||
t.Fatalf("data = %q, want %q", e.Data, "hello")
|
||||
}
|
||||
if e.Expired() {
|
||||
t.Fatal("fresh entry reported expired")
|
||||
}
|
||||
if e.Expiration.IsZero() {
|
||||
t.Fatal("expiration is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotFound(t *testing.T) {
|
||||
c := NewMemoryCache(Options{})
|
||||
_, err := c.Get(context.Background(), "nosuch")
|
||||
if err != cache.ErrNotFound {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpiredStillReturned(t *testing.T) {
|
||||
c := NewMemoryCache(Options{})
|
||||
ctx := context.Background()
|
||||
if err := c.Set(ctx, "k", []byte("data"), cache.WithTTL(1*time.Nanosecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
e, err := c.Get(ctx, "k")
|
||||
if err != nil {
|
||||
t.Fatal("expired entry should still be returned for serve-stale")
|
||||
}
|
||||
if !e.Expired() {
|
||||
t.Fatal("expected expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTTLOverride(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
|
||||
ctx := context.Background()
|
||||
if err := c.Set(ctx, "k", []byte("x"), cache.WithTTL(50*time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
e, _ := c.Get(ctx, "k")
|
||||
if !e.Expired() {
|
||||
t.Fatal("WithTTL should override default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 10 * time.Second})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "k", []byte("x"))
|
||||
if err := c.Delete(ctx, "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Get(ctx, "k"); err != cache.ErrNotFound {
|
||||
t.Fatalf("err = %v, want ErrNotFound after delete", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAbsentKey(t *testing.T) {
|
||||
c := NewMemoryCache(Options{})
|
||||
if err := c.Delete(context.Background(), "nosuch"); err != nil {
|
||||
t.Fatalf("Delete absent key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeverExpires(t *testing.T) {
|
||||
c := NewMemoryCache(Options{}) // no default TTL
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "k", []byte("data"))
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
e, _ := c.Get(ctx, "k")
|
||||
if e.Expired() {
|
||||
t.Fatal("entry with no TTL should never expire")
|
||||
}
|
||||
if e.TTL() != -1 {
|
||||
t.Fatalf("TTL = %v, want -1", e.TTL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxSizeEviction(t *testing.T) {
|
||||
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictOldest})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "a", []byte("1"))
|
||||
c.Set(ctx, "b", []byte("2"))
|
||||
c.Set(ctx, "c", []byte("3")) // evicts oldest (a)
|
||||
|
||||
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
|
||||
t.Fatal("a should have been evicted")
|
||||
}
|
||||
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
|
||||
t.Fatal("b should remain")
|
||||
}
|
||||
if _, err := c.Get(ctx, "c"); err == cache.ErrNotFound {
|
||||
t.Fatal("c should remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxSizeLRU(t *testing.T) {
|
||||
c := NewMemoryCache(Options{MaxSize: 2, Eviction: EvictLRU})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "a", []byte("1"))
|
||||
c.Set(ctx, "b", []byte("2"))
|
||||
c.Get(ctx, "a") // touch a
|
||||
c.Set(ctx, "c", []byte("3")) // LRU-evicts b (a's lastAccess is newer)
|
||||
|
||||
if _, err := c.Get(ctx, "b"); err != cache.ErrNotFound {
|
||||
t.Fatal("b should have been evicted (LRU)")
|
||||
}
|
||||
if _, err := c.Get(ctx, "a"); err == cache.ErrNotFound {
|
||||
t.Fatal("a should remain (touched)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBytesEviction(t *testing.T) {
|
||||
c := NewMemoryCache(Options{MaxBytes: 4})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "a", []byte{1, 2}) // 2 bytes, total=2
|
||||
c.Set(ctx, "b", []byte{3, 4}) // 2 bytes, total=4
|
||||
c.Set(ctx, "c", []byte{5}) // 1 byte, total=5 > 4 → evicts oldest (a)
|
||||
|
||||
if _, err := c.Get(ctx, "a"); err != cache.ErrNotFound {
|
||||
t.Fatal("a should have been evicted (maxBytes)")
|
||||
}
|
||||
if _, err := c.Get(ctx, "b"); err == cache.ErrNotFound {
|
||||
t.Fatal("b should remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
c := NewMemoryCache(Options{CleanupInterval: 10 * time.Millisecond})
|
||||
closer := c.(io.Closer)
|
||||
if err := closer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closer.Close(); err != nil {
|
||||
t.Fatal("second Close should be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseStopsBackgroundScan(t *testing.T) {
|
||||
c := NewMemoryCache(Options{
|
||||
DefaultTTL: 5 * time.Millisecond,
|
||||
CleanupInterval: 10 * time.Millisecond,
|
||||
})
|
||||
ctx := context.Background()
|
||||
c.Set(ctx, "k", []byte("data"))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
e, _ := c.Get(ctx, "k")
|
||||
// Without Close, background scanner cleared expired entries. With Close
|
||||
// before sleep, the entry stays. We test that Close means the goroutine
|
||||
// stops and does NOT panic another background prune. Just exercising the
|
||||
// code path.
|
||||
if e.Expired() {
|
||||
t.Log("expired (expected if scanner ran)")
|
||||
}
|
||||
c.(io.Closer).Close()
|
||||
time.Sleep(30 * time.Millisecond) // goroutine should have stopped
|
||||
// No panic = pass
|
||||
}
|
||||
|
||||
func TestDataCopied(t *testing.T) {
|
||||
c := NewMemoryCache(Options{DefaultTTL: 1 * time.Hour})
|
||||
ctx := context.Background()
|
||||
data := []byte("mutable")
|
||||
c.Set(ctx, "k", data)
|
||||
data[0] = 'X'
|
||||
|
||||
e, _ := c.Get(ctx, "k")
|
||||
if string(e.Data) != "mutable" {
|
||||
t.Fatal("Get returns internal data, not a copy")
|
||||
}
|
||||
// Mutate Get's returned slice — original should be unchanged
|
||||
e.Data[0] = 'Y'
|
||||
e2, _ := c.Get(ctx, "k")
|
||||
if string(e2.Data) != "mutable" {
|
||||
t.Fatal("Get returns shared slice")
|
||||
}
|
||||
}
|
||||
Vendored
+119
@@ -0,0 +1,119 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
// defaultKeyPrefix is prepended to cache keys when RedisOptions.KeyPrefix is empty.
|
||||
const defaultKeyPrefix = "gost:cache:"
|
||||
|
||||
// RedisOptions configures a Redis cache.
|
||||
type RedisOptions struct {
|
||||
// Addr is the Redis server address (host:port).
|
||||
Addr string
|
||||
// DB is the Redis database number.
|
||||
DB int
|
||||
// Username for Redis authentication. Optional.
|
||||
Username string
|
||||
// Password for Redis authentication. Optional.
|
||||
Password string
|
||||
// DefaultTTL is applied to entries stored without an explicit TTL.
|
||||
// A non-positive value means entries never expire by default.
|
||||
DefaultTTL time.Duration
|
||||
// KeyPrefix is prepended to all cache keys. Defaults to "gost:cache:".
|
||||
KeyPrefix string
|
||||
// Logger is used for debug logging. Optional.
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
// redisCache is a cache.Cache backed by a Redis server.
|
||||
//
|
||||
// Redis expires keys natively, so an expired entry is a true miss here — the
|
||||
// interface's serve-stale contract (returning expired entries from Get) does
|
||||
// not apply to this backend.
|
||||
type redisCache struct {
|
||||
client *redis.Client
|
||||
defaultTTL time.Duration
|
||||
keyPrefix string
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewRedisCache creates a Redis-backed cache. Close releases the underlying
|
||||
// client connections.
|
||||
func NewRedisCache(opts RedisOptions) cache.Cache {
|
||||
keyPrefix := opts.KeyPrefix
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = defaultKeyPrefix
|
||||
}
|
||||
|
||||
return &redisCache{
|
||||
client: redis.NewClient(&redis.Options{
|
||||
Addr: opts.Addr,
|
||||
Username: opts.Username,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
}),
|
||||
defaultTTL: opts.DefaultTTL,
|
||||
keyPrefix: keyPrefix,
|
||||
logger: opts.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the entry for key, or cache.ErrNotFound if the key is absent
|
||||
// (including keys Redis has already expired).
|
||||
func (c *redisCache) Get(ctx context.Context, key string) (*cache.Entry, error) {
|
||||
k := c.keyPrefix + key
|
||||
|
||||
pipe := c.client.Pipeline()
|
||||
getCmd := pipe.Get(ctx, k)
|
||||
ttlCmd := pipe.PTTL(ctx, k)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
if err == redis.Nil {
|
||||
return nil, cache.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := &cache.Entry{
|
||||
Data: []byte(getCmd.Val()),
|
||||
}
|
||||
if ttl := ttlCmd.Val(); ttl > 0 {
|
||||
entry.Expiration = time.Now().Add(ttl)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Set stores data under key. A non-positive TTL (after applying the default)
|
||||
// stores the entry without expiration.
|
||||
func (c *redisCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error {
|
||||
var options cache.SetOptions
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
ttl := options.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = c.defaultTTL
|
||||
}
|
||||
if ttl < 0 {
|
||||
ttl = 0 // redis: 0 = no expiration
|
||||
}
|
||||
|
||||
return c.client.Set(ctx, c.keyPrefix+key, data, ttl).Err()
|
||||
}
|
||||
|
||||
// Delete removes key from the cache. Deleting an absent key is a no-op.
|
||||
func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
return c.client.Del(ctx, c.keyPrefix+key).Err()
|
||||
}
|
||||
|
||||
// Close releases the Redis client. Implements io.Closer so the registry
|
||||
// closes the client on hot reload.
|
||||
func (c *redisCache) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
+197
-8
@@ -30,12 +30,16 @@ package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/core/routing"
|
||||
"github.com/go-gost/core/selector"
|
||||
"github.com/go-gost/x/internal/probe"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -111,6 +115,17 @@ func (c *Chain) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
// Close stops all probe goroutines by cascading to each hop's Close,
|
||||
// which in turn cascades to node.Close() for every node.
|
||||
func (c *Chain) Close() error {
|
||||
for _, h := range c.hops {
|
||||
if closer, ok := h.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Route builds a route by selecting one node from each hop. If a node
|
||||
// supports multiplexing, the route is split — nodes before it form a
|
||||
// sub-route that is copied into the transport for reuse.
|
||||
@@ -148,14 +163,20 @@ func (c *Chain) Route(ctx context.Context, network, address string, opts ...chai
|
||||
}
|
||||
|
||||
type chainGroup struct {
|
||||
chains []chain.Chainer
|
||||
selector selector.Selector[chain.Chainer]
|
||||
entries []*ChainEntry
|
||||
selector selector.Selector[chain.Chainer]
|
||||
logger logger.Logger
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
// NewChainGroup creates a chain group that selects one Chainer from the
|
||||
// given list using the configured selector (round-robin by default).
|
||||
func NewChainGroup(chains ...chain.Chainer) *chainGroup {
|
||||
return &chainGroup{chains: chains}
|
||||
entries := make([]*ChainEntry, len(chains))
|
||||
for i, c := range chains {
|
||||
entries[i] = NewChainEntry(c, nil, nil)
|
||||
}
|
||||
return &chainGroup{entries: entries}
|
||||
}
|
||||
|
||||
func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGroup {
|
||||
@@ -163,17 +184,185 @@ func (p *chainGroup) WithSelector(s selector.Selector[chain.Chainer]) *chainGrou
|
||||
return p
|
||||
}
|
||||
|
||||
// WithGroupEntries sets the chain entries for the group (replaces defaults)
|
||||
// and starts probe goroutines for entries that have probe configurations.
|
||||
func (p *chainGroup) WithGroupEntries(entries ...*ChainEntry) *chainGroup {
|
||||
// Cancel any previous probe goroutines before replacing entries.
|
||||
if p.cancelFunc != nil {
|
||||
p.cancelFunc()
|
||||
}
|
||||
p.entries = entries
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.cancelFunc = cancel
|
||||
for _, e := range entries {
|
||||
if e.probe != nil {
|
||||
go p.runEntryProbe(ctx, e)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// WithGroupLogger sets the logger for the group.
|
||||
func (p *chainGroup) WithGroupLogger(log logger.Logger) *chainGroup {
|
||||
p.logger = log
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *chainGroup) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
|
||||
if chain := p.next(ctx); chain != nil {
|
||||
return chain.Route(ctx, network, address, opts...)
|
||||
if c := p.selectChain(ctx, network, address, opts...); c != nil {
|
||||
return c.Route(ctx, network, address, opts...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *chainGroup) next(ctx context.Context) chain.Chainer {
|
||||
if p == nil || len(p.chains) == 0 {
|
||||
func (p *chainGroup) selectChain(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Chainer {
|
||||
if p == nil || len(p.entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.selector.Select(ctx, p.chains...)
|
||||
var options chain.RouteOptions
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
// When no entry has a matcher, the eligible pool equals all entries; the
|
||||
// selector must still run (FailFilter excludes probe-failed chains), so
|
||||
// skip the per-call allocation and pass entries directly.
|
||||
anyMatcher := false
|
||||
for _, e := range p.entries {
|
||||
if e.matcher != nil {
|
||||
anyMatcher = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyMatcher {
|
||||
eligible := make([]chain.Chainer, len(p.entries))
|
||||
for i, e := range p.entries {
|
||||
eligible[i] = e
|
||||
}
|
||||
return p.selectFrom(ctx, eligible...)
|
||||
}
|
||||
|
||||
// Stage 1: matcher filter — build eligible pool.
|
||||
req := routing.Request{
|
||||
Network: network,
|
||||
Host: options.Host,
|
||||
}
|
||||
|
||||
eligible := make([]chain.Chainer, 0, len(p.entries))
|
||||
for _, e := range p.entries {
|
||||
if e.matcher != nil {
|
||||
if !e.matcher.Match(&req) {
|
||||
continue
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Debugf("chain entry matched request %s %s", req.Network, req.Host)
|
||||
}
|
||||
}
|
||||
eligible = append(eligible, e) // *ChainEntry as chain.Chainer, FailFilter reads e.Marker()
|
||||
}
|
||||
|
||||
if len(eligible) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stage 2: group selector — FailFilter + strategy.
|
||||
return p.selectFrom(ctx, eligible...)
|
||||
}
|
||||
|
||||
func (p *chainGroup) selectFrom(ctx context.Context, eligible ...chain.Chainer) chain.Chainer {
|
||||
if p.selector != nil {
|
||||
return p.selector.Select(ctx, eligible...)
|
||||
}
|
||||
return eligible[0]
|
||||
}
|
||||
|
||||
// Close stops all probe goroutines.
|
||||
func (p *chainGroup) Close() error {
|
||||
if p.cancelFunc != nil {
|
||||
p.cancelFunc()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runEntryProbe periodically probes a chain and marks
|
||||
// the chain entry (not the chain's nodes) on failure.
|
||||
func (p *chainGroup) runEntryProbe(ctx context.Context, e *ChainEntry) {
|
||||
cfg := e.probe
|
||||
interval := cfg.Interval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
|
||||
p.probeEntry(ctx, e, cfg) // first probe immediately
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
p.probeEntry(ctx, e, cfg)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *chainGroup) probeEntry(ctx context.Context, e *ChainEntry, cfg *chain.ProbeConfig) {
|
||||
// cmd probe runs a shell command — no chain routing needed.
|
||||
if cfg.Type == chain.ProbeTypeCmd {
|
||||
probe.RunCmdProbe(cfg, e.marker, p.logger)
|
||||
return
|
||||
}
|
||||
|
||||
// TCP/HTTP probe: route through the chain to probe target.
|
||||
addr := cfg.Addr
|
||||
if addr == "" {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("chain entry probe: no probe address configured")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Route through the probe lifecycle context so cancellation (reload/close)
|
||||
// aborts an in-flight probe instead of blocking forever on a stuck route.
|
||||
route := e.chainer.Route(ctx, "tcp", addr)
|
||||
if route == nil {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("chain entry probe: no route available")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := route.Dial(probeCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debugf("chain entry probe dial %s failed: %v", addr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if cfg.Type == chain.ProbeTypeHTTP {
|
||||
if err := probe.NewHTTPProber(cfg).Probe(conn); err != nil {
|
||||
e.marker.Mark()
|
||||
if p.logger != nil {
|
||||
p.logger.Debugf("chain entry probe http %s failed: %v", addr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
e.marker.Reset()
|
||||
}
|
||||
|
||||
+77
-5
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/core/routing"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xmetadata "github.com/go-gost/x/metadata"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -787,7 +788,7 @@ func TestNewChainGroup(t *testing.T) {
|
||||
ch2 := NewChain("ch2")
|
||||
g := NewChainGroup(ch1, ch2)
|
||||
require.NotNil(t, g)
|
||||
assert.Len(t, g.chains, 2)
|
||||
assert.Len(t, g.entries, 2)
|
||||
}
|
||||
|
||||
func TestChainGroup_Route_NoChains(t *testing.T) {
|
||||
@@ -1758,12 +1759,83 @@ func TestChainGroup_WithSelector(t *testing.T) {
|
||||
assert.Len(t, rt.Nodes(), 1)
|
||||
}
|
||||
|
||||
func TestChainGroup_Next_NilReceiver(t *testing.T) {
|
||||
func TestChainGroup_SelectChain_NilReceiver(t *testing.T) {
|
||||
var g *chainGroup
|
||||
assert.Nil(t, g.next(context.Background()))
|
||||
assert.Nil(t, g.selectChain(context.Background(), "", ""))
|
||||
}
|
||||
|
||||
func TestChainGroup_Next_EmptyChains(t *testing.T) {
|
||||
func TestChainGroup_SelectChain_EmptyChains(t *testing.T) {
|
||||
g := NewChainGroup()
|
||||
assert.Nil(t, g.next(context.Background()))
|
||||
assert.Nil(t, g.selectChain(context.Background(), "", ""))
|
||||
}
|
||||
|
||||
// hostMatcher matches requests whose Host equals the configured value.
|
||||
type hostMatcher struct {
|
||||
host string
|
||||
}
|
||||
|
||||
func (m *hostMatcher) Match(req *routing.Request) bool {
|
||||
return req != nil && req.Host == m.host
|
||||
}
|
||||
|
||||
func TestChainGroup_SelectChain_MatcherFilter(t *testing.T) {
|
||||
matchChain := NewChain("match")
|
||||
skipChain := NewChain("skip")
|
||||
|
||||
entries := []*ChainEntry{
|
||||
NewChainEntry(matchChain, &hostMatcher{host: "a.example.com"}, nil),
|
||||
NewChainEntry(skipChain, &hostMatcher{host: "b.example.com"}, nil),
|
||||
}
|
||||
g := NewChainGroup().WithGroupEntries(entries...)
|
||||
|
||||
// Only the entry matching "a.example.com" should be eligible.
|
||||
got := g.selectChain(context.Background(), "tcp", "a.example.com:80", chain.WithHostRouteOption("a.example.com"))
|
||||
require.NotNil(t, got)
|
||||
// selectChain returns the *ChainEntry (so the FailFilter can read its marker),
|
||||
// not the inner *Chain.
|
||||
ce, ok := got.(*ChainEntry)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, matchChain, ce.chainer)
|
||||
|
||||
// Non-matching host → no eligible entry → nil.
|
||||
assert.Nil(t, g.selectChain(context.Background(), "tcp", "x.example.com:80", chain.WithHostRouteOption("x.example.com")))
|
||||
}
|
||||
|
||||
func TestChainGroup_Close_StopsProbeGoroutines(t *testing.T) {
|
||||
ch := NewChain("ch")
|
||||
// cmd probe with a short interval; the entry marker count is the observable
|
||||
// signal that the probe goroutine is (or is not) still running.
|
||||
entry := NewChainEntry(ch, nil, &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "true",
|
||||
Interval: 10 * time.Millisecond,
|
||||
})
|
||||
g := NewChainGroup().WithGroupEntries(entry)
|
||||
|
||||
// Let the probe tick a few times, then Close().
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
require.NoError(t, g.Close())
|
||||
|
||||
before := entry.Marker().Count()
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
after := entry.Marker().Count()
|
||||
|
||||
// A succeeding cmd probe ("true") never marks, so the count must stay
|
||||
// stable after Close(): the goroutine has stopped ticking.
|
||||
assert.Equal(t, before, after, "probe goroutine should stop ticking after Close()")
|
||||
}
|
||||
|
||||
func TestChainGroup_Probe_MarksEntryOnCmdFailure(t *testing.T) {
|
||||
ch := NewChain("ch")
|
||||
entry := NewChainEntry(ch, nil, &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "false", // always exits non-zero → probe failure
|
||||
Interval: time.Second,
|
||||
})
|
||||
g := NewChainGroup().WithGroupEntries(entry)
|
||||
|
||||
// First probe runs immediately; give it time to mark.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.NotZero(t, entry.Marker().Count(), "failing cmd probe should mark the entry")
|
||||
require.NoError(t, g.Close())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/routing"
|
||||
"github.com/go-gost/core/selector"
|
||||
)
|
||||
|
||||
// ChainEntry wraps a chain.Chainer with an optional matcher and an independent
|
||||
// failure marker for chain-level probe tracking. The entry's marker is
|
||||
// separate from any node-level markers inside the chain so that probe
|
||||
// Reset() cannot undo a node-level failCodes Mark().
|
||||
type ChainEntry struct {
|
||||
chainer chain.Chainer
|
||||
matcher routing.Matcher
|
||||
probe *chain.ProbeConfig
|
||||
marker selector.Marker
|
||||
}
|
||||
|
||||
// NewChainEntry creates a ChainEntry. matcher may be nil (unconditional
|
||||
// catch-all). probe may be nil (no health check for this entry).
|
||||
func NewChainEntry(c chain.Chainer, m routing.Matcher, probe *chain.ProbeConfig) *ChainEntry {
|
||||
return &ChainEntry{
|
||||
chainer: c,
|
||||
matcher: m,
|
||||
probe: probe,
|
||||
marker: selector.NewFailMarker(),
|
||||
}
|
||||
}
|
||||
|
||||
// Route implements chain.Chainer so the entry can be passed directly to the
|
||||
// group selector. The selector's FailFilter reads Marker() from the entry,
|
||||
// not from the underlying chain — this keeps probe-driven marking independent
|
||||
// of any node-level markers.
|
||||
func (e *ChainEntry) Route(ctx context.Context, network, address string, opts ...chain.RouteOption) chain.Route {
|
||||
return e.chainer.Route(ctx, network, address, opts...)
|
||||
}
|
||||
|
||||
// Marker implements selector.Markable.
|
||||
func (e *ChainEntry) Marker() selector.Marker {
|
||||
return e.marker
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/internal/probe"
|
||||
)
|
||||
|
||||
// StartNodeProbe starts a background goroutine that periodically probes the
|
||||
// node's transport. On success the node's marker is reset; on failure the
|
||||
// marker is incremented. cfg may be nil (no-op).
|
||||
func StartNodeProbe(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.Addr == "" && cfg.Type != chain.ProbeTypeCmd {
|
||||
return
|
||||
}
|
||||
if cfg.Type == chain.ProbeTypeCmd && cfg.Command == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
node.SetProbeCancel(cancel)
|
||||
go runNodeProbe(ctx, node, cfg, log)
|
||||
}
|
||||
|
||||
func runNodeProbe(ctx context.Context, node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
|
||||
interval := cfg.Interval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
probeNode(node, cfg, log) // first probe immediately
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
probeNode(node, cfg, log)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func probeNode(node *chain.Node, cfg *chain.ProbeConfig, log logger.Logger) {
|
||||
// cmd probe runs a shell command directly — no transport connection needed.
|
||||
if cfg.Type == chain.ProbeTypeCmd {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
start := time.Now()
|
||||
err := (&probe.CmdProber{Command: cfg.Command, Timeout: timeout}).Probe()
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
recordProbe(node, false, latency, err.Error(), log)
|
||||
} else {
|
||||
recordProbe(node, true, latency, "", log)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
tr := node.Options().Transport
|
||||
conn, err := tr.Dial(ctx, node.Addr)
|
||||
if err != nil {
|
||||
recordProbe(node, false, time.Since(start), err.Error(), log)
|
||||
return
|
||||
}
|
||||
|
||||
if hc, err2 := tr.Handshake(ctx, conn); err2 == nil {
|
||||
conn = hc
|
||||
} else {
|
||||
conn.Close()
|
||||
recordProbe(node, false, time.Since(start), err2.Error(), log)
|
||||
return
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
if cfg.Type == chain.ProbeTypeHTTP {
|
||||
err = probe.NewHTTPProber(cfg).Probe(conn)
|
||||
conn.Close()
|
||||
if err != nil {
|
||||
recordProbe(node, false, latency, err.Error(), log)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
recordProbe(node, true, latency, "", log)
|
||||
}
|
||||
|
||||
func recordProbe(node *chain.Node, success bool, latency time.Duration, errStr string, log logger.Logger) {
|
||||
result := &chain.ProbeResult{
|
||||
Success: success,
|
||||
Latency: latency,
|
||||
Error: errStr,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
node.SetProbeResult(result)
|
||||
|
||||
if success {
|
||||
node.Marker().Reset()
|
||||
} else {
|
||||
node.Marker().Mark()
|
||||
}
|
||||
|
||||
log.Debugf("node probe %s: success=%v latency=%v err=%s", node.Name, success, latency, errStr)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// testTransport implements chain.Transporter with real TCP dials.
|
||||
type testTransport struct {
|
||||
dialer *net.Dialer
|
||||
options chain.TransportOptions
|
||||
}
|
||||
|
||||
func (t *testTransport) Dial(ctx context.Context, addr string) (net.Conn, error) {
|
||||
return t.dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
|
||||
func (t *testTransport) Handshake(ctx context.Context, conn net.Conn) (net.Conn, error) {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (t *testTransport) Connect(ctx context.Context, conn net.Conn, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (t *testTransport) Bind(ctx context.Context, conn net.Conn, network, address string, opts ...connector.BindOption) (net.Listener, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (t *testTransport) Multiplex() bool { return false }
|
||||
|
||||
func (t *testTransport) Options() *chain.TransportOptions { return &t.options }
|
||||
|
||||
func (t *testTransport) Copy() chain.Transporter { return t }
|
||||
|
||||
func TestNodeProbeTCP(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
// accept one connection, then close immediately (TCP probe only needs dial+handshake)
|
||||
go func() {
|
||||
for {
|
||||
conn, _ := l.Accept()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
|
||||
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
|
||||
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeTCP,
|
||||
Addr: addr,
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
|
||||
// Wait for first probe
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr := node.ProbeResult()
|
||||
if pr == nil || !pr.Success {
|
||||
t.Fatalf("expected successful probe, got %+v", pr)
|
||||
}
|
||||
|
||||
// Close listener → next probe fails
|
||||
l.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr = node.ProbeResult()
|
||||
if pr == nil || pr.Success {
|
||||
t.Fatalf("expected failed probe after listener close, got %+v", pr)
|
||||
}
|
||||
if node.Marker().Count() == 0 {
|
||||
t.Error("expected marker count > 0 after failure")
|
||||
}
|
||||
|
||||
node.Close()
|
||||
}
|
||||
|
||||
func TestNodeProbeClose(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, _ := l.Accept()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
|
||||
node := chain.NewNode("test", addr, chain.TransportNodeOption(tr))
|
||||
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeTCP,
|
||||
Addr: addr,
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Snapshot result before close
|
||||
lastSuccess := node.ProbeResult().Success
|
||||
if !lastSuccess {
|
||||
t.Fatal("expected initial success before close")
|
||||
}
|
||||
|
||||
node.Close()
|
||||
|
||||
// Capture marker count right after close
|
||||
countAfterClose := node.Marker().Count()
|
||||
|
||||
// Wait longer than probe interval — no more updates after close
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Marker count should not increase after close (no more probes running)
|
||||
if node.Marker().Count() != countAfterClose {
|
||||
t.Errorf("marker changed after close: was %d, now %d", countAfterClose, node.Marker().Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeProbeDisabled(t *testing.T) {
|
||||
node := chain.NewNode("no-probe", "addr")
|
||||
// nil config → no-op
|
||||
StartNodeProbe(node, nil, xlogger.Nop())
|
||||
if pr := node.ProbeResult(); pr != nil {
|
||||
t.Error("expected nil probe result for disabled probe")
|
||||
}
|
||||
// empty addr → no-op
|
||||
StartNodeProbe(node, &chain.ProbeConfig{}, xlogger.Nop())
|
||||
if pr := node.ProbeResult(); pr != nil {
|
||||
t.Error("expected nil probe result for empty addr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeProbeCmd(t *testing.T) {
|
||||
// Cmd probe doesn't need a transport — StartNodeProbe skips the addr check.
|
||||
node := chain.NewNode("cmd-test", "")
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "true",
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr := node.ProbeResult()
|
||||
if pr == nil || !pr.Success {
|
||||
t.Fatalf("expected cmd probe success, got %+v", pr)
|
||||
}
|
||||
if node.Marker().Count() != 0 {
|
||||
t.Errorf("expected marker count 0 after success, got %d", node.Marker().Count())
|
||||
}
|
||||
|
||||
// Switch to failing command
|
||||
cfg2 := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "false",
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
node.Close() // stop old probe
|
||||
node2 := chain.NewNode("cmd-test-2", "")
|
||||
StartNodeProbe(node2, cfg2, xlogger.Nop())
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr2 := node2.ProbeResult()
|
||||
if pr2 == nil || pr2.Success {
|
||||
t.Fatalf("expected cmd probe failure for exit 1, got %+v", pr2)
|
||||
}
|
||||
if node2.Marker().Count() == 0 {
|
||||
t.Error("expected marker count > 0 after cmd failure")
|
||||
}
|
||||
|
||||
node2.Close()
|
||||
}
|
||||
|
||||
func TestNodeProbeCmdDisabled(t *testing.T) {
|
||||
// cmd probe without command → no-op
|
||||
node := chain.NewNode("cmd-disabled", "")
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeCmd,
|
||||
Command: "",
|
||||
}
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
if pr := node.ProbeResult(); pr != nil {
|
||||
t.Error("expected nil probe result for empty command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeProbeHTTP(t *testing.T) {
|
||||
// Start a small HTTP server
|
||||
mux := &httpTestMux{}
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go mux.serve(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
tr := &testTransport{dialer: &net.Dialer{Timeout: 2 * time.Second}}
|
||||
node := chain.NewNode("test-http", addr, chain.TransportNodeOption(tr))
|
||||
|
||||
cfg := &chain.ProbeConfig{
|
||||
Type: chain.ProbeTypeHTTP,
|
||||
Addr: addr,
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 2 * time.Second,
|
||||
HTTPPath: "/health",
|
||||
ExpectedStatus: 200,
|
||||
}
|
||||
|
||||
// Case 1: server returns 200
|
||||
mux.status.Store(200)
|
||||
StartNodeProbe(node, cfg, xlogger.Nop())
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr := node.ProbeResult()
|
||||
if pr == nil || !pr.Success {
|
||||
t.Fatalf("expected HTTP 200 probe success, got %+v", pr)
|
||||
}
|
||||
|
||||
// Case 2: server returns 500
|
||||
mux.status.Store(500)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
pr = node.ProbeResult()
|
||||
if pr == nil || pr.Success {
|
||||
t.Fatalf("expected HTTP 500 probe failure, got %+v", pr)
|
||||
}
|
||||
|
||||
node.Close()
|
||||
}
|
||||
|
||||
// httpTestMux is a minimal HTTP server that returns a configurable status.
|
||||
type httpTestMux struct {
|
||||
status atomic.Int32
|
||||
}
|
||||
|
||||
func (m *httpTestMux) serve(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := conn.Read(buf)
|
||||
// Parse just enough to construct a response
|
||||
body := "HTTP/1.1 000 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
switch m.status.Load() {
|
||||
case 200:
|
||||
body = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case 500:
|
||||
body = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
}
|
||||
_ = n
|
||||
conn.Write([]byte(body))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -49,6 +50,19 @@ func (r *Router) Options() *chain.RouterOptions {
|
||||
return &r.options
|
||||
}
|
||||
|
||||
// Close releases resources held by the router's chain. A chain that owns
|
||||
// background goroutines (e.g. a chainGroup running liveness probes) is closed
|
||||
// here so its goroutines stop when the service shuts down or is reloaded.
|
||||
func (r *Router) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if c, ok := r.options.Chain.(io.Closer); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dial establishes a connection to the target address through the configured
|
||||
// chain. It resolves the address, records the dial event, retries up to
|
||||
// Retries+1 times, and returns the resulting connection.
|
||||
|
||||
+49
-21
@@ -397,15 +397,24 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
listener = schemes[1]
|
||||
}
|
||||
|
||||
// For path-based protocols (Unix socket listeners and serial), use path as address
|
||||
// Decide path-based from the scheme before registry remaps listener to tcp.
|
||||
isPathBasedProtocol := listener == "unix" || listener == "runix" || listener == "serial"
|
||||
for _, s := range schemes {
|
||||
if s == "unix" || s == "runix" || s == "serial" {
|
||||
isPathBasedProtocol = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// A path-based protocol with an empty host (three-slash URL like
|
||||
// unix:///var/run/sock) uses the path as the absolute listen address and
|
||||
// has no forward target. With a non-empty host (two-slash URL like
|
||||
// unix://sock/other.sock), the host is the listen address and any path is a
|
||||
// forward target (port-forward to another socket), handled below.
|
||||
isPathAddr := isPathBasedProtocol && url.Host == ""
|
||||
|
||||
var addrs []string
|
||||
if isPathBasedProtocol {
|
||||
path := url.EscapedPath()
|
||||
if url.Host != "" {
|
||||
addrs = append(addrs, url.Host+strings.TrimPrefix(path, "/"))
|
||||
} else if path != "" {
|
||||
if isPathAddr {
|
||||
if path := url.EscapedPath(); path != "" {
|
||||
addrs = append(addrs, path)
|
||||
} else {
|
||||
addrs = append(addrs, url.Host)
|
||||
@@ -436,8 +445,10 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
|
||||
var nodes []*config.ForwardNodeConfig
|
||||
// forward mode
|
||||
// For path-based protocols, path is address not forward target
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathBasedProtocol {
|
||||
// A path-based protocol with an empty host (e.g. unix:///abs.sock) treats
|
||||
// the path as the listen address, not a forward target. With a host
|
||||
// (e.g. unix://a.sock/b.sock) the path is a forward target.
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathAddr {
|
||||
i := 0
|
||||
for _, addr := range strings.Split(remotes, ",") {
|
||||
addrs := xnet.AddrPortRange(addr).Addrs()
|
||||
@@ -466,6 +477,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{
|
||||
@@ -629,6 +651,18 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
|
||||
dialer = schemes[1]
|
||||
}
|
||||
|
||||
// Path-based address logic must use the requested scheme, not the
|
||||
// registry fallback (unix/serial often remap to http/tcp when only
|
||||
// the CLI registry is partially populated, or in unit tests).
|
||||
isPathBasedProtocol := dialer == "unix" || dialer == "serial" ||
|
||||
connector == "unix" || connector == "serial"
|
||||
for _, s := range schemes {
|
||||
if s == "unix" || s == "serial" || s == "runix" {
|
||||
isPathBasedProtocol = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
md := mdx.NewMetadata(m)
|
||||
|
||||
if c := registry.ConnectorRegistry().Get(connector); c == nil {
|
||||
@@ -714,20 +748,14 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
|
||||
nodeMd[k] = v
|
||||
}
|
||||
|
||||
// For path-based protocols (Unix socket dialers and serial), use path as address
|
||||
// For path-based protocols (Unix socket dialers and serial), use path as address.
|
||||
// Three-slash URLs (empty host) give the absolute socket path; two-slash URLs
|
||||
// use the host as the socket. The path on a host form is not part of the node
|
||||
// address (it is a forward target for services, ignored for nodes).
|
||||
var nodeAddr string
|
||||
isPathBasedProtocol := dialer == "unix" || dialer == "serial"
|
||||
if isPathBasedProtocol {
|
||||
path := url.EscapedPath()
|
||||
if url.Host != "" {
|
||||
// Two slashes: host + path = relative path
|
||||
nodeAddr = url.Host + strings.TrimPrefix(path, "/")
|
||||
} else if path != "" {
|
||||
// Three slashes: path is absolute (keep leading slash)
|
||||
nodeAddr = path
|
||||
} else {
|
||||
nodeAddr = url.Host
|
||||
}
|
||||
if isPathBasedProtocol && url.Host == "" {
|
||||
// Three slashes: path is absolute (keep leading slash)
|
||||
nodeAddr = url.EscapedPath()
|
||||
} else {
|
||||
nodeAddr = url.Host
|
||||
}
|
||||
|
||||
+62
-6
@@ -1765,10 +1765,10 @@ func TestBuildNodeConfig_TLSWithCert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
|
||||
func TestBuildNodeConfig_UnixAbsolutePath(t *testing.T) {
|
||||
// http+unix scheme: connector=http, dialer=unix.
|
||||
// Since unix is not in the test registry, dialer falls back to tcp,
|
||||
// and the path-based address logic (dialer=="unix") is skipped.
|
||||
// Dialer falls back to tcp when unix is not in the test registry, but
|
||||
// the absolute socket path from a three-slash URL must still be kept.
|
||||
rawURL := "http+unix:///var/run/socket"
|
||||
u, _ := url.Parse(rawURL)
|
||||
|
||||
@@ -1780,12 +1780,68 @@ func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
|
||||
if node.Dialer.Type != "tcp" {
|
||||
t.Errorf("Dialer.Type = %q, want tcp (unix not in test registry)", node.Dialer.Type)
|
||||
}
|
||||
// Addr is url.Host ("") since no host component in three-slash unix URL
|
||||
if node.Addr != "" {
|
||||
t.Errorf("Addr = %q, want empty", node.Addr)
|
||||
if node.Addr != "/var/run/socket" {
|
||||
t.Errorf("Addr = %q, want /var/run/socket", node.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNodeConfig_UnixRelativeHost(t *testing.T) {
|
||||
u, _ := url.Parse("unix://docker.sock")
|
||||
node, err := buildNodeConfig(u, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildNodeConfig: %v", err)
|
||||
}
|
||||
if node.Addr != "docker.sock" {
|
||||
t.Errorf("Addr = %q, want docker.sock", node.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServiceConfig_UnixAbsolutePath(t *testing.T) {
|
||||
// three-slash: path is the absolute socket listen address, no forward target
|
||||
svcs, err := buildServiceConfig(mustURL("unix:///var/run/gost.sock"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildServiceConfig: %v", err)
|
||||
}
|
||||
if len(svcs) != 1 {
|
||||
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
|
||||
}
|
||||
if svcs[0].Addr != "/var/run/gost.sock" {
|
||||
t.Errorf("Addr = %q, want /var/run/gost.sock", svcs[0].Addr)
|
||||
}
|
||||
if svcs[0].Forwarder != nil {
|
||||
t.Errorf("Forwarder = %+v, want nil (no forward target)", svcs[0].Forwarder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServiceConfig_UnixPortForward(t *testing.T) {
|
||||
// two-slash: host is the listen socket, path is a forward target socket
|
||||
svcs, err := buildServiceConfig(mustURL("unix://gost.sock/gost2.sock"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildServiceConfig: %v", err)
|
||||
}
|
||||
if len(svcs) != 1 {
|
||||
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
|
||||
}
|
||||
if svcs[0].Addr != "gost.sock" {
|
||||
t.Errorf("Addr = %q, want gost.sock", svcs[0].Addr)
|
||||
}
|
||||
if svcs[0].Forwarder == nil || len(svcs[0].Forwarder.Nodes) == 0 {
|
||||
t.Fatalf("Forwarder = %+v, want a forward target node", svcs[0].Forwarder)
|
||||
}
|
||||
if got := svcs[0].Forwarder.Nodes[0].Addr; got != "gost2.sock" {
|
||||
t.Errorf("forward Addr = %q, want gost2.sock", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustURL(raw string) *url.URL {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
|
||||
func TestBuildServiceConfig_RelayTLSForward(t *testing.T) {
|
||||
// relay+tls scheme: handler=relay, listener=tls.
|
||||
// relay not in test registry → handler falls back to auto, listener falls back to tcp.
|
||||
|
||||
+139
-8
@@ -4,10 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -26,8 +29,21 @@ func init() {
|
||||
var (
|
||||
global = &Config{}
|
||||
globalMux sync.RWMutex
|
||||
|
||||
// configFile remembers the last file loaded via ReadFile so the API can
|
||||
// save back to the same location (issue go-gost/x#123).
|
||||
configFile atomic.Value // string
|
||||
)
|
||||
|
||||
// ConfigFile returns the path of the last config file loaded via ReadFile,
|
||||
// or an empty string when the config came from stdin, a URL, or inline JSON.
|
||||
func ConfigFile() string {
|
||||
if s, ok := configFile.Load().(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Global() *Config {
|
||||
globalMux.RLock()
|
||||
defer globalMux.RUnlock()
|
||||
@@ -157,6 +173,7 @@ type SelectorConfig struct {
|
||||
Strategy string `json:"strategy"`
|
||||
MaxFails int `yaml:"maxFails" json:"maxFails"`
|
||||
FailTimeout time.Duration `yaml:"failTimeout" json:"failTimeout"`
|
||||
MaxLatency time.Duration `yaml:"maxLatency,omitempty" json:"maxLatency,omitempty"`
|
||||
}
|
||||
|
||||
type AdmissionConfig struct {
|
||||
@@ -316,6 +333,38 @@ 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"`
|
||||
}
|
||||
|
||||
// CacheConfig defines a named cache store. Memory and Redis backends are
|
||||
// supported; other backends (file, plugin) are follow-ups.
|
||||
type CacheConfig struct {
|
||||
Name string `json:"name"`
|
||||
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
|
||||
Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"`
|
||||
}
|
||||
|
||||
// RedisCache configures the Redis cache backend.
|
||||
type RedisCache 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"`
|
||||
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
|
||||
Key string `yaml:",omitempty" json:"key,omitempty"` // key prefix, default "gost:cache:"
|
||||
}
|
||||
|
||||
// MemoryCache configures the in-memory cache backend.
|
||||
type MemoryCache struct {
|
||||
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
|
||||
MaxSize int `yaml:"maxSize,omitempty" json:"maxSize,omitempty"`
|
||||
MaxBytes int64 `yaml:"maxBytes,omitempty" json:"maxBytes,omitempty"`
|
||||
CleanupInterval time.Duration `yaml:"cleanupInterval,omitempty" json:"cleanupInterval,omitempty"`
|
||||
Eviction string `yaml:",omitempty" json:"eviction,omitempty"` // "oldest" | "lru"
|
||||
}
|
||||
|
||||
type LimiterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
|
||||
@@ -360,9 +409,21 @@ type ForwarderConfig struct {
|
||||
// Deprecated: use hop instead
|
||||
Name string `yaml:",omitempty" json:"name,omitempty"`
|
||||
// the referenced hop name
|
||||
Hop string `yaml:",omitempty" json:"hop,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Nodes []*ForwardNodeConfig `json:"nodes"`
|
||||
Hop string `yaml:",omitempty" json:"hop,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Nodes []*ForwardNodeConfig `json:"nodes"`
|
||||
HopGroup *ForwardHopGroupConfig `yaml:"hopGroup,omitempty" json:"hopGroup,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardHopGroupConfig struct {
|
||||
Hops []*ForwardHopConfig `yaml:",omitempty" json:"hops,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardHopConfig struct {
|
||||
Hop string `yaml:",omitempty" json:"hop,omitempty"`
|
||||
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardNodeConfig struct {
|
||||
@@ -384,6 +445,7 @@ type ForwardNodeConfig struct {
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
|
||||
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -397,6 +459,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 {
|
||||
@@ -405,9 +470,36 @@ 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"`
|
||||
// BodySize is the max request body prefix (bytes) exposed to BodyRegexp
|
||||
// and BodyJSON matchers. 0 (default) uses DefaultMatcherBodySize (1MB).
|
||||
// Capped at MaxMatcherBodySize (10MB). Only takes effect under an HTTP
|
||||
// sniffing handler whose sniffer reads the body prefix before node selection.
|
||||
BodySize int `yaml:",omitempty" json:"bodySize,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeConfig holds the configuration for a node-level liveness probe.
|
||||
type ProbeConfig struct {
|
||||
Type string `json:"type"`
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
HTTPPath string `yaml:"httpPath,omitempty" json:"httpPath,omitempty"`
|
||||
HTTPHost string `yaml:"httpHost,omitempty" json:"httpHost,omitempty"`
|
||||
HTTPHeaders map[string]string `yaml:"httpHeaders,omitempty" json:"httpHeaders,omitempty"`
|
||||
ExpectedStatus int `yaml:"expectedStatus,omitempty" json:"expectedStatus,omitempty"`
|
||||
Command string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPNodeConfig struct {
|
||||
@@ -430,6 +522,9 @@ type HTTPNodeConfig struct {
|
||||
// rewrite response body
|
||||
RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"`
|
||||
|
||||
// comma-separated response status codes marking the node failed, e.g. "429,5xx"
|
||||
FailCodes string `yaml:"failCodes,omitempty" json:"failCodes,omitempty"`
|
||||
|
||||
// HTTP basic auth
|
||||
Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"`
|
||||
}
|
||||
@@ -478,7 +573,9 @@ type ServiceConfig struct {
|
||||
Logger string `yaml:",omitempty" json:"logger,omitempty"`
|
||||
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
|
||||
Observer string `yaml:",omitempty" json:"observer,omitempty"`
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Cache string `yaml:",omitempty" json:"cache,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
|
||||
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
|
||||
@@ -566,9 +663,39 @@ type ChainConfig struct {
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ChainGroupEntry struct {
|
||||
Chain string `yaml:",omitempty" json:"chain,omitempty"`
|
||||
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
}
|
||||
|
||||
// gostDecodeHook is a composite mapstructure decode hook that handles custom
|
||||
// type conversions needed by GOST config fields. viper's internal unmarshalling
|
||||
// never calls yaml.Unmarshaler / json.Unmarshaler on leaf types, so the hook is
|
||||
// the only way to support these.
|
||||
func gostDecodeHook() mapstructure.DecodeHookFunc {
|
||||
return func(f, t reflect.Type, data any) (any, error) {
|
||||
// Plain string → ChainGroupEntry: backward compat for "chains: [a, b]".
|
||||
if t == reflect.TypeOf(ChainGroupEntry{}) {
|
||||
if s, ok := data.(string); ok {
|
||||
return ChainGroupEntry{Chain: s}, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
// String → time.Duration: accept "10s", "1m", etc. in YAML/JSON.
|
||||
if t == reflect.TypeOf(time.Duration(0)) {
|
||||
if s, ok := data.(string); ok {
|
||||
return time.ParseDuration(s)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
type ChainGroupConfig struct {
|
||||
Chains []string `yaml:",omitempty" json:"chains,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
Chains []*ChainGroupEntry `yaml:",omitempty" json:"chains,omitempty"`
|
||||
Selector *SelectorConfig `yaml:",omitempty" json:"selector,omitempty"`
|
||||
}
|
||||
|
||||
type HopConfig struct {
|
||||
@@ -612,6 +739,7 @@ type NodeConfig struct {
|
||||
Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"`
|
||||
HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"`
|
||||
TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Probe *ProbeConfig `yaml:",omitempty" json:"probe,omitempty"`
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -628,6 +756,8 @@ 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"`
|
||||
Caches []*CacheConfig `yaml:",omitempty" json:"caches,omitempty"`
|
||||
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
|
||||
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
|
||||
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
|
||||
@@ -646,7 +776,7 @@ func (c *Config) Load() error {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.Unmarshal(c)
|
||||
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
|
||||
}
|
||||
|
||||
func (c *Config) Read(r io.Reader, configType string) error {
|
||||
@@ -655,7 +785,7 @@ func (c *Config) Read(r io.Reader, configType string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.Unmarshal(c)
|
||||
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
|
||||
}
|
||||
|
||||
func (c *Config) ReadFile(file string) error {
|
||||
@@ -664,7 +794,8 @@ func (c *Config) ReadFile(file string) error {
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.Unmarshal(c)
|
||||
configFile.Store(file)
|
||||
return v.Unmarshal(c, viper.DecodeHook(gostDecodeHook()))
|
||||
}
|
||||
|
||||
func (c *Config) Write(w io.Writer, format string) error {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-gost/core/admission"
|
||||
"github.com/go-gost/core/auth"
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/hosts"
|
||||
@@ -22,12 +23,14 @@ 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"
|
||||
admission_parser "github.com/go-gost/x/config/parsing/admission"
|
||||
auth_parser "github.com/go-gost/x/config/parsing/auth"
|
||||
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
|
||||
cache_parser "github.com/go-gost/x/config/parsing/cache"
|
||||
chain_parser "github.com/go-gost/x/config/parsing/chain"
|
||||
hop_parser "github.com/go-gost/x/config/parsing/hop"
|
||||
hosts_parser "github.com/go-gost/x/config/parsing/hosts"
|
||||
@@ -38,6 +41,7 @@ import (
|
||||
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"
|
||||
@@ -244,6 +248,26 @@ 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[cache.Cache]
|
||||
for _, c := range cfg.Caches {
|
||||
entries = append(entries, named[cache.Cache]{c.Name, cache_parser.ParseCache(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.CacheRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[traffic.TrafficLimiter]
|
||||
for _, c := range cfg.Limiters {
|
||||
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/cache"
|
||||
"github.com/go-gost/core/logger"
|
||||
xcache "github.com/go-gost/x/cache"
|
||||
"github.com/go-gost/x/config"
|
||||
)
|
||||
|
||||
// ParseCache converts a CacheConfig into a cache.Cache. Memory and Redis
|
||||
// backends are supported. Returns nil when cfg is nil or no backend is
|
||||
// configured.
|
||||
func ParseCache(cfg *config.CacheConfig) cache.Cache {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Memory != nil {
|
||||
return xcache.NewMemoryCache(xcache.Options{
|
||||
DefaultTTL: cfg.Memory.TTL,
|
||||
MaxSize: cfg.Memory.MaxSize,
|
||||
MaxBytes: cfg.Memory.MaxBytes,
|
||||
CleanupInterval: cfg.Memory.CleanupInterval,
|
||||
Eviction: xcache.EvictionPolicy(strings.ToLower(cfg.Memory.Eviction)),
|
||||
Logger: logger.Default(),
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Redis != nil {
|
||||
return xcache.NewRedisCache(xcache.RedisOptions{
|
||||
Addr: cfg.Redis.Addr,
|
||||
DB: cfg.Redis.DB,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DefaultTTL: cfg.Redis.TTL,
|
||||
KeyPrefix: cfg.Redis.Key,
|
||||
Logger: logger.Default(),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
xhop "github.com/go-gost/x/hop"
|
||||
hop_plugin "github.com/go-gost/x/hop/plugin"
|
||||
xs "github.com/go-gost/x/selector"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"github.com/go-gost/x/metadata"
|
||||
@@ -158,10 +159,21 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
sel = selector_parser.DefaultNodeSelector()
|
||||
}
|
||||
|
||||
maxFails, timeout := xs.DefaultMaxFails, xs.DefaultFailTimeout
|
||||
if cfg.Selector != nil {
|
||||
if cfg.Selector.MaxFails > 0 {
|
||||
maxFails = cfg.Selector.MaxFails
|
||||
}
|
||||
if cfg.Selector.FailTimeout > 0 {
|
||||
timeout = cfg.Selector.FailTimeout
|
||||
}
|
||||
}
|
||||
|
||||
opts := []xhop.Option{
|
||||
xhop.NameOption(cfg.Name),
|
||||
xhop.NodeOption(nodes...),
|
||||
xhop.SelectorOption(sel),
|
||||
xhop.FailFilterSettingsOption(maxFails, timeout),
|
||||
xhop.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
xhop.ReloadPeriodOption(cfg.Reload),
|
||||
xhop.LoggerOption(log.WithFields(map[string]any{
|
||||
|
||||
+228
-59
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
@@ -24,11 +26,123 @@ import (
|
||||
"github.com/go-gost/x/routing"
|
||||
)
|
||||
|
||||
// DefaultMatcherBodySize is the default request body prefix (in bytes) exposed
|
||||
// to body matchers when a node has BodyRegexp/BodyJSON matchers but no
|
||||
// explicit bodySize.
|
||||
const DefaultMatcherBodySize = 1 << 20 // 1MB
|
||||
|
||||
// MaxMatcherBodySize is the hard cap for matcher body prefix. Values above
|
||||
// this are silently clamped. Protects against unbounded in-memory buffering
|
||||
// when a node opts in to body matching.
|
||||
const MaxMatcherBodySize = 10 << 20 // 10MB
|
||||
|
||||
// filterToMatcherRule converts a deprecated NodeFilterConfig (host/protocol/
|
||||
// path) into an equivalent matcher DSL rule. Empty fields are omitted; an
|
||||
// empty result means the node is unconditional. Path maps to PathPrefix
|
||||
// (the legacy filter is a prefix match, not exact).
|
||||
func filterToMatcherRule(filter *config.NodeFilterConfig) string {
|
||||
if filter == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
// Normalize the legacy host forms first: `*.example.com` and
|
||||
// `.example.com` both meant "apex AND subdomains" in the legacy filter.
|
||||
// Strip the wildcard so the leading-dot branch below handles both.
|
||||
host := filter.Host
|
||||
if strings.HasPrefix(host, "*") {
|
||||
host = host[1:]
|
||||
if !strings.HasPrefix(host, ".") {
|
||||
host = "." + host
|
||||
}
|
||||
}
|
||||
if host != "" {
|
||||
// A leading-dot host matches the apex AND its subdomains in the
|
||||
// legacy filter (HasSuffix on the dot-stripped form), but matcher
|
||||
// Host(.x) only matches subdomains. Emit both to preserve apex
|
||||
// coverage. Parenthesize the OR when other conjuncts follow, since
|
||||
// && binds tighter than || in the matcher DSL.
|
||||
if strings.HasPrefix(host, ".") {
|
||||
h := "Host(`" + host[1:] + "`) || Host(`" + host + "`)"
|
||||
if filter.Protocol != "" || filter.Path != "" {
|
||||
h = "(" + h + ")"
|
||||
}
|
||||
parts = append(parts, h)
|
||||
} else {
|
||||
parts = append(parts, "Host(`"+host+"`)")
|
||||
}
|
||||
}
|
||||
if prot := filter.Protocol; prot != "" {
|
||||
parts = append(parts, "Proto(`"+prot+"`)")
|
||||
}
|
||||
if path := filter.Path; path != "" {
|
||||
parts = append(parts, "PathPrefix(`"+path+"`)")
|
||||
}
|
||||
return strings.Join(parts, " && ")
|
||||
}
|
||||
|
||||
// ParseNode converts a NodeConfig into a *chain.Node. It resolves the
|
||||
// connector and dialer from their registries, applies TLS settings, extracts
|
||||
// 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 {
|
||||
var pattern *regexp.Regexp
|
||||
var rewriteType string
|
||||
|
||||
js, ok := strings.CutPrefix(v.Match, "json:")
|
||||
del := false
|
||||
if !ok {
|
||||
// json-:<path> deletes the matched JSON field instead of replacing it.
|
||||
if js, ok = strings.CutPrefix(v.Match, "json-:"); ok {
|
||||
del = true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
// json:<path>[=<value-regex>] or json-:<path>[=<value-regex>]
|
||||
path, valRegex, _ := strings.Cut(js, "=")
|
||||
if valRegex == "" {
|
||||
valRegex = ".*"
|
||||
}
|
||||
var err error
|
||||
pattern, err = regexp.Compile(valRegex)
|
||||
if err != nil {
|
||||
log.Warnf("invalid JSON value regex %q for path %q: %v", valRegex, path, err)
|
||||
continue
|
||||
}
|
||||
if del {
|
||||
rewriteType = "json-:" + path
|
||||
} else {
|
||||
rewriteType = "json:" + path
|
||||
}
|
||||
} else {
|
||||
if v.Match != "" {
|
||||
pattern, _ = regexp.Compile(v.Match)
|
||||
}
|
||||
rewriteType = v.Type
|
||||
}
|
||||
|
||||
rw := chain.HTTPBodyRewriteSettings{
|
||||
Type: rewriteType,
|
||||
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
|
||||
@@ -151,44 +265,53 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
chain.NetworkNodeOption(cfg.Network),
|
||||
}
|
||||
|
||||
if filter := cfg.Filter; filter != nil {
|
||||
// convert *.example.com to .example.com
|
||||
// convert *example.com to example.com
|
||||
host := filter.Host
|
||||
if strings.HasPrefix(host, "*") {
|
||||
host = host[1:]
|
||||
if !strings.HasPrefix(host, ".") {
|
||||
host = "." + host
|
||||
}
|
||||
}
|
||||
var (
|
||||
rule string
|
||||
priority int
|
||||
bodySize int
|
||||
)
|
||||
|
||||
settings := &chain.NodeFilterSettings{
|
||||
Protocol: filter.Protocol,
|
||||
Host: host,
|
||||
Path: filter.Path,
|
||||
}
|
||||
opts = append(opts, chain.NodeFilterOption(settings))
|
||||
// Deprecated filter: normalize into an equivalent matcher DSL rule so the
|
||||
// runtime keeps a single filtering logic. Explicit -1 priority opts out of
|
||||
// auto-priority and the priority short-circuit, preserving the legacy
|
||||
// filter behavior (always through the selector).
|
||||
if filter := cfg.Filter; filter != nil {
|
||||
rule = filterToMatcherRule(filter)
|
||||
priority = -1
|
||||
bodySize = DefaultMatcherBodySize
|
||||
}
|
||||
|
||||
if cfg.Matcher != nil {
|
||||
priority := cfg.Matcher.Priority
|
||||
|
||||
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)
|
||||
if priority == 0 {
|
||||
priority = len(cfg.Matcher.Rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
opts = append(opts, chain.PriorityNodeOption(priority))
|
||||
priority = cfg.Matcher.Priority
|
||||
rule = cfg.Matcher.Rule
|
||||
bodySize = cfg.Matcher.BodySize
|
||||
}
|
||||
|
||||
if rule = strings.TrimSpace(rule); rule != "" {
|
||||
if matcher, err := routing.NewMatcher(rule); err == nil {
|
||||
log.Debugf("new matcher for node %s with rule %s", cfg.Name, 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(rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
if bodySize <= 0 {
|
||||
bodySize = DefaultMatcherBodySize
|
||||
} else if bodySize > MaxMatcherBodySize {
|
||||
bodySize = MaxMatcherBodySize
|
||||
}
|
||||
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
|
||||
opts = append(opts, chain.PriorityNodeOption(priority))
|
||||
|
||||
if cfg.HTTP != nil {
|
||||
settings := &chain.HTTPNodeSettings{
|
||||
Host: cfg.HTTP.Host,
|
||||
@@ -222,33 +345,14 @@ 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)...)
|
||||
|
||||
if v := strings.TrimSpace(cfg.HTTP.FailCodes); v != "" {
|
||||
settings.FailCodes = parseFailCodes(v, nodeLogger)
|
||||
}
|
||||
|
||||
opts = append(opts, chain.HTTPNodeOption(settings))
|
||||
}
|
||||
|
||||
@@ -265,5 +369,70 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
}
|
||||
opts = append(opts, chain.TLSNodeOption(tlsCfg))
|
||||
}
|
||||
return chain.NewNode(cfg.Name, cfg.Addr, opts...), nil
|
||||
|
||||
node := chain.NewNode(cfg.Name, cfg.Addr, opts...)
|
||||
if cfg.Probe != nil {
|
||||
if pc := ParseProbeConfig(cfg.Probe); pc != nil {
|
||||
xchain.StartNodeProbe(node, pc, nodeLogger)
|
||||
}
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// ParseProbeConfig converts a config.ProbeConfig into a chain.ProbeConfig.
|
||||
// Returns nil when the config is invalid (e.g. empty addr).
|
||||
func ParseProbeConfig(cfg *config.ProbeConfig) *chain.ProbeConfig {
|
||||
if cfg == nil || (cfg.Addr == "" && cfg.Type != "cmd") {
|
||||
return nil
|
||||
}
|
||||
if cfg.Type == "cmd" && cfg.Command == "" {
|
||||
return nil
|
||||
}
|
||||
pt := chain.ProbeTypeTCP
|
||||
switch cfg.Type {
|
||||
case "http":
|
||||
pt = chain.ProbeTypeHTTP
|
||||
case "cmd":
|
||||
pt = chain.ProbeTypeCmd
|
||||
}
|
||||
interval := cfg.Interval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &chain.ProbeConfig{
|
||||
Type: pt,
|
||||
Addr: cfg.Addr,
|
||||
Interval: interval,
|
||||
Timeout: timeout,
|
||||
HTTPPath: cfg.HTTPPath,
|
||||
HTTPHost: cfg.HTTPHost,
|
||||
HTTPHeaders: cfg.HTTPHeaders,
|
||||
ExpectedStatus: cfg.ExpectedStatus,
|
||||
Command: cfg.Command,
|
||||
}
|
||||
}
|
||||
|
||||
// parseFailCodes parses a comma-separated status code list, e.g. "429,5xx".
|
||||
// Tokens ending in "xx" become hundred-level wildcards (5xx → 5, matching
|
||||
// 500-599). Invalid tokens are logged and skipped.
|
||||
func parseFailCodes(s string, log logger.Logger) chain.FailCodes {
|
||||
var codes chain.FailCodes
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) == 3 && strings.HasSuffix(part, "xx") {
|
||||
if prefix, err := strconv.Atoi(part[:1]); err == nil && prefix > 0 {
|
||||
codes = append(codes, prefix) // < 100 → wildcard
|
||||
continue
|
||||
}
|
||||
} else if code, err := strconv.Atoi(part); err == nil && code >= 100 {
|
||||
codes = append(codes, code)
|
||||
continue
|
||||
}
|
||||
log.Warnf("failCodes: invalid token %q", part)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
@@ -132,9 +132,9 @@ func TestParseNode_ExplicitDialer(t *testing.T) {
|
||||
|
||||
func TestParseNode_WithBypass(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "bypass-node",
|
||||
Addr: "example.com:8080",
|
||||
Bypass: "bp1",
|
||||
Name: "bypass-node",
|
||||
Addr: "example.com:8080",
|
||||
Bypass: "bp1",
|
||||
Bypasses: []string{"bp2", "bp3"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
@@ -291,6 +291,48 @@ func TestParseNode_WithMatcherAutoPriority(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcherBodySize(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "body-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
// Escaped predicate form; in YAML users write the backtick
|
||||
// equivalent: BodyRegexp(`"model":"gpt-4"`).
|
||||
Rule: `BodyRegexp("\"model\":\"gpt-4\"")`,
|
||||
BodySize: 65536,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
if got := n.Options().MatcherBodySize; got != 65536 {
|
||||
t.Errorf("MatcherBodySize = %d, want 65536", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcherBodySize_Clamped(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "body-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
Rule: `BodyRegexp("foo")`,
|
||||
BodySize: 10 * 1024 * 1024, // 10MB, above the 1MB cap
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
if got := n.Options().MatcherBodySize; got != MaxMatcherBodySize {
|
||||
t.Errorf("MatcherBodySize = %d, want %d (clamped)", got, MaxMatcherBodySize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHTTPNode(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "http-node",
|
||||
@@ -459,3 +501,38 @@ func TestParseNode_DeprecatedFilterFields(t *testing.T) {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterToMatcherRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filter *config.NodeFilterConfig
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"all empty", &config.NodeFilterConfig{}, ""},
|
||||
{"host only", &config.NodeFilterConfig{Host: "example.com"}, "Host(`example.com`)"},
|
||||
{"dot host", &config.NodeFilterConfig{Host: ".example.com"},
|
||||
"Host(`example.com`) || Host(`.example.com`)"},
|
||||
{"star host", &config.NodeFilterConfig{Host: "*.example.com"},
|
||||
"Host(`example.com`) || Host(`.example.com`)"},
|
||||
{"protocol only", &config.NodeFilterConfig{Protocol: "ssh"}, "Proto(`ssh`)"},
|
||||
{"path only", &config.NodeFilterConfig{Path: "/api"}, "PathPrefix(`/api`)"},
|
||||
{"combined", &config.NodeFilterConfig{
|
||||
Host: ".example.com",
|
||||
Protocol: "http",
|
||||
Path: "/api",
|
||||
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
|
||||
{"star combined", &config.NodeFilterConfig{
|
||||
Host: "*.example.com",
|
||||
Protocol: "http",
|
||||
Path: "/api",
|
||||
}, "(Host(`example.com`) || Host(`.example.com`)) && Proto(`http`) && PathPrefix(`/api`)"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := filterToMatcherRule(tt.filter); got != tt.want {
|
||||
t.Errorf("filterToMatcherRule(%v) = %q, want %q", tt.filter, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
coreRouting "github.com/go-gost/core/routing"
|
||||
"github.com/go-gost/x/routing"
|
||||
)
|
||||
|
||||
func TestFilterToMatcherRule_Precedence(t *testing.T) {
|
||||
for _, fc := range []struct{ name, host string }{
|
||||
{"dot", ".example.com"},
|
||||
{"star", "*.example.com"},
|
||||
} {
|
||||
t.Run(fc.name, func(t *testing.T) {
|
||||
rule := filterToMatcherRule(&config.NodeFilterConfig{
|
||||
Host: fc.host, Protocol: "http", Path: "/api",
|
||||
})
|
||||
m, err := routing.NewMatcher(rule)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", rule, err)
|
||||
}
|
||||
if m.Match(&coreRouting.Request{Host: "example.com", Protocol: "https", Path: "/other"}) {
|
||||
t.Errorf("rule %q matched apex+https+/other (should reject)", rule)
|
||||
}
|
||||
if !m.Match(&coreRouting.Request{Host: "example.com", Protocol: "http", Path: "/api/v1"}) {
|
||||
t.Errorf("rule %q rejected apex+http+/api/v1 (should accept)", rule)
|
||||
}
|
||||
if !m.Match(&coreRouting.Request{Host: "www.example.com", Protocol: "http", Path: "/api"}) {
|
||||
t.Errorf("rule %q rejected www+http+/api (should accept)", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,8 @@ 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...),
|
||||
Caches: append(cfg1.Caches, cfg2.Caches...),
|
||||
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
|
||||
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
|
||||
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -55,15 +55,21 @@ func ParseNodeSelector(cfg *config.SelectorConfig) selector.Selector[*chain.Node
|
||||
strategy = xs.HashStrategy[*chain.Node]()
|
||||
case "parallel":
|
||||
strategy = xs.ParallelStrategy[*chain.Node]()
|
||||
case "lowestlatency", "lowest", "ll":
|
||||
strategy = xs.LowestLatencyStrategy[*chain.Node]()
|
||||
default:
|
||||
strategy = xs.RoundRobinStrategy[*chain.Node]()
|
||||
}
|
||||
|
||||
return xs.NewSelector(
|
||||
strategy,
|
||||
filters := []selector.Filter[*chain.Node]{
|
||||
xs.FailFilter[*chain.Node](cfg.MaxFails, cfg.FailTimeout),
|
||||
xs.BackupFilter[*chain.Node](),
|
||||
)
|
||||
}
|
||||
if cfg.MaxLatency > 0 {
|
||||
filters = append(filters, xs.LatencyFilter[*chain.Node](cfg.MaxLatency))
|
||||
}
|
||||
|
||||
return xs.NewSelector(strategy, filters...)
|
||||
}
|
||||
|
||||
// DefaultNodeSelector returns a node selector using round-robin strategy with
|
||||
|
||||
+234
-24
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,6 +12,8 @@ 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/routing"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
@@ -27,18 +30,38 @@ import (
|
||||
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
|
||||
hop_parser "github.com/go-gost/x/config/parsing/hop"
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
node_parser "github.com/go-gost/x/config/parsing/node"
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
xhop "github.com/go-gost/x/hop"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xrouting "github.com/go-gost/x/routing"
|
||||
xs "github.com/go-gost/x/selector"
|
||||
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,
|
||||
@@ -88,6 +111,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 {
|
||||
@@ -177,13 +205,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
if !ignoreChain {
|
||||
routerOpts = append(routerOpts,
|
||||
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup)),
|
||||
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup, log)),
|
||||
)
|
||||
}
|
||||
lnRouter := xchain.NewRouter(routerOpts...)
|
||||
|
||||
// The routers may hold a chainGroup running probe goroutines, and the
|
||||
// forwarder hop may run probe goroutines of its own; make sure they are
|
||||
// closed if service construction fails after this point.
|
||||
var hRouter *xchain.Router
|
||||
var fwdCloser io.Closer
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
lnRouter.Close()
|
||||
if hRouter != nil {
|
||||
hRouter.Close()
|
||||
}
|
||||
if fwdCloser != nil {
|
||||
fwdCloser.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
listenOpts := []listener.Option{
|
||||
listener.AddrOption(cfg.Addr),
|
||||
listener.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
listener.RouterOption(lnRouter),
|
||||
listener.AutherOption(auther),
|
||||
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
|
||||
listener.TLSConfigOption(tlsConfig),
|
||||
@@ -282,11 +329,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),
|
||||
@@ -295,9 +375,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),
|
||||
@@ -311,14 +400,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
if !ignoreChain {
|
||||
routerOpts = append(routerOpts,
|
||||
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup)),
|
||||
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup, log)),
|
||||
)
|
||||
}
|
||||
|
||||
var h handler.Handler
|
||||
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
|
||||
hRouter = xchain.NewRouter(routerOpts...)
|
||||
h = rf(
|
||||
handler.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
handler.RouterOption(hRouter),
|
||||
handler.AutherOption(auther),
|
||||
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
|
||||
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
@@ -327,6 +417,8 @@ 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.CacheOption(registry.CacheRegistry().Get(cfg.Cache)),
|
||||
handler.RewriterOption(rew),
|
||||
handler.LoggerOption(handlerLogger),
|
||||
handler.ServiceOption(cfg.Name),
|
||||
handler.NetnsOption(netnsIn),
|
||||
@@ -336,10 +428,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
|
||||
if forwarder, ok := h.(handler.Forwarder); ok {
|
||||
hop, err := parseForwarder(cfg.Forwarder, log)
|
||||
hop, closer, err := parseForwarder(cfg.Forwarder, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fwdCloser = closer
|
||||
forwarder.Forward(hop)
|
||||
}
|
||||
|
||||
@@ -352,6 +445,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
closers := []io.Closer{lnRouter, hRouter}
|
||||
if fwdCloser != nil {
|
||||
closers = append(closers, fwdCloser)
|
||||
}
|
||||
s := xservice.NewService(cfg.Name, ln, h,
|
||||
xservice.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
|
||||
xservice.PreUpOption(preUp),
|
||||
@@ -364,15 +461,31 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
xservice.ObserverPeriodOption(observerPeriod),
|
||||
xservice.LoggerOption(serviceLogger),
|
||||
xservice.LabelsOption(labels),
|
||||
xservice.ClosersOption(closers...),
|
||||
)
|
||||
success = true
|
||||
|
||||
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, error) {
|
||||
// parseForwarder builds the forwarder hop for a service. The returned
|
||||
// io.Closer is non-nil only when the hop is owned by the service (inline
|
||||
// nodes or a hop group) and must be closed on service shutdown; hops from
|
||||
// the HopRegistry are closed by the registry on unregister.
|
||||
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, io.Closer, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// HopGroup takes precedence over Hop / Name / Nodes.
|
||||
if cfg.HopGroup != nil {
|
||||
group, err := parseHopGroup(cfg.HopGroup, log)
|
||||
if err != nil || group == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
closer, _ := group.(io.Closer)
|
||||
return group, closer, nil
|
||||
}
|
||||
|
||||
hopName := cfg.Hop
|
||||
@@ -380,7 +493,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
|
||||
hopName = cfg.Name
|
||||
}
|
||||
if hopName != "" {
|
||||
return registry.HopRegistry().Get(hopName), nil
|
||||
return registry.HopRegistry().Get(hopName), nil, nil
|
||||
}
|
||||
|
||||
hc := config.HopConfig{
|
||||
@@ -422,35 +535,132 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
|
||||
Matcher: node.Matcher,
|
||||
HTTP: httpCfg,
|
||||
TLS: node.TLS,
|
||||
Probe: node.Probe,
|
||||
Metadata: node.Metadata,
|
||||
})
|
||||
}
|
||||
return hop_parser.ParseHop(&hc, log)
|
||||
// Inline nodes build a per-service hop, owned (and closed) by the service.
|
||||
h, err := hop_parser.ParseHop(&hc, log)
|
||||
if err != nil || h == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
closer, _ := h.(io.Closer)
|
||||
return h, closer, nil
|
||||
}
|
||||
|
||||
func chainGroup(name string, group *config.ChainGroupConfig) chain.Chainer {
|
||||
var chains []chain.Chainer
|
||||
var sel selector.Selector[chain.Chainer]
|
||||
|
||||
if c := registry.ChainRegistry().Get(name); c != nil {
|
||||
chains = append(chains, c)
|
||||
func parseHopGroup(cfg *config.ForwardHopGroupConfig, log logger.Logger) (hop.Hop, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if group != nil {
|
||||
for _, s := range group.Chains {
|
||||
if c := registry.ChainRegistry().Get(s); c != nil {
|
||||
chains = append(chains, c)
|
||||
|
||||
var entries []*xhop.HopEntry
|
||||
for _, hc := range cfg.Hops {
|
||||
if hc == nil {
|
||||
continue
|
||||
}
|
||||
h := registry.HopRegistry().Get(hc.Hop)
|
||||
if h == nil {
|
||||
log.Warnf("hop %q not found in hopGroup, skipping", hc.Hop)
|
||||
continue
|
||||
}
|
||||
|
||||
var m routing.Matcher
|
||||
if hc.Matcher != nil && hc.Matcher.Rule != "" {
|
||||
var err error
|
||||
m, err = xrouting.NewMatcher(hc.Matcher.Rule)
|
||||
if err != nil {
|
||||
log.Warnf("hop %q: bad matcher rule %q: %v, skipping", hc.Hop, hc.Matcher.Rule, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sel = selector_parser.ParseChainSelector(group.Selector)
|
||||
|
||||
entries = append(entries, xhop.NewHopEntry(h, m, node_parser.ParseProbeConfig(hc.Probe)))
|
||||
}
|
||||
if len(chains) == 0 {
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var hopSelector selector.Selector[hop.Hop]
|
||||
if cfg.Selector != nil {
|
||||
var strategy selector.Strategy[hop.Hop]
|
||||
switch cfg.Selector.Strategy {
|
||||
case "round", "rr":
|
||||
strategy = xs.RoundRobinStrategy[hop.Hop]()
|
||||
case "random", "rand":
|
||||
strategy = xs.RandomStrategy[hop.Hop]()
|
||||
case "fifo", "ha":
|
||||
strategy = xs.FIFOStrategy[hop.Hop]()
|
||||
case "hash":
|
||||
strategy = xs.HashStrategy[hop.Hop]()
|
||||
default:
|
||||
strategy = xs.RoundRobinStrategy[hop.Hop]()
|
||||
}
|
||||
hopSelector = xs.NewSelector(
|
||||
strategy,
|
||||
xs.FailFilter[hop.Hop](cfg.Selector.MaxFails, cfg.Selector.FailTimeout),
|
||||
xs.BackupFilter[hop.Hop](),
|
||||
)
|
||||
}
|
||||
if hopSelector == nil {
|
||||
hopSelector = xs.NewSelector(
|
||||
xs.RoundRobinStrategy[hop.Hop](),
|
||||
xs.FailFilter[hop.Hop](xs.DefaultMaxFails, xs.DefaultFailTimeout),
|
||||
xs.BackupFilter[hop.Hop](),
|
||||
)
|
||||
}
|
||||
|
||||
return xhop.NewHopGroup(
|
||||
xhop.WithEntriesOption(entries...),
|
||||
xhop.WithGroupSelectorOption(hopSelector),
|
||||
xhop.WithGroupLoggerOption(log.WithFields(map[string]any{
|
||||
"kind": "hopGroup",
|
||||
"service": "forwarder",
|
||||
})),
|
||||
), nil
|
||||
}
|
||||
|
||||
func chainGroup(name string, group *config.ChainGroupConfig, log logger.Logger) chain.Chainer {
|
||||
var entries []*xchain.ChainEntry
|
||||
|
||||
if c := registry.ChainRegistry().Get(name); c != nil {
|
||||
entries = append(entries, xchain.NewChainEntry(c, nil, nil))
|
||||
}
|
||||
if group != nil {
|
||||
for _, ge := range group.Chains {
|
||||
c := registry.ChainRegistry().Get(ge.Chain)
|
||||
if c == nil {
|
||||
log.Warnf("chain %q not found in chainGroup, skipping", ge.Chain)
|
||||
continue
|
||||
}
|
||||
|
||||
var m routing.Matcher
|
||||
if ge.Matcher != nil && ge.Matcher.Rule != "" {
|
||||
var err error
|
||||
m, err = xrouting.NewMatcher(ge.Matcher.Rule)
|
||||
if err != nil {
|
||||
log.Warnf("chain %q: bad matcher rule %q: %v, skipping", ge.Chain, ge.Matcher.Rule, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, xchain.NewChainEntry(c, m, node_parser.ParseProbeConfig(ge.Probe)))
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sel selector.Selector[chain.Chainer]
|
||||
if group != nil {
|
||||
sel = selector_parser.ParseChainSelector(group.Selector)
|
||||
}
|
||||
if sel == nil {
|
||||
sel = selector_parser.DefaultChainSelector()
|
||||
}
|
||||
|
||||
return xchain.NewChainGroup(chains...).
|
||||
WithSelector(sel)
|
||||
return xchain.NewChainGroup().
|
||||
WithGroupEntries(entries...).
|
||||
WithSelector(sel).
|
||||
WithGroupLogger(log)
|
||||
}
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package masque
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
)
|
||||
|
||||
const udpAssociationBufferSize = 64 * 1024
|
||||
|
||||
var errMissingDestination = errors.New("masque: destination address required")
|
||||
|
||||
type udpAssociationDialFunc func(ctx context.Context, addr net.Addr) (net.PacketConn, error)
|
||||
|
||||
type udpAssociationResult struct {
|
||||
data []byte
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
type udpAssociationTunnel struct {
|
||||
conn net.PacketConn
|
||||
}
|
||||
|
||||
type udpAssociationConn struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
localAddr net.Addr
|
||||
dial udpAssociationDialFunc
|
||||
closeIdle func() error
|
||||
log logger.Logger
|
||||
timeout time.Duration
|
||||
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
results chan udpAssociationResult
|
||||
|
||||
mu sync.Mutex
|
||||
tunnels map[string]*udpAssociationTunnel
|
||||
readDeadline time.Time
|
||||
readDeadlineChanged chan struct{}
|
||||
}
|
||||
|
||||
func newUDPAssociationConn(
|
||||
ctx context.Context,
|
||||
localAddr net.Addr,
|
||||
timeout time.Duration,
|
||||
dial udpAssociationDialFunc,
|
||||
closeIdle func() error,
|
||||
log logger.Logger,
|
||||
) *udpAssociationConn {
|
||||
ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||
c := &udpAssociationConn{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
localAddr: localAddr,
|
||||
dial: dial,
|
||||
closeIdle: closeIdle,
|
||||
log: log,
|
||||
timeout: timeout,
|
||||
closed: make(chan struct{}),
|
||||
results: make(chan udpAssociationResult, 32),
|
||||
tunnels: make(map[string]*udpAssociationTunnel),
|
||||
readDeadlineChanged: make(chan struct{}),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
for {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, nil, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
deadline := c.readDeadline
|
||||
deadlineChanged := c.readDeadlineChanged
|
||||
c.mu.Unlock()
|
||||
|
||||
var (
|
||||
timer *time.Timer
|
||||
timeout <-chan time.Time
|
||||
)
|
||||
if !deadline.IsZero() {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return 0, nil, os.ErrDeadlineExceeded
|
||||
}
|
||||
timer = time.NewTimer(remaining)
|
||||
timeout = timer.C
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-c.results:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return copy(b, result.data), result.addr, nil
|
||||
case <-deadlineChanged:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
continue
|
||||
case <-timeout:
|
||||
return 0, nil, os.ErrDeadlineExceeded
|
||||
case <-c.closed:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return 0, nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
if addr == nil {
|
||||
return 0, errMissingDestination
|
||||
}
|
||||
|
||||
tunnel, err := c.tunnel(addr)
|
||||
if err != nil {
|
||||
if c.isClosed() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
c.logError(addr, err)
|
||||
return len(b), nil
|
||||
}
|
||||
n, err := tunnel.conn.WriteTo(b, addr)
|
||||
if err == nil {
|
||||
return n, nil
|
||||
}
|
||||
if c.isClosed() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
if c.removeTunnel(addr.String(), tunnel) {
|
||||
c.logError(addr, err)
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) tunnel(addr net.Addr) (*udpAssociationTunnel, error) {
|
||||
key := addr.String()
|
||||
|
||||
c.mu.Lock()
|
||||
select {
|
||||
case <-c.closed:
|
||||
c.mu.Unlock()
|
||||
return nil, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
|
||||
if tunnel := c.tunnels[key]; tunnel != nil {
|
||||
c.mu.Unlock()
|
||||
return tunnel, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
ctx := c.ctx
|
||||
if c.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
conn, err := c.dial(ctx, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conn == nil {
|
||||
return nil, errors.New("masque: nil UDP tunnel")
|
||||
}
|
||||
|
||||
tunnel := &udpAssociationTunnel{conn: conn}
|
||||
c.mu.Lock()
|
||||
select {
|
||||
case <-c.closed:
|
||||
c.mu.Unlock()
|
||||
conn.Close()
|
||||
return nil, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
if current := c.tunnels[key]; current != nil {
|
||||
c.mu.Unlock()
|
||||
conn.Close()
|
||||
return current, nil
|
||||
}
|
||||
c.tunnels[key] = tunnel
|
||||
c.mu.Unlock()
|
||||
go c.readTunnel(key, tunnel)
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) readTunnel(key string, tunnel *udpAssociationTunnel) {
|
||||
buf := make([]byte, udpAssociationBufferSize)
|
||||
for {
|
||||
n, addr, err := tunnel.conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if c.isClosed() {
|
||||
return
|
||||
}
|
||||
if c.removeTunnel(key, tunnel) {
|
||||
c.logError(key, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data := append([]byte(nil), buf[:n]...)
|
||||
select {
|
||||
case c.results <- udpAssociationResult{data: data, addr: addr}:
|
||||
case <-c.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) isClosed() bool {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) logError(addr any, err error) {
|
||||
if c.log != nil {
|
||||
c.log.Warnf("masque: UDP tunnel %v: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) removeTunnel(key string, tunnel *udpAssociationTunnel) bool {
|
||||
c.mu.Lock()
|
||||
removed := c.tunnels[key] == tunnel
|
||||
if c.tunnels[key] == tunnel {
|
||||
delete(c.tunnels, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
tunnel.conn.Close()
|
||||
return removed
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) Read(b []byte) (int, error) {
|
||||
n, _, err := c.ReadFrom(b)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) Write(b []byte) (int, error) {
|
||||
return 0, errMissingDestination
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.closed)
|
||||
c.cancel()
|
||||
|
||||
c.mu.Lock()
|
||||
close(c.readDeadlineChanged)
|
||||
tunnels := make([]*udpAssociationTunnel, 0, len(c.tunnels))
|
||||
for _, tunnel := range c.tunnels {
|
||||
tunnels = append(tunnels, tunnel)
|
||||
}
|
||||
clear(c.tunnels)
|
||||
c.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
for _, tunnel := range tunnels {
|
||||
if err := tunnel.conn.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if c.closeIdle != nil {
|
||||
if err := c.closeIdle(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
c.closeErr = errors.Join(errs...)
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) LocalAddr() net.Addr {
|
||||
return c.localAddr
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) RemoteAddr() net.Addr {
|
||||
return &net.UDPAddr{}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) SetDeadline(t time.Time) error {
|
||||
if err := c.SetReadDeadline(t); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) SetReadDeadline(t time.Time) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
select {
|
||||
case <-c.closed:
|
||||
return net.ErrClosed
|
||||
default:
|
||||
}
|
||||
c.readDeadline = t
|
||||
close(c.readDeadlineChanged)
|
||||
c.readDeadlineChanged = make(chan struct{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) SetWriteDeadline(t time.Time) error {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return net.ErrClosed
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ net.Conn = (*udpAssociationConn)(nil)
|
||||
_ net.PacketConn = (*udpAssociationConn)(nil)
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
package masque
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUDPAssociationConnRoutesByDestination(t *testing.T) {
|
||||
type dialCall struct {
|
||||
addr net.Addr
|
||||
conn *fakePacketConn
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls []dialCall
|
||||
)
|
||||
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
|
||||
conn := newFakePacketConn()
|
||||
mu.Lock()
|
||||
calls = append(calls, dialCall{addr: addr, conn: conn})
|
||||
mu.Unlock()
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
conn := newUDPAssociationConn(ctx, &net.UDPAddr{}, 0, dial, nil, nil)
|
||||
cancel() // The route dial context ends before the UDP association is used.
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}
|
||||
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 53}
|
||||
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
|
||||
t.Fatalf("first WriteTo failed: %v", err)
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("second"), addr1); err != nil {
|
||||
t.Fatalf("second WriteTo failed: %v", err)
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("third"), addr2); err != nil {
|
||||
t.Fatalf("third WriteTo failed: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
gotCalls := append([]dialCall(nil), calls...)
|
||||
mu.Unlock()
|
||||
if len(gotCalls) != 2 {
|
||||
t.Fatalf("expected one tunnel per destination, got %d", len(gotCalls))
|
||||
}
|
||||
if gotCalls[0].addr.String() != addr1.String() || gotCalls[1].addr.String() != addr2.String() {
|
||||
t.Fatalf("unexpected tunnel destinations: %s, %s", gotCalls[0].addr, gotCalls[1].addr)
|
||||
}
|
||||
if got := gotCalls[0].conn.writtenData(); len(got) != 2 || string(got[0]) != "first" || string(got[1]) != "second" {
|
||||
t.Fatalf("unexpected writes for first destination: %q", got)
|
||||
}
|
||||
if got := gotCalls[1].conn.writtenData(); len(got) != 1 || string(got[0]) != "third" {
|
||||
t.Fatalf("unexpected writes for second destination: %q", got)
|
||||
}
|
||||
|
||||
replyAddr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}
|
||||
gotCalls[1].conn.queueRead([]byte("reply"), replyAddr)
|
||||
buf := make([]byte, 32)
|
||||
n, addr, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrom failed: %v", err)
|
||||
}
|
||||
if string(buf[:n]) != "reply" {
|
||||
t.Fatalf("unexpected reply: %q", buf[:n])
|
||||
}
|
||||
if addr.String() != replyAddr.String() {
|
||||
t.Fatalf("expected reply from %s, got %s", replyAddr, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnClose(t *testing.T) {
|
||||
upstream := newFakePacketConn()
|
||||
idleClosed := 0
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
0,
|
||||
func(context.Context, net.Addr) (net.PacketConn, error) { return upstream, nil },
|
||||
func() error {
|
||||
idleClosed++
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}); err != nil {
|
||||
t.Fatalf("WriteTo failed: %v", err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatalf("second Close failed: %v", err)
|
||||
}
|
||||
if idleClosed != 1 {
|
||||
t.Fatalf("expected idle stream to be closed once, got %d", idleClosed)
|
||||
}
|
||||
if !upstream.isClosed() {
|
||||
t.Fatal("expected destination tunnel to be closed")
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{}); !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("expected net.ErrClosed after Close, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnTunnelFailureIsIsolated(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls []*fakePacketConn
|
||||
)
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
0,
|
||||
func(context.Context, net.Addr) (net.PacketConn, error) {
|
||||
pc := newFakePacketConn()
|
||||
mu.Lock()
|
||||
calls = append(calls, pc)
|
||||
mu.Unlock()
|
||||
return pc, nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443}
|
||||
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 443}
|
||||
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
|
||||
t.Fatalf("first WriteTo failed: %v", err)
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("second"), addr2); err != nil {
|
||||
t.Fatalf("second WriteTo failed: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
first, second := calls[0], calls[1]
|
||||
mu.Unlock()
|
||||
first.queueReadError(errors.New("stream reset"))
|
||||
select {
|
||||
case <-first.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("failed tunnel was not removed")
|
||||
}
|
||||
|
||||
second.queueRead([]byte("reply"), addr2)
|
||||
buf := make([]byte, 32)
|
||||
n, addr, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("healthy tunnel failed after peer reset: %v", err)
|
||||
}
|
||||
if string(buf[:n]) != "reply" || addr.String() != addr2.String() {
|
||||
t.Fatalf("unexpected healthy tunnel reply %q from %v", buf[:n], addr)
|
||||
}
|
||||
|
||||
if _, err := conn.WriteTo([]byte("retry"), addr1); err != nil {
|
||||
t.Fatalf("retry WriteTo failed: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
callCount := len(calls)
|
||||
mu.Unlock()
|
||||
if callCount != 3 {
|
||||
t.Fatalf("expected failed destination to redial, got %d dials", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnDialTimeout(t *testing.T) {
|
||||
dialErr := make(chan error, 1)
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
20*time.Millisecond,
|
||||
func(ctx context.Context, _ net.Addr) (net.PacketConn, error) {
|
||||
<-ctx.Done()
|
||||
dialErr <- ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
payload := []byte("request")
|
||||
n, err := conn.WriteTo(payload, &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
|
||||
if err != nil || n != len(payload) {
|
||||
t.Fatalf("timed out datagram was not dropped cleanly: n=%d err=%v", n, err)
|
||||
}
|
||||
if err := <-dialErr; !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected dial deadline, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnDialDoesNotHoldAssociationLock(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
0,
|
||||
func(context.Context, net.Addr) (net.PacketConn, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return newFakePacketConn(), nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
writeDone := make(chan struct{})
|
||||
go func() {
|
||||
conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
|
||||
close(writeDone)
|
||||
}()
|
||||
<-started
|
||||
|
||||
deadlineDone := make(chan error, 1)
|
||||
go func() {
|
||||
deadlineDone <- conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
}()
|
||||
select {
|
||||
case err := <-deadlineDone:
|
||||
if err != nil {
|
||||
t.Fatalf("SetReadDeadline failed: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dial held the association lock")
|
||||
}
|
||||
|
||||
close(release)
|
||||
<-writeDone
|
||||
}
|
||||
|
||||
type fakePacket struct {
|
||||
data []byte
|
||||
addr net.Addr
|
||||
err error
|
||||
}
|
||||
|
||||
type fakePacketConn struct {
|
||||
reads chan fakePacket
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
mu sync.Mutex
|
||||
writes [][]byte
|
||||
}
|
||||
|
||||
func newFakePacketConn() *fakePacketConn {
|
||||
return &fakePacketConn{
|
||||
reads: make(chan fakePacket, 1),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) queueRead(data []byte, addr net.Addr) {
|
||||
c.reads <- fakePacket{data: append([]byte(nil), data...), addr: addr}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) queueReadError(err error) {
|
||||
c.reads <- fakePacket{err: err}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) writtenData() [][]byte {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make([][]byte, len(c.writes))
|
||||
for i, data := range c.writes {
|
||||
result[i] = append([]byte(nil), data...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) isClosed() bool {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||
select {
|
||||
case packet := <-c.reads:
|
||||
if packet.err != nil {
|
||||
return 0, nil, packet.err
|
||||
}
|
||||
return copy(b, packet.data), packet.addr, nil
|
||||
case <-c.closed:
|
||||
return 0, nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.writes = append(c.writes, append([]byte(nil), b...))
|
||||
c.mu.Unlock()
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) Close() error {
|
||||
c.closeOnce.Do(func() { close(c.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) LocalAddr() net.Addr { return &net.UDPAddr{} }
|
||||
func (c *fakePacketConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *fakePacketConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *fakePacketConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/connector"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
masque_dialer "github.com/go-gost/x/dialer/http3/masque"
|
||||
masque_util "github.com/go-gost/x/internal/util/masque"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -73,6 +75,16 @@ func (c *masqueConnector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedNetwork, network)
|
||||
}
|
||||
|
||||
func newTCPConnectRequest(address string) *http.Request {
|
||||
return &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{},
|
||||
Host: address,
|
||||
Header: http.Header{},
|
||||
ProtoMajor: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address string, log logger.Logger) (net.Conn, error) {
|
||||
log.Debugf("connect-tcp %s", address)
|
||||
|
||||
@@ -103,13 +115,7 @@ func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address
|
||||
|
||||
// Create standard HTTP/3 CONNECT request (RFC 9114)
|
||||
// No :protocol pseudo-header for standard CONNECT
|
||||
req := &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{},
|
||||
Host: address, // Target address goes in :authority
|
||||
Header: http.Header{},
|
||||
Proto: "HTTP/3.0",
|
||||
}
|
||||
req := newTCPConnectRequest(address)
|
||||
|
||||
// Add proxy authentication if configured
|
||||
if c.options.Auth != nil {
|
||||
@@ -172,9 +178,69 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
|
||||
return nil, ErrInvalidConnection
|
||||
}
|
||||
|
||||
// Get pre-opened stream from dialer (stream opening happens there for dead connection detection)
|
||||
reqStream := masqueConn.GetRequestStream()
|
||||
proxyHost := masqueConn.GetHost()
|
||||
if address == "" {
|
||||
return c.connectUDPAssociation(ctx, masqueConn, log), nil
|
||||
}
|
||||
|
||||
return c.connectUDPTarget(
|
||||
ctx,
|
||||
conn.LocalAddr(),
|
||||
masqueConn.GetHost(),
|
||||
masqueConn.GetRequestStream(),
|
||||
address,
|
||||
nil,
|
||||
log,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *masqueConnector) connectUDPAssociation(ctx context.Context, conn *masque_dialer.MasqueConn, log logger.Logger) net.Conn {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
firstStream = conn.GetRequestStream()
|
||||
)
|
||||
|
||||
nextStream := func(ctx context.Context) (*http3.RequestStream, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if firstStream != nil {
|
||||
stream := firstStream
|
||||
firstStream = nil
|
||||
return stream, nil
|
||||
}
|
||||
return conn.OpenRequestStream(ctx)
|
||||
}
|
||||
|
||||
closeIdle := func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if firstStream == nil {
|
||||
return nil
|
||||
}
|
||||
err := firstStream.Close()
|
||||
firstStream = nil
|
||||
return err
|
||||
}
|
||||
|
||||
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
|
||||
stream, err := nextStream(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.connectUDPTarget(ctx, conn.LocalAddr(), conn.GetHost(), stream, addr.String(), addr, log)
|
||||
}
|
||||
|
||||
return newUDPAssociationConn(ctx, conn.LocalAddr(), c.md.connectTimeout, dial, closeIdle, log)
|
||||
}
|
||||
|
||||
func (c *masqueConnector) connectUDPTarget(
|
||||
ctx context.Context,
|
||||
localAddr net.Addr,
|
||||
proxyHost string,
|
||||
reqStream *http3.RequestStream,
|
||||
address string,
|
||||
remoteAddr net.Addr,
|
||||
log logger.Logger,
|
||||
) (*masque_util.DatagramConn, error) {
|
||||
|
||||
// Apply connect timeout to the actual stream
|
||||
if c.md.connectTimeout > 0 {
|
||||
@@ -259,14 +325,15 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
|
||||
// Get the underlying HTTP/3 stream for datagrams
|
||||
stream := reqStream
|
||||
|
||||
// Resolve target address
|
||||
raddr, err := net.ResolveUDPAddr("udp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if remoteAddr == nil {
|
||||
remoteAddr, err = net.ResolveUDPAddr("udp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create datagram connection wrapping the request stream
|
||||
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, conn.LocalAddr(), raddr)
|
||||
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, localAddr, remoteAddr)
|
||||
|
||||
success = true // Prevent defer from closing stream - datagramConn now owns it
|
||||
return datagramConn, nil
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package masque
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTCPConnectRequestUsesStandardConnect(t *testing.T) {
|
||||
req := newTCPConnectRequest("example.com:443")
|
||||
|
||||
if req.Method != http.MethodConnect {
|
||||
t.Fatalf("expected CONNECT method, got %s", req.Method)
|
||||
}
|
||||
if req.Host != "example.com:443" {
|
||||
t.Fatalf("expected target authority, got %s", req.Host)
|
||||
}
|
||||
if req.Proto != "" {
|
||||
// quic-go treats any non-empty Proto as an extended CONNECT :protocol.
|
||||
t.Fatalf("standard CONNECT must not set :protocol, got %q", req.Proto)
|
||||
}
|
||||
if req.ProtoMajor != 3 {
|
||||
t.Fatalf("expected HTTP/3 request, got HTTP/%d", req.ProtoMajor)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type bindListener struct {
|
||||
network string
|
||||
addr net.Addr
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"time"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/internal/util/mux"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
@@ -31,6 +31,8 @@ func (c *relayConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -137,7 +137,12 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
addr.Host = "127.0.0.1"
|
||||
}
|
||||
|
||||
req := gosocks5.NewRequest(gosocks5.CmdConnect, &addr)
|
||||
cmd := gosocks5.CmdConnect
|
||||
if c, ok := ctxvalue.Socks5CmdFromContext(ctx); ok {
|
||||
cmd = c
|
||||
}
|
||||
|
||||
req := gosocks5.NewRequest(cmd, &addr)
|
||||
log.Trace(req)
|
||||
if err := req.Write(conn); err != nil {
|
||||
log.Error(err)
|
||||
@@ -157,6 +162,10 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cmd == socks.CmdResolve || cmd == socks.CmdResolvePTR {
|
||||
return &resolveConn{Conn: conn, resolvedAddr: reply.Addr}, nil
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func (p *tcpListener) Close() error {
|
||||
|
||||
type tcpMuxListener struct {
|
||||
addr net.Addr
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ func (c *socks5Connector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package v5
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/gosocks5"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// resolveConn wraps a raw net.Conn and carries the resolved address from a
|
||||
// Tor RESOLVE/RESOLVE_PTR reply as metadata. It implements net.Conn (passthrough)
|
||||
// and metadata.Metadatable so the handler can extract the resolved address.
|
||||
type resolveConn struct {
|
||||
net.Conn
|
||||
resolvedAddr *gosocks5.Addr
|
||||
}
|
||||
|
||||
func (c *resolveConn) Metadata() md.Metadata {
|
||||
return xmd.NewMetadata(map[string]any{
|
||||
"resolvedAddr": c.resolvedAddr,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package v5
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/gosocks5"
|
||||
)
|
||||
|
||||
type dummyConn struct{ net.Conn }
|
||||
|
||||
func TestResolveConn_Metadata(t *testing.T) {
|
||||
addr := &gosocks5.Addr{Type: gosocks5.AddrIPv4, Host: "93.184.216.34", Port: 0}
|
||||
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: addr}
|
||||
|
||||
md := rc.Metadata()
|
||||
if md == nil {
|
||||
t.Fatal("Metadata() returned nil")
|
||||
}
|
||||
|
||||
v := md.Get("resolvedAddr")
|
||||
if v == nil {
|
||||
t.Fatal("resolvedAddr key not present in metadata")
|
||||
}
|
||||
|
||||
got, ok := v.(*gosocks5.Addr)
|
||||
if !ok {
|
||||
t.Fatalf("resolvedAddr is %T, want *gosocks5.Addr", v)
|
||||
}
|
||||
if got.Host != "93.184.216.34" {
|
||||
t.Errorf("Host = %q, want %q", got.Host, "93.184.216.34")
|
||||
}
|
||||
if got.Type != gosocks5.AddrIPv4 {
|
||||
t.Errorf("Type = %d, want %d", got.Type, gosocks5.AddrIPv4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConn_Metadata_NilAddr(t *testing.T) {
|
||||
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: nil}
|
||||
|
||||
md := rc.Metadata()
|
||||
v := md.Get("resolvedAddr")
|
||||
addr, ok := v.(*gosocks5.Addr)
|
||||
if !ok {
|
||||
t.Fatalf("resolvedAddr has type %T, want *gosocks5.Addr", v)
|
||||
}
|
||||
if addr != nil {
|
||||
t.Errorf("resolvedAddr = %v, want nil", addr)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/common/bufpool"
|
||||
"github.com/go-gost/core/connector"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/go-shadowsocks2/core"
|
||||
@@ -97,10 +96,13 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
rawaddr := bufpool.Get(512)
|
||||
defer bufpool.Put(rawaddr)
|
||||
// The wrapped conn stores this target and only writes it on first I/O,
|
||||
// so it must be owned by this connection, not a shared pooled buffer
|
||||
// (see go-gost/gost#896).
|
||||
const maxAddrLen = 1 + 1 + 255 + 2 // SOCKS5 addr: atyp + len + host + port
|
||||
|
||||
n, err := addr.Encode(rawaddr)
|
||||
target := make([]byte, maxAddrLen)
|
||||
n, err := addr.Encode(target)
|
||||
if err != nil {
|
||||
log.Error("encoding addr: ", err)
|
||||
return nil, err
|
||||
@@ -111,8 +113,7 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
|
||||
defer conn.SetDeadline(time.Time{})
|
||||
}
|
||||
|
||||
target := socks.Addr(rawaddr[:n])
|
||||
conn, err = c.client.WrapConn(conn, target)
|
||||
conn, err = c.client.WrapConn(conn, socks.Addr(target[:n]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/connector"
|
||||
"github.com/go-gost/go-shadowsocks2/socks"
|
||||
"github.com/go-gost/x/internal/util/ss/none"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
mdx "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// captureConn records everything written to it, so a test can inspect the
|
||||
// bytes a connector actually puts on the wire.
|
||||
type captureConn struct {
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (c *captureConn) Read(b []byte) (int, error) { return 0, io.EOF }
|
||||
func (c *captureConn) Write(b []byte) (int, error) { return c.buf.Write(b) }
|
||||
func (c *captureConn) Close() error { return nil }
|
||||
func (c *captureConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *captureConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *captureConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *captureConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *captureConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
// TestConnectTargetNotOverwrittenByPool is a regression test for
|
||||
// https://github.com/go-gost/gost/issues/896: the connector previously passed
|
||||
// a slice aliasing the bufpool buffer to WrapConn, and the buffer was returned
|
||||
// to the pool before the wrapped conn lazily wrote the target on first I/O. A
|
||||
// second Connect could then overwrite the bytes and the first conn would send
|
||||
// the second conn's target. The none cipher keeps the wire plaintext so the
|
||||
// written target is directly assertable.
|
||||
func TestConnectTargetNotOverwrittenByPool(t *testing.T) {
|
||||
c := NewConnector(
|
||||
connector.AuthOption(url.UserPassword("none", "x")),
|
||||
connector.LoggerOption(xlogger.NewLogger()),
|
||||
)
|
||||
if err := c.Init(mdx.NewMetadata(nil)); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cap1 := &captureConn{}
|
||||
conn1, err := c.Connect(ctx, cap1, "tcp", "1.2.3.4:80")
|
||||
if err != nil {
|
||||
t.Fatalf("first Connect: %v", err)
|
||||
}
|
||||
|
||||
// Second Connect reuses the same 512-byte bufpool buffer that conn1's
|
||||
// target used to alias (single pool tier, same goroutine).
|
||||
if _, err := c.Connect(ctx, &captureConn{}, "tcp", "5.6.7.8:443"); err != nil {
|
||||
t.Fatalf("second Connect: %v", err)
|
||||
}
|
||||
|
||||
if _, err := conn1.Write([]byte("X")); err != nil {
|
||||
t.Fatalf("first Write: %v", err)
|
||||
}
|
||||
|
||||
// initWriter frames the target as [salt][2-byte length][addr].
|
||||
data := cap1.buf.Bytes()
|
||||
payload := data[none.Cipher.SaltSize():]
|
||||
if len(payload) < 2 {
|
||||
t.Fatalf("captured payload too short: %d bytes: %x", len(payload), data)
|
||||
}
|
||||
n := int(payload[0])<<8 | int(payload[1])
|
||||
if len(payload) < 2+n {
|
||||
t.Fatalf("captured payload too short: len=%d, need %d: %x", len(payload), 2+n, data)
|
||||
}
|
||||
addr := socks.SplitAddr(payload[2 : 2+n])
|
||||
if got, want := addr.String(), "1.2.3.4:80"; got != want {
|
||||
t.Fatalf("first conn wrote target %q, want %q (captured %x)", got, want, data)
|
||||
}
|
||||
}
|
||||
@@ -69,8 +69,8 @@ func (c *tunnelConnector) initTunnel(conn net.Conn, network, address string) (ad
|
||||
req.Features = append(req.Features, af)
|
||||
|
||||
af = &relay.AddrFeature{}
|
||||
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
if h, p, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", p)
|
||||
}
|
||||
af.ParseFrom(address)
|
||||
req.Features = append(req.Features, af) // dst address
|
||||
@@ -78,6 +78,11 @@ func (c *tunnelConnector) initTunnel(conn net.Conn, network, address string) (ad
|
||||
req.Features = append(req.Features, &relay.TunnelFeature{
|
||||
ID: c.md.tunnelID,
|
||||
})
|
||||
if c.md.recordMode != "" {
|
||||
req.Features = append(req.Features, &relay.MetadataFeature{
|
||||
KVs: map[string]string{"record.mode": c.md.recordMode},
|
||||
})
|
||||
}
|
||||
if _, err = req.WriteTo(conn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/go-gost/core/common/bufpool"
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/relay"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xrelay "github.com/go-gost/x/internal/util/relay"
|
||||
)
|
||||
|
||||
@@ -94,6 +96,14 @@ func (c *bindConn) Metadata() mdata.Metadata {
|
||||
return c.md
|
||||
}
|
||||
|
||||
// Context implements xctx.Context, exposing the connection metadata (e.g. the
|
||||
// tunneled hostname) to the handler's context. The service accept loop derives
|
||||
// the handler context from a conn that implements this interface, which lets
|
||||
// reverse-forward handlers (rtcp) route on the hostname carried by the tunnel.
|
||||
func (c *bindConn) Context() context.Context {
|
||||
return ictx.ContextWithMetadata(context.Background(), c.md)
|
||||
}
|
||||
|
||||
type bindUDPConn struct {
|
||||
net.Conn
|
||||
localAddr net.Addr
|
||||
@@ -162,6 +172,12 @@ func (c *bindUDPConn) Metadata() mdata.Metadata {
|
||||
return c.md
|
||||
}
|
||||
|
||||
// Context implements xctx.Context, exposing the connection metadata (e.g. the
|
||||
// tunneled hostname) to the handler's context. See bindConn.Context.
|
||||
func (c *bindUDPConn) Context() context.Context {
|
||||
return ictx.ContextWithMetadata(context.Background(), c.md)
|
||||
}
|
||||
|
||||
type bindAddr struct {
|
||||
network string
|
||||
addr string
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
// TestBindConnContext verifies that a bindConn exposes its connection metadata
|
||||
// (the tunneled hostname) via the xctx.Context interface, so the service accept
|
||||
// loop forwards it into the handler context for filter.host routing (gost#898).
|
||||
func TestBindConnContext(t *testing.T) {
|
||||
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
|
||||
cn := &bindConn{
|
||||
Conn: &net.TCPConn{},
|
||||
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
|
||||
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
|
||||
md: md,
|
||||
}
|
||||
|
||||
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
|
||||
if got != "example.local:80" {
|
||||
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindUDPConnContext is the same check for the UDP reverse-tunnel conn.
|
||||
func TestBindUDPConnContext(t *testing.T) {
|
||||
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
|
||||
cn := &bindUDPConn{
|
||||
Conn: &net.TCPConn{},
|
||||
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
|
||||
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
|
||||
md: md,
|
||||
}
|
||||
|
||||
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
|
||||
if got != "example.local:80" {
|
||||
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,11 @@ func (c *tunnelConnector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
req.Features = append(req.Features, &relay.TunnelFeature{
|
||||
ID: c.md.tunnelID,
|
||||
})
|
||||
if c.md.recordMode != "" {
|
||||
req.Features = append(req.Features, &relay.MetadataFeature{
|
||||
KVs: map[string]string{"record.mode": c.md.recordMode},
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := req.WriteTo(conn); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type bindListener struct {
|
||||
network string
|
||||
addr net.Addr
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type metadata struct {
|
||||
connectTimeout time.Duration
|
||||
tunnelID relay.TunnelID
|
||||
muxCfg *mux.Config
|
||||
recordMode string
|
||||
}
|
||||
|
||||
func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
@@ -44,6 +45,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
c.md.tunnelID = c.md.tunnelID.SetWeight(uint8(weight))
|
||||
}
|
||||
|
||||
c.md.recordMode = mdutil.GetString(md, "record.mode")
|
||||
|
||||
c.md.muxCfg = &mux.Config{
|
||||
Version: mdutil.GetInt(md, "mux.version"),
|
||||
KeepAliveInterval: mdutil.GetDuration(md, "mux.keepaliveInterval"),
|
||||
@@ -52,6 +55,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
if c.md.muxCfg.Version == 0 {
|
||||
c.md.muxCfg.Version = 2
|
||||
|
||||
@@ -101,6 +101,42 @@ func ClientIDFromContext(ctx context.Context) ClientID {
|
||||
return v
|
||||
}
|
||||
|
||||
type peerCertKey struct{}
|
||||
|
||||
// PeerCert carries the verified mTLS client certificate identity.
|
||||
type PeerCert struct {
|
||||
CN string
|
||||
SANs []string
|
||||
Fingerprint string // SHA-256 hex of cert.Raw
|
||||
}
|
||||
|
||||
// ContextWithPeerCert returns a copy of ctx that carries the peer cert.
|
||||
func ContextWithPeerCert(ctx context.Context, cert *PeerCert) context.Context {
|
||||
return context.WithValue(ctx, peerCertKey{}, cert)
|
||||
}
|
||||
|
||||
// PeerCertFromContext returns the peer cert stored in ctx, or nil.
|
||||
func PeerCertFromContext(ctx context.Context) *PeerCert {
|
||||
v, _ := ctx.Value(peerCertKey{}).(*PeerCert)
|
||||
return v
|
||||
}
|
||||
|
||||
type socks5CmdKey struct{}
|
||||
|
||||
// ContextWithSocks5Cmd returns a copy of ctx that carries a SOCKS5 command
|
||||
// override. Connectors use this to send a non-CONNECT command (e.g. Tor
|
||||
// RESOLVE/RESOLVE_PTR) to the upstream proxy.
|
||||
func ContextWithSocks5Cmd(ctx context.Context, cmd uint8) context.Context {
|
||||
return context.WithValue(ctx, socks5CmdKey{}, cmd)
|
||||
}
|
||||
|
||||
// Socks5CmdFromContext returns the SOCKS5 command override stored in ctx.
|
||||
// The bool is false if no override is present.
|
||||
func Socks5CmdFromContext(ctx context.Context) (uint8, bool) {
|
||||
v, ok := ctx.Value(socks5CmdKey{}).(uint8)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// labelsKey saves the static service labels.
|
||||
type labelsKey struct{}
|
||||
|
||||
|
||||
@@ -150,6 +150,49 @@ func TestClientIDFromContext_WrongType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertContext(t *testing.T) {
|
||||
cert := &PeerCert{
|
||||
CN: "client.example.com",
|
||||
SANs: []string{"san1.example.com", "san2.example.com"},
|
||||
Fingerprint: "abcdef1234567890",
|
||||
}
|
||||
ctx := ContextWithPeerCert(context.Background(), cert)
|
||||
|
||||
got := PeerCertFromContext(ctx)
|
||||
if got == nil {
|
||||
t.Fatal("PeerCertFromContext() = nil, want non-nil")
|
||||
}
|
||||
if got.CN != cert.CN {
|
||||
t.Errorf("PeerCertFromContext().CN = %q, want %q", got.CN, cert.CN)
|
||||
}
|
||||
if len(got.SANs) != len(cert.SANs) || got.SANs[0] != cert.SANs[0] {
|
||||
t.Errorf("PeerCertFromContext().SANs = %v, want %v", got.SANs, cert.SANs)
|
||||
}
|
||||
if got.Fingerprint != cert.Fingerprint {
|
||||
t.Errorf("PeerCertFromContext().Fingerprint = %q, want %q", got.Fingerprint, cert.Fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertFromContext_Empty(t *testing.T) {
|
||||
if got := PeerCertFromContext(context.Background()); got != nil {
|
||||
t.Errorf("PeerCertFromContext(empty) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertFromContext_WrongType(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), peerCertKey{}, "not-a-cert")
|
||||
if got := PeerCertFromContext(ctx); got != nil {
|
||||
t.Errorf("PeerCertFromContext(wrong type) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertFromContext_Nil(t *testing.T) {
|
||||
ctx := ContextWithPeerCert(context.Background(), nil)
|
||||
if got := PeerCertFromContext(ctx); got != nil {
|
||||
t.Errorf("PeerCertFromContext(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithLabels(t *testing.T) {
|
||||
labels := map[string]string{"tenant": "acme", "region": "eu"}
|
||||
ctx := ContextWithLabels(context.Background(), labels)
|
||||
@@ -218,6 +261,33 @@ func (c *testContextConn) Context() context.Context {
|
||||
return c.ctx
|
||||
}
|
||||
|
||||
func TestContextWithSocks5Cmd(t *testing.T) {
|
||||
ctx := ContextWithSocks5Cmd(context.Background(), 0xF0)
|
||||
|
||||
cmd, ok := Socks5CmdFromContext(ctx)
|
||||
if !ok {
|
||||
t.Fatal("Socks5CmdFromContext() ok = false, want true")
|
||||
}
|
||||
if cmd != 0xF0 {
|
||||
t.Errorf("Socks5CmdFromContext() = 0x%02X, want 0xF0", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocks5CmdFromContext_Empty(t *testing.T) {
|
||||
_, ok := Socks5CmdFromContext(context.Background())
|
||||
if ok {
|
||||
t.Error("Socks5CmdFromContext(empty) ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocks5CmdFromContext_WrongType(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), socks5CmdKey{}, "not-a-uint8")
|
||||
_, ok := Socks5CmdFromContext(ctx)
|
||||
if ok {
|
||||
t.Error("Socks5CmdFromContext(wrong type) ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextInterface(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx = ContextWithSid(ctx, Sid("iface-test"))
|
||||
|
||||
@@ -97,11 +97,10 @@ func (d *http3Dialer) Dial(ctx context.Context, addr string, opts ...dialer.Dial
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthorizePath: d.md.authorizePath,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: true,
|
||||
Logger: d.options.Logger,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: true,
|
||||
Logger: d.options.Logger,
|
||||
}
|
||||
|
||||
d.clients[addr] = client
|
||||
|
||||
@@ -102,6 +102,15 @@ func (c *MasqueConn) GetRequestStream() *http3.RequestStream {
|
||||
return c.reqStream
|
||||
}
|
||||
|
||||
// OpenRequestStream opens another request stream on the shared HTTP/3 connection.
|
||||
// CONNECT-UDP associations use one stream per destination.
|
||||
func (c *MasqueConn) OpenRequestStream(ctx context.Context) (*http3.RequestStream, error) {
|
||||
if c.clientConn == nil {
|
||||
return nil, errors.New("masque: connection is closed")
|
||||
}
|
||||
return c.clientConn.OpenRequestStream(ctx)
|
||||
}
|
||||
|
||||
// GetHost returns the proxy host.
|
||||
func (c *MasqueConn) GetHost() string {
|
||||
return c.host
|
||||
|
||||
@@ -9,16 +9,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAuthorizePath = "/authorize"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
authorizePath string
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
|
||||
// QUIC config options
|
||||
keepAlivePeriod time.Duration
|
||||
@@ -36,10 +34,6 @@ func (d *http3Dialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
maxStreams = "maxStreams"
|
||||
)
|
||||
|
||||
d.md.authorizePath = mdutil.GetString(md, "pht.authorizePath", "authorizePath")
|
||||
if !strings.HasPrefix(d.md.authorizePath, "/") {
|
||||
d.md.authorizePath = defaultAuthorizePath
|
||||
}
|
||||
d.md.pushPath = mdutil.GetString(md, "pht.pushPath", "pushPath")
|
||||
if !strings.HasPrefix(d.md.pushPath, "/") {
|
||||
d.md.pushPath = defaultPushPath
|
||||
|
||||
+5
-2
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -11,8 +12,10 @@ type quicSession struct {
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ 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() {
|
||||
@@ -95,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
|
||||
}
|
||||
|
||||
@@ -109,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()
|
||||
conn, err = session.GetConn(ctx)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -14,14 +14,24 @@ 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)
|
||||
if d.md.maxIdleTimeout <= 0 {
|
||||
d.md.maxIdleTimeout = 90 * time.Second
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+1
-5
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type muxSession struct {
|
||||
conn net.Conn
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
}
|
||||
|
||||
func (session *muxSession) GetConn() (net.Conn, error) {
|
||||
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
|
||||
}
|
||||
return session.session.IsClosed()
|
||||
}
|
||||
|
||||
func (session *muxSession) NumStreams() int {
|
||||
return session.session.NumStreams()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"time"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/internal/util/mux"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
@@ -34,6 +34,8 @@ func (d *mtcpDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
if d.md.muxCfg.Version == 0 {
|
||||
d.md.muxCfg.Version = 2
|
||||
|
||||
+1
-5
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type muxSession struct {
|
||||
conn net.Conn
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
}
|
||||
|
||||
func (session *muxSession) GetConn() (net.Conn, error) {
|
||||
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
|
||||
}
|
||||
return session.session.IsClosed()
|
||||
}
|
||||
|
||||
func (session *muxSession) NumStreams() int {
|
||||
return session.session.NumStreams()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"time"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/internal/util/mux"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
@@ -34,6 +34,8 @@ func (d *mtlsDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
+1
-5
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type muxSession struct {
|
||||
conn net.Conn
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
}
|
||||
|
||||
func (session *muxSession) GetConn() (net.Conn, error) {
|
||||
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
|
||||
}
|
||||
return session.session.IsClosed()
|
||||
}
|
||||
|
||||
func (session *muxSession) NumStreams() int {
|
||||
return session.session.NumStreams()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/internal/util/mux"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -49,6 +49,8 @@ func (d *mwsDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, "ws.handshakeTimeout", "handshakeTimeout")
|
||||
|
||||
@@ -105,12 +105,11 @@ func (d *phtDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
|
||||
// Timeout: 60 * time.Second,
|
||||
Transport: tr,
|
||||
},
|
||||
AuthorizePath: d.md.authorizePath,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: d.tlsEnabled,
|
||||
Header: d.md.header,
|
||||
Logger: d.options.Logger,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: d.tlsEnabled,
|
||||
Header: d.md.header,
|
||||
Logger: d.options.Logger,
|
||||
}
|
||||
d.clients[addr] = client
|
||||
}
|
||||
|
||||
+10
-18
@@ -9,33 +9,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
dialTimeout = "dialTimeout"
|
||||
defaultAuthorizePath = "/authorize"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
authorizePath string
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
header http.Header
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
header http.Header
|
||||
}
|
||||
|
||||
func (d *phtDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
const (
|
||||
authorizePath = "authorizePath"
|
||||
pushPath = "pushPath"
|
||||
pullPath = "pullPath"
|
||||
host = "host"
|
||||
header = "header"
|
||||
pushPath = "pushPath"
|
||||
pullPath = "pullPath"
|
||||
host = "host"
|
||||
header = "header"
|
||||
)
|
||||
|
||||
d.md.authorizePath = mdutil.GetString(md, authorizePath)
|
||||
if !strings.HasPrefix(d.md.authorizePath, "/") {
|
||||
d.md.authorizePath = defaultAuthorizePath
|
||||
}
|
||||
d.md.pushPath = mdutil.GetString(md, pushPath)
|
||||
if !strings.HasPrefix(d.md.pushPath, "/") {
|
||||
d.md.pushPath = defaultPushPath
|
||||
|
||||
+5
-2
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -11,8 +12,10 @@ type quicSession struct {
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+10
-3
@@ -58,7 +58,6 @@ 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() {
|
||||
@@ -73,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")
|
||||
}
|
||||
|
||||
@@ -89,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()
|
||||
conn, err = session.GetConn(ctx)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ func (d *quicDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
}
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
|
||||
if d.md.maxIdleTimeout <= 0 {
|
||||
d.md.maxIdleTimeout = 90 * time.Second
|
||||
}
|
||||
d.md.maxStreams = mdutil.GetInt(md, maxStreams)
|
||||
d.md.enableDatagram = mdutil.GetBool(md, "quic.enableDatagram", "enableDatagram")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -52,6 +52,8 @@ func (d *tlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
|
||||
d.log.Error(err)
|
||||
}
|
||||
|
||||
xnet.ApplyNoDelay(conn)
|
||||
|
||||
if d.md.keepalive {
|
||||
xnet.ApplyKeepalive(conn, net.KeepAliveConfig{
|
||||
Enable: true,
|
||||
|
||||
+107
-2
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/logger"
|
||||
@@ -82,7 +81,7 @@ func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
|
||||
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))
|
||||
utlsCfg := toUTLSConfig(d.options.TLSConfig)
|
||||
uconn := utls.UClient(conn, utlsCfg, clientHelloID)
|
||||
if err := uconn.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
@@ -104,3 +103,109 @@ func (d *utlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
|
||||
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// toUTLSConfig builds an equivalent utls.Config from a standard crypto/tls.Config.
|
||||
//
|
||||
// utls.Config is a distinct type from crypto/tls.Config and its field offsets
|
||||
// diverge from ServerName onward (crypto/tls added fields utls does not have),
|
||||
// so a direct (*utls.Config)(unsafe.Pointer(...)) reinterprets memory and reads
|
||||
// garbage for ServerName/InsecureSkipVerify — which is what broke TLS
|
||||
// verification for this dialer. We copy fields explicitly instead.
|
||||
//
|
||||
// Fields whose types differ between the two packages (Certificate, ClientAuth,
|
||||
// CurveID, RenegotiationSupport, ConnectionState, the Get* callbacks,
|
||||
// ClientSessionCache) are converted or skipped: the config path that feeds this
|
||||
// dialer (LoadClientConfig) never populates the Get*/ClientSessionCache
|
||||
// callbacks, so skipping them is safe here.
|
||||
func toUTLSConfig(c *tls.Config) *utls.Config {
|
||||
if c == nil {
|
||||
return &utls.Config{}
|
||||
}
|
||||
|
||||
uc := &utls.Config{
|
||||
Rand: c.Rand,
|
||||
Time: c.Time,
|
||||
Certificates: toUTLSCertificates(c.Certificates),
|
||||
RootCAs: c.RootCAs,
|
||||
NextProtos: c.NextProtos,
|
||||
ServerName: c.ServerName,
|
||||
ClientAuth: utls.ClientAuthType(c.ClientAuth),
|
||||
ClientCAs: c.ClientCAs,
|
||||
InsecureSkipVerify: c.InsecureSkipVerify,
|
||||
CipherSuites: c.CipherSuites,
|
||||
PreferServerCipherSuites: c.PreferServerCipherSuites,
|
||||
SessionTicketsDisabled: c.SessionTicketsDisabled,
|
||||
CurvePreferences: toUTLSCurveIDs(c.CurvePreferences),
|
||||
DynamicRecordSizingDisabled: c.DynamicRecordSizingDisabled,
|
||||
Renegotiation: utls.RenegotiationSupport(c.Renegotiation),
|
||||
KeyLogWriter: c.KeyLogWriter,
|
||||
MinVersion: c.MinVersion,
|
||||
MaxVersion: c.MaxVersion,
|
||||
VerifyPeerCertificate: c.VerifyPeerCertificate,
|
||||
}
|
||||
|
||||
if c.VerifyConnection != nil {
|
||||
vc := c.VerifyConnection
|
||||
uc.VerifyConnection = func(cs utls.ConnectionState) error {
|
||||
return vc(tls.ConnectionState{
|
||||
PeerCertificates: cs.PeerCertificates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return uc
|
||||
}
|
||||
|
||||
func toUTLSCertificates(certs []tls.Certificate) []utls.Certificate {
|
||||
if certs == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]utls.Certificate, len(certs))
|
||||
for i, c := range certs {
|
||||
sa := make([]utls.SignatureScheme, len(c.SupportedSignatureAlgorithms))
|
||||
for j, s := range c.SupportedSignatureAlgorithms {
|
||||
sa[j] = utls.SignatureScheme(s)
|
||||
}
|
||||
out[i] = utls.Certificate{
|
||||
Certificate: c.Certificate,
|
||||
PrivateKey: c.PrivateKey,
|
||||
SupportedSignatureAlgorithms: sa,
|
||||
OCSPStaple: c.OCSPStaple,
|
||||
SignedCertificateTimestamps: c.SignedCertificateTimestamps,
|
||||
Leaf: c.Leaf,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// utlsCurves maps the crypto/tls curve IDs that utls understands.
|
||||
//
|
||||
// crypto/tls and utls use separate CurveID enumerations. Go has added
|
||||
// post-quantum hybrid curves (X25519MLKEM768, SecP256r1MLKEM768,
|
||||
// SecP384r1MLKEM1024) whose numeric IDs utls does not recognise, so a
|
||||
// blind utls.CurveID(id) cast turns them into unsupported curve IDs and
|
||||
// aborts the handshake with "CurvePreferences includes unsupported curve".
|
||||
// We keep only the curves utls can actually use and drop the rest; if none
|
||||
// survive, return nil so utls falls back to the fingerprint's defaults.
|
||||
var utlsCurves = map[tls.CurveID]utls.CurveID{
|
||||
tls.X25519: utls.X25519,
|
||||
tls.CurveP256: utls.CurveP256,
|
||||
tls.CurveP384: utls.CurveP384,
|
||||
tls.CurveP521: utls.CurveP521,
|
||||
}
|
||||
|
||||
func toUTLSCurveIDs(ids []tls.CurveID) []utls.CurveID {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]utls.CurveID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if c, ok := utlsCurves[id]; ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ func (d *wsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOpt
|
||||
d.options.Logger.Error(err)
|
||||
}
|
||||
|
||||
xnet.ApplyNoDelay(conn)
|
||||
|
||||
if d.md.tcpKeepalive {
|
||||
xnet.ApplyKeepalive(conn, net.KeepAliveConfig{
|
||||
Enable: true,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Issue #777: relay+quic "假死" (Hanging) Under High Load
|
||||
|
||||
**Issue**: https://github.com/go-gost/gost/issues/777
|
||||
**Status**: Open, labeled `NeedsInvestigation`
|
||||
**Reported**: 2025-09-05
|
||||
**Maintainer note**: "May related to connection multiplexing or reuse" (@fernvenue)
|
||||
|
||||
## 适用版本 / Version Context
|
||||
|
||||
本分析最初基于 issue 报告版本 **v3.2.4**(docker)。当前 `main` 已被以下提交修复部分根因,**下列 PRIMARY/SECONDARY/TERTIARY 在 v3.2.4 成立,但在当前 `main` 已不成立**:
|
||||
|
||||
- `5484717` (2026-06-20) — 新增 `quicSession.IsClosed()`,并在 `Dial()` 复用前检查 / 驱逐死亡 session(修复 "僵尸 session")。
|
||||
- `9fab332` (2026-06-21) — `OpenStreamSync` 加 `30s` 超时;在 `GetConn()` 之前释放 `sessionMutex`(修复全局 mutex 阻塞)。
|
||||
|
||||
Plan 文档 `777-quic-hanging-plan.md` 为最新修正版(含剩余真问题与修复方案)。
|
||||
|
||||
## Symptom
|
||||
|
||||
Config: `gost -L socks5://:10086 -F relay+quic://IP:PORT?keepalive=true&ttl=21s&auth=XXXX`
|
||||
|
||||
Under high request frequency, connections are accepted (handler logs visible) but no forwarding occurs (no forwarding logs). Restarting the Docker container immediately recovers.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 已在当前 main 修复(v3.2.4 中存在)
|
||||
|
||||
#### ~~PRIMARY: Global `sessionMutex` 在 `OpenStreamSync` 期间被持有~~ — 已修复
|
||||
|
||||
原诊断认为 `quicDialer.Dial()` 在整个方法期间持有 `sync.Mutex`,包括会阻塞的 `OpenStreamSync`,导致全局假死。当前代码已在 `GetConn()` 之前释放锁:
|
||||
|
||||
```go
|
||||
// x/dialer/quic/dialer.go
|
||||
d.sessionMutex.Unlock() // :99 在 GetConn 之前释放
|
||||
conn, err = session.GetConn(ctx) // :101 锁外执行 OpenStreamSync
|
||||
```
|
||||
|
||||
`OpenStreamSync` 不再阻塞全局 mutex,但单个连接在死 session 上仍可能阻塞至超时(见 P1)。
|
||||
|
||||
#### ~~SECONDARY: `OpenStreamSync(context.Background())` 无超时~~ — 已修复
|
||||
|
||||
当前代码使用 `context.WithTimeout(ctx, 30*time.Second)`,不再是 `context.Background()` 无限阻塞。原诊断假设已不成立(提交 `9fab332`)。
|
||||
|
||||
#### ~~TERTIARY: 无 session 健康检查 / 驱逐~~ — 已修复
|
||||
|
||||
当前 `Dial()` 复用前调用 `session.IsClosed()` 检查并驱逐死亡 session:
|
||||
|
||||
```go
|
||||
// x/dialer/quic/dialer.go
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
delete(d.sessions, addr) // session is dead
|
||||
ok = false
|
||||
}
|
||||
```
|
||||
|
||||
`IsClosed()` 观察 `session.session.Context().Done()` channel 判定 session 是否存活(提交 `5484717`)。
|
||||
|
||||
### 仍真实存在的问题(P1–P3)
|
||||
|
||||
#### P1 (HIGH): 请求 ctx 未透传到 `GetConn` / `OpenStreamSync`
|
||||
|
||||
`Router` 默认 **15s 超时**(`x/chain/router.go:36`),并包裹为 `context.WithTimeout(ctx, 15s)` 透传至 `quicDialer.Dial`(`x/chain/router.go:108` → `route.Dial` → `transport.Dial` → `dialer.go:50`)。但 `GetConn()` 收到的仍是 `context.WithTimeout(context.Background(), 30s)`(由 `Dial` 内部构造),请求 ctx 被丢弃:
|
||||
|
||||
- `OpenStreamSync` 不遵守上游 15s 截止时间;
|
||||
- 请求被取消时,`OpenStreamSync` 不会随之取消。
|
||||
|
||||
一个卡死的 QUIC session 仍会让每个新连接在 `OpenStreamSync` 上阻塞最多 30s(单连接级 hang,可被上游 ctx 取消则是修复后的行为)。
|
||||
|
||||
#### P2 (MEDIUM): keepalive 在 dialer / listener 间不对称
|
||||
|
||||
- Dialer (`x/dialer/quic/metadata.go:35`):`md == nil || !md.IsExists(keepAlive) || GetBool(keepAlive)` → **默认开启**(周期 10s)。
|
||||
- Listener (`x/listener/quic/metadata.go:47`):`if GetBool(keepAlive)` → **仅显式 `keepalive=true` 才开启**。
|
||||
|
||||
若用户只在 client (`-F`) 配 `keepalive=true`,server 端 QUIC listener 不发 PING。过 NAT 时服务端回程映射过期,而客户端以为连接仍存活 —— 与 issue 症状吻合。
|
||||
|
||||
#### P3 (LOW): 两端均未显式设置 `MaxIdleTimeout` 默认
|
||||
|
||||
dialer 与 listener 均解析 `maxIdleTimeout` 并传入 `quic.Config.MaxIdleTimeout`,默认值为 `0` → quic-go 自身默认(约 30s),无显式下限。设置对称、合理的显式默认(90s)可让死 session 被 quic-go 自身更快检测,与 P2 互补。
|
||||
|
||||
## Recommended Fixes
|
||||
|
||||
| 编号 | 问题 | 状态 | 方案 |
|
||||
|------|------|------|------|
|
||||
| ~~PRIMARY~~ | 全局 mutex 持有 | **已修复** `9fab332` | — |
|
||||
| ~~SECONDARY~~ | 无超时 OpenStreamSync | **已修复** `9fab332` | — |
|
||||
| ~~TERTIARY~~ | 无 IsClosed 检查 | **已修复** `5484717` | — |
|
||||
| Fix B | P1 ctx 未透传 | 待实施 | `GetConn(ctx)` 透传 + 30s 上限,镜像 icmp |
|
||||
| Fix C | P2 keepalive 不对称 | 待实施 | listener 默认开启 keepalive |
|
||||
| Fix D | P3 MaxIdleTimeout 默认 | 待实施 | 两端默认 90s,允许 `maxIdleTimeout=` 覆盖 |
|
||||
|
||||
详见 `777-quic-hanging-plan.md`。
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `dialer/quic/dialer.go` | QUIC dialer;窄锁(GetConn 移出锁);IsClosed 复用前检查 |
|
||||
| `dialer/quic/conn.go` | `GetConn(ctx)` / `IsClosed()` — OpenStreamSync 带 30s 超时 |
|
||||
| `dialer/quic/metadata.go` | keepalive 默认 ON;maxIdleTimeout 解析 |
|
||||
| `listener/quic/listener.go` | QUIC listener,keepAlivePeriod 传入 quic.Config |
|
||||
| `listener/quic/metadata.go` | Listener keepalive / maxIdleTimeout 解析 |
|
||||
| `dialer/icmp/dialer.go` | ICMP dialer — 与 quic 结构一致,改动需镜像 |
|
||||
| `dialer/icmp/conn.go` | ICMP `quicSession` / `GetConn(ctx)` |
|
||||
| `connector/relay/connector.go` | Relay connector(无状态,每调用一次) |
|
||||
| `chain/router.go` | Router 默认 15s 超时,构造超时 ctx |
|
||||
| `chain/route.go` | `route.Dial()` → `Transport.Dial()` |
|
||||
@@ -0,0 +1,97 @@
|
||||
# Plan: go-gost/gost#777 - relay+quic hanging under high load
|
||||
|
||||
## Issue Summary
|
||||
|
||||
- **Reporter**: @qianhd
|
||||
- **Config**: `gost -L socks5://:10086 -F relay+quic://IP:PORT?keepalive=true&ttl=21s&auth=XXXX`
|
||||
- **Version**: 3.2.4 (docker)
|
||||
- **Symptom**: Under frequent requests, connections are accepted (handler logs visible) but no forwarding occurs (no forwarding logs). Restarting the container immediately recovers.
|
||||
- **Maintainer comment**: "May related to connection multiplexing or reuse" (@fernvenue)
|
||||
|
||||
## Version Context - prior analysis is partly stale
|
||||
|
||||
The earlier analysis (777-quic-hanging-analysis.md) was written against v3.2.4 and named three root causes (global mutex held during OpenStreamSync, no timeout on OpenStreamSync, no IsClosed check). All three are already fixed on current main:
|
||||
|
||||
- 5484717 (2026-06-20): added quicSession.IsClosed() and the reuse-time liveness guard in Dial().
|
||||
- 9fab332 (2026-06-21): added the 30s timeout on OpenStreamSync, and released sessionMutex before GetConn() so a dead session can no longer stall all Dial() calls.
|
||||
|
||||
This plan covers only the remaining real problems (P1-P3) plus doc correction. The sibling icmp dialer shares the quicSession type and the same structure, so every quic change is mirrored to icmp.
|
||||
|
||||
## Root Cause Analysis (current main)
|
||||
|
||||
### P1 (HIGH): request ctx not propagated to GetConn / OpenStreamSync
|
||||
|
||||
- Router applies a default 15s timeout (x/chain/router.go:36 sets Timeout = 15s) and wraps the context with context.WithTimeout(ctx, 15s) (x/chain/router.go:108). This ctx flows into quicDialer.Dial(ctx, ...) via route.Dial -> transport.Dial -> dialer.go:50.
|
||||
- But dialer.go calls session.GetConn() without ctx, and conn.go builds its own context.WithTimeout(context.Background(), 30s). The request ctx (deadline + cancellation) is dropped.
|
||||
- Consequence: OpenStreamSync neither honors the 15s request deadline nor cancels when the inbound request is cancelled. A stuck QUIC session still blocks each new connection on OpenStreamSync for up to 30s (single-connection hang, no longer global).
|
||||
|
||||
### P2 (MEDIUM): keepalive asymmetric between dialer and listener
|
||||
|
||||
- Dialer (x/dialer/quic/metadata.go:35): `md == nil || !md.IsExists(keepAlive) || GetBool(keepAlive)` -> keepalive ON by default (period 10s).
|
||||
- Listener (x/listener/quic/metadata.go:47): `if GetBool(keepAlive)` -> only ON when explicitly set.
|
||||
|
||||
If the user only configures keepalive=true on the client (-F), the server-side QUIC listener sends no PING. Across a NAT the server's return-path mapping can expire while the client still thinks the connection is alive - matching the reported symptom.
|
||||
|
||||
### P3 (LOW): no explicit MaxIdleTimeout default on either side
|
||||
|
||||
Both dialer and listener parse maxIdleTimeout into quic.Config.MaxIdleTimeout with a default of 0 -> quic-go's own default (~30s), with no explicit floor. A symmetric, reasonable explicit default (90s) lets quic-go itself detect dead sessions sooner, complementing P2.
|
||||
|
||||
## Fix Plan
|
||||
|
||||
### Fix B (P1, HIGH): propagate request ctx into GetConn
|
||||
|
||||
- x/dialer/quic/conn.go: change `GetConn()` to `GetConn(ctx context.Context)` and use
|
||||
`ctx, cancel := context.WithTimeout(ctx, 30*time.Second)` (keep the 30s cap, but the
|
||||
parent ctx now carries the 15s request deadline and cancellation).
|
||||
- x/dialer/quic/dialer.go:101: call `session.GetConn(ctx)`.
|
||||
- Mirror to x/dialer/icmp/conn.go and x/dialer/icmp/dialer.go:120 (identical structure).
|
||||
|
||||
### Fix C (P2, MEDIUM): symmetric listener keepalive default
|
||||
|
||||
- x/listener/quic/metadata.go:47: change `if mdutil.GetBool(md, keepAlive)` to
|
||||
`if md == nil || !md.IsExists(keepAlive) || mdutil.GetBool(md, keepAlive)` so the
|
||||
server side sends keepalive by default (period 10s). No new struct field is needed -
|
||||
keepAlivePeriod is already parsed.
|
||||
|
||||
### Fix D (P3, LOW): explicit MaxIdleTimeout default
|
||||
|
||||
- x/dialer/quic/metadata.go, x/dialer/icmp/metadata.go, x/listener/quic/metadata.go:
|
||||
after parsing maxIdleTimeout, if `<= 0` set to `90 * time.Second`. Users can still
|
||||
override via `maxIdleTimeout=`.
|
||||
|
||||
### Fix A (docs): correct the two issue docs
|
||||
|
||||
- 777-quic-hanging-analysis.md and this file: mark PRIMARY/SECONDARY/TERTIARY as already
|
||||
fixed on main (commits 5484717, 9fab332); rename the remaining items to P1-P3.
|
||||
|
||||
## Sibling pattern note
|
||||
|
||||
kcp / masque / mtls / mtcp / mws / ssh hold the lock for the whole Dial (their stream-open
|
||||
is non-blocking). quic / icmp are the only two using the narrow-lock variant (unlock before
|
||||
GetConn), and their code is byte-for-byte identical - so every quic change is mirrored to
|
||||
icmp. There is no shared session-cache abstraction to reuse; extraction is out of scope.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd x && go build ./... && go vet ./...
|
||||
cd gost && go build ./cmd/gost/...
|
||||
```
|
||||
|
||||
E2e (issue-like config):
|
||||
|
||||
```bash
|
||||
# server: $GOST_BIN -L "relay+quic://:8443?keepalive=true"
|
||||
# client: $GOST_BIN -L socks5://:10086 -F "relay+quic://SERVER:8443?keepalive=true&ttl=21s"
|
||||
```
|
||||
|
||||
- Fix B: under high concurrency with a cancelled request, confirm OpenStreamSync returns on
|
||||
ctx cancellation instead of blocking up to 30s.
|
||||
- Fix C: server WITHOUT explicit keepalive=true should still have quic.Config.KeepAlivePeriod > 0.
|
||||
- Fix D: without maxIdleTimeout set, confirm quic.Config.MaxIdleTimeout == 90s on both sides.
|
||||
|
||||
## Out of scope (optional follow-ups)
|
||||
|
||||
- Move initSession (quic.DialEarly handshake) out of sessionMutex (double-check / per-key lock).
|
||||
- Background session health-check goroutine (IsClosed reuse check already covers dead sessions).
|
||||
- Extract a shared session-cache abstraction across kcp/quic/icmp/ssh/masque (new abstraction).
|
||||
@@ -0,0 +1,166 @@
|
||||
# Plan: Make the mux layer pluggable and integrate Yamux
|
||||
|
||||
## Context
|
||||
|
||||
GOST's tunnel/relay/mux components multiplex streams over a single connection
|
||||
through `x/internal/util/mux/mux.go`, which currently hard-wraps
|
||||
`github.com/xtaci/smux`. Research (`x/docs/superpowers/specs/2026-06-08-tunnel-mux-protocol-research.md`)
|
||||
concluded Yamux is the most viable alternative backend (broad ecosystem adoption,
|
||||
different flow-control trade-off) and that making the layer pluggable is
|
||||
straightforward: the `Session` type is only a handful of methods, and all
|
||||
consumers funnel through two factory functions.
|
||||
|
||||
This plan converts the concrete `*mux.Session` into a `mux.Session` interface,
|
||||
adds a Yamux implementation behind a `mux.type` selector, and threads the
|
||||
selector through the existing metadata config (defaulting to `smux`, so all
|
||||
existing deployments are unchanged).
|
||||
|
||||
Scope note: SMUX stays the default; we do **not** change per-component SMUX
|
||||
version defaults (tunnel/mtcp already default to v2; relay/socks stay v1) — the
|
||||
research spec explicitly forbids changing those to avoid behavior regressions.
|
||||
|
||||
## Approach
|
||||
|
||||
Rung check: this needs to exist (integration request) → it's a new backend, not
|
||||
a re-implementation of an existing helper → stdlib/yamux already solve it → the
|
||||
smallest change that works is an interface split at the existing chokepoint.
|
||||
|
||||
Two design decisions worth flagging:
|
||||
|
||||
- **Per-site metadata edits, not a shared `mux.ParseConfig` helper.** The 12
|
||||
`metadata.go` files already duplicate the same `muxCfg` literal (pre-existing
|
||||
debt). A shared helper would centralize the selector but would couple the
|
||||
low-level `internal/util/mux` util to `x/metadata` (architectural smell) and
|
||||
risk silently changing per-component version defaults. Adding `Type` +
|
||||
`MaxStreamWindow` to each existing literal is the minimal, safe change.
|
||||
- **Drop `NumStreams()` from the new interface.** It has zero callers across the
|
||||
whole module; the 3 dialer wrapper `NumStreams` methods become dead code and
|
||||
are deleted.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `x/internal/util/mux/mux.go` — core refactor + Yamux backend
|
||||
|
||||
- Add imports: `github.com/hashicorp/yamux`, `github.com/go-gost/x/ctx`.
|
||||
- Extend `Config`:
|
||||
```go
|
||||
Type string // "smux" (default) | "yamux"
|
||||
MaxStreamWindow int // yamux MaxStreamWindowSize (bytes)
|
||||
```
|
||||
(keep all existing SMUX fields).
|
||||
- Define the interface and rename the concrete SMUX type:
|
||||
```go
|
||||
type Session interface {
|
||||
GetConn() (net.Conn, error)
|
||||
Accept() (net.Conn, error)
|
||||
Close() error
|
||||
IsClosed() bool
|
||||
}
|
||||
type smuxSession struct { conn net.Conn; session *smux.Session }
|
||||
```
|
||||
Keep the existing `streamConn` shim (exposes `Context()`) unchanged.
|
||||
- Add a Yamux implementation mirroring the SMUX one, including a `Context()`
|
||||
shim so ctx propagation behaves identically:
|
||||
```go
|
||||
type yamuxSession struct { conn net.Conn; session *yamux.Session }
|
||||
func (s *yamuxSession) GetConn() (net.Conn, error) { return wrapYamux(s.conn, s.session.Open()) }
|
||||
func (s *yamuxSession) Accept() (net.Conn, error) { return wrapYamux(s.conn, s.session.Accept()) }
|
||||
func (s *yamuxSession) Close() error { return s.session.Close() }
|
||||
func (s *yamuxSession) IsClosed() bool { return s.session.IsClosed() }
|
||||
```
|
||||
`wrapYamux` embeds the `*yamux.Stream` (a full `net.Conn`) and adds
|
||||
`Context()` delegating to the original `conn` (same pattern as `streamConn`).
|
||||
- Branch the factories on `cfg.Type` (empty → `smux`):
|
||||
```go
|
||||
func ClientSession(conn net.Conn, cfg *Config) (Session, error) {
|
||||
if muxType(cfg) == "yamux" { return newYamuxClientSession(conn, cfg) }
|
||||
return newSMUXClientSession(conn, cfg)
|
||||
}
|
||||
// ServerSession analogous
|
||||
```
|
||||
- `newYamuxClientSession` / `newYamuxServerSession` build `*yamux.Config` from
|
||||
`cfg`, **based on `yamux.DefaultConfig()`** (satisfies `VerifyConfig`'s
|
||||
`KeepAliveInterval > 0` / `AcceptBacklog > 0` requirements), overriding only
|
||||
when the user supplies a value:
|
||||
- `KeepAliveInterval` ← `cfg.KeepAliveInterval` (if > 0)
|
||||
- `EnableKeepAlive = false` ← `cfg.KeepAliveDisabled`
|
||||
- `MaxStreamWindowSize = uint32(cfg.MaxStreamWindow)` (if > 0; must be ≥ 256 KiB
|
||||
or Yamux errors at session creation — acceptable misconfig behavior)
|
||||
|
||||
### 2. `x/go.mod` + `x/go.sum` — add dependency
|
||||
|
||||
Run `cd x && go get github.com/hashicorp/yamux@v0.1.1 && go mod tidy`.
|
||||
(Yamux has no heavy deps; `go mod tidy` resolves it.)
|
||||
|
||||
### 3. The 12 `metadata.go` files — add the selector to the muxCfg literal
|
||||
|
||||
In each, add two fields to the existing `&mux.Config{...}` literal
|
||||
(keep all 7 existing keys + post-hoc `Version`/`MaxStreamBuffer` overrides):
|
||||
|
||||
```go
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
```
|
||||
|
||||
Files (the `muxCfg = &mux.Config{...}` site in each):
|
||||
- `handler/tunnel/metadata.go`
|
||||
- `handler/relay/metadata.go`
|
||||
- `handler/socks/v5/metadata.go`
|
||||
- `connector/relay/metadata.go`
|
||||
- `connector/socks/v5/metadata.go`
|
||||
- `connector/tunnel/metadata.go`
|
||||
- `dialer/mtcp/metadata.go`
|
||||
- `dialer/mws/metadata.go`
|
||||
- `dialer/mtls/metadata.go`
|
||||
- `listener/mtcp/metadata.go`
|
||||
- `listener/mws/metadata.go`
|
||||
- `listener/mtls/metadata.go`
|
||||
|
||||
### 4. Consumer type-reference flips `*mux.Session` → `mux.Session`
|
||||
|
||||
Mechanical: change the stored field / constructor param from pointer to
|
||||
interface value (all already produce values that satisfy the interface):
|
||||
|
||||
- `connector/relay/listener.go` — `bindListener.session`
|
||||
- `connector/socks/v5/listener.go` — `tcpMuxListener.session`
|
||||
- `connector/tunnel/listener.go` — `bindListener.session`
|
||||
- `dialer/mtcp/conn.go`, `dialer/mws/conn.go`, `dialer/mtls/conn.go` —
|
||||
`muxSession.session` **and delete the now-dead `NumStreams()` method** (zero callers)
|
||||
- `handler/relay/entrypoint.go` — `tcpHandler.session` field + `newTCPHandler` param
|
||||
- `handler/tunnel/connector.go` — `Connector.s` field + `NewConnector` param
|
||||
|
||||
### 5. Test files (keep them compiling)
|
||||
|
||||
`x/` has no test convention but these compile during `go vet`:
|
||||
- `handler/tunnel/tunnel_test.go` — `newTestSession` return type and
|
||||
`newTestConnector` param: `*mux.Session` → `mux.Session`.
|
||||
- `handler/tunnel/bind_test.go` — `muxCfg: &mux.Config{Version: 2}` literals are
|
||||
unaffected (still `*mux.Config`); no change needed unless a `*mux.Session`
|
||||
reference appears.
|
||||
|
||||
## Critical files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `x/internal/util/mux/mux.go` | Interface + SMUX rename + Yamux backend + dispatch |
|
||||
| `x/go.mod`, `x/go.sum` | Add `hashicorp/yamux` |
|
||||
| 12 × `*/metadata.go` | Add `Type` + `MaxStreamWindow` to `muxCfg` |
|
||||
| 8 consumer files (§4) | `*mux.Session` → `mux.Session` |
|
||||
| `handler/tunnel/*_test.go` | Update session type refs |
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd x && go build ./...` — compiles (incl. all consumer/metadata changes).
|
||||
2. `cd x && go vet ./...` — static analysis + compiles test files.
|
||||
3. Optional smoke test: build the gost binary and run a tunnel pair where the
|
||||
tunnel handler/service carries `metadata: { mux.type: yamux }`; confirm the
|
||||
session establishes and streams flow. (Full e2e suite needs Docker; not
|
||||
required for this change.)
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Shared `mux.ParseConfig(md)` helper to de-duplicate the 12 literals — would
|
||||
couple low-level `internal/util/mux` to `x/metadata`; do it only as part of a
|
||||
broader config-normalization pass.
|
||||
- QUIC-based multiplexing — already handled at the listener/dialer level
|
||||
(`http3`), not a mux-plugin concern.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Design: Tunnel Mux Negotiation via Relay Protocol Extension
|
||||
|
||||
**Date:** 2026-07-13
|
||||
**Status:** Design proposal (follow-up to `2026-07-13-mux-pluggable-yamux.md`)
|
||||
**Scope:** Server-side multi-mux support for the tunnel handler, with the client
|
||||
selecting the mux method per connection — negotiated through a new relay-protocol
|
||||
feature, SOCKS5-style. Backward compatible; no change to the existing wire protocol.
|
||||
|
||||
## Problem
|
||||
|
||||
Today the tunnel handler multiplexes streams with a single hard-wired backend
|
||||
(`github.com/xtaci/smux`), chosen statically per component via `mux.type`
|
||||
metadata. We want the tunnel server to support several mux methods at once
|
||||
(smux + yamux) and let each client pick one, **without changing the existing
|
||||
wire protocol and without breaking old peers**.
|
||||
|
||||
## Key finding: the negotiation window already exists
|
||||
|
||||
In the tunnel path the relay `CmdBind` handshake runs on the **raw `net.Conn`
|
||||
before** the mux session is created on that same connection:
|
||||
|
||||
```
|
||||
raw conn: relay Request (CmdBind) → relay Response → mux.ClientSession / mux.ServerSession → streams
|
||||
```
|
||||
|
||||
Confirmed at:
|
||||
- Server (`handler/tunnel/`): `req.ReadFrom(conn)` → `handleBind` writes `resp` → `mux.ClientSession(conn)`.
|
||||
- Client (`connector/tunnel/bind.go`): `req.WriteTo(conn)` → `resp.ReadFrom(conn)` → `mux.ServerSession(conn)`.
|
||||
|
||||
So a mux-method choice carried in the relay exchange is available in time to
|
||||
drive the `muxCfg` passed to `mux.ClientSession`/`ServerSession`.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. New relay feature type
|
||||
|
||||
Add `FeatureMux` (e.g. `0x05`) to `relay/feature.go`. Payload = list of
|
||||
supported mux methods, encoded as method IDs (sketch: `1=smux`, `2=smux2`,
|
||||
`3=yamux`). Mirror the existing `AddrFeature`/`TunnelFeature` pattern:
|
||||
`TYPE(1) + LEN(2) + DATA`.
|
||||
|
||||
- **Client** (`connector/tunnel/bind.go`, `connector/relay/bind.go`): append
|
||||
`FeatureMux` (supported methods) to `Request.Features`.
|
||||
- **Server** (`handler/tunnel/handler.go` → `handleBind`): read it, pick a
|
||||
mutually supported method (or its configured default), echo the **chosen**
|
||||
method in `Response.Features`.
|
||||
- Both ends build `muxCfg` for `mux.ClientSession`/`ServerSession` from the
|
||||
agreed method instead of static metadata alone.
|
||||
|
||||
The relay protocol has no built-in "offer-N / pick-one" opcode — it's symmetric
|
||||
feature lists — but a feature whose payload is a method list, with the server
|
||||
echoing the chosen one, reuses the existing echo shape. No protocol change.
|
||||
|
||||
### 2. Mux package abstraction (shared with the implementation plan)
|
||||
|
||||
`x/internal/util/mux` gains a `mux.Session` interface with an smux backend and a
|
||||
yamux backend; the negotiated feature selects the implementation + version. This
|
||||
is the same refactor as `2026-07-13-mux-pluggable-yamux.md`.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- **Wire format unchanged.** We only add a feature type to the existing
|
||||
`Features []Feature` list. Old peers are unaffected.
|
||||
- **`OpaqueFeature` guarantees graceful fallback.** A peer with no `case FeatureMux`
|
||||
simply drops the feature (never errors). So:
|
||||
- Old smux-only client → new server: no `FeatureMux` sent, server falls back to
|
||||
its configured/default mux = byte-identical to today.
|
||||
- New client offering `yamux` → old server: server ignores the feature, client
|
||||
sees no chosen method, falls back to its default (`smux`). No break.
|
||||
|
||||
This satisfies both constraints: 不改变当前协议 and 向后兼容.
|
||||
|
||||
## Constraints
|
||||
|
||||
1. **Timing is hard, not soft.** `smux.Client/Server` send/parse a version frame
|
||||
immediately at session creation; mismatched impl/version fails the handshake.
|
||||
Because `CmdBind` precedes it, the agreed choice is in time — *provided both
|
||||
ends build `muxCfg` from the agreed feature, not static metadata alone.*
|
||||
2. **Coverage = relay-based mux family only.** The negotiation window exists for
|
||||
tunnel handler, tunnel connector, relay handler, relay connector (all do
|
||||
`CmdBind` on the raw conn before mux). The **raw** mux tunnels
|
||||
(`listener/mtcp|mws|mtls` and their dialers) call `mux.ServerSession`/
|
||||
`ClientSession` directly with no relay handshake first, so they keep using the
|
||||
static `mux.type` from the implementation plan.
|
||||
3. **`relay/` is a separate zero-dependency module.** `FeatureMux` lives there,
|
||||
consumed by `x/`. Adding a feature type is backward-compatible but is a
|
||||
protocol-library change — deliberate, not casual.
|
||||
|
||||
## Relationship to the implementation plan
|
||||
|
||||
This negotiation design **extends** `2026-07-13-mux-pluggable-yamux.md`; it does
|
||||
not replace it. The mux package still gets the interface + yamux backend, and the
|
||||
static `mux.type` remains the default/fallback for raw-mux paths. The relay
|
||||
`FeatureMux` adds server-side multi-mux support + per-connection client selection
|
||||
for the tunnel/relay paths.
|
||||
|
||||
## Open questions (for later, when implementing)
|
||||
|
||||
- Exact `FeatureMux` payload encoding (method-ID list vs. string tags).
|
||||
- How the server's supported-methods set is configured (metadata keys like
|
||||
`mux.supported` / `mux.types`).
|
||||
- Whether `connector/socks/v5` muxbind (SOCKS5, not relay) should get an
|
||||
analogous SOCKS5-method negotiation, or stay static.
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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,36 +4,43 @@ 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/go-shadowsocks2 v0.1.3
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-gost/core v0.6.0
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.4
|
||||
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/tls-dissector v0.2.0
|
||||
github.com/go-gost/plugin v0.5.0
|
||||
github.com/go-gost/quic-dissector v0.1.0
|
||||
github.com/go-gost/relay v0.7.0
|
||||
github.com/go-gost/tls-dissector v0.3.1
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/gobwas/glob v0.2.3
|
||||
github.com/golang/snappy v0.0.4
|
||||
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/hashicorp/yamux v0.1.1
|
||||
github.com/klauspost/compress v1.19.0
|
||||
github.com/miekg/dns v1.1.61
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pion/dtls/v3 v3.1.1
|
||||
github.com/pion/dtls/v3 v3.1.4
|
||||
github.com/pires/go-proxyproto v0.8.1
|
||||
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/quic-go/quic-go v0.60.0
|
||||
github.com/quic-go/webtransport-go v0.11.1
|
||||
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
|
||||
github.com/tidwall/gjson v1.19.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54
|
||||
github.com/vishvananda/netns v0.0.4
|
||||
github.com/vulcand/predicate v1.2.0
|
||||
@@ -43,46 +50,55 @@ require (
|
||||
github.com/xtaci/tcpraw v1.2.25
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
github.com/zalando/go-keyring v0.2.4
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.40.0
|
||||
golang.org/x/time v0.12.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.10
|
||||
google.golang.org/grpc v1.82.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // 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/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // 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
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/coreos/go-iptables v0.5.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
@@ -90,16 +106,16 @@ require (
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/reedsolomon v1.11.8 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/onsi/gomega v1.34.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -120,16 +136,16 @@ require (
|
||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260723215102-3fe39f3c1018 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -10,18 +10,18 @@ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl
|
||||
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=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-iptables v0.5.0 h1:mw6SAibtHKZcNzAsOxjoHIG0gy5YFHhypWSSNc6EjbQ=
|
||||
github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
|
||||
@@ -43,46 +43,52 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
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/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/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-gost/core v0.6.0 h1:Y7jG5bI0QxdzDlMOyEtAYxCz+0GxAHKmaei667pRPWc=
|
||||
github.com/go-gost/core v0.6.0/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A=
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.4 h1:n2Po4TDKdLp1PsvhSiWFB/6S4e/YKZfsKJkA0PUa168=
|
||||
github.com/go-gost/go-shadowsocks2 v0.1.4/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/tls-dissector v0.2.0 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc1SicVg=
|
||||
github.com/go-gost/tls-dissector v0.2.0/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
|
||||
github.com/go-gost/plugin v0.5.0 h1:zK3Ezcv+z6bz6aUhaFZcZiJc42w44ohybzrj5tnWzcM=
|
||||
github.com/go-gost/plugin v0.5.0/go.mod h1:oN23l+yGDCIP9G3KnDl/I/0zVGOobZUDCB2Z5yYYXts=
|
||||
github.com/go-gost/quic-dissector v0.1.0 h1:zOaw2XjKxMf6vVC1z4BHDKGHbCs4zrYU1Rxaz5d0TQU=
|
||||
github.com/go-gost/quic-dissector v0.1.0/go.mod h1:ar+I40Izq9ZT2k/aNnLFGEMvdeSOBLLKxhF/i3FSnQQ=
|
||||
github.com/go-gost/relay v0.7.0 h1:J8e3Sba6DtBJQotXY5j5EaZNWwtv6Z9CoCFQsnrHNcE=
|
||||
github.com/go-gost/relay v0.7.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
|
||||
github.com/go-gost/tls-dissector v0.3.1 h1:gvOteWog5pjY/HCpc8l+gngmSi8Q6zl5rRrfK8gwRKA=
|
||||
github.com/go-gost/tls-dissector v0.3.1/go.mod h1:vGfog053fIm93iXBtvmVzMQqEJo5YwbbNaPNVDjbiOc=
|
||||
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=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -106,6 +112,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=
|
||||
@@ -121,24 +128,26 @@ github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf h1:C1GPyPJr
|
||||
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf/go.mod h1:zXqxTI6jXDdKnlf8s+nT+3c8LrwUEy3yNpO4XJL90lA=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
||||
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||
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=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/reedsolomon v1.11.8 h1:s8RpUW5TK4hjr+djiOpbZJB4ksx+TdYbRH7vHQpwPOY=
|
||||
github.com/klauspost/reedsolomon v1.11.8/go.mod h1:4bXRN+cVzMdml6ti7qLouuYi32KHJ5MGv0Qd8a47h6A=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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=
|
||||
@@ -162,10 +171,10 @@ github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pion/dtls/v3 v3.1.1 h1:wSLMam9Kf7DL1A74hnqRvEb9OT+aXPAsQ5VS+BdXOJ0=
|
||||
github.com/pion/dtls/v3 v3.1.1/go.mod h1:7FGvVYpHsUV6+aywaFpG7aE4Vz8nBOx74odPRFue6cI=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
|
||||
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
@@ -177,6 +186,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=
|
||||
@@ -186,12 +197,14 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
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/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA=
|
||||
github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA=
|
||||
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=
|
||||
@@ -206,6 +219,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=
|
||||
@@ -233,9 +252,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
@@ -244,12 +262,25 @@ github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
|
||||
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
|
||||
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
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=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 h1:8mhqcHPqTMhSPoslhGYihEgSfc77+7La1P6kiB6+9So=
|
||||
github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
|
||||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||
@@ -269,6 +300,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=
|
||||
@@ -277,33 +310,34 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
@@ -312,8 +346,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -322,33 +356,36 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -357,37 +394,37 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260723215102-3fe39f3c1018 h1:yXIvV9x4Vu2wUs2cCW8puVLHAjZkuipNK1MnTCZ0Jo0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260723215102-3fe39f3c1018/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -405,5 +442,3 @@ gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 h1:0DxLu8hxI1OGp1qVRPqNd+2k
|
||||
gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
+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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,21 @@ package local
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
quicdissector "github.com/go-gost/quic-dissector"
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/bypass"
|
||||
xbypass "github.com/go-gost/x/bypass"
|
||||
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/net/proxyproto"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
@@ -40,6 +45,7 @@ func (h *forwardHandler) dialTarget(ctx context.Context, conn net.Conn, ro *xrec
|
||||
log.Error(errNodeNotAvailable)
|
||||
return nil, errNodeNotAvailable
|
||||
}
|
||||
ro.Node = target.Name
|
||||
addr := target.Addr
|
||||
if opts := target.Options(); opts != nil {
|
||||
if opts.Network != "" {
|
||||
@@ -99,13 +105,41 @@ func (h *forwardHandler) dialTarget(ctx context.Context, conn net.Conn, ro *xrec
|
||||
// handleRawForwarding performs node selection, dials the target through the
|
||||
// router, and pipes the raw connection. It is used when sniffing is disabled
|
||||
// or the protocol was not HTTP/TLS.
|
||||
func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, network, proto string) error {
|
||||
// When proto is "quic" and clientDgram is set, the first upstream datagram is
|
||||
// intercepted to parse the TLS ServerHello, then threaded back via
|
||||
// io.MultiReader before the pipe.
|
||||
func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, network, proto string, clientDgram []byte) error {
|
||||
dr, err := h.dialTarget(ctx, conn, ro, log, network, proto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dr.cc.Close()
|
||||
|
||||
// QUIC ServerHello sniff from upstream response.
|
||||
if proto == sniffing.ProtoQUIC && ro.TLS != nil {
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
dr.cc.SetReadDeadline(time.Now().Add(h.md.sniffingTimeout))
|
||||
}
|
||||
|
||||
sdgram := make([]byte, h.md.bufferSize)
|
||||
n, readErr := dr.cc.Read(sdgram)
|
||||
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
dr.cc.SetReadDeadline(time.Time{})
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
dr.log.Debugf("udp server sniff read: %v", readErr)
|
||||
}
|
||||
if n > 0 {
|
||||
sdgram = sdgram[:n]
|
||||
if sh, shErr := quicdissector.SniffQUICServerHello(clientDgram, sdgram); shErr == nil {
|
||||
sniffing.PopulateQUICServerHello(sh, ro)
|
||||
}
|
||||
dr.cc = xnet.NewReadWriteConn(io.MultiReader(bytes.NewReader(sdgram), dr.cc), dr.cc, dr.cc)
|
||||
}
|
||||
}
|
||||
|
||||
t := time.Now()
|
||||
log.Infof("%s <-> %s", conn.RemoteAddr(), dr.target.Addr)
|
||||
if err := xnet.Pipe(ctx, conn, dr.cc, xnet.WithReadTimeout(h.md.idleTimeout)); err != nil {
|
||||
@@ -126,8 +160,36 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
|
||||
//
|
||||
// The method reuses dialTarget for the shared hop-selection, Router.Dial,
|
||||
// proxyproto, and recorder preamble.
|
||||
func (h *forwardHandler) handleRawDatagram(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, network, proto string) error {
|
||||
dr, err := h.dialTarget(ctx, conn, ro, log, network, proto)
|
||||
func (h *forwardHandler) handleRawDatagram(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, network string) error {
|
||||
bufp := make([]byte, h.md.bufferSize)
|
||||
n, err := conn.Read(bufp)
|
||||
if err != nil {
|
||||
log.Debugf("conn read: %v", err)
|
||||
return err
|
||||
}
|
||||
clientDgram := bufp[:n]
|
||||
|
||||
if h.md.sniffing {
|
||||
if info, sniffErr := quicdissector.SniffQUIC(clientDgram); sniffErr == nil {
|
||||
sniffing.PopulateQUICClientHello(clientDgram, info, ro)
|
||||
|
||||
if info.ServerName != "" {
|
||||
ro.Host = net.JoinHostPort(info.ServerName, "443")
|
||||
log = log.WithFields(map[string]any{
|
||||
"host": ro.Host,
|
||||
"proto": ro.Proto,
|
||||
})
|
||||
|
||||
if h.options.Bypass != nil &&
|
||||
h.options.Bypass.Contains(ctx, "udp", ro.Host, bypass.WithService(h.options.Service)) {
|
||||
log.Debug("bypass: ", ro.Host)
|
||||
return xbypass.ErrBypass
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dr, err := h.dialTarget(ctx, conn, ro, log, network, ro.Proto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -136,19 +198,12 @@ func (h *forwardHandler) handleRawDatagram(ctx context.Context, conn net.Conn, r
|
||||
t := time.Now()
|
||||
log.Infof("%s <-> %s", conn.RemoteAddr(), dr.target.Addr)
|
||||
|
||||
bufp := make([]byte, h.md.bufferSize)
|
||||
n, err := conn.Read(bufp)
|
||||
if err != nil {
|
||||
log.Debugf("conn read: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := dr.cc.Write(bufp[:n]); err != nil {
|
||||
if _, err := dr.cc.Write(clientDgram); err != nil {
|
||||
log.Debugf("outbound write: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Read the response into the same buffer to avoid a second allocation.
|
||||
// Read the response.
|
||||
if err := dr.cc.SetReadDeadline(time.Now().Add(h.md.readTimeout)); err != nil {
|
||||
log.Debugf("set read deadline: %v", err)
|
||||
return err
|
||||
@@ -158,11 +213,17 @@ func (h *forwardHandler) handleRawDatagram(ctx context.Context, conn net.Conn, r
|
||||
log.Debugf("outbound read: %v", err)
|
||||
return err
|
||||
}
|
||||
// Clear the deadline so it doesn't affect subsequent use of the
|
||||
// underlying connection (e.g., keep-alive pools).
|
||||
dr.cc.SetReadDeadline(time.Time{})
|
||||
|
||||
if _, err := conn.Write(bufp[:n]); err != nil {
|
||||
respDgram := bufp[:n]
|
||||
|
||||
if ro.Proto == sniffing.ProtoQUIC {
|
||||
if sh, shErr := quicdissector.SniffQUICServerHello(clientDgram, respDgram); shErr == nil {
|
||||
sniffing.PopulateQUICServerHello(sh, ro)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := conn.Write(respDgram); err != nil {
|
||||
log.Debugf("conn write: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestHandleRawForwarding_NilHop(t *testing.T) {
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "")
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when hop is nil")
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestHandleRawForwarding_NodeUnavailable(t *testing.T) {
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "")
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when node is nil")
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestHandleRawForwarding_DialError(t *testing.T) {
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "")
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when dial fails")
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestHandleRawForwarding_Success(t *testing.T) {
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "")
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func TestHandleRawForwarding_AddrWithoutPort(t *testing.T) {
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "")
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func TestHandleRawForwarding_UnixSocket(t *testing.T) {
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "")
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func TestHandleRawForwarding_MarkerReset(t *testing.T) {
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "")
|
||||
err := h.handleRawForwarding(context.Background(), conn, ro, nopLog(), "tcp", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -62,17 +62,24 @@ package local
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
quicdissector "github.com/go-gost/quic-dissector"
|
||||
|
||||
"github.com/go-gost/core/bypass"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hop"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xbypass "github.com/go-gost/x/bypass"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/util/httpcache"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
rate_limiter "github.com/go-gost/x/limiter/rate"
|
||||
@@ -138,6 +145,7 @@ func (h *forwardHandler) Init(md md.Metadata) (err error) {
|
||||
CertPool: h.certPool,
|
||||
MitmBypass: h.md.mitmBypass,
|
||||
ReadTimeout: h.md.readTimeout,
|
||||
Cache: httpcache.FromMetadata(h.options.Cache, md),
|
||||
}
|
||||
|
||||
return
|
||||
@@ -207,32 +215,77 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
||||
}
|
||||
|
||||
if h.md.stateless {
|
||||
return h.handleRawDatagram(ctx, conn, ro, log, network, "udp")
|
||||
return h.handleRawDatagram(ctx, conn, ro, log, network)
|
||||
}
|
||||
|
||||
var proto string
|
||||
if network == "tcp" && h.md.sniffing {
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Now().Add(h.md.sniffingTimeout))
|
||||
}
|
||||
var clientDgram []byte
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
proto, err = sniffing.Sniff(ctx, br)
|
||||
ro.Proto = proto
|
||||
if err != nil {
|
||||
log.Debugf("sniff: %v", err)
|
||||
}
|
||||
if h.md.sniffing {
|
||||
if network == "tcp" {
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Now().Add(h.md.sniffingTimeout))
|
||||
}
|
||||
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
br := bufio.NewReader(conn)
|
||||
proto, err = sniffing.Sniff(ctx, br)
|
||||
ro.Proto = proto
|
||||
if err != nil {
|
||||
log.Debugf("sniff: %v", err)
|
||||
}
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
handled, sniffErr := h.handleSniffedProtocol(ctx, conn, ro, log, proto)
|
||||
if handled {
|
||||
return sniffErr
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
handled, sniffErr := h.handleSniffedProtocol(ctx, conn, ro, log, proto)
|
||||
if handled {
|
||||
ro.Time = time.Time{}
|
||||
return sniffErr
|
||||
}
|
||||
} else {
|
||||
// UDP QUIC ClientHello sniff.
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Now().Add(h.md.sniffingTimeout))
|
||||
}
|
||||
|
||||
dgram := make([]byte, h.md.bufferSize)
|
||||
n, readErr := conn.Read(dgram)
|
||||
|
||||
if h.md.sniffingTimeout > 0 {
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
log.Debugf("udp sniff read: %v", readErr)
|
||||
return readErr
|
||||
}
|
||||
dgram = dgram[:n]
|
||||
|
||||
if info, sniffErr := quicdissector.SniffQUIC(dgram); sniffErr == nil {
|
||||
clientDgram = dgram
|
||||
proto = sniffing.ProtoQUIC
|
||||
sniffing.PopulateQUICClientHello(dgram, info, ro)
|
||||
|
||||
if info.ServerName != "" {
|
||||
ro.Host = net.JoinHostPort(info.ServerName, "443")
|
||||
log = log.WithFields(map[string]any{
|
||||
"host": ro.Host,
|
||||
"proto": ro.Proto,
|
||||
})
|
||||
|
||||
if h.options.Bypass != nil &&
|
||||
h.options.Bypass.Contains(ctx, "udp", ro.Host, bypass.WithService(h.options.Service)) {
|
||||
log.Debug("bypass: ", ro.Host)
|
||||
return xbypass.ErrBypass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn = xnet.NewReadWriteConn(io.MultiReader(bytes.NewReader(dgram), conn), conn, conn)
|
||||
}
|
||||
}
|
||||
|
||||
return h.handleRawForwarding(ctx, conn, ro, log, network, proto)
|
||||
return h.handleRawForwarding(ctx, conn, ro, log, network, proto, clientDgram)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
"github.com/go-gost/x/internal/net/proxyproto"
|
||||
"github.com/go-gost/x/internal/util/forwarder"
|
||||
"github.com/go-gost/x/internal/util/httpcache"
|
||||
"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"
|
||||
@@ -36,6 +37,8 @@ type SnifferBuilder struct {
|
||||
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
|
||||
// See forwarder.Sniffer.ReadTimeout for details.
|
||||
ReadTimeout time.Duration
|
||||
// Cache is the HTTP response cache (nil when caching is disabled).
|
||||
Cache *httpcache.Cache
|
||||
}
|
||||
|
||||
// Build creates a new forwarder.Sniffer from the builder's configuration.
|
||||
@@ -51,6 +54,7 @@ func (b *SnifferBuilder) Build() *forwarder.Sniffer {
|
||||
CertPool: b.CertPool,
|
||||
MitmBypass: b.MitmBypass,
|
||||
ReadTimeout: b.ReadTimeout,
|
||||
Cache: b.Cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,35 +72,19 @@ func (h *forwardHandler) sniffingDial(ctx context.Context, network, address stri
|
||||
}
|
||||
|
||||
// handleSniffedProtocol dispatches a sniffed connection to the protocol-specific
|
||||
// sniffer (HandleHTTP or HandleTLS). It returns (true, err) when the protocol was
|
||||
// sniffer registered for proto. It returns (true, err) when the protocol was
|
||||
// handled and (false, nil) when the caller should fall through to raw forwarding.
|
||||
func (h *forwardHandler) 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)
|
||||
}
|
||||
sniffer := h.sniffer.Build()
|
||||
if proto == sniffing.ProtoHTTP {
|
||||
return true, sniffer.HandleHTTP(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
forwarder.WithBypass(h.options.Bypass),
|
||||
forwarder.WithHTTPKeepalive(h.md.httpKeepalive),
|
||||
forwarder.WithRecorderObject(ro),
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleTLS(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
forwarder.WithBypass(h.options.Bypass),
|
||||
forwarder.WithRecorderObject(ro),
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
default:
|
||||
return false, nil
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return h.sniffingDial(ctx, network, address, ro)
|
||||
}
|
||||
return forwarder.Dispatch(h.sniffer.Build(), ctx, conn, proto,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithHop(h.getHop()),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithHTTPKeepalive(h.md.httpKeepalive),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
|
||||
log.Error(errNodeNotAvailable)
|
||||
return errNodeNotAvailable
|
||||
}
|
||||
ro.Node = target.Name
|
||||
addr := target.Addr
|
||||
if opts := target.Options(); opts != nil {
|
||||
if opts.Network != "" {
|
||||
@@ -92,6 +93,15 @@ func (h *forwardHandler) handleRawForwarding(ctx context.Context, conn net.Conn,
|
||||
// metadata when set, then falling back to hop selection.
|
||||
func (h *forwardHandler) selectTarget(ctx context.Context, proto string) *chain.Node {
|
||||
if host := mdutil.GetString(ictx.MetadataFromContext(ctx), "host"); host != "" {
|
||||
// Route the tunneled hostname through the hop so a node with a matching
|
||||
// filter.host is selected (the node's addr is the real target). If the
|
||||
// hop has no matching node (e.g. no filter configured), fall back to
|
||||
// dialing the hostname directly — the pre-regression behavior.
|
||||
if curHop := h.getHop(); curHop != nil {
|
||||
if node := curHop.Select(ctx, hop.ProtocolSelectOption(proto), hop.HostSelectOption(host)); node != nil {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return &chain.Node{Addr: host}
|
||||
}
|
||||
if curHop := h.getHop(); curHop != nil {
|
||||
|
||||
@@ -201,7 +201,52 @@ func TestHandleRawForwarding_MarkerReset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRawForwarding_ContextHost(t *testing.T) {
|
||||
// TestHandleRawForwarding_ContextHostHop: a tunneled hostname with a hop whose
|
||||
// node matches the host filter routes to that node's addr (the documented
|
||||
// filter.host client routing). The node addr is the real target.
|
||||
func TestHandleRawForwarding_ContextHostHop(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
var gotHost string
|
||||
h := newInitdHandler(withRouter(&mockRouter{
|
||||
opts: &chain.RouterOptions{},
|
||||
dialFn: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
h.Forward(&mockHop{
|
||||
selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node {
|
||||
var so hop.SelectOptions
|
||||
for _, o := range opts {
|
||||
o(&so)
|
||||
}
|
||||
gotHost = so.Host
|
||||
return &chain.Node{Name: "example", Addr: "10.0.0.2:80"}
|
||||
},
|
||||
})
|
||||
ctx := ictx.ContextWithMetadata(context.Background(), xmd.NewMetadata(map[string]any{
|
||||
"host": "example.local:80",
|
||||
}))
|
||||
conn := newStringConn(nil)
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := h.handleRawForwarding(ctx, conn, ro, nopLog(), "tcp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotHost != "example.local:80" {
|
||||
t.Errorf("expected host passed to hop selection, got '%s'", gotHost)
|
||||
}
|
||||
if ro.Host != "10.0.0.2:80" {
|
||||
t.Errorf("expected node addr to be dialed, got '%s'", ro.Host)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRawForwarding_ContextHostNoHop: when there is no hop, the tunneled
|
||||
// hostname is dialed directly (fallback preserving the pre-regression behavior).
|
||||
func TestHandleRawForwarding_ContextHostNoHop(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
@@ -212,11 +257,6 @@ func TestHandleRawForwarding_ContextHost(t *testing.T) {
|
||||
return server, nil
|
||||
},
|
||||
}))
|
||||
h.Forward(&mockHop{
|
||||
selectFn: func(ctx context.Context, opts ...hop.SelectOption) *chain.Node {
|
||||
return &chain.Node{Addr: "10.0.0.2:80"}
|
||||
},
|
||||
})
|
||||
ctx := ictx.ContextWithMetadata(context.Background(), xmd.NewMetadata(map[string]any{
|
||||
"host": "10.0.0.99:8080",
|
||||
}))
|
||||
|
||||
@@ -73,6 +73,7 @@ import (
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/util/httpcache"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
rate_limiter "github.com/go-gost/x/limiter/rate"
|
||||
@@ -138,6 +139,7 @@ func (h *forwardHandler) Init(md md.Metadata) (err error) {
|
||||
CertPool: h.certPool,
|
||||
MitmBypass: h.md.mitmBypass,
|
||||
ReadTimeout: h.md.readTimeout,
|
||||
Cache: httpcache.FromMetadata(h.options.Cache, md),
|
||||
}
|
||||
|
||||
return
|
||||
@@ -226,6 +228,7 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
handled, sniffErr := h.handleSniffedProtocol(ctx, conn, ro, log, proto)
|
||||
if handled {
|
||||
ro.Time = time.Time{}
|
||||
return sniffErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
"github.com/go-gost/x/internal/net/proxyproto"
|
||||
"github.com/go-gost/x/internal/util/forwarder"
|
||||
"github.com/go-gost/x/internal/util/httpcache"
|
||||
"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"
|
||||
@@ -36,6 +37,8 @@ type SnifferBuilder struct {
|
||||
// and TLS ServerHello during sniffing. Passed through to forwarder.Sniffer.
|
||||
// See forwarder.Sniffer.ReadTimeout for details.
|
||||
ReadTimeout time.Duration
|
||||
// Cache is the HTTP response cache (nil when caching is disabled).
|
||||
Cache *httpcache.Cache
|
||||
}
|
||||
|
||||
// Build creates a new forwarder.Sniffer from the builder's configuration.
|
||||
@@ -51,6 +54,7 @@ func (b *SnifferBuilder) Build() *forwarder.Sniffer {
|
||||
CertPool: b.CertPool,
|
||||
MitmBypass: b.MitmBypass,
|
||||
ReadTimeout: b.ReadTimeout,
|
||||
Cache: b.Cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,35 +72,19 @@ func (h *forwardHandler) sniffingDial(ctx context.Context, network, address stri
|
||||
}
|
||||
|
||||
// handleSniffedProtocol dispatches a sniffed connection to the protocol-specific
|
||||
// sniffer (HandleHTTP or HandleTLS). It returns (true, err) when the protocol was
|
||||
// sniffer registered for proto. It returns (true, err) when the protocol was
|
||||
// handled and (false, nil) when the caller should fall through to raw forwarding.
|
||||
func (h *forwardHandler) 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)
|
||||
}
|
||||
sniffer := h.sniffer.Build()
|
||||
if proto == sniffing.ProtoHTTP {
|
||||
return true, sniffer.HandleHTTP(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
forwarder.WithBypass(h.options.Bypass),
|
||||
forwarder.WithHTTPKeepalive(h.md.httpKeepalive),
|
||||
forwarder.WithRecorderObject(ro),
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleTLS(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
forwarder.WithBypass(h.options.Bypass),
|
||||
forwarder.WithRecorderObject(ro),
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
default:
|
||||
return false, nil
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return h.sniffingDial(ctx, network, address, ro)
|
||||
}
|
||||
return forwarder.Dispatch(h.sniffer.Build(), ctx, conn, proto,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithHop(h.getHop()),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithHTTPKeepalive(h.md.httpKeepalive),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
|
||||
+16
-23
@@ -15,12 +15,13 @@ 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"
|
||||
"github.com/go-gost/x/internal/util/httpcache"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
traffic_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
|
||||
@@ -79,6 +80,7 @@ func (h *httpHandler) handleConnect(ctx context.Context, conn net.Conn, ro *xrec
|
||||
if h.md.sniffing {
|
||||
snifferHandled, err = h.sniffAndHandle(ctx, conn, cc, ro, log)
|
||||
if snifferHandled {
|
||||
ro.Time = time.Time{}
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -126,26 +128,14 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
|
||||
sniffer := h.sniffer.Build()
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP:
|
||||
return true, sniffer.HandleHTTP(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
case sniffing.ProtoTLS:
|
||||
return true, sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return sniffing.Dispatch(sniffer, ctx, "tcp", conn, proto,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
|
||||
// dial establishes an upstream connection through the proxy chain router.
|
||||
@@ -233,7 +223,9 @@ 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
|
||||
// Cache is the HTTP response cache (nil when caching is disabled).
|
||||
Cache *httpcache.Cache
|
||||
}
|
||||
|
||||
// Build creates a new sniffing.Sniffer from the builder's configuration.
|
||||
@@ -249,5 +241,6 @@ func (b *SnifferBuilder) Build() *sniffing.Sniffer {
|
||||
CertPool: b.CertPool,
|
||||
MitmBypass: b.MitmBypass,
|
||||
ReadTimeout: b.ReadTimeout,
|
||||
Cache: b.Cache,
|
||||
}
|
||||
}
|
||||
|
||||
+63
-14
@@ -190,6 +190,9 @@ package http
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -209,6 +212,7 @@ import (
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
xhttp "github.com/go-gost/x/internal/net/http"
|
||||
"github.com/go-gost/x/internal/util/httpcache"
|
||||
stats_util "github.com/go-gost/x/internal/util/stats"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
rate_limiter "github.com/go-gost/x/limiter/rate"
|
||||
@@ -302,6 +306,7 @@ func (h *httpHandler) Init(md md.Metadata) error {
|
||||
CertPool: h.certPool,
|
||||
MitmBypass: h.md.mitmBypass,
|
||||
ReadTimeout: h.md.readTimeout,
|
||||
Cache: httpcache.FromMetadata(h.options.Cache, md),
|
||||
}
|
||||
|
||||
if h.md.certificate != nil && h.md.privateKey != nil {
|
||||
@@ -353,6 +358,9 @@ func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
})
|
||||
log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr())
|
||||
|
||||
// Save the raw conn before stats wrapping to preserve TLS metadata.
|
||||
rawConn := conn
|
||||
|
||||
pStats := xstats.Stats{}
|
||||
conn = stats_wrapper.WrapConn(conn, &pStats)
|
||||
|
||||
@@ -386,6 +394,11 @@ func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler
|
||||
}
|
||||
defer req.Body.Close()
|
||||
|
||||
// Extract mTLS peer certificate from the raw connection.
|
||||
if peerCert := getTLSPeerCert(rawConn); peerCert != nil {
|
||||
ctx = xctx.ContextWithPeerCert(ctx, peerCert)
|
||||
}
|
||||
|
||||
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
|
||||
ro.ClientIP = clientIP.String()
|
||||
}
|
||||
@@ -445,20 +458,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 +479,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
|
||||
|
||||
@@ -543,6 +559,39 @@ func buildHTTPRecorder(req *http.Request) *xrecorder.HTTPRecorderObject {
|
||||
}
|
||||
}
|
||||
|
||||
// getTLSPeerCert extracts the verified mTLS client certificate identity from
|
||||
// a connection by walking wrapper layers (traffic limiter, etc.) to reach the
|
||||
// underlying *tls.Conn, then reading ConnectionState().VerifiedChains.
|
||||
// It returns nil if the connection has no TLS peer certificate.
|
||||
func getTLSPeerCert(conn net.Conn) *xctx.PeerCert {
|
||||
for {
|
||||
if tc, ok := conn.(interface{ ConnectionState() tls.ConnectionState }); ok {
|
||||
cs := tc.ConnectionState()
|
||||
if cs.HandshakeComplete && len(cs.VerifiedChains) > 0 && len(cs.VerifiedChains[0]) > 0 {
|
||||
cert := cs.VerifiedChains[0][0]
|
||||
fpr := sha256.Sum256(cert.Raw)
|
||||
sans := make([]string, 0, len(cert.DNSNames)+len(cert.EmailAddresses)+len(cert.URIs))
|
||||
sans = append(sans, cert.DNSNames...)
|
||||
sans = append(sans, cert.EmailAddresses...)
|
||||
for _, u := range cert.URIs {
|
||||
sans = append(sans, u.String())
|
||||
}
|
||||
return &xctx.PeerCert{
|
||||
CN: cert.Subject.CommonName,
|
||||
SANs: sans,
|
||||
Fingerprint: hex.EncodeToString(fpr[:]),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if uw, ok := conn.(interface{ UnwrapConn() net.Conn }); ok {
|
||||
conn = uw.UnwrapConn()
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// observeStats periodically publishes per-client traffic stats to the
|
||||
// configured Observer. It runs in a background goroutine started by Init.
|
||||
// Events that fail to send are retried on the next tick.
|
||||
|
||||
@@ -93,6 +93,11 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
|
||||
*ro2 = *ro
|
||||
ro = ro2
|
||||
|
||||
if v := req.Header.Get("Gost-Record"); v != "" {
|
||||
ro.RecordMode = strings.ToLower(v)
|
||||
}
|
||||
req.Header.Del("Gost-Record")
|
||||
|
||||
host := normalizeHostPort(req.Host, "80")
|
||||
ro.Host = host
|
||||
ro.Time = time.Now()
|
||||
@@ -177,7 +182,7 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
|
||||
// Optionally intercept the request body for recording. The original
|
||||
// body is replaced with a tee reader so the transport still sees it.
|
||||
var reqBody *xhttp.Body
|
||||
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && req.Body != nil {
|
||||
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && req.Body != nil && ro.RecordMode != "headers" && ro.RecordMode != "off" {
|
||||
reqBody = xhttp.NewBody(req.Body, bodySize)
|
||||
req.Body = reqBody
|
||||
}
|
||||
@@ -226,7 +231,7 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
|
||||
|
||||
// Optionally intercept the response body for recording.
|
||||
var respBody *xhttp.Body
|
||||
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 {
|
||||
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && ro.RecordMode != "headers" && ro.RecordMode != "off" {
|
||||
respBody = xhttp.NewBody(resp.Body, bodySize)
|
||||
resp.Body = respBody
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func (h *httpHandler) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Bu
|
||||
MaskKey: fr.Header.MaskKey,
|
||||
Length: fr.Header.PayloadLength,
|
||||
}
|
||||
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 {
|
||||
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && ro.RecordMode != "headers" && ro.RecordMode != "off" {
|
||||
buf.Reset()
|
||||
if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil {
|
||||
return err
|
||||
|
||||
+19
-17
@@ -368,9 +368,6 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
|
||||
return ErrDatagramNotSupport
|
||||
}
|
||||
|
||||
// Get the underlying HTTP/3 stream
|
||||
stream := streamer.HTTPStream()
|
||||
|
||||
// Get target connection - either through router/chain or direct
|
||||
var targetPC net.PacketConn
|
||||
|
||||
@@ -393,15 +390,17 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
|
||||
|
||||
ro.SrcAddr = c.LocalAddr().String()
|
||||
|
||||
// The connection from router should be a PacketConn (e.g., from masque connector)
|
||||
if pc, ok := c.(net.PacketConn); ok {
|
||||
targetPC = pc
|
||||
log.Debugf("relaying UDP to %s via chain", targetAddr)
|
||||
} else {
|
||||
// Wrap as PacketConn if it's a regular Conn
|
||||
targetPC = &connPacketConn{Conn: c, raddr: raddr}
|
||||
log.Debugf("relaying UDP to %s via chain (wrapped)", targetAddr)
|
||||
}
|
||||
// Always wrap the router-returned conn as a point-to-point PacketConn
|
||||
// that writes via Conn.Write against the fixed target. A direct UDP
|
||||
// dial returns a *net.UDPConn that satisfies net.PacketConn but is
|
||||
// pre-connected, so calling WriteTo on it fails with "use of WriteTo
|
||||
// with pre-connected connection" and no packet ever reaches the target.
|
||||
// Routing writes through Conn.Write works for both direct dials and
|
||||
// stream-based upstream MASQUE connectors. The MASQUE target is fixed
|
||||
// for the connection lifetime (parsed from the request path), so a
|
||||
// single remote address is always correct.
|
||||
targetPC = &connPacketConn{Conn: c, raddr: raddr}
|
||||
log.Debugf("relaying UDP to %s via chain", targetAddr)
|
||||
} else {
|
||||
// Direct connection - create local UDP socket
|
||||
directConn, err := net.ListenPacket("udp", "")
|
||||
@@ -424,14 +423,17 @@ 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
|
||||
// Send the success response WITH the mandatory Capsule-Protocol header
|
||||
// (RFC 9298), then hijack the stream. HTTPStream() flushes the response
|
||||
// internally and commits the headers, so Capsule-Protocol MUST be set
|
||||
// before it; otherwise the client never sees Capsule-Protocol: ?1 and
|
||||
// rejects the tunnel. Dialing the target first (above) means we only
|
||||
// respond 200 once the relay path is ready, matching handleConnectTCP.
|
||||
w.Header().Set("Capsule-Protocol", "?1")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Flush the response headers
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
// Hijack the underlying HTTP/3 stream for datagram exchange.
|
||||
stream := streamer.HTTPStream()
|
||||
|
||||
// Create datagram connection wrapping the HTTP/3 stream (client side)
|
||||
datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user