feat: customer-level credentials shared across all nodes (item 2)
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
This commit is contained in:
+45
-15
@@ -196,18 +196,47 @@ func (q xbQuery) String() string {
|
||||
|
||||
func NodeEmail(customerID string) string { return "ng:" + customerID }
|
||||
|
||||
func NewNodeClientCredentials(protocol string) Credentials {
|
||||
keyBytes := 24
|
||||
if cipher, ok := ShadowsocksMethods[protocol]; ok {
|
||||
keyBytes = cipher.KeyBytes
|
||||
}
|
||||
// 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(keyBytes),
|
||||
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)))
|
||||
@@ -352,12 +381,13 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
users := make([]any, 0, len(nodeClients))
|
||||
shortIDs := make([]any, 0, len(nodeClients))
|
||||
for _, nc := range nodeClients {
|
||||
user := map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": nc.Credentials.ClientID}
|
||||
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, nc.Credentials.ShortID)
|
||||
shortIDs = append(shortIDs, creds.ShortID)
|
||||
}
|
||||
destPort := node.Reality.DestPort
|
||||
if destPort == 0 {
|
||||
@@ -383,7 +413,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
}
|
||||
users := make([]any, 0, len(nodeClients))
|
||||
for _, nc := range nodeClients {
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": nc.Credentials.ClientID})
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "uuid": clientCredentials(nc).ClientID})
|
||||
}
|
||||
wsPath := xbDeref(node.WSPath)
|
||||
if wsPath == "" {
|
||||
@@ -406,7 +436,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
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": nc.Credentials.RelayPassword})
|
||||
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,
|
||||
@@ -417,7 +447,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
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": nc.Credentials.RelayPassword})
|
||||
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
|
||||
}
|
||||
@@ -428,7 +458,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
}
|
||||
users := make([]any, 0, len(nodeClients))
|
||||
for _, nc := range nodeClients {
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
|
||||
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,
|
||||
@@ -446,7 +476,7 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
|
||||
}
|
||||
users := make([]any, 0, len(nodeClients))
|
||||
for _, nc := range nodeClients {
|
||||
users = append(users, map[string]any{"name": NodeEmail(nc.CustomerID), "password": nc.Credentials.RelayPassword})
|
||||
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,
|
||||
@@ -682,9 +712,9 @@ func BuildClientURI(p BuildClientURIParams) (string, bool) {
|
||||
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 := p.RelayPassword
|
||||
password := EffectiveRelayPassword(protocol, p.RelayPassword)
|
||||
if strings.HasPrefix(cipher.Method, "2022-blake3-") && p.SSServerKey != "" {
|
||||
password = p.SSServerKey + ":" + p.RelayPassword
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user