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
125 lines
4.6 KiB
Go
125 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func goldenSubData() *Data {
|
|
entryServer := &Server{ID: "srv_entry", PublicAddress: "entry.example.com", TLSDomain: "tls.example.com"}
|
|
v6Server := &Server{ID: "srv_v6", PublicAddress: "v6.example.com", TLSDomain: "tls.example.com"}
|
|
art := func(pub string) map[string]json.RawMessage {
|
|
raw, _ := marshalJSON(pub)
|
|
return map[string]json.RawMessage{"realityPublicKey": raw}
|
|
}
|
|
nodes := []*Node{
|
|
{ID: "nod_rf", Name: "RF-Reality", ServerID: "srv_entry", Protocol: "vless-reality-vision",
|
|
Topology: "direct", NetworkMode: "ipv4", Port: 23456, Status: "active",
|
|
Reality: &RealityConfig{KeyID: "reality-nod_rf", ServerName: "www.amd.com", DestPort: 443},
|
|
Artifacts: art("FIXEDPBKRfKey0123456789abcdefghijklmnopqrstu")},
|
|
{ID: "nod_ss", Name: "SS-Direct", ServerID: "srv_entry", Protocol: "shadowsocks-2022-aes128",
|
|
Topology: "direct", NetworkMode: "ipv4", Port: 23458, Status: "active",
|
|
SSServerKey: StrPtr("CwwNDg8QERITFBUWFxgZGg=="), Artifacts: map[string]json.RawMessage{}},
|
|
{ID: "nod_hy", Name: "HY2", ServerID: "srv_entry", Protocol: "hysteria2",
|
|
Topology: "direct", NetworkMode: "ipv4", Port: 23459, Status: "active", Artifacts: map[string]json.RawMessage{}},
|
|
{ID: "nod_any", Name: "AnyTLS", ServerID: "srv_entry", Protocol: "anytls",
|
|
Topology: "direct", NetworkMode: "ipv4", Port: 23460, Status: "active", Artifacts: map[string]json.RawMessage{}},
|
|
{ID: "nod_ws", Name: "WS-TLS", ServerID: "srv_entry", Protocol: "vless-ws-tls",
|
|
Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active",
|
|
WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}},
|
|
{ID: "nod_sk", Name: "Socks", ServerID: "srv_entry", Protocol: "socks5-auth",
|
|
Topology: "direct", NetworkMode: "ipv4", Port: 23462, Status: "active", Artifacts: map[string]json.RawMessage{}},
|
|
{ID: "nod_rv", Name: "RV-Plain", ServerID: "srv_v6", Protocol: "vless-reality",
|
|
Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active",
|
|
Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443},
|
|
Artifacts: art("FIXEDPBKRvKey0123456789abcdefghijklmnopqrstu")},
|
|
}
|
|
nc := func(nodeID string) *NodeClient {
|
|
return &NodeClient{NodeID: nodeID, CustomerID: "cus_1", Status: "active"}
|
|
}
|
|
clients := []*NodeClient{
|
|
nc("nod_rf"), nc("nod_ss"), nc("nod_hy"), nc("nod_any"),
|
|
nc("nod_ws"), nc("nod_sk"), nc("nod_rv"),
|
|
}
|
|
return &Data{
|
|
Servers: []*Server{entryServer, v6Server},
|
|
Nodes: nodes,
|
|
NodeClients: clients,
|
|
// One credential set for the whole customer (round-2 item 2). The
|
|
// values are the old RF binding's uuid/shortId and the old SS
|
|
// binding's password, so the RF and SS entries are byte-stable
|
|
// against the previous golden file; every other entry now carries
|
|
// this same set.
|
|
Customers: []*Customer{{ID: "cus_1", Credentials: &Credentials{
|
|
ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001ef01",
|
|
}}},
|
|
}
|
|
}
|
|
|
|
type goldenSubs struct {
|
|
Entries []SubEntry `json:"entries"`
|
|
Formats map[string]struct {
|
|
Body string `json:"body"`
|
|
ContentType string `json:"contentType"`
|
|
} `json:"formats"`
|
|
}
|
|
|
|
func loadGoldenSubs(t *testing.T) goldenSubs {
|
|
t.Helper()
|
|
raw, err := os.ReadFile("testdata/golden_subscriptions.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var g goldenSubs
|
|
if err := unmarshalJSONUseNumber(raw, &g); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
func TestGoldenEntries(t *testing.T) {
|
|
g := loadGoldenSubs(t)
|
|
got := Entries(goldenSubData(), "cus_1")
|
|
if !reflect.DeepEqual(got, g.Entries) {
|
|
t.Fatalf("entries mismatch:\n got: %+v\nwant: %+v", got, g.Entries)
|
|
}
|
|
}
|
|
|
|
func TestGoldenFormats(t *testing.T) {
|
|
g := loadGoldenSubs(t)
|
|
d := goldenSubData()
|
|
customer := &Customer{ID: "cus_1"}
|
|
cases := []struct {
|
|
key string
|
|
format string
|
|
ua string
|
|
}{
|
|
{"raw", "raw", ""},
|
|
{"base64", "base64", ""},
|
|
{"clash", "clash", ""},
|
|
{"clash-smart", "clash-smart", ""},
|
|
{"singbox", "singbox", ""},
|
|
{"surge", "surge", ""},
|
|
{"auto-clash-ua", "auto", "ClashForAndroid/2.0"},
|
|
{"auto-plain-ua", "auto", "Mozilla/5.0"},
|
|
}
|
|
for _, c := range cases {
|
|
want, ok := g.Formats[c.key]
|
|
if !ok {
|
|
t.Fatalf("golden missing format %s", c.key)
|
|
}
|
|
res, ok := FormatSubscription(d, customer, c.format, c.ua)
|
|
if !ok || res == nil {
|
|
t.Fatalf("format %s: FormatSubscription returned null", c.key)
|
|
}
|
|
if res.ContentType != want.ContentType {
|
|
t.Errorf("format %s contentType: got %q want %q", c.key, res.ContentType, want.ContentType)
|
|
}
|
|
if res.Body != want.Body {
|
|
t.Errorf("format %s body mismatch:\n got: %q\nwant: %q", c.key, res.Body, want.Body)
|
|
}
|
|
}
|
|
}
|