Files
3m-ui/backend/internal/node/generator.go
T
dzx941 5b4cb63a73 fix: align listener generation with mihomo official spec
- Port now supports mihomo ports format (single/range/list)
- Add explicit RoutingMark field support
- Auto-propagate UDP flag for vmess/vless/trojan/anytls/trusttunnel
- Update validator to accept port ranges
- Sync visual config and client converter port types
- Rename module from dzx941 to kazeyukiro
- Fix missing ExpiresAt in JWTClaims
- Fix nodeDTO Port type to string
- Fix frontend tsconfig and add missing dependencies
2026-08-16 09:21:42 +08:00

182 lines
4.4 KiB
Go

package node
import (
"encoding/json"
"fmt"
"strings"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
"gopkg.in/yaml.v3"
)
func GenerateMihomoListeners(dbNodes []models.Listener) ([]map[string]interface{}, error) {
list := make([]map[string]interface{}, 0, len(dbNodes))
for _, node := range dbNodes {
if !node.Enabled {
continue
}
if err := ValidateNode(&node); err != nil {
return nil, fmt.Errorf("invalid listener %q: %w", node.Name, err)
}
protocol := strings.ToLower(strings.TrimSpace(node.Protocol))
if !mihomoConfig.IsMihomoListenerProtocol(protocol) {
return nil, fmt.Errorf("unsupported Mihomo listener protocol: %s", protocol)
}
entry := map[string]interface{}{
"name": node.Name,
"type": protocol,
"port": node.Port,
"listen": firstNonEmpty(node.BindAddress, node.Listen, "0.0.0.0"),
}
if node.Rule != "" {
entry["rule"] = node.Rule
}
if node.Proxy != "" {
entry["proxy"] = node.Proxy
}
if node.RoutingMark > 0 {
entry["routing-mark"] = node.RoutingMark
}
if node.UDP && listenerSupportsUDP(protocol) {
entry["udp"] = true
}
options, err := decodeAndExpand(node.Config)
if err != nil {
return nil, fmt.Errorf("invalid config for listener %q: %w", node.Name, err)
}
normalizeListenerUsers(protocol, options)
for key, value := range options {
if value != nil {
entry[key] = value
}
}
list = append(list, entry)
}
return list, nil
}
func GenerateConfigYAML(dbNodes []models.Listener) (string, error) {
listeners, err := GenerateMihomoListeners(dbNodes)
if err != nil {
return "", err
}
data, err := yaml.Marshal(map[string]interface{}{"listeners": listeners})
if err != nil {
return "", fmt.Errorf("failed to marshal listeners yaml: %w", err)
}
return string(data), nil
}
func decodeAndExpand(raw string) (map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return map[string]interface{}{}, nil
}
var rawMap map[string]interface{}
if err := json.Unmarshal([]byte(raw), &rawMap); err != nil {
return nil, err
}
return expandDottedKeys(rawMap), nil
}
func expandDottedKeys(src map[string]interface{}) map[string]interface{} {
dst := make(map[string]interface{}, len(src))
for key, value := range src {
value = expandValue(value)
parts := strings.Split(key, ".")
if len(parts) == 1 {
dst[key] = value
continue
}
cursor := dst
for _, part := range parts[:len(parts)-1] {
next, ok := cursor[part].(map[string]interface{})
if !ok {
next = map[string]interface{}{}
cursor[part] = next
}
cursor = next
}
cursor[parts[len(parts)-1]] = value
}
return dst
}
func expandValue(value interface{}) interface{} {
switch v := value.(type) {
case map[string]interface{}:
return expandDottedKeys(v)
case []interface{}:
for i := range v {
v[i] = expandValue(v[i])
}
}
return value
}
func normalizeListenerUsers(protocol string, options map[string]interface{}) {
users, ok := options["users"].([]interface{})
if !ok || len(users) == 0 {
return
}
if protocol == "anytls" || protocol == "hysteria2" || protocol == "mieru" || protocol == "tuic" {
m := map[string]interface{}{}
for _, raw := range users {
row, ok := raw.(map[string]interface{})
if !ok {
continue
}
name := strings.TrimSpace(fmt.Sprint(row["username"]))
if name == "" {
name = strings.TrimSpace(fmt.Sprint(row["uuid"]))
}
password := row["password"]
if name != "" && password != nil {
m[name] = password
}
}
if len(m) > 0 {
options["users"] = m
}
return
}
for _, raw := range users {
row, ok := raw.(map[string]interface{})
if !ok {
continue
}
switch protocol {
case "vmess":
delete(row, "flow")
case "vless":
delete(row, "alterId")
case "trojan", "shadowquic", "trusttunnel":
delete(row, "uuid")
delete(row, "flow")
delete(row, "alterId")
}
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
// listenerSupportsUDP reports whether the given protocol supports the udp option
// in Mihomo's listener schema.
func listenerSupportsUDP(protocol string) bool {
switch protocol {
case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel":
return true
default:
return false
}
}