snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

201
internal/admin/handlers.go Normal file
View File

@@ -0,0 +1,201 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/middleware"
"github.com/centrifugal/centrifugo/v6/internal/reverseproxy"
"github.com/centrifugal/centrifugo/v6/internal/tools"
"github.com/centrifugal/centrifugo/v6/internal/webui"
"github.com/centrifugal/centrifuge"
"github.com/gorilla/securecookie"
"github.com/rs/zerolog/log"
)
type Config = configtypes.Admin
// Handler handles admin web UI endpoints.
type Handler struct {
mux *http.ServeMux
node *centrifuge.Node
config configtypes.Admin
}
// NewHandler creates new Handler.
func NewHandler(n *centrifuge.Node, apiExecutor *api.Executor, c Config) *Handler {
h := &Handler{
node: n,
config: c,
}
mux := http.NewServeMux()
prefix := strings.TrimRight(h.config.HandlerPrefix, "/")
mux.Handle(prefix+"/admin/init", http.HandlerFunc(h.initHandler))
// Throttle repeated failed password attempts per client IP to slow brute-force
// of admin.password. Only failures count and each IP is tracked independently,
// so a valid login is never throttled - including while another source attacks.
//
// This is best-effort, in-process protection only. The admin auth endpoint is
// intended to be reachable by trusted operators, and Centrifugo recommends
// restricting access to it at the infrastructure level (firewall rules, a
// private network, an authenticating reverse proxy, etc.). The throttle here
// raises the bar for brute-force but is not a substitute for that: the primary
// protection of the admin endpoint must be done at the infrastructure level.
authThrottle := middleware.NewAuthThrottle(10, time.Minute, nil)
mux.Handle(prefix+"/admin/auth", middleware.Post(authThrottle.Middleware(http.HandlerFunc(h.authHandler))))
mux.Handle(prefix+"/admin/api", middleware.Post(h.adminSecureTokenAuth(api.NewHandler(n, apiExecutor, api.Config{}).OldRoute())))
webPrefix := prefix + "/"
if c.WebProxyAddress != "" {
log.Info().Str("address", c.WebProxyAddress).Msg("using admin web reverse proxy")
proxy, err := reverseproxy.New(c.WebProxyAddress)
if err != nil {
log.Fatal().Err(err).Msg("error creating admin web reverse proxy")
}
mux.HandleFunc(webPrefix, reverseproxy.ProxyRequestHandler(proxy))
} else if c.WebPath != "" {
mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(http.Dir(c.WebPath))))
} else {
mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(webui.FS)))
}
h.mux = mux
return h
}
func (s *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(rw, r)
}
func (s *Handler) adminSecureTokenAuth(h http.Handler) http.Handler {
secret := s.config.Secret
insecure := s.config.Insecure
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if insecure {
h.ServeHTTP(w, r)
return
}
if secret == "" {
log.Error().Msg("no admin secret key found in configuration")
w.WriteHeader(http.StatusUnauthorized)
return
}
var token string
authorization := r.Header.Get("Authorization")
if authorization != "" {
parts := strings.Fields(authorization)
if len(parts) != 2 {
w.WriteHeader(http.StatusUnauthorized)
return
}
authMethod := strings.ToLower(parts[0])
if authMethod != "token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
token = parts[1]
}
if token == "" || !checkSecureAdminToken(secret, token) {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}
// initHandler allows to get admin web interface settings.
func (s *Handler) initHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"insecure": s.config.Insecure,
"edition": "oss",
}
secret := s.config.Secret
authenticated := false
if s.config.Insecure {
authenticated = true
} else if secret != "" {
var token string
authorization := r.Header.Get("Authorization")
if authorization != "" {
parts := strings.Fields(authorization)
if len(parts) == 2 && strings.ToLower(parts[0]) == "token" {
token = parts[1]
}
}
if token != "" && checkSecureAdminToken(secret, token) {
authenticated = true
}
}
resp["authenticated"] = authenticated
_ = json.NewEncoder(w).Encode(resp)
}
// authHandler allows to get admin web interface token.
func (s *Handler) authHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024) // 1MB limit.
formPassword := r.FormValue("password")
password := s.config.Password
secret := s.config.Secret
if password == "" || secret == "" {
log.Error().Msg("admin_password and admin_secret must be set in configuration")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if tools.SecureCompareString(formPassword, password) {
w.Header().Set("Content-Type", "application/json")
token, err := generateSecureAdminToken(secret)
if err != nil {
log.Error().Msgf("error generating admin token: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
resp := map[string]string{
"token": token,
}
_ = json.NewEncoder(w).Encode(resp)
return
}
http.Error(w, "Bad Request", http.StatusBadRequest)
}
const (
// AdminTokenKey is a key for admin authorization token.
secureAdminTokenKey = "token"
// AdminTokenValue is a value for secure admin authorization token.
secureAdminTokenValue = "authorized"
)
// generateSecureAdminToken generates admin authentication token.
func generateSecureAdminToken(secret string) (string, error) {
s := securecookie.New([]byte(secret), nil)
return s.Encode(secureAdminTokenKey, secureAdminTokenValue)
}
// checkSecureAdminToken checks admin connection token which Centrifugo returns after admin login.
func checkSecureAdminToken(secret string, token string) bool {
s := securecookie.New([]byte(secret), nil)
var val string
err := s.Decode(secureAdminTokenKey, token, &val)
if err != nil {
return false
}
if val != secureAdminTokenValue {
return false
}
return true
}

View File

@@ -0,0 +1,218 @@
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/centrifugal/centrifuge"
"github.com/stretchr/testify/require"
)
// TestNewHandler ensures NewHandler sets up routes and proxy handling correctly.
func TestNewHandler(t *testing.T) {
node := &centrifuge.Node{}
cfg := Config{HandlerPrefix: "/prefix", WebProxyAddress: "", WebPath: ""}
handler := NewHandler(node, nil, cfg)
require.NotNil(t, handler)
require.NotNil(t, handler.mux)
// Validate init route is registered.
req := httptest.NewRequest("GET", "/prefix/admin/init", nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestInitHandler checks the initHandler returns correct settings.
func TestInitHandler(t *testing.T) {
config := Config{}
handler := &Handler{config: config}
req := httptest.NewRequest("GET", "/admin/init", nil)
resp := httptest.NewRecorder()
handler.initHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]any
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.Equal(t, "oss", response["edition"])
require.Equal(t, false, response["insecure"])
require.Equal(t, false, response["authenticated"])
}
func TestInitHandler_Insecure(t *testing.T) {
config := Config{Insecure: true}
handler := &Handler{config: config}
req := httptest.NewRequest("GET", "/admin/init", nil)
resp := httptest.NewRecorder()
handler.initHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]any
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.Equal(t, "oss", response["edition"])
require.Equal(t, true, response["insecure"])
require.Equal(t, true, response["authenticated"]) // Since insecure on.
}
// TestAuthHandler_NoPasswordOrSecret tests authHandler error when password or secret is missing.
func TestAuthHandler_NoPasswordOrSecret(t *testing.T) {
config := Config{Password: "", Secret: ""}
handler := &Handler{config: config}
req := httptest.NewRequest("POST", "/admin/auth", nil)
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
// TestAuthHandler_ValidPassword tests authHandler token generation with valid password.
func TestAuthHandler_ValidPassword(t *testing.T) {
config := Config{Password: "test-password", Secret: "test-secret"}
handler := &Handler{config: config}
form := url.Values{}
form.Add("password", "test-password")
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var response map[string]string
err := json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.NotEmpty(t, response["token"])
}
// TestAuthHandler_InvalidPassword tests authHandler rejection with invalid password.
func TestAuthHandler_InvalidPassword(t *testing.T) {
config := Config{Password: "test-password", Secret: "test-secret"}
handler := &Handler{config: config}
form := url.Values{}
form.Add("password", "wrong-password")
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
handler.authHandler(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
// TestAuthHandler_Throttled ensures the /admin/auth route throttles repeated
// failed password attempts per client IP, without blocking a valid login from
// another IP while an attacker is brute-forcing.
func TestAuthHandler_Throttled(t *testing.T) {
node := &centrifuge.Node{}
cfg := Config{Password: "test-password", Secret: "test-secret"}
handler := NewHandler(node, nil, cfg)
post := func(ip, password string) int {
form := url.Values{}
form.Add("password", password)
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "10.0.0.1:12345" // trusted local proxy peer, so X-Real-IP is honored.
req.Header.Set("X-Real-IP", ip)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp.Code
}
// The default limit is 10 failures per IP per window.
for i := 0; i < 10; i++ {
require.Equal(t, http.StatusBadRequest, post("6.6.6.6", "wrong"), "attempt %d", i)
}
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "wrong"))
// The attacker cannot tell a correct guess from a throttled one.
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "test-password"))
// A valid login from a different IP still succeeds during the attack.
require.Equal(t, http.StatusOK, post("7.7.7.7", "test-password"))
}
// TestAdminSecureTokenAuth_InsecureMode tests adminSecureTokenAuth allows request in insecure mode.
func TestAdminSecureTokenAuth_InsecureMode(t *testing.T) {
config := Config{Insecure: true}
handler := &Handler{config: config}
// Mocked handler that should be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestAdminSecureTokenAuth_MissingToken tests adminSecureTokenAuth rejection when token is missing.
func TestAdminSecureTokenAuth_MissingToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should not be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusUnauthorized, resp.Code)
}
// TestAdminSecureTokenAuth_ValidToken tests adminSecureTokenAuth with valid token.
func TestAdminSecureTokenAuth_ValidToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should be invoked
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Generate a valid token
token, err := generateSecureAdminToken("test-secret")
require.NoError(t, err)
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
req.Header.Set("Authorization", "token "+token)
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// TestAdminSecureTokenAuth_InvalidToken tests adminSecureTokenAuth rejection with invalid token.
func TestAdminSecureTokenAuth_InvalidToken(t *testing.T) {
config := Config{Secret: "test-secret"}
handler := &Handler{config: config}
// Mocked handler that should not be invoked.
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Fail(t, "final handler should not be invoked")
w.WriteHeader(http.StatusOK)
})
authHandler := handler.adminSecureTokenAuth(finalHandler)
req := httptest.NewRequest("GET", "/admin/api", nil)
req.Header.Set("Authorization", "token invalid-token")
resp := httptest.NewRecorder()
authHandler.ServeHTTP(resp, req)
require.Equal(t, http.StatusUnauthorized, resp.Code)
}