From 56ec35904144e7cf807ccae376ddbc3b32a4db3a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 2 Jun 2026 01:24:27 +0200 Subject: [PATCH] feat(nodes): add per-node TLS verification mode for self-signed certs (#4757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-node TLS verification mode to the Add/Edit Node dialog so the panel can reach nodes that serve HTTPS with a self-signed certificate: - verify (default): normal CA validation. - skip: InsecureSkipVerify, with a clear UI warning that it drops MITM protection. - pin: validates the leaf certificate's SHA-256 (base64 or hex) via VerifyConnection while bypassing the default chain/name check — keeps MITM protection for self-signed certs, the secure alternative to skip. New Node model fields tlsVerifyMode + pinnedCertSha256 (gorm auto-migrated). Probe() selects the HTTP client per node via nodeHTTPClientFor, keeping the SSRF-guarded dialer. A new POST /panel/api/nodes/certFingerprint endpoint (FetchCertFingerprint) lets the UI fetch and pin the node's current certificate in one click. Endpoint documented in api-docs/openapi; i18n added across all locales. Verified end-to-end in Docker (verify rejects, skip bypasses, fetch matches, pin accepts correct / rejects wrong). --- database/model/model.go | 2 + frontend/public/openapi.json | 50 +++++++ frontend/src/api/queries/useNodeMutations.ts | 2 + frontend/src/pages/api-docs/endpoints.ts | 7 + frontend/src/pages/nodes/NodeFormModal.tsx | 67 ++++++++++ frontend/src/pages/nodes/NodesPage.tsx | 3 +- frontend/src/schemas/node.ts | 4 + web/controller/node.go | 24 ++++ web/service/node.go | 131 ++++++++++++++++++- web/translation/ar-EG.json | 14 +- web/translation/en-US.json | 14 +- web/translation/es-ES.json | 14 +- web/translation/fa-IR.json | 14 +- web/translation/id-ID.json | 14 +- web/translation/ja-JP.json | 14 +- web/translation/pt-BR.json | 14 +- web/translation/ru-RU.json | 14 +- web/translation/tr-TR.json | 14 +- web/translation/uk-UA.json | 14 +- web/translation/vi-VN.json | 14 +- web/translation/zh-CN.json | 14 +- web/translation/zh-TW.json | 14 +- 22 files changed, 457 insertions(+), 15 deletions(-) diff --git a/database/model/model.go b/database/model/model.go index dfde97d0..5066a7c7 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -379,6 +379,8 @@ type Node struct { ApiToken string `json:"apiToken" form:"apiToken" validate:"required"` Enable bool `json:"enable" form:"enable" gorm:"default:true"` AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` + TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"` + PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"` // Heartbeat-updated fields. UpdatedAt advances on every probe even when // the row is otherwise unchanged so the UI's "last seen" tooltip is diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c5fbd08e..72befad5 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -4203,6 +4203,56 @@ } } }, + "/panel/api/nodes/certFingerprint": { + "post": { + "tags": [ + "Nodes" + ], + "summary": "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.", + "operationId": "post_panel_api_nodes_certFingerprint", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "scheme": "https", + "address": "node1.example.com", + "port": 2053, + "basePath": "/" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": "k3b1...base64-sha256...=" + } + } + } + } + } + } + }, "/panel/api/nodes/probe/{id}": { "post": { "tags": [ diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index 47f2a87d..abdc0c24 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -70,5 +70,7 @@ export function useNodeMutations() { const raw = await HttpUtil.post('/panel/api/nodes/test', payload); return parseMsg(raw, ProbeResultSchema, 'nodes/test'); }, + fetchFingerprint: (payload: Partial): Promise> => + HttpUtil.post('/panel/api/nodes/certFingerprint', payload), }; } diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index ce40ef6d..7183f9b0 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -769,6 +769,13 @@ export const sections: readonly Section[] = [ body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}', response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}', }, + { + method: 'POST', + path: '/panel/api/nodes/certFingerprint', + summary: "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.", + body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}', + response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}', + }, { method: 'POST', path: '/panel/api/nodes/probe/:id', diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 9281fdc1..0d4f3f53 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -26,6 +26,7 @@ interface NodeFormModalProps { mode: Mode; node: NodeRecord | null; testConnection: (payload: Partial) => Promise>; + fetchFingerprint: (payload: Partial) => Promise>; save: (payload: Partial) => Promise>; onOpenChange: (open: boolean) => void; } @@ -42,6 +43,8 @@ function defaultValues(): NodeFormValues { apiToken: '', enable: true, allowPrivateAddress: false, + tlsVerifyMode: 'verify', + pinnedCertSha256: '', }; } @@ -50,6 +53,7 @@ export default function NodeFormModal({ mode, node, testConnection, + fetchFingerprint, save, onOpenChange, }: NodeFormModalProps) { @@ -59,7 +63,9 @@ export default function NodeFormModal({ const [submitting, setSubmitting] = useState(false); const [testing, setTesting] = useState(false); + const [fetchingPin, setFetchingPin] = useState(false); const [testResult, setTestResult] = useState(null); + const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify'; useEffect(() => { if (!open) return; @@ -94,6 +100,8 @@ export default function NodeFormModal({ apiToken: values.apiToken.trim(), enable: values.enable, allowPrivateAddress: values.allowPrivateAddress, + tlsVerifyMode: values.tlsVerifyMode, + pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '', }; } @@ -118,6 +126,27 @@ export default function NodeFormModal({ } } + async function onFetchPin() { + try { + await form.validateFields(['address', 'port']); + } catch { + return; + } + setFetchingPin(true); + try { + const payload = buildPayload(form.getFieldsValue(true)); + const msg = await fetchFingerprint(payload); + if (msg?.success && msg.obj) { + form.setFieldValue('pinnedCertSha256', msg.obj); + messageApi.success(t('pages.nodes.pinFetched')); + } else { + messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed')); + } + } finally { + setFetchingPin(false); + } + } + async function onFinish(values: NodeFormValues) { const result = NodeFormSchema.safeParse(values); if (!result.success) { @@ -233,6 +262,44 @@ export default function NodeFormModal({ + +