From 0b31d02f10214654954beeaafb7e352cd18c7774 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Tue, 26 May 2026 15:05:27 -0400 Subject: [PATCH] feat: DDNS self-healing heartbeat + manual re-register endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/ddns_manager.py | 20 ++++++++++++++++++++ api/routes/config.py | 24 ++++++++++++++++++++++++ tests/test_ddns_endpoints.py | 33 +++++++++++++++++++++++++++++++++ webui/src/pages/Settings.jsx | 31 ++++++++++++++++++++++++++++--- webui/src/services/api.js | 1 + 5 files changed, 106 insertions(+), 3 deletions(-) diff --git a/api/ddns_manager.py b/api/ddns_manager.py index 165042a..f47aba2 100644 --- a/api/ddns_manager.py +++ b/api/ddns_manager.py @@ -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) diff --git a/api/routes/config.py b/api/routes/config.py index 3e773ca..ab6e6b8 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -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 diff --git a/tests/test_ddns_endpoints.py b/tests/test_ddns_endpoints.py index 88f7a7e..50ae230 100644 --- a/tests/test_ddns_endpoints.py +++ b/tests/test_ddns_endpoints.py @@ -136,5 +136,38 @@ class TestUpdateDdnsConfig(unittest.TestCase): mock_id.assert_any_call('domain_name', 'home.example.com') +class TestDdnsRegister(unittest.TestCase): + + def setUp(self): + self.client = _make_client() + + def test_non_pic_ngo_provider_returns_400(self): + from app import config_manager + with patch.object(config_manager, 'configs', {'ddns': {'provider': 'cloudflare'}, '_identity': {}}): + r = self.client.post('/api/ddns/register') + self.assertEqual(r.status_code, 400) + + def test_missing_cell_name_returns_400(self): + from app import config_manager + with patch.object(config_manager, 'configs', {'ddns': {'provider': 'pic_ngo'}, '_identity': {}}): + r = self.client.post('/api/ddns/register') + self.assertEqual(r.status_code, 400) + self.assertIn('cell_name', json.loads(r.data)['error']) + + def test_register_success(self): + from app import config_manager + from ddns_manager import DDNSManager + with patch.object(config_manager, 'configs', { + 'ddns': {'provider': 'pic_ngo'}, + '_identity': {'cell_name': 'mypic'} + }): + with patch.object(DDNSManager, 'register', return_value={'subdomain': 'mypic.pic.ngo', 'token': 'tok'}) as mock_reg, \ + patch.object(config_manager, 'set_identity_field') as mock_id: + r = self.client.post('/api/ddns/register') + self.assertEqual(r.status_code, 200) + body = json.loads(r.data) + self.assertTrue(body['registered']) + self.assertEqual(body['subdomain'], 'mypic.pic.ngo') + if __name__ == '__main__': unittest.main() diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index df54447..115e62f 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -444,6 +444,7 @@ function Settings() { const [ddnsDuckStatus, setDdnsDuckStatus] = useState(null); const [ddnsDirty, setDdnsDirty] = useState(false); const [ddnsSaving, setDdnsSaving] = useState(false); + const [ddnsRegistering, setDdnsRegistering] = useState(false); // service configs const [serviceConfigs, setServiceConfigs] = useState({}); @@ -595,6 +596,21 @@ function Settings() { } }, [ddnsCfToken, domainName]); + const reRegister = useCallback(async () => { + setDdnsRegistering(true); + try { + const res = await ddnsAPI.register(); + setDomainName(res.data.subdomain || ''); + setDdnsHasToken(true); + setPicAvail(null); + toast(`Registered as ${res.data.subdomain}`); + } catch (err) { + toast(err.response?.data?.error || 'Registration failed', 'error'); + } finally { + setDdnsRegistering(false); + } + }, []); + const verifyDuck = useCallback(async () => { if (!ddnsDuckToken.trim()) return; setDdnsDuckStatus('checking'); @@ -865,9 +881,18 @@ function Settings() {
{domainMode === 'pic_ngo' && ( -
- Your cell is registered as {domainName || `${identity.cell_name}.pic.ngo`} on pic.ngo. - Change the Cell Name above to update this subdomain. +
+
+ Your cell is registered as {domainName || `${identity.cell_name}.pic.ngo`} on pic.ngo. + Change the Cell Name above to update this subdomain. +
+
)} {domainMode === 'cloudflare' && ( diff --git a/webui/src/services/api.js b/webui/src/services/api.js index c6e9e6c..f8063e9 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -325,6 +325,7 @@ export const logsAPI = { export const ddnsAPI = { checkName: (name) => api.get(`/api/ddns/check/${name}`), updateConfig: (data) => api.put('/api/ddns', data), + register: () => api.post('/api/ddns/register'), }; // Setup Wizard API