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'],
}
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/<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'])
def get_pending_config():
from app import config_manager