mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993419d845 | ||
|
|
8d253dcd88 | ||
|
|
49c998ee45 | ||
|
|
0954e57dcb | ||
|
|
1d0b6de411 | ||
|
|
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 | ||
|
|
32ebe6550c | ||
|
|
5810404829 | ||
|
|
a2e2810669 | ||
|
|
ca767cc102 | ||
|
|
2baf376c87 | ||
|
|
19be0882db | ||
|
|
1e6c0851e6 | ||
|
|
0ca1e7621a | ||
|
|
4bc3886825 | ||
|
|
52b80b257f | ||
|
|
7b5a92817f | ||
|
|
a7041074a8 | ||
|
|
d024c7786e | ||
|
|
d8317d876f | ||
|
|
69c397ded9 | ||
|
|
7f36d10605 | ||
|
|
5b1ff8330b | ||
|
|
3193eebd6c | ||
|
|
c71b16490b | ||
|
|
ae73d1ba61 | ||
|
|
4c086685b1 | ||
|
|
56c72823cb | ||
|
|
2a9e059bc2 | ||
|
|
f63f36805b | ||
|
|
1b07475bf3 | ||
|
|
3b25e0317f | ||
|
|
fb0cf72446 | ||
|
|
ee7b1e9462 | ||
|
|
143438a30e | ||
|
|
cd97e5e746 | ||
|
|
c1d954d689 | ||
|
|
d4e9450bad | ||
|
|
b777bcb81a | ||
|
|
39580f04ca | ||
|
|
b01f5d065f | ||
|
|
8fd5200bf1 | ||
|
|
ec4791485c | ||
|
|
c06ba17916 | ||
|
|
a32ffd5608 | ||
|
|
ffa7ceaed7 | ||
|
|
ea3ca94e53 | ||
|
|
5d6b00c3a6 | ||
|
|
0637486025 | ||
|
|
99a0f7323d | ||
|
|
4057ac54b8 | ||
|
|
6a0ce8914e | ||
|
|
03f4b1291e | ||
|
|
2e16457467 | ||
|
|
a1d7da4fec | ||
|
|
0522ae8155 | ||
|
|
55a246869e | ||
|
|
3ec3362ac4 | ||
|
|
3f01456480 | ||
|
|
9ea10763e4 | ||
|
|
e814852ca1 | ||
|
|
a102f9c6cf | ||
|
|
9ccf402ed1 | ||
|
|
61eb3e2a3b | ||
|
|
92002fa35e | ||
|
|
a424148018 | ||
|
|
d4d823df45 | ||
|
|
c45d27113e | ||
|
|
9fab332772 |
+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/
|
||||
@@ -0,0 +1,27 @@
|
||||
# SDD ledger — plan: x/docs/superpowers/plans/2026-10-07-tun-device-probe.md
|
||||
|
||||
Ruling: sdd-workspace script absent in this skill install (only task-start/task-done exist) — workspace created manually at x/.superpowers/sdd/2026-10-07-tun-device-probe/. Cost if wrong: none, git-ignored scratch.
|
||||
|
||||
Pre-flight shared-interface rows:
|
||||
- T1→T2: md.probe/probeReport + runDeviceProbe signature. Plan pins report func(sentDelta, ackedDelta uint64); runDeviceProbe assumes non-nil (nil guard lives in probeDevice). No conflict.
|
||||
- T1+T2→T3: h.md.probe/probeReport, runDeviceProbe, handleClient iterCtx/conn/ips. handleClient shape to verify in code recon before wiring.
|
||||
- T3→T4: only files + green suite; no interface.
|
||||
|
||||
Task 1: complete (no commit per plan Global Constraints; tests: go test -count=1 ./handler/tun/ ./observer/stats/ → both ok, tun 14.356s).
|
||||
Ruling: metadata constructor alias is xmd (github.com/go-gost/x/metadata), not mdx as plan Step 1 guessed — plan told me to grep first, did. Cost if wrong: nil.
|
||||
Task 2: complete (no commit per plan Global Constraints; tests: go test -count=1 ./handler/tun/ → ok 14.987s).
|
||||
Ruling: probeInterval/probeRecvTimeout are vars not consts (plan pinned values 30s/5s kept; tests override). Pinned signature runDeviceProbe(ctx,dev,ip,report,log) unchanged. Cost if wrong: nil.
|
||||
Ruling: wrote runDeviceProbe impl before its loop test, caught it, deleted impl, re-did RED (undefined) → GREEN. Cost if wrong: unproven test.
|
||||
Ruling: loop tests use atomic counters + polling (net.Pipe Read returns before writer Write/report; plain vars would race). Plan sketch used plain vars. Cost if wrong: flaky test.
|
||||
Task 3: complete (no commit per plan Global Constraints; tests: go test -count=1 ./handler/tun/ ./observer/stats/ → both ok).
|
||||
Note: gofmt -l flags observer/stats/wrapper/io_test.go — pre-existing, untouched (not in git status). My files clean; go vet clean.
|
||||
Ruling: probeDevice takes no log param (uses h.options.Logger, same as keepalive); conn at the wire site IS the tun device (transportClient(ctx, tun=conn, ...) proves it). Cost if wrong: probe writes into the tunnel stream.
|
||||
Task 4: green to the release gate (build ./... + both suites PASS). STOPPING before tag+push: publish side effect, maintainer-manual per plan + repo no-push norm. Awaiting human: commit + tag v0.23.0.
|
||||
Final: fresh review via subagent. C1 (UDP length 16→must be 24, acked could never fire on real device) — confirmed real, re-graded Critical stands.
|
||||
Final: fixed C1 — TestBuildProbePacket UDP-length assertion RED→GREEN, suite handler/tun ok.
|
||||
Final: fixed I1 (Reset forgot probe counters) — TestStatsProbeReset RED→GREEN, suite observer/stats ok.
|
||||
Final: minor (deferred): M1 redial overlap — old probe writes ≤5s alongside new after iterCtx cancel; datagram-atomic, self-healing.
|
||||
Final: minor (deferred): M2 log assumed non-nil in probe paths.
|
||||
Final: minor (deferred): M3 gate goroutine spawn on probeReport != nil to avoid one throwaway goroutine per redial on gost path.
|
||||
Final: reviewer declined go toolchain — overruled, tests ran under /root/.local/go/bin (see memory dev-container-toolchain); kernel trim-vs-drop of len-16 packet no longer matters post-fix; water-driver atomicity stands on datagram semantics + keepalive precedent.
|
||||
Pre-release workspace check (GOWORK=/root/opencode/probework/go.work, x+wisper+gost): go build ./... all three OK; wisper ./tunnel/... PASS (14.6s/15.3s); x handler/tun + observer/stats PASS.
|
||||
@@ -163,4 +163,10 @@ func Register(r *gin.Engine, opts *Options) {
|
||||
config.POST("/rlimiters", createRateLimiter)
|
||||
config.PUT("/rlimiters/:limiter", updateRateLimiter)
|
||||
config.DELETE("/rlimiters/:limiter", deleteRateLimiter)
|
||||
|
||||
config.GET("/caches", getCacheList)
|
||||
config.GET("/caches/:cache", getCache)
|
||||
config.POST("/caches", createCache)
|
||||
config.PUT("/caches/:cache", updateCache)
|
||||
config.DELETE("/caches/:cache", deleteCache)
|
||||
}
|
||||
|
||||
+29
-11
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
@@ -127,10 +129,11 @@ func getConfig(ctx *gin.Context) {
|
||||
|
||||
// swagger:parameters saveConfigRequest
|
||||
type saveConfigRequest struct {
|
||||
// output format, one of yaml|json, default is yaml.
|
||||
// output format, one of yaml|json, default is derived from the file extension.
|
||||
// in: query
|
||||
Format string `form:"format" json:"format"`
|
||||
// file path, default is gost.yaml|gost.json in current working directory.
|
||||
// file path, default is the loaded config file, or gost.yaml|gost.json in
|
||||
// the current working directory when no config file was loaded.
|
||||
// in: query
|
||||
Path string `form:"path" json:"path"`
|
||||
}
|
||||
@@ -155,16 +158,31 @@ func saveConfig(ctx *gin.Context) {
|
||||
var req saveConfigRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
file := "gost.yaml"
|
||||
switch req.Format {
|
||||
case "json":
|
||||
file = "gost.json"
|
||||
default:
|
||||
req.Format = "yaml"
|
||||
file := req.Path
|
||||
if file == "" {
|
||||
// Default to the config file that was loaded (via -C), falling back to
|
||||
// the current working directory when the config came from elsewhere.
|
||||
file = config.ConfigFile()
|
||||
}
|
||||
|
||||
if req.Path != "" {
|
||||
file = req.Path
|
||||
if file == "" {
|
||||
file = "gost.yaml"
|
||||
if req.Format == "json" {
|
||||
file = "gost.json"
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(req.Format) {
|
||||
case "json":
|
||||
req.Format = "json"
|
||||
case "yaml":
|
||||
req.Format = "yaml"
|
||||
default:
|
||||
// Derive the format from the file extension so a loaded JSON config
|
||||
// is saved back as JSON (ReadFile forces format from extension).
|
||||
if strings.ToLower(filepath.Ext(file)) == ".json" {
|
||||
req.Format = "json"
|
||||
} else {
|
||||
req.Format = "yaml"
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(file)
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-gost/x/config"
|
||||
parser "github.com/go-gost/x/config/parsing/cache"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// swagger:parameters getCacheListRequest
|
||||
type getCacheListRequest struct{}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getCacheListResponse
|
||||
type getCacheListResponse struct {
|
||||
// in: body
|
||||
Data cacheList
|
||||
}
|
||||
|
||||
type cacheList struct {
|
||||
Count int `json:"count"`
|
||||
List []*config.CacheConfig `json:"list"`
|
||||
}
|
||||
|
||||
func getCacheList(ctx *gin.Context) {
|
||||
// swagger:route GET /config/caches Cache getCacheListRequest
|
||||
//
|
||||
// Get cache list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getCacheListResponse
|
||||
|
||||
var req getCacheListRequest
|
||||
ctx.ShouldBindQuery(&req)
|
||||
|
||||
list := config.Global().Caches
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: cacheList{
|
||||
Count: len(list),
|
||||
List: list,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters getCacheRequest
|
||||
type getCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response getCacheResponse
|
||||
type getCacheResponse struct {
|
||||
// in: body
|
||||
Data *config.CacheConfig
|
||||
}
|
||||
|
||||
func getCache(ctx *gin.Context) {
|
||||
// swagger:route GET /config/caches/{cache} Cache getCacheRequest
|
||||
//
|
||||
// Get cache.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: getCacheResponse
|
||||
|
||||
var req getCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
var resp getCacheResponse
|
||||
for _, c := range config.Global().Caches {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
if c.Name == req.Cache {
|
||||
resp.Data = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Data: resp.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters createCacheRequest
|
||||
type createCacheRequest struct {
|
||||
// in: body
|
||||
Data config.CacheConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response createCacheResponse
|
||||
type createCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func createCache(ctx *gin.Context) {
|
||||
// swagger:route POST /config/caches Cache createCacheRequest
|
||||
//
|
||||
// Create a new cache, the name of the cache must be unique in cache list.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: createCacheResponse
|
||||
|
||||
var req createCacheRequest
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Data.Name)
|
||||
if name == "" {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache name is required"))
|
||||
return
|
||||
}
|
||||
req.Data.Name = name
|
||||
|
||||
if registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
v := parser.ParseCache(&req.Data)
|
||||
if v == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := registry.CacheRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
c.Caches = append(c.Caches, &req.Data)
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters updateCacheRequest
|
||||
type updateCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
// in: body
|
||||
Data config.CacheConfig `json:"data"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response updateCacheResponse
|
||||
type updateCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func updateCache(ctx *gin.Context) {
|
||||
// swagger:route PUT /config/caches/{cache} Cache updateCacheRequest
|
||||
//
|
||||
// Update cache by name, the cache must already exist.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: updateCacheResponse
|
||||
|
||||
var req updateCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
ctx.ShouldBindJSON(&req.Data)
|
||||
|
||||
name := strings.TrimSpace(req.Cache)
|
||||
|
||||
if !registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
|
||||
return
|
||||
}
|
||||
|
||||
req.Data.Name = name
|
||||
|
||||
v := parser.ParseCache(&req.Data)
|
||||
if v == nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "cache has no backend configured"))
|
||||
return
|
||||
}
|
||||
|
||||
registry.CacheRegistry().Unregister(name)
|
||||
|
||||
if err := registry.CacheRegistry().Register(name, v); err != nil {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("cache %s already exists", name)))
|
||||
return
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
for i := range c.Caches {
|
||||
if c.Caches[i].Name == name {
|
||||
c.Caches[i] = &req.Data
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// swagger:parameters deleteCacheRequest
|
||||
type deleteCacheRequest struct {
|
||||
// in: path
|
||||
// required: true
|
||||
Cache string `uri:"cache" json:"cache"`
|
||||
}
|
||||
|
||||
// successful operation.
|
||||
// swagger:response deleteCacheResponse
|
||||
type deleteCacheResponse struct {
|
||||
Data Response
|
||||
}
|
||||
|
||||
func deleteCache(ctx *gin.Context) {
|
||||
// swagger:route DELETE /config/caches/{cache} Cache deleteCacheRequest
|
||||
//
|
||||
// Delete cache by name.
|
||||
//
|
||||
// Security:
|
||||
// basicAuth: []
|
||||
//
|
||||
// Responses:
|
||||
// 200: deleteCacheResponse
|
||||
|
||||
var req deleteCacheRequest
|
||||
ctx.ShouldBindUri(&req)
|
||||
|
||||
name := strings.TrimSpace(req.Cache)
|
||||
|
||||
if !registry.CacheRegistry().IsRegistered(name) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("cache %s not found", name)))
|
||||
return
|
||||
}
|
||||
registry.CacheRegistry().Unregister(name)
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
caches := c.Caches
|
||||
c.Caches = nil
|
||||
for _, s := range caches {
|
||||
if s.Name == name {
|
||||
continue
|
||||
}
|
||||
c.Caches = append(c.Caches, s)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
}
|
||||
+633
-437
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
@@ -1003,3 +1005,96 @@ type staticAuther struct {
|
||||
func (s *staticAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
|
||||
return s.id, s.ok
|
||||
}
|
||||
|
||||
// --- whitelist tests ---
|
||||
|
||||
func TestWhitelistedAuthenticator_IPMatch(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "" || !ok {
|
||||
t.Fatalf("expected (\"\", true) for matched IP, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_IPNoMatch(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "test" || !ok {
|
||||
t.Fatalf("expected (\"test\", true) for non-matched IP, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_CIDRMatch(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.1.2.3"), Port: 9999})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "" || !ok {
|
||||
t.Fatalf("expected (\"\", true) for matched CIDR, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_CIDRNoMatch(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"10.0.0.0/8"})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "test" || !ok {
|
||||
t.Fatalf("expected (\"test\", true) for non-matched CIDR, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_EmptyPatterns(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "test" || !ok {
|
||||
t.Fatalf("expected (\"test\", true) for empty patterns, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_NoSrcAddr(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"192.168.1.100"})
|
||||
id, ok := w.Authenticate(context.Background(), "user", "pass")
|
||||
if id != "test" || !ok {
|
||||
t.Fatalf("expected (\"test\", true) with no src addr in context, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_NilAuther(t *testing.T) {
|
||||
// When IP matches, the whitelist returns OK regardless of nil auther.
|
||||
w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "" || !ok {
|
||||
t.Fatalf("expected (\"\", true) for matched whitelist even with nil auther, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_NilAutherNoMatch(t *testing.T) {
|
||||
// When IP does not match and auther is nil, it falls through to ("", false).
|
||||
w := WhitelistedAuthenticator(nil, []string{"192.168.1.100"})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 54321})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "" || ok {
|
||||
t.Fatalf("expected (\"\", false) for nil auther with non-matching IP, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_UnderlyingDenies(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: false}, []string{"10.0.0.0/8"})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 8080})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "test" || ok {
|
||||
t.Fatalf("expected (\"test\", false) when underlying auther denies, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistedAuthenticator_InvalidPatterns(t *testing.T) {
|
||||
w := WhitelistedAuthenticator(&staticAuther{id: "test", ok: true}, []string{"not-an-ip", "also-not-cidr/", ""})
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 54321})
|
||||
id, ok := w.Authenticate(ctx, "user", "pass")
|
||||
if id != "test" || !ok {
|
||||
t.Fatalf("expected (\"test\", true) for all-invalid patterns, got (%q, %v)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -60,13 +60,18 @@ func (p *grpcPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
if v := xctx.SrcAddrFromContext(ctx); v != nil {
|
||||
clientAddr = v.String()
|
||||
}
|
||||
r, err := p.client.Authenticate(ctx,
|
||||
&proto.AuthenticateRequest{
|
||||
Service: options.Service,
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: clientAddr,
|
||||
})
|
||||
req := &proto.AuthenticateRequest{
|
||||
Service: options.Service,
|
||||
Username: user,
|
||||
Password: password,
|
||||
Client: clientAddr,
|
||||
}
|
||||
if v := xctx.PeerCertFromContext(ctx); v != nil {
|
||||
req.ClientCn = v.CN
|
||||
req.ClientSan = v.SANs
|
||||
req.ClientCertFingerprint = v.Fingerprint
|
||||
}
|
||||
r, err := p.client.Authenticate(ctx, req)
|
||||
if err != nil {
|
||||
p.log.Error(err)
|
||||
return "", false
|
||||
|
||||
+12
-4
@@ -14,10 +14,13 @@ import (
|
||||
)
|
||||
|
||||
type httpPluginRequest struct {
|
||||
Service string `json:"service"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Client string `json:"client"`
|
||||
Service string `json:"service"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Client string `json:"client"`
|
||||
ClientCn string `json:"clientCn,omitempty"`
|
||||
ClientSan []string `json:"clientSan,omitempty"`
|
||||
ClientCertFingerprint string `json:"clientCertFingerprint,omitempty"`
|
||||
}
|
||||
|
||||
type httpPluginResponse struct {
|
||||
@@ -71,6 +74,11 @@ func (p *httpPlugin) Authenticate(ctx context.Context, user, password string, op
|
||||
Password: password,
|
||||
Client: clientAddr,
|
||||
}
|
||||
if v := xctx.PeerCertFromContext(ctx); v != nil {
|
||||
rb.ClientCn = v.CN
|
||||
rb.ClientSan = v.SANs
|
||||
rb.ClientCertFingerprint = v.Fingerprint
|
||||
}
|
||||
v, err := json.Marshal(&rb)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/auth"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/matcher"
|
||||
)
|
||||
|
||||
// whitelistedAuthenticator wraps an Authenticator to skip authentication
|
||||
// for client IPs matching the configured whitelist patterns.
|
||||
type whitelistedAuthenticator struct {
|
||||
auther auth.Authenticator
|
||||
ipMatcher matcher.Matcher
|
||||
cidrMatcher matcher.Matcher
|
||||
}
|
||||
|
||||
// WhitelistedAuthenticator returns an Authenticator that skips the underlying
|
||||
// auther when the client's IP address matches one of the given patterns.
|
||||
// Patterns may be IP addresses (e.g. "192.168.1.1") or CIDR ranges
|
||||
// (e.g. "10.0.0.0/8"). Invalid patterns are silently ignored.
|
||||
func WhitelistedAuthenticator(auther auth.Authenticator, patterns []string) auth.Authenticator {
|
||||
var ips []net.IP
|
||||
var inets []*net.IPNet
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(pattern); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue
|
||||
}
|
||||
if _, inet, err := net.ParseCIDR(pattern); err == nil {
|
||||
inets = append(inets, inet)
|
||||
}
|
||||
}
|
||||
return &whitelistedAuthenticator{
|
||||
auther: auther,
|
||||
ipMatcher: matcher.IPMatcher(ips),
|
||||
cidrMatcher: matcher.CIDRMatcher(inets),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *whitelistedAuthenticator) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
|
||||
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
||||
host, _, err := net.SplitHostPort(srcAddr.String())
|
||||
if err == nil && host != "" {
|
||||
if w.ipMatcher.Match(host) || w.cidrMatcher.Match(host) {
|
||||
return "", true
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.auther != nil {
|
||||
return w.auther.Authenticate(ctx, user, password, opts...)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
+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.
|
||||
|
||||
+49
-21
@@ -397,15 +397,24 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
listener = schemes[1]
|
||||
}
|
||||
|
||||
// For path-based protocols (Unix socket listeners and serial), use path as address
|
||||
// Decide path-based from the scheme before registry remaps listener to tcp.
|
||||
isPathBasedProtocol := listener == "unix" || listener == "runix" || listener == "serial"
|
||||
for _, s := range schemes {
|
||||
if s == "unix" || s == "runix" || s == "serial" {
|
||||
isPathBasedProtocol = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// A path-based protocol with an empty host (three-slash URL like
|
||||
// unix:///var/run/sock) uses the path as the absolute listen address and
|
||||
// has no forward target. With a non-empty host (two-slash URL like
|
||||
// unix://sock/other.sock), the host is the listen address and any path is a
|
||||
// forward target (port-forward to another socket), handled below.
|
||||
isPathAddr := isPathBasedProtocol && url.Host == ""
|
||||
|
||||
var addrs []string
|
||||
if isPathBasedProtocol {
|
||||
path := url.EscapedPath()
|
||||
if url.Host != "" {
|
||||
addrs = append(addrs, url.Host+strings.TrimPrefix(path, "/"))
|
||||
} else if path != "" {
|
||||
if isPathAddr {
|
||||
if path := url.EscapedPath(); path != "" {
|
||||
addrs = append(addrs, path)
|
||||
} else {
|
||||
addrs = append(addrs, url.Host)
|
||||
@@ -436,8 +445,10 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
|
||||
var nodes []*config.ForwardNodeConfig
|
||||
// forward mode
|
||||
// For path-based protocols, path is address not forward target
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathBasedProtocol {
|
||||
// A path-based protocol with an empty host (e.g. unix:///abs.sock) treats
|
||||
// the path as the listen address, not a forward target. With a host
|
||||
// (e.g. unix://a.sock/b.sock) the path is a forward target.
|
||||
if remotes := strings.Trim(url.EscapedPath(), "/"); remotes != "" && !isPathAddr {
|
||||
i := 0
|
||||
for _, addr := range strings.Split(remotes, ",") {
|
||||
addrs := xnet.AddrPortRange(addr).Addrs()
|
||||
@@ -466,6 +477,17 @@ func buildServiceConfig(url *url.URL) ([]*config.ServiceConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// stdio listener uses url.Host as the forwarding target.
|
||||
if listener == "stdio" && len(nodes) == 0 && url.Host != "" {
|
||||
nodes = append(nodes, &config.ForwardNodeConfig{
|
||||
Name: fmt.Sprintf("%starget-0", namePrefix),
|
||||
Addr: url.Host,
|
||||
})
|
||||
if handler == "auto" {
|
||||
handler = "tcp"
|
||||
}
|
||||
}
|
||||
|
||||
if len(nodes) > 0 {
|
||||
if len(services) == 1 {
|
||||
services[0].Forwarder = &config.ForwarderConfig{
|
||||
@@ -629,6 +651,18 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
|
||||
dialer = schemes[1]
|
||||
}
|
||||
|
||||
// Path-based address logic must use the requested scheme, not the
|
||||
// registry fallback (unix/serial often remap to http/tcp when only
|
||||
// the CLI registry is partially populated, or in unit tests).
|
||||
isPathBasedProtocol := dialer == "unix" || dialer == "serial" ||
|
||||
connector == "unix" || connector == "serial"
|
||||
for _, s := range schemes {
|
||||
if s == "unix" || s == "serial" || s == "runix" {
|
||||
isPathBasedProtocol = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
md := mdx.NewMetadata(m)
|
||||
|
||||
if c := registry.ConnectorRegistry().Get(connector); c == nil {
|
||||
@@ -714,20 +748,14 @@ func buildNodeConfig(url *url.URL, m map[string]any) (*config.NodeConfig, error)
|
||||
nodeMd[k] = v
|
||||
}
|
||||
|
||||
// For path-based protocols (Unix socket dialers and serial), use path as address
|
||||
// For path-based protocols (Unix socket dialers and serial), use path as address.
|
||||
// Three-slash URLs (empty host) give the absolute socket path; two-slash URLs
|
||||
// use the host as the socket. The path on a host form is not part of the node
|
||||
// address (it is a forward target for services, ignored for nodes).
|
||||
var nodeAddr string
|
||||
isPathBasedProtocol := dialer == "unix" || dialer == "serial"
|
||||
if isPathBasedProtocol {
|
||||
path := url.EscapedPath()
|
||||
if url.Host != "" {
|
||||
// Two slashes: host + path = relative path
|
||||
nodeAddr = url.Host + strings.TrimPrefix(path, "/")
|
||||
} else if path != "" {
|
||||
// Three slashes: path is absolute (keep leading slash)
|
||||
nodeAddr = path
|
||||
} else {
|
||||
nodeAddr = url.Host
|
||||
}
|
||||
if isPathBasedProtocol && url.Host == "" {
|
||||
// Three slashes: path is absolute (keep leading slash)
|
||||
nodeAddr = url.EscapedPath()
|
||||
} else {
|
||||
nodeAddr = url.Host
|
||||
}
|
||||
|
||||
+62
-6
@@ -1765,10 +1765,10 @@ func TestBuildNodeConfig_TLSWithCert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
|
||||
func TestBuildNodeConfig_UnixAbsolutePath(t *testing.T) {
|
||||
// http+unix scheme: connector=http, dialer=unix.
|
||||
// Since unix is not in the test registry, dialer falls back to tcp,
|
||||
// and the path-based address logic (dialer=="unix") is skipped.
|
||||
// Dialer falls back to tcp when unix is not in the test registry, but
|
||||
// the absolute socket path from a three-slash URL must still be kept.
|
||||
rawURL := "http+unix:///var/run/socket"
|
||||
u, _ := url.Parse(rawURL)
|
||||
|
||||
@@ -1780,12 +1780,68 @@ func TestBuildNodeConfig_UnixFallsBackToTCP(t *testing.T) {
|
||||
if node.Dialer.Type != "tcp" {
|
||||
t.Errorf("Dialer.Type = %q, want tcp (unix not in test registry)", node.Dialer.Type)
|
||||
}
|
||||
// Addr is url.Host ("") since no host component in three-slash unix URL
|
||||
if node.Addr != "" {
|
||||
t.Errorf("Addr = %q, want empty", node.Addr)
|
||||
if node.Addr != "/var/run/socket" {
|
||||
t.Errorf("Addr = %q, want /var/run/socket", node.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNodeConfig_UnixRelativeHost(t *testing.T) {
|
||||
u, _ := url.Parse("unix://docker.sock")
|
||||
node, err := buildNodeConfig(u, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildNodeConfig: %v", err)
|
||||
}
|
||||
if node.Addr != "docker.sock" {
|
||||
t.Errorf("Addr = %q, want docker.sock", node.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServiceConfig_UnixAbsolutePath(t *testing.T) {
|
||||
// three-slash: path is the absolute socket listen address, no forward target
|
||||
svcs, err := buildServiceConfig(mustURL("unix:///var/run/gost.sock"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildServiceConfig: %v", err)
|
||||
}
|
||||
if len(svcs) != 1 {
|
||||
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
|
||||
}
|
||||
if svcs[0].Addr != "/var/run/gost.sock" {
|
||||
t.Errorf("Addr = %q, want /var/run/gost.sock", svcs[0].Addr)
|
||||
}
|
||||
if svcs[0].Forwarder != nil {
|
||||
t.Errorf("Forwarder = %+v, want nil (no forward target)", svcs[0].Forwarder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServiceConfig_UnixPortForward(t *testing.T) {
|
||||
// two-slash: host is the listen socket, path is a forward target socket
|
||||
svcs, err := buildServiceConfig(mustURL("unix://gost.sock/gost2.sock"))
|
||||
if err != nil {
|
||||
t.Fatalf("buildServiceConfig: %v", err)
|
||||
}
|
||||
if len(svcs) != 1 {
|
||||
t.Fatalf("len(svcs) = %d, want 1", len(svcs))
|
||||
}
|
||||
if svcs[0].Addr != "gost.sock" {
|
||||
t.Errorf("Addr = %q, want gost.sock", svcs[0].Addr)
|
||||
}
|
||||
if svcs[0].Forwarder == nil || len(svcs[0].Forwarder.Nodes) == 0 {
|
||||
t.Fatalf("Forwarder = %+v, want a forward target node", svcs[0].Forwarder)
|
||||
}
|
||||
if got := svcs[0].Forwarder.Nodes[0].Addr; got != "gost2.sock" {
|
||||
t.Errorf("forward Addr = %q, want gost2.sock", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustURL(raw string) *url.URL {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
|
||||
func TestBuildServiceConfig_RelayTLSForward(t *testing.T) {
|
||||
// relay+tls scheme: handler=relay, listener=tls.
|
||||
// relay not in test registry → handler falls back to auto, listener falls back to tcp.
|
||||
|
||||
+170
-11
@@ -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"`
|
||||
@@ -316,6 +341,38 @@ type RecorderObject struct {
|
||||
Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RewriterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"`
|
||||
}
|
||||
|
||||
// CacheConfig defines a named cache store. Memory and Redis backends are
|
||||
// supported; other backends (file, plugin) are follow-ups.
|
||||
type CacheConfig struct {
|
||||
Name string `json:"name"`
|
||||
Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"`
|
||||
Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"`
|
||||
}
|
||||
|
||||
// RedisCache configures the Redis cache backend.
|
||||
type RedisCache struct {
|
||||
Addr string `json:"addr"`
|
||||
DB int `yaml:",omitempty" json:"db,omitempty"`
|
||||
Username string `yaml:",omitempty" json:"username,omitempty"`
|
||||
Password string `yaml:",omitempty" json:"password,omitempty"`
|
||||
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
|
||||
Key string `yaml:",omitempty" json:"key,omitempty"` // key prefix, default "gost:cache:"
|
||||
}
|
||||
|
||||
// MemoryCache configures the in-memory cache backend.
|
||||
type MemoryCache struct {
|
||||
TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"`
|
||||
MaxSize int `yaml:"maxSize,omitempty" json:"maxSize,omitempty"`
|
||||
MaxBytes int64 `yaml:"maxBytes,omitempty" json:"maxBytes,omitempty"`
|
||||
CleanupInterval time.Duration `yaml:"cleanupInterval,omitempty" json:"cleanupInterval,omitempty"`
|
||||
Eviction string `yaml:",omitempty" json:"eviction,omitempty"` // "oldest" | "lru"
|
||||
}
|
||||
|
||||
type LimiterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Limits []string `yaml:",omitempty" json:"limits,omitempty"`
|
||||
@@ -360,9 +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 {
|
||||
@@ -384,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"`
|
||||
}
|
||||
|
||||
@@ -397,6 +467,20 @@ type HTTPBodyRewriteConfig struct {
|
||||
Type string
|
||||
Match string
|
||||
Replacement string
|
||||
// name of the rewriter plugin (via registry)
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
MaxChunkSize int `yaml:"maxChunkSize,omitempty" json:"maxChunkSize,omitempty"`
|
||||
}
|
||||
|
||||
type 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 {
|
||||
@@ -405,14 +489,44 @@ type NodeFilterConfig struct {
|
||||
Path string `yaml:",omitempty" json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMatcherConfig defines a routing-rule matcher for a hop node.
|
||||
//
|
||||
// Priority controls election among multiple matching nodes:
|
||||
// - 0 (default): auto-computed from the rule string length — longer rules
|
||||
// (more specific) get higher priority.
|
||||
// - negative: the node participates in matching but priority short-circuit
|
||||
// is disabled; the selector (round-robin, random, hash, etc.) always applies.
|
||||
// - positive: explicit priority; when a single node has strictly higher
|
||||
// priority than all others, it wins directly, bypassing the selector.
|
||||
type NodeMatcherConfig struct {
|
||||
Rule string `yaml:",omitempty" json:"rule,omitempty"`
|
||||
Priority int `yaml:",omitempty" json:"priority,omitempty"`
|
||||
// BodySize is the max request body prefix (bytes) exposed to BodyRegexp
|
||||
// and BodyJSON matchers. 0 (default) uses DefaultMatcherBodySize (1MB).
|
||||
// Capped at MaxMatcherBodySize (10MB). Only takes effect under an HTTP
|
||||
// sniffing handler whose sniffer reads the body prefix before node selection.
|
||||
BodySize int `yaml:",omitempty" json:"bodySize,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeConfig holds the configuration for a node-level liveness probe.
|
||||
type ProbeConfig struct {
|
||||
Type string `json:"type"`
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
HTTPPath string `yaml:"httpPath,omitempty" json:"httpPath,omitempty"`
|
||||
HTTPHost string `yaml:"httpHost,omitempty" json:"httpHost,omitempty"`
|
||||
HTTPHeaders map[string]string `yaml:"httpHeaders,omitempty" json:"httpHeaders,omitempty"`
|
||||
ExpectedStatus int `yaml:"expectedStatus,omitempty" json:"expectedStatus,omitempty"`
|
||||
Command string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPNodeConfig struct {
|
||||
// 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
|
||||
@@ -430,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"`
|
||||
}
|
||||
@@ -478,7 +600,9 @@ type ServiceConfig struct {
|
||||
Logger string `yaml:",omitempty" json:"logger,omitempty"`
|
||||
Loggers []string `yaml:",omitempty" json:"loggers,omitempty"`
|
||||
Observer string `yaml:",omitempty" json:"observer,omitempty"`
|
||||
Rewriter string `yaml:",omitempty" json:"rewriter,omitempty"`
|
||||
Recorders []*RecorderObject `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Cache string `yaml:",omitempty" json:"cache,omitempty"`
|
||||
Handler *HandlerConfig `yaml:",omitempty" json:"handler,omitempty"`
|
||||
Listener *ListenerConfig `yaml:",omitempty" json:"listener,omitempty"`
|
||||
Forwarder *ForwarderConfig `yaml:",omitempty" json:"forwarder,omitempty"`
|
||||
@@ -566,9 +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 {
|
||||
@@ -612,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"`
|
||||
}
|
||||
|
||||
@@ -619,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"`
|
||||
@@ -628,6 +784,9 @@ type Config struct {
|
||||
Routers []*RouterConfig `yaml:",omitempty" json:"routers,omitempty"`
|
||||
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
|
||||
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
|
||||
Rewriters []*RewriterConfig `yaml:",omitempty" json:"rewriters,omitempty"`
|
||||
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"`
|
||||
@@ -646,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 {
|
||||
@@ -655,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 {
|
||||
@@ -664,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 {
|
||||
@@ -677,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)
|
||||
}
|
||||
|
||||
+59
-1
@@ -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,6 +25,7 @@ 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/rewriter"
|
||||
"github.com/go-gost/core/router"
|
||||
"github.com/go-gost/core/sd"
|
||||
"github.com/go-gost/x/config"
|
||||
@@ -28,6 +33,7 @@ import (
|
||||
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"
|
||||
@@ -35,13 +41,16 @@ 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"
|
||||
rewriter_parser "github.com/go-gost/x/config/parsing/rewriter"
|
||||
router_parser "github.com/go-gost/x/config/parsing/router"
|
||||
sd_parser "github.com/go-gost/x/config/parsing/sd"
|
||||
service_parser "github.com/go-gost/x/config/parsing/service"
|
||||
quota "github.com/go-gost/x/limiter/quota"
|
||||
xp2p "github.com/go-gost/x/p2p"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
@@ -244,6 +253,36 @@ func register(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[rewriter.Rewriter]
|
||||
for _, c := range cfg.Rewriters {
|
||||
entries = append(entries, named[rewriter.Rewriter]{c.Name, rewriter_parser.ParseRewriter(c)})
|
||||
}
|
||||
if err := registerGroup(entries, registry.RewriterRegistry()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var entries []named[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 {
|
||||
@@ -284,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{
|
||||
|
||||
@@ -3,7 +3,6 @@ package logger
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
@@ -25,8 +24,34 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
|
||||
xlogger.LevelOption(logger.LogLevel(cfg.Log.Level)),
|
||||
}
|
||||
|
||||
opts = append(opts, xlogger.OutputOption(outputWriter(cfg.Log)))
|
||||
|
||||
return xlogger.NewLogger(opts...)
|
||||
}
|
||||
|
||||
// defaultLogRotation is what a file log gets when its config leaves the
|
||||
// rotation out. It matches what an unconfigured logger has always carried, so
|
||||
// a log: section that names an output but no rotation is not a way to ask for
|
||||
// an unbounded file.
|
||||
//
|
||||
// The reason this is a default rather than a warning: an unrotated debug log
|
||||
// is not untidy, it stops the device. A spoke's log section with no rotation:
|
||||
// key grew to 111MB with no backups, on a phone.
|
||||
func defaultLogRotation() *config.LogRotationConfig {
|
||||
return &config.LogRotationConfig{
|
||||
MaxSize: 10,
|
||||
MaxAge: 7,
|
||||
MaxBackups: 10,
|
||||
LocalTime: true,
|
||||
}
|
||||
}
|
||||
|
||||
// outputWriter resolves the LogConfig's output to the writer that receives the
|
||||
// lines. Split out of ParseLogger so the rotation decision — the part that
|
||||
// decides whether a file log stops growing — is reachable without a logger.
|
||||
func outputWriter(cfg *config.LogConfig) io.Writer {
|
||||
var out io.Writer = os.Stderr
|
||||
switch cfg.Log.Output {
|
||||
switch cfg.Output {
|
||||
case "none", "null":
|
||||
out = io.Discard
|
||||
case "stdout":
|
||||
@@ -34,28 +59,19 @@ func ParseLogger(cfg *config.LoggerConfig) logger.Logger {
|
||||
case "stderr", "":
|
||||
out = os.Stderr
|
||||
default:
|
||||
if cfg.Log.Rotation != nil {
|
||||
out = &lumberjack.Logger{
|
||||
Filename: cfg.Log.Output,
|
||||
MaxSize: cfg.Log.Rotation.MaxSize,
|
||||
MaxAge: cfg.Log.Rotation.MaxAge,
|
||||
MaxBackups: cfg.Log.Rotation.MaxBackups,
|
||||
LocalTime: cfg.Log.Rotation.LocalTime,
|
||||
Compress: cfg.Log.Rotation.Compress,
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
} else {
|
||||
out = f
|
||||
}
|
||||
if cfg.Rotation == nil {
|
||||
cfg.Rotation = defaultLogRotation()
|
||||
}
|
||||
out = &lumberjack.Logger{
|
||||
Filename: cfg.Output,
|
||||
MaxSize: cfg.Rotation.MaxSize,
|
||||
MaxAge: cfg.Rotation.MaxAge,
|
||||
MaxBackups: cfg.Rotation.MaxBackups,
|
||||
LocalTime: cfg.Rotation.LocalTime,
|
||||
Compress: cfg.Rotation.Compress,
|
||||
}
|
||||
}
|
||||
opts = append(opts, xlogger.OutputOption(out))
|
||||
|
||||
return xlogger.NewLogger(opts...)
|
||||
return out
|
||||
}
|
||||
|
||||
// List resolves one or more logger names from the registry. It returns only
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// 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 +152,113 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLoggerUnrotatedFileRotates is the defect that filled a phone's
|
||||
// storage. A log: section without a rotation: key used to get a plain
|
||||
// *os.File — nothing rotates it — and one debug-level spoke grew it to 111MB
|
||||
// with no backups, which on a phone is not untidy so much as fatal.
|
||||
//
|
||||
// The default rotation is what the unconfigured logger already carries, so
|
||||
// naming one should not be a way to opt out of it.
|
||||
func TestParseLoggerUnrotatedFileRotates(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "wisper.log")
|
||||
cfg := &config.LoggerConfig{
|
||||
Log: &config.LogConfig{
|
||||
Output: file,
|
||||
Level: "debug",
|
||||
Format: "json",
|
||||
// Rotation deliberately absent: the shape wisper.yaml on Android has.
|
||||
},
|
||||
}
|
||||
|
||||
w := outputWriter(cfg.Log)
|
||||
if w == nil {
|
||||
t.Fatal("outputWriter = nil")
|
||||
}
|
||||
if _, ok := w.(*lumberjack.Logger); !ok {
|
||||
t.Fatalf("outputWriter = %T, want a *lumberjack.Logger so the file rotates", w)
|
||||
}
|
||||
|
||||
lg := w.(*lumberjack.Logger)
|
||||
if lg.Filename != file {
|
||||
t.Errorf("Filename = %q, want %q", lg.Filename, file)
|
||||
}
|
||||
if lg.MaxSize <= 0 || lg.MaxBackups <= 0 {
|
||||
t.Errorf("rotation is off: MaxSize=%d MaxBackups=%d, want both positive", lg.MaxSize, lg.MaxBackups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLoggerExplicitRotationWins: a configured rotation is respected as
|
||||
// written, so the default is only a floor for what was left out.
|
||||
func TestParseLoggerExplicitRotationWins(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "wisper.log")
|
||||
cfg := &config.LoggerConfig{
|
||||
Log: &config.LogConfig{
|
||||
Output: file,
|
||||
Rotation: &config.LogRotationConfig{
|
||||
MaxSize: 33,
|
||||
MaxBackups: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
lg, ok := outputWriter(cfg.Log).(*lumberjack.Logger)
|
||||
if !ok {
|
||||
t.Fatalf("outputWriter = %T, want *lumberjack.Logger", outputWriter(cfg.Log))
|
||||
}
|
||||
if lg.MaxSize != 33 || lg.MaxBackups != 5 {
|
||||
t.Errorf("MaxSize=%d MaxBackups=%d, want 33 and 5 as configured", lg.MaxSize, lg.MaxBackups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLoggerNotAFileStillGoesToTheSink: stderr/stdout/none are unaffected,
|
||||
// so defaulting the rotation cannot leak a file into a stream.
|
||||
func TestParseLoggerNotAFileStillGoesToTheSink(t *testing.T) {
|
||||
for _, out := range []string{"stderr", "stdout", "none", ""} {
|
||||
w := outputWriter(&config.LogConfig{Output: out})
|
||||
switch out {
|
||||
case "none":
|
||||
if w != io.Discard {
|
||||
t.Errorf("none: writer = %T, want io.Discard", w)
|
||||
}
|
||||
case "stdout":
|
||||
if w != os.Stdout {
|
||||
t.Errorf("stdout: writer = %T, want os.Stdout", w)
|
||||
}
|
||||
default:
|
||||
if w != os.Stderr {
|
||||
t.Errorf("%q: writer = %T, want os.Stderr", out, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+440
-59
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
@@ -20,15 +22,314 @@ 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
// metadata-driven options (so_mark, interface, netns, proxy protocol), sets up
|
||||
// bypass rules, node filters, HTTP settings, and TLS node settings. The hop
|
||||
// parameter is used only for logging context.
|
||||
|
||||
func parseBodyRewrites(vs []config.HTTPBodyRewriteConfig, log logger.Logger) []chain.HTTPBodyRewriteSettings {
|
||||
var out []chain.HTTPBodyRewriteSettings
|
||||
for _, v := range vs {
|
||||
var pattern *regexp.Regexp
|
||||
var rewriteType string
|
||||
|
||||
js, ok := strings.CutPrefix(v.Match, "json:")
|
||||
del := false
|
||||
if !ok {
|
||||
// json-:<path> deletes the matched JSON field instead of replacing it.
|
||||
if js, ok = strings.CutPrefix(v.Match, "json-:"); ok {
|
||||
del = true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
// json:<path>[=<value-regex>] or json-:<path>[=<value-regex>]
|
||||
path, valRegex, _ := strings.Cut(js, "=")
|
||||
if valRegex == "" {
|
||||
valRegex = ".*"
|
||||
}
|
||||
var err error
|
||||
pattern, err = regexp.Compile(valRegex)
|
||||
if err != nil {
|
||||
log.Warnf("invalid JSON value regex %q for path %q: %v", valRegex, path, err)
|
||||
continue
|
||||
}
|
||||
if del {
|
||||
rewriteType = "json-:" + path
|
||||
} else {
|
||||
rewriteType = "json:" + path
|
||||
}
|
||||
} else {
|
||||
if v.Match != "" {
|
||||
pattern, _ = regexp.Compile(v.Match)
|
||||
}
|
||||
rewriteType = v.Type
|
||||
}
|
||||
|
||||
rw := chain.HTTPBodyRewriteSettings{
|
||||
Type: rewriteType,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
MaxChunkSize: v.MaxChunkSize,
|
||||
}
|
||||
if v.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(v.Rewriter) {
|
||||
log.Warnf("rewriter %q not found in registry for rewrite rule", v.Rewriter)
|
||||
}
|
||||
rw.Rewriter = registry.RewriterRegistry().Get(v.Rewriter)
|
||||
}
|
||||
if pattern != nil || rw.Rewriter != nil {
|
||||
out = append(out, rw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func 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
|
||||
@@ -123,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
|
||||
@@ -151,44 +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)
|
||||
if priority == 0 {
|
||||
priority = len(cfg.Matcher.Rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
opts = append(opts, chain.PriorityNodeOption(priority))
|
||||
priority = cfg.Matcher.Priority
|
||||
rule = cfg.Matcher.Rule
|
||||
bodySize = cfg.Matcher.BodySize
|
||||
}
|
||||
|
||||
if rule = strings.TrimSpace(rule); rule != "" {
|
||||
if matcher, err := routing.NewMatcher(rule); err == nil {
|
||||
log.Debugf("new matcher for node %s with rule %s", cfg.Name, rule)
|
||||
// Priority 0 means "use default": automatically set to the
|
||||
// rule length so longer (more specific) rules outrank shorter
|
||||
// ones. Use a negative priority to opt out of this behavior
|
||||
// and always go through the selector.
|
||||
if priority == 0 {
|
||||
priority = len(rule)
|
||||
}
|
||||
opts = append(opts, chain.MatcherNodeOption(matcher))
|
||||
} else {
|
||||
log.Error(err)
|
||||
priority = -1
|
||||
}
|
||||
}
|
||||
|
||||
if bodySize <= 0 {
|
||||
bodySize = DefaultMatcherBodySize
|
||||
} else if bodySize > MaxMatcherBodySize {
|
||||
bodySize = MaxMatcherBodySize
|
||||
}
|
||||
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
|
||||
opts = append(opts, chain.PriorityNodeOption(priority))
|
||||
|
||||
if cfg.HTTP != nil {
|
||||
settings := &chain.HTTPNodeSettings{
|
||||
Host: cfg.HTTP.Host,
|
||||
@@ -199,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}),
|
||||
@@ -222,33 +555,16 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteBody {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteResponseBody {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, v := range cfg.HTTP.RewriteRequestBody {
|
||||
if pattern, _ := regexp.Compile(v.Match); pattern != nil {
|
||||
settings.RewriteRequestBody = append(settings.RewriteRequestBody, chain.HTTPBodyRewriteSettings{
|
||||
Type: v.Type,
|
||||
Pattern: pattern,
|
||||
Replacement: []byte(v.Replacement),
|
||||
})
|
||||
}
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteBody, log)...)
|
||||
settings.RewriteResponseBody = append(settings.RewriteResponseBody, parseBodyRewrites(cfg.HTTP.RewriteResponseBody, log)...)
|
||||
settings.RewriteRequestBody = append(settings.RewriteRequestBody, parseBodyRewrites(cfg.HTTP.RewriteRequestBody, log)...)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -265,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"
|
||||
@@ -132,9 +133,9 @@ func TestParseNode_ExplicitDialer(t *testing.T) {
|
||||
|
||||
func TestParseNode_WithBypass(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "bypass-node",
|
||||
Addr: "example.com:8080",
|
||||
Bypass: "bp1",
|
||||
Name: "bypass-node",
|
||||
Addr: "example.com:8080",
|
||||
Bypass: "bp1",
|
||||
Bypasses: []string{"bp2", "bp3"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
@@ -291,6 +292,48 @@ func TestParseNode_WithMatcherAutoPriority(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcherBodySize(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "body-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
// Escaped predicate form; in YAML users write the backtick
|
||||
// equivalent: BodyRegexp(`"model":"gpt-4"`).
|
||||
Rule: `BodyRegexp("\"model\":\"gpt-4\"")`,
|
||||
BodySize: 65536,
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
if got := n.Options().MatcherBodySize; got != 65536 {
|
||||
t.Errorf("MatcherBodySize = %d, want 65536", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithMatcherBodySize_Clamped(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "body-node",
|
||||
Addr: "example.com:8080",
|
||||
Matcher: &config.NodeMatcherConfig{
|
||||
Rule: `BodyRegexp("foo")`,
|
||||
BodySize: 10 * 1024 * 1024, // 10MB, above the 1MB cap
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("expected non-nil node")
|
||||
}
|
||||
if got := n.Options().MatcherBodySize; got != MaxMatcherBodySize {
|
||||
t.Errorf("MatcherBodySize = %d, want %d (clamped)", got, MaxMatcherBodySize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNode_WithHTTPNode(t *testing.T) {
|
||||
n, err := ParseNode("test-hop", &config.NodeConfig{
|
||||
Name: "http-node",
|
||||
@@ -345,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",
|
||||
@@ -459,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...),
|
||||
@@ -328,6 +329,9 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
|
||||
Ingresses: append(cfg1.Ingresses, cfg2.Ingresses...),
|
||||
SDs: append(cfg1.SDs, cfg2.SDs...),
|
||||
Recorders: append(cfg1.Recorders, cfg2.Recorders...),
|
||||
Rewriters: append(cfg1.Rewriters, cfg2.Rewriters...),
|
||||
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{
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package rewriter
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// ParseRewriter converts a RewriterConfig into a rewriter.Rewriter.
|
||||
// It currently supports plugin backends only (HTTP or gRPC).
|
||||
// Returns nil when cfg is nil or no backend is configured.
|
||||
func ParseRewriter(cfg *config.RewriterConfig) rewriter.Rewriter {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Plugin != nil {
|
||||
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":
|
||||
return rewriter_plugin.NewHTTPPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
default:
|
||||
return rewriter_plugin.NewGRPCPlugin(
|
||||
cfg.Name, cfg.Plugin.Addr,
|
||||
plugin.TokenOption(cfg.Plugin.Token),
|
||||
plugin.TLSConfigOption(tlsCfg),
|
||||
plugin.TimeoutOption(cfg.Plugin.Timeout),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+234
-24
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hop"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/rewriter"
|
||||
"github.com/go-gost/core/routing"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
@@ -27,18 +30,38 @@ import (
|
||||
bypass_parser "github.com/go-gost/x/config/parsing/bypass"
|
||||
hop_parser "github.com/go-gost/x/config/parsing/hop"
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
node_parser "github.com/go-gost/x/config/parsing/node"
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
xhop "github.com/go-gost/x/hop"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xrouting "github.com/go-gost/x/routing"
|
||||
xs "github.com/go-gost/x/selector"
|
||||
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
|
||||
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
xstats "github.com/go-gost/x/observer/stats"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
"github.com/go-gost/x/registry"
|
||||
xservice "github.com/go-gost/x/service"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
// plaintextListeners are listener types that never terminate TLS on their
|
||||
// accepted connections, so any certFile/keyFile/caFile configured for them is
|
||||
// silently ignored. This catches the common footgun where a user writes e.g.
|
||||
// `tls+mws://...?certFile=...` expecting TLS: the "tls+" prefix is parsed as
|
||||
// the handler scheme (see x/config/cmd/cmd.go buildServiceConfig), not a TLS
|
||||
// wrapper, so the underlying mws listener stays plaintext. Extend this set when
|
||||
// new plaintext listeners are added.
|
||||
var plaintextListeners = map[string]bool{
|
||||
"tcp": true, "udp": true,
|
||||
"ws": true, "mws": true,
|
||||
"redirect": true, "tproxy": true,
|
||||
"rtcp": true, "rudp": true,
|
||||
"unix": true, "runix": true, "serial": true, "stdio": true,
|
||||
}
|
||||
|
||||
// ParseService constructs a fully-wired service.Service from a ServiceConfig.
|
||||
// It defaults the listener to "tcp" and the handler to "auto", resolves named
|
||||
// components from the registry (authers, admissions, bypasses, resolvers,
|
||||
@@ -88,6 +111,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
tls_util.RejectUnknownSNIConfig(tlsConfig, tlsCfg.RejectUnknownSNI, tlsCfg.ServerNames)
|
||||
}
|
||||
|
||||
if (tlsCfg.CertFile != "" || tlsCfg.KeyFile != "" || tlsCfg.CAFile != "") && plaintextListeners[cfg.Listener.Type] {
|
||||
serviceLogger.Warnf("TLS certificate options are configured but the %q listener does not use TLS and will ignore them; the connection will be plaintext. Use a TLS-capable listener (e.g. mwss, wss, tls, quic, grpc, http2, http3) to enable TLS.",
|
||||
cfg.Listener.Type)
|
||||
}
|
||||
|
||||
authers := auth_parser.List(cfg.Listener.Auther, cfg.Listener.Authers...)
|
||||
if len(authers) == 0 {
|
||||
if auther := auth_parser.ParseAutherFromAuth(cfg.Listener.Auth); auther != nil {
|
||||
@@ -177,13 +205,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
if !ignoreChain {
|
||||
routerOpts = append(routerOpts,
|
||||
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup)),
|
||||
chain.ChainRouterOption(chainGroup(cfg.Listener.Chain, cfg.Listener.ChainGroup, log)),
|
||||
)
|
||||
}
|
||||
lnRouter := xchain.NewRouter(routerOpts...)
|
||||
|
||||
// The routers may hold a chainGroup running probe goroutines, and the
|
||||
// forwarder hop may run probe goroutines of its own; make sure they are
|
||||
// closed if service construction fails after this point.
|
||||
var hRouter *xchain.Router
|
||||
var fwdCloser io.Closer
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
lnRouter.Close()
|
||||
if hRouter != nil {
|
||||
hRouter.Close()
|
||||
}
|
||||
if fwdCloser != nil {
|
||||
fwdCloser.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
listenOpts := []listener.Option{
|
||||
listener.AddrOption(cfg.Addr),
|
||||
listener.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
listener.RouterOption(lnRouter),
|
||||
listener.AutherOption(auther),
|
||||
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
|
||||
listener.TLSConfigOption(tlsConfig),
|
||||
@@ -282,11 +329,44 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
auther = xauth.AuthenticatorGroup(authers...)
|
||||
}
|
||||
|
||||
// Parse skipauth from handler metadata to whitelist client IPs that
|
||||
// should bypass authentication. Accepts both YAML arrays
|
||||
// (skipauth: ["10.0.0.0/8"]) and comma-separated URL query values
|
||||
// (?skipauth=10.0.0.0/8,192.168.1.1).
|
||||
if cfg.Handler.Metadata != nil {
|
||||
hmd := metadata.NewMetadata(cfg.Handler.Metadata)
|
||||
skipauth := mdutil.GetStrings(hmd, "skipauth")
|
||||
if len(skipauth) == 0 {
|
||||
if s := mdutil.GetString(hmd, "skipauth"); s != "" {
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
skipauth = append(skipauth, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(skipauth) > 0 {
|
||||
if auther != nil {
|
||||
auther = xauth.WhitelistedAuthenticator(auther, skipauth)
|
||||
handlerLogger.Debugf("skipauth whitelist applied: %v", skipauth)
|
||||
} else {
|
||||
handlerLogger.Warnf("skipauth configured but no auther set — authentication is already disabled for all clients")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var recorders []recorder.RecorderObject
|
||||
for _, r := range cfg.Recorders {
|
||||
md := metadata.NewMetadata(r.Metadata)
|
||||
rec := registry.RecorderRegistry().Get(r.Name)
|
||||
if r.Metadata != nil {
|
||||
rec = &xrecorder.MetadataRecorder{
|
||||
Recorder: rec,
|
||||
Metadata: r.Metadata,
|
||||
}
|
||||
}
|
||||
recorders = append(recorders, recorder.RecorderObject{
|
||||
Recorder: registry.RecorderRegistry().Get(r.Name),
|
||||
Recorder: rec,
|
||||
Record: r.Record,
|
||||
Options: &recorder.Options{
|
||||
Direction: mdutil.GetBool(md, parsing.MDKeyRecorderDirection),
|
||||
@@ -295,9 +375,18 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
HTTPBody: mdutil.GetBool(md, parsing.MDKeyRecorderHTTPBody),
|
||||
MaxBodySize: mdutil.GetInt(md, parsing.MDKeyRecorderHTTPMaxBodySize),
|
||||
},
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
var rew rewriter.Rewriter
|
||||
if cfg.Rewriter != "" {
|
||||
if !registry.RewriterRegistry().IsRegistered(cfg.Rewriter) {
|
||||
serviceLogger.Warnf("rewriter %q not found in registry", cfg.Rewriter)
|
||||
}
|
||||
rew = registry.RewriterRegistry().Get(cfg.Rewriter)
|
||||
}
|
||||
|
||||
routerOpts = []chain.RouterOption{
|
||||
chain.RetriesRouterOption(cfg.Handler.Retries),
|
||||
chain.TimeoutRouterOption(dialTimeout),
|
||||
@@ -311,14 +400,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
if !ignoreChain {
|
||||
routerOpts = append(routerOpts,
|
||||
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup)),
|
||||
chain.ChainRouterOption(chainGroup(cfg.Handler.Chain, cfg.Handler.ChainGroup, log)),
|
||||
)
|
||||
}
|
||||
|
||||
var h handler.Handler
|
||||
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
|
||||
hRouter = xchain.NewRouter(routerOpts...)
|
||||
h = rf(
|
||||
handler.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
handler.RouterOption(hRouter),
|
||||
handler.AutherOption(auther),
|
||||
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
|
||||
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
@@ -327,6 +417,8 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
handler.TrafficLimiterOption(registry.TrafficLimiterRegistry().Get(cfg.Handler.Limiter)),
|
||||
handler.ObserverOption(registry.ObserverRegistry().Get(cfg.Handler.Observer)),
|
||||
handler.RecordersOption(recorders...),
|
||||
handler.CacheOption(registry.CacheRegistry().Get(cfg.Cache)),
|
||||
handler.RewriterOption(rew),
|
||||
handler.LoggerOption(handlerLogger),
|
||||
handler.ServiceOption(cfg.Name),
|
||||
handler.NetnsOption(netnsIn),
|
||||
@@ -336,10 +428,11 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
}
|
||||
|
||||
if forwarder, ok := h.(handler.Forwarder); ok {
|
||||
hop, err := parseForwarder(cfg.Forwarder, log)
|
||||
hop, closer, err := parseForwarder(cfg.Forwarder, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fwdCloser = closer
|
||||
forwarder.Forward(hop)
|
||||
}
|
||||
|
||||
@@ -352,6 +445,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
closers := []io.Closer{lnRouter, hRouter}
|
||||
if fwdCloser != nil {
|
||||
closers = append(closers, fwdCloser)
|
||||
}
|
||||
s := xservice.NewService(cfg.Name, ln, h,
|
||||
xservice.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
|
||||
xservice.PreUpOption(preUp),
|
||||
@@ -364,15 +461,31 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
xservice.ObserverPeriodOption(observerPeriod),
|
||||
xservice.LoggerOption(serviceLogger),
|
||||
xservice.LabelsOption(labels),
|
||||
xservice.ClosersOption(closers...),
|
||||
)
|
||||
success = true
|
||||
|
||||
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, error) {
|
||||
// parseForwarder builds the forwarder hop for a service. The returned
|
||||
// io.Closer is non-nil only when the hop is owned by the service (inline
|
||||
// nodes or a hop group) and must be closed on service shutdown; hops from
|
||||
// the HopRegistry are closed by the registry on unregister.
|
||||
func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, io.Closer, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// HopGroup takes precedence over Hop / Name / Nodes.
|
||||
if cfg.HopGroup != nil {
|
||||
group, err := parseHopGroup(cfg.HopGroup, log)
|
||||
if err != nil || group == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
closer, _ := group.(io.Closer)
|
||||
return group, closer, nil
|
||||
}
|
||||
|
||||
hopName := cfg.Hop
|
||||
@@ -380,7 +493,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
|
||||
hopName = cfg.Name
|
||||
}
|
||||
if hopName != "" {
|
||||
return registry.HopRegistry().Get(hopName), nil
|
||||
return registry.HopRegistry().Get(hopName), nil, nil
|
||||
}
|
||||
|
||||
hc := config.HopConfig{
|
||||
@@ -422,35 +535,132 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er
|
||||
Matcher: node.Matcher,
|
||||
HTTP: httpCfg,
|
||||
TLS: node.TLS,
|
||||
Probe: node.Probe,
|
||||
Metadata: node.Metadata,
|
||||
})
|
||||
}
|
||||
return hop_parser.ParseHop(&hc, log)
|
||||
// Inline nodes build a per-service hop, owned (and closed) by the service.
|
||||
h, err := hop_parser.ParseHop(&hc, log)
|
||||
if err != nil || h == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
closer, _ := h.(io.Closer)
|
||||
return h, closer, nil
|
||||
}
|
||||
|
||||
func chainGroup(name string, group *config.ChainGroupConfig) chain.Chainer {
|
||||
var chains []chain.Chainer
|
||||
var sel selector.Selector[chain.Chainer]
|
||||
|
||||
if c := registry.ChainRegistry().Get(name); c != nil {
|
||||
chains = append(chains, c)
|
||||
func parseHopGroup(cfg *config.ForwardHopGroupConfig, log logger.Logger) (hop.Hop, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if group != nil {
|
||||
for _, s := range group.Chains {
|
||||
if c := registry.ChainRegistry().Get(s); c != nil {
|
||||
chains = append(chains, c)
|
||||
|
||||
var entries []*xhop.HopEntry
|
||||
for _, hc := range cfg.Hops {
|
||||
if hc == nil {
|
||||
continue
|
||||
}
|
||||
h := registry.HopRegistry().Get(hc.Hop)
|
||||
if h == nil {
|
||||
log.Warnf("hop %q not found in hopGroup, skipping", hc.Hop)
|
||||
continue
|
||||
}
|
||||
|
||||
var m routing.Matcher
|
||||
if hc.Matcher != nil && hc.Matcher.Rule != "" {
|
||||
var err error
|
||||
m, err = xrouting.NewMatcher(hc.Matcher.Rule)
|
||||
if err != nil {
|
||||
log.Warnf("hop %q: bad matcher rule %q: %v, skipping", hc.Hop, hc.Matcher.Rule, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sel = selector_parser.ParseChainSelector(group.Selector)
|
||||
|
||||
entries = append(entries, xhop.NewHopEntry(h, m, node_parser.ParseProbeConfig(hc.Probe)))
|
||||
}
|
||||
if len(chains) == 0 {
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var hopSelector selector.Selector[hop.Hop]
|
||||
if cfg.Selector != nil {
|
||||
var strategy selector.Strategy[hop.Hop]
|
||||
switch cfg.Selector.Strategy {
|
||||
case "round", "rr":
|
||||
strategy = xs.RoundRobinStrategy[hop.Hop]()
|
||||
case "random", "rand":
|
||||
strategy = xs.RandomStrategy[hop.Hop]()
|
||||
case "fifo", "ha":
|
||||
strategy = xs.FIFOStrategy[hop.Hop]()
|
||||
case "hash":
|
||||
strategy = xs.HashStrategy[hop.Hop]()
|
||||
default:
|
||||
strategy = xs.RoundRobinStrategy[hop.Hop]()
|
||||
}
|
||||
hopSelector = xs.NewSelector(
|
||||
strategy,
|
||||
xs.FailFilter[hop.Hop](cfg.Selector.MaxFails, cfg.Selector.FailTimeout),
|
||||
xs.BackupFilter[hop.Hop](),
|
||||
)
|
||||
}
|
||||
if hopSelector == nil {
|
||||
hopSelector = xs.NewSelector(
|
||||
xs.RoundRobinStrategy[hop.Hop](),
|
||||
xs.FailFilter[hop.Hop](xs.DefaultMaxFails, xs.DefaultFailTimeout),
|
||||
xs.BackupFilter[hop.Hop](),
|
||||
)
|
||||
}
|
||||
|
||||
return xhop.NewHopGroup(
|
||||
xhop.WithEntriesOption(entries...),
|
||||
xhop.WithGroupSelectorOption(hopSelector),
|
||||
xhop.WithGroupLoggerOption(log.WithFields(map[string]any{
|
||||
"kind": "hopGroup",
|
||||
"service": "forwarder",
|
||||
})),
|
||||
), nil
|
||||
}
|
||||
|
||||
func chainGroup(name string, group *config.ChainGroupConfig, log logger.Logger) chain.Chainer {
|
||||
var entries []*xchain.ChainEntry
|
||||
|
||||
if c := registry.ChainRegistry().Get(name); c != nil {
|
||||
entries = append(entries, xchain.NewChainEntry(c, nil, nil))
|
||||
}
|
||||
if group != nil {
|
||||
for _, ge := range group.Chains {
|
||||
c := registry.ChainRegistry().Get(ge.Chain)
|
||||
if c == nil {
|
||||
log.Warnf("chain %q not found in chainGroup, skipping", ge.Chain)
|
||||
continue
|
||||
}
|
||||
|
||||
var m routing.Matcher
|
||||
if ge.Matcher != nil && ge.Matcher.Rule != "" {
|
||||
var err error
|
||||
m, err = xrouting.NewMatcher(ge.Matcher.Rule)
|
||||
if err != nil {
|
||||
log.Warnf("chain %q: bad matcher rule %q: %v, skipping", ge.Chain, ge.Matcher.Rule, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, xchain.NewChainEntry(c, m, node_parser.ParseProbeConfig(ge.Probe)))
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sel selector.Selector[chain.Chainer]
|
||||
if group != nil {
|
||||
sel = selector_parser.ParseChainSelector(group.Selector)
|
||||
}
|
||||
if sel == nil {
|
||||
sel = selector_parser.DefaultChainSelector()
|
||||
}
|
||||
|
||||
return xchain.NewChainGroup(chains...).
|
||||
WithSelector(sel)
|
||||
return xchain.NewChainGroup().
|
||||
WithGroupEntries(entries...).
|
||||
WithSelector(sel).
|
||||
WithGroupLogger(log)
|
||||
}
|
||||
|
||||
+98
-22
@@ -12,6 +12,8 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +26,24 @@ var (
|
||||
defaultTLSConfig atomic.Value
|
||||
)
|
||||
|
||||
// testDefaultCertDir overrides the default certificate directory in tests.
|
||||
// It is set only from same-package test code and left empty in production.
|
||||
var testDefaultCertDir string
|
||||
|
||||
// defaultCertDir returns the directory used for persisting auto-generated
|
||||
// certificates. It uses testDefaultCertDir when set (for testing), otherwise
|
||||
// returns $HOME/.gost/.
|
||||
func defaultCertDir() string {
|
||||
if testDefaultCertDir != "" {
|
||||
return testDefaultCertDir
|
||||
}
|
||||
dir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".gost"
|
||||
}
|
||||
return filepath.Join(dir, ".gost")
|
||||
}
|
||||
|
||||
// DefaultTLSConfig returns the global default TLS configuration used as a
|
||||
// fallback when a listener or handler does not specify its own TLS settings.
|
||||
// The returned config is a shared instance; callers that need to mutate it
|
||||
@@ -40,44 +60,99 @@ func SetDefaultTLSConfig(cfg *tls.Config) {
|
||||
}
|
||||
|
||||
// BuildDefaultTLSConfig loads or generates the default TLS certificate and key
|
||||
// from the given config. If cfg is nil it attempts to load well-known files
|
||||
// ("cert.pem", "key.pem", "ca.pem"). On failure it generates a self-signed
|
||||
// ECDSA P-256 certificate valid for one year.
|
||||
// from the given config.
|
||||
//
|
||||
// When explicit certificate files are configured (CertFile, KeyFile, or CAFile
|
||||
// is non-empty) it loads them via tls_util.LoadDefaultConfig — the original
|
||||
// behaviour preserved for backward compatibility.
|
||||
//
|
||||
// When no certificate files are configured (all three empty), it tries sources
|
||||
// in this order:
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/ (persisted)
|
||||
// 3. If none found, generates a new ECDSA P-256 CA certificate, persists it
|
||||
// to $HOME/.gost/, and uses it.
|
||||
//
|
||||
// If cfg is nil an empty TLSConfig is used (equivalent to no explicit files),
|
||||
// so the CWD → persisted → generate path is taken.
|
||||
func BuildDefaultTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
if cfg == nil {
|
||||
cfg = &config.TLSConfig{
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
CAFile: "ca.pem",
|
||||
}
|
||||
cfg = &config.TLSConfig{}
|
||||
}
|
||||
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
// generate random self-signed certificate.
|
||||
cert, err := genCertificate(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if cfg.CertFile != "" || cfg.KeyFile != "" || cfg.CAFile != "" {
|
||||
tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
log.Debug("load global TLS certificate files failed, use random generated certificate")
|
||||
} else {
|
||||
log.Debug("load global TLS certificate files OK")
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
tlsConfig, err := loadOrGeneratePersistentTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func genCertificate(validity time.Duration, org string, cn string) (cert tls.Certificate, err error) {
|
||||
rawCert, rawKey, err := generateKeyPair(validity, org, cn)
|
||||
if err != nil {
|
||||
return
|
||||
// loadOrGeneratePersistentTLSConfig tries loading an auto-generated CA
|
||||
// certificate from these locations (in order):
|
||||
// 1. cert.pem / key.pem in the current working directory (backward compatible)
|
||||
// 2. auto-ca-cert.pem / auto-ca-key.pem under defaultCertDir() (persisted)
|
||||
//
|
||||
// If none is found it generates a new one, persists it to defaultCertDir()
|
||||
// (best-effort), and returns it.
|
||||
func loadOrGeneratePersistentTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) {
|
||||
log := logger.Default()
|
||||
|
||||
// 1. Try CWD cert.pem / key.pem first (backward compatible).
|
||||
cwdCert, cwdErr := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if cwdErr == nil {
|
||||
log.Debug("loaded default certificate from current working directory (cert.pem / key.pem)")
|
||||
return &tls.Config{Certificates: []tls.Certificate{cwdCert}}, nil
|
||||
}
|
||||
return tls.X509KeyPair(rawCert, rawKey)
|
||||
|
||||
// 2. Try persisted certificate in defaultCertDir().
|
||||
dir := defaultCertDir()
|
||||
certFile := filepath.Join(dir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(dir, "auto-ca-key.pem")
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err == nil {
|
||||
log.Debugf("loaded persisted default certificate from %s", dir)
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
log.Debug("generating new default certificate (no persisted certificate found)")
|
||||
|
||||
// 3. Generate a new certificate.
|
||||
rawCert, rawKey, err := generateKeyPair(cfg.Validity, cfg.Organization, cfg.CommonName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Best-effort persist to disk. If it fails the in-memory certificate is
|
||||
// still usable.
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Warnf("failed to create certificate directory %s: %v", dir, err)
|
||||
} else {
|
||||
if err := os.WriteFile(certFile, rawCert, 0644); err != nil {
|
||||
log.Warnf("failed to persist certificate: %v", err)
|
||||
} else if err := os.WriteFile(keyFile, rawKey, 0600); err != nil {
|
||||
log.Warnf("failed to persist private key: %v", err)
|
||||
} else {
|
||||
log.Debugf("persisted default certificate to %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
cert, err = tls.X509KeyPair(rawCert, rawKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, rawKey []byte, err error) {
|
||||
@@ -124,6 +199,7 @@ func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, ra
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
if _, isRSA := priv.(*rsa.PrivateKey); isRSA {
|
||||
template.KeyUsage |= x509.KeyUsageKeyEncipherment
|
||||
|
||||
+181
-9
@@ -2,15 +2,58 @@ package parsing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
// writeTestCertKey writes a self-signed cert/key pair to the given file paths.
|
||||
// The cert is NOT persisted via the defaultCertDir mechanism — it simulates
|
||||
// user-provided cert.pem / key.pem in a working directory.
|
||||
func writeTestCertKey(t *testing.T, certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "cwd-test.local"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(certFile, certPEM, 0644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard)))
|
||||
m.Run()
|
||||
@@ -47,6 +90,10 @@ func TestDefaultTLSConfig_InitiallyNil(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
|
||||
// Use a temp dir so the test is isolated and doesn't touch $HOME/.gost
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
// BuildDefaultTLSConfig with nil should generate a self-signed cert
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
@@ -58,9 +105,22 @@ func TestBuildDefaultTLSConfig_Nil(t *testing.T) {
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Verify that the certificate and key files were persisted.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg := &config.TLSConfig{
|
||||
Validity: 0, // uses default
|
||||
Organization: "TestOrg",
|
||||
@@ -78,26 +138,138 @@ func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_TwoCallsDifferentCerts(t *testing.T) {
|
||||
func TestBuildDefaultTLSConfig_TwoCallsSameCerts(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
t.Cleanup(func() { testDefaultCertDir = "" })
|
||||
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
if len(cfg1.Certificates) == 0 || len(cfg2.Certificates) == 0 {
|
||||
t.Fatal("expected certificates in both configs")
|
||||
}
|
||||
|
||||
// Each call generates a random cert; they should have different raw bytes.
|
||||
if len(cfg1.Certificates[0].Certificate) == 0 || len(cfg2.Certificates[0].Certificate) == 0 {
|
||||
t.Fatal("expected non-empty certificate raw bytes")
|
||||
}
|
||||
// Both calls should return the SAME persisted certificate.
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected different certificates from two calls")
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate from two calls (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_PersistsAndReloads(t *testing.T) {
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// First call generates and persists.
|
||||
cfg1, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on first call: %v", err)
|
||||
}
|
||||
|
||||
// Verify files exist on disk.
|
||||
certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem")
|
||||
keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem")
|
||||
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||
t.Fatal("cert file was not persisted after first call")
|
||||
}
|
||||
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
|
||||
t.Fatal("key file was not persisted after first call")
|
||||
}
|
||||
|
||||
// Second call loads the persisted cert.
|
||||
cfg2, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
|
||||
c1 := cfg1.Certificates[0].Certificate[0]
|
||||
c2 := cfg2.Certificates[0].Certificate[0]
|
||||
if !bytes.Equal(c1, c2) {
|
||||
t.Fatal("expected same certificate on reload (persisted)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultTLSConfig_FallsBackWhenWriteBlocked(t *testing.T) {
|
||||
// Point to a directory that can't be created (e.g. a file path).
|
||||
// We use a path under /proc on Linux which is a read-only filesystem.
|
||||
testDefaultCertDir = "/proc/doesnotexist/certdir"
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (should fall back to in-memory): %v", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("expected non-nil config even when write fails")
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate from fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDefaultTLSConfig_CWDTakesPriority verifies that when cert.pem and
|
||||
// key.pem exist in the current working directory, they take precedence over
|
||||
// both the persisted certificate and auto-generation.
|
||||
func TestBuildDefaultTLSConfig_CWDTakesPriority(t *testing.T) {
|
||||
// Create a temp dir with cert.pem / key.pem.
|
||||
cwd := t.TempDir()
|
||||
writeTestCertKey(t, filepath.Join(cwd, "cert.pem"), filepath.Join(cwd, "key.pem"))
|
||||
|
||||
// Also set up a persisted cert directory.
|
||||
testDefaultCertDir = t.TempDir()
|
||||
defer func() { testDefaultCertDir = "" }()
|
||||
|
||||
// Pre-populate persisted cert so we can prove CWD wins.
|
||||
_, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pre-populate persisted cert: %v", err)
|
||||
}
|
||||
|
||||
// Change to the CWD that has cert.pem / key.pem.
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(cwd); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
// BuildDefaultTLSConfig should load CWD cert, not the persisted one.
|
||||
tlsCfg, err := BuildDefaultTLSConfig(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Fatal("expected at least one certificate")
|
||||
}
|
||||
|
||||
// Load CWD cert directly and compare.
|
||||
cwdCert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
|
||||
if err != nil {
|
||||
t.Fatalf("load CWD cert: %v", err)
|
||||
}
|
||||
got := tlsCfg.Certificates[0].Certificate[0]
|
||||
want := cwdCert.Certificate[0]
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("CWD certificate was not used (persisted or auto-generated cert took priority)")
|
||||
}
|
||||
|
||||
// Load persisted cert to prove it's different from CWD cert.
|
||||
persistedCert, err := tls.LoadX509KeyPair(
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-cert.pem"),
|
||||
filepath.Join(testDefaultCertDir, "auto-ca-key.pem"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted cert: %v", err)
|
||||
}
|
||||
if bytes.Equal(got, persistedCert.Certificate[0]) {
|
||||
t.Fatal("CWD cert should differ from persisted cert; test setup issue")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package masque
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
)
|
||||
|
||||
const udpAssociationBufferSize = 64 * 1024
|
||||
|
||||
var errMissingDestination = errors.New("masque: destination address required")
|
||||
|
||||
type udpAssociationDialFunc func(ctx context.Context, addr net.Addr) (net.PacketConn, error)
|
||||
|
||||
type udpAssociationResult struct {
|
||||
data []byte
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
type udpAssociationTunnel struct {
|
||||
conn net.PacketConn
|
||||
}
|
||||
|
||||
type udpAssociationConn struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
localAddr net.Addr
|
||||
dial udpAssociationDialFunc
|
||||
closeIdle func() error
|
||||
log logger.Logger
|
||||
timeout time.Duration
|
||||
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
results chan udpAssociationResult
|
||||
|
||||
mu sync.Mutex
|
||||
tunnels map[string]*udpAssociationTunnel
|
||||
readDeadline time.Time
|
||||
readDeadlineChanged chan struct{}
|
||||
}
|
||||
|
||||
func newUDPAssociationConn(
|
||||
ctx context.Context,
|
||||
localAddr net.Addr,
|
||||
timeout time.Duration,
|
||||
dial udpAssociationDialFunc,
|
||||
closeIdle func() error,
|
||||
log logger.Logger,
|
||||
) *udpAssociationConn {
|
||||
ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||
c := &udpAssociationConn{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
localAddr: localAddr,
|
||||
dial: dial,
|
||||
closeIdle: closeIdle,
|
||||
log: log,
|
||||
timeout: timeout,
|
||||
closed: make(chan struct{}),
|
||||
results: make(chan udpAssociationResult, 32),
|
||||
tunnels: make(map[string]*udpAssociationTunnel),
|
||||
readDeadlineChanged: make(chan struct{}),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
||||
for {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, nil, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
deadline := c.readDeadline
|
||||
deadlineChanged := c.readDeadlineChanged
|
||||
c.mu.Unlock()
|
||||
|
||||
var (
|
||||
timer *time.Timer
|
||||
timeout <-chan time.Time
|
||||
)
|
||||
if !deadline.IsZero() {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return 0, nil, os.ErrDeadlineExceeded
|
||||
}
|
||||
timer = time.NewTimer(remaining)
|
||||
timeout = timer.C
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-c.results:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return copy(b, result.data), result.addr, nil
|
||||
case <-deadlineChanged:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
continue
|
||||
case <-timeout:
|
||||
return 0, nil, os.ErrDeadlineExceeded
|
||||
case <-c.closed:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return 0, nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
if addr == nil {
|
||||
return 0, errMissingDestination
|
||||
}
|
||||
|
||||
tunnel, err := c.tunnel(addr)
|
||||
if err != nil {
|
||||
if c.isClosed() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
c.logError(addr, err)
|
||||
return len(b), nil
|
||||
}
|
||||
n, err := tunnel.conn.WriteTo(b, addr)
|
||||
if err == nil {
|
||||
return n, nil
|
||||
}
|
||||
if c.isClosed() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
if c.removeTunnel(addr.String(), tunnel) {
|
||||
c.logError(addr, err)
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) tunnel(addr net.Addr) (*udpAssociationTunnel, error) {
|
||||
key := addr.String()
|
||||
|
||||
c.mu.Lock()
|
||||
select {
|
||||
case <-c.closed:
|
||||
c.mu.Unlock()
|
||||
return nil, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
|
||||
if tunnel := c.tunnels[key]; tunnel != nil {
|
||||
c.mu.Unlock()
|
||||
return tunnel, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
ctx := c.ctx
|
||||
if c.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
conn, err := c.dial(ctx, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conn == nil {
|
||||
return nil, errors.New("masque: nil UDP tunnel")
|
||||
}
|
||||
|
||||
tunnel := &udpAssociationTunnel{conn: conn}
|
||||
c.mu.Lock()
|
||||
select {
|
||||
case <-c.closed:
|
||||
c.mu.Unlock()
|
||||
conn.Close()
|
||||
return nil, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
if current := c.tunnels[key]; current != nil {
|
||||
c.mu.Unlock()
|
||||
conn.Close()
|
||||
return current, nil
|
||||
}
|
||||
c.tunnels[key] = tunnel
|
||||
c.mu.Unlock()
|
||||
go c.readTunnel(key, tunnel)
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) readTunnel(key string, tunnel *udpAssociationTunnel) {
|
||||
buf := make([]byte, udpAssociationBufferSize)
|
||||
for {
|
||||
n, addr, err := tunnel.conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if c.isClosed() {
|
||||
return
|
||||
}
|
||||
if c.removeTunnel(key, tunnel) {
|
||||
c.logError(key, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data := append([]byte(nil), buf[:n]...)
|
||||
select {
|
||||
case c.results <- udpAssociationResult{data: data, addr: addr}:
|
||||
case <-c.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) isClosed() bool {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) logError(addr any, err error) {
|
||||
if c.log != nil {
|
||||
c.log.Warnf("masque: UDP tunnel %v: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) removeTunnel(key string, tunnel *udpAssociationTunnel) bool {
|
||||
c.mu.Lock()
|
||||
removed := c.tunnels[key] == tunnel
|
||||
if c.tunnels[key] == tunnel {
|
||||
delete(c.tunnels, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
tunnel.conn.Close()
|
||||
return removed
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) Read(b []byte) (int, error) {
|
||||
n, _, err := c.ReadFrom(b)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) Write(b []byte) (int, error) {
|
||||
return 0, errMissingDestination
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.closed)
|
||||
c.cancel()
|
||||
|
||||
c.mu.Lock()
|
||||
close(c.readDeadlineChanged)
|
||||
tunnels := make([]*udpAssociationTunnel, 0, len(c.tunnels))
|
||||
for _, tunnel := range c.tunnels {
|
||||
tunnels = append(tunnels, tunnel)
|
||||
}
|
||||
clear(c.tunnels)
|
||||
c.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
for _, tunnel := range tunnels {
|
||||
if err := tunnel.conn.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if c.closeIdle != nil {
|
||||
if err := c.closeIdle(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
c.closeErr = errors.Join(errs...)
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) LocalAddr() net.Addr {
|
||||
return c.localAddr
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) RemoteAddr() net.Addr {
|
||||
return &net.UDPAddr{}
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) SetDeadline(t time.Time) error {
|
||||
if err := c.SetReadDeadline(t); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) SetReadDeadline(t time.Time) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
select {
|
||||
case <-c.closed:
|
||||
return net.ErrClosed
|
||||
default:
|
||||
}
|
||||
c.readDeadline = t
|
||||
close(c.readDeadlineChanged)
|
||||
c.readDeadlineChanged = make(chan struct{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *udpAssociationConn) SetWriteDeadline(t time.Time) error {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return net.ErrClosed
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ net.Conn = (*udpAssociationConn)(nil)
|
||||
_ net.PacketConn = (*udpAssociationConn)(nil)
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
package masque
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUDPAssociationConnRoutesByDestination(t *testing.T) {
|
||||
type dialCall struct {
|
||||
addr net.Addr
|
||||
conn *fakePacketConn
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls []dialCall
|
||||
)
|
||||
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
|
||||
conn := newFakePacketConn()
|
||||
mu.Lock()
|
||||
calls = append(calls, dialCall{addr: addr, conn: conn})
|
||||
mu.Unlock()
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
conn := newUDPAssociationConn(ctx, &net.UDPAddr{}, 0, dial, nil, nil)
|
||||
cancel() // The route dial context ends before the UDP association is used.
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}
|
||||
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 53}
|
||||
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
|
||||
t.Fatalf("first WriteTo failed: %v", err)
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("second"), addr1); err != nil {
|
||||
t.Fatalf("second WriteTo failed: %v", err)
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("third"), addr2); err != nil {
|
||||
t.Fatalf("third WriteTo failed: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
gotCalls := append([]dialCall(nil), calls...)
|
||||
mu.Unlock()
|
||||
if len(gotCalls) != 2 {
|
||||
t.Fatalf("expected one tunnel per destination, got %d", len(gotCalls))
|
||||
}
|
||||
if gotCalls[0].addr.String() != addr1.String() || gotCalls[1].addr.String() != addr2.String() {
|
||||
t.Fatalf("unexpected tunnel destinations: %s, %s", gotCalls[0].addr, gotCalls[1].addr)
|
||||
}
|
||||
if got := gotCalls[0].conn.writtenData(); len(got) != 2 || string(got[0]) != "first" || string(got[1]) != "second" {
|
||||
t.Fatalf("unexpected writes for first destination: %q", got)
|
||||
}
|
||||
if got := gotCalls[1].conn.writtenData(); len(got) != 1 || string(got[0]) != "third" {
|
||||
t.Fatalf("unexpected writes for second destination: %q", got)
|
||||
}
|
||||
|
||||
replyAddr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}
|
||||
gotCalls[1].conn.queueRead([]byte("reply"), replyAddr)
|
||||
buf := make([]byte, 32)
|
||||
n, addr, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrom failed: %v", err)
|
||||
}
|
||||
if string(buf[:n]) != "reply" {
|
||||
t.Fatalf("unexpected reply: %q", buf[:n])
|
||||
}
|
||||
if addr.String() != replyAddr.String() {
|
||||
t.Fatalf("expected reply from %s, got %s", replyAddr, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnClose(t *testing.T) {
|
||||
upstream := newFakePacketConn()
|
||||
idleClosed := 0
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
0,
|
||||
func(context.Context, net.Addr) (net.PacketConn, error) { return upstream, nil },
|
||||
func() error {
|
||||
idleClosed++
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53}); err != nil {
|
||||
t.Fatalf("WriteTo failed: %v", err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatalf("second Close failed: %v", err)
|
||||
}
|
||||
if idleClosed != 1 {
|
||||
t.Fatalf("expected idle stream to be closed once, got %d", idleClosed)
|
||||
}
|
||||
if !upstream.isClosed() {
|
||||
t.Fatal("expected destination tunnel to be closed")
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("request"), &net.UDPAddr{}); !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("expected net.ErrClosed after Close, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnTunnelFailureIsIsolated(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls []*fakePacketConn
|
||||
)
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
0,
|
||||
func(context.Context, net.Addr) (net.PacketConn, error) {
|
||||
pc := newFakePacketConn()
|
||||
mu.Lock()
|
||||
calls = append(calls, pc)
|
||||
mu.Unlock()
|
||||
return pc, nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
addr1 := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443}
|
||||
addr2 := &net.UDPAddr{IP: net.ParseIP("8.8.8.8"), Port: 443}
|
||||
if _, err := conn.WriteTo([]byte("first"), addr1); err != nil {
|
||||
t.Fatalf("first WriteTo failed: %v", err)
|
||||
}
|
||||
if _, err := conn.WriteTo([]byte("second"), addr2); err != nil {
|
||||
t.Fatalf("second WriteTo failed: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
first, second := calls[0], calls[1]
|
||||
mu.Unlock()
|
||||
first.queueReadError(errors.New("stream reset"))
|
||||
select {
|
||||
case <-first.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("failed tunnel was not removed")
|
||||
}
|
||||
|
||||
second.queueRead([]byte("reply"), addr2)
|
||||
buf := make([]byte, 32)
|
||||
n, addr, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("healthy tunnel failed after peer reset: %v", err)
|
||||
}
|
||||
if string(buf[:n]) != "reply" || addr.String() != addr2.String() {
|
||||
t.Fatalf("unexpected healthy tunnel reply %q from %v", buf[:n], addr)
|
||||
}
|
||||
|
||||
if _, err := conn.WriteTo([]byte("retry"), addr1); err != nil {
|
||||
t.Fatalf("retry WriteTo failed: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
callCount := len(calls)
|
||||
mu.Unlock()
|
||||
if callCount != 3 {
|
||||
t.Fatalf("expected failed destination to redial, got %d dials", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnDialTimeout(t *testing.T) {
|
||||
dialErr := make(chan error, 1)
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
20*time.Millisecond,
|
||||
func(ctx context.Context, _ net.Addr) (net.PacketConn, error) {
|
||||
<-ctx.Done()
|
||||
dialErr <- ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
payload := []byte("request")
|
||||
n, err := conn.WriteTo(payload, &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
|
||||
if err != nil || n != len(payload) {
|
||||
t.Fatalf("timed out datagram was not dropped cleanly: n=%d err=%v", n, err)
|
||||
}
|
||||
if err := <-dialErr; !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected dial deadline, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPAssociationConnDialDoesNotHoldAssociationLock(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
conn := newUDPAssociationConn(
|
||||
context.Background(),
|
||||
&net.UDPAddr{},
|
||||
0,
|
||||
func(context.Context, net.Addr) (net.PacketConn, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return newFakePacketConn(), nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
writeDone := make(chan struct{})
|
||||
go func() {
|
||||
conn.WriteTo([]byte("request"), &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443})
|
||||
close(writeDone)
|
||||
}()
|
||||
<-started
|
||||
|
||||
deadlineDone := make(chan error, 1)
|
||||
go func() {
|
||||
deadlineDone <- conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
}()
|
||||
select {
|
||||
case err := <-deadlineDone:
|
||||
if err != nil {
|
||||
t.Fatalf("SetReadDeadline failed: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dial held the association lock")
|
||||
}
|
||||
|
||||
close(release)
|
||||
<-writeDone
|
||||
}
|
||||
|
||||
type fakePacket struct {
|
||||
data []byte
|
||||
addr net.Addr
|
||||
err error
|
||||
}
|
||||
|
||||
type fakePacketConn struct {
|
||||
reads chan fakePacket
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
mu sync.Mutex
|
||||
writes [][]byte
|
||||
}
|
||||
|
||||
func newFakePacketConn() *fakePacketConn {
|
||||
return &fakePacketConn{
|
||||
reads: make(chan fakePacket, 1),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) queueRead(data []byte, addr net.Addr) {
|
||||
c.reads <- fakePacket{data: append([]byte(nil), data...), addr: addr}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) queueReadError(err error) {
|
||||
c.reads <- fakePacket{err: err}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) writtenData() [][]byte {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make([][]byte, len(c.writes))
|
||||
for i, data := range c.writes {
|
||||
result[i] = append([]byte(nil), data...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) isClosed() bool {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||
select {
|
||||
case packet := <-c.reads:
|
||||
if packet.err != nil {
|
||||
return 0, nil, packet.err
|
||||
}
|
||||
return copy(b, packet.data), packet.addr, nil
|
||||
case <-c.closed:
|
||||
return 0, nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.writes = append(c.writes, append([]byte(nil), b...))
|
||||
c.mu.Unlock()
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) Close() error {
|
||||
c.closeOnce.Do(func() { close(c.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakePacketConn) LocalAddr() net.Addr { return &net.UDPAddr{} }
|
||||
func (c *fakePacketConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *fakePacketConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *fakePacketConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/connector"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
masque_dialer "github.com/go-gost/x/dialer/http3/masque"
|
||||
masque_util "github.com/go-gost/x/internal/util/masque"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -73,6 +75,16 @@ func (c *masqueConnector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedNetwork, network)
|
||||
}
|
||||
|
||||
func newTCPConnectRequest(address string) *http.Request {
|
||||
return &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{},
|
||||
Host: address,
|
||||
Header: http.Header{},
|
||||
ProtoMajor: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address string, log logger.Logger) (net.Conn, error) {
|
||||
log.Debugf("connect-tcp %s", address)
|
||||
|
||||
@@ -103,13 +115,7 @@ func (c *masqueConnector) connectTCP(ctx context.Context, conn net.Conn, address
|
||||
|
||||
// Create standard HTTP/3 CONNECT request (RFC 9114)
|
||||
// No :protocol pseudo-header for standard CONNECT
|
||||
req := &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{},
|
||||
Host: address, // Target address goes in :authority
|
||||
Header: http.Header{},
|
||||
Proto: "HTTP/3.0",
|
||||
}
|
||||
req := newTCPConnectRequest(address)
|
||||
|
||||
// Add proxy authentication if configured
|
||||
if c.options.Auth != nil {
|
||||
@@ -172,9 +178,69 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
|
||||
return nil, ErrInvalidConnection
|
||||
}
|
||||
|
||||
// Get pre-opened stream from dialer (stream opening happens there for dead connection detection)
|
||||
reqStream := masqueConn.GetRequestStream()
|
||||
proxyHost := masqueConn.GetHost()
|
||||
if address == "" {
|
||||
return c.connectUDPAssociation(ctx, masqueConn, log), nil
|
||||
}
|
||||
|
||||
return c.connectUDPTarget(
|
||||
ctx,
|
||||
conn.LocalAddr(),
|
||||
masqueConn.GetHost(),
|
||||
masqueConn.GetRequestStream(),
|
||||
address,
|
||||
nil,
|
||||
log,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *masqueConnector) connectUDPAssociation(ctx context.Context, conn *masque_dialer.MasqueConn, log logger.Logger) net.Conn {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
firstStream = conn.GetRequestStream()
|
||||
)
|
||||
|
||||
nextStream := func(ctx context.Context) (*http3.RequestStream, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if firstStream != nil {
|
||||
stream := firstStream
|
||||
firstStream = nil
|
||||
return stream, nil
|
||||
}
|
||||
return conn.OpenRequestStream(ctx)
|
||||
}
|
||||
|
||||
closeIdle := func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if firstStream == nil {
|
||||
return nil
|
||||
}
|
||||
err := firstStream.Close()
|
||||
firstStream = nil
|
||||
return err
|
||||
}
|
||||
|
||||
dial := func(ctx context.Context, addr net.Addr) (net.PacketConn, error) {
|
||||
stream, err := nextStream(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.connectUDPTarget(ctx, conn.LocalAddr(), conn.GetHost(), stream, addr.String(), addr, log)
|
||||
}
|
||||
|
||||
return newUDPAssociationConn(ctx, conn.LocalAddr(), c.md.connectTimeout, dial, closeIdle, log)
|
||||
}
|
||||
|
||||
func (c *masqueConnector) connectUDPTarget(
|
||||
ctx context.Context,
|
||||
localAddr net.Addr,
|
||||
proxyHost string,
|
||||
reqStream *http3.RequestStream,
|
||||
address string,
|
||||
remoteAddr net.Addr,
|
||||
log logger.Logger,
|
||||
) (*masque_util.DatagramConn, error) {
|
||||
|
||||
// Apply connect timeout to the actual stream
|
||||
if c.md.connectTimeout > 0 {
|
||||
@@ -259,14 +325,15 @@ func (c *masqueConnector) connectUDP(ctx context.Context, conn net.Conn, address
|
||||
// Get the underlying HTTP/3 stream for datagrams
|
||||
stream := reqStream
|
||||
|
||||
// Resolve target address
|
||||
raddr, err := net.ResolveUDPAddr("udp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if remoteAddr == nil {
|
||||
remoteAddr, err = net.ResolveUDPAddr("udp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create datagram connection wrapping the request stream
|
||||
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, conn.LocalAddr(), raddr)
|
||||
datagramConn := masque_util.NewDatagramConnFromRequestStream(stream, localAddr, remoteAddr)
|
||||
|
||||
success = true // Prevent defer from closing stream - datagramConn now owns it
|
||||
return datagramConn, nil
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package masque
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTCPConnectRequestUsesStandardConnect(t *testing.T) {
|
||||
req := newTCPConnectRequest("example.com:443")
|
||||
|
||||
if req.Method != http.MethodConnect {
|
||||
t.Fatalf("expected CONNECT method, got %s", req.Method)
|
||||
}
|
||||
if req.Host != "example.com:443" {
|
||||
t.Fatalf("expected target authority, got %s", req.Host)
|
||||
}
|
||||
if req.Proto != "" {
|
||||
// quic-go treats any non-empty Proto as an extended CONNECT :protocol.
|
||||
t.Fatalf("standard CONNECT must not set :protocol, got %q", req.Proto)
|
||||
}
|
||||
if req.ProtoMajor != 3 {
|
||||
t.Fatalf("expected HTTP/3 request, got HTTP/%d", req.ProtoMajor)
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,11 @@ func (c *relayConnector) bind(conn net.Conn, cmd relay.CmdType, network, address
|
||||
})
|
||||
fa := &relay.AddrFeature{}
|
||||
if network != "unix" {
|
||||
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
}
|
||||
if address == "" {
|
||||
address = "0.0.0.0:0"
|
||||
} else if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
}
|
||||
}
|
||||
fa.ParseFrom(address)
|
||||
req.Features = append(req.Features, fa)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type bindListener struct {
|
||||
network string
|
||||
addr net.Addr
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"time"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/internal/util/mux"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
@@ -31,6 +31,8 @@ func (c *relayConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -137,7 +137,12 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
addr.Host = "127.0.0.1"
|
||||
}
|
||||
|
||||
req := gosocks5.NewRequest(gosocks5.CmdConnect, &addr)
|
||||
cmd := gosocks5.CmdConnect
|
||||
if c, ok := ctxvalue.Socks5CmdFromContext(ctx); ok {
|
||||
cmd = c
|
||||
}
|
||||
|
||||
req := gosocks5.NewRequest(cmd, &addr)
|
||||
log.Trace(req)
|
||||
if err := req.Write(conn); err != nil {
|
||||
log.Error(err)
|
||||
@@ -157,6 +162,10 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cmd == socks.CmdResolve || cmd == socks.CmdResolvePTR {
|
||||
return &resolveConn{Conn: conn, resolvedAddr: reply.Addr}, nil
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func (p *tcpListener) Close() error {
|
||||
|
||||
type tcpMuxListener struct {
|
||||
addr net.Addr
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ func (c *socks5Connector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package v5
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/gosocks5"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// resolveConn wraps a raw net.Conn and carries the resolved address from a
|
||||
// Tor RESOLVE/RESOLVE_PTR reply as metadata. It implements net.Conn (passthrough)
|
||||
// and metadata.Metadatable so the handler can extract the resolved address.
|
||||
type resolveConn struct {
|
||||
net.Conn
|
||||
resolvedAddr *gosocks5.Addr
|
||||
}
|
||||
|
||||
func (c *resolveConn) Metadata() md.Metadata {
|
||||
return xmd.NewMetadata(map[string]any{
|
||||
"resolvedAddr": c.resolvedAddr,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package v5
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/go-gost/gosocks5"
|
||||
)
|
||||
|
||||
type dummyConn struct{ net.Conn }
|
||||
|
||||
func TestResolveConn_Metadata(t *testing.T) {
|
||||
addr := &gosocks5.Addr{Type: gosocks5.AddrIPv4, Host: "93.184.216.34", Port: 0}
|
||||
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: addr}
|
||||
|
||||
md := rc.Metadata()
|
||||
if md == nil {
|
||||
t.Fatal("Metadata() returned nil")
|
||||
}
|
||||
|
||||
v := md.Get("resolvedAddr")
|
||||
if v == nil {
|
||||
t.Fatal("resolvedAddr key not present in metadata")
|
||||
}
|
||||
|
||||
got, ok := v.(*gosocks5.Addr)
|
||||
if !ok {
|
||||
t.Fatalf("resolvedAddr is %T, want *gosocks5.Addr", v)
|
||||
}
|
||||
if got.Host != "93.184.216.34" {
|
||||
t.Errorf("Host = %q, want %q", got.Host, "93.184.216.34")
|
||||
}
|
||||
if got.Type != gosocks5.AddrIPv4 {
|
||||
t.Errorf("Type = %d, want %d", got.Type, gosocks5.AddrIPv4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConn_Metadata_NilAddr(t *testing.T) {
|
||||
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: nil}
|
||||
|
||||
md := rc.Metadata()
|
||||
v := md.Get("resolvedAddr")
|
||||
addr, ok := v.(*gosocks5.Addr)
|
||||
if !ok {
|
||||
t.Fatalf("resolvedAddr has type %T, want *gosocks5.Addr", v)
|
||||
}
|
||||
if addr != nil {
|
||||
t.Errorf("resolvedAddr = %v, want nil", addr)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/go-gost/core/common/bufpool"
|
||||
"github.com/go-gost/core/connector"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/go-shadowsocks2/core"
|
||||
@@ -97,10 +96,13 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
rawaddr := bufpool.Get(512)
|
||||
defer bufpool.Put(rawaddr)
|
||||
// The wrapped conn stores this target and only writes it on first I/O,
|
||||
// so it must be owned by this connection, not a shared pooled buffer
|
||||
// (see go-gost/gost#896).
|
||||
const maxAddrLen = 1 + 1 + 255 + 2 // SOCKS5 addr: atyp + len + host + port
|
||||
|
||||
n, err := addr.Encode(rawaddr)
|
||||
target := make([]byte, maxAddrLen)
|
||||
n, err := addr.Encode(target)
|
||||
if err != nil {
|
||||
log.Error("encoding addr: ", err)
|
||||
return nil, err
|
||||
@@ -111,8 +113,7 @@ func (c *ssConnector) Connect(ctx context.Context, conn net.Conn, network, addre
|
||||
defer conn.SetDeadline(time.Time{})
|
||||
}
|
||||
|
||||
target := socks.Addr(rawaddr[:n])
|
||||
conn, err = c.client.WrapConn(conn, target)
|
||||
conn, err = c.client.WrapConn(conn, socks.Addr(target[:n]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/connector"
|
||||
"github.com/go-gost/go-shadowsocks2/socks"
|
||||
"github.com/go-gost/x/internal/util/ss/none"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
mdx "github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// captureConn records everything written to it, so a test can inspect the
|
||||
// bytes a connector actually puts on the wire.
|
||||
type captureConn struct {
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (c *captureConn) Read(b []byte) (int, error) { return 0, io.EOF }
|
||||
func (c *captureConn) Write(b []byte) (int, error) { return c.buf.Write(b) }
|
||||
func (c *captureConn) Close() error { return nil }
|
||||
func (c *captureConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *captureConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *captureConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *captureConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *captureConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
// TestConnectTargetNotOverwrittenByPool is a regression test for
|
||||
// https://github.com/go-gost/gost/issues/896: the connector previously passed
|
||||
// a slice aliasing the bufpool buffer to WrapConn, and the buffer was returned
|
||||
// to the pool before the wrapped conn lazily wrote the target on first I/O. A
|
||||
// second Connect could then overwrite the bytes and the first conn would send
|
||||
// the second conn's target. The none cipher keeps the wire plaintext so the
|
||||
// written target is directly assertable.
|
||||
func TestConnectTargetNotOverwrittenByPool(t *testing.T) {
|
||||
c := NewConnector(
|
||||
connector.AuthOption(url.UserPassword("none", "x")),
|
||||
connector.LoggerOption(xlogger.NewLogger()),
|
||||
)
|
||||
if err := c.Init(mdx.NewMetadata(nil)); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cap1 := &captureConn{}
|
||||
conn1, err := c.Connect(ctx, cap1, "tcp", "1.2.3.4:80")
|
||||
if err != nil {
|
||||
t.Fatalf("first Connect: %v", err)
|
||||
}
|
||||
|
||||
// Second Connect reuses the same 512-byte bufpool buffer that conn1's
|
||||
// target used to alias (single pool tier, same goroutine).
|
||||
if _, err := c.Connect(ctx, &captureConn{}, "tcp", "5.6.7.8:443"); err != nil {
|
||||
t.Fatalf("second Connect: %v", err)
|
||||
}
|
||||
|
||||
if _, err := conn1.Write([]byte("X")); err != nil {
|
||||
t.Fatalf("first Write: %v", err)
|
||||
}
|
||||
|
||||
// initWriter frames the target as [salt][2-byte length][addr].
|
||||
data := cap1.buf.Bytes()
|
||||
payload := data[none.Cipher.SaltSize():]
|
||||
if len(payload) < 2 {
|
||||
t.Fatalf("captured payload too short: %d bytes: %x", len(payload), data)
|
||||
}
|
||||
n := int(payload[0])<<8 | int(payload[1])
|
||||
if len(payload) < 2+n {
|
||||
t.Fatalf("captured payload too short: len=%d, need %d: %x", len(payload), 2+n, data)
|
||||
}
|
||||
addr := socks.SplitAddr(payload[2 : 2+n])
|
||||
if got, want := addr.String(), "1.2.3.4:80"; got != want {
|
||||
t.Fatalf("first conn wrote target %q, want %q (captured %x)", got, want, data)
|
||||
}
|
||||
}
|
||||
@@ -69,8 +69,8 @@ func (c *tunnelConnector) initTunnel(conn net.Conn, network, address string) (ad
|
||||
req.Features = append(req.Features, af)
|
||||
|
||||
af = &relay.AddrFeature{}
|
||||
if h, _, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", address)
|
||||
if h, p, e := net.SplitHostPort(address); e == nil && h == "" {
|
||||
address = net.JoinHostPort("0.0.0.0", p)
|
||||
}
|
||||
af.ParseFrom(address)
|
||||
req.Features = append(req.Features, af) // dst address
|
||||
@@ -78,6 +78,11 @@ func (c *tunnelConnector) initTunnel(conn net.Conn, network, address string) (ad
|
||||
req.Features = append(req.Features, &relay.TunnelFeature{
|
||||
ID: c.md.tunnelID,
|
||||
})
|
||||
if c.md.recordMode != "" {
|
||||
req.Features = append(req.Features, &relay.MetadataFeature{
|
||||
KVs: map[string]string{"record.mode": c.md.recordMode},
|
||||
})
|
||||
}
|
||||
if _, err = req.WriteTo(conn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/go-gost/core/common/bufpool"
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
"github.com/go-gost/relay"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xrelay "github.com/go-gost/x/internal/util/relay"
|
||||
)
|
||||
|
||||
@@ -94,6 +96,14 @@ func (c *bindConn) Metadata() mdata.Metadata {
|
||||
return c.md
|
||||
}
|
||||
|
||||
// Context implements xctx.Context, exposing the connection metadata (e.g. the
|
||||
// tunneled hostname) to the handler's context. The service accept loop derives
|
||||
// the handler context from a conn that implements this interface, which lets
|
||||
// reverse-forward handlers (rtcp) route on the hostname carried by the tunnel.
|
||||
func (c *bindConn) Context() context.Context {
|
||||
return ictx.ContextWithMetadata(context.Background(), c.md)
|
||||
}
|
||||
|
||||
type bindUDPConn struct {
|
||||
net.Conn
|
||||
localAddr net.Addr
|
||||
@@ -162,6 +172,12 @@ func (c *bindUDPConn) Metadata() mdata.Metadata {
|
||||
return c.md
|
||||
}
|
||||
|
||||
// Context implements xctx.Context, exposing the connection metadata (e.g. the
|
||||
// tunneled hostname) to the handler's context. See bindConn.Context.
|
||||
func (c *bindUDPConn) Context() context.Context {
|
||||
return ictx.ContextWithMetadata(context.Background(), c.md)
|
||||
}
|
||||
|
||||
type bindAddr struct {
|
||||
network string
|
||||
addr string
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
mdata "github.com/go-gost/core/metadata"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
// TestBindConnContext verifies that a bindConn exposes its connection metadata
|
||||
// (the tunneled hostname) via the xctx.Context interface, so the service accept
|
||||
// loop forwards it into the handler context for filter.host routing (gost#898).
|
||||
func TestBindConnContext(t *testing.T) {
|
||||
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
|
||||
cn := &bindConn{
|
||||
Conn: &net.TCPConn{},
|
||||
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
|
||||
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
|
||||
md: md,
|
||||
}
|
||||
|
||||
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
|
||||
if got != "example.local:80" {
|
||||
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindUDPConnContext is the same check for the UDP reverse-tunnel conn.
|
||||
func TestBindUDPConnContext(t *testing.T) {
|
||||
md := mdata.Metadata(xmd.NewMetadata(map[string]any{"host": "example.local:80"}))
|
||||
cn := &bindUDPConn{
|
||||
Conn: &net.TCPConn{},
|
||||
localAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
|
||||
remoteAddr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80},
|
||||
md: md,
|
||||
}
|
||||
|
||||
got := mdutil.GetString(ictx.MetadataFromContext(cn.Context()), "host")
|
||||
if got != "example.local:80" {
|
||||
t.Errorf("host from Context() = %q, want %q", got, "example.local:80")
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,11 @@ func (c *tunnelConnector) Connect(ctx context.Context, conn net.Conn, network, a
|
||||
req.Features = append(req.Features, &relay.TunnelFeature{
|
||||
ID: c.md.tunnelID,
|
||||
})
|
||||
if c.md.recordMode != "" {
|
||||
req.Features = append(req.Features, &relay.MetadataFeature{
|
||||
KVs: map[string]string{"record.mode": c.md.recordMode},
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := req.WriteTo(conn); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type bindListener struct {
|
||||
network string
|
||||
addr net.Addr
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type metadata struct {
|
||||
connectTimeout time.Duration
|
||||
tunnelID relay.TunnelID
|
||||
muxCfg *mux.Config
|
||||
recordMode string
|
||||
}
|
||||
|
||||
func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
@@ -44,6 +45,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
c.md.tunnelID = c.md.tunnelID.SetWeight(uint8(weight))
|
||||
}
|
||||
|
||||
c.md.recordMode = mdutil.GetString(md, "record.mode")
|
||||
|
||||
c.md.muxCfg = &mux.Config{
|
||||
Version: mdutil.GetInt(md, "mux.version"),
|
||||
KeepAliveInterval: mdutil.GetDuration(md, "mux.keepaliveInterval"),
|
||||
@@ -52,6 +55,8 @@ func (c *tunnelConnector) parseMetadata(md mdata.Metadata) (err error) {
|
||||
MaxFrameSize: mdutil.GetInt(md, "mux.maxFrameSize"),
|
||||
MaxReceiveBuffer: mdutil.GetInt(md, "mux.maxReceiveBuffer"),
|
||||
MaxStreamBuffer: mdutil.GetInt(md, "mux.maxStreamBuffer"),
|
||||
Type: mdutil.GetString(md, "mux.type"),
|
||||
MaxStreamWindow: mdutil.GetInt(md, "mux.maxStreamWindow"),
|
||||
}
|
||||
if c.md.muxCfg.Version == 0 {
|
||||
c.md.muxCfg.Version = 2
|
||||
|
||||
@@ -101,6 +101,42 @@ func ClientIDFromContext(ctx context.Context) ClientID {
|
||||
return v
|
||||
}
|
||||
|
||||
type peerCertKey struct{}
|
||||
|
||||
// PeerCert carries the verified mTLS client certificate identity.
|
||||
type PeerCert struct {
|
||||
CN string
|
||||
SANs []string
|
||||
Fingerprint string // SHA-256 hex of cert.Raw
|
||||
}
|
||||
|
||||
// ContextWithPeerCert returns a copy of ctx that carries the peer cert.
|
||||
func ContextWithPeerCert(ctx context.Context, cert *PeerCert) context.Context {
|
||||
return context.WithValue(ctx, peerCertKey{}, cert)
|
||||
}
|
||||
|
||||
// PeerCertFromContext returns the peer cert stored in ctx, or nil.
|
||||
func PeerCertFromContext(ctx context.Context) *PeerCert {
|
||||
v, _ := ctx.Value(peerCertKey{}).(*PeerCert)
|
||||
return v
|
||||
}
|
||||
|
||||
type socks5CmdKey struct{}
|
||||
|
||||
// ContextWithSocks5Cmd returns a copy of ctx that carries a SOCKS5 command
|
||||
// override. Connectors use this to send a non-CONNECT command (e.g. Tor
|
||||
// RESOLVE/RESOLVE_PTR) to the upstream proxy.
|
||||
func ContextWithSocks5Cmd(ctx context.Context, cmd uint8) context.Context {
|
||||
return context.WithValue(ctx, socks5CmdKey{}, cmd)
|
||||
}
|
||||
|
||||
// Socks5CmdFromContext returns the SOCKS5 command override stored in ctx.
|
||||
// The bool is false if no override is present.
|
||||
func Socks5CmdFromContext(ctx context.Context) (uint8, bool) {
|
||||
v, ok := ctx.Value(socks5CmdKey{}).(uint8)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// labelsKey saves the static service labels.
|
||||
type labelsKey struct{}
|
||||
|
||||
|
||||
@@ -150,6 +150,49 @@ func TestClientIDFromContext_WrongType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertContext(t *testing.T) {
|
||||
cert := &PeerCert{
|
||||
CN: "client.example.com",
|
||||
SANs: []string{"san1.example.com", "san2.example.com"},
|
||||
Fingerprint: "abcdef1234567890",
|
||||
}
|
||||
ctx := ContextWithPeerCert(context.Background(), cert)
|
||||
|
||||
got := PeerCertFromContext(ctx)
|
||||
if got == nil {
|
||||
t.Fatal("PeerCertFromContext() = nil, want non-nil")
|
||||
}
|
||||
if got.CN != cert.CN {
|
||||
t.Errorf("PeerCertFromContext().CN = %q, want %q", got.CN, cert.CN)
|
||||
}
|
||||
if len(got.SANs) != len(cert.SANs) || got.SANs[0] != cert.SANs[0] {
|
||||
t.Errorf("PeerCertFromContext().SANs = %v, want %v", got.SANs, cert.SANs)
|
||||
}
|
||||
if got.Fingerprint != cert.Fingerprint {
|
||||
t.Errorf("PeerCertFromContext().Fingerprint = %q, want %q", got.Fingerprint, cert.Fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertFromContext_Empty(t *testing.T) {
|
||||
if got := PeerCertFromContext(context.Background()); got != nil {
|
||||
t.Errorf("PeerCertFromContext(empty) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertFromContext_WrongType(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), peerCertKey{}, "not-a-cert")
|
||||
if got := PeerCertFromContext(ctx); got != nil {
|
||||
t.Errorf("PeerCertFromContext(wrong type) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCertFromContext_Nil(t *testing.T) {
|
||||
ctx := ContextWithPeerCert(context.Background(), nil)
|
||||
if got := PeerCertFromContext(ctx); got != nil {
|
||||
t.Errorf("PeerCertFromContext(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithLabels(t *testing.T) {
|
||||
labels := map[string]string{"tenant": "acme", "region": "eu"}
|
||||
ctx := ContextWithLabels(context.Background(), labels)
|
||||
@@ -218,6 +261,33 @@ func (c *testContextConn) Context() context.Context {
|
||||
return c.ctx
|
||||
}
|
||||
|
||||
func TestContextWithSocks5Cmd(t *testing.T) {
|
||||
ctx := ContextWithSocks5Cmd(context.Background(), 0xF0)
|
||||
|
||||
cmd, ok := Socks5CmdFromContext(ctx)
|
||||
if !ok {
|
||||
t.Fatal("Socks5CmdFromContext() ok = false, want true")
|
||||
}
|
||||
if cmd != 0xF0 {
|
||||
t.Errorf("Socks5CmdFromContext() = 0x%02X, want 0xF0", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocks5CmdFromContext_Empty(t *testing.T) {
|
||||
_, ok := Socks5CmdFromContext(context.Background())
|
||||
if ok {
|
||||
t.Error("Socks5CmdFromContext(empty) ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocks5CmdFromContext_WrongType(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), socks5CmdKey{}, "not-a-uint8")
|
||||
_, ok := Socks5CmdFromContext(ctx)
|
||||
if ok {
|
||||
t.Error("Socks5CmdFromContext(wrong type) ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextInterface(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx = ContextWithSid(ctx, Sid("iface-test"))
|
||||
|
||||
@@ -97,11 +97,10 @@ func (d *http3Dialer) Dial(ctx context.Context, addr string, opts ...dialer.Dial
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthorizePath: d.md.authorizePath,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: true,
|
||||
Logger: d.options.Logger,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: true,
|
||||
Logger: d.options.Logger,
|
||||
}
|
||||
|
||||
d.clients[addr] = client
|
||||
|
||||
@@ -102,6 +102,15 @@ func (c *MasqueConn) GetRequestStream() *http3.RequestStream {
|
||||
return c.reqStream
|
||||
}
|
||||
|
||||
// OpenRequestStream opens another request stream on the shared HTTP/3 connection.
|
||||
// CONNECT-UDP associations use one stream per destination.
|
||||
func (c *MasqueConn) OpenRequestStream(ctx context.Context) (*http3.RequestStream, error) {
|
||||
if c.clientConn == nil {
|
||||
return nil, errors.New("masque: connection is closed")
|
||||
}
|
||||
return c.clientConn.OpenRequestStream(ctx)
|
||||
}
|
||||
|
||||
// GetHost returns the proxy host.
|
||||
func (c *MasqueConn) GetHost() string {
|
||||
return c.host
|
||||
|
||||
@@ -9,16 +9,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAuthorizePath = "/authorize"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
authorizePath string
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
|
||||
// QUIC config options
|
||||
keepAlivePeriod time.Duration
|
||||
@@ -36,10 +34,6 @@ func (d *http3Dialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
maxStreams = "maxStreams"
|
||||
)
|
||||
|
||||
d.md.authorizePath = mdutil.GetString(md, "pht.authorizePath", "authorizePath")
|
||||
if !strings.HasPrefix(d.md.authorizePath, "/") {
|
||||
d.md.authorizePath = defaultAuthorizePath
|
||||
}
|
||||
d.md.pushPath = mdutil.GetString(md, "pht.pushPath", "pushPath")
|
||||
if !strings.HasPrefix(d.md.pushPath, "/") {
|
||||
d.md.pushPath = defaultPushPath
|
||||
|
||||
+5
-2
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -11,8 +12,10 @@ type quicSession struct {
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
|
||||
d.sessionMutex.Lock()
|
||||
defer d.sessionMutex.Unlock()
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
@@ -95,6 +94,7 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
pc, err = icmp_pkg.ListenPacket("ip4:icmp", "")
|
||||
}
|
||||
if err != nil {
|
||||
d.sessionMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,16 +109,22 @@ func (d *icmpDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
if err != nil {
|
||||
d.logger.Error(err)
|
||||
pc.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.sessions[addr] = session
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
conn, err = session.GetConn(ctx)
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
delete(d.sessions, addr)
|
||||
}
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -14,14 +14,24 @@ type metadata struct {
|
||||
}
|
||||
|
||||
func (d *icmpDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
if mdutil.GetBool(md, "keepalive") {
|
||||
d.md.keepAlivePeriod = mdutil.GetDuration(md, "ttl")
|
||||
const (
|
||||
keepAlive = "keepAlive"
|
||||
keepAlivePeriod = "ttl"
|
||||
handshakeTimeout = "handshakeTimeout"
|
||||
maxIdleTimeout = "maxIdleTimeout"
|
||||
)
|
||||
|
||||
if md == nil || !md.IsExists(keepAlive) || mdutil.GetBool(md, keepAlive) {
|
||||
d.md.keepAlivePeriod = mdutil.GetDuration(md, keepAlivePeriod)
|
||||
if d.md.keepAlivePeriod <= 0 {
|
||||
d.md.keepAlivePeriod = 10 * time.Second
|
||||
}
|
||||
}
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, "handshakeTimeout")
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, "maxIdleTimeout")
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
|
||||
if d.md.maxIdleTimeout <= 0 {
|
||||
d.md.maxIdleTimeout = 90 * time.Second
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+1
-5
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type muxSession struct {
|
||||
conn net.Conn
|
||||
session *mux.Session
|
||||
session mux.Session
|
||||
}
|
||||
|
||||
func (session *muxSession) GetConn() (net.Conn, error) {
|
||||
@@ -32,7 +32,3 @@ func (session *muxSession) IsClosed() bool {
|
||||
}
|
||||
return session.session.IsClosed()
|
||||
}
|
||||
|
||||
func (session *muxSession) NumStreams() int {
|
||||
return session.session.NumStreams()
|
||||
}
|
||||
|
||||
+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")
|
||||
|
||||
@@ -105,12 +105,11 @@ func (d *phtDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialOp
|
||||
// Timeout: 60 * time.Second,
|
||||
Transport: tr,
|
||||
},
|
||||
AuthorizePath: d.md.authorizePath,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: d.tlsEnabled,
|
||||
Header: d.md.header,
|
||||
Logger: d.options.Logger,
|
||||
PushPath: d.md.pushPath,
|
||||
PullPath: d.md.pullPath,
|
||||
TLSEnabled: d.tlsEnabled,
|
||||
Header: d.md.header,
|
||||
Logger: d.options.Logger,
|
||||
}
|
||||
d.clients[addr] = client
|
||||
}
|
||||
|
||||
+10
-18
@@ -9,33 +9,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
dialTimeout = "dialTimeout"
|
||||
defaultAuthorizePath = "/authorize"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
defaultPushPath = "/push"
|
||||
defaultPullPath = "/pull"
|
||||
)
|
||||
|
||||
type metadata struct {
|
||||
authorizePath string
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
header http.Header
|
||||
pushPath string
|
||||
pullPath string
|
||||
host string
|
||||
header http.Header
|
||||
}
|
||||
|
||||
func (d *phtDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
const (
|
||||
authorizePath = "authorizePath"
|
||||
pushPath = "pushPath"
|
||||
pullPath = "pullPath"
|
||||
host = "host"
|
||||
header = "header"
|
||||
pushPath = "pushPath"
|
||||
pullPath = "pullPath"
|
||||
host = "host"
|
||||
header = "header"
|
||||
)
|
||||
|
||||
d.md.authorizePath = mdutil.GetString(md, authorizePath)
|
||||
if !strings.HasPrefix(d.md.authorizePath, "/") {
|
||||
d.md.authorizePath = defaultAuthorizePath
|
||||
}
|
||||
d.md.pushPath = mdutil.GetString(md, pushPath)
|
||||
if !strings.HasPrefix(d.md.pushPath, "/") {
|
||||
d.md.pushPath = defaultPushPath
|
||||
|
||||
+5
-2
@@ -3,6 +3,7 @@ package quic
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
@@ -11,8 +12,10 @@ type quicSession struct {
|
||||
session *quic.Conn
|
||||
}
|
||||
|
||||
func (session *quicSession) GetConn() (*quicConn, error) {
|
||||
stream, err := session.session.OpenStreamSync(context.Background())
|
||||
func (session *quicSession) GetConn(ctx context.Context) (*quicConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
stream, err := session.session.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+10
-3
@@ -58,7 +58,6 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
}
|
||||
|
||||
d.sessionMutex.Lock()
|
||||
defer d.sessionMutex.Unlock()
|
||||
|
||||
session, ok := d.sessions[addr]
|
||||
if session != nil && session.IsClosed() {
|
||||
@@ -73,11 +72,13 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
|
||||
c, err := options.Dialer.Dial(ctx, "udp", "")
|
||||
if err != nil {
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
pc, ok := c.(net.PacketConn)
|
||||
if !ok {
|
||||
c.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, errors.New("quic: wrong connection type")
|
||||
}
|
||||
|
||||
@@ -89,16 +90,22 @@ func (d *quicDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
if err != nil {
|
||||
d.logger.Error(err)
|
||||
pc.Close()
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.sessions[addr] = session
|
||||
}
|
||||
d.sessionMutex.Unlock()
|
||||
|
||||
conn, err = session.GetConn()
|
||||
conn, err = session.GetConn(ctx)
|
||||
if err != nil {
|
||||
d.sessionMutex.Lock()
|
||||
if d.sessions[addr] == session {
|
||||
delete(d.sessions, addr)
|
||||
}
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
d.sessionMutex.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ func (d *quicDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
}
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
|
||||
d.md.maxIdleTimeout = mdutil.GetDuration(md, maxIdleTimeout)
|
||||
if d.md.maxIdleTimeout <= 0 {
|
||||
d.md.maxIdleTimeout = 90 * time.Second
|
||||
}
|
||||
d.md.maxStreams = mdutil.GetInt(md, maxStreams)
|
||||
d.md.enableDatagram = mdutil.GetBool(md, "quic.enableDatagram", "enableDatagram")
|
||||
|
||||
|
||||
@@ -63,6 +63,18 @@ func (d *sshdDialer) Dial(ctx context.Context, addr string, opts ...dialer.DialO
|
||||
delete(d.sessions, addr) // session is dead
|
||||
ok = false
|
||||
}
|
||||
if ok {
|
||||
// Health check: verify the cached session is still alive before
|
||||
// returning it. A dead-but-not-yet-detected session (e.g. idle
|
||||
// connection silently dropped by NAT) would cause "ssh: unexpected
|
||||
// packet in response to channel open" later in the connector.
|
||||
if err := session.Ping(d.md.keepaliveTimeout); err != nil {
|
||||
d.options.Logger.Debugf("sshd session ping failed: %v, recreating", err)
|
||||
session.Close()
|
||||
delete(d.sessions, addr)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
var options dialer.DialOptions
|
||||
for _, opt := range opts {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user