- 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
337 lines
9.6 KiB
Go
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
|
|
}
|