feat: DDNS settings integration — check availability, update credentials

- GET /api/config now returns domain_mode, domain_name, ddns.{provider,subdomain,has_token}
- GET /api/ddns/check/<name> proxies availability check to DDNS service
- PUT /api/ddns validates and saves cloudflare/duckdns credentials post-setup
- When cell_name changes for pic_ngo provider, auto-registers the new subdomain
- Settings: Cell Name shows availability badge for pic_ngo; auto-save blocks on taken
- Settings: new External Domain & DDNS section — pic_ngo info, cloudflare/duckdns edit
- 11 new tests for the two new endpoints (all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 14:35:37 -04:00
parent 81dcced0ca
commit 61e8631c7d
4 changed files with 500 additions and 14 deletions
+119
View File
@@ -118,6 +118,14 @@ def get_config():
'vip_webdav': _ips['vip_webdav'], 'vip_webdav': _ips['vip_webdav'],
} }
config['service_configs'] = service_configs 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) return jsonify(config)
except Exception as e: except Exception as e:
logger.error(f"Error getting config: {e}") logger.error(f"Error getting config: {e}")
@@ -336,6 +344,18 @@ def update_config():
['dns'], ['dns'],
pre_change_snapshot=_pre_change_snapshot, 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', ''): if identity_updates.get('ip_range') and identity_updates['ip_range'] != old_identity.get('ip_range', ''):
new_range = identity_updates['ip_range'] new_range = identity_updates['ip_range']
@@ -442,6 +462,105 @@ def update_config():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bp.route('/api/ddns/check/<name>', 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']) @bp.route('/api/config/pending', methods=['GET'])
def get_pending_config(): def get_pending_config():
from app import config_manager from app import config_manager
+140
View File
@@ -0,0 +1,140 @@
"""Tests for GET /api/ddns/check/<name> 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()
+235 -14
View File
@@ -5,9 +5,9 @@ import {
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
ChevronDown, ChevronRight, CheckCircle, XCircle, ChevronDown, ChevronRight, CheckCircle, XCircle,
RefreshCw, Lock, FolderDown, X RefreshCw, Lock, FolderDown, X, Globe, Loader
} from 'lucide-react'; } from 'lucide-react';
import { cellAPI } from '../services/api'; import { cellAPI, ddnsAPI } from '../services/api';
// constants // 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 ( return (
<input <input
type={type} type={type}
@@ -230,6 +230,7 @@ function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) {
onChange={(e) => onChange && onChange(e.target.value)} onChange={(e) => onChange && onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
readOnly={readOnly} 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 ${ 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' 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 [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [identityDirty, setIdentityDirty] = useState(false); 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 // service configs
const [serviceConfigs, setServiceConfigs] = useState({}); const [serviceConfigs, setServiceConfigs] = useState({});
const [serviceDirty, setServiceDirty] = useState({}); const [serviceDirty, setServiceDirty] = useState({});
@@ -462,6 +475,15 @@ function Settings() {
ip_range: cfg.ip_range || '', ip_range: cfg.ip_range || '',
}); });
setIdentityDirty(false); 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 || {}); setServiceConfigs(cfg.service_configs || {});
setServiceDirty({}); setServiceDirty({});
setBackups(bkRes.data || []); setBackups(bkRes.data || []);
@@ -493,17 +515,101 @@ function Settings() {
? 'Domain must be 255 characters or fewer' ? 'Domain must be 255 characters or fewer'
: (!identity.domain ? 'Domain is required' : null); : (!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 () => { const saveIdentity = useCallback(async () => {
if (ipRangeError || cellNameError || domainError) return; 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 { try {
await cellAPI.updateConfig(identity); const res = await cellAPI.updateConfig(identity);
setIdentityDirty(false); setIdentityDirty(false);
draftConfig?.setDirty('identity', 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(); refreshConfig();
} catch (err) { } catch (err) {
toast(err.response?.data?.error || 'Failed to save identity', 'error'); 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 // service config save
const saveService = useCallback(async (key) => { const saveService = useCallback(async (key) => {
@@ -565,9 +671,10 @@ function Settings() {
useEffect(() => { useEffect(() => {
if (!identityDirty) return; if (!identityDirty) return;
if (ipRangeError || cellNameError || domainError) return; if (ipRangeError || cellNameError || domainError) return;
if (domainMode === 'pic_ngo' && (picAvail === 'taken' || picAvail === 'checking')) return;
const timer = setTimeout(() => saveIdentityRef.current(), 800); const timer = setTimeout(() => saveIdentityRef.current(), 800);
return () => clearTimeout(timer); 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(() => { useEffect(() => {
const timers = SERVICE_DEFS const timers = SERVICE_DEFS
@@ -712,18 +819,35 @@ function Settings() {
<Section icon={Server} title="Cell Identity"> <Section icon={Server} title="Cell Identity">
<div className="space-y-3"> <div className="space-y-3">
<Field label="Cell Name" error={cellNameError}> <Field label="Cell Name" error={cellNameError}>
<TextInput <div className="flex items-center gap-2">
value={identity.cell_name} <TextInput
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} value={identity.cell_name}
placeholder="mycell" onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
maxLength={255} placeholder="mycell"
/> maxLength={255}
/>
{domainMode === 'pic_ngo' && picAvail === 'checking' && (
<span className="text-xs text-gray-400 whitespace-nowrap flex items-center gap-1"><Loader className="h-3 w-3 animate-spin" /> checking</span>
)}
{domainMode === 'pic_ngo' && picAvail === 'available' && (
<span className="text-xs text-green-600 whitespace-nowrap flex items-center gap-1"><CheckCircle className="h-3 w-3" /> available</span>
)}
{domainMode === 'pic_ngo' && picAvail === 'taken' && (
<span className="text-xs text-red-600 whitespace-nowrap flex items-center gap-1"><XCircle className="h-3 w-3" /> taken</span>
)}
{domainMode === 'pic_ngo' && picAvail === 'unreachable' && (
<span className="text-xs text-yellow-600 whitespace-nowrap">DDNS unreachable</span>
)}
</div>
{domainMode === 'pic_ngo' && (
<p className="mt-1 text-xs text-gray-400">External: <span className="font-mono">{identity.cell_name || '…'}.pic.ngo</span></p>
)}
</Field> </Field>
<Field label="Domain" error={domainError}> <Field label="Local Domain" error={domainError}>
<TextInput <TextInput
value={identity.domain} value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="cell.local" placeholder="cell"
maxLength={255} maxLength={255}
/> />
</Field> </Field>
@@ -737,6 +861,103 @@ function Settings() {
</div> </div>
</Section> </Section>
{/* DDNS / External Domain */}
<Section icon={Globe} title="External Domain & DDNS" collapsible defaultOpen>
<div className="space-y-3">
{domainMode === 'pic_ngo' && (
<div className="rounded-lg bg-blue-50 border border-blue-100 px-4 py-3 text-sm text-blue-700">
Your cell is registered as <span className="font-mono font-semibold">{domainName || `${identity.cell_name}.pic.ngo`}</span> on pic.ngo.
Change the Cell Name above to update this subdomain.
</div>
)}
{domainMode === 'cloudflare' && (
<div className="space-y-3">
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2 text-xs text-gray-500">
Provider: <span className="font-semibold text-gray-700">Cloudflare</span>
{domainName && <> <span className="font-mono">{domainName}</span></>}
</div>
<Field label="Domain">
<TextInput value={domainName} onChange={(v) => { setDomainName(v); setDdnsDirty(true); }} placeholder="home.example.com" />
</Field>
<Field label="API Token" hint={ddnsHasToken ? 'Token is set — enter a new one to replace it' : undefined}>
<div className="flex gap-2">
<TextInput
value={ddnsCfToken}
onChange={(v) => { setDdnsCfToken(v); setDdnsDirty(true); setDdnsCfStatus(null); }}
placeholder={ddnsHasToken ? '••••••••' : 'Cloudflare API token'}
type="password"
/>
<button
onClick={verifyCf}
disabled={!ddnsCfToken.trim() || ddnsCfStatus === 'checking'}
className="px-3 py-1.5 text-xs font-medium rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap"
>
{ddnsCfStatus === 'checking' ? 'Verifying…' : 'Verify & Save'}
</button>
</div>
{ddnsCfStatus === 'valid' && <p className="mt-1 text-xs text-green-600 flex items-center gap-1"><CheckCircle className="h-3 w-3" /> Token valid and saved</p>}
{ddnsCfStatus === 'invalid' && <p className="mt-1 text-xs text-red-600 flex items-center gap-1"><XCircle className="h-3 w-3" /> Token invalid</p>}
</Field>
{ddnsDirty && domainName && (
<button
onClick={saveDdns}
disabled={ddnsSaving}
className="btn-primary text-sm"
>
{ddnsSaving ? 'Saving…' : 'Save Domain'}
</button>
)}
</div>
)}
{domainMode === 'duckdns' && (
<div className="space-y-3">
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2 text-xs text-gray-500">
Provider: <span className="font-semibold text-gray-700">DuckDNS</span>
{domainName && <> <span className="font-mono">{domainName}</span></>}
</div>
<Field label="Subdomain" hint="e.g. myname.duckdns.org">
<TextInput value={domainName} onChange={(v) => { setDomainName(v); setDdnsDirty(true); }} placeholder="myname.duckdns.org" />
</Field>
<Field label="Token" hint={ddnsHasToken ? 'Token is set — enter a new one to replace it' : undefined}>
<div className="flex gap-2">
<TextInput
value={ddnsDuckToken}
onChange={(v) => { setDdnsDuckToken(v); setDdnsDirty(true); setDdnsDuckStatus(null); }}
placeholder={ddnsHasToken ? '••••••••' : 'DuckDNS token'}
type="password"
/>
<button
onClick={verifyDuck}
disabled={!ddnsDuckToken.trim() || ddnsDuckStatus === 'checking'}
className="px-3 py-1.5 text-xs font-medium rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap"
>
{ddnsDuckStatus === 'checking' ? 'Verifying…' : 'Verify & Save'}
</button>
</div>
{ddnsDuckStatus === 'valid' && <p className="mt-1 text-xs text-green-600 flex items-center gap-1"><CheckCircle className="h-3 w-3" /> Token valid and saved</p>}
{ddnsDuckStatus === 'invalid' && <p className="mt-1 text-xs text-red-600 flex items-center gap-1"><XCircle className="h-3 w-3" /> Token invalid</p>}
</Field>
{ddnsDirty && domainName && (
<button
onClick={saveDdns}
disabled={ddnsSaving}
className="btn-primary text-sm"
>
{ddnsSaving ? 'Saving…' : 'Save Domain'}
</button>
)}
</div>
)}
{(domainMode === 'http01' || domainMode === 'lan') && (
<div className="text-sm text-gray-500">
{domainMode === 'http01'
? <>Domain: <span className="font-mono text-gray-700">{domainName || '—'}</span></>
: 'Local-only install — no external domain configured.'}
</div>
)}
</div>
</Section>
{/* Service Configurations */} {/* Service Configurations */}
<div className="mb-2"> <div className="mb-2">
<h2 className="text-lg font-semibold text-gray-800">Service Configuration</h2> <h2 className="text-lg font-semibold text-gray-800">Service Configuration</h2>
+6
View File
@@ -321,6 +321,12 @@ export const logsAPI = {
setVerbosity: (levels) => api.put('/api/logs/verbosity', levels), 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 // Setup Wizard API
export const setupAPI = { export const setupAPI = {
getStatus: () => api.get('/api/setup/status'), getStatus: () => api.get('/api/setup/status'),