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."""
|
"""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
|
# Provider base class
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -96,6 +100,10 @@ class PicNgoDDNS(DDNSProvider):
|
|||||||
|
|
||||||
def _raise_for_status(self, response: requests.Response, action: str):
|
def _raise_for_status(self, response: requests.Response, action: str):
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise DDNSTokenExpired(
|
||||||
|
f"PicNgoDDNS {action} rejected token: HTTP 401 — {response.text}"
|
||||||
|
)
|
||||||
raise DDNSError(
|
raise DDNSError(
|
||||||
f"PicNgoDDNS {action} failed: HTTP {response.status_code} — {response.text}"
|
f"PicNgoDDNS {action} failed: HTTP {response.status_code} — {response.text}"
|
||||||
)
|
)
|
||||||
@@ -432,6 +440,18 @@ class DDNSManager(BaseServiceManager):
|
|||||||
self._last_ip = current_ip
|
self._last_ip = current_ip
|
||||||
else:
|
else:
|
||||||
logger.warning("DDNS update_ip: provider.update() returned False")
|
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:
|
except DDNSError as exc:
|
||||||
logger.error("DDNS update_ip: provider error: %s", exc)
|
logger.error("DDNS update_ip: provider error: %s", exc)
|
||||||
|
|
||||||
|
|||||||
@@ -561,6 +561,30 @@ def update_ddns_config():
|
|||||||
return jsonify({'error': str(e)}), 500
|
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'])
|
@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
|
||||||
|
|||||||
@@ -136,5 +136,38 @@ class TestUpdateDdnsConfig(unittest.TestCase):
|
|||||||
mock_id.assert_any_call('domain_name', 'home.example.com')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -444,6 +444,7 @@ function Settings() {
|
|||||||
const [ddnsDuckStatus, setDdnsDuckStatus] = useState(null);
|
const [ddnsDuckStatus, setDdnsDuckStatus] = useState(null);
|
||||||
const [ddnsDirty, setDdnsDirty] = useState(false);
|
const [ddnsDirty, setDdnsDirty] = useState(false);
|
||||||
const [ddnsSaving, setDdnsSaving] = useState(false);
|
const [ddnsSaving, setDdnsSaving] = useState(false);
|
||||||
|
const [ddnsRegistering, setDdnsRegistering] = useState(false);
|
||||||
|
|
||||||
// service configs
|
// service configs
|
||||||
const [serviceConfigs, setServiceConfigs] = useState({});
|
const [serviceConfigs, setServiceConfigs] = useState({});
|
||||||
@@ -595,6 +596,21 @@ function Settings() {
|
|||||||
}
|
}
|
||||||
}, [ddnsCfToken, domainName]);
|
}, [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 () => {
|
const verifyDuck = useCallback(async () => {
|
||||||
if (!ddnsDuckToken.trim()) return;
|
if (!ddnsDuckToken.trim()) return;
|
||||||
setDdnsDuckStatus('checking');
|
setDdnsDuckStatus('checking');
|
||||||
@@ -865,9 +881,18 @@ function Settings() {
|
|||||||
<Section icon={Globe} title="External Domain & DDNS" collapsible defaultOpen>
|
<Section icon={Globe} title="External Domain & DDNS" collapsible defaultOpen>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{domainMode === 'pic_ngo' && (
|
{domainMode === 'pic_ngo' && (
|
||||||
<div className="rounded-lg bg-blue-50 border border-blue-100 px-4 py-3 text-sm text-blue-700">
|
<div className="space-y-2">
|
||||||
Your cell is registered as <span className="font-mono font-semibold">{domainName || `${identity.cell_name}.pic.ngo`}</span> on pic.ngo.
|
<div className="rounded-lg bg-blue-50 border border-blue-100 px-4 py-3 text-sm text-blue-700">
|
||||||
Change the Cell Name above to update this subdomain.
|
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>
|
||||||
|
<button
|
||||||
|
onClick={reRegister}
|
||||||
|
disabled={ddnsRegistering}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded border border-blue-300 text-blue-700 hover:bg-blue-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{ddnsRegistering ? 'Registering…' : 'Re-register with pic.ngo'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{domainMode === 'cloudflare' && (
|
{domainMode === 'cloudflare' && (
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ export const logsAPI = {
|
|||||||
export const ddnsAPI = {
|
export const ddnsAPI = {
|
||||||
checkName: (name) => api.get(`/api/ddns/check/${name}`),
|
checkName: (name) => api.get(`/api/ddns/check/${name}`),
|
||||||
updateConfig: (data) => api.put('/api/ddns', data),
|
updateConfig: (data) => api.put('/api/ddns', data),
|
||||||
|
register: () => api.post('/api/ddns/register'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup Wizard API
|
// Setup Wizard API
|
||||||
|
|||||||
Reference in New Issue
Block a user