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:
+72
-63
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user