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:
leonidorlov-hash committed 2026-10-03 15:08:03 +03:00
1 parent 93981edc68
commit 133f27fdd3
8 files changed
+495

No files matched your search

+163
View File
@@ -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."""
+74
View File
@@ -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');
+168
View File
@@ -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()
+18
View File
@@ -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",
+18
View File
@@ -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": "نام دامنه",
+18
View File
@@ -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",
+18
View File
@@ -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": "Доменное имя",
+18
View File
@@ -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": "域名",