package main import ( "crypto/ecdh" "encoding/base64" "encoding/hex" "os" "os/exec" "path/filepath" "strings" "testing" ) // ---- Reality keys ----------------------------------------------------------- func TestX25519RFC7748Vector(t *testing.T) { // RFC 7748 §5.2 Alice keypair: proves Go's ecdh byte conventions // (raw little-endian scalar / u-coordinate) match the JWK d/x values // Node produces, which is what sing-box expects. privBytes, _ := hex.DecodeString("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a") wantPub, _ := hex.DecodeString("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a") priv, err := ecdh.X25519().NewPrivateKey(privBytes) if err != nil { t.Fatal(err) } got := priv.PublicKey().Bytes() if len(got) != 32 { t.Fatalf("public key length %d, want 32", len(got)) } for i := range got { if got[i] != wantPub[i] { t.Fatalf("public key mismatch at %d: got %x want %x", i, got, wantPub) } } // JWK d/x are base64url of exactly these byte strings: the encoding // must round-trip to the RFC bytes with the raw (unpadded) alphabet. enc := base64.RawURLEncoding.EncodeToString(privBytes) back, err := base64.RawURLEncoding.DecodeString(enc) if err != nil || len(back) != 32 { t.Fatalf("base64url round-trip failed: %q %v", enc, err) } for i := range back { if back[i] != privBytes[i] { t.Fatalf("base64url round-trip mismatch at %d", i) } } } func TestRealityKeyFormatAndPersistence(t *testing.T) { dir := t.TempDir() old := cfg.KeyFile cfg.KeyFile = filepath.Join(dir, "keys.json") defer func() { cfg.KeyFile = old }() pair, err := realityKey("reality-node-test") if err != nil { t.Fatal(err) } for _, k := range []string{pair.PrivateKey, pair.PublicKey} { if len(k) != 43 { t.Fatalf("key %q length %d, want 43 (32 bytes, base64url no padding)", k, len(k)) } if strings.Contains(k, "=") || strings.Contains(k, "+") || strings.Contains(k, "/") { t.Fatalf("key %q is not raw base64url", k) } raw, err := base64.RawURLEncoding.DecodeString(k) if err != nil || len(raw) != 32 { t.Fatalf("key %q does not decode to 32 bytes: %v", k, err) } } // Public key must derive from the private key. priv, err := ecdh.X25519().NewPrivateKey(mustDecode(t, pair.PrivateKey)) if err != nil { t.Fatal(err) } if base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()) != pair.PublicKey { t.Fatal("stored public key does not match private key") } // Second call returns the persisted pair; file is 0600 with the JS shape. again, err := realityKey("reality-node-test") if err != nil { t.Fatal(err) } if again != pair { t.Fatalf("key not persisted: %+v vs %+v", again, pair) } info, err := os.Stat(cfg.KeyFile) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("keys.json mode %o, want 600", info.Mode().Perm()) } data, _ := os.ReadFile(cfg.KeyFile) for _, field := range []string{`"privateKey"`, `"publicKey"`, `"createdAt"`, `"reality-node-test"`} { if !strings.Contains(string(data), field) { t.Fatalf("keys.json missing %s: %s", field, data) } } } func mustDecode(t *testing.T, s string) []byte { t.Helper() b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { t.Fatal(err) } return b } // ---- Combined config ---------------------------------------------------------- const testSSKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" // 32 bytes 0x00..0x1f func sampleResources(t *testing.T) []map[string]interface{} { t.Helper() old := cfg.KeyFile cfg.KeyFile = filepath.Join(t.TempDir(), "keys.json") defer func() { cfg.KeyFile = old }() art := &artifacts{} v1, err := decodeJSON([]byte(`{ "id": "res-node-0001", "engine": "sing-box", "meta": {"kind": "node", "nodeId": "node-abc", "metricsTag": "ng-node-abc-in", "protocol": "vless-reality"}, "inbounds": [{ "type": "vless", "tag": "ng-node-abc-in", "listen": "203.0.113.9", "listen_port": 22038, "users": [{"name": "ng:cust-1", "uuid": "11111111-1111-1111-1111-111111111111", "flow": "xtls-rprx-vision"}], "tls": {"enabled": true, "server_name": "www.microsoft.com", "reality": {"enabled": true, "handshake": {"server": "www.microsoft.com", "server_port": 443}, "private_key": "${REALITY_PRIVATE:reality-node-abc}", "short_id": ["abcd1234"]}} }], "outbounds": [{"type": "direct", "tag": "ng-node-abc-direct"}], "routingRules": [{"inbound": ["ng-node-abc-in"], "action": "route", "outbound": "ng-node-abc-direct"}] }`)) if err != nil { t.Fatal(err) } m1, err := materialize(v1, art) if err != nil { t.Fatal(err) } if art.RealityPublicKey == nil || *art.RealityPublicKey == "" { t.Fatal("materialize did not record the reality public key artifact") } v2, err := decodeJSON([]byte(`{ "id": "res-node-0002", "engine": "sing-box", "meta": {"kind": "node", "nodeId": "node-def", "metricsTag": "ng-node-def-in", "protocol": "ss2022"}, "inbounds": [ {"type": "shadowsocks", "tag": "ng-node-def-in", "listen": "0.0.0.0", "listen_port": 8388, "method": "2022-blake3-aes-256-gcm", "password": "` + testSSKey + `", "users": [{"name": "ng:cust-2", "password": "` + testSSKey + `"}]}, {"type": "socks", "tag": "ng-node-def-socks", "listen": "127.0.0.1", "listen_port": 1080, "users": [{"username": "ng:cust-3", "password": "pw"}]} ], "outbounds": [{"type": "direct", "tag": "ng-node-def-direct"}], "routingRules": [] }`)) if err != nil { t.Fatal(err) } r1, _ := asMap(m1) r2, _ := asMap(v2) return []map[string]interface{}{r1, r2} } func TestCombinedSingBoxConfig(t *testing.T) { resources := sampleResources(t) combined := combinedSingBoxConfig(resources) if len(combined.Inbounds) != 3 { t.Fatalf("inbounds %d, want 3", len(combined.Inbounds)) } first, _ := asMap(combined.Inbounds[0]) if first["listen"] != "0.0.0.0" { t.Fatalf("non-local listen 203.0.113.9 should fall back to 0.0.0.0, got %v", first["listen"]) } third, _ := asMap(combined.Inbounds[2]) if third["listen"] != "127.0.0.1" { t.Fatalf("local listen 127.0.0.1 must be kept, got %v", third["listen"]) } if len(combined.Outbounds) != 3 { t.Fatalf("outbounds %d, want 3 (fallback + 2)", len(combined.Outbounds)) } ob0, _ := asMap(combined.Outbounds[0]) if ob0["tag"] != "ng-fallback-direct" { t.Fatalf("first outbound = %v, want ng-fallback-direct", ob0["tag"]) } if combined.Route.Final != "ng-fallback-direct" || len(combined.Route.Rules) != 1 { t.Fatalf("route = %+v", combined.Route) } stats := combined.Experimental.V2RayAPI.Stats wantTags := []string{"ng-node-abc-in", "ng-node-def-in", "ng-node-def-socks"} if len(stats.Inbounds) != len(wantTags) { t.Fatalf("stats inbounds %v", stats.Inbounds) } for i, tag := range wantTags { if stats.Inbounds[i] != tag { t.Fatalf("stats inbounds[%d] = %v, want %s", i, stats.Inbounds[i], tag) } } wantUsers := []string{"ng:cust-1", "ng:cust-2", "ng:cust-3"} if len(stats.Users) != len(wantUsers) { t.Fatalf("stats users %v", stats.Users) } for i, u := range wantUsers { if stats.Users[i] != u { t.Fatalf("stats users[%d] = %v, want %s", i, stats.Users[i], u) } } // Deterministic output (the activate path byte-compares against the // existing config to decide whether a restart is needed). a, _ := marshalIndentJSON(combined) b, _ := marshalIndentJSON(combinedSingBoxConfig(resources)) if a != b { t.Fatal("combined config marshaling is not deterministic") } // The materialized reality private key must be a raw-base64url 32-byte key. in0, _ := asMap(combined.Inbounds[0]) tls, _ := asMap(in0["tls"]) reality, _ := asMap(tls["reality"]) pk := asString(reality["private_key"]) if len(pk) != 43 { t.Fatalf("materialized private_key %q is not a 43-char raw base64url key", pk) } } // TestCombinedConfigAcceptedBySingBox runs the real sing-box binary's // `check` over a generated config when the release binary is present. func TestCombinedConfigAcceptedBySingBox(t *testing.T) { bin := "/home/hatch/workspace/nexusgate-release/bin/nexusgate-sing-box-linux-amd64-v1.13.21" if _, err := os.Stat(bin); err != nil { t.Skip("sing-box release binary not present") } oldLog := cfg.SingBoxLog cfg.SingBoxLog = filepath.Join(t.TempDir(), "access.log") defer func() { cfg.SingBoxLog = oldLog }() resources := sampleResources(t) body, err := marshalIndentJSON(combinedSingBoxConfig(resources)) if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(body+"\n"), 0o600); err != nil { t.Fatal(err) } out, err := exec.Command(bin, "check", "-c", path).CombinedOutput() if err != nil { t.Fatalf("sing-box check rejected generated config: %v\n%s", err, out) } } // ---- parseUsageStats ----------------------------------------------------------- func TestParseUsageStats(t *testing.T) { parsed, err := parseUsageStats(`{"stat":null}`) if err != nil { t.Fatal(err) } if len(parsed.inbounds.keys) != 0 || len(parsed.users.keys) != 0 { t.Fatal("null stat should yield no counters") } parsed, err = parseUsageStats(`{"stat":[ {"name":"inbound>>>ng-a-in>>>traffic>>>uplink","value":"100"}, {"name":"inbound>>>ng-a-in>>>traffic>>>downlink","value":"250"}, {"name":"inbound>>>ng-a-in>>>traffic>>>uplink","value":"130"}, {"name":"user>>>ng:cust-1>>>traffic>>>uplink","value":"40"}, {"name":"user>>>ng:cust-1>>>traffic>>>downlink"}, {"name":"outbound>>>direct>>>traffic>>>uplink","value":"999"}, {"name":"user>>>other>>>traffic>>>uplink","value":42} ]}`) if err != nil { t.Fatal(err) } in := parsed.inbounds.m["ng-a-in"] if in == nil || in.Uplink != 130 || in.Downlink != 250 { t.Fatalf("inbound counters = %+v (later value must overwrite)", in) } u := parsed.users.m["ng:cust-1"] if u == nil || u.Uplink != 40 || u.Downlink != 0 { t.Fatalf("user counters = %+v (missing value must default to 0)", u) } if parsed.users.m["other"].Uplink != 42 { t.Fatal("bare JSON number value 42 not accepted") } cases := []struct { name string input string wantErr string }{ {"negative", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"-5"}]}`, "负数"}, {"overflow", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"99999999999999999999999"}]}`, "超出安全整数范围"}, {"alpha", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":"abc"}]}`, "格式 string"}, {"empty-string", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":""}]}`, "格式 string"}, {"null-value", `{"stat":[{"name":"user>>>ng:x>>>traffic>>>uplink","value":null}]}`, "格式 object"}, {"top-array", `[]`, "统计接口返回的 JSON stat 格式无效"}, {"stat-object", `{"stat":{}}`, "统计接口返回的 JSON stat 格式无效"}, {"garbage", `not json`, "统计接口返回的 JSON stat 格式无效"}, } for _, tc := range cases { _, err := parseUsageStats(tc.input) if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("%s: err = %v, want substring %q", tc.name, err, tc.wantErr) } } } // ---- observations ------------------------------------------------------------ func TestReadSingBoxObservations(t *testing.T) { dir := t.TempDir() oldRes, oldLog, oldCursor := cfg.ResourceDir, cfg.SingBoxLog, cfg.SingBoxCursor cfg.ResourceDir = filepath.Join(dir, "resources") cfg.SingBoxLog = filepath.Join(dir, "access.log") cfg.SingBoxCursor = filepath.Join(dir, "cursor.json") defer func() { cfg.ResourceDir, cfg.SingBoxLog, cfg.SingBoxCursor = oldRes, oldLog, oldCursor }() if err := os.MkdirAll(cfg.ResourceDir, 0o700); err != nil { t.Fatal(err) } res := `{"id":"res-node-0001","engine":"sing-box","meta":{"kind":"node","nodeId":"node-abc","metricsTag":"ng-node-abc-in"},"inbounds":[],"outbounds":[],"routingRules":[]}` if err := os.WriteFile(filepath.Join(cfg.ResourceDir, "res-node-0001.json"), []byte(res), 0o600); err != nil { t.Fatal(err) } logBody := strings.Join([]string{ `2026-10-20T05:00:01+08:00 INFO [111 5ms] inbound/anytls[ng-node-abc-in]: inbound connection from 203.0.113.5:40001`, `2026-10-20T05:00:01+08:00 INFO [111 9ms] inbound/anytls[ng-node-abc-in]: [ng:cust-9] inbound connection to example.com:443`, `2026-10-20T05:00:02+08:00 INFO [222 3ms] inbound/anytls[ng-node-abc-in]: inbound connection from 203.0.113.6:40002`, `2026-10-20T05:00:03+08:00 INFO [333 3ms] inbound/anytls[other-tag]: inbound connection from 203.0.113.7:40003`, `2026-10-20T05:00:03+08:00 INFO [333 4ms] inbound/anytls[other-tag]: [ng:cust-9] inbound connection to example.org:443`, "", }, "\n") if err := os.WriteFile(cfg.SingBoxLog, []byte(logBody), 0o600); err != nil { t.Fatal(err) } result := readSingBoxObservationsLocked() if len(result.observations) != 1 { t.Fatalf("observations = %+v, want exactly the one authenticated session", result.observations) } obs := result.observations[0] if obs.CustomerID != "cust-9" || obs.IP != "203.0.113.5" { t.Fatalf("observation = %+v", obs) } if result.offset == nil || *result.offset != int64(len(logBody)) { t.Fatalf("offset = %v, want %d (end of file)", result.offset, len(logBody)) } // With the cursor persisted at EOF, a second read finds nothing new. body, _ := marshalJSON(map[string]interface{}{"offset": *result.offset}) if err := os.WriteFile(cfg.SingBoxCursor, []byte(body+"\n"), 0o600); err != nil { t.Fatal(err) } second := readSingBoxObservationsLocked() if len(second.observations) != 0 || second.offset != nil { t.Fatalf("second read = %+v, want empty with nil offset", second) } } // ---- Panel-hosted Reality keys -------------------------------------------------- func TestPersistKeyMaterialPanelWins(t *testing.T) { dir := t.TempDir() old := cfg.KeyFile defer func() { cfg.KeyFile = old }() // Produce a genuine second keypair in a scratch file to act as the // panel-hosted pair (realityKey always generates valid x25519 keys). cfg.KeyFile = filepath.Join(dir, "scratch.json") panel, err := realityKey("reality-node-host") if err != nil { t.Fatal(err) } // The node has a divergent pre-hosting local pair under the same id. cfg.KeyFile = filepath.Join(dir, "keys.json") local, err := realityKey("reality-node-host") if err != nil { t.Fatal(err) } if local.PrivateKey == panel.PrivateKey { t.Fatal("scratch pairs collided") } provided := map[string]realityKeyPair{ "reality-node-host": {PrivateKey: panel.PrivateKey, PublicKey: panel.PublicKey}, } if err := persistKeyMaterial(provided); err != nil { t.Fatal(err) } got, err := realityKey("reality-node-host") if err != nil { t.Fatal(err) } if got.PrivateKey != panel.PrivateKey || got.PublicKey != panel.PublicKey { t.Fatalf("panel pair did not win: got %s/%s want %s/%s", got.PrivateKey, got.PublicKey, panel.PrivateKey, panel.PublicKey) } info, err := os.Stat(cfg.KeyFile) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("keys.json mode %v, want 0600", info.Mode().Perm()) } // Persisting identical material is a byte-for-byte no-op. before, _ := os.ReadFile(cfg.KeyFile) if err := persistKeyMaterial(provided); err != nil { t.Fatal(err) } after, _ := os.ReadFile(cfg.KeyFile) if string(before) != string(after) { t.Fatal("repeat persist changed keys.json") } // Entries without both halves are ignored. if err := persistKeyMaterial(map[string]realityKeyPair{"reality-node-bad": {PrivateKey: "x"}}); err != nil { t.Fatal(err) } keys, err := loadKeys() if err != nil { t.Fatal(err) } if _, ok := keys["reality-node-bad"]; ok { t.Fatal("incomplete keyMaterial entry was persisted") } if err := persistKeyMaterial(nil); err != nil { t.Fatal(err) } } func TestMaterializeBackfillTracking(t *testing.T) { old := cfg.KeyFile cfg.KeyFile = filepath.Join(t.TempDir(), "keys.json") defer func() { cfg.KeyFile = old }() resource := map[string]interface{}{ "inbounds": []interface{}{map[string]interface{}{ "tls": map[string]interface{}{"reality": map[string]interface{}{ "private_key": "${REALITY_PRIVATE:reality-node-bf}", }}, }}, } // No panel material: materialize generates locally; the used pair must be // tracked and reported as backfill so the panel can adopt it. art := &artifacts{} out, err := materialize(resource, art) if err != nil { t.Fatal(err) } used := art.used["reality-node-bf"] if used.PrivateKey == "" || used.PublicKey == "" { t.Fatal("materialize did not track the used keypair") } outMap, _ := asMap(out) inbound, _ := asMap(asSlice(outMap["inbounds"])[0]) tlsMap, _ := asMap(inbound["tls"]) realityMap, _ := asMap(tlsMap["reality"]) deployed := realityMap["private_key"] if deployed != used.PrivateKey { t.Fatalf("materialized private_key %v != tracked pair %q", deployed, used.PrivateKey) } backfill := backfillKeyMaterial(art.used, nil) if len(backfill) != 1 || backfill["reality-node-bf"] != used { t.Fatalf("backfill mismatch: %v", backfill) } // When the panel provided the id, nothing is backfilled. provided := map[string]realityKeyPair{"reality-node-bf": used} if got := backfillKeyMaterial(art.used, provided); len(got) != 0 { t.Fatalf("expected no backfill for panel-provided key, got %v", got) } // toMap carries keyMaterial only when set. art.KeyMaterial = backfill if _, ok := art.toMap()["keyMaterial"]; !ok { t.Fatal("toMap missing keyMaterial") } art.KeyMaterial = nil if _, ok := art.toMap()["keyMaterial"]; ok { t.Fatal("toMap should omit empty keyMaterial") } } // ---- misc ----------------------------------------------------------------------- func TestSafeError(t *testing.T) { in := "dial failed Private Key: abcDEF123, more; Authorization: Bearer tok123 end" out := safeError(in) if strings.Contains(out, "abcDEF123") || strings.Contains(out, "tok123") { t.Fatalf("safeError did not redact: %s", out) } if !strings.Contains(out, "PrivateKey: [REDACTED]") || !strings.Contains(out, "Bearer [REDACTED]") { t.Fatalf("safeError replacements missing: %s", out) } }