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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user