fix: protect shared rules across delivery and recovery (#560)

This commit is contained in:
sagit
2026-09-29 14:05:49 +08:00
committed by GitHub
parent 129fa0aa4c
commit c952d2fb3a
49 changed files with 5053 additions and 643 deletions
+5 -3
View File
@@ -125,11 +125,13 @@ func main() {
distro := socket.DetectDistro()
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
p := &program{}
p := &program{
startReporter: func() reporter {
return socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
},
}
if err := svc.Run(p); err != nil {
logger.Default().Fatal(err)
}
+88 -35
View File
@@ -3,6 +3,14 @@ package main
import (
"context"
"errors"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
@@ -18,20 +26,22 @@ import (
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/socket"
"github.com/judwhite/go-svc"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
type program struct {
srvApi service.Service
srvMetrics service.Service
srvProfiling *http.Server
type reporter interface {
Stop()
}
cancel context.CancelFunc
type program struct {
startReporter func() reporter
reporter reporter
srvApi service.Service
srvMetrics service.Service
srvProfiling *http.Server
profilingListener net.Listener
cancel context.CancelFunc
stopped bool
}
func (p *program) Init(env svc.Environment) error {
@@ -48,7 +58,15 @@ func (p *program) Init(env svc.Environment) error {
return nil
}
func (p *program) Start() error {
func (p *program) Start() (err error) {
unlock := config.LockMutation()
defer unlock()
p.stopped = false
defer func() {
if err != nil {
p.stopRuntime()
}
}()
cfg, err := parser.Parse()
if err != nil {
return err
@@ -61,23 +79,28 @@ func (p *program) Start() error {
os.Exit(0)
}
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
return err
}
// Enable config persistence after initial load so runtime mutations
// (AddService, UpdateService, DeleteService, etc.) are saved to disk.
socket.EnableConfigPersist()
if err := p.run(cfg); err != nil {
return err
}
config.Set(cfg)
socket.EnableConfigPersist()
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.reload(ctx)
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
go p.reload(ctx, c)
// A connected panel may immediately send commands. Only expose the agent
// after initial config loading, runtime startup and persistence are ready.
if p.startReporter != nil {
p.reporter = p.startReporter()
}
go func() {
select {
@@ -91,7 +114,14 @@ func (p *program) Start() error {
return nil
}
func (p *program) run(cfg *config.Config) error {
func (p *program) run(cfg *config.Config) (err error) {
defer func() {
if err != nil {
// Auxiliary listeners may occupy ports required by the rollback config.
// Release all resources opened by this attempt before rebuilding it.
p.stopRuntime()
}
}()
for _, svc := range registry.ServiceRegistry().GetAll() {
svc := svc
go func() {
@@ -152,6 +182,10 @@ func (p *program) run(cfg *config.Config) error {
if p.srvProfiling != nil {
p.srvProfiling.Close()
if p.profilingListener != nil {
p.profilingListener.Close()
p.profilingListener = nil
}
p.srvProfiling = nil
}
if cfg.Profiling != nil {
@@ -162,7 +196,12 @@ func (p *program) run(cfg *config.Config) error {
s := &http.Server{
Addr: addr,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
p.srvProfiling = s
p.profilingListener = ln
go func() {
defer s.Close()
@@ -170,7 +209,7 @@ func (p *program) run(cfg *config.Config) error {
log := logger.Default().WithFields(map[string]any{"kind": "service", "service": "@profiling"})
log.Info("listening on ", addr)
if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
if err := s.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
log.Error(err)
}
}()
@@ -184,30 +223,45 @@ func (p *program) Stop() error {
p.cancel()
}
for name, srv := range registry.ServiceRegistry().GetAll() {
srv.Close()
if p.reporter != nil {
p.reporter.Stop()
}
unlock := config.LockMutation()
defer unlock()
p.stopped = true
p.stopRuntime()
return nil
}
func (p *program) stopRuntime() {
for name := range registry.ServiceRegistry().GetAll() {
registry.ServiceRegistry().Unregister(name)
logger.Default().Debugf("service %s shutdown", name)
}
if p.srvApi != nil {
p.srvApi.Close()
p.srvApi = nil
logger.Default().Debug("service @api shutdown")
}
if p.srvMetrics != nil {
p.srvMetrics.Close()
p.srvMetrics = nil
logger.Default().Debug("service @metrics shutdown")
}
if p.srvProfiling != nil {
p.srvProfiling.Close()
if p.profilingListener != nil {
p.profilingListener.Close()
p.profilingListener = nil
}
p.srvProfiling = nil
logger.Default().Debug("service @profiling shutdown")
}
return nil
}
func (p *program) reload(ctx context.Context) {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
func (p *program) reload(ctx context.Context, c chan os.Signal) {
defer signal.Stop(c)
for {
select {
@@ -225,13 +279,16 @@ func (p *program) reload(ctx context.Context) {
}
func (p *program) reloadConfig() error {
unlock := config.LockMutation()
defer unlock()
if p.stopped {
return errors.New("agent is shutting down")
}
cfg, err := parser.Parse()
if err != nil {
return err
}
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
if err := loader.Reload(cfg, p.run); err != nil {
return err
}
activeServices := make(map[string]struct{}, len(cfg.Services))
@@ -242,10 +299,6 @@ func (p *program) reloadConfig() error {
}
xservice.GetGlobalTrafficManager().RetainServices(activeServices)
if err := p.run(cfg); err != nil {
return err
}
return nil
}
+313
View File
@@ -0,0 +1,313 @@
//go:build linux || darwin
package lifecycle_test
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/gorilla/websocket"
)
// Exercise the real binary: initial parsing is held at a FIFO while the panel
// tries to send a rule as soon as the WebSocket connects. This reproduced the
// old startup overwrite reliably without timing a large config load.
func TestAgentStartupAndFailedReloadPreservePanelRules(t *testing.T) {
if testing.Short() {
t.Skip("builds and runs the real agent")
}
binary := filepath.Join(t.TempDir(), "gost")
build := exec.Command("go", "build", "-o", binary, "../..")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build: %v\n%s", err, output)
}
dir := t.TempDir()
early, baseline := address(t), address(t)
apiAddr := address(t)
panelConnections := make(chan *websocket.Conn, 2)
wsReady := make(chan struct{})
responses := make(chan map[string]any, 8)
var ready sync.Once
upgrader := websocket.Upgrader{}
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/system-info" {
w.Write([]byte("ok"))
return
}
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
ready.Do(func() { close(wsReady) })
panelConnections <- c
if err := c.WriteJSON(map[string]any{"type": "AddService", "requestId": "early-rule", "data": []any{service("70_1_0", early)}}); err != nil {
return
}
for {
_, payload, err := c.ReadMessage()
if err != nil {
return
}
env := decodeResponse(t, payload)
if env["requestId"] == "early-rule" {
responses <- env
}
}
}))
defer panel.Close()
writeJSON(t, filepath.Join(dir, "config.json"), map[string]any{"addr": panel.URL, "secret": "audit-secret", "http": 1, "tls": 1, "socks": 1})
fifo := filepath.Join(dir, "delayed.json")
if err := syscall.Mkfifo(fifo, 0600); err != nil {
t.Fatal(err)
}
logPath := filepath.Join(dir, "agent.log")
logfile, err := os.Create(logPath)
if err != nil {
t.Fatal(err)
}
defer logfile.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
agent := exec.CommandContext(ctx, binary, "-C", fifo)
agent.Dir, agent.Stdout, agent.Stderr = dir, logfile, logfile
if err := agent.Start(); err != nil {
t.Fatal(err)
}
exited := make(chan error, 1)
go func() { exited <- agent.Wait() }()
defer func() {
agent.Process.Kill()
if t.Failed() {
b, _ := os.ReadFile(logPath)
t.Logf("agent log:\n%s", b)
}
}()
select {
case <-wsReady:
t.Fatal("panel connected before initial config loaded")
case err := <-exited:
t.Fatalf("agent exited during startup: %v", err)
case <-time.After(500 * time.Millisecond):
}
boot := map[string]any{"services": []any{service("71_1_0", baseline)}, "api": map[string]any{"addr": apiAddr}}
writeJSON(t, filepath.Join(dir, "gost.json"), boot)
writeFIFO(t, fifo, boot)
select {
case <-wsReady:
case <-time.After(10 * time.Second):
t.Fatal("panel did not connect after startup")
}
select {
case response := <-responses:
if response["success"] != true {
t.Fatalf("AddService failed: %v", response)
}
case <-time.After(5 * time.Second):
t.Fatal("missing AddService response")
}
await(t, "both startup and panel listeners", func() bool { return listening(early) && listening(baseline) })
saved, err := os.ReadFile(fifo)
if err != nil || !strings.Contains(string(saved), "70_1_0") {
t.Fatalf("acknowledged rule not persisted: %v", err)
}
// A valid listener plus an invalid handler also exercises rollback of a
// partially initialized service which already bound the original port.
invalid := service("71_1_0", baseline)
invalid["handler"] = map[string]any{"type": "handler-does-not-exist"}
// The first persisted mutation atomically replaced the FIFO with a regular
// config file, so subsequent reloads use the same real persistence path.
writeJSON(t, fifo, map[string]any{"services": []any{invalid}})
if err := agent.Process.Signal(syscall.SIGHUP); err != nil {
t.Fatal(err)
}
await(t, "failed reload rollback", func() bool {
b, _ := os.ReadFile(logPath)
return strings.Contains(string(b), "previous config restored")
})
if !listening(early) || !listening(baseline) {
t.Fatal("failed reload lost a previously acknowledged listener")
}
// Starting the candidate API on the old service port must not prevent
// rollback when a later auxiliary listener fails to initialize.
logBefore, _ := os.ReadFile(logPath)
writeJSON(t, fifo, map[string]any{
"services": []any{service("candidate", address(t))},
"api": map[string]any{"addr": baseline},
"metrics": map[string]any{"addr": "127.0.0.1:not-a-port"},
})
if err := agent.Process.Signal(syscall.SIGHUP); err != nil {
t.Fatal(err)
}
await(t, "auxiliary listener rollback", func() bool {
b, _ := os.ReadFile(logPath)
return strings.Count(string(b), "previous config restored") > strings.Count(string(logBefore), "previous config restored")
})
if !listening(early) || !listening(baseline) || !listening(apiAddr) {
t.Fatal("candidate auxiliary listener prevented rollback")
}
// An authenticated API request that never finishes its body must not hold
// the runtime transaction lock against WS mutations or process shutdown.
slow, err := net.Dial("tcp", apiAddr)
if err != nil {
t.Fatal(err)
}
defer slow.Close()
if _, err = fmt.Fprintf(slow, "POST /config/services HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic dGVzdDp0ZXN0\r\nContent-Type: application/json\r\nContent-Length: 100000\r\n\r\n{"); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
panelConn := <-panelConnections
if err := panelConn.WriteJSON(map[string]any{"type": "DeleteService", "requestId": "slow-body-check", "data": map[string]any{"services": []string{"70_1_0"}}}); err != nil {
t.Fatal(err)
}
await(t, "WS mutation despite slow API upload", func() bool { return !listening(early) })
if err := agent.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
select {
case err := <-exited:
if err != nil {
t.Fatalf("shutdown: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("agent did not stop")
}
if listening(early) || listening(baseline) {
t.Fatal("shutdown left listeners open")
}
// Startup errors must exit without advertising a node ready to accept rules.
bad := filepath.Join(dir, "invalid.json")
writeJSON(t, bad, map[string]any{"services": []any{invalid}})
failed := exec.CommandContext(ctx, binary, "-C", bad)
failed.Dir = dir
if output, err := failed.CombinedOutput(); err == nil {
t.Fatalf("invalid startup succeeded: %s", output)
}
select {
case response := <-responses:
t.Fatalf("failed startup accepted panel command: %v", response)
default:
}
}
func service(name, addr string) map[string]any {
return map[string]any{"name": name, "addr": addr, "listener": map[string]any{"type": "tcp"}, "handler": map[string]any{"type": "auto"}}
}
func address(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().String()
}
func listening(addr string) bool {
c, err := net.DialTimeout("tcp", addr, 50*time.Millisecond)
if err != nil {
return false
}
c.Close()
return true
}
func await(t *testing.T, label string, pred func() bool) {
t.Helper()
for until := time.Now().Add(5 * time.Second); time.Now().Before(until); time.Sleep(10 * time.Millisecond) {
if pred() {
return
}
}
t.Fatal("timeout: " + label)
}
func writeJSON(t *testing.T, path string, value any) {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, b, 0600); err != nil {
t.Fatal(err)
}
}
func writeFIFO(t *testing.T, path string, value any) {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
done <- err
return
}
_, err = f.Write(b)
f.Close()
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("agent did not read config FIFO")
}
}
func decodeResponse(t *testing.T, payload []byte) map[string]any {
t.Helper()
env := map[string]any{}
if err := json.Unmarshal(payload, &env); err != nil {
t.Error(err)
return nil
}
if encrypted, _ := env["encrypted"].(bool); !encrypted {
return env
}
encoded, _ := env["data"].(string)
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Error(err)
return nil
}
hash := sha256.Sum256([]byte("audit-secret"))
block, _ := aes.NewCipher(hash[:])
gcm, _ := cipher.NewGCM(block)
if len(raw) < gcm.NonceSize() {
t.Error("short encrypted response")
return nil
}
payload, err = gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
t.Error(err)
return nil
}
env = map[string]any{}
if err := json.Unmarshal(payload, &env); err != nil {
t.Error(err)
return nil
}
return env
}
+1 -1
View File
@@ -52,7 +52,7 @@ func Register(r *gin.Engine, opts *Options) {
router.StaticFS("/docs", http.FS(swaggerDoc))
config := router.Group("/config")
config.Use(mwBasicAuth(opts.Auther))
config.Use(mwBasicAuth(opts.Auther), configTransaction())
config.GET("", getConfig)
config.POST("", saveConfig)
+6 -10
View File
@@ -37,9 +37,12 @@ func reloadConfig(ctx *gin.Context) {
return
}
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
if err := loader.Reload(cfg, func(*config.Config) error {
for _, svc := range registry.ServiceRegistry().GetAll() {
go svc.Serve()
}
return nil
}); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, err.Error()))
return
}
@@ -51,13 +54,6 @@ func reloadConfig(ctx *gin.Context) {
}
xservice.GetGlobalTrafficManager().RetainServices(activeServices)
for _, svc := range registry.ServiceRegistry().GetAll() {
svc := svc
go func() {
svc.Serve()
}()
}
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"bytes"
"errors"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
)
const maxConfigRequestBody = 16 << 20
// Read the complete bounded request before acquiring the runtime transaction
// lock. Buffer the response until after it is released: neither a slow upload
// nor a client that stops reading may block panel commands, reload or shutdown.
func configTransaction() gin.HandlerFunc {
return func(ctx *gin.Context) {
if ctx.Request.Body != nil {
body := http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxConfigRequestBody)
data, err := io.ReadAll(body)
body.Close()
if err != nil {
status := http.StatusBadRequest
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
status = http.StatusRequestEntityTooLarge
}
ctx.AbortWithStatusJSON(status, Response{Code: status, Msg: "Unable to read configuration request"})
return
}
ctx.Request.Body = io.NopCloser(bytes.NewReader(data))
}
writer := ctx.Writer
buffered := &configResponseWriter{ResponseWriter: writer, header: writer.Header().Clone(), status: http.StatusOK, size: -1}
ctx.Writer = buffered
defer func() { ctx.Writer = writer }()
func() {
unlock := config.LockMutation()
defer unlock()
// A request waiting behind reload may have been closed during shutdown.
if ctx.Request.Context().Err() != nil {
ctx.Abort()
return
}
ctx.Next()
}()
ctx.Writer = writer
for key, values := range buffered.header {
writer.Header()[key] = values
}
writer.WriteHeader(buffered.status)
writer.Write(buffered.body.Bytes())
}
}
// Config endpoints return JSON rather than streaming. Preserve Gin's response
// bookkeeping while delaying all network writes until the transaction ends.
type configResponseWriter struct {
gin.ResponseWriter
header http.Header
body bytes.Buffer
status int
size int
}
func (w *configResponseWriter) Header() http.Header { return w.header }
func (w *configResponseWriter) WriteHeader(status int) {
if !w.Written() && status > 0 {
w.status = status
}
}
func (w *configResponseWriter) WriteHeaderNow() {
if !w.Written() {
w.size = 0
}
}
func (w *configResponseWriter) Write(p []byte) (int, error) {
w.WriteHeaderNow()
n, err := w.body.Write(p)
w.size += n
return n, err
}
func (w *configResponseWriter) WriteString(s string) (int, error) { return w.Write([]byte(s)) }
func (w *configResponseWriter) Status() int { return w.status }
func (w *configResponseWriter) Size() int { return w.size }
func (w *configResponseWriter) Written() bool { return w.size >= 0 }
func (w *configResponseWriter) Flush() { w.WriteHeaderNow() }
+76
View File
@@ -0,0 +1,76 @@
package api
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
)
func assertMutationAvailable(t *testing.T) {
t.Helper()
done := make(chan struct{})
go func() { unlock := config.LockMutation(); unlock(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("network I/O holds runtime mutation lock")
}
}
func TestConfigTransactionDoesNotLockWhileReadingBody(t *testing.T) {
router := gin.New()
router.Use(configTransaction())
router.POST("/config", func(c *gin.Context) { c.JSON(http.StatusOK, Response{Msg: "OK"}) })
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
request := httptest.NewRequest(http.MethodPost, "/config", reader)
done := make(chan struct{})
go func() { defer close(done); router.ServeHTTP(httptest.NewRecorder(), request) }()
if _, err := writer.Write([]byte("{")); err != nil {
t.Fatal(err)
}
assertMutationAvailable(t)
writer.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("handler did not finish")
}
}
type blockedResponse struct {
header http.Header
started chan struct{}
release chan struct{}
}
func (w *blockedResponse) Header() http.Header { return w.header }
func (w *blockedResponse) WriteHeader(int) {}
func (w *blockedResponse) Write(p []byte) (int, error) {
close(w.started)
<-w.release
return len(p), nil
}
func TestConfigTransactionReleasesLockBeforeSendingResponse(t *testing.T) {
router := gin.New()
router.Use(configTransaction())
router.POST("/config", func(c *gin.Context) { c.JSON(http.StatusOK, Response{Msg: "OK"}) })
writer := &blockedResponse{header: make(http.Header), started: make(chan struct{}), release: make(chan struct{})}
defer close(writer.release)
request := httptest.NewRequest(http.MethodPost, "/config", strings.NewReader("{}"))
go router.ServeHTTP(writer, request)
select {
case <-writer.started:
case <-time.After(time.Second):
t.Fatal("response did not start")
}
assertMutationAvailable(t)
}
+11 -2
View File
@@ -3,6 +3,7 @@ package service
import (
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-gost/core/auth"
@@ -67,7 +68,11 @@ func NewService(network, addr string, opts ...Option) (service.Service, error) {
return &server{
s: &http.Server{
Handler: r,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
},
ln: ln,
cclose: make(chan struct{}),
@@ -83,7 +88,11 @@ func (s *server) Addr() net.Addr {
}
func (s *server) Close() error {
return s.s.Close()
// Close can race the goroutine entering Serve during a failed startup.
// http.Server.Close alone does not own the listener until Serve starts.
err := s.s.Close()
s.ln.Close()
return err
}
func (s *server) IsClosed() bool {
+12 -2
View File
@@ -23,10 +23,20 @@ func init() {
}
var (
global = &Config{}
globalMux sync.RWMutex
global = &Config{}
globalMux sync.RWMutex
mutationMux sync.Mutex
)
// LockMutation serializes complete runtime/config transactions across panel
// commands, management API requests, startup and reload. It is separate from
// globalMux so callers may safely use Global, Set and OnUpdate while holding it.
// Lock before reading/parsing the config and hold through persistence/rollback.
func LockMutation() func() {
mutationMux.Lock()
return mutationMux.Unlock
}
func Global() *Config {
globalMux.RLock()
defer globalMux.RUnlock()
+37
View File
@@ -1,6 +1,8 @@
package loader
import (
"fmt"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
@@ -30,6 +32,34 @@ func Load(cfg *config.Config) error {
return defaultLoader.Load(cfg)
}
// Reload replaces the runtime and commits the config only after it starts.
// The caller must hold config.LockMutation, including while parsing cfg, so the
// rollback snapshot includes every previously acknowledged runtime command.
// Failed loads can partially replace registries and bind listeners; always
// rebuild the last successful snapshot before returning an error.
func Reload(cfg *config.Config, run func(*config.Config) error) error {
previous := config.Global()
if err := apply(cfg, run); err != nil {
// A failed partial load may have left both old and new listeners behind.
for name := range registry.ServiceRegistry().GetAll() {
registry.ServiceRegistry().Unregister(name)
}
if rollbackErr := apply(previous, run); rollbackErr != nil {
return fmt.Errorf("reload failed: %w; restore previous config failed: %v", err, rollbackErr)
}
return fmt.Errorf("reload failed (previous config restored): %w", err)
}
config.Set(cfg)
return nil
}
func apply(cfg *config.Config, run func(*config.Config) error) error {
if err := Load(cfg); err != nil {
return err
}
return run(cfg)
}
type loader struct{}
func (l *loader) Load(cfg *config.Config) error {
@@ -217,12 +247,19 @@ func register(cfg *config.Config) error {
registry.ServiceRegistry().Unregister(name)
}
for _, svcCfg := range cfg.Services {
if svcCfg == nil {
return fmt.Errorf("service config is nil")
}
if paused, _ := svcCfg.Metadata["paused"].(bool); paused {
continue
}
svc, err := service_parser.ParseService(svcCfg)
if err != nil {
return err
}
if svc != nil {
if err := registry.ServiceRegistry().Register(svcCfg.Name, svc); err != nil {
svc.Close()
return err
}
}
+84
View File
@@ -0,0 +1,84 @@
package loader_test
import (
"net"
"testing"
"time"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/loader"
_ "github.com/go-gost/x/handler/auto"
_ "github.com/go-gost/x/listener/tcp"
"github.com/go-gost/x/registry"
)
func TestReloadRestoresPreviousRuntime(t *testing.T) {
for _, failure := range []string{"listener", "handler", "partial", "run"} {
t.Run(failure, func(t *testing.T) {
unlock := config.LockMutation()
defer unlock()
original := config.Global()
defer config.Set(original)
defer func() {
for name := range registry.ServiceRegistry().GetAll() {
registry.ServiceRegistry().Unregister(name)
}
}()
old := &config.Config{Services: []*config.ServiceConfig{
{Name: "shared-rule", Addr: "127.0.0.1:0", Handler: &config.HandlerConfig{Type: "auto"}, Listener: &config.ListenerConfig{Type: "tcp"}},
{Name: "paused-rule", Addr: "127.0.0.1:0", Metadata: map[string]any{"paused": true}},
}}
if err := loader.Load(old); err != nil {
t.Fatal(err)
}
addr := registry.ServiceRegistry().Get("shared-rule").Addr().String()
old.Services[0].Addr = addr
config.Set(old)
serve := func(*config.Config) error {
for _, svc := range registry.ServiceRegistry().GetAll() {
go svc.Serve()
}
return nil
}
serve(old)
replacement := config.Global()
replacement.Services = replacement.Services[:1]
switch failure {
case "listener":
replacement.Services[0].Listener.Type = "invalid-listener"
case "handler":
replacement.Services[0].Handler.Type = "invalid-handler"
case "partial":
replacement.Services = append(replacement.Services, &config.ServiceConfig{Name: "broken", Listener: &config.ListenerConfig{Type: "invalid-listener"}})
}
run := serve
if failure == "run" {
run = func(cfg *config.Config) error {
if cfg == replacement {
return &net.AddrError{Err: "auxiliary listener failed", Addr: "test"}
}
return serve(cfg)
}
}
if err := loader.Reload(replacement, run); err == nil {
t.Fatal("expected reload failure")
}
restored := registry.ServiceRegistry().Get("shared-rule")
if restored == nil || restored.Addr().String() != addr {
t.Fatal("previous service was not restored on its original port")
}
if registry.ServiceRegistry().Get("paused-rule") != nil {
t.Fatal("rollback resumed a paused service")
}
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
t.Fatalf("restored listener unavailable: %v", err)
}
conn.Close()
got := config.Global()
if len(got.Services) != 2 || got.Services[0].Listener.Type != "tcp" || got.Services[0].Handler.Type != "auto" {
t.Fatalf("failed config was committed: %+v", got.Services)
}
})
}
}
+10
View File
@@ -255,6 +255,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
// Listener initialization binds the port. If handler/TLS/forwarder parsing
// fails, release it so a reload rollback can restore the previous listener.
configured := false
defer func() {
if !configured {
ln.Close()
}
}()
handlerLogger := serviceLogger.WithFields(map[string]any{
"kind": "handler",
})
@@ -379,6 +388,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
)
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
configured = true
return s, nil
}
+3 -1
View File
@@ -84,7 +84,9 @@ func (s *metricService) Addr() net.Addr {
}
func (s *metricService) Close() error {
return s.s.Close()
err := s.s.Close()
s.ln.Close()
return err
}
func (s *metricService) IsClosed() bool {
+30 -30
View File
@@ -370,43 +370,43 @@ type getConfigResponse struct {
// getConfigData 获取配置数据(避免循环依赖)
func getConfigData() ([]byte, error) {
config.OnUpdate(func(c *config.Config) error {
for _, svc := range c.Services {
if svc == nil {
continue
// Reporting is read-only: enriching a detached snapshot must not persist
// stale state over a config currently being reloaded or acknowledged.
cfg := config.Global()
for _, svc := range cfg.Services {
if svc == nil {
continue
}
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
}
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
}
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
}
}
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
}
}
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
}
}
}
return nil
})
}
var resp getConfigResponse
resp.Config = config.Global()
resp.Config = cfg
buf := &bytes.Buffer{}
resp.Config.Write(buf, "json")
@@ -1,10 +1,14 @@
package service
import (
"bytes"
"context"
"errors"
"github.com/go-gost/x/config"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -148,3 +152,31 @@ func TestPostJSONWithFallbackRemembersDetectedURL(t *testing.T) {
t.Fatalf("expected remembered http url first, got %s", calls[0])
}
}
func TestConfigReportDoesNotPersistStaleRuntime(t *testing.T) {
previous, path := config.Global(), config.PersistPath()
defer config.Set(previous)
defer config.SetPersistPath(path)
config.Set(&config.Config{Services: []*config.ServiceConfig{{Name: "old-runtime"}}})
filename := filepath.Join(t.TempDir(), "gost.json")
candidate := []byte(`{"services":[{"name":"new-on-disk"}]}`)
if err := os.WriteFile(filename, candidate, 0600); err != nil {
t.Fatal(err)
}
config.SetPersistPath(filename)
config.EnablePersist()
report, err := getConfigData()
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(report, []byte("old-runtime")) {
t.Fatalf("unexpected report: %s", report)
}
saved, err := os.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(saved, candidate) {
t.Fatalf("report overwrote candidate config: %s", saved)
}
}
+36
View File
@@ -115,3 +115,39 @@ func TestMutationQueueExecutesCommandsInArrivalOrder(t *testing.T) {
}
close(second.release)
}
func TestMutationCommandWaitsForRuntimeTransaction(t *testing.T) {
original := config.Global()
defer config.Set(original)
name := "reload_transaction_service"
svc := &blockingCommandService{started: make(chan struct{}), release: make(chan struct{})}
close(svc.release)
if err := registry.ServiceRegistry().Register(name, svc); err != nil {
t.Fatal(err)
}
defer registry.ServiceRegistry().Unregister(name)
config.Set(&config.Config{Services: []*config.ServiceConfig{{Name: name}}})
reporter := NewWebSocketReporter("", "transaction-test-secret")
defer reporter.Stop()
unlock := config.LockMutation()
done := make(chan struct{})
go func() {
defer close(done)
reporter.routeCommand(CommandMessage{Type: "DeleteService", Data: map[string]any{"services": []string{name}}})
}()
select {
case <-svc.started:
unlock()
t.Fatal("command interleaved with runtime transaction")
case <-time.After(50 * time.Millisecond):
}
unlock()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("command did not resume after transaction")
}
if registry.ServiceRegistry().Get(name) != nil {
t.Fatal("service was not removed")
}
}
+26 -5
View File
@@ -179,6 +179,7 @@ type WebSocketReporter struct {
tcpPingSem chan struct{} // 限制诊断探测并发,避免离线目标耗尽连接
readCommandSem chan struct{} // 限制只读命令并发,避免诊断请求耗尽资源
mutationQueue chan CommandMessage
workers sync.WaitGroup
}
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
@@ -238,8 +239,15 @@ func (w *WebSocketReporter) releaseTCPPingSlot() {
// Start 启动WebSocket报告器
func (w *WebSocketReporter) Start() {
go w.runMutationCommands()
go w.run()
w.workers.Add(2)
go func() {
defer w.workers.Done()
w.runMutationCommands()
}()
go func() {
defer w.workers.Done()
w.run()
}()
}
// Stop 停止WebSocket报告器
@@ -250,6 +258,7 @@ func (w *WebSocketReporter) Stop() {
w.conn.Close()
}
w.connMutex.Unlock()
w.workers.Wait()
}
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
@@ -339,10 +348,10 @@ func (w *WebSocketReporter) connect() error {
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
dialer := websocket.DefaultDialer
dialer := *websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
conn, usedURL, err := dialWebSocketWithFallback(dialer, candidates)
conn, usedURL, err := dialWebSocketWithFallback(&dialer, candidates)
if err != nil {
return err
}
@@ -528,7 +537,11 @@ func (w *WebSocketReporter) handleConnection() {
}()
// 启动消息接收goroutine
go w.receiveMessages()
w.workers.Add(1)
go func() {
defer w.workers.Done()
w.receiveMessages()
}()
// 指标上报 ticker
metricTicker := time.NewTicker(w.pingInterval)
@@ -887,6 +900,14 @@ func isMutationCommand(commandType string) bool {
// routeCommand 路由命令到对应的处理函数
func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
if isMutationCommand(cmd.Type) {
unlock := config.LockMutation()
defer unlock()
if w.ctx.Err() != nil {
w.sendCommandFailure(cmd, "Agent is shutting down")
return
}
}
jsonBytes, errs := json.Marshal(cmd)
if errs != nil {
fmt.Println("Error marshaling JSON:", errs)