diff --git a/admin/admin.go b/admin/admin.go new file mode 100644 index 000000000..17e873293 --- /dev/null +++ b/admin/admin.go @@ -0,0 +1,103 @@ +// Copyright (c) 2024 The VeChainThor developers + +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package admin + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/vechain/thor/v2/log" +) + +type logLevelRequest struct { + Level string `json:"level"` +} + +type logLevelResponse struct { + CurrentLevel string `json:"currentLevel"` +} + +type errorResponse struct { + ErrorCode int `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` +} + +func writeError(w http.ResponseWriter, errCode int, errMsg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(errCode) + json.NewEncoder(w).Encode(errorResponse{ + ErrorCode: errCode, + ErrorMessage: errMsg, + }) +} + +func getLogLevelHandler(logLevel *slog.LevelVar) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + response := logLevelResponse{ + CurrentLevel: logLevel.Level().String(), + } + if err := json.NewEncoder(w).Encode(response); err != nil { + writeError(w, http.StatusInternalServerError, "Failed to encode response") + } + } +} + +func postLogLevelHandler(logLevel *slog.LevelVar) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req logLevelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Invalid request body") + return + } + + switch req.Level { + case "debug": + logLevel.Set(log.LevelDebug) + case "info": + logLevel.Set(log.LevelInfo) + case "warn": + logLevel.Set(log.LevelWarn) + case "error": + logLevel.Set(log.LevelError) + case "trace": + logLevel.Set(log.LevelTrace) + case "crit": + logLevel.Set(log.LevelCrit) + default: + writeError(w, http.StatusBadRequest, "Invalid verbosity level") + return + } + + w.Header().Set("Content-Type", "application/json") + response := logLevelResponse{ + CurrentLevel: logLevel.Level().String(), + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + } +} + +func logLevelHandler(logLevel *slog.LevelVar) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getLogLevelHandler(logLevel).ServeHTTP(w, r) + case http.MethodPost: + postLogLevelHandler(logLevel).ServeHTTP(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } + } +} + +func HTTPHandler(logLevel *slog.LevelVar) http.Handler { + router := mux.NewRouter() + router.HandleFunc("/admin/loglevel", logLevelHandler(logLevel)) + return handlers.CompressHandler(router) +} diff --git a/cmd/disco/utils.go b/cmd/disco/utils.go index 53a2a2e5f..c73d357e4 100644 --- a/cmd/disco/utils.go +++ b/cmd/disco/utils.go @@ -9,6 +9,7 @@ import ( "crypto/ecdsa" "fmt" "io" + "log/slog" "os" "os/user" "path/filepath" @@ -19,15 +20,19 @@ import ( "github.com/vechain/thor/v2/log" ) -func initLogger(lvl int) { +func initLogger(lvl int) *slog.LevelVar { logLevel := log.FromLegacyLevel(lvl) + var level slog.LevelVar + level.Set(logLevel) output := io.Writer(os.Stdout) useColor := (isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd())) && os.Getenv("TERM") != "dumb" - handler := log.NewTerminalHandlerWithLevel(output, logLevel, useColor) + handler := log.NewTerminalHandlerWithLevel(output, &level, useColor) log.SetDefault(log.NewLogger(handler)) ethlog.Root().SetHandler(ðLogger{ logger: log.WithContext("pkg", "geth"), }) + + return &level } type ethLogger struct { diff --git a/cmd/thor/flags.go b/cmd/thor/flags.go index 4bdd6c80d..b93167e21 100644 --- a/cmd/thor/flags.go +++ b/cmd/thor/flags.go @@ -150,6 +150,16 @@ var ( Value: "localhost:2112", Usage: "metrics service listening address", } + + enableAdminFlag = cli.BoolFlag{ + Name: "enable-admin", + Usage: "enables admin service", + } + adminAddrFlag = cli.StringFlag{ + Name: "admin-addr", + Value: "localhost:2113", + Usage: "admin service listening address", + } txPoolLimitPerAccountFlag = cli.Uint64Flag{ Name: "txpool-limit-per-account", Value: 16, diff --git a/cmd/thor/main.go b/cmd/thor/main.go index 4015a53a6..bd357beb6 100644 --- a/cmd/thor/main.go +++ b/cmd/thor/main.go @@ -93,6 +93,8 @@ func main() { disablePrunerFlag, enableMetricsFlag, metricsAddrFlag, + adminAddrFlag, + enableAdminFlag, txPoolLimitPerAccountFlag, }, Action: defaultAction, @@ -126,6 +128,8 @@ func main() { disablePrunerFlag, enableMetricsFlag, metricsAddrFlag, + adminAddrFlag, + enableAdminFlag, }, Action: soloAction, }, @@ -157,7 +161,7 @@ func defaultAction(ctx *cli.Context) error { if err != nil { return errors.Wrap(err, "parse verbosity flag") } - initLogger(lvl, ctx.Bool(jsonLogsFlag.Name)) + logLevel := initLogger(lvl, ctx.Bool(jsonLogsFlag.Name)) // enable metrics as soon as possible metricsURL := "" @@ -171,6 +175,16 @@ func defaultAction(ctx *cli.Context) error { defer func() { log.Info("stopping metrics server..."); close() }() } + adminURL := "" + if ctx.Bool(enableAdminFlag.Name) { + url, close, err := startAdminServer(ctx.String(adminAddrFlag.Name), logLevel) + if err != nil { + return fmt.Errorf("unable to start admin server - %w", err) + } + adminURL = url + defer func() { log.Info("stopping admin server..."); close() }() + } + gene, forkConfig, err := selectGenesis(ctx) if err != nil { return err @@ -256,7 +270,7 @@ func defaultAction(ctx *cli.Context) error { } defer func() { log.Info("stopping API server..."); srvCloser() }() - printStartupMessage2(gene, apiURL, p2pCommunicator.Enode(), metricsURL) + printStartupMessage2(gene, apiURL, p2pCommunicator.Enode(), metricsURL, adminURL) if err := p2pCommunicator.Start(); err != nil { return err @@ -288,7 +302,8 @@ func soloAction(ctx *cli.Context) error { if err != nil { return errors.Wrap(err, "parse verbosity flag") } - initLogger(lvl, ctx.Bool(jsonLogsFlag.Name)) + + logLevel := initLogger(lvl, ctx.Bool(jsonLogsFlag.Name)) // enable metrics as soon as possible metricsURL := "" @@ -302,6 +317,16 @@ func soloAction(ctx *cli.Context) error { defer func() { log.Info("stopping metrics server..."); close() }() } + adminURL := "" + if ctx.Bool(enableAdminFlag.Name) { + url, close, err := startAdminServer(ctx.String(adminAddrFlag.Name), logLevel) + if err != nil { + return fmt.Errorf("unable to start admin server - %w", err) + } + adminURL = url + defer func() { log.Info("stopping admin server..."); close() }() + } + var ( gene *genesis.Genesis forkConfig thor.ForkConfig @@ -402,7 +427,7 @@ func soloAction(ctx *cli.Context) error { return errors.New("block-interval cannot be zero") } - printSoloStartupMessage(gene, repo, instanceDir, apiURL, forkConfig, metricsURL) + printSoloStartupMessage(gene, repo, instanceDir, apiURL, forkConfig, metricsURL, adminURL) optimizer := optimizer.New(mainDB, repo, !ctx.Bool(disablePrunerFlag.Name)) defer func() { log.Info("stopping optimizer..."); optimizer.Stop() }() diff --git a/cmd/thor/utils.go b/cmd/thor/utils.go index 620e9c16a..cb810364d 100644 --- a/cmd/thor/utils.go +++ b/cmd/thor/utils.go @@ -39,6 +39,7 @@ import ( "github.com/mattn/go-isatty" "github.com/mattn/go-tty" "github.com/pkg/errors" + "github.com/vechain/thor/v2/admin" "github.com/vechain/thor/v2/api/doc" "github.com/vechain/thor/v2/chain" "github.com/vechain/thor/v2/cmd/thor/node" @@ -60,21 +61,25 @@ import ( var devNetGenesisID = genesis.NewDevnet().ID() -func initLogger(lvl int, jsonLogs bool) { +func initLogger(lvl int, jsonLogs bool) *slog.LevelVar { logLevel := log.FromLegacyLevel(lvl) output := io.Writer(os.Stdout) + var level slog.LevelVar + level.Set(logLevel) var handler slog.Handler if jsonLogs { - handler = log.JSONHandlerWithLevel(output, logLevel) + handler = log.JSONHandlerWithLevel(output, &level) } else { useColor := (isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd())) && os.Getenv("TERM") != "dumb" - handler = log.NewTerminalHandlerWithLevel(output, logLevel, useColor) + handler = log.NewTerminalHandlerWithLevel(output, &level, useColor) } log.SetDefault(log.NewLogger(handler)) ethlog.Root().SetHandler(ethlog.LvlFilterHandler(ethlog.LvlWarn, ðLogger{ logger: log.WithContext("pkg", "geth"), })) + + return &level } type ethLogger struct { @@ -587,6 +592,27 @@ func startMetricsServer(addr string) (string, func(), error) { }, nil } +func startAdminServer(addr string, logLevel *slog.LevelVar) (string, func(), error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return "", nil, errors.Wrapf(err, "listen admin API addr [%v]", addr) + } + + router := mux.NewRouter() + router.PathPrefix("/admin").Handler(admin.HTTPHandler(logLevel)) + handler := handlers.CompressHandler(router) + + srv := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second, ReadTimeout: 5 * time.Second} + var goes co.Goes + goes.Go(func() { + srv.Serve(listener) + }) + return "http://" + listener.Addr().String() + "/admin", func() { + srv.Close() + goes.Wait() + }, nil +} + func printStartupMessage1( gene *genesis.Genesis, repo *chain.Repository, @@ -638,8 +664,9 @@ func printStartupMessage2( apiURL string, nodeID string, metricsURL string, + adminURL string, ) { - fmt.Printf(`%v API portal [ %v ]%v%v`, + fmt.Printf(`%v API portal [ %v ]%v%v%v`, func() string { // node ID if nodeID == "" { return "" @@ -659,6 +686,15 @@ func printStartupMessage2( metricsURL) } }(), + func() string { // admin URL + if adminURL == "" { + return "" + } else { + return fmt.Sprintf(` + Admin [ %v ]`, + adminURL) + } + }(), func() string { // print default dev net's dev accounts info if gene.ID() == devNetGenesisID { @@ -681,6 +717,7 @@ func printSoloStartupMessage( apiURL string, forkConfig thor.ForkConfig, metricsURL string, + adminURL string, ) { bestBlock := repo.BestBlockSummary() @@ -691,6 +728,7 @@ func printSoloStartupMessage( Data dir [ %v ] API portal [ %v ] Metrics [ %v ] + Admin [ %v ] `, common.MakeName("Thor solo", fullVersion()), gene.ID(), gene.Name(), @@ -704,6 +742,12 @@ func printSoloStartupMessage( } return metricsURL }(), + func() string { + if adminURL == "" { + return "Disabled" + } + return adminURL + }(), ) if gene.ID() == devNetGenesisID { diff --git a/docs/hosting-a-node.md b/docs/hosting-a-node.md index ac9e8fa73..8defd58dc 100644 --- a/docs/hosting-a-node.md +++ b/docs/hosting-a-node.md @@ -108,4 +108,22 @@ By default, a [prometheus](https://prometheus.io/docs/introduction/overview/) se curl localhost:2112/metrics ``` -Instrumentation is in a beta phase at this stage. You can read more about the metric types [here](https://prometheus.io/docs/concepts/metric_types/). \ No newline at end of file +Instrumentation is in a beta phase at this stage. You can read more about the metric types [here](https://prometheus.io/docs/concepts/metric_types/). + +### Admin + +Admin is used to allow privileged actions to the node by the administrator. Currently it supports changing the logger's verbosity at runtime. + +Admin is not enabled in nodes by default. It's possible to enable it by setting `--enable-admin`. Once enabled, an Admin server is available at `localhost:2113/admin` with the following capabilities: + +Retrieve the current log level via a GET request to /admin/loglevel. + +```shell +curl http://localhost:2113/admin/loglevel +``` + +Change the log level via a POST request to /admin/loglevel. + +```shell +curl -X POST -H "Content-Type: application/json" -d '{"level": "trace"}' http://localhost:2113/admin/loglevel +``` \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md index b34b3feff..e1ece95bb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -182,6 +182,8 @@ bin/thor -h | `--disable-pruner` | Disable state pruner to keep all history | | `--enable-metrics` | Enables the metrics server | | `--metrics-addr` | Metrics service listening address | +| `--enable-admin` | Enables the admin server | +| `--admin-addr` | Admin service listening address | | `--txpool-limit-per-account`| Transaction pool size limit per account | | `--help, -h` | Show help | | `--version, -v` | Print the version | diff --git a/log/handler.go b/log/handler.go index 17ad6bc89..2cc150fa4 100644 --- a/log/handler.go +++ b/log/handler.go @@ -55,7 +55,7 @@ func (h *discardHandler) WithAttrs(attrs []slog.Attr) slog.Handler { type TerminalHandler struct { mu sync.Mutex wr io.Writer - lvl slog.Level + lvl *slog.LevelVar useColor bool attrs []slog.Attr // fieldPadding is a map with maximum field value lengths seen until now @@ -75,12 +75,14 @@ type TerminalHandler struct { // // [DBUG] [May 16 20:58:45] remove route ns=haproxy addr=127.0.0.1:50002 func NewTerminalHandler(wr io.Writer, useColor bool) *TerminalHandler { - return NewTerminalHandlerWithLevel(wr, levelMaxVerbosity, useColor) + var level slog.LevelVar + level.Set(levelMaxVerbosity) + return NewTerminalHandlerWithLevel(wr, &level, useColor) } // NewTerminalHandlerWithLevel returns the same handler as NewTerminalHandler but only outputs // records which are less than or equal to the specified verbosity level. -func NewTerminalHandlerWithLevel(wr io.Writer, lvl slog.Level, useColor bool) *TerminalHandler { +func NewTerminalHandlerWithLevel(wr io.Writer, lvl *slog.LevelVar, useColor bool) *TerminalHandler { return &TerminalHandler{ wr: wr, lvl: lvl, @@ -99,7 +101,7 @@ func (h *TerminalHandler) Handle(_ context.Context, r slog.Record) error { } func (h *TerminalHandler) Enabled(_ context.Context, level slog.Level) bool { - return level >= h.lvl + return level.Level() >= h.lvl.Level() } func (h *TerminalHandler) WithGroup(name string) slog.Handler { @@ -123,20 +125,22 @@ func (t *TerminalHandler) ResetFieldPadding() { t.mu.Unlock() } -type leveler struct{ minLevel slog.Level } +type leveler struct{ minLevel *slog.LevelVar } func (l *leveler) Level() slog.Level { - return l.minLevel + return l.minLevel.Level() } // JSONHandler returns a handler which prints records in JSON format. func JSONHandler(wr io.Writer) slog.Handler { - return JSONHandlerWithLevel(wr, levelMaxVerbosity) + var level slog.LevelVar + level.Set(levelMaxVerbosity) + return JSONHandlerWithLevel(wr, &level) } // JSONHandlerWithLevel returns a handler which prints records in JSON format that are less than or equal to // the specified verbosity level. -func JSONHandlerWithLevel(wr io.Writer, level slog.Level) slog.Handler { +func JSONHandlerWithLevel(wr io.Writer, level *slog.LevelVar) slog.Handler { return slog.NewJSONHandler(wr, &slog.HandlerOptions{ ReplaceAttr: builtinReplaceJSON, Level: &leveler{level}, @@ -155,7 +159,7 @@ func LogfmtHandler(wr io.Writer) slog.Handler { // LogfmtHandlerWithLevel returns the same handler as LogfmtHandler but it only outputs // records which are less than or equal to the specified verbosity level. -func LogfmtHandlerWithLevel(wr io.Writer, level slog.Level) slog.Handler { +func LogfmtHandlerWithLevel(wr io.Writer, level *slog.LevelVar) slog.Handler { return slog.NewTextHandler(wr, &slog.HandlerOptions{ ReplaceAttr: builtinReplaceLogfmt, Level: &leveler{level}, diff --git a/log/logger_test.go b/log/logger_test.go index 5310362dc..4d82c710e 100644 --- a/log/logger_test.go +++ b/log/logger_test.go @@ -33,7 +33,9 @@ import ( func TestTerminalHandlerWithAttrs(t *testing.T) { out := new(bytes.Buffer) - handler := NewTerminalHandlerWithLevel(out, LevelTrace, false).WithAttrs([]slog.Attr{slog.String("baz", "bat")}) + var level slog.LevelVar + level.Set(LevelTrace) + handler := NewTerminalHandlerWithLevel(out, &level, false).WithAttrs([]slog.Attr{slog.String("baz", "bat")}) logger := NewLogger(handler) logger.Trace("a message", "foo", "bar") have := out.String() @@ -57,7 +59,11 @@ func TestJSONHandler(t *testing.T) { } out.Reset() - handler = JSONHandlerWithLevel(out, slog.LevelInfo) + + var level slog.LevelVar + level.Set(LevelInfo) + + handler = JSONHandlerWithLevel(out, &level) logger = slog.New(handler) logger.Debug("hi there") if len(out.String()) != 0 { @@ -128,7 +134,9 @@ func TestLoggerOutput(t *testing.T) { ) out := new(bytes.Buffer) - handler := NewTerminalHandlerWithLevel(out, LevelInfo, false) + var level slog.LevelVar + level.Set(LevelInfo) + handler := NewTerminalHandlerWithLevel(out, &level, false) NewLogger(handler).Info("This is a message", "foo", int16(123), "bytes", bb,