One credential set per customer (UUID + 32-byte root relay key + shortId)
lives on the customer record; node bindings no longer store credentials.
SS2022 user keys derive deterministically from the root key by method
length (16/24/32-byte prefix), so a single password serves every protocol,
matching 3x-ui's per-client credential model. Reality public keys stay a
node property and are shown per binding in the credentials pane.
- store: load-time convergence — first binding's set wins; a converged
password that is not a 32-byte root key is regenerated (uuid/shortId
preserved); bindings are stripped; idempotent, covered by tests
- deploy: the orchestrator stamps the customer's set onto a build copy;
subscriptions resolve the same set; the derivation applies in both the
inbound builder and the URI builder (verified live: Alice's N1 link is
byte-identical to the pre-change seed expectation, her N2 user key is
exactly the root key's 16-byte prefix)
- API: GET /api/customers/:id/credentials returns {credentials, bindings};
regenerate is now POST /api/customers/:id/credentials/regenerate and
rotates the whole set, rebuilding every deployed node of the customer
- UI: credentials pane shows the single shared set plus per-binding
node/protocol/Reality-public-key rows
- golden subscriptions fixture regenerated under one shared set (RF and
SS entries byte-identical to before; five entries audited by hand)
- golden baseline re-captured for the new shapes; difftest normalizer now
treats a base64 root key's prefix-truncation encodings and the Surge
'='-mangled variants as the same secret
831 lines
27 KiB
Go
831 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"strings"
|
|
)
|
|
|
|
// ---------- catalog data (mirrors lib/protocols.js) ----------
|
|
|
|
type Profile struct {
|
|
ID string `json:"id"`
|
|
Role string `json:"role"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Deployable bool `json:"deployable"`
|
|
Capabilities []string `json:"capabilities"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type RealityPreset struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
ServerName string `json:"serverName"`
|
|
DestPort int `json:"destPort"`
|
|
}
|
|
|
|
var RealityPresets = []RealityPreset{
|
|
{ID: "tesla", Label: "Tesla", ServerName: "www.tesla.com", DestPort: 443},
|
|
{ID: "amazon", Label: "Amazon", ServerName: "www.amazon.com", DestPort: 443},
|
|
{ID: "apple", Label: "Apple", ServerName: "www.apple.com", DestPort: 443},
|
|
{ID: "intel", Label: "Intel", ServerName: "www.intel.com", DestPort: 443},
|
|
{ID: "amd", Label: "AMD", ServerName: "www.amd.com", DestPort: 443},
|
|
}
|
|
|
|
var ProfileCatalog = []Profile{
|
|
{ID: "vless-reality-vision", Role: "relay-ingress", Name: "VLESS · Reality · Vision", Status: "stable", Deployable: true, Capabilities: []string{"sing-box", "reality"}, Description: "推荐公网入口,无需在节点机部署证书。"},
|
|
{ID: "vless-reality", Role: "relay-ingress", Name: "VLESS · Reality", Status: "stable", Deployable: true, Capabilities: []string{"sing-box", "reality"}, Description: "不启用 Vision 流控的 Reality 入口。"},
|
|
{ID: "vless-ws", Role: "relay-ingress", Name: "VLESS · WebSocket", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "websocket"}, Description: "无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。"},
|
|
{ID: "shadowsocks-2022-aes128", Role: "relay-ingress", Name: "SS 2022 · AES-128", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "轻量、推荐的 AEAD 2022 入口。"},
|
|
{ID: "shadowsocks-2022-aes256", Role: "relay-ingress", Name: "SS 2022 · AES-256", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "32 字节密钥的 AEAD 2022 入口。"},
|
|
{ID: "shadowsocks-aes128-gcm", Role: "relay-ingress", Name: "SS · AES-128-GCM", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "兼容主流旧版 Shadowsocks 客户端。"},
|
|
{ID: "shadowsocks-aes256-gcm", Role: "relay-ingress", Name: "SS · AES-256-GCM", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "兼容主流旧版 Shadowsocks 客户端。"},
|
|
{ID: "socks5-auth", Role: "relay-ingress", Name: "SOCKS5 · 用户密码", Status: "stable", Deployable: true, Capabilities: []string{"sing-box"}, Description: "适合受控网络或程序级代理。"},
|
|
{ID: "vless-ws-tls", Role: "relay-ingress", Name: "VLESS · WS · TLS", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "tls-domain"}, Description: "需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。"},
|
|
{ID: "hysteria2", Role: "relay-ingress", Name: "Hysteria 2 · UDP", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "udp", "tls-domain"}, Description: "由 sing-box 承载;入口须安装有效节点证书并放行所选 UDP 端口。"},
|
|
{ID: "anytls", Role: "relay-ingress", Name: "AnyTLS · TLS", Status: "beta", Deployable: true, Capabilities: []string{"sing-box", "tls-domain"}, Description: "由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。"},
|
|
{ID: "shadowsocks-2022-aes128", Role: "exit-transport", Name: "SS 2022 · AES-128", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "推荐的转发到出口传输。"},
|
|
{ID: "shadowsocks-2022-aes256", Role: "exit-transport", Name: "SS 2022 · AES-256", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "增强密钥长度的转发传输。"},
|
|
{ID: "shadowsocks-aes128-gcm", Role: "exit-transport", Name: "SS · AES-128-GCM", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "兼容旧环境的转发传输。"},
|
|
{ID: "shadowsocks-aes256-gcm", Role: "exit-transport", Name: "SS · AES-256-GCM", Status: "stable", Deployable: true, Capabilities: []string{"encrypted"}, Description: "兼容旧环境的转发传输。"},
|
|
{ID: "vless-tcp", Role: "exit-transport", Name: "VLESS · TCP", Status: "beta", Deployable: true, Capabilities: []string{}, Description: "适合可信内网或已有加密隧道,不建议裸露公网。"},
|
|
{ID: "socks5-auth", Role: "exit-transport", Name: "SOCKS5 · 用户密码", Status: "beta", Deployable: true, Capabilities: []string{}, Description: "不加密,仅用于可信网络或外层隧道。"},
|
|
}
|
|
|
|
type SSMethod struct {
|
|
Method string
|
|
KeyBytes int
|
|
}
|
|
|
|
var ShadowsocksMethods = map[string]SSMethod{
|
|
"shadowsocks-2022-aes128": {Method: "2022-blake3-aes-128-gcm", KeyBytes: 16},
|
|
"shadowsocks-2022-aes256": {Method: "2022-blake3-aes-256-gcm", KeyBytes: 32},
|
|
"shadowsocks-aes128-gcm": {Method: "aes-128-gcm", KeyBytes: 24},
|
|
"shadowsocks-aes256-gcm": {Method: "aes-256-gcm", KeyBytes: 32},
|
|
}
|
|
|
|
// ---------- small helpers ----------
|
|
|
|
func xbDeref(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func xbDerefInt(p *int) int {
|
|
if p == nil {
|
|
return 0
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func xbRandBytes(n int) []byte {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// b64 mirrors JS b64(): standard base64 (with padding) of n random bytes.
|
|
func b64(n int) string { return base64.StdEncoding.EncodeToString(xbRandBytes(n)) }
|
|
|
|
func shortId() string { return hex.EncodeToString(xbRandBytes(8)) }
|
|
|
|
func xbRandInt(n int) int {
|
|
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return int(v.Int64())
|
|
}
|
|
|
|
// ---------- encoding helpers (JS-exact) ----------
|
|
|
|
const xbHexUpper = "0123456789ABCDEF"
|
|
|
|
func xbPctEncodeByte(sb *strings.Builder, c byte) {
|
|
sb.WriteByte('%')
|
|
sb.WriteByte(xbHexUpper[c>>4])
|
|
sb.WriteByte(xbHexUpper[c&0x0f])
|
|
}
|
|
|
|
// formEncode is URLSearchParams' application/x-www-form-urlencoded
|
|
// serialization: alphanumerics and * - . _ pass through, space becomes +,
|
|
// everything else (including ~) is %XX uppercase.
|
|
func formEncode(s string) string {
|
|
var sb strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' ||
|
|
c == '*' || c == '-' || c == '.' || c == '_':
|
|
sb.WriteByte(c)
|
|
case c == ' ':
|
|
sb.WriteByte('+')
|
|
default:
|
|
xbPctEncodeByte(&sb, c)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// encodeURIComponent: A-Za-z0-9-_.!~*'() pass through, everything else %XX.
|
|
func encodeURIComponent(s string) string {
|
|
var sb strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' ||
|
|
c == '-' || c == '_' || c == '.' || c == '!' || c == '~' || c == '*' ||
|
|
c == '\'' || c == '(' || c == ')':
|
|
sb.WriteByte(c)
|
|
default:
|
|
xbPctEncodeByte(&sb, c)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
type xbQueryParam struct{ k, v string }
|
|
|
|
type xbQuery []xbQueryParam
|
|
|
|
func (q *xbQuery) add(k, v string) { *q = append(*q, xbQueryParam{k, v}) }
|
|
|
|
// set mirrors URLSearchParams.set: replace first occurrence, drop later
|
|
// duplicates, append when absent.
|
|
func (q *xbQuery) set(k, v string) {
|
|
out := (*q)[:0]
|
|
found := false
|
|
for _, p := range *q {
|
|
if p.k == k {
|
|
if found {
|
|
continue
|
|
}
|
|
found = true
|
|
out = append(out, xbQueryParam{k, v})
|
|
} else {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
if !found {
|
|
out = append(out, xbQueryParam{k, v})
|
|
}
|
|
*q = out
|
|
}
|
|
|
|
func (q xbQuery) String() string {
|
|
parts := make([]string, len(q))
|
|
for i, p := range q {
|
|
parts[i] = formEncode(p.k) + "=" + formEncode(p.v)
|
|
}
|
|
return strings.Join(parts, "&")
|
|
}
|
|
|
|
// ---------- node secrets ----------
|
|
|
|
func NodeEmail(customerID string) string { return "ng:" + customerID }
|
|
|
|
// NewCustomerCredentials mints the single credential set a customer uses on
|
|
// every node (round-2 item 2, 3x-ui model). The relay password is always a
|
|
// base64 32-byte root key: SS2022 user keys are derived from it per method
|
|
// (see EffectiveRelayPassword), so one set serves aes128 and aes256 nodes
|
|
// alike; every other protocol uses it verbatim.
|
|
func NewCustomerCredentials() Credentials {
|
|
return Credentials{
|
|
ClientID: NewUUID(),
|
|
RelayPassword: b64(32),
|
|
ShortID: shortId(),
|
|
}
|
|
}
|
|
|
|
// EffectiveRelayPassword maps a customer's relay password onto a node's
|
|
// protocol. SS2022 methods require the user key to be base64 of exactly the
|
|
// method's key length, so the user key is the deterministic prefix
|
|
// truncation of the 32-byte root key. Legacy SS methods and all other
|
|
// protocols take the password verbatim.
|
|
func EffectiveRelayPassword(protocol, relayPassword string) string {
|
|
cipher, ok := ShadowsocksMethods[protocol]
|
|
if !ok || !strings.HasPrefix(cipher.Method, "2022-blake3-") {
|
|
return relayPassword
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(relayPassword)
|
|
if err != nil || len(raw) < cipher.KeyBytes {
|
|
return relayPassword
|
|
}
|
|
return base64.StdEncoding.EncodeToString(raw[:cipher.KeyBytes])
|
|
}
|
|
|
|
// clientCredentials resolves the credentials stamped onto a binding for
|
|
// resource building. Stored bindings no longer carry credentials (they live
|
|
// on the customer); builders receive resolved copies, and this accessor
|
|
// keeps a nil hole from becoming a panic.
|
|
func clientCredentials(nc *NodeClient) Credentials {
|
|
if nc == nil || nc.Credentials == nil {
|
|
return Credentials{}
|
|
}
|
|
return *nc.Credentials
|
|
}
|
|
|
|
func EnsureNodeSecrets(node *Node) {
|
|
if node.WSPath == nil || *node.WSPath == "" {
|
|
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
|
|
}
|
|
if cipher, ok := ShadowsocksMethods[node.Protocol]; ok {
|
|
if node.SSServerKey == nil || *node.SSServerKey == "" {
|
|
node.SSServerKey = StrPtr(b64(cipher.KeyBytes))
|
|
}
|
|
}
|
|
if IsRealityProtocol(node.Protocol) {
|
|
if node.Reality == nil {
|
|
node.Reality = &RealityConfig{KeyID: "reality-" + node.ID}
|
|
}
|
|
serverName := node.RealityServerName
|
|
if serverName == "" {
|
|
serverName = "www.tesla.com"
|
|
}
|
|
node.Reality.ServerName = serverName
|
|
destPort := node.RealityDestPort
|
|
if destPort == 0 {
|
|
destPort = 443
|
|
}
|
|
node.Reality.DestPort = destPort
|
|
}
|
|
topology := node.Topology
|
|
if topology == "" {
|
|
topology = "forward"
|
|
}
|
|
if topology != "direct" && node.ExitCredentials == nil {
|
|
keyBytes := 24
|
|
if cipher, ok := ShadowsocksMethods[xbDeref(node.ExitProtocol)]; ok {
|
|
keyBytes = cipher.KeyBytes
|
|
}
|
|
node.ExitCredentials = &ExitCredentials{
|
|
ClientID: NewUUID(),
|
|
Password: b64(keyBytes),
|
|
SocksUser: "ng_" + hex.EncodeToString(xbRandBytes(5)),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- addresses ----------
|
|
|
|
func listenAddress(networkMode string, server *Server, bindAddress string) string {
|
|
if bindAddress != "" {
|
|
return bindAddress
|
|
}
|
|
if networkMode == "ipv6" {
|
|
if server != nil && server.PublicAddressV6 != "" {
|
|
return server.PublicAddressV6
|
|
}
|
|
return "::"
|
|
}
|
|
if networkMode == "dual" {
|
|
return "::"
|
|
}
|
|
if server != nil && server.PublicAddress != "" {
|
|
return server.PublicAddress
|
|
}
|
|
return "0.0.0.0"
|
|
}
|
|
|
|
func serverAddress(server *Server, networkMode string) (string, error) {
|
|
if server == nil {
|
|
return "", errors.New("Missing server for address selection")
|
|
}
|
|
if networkMode == "ipv6" {
|
|
return server.PublicAddressV6, nil
|
|
}
|
|
return server.PublicAddress, nil
|
|
}
|
|
|
|
// ResolveBindAddress mirrors resolveBindAddress: dual/absent -> ("", nil).
|
|
// DNS failures are HTTPError 400; the parenthesized code approximates
|
|
// err.code with the Go resolver's short error text (e.g. "no such host"),
|
|
// since Go exposes no getaddrinfo code.
|
|
func ResolveBindAddress(networkMode string, server *Server) (string, error) {
|
|
if networkMode == "dual" {
|
|
return "", nil
|
|
}
|
|
var raw string
|
|
if server != nil {
|
|
if networkMode == "ipv6" {
|
|
raw = server.PublicAddressV6
|
|
} else {
|
|
raw = server.PublicAddress
|
|
}
|
|
}
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
text := strings.TrimSpace(raw)
|
|
family := 4
|
|
record := "A"
|
|
network := "ip4"
|
|
if networkMode == "ipv6" {
|
|
family = 6
|
|
record = "AAAA"
|
|
network = "ip6"
|
|
}
|
|
_ = family
|
|
if ip := net.ParseIP(text); ip != nil {
|
|
return text, nil
|
|
}
|
|
addrs, err := net.DefaultResolver.LookupIP(context.Background(), network, text)
|
|
if err != nil || len(addrs) == 0 {
|
|
code := ""
|
|
if err != nil {
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) && dnsErr.Err != "" {
|
|
code = dnsErr.Err
|
|
} else {
|
|
code = err.Error()
|
|
}
|
|
} else {
|
|
code = "no addresses"
|
|
}
|
|
return "", httpErr(400, "绑定地址 DNS 解析失败:%s 没有 %s 记录(%s),请检查该服务器登记的地址", text, record, code)
|
|
}
|
|
return addrs[0].String(), nil
|
|
}
|
|
|
|
// ---------- sing-box resource builders ----------
|
|
|
|
func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, server *Server, bindAddress string) (map[string]any, error) {
|
|
protocol := node.Protocol
|
|
port := node.Port
|
|
networkMode := node.NetworkMode
|
|
if networkMode == "" {
|
|
networkMode = "ipv4"
|
|
}
|
|
listen := listenAddress(networkMode, server, bindAddress)
|
|
tlsDomain := ""
|
|
if server != nil {
|
|
tlsDomain = server.TLSDomain
|
|
}
|
|
|
|
if protocol == "vless-reality-vision" || protocol == "vless-reality" {
|
|
if node.Reality == nil {
|
|
return nil, errors.New("Reality 节点缺少密钥配置")
|
|
}
|
|
users := make([]any, 0, len(nodeClients))
|
|
shortIDs := make([]any, 0, len(nodeClients))
|
|
for _, nc := range nodeClients {
|
|
creds := clientCredentials(nc)
|
|
user := map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": creds.ClientID}
|
|
if protocol == "vless-reality-vision" {
|
|
user["flow"] = "xtls-rprx-vision"
|
|
}
|
|
users = append(users, user)
|
|
shortIDs = append(shortIDs, creds.ShortID)
|
|
}
|
|
destPort := node.Reality.DestPort
|
|
if destPort == 0 {
|
|
destPort = 443
|
|
}
|
|
return map[string]any{
|
|
"type": "vless", "tag": tag, "listen": listen, "listen_port": port, "users": users,
|
|
"tls": map[string]any{
|
|
"enabled": true, "server_name": node.Reality.ServerName,
|
|
"reality": map[string]any{
|
|
"enabled": true,
|
|
"handshake": map[string]any{"server": node.Reality.ServerName, "server_port": destPort},
|
|
"private_key": "${REALITY_PRIVATE:" + node.Reality.KeyID + "}",
|
|
"short_id": shortIDs,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
if protocol == "vless-ws" || protocol == "vless-ws-tls" {
|
|
if protocol == "vless-ws-tls" && tlsDomain == "" {
|
|
return nil, errors.New("TLS 节点缺少有效证书域名")
|
|
}
|
|
users := make([]any, 0, len(nodeClients))
|
|
for _, nc := range nodeClients {
|
|
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": clientCredentials(nc).ClientID})
|
|
}
|
|
wsPath := xbDeref(node.WSPath)
|
|
if wsPath == "" {
|
|
wsPath = "/"
|
|
}
|
|
inbound := map[string]any{
|
|
"type": "vless", "tag": tag, "listen": listen, "listen_port": port, "users": users,
|
|
"transport": map[string]any{"type": "ws", "path": wsPath},
|
|
}
|
|
if protocol == "vless-ws-tls" {
|
|
inbound["tls"] = map[string]any{
|
|
"enabled": true, "server_name": tlsDomain,
|
|
"certificate_path": "/etc/nexusgate/tls/" + tlsDomain + "/fullchain.pem",
|
|
"key_path": "/etc/nexusgate/tls/" + tlsDomain + "/privkey.pem",
|
|
}
|
|
}
|
|
return inbound, nil
|
|
}
|
|
|
|
if cipher, ok := ShadowsocksMethods[protocol]; ok {
|
|
users := make([]any, 0, len(nodeClients))
|
|
for _, nc := range nodeClients {
|
|
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": EffectiveRelayPassword(protocol, clientCredentials(nc).RelayPassword)})
|
|
}
|
|
return map[string]any{
|
|
"type": "shadowsocks", "tag": tag, "listen": listen, "listen_port": port,
|
|
"method": cipher.Method, "password": xbDeref(node.SSServerKey), "users": users,
|
|
}, nil
|
|
}
|
|
|
|
if protocol == "socks5-auth" {
|
|
users := make([]any, 0, len(nodeClients))
|
|
for _, nc := range nodeClients {
|
|
users = append(users, map[string]any{"username": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
|
|
}
|
|
return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil
|
|
}
|
|
|
|
if protocol == "hysteria2" {
|
|
if tlsDomain == "" {
|
|
return nil, errors.New("Hysteria 2 入口缺少有效证书域名")
|
|
}
|
|
users := make([]any, 0, len(nodeClients))
|
|
for _, nc := range nodeClients {
|
|
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
|
|
}
|
|
return map[string]any{
|
|
"type": "hysteria2", "tag": tag, "listen": listen, "listen_port": port, "users": users,
|
|
"tls": map[string]any{
|
|
"enabled": true, "server_name": tlsDomain, "alpn": []any{"h3"},
|
|
"certificate_path": "/etc/nexusgate/tls/" + tlsDomain + "/fullchain.pem",
|
|
"key_path": "/etc/nexusgate/tls/" + tlsDomain + "/privkey.pem",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
if protocol == "anytls" {
|
|
if tlsDomain == "" {
|
|
return nil, errors.New("AnyTLS 入口缺少有效证书域名")
|
|
}
|
|
users := make([]any, 0, len(nodeClients))
|
|
for _, nc := range nodeClients {
|
|
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword})
|
|
}
|
|
return map[string]any{
|
|
"type": "anytls", "tag": tag, "listen": listen, "listen_port": port, "users": users,
|
|
"tls": map[string]any{
|
|
"enabled": true, "server_name": tlsDomain,
|
|
"certificate_path": "/etc/nexusgate/tls/" + tlsDomain + "/fullchain.pem",
|
|
"key_path": "/etc/nexusgate/tls/" + tlsDomain + "/privkey.pem",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("sing-box 不支持的协议: %s", protocol)
|
|
}
|
|
|
|
type BuildNodeResourceParams struct {
|
|
ResourceID string
|
|
TagPrefix string
|
|
Node *Node
|
|
NodeClients []*NodeClient
|
|
Server *Server
|
|
BindAddress string
|
|
ExitServer *Server
|
|
}
|
|
|
|
func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
|
|
node := p.Node
|
|
EnsureNodeSecrets(node)
|
|
tag := p.TagPrefix + "-in"
|
|
topology := node.Topology
|
|
if topology == "" {
|
|
topology = "forward"
|
|
}
|
|
forward := topology == "forward"
|
|
outboundTag := p.TagPrefix + "-direct"
|
|
if forward {
|
|
outboundTag = p.TagPrefix + "-exit"
|
|
}
|
|
networkMode := node.NetworkMode
|
|
if networkMode == "" {
|
|
networkMode = "ipv4"
|
|
}
|
|
var outbound map[string]any
|
|
if !forward {
|
|
outbound = map[string]any{"type": "direct", "tag": outboundTag}
|
|
} else {
|
|
exitAddr, err := serverAddress(p.ExitServer, networkMode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base := map[string]any{"tag": outboundTag, "server": exitAddr, "server_port": xbDerefInt(node.ExitPort)}
|
|
creds := node.ExitCredentials
|
|
if creds == nil {
|
|
creds = &ExitCredentials{}
|
|
}
|
|
exitProtocol := xbDeref(node.ExitProtocol)
|
|
if cipher, ok := ShadowsocksMethods[exitProtocol]; ok {
|
|
outbound = cloneMap(base)
|
|
outbound["type"] = "shadowsocks"
|
|
outbound["method"] = cipher.Method
|
|
outbound["password"] = creds.Password
|
|
} else if exitProtocol == "socks5-auth" {
|
|
outbound = cloneMap(base)
|
|
outbound["type"] = "socks"
|
|
outbound["version"] = "5"
|
|
outbound["username"] = creds.SocksUser
|
|
outbound["password"] = creds.Password
|
|
} else if exitProtocol == "vless-tcp" {
|
|
outbound = cloneMap(base)
|
|
outbound["type"] = "vless"
|
|
outbound["uuid"] = creds.ClientID
|
|
} else {
|
|
return nil, fmt.Errorf("Unsupported exit transport: %s", exitProtocol)
|
|
}
|
|
}
|
|
inbound, err := buildSingBoxInbound(tag, node, p.NodeClients, p.Server, p.BindAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": p.ResourceID, "engine": "sing-box",
|
|
"meta": map[string]any{"kind": "node", "nodeId": node.ID, "metricsTag": tag, "protocol": node.Protocol},
|
|
"inbounds": []any{inbound},
|
|
"outbounds": []any{outbound},
|
|
"routingRules": []any{
|
|
map[string]any{"inbound": []any{tag}, "action": "route", "outbound": outboundTag},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func cloneMap(m map[string]any) map[string]any {
|
|
out := make(map[string]any, len(m))
|
|
for k, v := range m {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
type BuildNodeExitResourceParams struct {
|
|
ResourceID string
|
|
TagPrefix string
|
|
Node *Node
|
|
Server *Server
|
|
BindAddress string
|
|
}
|
|
|
|
func BuildNodeExitResource(p BuildNodeExitResourceParams) (map[string]any, error) {
|
|
node := p.Node
|
|
tag := p.TagPrefix + "-exit-in"
|
|
directTag := p.TagPrefix + "-exit-direct"
|
|
protocol := xbDeref(node.ExitProtocol)
|
|
creds := node.ExitCredentials
|
|
if creds == nil {
|
|
creds = &ExitCredentials{}
|
|
}
|
|
networkMode := node.NetworkMode
|
|
if networkMode == "" {
|
|
networkMode = "ipv4"
|
|
}
|
|
listen := listenAddress(networkMode, p.Server, p.BindAddress)
|
|
var inbound map[string]any
|
|
if cipher, ok := ShadowsocksMethods[protocol]; ok {
|
|
inbound = map[string]any{
|
|
"type": "shadowsocks", "tag": tag, "listen": listen, "listen_port": xbDerefInt(node.ExitPort),
|
|
"method": cipher.Method, "password": creds.Password,
|
|
}
|
|
} else if protocol == "vless-tcp" {
|
|
inbound = map[string]any{
|
|
"type": "vless", "tag": tag, "listen": listen, "listen_port": xbDerefInt(node.ExitPort),
|
|
"users": []any{map[string]any{"uuid": creds.ClientID}},
|
|
}
|
|
} else if protocol == "socks5-auth" {
|
|
inbound = map[string]any{
|
|
"type": "socks", "tag": tag, "listen": listen, "listen_port": xbDerefInt(node.ExitPort),
|
|
"users": []any{map[string]any{"username": creds.SocksUser, "password": creds.Password}},
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("Unsupported exit transport: %s", protocol)
|
|
}
|
|
return map[string]any{
|
|
"id": p.ResourceID, "engine": "sing-box",
|
|
"meta": map[string]any{"kind": "node-exit", "nodeId": node.ID, "metricsTag": tag},
|
|
"inbounds": []any{inbound},
|
|
"outbounds": []any{
|
|
map[string]any{"type": "direct", "tag": directTag},
|
|
},
|
|
"routingRules": []any{
|
|
map[string]any{"inbound": []any{tag}, "action": "route", "outbound": directTag},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ---------- client URIs ----------
|
|
|
|
func uriHost(address string) string {
|
|
if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") {
|
|
return "[" + address + "]"
|
|
}
|
|
return address
|
|
}
|
|
|
|
type BuildClientURIParams struct {
|
|
Protocol string
|
|
RelayServer *Server
|
|
RelayPort int
|
|
ClientID string
|
|
RelayPassword string
|
|
ShortID string
|
|
WSPath string
|
|
SocksUser string
|
|
Reality *RealityConfig
|
|
PublicKey string
|
|
Name string
|
|
NetworkMode string
|
|
TLSDomain string
|
|
SSServerKey string
|
|
}
|
|
|
|
// BuildClientURI mirrors buildClientUri; ok=false mirrors JS null.
|
|
func BuildClientURI(p BuildClientURIParams) (string, bool) {
|
|
label := encodeURIComponent(p.Name)
|
|
publicAddress, err := serverAddress(p.RelayServer, p.NetworkMode)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
host := uriHost(publicAddress)
|
|
protocol := p.Protocol
|
|
if protocol == "hysteria2" {
|
|
var q xbQuery
|
|
q.add("sni", p.TLSDomain)
|
|
q.add("alpn", "h3")
|
|
return fmt.Sprintf("hysteria2://%s@%s:%d?%s#%s", encodeURIComponent(p.RelayPassword), host, p.RelayPort, q.String(), label), true
|
|
}
|
|
if protocol == "anytls" {
|
|
var q xbQuery
|
|
q.add("sni", p.TLSDomain)
|
|
return fmt.Sprintf("anytls://%s@%s:%d?%s#%s", encodeURIComponent(p.RelayPassword), host, p.RelayPort, q.String(), label), true
|
|
}
|
|
if protocol == "vless-reality-vision" || protocol == "vless-reality" {
|
|
if p.PublicKey == "" {
|
|
return "", false
|
|
}
|
|
serverName := ""
|
|
if p.Reality != nil {
|
|
serverName = p.Reality.ServerName
|
|
}
|
|
var q xbQuery
|
|
q.add("encryption", "none")
|
|
q.add("security", "reality")
|
|
q.add("sni", serverName)
|
|
q.add("fp", "chrome")
|
|
q.add("pbk", p.PublicKey)
|
|
q.add("sid", p.ShortID)
|
|
q.add("type", "tcp")
|
|
q.add("headerType", "none")
|
|
if protocol == "vless-reality-vision" {
|
|
q.set("flow", "xtls-rprx-vision")
|
|
}
|
|
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", p.ClientID, host, p.RelayPort, q.String(), label), true
|
|
}
|
|
if protocol == "vless-ws" || protocol == "vless-ws-tls" {
|
|
security := "none"
|
|
if protocol == "vless-ws-tls" {
|
|
security = "tls"
|
|
}
|
|
var q xbQuery
|
|
q.add("encryption", "none")
|
|
q.add("security", security)
|
|
q.add("type", "ws")
|
|
q.add("path", p.WSPath)
|
|
if protocol == "vless-ws-tls" {
|
|
q.set("sni", p.TLSDomain)
|
|
}
|
|
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", p.ClientID, host, p.RelayPort, q.String(), label), true
|
|
}
|
|
if cipher, ok := ShadowsocksMethods[protocol]; ok {
|
|
password := EffectiveRelayPassword(protocol, p.RelayPassword)
|
|
if strings.HasPrefix(cipher.Method, "2022-blake3-") && p.SSServerKey != "" {
|
|
password = p.SSServerKey + ":" + password
|
|
}
|
|
user := base64.RawURLEncoding.EncodeToString([]byte(cipher.Method + ":" + password))
|
|
return fmt.Sprintf("ss://%s@%s:%d#%s", user, host, p.RelayPort, label), true
|
|
}
|
|
if protocol == "socks5-auth" {
|
|
return fmt.Sprintf("socks5://%s:%s@%s:%d#%s", encodeURIComponent(p.SocksUser), encodeURIComponent(p.RelayPassword), host, p.RelayPort, label), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// ---------- validation ----------
|
|
|
|
func profileFor(role, protocol string) *Profile {
|
|
for i := range ProfileCatalog {
|
|
if ProfileCatalog[i].Role == role && ProfileCatalog[i].ID == protocol {
|
|
return &ProfileCatalog[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateEntryProtocol(protocol string) *HTTPError {
|
|
item := profileFor("relay-ingress", protocol)
|
|
if item == nil || !item.Deployable {
|
|
return httpErr(400, "Unsupported relay ingress: %s", protocol)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateProtocolPair(relayProtocol, exitProtocol string) *HTTPError {
|
|
if err := ValidateEntryProtocol(relayProtocol); err != nil {
|
|
return err
|
|
}
|
|
exit := profileFor("exit-transport", exitProtocol)
|
|
if exit == nil || !exit.Deployable {
|
|
return httpErr(400, "Unsupported exit transport: %s", exitProtocol)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func IsRealityProtocol(protocol string) bool {
|
|
return protocol == "vless-reality-vision" || protocol == "vless-reality"
|
|
}
|
|
|
|
// ---------- Reality keys ----------
|
|
|
|
func GenerateRealityKeyPair() KeyPair {
|
|
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return KeyPair{
|
|
PrivateKey: base64.RawURLEncoding.EncodeToString(priv.Bytes()),
|
|
PublicKey: base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()),
|
|
}
|
|
}
|
|
|
|
func realityPublicKeyFromPrivate(privateKey string) (string, error) {
|
|
raw, err := base64.RawURLEncoding.DecodeString(privateKey)
|
|
if err != nil {
|
|
// Tolerate padded input.
|
|
raw, err = base64.URLEncoding.DecodeString(privateKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
priv, err := ecdh.X25519().NewPrivateKey(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()), nil
|
|
}
|
|
|
|
func RealityKeyIDForNode(node *Node) string {
|
|
if node.Reality != nil && node.Reality.KeyID != "" {
|
|
return node.Reality.KeyID
|
|
}
|
|
return "reality-" + node.ID
|
|
}
|
|
|
|
func EnsureRealityKey(d *Data, node *Node) *KeyEntry {
|
|
if !IsRealityProtocol(node.Protocol) {
|
|
return nil
|
|
}
|
|
EnsureNodeSecrets(node)
|
|
keyID := RealityKeyIDForNode(node)
|
|
if d.KeyStore == nil {
|
|
d.KeyStore = map[string]*KeyEntry{}
|
|
}
|
|
entry := d.KeyStore[keyID]
|
|
if entry == nil || entry.PrivateKey == "" || entry.PublicKey == "" {
|
|
pair := GenerateRealityKeyPair()
|
|
entry = &KeyEntry{PrivateKey: pair.PrivateKey, PublicKey: pair.PublicKey, UpdatedAt: NowISO()}
|
|
d.KeyStore[keyID] = entry
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func KeyMaterialForNode(d *Data, node *Node) map[string]KeyPair {
|
|
entry := EnsureRealityKey(d, node)
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
return map[string]KeyPair{
|
|
RealityKeyIDForNode(node): {PrivateKey: entry.PrivateKey, PublicKey: entry.PublicKey},
|
|
}
|
|
}
|
|
|
|
func DropRealityKey(d *Data, node *Node) {
|
|
if d.KeyStore != nil {
|
|
delete(d.KeyStore, RealityKeyIDForNode(node))
|
|
}
|
|
}
|