feat: show server Reality keypair on node edit form (item 17)
New read-only GET /api/nodes/:id/reality-keys returns the panel-hosted keypair for the node's server from keyStore (display only — it never mints a pair; a missing entry reports nulls). The node edit form shows public and private key rows with copy buttons for Reality protocols.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -903,6 +903,7 @@ function nodeForm(item = null) {
|
||||
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
|
||||
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select></label>
|
||||
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
|
||||
${item ? '<div class="wide cred-group" data-reality-only id="reality-keys-box"><div class="g-head">服务器 Reality 密钥对(主控托管)</div><div class="empty"><b>加载中…</b></div></div>' : ''}
|
||||
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
|
||||
<label data-socks-only>SOCKS5 固定密码<div class="inline-fields"><input name="socksPassword" value="${esc(node.socksPassword || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksPassword">${ICONS.copy}</button></div></label>
|
||||
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward', node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct', node.topology)}>单机直连(无需出口)</option></select></label>
|
||||
@@ -918,6 +919,15 @@ function nodeForm(item = null) {
|
||||
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
|
||||
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, 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 = `<div class="g-head">服务器 Reality 密钥对(主控托管,同机 Reality 节点共用)</div>
|
||||
${data.publicKey ? credRowHTML('公钥', data.publicKey) + credRowHTML('私钥', data.privateKey) : '<div class="empty"><b>尚未生成(首次部署时由主控生成)</b></div>'}`;
|
||||
}).catch(() => { const box = $('#reality-keys-box'); if (box) box.innerHTML = '<div class="empty"><b>密钥读取失败</b></div>'; });
|
||||
}
|
||||
}
|
||||
function syncNodeForm() {
|
||||
const form = $('#node-form'); if (!form) return;
|
||||
|
||||
Reference in New Issue
Block a user