From 104f881c21655286be3eb6c4637bf9aefac2be2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?= Date: Sat, 29 Aug 2026 00:30:06 +0800 Subject: [PATCH] feat(mui): port m-ui domain + protocol Compile/BuildShare --- backend/internal/mui/bridge.go | 304 +++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 backend/internal/mui/bridge.go diff --git a/backend/internal/mui/bridge.go b/backend/internal/mui/bridge.go new file mode 100644 index 0000000..7f595ed --- /dev/null +++ b/backend/internal/mui/bridge.go @@ -0,0 +1,304 @@ +package mui + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "github.com/kazeyukiro/3m-ui/backend/internal/mui/domain" + muiprotocol "github.com/kazeyukiro/3m-ui/backend/internal/mui/protocol" +) + +// Cred is a panel-side credential used when adapting Listeners to m-ui nodes. +type Cred struct { + Username string + Password string + UUID string + Flow string +} + +// ListenerToNode adapts a 3m-ui Listener + credentials into an m-ui domain.Node. +func ListenerToNode(l models.Listener, creds []Cred) (domain.Node, error) { + cfg := map[string]interface{}{} + if strings.TrimSpace(l.Config) != "" { + if err := json.Unmarshal([]byte(l.Config), &cfg); err != nil { + return domain.Node{}, fmt.Errorf("listener config: %w", err) + } + } + proto := domain.ProtocolKind(strings.ToLower(strings.TrimSpace(l.Protocol))) + nodeID := fmt.Sprintf("%d", l.ID) + node := domain.Node{ + ID: nodeID, + Name: l.Name, + Enabled: l.Enabled, + ListenAddress: firstNonEmpty(l.Listen, l.BindAddress, "0.0.0.0"), + Port: firstNonEmpty(strings.TrimSpace(l.PublicPort), strings.TrimSpace(l.Port)), + Protocol: proto, + SchemaVersion: domain.NodeSchemaVersion, + } + + // Access profile + pubPort := uint16(0) + if p := firstNonEmpty(l.PublicPort, l.Port); p != "" { + if n, err := strconv.Atoi(p); err == nil && n > 0 && n < 65536 { + pubPort = uint16(n) + } + } + profile := domain.AccessProfile{ + ID: nodeID + "-default", + NodeID: nodeID, + Name: "default", + Default: true, + PublicHost: strings.TrimSpace(l.PublicHost), + PublicPort: pubPort, + ServerName: strings.TrimSpace(l.AccessSNI), + Fingerprint: strings.TrimSpace(l.ClientFingerprint), + AllowInsecure: boolCfg(cfg, "skip-cert-verify"), + } + if profile.ServerName == "" { + profile.ServerName = strCfg(cfg, "sni", "servername") + } + if profile.Fingerprint == "" { + profile.Fingerprint = strCfg(cfg, "client-fingerprint", "fingerprint") + } + if profile.Fingerprint == "" { + profile.Fingerprint = domain.ClientFingerprint + } + node.AccessProfiles = []domain.AccessProfile{profile} + + // Users from panel credentials + for i, c := range creds { + u := domain.NodeUser{ + ID: fmt.Sprintf("%s-u%d", nodeID, i+1), + NodeID: nodeID, + Name: firstNonEmpty(c.Username, fmt.Sprintf("user-%d", i+1)), + Enabled: true, + } + switch proto { + case domain.ProtocolVLESS: + u.VLESS = &domain.VLESSCredential{UUID: c.UUID, Flow: c.Flow} + case domain.ProtocolVMess: + u.VMess = &domain.VMessCredential{UUID: c.UUID, Cipher: "auto"} + case domain.ProtocolTrojan: + u.Trojan = &domain.TrojanCredential{Password: c.Password} + case domain.ProtocolHysteria2: + u.Hysteria2 = &domain.Hysteria2Credential{Password: c.Password} + case domain.ProtocolShadowsocks: + u.Shadowsocks = &domain.ShadowsocksCredential{Password: c.Password} + } + node.Users = append(node.Users, u) + } + + // Protocol specs from flat Mihomo listener JSON + switch proto { + case domain.ProtocolVLESS: + node.VLESS = decodeVLESS(cfg, l.TLS) + case domain.ProtocolVMess: + node.VMess = decodeVMess(cfg, l.TLS) + case domain.ProtocolTrojan: + node.Trojan = decodeTrojan(cfg, l.TLS) + case domain.ProtocolShadowsocks: + node.Shadowsocks = decodeSS(cfg) + case domain.ProtocolHysteria2: + node.Hysteria2 = decodeHy2(cfg) + default: + return domain.Node{}, fmt.Errorf("protocol %q is not supported by m-ui port", proto) + } + return node, nil +} + +// BuildShare adapts a listener and builds an m-ui share for the first/default user, +// or for all users when building URI lists. +func BuildShares(l models.Listener, publicHost string, creds []Cred) ([]muiprotocol.Share, error) { + node, err := ListenerToNode(l, creds) + if err != nil { + return nil, err + } + if publicHost != "" { + for i := range node.AccessProfiles { + if node.AccessProfiles[i].Default { + node.AccessProfiles[i].PublicHost = publicHost + } + } + } + profile, ok := node.DefaultAccessProfile() + if !ok { + return nil, fmt.Errorf("access profile missing") + } + if profile.PublicHost == "" { + profile.PublicHost = publicHost + } + state := domain.DesiredState{AsOf: time.Now().UTC(), PublicHost: profile.PublicHost} + reg := muiprotocol.DefaultRegistry() + out := make([]muiprotocol.Share, 0, len(node.Users)) + for _, user := range node.Users { + // Ensure user/profile node id match for registry checks. + user.NodeID = node.ID + profile.NodeID = node.ID + s, err := reg.BuildShare(state, node, user, profile) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil +} + +// CompileListener compiles a listener via m-ui protocol modules into a YAML-ready map. +func CompileListener(l models.Listener, creds []Cred) (map[string]interface{}, error) { + node, err := ListenerToNode(l, creds) + if err != nil { + return nil, err + } + compiled, err := muiprotocol.DefaultRegistry().Compile(context.Background(), node, time.Now().UTC()) + if err != nil { + return nil, err + } + raw, err := json.Marshal(compiled) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return m, nil +} + +func decodeVLESS(cfg map[string]interface{}, tls bool) *domain.VLESSSpec { + spec := &domain.VLESSSpec{ + Decryption: strCfg(cfg, "encryption", "decryption"), + Handler: decodeHandler(cfg), + Security: decodeSecurity(cfg, tls), + } + if spec.Decryption == "" { + spec.Decryption = "none" + } + return spec +} + +func decodeVMess(cfg map[string]interface{}, tls bool) *domain.VMessSpec { + return &domain.VMessSpec{Handler: decodeHandler(cfg), Security: decodeSecurity(cfg, tls)} +} + +func decodeTrojan(cfg map[string]interface{}, tls bool) *domain.TrojanSpec { + return &domain.TrojanSpec{Handler: decodeHandler(cfg), Security: decodeSecurity(cfg, tls)} +} + +func decodeSS(cfg map[string]interface{}) *domain.ShadowsocksSpec { + return &domain.ShadowsocksSpec{ + Cipher: strCfg(cfg, "cipher"), + Password: strCfg(cfg, "password"), + } +} + +func decodeHy2(cfg map[string]interface{}) *domain.Hysteria2Spec { + return &domain.Hysteria2Spec{ + Certificate: strCfg(cfg, "certificate"), + PrivateKey: strCfg(cfg, "private-key"), + Up: strCfg(cfg, "up"), + Down: strCfg(cfg, "down"), + Obfs: strCfg(cfg, "obfs"), + ObfsPassword: strCfg(cfg, "obfs-password"), + SkipCertVerify: boolCfg(cfg, "skip-cert-verify"), + } +} + +func decodeHandler(cfg map[string]interface{}) domain.VLESSHandlerSpec { + h := domain.VLESSHandlerSpec{Type: domain.VLESSHandlerRaw} + if path := strCfg(cfg, "ws-path"); path != "" { + h.Type = domain.VLESSHandlerWebSocket + h.WebSocket = &domain.WebSocketSpec{Path: path} + } + if svc := strCfg(cfg, "grpc-service-name"); svc != "" { + h.Type = domain.VLESSHandlerGRPC + h.GRPC = &domain.GRPCSpec{ServiceName: svc} + } + if xhttp, ok := cfg["xhttp-config"].(map[string]interface{}); ok { + h.Type = domain.VLESSHandlerXHTTP + h.XHTTP = &domain.XHTTPConfig{Path: strMap(xhttp, "path"), Host: strMap(xhttp, "host"), Mode: strMap(xhttp, "mode")} + } + return h +} + +func decodeSecurity(cfg map[string]interface{}, tlsFlag bool) domain.VLESSSecuritySpec { + if raw, ok := cfg["reality-config"].(map[string]interface{}); ok && raw != nil { + shortIDs := []string{} + if s, ok := raw["short-id"].(string); ok && s != "" { + shortIDs = []string{s} + } else if arr, ok := raw["short-id"].([]interface{}); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + shortIDs = append(shortIDs, s) + } + } + } + names := []string{} + if arr, ok := raw["server-names"].([]interface{}); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + names = append(names, s) + } + } + } + return domain.VLESSSecuritySpec{ + Type: domain.VLESSSecurityReality, + Reality: &domain.RealityConfig{ + PrivateKey: strMap(raw, "private-key"), + PublicKey: strMap(raw, "public-key"), + ShortIDs: shortIDs, + ServerNames: names, + Destination: strMap(raw, "dest"), + }, + } + } + if tlsFlag || strCfg(cfg, "certificate") != "" { + return domain.VLESSSecuritySpec{ + Type: domain.VLESSSecurityTLS, + TLS: &domain.TLSConfig{ + Certificate: strCfg(cfg, "certificate"), + PrivateKey: strCfg(cfg, "private-key"), + ServerName: strCfg(cfg, "sni", "servername"), + SkipCertVerify: boolCfg(cfg, "skip-cert-verify"), + }, + } + } + return domain.VLESSSecuritySpec{Type: domain.VLESSSecurityNone} +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func strCfg(cfg map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := cfg[k].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func strMap(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +func boolCfg(cfg map[string]interface{}, key string) bool { + b, _ := cfg[key].(bool) + return b +}