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:
|
except ValueError as _e:
|
||||||
return jsonify({'error': f'Invalid ip_range: {_e}'}), 400
|
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
|
# Capture old identity and service configs BEFORE saving, for change detection
|
||||||
old_identity = dict(config_manager.configs.get('_identity', {}))
|
old_identity = dict(config_manager.configs.get('_identity', {}))
|
||||||
old_svc_configs = {
|
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 ───────────────────────────────────────────────────────
|
// ── RFC-1918 validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isRFC1918Cidr(cidr) {
|
function isRFC1918Cidr(cidr) {
|
||||||
@@ -182,13 +236,13 @@ function TagList({ value = [], onChange, placeholder }) {
|
|||||||
|
|
||||||
// ── Service config forms ──────────────────────────────────────────────────────
|
// ── Service config forms ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NetworkForm({ data, onChange }) {
|
function NetworkForm({ data, onChange, errors = {} }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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} />
|
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</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" />
|
<TextInput value={data.dhcp_range} onChange={(v) => onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="NTP Servers">
|
<Field label="NTP Servers">
|
||||||
@@ -198,13 +252,13 @@ function NetworkForm({ data, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WireguardForm({ data, onChange }) {
|
function WireguardForm({ data, onChange, errors = {} }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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} />
|
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</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" />
|
<TextInput value={data.address} onChange={(v) => onChange({ ...data, address: v })} placeholder="10.0.0.1/24" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Private Key">
|
<Field label="Private Key">
|
||||||
@@ -214,32 +268,32 @@ function WireguardForm({ data, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmailForm({ data, onChange }) {
|
function EmailForm({ data, onChange, errors = {} }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Field label="Mail Domain">
|
<Field label="Mail Domain">
|
||||||
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
|
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
|
||||||
</Field>
|
</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} />
|
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</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} />
|
<NumberInput value={data.submission_port ?? 587} onChange={(v) => onChange({ ...data, submission_port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</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} />
|
<NumberInput value={data.imap_port ?? 993} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</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} />
|
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarForm({ data, onChange }) {
|
function CalendarForm({ data, onChange, errors = {} }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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} />
|
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Data Directory">
|
<Field label="Data Directory">
|
||||||
@@ -249,13 +303,13 @@ function CalendarForm({ data, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilesForm({ data, onChange }) {
|
function FilesForm({ data, onChange, errors = {} }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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} />
|
<NumberInput value={data.port ?? 8080} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</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} />
|
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Data Directory">
|
<Field label="Data Directory">
|
||||||
@@ -384,6 +438,9 @@ function Settings() {
|
|||||||
|
|
||||||
// service config save
|
// service config save
|
||||||
const saveService = async (key) => {
|
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 }));
|
setServiceSaving((s) => ({ ...s, [key]: true }));
|
||||||
try {
|
try {
|
||||||
const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
|
const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
|
||||||
@@ -535,16 +592,18 @@ function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
{SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => {
|
{SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => {
|
||||||
const data = { ...defaults, ...(serviceConfigs[key] || {}) };
|
const data = { ...defaults, ...(serviceConfigs[key] || {}) };
|
||||||
|
const errors = validateServiceConfig(key, data);
|
||||||
|
const hasErrors = Object.keys(errors).length > 0;
|
||||||
const dirty = serviceDirty[key];
|
const dirty = serviceDirty[key];
|
||||||
const saving = serviceSaving[key];
|
const saving = serviceSaving[key];
|
||||||
return (
|
return (
|
||||||
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
|
<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">
|
<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>
|
<span className="text-xs text-gray-400">Port/directory changes take effect after container restart.</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => saveService(key)}
|
onClick={() => saveService(key)}
|
||||||
disabled={!dirty || saving}
|
disabled={!dirty || saving || hasErrors}
|
||||||
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
|
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" />}
|
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
|||||||
Reference in New Issue
Block a user