mirror of
https://github.com/go-gost/x.git
synced 2026-10-20 00:17:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f143aa694 | ||
|
|
a654aee7af | ||
|
|
c2c78f3279 | ||
|
|
9e3ce7cb68 | ||
|
|
bb682a90c9 | ||
|
|
be86cfef74 | ||
|
|
640e239280 | ||
|
|
98f64ea13d | ||
|
|
295a2c2296 | ||
|
|
1d42c2ec66 | ||
|
|
3e533c61c7 | ||
|
|
89188536d3 | ||
|
|
239da867c3 | ||
|
|
17909c4f95 | ||
|
|
8f6a725e7e | ||
|
|
312187d3d8 | ||
|
|
bacaa9af89 | ||
|
|
a939758583 | ||
|
|
9ec8bdbfeb | ||
|
|
4bbf81c9f4 | ||
|
|
6911ecd304 | ||
|
|
429c6a6199 | ||
|
|
1e17b38512 | ||
|
|
d2678b8f5f | ||
|
|
3d49aef879 | ||
|
|
dc9c7444b9 | ||
|
|
f1cd7c945a | ||
|
|
bc137fbd83 | ||
|
|
c67b2ec1d2 | ||
|
|
8787aff108 | ||
|
|
6e84d375c1 | ||
|
|
e95b830670 | ||
|
|
64b14930b3 | ||
|
|
70b52694eb | ||
|
|
8683da6a65 | ||
|
|
2306f45bf9 | ||
|
|
85c257ad65 | ||
|
|
ec4800293d | ||
|
|
1fadb8fe38 | ||
|
|
660af7ce59 | ||
|
|
ce290f9143 | ||
|
|
1776e1a4ef | ||
|
|
a9cc544470 | ||
|
|
73baffc689 | ||
|
|
5b85e90db8 | ||
|
|
3627030098 | ||
|
|
34fbff3d3c | ||
|
|
1c42c638a6 | ||
|
|
06197bd987 | ||
|
|
a5605b935c | ||
|
|
8c999f1f5b | ||
|
|
7a4db061c9 | ||
|
|
a3e82c086c | ||
|
|
ad702b4cbe | ||
|
|
2cb1365d46 | ||
|
|
4f3261a9d9 | ||
|
|
988c9afe28 | ||
|
|
30f5a0b2c8 | ||
|
|
5bf06ef9ac | ||
|
|
8c255d8ec4 | ||
|
|
54ea0220a9 | ||
|
|
f70b114578 | ||
|
|
4cd6cd5c30 | ||
|
|
dcd02f8d52 | ||
|
|
d7f0b88086 | ||
|
|
71c1566b00 | ||
|
|
8127c99153 | ||
|
|
967053c690 | ||
|
|
fe9d9c9574 | ||
|
|
dadbbdf5c7 | ||
|
|
c210b74106 | ||
|
|
dac42ba39c | ||
|
|
53269265e1 | ||
|
|
cfccd22fc9 | ||
|
|
de301e8cfb | ||
|
|
e119a973f2 | ||
|
|
66960e7dd6 | ||
|
|
14fed91c62 | ||
|
|
2299c43bb7 | ||
|
|
dd65d46926 | ||
|
|
2e9befc39c | ||
|
|
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 |
+23
@@ -0,0 +1,23 @@
|
||||
# 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/
|
||||
.memory/
|
||||
.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
+40
-46
@@ -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 (
|
||||
@@ -44,13 +44,13 @@ var ErrBypass = errors.New("bypass")
|
||||
type options struct {
|
||||
// whitelist toggles between blacklist and whitelist mode.
|
||||
// When false (default): matching addresses bypass the proxy.
|
||||
// When true: only matching addresses bypass the proxy.
|
||||
// When true: matching addresses use the proxy and all others bypass it.
|
||||
whitelist bool
|
||||
|
||||
// network restricts this bypass to a specific network protocol.
|
||||
// When set, the incoming network must match before address matchers
|
||||
// are evaluated. Recognized values include "tcp" and "udp".
|
||||
// An empty value disables the network check.
|
||||
// When set, the incoming network must match as part of the base rule.
|
||||
// Recognized values include "tcp" and "udp". An empty value
|
||||
// disables the network check.
|
||||
network string
|
||||
|
||||
// matchers holds static patterns provided at construction time
|
||||
@@ -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
|
||||
@@ -89,8 +89,8 @@ func WhitelistOption(whitelist bool) Option {
|
||||
}
|
||||
|
||||
// NetworkOption restricts this bypass to a specific network protocol.
|
||||
// When set, Contains returns false when the incoming network does not
|
||||
// match, regardless of address matchers. Accepts values like "tcp" or "udp".
|
||||
// Network and address predicates are combined before whitelist mode is
|
||||
// applied to the resulting match. Accepts values like "tcp" or "udp".
|
||||
func NetworkOption(network string) Option {
|
||||
return func(opts *options) {
|
||||
opts.network = network
|
||||
@@ -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.
|
||||
@@ -427,43 +427,28 @@ func (p *localBypass) Contains(ctx context.Context, network, addr string, opts .
|
||||
return decision == decisionBypass
|
||||
}
|
||||
|
||||
// decide returns the bypass decision for the given address, applying the
|
||||
// whitelist or blacklist mode to the pattern match result.
|
||||
// decide returns the bypass decision for the given address. Network and
|
||||
// address predicates form one base match, then whitelist mode is applied once
|
||||
// to that combined result.
|
||||
func (p *localBypass) decide(network string, addr string) bypassDecision {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if p.options.network != "" && p.options.network != network {
|
||||
matched := p.options.network == "" || p.options.network == network
|
||||
if p.patterns != nil {
|
||||
matched = matched && p.patterns.matchAny(addr)
|
||||
} else if p.options.network == "" {
|
||||
// A bypass with no predicates has no base match.
|
||||
matched = false
|
||||
}
|
||||
|
||||
// Whitelist is a top-level negation of the normal blacklist decision:
|
||||
// blacklist mode proxies a failed combined match, while whitelist mode
|
||||
// proxies a successful combined match.
|
||||
if matched == p.options.whitelist {
|
||||
return decisionProxy
|
||||
}
|
||||
|
||||
if p.patterns == nil {
|
||||
if p.options.network != "" {
|
||||
if p.options.whitelist {
|
||||
return decisionProxy
|
||||
}
|
||||
return decisionBypass
|
||||
}
|
||||
|
||||
return decisionProxy
|
||||
}
|
||||
|
||||
matched := p.patterns.matchAny(addr)
|
||||
|
||||
if p.options.whitelist {
|
||||
// Whitelist mode: the pattern set specifies addresses that MUST use the proxy.
|
||||
// Matched addresses go through the proxy; unmatched addresses bypass.
|
||||
if matched {
|
||||
return decisionProxy
|
||||
}
|
||||
return decisionBypass
|
||||
}
|
||||
// Blacklist mode: the pattern set specifies addresses that should bypass the proxy.
|
||||
// Matched addresses bypass; unmatched addresses go through the proxy.
|
||||
if matched {
|
||||
return decisionBypass
|
||||
}
|
||||
return decisionProxy
|
||||
return decisionBypass
|
||||
}
|
||||
|
||||
func (p *localBypass) IsWhitelist() bool {
|
||||
@@ -552,8 +537,17 @@ func (p *bypassGroup) evaluate(ctx context.Context, network, addr string, opts .
|
||||
return decisionProxy
|
||||
}
|
||||
|
||||
// IsWhitelist always returns false for a group, since the group may contain
|
||||
// a mix of whitelist and blacklist rules.
|
||||
// IsWhitelist reports whether the group acts as a pure whitelist: every
|
||||
// member is a whitelist rule. A mixed group is neither pure whitelist nor
|
||||
// pure blacklist, so it reports false.
|
||||
func (p *bypassGroup) IsWhitelist() bool {
|
||||
return false
|
||||
if len(p.bypasses) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, bp := range p.bypasses {
|
||||
if !bp.IsWhitelist() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
+69
-12
@@ -667,6 +667,20 @@ func TestBypassGroup_IsWhitelist(t *testing.T) {
|
||||
assert.False(t, g.IsWhitelist())
|
||||
}
|
||||
|
||||
// gost#899: in whitelist mode a domain-only allowlist must not reject the
|
||||
// destination IP before sniffing runs (the sniffer checks the sniffed host).
|
||||
func TestBypassGroup_WhitelistDomainNoMatch(t *testing.T) {
|
||||
g := BypassGroup(
|
||||
NewBypass(
|
||||
WhitelistOption(true),
|
||||
MatchersOption([]string{"example.com"}),
|
||||
LoggerOption(xlogger.Nop()),
|
||||
),
|
||||
)
|
||||
assert.True(t, g.Contains(context.Background(), "tcp", "93.184.216.34:443"))
|
||||
assert.False(t, g.Contains(context.Background(), "tcp", "example.com:443"))
|
||||
}
|
||||
|
||||
// --- NewBypass reload error in background ---
|
||||
|
||||
func TestNewBypass_ReloadError(t *testing.T) {
|
||||
@@ -751,7 +765,40 @@ func TestContains_NetworkMatch_WhitelistBypass(t *testing.T) {
|
||||
assert.True(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
|
||||
}
|
||||
|
||||
func TestContains_NetworkMismatch_ReturnsFalse(t *testing.T) {
|
||||
func TestContains_WhitelistNegatesCombinedMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
whitelist bool
|
||||
network string
|
||||
addr string
|
||||
wantProxy bool
|
||||
}{
|
||||
{name: "blacklist both match", network: "tcp", addr: "192.168.1.1", wantProxy: false},
|
||||
{name: "blacklist network fails", network: "udp", addr: "192.168.1.1", wantProxy: true},
|
||||
{name: "blacklist matcher fails", network: "tcp", addr: "10.0.0.1", wantProxy: true},
|
||||
{name: "blacklist both fail", network: "udp", addr: "10.0.0.1", wantProxy: true},
|
||||
{name: "whitelist both match", whitelist: true, network: "tcp", addr: "192.168.1.1", wantProxy: true},
|
||||
{name: "whitelist network fails", whitelist: true, network: "udp", addr: "192.168.1.1", wantProxy: false},
|
||||
{name: "whitelist matcher fails", whitelist: true, network: "tcp", addr: "10.0.0.1", wantProxy: false},
|
||||
{name: "whitelist both fail", whitelist: true, network: "udp", addr: "10.0.0.1", wantProxy: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := newSyncedBypass(
|
||||
WhitelistOption(tt.whitelist),
|
||||
NetworkOption("tcp"),
|
||||
MatchersOption([]string{"192.168.1.1"}),
|
||||
)
|
||||
defer b.Close()
|
||||
|
||||
gotProxy := !b.Contains(context.Background(), tt.network, tt.addr)
|
||||
assert.Equal(t, tt.wantProxy, gotProxy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContains_NetworkMismatch_BlacklistReturnsFalse(t *testing.T) {
|
||||
b := newSyncedBypass(
|
||||
NetworkOption("tcp"),
|
||||
MatchersOption([]string{"192.168.1.1"}),
|
||||
@@ -775,7 +822,7 @@ func TestContains_NetworkMismatch_EmptyAddr(t *testing.T) {
|
||||
assert.False(t, b.Contains(context.Background(), "tcp", ""))
|
||||
}
|
||||
|
||||
func TestContains_NetworkMismatch_WhitelistReturnsFalse(t *testing.T) {
|
||||
func TestContains_NetworkMismatch_WhitelistBypasses(t *testing.T) {
|
||||
b := newSyncedBypass(
|
||||
WhitelistOption(true),
|
||||
NetworkOption("tcp"),
|
||||
@@ -783,9 +830,9 @@ func TestContains_NetworkMismatch_WhitelistReturnsFalse(t *testing.T) {
|
||||
)
|
||||
defer b.Close()
|
||||
|
||||
// Network does not match → bypass returns false even in whitelist mode
|
||||
assert.False(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
|
||||
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
|
||||
// Network fails the combined predicate, then whitelist mode negates it.
|
||||
assert.True(t, b.Contains(context.Background(), "udp", "10.0.0.1"))
|
||||
assert.True(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestContains_PatternSetPassesThroughNetworkFilter(t *testing.T) {
|
||||
@@ -832,15 +879,15 @@ func TestNetworkOnly_Whitelist_ProxiesMatchingNetwork(t *testing.T) {
|
||||
assert.False(t, b.Contains(context.Background(), "tcp", "10.0.0.1"))
|
||||
}
|
||||
|
||||
func TestNetworkOnly_Whitelist_NonMatchingNotAffected(t *testing.T) {
|
||||
func TestNetworkOnly_Whitelist_NonMatchingBypasses(t *testing.T) {
|
||||
b := newSyncedBypass(
|
||||
WhitelistOption(true),
|
||||
NetworkOption("tcp"),
|
||||
)
|
||||
defer b.Close()
|
||||
|
||||
// network: tcp 是作用域限定,非 TCP 流量不受影响
|
||||
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
|
||||
// The network predicate fails, then whitelist mode negates it.
|
||||
assert.True(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestNetworkOnly_NoNetwork_BehavesAsBefore(t *testing.T) {
|
||||
@@ -852,6 +899,16 @@ func TestNetworkOnly_NoNetwork_BehavesAsBefore(t *testing.T) {
|
||||
assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestNetworkOnly_NoNetwork_WhitelistBypassesAll(t *testing.T) {
|
||||
b := newSyncedBypass(WhitelistOption(true))
|
||||
defer b.Close()
|
||||
|
||||
// No predicates → base match is false, whitelist negates it → bypass all.
|
||||
// This flipped from proxy-all before #129; lock in the new behavior.
|
||||
assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1"))
|
||||
assert.True(t, b.Contains(context.Background(), "udp", "192.168.1.1"))
|
||||
}
|
||||
|
||||
func TestNetworkOnly_EmptyAddr_ReturnsFalse(t *testing.T) {
|
||||
b := newSyncedBypass(NetworkOption("tcp"))
|
||||
defer b.Close()
|
||||
@@ -1022,9 +1079,9 @@ func TestPatternSet_MatchAny_Empty(t *testing.T) {
|
||||
|
||||
func TestPatternSet_MatchAny_MixedTypes(t *testing.T) {
|
||||
ps := classifyPatterns([]string{
|
||||
"192.168.1.1", // address
|
||||
"10.0.0.0/8", // CIDR
|
||||
"*.example.com", // wildcard
|
||||
"192.168.1.1", // address
|
||||
"10.0.0.0/8", // CIDR
|
||||
"*.example.com", // wildcard
|
||||
"172.16.0.1-172.16.0.255", // IP range
|
||||
}, xlogger.Nop())
|
||||
assert.True(t, ps.matchAny("192.168.1.1"))
|
||||
@@ -1129,7 +1186,7 @@ func TestDecide_NilPatterns(t *testing.T) {
|
||||
|
||||
func TestEvaluate_AllBlacklistAllMatch(t *testing.T) {
|
||||
g := BypassGroup(alwaysContains{}, alwaysContains{}).(*bypassGroup)
|
||||
assert.Equal(t, decisionBypass, g.evaluate(context.Background(), "tcp", "any", ))
|
||||
assert.Equal(t, decisionBypass, g.evaluate(context.Background(), "tcp", "any"))
|
||||
}
|
||||
|
||||
func TestEvaluate_BlacklistNoneMatch(t *testing.T) {
|
||||
|
||||
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.
|
||||
|
||||
+38
-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()
|
||||
@@ -640,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 {
|
||||
@@ -725,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.
|
||||
|
||||
+149
-14
@@ -4,12 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -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()
|
||||
@@ -132,6 +148,7 @@ type TLSOptions struct {
|
||||
type PluginConfig struct {
|
||||
Type string `json:"type"`
|
||||
Addr string `json:"addr"`
|
||||
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||
TLS *TLSConfig `yaml:",omitempty" json:"tls,omitempty"`
|
||||
Timeout time.Duration `yaml:",omitempty" json:"timeout,omitempty"`
|
||||
Token string `yaml:",omitempty" json:"token,omitempty"`
|
||||
@@ -157,6 +174,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 {
|
||||
@@ -284,6 +302,13 @@ type RecorderConfig struct {
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
// P2PConfig defines a named p2p tunnel provider, currently backed by an
|
||||
// external gRPC plugin process.
|
||||
type P2PConfig struct {
|
||||
Name string `json:"name"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
type FileRecorder struct {
|
||||
Path string `json:"path"`
|
||||
Sep string `yaml:",omitempty" json:"sep,omitempty"`
|
||||
@@ -321,6 +346,33 @@ type RewriterConfig struct {
|
||||
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"`
|
||||
@@ -365,9 +417,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 {
|
||||
@@ -389,6 +453,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"`
|
||||
}
|
||||
|
||||
@@ -407,6 +472,17 @@ type HTTPBodyRewriteConfig struct {
|
||||
MaxChunkSize int `yaml:"maxChunkSize,omitempty" json:"maxChunkSize,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPHeaderRewriteConfig struct {
|
||||
// Name is the header name regex, e.g. "(?i)^(location|set-cookie)$".
|
||||
Name string
|
||||
// Match is the header value regex.
|
||||
Match string
|
||||
// Replacement is the replacement string.
|
||||
Replacement string
|
||||
// Rewriter is the name of the rewriter plugin (via registry).
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
}
|
||||
|
||||
type NodeFilterConfig struct {
|
||||
Host string `yaml:",omitempty" json:"host,omitempty"`
|
||||
Protocol string `yaml:",omitempty" json:"protocol,omitempty"`
|
||||
@@ -426,15 +502,31 @@ 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
|
||||
// matchers. 0 (default) disables body reading for this node. Capped at
|
||||
// chain.MaxMatcherBodySize. Only takes effect under an HTTP sniffing handler
|
||||
// whose sniffer reads the body prefix before node selection.
|
||||
// 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 {
|
||||
// rewrite host header
|
||||
Host string `yaml:",omitempty" json:"host,omitempty"`
|
||||
// regex matched against the request path to derive Host; Host becomes a
|
||||
// replacement template ($1, $2, ...) when set.
|
||||
HostPattern string `yaml:"hostPattern,omitempty" json:"hostPattern,omitempty"`
|
||||
// Deprecated: use requestHeader instead
|
||||
Header map[string]string `yaml:",omitempty" json:"header,omitempty"`
|
||||
// additional request header
|
||||
@@ -452,6 +544,14 @@ type HTTPNodeConfig struct {
|
||||
// rewrite response body
|
||||
RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"`
|
||||
|
||||
// rewrite request header values
|
||||
RewriteRequestHeader []HTTPHeaderRewriteConfig `yaml:"rewriteRequestHeader,omitempty" json:"rewriteRequestHeader,omitempty"`
|
||||
// rewrite response header values
|
||||
RewriteResponseHeader []HTTPHeaderRewriteConfig `yaml:"rewriteResponseHeader,omitempty" json:"rewriteResponseHeader,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"`
|
||||
}
|
||||
@@ -502,6 +602,7 @@ type ServiceConfig struct {
|
||||
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"`
|
||||
@@ -589,9 +690,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 {
|
||||
@@ -635,6 +766,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"`
|
||||
}
|
||||
|
||||
@@ -642,6 +774,7 @@ type Config struct {
|
||||
Services []*ServiceConfig `json:"services"`
|
||||
Chains []*ChainConfig `yaml:",omitempty" json:"chains,omitempty"`
|
||||
Hops []*HopConfig `yaml:",omitempty" json:"hops,omitempty"`
|
||||
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
|
||||
Authers []*AutherConfig `yaml:",omitempty" json:"authers,omitempty"`
|
||||
Admissions []*AdmissionConfig `yaml:",omitempty" json:"admissions,omitempty"`
|
||||
Bypasses []*BypassConfig `yaml:",omitempty" json:"bypasses,omitempty"`
|
||||
@@ -652,6 +785,8 @@ type Config struct {
|
||||
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
|
||||
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
|
||||
P2Ps []*P2PConfig `yaml:"p2ps,omitempty" json:"p2ps,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"`
|
||||
@@ -670,7 +805,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 {
|
||||
@@ -679,7 +814,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 {
|
||||
@@ -688,7 +823,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 {
|
||||
@@ -701,9 +837,8 @@ func (c *Config) Write(w io.Writer, format string) error {
|
||||
case "yaml":
|
||||
fallthrough
|
||||
default:
|
||||
enc := yaml.NewEncoder(w)
|
||||
enc := yaml.NewEncoder(w, yaml.Indent(2))
|
||||
defer enc.Close()
|
||||
enc.SetIndent(2)
|
||||
|
||||
return enc.Encode(c)
|
||||
}
|
||||
|
||||
+48
-2
@@ -6,9 +6,13 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
@@ -21,14 +25,15 @@ import (
|
||||
"github.com/go-gost/core/recorder"
|
||||
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/router"
|
||||
"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"
|
||||
@@ -36,6 +41,7 @@ import (
|
||||
limiter_parser "github.com/go-gost/x/config/parsing/limiter"
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
observer_parser "github.com/go-gost/x/config/parsing/observer"
|
||||
p2p_parser "github.com/go-gost/x/config/parsing/p2p"
|
||||
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"
|
||||
@@ -44,6 +50,7 @@ import (
|
||||
sd_parser "github.com/go-gost/x/config/parsing/sd"
|
||||
service_parser "github.com/go-gost/x/config/parsing/service"
|
||||
quota "github.com/go-gost/x/limiter/quota"
|
||||
xp2p "github.com/go-gost/x/p2p"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -256,6 +263,26 @@ func register(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[xp2p.Tunnel]
|
||||
for _, c := range cfg.P2Ps {
|
||||
entries = append(entries, named[xp2p.Tunnel]{c.Name, p2p_parser.ParseP2P(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.P2PRegistry()); 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 {
|
||||
@@ -296,7 +323,26 @@ func register(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// --- hops (references bypasses, resolvers, hosts from registries) ---
|
||||
// --- nodes (definitions referenced by hops) ---
|
||||
|
||||
{
|
||||
var entries []named[*config.NodeConfig]
|
||||
for _, c := range cfg.Nodes {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
c.Name = strings.TrimSpace(c.Name)
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("global node name is required")
|
||||
}
|
||||
entries = append(entries, named[*config.NodeConfig]{c.Name, c})
|
||||
}
|
||||
if err := registerGroup(entries, registry.NodeRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// --- hops (references nodes, bypasses, resolvers, hosts from registries) ---
|
||||
|
||||
{
|
||||
var entries []named[hop.Hop]
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/metadata"
|
||||
@@ -17,6 +18,9 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/registry"
|
||||
|
||||
_ "github.com/go-gost/x/connector/http"
|
||||
_ "github.com/go-gost/x/dialer/tcp"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -465,6 +469,77 @@ func TestRegister_Hops(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_GlobalNodesBeforeHops(t *testing.T) {
|
||||
nodeName := "test-global-node"
|
||||
hopName := "test-global-node-hop"
|
||||
t.Cleanup(func() {
|
||||
registry.HopRegistry().Unregister(hopName)
|
||||
registry.NodeRegistry().Unregister(nodeName)
|
||||
})
|
||||
|
||||
cfg := &config.Config{
|
||||
Nodes: []*config.NodeConfig{{
|
||||
Name: nodeName,
|
||||
Addr: "example.com:8080",
|
||||
}},
|
||||
Hops: []*config.HopConfig{{
|
||||
Name: hopName,
|
||||
Nodes: []*config.NodeConfig{{Name: nodeName}},
|
||||
}},
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
definition := registry.NodeRegistry().Get(nodeName)
|
||||
if definition == nil {
|
||||
t.Fatal("expected global node to be registered")
|
||||
}
|
||||
nodes := registry.HopRegistry().Get(hopName).(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 || nodes[0].Name != definition.Name || nodes[0].Addr != definition.Addr {
|
||||
t.Fatalf("hop nodes = %v, want global definition %q at %q", nodes, definition.Name, definition.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_GlobalNodeReferenceFromYAML(t *testing.T) {
|
||||
const nodeName = "yaml-global-node"
|
||||
const hopName = "yaml-global-hop"
|
||||
t.Cleanup(func() {
|
||||
registry.HopRegistry().Unregister(hopName)
|
||||
registry.NodeRegistry().Unregister(nodeName)
|
||||
})
|
||||
|
||||
cfg := &config.Config{}
|
||||
err := cfg.Read(strings.NewReader(`
|
||||
nodes:
|
||||
- name: yaml-global-node
|
||||
addr: global.example:8080
|
||||
hops:
|
||||
- name: yaml-global-hop
|
||||
nodes:
|
||||
- name: yaml-global-node
|
||||
- name: inline-node
|
||||
addr: inline.example:8081
|
||||
`), "yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("read YAML: %v", err)
|
||||
}
|
||||
if err := register(cfg); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
nodes := registry.HopRegistry().Get(hopName).(hop.NodeList).Nodes()
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("hop nodes = %d, want 2", len(nodes))
|
||||
}
|
||||
if nodes[0].Name != nodeName || nodes[0].Addr != "global.example:8080" {
|
||||
t.Fatalf("referenced node = %q at %q", nodes[0].Name, nodes[0].Addr)
|
||||
}
|
||||
if nodes[1].Name != "inline-node" || nodes[1].Addr != "inline.example:8081" {
|
||||
t.Fatalf("inline node = %q at %q", nodes[1].Name, nodes[1].Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_Chains(t *testing.T) {
|
||||
r := registry.ChainRegistry()
|
||||
name := "test-chain"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,9 @@ func ParseAdmission(cfg *config.AdmissionConfig) admission.Admission {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -27,6 +28,9 @@ func ParseAuther(cfg *config.AutherConfig) auth.Authenticator {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,9 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
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
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
xs "github.com/go-gost/x/selector"
|
||||
)
|
||||
|
||||
// ParseHop converts a HopConfig into a hop.Hop. It resolves plugin backends
|
||||
@@ -31,6 +33,9 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
@@ -83,6 +88,16 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
v, err = node_parser.ResolveConfig(v)
|
||||
if err != nil {
|
||||
// Fail the whole hop: static config is validated at startup,
|
||||
// so a dangling reference is a hard error. (Contrast with
|
||||
// chainHop.parseNode, which warns and skips bad nodes to
|
||||
// keep hot-reloaded hops resilient.)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build a merged metadata map for inheritance without mutating
|
||||
// the original node config.
|
||||
merged := make(map[string]any)
|
||||
@@ -158,10 +173,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{
|
||||
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/registry"
|
||||
|
||||
// Register connector and dialer implementations needed for node parsing.
|
||||
_ "github.com/go-gost/x/connector/http"
|
||||
@@ -185,6 +186,81 @@ func TestParseHop_WithNodeNilEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_GlobalNodeReference(t *testing.T) {
|
||||
global := &config.NodeConfig{Name: "global-node", Addr: "example.com:8080"}
|
||||
registry.NodeRegistry().Register(global.Name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
|
||||
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "reference-hop",
|
||||
Nodes: []*config.NodeConfig{{Name: global.Name}},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 || nodes[0].Name != global.Name || nodes[0].Addr != global.Addr {
|
||||
t.Fatalf("referenced nodes = %v, want node %q at %q", nodes, global.Name, global.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_GlobalNodeReferenceNotFound(t *testing.T) {
|
||||
_, err := ParseHop(&config.HopConfig{
|
||||
Name: "reference-hop",
|
||||
Nodes: []*config.NodeConfig{{Name: "missing-global-node"}},
|
||||
}, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing global node error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_NodeWithAdditionalFieldRemainsInline(t *testing.T) {
|
||||
global := &config.NodeConfig{Name: "shared-name", Addr: "global.example:8080"}
|
||||
registry.NodeRegistry().Register(global.Name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
|
||||
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "inline-hop",
|
||||
Nodes: []*config.NodeConfig{{
|
||||
Name: global.Name,
|
||||
Addr: "inline.example:8080",
|
||||
}},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
nodes := h.(hop.NodeList).Nodes()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0].Addr != "inline.example:8080" {
|
||||
t.Fatalf("node addr = %q, want inline node address", nodes[0].Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_GlobalNodeCreatesIndependentRuntimeNodes(t *testing.T) {
|
||||
global := &config.NodeConfig{Name: "independent-node", Addr: "example.com:8080"}
|
||||
registry.NodeRegistry().Register(global.Name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
|
||||
|
||||
parse := func(name string) *chain.Node {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: name, Nodes: []*config.NodeConfig{{Name: global.Name}},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseHop: %v", err)
|
||||
}
|
||||
return h.(hop.NodeList).Nodes()[0]
|
||||
}
|
||||
|
||||
if first, second := parse("hop-1"), parse("hop-2"); first == second {
|
||||
t.Fatal("global node references must create independent runtime nodes")
|
||||
}
|
||||
if global.Connector != nil || global.Dialer != nil {
|
||||
t.Fatal("parsing a reference must not mutate the global definition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHop_SockOptsMark(t *testing.T) {
|
||||
h, err := ParseHop(&config.HopConfig{
|
||||
Name: "sockopts-hop",
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
hosts_plugin "github.com/go-gost/x/hosts/plugin"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseHostMapper converts a HostsConfig into a hosts.HostMapper. It resolves
|
||||
@@ -23,6 +24,9 @@ func ParseHostMapper(cfg *config.HostsConfig) hosts.HostMapper {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
ingress_plugin "github.com/go-gost/x/ingress/plugin"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseIngress converts an IngressConfig into an ingress.Ingress. It resolves
|
||||
@@ -22,6 +23,9 @@ func ParseIngress(cfg *config.IngressConfig) ingress.Ingress {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
xrate "github.com/go-gost/x/limiter/rate"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
traffic_plugin "github.com/go-gost/x/limiter/traffic/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseTrafficLimiter converts a LimiterConfig into a traffic.TrafficLimiter.
|
||||
@@ -26,6 +27,9 @@ func ParseTrafficLimiter(cfg *config.LimiterConfig) (lim traffic.TrafficLimiter)
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -44,10 +45,20 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
|
||||
Compress: cfg.Log.Rotation.Compress,
|
||||
}
|
||||
} else {
|
||||
// Nothing rotates this file, and it is the only place that sees both
|
||||
// the path and the missing rotation — the default (an unconfigured
|
||||
// logger) always carries one.
|
||||
fmt.Fprintf(os.Stderr, "logger: %s has no rotation configured: it will grow without bound\n", cfg.Log.Output)
|
||||
os.MkdirAll(filepath.Dir(cfg.Log.Output), 0755)
|
||||
f, err := os.OpenFile(cfg.Log.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
logger.Default().Warn(err)
|
||||
// Reported on stderr, not through logger.Default(): that logger
|
||||
// is what this call is on its way to installing and is nil until
|
||||
// it is — calling a method on it panics, which aborted the whole
|
||||
// process on a log path that could not be opened (an app whose
|
||||
// working directory is not writable, say). A log file that
|
||||
// cannot be opened leaves the default output in place instead.
|
||||
fmt.Fprintf(os.Stderr, "logger: open %s: %v (logging to stderr)\n", cfg.Log.Output, err)
|
||||
} else {
|
||||
out = f
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
@@ -9,8 +12,15 @@ import (
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// probeEnv marks the child process TestParseLogger_UnopenableOutput spawns:
|
||||
// that test needs a process in which no default logger was ever installed,
|
||||
// which is the state the failure path used to dereference.
|
||||
const probeEnv = "X_LOGGER_UNOPENABLE_PROBE"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
if os.Getenv(probeEnv) != "1" {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
}
|
||||
m.Run()
|
||||
}
|
||||
|
||||
@@ -140,3 +150,33 @@ func TestList_WithNames(t *testing.T) {
|
||||
t.Fatal("expected empty list for unregistered names")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLogger_UnopenableOutput: a log file that cannot be opened must leave
|
||||
// the process logging somewhere, not panic. The failure used to be reported
|
||||
// through logger.Default(), which is nil until a caller installs one — and this
|
||||
// call is usually the one doing the installing — so an unwritable path (an app
|
||||
// whose working directory is not writable, a typo'd directory) dereferenced a
|
||||
// nil logger and took the whole process down.
|
||||
//
|
||||
// The state that matters is process-wide and TestMain sets it for every other
|
||||
// case here, so the assertion runs in a child process: a fresh one is the only
|
||||
// place the default logger is still unset, exactly as it is at startup.
|
||||
func TestParseLogger_UnopenableOutput(t *testing.T) {
|
||||
if os.Getenv(probeEnv) == "1" {
|
||||
lg := ParseLogger(&config.LoggerConfig{Log: &config.LogConfig{
|
||||
Output: "/nonexistent-dir-for-test/wisper.log",
|
||||
Level: "info",
|
||||
}})
|
||||
if lg == nil {
|
||||
fmt.Fprintln(os.Stderr, "ParseLogger returned nil for an unopenable output")
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestParseLogger_UnopenableOutput", "-test.v")
|
||||
cmd.Env = append(os.Environ(), probeEnv+"=1")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("a log file that cannot be opened must not be fatal:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
+415
-50
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
@@ -20,14 +22,218 @@ import (
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
mdx "github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xp2p "github.com/go-gost/x/p2p"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/go-gost/x/routing"
|
||||
)
|
||||
|
||||
// MaxMatcherBodySize bounds the request body prefix (in bytes) exposed to
|
||||
// body matchers via routing.Request.Body. It protects against unbounded
|
||||
// buffering when a node opts in to body matching.
|
||||
const MaxMatcherBodySize = 1 << 20 // 1MB
|
||||
// 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
|
||||
|
||||
// IsReference reports whether cfg contains only a non-empty name. Such a
|
||||
// config references a definition from the top-level nodes section.
|
||||
// Any additional field, even a zero-valued one set explicitly (e.g. an
|
||||
// empty but non-nil connector), makes the config inline.
|
||||
// NOTE: keep this in sync with NodeConfig — a new field must be added here,
|
||||
// otherwise a config setting only that field would misresolve as a reference.
|
||||
func IsReference(cfg *config.NodeConfig) bool {
|
||||
if cfg == nil || strings.TrimSpace(cfg.Name) == "" {
|
||||
return false
|
||||
}
|
||||
return cfg.Addr == "" && cfg.Network == "" && cfg.Bypass == "" &&
|
||||
len(cfg.Bypasses) == 0 && cfg.Resolver == "" && cfg.Hosts == "" &&
|
||||
cfg.Connector == nil && cfg.Dialer == nil &&
|
||||
cfg.Interface == "" && cfg.Netns == "" && cfg.SockOpts == nil &&
|
||||
cfg.Filter == nil && cfg.Matcher == nil && cfg.HTTP == nil &&
|
||||
cfg.TLS == nil && cfg.Probe == nil && len(cfg.Metadata) == 0
|
||||
}
|
||||
|
||||
// ResolveConfig resolves a name-only config through the global node registry
|
||||
// and returns an independent copy of the definition. Inline configs are
|
||||
// returned unchanged to preserve their existing parsing behavior.
|
||||
func ResolveConfig(cfg *config.NodeConfig) (*config.NodeConfig, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !IsReference(cfg) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(cfg.Name)
|
||||
cfg = registry.NodeRegistry().Get(name)
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("node %q not found", name)
|
||||
}
|
||||
return cloneConfig(cfg), nil
|
||||
}
|
||||
|
||||
// cloneConfig returns a deep copy of cfg so that parsing (hop-level
|
||||
// inheritance, connector/dialer type defaults, TLS ServerName defaulting)
|
||||
// only ever mutates the copy, never the shared global definition.
|
||||
// NOTE: keep this in sync with NodeConfig — a new reference-type field
|
||||
// must be cloned here, otherwise hops would share it with the registry.
|
||||
func cloneConfig(cfg *config.NodeConfig) *config.NodeConfig {
|
||||
c := *cfg
|
||||
c.Bypasses = append([]string(nil), cfg.Bypasses...)
|
||||
if cfg.Metadata != nil {
|
||||
c.Metadata = make(map[string]any, len(cfg.Metadata))
|
||||
for k, v := range cfg.Metadata {
|
||||
c.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
if cfg.Connector != nil {
|
||||
connector := *cfg.Connector
|
||||
connector.Auth = cloneAuth(cfg.Connector.Auth)
|
||||
connector.TLS = cloneTLSConfig(cfg.Connector.TLS)
|
||||
if cfg.Connector.Metadata != nil {
|
||||
connector.Metadata = make(map[string]any, len(cfg.Connector.Metadata))
|
||||
for k, v := range cfg.Connector.Metadata {
|
||||
connector.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
c.Connector = &connector
|
||||
}
|
||||
if cfg.Dialer != nil {
|
||||
dialer := *cfg.Dialer
|
||||
dialer.Auth = cloneAuth(cfg.Dialer.Auth)
|
||||
dialer.TLS = cloneTLSConfig(cfg.Dialer.TLS)
|
||||
if cfg.Dialer.Metadata != nil {
|
||||
dialer.Metadata = make(map[string]any, len(cfg.Dialer.Metadata))
|
||||
for k, v := range cfg.Dialer.Metadata {
|
||||
dialer.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
c.Dialer = &dialer
|
||||
}
|
||||
if cfg.SockOpts != nil {
|
||||
sockOpts := *cfg.SockOpts
|
||||
c.SockOpts = &sockOpts
|
||||
}
|
||||
if cfg.Filter != nil {
|
||||
filter := *cfg.Filter
|
||||
c.Filter = &filter
|
||||
}
|
||||
if cfg.Matcher != nil {
|
||||
matcher := *cfg.Matcher
|
||||
c.Matcher = &matcher
|
||||
}
|
||||
if cfg.HTTP != nil {
|
||||
http := *cfg.HTTP
|
||||
http.Header = cloneStringMap(cfg.HTTP.Header)
|
||||
http.RequestHeader = cloneStringMap(cfg.HTTP.RequestHeader)
|
||||
http.ResponseHeader = cloneStringMap(cfg.HTTP.ResponseHeader)
|
||||
http.Rewrite = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.Rewrite...)
|
||||
http.RewriteURL = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.RewriteURL...)
|
||||
http.RewriteBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteBody...)
|
||||
http.RewriteRequestBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteRequestBody...)
|
||||
http.RewriteResponseBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteResponseBody...)
|
||||
http.RewriteRequestHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteRequestHeader...)
|
||||
http.RewriteResponseHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteResponseHeader...)
|
||||
http.Auth = cloneAuth(cfg.HTTP.Auth)
|
||||
c.HTTP = &http
|
||||
}
|
||||
if cfg.TLS != nil {
|
||||
tls := *cfg.TLS
|
||||
if cfg.TLS.Options != nil {
|
||||
options := *cfg.TLS.Options
|
||||
options.CipherSuites = append([]string(nil), cfg.TLS.Options.CipherSuites...)
|
||||
options.ALPN = append([]string(nil), cfg.TLS.Options.ALPN...)
|
||||
tls.Options = &options
|
||||
}
|
||||
c.TLS = &tls
|
||||
}
|
||||
if cfg.Probe != nil {
|
||||
probe := *cfg.Probe
|
||||
probe.HTTPHeaders = cloneStringMap(cfg.Probe.HTTPHeaders)
|
||||
c.Probe = &probe
|
||||
}
|
||||
return &c
|
||||
}
|
||||
|
||||
func cloneAuth(cfg *config.AuthConfig) *config.AuthConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
auth := *cfg
|
||||
return &auth
|
||||
}
|
||||
|
||||
func cloneTLSConfig(cfg *config.TLSConfig) *config.TLSConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
tls := *cfg
|
||||
tls.ServerNames = append([]string(nil), cfg.ServerNames...)
|
||||
if cfg.Options != nil {
|
||||
options := *cfg.Options
|
||||
options.CipherSuites = append([]string(nil), cfg.Options.CipherSuites...)
|
||||
options.ALPN = append([]string(nil), cfg.Options.ALPN...)
|
||||
tls.Options = &options
|
||||
}
|
||||
return &tls
|
||||
}
|
||||
|
||||
func cloneStringMap(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
c := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
c[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -38,9 +244,43 @@ const MaxMatcherBodySize = 1 << 20 // 1MB
|
||||
func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []chain.HTTPBodyRewriteSettings {
|
||||
var out []chain.HTTPBodyRewriteSettings
|
||||
for _, v := range vs {
|
||||
pattern, _ := regexp.Compile(v.Match)
|
||||
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: v.Type,
|
||||
Type: rewriteType,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
MaxChunkSize: v.MaxChunkSize,
|
||||
@@ -58,6 +298,38 @@ func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []c
|
||||
return out
|
||||
}
|
||||
|
||||
func parseHeaderRewrites(vs []config.HTTPHeaderRewriteConfig, log logger.Logger) []chain.HTTPHeaderRewriteSettings {
|
||||
var out []chain.HTTPHeaderRewriteSettings
|
||||
for _, v := range vs {
|
||||
var name, pattern *regexp.Regexp
|
||||
if v.Name != "" {
|
||||
name, _ = regexp.Compile(v.Name)
|
||||
}
|
||||
if v.Match != "" {
|
||||
pattern, _ = regexp.Compile(v.Match)
|
||||
}
|
||||
|
||||
rw := chain.HTTPHeaderRewriteSettings{
|
||||
Name: name,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Gate on the config string, not the compiled regexp: regexp.Compile("")
|
||||
// returns a non-nil match-all regexp, so an empty Name with no rewriter
|
||||
// would otherwise become a silent no-op rule.
|
||||
if v.Name != "" || 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
|
||||
@@ -152,6 +424,25 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
return nil, fmt.Errorf("unregistered dialer: %s", dialCfg.Type)
|
||||
}
|
||||
|
||||
// p2p: wrap the dialer so the node's base transport (the path to this
|
||||
// node) goes through a tunnel opened by the named p2p plugin instead of
|
||||
// a direct network dial. The inner dialer's protocol (tls/ws/mux) runs
|
||||
// unchanged on top of the tunnel. Only whitelisted dialers qualify:
|
||||
// the wrapper's contract is "inner dials one plain TCP stream via
|
||||
// options.Dialer". The wrap happens before Init so the chain inits
|
||||
// exactly once, through the wrapper.
|
||||
if name := mdutil.GetString(md, parsing.MDKeyP2P); name != "" {
|
||||
provider := registry.P2PRegistry().Get(name)
|
||||
if provider == nil {
|
||||
return nil, fmt.Errorf("unregistered p2p: %s", name)
|
||||
}
|
||||
if !xp2p.SupportedDialer(dialCfg.Type) {
|
||||
return nil, fmt.Errorf("dialer %q does not support p2p", dialCfg.Type)
|
||||
}
|
||||
d = xp2p.NewTunnelDialer(d, provider,
|
||||
xp2p.WithTunnelNetwork(mdutil.GetString(md, parsing.MDKeyP2PNetwork)))
|
||||
}
|
||||
|
||||
if err := d.Init(mdx.NewMetadata(dialCfg.Metadata)); err != nil {
|
||||
dialerLogger.Error("init: ", err)
|
||||
return nil, err
|
||||
@@ -180,55 +471,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)
|
||||
// Priority 0 means "use default": automatically set to the
|
||||
// rule length so longer (more specific) rules outrank shorter
|
||||
// ones. Use a negative priority to opt out of this behavior
|
||||
// and always go through the selector.
|
||||
if priority == 0 {
|
||||
priority = len(cfg.Matcher.Rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
if bodySize := cfg.Matcher.BodySize; bodySize > 0 {
|
||||
if bodySize > MaxMatcherBodySize {
|
||||
bodySize = MaxMatcherBodySize
|
||||
}
|
||||
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -239,6 +528,10 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
settings.RequestHeader = cfg.HTTP.Header
|
||||
}
|
||||
|
||||
if v := cfg.HTTP.HostPattern; v != "" {
|
||||
settings.HostPattern, _ = regexp.Compile(v)
|
||||
}
|
||||
|
||||
if auth := cfg.HTTP.Auth; auth != nil && auth.Username != "" {
|
||||
settings.Auther = xauth.NewAuthenticator(
|
||||
xauth.AuthsOption(map[string]string{auth.Username: auth.Password}),
|
||||
@@ -265,6 +558,13 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
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)...)
|
||||
settings.RewriteRequestHeader = parseHeaderRewrites(cfg.HTTP.RewriteRequestHeader, log)
|
||||
settings.RewriteResponseHeader = parseHeaderRewrites(cfg.HTTP.RewriteResponseHeader, log)
|
||||
|
||||
if v := strings.TrimSpace(cfg.HTTP.FailCodes); v != "" {
|
||||
settings.FailCodes = parseFailCodes(v, nodeLogger)
|
||||
}
|
||||
|
||||
opts = append(opts, chain.HTTPNodeOption(settings))
|
||||
}
|
||||
|
||||
@@ -281,5 +581,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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/registry"
|
||||
|
||||
// Register connector and dialer implementations needed for node parsing.
|
||||
_ "github.com/go-gost/x/connector/http"
|
||||
@@ -387,6 +388,44 @@ func TestParseNode_WithHTTPDeprecatedHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHeaderRewrite(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "hdr-node",
|
||||
Addr: "example.com:8080",
|
||||
HTTP: &config.HTTPNodeConfig{
|
||||
RewriteRequestHeader: []config.HTTPHeaderRewriteConfig{
|
||||
{Name: "(?i)^referer$", Match: ".*", Replacement: ""},
|
||||
},
|
||||
RewriteResponseHeader: []config.HTTPHeaderRewriteConfig{
|
||||
{Name: "(?i)^location$", Match: "https://github.com", Replacement: "https://127.0.0.1:8080"},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
settings := n.Options().HTTP
|
||||
if settings == nil {
|
||||
t.Fatal("expected HTTP settings")
|
||||
}
|
||||
if len(settings.RewriteRequestHeader) != 1 || len(settings.RewriteResponseHeader) != 1 {
|
||||
t.Fatalf("rewrite header counts = %d/%d, want 1/1", len(settings.RewriteRequestHeader), len(settings.RewriteResponseHeader))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHeaderRewrites_EmptyNameNoRewriter(t *testing.T) {
|
||||
// Empty Name with no Rewriter must not be appended (silent no-op guard).
|
||||
out := parseHeaderRewrites([]config.HTTPHeaderRewriteConfig{
|
||||
{Name: "", Match: ".*", Replacement: ""},
|
||||
}, testLogger())
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("out = %d rules, want 0", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithTLSNode(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "tls-node",
|
||||
@@ -501,3 +540,136 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.NodeConfig
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty name", &config.NodeConfig{}, false},
|
||||
{"blank name", &config.NodeConfig{Name: " "}, false},
|
||||
{"name only", &config.NodeConfig{Name: "shared"}, true},
|
||||
{"name and addr", &config.NodeConfig{Name: "shared", Addr: "example.com:80"}, false},
|
||||
{"name and network", &config.NodeConfig{Name: "shared", Network: "tcp"}, false},
|
||||
{"name and empty-but-non-nil connector", &config.NodeConfig{Name: "shared", Connector: &config.ConnectorConfig{}}, false},
|
||||
{"name and empty bypasses slice", &config.NodeConfig{Name: "shared", Bypasses: []string{}}, true},
|
||||
{"name and matcher", &config.NodeConfig{Name: "shared", Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsReference(tt.cfg); got != tt.want {
|
||||
t.Errorf("IsReference(%+v) = %v, want %v", tt.cfg, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfig_TrimsName(t *testing.T) {
|
||||
const name = "spaced-global-node"
|
||||
registry.NodeRegistry().Register(name, &config.NodeConfig{Name: name, Addr: "example.com:8080"})
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
|
||||
|
||||
got, err := ResolveConfig(&config.NodeConfig{Name: " " + name + " "})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if got == nil || got.Addr != "example.com:8080" {
|
||||
t.Fatalf("resolved = %+v, want addr %q", got, "example.com:8080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfig_DeepCopyIsolation(t *testing.T) {
|
||||
const name = "rich-global-node"
|
||||
global := &config.NodeConfig{
|
||||
Name: name,
|
||||
Addr: "example.com:8080",
|
||||
Metadata: map[string]any{"k": "v"},
|
||||
Connector: &config.ConnectorConfig{
|
||||
Type: "http",
|
||||
Auth: &config.AuthConfig{Username: "u"},
|
||||
Metadata: map[string]any{"ck": "cv"},
|
||||
TLS: &config.TLSConfig{ServerName: "sni.example", Options: &config.TLSOptions{ALPN: []string{"h2"}}},
|
||||
},
|
||||
Dialer: &config.DialerConfig{
|
||||
Type: "tcp",
|
||||
Metadata: map[string]any{"dk": "dv"},
|
||||
},
|
||||
Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"},
|
||||
HTTP: &config.HTTPNodeConfig{
|
||||
Host: "h",
|
||||
RequestHeader: map[string]string{"A": "b"},
|
||||
Auth: &config.AuthConfig{Username: "hu"},
|
||||
},
|
||||
TLS: &config.TLSNodeConfig{ServerName: "tls.example", Options: &config.TLSOptions{MinVersion: "1.2"}},
|
||||
Probe: &config.ProbeConfig{Type: "tcp", Addr: "example.com:80", HTTPHeaders: map[string]string{"H": "v"}},
|
||||
}
|
||||
registry.NodeRegistry().Register(name, global)
|
||||
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
|
||||
|
||||
got, err := ResolveConfig(&config.NodeConfig{Name: name})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
|
||||
// Mutate every nested value on the copy; the global must not change.
|
||||
got.Metadata["k"] = "mut"
|
||||
got.Connector.Auth.Username = "mut"
|
||||
got.Connector.Metadata["ck"] = "mut"
|
||||
got.Connector.TLS.Options.ALPN[0] = "mut"
|
||||
got.Dialer.Metadata["dk"] = "mut"
|
||||
got.Matcher.Rule = "mut"
|
||||
got.HTTP.RequestHeader["A"] = "mut"
|
||||
got.HTTP.Auth.Username = "mut"
|
||||
got.TLS.Options.MinVersion = "mut"
|
||||
got.Probe.HTTPHeaders["H"] = "mut"
|
||||
|
||||
if global.Metadata["k"] != "v" ||
|
||||
global.Connector.Auth.Username != "u" ||
|
||||
global.Connector.Metadata["ck"] != "cv" ||
|
||||
global.Connector.TLS.Options.ALPN[0] != "h2" ||
|
||||
global.Dialer.Metadata["dk"] != "dv" ||
|
||||
global.Matcher.Rule != "Host(`x`)" ||
|
||||
global.HTTP.RequestHeader["A"] != "b" ||
|
||||
global.HTTP.Auth.Username != "hu" ||
|
||||
global.TLS.Options.MinVersion != "1.2" ||
|
||||
global.Probe.HTTPHeaders["H"] != "v" {
|
||||
t.Fatalf("global definition was mutated via the resolved copy: %+v", global)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
observer_plugin "github.com/go-gost/x/observer/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseObserver converts an ObserverConfig into an observer.Observer. It only
|
||||
@@ -18,6 +20,9 @@ func ParseObserver(cfg *config.ObserverConfig) observer.Observer {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package p2p converts a [config.P2PConfig] into a p2p.Tunnel.
|
||||
package p2p
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xp2p "github.com/go-gost/x/p2p"
|
||||
p2p_plugin "github.com/go-gost/x/p2p/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
)
|
||||
|
||||
// ParseP2P converts a P2PConfig into a p2p.Tunnel backed by an
|
||||
// external gRPC plugin. Returns nil when cfg is nil or no plugin is
|
||||
// configured. The HTTP plugin variant is not implemented; configuring it
|
||||
// logs a deprecation notice and falls back to gRPC.
|
||||
func ParseP2P(cfg *config.P2PConfig) (p xp2p.Tunnel) {
|
||||
if cfg == nil || cfg.Plugin == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
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":
|
||||
logger.Default().Warn("p2p: http plugin type is deprecated/unimplemented, falling back to grpc")
|
||||
fallthrough
|
||||
default:
|
||||
return p2p_plugin.NewGRPCPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -96,4 +96,14 @@ const (
|
||||
// MDKeyLabels holds static key/value labels attached to a service's
|
||||
// records and logs.
|
||||
MDKeyLabels = "labels"
|
||||
|
||||
// MDKeyP2P selects the p2p tunnel provider for a chain node's base
|
||||
// transport. The value is the name of a registered p2ps: component.
|
||||
MDKeyP2P = "p2p"
|
||||
|
||||
// MDKeyP2PNetwork overrides the tunnel network a p2p-wrapped node asks the
|
||||
// provider for (see xp2p.WithTunnelNetwork). Empty derives it from the inner
|
||||
// dialer. A tun link sets it to "ip": datagram-shaped like udp, but
|
||||
// session-scoped on the host, so the link ends on a lost peer session.
|
||||
MDKeyP2PNetwork = "p2p.network"
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
||||
Services: []*config.ServiceConfig{{Name: "s1"}},
|
||||
Chains: []*config.ChainConfig{{Name: "c1"}},
|
||||
Hops: []*config.HopConfig{{Name: "h1"}},
|
||||
Nodes: []*config.NodeConfig{{Name: "n1", Addr: "n1.example:80"}},
|
||||
Authers: []*config.AutherConfig{{Name: "a1"}},
|
||||
Admissions: []*config.AdmissionConfig{{Name: "ad1"}},
|
||||
Bypasses: []*config.BypassConfig{{Name: "b1"}},
|
||||
@@ -63,6 +64,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
||||
Services: []*config.ServiceConfig{{Name: "s2"}},
|
||||
Chains: []*config.ChainConfig{{Name: "c2"}},
|
||||
Hops: []*config.HopConfig{{Name: "h2"}},
|
||||
Nodes: []*config.NodeConfig{{Name: "n2", Addr: "n2.example:80"}},
|
||||
Authers: []*config.AutherConfig{{Name: "a2"}},
|
||||
Admissions: []*config.AdmissionConfig{{Name: "ad2"}},
|
||||
Bypasses: []*config.BypassConfig{{Name: "b2"}},
|
||||
@@ -91,6 +93,9 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
|
||||
if len(got.Hops) != 2 {
|
||||
t.Fatal("hops not properly appended")
|
||||
}
|
||||
if len(got.Nodes) != 2 {
|
||||
t.Fatal("nodes not properly appended")
|
||||
}
|
||||
if len(got.Authers) != 2 {
|
||||
t.Fatal("authers not properly appended")
|
||||
}
|
||||
|
||||
@@ -320,6 +320,7 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
Services: append(cfg1.Services, cfg2.Services...),
|
||||
Chains: append(cfg1.Chains, cfg2.Chains...),
|
||||
Hops: append(cfg1.Hops, cfg2.Hops...),
|
||||
Nodes: append(cfg1.Nodes, cfg2.Nodes...),
|
||||
Authers: append(cfg1.Authers, cfg2.Authers...),
|
||||
Admissions: append(cfg1.Admissions, cfg2.Admissions...),
|
||||
Bypasses: append(cfg1.Bypasses, cfg2.Bypasses...),
|
||||
@@ -329,6 +330,8 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
SDs: append(cfg1.SDs, cfg2.SDs...),
|
||||
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
|
||||
Rewriters: append(cfg1.Rewriters, cfg2.Rewriters...),
|
||||
P2Ps: append(cfg1.P2Ps, cfg2.P2Ps...),
|
||||
Caches: append(cfg1.Caches, cfg2.Caches...),
|
||||
Limiters: append(cfg1.Limiters, cfg2.Limiters...),
|
||||
Quotas: append(cfg1.Quotas, cfg2.Quotas...),
|
||||
CLimiters: append(cfg1.CLimiters, cfg2.CLimiters...),
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
recorder_plugin "github.com/go-gost/x/recorder/plugin"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
@@ -33,6 +34,9 @@ func ParseRecorder(cfg *config.RecorderConfig) (r recorder.Recorder) {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-gost/core/resolver"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
"github.com/go-gost/x/registry"
|
||||
xresolver "github.com/go-gost/x/resolver"
|
||||
resolver_plugin "github.com/go-gost/x/resolver/plugin"
|
||||
@@ -24,6 +25,9 @@ func ParseResolver(cfg *config.ResolverConfig) (resolver.Resolver, error) {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
rewriter_plugin "github.com/go-gost/x/rewriter/plugin"
|
||||
)
|
||||
|
||||
@@ -19,6 +21,9 @@ func ParseRewriter(cfg *config.RewriterConfig) rewriter.Rewriter {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
xrouter "github.com/go-gost/x/router"
|
||||
router_plugin "github.com/go-gost/x/router/plugin"
|
||||
)
|
||||
@@ -23,6 +24,9 @@ func ParseRouter(cfg *config.RouterConfig) router.Router {
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/sd"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/internal/plugin"
|
||||
xplugin "github.com/go-gost/x/plugin"
|
||||
sd_plugin "github.com/go-gost/x/sd/plugin"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,9 @@ func ParseSD(cfg *config.SDConfig) sd.SD {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := xplugin.Spawn(cfg.Plugin.Command); err != nil {
|
||||
logger.Default().Errorf("spawn plugin %s: %v", cfg.Name, err)
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if cfg.Plugin.TLS != nil {
|
||||
tlsCfg = &tls.Config{
|
||||
|
||||
@@ -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
|
||||
|
||||
+168
-23
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"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"
|
||||
@@ -28,8 +30,12 @@ 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"
|
||||
@@ -199,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),
|
||||
@@ -375,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...)...)),
|
||||
@@ -391,6 +417,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
|
||||
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
|
||||
handler.RecordersOption(recorders...),
|
||||
handler.CacheOption(registry.CacheRegistry().Get(cfg.Cache)),
|
||||
handler.RewriterOption(rew),
|
||||
handler.LoggerOption(handlerLogger),
|
||||
handler.ServiceOption(cfg.Name),
|
||||
@@ -401,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)
|
||||
}
|
||||
|
||||
@@ -417,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),
|
||||
@@ -429,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
|
||||
@@ -445,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{
|
||||
@@ -487,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -121,6 +121,22 @@ func PeerCertFromContext(ctx context.Context) *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{}
|
||||
|
||||
|
||||
@@ -261,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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ type quicSession struct {
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
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 {
|
||||
|
||||
@@ -117,7 +117,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
conn, err = session.GetConn(ctx)
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
|
||||
@@ -29,6 +29,9 @@ func (d *icmpDialer) 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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
+20
-4
@@ -2,7 +2,6 @@ package mtcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -61,7 +60,17 @@ func (d *mtcpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
delete(d.sessions, addr) // session is dead
|
||||
// A session still waiting for its handshake is not dead: its conn
|
||||
// belongs to the request that dialed it, whose Handshake either
|
||||
// builds the session on it or closes it. Only an established
|
||||
// session's base conn is closed here.
|
||||
if session.session != nil {
|
||||
session.Close()
|
||||
if session.conn != nil {
|
||||
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
|
||||
}
|
||||
}
|
||||
delete(d.sessions, addr)
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
@@ -114,8 +123,15 @@ func (d *mtcpDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
|
||||
|
||||
session, ok := d.sessions[opts.Addr]
|
||||
if session != nil && session.conn != conn {
|
||||
conn.Close()
|
||||
return nil, errors.New("mtls: unrecognized connection")
|
||||
// Another Dial registered a conn of its own while this one waited
|
||||
// for the lock. If it has become a live session, use that and drop
|
||||
// this conn; otherwise build the session on this conn, and the other
|
||||
// request's Handshake will find it here and use it.
|
||||
if session.IsClosed() {
|
||||
ok = false
|
||||
} else {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if !ok || session.session == nil {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package mtcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/listener"
|
||||
mtcp_listener "github.com/go-gost/x/listener/mtcp"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
type netDialer struct{}
|
||||
|
||||
func (netDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
// echoServer serves mtcp on a local port and echoes every stream.
|
||||
func echoServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln := mtcp_listener.NewListener(
|
||||
listener.AddrOption("127.0.0.1:0"),
|
||||
listener.LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
if err := ln.Init(xmd.NewMetadata(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
io.Copy(conn, conn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// Concurrent requests that find no live session each dial a conn of their
|
||||
// own, and a Dial that finds a session still waiting for its handshake
|
||||
// replaces it. Both handshakes must still end on one usable session,
|
||||
// whichever runs first; they used to fail with "unrecognized connection",
|
||||
// or on a conn the later Dial had closed.
|
||||
func TestHandshakeAfterAnotherDial(t *testing.T) {
|
||||
for _, first := range []string{"earlier", "later"} {
|
||||
t.Run(first+" conn first", func(t *testing.T) {
|
||||
addr := echoServer(t)
|
||||
d := NewDialer(
|
||||
dialer.LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
if err := d.Init(xmd.NewMetadata(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
earlier, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conns := []net.Conn{earlier, later}
|
||||
if first == "later" {
|
||||
conns = []net.Conn{later, earlier}
|
||||
}
|
||||
for i, conn := range conns {
|
||||
cc, err := d.(dialer.Handshaker).Handshake(ctx, conn, dialer.AddrHandshakeOption(addr))
|
||||
if err != nil {
|
||||
t.Fatalf("handshake %d: %v", i+1, err)
|
||||
}
|
||||
if _, err := cc.Write([]byte("ping")); err != nil {
|
||||
t.Fatalf("handshake %d: write: %v", i+1, err)
|
||||
}
|
||||
if _, err := io.ReadFull(cc, make([]byte, 4)); err != nil {
|
||||
t.Fatalf("handshake %d: read: %v", i+1, err)
|
||||
}
|
||||
cc.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+20
-4
@@ -3,7 +3,6 @@ package mtls
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -62,7 +61,17 @@ func (d *mtlsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
delete(d.sessions, addr) // session is dead
|
||||
// A session still waiting for its handshake is not dead: its conn
|
||||
// belongs to the request that dialed it, whose Handshake either
|
||||
// builds the session on it or closes it. Only an established
|
||||
// session's base conn is closed here.
|
||||
if session.session != nil {
|
||||
session.Close()
|
||||
if session.conn != nil {
|
||||
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
|
||||
}
|
||||
}
|
||||
delete(d.sessions, addr)
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
@@ -115,8 +124,15 @@ func (d *mtlsDialer) Handshake(ctx context.Context, conn net.Conn, options ...di
|
||||
|
||||
session, ok := d.sessions[opts.Addr]
|
||||
if session != nil && session.conn != conn {
|
||||
conn.Close()
|
||||
return nil, errors.New("mtls: unrecognized connection")
|
||||
// Another Dial registered a conn of its own while this one waited
|
||||
// for the lock. If it has become a live session, use that and drop
|
||||
// this conn; otherwise build the session on this conn, and the other
|
||||
// request's Handshake will find it here and use it.
|
||||
if session.IsClosed() {
|
||||
ok = false
|
||||
} else {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if !ok || session.session == nil {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/listener"
|
||||
mtls_listener "github.com/go-gost/x/listener/mtls"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
type netDialer struct{}
|
||||
|
||||
func (netDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
// echoServer serves mtls on a local port and echoes every stream.
|
||||
func echoServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln := mtls_listener.NewListener(
|
||||
listener.AddrOption("127.0.0.1:0"),
|
||||
listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}),
|
||||
listener.LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
if err := ln.Init(xmd.NewMetadata(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
io.Copy(conn, conn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func selfSignedCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
|
||||
// Concurrent requests that find no live session each dial a conn of their
|
||||
// own, and a Dial that finds a session still waiting for its handshake
|
||||
// replaces it. Both handshakes must still end on one usable session,
|
||||
// whichever runs first; they used to fail with "unrecognized connection",
|
||||
// or on a conn the later Dial had closed.
|
||||
func TestHandshakeAfterAnotherDial(t *testing.T) {
|
||||
for _, first := range []string{"earlier", "later"} {
|
||||
t.Run(first+" conn first", func(t *testing.T) {
|
||||
addr := echoServer(t)
|
||||
d := NewDialer(
|
||||
dialer.TLSConfigOption(&tls.Config{InsecureSkipVerify: true}),
|
||||
dialer.LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
if err := d.Init(xmd.NewMetadata(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
earlier, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conns := []net.Conn{earlier, later}
|
||||
if first == "later" {
|
||||
conns = []net.Conn{later, earlier}
|
||||
}
|
||||
for i, conn := range conns {
|
||||
cc, err := d.(dialer.Handshaker).Handshake(ctx, conn, dialer.AddrHandshakeOption(addr))
|
||||
if err != nil {
|
||||
t.Fatalf("handshake %d: %v", i+1, err)
|
||||
}
|
||||
if _, err := cc.Write([]byte("ping")); err != nil {
|
||||
t.Fatalf("handshake %d: write: %v", i+1, err)
|
||||
}
|
||||
if _, err := io.ReadFull(cc, make([]byte, 4)); err != nil {
|
||||
t.Fatalf("handshake %d: read: %v", i+1, err)
|
||||
}
|
||||
cc.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+20
-6
@@ -2,7 +2,6 @@ package mws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"sync"
|
||||
@@ -76,7 +75,17 @@ func (d *mwsDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
delete(d.sessions, addr) // session is dead
|
||||
// A session still waiting for its handshake is not dead: its conn
|
||||
// belongs to the request that dialed it, whose Handshake either
|
||||
// builds the session on it or closes it. Only an established
|
||||
// session's base conn is closed here.
|
||||
if session.session != nil {
|
||||
session.Close()
|
||||
if session.conn != nil {
|
||||
session.conn.Close() // base conn would otherwise leak (and hold a p2p tunnel open)
|
||||
}
|
||||
}
|
||||
delete(d.sessions, addr)
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
@@ -129,10 +138,15 @@ func (d *mwsDialer) Handshake(ctx context.Context, conn net.Conn, options ...dia
|
||||
|
||||
session, ok := d.sessions[opts.Addr]
|
||||
if session != nil && session.conn != conn {
|
||||
err := errors.New("mws: unrecognized connection")
|
||||
log.Error(err)
|
||||
conn.Close()
|
||||
return nil, err
|
||||
// Another Dial registered a conn of its own while this one waited
|
||||
// for the lock. If it has become a live session, use that and drop
|
||||
// this conn; otherwise build the session on this conn, and the other
|
||||
// request's Handshake will find it here and use it.
|
||||
if session.IsClosed() {
|
||||
ok = false
|
||||
} else {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if !ok || session.session == nil {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package mws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/listener"
|
||||
mws_listener "github.com/go-gost/x/listener/mws"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
type netDialer struct{}
|
||||
|
||||
func (netDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
// echoServer serves mws on a local port and echoes every stream.
|
||||
func echoServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln := mws_listener.NewListener(
|
||||
listener.AddrOption("127.0.0.1:0"),
|
||||
listener.LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
if err := ln.Init(xmd.NewMetadata(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
io.Copy(conn, conn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// Concurrent requests that find no live session each dial a conn of their
|
||||
// own, and a Dial that finds a session still waiting for its handshake
|
||||
// replaces it. Both handshakes must still end on one usable session,
|
||||
// whichever runs first; they used to fail with "unrecognized connection",
|
||||
// or on a conn the later Dial had closed.
|
||||
func TestHandshakeAfterAnotherDial(t *testing.T) {
|
||||
for _, first := range []string{"earlier", "later"} {
|
||||
t.Run(first+" conn first", func(t *testing.T) {
|
||||
addr := echoServer(t)
|
||||
d := NewDialer(
|
||||
dialer.LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
if err := d.Init(xmd.NewMetadata(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
earlier, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later, err := d.Dial(ctx, addr, dialer.NetDialerDialOption(netDialer{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conns := []net.Conn{earlier, later}
|
||||
if first == "later" {
|
||||
conns = []net.Conn{later, earlier}
|
||||
}
|
||||
for i, conn := range conns {
|
||||
cc, err := d.(dialer.Handshaker).Handshake(ctx, conn, dialer.AddrHandshakeOption(addr))
|
||||
if err != nil {
|
||||
t.Fatalf("handshake %d: %v", i+1, err)
|
||||
}
|
||||
if _, err := cc.Write([]byte("ping")); err != nil {
|
||||
t.Fatalf("handshake %d: write: %v", i+1, err)
|
||||
}
|
||||
if _, err := io.ReadFull(cc, make([]byte, 4)); err != nil {
|
||||
t.Fatalf("handshake %d: read: %v", i+1, err)
|
||||
}
|
||||
cc.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ type quicSession struct {
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
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 {
|
||||
|
||||
@@ -98,7 +98,7 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
conn, err = session.GetConn(ctx)
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-3
@@ -4,16 +4,21 @@ import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// conn presents a dialed connection as a connected datagram socket: one
|
||||
// Write/Read per datagram, the peer address always the connected one, and the
|
||||
// address argument of WriteTo ignored. It covers both a raw *net.UDPConn and a
|
||||
// wrapped base dialer (e.g. a p2p tunnel endpoint), neither of which the caller
|
||||
// needs to tell apart.
|
||||
type conn struct {
|
||||
*net.UDPConn
|
||||
net.Conn
|
||||
}
|
||||
|
||||
func (c *conn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
return c.UDPConn.Write(b)
|
||||
return c.Write(b)
|
||||
}
|
||||
|
||||
func (c *conn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
n, err = c.UDPConn.Read(b)
|
||||
n, err = c.Read(b)
|
||||
addr = c.RemoteAddr()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,8 +49,11 @@ func (d *udpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The base may be a raw *net.UDPConn (a direct dial) or any conn carrying
|
||||
// datagrams (a wrapped base dialer, e.g. a p2p tunnel endpoint); the
|
||||
// wrapper treats both the same.
|
||||
c = &conn{
|
||||
UDPConn: c.(*net.UDPConn),
|
||||
Conn: c,
|
||||
}
|
||||
|
||||
c = proxyproto.WrapClientConn(
|
||||
|
||||
@@ -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,129 @@
|
||||
# Plan: obfs4 Pluggable Transport Support (go-gost/gost#182)
|
||||
|
||||
## Context
|
||||
|
||||
Issue [#182](https://github.com/go-gost/gost/issues/182) requests obfs4 support. GOST v3 currently has `ohttp` (HTTP WebSocket upgrade) and `otls` (fake TLS handshake) obfuscation, but not obfs4 — the "look-like-nothing" pluggable transport used by Tor bridges. The maintainer confirmed in the issue comments that obfs4 was not yet implemented. The `feature` label indicates this is desired functionality.
|
||||
|
||||
## Approach: Use external library
|
||||
|
||||
Use **`github.com/refraction-networking/obfs4`** (BSD-licensed) — the same organization that provides `refraction-networking/utls` already depended on by GOST. This library implements the full obfs4 protocol (ntor handshake, Elligator 2 key obfuscation, NaCl secretbox framing, SipHash length masking, replay filtering).
|
||||
|
||||
### Why this library vs implementing from scratch
|
||||
|
||||
| Factor | Library | From scratch |
|
||||
|--------|---------|-------------|
|
||||
| Cryptographic safety | Battle-tested (used by Tor/Refraction Networking) | Risk of subtle bugs in Elligator 2, ntor, constant-time ops |
|
||||
| Code volume | ~200 lines of GOST wrappers | ~1500+ lines of complex crypto code |
|
||||
| Maintenance | Upstream maintains protocol compliance | GOST maintainers own all crypto code |
|
||||
| Dependency footprint | Adds ~3 transitive deps | None new |
|
||||
| Integration fit | `ServerFactory.WrapConn()` is a perfect match for Accept(); `ClientFactory.Dial()` needs a thin bridge in Handshake() | Full control |
|
||||
|
||||
The library approach matches how GOST already uses `refraction-networking/utls` for TLS fingerprinting.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. `x/listener/obfs/obfs4/listener.go`
|
||||
- Registers `"obfs4"` in `ListenerRegistry`
|
||||
- `obfsListener` struct following the same pattern as `listener/obfs/tls/listener.go` and `listener/obfs/http/listener.go`
|
||||
- `NewListener()` factory, `Init(metadata)`, `Accept()` methods
|
||||
- In `Accept()`: wrap accepted conn with limiter, then call `serverFactory.WrapConn(conn)` to perform server-side obfs4 handshake
|
||||
|
||||
### 2. `x/listener/obfs/obfs4/conn.go`
|
||||
- Thin: the library's `WrapConn` already returns a `net.Conn` that handles framing transparently
|
||||
- May not even need a custom conn type — `WrapConn` returns a fully functional `net.Conn`
|
||||
|
||||
### 3. `x/listener/obfs/obfs4/metadata.go`
|
||||
- Parse server-side config: `nodeId` (hex), `privateKey` (base64), `iatMode` (int, default 0)
|
||||
- Construct `pt.Args` from metadata for `ServerFactory()`
|
||||
|
||||
### 4. `x/dialer/obfs/obfs4/dialer.go`
|
||||
- Registers `"obfs4"` in `DialerRegistry`
|
||||
- `obfs4Dialer` struct following the same pattern as `dialer/obfs/tls/dialer.go`
|
||||
- `NewDialer()`, `Init(metadata)`, `Dial()`, `Handshake()` methods
|
||||
- `Dial()`: delegates to options.Dialer (standard raw TCP connection)
|
||||
- `Handshake()`: uses `ClientFactory.Dial()` with a passthrough `DialFunc` that returns the already-established conn — the library then performs the obfs4 client handshake on it
|
||||
|
||||
### 5. `x/dialer/obfs/obfs4/metadata.go`
|
||||
- Parse client-side config: `cert` (base64, required), `iatMode` (int)
|
||||
- Store state dir path for client factory
|
||||
|
||||
### 6. Update `gost/cmd/gost/register.go`
|
||||
- Add blank imports:
|
||||
```go
|
||||
_ "github.com/go-gost/x/listener/obfs/obfs4"
|
||||
_ "github.com/go-gost/x/dialer/obfs/obfs4"
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Server-side: Use `ServerFactory.WrapConn()`
|
||||
The library's `base.ServerFactory` interface has `WrapConn(conn net.Conn) (net.Conn, error)` which is an exact match for GOST's `Accept()` pattern — it takes a raw TCP connection, performs the obfs4 server handshake, and returns a wrapped connection. No custom conn type needed.
|
||||
|
||||
### Client-side: Bridge Dial/Handshake with passthrough DialFunc
|
||||
The library's `ClientFactory.Dial(network, address, DialFunc, args)` both connects AND handshakes. GOST separates these. Solution:
|
||||
|
||||
```go
|
||||
func (d *obfs4Dialer) Handshake(ctx context.Context, conn net.Conn, ...) (net.Conn, error) {
|
||||
cf, _ := d.transport.ClientFactory(d.md.stateDir)
|
||||
args, _ := cf.ParseArgs(d.md.ptArgs)
|
||||
return cf.Dial("tcp", "", func(network, address string) (net.Conn, error) {
|
||||
return conn, nil // passthrough — conn already established
|
||||
}, args)
|
||||
}
|
||||
```
|
||||
|
||||
### Config interface
|
||||
Users configure obfs4 via YAML/JSON metadata, mirroring Tor's bridge line format:
|
||||
|
||||
**Server** (`listener`):
|
||||
```yaml
|
||||
services:
|
||||
- name: obfs4-bridge
|
||||
addr: :8080
|
||||
listener:
|
||||
type: obfs4
|
||||
metadata:
|
||||
nodeId: "0123456789abcdef0123456789abcdef01234567"
|
||||
privateKey: "base64-encoded-curve25519-private-key"
|
||||
iatMode: 0
|
||||
handler:
|
||||
type: auto
|
||||
```
|
||||
|
||||
**Client** (`hop node`):
|
||||
```yaml
|
||||
chains:
|
||||
- name: obfs4-chain
|
||||
hops:
|
||||
- nodes:
|
||||
- addr: bridge.example.com:8080
|
||||
dialer:
|
||||
type: obfs4
|
||||
metadata:
|
||||
cert: "base64-encoded-server-cert"
|
||||
iatMode: 0
|
||||
connector:
|
||||
type: http
|
||||
```
|
||||
|
||||
### follow existing patterns exactly
|
||||
- Same `init()` registration pattern
|
||||
- Same `metadata` struct + `parseMetadata()` pattern
|
||||
- Same `mdutil.GetString/GetInt` helpers for metadata extraction
|
||||
- Same listener wrapper chain (proxyproto → metrics → stats → admission → traffic limiter → conn limiter)
|
||||
- Same dialer factory + Handshake pattern
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Library API stability**: The refraction-networking/obfs4 library is used by the Tor anti-censorship community. API changes are unlikely but possible. Mitigation: pin to a specific version in go.mod.
|
||||
|
||||
2. **State directory requirement**: The library requires a state directory for replay filter persistence. For stateless deployments (containers), we'll default to a temp directory that's cleaned up on exit.
|
||||
|
||||
3. **Interop with real Tor obfs4 bridges**: This should work since we're using the same protocol implementation library that Tor uses.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Build: `cd x && go build ./...` and `cd gost && go build ./cmd/gost/...`
|
||||
2. Vet: `cd x && go vet ./...`
|
||||
3. Smoke test: Create a minimal config with obfs4 listener + handler, verify the binary starts without error
|
||||
4. Integration test: Start a server with obfs4 listener and client with obfs4 dialer, verify connectivity. This can't be tested with real Tor bridges without credentials, but local loopback testing validates the integration layer.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Issue #349: 通用 Cache 组件设计
|
||||
|
||||
## Context
|
||||
|
||||
Issue [#349](https://github.com/go-gost/gost/issues/349) 请求 HTTP 代理缓存层。用户要求先聚焦 cache 组件本身,设计要通用——不仅用于 HTTP,也要能统一替换现有的 DNS、Router、Traffic Limiter、TLS 等模块中的 ad-hoc 缓存。
|
||||
|
||||
## 现状:6 处独立缓存实现
|
||||
|
||||
| 位置 | 底层库 | Key | Value | TTL 模式 | 淘汰 | 最大容量 |
|
||||
|------|--------|-----|-------|----------|------|----------|
|
||||
| `x/internal/util/cache` | 自实现 | `string` | `interface{}` | 硬过期 | 无 | 无 |
|
||||
| `x/internal/util/resolver` (DNS) | 自实现 | `CacheKey(string)` | `*dns.Msg` | 硬过期 + serve-stale | 最旧(O(n)) | 可选 |
|
||||
| `x/limiter/traffic/cache` | #1 之上 | `string` | `traffic.Limiter` | 硬过期 + 刷新时滑动 | 无 | 无 |
|
||||
| `x/limiter/traffic` | patrickmn/go-cache | `string` | `traffic.Limiter` | **滑动**(Get时重置) | 后台清理 | 无 |
|
||||
| `x/internal/util/tls` | patrickmn/go-cache | `string` | `*x509.Certificate` | 硬过期(1周) | 后台清理 | 无 |
|
||||
| `x/handler/router` | #1 之上 | `string` | `net.Addr` / `*Route` | 硬过期(1s) | 无 | 无 |
|
||||
|
||||
**问题**: 三种底层实现(自实现 ×2 + patrickmn/go-cache),功能重叠但接口不兼容,无通用配置入口。
|
||||
|
||||
---
|
||||
|
||||
## Core Interface
|
||||
|
||||
### `core/cache/cache.go` (新文件)
|
||||
|
||||
```go
|
||||
package cache
|
||||
|
||||
var ErrNotFound = errors.New("cache: key not found")
|
||||
|
||||
// Entry 缓存条目
|
||||
type Entry struct {
|
||||
Data []byte
|
||||
Expiration time.Time // zero = 永不过期
|
||||
}
|
||||
|
||||
func (e *Entry) Expired() bool
|
||||
func (e *Entry) TTL() time.Duration // 剩余 TTL,负数=已过期,-1=永不过期
|
||||
|
||||
// SetOptions 写入选项
|
||||
type SetOptions struct {
|
||||
TTL time.Duration // 逐条 TTL
|
||||
}
|
||||
type SetOption func(*SetOptions)
|
||||
func WithTTL(d time.Duration) SetOption
|
||||
|
||||
// Cache 通用 KV 缓存接口
|
||||
// 实现必须并发安全
|
||||
type Cache interface {
|
||||
Get(ctx context.Context, key string) (*Entry, error)
|
||||
Set(ctx context.Context, key string, data []byte, opts ...SetOption) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
Has(ctx context.Context, key string) bool
|
||||
Clear(ctx context.Context) error
|
||||
Len(ctx context.Context) int
|
||||
RefreshTTL(ctx context.Context, key string, ttl time.Duration) error
|
||||
}
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- Key 固定为 `string`,Value 固定为 `[]byte` — 与 registry 模式兼容,所有后端统一
|
||||
- `Get` 返回过期条目(`ErrNotFound` 仅对不存在的 key)— 支持 DNS serve-stale
|
||||
- `RefreshTTL` 显式延长过期时间 — 支持 DNS 异步刷新防重复
|
||||
- 调用方自行序列化/反序列化 — Cache 不绑定任何业务类型,真正通用
|
||||
- 与 `recorder.Recorder` 接口复杂度相当(都是单方法 + 选项)
|
||||
|
||||
### Each handler wraps with typed methods
|
||||
|
||||
```go
|
||||
// DNS handler 中:
|
||||
func dnsCacheGet(c cache.Cache, key string) (*dns.Msg, time.Duration) {
|
||||
entry, err := c.Get(ctx, key)
|
||||
if err != nil { return nil, 0 }
|
||||
msg := &dns.Msg{}
|
||||
msg.Unpack(entry.Data)
|
||||
return msg, entry.TTL()
|
||||
}
|
||||
|
||||
// HTTP handler 中:
|
||||
func httpCacheGet(c cache.Cache, key string) (*http.Response, error) { ... }
|
||||
func httpCacheSet(c cache.Cache, key string, resp *http.Response, ttl time.Duration) error { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config Structure
|
||||
|
||||
### `x/config/config.go` — 新增
|
||||
|
||||
```go
|
||||
type CacheConfig struct {
|
||||
Name string `json:"name"`
|
||||
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
|
||||
File *FileCache `yaml:",omitempty" json:"file,omitempty"`
|
||||
Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
type MemoryCache struct {
|
||||
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
|
||||
MaxSize int `yaml:",omitempty" json:"maxSize,omitempty"`
|
||||
MaxBytes int64 `yaml:",omitempty" json:"maxBytes,omitempty"`
|
||||
CleanupInterval time.Duration `yaml:",omitempty" json:"cleanupInterval,omitempty"`
|
||||
Eviction string `yaml:",omitempty" json:"eviction,omitempty"` // "oldest" | "lru"
|
||||
}
|
||||
|
||||
type FileCache struct {
|
||||
Dir string `json:"dir"`
|
||||
MaxBytes int64 `yaml:",omitempty" json:"maxBytes,omitempty"`
|
||||
Levels int `yaml:",omitempty" json:"levels,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
KeyPrefix string `yaml:",omitempty" json:"keyPrefix,omitempty"`
|
||||
}
|
||||
|
||||
// 在 Config struct 加:
|
||||
// Caches []*CacheConfig `yaml:",omitempty" json:"caches,omitempty"`
|
||||
|
||||
// 在 ServiceConfig struct 加:
|
||||
// Cache string `yaml:",omitempty" json:"cache,omitempty"`
|
||||
```
|
||||
|
||||
### YAML 示例
|
||||
|
||||
```yaml
|
||||
caches:
|
||||
- name: cache-0
|
||||
memory:
|
||||
ttl: 60m
|
||||
maxSize: 10000
|
||||
maxBytes: 268435456 # 256MB
|
||||
cleanupInterval: 5m
|
||||
eviction: lru
|
||||
|
||||
services:
|
||||
- name: service-0
|
||||
addr: ":8080"
|
||||
handler:
|
||||
type: http
|
||||
cache: cache-0 # 引用
|
||||
forwarder:
|
||||
nodes: [...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Backend
|
||||
|
||||
### `x/cache/cache.go` — 内建实现
|
||||
|
||||
不依赖外部库,替代 `x/internal/util/cache/cache.go` 和 patrickmn/go-cache。
|
||||
|
||||
```go
|
||||
type memoryCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*entry
|
||||
defaultTTL time.Duration
|
||||
maxSize int
|
||||
maxBytes int64
|
||||
currentBytes int64
|
||||
cleanupInterval time.Duration
|
||||
evictionPolicy EvictionPolicy
|
||||
stopCleanup chan struct{}
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
data []byte
|
||||
expiration time.Time // zero = 永不过期
|
||||
lastAccess time.Time // LRU 用
|
||||
}
|
||||
```
|
||||
|
||||
**功能矩阵**:
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 并发安全 | `sync.RWMutex` |
|
||||
| 默认 TTL | 配置项,`Set` 的 `WithTTL` 可逐条覆盖 |
|
||||
| 最大条目数 | 满时按 `evictionPolicy` 淘汰(oldest / lru) |
|
||||
| 最大字节数 | 满时同样淘汰 |
|
||||
| 后台清理 | 可选,按 `cleanupInterval` 定期删除过期条目 |
|
||||
| serve-stale | `Get` 返回过期条目,`Entry.Expired()` 让调用方判断 |
|
||||
| `RefreshTTL` | 延长过期时间,用于 DNS 异步刷新模式 |
|
||||
| 注册 | `init()` 注册为 `registry.CacheRegistry().Register("memory", ...)` |
|
||||
|
||||
**淘汰策略**: 满时 → 先清过期 → 若仍满则按策略淘汰(oldest: 按插入时间; lru: 按 `lastAccess`)
|
||||
|
||||
---
|
||||
|
||||
## 实现步骤
|
||||
|
||||
| Step | 文件 | 动作 | 行数 |
|
||||
|------|------|------|------|
|
||||
| 1 | `core/cache/cache.go` | 新建 — `Entry`, `Cache` 接口, 选项 | ~70 |
|
||||
| 2 | `x/config/config.go` | 修改 — `CacheConfig` + 子结构体 + Config 字段 | ~50 |
|
||||
| 3 | `x/registry/registry.go` | 修改 — `cacheReg` + `CacheRegistry()` | ~15 |
|
||||
| 4 | `x/registry/cache.go` | 新建 — 热重载包装器 | ~30 |
|
||||
| 5 | `x/cache/cache.go` | 新建 — memory cache 实现 | ~200 |
|
||||
| 6 | `x/config/parsing/cache/parse.go` | 新建 — `ParseCache()` 后端分派 | ~50 |
|
||||
| 7 | `x/config/loader/loader.go` | 修改 — cache 注册块 | ~10 |
|
||||
| 8 | `x/config/parsing/service/parse.go` | 修改 — 解析 cache 引用注入 handler option | ~25 |
|
||||
| 9 | `core/handler/option.go` | 修改 — `CacheOption` | ~10 |
|
||||
| 10 | `gost/cmd/gost/register.go` | 修改 — blank import `x/cache` | +1 |
|
||||
|
||||
**总计: ~460 行,集中在 `core/cache/` 和 `x/cache/`**
|
||||
|
||||
### 迁移路径 (后续 Phase)
|
||||
|
||||
| Phase | 内容 |
|
||||
|-------|------|
|
||||
| **Phase 1 (本次)** | `core/cache/` 接口 + memory 后端 + config/registry 全链路 |
|
||||
| **Phase 2** | DNS handler 切换到新 cache;Router handler 切换;替换 `x/internal/util/cache` |
|
||||
| **Phase 3** | Traffic limiter 切换;TLS cert pool 切换;移除 patrickmn/go-cache 依赖 |
|
||||
| **Phase 4** | HTTP 代理缓存集成(handler 中查缓存/存缓存) |
|
||||
| **Phase 5** | File 后端;Redis 后端;Plugin 后端 |
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
cd x && go build ./...
|
||||
cd gost && go build ./cmd/gost/...
|
||||
|
||||
# 单元测试
|
||||
cd x && go test ./cache/... -v
|
||||
|
||||
# 验证 registry 集成
|
||||
cd x && go vet ./...
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
# Plan: Tor SOCKS5 Resolve Commands (go-gost/gost#524)
|
||||
|
||||
## Context
|
||||
|
||||
Issue [#524](https://github.com/go-gost/gost/issues/524) requests support for Tor-specific SOCKS5 extension commands:
|
||||
|
||||
- **`0xF0` RESOLVE** — resolve a hostname to an IP address via Tor's DNS
|
||||
- **`0xF1` RESOLVE_PTR** — reverse DNS lookup (IP → hostname) via Tor's DNS
|
||||
|
||||
This allows tools like `torsocks` and `tor-resolve` to work through GOST when forwarding to a Tor SOCKS5 upstream proxy. Previously, GOST's SOCKS5 handler didn't recognize these commands (returning `CmdUnsupported`), so Tor-specific tools failed to work through GOST.
|
||||
|
||||
### Existing extension pattern
|
||||
|
||||
GOST already defines custom SOCKS5 extension commands in `x/internal/util/socks/socks.go`:
|
||||
- `CmdMuxBind = 0xF2` — multiplex bind
|
||||
- `CmdUDPTun = 0xF3` — UDP tunnel over TCP
|
||||
|
||||
We add `CmdResolve = 0xF0` and `CmdResolvePTR = 0xF1` to match the same pattern.
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
Client (torsocks) -> GOST SOCKS5 handler (CMD=0xF0) -> Chain -> SOCKS5 connector (CMD=0xF0 to Tor) -> Tor -> reply
|
||||
|
|
||||
Client <- GOST SOCKS5 handler (writes Reply with resolved IP) <- chain conn (carries resolved addr metadata)
|
||||
```
|
||||
|
||||
Unlike CONNECT (continuous bidirectional pipe), RESOLVE/PTR is a single request-reply exchange. The connector sends the resolve command upstream, reads the reply containing the resolved address, and returns a connection carrying that address as metadata. The handler extracts it, writes the reply to the client, and closes.
|
||||
|
||||
## Files to modify
|
||||
|
||||
### 1. `x/internal/util/socks/socks.go` — Add Tor resolve command constants
|
||||
|
||||
Add after `CmdUDPTun`:
|
||||
```go
|
||||
CmdResolve uint8 = 0xF0 // Tor RESOLVE (hostname -> IP)
|
||||
CmdResolvePTR uint8 = 0xF1 // Tor RESOLVE_PTR (IP -> hostname)
|
||||
```
|
||||
|
||||
### 2. `x/ctx/value.go` — Add context key for SOCKS5 command override
|
||||
|
||||
Add a context key and helpers so the handler can signal a custom SOCKS5 command to the connector. Follows the existing pattern used by other context values in this file (Hash, ClientID, SrcAddr, etc.).
|
||||
|
||||
```go
|
||||
type socks5CmdKey struct{}
|
||||
|
||||
func ContextWithSocks5Cmd(ctx context.Context, cmd uint8) context.Context {
|
||||
return context.WithValue(ctx, socks5CmdKey{}, cmd)
|
||||
}
|
||||
|
||||
func Socks5CmdFromContext(ctx context.Context) (uint8, bool) {
|
||||
v, ok := ctx.Value(socks5CmdKey{}).(uint8)
|
||||
return v, ok
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `x/connector/socks/v5/connector.go` — Send custom SOCKS5 command when signaled
|
||||
|
||||
In `Connect()` at the point where `gosocks5.NewRequest(gosocks5.CmdConnect, &addr)` is created:
|
||||
- Check `Socks5CmdFromContext(ctx)` for a custom command
|
||||
- Use the context-specified command if present, otherwise default to `CmdConnect`
|
||||
- After reading the reply, if the command was a resolve type, return a `resolveConn` wrapping the raw connection with the resolved address stored as metadata
|
||||
|
||||
### 4. `x/connector/socks/v5/resolve.go` (new file) — Define `resolveConn`
|
||||
|
||||
```go
|
||||
type resolveConn struct {
|
||||
net.Conn
|
||||
resolvedAddr *gosocks5.Addr
|
||||
}
|
||||
|
||||
func (c *resolveConn) Metadata() mdata.Metadata {
|
||||
return mdata.NewMetadata(map[string]any{
|
||||
"resolvedAddr": c.resolvedAddr,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Implements `net.Conn` (passing through to underlying connection) plus `metadata.Metadatable` to expose the resolved address.
|
||||
|
||||
### 5. `x/handler/socks/v5/handler.go` — Add resolve command cases
|
||||
|
||||
In `Handle()` switch on `req.Cmd`, add:
|
||||
```go
|
||||
case socks.CmdResolve:
|
||||
return h.handleResolve(ctx, conn, address, ro, log)
|
||||
case socks.CmdResolvePTR:
|
||||
return h.handleResolvePTR(ctx, conn, address, ro, log)
|
||||
```
|
||||
|
||||
### 6. `x/handler/socks/v5/resolve.go` (new file) — Implement resolve handlers
|
||||
|
||||
**`handleResolve`**:
|
||||
1. If `enableTor` metadata is false, return `CmdUnsupported` reply
|
||||
2. Set context with `socks.CmdResolve` via `ContextWithSocks5Cmd`
|
||||
3. Call `h.options.Router.Dial(ctx, "tcp", address)` — chain routes to SOCKS5 connector, which sends CMD=0xF0
|
||||
4. Type-assert returned conn to `md.Metadatable`, extract resolved addr
|
||||
5. Write `gosocks5.Reply{Rep: Succeeded, Addr: resolvedAddr}` to client conn
|
||||
6. Close chain connection
|
||||
|
||||
**`handleResolvePTR`**: Same structure but uses `socks.CmdResolvePTR`.
|
||||
|
||||
Both follow the existing error-handling patterns (log errors, write failure replies to client).
|
||||
|
||||
### 7. `x/handler/socks/v5/metadata.go` — Add `enableTor` metadata option
|
||||
|
||||
Add a boolean metadata field (keys: `"tor"`, `"enableTor"`, `"socks5.tor"`) that defaults to `false`. When `false`, Tor resolve commands are rejected with `CmdUnsupported`. When `true`, the handler accepts and forwards them.
|
||||
|
||||
## Verification
|
||||
|
||||
### Build check
|
||||
```bash
|
||||
cd /config/workspace/go-gost/x && go build ./... && go vet ./...
|
||||
```
|
||||
|
||||
### Integration test
|
||||
1. Start GOST with `-L "socks5://127.0.0.1:19055?tor=true" -F "socks5://127.0.0.1:9050"` (pointing at a Tor daemon)
|
||||
2. Use a SOCKS5 client to send a RESOLVE (0xF0) request to port 19055
|
||||
3. Verify the reply contains a valid resolved IP address
|
||||
|
||||
### Manual testing
|
||||
```bash
|
||||
gost -L "socks5://127.0.0.1:19055?tor=true" -F "socks5://127.0.0.1:9050"
|
||||
tor-resolve example.com 127.0.0.1:19055
|
||||
```
|
||||
|
||||
### Test areas
|
||||
- `tor=false` (default) rejects 0xF0/0xF1 commands with `CmdUnsupported`
|
||||
- `tor=true` accepts them and forwards through the chain
|
||||
- Connector sends correct command byte (0xF0/0xF1) when context signals it
|
||||
- Connector returns `resolveConn` with correct resolved address in metadata
|
||||
@@ -0,0 +1,119 @@
|
||||
# go-gost/gost#642 — UDP 目的端口范围跳跃: 可行性 & 复杂度分析
|
||||
|
||||
## 需求
|
||||
|
||||
客户端支持 UDP 目的端口范围跳跃 (destination port-range hopping),类似 Hysteria 的端口跳跃机制:
|
||||
|
||||
```
|
||||
# 期望的客户端语法
|
||||
gost3 -L=udp://:8000/mydomain.com:50000-50050/?hopInterval=x秒
|
||||
gost3 -L=:1080 -F=kcp://127.0.0.1:8000
|
||||
|
||||
# 服务端用 iptables 将端口范围折叠到单一端口
|
||||
iptables -t nat -A PREROUTING -i ens3 -p udp --dport 50000:50050 -j DNAT --to-destination :51000
|
||||
gost3 -L=kcp://:51000
|
||||
```
|
||||
|
||||
核心用例: 端口跳跃 evasion → iptables 服务端范围折叠 → KCP/QUIC 隧道 → 透明代理。
|
||||
|
||||
## 现有基础设施
|
||||
|
||||
### 1. `AddrPortRange` — 目的端口范围已可解析
|
||||
|
||||
[x/internal/net/addr.go:100](x/internal/net/addr.go#L100) 中 `AddrPortRange.Addrs()` 会将 `50000-50050` 展开为 51 个独立地址 `[":50000", ":50001", ..., ":50050"]`。
|
||||
|
||||
[x/config/cmd/cmd.go:440-499](x/config/cmd/cmd.go#L440) 中 `buildServiceConfig()` 将这些地址转化为 `ForwardNodeConfig` 条目。当只有 1 个 listener 但 N 个 forward node 时,所有 N 个 node 都会附加到同一个 service 的 `ForwarderConfig` 上 (一个 hop 持有 N 个 target)。
|
||||
|
||||
**关键缺漏**: selector 策略 (`RoundRobin`, `Random`, `Hash`, `FIFO`) 是 per-association / per-connection 的,**不支持基于时间的旋转**。所以要实现"每 X 秒跳跃端口",需要时间感知的选择逻辑。
|
||||
|
||||
### 2. UDP forwarder 的 relay 路径
|
||||
|
||||
UDP forwarding 由 `x/handler/forward/local/` 处理 (注册名为 `"udp"`, `"tcp"`, `"forward"`):
|
||||
|
||||
- **connected UDP** (`handleRawForwarding`): 一次 `dialTarget()` → 单次 `Router.Dial()` → 双向 pipe。目标地址由 hop.Selector 选出,在 association 生命周期内固定。
|
||||
- **stateless UDP** (`handleRawDatagram`): 每个 datagram 做一次 `dialTarget()` → `ReadFrom` → `WriteTo` → `ReadFrom` → `WriteTo`。从技术上讲,dest port **可以 per-datagram 改变**,但当前 selector 不提供 per-datagram 旋转。
|
||||
|
||||
[x/handler/forward/local/forward.go:32-175](x/handler/forward/local/forward.go#L32)
|
||||
|
||||
### 3. KCP 会话身份绑定到 4-tuple
|
||||
|
||||
xtaci/kcp-go 库 (`v5.6.5`) 的会话模型:
|
||||
|
||||
| 组件 | 机制 | 端口跳跃的影响 |
|
||||
|------|------|---------------|
|
||||
| **服务端 `Listener`** | 会话存储在 `map[string]*UDPSession`,key 为 `remoteIP:Port` 字符串 | 若客户端源端口稳定 → 同一 session。symmetric NAT 下失效。 |
|
||||
| **客户端 `output()`** | 固定 `msg.Addr = s.remote`(dial 时解析的地址) | 必须通过 PacketConn wrapper 重写目标端口才能跳跃。 |
|
||||
| **客户端 `readLoop`** | 锁定到第一个看到的源地址;丢弃所有来自不同地址的包 | 服务端始终从 `:51000` 回复(真实端口,非跳跃范围端口),所以稳定。只有服务端回复时源端口变化才会出问题。 |
|
||||
|
||||
### 4. QUIC 支持连接迁移
|
||||
|
||||
GOST 的 QUIC 传输使用 quic-go,天然支持 QUIC Connection IDs。这些 ID 允许会话在客户端的 IP/端口变更后依然存活 (RFC 9000 连接迁移)。所以 **QUIC 对 symmetric NAT 也具有较高鲁棒性。**
|
||||
|
||||
但 QUIC listener 和 KCP listener 一样,只绑定到**一个端口**。跳跃仍然是面向**服务端的监听端口**的,因此需要 iptables 折叠范围,或者在 gost 内支持多端口 QUIC listener。
|
||||
|
||||
## 可行性: 三个方案层级
|
||||
|
||||
### 方案 A: 传输层跳跃 (推荐) — 中等工作量
|
||||
|
||||
**机制**: 用 PacketConn wrapper 包装 KCP/QUIC dialer 的 `net.PacketConn`,在 `WriteTo` 中根据当前跳跃端口重写目标端口。底层库 (kcp-go/quic-go) 不知道端口在跳跃。
|
||||
|
||||
**数据通路**:
|
||||
```
|
||||
客户端 KCP dialer 创建 PacketConn → hopWrapper.WriteTo(addr) 重写端口 → kcp-go output() 向 hopped 端口发包
|
||||
服务端 iptables dport 范围 → 折叠到 :51000 → KCP listener 收到 → 按客户端源 IP:Port 关联会话
|
||||
服务端从 :51000 回复 → 客户端 readLoop 收到稳定源地址 (一直是 :51000)
|
||||
```
|
||||
|
||||
**需要修改的地方**:
|
||||
1. **配置解析** (`x/config/cmd/cmd.go` / `x/config/config.go`): 为 `ForwardNodeConfig` 添加 `PortRange` + `HopInterval` 字段,或扩充 chain/hop 的 metadata。
|
||||
2. **KCP dialer** (`x/dialer/kcp/dialer.go`): 若配置了跳跃参数,将 `kcp.NewConn` 传入的基础 `net.PacketConn` 用 `portHoppingConn` 包装。
|
||||
3. **QUIC dialer** (`x/dialer/quic/dialer.go`): 同上,但这边的 `quic.DialEarly` 使用的是包装过的 PacketConn。
|
||||
4. **PortHoppingConn** (新文件/复用 `x/internal/net/` 内现有工具): 一个实现了 `net.PacketConn` 的 wrapper:
|
||||
- 持有 `currentIndex atomic.Int32` 用于在范围端口间轮转
|
||||
- 每 `hopInterval` 秒递增 `currentIndex`
|
||||
- `WriteTo(b, addr)` → 用 `currentPort()` 重写 `addr.Port`,委托到底层 `PacketConn.WriteTo`
|
||||
- `ReadFrom` / `Close` / `LocalAddr` → 直透传到底层
|
||||
|
||||
**KCP 的注意事项**: symmetric NAT(为不同的目标端口分配不同的源端口)会导致服务端因为客户端的 `addr.String()` 变化而创建新 KCP 会话 → 连接中断。这是 KCP 的基本限制,无法在 gost 代码层面解决。**QUIC 没有这个问题**,因为连接 ID 的存在使得源端口变化也没问题。
|
||||
|
||||
**工作量**: 预估约 200-300 行新代码 / 约 5 个文件。
|
||||
|
||||
### 方案 B: Stateless UDP forwarder 跳跃 — 小工作量
|
||||
|
||||
**机制**: 只为 plain-UDP forwarding(非隧道)添加时间跳跃。已经在现有的多 node forwarder (方案 A 的步骤 1) 基础上完成了一半。
|
||||
|
||||
**数据通路**: `handleRawDatagram` 中的每次 `dialTarget()` → 当前端口 → `WriteTo`
|
||||
|
||||
**限制**: 仅适用于 plain-UDP relay,**不适用于 KCP/QUIC 隧道**,而隧道才是 evasion 真正需要的地方。
|
||||
|
||||
**工作量**: 约 50-100 行新代码 / 2-3 个文件。
|
||||
|
||||
### 方案 C: 完全协议重设计 — 超大工作量
|
||||
|
||||
机制: 不带 iptables 的原生会话迁移,fork / 修补 kcp-go 让会话按 Conversation ID 查找,多端口 listener,迁移信令。这正是 ginuerzh 在 [这条评论](https://github.com/go-gost/gost/issues/642#issuecomment-2568987717) 中提到 "需要新协议和流程" 时的设想。
|
||||
|
||||
**工作量**: 数周,跨多个仓库 (fork kcp-go,新认证流程,多 socket 监听)。
|
||||
|
||||
## 建议
|
||||
|
||||
**方案 A (传输层 PacketConn wrapper)** 是正确的中位数: 用约 200-300 行代码和简单的 PacketConn wrapper,为 KCP 和 QUIC 隧道实现真实的端口跳跃,服务端无需改动 (只需 iptables)。
|
||||
|
||||
回应 issue 时,需要解释以下几点:
|
||||
1. 已实现用途: `AddrPortRange` 已经能将目标端口范围展开为多个 forward node。
|
||||
2. 未实现部分: 时间感知跳跃,推荐用方案 A 实现。
|
||||
3. KCP 限制: 服务端 symmetric NAT 会导致跳跃连接中断。建议用 QUIC 传输以获得完全的鲁棒性。
|
||||
4. 服务端: iptables DNAT 是跨越端口范围的最轻量方案;不需要在 gost 服务端做任何改动。
|
||||
|
||||
## 验证
|
||||
|
||||
如果决定实现: 不具备 ci/macos 下自动化测试的大规模开销价值。手工验证:
|
||||
```bash
|
||||
# 服务端
|
||||
iptables -t nat -A PREROUTING -p udp --dport 50000:50050 -j DNAT --to-destination :51000
|
||||
gost -L=kcp://:51000
|
||||
|
||||
# 客户端 (运行需要方案 A 的二进制)
|
||||
gost -L=:1080 -F=kcp://server:50000-50050?hopInterval=10s
|
||||
|
||||
# 通过代理发起 UDP 连接,验证 10 秒后目标端口变化 (tcpdump)
|
||||
```
|
||||
@@ -0,0 +1,942 @@
|
||||
# Go-Gost #652: User Namespace 支持 — 技术调研
|
||||
|
||||
## Issue
|
||||
|
||||
[go-gost/gost#652](https://github.com/go-gost/gost/issues/652) — Feature request: Linux user namespace support.
|
||||
|
||||
作者: @Niek | 状态: OPEN
|
||||
|
||||
请求支持 Linux user namespaces,使任意程序可以被透明代理。参考项目:[wirez](https://github.com/v-byte-cpu/wirez)(Go 实现的 per-program 透明代理)。
|
||||
|
||||
## 核心发现:User NS vs Net NS
|
||||
|
||||
GOST 已有完善的**网络命名空间 (netns)** 支持,但 user namespace 与 network namespace 有本质差异:
|
||||
|
||||
| 属性 | Network NS | User NS |
|
||||
|------|-----------|---------|
|
||||
| 切换时机 | 可事后进入 (`setns()`) | **必须在进程创建时** (`clone(CLONE_NEWUSER)`) |
|
||||
| 作用域 | per-thread | **per-process** |
|
||||
| 权限要求 | 需要 `CAP_SYS_ADMIN` | **无需特权** (Linux 3.8+) |
|
||||
| Go 实现 | `github.com/vishvananda/netns` | `syscall.SysProcAttr` + `os/exec` |
|
||||
| 可事后 setns? | ✅ (如果有 fd) | ❌ per-process,不能 per-thread 切换 |
|
||||
|
||||
**关键结论:现有的 `switchNetns()` 模式([x/internal/net/dialer/dialer_netns.go])无法直接套用到 user namespace。** 需要完全不同的架构:进程创建时通过 `clone()` 建立 namespace,而不是事后 `setns()` 切换。
|
||||
|
||||
---
|
||||
|
||||
## 现有基础:GOST 已有的可复用组件
|
||||
|
||||
### 1. TUN Listener 框架
|
||||
|
||||
两个 TUN listener 实现:
|
||||
- `x/listener/tun/` — 使用 `golang.zx2c4.com/wireguard/tun`,注册名 `"tun"`
|
||||
- `x/listener/tungo/` — 同样的 wireguard tun driver,注册名 `"tungo"`
|
||||
|
||||
两者结构相同:`Init()` → `listenLoop()` → `Accept()` 返回 TUN `net.Conn`,handler 在 conn 上读写 IP 包。
|
||||
|
||||
### 2. gVisor Netstack 集成
|
||||
|
||||
`x/handler/tungo/` 已深度集成 gVisor netstack (`gvisor.dev/gvisor/pkg/tcpip/stack`)。handler 接收 TUN conn 上的 IP 包,通过 gVisor 做 TCP/UDP 协议栈处理,将流量路由到 GOST proxy chain。
|
||||
|
||||
**关键架构优势**:TUN listener 和 handler 在 `io.ReadWriteCloser` 接口处解耦。意味着新增一个 "在 user namespace 内创建 TUN 并通过 fd 传递传回" 的 listener,可以直接对接现有的 `tungo` handler。
|
||||
|
||||
### 3. Namespace 基础设施
|
||||
|
||||
- `x/internal/net/dialer/dialer_netns.go` — `switchNetns()` 函数,`runtime.LockOSThread()` + `netns.Get()` + `netns.Set()` 模式
|
||||
- `x/internal/net/net.go` — `ListenConfig` wrapper,支持 namespace-aware 的 `Listen()`/`ListenPacket()`
|
||||
- `x/config/parsing/parse.go` — `MDKeyNetns`, `MDKeyNetnsOut` metadata key 常量
|
||||
- `x/config/parsing/service/parse.go` — Service 级别的 netns 解析和注入
|
||||
|
||||
### 4. Proxy Chain 基础设施
|
||||
|
||||
完全复用:路由 → chain → hop → dialer → connector 管道无需任何修改。
|
||||
|
||||
---
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案 A:Wirez 风格透明代理 — 新增 `usertun` Listener ⭐ 推荐
|
||||
|
||||
新增 listener 类型,在子进程中 spawn 目标程序(`CLONE_NEWUSER | CLONE_NEWNET`),子进程创建 TUN 并通过 Unix socket + `SCM_RIGHTS` 将 fd 传回父进程。父进程用 gVisor netstack 在 TUN 上处理 TCP/UDP,路由到 GOST proxy chain。
|
||||
|
||||
```
|
||||
┌─ Parent (GOST, unprivileged host user) ───────────┐
|
||||
│ │
|
||||
│ TUN fd ← SCM_RIGHTS ← Unix socket ← Child │
|
||||
│ gVisor netstack ← TUN │
|
||||
│ TCP/UDP → GOST chain → SOCKS5/HTTP proxy │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│ clone(CLONE_NEWUSER | CLONE_NEWNET)
|
||||
▼
|
||||
┌─ Child (new userns+netns, UID 0 inside) ───────────┐
|
||||
│ │
|
||||
│ Creates /dev/net/tun │
|
||||
│ Configures routing (default gw → TUN) │
|
||||
│ exec's target program │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**re-exec 核心代码示意:**
|
||||
|
||||
```go
|
||||
// Parent: spawn child in new user+net namespace
|
||||
socks, _ := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
|
||||
cmd := exec.Command("/proc/self/exe", "--gost-usertun-child", ...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
|
||||
UidMappings: []syscall.SysProcIDMap{
|
||||
{ContainerID: 0, HostID: os.Getuid(), Size: 1},
|
||||
},
|
||||
GidMappings: []syscall.SysProcIDMap{
|
||||
{ContainerID: 0, HostID: os.Getgid(), Size: 1},
|
||||
},
|
||||
GidMappingsEnableSetgroups: false,
|
||||
}
|
||||
cmd.ExtraFiles = []*os.File{os.NewFile(uintptr(socks[1]), "tun-sock")}
|
||||
cmd.Start()
|
||||
|
||||
// Receive TUN fd from child
|
||||
oob := make([]byte, unix.CmsgSpace(4))
|
||||
_, _, _, _, err := unix.Recvmsg(socks[0], nil, oob, 0)
|
||||
// ... extract fd from SCM_RIGHTS control message
|
||||
```
|
||||
|
||||
**可复用的已有代码:**
|
||||
|
||||
| 组件 | 文件 | 复用方式 |
|
||||
|------|------|----------|
|
||||
| TUN 设备创建 | `x/listener/tun/tun.go` (`createTunDevice()`) | 在子进程中调用 |
|
||||
| gVisor netstack | `x/handler/tungo/` | 父进程中直接使用 |
|
||||
| Proxy chain | `x/chain/`, `x/router/` | 无修改 |
|
||||
| LockOSThread 模式 | `x/internal/net/dialer/dialer_netns.go` | 参考模式 |
|
||||
| Netlink 路由配置 | `x/listener/tun/tun_linux.go` | 在子进程中调用 |
|
||||
|
||||
**需新增的代码(估算 ~400-600 行):**
|
||||
|
||||
| 工作项 | 估算行数 |
|
||||
|--------|----------|
|
||||
| re-exec 入口点 (child mode handler in `cmd/gost/`) | ~50 |
|
||||
| Namespace + TUN 创建 + 路由 (child side) | ~120 |
|
||||
| Parent/child Unix socket + SCM_RIGHTS fd 传递 | ~60 |
|
||||
| usertun listener (parent side, gVisor attachment) | ~150 |
|
||||
| Metadata 解析 + config 定义 | ~80 |
|
||||
| 错误处理 + 优雅关闭 + cleanup | ~80 |
|
||||
| 非 Linux 平台 stub | ~40 |
|
||||
|
||||
**依赖变化:零新增依赖。** 所有需要的原语已在依赖树中:
|
||||
- `golang.org/x/sys/unix` — `CLONE_NEWUSER`, `CLONE_NEWNET`, `SCM_RIGHTS`, `Recvmsg`
|
||||
- `golang.zx2c4.com/wireguard/tun` — TUN 设备创建
|
||||
- `github.com/vishvananda/netlink` — 地址/路由配置
|
||||
- `gvisor.dev/gvisor/pkg/tcpip/stack` — userspace 协议栈(已在 tungo handler 中使用)
|
||||
|
||||
**复杂度:中等。** 核心挑战是进程管理和 fd 传递,不涉及 GOST 核心转发逻辑改动。
|
||||
|
||||
**平台限制:Linux only。**
|
||||
|
||||
---
|
||||
|
||||
### 方案 B:简单 User NS 包装器 — 新增 `userexec` Listener
|
||||
|
||||
不集成 TUN/gVisor。让 GOST 启动外部命令在 user+net namespace 中运行,GOST 在 namespace 内暴露一个 SOCKS5 listener,子进程通过该 listener 代理。
|
||||
|
||||
```
|
||||
gost -L "userexec://?cmd=curl&listen=127.0.0.1:0"
|
||||
&proxy=socks5://127.0.0.1:1080"
|
||||
```
|
||||
|
||||
GOST 在 namespace 内创建一个 SOCKS5 listener + handler,子进程通过环境变量 (`ALL_PROXY`) 或直接配置代理。
|
||||
|
||||
**改动量:~200-300 行**
|
||||
|
||||
**优点:** 实现简单,不影响现有架构
|
||||
**缺点:**
|
||||
- 需要目标程序支持 SOCKS5/HTTP 代理(或 LD_PRELOAD),比不上 Wirez 的透明性
|
||||
- 对于不支持代理的程序(Go 静态链接、某些 CLI 工具)无效
|
||||
- 本质上只是 `unshare -Urn` + `proxy exec` 的包装
|
||||
|
||||
---
|
||||
|
||||
### 方案 C:GOST 自身沙箱化
|
||||
|
||||
将 GOST 拆分为特权 controller(处理 listener bind、TUN 创建)+ 非特权 worker(在 user namespace 中处理流量转发)。
|
||||
|
||||
**不推荐:** 改动范围太大(涉及核心生命周期重构),投入产出比不合理。作为安全加固方向可以在未来考虑,但不是 #652 的合理解决方案。
|
||||
|
||||
---
|
||||
|
||||
## 关键技术风险
|
||||
|
||||
| 风险 | 严重程度 | 缓解措施 |
|
||||
|------|----------|----------|
|
||||
| **Go #29789**: `Unshareflags` + `UidMappings` 组合在 `exec.Cmd` 中可能 `EPERM` | 中 | 使用 `Cloneflags` 而非 `Unshareflags`;此 bug 在 `exec.Cmd` 中已有 workaround |
|
||||
| **`/dev/net/tun` 访问权限** | 低 | 子进程在 user ns 中是 UID 0,有 `CAP_NET_ADMIN`;`/dev/net/tun` 在内核中不检查 host UID |
|
||||
| **gVisor netstack 性能** | 低 | `tungo` handler 已验证 gVisor 性能可接受;对日常使用足够 |
|
||||
| **`/etc/subuid` 依赖** | 无 | 单 UID 映射 (`host_uid → ns_uid 0`) 不需要 `/etc/subuid` |
|
||||
| **`setgroups` 限制** | 低 | `GidMappingsEnableSetgroups: false` 是 Go 1.7+ 的标准写法 |
|
||||
| **User NS 嵌套** | 低 | 如果 GOST 本身在容器中运行(已有 user ns),嵌套创建需要内核支持(最大 32 层);Linux 4.9+ 默认支持 |
|
||||
| **re-exec 入口点** | 低 | 隐藏 CLI flag(`--gost-usertun-child`),不影响用户界面 |
|
||||
|
||||
---
|
||||
|
||||
## 不需要引入的库
|
||||
|
||||
以下库经过评估,**不建议引入**:
|
||||
|
||||
| 库 | 原因 |
|
||||
|----|------|
|
||||
| `github.com/opencontainers/runc/libcontainer` | 太重:~50+ 间接依赖(cgroups, seccomp, capabilities, apparmor, CRIU),只为 user+net ns 创建引入不划算 |
|
||||
| `github.com/rootless-containers/rootlesskit` | 二进制导向架构(外部 `newuidmap`/`newgidmap`),不适合做 library |
|
||||
| `github.com/v-byte-cpu/wirez` | 独立的命令行工具,不是 library |
|
||||
|
||||
---
|
||||
|
||||
## 实现路径建议
|
||||
|
||||
如果决定实现方案 A,分 3 个阶段:
|
||||
|
||||
### Phase 1: POC (~1-2 天)
|
||||
- [ ] parent/child re-exec 框架(`cmd/gost/main.go` 中增加 `--gost-usertun-child` 隐藏 flag)
|
||||
- [ ] 子进程中 user+net namespace 创建 + TUN 创建 + 路由配置
|
||||
- [ ] Unix socket + SCM_RIGHTS fd 传递
|
||||
- [ ] 父进程中 TUN fd → gVisor netstack → proxy chain 通路验证
|
||||
- [ ] 非 root 用户端到端测试(如 `curl ifconfig.me` → SOCKS5 代理)
|
||||
|
||||
### Phase 2: 生产化 (~2-3 天)
|
||||
- [ ] `usertun` listener 完整实现(metadata 解析、config 定义)
|
||||
- [ ] 错误处理:子进程 crash、TUN 断开、namespace 清理
|
||||
- [ ] 优雅关闭:SIGTERM → 子进程 cleanup → namespace 销毁
|
||||
- [ ] DNS 在 namespace 内的处理(gVisor DNS resolver)
|
||||
- [ ] 非 Linux 平台 stub(返回 "not supported")
|
||||
|
||||
### Phase 3: 测试/文档 (~1 天)
|
||||
- [ ] E2E 测试(Docker-based,参考 `gost/tests/e2e/`)
|
||||
- [ ] `docs/issues/` 中配置文档
|
||||
- [ ] 上游 issue 回复
|
||||
|
||||
---
|
||||
|
||||
## 需要修改的文件清单
|
||||
|
||||
### 新文件
|
||||
- `x/listener/usertun/listener.go` — usertun listener(父进程侧,接收 TUN fd)
|
||||
- `x/listener/usertun/listener_linux.go` — Linux 实现
|
||||
- `x/listener/usertun/listener_other.go` — 非 Linux stub
|
||||
- `x/listener/usertun/metadata.go` — metadata 解析
|
||||
- `x/listener/usertun/conn.go` — TUN conn 包装
|
||||
- `x/internal/net/userns/userns_linux.go` — user+net namespace 创建 + re-exec 逻辑
|
||||
- `x/internal/net/userns/userns_other.go` — 非 Linux stub
|
||||
- `gost/cmd/gost/usertun_child.go` — re-exec 子进程入口点
|
||||
|
||||
### 需要修改的已有文件
|
||||
- `x/config/parsing/parse.go` — 新增 `MDKeyUserTun` metadata key
|
||||
- `x/config/parsing/service/parse.go` — usertun 的 service 解析
|
||||
- `x/config/config.go` — UserTun 相关 config struct(如需要)
|
||||
- `gost/cmd/gost/main.go` — re-exec 入口分发
|
||||
- `gost/cmd/gost/register.go` — 注册 usertun listener
|
||||
|
||||
### 不需要修改的文件
|
||||
- `x/handler/tungo/` — 不做任何修改,直接复用
|
||||
- `x/chain/`, `x/router/` — 不做任何修改
|
||||
- `x/internal/net/dialer/` — 不做任何修改
|
||||
- `core/` — 接口不变
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
**可行性:高。** GOST 已有的 TUN + gVisor + proxy chain 架构天然适合 Wirez 风格的透明代理集成。User namespace 逻辑是完全隔离的"胶水代码",不改动任何现有核心转发路径。
|
||||
|
||||
**集成复杂度:中等偏低。** 核心工作约 400-600 行新代码,零新增依赖,主要挑战是进程管理和 fd 传递。分为 3 个阶段,总计约 4-6 天工作量。
|
||||
|
||||
**建议:** 技术可行,值得列入 roadmap,但不急于实现。这个功能开启了"非 root 透明代理任意程序"的使用场景,对移动端/CI 环境/容器化部署有意义。可以等社区有更多需求信号时再推进实现。
|
||||
|
||||
---
|
||||
|
||||
## 附录:Windows 平台可行性研究
|
||||
|
||||
Linux user namespace 的核心价值是:**无需管理员权限,创建隔离的网络环境,使任意程序的流量可以被透明代理。** 在 Windows 上是否有等价机制?本节对此进行独立评估。
|
||||
|
||||
### Windows 与 Linux 的根本差异
|
||||
|
||||
| 概念 | Linux | Windows |
|
||||
|------|-------|---------|
|
||||
| Namespace 隔离 | 内核原生支持,非特权用户可创建 | 无等价概念 |
|
||||
| 网络重定向 | `netns` + TUN + 路由表 | WFP / WinDivert / TUN(均需管理员) |
|
||||
| 进程级代理 | `CLONE_NEWUSER` → 子进程隔离 | 无原生机制 |
|
||||
| TUN 设备创建 | 非特权用户(在 user ns 内) | **始终需要管理员** |
|
||||
|
||||
**核心结论:Windows 不存在 Linux user namespace 的等价物。** 所有能实现"透明代理单个程序"的 Windows 机制都至少需要一次性管理员权限。
|
||||
|
||||
---
|
||||
|
||||
### Windows 透明代理技术全景
|
||||
|
||||
#### 方案 1:WinDivert(包级拦截 + 重定向)⭐ Windows 首选
|
||||
|
||||
WinDivert 是一个用户态包捕获/修改/重注入框架,内核驱动已签名预编译。
|
||||
|
||||
```
|
||||
目标程序 → WinDivert.sys (内核) → Go 用户态程序 (godivert)
|
||||
│
|
||||
修改目标 IP → 127.0.0.1:proxy
|
||||
│
|
||||
GOST SOCKS5/HTTP 代理
|
||||
│
|
||||
GOST chain → 真实目标
|
||||
```
|
||||
|
||||
| 属性 | 评估 |
|
||||
|------|------|
|
||||
| 管理员权限 | **需要**(安装驱动) |
|
||||
| 单进程过滤 | ✅(`process.path = "C:\\...\\app.exe"`) |
|
||||
| TCP 重定向 | ✅ |
|
||||
| UDP 重定向 | ⚠️ 可能但复杂 |
|
||||
| Go 库 | `github.com/yanlinLiu0424/godivert`, `github.com/imgk/divert-go` |
|
||||
| 成熟度 | 高(ProxyBridge、Mullvad、SSTap 等均基于此) |
|
||||
| 新增依赖 | `godivert` + `WinDivert64.sys`(预编译,~200KB) |
|
||||
|
||||
**GOST 集成方式:** 新增 `windivert` handler,监听 WinDivert 捕获的包,将 TCP SYN 目标改写为本机代理端口。参考实现:[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)(MIT,Go)。
|
||||
|
||||
**复杂度:中。** GOST 需要实现:WinDivert 会话管理 + 包解析/重写 + TCP 状态跟踪 + 代理转发。约 500-800 行新代码。
|
||||
|
||||
---
|
||||
|
||||
#### 方案 2:Wintun TUN + Route 重定向
|
||||
|
||||
GOST 已有完整的 Windows TUN 支持(`x/listener/tun/tun_windows.go`)。
|
||||
|
||||
| 属性 | 评估 |
|
||||
|------|------|
|
||||
| 管理员权限 | **需要**(创建网络适配器 + `netsh` 路由操作) |
|
||||
| 单进程过滤 | ❌(路由表是系统级的) |
|
||||
| TUN 创建 | ✅(已集成 `golang.zx2c4.com/wireguard/tun`) |
|
||||
| gVisor netstack | ✅(`x/handler/tungo/` 已集成) |
|
||||
| Go 库 | 全部已有 |
|
||||
|
||||
**限制:** Windows 没有 per-process 路由表。TUN 模式会将**整个系统**的流量(或在路由表中的特定 CIDR)导入代理,无法限制只代理单个进程。
|
||||
|
||||
**GOST 集成方式:** 完全不需新代码——`gost -L tun://` 在 Windows 上已经可以工作(以管理员权限运行)。
|
||||
|
||||
---
|
||||
|
||||
#### 方案 3:Wintun + WFP Callout(Mullvad 模式)— WinDivert 的主要竞争者
|
||||
|
||||
这是理论上最优、实践上最难的方案。核心思路:
|
||||
|
||||
**WFP callout 内核驱动在 `ALE_CONNECT_REDIRECT` / `ALE_BIND_REDIRECT` 层拦截特定进程的 socket 操作,将流量重定向到 Wintun 虚拟适配器。Wintun 上的 IP 包**保留原始目标地址**(因为重定向发生在 socket 层而非包层),GOST 已有的 TUN listener + gVisor netstack + proxy chain 直接处理,零新 handler 代码。**
|
||||
|
||||
```
|
||||
目标进程调用 connect("example.com:443")
|
||||
│
|
||||
▼
|
||||
┌─ WFP Callout 内核驱动 ──────────────────────────────┐
|
||||
│ ALE_CONNECT_REDIRECT_V4/V6 │
|
||||
│ ├─ 按 PID/进程名过滤 │
|
||||
│ ├─ 重定向 socket → Wintun 接口 │
|
||||
│ └─ 原始目标 IP 保持不变(socket 级操作) │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ TCP SYN dst=example.com:443
|
||||
▼
|
||||
┌─ Wintun TUN 适配器 ─────────────────────────────────┐
|
||||
│ IP 包: src=<app> dst=example.com:443 ← 目标完整保留 │
|
||||
│ GOST TUN listener (已有) │
|
||||
│ gVisor netstack (已有) │
|
||||
│ GOST proxy chain (已有) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**为什么这比 WinDivert 优雅:**
|
||||
|
||||
| 维度 | WFP Callout → Wintun | WinDivert |
|
||||
|------|---------------------|-----------|
|
||||
| **拦截层** | Socket 层(`connect()`/`bind()` 级别) | 包层(IP/TCP 头修改) |
|
||||
| **原始目标** | 自然保留在 IP 头中 | 丢失,需映射表恢复 |
|
||||
| **TCP 状态** | 内核处理,无需用户态跟踪 | 需用户态维护 TCP 状态机 |
|
||||
| **校验和** | 无需修改 | 需重算 IP/TCP checksum |
|
||||
| **UDP 支持** | ✅ 干净(BIND_REDIRECT + CONNECT_REDIRECT) | ⚠️ 复杂 |
|
||||
| **GOST 代码复用** | ✅ 100%(TUN+gVisor+chain 不变) | ⚠️ ~50%(需新 handler) |
|
||||
| **连接生命周期** | WFP 原生跟踪(connect/close 事件) | 用户态推断 |
|
||||
| **递归重定向防护** | WFP 内置机制(`SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS`) | 需手动维护排除列表 |
|
||||
|
||||
**但是——实现代价极其巨大:**
|
||||
|
||||
下面是 Mullvad [win-split-tunnel](https://github.com/mullvad/win-split-tunnel)(开源的 WFP callout 驱动,GPLv3/MPL-2.0)的工程复杂度分析:
|
||||
|
||||
| 维度 | 数据 |
|
||||
|------|------|
|
||||
| 语言 | C++ 94.2% + C 4.2%(非 C#/Rust/Go) |
|
||||
| 类型 | 非 PnP KMDF 内核驱动 |
|
||||
| 源文件 | 21 个根级文件 + 8 个 firewall/ 子系统文件 |
|
||||
| 子系统 | 防火墙(WFP callout)、进程管理(完整进程树)、事件(用户态通信)、IOCTL 接口 |
|
||||
| 驱动状态机 | STARTED → INITIALIZED → READY → ENGAGED(4 阶段 IOCTL 初始化) |
|
||||
| WFP 层 | `ALE_CONNECT_REDIRECT_V4/V6`, `ALE_BIND_REDIRECT_V4/V6`, `ALE_AUTH_CONNECT_V4/V6`, `ALE_AUTH_RECV_ACCEPT_V4/V6` |
|
||||
| 流量场景 | 9 种组合(IPv4/v6 × internet/tunnel 接口可用性) |
|
||||
| 进程管理 | 系统范围进程创建/销毁通知 + 父子进程继承 |
|
||||
| 事务系统 | 自定义并行事务系统(独立于 WFP 事务) |
|
||||
| 构建环境 | Visual Studio 2022 + WDK v10.0.22621 + Windows SDK v10.0.22621(仅可在 Win10 2004+ 构建) |
|
||||
| 代码签名 | EV 证书($300-500/年)+ Microsoft 认证签名 |
|
||||
| 用户态依赖 | 需要 agent 进程持续提供配置、监控网络接口变化 |
|
||||
| Git 提交 | 205 commits |
|
||||
|
||||
**对 GOST 来说,实现一个 WFP callout 驱动的实际工作量估算:**
|
||||
|
||||
| 工作项 | 估算 |
|
||||
|--------|------|
|
||||
| WFP callout 驱动开发(C++) | 3-5 周(全职) |
|
||||
| 进程管理子系统(PID/进程名过滤 + 父子继承) | 1-2 周 |
|
||||
| IOCTL 接口 + 用户态 Go 控制库(CGO) | 1 周 |
|
||||
| EV 签名 + Microsoft 认证签名 | 持续成本 |
|
||||
| 驱动安装/卸载/升级逻辑 | 1 周 |
|
||||
| 跨 Windows 版本兼容性测试(10/11/Server) | 1-2 周 |
|
||||
| 调试和稳定性(内核崩溃 = BSOD) | 2-4 周 |
|
||||
| 用户态 agent(网络变化监控 + 配置推送) | 1 周 |
|
||||
| **总计** | **10-16 周 + 持续的签名费用** |
|
||||
|
||||
**结论:Wintun+WFP 在技术架构上明显优于 WinDivert,但实现代价完全不匹配 GOST 的投入规模。** 这条路线只有在以下场景才合理:(a) 商业化产品有专职 Windows 内核开发团队;(b) 可以 fork Mullvad win-split-tunnel 直接复用(但 Mullvad 的驱动是"排除进程出 VPN"方向,需要反转逻辑 + 仍然需要签名)。
|
||||
|
||||
**GOST 若选择此路线,最现实的方案是直接复用 Mullvad 驱动 + 反转过滤逻辑(从"排除"改为"包含")**——但即使如此,仍需精通 Windows 内核开发的工程师,以及持续的代码签名费用。
|
||||
|
||||
---
|
||||
|
||||
#### 方案 4:API Hooking / DLL 注入
|
||||
|
||||
启动目标进程时注入 hook DLL,拦截 Winsock `connect()`/`WSAConnect()` 调用并重定向到本机代理。
|
||||
|
||||
| 属性 | 评估 |
|
||||
|------|------|
|
||||
| 管理员权限 | ⚠️ 不一定需要(注入自己启动的子进程通常可行) |
|
||||
| 单进程过滤 | ✅(注入到目标进程内) |
|
||||
| TCP 重定向 | ✅ |
|
||||
| UDP 重定向 | ✅(hook `sendto()`/`recvfrom()`) |
|
||||
| Go 实现 | 需要 CGO + 注入 DLL(C 编写) |
|
||||
| 风险 | 杀软可能标记;32/64 位不匹配;对反注入保护的应用无效 |
|
||||
|
||||
参考:[ProxiGo](https://github.com/hypnguyen1209/proxigo)(Go 实现的 Detours 风格注入器)。
|
||||
|
||||
**复杂度:中高。** 需要维护注入 DLL(C 代码),且可靠性不如内核级方案。
|
||||
|
||||
---
|
||||
|
||||
#### 方案 5:AppContainer + 环境变量
|
||||
|
||||
Windows AppContainer 提供进程级安全隔离(UWP/IoT 使用),结合环境变量实现"受限代理"。
|
||||
|
||||
| 属性 | 评估 |
|
||||
|------|------|
|
||||
| 管理员权限 | **不需要** |
|
||||
| 网络能力控制 | ✅(通过 Capability SID 限制 Internet/Private 访问) |
|
||||
| 代理重定向 | ❌(只能 Allow/Deny,不能重定向) |
|
||||
| 环境变量注入 | ✅(`HTTP_PROXY` 等) |
|
||||
|
||||
**限制:** AppContainer 不能重定向流量到代理。只能配合 `HTTP_PROXY` 环境变量使用(仅覆盖支持它的应用)。这不是透明代理方案。
|
||||
|
||||
---
|
||||
|
||||
#### 方案 6:Windows 容器 / HCS
|
||||
|
||||
Windows 容器(通过 `hcsshim` Go SDK)提供网络命名空间级别的隔离,等效于 Linux network namespace + user namespace。
|
||||
|
||||
| 属性 | 评估 |
|
||||
|------|------|
|
||||
| 管理员权限 | **需要**(启用 Containers 功能 + HCS 操作) |
|
||||
| 网络隔离 | ✅(HCN/HNS 提供 network namespace 等效能力) |
|
||||
| 代理策略 | ✅(`hcnproxyctrl` 支持 L4 WFP 代理策略) |
|
||||
| Go SDK | ✅(`github.com/Microsoft/hcsshim`) |
|
||||
| 开销 | 高(~200MB+ 基础镜像,秒级启动) |
|
||||
|
||||
**不推荐。** 开销太大,不适合"代理单个命令"的场景。
|
||||
|
||||
---
|
||||
|
||||
#### 方案 7:PAC / WinHTTP / WinINET 代理设置
|
||||
|
||||
| 属性 | 评估 |
|
||||
|------|------|
|
||||
| 管理员权限 | WinINET: 不需要(HKCU registry)。WinHTTP: 需要(HKLM)。 |
|
||||
| 单进程过滤 | ❌(用户级或机器级全局设置) |
|
||||
| 覆盖范围 | WinINET: IE + 使用 WinINET 的应用。WinHTTP: 服务/后台应用。均不覆盖直接使用 Winsock 的程序。 |
|
||||
|
||||
**不适合。** 既不能做到单进程,也不能覆盖所有应用。
|
||||
|
||||
---
|
||||
|
||||
### Windows 方案对比总表
|
||||
|
||||
| 方案 | 需要 Admin | 单进程代理 | 透明性 | TCP | UDP | 新代码 | 新依赖 |
|
||||
|------|-----------|-----------|--------|-----|-----|--------|--------|
|
||||
| **WinDivert** ⭐ | 一次性(驱动安装) | ✅ | ✅ | ✅ | ⚠️ | ~500-800行 | `godivert` |
|
||||
| Wintun TUN | 每次运行 | ❌ | ✅ | ✅ | ✅ | 0 | 无 |
|
||||
| Wintun + WFP | 每次运行 | ✅ | ✅ | ✅ | ✅ | ~1000行 | 内核驱动(C) |
|
||||
| DLL 注入/Hooking | ⚠️ 不一定 | ✅ | ✅ | ✅ | ✅ | ~600行 | 注入DLL(C) |
|
||||
| AppContainer | ❌ | ✅ | ❌ | N/A | N/A | ~200行 | 无 |
|
||||
| Windows 容器 | 每次运行 | ✅ | ✅ | ✅ | ✅ | ~400行 | `hcsshim` (~10MB) |
|
||||
| PAC/WinHTTP | ❌ | ❌ | ❌ | N/A | N/A | 0 | 无 |
|
||||
|
||||
---
|
||||
|
||||
### Windows 推荐方案:WinDivert
|
||||
|
||||
在 Windows 上实现 #652 的目标(透明代理单个程序),**WinDivert 是唯一在复杂度、可靠性、成熟度之间取得合理平衡的方案**。
|
||||
|
||||
**架构设计:**
|
||||
|
||||
```
|
||||
gost -L "windivert://?process=chrome.exe&proxy=socks5://127.0.0.1:1080"
|
||||
```
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ GOST Process │
|
||||
│ │
|
||||
WinDivert64.sys ──→ │ godivert │──→ TCP 状态跟踪
|
||||
(kernel driver) │ handler │──→ 包重写(目标→127.0.0.1:proxy)
|
||||
↑ │ │──→ 代理 SOCKS5/HTTP
|
||||
目标进程流量 │ local proxy │──→ GOST proxy chain
|
||||
(chrome.exe) └──────────────┘
|
||||
```
|
||||
|
||||
**关键实现要点:**
|
||||
|
||||
1. **进程过滤:** WinDivert filter string `outbound and tcp and process.path = "C:\\...\\target.exe"`
|
||||
2. **包重写:** 修改 TCP SYN 的 destination IP/port 为 `127.0.0.1:<proxy_port>`,重算 checksum
|
||||
3. **原目标记录:** 在代理端通过自定义协议或 TPROXY 等价机制获取原始目标地址
|
||||
4. **连接跟踪:** 维护 src_ip:src_port → original_dst 映射表
|
||||
5. **DNS 劫持:** 拦截 UDP 53 的 DNS 请求,防止 DNS 泄漏
|
||||
|
||||
**与 Linux 方案的对比:**
|
||||
|
||||
| 维度 | Linux (usertun) | Windows (windivert) |
|
||||
|------|----------------|---------------------|
|
||||
| 权限模型 | 零特权(user ns 内 root) | 需要一次性管理员安装驱动 |
|
||||
| 架构 | re-exec 子进程 + TUN fd 传递 | 内核驱动拦截 + 用户态包重写 |
|
||||
| 代码量 | ~400-600 行 | ~500-800 行 |
|
||||
| 新增依赖 | 零 | `godivert` (~3KB Go 代码) + `WinDivert64.sys` (~200KB) |
|
||||
| 成熟度参考 | wirez, tun2socks, gVisor | ProxyBridge, Mullvad, SSTap |
|
||||
| 平台 | Linux 3.8+ | Windows 7+ |
|
||||
|
||||
---
|
||||
|
||||
### 业界参考:SocksCap64 与 Netch 的进程流量代理原理
|
||||
|
||||
为了更全面地评估 Windows 上的技术选型,本节分析两个成熟的 Windows 进程代理工具的内部机制。
|
||||
|
||||
#### SocksCap64:Inline API Hook + 远程 DLL 注入
|
||||
|
||||
**主页:** https://www.sockscap64.com/
|
||||
|
||||
**核心原理:** SocksCap64 是经典的 Permeo SocksCap 的 64 位继任者,采用**用户态 Inline API Hook + 远程 DLL 注入**技术。
|
||||
|
||||
**架构流程:**
|
||||
|
||||
```
|
||||
SocksCap64.exe (启动器)
|
||||
│
|
||||
│ CreateProcess + 远程 DLL 注入
|
||||
▼
|
||||
目标进程 (已注入 Hook DLL)
|
||||
│
|
||||
│ Hook ws2_32.dll 函数:
|
||||
│ connect(), send(), recv()
|
||||
│ WSASend(), WSARecv()
|
||||
│ sendto(), recvfrom()
|
||||
│ bind(), GetAddrInfoW()
|
||||
│
|
||||
│ 每次 connect() 被截获时:
|
||||
│ 1. 记录原始目标地址
|
||||
│ 2. 将目标改写为 127.0.0.1:<proxy_port>
|
||||
│ 3. 完成 SOCKS5 握手(认证 + CONNECT)
|
||||
│ 4. 所有 send()/recv() → 通过 SOCKS5 隧道中转
|
||||
▼
|
||||
SOCKS5 代理服务器
|
||||
```
|
||||
|
||||
**技术细节:**
|
||||
|
||||
| 维度 | 说明 |
|
||||
|------|------|
|
||||
| Hook 方式 | **Inline Hook**(函数开头写入 `JMP <HookHandler>`,5 字节) |
|
||||
| Hook 库 | 未公开(可能基于 Detours 或自研) |
|
||||
| 注入方式 | 远程线程注入(`CreateRemoteThread` + `LoadLibrary`) |
|
||||
| 目标进程 | 由 SocksCap64 启动的任意 Win32 程序 |
|
||||
| 管理员权限 | 理论上不需要(注入自己启动的子进程),安装时可能需要 |
|
||||
| 源码 | 闭源 |
|
||||
| Go 绑定 | 无 |
|
||||
| 稳定性 | 中低 — 侵入式注入可能被反作弊/杀软拦截 |
|
||||
| 覆盖范围 | 所有使用 Winsock 的 Win32 应用;**不覆盖 UWP/Metro 应用** |
|
||||
|
||||
**Hook 过程(5 步):**
|
||||
|
||||
1. `GetProcAddress` 获取 `ws2_32.dll` 中 `connect`/`send`/`recv` 等函数地址
|
||||
2. 保存原始函数的前 N 字节指令(用于 trampoline 回调原始函数)
|
||||
3. `VirtualProtect` 将函数地址页设为 `PAGE_EXECUTE_READWRITE`
|
||||
4. 在函数开头写入 `JMP <HookHandler>`(E9 相对跳转,5 字节)
|
||||
5. Hook Handler 中:判断是否代理 → 修改目标地址 → SOCKS5 握手 → 转发数据
|
||||
|
||||
**SocksCap64 vs Proxifier 差异:**
|
||||
|
||||
| | SocksCap64 | Proxifier |
|
||||
|---|---|---|
|
||||
| 技术 | Inline API Hook + DLL 注入 | Winsock LSP(Winsock SPI) |
|
||||
| 机制 | 修改函数 prologue | 注册 Layered Service Provider |
|
||||
| 稳定性 | 较低(侵入式注入) | 较高(使用官方 SPI 接口) |
|
||||
| 反作弊对抗 | 容易被检测/拦截 | 兼容性更好 |
|
||||
| 现状 | 已停止更新 | 持续维护 |
|
||||
| 64 位支持 | 原生支持 | 原生支持 |
|
||||
|
||||
**对 GOST 的参考价值:**
|
||||
- ✅ 验证了 "拦截 Winsock → 改写目标 → SOCKS5 转发" 模式的有效性
|
||||
- ✅ 不需要内核驱动,不强制需要管理员权限
|
||||
- ❌ DLL 注入本身可靠性低,对 GOST 这种网络工具不适用
|
||||
- ❌ 注入 DLL 需要 C 编写,Go 无法直接实现
|
||||
|
||||
---
|
||||
|
||||
#### Netch ProcessMode:内核级包过滤 (netfilter2) + 用户态重定向
|
||||
|
||||
**主页:** https://github.com/BoyceLig/Netch | **许可:** GPLv3
|
||||
|
||||
**核心原理:** Netch 的 `ProcessMode` 使用 **ntkernel.com netfilter2 SDK**(商业内核驱动)+ **用户态 Redirector DLL**(C++),实现进程级 TCP/UDP 流量拦截和 SOCKS5 重定向。
|
||||
|
||||
**架构流程:**
|
||||
|
||||
```
|
||||
┌─ Netch.exe (C# / .NET) ──────────────────────────┐
|
||||
│ │
|
||||
│ P/Invoke → Redirector.bin (C++ DLL) │
|
||||
│ ├─ aio_init() 初始化 │
|
||||
│ ├─ aio_register() 注册进程过滤规则 │
|
||||
│ └─ aio_dial() 配置代理目标 │
|
||||
│ │
|
||||
│ Redirector DLL: │
|
||||
│ ├─ nf_init() 注册 netfilter2 驱动 │
|
||||
│ ├─ nf_registerDriver() │
|
||||
│ ├─ nf_addRule() 安装过滤规则 │
|
||||
│ └─ NF_EventHandler 回调: │
|
||||
│ ├─ tcpConnectRequest ← 内核拦截 TCP 连接 │
|
||||
│ ├─ tcpSend / tcpReceive │
|
||||
│ ├─ udpCreated / udpSend / udpReceive │
|
||||
│ └─ tcpClosed / udpClosed │
|
||||
│ │
|
||||
│ TCPHandler (用户态): │
|
||||
│ ├─ 本地 TCP listener (127.0.0.1:<port>) │
|
||||
│ ├─ CreateHandler(client, original_dst) 映射表 │
|
||||
│ └─ Handle() → SocksHelper::TCP → SOCKS5 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│ nf_registerDriver (ioctl)
|
||||
▼
|
||||
┌─ netfilter2.sys (内核驱动, ntkernel.com) ──────────┐
|
||||
│ 拦截 TCP connect request (NDIS/WFP 层) │
|
||||
│ 按 PID/进程名过滤 │
|
||||
│ 将包信息回调到用户态 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键源码文件(基于 GitHub 仓库分析):**
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `Redirector/Redirector.cpp` | DLL 入口,初始化 netfilter2 |
|
||||
| `Redirector/EventHandler.cpp` | `tcpConnectRequest` 等回调实现 |
|
||||
| `Redirector/TCPHandler.cpp` | 本地 TCP 监听 + 连接映射表 |
|
||||
| `Redirector/SocksHelper.cpp` | SOCKS5 握手 + 认证 + CONNECT/UDP ASSOCIATE |
|
||||
| `Redirector/DNSHandler.cpp` | DNS 劫持/重定向 |
|
||||
| `Netch/` (C#) | 主程序,P/Invoke 调用 Redirector.bin |
|
||||
| `RouteHelper/` | 辅助路由工具 |
|
||||
|
||||
**TCP `tcpConnectRequest` 重定向流程(基于 EventHandler.cpp 源码):**
|
||||
|
||||
```cpp
|
||||
void tcpConnectRequest(ENDPOINT_ID id, PNF_TCP_CONN_INFO info) {
|
||||
// 1. 自过滤:不拦截 redirector 自身的流量
|
||||
if (CurrentID == info->processId) return;
|
||||
|
||||
// 2. 进程名过滤:检查 bypassList 和 handleList
|
||||
if (checkBypassName(info->processName)) return; // 白名单跳过
|
||||
if (!checkHandleName(info->processName)) return; // 不在代理列表中
|
||||
|
||||
// 3. 记录原始目标地址
|
||||
SOCKADDR_IN6 client, remote;
|
||||
memcpy(&client, &info->localAddress, sizeof(client));
|
||||
memcpy(&remote, &info->remoteAddress, sizeof(remote));
|
||||
|
||||
// 4. 改写连接目标 → 本机 SOCKS 端口
|
||||
// IPv4: info->remoteAddress = 127.0.0.1:tcpListen
|
||||
// IPv6: info->remoteAddress = ::1:tcpListen
|
||||
|
||||
// 5. 建立映射表:client_addr → original_remote
|
||||
TCPHandler::CreateHandler(client, remote);
|
||||
}
|
||||
```
|
||||
|
||||
**技术总结:**
|
||||
|
||||
| 维度 | 说明 |
|
||||
|------|------|
|
||||
| 拦截层 | **内核级**(netfilter2 NDIS/WFP 驱动) |
|
||||
| 用户态组件 | Redirector.bin (C++ DLL),C# P/Invoke 调用 |
|
||||
| 进程过滤 | 驱动层按 PID + 进程名匹配 |
|
||||
| TCP 重定向 | 内核拦截 connect → 改写目标 → 本地 listener → SOCKS5 |
|
||||
| DNS 处理 | DNSHandler 劫持 DNS 查询 |
|
||||
| 管理员权限 | **必须**(内核驱动安装 + 运行) |
|
||||
| 源码 | C++/C# 部分 GPLv3 开源;netfilter2 驱动是商业闭源 |
|
||||
| Go 绑定 | **无**(netfilter2 SDK 提供 C API,可理论上通过 CGO 调用) |
|
||||
| 稳定性 | 高(内核级拦截比用户态 DLL 注入可靠) |
|
||||
|
||||
**Netch 的 4 种模式对比:**
|
||||
|
||||
| 模式 | 技术 | 粒度 | Admin |
|
||||
|------|------|------|-------|
|
||||
| **ProcessMode** | netfilter2 内核驱动 | 单进程 | 必须 |
|
||||
| **ShareMode** | WinPcap/Npcap | 系统级(共享) | 必须 |
|
||||
| **TunMode** | WinTUN 驱动 | 系统级 | 必须 |
|
||||
| **WebMode** | HTTP 代理 | 应用层(需应用支持) | 不需要 |
|
||||
|
||||
---
|
||||
|
||||
### Wintun+WFP vs WinDivert — 深度对比
|
||||
|
||||
#### 架构差异的本质
|
||||
|
||||
这两种方案的**根本区别**在于流量拦截的层级:
|
||||
|
||||
```
|
||||
Wintun+WFP (socket级): WinDivert (包级):
|
||||
|
||||
connect("example.com") connect("example.com")
|
||||
│ │
|
||||
[WFP callout] [WinDivert driver]
|
||||
改 socket 路由到 TUN 捕获 TCP SYN 包
|
||||
│ │
|
||||
TCP SYN: dst=example.com 改包: dst=127.0.0.1:proxy
|
||||
原始目标在 IP 头中! │
|
||||
│ TCP SYN: dst=127.0.0.1
|
||||
[GOST TUN listener] 原始目标丢失!
|
||||
│ │
|
||||
[gVisor netstack] [自定义 handler]
|
||||
TCP 协议栈自然处理 SOCKS5 握手 + dst 恢复
|
||||
│ │
|
||||
[GOST proxy chain] [GOST proxy chain]
|
||||
│ │
|
||||
→ example.com:443 → example.com:443
|
||||
```
|
||||
|
||||
#### 逐项对比
|
||||
|
||||
| 评估维度 | Wintun+WFP Callout | WinDivert | 优势方 |
|
||||
|----------|-------------------|-----------|--------|
|
||||
| **原始目标保留** | ✅ IP 头自然保留 | ❌ 需映射表/编码恢复 | WFP |
|
||||
| **TCP 处理** | ✅ gVisor 协议栈 | ⚠️ 手动 checksum + 状态跟踪 | WFP |
|
||||
| **UDP 处理** | ✅ WFP bind/connect redirect | ⚠️ 无连接语义,复杂 | WFP |
|
||||
| **连接生命周期** | ✅ WFP 原生 connect/close 事件 | ⚠️ 用户态推断 | WFP |
|
||||
| **DNS 处理** | ✅ gVisor DNS resolver | ⚠️ 需独立 DNSHandler | WFP |
|
||||
| **递归重定向防护** | ✅ WFP `SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS` | ⚠️ 手动排除本地代理端口 | WFP |
|
||||
| **GOST 代码复用** | ✅ 100%(TUN+gVisor+chain 零修改) | ⚠️ ~50%(需新 handler + 协议) | WFP |
|
||||
| **驱动依赖** | ❌ 需自写内核驱动 (C++) | ✅ WinDivert64.sys 已存在 | **WinDivert** |
|
||||
| **驱动签名** | ❌ EV 证书 + MS 认证 ($300-500/年) | ✅ 已签名 | **WinDivert** |
|
||||
| **开发语言** | ❌ C++ (KMDF 内核驱动) | ✅ Go (`godivert`) | **WinDivert** |
|
||||
| **开发工作量** | ❌ 10-16 周 + 内核调试 | ✅ 1-2 周 | **WinDivert** |
|
||||
| **维护负担** | ❌ 跨 Win10/11/Server 兼容性 + BSOD 风险 | ✅ 上游维护驱动 | **WinDivert** |
|
||||
| **Go 生态** | ❌ 无 Go 库,需 CGO 封装 | ✅ `godivert` 成熟 | **WinDivert** |
|
||||
| **构建环境** | ❌ VS2022 + WDK + WinSDK(仅 Win10 2004+ 可构建) | ✅ `go build` | **WinDivert** |
|
||||
| **开源参考** | Mullvad win-split-tunnel (C++, 205 commits) | ProxyBridge (Go, MIT) | **WinDivert** |
|
||||
|
||||
#### 结论
|
||||
|
||||
**Wintun+WFP 在技术架构上全面优于 WinDivert**(socket 级重定向 + 原始目标保留 + 100% 复用 GOST 已有 TUN 代码),但**实现代价完全不匹配 GOST 的投入规模**。
|
||||
|
||||
| | Wintun+WFP | WinDivert |
|
||||
|---|---|---|
|
||||
| 技术优雅度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| 实现可行性(对 GOST) | ⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| 投入产出比 | ⭐ | ⭐⭐⭐⭐ |
|
||||
|
||||
**WinDivert 仍然是 GOST 在 Windows 上唯一实际可行的单进程透明代理方案。** Wintun+WFP 在以下条件同时满足时才有意义:
|
||||
1. 有专职 Windows 内核开发工程师
|
||||
2. 愿意持续支付 EV 代码签名费用
|
||||
3. 愿意承担跨 Windows 版本的内核兼容性维护
|
||||
|
||||
对于 GOST 这样以 Go 为主的社区驱动项目——WinDivert 是正确选择。
|
||||
|
||||
---
|
||||
|
||||
### Windows 三种技术路线对比
|
||||
|
||||
综合 SocksCap64、Netch、Wintun+WFP、WinDivert 的分析,Windows 上实现单进程透明代理的路径:
|
||||
|
||||
| | **SocksCap64** | **Netch** | **Wintun+WFP** | **WinDivert** ⭐ |
|
||||
|---|---|---|---|---|
|
||||
| **技术** | Inline Hook + DLL 注入 | netfilter2 商业驱动 | WFP Callout 驱动 + Wintun | WinDivert 开源驱动 |
|
||||
| **拦截层** | 用户态(函数 hook) | 内核态(NDIS/WFP) | 内核态(WFP socket 重定向) | 内核态(WFP 包捕获) |
|
||||
| **Go 集成** | ❌(需 C 注入 DLL) | ❌(商业驱动 + C API) | ❌(需写 C++ 内核驱动) | ✅(`godivert` Go 库) |
|
||||
| **开源** | 闭源 | 半开源(驱动商业) | Mullvad 参考(GPLv3) | 开源(GPLv3+MIT) |
|
||||
| **Admin** | ⚠️ 不必要 | 必须 | 必须 | 一次性驱动安装 |
|
||||
| **可靠性** | 中低(注入易被拦截) | 高 | 高 | 高 |
|
||||
| **原始目标保留** | ✅(hook 参数) | ✅(内核提供) | ✅(IP 头自然保留) | ❌(需映射表) |
|
||||
| **GOST 代码复用** | 低 | 低 | ✅ 100%(TUN+gVisor 零改动) | 中(需新 handler) |
|
||||
| **开发量** | 高(注入 DLL) | 中高(商业授权) | **极高(10-16周+签名费)** | 中(~500-800行Go) |
|
||||
| **维护** | 高 | 中高 | **极高(内核跨版本)** | 低(上游维护) |
|
||||
| **GOST** | 不推荐 | 不推荐(商业驱动) | 技术最优,实践不现实 | ⭐ 唯一可行 |
|
||||
|
||||
**结论:WinDivert 仍然是 GOST 在 Windows 上的唯一实际可行方案。** Wintun+WFP 在技术架构上更优(socket 级重定向保留原始目标、100% 复用 GOST TUN+gVisor 代码),但实现代价(自写 C++ 内核驱动 + EV 签名费 + 跨版本兼容维护)与 GOST 的投入规模完全不匹配。
|
||||
|
||||
- SocksCap64 路线:Go 不能写注入 DLL,排除
|
||||
- Netch 路线:netfilter2 是商业闭源驱动,排除
|
||||
- **Wintun+WFP 路线:技术架构最优,但需 10-16 周 C++ 内核开发 + 持续签名费,对社区项目不现实**
|
||||
- **WinDivert 路线:技术上不如 WFP 优雅,但是唯一开源+Go 可集成的方案**
|
||||
|
||||
---
|
||||
|
||||
### WinDivert UDP 可靠性深度分析
|
||||
|
||||
WinDivert 对 TCP 的支持相对成熟(有连接的语义、可以可靠地重写 SYN 目标地址、通过 ACK/FIN 跟踪生命周期),但 **UDP 的支持有一系列架构层面的限制**,直接影响 GOST 集成的可行性。
|
||||
|
||||
#### 核心矛盾:SOCKET 层 vs NETWORK 层
|
||||
|
||||
这是 WinDivert 最根本的问题,源于 WFP 内核框架的设计:
|
||||
|
||||
| 层 | 进程 ID 可用 | 包数据正确 | 用途 |
|
||||
|-----|------------|-----------|------|
|
||||
| `WINDIVERT_LAYER_SOCKET` | ✅ `processId` 可用 | ❌ 包数据**损坏/丢失** | 仅能获取进程关联,不能用于数据捕获 |
|
||||
| `WINDIVERT_LAYER_FLOW` | ✅ `processId` 可用 | ❌ 包数据**损坏/丢失** | 同上 |
|
||||
| `WINDIVERT_LAYER_NETWORK` | ❌ `processId = 0` | ✅ 完整正确的 IP 包 | 可用于数据捕获,但**不能按进程过滤** |
|
||||
|
||||
**根因**(来自 WinDivert 作者):WFP 的 NETWORK/IPPACKET 层在内核中**不携带 ProcessId**——这不是 WinDivert 的 bug,而是 WFP 框架的设计限制。SOCKET/FLOW 层有进程信息,但 WinDivert 在这两层捕获的包数据不包含可解析的 IP/TCP/UDP 头。
|
||||
|
||||
来源:[basil00/WinDivert#382](https://github.com/basil00/WinDivert/issues/382), [basil00/WinDivert#71](https://github.com/basil00/WinDivert/issues/71)
|
||||
|
||||
#### 唯一可行的 workaround:双 Handle + 5-tuple 映射
|
||||
|
||||
所有基于 WinDivert 的透明代理工具(Tallow、zapret/winws、ProxyBridge)的通用做法:
|
||||
|
||||
```
|
||||
┌─ Handle 1: SOCKET 层 ────────────────────────┐
|
||||
│ Filter: "processId != 0" │
|
||||
│ 目的: 仅用于建立 5-tuple → PID 映射表 │
|
||||
│ (数据损坏不关心,只要事件到达即可) │
|
||||
│ │
|
||||
│ g_pidMap[(proto, srcIP, srcPort, │
|
||||
│ dstIP, dstPort)] = processId │
|
||||
└────────────────────────────────────────────────┘
|
||||
│ 共享映射表 (mutex + cv)
|
||||
▼
|
||||
┌─ Handle 2: NETWORK 层 ───────────────────────┐
|
||||
│ Filter: "outbound && udp" │
|
||||
│ 目的: 捕获正确的 UDP 包 │
|
||||
│ 每个包: 提取 5-tuple → 查映射表 → 决定放行/拦截│
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**但这个架构有三个不可消除的固有问题:**
|
||||
|
||||
**问题 1:竞态条件 (Race Condition)**
|
||||
|
||||
NETWORK 层包可能**先于** SOCKET 层事件到达。没有顺序保证。
|
||||
|
||||
缓解方案:
|
||||
- 对未命中映射的包做**排队等待**(典型超时 2-5 秒)
|
||||
- 超时后如果仍无映射,**丢弃该包**(首包丢失)
|
||||
- 这意味着 UDP 应用的**第一个数据包可能被静默丢弃**
|
||||
|
||||
**问题 2:UDP 是无连接的 — 映射表管理远比 TCP 复杂**
|
||||
|
||||
| | TCP | UDP |
|
||||
|---|---|---|
|
||||
| 生命周期事件 | SYN → 已连接 → FIN/RST | **无** |
|
||||
| 映射何时建立 | connect 前即可获得 | 第一个 sendto() 后 |
|
||||
| 映射何时释放 | close/fin 事件明确可知 | **无法确定**(socket 可能一直复用) |
|
||||
| 端口复用 | 简单(TIME_WAIT 后不可复用) | 同一个 socket sendto 到**不同目标**可能复用端口 |
|
||||
| 映射表增长 | 可控(一个连接一条记录) | 不可控(一个 socket 可能有 N 条记录) |
|
||||
|
||||
对于 UDP,如果程序创建了一个 socket 并 sendto 到 100 个不同目标,映射表需要 100 条记录。且无法知道程序何时不再向某个目标发送数据。
|
||||
|
||||
缓解方案(ProxyBridge 的做法):
|
||||
- 通过 `GetExtendedUdpTable` 轮询当前 UDP 绑定关系,作为补充
|
||||
- 设置较短的 TTL(~30s),超时未使用则清除映射
|
||||
|
||||
**问题 3:SOCKET 层可能不触发 UDP 事件**
|
||||
|
||||
WinDivert SOCKET 层的 `WINDIVERT_EVENT_SOCKET_BIND` 和 `WINDIVERT_EVENT_SOCKET_CONNECT` 事件主要针对 TCP 设计。UDP 的 `bind()`/`connect()`(若存在)触发的事件覆盖率不可靠。对于无连接的 `sendto()`,SOCKET 层可能**完全不产生事件**。
|
||||
|
||||
这意味着某些 UDP 应用的 5-tuple → PID 映射**永远无法建立**。
|
||||
|
||||
#### WinDivert UDP 已知问题清单
|
||||
|
||||
| 问题 | 严重度 | 缓解措施 | 来源 |
|
||||
|------|--------|----------|------|
|
||||
| **SOCKET 层包数据损坏** | 🔴 致命 | 双 handle 架构 | [#382](https://github.com/basil00/WinDivert/issues/382) |
|
||||
| **NETWORK 层无 processId** | 🔴 致命 | 双 handle + 5-tuple 映射 | [#71](https://github.com/basil00/WinDivert/issues/71) |
|
||||
| **NETWORK 与 SOCKET 层竞态** | 🟠 高 | 包排队 + 超时(首包可能丢) | 通用架构问题 |
|
||||
| **DHCP/广播 UDP 丢失** | 🟠 高 | Filter 排除广播 IP | [mitmproxy#6902](https://github.com/mitmproxy/mitmproxy/issues/6902) |
|
||||
| **多播包重复** | 🟡 中 | 跳过 Loopback=1 的包 | [#364](https://github.com/basil00/WinDivert/issues/364) |
|
||||
| **校验和 offload 冲突(VM)** | 🟡 中 | 禁用网卡 checksum offload | Xen ML |
|
||||
| **DontFragment 标志干扰** | 🟡 中 | 无已知 fix | [#278](https://github.com/basil00/WinDivert/issues/278) |
|
||||
| **高负载下队列断开** | 🟡 中 | 保护性队列大小限制 | 文档 |
|
||||
| **包重排序** | 🟡 中 | 无(WinDivert 本身会引入) | 文档 |
|
||||
| **PID 过期/无效** | 🟡 中 | TTL 缓存 | SO |
|
||||
|
||||
#### 业界工具的实际 UDP 支持情况
|
||||
|
||||
| 工具 | UDP 支持 | 实际策略 | 可靠性 |
|
||||
|------|----------|----------|--------|
|
||||
| **ProxyBridge** | 声称支持 | 双 handle + GetExtendedUdpTable 轮询 | ⚠️ DNS 单独处理,警告"大多数 SOCKS5 代理不支持 UDP" |
|
||||
| **Netch ProcessMode** | 声称支持 | netfilter2 商业驱动(内核级 udpCreated/udpSend 回调) | ✅ 高(商业驱动,socket 级拦截) |
|
||||
| **SocksCap64** | 声称支持 | Hook sendto/recvfrom | ⚠️ 中(用户态 hook,覆盖不全) |
|
||||
| **Clash Verge/Mihomo** | 部分支持 | TUN 模式(系统级,不走 WinDivert) | ✅ 高(TUN 模式绕过 WinDivert) |
|
||||
| **Tallow (Transparent Tor)** | ❌ TCP only | 明确只支持 TCP | N/A |
|
||||
|
||||
#### 对 GOST 集成的具体影响
|
||||
|
||||
如果 GOST 采用 WinDivert 实现 Windows 透明代理,UDP 的可靠性边界如下:
|
||||
|
||||
**可以可靠支持的场景:**
|
||||
- ✅ TCP 所有流量(有连接 + 生命周期清晰 + 映射表可靠)
|
||||
- ✅ DNS UDP(端口 53,单独处理,不改写目标,仅劫持响应)
|
||||
- ⚠️ "典型" UDP 应用(使用 connected UDP socket 的应用,如 QUIC/HTTP3)
|
||||
|
||||
**不可靠/不支持的场景:**
|
||||
- ⚠️ 无连接 UDP (sendto 到多目标,映射表爆炸)
|
||||
- ⚠️ 高频 UDP(游戏、VoIP,包重排序和延迟不可预测)
|
||||
- ⚠️ 广播/多播 UDP(DHCP、mDNS、SSDP)
|
||||
- ❌ 依赖精确 `processId` 的 UDP 过滤(5-tuple 映射可能永远无法建立)
|
||||
|
||||
**实用建议:** GOST 的 WinDivert handler 应该默认只处理 TCP,UDP 支持作为**实验性功能**(opt-in flag),并明确文档化限制。
|
||||
|
||||
#### 与 Linux usertun 方案的对比
|
||||
|
||||
| | Linux usertun (user ns) | Windows WinDivert |
|
||||
|---|---|---|
|
||||
| TCP | ✅ 内核协议栈 (gVisor) | ✅ 可接受 |
|
||||
| UDP | ✅ 内核协议栈 (gVisor) | ⚠️ 有限制 |
|
||||
| 原始目标 | ✅ IP 头保留 | ❌ 需映射(TCP 还算可靠,UDP 不可靠) |
|
||||
| 进程隔离 | ✅ 完全(独立 netns) | ⚠️ 基于 5-tuple 推断 |
|
||||
| 实现复杂度 | TUN+gVisor (已有) | 自定义 handler (~500 行) |
|
||||
|
||||
这也又一次说明:**Linux usertun 方案在架构上远优于 Windows 上的任何方案**(包括 WinDivert)。
|
||||
|
||||
---
|
||||
|
||||
### Windows 额外发现:GOST 现有能力
|
||||
|
||||
GOST 在 Windows 上已有以下可用的透明代理能力(均需管理员):
|
||||
|
||||
1. **`gost -L tun://`** — TUN 模式,通过 Wintun 创建虚拟网卡,系统级透明代理。**无需新代码,已可用。**
|
||||
2. **`gost -L tap://`** — TAP 模式,通过 OpenVPN TAP 驱动。同上。
|
||||
3. **`gost -L tun://` + `gost -L tungo://`** — TUN + gVisor netstack,更完整的协议栈支持。
|
||||
|
||||
限制:这些是**系统级**透明代理,不是 Linux user namespace 的**单进程**透明代理。
|
||||
|
||||
---
|
||||
|
||||
### Windows 结论
|
||||
|
||||
**可行性:有条件可行。** 在 Windows 上不存在 Linux user namespace 的非特权等价物。实现"透明代理单个程序"需要内核级拦截(WinDivert 或 WFP callout),均需要一次性管理员权限安装驱动。
|
||||
|
||||
**推荐路径:**
|
||||
1. **如果需要管理员权限可接受:** 实现 `windivert` handler(新代码 ~500-800 行),基于 `godivert` + 已有的 proxy chain。**TCP 支持可靠,UDP 应作为实验性 opt-in 功能。**
|
||||
2. **如果要求零管理员权限:** 不可行。退而求其次的方案是环境变量注入(`HTTP_PROXY`)+ AppContainer 网络限制,仅覆盖尊重代理环境变量的应用。
|
||||
3. **如果只需系统级透明代理:** GOST 现有 `tun://` listener 在 Windows 上已可用(需管理员运行)。TUN 模式走 gVisor netstack,**TCP/UDP 均可靠**——缺点是系统级而非单进程。
|
||||
|
||||
**建议:** Windows 的 WinDivert 方案可以作为 Linux usertun 方案的后续补充(Phase 2),但不作为 #652 的核心交付。Issue #652 应以 Linux user namespace 方案为主。
|
||||
@@ -0,0 +1,206 @@
|
||||
# Fix go-gost #754 — SIGHUP reload `bind: address already in use`
|
||||
|
||||
## Context
|
||||
|
||||
On SIGHUP hot-reload, every service that keeps its address logs
|
||||
`init: listen tcp :PORT: bind: address already in use` and the reload fails
|
||||
(issue go-gost/gost#754). Root cause: in `x/config/loader/loader.go`,
|
||||
`register()` parses+binds **all** new services in a loop (each
|
||||
`service_parser.ParseService(c)` binds its port at `ln.Init()`) **before**
|
||||
`registerGroup` closes the old services. So new listeners bind while old
|
||||
listeners are still open → `EADDRINUSE`. This is a regression from commit
|
||||
`82e7e50`, which reversed the order (previously close-old-first, then parse)
|
||||
to preserve old services on a *parse* error.
|
||||
|
||||
Only **services** bind ports; chains/hops/limiters/etc. don't, so the bug is
|
||||
services-only.
|
||||
|
||||
**Goal (Fix #1):** separate listener **construction** from **binding** so the
|
||||
reload can: (1) construct+validate all new services **without** binding
|
||||
(any error here leaves old services running — preserves `82e7e50`'s property),
|
||||
(2) close all old services (free ports), (3) bind+create+register the new ones.
|
||||
|
||||
## Design
|
||||
|
||||
Split `ParseService` into `PrepareService` (construct, no bind) + a
|
||||
`(*PreparedService).Build()` method (bind + `NewService`). Keep `ParseService`
|
||||
as a thin wrapper so its two other callers (`x/api/config_service.go`
|
||||
`createService`/`updateService`) are unaffected — both already avoid
|
||||
self-collision.
|
||||
|
||||
### Critical correctness detail — netns
|
||||
|
||||
The tcp listener binds with a **plain** `net.ListenConfig{}` (no self-switch,
|
||||
`listener/tcp/listener.go:54,59`). For `metadata.netns` services, the bind
|
||||
currently relies on the caller having switched namespaces first via the
|
||||
`netns.Set` block at `parse.go:206-231`. Since that block wraps **both**
|
||||
construction (line 235) **and** the bind (line 244) today, moving `ln.Init()`
|
||||
into `Build()` would otherwise bind in the wrong (origin) namespace.
|
||||
|
||||
Resolution: **move the netns switch into `Build()`**, wrapping `ln.Init()`.
|
||||
Construction (`rf(listenOpts...)`) only stores options — it is namespace-
|
||||
independent, so `PrepareService` drops the netns block. Listeners that
|
||||
self-switch (`xnet.ListenConfig`, `internal/net/net.go:51`) are unaffected
|
||||
(a redundant same-namespace re-switch is harmless). Capture `netnsIn` on the
|
||||
`PreparedService`.
|
||||
|
||||
## File-by-file changes
|
||||
|
||||
### 1. `x/config/parsing/service/parse.go`
|
||||
|
||||
- Add exported type:
|
||||
```go
|
||||
// PreparedService is a service fully constructed (listener+handler built,
|
||||
// options assembled, handler.Init'd) but whose listener is NOT yet bound.
|
||||
// Build binds the listener and returns a runnable service.Service.
|
||||
type PreparedService struct {
|
||||
name string
|
||||
ln listener.Listener
|
||||
listenerMD metadata.Metadata
|
||||
h handler.Handler
|
||||
netns string
|
||||
log logger.Logger
|
||||
options []xservice.Option
|
||||
}
|
||||
```
|
||||
- Add `func PrepareService(cfg *config.ServiceConfig) (*PreparedService, error)`:
|
||||
the current `ParseService` body with these changes:
|
||||
- **Remove** the netns block (current lines 206-231) — no longer needed at
|
||||
construction.
|
||||
- Construct listener (factory lookup + `rf(listenOpts...)`) as today.
|
||||
- Capture `listenerMD := metadata.NewMetadata(cfg.Listener.Metadata)` instead
|
||||
of calling `ln.Init`.
|
||||
- **Reorder** handler construction (current lines 249-348: handler TLS/auth/
|
||||
recorders/routerOpts/handler factory/forwarder/`h.Init()`) to run **before**
|
||||
the bind. Safe: none of it depends on a bound listener. This also fixes a
|
||||
latent leak where a handler error after `ln.Init` orphans a bound socket.
|
||||
- Assemble the `[]xservice.Option` slice (current lines 350-362) here while
|
||||
locals are in scope.
|
||||
- Return `&PreparedService{...}` (store `netnsIn`).
|
||||
- Add `func (p *PreparedService) Build() (service.Service, error)`:
|
||||
- If `p.netns != ""`: `runtime.LockOSThread()` + `netns.Get`/`GetFromPath`|
|
||||
`GetFromName`/`Set` with `defer netns.Set(originNs)` (mirror old 206-231).
|
||||
- `p.ln.Init(p.listenerMD)`; on error log `"init: "`, then
|
||||
`if c, ok := p.h.(io.Closer); ok { c.Close() }` (avoid orphaned handler
|
||||
goroutines), return `nil, err`.
|
||||
- `s := xservice.NewService(p.name, p.ln, p.h, p.options...)`; return `s`.
|
||||
- Refactor `ParseService` to:
|
||||
```go
|
||||
func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
ps, err := PrepareService(cfg)
|
||||
if err != nil { return nil, err }
|
||||
if ps == nil { return nil, nil }
|
||||
return ps.Build()
|
||||
}
|
||||
```
|
||||
- Add `"io"` import (used in `Build` error path). `netns`, `runtime`,
|
||||
`strings`, `metadata`, `xservice` already imported.
|
||||
|
||||
### 2. `x/config/loader/loader.go`
|
||||
|
||||
Replace the services block in `register()` (lines 304-318) with a three-phase
|
||||
sequence (services are now special-cased away from the generic
|
||||
`registerGroup` full-swap):
|
||||
|
||||
```go
|
||||
// --- services (special: only group whose Init binds a port) ---
|
||||
// Phase 1: construct+validate all new services WITHOUT binding; on any
|
||||
// error return early (old services untouched).
|
||||
// Phase 2: close all old services (free ports).
|
||||
// Phase 3: bind+create+register each prepared service.
|
||||
{
|
||||
type preparedEntry struct {
|
||||
name string
|
||||
ps *service_parser.PreparedService
|
||||
}
|
||||
var prepared []preparedEntry
|
||||
for _, c := range cfg.Services {
|
||||
ps, err := service_parser.PrepareService(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ps != nil {
|
||||
prepared = append(prepared, preparedEntry{c.Name, ps})
|
||||
}
|
||||
}
|
||||
|
||||
registerGroup[service.Service](nil, registry.ServiceRegistry()) // Phase 2: close-all
|
||||
|
||||
for _, e := range prepared { // Phase 3
|
||||
svc, err := e.ps.Build()
|
||||
if err != nil {
|
||||
logger.Default().Errorf("service %s: %v", e.name, err) // log+continue
|
||||
continue
|
||||
}
|
||||
if err := registry.ServiceRegistry().Register(e.name, svc); err != nil {
|
||||
svc.Close()
|
||||
logger.Default().Errorf("service %s: %v", e.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Phase 3 partial-failure policy is **log+continue** (aborting would leave all
|
||||
not-yet-built services down with old already closed). `registerGroup(nil, r)`
|
||||
is the existing "clear the group" idiom (covered by
|
||||
`TestRegisterGroup_EmptyEntries_ClearsExisting`). No new imports needed.
|
||||
|
||||
### 3. `x/config/loader/loader_test.go`
|
||||
|
||||
Add a port-binding stub + reload test that reproduces EADDRINUSE pre-fix:
|
||||
- `bindingStubListener` (distinct from inert `stubListener`): `Init` does
|
||||
`net.Listen("tcp", addr)`, stores the `net.Listener`; `Addr`/`Close` delegate.
|
||||
- `freeTCPPort(t)` helper: `net.Listen("tcp","127.0.0.1:0")`, read port, close.
|
||||
- Register the binding factory under a test-only name (e.g. `"tcp-binding-test"`)
|
||||
and the existing inert `"auto"` handler stub, with save/restore `t.Cleanup`.
|
||||
- `TestRegister_ServiceReloadNoCollision`: build a config with one service on a
|
||||
fixed free port; call `register()` twice; assert the **second** call returns
|
||||
no error and the service stays registered. Before the fix the second call
|
||||
returns `EADDRINUSE`. Assert via `strings.Contains(err.Error(), "address
|
||||
already in use")` on failure.
|
||||
- Add `"fmt"`, `"strings"` imports.
|
||||
|
||||
## Reused / referenced (no edits)
|
||||
|
||||
- `x/registry/registry.go:83` `Unregister` → calls `Close()` (frees ports in
|
||||
Phase 2). `registerGroup` (loader.go:101) used as close-all idiom.
|
||||
- `x/service/service.go:144` `NewService` (inert re: sockets; runs pre-up,
|
||||
sets state) and `:344` `Close()` (closes listener/handler/observer).
|
||||
- `internal/net/net.go:51` `xnet.ListenConfig` self-switches netns (context
|
||||
for why moving the netns block to `Build` is safe).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /config/workspace/go-gost/x
|
||||
go build ./... # refactor compiles
|
||||
go vet ./...
|
||||
go test ./config/loader/... # existing + new test
|
||||
CGO_ENABLED=1 go test -race ./config/loader/... # race-clean (per CLAUDE.md)
|
||||
cd /config/workspace/go-gost && go build ./... # gost binary still builds
|
||||
```
|
||||
The new test must pass after the change and would fail (`EADDRINUSE` on the
|
||||
second `register()`) if the construct/bind split were reverted.
|
||||
|
||||
Optional manual end-to-end: run gost with one TCP service on a fixed port,
|
||||
`kill -HUP <pid>`, confirm no `address already in use` in the log and the
|
||||
service keeps serving.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **netns**: resolved by moving the netns switch into `Build` (see Design).
|
||||
During implementation, grep to confirm no listener binds in its *constructor*
|
||||
(factories should only store options); if one does, it would need separate
|
||||
handling. The 4 checked (tcp/udp/tls/ws) bind in `Init`.
|
||||
- **Bind failure = one service down**: after close-all, a single bind failure
|
||||
takes only that service down (log+continue); unavoidable given the fix's
|
||||
premise. A joined-error return is a possible follow-up.
|
||||
- **Pre-existing, orthogonal**: `x/api/config_reload.go` spawns fresh `Serve`
|
||||
goroutines for every registered service on each reload — not caused or
|
||||
changed by this fix; flagged so it isn't mistaken for a regression.
|
||||
|
||||
## Post-implementation (out of plan scope)
|
||||
|
||||
After the code lands + builds + tests pass: optionally post the drafted
|
||||
explanatory comment to issue #754 and/or open a PR (English comment; show to
|
||||
user before posting per global GitHub-comments rule).
|
||||
@@ -0,0 +1,203 @@
|
||||
# Plan: QUIC/HTTP3 SNI sniffer (issue go-gost/gost#761, Scope A)
|
||||
|
||||
## Context
|
||||
|
||||
Issue go-gost/gost#761 requests a QUIC/HTTP3 protocol sniffer, referencing the v2fly and Xray
|
||||
implementations. GOST already sniffs **TCP** traffic (TLS/HTTP/SSH) via
|
||||
`x/internal/util/sniffing/` to extract the SNI/Host for **bypass decisions and traffic recording**.
|
||||
QUIC (HTTP/3) runs over **UDP** and is encrypted from the first packet, so it cannot flow through
|
||||
the existing TCP `Sniff(*bufio.Reader)` path — there is currently **no UDP sniffing anywhere** in
|
||||
the codebase (`grep udp` in `sniffing/`+`forwarder/` → nothing).
|
||||
|
||||
This plan adds **Scope A only**: sniff the QUIC Initial packet to extract the SNI (server_name) for
|
||||
SNI-based bypass and recording, then forward datagrams transparently. **No MITM/termination** of
|
||||
QUIC (that is Scope B — a far larger effort, intentionally excluded). The goal matches exactly what
|
||||
Xray/v2fly do and what GOST's existing TLS sniffer achieves for TCP.
|
||||
|
||||
Outcome: a transparent UDP redirect (`redu`) service can apply bypass rules and record QUIC flows by
|
||||
hostname, closing the HTTP/3 gap.
|
||||
|
||||
## Approach
|
||||
|
||||
The QUIC Initial parser is extracted into a **separate repo `quic-dissector/`** (parallel to
|
||||
[`tls-dissector/`](../../../tls-dissector/)), following the same pattern: zero/minimal dependencies,
|
||||
clean I/O boundary (bytes in → parsed info out), independently versioned, reusable by projects
|
||||
outside GOST. The `x/` module consumes it as a standard Go dependency, just like it does
|
||||
`tls-dissector`.
|
||||
|
||||
Three layers, lowest-risk-first.
|
||||
|
||||
### Phase 1 — `quic-dissector/` repo (new, parallel to `tls-dissector/`)
|
||||
|
||||
New repo `github.com/go-gost/quic-dissector` at workspace root `quic-dissector/`. Structure:
|
||||
|
||||
```
|
||||
quic-dissector/
|
||||
├── go.mod module github.com/go-gost/quic-dissector
|
||||
│ deps: tls-dissector + golang.org/x/crypto
|
||||
├── dissector.go SniffQUIC — public API
|
||||
├── dissector_test.go golden-packet tests + fuzz
|
||||
├── internal/quic/
|
||||
│ ├── varint.go QUIC varint (~5 lines, avoids quic-go dep)
|
||||
│ ├── hkdf.go HKDF-Expand-Label per RFC 8446 §7.1 (~30 lines)
|
||||
│ └── initial.go Header protection removal + AEAD decrypt + CRYPTO reassembly
|
||||
├── CLAUDE.md
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
└── .github/workflows/ci.yml
|
||||
```
|
||||
|
||||
**Why separate repo** (same rationale as `tls-dissector`):
|
||||
- Clean boundary: `[]byte` in, `*dissector.ClientHelloInfo` out, no I/O, no state
|
||||
- Reusable: any Go project doing QUIC inspection needs this (Xray, v2fly, network monitors)
|
||||
- Independently versionable: QUIC v2 (RFC 9369) has different salt + inverted type bits
|
||||
- Nearly zero-dependency: only `x/crypto` (ubiquitous) + `tls-dissector` (same org, zero-dep)
|
||||
- Precedent: `tls-dissector/` already established this pattern in the go-gost ecosystem
|
||||
|
||||
**Public API** (`dissector.go`):
|
||||
|
||||
```go
|
||||
package quicdissector
|
||||
|
||||
import "github.com/go-gost/tls-dissector"
|
||||
|
||||
// ErrNotQUIC is returned when the datagram is not a parsable QUIC Initial packet.
|
||||
var ErrNotQUIC = errors.New("not a QUIC Initial packet")
|
||||
|
||||
// SniffQUIC decrypts a QUIC Initial datagram and parses the embedded TLS ClientHello.
|
||||
// Returns the ClientHello info (ServerName=SNI, SupportedProtos=ALPN),
|
||||
// or ErrNotQUIC when the datagram cannot be parsed.
|
||||
// Only the first datagram is parsed; cross-datagram ClientHello reassembly is out of scope.
|
||||
func SniffQUIC(datagram []byte) (*dissector.ClientHelloInfo, error)
|
||||
```
|
||||
|
||||
Returning `*dissector.ClientHelloInfo` directly (rather than separate `sni, alpn` strings)
|
||||
gives callers access to all ClientHello fields (SupportedVersions, CipherSuites, etc.) at
|
||||
zero extra cost. The `x/` handler extracts `.ServerName` and `.SupportedProtos[0]` for
|
||||
bypass/recording — same fields the TCP TLS sniffer uses today.
|
||||
|
||||
Algorithm (port of Xray `common/protocol/quic/sniff.go`, confirmed against quic-go internals):
|
||||
|
||||
1. **Long-Header validation** — bit 7 set (`0x80`), fixed bit set (`0x40`); read 4-byte version;
|
||||
accept `0x00000001` (QUIC v1) and `0xff00001d` (draft-29). Reject short headers (1-RTT) — not
|
||||
sniffable.
|
||||
2. **Packet type** — `(typeByte & 0x30) >> 4 == 0x0` = Initial; skip non-Initial long-header packets.
|
||||
3. **DCID** (8–20 bytes) — the key material. Add an explicit `dcidLen` range guard Xray omits.
|
||||
4. **Token** (varint-length, Initial-only) — skip.
|
||||
5. **Packet length + header boundary** via QUIC varint (self-contained, ~5 lines — avoids pulling in
|
||||
`github.com/quic-go/quic-go/quicvarint`).
|
||||
6. **Derive Initial keys** (HMAC-SHA256 HKDF, `golang.org/x/crypto/hkdf` + stdlib `crypto/aes`,
|
||||
`crypto/cipher`):
|
||||
- `initialSecret = HKDF-Extract(salt=QUIC_SALT, IKM=DCID)` — v1 salt
|
||||
`38762cf7f55934b34d179ae6a4c80cadccbb7f0a` (RFC 9001 §5.2).
|
||||
- `clientSecret = HKDF-Expand-Label(initialSecret, "client in", 32)`.
|
||||
- `hpKey = HKDF-Expand-Label(clientSecret, "quic hp", 16)`,
|
||||
`key = …("quic key", 16)`, `iv = …("quic iv", 12)`.
|
||||
- `HKDF-Expand-Label` per RFC 8446 §7.1 (`"tls13 "+label`, 2B length + 1B label-len + 1B ctx-len).
|
||||
Self-contained implementation in `internal/quic/hkdf.go`.
|
||||
7. **Remove header protection** — AES-ECB single-block of the 16-byte sample at offset 4 into the
|
||||
protected region; XOR low nibble of type byte (→ packet-number length) + up to 4 PN bytes.
|
||||
8. **AEAD decrypt** — AES-128-GCM (`key`/`iv`), nonce = `iv` XOR truncated packet number, AAD = the
|
||||
full unmasked header. QUIC Initial protection is always AES-128-GCM regardless of negotiated cipher.
|
||||
9. **CRYPTO frame reassembly** — within the decrypted payload, iterate frames (PADDING/PING/ACK/
|
||||
CRYPTO/CONNECTION_CLOSE); assemble `CRYPTO` (type `0x06`) data by offset into a buffer. Handle
|
||||
coalesced packets within the single datagram (Xray's outer loop).
|
||||
10. **ClientHello SNI** — prepend a synthetic TLS record header (`0x16,0x03,0x03,<len>`) to the
|
||||
reassembled handshake bytes and call `dissector.ParseClientHello` (reuse — same parser the TCP
|
||||
TLS sniffer uses at `sniffer_tls.go:34`), returning the full `*ClientHelloInfo`.
|
||||
|
||||
Reuses: `dissector.ParseClientHello`. New deps: `golang.org/x/crypto` (hkdf). **No quic-go
|
||||
dependency** — varint and HKDF-Expand-Label are self-contained.
|
||||
|
||||
**Limitation (documented):** parses only the ClientHello within the **first datagram** (handles
|
||||
coalescing inside it). Cross-datagram ClientHello reassembly needs connection-state tracking and is
|
||||
out of scope — note in a code comment. This covers the overwhelming majority of real traffic.
|
||||
|
||||
### Phase 2 — redirect/udp (`redu`) handler integration
|
||||
|
||||
`x/handler/redirect/udp/` is the clean mirror of `redirect/tcp` (both use `conn.LocalAddr()` as the
|
||||
TPROXY destination and `xnet.Pipe`). The per-client `net.Conn` from `internal/net/udp/listener.go`
|
||||
already buffers the first datagram by the time `Handle()` runs (the stateful listener Accepts on the
|
||||
first datagram), so peeking will not block.
|
||||
|
||||
- **`metadata.go`** — add `sniffing bool` and `sniffingTimeout time.Duration`. Keys
|
||||
`mdutil.GetBool(md, "sniffing")`, `mdutil.GetDuration(md, "sniffing.timeout")` (mirror
|
||||
`redirect/tcp/metadata.go`). No `mitm.*` fields — Scope A does no MITM.
|
||||
- **`handler.go` `Handle()`** — after `dstAddr := conn.LocalAddr()`, if `h.md.sniffing`:
|
||||
1. `conn.SetReadDeadline(now + sniffingTimeout)` (default from `sniffing.DefaultReadTimeout`).
|
||||
2. Read the first datagram into a buffer (one `Read` returns exactly the buffered datagram).
|
||||
3. `info, err := quicdissector.SniffQUIC(buf)`. Clear the read deadline.
|
||||
4. On success: set `ro.Host = net.JoinHostPort(info.ServerName, port)`, `ro.TLS = &xrecorder.TLSRecorderObject{ServerName: info.ServerName, Proto: info.SupportedProtos[0]}` (ALPN, may be empty). Apply SNI-based bypass via `h.options.Bypass.Contains(ctx, "udp", ro.Host, ...)` → `xbypass.ErrBypass` (mirrors `sniffer_tls.go:59`).
|
||||
5. Dial upstream to `dstAddr` (transparent — destination stays the original IP, SNI is for bypass/recording only), then forward the buffered first datagram + remaining via `xnet.Pipe`, prepending the buffered bytes with `xnet.NewReadWriteConn(io.MultiReader(bytes.NewReader(buf), conn), conn, conn)` (exact pattern from `redirect/tcp/handler.go:207`).
|
||||
6. On `quicdissector.ErrNotQUIC` / decode failure: fall through to the existing transparent-forward path, prepending the already-read datagram the same way so no data is lost.
|
||||
|
||||
`forward/local` UDP sniffing is a **secondary, optional** extension (its sniffing is currently gated
|
||||
`network == "tcp"`). Defer unless requested — `redirect/udp` is the primary, cleanest target.
|
||||
|
||||
### Phase 3 — protocol constant + integration
|
||||
|
||||
- **`sniff.go`** — add `ProtoQUIC = "quic"` constant for recording consistency (the TCP `Sniff()`
|
||||
function itself is unchanged — it is TCP-only by design; QUIC has its own datagram entry point).
|
||||
- **`handler/redirect/udp`** — build is the verification gate (no tests in this module per
|
||||
`x/CLAUDE.md`); a `play/` YAML for manual end-to-end verification (below).
|
||||
- **`quic-dissector/` tests** — golden-packet table tests (`SniffQUIC` against captured QUIC Initial
|
||||
datagrams as hex literals with known SNI), plus negative cases (short header, non-Initial, truncated,
|
||||
bogus version). Fuzz test (`FuzzSniffQUIC`) for panic/DoS resistance. Mirror the fixture style in
|
||||
`tls-dissector/dissector_test.go`. Capture golden packets at implementation time via a one-off
|
||||
quic-go client or `openssl` to a test endpoint; commit as hex.
|
||||
|
||||
## Files to add / modify
|
||||
|
||||
### New repo: `quic-dissector/`
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `quic-dissector/go.mod` | **NEW** — module `github.com/go-gost/quic-dissector`, deps: `tls-dissector` + `golang.org/x/crypto` |
|
||||
| `quic-dissector/dissector.go` | **NEW** — `SniffQUIC` public API |
|
||||
| `quic-dissector/dissector_test.go` | **NEW** — golden-packet unit tests + fuzz |
|
||||
| `quic-dissector/internal/quic/varint.go` | **NEW** — QUIC varint (~5 lines) |
|
||||
| `quic-dissector/internal/quic/hkdf.go` | **NEW** — HKDF-Expand-Label per RFC 8446 §7.1 (~30 lines) |
|
||||
| `quic-dissector/internal/quic/initial.go` | **NEW** — header protection removal + AEAD decrypt + CRYPTO reassembly |
|
||||
| `quic-dissector/CLAUDE.md` | **NEW** — build/test instructions |
|
||||
| `quic-dissector/.github/workflows/ci.yml` | **NEW** — CI (vet + test + fuzz loop, mirror tls-dissector) |
|
||||
|
||||
### Existing repo: `x/`
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `x/go.mod` | add `github.com/go-gost/quic-dissector v0.1.0` |
|
||||
| `x/internal/util/sniffing/sniff.go` | add `ProtoQUIC` constant |
|
||||
| `x/handler/redirect/udp/metadata.go` | add `sniffing`, `sniffingTimeout` fields + parse keys |
|
||||
| `x/handler/redirect/udp/handler.go` | sniff-first-datagram branch (bypass + recording + prepend) |
|
||||
| `play/quic-sniff.yaml` (or similar) | manual e2e config (TPROXY UDP → QUIC upstream w/ bypass rule) |
|
||||
|
||||
Reused, existing code (do not duplicate): `dissector.ParseClientHello` (`tls-dissector/dissector.go`),
|
||||
`sniffing.DefaultReadTimeout`, `xnet.NewReadWriteConn`/`Pipe`,
|
||||
`xrecorder.TLSRecorderObject`, `xbypass.ErrBypass`, `mdutil.GetBool/GetDuration`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **`quic-dissector/` build + test** (own CI, mirror `tls-dissector`):
|
||||
`cd quic-dissector && go build ./... && go vet ./... && go test -v -cover ./...`
|
||||
Golden packets must round-trip the expected SNI; negative cases must return `ErrNotQUIC` without
|
||||
false positives. Fuzz test runs 30s in CI.
|
||||
2. **`x/` build + vet** (per `x/CLAUDE.md`, the module's verification path):
|
||||
`cd x && go build ./... && go vet ./...`
|
||||
Confirms the `quic-dissector` import compiles and the handler integration is correct.
|
||||
3. **Manual e2e** (transparent UDP redirect): run the `play/` config with `sniffing: true` and a
|
||||
bypass rule on a known QUIC hostname; `curl --http3 https://<host>` through the redirect and
|
||||
confirm (a) the bypass rule fires for the SNI, (b) non-bypassed QUIC still reaches the upstream,
|
||||
(c) recorder logs show `ro.TLS.ServerName` populated.
|
||||
|
||||
## Notes / scope boundaries
|
||||
|
||||
- **Scope A only** — sniff SNI + transparent forward. No QUIC MITM/termination (Scope B).
|
||||
- **QUIC v1 + draft-29** initially (matches proven Xray code). QUIC **v2** (`0x6b3343cf`, different
|
||||
salt + inverted type bits, RFC 9369) is a small follow-up enhancement — noted in a code comment.
|
||||
- **Single-datagram** ClientHello parsing; cross-datagram reassembly out of scope (documented).
|
||||
- **Separate repo** (`quic-dissector/`) — follows the `tls-dissector/` pattern. The parser is
|
||||
self-contained, independently versioned, and reusable by projects outside GOST. The `x/` module
|
||||
consumes it as a standard Go dependency.
|
||||
- **No quic-go dependency** — varint and HKDF-Expand-Label are self-contained implementations
|
||||
(~35 lines total). Only external dep is `golang.org/x/crypto` (hkdf).
|
||||
- Comments in **English only** (project convention). Doc comments on all exported symbols.
|
||||
@@ -0,0 +1,129 @@
|
||||
# go-gost/gost#765 — IPv6 TPROXY UDP fails through SOCKS5 with `relay=udp`
|
||||
|
||||
**Status**: Open, `NeedsInvestigation`
|
||||
**Reported**: 2025-08-19
|
||||
**Reporter**: @przemyslaw0
|
||||
|
||||
## Summary
|
||||
|
||||
When GOST uses TPROXY (`redu` listener) to intercept IPv6 UDP traffic and forwards it through a SOCKS5 proxy chain with `relay=udp`, responses never come back — `outputBytes: 0` for all sessions, DNS queries time out.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```
|
||||
gost -L "redu://:1111?netns=ns-a" -F "socks5://127.0.0.1:1080?relay=udp"
|
||||
```
|
||||
|
||||
TPROXY-intercepted IPv6 DNS packets (`[2606:4700:4700::1001]:53`) forwarded through 3proxy SOCKS5 server. DNS queries time out with no response data.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The primary bug is in `relayUDP()` at `x/connector/socks/v5/connector.go:216`. When `relay=udp` is used (standard SOCKS5 UDP ASSOCIATE per RFC 1928), GOST does not handle the server's relay bind address correctly.
|
||||
|
||||
### Bug: Wildcard relay address not substituted
|
||||
|
||||
When 3proxy returns `0.0.0.0:0` as the relay endpoint (visible in 3proxy logs: `UDPMAP 0.0.0.0:0`), GOST dials directly to this literal address:
|
||||
|
||||
```go
|
||||
// connector.go:216
|
||||
cc, err := opts.Dialer.Dial(ctx, "udp", reply.Addr.String())
|
||||
```
|
||||
|
||||
This creates a UDP socket "connected" to `0.0.0.0:0`, which is not a usable destination. Per RFC 1928 practice, when the SOCKS5 server returns a wildcard address (`0.0.0.0` or `[::]`), the client should substitute it with the TCP connection's remote address (the SOCKS5 server's actual address).
|
||||
|
||||
**Compare with GOST's own SOCKS5 server** in `x/handler/socks/v5/udp.go:63`, which correctly substitutes the wildcard:
|
||||
|
||||
```go
|
||||
saddr.Host, _, _ = net.SplitHostPort(conn.LocalAddr().String())
|
||||
```
|
||||
|
||||
The client-side `relayUDP()` does NOT perform this substitution — it uses `reply.Addr` verbatim.
|
||||
|
||||
### Additional concern: earlier IPv6 address corruption
|
||||
|
||||
In an earlier test (2024-07-11, [3proxy/3proxy#560](https://github.com/3proxy/3proxy/issues/560#issuecomment-2224053601)), the user noted "The IPv6 address received by 3proxy from GOST is received incomplete." The address `2606:4700:4700::1001` arrived as `4700::1001:0:0:53` — a 2-byte left shift of the raw bytes, suggesting possible SOCKS5 UDP header encoding/parsing issues.
|
||||
|
||||
In the later test (2025-08-19, the go-gost issue), the address in the 3proxy log appears correct (`2606:4700:4700::1001:53`), though it's ambiguous whether the `:53` is the port or part of the address. This may have been fixed in a GOST version update, or the corruption was a 3proxy log formatting issue.
|
||||
|
||||
## Evidence from Logs
|
||||
|
||||
**GOST log** (session lifecycle):
|
||||
- Receives TPROXY UDP from `[fd00::2]:59871` → `[2606:4700:4700::1001]:53`
|
||||
- `src: 127.0.0.1:42587` — the outbound relay socket's local address (IPv4, expected)
|
||||
- `inputBytes: 40, outputBytes: 0` — sent 40-byte DNS query, received 0 bytes back
|
||||
- `duration: 30s` — timed out
|
||||
|
||||
**3proxy log**:
|
||||
- `127.0.0.1:53888 2606:4700:4700::1001:53 40 0 0 UDPMAP 0.0.0.0:0`
|
||||
- Received 40 bytes from client, returned 0 bytes to client
|
||||
- Relay bound to `0.0.0.0:0` (wildcard)
|
||||
|
||||
## Data Flow Trace
|
||||
|
||||
```
|
||||
dig @2606:4700:4700::1001
|
||||
│
|
||||
▼ (TPROXY intercepts IPv6 UDP)
|
||||
redu listener (::1:1111)
|
||||
│ conn.LocalAddr() = [2606:4700:4700::1001]:53
|
||||
│ conn.RemoteAddr() = [fd00::2]:59871
|
||||
▼
|
||||
handler.redirect/udp → Router.Dial(ctx, "udp", "[2606:4700:4700::1001]:53")
|
||||
│
|
||||
▼ chain: socks5://127.0.0.1:1080?relay=udp
|
||||
TCP connect to 127.0.0.1:1080
|
||||
│ SOCKS5 handshake
|
||||
▼
|
||||
socks5Connector.relayUDP()
|
||||
│ sends CmdUdp (0x03) with nil address
|
||||
│ receives reply: bind = 0.0.0.0:0 ← WILDCARD
|
||||
▼
|
||||
opts.Dialer.Dial(ctx, "udp", "0.0.0.0:0") ← BUG: dials to wildcard
|
||||
│ creates IPv4 UDP socket connected to 0.0.0.0:0
|
||||
▼
|
||||
udpRelayConn.WriteTo(data, [2606:4700:4700::1001]:53)
|
||||
│ wraps in SOCKS5 UDP header (correct IPv6 ATYP=4)
|
||||
│ sends via c.udpConn.Write() → to 0.0.0.0:0 ← goes nowhere useful
|
||||
▼
|
||||
outputBytes: 0 — no response received
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `x/connector/socks/v5/connector.go` | `relayUDP()` — the buggy method (line 195-234) |
|
||||
| `x/connector/socks/v5/conn.go` | `udpRelayConn` — SOCKS5 UDP encapsulation (ReadFrom/WriteTo) |
|
||||
| `x/handler/socks/v5/udp.go` | GOST's own SOCKS5 server `handleUDP()` — has correct wildcard substitution |
|
||||
| `gosocks5/socks5.go` | SOCKS5 protocol: `Addr`, `UDPHeader`, `UDPDatagram` encoding |
|
||||
| `x/handler/redirect/udp/handler.go` | Redu handler — TPROXY UDP forwarding |
|
||||
| `x/listener/redirect/udp/listener_linux.go` | Redu listener — Linux TPROXY socket handling |
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
In `relayUDP()` at `x/connector/socks/v5/connector.go`, after receiving the SOCKS5 UDP ASSOCIATE reply (line 213), substitute wildcard addresses:
|
||||
|
||||
```go
|
||||
// After reply is read and verified
|
||||
bindAddr := reply.Addr
|
||||
|
||||
// Per RFC 1928: if server returns 0.0.0.0 or [::], substitute
|
||||
// with the TCP connection's remote address (the SOCKS5 server).
|
||||
if bindAddr.Host == "0.0.0.0" || bindAddr.Host == "::" {
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
bindAddr.Host = host
|
||||
}
|
||||
|
||||
log.Debugf("bind on: %v", bindAddr)
|
||||
cc, err := opts.Dialer.Dial(ctx, "udp", bindAddr.String())
|
||||
```
|
||||
|
||||
## Workaround
|
||||
|
||||
Use GOST's proprietary UDP tunnel mode (default, without `relay=udp`), which tunnels SOCKS5-encapsulated UDP datagrams over the existing TCP connection. This avoids the separate UDP relay socket entirely:
|
||||
|
||||
```
|
||||
gost -L "redu://:1111?netns=ns-a" -F "socks5://127.0.0.1:1080"
|
||||
```
|
||||
|
||||
**Caveat**: This uses GOST's proprietary `CmdUDPTun` (0xF3), which 3proxy does NOT support. For 3proxy specifically, the `relay=udp` fix is required.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user