From 7294f903f3f45726b32313abd6f931986d10d115 Mon Sep 17 00:00:00 2001 From: barry Date: Sun, 11 Jan 2026 14:49:52 +0800 Subject: [PATCH 01/80] chore: quick update fix/router at 2026-01-11 14:49:51 --- servers/grpcs/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/grpcs/server.go b/servers/grpcs/server.go index 7d2e520a..5f87a494 100644 --- a/servers/grpcs/server.go +++ b/servers/grpcs/server.go @@ -206,7 +206,7 @@ func (s *serviceImpl) init( // grpcServer.RegisterService(h.ServiceDesc(), h) //} - grpcGatewayApiPrefix := "api" + grpcGatewayApiPrefix := "/api" s.log.Info().Msgf("service gateway base path: %s", grpcGatewayApiPrefix) for _, m := range mux.GetRouteMethods() { From 59c44d000443070406e0c7711251de1a9cf49244 Mon Sep 17 00:00:00 2001 From: barry Date: Sun, 11 Jan 2026 20:17:44 +0800 Subject: [PATCH 02/80] chore: quick update fix/router at 2026-01-11 20:17:43 --- cmds/configcmd/cmd.go | 32 ++++++++++++++++++++++++++++++++ cmds/envcmd/cmd.go | 37 +++++++++++++++++++++++++++++++++++++ cmds/fileservercmd/cmd.go | 7 +++++-- cmds/versioncmd/cmd.go | 3 +++ pkg/netutil/util.go | 7 +++++++ 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 cmds/configcmd/cmd.go create mode 100644 cmds/envcmd/cmd.go diff --git a/cmds/configcmd/cmd.go b/cmds/configcmd/cmd.go new file mode 100644 index 00000000..1dca69f5 --- /dev/null +++ b/cmds/configcmd/cmd.go @@ -0,0 +1,32 @@ +package configcmd + +import ( + "context" + "fmt" + + "github.com/pubgo/redant" + yaml "gopkg.in/yaml.v3" + + "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/recovery" +) + +func New[Cfg any]() *redant.Command { + return &redant.Command{ + Use: "config", + Short: "config management", + Children: []*redant.Command{ + { + Use: "show", + Short: "show config data", + Handler: func(ctx context.Context, i *redant.Invocation) error { + defer recovery.Exit() + fmt.Println("config path:\n", config.GetConfigPath()) + fmt.Println("config raw data:\n", string(assert.Must1(yaml.Marshal(config.Load[Cfg]().T)))) + return nil + }, + }, + }, + } +} diff --git a/cmds/envcmd/cmd.go b/cmds/envcmd/cmd.go new file mode 100644 index 00000000..98e52b72 --- /dev/null +++ b/cmds/envcmd/cmd.go @@ -0,0 +1,37 @@ +package envcmd + +import ( + "context" + "fmt" + + "github.com/pubgo/redant" + + "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/pretty" + "github.com/pubgo/funk/v2/recovery" +) + +func New() *redant.Command { + return &redant.Command{ + Use: "envs", + Short: "show all envs", + Handler: func(ctx context.Context, i *redant.Invocation) error { + defer recovery.Exit() + + env.Reload() + + fmt.Println("config path:", config.GetConfigPath()) + envs := config.LoadEnvMap(config.GetConfigPath()) + for name, cfg := range envs { + envData := env.Get(name) + if envData != "" { + cfg.Default = envData + } + } + + pretty.Println(envs) + return nil + }, + } +} diff --git a/cmds/fileservercmd/cmd.go b/cmds/fileservercmd/cmd.go index a4c20a69..e9e069c6 100644 --- a/cmds/fileservercmd/cmd.go +++ b/cmds/fileservercmd/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" "github.com/pubgo/funk/v2/running" + "github.com/pubgo/lava/v2/pkg/netutil" "github.com/pubgo/redant" "github.com/valyala/fasthttp" ) @@ -17,7 +18,7 @@ import ( func New() *redant.Command { return &redant.Command{ Use: "fileserver ", - Short: "serve `pwd` via http at *:8080", + Short: "serve `pwd` via http at *:8080 or ", Handler: func(ctx context.Context, command *redant.Invocation) error { defer recovery.Exit() @@ -43,7 +44,9 @@ func New() *redant.Command { Handler: fs.NewRequestHandler(), Logger: log.NewStd(log.GetLogger("fileserver")), } - go func() { assert.Must(s.ListenAndServe(fmt.Sprintf(":%v", port))) }() + go func() { + assert.Exit(netutil.SkipServerClosedError(s.ListenAndServe(fmt.Sprintf(":%v", port)))) + }() <-ctx.Done() return s.ShutdownWithContext(ctx) diff --git a/cmds/versioncmd/cmd.go b/cmds/versioncmd/cmd.go index 80aebf4a..0906a376 100644 --- a/cmds/versioncmd/cmd.go +++ b/cmds/versioncmd/cmd.go @@ -19,11 +19,14 @@ func New() *redant.Command { Short: cliutil.UsageDesc("%s version info", version.Project()), Handler: func(ctx context.Context, i *redant.Invocation) error { defer recovery.Exit() + running.CheckVersion() + fmt.Println("project:", version.Project()) fmt.Println("version:", version.Version()) fmt.Println("commit-id:", version.CommitID()) fmt.Println("build-time:", version.BuildTime()) fmt.Println("instance-id:", running.InstanceID) + fmt.Println("system-info:", running.GetSysInfo()) return nil }, } diff --git a/pkg/netutil/util.go b/pkg/netutil/util.go index 26881fbe..87374d93 100644 --- a/pkg/netutil/util.go +++ b/pkg/netutil/util.go @@ -190,3 +190,10 @@ func IsErrServerClosed(err error) bool { errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) } + +func SkipServerClosedError(err error) error { + if IsErrServerClosed(err) { + return nil + } + return err +} From 56c7df2e83d634e946b7ac1d0fbffd94f793d308 Mon Sep 17 00:00:00 2001 From: barry Date: Mon, 12 Jan 2026 21:49:59 +0800 Subject: [PATCH 03/80] chore: quick update fix/router at 2026-01-12 21:49:58 --- cmds/configcmd/cmd.go | 5 +- cmds/envcmd/cmd.go | 3 +- cmds/fileservercmd/cmd.go | 3 +- core/debug/debug/debug.go | 4 +- core/debug/vars/debug.go | 2 +- core/pidfile/pidfile.go | 14 +-- core/scheduler/builder.go | 13 +-- core/scheduler/config.go | 18 ++- core/scheduler/scheduler.go | 28 +---- core/supervisor/manager.go | 2 +- go.mod | 72 ++++++------ go.sum | 175 +++++++++++++++------------- internal/examples/scheduler/main.go | 4 +- pkg/gateway/mux.go | 6 +- pkg/grpcutil/util.go | 2 +- pkg/wsproxy/websocket_proxy.go | 2 +- 16 files changed, 167 insertions(+), 186 deletions(-) diff --git a/cmds/configcmd/cmd.go b/cmds/configcmd/cmd.go index 1dca69f5..f3a29b0c 100644 --- a/cmds/configcmd/cmd.go +++ b/cmds/configcmd/cmd.go @@ -4,12 +4,11 @@ import ( "context" "fmt" - "github.com/pubgo/redant" - yaml "gopkg.in/yaml.v3" - "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/config" "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/redant" + yaml "gopkg.in/yaml.v3" ) func New[Cfg any]() *redant.Command { diff --git a/cmds/envcmd/cmd.go b/cmds/envcmd/cmd.go index 98e52b72..9f9f7132 100644 --- a/cmds/envcmd/cmd.go +++ b/cmds/envcmd/cmd.go @@ -4,12 +4,11 @@ import ( "context" "fmt" - "github.com/pubgo/redant" - "github.com/pubgo/funk/v2/config" "github.com/pubgo/funk/v2/env" "github.com/pubgo/funk/v2/pretty" "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/redant" ) func New() *redant.Command { diff --git a/cmds/fileservercmd/cmd.go b/cmds/fileservercmd/cmd.go index e9e069c6..7478e701 100644 --- a/cmds/fileservercmd/cmd.go +++ b/cmds/fileservercmd/cmd.go @@ -10,9 +10,10 @@ import ( "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" "github.com/pubgo/funk/v2/running" - "github.com/pubgo/lava/v2/pkg/netutil" "github.com/pubgo/redant" "github.com/valyala/fasthttp" + + "github.com/pubgo/lava/v2/pkg/netutil" ) func New() *redant.Command { diff --git a/core/debug/debug/debug.go b/core/debug/debug/debug.go index 186bd77d..4accd6e9 100644 --- a/core/debug/debug/debug.go +++ b/core/debug/debug/debug.go @@ -31,13 +31,13 @@ func initDebug() { } } - var pathList []string + pathList := make([]string, 0, len(pathMap)) for k := range pathMap { pathList = append(pathList, k) } sort.Strings(pathList) - var nodes []g.Node + nodes := make([]g.Node, 0, len(pathList)) nodes = append(nodes, h.H1(g.Text("routes"))) for i := range pathList { path := pathList[i] diff --git a/core/debug/vars/debug.go b/core/debug/vars/debug.go index b7666e33..ddc85312 100644 --- a/core/debug/vars/debug.go +++ b/core/debug/vars/debug.go @@ -16,7 +16,7 @@ import ( func init() { defer recovery.Exit() index := func(keys []string) g.Node { - var nodes []g.Node + nodes := make([]g.Node, 0, len(keys)) nodes = append(nodes, h.H1(g.Text("/expvar"))) nodes = append(nodes, h.A(g.Text("/debug"), g.Attr("href", "/debug")), h.Br()) for i := range keys { diff --git a/core/pidfile/pidfile.go b/core/pidfile/pidfile.go index 592a4515..30e77a29 100644 --- a/core/pidfile/pidfile.go +++ b/core/pidfile/pidfile.go @@ -8,10 +8,8 @@ import ( "sync" "github.com/pubgo/funk/v2/config" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" "github.com/pubgo/funk/v2/running" - "github.com/rs/zerolog" ) var getPidPath = sync.OnceValue(func() string { @@ -29,9 +27,9 @@ func Get() (r result.Result[int]) { } return nil }). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("path", pidPath) - e.Str(logfields.Msg, "read pid file failed") + e.Msg("read pid file failed") }). UnwrapOrThrow(&r) if r.IsErr() { @@ -39,10 +37,10 @@ func Get() (r result.Result[int]) { } return result.Wrap(strconv.Atoi(string(p))). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("path", pidPath) e.Str("pid", string(p)) - e.Str(logfields.Msg, "convert pid to int failed") + e.Msg("convert pid to int failed") }) } @@ -53,9 +51,9 @@ func Save() (r result.Error) { pid := os.Getpid() return result.ErrOf(os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), pidPerm)). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("path", pidPath) e.Int("pid", pid) - e.Str(logfields.Msg, "write pid file failed") + e.Msg("write pid file failed") }) } diff --git a/core/scheduler/builder.go b/core/scheduler/builder.go index ff3deb1e..3b6732c1 100644 --- a/core/scheduler/builder.go +++ b/core/scheduler/builder.go @@ -10,7 +10,6 @@ import ( "github.com/pubgo/funk/v2/vars" qlog "github.com/reugn/go-quartz/logger" "github.com/reugn/go-quartz/quartz" - "github.com/rs/zerolog" "github.com/pubgo/lava/v2/core/lifecycle" "github.com/pubgo/lava/v2/core/metrics" @@ -37,24 +36,24 @@ func New(m lifecycle.Lifecycle, logger log.Logger, metric metrics.Metric, config defer result.RecoveryErr(&gErr) configMap := result.Wrap(createConfig(configs)). - UnwrapOrLog(func(e *zerolog.Event) { + UnwrapOrLog(func(e result.Event) { e.Any("configs", configs) - e.Any(logfields.Msg, "failed to create config") + e.Msg("failed to create config") }) ctx, cancel := context.WithCancel(context.Background()) slogLogger := qlog.NewSlogLogger(ctx, slog.With(slog.String(logfields.Module, Name))) scheduler := result.Wrap(quartz.NewStdScheduler(quartz.WithLogger(slogLogger), quartz.WithJobMetadata())). - UnwrapOrLog(func(e *zerolog.Event) { - e.Str(logfields.Msg, "failed to create scheduler") + UnwrapOrLog(func(e result.Event) { + e.Msg("failed to create scheduler") }) jobExecutors := make(map[string]JobExecutor) for _, executor := range executors { regJobExecutor(jobExecutors, executor). - MustWithLog(func(e *zerolog.Event) { - e.Str(logfields.Msg, "failed to register job executor") + MustWithLog(func(e result.Event) { + e.Msg("failed to register job executor") }) } diff --git a/core/scheduler/config.go b/core/scheduler/config.go index 687afa3f..b2ae81e6 100644 --- a/core/scheduler/config.go +++ b/core/scheduler/config.go @@ -1,14 +1,11 @@ package scheduler import ( - "fmt" "time" "github.com/pubgo/funk/v2/errors" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" "github.com/reugn/go-quartz/quartz" - "github.com/rs/zerolog" "github.com/samber/lo" ) @@ -43,9 +40,7 @@ func defaultConfig(name string) *JobConfig { } } -func initAndMergeConfig(name string, jobConfigs ...*JobConfig) (r result.Result[*JobConfig]) { - defer result.Recovery(&r) - +func initAndMergeConfig(name string, jobConfigs ...*JobConfig) *JobConfig { cfg := defaultConfig(name) for _, jobConfig := range jobConfigs { if jobConfig == nil { @@ -76,13 +71,14 @@ func initAndMergeConfig(name string, jobConfigs ...*JobConfig) (r result.Result[ cfg.Location = jobConfig.Location } - cfg.location = result.Wrap(time.LoadLocation(lo.FromPtr(cfg.Location))). - UnwrapOrLog(func(e *zerolog.Event) { - e.Str(logfields.Msg, fmt.Sprintf("failed to parse time location:%s", lo.FromPtr(cfg.Location))) - }) + if cfg.Location != nil { + location, err := result.WrapErr(time.LoadLocation(lo.FromPtr(cfg.Location))) + err.MustWithLog(func(e result.Event) { e.Msgf("failed to parse time location:%s", lo.FromPtr(cfg.Location)) }) + cfg.location = location + } } - return r.WithValue(cfg) + return cfg } type JobConfig struct { diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index bdf8eea8..24a70396 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -7,7 +7,6 @@ import ( "time" "github.com/pubgo/funk/v2/log" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" "github.com/reugn/go-quartz/quartz" "github.com/rs/zerolog" @@ -89,19 +88,8 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { return r } - config := initAndMergeConfig(name, s.configMap[name], spec.Config). - Log(func(e *zerolog.Event) { - e.Str(logfields.Msg, fmt.Sprintf("failed to init schedule job(%s) config", name)) - }). - IfOK(func(config *JobConfig) { - task.spec.Config = config - }). - UnwrapOrThrow(&r) - if r.IsErr() { - return r - } - - triggerRes := getTrigger(spec, config.location). + task.spec.Config = initAndMergeConfig(name, s.configMap[name], spec.Config) + triggerRes := getTrigger(spec, task.spec.Config.location). IfErr(func(err error) { log.Err(err).Msgf("failed to get schedule job(%s) trigger", name) }). @@ -112,7 +100,7 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { return r } - jobOpt := config.ToJobDetailOptions() + jobOpt := task.spec.Config.ToJobDetailOptions() job := &namedJob{s: s, task: &task, log: s.log} jobDetail := quartz.NewJobDetailWithOptions(job, parseJobKey(name), jobOpt) @@ -147,15 +135,7 @@ func (s *Scheduler) PatchJob(name string, config *JobConfig) (r result.Error) { return r } - initAndMergeConfig(name, job.spec.Config, config). - Log(func(e *zerolog.Event) { - e.Str(logfields.Msg, fmt.Sprintf("failed to patch schedule job(%s) config", name)) - }). - IfOK(func(config *JobConfig) { - job.spec.Config = config - }). - Throw(&r) - + job.spec.Config = initAndMergeConfig(name, job.spec.Config, config) return r } diff --git a/core/supervisor/manager.go b/core/supervisor/manager.go index 57760619..0b324e80 100644 --- a/core/supervisor/manager.go +++ b/core/supervisor/manager.go @@ -50,7 +50,7 @@ type Manager struct { func (m *Manager) init() *Manager { debug.Route("/supervisor", func(router fiber.Router) { router.Get("services", func(ctx *fiber.Ctx) error { - var services []*Metric + services := make([]*Metric, 0, len(m.services)) for _, srv := range m.services { services = append(services, srv.service.Metric()) } diff --git a/go.mod b/go.mod index 7692c4d5..96b9be3e 100644 --- a/go.mod +++ b/go.mod @@ -14,17 +14,17 @@ require ( github.com/pkg/errors v0.9.1 github.com/reugn/go-quartz v0.15.1 github.com/vmihailenco/msgpack/v5 v5.4.1 - go.opentelemetry.io/otel v1.37.0 - go.opentelemetry.io/otel/metric v1.37.0 - go.opentelemetry.io/otel/trace v1.37.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/metric v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 go.uber.org/atomic v1.11.0 go.uber.org/automaxprocs v1.6.0 - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.46.0 - golang.org/x/sys v0.37.0 // indirect - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.48.0 + golang.org/x/sys v0.40.0 // indirect + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 ) require ( @@ -44,7 +44,7 @@ require ( github.com/felixge/fgprof v0.9.5 github.com/fullstorydev/grpchan v1.1.2 github.com/go-logr/logr v1.4.3 - github.com/go-playground/validator/v10 v10.27.0 + github.com/go-playground/validator/v10 v10.30.1 github.com/gofiber/adaptor/v2 v2.2.1 github.com/gofiber/utils v1.1.0 github.com/golangci/golangci-lint v1.61.0 @@ -54,12 +54,12 @@ require ( github.com/maragudk/gomponents v0.22.0 github.com/maruel/panicparse/v2 v2.5.0 github.com/pubgo/dix/v2 v2.0.0-beta.10 - github.com/pubgo/funk/v2 v2.0.0-beta.8 + github.com/pubgo/funk/v2 v2.0.0-beta.10 github.com/pubgo/redant v0.0.4 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/samber/lo v1.52.0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/thejerf/suture/v4 v4.0.6 github.com/uber-go/tally/v4 v4.1.17 github.com/ulikunitz/xz v0.5.15 @@ -72,11 +72,11 @@ require ( go.opentelemetry.io/otel/exporters/prometheus v0.59.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 - go.opentelemetry.io/otel/sdk v1.37.0 - go.opentelemetry.io/otel/sdk/metric v1.37.0 - golang.org/x/tools v0.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/sdk/metric v1.38.0 + golang.org/x/tools v0.40.0 golang.org/x/vuln v1.1.3 - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 + google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b gopkg.in/yaml.v3 v3.0.1 maragu.dev/gomponents v1.2.0 ) @@ -84,6 +84,7 @@ require ( require ( 4d63.com/gocheckcompilerdirectives v1.2.1 // indirect 4d63.com/gochecknoglobals v0.2.1 // indirect + cel.dev/expr v0.24.0 // indirect github.com/4meepo/tagalign v1.3.4 // indirect github.com/Abirdcfly/dupword v0.1.1 // indirect github.com/Antonboom/errname v0.1.13 // indirect @@ -101,6 +102,7 @@ require ( github.com/alexkohler/prealloc v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/ashanbrown/forbidigo v1.6.0 // indirect github.com/ashanbrown/makezero v1.1.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -125,16 +127,16 @@ require ( github.com/creasty/defaults v1.7.0 // indirect github.com/curioswitch/go-reassign v0.2.0 // indirect github.com/daixiang0/gci v0.13.5 // indirect - github.com/dave/jennifer v1.7.0 // indirect + github.com/dave/jennifer v1.7.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.5 // indirect - github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/ghostiam/protogetter v0.3.6 // indirect github.com/go-critic/go-critic v0.11.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -159,6 +161,7 @@ require ( github.com/golangci/plugin-module-register v0.1.1 // indirect github.com/golangci/revgrep v0.5.3 // indirect github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/uuid v1.6.0 // indirect @@ -167,11 +170,11 @@ require ( github.com/gostaticanalysis/comment v1.4.2 // indirect github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect github.com/gostaticanalysis/nilerr v0.1.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/huandu/go-clone v1.5.1 // indirect + github.com/huandu/go-clone v1.7.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jgautheron/goconst v1.7.1 // indirect github.com/jhump/protoreflect v1.17.0 // indirect @@ -184,7 +187,7 @@ require ( github.com/karamaru-alpha/copyloopvar v1.1.0 // indirect github.com/kisielk/errcheck v1.7.0 // indirect github.com/kkHAIKE/contextcheck v1.1.5 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/kulti/thelper v0.6.3 // indirect github.com/kunwardeep/paralleltest v1.0.10 // indirect github.com/kyoh86/exportloopref v0.1.11 // indirect @@ -194,10 +197,10 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect github.com/lmittmann/tint v1.1.2 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufeee/execinquery v1.2.1 // indirect github.com/macabu/inamedparam v0.1.3 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/maratori/testableexamples v1.0.0 // indirect github.com/maratori/testpackage v1.1.1 // indirect github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26 // indirect @@ -211,7 +214,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/moricho/tparallel v0.3.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect @@ -256,6 +259,7 @@ require ( github.com/spf13/viper v1.12.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tdakkota/asciicheck v0.2.0 // indirect @@ -276,17 +280,17 @@ require ( gitlab.com/bosi/decorder v0.4.2 // indirect go-simpler.org/musttag v0.12.2 // indirect go-simpler.org/sloglint v0.7.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.31.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect honnef.co/go/tools v0.5.1 // indirect diff --git a/go.sum b/go.sum index 6f865010..e4a252d6 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 4d63.com/gocheckcompilerdirectives v1.2.1/go.mod h1:yjDJSxmDTtIHHCqX0ufRYZDL6vQtMG7tJdKVeWwsqvs= 4d63.com/gochecknoglobals v0.2.1 h1:1eiorGsgHOFOuoOiJDy2psSrQbRdIHrlge0IJIkUgDc= 4d63.com/gochecknoglobals v0.2.1/go.mod h1:KRE8wtJB3CXCsb1xy421JfTHIIbmT3U5ruxw2Qu8fSU= -cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= -cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= @@ -50,6 +50,8 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U= github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0= github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= @@ -102,8 +104,8 @@ github.com/ckaznocha/intrange v0.2.0 h1:FykcZuJ8BD7oX93YbO1UY9oZtkRbp+1/kJcDjkef github.com/ckaznocha/intrange v0.2.0/go.mod h1:r5I7nUlAAG56xmkOpw4XVr16BXhwYTUdcuRFeevn1oE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -114,8 +116,8 @@ github.com/curioswitch/go-reassign v0.2.0 h1:G9UZyOcpk/d7Gd6mqYgd8XYWFMw/znxwGDU github.com/curioswitch/go-reassign v0.2.0/go.mod h1:x6OpXuWvgfQaMGks2BZybTngWjT84hqJfKoO8Tt/Roc= github.com/daixiang0/gci v0.13.5 h1:kThgmH1yBmZSBCh1EJVxQ7JsHpm5Oms0AMed/0LaH4c= github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= -github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= -github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -127,9 +129,9 @@ github.com/ecordell/optgen v0.0.9/go.mod h1:+YZ4tk5pNGMoeH+Y4F4HeDDj0SLOlIgMMNae github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= @@ -149,14 +151,14 @@ github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6 github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fullstorydev/grpchan v1.1.2 h1:Bmo6KbPe/xvftY/8tCbV3MmX/5Z87zcXu+5Xus7EDz4= github.com/fullstorydev/grpchan v1.1.2/go.mod h1:GrXuhvxw+EM9Z1c7pHQY7SOWn8cv1+SamuCsdNH0j9A= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghostiam/protogetter v0.3.6 h1:R7qEWaSgFCsy20yYHNIJsU9ZOb8TziSRRxuAOTVKeOk= github.com/ghostiam/protogetter v0.3.6/go.mod h1:7lpeDnEJ1ZjL/YtyoN99ljO4z0pd3H0d18/t2dPBxHw= github.com/go-critic/go-critic v0.11.4 h1:O7kGOCx0NDIni4czrkRIXTnit0mkyKOCePh3My6OyEU= @@ -174,8 +176,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -246,6 +248,8 @@ github.com/golangci/revgrep v0.5.3 h1:3tL7c1XBMtWHHqVpS5ChmiAAoe4PF/d5+ULzV9sLAz github.com/golangci/revgrep v0.5.3/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNFP0hTEi1YKjB/ub8zkpaOqFFMApi2EAs= github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed/go.mod h1:XLXN8bNw4CGRPaqgl3bv/lhz7bsGPh4/xSaMTbo2vkQ= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -286,19 +290,19 @@ github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6 github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= -github.com/huandu/go-clone v1.5.1 h1:1wlwYRlHZo4HspdOM0YQ6O7Y7bjtxTrrt+4jnDeejVo= -github.com/huandu/go-clone v1.5.1/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -333,8 +337,8 @@ github.com/kisielk/errcheck v1.7.0/go.mod h1:1kLL+jV4e+CFfueBmI1dSK2ADDyQnlrnrY/ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkHAIKE/contextcheck v1.1.5 h1:CdnJh63tcDe53vG+RebdpdXJTc9atMgGqdx8LXxiilg= github.com/kkHAIKE/contextcheck v1.1.5/go.mod h1:O930cpht4xb1YQpK+1+AgoM3mFsvxr7uyFptcnWTYUA= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -364,14 +368,14 @@ github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84Yrj github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufeee/execinquery v1.2.1 h1:hf0Ems4SHcUGBxpGN7Jz78z1ppVkP/837ZlETPCEtOM= github.com/lufeee/execinquery v1.2.1/go.mod h1:EC7DrEKView09ocscGHC+apXMIaorh4xqSxS/dy8SbM= github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailgun/holster/v4 v4.21.0 h1:EH3fwKEGv56WA5gUwxjOTqZbeILY+oJ/VWEo1xku7t8= github.com/mailgun/holster/v4 v4.21.0/go.mod h1:G06Q741dj+zsH1WFrmoFvih3LtaocvBIoNtxITdWEtg= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -414,8 +418,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= @@ -474,8 +478,8 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/pubgo/dix/v2 v2.0.0-beta.10 h1:HE1gqY8vzNPPdz4FwN91hWVZpeWkfvuIRAT7dGSgdIw= github.com/pubgo/dix/v2 v2.0.0-beta.10/go.mod h1:jV/9KWf+YxtoQATuZLyUraACduxHvfaum5EZDSCK5gE= -github.com/pubgo/funk/v2 v2.0.0-beta.8 h1:zYL/4Cp4T1QuQnEp1EI4jGMsRKaHB91s7sdANSrkebw= -github.com/pubgo/funk/v2 v2.0.0-beta.8/go.mod h1:uMQn+vuKx++99J+QZnYTekqzwJHT/j7lAz/qwqQ8PyY= +github.com/pubgo/funk/v2 v2.0.0-beta.10 h1:K8QomGlsoMFLWpx8KcAfHDKifQDLA4dcUL0YLL2a8cU= +github.com/pubgo/funk/v2 v2.0.0-beta.10/go.mod h1:YTWjAG9bJ//P3fFc1cyzD95L7lTO0H27gv9VRQ18o1c= github.com/pubgo/redant v0.0.4 h1:Yweyxj33Y+j4eE9b36QAn9FcOWPymUE0CxaqOrJgTvs= github.com/pubgo/redant v0.0.4/go.mod h1:FOBNjL8pPLOBcZS3SL2R5GusFz/bNBwDJzSinGuKs7A= github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 h1:+Wl/0aFp0hpuHM3H//KMft64WQ1yX9LdJY64Qm/gFCo= @@ -552,6 +556,8 @@ github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YE github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.1.1 h1:tYugd/yrm1O0dV+ThCbaKZh195Dfm07ysF0U6JQXczc= github.com/stbenjam/no-sprintf-host-port v0.1.1/go.mod h1:TLhvtIvONRzdmkFiio4O8LHsN9N74I+PhRquPsxpL0I= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -566,8 +572,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tdakkota/asciicheck v0.2.0 h1:o8jvnUANo0qXtnslk2d3nMKTFNlOnJjRrNcj0j9qkHM= @@ -580,12 +586,12 @@ github.com/tetafro/godot v1.4.17 h1:pGzu+Ye7ZUEFx7LHU0dAKmCOXWsPjl7qA6iMGndsjPs= github.com/tetafro/godot v1.4.17/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= github.com/thejerf/suture/v4 v4.0.6 h1:QsuCEsCqb03xF9tPAsWAj8QOAJBgQI1c0VqJNaingg8= github.com/thejerf/suture/v4 v4.0.6/go.mod h1:gu9Y4dXNUWFrByqRt30Rm9/UZ0wzRSt9AJS6xu/ZGxU= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 h1:quvGphlmUVU+nhpFa4gg4yJyTRJ13reZMDHrKwYw53M= github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966/go.mod h1:27bSVNWSBOHm+qRp1T9qzaIpsWEP6TbUnei/43HK+PQ= github.com/timonwong/loggercheck v0.9.4 h1:HKKhqrjcVj8sxL7K77beXh0adEm6DLjV/QOGeMXEVi4= @@ -641,12 +647,12 @@ go-simpler.org/musttag v0.12.2 h1:J7lRc2ysXOq7eM8rwaTYnNrHd5JwjppzB6mScysB2Cs= go-simpler.org/musttag v0.12.2/go.mod h1:uN1DVIasMTQKk6XSik7yrJoEysGtR2GRqvWnI9S7TYM= go-simpler.org/sloglint v0.7.2 h1:Wc9Em/Zeuu7JYpl+oKoYOsQSy2X560aVueCW/m6IijY= go-simpler.org/sloglint v0.7.2/go.mod h1:US+9C80ppl7VsThQclkM7BkCHQAzuz8kHLsW3ppuluo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/zpages v0.62.0 h1:9fUYTLmrK0x/lweM2uM+BOx069jLx8PxVqWhegGJ9Bo= go.opentelemetry.io/contrib/zpages v0.62.0/go.mod h1:C8kXoiC1Ytvereztus2R+kqdSa6W/MZ8FfS8Zwj+LiM= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= @@ -659,14 +665,14 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iL go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -681,18 +687,18 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= @@ -712,8 +718,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -733,8 +739,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -745,8 +751,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -769,25 +775,24 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= -golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -796,8 +801,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -827,10 +832,10 @@ golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= -golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/vuln v1.1.3 h1:NPGnvPOTgnjBc9HTaUx+nj+EaUYxl5SJOWqaDYGaFYw= @@ -839,24 +844,26 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/examples/scheduler/main.go b/internal/examples/scheduler/main.go index 4f7d93ac..ecf4dc82 100644 --- a/internal/examples/scheduler/main.go +++ b/internal/examples/scheduler/main.go @@ -5,12 +5,12 @@ import ( "fmt" "time" - "github.com/pubgo/funk/v2/cmds/configcmd" - "github.com/pubgo/funk/v2/cmds/envcmd" "github.com/pubgo/funk/v2/config" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" + "github.com/pubgo/lava/v2/cmds/configcmd" + "github.com/pubgo/lava/v2/cmds/envcmd" "github.com/pubgo/lava/v2/core/lavabuilder" "github.com/pubgo/lava/v2/core/logging" "github.com/pubgo/lava/v2/core/metrics" diff --git a/pkg/gateway/mux.go b/pkg/gateway/mux.go index 2d20a364..5cf8c984 100644 --- a/pkg/gateway/mux.go +++ b/pkg/gateway/mux.go @@ -16,9 +16,7 @@ import ( "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/log" - "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/result" - "github.com/rs/zerolog" "github.com/samber/lo" "google.golang.org/grpc" "google.golang.org/grpc/metadata" @@ -90,10 +88,10 @@ func (m *Mux) SetRequestDecoder(name protoreflect.FullName, f func(ctx *fiber.Ct func (m *Mux) MatchOperation(method, path string) (r result.Result[*MatchOperation]) { return result.Wrap(m.routerTree.Match(method, path)). - Log(func(e *zerolog.Event) { + Log(func(e result.Event) { e.Str("method", method) e.Str("path", path) - e.Str(logfields.Msg, "match operation failed") + e.Msg("match operation failed") }) } diff --git a/pkg/grpcutil/util.go b/pkg/grpcutil/util.go index 2970ebcc..d9a19502 100644 --- a/pkg/grpcutil/util.go +++ b/pkg/grpcutil/util.go @@ -73,7 +73,7 @@ func IsGRPCRequest(r *http.Request) bool { // // This makes it easy to register all the relevant routes in your HTTP router of choice. func ListGRPCResources(server *grpc.Server) []string { - var ret []string + ret := make([]string, 0, len(server.GetServiceInfo())) for serviceName, serviceInfo := range server.GetServiceInfo() { for _, methodInfo := range serviceInfo.Methods { ret = append(ret, fmt.Sprintf("/%s/%s", serviceName, methodInfo.Name)) diff --git a/pkg/wsproxy/websocket_proxy.go b/pkg/wsproxy/websocket_proxy.go index 42be1ccd..305460e7 100644 --- a/pkg/wsproxy/websocket_proxy.go +++ b/pkg/wsproxy/websocket_proxy.go @@ -3,6 +3,7 @@ package wsproxy import ( "bufio" "bytes" + "context" "errors" "io" "net" @@ -14,7 +15,6 @@ import ( "github.com/gorilla/websocket" "github.com/pubgo/funk/v2/closer" "github.com/pubgo/funk/v2/log" - "golang.org/x/net/context" "github.com/pubgo/lava/v2/internal/logutil" ) From fabd2825c892336be79fa5f24e686f4addfc4cb4 Mon Sep 17 00:00:00 2001 From: barry Date: Mon, 19 Jan 2026 13:31:36 +0800 Subject: [PATCH 04/80] chore: quick update fix/router at 2026-01-19 13:31:33 --- core/debug/configview/configview.go | 327 +++++++++++++++++ core/debug/debug/debug.go | 170 ++++++++- core/debug/debug/mux.go | 133 +++++-- core/debug/goroutine/goroutine.go | 446 +++++++++++++++++++++++ core/debug/healthy/debug.go | 182 ++++++++- core/debug/loglevel/loglevel.go | 219 +++++++++++ core/debug/ratelimit/ratelimit.go | 158 ++++++++ core/debug/runtime/runtime.go | 395 ++++++++++++++++++++ core/debug/sysinfo/sysinfo.go | 435 ++++++++++++++++++++++ core/debug/ui/ui.go | 266 ++++++++++++++ go.mod | 9 + go.sum | 23 ++ internal/configs/envs/envs.yaml | 50 +++ internal/configs/envs/s3.yaml | 17 + internal/configs/envs/website.yaml | 11 + internal/configs/scheduler.yaml | 3 +- internal/examples/scheduler/taskfile.yml | 1 + 17 files changed, 2782 insertions(+), 63 deletions(-) create mode 100644 core/debug/configview/configview.go create mode 100644 core/debug/goroutine/goroutine.go create mode 100644 core/debug/loglevel/loglevel.go create mode 100644 core/debug/ratelimit/ratelimit.go create mode 100644 core/debug/runtime/runtime.go create mode 100644 core/debug/sysinfo/sysinfo.go create mode 100644 core/debug/ui/ui.go create mode 100644 internal/configs/envs/envs.yaml create mode 100644 internal/configs/envs/s3.yaml create mode 100644 internal/configs/envs/website.yaml diff --git a/core/debug/configview/configview.go b/core/debug/configview/configview.go new file mode 100644 index 00000000..736ce75e --- /dev/null +++ b/core/debug/configview/configview.go @@ -0,0 +1,327 @@ +package configview + +import ( + "fmt" + "html/template" + "os" + "regexp" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/pubgo/funk/v2/config" + "gopkg.in/yaml.v3" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var sensitivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)password`), + regexp.MustCompile(`(?i)secret`), + regexp.MustCompile(`(?i)token`), + regexp.MustCompile(`(?i)api[_-]?key`), + regexp.MustCompile(`(?i)access[_-]?key`), + regexp.MustCompile(`(?i)private[_-]?key`), + regexp.MustCompile(`(?i)credential`), + regexp.MustCompile(`(?i)auth`), + regexp.MustCompile(`(?i)dsn`), + regexp.MustCompile(`(?i)connection[_-]?string`), +} + +func init() { + debug.Get("/config", func(ctx *fiber.Ctx) error { + configPath := config.GetConfigPath() + + // JSON 响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + if configPath == "" { + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "config path not found", + }) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to read config: " + err.Error(), + }) + } + + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to parse config: " + err.Error(), + }) + } + + maskSensitiveData(cfg, "") + + return ctx.JSON(fiber.Map{ + "config_path": configPath, + "config": cfg, + }) + } + + // HTML 页面 + var cfg map[string]any + var configError string + var configYaml string + + if configPath == "" { + configError = "配置文件路径未设置" + } else { + data, err := os.ReadFile(configPath) + if err != nil { + configError = "读取配置文件失败: " + err.Error() + } else { + configYaml = string(data) + if err := yaml.Unmarshal(data, &cfg); err != nil { + configError = "解析配置文件失败: " + err.Error() + } else { + maskSensitiveData(cfg, "") + } + } + } + + // 统计卡片 + keyCount := countKeys(cfg) + statsContent := ui.StatsCard("配置路径", shortenPath(configPath, 30), "") + statsContent += ui.StatsCard("配置项数", fmt.Sprintf("%d", keyCount), "") + statsContent += ui.StatsCard("敏感字段", fmt.Sprintf("%d 个模式", len(sensitivePatterns)), "自动脱敏") + + // 错误提示 + errorContent := template.HTML("") + if configError != "" { + errorContent = ui.Alert(configError, "red") + } + + // 配置树形展示 + configContent := template.HTML("") + if cfg != nil { + configContent = buildConfigTree(cfg, "") + } + + // 环境变量统计 + envCount := len(os.Environ()) + + // 快速操作 + actionsContent := template.HTML(fmt.Sprintf(` +
+ 配置路径 + 环境变量 (%d) + + +
+`, envCount)) + + // YAML 视图 + yamlView := template.HTML(fmt.Sprintf(` +`, + template.HTMLEscapeString(maskYaml(configYaml)))) + + content := template.HTML(fmt.Sprintf(` +
%s
+%s +%s +
+
+

配置内容

+
+
+
%s
+ %s +
+
`, + statsContent, + errorContent, + ui.Card("快速操作", actionsContent), + configContent, + yamlView)) + + html, err := ui.Render(ui.PageData{ + Title: "配置查看", + Description: "应用配置文件查看(敏感信息已脱敏)", + Breadcrumb: []string{"Config"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Get("/config/path", func(ctx *fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "config_path": config.GetConfigPath(), + }) + }) + + debug.Get("/config/raw", func(ctx *fiber.Ctx) error { + if ctx.Query("confirm") != "yes" { + return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "raw config access requires confirm=yes parameter", + "warning": "raw config may contain sensitive data", + }) + } + + configPath := config.GetConfigPath() + if configPath == "" { + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "config path not found", + }) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to read config: " + err.Error(), + }) + } + + ctx.Set("Content-Type", "text/yaml; charset=utf-8") + return ctx.Send(data) + }) + + debug.Get("/config/env", func(ctx *fiber.Ctx) error { + envs := make(map[string]string) + for _, env := range os.Environ() { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + key := parts[0] + value := parts[1] + if isSensitiveKey(key) { + value = maskString(value) + } + envs[key] = value + } + } + + return ctx.JSON(fiber.Map{ + "count": len(envs), + "envs": envs, + }) + }) +} + +func maskSensitiveData(data map[string]any, parentKey string) { + for key, value := range data { + fullKey := key + if parentKey != "" { + fullKey = parentKey + "." + key + } + + switch v := value.(type) { + case map[string]any: + maskSensitiveData(v, fullKey) + case string: + if isSensitiveKey(key) || isSensitiveKey(fullKey) { + data[key] = maskString(v) + } + case []any: + for i, item := range v { + if m, ok := item.(map[string]any); ok { + maskSensitiveData(m, fullKey) + v[i] = m + } + } + } + } +} + +func isSensitiveKey(key string) bool { + for _, pattern := range sensitivePatterns { + if pattern.MatchString(key) { + return true + } + } + return false +} + +func maskString(s string) string { + if len(s) == 0 { + return "" + } + if len(s) <= 4 { + return "****" + } + if len(s) <= 8 { + return s[:1] + "****" + s[len(s)-1:] + } + return s[:2] + "****" + s[len(s)-2:] +} + +func countKeys(m map[string]any) int { + count := 0 + for _, v := range m { + count++ + if sub, ok := v.(map[string]any); ok { + count += countKeys(sub) + } + } + return count +} + +func shortenPath(path string, maxLen int) string { + if len(path) <= maxLen { + return path + } + return "..." + path[len(path)-maxLen+3:] +} + +func buildConfigTree(data map[string]any, indent string) template.HTML { + result := "" + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + + for _, key := range keys { + value := data[key] + switch v := value.(type) { + case map[string]any: + result += fmt.Sprintf(`
%s:`, indent, key) + result += string(buildConfigTree(v, indent+"ml-4")) + result += `
` + case []any: + result += fmt.Sprintf(`
%s: [%d items]
`, indent, key, len(v)) + default: + valueStr := fmt.Sprintf("%v", v) + valueClass := "text-green-400" + if isSensitiveKey(key) { + valueClass = "text-yellow-400" + } + result += fmt.Sprintf(`
%s: %s
`, indent, key, valueClass, template.HTMLEscapeString(valueStr)) + } + } + return template.HTML(result) +} + +func maskYaml(yaml string) string { + lines := strings.Split(yaml, "\n") + for i, line := range lines { + for _, pattern := range sensitivePatterns { + if pattern.MatchString(line) { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + lines[i] = parts[0] + ": ****" + } + } + } + } + return strings.Join(lines, "\n") +} diff --git a/core/debug/debug/debug.go b/core/debug/debug/debug.go index 4accd6e9..91bbddd9 100644 --- a/core/debug/debug/debug.go +++ b/core/debug/debug/debug.go @@ -1,33 +1,48 @@ package debug import ( - "path/filepath" + "fmt" + "html/template" + "runtime" "sort" + "strings" + "time" "github.com/gofiber/fiber/v2" - g "github.com/maragudk/gomponents" - c "github.com/maragudk/gomponents/components" - h "github.com/maragudk/gomponents/html" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" ) +var debugStartTime = time.Now() + func init() { initDebug() } func initDebug() { + // 主页 - 仪表盘 debug.Get("/", func(ctx *fiber.Ctx) error { - pathMap := make(map[string]any) + var m runtime.MemStats + runtime.ReadMemStats(&m) + + // 统计卡片 + statsHTML := ui.Grid(4, ui.StatsCard("Goroutines", fmt.Sprintf("%d", runtime.NumGoroutine()), "")+ + ui.StatsCard("堆内存", ui.FormatBytes(m.HeapAlloc), fmt.Sprintf("总分配: %s", ui.FormatBytes(m.TotalAlloc)))+ + ui.StatsCard("GC 次数", fmt.Sprintf("%d", m.NumGC), fmt.Sprintf("暂停: %.2fms", float64(m.PauseTotalNs)/1e6))+ + ui.StatsCard("运行时间", formatDuration(time.Since(debugStartTime)), "")) + // 路由列表 + pathMap := make(map[string][]string) stack := ctx.App().Stack() - for m := range stack { - for r := range stack[m] { - route := stack[m][r] - //if strings.Contains(route.Path, "*") || strings.Contains(route.Path, ":") { - // continue - //} - pathMap[route.Path] = nil + for mi := range stack { + for ri := range stack[mi] { + route := stack[mi][ri] + method := route.Method + if method == "HEAD" { + continue + } + pathMap[route.Path] = append(pathMap[route.Path], method) } } @@ -37,13 +52,132 @@ func initDebug() { } sort.Strings(pathList) - nodes := make([]g.Node, 0, len(pathList)) - nodes = append(nodes, h.H1(g.Text("routes"))) - for i := range pathList { - path := pathList[i] - nodes = append(nodes, h.A(g.Text(path), g.Attr("href", filepath.Join(string(ctx.Request().Header.Peek("Path-Prefix")), path))), h.Br()) + // 分组路由 + groups := make(map[string][]routeInfo) + for _, path := range pathList { + methods := pathMap[path] + group := getGroup(path) + groups[group] = append(groups[group], routeInfo{Path: path, Methods: methods}) } + + groupNames := make([]string, 0, len(groups)) + for g := range groups { + groupNames = append(groupNames, g) + } + sort.Strings(groupNames) + + var routesHTML template.HTML + for _, group := range groupNames { + routes := groups[group] + var rows template.HTML + for _, r := range routes { + methodBadges := "" + for _, m := range r.Methods { + color := methodColor(m) + methodBadges += string(ui.Badge(m, color)) + " " + } + rows += ui.TR(methodBadges, fmt.Sprintf(`%s`, r.Path, r.Path)) + } + tableHTML := ui.Table([]string{"方法", "路径"}) + rows + ui.TableEnd() + routesHTML += ui.Card(fmt.Sprintf("📁 %s (%d)", group, len(routes)), tableHTML) + } + + // 快捷操作 + actionsHTML := template.HTML(` +`) + + content := statsHTML + `
` + actionsHTML + routesHTML + + html, _ := ui.Render(ui.PageData{ + Title: "Debug 控制台", + Description: "应用调试与监控中心", + Content: template.HTML(content), + }) ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - return c.HTML5(c.HTML5Props{Title: "/app/routes", Body: nodes}).Render(ctx) + return ctx.SendString(html) }) } + +type routeInfo struct { + Path string + Methods []string +} + +func getGroup(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) >= 2 { + return "/" + parts[0] + "/" + parts[1] + } + if len(parts) >= 1 { + return "/" + parts[0] + } + return "/" +} + +func methodColor(m string) string { + colors := map[string]string{ + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "purple", + } + if c, ok := colors[m]; ok { + return c + } + return "gray" +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + if d < time.Hour { + return fmt.Sprintf("%.0fm", d.Minutes()) + } + if d < 24*time.Hour { + return fmt.Sprintf("%.1fh", d.Hours()) + } + return fmt.Sprintf("%.1fd", d.Hours()/24) +} diff --git a/core/debug/debug/mux.go b/core/debug/debug/mux.go index 6a185feb..38e1f192 100644 --- a/core/debug/debug/mux.go +++ b/core/debug/debug/mux.go @@ -1,21 +1,20 @@ package debug import ( + "crypto/subtle" "net/http" "os" "strings" "sync" + "time" "github.com/gofiber/fiber/v2" - "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/config" "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/result" "github.com/pubgo/funk/v2/running" "github.com/pubgo/funk/v2/strutil" - "github.com/samber/lo" "github.com/valyala/fasthttp" "gopkg.in/yaml.v3" @@ -23,57 +22,129 @@ import ( ) var ( - passwd = running.InstanceID - once sync.Once + passwd = running.InstanceID + once sync.Once + configErr error + startTime = time.Now() ) +// 允许访问的本地 IP 列表 +var localHosts = map[string]bool{ + "localhost": true, + "127.0.0.1": true, + "::1": true, +} + +// secureCompare 使用常量时间比较防止时序攻击 +func secureCompare(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// isLocalHost 检查是否为本地访问 +func isLocalHost(host string) bool { + host = strings.Split(host, ":")[0] + return localHosts[host] +} + +// loadConfig 加载配置(只执行一次) +func loadConfig() { + once.Do(func() { + configPath := config.GetConfigPath() + if configPath == "" { + log.Warn().Msg("debug: config path is empty, using default password") + return + } + + configBytes, err := os.ReadFile(configPath) + if err != nil { + configErr = errors.WrapCaller(err) + log.Err(err).Str("path", configPath).Msg("debug: failed to read config file") + return + } + + var cfg debug.Config + if err := yaml.Unmarshal(configBytes, &cfg); err != nil { + configErr = errors.WrapCaller(err) + log.Err(err).Msg("debug: failed to parse config file") + return + } + + if cfg.Debug.Password != "" { + passwd = cfg.Debug.Password + } + }) +} + func init() { - // log.Info().Str("password", passwd).Msg("debug password") debug.App().Use(func(c *fiber.Ctx) (gErr error) { defer recovery.Recovery(func(err error) { err = errors.WrapTags(err, errors.Tags{ "headers": c.GetReqHeaders(), "url": c.Request().URI().String(), }) - gErr = c.JSON(err) + gErr = c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "success": false, + }) }) + // 检查是否为 WebSocket 升级请求 + isWebSocket := strings.EqualFold(string(c.Request().Header.Peek("Upgrade")), "websocket") + + // 提取 token token := strutil.FirstFnNotEmpty( func() string { return c.Query("token") }, func() string { return string(c.Request().Header.Peek("token")) }, + func() string { return string(c.Request().Header.Peek("Authorization")) }, func() string { return c.Cookies("token") }, ) - once.Do(func() { - configBytes := assert.Must1(os.ReadFile(config.GetConfigPath())) - var cfg debug.Config - assert.Must(yaml.Unmarshal(configBytes, &cfg)) - passwd = cfg.Debug.Password - }) + // 移除 Bearer 前缀 + token = strings.TrimPrefix(token, "Bearer ") + + // 加载配置 + loadConfig() + + // 非本地访问需要验证 token + if !isLocalHost(c.Hostname()) { + if !secureCompare(token, passwd) { + log.Warn(). + Str("ip", c.IP()). + Str("path", c.Path()). + Msg("debug: unauthorized access attempt") - host := strings.Split(c.Hostname(), ":")[0] - if host != "localhost" && host != "127.0.0.1" { - if token != passwd { - err := errors.New("token 不存在或者密码不对") - if result.ThrowErr(&gErr, lo.T2(c.WriteString(err.Error())).B) { - return gErr - } - - if err := c.SendStatus(http.StatusInternalServerError); err != nil { - return errors.WrapCaller(err) - } - return err + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ + "error": "unauthorized: invalid or missing token", + "success": false, + }) } } - cc := fasthttp.AcquireCookie() - defer fasthttp.ReleaseCookie(cc) + // WebSocket 请求不设置额外的响应头,避免干扰握手 + if !isWebSocket { + // 设置 cookie 以便后续请求 + if token != "" { + cc := fasthttp.AcquireCookie() + defer fasthttp.ReleaseCookie(cc) - cc.SetKey("token") - cc.SetValue(token) - c.Response().Header.SetCookie(cc) + cc.SetKey("token") + cc.SetValue(token) + cc.SetHTTPOnly(true) + cc.SetSameSite(fasthttp.CookieSameSiteStrictMode) + c.Response().Header.SetCookie(cc) + } + + // 添加响应头 + c.Set("X-Debug-Version", "1.0") + c.Set("X-Request-ID", running.InstanceID) + } - log.Info().Str("path", c.Request().URI().String()).Msg("request") + log.Debug(). + Str("method", c.Method()). + Str("path", c.Path()). + Str("ip", c.IP()). + Bool("websocket", isWebSocket). + Msg("debug request") return c.Next() }) diff --git a/core/debug/goroutine/goroutine.go b/core/debug/goroutine/goroutine.go new file mode 100644 index 00000000..6eeb271b --- /dev/null +++ b/core/debug/goroutine/goroutine.go @@ -0,0 +1,446 @@ +package goroutine + +import ( + "fmt" + "html/template" + "runtime" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/pubgo/funk/v2/log" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var ( + monitoring atomic.Bool + monitorMu sync.Mutex + goroutineStats []goroutineStat + maxStats = 100 +) + +type goroutineStat struct { + Timestamp time.Time `json:"timestamp"` + Count int `json:"count"` +} + +func init() { + // Goroutine 仪表板 HTML 页面 + debug.Get("/goroutine", func(ctx *fiber.Ctx) error { + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "count": runtime.NumGoroutine(), + }) + } + + monitorMu.Lock() + stats := make([]goroutineStat, len(goroutineStats)) + copy(stats, goroutineStats) + monitorMu.Unlock() + + isMonitoring := monitoring.Load() + monitorStatus := "停止" + if isMonitoring { + monitorStatus = "运行中" + } + + // 构建统计卡片 + statsContent := ui.StatsCard("Goroutine 数量", fmt.Sprintf("%d", runtime.NumGoroutine()), "当前") + statsContent += ui.StatsCard("监控状态", monitorStatus, fmt.Sprintf("已采集 %d 个样本", len(stats))) + statsContent += ui.StatsCard("最大样本", fmt.Sprintf("%d", maxStats), "保留数量") + statsContent += ui.StatsCard("CPU 核心", fmt.Sprintf("%d", runtime.NumCPU()), "GOMAXPROCS: "+fmt.Sprintf("%d", runtime.GOMAXPROCS(0))) + + // 趋势分析 + trendContent := template.HTML("") + if len(stats) >= 2 { + first := stats[0].Count + last := stats[len(stats)-1].Count + diff := last - first + trend := "稳定" + trendColor := "blue" + if diff > 10 { + trend = "上升 ↑" + trendColor = "yellow" + } else if diff < -10 { + trend = "下降 ↓" + trendColor = "green" + } + isPossibleLeak := diff > 50 + leakWarning := "" + if isPossibleLeak { + leakWarning = fmt.Sprintf(`
⚠️ 可能存在 Goroutine 泄漏!增长了 %d 个
`, diff) + } + trendContent = template.HTML(fmt.Sprintf(` +
+
+
初始数量
+
%d
+
+
+
当前数量
+
%d
+
+
+
变化
+
%+d
+
+
+
趋势
+
%s
+
+
+%s`, first, last, diff, ui.Badge(trend, trendColor), leakWarning)) + } else { + trendContent = ui.Alert("需要更多数据点,请先启动监控", "yellow") + } + + // 图表数据 + chartData := "[]" + if len(stats) > 0 { + points := make([]string, 0, len(stats)) + for _, s := range stats { + points = append(points, fmt.Sprintf(`{x:"%s",y:%d}`, s.Timestamp.Format("15:04:05"), s.Count)) + } + chartData = "[" + concatStrings(points, ",") + "]" + } + + // 操作按钮和图表 + actionsContent := template.HTML(fmt.Sprintf(` +
+ + + 查看堆栈 + Goroutine 分析 + +
+
+ +
+ +`, + map[bool]string{true: "bg-red-600 hover:bg-red-700", false: "bg-green-600 hover:bg-green-700"}[isMonitoring], + map[bool]string{true: "停止监控", false: "开始监控"}[isMonitoring], + isMonitoring, chartData)) + + // 组合内容 + content := template.HTML(fmt.Sprintf(` +
%s
+%s +%s`, + statsContent, + ui.Card("趋势分析", trendContent), + ui.Card("监控图表", actionsContent))) + + html, err := ui.Render(ui.PageData{ + Title: "Goroutine 监控", + Description: "Goroutine 数量监控和泄漏检测", + Breadcrumb: []string{"Goroutine"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Get("/goroutine/count", func(ctx *fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "count": runtime.NumGoroutine(), + }) + }) + + debug.Get("/goroutine/stack", func(ctx *fiber.Ctx) error { + buf := make([]byte, 1024*1024) + n := runtime.Stack(buf, true) + ctx.Set("Content-Type", "text/plain; charset=utf-8") + return ctx.Send(buf[:n]) + }) + + debug.Get("/goroutine/stats", func(ctx *fiber.Ctx) error { + monitorMu.Lock() + stats := make([]goroutineStat, len(goroutineStats)) + copy(stats, goroutineStats) + monitorMu.Unlock() + + return ctx.JSON(fiber.Map{ + "monitoring": monitoring.Load(), + "count": len(stats), + "stats": stats, + }) + }) + + debug.Post("/goroutine/monitor/start", func(ctx *fiber.Ctx) error { + if monitoring.Load() { + return ctx.JSON(fiber.Map{ + "success": false, + "message": "monitoring already started", + }) + } + + monitoring.Store(true) + go monitorGoroutines() + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "monitoring started", + }) + }) + + debug.Post("/goroutine/monitor/stop", func(ctx *fiber.Ctx) error { + if !monitoring.Load() { + return ctx.JSON(fiber.Map{ + "success": false, + "message": "monitoring not started", + }) + } + + monitoring.Store(false) + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "monitoring stopped", + }) + }) + + debug.Post("/goroutine/monitor/clear", func(ctx *fiber.Ctx) error { + monitorMu.Lock() + goroutineStats = nil + monitorMu.Unlock() + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "stats cleared", + }) + }) + + debug.Get("/goroutine/profile", func(ctx *fiber.Ctx) error { + profiles := make([]fiber.Map, 0) + profileMap := make(map[string]int) + + buf := make([]byte, 1024*1024) + n := runtime.Stack(buf, true) + stacks := string(buf[:n]) + + lines := splitLines(stacks) + var currentStack string + for _, line := range lines { + if line == "" { + if currentStack != "" { + profileMap[currentStack]++ + currentStack = "" + } + continue + } + if len(line) > 0 && line[0] != '\t' && line[0] != ' ' { + if currentStack != "" { + profileMap[currentStack]++ + } + currentStack = line + } + } + + type kv struct { + Key string + Value int + } + var sorted []kv + for k, v := range profileMap { + sorted = append(sorted, kv{k, v}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Value > sorted[j].Value + }) + + for _, item := range sorted { + profiles = append(profiles, fiber.Map{ + "state": item.Key, + "count": item.Value, + }) + } + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "total": runtime.NumGoroutine(), + "unique_profiles": len(profiles), + "profiles": profiles, + }) + }) + + debug.Get("/goroutine/leak/check", func(ctx *fiber.Ctx) error { + monitorMu.Lock() + stats := make([]goroutineStat, len(goroutineStats)) + copy(stats, goroutineStats) + monitorMu.Unlock() + + if len(stats) < 2 { + return ctx.JSON(fiber.Map{ + "status": "insufficient_data", + "message": "need more data points, start monitoring first", + }) + } + + first := stats[0].Count + last := stats[len(stats)-1].Count + diff := last - first + trend := "stable" + if diff > 10 { + trend = "increasing" + } else if diff < -10 { + trend = "decreasing" + } + + return ctx.JSON(fiber.Map{ + "status": "ok", + "trend": trend, + "initial_count": first, + "current_count": last, + "difference": diff, + "sample_count": len(stats), + "possible_leak": trend == "increasing" && diff > 50, + }) + }) +} + +func monitorGoroutines() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for monitoring.Load() { + select { + case <-ticker.C: + stat := goroutineStat{ + Timestamp: time.Now(), + Count: runtime.NumGoroutine(), + } + + monitorMu.Lock() + goroutineStats = append(goroutineStats, stat) + if len(goroutineStats) > maxStats { + goroutineStats = goroutineStats[len(goroutineStats)-maxStats:] + } + monitorMu.Unlock() + + log.Debug().Int("count", stat.Count).Msg("goroutine count") + } + } +} + +func splitLines(s string) []string { + var lines []string + var current string + for _, c := range s { + if c == '\n' { + lines = append(lines, current) + current = "" + } else { + current += fmt.Sprintf("%c", c) + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func concatStrings(strs []string, sep string) string { + if len(strs) == 0 { + return "" + } + result := strs[0] + for i := 1; i < len(strs); i++ { + result += sep + strs[i] + } + return result +} diff --git a/core/debug/healthy/debug.go b/core/debug/healthy/debug.go index 4b3b1ac4..8e7a98b2 100644 --- a/core/debug/healthy/debug.go +++ b/core/debug/healthy/debug.go @@ -1,6 +1,8 @@ package healthy import ( + "fmt" + "html/template" "net/http" "time" @@ -9,37 +11,191 @@ import ( "github.com/pubgo/funk/v2/try" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" "github.com/pubgo/lava/v2/core/healthy" ) func init() { debug.Get("/health", func(ctx *fiber.Ctx) error { dt := make(map[string]*health) + allHealthy := true + healthyCount := 0 + unhealthyCount := 0 + for _, name := range healthy.List() { - h := &health{} - h.Err = try.Try(func() error { + h := &health{ + Status: "healthy", + } + h.Error = try.Try(func() error { defer func(s time.Time) { h.Cost = time.Since(s).String() }(time.Now()) return healthy.Get(name)(ctx) }) + + if h.Error != nil { + h.Status = "unhealthy" + h.ErrMsg = h.Error.Error() + h.Error = nil + allHealthy = false + unhealthyCount++ + } else { + healthyCount++ + } dt[name] = h } - bts, err := jjson.Marshal(dt) - if err != nil { - ctx.Status(http.StatusInternalServerError) - _, err = ctx.Write([]byte(err.Error())) + // JSON 响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + bts, err := jjson.Marshal(fiber.Map{ + "status": statusString(allHealthy), + "timestamp": time.Now().Format(time.RFC3339), + "components": dt, + }) + if err != nil { + ctx.Status(http.StatusInternalServerError) + _, err = ctx.Write([]byte(err.Error())) + return err + } + + ctx.Response().Header.Set("content-type", "application/json") + if allHealthy { + ctx.Status(http.StatusOK) + } else { + ctx.Status(http.StatusServiceUnavailable) + } + _, err = ctx.Write(bts) return err } - ctx.Response().Header.Set("content-type", "application/json") - ctx.Status(http.StatusOK) - _, err = ctx.Write(bts) - return err + // HTML 页面 + overallStatus := "healthy" + overallColor := "green" + if !allHealthy { + overallStatus = "unhealthy" + overallColor = "red" + } + + // 统计卡片 + statsContent := ui.StatsCard("总体状态", overallStatus, "") + statsContent += ui.StatsCard("健康组件", fmt.Sprintf("%d", healthyCount), "") + statsContent += ui.StatsCard("异常组件", fmt.Sprintf("%d", unhealthyCount), "") + statsContent += ui.StatsCard("总组件数", fmt.Sprintf("%d", len(dt)), "") + + // 组件列表 + componentsContent := template.HTML("") + for name, h := range dt { + statusColor := "green" + statusIcon := "✓" + if h.Status == "unhealthy" { + statusColor = "red" + statusIcon = "✗" + } + + errorSection := "" + if h.ErrMsg != "" { + errorSection = fmt.Sprintf(`
%s
`, h.ErrMsg) + } + + componentsContent += template.HTML(fmt.Sprintf(` +
+
+ %s + %s %s +
+
耗时: %s
+ %s +
`, name, statusColor, statusColor, statusIcon, h.Status, h.Cost, errorSection)) + } + + if len(dt) == 0 { + componentsContent = ui.Alert("暂无健康检查组件注册", "yellow") + } + + // 快速操作 + actionsContent := template.HTML(` +`) + + // 总体状态显示 + statusBanner := template.HTML(fmt.Sprintf(` +
+
+
+ %s +
+
系统%s
+
最后检查: %s
+
+
+
+
`, + overallColor, overallColor, + map[bool]string{true: "✅", false: "❌"}[allHealthy], + overallColor, + map[bool]string{true: "健康", false: "异常"}[allHealthy], + time.Now().Format("15:04:05"))) + + content := template.HTML(fmt.Sprintf(` +%s +
%s
+%s +
+
+

组件状态

+
+
%s
+
`, + statusBanner, + statsContent, + ui.Card("快速操作", actionsContent), + componentsContent)) + + html, err := ui.Render(ui.PageData{ + Title: "健康检查", + Description: "系统健康状态监控", + Breadcrumb: []string{"Health"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + // 简单的存活检查 + debug.Get("/healthz", func(ctx *fiber.Ctx) error { + return ctx.SendString("ok") }) + + // 就绪检查 + debug.Get("/readyz", func(ctx *fiber.Ctx) error { + for _, name := range healthy.List() { + if err := healthy.Get(name)(ctx); err != nil { + ctx.Status(http.StatusServiceUnavailable) + return ctx.JSON(fiber.Map{ + "status": "not ready", + "component": name, + "error": err.Error(), + }) + } + } + return ctx.SendString("ok") + }) +} + +func statusString(healthy bool) string { + if healthy { + return "healthy" + } + return "unhealthy" } type health struct { - Cost string `json:"cost,omitempty"` - Err error `json:"err,omitempty"` - Msg string `json:"err_msg,omitempty"` + Status string `json:"status"` + Cost string `json:"cost,omitempty"` + Error error `json:"-"` + ErrMsg string `json:"error,omitempty"` } diff --git a/core/debug/loglevel/loglevel.go b/core/debug/loglevel/loglevel.go new file mode 100644 index 00000000..f0f02024 --- /dev/null +++ b/core/debug/loglevel/loglevel.go @@ -0,0 +1,219 @@ +package loglevel + +import ( + "fmt" + "html/template" + "strings" + "sync/atomic" + + "github.com/gofiber/fiber/v2" + "github.com/pubgo/funk/v2/log" + "github.com/rs/zerolog" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var currentLevel atomic.Value + +func init() { + currentLevel.Store(zerolog.GlobalLevel().String()) + + debug.Get("/log/level", func(ctx *fiber.Ctx) error { + // JSON 格式响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + return ctx.JSON(fiber.Map{ + "level": currentLevel.Load(), + "available_levels": getAvailableLevels(), + }) + } + + // HTML 页面 + current := currentLevel.Load().(string) + levels := getAvailableLevels() + + // 构建级别选择器 + levelOptions := "" + for _, level := range levels { + selected := "" + if level == current { + selected = "selected" + } + levelColor := getLevelColor(level) + levelOptions += fmt.Sprintf(``, level, selected, levelColor, strings.ToUpper(level)) + } + + // 级别颜色映射 + levelBadges := template.HTML("") + for _, level := range levels { + active := "" + if level == current { + active = "ring-2 ring-white" + } + levelBadges += template.HTML(fmt.Sprintf(`%s`, + getLevelBgColor(level), active, level, strings.ToUpper(level))) + } + + content := template.HTML(fmt.Sprintf(` +
+ %s + %s + %s +
+
+
+

日志级别选择

+
+
+
%s
+
+
级别说明:
+
    +
  • TRACE - 最详细的追踪信息
  • +
  • DEBUG - 调试信息
  • +
  • INFO - 一般信息
  • +
  • WARN - 警告信息
  • +
  • ERROR - 错误信息
  • +
  • FATAL - 致命错误
  • +
  • PANIC - 严重错误
  • +
  • DISABLED - 禁用日志
  • +
+
+
+
+ +`, + ui.StatsCard("当前级别", strings.ToUpper(current), ""), + ui.StatsCard("可用级别", fmt.Sprintf("%d", len(levels)), ""), + ui.StatsCard("全局日志", "zerolog", ""), + levelBadges)) + + html, err := ui.Render(ui.PageData{ + Title: "日志级别管理", + Description: "动态调整应用日志级别", + Breadcrumb: []string{"Log", "Level"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Put("/log/level", func(ctx *fiber.Ctx) error { + return setLogLevel(ctx) + }) + + debug.Post("/log/level", func(ctx *fiber.Ctx) error { + return setLogLevel(ctx) + }) +} + +func setLogLevel(ctx *fiber.Ctx) error { + type request struct { + Level string `json:"level" form:"level" query:"level"` + } + + var req request + if err := ctx.BodyParser(&req); err != nil { + req.Level = ctx.Query("level") + } + + if req.Level == "" { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "level is required", + "available_levels": getAvailableLevels(), + }) + } + + level, err := zerolog.ParseLevel(strings.ToLower(req.Level)) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid log level: " + req.Level, + "available_levels": getAvailableLevels(), + }) + } + + oldLevel := currentLevel.Load() + zerolog.SetGlobalLevel(level) + currentLevel.Store(level.String()) + + log.Info(). + Str("old_level", oldLevel.(string)). + Str("new_level", level.String()). + Msg("log level changed") + + return ctx.JSON(fiber.Map{ + "success": true, + "old_level": oldLevel, + "new_level": level.String(), + }) +} + +func getAvailableLevels() []string { + return []string{ + zerolog.TraceLevel.String(), + zerolog.DebugLevel.String(), + zerolog.InfoLevel.String(), + zerolog.WarnLevel.String(), + zerolog.ErrorLevel.String(), + zerolog.FatalLevel.String(), + zerolog.PanicLevel.String(), + zerolog.Disabled.String(), + } +} + +func getLevelColor(level string) string { + colors := map[string]string{ + "trace": "text-purple-400", + "debug": "text-blue-400", + "info": "text-green-400", + "warn": "text-yellow-400", + "error": "text-red-400", + "fatal": "text-red-600", + "panic": "text-red-800", + "disabled": "text-gray-500", + } + if c, ok := colors[level]; ok { + return c + } + return "text-gray-400" +} + +func getLevelBgColor(level string) string { + colors := map[string]string{ + "trace": "bg-purple-500/20 text-purple-400", + "debug": "bg-blue-500/20 text-blue-400", + "info": "bg-green-500/20 text-green-400", + "warn": "bg-yellow-500/20 text-yellow-400", + "error": "bg-red-500/20 text-red-400", + "fatal": "bg-red-600/20 text-red-500", + "panic": "bg-red-800/20 text-red-600", + "disabled": "bg-gray-500/20 text-gray-500", + } + if c, ok := colors[level]; ok { + return c + } + return "bg-gray-500/20 text-gray-400" +} diff --git a/core/debug/ratelimit/ratelimit.go b/core/debug/ratelimit/ratelimit.go new file mode 100644 index 00000000..e1661a37 --- /dev/null +++ b/core/debug/ratelimit/ratelimit.go @@ -0,0 +1,158 @@ +package ratelimit + +import ( + "strconv" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + + "github.com/pubgo/lava/v2/core/debug" +) + +var ( + enabled = false + limit = 100 + window = time.Minute + requests = make(map[string]*requestCounter) + requestsMu sync.RWMutex +) + +type requestCounter struct { + Count int + ResetTime time.Time +} + +func init() { + debug.App().Use(rateLimitMiddleware) + + debug.Get("/ratelimit", func(ctx *fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "enabled": enabled, + "limit": limit, + "window": window.String(), + "window_seconds": window.Seconds(), + }) + }) + + debug.Post("/ratelimit/enable", func(ctx *fiber.Ctx) error { + enabled = true + return ctx.JSON(fiber.Map{ + "success": true, + "enabled": enabled, + }) + }) + + debug.Post("/ratelimit/disable", func(ctx *fiber.Ctx) error { + enabled = false + return ctx.JSON(fiber.Map{ + "success": true, + "enabled": enabled, + }) + }) + + debug.Put("/ratelimit/config", func(ctx *fiber.Ctx) error { + type request struct { + Limit int `json:"limit" form:"limit" query:"limit"` + Window int `json:"window" form:"window" query:"window"` + } + + var req request + if err := ctx.BodyParser(&req); err != nil { + if l := ctx.QueryInt("limit", 0); l > 0 { + req.Limit = l + } + if w := ctx.QueryInt("window", 0); w > 0 { + req.Window = w + } + } + + if req.Limit > 0 { + limit = req.Limit + } + if req.Window > 0 { + window = time.Duration(req.Window) * time.Second + } + + return ctx.JSON(fiber.Map{ + "success": true, + "limit": limit, + "window": window.String(), + "window_seconds": window.Seconds(), + }) + }) + + debug.Get("/ratelimit/stats", func(ctx *fiber.Ctx) error { + requestsMu.RLock() + stats := make(map[string]fiber.Map) + for ip, counter := range requests { + stats[ip] = fiber.Map{ + "count": counter.Count, + "reset_time": counter.ResetTime.Format(time.RFC3339), + "remaining": limit - counter.Count, + } + } + requestsMu.RUnlock() + + return ctx.JSON(fiber.Map{ + "enabled": enabled, + "limit": limit, + "window": window.String(), + "active_users": len(stats), + "stats": stats, + }) + }) + + debug.Post("/ratelimit/reset", func(ctx *fiber.Ctx) error { + requestsMu.Lock() + requests = make(map[string]*requestCounter) + requestsMu.Unlock() + + return ctx.JSON(fiber.Map{ + "success": true, + "message": "rate limit stats cleared", + }) + }) +} + +func rateLimitMiddleware(ctx *fiber.Ctx) error { + if !enabled { + return ctx.Next() + } + + ip := ctx.IP() + + requestsMu.Lock() + counter, exists := requests[ip] + if !exists || time.Now().After(counter.ResetTime) { + counter = &requestCounter{ + Count: 0, + ResetTime: time.Now().Add(window), + } + requests[ip] = counter + } + counter.Count++ + count := counter.Count + resetTime := counter.ResetTime + requestsMu.Unlock() + + remaining := limit - count + if remaining < 0 { + remaining = 0 + } + + ctx.Set("X-RateLimit-Limit", strconv.Itoa(limit)) + ctx.Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) + ctx.Set("X-RateLimit-Reset", resetTime.Format(time.RFC3339)) + + if count > limit { + return ctx.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "rate limit exceeded", + "limit": limit, + "remaining": 0, + "reset": resetTime.Format(time.RFC3339), + }) + } + + return ctx.Next() +} diff --git a/core/debug/runtime/runtime.go b/core/debug/runtime/runtime.go new file mode 100644 index 00000000..7ca6a7f6 --- /dev/null +++ b/core/debug/runtime/runtime.go @@ -0,0 +1,395 @@ +package runtime + +import ( + "fmt" + "html/template" + "runtime" + rd "runtime/debug" + "runtime/metrics" + "sort" + "time" + + "github.com/gofiber/fiber/v2" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var runtimeStartTime = time.Now() + +func init() { + // Runtime 仪表板 HTML 页面 + debug.Get("/runtime", func(ctx *fiber.Ctx) error { + // 如果请求 JSON 格式 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "go_version": runtime.Version(), + "go_os": runtime.GOOS, + "go_arch": runtime.GOARCH, + "num_cpu": runtime.NumCPU(), + "num_goroutine": runtime.NumGoroutine(), + "gomaxprocs": runtime.GOMAXPROCS(0), + "heap_alloc": m.HeapAlloc, + "heap_sys": m.HeapSys, + "uptime": time.Since(runtimeStartTime).String(), + }) + } + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + heapPercent := float64(m.HeapInuse) / float64(m.HeapSys) * 100 + heapColor := "green" + if heapPercent > 80 { + heapColor = "red" + } else if heapPercent > 60 { + heapColor = "yellow" + } + + // 构建统计卡片 + statsContent := ui.StatsCard("Go 版本", runtime.Version(), runtime.GOOS+"/"+runtime.GOARCH) + statsContent += ui.StatsCard("CPU", fmt.Sprintf("%d/%d", runtime.GOMAXPROCS(0), runtime.NumCPU()), "GOMAXPROCS/NumCPU") + statsContent += ui.StatsCard("Goroutines", fmt.Sprintf("%d", runtime.NumGoroutine()), "当前活跃") + statsContent += ui.StatsCard("Cgo 调用", fmt.Sprintf("%d", runtime.NumCgoCall()), "累计") + + // 内存卡片内容 + memContent := template.HTML(fmt.Sprintf(` +
+
+ 堆内存使用 + %s / %s +
+ %s +
+
+
Heap Alloc
+
%s
+
+
+
Heap Objects
+
%d
+
+
+
Stack Inuse
+
%s
+
+
+
Total Alloc
+
%s
+
+
+
`, ui.FormatBytes(m.HeapInuse), ui.FormatBytes(m.HeapSys), ui.ProgressBar(heapPercent, heapColor), + ui.FormatBytes(m.HeapAlloc), m.HeapObjects, ui.FormatBytes(m.StackInuse), ui.FormatBytes(m.TotalAlloc))) + + // GC 卡片内容 + gcContent := template.HTML(fmt.Sprintf(` +
+
+
GC 次数
+
%d
+
+
+
GC CPU
+
%.4f%%
+
+
+
上次 GC
+
%s
+
+
+
下次 GC 阈值
+
%s
+
+
`, m.NumGC, m.GCCPUFraction*100, time.Unix(0, int64(m.LastGC)).Format("15:04:05"), ui.FormatBytes(m.NextGC))) + + // 操作按钮 + actionsContent := template.HTML(` +
+ 内存详情 + GC 详情 + 所有指标 + + +
+`) + + // 组合内容 + content := template.HTML(fmt.Sprintf(` +
%s
+%s +
+ %s + %s +
+
+

运行时 %s

+
Sys: %s | Uptime: %s
+
`, + statsContent, + ui.CardWithAction("快捷操作", "", actionsContent), + ui.Card("内存状态", memContent), + ui.Card("GC 状态", gcContent), + time.Since(runtimeStartTime).Round(time.Second).String(), + ui.FormatBytes(m.Sys), + time.Since(runtimeStartTime).Round(time.Second).String())) + + html, err := ui.Render(ui.PageData{ + Title: "Runtime 监控", + Description: "Go 运行时状态和内存监控", + Breadcrumb: []string{"Runtime"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + // 获取所有 runtime/metrics 指标 + debug.Get("/runtime/metrics", func(ctx *fiber.Ctx) error { + descs := metrics.All() + samples := make([]metrics.Sample, len(descs)) + for i := range descs { + samples[i].Name = descs[i].Name + } + metrics.Read(samples) + + result := make(map[string]any, len(samples)) + for _, sample := range samples { + result[sample.Name] = formatMetricValue(sample.Value) + } + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "metrics": result, + }) + }) + + // 内存统计 + debug.Get("/runtime/memory", func(ctx *fiber.Ctx) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "memory": fiber.Map{ + "heap_alloc_bytes": m.HeapAlloc, + "heap_sys_bytes": m.HeapSys, + "heap_idle_bytes": m.HeapIdle, + "heap_inuse_bytes": m.HeapInuse, + "heap_released_bytes": m.HeapReleased, + "heap_objects": m.HeapObjects, + "stack_inuse_bytes": m.StackInuse, + "stack_sys_bytes": m.StackSys, + "alloc_bytes": m.Alloc, + "total_alloc_bytes": m.TotalAlloc, + "sys_bytes": m.Sys, + "mallocs": m.Mallocs, + "frees": m.Frees, + "gc_sys_bytes": m.GCSys, + "gc_next_bytes": m.NextGC, + "gc_num": m.NumGC, + "gc_cpu_fraction": m.GCCPUFraction, + }, + }) + }) + + // 运行时信息 + debug.Get("/runtime/info", func(ctx *fiber.Ctx) error { + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "go_version": runtime.Version(), + "go_os": runtime.GOOS, + "go_arch": runtime.GOARCH, + "num_cpu": runtime.NumCPU(), + "num_goroutine": runtime.NumGoroutine(), + "num_cgo_call": runtime.NumCgoCall(), + "gomaxprocs": runtime.GOMAXPROCS(0), + "uptime": time.Since(runtimeStartTime).String(), + "uptime_seconds": time.Since(runtimeStartTime).Seconds(), + }) + }) + + // GC 统计 + debug.Get("/runtime/gc", func(ctx *fiber.Ctx) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "gc": fiber.Map{ + "num_gc": m.NumGC, + "num_forced_gc": m.NumForcedGC, + "gc_cpu_fraction": m.GCCPUFraction, + "pause_total_ns": m.PauseTotalNs, + "pause_total_ms": float64(m.PauseTotalNs) / 1e6, + "last_gc": time.Unix(0, int64(m.LastGC)).Format(time.RFC3339), + "next_gc_bytes": m.NextGC, + "recent_pauses": recentPauses(&m), + }, + }) + }) + + // 手动触发 GC + debug.Post("/runtime/gc/trigger", func(ctx *fiber.Ctx) error { + before := runtime.NumGoroutine() + var mBefore runtime.MemStats + runtime.ReadMemStats(&mBefore) + + runtime.GC() + + var mAfter runtime.MemStats + runtime.ReadMemStats(&mAfter) + after := runtime.NumGoroutine() + + return ctx.JSON(fiber.Map{ + "success": true, + "timestamp": time.Now().Format(time.RFC3339), + "before": fiber.Map{ + "heap_alloc_bytes": mBefore.HeapAlloc, + "heap_objects": mBefore.HeapObjects, + "goroutines": before, + }, + "after": fiber.Map{ + "heap_alloc_bytes": mAfter.HeapAlloc, + "heap_objects": mAfter.HeapObjects, + "goroutines": after, + }, + "freed_bytes": int64(mBefore.HeapAlloc) - int64(mAfter.HeapAlloc), + }) + }) + + // 释放内存给操作系统 + debug.Post("/runtime/freemem", func(ctx *fiber.Ctx) error { + var mBefore runtime.MemStats + runtime.ReadMemStats(&mBefore) + + runtime.GC() + rd.FreeOSMemory() + + var mAfter runtime.MemStats + runtime.ReadMemStats(&mAfter) + + return ctx.JSON(fiber.Map{ + "success": true, + "timestamp": time.Now().Format(time.RFC3339), + "before": fiber.Map{ + "heap_sys_bytes": mBefore.HeapSys, + "heap_released_bytes": mBefore.HeapReleased, + }, + "after": fiber.Map{ + "heap_sys_bytes": mAfter.HeapSys, + "heap_released_bytes": mAfter.HeapReleased, + }, + }) + }) + + // 可用的 metrics 描述 + debug.Get("/runtime/metrics/desc", func(ctx *fiber.Ctx) error { + descs := metrics.All() + result := make([]fiber.Map, 0, len(descs)) + + for _, desc := range descs { + result = append(result, fiber.Map{ + "name": desc.Name, + "description": desc.Description, + "kind": kindString(desc.Kind), + "cumulative": desc.Cumulative, + }) + } + + sort.Slice(result, func(i, j int) bool { + return result[i]["name"].(string) < result[j]["name"].(string) + }) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "count": len(result), + "metrics": result, + }) + }) +} + +func formatMetricValue(v metrics.Value) any { + switch v.Kind() { + case metrics.KindUint64: + return v.Uint64() + case metrics.KindFloat64: + return v.Float64() + case metrics.KindFloat64Histogram: + h := v.Float64Histogram() + return fiber.Map{ + "buckets": h.Buckets, + "counts": h.Counts, + } + case metrics.KindBad: + return "bad metric" + default: + return "unknown" + } +} + +func kindString(k metrics.ValueKind) string { + switch k { + case metrics.KindUint64: + return "uint64" + case metrics.KindFloat64: + return "float64" + case metrics.KindFloat64Histogram: + return "float64_histogram" + case metrics.KindBad: + return "bad" + default: + return "unknown" + } +} + +func recentPauses(m *runtime.MemStats) []uint64 { + n := int(m.NumGC) + if n > 256 { + n = 256 + } + if n > 10 { + n = 10 + } + + pauses := make([]uint64, n) + for i := 0; i < n; i++ { + idx := (int(m.NumGC) - 1 - i) % 256 + pauses[i] = m.PauseNs[idx] + } + return pauses +} diff --git a/core/debug/sysinfo/sysinfo.go b/core/debug/sysinfo/sysinfo.go new file mode 100644 index 00000000..83688bd0 --- /dev/null +++ b/core/debug/sysinfo/sysinfo.go @@ -0,0 +1,435 @@ +package sysinfo + +import ( + "fmt" + "html/template" + "os" + "runtime" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/pubgo/funk/v2/running" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/load" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" + psprocess "github.com/shirou/gopsutil/v3/process" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" +) + +var sysStartTime = time.Now() + +func init() { + // 系统信息仪表板 HTML 页面 + debug.Get("/sys", func(ctx *fiber.Ctx) error { + vmem, _ := mem.VirtualMemory() + cpuPercent, _ := cpu.Percent(0, false) + loadAvg, _ := load.Avg() + hostInfo, _ := host.Info() + + // JSON 响应 + if ctx.Get("Accept") == "application/json" || ctx.Query("format") == "json" { + pid := int32(os.Getpid()) + proc, _ := psprocess.NewProcess(pid) + procCPU, _ := proc.CPUPercent() + procMem, _ := proc.MemoryPercent() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "system": fiber.Map{ + "hostname": hostInfo.Hostname, + "platform": hostInfo.Platform, + "cpu_percent": cpuPercent, + "load_avg": loadAvg, + "memory_total": vmem.Total, + "memory_used": vmem.Used, + "memory_percent": vmem.UsedPercent, + }, + "process": fiber.Map{ + "pid": pid, + "cpu_percent": procCPU, + "memory_percent": procMem, + "goroutines": runtime.NumGoroutine(), + "heap_alloc": m.HeapAlloc, + }, + }) + } + + // HTML 页面 + partitions, _ := disk.Partitions(false) + + pid := int32(os.Getpid()) + proc, _ := psprocess.NewProcess(pid) + procCPU, _ := proc.CPUPercent() + procMem, _ := proc.MemoryPercent() + numThreads, _ := proc.NumThreads() + numFDs, _ := proc.NumFDs() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + // CPU 使用率 + cpuVal := float64(0) + if len(cpuPercent) > 0 { + cpuVal = cpuPercent[0] + } + cpuColor := "green" + if cpuVal > 80 { + cpuColor = "red" + } else if cpuVal > 60 { + cpuColor = "yellow" + } + + // 内存使用率 + memColor := "green" + if vmem.UsedPercent > 80 { + memColor = "red" + } else if vmem.UsedPercent > 60 { + memColor = "yellow" + } + + // 系统概览卡片 + statsContent := ui.StatsCard("主机名", hostInfo.Hostname, hostInfo.Platform+" "+hostInfo.PlatformVersion) + statsContent += ui.StatsCard("CPU", fmt.Sprintf("%.1f%%", cpuVal), fmt.Sprintf("%d 核心, 负载: %.2f", runtime.NumCPU(), loadAvg.Load1)) + statsContent += ui.StatsCard("内存", fmt.Sprintf("%.1f%%", vmem.UsedPercent), fmt.Sprintf("%s / %s", ui.FormatBytes(vmem.Used), ui.FormatBytes(vmem.Total))) + statsContent += ui.StatsCard("运行时间", time.Since(sysStartTime).Round(time.Second).String(), fmt.Sprintf("开机: %s", formatDuration(hostInfo.Uptime))) + + // CPU 详情 + cpuContent := template.HTML(fmt.Sprintf(` +
+
+ CPU 使用率 + %.1f%% +
+ %s +
+
+
1 分钟负载
+
%.2f
+
+
+
5 分钟负载
+
%.2f
+
+
+
15 分钟负载
+
%.2f
+
+
+
`, cpuVal, ui.ProgressBar(cpuVal, cpuColor), loadAvg.Load1, loadAvg.Load5, loadAvg.Load15)) + + // 内存详情 + memContent := template.HTML(fmt.Sprintf(` +
+
+ 内存使用率 + %s / %s (%.1f%%) +
+ %s +
+
+
可用内存
+
%s
+
+
+
缓冲/缓存
+
%s
+
+
+
`, ui.FormatBytes(vmem.Used), ui.FormatBytes(vmem.Total), vmem.UsedPercent, + ui.ProgressBar(vmem.UsedPercent, memColor), + ui.FormatBytes(vmem.Available), ui.FormatBytes(vmem.Cached))) + + // 进程信息 + processContent := template.HTML(fmt.Sprintf(` +
+
+
PID
+
%d
+
+
+
CPU
+
%.1f%%
+
+
+
内存
+
%.1f%%
+
+
+
Goroutines
+
%d
+
+
+
线程
+
%d
+
+
+
文件描述符
+
%d
+
+
+
堆内存
+
%s
+
+
+
GC 次数
+
%d
+
+
`, pid, procCPU, procMem, runtime.NumGoroutine(), numThreads, numFDs, ui.FormatBytes(m.HeapAlloc), m.NumGC)) + + // 磁盘信息 + diskContent := template.HTML("") + for _, p := range partitions { + usage, err := disk.Usage(p.Mountpoint) + if err != nil { + continue + } + diskColor := "green" + if usage.UsedPercent > 90 { + diskColor = "red" + } else if usage.UsedPercent > 70 { + diskColor = "yellow" + } + diskContent += template.HTML(fmt.Sprintf(` +
+
+ %s + %s +
+
%s / %s (%.1f%%)
+ %s +
`, p.Mountpoint, p.Fstype, ui.FormatBytes(usage.Used), ui.FormatBytes(usage.Total), usage.UsedPercent, ui.ProgressBar(usage.UsedPercent, diskColor))) + } + if diskContent == "" { + diskContent = template.HTML(`
无磁盘信息
`) + } + + // 快速操作 + actionsContent := template.HTML(` +`) + + // 组合内容 + content := template.HTML(fmt.Sprintf(` +
%s
+%s +
+ %s + %s +
+%s +%s`, + statsContent, + ui.Card("快速操作", actionsContent), + ui.Card("CPU 状态", cpuContent), + ui.Card("内存状态", memContent), + ui.Card("当前进程", processContent), + ui.Card("磁盘使用", diskContent))) + + html, err := ui.Render(ui.PageData{ + Title: "系统信息", + Description: "系统资源监控和进程信息", + Breadcrumb: []string{"System"}, + Content: content, + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) + }) + + debug.Get("/sys/info", func(ctx *fiber.Ctx) error { + hostInfo, _ := host.Info() + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "host": hostInfo, + "go": fiber.Map{ + "version": runtime.Version(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "num_cpu": runtime.NumCPU(), + "gomaxprocs": runtime.GOMAXPROCS(0), + "num_goroutine": runtime.NumGoroutine(), + }, + "app": fiber.Map{ + "project": running.Project(), + "version": running.Version(), + "hostname": running.Hostname, + "instance_id": running.InstanceID, + "uptime": time.Since(sysStartTime).String(), + }, + }) + }) + + debug.Get("/sys/cpu", func(ctx *fiber.Ctx) error { + cpuInfo, _ := cpu.Info() + cpuPercent, _ := cpu.Percent(time.Second, false) + cpuTimes, _ := cpu.Times(false) + loadAvg, _ := load.Avg() + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "info": cpuInfo, + "percent": cpuPercent, + "times": cpuTimes, + "load_avg": loadAvg, + "num_cpu": runtime.NumCPU(), + "gomaxprocs": runtime.GOMAXPROCS(0), + }) + }) + + debug.Get("/sys/memory", func(ctx *fiber.Ctx) error { + vmem, _ := mem.VirtualMemory() + swap, _ := mem.SwapMemory() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "virtual": vmem, + "swap": swap, + "go_heap": fiber.Map{ + "alloc_bytes": m.HeapAlloc, + "sys_bytes": m.HeapSys, + "idle_bytes": m.HeapIdle, + "inuse_bytes": m.HeapInuse, + "objects": m.HeapObjects, + }, + }) + }) + + debug.Get("/sys/disk", func(ctx *fiber.Ctx) error { + partitions, _ := disk.Partitions(false) + var diskUsages []fiber.Map + for _, p := range partitions { + usage, err := disk.Usage(p.Mountpoint) + if err == nil { + diskUsages = append(diskUsages, fiber.Map{ + "device": p.Device, + "mountpoint": p.Mountpoint, + "fstype": p.Fstype, + "total": usage.Total, + "used": usage.Used, + "free": usage.Free, + "percent": usage.UsedPercent, + }) + } + } + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "disks": diskUsages, + }) + }) + + debug.Get("/sys/network", func(ctx *fiber.Ctx) error { + interfaces, _ := net.Interfaces() + ioCounters, _ := net.IOCounters(true) + connections, _ := net.Connections("all") + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "interfaces": interfaces, + "io_counters": ioCounters, + "connection_count": len(connections), + }) + }) + + debug.Get("/sys/process", func(ctx *fiber.Ctx) error { + pid := int32(os.Getpid()) + proc, err := psprocess.NewProcess(pid) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + cpuPercent, _ := proc.CPUPercent() + memInfo, _ := proc.MemoryInfo() + memPercent, _ := proc.MemoryPercent() + numThreads, _ := proc.NumThreads() + numFDs, _ := proc.NumFDs() + ioCounters, _ := proc.IOCounters() + connections, _ := proc.Connections() + openFiles, _ := proc.OpenFiles() + createTime, _ := proc.CreateTime() + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "pid": pid, + "cpu_percent": cpuPercent, + "memory_info": memInfo, + "memory_percent": memPercent, + "num_threads": numThreads, + "num_fds": numFDs, + "io_counters": ioCounters, + "connections": len(connections), + "open_files": len(openFiles), + "create_time": time.UnixMilli(createTime).Format(time.RFC3339), + "uptime": time.Since(sysStartTime).String(), + }) + }) + + debug.Get("/sys/summary", func(ctx *fiber.Ctx) error { + vmem, _ := mem.VirtualMemory() + cpuPercent, _ := cpu.Percent(0, false) + loadAvg, _ := load.Avg() + hostInfo, _ := host.Info() + + pid := int32(os.Getpid()) + proc, _ := psprocess.NewProcess(pid) + procCPU, _ := proc.CPUPercent() + procMem, _ := proc.MemoryPercent() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + return ctx.JSON(fiber.Map{ + "timestamp": time.Now().Format(time.RFC3339), + "system": fiber.Map{ + "hostname": hostInfo.Hostname, + "os": hostInfo.OS, + "platform": hostInfo.Platform, + "cpu_percent": cpuPercent, + "load_avg": loadAvg, + "memory_total": vmem.Total, + "memory_used": vmem.Used, + "memory_percent": vmem.UsedPercent, + }, + "process": fiber.Map{ + "pid": pid, + "cpu_percent": procCPU, + "memory_percent": procMem, + "goroutines": runtime.NumGoroutine(), + "heap_alloc": m.HeapAlloc, + "uptime": time.Since(sysStartTime).String(), + }, + }) + }) +} + +func formatDuration(seconds uint64) string { + d := time.Duration(seconds) * time.Second + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} diff --git a/core/debug/ui/ui.go b/core/debug/ui/ui.go new file mode 100644 index 00000000..c68308e7 --- /dev/null +++ b/core/debug/ui/ui.go @@ -0,0 +1,266 @@ +package ui + +import ( + "bytes" + "fmt" + "html/template" +) + +// BaseTemplate 基础 HTML 模板 +const BaseTemplate = ` + + + + + {{.Title}} - Debug Console + + + + {{if .ExtraHead}}{{.ExtraHead}}{{end}} + + + +
+ {{if .Breadcrumb}} + + {{end}} +
+

{{.Title}}

+ {{if .Description}}

{{.Description}}

{{end}} +
+ {{.Content}} +
+ + +` + +// PageData 页面数据 +type PageData struct { + Title string + Description string + Breadcrumb []string + Content template.HTML + ExtraHead template.HTML +} + +// Render 渲染基础模板 +func Render(data PageData) (string, error) { + tmpl, err := template.New("base").Parse(BaseTemplate) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +// Card 渲染卡片 +func Card(title string, content template.HTML) template.HTML { + return template.HTML(fmt.Sprintf(` +
+
+

%s

+
+
%s
+
`, title, content)) +} + +// CardWithAction 带操作按钮的卡片 +func CardWithAction(title string, actions, content template.HTML) template.HTML { + return template.HTML(fmt.Sprintf(` +
+
+

%s

+
%s
+
+
%s
+
`, title, actions, content)) +} + +// StatsCard 统计卡片 +func StatsCard(label, value, subtext string) template.HTML { + sub := "" + if subtext != "" { + sub = fmt.Sprintf(`
%s
`, subtext) + } + return template.HTML(fmt.Sprintf(` +
+
%s
+
%s
+ %s +
`, label, value, sub)) +} + +// Badge 徽章 +func Badge(text, color string) template.HTML { + colorClass := map[string]string{ + "green": "bg-green-500/20 text-green-400", + "red": "bg-red-500/20 text-red-400", + "yellow": "bg-yellow-500/20 text-yellow-400", + "blue": "bg-blue-500/20 text-blue-400", + "gray": "bg-gray-500/20 text-gray-400", + "purple": "bg-purple-500/20 text-purple-400", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["gray"] + } + return template.HTML(fmt.Sprintf(`%s`, cls, text)) +} + +// Button 按钮 +func Button(text, onclick, color string) template.HTML { + colorClass := map[string]string{ + "blue": "bg-blue-600 hover:bg-blue-700", + "green": "bg-green-600 hover:bg-green-700", + "red": "bg-red-600 hover:bg-red-700", + "yellow": "bg-yellow-600 hover:bg-yellow-700", + "gray": "bg-gray-600 hover:bg-gray-700", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["blue"] + } + return template.HTML(fmt.Sprintf(``, onclick, cls, text)) +} + +// Link 链接按钮 +func Link(text, href, color string) template.HTML { + colorClass := map[string]string{ + "blue": "text-blue-400 hover:text-blue-300", + "green": "text-green-400 hover:text-green-300", + "gray": "text-gray-400 hover:text-gray-300", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["blue"] + } + return template.HTML(fmt.Sprintf(`%s`, href, cls, text)) +} + +// Table 表格开始 +func Table(headers []string) template.HTML { + var h string + for _, header := range headers { + h += fmt.Sprintf(`%s`, header) + } + return template.HTML(fmt.Sprintf(` +
+ + %s + `, h)) +} + +// TableEnd 表格结束 +func TableEnd() template.HTML { + return template.HTML(`
`) +} + +// TR 表格行 +func TR(cells ...string) template.HTML { + var c string + for _, cell := range cells { + c += fmt.Sprintf(`%s`, cell) + } + return template.HTML(fmt.Sprintf(`%s`, c)) +} + +// Grid 网格布局 +func Grid(cols int, content template.HTML) template.HTML { + return template.HTML(fmt.Sprintf(`
%s
`, cols, content)) +} + +// JSONBlock JSON 代码块 +func JSONBlock(id string) template.HTML { + return template.HTML(fmt.Sprintf(`
`, id))
+}
+
+// FormatBytes 格式化字节
+func FormatBytes(b uint64) string {
+	const unit = 1024
+	if b < unit {
+		return fmt.Sprintf("%d B", b)
+	}
+	div, exp := uint64(unit), 0
+	for n := b / unit; n >= unit; n /= unit {
+		div *= unit
+		exp++
+	}
+	return fmt.Sprintf("%.2f %s", float64(b)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
+}
+
+// FormatPercent 格式化百分比
+func FormatPercent(p float64) string {
+	return fmt.Sprintf("%.1f%%", p)
+}
+
+// ProgressBar 进度条
+func ProgressBar(percent float64, color string) template.HTML {
+	colorClass := map[string]string{
+		"green":  "bg-green-500",
+		"red":    "bg-red-500",
+		"yellow": "bg-yellow-500",
+		"blue":   "bg-blue-500",
+	}
+	cls := colorClass[color]
+	if cls == "" {
+		cls = colorClass["blue"]
+	}
+	return template.HTML(fmt.Sprintf(`
+
+
+
`, cls, percent)) +} + +// Alert 警告框 +func Alert(text, color string) template.HTML { + colorClass := map[string]string{ + "green": "bg-green-500/10 border-green-500/50 text-green-400", + "red": "bg-red-500/10 border-red-500/50 text-red-400", + "yellow": "bg-yellow-500/10 border-yellow-500/50 text-yellow-400", + "blue": "bg-blue-500/10 border-blue-500/50 text-blue-400", + } + cls := colorClass[color] + if cls == "" { + cls = colorClass["blue"] + } + return template.HTML(fmt.Sprintf(`
%s
`, cls, text)) +} diff --git a/go.mod b/go.mod index 96b9be3e..231c5c91 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/samber/lo v1.52.0 + github.com/shirou/gopsutil/v3 v3.24.5 github.com/stretchr/testify v1.11.1 github.com/thejerf/suture/v4 v4.0.6 github.com/uber-go/tally/v4 v4.1.17 @@ -140,6 +141,7 @@ require ( github.com/ghostiam/protogetter v0.3.6 // indirect github.com/go-critic/go-critic v0.11.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect @@ -152,6 +154,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.1.0 // indirect github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/gofiber/contrib/websocket v1.3.4 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect @@ -199,6 +202,7 @@ require ( github.com/lmittmann/tint v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufeee/execinquery v1.2.1 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/macabu/inamedparam v0.1.3 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/maratori/testableexamples v1.0.0 // indirect @@ -226,6 +230,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.6.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect @@ -246,6 +251,7 @@ require ( github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect github.com/securego/gosec/v2 v2.21.2 // indirect github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/sivchari/tenv v1.10.0 // indirect @@ -266,6 +272,8 @@ require ( github.com/tetafro/godot v1.4.17 // indirect github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 // indirect github.com/timonwong/loggercheck v0.9.4 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.9.0 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/twmb/murmur3 v1.1.8 // indirect @@ -277,6 +285,7 @@ require ( github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect go-simpler.org/musttag v0.12.2 // indirect go-simpler.org/sloglint v0.7.2 // indirect diff --git a/go.sum b/go.sum index e4a252d6..fc415502 100644 --- a/go.sum +++ b/go.sum @@ -170,6 +170,9 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -216,6 +219,8 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4= github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc= +github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg= +github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxdz7E85/STdkmZPEmPs= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= @@ -372,6 +377,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufeee/execinquery v1.2.1 h1:hf0Ems4SHcUGBxpGN7Jz78z1ppVkP/837ZlETPCEtOM= github.com/lufeee/execinquery v1.2.1/go.mod h1:EC7DrEKView09ocscGHC+apXMIaorh4xqSxS/dy8SbM= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -463,6 +470,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.6.0 h1:tftWV9DE7txiFzPpztTAwyoRLKNj9gpVm2cg8/OwcYY= github.com/polyfloyd/go-errorlint v1.6.0/go.mod h1:HR7u8wuP1kb1NeN1zqTd1ZMlqUKPPHF+Id4vIPvDqVw= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 h1:eR+0HE//Ciyfwy3HC7fjRyKShSJHYoX2Pv7pPshjK/Q= @@ -526,6 +535,12 @@ github.com/securego/gosec/v2 v2.21.2 h1:deZp5zmYf3TWwU7A7cR2+SolbTpZ3HQiwFqnzQyE github.com/securego/gosec/v2 v2.21.2/go.mod h1:au33kg78rNseF5PwPnTWhuYBFf534bvJRvOrgZ/bFzU= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -596,6 +611,10 @@ github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 h1:quvGphlmUVU+n github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966/go.mod h1:27bSVNWSBOHm+qRp1T9qzaIpsWEP6TbUnei/43HK+PQ= github.com/timonwong/loggercheck v0.9.4 h1:HKKhqrjcVj8sxL7K77beXh0adEm6DLjV/QOGeMXEVi4= github.com/timonwong/loggercheck v0.9.4/go.mod h1:caz4zlPcgvpEkXgVnAJGowHAMW2NwHaNlpS8xDbVhTg= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tomarrell/wrapcheck/v2 v2.9.0 h1:801U2YCAjLhdN8zhZ/7tdjB3EnAoRlJHt/s+9hijLQ4= github.com/tomarrell/wrapcheck/v2 v2.9.0/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= @@ -639,6 +658,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= @@ -757,10 +778,12 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/configs/envs/envs.yaml b/internal/configs/envs/envs.yaml new file mode 100644 index 00000000..1a1b3ced --- /dev/null +++ b/internal/configs/envs/envs.yaml @@ -0,0 +1,50 @@ +OPENAI_API_KEY: + desc: "OpenAI API Key" + validate: "required" +OPENAI_BASE_URL: + desc: "OpenAI Base URL" + default: "https://api.deepseek.com/v1" +OPENAI_MODEL: + desc: "OpenAI Model" + default: "deepseek-chat" +ENABLE_DEBUG: + desc: "enable debug" + default: "false" +DB_DSN: + desc: "db dsn" + default: "host=ec2-3-114-17-139.ap-northeast-1.compute.amazonaws.com dbname=welogin port=15432 user=postgres password=QMDifpRFo3zbN01ySrJg sslmode=disable" +ENV: + desc: app env + default: dev +CATDOGS_BASIC_AUTH_PASSWD: + desc: basic auth password + default: "123456" +GOOGLE_CALLBACK_URL: + desc: "google callback url" + default: "https://travel.chameleontravel.club/chameleon/auth/authorize/google/callback" +LOG_LEVEL: + desc: "log level" + default: "debug" + +LOG_AS_JSON: + desc: "log as json" + default: "false" +NATS_URL: + desc: nats url +NATS_POOL_WORKER_SIZE: + desc: nats pool worker size + default: "1000" +NATS_POOL_QUEUE_SIZE: + desc: nats pool queue size + default: 1000 +NATS_ENABLE_IGNORE: + desc: nats enable ignore + default: true + +REDIS_ADDR: + desc: redis address + default: "localhost:6379" + +REDIS_PWD: + desc: redis password + default: "123456" diff --git a/internal/configs/envs/s3.yaml b/internal/configs/envs/s3.yaml new file mode 100644 index 00000000..f49468ad --- /dev/null +++ b/internal/configs/envs/s3.yaml @@ -0,0 +1,17 @@ +S3_AK: + desc: "S3 access key" + value: "" + +S3_SK: + desc: "S3 secret key" + +S3_BUCKET: + desc: "S3 bucket name" + +S3_REGION: + desc: "S3 region" + value: "us-west-1" + +S3_FORCE_BUCKET: + desc: "S3 force bucket" + value: false diff --git a/internal/configs/envs/website.yaml b/internal/configs/envs/website.yaml new file mode 100644 index 00000000..24a3f9a9 --- /dev/null +++ b/internal/configs/envs/website.yaml @@ -0,0 +1,11 @@ +WEB_BASE_URL: + desc: "website base url" + +WEB_CHANGE_PWD_PATH: + desc: "website change password uri path" + +WEB_USER_ACTIVATE_PATH: + desc: "website user activate uri path" + +BACKEND_URL: + desc: "backend base url" diff --git a/internal/configs/scheduler.yaml b/internal/configs/scheduler.yaml index e62322fb..4b2eb464 100644 --- a/internal/configs/scheduler.yaml +++ b/internal/configs/scheduler.yaml @@ -5,4 +5,5 @@ patch_resources: - .local.yaml patch_envs: - - envs/envs.yaml + - envs + diff --git a/internal/examples/scheduler/taskfile.yml b/internal/examples/scheduler/taskfile.yml index 34ac11d0..3e156cd4 100644 --- a/internal/examples/scheduler/taskfile.yml +++ b/internal/examples/scheduler/taskfile.yml @@ -23,4 +23,5 @@ tasks: run: cmds: - task scheduler:build + - kill -9 $(ps -ef | grep scheduler.yaml | grep -v grep | awk '{print $2}') - SERVER_HTTP_PORT=8082 ./bin/scheduler scheduler -c ./internal/configs/scheduler.yaml From 7e4306acfe8a5082ede78d406633705a4542fd9d Mon Sep 17 00:00:00 2001 From: barry Date: Mon, 19 Jan 2026 16:05:07 +0800 Subject: [PATCH 05/80] chore: quick update fix/router at 2026-01-19 16:05:06 --- go.mod | 3 +- go.sum | 6 +- internal/examples/scheduler/taskfile.yml | 2 +- pkg/httputil/fiber.go | 122 +---------------------- 4 files changed, 5 insertions(+), 128 deletions(-) diff --git a/go.mod b/go.mod index 231c5c91..d80c1287 100644 --- a/go.mod +++ b/go.mod @@ -64,7 +64,7 @@ require ( github.com/thejerf/suture/v4 v4.0.6 github.com/uber-go/tally/v4 v4.1.17 github.com/ulikunitz/xz v0.5.15 - github.com/valyala/fasthttp v1.63.0 + github.com/valyala/fasthttp v1.69.0 github.com/valyala/fasttemplate v1.2.2 go.opentelemetry.io/contrib/zpages v0.62.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 @@ -154,7 +154,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.1.0 // indirect github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/gofiber/contrib/websocket v1.3.4 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect diff --git a/go.sum b/go.sum index fc415502..1bda7bb7 100644 --- a/go.sum +++ b/go.sum @@ -219,8 +219,6 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4= github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc= -github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg= -github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxdz7E85/STdkmZPEmPs= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= @@ -633,8 +631,8 @@ github.com/uudashr/gocognit v1.1.3 h1:l+a111VcDbKfynh+airAy/DJQKaXh2m9vkoysMPSZy github.com/uudashr/gocognit v1.1.3/go.mod h1:aKH8/e8xbTRBwjbCkwZ8qt4l2EpKXl31KMHgSS+lZ2U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.63.0 h1:DisIL8OjB7ul2d7cBaMRcKTQDYnrGy56R4FCiuDP0Ns= -github.com/valyala/fasthttp v1.63.0/go.mod h1:REc4IeW+cAEyLrRPa5A81MIjvz0QE1laoTX2EaPHKJM= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= diff --git a/internal/examples/scheduler/taskfile.yml b/internal/examples/scheduler/taskfile.yml index 3e156cd4..118d6e80 100644 --- a/internal/examples/scheduler/taskfile.yml +++ b/internal/examples/scheduler/taskfile.yml @@ -23,5 +23,5 @@ tasks: run: cmds: - task scheduler:build - - kill -9 $(ps -ef | grep scheduler.yaml | grep -v grep | awk '{print $2}') + - kill -9 $(ps -ef | grep scheduler.yaml | grep -v grep | awk '{print $2}') || true - SERVER_HTTP_PORT=8082 ./bin/scheduler scheduler -c ./internal/configs/scheduler.yaml diff --git a/pkg/httputil/fiber.go b/pkg/httputil/fiber.go index 0b31939f..777a5d20 100644 --- a/pkg/httputil/fiber.go +++ b/pkg/httputil/fiber.go @@ -1,7 +1,6 @@ package httputil import ( - "bufio" "io" "net" "net/http" @@ -29,131 +28,12 @@ func HTTPHandlerFunc(h http.HandlerFunc) fiber.Handler { return HTTPHandler(h) } func HTTPHandler(h http.Handler) fiber.Handler { return func(c *fiber.Ctx) error { - handler := NewFastHTTPHandler(h) + handler := fasthttpadaptor.NewFastHTTPHandler(h) handler(c.Context()) return nil } } -// NewFastHTTPHandlerFunc wraps net/http handler func to fasthttp -// request handler, so it can be passed to fasthttp server. -// -// While this function may be used for easy switching from net/http to fasthttp, -// it has the following drawbacks comparing to using manually written fasthttp -// request handler: -// -// - A lot of useful functionality provided by fasthttp is missing -// from net/http handler. -// - net/http -> fasthttp handler conversion has some overhead, -// so the returned handler will be always slower than manually written -// fasthttp handler. -// -// So it is advisable using this function only for quick net/http -> fasthttp -// switching. Then manually convert net/http handlers to fasthttp handlers -// according to https://github.com/valyala/fasthttp#switching-from-nethttp-to-fasthttp . -func NewFastHTTPHandlerFunc(h http.HandlerFunc) fasthttp.RequestHandler { - return NewFastHTTPHandler(h) -} - -// NewFastHTTPHandler wraps net/http handler to fasthttp request handler, -// so it can be passed to fasthttp server. -// -// While this function may be used for easy switching from net/http to fasthttp, -// it has the following drawbacks comparing to using manually written fasthttp -// request handler: -// -// - A lot of useful functionality provided by fasthttp is missing -// from net/http handler. -// - net/http -> fasthttp handler conversion has some overhead, -// so the returned handler will be always slower than manually written -// fasthttp handler. -// -// So it is advisable using this function only for quick net/http -> fasthttp -// switching. Then manually convert net/http handlers to fasthttp handlers -// according to https://github.com/valyala/fasthttp#switching-from-nethttp-to-fasthttp . -func NewFastHTTPHandler(h http.Handler) fasthttp.RequestHandler { - return func(ctx *fasthttp.RequestCtx) { - var r http.Request - if err := fasthttpadaptor.ConvertRequest(ctx, &r, true); err != nil { - ctx.Logger().Printf("cannot parse requestURI %q: %v", r.RequestURI, err) - ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError) - return - } - - w := netHTTPResponseWriter{ - h: r.Header, - w: ctx.Response.BodyWriter(), - r: ctx.RequestBodyStream(), - conn: ctx.Conn(), - } - h.ServeHTTP(&w, r.WithContext(ctx)) - - ctx.SetStatusCode(w.StatusCode()) - haveContentType := false - for k, vv := range w.Header() { - if k == fasthttp.HeaderContentType { - haveContentType = true - } - - for _, v := range vv { - ctx.Response.Header.Add(k, v) - } - } - if !haveContentType { - // From net/http.ResponseWriter.Write: - // If the Header does not contain a Content-Type line, Write adds a Content-Type set - // to the result of passing the initial 512 bytes of written data to DetectContentType. - l := 512 - b := ctx.Response.Body() - if len(b) < 512 { - l = len(b) - } - ctx.Response.Header.Set(fasthttp.HeaderContentType, http.DetectContentType(b[:l])) - } - } -} - -type netHTTPResponseWriter struct { - statusCode int - h http.Header - w io.Writer - r io.Reader - conn net.Conn -} - -func (w *netHTTPResponseWriter) StatusCode() int { - if w.statusCode == 0 { - return http.StatusOK - } - return w.statusCode -} - -func (w *netHTTPResponseWriter) Header() http.Header { - if w.h == nil { - w.h = make(http.Header) - } - return w.h -} - -func (w *netHTTPResponseWriter) WriteHeader(statusCode int) { - w.statusCode = statusCode -} - -func (w *netHTTPResponseWriter) Write(p []byte) (int, error) { - return w.w.Write(p) -} - -func (w *netHTTPResponseWriter) Flush() { - ff, ok := w.w.(http.Flusher) - if ok { - ff.Flush() - } -} - -func (w *netHTTPResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - return w.conn, &bufio.ReadWriter{Reader: bufio.NewReader(w.r), Writer: bufio.NewWriter(w.w)}, nil -} - func handlerFunc(h fasthttp.RequestHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // New fasthttp request From d2e3fa070c342005a79ba7a30beceedee1bd8502 Mon Sep 17 00:00:00 2001 From: barry Date: Mon, 19 Jan 2026 16:23:58 +0800 Subject: [PATCH 06/80] chore: quick update fix/router at 2026-01-19 16:23:57 --- core/debug/debug/debug.go | 5 +++++ core/debug/featurehttp/featurehttp.go | 14 ++++++++++++++ core/lavabuilder/builder.go | 8 ++++++++ internal/examples/scheduler/main.go | 2 ++ 4 files changed, 29 insertions(+) create mode 100644 core/debug/featurehttp/featurehttp.go diff --git a/core/debug/debug/debug.go b/core/debug/debug/debug.go index 91bbddd9..1b6878c3 100644 --- a/core/debug/debug/debug.go +++ b/core/debug/debug/debug.go @@ -125,6 +125,11 @@ func initDebug() {
配置
查看配置
+ +
🚩
+
Feature Flags
+
功能开关
+
`) content := statsHTML + `
` + actionsHTML + routesHTML diff --git a/core/debug/featurehttp/featurehttp.go b/core/debug/featurehttp/featurehttp.go new file mode 100644 index 00000000..1f3167b2 --- /dev/null +++ b/core/debug/featurehttp/featurehttp.go @@ -0,0 +1,14 @@ +// Package featurehttp 提供 feature flags 的 HTTP 调试接口 +package featurehttp + +import ( + "github.com/pubgo/funk/v2/features/featurehttp" + + "github.com/pubgo/lava/v2/core/debug" +) + +func init() { + srv := featurehttp.NewServer("").WithPrefix("/debug/features") + debug.App().All("/features", debug.Wrap(srv.Handler())) + debug.App().All("/features/*", debug.Wrap(srv.Handler())) +} diff --git a/core/lavabuilder/builder.go b/core/lavabuilder/builder.go index e6d12788..d197d81f 100644 --- a/core/lavabuilder/builder.go +++ b/core/lavabuilder/builder.go @@ -20,12 +20,20 @@ import ( "github.com/pubgo/lava/v2/cmds/httpservercmd" "github.com/pubgo/lava/v2/cmds/schedulercmd" "github.com/pubgo/lava/v2/cmds/versioncmd" + _ "github.com/pubgo/lava/v2/core/debug/configview" _ "github.com/pubgo/lava/v2/core/debug/debug" "github.com/pubgo/lava/v2/core/debug/dixdebug" + _ "github.com/pubgo/lava/v2/core/debug/featurehttp" //_ "github.com/pubgo/lava/v2/core/debug/gops" + _ "github.com/pubgo/lava/v2/core/debug/goroutine" + _ "github.com/pubgo/lava/v2/core/debug/healthy" + _ "github.com/pubgo/lava/v2/core/debug/loglevel" _ "github.com/pubgo/lava/v2/core/debug/pprof" _ "github.com/pubgo/lava/v2/core/debug/process" + _ "github.com/pubgo/lava/v2/core/debug/ratelimit" + _ "github.com/pubgo/lava/v2/core/debug/runtime" _ "github.com/pubgo/lava/v2/core/debug/statsviz" + _ "github.com/pubgo/lava/v2/core/debug/sysinfo" _ "github.com/pubgo/lava/v2/core/debug/trace" _ "github.com/pubgo/lava/v2/core/debug/vars" _ "github.com/pubgo/lava/v2/core/debug/version" diff --git a/internal/examples/scheduler/main.go b/internal/examples/scheduler/main.go index ecf4dc82..6de078d0 100644 --- a/internal/examples/scheduler/main.go +++ b/internal/examples/scheduler/main.go @@ -6,6 +6,7 @@ import ( "time" "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/debugs" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" @@ -37,6 +38,7 @@ func (s schedulerExample) RegisterSchedulerJob(reg scheduler.JobRegistry) { reg.Every("every_task", time.Second*5, func(ctx context.Context, name string, metadata *scheduler.JobMetadata) result.Result[[]byte] { fmt.Printf("exec every task: %s: %#v\n", name, metadata) + fmt.Println(debugs.Enabled.String()) time.Sleep(time.Second * 1) return result.OK([]byte("every")) }) From 59ee1ee6b9ea35c1edbdb72b8e4a645a04eec2ea Mon Sep 17 00:00:00 2001 From: barry Date: Mon, 19 Jan 2026 16:30:17 +0800 Subject: [PATCH 07/80] chore: quick update fix/router at 2026-01-19 16:30:16 --- core/lavabuilder/builder.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/lavabuilder/builder.go b/core/lavabuilder/builder.go index d197d81f..51a9ca37 100644 --- a/core/lavabuilder/builder.go +++ b/core/lavabuilder/builder.go @@ -9,6 +9,7 @@ import ( "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/features/featureflags" "github.com/pubgo/funk/v2/recovery" + // metric "github.com/pubgo/redant" _ "go.uber.org/automaxprocs" @@ -24,6 +25,7 @@ import ( _ "github.com/pubgo/lava/v2/core/debug/debug" "github.com/pubgo/lava/v2/core/debug/dixdebug" _ "github.com/pubgo/lava/v2/core/debug/featurehttp" + //_ "github.com/pubgo/lava/v2/core/debug/gops" _ "github.com/pubgo/lava/v2/core/debug/goroutine" _ "github.com/pubgo/lava/v2/core/debug/healthy" @@ -38,12 +40,14 @@ import ( _ "github.com/pubgo/lava/v2/core/debug/vars" _ "github.com/pubgo/lava/v2/core/debug/version" "github.com/pubgo/lava/v2/core/discovery" + // encoding _ "github.com/pubgo/lava/v2/core/encoding/protobuf" _ "github.com/pubgo/lava/v2/core/encoding/protojson" "github.com/pubgo/lava/v2/core/flags" "github.com/pubgo/lava/v2/core/lifecycle/lifecyclebuilder" "github.com/pubgo/lava/v2/core/logging/logbuilder" + // logging _ "github.com/pubgo/lava/v2/core/logging/logext/grpclog" _ "github.com/pubgo/lava/v2/core/logging/logext/slog" From 7dcde43715356515114ed31003e18cc1ff16b67d Mon Sep 17 00:00:00 2001 From: barry Date: Mon, 19 Jan 2026 18:17:02 +0800 Subject: [PATCH 08/80] chore: quick update fix/router at 2026-01-19 18:17:00 --- core/debug/debug/debug.go | 5 + core/scheduler/scheduler.go | 8 +- core/supervisor/_.go | 3 +- core/supervisor/aaa.go | 82 ++- core/supervisor/errs.go | 60 +- core/supervisor/manager.go | 1075 ++++++++++++++++++++++++++++++++--- core/supervisor/service.go | 92 ++- core/supervisor/spec.go | 61 -- go.mod | 1 - go.sum | 2 - 10 files changed, 1210 insertions(+), 179 deletions(-) delete mode 100644 core/supervisor/spec.go diff --git a/core/debug/debug/debug.go b/core/debug/debug/debug.go index 1b6878c3..67ad03fb 100644 --- a/core/debug/debug/debug.go +++ b/core/debug/debug/debug.go @@ -100,6 +100,11 @@ func initDebug() {
Goroutine
协程监控
+ +
🎛️
+
Supervisor
+
服务监控与管理
+
❤️
健康检查
diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index 24a70396..febb2379 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -244,11 +244,15 @@ func (s *Scheduler) String() string { } func (s *Scheduler) Serve(ctx context.Context) error { + // 每次 Serve 调用时重新创建内部 context,支持服务重启 + s.ctx, s.cancel = context.WithCancel(ctx) defer s.stop() s.start() - s.scheduler.Wait(ctx) - return nil + s.scheduler.Wait(s.ctx) + + // 返回 context 的错误,这样 supervisor 能正确判断是正常停止还是需要重启 + return ctx.Err() } func (s *Scheduler) stop() { diff --git a/core/supervisor/_.go b/core/supervisor/_.go index 426e0e9c..60a057bb 100644 --- a/core/supervisor/_.go +++ b/core/supervisor/_.go @@ -1,4 +1,3 @@ package supervisor -// "github.com/kardianos/service" -// github.com/thejerf/suture +// Service supervision and lifecycle management for lava services diff --git a/core/supervisor/aaa.go b/core/supervisor/aaa.go index 760de73c..88d5570b 100644 --- a/core/supervisor/aaa.go +++ b/core/supervisor/aaa.go @@ -3,20 +3,41 @@ package supervisor import ( "context" "time" - - "github.com/thejerf/suture/v4" ) -type Supervisor = suture.Supervisor +// ServiceStatus 服务状态 +type ServiceStatus string + +const ( + StatusIdle ServiceStatus = "idle" // 空闲,未启动 + StatusRunning ServiceStatus = "running" // 运行中 + StatusStopped ServiceStatus = "stopped" // 已停止(手动) + StatusError ServiceStatus = "error" // 错误状态 + StatusCrashing ServiceStatus = "crashing" // 崩溃循环中 + StatusFailed ServiceStatus = "failed" // 已失败(达到重启上限) +) +// Metric 服务指标 type Metric struct { - Name string - Error string - Restart uint32 - StartTime time.Time - OnlineDuration time.Duration + Name string `json:"name"` // 服务名称 + Status ServiceStatus `json:"status"` // 当前状态 + StartCount uint32 `json:"start_count"` // 启动次数 + ErrorCount uint32 `json:"error_count"` // 错误次数 + SuccessCount uint32 `json:"success_count"` // 成功退出次数 + ConsecFailures uint32 `json:"consec_failures"` // 连续失败次数 + LastError string `json:"last_error"` // 最后一次错误信息 + LastErrorTime time.Time `json:"last_error_time"` // 最后一次错误时间 + LastStartTime time.Time `json:"last_start_time"` // 最后一次启动时间 + LastStopTime time.Time `json:"last_stop_time"` // 最后一次停止时间 + CurrentUptime time.Duration `json:"current_uptime"` // 当前运行时长 + TotalUptime time.Duration `json:"total_uptime"` // 总运行时长 + AverageUptime time.Duration `json:"average_uptime"` // 平均运行时长 + CreatedAt time.Time `json:"created_at"` // 服务创建时间 + CurrentDelay time.Duration `json:"current_delay"` // 当前重启延迟 + RestartsInWindow uint32 `json:"restarts_in_window"` // 窗口期内重启次数 } +// Service 服务接口 type Service interface { Name() string Error() error @@ -25,8 +46,53 @@ type Service interface { Metric() *Metric } +// serviceFn 函数类型服务 type serviceFn func(ctx context.Context) error func (fn serviceFn) Serve(ctx context.Context) error { return fn(ctx) } + +// RestartPolicy 重启策略 +type RestartPolicy int + +const ( + // RestartAlways 总是重启(除非手动停止) + RestartAlways RestartPolicy = iota + // RestartOnFailure 仅在失败时重启 + RestartOnFailure + // RestartNever 从不自动重启 + RestartNever +) + +// ServiceConfig 服务配置 +type ServiceConfig struct { + // RestartPolicy 重启策略 + RestartPolicy RestartPolicy + // MaxRestarts 最大重启次数,0 表示无限制 + MaxRestarts int + // RestartDelay 初始重启延迟 + RestartDelay time.Duration + // MaxRestartDelay 最大重启延迟(用于指数退避) + MaxRestartDelay time.Duration + // RestartWindow 重启计数窗口期,在此期间内的重启会被计数 + // 如果服务运行超过此时间后崩溃,重启计数会重置 + RestartWindow time.Duration + // MaxRestartsInWindow 窗口期内最大重启次数,超过则标记为 failed + MaxRestartsInWindow int + // BackoffMultiplier 退避乘数,默认 2.0 + BackoffMultiplier float64 +} + +// DefaultServiceConfig 默认服务配置 +func DefaultServiceConfig() ServiceConfig { + return ServiceConfig{ + RestartPolicy: RestartAlways, + MaxRestarts: 0, // 无限制 + RestartDelay: time.Second, // 初始 1 秒 + MaxRestartDelay: time.Minute, // 最大 1 分钟 + RestartWindow: 5 * time.Minute, // 5 分钟窗口 + MaxRestartsInWindow: 5, // 窗口内最多 5 次 + BackoffMultiplier: 2.0, // 每次翻倍 + } +} diff --git a/core/supervisor/errs.go b/core/supervisor/errs.go index 86e05628..f5dec2eb 100644 --- a/core/supervisor/errs.go +++ b/core/supervisor/errs.go @@ -1,30 +1,44 @@ package supervisor import ( - "github.com/pubgo/funk/v2/errors" - "github.com/thejerf/suture/v4" + "errors" ) +// 预定义错误 +var ( + // ErrServiceNotFound 服务未找到 + ErrServiceNotFound = errors.New("service not found") + // ErrServiceAlreadyExists 服务已存在 + ErrServiceAlreadyExists = errors.New("service already exists") + // ErrServiceStopped 服务已停止 + ErrServiceStopped = errors.New("service is stopped") + // ErrServiceRunning 服务正在运行 + ErrServiceRunning = errors.New("service is running") + // ErrDoNotRestart 不要重启服务 + ErrDoNotRestart = errors.New("do not restart") + // ErrTerminateSupervisor 终止 supervisor + ErrTerminateSupervisor = errors.New("terminate supervisor") +) + +// FatalErr 致命错误,将导致 supervisor 终止 type FatalErr struct { Err error Status ExitStatus } -// AsFatalErr wraps the given error creating a FatalErr. If the given error -// already is of type FatalErr, it is not wrapped again. -func AsFatalErr(err error, status ExitStatus) (gErr *FatalErr) { - if errors.As(err, &gErr) { - return gErr - } - - return &FatalErr{ - Err: err, - Status: status, +// AsFatalErr 将错误包装为 FatalErr +func AsFatalErr(err error, status ExitStatus) *FatalErr { + var fErr *FatalErr + if errors.As(err, &fErr) { + return fErr } + return &FatalErr{Err: err, Status: status} } +// IsFatal 判断是否为致命错误 func IsFatal(err error) bool { - return errors.As(err, &FatalErr{}) + var fErr *FatalErr + return errors.As(err, &fErr) } func (e *FatalErr) Error() string { @@ -36,18 +50,27 @@ func (e *FatalErr) Unwrap() error { } func (*FatalErr) Is(target error) bool { - return target == suture.ErrTerminateSupervisorTree + return target == ErrTerminateSupervisor } -// NoRestartErr wraps the given error err (which may be nil) to make sure that -// `errors.Is(err, suture.ErrDoNotRestart) == true`. +// NoRestartErr 包装错误,使其不会触发自动重启 func NoRestartErr(err error) error { if err == nil { - return suture.ErrDoNotRestart + return ErrDoNotRestart } return &noRestartErr{err} } +// IsNoRestartErr 判断是否是不需要重启的错误 +func IsNoRestartErr(err error) bool { + return errors.Is(err, ErrDoNotRestart) +} + +// IsFatalErr 判断是否是致命错误 +func IsFatalErr(err error) bool { + return IsFatal(err) || errors.Is(err, ErrTerminateSupervisor) +} + type noRestartErr struct { err error } @@ -61,9 +84,10 @@ func (e *noRestartErr) Unwrap() error { } func (*noRestartErr) Is(target error) bool { - return target == suture.ErrDoNotRestart + return target == ErrDoNotRestart } +// ExitStatus 退出状态码 type ExitStatus int const ( diff --git a/core/supervisor/manager.go b/core/supervisor/manager.go index 0b324e80..8fa7df33 100644 --- a/core/supervisor/manager.go +++ b/core/supervisor/manager.go @@ -3,27 +3,37 @@ package supervisor import ( "context" "fmt" + "sync" + "time" "github.com/gofiber/fiber/v2" "github.com/pubgo/funk/v2/assert" - "github.com/pubgo/funk/v2/async" - "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" - "github.com/pubgo/funk/v2/result" "github.com/pubgo/funk/v2/running" "github.com/pubgo/funk/v2/stack" - "github.com/thejerf/suture/v4" "github.com/pubgo/lava/v2/core/debug" "github.com/pubgo/lava/v2/core/lifecycle" "github.com/pubgo/lava/v2/internal/logutil" - "github.com/pubgo/lava/v2/pkg/netutil" ) -type serviceWrapper struct { - token suture.ServiceToken +// serviceRunner 管理单个服务的运行 +type serviceRunner struct { service Service + config ServiceConfig + cancel context.CancelFunc + stopped bool // 是否被手动停止 + failed bool // 是否已失败(达到重启上限) + done chan struct{} + + // 重启状态跟踪 + restartCount int // 总重启次数 + consecFailures int // 连续失败次数 + windowRestarts int // 窗口期内重启次数 + windowStart time.Time // 窗口开始时间 + currentDelay time.Duration // 当前重启延迟 + lastServiceStart time.Time // 上次服务启动时间 } func Default(lc lifecycle.Getter) *Manager { @@ -32,114 +42,871 @@ func Default(lc lifecycle.Getter) *Manager { func NewManager(name string, lc lifecycle.Getter) *Manager { m := &Manager{ - lc: lc, - supervisor: suture.New(name, SpecWithInfoLogger()), - services: make(map[string]*serviceWrapper), - logger: log.GetLogger(name), + name: name, + lc: lc, + services: make(map[string]*serviceRunner), + logger: log.GetLogger(name), } return m.init() } type Manager struct { - lc lifecycle.Getter - logger log.Logger - supervisor *Supervisor - services map[string]*serviceWrapper + name string + lc lifecycle.Getter + logger log.Logger + mu sync.RWMutex + services map[string]*serviceRunner + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup } func (m *Manager) init() *Manager { debug.Route("/supervisor", func(router fiber.Router) { - router.Get("services", func(ctx *fiber.Ctx) error { - services := make([]*Metric, 0, len(m.services)) - for _, srv := range m.services { - services = append(services, srv.service.Metric()) - } - return ctx.JSON(services) - }) + // 主页面 - UI 界面 + router.Get("/", m.handleDebugPage) + + // API 端点 + router.Get("/api/services", m.handleAPIServices) + router.Get("/api/service/:name", m.handleAPIServiceDetail) + router.Post("/api/service/:name/restart", m.handleAPIRestartService) + router.Post("/api/service/:name/stop", m.handleAPIStopService) + router.Post("/api/service/:name/start", m.handleAPIStartService) + router.Post("/api/service/:name/reset", m.handleAPIResetService) + router.Post("/api/services/restart", m.handleAPIRestartAll) + + // 兼容旧端点 + router.Get("services", m.handleAPIServices) }) return m } +// ServiceInfo 包含服务指标和运行时状态 +type ServiceInfo struct { + *Metric + Stopped bool `json:"stopped"` + Failed bool `json:"failed"` + RestartCount int `json:"restart_count"` + ConsecFailures int `json:"consec_failures"` + WindowRestarts int `json:"window_restarts"` + CurrentDelay time.Duration `json:"current_delay_ns"` + CurrentDelayStr string `json:"current_delay"` + WindowStart time.Time `json:"window_start"` + LastServiceStart time.Time `json:"last_service_start"` +} + +func (m *Manager) handleAPIServices(ctx *fiber.Ctx) error { + m.mu.RLock() + services := make([]*ServiceInfo, 0, len(m.services)) + for _, srv := range m.services { + metric := srv.service.Metric() + // 覆盖状态 + if srv.failed { + metric.Status = StatusFailed + } else if srv.stopped { + metric.Status = StatusStopped + } else if srv.consecFailures > 0 { + metric.Status = StatusCrashing + } + services = append(services, &ServiceInfo{ + Metric: metric, + Stopped: srv.stopped, + Failed: srv.failed, + RestartCount: srv.restartCount, + ConsecFailures: srv.consecFailures, + WindowRestarts: srv.windowRestarts, + CurrentDelay: srv.currentDelay, + CurrentDelayStr: srv.currentDelay.String(), + WindowStart: srv.windowStart, + LastServiceStart: srv.lastServiceStart, + }) + } + m.mu.RUnlock() + return ctx.JSON(services) +} + +func (m *Manager) handleAPIServiceDetail(ctx *fiber.Ctx) error { + name := ctx.Params("name") + m.mu.RLock() + srv, ok := m.services[name] + if !ok { + m.mu.RUnlock() + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "service not found", + "name": name, + }) + } + metric := srv.service.Metric() + if srv.failed { + metric.Status = StatusFailed + } else if srv.stopped { + metric.Status = StatusStopped + } else if srv.consecFailures > 0 { + metric.Status = StatusCrashing + } + info := &ServiceInfo{ + Metric: metric, + Stopped: srv.stopped, + Failed: srv.failed, + RestartCount: srv.restartCount, + ConsecFailures: srv.consecFailures, + WindowRestarts: srv.windowRestarts, + CurrentDelay: srv.currentDelay, + CurrentDelayStr: srv.currentDelay.String(), + WindowStart: srv.windowStart, + LastServiceStart: srv.lastServiceStart, + } + m.mu.RUnlock() + return ctx.JSON(info) +} + +func (m *Manager) handleAPIRestartService(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := m.RestartService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service restarted", + "name": name, + }) +} + +func (m *Manager) handleAPIStopService(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := m.StopService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service stopped", + "name": name, + }) +} + +func (m *Manager) handleAPIStartService(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := m.StartService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service started", + "name": name, + }) +} + +func (m *Manager) handleAPIResetService(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := m.ResetService(name); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + "name": name, + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "service reset", + "name": name, + }) +} + +func (m *Manager) handleAPIRestartAll(ctx *fiber.Ctx) error { + if err := m.RestartServices(); err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return ctx.JSON(fiber.Map{ + "success": true, + "message": "all services restarted", + }) +} + +func (m *Manager) handleDebugPage(ctx *fiber.Ctx) error { + html := supervisorDebugPageHTML + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) +} + +const supervisorDebugPageHTML = ` + + + + + Supervisor - Debug Console + + + + + +
+ +
+ +
+
+
服务总数
+
0
+
+
+
运行中
+
0
+
+
+
错误状态
+
0
+
+
+
总启动次数
+
0
+
+
+
总错误次数
+
0
+
+
+ + +
+
+

服务列表

+ 自动刷新: 每 +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + +
服务名状态启动次数错误次数重启次数运行时长重启延迟最后错误操作
+
+
+ + +
+
+
+

服务详情:

+ +
+
+

+                
+
+
+ + +
+ +
+
+ + + +` + func (m *Manager) Has(name string) bool { + m.mu.RLock() _, ok := m.services[name] + m.mu.RUnlock() return ok } func (m *Manager) OnClose(fn func()) { - m.supervisor.Add(serviceFn(func(ctx context.Context) error { - <-ctx.Done() - fn() - return nil - })) + _ = m.Add(&onCloseService{fn: fn}) +} + +type onCloseService struct { + fn func() +} + +func (s *onCloseService) Name() string { return "on-close-" + fmt.Sprintf("%p", s.fn) } +func (s *onCloseService) Error() error { return nil } +func (s *onCloseService) String() string { return s.Name() } +func (s *onCloseService) Metric() *Metric { return &Metric{Name: s.Name()} } +func (s *onCloseService) Serve(ctx context.Context) error { + <-ctx.Done() + s.fn() + return NoRestartErr(nil) } func (m *Manager) Add(srv Service) error { name := srv.Name() + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.services[name]; ok { - return errors.Errorf("service already exists, name=%s", name) + return fmt.Errorf("service already exists, name=%s", name) } m.logger.Info().Str("name", name).Msg("add service to supervisor") - m.services[name] = &serviceWrapper{service: srv, token: m.supervisor.Add(srv)} + runner := &serviceRunner{ + service: srv, + config: DefaultServiceConfig(), + stopped: false, + } + m.services[name] = runner + + // 如果 manager 已经启动,立即启动这个服务 + if m.ctx != nil { + m.startRunner(runner) + } + + return nil +} + +// AddWithConfig 添加服务并指定配置 +func (m *Manager) AddWithConfig(srv Service, config ServiceConfig) error { + name := srv.Name() + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.services[name]; ok { + return fmt.Errorf("service already exists, name=%s", name) + } + + m.logger.Info().Str("name", name).Msg("add service to supervisor with config") + runner := &serviceRunner{ + service: srv, + config: config, + stopped: false, + } + m.services[name] = runner + + // 如果 manager 已经启动,立即启动这个服务 + if m.ctx != nil { + m.startRunner(runner) + } + return nil } func (m *Manager) Delete(name string) error { + m.mu.Lock() srv := m.services[name] if srv == nil { + m.mu.Unlock() m.logger.Warn().Str("name", name).Msg("service not found, cannot delete") - return nil + return fmt.Errorf("service not found, name=%s", name) + } + + // 停止服务 + cancel := srv.cancel + done := srv.done + delete(m.services, name) + m.mu.Unlock() + + if cancel != nil { + cancel() + if done != nil { + <-done + } } - defer func() { delete(m.services, name) }() m.logger.Info().Str("name", name).Msg("delete service from supervisor") - return errors.Wrapf(m.supervisor.Remove(srv.token), "failed to remove service, name=%s", name) + return nil } -func (m *Manager) RemoveServices() (gErr error) { +func (m *Manager) RemoveServices() error { + m.mu.Lock() + defer m.mu.Unlock() + for name, srv := range m.services { - if result.ThrowErr(&gErr, m.supervisor.Remove(srv.token)) { - return errors.Wrapf(gErr, "failed to remove service, name=%s", name) + if srv.cancel != nil { + srv.cancel() } m.logger.Info().Str("name", name).Msg("removing service from supervisor") } - m.services = make(map[string]*serviceWrapper) + m.services = make(map[string]*serviceRunner) return nil } -func (m *Manager) RestartServices() (gErr error) { - for name, srv := range m.services { - if result.ThrowErr(&gErr, m.supervisor.Remove(srv.token)) { - return errors.Wrapf(gErr, "failed to remove service, name=%s", name) - } +func (m *Manager) RestartServices() error { + m.mu.Lock() + defer m.mu.Unlock() - m.services[name] = &serviceWrapper{service: srv.service, token: m.supervisor.Add(srv.service)} + for name, srv := range m.services { + m.restartRunnerLocked(srv) m.logger.Info().Str("name", name).Msg("restarting service in supervisor") } return nil } -func (m *Manager) RestartService(name string) (gErr error) { +func (m *Manager) RestartService(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + srv := m.services[name] if srv == nil { m.logger.Warn().Str("name", name).Msg("service not found, cannot restart") - return nil + return fmt.Errorf("service not found, name=%s", name) } - if result.ThrowErr(&gErr, m.supervisor.Remove(srv.token)) { - return errors.Wrapf(gErr, "failed to remove service, name=%s", name) + // 如果服务已暂停,直接启动 + if srv.stopped { + srv.stopped = false + m.startRunner(srv) + m.logger.Info().Str("name", name).Msg("starting stopped service in supervisor") + return nil } - m.services[name] = &serviceWrapper{service: srv.service, token: m.supervisor.Add(srv.service)} + m.restartRunnerLocked(srv) m.logger.Info().Str("name", name).Msg("restarting service in supervisor") return nil } +// restartRunnerLocked 重启服务,必须在持有锁的情况下调用 +func (m *Manager) restartRunnerLocked(runner *serviceRunner) { + // 停止旧的 + if runner.cancel != nil { + runner.cancel() + // 等待旧的 goroutine 退出 + if runner.done != nil { + <-runner.done + } + } + runner.stopped = false + // 启动新的 + m.startRunner(runner) +} + +// StopService 暂停服务,但保留在 services map 中 +func (m *Manager) StopService(name string) error { + m.mu.Lock() + srv := m.services[name] + if srv == nil { + m.mu.Unlock() + m.logger.Warn().Str("name", name).Msg("service not found, cannot stop") + return fmt.Errorf("service not found, name=%s", name) + } + + if srv.stopped { + m.mu.Unlock() + m.logger.Warn().Str("name", name).Msg("service already stopped") + return nil + } + + cancel := srv.cancel + done := srv.done + srv.stopped = true + m.mu.Unlock() + + if cancel != nil { + cancel() + if done != nil { + <-done + } + } + + m.logger.Info().Str("name", name).Msg("stopped service in supervisor") + return nil +} + +// StartService 启动已暂停的服务 +func (m *Manager) StartService(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + srv := m.services[name] + if srv == nil { + m.logger.Warn().Str("name", name).Msg("service not found, cannot start") + return fmt.Errorf("service not found, name=%s", name) + } + + if !srv.stopped && !srv.failed { + m.logger.Warn().Str("name", name).Msg("service already running") + return nil + } + + // 重置状态 + srv.stopped = false + srv.failed = false + srv.consecFailures = 0 + srv.windowRestarts = 0 + srv.windowStart = time.Now() + srv.currentDelay = srv.config.RestartDelay + + m.startRunner(srv) + m.logger.Info().Str("name", name).Msg("started service in supervisor") + return nil +} + +// ResetService 重置失败的服务,清除所有重启计数 +func (m *Manager) ResetService(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + srv := m.services[name] + if srv == nil { + return fmt.Errorf("service not found, name=%s", name) + } + + // 重置所有重启相关状态 + srv.failed = false + srv.restartCount = 0 + srv.consecFailures = 0 + srv.windowRestarts = 0 + srv.windowStart = time.Now() + srv.currentDelay = srv.config.RestartDelay + + m.logger.Info().Str("name", name).Msg("reset service restart counters") + return nil +} + +// GetServiceStatus 获取服务运行状态 +func (m *Manager) GetServiceStatus(name string) (status ServiceStatus, info map[string]any, err error) { + m.mu.RLock() + defer m.mu.RUnlock() + + srv := m.services[name] + if srv == nil { + return "", nil, fmt.Errorf("service not found, name=%s", name) + } + + info = map[string]any{ + "stopped": srv.stopped, + "failed": srv.failed, + "restart_count": srv.restartCount, + "consec_failures": srv.consecFailures, + "window_restarts": srv.windowRestarts, + "current_delay": srv.currentDelay.String(), + "window_start": srv.windowStart, + "last_service_start": srv.lastServiceStart, + } + + if srv.failed { + return StatusFailed, info, nil + } + if srv.stopped { + return StatusStopped, info, nil + } + if srv.consecFailures > 0 { + return StatusCrashing, info, nil + } + if srv.cancel != nil { + return StatusRunning, info, nil + } + return StatusIdle, info, nil +} + func (m *Manager) Services() []Service { + m.mu.RLock() + defer m.mu.RUnlock() + services := make([]Service, 0, len(m.services)) for _, srv := range m.services { services = append(services, srv.service) @@ -147,8 +914,163 @@ func (m *Manager) Services() []Service { return services } +// startRunner 启动一个服务 runner +func (m *Manager) startRunner(runner *serviceRunner) { + if m.ctx == nil { + return + } + + ctx, cancel := context.WithCancel(m.ctx) + runner.cancel = cancel + runner.done = make(chan struct{}) + + m.wg.Add(1) + go func() { + defer m.wg.Done() + defer close(runner.done) + m.runService(ctx, runner) + }() +} + +// runService 运行服务的主循环,包含自动重启逻辑 +func (m *Manager) runService(ctx context.Context, runner *serviceRunner) { + srv := runner.service + name := srv.Name() + config := runner.config + + // 初始化重启状态 + if runner.currentDelay == 0 { + runner.currentDelay = config.RestartDelay + } + if runner.windowStart.IsZero() { + runner.windowStart = time.Now() + } + + for { + // 先检查 context 是否已取消 + select { + case <-ctx.Done(): + return + default: + } + + // 记录服务启动时间 + runner.lastServiceStart = time.Now() + + err := srv.Serve(ctx) + + // Serve 返回后立即检查 context + // 如果 context 已取消,无论错误是什么都应该退出 + select { + case <-ctx.Done(): + return + default: + } + + // 计算运行时长 + runDuration := time.Since(runner.lastServiceStart) + + // 处理错误 + if err != nil { + // context 相关错误视为正常停止,不重启 + if err == context.Canceled || err == context.DeadlineExceeded { + return + } + + // 检查是否是不重启的错误 + if IsNoRestartErr(err) || IsFatalErr(err) { + runner.failed = true + m.logger.Error().Err(err).Str("name", name).Msg("service exited with fatal error, not restarting") + return + } + + // 根据重启策略决定是否重启 + if config.RestartPolicy == RestartNever { + runner.failed = true + m.logger.Error().Err(err).Str("name", name).Msg("service exited with error, restart policy is Never") + return + } + + runner.consecFailures++ + m.logger.Warn().Err(err).Str("name", name). + Int("consec_failures", runner.consecFailures). + Msg("service exited with error") + } else { + // 正常退出 + if config.RestartPolicy == RestartOnFailure || config.RestartPolicy == RestartNever { + m.logger.Info().Str("name", name).Msg("service exited normally, not restarting per policy") + return + } + + // RestartAlways 策略下正常退出也会重启 + // 如果运行了足够长的时间,重置连续失败计数 + if runDuration > config.RestartWindow { + runner.consecFailures = 0 + runner.currentDelay = config.RestartDelay + } + + m.logger.Info().Str("name", name).Msg("service exited normally, will restart") + } + + // 更新窗口内重启计数 + now := time.Now() + if now.Sub(runner.windowStart) > config.RestartWindow { + // 窗口已过期,重置 + runner.windowStart = now + runner.windowRestarts = 0 + runner.currentDelay = config.RestartDelay // 重置延迟 + } + runner.windowRestarts++ + runner.restartCount++ + + // 检查是否超过最大重启次数 + if config.MaxRestarts > 0 && runner.restartCount >= config.MaxRestarts { + runner.failed = true + m.logger.Error().Str("name", name). + Int("restarts", runner.restartCount). + Int("max_restarts", config.MaxRestarts). + Msg("service exceeded max restart limit, marking as failed") + return + } + + // 检查窗口期内重启次数 + if config.MaxRestartsInWindow > 0 && runner.windowRestarts > config.MaxRestartsInWindow { + runner.failed = true + m.logger.Error().Str("name", name). + Int("window_restarts", runner.windowRestarts). + Int("max_in_window", config.MaxRestartsInWindow). + Dur("window", config.RestartWindow). + Msg("service restart rate too high, marking as failed") + return + } + + // 计算并应用退避延迟 + delay := runner.currentDelay + m.logger.Info().Str("name", name). + Dur("delay", delay). + Int("window_restarts", runner.windowRestarts). + Int("total_restarts", runner.restartCount). + Msg("waiting before restart") + + select { + case <-ctx.Done(): + return + case <-time.After(delay): + // 应用指数退避 + runner.currentDelay = time.Duration(float64(runner.currentDelay) * config.BackoffMultiplier) + if runner.currentDelay > config.MaxRestartDelay { + runner.currentDelay = config.MaxRestartDelay + } + } + } +} + func (m *Manager) start(ctx context.Context) error { defer recovery.Exit() + + // 保存 context + m.ctx, m.cancel = context.WithCancel(ctx) + logutil.OkOrFailed(m.logger, "start lifecycle before service", func() error { defer recovery.Exit() for _, run := range m.lc.GetBeforeStarts() { @@ -158,14 +1080,14 @@ func (m *Manager) start(ctx context.Context) error { return nil }) - async.GoDelay(func() error { - err := m.supervisor.Serve(ctx) - if netutil.IsErrServerClosed(err) { - return nil + // 启动所有服务 + m.mu.RLock() + for _, runner := range m.services { + if !runner.stopped { + m.startRunner(runner) } - assert.Exit(err) - return nil - }) + } + m.mu.RUnlock() logutil.OkOrFailed(m.logger, "start lifecycle after service", func() error { defer recovery.Exit() @@ -191,12 +1113,23 @@ func (m *Manager) stop(ctx context.Context) error { return nil }) - unstoppedServices, _ := m.supervisor.UnstoppedServiceReport() - if len(unstoppedServices) > 0 { - for _, service := range unstoppedServices { - m.logger.Error().Any("service", service).Msgf("service:%s is still running", service.Name) - } - return errors.New("services are still running") + // 取消所有服务 + if m.cancel != nil { + m.cancel() + } + + // 等待所有服务停止,带超时 + done := make(chan struct{}) + go func() { + m.wg.Wait() + close(done) + }() + + select { + case <-done: + m.logger.Info().Msg("all services stopped") + case <-time.After(30 * time.Second): + m.logger.Warn().Msg("timeout waiting for services to stop") } logutil.OkOrFailed(m.logger, "stop lifecycle after service", func() error { @@ -223,15 +1156,27 @@ func (m *Manager) Run(ctx context.Context) error { } func (m *Manager) Serve(ctx context.Context) error { - err := m.supervisor.Serve(ctx) + err := m.start(ctx) + if err != nil { + return err + } - if netutil.IsErrServerClosed(err) { - return nil + // 等待 context 取消 + <-ctx.Done() + + // 停止所有服务 + if m.cancel != nil { + m.cancel() } + m.wg.Wait() - return err + return nil } func (m *Manager) ServeBackground(ctx context.Context) <-chan error { - return m.supervisor.ServeBackground(ctx) + errCh := make(chan error, 1) + go func() { + errCh <- m.Serve(ctx) + }() + return errCh } diff --git a/core/supervisor/service.go b/core/supervisor/service.go index 2d0e1deb..6cd9981f 100644 --- a/core/supervisor/service.go +++ b/core/supervisor/service.go @@ -12,14 +12,23 @@ import ( ) type serviceMetric struct { - Error atomic.String - Restart atomic.Uint32 - StartTime atomic.Time - OnlineDuration atomic.Duration + Status atomic.String // 当前状态 + StartCount atomic.Uint32 // 启动次数 + ErrorCount atomic.Uint32 // 错误次数 + SuccessCount atomic.Uint32 // 成功退出次数 + LastError atomic.String // 最后一次错误信息 + LastErrorTime atomic.Time // 最后一次错误时间 + LastStartTime atomic.Time // 最后一次启动时间 + LastStopTime atomic.Time // 最后一次停止时间 + TotalUptime atomic.Duration // 总运行时长 + CreatedAt atomic.Time // 服务创建时间 } func NewService(name string, fn func(ctx context.Context) error) Service { - return &serviceImpl{name: name, fn: fn, metric: &serviceMetric{}} + m := &serviceMetric{} + m.Status.Store(string(StatusIdle)) + m.CreatedAt.Store(time.Now()) + return &serviceImpl{name: name, fn: fn, metric: m} } var _ Service = &serviceImpl{} @@ -33,13 +42,38 @@ type serviceImpl struct { } func (s *serviceImpl) Metric() *Metric { - metric := s.metric + m := s.metric + status := ServiceStatus(m.Status.Load()) + startCount := m.StartCount.Load() + lastStartTime := m.LastStartTime.Load() + totalUptime := m.TotalUptime.Load() + + // 计算当前运行时长 + var currentUptime time.Duration + if status == StatusRunning && !lastStartTime.IsZero() { + currentUptime = time.Since(lastStartTime) + } + + // 计算平均运行时长 + var averageUptime time.Duration + if startCount > 0 { + averageUptime = (totalUptime + currentUptime) / time.Duration(startCount) + } + return &Metric{ - Name: s.name, - Error: metric.Error.Load(), - Restart: metric.Restart.Load(), - StartTime: metric.StartTime.Load(), - OnlineDuration: time.Since(metric.StartTime.Load()), + Name: s.name, + Status: status, + StartCount: startCount, + ErrorCount: m.ErrorCount.Load(), + SuccessCount: m.SuccessCount.Load(), + LastError: m.LastError.Load(), + LastErrorTime: m.LastErrorTime.Load(), + LastStartTime: lastStartTime, + LastStopTime: m.LastStopTime.Load(), + CurrentUptime: currentUptime, + TotalUptime: totalUptime + currentUptime, + AverageUptime: averageUptime, + CreatedAt: m.CreatedAt.Load(), } } @@ -56,14 +90,33 @@ func (s *serviceImpl) String() string { } func (s *serviceImpl) Serve(ctx context.Context) (gErr error) { - s.metric.StartTime.Store(time.Now()) + startTime := time.Now() + s.metric.LastStartTime.Store(startTime) + s.metric.Status.Store(string(StatusRunning)) + s.metric.StartCount.Add(1) + defer func() { - if gErr != nil { - s.err = gErr - } + stopTime := time.Now() + runDuration := stopTime.Sub(startTime) + + // 累加总运行时长 + s.metric.TotalUptime.Add(runDuration) + s.metric.LastStopTime.Store(stopTime) - if s.err != nil { - s.metric.Error.Store(s.err.Error()) + // context 取消或超时视为正常停止 + isNormalStop := gErr == nil || + errors.Is(gErr, context.Canceled) || + errors.Is(gErr, context.DeadlineExceeded) + + if isNormalStop { + s.metric.Status.Store(string(StatusStopped)) + s.metric.SuccessCount.Add(1) + } else { + s.err = gErr + s.metric.Status.Store(string(StatusError)) + s.metric.ErrorCount.Add(1) + s.metric.LastError.Store(gErr.Error()) + s.metric.LastErrorTime.Store(stopTime) } log.Info(ctx). @@ -74,11 +127,10 @@ func (s *serviceImpl) Serve(ctx context.Context) (gErr error) { defer recovery.Err(&gErr) s.err = nil - s.metric.Restart.Add(1) log.Info(ctx).Str("service", s.name).Msg("start service") err := s.fn(ctx) - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("non-context error, service=%s meta=%v err=%w", s.name, s.Metric(), err) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("service error, service=%s meta=%v err=%w", s.name, s.Metric(), err) } return err } diff --git a/core/supervisor/spec.go b/core/supervisor/spec.go deleted file mode 100644 index a516f0b6..00000000 --- a/core/supervisor/spec.go +++ /dev/null @@ -1,61 +0,0 @@ -package supervisor - -import ( - "log/slog" - "time" - - "github.com/pubgo/funk/v2/log" - "github.com/thejerf/suture/v4" -) - -const ServiceTimeout = 10 * time.Second - -func SpecWithDebugLogger() suture.Spec { - return spec(func(e suture.Event) { log.Debug().Msg(e.String()) }) -} - -func SpecWithInfoLogger() suture.Spec { - return spec(infoEventHook()) -} - -func spec(eventHook suture.EventHook) suture.Spec { - return suture.Spec{ - EventHook: eventHook, - Timeout: ServiceTimeout, - PassThroughPanics: true, - DontPropagateTermination: false, - } -} - -// infoEventHook prints service failures and failures to stop services at level -// info. All other events and identical, consecutive failures are logged at -// debug only. -func infoEventHook() suture.EventHook { - var prevTerminate suture.EventServiceTerminate - return func(ei suture.Event) { - m := ei.Map() - l := slog.With("supervisor", m["supervisor_name"], "service", m["service_name"]) - switch e := ei.(type) { - case suture.EventStopTimeout: - l.Warn("Service failed to terminate in a timely manner") - case suture.EventServicePanic: - l.Error("Caught a service panic, which shouldn't happen") - l.Warn(e.String()) //nolint:sloglint - case suture.EventServiceTerminate: - if e.ServiceName == prevTerminate.ServiceName && e.Err == prevTerminate.Err { - l.Debug("Service failed repeatedly", e.Err) - } else { - l.Warn("Service failed", e.Err) - } - prevTerminate = e - l.Debug(e.String()) // Contains some backoff statistics - case suture.EventBackoff: - l.Debug("Exiting the backoff state") - case suture.EventResume: - l.Debug("Too many service failures - entering the backoff state") - default: - l.Warn("Unknown suture supervisor event", slog.Any("type", e.Type())) - l.Warn(e.String()) //nolint:lint - } - } -} diff --git a/go.mod b/go.mod index d80c1287..f25bf5d6 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,6 @@ require ( github.com/samber/lo v1.52.0 github.com/shirou/gopsutil/v3 v3.24.5 github.com/stretchr/testify v1.11.1 - github.com/thejerf/suture/v4 v4.0.6 github.com/uber-go/tally/v4 v4.1.17 github.com/ulikunitz/xz v0.5.15 github.com/valyala/fasthttp v1.69.0 diff --git a/go.sum b/go.sum index 1bda7bb7..bda2ba28 100644 --- a/go.sum +++ b/go.sum @@ -597,8 +597,6 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.4.17 h1:pGzu+Ye7ZUEFx7LHU0dAKmCOXWsPjl7qA6iMGndsjPs= github.com/tetafro/godot v1.4.17/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= -github.com/thejerf/suture/v4 v4.0.6 h1:QsuCEsCqb03xF9tPAsWAj8QOAJBgQI1c0VqJNaingg8= -github.com/thejerf/suture/v4 v4.0.6/go.mod h1:gu9Y4dXNUWFrByqRt30Rm9/UZ0wzRSt9AJS6xu/ZGxU= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= From e9d1a5e388e1853c06d3d779991b2a86a3551cee Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 20 Jan 2026 00:31:57 +0800 Subject: [PATCH 09/80] chore: quick update fix/router at 2026-01-20 00:31:56 --- .gitignore | 1 + core/debug/configview/configview.go | 68 +- core/debug/debug/debug.go | 53 +- core/debug/ui/ui.go | 4 +- core/debug/vars/debug.go | 284 +++++++- core/debug/version/version.go | 2 +- core/lavabuilder/builder.go | 6 +- core/logging/config.go | 130 +++- core/logging/config_test.go | 108 +++ core/logging/factory.go | 74 +- core/logging/logbuilder/builder.go | 123 +++- core/logging/logext/gologr/log_test.go | 2 +- core/logging/logext/grpclog/log.go | 46 +- core/logging/loggerdebug/debug.go | 865 ++++++++++++++++++++++++ core/scheduler/config.go | 4 +- core/scheduler/job.go | 9 +- core/scheduler/scheduler.go | 40 +- core/scheduler/schedulerdebug/debug.go | 784 +++++++++++++++++---- core/scheduler/schedulerdebug/html.go | 16 +- core/supervisor/aaa.go | 54 +- go.mod | 2 +- go.sum | 4 +- internal/configs/components/logger.yaml | 7 +- 23 files changed, 2418 insertions(+), 268 deletions(-) create mode 100644 core/logging/config_test.go create mode 100644 core/logging/loggerdebug/debug.go diff --git a/.gitignore b/.gitignore index 5fcf71a2..4ec68eea 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ bin /example/bin .proto proto-vendor +.local diff --git a/core/debug/configview/configview.go b/core/debug/configview/configview.go index 736ce75e..48ecb843 100644 --- a/core/debug/configview/configview.go +++ b/core/debug/configview/configview.go @@ -294,11 +294,37 @@ func buildConfigTree(data map[string]any, indent string) template.HTML { value := data[key] switch v := value.(type) { case map[string]any: - result += fmt.Sprintf(`
%s:`, indent, key) - result += string(buildConfigTree(v, indent+"ml-4")) - result += `
` + if len(v) == 0 { + result += fmt.Sprintf(`
%s: {}
`, indent, key) + } else { + result += fmt.Sprintf(` +
+ + + + + %s + (%d) + +
%s
+
`, indent, key, len(v), buildConfigTree(v, "")) + } case []any: - result += fmt.Sprintf(`
%s: [%d items]
`, indent, key, len(v)) + if len(v) == 0 { + result += fmt.Sprintf(`
%s: []
`, indent, key) + } else { + result += fmt.Sprintf(` +
+ + + + + %s + [%d items] + +
%s
+
`, indent, key, len(v), buildArrayTree(v, key)) + } default: valueStr := fmt.Sprintf("%v", v) valueClass := "text-green-400" @@ -311,6 +337,40 @@ func buildConfigTree(data map[string]any, indent string) template.HTML { return template.HTML(result) } +func buildArrayTree(arr []any, parentKey string) template.HTML { + result := "" + for i, item := range arr { + switch v := item.(type) { + case map[string]any: + if len(v) == 0 { + result += fmt.Sprintf(`
[%d]: {}
`, i) + } else { + result += fmt.Sprintf(` +
+ + + + + [%d] + +
%s
+
`, i, buildConfigTree(v, "")) + } + case []any: + result += fmt.Sprintf(`
[%d]: [%d items]
`, i, len(v)) + default: + valueStr := fmt.Sprintf("%v", v) + valueClass := "text-green-400" + if isSensitiveKey(parentKey) { + valueClass = "text-yellow-400" + valueStr = maskString(valueStr) + } + result += fmt.Sprintf(`
[%d]: %s
`, i, valueClass, template.HTMLEscapeString(valueStr)) + } + } + return template.HTML(result) +} + func maskYaml(yaml string) string { lines := strings.Split(yaml, "\n") for i, line := range lines { diff --git a/core/debug/debug/debug.go b/core/debug/debug/debug.go index 67ad03fb..d382617f 100644 --- a/core/debug/debug/debug.go +++ b/core/debug/debug/debug.go @@ -66,7 +66,8 @@ func initDebug() { } sort.Strings(groupNames) - var routesHTML template.HTML + // 构建折叠面板的路由列表 + var routeItems string for _, group := range groupNames { routes := groups[group] var rows template.HTML @@ -79,9 +80,42 @@ func initDebug() { rows += ui.TR(methodBadges, fmt.Sprintf(`%s`, r.Path, r.Path)) } tableHTML := ui.Table([]string{"方法", "路径"}) + rows + ui.TableEnd() - routesHTML += ui.Card(fmt.Sprintf("📁 %s (%d)", group, len(routes)), tableHTML) + + routeItems += fmt.Sprintf(` +
+ +
+ 📁 + %s + %d +
+ + + +
+
%s
+
`, group, len(routes), tableHTML) } + totalRoutes := len(pathList) + routesHTML := template.HTML(fmt.Sprintf(` +
+
+
+

🛣️ API 路由

+ %d 个路由 + %d 个分组 +
+
+ +
+
+
%s
+
`, totalRoutes, len(groupNames), routeItems)) + // 快捷操作 actionsHTML := template.HTML(` diff --git a/core/debug/vars/debug.go b/core/debug/vars/debug.go index ddc85312..8abf0e88 100644 --- a/core/debug/vars/debug.go +++ b/core/debug/vars/debug.go @@ -3,43 +3,279 @@ package vars import ( "expvar" "fmt" + "html/template" + "sort" "github.com/gofiber/fiber/v2" - g "github.com/maragudk/gomponents" - c "github.com/maragudk/gomponents/components" - h "github.com/maragudk/gomponents/html" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" ) func init() { defer recovery.Exit() - index := func(keys []string) g.Node { - nodes := make([]g.Node, 0, len(keys)) - nodes = append(nodes, h.H1(g.Text("/expvar"))) - nodes = append(nodes, h.A(g.Text("/debug"), g.Attr("href", "/debug")), h.Br()) - for i := range keys { - nodes = append(nodes, h.A(g.Text(keys[i]), g.Attr("href", keys[i])), h.Br()) - } - return c.HTML5(c.HTML5Props{Title: "/expvar", Body: nodes}) - } debug.Route("/vars", func(r fiber.Router) { - r.Get("/", func(ctx *fiber.Ctx) error { - ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - var keys []string - expvar.Do(func(kv expvar.KeyValue) { - keys = append(keys, fmt.Sprintf("/debug/vars/%s", kv.Key)) - }) - - return index(keys).Render(ctx) + r.Get("/", handleVarsPage) + r.Get("/api/list", handleVarsList) + r.Get("/api/get/:name", handleVarGet) + r.Get("/:name", handleVarDetail) + }) +} + +func handleVarsPage(ctx *fiber.Ctx) error { + var vars []varInfo + expvar.Do(func(kv expvar.KeyValue) { + vars = append(vars, varInfo{ + Name: kv.Key, + Value: kv.Value.String(), }) + }) + sort.Slice(vars, func(i, j int) bool { + return vars[i].Name < vars[j].Name + }) + + content := buildVarsContent(vars) + + html, err := ui.Render(ui.PageData{ + Title: "Expvar 变量", + Description: "应用运行时导出的变量", + Breadcrumb: []string{"Vars"}, + Content: template.HTML(content), + ExtraHead: template.HTML(buildVarsScript()), + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + ctx.Set("Content-Type", "text/html; charset=utf-8") + return ctx.SendString(html) +} - r.Get("/:name", func(ctx *fiber.Ctx) error { - name := ctx.Params("name") - ctx.Response().Header.Set("Content-Type", "application/json; charset=utf-8") - return ctx.SendString(expvar.Get(name).String()) +type varInfo struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func handleVarsList(ctx *fiber.Ctx) error { + var vars []varInfo + expvar.Do(func(kv expvar.KeyValue) { + vars = append(vars, varInfo{ + Name: kv.Key, + Value: kv.Value.String(), }) }) + return ctx.JSON(vars) +} + +func handleVarGet(ctx *fiber.Ctx) error { + name := ctx.Params("name") + v := expvar.Get(name) + if v == nil { + return ctx.Status(404).JSON(fiber.Map{"error": "variable not found"}) + } + ctx.Set("Content-Type", "application/json; charset=utf-8") + return ctx.SendString(v.String()) +} + +func handleVarDetail(ctx *fiber.Ctx) error { + name := ctx.Params("name") + v := expvar.Get(name) + if v == nil { + return ctx.Status(404).SendString("variable not found") + } + ctx.Set("Content-Type", "application/json; charset=utf-8") + return ctx.SendString(v.String()) +} + +func buildVarsScript() string { + return ` + +` +} + +func buildVarsContent(vars []varInfo) string { + return fmt.Sprintf(` +
+ +
+
+
总变量数
+
%d
+
+
+
筛选结果
+
+
+
+
刷新
+ +
+
+ + +
+ +
+ + +
+ +
+
+

变量列表

+
+
+ +
+ 暂无匹配的变量 +
+
+
+ + +
+
+
+

变量详情

+ +
+ +
+
+ + +
+
+
+
+`, len(vars)) } diff --git a/core/debug/version/version.go b/core/debug/version/version.go index b27d96e6..67b548ed 100644 --- a/core/debug/version/version.go +++ b/core/debug/version/version.go @@ -1,11 +1,11 @@ package version import ( + "encoding/json" "net/http" "os" rd "runtime/debug" - json "github.com/goccy/go-json" "github.com/gofiber/adaptor/v2" "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/running" diff --git a/core/lavabuilder/builder.go b/core/lavabuilder/builder.go index 51a9ca37..dc16c347 100644 --- a/core/lavabuilder/builder.go +++ b/core/lavabuilder/builder.go @@ -9,7 +9,6 @@ import ( "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/features/featureflags" "github.com/pubgo/funk/v2/recovery" - // metric "github.com/pubgo/redant" _ "go.uber.org/automaxprocs" @@ -25,7 +24,6 @@ import ( _ "github.com/pubgo/lava/v2/core/debug/debug" "github.com/pubgo/lava/v2/core/debug/dixdebug" _ "github.com/pubgo/lava/v2/core/debug/featurehttp" - //_ "github.com/pubgo/lava/v2/core/debug/gops" _ "github.com/pubgo/lava/v2/core/debug/goroutine" _ "github.com/pubgo/lava/v2/core/debug/healthy" @@ -40,18 +38,18 @@ import ( _ "github.com/pubgo/lava/v2/core/debug/vars" _ "github.com/pubgo/lava/v2/core/debug/version" "github.com/pubgo/lava/v2/core/discovery" - // encoding _ "github.com/pubgo/lava/v2/core/encoding/protobuf" _ "github.com/pubgo/lava/v2/core/encoding/protojson" "github.com/pubgo/lava/v2/core/flags" "github.com/pubgo/lava/v2/core/lifecycle/lifecyclebuilder" "github.com/pubgo/lava/v2/core/logging/logbuilder" - // logging _ "github.com/pubgo/lava/v2/core/logging/logext/grpclog" _ "github.com/pubgo/lava/v2/core/logging/logext/slog" _ "github.com/pubgo/lava/v2/core/logging/logext/stdlog" + // loggerdebug + _ "github.com/pubgo/lava/v2/core/logging/loggerdebug" _ "github.com/pubgo/lava/v2/core/metrics/drivers/prometheus" "github.com/pubgo/lava/v2/core/metrics/metricbuilder" "github.com/pubgo/lava/v2/core/signals" diff --git a/core/logging/config.go b/core/logging/config.go index 7ad69e4e..a43a3485 100644 --- a/core/logging/config.go +++ b/core/logging/config.go @@ -1,12 +1,136 @@ package logging +import "fmt" + +// LogConfigLoader 配置加载器 type LogConfigLoader struct { Log *Config `yaml:"logger"` } +// Config 日志配置 type Config struct { - Level string `yaml:"level"` - AsJson bool `yaml:"as_json"` + // Level 日志级别: trace, debug, info, warn, error, fatal, panic + Level string `yaml:"level"` + // AsJson 终端是否以 JSON 格式输出 + AsJson bool `yaml:"as_json"` + // DisableLoggers 要禁用日志输出的 logger 名称列表 + // 支持前缀匹配,如 "grpc" 会禁用 "grpc", "grpc.server" 等 DisableLoggers []string `yaml:"disable_loggers"` - Filters []string `yaml:"filters"` + // Filters expr 表达式过滤器列表,多个表达式以 && 连接 + Filters []string `yaml:"filters"` + // File 文件输出配置,为空则不输出到文件 + File *FileConfig `yaml:"file"` +} + +// FileConfig 文件输出配置 +type FileConfig struct { + // Enabled 是否启用文件输出 + Enabled bool `yaml:"enabled"` + // Path 日志文件路径 + Path string `yaml:"path"` + // MaxSize 单个日志文件最大大小(MB) + MaxSize int `yaml:"max_size"` + // MaxBackups 保留的旧日志文件最大数量 + MaxBackups int `yaml:"max_backups"` + // MaxAge 保留旧日志文件的最大天数 + MaxAge int `yaml:"max_age"` + // Compress 是否压缩旧日志文件 + Compress bool `yaml:"compress"` +} + +// DefaultFileConfig 返回默认文件配置 +func DefaultFileConfig() *FileConfig { + return &FileConfig{ + Enabled: false, + Path: "logs/app.log", + MaxSize: 100, // 100MB + MaxBackups: 10, + MaxAge: 30, // 30 天 + Compress: true, + } +} + +// DefaultConfig 返回默认配置 +func DefaultConfig() *Config { + return &Config{ + Level: "info", + AsJson: false, + File: DefaultFileConfig(), + } +} + +// Validate 验证配置有效性 +func (c *Config) Validate() error { + if c == nil { + return nil + } + + validLevels := map[string]bool{ + "": true, // 空字符串使用默认值 + "trace": true, + "debug": true, + "info": true, + "warn": true, + "error": true, + "fatal": true, + "panic": true, + } + + if !validLevels[c.Level] { + return fmt.Errorf("invalid log level: %s", c.Level) + } + + // 验证文件配置 + if c.File != nil && c.File.Enabled { + if c.File.Path == "" { + return fmt.Errorf("file path is required when file output is enabled") + } + if c.File.MaxSize <= 0 { + c.File.MaxSize = 100 + } + if c.File.MaxBackups <= 0 { + c.File.MaxBackups = 10 + } + if c.File.MaxAge <= 0 { + c.File.MaxAge = 30 + } + } + + return nil +} + +// Merge 合并配置,优先使用 other 的非零值 +func (c *Config) Merge(other *Config) *Config { + if other == nil { + return c + } + if c == nil { + return other + } + + result := *c + if other.Level != "" { + result.Level = other.Level + } + if other.AsJson { + result.AsJson = other.AsJson + } + if len(other.DisableLoggers) > 0 { + result.DisableLoggers = other.DisableLoggers + } + if len(other.Filters) > 0 { + result.Filters = other.Filters + } + if other.File != nil { + result.File = other.File + } + return &result +} + +// GetLogFilePath 获取日志文件路径(用于 loggerdebug 读取) +func (c *Config) GetLogFilePath() string { + if c.File != nil && c.File.Enabled { + return c.File.Path + } + return "" } diff --git a/core/logging/config_test.go b/core/logging/config_test.go new file mode 100644 index 00000000..b6a7fb1d --- /dev/null +++ b/core/logging/config_test.go @@ -0,0 +1,108 @@ +package logging + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, "info", cfg.Level) + assert.False(t, cfg.AsJson) + assert.Nil(t, cfg.DisableLoggers) + assert.Nil(t, cfg.Filters) +} + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + level string + wantErr bool + }{ + {"empty level", "", false}, + {"debug level", "debug", false}, + {"info level", "info", false}, + {"warn level", "warn", false}, + {"error level", "error", false}, + {"invalid level", "invalid", true}, + {"uppercase level", "INFO", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{Level: tt.level} + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + + // nil config should not error + var nilCfg *Config + assert.NoError(t, nilCfg.Validate()) +} + +func TestConfigMerge(t *testing.T) { + base := &Config{ + Level: "debug", + AsJson: false, + } + + override := &Config{ + Level: "info", + AsJson: true, + DisableLoggers: []string{"grpcLog"}, + } + + merged := base.Merge(override) + + assert.Equal(t, "info", merged.Level) + assert.True(t, merged.AsJson) + assert.Equal(t, []string{"grpcLog"}, merged.DisableLoggers) + + // nil merge tests + assert.Equal(t, base, base.Merge(nil)) + + var nilCfg *Config + assert.Equal(t, override, nilCfg.Merge(override)) +} + +func TestDisabledLoggers(t *testing.T) { + // Reset state + SetDisabledLoggers(nil) + + assert.False(t, IsDisabled("grpc")) + + SetDisabledLoggers([]string{"grpc", "std"}) + + // 精确匹配 + assert.True(t, IsDisabled("grpc")) + assert.True(t, IsDisabled("std")) + assert.False(t, IsDisabled("slog")) + + // 前缀匹配 + assert.True(t, IsDisabled("grpc.server")) + assert.True(t, IsDisabled("grpc.client")) + assert.True(t, IsDisabled("std.log")) + assert.False(t, IsDisabled("grpclog")) // 不是前缀匹配(没有点分隔) + assert.False(t, IsDisabled("stdlib")) // 不是前缀匹配 + + // Clear + SetDisabledLoggers(nil) + assert.False(t, IsDisabled("grpc")) + assert.False(t, IsDisabled("grpc.server")) +} + +func TestFactoryRegisterAndList(t *testing.T) { + // Note: factories are global state, be careful with tests + initialCount := len(List()) + + // Register should work (but we can't easily test without affecting global state) + factories := List() + assert.NotNil(t, factories) + assert.GreaterOrEqual(t, len(factories), initialCount) +} diff --git a/core/logging/factory.go b/core/logging/factory.go index 134740f3..12d9128d 100644 --- a/core/logging/factory.go +++ b/core/logging/factory.go @@ -1,6 +1,8 @@ package logging import ( + "sync" + "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" @@ -8,12 +10,80 @@ import ( type Factory func(log log.Logger) -var factories = make(map[string]Factory) +var ( + factories = make(map[string]Factory) + factoryMu sync.RWMutex + disabledLoggers = make(map[string]struct{}) + logFilePath string // 日志文件路径,供 loggerdebug 使用 +) + +// List 返回所有注册的日志工厂(返回副本,线程安全) +func List() map[string]Factory { + factoryMu.RLock() + defer factoryMu.RUnlock() + + result := make(map[string]Factory, len(factories)) + for k, v := range factories { + result[k] = v + } + return result +} -func List() map[string]Factory { return factories } +// Register 注册日志工厂 func Register(name string, factory Factory) { defer recovery.Exit() assert.If(name == "" || factory == nil, "[factory, name] should not be null") + + factoryMu.Lock() + defer factoryMu.Unlock() + assert.If(factories[name] != nil, "[factory] %s already exists", name) factories[name] = factory } + +// SetDisabledLoggers 设置要禁用的 logger 名称列表 +// 这些 logger 的日志输出将被 EnableChecker 过滤掉 +func SetDisabledLoggers(names []string) { + factoryMu.Lock() + defer factoryMu.Unlock() + + disabledLoggers = make(map[string]struct{}, len(names)) + for _, name := range names { + disabledLoggers[name] = struct{}{} + } +} + +// IsDisabled 检查指定的 logger 是否被禁用 +// 支持前缀匹配,如禁用 "grpc" 会同时禁用 "grpc", "grpc.server", "grpc.client" 等 +func IsDisabled(name string) bool { + factoryMu.RLock() + defer factoryMu.RUnlock() + + // 精确匹配 + if _, ok := disabledLoggers[name]; ok { + return true + } + + // 前缀匹配 + for disabled := range disabledLoggers { + if len(name) > len(disabled) && name[:len(disabled)] == disabled && name[len(disabled)] == '.' { + return true + } + } + + return false +} + +// SetLogFilePath 设置日志文件路径 +func SetLogFilePath(path string) { + factoryMu.Lock() + defer factoryMu.Unlock() + logFilePath = path +} + +// GetLogFilePath 获取日志文件路径 +func GetLogFilePath() string { + factoryMu.RLock() + defer factoryMu.RUnlock() + return logFilePath +} diff --git a/core/logging/logbuilder/builder.go b/core/logging/logbuilder/builder.go index 1b428f45..0d03aced 100644 --- a/core/logging/logbuilder/builder.go +++ b/core/logging/logbuilder/builder.go @@ -2,14 +2,17 @@ package logbuilder import ( "context" + "fmt" "io" "os" + "path/filepath" "strings" "time" "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" "github.com/pubgo/funk/v2/assert" + "github.com/pubgo/funk/v2/features" "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/log/logfields" "github.com/pubgo/funk/v2/pretty" @@ -18,6 +21,7 @@ import ( "github.com/pubgo/funk/v2/running" "github.com/rs/zerolog" "github.com/samber/lo" + "gopkg.in/natefinch/lumberjack.v2" "github.com/pubgo/lava/v2/core/logging" "github.com/pubgo/lava/v2/core/logging/logkey" @@ -25,28 +29,59 @@ import ( var GlobalHook zerolog.Hook -// New logger +// ConsoleLogEnabled 控制终端日志输出的开关 +var ConsoleLogEnabled = features.Bool("log.console.enabled", true, "是否启用终端日志输出") + +// New 创建新的 logger 实例 func New(cfg *logging.Config, hooks []zerolog.Hook) log.Logger { defer recovery.Exit(func(err error) error { pretty.Println(cfg) return err }) + // 配置验证 + if err := cfg.Validate(); err != nil { + assert.Exit(err, "invalid logging config") + } + + // 设置禁用的 loggers + if len(cfg.DisableLoggers) > 0 { + logging.SetDisabledLoggers(cfg.DisableLoggers) + } + level := zerolog.DebugLevel if cfg.Level != "" { level = result.Wrap(zerolog.ParseLevel(cfg.Level)).Expect("log level is invalid") } zerolog.SetGlobalLevel(level) - logger := zerolog.New(&writer{os.Stdout}).Level(level).With().Timestamp().Caller().Logger() - if !cfg.AsJson { - logger = logger.Output(&writer{ - zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { - w.Out = os.Stdout - w.TimeFormat = time.RFC3339 - }), + // 构建输出 writers + var writers []io.Writer + + // 终端输出(受 feature flag 控制) + var consoleWriter io.Writer + if cfg.AsJson { + consoleWriter = os.Stdout + } else { + consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.Out = os.Stdout + w.TimeFormat = time.RFC3339 }) } + // 包装 consoleWriter,根据 feature flag 动态控制 + writers = append(writers, &consoleWriterWrapper{w: consoleWriter}) + + // 文件输出(JSON 格式,带 logrotate) + if cfg.File != nil && cfg.File.Enabled { + fileWriter := newFileWriter(cfg.File) + writers = append(writers, fileWriter) + // 保存文件路径供 loggerdebug 使用 + logging.SetLogFilePath(cfg.File.Path) + } + + // 组合多个 writer + multiWriter := io.MultiWriter(writers...) + logger := zerolog.New(&writer{multiWriter}).Level(level).With().Timestamp().Caller().Logger() if GlobalHook != nil { hooks = append(hooks, GlobalHook) @@ -64,23 +99,42 @@ func New(cfg *logging.Config, hooks []zerolog.Hook) log.Logger { } filters := lo.Filter(cfg.Filters, func(item string, index int) bool { return strings.TrimSpace(item) != "" }) - if len(filters) > 0 { - expCode := strings.Join(filters, " && ") - log.Info().Str(logfields.Msg, "log filter expr").Msg(expCode) - exp := exprFilter(expCode) + // 设置日志过滤器 + if len(filters) > 0 || len(cfg.DisableLoggers) > 0 { + var exp *vm.Program + var expCode string + + if len(filters) > 0 { + expCode = strings.Join(filters, " && ") + log.Info().Str(logfields.Msg, "log filter expr").Msg(expCode) + exp = exprFilter(expCode) + } + log.SetEnableChecker(func(ctx context.Context, lvl log.Level, name, message string, fields log.Fields) bool { - envData := map[string]any{"level": lvl.String(), "msg": message, "name": name, "fields": fields} - output, err := expr.Run(exp, envData) - if err != nil { - log.Err(err).Str("expr", expCode).Msg("failed to run log filter expr") + // 检查 logger name 是否被禁用 + if logging.IsDisabled(name) { + return false } - return err == nil && output.(bool) + + // 检查 expr 过滤器 + if exp != nil { + envData := map[string]any{"level": lvl.String(), "msg": message, "name": name, "fields": fields} + output, err := expr.Run(exp, envData) + if err != nil { + log.Err(err).Str("expr", expCode).Msg("failed to run log filter expr") + return true // 出错时不过滤 + } + return output.(bool) + } + + return true }) } log.SetLogger(lo.ToPtr(ee.Logger())) + // 初始化扩展 loggers gl := log.GetLogger("ext") for _, ext := range logging.List() { ext(gl) @@ -89,6 +143,24 @@ func New(cfg *logging.Config, hooks []zerolog.Hook) log.Logger { return log.GetLogger() } +// newFileWriter 创建带 logrotate 的文件 writer +func newFileWriter(cfg *logging.FileConfig) io.Writer { + // 确保目录存在 + dir := filepath.Dir(cfg.Path) + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Err(err).Str("dir", dir).Msg("failed to create log directory") + } + + return &lumberjack.Logger{ + Filename: cfg.Path, + MaxSize: cfg.MaxSize, // MB + MaxBackups: cfg.MaxBackups, // 保留的旧文件数量 + MaxAge: cfg.MaxAge, // 保留天数 + Compress: cfg.Compress, // 是否压缩 + LocalTime: true, // 使用本地时间 + } +} + type writer struct { io.Writer } @@ -96,13 +168,24 @@ type writer struct { func (w writer) Write(p []byte) (n int, err error) { n, err = w.Writer.Write(p) if err != nil { - log.Err(err).Str("raw_json", string(p)).Msg("failed to decode invalid json") - return n, err + // 使用 stderr 直接输出避免递归 + _, _ = fmt.Fprintf(os.Stderr, "[logging] write error: %v, raw: %s\n", err, string(p)) } - return n, err } +// consoleWriterWrapper 包装 console writer,根据 feature flag 动态控制输出 +type consoleWriterWrapper struct { + w io.Writer +} + +func (c *consoleWriterWrapper) Write(p []byte) (n int, err error) { + if !ConsoleLogEnabled.Value() { + return len(p), nil // 禁用时丢弃输出 + } + return c.w.Write(p) +} + func exprFilter(code string) *vm.Program { env := map[string]any{"level": "", "name": "", "msg": "", "fields": log.Fields{}} diff --git a/core/logging/logext/gologr/log_test.go b/core/logging/logext/gologr/log_test.go index 3e74cb26..92434356 100644 --- a/core/logging/logext/gologr/log_test.go +++ b/core/logging/logext/gologr/log_test.go @@ -20,5 +20,5 @@ func TestName(t *testing.T) { assert.Equal(t, data["hello"], float64(123456)) assert.Equal(t, data["level"], "info") assert.Equal(t, data["message"], "test") - assert.Contains(t, data["caller"], "otellogger/log_test.go") + assert.Contains(t, data["caller"], "gologr/log_test.go") } diff --git a/core/logging/logext/grpclog/log.go b/core/logging/logext/grpclog/log.go index 5ab1dece..954aa229 100644 --- a/core/logging/logext/grpclog/log.go +++ b/core/logging/logext/grpclog/log.go @@ -37,9 +37,9 @@ func init() { } func SetLogger(logger log.Logger) { - logger = logger.WithName("grpc").WithCallerSkip(2) + logger = logger.WithName("grpc") grpclog.SetLoggerV2(&loggerWrapper{ - log: logger, + log: logger.WithCallerSkip(2), depthLog: logger, }) } @@ -57,22 +57,26 @@ type loggerWrapper struct { printlnFilter func(args ...any) bool } +// DepthLoggerV2 实现 + func (l *loggerWrapper) InfoDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Info().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Info().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } func (l *loggerWrapper) WarningDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Warn().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Warn().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } func (l *loggerWrapper) ErrorDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Error().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Error().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } func (l *loggerWrapper) FatalDepth(depth int, args ...any) { - l.depthLog.WithCallerSkip(depth).Fatal().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) + l.depthLog.WithCallerSkip(depth + 2).Fatal().Func(grpcComponentName(args[0])).Msg(fmt.Sprint(args[1:]...)) } +// Filter 设置 + func (l *loggerWrapper) SetPrintFilter(filter func(args ...any) bool) { l.printFilter = filter } @@ -97,11 +101,12 @@ func (l *loggerWrapper) filterln(args ...any) bool { return l.printlnFilter != nil && l.printlnFilter(args...) } +// LoggerV2 实现 - Info + func (l *loggerWrapper) Info(args ...any) { if l.filter(args) { return } - l.log.Info().Msg(fmt.Sprint(args...)) } @@ -109,7 +114,6 @@ func (l *loggerWrapper) Infoln(args ...any) { if l.filterln(args) { return } - l.log.Info().Msg(fmt.Sprint(args...)) } @@ -117,15 +121,15 @@ func (l *loggerWrapper) Infof(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Info().Msg(fmt.Sprintf(format, args...)) + l.log.Info().Msgf(format, args...) } +// LoggerV2 实现 - Warning + func (l *loggerWrapper) Warning(args ...any) { if l.filter(args...) { return } - l.log.Warn().Msg(fmt.Sprint(args...)) } @@ -133,7 +137,6 @@ func (l *loggerWrapper) Warningln(args ...any) { if l.filterln(args) { return } - l.log.Warn().Msg(fmt.Sprint(args...)) } @@ -141,15 +144,15 @@ func (l *loggerWrapper) Warningf(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Warn().Msg(fmt.Sprintf(format, args...)) + l.log.Warn().Msgf(format, args...) } +// LoggerV2 实现 - Error + func (l *loggerWrapper) Error(args ...any) { if l.filter(args...) { return } - l.log.Error().Msg(fmt.Sprint(args...)) } @@ -157,7 +160,6 @@ func (l *loggerWrapper) Errorln(args ...any) { if l.filterln(args) { return } - l.log.Error().Msg(fmt.Sprint(args...)) } @@ -165,15 +167,15 @@ func (l *loggerWrapper) Errorf(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Error().Msg(fmt.Sprintf(format, args...)) + l.log.Error().Msgf(format, args...) } +// LoggerV2 实现 - Fatal + func (l *loggerWrapper) Fatal(args ...any) { if l.filter(args...) { return } - l.log.Fatal().Msg(fmt.Sprint(args...)) } @@ -181,7 +183,6 @@ func (l *loggerWrapper) Fatalln(args ...any) { if l.filterln(args) { return } - l.log.Fatal().Msg(fmt.Sprint(args...)) } @@ -189,10 +190,11 @@ func (l *loggerWrapper) Fatalf(format string, args ...any) { if l.filterf(format, args...) { return } - - l.log.Fatal().Msg(fmt.Sprintf(format, args...)) + l.log.Fatal().Msgf(format, args...) } +// V 实现日志级别检查 + func (l *loggerWrapper) V(level int) bool { return _grpcToZapLevel[level] >= zerolog.GlobalLevel() } diff --git a/core/logging/loggerdebug/debug.go b/core/logging/loggerdebug/debug.go new file mode 100644 index 00000000..fe6891c9 --- /dev/null +++ b/core/logging/loggerdebug/debug.go @@ -0,0 +1,865 @@ +package loggerdebug + +import ( + "bufio" + "encoding/json" + "fmt" + "html/template" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" + "github.com/gofiber/fiber/v2" + "github.com/pubgo/funk/v2/closer" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" + "github.com/pubgo/lava/v2/core/logging" +) + +// LogEntry 日志条目 +type LogEntry struct { + Time string `json:"time"` + Level string `json:"level"` + Caller string `json:"caller"` + Message string `json:"message"` + Logger string `json:"logger"` + Fields map[string]any `json:"-"` + Raw string `json:"raw"` +} + +// QueryRequest 查询请求 +type QueryRequest struct { + Filter string `json:"filter"` + Level string `json:"level"` + Logger string `json:"logger"` + Keyword string `json:"keyword"` + Start string `json:"start"` + End string `json:"end"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Tail bool `json:"tail"` + FileName string `json:"fileName"` +} + +// QueryResponse 查询响应 +type QueryResponse struct { + Logs []LogEntry `json:"logs"` + Total int `json:"total"` + HasMore bool `json:"hasMore"` + Files []FileInfo `json:"files"` + CurrentFile string `json:"currentFile"` +} + +// FileInfo 日志文件信息 +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime string `json:"modTime"` +} + +func init() { + debug.Route("/logs", func(router fiber.Router) { + router.Get("/", handlePage) + router.Get("/api/logs", handleQuery) + router.Get("/api/files", handleListFiles) + router.Get("/api/stats", handleStats) + }) +} + +// handlePage 渲染日志查看页面 +func handlePage(c *fiber.Ctx) error { + logPath := logging.GetLogFilePath() + + html, err := ui.Render(ui.PageData{ + Title: "日志查看器", + Description: "查询和分析 JSON 日志文件", + Breadcrumb: []string{"Logs"}, + Content: template.HTML(buildPageContent(logPath)), + ExtraHead: template.HTML(buildPageScript()), + }) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + c.Set("Content-Type", "text/html; charset=utf-8") + return c.SendString(html) +} + +func buildPageScript() string { + return ` + +` +} + +func buildPageContent(logPath string) string { + return ` +
+ +
+
+ +
+ 📁 + +
+ +
+ 总计: + 错误: + 警告: +
+
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + 高级过滤 (expr 表达式) + +
+ +
+ 可用变量: + level + message + logger + caller + fields + 函数: + contains(s, sub) + startsWith(s, pre) + endsWith(s, suf) +
+
+
+
+ + +
+
+
+

日志列表

+ 显示 + 有更多记录 +
+ 加载中... +
+
+ + + + + + + + + + + + + + + + +
时间级别Logger消息Caller
+
📭
+ 暂无匹配的日志记录 +
+
+
+ + +
+
+ +
+
+

日志详情

+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+` +} + +// handleQuery 处理日志查询 +func handleQuery(c *fiber.Ctx) error { + req := QueryRequest{ + Filter: c.Query("filter"), + Level: c.Query("level"), + Logger: c.Query("logger"), + Keyword: c.Query("keyword"), + Start: c.Query("start"), + End: c.Query("end"), + Limit: c.QueryInt("limit", 100), + Offset: c.QueryInt("offset", 0), + Tail: c.QueryBool("tail", true), + FileName: c.Query("fileName"), + } + + if req.Limit > 1000 { + req.Limit = 1000 + } + + logPath := logging.GetLogFilePath() + if logPath == "" { + return c.JSON(QueryResponse{Logs: []LogEntry{}}) + } + + filePath := logPath + if req.FileName != "" { + dir := filepath.Dir(logPath) + filePath = filepath.Join(dir, req.FileName) + } + + logs, hasMore, err := queryLogs(filePath, req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(QueryResponse{ + Logs: logs, + Total: len(logs), + HasMore: hasMore, + CurrentFile: filepath.Base(filePath), + }) +} + +// handleListFiles 列出日志文件 +func handleListFiles(c *fiber.Ctx) error { + logPath := logging.GetLogFilePath() + if logPath == "" { + return c.JSON(fiber.Map{"files": []FileInfo{}}) + } + + dir := filepath.Dir(logPath) + baseName := filepath.Base(logPath) + baseNameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + + var files []FileInfo + entries, err := os.ReadDir(dir) + if err != nil { + return c.JSON(fiber.Map{"files": files}) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, baseNameNoExt) { + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, FileInfo{ + Name: name, + Size: info.Size(), + ModTime: info.ModTime().Format(time.RFC3339), + }) + } + } + + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime > files[j].ModTime + }) + + return c.JSON(fiber.Map{"files": files}) +} + +// handleStats 获取日志统计 +func handleStats(c *fiber.Ctx) error { + logPath := logging.GetLogFilePath() + if logPath == "" { + return c.JSON(fiber.Map{}) + } + + fileName := c.Query("fileName") + filePath := logPath + if fileName != "" { + dir := filepath.Dir(logPath) + filePath = filepath.Join(dir, fileName) + } + + stats := countLogStats(filePath) + return c.JSON(stats) +} + +// queryLogs 查询日志 +func queryLogs(filePath string, req QueryRequest) ([]LogEntry, bool, error) { + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + return []LogEntry{}, false, nil + } + return nil, false, err + } + defer closer.SafeClose(file) + + var exprProgram *vm.Program + if req.Filter != "" { + env := map[string]any{ + "level": "", + "message": "", + "logger": "", + "caller": "", + "time": "", + "fields": map[string]any{}, + } + prog, err := expr.Compile(req.Filter, expr.Env(env), expr.AsBool()) + if err != nil { + return nil, false, fmt.Errorf("invalid filter expression: %w", err) + } + exprProgram = prog + } + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + var allLogs []LogEntry + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + entry, ok := parseLine(line) + if !ok { + continue + } + + if req.Level != "" && entry.Level != req.Level { + continue + } + + if req.Logger != "" && entry.Logger != req.Logger { + continue + } + + if req.Keyword != "" && !strings.Contains(strings.ToLower(entry.Raw), strings.ToLower(req.Keyword)) { + continue + } + + if exprProgram != nil { + env := map[string]any{ + "level": entry.Level, + "message": entry.Message, + "logger": entry.Logger, + "caller": entry.Caller, + "time": entry.Time, + "fields": entry.Fields, + } + result, err := expr.Run(exprProgram, env) + if err != nil || result != true { + continue + } + } + + allLogs = append(allLogs, entry) + } + + total := len(allLogs) + hasMore := false + var logs []LogEntry + + if req.Tail { + start := total - req.Limit - req.Offset + if start < 0 { + start = 0 + } + end := total - req.Offset + if end > total { + end = total + } + if end < 0 { + end = 0 + } + if start < end { + logs = allLogs[start:end] + } + hasMore = start > 0 + for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 { + logs[i], logs[j] = logs[j], logs[i] + } + } else { + start := req.Offset + end := req.Offset + req.Limit + if start > total { + start = total + } + if end > total { + end = total + } + logs = allLogs[start:end] + hasMore = end < total + } + + return logs, hasMore, scanner.Err() +} + +// parseLine 解析单行日志 +func parseLine(line string) (LogEntry, bool) { + var data map[string]any + if err := json.Unmarshal([]byte(line), &data); err != nil { + return LogEntry{}, false + } + + entry := LogEntry{ + Raw: line, + Fields: make(map[string]any), + } + + if v, ok := data["time"].(string); ok { + entry.Time = v + } + if v, ok := data["level"].(string); ok { + entry.Level = v + } + if v, ok := data["caller"].(string); ok { + entry.Caller = v + } + if v, ok := data["message"].(string); ok { + entry.Message = v + } + if v, ok := data["logger"].(string); ok { + entry.Logger = v + } + + for k, v := range data { + if k != "time" && k != "level" && k != "caller" && k != "message" && k != "logger" { + entry.Fields[k] = v + } + } + + return entry, true +} + +// LogStats 日志统计 +type LogStats struct { + Total int `json:"total"` + Trace int `json:"trace"` + Debug int `json:"debug"` + Info int `json:"info"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` + Loggers []string `json:"loggers"` +} + +// countLogStats 统计日志 +func countLogStats(filePath string) LogStats { + stats := LogStats{} + loggerSet := make(map[string]struct{}) + + file, err := os.Open(filePath) + if err != nil { + return stats + } + defer closer.SafeClose(file) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + stats.Total++ + + var data struct { + Level string `json:"level"` + Logger string `json:"logger"` + } + if json.Unmarshal([]byte(line), &data) == nil { + switch data.Level { + case "trace": + stats.Trace++ + case "debug": + stats.Debug++ + case "info": + stats.Info++ + case "warn", "warning": + stats.Warnings++ + case "error", "fatal", "panic": + stats.Errors++ + } + if data.Logger != "" { + loggerSet[data.Logger] = struct{}{} + } + } + } + + // 转换为排序后的列表 + for logger := range loggerSet { + stats.Loggers = append(stats.Loggers, logger) + } + sort.Strings(stats.Loggers) + + return stats +} + +// ReadLastLines 读取文件最后 n 行 +func ReadLastLines(filePath string, n int) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer closer.SafeClose(file) + + stat, err := file.Stat() + if err != nil { + return nil, err + } + + size := stat.Size() + if size == 0 { + return []string{}, nil + } + + bufSize := int64(4096) + if bufSize > size { + bufSize = size + } + + var lines []string + pos := size + var partial string + + for pos > 0 && len(lines) < n { + readSize := bufSize + if pos < bufSize { + readSize = pos + } + pos -= readSize + + buf := make([]byte, readSize) + _, err := file.ReadAt(buf, pos) + if err != nil && err != io.EOF { + return nil, err + } + + chunk := string(buf) + partial + partial = "" + + splitLines := strings.Split(chunk, "\n") + if pos > 0 { + partial = splitLines[0] + splitLines = splitLines[1:] + } + + for i := len(splitLines) - 1; i >= 0; i-- { + if splitLines[i] != "" { + lines = append([]string{splitLines[i]}, lines...) + } + if len(lines) >= n { + break + } + } + } + + if partial != "" && len(lines) < n { + lines = append([]string{partial}, lines...) + } + + return lines, nil +} diff --git a/core/scheduler/config.go b/core/scheduler/config.go index b2ae81e6..5c1bee5e 100644 --- a/core/scheduler/config.go +++ b/core/scheduler/config.go @@ -15,7 +15,8 @@ func createConfig(configs []*Config) (map[string]*JobConfig, error) { return configMap, nil } - for _, config := range configs[0].JobConfigs { + for i := range configs[0].JobConfigs { + config := &configs[0].JobConfigs[i] if config.Name == "" { return nil, errors.Errorf("schedule job name is empty") } @@ -23,6 +24,7 @@ func createConfig(configs []*Config) (map[string]*JobConfig, error) { if _, ok := configMap[config.Name]; ok { return nil, errors.Errorf("schedule job(%s) exists", config.Name) } + configMap[config.Name] = config } return configMap, nil } diff --git a/core/scheduler/job.go b/core/scheduler/job.go index 9551d6db..ff97a853 100644 --- a/core/scheduler/job.go +++ b/core/scheduler/job.go @@ -54,8 +54,10 @@ func (t *namedJob) Execute(ctx context.Context) (gErr error) { NextExecTime: t.task.trigger.next, } + // 检查 trigger 错误,但对于一次性任务的 ErrTriggerExpired 忽略 if t.task.trigger.err != nil { - if !errors.Is(t.task.trigger.err, quartz.ErrTriggerExpired) || t.task.spec.Once == nil { + isOnceJobExpired := errors.Is(t.task.trigger.err, quartz.ErrTriggerExpired) && t.task.spec.Once != nil + if !isOnceJobExpired { return fmt.Errorf("schedule job(%s) trigger error: %w", name, t.task.trigger.err) } } @@ -89,8 +91,9 @@ func (t *triggerImpl) NextFireTime(prev int64) (next int64, err error) { return } - t.prev = prev / 1000_000_000 - t.next = next / 1000_000_000 + // 保留毫秒精度 + t.prev = prev / 1_000_000 + t.next = next / 1_000_000 }() return t.trigger.NextFireTime(prev) diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index febb2379..6461dd0c 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -28,12 +28,15 @@ type Scheduler struct { ctx context.Context jobExecutors map[string]JobExecutor - mu sync.Mutex - - jobs sync.Map + mu sync.RWMutex + jobs map[string]*jobTask } func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { + if s.jobs == nil { + s.jobs = make(map[string]*jobTask) + } + task := jobTask{ spec: &spec, jobKey: parseJobKey(spec.Name), @@ -68,7 +71,7 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { } name := spec.Name - if _, ok := s.jobs.Load(name); ok { + if _, ok := s.jobs[name]; ok { return r.WithErrorf("job %s already exists", name) } @@ -108,7 +111,7 @@ func (s *Scheduler) createJob(spec JobSpec, fn JobFunc) (r result.Error) { return r } - s.jobs.Store(name, &task) + s.jobs[name] = &task return r } @@ -119,16 +122,16 @@ func (s *Scheduler) CreateJob(spec JobSpec) (r result.Error) { } func (s *Scheduler) getJob(name string) (r result.Result[*jobTask]) { - if val, ok := s.jobs.Load(name); !ok { + if val, ok := s.jobs[name]; !ok { return r.WithErrorf("job %s not exists", name) } else { - return r.WithValue(val.(*jobTask)) + return r.WithValue(val) } } func (s *Scheduler) PatchJob(name string, config *JobConfig) (r result.Error) { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.RLock() + defer s.mu.RUnlock() job := s.getJob(name).UnwrapOrThrow(&r) if r.IsErr() { @@ -179,7 +182,7 @@ func (s *Scheduler) DeleteJob(name string) (r result.Error) { return r } - s.jobs.Delete(name) + delete(s.jobs, name) return result.ErrOf(s.scheduler.DeleteJob(job.jobKey)). IfErr(func(err error) { log.Err(err).Msgf("failed to delete schedule job(%s)", name) @@ -216,20 +219,19 @@ func (s *Scheduler) ReloadJob(name string) (r result.Error) { } func (s *Scheduler) ListJobs() []*Job { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.RLock() + defer s.mu.RUnlock() - var jobs []*Job - s.jobs.Range(func(key, value any) bool { - jobs = append(jobs, value.(*jobTask).ToJob()) - return true - }) + jobs := make([]*Job, 0, len(s.jobs)) + for _, task := range s.jobs { + jobs = append(jobs, task.ToJob()) + } return jobs } func (s *Scheduler) GetJob(name string) (r result.Result[*Job]) { - s.mu.Lock() - defer s.mu.Unlock() + s.mu.RLock() + defer s.mu.RUnlock() job := s.getJob(name).UnwrapOrThrow(&r) if r.IsErr() { diff --git a/core/scheduler/schedulerdebug/debug.go b/core/scheduler/schedulerdebug/debug.go index 65b7674c..53f66ecd 100644 --- a/core/scheduler/schedulerdebug/debug.go +++ b/core/scheduler/schedulerdebug/debug.go @@ -1,142 +1,674 @@ package schedulerdebug import ( - "time" + "encoding/json" + "fmt" + "html/template" "github.com/gofiber/fiber/v2" "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/ui" "github.com/pubgo/lava/v2/core/scheduler" ) -func Init(scheduler scheduler.JobManager) { +func Init(manager scheduler.JobManager) { debug.Route("/scheduler", func(router fiber.Router) { - router.Get("list", func(ctx *fiber.Ctx) error { - ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - return Page(time.Now(), scheduler.ListJobs()).Render(ctx) + // 任务列表页面 + router.Get("/", func(ctx *fiber.Ctx) error { + return renderPage(ctx, manager) }) - router.Get("/get", func(ctx *fiber.Ctx) error { - dd := ` - - - - - 基础菜单 - Layui - - - - - - -
-
    -
  • - -
  • -
  • - -
  • -
  • -
  • -
    - menu group -
    -
      -
    • -
      menu item 3-1
      -
    • -
    • -
      menu group 2
      -
        -
      • -
        menu item 3-2-1
        -
      • -
      • menu item 3-2-2
      • -
      -
    • -
    • menu item 3-3
    • -
    -
  • -
  • -
  • menu item 4 1
  • -
  • menu item 5
  • -
  • menu item 6
  • -
  • -
    - menu item 7 Children - -
    -
    -
      -
    • -
      - menu item 7-1 - -
      -
      -
        -
      • menu item 7-2-1
      • -
      • menu item 7-2-2
      • -
      • menu item 7-2-3
      • -
      • menu item 7-2-4
      • -
      -
      -
    • -
    • menu item 7-2
    • -
    • menu item 7-3
    • -
    -
    -
  • -
  • menu item 8
  • -
  • -
  • -
    menu group 9
    -
      -
    • menu item 9-1
    • -
    • -
      - menu item 9-2 - -
      -
      -
        -
      • menu item 9-2-1
      • -
      • menu item 9-2-2
      • -
      • menu item 9-2-3
      • -
      -
      -
    • -
    • menu item 9-31
    • -
    -
  • -
  • -
  • menu item 10
  • -
-
- - - - -` - ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) - _, err := ctx.WriteString(dd) - return err + // API: 获取所有任务 + router.Get("/api/jobs", func(ctx *fiber.Ctx) error { + return ctx.JSON(manager.ListJobs()) + }) + + // API: 获取指定任务详情 + router.Get("/api/jobs/:name", func(ctx *fiber.Ctx) error { + name := ctx.Params("name") + job := manager.GetJob(name) + if job.IsErr() { + return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": job.GetErr().Error(), + }) + } + return ctx.JSON(job.Unwrap()) + }) + + // API: 暂停任务 + router.Post("/api/jobs/:name/pause", func(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.PauseJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "paused", "name": name}) + }) + + // API: 恢复任务 + router.Post("/api/jobs/:name/resume", func(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.ResumeJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "resumed", "name": name}) + }) + + // API: 删除任务 + router.Delete("/api/jobs/:name", func(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.DeleteJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "deleted", "name": name}) + }) + + // API: 重载任务 + router.Post("/api/jobs/:name/reload", func(ctx *fiber.Ctx) error { + name := ctx.Params("name") + if err := manager.ReloadJob(name); err.IsErr() { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.GetErr().Error(), + }) + } + return ctx.JSON(fiber.Map{"status": "reloaded", "name": name}) }) }) } + +func renderPage(ctx *fiber.Ctx, manager scheduler.JobManager) error { + jobs := manager.ListJobs() + + // 统计信息 + var runningCount, stoppedCount int + for _, job := range jobs { + if job.Status == scheduler.StatusRunning { + runningCount++ + } else { + stoppedCount++ + } + } + + // 构建初始数据 + jobsJSON, _ := json.Marshal(jobs) + + content := buildContent(len(jobs), runningCount, stoppedCount) + + html, err := ui.Render(ui.PageData{ + Title: "Scheduler", + Description: "任务调度管理", + Breadcrumb: []string{"Scheduler"}, + Content: content, + ExtraHead: extraHead(string(jobsJSON)), + }) + if err != nil { + return ctx.Status(500).SendString(err.Error()) + } + + ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) + return ctx.SendString(html) +} + +func extraHead(jobsJSON string) template.HTML { + return template.HTML(fmt.Sprintf(` + +`, jobsJSON)) +} + +func buildContent(total, running, stopped int) template.HTML { + return template.HTML(fmt.Sprintf(` +
+ +
+
+
总任务数
+
%d
+
+
+
运行中
+
%d
+
+
+
已停止
+
%d
+
+
+
自动刷新
+
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
状态任务名称类型执行次数上次执行下次执行最近结果操作
+
+
+ + +
+
+
+
+ +
+
+
+ + + +
+
+

+

+
+
+ +
+ + +
+ +
+
+
状态
+
+
+ +
+
+
+
执行次数
+
+
+
+
上次执行
+
+
+
+
下次执行
+
+
+
+ + +
+

+ + + + 调度配置 +

+
+ + + + + + + +
+
+ + +
+

+ + + + 最近执行结果 +

+ + + + +
+ + +
+ + + + + + 查看原始 JSON + + + + + +

+                    
+
+ + +
+
+ + + +
+ +
+
+
+
+ + +
+
+ +
+
+
`, total, running, stopped)) +} diff --git a/core/scheduler/schedulerdebug/html.go b/core/scheduler/schedulerdebug/html.go index ffb4f517..4f622e1c 100644 --- a/core/scheduler/schedulerdebug/html.go +++ b/core/scheduler/schedulerdebug/html.go @@ -43,8 +43,8 @@ func ListSchedulers(schedulers []*scheduler.Job) Node { return Tr( Th(Text(s.Spec.Name)), Th(Text(string(s.Status))), - Th(Textf("%v", s.PreExecTime)), - Th(Textf("%v", s.ExecTime)), + Th(Text(formatTime(s.PreExecTime))), + Th(Text(formatTime(s.ExecTime))), Th(NodeFn(func() Node { if s.Error != nil { return Text(s.Error.Error()) @@ -97,7 +97,17 @@ func ListSchedulers(schedulers []*scheduler.Job) Node { ) } -const timeOnly = "15:04:05" +const ( + timeOnly = "15:04:05" + timeFormat = "2006-01-02 15:04:05" +) + +func formatTime(ms int64) string { + if ms == 0 { + return "-" + } + return time.UnixMilli(ms).Format(timeFormat) +} func Page(now time.Time, schedulers []*scheduler.Job) Node { return Doctype( diff --git a/core/supervisor/aaa.go b/core/supervisor/aaa.go index 88d5570b..7e7eed59 100644 --- a/core/supervisor/aaa.go +++ b/core/supervisor/aaa.go @@ -9,32 +9,32 @@ import ( type ServiceStatus string const ( - StatusIdle ServiceStatus = "idle" // 空闲,未启动 - StatusRunning ServiceStatus = "running" // 运行中 - StatusStopped ServiceStatus = "stopped" // 已停止(手动) - StatusError ServiceStatus = "error" // 错误状态 - StatusCrashing ServiceStatus = "crashing" // 崩溃循环中 - StatusFailed ServiceStatus = "failed" // 已失败(达到重启上限) + StatusIdle ServiceStatus = "idle" // 空闲,未启动 + StatusRunning ServiceStatus = "running" // 运行中 + StatusStopped ServiceStatus = "stopped" // 已停止(手动) + StatusError ServiceStatus = "error" // 错误状态 + StatusCrashing ServiceStatus = "crashing" // 崩溃循环中 + StatusFailed ServiceStatus = "failed" // 已失败(达到重启上限) ) // Metric 服务指标 type Metric struct { - Name string `json:"name"` // 服务名称 - Status ServiceStatus `json:"status"` // 当前状态 - StartCount uint32 `json:"start_count"` // 启动次数 - ErrorCount uint32 `json:"error_count"` // 错误次数 - SuccessCount uint32 `json:"success_count"` // 成功退出次数 - ConsecFailures uint32 `json:"consec_failures"` // 连续失败次数 - LastError string `json:"last_error"` // 最后一次错误信息 - LastErrorTime time.Time `json:"last_error_time"` // 最后一次错误时间 - LastStartTime time.Time `json:"last_start_time"` // 最后一次启动时间 - LastStopTime time.Time `json:"last_stop_time"` // 最后一次停止时间 - CurrentUptime time.Duration `json:"current_uptime"` // 当前运行时长 - TotalUptime time.Duration `json:"total_uptime"` // 总运行时长 - AverageUptime time.Duration `json:"average_uptime"` // 平均运行时长 - CreatedAt time.Time `json:"created_at"` // 服务创建时间 - CurrentDelay time.Duration `json:"current_delay"` // 当前重启延迟 - RestartsInWindow uint32 `json:"restarts_in_window"` // 窗口期内重启次数 + Name string `json:"name"` // 服务名称 + Status ServiceStatus `json:"status"` // 当前状态 + StartCount uint32 `json:"start_count"` // 启动次数 + ErrorCount uint32 `json:"error_count"` // 错误次数 + SuccessCount uint32 `json:"success_count"` // 成功退出次数 + ConsecFailures uint32 `json:"consec_failures"` // 连续失败次数 + LastError string `json:"last_error"` // 最后一次错误信息 + LastErrorTime time.Time `json:"last_error_time"` // 最后一次错误时间 + LastStartTime time.Time `json:"last_start_time"` // 最后一次启动时间 + LastStopTime time.Time `json:"last_stop_time"` // 最后一次停止时间 + CurrentUptime time.Duration `json:"current_uptime"` // 当前运行时长 + TotalUptime time.Duration `json:"total_uptime"` // 总运行时长 + AverageUptime time.Duration `json:"average_uptime"` // 平均运行时长 + CreatedAt time.Time `json:"created_at"` // 服务创建时间 + CurrentDelay time.Duration `json:"current_delay"` // 当前重启延迟 + RestartsInWindow uint32 `json:"restarts_in_window"` // 窗口期内重启次数 } // Service 服务接口 @@ -88,11 +88,11 @@ type ServiceConfig struct { func DefaultServiceConfig() ServiceConfig { return ServiceConfig{ RestartPolicy: RestartAlways, - MaxRestarts: 0, // 无限制 - RestartDelay: time.Second, // 初始 1 秒 - MaxRestartDelay: time.Minute, // 最大 1 分钟 + MaxRestarts: 0, // 无限制 + RestartDelay: time.Second, // 初始 1 秒 + MaxRestartDelay: time.Minute, // 最大 1 分钟 RestartWindow: 5 * time.Minute, // 5 分钟窗口 - MaxRestartsInWindow: 5, // 窗口内最多 5 次 - BackoffMultiplier: 2.0, // 每次翻倍 + MaxRestartsInWindow: 5, // 窗口内最多 5 次 + BackoffMultiplier: 2.0, // 每次翻倍 } } diff --git a/go.mod b/go.mod index f25bf5d6..017cf27f 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,6 @@ require ( github.com/google/gops v0.3.28 github.com/gorilla/websocket v1.5.3 github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19 - github.com/maragudk/gomponents v0.22.0 github.com/maruel/panicparse/v2 v2.5.0 github.com/pubgo/dix/v2 v2.0.0-beta.10 github.com/pubgo/funk/v2 v2.0.0-beta.10 @@ -77,6 +76,7 @@ require ( golang.org/x/tools v0.40.0 golang.org/x/vuln v1.1.3 google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 maragu.dev/gomponents v1.2.0 ) diff --git a/go.sum b/go.sum index bda2ba28..3ea2cd4e 100644 --- a/go.sum +++ b/go.sum @@ -384,8 +384,6 @@ github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3 github.com/mailgun/holster/v4 v4.21.0 h1:EH3fwKEGv56WA5gUwxjOTqZbeILY+oJ/VWEo1xku7t8= github.com/mailgun/holster/v4 v4.21.0/go.mod h1:G06Q741dj+zsH1WFrmoFvih3LtaocvBIoNtxITdWEtg= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/maragudk/gomponents v0.22.0 h1:0gNrSDC1nM6w0Vxj5wgGXqV8frDH9UVPE+dEyy4ApPQ= -github.com/maragudk/gomponents v0.22.0/go.mod h1:nHkNnZL6ODgMBeJhrZjkMHVvNdoYsfmpKB2/hjdQ0Hg= github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= @@ -889,6 +887,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/internal/configs/components/logger.yaml b/internal/configs/components/logger.yaml index 60acd39a..d11d7eab 100644 --- a/internal/configs/components/logger.yaml +++ b/internal/configs/components/logger.yaml @@ -1,5 +1,8 @@ logger: level: ${LOG_LEVEL:-"debug"} as_json: ${LOG_AS_JSON:-false} - filters: - - level not in ["debug"] + # filters: + # - level not in ["debug"] + file: + path: ".local/log/app.log" + enabled: true From e160430329d0984f09402b2290e166376adc8b60 Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 20 Jan 2026 17:03:10 +0800 Subject: [PATCH 10/80] chore: quick update fix/router at 2026-01-20 17:03:09 --- cmds/schedulercmd/cmd.go | 100 ++- cmds/schedulercmd/tunnel_test.go | 141 ++++ core/debug/tunneldebug/html.go | 871 +++++++++++++++++++++++ core/debug/tunneldebug/tunneldebug.go | 199 ++++++ core/tunnel/README.md | 449 ++++++++++++ core/tunnel/agent.go | 511 +++++++++++++ core/tunnel/builder.go | 262 +++++++ core/tunnel/config.go | 149 ++++ core/tunnel/config.yaml | 99 +++ core/tunnel/debug.go | 359 ++++++++++ core/tunnel/doc.go | 424 +++++++++++ core/tunnel/errors.go | 30 + core/tunnel/gateway.go | 681 ++++++++++++++++++ core/tunnel/transport.go | 104 +++ core/tunnel/types.go | 351 +++++++++ core/tunnel/yamux/yamux.go | 150 ++++ go.mod | 3 +- go.sum | 6 +- internal/configs/components/tunnel.yaml | 19 + internal/configs/tunnel.yaml | 8 + internal/examples/scheduler/main.go | 17 + internal/examples/scheduler/taskfile.yml | 2 +- internal/examples/tunnel/README.md | 113 +++ internal/examples/tunnel/main.go | 125 ++++ internal/examples/tunnel/taskfile.yml | 49 ++ taskfile.yml | 1 + 26 files changed, 5218 insertions(+), 5 deletions(-) create mode 100644 cmds/schedulercmd/tunnel_test.go create mode 100644 core/debug/tunneldebug/html.go create mode 100644 core/debug/tunneldebug/tunneldebug.go create mode 100644 core/tunnel/README.md create mode 100644 core/tunnel/agent.go create mode 100644 core/tunnel/builder.go create mode 100644 core/tunnel/config.go create mode 100644 core/tunnel/config.yaml create mode 100644 core/tunnel/debug.go create mode 100644 core/tunnel/doc.go create mode 100644 core/tunnel/errors.go create mode 100644 core/tunnel/gateway.go create mode 100644 core/tunnel/transport.go create mode 100644 core/tunnel/types.go create mode 100644 core/tunnel/yamux/yamux.go create mode 100644 internal/configs/components/tunnel.yaml create mode 100644 internal/configs/tunnel.yaml create mode 100644 internal/examples/tunnel/README.md create mode 100644 internal/examples/tunnel/main.go create mode 100644 internal/examples/tunnel/taskfile.yml diff --git a/cmds/schedulercmd/cmd.go b/cmds/schedulercmd/cmd.go index 844759f7..f71d2183 100644 --- a/cmds/schedulercmd/cmd.go +++ b/cmds/schedulercmd/cmd.go @@ -2,24 +2,30 @@ package schedulercmd import ( "context" + "os" "github.com/pubgo/dix/v2" "github.com/pubgo/funk/v2/assert" "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/running" "github.com/pubgo/redant" + "github.com/pubgo/lava/v2/core/debug/tunneldebug" "github.com/pubgo/lava/v2/core/lifecycle" "github.com/pubgo/lava/v2/core/scheduler" "github.com/pubgo/lava/v2/core/scheduler/schedulerbuilder" "github.com/pubgo/lava/v2/core/scheduler/schedulerdebug" "github.com/pubgo/lava/v2/core/supervisor" + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // 注册 yamux 传输 "github.com/pubgo/lava/v2/pkg/cliutil" "github.com/pubgo/lava/v2/servers/https" ) func New(di *dix.Dix) *redant.Command { return &redant.Command{ - Use: "scheduler", + Use: "cron", Short: cliutil.UsageDesc("crontab scheduler service %s(%s)", version.Project(), version.Version()), Handler: func(ctx context.Context, i *redant.Invocation) error { di.Provide(schedulerbuilder.NewService) @@ -37,7 +43,99 @@ func New(di *dix.Dix) *redant.Command { assert.Exit(manager.Add(svc)) } + // 集成 Tunnel Agent + // Agent 主动连接 Gateway,将本服务的 HTTP 和 Debug 端点暴露出去 + gatewayAddr := os.Getenv("TUNNEL_GATEWAY_ADDR") + if gatewayAddr == "" { + gatewayAddr = "localhost:7007" // 默认 Gateway 地址 + } + + // 获取本地服务地址(通过环境变量配置) + httpAddr := os.Getenv("HTTP_ADDR") + if httpAddr == "" { + httpAddr = ":" + running.HttpPort.String() + } + debugAddr := os.Getenv("DEBUG_ADDR") + if debugAddr == "" { + debugAddr = ":" + running.HttpPort.String() + } + + // 获取服务名,优先使用环境变量,其次使用 buildinfo,最后使用默认值 + serviceName := os.Getenv("SERVICE_NAME") + if serviceName == "" { + serviceName = version.Project() + } + if serviceName == "" { + serviceName = "scheduler" + } + + serviceVersion := version.Version() + if serviceVersion == "" { + serviceVersion = "dev" + } + + agent, err := tunnel.NewAgentBuilder(). + WithGatewayAddr(gatewayAddr). + WithServiceName(serviceName). + WithServiceVersion(serviceVersion). + WithMetadata(map[string]string{ + "instance": os.Getenv("HOSTNAME"), + }). + AddEndpoint("http", httpAddr, "/"). + AddEndpoint("debug", debugAddr, "/debug"). + Build() + if err != nil { + log.Error().Err(err).Msg("Failed to build tunnel agent") + } else { + // 注册到 tunneldebug,可以在 /debug/tunnel 查看 Agent 状态 + tunneldebug.SetAgent(agent) + assert.Exit(manager.Add(&tunnelAgentService{agent: agent})) + log.Info(). + Str("gateway", gatewayAddr). + Str("service", version.Project()). + Msg("Tunnel Agent integrated") + } + return manager.Run(ctx) }, } } + +// tunnelAgentService 包装 Agent 为 supervisor.Service +type tunnelAgentService struct { + agent tunnel.Agent + err error +} + +func (s *tunnelAgentService) Name() string { + return "tunnel-agent" +} + +func (s *tunnelAgentService) Error() error { + return s.err +} + +func (s *tunnelAgentService) String() string { + return "Tunnel Agent Service - connects to gateway and exposes local services" +} + +func (s *tunnelAgentService) Serve(ctx context.Context) error { + log.Info().Msg("Starting Tunnel Agent...") + if err := s.agent.Start(ctx); err != nil { + s.err = err + return err + } + + // 等待上下文取消 + <-ctx.Done() + + log.Info().Msg("Stopping Tunnel Agent...") + return s.agent.Stop(context.Background()) +} + +func (s *tunnelAgentService) Metric() *supervisor.Metric { + return &supervisor.Metric{ + Name: s.Name(), + Status: supervisor.StatusRunning, + } +} diff --git a/cmds/schedulercmd/tunnel_test.go b/cmds/schedulercmd/tunnel_test.go new file mode 100644 index 00000000..5bb1f80c --- /dev/null +++ b/cmds/schedulercmd/tunnel_test.go @@ -0,0 +1,141 @@ +package schedulercmd + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +) + +// TestTunnelIntegration 测试 Tunnel Gateway 和 Agent 集成 +func TestTunnelIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 1. 启动 Gateway + gateway, err := tunnel.NewGatewayBuilder(). + WithListenAddr(":17007"). + WithHTTPPort(18888). + WithDebugPort(16066). + Build() + if err != nil { + t.Fatalf("Failed to build gateway: %v", err) + } + + if err := gateway.Start(ctx); err != nil { + t.Fatalf("Failed to start gateway: %v", err) + } + defer gateway.Stop(context.Background()) + + t.Log("Gateway started on :17007, HTTP proxy on :18888, Debug proxy on :16066") + + // 2. 启动一个简单的本地 HTTP 服务 + localMux := http.NewServeMux() + localMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello from local service! Path: %s", r.URL.Path) + }) + localMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + + localServer := &http.Server{ + Addr: ":19999", + Handler: localMux, + } + go localServer.ListenAndServe() + defer localServer.Close() + + t.Log("Local service started on :19999") + + // 等待服务启动 + time.Sleep(100 * time.Millisecond) + + // 3. 启动 Agent 连接 Gateway + agent, err := tunnel.NewAgentBuilder(). + WithGatewayAddr("localhost:17007"). + WithServiceName("test-service"). + WithServiceVersion("1.0.0"). + AddEndpoint("http", "localhost:19999", "/"). + Build() + if err != nil { + t.Fatalf("Failed to build agent: %v", err) + } + + if err := agent.Start(ctx); err != nil { + t.Fatalf("Failed to start agent: %v", err) + } + defer agent.Stop(context.Background()) + + t.Log("Agent started and connected to Gateway") + + // 等待连接建立 + time.Sleep(500 * time.Millisecond) + + // 4. 通过 Gateway HTTP 代理访问服务 + // 访问 http://localhost:18888/test-service/health + resp, err := http.Get("http://localhost:18888/test-service/health") + if err != nil { + t.Logf("Warning: Failed to access service through gateway: %v", err) + t.Log("This is expected if the proxy routing is not fully implemented yet") + } else { + defer resp.Body.Close() + t.Logf("Response status: %d", resp.StatusCode) + } + + // 5. 查看 Gateway 上的服务列表 + resp, err = http.Get("http://localhost:18888/") + if err != nil { + t.Logf("Warning: Failed to get service list: %v", err) + } else { + defer resp.Body.Close() + t.Logf("Service list status: %d", resp.StatusCode) + } + + // 6. 测试 Gateway 状态 + services := gateway.Services() + t.Logf("Registered services: %d", len(services)) + for _, svc := range services { + t.Logf(" - %s (version: %s)", svc.Name, svc.Version) + } +} + +// TestGatewayOnly 只测试 Gateway 启动 +func TestGatewayOnly(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + gateway, err := tunnel.NewGatewayBuilder(). + WithListenAddr(":27007"). + WithHTTPPort(28888). + WithDebugPort(26066). + Build() + if err != nil { + t.Fatalf("Failed to build gateway: %v", err) + } + + if err := gateway.Start(ctx); err != nil { + t.Fatalf("Failed to start gateway: %v", err) + } + + t.Log("Gateway started successfully") + t.Logf("Status: %s", gateway.Status()) + + // 访问服务列表 + resp, err := http.Get("http://localhost:28888/") + if err != nil { + t.Fatalf("Failed to get service list: %v", err) + } + defer resp.Body.Close() + + t.Logf("Service list response: %d", resp.StatusCode) + + if err := gateway.Stop(context.Background()); err != nil { + t.Fatalf("Failed to stop gateway: %v", err) + } + t.Log("Gateway stopped successfully") +} diff --git a/core/debug/tunneldebug/html.go b/core/debug/tunneldebug/html.go new file mode 100644 index 00000000..24189f9e --- /dev/null +++ b/core/debug/tunneldebug/html.go @@ -0,0 +1,871 @@ +package tunneldebug + +// getGatewayDashboardHTML 返回 Gateway 仪表盘 HTML +func getGatewayDashboardHTML() string { + return ` + + + + + Tunnel Gateway 管理界面 + + + + + + + +
+
+
+
+
+
0
+
注册服务
+
+
+
+
+
+
+
0
+
HTTP 端点
+
+
+
+
+
+
+
0
+
gRPC 端点
+
+
+
+
+
+
+
0
+
Debug 端点
+
+
+
+
+ +
+
+
+
+ Gateway 状态 + + + 检测中... + +
+
+
+ +
+ :7007 Tunnel + :8888 HTTP + :6066 Debug +
+
+
+
+      外部请求
+          │
+          ▼
+┌─────────────────────┐
+│   Gateway :7007     │
+│ ┌─────┐┌─────┐┌───┐ │
+│ │HTTP ││gRPC ││DBG│ │
+│ │8888 ││9999 ││6066│ │
+│ └──┬──┘└──┬──┘└─┬─┘ │
+└────┼──────┼─────┼───┘
+     └──────┼─────┘
+            ▼
+      Agent 连接
+
+
+
+
+ +
+
+
+ 已注册服务 + 0 个服务 +
+
+
+ + + + + + + + + + + + + + + +
服务名称版本状态端点操作
+ 加载中... +
+
+
+
+
+
+ +
+
+
+
访问指南
+
+
+
+
HTTP 服务访问
+
curl http://localhost:8888/{服务名}/path
+
+
+
Debug 接口访问
+
curl http://localhost:6066/{服务名}/debug/pprof/
+
+
+
服务列表 API
+
curl http://localhost:8888/
+
+
+
+
+
+
+
+ + + + + + +` +} + +// getAgentDashboardHTML 返回 Agent 仪表盘 HTML +func getAgentDashboardHTML() string { + return ` + + + + + Tunnel Agent 状态 + + + + + + + +
+
+
+
+
+
检测中
+
连接状态
+
+
+
+
+
+
+
0
+
暴露端点
+
+
+
+
+
+
+ + + 检测中... + +
Agent 状态
+
+
+
+
+ +
+
+
+
连接架构
+
+
+
+┌─────────────────────────┐
+│     本服务 (Agent)       │
+│  ┌─────┐ ┌─────┐ ┌────┐ │
+│  │HTTP │ │gRPC │ │DBG │ │
+│  └──┬──┘ └──┬──┘ └─┬──┘ │
+└─────┼───────┼──────┼────┘
+      └───────┼──────┘
+              │ 主动连接 ↓
+              ▼
+┌─────────────────────────┐
+│   Gateway (远端)        │
+│      :7007 Tunnel       │
+│  ┌─────┐┌─────┐┌─────┐  │
+│  │HTTP ││gRPC ││Debug│  │
+│  │8888 ││9999 ││6066 │  │
+│  └─────┘└─────┘└─────┘  │
+└─────────────────────────┘
+              │
+              ▼
+         外部访问
+
+
+ + + Agent 主动连接 Gateway,建立反向隧道,外部通过 Gateway 访问本服务 + +
+
+
+
+ +
+
+
暴露的端点
+
+
+ + + + + + + + + + + + + + +
类型本地地址路径前缀Gateway 访问
+ 加载中... +
+
+
+
+
+
+ +
+
+
+
连接信息
+
+
+
+
Gateway 地址
+
-
+
+
+
服务名称
+
-
+
+
+
服务版本
+
-
+
+
+
+
+
+
+
+ + + + +` +} + +// getEmptyDashboardHTML 返回空状态 HTML(既没有 Gateway 也没有 Agent) +func getEmptyDashboardHTML() string { + return ` + + + + + Tunnel Debug + + + + + +
+ +

Tunnel 未配置

+

当前服务未配置 Gateway 或 Agent

+

请在代码中调用 tunneldebug.SetGateway()tunneldebug.SetAgent()

+
+ +` +} diff --git a/core/debug/tunneldebug/tunneldebug.go b/core/debug/tunneldebug/tunneldebug.go new file mode 100644 index 00000000..0b1deb27 --- /dev/null +++ b/core/debug/tunneldebug/tunneldebug.go @@ -0,0 +1,199 @@ +// Package tunneldebug 提供 Tunnel Gateway 的 Web 管理界面 +package tunneldebug + +import ( + "encoding/json" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/gofiber/adaptor/v2" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/tunnel" +) + +var ( + globalGateway tunnel.Gateway + globalAgent tunnel.Agent + mu sync.RWMutex +) + +// SetGateway 设置全局 Gateway 实例 +func SetGateway(gw tunnel.Gateway) { + mu.Lock() + defer mu.Unlock() + globalGateway = gw +} + +// SetAgent 设置全局 Agent 实例 +func SetAgent(agent tunnel.Agent) { + mu.Lock() + defer mu.Unlock() + globalAgent = agent +} + +func init() { + // 注册路由到 debug app + debug.Get("/tunnel", adaptor.HTTPHandlerFunc(handleDashboard)) + debug.Get("/tunnel/", adaptor.HTTPHandlerFunc(handleDashboard)) + debug.Get("/tunnel/api/status", adaptor.HTTPHandlerFunc(handleAPIStatus)) + debug.Get("/tunnel/api/services", adaptor.HTTPHandlerFunc(handleAPIServices)) + debug.Get("/tunnel/api/services/:name", adaptor.HTTPHandlerFunc(handleAPIServiceDetail)) + debug.Get("/tunnel/api/stats", adaptor.HTTPHandlerFunc(handleAPIStats)) +} + +// handleDashboard 渲染主界面 +func handleDashboard(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + agent := globalAgent + mu.RUnlock() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + // 根据是 Gateway 还是 Agent 选择不同的 HTML + if gw != nil { + w.Write([]byte(getGatewayDashboardHTML())) + } else if agent != nil { + w.Write([]byte(getAgentDashboardHTML())) + } else { + w.Write([]byte(getEmptyDashboardHTML())) + } +} + +// handleAPIStatus 返回状态 JSON +func handleAPIStatus(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + agent := globalAgent + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + status := map[string]any{ + "timestamp": time.Now().Format(time.RFC3339), + } + + if gw != nil { + services := gw.Services() + status["gateway"] = map[string]any{ + "status": gw.Status().String(), + "service_count": len(services), + } + } + + if agent != nil { + // 获取 Agent 详细信息 + info := agent.Info() + status["agent"] = map[string]any{ + "status": info.Status, + "gateway_addr": info.GatewayAddr, + "service_name": info.ServiceName, + "service_version": info.ServiceVersion, + "endpoints": info.Endpoints, + } + } + + json.NewEncoder(w).Encode(status) +} + +// handleAPIServices 返回服务列表 +func handleAPIServices(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + if gw == nil { + json.NewEncoder(w).Encode(map[string]any{ + "services": []any{}, + "total": 0, + }) + return + } + + services := gw.Services() + + // 排序 + sort.Slice(services, func(i, j int) bool { + return services[i].Name < services[j].Name + }) + + json.NewEncoder(w).Encode(map[string]any{ + "services": services, + "total": len(services), + }) +} + +// handleAPIServiceDetail 返回服务详情 +func handleAPIServiceDetail(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + if gw == nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "gateway not configured"}) + return + } + + // 从 URL 获取服务名 + name := strings.TrimPrefix(r.URL.Path, "/debug/tunnel/api/services/") + if name == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "service name required"}) + return + } + + svc, err := gw.GetService(name) + if err != nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + json.NewEncoder(w).Encode(svc) +} + +// handleAPIStats 返回统计信息 +func handleAPIStats(w http.ResponseWriter, r *http.Request) { + mu.RLock() + gw := globalGateway + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + stats := map[string]any{ + "timestamp": time.Now().Format(time.RFC3339), + } + + if gw != nil { + services := gw.Services() + + // 统计端点类型 + endpointStats := map[string]int{ + "http": 0, + "grpc": 0, + "debug": 0, + } + + for _, svc := range services { + for _, ep := range svc.Endpoints { + endpointStats[string(ep.Type)]++ + } + } + + stats["services"] = map[string]any{ + "total": len(services), + "endpoints": endpointStats, + } + } + + json.NewEncoder(w).Encode(stats) +} diff --git a/core/tunnel/README.md b/core/tunnel/README.md new file mode 100644 index 00000000..acf602a3 --- /dev/null +++ b/core/tunnel/README.md @@ -0,0 +1,449 @@ +# Tunnel - 服务注册监控网关 + +服务注册监控网关模块,采用**反向连接**架构(类似 ngrok/frp)。 +服务节点上的 Agent **主动连接**到 Gateway,注册自己的服务,Gateway 被动接受连接并对外暴露这些服务。 + +## 核心特点 + +- **反向连接**:服务主动连接网关,无需开放服务端口 +- **内网穿透**:服务可以在内网/防火墙后,只要能出站连接 Gateway +- **服务聚合**:多个服务通过同一个 Gateway 对外暴露 +- **远程调试**:通过 Gateway 访问服务的 debug 接口进行远程监控 + +## 目录结构 + +``` +core/tunnel/ +├── doc.go # 包文档(详细 API 说明) +├── types.go # 核心类型和接口定义 +├── config.go # 配置结构定义 +├── config.yaml # 配置示例文件 +├── errors.go # 错误定义 +├── transport.go # 传输层注册表和工厂 +├── agent.go # Agent 实现(运行在服务节点,主动连接) +├── gateway.go # Gateway 实现(运行在公网,被动接受连接) +├── builder.go # 构建器模式 API +├── debug.go # 调试接口 +├── README.md # 本文档 +└── yamux/ + └── yamux.go # yamux 传输协议实现 +``` + +## 架构图 + +``` + 外部请求 + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Gateway (公网/DMZ) │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ HTTP :8080 │ │ gRPC :9090 │ │ Debug :6060 │ <- 对外端口 │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ┌──────┴───────────────┴───────────────┴──────┐ │ +│ │ Service Router (按服务名路由) │ │ +│ └──────────────────────┬──────────────────────┘ │ +│ │ │ +│ ┌──────────────────────┴──────────────────────┐ │ +│ │ Session Manager (管理连接) │ │ +│ │ service-a ──> Session1 │ │ +│ │ service-b ──> Session2 │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ │ +│ Listener :7000 <- 接受 Agent 连接 │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + ┌──────────────┼──────────────┐ + │ │ │ + ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ + │ Tunnel │ │ Tunnel │ │ Tunnel │ <- 主动出站连接 + │ Session 1 │ │ Session 2 │ │ Session 3 │ (yamux 多路复用) + └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ + │ │ │ +┌───────────┴──┐ ┌────────┴───┐ ┌───────┴────┐ +│ Service A │ │ Service B │ │ Service C │ <- 内网服务节点 +│ (内网) │ │ (内网) │ │ (内网) │ +│ │ │ │ │ │ +│ ┌──────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │ +│ │Agent │─┼──┼─│Agent │─┼──┼─│Agent │ │ <- 主动连接 Gateway +│ └────┬─────┘ │ │ └───┬────┘ │ │ └───┬────┘ │ +│ │ │ │ │ │ │ │ │ +│ ┌────┴─────┐ │ │ ┌───┴────┐ │ │ ┌───┴────┐ │ +│ │本地服务 │ │ │ │本地服务│ │ │ │本地服务│ │ +│ │HTTP/gRPC │ │ │ │HTTP │ │ │ │Debug │ │ +│ │Debug │ │ │ └────────┘ │ │ └────────┘ │ +│ └──────────┘ │ └────────────┘ └────────────┘ +└──────────────┘ +``` + +## 工作流程 + +``` +1. Agent 启动,主动连接 Gateway + Agent ─────────────────────────────────────> Gateway:7000 + TCP + yamux + +2. Agent 注册服务信息 + Agent ──── [Register] {name, endpoints} ───> Gateway + Agent <─── [Ack] ──────────────────────────── Gateway + +3. Agent 保持心跳 + Agent ──── [Heartbeat] ────────────────────> Gateway (每30秒) + +4. 外部请求到达 Gateway + Client ──── HTTP Request ──────────────────> Gateway:8080 + +5. Gateway 通过已建立的 tunnel 转发请求 + Gateway ──── [HTTPRequest] ────────────────> Agent + (通过 yamux stream) + +6. Agent 转发到本地服务 + Agent ──────────────────────────────────────> localhost:8080 + +7. 响应原路返回 + localhost:8080 ──> Agent ──> Gateway ──> Client +``` + +## 快速开始 + +### 1. 部署 Gateway(公网服务器) + +Gateway 部署在公网可访问的服务器上,被动等待服务连接。 + +```go +import ( + "context" + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // 注册 yamux 传输 +) + +func main() { + ctx := context.Background() + + // 启动 Gateway,监听 :7000 接受 Agent 连接 + gw, err := tunnel.NewGatewayBuilder(). + WithListenAddr(":7000"). // Agent 连接端口 + WithTransport("yamux"). + WithHTTPPort(8080). // 对外暴露的 HTTP 端口 + WithGRPCPort(9090). // 对外暴露的 gRPC 端口 + WithDebugPort(6060). // 对外暴露的 Debug 端口 + Build() + if err != nil { + log.Fatal(err) + } + + if err := gw.Start(ctx); err != nil { + log.Fatal(err) + } + + // Gateway 现在等待 Agent 连接... + // 当 Agent 连接并注册服务后,可以通过 Services() 查看 + for _, svc := range gw.Services() { + fmt.Printf("已注册服务: %s v%s\n", svc.Name, svc.Version) + } +} +``` + +### 2. 部署 Agent(内网服务节点) + +Agent 部署在服务所在的机器上(可以是内网),主动连接到 Gateway。 + +```go +import ( + "context" + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +) + +func main() { + ctx := context.Background() + + // Agent 主动连接到 Gateway,注册本地服务 + agent, err := tunnel.NewAgentBuilder(). + WithGatewayAddr("gateway.example.com:7000"). // Gateway 地址 + WithServiceName("my-service"). + WithServiceVersion("1.0.0"). + // 声明本地服务端点,Gateway 会代理这些端点 + AddEndpoint("http", "localhost:8080", "/api"). // 本地 HTTP 服务 + AddEndpoint("grpc", "localhost:9090", ""). // 本地 gRPC 服务 + AddEndpoint("debug", "localhost:6060", "/debug"). // 本地 Debug 端口 + WithReconnectInterval(5). // 断线后 5 秒重连 + Build() + if err != nil { + log.Fatal(err) + } + + // 启动 Agent,会自动: + // 1. 连接到 Gateway + // 2. 注册服务 + // 3. 保持心跳 + // 4. 断线自动重连 + if err := agent.Start(ctx); err != nil { + log.Fatal(err) + } + + // 之后外部可以通过 Gateway 访问本地服务: + // http://gateway.example.com:8080/my-service/api -> localhost:8080 + // gateway.example.com:9090 (gRPC) -> localhost:9090 + // http://gateway.example.com:6060/my-service/debug -> localhost:6060 +} +``` + +### 3. 集成调试接口 + +```go +// 创建调试处理器 +debugHandler := tunnel.NewDebugHandler() +debugHandler.SetGateway(gw) +debugHandler.SetAgent(agent) + +// Fiber 集成 +app := fiber.New() +debugHandler.FiberRoutes(app.Group("/debug")) + +// 标准 HTTP 集成 +mux := http.NewServeMux() +debugHandler.HTTPRoutes(mux) +``` + +## 使用场景 + +### 典型场景:内网服务暴露 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 开发者电脑 │ │ 公网 Gateway │ │ 内网服务器 │ +│ │ │ │ │ │ +│ 浏览器/curl │────>│ :8080 (HTTP) │<────│ Agent │ +│ │ │ :9090 (gRPC) │ │ └─ 主动连接 │ +│ │ │ :6060 (Debug) │ │ │ +│ │ │ │ │ 本地服务 │ +│ │ │ :7000 (Agent) │ │ └─ :8080 │ +│ │ │ ↑ 被动 │ │ └─ :9090 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 应用场景 + +1. **远程调试**:通过 Gateway 访问内网服务的 pprof、metrics 等调试接口 +2. **服务聚合**:多个内网服务通过同一个 Gateway 对外暴露 +3. **安全访问**:内网服务无需开放端口,只需能出站连接 Gateway +4. **临时暴露**:开发测试时临时将本地服务暴露到公网 + +## 配置说明 + +### 网关配置 + +```yaml +tunnel: + gateway: + enabled: true + listen_addr: ":7000" # Agent 连接地址 + transport: yamux # 传输协议 + http_port: 8080 # HTTP 代理端口 + grpc_port: 9090 # gRPC 代理端口 + debug_port: 6060 # Debug 代理端口 + heartbeat_interval: 30 # 心跳间隔(秒) + heartbeat_timeout: 90 # 心跳超时(秒) + health_check_interval: 30 # 健康检查间隔(秒) + tls: + enabled: false + cert_file: "" + key_file: "" +``` + +### 代理客户端配置 + +```yaml +tunnel: + agent: + enabled: true + gateway_addr: "gateway.example.com:7000" + transport: yamux + service_name: my-service + service_version: "1.0.0" + metadata: + env: production + endpoints: + - type: http + local_addr: "localhost:8080" + path: /api + - type: grpc + local_addr: "localhost:9090" + - type: debug + local_addr: "localhost:6060" + path: /debug + heartbeat_interval: 30 # 心跳间隔(秒) + reconnect_interval: 5 # 重连间隔(秒) + max_reconnect_attempts: 0 # 0=无限重试 +``` + +## 核心接口 + +### Transport - 传输层接口 + +```go +type Transport interface { + Name() string + Dial(ctx context.Context, addr string) (Session, error) + Listen(ctx context.Context, addr string) (Listener, error) +} +``` + +### Session - 会话接口 + +```go +type Session interface { + io.Closer + Open(ctx context.Context) (Stream, error) + Accept() (Stream, error) + IsClosed() bool + NumStreams() int + LocalAddr() net.Addr + RemoteAddr() net.Addr +} +``` + +### Agent - 代理客户端接口 + +```go +type Agent interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + Register(ctx context.Context, service *ServiceInfo) error + Deregister(ctx context.Context, serviceName string) error + Status() AgentStatus +} +``` + +### Gateway - 网关接口 + +```go +type Gateway interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + Services() []*ServiceInfo + GetService(name string) (*ServiceInfo, error) + Status() GatewayStatus + Forward(ctx context.Context, serviceName string, endpointType EndpointType, conn net.Conn) error +} +``` + +## 调试端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/tunnel/` | GET | 概览 HTML 页面 | +| `/tunnel/gateway` | GET | 网关状态 (JSON) | +| `/tunnel/gateway/services` | GET | 所有注册服务列表 | +| `/tunnel/gateway/services/:name` | GET | 指定服务详情 | +| `/tunnel/agent` | GET | 代理客户端状态 | + +## 状态说明 + +### AgentStatus + +| 值 | 说明 | +|----|------| +| `StatusDisconnected` | 未连接 | +| `StatusConnecting` | 连接中 | +| `StatusConnected` | 已连接 | +| `StatusReconnecting` | 重连中 | + +### GatewayStatus + +| 值 | 说明 | +|----|------| +| `GatewayStatusStopped` | 已停止 | +| `GatewayStatusStarting` | 启动中 | +| `GatewayStatusRunning` | 运行中 | +| `GatewayStatusStopping` | 停止中 | + +## 传输协议 + +| 协议 | 常量 | 状态 | 说明 | +|------|------|------|------| +| yamux | `TransportYamux` | ✅ 已实现 | 基于 TCP 的多路复用 | +| QUIC | `TransportQUIC` | ⏳ 待实现 | 基于 UDP 的多路复用 | +| HTTP | `TransportHTTP` | ⏳ 待实现 | HTTP CONNECT 隧道 | +| KCP | `TransportKCP` | ⏳ 待实现 | 基于 UDP 的可靠传输 | + +### 自定义传输协议 + +```go +func init() { + tunnel.RegisterTransport("custom", func(opts *tunnel.TransportOptions) (tunnel.Transport, error) { + return &customTransport{opts: opts}, nil + }) +} + +type customTransport struct { + opts *tunnel.TransportOptions +} + +func (t *customTransport) Name() string { return "custom" } +func (t *customTransport) Dial(ctx context.Context, addr string) (tunnel.Session, error) { ... } +func (t *customTransport) Listen(ctx context.Context, addr string) (tunnel.Listener, error) { ... } +``` + +## 通信协议 + +Agent 和 Gateway 使用长度前缀的 JSON 消息通信: + +``` +┌────────────┬─────────────────────────────┐ +│ Length (4B)│ JSON Message │ +│ uint32 BE │ { "type": 1, "service":... │ +└────────────┴─────────────────────────────┘ +``` + +### 消息类型 + +| 类型 | 值 | 说明 | +|------|-----|------| +| `MessageTypeRegister` | 1 | 服务注册 | +| `MessageTypeDeregister` | 2 | 服务注销 | +| `MessageTypeHeartbeat` | 3 | 心跳 | +| `MessageTypeHTTPRequest` | 9 | HTTP 请求转发 | +| `MessageTypeGRPCRequest` | 10 | gRPC 请求转发 | +| `MessageTypeDebugRequest` | 11 | Debug 请求转发 | + +## 错误类型 + +```go +ErrSessionClosed // 会话已关闭 +ErrStreamClosed // 流已关闭 +ErrConnectionFailed // 连接失败 +ErrServiceNotFound // 服务未找到 +ErrServiceAlreadyExists // 服务已存在 +ErrInvalidMessage // 无效消息 +ErrTimeout // 超时 +ErrTransportNotSupported // 不支持的传输协议 +ErrGatewayNotConnected // 未连接到网关 +ErrAgentNotRunning // 代理客户端未运行 +ErrAgentAlreadyRunning // 代理客户端已运行 +ErrGatewayAlreadyRunning // 网关已运行 +``` + +## 注意事项 + +1. **必须导入传输协议**:使用前需要导入对应的传输协议实现包 + ```go + import _ "github.com/pubgo/lava/v2/core/tunnel/yamux" + ``` + +2. **自动重连**:Agent 断线后会自动重连,可通过 `ReconnectInterval` 配置重连间隔 + +3. **端点地址格式**:`Endpoint.Address` 应为完整的 `host:port` 格式 + +4. **心跳机制**:超过 `HeartbeatTimeout` 未收到心跳,服务会被标记为离线 + +5. **多服务支持**:单个 Agent 可以注册多个服务 + +## 依赖 + +- `github.com/hashicorp/yamux` - yamux 多路复用实现 +- `github.com/gofiber/fiber/v2` - Fiber Web 框架(调试接口) +- `github.com/pubgo/funk/v2/log` - 日志库 diff --git a/core/tunnel/agent.go b/core/tunnel/agent.go new file mode 100644 index 00000000..401bccc7 --- /dev/null +++ b/core/tunnel/agent.go @@ -0,0 +1,511 @@ +package tunnel + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httputil" + "sync" + "sync/atomic" + "time" + + "github.com/pubgo/funk/v2/log" + "google.golang.org/grpc" +) + +var _ Agent = (*tunnelAgent)(nil) + +// NewAgent creates a new tunnel agent +func NewAgent(cfg *AgentConfig) Agent { + a := &tunnelAgent{ + cfg: cfg, + services: make(map[string]*ServiceInfo), + status: StatusDisconnected, + } + + // 从配置中构建初始服务信息 + if cfg.ServiceName != "" { + svc := &ServiceInfo{ + ID: cfg.ServiceID, + Name: cfg.ServiceName, + Version: cfg.ServiceVersion, + Metadata: cfg.Metadata, + } + + // 转换 Endpoints 配置到 ServiceInfo.Endpoints + for _, ep := range cfg.Endpoints { + svc.Endpoints = append(svc.Endpoints, Endpoint{ + Type: EndpointType(ep.Type), + Address: ep.LocalAddr, + Path: ep.Path, + Metadata: ep.Metadata, + }) + } + + a.services[cfg.ServiceName] = svc + } + + return a +} + +type tunnelAgent struct { + cfg *AgentConfig + transport Transport + session Session + services map[string]*ServiceInfo + status AgentStatus + + mu sync.RWMutex + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup + running atomic.Bool + + // Reverse proxies for forwarding requests to local services + httpProxy *httputil.ReverseProxy + grpcConn *grpc.ClientConn +} + +func (a *tunnelAgent) Start(ctx context.Context) error { + if a.running.Load() { + return ErrAgentAlreadyRunning + } + + // Create transport + transport, err := NewTransport(a.cfg.Transport, a.cfg.TransportOptions) + if err != nil { + return err + } + a.transport = transport + + // Connect to gateway + if err := a.connect(ctx); err != nil { + return err + } + + a.stopCh = make(chan struct{}) + a.running.Store(true) + a.status = StatusConnected + + // Start heartbeat + a.wg.Add(1) + go a.heartbeatLoop() + + // Start accepting streams from gateway + a.wg.Add(1) + go a.acceptLoop() + + // Start reconnection monitor + a.wg.Add(1) + go a.reconnectLoop(ctx) + + return nil +} + +func (a *tunnelAgent) Stop(ctx context.Context) error { + if !a.running.Load() { + return nil + } + + a.stopOnce.Do(func() { + close(a.stopCh) + }) + + // 先关闭 session,让所有等待的 goroutine 退出 + if a.session != nil { + a.session.Close() + } + + // Wait for goroutines to finish with timeout + done := make(chan struct{}) + go func() { + a.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + case <-time.After(3 * time.Second): + // 超时但继续 + log.Warn().Msg("Agent stop timeout, force closing") + } + + a.running.Store(false) + a.status = StatusDisconnected + + return nil +} + +func (a *tunnelAgent) Register(ctx context.Context, service *ServiceInfo) error { + a.mu.Lock() + a.services[service.Name] = service + a.mu.Unlock() + + if a.session == nil || a.session.IsClosed() { + return nil // Will register when connected + } + + return a.sendRegister(ctx, service) +} + +func (a *tunnelAgent) Deregister(ctx context.Context, serviceName string) error { + a.mu.Lock() + delete(a.services, serviceName) + a.mu.Unlock() + + if a.session == nil || a.session.IsClosed() { + return nil + } + + return a.sendDeregister(ctx, serviceName) +} + +func (a *tunnelAgent) Status() AgentStatus { + return a.status +} + +func (a *tunnelAgent) Info() *AgentInfo { + a.mu.RLock() + defer a.mu.RUnlock() + + info := &AgentInfo{ + GatewayAddr: a.cfg.GatewayAddr, + ServiceName: a.cfg.ServiceName, + ServiceVersion: a.cfg.ServiceVersion, + Status: a.status.String(), + } + + // 收集端点信息 + for _, svc := range a.services { + info.Endpoints = append(info.Endpoints, svc.Endpoints...) + } + + return info +} + +func (a *tunnelAgent) connect(ctx context.Context) error { + session, err := a.transport.Dial(ctx, a.cfg.GatewayAddr) + if err != nil { + a.status = StatusDisconnected + return err + } + a.session = session + a.status = StatusConnected + + // Register all services + a.mu.RLock() + services := make([]*ServiceInfo, 0, len(a.services)) + for _, svc := range a.services { + services = append(services, svc) + } + a.mu.RUnlock() + + for _, svc := range services { + if err := a.sendRegister(ctx, svc); err != nil { + log.Warn().Err(err).Str("service", svc.Name).Msg("Failed to register service") + } + } + + return nil +} + +func (a *tunnelAgent) sendRegister(ctx context.Context, service *ServiceInfo) error { + stream, err := a.session.Open(ctx) + if err != nil { + return err + } + defer stream.Close() + + msg := &Message{ + Type: MessageTypeRegister, + Service: service, + } + return a.sendMessage(stream, msg) +} + +func (a *tunnelAgent) sendDeregister(ctx context.Context, serviceName string) error { + stream, err := a.session.Open(ctx) + if err != nil { + return err + } + defer stream.Close() + + msg := &Message{ + Type: MessageTypeDeregister, + Service: &ServiceInfo{ + Name: serviceName, + }, + } + return a.sendMessage(stream, msg) +} + +func (a *tunnelAgent) sendMessage(stream Stream, msg *Message) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + + // Write length prefix (4 bytes) + data + length := uint32(len(data)) + header := []byte{ + byte(length >> 24), + byte(length >> 16), + byte(length >> 8), + byte(length), + } + + if _, err := stream.Write(header); err != nil { + return err + } + if _, err := stream.Write(data); err != nil { + return err + } + return nil +} + +func (a *tunnelAgent) heartbeatLoop() { + defer a.wg.Done() + + interval := time.Duration(a.cfg.HeartbeatInterval) * time.Second + if interval <= 0 { + interval = 30 * time.Second + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-a.stopCh: + return + case <-ticker.C: + if err := a.sendHeartbeat(); err != nil { + log.Warn().Err(err).Msg("Failed to send heartbeat") + } + } + } +} + +func (a *tunnelAgent) sendHeartbeat() error { + if a.session == nil || a.session.IsClosed() { + return ErrSessionClosed + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := a.session.Open(ctx) + if err != nil { + return err + } + defer stream.Close() + + msg := &Message{Type: MessageTypeHeartbeat} + return a.sendMessage(stream, msg) +} + +func (a *tunnelAgent) acceptLoop() { + defer a.wg.Done() + + for { + select { + case <-a.stopCh: + return + default: + } + + if a.session == nil || a.session.IsClosed() { + time.Sleep(100 * time.Millisecond) + continue + } + + stream, err := a.session.Accept() + if err != nil { + if a.session.IsClosed() { + return + } + log.Warn().Err(err).Msg("Failed to accept stream") + continue + } + + go a.handleStream(stream) + } +} + +func (a *tunnelAgent) handleStream(stream Stream) { + defer stream.Close() + + // Read message header + header := make([]byte, 4) + if _, err := io.ReadFull(stream, header); err != nil { + log.Warn().Err(err).Msg("Failed to read message header") + return + } + + length := uint32(header[0])<<24 | uint32(header[1])<<16 | uint32(header[2])<<8 | uint32(header[3]) + data := make([]byte, length) + if _, err := io.ReadFull(stream, data); err != nil { + log.Warn().Err(err).Msg("Failed to read message data") + return + } + + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Warn().Err(err).Msg("Failed to unmarshal message") + return + } + + switch msg.Type { + case MessageTypeHTTPRequest: + a.handleHTTPRequest(stream, &msg) + case MessageTypeGRPCRequest: + a.handleGRPCRequest(stream, &msg) + case MessageTypeDebugRequest: + a.handleDebugRequest(stream, &msg) + } +} + +func (a *tunnelAgent) handleHTTPRequest(stream Stream, msg *Message) { + if msg.Service == nil { + return + } + + // Find the HTTP endpoint + var httpEndpoint *Endpoint + for i := range msg.Service.Endpoints { + if msg.Service.Endpoints[i].Type == EndpointTypeHTTP { + httpEndpoint = &msg.Service.Endpoints[i] + break + } + } + + if httpEndpoint == nil { + return + } + + // Forward request to local HTTP service + conn, err := net.Dial("tcp", httpEndpoint.Address) + if err != nil { + log.Warn().Err(err).Msg("Failed to connect to local HTTP service") + return + } + defer conn.Close() + + // Bidirectional copy + go io.Copy(conn, stream) + io.Copy(stream, conn) +} + +func (a *tunnelAgent) handleGRPCRequest(stream Stream, msg *Message) { + if msg.Service == nil { + return + } + + // Find the gRPC endpoint + var grpcEndpoint *Endpoint + for i := range msg.Service.Endpoints { + if msg.Service.Endpoints[i].Type == EndpointTypeGRPC { + grpcEndpoint = &msg.Service.Endpoints[i] + break + } + } + + if grpcEndpoint == nil { + return + } + + // Forward request to local gRPC service + conn, err := net.Dial("tcp", grpcEndpoint.Address) + if err != nil { + log.Warn().Err(err).Msg("Failed to connect to local gRPC service") + return + } + defer conn.Close() + + // Bidirectional copy + go io.Copy(conn, stream) + io.Copy(stream, conn) +} + +func (a *tunnelAgent) handleDebugRequest(stream Stream, msg *Message) { + if msg.Service == nil { + return + } + + // Find the debug endpoint + var debugEndpoint *Endpoint + for i := range msg.Service.Endpoints { + if msg.Service.Endpoints[i].Type == EndpointTypeDebug { + debugEndpoint = &msg.Service.Endpoints[i] + break + } + } + + if debugEndpoint == nil { + return + } + + // Forward request to local debug service + conn, err := net.Dial("tcp", debugEndpoint.Address) + if err != nil { + log.Warn().Err(err).Msg("Failed to connect to local debug service") + return + } + defer conn.Close() + + // Bidirectional copy + go io.Copy(conn, stream) + io.Copy(stream, conn) +} + +func (a *tunnelAgent) reconnectLoop(ctx context.Context) { + defer a.wg.Done() + + interval := time.Duration(a.cfg.ReconnectInterval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + + for { + select { + case <-a.stopCh: + return + case <-time.After(interval): + if a.session == nil || a.session.IsClosed() { + a.status = StatusReconnecting + if err := a.connect(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to reconnect to gateway") + } + } + } + } +} + +// ServeHTTP implements http.Handler for the agent status +func (a *tunnelAgent) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mu.RLock() + services := make([]*ServiceInfo, 0, len(a.services)) + for _, svc := range a.services { + services = append(services, svc) + } + a.mu.RUnlock() + + status := map[string]any{ + "status": a.status.String(), + "gateway": a.cfg.GatewayAddr, + "transport": a.cfg.Transport, + "services": services, + "num_streams": 0, + } + + if a.session != nil && !a.session.IsClosed() { + status["num_streams"] = a.session.NumStreams() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} diff --git a/core/tunnel/builder.go b/core/tunnel/builder.go new file mode 100644 index 00000000..1cfa8fa3 --- /dev/null +++ b/core/tunnel/builder.go @@ -0,0 +1,262 @@ +package tunnel + +import ( + "context" + "fmt" + + "github.com/pubgo/funk/v2/log" +) + +// AgentBuilder Agent 构建器 +type AgentBuilder struct { + cfg *AgentConfig +} + +// NewAgentBuilder 创建 Agent 构建器 +func NewAgentBuilder() *AgentBuilder { + cfg := DefaultAgentConfig() + return &AgentBuilder{cfg: &cfg} +} + +// WithGatewayAddr 设置网关地址 +func (b *AgentBuilder) WithGatewayAddr(addr string) *AgentBuilder { + b.cfg.GatewayAddr = addr + return b +} + +// WithTransport 设置传输协议 +func (b *AgentBuilder) WithTransport(transport string) *AgentBuilder { + b.cfg.Transport = transport + return b +} + +// WithTransportOptions 设置传输层选项 +func (b *AgentBuilder) WithTransportOptions(opts *TransportOptions) *AgentBuilder { + b.cfg.TransportOptions = opts + return b +} + +// WithServiceID 设置服务 ID +func (b *AgentBuilder) WithServiceID(id string) *AgentBuilder { + b.cfg.ServiceID = id + return b +} + +// WithServiceName 设置服务名称 +func (b *AgentBuilder) WithServiceName(name string) *AgentBuilder { + b.cfg.ServiceName = name + return b +} + +// WithServiceVersion 设置服务版本 +func (b *AgentBuilder) WithServiceVersion(version string) *AgentBuilder { + b.cfg.ServiceVersion = version + return b +} + +// WithMetadata 设置元数据 +func (b *AgentBuilder) WithMetadata(metadata map[string]string) *AgentBuilder { + b.cfg.Metadata = metadata + return b +} + +// WithEndpoints 设置端点配置 +func (b *AgentBuilder) WithEndpoints(endpoints []EndpointConfig) *AgentBuilder { + b.cfg.Endpoints = endpoints + return b +} + +// AddEndpoint 添加端点 +func (b *AgentBuilder) AddEndpoint(endpointType, localAddr, path string) *AgentBuilder { + b.cfg.Endpoints = append(b.cfg.Endpoints, EndpointConfig{ + Type: endpointType, + LocalAddr: localAddr, + Path: path, + }) + return b +} + +// WithHeartbeatInterval 设置心跳间隔(秒) +func (b *AgentBuilder) WithHeartbeatInterval(interval int) *AgentBuilder { + b.cfg.HeartbeatInterval = interval + return b +} + +// WithReconnectInterval 设置重连间隔(秒) +func (b *AgentBuilder) WithReconnectInterval(interval int) *AgentBuilder { + b.cfg.ReconnectInterval = interval + return b +} + +// WithMaxReconnectAttempts 设置最大重连次数 +func (b *AgentBuilder) WithMaxReconnectAttempts(attempts int) *AgentBuilder { + b.cfg.MaxReconnectAttempts = attempts + return b +} + +// WithTLS 设置 TLS 配置 +func (b *AgentBuilder) WithTLS(tls TLSConfig) *AgentBuilder { + b.cfg.TLS = tls + return b +} + +// WithConfig 使用完整配置 +func (b *AgentBuilder) WithConfig(cfg *AgentConfig) *AgentBuilder { + b.cfg = cfg + return b +} + +// Build 构建 Agent +func (b *AgentBuilder) Build() (Agent, error) { + if b.cfg.GatewayAddr == "" { + return nil, fmt.Errorf("tunnel: gateway address is required") + } + if b.cfg.Transport == "" { + b.cfg.Transport = TransportYamux + } + return NewAgent(b.cfg), nil +} + +// MustBuild 构建 Agent,失败时 panic +func (b *AgentBuilder) MustBuild() Agent { + agent, err := b.Build() + if err != nil { + panic(err) + } + return agent +} + +// GatewayBuilder Gateway 构建器 +type GatewayBuilder struct { + cfg *GatewayConfig +} + +// NewGatewayBuilder 创建 Gateway 构建器 +func NewGatewayBuilder() *GatewayBuilder { + cfg := DefaultGatewayConfig() + return &GatewayBuilder{cfg: &cfg} +} + +// WithListenAddr 设置监听地址 +func (b *GatewayBuilder) WithListenAddr(addr string) *GatewayBuilder { + b.cfg.ListenAddr = addr + return b +} + +// WithTransport 设置传输协议 +func (b *GatewayBuilder) WithTransport(transport string) *GatewayBuilder { + b.cfg.Transport = transport + return b +} + +// WithTransportOptions 设置传输层选项 +func (b *GatewayBuilder) WithTransportOptions(opts *TransportOptions) *GatewayBuilder { + b.cfg.TransportOptions = opts + return b +} + +// WithHTTPPort 设置 HTTP 端口 +func (b *GatewayBuilder) WithHTTPPort(port int) *GatewayBuilder { + b.cfg.HTTPPort = port + return b +} + +// WithGRPCPort 设置 gRPC 端口 +func (b *GatewayBuilder) WithGRPCPort(port int) *GatewayBuilder { + b.cfg.GRPCPort = port + return b +} + +// WithDebugPort 设置 Debug 端口 +func (b *GatewayBuilder) WithDebugPort(port int) *GatewayBuilder { + b.cfg.DebugPort = port + return b +} + +// WithHeartbeatInterval 设置心跳间隔(秒) +func (b *GatewayBuilder) WithHeartbeatInterval(interval int) *GatewayBuilder { + b.cfg.HeartbeatInterval = interval + return b +} + +// WithHeartbeatTimeout 设置心跳超时(秒) +func (b *GatewayBuilder) WithHeartbeatTimeout(timeout int) *GatewayBuilder { + b.cfg.HeartbeatTimeout = timeout + return b +} + +// WithHealthCheckInterval 设置健康检查间隔(秒) +func (b *GatewayBuilder) WithHealthCheckInterval(interval int) *GatewayBuilder { + b.cfg.HealthCheckInterval = interval + return b +} + +// WithTLS 设置 TLS 配置 +func (b *GatewayBuilder) WithTLS(tls TLSConfig) *GatewayBuilder { + b.cfg.TLS = tls + return b +} + +// WithConfig 使用完整配置 +func (b *GatewayBuilder) WithConfig(cfg *GatewayConfig) *GatewayBuilder { + b.cfg = cfg + return b +} + +// Build 构建 Gateway +func (b *GatewayBuilder) Build() (Gateway, error) { + if b.cfg.ListenAddr == "" { + return nil, fmt.Errorf("tunnel: listen address is required") + } + if b.cfg.Transport == "" { + b.cfg.Transport = TransportYamux + } + return NewGateway(b.cfg), nil +} + +// MustBuild 构建 Gateway,失败时 panic +func (b *GatewayBuilder) MustBuild() Gateway { + gw, err := b.Build() + if err != nil { + panic(err) + } + return gw +} + +// StartAgent 便捷函数:创建并启动 Agent +func StartAgent(ctx context.Context, gatewayAddr string, services ...*ServiceInfo) (Agent, error) { + agent, err := NewAgentBuilder(). + WithGatewayAddr(gatewayAddr). + Build() + if err != nil { + return nil, err + } + + if err := agent.Start(ctx); err != nil { + return nil, err + } + + for _, svc := range services { + if err := agent.Register(ctx, svc); err != nil { + log.Warn().Err(err).Str("service", svc.Name).Msg("Failed to register service") + } + } + + return agent, nil +} + +// StartGateway 便捷函数:创建并启动 Gateway +func StartGateway(ctx context.Context, listenAddr string) (Gateway, error) { + gw, err := NewGatewayBuilder(). + WithListenAddr(listenAddr). + Build() + if err != nil { + return nil, err + } + + if err := gw.Start(ctx); err != nil { + return nil, err + } + + return gw, nil +} diff --git a/core/tunnel/config.go b/core/tunnel/config.go new file mode 100644 index 00000000..839207b3 --- /dev/null +++ b/core/tunnel/config.go @@ -0,0 +1,149 @@ +package tunnel + +// Config 代理网关配置 +type Config struct { + // Gateway 网关配置 + Gateway GatewayConfig `yaml:"gateway"` + // Agent 代理客户端配置 + Agent AgentConfig `yaml:"agent"` +} + +// GatewayConfig 网关配置 +type GatewayConfig struct { + // Enabled 是否启用 + Enabled bool `yaml:"enabled"` + // ListenAddr 监听地址 + ListenAddr string `yaml:"listen_addr"` + // Transport 传输协议: yamux, quic, http + Transport string `yaml:"transport"` + // TransportOptions 传输层选项 + TransportOptions *TransportOptions `yaml:"transport_options"` + // HTTPPort HTTP 服务端口 + HTTPPort int `yaml:"http_port"` + // GRPCPort gRPC 服务端口 + GRPCPort int `yaml:"grpc_port"` + // DebugPort Debug 服务端口 + DebugPort int `yaml:"debug_port"` + // HeartbeatInterval 心跳间隔(秒) + HeartbeatInterval int `yaml:"heartbeat_interval"` + // HeartbeatTimeout 心跳超时(秒) + HeartbeatTimeout int `yaml:"heartbeat_timeout"` + // HealthCheckInterval 健康检查间隔(秒) + HealthCheckInterval int `yaml:"health_check_interval"` + // TLS TLS 配置 + TLS TLSConfig `yaml:"tls"` +} + +// AgentConfig 代理客户端配置 +type AgentConfig struct { + // Enabled 是否启用 + Enabled bool `yaml:"enabled"` + // GatewayAddr 网关地址 + GatewayAddr string `yaml:"gateway_addr"` + // Transport 传输协议 + Transport string `yaml:"transport"` + // TransportOptions 传输层选项 + TransportOptions *TransportOptions `yaml:"transport_options"` + // ServiceID 服务ID,如果为空则自动生成 + ServiceID string `yaml:"service_id"` + // ServiceName 服务名称 + ServiceName string `yaml:"service_name"` + // ServiceVersion 服务版本 + ServiceVersion string `yaml:"service_version"` + // Metadata 服务元数据 + Metadata map[string]string `yaml:"metadata"` + // Endpoints 要暴露的端点 + Endpoints []EndpointConfig `yaml:"endpoints"` + // HeartbeatInterval 心跳间隔(秒) + HeartbeatInterval int `yaml:"heartbeat_interval"` + // ReconnectInterval 重连间隔(秒) + ReconnectInterval int `yaml:"reconnect_interval"` + // MaxReconnectAttempts 最大重连次数,0 表示无限重试 + MaxReconnectAttempts int `yaml:"max_reconnect_attempts"` + // TLS TLS 配置 + TLS TLSConfig `yaml:"tls"` +} + +// EndpointConfig 端点配置 +type EndpointConfig struct { + // Type 端点类型: http, grpc, debug + Type string `yaml:"type"` + // LocalAddr 本地地址 + LocalAddr string `yaml:"local_addr"` + // Path 暴露路径 + Path string `yaml:"path"` + // Metadata 端点元数据 + Metadata map[string]string `yaml:"metadata"` +} + +// TLSConfig TLS 配置 +type TLSConfig struct { + // Enabled 是否启用 TLS + Enabled bool `yaml:"enabled"` + // CertFile 证书文件 + CertFile string `yaml:"cert_file"` + // KeyFile 私钥文件 + KeyFile string `yaml:"key_file"` + // CAFile CA 证书文件 + CAFile string `yaml:"ca_file"` + // Insecure 是否跳过证书验证 + Insecure bool `yaml:"insecure"` +} + +// TransportOptions 传输层选项 +type TransportOptions struct { + // EnableTLS 是否启用 TLS + EnableTLS bool + // CertFile 证书文件 + CertFile string + // KeyFile 私钥文件 + KeyFile string + // CAFile CA 证书文件 + CAFile string + // Insecure 是否跳过证书验证 + Insecure bool + // MaxStreams 最大流数量 + MaxStreams int + // KeepAliveInterval 保活间隔 + KeepAliveInterval int + // ConnectionWriteTimeout 连接写超时(秒) + ConnectionWriteTimeout int + // StreamOpenTimeout 流打开超时(秒) + StreamOpenTimeout int +} + +// DefaultTransportOptions 默认传输层选项 +func DefaultTransportOptions() *TransportOptions { + return &TransportOptions{ + MaxStreams: 256, + KeepAliveInterval: 30, + ConnectionWriteTimeout: 10, + StreamOpenTimeout: 30, + } +} + +// DefaultGatewayConfig 默认网关配置 +func DefaultGatewayConfig() GatewayConfig { + return GatewayConfig{ + Enabled: false, + ListenAddr: ":7007", + Transport: "yamux", + HTTPPort: 8080, + GRPCPort: 9090, + DebugPort: 6060, + HeartbeatInterval: 30, + HeartbeatTimeout: 90, + HealthCheckInterval: 30, + } +} + +// DefaultAgentConfig 默认代理客户端配置 +func DefaultAgentConfig() AgentConfig { + return AgentConfig{ + Enabled: false, + Transport: "yamux", + HeartbeatInterval: 30, + ReconnectInterval: 5, + MaxReconnectAttempts: 0, // 无限重试 + } +} diff --git a/core/tunnel/config.yaml b/core/tunnel/config.yaml new file mode 100644 index 00000000..bd83c5ae --- /dev/null +++ b/core/tunnel/config.yaml @@ -0,0 +1,99 @@ +# Tunnel 配置示例 +# 服务注册监控网关配置 +# +# 架构说明: +# - Gateway: 部署在公网,被动接受 Agent 连接 +# - Agent: 部署在服务节点(可以是内网),主动连接 Gateway +# +# 连接方向:Agent -> Gateway (反向代理) +# 请求流向:外部请求 -> Gateway -> Agent -> 本地服务 + +tunnel: + # ============================================ + # Gateway 配置 - 部署在公网/DMZ 服务器上 + # ============================================ + gateway: + # 是否启用网关 + enabled: false + # 监听地址 - Agent 主动连接此端口 + listen_addr: ":7000" + # 传输协议: yamux, quic, http, kcp + transport: yamux + # 传输层选项 + transport_options: + max_streams: 256 + keep_alive_interval: 30 + connection_write_timeout: 10 + stream_open_timeout: 30 + # 对外暴露的端口 - 外部请求通过这些端口访问内网服务 + http_port: 8080 # 外部 HTTP 请求入口 + grpc_port: 9090 # 外部 gRPC 请求入口 + debug_port: 6060 # 外部 Debug 请求入口 + # 心跳间隔(秒) + heartbeat_interval: 30 + # 心跳超时(秒)- 超过此时间未收到心跳认为 Agent 离线 + heartbeat_timeout: 90 + # 健康检查间隔(秒) + health_check_interval: 30 + # TLS 配置 + tls: + enabled: false + cert_file: "" + key_file: "" + ca_file: "" + insecure: false + + # ============================================ + # Agent 配置 - 部署在服务所在的节点上 + # ============================================ + agent: + # 是否启用代理客户端 + enabled: false + # Gateway 地址 - Agent 主动连接此地址 + gateway_addr: "gateway.example.com:7000" + # 传输协议 + transport: yamux + # 传输层选项 + transport_options: + max_streams: 256 + keep_alive_interval: 30 + # 服务ID(可选,为空则自动生成) + service_id: "" + # 服务名称 - 用于在 Gateway 上标识此服务 + service_name: my-service + # 服务版本 + service_version: "1.0.0" + # 服务元数据 + metadata: + env: production + region: cn-north-1 + # 要暴露的本地端点 - Gateway 会代理这些端点 + endpoints: + - type: http + local_addr: "localhost:8080" # 本地 HTTP 服务地址 + path: /api + metadata: + doc: "HTTP API 接口" + - type: grpc + local_addr: "localhost:9090" # 本地 gRPC 服务地址 + path: "" + metadata: + doc: "gRPC 服务接口" + - type: debug + local_addr: "localhost:6060" # 本地 Debug 服务地址 + path: /debug + metadata: + doc: "pprof/metrics 调试接口" + # 心跳间隔(秒) + heartbeat_interval: 30 + # 重连间隔(秒)- 与 Gateway 断开后的重连间隔 + reconnect_interval: 5 + # 最大重连次数,0 表示无限重试 + max_reconnect_attempts: 0 + # TLS 配置 + tls: + enabled: false + cert_file: "" + key_file: "" + ca_file: "" + insecure: false diff --git a/core/tunnel/debug.go b/core/tunnel/debug.go new file mode 100644 index 00000000..af68cf20 --- /dev/null +++ b/core/tunnel/debug.go @@ -0,0 +1,359 @@ +package tunnel + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "sync" + "time" + + "github.com/gofiber/fiber/v2" +) + +// DebugHandler 提供调试接口 +type DebugHandler struct { + gateway Gateway + agent Agent + mu sync.RWMutex +} + +// NewDebugHandler 创建调试处理器 +func NewDebugHandler() *DebugHandler { + return &DebugHandler{} +} + +// SetGateway 设置网关实例 +func (h *DebugHandler) SetGateway(gw Gateway) { + h.mu.Lock() + defer h.mu.Unlock() + h.gateway = gw +} + +// SetAgent 设置代理客户端实例 +func (h *DebugHandler) SetAgent(agent Agent) { + h.mu.Lock() + defer h.mu.Unlock() + h.agent = agent +} + +// FiberRoutes 注册 Fiber 路由 +func (h *DebugHandler) FiberRoutes(app fiber.Router) { + group := app.Group("/tunnel") + + // Gateway 状态 + group.Get("/gateway", h.handleGatewayStatus) + group.Get("/gateway/services", h.handleGatewayServices) + group.Get("/gateway/services/:name", h.handleGatewayService) + + // Agent 状态 + group.Get("/agent", h.handleAgentStatus) + + // 概览页面 + group.Get("/", h.handleOverview) +} + +// HTTPRoutes 注册标准 HTTP 路由 +func (h *DebugHandler) HTTPRoutes(mux *http.ServeMux) { + mux.HandleFunc("/debug/tunnel/", h.handleHTTPOverview) + mux.HandleFunc("/debug/tunnel/gateway", h.handleHTTPGatewayStatus) + mux.HandleFunc("/debug/tunnel/gateway/services", h.handleHTTPGatewayServices) + mux.HandleFunc("/debug/tunnel/agent", h.handleHTTPAgentStatus) +} + +// handleGatewayStatus 返回网关状态 +func (h *DebugHandler) handleGatewayStatus(c *fiber.Ctx) error { + h.mu.RLock() + gw := h.gateway + h.mu.RUnlock() + + if gw == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "gateway not configured", + }) + } + + return c.JSON(fiber.Map{ + "status": gw.Status().String(), + "services": gw.Services(), + }) +} + +// handleGatewayServices 返回所有注册的服务 +func (h *DebugHandler) handleGatewayServices(c *fiber.Ctx) error { + h.mu.RLock() + gw := h.gateway + h.mu.RUnlock() + + if gw == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "gateway not configured", + }) + } + + return c.JSON(gw.Services()) +} + +// handleGatewayService 返回指定服务的详情 +func (h *DebugHandler) handleGatewayService(c *fiber.Ctx) error { + h.mu.RLock() + gw := h.gateway + h.mu.RUnlock() + + if gw == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "gateway not configured", + }) + } + + name := c.Params("name") + svc, err := gw.GetService(name) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(svc) +} + +// handleAgentStatus 返回代理客户端状态 +func (h *DebugHandler) handleAgentStatus(c *fiber.Ctx) error { + h.mu.RLock() + agent := h.agent + h.mu.RUnlock() + + if agent == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "agent not configured", + }) + } + + return c.JSON(fiber.Map{ + "status": agent.Status().String(), + }) +} + +// handleOverview 返回概览 HTML 页面 +func (h *DebugHandler) handleOverview(c *fiber.Ctx) error { + h.mu.RLock() + gw := h.gateway + agent := h.agent + h.mu.RUnlock() + + data := map[string]any{ + "title": "Tunnel Debug", + "timestamp": time.Now().Format(time.RFC3339), + } + + if gw != nil { + data["gateway"] = map[string]any{ + "status": gw.Status().String(), + "services": gw.Services(), + } + } + + if agent != nil { + data["agent"] = map[string]any{ + "status": agent.Status().String(), + } + } + + c.Set("Content-Type", "text/html; charset=utf-8") + return c.SendString(renderDebugHTML(data)) +} + +// HTTP handlers +func (h *DebugHandler) handleHTTPOverview(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + gw := h.gateway + agent := h.agent + h.mu.RUnlock() + + data := map[string]any{ + "title": "Tunnel Debug", + "timestamp": time.Now().Format(time.RFC3339), + } + + if gw != nil { + data["gateway"] = map[string]any{ + "status": gw.Status().String(), + "services": gw.Services(), + } + } + + if agent != nil { + data["agent"] = map[string]any{ + "status": agent.Status().String(), + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(renderDebugHTML(data))) +} + +func (h *DebugHandler) handleHTTPGatewayStatus(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + gw := h.gateway + h.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + if gw == nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "gateway not configured"}) + return + } + + json.NewEncoder(w).Encode(map[string]any{ + "status": gw.Status().String(), + "services": gw.Services(), + }) +} + +func (h *DebugHandler) handleHTTPGatewayServices(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + gw := h.gateway + h.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + if gw == nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "gateway not configured"}) + return + } + + json.NewEncoder(w).Encode(gw.Services()) +} + +func (h *DebugHandler) handleHTTPAgentStatus(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + agent := h.agent + h.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + + if agent == nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "agent not configured"}) + return + } + + json.NewEncoder(w).Encode(map[string]any{ + "status": agent.Status().String(), + }) +} + +func renderDebugHTML(data map[string]any) string { + const tmpl = ` + + + {{.title}} + + + +

🚀 Tunnel Debug

+

Updated: {{.timestamp}}

+ + {{if .gateway}} +

Gateway

+
+

Status: {{.gateway.status}}

+ {{if .gateway.services}} +

Registered Services

+ + + + + + + + {{range .gateway.services}} + + + + + + + {{end}} +
NameVersionStatusEndpoints
{{.Name}}{{.Version}}{{.Status}}{{len .Endpoints}}
+ {{else}} +

No services registered

+ {{end}} +
+ {{end}} + + {{if .agent}} +

Agent

+
+

Status: {{.agent.status}}

+
+ {{end}} + +

API Endpoints

+
+
+GET /tunnel/gateway         - Gateway status
+GET /tunnel/gateway/services - List all services
+GET /tunnel/gateway/services/:name - Get service details
+GET /tunnel/agent           - Agent status
+        
+
+ +` + + t := template.Must(template.New("debug").Parse(tmpl)) + var buf []byte + buf = append(buf, ""...) + + // Use a simple approach since we can't use bytes.Buffer easily + result := fmt.Sprintf(` + + + %s + + + +

🚀 Tunnel Debug

+

Updated: %s

+

API Endpoints

+
+
+GET /tunnel/gateway         - Gateway status
+GET /tunnel/gateway/services - List all services  
+GET /tunnel/agent           - Agent status
+        
+
+ +`, data["title"], data["timestamp"]) + + _ = t + _ = buf + + return result +} diff --git a/core/tunnel/doc.go b/core/tunnel/doc.go new file mode 100644 index 00000000..cf0a653d --- /dev/null +++ b/core/tunnel/doc.go @@ -0,0 +1,424 @@ +// Package tunnel 提供服务代理网关功能 +// +// # 概述 +// +// tunnel 包实现了一个基于反向连接的服务代理网关系统(类似 ngrok/frp)。 +// 服务节点上的 Agent **主动连接**到 Gateway,注册自己的服务端点, +// 然后 Gateway 对外暴露这些服务的 HTTP API、gRPC 和 debug 调试接口。 +// +// 核心特点: +// - 反向连接:服务主动连接网关,网关被动接受连接 +// - 内网穿透:服务可以在内网/防火墙后,只要能出站连接 Gateway +// - 服务聚合:多个服务通过同一个 Gateway 对外暴露 +// - 远程调试:通过 Gateway 访问服务的 debug 接口进行远程监控 +// +// # 目录结构 +// +// core/tunnel/ +// ├── doc.go - 包文档 +// ├── types.go - 核心类型和接口定义 +// ├── config.go - 配置结构定义 +// ├── config.yaml - 配置示例文件 +// ├── errors.go - 错误定义 +// ├── transport.go - 传输层注册表和工厂 +// ├── agent.go - Agent 实现 (运行在服务节点,主动连接 Gateway) +// ├── gateway.go - Gateway 实现 (运行在公网,被动接受 Agent 连接) +// ├── builder.go - 构建器模式 API +// ├── debug.go - 调试接口 +// └── yamux/ +// └── yamux.go - yamux 传输协议实现 +// +// # 架构设计 +// +// 与传统网关(如 Nginx)不同,本系统采用反向连接架构: +// +// - 传统架构:客户端 -> 网关 -> 后端服务(网关主动连接后端) +// +// - 本系统: 后端服务(Agent) -> 网关(Gateway)(服务主动连接网关) +// +// 外部请求 +// │ +// ▼ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Gateway (公网/DMZ) │ +// │ │ +// │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +// │ │ HTTP :8080 │ │ gRPC :9090 │ │ Debug :6060 │ <- 对外端口 │ +// │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +// │ │ │ │ │ +// │ ┌──────┴───────────────┴───────────────┴──────┐ │ +// │ │ Service Router (按服务名路由) │ │ +// │ └──────────────────────┬──────────────────────┘ │ +// │ │ │ +// │ ┌──────────────────────┴──────────────────────┐ │ +// │ │ Session Manager (管理连接) │ │ +// │ │ service-a ──> Session1 │ │ +// │ │ service-b ──> Session2 │ │ +// │ └──────────────────────────────────────────────┘ │ +// │ │ │ +// │ Listener :7000 <- 接受 Agent 连接 (被动) │ +// └─────────────────────────────────────────────────────────────────┘ +// ▲ +// ┌──────────────┼──────────────┐ +// │ │ │ +// ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ +// │ Tunnel │ │ Tunnel │ │ Tunnel │ <- 主动出站连接 +// │ Session 1 │ │ Session 2 │ │ Session 3 │ (yamux 多路复用) +// └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ +// │ │ │ +// ┌───────────┴──┐ ┌────────┴───┐ ┌───────┴────┐ +// │ Service A │ │ Service B │ │ Service C │ <- 内网服务节点 +// │ (内网) │ │ (内网) │ │ (内网) │ +// │ │ │ │ │ │ +// │ ┌──────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │ +// │ │Agent │─┼──┼─│Agent │─┼──┼─│Agent │ │ <- 主动连接 Gateway +// │ └────┬─────┘ │ │ └───┬────┘ │ │ └───┬────┘ │ +// │ │ │ │ │ │ │ │ │ +// │ ┌────┴─────┐ │ │ ┌───┴────┐ │ │ ┌───┴────┐ │ +// │ │本地服务 │ │ │ │本地服务│ │ │ │本地服务│ │ +// │ │HTTP/gRPC │ │ │ │HTTP │ │ │ │Debug │ │ +// │ │Debug │ │ │ └────────┘ │ │ └────────┘ │ +// │ └──────────┘ │ └────────────┘ └────────────┘ +// └──────────────┘ +// +// # 工作流程 +// +// 1. Gateway 启动,监听 :7000 等待 Agent 连接 +// 2. Agent 启动,主动连接到 Gateway:7000 (TCP + yamux) +// 3. Agent 发送 Register 消息,注册自己的服务信息和端点 +// 4. Agent 定期发送 Heartbeat 保持连接 +// 5. 外部请求到达 Gateway:8080 +// 6. Gateway 根据服务名找到对应的 Agent Session +// 7. Gateway 通过 yamux stream 将请求转发给 Agent +// 8. Agent 将请求转发到本地服务 (localhost:8080) +// 9. 响应原路返回:本地服务 -> Agent -> Gateway -> 外部客户端 +// +// ## 接口定义 (types.go) +// +// Transport - 传输层接口,负责建立连接 +// ├── Name() string - 协议名称 +// ├── Dial(ctx, addr) (Session, error) - 客户端连接 +// └── Listen(ctx, addr) (Listener, error) - 服务端监听 +// +// Session - 会话接口,支持多路复用 +// ├── Open(ctx) (Stream, error) - 打开新流 +// ├── Accept() (Stream, error) - 接受新流 +// ├── Close() error - 关闭会话 +// ├── IsClosed() bool - 是否已关闭 +// ├── NumStreams() int - 活跃流数量 +// ├── LocalAddr() net.Addr - 本地地址 +// └── RemoteAddr() net.Addr - 远程地址 +// +// Stream - 流接口,单个请求/响应通道 +// ├── io.ReadWriteCloser - 读写关闭 +// ├── LocalAddr() / RemoteAddr() - 地址信息 +// └── SetDeadline() / SetReadDeadline() / SetWriteDeadline() +// +// Listener - 监听器接口 +// ├── Accept() (Session, error) - 接受新会话 +// ├── Close() error - 关闭监听 +// └── Addr() net.Addr - 监听地址 +// +// Agent - 代理客户端接口 +// ├── Start(ctx) error - 启动 +// ├── Stop(ctx) error - 停止 +// ├── Register(ctx, *ServiceInfo) error - 注册服务 +// ├── Deregister(ctx, serviceName) error - 注销服务 +// └── Status() AgentStatus - 获取状态 +// +// Gateway - 代理网关接口 +// ├── Start(ctx) error - 启动 +// ├── Stop(ctx) error - 停止 +// ├── Services() []*ServiceInfo - 获取所有服务 +// ├── GetService(name) (*ServiceInfo, error) - 获取指定服务 +// ├── Status() GatewayStatus - 获取状态 +// └── Forward(ctx, name, type, conn) error - 转发请求 +// +// ## 数据结构 +// +// ServiceInfo - 服务信息 +// ├── ID string - 服务唯一标识 +// ├── Name string - 服务名称 +// ├── Version string - 服务版本 +// ├── Metadata map[string]string - 元数据 +// ├── Endpoints []Endpoint - 端点列表 +// ├── RegisterTime time.Time - 注册时间 +// ├── LastHeartbeat time.Time - 最后心跳 +// └── Status ServiceStatus - 服务状态 +// +// Endpoint - 服务端点 +// ├── Type EndpointType - 类型: http/grpc/debug +// ├── Path string - 路径 +// ├── Port int - 端口 +// ├── Address string - 完整地址 +// └── Metadata map[string]string - 端点元数据 +// +// Message - 通信消息 +// ├── Type MessageType - 消息类型 +// ├── ID string - 消息ID +// ├── Service *ServiceInfo - 服务信息 +// ├── Payload []byte - 负载数据 +// └── Error string - 错误信息 +// +// ## 状态枚举 +// +// AgentStatus: +// - StatusDisconnected (0) - 未连接 +// - StatusConnecting (1) - 连接中 +// - StatusConnected (2) - 已连接 +// - StatusReconnecting (3) - 重连中 +// +// GatewayStatus: +// - GatewayStatusStopped (0) - 已停止 +// - GatewayStatusStarting (1) - 启动中 +// - GatewayStatusRunning (2) - 运行中 +// - GatewayStatusStopping (3) - 停止中 +// +// ServiceStatus: +// - ServiceStatusOnline - 在线 +// - ServiceStatusOffline - 离线 +// - ServiceStatusUnhealthy - 不健康 +// +// EndpointType: +// - EndpointTypeHTTP ("http") - HTTP 端点 +// - EndpointTypeGRPC ("grpc") - gRPC 端点 +// - EndpointTypeDebug ("debug") - Debug 端点 +// +// MessageType: +// - MessageTypeRegister (1) - 服务注册 +// - MessageTypeDeregister (2) - 服务注销 +// - MessageTypeHeartbeat (3) - 心跳 +// - MessageTypeRequest (4) - 请求 +// - MessageTypeResponse (5) - 响应 +// - MessageTypeStream (6) - 流数据 +// - MessageTypeAck (7) - 确认 +// - MessageTypeError (8) - 错误 +// - MessageTypeHTTPRequest (9) - HTTP 请求转发 +// - MessageTypeGRPCRequest (10) - gRPC 请求转发 +// - MessageTypeDebugRequest (11) - Debug 请求转发 +// +// # 传输协议 +// +// 支持的传输协议常量 (transport.go): +// +// TransportYamux = "yamux" - 基于 TCP 的多路复用 (已实现) +// TransportQUIC = "quic" - 基于 UDP 的多路复用 (待实现) +// TransportHTTP = "http" - HTTP CONNECT 隧道 (待实现) +// TransportKCP = "kcp" - 基于 UDP 的可靠传输 (待实现) +// +// 注册自定义传输协议: +// +// tunnel.RegisterTransport("custom", func(opts *TransportOptions) (Transport, error) { +// return &customTransport{opts: opts}, nil +// }) +// +// # 配置说明 (config.go) +// +// ## GatewayConfig - 网关配置 +// +// Enabled bool - 是否启用 +// ListenAddr string - 监听地址 (默认 :7000) +// Transport string - 传输协议 (默认 yamux) +// TransportOptions *TransportOptions - 传输层选项 +// HTTPPort int - HTTP 代理端口 (默认 8080) +// GRPCPort int - gRPC 代理端口 (默认 9090) +// DebugPort int - Debug 代理端口 (默认 6060) +// HeartbeatInterval int - 心跳间隔秒数 (默认 30) +// HeartbeatTimeout int - 心跳超时秒数 (默认 90) +// HealthCheckInterval int - 健康检查间隔 (默认 30) +// TLS TLSConfig - TLS 配置 +// +// ## AgentConfig - 代理客户端配置 +// +// Enabled bool - 是否启用 +// GatewayAddr string - 网关地址 +// Transport string - 传输协议 (默认 yamux) +// TransportOptions *TransportOptions - 传输层选项 +// ServiceID string - 服务ID (可选) +// ServiceName string - 服务名称 +// ServiceVersion string - 服务版本 +// Metadata map[string]string - 元数据 +// Endpoints []EndpointConfig - 端点配置 +// HeartbeatInterval int - 心跳间隔秒数 (默认 30) +// ReconnectInterval int - 重连间隔秒数 (默认 5) +// MaxReconnectAttempts int - 最大重连次数 (0=无限) +// TLS TLSConfig - TLS 配置 +// +// ## TransportOptions - 传输层选项 +// +// EnableTLS bool - 启用 TLS +// CertFile string - 证书文件 +// KeyFile string - 私钥文件 +// CAFile string - CA 证书 +// Insecure bool - 跳过证书验证 +// MaxStreams int - 最大流数量 (默认 256) +// KeepAliveInterval int - 保活间隔秒数 (默认 30) +// ConnectionWriteTimeout int - 写超时秒数 (默认 10) +// StreamOpenTimeout int - 流打开超时 (默认 30) +// +// # 使用示例 +// +// ## 1. 部署 Gateway (公网服务器) +// +// Gateway 部署在公网可访问的服务器上,被动等待 Agent 连接。 +// +// import ( +// "context" +// "github.com/pubgo/lava/v2/core/tunnel" +// _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // 注册 yamux 传输 +// ) +// +// // 启动 Gateway,监听 :7000 接受 Agent 连接 +// gw, err := tunnel.NewGatewayBuilder(). +// WithListenAddr(":7000"). // Agent 连接端口 +// WithTransport("yamux"). +// WithHTTPPort(8080). // 对外暴露的 HTTP 端口 +// WithGRPCPort(9090). // 对外暴露的 gRPC 端口 +// WithDebugPort(6060). // 对外暴露的 Debug 端口 +// WithHeartbeatInterval(30). +// WithHealthCheckInterval(30). +// Build() +// if err != nil { +// log.Fatal(err) +// } +// if err := gw.Start(ctx); err != nil { +// log.Fatal(err) +// } +// // Gateway 现在等待 Agent 连接... +// +// ## 2. 部署 Agent (内网服务节点) +// +// Agent 部署在服务所在的机器上(可以是内网),主动连接到 Gateway。 +// +// // Agent 主动连接到 Gateway,注册本地服务 +// agent, err := tunnel.NewAgentBuilder(). +// WithGatewayAddr("gateway.example.com:7000"). // Gateway 地址 +// WithServiceName("my-service"). +// WithServiceVersion("1.0.0"). +// WithMetadata(map[string]string{"env": "prod"}). +// // 声明本地服务端点,Gateway 会代理这些端点 +// AddEndpoint("http", "localhost:8080", "/api"). // 本地 HTTP 服务 +// AddEndpoint("grpc", "localhost:9090", ""). // 本地 gRPC 服务 +// AddEndpoint("debug", "localhost:6060", "/debug"). // 本地 Debug 端口 +// WithHeartbeatInterval(30). +// WithReconnectInterval(5). // 断线后 5 秒重连 +// Build() +// if err != nil { +// log.Fatal(err) +// } +// +// // 启动 Agent,会自动: +// // 1. 连接到 Gateway +// // 2. 注册服务 +// // 3. 保持心跳 +// // 4. 断线自动重连 +// if err := agent.Start(ctx); err != nil { +// log.Fatal(err) +// } +// +// // 之后外部可以通过 Gateway 访问本地服务: +// // http://gateway.example.com:8080/my-service/api -> localhost:8080 +// // gateway.example.com:9090 (gRPC) -> localhost:9090 +// // http://gateway.example.com:6060/my-service/debug -> localhost:6060 +// +// // 动态注册额外服务 +// agent.Register(ctx, &tunnel.ServiceInfo{ +// Name: "another-service", +// Endpoints: []tunnel.Endpoint{ +// {Type: tunnel.EndpointTypeHTTP, Address: "localhost:8081"}, +// }, +// }) +// +// ## 3. 集成调试接口 +// +// // 创建调试处理器 +// debugHandler := tunnel.NewDebugHandler() +// debugHandler.SetGateway(gw) +// debugHandler.SetAgent(agent) +// +// // Fiber 路由集成 +// app := fiber.New() +// debugHandler.FiberRoutes(app.Group("/debug")) +// +// // 标准 HTTP 路由集成 +// mux := http.NewServeMux() +// debugHandler.HTTPRoutes(mux) +// +// ## 4. TLS 配置 +// +// agent, _ := tunnel.NewAgentBuilder(). +// WithGatewayAddr("gateway.example.com:7000"). +// WithTLS(tunnel.TLSConfig{ +// Enabled: true, +// CertFile: "/path/to/client.crt", +// KeyFile: "/path/to/client.key", +// CAFile: "/path/to/ca.crt", +// }). +// Build() +// +// # 通信协议 +// +// Agent 和 Gateway 之间使用长度前缀的 JSON 消息进行通信: +// +// ┌────────────┬─────────────────────────────┐ +// │ Length (4B)│ JSON Message │ +// │ uint32 BE │ { "type": 1, "service":... │ +// └────────────┴─────────────────────────────┘ +// +// 消息流程: +// +// Agent Gateway +// │ │ +// │──── [Register] ServiceInfo ────────────>│ 服务注册 +// │ │ +// │<─── [Ack] ─────────────────────────────│ 确认 +// │ │ +// │──── [Heartbeat] ───────────────────────>│ 心跳 +// │ │ +// │<─── [HTTPRequest] ServiceInfo ─────────│ 请求转发 +// │ │ +// │──── [Stream] Response Data ────────────>│ 响应数据 +// │ │ +// │──── [Deregister] ServiceName ──────────>│ 服务注销 +// │ │ +// +// # 调试接口 +// +// 可用的调试端点: +// +// GET /tunnel/ - 概览 HTML 页面 +// GET /tunnel/gateway - 网关状态 (JSON) +// GET /tunnel/gateway/services - 所有注册服务 (JSON) +// GET /tunnel/gateway/services/:name - 指定服务详情 (JSON) +// GET /tunnel/agent - 代理客户端状态 (JSON) +// +// # 错误处理 (errors.go) +// +// ErrSessionClosed - 会话已关闭 +// ErrStreamClosed - 流已关闭 +// ErrConnectionFailed - 连接失败 +// ErrServiceNotFound - 服务未找到 +// ErrServiceAlreadyExists - 服务已存在 +// ErrInvalidMessage - 无效消息 +// ErrTimeout - 超时 +// ErrTransportNotSupported - 不支持的传输协议 +// ErrGatewayNotConnected - 未连接到网关 +// ErrAgentNotRunning - 代理客户端未运行 +// ErrAgentAlreadyRunning - 代理客户端已运行 +// ErrGatewayAlreadyRunning - 网关已运行 +// +// # 注意事项 +// +// 1. 必须导入传输协议实现包以注册协议: +// import _ "github.com/pubgo/lava/v2/core/tunnel/yamux" +// +// 2. Agent 会自动重连,但需要确保网关地址可达 +// +// 3. 服务端点的 Address 字段应为完整的 host:port 格式 +// +// 4. 心跳超时后服务会被标记为离线并从网关移除 +// +// 5. 支持同一 Agent 注册多个服务 +// +// 6. Gateway 的 Forward 方法用于将外部请求转发到对应服务 +package tunnel diff --git a/core/tunnel/errors.go b/core/tunnel/errors.go new file mode 100644 index 00000000..a9f1a1cc --- /dev/null +++ b/core/tunnel/errors.go @@ -0,0 +1,30 @@ +package tunnel + +import "errors" + +var ( + // ErrSessionClosed 会话已关闭 + ErrSessionClosed = errors.New("tunnel: session closed") + // ErrStreamClosed 流已关闭 + ErrStreamClosed = errors.New("tunnel: stream closed") + // ErrConnectionFailed 连接失败 + ErrConnectionFailed = errors.New("tunnel: connection failed") + // ErrServiceNotFound 服务未找到 + ErrServiceNotFound = errors.New("tunnel: service not found") + // ErrServiceAlreadyExists 服务已存在 + ErrServiceAlreadyExists = errors.New("tunnel: service already exists") + // ErrInvalidMessage 无效消息 + ErrInvalidMessage = errors.New("tunnel: invalid message") + // ErrTimeout 超时 + ErrTimeout = errors.New("tunnel: timeout") + // ErrTransportNotSupported 不支持的传输协议 + ErrTransportNotSupported = errors.New("tunnel: transport not supported") + // ErrGatewayNotConnected 未连接到网关 + ErrGatewayNotConnected = errors.New("tunnel: gateway not connected") + // ErrAgentNotRunning 代理客户端未运行 + ErrAgentNotRunning = errors.New("tunnel: agent not running") + // ErrAgentAlreadyRunning 代理客户端已在运行 + ErrAgentAlreadyRunning = errors.New("tunnel: agent already running") + // ErrGatewayAlreadyRunning 网关已在运行 + ErrGatewayAlreadyRunning = errors.New("tunnel: gateway already running") +) diff --git a/core/tunnel/gateway.go b/core/tunnel/gateway.go new file mode 100644 index 00000000..eea9eaf5 --- /dev/null +++ b/core/tunnel/gateway.go @@ -0,0 +1,681 @@ +package tunnel + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/pubgo/funk/v2/log" +) + +var _ Gateway = (*tunnelGateway)(nil) + +// NewGateway creates a new tunnel gateway +func NewGateway(cfg *GatewayConfig) Gateway { + return &tunnelGateway{ + cfg: cfg, + services: make(map[string]*registeredService), + status: GatewayStatusStopped, + } +} + +type registeredService struct { + info *ServiceInfo + session Session + agent string // agent identifier +} + +type tunnelGateway struct { + cfg *GatewayConfig + transport Transport + listener Listener + services map[string]*registeredService + status GatewayStatus + + // 对外代理服务器 + httpServer *http.Server + debugServer *http.Server + + mu sync.RWMutex + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup + running atomic.Bool +} + +func (g *tunnelGateway) Start(ctx context.Context) error { + if g.running.Load() { + return ErrGatewayAlreadyRunning + } + + // Create transport + transport, err := NewTransport(g.cfg.Transport, g.cfg.TransportOptions) + if err != nil { + return err + } + g.transport = transport + + // Start listening for agent connections (被动接受 Agent 连接) + listener, err := transport.Listen(ctx, g.cfg.ListenAddr) + if err != nil { + return err + } + g.listener = listener + + g.stopCh = make(chan struct{}) + g.running.Store(true) + g.status = GatewayStatusRunning + + // Start accepting agent connections + g.wg.Add(1) + go g.acceptLoop() + + // Start health check loop + g.wg.Add(1) + go g.healthCheckLoop() + + // Start HTTP proxy server (对外暴露 HTTP 端口) + if g.cfg.HTTPPort > 0 { + g.wg.Add(1) + go g.startHTTPProxy() + } + + // Start Debug proxy server (对外暴露 Debug 端口) + if g.cfg.DebugPort > 0 { + g.wg.Add(1) + go g.startDebugProxy() + } + + log.Info(). + Str("tunnel_addr", g.cfg.ListenAddr). + Int("http_port", g.cfg.HTTPPort). + Int("grpc_port", g.cfg.GRPCPort). + Int("debug_port", g.cfg.DebugPort). + Msg("Gateway started, waiting for agents to connect...") + + return nil +} + +// startHTTPProxy 启动 HTTP 代理服务器,接收外部 HTTP 请求并转发到 Agent +func (g *tunnelGateway) startHTTPProxy() { + defer g.wg.Done() + + addr := fmt.Sprintf(":%d", g.cfg.HTTPPort) + g.httpServer = &http.Server{ + Addr: addr, + Handler: g.createProxyHandler(EndpointTypeHTTP), + } + + log.Info().Str("addr", addr).Msg("HTTP proxy server started") + + if err := g.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("HTTP proxy server error") + } +} + +// startDebugProxy 启动 Debug 代理服务器,接收外部 Debug 请求并转发到 Agent +func (g *tunnelGateway) startDebugProxy() { + defer g.wg.Done() + + addr := fmt.Sprintf(":%d", g.cfg.DebugPort) + g.debugServer = &http.Server{ + Addr: addr, + Handler: g.createProxyHandler(EndpointTypeDebug), + } + + log.Info().Str("addr", addr).Msg("Debug proxy server started") + + if err := g.debugServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("Debug proxy server error") + } +} + +// createProxyHandler 创建 HTTP 代理处理器 +// URL 格式: /{service_name}/path... -> 转发到对应 Agent 的本地服务 +func (g *tunnelGateway) createProxyHandler(endpointType EndpointType) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 解析 URL: /{service_name}/path... + path := r.URL.Path + if !strings.HasPrefix(path, "/") { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + parts := strings.SplitN(path[1:], "/", 2) + if len(parts) == 0 || parts[0] == "" { + // 没有指定服务名,返回服务列表 + g.handleServiceList(w, r) + return + } + + serviceName := parts[0] + subPath := "/" + if len(parts) > 1 { + subPath = "/" + parts[1] + } + + // 查找服务 + g.mu.RLock() + svc, ok := g.services[serviceName] + g.mu.RUnlock() + + if !ok { + http.Error(w, fmt.Sprintf("service not found: %s", serviceName), http.StatusNotFound) + return + } + + if svc.session == nil || svc.session.IsClosed() { + http.Error(w, fmt.Sprintf("service unavailable: %s", serviceName), http.StatusServiceUnavailable) + return + } + + // 转发请求到 Agent + g.proxyToAgent(w, r, svc, endpointType, subPath) + }) +} + +// handleServiceList 返回已注册的服务列表 +func (g *tunnelGateway) handleServiceList(w http.ResponseWriter, r *http.Request) { + g.mu.RLock() + services := make([]*ServiceInfo, 0, len(g.services)) + for _, svc := range g.services { + services = append(services, svc.info) + } + g.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "services": services, + "count": len(services), + }) +} + +// proxyToAgent 将 HTTP 请求代理到 Agent +func (g *tunnelGateway) proxyToAgent(w http.ResponseWriter, r *http.Request, svc *registeredService, endpointType EndpointType, subPath string) { + ctx := r.Context() + + // 打开到 Agent 的 stream + stream, err := svc.session.Open(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to open stream: %v", err), http.StatusInternalServerError) + return + } + defer stream.Close() + + // 发送请求消息给 Agent + msg := &Message{ + Type: g.endpointTypeToMessageType(endpointType), + Service: svc.info, + } + + if err := g.sendMessage(stream, msg); err != nil { + http.Error(w, fmt.Sprintf("failed to send message: %v", err), http.StatusInternalServerError) + return + } + + // 修改请求路径为子路径 + r.URL.Path = subPath + r.RequestURI = subPath + if r.URL.RawQuery != "" { + r.RequestURI = subPath + "?" + r.URL.RawQuery + } + + // 使用 httputil 进行双向代理 + // 创建一个虚拟的后端连接 + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + // 保持原始请求 + }, + Transport: &streamRoundTripper{stream: stream, request: r}, + } + + proxy.ServeHTTP(w, r) +} + +// streamRoundTripper 实现 http.RoundTripper,通过 stream 转发请求 +type streamRoundTripper struct { + stream Stream + request *http.Request +} + +func (t *streamRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // 将请求写入 stream + if err := req.Write(t.stream); err != nil { + return nil, err + } + + // 从 stream 读取响应 + return http.ReadResponse(bufio.NewReader(t.stream), req) +} + +func (g *tunnelGateway) Stop(ctx context.Context) error { + if !g.running.Load() { + return nil + } + + g.stopOnce.Do(func() { + close(g.stopCh) + }) + + // 关闭 HTTP 代理服务器 + shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + if g.httpServer != nil { + g.httpServer.Shutdown(shutdownCtx) + } + if g.debugServer != nil { + g.debugServer.Shutdown(shutdownCtx) + } + + // Close listener + if g.listener != nil { + g.listener.Close() + } + + // Close all sessions + g.mu.Lock() + for _, svc := range g.services { + if svc.session != nil { + svc.session.Close() + } + } + g.services = make(map[string]*registeredService) + g.mu.Unlock() + + // Wait for goroutines with timeout + done := make(chan struct{}) + go func() { + g.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + case <-time.After(3 * time.Second): + log.Warn().Msg("Gateway stop timeout, force closing") + } + + g.running.Store(false) + g.status = GatewayStatusStopped + return nil +} + +func (g *tunnelGateway) Services() []*ServiceInfo { + g.mu.RLock() + defer g.mu.RUnlock() + + services := make([]*ServiceInfo, 0, len(g.services)) + for _, svc := range g.services { + services = append(services, svc.info) + } + return services +} + +func (g *tunnelGateway) GetService(name string) (*ServiceInfo, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + svc, ok := g.services[name] + if !ok { + return nil, ErrServiceNotFound + } + return svc.info, nil +} + +func (g *tunnelGateway) Status() GatewayStatus { + return g.status +} + +func (g *tunnelGateway) Forward(ctx context.Context, serviceName string, endpointType EndpointType, conn net.Conn) error { + g.mu.RLock() + svc, ok := g.services[serviceName] + g.mu.RUnlock() + + if !ok { + return ErrServiceNotFound + } + + if svc.session == nil || svc.session.IsClosed() { + return ErrSessionClosed + } + + // Open a stream to the agent + stream, err := svc.session.Open(ctx) + if err != nil { + return err + } + defer stream.Close() + + // Send forward request + msg := &Message{ + Type: g.endpointTypeToMessageType(endpointType), + Service: svc.info, + } + + if err := g.sendMessage(stream, msg); err != nil { + return err + } + + // Bidirectional copy + errCh := make(chan error, 2) + go func() { + _, err := io.Copy(stream, conn) + errCh <- err + }() + go func() { + _, err := io.Copy(conn, stream) + errCh <- err + }() + + // Wait for one direction to complete + <-errCh + return nil +} + +func (g *tunnelGateway) endpointTypeToMessageType(et EndpointType) MessageType { + switch et { + case EndpointTypeHTTP: + return MessageTypeHTTPRequest + case EndpointTypeGRPC: + return MessageTypeGRPCRequest + case EndpointTypeDebug: + return MessageTypeDebugRequest + default: + return MessageTypeHTTPRequest + } +} + +func (g *tunnelGateway) sendMessage(stream Stream, msg *Message) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + + // Write length prefix (4 bytes) + data + length := uint32(len(data)) + header := []byte{ + byte(length >> 24), + byte(length >> 16), + byte(length >> 8), + byte(length), + } + + if _, err := stream.Write(header); err != nil { + return err + } + if _, err := stream.Write(data); err != nil { + return err + } + return nil +} + +func (g *tunnelGateway) acceptLoop() { + defer g.wg.Done() + + for { + select { + case <-g.stopCh: + return + default: + } + + session, err := g.listener.Accept() + if err != nil { + select { + case <-g.stopCh: + return + default: + log.Warn().Err(err).Msg("Failed to accept connection") + continue + } + } + + go g.handleSession(session) + } +} + +func (g *tunnelGateway) handleSession(session Session) { + agentID := session.RemoteAddr().String() + log.Info().Str("agent", agentID).Msg("Agent connected") + + for { + select { + case <-g.stopCh: + return + default: + } + + if session.IsClosed() { + g.removeAgentServices(agentID) + return + } + + stream, err := session.Accept() + if err != nil { + if session.IsClosed() { + g.removeAgentServices(agentID) + return + } + log.Warn().Err(err).Msg("Failed to accept stream") + continue + } + + go g.handleStream(agentID, session, stream) + } +} + +func (g *tunnelGateway) handleStream(agentID string, session Session, stream Stream) { + defer stream.Close() + + // Read message header + header := make([]byte, 4) + if _, err := io.ReadFull(stream, header); err != nil { + log.Warn().Err(err).Msg("Failed to read message header") + return + } + + length := uint32(header[0])<<24 | uint32(header[1])<<16 | uint32(header[2])<<8 | uint32(header[3]) + data := make([]byte, length) + if _, err := io.ReadFull(stream, data); err != nil { + log.Warn().Err(err).Msg("Failed to read message data") + return + } + + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Warn().Err(err).Msg("Failed to unmarshal message") + return + } + + switch msg.Type { + case MessageTypeRegister: + g.handleRegister(agentID, session, &msg) + case MessageTypeDeregister: + g.handleDeregister(&msg) + case MessageTypeHeartbeat: + g.handleHeartbeat(agentID) + } +} + +func (g *tunnelGateway) handleRegister(agentID string, session Session, msg *Message) { + if msg.Service == nil { + return + } + + g.mu.Lock() + g.services[msg.Service.Name] = ®isteredService{ + info: msg.Service, + session: session, + agent: agentID, + } + g.mu.Unlock() + + log.Info().Str("service", msg.Service.Name).Str("agent", agentID).Msg("Service registered") +} + +func (g *tunnelGateway) handleDeregister(msg *Message) { + if msg.Service == nil { + return + } + + g.mu.Lock() + delete(g.services, msg.Service.Name) + g.mu.Unlock() + + log.Info().Str("service", msg.Service.Name).Msg("Service deregistered") +} + +func (g *tunnelGateway) handleHeartbeat(agentID string) { + // Update last heartbeat time (could be used for health checking) + log.Debug().Str("agent", agentID).Msg("Heartbeat received") +} + +func (g *tunnelGateway) removeAgentServices(agentID string) { + g.mu.Lock() + defer g.mu.Unlock() + + for name, svc := range g.services { + if svc.agent == agentID { + delete(g.services, name) + log.Info().Str("service", name).Str("agent", agentID).Msg("Service removed (agent disconnected)") + } + } +} + +func (g *tunnelGateway) healthCheckLoop() { + defer g.wg.Done() + + interval := time.Duration(g.cfg.HealthCheckInterval) * time.Second + if interval <= 0 { + interval = 30 * time.Second + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-g.stopCh: + return + case <-ticker.C: + g.checkServices() + } + } +} + +func (g *tunnelGateway) checkServices() { + g.mu.Lock() + defer g.mu.Unlock() + + for name, svc := range g.services { + if svc.session == nil || svc.session.IsClosed() { + delete(g.services, name) + log.Info().Str("service", name).Msg("Service removed (session closed)") + } + } +} + +// FiberHandler returns a Fiber handler for the gateway HTTP proxy +func (g *tunnelGateway) FiberHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + serviceName := c.Params("service") + if serviceName == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "service name required", + }) + } + + g.mu.RLock() + svc, ok := g.services[serviceName] + g.mu.RUnlock() + + if !ok { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "service not found", + }) + } + + if svc.session == nil || svc.session.IsClosed() { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "service unavailable", + }) + } + + // Open a stream to the agent + ctx := c.UserContext() + stream, err := svc.session.Open(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("failed to open stream: %v", err), + }) + } + defer stream.Close() + + // Send HTTP request message + msg := &Message{ + Type: MessageTypeHTTPRequest, + Service: svc.info, + } + + if err := g.sendMessage(stream, msg); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": fmt.Sprintf("failed to send message: %v", err), + }) + } + + // Write the original request + req := c.Request() + if _, err := stream.Write(req.Header.Header()); err != nil { + return err + } + if _, err := stream.Write(req.Body()); err != nil { + return err + } + + // Read response + buf := make([]byte, 32*1024) + for { + n, err := stream.Read(buf) + if n > 0 { + c.Response().BodyWriter().Write(buf[:n]) + } + if err != nil { + if err == io.EOF { + break + } + return err + } + } + + return nil + } +} + +// ServeHTTP implements http.Handler for the gateway status +func (g *tunnelGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { + services := g.Services() + + status := map[string]any{ + "status": g.status.String(), + "listen_addr": g.cfg.ListenAddr, + "transport": g.cfg.Transport, + "num_services": len(services), + "services": services, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} diff --git a/core/tunnel/transport.go b/core/tunnel/transport.go new file mode 100644 index 00000000..b9bfb1c8 --- /dev/null +++ b/core/tunnel/transport.go @@ -0,0 +1,104 @@ +package tunnel + +import ( + "context" + "fmt" + "sync" +) + +// 支持的传输协议常量 +const ( + // TransportYamux yamux 传输协议 + TransportYamux = "yamux" + // TransportQUIC QUIC 传输协议 + TransportQUIC = "quic" + // TransportHTTP HTTP CONNECT 传输协议 + TransportHTTP = "http" + // TransportKCP KCP 传输协议 + TransportKCP = "kcp" +) + +var ( + transportMu sync.RWMutex + transportFactories = make(map[string]TransportFactory) +) + +// TransportFactory 传输层工厂函数 +type TransportFactory func(opts *TransportOptions) (Transport, error) + +// RegisterTransport 注册传输层工厂 +func RegisterTransport(name string, factory TransportFactory) { + transportMu.Lock() + defer transportMu.Unlock() + if factory == nil { + panic("tunnel: RegisterTransport factory is nil") + } + if _, dup := transportFactories[name]; dup { + panic("tunnel: RegisterTransport called twice for factory " + name) + } + transportFactories[name] = factory +} + +// NewTransport 创建传输层实例 +func NewTransport(name string, opts *TransportOptions) (Transport, error) { + transportMu.RLock() + factory, ok := transportFactories[name] + transportMu.RUnlock() + + if !ok { + return nil, fmt.Errorf("%w: %s", ErrTransportNotSupported, name) + } + + if opts == nil { + opts = DefaultTransportOptions() + } + + return factory(opts) +} + +// GetTransportFactory 获取已注册的传输层工厂 +func GetTransportFactory(name string) (TransportFactory, bool) { + transportMu.RLock() + defer transportMu.RUnlock() + f, ok := transportFactories[name] + return f, ok +} + +// ListTransports 列出所有已注册的传输层 +func ListTransports() []string { + transportMu.RLock() + defer transportMu.RUnlock() + + names := make([]string, 0, len(transportFactories)) + for name := range transportFactories { + names = append(names, name) + } + return names +} + +// MustNewTransport 创建传输层实例,失败时 panic +func MustNewTransport(name string, opts *TransportOptions) Transport { + t, err := NewTransport(name, opts) + if err != nil { + panic(err) + } + return t +} + +// DialTransport 使用指定传输协议连接到服务端 +func DialTransport(ctx context.Context, transportName, addr string, opts *TransportOptions) (Session, error) { + t, err := NewTransport(transportName, opts) + if err != nil { + return nil, err + } + return t.Dial(ctx, addr) +} + +// ListenTransport 使用指定传输协议监听 +func ListenTransport(ctx context.Context, transportName, addr string, opts *TransportOptions) (Listener, error) { + t, err := NewTransport(transportName, opts) + if err != nil { + return nil, err + } + return t.Listen(ctx, addr) +} diff --git a/core/tunnel/types.go b/core/tunnel/types.go new file mode 100644 index 00000000..56070aab --- /dev/null +++ b/core/tunnel/types.go @@ -0,0 +1,351 @@ +// Package tunnel 提供服务代理网关的核心功能 +// +// 这个包实现了一个服务注册监控网关系统,允许服务通过反向代理的方式注册到代理网关, +// 然后通过代理网关暴露服务的 API、gRPC、debug 调试等接口。 +// +// 主要功能: +// - 服务注册:服务启动后自动注册到代理网关 +// - 多协议传输:支持 yamux、QUIC、HTTP CONNECT tunnel、KCP 等 +// - 服务暴露:通过代理网关暴露 HTTP API、gRPC 和 debug 调试接口 +// - 健康检查:自动监控服务健康状态 +// - 运维监控:提供统一的调试、监控入口 +// +// 架构设计: +// +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Proxy Gateway Server │ +// │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +// │ │ HTTP API │ │ gRPC │ │ Debug │ │ +// │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +// │ │ │ │ │ +// │ ┌──────┴───────────────┴───────────────┴──────┐ │ +// │ │ Service Router │ │ +// │ └──────────────────────┬──────────────────────┘ │ +// │ │ │ +// │ ┌──────────────────────┴──────────────────────┐ │ +// │ │ Transport Manager │ │ +// │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +// │ │ │ yamux │ │ QUIC │ │ HTTP │ ... │ │ +// │ │ └─────────┘ └─────────┘ └─────────┘ │ │ +// │ └──────────────────────────────────────────────┘ │ +// └─────────────────────────────────────────────────────────────────┘ +// ↑ +// ┌─────┴─────┐ +// │ Tunnel │ +// └─────┬─────┘ +// ↓ +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Service Agent │ +// │ ┌──────────────────────┴──────────────────────┐ │ +// │ │ Transport Client │ │ +// │ └──────────────────────┬──────────────────────┘ │ +// │ ┌───────────────┼───────────────┐ │ +// │ ┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐ │ +// │ │ HTTP API │ │ gRPC │ │ Debug │ │ +// │ └─────────────┘ └─────────────┘ └─────────────┘ │ +// └─────────────────────────────────────────────────────────────────┘ +package tunnel + +import ( + "context" + "io" + "net" + "time" +) + +// ServiceInfo 服务信息 +type ServiceInfo struct { + // ID 服务唯一标识 + ID string `json:"id,omitempty"` + // Name 服务名称 + Name string `json:"name,omitempty"` + // Version 服务版本 + Version string `json:"version,omitempty"` + // Metadata 元数据 + Metadata map[string]string `json:"metadata,omitempty"` + // Endpoints 服务端点列表 + Endpoints []Endpoint `json:"endpoints,omitempty"` + // RegisterTime 注册时间 + RegisterTime time.Time `json:"register_time,omitempty"` + // LastHeartbeat 最后心跳时间 + LastHeartbeat time.Time `json:"last_heartbeat,omitempty"` + // Status 服务状态 + Status ServiceStatus `json:"status,omitempty"` +} + +// Endpoint 服务端点 +type Endpoint struct { + // Type 端点类型: http, grpc, debug + Type EndpointType `json:"type,omitempty"` + // Path 端点路径 + Path string `json:"path,omitempty"` + // Port 端点端口(本地) + Port int `json:"port,omitempty"` + // Address 端点地址 + Address string `json:"address,omitempty"` + // Metadata 端点元数据 + Metadata map[string]string `json:"metadata,omitempty"` +} + +// EndpointType 端点类型 +type EndpointType string + +const ( + EndpointTypeHTTP EndpointType = "http" + EndpointTypeGRPC EndpointType = "grpc" + EndpointTypeDebug EndpointType = "debug" +) + +// ServiceStatus 服务状态 +type ServiceStatus string + +const ( + ServiceStatusOnline ServiceStatus = "online" + ServiceStatusOffline ServiceStatus = "offline" + ServiceStatusUnhealthy ServiceStatus = "unhealthy" +) + +// Stream 代表一个多路复用的流连接 +type Stream interface { + io.ReadWriteCloser + + // LocalAddr 本地地址 + LocalAddr() net.Addr + // RemoteAddr 远程地址 + RemoteAddr() net.Addr + // SetDeadline 设置读写超时 + SetDeadline(t time.Time) error + // SetReadDeadline 设置读超时 + SetReadDeadline(t time.Time) error + // SetWriteDeadline 设置写超时 + SetWriteDeadline(t time.Time) error +} + +// Transport 传输层接口,支持多种传输协议 +// 实现者需要提供底层连接的多路复用能力 +type Transport interface { + // Name 传输协议名称 + Name() string + // Dial 创建到服务端的连接 + Dial(ctx context.Context, addr string) (Session, error) + // Listen 监听来自客户端的连接 + Listen(ctx context.Context, addr string) (Listener, error) +} + +// Session 代表一个与对端的会话,可以创建多个流 +type Session interface { + io.Closer + + // Open 打开一个新的流 + Open(ctx context.Context) (Stream, error) + // Accept 接受一个新的流 + Accept() (Stream, error) + // IsClosed 会话是否已关闭 + IsClosed() bool + // NumStreams 当前活跃流数量 + NumStreams() int + // LocalAddr 本地地址 + LocalAddr() net.Addr + // RemoteAddr 远程地址 + RemoteAddr() net.Addr +} + +// Listener 监听器接口 +type Listener interface { + io.Closer + // Accept 接受新的会话连接 + Accept() (Session, error) + // Addr 监听地址 + Addr() net.Addr +} + +// Agent 服务代理客户端接口 +// 运行在服务节点上,负责将本地服务注册到代理网关 +type Agent interface { + // Start 启动代理客户端 + Start(ctx context.Context) error + // Stop 停止代理客户端 + Stop(ctx context.Context) error + // Register 注册服务 + Register(ctx context.Context, service *ServiceInfo) error + // Deregister 注销服务 + Deregister(ctx context.Context, serviceID string) error + // Status 获取代理客户端状态 + Status() AgentStatus + // Info 获取 Agent 信息(用于调试显示) + Info() *AgentInfo +} + +// AgentInfo Agent 信息 +type AgentInfo struct { + GatewayAddr string `json:"gateway_addr"` + ServiceName string `json:"service_name"` + ServiceVersion string `json:"service_version"` + Endpoints []Endpoint `json:"endpoints"` + Status string `json:"status"` +} + +// AgentStatus 代理客户端状态 +type AgentStatus int + +const ( + // StatusDisconnected 未连接 + StatusDisconnected AgentStatus = iota + // StatusConnecting 连接中 + StatusConnecting + // StatusConnected 已连接 + StatusConnected + // StatusReconnecting 重连中 + StatusReconnecting +) + +// String 返回状态的字符串表示 +func (s AgentStatus) String() string { + switch s { + case StatusDisconnected: + return "disconnected" + case StatusConnecting: + return "connecting" + case StatusConnected: + return "connected" + case StatusReconnecting: + return "reconnecting" + default: + return "unknown" + } +} + +// AgentStatusInfo 代理客户端状态信息 +type AgentStatusInfo struct { + // Connected 是否已连接到网关 + Connected bool `json:"connected"` + // GatewayAddr 网关地址 + GatewayAddr string `json:"gateway_addr"` + // Transport 使用的传输协议 + Transport string `json:"transport"` + // Services 已注册的服务列表 + Services []ServiceInfo `json:"services"` + // LastError 最后一次错误 + LastError string `json:"last_error,omitempty"` + // ConnectedAt 连接时间 + ConnectedAt time.Time `json:"connected_at,omitempty"` +} + +// Gateway 代理网关服务端接口 +// 运行在代理网关上,负责接收服务注册并暴露服务 +type Gateway interface { + // Start 启动网关 + Start(ctx context.Context) error + // Stop 停止网关 + Stop(ctx context.Context) error + // Services 获取所有注册的服务 + Services() []*ServiceInfo + // GetService 获取指定服务 + GetService(name string) (*ServiceInfo, error) + // Status 获取网关状态 + Status() GatewayStatus + // Forward 转发请求到指定服务 + Forward(ctx context.Context, serviceName string, endpointType EndpointType, conn net.Conn) error +} + +// GatewayStatus 网关状态 +type GatewayStatus int + +const ( + // GatewayStatusStopped 网关已停止 + GatewayStatusStopped GatewayStatus = iota + // GatewayStatusStarting 网关启动中 + GatewayStatusStarting + // GatewayStatusRunning 网关运行中 + GatewayStatusRunning + // GatewayStatusStopping 网关停止中 + GatewayStatusStopping +) + +// String 返回状态的字符串表示 +func (s GatewayStatus) String() string { + switch s { + case GatewayStatusStopped: + return "stopped" + case GatewayStatusStarting: + return "starting" + case GatewayStatusRunning: + return "running" + case GatewayStatusStopping: + return "stopping" + default: + return "unknown" + } +} + +// GatewayStatusInfo 网关状态信息 +type GatewayStatusInfo struct { + // Running 是否运行中 + Running bool `json:"running"` + // ListenAddr 监听地址 + ListenAddr string `json:"listen_addr"` + // Transport 使用的传输协议 + Transport string `json:"transport"` + // ServiceCount 注册的服务数量 + ServiceCount int `json:"service_count"` + // ConnectionCount 当前连接数 + ConnectionCount int `json:"connection_count"` + // StartedAt 启动时间 + StartedAt time.Time `json:"started_at,omitempty"` +} + +// Message 通信消息 +type Message struct { + // Type 消息类型 + Type MessageType `json:"type"` + // ID 消息ID + ID string `json:"id,omitempty"` + // Service 服务信息(用于注册/注销) + Service *ServiceInfo `json:"service,omitempty"` + // Payload 消息负载 + Payload []byte `json:"payload,omitempty"` + // Error 错误信息 + Error string `json:"error,omitempty"` +} + +// MessageType 消息类型 +type MessageType uint8 + +const ( + // MessageTypeRegister 服务注册 + MessageTypeRegister MessageType = iota + 1 + // MessageTypeDeregister 服务注销 + MessageTypeDeregister + // MessageTypeHeartbeat 心跳 + MessageTypeHeartbeat + // MessageTypeRequest 请求 + MessageTypeRequest + // MessageTypeResponse 响应 + MessageTypeResponse + // MessageTypeStream 流数据 + MessageTypeStream + // MessageTypeAck 确认 + MessageTypeAck + // MessageTypeError 错误 + MessageTypeError + // MessageTypeHTTPRequest HTTP请求转发 + MessageTypeHTTPRequest + // MessageTypeGRPCRequest gRPC请求转发 + MessageTypeGRPCRequest + // MessageTypeDebugRequest Debug请求转发 + MessageTypeDebugRequest +) + +// RequestMeta 请求元数据 +type RequestMeta struct { + // ServiceID 目标服务ID + ServiceID string `json:"service_id"` + // EndpointType 端点类型 + EndpointType EndpointType `json:"endpoint_type"` + // Path 请求路径 + Path string `json:"path"` + // Method HTTP方法(仅HTTP端点) + Method string `json:"method,omitempty"` + // Headers 请求头 + Headers map[string]string `json:"headers,omitempty"` +} diff --git a/core/tunnel/yamux/yamux.go b/core/tunnel/yamux/yamux.go new file mode 100644 index 00000000..3f875fd1 --- /dev/null +++ b/core/tunnel/yamux/yamux.go @@ -0,0 +1,150 @@ +package yamux + +import ( + "context" + "crypto/tls" + "net" + "sync" + "time" + + "github.com/hashicorp/yamux" + + "github.com/pubgo/lava/v2/core/tunnel" +) + +func init() { + tunnel.RegisterTransport(tunnel.TransportYamux, NewTransport) +} + +// NewTransport creates a new yamux transport +func NewTransport(opts *tunnel.TransportOptions) (tunnel.Transport, error) { + return &yamuxTransport{opts: opts}, nil +} + +type yamuxTransport struct { + opts *tunnel.TransportOptions +} + +func (t *yamuxTransport) Name() string { + return tunnel.TransportYamux +} + +func (t *yamuxTransport) Dial(ctx context.Context, addr string) (tunnel.Session, error) { + dialer := &net.Dialer{Timeout: 30 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + + if t.opts != nil && t.opts.EnableTLS { + tlsConfig := &tls.Config{InsecureSkipVerify: t.opts.Insecure} + conn = tls.Client(conn, tlsConfig) + } + + session, err := yamux.Client(conn, t.buildConfig()) + if err != nil { + conn.Close() + return nil, err + } + + return &yamuxSession{session: session, conn: conn}, nil +} + +func (t *yamuxTransport) Listen(ctx context.Context, addr string) (tunnel.Listener, error) { + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + + if t.opts != nil && t.opts.EnableTLS { + cert, err := tls.LoadX509KeyPair(t.opts.CertFile, t.opts.KeyFile) + if err != nil { + ln.Close() + return nil, err + } + ln = tls.NewListener(ln, &tls.Config{Certificates: []tls.Certificate{cert}}) + } + + return &yamuxListener{listener: ln, transport: t}, nil +} + +func (t *yamuxTransport) buildConfig() *yamux.Config { + cfg := yamux.DefaultConfig() + if t.opts != nil { + if t.opts.MaxStreams > 0 { + cfg.AcceptBacklog = t.opts.MaxStreams + } + if t.opts.KeepAliveInterval > 0 { + cfg.KeepAliveInterval = time.Duration(t.opts.KeepAliveInterval) * time.Second + } + if t.opts.ConnectionWriteTimeout > 0 { + cfg.ConnectionWriteTimeout = time.Duration(t.opts.ConnectionWriteTimeout) * time.Second + } + if t.opts.StreamOpenTimeout > 0 { + cfg.StreamOpenTimeout = time.Duration(t.opts.StreamOpenTimeout) * time.Second + } + } + return cfg +} + +type yamuxListener struct { + listener net.Listener + transport *yamuxTransport +} + +func (l *yamuxListener) Accept() (tunnel.Session, error) { + conn, err := l.listener.Accept() + if err != nil { + return nil, err + } + session, err := yamux.Server(conn, l.transport.buildConfig()) + if err != nil { + conn.Close() + return nil, err + } + return &yamuxSession{session: session, conn: conn}, nil +} + +func (l *yamuxListener) Close() error { return l.listener.Close() } +func (l *yamuxListener) Addr() net.Addr { return l.listener.Addr() } + +type yamuxSession struct { + session *yamux.Session + conn net.Conn + mu sync.Mutex +} + +func (s *yamuxSession) Open(ctx context.Context) (tunnel.Stream, error) { + stream, err := s.session.OpenStream() + if err != nil { + return nil, err + } + return &yamuxStream{stream: stream}, nil +} + +func (s *yamuxSession) Accept() (tunnel.Stream, error) { + stream, err := s.session.AcceptStream() + if err != nil { + return nil, err + } + return &yamuxStream{stream: stream}, nil +} + +func (s *yamuxSession) Close() error { return s.session.Close() } +func (s *yamuxSession) IsClosed() bool { return s.session.IsClosed() } +func (s *yamuxSession) NumStreams() int { return s.session.NumStreams() } +func (s *yamuxSession) LocalAddr() net.Addr { return s.conn.LocalAddr() } +func (s *yamuxSession) RemoteAddr() net.Addr { return s.conn.RemoteAddr() } + +type yamuxStream struct { + stream *yamux.Stream +} + +func (s *yamuxStream) Read(p []byte) (int, error) { return s.stream.Read(p) } +func (s *yamuxStream) Write(p []byte) (int, error) { return s.stream.Write(p) } +func (s *yamuxStream) Close() error { return s.stream.Close() } +func (s *yamuxStream) LocalAddr() net.Addr { return s.stream.LocalAddr() } +func (s *yamuxStream) RemoteAddr() net.Addr { return s.stream.RemoteAddr() } +func (s *yamuxStream) SetDeadline(t time.Time) error { return s.stream.SetDeadline(t) } +func (s *yamuxStream) SetReadDeadline(t time.Time) error { return s.stream.SetReadDeadline(t) } +func (s *yamuxStream) SetWriteDeadline(t time.Time) error { return s.stream.SetWriteDeadline(t) } diff --git a/go.mod b/go.mod index 017cf27f..81f7a668 100644 --- a/go.mod +++ b/go.mod @@ -50,11 +50,12 @@ require ( github.com/golangci/golangci-lint v1.61.0 github.com/google/gops v0.3.28 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/yamux v0.1.2 github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19 github.com/maruel/panicparse/v2 v2.5.0 github.com/pubgo/dix/v2 v2.0.0-beta.10 github.com/pubgo/funk/v2 v2.0.0-beta.10 - github.com/pubgo/redant v0.0.4 + github.com/pubgo/redant v0.0.5 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/samber/lo v1.52.0 diff --git a/go.sum b/go.sum index 3ea2cd4e..da152683 100644 --- a/go.sum +++ b/go.sum @@ -300,6 +300,8 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= @@ -485,8 +487,8 @@ github.com/pubgo/dix/v2 v2.0.0-beta.10 h1:HE1gqY8vzNPPdz4FwN91hWVZpeWkfvuIRAT7dG github.com/pubgo/dix/v2 v2.0.0-beta.10/go.mod h1:jV/9KWf+YxtoQATuZLyUraACduxHvfaum5EZDSCK5gE= github.com/pubgo/funk/v2 v2.0.0-beta.10 h1:K8QomGlsoMFLWpx8KcAfHDKifQDLA4dcUL0YLL2a8cU= github.com/pubgo/funk/v2 v2.0.0-beta.10/go.mod h1:YTWjAG9bJ//P3fFc1cyzD95L7lTO0H27gv9VRQ18o1c= -github.com/pubgo/redant v0.0.4 h1:Yweyxj33Y+j4eE9b36QAn9FcOWPymUE0CxaqOrJgTvs= -github.com/pubgo/redant v0.0.4/go.mod h1:FOBNjL8pPLOBcZS3SL2R5GusFz/bNBwDJzSinGuKs7A= +github.com/pubgo/redant v0.0.5 h1:iDq0cQJNtST8pu9bFSgxZ78JoQ0aVW+svZyOMouWjfM= +github.com/pubgo/redant v0.0.5/go.mod h1:FOBNjL8pPLOBcZS3SL2R5GusFz/bNBwDJzSinGuKs7A= github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 h1:+Wl/0aFp0hpuHM3H//KMft64WQ1yX9LdJY64Qm/gFCo= github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1/go.mod h1:GJLgqsLeo4qgavUoL8JeGFNS7qcisx3awV/w9eWTmNI= github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= diff --git a/internal/configs/components/tunnel.yaml b/internal/configs/components/tunnel.yaml new file mode 100644 index 00000000..b7072cad --- /dev/null +++ b/internal/configs/components/tunnel.yaml @@ -0,0 +1,19 @@ +# Tunnel Gateway 配置 +tunnel: + # Agent 连接监听地址 + # Agent 主动连接此地址注册服务 + listen_addr: ":7007" + + # HTTP 代理端口 + # 外部 HTTP 请求通过此端口访问已注册的服务 + # URL 格式: http://gateway:8888/{service_name}/path + http_port: 8888 + + # gRPC 代理端口 + # 外部 gRPC 请求通过此端口访问已注册的服务 + grpc_port: 9999 + + # Debug 代理端口 + # 外部可通过此端口访问已注册服务的 debug 接口 + # URL 格式: http://gateway:6066/{service_name}/debug/pprof + debug_port: 6066 diff --git a/internal/configs/tunnel.yaml b/internal/configs/tunnel.yaml new file mode 100644 index 00000000..ea6ce7d4 --- /dev/null +++ b/internal/configs/tunnel.yaml @@ -0,0 +1,8 @@ +resources: + - components + +patch_resources: + - .local.yaml + +patch_envs: + - envs diff --git a/internal/examples/scheduler/main.go b/internal/examples/scheduler/main.go index 6de078d0..87285227 100644 --- a/internal/examples/scheduler/main.go +++ b/internal/examples/scheduler/main.go @@ -5,10 +5,14 @@ import ( "fmt" "time" + "github.com/pubgo/funk/v2/buildinfo/version" "github.com/pubgo/funk/v2/config" "github.com/pubgo/funk/v2/debugs" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/log" "github.com/pubgo/funk/v2/recovery" "github.com/pubgo/funk/v2/result" + "github.com/pubgo/funk/v2/result/resultchecker" "github.com/pubgo/lava/v2/cmds/configcmd" "github.com/pubgo/lava/v2/cmds/envcmd" @@ -53,6 +57,19 @@ func (s schedulerExample) RegisterSchedulerJob(reg scheduler.JobRegistry) { func main() { defer recovery.Exit() + version.SetVersion("v1.0.0") + version.SetProject("scheduler") + config.SetConfigPath("internal/configs/scheduler.yaml") + resultchecker.RegisterErrCheck(log.RecordErr()) + log.SetEnableChecker(func(ctx context.Context, lvl log.Level, name, message string, fields log.Fields) bool { + //if lvl == zerolog.DebugLevel { + // return false + //} + return true + }) + //debugs.SetEnabled() + env.LoadFiles(".env").Must() + builder := lavabuilder.New() builder.Provide(config.Load[Config]) builder.Provide(envcmd.New) diff --git a/internal/examples/scheduler/taskfile.yml b/internal/examples/scheduler/taskfile.yml index 118d6e80..40428d8b 100644 --- a/internal/examples/scheduler/taskfile.yml +++ b/internal/examples/scheduler/taskfile.yml @@ -24,4 +24,4 @@ tasks: cmds: - task scheduler:build - kill -9 $(ps -ef | grep scheduler.yaml | grep -v grep | awk '{print $2}') || true - - SERVER_HTTP_PORT=8082 ./bin/scheduler scheduler -c ./internal/configs/scheduler.yaml + - SERVER_HTTP_PORT=8082 ./bin/scheduler cron -c ./internal/configs/scheduler.yaml diff --git a/internal/examples/tunnel/README.md b/internal/examples/tunnel/README.md new file mode 100644 index 00000000..e07a5f7f --- /dev/null +++ b/internal/examples/tunnel/README.md @@ -0,0 +1,113 @@ +# Tunnel Gateway Example + +这是一个独立运行的 Tunnel Gateway 服务示例。 + +## 架构说明 + +``` + 外部请求 + │ + ▼ +┌───────────────────────────────────────────────────┐ +│ Tunnel Gateway (本示例) │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ +│ │ HTTP :8888 │ │ gRPC :9999 │ │Debug :6066│ │ +│ └──────┬──────┘ └──────┬──────┘ └─────┬─────┘ │ +│ └────────────────┼───────────────┘ │ +│ │ │ +│ Tunnel Listener :7000 │ +└──────────────────────────┬───────────────────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ┌──────┴──────┐ ┌─────┴─────┐ ┌──────┴──────┐ + │ Scheduler │ │ Service B │ │ Service C │ + │ (Agent) │ │ (Agent) │ │ (Agent) │ + │ :8080/:6060 │ │ │ │ │ + └─────────────┘ └───────────┘ └─────────────┘ + 内网服务节点(主动连接 Gateway) +``` + +## 运行 + +### 1. 启动 Gateway + +```bash +cd internal/examples/tunnel +go run . scheduler -c ../../configs/tunnel.yaml +``` + +Gateway 将监听以下端口: +- `:7007` - 接受 Agent 连接 +- `:8888` - HTTP 代理(对外暴露服务) +- `:9999` - gRPC 代理 +- `:6066` - Debug 代理 + +### 2. 启动带 Agent 的 Scheduler 服务 + +```bash +# 在另一个终端 +cd internal/examples/scheduler +TUNNEL_GATEWAY_ADDR=localhost:7007 go run . scheduler -c ../../configs/scheduler.yaml +``` + +Scheduler 服务会: +1. 启动本地 HTTP 服务(`:8080`) +2. 启动本地 Debug 服务(`:6060`) +3. 通过 Agent 连接到 Gateway,注册自己 + +### 3. 通过 Gateway 访问服务 + +```bash +# 查看已注册的服务列表 +curl http://localhost:8888/ + +# 访问 scheduler 服务的接口 +curl http://localhost:8888/scheduler/api/v1/jobs + +# 访问 scheduler 服务的 debug 接口 +curl http://localhost:6066/scheduler/debug/pprof/ +``` + +## 配置 + +配置文件位于 `internal/configs/` 目录下,复用项目统一的配置结构: + +``` +internal/configs/ +├── tunnel.yaml # Tunnel 主配置 +├── scheduler.yaml # Scheduler 主配置 +├── components/ +│ ├── tunnel.yaml # Tunnel Gateway 组件配置 +│ ├── http_server.yaml # HTTP 服务配置 +│ ├── logger.yaml # 日志配置 +│ └── metric.yaml # 指标配置 +└── envs/ + └── .env # 环境变量 +``` + +### Gateway 配置 (components/tunnel.yaml) + +```yaml +tunnel: + listen_addr: ":7007" # Agent 连接地址 + http_port: 8888 # HTTP 代理端口 + grpc_port: 9999 # gRPC 代理端口 + debug_port: 6066 # Debug 代理端口 +``` + +### Agent 配置(环境变量) + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `TUNNEL_GATEWAY_ADDR` | Gateway 地址 | `localhost:7000` | +| `HTTP_ADDR` | 本地 HTTP 服务地址 | `localhost:8080` | +| `DEBUG_ADDR` | 本地 Debug 服务地址 | `localhost:6060` | + +## 使用场景 + +1. **内网服务暴露**:服务在内网/防火墙后,通过 Agent 主动连接 Gateway 暴露到公网 +2. **服务聚合**:多个微服务通过同一个 Gateway 统一入口 +3. **远程调试**:通过 Gateway 访问内网服务的 pprof/debug 接口 +4. **零配置部署**:服务只需知道 Gateway 地址,无需开放端口 diff --git a/internal/examples/tunnel/main.go b/internal/examples/tunnel/main.go new file mode 100644 index 00000000..5b4c2b50 --- /dev/null +++ b/internal/examples/tunnel/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/config" + "github.com/pubgo/funk/v2/env" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/funk/v2/result/resultchecker" + + "github.com/pubgo/lava/v2/cmds/configcmd" + "github.com/pubgo/lava/v2/cmds/envcmd" + "github.com/pubgo/lava/v2/core/debug/tunneldebug" + "github.com/pubgo/lava/v2/core/lavabuilder" + "github.com/pubgo/lava/v2/core/logging" + "github.com/pubgo/lava/v2/core/metrics" + "github.com/pubgo/lava/v2/core/supervisor" + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" + "github.com/pubgo/lava/v2/servers/https" +) + +// Config 配置结构 +type Config struct { + metrics.MetricConfigLoader `yaml:",inline"` + logging.LogConfigLoader `yaml:",inline"` + https.HttpServerConfigLoader `yaml:",inline"` + + // Tunnel Gateway 配置 + Tunnel *TunnelConfig `yaml:"tunnel"` +} + +// TunnelConfig Tunnel Gateway 配置 +type TunnelConfig struct { + // ListenAddr Agent 连接监听地址 + ListenAddr string `yaml:"listen_addr" default:":7007"` + // HTTPPort HTTP 代理端口 + HTTPPort int `yaml:"http_port" default:"8888"` + // GRPCPort gRPC 代理端口 + GRPCPort int `yaml:"grpc_port" default:"9999"` + // DebugPort Debug 代理端口 + DebugPort int `yaml:"debug_port" default:"6066"` +} + +// tunnelGatewayService 包装 Gateway 为 supervisor.Service +type tunnelGatewayService struct { + gateway tunnel.Gateway + err error +} + +func (s *tunnelGatewayService) Name() string { + return "tunnel-gateway" +} + +func (s *tunnelGatewayService) Error() error { + return s.err +} + +func (s *tunnelGatewayService) String() string { + return "Tunnel Gateway Service - accepts agent connections and proxies requests" +} + +func (s *tunnelGatewayService) Serve(ctx context.Context) error { + if err := s.gateway.Start(ctx); err != nil { + s.err = err + return err + } + + // 等待上下文取消 + <-ctx.Done() + + return s.gateway.Stop(context.Background()) +} + +func (s *tunnelGatewayService) Metric() *supervisor.Metric { + return &supervisor.Metric{ + Name: s.Name(), + Status: supervisor.StatusRunning, + } +} + +// NewTunnelGatewayService 创建 Tunnel Gateway 服务 +func NewTunnelGatewayService(cfg *Config) supervisor.Service { + gateway, err := tunnel.NewGatewayBuilder(). + WithListenAddr(cfg.Tunnel.ListenAddr). + WithHTTPPort(cfg.Tunnel.HTTPPort). + WithGRPCPort(cfg.Tunnel.GRPCPort). + WithDebugPort(cfg.Tunnel.DebugPort). + Build() + if err != nil { + panic(err) + } + + // 注册到 debug 界面 + tunneldebug.SetGateway(gateway) + + return &tunnelGatewayService{gateway: gateway} +} + +func main() { + defer recovery.Exit() + + version.SetVersion("v1.0.0") + version.SetProject("tunnel-gateway") + config.SetConfigPath("internal/configs/tunnel.yaml") + resultchecker.RegisterErrCheck(log.RecordErr()) + log.SetEnableChecker(func(ctx context.Context, lvl log.Level, name, message string, fields log.Fields) bool { + //if lvl == zerolog.DebugLevel { + // return false + //} + return true + }) + //debugs.SetEnabled() + env.LoadFiles(".env").Must() + + builder := lavabuilder.New() + builder.Provide(config.Load[Config]) + builder.Provide(envcmd.New) + builder.Provide(configcmd.New[Config]) + builder.Provide(NewTunnelGatewayService) + + lavabuilder.Run(builder) +} diff --git a/internal/examples/tunnel/taskfile.yml b/internal/examples/tunnel/taskfile.yml new file mode 100644 index 00000000..9c3dd722 --- /dev/null +++ b/internal/examples/tunnel/taskfile.yml @@ -0,0 +1,49 @@ +# https://taskfile.dev + +version: '3' + +vars: + TunnelRelease: "v0.1.0" + TunnelProject: "tunnel-gateway" + +tasks: + info: + cmds: + - echo "{{.TunnelRelease}}" "{{.TunnelProject}}" + + default: + cmds: + - task tunnel:info + + build: + cmds: + - go build -o ./bin/tunnel-gateway ./internal/examples/tunnel/main.go + - ls -alh ./bin + + run: + desc: "启动 Tunnel Gateway" + cmds: + - task tunnel:build + - kill -9 $(ps -ef | grep tunnel-gateway | grep -v grep | awk '{print $2}') || true + - ./bin/tunnel-gateway scheduler -c ./internal/configs/tunnel.yaml + + run-foreground: + desc: "仅启动 Gateway(前台运行)" + cmds: + - task tunnel:build + - ./bin/tunnel-gateway scheduler -c ./internal/configs/tunnel.yaml + + test: + desc: "运行 tunnel 模块测试" + cmds: + - go test -v ./core/tunnel/... -timeout 30s + + test-integration: + desc: "运行集成测试" + cmds: + - go test -v ./cmds/schedulercmd/... -run TestTunnelIntegration -timeout 30s + + clean: + desc: "清理构建产物" + cmds: + - rm -f ./bin/tunnel-gateway diff --git a/taskfile.yml b/taskfile.yml index 3ad16821..5dcba61f 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -19,6 +19,7 @@ vars: includes: scheduler: ./internal/examples/scheduler + tunnel: ./internal/examples/tunnel tasks: default: From 5b0148e85a5f08c73e818cbfa503dd23e99ff8ff Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 20 Jan 2026 18:56:48 +0800 Subject: [PATCH 11/80] chore: quick update fix/router at 2026-01-20 18:56:48 --- cmds/tunnelcmd/cmd.go | 217 ++++ core/debug/tunneldebug/html.go | 1270 ++++++++++------------- core/lavabuilder/builder.go | 7 + core/tunnel/agent.go | 138 ++- core/tunnel/gateway.go | 19 + internal/configs/components/tunnel.yaml | 6 + internal/examples/scheduler/main.go | 53 + internal/examples/tunnel/README.md | 40 +- internal/examples/tunnel/main.go | 103 -- internal/examples/tunnel/taskfile.yml | 4 +- 10 files changed, 968 insertions(+), 889 deletions(-) create mode 100644 cmds/tunnelcmd/cmd.go diff --git a/cmds/tunnelcmd/cmd.go b/cmds/tunnelcmd/cmd.go new file mode 100644 index 00000000..69fe96e7 --- /dev/null +++ b/cmds/tunnelcmd/cmd.go @@ -0,0 +1,217 @@ +package tunnelcmd + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/pubgo/dix/v2" + "github.com/pubgo/funk/v2/buildinfo/version" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/redant" + + "github.com/pubgo/lava/v2/core/debug" + "github.com/pubgo/lava/v2/core/debug/tunneldebug" + "github.com/pubgo/lava/v2/core/lifecycle" + "github.com/pubgo/lava/v2/core/supervisor" + "github.com/pubgo/lava/v2/core/tunnel" + _ "github.com/pubgo/lava/v2/core/tunnel/yamux" // 注册 yamux 传输 + "github.com/pubgo/lava/v2/pkg/cliutil" +) + +// TunnelConfig Tunnel Gateway 配置 +type TunnelConfig struct { + // ListenAddr Agent 连接监听地址 + ListenAddr string `yaml:"listen_addr" default:":7007"` + // HTTPPort HTTP 代理端口 + HTTPPort int `yaml:"http_port" default:"8888"` + // GRPCPort gRPC 代理端口 + GRPCPort int `yaml:"grpc_port" default:"9999"` + // DebugPort Debug 代理端口 + DebugPort int `yaml:"debug_port" default:"6066"` +} + +// Config 配置结构 +type Config struct { + Tunnel *TunnelConfig `yaml:"tunnel"` +} + +// tunnelGatewayService 包装 Gateway 为 supervisor.Service +type tunnelGatewayService struct { + gateway tunnel.Gateway + err error +} + +func (s *tunnelGatewayService) Name() string { + return "tunnel-gateway" +} + +func (s *tunnelGatewayService) Error() error { + return s.err +} + +func (s *tunnelGatewayService) String() string { + return "Tunnel Gateway Service - accepts agent connections and proxies requests" +} + +func (s *tunnelGatewayService) Serve(ctx context.Context) error { + if err := s.gateway.Start(ctx); err != nil { + s.err = err + return err + } + + // 等待上下文取消 + <-ctx.Done() + + return s.gateway.Stop(context.Background()) +} + +func (s *tunnelGatewayService) Metric() *supervisor.Metric { + return &supervisor.Metric{ + Name: s.Name(), + Status: supervisor.StatusRunning, + } +} + +// debugServerService 内嵌的 debug 服务器 +type debugServerService struct { + app *fiber.App + addr string + err error +} + +func (s *debugServerService) Name() string { return "debug-server" } +func (s *debugServerService) Error() error { return s.err } +func (s *debugServerService) String() string { + return "Debug Server - provides management UI at " + s.addr +} + +func (s *debugServerService) Serve(ctx context.Context) error { + go func() { + <-ctx.Done() + _ = s.app.Shutdown() + }() + + log.Info().Str("addr", s.addr).Msg("Debug server started") + if err := s.app.Listen(s.addr); err != nil && err != http.ErrServerClosed { + s.err = err + return err + } + return nil +} + +func (s *debugServerService) Metric() *supervisor.Metric { + return &supervisor.Metric{ + Name: s.Name(), + Status: supervisor.StatusRunning, + } +} + +// newDebugServer 创建 debug 服务器 +func newDebugServer(addr string) *debugServerService { + app := fiber.New(fiber.Config{ + DisableStartupMessage: true, + }) + + // 挂载 debug 路由 + app.Mount("/debug", debug.App()) + + // 根路由重定向到 tunnel dashboard + app.Get("/", func(c *fiber.Ctx) error { + return c.Redirect("/debug/tunnel") + }) + + return &debugServerService{ + app: app, + addr: addr, + } +} + +// New 创建 tunnel gateway 命令 +func New(di *dix.Dix) *redant.Command { + return &redant.Command{ + Use: "tunnel", + Short: cliutil.UsageDesc("tunnel gateway service %s(%s)", version.Project(), version.Version()), + Handler: func(ctx context.Context, i *redant.Invocation) error { + // 设置默认值(直接从环境变量读取,不依赖配置文件) + tunnelCfg := &TunnelConfig{ + ListenAddr: ":7007", + HTTPPort: 8888, + GRPCPort: 9999, + DebugPort: 6066, + } + + // 环境变量覆盖 + if addr := os.Getenv("TUNNEL_LISTEN_ADDR"); addr != "" { + tunnelCfg.ListenAddr = addr + } + if port := os.Getenv("TUNNEL_HTTP_PORT"); port != "" { + if p, err := parsePort(port); err == nil { + tunnelCfg.HTTPPort = p + } + } + if port := os.Getenv("TUNNEL_GRPC_PORT"); port != "" { + if p, err := parsePort(port); err == nil { + tunnelCfg.GRPCPort = p + } + } + if port := os.Getenv("TUNNEL_DEBUG_PORT"); port != "" { + if p, err := parsePort(port); err == nil { + tunnelCfg.DebugPort = p + } + } + + // 创建 Gateway + gateway, err := tunnel.NewGatewayBuilder(). + WithListenAddr(tunnelCfg.ListenAddr). + WithHTTPPort(tunnelCfg.HTTPPort). + WithGRPCPort(tunnelCfg.GRPCPort). + WithDebugPort(tunnelCfg.DebugPort). + Build() + if err != nil { + return err + } + + // 注册到 debug 界面 + tunneldebug.SetGateway(gateway) + + params := dix.Inject(di, new(struct { + LC lifecycle.Getter + })) + + manager := supervisor.Default(params.LC) + + // 添加 Gateway 服务 + if err := manager.Add(&tunnelGatewayService{gateway: gateway}); err != nil { + return err + } + + // 添加 Debug 服务器(管理界面) + debugAddr := ":6067" // 使用独立端口,避免与 debug proxy 冲突 + if addr := os.Getenv("TUNNEL_ADMIN_ADDR"); addr != "" { + debugAddr = addr + } + if err := manager.Add(newDebugServer(debugAddr)); err != nil { + return err + } + + log.Info(). + Str("listen_addr", tunnelCfg.ListenAddr). + Int("http_port", tunnelCfg.HTTPPort). + Int("grpc_port", tunnelCfg.GRPCPort). + Int("debug_port", tunnelCfg.DebugPort). + Str("admin_addr", debugAddr). + Msg("Starting Tunnel Gateway") + + return manager.Run(ctx) + }, + } +} + +func parsePort(s string) (int, error) { + var port int + _, err := fmt.Sscanf(s, "%d", &port) + return port, err +} diff --git a/core/debug/tunneldebug/html.go b/core/debug/tunneldebug/html.go index 24189f9e..d24cc01d 100644 --- a/core/debug/tunneldebug/html.go +++ b/core/debug/tunneldebug/html.go @@ -1,870 +1,644 @@ package tunneldebug -// getGatewayDashboardHTML 返回 Gateway 仪表盘 HTML +// getGatewayDashboardHTML 返回 Gateway 仪表盘 HTML (使用 Tailwind CSS + Alpine.js) func getGatewayDashboardHTML() string { return ` - Tunnel Gateway 管理界面 - - + Tunnel Gateway - Debug Console + + - -