Add port and IP validation across all service config forms
UI: validateServiceConfig() checks all port fields (1–65535) and WireGuard address (IP/CIDR) on every keystroke; Save button is disabled and saveService() guards against any field errors. API: update_config() rejects out-of-range port values and invalid WireGuard address before persisting, returning 400 with a clear field path (e.g. email.smtp_port). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+32
@@ -454,6 +454,38 @@ def update_config():
|
||||
except ValueError as _e:
|
||||
return jsonify({'error': f'Invalid ip_range: {_e}'}), 400
|
||||
|
||||
# Validate service config port and IP fields
|
||||
_port_fields = {
|
||||
'network': ['dns_port'],
|
||||
'wireguard': ['port'],
|
||||
'email': ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'],
|
||||
'calendar': ['port'],
|
||||
'files': ['port', 'manager_port'],
|
||||
}
|
||||
for _svc, _fields in _port_fields.items():
|
||||
if _svc not in data:
|
||||
continue
|
||||
_svc_data = data[_svc]
|
||||
if not isinstance(_svc_data, dict):
|
||||
continue
|
||||
for _f in _fields:
|
||||
if _f in _svc_data and _svc_data[_f] is not None and _svc_data[_f] != '':
|
||||
try:
|
||||
_p = int(_svc_data[_f])
|
||||
if not (1 <= _p <= 65535):
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': f'{_svc}.{_f} must be an integer between 1 and 65535'}), 400
|
||||
# Validate WireGuard address (must be valid IP/CIDR)
|
||||
if 'wireguard' in data and isinstance(data['wireguard'], dict):
|
||||
_addr = data['wireguard'].get('address')
|
||||
if _addr:
|
||||
import ipaddress as _ipa2
|
||||
try:
|
||||
_ipa2.ip_interface(_addr)
|
||||
except ValueError as _e:
|
||||
return jsonify({'error': f'wireguard.address is not a valid IP/CIDR: {_e}'}), 400
|
||||
|
||||
# Capture old identity and service configs BEFORE saving, for change detection
|
||||
old_identity = dict(config_manager.configs.get('_identity', {}))
|
||||
old_svc_configs = {
|
||||
|
||||
@@ -69,6 +69,60 @@ function Section({ icon: Icon, title, children, collapsible = false, defaultOpen
|
||||
);
|
||||
}
|
||||
|
||||
// ── Validation utilities ──────────────────────────────────────────────────────
|
||||
|
||||
function isValidPort(v) {
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
}
|
||||
|
||||
function isValidIp(v) {
|
||||
if (!v || !v.trim()) return false;
|
||||
const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!m) return false;
|
||||
return m.slice(1, 5).map(Number).every(n => n >= 0 && n <= 255);
|
||||
}
|
||||
|
||||
function isValidIpCidr(v) {
|
||||
if (!v || !v.trim()) return false;
|
||||
const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/);
|
||||
if (!m) return false;
|
||||
const [, a, b, c, d, p] = m.map(Number);
|
||||
return [a, b, c, d].every(n => n >= 0 && n <= 255) && p >= 0 && p <= 32;
|
||||
}
|
||||
|
||||
const E_PORT = 'Must be 1–65535';
|
||||
const E_IP = 'Must be a valid IP address';
|
||||
const E_CIDR = 'Must be a valid IP/CIDR (e.g. 10.0.0.1/24)';
|
||||
|
||||
function validateServiceConfig(key, data) {
|
||||
const errors = {};
|
||||
const port = (field) => {
|
||||
if (data[field] !== undefined && data[field] !== '' && !isValidPort(data[field]))
|
||||
errors[field] = E_PORT;
|
||||
};
|
||||
if (key === 'network') {
|
||||
port('dns_port');
|
||||
if (data.dhcp_range) {
|
||||
const parts = data.dhcp_range.split(',');
|
||||
if (parts[0]?.trim() && !isValidIp(parts[0].trim()))
|
||||
errors.dhcp_range = `Start IP is invalid`;
|
||||
else if (parts[1]?.trim() && !isValidIp(parts[1].trim()))
|
||||
errors.dhcp_range = `End IP is invalid`;
|
||||
}
|
||||
}
|
||||
if (key === 'wireguard') {
|
||||
port('port');
|
||||
if (data.address && !isValidIpCidr(data.address)) errors.address = E_CIDR;
|
||||
}
|
||||
if (key === 'email') {
|
||||
port('smtp_port'); port('submission_port'); port('imap_port'); port('webmail_port');
|
||||
}
|
||||
if (key === 'calendar') port('port');
|
||||
if (key === 'files') { port('port'); port('manager_port'); }
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── RFC-1918 validation ───────────────────────────────────────────────────────
|
||||
|
||||
function isRFC1918Cidr(cidr) {
|
||||
@@ -182,13 +236,13 @@ function TagList({ value = [], onChange, placeholder }) {
|
||||
|
||||
// ── Service config forms ──────────────────────────────────────────────────────
|
||||
|
||||
function NetworkForm({ data, onChange }) {
|
||||
function NetworkForm({ data, onChange, errors = {} }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field label="DNS Port">
|
||||
<Field label="DNS Port" error={errors.dns_port}>
|
||||
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h">
|
||||
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h" error={errors.dhcp_range}>
|
||||
<TextInput value={data.dhcp_range} onChange={(v) => onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" />
|
||||
</Field>
|
||||
<Field label="NTP Servers">
|
||||
@@ -198,13 +252,13 @@ function NetworkForm({ data, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WireguardForm({ data, onChange }) {
|
||||
function WireguardForm({ data, onChange, errors = {} }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field label="Listen Port">
|
||||
<Field label="Listen Port" error={errors.port}>
|
||||
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="Server Address" hint="CIDR, e.g. 10.0.0.1/24">
|
||||
<Field label="Server Address" hint="CIDR, e.g. 10.0.0.1/24" error={errors.address}>
|
||||
<TextInput value={data.address} onChange={(v) => onChange({ ...data, address: v })} placeholder="10.0.0.1/24" />
|
||||
</Field>
|
||||
<Field label="Private Key">
|
||||
@@ -214,32 +268,32 @@ function WireguardForm({ data, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EmailForm({ data, onChange }) {
|
||||
function EmailForm({ data, onChange, errors = {} }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field label="Mail Domain">
|
||||
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
|
||||
</Field>
|
||||
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)">
|
||||
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)" error={errors.smtp_port}>
|
||||
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="Submission Port" hint="Client mail send (default 587)">
|
||||
<Field label="Submission Port" hint="Client mail send (default 587)" error={errors.submission_port}>
|
||||
<NumberInput value={data.submission_port ?? 587} onChange={(v) => onChange({ ...data, submission_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="IMAP Port" hint="Client mail fetch (default 993)">
|
||||
<Field label="IMAP Port" hint="Client mail fetch (default 993)" error={errors.imap_port}>
|
||||
<NumberInput value={data.imap_port ?? 993} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)">
|
||||
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)" error={errors.webmail_port}>
|
||||
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarForm({ data, onChange }) {
|
||||
function CalendarForm({ data, onChange, errors = {} }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy">
|
||||
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy" error={errors.port}>
|
||||
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="Data Directory">
|
||||
@@ -249,13 +303,13 @@ function CalendarForm({ data, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilesForm({ data, onChange }) {
|
||||
function FilesForm({ data, onChange, errors = {} }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)">
|
||||
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)" error={errors.port}>
|
||||
<NumberInput value={data.port ?? 8080} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="File Manager Port" hint="Filegator host port (default 8082)">
|
||||
<Field label="File Manager Port" hint="Filegator host port (default 8082)" error={errors.manager_port}>
|
||||
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="Data Directory">
|
||||
@@ -384,6 +438,9 @@ function Settings() {
|
||||
|
||||
// service config save
|
||||
const saveService = async (key) => {
|
||||
const { defaults } = SERVICE_DEFS.find((d) => d.key === key) || {};
|
||||
const data = { ...(defaults || {}), ...(serviceConfigs[key] || {}) };
|
||||
if (Object.keys(validateServiceConfig(key, data)).length > 0) return;
|
||||
setServiceSaving((s) => ({ ...s, [key]: true }));
|
||||
try {
|
||||
const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
|
||||
@@ -535,16 +592,18 @@ function Settings() {
|
||||
</div>
|
||||
{SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => {
|
||||
const data = { ...defaults, ...(serviceConfigs[key] || {}) };
|
||||
const errors = validateServiceConfig(key, data);
|
||||
const hasErrors = Object.keys(errors).length > 0;
|
||||
const dirty = serviceDirty[key];
|
||||
const saving = serviceSaving[key];
|
||||
return (
|
||||
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
|
||||
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} />
|
||||
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} errors={errors} />
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className="text-xs text-gray-400">Port/directory changes take effect after container restart.</span>
|
||||
<button
|
||||
onClick={() => saveService(key)}
|
||||
disabled={!dirty || saving}
|
||||
disabled={!dirty || saving || hasErrors}
|
||||
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
|
||||
Reference in New Issue
Block a user