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'],
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user