feat(agent-go): consume panel-hosted Reality keys (persist keyMaterial, backfill unused-provided keys)
This commit is contained in:
@@ -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
|
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
|
`"0.6.8-go"` suffix would parse the third component as `NaN` and fail the
|
||||||
gate. Do not change without updating the panel check.
|
gate. Do not change without updating the panel check.
|
||||||
|
- **Panel-hosted Reality keys**: apply payloads from a hosting panel carry
|
||||||
|
`payload.keyMaterial = { "<keyId>": { 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
|
- Heartbeat `system.arch` uses Node vocabulary (`x64` for amd64) — mapped in
|
||||||
`nodeArch()`.
|
`nodeArch()`.
|
||||||
- Engine object sends `singBoxStatus` as the raw service status
|
- Engine object sends `singBoxStatus` as the raw service status
|
||||||
|
|||||||
+15
-2
@@ -15,6 +15,7 @@ type agentJob struct {
|
|||||||
Payload struct {
|
Payload struct {
|
||||||
Resource json.RawMessage `json:"resource"`
|
Resource json.RawMessage `json:"resource"`
|
||||||
ResourceID string `json:"resourceId"`
|
ResourceID string `json:"resourceId"`
|
||||||
|
KeyMaterial map[string]realityKeyPair `json:"keyMaterial"`
|
||||||
} `json:"payload"`
|
} `json:"payload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +28,19 @@ func tlsCertMissingError(domain string) error {
|
|||||||
// applyResourceLocked validates and persists one materialized resource, then
|
// applyResourceLocked validates and persists one materialized resource, then
|
||||||
// re-activates sing-box, restoring the previous resource on failure —
|
// re-activates sing-box, restoring the previous resource on failure —
|
||||||
// agent.js applyResource(). Callers must hold stateMu.
|
// 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 {
|
if len(rawResource) == 0 {
|
||||||
return nil, fmt.Errorf("Missing resource payload")
|
return nil, fmt.Errorf("Missing resource payload")
|
||||||
}
|
}
|
||||||
|
if err := persistKeyMaterial(keyMaterial); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
decoded, err := decodeJSON(rawResource)
|
decoded, err := decodeJSON(rawResource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -44,6 +54,9 @@ func applyResourceLocked(rawResource json.RawMessage) (map[string]interface{}, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if backfill := backfillKeyMaterial(art.used, keyMaterial); len(backfill) > 0 {
|
||||||
|
art.KeyMaterial = backfill
|
||||||
|
}
|
||||||
resource, _ := asMap(materialized)
|
resource, _ := asMap(materialized)
|
||||||
if engine, present := resource["engine"]; present && jsTruthy(engine) && asString(engine) != "sing-box" {
|
if engine, present := resource["engine"]; present && jsTruthy(engine) && asString(engine) != "sing-box" {
|
||||||
return nil, fmt.Errorf("Unsupported engine: %v(Xray 已弃用,只支持 sing-box)", engine)
|
return nil, fmt.Errorf("Unsupported engine: %v(Xray 已弃用,只支持 sing-box)", engine)
|
||||||
@@ -165,7 +178,7 @@ func execute(job *agentJob) {
|
|||||||
var jobErr error
|
var jobErr error
|
||||||
switch job.Action {
|
switch job.Action {
|
||||||
case "apply_resource":
|
case "apply_resource":
|
||||||
result, jobErr = applyResourceLocked(job.Payload.Resource)
|
result, jobErr = applyResourceLocked(job.Payload.Resource, job.Payload.KeyMaterial)
|
||||||
case "delete_resource":
|
case "delete_resource":
|
||||||
result, jobErr = deleteResourceLocked(job.Payload.ResourceID)
|
result, jobErr = deleteResourceLocked(job.Payload.ResourceID)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ func realityKey(keyID string) (realityKeyPair, error) {
|
|||||||
|
|
||||||
type artifacts struct {
|
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{} {
|
func (a *artifacts) toMap() map[string]interface{} {
|
||||||
@@ -100,6 +105,67 @@ func (a *artifacts) toMap() map[string]interface{} {
|
|||||||
if a.RealityPublicKey != nil {
|
if a.RealityPublicKey != nil {
|
||||||
out["realityPublicKey"] = *a.RealityPublicKey
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +207,10 @@ func materialize(value interface{}, art *artifacts) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
pub := pair.PublicKey
|
pub := pair.PublicKey
|
||||||
art.RealityPublicKey = &pub
|
art.RealityPublicKey = &pub
|
||||||
|
if art.used == nil {
|
||||||
|
art.used = map[string]realityKeyPair{}
|
||||||
|
}
|
||||||
|
art.used[match[1]] = pair
|
||||||
return pair.PrivateKey, nil
|
return pair.PrivateKey, nil
|
||||||
default:
|
default:
|
||||||
return value, nil
|
return value, nil
|
||||||
|
|||||||
@@ -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 -----------------------------------------------------------------------
|
// ---- misc -----------------------------------------------------------------------
|
||||||
|
|
||||||
func TestSafeError(t *testing.T) {
|
func TestSafeError(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user