feat: DDNS self-healing heartbeat + manual re-register endpoint
Unit Tests / test (push) Successful in 15m26s
Unit Tests / test (push) Successful in 15m26s
- DDNSTokenExpired exception triggers auto re-register in update_ip() so cells recover silently after a DDNS DB reset - POST /api/ddns/register lets the user force re-registration from Settings - Re-register button in Settings → External Domain & DDNS (pic_ngo only) - 3 new tests covering register endpoint: wrong provider, missing name, success Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,10 @@ class DDNSError(Exception):
|
||||
"""Raised when a DDNS provider returns an error response."""
|
||||
|
||||
|
||||
class DDNSTokenExpired(DDNSError):
|
||||
"""Raised when the DDNS service rejects the token (401) — usually after a DB reset."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider base class
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -96,6 +100,10 @@ class PicNgoDDNS(DDNSProvider):
|
||||
|
||||
def _raise_for_status(self, response: requests.Response, action: str):
|
||||
if not response.ok:
|
||||
if response.status_code == 401:
|
||||
raise DDNSTokenExpired(
|
||||
f"PicNgoDDNS {action} rejected token: HTTP 401 — {response.text}"
|
||||
)
|
||||
raise DDNSError(
|
||||
f"PicNgoDDNS {action} failed: HTTP {response.status_code} — {response.text}"
|
||||
)
|
||||
@@ -432,6 +440,18 @@ class DDNSManager(BaseServiceManager):
|
||||
self._last_ip = current_ip
|
||||
else:
|
||||
logger.warning("DDNS update_ip: provider.update() returned False")
|
||||
except DDNSTokenExpired:
|
||||
logger.warning("DDNS update_ip: token rejected (401) — attempting re-registration")
|
||||
try:
|
||||
cell_name = self._identity().get('cell_name', '')
|
||||
if cell_name:
|
||||
self.register(cell_name, current_ip)
|
||||
logger.info("DDNS re-registered after token expiry: cell_name=%r", cell_name)
|
||||
self._last_ip = current_ip
|
||||
else:
|
||||
logger.error("DDNS update_ip: cannot re-register — cell_name not in identity")
|
||||
except Exception as exc2:
|
||||
logger.error("DDNS update_ip: re-registration failed: %s", exc2)
|
||||
except DDNSError as exc:
|
||||
logger.error("DDNS update_ip: provider error: %s", exc)
|
||||
|
||||
|
||||
@@ -561,6 +561,30 @@ def update_ddns_config():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/ddns/register', methods=['POST'])
|
||||
def ddns_register():
|
||||
"""Trigger (re-)registration with the configured DDNS provider."""
|
||||
try:
|
||||
from app import config_manager
|
||||
ddns_cfg = config_manager.configs.get('ddns', {})
|
||||
if ddns_cfg.get('provider') != 'pic_ngo':
|
||||
return jsonify({'error': 'Re-registration only supported for pic_ngo provider'}), 400
|
||||
identity = config_manager.configs.get('_identity', {})
|
||||
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', ''))
|
||||
if not cell_name:
|
||||
return jsonify({'error': 'cell_name not configured'}), 400
|
||||
from ddns_manager import DDNSManager as _DDNSManager
|
||||
_mgr = _DDNSManager(config_manager)
|
||||
result = _mgr.register(cell_name, '')
|
||||
new_sub = result.get('subdomain', f'{cell_name}.pic.ngo')
|
||||
config_manager.set_identity_field('domain_name', new_sub)
|
||||
logger.info('DDNS registered via /api/ddns/register: cell_name=%r subdomain=%r', cell_name, new_sub)
|
||||
return jsonify({'registered': True, 'subdomain': new_sub})
|
||||
except Exception as e:
|
||||
logger.error('Error in /api/ddns/register: %s', 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