Files
NexusGate/panel-go/protocols.go
aaaamoooo 0037116edc feat: manual landing endpoints for forward nodes (item 8)
A forward node's exit target is now a choice: a managed panel server
(existing behavior) or a manual landing endpoint — address + port +
protocol (VLESS / SS2022 / SOCKS5) + operator-supplied credentials,
including third-party nodes the panel does not run. The panel only
builds the entry-side sing-box outbound and route rule; there is no
exit resource, no exit job, and the node is exit-ready by definition.

- model: Node.ExitMode/ExitAddress; manual exits force fixed port mode
  and skip the exit-server port table entirely
- validation: address/port required; per-protocol credential presence;
  SS2022 passwords must be base64 key material of the method's key
  length (one PSK segment or serverKey:userKey) — the exact form
  sing-box accepts, rejected up front instead of failing at deploy
- orchestration: resource building, deploy validation and rebuilds all
  branch on manualExit; switching managed->manual queues removal of the
  old exit resource, manual->managed mints a fresh exit resource id
  (deploy validation previously dereferenced the nil exit server —
  regression test locks the fix)
- UI: exit target segmented control, link paste box that parses
  vless:// / ss:// / socks5:// links into the form, per-protocol
  credential fields (blank keeps the stored value on edit)
- golden: new manualCreate/manualAssign/manualDeploy/manualJobs/
  manualNodes sections — exactly one entry-side job whose outbound
  targets the manual address; all prior sections unchanged
- the generated combined sing-box config passes the real sing-box
  1.13.21 check
2026-10-21 11:18:00 +08:00

838 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
// ExitAddress, when non-empty, is the manual landing endpoint the
// entry outbound connects to (no managed exit server involved).
ExitAddress string
}
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 := p.ExitAddress
if exitAddr == "" {
var err error
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))
}
}