From 1f3386d43b18c9e3a724e4b3aca82c6b1be0ccdc Mon Sep 17 00:00:00 2001
From: Dmitrii Iurco
Date: Tue, 21 Apr 2026 05:05:51 -0400
Subject: [PATCH] fix: all service pages use live domain; cell_name/domain
propagate to DNS; /api/status reads stored identity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Changes:
- ConfigContext.jsx: React context that loads /api/config once; exposes domain,
cell_name, refresh() — wraps entire app in App.jsx
- Email/Calendar/Files pages: replace hardcoded 'mail.cell', 'calendar.cell',
'files.cell', 'webdav.cell' with domain from ConfigContext; hostname updates
immediately after Settings save (refreshConfig() called on save)
- /api/status: cell_name and domain now read from stored _identity in config_manager,
not hardcoded 'personal-internet-cell' / 'cell.local'
- network_manager.apply_cell_name(old, new): updates hostname A-record in primary
zone file and reloads CoreDNS; called from PUT /api/config when cell_name changes
- Old identity captured before save so apply_cell_name gets the correct old value
- Settings EmailForm: smtp/imap ports are read-only with note (docker-compose.yml level)
- Settings FilesForm: port is read-only with note (Caddy proxies on 80 externally)
- Settings CalendarForm: port labeled "Internal port; clients use 80 via Caddy"
Tests added:
- test_apply_cell_name_renames_host_record
- test_apply_cell_name_noop_when_same
Co-Authored-By: Claude Sonnet 4.6
---
api/app.py | 16 ++++++++--
api/network_manager.py | 30 +++++++++++++++++++
tests/test_config_manager.py | 45 ++++++++++++++++++++++++++++
webui/src/App.jsx | 3 ++
webui/src/contexts/ConfigContext.jsx | 22 ++++++++++++++
webui/src/pages/Calendar.jsx | 16 +++++-----
webui/src/pages/Email.jsx | 12 ++++----
webui/src/pages/Files.jsx | 18 ++++++-----
webui/src/pages/Settings.jsx | 24 ++++++++++-----
9 files changed, 157 insertions(+), 29 deletions(-)
create mode 100644 webui/src/contexts/ConfigContext.jsx
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 (
+
@@ -126,6 +128,7 @@ function App() {
+
);
}
diff --git a/webui/src/contexts/ConfigContext.jsx b/webui/src/contexts/ConfigContext.jsx
new file mode 100644
index 0000000..705e2df
--- /dev/null
+++ b/webui/src/contexts/ConfigContext.jsx
@@ -0,0 +1,22 @@
+import { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import { cellAPI } from '../services/api';
+
+const ConfigContext = createContext({ domain: 'cell', cell_name: 'mycell' });
+
+export function ConfigProvider({ children }) {
+ const [config, setConfig] = useState({ domain: 'cell', cell_name: 'mycell' });
+
+ const refresh = useCallback(() => {
+ cellAPI.getConfig().then(r => setConfig(r.data)).catch(() => {});
+ }, []);
+
+ useEffect(() => { refresh(); }, [refresh]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useConfig = () => useContext(ConfigContext);
diff --git a/webui/src/pages/Calendar.jsx b/webui/src/pages/Calendar.jsx
index 0573382..9b26d12 100644
--- a/webui/src/pages/Calendar.jsx
+++ b/webui/src/pages/Calendar.jsx
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { calendarAPI } from '../services/api';
+import { useConfig } from '../contexts/ConfigContext';
-const CELL_HOST = 'calendar.cell';
const CELL_IP = '172.20.0.21';
function CopyButton({ text }) {
@@ -32,6 +32,8 @@ function InfoRow({ label, value }) {
}
function Calendar() {
+ const { domain = 'cell' } = useConfig();
+ const cellHost = `calendar.${domain}`;
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -81,9 +83,9 @@ function Calendar() {
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
-
-
-
+
+
+
@@ -104,7 +106,7 @@ function Calendar() {
iOS (Settings → Calendar → Accounts)
- Add Account → Other → Add CalDAV Account
- - Server: calendar.cell
+ - Server: {cellHost}
- Enter username & password
- For contacts: Add CardDAV Account, same server
@@ -113,7 +115,7 @@ function Calendar() {
Android (DAVx⁵ app)
- Install DAVx⁵ from Play Store / F-Droid
- - Login with URL: http://calendar.cell/
+ - Login with URL: http://{cellHost}/
- Select calendars & address books to sync
@@ -121,7 +123,7 @@ function Calendar() {
Thunderbird
- Calendar → New Calendar → On the Network
- - Format: CalDAV, Location: http://calendar.cell/
+ - Format: CalDAV, Location: http://{cellHost}/
diff --git a/webui/src/pages/Email.jsx b/webui/src/pages/Email.jsx
index 9dacb5e..41852d3 100644
--- a/webui/src/pages/Email.jsx
+++ b/webui/src/pages/Email.jsx
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { emailAPI } from '../services/api';
+import { useConfig } from '../contexts/ConfigContext';
-const CELL_HOST = 'mail.cell';
const CELL_IP = '172.20.0.23';
function CopyButton({ text }) {
@@ -32,6 +32,8 @@ function InfoRow({ label, value }) {
}
function Email() {
+ const { domain = 'cell' } = useConfig();
+ const cellHost = `mail.${domain}`;
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -78,7 +80,7 @@ function Email() {
Incoming mail (IMAP)
-
+
@@ -92,7 +94,7 @@ function Email() {
Outgoing mail (SMTP)
-
+
@@ -106,8 +108,8 @@ function Email() {
Webmail
-
-
+
+
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() {
Web file manager
-
+
@@ -96,7 +98,7 @@ function Files() {
WebDAV (mount as drive)
-
+
@@ -115,19 +117,19 @@ 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}
diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx
index 874dbed..b157e64 100644
--- a/webui/src/pages/Settings.jsx
+++ b/webui/src/pages/Settings.jsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
+import { useConfig } from '../contexts/ConfigContext';
import {
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
@@ -194,12 +195,16 @@ function EmailForm({ data, onChange }) {
onChange({ ...data, domain: v })} placeholder="mail.example.com" />
-
- onChange({ ...data, smtp_port: v })} min={1} max={65535} />
+
+
-
- onChange({ ...data, imap_port: v })} min={1} max={65535} />
+
+
+
+ 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 (
-
+
onChange({ ...data, port: v })} min={1} max={65535} />
@@ -220,8 +225,8 @@ function CalendarForm({ data, onChange }) {
function FilesForm({ data, onChange }) {
return (
-
- onChange({ ...data, port: v })} min={1} max={65535} />
+
+
onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
@@ -229,6 +234,9 @@ function FilesForm({ data, onChange }) {
onChange({ ...data, quota: v })} min={0} />
+
+ Clients always connect on port 80 via Caddy reverse proxy, regardless of internal port.
+
);
}
@@ -274,6 +282,7 @@ const SERVICE_DEFS = [
function Settings() {
const toasts = useToasts();
+ const { refresh: refreshConfig } = useConfig();
// identity
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '', wireguard_port: 51820 });
@@ -334,6 +343,7 @@ function Settings() {
const res = await cellAPI.updateConfig(identity);
setIdentityDirty(false);
_applyResult(res, 'Cell identity');
+ refreshConfig();
} catch {
toast('Failed to save identity', 'error');
} finally {