Files
3m-ui/backend/internal/cluster/api.go
T
風雪 58f69e3f9d fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
fix(backend): XFF-aware rate limiting, host trust, error sanitization
- M-3: clientIdentifier now trusts X-Forwarded-For (right-most valid
IP) only when the direct RemoteAddr is loopback/private/link-local
(typical same-host reverse proxy). Public exposure still rejects
spoofed XFF, restoring per-client granularity behind nginx/caddy.
- LOW-10: node ExportNodeURI fell back to c.Request.Host (client
controlled) even when publicURL was configured. Now publicURL wins
over client headers when set.
- LOW-11: Replace direct err.Error() in 500/502 responses with generic
messages; real errors logged server-side via log.Printf. Prevents
leakage of DB paths, SQL fragments, remote server responses.
4xx business messages (invalid id, not found) preserved.
2026-08-29 09:39:00 +00:00

307 lines
6.8 KiB
Go

package cluster
import (
"errors"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("", h.List)
rg.POST("", h.Create)
rg.POST("/health-all", h.HealthAll)
rg.PUT("/:id", h.Update)
rg.DELETE("/:id", h.Delete)
rg.POST("/:id/health", h.Health)
rg.GET("/:id/dashboard", h.RemoteDashboard)
rg.GET("/:id/users", h.RemoteUsers)
rg.GET("/:id/nodes", h.RemoteNodes)
rg.POST("/:id/nodes", h.RemoteCreateNode)
rg.PUT("/:id/nodes/:nodeId", h.RemoteUpdateNode)
rg.DELETE("/:id/nodes/:nodeId", h.RemoteDeleteNode)
rg.POST("/:id/mihomo/start", h.RemoteStart)
rg.POST("/:id/mihomo/stop", h.RemoteStop)
rg.POST("/:id/mihomo/restart", h.RemoteRestart)
rg.POST("/:id/proxy", h.Proxy)
}
func parseID(c *gin.Context) (uint, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return 0, false
}
return uint(id), true
}
func (h *Handler) List(c *gin.Context) {
rows, err := h.svc.List()
if err != nil {
log.Printf("cluster list failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, rows)
}
func (h *Handler) Create(c *gin.Context) {
var in CreateInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
row, err := h.svc.Create(in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, row)
}
func (h *Handler) Update(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var in UpdateInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
row, err := h.svc.Update(id, in)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}
func (h *Handler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.svc.Delete(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (h *Handler) Health(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
row, err := h.svc.HealthCheck(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
log.Printf("cluster health check failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, row)
}
func (h *Handler) HealthAll(c *gin.Context) {
rows, err := h.svc.HealthCheckAll()
if err != nil {
log.Printf("cluster health-check-all failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, rows)
}
func (h *Handler) RemoteNodes(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.FetchRemoteNodes(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteDashboard(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.FetchDashboard(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteUsers(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.FetchUsers(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteCreateNode(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
body, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status, raw, err := h.svc.ProxyRemote(id, http.MethodPost, "/api/v1/nodes", body)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteUpdateNode(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
nodeID := c.Param("nodeId")
body, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status, raw, err := h.svc.ProxyRemote(id, http.MethodPut, "/api/v1/nodes/"+nodeID, body)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteDeleteNode(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
nodeID := c.Param("nodeId")
status, raw, err := h.svc.ProxyRemote(id, http.MethodDelete, "/api/v1/nodes/"+nodeID, nil)
if err != nil {
writeRemoteErr(c, err)
return
}
if len(raw) == 0 {
c.JSON(status, gin.H{"status": "ok"})
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteStart(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.StartCore(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteStop(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.StopCore(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteRestart(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.RestartCore(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
// Proxy is a restricted generic forwarder (allowlisted paths only).
func (h *Handler) Proxy(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "method and path required"})
return
}
method := body.Method
if method == "" {
method = http.MethodGet
}
var payload []byte
if body.Body != "" {
payload = []byte(body.Body)
}
status, raw, err := h.svc.ProxyRemote(id, method, body.Path, payload)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func writeRemoteErr(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
log.Printf("cluster remote operation failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream cluster operation failed"})
}