Files

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
}