feat(service-ports): remove hardcoded ports from docker-compose, make all service ports configurable

All host port bindings in docker-compose.yml now use \${VAR:-default} substitution,
driven by the .env file generated by ip_utils.write_env_file(). Changing a port in
Settings triggers a per-container pending-restart banner so only the affected container
is restarted on Apply.

- ip_utils: add PORT_DEFAULTS, PORT_ENV_VAR_NAMES, PORT_TO_CONTAINERS; extend
  write_env_file() to accept optional ports dict and write all port env vars
- docker-compose: convert all hardcoded port bindings to \${VAR:-default} form
- app.py: add _collect_service_ports helper; detect port changes in update_config,
  write updated .env and call _set_pending_restart with specific container list;
  update _set_pending_restart to merge/accumulate pending state with containers list;
  update apply_pending_config to use --no-deps <service> for targeted restarts
- config_manager: add submission_port, webmail_port to email schema; add manager_port
  to files schema
- Settings.jsx: make all email/files ports editable, add submission_port, webmail_port,
  manager_port fields; update stale identity note
- tests: 8 new tests for PORT_DEFAULTS, PORT_ENV_VAR_NAMES, and port override in write_env_file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 11:51:10 -04:00
parent c3b2c8d8e5
commit 673fe04164
7 changed files with 283 additions and 53 deletions
+19 -17
View File
@@ -195,16 +195,18 @@ function EmailForm({ data, onChange }) {
<Field label="Mail Domain">
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
</Field>
<Field label="SMTP Port" hint="Fixed by docker-compose.yml">
<TextInput value={data.smtp_port ?? 587} readOnly />
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)">
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Fixed by docker-compose.yml">
<TextInput value={data.imap_port ?? 993} readOnly />
<Field label="Submission Port" hint="Client mail send (default 587)">
<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)">
<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)">
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
</Field>
<p className="text-xs text-gray-400">
Ports 587 (SMTP) and 993 (IMAP) are set by docker-compose port bindings and cannot be changed at runtime.
Only <strong>domain</strong> is applied on Save.
</p>
</div>
);
}
@@ -225,8 +227,11 @@ function CalendarForm({ data, onChange }) {
function FilesForm({ data, onChange }) {
return (
<div className="space-y-3">
<Field label="Internal Port" hint="Fixed by docker-compose.yml">
<TextInput value={data.port ?? 80} readOnly />
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)">
<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)">
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
@@ -234,9 +239,6 @@ function FilesForm({ data, onChange }) {
<Field label="Default Quota (MB)">
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
</Field>
<p className="text-xs text-gray-400">
Clients always connect on port 80 via Caddy reverse proxy, regardless of internal port.
</p>
</div>
);
}
@@ -271,9 +273,9 @@ function VaultForm({ data, onChange }) {
const SERVICE_DEFS = [
{ key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } },
{ key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } },
{ key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 587, imap_port: 993 } },
{ key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 } },
{ key: 'calendar', label: 'Calendar (CalDAV)', icon: Calendar, Form: CalendarForm, defaults: { port: 5232, data_dir: '' } },
{ key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 80, data_dir: '', quota: 1024 } },
{ key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 } },
{ key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } },
{ key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } },
];
@@ -499,8 +501,8 @@ function Settings() {
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
Note: IP Range and WireGuard Port are also set via environment variables in docker-compose.yml.
Changes here are stored in config and take effect on next container start.
IP Range and port changes update the .env file and mark affected containers for restart.
Use the banner above to apply when ready.
</p>
</Section>