370 lines
14 KiB
Go
370 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// ---- 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)
|
|
}
|
|
}
|