diff --git a/api/routes/config.py b/api/routes/config.py index c4f1b2b..3e773ca 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -118,6 +118,14 @@ def get_config(): 'vip_webdav': _ips['vip_webdav'], } config['service_configs'] = service_configs + config['domain_mode'] = identity.get('domain_mode', 'lan') + config['domain_name'] = identity.get('domain_name', '') + ddns_section = config_manager.configs.get('ddns', {}) + config['ddns'] = { + 'provider': ddns_section.get('provider', ''), + 'subdomain': ddns_section.get('subdomain', ''), + 'has_token': bool(ddns_section.get('token') or ddns_section.get('api_token')), + } return jsonify(config) except Exception as e: logger.error(f"Error getting config: {e}") @@ -336,6 +344,18 @@ def update_config(): ['dns'], pre_change_snapshot=_pre_change_snapshot, ) + _ddns_cfg = config_manager.configs.get('ddns', {}) + if _ddns_cfg.get('provider') == 'pic_ngo': + try: + from ddns_manager import DDNSManager as _DDNSManager + _ddns_mgr = _DDNSManager(config_manager) + _result = _ddns_mgr.register(new_name, '') + _new_sub = _result.get('subdomain', f'{new_name}.pic.ngo') + config_manager.set_identity_field('domain_name', _new_sub) + logger.info('DDNS re-registered: cell_name=%r subdomain=%r', new_name, _new_sub) + except Exception as _exc: + logger.warning('DDNS re-registration failed for %r: %s', new_name, _exc) + all_warnings.append(f'DDNS name update failed — {_exc}') if identity_updates.get('ip_range') and identity_updates['ip_range'] != old_identity.get('ip_range', ''): new_range = identity_updates['ip_range'] @@ -442,6 +462,105 @@ def update_config(): return jsonify({"error": str(e)}), 500 +@bp.route('/api/ddns/check/', methods=['GET']) +def ddns_check_name(name): + import urllib.request as _ureq + import urllib.error as _uerr + import json as _json_ + from setup_manager import DDNS_API_BASE as _DDNS_BASE + try: + url = f'{_DDNS_BASE}/api/v1/check/{name}' + with _ureq.urlopen(url, timeout=8) as resp: + body = _json_.loads(resp.read()) + return jsonify({'available': bool(body.get('available'))}) + except Exception as exc: + logger.warning('DDNS check failed for %r: %s', name, exc) + return jsonify({'available': None, 'error': 'DDNS service unreachable'}), 503 + + +@bp.route('/api/ddns', methods=['PUT']) +def update_ddns_config(): + import urllib.request as _ureq + import urllib.error as _uerr + import json as _json_ + try: + from app import config_manager + from setup_manager import _build_ddns_config, DDNS_API_BASE as _DDNS_BASE + + data = request.get_json(silent=True) or {} + domain_mode = data.get('domain_mode', '').strip() + domain_name = data.get('domain_name', '').strip() + cf_token = data.get('cloudflare_api_token', '').strip() + duck_token = data.get('duckdns_token', '').strip() + + from setup_manager import VALID_DOMAIN_MODES + if domain_mode not in VALID_DOMAIN_MODES: + return jsonify({'error': f'domain_mode must be one of: {", ".join(sorted(VALID_DOMAIN_MODES))}'}), 400 + + if domain_mode == 'cloudflare': + if not domain_name: + return jsonify({'error': 'domain_name is required for cloudflare'}), 400 + if not cf_token: + existing = config_manager.configs.get('ddns', {}).get('api_token', '') + if not existing: + return jsonify({'error': 'cloudflare_api_token is required'}), 400 + cf_token = existing + try: + req = _ureq.Request( + 'https://api.cloudflare.com/client/v4/user/tokens/verify', + headers={'Authorization': f'Bearer {cf_token}'}, + ) + with _ureq.urlopen(req, timeout=8) as resp: + body = _json_.loads(resp.read()) + if not body.get('success'): + return jsonify({'error': 'Cloudflare token is invalid'}), 422 + except _uerr.HTTPError: + return jsonify({'error': 'Cloudflare token is invalid'}), 422 + except Exception as exc: + return jsonify({'error': f'Could not reach Cloudflare: {exc}'}), 503 + + if domain_mode == 'duckdns': + if not domain_name: + return jsonify({'error': 'domain_name is required for duckdns'}), 400 + if not duck_token: + existing = config_manager.configs.get('ddns', {}).get('token', '') + if not existing: + return jsonify({'error': 'duckdns_token is required'}), 400 + duck_token = existing + subdomain = domain_name.replace('.duckdns.org', '') + try: + url = f'https://www.duckdns.org/update?domains={subdomain}&token={duck_token}&ip=' + with _ureq.urlopen(url, timeout=8) as resp: + if resp.read().strip() != b'OK': + return jsonify({'error': 'DuckDNS token or subdomain is invalid'}), 422 + except Exception as exc: + return jsonify({'error': f'Could not reach DuckDNS: {exc}'}), 503 + + duck_sub = domain_name.replace('.duckdns.org', '') if domain_mode == 'duckdns' else '' + ddns_cfg = _build_ddns_config( + domain_mode, + cloudflare_api_token=cf_token, + duckdns_token=duck_token, + duckdns_subdomain=duck_sub, + ) + config_manager.set_ddns_config(ddns_cfg) + config_manager.set_identity_field('domain_mode', domain_mode) + if domain_name: + config_manager.set_identity_field('domain_name', domain_name) + if domain_mode == 'cloudflare' and cf_token: + config_manager.set_identity_field('cloudflare_api_token', cf_token) + if domain_mode == 'duckdns': + if duck_token: + config_manager.set_identity_field('duckdns_token', duck_token) + config_manager.set_identity_field('duckdns_subdomain', duck_sub) + + logger.info('DDNS config updated: domain_mode=%r domain_name=%r', domain_mode, domain_name) + return jsonify({'updated': True}) + except Exception as e: + logger.error(f'Error updating DDNS config: {e}') + return jsonify({'error': str(e)}), 500 + + @bp.route('/api/config/pending', methods=['GET']) def get_pending_config(): from app import config_manager diff --git a/tests/test_ddns_endpoints.py b/tests/test_ddns_endpoints.py new file mode 100644 index 0000000..88f7a7e --- /dev/null +++ b/tests/test_ddns_endpoints.py @@ -0,0 +1,140 @@ +"""Tests for GET /api/ddns/check/ and PUT /api/ddns.""" +import json +import sys +import os +import unittest +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api')) + + +def _make_client(): + from app import app + app.config['TESTING'] = True + return app.test_client() + + +class TestDdnsCheckName(unittest.TestCase): + + def setUp(self): + self.client = _make_client() + + def _get(self, name): + return self.client.get(f'/api/ddns/check/{name}') + + @patch('routes.config.DDNS_API_BASE', 'http://ddns.test', create=True) + def test_available_name_returns_true(self): + with patch('routes.config._ureq', create=True): + import io + resp_mock = MagicMock() + resp_mock.read.return_value = b'{"available": true}' + resp_mock.__enter__ = lambda s: resp_mock + resp_mock.__exit__ = MagicMock(return_value=False) + with patch('urllib.request.urlopen', return_value=resp_mock): + r = self._get('testname') + self.assertEqual(r.status_code, 200) + body = json.loads(r.data) + self.assertTrue(body['available']) + + @patch('routes.config.DDNS_API_BASE', 'http://ddns.test', create=True) + def test_taken_name_returns_false(self): + resp_mock = MagicMock() + resp_mock.read.return_value = b'{"available": false}' + resp_mock.__enter__ = lambda s: resp_mock + resp_mock.__exit__ = MagicMock(return_value=False) + with patch('urllib.request.urlopen', return_value=resp_mock): + r = self._get('taken') + self.assertEqual(r.status_code, 200) + body = json.loads(r.data) + self.assertFalse(body['available']) + + def test_unreachable_returns_503(self): + import urllib.error + with patch('urllib.request.urlopen', side_effect=OSError('conn refused')): + r = self._get('anything') + self.assertEqual(r.status_code, 503) + body = json.loads(r.data) + self.assertIsNone(body['available']) + + +class TestUpdateDdnsConfig(unittest.TestCase): + + def setUp(self): + self.client = _make_client() + + def _put(self, payload): + return self.client.put( + '/api/ddns', + data=json.dumps(payload), + content_type='application/json', + ) + + def test_invalid_domain_mode_returns_400(self): + r = self._put({'domain_mode': 'invalid_mode'}) + self.assertEqual(r.status_code, 400) + self.assertIn('domain_mode', json.loads(r.data)['error']) + + def test_cloudflare_requires_domain_name(self): + r = self._put({'domain_mode': 'cloudflare', 'cloudflare_api_token': 'tok'}) + self.assertEqual(r.status_code, 400) + self.assertIn('domain_name', json.loads(r.data)['error']) + + def test_cloudflare_invalid_token_returns_422(self): + import urllib.error + with patch('urllib.request.urlopen', side_effect=urllib.error.HTTPError( + None, 403, 'Forbidden', {}, None + )): + r = self._put({'domain_mode': 'cloudflare', 'domain_name': 'home.example.com', + 'cloudflare_api_token': 'bad-token'}) + self.assertEqual(r.status_code, 422) + + def test_cloudflare_valid_token_saves_config(self): + from app import config_manager + resp_mock = MagicMock() + resp_mock.read.return_value = b'{"success": true}' + resp_mock.__enter__ = lambda s: resp_mock + resp_mock.__exit__ = MagicMock(return_value=False) + with patch('urllib.request.urlopen', return_value=resp_mock): + with patch.object(config_manager, 'set_ddns_config') as mock_set_ddns, \ + patch.object(config_manager, 'set_identity_field') as mock_set_id: + r = self._put({'domain_mode': 'cloudflare', 'domain_name': 'home.example.com', + 'cloudflare_api_token': 'valid-token'}) + self.assertEqual(r.status_code, 200) + self.assertTrue(json.loads(r.data)['updated']) + mock_set_ddns.assert_called_once() + mock_set_id.assert_any_call('domain_mode', 'cloudflare') + + def test_duckdns_requires_domain_name(self): + r = self._put({'domain_mode': 'duckdns', 'duckdns_token': 'tok'}) + self.assertEqual(r.status_code, 400) + + def test_duckdns_invalid_token_returns_422(self): + resp_mock = MagicMock() + resp_mock.read.return_value = b'KO' + resp_mock.__enter__ = lambda s: resp_mock + resp_mock.__exit__ = MagicMock(return_value=False) + with patch('urllib.request.urlopen', return_value=resp_mock): + r = self._put({'domain_mode': 'duckdns', 'domain_name': 'myname.duckdns.org', + 'duckdns_token': 'bad'}) + self.assertEqual(r.status_code, 422) + + def test_lan_mode_saves_without_validation(self): + from app import config_manager + with patch.object(config_manager, 'set_ddns_config') as mock_ddns, \ + patch.object(config_manager, 'set_identity_field') as mock_id: + r = self._put({'domain_mode': 'lan'}) + self.assertEqual(r.status_code, 200) + mock_ddns.assert_called_once() + mock_id.assert_any_call('domain_mode', 'lan') + + def test_http01_mode_saves_with_domain(self): + from app import config_manager + with patch.object(config_manager, 'set_ddns_config') as mock_ddns, \ + patch.object(config_manager, 'set_identity_field') as mock_id: + r = self._put({'domain_mode': 'http01', 'domain_name': 'home.example.com'}) + self.assertEqual(r.status_code, 200) + mock_id.assert_any_call('domain_name', 'home.example.com') + + +if __name__ == '__main__': + unittest.main() diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index c83ab7d..df54447 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -5,9 +5,9 @@ import { Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, ChevronDown, ChevronRight, CheckCircle, XCircle, - RefreshCw, Lock, FolderDown, X + RefreshCw, Lock, FolderDown, X, Globe, Loader } from 'lucide-react'; -import { cellAPI } from '../services/api'; +import { cellAPI, ddnsAPI } from '../services/api'; // ── constants ──────────────────────────────────────────────────────────────── @@ -222,7 +222,7 @@ function Field({ label, children, hint, error }) { ); } -function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) { +function TextInput({ value, onChange, placeholder, type = 'text', readOnly, maxLength }) { return ( onChange && onChange(e.target.value)} placeholder={placeholder} readOnly={readOnly} + maxLength={maxLength} className={`w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 ${ readOnly ? 'bg-gray-50 text-gray-500 cursor-default' : 'bg-white' }`} @@ -432,6 +433,18 @@ function Settings() { const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' }); const [identityDirty, setIdentityDirty] = useState(false); + // DDNS + const [domainMode, setDomainMode] = useState('lan'); + const [domainName, setDomainName] = useState(''); + const [ddnsHasToken, setDdnsHasToken] = useState(false); + const [picAvail, setPicAvail] = useState(null); // null|'checking'|'available'|'taken'|'unreachable' + const [ddnsCfToken, setDdnsCfToken] = useState(''); + const [ddnsDuckToken, setDdnsDuckToken] = useState(''); + const [ddnsCfStatus, setDdnsCfStatus] = useState(null); // null|'valid'|'invalid' + const [ddnsDuckStatus, setDdnsDuckStatus] = useState(null); + const [ddnsDirty, setDdnsDirty] = useState(false); + const [ddnsSaving, setDdnsSaving] = useState(false); + // service configs const [serviceConfigs, setServiceConfigs] = useState({}); const [serviceDirty, setServiceDirty] = useState({}); @@ -462,6 +475,15 @@ function Settings() { ip_range: cfg.ip_range || '', }); setIdentityDirty(false); + setDomainMode(cfg.domain_mode || 'lan'); + setDomainName(cfg.domain_name || ''); + setDdnsHasToken(cfg.ddns?.has_token || false); + setPicAvail(null); + setDdnsCfToken(''); + setDdnsDuckToken(''); + setDdnsCfStatus(null); + setDdnsDuckStatus(null); + setDdnsDirty(false); setServiceConfigs(cfg.service_configs || {}); setServiceDirty({}); setBackups(bkRes.data || []); @@ -493,17 +515,101 @@ function Settings() { ? 'Domain must be 255 characters or fewer' : (!identity.domain ? 'Domain is required' : null); + // pic_ngo availability check — fires 900ms after cell_name changes + const picAvailTimerRef = useRef(null); + useEffect(() => { + if (domainMode !== 'pic_ngo') { setPicAvail(null); return; } + const name = identity.cell_name; + if (!name) { setPicAvail(null); return; } + clearTimeout(picAvailTimerRef.current); + setPicAvail(null); + picAvailTimerRef.current = setTimeout(async () => { + setPicAvail('checking'); + try { + const res = await ddnsAPI.checkName(name); + setPicAvail(res.data.available ? 'available' : 'taken'); + } catch { + setPicAvail('unreachable'); + } + }, 900); + return () => clearTimeout(picAvailTimerRef.current); + }, [identity.cell_name, domainMode]); // eslint-disable-line react-hooks/exhaustive-deps + const saveIdentity = useCallback(async () => { if (ipRangeError || cellNameError || domainError) return; + if (domainMode === 'pic_ngo' && picAvail === 'taken') { + toast('This subdomain is already taken on pic.ngo — choose a different cell name', 'error'); + return; + } try { - await cellAPI.updateConfig(identity); + const res = await cellAPI.updateConfig(identity); setIdentityDirty(false); draftConfig?.setDirty('identity', false); + if (res.data.warnings?.length) res.data.warnings.forEach((w) => toast(w, 'warning')); + // Refresh to get updated domain_name after DDNS registration + const cfgRes = await cellAPI.getConfig(); + setDomainName(cfgRes.data.domain_name || ''); + setDdnsHasToken(cfgRes.data.ddns?.has_token || false); refreshConfig(); } catch (err) { toast(err.response?.data?.error || 'Failed to save identity', 'error'); } - }, [identity, ipRangeError, cellNameError, domainError, draftConfig, refreshConfig]); + }, [identity, ipRangeError, cellNameError, domainError, domainMode, picAvail, draftConfig, refreshConfig]); + + const saveDdns = useCallback(async () => { + setDdnsSaving(true); + try { + const payload = { domain_mode: domainMode, domain_name: domainName }; + if (domainMode === 'cloudflare' && ddnsCfToken) payload.cloudflare_api_token = ddnsCfToken; + if (domainMode === 'duckdns' && ddnsDuckToken) payload.duckdns_token = ddnsDuckToken; + await ddnsAPI.updateConfig(payload); + setDdnsDirty(false); + setDdnsCfToken(''); + setDdnsDuckToken(''); + setDdnsCfStatus(null); + setDdnsDuckStatus(null); + const cfgRes = await cellAPI.getConfig(); + setDomainName(cfgRes.data.domain_name || ''); + setDdnsHasToken(cfgRes.data.ddns?.has_token || false); + toast('DDNS configuration saved'); + } catch (err) { + toast(err.response?.data?.error || 'Failed to save DDNS config', 'error'); + } finally { + setDdnsSaving(false); + } + }, [domainMode, domainName, ddnsCfToken, ddnsDuckToken]); + + const verifyCf = useCallback(async () => { + if (!ddnsCfToken.trim()) return; + setDdnsCfStatus('checking'); + try { + await ddnsAPI.updateConfig({ domain_mode: 'cloudflare', domain_name: domainName, cloudflare_api_token: ddnsCfToken }); + setDdnsCfStatus('valid'); + setDdnsDirty(false); + const cfgRes = await cellAPI.getConfig(); + setDdnsHasToken(cfgRes.data.ddns?.has_token || false); + toast('Cloudflare token saved'); + } catch (err) { + setDdnsCfStatus('invalid'); + toast(err.response?.data?.error || 'Invalid Cloudflare token', 'error'); + } + }, [ddnsCfToken, domainName]); + + const verifyDuck = useCallback(async () => { + if (!ddnsDuckToken.trim()) return; + setDdnsDuckStatus('checking'); + try { + await ddnsAPI.updateConfig({ domain_mode: 'duckdns', domain_name: domainName, duckdns_token: ddnsDuckToken }); + setDdnsDuckStatus('valid'); + setDdnsDirty(false); + const cfgRes = await cellAPI.getConfig(); + setDdnsHasToken(cfgRes.data.ddns?.has_token || false); + toast('DuckDNS token saved'); + } catch (err) { + setDdnsDuckStatus('invalid'); + toast(err.response?.data?.error || 'Invalid DuckDNS token', 'error'); + } + }, [ddnsDuckToken, domainName]); // service config save const saveService = useCallback(async (key) => { @@ -565,9 +671,10 @@ function Settings() { useEffect(() => { if (!identityDirty) return; if (ipRangeError || cellNameError || domainError) return; + if (domainMode === 'pic_ngo' && (picAvail === 'taken' || picAvail === 'checking')) return; const timer = setTimeout(() => saveIdentityRef.current(), 800); return () => clearTimeout(timer); - }, [identity, identityDirty, ipRangeError, cellNameError, domainError]); // eslint-disable-line react-hooks/exhaustive-deps + }, [identity, identityDirty, ipRangeError, cellNameError, domainError, domainMode, picAvail]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const timers = SERVICE_DEFS @@ -712,18 +819,35 @@ function Settings() {
- { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} - placeholder="mycell" - maxLength={255} - /> +
+ { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} + placeholder="mycell" + maxLength={255} + /> + {domainMode === 'pic_ngo' && picAvail === 'checking' && ( + checking… + )} + {domainMode === 'pic_ngo' && picAvail === 'available' && ( + available + )} + {domainMode === 'pic_ngo' && picAvail === 'taken' && ( + taken + )} + {domainMode === 'pic_ngo' && picAvail === 'unreachable' && ( + DDNS unreachable + )} +
+ {domainMode === 'pic_ngo' && ( +

External: {identity.cell_name || '…'}.pic.ngo

+ )}
- + { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} - placeholder="cell.local" + placeholder="cell" maxLength={255} /> @@ -737,6 +861,103 @@ function Settings() {
+ {/* DDNS / External Domain */} +
+
+ {domainMode === 'pic_ngo' && ( +
+ Your cell is registered as {domainName || `${identity.cell_name}.pic.ngo`} on pic.ngo. + Change the Cell Name above to update this subdomain. +
+ )} + {domainMode === 'cloudflare' && ( +
+
+ Provider: Cloudflare + {domainName && <> — {domainName}} +
+ + { setDomainName(v); setDdnsDirty(true); }} placeholder="home.example.com" /> + + +
+ { setDdnsCfToken(v); setDdnsDirty(true); setDdnsCfStatus(null); }} + placeholder={ddnsHasToken ? '••••••••' : 'Cloudflare API token'} + type="password" + /> + +
+ {ddnsCfStatus === 'valid' &&

Token valid and saved

} + {ddnsCfStatus === 'invalid' &&

Token invalid

} +
+ {ddnsDirty && domainName && ( + + )} +
+ )} + {domainMode === 'duckdns' && ( +
+
+ Provider: DuckDNS + {domainName && <> — {domainName}} +
+ + { setDomainName(v); setDdnsDirty(true); }} placeholder="myname.duckdns.org" /> + + +
+ { setDdnsDuckToken(v); setDdnsDirty(true); setDdnsDuckStatus(null); }} + placeholder={ddnsHasToken ? '••••••••' : 'DuckDNS token'} + type="password" + /> + +
+ {ddnsDuckStatus === 'valid' &&

Token valid and saved

} + {ddnsDuckStatus === 'invalid' &&

Token invalid

} +
+ {ddnsDirty && domainName && ( + + )} +
+ )} + {(domainMode === 'http01' || domainMode === 'lan') && ( +
+ {domainMode === 'http01' + ? <>Domain: {domainName || '—'} + : 'Local-only install — no external domain configured.'} +
+ )} +
+
+ {/* Service Configurations */}

Service Configuration

diff --git a/webui/src/services/api.js b/webui/src/services/api.js index ee5f41c..c6e9e6c 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -321,6 +321,12 @@ export const logsAPI = { setVerbosity: (levels) => api.put('/api/logs/verbosity', levels), }; +// DDNS API +export const ddnsAPI = { + checkName: (name) => api.get(`/api/ddns/check/${name}`), + updateConfig: (data) => api.put('/api/ddns', data), +}; + // Setup Wizard API export const setupAPI = { getStatus: () => api.get('/api/setup/status'),