mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-10-23 03:37:36 +09:00
feat(settings): duckdns card — free domain + one-click auto-renewed cert
New settings card 'Your own duckdns.org domain' next to SSL settings: - 3-step mini guide, domain + token fields (token never echoed back, empty field keeps the stored one) - POST /api/settings/duckdns/apply: normalizes the domain, points it at this server's public IP (empty ip= -> duckdns uses the caller), then issues a Let's Encrypt cert via acme.sh DNS-01 (dns_duckdns plugin, port 80 not required) and installs it to /etc/amnezia/ - the card drives the SAME settings['ssl'] config as the manual card - acme.sh cron renews automatically; under systemd --reloadcmd restarts the panel, and the first apply restarts it too (2s timer after the response) so HTTPS comes up on its own - background monitor keeps the duckdns IP fresh every 600s - 5 locales, 11 new tests (484 total OK)
This commit is contained in:
1 parent
93981edc68
commit
133f27fdd3
8 files changed
+495
No files matched your search
@@ -2382,6 +2382,12 @@ class SSLSettings(BaseModel):
|
||||
key_text: str = ''
|
||||
panel_port: int = 5000
|
||||
|
||||
class DuckDNSApplyRequest(BaseModel):
|
||||
enabled: bool = False
|
||||
domain: str = ''
|
||||
# Empty keeps the stored token: the settings page never echoes it back.
|
||||
token: str = ''
|
||||
|
||||
class TelegramSettings(BaseModel):
|
||||
token: str = ''
|
||||
enabled: bool = False
|
||||
@@ -2566,6 +2572,7 @@ def _start_conn_monitor():
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
_start_conn_monitor()
|
||||
_start_duckdns_monitor()
|
||||
data = load_data()
|
||||
changed = False
|
||||
if not data.get('users'):
|
||||
@@ -6126,6 +6133,162 @@ def save_settings(request: Request, payload: SaveSettingsRequest):
|
||||
return {"status": "success", "bot_running": tg_bot.is_running(), "warnings": warnings}
|
||||
|
||||
|
||||
# ----- DuckDNS: free domain + one-click auto-renewed Let's Encrypt cert -----
|
||||
|
||||
DUCKDNS_CERT_DIR = '/etc/amnezia'
|
||||
DUCKDNS_UPDATE_INTERVAL = 600
|
||||
|
||||
|
||||
def _duckdns_normalize_domain(domain):
|
||||
"""Accept 'example' or 'example.duckdns.org', return the full domain."""
|
||||
d = (domain or '').strip().lower().rstrip('.')
|
||||
if d.endswith('.duckdns.org'):
|
||||
d = d[:-len('.duckdns.org')]
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,61}[a-z0-9]|[a-z0-9]', d or ''):
|
||||
return ''
|
||||
return f"{d}.duckdns.org"
|
||||
|
||||
|
||||
def _duckdns_update_ip(domain, token):
|
||||
"""Point the duckdns domain at this server's public IP.
|
||||
|
||||
An empty ip= makes duckdns use the caller's address, which is exactly
|
||||
the panel server. Returns (ok, detail)."""
|
||||
subdomain = domain[:-len('.duckdns.org')]
|
||||
url = ('https://www.duckdns.org/update?domains=%s&token=%s&ip='
|
||||
% (urllib.parse.quote(subdomain), urllib.parse.quote(token)))
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as resp:
|
||||
body = resp.read().decode('utf-8', 'replace').strip()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return (body.upper().startswith('OK'), body)
|
||||
|
||||
|
||||
def _duckdns_issue_cert(domain, token):
|
||||
"""Issue a Let's Encrypt cert via acme.sh DNS-01 (duckdns plugin) and
|
||||
install it where the panel ssl settings expect. acme.sh drops its own
|
||||
cron entry, so renewal (and the panel restart via --reloadcmd under
|
||||
systemd) is automatic from then on. Returns (ok, log_tail)."""
|
||||
if os.name == 'nt':
|
||||
return False, 'DuckDNS auto-cert is supported on Linux panel hosts only'
|
||||
acme = os.path.expanduser('~/.acme.sh/acme.sh')
|
||||
logs = []
|
||||
if not os.path.exists(acme):
|
||||
inst = subprocess.run(
|
||||
'curl -s https://get.acme.sh | sh -s email=admin@%s' % domain,
|
||||
shell=True, capture_output=True, text=True, timeout=300)
|
||||
logs.append(((inst.stdout or '') + (inst.stderr or ''))[-2000:])
|
||||
if not os.path.exists(acme):
|
||||
return False, 'acme.sh install failed:\n' + '\n'.join(logs)
|
||||
env = dict(os.environ, DuckDNS_Token=token)
|
||||
issue = subprocess.run(
|
||||
[acme, '--issue', '--dns', 'dns_duckdns', '-d', domain,
|
||||
'--server', 'letsencrypt'],
|
||||
capture_output=True, text=True, timeout=300, env=env)
|
||||
logs.append(((issue.stdout or '') + (issue.stderr or ''))[-4000:])
|
||||
# rc 2 = "Skip, next renewal time" (a valid cert already exists) — fine,
|
||||
# --install-cert below will still export it to the panel paths.
|
||||
if issue.returncode not in (0, 2):
|
||||
return False, 'certificate issue failed:\n' + '\n'.join(logs)
|
||||
os.makedirs(DUCKDNS_CERT_DIR, exist_ok=True)
|
||||
key_file = os.path.join(DUCKDNS_CERT_DIR, 'duckdns.key.pem')
|
||||
cert_file = os.path.join(DUCKDNS_CERT_DIR, 'duckdns.cert.pem')
|
||||
cmd = [acme, '--install-cert', '-d', domain,
|
||||
'--key-file', key_file, '--fullchain-file', cert_file]
|
||||
if os.environ.get('INVOCATION_ID'):
|
||||
# renewed cert -> restart the panel so uvicorn picks it up
|
||||
cmd += ['--reloadcmd', 'systemctl restart amnezia-panel']
|
||||
install = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env=env)
|
||||
logs.append(((install.stdout or '') + (install.stderr or ''))[-2000:])
|
||||
if install.returncode != 0 or not (os.path.exists(key_file) and os.path.exists(cert_file)):
|
||||
return False, 'certificate install failed:\n' + '\n'.join(logs)
|
||||
return True, '\n'.join(logs)
|
||||
|
||||
|
||||
@app.post('/api/settings/duckdns/apply', tags=["Settings"])
|
||||
def api_duckdns_apply(request: Request, payload: DuckDNSApplyRequest):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
data = load_data()
|
||||
settings = data.setdefault('settings', {})
|
||||
stored = settings.get('duckdns', {}) or {}
|
||||
domain = _duckdns_normalize_domain(payload.domain) or stored.get('domain', '')
|
||||
token = payload.token.strip() or stored.get('token', '')
|
||||
if payload.enabled and (not domain or not token):
|
||||
return JSONResponse({'error': 'duckdns_domain_token_required'}, status_code=400)
|
||||
settings['duckdns'] = {
|
||||
'enabled': bool(payload.enabled),
|
||||
'domain': domain,
|
||||
'token': token,
|
||||
}
|
||||
result = {'status': 'success', 'domain': domain}
|
||||
if payload.enabled:
|
||||
ok, detail = _duckdns_update_ip(domain, token)
|
||||
result['ip_update'] = {'ok': ok, 'detail': detail}
|
||||
if not ok:
|
||||
save_data(data)
|
||||
return JSONResponse(
|
||||
{'error': 'duckdns_ip_update_failed', 'detail': detail},
|
||||
status_code=502)
|
||||
ok, log = _duckdns_issue_cert(domain, token)
|
||||
if not ok:
|
||||
save_data(data)
|
||||
return JSONResponse(
|
||||
{'error': 'duckdns_cert_failed', 'detail': log[-3000:]},
|
||||
status_code=502)
|
||||
# The duckdns card drives the SAME ssl config as the manual card:
|
||||
# issued paths are written into settings['ssl'].
|
||||
ssl_conf = settings.setdefault('ssl', {})
|
||||
ssl_conf.update({
|
||||
'enabled': True,
|
||||
'domain': domain,
|
||||
'cert_path': os.path.join(DUCKDNS_CERT_DIR, 'duckdns.cert.pem'),
|
||||
'key_path': os.path.join(DUCKDNS_CERT_DIR, 'duckdns.key.pem'),
|
||||
'cert_text': '',
|
||||
'key_text': '',
|
||||
})
|
||||
if os.environ.get('INVOCATION_ID'):
|
||||
# Switch to HTTPS right away: restart ourselves after the
|
||||
# response had time to reach the browser.
|
||||
threading.Timer(2.0, lambda: subprocess.run(
|
||||
['systemctl', 'restart', 'amnezia-panel'],
|
||||
capture_output=True)).start()
|
||||
result['restarting'] = True
|
||||
else:
|
||||
result['restart_required'] = True
|
||||
save_data(data)
|
||||
logger.info(f"DuckDNS settings applied: enabled={payload.enabled} domain={domain}")
|
||||
return result
|
||||
|
||||
|
||||
_duckdns_monitor_started = False
|
||||
|
||||
|
||||
def _duckdns_monitor_loop():
|
||||
"""Keep the duckdns record pointed at this server's current public IP."""
|
||||
while True:
|
||||
try:
|
||||
d = load_data().get('settings', {}).get('duckdns', {}) or {}
|
||||
if d.get('enabled') and d.get('domain') and d.get('token'):
|
||||
ok, detail = _duckdns_update_ip(d['domain'], d['token'])
|
||||
if not ok:
|
||||
logger.warning(f"duckdns monitor: ip update failed: {detail}")
|
||||
except Exception as e:
|
||||
logger.warning(f"duckdns monitor loop: {e}")
|
||||
time.sleep(DUCKDNS_UPDATE_INTERVAL)
|
||||
|
||||
|
||||
def _start_duckdns_monitor():
|
||||
global _duckdns_monitor_started
|
||||
if _duckdns_monitor_started:
|
||||
return
|
||||
_duckdns_monitor_started = True
|
||||
threading.Thread(target=_duckdns_monitor_loop, daemon=True,
|
||||
name='duckdns-monitor').start()
|
||||
logger.info(f"DuckDNS monitor started (every {DUCKDNS_UPDATE_INTERVAL}s)")
|
||||
|
||||
|
||||
@app.post('/api/settings/telegram/toggle', tags=["Settings"])
|
||||
async def api_telegram_toggle(request: Request):
|
||||
"""Quick enable/disable of the bot without a full settings save."""
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<a href="#settings-mail">{{ _('mail_title') }}</a>
|
||||
<a href="#settings-tunnels">{{ _('tunnels_title') }}</a>
|
||||
<a href="#settings-ssl">{{ _('ssl_title') }}</a>
|
||||
<a href="#settings-duckdns">{{ _('duckdns_title') }}</a>
|
||||
<a href="#settings-import">{{ _('import_users_title') }}</a>
|
||||
<a href="#settings-backup">{{ _('backup_title') }}</a>
|
||||
<a href="#settings-tokens">{{ _('api_tokens_title') }}</a>
|
||||
@@ -331,6 +332,39 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK 2b: DuckDNS domain -->
|
||||
<div class="card" id="settings-duckdns">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>{{ _('duckdns_title') }}</h3>
|
||||
<ol style="margin: 0 0 var(--space-md) 1.2em; padding: 0; color: var(--text-muted); font-size: 13px; line-height: 1.7;">
|
||||
<li>{{ _('duckdns_step1') }}</li>
|
||||
<li>{{ _('duckdns_step2') }}</li>
|
||||
<li>{{ _('duckdns_step3') }}</li>
|
||||
</ol>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('duckdns_domain_label') }}</label>
|
||||
<input type="text" class="form-input" id="duckdns_domain" value="{{ settings.get('duckdns', {}).get('domain', '') }}" placeholder="example.duckdns.org">
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: var(--space-md);">
|
||||
<label class="form-label">{{ _('duckdns_token_label') }}</label>
|
||||
<input type="password" class="form-input" id="duckdns_token" value="" autocomplete="off" placeholder="{% if settings.get('duckdns', {}).get('token') %}{{ _('duckdns_token_saved') }}{% else %}xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx{% endif %}">
|
||||
<p class="form-hint">{{ _('duckdns_token_hint') }}</p>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: var(--space-md);">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" id="duckdns_enabled" {% if settings.get('duckdns', {}).get('enabled') %}checked{% endif %}>
|
||||
{{ _('duckdns_enable_label') }}
|
||||
</label>
|
||||
<p class="form-hint">{{ _('duckdns_enable_hint') }}</p>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md);">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="duckdnsApplyBtn" onclick="applyDuckDNS()">
|
||||
<span id="duckdnsApplyText">{{ _('duckdns_apply_btn') }}</span>
|
||||
<span id="duckdnsApplySpinner" class="spinner hidden"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p id="duckdnsStatus" class="form-hint" style="margin-top: var(--space-sm); word-break: break-all;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN -->
|
||||
@@ -1067,6 +1101,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDuckDNS() {
|
||||
const btn = document.getElementById('duckdnsApplyBtn');
|
||||
const spinner = document.getElementById('duckdnsApplySpinner');
|
||||
const status = document.getElementById('duckdnsStatus');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
status.textContent = _('duckdns_applying');
|
||||
try {
|
||||
const res = await fetch('/api/settings/duckdns/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: document.getElementById('duckdns_enabled').checked,
|
||||
domain: document.getElementById('duckdns_domain').value.trim(),
|
||||
token: document.getElementById('duckdns_token').value.trim()
|
||||
})
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
status.textContent = _(data.error || 'error') + (data.detail ? '\n' + data.detail : '');
|
||||
showToast(_(data.error || 'error'), 'error');
|
||||
return;
|
||||
}
|
||||
if (data.restarting) {
|
||||
status.textContent = _('duckdns_success_restarting') + ' https://' + data.domain + ':' + (document.getElementById('panel_port').value || 5000);
|
||||
} else if (data.restart_required) {
|
||||
status.textContent = _('duckdns_success_restart_required');
|
||||
} else {
|
||||
status.textContent = _('duckdns_saved');
|
||||
}
|
||||
showToast(_('settings_saved'), 'success');
|
||||
} catch (err) {
|
||||
status.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestMail() {
|
||||
const btn = document.getElementById('mailTestBtn');
|
||||
const spinner = document.getElementById('mailTestSpinner');
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""DuckDNS settings card: domain normalization, IP updates, cert apply flow.
|
||||
|
||||
Everything external is faked -- no duckdns.org, no acme.sh -- because the
|
||||
parts worth guarding are the decisions: token is never wiped by an empty
|
||||
field, the domain is normalized to a full duckdns name, a failed IP update
|
||||
or cert issue must not flip ssl settings, and a successful apply writes the
|
||||
same ssl config the manual card edits.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as panel
|
||||
|
||||
|
||||
class NormalizeDomainTests(unittest.TestCase):
|
||||
def test_bare_subdomain_gets_suffix(self):
|
||||
self.assertEqual(panel._duckdns_normalize_domain('myvpn'), 'myvpn.duckdns.org')
|
||||
|
||||
def test_full_domain_and_case_whitespace(self):
|
||||
self.assertEqual(panel._duckdns_normalize_domain(' MyVPN.duckdns.org. '),
|
||||
'myvpn.duckdns.org')
|
||||
|
||||
def test_rejects_garbage(self):
|
||||
for bad in ('', 'a_b', 'evil.com', 'x' * 64, '-lead', 'trail-', 'a..b'):
|
||||
self.assertEqual(panel._duckdns_normalize_domain(bad), '', bad)
|
||||
|
||||
|
||||
class UpdateIpTests(unittest.TestCase):
|
||||
class _Resp:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
def read(self):
|
||||
return self._body.encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def test_ok_response(self):
|
||||
with mock.patch.object(panel.urllib.request, 'urlopen',
|
||||
return_value=self._Resp('OK')) as m:
|
||||
ok, detail = panel._duckdns_update_ip('myvpn.duckdns.org', 'tok')
|
||||
self.assertTrue(ok)
|
||||
url = m.call_args[0][0]
|
||||
self.assertIn('domains=myvpn', url)
|
||||
self.assertIn('token=tok', url)
|
||||
# empty ip= -> duckdns uses the caller's (panel server's) address
|
||||
self.assertTrue(url.endswith('ip='))
|
||||
|
||||
def test_ko_and_network_error(self):
|
||||
with mock.patch.object(panel.urllib.request, 'urlopen',
|
||||
return_value=self._Resp('KO')):
|
||||
ok, _ = panel._duckdns_update_ip('myvpn.duckdns.org', 'bad')
|
||||
self.assertFalse(ok)
|
||||
with mock.patch.object(panel.urllib.request, 'urlopen',
|
||||
side_effect=OSError('no route')):
|
||||
ok, detail = panel._duckdns_update_ip('myvpn.duckdns.org', 'tok')
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('no route', detail)
|
||||
|
||||
|
||||
class ApplyApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self._data_file = panel.DATA_FILE
|
||||
panel.DATA_FILE = os.path.join(self.tmp, 'data.json')
|
||||
with open(panel.DATA_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump({'servers': [], 'users': [], 'user_connections': []}, f)
|
||||
self._flags = (panel._conn_monitor_started, panel._duckdns_monitor_started)
|
||||
panel._conn_monitor_started = True
|
||||
panel._duckdns_monitor_started = True
|
||||
|
||||
def tearDown(self):
|
||||
panel.DATA_FILE = self._data_file
|
||||
panel._conn_monitor_started, panel._duckdns_monitor_started = self._flags
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def login(self, client):
|
||||
r = client.post('/api/auth/login', json={'username': 'admin', 'password': 'admin'})
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
|
||||
def apply(self, client, **payload):
|
||||
return client.post('/api/settings/duckdns/apply', json=payload)
|
||||
|
||||
def test_disabled_save_stores_settings_without_network(self):
|
||||
with TestClient(panel.app) as client:
|
||||
self.login(client)
|
||||
r = self.apply(client, enabled=False, domain='myvpn', token='t1')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
stored = panel.load_data()['settings']['duckdns']
|
||||
self.assertEqual(stored, {'enabled': False,
|
||||
'domain': 'myvpn.duckdns.org', 'token': 't1'})
|
||||
# ssl untouched
|
||||
self.assertFalse(panel.load_data()['settings'].get('ssl', {}).get('enabled', False))
|
||||
|
||||
def test_enabled_requires_domain_and_token(self):
|
||||
with TestClient(panel.app) as client:
|
||||
self.login(client)
|
||||
r = self.apply(client, enabled=True, domain='', token='')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(r.json()['error'], 'duckdns_domain_token_required')
|
||||
|
||||
def test_empty_token_keeps_the_stored_one(self):
|
||||
with TestClient(panel.app) as client:
|
||||
self.login(client)
|
||||
self.apply(client, enabled=False, domain='myvpn', token='s3cret')
|
||||
with mock.patch.object(panel, '_duckdns_update_ip',
|
||||
return_value=(True, 'OK')) as upd, \
|
||||
mock.patch.object(panel, '_duckdns_issue_cert',
|
||||
return_value=(True, 'log')) as cert:
|
||||
r = self.apply(client, enabled=True, domain='myvpn', token='')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
# both helpers were called with the STORED token
|
||||
self.assertEqual(upd.call_args[0][1], 's3cret')
|
||||
self.assertEqual(cert.call_args[0][1], 's3cret')
|
||||
self.assertEqual(panel.load_data()['settings']['duckdns']['token'], 's3cret')
|
||||
|
||||
def test_successful_apply_writes_ssl_config(self):
|
||||
with TestClient(panel.app) as client:
|
||||
self.login(client)
|
||||
with mock.patch.object(panel, '_duckdns_update_ip', return_value=(True, 'OK')), \
|
||||
mock.patch.object(panel, '_duckdns_issue_cert', return_value=(True, 'ok')):
|
||||
r = self.apply(client, enabled=True, domain='myvpn', token='t1')
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json()
|
||||
# dev box is not systemd -> the UI must ask for a restart
|
||||
self.assertTrue(body.get('restart_required') or body.get('restarting'))
|
||||
ssl = panel.load_data()['settings']['ssl']
|
||||
self.assertTrue(ssl['enabled'])
|
||||
self.assertEqual(ssl['domain'], 'myvpn.duckdns.org')
|
||||
self.assertTrue(ssl['cert_path'].endswith('duckdns.cert.pem'))
|
||||
self.assertTrue(ssl['key_path'].endswith('duckdns.key.pem'))
|
||||
self.assertEqual(ssl['cert_text'], '')
|
||||
|
||||
def test_ip_update_failure_does_not_touch_ssl(self):
|
||||
with TestClient(panel.app) as client:
|
||||
self.login(client)
|
||||
with mock.patch.object(panel, '_duckdns_update_ip',
|
||||
return_value=(False, 'KO')):
|
||||
r = self.apply(client, enabled=True, domain='myvpn', token='bad')
|
||||
self.assertEqual(r.status_code, 502)
|
||||
self.assertEqual(r.json()['error'], 'duckdns_ip_update_failed')
|
||||
self.assertFalse(panel.load_data()['settings'].get('ssl', {}).get('enabled', False))
|
||||
|
||||
def test_cert_failure_does_not_touch_ssl(self):
|
||||
with TestClient(panel.app) as client:
|
||||
self.login(client)
|
||||
with mock.patch.object(panel, '_duckdns_update_ip', return_value=(True, 'OK')), \
|
||||
mock.patch.object(panel, '_duckdns_issue_cert',
|
||||
return_value=(False, 'boom')):
|
||||
r = self.apply(client, enabled=True, domain='myvpn', token='t1')
|
||||
self.assertEqual(r.status_code, 502)
|
||||
self.assertEqual(r.json()['error'], 'duckdns_cert_failed')
|
||||
self.assertFalse(panel.load_data()['settings'].get('ssl', {}).get('enabled', False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -391,6 +391,24 @@
|
||||
"clear_server": "Clear server",
|
||||
"remove_server": "Remove server from panel",
|
||||
"ssl_title": "SSL / HTTPS Settings",
|
||||
"duckdns_title": "Your own duckdns.org domain",
|
||||
"duckdns_step1": "Register and sign in at duckdns.org",
|
||||
"duckdns_step2": "Create a subdomain, e.g. example.duckdns.org",
|
||||
"duckdns_step3": "Click the button below — the panel will point it to this server's IP and keep it up to date",
|
||||
"duckdns_domain_label": "Domain",
|
||||
"duckdns_token_label": "DuckDNS token",
|
||||
"duckdns_token_saved": "•••••••• (token saved — enter only to change it)",
|
||||
"duckdns_token_hint": "The token is shown at the top of duckdns.org after sign-in (www.duckdns.org/account)",
|
||||
"duckdns_enable_label": "SSL — free auto-renewed certificate in one click",
|
||||
"duckdns_enable_hint": "Let's Encrypt via DNS-01: port 80 not required. Renewal is automatic (acme.sh cron), the panel restarts itself.",
|
||||
"duckdns_apply_btn": "Save and issue certificate",
|
||||
"duckdns_applying": "Applying: updating IP and issuing the certificate, this can take up to a minute…",
|
||||
"duckdns_saved": "Saved.",
|
||||
"duckdns_success_restarting": "Done! The panel is restarting and will be available in a few seconds at ",
|
||||
"duckdns_success_restart_required": "Certificate issued. Restart the panel (systemctl restart amnezia-panel) to enable HTTPS.",
|
||||
"duckdns_domain_token_required": "DuckDNS domain and token are required",
|
||||
"duckdns_ip_update_failed": "Failed to update the duckdns IP",
|
||||
"duckdns_cert_failed": "Failed to issue the certificate",
|
||||
"enable_https": "Enable HTTPS",
|
||||
"panel_port_label": "Panel Port",
|
||||
"domain_label": "Domain name",
|
||||
|
||||
@@ -603,6 +603,24 @@
|
||||
"traffic_reset_info": "بازنشانی بعدی: {}",
|
||||
"services": "سرویسها",
|
||||
"ssl_title": "تنظیمات SSL / HTTPS",
|
||||
"duckdns_title": "دامنه شخصی duckdns.org",
|
||||
"duckdns_step1": "در duckdns.org ثبتنام کرده و وارد شوید",
|
||||
"duckdns_step2": "یک زیردامنه بسازید، مثلاً example.duckdns.org",
|
||||
"duckdns_step3": "دکمه زیر را بزنید — پنل دامنه را به IP همین سرور اشاره میدهد و بهروز نگه میدارد",
|
||||
"duckdns_domain_label": "دامنه",
|
||||
"duckdns_token_label": "توکن duckdns",
|
||||
"duckdns_token_saved": "•••••••• (توکن ذخیره شده — فقط برای تغییر وارد کنید)",
|
||||
"duckdns_token_hint": "توکن پس از ورود در بالای صفحه duckdns.org نمایش داده میشود (www.duckdns.org/account)",
|
||||
"duckdns_enable_label": "SSL — گواهی رایگان با تمدید خودکار در یک کلیک",
|
||||
"duckdns_enable_hint": "Let's Encrypt از طریق DNS-01: پورت 80 لازم نیست. تمدید خودکار (cron مربوط به acme.sh)، پنل خودش ریاستارت میشود.",
|
||||
"duckdns_apply_btn": "ذخیره و صدور گواهی",
|
||||
"duckdns_applying": "در حال اعمال: بهروزرسانی IP و صدور گواهی، ممکن است تا یک دقیقه طول بکشد…",
|
||||
"duckdns_saved": "ذخیره شد.",
|
||||
"duckdns_success_restarting": "انجام شد! پنل در حال ریاستارت است و چند ثانیه دیگر در این آدرس در دسترس خواهد بود ",
|
||||
"duckdns_success_restart_required": "گواهی صادر شد. برای فعالسازی HTTPS پنل را ریاستارت کنید (systemctl restart amnezia-panel).",
|
||||
"duckdns_domain_token_required": "دامنه و توکن duckdns الزامی است",
|
||||
"duckdns_ip_update_failed": "بهروزرسانی IP در duckdns ناموفق بود",
|
||||
"duckdns_cert_failed": "صدور گواهی ناموفق بود",
|
||||
"enable_https": "فعالسازی HTTPS",
|
||||
"panel_port_label": "پورت پنل",
|
||||
"domain_label": "نام دامنه",
|
||||
|
||||
@@ -603,6 +603,24 @@
|
||||
"traffic_reset_info": "Prochaine remise à zéro : {}",
|
||||
"services": "Services",
|
||||
"ssl_title": "Paramètres SSL / HTTPS",
|
||||
"duckdns_title": "Votre propre domaine duckdns.org",
|
||||
"duckdns_step1": "Inscrivez-vous et connectez-vous sur duckdns.org",
|
||||
"duckdns_step2": "Créez un sous-domaine, par ex. example.duckdns.org",
|
||||
"duckdns_step3": "Cliquez sur le bouton ci-dessous — le panneau pointera le domaine vers l'IP de ce serveur et la maintiendra à jour",
|
||||
"duckdns_domain_label": "Domaine",
|
||||
"duckdns_token_label": "Jeton duckdns",
|
||||
"duckdns_token_saved": "•••••••• (jeton enregistré — saisissez-le uniquement pour le changer)",
|
||||
"duckdns_token_hint": "Le jeton est affiché en haut de duckdns.org après connexion (www.duckdns.org/account)",
|
||||
"duckdns_enable_label": "SSL — certificat gratuit auto-renouvelé en un clic",
|
||||
"duckdns_enable_hint": "Let's Encrypt via DNS-01 : le port 80 n'est pas requis. Renouvellement automatique (cron acme.sh), le panneau redémarre tout seul.",
|
||||
"duckdns_apply_btn": "Enregistrer et émettre le certificat",
|
||||
"duckdns_applying": "Application : mise à jour de l'IP et émission du certificat, cela peut prendre une minute…",
|
||||
"duckdns_saved": "Enregistré.",
|
||||
"duckdns_success_restarting": "Terminé ! Le panneau redémarre et sera disponible dans quelques secondes sur ",
|
||||
"duckdns_success_restart_required": "Certificat émis. Redémarrez le panneau (systemctl restart amnezia-panel) pour activer HTTPS.",
|
||||
"duckdns_domain_token_required": "Le domaine et le jeton duckdns sont requis",
|
||||
"duckdns_ip_update_failed": "Échec de la mise à jour de l'IP duckdns",
|
||||
"duckdns_cert_failed": "Échec de l'émission du certificat",
|
||||
"enable_https": "Activer HTTPS",
|
||||
"panel_port_label": "Port du panneau",
|
||||
"domain_label": "Nom de domaine",
|
||||
|
||||
@@ -391,6 +391,24 @@
|
||||
"clear_server": "Очистить сервер",
|
||||
"remove_server": "Удалить сервер из панели",
|
||||
"ssl_title": "SSL / HTTPS Настройки",
|
||||
"duckdns_title": "Свой домен на duckdns.org",
|
||||
"duckdns_step1": "Зарегистрируйтесь и войдите на duckdns.org",
|
||||
"duckdns_step2": "Придумайте поддомен, например example.duckdns.org",
|
||||
"duckdns_step3": "Нажмите кнопку ниже — панель сама укажет IP этого сервера и будет держать его актуальным",
|
||||
"duckdns_domain_label": "Домен",
|
||||
"duckdns_token_label": "Токен duckdns",
|
||||
"duckdns_token_saved": "•••••••• (токен сохранён — введите, только если меняете)",
|
||||
"duckdns_token_hint": "Токен показан вверху страницы duckdns.org после входа (www.duckdns.org/account)",
|
||||
"duckdns_enable_label": "SSL — бесплатный автопродлеваемый сертификат в 1 клик",
|
||||
"duckdns_enable_hint": "Let's Encrypt через DNS-01: порт 80 не нужен. Продление автоматическое (acme.sh cron), панель перезапустится сама.",
|
||||
"duckdns_apply_btn": "Сохранить и выпустить сертификат",
|
||||
"duckdns_applying": "Применяем: обновление IP и выпуск сертификата, это может занять до минуты…",
|
||||
"duckdns_saved": "Сохранено.",
|
||||
"duckdns_success_restarting": "Готово! Панель перезапускается и через несколько секунд будет доступна по ",
|
||||
"duckdns_success_restart_required": "Сертификат выпущен. Перезапустите панель (systemctl restart amnezia-panel), чтобы включить HTTPS.",
|
||||
"duckdns_domain_token_required": "Укажите домен и токен duckdns",
|
||||
"duckdns_ip_update_failed": "Не удалось обновить IP в duckdns",
|
||||
"duckdns_cert_failed": "Не удалось выпустить сертификат",
|
||||
"enable_https": "Включить HTTPS",
|
||||
"panel_port_label": "Порт панели",
|
||||
"domain_label": "Доменное имя",
|
||||
|
||||
@@ -603,6 +603,24 @@
|
||||
"traffic_reset_info": "下次重置:{}",
|
||||
"services": "服务",
|
||||
"ssl_title": "SSL / HTTPS 设置",
|
||||
"duckdns_title": "您自己的 duckdns.org 域名",
|
||||
"duckdns_step1": "在 duckdns.org 注册并登录",
|
||||
"duckdns_step2": "创建一个子域名,例如 example.duckdns.org",
|
||||
"duckdns_step3": "点击下方按钮 — 面板会将域名指向本服务器的 IP 并自动保持更新",
|
||||
"duckdns_domain_label": "域名",
|
||||
"duckdns_token_label": "DuckDNS 令牌",
|
||||
"duckdns_token_saved": "•••••••• (令牌已保存 — 仅在需要更换时输入)",
|
||||
"duckdns_token_hint": "登录 duckdns.org 后令牌显示在页面顶部 (www.duckdns.org/account)",
|
||||
"duckdns_enable_label": "SSL — 一键申请免费自动续期证书",
|
||||
"duckdns_enable_hint": "通过 DNS-01 申请 Let's Encrypt:无需 80 端口。自动续期 (acme.sh cron),面板会自动重启。",
|
||||
"duckdns_apply_btn": "保存并签发证书",
|
||||
"duckdns_applying": "正在应用:更新 IP 并签发证书,可能需要一分钟…",
|
||||
"duckdns_saved": "已保存。",
|
||||
"duckdns_success_restarting": "完成!面板正在重启,几秒后可通过以下地址访问 ",
|
||||
"duckdns_success_restart_required": "证书已签发。请重启面板 (systemctl restart amnezia-panel) 以启用 HTTPS。",
|
||||
"duckdns_domain_token_required": "需要填写 duckdns 域名和令牌",
|
||||
"duckdns_ip_update_failed": "更新 duckdns IP 失败",
|
||||
"duckdns_cert_failed": "证书签发失败",
|
||||
"enable_https": "启用 HTTPS",
|
||||
"panel_port_label": "面板端口",
|
||||
"domain_label": "域名",
|
||||
|
||||
Reference in new issue
Block a user