Files
aaaamoooo ad18e16ac6 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
2026-10-20 21:21:13 +00:00

144 lines
4.6 KiB
Go

package main
import (
"encoding/base64"
"os"
"reflect"
"testing"
)
func TestRealityVectors(t *testing.T) {
raw, err := os.ReadFile("testdata/reality_vectors.json")
if err != nil {
t.Fatal(err)
}
var vectors []KeyPair
if err := unmarshalJSONUseNumber(raw, &vectors); err != nil {
t.Fatal(err)
}
if len(vectors) == 0 {
t.Fatal("no vectors")
}
for _, v := range vectors {
got, err := realityPublicKeyFromPrivate(v.PrivateKey)
if err != nil {
t.Fatalf("derive from %s: %v", v.PrivateKey, err)
}
if got != v.PublicKey {
t.Errorf("private %s: got public %s, want %s", v.PrivateKey, got, v.PublicKey)
}
}
}
func TestGenerateRealityKeyPairFormat(t *testing.T) {
pair := GenerateRealityKeyPair()
for _, k := range []string{pair.PrivateKey, pair.PublicKey} {
if len(k) != 43 {
t.Errorf("key %q length %d, want 43", k, len(k))
}
raw, err := base64.RawURLEncoding.DecodeString(k)
if err != nil {
t.Errorf("key %q not base64url: %v", k, err)
}
if len(raw) != 32 {
t.Errorf("key %q decodes to %d bytes, want 32", k, len(raw))
}
}
derived, err := realityPublicKeyFromPrivate(pair.PrivateKey)
if err != nil {
t.Fatal(err)
}
if derived != pair.PublicKey {
t.Errorf("generated pair inconsistent: derived %s, public %s", derived, pair.PublicKey)
}
}
func TestGoldenResources(t *testing.T) {
raw, err := os.ReadFile("testdata/golden_resources.json")
if err != nil {
t.Fatal(err)
}
var golden map[string]any
if err := unmarshalJSONUseNumber(raw, &golden); err != nil {
t.Fatal(err)
}
exitPassword := "AwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISI="
rfNode := &Node{
ID: "nod_rf", Name: "RF-Reality", ServerID: "srv_entry",
ExitServerID: StrPtr("srv_exit"),
Protocol: "vless-reality-vision",
Topology: "forward",
ExitProtocol: StrPtr("shadowsocks-2022-aes256"),
NetworkMode: "ipv4",
RealityServerName: "www.amd.com",
RealityDestPort: 443,
Port: 23456,
ExitPort: IntPtr(23457),
Status: "active",
ResourceID: "res_rf",
ExitResourceID: "res_rfx",
WSPath: StrPtr("/rfpath"),
Reality: &RealityConfig{KeyID: "reality-nod_rf", ServerName: "www.amd.com", DestPort: 443},
ExitCredentials: &ExitCredentials{ClientID: "11111111-2222-3333-4444-0000000000ff", Password: exitPassword, SocksUser: "ng_0123456789"},
}
rfClients := []*NodeClient{
{NodeID: "nod_rf", CustomerID: "cus_1", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "unused1", ShortID: "abcd0001ef01"}},
{NodeID: "nod_rf", CustomerID: "cus_2", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "unused2", ShortID: "abcd0002ef01"}},
}
entryServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.10"}
exitServer := &Server{ID: "srv_exit", PublicAddress: "exit.example.com"}
exitServerLocal := &Server{ID: "srv_exit", PublicAddress: "203.0.113.20"}
ssNode := &Node{
ID: "nod_ss", Name: "SS-Direct", ServerID: "srv_entry",
Protocol: "shadowsocks-2022-aes128",
Topology: "direct",
NetworkMode: "ipv4",
Port: 23458,
Status: "active",
ResourceID: "res_ss",
WSPath: StrPtr("/sspath"),
SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="),
}
ssClients := []*NodeClient{
{NodeID: "nod_ss", CustomerID: "cus_1", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01"}},
}
ssServer := &Server{ID: "srv_entry", PublicAddress: "203.0.113.11"}
rfEntry, err := BuildNodeResource(BuildNodeResourceParams{
ResourceID: "res_rf", TagPrefix: tagForNode(rfNode), Node: rfNode,
NodeClients: rfClients, Server: entryServer, ExitServer: exitServer,
})
if err != nil {
t.Fatal(err)
}
rfExit, err := BuildNodeExitResource(BuildNodeExitResourceParams{
ResourceID: "res_rfx", TagPrefix: tagForNode(rfNode), Node: rfNode, Server: exitServerLocal,
})
if err != nil {
t.Fatal(err)
}
ssEntry, err := BuildNodeResource(BuildNodeResourceParams{
ResourceID: "res_ss", TagPrefix: tagForNode(ssNode), Node: ssNode,
NodeClients: ssClients, Server: ssServer,
})
if err != nil {
t.Fatal(err)
}
for name, got := range map[string]map[string]any{"rfEntry": rfEntry, "rfExit": rfExit, "ssEntry": ssEntry} {
rawGot, err := marshalJSON(got)
if err != nil {
t.Fatal(err)
}
var decoded any
if err := unmarshalJSONUseNumber(rawGot, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, golden[name]) {
t.Errorf("%s mismatch:\n got: %s\nwant: %v", name, rawGot, golden[name])
}
}
}