Security, Fixes and localization

Security:
- reject existing sessions for disabled and record-only accounts
- enforce protocol and instance ownership when downloading configurations
- store CAPTCHA answers server-side with expiry and replay protection
- revoke share authorization after password changes or sharing is disabled
- restrict language-switch redirects and harden language cookies
- limit JSON backup size and improve input validation
- quote Telemt command arguments and SSH upload paths
- use private temporary directories for sudo operations and backup downloads
- selectively update dependencies to address confirmed vulnerabilities

Fixes and localization:
- fix service worker and static asset version paths in PyInstaller builds
- complete French, Persian, and Chinese translations to match all 687 keys
- translate remaining English strings in those locales
- add security, bundled-resource, and translation completeness regression tests
- add English and Russian security reports documenting fixes and remaining risks
This commit is contained in:
Максимов Владимир Сергеевич committed 2026-09-28 17:53:25 +03:00
1 parent f90cc5c8bb
commit 553f41926d
20 files changed
+2155 -698

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
# Security review and remediation report
## Scope and release status
Repository: PRVTPRO/Amnezia-Web-Panel. Baseline: `f90cc5c` (1.7.0).
Its embedded data contains **18 groups / 30 occurrences**, all reviewed. Report line numbers refer to the scanned revision, not current source. Review includes additional local authentication/security findings.
**Status: fixes in the local working tree, not committed, published, deployed or rescanned by ScanGit.** “Fixed” below means locally implemented and regression-tested, not closed by the scanner. This is a bounded source review, not a comprehensive penetration test or a guarantee that the application has no vulnerabilities.
## ScanGit findings disposition
| Group (zero-based) | Finding / occurrences | Assessment and action |
|---|---|---|
| 0 | Insecure HTTP / 1 | Fixed loopback ngrok API (`127.0.0.1:4040`), not external cleartext transport. No remote transport vulnerability established. Unchanged. |
| 1 | Unrestricted file upload / 1 | Administrator-only JSON restore, not an arbitrary executable upload. Hardened with a 32 MiB bounded read, object validation and malformed-encoding handling. Multipart parsing happens earlier: proxy/body limits remain required. |
| 2 | Cookie missing HttpOnly / 1 | Language preference, not an authentication secret. Defense-in-depth fix: HttpOnly enabled. |
| 3 | Cookie missing Secure / 1 | Language preference. Secure enabled on HTTPS; HTTP localhost operation deliberately preserved. This does not enforce HTTPS globally or change the session-cookie policy. |
| 4 | Telemt curl SSRF / 1 | Arbitrary destination SSRF not established: destination is fixed loopback. A real shell-argument injection boundary was found and fixed: quote each argument, disable URL globbing and use `--` before URL. |
| 5 | Critical command injection / 1 | Reported `taskkill` invocation uses an argument list without a shell. The indicated occurrence does not establish shell injection. Not relabeled as a repaired critical vulnerability. |
| 6 | Path traversal / 5 | Flagged reads use local translations, fixed application paths, allowlisted tunnel providers and fixed archive output filenames. Archive member names are not used as extraction destinations in this tunnel path. No request-controlled traversal established at these occurrences. |
| 7 | Template XSS / 7 | Generic template/redirect/response sinks are not proof of XSS. Jinja autoescaping and fixed redirects cover the reported ordinary cases. The language Referer redirect was a real open redirect, now restricted to a same-origin local path. No claim that all client-side/template XSS has been exhaustively excluded. |
| 8 | AST path traversal / 2 | `/proc` enumeration of process entries, not request-controlled paths. No web traversal established. |
| 9 | Predictable temporary paths / 2 | Fixed SSH privileged staging: atomic private `mktemp -d` directories, quoted paths and finally cleanup. Also fixed destination injection in privileged uploads and checked move failure. |
| 10 | click 8.3.1 / 1 | CVE-2026-7246 is disputed in its CNA/project record; application use of `click.edit` was not found. Pin unchanged; not counted as confirmed application injection. |
| 11 | cryptography 44.0.0 / 1 | Updated to 44.0.1 for the reported bundled-OpenSSL issue CVE-2024-12797. **Additional newer cryptography advisories remain open**; this is not a clean dependency bill of health. |
| 12 | Flask 3.1.0 / 1 | Updated to 3.1.3. Reported CVE-2025-47278 fixed from 3.1.1. Flask/fallback-key feature use not found in this FastAPI application. |
| 13 | idna 3.11 / 1 | Updated to 3.15, covering CVE-2026-45409, including alternate label/codec paths. |
| 14 | Paramiko 3.5.1 / 1 | **Open**: CVE-2026-44405, legacy RSA SHA-1 negotiation. Fix commit is included in 5.0.0; major-version upgrade or algorithm restrictions require testing against managed servers. No silent compatibility-breaking change. |
| 15 | Pillow 12.1.1 / 1 | Updated to 12.3.0, covering reported CVE-2026-42311 and additional identified Pillow fixes. Untrusted PSD decoding was not found in current application paths. |
| 16 | python-multipart 0.0.20 / 1 | Updated to 0.0.32, covering reported CVE-2026-53539 and later identified parser fixes. |
| 17 | Starlette 0.46.2 / 1 | Updated to 0.49.1 with compatible FastAPI 0.120.1; fixes reported CVE-2025-62727 Range-header DoS and multipart blocking fix. **Newer Starlette advisories remain open**, see below. |
AnyIO additionally updated from 4.12.1 to 4.14.2 for identified newer advisories. Unrelated pins were not mass-upgraded.
## Additional implemented fixes
- **Disabled/record-only accounts:** existing cookie sessions no longer authorize disabled users or `role=none`. Legacy missing-enabled defaults remain supported.
- **Configuration ownership:** ordinary-user access now matches server, client ID, protocol family and instance. Legitimate AWG aliases remain supported. Only explicit administrator/support roles bypass ownership. The original cross-instance disclosure required a coincident client ID.
- **CAPTCHA disclosure/replay:** answers are no longer stored in client-readable signed cookies. Cookies carry opaque random IDs; answers stay in a locked, bounded, server-side store. Five-minute TTL, single-use consumption, refresh invalidation and maximum 4,096 entries. Failed attempts consume the challenge.
- **Public-share password changes:** revision-bound authorization invalidates old share sessions after password changes/clearing or disabling sharing. Stable share URLs and passwordless sharing are preserved. Legacy Boolean authorization cookies require reauthentication.
- **Language redirect/input:** rejects unsupported languages and off-site Referer redirects while preserving valid local path/query navigation.
- **Backup validation:** malformed JSON encodings and non-object roots return client errors, with no data writes.
The earlier `/sw.js` bundled-resource fix remains in the working tree and was regression-tested; it is not counted as a security closure.
## Independent review follow-up
Independent review identified another retained high-risk issue in remote backup downloads: predictable `/tmp/<filename>` staging exposed archive secrets and allowed a conditional symlink attack. Fixed by allocating a private mode-0700 directory owned by the SSH/SFTP user before the privileged copy. Remote cleanup now runs from the outer finally block, including local-temp and SFTP-open failures, and cleanup failures are logged. Two new regression tests failed before this change and pass afterward. The review's sudo-test fixture blocker was also resolved as described below. The final follow-up itself was locally tested, not subjected to a second independent review.
## Verification
- Focused combined run: **59 tests passed**, zero failures/errors (auth, CAPTCHA, managers, share, web hardening, scanner trust-boundary tests, PWA resource paths and sudo-password regression).
- Full suite: **459 tests**, `failures=1, errors=9, skipped=1`. These final failure names match existing baseline server-template failures; baseline `HEAD` run had 401 tests with the same 1 failure / 9 errors / 1 skip. A separate earlier AWG randomized-default failure was also observed, not attributed to this patch.
- `uv pip check --python .venv/Scripts/python.exe`: all 42 installed packages compatible.
- `git diff --check` passes after normalizing requirements line endings.
- Existing password-leak test fixture was adapted to the new `mktemp` command and now checks every executed command for password disclosure, not merely the last command.
- Validation is local/in-process and mocked SSH, not a live managed-server test. No frozen EXE or Linux/macOS build was exercised. No external attack traffic was sent to deployed panels.
## Open risks and deployment notes
1. **Do not declare all dependencies clean.** Newer cryptography fixes require a larger migration (audit candidate 50.0.0); newer Starlette fixes include Windows UNC static-file handling, method dispatch and URL-encoded form limits (audit candidate Starlette 1.3.1 with compatible FastAPI). These were deliberately not silently installed under the minimal-change scope. Windows EXE deployments should treat the static-file issue as a priority follow-up, not a false positive.
2. Paramiko legacy algorithms and `AutoAddPolicy` host-key trust need compatibility-tested hardening. No live SSH compatibility certification is claimed.
3. Remote backup extraction symlink/hardlink containment needs a separate design review; a post-extraction path check is not a sufficient general guarantee. Administrator-only access limits but does not eliminate the risk of importing an untrusted archive.
4. CAPTCHA storage assumes **one application worker/process**. Multiple workers require shared TTL storage with atomic consumption (or equivalent design); restarts invalidate outstanding challenges. Rate limiting remains separate and flooding can evict valid challenges.
5. Configure HTTPS and reverse-proxy request limits. The JSON restore limit bounds application reads, not all multipart ingestion/disk usage before endpoint authorization.
6. Password changes for ordinary panel accounts are not given general session-version revocation by this patch. Disabled-user enforcement is immediate while the account is disabled; global session revocation remains separate work.
7. Existing `server.html` rendering/JavaScript test failures remain unresolved and prevent a claim that the complete suite passes.
8. Before release: review local diff, test SSH against representative root/sudo hosts, test login/sharing/CAPTCHA and restore with a disposable backup, rebuild Windows/Linux/macOS artifacts, then rerun ScanGit. Preserve a rollback backup. Nothing was pushed automatically.
## Advisory references
- https://github.com/encode/starlette/security/advisories/GHSA-7f5h-v6xp-fcq8
- https://github.com/pyca/cryptography/security/advisories/GHSA-79v4-65xg-pq4g
- https://github.com/kjd/idna/security/advisories/GHSA-65pc-fj4g-8rjx
- https://github.com/advisories/GHSA-4grg-w6v8-c28g
- https://github.com/advisories/GHSA-r374-rxx8-8654
- https://github.com/advisories/GHSA-pwv6-vv43-88gr
- https://github.com/advisories/GHSA-5rvq-cxj2-64vf
Dependency applicability was compared to advisory version ranges; an affected installed version is not by itself proof of a remotely exploitable application path. Some supplementary upstream API queries were rate-limited; this is not an exhaustive current/transitive CVE audit.
+80
View File
@@ -0,0 +1,80 @@
# Отчёт о проверке и устранении уязвимостей
## Область проверки и статус
Проект: PRVTPRO/Amnezia-Web-Panel. Исходная версия: `f90cc5c`, 1.7.0.
Отчёт: https://www.scangit.org/report/76397 . Из встроенных данных извлечены и рассмотрены **18 групп, 30 срабатываний**. Номера строк сканера относятся к сканировавшейся версии, а не к текущему файлу. Дополнительно проверены авторизация, CAPTCHA, защищённые ссылки и исполнение SSH-команд.
**Исправления находятся только в локальной рабочей копии. Коммит, публикация, развёртывание и повторное сканирование ScanGit не выполнялись.** «Исправлено» означает изменение кода и локальную регрессионную проверку, а не закрытие записи на сайте сканера. Это ограниченный аудит исходного кода, не полный пентест и не гарантия отсутствия других уязвимостей.
## Разбор всех групп ScanGit
| Группа, с нуля | Находка / количество | Заключение и действие |
|---|---|---|
| 0 | Insecure HTTP / 1 | Фиксированный локальный API ngrok `127.0.0.1:4040`, а не передача через внешний HTTP. Заявленная удалённая транспортная уязвимость не подтверждена. |
| 1 | Unrestricted file upload / 1 | Импорт JSON доступен администратору; загрузка исполняемого файла не подтверждена. Усилена защита: чтение максимум 32 MiB, проверка корневого объекта и кодировки. Ограничение тела на прокси всё равно необходимо: multipart разбирается раньше обработчика. |
| 2 | Cookie без HttpOnly / 1 | Cookie языка не содержит секрета авторизации. Добавлен HttpOnly как дополнительная защита. |
| 3 | Cookie без Secure / 1 | Secure включён при HTTPS; работа через HTTP на localhost сохранена. Это не принудительный HTTPS и не изменение политики session-cookie. |
| 4 | Telemt curl SSRF / 1 | Произвольное назначение запроса не подтверждено: используется loopback. Однако выявлена и исправлена настоящая инъекция shell-аргументов: каждый аргумент экранируется, URL передаётся после `--`, globbing curl выключен. |
| 5 | Critical command injection / 1 | Указанный `taskkill` запускается списком аргументов без shell. Данная точка не подтверждает shell injection. Не выдаётся за закрытую критическую уязвимость. |
| 6 | Path traversal / 5 | Локальные переводы, фиксированные пути, разрешённые провайдеры туннеля и фиксированные имена назначения. Имена элементов ZIP/TAR в установщике туннеля не используются как пути назначения. Управляемый HTTP-запросом обход пути в указанных точках не подтверждён. |
| 7 | Template XSS / 7 | Вызов шаблона/редиректа/ответа сам по себе не доказывает XSS. Обычные случаи защищены автоэкранированием Jinja или фиксированными адресами. Реальный open redirect через Referer при смене языка устранён: разрешён только локальный путь того же origin. Полное отсутствие XSS во всех шаблонах и JavaScript не заявляется. |
| 8 | AST path traversal / 2 | Перебор процессов в `/proc`, а не пути из HTTP-запроса. Заявленная web path traversal не подтверждена. |
| 9 | Предсказуемые временные пути / 2 | Исправлено: атомарные приватные каталоги `mktemp -d`, экранирование путей, очистка в finally. Дополнительно устранена подстановка команд через путь назначения привилегированной загрузки и добавлена проверка результата перемещения. |
| 10 | click 8.3.1 / 1 | CVE-2026-7246 оспаривается в записи CNA/проектом. Использование `click.edit` приложением не найдено. Версия оставлена; подтверждённая инъекция команд в панели не заявляется. |
| 11 | cryptography 44.0.0 / 1 | Обновлена до 44.0.1 для указанной проблемы встроенного OpenSSL, CVE-2024-12797. **Более новые advisory cryptography остаются открытыми.** |
| 12 | Flask 3.1.0 / 1 | Обновлён до 3.1.3. CVE-2025-47278 исправлена начиная с 3.1.1. Использование Flask и fallback-ключей этим FastAPI-приложением не найдено. |
| 13 | idna 3.11 / 1 | Обновлена до 3.15 для CVE-2026-45409, включая альтернативные функции обработки меток/кодека. |
| 14 | Paramiko 3.5.1 / 1 | **Открыто:** CVE-2026-44405, согласование устаревшего RSA SHA-1. Исправляющий коммит присутствует в 5.0.0; major-upgrade или запрет алгоритмов требуют проверки управляемых серверов. Потенциально несовместимое изменение автоматически не внесено. |
| 15 | Pillow 12.1.1 / 1 | Обновлена до 12.3.0: указанная CVE-2026-42311 и дополнительные найденные исправления. Декодирование недоверенных PSD в приложении не найдено. |
| 16 | python-multipart 0.0.20 / 1 | Обновлена до 0.0.32: CVE-2026-53539 и дополнительные найденные исправления парсера. |
| 17 | Starlette 0.46.2 / 1 | Обновлена до 0.49.1 вместе с совместимой FastAPI 0.120.1: закрыта указанная CVE-2025-62727, DoS через Range, и проблема блокирования multipart. **Более новые advisory Starlette остаются открытыми.** |
Дополнительно AnyIO обновлена с 4.12.1 до 4.14.2 по найденным новым advisory. Массового обновления всех библиотек не проводилось.
## Дополнительные исправления
- **Отключённые учётные записи:** старые cookie-сессии больше не предоставляют доступ пользователям с `enabled=False` или `role=none`. Сохранена совместимость старых записей без enabled.
- **Чужие конфигурации:** проверка владения теперь учитывает сервер, client ID, семейство протокола и экземпляр. Сохранены допустимые псевдонимы AWG. Обход проверки доступен только явным ролям admin/support. Исходная проблема требовала совпадения client ID в разных экземплярах.
- **Ответ CAPTCHA в cookie и повторное использование:** клиент получает только случайный непрозрачный ID. Ответ хранится на сервере; TTL пять минут, максимум 4096 записей, блокировка и атомарное однократное потребление. Неверная попытка расходует challenge; обновление CAPTCHA аннулирует предыдущую.
- **Старые сессии защищённых ссылок:** авторизация привязана к ревизии. Смена/очистка пароля и отключение sharing аннулируют прежние сессии. URL ссылки остаётся прежним. Старые Boolean-cookie требуют повторной авторизации; ссылки без пароля остаются публичными.
- **Смена языка:** отклоняются неподдерживаемые языки и внешние Referer-редиректы; корректный локальный путь и query сохраняются.
- **JSON-бэкапы:** некорректная кодировка и корень не-объект возвращают клиентскую ошибку без записи данных.
Предыдущая правка ресурсов EXE `/sw.js` сохранена и проверена отдельно; она не считается устранением уязвимости.
## Доработки по независимому ревью
Ревью выявило ещё одну существовавшую проблему высокого риска: скачивание удалённого бэкапа через предсказуемый `/tmp/<filename>` открывало секреты другим локальным пользователям и допускало условную атаку через симлинк. Исправлено: перед привилегированным копированием создаётся приватный каталог mode-0700 владельца SSH/SFTP. Очистка перенесена во внешний finally и выполняется также при сбое создания локального файла или открытия SFTP; ошибки очистки журналируются. Два новых регрессионных теста падали до исправления и проходят после. Замечание к фикстуре sudo-теста также устранено, как описано ниже. Эта последняя доработка проверена локально, но отдельное повторное независимое ревью для неё не выполнялось.
## Проверки
- Объединённый целевой запуск: **59 тестов прошли**, без ошибок и падений — авторизация, CAPTCHA, SSH/Telemt, sharing, web-защита, предположения сканера, пути PWA и отсутствие sudo-паролей в командах.
- Полный набор: **459 тестов**, `failures=1, errors=9, skipped=1`. Названия оставшихся падений совпадают с исходными проблемами шаблона сервера. На исходном HEAD ранее выполнялся набор из 401 теста с теми же 1 failure / 9 errors / 1 skip. Отдельно наблюдалось нестабильное падение теста случайных AWG-параметров; его не относим к этой правке.
- `uv pip check --python .venv/Scripts/python.exe`: все 42 установленных пакета совместимы по ограничениям зависимостей.
- `git diff --check` проходит после нормализации окончаний строк requirements.
- Существующая фикстура теста утечки sudo-пароля адаптирована к `mktemp`; теперь проверяет отсутствие пароля во всех командах, а не только в последней.
- Проверки локальные, SSH замокан. Реальные удалённые серверы, собранный EXE и сборки Linux/macOS не проверялись. Атакующие запросы к развёрнутой панели не отправлялись.
## Открытые риски и выпуск
1. **Все зависимости безопасными не объявляются.** Новые исправления cryptography требуют более крупного перехода — рассмотрен кандидат 50.0.0. Новые проблемы Starlette включают обработку UNC-путей Windows StaticFiles, dispatch методов и лимиты URL-encoded форм — рассмотрены Starlette 1.3.1 и совместимая FastAPI. Эти миграции не применены автоматически в рамках минимальных изменений. Для Windows EXE проблему статики следует рассматривать приоритетно, а не как ложное срабатывание.
2. Устаревшие SSH-алгоритмы Paramiko и доверие ключам через AutoAddPolicy требуют отдельной проверки совместимости и hardening.
3. Защита восстановления удалённых архивов от symlink/hardlink требует отдельной доработки: проверка пути после распаковки не является общей гарантией безопасности. Доступ администратора уменьшает поверхность атаки, но не делает импорт чужого архива безопасным.
4. Хранилище CAPTCHA рассчитано на **один worker/process**. Для нескольких процессов нужно общее TTL-хранилище с атомарным извлечением. Перезапуск аннулирует CAPTCHA; массовая выдача может вытеснять действующие записи. Ограничение частоты запросов остаётся отдельной задачей.
5. Нужны HTTPS и лимиты запросов на reverse proxy. Лимит JSON-импорта не ограничивает весь multipart-трафик/временный диск до проверки прав обработчиком.
6. Эта правка не вводит глобальное версионирование всех сессий при смене пароля обычной учётной записи. Доступ отключённого пользователя запрещён, пока он отключён; общее принудительное завершение сессий — отдельная задача.
7. Старые ошибки рендеринга/JavaScript `server.html` не исправлены; полного зелёного набора тестов пока нет.
8. Перед выпуском: проверить diff, выполнить SSH smoke-test на root/sudo-серверах, проверить вход/CAPTCHA/sharing и восстановление тестового бэкапа, пересобрать артефакты всех платформ и повторить ScanGit. Сохранить резервную копию и возможность отката. Автоматического push не было.
## Источники
- https://github.com/encode/starlette/security/advisories/GHSA-7f5h-v6xp-fcq8
- https://github.com/pyca/cryptography/security/advisories/GHSA-79v4-65xg-pq4g
- https://github.com/kjd/idna/security/advisories/GHSA-65pc-fj4g-8rjx
- https://github.com/advisories/GHSA-4grg-w6v8-c28g
- https://github.com/advisories/GHSA-r374-rxx8-8654
- https://github.com/advisories/GHSA-pwv6-vv43-88gr
- https://github.com/advisories/GHSA-5rvq-cxj2-64vf
Версии сопоставлены с диапазонами advisory. Наличие уязвимой библиотеки само по себе не доказывает удалённую эксплуатацию через приложение. Часть дополнительных API-запросов к источникам ограничена rate limit; исчерпывающая проверка всех текущих и транзитивных CVE не заявляется.
+80 -26
View File
@@ -57,6 +57,7 @@ from mail_service import (
merge_mail_settings,
)
from pwa import build_manifest
from captcha_challenges import captcha_challenges
from connection_service import (
ConnectionService,
DEFAULT_SELF_SERVICE_SETTINGS,
@@ -145,7 +146,9 @@ class CachedStaticFiles(StaticFiles):
else 'public, max-age=3600, must-revalidate')
return response
app.mount("/static", CachedStaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
# Bundled resources live beside __file__, not beside the PyInstaller executable.
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", CachedStaticFiles(directory=STATIC_DIR), name="static")
templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates"))
if getattr(sys, 'frozen', False):
@@ -2048,6 +2051,8 @@ def get_current_user(request: Request):
data = load_data()
for u in data.get('users', []):
if u['id'] == user_id:
if not u.get('enabled', True) or u.get('role') == 'none':
return None
return u
return None
@@ -2060,7 +2065,7 @@ def static_version():
page renders new markup against old rules.
"""
newest = 0.0
for root, _dirs, files in os.walk(os.path.join(application_path, 'static')):
for root, _dirs, files in os.walk(STATIC_DIR):
for name in files:
try:
newest = max(newest, os.path.getmtime(os.path.join(root, name)))
@@ -2104,7 +2109,7 @@ def web_manifest(request: Request):
@app.get('/sw.js')
def service_worker():
"""Root-scoped service worker. Must not live under /static/ or scope is confined."""
path = os.path.join(application_path, 'static', 'sw.js')
path = os.path.join(STATIC_DIR, 'sw.js')
return FileResponse(
path,
media_type='text/javascript',
@@ -2905,9 +2910,25 @@ def login_page(request: Request):
@app.get("/set_lang/{lang}", tags=["System Templates"])
def set_lang(lang: str, request: Request):
if lang not in TRANSLATIONS:
return JSONResponse({'error': 'Unsupported language'}, status_code=400)
# Referer is untrusted: keep only a same-origin path, never an external URL.
ref = request.headers.get("referer", "/")
response = RedirectResponse(url=ref)
response.set_cookie(key="lang", value=lang, max_age=31536000)
target = '/'
try:
parsed = urllib.parse.urlsplit(ref)
origin = urllib.parse.urlsplit(str(request.base_url))
same_origin = (parsed.scheme, parsed.netloc) == (origin.scheme, origin.netloc)
relative = not parsed.scheme and not parsed.netloc
if (same_origin or relative) and not any(c in ref for c in (chr(92), chr(13), chr(10))):
path = parsed.path or '/'
if path.startswith('/') and not path.startswith('//'):
target = urllib.parse.urlunsplit(('', '', path, parsed.query, ''))
except ValueError:
pass
response = RedirectResponse(url=target)
response.set_cookie(key="lang", value=lang, max_age=31536000,
httponly=True, secure=request.url.scheme == 'https', samesite='lax')
return response
@@ -2987,7 +3008,9 @@ def api_captcha(request: Request):
# 2 is a multiplier for the image resolution size
generator = CaptchaGenerator(2)
captcha = generator.gen_captcha_image(difficult_level=2)
request.session['captcha_answer'] = captcha.characters
request.session.pop('captcha_answer', None) # Remove legacy disclosed answers.
captcha_challenges.discard(request.session.pop('captcha_challenge_id', None))
request.session['captcha_challenge_id'] = captcha_challenges.issue(captcha.characters)
img_bytes = io.BytesIO()
captcha.image.save(img_bytes, format='PNG')
@@ -3000,13 +3023,12 @@ def api_captcha(request: Request):
def api_login(request: Request, req: LoginRequest):
data = load_data()
captcha_settings = data.get('settings', {}).get('captcha', {})
request.session.pop('captcha_answer', None) # Never accept legacy cookie answers.
if captcha_settings.get('enabled') is True:
answer = request.session.get('captcha_answer')
challenge_id = request.session.pop('captcha_challenge_id', None)
lang = request.cookies.get('lang', 'ru')
if not answer or not req.captcha or answer.lower() != req.captcha.lower():
request.session.pop('captcha_answer', None)
if not captcha_challenges.consume(challenge_id, req.captcha):
return JSONResponse({'error': _t('invalid_captcha', lang)}, status_code=400)
request.session.pop('captcha_answer', None)
for u in data.get('users', []):
# Users without a password (role 'none', record-only) can never log in.
@@ -4277,7 +4299,7 @@ def api_protocol_backup_download(request: Request, server_id: int, req: BackupDo
return JSONResponse({'error': 'Invalid backup filename'}, status_code=400)
ssh = None
tmp_path = None
tmp_remote = f'/tmp/{filename}'
remote_temp_dir = None
remote_path = f'{manager.BACKUP_ROOT}/{safe_proto}/{filename}'
try:
data = load_data()
@@ -4286,6 +4308,10 @@ def api_protocol_backup_download(request: Request, server_id: int, req: BackupDo
server = data['servers'][server_id]
ssh = get_ssh(server)
ssh.connect()
# mktemp creates a mode-0700 directory owned by the SSH/SFTP user.
# The root-owned readable copy stays inaccessible to other local users.
remote_temp_dir = ssh._make_private_temp_dir()
tmp_remote = remote_temp_dir + '/' + filename
quoted_remote = shlex.quote(remote_path)
quoted_tmp = shlex.quote(tmp_remote)
# `sudo <a> && <b>` elevates only `<a>`; the whole chain needs one shell
@@ -4301,9 +4327,6 @@ def api_protocol_backup_download(request: Request, server_id: int, req: BackupDo
sftp.get(tmp_remote, tmp_path)
finally:
sftp.close()
ssh.run_sudo_command(f"rm -f {quoted_tmp}")
ssh.disconnect()
ssh = None
return FileResponse(
tmp_path,
media_type='application/gzip',
@@ -4320,6 +4343,15 @@ def api_protocol_backup_download(request: Request, server_id: int, req: BackupDo
return JSONResponse({'error': str(e)}, status_code=500)
finally:
if ssh:
try:
if remote_temp_dir:
_, cleanup_error, cleanup_code = ssh.run_sudo_command(
'rm -rf -- ' + shlex.quote(remote_temp_dir))
if cleanup_code != 0:
logger.warning('Remote backup staging cleanup failed: %s', cleanup_error)
except Exception:
logger.warning('Remote backup staging cleanup failed', exc_info=True)
finally:
ssh.disconnect()
@@ -5131,11 +5163,17 @@ def api_get_connection_config(request: Request, server_id: int, req: ConnectionA
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
# Users can only view their own connections
if user['role'] in ('user', 'none'):
# Only explicit privileged roles may view unowned connections.
if user.get('role') not in ('admin', 'support'):
owned = any(
c for c in data.get('user_connections', [])
if c.get('client_id') == req.client_id and c.get('server_id') == server_id and c.get('user_id') == user['id']
if c.get('client_id') == req.client_id
and c.get('server_id') == server_id
and c.get('user_id') == user['id']
and c.get('protocol')
# Preserve manager aliases such as awg and awg__1.
and protocol_base(c['protocol']) == protocol_base(req.protocol)
and protocol_instance(c['protocol']) == protocol_instance(req.protocol)
)
if not owned:
return JSONResponse({'error': 'Forbidden'}, status_code=403)
@@ -5678,6 +5716,10 @@ def api_user_share_setup(user_id: str, req: ShareSetupRequest, request: Request)
if not user:
return JSONResponse({'error': 'User not found'}, status_code=404)
# Revoke signed cookies without changing the public URL. Missing revisions
# on existing records start at zero; legacy boolean cookies are rejected.
if not req.enabled or req.password is not None:
user['share_auth_revision'] = user.get('share_auth_revision', 0) + 1
user['share_enabled'] = req.enabled
if not user.get('share_token'):
user['share_token'] = secrets.token_urlsafe(16)
@@ -5690,6 +5732,14 @@ def api_user_share_setup(user_id: str, req: ShareSetupRequest, request: Request)
return {'status': 'success', 'share_token': user.get('share_token')}
def _share_session_authorized(user, request):
if not user.get('share_password_hash'):
return True
revision = request.session.get(f"share_auth_{user['share_token']}")
# bool is an int subclass: require the exact type to reject old True cookies.
return type(revision) is int and revision == user.get('share_auth_revision', 0)
@app.get('/share/{token}', response_class=HTMLResponse, tags=["System Templates"])
def share_page(token: str, request: Request):
data = load_data()
@@ -5698,8 +5748,7 @@ def share_page(token: str, request: Request):
lang = request.cookies.get('lang', 'ru')
return HTMLResponse(f"<h1>{_t('share_not_found', lang)}</h1><p>{_t('share_not_found_desc', lang)}</p>", status_code=404)
auth_session_key = f'share_auth_{token}'
need_password = bool(user.get('share_password_hash')) and not request.session.get(auth_session_key)
need_password = not _share_session_authorized(user, request)
return tpl(request, 'user_share.html',
share_user=user,
@@ -5715,7 +5764,7 @@ def api_share_auth(token: str, req: ShareAuthRequest, request: Request):
return JSONResponse({'error': 'Link expired or disabled'}, status_code=404)
if verify_password(req.password, user.get('share_password_hash', '')):
request.session[f'share_auth_{token}'] = True
request.session[f'share_auth_{token}'] = user.get('share_auth_revision', 0)
return {'status': 'success'}
else:
lang = request.cookies.get('lang', 'ru')
@@ -5729,8 +5778,7 @@ def api_share_connections(token: str, request: Request):
if not user or not user.get('share_enabled'):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if user.get('share_password_hash'):
if not request.session.get(f'share_auth_{token}'):
if not _share_session_authorized(user, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
conns = [dict(c) for c in data.get('user_connections', []) if c['user_id'] == user['id']]
@@ -5751,8 +5799,7 @@ def api_share_config(token: str, connection_id: str, request: Request):
if not user or not user.get('share_enabled'):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if user.get('share_password_hash'):
if not request.session.get(f'share_auth_{token}'):
if not _share_session_authorized(user, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
conn = next((c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']), None)
@@ -6232,16 +6279,23 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
content = await file.read()
# Limit the in-memory JSON read; enforce request-body limits at the
# reverse proxy too, since multipart parsing happens before this handler.
max_backup_bytes = 32 * 1024 * 1024
content = await file.read(max_backup_bytes + 1)
if len(content) > max_backup_bytes:
return JSONResponse({'error': 'Backup exceeds the 32 MiB limit'}, status_code=413)
if not content:
return JSONResponse({'error': 'Empty file'}, status_code=400)
try:
backup_data = json.loads(content)
except json.JSONDecodeError:
except (json.JSONDecodeError, UnicodeDecodeError):
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400)
# Basic structure validation
if not isinstance(backup_data, dict):
return JSONResponse({'error': 'Invalid structure: expected a JSON object'}, status_code=400)
required_keys = ['servers', 'users']
missing = [k for k in required_keys if k not in backup_data]
if missing:
+70
View File
@@ -0,0 +1,70 @@
"""Bounded, one-use CAPTCHA challenges for the single-process panel.
Only random opaque IDs may be placed in the signed (not encrypted) session.
Answers stay in process memory. Challenges expire after five minutes, are
consumed on every validation attempt, and oldest entries are evicted at capacity.
Cleanup is lazy on store operations; no background thread is needed.
Deployment: use ONE application worker. Multiple workers/replicas require a
shared TTL store with atomic consume (e.g. Redis GETDEL); otherwise challenges
issued by another worker fail closed. Restarting also invalidates challenges.
This does not replace endpoint rate limiting: flooding can evict live challenges.
"""
from collections import OrderedDict
import secrets
import threading
import time
class CaptchaChallengeStore:
def __init__(self, ttl_seconds=300, max_entries=4096, clock=time.monotonic):
if ttl_seconds <= 0 or max_entries <= 0:
raise ValueError('CAPTCHA TTL and capacity must be positive')
self._ttl = ttl_seconds
self._capacity = max_entries
self._clock = clock
self._entries = OrderedDict()
self._lock = threading.Lock()
def _prune(self, now):
# Fixed TTL and monotonic insertion order mean oldest expires first.
while self._entries:
token, (_, deadline) = next(iter(self._entries.items()))
if deadline > now:
break
del self._entries[token]
def issue(self, answer):
with self._lock:
now = self._clock()
self._prune(now)
token = secrets.token_urlsafe(32)
while token in self._entries:
token = secrets.token_urlsafe(32)
while len(self._entries) >= self._capacity:
self._entries.popitem(last=False)
self._entries[token] = (answer.lower(), now + self._ttl)
return token
def consume(self, token, answer):
with self._lock:
self._prune(self._clock())
entry = self._entries.pop(token, None) if isinstance(token, str) else None
# Pop before checking the answer: wrong/empty answers consume too.
return bool(entry and isinstance(answer, str) and answer
and secrets.compare_digest(entry[0].encode('utf-8'),
answer.lower().encode('utf-8')))
def discard(self, token):
with self._lock:
self._prune(self._clock())
if isinstance(token, str):
self._entries.pop(token, None)
def __len__(self):
with self._lock:
self._prune(self._clock())
return len(self._entries)
captcha_challenges = CaptchaChallengeStore()
+24 -18
View File
@@ -8,6 +8,7 @@ import io
import time
import threading
import logging
import shlex
from contextlib import nullcontext
logger = logging.getLogger(__name__)
@@ -345,19 +346,21 @@ class SSHManager:
if self._is_root:
return self.run_script(script, timeout=timeout)
# Write script to temp file via SFTP (avoids heredoc/pipe conflicts)
import hashlib
script_hash = hashlib.md5(script.encode()).hexdigest()[:8]
tmp_script = f"/tmp/_amnz_script_{script_hash}.sh"
# Atomically allocate private staging; never follow a preplanted /tmp file.
tmp_dir = self._make_private_temp_dir()
try:
tmp_script = tmp_dir + '/script.sh'
self.upload_file(script, tmp_script)
return self.run_sudo_command('bash ' + shlex.quote(tmp_script), timeout=timeout)
finally:
self.run_command('rm -rf -- ' + shlex.quote(tmp_dir))
# Run with sudo (password via stdin, never on the command line)
if self.password:
return self.run_command(
f"sudo -S -p '' bash {tmp_script}; rm -f {tmp_script}",
timeout=timeout, stdin_input=self.password + '\n')
return self.run_command(f"sudo bash {tmp_script}; rm -f {tmp_script}", timeout=timeout)
def _make_private_temp_dir(self):
out, err, code = self.run_command('mktemp -d /tmp/amnezia-private.XXXXXXXXXX')
path = out.strip()
if code != 0 or not path.startswith('/tmp/amnezia-private.') or '/' in path[5:] or '\n' in path:
raise RuntimeError(err or 'Failed to create private remote staging directory')
return path
def run_script(self, script, timeout=120):
"""Execute a multi-line script on remote server."""
@@ -392,13 +395,16 @@ class SSHManager:
# Normalize line endings (Windows CRLF -> Unix LF)
content = content.replace('\r\n', '\n')
# Write to temp file via SFTP (no sudo needed for /tmp)
import hashlib
tmp_name = f"/tmp/_amnz_{hashlib.md5(remote_path.encode()).hexdigest()[:8]}"
self.upload_file(content, tmp_name)
# Move to target with sudo
self.run_sudo_command(f"mv {tmp_name} {remote_path}")
tmp_dir = self._make_private_temp_dir()
try:
tmp_path = tmp_dir + '/content'
self.upload_file(content, tmp_path)
out, err, code = self.run_sudo_command(
'mv -- ' + shlex.quote(tmp_path) + ' ' + shlex.quote(remote_path))
if code != 0:
raise RuntimeError(err or out or 'Failed to install remote file')
finally:
self.run_command('rm -rf -- ' + shlex.quote(tmp_dir))
self.run_sudo_command(f"chmod 644 {remote_path}")
return True
+7 -4
View File
@@ -4,6 +4,7 @@ import uuid
import re
import os
import secrets
import shlex
from datetime import datetime
from .ssh_manager import SSHManager
@@ -51,10 +52,12 @@ class TelemtManager:
def _api_request(self, method, path, data=None):
"""Execute a curl request inside the docker container."""
cmd = f"docker exec {self.container_name} curl -s -X {method} {self.API_URL}{path}"
if data:
js_data = json.dumps(data).replace('"', '\\"')
cmd += f" -H 'Content-Type: application/json' -d \"{js_data}\""
args = ['docker', 'exec', self.container_name, 'curl', '-s', '--globoff',
'-X', method]
if data is not None:
args.extend(['-H', 'Content-Type: application/json', '-d', json.dumps(data)])
args.extend(['--', self.API_URL + path])
cmd = ' '.join(shlex.quote(arg) for arg in args)
out, err, code = self.ssh.run_sudo_command(cmd)
if code != 0:
+8 -8
View File
@@ -1,36 +1,36 @@
annotated-types==0.7.0
anyio==4.12.1
anyio==4.14.2
bcrypt==5.0.0
blinker==1.9.0
certifi==2026.2.25
cffi==2.0.0
click==8.3.1
colorama==0.4.6
cryptography==44.0.0
fastapi==0.115.12
Flask==3.1.0
cryptography==44.0.1
fastapi==0.120.1
Flask==3.1.3
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.25.2
idna==3.11
idna==3.15
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
multicolorcaptcha==1.2.0
paramiko==3.5.1
playwright==1.62.0
pillow==12.1.1
pillow==12.3.0
pycparser==3.0
pydantic==2.12.5
pydantic_core==2.41.5
PyNaCl==1.6.2
python-dotenv==1.2.2
python-multipart==0.0.20
python-multipart==0.0.32
python-telegram-bot==20.7
PyYAML==6.0.3
sniffio==1.3.1
starlette==0.46.2
starlette==0.49.1
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.34.0
+150
View File
@@ -0,0 +1,150 @@
"""Execute real auth functions in isolation: no app startup, data files or SSH."""
import ast
import hashlib
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from fastapi.responses import JSONResponse
FUNCTIONS = {
'get_current_user', '_check_admin', 'api_get_connection_config',
'_hash_api_token', '_resolve_api_token', 'protocol_base', 'protocol_instance',
}
def isolated_panel():
path = Path(__file__).resolve().parents[1] / 'app.py'
tree = ast.parse(path.read_text(encoding='utf-8'))
nodes = [node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name in FUNCTIONS]
assert {node.name for node in nodes} == FUNCTIONS
for node in nodes:
node.decorator_list = []
namespace = {
'Request': object, 'ConnectionActionRequest': object,
'JSONResponse': JSONResponse, 'hashlib': hashlib,
'logger': Mock(), 'get_ssh': Mock(), 'get_protocol_manager': Mock(),
'_manager_call': Mock(return_value='owned config'),
'protocol_public_endpoint': Mock(return_value=('example.invalid', None)),
'config_payloads': Mock(return_value={}),
'_touch_api_token': Mock(return_value=False), 'save_data': Mock(),
}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), 'exec'), namespace)
return namespace
class AuthSecurityTests(unittest.TestCase):
def setUp(self):
self.panel = isolated_panel()
self.user = {'id': 'owner', 'role': 'user', 'enabled': True}
self.connection = {'user_id': 'owner', 'server_id': 0,
'client_id': 'shared-id', 'protocol': 'awg'}
self.data = {'users': [self.user], 'servers': [{'protocols': {}}],
'user_connections': [self.connection]}
self.panel['load_data'] = Mock(return_value=self.data)
self.request = SimpleNamespace(session={'user_id': 'owner'}, headers={})
def config(self, protocol='awg'):
req = SimpleNamespace(client_id='shared-id', protocol=protocol)
return self.panel['api_get_connection_config'](self.request, 0, req)
def assert_forbidden_without_transport(self, response):
self.assertIsInstance(response, JSONResponse)
self.assertEqual(response.status_code, 403)
self.panel['get_ssh'].assert_not_called()
self.panel['get_protocol_manager'].assert_not_called()
self.panel['_manager_call'].assert_not_called()
def test_existing_disabled_sessions_are_rejected_centrally(self):
for role in ('admin', 'support', 'user'):
with self.subTest(role=role):
self.user.update(role=role, enabled=True)
self.assertIs(self.panel['get_current_user'](self.request), self.user)
self.user['enabled'] = False
self.assertIsNone(self.panel['get_current_user'](self.request))
def test_disabled_admin_and_support_lose_privileged_cookie_access(self):
for role in ('admin', 'support'):
with self.subTest(role=role):
self.user.update(role=role, enabled=False)
self.assertIsNone(self.panel['_check_admin'](self.request))
def test_disabled_sessions_cannot_download_configs(self):
for role in ('admin', 'support', 'user'):
with self.subTest(role=role):
self.user.update(role=role, enabled=False)
self.assert_forbidden_without_transport(self.config())
def test_record_only_session_is_revoked(self):
self.user['role'] = 'none'
self.assertIsNone(self.panel['get_current_user'](self.request))
self.assertIsNone(self.panel['_check_admin'](self.request))
self.assert_forbidden_without_transport(self.config())
def test_legacy_enabled_default_and_active_roles_still_work(self):
del self.user['enabled']
for role in ('admin', 'support', 'user'):
with self.subTest(role=role):
self.user['role'] = role
self.assertIs(self.panel['get_current_user'](self.request), self.user)
self.assertEqual(self.config()['config'], 'owned config')
def test_missing_and_deleted_session_users_are_rejected(self):
for session in ({}, {'user_id': 'deleted'}):
with self.subTest(session=session):
self.request.session = session
self.assertIsNone(self.panel['get_current_user'](self.request))
self.assert_forbidden_without_transport(self.config())
def test_bearer_tokens_remain_revoked_on_disable_or_demotion(self):
token = 'synthetic-token'
self.data['api_tokens'] = [{'user_id': 'owner', 'token_hash':
hashlib.sha256(token.encode()).hexdigest()}]
self.request.session = {}
self.request.headers = {'Authorization': 'Bearer ' + token}
self.user['role'] = 'admin'
self.assertIs(self.panel['_check_admin'](self.request), self.user)
for role, enabled in (('admin', False), ('support', False),
('user', True), ('none', True)):
with self.subTest(role=role, enabled=enabled):
self.user.update(role=role, enabled=enabled)
self.assertIsNone(self.panel['_check_admin'](self.request))
def test_duplicate_client_id_does_not_authorize_other_protocol_or_instance(self):
for protocol in ('awg__2', 'awg2', 'awg3', 'awg_legacy', 'xray'):
with self.subTest(protocol=protocol):
self.data['user_connections'] = [self.connection, {
**self.connection, 'user_id': 'someone-else', 'protocol': protocol}]
self.assert_forbidden_without_transport(self.config(protocol))
def test_exact_owned_tuple_and_manager_aliases_work(self):
# AWGManager._instance_index normalizes first and numeric instances.
for owned, requested in (('awg', 'awg'), ('awg', 'awg__1'),
('awg__1', 'awg'), ('awg__2', 'awg__02'),
('awg2__2', 'awg2__2')):
with self.subTest(owned=owned, requested=requested):
self.connection['protocol'] = owned
self.assertEqual(self.config(requested)['config'], 'owned config')
self.assertEqual(self.panel['_manager_call'].call_args.args[2], requested)
def test_missing_protocol_does_not_imply_awg_ownership(self):
del self.connection['protocol']
self.assert_forbidden_without_transport(self.config())
def test_unknown_roles_do_not_bypass_ownership(self):
self.user['role'] = 'unexpected-role'
self.assert_forbidden_without_transport(self.config('awg__2'))
def test_explicit_privileged_roles_can_access_unowned_protocols(self):
self.data['user_connections'] = []
for role in ('admin', 'support'):
with self.subTest(role=role):
self.user['role'] = role
self.assertEqual(self.config('awg__2')['config'], 'owned config')
if __name__ == '__main__':
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
"""Remote backup staging must be private and cleaned on every exit."""
import os
import unittest
from unittest.mock import MagicMock, patch
import app as panel
class BackupDownloadSecurityTests(unittest.TestCase):
def run_download(self, failure=None):
ssh = MagicMock()
ssh._make_private_temp_dir.return_value = '/tmp/amnezia-private.ABC123def0'
ssh.run_sudo_command.return_value = ('', '', 0)
if failure == 'sftp':
ssh.client.open_sftp.side_effect = OSError('sftp unavailable')
with patch.object(panel, '_check_admin', return_value=True), \
patch.object(panel, 'load_data', return_value={'servers': [{}]}), \
patch.object(panel, 'get_ssh', return_value=ssh):
if failure == 'local_temp':
with patch.object(panel.tempfile, 'mkstemp', side_effect=OSError('disk full')):
result = panel.api_protocol_backup_download(None, 0, panel.BackupDownloadRequest(
protocol='awg', filename='backup.tar.gz'))
else:
result = panel.api_protocol_backup_download(None, 0, panel.BackupDownloadRequest(
protocol='awg', filename='backup.tar.gz'))
return result, ssh
def test_staging_uses_private_directory(self):
result, ssh = self.run_download()
try:
self.assertEqual(result.status_code, 200)
ssh._make_private_temp_dir.assert_called_once()
self.assertEqual(ssh.client.open_sftp.return_value.get.call_args[0][0],
'/tmp/amnezia-private.ABC123def0/backup.tar.gz')
self.assertIn('rm -rf -- /tmp/amnezia-private.ABC123def0',
[c.args[0] for c in ssh.run_sudo_command.call_args_list])
ssh.disconnect.assert_called_once()
finally:
if hasattr(result, 'path') and os.path.exists(result.path):
os.remove(result.path)
def test_cleanup_on_local_temp_or_sftp_failure(self):
for failure in ('local_temp', 'sftp'):
with self.subTest(failure=failure):
result, ssh = self.run_download(failure)
self.assertEqual(result.status_code, 500)
self.assertIn('rm -rf -- /tmp/amnezia-private.ABC123def0',
[c.args[0] for c in ssh.run_sudo_command.call_args_list])
ssh.disconnect.assert_called_once()
if __name__ == '__main__':
unittest.main()
+186
View File
@@ -0,0 +1,186 @@
"""Exercise real auth handlers without importing app or touching panel data.
The process-local challenge store intentionally supports a single worker only;
multiple workers need a shared store with atomic consume (not cookie answers).
"""
import ast
import base64
from concurrent.futures import ThreadPoolExecutor
import importlib.util
import io
import json
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from typing import Optional
ROOT = Path(__file__).resolve().parents[1]
ANSWER = 'AbC72'
PNG = b'\x89PNG\r\n\x1a\nsynthetic-image'
def load_handlers():
"""Compile only the actual auth handlers, model and challenge import."""
tree = ast.parse((ROOT / 'app.py').read_text(encoding='utf-8'))
names = {'api_captcha', 'api_login', 'LoginRequest'}
selected = [node for node in tree.body
if getattr(node, 'name', '') in names
or (isinstance(node, ast.ImportFrom)
and node.module == 'captcha_challenges')]
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key='synthetic-test-key')
data = {'settings': {'captcha': {'enabled': True}},
'users': [{'id': 'test-user', 'username': 'alice', 'password_hash': 'hashed',
'role': 'admin', 'enabled': True}]}
image = SimpleNamespace(save=lambda out, **kwargs: out.write(PNG))
generator = Mock(return_value=SimpleNamespace(gen_captcha_image=Mock(
return_value=SimpleNamespace(characters=ANSWER, image=image))))
ns = dict(app=app, Request=Request, BaseModel=BaseModel, Optional=Optional,
JSONResponse=JSONResponse, StreamingResponse=StreamingResponse,
io=io, CaptchaGenerator=generator, load_data=lambda: data,
verify_password=Mock(return_value=True),
_t=lambda key, lang: key)
exec(compile(ast.Module(body=selected, type_ignores=[]), 'app.py', 'exec'), ns)
# Every test gets a fresh store, never the application's runtime state.
if 'captcha_challenges' in ns:
ns['captcha_challenges'] = type(ns['captcha_challenges'])()
return app, ns, data
class CaptchaHandlerTests(unittest.TestCase):
def setUp(self):
self.app, self.ns, self.data = load_handlers()
self.client = TestClient(self.app)
def challenge(self):
response = self.client.get('/api/auth/captcha')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers['content-type'], 'image/png')
self.assertEqual(response.content, PNG)
return self.client.cookies.get('session')
def login(self, answer=ANSWER):
return self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'test-password', 'captcha': answer})
def replay_cookie(self, cookie):
self.client.cookies.clear()
self.client.cookies.set('session', cookie, domain='testserver.local', path='/')
def test_cookie_contains_only_opaque_challenge_id(self):
cookie = self.challenge()
payload = json.loads(base64.b64decode(cookie.split('.')[0]))
self.assertEqual(set(payload), {'captcha_challenge_id'})
self.assertRegex(payload['captcha_challenge_id'], r'^[A-Za-z0-9_-]{43}$')
self.assertNotIn(ANSWER.lower(), json.dumps(payload).lower())
def test_solved_cookie_cannot_be_replayed(self):
cookie = self.challenge()
self.assertEqual(self.login(ANSWER.lower()).status_code, 200)
self.replay_cookie(cookie)
self.assertEqual(self.login().status_code, 400)
self.assertEqual(self.ns['verify_password'].call_count, 1)
def test_failed_or_missing_answer_consumes_challenge(self):
for answer in ('wrong', '', None):
with self.subTest(answer=answer):
cookie = self.challenge()
self.assertEqual(self.login(answer).status_code, 400)
self.replay_cookie(cookie)
self.assertEqual(self.login().status_code, 400)
self.ns['verify_password'].assert_not_called()
def test_bad_password_also_consumes_challenge(self):
cookie = self.challenge()
self.ns['verify_password'].return_value = False
self.assertEqual(self.login().status_code, 401)
self.ns['verify_password'].return_value = True
self.replay_cookie(cookie)
self.assertEqual(self.login().status_code, 400)
def test_refresh_revokes_previous_challenge(self):
old = self.challenge()
new = self.challenge()
self.assertNotEqual(old, new)
self.replay_cookie(old)
self.assertEqual(self.login().status_code, 400)
self.replay_cookie(new)
self.assertEqual(self.login().status_code, 200)
def test_no_challenge_rejected_before_password_verification(self):
self.assertEqual(self.login().status_code, 400)
self.ns['verify_password'].assert_not_called()
def test_disabled_captcha_does_not_block_login(self):
self.data['settings']['captcha']['enabled'] = False
self.assertEqual(self.login(None).status_code, 200)
def test_missing_generator_retains_error_response(self):
self.ns['CaptchaGenerator'] = None
response = self.client.get('/api/auth/captcha')
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json(), {'error': 'multicolorcaptcha is not installed'})
class ChallengeStoreTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(importlib.util.find_spec('captcha_challenges'),
'server-side challenge store is required')
from captcha_challenges import CaptchaChallengeStore
self.now = 100.0
self.store = CaptchaChallengeStore(ttl_seconds=60, max_entries=3,
clock=lambda: self.now)
def test_exact_expiry_boundary_and_expired_cleanup(self):
old = self.store.issue(ANSWER)
self.now += 59
self.assertTrue(self.store.consume(old, ANSWER))
expired = self.store.issue(ANSWER)
self.now += 60
self.assertFalse(self.store.consume(expired, ANSWER))
self.assertEqual(len(self.store), 0)
def test_capacity_evicts_oldest_not_newest(self):
ids = [self.store.issue(ANSWER) for _ in range(4)]
self.assertEqual(len(self.store), 3)
self.assertFalse(self.store.consume(ids[0], ANSWER))
for token in ids[1:]:
self.assertTrue(self.store.consume(token, ANSWER))
def test_expired_entries_are_pruned_when_issuing(self):
self.store.issue(ANSWER)
self.store.issue(ANSWER)
self.now += 60
self.store.issue(ANSWER)
self.assertEqual(len(self.store), 1)
def test_concurrent_consume_allows_one_winner(self):
token = self.store.issue(ANSWER)
with ThreadPoolExecutor(max_workers=16) as pool:
results = list(pool.map(lambda _: self.store.consume(token, ANSWER), range(32)))
self.assertEqual(sum(results), 1)
def test_concurrent_issue_stays_bounded(self):
with ThreadPoolExecutor(max_workers=16) as pool:
ids = list(pool.map(lambda _: self.store.issue(ANSWER), range(64)))
self.assertEqual(len(set(ids)), 64)
self.assertEqual(len(self.store), 3)
def test_discard_and_unknown_id_fail_closed(self):
token = self.store.issue(ANSWER)
self.store.discard(token)
self.assertFalse(self.store.consume(token, ANSWER))
self.assertFalse(self.store.consume('unknown', ANSWER))
self.assertFalse(self.store.consume(None, ANSWER))
if __name__ == '__main__':
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
"""Regression tests for shell argument boundaries and remote staging."""
import json
import shlex
import unittest
from unittest.mock import Mock
from managers.backup_manager import BackupManager
from managers.ssh_manager import SSHManager
from managers.telemt_manager import TelemtManager
class ManagerSecurityTests(unittest.TestCase):
def test_telemt_payload_is_literal_json(self):
ssh = Mock()
ssh.run_sudo_command.return_value = ('{}', '', 0)
data = {'secret': '$(touch /tmp/owned)`id`\\"\'value'}
TelemtManager(ssh)._api_request('POST', '/v1/users', data)
command = ssh.run_sudo_command.call_args.args[0]
argv = shlex.split(command)
self.assertEqual(json.loads(argv[argv.index('-d') + 1]), data)
# POSIX single quoting must protect substitutions, not just JSON quotes.
self.assertIn(shlex.quote(json.dumps(data)), command)
def test_telemt_path_cannot_add_shell_or_curl_arguments(self):
ssh = Mock()
ssh.run_sudo_command.return_value = ('{}', '', 0)
path = '/v1/users/a; id # -o /tmp/owned'
TelemtManager(ssh)._api_request('DELETE', path)
command = ssh.run_sudo_command.call_args.args[0]
argv = shlex.split(command)
self.assertEqual(argv[-1], 'http://127.0.0.1:9091' + path)
self.assertIn(shlex.quote(argv[-1]), command)
self.assertIn('--globoff', argv)
def test_backup_script_assignments_are_literal(self):
ssh = Mock()
ssh.run_sudo_script.return_value = ('', 'failure', 1)
BackupManager(ssh).create_backup("telemt'; id #", "telemt'; id #")
script = ssh.run_sudo_script.call_args.args[0]
for name in ('protocol', 'container'):
line = next(line for line in script.splitlines() if line.startswith(name + '='))
self.assertEqual(shlex.split(line), [name + "=telemt'; id #"])
def make_ssh(self):
ssh = SSHManager('example.test', 22, 'admin', password='secret')
ssh.ensure_connected = Mock()
ssh.run_command = Mock(return_value=('/tmp/amnezia-private.ABC123\n', '', 0))
ssh.run_sudo_command = Mock(return_value=('', '', 0))
ssh.upload_file = Mock()
return ssh
def test_sudo_script_uses_private_allocated_directory_and_cleans_up(self):
ssh = self.make_ssh()
ssh.run_sudo_script('echo ok; exit 7')
self.assertIn('mktemp -d', ssh.run_command.call_args_list[0].args[0])
self.assertEqual(ssh.upload_file.call_args.args[1], '/tmp/amnezia-private.ABC123/script.sh')
self.assertIn('rm -rf --', ssh.run_command.call_args_list[-1].args[0])
def test_sudo_upload_quotes_target_and_cleans_up_on_failure(self):
ssh = self.make_ssh()
ssh.run_sudo_command.return_value = ('', 'denied', 1)
target = "/etc/config'; touch /tmp/owned; #"
with self.assertRaises(RuntimeError):
ssh.upload_file_sudo('secret', target)
argv = shlex.split(ssh.run_sudo_command.call_args.args[0])
self.assertEqual(argv, ['mv', '--', '/tmp/amnezia-private.ABC123/content', target])
self.assertIn('rm -rf --', ssh.run_command.call_args_list[-1].args[0])
def test_staging_failure_never_uploads(self):
ssh = self.make_ssh()
ssh.run_command.return_value = ('', 'no space', 1)
with self.assertRaises(RuntimeError):
ssh.run_sudo_script('echo ok')
ssh.upload_file.assert_not_called()
def test_root_admin_script_is_not_filtered(self):
ssh = self.make_ssh()
ssh._is_root = True
script = 'echo $(id); cat /etc/os-release | sort && true'
ssh.run_sudo_script(script)
ssh.run_command.assert_called_once_with(script, timeout=120)
if __name__ == '__main__':
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
"""Bundled PWA assets must not be resolved beside the executable."""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
import app as panel
STATIC = Path(panel.__file__).resolve().parent / 'static'
class PwaResourcePathTests(unittest.TestCase):
def test_service_worker_from_source(self):
response = TestClient(panel.app).get('/sw.js?v=123')
self.assert_worker(response)
def test_service_worker_when_executable_is_outside_bundle(self):
# PyInstaller puts __file__ in its extracted bundle, while
# application_path points at the directory containing the executable.
with tempfile.TemporaryDirectory() as executable_dir:
with patch.object(panel, 'application_path', executable_dir):
response = TestClient(panel.app).get('/sw.js?v=123')
self.assert_worker(response)
def assert_worker(self, response):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, (STATIC / 'sw.js').read_bytes())
self.assertTrue(response.headers['content-type'].startswith('text/javascript'))
self.assertEqual(response.headers['cache-control'], 'no-cache')
self.assertEqual(response.headers['service-worker-allowed'], '/')
def test_static_version_when_executable_is_outside_bundle(self):
expected = str(int(max(
os.path.getmtime(Path(root) / name)
for root, _, files in os.walk(STATIC)
for name in files
)))
self.assertNotEqual(expected, '0')
with tempfile.TemporaryDirectory() as executable_dir:
with patch.object(panel, 'application_path', executable_dir):
self.assertEqual(panel.static_version(), expected)
if __name__ == '__main__':
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
"""Verify trust boundaries behind ScanGit path/HTTP/template findings."""
import io
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
import app as panel
class ScanAssumptionTests(unittest.TestCase):
def test_tunnel_provider_cannot_supply_paths(self):
for provider in ('../../outside', '/tmp/evil', 'ngrok/../../evil', 'unknown'):
with self.subTest(provider=provider):
with self.assertRaises(ValueError):
panel.get_tunnel_command_name(provider)
with self.assertRaises(ValueError):
panel.get_tunnel_download(provider)
with self.assertRaises(ValueError):
panel.get_tunnel_binary_path(provider)
def test_zip_member_path_is_not_used_as_destination(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / 'ngrok.exe'
def download(url, filename):
with zipfile.ZipFile(filename, 'w') as archive:
archive.writestr('../../ngrok.exe', b'test-binary')
archive.writestr('../../unexpected.txt', b'do-not-extract')
with patch.object(panel, 'BIN_DIR', directory), \
patch.object(panel, 'is_tunnel_installed', return_value=False), \
patch.object(panel, 'get_tunnel_download', return_value=('https://example.test/bin', 'zip')), \
patch.object(panel, 'get_tunnel_command_name', return_value='ngrok.exe'), \
patch.object(panel.urllib.request, 'urlretrieve', side_effect=download):
panel.install_tunnel_binary('ngrok')
self.assertEqual(target.read_bytes(), b'test-binary')
self.assertEqual(sorted(p.name for p in Path(directory).iterdir()), ['ngrok.exe'])
def test_backup_download_is_admin_only(self):
with patch.object(panel, '_check_admin', return_value=False):
response = TestClient(panel.app).get('/api/settings/backup/download')
self.assertEqual(response.status_code, 403)
def test_jinja_autoescapes_untrusted_html(self):
template = panel.templates.env.from_string('<p>{{ value }}</p>')
self.assertEqual(template.render(value='<script>alert(1)</script>'),
'<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>')
if __name__ == '__main__':
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""Exercise real share functions with synthetic state; never import/start app."""
import ast
import copy
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from starlette.responses import HTMLResponse, JSONResponse
class ShareSecurityTests(unittest.TestCase):
def setUp(self):
source = Path(__file__).resolve().parents[1].joinpath('app.py').read_text(encoding='utf-8-sig')
tree = ast.parse(source)
names = {'api_user_share_setup', 'share_page', 'api_share_auth',
'api_share_connections', 'api_share_config', '_share_session_authorized'}
nodes = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names]
for node in nodes:
node.decorator_list = []
self.user = {'id': 'u1', 'username': 'synthetic', 'share_token': 'stable-url',
'share_enabled': True, 'share_password_hash': 'hash:old'}
self.data = {'users': [self.user], 'servers': [], 'user_connections': []}
self.saved = []
self.ssh = Mock(side_effect=AssertionError('SSH must not be reached'))
self.ns = {'Request': object, 'ShareSetupRequest': object, 'ShareAuthRequest': object,
'JSONResponse': JSONResponse, 'HTMLResponse': HTMLResponse,
'load_data': lambda: self.data,
'save_data': lambda data: self.saved.append(copy.deepcopy(data)),
'_check_admin': lambda request: True,
'hash_password': lambda password: 'hash:' + password,
'verify_password': lambda password, hashed: hashed == 'hash:' + password,
'_t': lambda text, lang: text, 'tpl': lambda *args, **kwargs: kwargs,
'get_ssh': self.ssh}
exec(compile(ast.Module(body=nodes, type_ignores=[]), '<isolated-share-functions>', 'exec'), self.ns)
self.request = SimpleNamespace(session={}, cookies={})
def call(self, name, **kwargs):
return self.ns[name](token='stable-url', request=self.request, **kwargs)
def login(self, password='old'):
return self.call('api_share_auth', req=SimpleNamespace(password=password))
def setup_share(self, enabled=True, password=None):
result = self.ns['api_user_share_setup']('u1', SimpleNamespace(enabled=enabled, password=password), self.request)
self.assertEqual(result['share_token'], 'stable-url')
self.assertEqual(self.user['share_token'], 'stable-url')
def assert_locked(self):
self.assertTrue(self.call('share_page')['need_password'])
self.assertEqual(self.call('api_share_connections').status_code, 401)
self.assertEqual(self.call('api_share_config', connection_id='missing').status_code, 401)
self.ssh.assert_not_called()
def assert_authorized(self):
self.assertFalse(self.call('share_page')['need_password'])
self.assertEqual(self.call('api_share_connections')['username'], 'synthetic')
# Passing auth reaches connection lookup, but never a real server.
self.assertEqual(self.call('api_share_config', connection_id='missing').status_code, 404)
self.ssh.assert_not_called()
def test_password_change_revokes_replayed_session_on_all_routes(self):
self.assertEqual(self.login(), {'status': 'success'})
self.assert_authorized()
old_session = copy.deepcopy(self.request.session)
self.setup_share(password='new')
self.assertIn('share_auth_revision', self.saved[-1]['users'][0])
self.request.session = old_session
self.assert_locked()
self.assertEqual(self.login('old').status_code, 401)
self.assert_locked()
self.assertEqual(self.login('new'), {'status': 'success'})
self.assert_authorized()
def test_disable_and_reenable_does_not_resurrect_session(self):
self.login()
old_session = copy.deepcopy(self.request.session)
self.setup_share(enabled=False)
self.assertEqual(self.call('share_page').status_code, 404)
self.assertEqual(self.login().status_code, 404)
self.assertEqual(self.call('api_share_connections').status_code, 403)
self.assertEqual(self.call('api_share_config', connection_id='missing').status_code, 403)
self.setup_share(enabled=True)
self.request.session = old_session
self.assert_locked()
self.login()
self.assert_authorized()
def test_legacy_boolean_session_is_rejected_even_at_revision_one(self):
for revision in (None, 0, 1):
with self.subTest(revision=revision):
if revision is not None:
self.user['share_auth_revision'] = revision
self.request.session = {'share_auth_stable-url': True}
self.assert_locked()
self.login()
self.assert_authorized()
def test_new_login_uses_revision_not_password_hash(self):
self.login()
value = self.request.session['share_auth_stable-url']
self.assertIs(type(value), int)
self.assertEqual(value, self.user.get('share_auth_revision', 0))
self.assertNotIn(self.user['share_password_hash'], repr(self.request.session))
self.assert_authorized()
def test_unchanged_settings_preserve_authorization(self):
self.login()
self.setup_share()
self.assert_authorized()
def test_clear_and_reprotect_revokes_previous_session(self):
self.login()
old_session = copy.deepcopy(self.request.session)
self.setup_share(password='')
self.request.session = {}
self.assert_authorized() # Explicitly passwordless sharing stays public.
self.setup_share(password='new')
self.request.session = old_session
self.assert_locked()
def test_missing_and_wrong_typed_sessions_fail_closed(self):
for value in (None, False, True, '0', {}, []):
with self.subTest(value=value):
self.request.session = {'share_auth_stable-url': value}
self.assert_locked()
if __name__ == '__main__':
unittest.main()
+13 -5
View File
@@ -8,6 +8,7 @@ of the command. The password now travels through the channel's stdin only.
"""
import logging
import io
import threading
import unittest
from unittest import mock
@@ -67,8 +68,13 @@ class SudoPasswordLeakTests(unittest.TestCase):
ssh = make_ssh()
stdin = FakeStdin()
channel = FakeChannel()
ssh.client.exec_command.return_value = (
stdin, FakeStream(channel), FakeStream(channel))
def execute(command, **kwargs):
stdout = (io.BytesIO(b'/tmp/amnezia-private.ABC123def0\n')
if command.startswith('mktemp -d ') else FakeStream(channel))
if isinstance(stdout, io.BytesIO):
stdout.channel = channel
return stdin, stdout, FakeStream(channel)
ssh.client.exec_command.side_effect = execute
records = []
@@ -86,8 +92,8 @@ class SudoPasswordLeakTests(unittest.TestCase):
return ssh, stdin, records
def assert_password_hidden(self, ssh, stdin, records):
command = ssh.client.exec_command.call_args[0][0]
self.assertNotIn(PASSWORD, command)
for call in ssh.client.exec_command.call_args_list:
self.assertNotIn(PASSWORD, call[0][0])
self.assertTrue(all(PASSWORD not in r for r in records))
self.assertEqual(stdin.written, [PASSWORD + '\n'])
self.assertTrue(stdin.channel.write_closed)
@@ -104,7 +110,9 @@ class SudoPasswordLeakTests(unittest.TestCase):
lambda s: s.run_sudo_script('echo hi'))
self.assert_password_hidden(ssh, stdin, records)
command = ssh.client.exec_command.call_args[0][0]
self.assertTrue(command.startswith("sudo -S -p '' bash /tmp/"))
commands = [call[0][0] for call in ssh.client.exec_command.call_args_list]
self.assertTrue(any(cmd.startswith("sudo -S -p '' bash /tmp/") for cmd in commands))
self.assertTrue(command.startswith('rm -rf -- /tmp/amnezia-private.'))
def test_root_login_needs_no_stdin(self):
ssh = make_ssh()
+58
View File
@@ -0,0 +1,58 @@
"""All shipped locales must cover the English/Russian reference keys."""
import collections
import json
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1] / 'translations'
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f'Duplicate translation key: {key}')
result[key] = value
return result
class TranslationCompletenessTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.locales = {
path.stem: json.loads(path.read_text(encoding='utf-8'),
object_pairs_hook=unique_object)
for path in ROOT.glob('*.json')
}
def test_reference_languages_have_identical_keys(self):
self.assertEqual(set(self.locales['en']), set(self.locales['ru']))
def test_all_locales_cover_reference_keys(self):
expected = set(self.locales['en']) | set(self.locales['ru'])
for lang, values in self.locales.items():
with self.subTest(lang=lang):
self.assertEqual(set(values), expected)
def test_values_are_nonempty_strings(self):
for lang, values in self.locales.items():
for key, value in values.items():
with self.subTest(lang=lang, key=key):
self.assertIsInstance(value, str)
self.assertTrue(value.strip())
def test_format_placeholders_are_preserved(self):
for lang, values in self.locales.items():
for key, value in values.items():
if key not in self.locales['en']:
continue
with self.subTest(lang=lang, key=key):
self.assertEqual(
collections.Counter(re.findall(r'\{[^{}]*\}', value)),
collections.Counter(re.findall(r'\{[^{}]*\}', self.locales['en'][key])),
)
if __name__ == '__main__':
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
"""Regression tests for small web security fixes; never touch real panel data."""
import asyncio
import io
import json
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from starlette.datastructures import UploadFile
import app as panel
class LanguageSecurityTests(unittest.TestCase):
def test_referer_cannot_redirect_off_site(self):
client = TestClient(panel.app)
for referer in ('https://evil.example/phishing', '//evil.example/',
'/\\evil.example/', 'javascript:alert(1)'):
with self.subTest(referer=referer):
response = client.get('/set_lang/ru', headers={'referer': referer},
follow_redirects=False)
self.assertEqual(response.status_code, 307)
self.assertEqual(response.headers['location'], '/')
def test_local_referer_keeps_path_and_query(self):
client = TestClient(panel.app)
for referer in ('/my?tab=configs', 'http://testserver/my?tab=configs'):
response = client.get('/set_lang/ru', headers={'referer': referer},
follow_redirects=False)
self.assertEqual(response.headers['location'], '/my?tab=configs')
def test_language_cookie_is_httponly_and_secure_on_https_only(self):
for scheme in ('http', 'https'):
with self.subTest(scheme=scheme):
response = TestClient(panel.app, base_url=f'{scheme}://testserver').get(
'/set_lang/ru', follow_redirects=False)
cookie = response.headers['set-cookie'].lower()
self.assertIn('httponly', cookie)
self.assertIn('samesite=lax', cookie)
self.assertEqual('secure' in cookie, scheme == 'https')
def test_unknown_language_is_rejected(self):
response = TestClient(panel.app).get('/set_lang/not-a-language', follow_redirects=False)
self.assertEqual(response.status_code, 400)
self.assertNotIn('set-cookie', response.headers)
class BackupUploadSecurityTests(unittest.TestCase):
def restore(self, content, allowed=True):
upload = UploadFile(file=io.BytesIO(content), filename='data.json')
with patch.object(panel, '_check_admin', return_value=allowed), \
patch.object(panel, 'save_data') as save:
response = asyncio.run(panel.api_backup_restore(None, upload))
return response, save
def test_non_admin_cannot_restore(self):
response, save = self.restore(b'{}', allowed=False)
self.assertEqual(response.status_code, 403)
save.assert_not_called()
def test_non_object_json_is_rejected(self):
for content in (b'null', b'42', b'[]', b'"servers users"'):
with self.subTest(content=content):
response, save = self.restore(content)
self.assertEqual(response.status_code, 400)
save.assert_not_called()
def test_invalid_utf8_is_rejected(self):
response, save = self.restore(b'\xff')
self.assertEqual(response.status_code, 400)
save.assert_not_called()
def test_large_backup_is_rejected_without_unbounded_read(self):
class BoundedUpload:
async def read(self, size=-1):
self.requested = size
if size < 0:
raise AssertionError('Unbounded backup read')
return b' ' * size
upload = BoundedUpload()
with patch.object(panel, '_check_admin', return_value=True), \
patch.object(panel, 'save_data') as save:
response = asyncio.run(panel.api_backup_restore(None, upload))
self.assertEqual(response.status_code, 413)
self.assertLessEqual(upload.requested, 32 * 1024 * 1024 + 1)
save.assert_not_called()
def test_valid_backup_still_restores(self):
response, save = self.restore(json.dumps({'servers': [], 'users': []}).encode())
self.assertEqual(response, {'status': 'success'})
save.assert_called_once()
if __name__ == '__main__':
unittest.main()
+312 -213
View File
@@ -222,7 +222,7 @@
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.",
"api_docs_title": "🔌 مستندات API",
"api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید",
"tunnels_title": "Tunnels",
"tunnels_title": "تونلها",
"local_server": "سرور محلی",
"tunnel_install_enable": "نصب و فعال‌سازی",
"tunnel_enable": "فعال‌سازی تونل",
@@ -287,14 +287,14 @@
"lang_ru": "Русский",
"lang_en": "English",
"lang_fr": "Français",
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"lang_zh": "中文 (چینی)",
"lang_fa": "فارسی",
"backup_title": "پشتیبان‌گیری ساده",
"download_backup": "دانلود data.json",
"restore_backup": "بازیابی از فایل",
"restore_confirm": "آیا مطمئن هستید؟ داده‌های فعلی بازنویسی می‌شوند و برنامه دوباره راه‌اندازی خواهد شد.",
"restore_success": "بازیابی پشتیبان با موفقیت انجام شد!",
"auto_backup_title": "Auto backup",
"auto_backup_title": "پشتیبان‌گیری خودکار",
"enable_auto_backup": "فعالسازی پشتیبانگیری خودکار سرورها",
"auto_backup_interval_label": "فاصله زمانی ایجاد",
"auto_backup_hint": "پنل از پروتکلهای نصبشده روی هر سرور در بازه انتخابشده پشتیبان میگیرد.",
@@ -315,121 +315,121 @@
"remove_server": "حذف سرور از پنل",
"telemt_desc": "پروکسی تلگرام بر پایه‌ی MTProxy با پوشش‌های پیشرفته.",
"dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.",
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
"socks5_username": "Username",
"socks5_password": "Password",
"socks5_password_placeholder": "Leave empty to auto-generate",
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
"socks5_change_settings": "Change settings",
"socks5_settings_title": "SOCKS5 — Connection settings",
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
"socks5_settings_saved": "SOCKS5 settings saved",
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
"adguard_mode": "Installation mode",
"adguard_mode_sidebyside": "Side-by-side",
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
"adguard_mode_replace": "Replace AmneziaDNS",
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
"adguard_dns_port": "DNS port (53)",
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
"adguard_web_port": "Web UI port",
"adguard_dot_port": "DoT port",
"adguard_doh_port": "DoH port",
"adguard_expose_web": "Expose to internet",
"adguard_expose": "Expose to host",
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
"adguard_internal_ip": "Internal IP",
"adguard_web_ui": "Admin URL",
"adguard_open_ui": "Open Web UI",
"edit_server_title": "Edit server",
"edit_keep_credential": "Leave empty to keep the current value",
"server_saved": "Server settings saved",
"servers_reordered": "Servers reordered",
"ping_checking": "Checking connectivity...",
"offline": "offline",
"api_tokens_title": "API tokens",
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <token>` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.",
"api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.",
"api_tokens_create": "Create token",
"api_tokens_create_title": "Create API token",
"api_tokens_name_label": "Name",
"api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring",
"api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.",
"api_tokens_name_required": "Token name is required",
"api_tokens_created_title": "Token created",
"api_tokens_value_label": "Token value",
"api_tokens_warning_title": "This is the only time you'll see this token.",
"api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.",
"api_tokens_revoke": "Revoke",
"api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.",
"api_tokens_revoked": "Token revoked",
"api_tokens_created": "Created",
"api_tokens_last_used": "Last used",
"api_tokens_owner": "Owner",
"api_tokens_never": "never",
"coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub",
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
"warp_install_required": "Install WARP first",
"warp_connect": "Connect WARP",
"warp_disconnect": "Disconnect",
"warp_connected": "WARP connected",
"warp_disconnected": "WARP disconnected",
"warp_status_unknown": "WARP status is unknown.",
"warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.",
"warp_status_connected": "Cloudflare WARP is connected.",
"warp_status_connecting": "Cloudflare WARP is connecting.",
"warp_status_disconnected": "Cloudflare WARP is disconnected.",
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
"warp_status_error": "Cloudflare WARP returned an error.",
"install_starting": "Starting installation...",
"server_check_complete": "Check completed",
"server_connection_error": "Connection error",
"connections_load_error": "Error loading connections",
"connection_updated": "Connection updated",
"edit_connection": "Edit connection",
"enable_connection_progress": "Enabling connection...",
"disable_connection_progress": "Disabling connection...",
"connection_enabled": "Connection enabled",
"connection_disabled": "Connection disabled",
"dns_internal_hint": "این سرویس روی شبکهٔ داخلی (172.29.172.254) اجرا می‌شود و به پورت‌های میزبان متصل نمی‌شود.",
"telemt_port_hint": "پورت پروکسی Telegram (معمولاً 443).",
"socks5_desc": "پروکسی تک‌حسابی SOCKS5 (3proxy) با احراز هویت از طریق نام کاربری و گذرواژه.",
"socks5_username": "نام کاربری",
"socks5_password": "گذرواژه",
"socks5_password_placeholder": "برای تولید خودکار، خالی بگذارید",
"socks5_password_hint": "اگر خالی بماند، یک گذرواژهٔ تصادفی 16 نویسه‌ای تولید می‌شود.",
"socks5_port_hint": "پورت TCP که پروکسی روی آن گوش می‌دهد. مقدار پیش‌فرض در برنامهٔ رسمی Amnezia برابر با 38080 است.",
"socks5_change_settings": "تغییر تنظیمات",
"socks5_settings_title": "SOCKS5 — تنظیمات اتصال",
"socks5_port_change_hint": "تغییر پورت، کانتینر را دوباره ایجاد می‌کند؛ نشست‌های فعلی قطع خواهند شد.",
"socks5_settings_saved": "تنظیمات SOCKS5 ذخیره شد",
"adguard_desc": "AdGuard Home — مسدودکنندهٔ تبلیغات مبتنی بر DNS با رابط وب مدیریت.",
"adguard_mode": "حالت نصب",
"adguard_mode_sidebyside": "نصب در کنار سرویس موجود",
"adguard_mode_sidebyside_hint": "در کنار AmneziaDNS با یک IP داخلی جداگانه (172.29.172.253) اجرا می‌شود",
"adguard_mode_replace": "جایگزینی AmneziaDNS",
"adguard_mode_replace_hint": "amnezia-dns را حذف می‌کند و IP آن را می‌گیرد — کلاینت‌های VPN بلافاصله از AdGuard استفاده می‌کنند",
"adguard_dns_port": "پورت DNS (53)",
"adguard_dns_port_hint": "پورت داخلی DNS که از طریق شبکهٔ docker به کلاینت‌های VPN سرویس می‌دهد.",
"adguard_web_port": "پورت رابط وب",
"adguard_dot_port": "پورت DoT",
"adguard_doh_port": "پورت DoH",
"adguard_expose_web": "دسترسی از اینترنت",
"adguard_expose": "دسترسی از میزبان",
"adguard_install_hint": "پس از نصب، راه‌اندازی اولیهٔ AdGuard از طریق راهنمای گام‌به‌گام داخلی آن در رابط وب انجام می‌شود.",
"adguard_internal_ip": "IP داخلی",
"adguard_web_ui": "نشانی مدیریت",
"adguard_open_ui": "باز کردن رابط وب",
"edit_server_title": "ویرایش سرور",
"edit_keep_credential": "برای حفظ مقدار فعلی، خالی بگذارید",
"server_saved": "تنظیمات سرور ذخیره شد",
"servers_reordered": "ترتیب سرورها تغییر کرد",
"ping_checking": "در حال بررسی اتصال...",
"offline": "آفلاین",
"api_tokens_title": "توکن‌های API",
"api_tokens_hint": "توکن‌های Bearer برای یکپارچه‌سازی با سرویس‌های خارجی. توکن را در سرآیند `Authorization: Bearer <token>` ارسال کنید. توکن‌ها دسترسی معادل مدیر دارند و اگر مالک آنها غیرفعال شود یا سطح دسترسی‌اش کاهش یابد، از کار می‌افتند.",
"api_tokens_empty": "هنوز توکن API وجود ندارد. برای اتصال پنل به یک سرویس خارجی، توکن ایجاد کنید.",
"api_tokens_create": "ایجاد توکن",
"api_tokens_create_title": "ایجاد توکن API",
"api_tokens_name_label": "نام",
"api_tokens_name_placeholder": "مثلاً اتصال به Linear، ربات CI، پایش",
"api_tokens_name_hint": "برای شناسایی این توکن در فهرست استفاده می‌شود. مقدار خود توکن برای شما تولید می‌شود.",
"api_tokens_name_required": "نام توکن الزامی است",
"api_tokens_created_title": "توکن ایجاد شد",
"api_tokens_value_label": "مقدار توکن",
"api_tokens_warning_title": "این تنها باری است که این توکن را می‌بینید.",
"api_tokens_warning_body": "اکنون آن را کپی کنید و در سامانهٔ مدیریت اسرار سرویس خود نگه دارید. پنل فقط هش را ذخیره می‌کند — اگر این مقدار را گم کنید، باید توکن را باطل و دوباره ایجاد کنید.",
"api_tokens_revoke": "ابطال",
"api_tokens_revoke_confirm": "توکن «{}» باطل شود؟ هر سرویسی که از آن استفاده می‌کند از کار خواهد افتاد.",
"api_tokens_revoked": "توکن باطل شد",
"api_tokens_created": "زمان ایجاد",
"api_tokens_last_used": "آخرین استفاده",
"api_tokens_owner": "مالک",
"api_tokens_never": "هرگز",
"coming_soon": "به‌زودی",
"promo_star_cta": "در GitHub به ما ستاره بدهید",
"aivpn_subtitle": "انتخاب پروتکل با هوش مصنوعی، تونل مناسب را برای هر لحظه برمی‌گزیند. برای عرضهٔ زودتر، به ما ستاره بدهید.",
"revproxy_title": "پروکسی معکوس",
"revproxy_subtitle": "مبهم‌سازی ترافیک با کارایی بالا: نقطهٔ اتصال VPN خود را پشت یک وب‌سایت عمومی آراسته پنهان کنید و ردپای قابل شناسایی توسط DPI را کاهش دهید.",
"warp_hint": "WARP ترافیک خروجی این میزبان را از Cloudflare عبور می‌دهد. برخلاف Quick Tunnel یا ngrok، نشانی عمومی برای پنل ایجاد نمی‌کند.",
"warp_install_hint": "ابتدا کلاینت رسمی Cloudflare WARP را نصب کنید، سپس پنل را دوباره راه‌اندازی کنید.",
"warp_install_required": "ابتدا WARP را نصب کنید",
"warp_connect": "اتصال WARP",
"warp_disconnect": "قطع اتصال",
"warp_connected": "WARP متصل شد",
"warp_disconnected": "اتصال WARP قطع شد",
"warp_status_unknown": "وضعیت WARP نامشخص است.",
"warp_status_not_installed": "ابزار خط فرمان Cloudflare WARP روی این میزبان نصب نیست.",
"warp_status_connected": "Cloudflare WARP متصل است.",
"warp_status_connecting": "Cloudflare WARP در حال اتصال است.",
"warp_status_disconnected": "Cloudflare WARP قطع است.",
"warp_status_not_registered": "Cloudflare WARP نصب شده اما هنوز ثبت نشده است. با زدن اتصال، ثبت خودکار انجام خواهد شد.",
"warp_status_service_unavailable": "سرویس Cloudflare WARP در دسترس نیست. مطمئن شوید warp-svc در حال اجراست.",
"warp_status_error": "Cloudflare WARP خطایی برگرداند.",
"install_starting": "در حال آغاز نصب...",
"server_check_complete": "بررسی تکمیل شد",
"server_connection_error": "خطای اتصال",
"connections_load_error": "خطا در بارگذاری اتصال‌ها",
"connection_updated": "اتصال به‌روز شد",
"edit_connection": "ویرایش اتصال",
"enable_connection_progress": "در حال فعال‌سازی اتصال...",
"disable_connection_progress": "در حال غیرفعال‌سازی اتصال...",
"connection_enabled": "اتصال فعال شد",
"connection_disabled": "اتصال غیرفعال شد",
"gb": "GB",
"marketplace": "Templates",
"open_marketplace": "Open Templates",
"marketplace_desc": "Install protocol and service templates available for this server.",
"installed_apps": "Installed applications",
"installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.",
"no_installed_apps": "No installed applications yet",
"no_installed_apps_desc": "Open Templates to install protocols or services on this server.",
"install_another": "Install another",
"new_instance": "new instance",
"port_next_instance_hint": "Choose a free UDP port for the additional instance.",
"web_servers": "Web servers",
"nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.",
"nginx_domain": "Domain",
"nginx_email": "Let's Encrypt email",
"nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.",
"marketplace": "قالب‌ها",
"open_marketplace": "باز کردن قالب‌ها",
"marketplace_desc": "قالب‌های پروتکل و سرویس موجود برای این سرور را نصب کنید.",
"installed_apps": "برنامه‌های نصب‌شده",
"installed_apps_hint": "فقط پروتکل‌ها و سرویس‌های نصب‌شده در اینجا نمایش داده می‌شوند. برای افزودن موارد بیشتر، از قالب‌ها استفاده کنید.",
"no_installed_apps": "هنوز برنامه‌ای نصب نشده است",
"no_installed_apps_desc": "برای نصب پروتکل‌ها یا سرویس‌ها روی این سرور، قالب‌ها را باز کنید.",
"install_another": "نصب مورد دیگر",
"new_instance": "نمونهٔ جدید",
"port_next_instance_hint": "برای نمونهٔ اضافی، یک پورت UDP آزاد انتخاب کنید.",
"web_servers": "وب‌سرورها",
"nginx_desc": "وب‌سرور NGINX با گواهی HTTPS از Let's Encrypt و فایل index.html قابل ویرایش.",
"nginx_domain": "دامنه",
"nginx_email": "ایمیل Let's Encrypt",
"nginx_port_hint": "پورت HTTPS در دسترس روی سرور. برای اعتبارسنجی Let's Encrypt، پورت 80/TCP باید آزاد و قابل دسترسی باشد.",
"nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used for HTTP-01 certificate validation.",
"site": "Site",
"site_editor": "Site editor",
"site_saved": "Site saved",
"nginx_dns_hint": "Create this DNS record before installation:",
"backup": "Backup",
"backups": "Backups",
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
"create_backup": "Create backup",
"creating_backup": "Creating backup...",
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh",
"site": "سایت",
"site_editor": "ویرایشگر سایت",
"site_saved": "سایت ذخیره شد",
"nginx_dns_hint": "پیش از نصب، این رکورد DNS را ایجاد کنید:",
"backup": "نسخهٔ پشتیبان",
"backups": "نسخه‌های پشتیبان",
"backup_desc": "نسخه‌های پشتیبان روی سرور ایجاد می‌شوند و حاوی فایل‌های مهم پروتکل هستند که برای بازیابی یا ایجاد دوبارهٔ آن در آینده لازمند.",
"create_backup": "ایجاد نسخهٔ پشتیبان",
"creating_backup": "در حال ایجاد نسخهٔ پشتیبان...",
"backup_created": "نسخهٔ پشتیبان ایجاد شد",
"no_backups": "هنوز نسخهٔ پشتیبانی وجود ندارد",
"no_backups_desc": "اولین نسخهٔ پشتیبان این پروتکل را ایجاد کنید.",
"refresh": "تازه‌سازی",
"rename": "تغییر نام",
"connection_renamed": "اتصال تغییر نام یافت",
"enter_connection_name": "نام جدید اتصال را وارد کنید",
@@ -445,85 +445,85 @@
"max_speed": "حداکثر سرعت (Mbps)",
"speed_limit_unlim": "محدودیت سرعت (0 = نامحدود)",
"port_in_use_by": "پورت {port} قبلاً توسط {owner} استفاده شده است. پورت دیگری انتخاب کنید.",
"exit_desc": "Exit node: a transit endpoint for other panel servers. Client traffic that enters elsewhere leaves to the internet from this server.",
"exit_port_hint": "UDP port the transit link listens on. Only other panel servers connect here, never end users. Open it in the server's firewall for the entry nodes.",
"exit_subnet": "Transit subnet",
"exit_subnet_hint": "Private IPv4 /24 shared between this exit and its entry nodes. Must not overlap the client subnets (10.8.x.x).",
"exit_obfuscation": "Obfuscate the inter-node link (AmneziaWG)",
"exit_obfuscation_short": "Obfuscation",
"exit_obfuscation_hint": "Enable when the hop between the nodes crosses DPI. Plain WireGuard otherwise. AmneziaWG Legacy entries can only use a non-obfuscated exit.",
"exit_peers": "Peers",
"exit_peers_title": "Exit node — linked entry nodes",
"exit_peer_name": "Entry node",
"exit_transit_ip": "Transit IP",
"exit_public_key": "Public key",
"exit_handshake": "Handshake",
"exit_transfer": "Transfer",
"exit_peer_external": "not managed by this panel",
"exit_peer_remove_confirm": "Remove this peer? The entry node behind it loses its route through this exit.",
"exit_peer_removed": "Peer removed",
"exit_no_peers": "No entry nodes are linked to this exit yet.",
"exit_stop_confirm": "Entry nodes route their clients through this exit. Stopping it puts those clients on the kill-switch (no traffic). Stop anyway?",
"exit_in_use": "Entry nodes route through this exit node",
"exit_link_title": "Exit node",
"exit_link_desc": "Route all clients of this instance through another panel server. The link between the nodes is encrypted; clients keep their configs. IPv4 only: while linked, client IPv6 is refused rather than leaking from this server.",
"exit_link_select": "Exit node",
"exit_link_none": "Traffic leaves directly from this server.",
"exit_link_current": "Linked to {name} (transit {ip})",
"exit_link_via": "Exit node",
"exit_link_btn": "Link",
"exit_unlink_btn": "Unlink",
"exit_unlink_confirm": "Unlink from the exit node? Clients will leave to the internet from this server again.",
"exit_link_saved": "Exit node linked",
"exit_unlinked": "Exit node unlinked",
"exit_link_stale": "The exit node is unreachable or was removed. Clients are blocked by the kill-switch until you unlink or repair the link.",
"exit_link_repair": "Repair link",
"exit_no_exit_nodes": "No exit nodes installed yet. Install the Exit Node service on the server that should be the egress.",
"exit_no_handshake": "No handshake from the exit node within 15 s — is the transit UDP port reachable? The link was rolled back.",
"exit_apply_failed": "The routing could not be applied on this server; the link was rolled back.",
"exit_not_found": "Exit node not found or not installed.",
"not_linked": "This instance is not linked to an exit node.",
"exit_mtu_too_big": "The client MTU of this instance exceeds the link MTU (1420). Lower it in the AWG settings (1376 is the default).",
"exit_link_force": "Link anyway",
"exit_self": "A server cannot be its own exit node.",
"exit_legacy_obfuscation": "AmneziaWG Legacy can only link to a non-obfuscated exit node.",
"exit_link_ipv6_warning": "This instance is dual-stack. While linked, client IPv6 is refused so nothing leaks from this server; clients fall back to IPv4 automatically.",
"exit_link_ipv6_confirm": "Client IPv6 will be refused while linked. Continue?",
"exit_link_mtu_warning": "Client MTU {mtu} exceeds the link MTU {exit_mtu}. Lower it in the AWG settings before linking.",
"exit_status_up": "up",
"exit_status_stale": "no handshake",
"exit_status_down": "down",
"exit_handshake_ago": "handshake {s}s ago",
"no_handshake_yet": "No handshake yet — the link stays up, check the exit's firewall.",
"old_exit_unreachable_orphan_peer": "The previous exit node was unreachable; its peer entry was left behind.",
"exit_unreachable_orphan_peer": "The exit node was unreachable; its peer entry was left behind.",
"exit_egress_check": "Check egress",
"exit_egress_checking": "Checking the egress IP…",
"exit_egress_match": "✓ Clients leave from {ip} — the exit node {name}.",
"exit_egress_mismatch": "⚠ Clients leave from {ip}, the exit node address is {expected}. Expected when the exit is behind NAT.",
"exit_egress_direct": "✗ Clients still leave from this server ({ip}) — their traffic is not going through the link.",
"exit_egress_unknown": "Could not determine the egress IP: the check services did not answer.",
"exit_egress_failed": "The egress check could not run on this server.",
"exit_dns_via_exit": "Resolve client DNS at the exit node",
"exit_dns_via_exit_hint": "Off by default: DNS queries stay on this server. On, they follow the link and are answered by the AmneziaDNS of the exit node, so a resolver never sees this server's country. Requires AmneziaDNS on the exit node.",
"exit_dns_via_exit_on": "Client DNS now resolves at the exit node",
"exit_dns_via_exit_off": "Client DNS resolves on this server again",
"exit_dns_needs_dns": "Install AmneziaDNS on the exit node before routing client DNS through it.",
"awg_mtu_exit_hint": "This instance is linked to an exit node: the link carries at most {exit_mtu}, so keep the client MTU at or below it.",
"exit_dns_disabled_no_dns": "The exit node has no AmneziaDNS any more — client DNS was brought back to this server.",
"exit_defaults_title": "Exit nodes",
"exit_default_node": "Default exit node",
"exit_default_none": "None — new instances keep their own egress",
"exit_default_hint": "Every AmneziaWG instance installed from now on is linked to this exit node right after the install. Existing instances are left alone, and an install still succeeds if the link fails.",
"exit_default_cleared": "The default exit node no longer exists and was cleared.",
"exit_restore_entries_relinked": "Restored the exit-node peers of {n} linked entry node(s).",
"exit_restore_entries_failed": "{n} entry node(s) could not be re-registered on this exit — repair them from their own page.",
"exit_restore_relinked": "The exit-node link of this instance was re-established after the restore.",
"exit_restore_stale": "The exit-node link could not be re-established after the restore — clients stay on the kill-switch until you repair or remove it.",
"exit_restore_removed": "The archive carried an exit-node link this panel does not track; it was removed and clients leave from this server again.",
"exit_restore_orphan": "The archive carried an unknown exit-node link and it could not be removed — check this instance manually.",
"on": "on",
"off": "off",
"exit_desc": "گره خروجی: نقطهٔ واسط برای دیگر سرورهای پنل. ترافیک کلاینت‌هایی که از جای دیگری وارد می‌شود، از این سرور به اینترنت خارج می‌شود.",
"exit_port_hint": "پورت UDP که پیوند واسط روی آن گوش می‌دهد. فقط دیگر سرورهای پنل به اینجا متصل می‌شوند، نه کاربران نهایی. این پورت را در دیوارهٔ آتش سرور برای گره‌های ورودی باز کنید.",
"exit_subnet": "زیرشبکهٔ واسط",
"exit_subnet_hint": "زیرشبکهٔ خصوصی IPv4 با پیشوند /24 که میان این گره خروجی و گره‌های ورودی آن مشترک است. نباید با زیرشبکه‌های کلاینت‌ها (10.8.x.x) همپوشانی داشته باشد.",
"exit_obfuscation": "مبهم‌سازی پیوند بین گره‌ها (AmneziaWG)",
"exit_obfuscation_short": "مبهم‌سازی",
"exit_obfuscation_hint": "اگر مسیر بین گره‌ها از DPI عبور می‌کند، فعال کنید. در غیر این صورت از WireGuard معمولی استفاده می‌شود. گره‌های ورودی AmneziaWG Legacy فقط می‌توانند از گره خروجی بدون مبهم‌سازی استفاده کنند.",
"exit_peers": "همتاها",
"exit_peers_title": "گره خروجی — گره‌های ورودی پیوند‌شده",
"exit_peer_name": "گره ورودی",
"exit_transit_ip": "IP واسط",
"exit_public_key": "کلید عمومی",
"exit_handshake": "دست‌دهی",
"exit_transfer": "انتقال",
"exit_peer_external": "توسط این پنل مدیریت نمی‌شود",
"exit_peer_remove_confirm": "این همتا حذف شود؟ گره ورودی پشت آن، مسیر خود از طریق این گره خروجی را از دست می‌دهد.",
"exit_peer_removed": "همتا حذف شد",
"exit_no_peers": "هنوز هیچ گره ورودی به این گره خروجی پیوند نشده است.",
"exit_stop_confirm": "گره‌های ورودی ترافیک کلاینت‌های خود را از این گره خروجی عبور می‌دهند. توقف آن، قطع اضطراری را برای این کلاینت‌ها فعال می‌کند (بدون ترافیک). با این حال متوقف شود؟",
"exit_in_use": "گره‌های ورودی از این گره خروجی استفاده می‌کنند",
"exit_link_title": "گره خروجی",
"exit_link_desc": "ترافیک همهٔ کلاینت‌های این نمونه را از یک سرور دیگر پنل عبور دهید. پیوند بین گره‌ها رمزنگاری می‌شود؛ پیکربندی کلاینت‌ها تغییر نمی‌کند. فقط IPv4: تا زمانی که پیوند برقرار است، IPv6 کلاینت‌ها مسدود می‌شود تا از این سرور نشت نکند.",
"exit_link_select": "گره خروجی",
"exit_link_none": "ترافیک مستقیماً از این سرور خارج می‌شود.",
"exit_link_current": "پیوند‌شده به {name} (IP واسط {ip})",
"exit_link_via": "گره خروجی",
"exit_link_btn": "برقراری پیوند",
"exit_unlink_btn": "قطع پیوند",
"exit_unlink_confirm": "پیوند با گره خروجی قطع شود؟ کلاینت‌ها دوباره از این سرور به اینترنت متصل خواهند شد.",
"exit_link_saved": "پیوند با گره خروجی برقرار شد",
"exit_unlinked": "پیوند با گره خروجی قطع شد",
"exit_link_stale": "گره خروجی در دسترس نیست یا حذف شده است. تا زمانی که پیوند را قطع یا تعمیر نکنید، قطع اضطراری ترافیک کلاینت‌ها را مسدود می‌کند.",
"exit_link_repair": "تعمیر پیوند",
"exit_no_exit_nodes": "هنوز گره خروجی نصب نشده است. سرویس گره خروجی را روی سروری نصب کنید که باید محل خروج ترافیک باشد.",
"exit_no_handshake": "ظرف 15 ثانیه هیچ دست‌دهی از گره خروجی دریافت نشد — آیا پورت UDP واسط قابل دسترسی است؟ تغییرات پیوند بازگردانده شد.",
"exit_apply_failed": "مسیریابی روی این سرور اعمال نشد؛ تغییرات پیوند بازگردانده شد.",
"exit_not_found": "گره خروجی یافت نشد یا نصب نیست.",
"not_linked": "این نمونه به هیچ گره خروجی پیوند نشده است.",
"exit_mtu_too_big": "MTU کلاینت این نمونه از MTU پیوند (1420) بیشتر است. آن را در تنظیمات AWG کاهش دهید (مقدار پیش‌فرض 1376 است).",
"exit_link_force": "برقراری پیوند در هر صورت",
"exit_self": "یک سرور نمی‌تواند گره خروجی خودش باشد.",
"exit_legacy_obfuscation": "AmneziaWG Legacy فقط می‌تواند به گره خروجی بدون مبهم‌سازی پیوند شود.",
"exit_link_ipv6_warning": "این نمونه دوپشته‌ای است. تا زمانی که پیوند برقرار است، IPv6 کلاینت‌ها مسدود می‌شود تا چیزی از این سرور نشت نکند؛ کلاینت‌ها به‌طور خودکار از IPv4 استفاده می‌کنند.",
"exit_link_ipv6_confirm": "تا زمانی که پیوند برقرار است، IPv6 کلاینت‌ها مسدود خواهد شد. ادامه می‌دهید؟",
"exit_link_mtu_warning": "MTU کلاینت {mtu} از MTU پیوند {exit_mtu} بیشتر است. پیش از برقراری پیوند، آن را در تنظیمات AWG کاهش دهید.",
"exit_status_up": "برقرار",
"exit_status_stale": "بدون دست‌دهی",
"exit_status_down": "قطع",
"exit_handshake_ago": "دست‌دهی {s} ثانیه پیش",
"no_handshake_yet": "هنوز دست‌دهی انجام نشده است — پیوند برقرار می‌ماند؛ دیوارهٔ آتش گره خروجی را بررسی کنید.",
"old_exit_unreachable_orphan_peer": "گره خروجی قبلی در دسترس نبود؛ رکورد همتای آن باقی ماند.",
"exit_unreachable_orphan_peer": "گره خروجی در دسترس نبود؛ رکورد همتای آن باقی ماند.",
"exit_egress_check": "بررسی خروج ترافیک",
"exit_egress_checking": "در حال بررسی IP خروجی…",
"exit_egress_match": "✓ کلاینت‌ها از {ip} خارج می‌شوند — گره خروجی {name}.",
"exit_egress_mismatch": "⚠ کلاینت‌ها از {ip} خارج می‌شوند، اما نشانی گره خروجی {expected} است. اگر گره خروجی پشت NAT باشد، این وضعیت طبیعی است.",
"exit_egress_direct": "✗ کلاینت‌ها همچنان از این سرور ({ip}) خارج می‌شوند — ترافیک آنها از پیوند عبور نمی‌کند.",
"exit_egress_unknown": "IP خروجی مشخص نشد: سرویس‌های بررسی پاسخ ندادند.",
"exit_egress_failed": "بررسی خروج ترافیک روی این سرور اجرا نشد.",
"exit_dns_via_exit": "پاسخ‌گویی به DNS کلاینت‌ها در گره خروجی",
"exit_dns_via_exit_hint": "به‌طور پیش‌فرض خاموش است: درخواست‌های DNS روی این سرور می‌مانند. با روشن کردن، درخواست‌ها از پیوند عبور می‌کنند و AmneziaDNS گره خروجی به آنها پاسخ می‌دهد؛ بنابراین حل‌کنندهٔ DNS هرگز کشور این سرور را نمی‌بیند. نیازمند AmneziaDNS روی گره خروجی است.",
"exit_dns_via_exit_on": "اکنون DNS کلاینت‌ها در گره خروجی پاسخ داده می‌شود",
"exit_dns_via_exit_off": "DNS کلاینت‌ها دوباره روی این سرور پاسخ داده می‌شود",
"exit_dns_needs_dns": "پیش از هدایت DNS کلاینت‌ها از طریق گره خروجی، AmneziaDNS را روی آن نصب کنید.",
"awg_mtu_exit_hint": "این نمونه به گره خروجی پیوند شده است: حداکثر MTU پیوند {exit_mtu} است؛ بنابراین MTU کلاینت را برابر یا کمتر از آن نگه دارید.",
"exit_dns_disabled_no_dns": "گره خروجی دیگر AmneziaDNS ندارد — DNS کلاینت‌ها به این سرور بازگردانده شد.",
"exit_defaults_title": "گره‌های خروجی",
"exit_default_node": "گره خروجی پیش‌فرض",
"exit_default_none": "هیچ‌کدام — نمونه‌های جدید مسیر خروجی خود را حفظ می‌کنند",
"exit_default_hint": "هر نمونهٔ AmneziaWG که از این پس نصب شود، بلافاصله پس از نصب به این گره خروجی پیوند می‌شود. نمونه‌های موجود تغییری نمی‌کنند و حتی اگر پیوند ناموفق باشد، نصب با موفقیت انجام می‌شود.",
"exit_default_cleared": "گره خروجی پیش‌فرض دیگر وجود ندارد و از تنظیمات حذف شد.",
"exit_restore_entries_relinked": "همتاهای گره خروجی برای {n} گره ورودی پیوند‌شده بازیابی شدند.",
"exit_restore_entries_failed": "ثبت مجدد {n} گره ورودی روی این گره خروجی ممکن نشد — آنها را از صفحهٔ خودشان تعمیر کنید.",
"exit_restore_relinked": "پیوند این نمونه با گره خروجی پس از بازیابی دوباره برقرار شد.",
"exit_restore_stale": "پیوند با گره خروجی پس از بازیابی دوباره برقرار نشد — تا زمانی که آن را تعمیر یا حذف نکنید، ترافیک کلاینت‌ها با قطع اضطراری مسدود می‌ماند.",
"exit_restore_removed": "بایگانی حاوی پیوندی با گره خروجی بود که این پنل آن را ردیابی نمی‌کند؛ پیوند حذف شد و کلاینت‌ها دوباره از این سرور خارج می‌شوند.",
"exit_restore_orphan": "بایگانی حاوی پیوندی ناشناخته با گره خروجی بود که حذف آن ممکن نشد — این نمونه را دستی بررسی کنید.",
"on": "روشن",
"off": "خاموش",
"restore": "بازیابی",
"restore_confirm_protocol": "آیا مطمئن هستید؟ پیکربندی فعلی پروتکل با این پشتیبان جایگزین خواهد شد.",
"delete_backup_confirm": "این پشتیبان از سرور حذف شود؟ این عمل قابل بازگشت نیست.",
@@ -531,22 +531,22 @@
"upload_backup": "بارگذاری",
"uploading_backup": "در حال بارگذاری...",
"backup_uploaded": "پشتیبان بارگذاری شد",
"wgeasy_import": "Import from wg-easy",
"wgeasy_import_title": "Import from wg-easy / amnezia-wg-easy",
"wgeasy_web_port": "wg-easy web UI port (on this server)",
"wgeasy_password": "wg-easy admin password",
"wgeasy_username": "Username (v15 only)",
"wgeasy_fetch": "Load clients",
"wgeasy_import_btn": "Import",
"wgeasy_import_warning": "The old wg-easy container will be stopped (not deleted). User configs keep working unchanged.",
"wgeasy_no_clients": "No clients selected",
"wgeasy_confirm": "Import selected clients? The old wg-easy container will be stopped.",
"wgeasy_importing": "Importing, this may take a few minutes...",
"wgeasy_target_wg": "target: WireGuard (no obfuscation)",
"wgeasy_target_awg": "obfuscation detected → target: AmneziaWG 2.0",
"rename_instance": "Rename instance",
"instance_name": "Instance name",
"rename_instance_hint": "Leave empty to restore the default name.",
"wgeasy_import": "درون‌ریزی از wg-easy",
"wgeasy_import_title": "درون‌ریزی از wg-easy / amnezia-wg-easy",
"wgeasy_web_port": "پورت رابط وب wg-easy (روی این سرور)",
"wgeasy_password": "گذرواژهٔ مدیر wg-easy",
"wgeasy_username": "نام کاربری (فقط v15)",
"wgeasy_fetch": "بارگذاری کلاینت‌ها",
"wgeasy_import_btn": "درون‌ریزی",
"wgeasy_import_warning": "کانتینر قدیمی wg-easy متوقف می‌شود (حذف نمی‌شود). پیکربندی کاربران بدون تغییر به کار خود ادامه می‌دهد.",
"wgeasy_no_clients": "هیچ کلاینتی انتخاب نشده است",
"wgeasy_confirm": "کلاینت‌های انتخاب‌شده درون‌ریزی شوند؟ کانتینر قدیمی wg-easy متوقف خواهد شد.",
"wgeasy_importing": "در حال درون‌ریزی، ممکن است چند دقیقه طول بکشد...",
"wgeasy_target_wg": "مقصد: WireGuard (بدون مبهم‌سازی)",
"wgeasy_target_awg": "مبهم‌سازی شناسایی شد → مقصد: AmneziaWG 2.0",
"rename_instance": "تغییر نام نمونه",
"instance_name": "نام نمونه",
"rename_instance_hint": "برای بازگرداندن نام پیش‌فرض، خالی بگذارید.",
"no_expiry": "بدون انقضا",
"telemt_secret_label": "رمز (hex)",
"telemt_ad_tag_label": "تگ AD (hex)",
@@ -586,5 +586,104 @@
"peer_poll_title": "به‌روزرسانی زنده پیرها",
"peer_poll_hint": "هر چند ثانیه فهرست پیرها خودکار تازه شود. 0 = خاموش (پیش‌فرض). در سرورهای سریع 5–10 کافی است؛ در سرورهای کند 300–600 بگذارید یا خاموش بماند.",
"peer_poll_off": "خاموش",
"peer_poll_sec": "ثانیه"
"peer_poll_sec": "ثانیه",
"wireguard_desc": "پروتکل استاندارد و سریع VPN. به‌صورت بومی در همهٔ سیستم‌عامل‌های جدید پشتیبانی می‌شود، اما DPI به‌راحتی آن را شناسایی می‌کند.",
"expiration_date_label": "تاریخ انقضا",
"tls_emulation": "شبیه‌سازی TLS",
"tls_domain": "دامنهٔ پوششی",
"max_connections_limit": "حداکثر اتصال‌ها",
"max_connections_hint": "برای نامحدود بودن، 0 وارد کنید. تعداد کاربران همزمان را محدود می‌کند.",
"traffic_reset_strategy_label": "روش بازنشانی ترافیک",
"traffic_reset_never": "بدون بازنشانی",
"traffic_reset_daily": "بازنشانی روزانه",
"traffic_reset_weekly": "بازنشانی هفتگی",
"traffic_reset_monthly": "بازنشانی ماهانه",
"traffic_reset_hint": "ترافیک مصرفی کاربر هر چند وقت یک‌بار بازنشانی شود",
"traffic_used_total": "کل مصرف",
"traffic_reset_info": "بازنشانی بعدی: {}",
"services": "سرویس‌ها",
"ssl_title": "تنظیمات SSL / HTTPS",
"enable_https": "فعال‌سازی HTTPS",
"panel_port_label": "پورت پنل",
"domain_label": "نام دامنه",
"cert_path_label": "مسیر گواهی SSL (.pem)",
"key_path_label": "مسیر کلید خصوصی (.pem)",
"or_paste_text": "یا متن را جایگذاری کنید",
"cert_text_label": "محتوای گواهی (CRT)",
"key_text_label": "محتوای کلید خصوصی (KEY)",
"ssl_hint": "پس از فعال‌سازی، پنل از طریق https:// روی دامنهٔ مشخص‌شده در دسترس خواهد بود. مطمئن شوید مسیر فایل‌ها یا متن وارد‌شده درست است.",
"about_title": "درباره و به‌روزرسانیها",
"current_version": "نسخهٔ فعلی",
"check_updates": "بررسی به‌روزرسانیها",
"checking_updates": "در حال بررسی...",
"update_available": "نسخهٔ جدید در دسترس است",
"download_update": "دریافت به‌روزرسانی",
"up_to_date": "آخرین نسخه را دارید",
"checking_server": "در حال بررسی سرویس‌های سرور...",
"mail_title": "ارسال ایمیل (SMTP)",
"mail_hint": "پیکربندی‌ها و پیوند‌های پروکسی از این صندوق ایمیل ارسال می‌شوند. گذرواژه در data.json ذخیره می‌شود؛ بنابراین از حسابی اختصاصی یا گذرواژهٔ برنامه استفاده کنید.",
"mail_enabled_label": "فعال‌سازی ارسال ایمیل",
"mail_host_label": "سرور SMTP",
"mail_port_label": "پورت",
"mail_security_label": "رمزنگاری",
"mail_security_none": "هیچ‌کدام",
"mail_security_starttls": "STARTTLS",
"mail_security_ssl": "SSL/TLS",
"mail_username_label": "نام کاربری",
"mail_password_label": "گذرواژه",
"mail_password_hint": "برای حفظ گذرواژهٔ ذخیره‌شده، خالی بگذارید؛ برای پاک کردن آن، یک فاصله وارد کنید.",
"mail_from_label": "نشانی فرستنده",
"mail_from_name_label": "نام فرستنده",
"mail_reply_to_label": "نشانی پاسخ (اختیاری)",
"mail_timeout_label": "مهلت انتظار (ثانیه)",
"mail_test_btn": "ارسال پیام آزمایشی",
"mail_test_recipient_label": "گیرندهٔ پیام آزمایشی",
"mail_test_sent": "پیام آزمایشی به {} ارسال شد",
"mail_send_btn": "ارسال با ایمیل",
"mail_send_title": "ارسال پیکربندی‌ها با ایمیل",
"mail_send_all_btn": "ارسال ایمیل به همهٔ کاربران",
"mail_send_all_title": "ارسال ایمیل به تک‌تک کاربران",
"mail_recipient_label": "گیرنده",
"mail_include_configs": "پیوست کردن فایل‌های پیکربندی",
"mail_include_links": "افزودن پیوند‌های اتصال",
"mail_subject_label": "موضوع",
"mail_subject_placeholder": "برای استفاده از موضوع پیش‌فرض، خالی بگذارید",
"mail_message_label": "پیام (اختیاری)",
"mail_message_placeholder": "چند کلمه که پیش از فهرست اتصال‌ها نمایش داده می‌شود",
"mail_only_enabled": "رد کردن کاربران غیرفعال",
"mail_skip_empty": "رد کردن کاربران بدون اتصال",
"mail_send_action": "ارسال",
"mail_sent_ok": "به {} ارسال شد",
"mail_bulk_started": "ارسال گروهی آغاز شد",
"mail_bulk_targets": "گیرندگان: همهٔ کاربران دارای نشانی ایمیل",
"mail_bulk_progress": "{} از {} پردازش شد",
"mail_bulk_summary": "ارسال‌شده: {}، رد‌شده: {}، خطاها: {}",
"mail_bulk_running_note": "می‌توانید این پنجره را ببندید — ارسال ادامه می‌یابد.",
"mail_no_email_hint": "این کاربر نشانی ایمیل ندارد",
"mail_status_sent": "ارسال شد",
"mail_status_skipped": "رد شد",
"mail_status_failed": "خطا",
"mail_greeting": "سلام، {}!",
"mail_default_subject": "دسترسی VPN شما",
"mail_intro_connections": "اتصال‌های شما در زیر آمده‌اند. فایل پیوست را در برنامهٔ VPN وارد کنید یا پیوند را روی دستگاه خود باز کنید.",
"mail_link_label": "پیوند",
"mail_file_label": "فایل پیوست",
"mail_no_connections_note": "هنوز هیچ اتصالی در حساب شما وجود ندارد — لطفاً با مدیر خود تماس بگیرید.",
"mail_footer": "این پیام به‌صورت خودکار توسط پنل VPN ارسال شده است. لطفاً به آن پاسخ ندهید.",
"mail_test_subject": "پیام آزمایشی از پنل VPN",
"mail_test_body": "تنظیمات SMTP درست کار می‌کند: این پیام آزمایشی توسط پنل VPN ارسال شده است.",
"mail_error_disabled": "ارسال ایمیل در تنظیمات غیرفعال است",
"mail_error_no_host": "سرور SMTP مشخص نشده است",
"mail_error_no_sender": "نشانی فرستنده وارد نشده یا نامعتبر است",
"mail_error_bad_security": "روش رمزنگاری ناشناخته است",
"mail_error_no_recipient": "نشانی گیرندهٔ معتبری وجود ندارد",
"mail_error_no_recipients": "هیچ کاربری با نشانی ایمیل وجود ندارد",
"mail_error_no_connections": "این کاربر اتصالی برای ارسال ندارد",
"mail_error_user_not_found": "کاربر یافت نشد",
"mail_error_nothing_to_send": "فایل‌ها، پیوند‌ها یا هر دو را انتخاب کنید",
"mail_error_bulk_running": "یک ارسال گروهی از قبل در حال انجام است",
"mail_skip_no_email": "بدون نشانی ایمیل",
"mail_skip_bad_email": "نشانی ایمیل نامعتبر",
"mail_skip_disabled": "کاربر غیرفعال است",
"mail_test_hint": "آزمایش از تنظیمات ذخیره‌شده استفاده می‌کند — ابتدا ذخیره را بزنید."
}
+310 -211
View File
@@ -114,7 +114,7 @@
"connection_created": "Connexion \"{}\" créée",
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
"connection_deleted": "Connexion supprimée",
"self_service_title": "Self-Service",
"self_service_title": "Libre-service",
"self_service_enabled": "Activer le self-service",
"self_service_enabled_desc": "Permettre aux utilisateurs de créer et gérer leurs propres connexions VPN",
"self_service_web": "Activer le self-service web",
@@ -130,7 +130,7 @@
"connections_counter": "Connexions : {0} / {1}",
"device_name": "Nom du périphérique",
"self_service_disabled": "Le self-service n'est pas activé.",
"config_tab": "📄 Config",
"config_tab": "📄 Configuration",
"vpn_key_tab": "🔑 Clé-VPN",
"qr_code_tab": "📱 Code-QR",
"copy_config": "📋 Copier",
@@ -143,9 +143,9 @@
"qr_format_amnezia_hint": "Série animée pour AmneziaVPN, transmet le nom du serveur. Gardez la caméra jusqu'à ce que toutes les images soient lues —",
"qr_format_wireguard_hint": "La configuration ou le lien tel quel, pour AmneziaWG, wg-quick et d'autres clients. Le nom du serveur n'est pas transmis.",
"no_data": "Aucune donnée",
"start_btn": "▶ Start",
"stop_btn": "⏹ Stop",
"config_btn": "⚙️ Config",
"start_btn": "▶ Démarrer",
"stop_btn": "⏹ Arrêter",
"config_btn": "⚙️ Configuration",
"done": "Terminé",
"installing": "Installation...",
"start_install": "Début de l'installation...",
@@ -171,9 +171,9 @@
"role_support_desc": "Support — gestion des serveurs",
"role_admin_desc": "Admin — accès complet",
"tg_id_label": "ID Telegram (opt.)",
"email_label": "Email (opt.)",
"email_label": "E-mail (facultatif)",
"traffic_limit_label": "Limite trafic (Go) (0 = illimité)",
"description_label": "Description (opt.)",
"description_label": "Description (facultatif)",
"auto_conn_title": "Création auto de connexion (optionnel)",
"server_label": "Serveur",
"no_create_conn": "— Ne pas créer —",
@@ -287,14 +287,14 @@
"lang_ru": "Русский",
"lang_en": "English",
"lang_fr": "Français",
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"lang_zh": "中文 (chinois)",
"lang_fa": "فارسی (persan)",
"backup_title": "Sauvegarde Simple",
"download_backup": "Télécharger data.json",
"restore_backup": "Restaurer depuis un fichier",
"restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l'application redémarrera.",
"restore_success": "Sauvegarde restaurée avec succès !",
"auto_backup_title": "Auto backup",
"auto_backup_title": "Sauvegarde automatique",
"enable_auto_backup": "Activer les sauvegardes automatiques des serveurs",
"auto_backup_interval_label": "Intervalle de création",
"auto_backup_hint": "Le panneau créera des sauvegardes des protocoles installés sur chaque serveur selon l'intervalle choisi.",
@@ -315,102 +315,102 @@
"remove_server": "Retirer le serveur du panneau",
"telemt_desc": "Proxy Telegram basé sur MTProxy avec obfuscation avancée.",
"dns_desc": "Serveur DNS personnel pour bloquer les publicités et protéger la vie privée.",
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
"socks5_username": "Username",
"socks5_password": "Password",
"socks5_password_placeholder": "Leave empty to auto-generate",
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
"socks5_change_settings": "Change settings",
"socks5_settings_title": "SOCKS5 — Connection settings",
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
"socks5_settings_saved": "SOCKS5 settings saved",
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
"adguard_mode": "Installation mode",
"adguard_mode_sidebyside": "Side-by-side",
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
"adguard_mode_replace": "Replace AmneziaDNS",
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
"adguard_dns_port": "DNS port (53)",
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
"adguard_web_port": "Web UI port",
"adguard_dot_port": "DoT port",
"adguard_doh_port": "DoH port",
"adguard_expose_web": "Expose to internet",
"adguard_expose": "Expose to host",
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
"adguard_internal_ip": "Internal IP",
"adguard_web_ui": "Admin URL",
"adguard_open_ui": "Open Web UI",
"edit_server_title": "Edit server",
"edit_keep_credential": "Leave empty to keep the current value",
"server_saved": "Server settings saved",
"servers_reordered": "Servers reordered",
"ping_checking": "Checking connectivity...",
"offline": "offline",
"api_tokens_title": "API tokens",
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <token>` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.",
"api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.",
"api_tokens_create": "Create token",
"api_tokens_create_title": "Create API token",
"api_tokens_name_label": "Name",
"api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring",
"api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.",
"api_tokens_name_required": "Token name is required",
"api_tokens_created_title": "Token created",
"api_tokens_value_label": "Token value",
"api_tokens_warning_title": "This is the only time you'll see this token.",
"api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.",
"api_tokens_revoke": "Revoke",
"api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.",
"api_tokens_revoked": "Token revoked",
"api_tokens_created": "Created",
"api_tokens_last_used": "Last used",
"api_tokens_owner": "Owner",
"api_tokens_never": "never",
"coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub",
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
"warp_install_required": "Install WARP first",
"warp_connect": "Connect WARP",
"warp_disconnect": "Disconnect",
"warp_connected": "WARP connected",
"warp_disconnected": "WARP disconnected",
"warp_status_unknown": "WARP status is unknown.",
"warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.",
"warp_status_connected": "Cloudflare WARP is connected.",
"warp_status_connecting": "Cloudflare WARP is connecting.",
"warp_status_disconnected": "Cloudflare WARP is disconnected.",
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
"warp_status_error": "Cloudflare WARP returned an error.",
"install_starting": "Starting installation...",
"server_check_complete": "Check completed",
"server_connection_error": "Connection error",
"connections_load_error": "Error loading connections",
"connection_updated": "Connection updated",
"edit_connection": "Edit connection",
"enable_connection_progress": "Enabling connection...",
"disable_connection_progress": "Disabling connection...",
"connection_enabled": "Connection enabled",
"connection_disabled": "Connection disabled",
"gb": "GB",
"marketplace": "Templates",
"open_marketplace": "Open Templates",
"marketplace_desc": "Install protocol and service templates available for this server.",
"installed_apps": "Installed applications",
"installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.",
"no_installed_apps": "No installed applications yet",
"no_installed_apps_desc": "Open Templates to install protocols or services on this server.",
"install_another": "Install another",
"new_instance": "new instance",
"port_next_instance_hint": "Choose a free UDP port for the additional instance.",
"dns_internal_hint": "Le service fonctionne sur le réseau interne (172.29.172.254) sans liaison aux ports de l’hôte.",
"telemt_port_hint": "Port du proxy Telegram (généralement 443).",
"socks5_desc": "Proxy SOCKS5 à compte unique (3proxy) avec authentification par nom d’utilisateur et mot de passe.",
"socks5_username": "Nom d’utilisateur",
"socks5_password": "Mot de passe",
"socks5_password_placeholder": "Laissez ce champ vide pour générer un mot de passe automatiquement",
"socks5_password_hint": "Si ce champ reste vide, un mot de passe aléatoire de 16 caractères sera généré.",
"socks5_port_hint": "Port TCP sur lequel le proxy sera à l’écoute. La valeur par défaut dans le client officiel Amnezia est 38080.",
"socks5_change_settings": "Modifier les paramètres",
"socks5_settings_title": "SOCKS5 — Paramètres de connexion",
"socks5_port_change_hint": "Modifier le port recrée le conteneur ; les sessions existantes seront interrompues.",
"socks5_settings_saved": "Paramètres SOCKS5 enregistrés",
"adguard_desc": "AdGuard Home — Bloqueur de publicités basé sur le DNS avec une interface d’administration web.",
"adguard_mode": "Mode d’installation",
"adguard_mode_sidebyside": "Installation en parallèle",
"adguard_mode_sidebyside_hint": "fonctionne aux côtés d’AmneziaDNS sur une adresse IP interne distincte (172.29.172.253)",
"adguard_mode_replace": "Remplacer AmneziaDNS",
"adguard_mode_replace_hint": "supprime amnezia-dns et reprend son adresse IP : les clients VPN utilisent immédiatement AdGuard",
"adguard_dns_port": "Port DNS (53)",
"adguard_dns_port_hint": "Port DNS interne utilisé par les clients VPN via le réseau Docker.",
"adguard_web_port": "Port de l’interface web",
"adguard_dot_port": "Port DoT",
"adguard_doh_port": "Port DoH",
"adguard_expose_web": "Exposer sur Internet",
"adguard_expose": "Exposer sur l’hôte",
"adguard_install_hint": "Après l’installation, la configuration initiale d’AdGuard s’effectue à l’aide de son assistant intégré, dans l’interface web.",
"adguard_internal_ip": "Adresse IP interne",
"adguard_web_ui": "URL d’administration",
"adguard_open_ui": "Ouvrir l’interface web",
"edit_server_title": "Modifier le serveur",
"edit_keep_credential": "Laissez ce champ vide pour conserver la valeur actuelle",
"server_saved": "Paramètres du serveur enregistrés",
"servers_reordered": "Ordre des serveurs modifié",
"ping_checking": "Vérification de la connectivité...",
"offline": "hors ligne",
"api_tokens_title": "Jetons d’API",
"api_tokens_hint": "Jetons Bearer pour les intégrations externes. Envoyez le jeton dans l’en-tête `Authorization: Bearer <token>`. Les jetons disposent des mêmes droits qu’un administrateur et cessent de fonctionner si leur propriétaire est désactivé ou perd ses droits d’administration.",
"api_tokens_empty": "Aucun jeton d’API pour le moment. Créez-en un pour intégrer le panneau à un service externe.",
"api_tokens_create": "Créer un jeton",
"api_tokens_create_title": "Créer un jeton d’API",
"api_tokens_name_label": "Nom",
"api_tokens_name_placeholder": "Par ex. intégration Linear, bot CI, supervision",
"api_tokens_name_hint": "Permet d’identifier ce jeton dans la liste. La valeur du jeton est générée automatiquement.",
"api_tokens_name_required": "Le nom du jeton est obligatoire",
"api_tokens_created_title": "Jeton créé",
"api_tokens_value_label": "Valeur du jeton",
"api_tokens_warning_title": "Ce jeton ne sera affiché qu’une seule fois.",
"api_tokens_warning_body": "Copiez-le maintenant et conservez-le dans le gestionnaire de secrets de votre intégration. Le panneau ne conserve qu’une empreinte : si vous perdez cette valeur, vous devrez révoquer le jeton et en créer un nouveau.",
"api_tokens_revoke": "Révoquer",
"api_tokens_revoke_confirm": "Révoquer le jeton « {} » ? Toute intégration qui l’utilise cessera de fonctionner.",
"api_tokens_revoked": "Jeton révoqué",
"api_tokens_created": "Créé le",
"api_tokens_last_used": "Dernière utilisation",
"api_tokens_owner": "Propriétaire",
"api_tokens_never": "jamais",
"coming_soon": "Bientôt disponible",
"promo_star_cta": "Ajoutez une étoile à notre projet sur GitHub",
"aivpn_subtitle": "Une sélection de protocole pilotée par l’IA qui choisit le tunnel adapté à chaque instant. Accélérez son arrivée : ajoutez une étoile.",
"revproxy_title": "Proxy inverse",
"revproxy_subtitle": "Obfuscation du trafic haute performance : masquez votre point d’accès VPN derrière un site public soigné et réduisez les traces détectables par le DPI.",
"warp_hint": "WARP achemine le trafic sortant de cet hôte via Cloudflare. Il ne crée pas d’URL publique pour le panneau, contrairement à Quick Tunnel ou ngrok.",
"warp_install_hint": "Installez d’abord le client officiel Cloudflare WARP, puis redémarrez le panneau.",
"warp_install_required": "Installez d’abord WARP",
"warp_connect": "Connecter WARP",
"warp_disconnect": "Déconnecter",
"warp_connected": "WARP connecté",
"warp_disconnected": "WARP déconnecté",
"warp_status_unknown": "L’état de WARP est inconnu.",
"warp_status_not_installed": "L’outil en ligne de commande Cloudflare WARP n’est pas installé sur cet hôte.",
"warp_status_connected": "Cloudflare WARP est connecté.",
"warp_status_connecting": "Cloudflare WARP est en cours de connexion.",
"warp_status_disconnected": "Cloudflare WARP est déconnecté.",
"warp_status_not_registered": "Cloudflare WARP est installé, mais pas encore enregistré. La connexion tentera de l’enregistrer automatiquement.",
"warp_status_service_unavailable": "Le service Cloudflare WARP n’est pas disponible. Vérifiez que warp-svc est en cours d’exécution.",
"warp_status_error": "Cloudflare WARP a renvoyé une erreur.",
"install_starting": "Démarrage de l’installation...",
"server_check_complete": "Vérification terminée",
"server_connection_error": "Erreur de connexion",
"connections_load_error": "Erreur lors du chargement des connexions",
"connection_updated": "Connexion mise à jour",
"edit_connection": "Modifier la connexion",
"enable_connection_progress": "Activation de la connexion...",
"disable_connection_progress": "Désactivation de la connexion...",
"connection_enabled": "Connexion activée",
"connection_disabled": "Connexion désactivée",
"gb": "Go",
"marketplace": "Modèles",
"open_marketplace": "Ouvrir les modèles",
"marketplace_desc": "Installez les modèles de protocoles et de services disponibles pour ce serveur.",
"installed_apps": "Applications installées",
"installed_apps_hint": "Seuls les protocoles et services installés sont affichés ici. Utilisez les modèles pour en ajouter d’autres.",
"no_installed_apps": "Aucune application installée pour le moment",
"no_installed_apps_desc": "Ouvrez les modèles pour installer des protocoles ou des services sur ce serveur.",
"install_another": "Installer une autre instance",
"new_instance": "nouvelle instance",
"port_next_instance_hint": "Choisissez un port UDP libre pour l’instance supplémentaire.",
"web_servers": "Serveurs web",
"nginx_desc": "Serveur web NGINX avec certificat HTTPS Let's Encrypt et index.html modifiable.",
"nginx_domain": "Domaine",
@@ -421,15 +421,15 @@
"site_editor": "Éditeur du site",
"site_saved": "Site enregistré",
"nginx_dns_hint": "Creez cet enregistrement DNS avant l installation :",
"backup": "Backup",
"backups": "Backups",
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
"create_backup": "Create backup",
"creating_backup": "Creating backup...",
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh",
"backup": "Sauvegarde",
"backups": "Sauvegardes",
"backup_desc": "Les sauvegardes sont créées sur le serveur et contiennent les fichiers essentiels du protocole permettant de le restaurer ou de le recréer ultérieurement.",
"create_backup": "Créer une sauvegarde",
"creating_backup": "Création de la sauvegarde...",
"backup_created": "Sauvegarde créée",
"no_backups": "Aucune sauvegarde pour le moment",
"no_backups_desc": "Créez la première sauvegarde de ce protocole.",
"refresh": "Actualiser",
"rename": "Renommer",
"connection_renamed": "Connexion renommée",
"enter_connection_name": "Saisir un nouveau nom de connexion",
@@ -445,85 +445,85 @@
"max_speed": "Vitesse max (Mbps)",
"speed_limit_unlim": "Limite de vitesse (0 = illimité)",
"port_in_use_by": "Le port {port} est déjà utilisé par {owner}. Choisissez un autre port.",
"exit_desc": "Exit node: a transit endpoint for other panel servers. Client traffic that enters elsewhere leaves to the internet from this server.",
"exit_port_hint": "UDP port the transit link listens on. Only other panel servers connect here, never end users. Open it in the server's firewall for the entry nodes.",
"exit_subnet": "Transit subnet",
"exit_subnet_hint": "Private IPv4 /24 shared between this exit and its entry nodes. Must not overlap the client subnets (10.8.x.x).",
"exit_obfuscation": "Obfuscate the inter-node link (AmneziaWG)",
"exit_desc": "Nœud de sortie : un point de transit pour les autres serveurs du panneau. Le trafic des clients qui entre ailleurs rejoint Internet depuis ce serveur.",
"exit_port_hint": "Port UDP sur lequel la liaison de transit est à l’écoute. Seuls les autres serveurs du panneau s’y connectent, jamais les utilisateurs finaux. Ouvrez-le dans le pare-feu du serveur pour les nœuds d’entrée.",
"exit_subnet": "Sous-réseau de transit",
"exit_subnet_hint": "Sous-réseau IPv4 privé /24 partagé entre ce nœud de sortie et ses nœuds d’entrée. Il ne doit pas chevaucher les sous-réseaux des clients (10.8.x.x).",
"exit_obfuscation": "Obfusquer la liaison entre les nœuds (AmneziaWG)",
"exit_obfuscation_short": "Obfuscation",
"exit_obfuscation_hint": "Enable when the hop between the nodes crosses DPI. Plain WireGuard otherwise. AmneziaWG Legacy entries can only use a non-obfuscated exit.",
"exit_peers": "Peers",
"exit_peers_title": "Exit node — linked entry nodes",
"exit_peer_name": "Entry node",
"exit_transit_ip": "Transit IP",
"exit_public_key": "Public key",
"exit_handshake": "Handshake",
"exit_transfer": "Transfer",
"exit_peer_external": "not managed by this panel",
"exit_peer_remove_confirm": "Remove this peer? The entry node behind it loses its route through this exit.",
"exit_peer_removed": "Peer removed",
"exit_no_peers": "No entry nodes are linked to this exit yet.",
"exit_stop_confirm": "Entry nodes route their clients through this exit. Stopping it puts those clients on the kill-switch (no traffic). Stop anyway?",
"exit_in_use": "Entry nodes route through this exit node",
"exit_link_title": "Exit node",
"exit_link_desc": "Route all clients of this instance through another panel server. The link between the nodes is encrypted; clients keep their configs. IPv4 only: while linked, client IPv6 is refused rather than leaking from this server.",
"exit_link_select": "Exit node",
"exit_link_none": "Traffic leaves directly from this server.",
"exit_link_current": "Linked to {name} (transit {ip})",
"exit_link_via": "Exit node",
"exit_link_btn": "Link",
"exit_unlink_btn": "Unlink",
"exit_unlink_confirm": "Unlink from the exit node? Clients will leave to the internet from this server again.",
"exit_link_saved": "Exit node linked",
"exit_unlinked": "Exit node unlinked",
"exit_link_stale": "The exit node is unreachable or was removed. Clients are blocked by the kill-switch until you unlink or repair the link.",
"exit_link_repair": "Repair link",
"exit_no_exit_nodes": "No exit nodes installed yet. Install the Exit Node service on the server that should be the egress.",
"exit_no_handshake": "No handshake from the exit node within 15 s — is the transit UDP port reachable? The link was rolled back.",
"exit_apply_failed": "The routing could not be applied on this server; the link was rolled back.",
"exit_not_found": "Exit node not found or not installed.",
"not_linked": "This instance is not linked to an exit node.",
"exit_mtu_too_big": "The client MTU of this instance exceeds the link MTU (1420). Lower it in the AWG settings (1376 is the default).",
"exit_link_force": "Link anyway",
"exit_self": "A server cannot be its own exit node.",
"exit_legacy_obfuscation": "AmneziaWG Legacy can only link to a non-obfuscated exit node.",
"exit_link_ipv6_warning": "This instance is dual-stack. While linked, client IPv6 is refused so nothing leaks from this server; clients fall back to IPv4 automatically.",
"exit_link_ipv6_confirm": "Client IPv6 will be refused while linked. Continue?",
"exit_link_mtu_warning": "Client MTU {mtu} exceeds the link MTU {exit_mtu}. Lower it in the AWG settings before linking.",
"exit_status_up": "up",
"exit_status_stale": "no handshake",
"exit_status_down": "down",
"exit_handshake_ago": "handshake {s}s ago",
"no_handshake_yet": "No handshake yet — the link stays up, check the exit's firewall.",
"old_exit_unreachable_orphan_peer": "The previous exit node was unreachable; its peer entry was left behind.",
"exit_unreachable_orphan_peer": "The exit node was unreachable; its peer entry was left behind.",
"exit_egress_check": "Check egress",
"exit_egress_checking": "Checking the egress IP…",
"exit_egress_match": "✓ Clients leave from {ip} — the exit node {name}.",
"exit_egress_mismatch": "⚠ Clients leave from {ip}, the exit node address is {expected}. Expected when the exit is behind NAT.",
"exit_egress_direct": "✗ Clients still leave from this server ({ip}) — their traffic is not going through the link.",
"exit_egress_unknown": "Could not determine the egress IP: the check services did not answer.",
"exit_egress_failed": "The egress check could not run on this server.",
"exit_dns_via_exit": "Resolve client DNS at the exit node",
"exit_dns_via_exit_hint": "Off by default: DNS queries stay on this server. On, they follow the link and are answered by the AmneziaDNS of the exit node, so a resolver never sees this server's country. Requires AmneziaDNS on the exit node.",
"exit_dns_via_exit_on": "Client DNS now resolves at the exit node",
"exit_dns_via_exit_off": "Client DNS resolves on this server again",
"exit_dns_needs_dns": "Install AmneziaDNS on the exit node before routing client DNS through it.",
"awg_mtu_exit_hint": "This instance is linked to an exit node: the link carries at most {exit_mtu}, so keep the client MTU at or below it.",
"exit_dns_disabled_no_dns": "The exit node has no AmneziaDNS any more — client DNS was brought back to this server.",
"exit_defaults_title": "Exit nodes",
"exit_default_node": "Default exit node",
"exit_default_none": "None — new instances keep their own egress",
"exit_default_hint": "Every AmneziaWG instance installed from now on is linked to this exit node right after the install. Existing instances are left alone, and an install still succeeds if the link fails.",
"exit_default_cleared": "The default exit node no longer exists and was cleared.",
"exit_restore_entries_relinked": "Restored the exit-node peers of {n} linked entry node(s).",
"exit_restore_entries_failed": "{n} entry node(s) could not be re-registered on this exit — repair them from their own page.",
"exit_restore_relinked": "The exit-node link of this instance was re-established after the restore.",
"exit_restore_stale": "The exit-node link could not be re-established after the restore — clients stay on the kill-switch until you repair or remove it.",
"exit_restore_removed": "The archive carried an exit-node link this panel does not track; it was removed and clients leave from this server again.",
"exit_restore_orphan": "The archive carried an unknown exit-node link and it could not be removed — check this instance manually.",
"on": "on",
"off": "off",
"exit_obfuscation_hint": "Activez cette option lorsque la liaison entre les nœuds traverse un dispositif DPI. Sinon, utilisez WireGuard sans obfuscation. Les nœuds d’entrée AmneziaWG Legacy ne peuvent utiliser qu’un nœud de sortie sans obfuscation.",
"exit_peers": "Pairs",
"exit_peers_title": "Nœud de sortie — Nœuds d’entrée liés",
"exit_peer_name": "Nœud d’entrée",
"exit_transit_ip": "Adresse IP de transit",
"exit_public_key": "Clé publique",
"exit_handshake": "Échange initial",
"exit_transfer": "Transfert",
"exit_peer_external": "non géré par ce panneau",
"exit_peer_remove_confirm": "Supprimer ce pair ? Le nœud d’entrée associé perdra sa route via ce nœud de sortie.",
"exit_peer_removed": "Pair supprimé",
"exit_no_peers": "Aucun nœud d’entrée n’est encore lié à ce nœud de sortie.",
"exit_stop_confirm": "Des nœuds d’entrée acheminent le trafic de leurs clients via ce nœud de sortie. Son arrêt déclenchera le coupe-circuit pour ces clients (aucun trafic). Arrêter quand même ?",
"exit_in_use": "Des nœuds d’entrée utilisent ce nœud de sortie",
"exit_link_title": "Nœud de sortie",
"exit_link_desc": "Acheminez le trafic de tous les clients de cette instance via un autre serveur du panneau. La liaison entre les nœuds est chiffrée ; les clients conservent leurs configurations. IPv4 uniquement : tant que la liaison est active, le trafic IPv6 des clients est bloqué pour éviter toute fuite depuis ce serveur.",
"exit_link_select": "Nœud de sortie",
"exit_link_none": "Le trafic sort directement depuis ce serveur.",
"exit_link_current": "Lié à {name} (transit {ip})",
"exit_link_via": "Nœud de sortie",
"exit_link_btn": "Lier",
"exit_unlink_btn": "Délier",
"exit_unlink_confirm": "Supprimer la liaison au nœud de sortie ? Les clients accéderont de nouveau à Internet directement depuis ce serveur.",
"exit_link_saved": "Nœud de sortie lié",
"exit_unlinked": "Liaison au nœud de sortie supprimée",
"exit_link_stale": "Le nœud de sortie est injoignable ou a été supprimé. Les clients sont bloqués par le coupe-circuit jusqu’à ce que vous supprimiez ou répariez la liaison.",
"exit_link_repair": "Réparer la liaison",
"exit_no_exit_nodes": "Aucun nœud de sortie n’est encore installé. Installez le service Nœud de sortie sur le serveur qui doit assurer la sortie vers Internet.",
"exit_no_handshake": "Aucun échange initial avec le nœud de sortie dans les 15 s : le port UDP de transit est-il accessible ? La liaison a été annulée.",
"exit_apply_failed": "Le routage n’a pas pu être appliqué sur ce serveur ; la liaison a été annulée.",
"exit_not_found": "Nœud de sortie introuvable ou non installé.",
"not_linked": "Cette instance n’est pas liée à un nœud de sortie.",
"exit_mtu_too_big": "La MTU des clients de cette instance dépasse celle de la liaison (1420). Réduisez-la dans les paramètres AWG (1376 par défaut).",
"exit_link_force": "Lier quand même",
"exit_self": "Un serveur ne peut pas être son propre nœud de sortie.",
"exit_legacy_obfuscation": "AmneziaWG Legacy ne peut être lié qu’à un nœud de sortie sans obfuscation.",
"exit_link_ipv6_warning": "Cette instance utilise IPv4 et IPv6. Tant que la liaison est active, le trafic IPv6 des clients est bloqué pour éviter toute fuite depuis ce serveur ; les clients utilisent automatiquement IPv4.",
"exit_link_ipv6_confirm": "Le trafic IPv6 des clients sera bloqué tant que la liaison sera active. Continuer ?",
"exit_link_mtu_warning": "La MTU des clients ({mtu}) dépasse celle de la liaison ({exit_mtu}). Réduisez-la dans les paramètres AWG avant d’établir la liaison.",
"exit_status_up": "actif",
"exit_status_stale": "aucun échange initial",
"exit_status_down": "inactif",
"exit_handshake_ago": "échange initial il y a {s} s",
"no_handshake_yet": "Aucun échange initial pour le moment : la liaison reste active, vérifiez le pare-feu du nœud de sortie.",
"old_exit_unreachable_orphan_peer": "Le nœud de sortie précédent était injoignable ; son entrée de pair a été conservée.",
"exit_unreachable_orphan_peer": "Le nœud de sortie était injoignable ; son entrée de pair a été conservée.",
"exit_egress_check": "Vérifier la sortie",
"exit_egress_checking": "Vérification de l’adresse IP de sortie…",
"exit_egress_match": "✓ Les clients sortent depuis {ip} — le nœud de sortie {name}.",
"exit_egress_mismatch": "⚠ Les clients sortent depuis {ip}, mais l’adresse du nœud de sortie est {expected}. C’est normal si le nœud de sortie est derrière un NAT.",
"exit_egress_direct": "✗ Les clients sortent toujours depuis ce serveur ({ip}) : leur trafic ne passe pas par la liaison.",
"exit_egress_unknown": "Impossible de déterminer l’adresse IP de sortie : les services de vérification n’ont pas répondu.",
"exit_egress_failed": "La vérification de la sortie n’a pas pu être exécutée sur ce serveur.",
"exit_dns_via_exit": "Résoudre les requêtes DNS des clients sur le nœud de sortie",
"exit_dns_via_exit_hint": "Désactivé par défaut : les requêtes DNS restent sur ce serveur. Si cette option est activée, elles suivent la liaison et sont traitées par AmneziaDNS sur le nœud de sortie, afin que le résolveur ne voie jamais le pays de ce serveur. Nécessite AmneziaDNS sur le nœud de sortie.",
"exit_dns_via_exit_on": "Les requêtes DNS des clients sont désormais résolues sur le nœud de sortie",
"exit_dns_via_exit_off": "Les requêtes DNS des clients sont de nouveau résolues sur ce serveur",
"exit_dns_needs_dns": "Installez AmneziaDNS sur le nœud de sortie avant d’y acheminer les requêtes DNS des clients.",
"awg_mtu_exit_hint": "Cette instance est liée à un nœud de sortie : la liaison accepte une MTU maximale de {exit_mtu}, gardez donc la MTU des clients inférieure ou égale à cette valeur.",
"exit_dns_disabled_no_dns": "Le nœud de sortie ne dispose plus d’AmneziaDNS : la résolution DNS des clients a été rétablie sur ce serveur.",
"exit_defaults_title": "Nœuds de sortie",
"exit_default_node": "Nœud de sortie par défaut",
"exit_default_none": "Aucun — les nouvelles instances conservent leur propre sortie",
"exit_default_hint": "Toute instance AmneziaWG installée à partir de maintenant sera liée à ce nœud de sortie immédiatement après son installation. Les instances existantes ne sont pas modifiées, et l’installation réussit même si la liaison échoue.",
"exit_default_cleared": "Le nœud de sortie par défaut n’existe plus et a été retiré des paramètres.",
"exit_restore_entries_relinked": "Les pairs correspondant à {n} nœud(s) d’entrée lié(s) ont été restaurés sur le nœud de sortie.",
"exit_restore_entries_failed": "{n} nœud(s) d’entrée n’ont pas pu être réenregistrés sur ce nœud de sortie : réparez leur liaison depuis leur propre page.",
"exit_restore_relinked": "La liaison de cette instance au nœud de sortie a été rétablie après la restauration.",
"exit_restore_stale": "La liaison au nœud de sortie n’a pas pu être rétablie après la restauration : les clients restent bloqués par le coupe-circuit jusqu’à ce que vous la répariez ou la supprimiez.",
"exit_restore_removed": "L’archive contenait une liaison à un nœud de sortie non suivi par ce panneau ; elle a été supprimée et les clients sortent de nouveau depuis ce serveur.",
"exit_restore_orphan": "L’archive contenait une liaison à un nœud de sortie inconnu qui n’a pas pu être supprimée : vérifiez cette instance manuellement.",
"on": "activé",
"off": "désactivé",
"restore": "Restaurer",
"restore_confirm_protocol": "Êtes-vous sûr ? La configuration actuelle du protocole sera écrasée par cette sauvegarde.",
"delete_backup_confirm": "Supprimer cette sauvegarde du serveur ? Cette action est irréversible.",
@@ -531,29 +531,29 @@
"upload_backup": "Téléverser",
"uploading_backup": "Téléversement...",
"backup_uploaded": "Sauvegarde téléversée",
"wgeasy_import": "Import from wg-easy",
"wgeasy_import_title": "Import from wg-easy / amnezia-wg-easy",
"wgeasy_web_port": "wg-easy web UI port (on this server)",
"wgeasy_password": "wg-easy admin password",
"wgeasy_username": "Username (v15 only)",
"wgeasy_fetch": "Load clients",
"wgeasy_import_btn": "Import",
"wgeasy_import_warning": "The old wg-easy container will be stopped (not deleted). User configs keep working unchanged.",
"wgeasy_no_clients": "No clients selected",
"wgeasy_confirm": "Import selected clients? The old wg-easy container will be stopped.",
"wgeasy_importing": "Importing, this may take a few minutes...",
"wgeasy_target_wg": "target: WireGuard (no obfuscation)",
"wgeasy_target_awg": "obfuscation detected → target: AmneziaWG 2.0",
"rename_instance": "Rename instance",
"instance_name": "Instance name",
"rename_instance_hint": "Leave empty to restore the default name.",
"wgeasy_import": "Importer depuis wg-easy",
"wgeasy_import_title": "Importer depuis wg-easy / amnezia-wg-easy",
"wgeasy_web_port": "Port de l’interface web de wg-easy (sur ce serveur)",
"wgeasy_password": "Mot de passe administrateur de wg-easy",
"wgeasy_username": "Nom d’utilisateur (v15 uniquement)",
"wgeasy_fetch": "Charger les clients",
"wgeasy_import_btn": "Importer",
"wgeasy_import_warning": "L’ancien conteneur wg-easy sera arrêté (sans être supprimé). Les configurations des utilisateurs continueront de fonctionner sans modification.",
"wgeasy_no_clients": "Aucun client sélectionné",
"wgeasy_confirm": "Importer les clients sélectionnés ? L’ancien conteneur wg-easy sera arrêté.",
"wgeasy_importing": "Importation en cours, cela peut prendre quelques minutes...",
"wgeasy_target_wg": "cible : WireGuard (sans obfuscation)",
"wgeasy_target_awg": "obfuscation détectée → cible : AmneziaWG 2.0",
"rename_instance": "Renommer l’instance",
"instance_name": "Nom de l’instance",
"rename_instance_hint": "Laissez ce champ vide pour rétablir le nom par défaut.",
"no_expiry": "Sans expiration",
"telemt_secret_label": "Secret (hex)",
"telemt_ad_tag_label": "Tag AD (hex)",
"telemt_max_conns_label": "Connexions TCP max",
"telemt_quota_label": "Quota (octets)",
"telemt_max_ips_label": "IP uniques max",
"telemt_expiry_label": "Expiration (ISO 8601)",
"telemt_expiry_label": "Date d’expiration (ISO 8601)",
"optional": "Facultatif",
"role_none_desc": "Aucun rôle — simple fiche, connexion impossible",
"role_none_badge": "Aucun rôle",
@@ -586,5 +586,104 @@
"peer_poll_title": "Actualisation en direct des pairs",
"peer_poll_hint": "Fréquence de rafraîchissement automatique de la liste des pairs. 0 = désactivé (par défaut). 5–10 suffit sur les serveurs rapides ; sur les lents, utilisez 300–600 ou laissez désactivé.",
"peer_poll_off": "désactivé",
"peer_poll_sec": "s"
"peer_poll_sec": "s",
"wireguard_desc": "Le protocole VPN standard et le plus rapide. Pris en charge nativement par tous les systèmes d’exploitation modernes, mais facilement détecté par le DPI.",
"expiration_date_label": "Date d’expiration",
"tls_emulation": "Émulation TLS",
"tls_domain": "Domaine de camouflage",
"max_connections_limit": "Nombre maximal de connexions",
"max_connections_hint": "0 pour aucune limite. Limite le nombre d’utilisateurs simultanés.",
"traffic_reset_strategy_label": "Règle de remise à zéro du trafic",
"traffic_reset_never": "Ne jamais remettre à zéro",
"traffic_reset_daily": "Remise à zéro quotidienne",
"traffic_reset_weekly": "Remise à zéro hebdomadaire",
"traffic_reset_monthly": "Remise à zéro mensuelle",
"traffic_reset_hint": "Fréquence de remise à zéro du trafic utilisateur",
"traffic_used_total": "Total consommé",
"traffic_reset_info": "Prochaine remise à zéro : {}",
"services": "Services",
"ssl_title": "Paramètres SSL / HTTPS",
"enable_https": "Activer HTTPS",
"panel_port_label": "Port du panneau",
"domain_label": "Nom de domaine",
"cert_path_label": "Chemin du certificat SSL (.pem)",
"key_path_label": "Chemin de la clé privée (.pem)",
"or_paste_text": "OU collez le texte du certificat",
"cert_text_label": "Contenu du certificat (CRT)",
"key_text_label": "Contenu de la clé privée (KEY)",
"ssl_hint": "Une fois activé, le panneau sera accessible via https:// sur le domaine indiqué. Vérifiez que les chemins des fichiers ou les données textuelles sont corrects.",
"about_title": "À propos et mises à jour",
"current_version": "Version actuelle",
"check_updates": "Rechercher des mises à jour",
"checking_updates": "Vérification...",
"update_available": "Nouvelle version disponible",
"download_update": "Télécharger la mise à jour",
"up_to_date": "Vous disposez de la dernière version",
"checking_server": "Vérification des services du serveur...",
"mail_title": "Envoi d’e-mails (SMTP)",
"mail_hint": "Les configurations et les liens de proxy sont envoyés depuis cette boîte mail. Le mot de passe est stocké dans data.json : utilisez un compte dédié ou un mot de passe d’application.",
"mail_enabled_label": "Activer l’envoi d’e-mails",
"mail_host_label": "Serveur SMTP",
"mail_port_label": "Port",
"mail_security_label": "Chiffrement",
"mail_security_none": "Aucun",
"mail_security_starttls": "STARTTLS",
"mail_security_ssl": "SSL/TLS",
"mail_username_label": "Nom d’utilisateur",
"mail_password_label": "Mot de passe",
"mail_password_hint": "Laissez ce champ vide pour conserver le mot de passe enregistré ; saisissez un espace pour l’effacer.",
"mail_from_label": "Adresse de l’expéditeur",
"mail_from_name_label": "Nom de l’expéditeur",
"mail_reply_to_label": "Adresse de réponse (facultatif)",
"mail_timeout_label": "Délai d’attente (s)",
"mail_test_btn": "Envoyer un message de test",
"mail_test_recipient_label": "Destinataire du test",
"mail_test_sent": "Message de test envoyé à {}",
"mail_send_btn": "Envoyer par e-mail",
"mail_send_title": "Envoyer les configurations par e-mail",
"mail_send_all_btn": "Envoyer un e-mail à tous les utilisateurs",
"mail_send_all_title": "Envoyer un e-mail à chaque utilisateur",
"mail_recipient_label": "Destinataire",
"mail_include_configs": "Joindre les fichiers de configuration",
"mail_include_links": "Inclure les liens de connexion",
"mail_subject_label": "Objet",
"mail_subject_placeholder": "Laissez ce champ vide pour utiliser l’objet par défaut",
"mail_message_label": "Message (facultatif)",
"mail_message_placeholder": "Quelques mots affichés au-dessus des connexions",
"mail_only_enabled": "Ignorer les utilisateurs désactivés",
"mail_skip_empty": "Ignorer les utilisateurs sans connexion",
"mail_send_action": "Envoyer",
"mail_sent_ok": "Envoyé à {}",
"mail_bulk_started": "Envoi groupé démarré",
"mail_bulk_targets": "Destinataires : tous les utilisateurs disposant d’une adresse e-mail",
"mail_bulk_progress": "{} sur {} traités",
"mail_bulk_summary": "Envoyés : {}, ignorés : {}, erreurs : {}",
"mail_bulk_running_note": "Vous pouvez fermer cette fenêtre : l’envoi se poursuit.",
"mail_no_email_hint": "Cet utilisateur n’a pas d’adresse e-mail",
"mail_status_sent": "envoyé",
"mail_status_skipped": "ignoré",
"mail_status_failed": "erreur",
"mail_greeting": "Bonjour, {} !",
"mail_default_subject": "Votre accès VPN",
"mail_intro_connections": "Vous trouverez vos connexions ci-dessous. Importez le fichier joint dans le client VPN ou ouvrez le lien sur votre appareil.",
"mail_file_label": "Fichier joint",
"mail_no_connections_note": "Votre compte ne dispose pas encore de connexions. Veuillez contacter votre administrateur.",
"mail_footer": "Envoyé automatiquement par le panneau VPN. Veuillez ne pas répondre à ce message.",
"mail_test_subject": "Message de test du panneau VPN",
"mail_test_body": "Les paramètres SMTP fonctionnent : ce message de test a été envoyé par le panneau VPN.",
"mail_error_disabled": "L’envoi d’e-mails est désactivé dans les paramètres",
"mail_error_no_host": "Le serveur SMTP n’est pas défini",
"mail_error_no_sender": "L’adresse de l’expéditeur est absente ou invalide",
"mail_error_bad_security": "Mode de chiffrement inconnu",
"mail_error_no_recipient": "Aucune adresse de destinataire valide",
"mail_error_no_recipients": "Aucun utilisateur ne dispose d’une adresse e-mail",
"mail_error_no_connections": "Cet utilisateur n’a aucune connexion à envoyer",
"mail_error_user_not_found": "Utilisateur introuvable",
"mail_error_nothing_to_send": "Choisissez des fichiers, des liens ou les deux",
"mail_error_bulk_running": "Un envoi groupé est déjà en cours",
"mail_skip_no_email": "aucune adresse e-mail",
"mail_skip_bad_email": "adresse e-mail invalide",
"mail_skip_disabled": "utilisateur désactivé",
"mail_test_hint": "Le test utilise les paramètres enregistrés : cliquez d’abord sur Enregistrer.",
"mail_link_label": "Lien"
}
+312 -213
View File
@@ -222,7 +222,7 @@
"bot_hint": "修改 Token 后请保存,机器人将自动重启。用户需在个人资料中填写 Telegram ID。",
"api_docs_title": "🔌 API 文档",
"api_docs_hint": "使用这些接口了解面板功能",
"tunnels_title": "Tunnels",
"tunnels_title": "隧道",
"local_server": "本地服务器",
"tunnel_install_enable": "安装并启用",
"tunnel_enable": "启用隧道",
@@ -287,14 +287,14 @@
"lang_ru": "Русский",
"lang_en": "English",
"lang_fr": "Français",
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"lang_zh": "中文(简体)",
"lang_fa": "فارسی(波斯语)",
"backup_title": "简易备份",
"download_backup": "下载 data.json",
"restore_backup": "从文件恢复",
"restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。",
"restore_success": "备份恢复成功!",
"auto_backup_title": "Auto backup",
"auto_backup_title": "自动备份",
"enable_auto_backup": "启用服务器自动备份",
"auto_backup_interval_label": "创建间隔",
"auto_backup_hint": "面板会按所选间隔为每台服务器上已安装的协议创建备份。",
@@ -315,121 +315,121 @@
"remove_server": "从面板移除服务器",
"telemt_desc": "基于 MTProxy 的 Telegram 代理,支持高级混淆。",
"dns_desc": "防止广告和保护隐私的个人 DNS 服务器。",
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
"socks5_username": "Username",
"socks5_password": "Password",
"socks5_password_placeholder": "Leave empty to auto-generate",
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
"socks5_change_settings": "Change settings",
"socks5_settings_title": "SOCKS5 — Connection settings",
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
"socks5_settings_saved": "SOCKS5 settings saved",
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
"adguard_mode": "Installation mode",
"adguard_mode_sidebyside": "Side-by-side",
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
"adguard_mode_replace": "Replace AmneziaDNS",
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
"adguard_dns_port": "DNS port (53)",
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
"adguard_web_port": "Web UI port",
"adguard_dot_port": "DoT port",
"adguard_doh_port": "DoH port",
"adguard_expose_web": "Expose to internet",
"adguard_expose": "Expose to host",
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
"adguard_internal_ip": "Internal IP",
"adguard_web_ui": "Admin URL",
"adguard_open_ui": "Open Web UI",
"edit_server_title": "Edit server",
"edit_keep_credential": "Leave empty to keep the current value",
"server_saved": "Server settings saved",
"servers_reordered": "Servers reordered",
"ping_checking": "Checking connectivity...",
"offline": "offline",
"api_tokens_title": "API tokens",
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <token>` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.",
"api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.",
"api_tokens_create": "Create token",
"api_tokens_create_title": "Create API token",
"api_tokens_name_label": "Name",
"api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring",
"api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.",
"api_tokens_name_required": "Token name is required",
"api_tokens_created_title": "Token created",
"api_tokens_value_label": "Token value",
"api_tokens_warning_title": "This is the only time you'll see this token.",
"api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.",
"api_tokens_revoke": "Revoke",
"api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.",
"api_tokens_revoked": "Token revoked",
"api_tokens_created": "Created",
"api_tokens_last_used": "Last used",
"api_tokens_owner": "Owner",
"api_tokens_never": "never",
"coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub",
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
"revproxy_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
"warp_install_required": "Install WARP first",
"warp_connect": "Connect WARP",
"warp_disconnect": "Disconnect",
"warp_connected": "WARP connected",
"warp_disconnected": "WARP disconnected",
"warp_status_unknown": "WARP status is unknown.",
"warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.",
"warp_status_connected": "Cloudflare WARP is connected.",
"warp_status_connecting": "Cloudflare WARP is connecting.",
"warp_status_disconnected": "Cloudflare WARP is disconnected.",
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
"warp_status_error": "Cloudflare WARP returned an error.",
"install_starting": "Starting installation...",
"server_check_complete": "Check completed",
"server_connection_error": "Connection error",
"connections_load_error": "Error loading connections",
"connection_updated": "Connection updated",
"edit_connection": "Edit connection",
"enable_connection_progress": "Enabling connection...",
"disable_connection_progress": "Disabling connection...",
"connection_enabled": "Connection enabled",
"connection_disabled": "Connection disabled",
"dns_internal_hint": "此服务在内部网络 (172.29.172.254) 上运行,不绑定主机端口。",
"telemt_port_hint": "Telegram 代理使用的端口(通常为 443)。",
"socks5_desc": "支持用户名和密码认证的单账户 SOCKS5 代理 (3proxy)。",
"socks5_username": "用户名",
"socks5_password": "密码",
"socks5_password_placeholder": "留空以自动生成",
"socks5_password_hint": "留空将生成一个 16 位随机密码。",
"socks5_port_hint": "代理监听的 TCP 端口。Amnezia 官方客户端的默认端口为 38080。",
"socks5_change_settings": "修改设置",
"socks5_settings_title": "SOCKS5 — 连接设置",
"socks5_port_change_hint": "更改端口会重新创建容器,现有会话将断开。",
"socks5_settings_saved": "SOCKS5 设置已保存",
"adguard_desc": "AdGuard Home — 基于 DNS 的广告拦截器,提供网页管理界面。",
"adguard_mode": "安装模式",
"adguard_mode_sidebyside": "并行运行",
"adguard_mode_sidebyside_hint": "使用独立的内部 IP (172.29.172.253),与 AmneziaDNS 并行运行",
"adguard_mode_replace": "替换 AmneziaDNS",
"adguard_mode_replace_hint": "移除 amnezia-dns 并接管其 IP,VPN 客户端将立即使用 AdGuard",
"adguard_dns_port": "DNS 端口 (53)",
"adguard_dns_port_hint": "通过 Docker 网络向 VPN 客户端提供服务的内部 DNS 端口。",
"adguard_web_port": "网页界面端口",
"adguard_dot_port": "DoT 端口",
"adguard_doh_port": "DoH 端口",
"adguard_expose_web": "开放至互联网",
"adguard_expose": "映射到主机",
"adguard_install_hint": "安装后,请通过网页界面中的内置向导完成 AdGuard 初始设置。",
"adguard_internal_ip": "内部 IP",
"adguard_web_ui": "管理地址",
"adguard_open_ui": "打开网页界面",
"edit_server_title": "编辑服务器",
"edit_keep_credential": "留空以保留当前值",
"server_saved": "服务器设置已保存",
"servers_reordered": "服务器排序已更新",
"ping_checking": "正在检查连通性...",
"offline": "离线",
"api_tokens_title": "API 令牌",
"api_tokens_hint": "用于外部集成的 Bearer 令牌。请在 `Authorization: Bearer <token>` 请求头中发送令牌。令牌拥有与管理员相同的权限;其所有者被禁用或降权后,令牌将失效。",
"api_tokens_empty": "尚无 API 令牌。创建令牌以将面板与外部服务集成。",
"api_tokens_create": "创建令牌",
"api_tokens_create_title": "创建 API 令牌",
"api_tokens_name_label": "名称",
"api_tokens_name_placeholder": "例如:Linear 集成、CI 机器人、监控",
"api_tokens_name_hint": "用于在列表中识别此令牌。令牌值将自动生成。",
"api_tokens_name_required": "请输入令牌名称",
"api_tokens_created_title": "令牌已创建",
"api_tokens_value_label": "令牌值",
"api_tokens_warning_title": "此令牌仅显示一次。",
"api_tokens_warning_body": "请立即复制,并将其存储在集成服务的密钥管理器中。面板仅保存哈希值;如果丢失令牌值,您需要撤销并重新创建令牌。",
"api_tokens_revoke": "撤销",
"api_tokens_revoke_confirm": "撤销令牌“{}”?所有使用此令牌的集成都将停止工作。",
"api_tokens_revoked": "令牌已撤销",
"api_tokens_created": "创建时间",
"api_tokens_last_used": "上次使用时间",
"api_tokens_owner": "所有者",
"api_tokens_never": "从未",
"coming_soon": "即将推出",
"promo_star_cta": "在 GitHub 上为我们点亮 Star",
"aivpn_subtitle": "由 AI 根据当前情况选择合适的协议和隧道。点亮 Star,助力功能早日上线。",
"revproxy_title": "反向代理",
"revproxy_subtitle": "高性能流量混淆:将 VPN 端点隐藏在精美的公开网站背后,减少可被 DPI 识别的特征。",
"warp_hint": "WARP 将此主机的出站流量通过 Cloudflare 路由。它不会像 Quick Tunnel 或 ngrok 那样为面板创建公开访问地址。",
"warp_install_hint": "请先安装 Cloudflare WARP 官方客户端,然后重启面板。",
"warp_install_required": "请先安装 WARP",
"warp_connect": "连接 WARP",
"warp_disconnect": "断开 WARP",
"warp_connected": "WARP 已连接",
"warp_disconnected": "WARP 已断开",
"warp_status_unknown": "WARP 状态未知。",
"warp_status_not_installed": "此主机未安装 Cloudflare WARP CLI。",
"warp_status_connected": "Cloudflare WARP 已连接。",
"warp_status_connecting": "Cloudflare WARP 正在连接。",
"warp_status_disconnected": "Cloudflare WARP 已断开。",
"warp_status_not_registered": "Cloudflare WARP 已安装但尚未注册。点击连接将尝试自动注册。",
"warp_status_service_unavailable": "Cloudflare WARP 服务不可用。请确保 warp-svc 正在运行。",
"warp_status_error": "Cloudflare WARP 返回了错误。",
"install_starting": "正在开始安装...",
"server_check_complete": "检查完成",
"server_connection_error": "连接错误",
"connections_load_error": "加载连接时出错",
"connection_updated": "连接已更新",
"edit_connection": "编辑连接",
"enable_connection_progress": "正在启用连接...",
"disable_connection_progress": "正在禁用连接...",
"connection_enabled": "连接已启用",
"connection_disabled": "连接已禁用",
"gb": "GB",
"marketplace": "Templates",
"open_marketplace": "Open Templates",
"marketplace_desc": "Install protocol and service templates available for this server.",
"installed_apps": "Installed applications",
"installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.",
"no_installed_apps": "No installed applications yet",
"no_installed_apps_desc": "Open Templates to install protocols or services on this server.",
"install_another": "Install another",
"new_instance": "new instance",
"port_next_instance_hint": "Choose a free UDP port for the additional instance.",
"web_servers": "Web servers",
"nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.",
"nginx_domain": "Domain",
"nginx_email": "Let's Encrypt email",
"nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.",
"marketplace": "模板",
"open_marketplace": "打开模板",
"marketplace_desc": "安装适用于此服务器的协议和服务模板。",
"installed_apps": "已安装的应用",
"installed_apps_hint": "此处仅显示已安装的协议和服务。可通过“模板”添加更多。",
"no_installed_apps": "尚未安装应用",
"no_installed_apps_desc": "打开“模板”,在此服务器上安装协议或服务。",
"install_another": "安装另一个实例",
"new_instance": "新实例",
"port_next_instance_hint": "为新增实例选择一个空闲的 UDP 端口。",
"web_servers": "Web 服务器",
"nginx_desc": "NGINX Web 服务器,支持 Let's Encrypt HTTPS 证书和可编辑的 index.html。",
"nginx_domain": "域名",
"nginx_email": "Let's Encrypt 邮箱",
"nginx_port_hint": "服务器对外开放的 HTTPS 端口。80/TCP 端口必须空闲且可访问,以供 Let's Encrypt 验证。",
"nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used for HTTP-01 certificate validation.",
"site": "Site",
"site_editor": "Site editor",
"site_saved": "Site saved",
"nginx_dns_hint": "Create this DNS record before installation:",
"backup": "Backup",
"backups": "Backups",
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
"create_backup": "Create backup",
"creating_backup": "Creating backup...",
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh",
"site": "网站",
"site_editor": "网站编辑器",
"site_saved": "网站已保存",
"nginx_dns_hint": "安装前请创建以下 DNS 记录:",
"backup": "备份",
"backups": "备份",
"backup_desc": "备份在服务器上创建,包含日后恢复或重建此协议所需的重要文件。",
"create_backup": "创建备份",
"creating_backup": "正在创建备份...",
"backup_created": "备份已创建",
"no_backups": "尚无备份",
"no_backups_desc": "为此协议创建第一个备份。",
"refresh": "刷新",
"rename": "重命名",
"connection_renamed": "连接已重命名",
"enter_connection_name": "请输入新的连接名称",
@@ -445,85 +445,85 @@
"max_speed": "最大速度 (Mbps)",
"speed_limit_unlim": "速度限制(0 = 不限)",
"port_in_use_by": "端口 {port} 已被 {owner} 占用,请选择其他端口。",
"exit_desc": "Exit node: a transit endpoint for other panel servers. Client traffic that enters elsewhere leaves to the internet from this server.",
"exit_port_hint": "UDP port the transit link listens on. Only other panel servers connect here, never end users. Open it in the server's firewall for the entry nodes.",
"exit_subnet": "Transit subnet",
"exit_subnet_hint": "Private IPv4 /24 shared between this exit and its entry nodes. Must not overlap the client subnets (10.8.x.x).",
"exit_obfuscation": "Obfuscate the inter-node link (AmneziaWG)",
"exit_obfuscation_short": "Obfuscation",
"exit_obfuscation_hint": "Enable when the hop between the nodes crosses DPI. Plain WireGuard otherwise. AmneziaWG Legacy entries can only use a non-obfuscated exit.",
"exit_peers": "Peers",
"exit_peers_title": "Exit node — linked entry nodes",
"exit_peer_name": "Entry node",
"exit_transit_ip": "Transit IP",
"exit_public_key": "Public key",
"exit_handshake": "Handshake",
"exit_transfer": "Transfer",
"exit_peer_external": "not managed by this panel",
"exit_peer_remove_confirm": "Remove this peer? The entry node behind it loses its route through this exit.",
"exit_peer_removed": "Peer removed",
"exit_no_peers": "No entry nodes are linked to this exit yet.",
"exit_stop_confirm": "Entry nodes route their clients through this exit. Stopping it puts those clients on the kill-switch (no traffic). Stop anyway?",
"exit_in_use": "Entry nodes route through this exit node",
"exit_link_title": "Exit node",
"exit_link_desc": "Route all clients of this instance through another panel server. The link between the nodes is encrypted; clients keep their configs. IPv4 only: while linked, client IPv6 is refused rather than leaking from this server.",
"exit_link_select": "Exit node",
"exit_link_none": "Traffic leaves directly from this server.",
"exit_link_current": "Linked to {name} (transit {ip})",
"exit_link_via": "Exit node",
"exit_link_btn": "Link",
"exit_unlink_btn": "Unlink",
"exit_unlink_confirm": "Unlink from the exit node? Clients will leave to the internet from this server again.",
"exit_link_saved": "Exit node linked",
"exit_unlinked": "Exit node unlinked",
"exit_link_stale": "The exit node is unreachable or was removed. Clients are blocked by the kill-switch until you unlink or repair the link.",
"exit_link_repair": "Repair link",
"exit_no_exit_nodes": "No exit nodes installed yet. Install the Exit Node service on the server that should be the egress.",
"exit_no_handshake": "No handshake from the exit node within 15 s — is the transit UDP port reachable? The link was rolled back.",
"exit_apply_failed": "The routing could not be applied on this server; the link was rolled back.",
"exit_not_found": "Exit node not found or not installed.",
"not_linked": "This instance is not linked to an exit node.",
"exit_mtu_too_big": "The client MTU of this instance exceeds the link MTU (1420). Lower it in the AWG settings (1376 is the default).",
"exit_link_force": "Link anyway",
"exit_self": "A server cannot be its own exit node.",
"exit_legacy_obfuscation": "AmneziaWG Legacy can only link to a non-obfuscated exit node.",
"exit_link_ipv6_warning": "This instance is dual-stack. While linked, client IPv6 is refused so nothing leaks from this server; clients fall back to IPv4 automatically.",
"exit_link_ipv6_confirm": "Client IPv6 will be refused while linked. Continue?",
"exit_link_mtu_warning": "Client MTU {mtu} exceeds the link MTU {exit_mtu}. Lower it in the AWG settings before linking.",
"exit_status_up": "up",
"exit_status_stale": "no handshake",
"exit_status_down": "down",
"exit_handshake_ago": "handshake {s}s ago",
"no_handshake_yet": "No handshake yet — the link stays up, check the exit's firewall.",
"old_exit_unreachable_orphan_peer": "The previous exit node was unreachable; its peer entry was left behind.",
"exit_unreachable_orphan_peer": "The exit node was unreachable; its peer entry was left behind.",
"exit_egress_check": "Check egress",
"exit_egress_checking": "Checking the egress IP…",
"exit_egress_match": "✓ Clients leave from {ip} — the exit node {name}.",
"exit_egress_mismatch": "⚠ Clients leave from {ip}, the exit node address is {expected}. Expected when the exit is behind NAT.",
"exit_egress_direct": "✗ Clients still leave from this server ({ip}) — their traffic is not going through the link.",
"exit_egress_unknown": "Could not determine the egress IP: the check services did not answer.",
"exit_egress_failed": "The egress check could not run on this server.",
"exit_dns_via_exit": "Resolve client DNS at the exit node",
"exit_dns_via_exit_hint": "Off by default: DNS queries stay on this server. On, they follow the link and are answered by the AmneziaDNS of the exit node, so a resolver never sees this server's country. Requires AmneziaDNS on the exit node.",
"exit_dns_via_exit_on": "Client DNS now resolves at the exit node",
"exit_dns_via_exit_off": "Client DNS resolves on this server again",
"exit_dns_needs_dns": "Install AmneziaDNS on the exit node before routing client DNS through it.",
"awg_mtu_exit_hint": "This instance is linked to an exit node: the link carries at most {exit_mtu}, so keep the client MTU at or below it.",
"exit_dns_disabled_no_dns": "The exit node has no AmneziaDNS any more — client DNS was brought back to this server.",
"exit_defaults_title": "Exit nodes",
"exit_default_node": "Default exit node",
"exit_default_none": "None — new instances keep their own egress",
"exit_default_hint": "Every AmneziaWG instance installed from now on is linked to this exit node right after the install. Existing instances are left alone, and an install still succeeds if the link fails.",
"exit_default_cleared": "The default exit node no longer exists and was cleared.",
"exit_restore_entries_relinked": "Restored the exit-node peers of {n} linked entry node(s).",
"exit_restore_entries_failed": "{n} entry node(s) could not be re-registered on this exit — repair them from their own page.",
"exit_restore_relinked": "The exit-node link of this instance was re-established after the restore.",
"exit_restore_stale": "The exit-node link could not be re-established after the restore — clients stay on the kill-switch until you repair or remove it.",
"exit_restore_removed": "The archive carried an exit-node link this panel does not track; it was removed and clients leave from this server again.",
"exit_restore_orphan": "The archive carried an unknown exit-node link and it could not be removed — check this instance manually.",
"on": "on",
"off": "off",
"exit_desc": "出口节点:供面板中其他服务器使用的中转端点。从其他位置接入的客户端流量经由此服务器访问互联网。",
"exit_port_hint": "中转链路监听的 UDP 端口。仅供面板中的其他服务器连接,终端用户不会直接连接。请在服务器防火墙中对入口节点开放此端口。",
"exit_subnet": "中转子网",
"exit_subnet_hint": "此出口节点与其入口节点共用的私有 IPv4 /24 子网。不得与客户端子网 (10.8.x.x) 重叠。",
"exit_obfuscation": "混淆节点间链路 (AmneziaWG)",
"exit_obfuscation_short": "混淆",
"exit_obfuscation_hint": "节点间的链路经过 DPI 检测时,请启用此选项;否则使用普通 WireGuard。AmneziaWG Legacy 入口节点只能使用未启用混淆的出口节点。",
"exit_peers": "对等节点",
"exit_peers_title": "出口节点 — 已关联的入口节点",
"exit_peer_name": "入口节点",
"exit_transit_ip": "中转 IP",
"exit_public_key": "公钥",
"exit_handshake": "握手",
"exit_transfer": "传输量",
"exit_peer_external": "不由此面板管理",
"exit_peer_remove_confirm": "移除此对等节点?对应的入口节点将失去经由此出口节点的路由。",
"exit_peer_removed": "对等节点已移除",
"exit_no_peers": "尚无入口节点关联到此出口节点。",
"exit_stop_confirm": "入口节点的客户端流量正经由此出口节点路由。停止后将触发断网保护,阻断这些客户端的所有流量。仍要停止吗?",
"exit_in_use": "有入口节点正经由此出口节点路由",
"exit_link_title": "出口节点",
"exit_link_desc": "将此实例的所有客户端流量经由面板中的另一台服务器路由。节点间链路已加密,客户端无需更改配置。仅支持 IPv4:关联期间将阻断客户端 IPv6 流量,防止其从此服务器泄漏。",
"exit_link_select": "出口节点",
"exit_link_none": "流量直接从此服务器访问互联网。",
"exit_link_current": "已关联到 {name}(中转 {ip})",
"exit_link_via": "出口节点",
"exit_link_btn": "关联",
"exit_unlink_btn": "解除关联",
"exit_unlink_confirm": "解除与出口节点的关联?客户端将重新从此服务器直接访问互联网。",
"exit_link_saved": "出口节点已关联",
"exit_unlinked": "已解除与出口节点的关联",
"exit_link_stale": "出口节点无法访问或已被移除。在解除关联或修复链路之前,断网保护将持续阻断客户端流量。",
"exit_link_repair": "修复链路",
"exit_no_exit_nodes": "尚未安装出口节点。请在预期作为流量出口的服务器上安装出口节点服务。",
"exit_no_handshake": "15 秒内未收到出口节点的握手,中转 UDP 端口是否可访问?关联操作已回滚。",
"exit_apply_failed": "无法在此服务器上应用路由,关联操作已回滚。",
"exit_not_found": "出口节点不存在或尚未安装。",
"not_linked": "此实例未关联到出口节点。",
"exit_mtu_too_big": "此实例的客户端 MTU 超过链路 MTU (1420)。请在 AWG 设置中调低(默认值为 1376)。",
"exit_link_force": "仍然关联",
"exit_self": "服务器不能作为自身的出口节点。",
"exit_legacy_obfuscation": "AmneziaWG Legacy 只能关联到未启用混淆的出口节点。",
"exit_link_ipv6_warning": "此实例使用双栈。关联期间将阻断客户端 IPv6 流量,防止其从此服务器泄漏;客户端将自动回退到 IPv4。",
"exit_link_ipv6_confirm": "关联期间将阻断客户端 IPv6 流量。是否继续?",
"exit_link_mtu_warning": "客户端 MTU {mtu} 超过链路 MTU {exit_mtu}。请先在 AWG 设置中调低,再进行关联。",
"exit_status_up": "已连通",
"exit_status_stale": "无握手",
"exit_status_down": "已断开",
"exit_handshake_ago": "上次握手:{s} 秒前",
"no_handshake_yet": "尚未握手,链路仍保持启用,请检查出口节点的防火墙。",
"old_exit_unreachable_orphan_peer": "原出口节点无法访问,其对等节点记录未被清除。",
"exit_unreachable_orphan_peer": "出口节点无法访问,其对等节点记录未被清除。",
"exit_egress_check": "检查出口 IP",
"exit_egress_checking": "正在检查出口 IP…",
"exit_egress_match": "✓ 客户端通过出口节点 {name} 的 {ip} 访问互联网。",
"exit_egress_mismatch": "⚠ 客户端的出口 IP 为 {ip},出口节点地址为 {expected}。如果出口节点位于 NAT 后方,这是正常现象。",
"exit_egress_direct": "✗ 客户端仍从此服务器 ({ip}) 访问互联网,流量未经过节点间链路。",
"exit_egress_unknown": "无法确定出口 IP:检测服务未响应。",
"exit_egress_failed": "无法在此服务器上执行出口检查。",
"exit_dns_via_exit": "通过出口节点解析客户端 DNS",
"exit_dns_via_exit_hint": "默认关闭:DNS 查询留在此服务器上。启用后,查询将经由链路交给出口节点上的 AmneziaDNS 处理,使 DNS 解析服务无法得知此服务器所在的国家。出口节点需已安装 AmneziaDNS。",
"exit_dns_via_exit_on": "客户端 DNS 现由出口节点解析",
"exit_dns_via_exit_off": "客户端 DNS 已恢复由此服务器解析",
"exit_dns_needs_dns": "请先在出口节点上安装 AmneziaDNS,再将客户端 DNS 路由至该节点。",
"awg_mtu_exit_hint": "此实例已关联到出口节点:链路最多承载 {exit_mtu},请确保客户端 MTU 不超过此值。",
"exit_dns_disabled_no_dns": "出口节点上已无 AmneziaDNS,客户端 DNS 已恢复由此服务器解析。",
"exit_defaults_title": "出口节点",
"exit_default_node": "默认出口节点",
"exit_default_none": "无 — 新实例使用自身出口",
"exit_default_hint": "从现在起,每个新安装的 AmneziaWG 实例都会在安装完成后立即关联到此出口节点。现有实例不受影响,即使关联失败也不会影响安装成功。",
"exit_default_cleared": "默认出口节点已不存在,相关设置已清除。",
"exit_restore_entries_relinked": "已恢复 {n} 个已关联入口节点在出口节点上的对等节点记录。",
"exit_restore_entries_failed": "有 {n} 个入口节点未能在此出口节点上重新注册,请前往各入口节点的页面修复。",
"exit_restore_relinked": "恢复后已重新建立此实例与出口节点的关联。",
"exit_restore_stale": "恢复后未能重新建立与出口节点的关联。在修复或解除关联之前,断网保护将持续阻断客户端流量。",
"exit_restore_removed": "备份中包含此面板未记录的出口节点关联,已将其移除,客户端将重新从此服务器访问互联网。",
"exit_restore_orphan": "备份中包含未知的出口节点关联,且无法移除,请手动检查此实例。",
"on": "开启",
"off": "关闭",
"restore": "恢复",
"restore_confirm_protocol": "您确定吗?当前协议配置将被此备份覆盖。",
"delete_backup_confirm": "从服务器删除此备份?此操作无法撤销。",
@@ -531,22 +531,22 @@
"upload_backup": "上传",
"uploading_backup": "正在上传...",
"backup_uploaded": "备份已上传",
"wgeasy_import": "Import from wg-easy",
"wgeasy_import_title": "Import from wg-easy / amnezia-wg-easy",
"wgeasy_web_port": "wg-easy web UI port (on this server)",
"wgeasy_password": "wg-easy admin password",
"wgeasy_username": "Username (v15 only)",
"wgeasy_fetch": "Load clients",
"wgeasy_import_btn": "Import",
"wgeasy_import_warning": "The old wg-easy container will be stopped (not deleted). User configs keep working unchanged.",
"wgeasy_no_clients": "No clients selected",
"wgeasy_confirm": "Import selected clients? The old wg-easy container will be stopped.",
"wgeasy_importing": "Importing, this may take a few minutes...",
"wgeasy_target_wg": "target: WireGuard (no obfuscation)",
"wgeasy_target_awg": "obfuscation detected → target: AmneziaWG 2.0",
"rename_instance": "Rename instance",
"instance_name": "Instance name",
"rename_instance_hint": "Leave empty to restore the default name.",
"wgeasy_import": "从 wg-easy 导入",
"wgeasy_import_title": "从 wg-easy / amnezia-wg-easy 导入",
"wgeasy_web_port": "wg-easy 网页界面端口(此服务器上)",
"wgeasy_password": "wg-easy 管理员密码",
"wgeasy_username": "用户名(仅 v15)",
"wgeasy_fetch": "加载客户端",
"wgeasy_import_btn": "导入",
"wgeasy_import_warning": "旧的 wg-easy 容器将被停止,但不会被删除。用户配置无需更改即可继续使用。",
"wgeasy_no_clients": "未选择客户端",
"wgeasy_confirm": "导入所选客户端?旧的 wg-easy 容器将被停止。",
"wgeasy_importing": "正在导入,可能需要几分钟...",
"wgeasy_target_wg": "目标:WireGuard(无混淆)",
"wgeasy_target_awg": "检测到混淆 → 目标:AmneziaWG 2.0",
"rename_instance": "重命名实例",
"instance_name": "实例名称",
"rename_instance_hint": "留空以恢复默认名称。",
"no_expiry": "无期限",
"telemt_secret_label": "密钥 (hex)",
"telemt_ad_tag_label": "AD 标签 (hex)",
@@ -586,5 +586,104 @@
"peer_poll_title": "节点实时刷新",
"peer_poll_hint": "节点列表自动刷新的间隔。0 = 关闭(默认)。快的服务器用 5–10 即可;慢的服务器建议 300–600 或保持关闭。",
"peer_poll_off": "关闭",
"peer_poll_sec": "秒"
"peer_poll_sec": "秒",
"wireguard_desc": "标准且速度最快的 VPN 协议。所有现代操作系统均原生支持,但容易被 DPI 识别。",
"expiration_date_label": "到期日期",
"tls_emulation": "TLS 模拟",
"tls_domain": "伪装域名",
"max_connections_limit": "最大连接数",
"max_connections_hint": "设为 0 表示不限。限制同时在线的用户总数。",
"traffic_reset_strategy_label": "流量重置策略",
"traffic_reset_never": "永不重置",
"traffic_reset_daily": "每日重置",
"traffic_reset_weekly": "每周重置",
"traffic_reset_monthly": "每月重置",
"traffic_reset_hint": "用户流量的重置频率",
"traffic_used_total": "累计用量",
"traffic_reset_info": "下次重置:{}",
"services": "服务",
"ssl_title": "SSL / HTTPS 设置",
"enable_https": "启用 HTTPS",
"panel_port_label": "面板端口",
"domain_label": "域名",
"cert_path_label": "SSL 证书路径 (.pem)",
"key_path_label": "私钥路径 (.pem)",
"or_paste_text": "或粘贴证书文本",
"cert_text_label": "证书内容 (CRT)",
"key_text_label": "私钥内容 (KEY)",
"ssl_hint": "启用后,可通过指定域名的 https:// 地址访问面板。请确保文件路径或文本内容正确。",
"about_title": "关于与更新",
"current_version": "当前版本",
"check_updates": "检查更新",
"checking_updates": "正在检查...",
"update_available": "有新版本可用",
"download_update": "下载更新",
"up_to_date": "您使用的已是最新版本",
"checking_server": "正在检查服务器服务...",
"mail_title": "邮件发送 (SMTP)",
"mail_hint": "配置文件和代理链接将通过此邮箱发送。密码存储在 data.json 中,因此请使用专用账户或应用专用密码。",
"mail_enabled_label": "启用邮件发送",
"mail_host_label": "SMTP 服务器",
"mail_port_label": "端口",
"mail_security_label": "加密方式",
"mail_security_none": "无",
"mail_security_starttls": "STARTTLS",
"mail_security_ssl": "SSL/TLS",
"mail_username_label": "用户名",
"mail_password_label": "密码",
"mail_password_hint": "留空以保留已保存的密码;输入一个空格可清除密码。",
"mail_from_label": "发件人地址",
"mail_from_name_label": "发件人名称",
"mail_reply_to_label": "回复地址(可选)",
"mail_timeout_label": "超时时间(秒)",
"mail_test_btn": "发送测试邮件",
"mail_test_recipient_label": "测试收件人",
"mail_test_sent": "测试邮件已发送至 {}",
"mail_send_btn": "通过邮件发送",
"mail_send_title": "通过邮件发送配置",
"mail_send_all_btn": "向所有用户发送邮件",
"mail_send_all_title": "向每位用户发送邮件",
"mail_recipient_label": "收件人",
"mail_include_configs": "附加配置文件",
"mail_include_links": "包含连接链接",
"mail_subject_label": "主题",
"mail_subject_placeholder": "留空以使用默认主题",
"mail_message_label": "正文(可选)",
"mail_message_placeholder": "在连接信息上方显示的简短说明",
"mail_only_enabled": "跳过已禁用的用户",
"mail_skip_empty": "跳过没有连接的用户",
"mail_send_action": "发送",
"mail_sent_ok": "已发送至 {}",
"mail_bulk_started": "批量发送已开始",
"mail_bulk_targets": "收件人:所有填写了邮箱地址的用户",
"mail_bulk_progress": "已处理 {} / {}",
"mail_bulk_summary": "已发送:{},已跳过:{},错误:{}",
"mail_bulk_running_note": "您可以关闭此窗口,发送将在后台继续。",
"mail_no_email_hint": "此用户未填写邮箱地址",
"mail_status_sent": "已发送",
"mail_status_skipped": "已跳过",
"mail_status_failed": "错误",
"mail_greeting": "您好,{}!",
"mail_default_subject": "您的 VPN 访问信息",
"mail_intro_connections": "以下是您的连接信息。请将附件导入 VPN 客户端,或在设备上打开链接。",
"mail_link_label": "链接",
"mail_file_label": "附件",
"mail_no_connections_note": "您的账户尚无连接,请联系管理员。",
"mail_footer": "此邮件由 VPN 面板自动发送,请勿回复。",
"mail_test_subject": "来自 VPN 面板的测试邮件",
"mail_test_body": "SMTP 设置正常:此测试邮件由 VPN 面板发送。",
"mail_error_disabled": "设置中已禁用邮件发送",
"mail_error_no_host": "未设置 SMTP 服务器",
"mail_error_no_sender": "发件人地址缺失或无效",
"mail_error_bad_security": "未知的加密模式",
"mail_error_no_recipient": "没有有效的收件人地址",
"mail_error_no_recipients": "没有填写邮箱地址的用户",
"mail_error_no_connections": "此用户没有可发送的连接",
"mail_error_user_not_found": "未找到用户",
"mail_error_nothing_to_send": "请选择文件、链接或两者",
"mail_error_bulk_running": "已有批量发送任务正在运行",
"mail_skip_no_email": "未填写邮箱地址",
"mail_skip_bad_email": "邮箱地址无效",
"mail_skip_disabled": "用户已禁用",
"mail_test_hint": "测试使用已保存的设置,请先点击“保存”。"
}