diff --git a/api/app.py b/api/app.py
index 3a68822..7059c52 100644
--- a/api/app.py
+++ b/api/app.py
@@ -356,9 +356,10 @@ def get_cell_status():
current_time = time.time()
uptime_seconds = int(current_time - API_START_TIME)
+ identity = config_manager.configs.get('_identity', {})
return jsonify({
- "cell_name": "personal-internet-cell",
- "domain": "cell.local",
+ "cell_name": identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
+ "domain": identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
"uptime": uptime_seconds,
"peers_count": len(peers),
"services": services_status,
@@ -397,6 +398,8 @@ def update_config():
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'}
identity_updates = {k: v for k, v in data.items() if k in identity_keys}
+ # Capture old identity BEFORE saving, for apply_cell_name comparison
+ old_identity = dict(config_manager.configs.get('_identity', {}))
if identity_updates:
stored = config_manager.configs.get('_identity', {})
stored.update(identity_updates)
@@ -439,6 +442,15 @@ def update_config():
all_restarted.extend(net_result.get('restarted', []))
all_warnings.extend(net_result.get('warnings', []))
+ # Apply cell name change to DNS hostname record
+ if identity_updates.get('cell_name'):
+ old_name = old_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
+ new_name = identity_updates['cell_name']
+ if old_name != new_name:
+ cn_result = network_manager.apply_cell_name(old_name, new_name)
+ all_restarted.extend(cn_result.get('restarted', []))
+ all_warnings.extend(cn_result.get('warnings', []))
+
logger.info(f"Updated config, restarted: {all_restarted}")
return jsonify({
"message": "Configuration updated and applied",
diff --git a/api/network_manager.py b/api/network_manager.py
index 4795b9f..23fcc8d 100644
--- a/api/network_manager.py
+++ b/api/network_manager.py
@@ -422,6 +422,36 @@ class NetworkManager(BaseServiceManager):
warnings.append(f"CoreDNS reload failed: {e}")
return {'restarted': restarted, 'warnings': warnings}
+
+ def apply_cell_name(self, old_name: str, new_name: str) -> Dict[str, Any]:
+ """Update the cell hostname record in the primary DNS zone file."""
+ restarted = []
+ warnings = []
+ if not old_name or not new_name or old_name == new_name:
+ return {'restarted': restarted, 'warnings': warnings}
+ try:
+ dns_data = os.path.join(self.data_dir, 'dns')
+ if os.path.isdir(dns_data):
+ for fname in os.listdir(dns_data):
+ if fname.endswith('.zone') and 'local' not in fname:
+ zone_file = os.path.join(dns_data, fname)
+ with open(zone_file) as f:
+ content = f.read()
+ # Replace hostname record: old_name IN A ...
+ import re
+ content = re.sub(
+ rf'^{re.escape(old_name)}(\s+IN\s+A\s+)',
+ f'{new_name}\\1',
+ content, flags=re.MULTILINE
+ )
+ with open(zone_file, 'w') as f:
+ f.write(content)
+ break
+ self._reload_dns_service()
+ restarted.append('cell-dns (reloaded)')
+ except Exception as e:
+ warnings.append(f"cell_name DNS update failed: {e}")
+ return {'restarted': restarted, 'warnings': warnings}
def test_dns_resolution(self, domain: str) -> Dict:
"""Test DNS resolution for a domain using Python socket."""
diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py
index efd2453..472758a 100644
--- a/tests/test_config_manager.py
+++ b/tests/test_config_manager.py
@@ -335,6 +335,51 @@ class TestNetworkManagerApply(unittest.TestCase):
"Corefile must not keep old 'cell' zone block")
+class TestNetworkManagerApplyCellName(unittest.TestCase):
+ """apply_cell_name updates the DNS zone hostname record."""
+
+ def setUp(self):
+ self.test_dir = tempfile.mkdtemp()
+ self.data_dir = os.path.join(self.test_dir, 'data')
+ self.config_dir = os.path.join(self.test_dir, 'config')
+ os.makedirs(os.path.join(self.data_dir, 'dns'), exist_ok=True)
+ os.makedirs(os.path.join(self.config_dir, 'dhcp'), exist_ok=True)
+ os.makedirs(os.path.join(self.config_dir, 'ntp'), exist_ok=True)
+ with open(os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf'), 'w') as f:
+ f.write('domain=cell\n')
+ with open(os.path.join(self.config_dir, 'ntp', 'chrony.conf'), 'w') as f:
+ f.write('server pool.ntp.org iburst\n')
+ # Create a zone file with old cell name
+ with open(os.path.join(self.data_dir, 'dns', 'cell.zone'), 'w') as f:
+ f.write('$ORIGIN cell.\n$TTL 300\n'
+ '@ IN SOA ns1.cell. admin.cell. 2024010101 3600 900 604800 300\n'
+ 'ns1 IN A 172.20.0.3\n'
+ 'mycell IN A 172.20.0.2\n'
+ '@ IN A 172.20.0.2\n')
+ sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
+ from network_manager import NetworkManager
+ self.nm = NetworkManager(self.data_dir, self.config_dir)
+
+ def tearDown(self):
+ shutil.rmtree(self.test_dir)
+
+ @patch('subprocess.run')
+ def test_apply_cell_name_renames_host_record(self, mock_run):
+ mock_run.return_value = MagicMock(returncode=0)
+ result = self.nm.apply_cell_name('mycell', 'newcell')
+ zone = open(os.path.join(self.data_dir, 'dns', 'cell.zone')).read()
+ self.assertIn('newcell IN A 172.20.0.2', zone)
+ self.assertNotIn('mycell IN A', zone)
+ self.assertIn('cell-dns', ' '.join(result['restarted']))
+
+ @patch('subprocess.run')
+ def test_apply_cell_name_noop_when_same(self, mock_run):
+ mock_run.return_value = MagicMock(returncode=0)
+ result = self.nm.apply_cell_name('mycell', 'mycell')
+ self.assertEqual(result['restarted'], [])
+ mock_run.assert_not_called()
+
+
class TestEmailManagerApply(unittest.TestCase):
"""Test email_manager.apply_config writes mailserver.env correctly."""
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 7b110dc..e335991 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -16,6 +16,7 @@ import {
Settings as SettingsIcon
} from 'lucide-react';
import { healthAPI } from './services/api';
+import { ConfigProvider } from './contexts/ConfigContext';
import Sidebar from './components/Sidebar';
import Dashboard from './pages/Dashboard';
import Peers from './pages/Peers';
@@ -81,6 +82,7 @@ function App() {
return (
iOS (Settings → Calendar → Accounts)
Android (DAVx⁵ app)
Thunderbird
diff --git a/webui/src/pages/Files.jsx b/webui/src/pages/Files.jsx index 046380c..4fdd0f3 100644 --- a/webui/src/pages/Files.jsx +++ b/webui/src/pages/Files.jsx @@ -1,10 +1,9 @@ import { useState, useEffect } from 'react'; import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react'; import { fileAPI } from '../services/api'; +import { useConfig } from '../contexts/ConfigContext'; -const FILES_HOST = 'files.cell'; const FILES_IP = '172.20.0.22'; -const WEBDAV_HOST = 'webdav.cell'; const WEBDAV_IP = '172.20.0.24'; function CopyButton({ text }) { @@ -34,6 +33,9 @@ function InfoRow({ label, value }) { } function Files() { + const { domain = 'cell' } = useConfig(); + const filesHost = `files.${domain}`; + const webdavHost = `webdav.${domain}`; const [users, setUsers] = useState([]); const [status, setStatus] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -80,7 +82,7 @@ function Files() {
macOS (Finder)
-Go → Connect to Server → http://webdav.cell
+Go → Connect to Server → http://{webdavHost}
Windows
-Map Network Drive → \\webdav.cell\DavWWWRoot or use http://webdav.cell in "Connect to a Web Site"
+Map Network Drive → \\{webdavHost}\DavWWWRoot or use http://{webdavHost} in "Connect to a Web Site"
iOS (Files app)
-Files → ... → Connect to Server → http://webdav.cell
+Files → ... → Connect to Server → http://{webdavHost}
Android
-Use Solid Explorer or FX File Explorer → Add cloud → WebDAV
+Use Solid Explorer or FX File Explorer → Add cloud → WebDAV → http://{webdavHost}
+ Ports 587 (SMTP) and 993 (IMAP) are set by docker-compose port bindings and cannot be changed at runtime. + Only domain is applied on Save. +
); } @@ -207,7 +212,7 @@ function EmailForm({ data, onChange }) { function CalendarForm({ data, onChange }) { return (+ Clients always connect on port 80 via Caddy reverse proxy, regardless of internal port. +