Files
haproxy-openmanager/frontend/src/components/Settings.js
T
taylanbakircioglu c492b26bb1 feat(acme): DNS-01 challenge support with pluggable DNS providers (v1.8.0)
Add ACME DNS-01 (TXT-record) validation alongside the existing HTTP-01,
for internal/isolated clusters with no public port 80 and for wildcard
certificates. Opt-in via a global kill-switch (default off); HTTP-01 is
byte-for-byte unchanged, with zero agent or rendered-config changes.

- Pluggable DNS provider interface (Manual + Cloudflare). Per-account
  credentials are Fernet-encrypted at rest, verified on save, and never
  returned by the API or written to logs/events/error_detail.
- Non-blocking per-cycle orchestrator: publish (CAS) -> propagation grace
  (across cycles, no in-loop sleep) -> respond -> finalize/download, with a
  bounded fresh-order retry chain (1 original + 3 retries) on propagation lag.
- Manual flow: user publishes the TXT record and confirms; manual DNS-01
  cannot auto-renew unattended (auto-renew forced off and surfaced in the UI).
- Migration v8: additive, idempotent columns on letsencrypt_accounts/orders
  and acme_challenges, plus a new letsencrypt_account_dns_credentials table.
- Challenge-type-aware diagnostics (port80/routing/DNS checks skipped for
  DNS-01) and a DNS-01 event timeline in the order detail.
- Frontend: DNS-01 account + credentials management, cert wizard adaptation,
  order-detail TXT records + verify, orders/renewal Method columns, and a
  Settings kill-switch. README, release notes, and API docs updated.

Implements #35.
2026-06-24 02:24:33 +03:00

384 lines
14 KiB
JavaScript

import React, { useEffect, useState } from 'react';
import { Card, Form, Switch, Button, InputNumber, message, Tabs, Input, Select, Collapse, Space, Alert, Tag, Spin, Tooltip } from 'antd';
import { SafetyCertificateOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import axios from 'axios';
const { Option } = Select;
const ACME_PROVIDERS = {
letsencrypt: {
label: "Let's Encrypt",
directory: 'https://acme-v02.api.letsencrypt.org/directory',
staging: 'https://acme-staging-v02.api.letsencrypt.org/directory',
requiresEAB: false,
},
zerossl: {
label: 'ZeroSSL',
directory: 'https://acme.zerossl.com/v2/DV90',
staging: null,
requiresEAB: true,
},
google: {
label: 'Google Trust Services',
directory: 'https://dv.acme-v02.api.pki.goog/directory',
staging: 'https://dv.acme-v02.test-api.pki.goog/directory',
requiresEAB: true,
},
custom: {
label: 'Custom ACME CA',
directory: '',
staging: null,
requiresEAB: false,
},
};
const Settings = () => {
const [searchParams] = useSearchParams();
const defaultTab = searchParams.get('tab') || 'general';
const [form] = Form.useForm();
const [acmeForm] = Form.useForm();
const [acmeLoading, setAcmeLoading] = useState(false);
const [acmeSaving, setAcmeSaving] = useState(false);
const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false);
const onFinish = (values) => {
try {
localStorage.setItem('app_settings', JSON.stringify({
autoRefresh: values.autoRefresh,
refreshInterval: values.refreshInterval,
notifications: values.notifications
}));
} catch (error) {
console.error('Error saving settings:', error);
}
message.success('Settings saved successfully');
};
useEffect(() => {
try {
const savedSettings = localStorage.getItem('app_settings');
if (savedSettings) {
const settings = JSON.parse(savedSettings);
form.setFieldsValue(settings);
}
} catch (error) {
console.error('Error loading settings:', error);
}
}, [form]);
useEffect(() => {
loadAcmeSettings();
}, []);
const loadAcmeSettings = async () => {
setAcmeLoading(true);
try {
const res = await axios.get('/api/settings/acme');
const settings = res.data?.settings || {};
const formValues = {};
Object.entries(settings).forEach(([key, obj]) => {
let val = obj.value;
if (typeof val === 'string') {
try { val = JSON.parse(val); } catch { /* keep as-is */ }
}
formValues[key] = val;
});
acmeForm.setFieldsValue(formValues);
} catch (err) {
if (err?.response?.status !== 403) {
console.error('Error loading ACME settings:', err);
}
} finally {
setAcmeLoading(false);
}
};
const onAcmeSave = async (values) => {
setAcmeSaving(true);
try {
const payload = {};
Object.entries(values).forEach(([key, val]) => {
payload[key] = JSON.stringify(val);
});
await axios.put('/api/settings/acme', { settings: payload });
message.success('ACME settings saved successfully');
setTestResult(null);
} catch (err) {
message.error(err?.response?.data?.detail || 'Failed to save ACME settings');
} finally {
setAcmeSaving(false);
}
};
const handleProviderChange = (provider) => {
const info = ACME_PROVIDERS[provider];
if (info) {
const staging = acmeForm.getFieldValue('staging_mode');
acmeForm.setFieldsValue({
directory_url: staging && info.staging ? info.staging : info.directory,
});
}
};
const handleStagingChange = (checked) => {
const provider = acmeForm.getFieldValue('provider');
const info = ACME_PROVIDERS[provider];
if (info) {
if (checked && info.staging) {
acmeForm.setFieldsValue({ directory_url: info.staging });
} else {
acmeForm.setFieldsValue({ directory_url: info.directory });
}
}
};
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const currentUrl = acmeForm.getFieldValue('directory_url');
const params = currentUrl ? { directory_url: currentUrl } : {};
const res = await axios.get('/api/settings/acme/test-connection', { params });
setTestResult(res.data);
} catch (err) {
setTestResult({ success: false, error: err?.response?.data?.detail || 'Connection test failed' });
} finally {
setTesting(false);
}
};
const tabItems = [
{
key: 'general',
label: 'General Settings',
children: (
<Card>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
initialValues={{
autoRefresh: true,
refreshInterval: 5,
notifications: true,
}}
>
<Form.Item name="autoRefresh" label="Auto Refresh Dashboard" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="refreshInterval" label="Refresh Interval (seconds)">
<InputNumber min={1} max={60} />
</Form.Item>
<Form.Item name="notifications" label="Enable Notifications" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">Save Settings</Button>
</Form.Item>
</Form>
</Card>
),
},
{
key: 'acme',
label: (
<span><SafetyCertificateOutlined /> ACME / SSL Automation</span>
),
children: (
<Spin spinning={acmeLoading}>
<Card>
<Alert
message="ACME Automated Certificate Management"
description="Configure automatic SSL certificate issuance and renewal via ACME protocol (Let's Encrypt, ZeroSSL, Google Trust Services, or any ACME-compatible CA)."
type="info"
showIcon
icon={<SafetyCertificateOutlined />}
style={{ marginBottom: 24 }}
/>
<Form
form={acmeForm}
layout="vertical"
onFinish={onAcmeSave}
initialValues={{
provider: 'letsencrypt',
directory_url: 'https://acme-v02.api.letsencrypt.org/directory',
staging_mode: false,
auto_renew_enabled: false,
renew_before_days: 30,
tos_accepted: false,
contact_email: '',
eab_kid: '',
eab_hmac_key: '',
challenge_backend_url: '',
dns01_enabled: false,
}}
>
<Form.Item name="provider" label="ACME Provider">
<Select onChange={handleProviderChange}>
{Object.entries(ACME_PROVIDERS).map(([key, info]) => (
<Option key={key} value={key}>{info.label}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="directory_url"
label={<span>Directory URL <Tooltip title="The ACME directory endpoint of your CA"><InfoCircleOutlined /></Tooltip></span>}
rules={[{ required: true, message: 'Directory URL is required' }]}
>
<Input placeholder="https://acme-v02.api.letsencrypt.org/directory" />
</Form.Item>
<Form.Item
name="contact_email"
label="Contact Email"
rules={[{ type: 'email', message: 'Please enter a valid email' }]}
>
<Input placeholder="admin@example.com" />
</Form.Item>
<Form.Item name="staging_mode" label="Staging Mode" valuePropName="checked">
<Switch onChange={handleStagingChange} />
</Form.Item>
<div style={{ marginTop: -16, marginBottom: 16 }}>
<Tag color="orange">Staging mode issues test certificates that are NOT trusted by browsers</Tag>
</div>
{/* Commit 5f: optional staging URL override for non-LE CAs (Pebble, custom test ACME). */}
<Form.Item
name="staging_url_override"
label="Custom Staging Directory URL (optional)"
tooltip="When Staging Mode is on, use this URL instead of the default Let's Encrypt staging endpoint. Useful for testing with Pebble or a private ACME test CA."
>
<Input placeholder="https://pebble.example.local:14000/dir" allowClear />
</Form.Item>
<Form.Item name="tos_accepted" label="Terms of Service Accepted" valuePropName="checked">
<Switch />
</Form.Item>
<Card size="small" title="Auto-Renewal" style={{ marginBottom: 24 }}>
<Form.Item name="auto_renew_enabled" label="Enable Auto-Renewal" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="renew_before_days" label="Renew Before Expiry (days)">
<InputNumber min={1} max={90} />
</Form.Item>
</Card>
<Collapse
ghost
items={[{
key: 'eab',
label: 'External Account Binding (EAB)',
children: (
<>
<Alert
message="Required for ZeroSSL and Google Trust Services"
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item name="eab_kid" label="EAB Key ID">
<Input placeholder="EAB Key Identifier" />
</Form.Item>
<Form.Item name="eab_hmac_key" label="EAB HMAC Key">
<Input.Password placeholder="EAB HMAC Key" />
</Form.Item>
</>
),
}]}
/>
<Collapse
ghost
style={{ marginTop: 16 }}
items={[{
key: 'advanced',
label: 'Advanced Settings',
children: (
<Form.Item
name="challenge_backend_url"
label={<span>Challenge Backend URL <Tooltip title="Override the URL HAProxy uses to reach this management backend for ACME challenges. Leave empty for auto-detection."><InfoCircleOutlined /></Tooltip></span>}
>
<Input placeholder="Auto-detect (leave empty)" />
</Form.Item>
),
}]}
/>
{/* Issue #35: DNS-01 challenge support (global kill-switch). Per-account DNS provider
credentials are configured on each ACME account in ACME Automation. */}
<Collapse
ghost
style={{ marginTop: 16 }}
items={[{
key: 'dns01',
label: 'DNS-01 Challenge (Advanced)',
children: (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="DNS-01 validates certificates via a DNS TXT record instead of HTTP on port 80."
description="Use it for internal/isolated clusters with no public inbound port 80, or for wildcard certificates. When enabled, choose DNS-01 and a DNS provider per ACME account in ACME Automation. Leaving this off keeps the default HTTP-01 behavior unchanged."
/>
<Form.Item
name="dns01_enabled"
label="Enable DNS-01 Challenge"
valuePropName="checked"
tooltip="Master switch. While off, DNS-01 options are hidden and no DNS-01 orders can be created."
>
<Switch />
</Form.Item>
</>
),
}]}
/>
<div style={{ marginTop: 24, display: 'flex', gap: 12 }}>
<Button type="primary" htmlType="submit" loading={acmeSaving}>
Save ACME Settings
</Button>
<Button
icon={<ApiOutlined />}
onClick={testConnection}
loading={testing}
>
Test Connection
</Button>
</div>
{testResult && (
<Alert
style={{ marginTop: 16 }}
type={testResult.success ? 'success' : 'error'}
showIcon
icon={testResult.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
message={testResult.success ? 'Connection Successful' : 'Connection Failed'}
description={
testResult.success
? `Connected to ${testResult.directory}. Available endpoints: ${testResult.endpoints?.join(', ')}`
: testResult.error
}
/>
)}
</Form>
</Card>
</Spin>
),
},
];
return (
<div>
<Tabs items={tabItems} defaultActiveKey={defaultTab} />
</div>
);
};
export { Settings };