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)

  1. Add Account → Other → Add CalDAV Account
  2. -
  3. Server: calendar.cell
  4. +
  5. Server: {cellHost}
  6. Enter username & password
  7. For contacts: Add CardDAV Account, same server
@@ -113,7 +115,7 @@ function Calendar() {

Android (DAVx⁵ app)

  1. Install DAVx⁵ from Play Store / F-Droid
  2. -
  3. Login with URL: http://calendar.cell/
  4. +
  5. Login with URL: http://{cellHost}/
  6. Select calendars & address books to sync
@@ -121,7 +123,7 @@ function Calendar() {

Thunderbird

  1. Calendar → New Calendar → On the Network
  2. -
  3. Format: CalDAV, Location: http://calendar.cell/
  4. +
  5. 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 {