package node import ( "crypto/rand" "encoding/hex" "net" "net/http" "net/url" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/kazeyukiro/3m-ui/backend/internal/config" "github.com/kazeyukiro/3m-ui/backend/internal/converter" "github.com/kazeyukiro/3m-ui/backend/internal/database/models" "github.com/kazeyukiro/3m-ui/backend/internal/user" "gorm.io/gorm" ) // Handler serves node/listener HTTP endpoints with injected dependencies. type Handler struct { svc *Service user *user.Service db *gorm.DB } // NewHandler constructs a node HTTP handler. func NewHandler(svc *Service, userSvc *user.Service, db *gorm.DB) *Handler { return &Handler{svc: svc, user: userSvc, db: db} } // RegisterRoutes registers node routes on the provided group. func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("", h.ListNodes) rg.POST("", h.CreateNode) rg.GET("/:id", h.GetNode) rg.GET("/:id/uri", h.ExportNodeURI) rg.PUT("/:id", h.UpdateNode) rg.DELETE("/:id", h.DeleteNode) rg.POST("/:id/reload", h.ReloadNode) rg.POST("/:id/client-access", h.CreateClientAccess) } func (h *Handler) ListNodes(c *gin.Context) { list, err := h.svc.GetAll() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, list) } func (h *Handler) CreateNode(c *gin.Context) { var l models.Listener if err := c.ShouldBindJSON(&l); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := ValidateNode(&l); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.svc.Create(&l); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, l) } func (h *Handler) GetNode(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) return } l, err := h.svc.GetByID(uint(id)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, l) } // isTrustedHost validates that the provided host is acceptable for URI export. // It rejects path traversal, illegal characters, and hosts that do not match // the configured public_url (if set). func (h *Handler) isTrustedHost(host string) bool { host = strings.TrimSpace(host) if host == "" { return false } // Strip port for comparison if h, _, err := net.SplitHostPort(host); err == nil { host = h } // Reject anything that looks like a path or contains suspicious characters if strings.ContainsAny(host, "/\\?#@") || strings.Contains(host, "..") { return false } // If public_url is configured, only allow hosts that match it if config.GlobalConfig != nil && config.GlobalConfig.Server.PublicURL != "" { expected := config.GlobalConfig.Server.PublicURL if u, err := url.Parse(expected); err == nil && u.Host != "" { expected = u.Host } if eh, _, err := net.SplitHostPort(expected); err == nil { expected = eh } if !strings.EqualFold(host, expected) { return false } } return true } // ExportNodeURI returns one URI per active client credential. Public host // comes from the request Host; wildcard listener addresses are never exposed. func (h *Handler) ExportNodeURI(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) return } listener, err := h.svc.GetByID(uint(id)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"}) return } if !listener.Enabled { c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"}) return } // Prefer X-Forwarded-Host only when it passes trust validation. host := c.GetHeader("X-Forwarded-Host") if host != "" && !h.isTrustedHost(host) { host = "" } if host == "" { host = c.Request.Host } if host != "" { if parsed, err := url.Parse("//" + host); err == nil && parsed.Hostname() != "" { host = parsed.Hostname() } } credentials := []user.Credential{} if h.user != nil { byListener, err := h.user.ActiveCredentialsByListener() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load active credentials: " + err.Error()}) return } credentials = byListener[listener.ID] } uris, err := ClientURIsWithCredentials(*listener, strings.TrimSpace(host), credentials) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"name": listener.Name, "protocol": listener.Protocol, "uris": uris}) } func (h *Handler) UpdateNode(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) return } var l models.Listener if err := c.ShouldBindJSON(&l); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } l.ID = uint(id) if err := ValidateNode(&l); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.svc.Update(&l); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, l) } func (h *Handler) DeleteNode(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) return } if err := h.svc.Delete(uint(id)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node deleted"}) } func (h *Handler) ReloadNode(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) return } if err := h.svc.TriggerReload(uint(id)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node reloaded"}) } func (h *Handler) CreateClientAccess(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"}) return } db := h.db if db == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) return } var listener models.Listener if err := db.First(&listener, uint(id)).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"}) return } if !listener.Enabled { c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"}) return } var existing models.AccessToken if err := db.Where("listener_id = ? AND enabled = ?", listener.ID, true).First(&existing).Error; err == nil { if existing.ExpireAt == nil || existing.ExpireAt.After(time.Now()) { c.JSON(http.StatusOK, h.clientAccessResponse(c, existing)) return } } buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secure token"}) return } token := models.AccessToken{Name: listener.Name, Token: hex.EncodeToString(buf), Enabled: true, ListenerID: listener.ID} if err := db.Create(&token).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, h.clientAccessResponse(c, token)) } func (h *Handler) clientAccessResponse(c *gin.Context, token models.AccessToken) gin.H { cfg := config.GlobalConfig return gin.H{ "id": token.ID, "name": token.Name, "token": token.Token, "type": "listener", "listener_id": token.ListenerID, "mihomo_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "mihomo"), "clash_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "clash"), "singbox_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "singbox"), "shadowrocket_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "shadowrocket"), } }