Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions api/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,49 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"

"github.com/vechain/thor/v2/api/admin/apilogs"
"github.com/vechain/thor/v2/api/admin/featuregate"
"github.com/vechain/thor/v2/api/admin/loglevel"
"github.com/vechain/thor/v2/api/admin/pprof"
"github.com/vechain/thor/v2/cmd/thor/node"

healthAPI "github.com/vechain/thor/v2/api/admin/health"
)

func NewHTTPHandler(logLevel *slog.LevelVar, health *healthAPI.Health, apiLogsToggle *atomic.Bool, master *node.Master) http.HandlerFunc {
func NewHTTPHandler(
logLevel *slog.LevelVar,
health *healthAPI.Health,
apiLogsGate *featuregate.Gate,
txpoolAPIGate *featuregate.Gate,
pprofGate *featuregate.Gate,
master *node.Master,
) http.HandlerFunc {
router := mux.NewRouter()
subRouter := router.PathPrefix("/admin").Subrouter()

loglevel.New(logLevel).Mount(subRouter, "/loglevel")
healthAPI.NewAPI(health, master).Mount(subRouter, "/health")
apilogs.New(apiLogsToggle).Mount(subRouter, "/apilogs")

reg := featuregate.NewRegistry()
reg.Add(apiLogsGate)
reg.Add(txpoolAPIGate)
reg.Add(pprofGate)
reg.MountAPI(subRouter, "/features")

// Legacy alias — /admin/apilogs predates the unified /admin/features
// namespace; kept for backward compatibility with existing clients.
reg.MountLegacyAlias(subRouter, "/apilogs", "apilogs")

// pprof's /debug/pprof/* handlers must live on the router root
// (net/http/pprof.Index hard-codes the prefix). Gated by pprofGate.
pprof.MountHandlers(router, pprofGate)

handler := handlers.CompressHandler(router)
return handler.ServeHTTP
}

// NewGate builds a featuregate.Gate pre-wired with the admin audit metric.
// Callers don't need to know about the metric layer; this keeps the
// "every admin toggle is audited" invariant inside this package.
func NewGate(name string, enabled *atomic.Bool) *featuregate.Gate {
return featuregate.New(name, enabled, recordToggle)
}
100 changes: 100 additions & 0 deletions api/admin/admin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 The VeChainThor developers

// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying
// file LICENSE or <https://www.gnu.org/licenses/lgpl-3.0.html>

package admin_test

import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/ethereum/go-ethereum/crypto"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/vechain/thor/v2/api"
"github.com/vechain/thor/v2/api/admin"
healthAPI "github.com/vechain/thor/v2/api/admin/health"
apinode "github.com/vechain/thor/v2/api/node"
"github.com/vechain/thor/v2/cmd/thor/node"
"github.com/vechain/thor/v2/comm"
"github.com/vechain/thor/v2/test/testchain"
"github.com/vechain/thor/v2/thor"
"github.com/vechain/thor/v2/txpool"
)

// TestAdminToggleAffectsNodeAPI is the e2e contract test: flipping
// /admin/features/txpool-api via the admin server must immediately gate
// /node/txpool on the business API server, via the shared atomic.Bool.
func TestAdminToggleAffectsNodeAPI(t *testing.T) {
chain, err := testchain.NewDefault()
require.NoError(t, err)
pool := txpool.New(chain.Repo(), chain.Stater(), txpool.Options{
Limit: 100, LimitPerAccount: 16, MaxLifetime: time.Minute,
}, &thor.NoFork)
defer pool.Close()

enableTxPool := &atomic.Bool{}
enableTxPool.Store(true)
txpoolGate := admin.NewGate("txpool-api", enableTxPool)
apiLogsGate := admin.NewGate("apilogs", &atomic.Bool{})
pprofGate := admin.NewGate("pprof", &atomic.Bool{})

// Admin server
privKey, _ := crypto.HexToECDSA("99f0500549792796c14fed62011a51081dc5b5e68fe8bd8a13b86be829c4fd36")
master := &node.Master{PrivateKey: privKey}
adminHandler := admin.NewHTTPHandler(
&slog.LevelVar{},
healthAPI.New(chain.Repo(), comm.New(chain.Repo(), pool)),
apiLogsGate, txpoolGate, pprofGate,
master,
)
adminTS := httptest.NewServer(adminHandler)
defer adminTS.Close()

// Business API server, sharing enableTxPool with the admin gate
nodeRouter := mux.NewRouter()
apinode.New(comm.New(chain.Repo(), pool), pool, enableTxPool).Mount(nodeRouter, "/node")
nodeTS := httptest.NewServer(nodeRouter)
defer nodeTS.Close()

// Sanity: initially enabled
require.Equal(t, http.StatusOK, getStatus(t, nodeTS.URL+"/node/txpool"))

// Toggle off via admin
body, _ := json.Marshal(api.ToggleStatus{Enabled: false})
resp, err := http.Post(adminTS.URL+"/admin/features/txpool-api", "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)

// Business endpoint now 503
assert.Equal(t, http.StatusServiceUnavailable, getStatus(t, nodeTS.URL+"/node/txpool"))

// Toggle back on via admin
body, _ = json.Marshal(api.ToggleStatus{Enabled: true})
resp, err = http.Post(adminTS.URL+"/admin/features/txpool-api", "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)

assert.Equal(t, http.StatusOK, getStatus(t, nodeTS.URL+"/node/txpool"))
}

func getStatus(t *testing.T, url string) int {
t.Helper()
res, err := http.Get(url) //#nosec G107
require.NoError(t, err)
defer res.Body.Close()
_, _ = io.Copy(io.Discard, res.Body)
return res.StatusCode
}
68 changes: 0 additions & 68 deletions api/admin/apilogs/api_logs.go

This file was deleted.

93 changes: 0 additions & 93 deletions api/admin/apilogs/api_logs_test.go

This file was deleted.

Loading
Loading