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
144 lines
4.6 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|