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