From 55d6729955ae09728890c2bbbd995985d0b47897 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 3 Jun 2026 16:41:02 +0200 Subject: [PATCH] fix(nodes): Set Cert from Panel uses the node's own web cert for node inbounds For an inbound deployed to a node, the button read the central panel's webCertFile/webKeyFile and inserted paths that don't exist on the node, crashing the node's Xray on startup. Add a token-accessible GET /panel/api/server/getWebCertFiles that returns a panel's own web cert/key paths, Remote.GetWebCertFiles to fetch it from a node, and GET /panel/api/nodes/webCert/:id to proxy it. setCertFromPanel now calls the node endpoint for a node-assigned inbound and the local settings otherwise, warning instead of inserting wrong paths on error/empty. Fixes #4854 --- frontend/public/openapi.json | 85 +++++++++++++++++++ frontend/src/pages/api-docs/endpoints.ts | 15 ++++ .../pages/inbounds/form/InboundFormModal.tsx | 2 +- .../pages/inbounds/form/useSecurityActions.ts | 42 +++++---- web/controller/node.go | 17 ++++ web/controller/server.go | 19 +++++ web/runtime/remote.go | 22 +++++ web/service/node.go | 24 ++++++ 8 files changed, 209 insertions(+), 17 deletions(-) diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e53989c3..d51b0b8d 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1529,6 +1529,43 @@ } } }, + "/panel/api/server/getWebCertFiles": { + "get": { + "tags": [ + "Server" + ], + "summary": "Return this panel's own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so \"Set Cert from Panel\" fills a node-assigned inbound with paths that exist on the node.", + "operationId": "get_panel_api_server_getWebCertFiles", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": { + "webCertFile": "/root/cert/example.com/fullchain.pem", + "webKeyFile": "/root/cert/example.com/privkey.pem" + } + } + } + } + } + } + } + }, "/panel/api/server/getNewX25519Cert": { "get": { "tags": [ @@ -4016,6 +4053,54 @@ } } }, + "/panel/api/nodes/webCert/{id}": { + "get": { + "tags": [ + "Nodes" + ], + "summary": "Fetch a node's own web TLS certificate/key file paths (proxied to the node). Used by the inbound form's \"Set Cert from Panel\" so a node-assigned inbound gets paths that exist on the node, not the central panel.", + "operationId": "get_panel_api_nodes_webCert_id", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Node ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": { + "webCertFile": "/root/cert/example.com/fullchain.pem", + "webKeyFile": "/root/cert/example.com/privkey.pem" + } + } + } + } + } + } + } + }, "/panel/api/nodes/add": { "post": { "tags": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index f9aa88a7..6b3a5c8e 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -313,6 +313,12 @@ export const sections: readonly Section[] = [ summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.', response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}', }, + { + method: 'GET', + path: '/panel/api/server/getWebCertFiles', + summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.', + response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}', + }, { method: 'GET', path: '/panel/api/server/getNewX25519Cert', @@ -741,6 +747,15 @@ export const sections: readonly Section[] = [ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, ], }, + { + method: 'GET', + path: '/panel/api/nodes/webCert/:id', + summary: 'Fetch a node\'s own web TLS certificate/key file paths (proxied to the node). Used by the inbound form\'s "Set Cert from Panel" so a node-assigned inbound gets paths that exist on the node, not the central panel.', + params: [ + { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, + ], + response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}', + }, { method: 'POST', path: '/panel/api/nodes/add', diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 0c0db30a..99209cd6 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -194,7 +194,7 @@ export default function InboundFormModal({ setCertFromPanel, clearCertFiles, onSecurityChange, - } = useSecurityActions({ form, setSaving, messageApi }); + } = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null }); const toggleExternalProxy = (on: boolean) => { if (on) { diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index e3ca1796..863d6aac 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -13,13 +13,17 @@ interface UseSecurityActionsArgs { form: FormInstance; setSaving: Dispatch>; messageApi: MessageInstance; + // Node the inbound is deployed to (null = central panel). "Set Cert from + // Panel" must read the node's own cert paths for a node-assigned inbound — + // the central panel's paths don't exist on the node. See issue #4854. + nodeId: number | null; } // Server-side TLS / Reality key + certificate generation handlers for the // inbound modal's security tab. Each talks to a /panel server endpoint and // writes the result back into the form. Lifted out of InboundFormModal so // the modal body stays focused on orchestration. -export function useSecurityActions({ form, setSaving, messageApi }: UseSecurityActionsArgs) { +export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseSecurityActionsArgs) { const { t } = useTranslation(); const genRealityKeypair = async () => { @@ -112,22 +116,28 @@ export function useSecurityActions({ form, setSaving, messageApi }: UseSecurityA const setCertFromPanel = async (certName: number) => { setSaving(true); try { - const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }); - if (msg?.success) { - const obj = msg.obj as { webCertFile?: string; webKeyFile?: string }; - if (!obj.webCertFile && !obj.webKeyFile) { - messageApi.warning(t('pages.inbounds.setDefaultCertEmpty')); - return; - } - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], - obj.webCertFile ?? '', - ); - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], - obj.webKeyFile ?? '', - ); + // Node-assigned inbounds run on the node, so their cert files must be the + // node's own paths (fetched through the central panel), not this panel's. + const msg = typeof nodeId === 'number' + ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true }) + : await HttpUtil.post('/panel/setting/all', undefined, { silent: true }); + if (!msg?.success) { + messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty')); + return; } + const obj = msg.obj as { webCertFile?: string; webKeyFile?: string }; + if (!obj?.webCertFile && !obj?.webKeyFile) { + messageApi.warning(t('pages.inbounds.setDefaultCertEmpty')); + return; + } + form.setFieldValue( + ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], + obj.webCertFile ?? '', + ); + form.setFieldValue( + ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], + obj.webKeyFile ?? '', + ); } finally { setSaving(false); } diff --git a/web/controller/node.go b/web/controller/node.go index 966a69e8..ae6036b5 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -28,6 +28,7 @@ func NewNodeController(g *gin.RouterGroup) *NodeController { func (a *NodeController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) g.GET("/get/:id", a.get) + g.GET("/webCert/:id", a.webCert) g.POST("/add", a.add) g.POST("/update/:id", a.update) @@ -64,6 +65,22 @@ func (a *NodeController) get(c *gin.Context) { jsonObj(c, n, nil) } +// webCert returns the node's own web TLS certificate/key file paths so the +// inbound form's "Set Cert from Panel" can fill paths that exist on the node. +func (a *NodeController) webCert(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "get"), err) + return + } + files, err := a.nodeService.GetWebCertFiles(id) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) + return + } + jsonObj(c, files, nil) +} + func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error { ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) defer cancel() diff --git a/web/controller/server.go b/web/controller/server.go index f44cf003..6c70de74 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -54,6 +54,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.GET("/getConfigJson", a.getConfigJson) g.GET("/getDb", a.getDb) g.GET("/getNewUUID", a.getNewUUID) + g.GET("/getWebCertFiles", a.getWebCertFiles) g.GET("/getNewX25519Cert", a.getNewX25519Cert) g.GET("/getNewmldsa65", a.getNewmldsa65) g.GET("/getNewmlkem768", a.getNewmlkem768) @@ -314,6 +315,24 @@ func (a *ServerController) importDB(c *gin.Context) { jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil) } +// getWebCertFiles returns this panel's own web TLS certificate and key file +// paths. The central panel calls it on a node (via the node's API token) so +// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist +// on the node's filesystem instead of the central panel's — see issue #4854. +func (a *ServerController) getWebCertFiles(c *gin.Context) { + certFile, err := a.settingService.GetCertFile() + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + keyFile, err := a.settingService.GetKeyFile() + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil) +} + // getNewX25519Cert generates a new X25519 certificate. func (a *ServerController) getNewX25519Cert(c *gin.Context) { cert, err := a.serverService.GetNewX25519Cert() diff --git a/web/runtime/remote.go b/web/runtime/remote.go index 6c80b311..b525ba5f 100644 --- a/web/runtime/remote.go +++ b/web/runtime/remote.go @@ -328,6 +328,28 @@ func (r *Remote) UpdatePanel(ctx context.Context) error { return err } +// WebCertFiles holds a node's own web TLS certificate and key file paths. +type WebCertFiles struct { + WebCertFile string `json:"webCertFile"` + WebKeyFile string `json:"webKeyFile"` +} + +// GetWebCertFiles fetches the node's own web TLS certificate/key file paths so +// the central panel can offer them as the "Set Cert from Panel" default for a +// node-assigned inbound — those paths exist on the node, the central panel's +// don't. See issue #4854. +func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) { + env, err := r.do(ctx, http.MethodGet, "panel/api/server/getWebCertFiles", nil) + if err != nil { + return nil, err + } + var files WebCertFiles + if err := json.Unmarshal(env.Obj, &files); err != nil { + return nil, fmt.Errorf("decode web cert files: %w", err) + } + return &files, nil +} + func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error { _, err := r.do(ctx, http.MethodPost, "panel/api/clients/resetTraffic/"+url.PathEscape(email), nil) diff --git a/web/service/node.go b/web/service/node.go index 705b10e7..de92cc8e 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -382,6 +382,30 @@ func (s *NodeService) SetEnable(id int, enable bool) error { return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error } +// GetWebCertFiles asks a node for its own web TLS certificate/key file paths, +// used by "Set Cert from Panel" so a node-assigned inbound gets paths that +// exist on the node rather than the central panel. See issue #4854. +func (s *NodeService) GetWebCertFiles(id int) (*runtime.WebCertFiles, error) { + n, err := s.GetById(id) + if err != nil || n == nil { + return nil, fmt.Errorf("node not found") + } + if !n.Enable { + return nil, fmt.Errorf("node is disabled") + } + mgr := runtime.GetManager() + if mgr == nil { + return nil, fmt.Errorf("runtime manager unavailable") + } + remote, err := mgr.RemoteFor(n) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return remote.GetWebCertFiles(ctx) +} + // NodeUpdateResult reports the outcome of triggering a panel self-update on one // node so the UI can show per-node success/failure for a bulk request. type NodeUpdateResult struct {