Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP. docs: remove third-party panel name references from copy Drop and external m-ui product names from docs, UI strings, and code comments. Keep functional descriptions of server egress and WARP.
384 lines
12 KiB
Go
384 lines
12 KiB
Go
package node
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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/mui"
|
|
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
|
|
"github.com/kazeyukiro/3m-ui/backend/internal/protocol"
|
|
"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.POST("/:id/connection-check", h.CheckConnection)
|
|
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)
|
|
}
|
|
|
|
// RegisterClientRoutes registers only routes that are unique to the node
|
|
// handler. The listener handler already owns the shared CRUD/reload routes.
|
|
func (h *Handler) RegisterClientRoutes(rg *gin.RouterGroup) {
|
|
rg.POST("/:id/connection-check", h.CheckConnection)
|
|
rg.GET("/:id/uri", h.ExportNodeURI)
|
|
rg.POST("/:id/client-access", h.CreateClientAccess)
|
|
}
|
|
|
|
func (h *Handler) ListNodes(c *gin.Context) {
|
|
list, err := h.svc.GetAll()
|
|
if err != nil {
|
|
log.Printf("node list failed: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server 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 := h.svc.Create(&l); err != nil {
|
|
c.JSON(http.StatusBadRequest, 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": "listener not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, l)
|
|
}
|
|
|
|
func (h *Handler) isTrustedHost(host string) bool {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
return false
|
|
}
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
host = strings.Trim(host, "[]")
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
|
}
|
|
return strings.EqualFold(host, "localhost")
|
|
}
|
|
|
|
// ExportNodeURI returns share links (: uri / uris / qr / client_yaml).
|
|
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
|
|
}
|
|
|
|
publicURL := ""
|
|
if config.GlobalConfig != nil {
|
|
publicURL = strings.TrimSpace(config.GlobalConfig.Server.PublicURL)
|
|
}
|
|
ap := protocol.LoadAccessProfile(h.db)
|
|
if ap.PublicHost != "" {
|
|
publicURL = ap.PublicHost
|
|
}
|
|
// Apply global access-profile fields as fallbacks when the per-listener
|
|
// values are empty. Per-listener values always take precedence so admins
|
|
// can still override a panel-wide default for an individual node.
|
|
if ap.PublicPort != "" && strings.TrimSpace(listener.PublicPort) == "" {
|
|
listener.PublicPort = ap.PublicPort
|
|
}
|
|
if ap.SNI != "" && strings.TrimSpace(listener.AccessSNI) == "" {
|
|
listener.AccessSNI = ap.SNI
|
|
}
|
|
if ap.ClientFingerprint != "" && strings.TrimSpace(listener.ClientFingerprint) == "" {
|
|
listener.ClientFingerprint = ap.ClientFingerprint
|
|
}
|
|
if len(ap.ALPN) > 0 && strings.TrimSpace(listener.AccessALPN) == "" {
|
|
listener.AccessALPN = strings.Join(ap.ALPN, ",")
|
|
}
|
|
if host := strings.TrimSpace(listener.PublicHost); host != "" {
|
|
publicURL = host
|
|
}
|
|
host := c.GetHeader("X-Forwarded-Host")
|
|
if host != "" && !h.isTrustedHost(host) {
|
|
host = ""
|
|
}
|
|
if host == "" {
|
|
host = c.Request.Host
|
|
}
|
|
host = netutil.NormalizeHost(host)
|
|
host = normalizeExportHostPrefer(host, listener.BindAddress, listener.Listen, publicURL)
|
|
host = netutil.NormalizeHost(host)
|
|
// If publicURL was explicitly resolved (config / access profile / listener
|
|
// PublicHost), it is authoritative and must override any client-controlled
|
|
// Host header (X-Forwarded-Host / Request.Host) used above. This prevents
|
|
// share links from advertising an attacker-supplied host.
|
|
if publicURL != "" {
|
|
if u, perr := url.Parse(publicURL); perr == nil && u.Host != "" {
|
|
host = netutil.NormalizeHost(u.Host)
|
|
}
|
|
}
|
|
|
|
credentials := []user.Credential{}
|
|
if h.user != nil {
|
|
byListener, err := h.user.ActiveCredentialsByListener()
|
|
if err != nil {
|
|
log.Printf("export-node-uri load credentials failed: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
|
return
|
|
}
|
|
credentials = byListener[listener.ID]
|
|
}
|
|
|
|
// Three-tier share export: panel protocol port -> 3m-ui registry -> legacy URIs.
|
|
//
|
|
// Follow-up (P3-9 / X-MUI-2): the three tiers produce inconsistent output
|
|
// shapes (panel emits URIs + client YAML from the protocol port; the
|
|
// 3m-ui registry emits its own URI format; the legacy builder emits
|
|
// plain URIs only). When the first tier succeeds the client gets the
|
|
// panel shape, otherwise it silently degrades to a different shape for
|
|
// the same listener. Unifying the output contract across tiers is
|
|
// tracked as a separate design task.
|
|
var uris []string
|
|
var clientYAML, primary string
|
|
muiCreds := make([]mui.Cred, 0, len(credentials))
|
|
for _, c := range credentials {
|
|
muiCreds = append(muiCreds, mui.Cred{Username: c.Username, Password: c.Password, UUID: c.UUID})
|
|
}
|
|
creds := make([]protocol.UserCred, 0, len(credentials))
|
|
for _, c := range credentials {
|
|
creds = append(creds, protocol.UserCred{Username: c.Username, Password: c.Password, UUID: c.UUID})
|
|
}
|
|
if shares, err := mui.BuildShares(*listener, host, muiCreds); err == nil && len(shares) > 0 {
|
|
uris = make([]string, 0, len(shares))
|
|
for _, s := range shares {
|
|
if s.URI != "" {
|
|
uris = append(uris, s.URI)
|
|
}
|
|
if clientYAML == "" && len(s.ClientYAML) > 0 {
|
|
clientYAML = string(s.ClientYAML)
|
|
}
|
|
}
|
|
if len(uris) > 0 {
|
|
primary = uris[0]
|
|
}
|
|
}
|
|
// Fall through tiers when panel produced neither a URI nor client YAML.
|
|
if primary == "" && clientYAML == "" {
|
|
if shares, err := protocol.ExportShares(*listener, host, creds); err == nil && len(shares) > 0 {
|
|
uris = make([]string, 0, len(shares))
|
|
for _, s := range shares {
|
|
if s.URI != "" {
|
|
uris = append(uris, s.URI)
|
|
}
|
|
if clientYAML == "" && s.ClientYAML != "" {
|
|
clientYAML = s.ClientYAML
|
|
}
|
|
}
|
|
if len(uris) > 0 {
|
|
primary = uris[0]
|
|
}
|
|
}
|
|
}
|
|
// Tier 2 covers a different protocol set than the legacy builders: vmess,
|
|
// snell, shadowquic, mieru, sudoku and trusttunnel compile to client YAML
|
|
// but never produce a share URI there. Gating this tier on the YAML too
|
|
// would leave those listeners reporting no link at all, even though
|
|
// ClientURIsWithCredentials exports one — so it runs whenever the URI is
|
|
// still missing, and only replaces the YAML if none arrived.
|
|
if primary == "" {
|
|
legacy, lerr := ClientURIsWithCredentials(*listener, host, credentials)
|
|
if lerr != nil && clientYAML == "" {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": lerr.Error()})
|
|
return
|
|
}
|
|
if lerr == nil {
|
|
uris = legacy
|
|
if uris == nil {
|
|
uris = []string{}
|
|
}
|
|
if len(uris) > 0 {
|
|
primary = uris[0]
|
|
}
|
|
}
|
|
if clientYAML == "" {
|
|
if y, err := converter.ExportClientYAML(*listener, host, credentials); err == nil {
|
|
clientYAML = y
|
|
}
|
|
}
|
|
}
|
|
if clientYAML == "" {
|
|
if y, err := converter.ExportClientYAML(*listener, host, credentials); err == nil {
|
|
clientYAML = y
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"name": listener.Name,
|
|
"protocol": listener.Protocol,
|
|
"uri": primary,
|
|
"uris": uris,
|
|
"qr_content": primary,
|
|
"client_yaml": clientYAML,
|
|
"hint": emptyURIHint(len(uris), len(credentials)),
|
|
"typed": true,
|
|
})
|
|
}
|
|
|
|
func emptyURIHint(uriCount, credCount int) string {
|
|
if uriCount > 0 {
|
|
return ""
|
|
}
|
|
if credCount == 0 {
|
|
return "no active users bound to this listener; bind users in User Management"
|
|
}
|
|
return "could not build share links for this protocol/config"
|
|
}
|
|
|
|
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 := h.svc.Update(&l); err != nil {
|
|
c.JSON(http.StatusBadRequest, 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.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
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{"ok": true})
|
|
}
|
|
|
|
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 node id"})
|
|
return
|
|
}
|
|
listener, err := h.svc.GetByID(uint(id))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
_ = c.ShouldBindJSON(&body)
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
name = listener.Name + "-access"
|
|
}
|
|
buf := make([]byte, 24)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
|
return
|
|
}
|
|
db := h.db
|
|
if db == nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
|
|
return
|
|
}
|
|
token := models.AccessToken{Name: name, Token: hex.EncodeToString(buf), Enabled: true, ListenerID: listener.ID}
|
|
if err := db.Create(&token).Error; err != nil {
|
|
log.Printf("node client-access create failed: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server 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"),
|
|
}
|
|
}
|