483 lines
15 KiB
Go
483 lines
15 KiB
Go
package node
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"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"
|
|
}
|
|
// Strip brackets; accept IPv4 / IPv6 (including :: and 0.0.0.0).
|
|
l.BindAddress = strings.Trim(l.BindAddress, "[]")
|
|
if net.ParseIP(l.BindAddress) == nil {
|
|
return fmt.Errorf("bind address must be a valid IPv4 or IPv6 address (got %q)", l.BindAddress)
|
|
}
|
|
if h := strings.TrimSpace(l.PublicHost); h != "" {
|
|
// Public host may be domain or IP — normalize bracketed IPv6.
|
|
l.PublicHost = strings.Trim(h, "[]")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func isValidPortString(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return false
|
|
}
|
|
if strings.Contains(s, ",") {
|
|
parts := strings.Split(s, ",")
|
|
for _, p := range parts {
|
|
if !isValidPortString(strings.TrimSpace(p)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// mapUserProtocols lists protocols whose `users` field is a free-form map
|
|
// (UUID→password) or array of {username,password,...} objects, where the
|
|
// per-user keys must not be validated against the schema's NestedFields.
|
|
// - anytls / hysteria2 / mieru: map UUID→password
|
|
// - tuic (generic) / tuic-v5: v5 form is map UUID→password per wiki
|
|
// - tuic-v4 is intentionally NOT here — v4 uses `token: [string]`, and any
|
|
// `users` field on v4 is rejected by validateProtocolSpecific (line ~190).
|
|
var mapUserProtocols = map[string]bool{
|
|
"anytls": true,
|
|
"hysteria2": true,
|
|
"mieru": true,
|
|
"tuic": true,
|
|
"tuic-v5": 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")
|
|
}
|
|
// Official mihomo Hysteria2 inbound requires certificate/private-key; there is no allow-insecure field.
|
|
if !hasCertificatePair(cfg) {
|
|
return fmt.Errorf("hysteria2 listener requires certificate and private-key")
|
|
}
|
|
if users, ok := cfg["users"].(map[string]interface{}); !ok || len(users) == 0 {
|
|
return fmt.Errorf("hysteria2 listener requires at least one user")
|
|
}
|
|
case "tuic", "tuic-v4", "tuic-v5":
|
|
// Panel uses tuic-v4 / tuic-v5; Mihomo YAML type is always "tuic".
|
|
users, token := hasNonEmpty(cfg["users"]), hasNonEmpty(cfg["token"])
|
|
switch proto {
|
|
case "tuic-v4":
|
|
if !token {
|
|
return fmt.Errorf("tuic-v4 listener requires a non-empty token array")
|
|
}
|
|
if users {
|
|
return fmt.Errorf("tuic-v4 listener must not set users (token-only)")
|
|
}
|
|
case "tuic-v5":
|
|
if !users {
|
|
return fmt.Errorf("tuic-v5 listener requires users map (UUID → password)")
|
|
}
|
|
if token {
|
|
return fmt.Errorf("tuic-v5 listener must not set token (users-only)")
|
|
}
|
|
if values, ok := cfg["users"].(map[string]interface{}); !ok || len(values) == 0 {
|
|
return fmt.Errorf("tuic-v5 listener users must be a UUID-to-password map")
|
|
}
|
|
default: // "tuic" legacy: exactly one of token or users
|
|
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":
|
|
if err := validateServerTLSModes(proto, cfg); err != nil {
|
|
return err
|
|
}
|
|
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":
|
|
if err := validateServerTLSModes(proto, cfg); err != nil {
|
|
return err
|
|
}
|
|
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 "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")
|
|
}
|
|
if transport, ok := cfg["transport"].(string); ok && transport != "" {
|
|
if !strings.EqualFold(transport, "TCP") && !strings.EqualFold(transport, "UDP") {
|
|
return fmt.Errorf("mieru transport must be TCP or UDP")
|
|
}
|
|
}
|
|
case "sudoku":
|
|
if aead, ok := cfg["aead-method"].(string); ok && aead != "" {
|
|
switch aead {
|
|
case "chacha20-poly1305", "aes-128-gcm", "none":
|
|
default:
|
|
return fmt.Errorf("sudoku aead-method must be chacha20-poly1305, aes-128-gcm, or none")
|
|
}
|
|
}
|
|
if table, ok := cfg["table-type"].(string); ok && table != "" {
|
|
switch table {
|
|
case "prefer_ascii", "prefer_entropy", "up_ascii_down_entropy", "up_entropy_down_ascii":
|
|
default:
|
|
return fmt.Errorf("sudoku table-type is invalid")
|
|
}
|
|
}
|
|
min, minOK := numeric(cfg["padding-min"])
|
|
max, maxOK := numeric(cfg["padding-max"])
|
|
if minOK && (min < 0 || min > 100) {
|
|
return fmt.Errorf("sudoku padding-min must be between 0 and 100")
|
|
}
|
|
if maxOK && (max < 0 || max > 100) {
|
|
return fmt.Errorf("sudoku padding-max must be between 0 and 100")
|
|
}
|
|
if minOK && maxOK && max < min {
|
|
return fmt.Errorf("sudoku padding-max must be greater than or equal to padding-min")
|
|
}
|
|
if mode, ok := cfg["httpmask"].(map[string]interface{}); ok {
|
|
if v, ok := mode["mode"].(string); ok && v != "" {
|
|
switch v {
|
|
case "legacy", "stream", "poll", "auto", "ws":
|
|
default:
|
|
return fmt.Errorf("sudoku httpmask mode is invalid")
|
|
}
|
|
}
|
|
}
|
|
case "trusttunnel":
|
|
if !hasCertificatePair(cfg) {
|
|
return fmt.Errorf("trusttunnel listener requires certificate and private-key")
|
|
}
|
|
if network, ok := cfg["network"].([]interface{}); ok {
|
|
for _, raw := range network {
|
|
v := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw)))
|
|
if v != "tcp" && v != "udp" {
|
|
return fmt.Errorf("trusttunnel network must contain only tcp or udp")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
// Protocol-specific field bans with explicit messages expected by tests
|
|
// and matching official Mihomo listener schemas.
|
|
if proto == "vmess" {
|
|
if _, ok := row["flow"]; ok {
|
|
return fmt.Errorf("vmess listener users[%d] does not support flow", index)
|
|
}
|
|
}
|
|
if proto == "vless" {
|
|
if _, ok := row["alterId"]; ok {
|
|
return fmt.Errorf("vless listener users[%d] does not support alterId", 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)
|
|
}
|
|
}
|
|
// Official Mihomo VLESS/VMess listener users are keyed by uuid (and optional
|
|
// flow/alterId). username is panel/bookkeeping only — not required by the core.
|
|
if uuidMode {
|
|
if !hasString(row["uuid"]) {
|
|
return fmt.Errorf("%s listener users[%d] requires uuid", proto, index)
|
|
}
|
|
return nil
|
|
}
|
|
if !hasString(row["username"]) {
|
|
return fmt.Errorf("%s listener users[%d] requires username", proto, index)
|
|
}
|
|
if !hasString(row["password"]) {
|
|
return fmt.Errorf("%s listener users[%d] requires password", proto, index)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateServerTLSModes enforces official Mihomo listener TLS exclusivity:
|
|
// certificate/private-key must appear together, and must not be combined with
|
|
// reality-config (or other exclusive TLS modes when present).
|
|
func validateServerTLSModes(proto string, cfg map[string]interface{}) error {
|
|
cert, key := hasString(cfg["certificate"]), hasString(cfg["private-key"])
|
|
if cert != key {
|
|
return fmt.Errorf("%s listener requires certificate and private-key together", proto)
|
|
}
|
|
if cert {
|
|
if _, hasReality := cfg["reality-config"]; hasReality {
|
|
return fmt.Errorf("%s listener mutually exclusive TLS modes: certificate/private-key cannot be used with reality-config", proto)
|
|
}
|
|
if _, hasMirror := cfg["tlsmirror-config"]; hasMirror {
|
|
return fmt.Errorf("%s listener mutually exclusive TLS modes: certificate/private-key cannot be used with tlsmirror-config", proto)
|
|
}
|
|
}
|
|
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.Map:
|
|
return v.Len() > 0
|
|
case reflect.Slice:
|
|
if v.Len() == 0 {
|
|
return false
|
|
}
|
|
// token: [""] must not count as configured
|
|
allEmpty := true
|
|
for i := 0; i < v.Len(); i++ {
|
|
elem := v.Index(i).Interface()
|
|
if s, ok := elem.(string); ok {
|
|
if strings.TrimSpace(s) != "" {
|
|
return true
|
|
}
|
|
} else if elem != nil {
|
|
allEmpty = false
|
|
}
|
|
}
|
|
return !allEmpty && 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
|
|
}
|