Files
3m-ui/backend/internal/node/validator.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

337 lines
9.6 KiB
Go

package node
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"github.com/kazeyukiro/3m-ui/backend/internal/credentials"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
)
func ValidateNode(l *models.Listener) error {
l.Name = strings.TrimSpace(l.Name)
if l.Name == "" {
return fmt.Errorf("listener name cannot be empty")
}
proto := strings.ToLower(strings.TrimSpace(l.Protocol))
if proto == "" {
proto = strings.ToLower(strings.TrimSpace(l.Type))
}
if !mihomoConfig.IsMihomoListenerProtocol(proto) {
return fmt.Errorf("unsupported Mihomo listener protocol: %s", proto)
}
l.Protocol, l.Type = proto, proto
if !isValidPortString(l.Port) {
return fmt.Errorf("port must be a valid port number, range (e.g. 8080-8090), or comma-separated list")
}
l.BindAddress = strings.TrimSpace(l.BindAddress)
if l.BindAddress == "" {
l.BindAddress = strings.TrimSpace(l.Listen)
}
if l.BindAddress == "" {
l.BindAddress = "0.0.0.0"
}
if l.RoutingMark < 0 {
return fmt.Errorf("routing-mark must be non-negative")
}
if err := credentials.EnsureListenerCredentials(l); err != nil {
return err
}
cfg, err := decodeConfig(l.Config)
if err != nil {
return err
}
if err := validateSchema(proto, cfg); err != nil {
return err
}
return validateProtocolSpecific(proto, cfg)
}
// isValidPortString validates mihomo's "ports format":
// - single port: "8080"
// - port range: "8080-8090"
// - port list: "8080,8081,8082"
func isValidPortString(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
// Comma-separated list
if strings.Contains(s, ",") {
parts := strings.Split(s, ",")
for _, p := range parts {
if !isValidPortString(strings.TrimSpace(p)) {
return false
}
}
return true
}
// Range
if strings.Contains(s, "-") {
parts := strings.SplitN(s, "-", 2)
if len(parts) != 2 {
return false
}
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil || start < 1 || end > 65535 || start > end {
return false
}
return true
}
// Single port
port, err := strconv.Atoi(s)
if err != nil || port < 1 || port > 65535 {
return false
}
return true
}
func decodeConfig(raw string) (map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return map[string]interface{}{}, nil
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, fmt.Errorf("invalid listener configuration: %w", err)
}
if cfg == nil {
return map[string]interface{}{}, nil
}
return cfg, nil
}
func validateSchema(proto string, cfg map[string]interface{}) error {
schema, ok := mihomoConfig.GetMihomoListenerSchema(proto)
if !ok {
return fmt.Errorf("no listener schema registered for protocol %s", proto)
}
return validateObject(proto, "", cfg, schema.Fields, schema.NestedFields)
}
func validateObject(proto, prefix string, value map[string]interface{}, allowed map[string]struct{}, nested map[string]map[string]struct{}) error {
for key, child := range value {
if _, ok := allowed[key]; !ok {
return fmt.Errorf("%s listener: unsupported field %q", proto, joinField(prefix, key))
}
if childMap, ok := child.(map[string]interface{}); ok {
if key == "users" && mapUserProtocols[proto] {
continue
}
childAllowed, hasNestedSchema := nested[key]
if !hasNestedSchema {
return fmt.Errorf("%s listener: field %q does not accept an object", proto, joinField(prefix, key))
}
childNested := make(map[string]map[string]struct{})
for nestedKey, nestedAllowed := range nested {
if strings.HasPrefix(nestedKey, key+".") {
childNested[strings.TrimPrefix(nestedKey, key+".")] = nestedAllowed
}
}
if err := validateObject(proto, joinField(prefix, key), childMap, childAllowed, childNested); err != nil {
return err
}
}
}
return nil
}
var mapUserProtocols = map[string]bool{"anytls": true, "hysteria2": true, "mieru": true, "tuic": true}
func joinField(prefix, field string) string {
if prefix == "" {
return field
}
return prefix + "." + field
}
func validateProtocolSpecific(proto string, cfg map[string]interface{}) error {
switch proto {
case "anytls":
return validateAnyTLS(cfg)
case "snell":
if value, ok := numeric(cfg["version"]); ok && (value < 1 || value > 5) {
return fmt.Errorf("snell version must be between 1 and 5")
}
case "hysteria2":
if obfs, ok := cfg["obfs"].(string); ok && obfs != "" && obfs != "salamander" {
return fmt.Errorf("hysteria2 obfs must be salamander")
}
if !hasCertificatePair(cfg) && !boolValue(cfg["allow-insecure"]) {
return fmt.Errorf("hysteria2 listener requires certificate/private-key or allow-insecure")
}
if users, ok := cfg["users"].(map[string]interface{}); !ok || len(users) == 0 {
return fmt.Errorf("hysteria2 listener requires at least one user")
}
case "trusttunnel":
if !hasCertificatePair(cfg) {
return fmt.Errorf("trusttunnel listener requires certificate and private-key")
}
case "tuic":
users, token := hasNonEmpty(cfg["users"]), hasNonEmpty(cfg["token"])
if users == token {
return fmt.Errorf("tuic listener must configure exactly one of users (TUIC V5) or token (TUIC V4)")
}
if users {
if values, ok := cfg["users"].(map[string]interface{}); !ok || len(values) == 0 {
return fmt.Errorf("tuic V5 listener users must be a username-to-password map")
}
}
case "vless", "vmess":
users, ok := cfg["users"].([]interface{})
if !ok || len(users) == 0 {
return fmt.Errorf("%s listener requires at least one user", proto)
}
for i, user := range users {
if err := validateUserRow(proto, i, user, true); err != nil {
return err
}
}
case "trojan", "shadowquic":
users, ok := cfg["users"].([]interface{})
if !ok || len(users) == 0 {
return fmt.Errorf("%s listener requires at least one user", proto)
}
for i, user := range users {
if err := validateUserRow(proto, i, user, false); err != nil {
return err
}
}
case "mieru":
if users, ok := cfg["users"].(map[string]interface{}); !ok || len(users) == 0 {
return fmt.Errorf("mieru listener requires at least one user")
}
case "sudoku":
min, minOK := numeric(cfg["padding-min"])
max, maxOK := numeric(cfg["padding-max"])
if minOK && maxOK && max < min {
return fmt.Errorf("sudoku padding-max must be greater than or equal to padding-min")
}
}
return nil
}
func validateAnyTLS(cfg map[string]interface{}) error {
cert, key := hasString(cfg["certificate"]), hasString(cfg["private-key"])
if cert != key {
return fmt.Errorf("anytls listener requires certificate and private-key together")
}
shadow, res, jls := enabledObject(cfg["shadow-tls"]), enabledObject(cfg["res-tls"]), enabledObject(cfg["jls-config"])
secureModes := 0
if shadow {
secureModes++
}
if res {
secureModes++
}
if jls {
secureModes++
}
if cert {
secureModes++
}
if secureModes > 1 {
return fmt.Errorf("anytls listener TLS modes are mutually exclusive: use certificate/private-key, shadow-tls, res-tls, or jls-config")
}
if !boolValue(cfg["allow-insecure"]) && secureModes == 0 {
return fmt.Errorf("anytls listener requires certificate/private-key, shadow-tls, res-tls, jls-config, or allow-insecure=true")
}
if auth, ok := cfg["client-auth-type"].(string); ok && (auth == "verify-if-given" || auth == "require-and-verify") && !hasString(cfg["client-auth-cert"]) {
return fmt.Errorf("anytls listener client-auth-cert is required for %s", auth)
}
users, ok := cfg["users"].(map[string]interface{})
if !ok || len(users) == 0 {
return fmt.Errorf("anytls listener requires at least one user")
}
for username, password := range users {
if strings.TrimSpace(username) == "" || !hasString(password) {
return fmt.Errorf("anytls listener users must contain non-empty username/password pairs")
}
}
return nil
}
func enabledObject(v interface{}) bool {
m, ok := v.(map[string]interface{})
return ok && boolValue(m["enable"])
}
func validateUserRow(proto string, index int, raw interface{}, uuidMode bool) error {
row, ok := raw.(map[string]interface{})
if !ok {
return fmt.Errorf("%s listener users[%d] must be an object", proto, index)
}
allowed := map[string]struct{}{"username": {}}
if uuidMode {
allowed["uuid"] = struct{}{}
if proto == "vmess" {
allowed["alterId"] = struct{}{}
} else {
allowed["flow"] = struct{}{}
}
} else {
allowed["password"] = struct{}{}
}
for key := range row {
if _, ok := allowed[key]; !ok {
return fmt.Errorf("%s listener users[%d]: unsupported field %q", proto, index, key)
}
}
if !hasString(row["username"]) {
return fmt.Errorf("%s listener users[%d] requires username", proto, index)
}
if uuidMode && !hasString(row["uuid"]) {
return fmt.Errorf("%s listener users[%d] requires uuid", proto, index)
}
if !uuidMode && !hasString(row["password"]) {
return fmt.Errorf("%s listener users[%d] requires password", proto, index)
}
return nil
}
func hasCertificatePair(cfg map[string]interface{}) bool {
return hasString(cfg["certificate"]) && hasString(cfg["private-key"])
}
func hasString(value interface{}) bool {
s, ok := value.(string)
return ok && strings.TrimSpace(s) != ""
}
func hasNonEmpty(value interface{}) bool {
if value == nil {
return false
}
if s, ok := value.(string); ok {
return strings.TrimSpace(s) != ""
}
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.Slice, reflect.Map:
return v.Len() > 0
default:
return true
}
}
func boolValue(v interface{}) bool {
b, _ := v.(bool)
return b
}
func numeric(v interface{}) (float64, bool) {
n, ok := v.(float64)
return n, ok
}