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
+71
View File
@@ -143,5 +143,76 @@ class TestWriteEnvFile(unittest.TestCase):
self.assertTrue(val.strip())
class TestPortDefaults(unittest.TestCase):
def test_port_defaults_exist(self):
self.assertIsInstance(ip_utils.PORT_DEFAULTS, dict)
for key in ('dns_port', 'mail_smtp_port', 'wg_port', 'radicale_port',
'webdav_port', 'api_port', 'webui_port', 'rainloop_port', 'filegator_port'):
self.assertIn(key, ip_utils.PORT_DEFAULTS)
def test_port_env_var_names_cover_all_defaults(self):
self.assertEqual(set(ip_utils.PORT_DEFAULTS.keys()), set(ip_utils.PORT_ENV_VAR_NAMES.keys()))
def test_port_to_containers_covers_all_defaults(self):
self.assertEqual(set(ip_utils.PORT_DEFAULTS.keys()), set(ip_utils.PORT_TO_CONTAINERS.keys()))
def test_default_values(self):
self.assertEqual(ip_utils.PORT_DEFAULTS['dns_port'], 53)
self.assertEqual(ip_utils.PORT_DEFAULTS['mail_smtp_port'], 25)
self.assertEqual(ip_utils.PORT_DEFAULTS['mail_submission_port'], 587)
self.assertEqual(ip_utils.PORT_DEFAULTS['mail_imap_port'], 993)
self.assertEqual(ip_utils.PORT_DEFAULTS['radicale_port'], 5232)
self.assertEqual(ip_utils.PORT_DEFAULTS['webdav_port'], 8080)
self.assertEqual(ip_utils.PORT_DEFAULTS['wg_port'], 51820)
self.assertEqual(ip_utils.PORT_DEFAULTS['api_port'], 3000)
self.assertEqual(ip_utils.PORT_DEFAULTS['webui_port'], 8081)
self.assertEqual(ip_utils.PORT_DEFAULTS['rainloop_port'], 8888)
self.assertEqual(ip_utils.PORT_DEFAULTS['filegator_port'], 8082)
class TestWriteEnvFilePorts(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.env_path = os.path.join(self.tmp, '.env')
def tearDown(self):
import shutil
shutil.rmtree(self.tmp)
def test_contains_default_ports(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
self.assertIn('DNS_PORT=53', content)
self.assertIn('WG_PORT=51820', content)
self.assertIn('MAIL_SMTP_PORT=25', content)
self.assertIn('MAIL_SUBMISSION_PORT=587', content)
self.assertIn('MAIL_IMAP_PORT=993', content)
self.assertIn('RADICALE_PORT=5232', content)
self.assertIn('WEBDAV_PORT=8080', content)
self.assertIn('API_PORT=3000', content)
self.assertIn('WEBUI_PORT=8081', content)
self.assertIn('RAINLOOP_PORT=8888', content)
self.assertIn('FILEGATOR_PORT=8082', content)
def test_custom_ports_override_defaults(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path, ports={'wg_port': 12345, 'api_port': 4000})
content = open(self.env_path).read()
self.assertIn('WG_PORT=12345', content)
self.assertIn('API_PORT=4000', content)
self.assertIn('DNS_PORT=53', content) # unchanged default
def test_custom_ports_do_not_leak_default(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path, ports={'wg_port': 12345})
content = open(self.env_path).read()
self.assertNotIn('WG_PORT=51820', content)
self.assertIn('WG_PORT=12345', content)
def test_all_port_env_vars_present(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
for var in ip_utils.PORT_ENV_VAR_NAMES.values():
self.assertIn(var + '=', content, f'{var} missing from .env')
if __name__ == '__main__':
unittest.main()