Data.Clone() was a full JSON marshal/unmarshal round trip (with a per-record map dance for unknown fields): 6.3ms and ~13.8k allocs per call on sandbox hardware, and every admin GET paid it at least twice (RequireAdmin's user lookup + the handler). Clone is now a reflective deep copy of the plain data graph — byte-identical output, proven against the JSON clone on the real seed database by TestCloneEquivalence — at 0.37ms / 538 allocs. CurrentAdmin resolves the session user under a read-only store View and no longer clones at all, so an admin GET takes exactly one snapshot. Sandbox benchmarks (11 servers / 26 customers / 25 nodes): overview 17.2ms -> 0.38ms, servers 13.3 -> 0.85ms, customers 20.5 -> 1.54ms, nodes 15.8 -> 3.45ms, jobs 13.0 -> 0.39ms per request.
811 lines
21 KiB
Go
811 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ---------- ordered map (JS object insertion order for Clash proxies) ----------
|
|
|
|
type OrderedKV struct {
|
|
Key string
|
|
Value any
|
|
}
|
|
|
|
type OrderedMap []OrderedKV
|
|
|
|
func (m OrderedMap) Get(key string) (any, bool) {
|
|
for _, kv := range m {
|
|
if kv.Key == key {
|
|
return kv.Value, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (m *OrderedMap) Set(key string, value any) {
|
|
for i := range *m {
|
|
if (*m)[i].Key == key {
|
|
(*m)[i].Value = value
|
|
return
|
|
}
|
|
}
|
|
*m = append(*m, OrderedKV{Key: key, Value: value})
|
|
}
|
|
|
|
func (m OrderedMap) Str(key string) string {
|
|
v, ok := m.Get(key)
|
|
if !ok || v == nil {
|
|
return ""
|
|
}
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
func (m OrderedMap) Bool(key string) bool {
|
|
v, _ := m.Get(key)
|
|
b, _ := v.(bool)
|
|
return b
|
|
}
|
|
|
|
// ---------- entries ----------
|
|
|
|
type SubEntry struct {
|
|
NodeID string `json:"nodeId"`
|
|
ClientURI string `json:"clientUri"`
|
|
}
|
|
|
|
type SubResult struct {
|
|
Body string
|
|
ContentType string
|
|
}
|
|
|
|
func decodeURIComponent(s string) string {
|
|
out, err := url.PathUnescape(s)
|
|
if err != nil {
|
|
return s
|
|
}
|
|
return out
|
|
}
|
|
|
|
func NodeClientURI(d *Data, nc *NodeClient) string {
|
|
var node *Node
|
|
for _, item := range d.Nodes {
|
|
if item.ID == nc.NodeID {
|
|
node = item
|
|
break
|
|
}
|
|
}
|
|
if node == nil || node.Status != "active" {
|
|
return ""
|
|
}
|
|
var server *Server
|
|
for _, item := range d.Servers {
|
|
if item.ID == node.ServerID {
|
|
server = item
|
|
break
|
|
}
|
|
}
|
|
if server == nil {
|
|
return ""
|
|
}
|
|
publicKey := ""
|
|
if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 {
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
publicKey = s
|
|
}
|
|
}
|
|
networkMode := node.NetworkMode
|
|
if networkMode == "" {
|
|
networkMode = "ipv4"
|
|
}
|
|
creds := Credentials{}
|
|
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
|
|
creds = *customer.Credentials
|
|
}
|
|
// Round-3 item 21: a socks node's link carries the node's fixed
|
|
// account, identical for every bound customer; the binding only
|
|
// controls whether the node appears in the subscription at all.
|
|
socksUser := NodeEmail(nc.CustomerID)
|
|
relayPassword := creds.RelayPassword
|
|
if node.Protocol == "socks5-auth" {
|
|
socksUser, relayPassword = node.SocksUser, node.SocksPassword
|
|
if socksUser == "" || relayPassword == "" {
|
|
// Legacy record the load migration has not reached (only
|
|
// possible on an in-memory fixture): mint into a throwaway
|
|
// copy — a read path must never mutate the node record.
|
|
probe := *node
|
|
EnsureSocksCredentials(&probe)
|
|
socksUser, relayPassword = probe.SocksUser, probe.SocksPassword
|
|
}
|
|
}
|
|
uri, ok := BuildClientURI(BuildClientURIParams{
|
|
Protocol: node.Protocol,
|
|
RelayServer: server,
|
|
RelayPort: node.Port,
|
|
ClientID: creds.ClientID,
|
|
RelayPassword: relayPassword,
|
|
ShortID: creds.ShortID,
|
|
WSPath: xbDeref(node.WSPath),
|
|
SocksUser: socksUser,
|
|
Reality: node.Reality,
|
|
PublicKey: publicKey,
|
|
Name: node.Name,
|
|
NetworkMode: networkMode,
|
|
TLSDomain: server.TLSDomain,
|
|
SSServerKey: xbDeref(node.SSServerKey),
|
|
})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return uri
|
|
}
|
|
|
|
// sortIndexOf mirrors sortIndexOf in lib/subscriptions.js.
|
|
func sortIndexOf(d *Data, nodeID string) int {
|
|
for _, item := range d.Nodes {
|
|
if item.ID == nodeID {
|
|
return item.SortIndex
|
|
}
|
|
}
|
|
return 1 << 30
|
|
}
|
|
|
|
func Entries(d *Data, customerID string) []SubEntry {
|
|
seen := map[string]int{}
|
|
items := []SubEntry{}
|
|
bindings := []*NodeClient{}
|
|
for _, nc := range d.NodeClients {
|
|
if nc.CustomerID == customerID && nc.Status == "active" {
|
|
bindings = append(bindings, nc)
|
|
}
|
|
}
|
|
sort.SliceStable(bindings, func(i, j int) bool {
|
|
return sortIndexOf(d, bindings[i].NodeID) < sortIndexOf(d, bindings[j].NodeID)
|
|
})
|
|
for _, nc := range bindings {
|
|
uri := NodeClientURI(d, nc)
|
|
if uri == "" {
|
|
continue
|
|
}
|
|
nodeName := nc.NodeID
|
|
for _, item := range d.Nodes {
|
|
if item.ID == nc.NodeID {
|
|
nodeName = item.Name
|
|
break
|
|
}
|
|
}
|
|
index := seen[nodeName] + 1
|
|
seen[nodeName] = index
|
|
name := nodeName
|
|
if index != 1 {
|
|
name = fmt.Sprintf("%s (%d)", nodeName, index)
|
|
}
|
|
separator := strings.LastIndex(uri, "#")
|
|
prefix := uri
|
|
if separator >= 0 {
|
|
prefix = uri[:separator]
|
|
}
|
|
items = append(items, SubEntry{
|
|
NodeID: nc.NodeID,
|
|
ClientURI: prefix + "#" + encodeURIComponent(name),
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
// ---------- Clash ----------
|
|
|
|
func q(value any) string {
|
|
// JSON.stringify(String(value)); String(null) is "null".
|
|
b, err := marshalJSON(toJSString(value))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func toJSString(value any) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
return v
|
|
case bool:
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case int:
|
|
return strconv.Itoa(v)
|
|
case int64:
|
|
return strconv.FormatInt(v, 10)
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'g', -1, 64)
|
|
case nil:
|
|
return "null"
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
func ClashProxy(item SubEntry) (OrderedMap, error) {
|
|
uri := item.ClientURI
|
|
hashIdx := strings.LastIndex(uri, "#")
|
|
name := decodeURIComponent(uri[hashIdx+1:])
|
|
u, err := url.Parse(uri)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
port := 0
|
|
if p := u.Port(); p != "" {
|
|
if n, err := strconv.Atoi(p); err == nil {
|
|
port = n
|
|
}
|
|
}
|
|
base := OrderedMap{
|
|
{Key: "name", Value: name},
|
|
{Key: "server", Value: u.Hostname()},
|
|
{Key: "port", Value: port},
|
|
}
|
|
query := u.Query()
|
|
switch u.Scheme + ":" {
|
|
case "hysteria2:":
|
|
password := ""
|
|
if u.User != nil {
|
|
password = u.User.Username()
|
|
}
|
|
out := append(OrderedMap{}, base...)
|
|
out = append(out,
|
|
OrderedKV{Key: "type", Value: "hysteria2"},
|
|
OrderedKV{Key: "password", Value: password},
|
|
OrderedKV{Key: "sni", Value: queryGetOrNil(query, "sni")},
|
|
OrderedKV{Key: "alpn", Value: []string{"h3"}},
|
|
)
|
|
return out, nil
|
|
case "anytls:":
|
|
password := ""
|
|
if u.User != nil {
|
|
password = u.User.Username()
|
|
}
|
|
out := append(OrderedMap{}, base...)
|
|
out = append(out,
|
|
OrderedKV{Key: "type", Value: "anytls"},
|
|
OrderedKV{Key: "password", Value: password},
|
|
OrderedKV{Key: "sni", Value: queryGetOrNil(query, "sni")},
|
|
OrderedKV{Key: "udp", Value: true},
|
|
)
|
|
return out, nil
|
|
case "vless:":
|
|
security := query.Get("security")
|
|
uuid := ""
|
|
if u.User != nil {
|
|
uuid = u.User.Username()
|
|
}
|
|
isTLS := security == "reality" || security == "tls"
|
|
network := "tcp"
|
|
if query.Get("type") == "ws" {
|
|
network = "ws"
|
|
}
|
|
out := append(OrderedMap{}, base...)
|
|
out = append(out,
|
|
OrderedKV{Key: "type", Value: "vless"},
|
|
OrderedKV{Key: "uuid", Value: uuid},
|
|
OrderedKV{Key: "tls", Value: isTLS},
|
|
OrderedKV{Key: "network", Value: network},
|
|
)
|
|
if network == "ws" {
|
|
path := query.Get("path")
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
out = append(out, OrderedKV{Key: "ws-opts", Value: OrderedMap{{Key: "path", Value: path}}})
|
|
}
|
|
if isTLS {
|
|
out = append(out, OrderedKV{Key: "servername", Value: queryGetOrNil(query, "sni")})
|
|
if security == "reality" {
|
|
fp := query.Get("fp")
|
|
if fp == "" {
|
|
fp = "chrome"
|
|
}
|
|
out = append(out,
|
|
OrderedKV{Key: "client-fingerprint", Value: fp},
|
|
OrderedKV{Key: "reality-opts", Value: OrderedMap{
|
|
{Key: "public-key", Value: queryGetOrNil(query, "pbk")},
|
|
{Key: "short-id", Value: queryGetOrNil(query, "sid")},
|
|
}},
|
|
)
|
|
}
|
|
if _, has := query["flow"]; has {
|
|
out = append(out, OrderedKV{Key: "flow", Value: query.Get("flow")})
|
|
}
|
|
}
|
|
return out, nil
|
|
case "ss:":
|
|
username := ""
|
|
if u.User != nil {
|
|
username = u.User.Username()
|
|
}
|
|
raw, err := decodeBase64URL(username)
|
|
if err != nil {
|
|
return nil, errors.New("Invalid Shadowsocks URI")
|
|
}
|
|
decoded := string(raw)
|
|
separator := strings.Index(decoded, ":")
|
|
if separator < 0 {
|
|
return nil, errors.New("Invalid Shadowsocks URI")
|
|
}
|
|
out := append(OrderedMap{}, base...)
|
|
out = append(out,
|
|
OrderedKV{Key: "type", Value: "ss"},
|
|
OrderedKV{Key: "cipher", Value: decoded[:separator]},
|
|
OrderedKV{Key: "password", Value: decoded[separator+1:]},
|
|
)
|
|
return out, nil
|
|
case "socks5:":
|
|
username, password := "", ""
|
|
if u.User != nil {
|
|
username = u.User.Username()
|
|
password, _ = u.User.Password()
|
|
}
|
|
out := append(OrderedMap{}, base...)
|
|
out = append(out,
|
|
OrderedKV{Key: "type", Value: "socks5"},
|
|
OrderedKV{Key: "username", Value: username},
|
|
OrderedKV{Key: "password", Value: password},
|
|
)
|
|
return out, nil
|
|
}
|
|
return nil, errors.New("Unsupported client URI")
|
|
}
|
|
|
|
func queryGetOrNil(query url.Values, key string) any {
|
|
vals, ok := query[key]
|
|
if !ok || len(vals) == 0 {
|
|
return nil
|
|
}
|
|
return vals[0]
|
|
}
|
|
|
|
func decodeBase64URL(s string) ([]byte, error) {
|
|
if raw, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
|
return raw, nil
|
|
}
|
|
padded := s
|
|
for len(padded)%4 != 0 {
|
|
padded += "="
|
|
}
|
|
return base64.URLEncoding.DecodeString(padded)
|
|
}
|
|
|
|
func yamlValue(value any, indent int) string {
|
|
switch v := value.(type) {
|
|
case []string:
|
|
raw, err := marshalJSON(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(raw)
|
|
case []any:
|
|
raw, err := marshalJSON(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(raw)
|
|
case OrderedMap:
|
|
parts := make([]string, 0, len(v))
|
|
pad := strings.Repeat(" ", indent)
|
|
for _, kv := range v {
|
|
if child, isObj := kv.Value.(OrderedMap); isObj {
|
|
parts = append(parts, pad+kv.Key+": "+"\n"+yamlValue(child, indent+2))
|
|
} else {
|
|
parts = append(parts, pad+kv.Key+": "+yamlValue(kv.Value, 0))
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
case bool:
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case int:
|
|
return strconv.Itoa(v)
|
|
case int64:
|
|
return strconv.FormatInt(v, 10)
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'g', -1, 64)
|
|
case json.Number:
|
|
return v.String()
|
|
default:
|
|
return q(value)
|
|
}
|
|
}
|
|
|
|
func MakeClash(items []SubEntry, smart bool) string {
|
|
reserved := []string{"节点选择", "DIRECT", "REJECT"}
|
|
if smart {
|
|
reserved = []string{"节点选择", "自动选择", "流媒体", "AI 服务", "Meta 服务", "DIRECT", "REJECT"}
|
|
}
|
|
used := map[string]bool{}
|
|
for _, r := range reserved {
|
|
used[r] = true
|
|
}
|
|
proxies := make([]OrderedMap, 0, len(items))
|
|
for _, item := range items {
|
|
proxy, err := ClashProxy(item)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
name := proxy.Str("name")
|
|
finalName := name
|
|
if used[finalName] {
|
|
index := 1
|
|
for {
|
|
suffix := ""
|
|
if index != 1 {
|
|
suffix = fmt.Sprintf(" %d", index)
|
|
}
|
|
finalName = fmt.Sprintf("%s (节点%s)", name, suffix)
|
|
index++
|
|
if !used[finalName] {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
used[finalName] = true
|
|
proxy.Set("name", finalName)
|
|
proxies = append(proxies, proxy)
|
|
}
|
|
names := make([]string, 0, len(proxies))
|
|
yamlParts := make([]string, 0, len(proxies))
|
|
for _, proxy := range proxies {
|
|
names = append(names, proxy.Str("name"))
|
|
y := yamlValue(proxy, 4)
|
|
if strings.HasPrefix(y, " ") {
|
|
y = " - " + y[4:]
|
|
}
|
|
yamlParts = append(yamlParts, y)
|
|
}
|
|
yaml := strings.Join(yamlParts, "\n")
|
|
group := func(name, typ string, members []string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(" - name: " + q(name) + "\n")
|
|
sb.WriteString(" type: " + typ + "\n")
|
|
if typ == "url-test" {
|
|
sb.WriteString(" url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n")
|
|
}
|
|
sb.WriteString(" proxies:\n")
|
|
lines := make([]string, len(members))
|
|
for i, m := range members {
|
|
lines[i] = " - " + q(m)
|
|
}
|
|
sb.WriteString(strings.Join(lines, "\n"))
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
var groups string
|
|
if smart {
|
|
groups = group("节点选择", "select", append([]string{"自动选择"}, append(append([]string{}, names...), "DIRECT")...)) +
|
|
group("自动选择", "url-test", names) +
|
|
group("流媒体", "select", append([]string{"节点选择", "自动选择"}, names...)) +
|
|
group("AI 服务", "select", append([]string{"节点选择", "自动选择"}, names...)) +
|
|
group("Meta 服务", "select", append([]string{"节点选择", "自动选择"}, names...))
|
|
} else {
|
|
groups = group("节点选择", "select", names)
|
|
}
|
|
rules := []string{"MATCH,节点选择"}
|
|
if smart {
|
|
rules = []string{
|
|
"GEOSITE,category-ads-all,REJECT", "GEOSITE,private,DIRECT",
|
|
"GEOSITE,openai,AI 服务", "GEOSITE,netflix,流媒体",
|
|
"GEOSITE,facebook,Meta 服务", "DOMAIN-SUFFIX,fbcdn.net,Meta 服务",
|
|
"DOMAIN-SUFFIX,instagram.com,Meta 服务", "DOMAIN-SUFFIX,whatsapp.net,Meta 服务",
|
|
"GEOSITE,geolocation-!cn,节点选择", "GEOSITE,cn,DIRECT",
|
|
"GEOIP,CN,DIRECT", "MATCH,节点选择",
|
|
}
|
|
}
|
|
ruleLines := make([]string, len(rules))
|
|
for i, r := range rules {
|
|
ruleLines[i] = " - " + q(r)
|
|
}
|
|
return "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n" + yaml +
|
|
"\nproxy-groups:\n" + groups + "rules:\n" + strings.Join(ruleLines, "\n") + "\n"
|
|
}
|
|
|
|
// ---------- Surge ----------
|
|
|
|
// MakeSurge mirrors makeSurge; ok=false mirrors JS null.
|
|
func MakeSurge(items []SubEntry) (string, bool) {
|
|
proxies := []OrderedMap{}
|
|
for _, item := range items {
|
|
proxy, err := ClashProxy(item)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
typ := proxy.Str("type")
|
|
if typ == "ss" || typ == "socks5" {
|
|
proxies = append(proxies, proxy)
|
|
}
|
|
}
|
|
if len(proxies) == 0 {
|
|
return "", false
|
|
}
|
|
safeReplacer := strings.NewReplacer("\r", "_", "\n", "_", ",", "_", "=", "_")
|
|
safe := func(v string) string { return safeReplacer.Replace(v) }
|
|
policies := make([]string, 0, len(proxies))
|
|
names := make([]string, 0, len(proxies))
|
|
for _, proxy := range proxies {
|
|
prefix := safe(proxy.Str("name")) + " = "
|
|
server, _ := proxy.Get("server")
|
|
port, _ := proxy.Get("port")
|
|
if proxy.Str("type") == "ss" {
|
|
policies = append(policies, fmt.Sprintf("%sss, %s, %s, encrypt-method=%s, password=%s",
|
|
prefix, toJSString(server), toJSString(port), proxy.Str("cipher"), safe(proxy.Str("password"))))
|
|
} else {
|
|
policies = append(policies, fmt.Sprintf("%ssocks5, %s, %s, %s, %s",
|
|
prefix, toJSString(server), toJSString(port), safe(proxy.Str("username")), safe(proxy.Str("password"))))
|
|
}
|
|
names = append(names, safe(proxy.Str("name")))
|
|
}
|
|
joined := strings.Join(names, ", ")
|
|
body := "[General]\nloglevel = notify\n[Proxy]\n" + strings.Join(policies, "\n") +
|
|
"\n[Proxy Group]\n节点选择 = select, " + joined + ", DIRECT\n自动选择 = url-test, " + joined +
|
|
", url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n"
|
|
return body, true
|
|
}
|
|
|
|
// ---------- sing-box ----------
|
|
|
|
type sbTransport struct {
|
|
Type string `json:"type"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type sbReality struct {
|
|
Enabled bool `json:"enabled"`
|
|
PublicKey string `json:"public_key"`
|
|
ShortID string `json:"short_id"`
|
|
}
|
|
|
|
type sbTLS struct {
|
|
Enabled bool `json:"enabled"`
|
|
ServerName string `json:"server_name"`
|
|
Alpn []string `json:"alpn,omitempty"`
|
|
Reality *sbReality `json:"reality,omitempty"`
|
|
}
|
|
|
|
type sbOutbound struct {
|
|
Type string `json:"type"`
|
|
Tag string `json:"tag"`
|
|
Server string `json:"server,omitempty"`
|
|
ServerPort int `json:"server_port,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
UUID string `json:"uuid,omitempty"`
|
|
Flow string `json:"flow,omitempty"`
|
|
Transport *sbTransport `json:"transport,omitempty"`
|
|
TLS *sbTLS `json:"tls,omitempty"`
|
|
Outbounds []string `json:"outbounds,omitempty"`
|
|
}
|
|
|
|
type sbInbound struct {
|
|
Type string `json:"type"`
|
|
Tag string `json:"tag"`
|
|
Listen string `json:"listen"`
|
|
ListenPort int `json:"listen_port"`
|
|
}
|
|
|
|
type sbConfig struct {
|
|
Log struct {
|
|
Level string `json:"level"`
|
|
} `json:"log"`
|
|
Inbounds []sbInbound `json:"inbounds"`
|
|
Outbounds []sbOutbound `json:"outbounds"`
|
|
Route struct {
|
|
Final string `json:"final"`
|
|
} `json:"route"`
|
|
}
|
|
|
|
func subMarshalIndentJSON(v any) string {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(v); err != nil {
|
|
panic(err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func MakeSingBox(items []SubEntry) string {
|
|
proxies := make([]sbOutbound, 0, len(items))
|
|
tags := make([]string, 0, len(items))
|
|
for i, item := range items {
|
|
proxy, err := ClashProxy(item)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
tag := fmt.Sprintf("node-%d", i+1)
|
|
tags = append(tags, tag)
|
|
server := proxy.Str("server")
|
|
portVal, _ := proxy.Get("port")
|
|
port, _ := portVal.(int)
|
|
base := sbOutbound{Tag: tag, Server: server, ServerPort: port}
|
|
switch proxy.Str("type") {
|
|
case "ss":
|
|
base.Type = "shadowsocks"
|
|
base.Method = proxy.Str("cipher")
|
|
base.Password = proxy.Str("password")
|
|
case "hysteria2":
|
|
base.Type = "hysteria2"
|
|
base.Password = proxy.Str("password")
|
|
base.TLS = &sbTLS{Enabled: true, ServerName: proxy.Str("sni"), Alpn: []string{"h3"}}
|
|
case "anytls":
|
|
base.Type = "anytls"
|
|
base.Password = proxy.Str("password")
|
|
base.TLS = &sbTLS{Enabled: true, ServerName: proxy.Str("sni")}
|
|
case "socks5":
|
|
base.Type = "socks"
|
|
base.Version = "5"
|
|
base.Username = proxy.Str("username")
|
|
base.Password = proxy.Str("password")
|
|
default: // vless
|
|
base.Type = "vless"
|
|
base.UUID = proxy.Str("uuid")
|
|
if flow := proxy.Str("flow"); flow != "" {
|
|
base.Flow = flow
|
|
}
|
|
if proxy.Str("network") == "ws" {
|
|
path := "/"
|
|
if wsOpts, ok := proxy.Get("ws-opts"); ok {
|
|
if om, isOM := wsOpts.(OrderedMap); isOM {
|
|
if p := om.Str("path"); p != "" {
|
|
path = p
|
|
}
|
|
}
|
|
}
|
|
base.Transport = &sbTransport{Type: "ws", Path: path}
|
|
}
|
|
if proxy.Bool("tls") {
|
|
tls := &sbTLS{Enabled: true, ServerName: proxy.Str("servername")}
|
|
if ro, ok := proxy.Get("reality-opts"); ok && ro != nil {
|
|
if om, isOM := ro.(OrderedMap); isOM {
|
|
tls.Reality = &sbReality{Enabled: true, PublicKey: om.Str("public-key"), ShortID: om.Str("short-id")}
|
|
}
|
|
}
|
|
base.TLS = tls
|
|
}
|
|
}
|
|
proxies = append(proxies, base)
|
|
}
|
|
cfg := sbConfig{}
|
|
cfg.Log.Level = "warn"
|
|
cfg.Inbounds = []sbInbound{{Type: "mixed", Tag: "local", Listen: "127.0.0.1", ListenPort: 2080}}
|
|
cfg.Outbounds = append([]sbOutbound{{Type: "selector", Tag: "proxy", Outbounds: tags}}, proxies...)
|
|
cfg.Outbounds = append(cfg.Outbounds, sbOutbound{Type: "direct", Tag: "direct"})
|
|
cfg.Route.Final = "proxy"
|
|
return subMarshalIndentJSON(cfg)
|
|
}
|
|
|
|
// ---------- format dispatch ----------
|
|
|
|
var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`)
|
|
|
|
// AggregateEntries mirrors aggregateEntries in lib/subscriptions.js:
|
|
// own entries (sortIndex order) → manual links → external sources,
|
|
// deduped by scheme+host+port+credential fingerprint, first wins.
|
|
func AggregateEntries(d *Data, customer *Customer) []SubEntry {
|
|
own := Entries(d, customer.ID)
|
|
out := []SubEntry{}
|
|
seenKeys := map[string]bool{}
|
|
seenNames := map[string]int{}
|
|
push := func(uri, name string) {
|
|
if uri == "" {
|
|
return
|
|
}
|
|
key := DedupKey(uri)
|
|
if key != "" && seenKeys[key] {
|
|
return
|
|
}
|
|
if key != "" {
|
|
seenKeys[key] = true
|
|
}
|
|
index := seenNames[name] + 1
|
|
seenNames[name] = index
|
|
finalName := name
|
|
if index != 1 {
|
|
finalName = fmt.Sprintf("%s (%d)", name, index)
|
|
}
|
|
out = append(out, SubEntry{ClientURI: WithName(uri, finalName)})
|
|
}
|
|
for _, entry := range own {
|
|
key := DedupKey(entry.ClientURI)
|
|
if key != "" {
|
|
seenKeys[key] = true
|
|
}
|
|
seenNames[NameFromURI(entry.ClientURI)]++
|
|
out = append(out, entry)
|
|
}
|
|
for i, link := range customer.ManualLinks {
|
|
if !link.Enabled {
|
|
continue
|
|
}
|
|
name := link.Remark
|
|
if name == "" {
|
|
name = NameFromURI(link.URI)
|
|
}
|
|
if name == "" {
|
|
name = fmt.Sprintf("手动节点 %d", i+1)
|
|
}
|
|
push(link.URI, name)
|
|
}
|
|
for _, source := range customer.ExternalSources {
|
|
if !source.Enabled {
|
|
continue
|
|
}
|
|
entry := d.SubSourceCache[source.ID]
|
|
if entry == nil || entry.OkAt == nil {
|
|
continue
|
|
}
|
|
for _, item := range entry.Items {
|
|
name := item.Name
|
|
if name == "" {
|
|
name = "未命名节点"
|
|
}
|
|
if source.Remark != "" {
|
|
name = "[" + source.Remark + "] " + name
|
|
}
|
|
push(item.URI, name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) {
|
|
items := AggregateEntries(d, customer)
|
|
if len(items) == 0 {
|
|
return nil, false
|
|
}
|
|
selected := format
|
|
if format == "auto" {
|
|
if clashUARe.MatchString(userAgent) {
|
|
selected = "clash"
|
|
} else {
|
|
selected = "base64"
|
|
}
|
|
}
|
|
joined := func() string {
|
|
uris := make([]string, len(items))
|
|
for i, item := range items {
|
|
uris[i] = item.ClientURI
|
|
}
|
|
return strings.Join(uris, "\n") + "\n"
|
|
}
|
|
switch selected {
|
|
case "raw":
|
|
return &SubResult{Body: joined(), ContentType: "text/plain; charset=utf-8"}, true
|
|
case "base64":
|
|
return &SubResult{Body: base64.StdEncoding.EncodeToString([]byte(joined())), ContentType: "text/plain; charset=utf-8"}, true
|
|
case "clash", "clash-smart":
|
|
return &SubResult{Body: MakeClash(items, selected == "clash-smart"), ContentType: "text/yaml; charset=utf-8"}, true
|
|
case "singbox":
|
|
return &SubResult{Body: MakeSingBox(items), ContentType: "application/json; charset=utf-8"}, true
|
|
case "surge":
|
|
body, ok := MakeSurge(items)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return &SubResult{Body: body, ContentType: "text/plain; charset=utf-8"}, true
|
|
}
|
|
return nil, false
|
|
}
|