diff --git a/panel-go/handlers.go b/panel-go/handlers.go index 5a85c0d..def1aa5 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -2580,6 +2580,27 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil) return true, nil } + if params, ok := RoutePattern("/api/nodes/:id/reality-keys", pathname); ok && r.Method == http.MethodGet { + // Round-3 item 17: display-only view of the panel-hosted Reality + // keypair for the node's server. This NEVER generates a pair — + // a missing entry simply reports nulls (the pair is minted by + // EnsureRealityKey at deploy time). + data := a.store.Snapshot() + node := findNode(data, params["id"]) + if node == nil { + return true, httpErr(404, "节点不存在") + } + if !IsRealityProtocol(node.Protocol) { + return true, httpErr(400, "该节点不是 Reality 协议") + } + keyID := RealityKeyIDForNode(node) + var publicKey, privateKey any + if entry := data.KeyStore[keyID]; entry != nil { + publicKey, privateKey = entry.PublicKey, entry.PrivateKey + } + SendJSON(w, 200, map[string]any{"keyId": keyID, "publicKey": publicKey, "privateKey": privateKey}, nil) + return true, nil + } if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch { body, err := ReadJSON(r, defaultMaxBody) if err != nil { diff --git a/panel-go/realitykeys_test.go b/panel-go/realitykeys_test.go new file mode 100644 index 0000000..614e5fe --- /dev/null +++ b/panel-go/realitykeys_test.go @@ -0,0 +1,73 @@ +package main + +// Item 17: GET /api/nodes/:id/reality-keys is a display-only view of the +// panel-hosted keypair — it must never mint one. + +import ( + "strings" + "testing" +) + +func loginHeaders(t *testing.T, app *App) map[string]string { + t.Helper() + rec := doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil) + if rec.Code != 200 { + t.Fatalf("login %d", rec.Code) + } + cookie := strings.Split(rec.Header().Get("Set-Cookie"), ";")[0] + return map[string]string{"Cookie": cookie} +} + +func TestRealityKeysEndpoint(t *testing.T) { + app := newSmokeApp(t) + _, err := app.store.Transaction(func(d *Data) (any, error) { + d.Servers = append(d.Servers, &Server{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com"}) + d.Nodes = append(d.Nodes, + &Node{ID: "nod_r", Protocol: "vless-reality-vision", ServerID: "srv_1", Status: "active", + Reality: &RealityConfig{KeyID: "reality-nod_r", ServerName: "www.tesla.com", DestPort: 443}}, + &Node{ID: "nod_s", Protocol: "socks5-auth", ServerID: "srv_1", Status: "active"}, + ) + d.KeyStore["reality-nod_r"] = &KeyEntry{PrivateKey: "PRIV", PublicKey: "PUB", UpdatedAt: NowISO()} + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + headers := loginHeaders(t, app) + + rec := doRequest(app, "GET", "/api/nodes/nod_r/reality-keys", "", headers) + if rec.Code != 200 { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + body := decodeBody(t, rec) + if body["publicKey"] != "PUB" || body["privateKey"] != "PRIV" || body["keyId"] != "reality-nod_r" { + t.Fatalf("body %v", body) + } + + // Non-Reality node -> 400; unknown node -> 404. + if rec := doRequest(app, "GET", "/api/nodes/nod_s/reality-keys", "", headers); rec.Code != 400 { + t.Fatalf("socks node status %d, want 400", rec.Code) + } + if rec := doRequest(app, "GET", "/api/nodes/nope/reality-keys", "", headers); rec.Code != 404 { + t.Fatalf("unknown node status %d, want 404", rec.Code) + } + + // A Reality node without a stored pair reports nulls and the + // endpoint must NOT create one. + _, err = app.store.Transaction(func(d *Data) (any, error) { + d.Nodes = append(d.Nodes, &Node{ID: "nod_r2", Protocol: "vless-reality", ServerID: "srv_1", Status: "draft", + Reality: &RealityConfig{KeyID: "reality-nod_r2", ServerName: "www.tesla.com", DestPort: 443}}) + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + rec = doRequest(app, "GET", "/api/nodes/nod_r2/reality-keys", "", headers) + body = decodeBody(t, rec) + if rec.Code != 200 || body["publicKey"] != nil || body["privateKey"] != nil { + t.Fatalf("missing pair: %d %v", rec.Code, body) + } + if _, exists := app.store.Snapshot().KeyStore["reality-nod_r2"]; exists { + t.Fatal("display endpoint must never generate a keypair") + } +} diff --git a/public/app.js b/public/app.js index 2799dc1..b78982e 100644 --- a/public/app.js +++ b/public/app.js @@ -903,6 +903,7 @@ function nodeForm(item = null) { + ${item ? '
服务器 Reality 密钥对(主控托管)
加载中…
' : ''} @@ -918,6 +919,15 @@ function nodeForm(item = null) {
`, true); syncNodeForm(); + // Round-3 item 17: show the server's panel-hosted Reality keypair on + // existing Reality nodes (display only — no rotation action here). + if (item && ['vless-reality-vision', 'vless-reality'].includes(node.protocol)) { + api(`/api/nodes/${item.id}/reality-keys`).then((data) => { + const box = $('#reality-keys-box'); if (!box) return; + box.innerHTML = `
服务器 Reality 密钥对(主控托管,同机 Reality 节点共用)
+ ${data.publicKey ? credRowHTML('公钥', data.publicKey) + credRowHTML('私钥', data.privateKey) : '
尚未生成(首次部署时由主控生成)
'}`; + }).catch(() => { const box = $('#reality-keys-box'); if (box) box.innerHTML = '
密钥读取失败
'; }); + } } function syncNodeForm() { const form = $('#node-form'); if (!form) return;