Re-apply on post-theme baseline (52e848a): schema, compile modules,
http:// and socks5:// share URIs, and client subscription YAML
(type: http / socks5). Fixes gofmt for CI.
401 lines
12 KiB
Go
401 lines
12 KiB
Go
package protocol
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// UserCred is a protocol-agnostic credential injected at compile time.
|
|
type UserCred struct {
|
|
Username string
|
|
Password string
|
|
UUID string
|
|
Flow string
|
|
}
|
|
|
|
// CompileInput is the normalized node state passed to protocol modules.
|
|
type CompileInput struct {
|
|
Name string
|
|
Protocol string
|
|
Listen string
|
|
Port interface{} // int or official ports string
|
|
UDP bool
|
|
TLS bool
|
|
Proxy string
|
|
Rule string
|
|
RoutingMark int
|
|
Config map[string]interface{}
|
|
Users []UserCred
|
|
HasCredentialState bool // true when creds map has this listener key (even if empty)
|
|
}
|
|
|
|
// Module is a protocol compiler .
|
|
type Module interface {
|
|
Kind() string
|
|
Compile(in CompileInput) (map[string]interface{}, error)
|
|
Capability() ProtocolCapability
|
|
}
|
|
|
|
// Registry maps protocol kind → Module.
|
|
type Registry struct {
|
|
modules map[string]Module
|
|
}
|
|
|
|
func NewRegistry(mods ...Module) Registry {
|
|
r := Registry{modules: make(map[string]Module, len(mods))}
|
|
for _, m := range mods {
|
|
if m == nil {
|
|
continue
|
|
}
|
|
r.modules[strings.ToLower(m.Kind())] = m
|
|
}
|
|
return r
|
|
}
|
|
|
|
func DefaultCompileRegistry() Registry {
|
|
return NewRegistry(
|
|
VLESSCompiler{},
|
|
VMessCompiler{},
|
|
TrojanCompiler{},
|
|
ShadowsocksCompiler{},
|
|
Hysteria2Compiler{},
|
|
TUICCompiler{kind: "tuic"},
|
|
TUICCompiler{kind: "tuic-v4"},
|
|
TUICCompiler{kind: "tuic-v5"},
|
|
ShadowQUICCompiler{},
|
|
GenericCompiler{kind: "snell"},
|
|
AnyTLSCompiler{},
|
|
MieruCompiler{},
|
|
GenericCompiler{kind: "sudoku"},
|
|
TrustTunnelCompiler{},
|
|
HTTPCompiler{},
|
|
SocksCompiler{},
|
|
)
|
|
}
|
|
|
|
func (r Registry) Compile(in CompileInput) (map[string]interface{}, error) {
|
|
kind := strings.ToLower(strings.TrimSpace(in.Protocol))
|
|
m, ok := r.modules[kind]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported protocol %q", kind)
|
|
}
|
|
return m.Compile(in)
|
|
}
|
|
|
|
func (r Registry) Has(kind string) bool {
|
|
_, ok := r.modules[strings.ToLower(strings.TrimSpace(kind))]
|
|
return ok
|
|
}
|
|
|
|
// ParseConfigJSON decodes listener config JSON into a map.
|
|
func ParseConfigJSON(raw string) (map[string]interface{}, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return map[string]interface{}{}, nil
|
|
}
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
return nil, fmt.Errorf("invalid config json: %w", err)
|
|
}
|
|
if m == nil {
|
|
m = map[string]interface{}{}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func baseMap(in CompileInput) map[string]interface{} {
|
|
m := map[string]interface{}{
|
|
"name": in.Name,
|
|
"type": strings.ToLower(in.Protocol),
|
|
"port": in.Port,
|
|
"listen": in.Listen,
|
|
}
|
|
if in.Proxy != "" {
|
|
m["proxy"] = in.Proxy
|
|
}
|
|
if in.Rule != "" {
|
|
m["rule"] = in.Rule
|
|
}
|
|
if in.RoutingMark > 0 {
|
|
m["routing-mark"] = in.RoutingMark
|
|
}
|
|
return m
|
|
}
|
|
|
|
func copyConfigPassthrough(dst, cfg map[string]interface{}, skip map[string]struct{}) {
|
|
for k, v := range cfg {
|
|
if _, ok := skip[k]; ok {
|
|
continue
|
|
}
|
|
// strip panel-only / client-export-only keys.
|
|
// NOTE: "encryption" is a CLIENT-side field per proxies-vless wiki.
|
|
// It is preserved in the panel Config map for client YAML emission
|
|
// (converter/client.go reads p["encryption"]) but stripped from
|
|
// the listener config.yaml via clientOnlyListenerKey. The server-side
|
|
// counterpart is "decryption" only per inbound-vless wiki.
|
|
if strings.HasPrefix(k, "_") || k == "access_profile" ||
|
|
k == "transport_layer" || k == "security_layer" {
|
|
continue
|
|
}
|
|
// Client-side TLS/hostname fields must not appear on listeners
|
|
// (wiki inbound docs use certificate/reality/wrappers, not sni/skip-cert-verify).
|
|
if clientOnlyListenerKey(k) {
|
|
continue
|
|
}
|
|
dst[k] = v
|
|
}
|
|
}
|
|
|
|
func clientOnlyListenerKey(k string) bool {
|
|
switch k {
|
|
case "sni", "servername", "skip-cert-verify", "name-cert-verify",
|
|
"fingerprint", "client-fingerprint", "reality-opts",
|
|
"shadow-tls-opts", "restls-opts", "jls-opts", "ss-opts",
|
|
"plugin", "plugin-opts", "encryption",
|
|
// Client grpc-opts extras (proxies transport); listener only has grpc-service-name.
|
|
"grpc-user-agent", "ping-interval", "max-connections", "min-streams", "max-streams",
|
|
// Client-only TUIC/Hy2 hints sometimes stored in panel JSON.
|
|
"udp-relay-mode", "reduce-rtt", "request-timeout", "heartbeat-interval",
|
|
"fast-open", "max-open-streams", "disable-sni", "ip",
|
|
// Listener WS is only ws-path (wiki inbound VLESS/VMess). Headers belong on
|
|
// the client as ws-opts.headers / share URI — not on the serving listener.
|
|
"ws-headers", "ws-opts", "headers", "network":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func managedKeys() map[string]struct{} {
|
|
return map[string]struct{}{
|
|
"name": {}, "type": {}, "port": {}, "listen": {}, "proxy": {}, "rule": {},
|
|
"routing-mark": {}, "udp": {}, "tls": {}, "users": {}, "flow": {},
|
|
"alterId": {},
|
|
}
|
|
}
|
|
|
|
func asUsersArray(cfg map[string]interface{}, fromCreds []UserCred, field string, hasCredState bool) []map[string]interface{} {
|
|
var out []map[string]interface{}
|
|
if len(fromCreds) > 0 {
|
|
out = make([]map[string]interface{}, 0, len(fromCreds))
|
|
for _, c := range fromCreds {
|
|
u := map[string]interface{}{}
|
|
switch field {
|
|
case "uuid":
|
|
// Official MetaCubeX VLESS/VMess user object uses:
|
|
// username (display / inboundUser) + uuid (+ optional flow)
|
|
// NOT "name". Wrong key is ignored by mihomo → inboundUser empty.
|
|
// Wiki: users: [{username, uuid, flow}]
|
|
if c.UUID != "" {
|
|
u["uuid"] = c.UUID
|
|
}
|
|
uname := strings.TrimSpace(c.Username)
|
|
if uname == "" {
|
|
uname = strings.TrimSpace(c.UUID) // still better than blank for tracking
|
|
}
|
|
if uname != "" {
|
|
u["username"] = uname
|
|
}
|
|
if c.Flow != "" {
|
|
u["flow"] = c.Flow
|
|
}
|
|
case "password":
|
|
// Trojan / ShadowQUIC / TrustTunnel: username + password.
|
|
// username is reported as inboundUser when present.
|
|
if c.Username != "" {
|
|
u["username"] = c.Username
|
|
u["password"] = c.Password
|
|
} else {
|
|
u["password"] = c.Password
|
|
if c.UUID != "" {
|
|
u["uuid"] = c.UUID
|
|
}
|
|
}
|
|
}
|
|
if len(u) > 0 {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
} else {
|
|
// Fall back to config users when panel credentials are empty.
|
|
// Previously, hasCredState=true with empty fromCreds returned nil,
|
|
// which caused Mihomo validation failure ("unset fields: users").
|
|
// Now we fall back to config users so the listener is at least valid.
|
|
// Panel-managed users take precedence when available; config users
|
|
// are a safety net for decrypt failures, inactive users, etc.
|
|
if raw, ok := cfg["users"]; ok {
|
|
out = normalizeUsersValue(raw)
|
|
}
|
|
}
|
|
applyListenerFlow(cfg, out)
|
|
// Propagate top-level alterId to each user object. The official Mihomo
|
|
// schema for VMess expects alterId inside users[].alterId; without it,
|
|
// per-user alterId is silently dropped when panel credentials are bound.
|
|
// Preserve per-user VMess alterId from the listener config when panel
|
|
// credentials are injected. The official Listener schema stores alterId
|
|
// inside each users[] entry; replacing users from the credential store must
|
|
// not erase those per-user values.
|
|
if field == "uuid" {
|
|
if raw, ok := cfg["users"].([]interface{}); ok {
|
|
byUUID := make(map[string]map[string]interface{}, len(raw))
|
|
for _, item := range raw {
|
|
if row, ok := item.(map[string]interface{}); ok {
|
|
if uuid := strings.TrimSpace(fmt.Sprint(row["uuid"])); uuid != "" {
|
|
byUUID[uuid] = row
|
|
}
|
|
}
|
|
}
|
|
for _, user := range out {
|
|
uuid := strings.TrimSpace(fmt.Sprint(user["uuid"]))
|
|
if row, ok := byUUID[uuid]; ok {
|
|
for _, key := range []string{"alterId", "flow"} {
|
|
if value, exists := row[key]; exists && value != nil {
|
|
user[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Backward-compatible panel-level alterId fallback.
|
|
if alterId, ok := cfg["alterId"]; ok && alterId != nil {
|
|
for _, user := range out {
|
|
if _, exists := user["alterId"]; !exists {
|
|
user["alterId"] = alterId
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// asUsersMap returns users as a map{username: password} for protocols whose
|
|
// Mihomo listener schema expects that shape (hysteria2, anytls, mieru).
|
|
func asUsersMap(cfg map[string]interface{}, fromCreds []UserCred, hasCredState bool) map[string]interface{} {
|
|
if len(fromCreds) > 0 {
|
|
out := make(map[string]interface{}, len(fromCreds))
|
|
for _, c := range fromCreds {
|
|
name := c.Username
|
|
if name == "" {
|
|
name = "default"
|
|
}
|
|
out[name] = c.Password
|
|
}
|
|
return out
|
|
}
|
|
// Fall back to config users when panel credentials are empty.
|
|
// See asUsersArray for rationale.
|
|
if raw, ok := cfg["users"]; ok {
|
|
switch users := raw.(type) {
|
|
case map[string]interface{}:
|
|
return users
|
|
case map[interface{}]interface{}:
|
|
out := make(map[string]interface{}, len(users))
|
|
for k, v := range users {
|
|
out[fmt.Sprint(k)] = fmt.Sprint(v)
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// asUsersMapUUID returns users as a map{uuid: password} for protocols whose
|
|
// Mihomo listener schema expects that shape (TUIC v5: users is a map keyed by UUID).
|
|
// Falls back to Username when UUID is empty (legacy panel users without UUID).
|
|
func asUsersMapUUID(cfg map[string]interface{}, fromCreds []UserCred, hasCredState bool) map[string]interface{} {
|
|
// Union: Config autofill UUID(s) + every bound panel user.
|
|
// Subscription exports panel UUID/password; inbound must accept the same
|
|
// keys or TUIC v5 auth fails (config-only random UUID ≠ panel UUID).
|
|
out := map[string]interface{}{}
|
|
if raw, ok := cfg["users"]; ok {
|
|
switch users := raw.(type) {
|
|
case map[string]interface{}:
|
|
for k, v := range users {
|
|
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" {
|
|
out[k] = s
|
|
}
|
|
}
|
|
case map[interface{}]interface{}:
|
|
for k, v := range users {
|
|
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" {
|
|
out[fmt.Sprint(k)] = s
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, c := range fromCreds {
|
|
key := strings.TrimSpace(c.UUID)
|
|
if key == "" {
|
|
key = strings.TrimSpace(c.Username)
|
|
}
|
|
if key == "" {
|
|
continue
|
|
}
|
|
pass := strings.TrimSpace(c.Password)
|
|
if pass == "" {
|
|
continue
|
|
}
|
|
out[key] = pass
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyListenerFlow copies a listener-level VLESS flow onto every user that does
|
|
// not carry one.
|
|
//
|
|
// Vision flow is TCP-only, so a stale flow must not be pushed onto users of a
|
|
// listener that has since been switched to ws/grpc/xhttp: Mihomo rejects that
|
|
// combination outright, which takes the whole listener down rather than one user.
|
|
func applyListenerFlow(cfg map[string]interface{}, users []map[string]interface{}) {
|
|
flow, ok := cfg["flow"].(string)
|
|
if !ok || strings.TrimSpace(flow) == "" || !TransportCarriesFlow(cfg) {
|
|
return
|
|
}
|
|
for _, user := range users {
|
|
if _, exists := user["flow"]; !exists {
|
|
user["flow"] = flow
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizeUsersValue(value interface{}) []map[string]interface{} {
|
|
switch users := value.(type) {
|
|
case []interface{}:
|
|
out := make([]map[string]interface{}, 0, len(users))
|
|
for _, item := range users {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
case []map[string]interface{}:
|
|
return users
|
|
case map[string]interface{}:
|
|
out := make([]map[string]interface{}, 0, len(users))
|
|
for username, password := range users {
|
|
out = append(out, map[string]interface{}{"username": username, "password": fmt.Sprint(password)})
|
|
}
|
|
return out
|
|
case map[interface{}]interface{}:
|
|
out := make([]map[string]interface{}, 0, len(users))
|
|
for rawUsername, rawPassword := range users {
|
|
out = append(out, map[string]interface{}{"username": fmt.Sprint(rawUsername), "password": fmt.Sprint(rawPassword)})
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func portValue(port string) interface{} {
|
|
s := strings.TrimSpace(port)
|
|
if p, err := strconv.Atoi(s); err == nil {
|
|
return p
|
|
}
|
|
return s
|
|
}
|