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
This commit is contained in:
MHSanaei
2026-06-03 16:41:02 +02:00
parent 42d7f62d8b
commit 55d6729955
8 changed files with 209 additions and 17 deletions

View File

@@ -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": [

View File

@@ -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',

View File

@@ -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) {

View File

@@ -13,13 +13,17 @@ interface UseSecurityActionsArgs {
form: FormInstance<InboundFormValues>;
setSaving: Dispatch<SetStateAction<boolean>>;
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);
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 {