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:
aaaamoooo
2026-10-20 21:21:13 +00:00
parent bb8a0cb0c4
commit ad18e16ac6
16 changed files with 673 additions and 272 deletions
+72 -63
View File
@@ -603,9 +603,40 @@ func CustomerSummary(data *Data, nowMs int64) map[string]any {
}
}
// credentialItems mirrors credentialItems in server.js.
func credentialItems(d *Data, customer *Customer) []map[string]any {
items := []map[string]any{}
// realityPublicKeyOf returns the node's panel-hosted Reality public key as
// reported back by the agent ("" when unknown). The keypair is the server's
// identity — it stays per-node even though customer credentials are shared.
func realityPublicKeyOf(node *Node) string {
if node == nil {
return ""
}
if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
}
return ""
}
// customerCredentialsView is the credentials-tab payload (round-2 item 2):
// the customer's ONE credential set, plus per-binding node facts. The
// Reality public key is shown per node because it belongs to the server,
// not to the customer.
func customerCredentialsView(d *Data, customer *Customer) map[string]any {
creds := map[string]any{"clientId": nil, "password": nil, "shortId": nil}
if customer.Credentials != nil {
if customer.Credentials.ClientID != "" {
creds["clientId"] = customer.Credentials.ClientID
}
if customer.Credentials.RelayPassword != "" {
creds["password"] = customer.Credentials.RelayPassword
}
if customer.Credentials.ShortID != "" {
creds["shortId"] = customer.Credentials.ShortID
}
}
bindings := []map[string]any{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
continue
@@ -618,30 +649,26 @@ func credentialItems(d *Data, customer *Customer) []map[string]any {
if node.Protocol == "socks5-auth" {
username = NodeEmail(customer.ID)
}
creds := nc.Credentials
var clientID, password, shortID any
if creds.ClientID != "" {
clientID = creds.ClientID
var publicKey any
if key := realityPublicKeyOf(node); key != "" {
publicKey = key
}
if creds.RelayPassword != "" {
password = creds.RelayPassword
}
if creds.ShortID != "" {
shortID = creds.ShortID
}
items = append(items, map[string]any{
"nodeClientId": nc.ID,
"nodeId": node.ID,
"nodeName": node.Name,
"protocol": node.Protocol,
"status": nc.Status,
"clientId": clientID,
"password": password,
"shortId": shortID,
"username": username,
bindings = append(bindings, map[string]any{
"nodeClientId": nc.ID,
"nodeId": node.ID,
"nodeName": node.Name,
"protocol": node.Protocol,
"status": nc.Status,
"username": username,
"realityPublicKey": publicKey,
})
}
return items
return map[string]any{
"customerId": customer.ID,
"subscriptionToken": customer.SubscriptionToken,
"credentials": creds,
"bindings": bindings,
}
}
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
@@ -1926,6 +1953,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
Notes: CleanText(body["notes"], 1000),
CreatedAt: NowISO(),
UpdatedAt: NowISO(),
Credentials: func() *Credentials { c := NewCustomerCredentials(); return &c }(),
}
// Arm the monthly-reset schedule: it starts with the next
// reset instant, it never fires retroactively at creation.
@@ -2131,54 +2159,44 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if customer == nil {
return true, httpErr(404, "客户不存在")
}
SendJSON(w, 200, map[string]any{
"customerId": customer.ID,
"subscriptionToken": customer.SubscriptionToken,
"items": credentialItems(data, customer),
}, nil)
SendJSON(w, 200, customerCredentialsView(data, customer), nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/credentials/:ncid/regenerate", pathname); ok && r.Method == http.MethodPost {
var responseItem map[string]any
if params, ok := RoutePattern("/api/customers/:id/credentials/regenerate", pathname); ok && r.Method == http.MethodPost {
var view map[string]any
_, err := a.store.Transaction(func(d *Data) (any, error) {
customer := findCustomer(d, params["id"])
if customer == nil {
return nil, httpErr(404, "客户不存在")
}
var binding *NodeClient
// One set for the whole customer: rotating it rotates every
// node at once, and every deployed node is rebuilt so the new
// users land on the agents.
creds := NewCustomerCredentials()
customer.Credentials = &creds
customer.UpdatedAt = NowISO()
rebuilt := map[string]bool{}
for _, nc := range d.NodeClients {
if nc.ID == params["ncid"] && nc.CustomerID == customer.ID {
binding = nc
break
if nc.CustomerID != customer.ID || nc.Status != "active" {
continue
}
}
if binding == nil {
return nil, httpErr(404, "凭据不存在")
}
node := findNode(d, binding.NodeID)
if node == nil {
return nil, httpErr(404, "节点不存在")
}
binding.Credentials = NewNodeClientCredentials(node.Protocol)
binding.UpdatedAt = NowISO()
if node.Status != "draft" && node.Status != "removing" {
node := findNode(d, nc.NodeID)
if node == nil || node.Status == "draft" || node.Status == "removing" || rebuilt[node.ID] {
continue
}
rebuilt[node.ID] = true
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodeId": node.ID, "node": node.Name})
for _, entry := range credentialItems(d, customer) {
if entry["nodeClientId"] == binding.ID {
responseItem = entry
break
}
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
view = customerCredentialsView(d, customer)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"item": responseItem}, nil)
SendJSON(w, 200, view, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
@@ -2437,21 +2455,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
oldServerID := item.ServerID
oldExitServerID := strVal(item.ExitServerID)
oldForward := forwardNode(item)
oldProtocol := item.Protocol
next, err := NormalizeNode(body, item, d)
if err != nil {
return nil, err
}
*item = *next
EnsureNodeSecrets(item)
if item.Protocol != oldProtocol {
for _, nc := range d.NodeClients {
if nc.NodeID == item.ID {
nc.Credentials = NewNodeClientCredentials(item.Protocol)
nc.UpdatedAt = NowISO()
}
}
}
if item.ServerID != oldServerID {
QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"})
item.EntryReady = false