feat(nodes): add per-node TLS verification mode for self-signed certs (#4757)

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).
This commit is contained in:
MHSanaei
2026-06-02 01:24:27 +02:00
parent b2e2120eb3
commit 56ec359041
22 changed files with 457 additions and 15 deletions

View File

@@ -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<NodeRecord>): Promise<Msg<string>> =>
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
};
}

View File

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

View File

@@ -26,6 +26,7 @@ interface NodeFormModalProps {
mode: Mode;
node: NodeRecord | null;
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
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<ProbeResult | null>(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({
<Switch />
</Form.Item>
<Form.Item
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
extra={t('pages.nodes.tlsVerifyModeHint')}
>
<Select
options={[
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
]}
/>
</Form.Item>
{tlsVerifyMode === 'skip' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.tlsSkipWarning')}
/>
)}
{tlsVerifyMode === 'pin' && (
<Form.Item
label={t('pages.nodes.pinnedCert')}
name="pinnedCertSha256"
extra={t('pages.nodes.pinnedCertHint')}
>
<Input.Search
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
enterButton={t('pages.nodes.fetchPin')}
loading={fetchingPin}
onSearch={onFetchPin}
/>
</Form.Item>
)}
<Form.Item
label={t('pages.nodes.apiToken')}
name="apiToken"

View File

@@ -30,7 +30,7 @@ export default function NodesPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
const { data: latestVersion = '' } = useQuery({
queryKey: ['server', 'panelUpdateInfo'],
@@ -231,6 +231,7 @@ export default function NodesPage() {
mode={formMode}
node={formNode}
testConnection={testConnection}
fetchFingerprint={fetchFingerprint}
save={onSave}
onOpenChange={setFormOpen}
/>

View File

@@ -24,6 +24,8 @@ export const NodeRecordSchema = z.object({
lastHeartbeat: z.number().optional(),
lastError: z.string().optional(),
allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(),
}).loose();
export const NodeListSchema = z.array(NodeRecordSchema);
@@ -46,6 +48,8 @@ export const NodeFormSchema = z.object({
apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
enable: z.boolean(),
allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
pinnedCertSha256: z.string(),
});
export type NodeRecord = z.infer<typeof NodeRecordSchema>;