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:
+40
-15
@@ -239,6 +239,16 @@ func payloadWithResource(resource map[string]any, kind string, keyMaterial map[s
|
||||
return payload
|
||||
}
|
||||
|
||||
// ensureCustomerCredentials fills a missing customer-level credential set
|
||||
// (defensive: the store-load migration and customer creation provide one).
|
||||
func ensureCustomerCredentials(d *Data, customerID string) {
|
||||
customer := findCustomer(d, customerID)
|
||||
if customer != nil && (customer.Credentials == nil || customer.Credentials.ClientID == "") {
|
||||
creds := NewCustomerCredentials()
|
||||
customer.Credentials = &creds
|
||||
}
|
||||
}
|
||||
|
||||
func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any, error) {
|
||||
server := orchFindServer(d, node.ServerID)
|
||||
if server == nil {
|
||||
@@ -262,11 +272,26 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
|
||||
if clients == nil {
|
||||
clients = activeNodeClients(d, node)
|
||||
}
|
||||
// Credentials live on the customer (round-2 item 2); stored bindings
|
||||
// carry none. Stamp each customer's set onto throwaway copies for the
|
||||
// resource builder so the stored records stay clean.
|
||||
resolved := make([]*NodeClient, len(clients))
|
||||
for i, nc := range clients {
|
||||
ensureCustomerCredentials(d, nc.CustomerID)
|
||||
cp := *nc
|
||||
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
|
||||
creds := *customer.Credentials
|
||||
cp.Credentials = &creds
|
||||
} else {
|
||||
cp.Credentials = nil
|
||||
}
|
||||
resolved[i] = &cp
|
||||
}
|
||||
return BuildNodeResource(BuildNodeResourceParams{
|
||||
ResourceID: node.ResourceID,
|
||||
TagPrefix: tagForNode(node),
|
||||
Node: node,
|
||||
NodeClients: clients,
|
||||
NodeClients: resolved,
|
||||
Server: server,
|
||||
BindAddress: bindAddress,
|
||||
ExitServer: exitServer,
|
||||
@@ -977,14 +1002,14 @@ func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient,
|
||||
nc.UpdatedAt = NowISO()
|
||||
} else {
|
||||
now := NowISO()
|
||||
ensureCustomerCredentials(d, customerID)
|
||||
nc = &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients, nc)
|
||||
}
|
||||
@@ -1077,14 +1102,14 @@ func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor
|
||||
status = "active"
|
||||
}
|
||||
now := NowISO()
|
||||
ensureCustomerCredentials(d, customer.ID)
|
||||
d.NodeClients = append(d.NodeClients, &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customer.ID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customer.ID,
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customer.ID, "customer": customer.Name})
|
||||
if status == "active" {
|
||||
|
||||
Reference in New Issue
Block a user