diff --git a/agent-go/PARITY.md b/agent-go/PARITY.md index 4dc6992..755c136 100644 --- a/agent-go/PARITY.md +++ b/agent-go/PARITY.md @@ -62,6 +62,14 @@ it can take over an existing node without wiping state. gates AnyTLS deploys on `version.split('.').map(Number) >= 0.6.1`; a `"0.6.8-go"` suffix would parse the third component as `NaN` and fail the gate. Do not change without updating the panel check. +- **Panel-hosted Reality keys**: apply payloads from a hosting panel carry + `payload.keyMaterial = { "": { privateKey, publicKey } }`. The agent + persists it into keys.json *before* materializing (panel store wins over a + divergent local entry), and reports any used keypair the panel did not + provide back as `artifacts.keyMaterial` for the panel to adopt. Panels + without hosting send no `keyMaterial` and old panels ignore the new + artifact field — both directions degrade to the pre-hosting flow. Protocol + details and precedence rules: `KEYHOSTING.md` in the repo root. - Heartbeat `system.arch` uses Node vocabulary (`x64` for amd64) — mapped in `nodeArch()`. - Engine object sends `singBoxStatus` as the raw service status diff --git a/agent-go/jobs.go b/agent-go/jobs.go index 349bca1..4e1bb42 100644 --- a/agent-go/jobs.go +++ b/agent-go/jobs.go @@ -13,8 +13,9 @@ type agentJob struct { ID string `json:"id"` Action string `json:"action"` Payload struct { - Resource json.RawMessage `json:"resource"` - ResourceID string `json:"resourceId"` + Resource json.RawMessage `json:"resource"` + ResourceID string `json:"resourceId"` + KeyMaterial map[string]realityKeyPair `json:"keyMaterial"` } `json:"payload"` } @@ -27,10 +28,19 @@ func tlsCertMissingError(domain string) error { // applyResourceLocked validates and persists one materialized resource, then // re-activates sing-box, restoring the previous resource on failure — // agent.js applyResource(). Callers must hold stateMu. -func applyResourceLocked(rawResource json.RawMessage) (map[string]interface{}, error) { +// +// Panel-hosted Reality keys: payload keyMaterial (if any) is written to +// keys.json before materializing so the hosted pair is what gets deployed; +// keypairs used but not provided are reported back in artifacts.keyMaterial +// for the panel to store. Payloads from older panels carry no keyMaterial +// and behave exactly as before. +func applyResourceLocked(rawResource json.RawMessage, keyMaterial map[string]realityKeyPair) (map[string]interface{}, error) { if len(rawResource) == 0 { return nil, fmt.Errorf("Missing resource payload") } + if err := persistKeyMaterial(keyMaterial); err != nil { + return nil, err + } decoded, err := decodeJSON(rawResource) if err != nil { return nil, err @@ -44,6 +54,9 @@ func applyResourceLocked(rawResource json.RawMessage) (map[string]interface{}, e if err != nil { return nil, err } + if backfill := backfillKeyMaterial(art.used, keyMaterial); len(backfill) > 0 { + art.KeyMaterial = backfill + } resource, _ := asMap(materialized) if engine, present := resource["engine"]; present && jsTruthy(engine) && asString(engine) != "sing-box" { return nil, fmt.Errorf("Unsupported engine: %v(Xray 已弃用,只支持 sing-box)", engine) @@ -165,7 +178,7 @@ func execute(job *agentJob) { var jobErr error switch job.Action { case "apply_resource": - result, jobErr = applyResourceLocked(job.Payload.Resource) + result, jobErr = applyResourceLocked(job.Payload.Resource, job.Payload.KeyMaterial) case "delete_resource": result, jobErr = deleteResourceLocked(job.Payload.ResourceID) default: diff --git a/agent-go/keys.go b/agent-go/keys.go index 0873691..694ea27 100644 --- a/agent-go/keys.go +++ b/agent-go/keys.go @@ -92,7 +92,12 @@ func realityKey(keyID string) (realityKeyPair, error) { } type artifacts struct { - RealityPublicKey *string `json:"realityPublicKey,omitempty"` + RealityPublicKey *string `json:"realityPublicKey,omitempty"` + KeyMaterial map[string]realityKeyPair `json:"keyMaterial,omitempty"` + // used records every keypair resolved during materialize (never + // serialized); applyResourceLocked diffs it against the payload-provided + // keys to compute the backfill reported to the panel. + used map[string]realityKeyPair } func (a *artifacts) toMap() map[string]interface{} { @@ -100,6 +105,67 @@ func (a *artifacts) toMap() map[string]interface{} { if a.RealityPublicKey != nil { out["realityPublicKey"] = *a.RealityPublicKey } + if len(a.KeyMaterial) > 0 { + out["keyMaterial"] = a.KeyMaterial + } + return out +} + +// persistKeyMaterial merges panel-provided keypairs into keys.json before a +// resource is materialized. The panel is the source of truth: a provided +// pair replaces a divergent local entry, so a node restored from the panel +// store converges back to the hosted keypair. keys.json stays 0600 (saveKeys). +func persistKeyMaterial(material map[string]realityKeyPair) error { + if len(material) == 0 { + return nil + } + keys, err := loadKeys() + if err != nil { + return err + } + changed := false + for keyID, pair := range material { + if pair.PrivateKey == "" || pair.PublicKey == "" { + continue + } + stored := pair + if raw, ok := keys[keyID]; ok { + var existing realityKeyPair + if err := json.Unmarshal(raw, &existing); err == nil { + if existing.PrivateKey == pair.PrivateKey && existing.PublicKey == pair.PublicKey { + continue + } + if stored.CreatedAt == "" { + stored.CreatedAt = existing.CreatedAt + } + } + } + if stored.CreatedAt == "" { + stored.CreatedAt = isoNow() + } + encoded, err := json.Marshal(stored) + if err != nil { + return err + } + keys[keyID] = encoded + changed = true + } + if !changed { + return nil + } + return saveKeys(keys) +} + +// backfillKeyMaterial returns the keypairs this apply actually used that the +// panel did not provide in the payload. The panel stores them on job +// completion, so keys that predate panel hosting migrate into the store. +func backfillKeyMaterial(used, provided map[string]realityKeyPair) map[string]realityKeyPair { + out := map[string]realityKeyPair{} + for keyID, pair := range used { + if _, ok := provided[keyID]; !ok { + out[keyID] = pair + } + } return out } @@ -141,6 +207,10 @@ func materialize(value interface{}, art *artifacts) (interface{}, error) { } pub := pair.PublicKey art.RealityPublicKey = &pub + if art.used == nil { + art.used = map[string]realityKeyPair{} + } + art.used[match[1]] = pair return pair.PrivateKey, nil default: return value, nil diff --git a/agent-go/main_test.go b/agent-go/main_test.go index 1bea33d..238ae0c 100644 --- a/agent-go/main_test.go +++ b/agent-go/main_test.go @@ -355,6 +355,129 @@ func TestReadSingBoxObservations(t *testing.T) { } } +// ---- 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) {