feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user