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({ + +